< Summary

Information
Class: Services.ApiServices.MoskvorechieApi.MoskvorechiePortalPriceClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/MoskvorechieApi/MoskvorechiePortalPriceClient.cs
Line coverage
65%
Covered lines: 75
Uncovered lines: 39
Coverable lines: 114
Total lines: 201
Line coverage: 65.7%
Branch coverage
40%
Covered branches: 35
Total branches: 86
Branch coverage: 40.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
.ctor(...)100%210%
get_IsConfigured()50%22100%
SearchAsync()81.25%161690.9%
AddToBasketAsync()0%210140%
AuthenticateAsync()50%44100%
SendAsync()100%2275%
FindGid(...)80%2020100%
ExtractBody(...)25%4480%
ParseOffers(...)0%600240%
Dispose()100%11100%
Normalize(...)100%11100%
CreatePortalEncoding()100%11100%
ReadPortalStringAsync()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/MoskvorechieApi/MoskvorechiePortalPriceClient.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text;
 4using HtmlAgilityPack;
 5using Microsoft.Extensions.Options;
 6using Newtonsoft.Json.Linq;
 7using Services.ApiServices.ApiSupplierRequestLog;
 8using Services.ProducerAliases;
 9
 10namespace Services.ApiServices.MoskvorechieApi;
 11
 12/// <summary>Read-only клиент внутренней страницы проценки портала: склад, остаток и id строки корзины.</summary>
 13public sealed class MoskvorechiePortalPriceClient : IDisposable
 14{
 15    private readonly IOptionsMonitor<MoskvorechieIstraConfig> _config;
 1116    private readonly SemaphoreSlim _gate = new(1, 1);
 17    private readonly HttpClient _client;
 18    private readonly ApiSupplierRequestLogStore _logs;
 119    private static readonly Encoding PortalEncoding = CreatePortalEncoding();
 20    private bool _authenticated;
 21
 22    public MoskvorechiePortalPriceClient(IOptionsMonitor<MoskvorechieIstraConfig> config, ApiSupplierRequestLogStore log
 023        : this(config, logs, new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AutomaticDe
 024        { BaseAddress = new Uri("https://portal.moskvorechie.ru/"), Timeout = TimeSpan.FromSeconds(30) })
 25    {
 026    }
 27
 1128    internal MoskvorechiePortalPriceClient(IOptionsMonitor<MoskvorechieIstraConfig> config, ApiSupplierRequestLogStore l
 29    {
 1130        _config = config;
 1131        _logs = logs;
 1132        _client = client;
 1133    }
 34
 935    public bool IsConfigured => !string.IsNullOrWhiteSpace(_config.CurrentValue.Login) && !string.IsNullOrWhiteSpace(_co
 36
 37    public async Task<IReadOnlyList<MoskvorechiePortalPriceOffer>> SearchAsync(string article, string? brand,
 38        ProducerBrandAliasMap? brandAliases = null, CancellationToken ct = default)
 39    {
 740        if (!IsConfigured) return [];
 741        await _gate.WaitAsync(ct);
 42        try
 43        {
 644            await AuthenticateAsync(ct);
 245            using var searchContent = new FormUrlEncodedContent(new Dictionary<string, string>
 246            {
 247                ["sv"] = article,
 248                ["dv"] = "1"
 249            });
 250            using var searchResponse = await SendAsync(HttpMethod.Post, "search.lmz", searchContent, "sv=" + article + "
 251            if (!searchResponse.IsSuccessStatusCode)
 152                throw new MoskvorechieApiException($"Портал Москворечье вернул ошибку при поиске товара (HTTP {(int)sear
 153            var search = await ReadPortalStringAsync(searchResponse.Content, ct);
 154            var gid = FindGid(search, article, brand, brandAliases ?? ProducerBrandAliasMap.Empty);
 155            if (gid is null) return [];
 156            var priceUrl = $"show_price.aj?i4&cat_id=2&gid={gid.Value}&hash={Random.Shared.NextDouble().ToString(Culture
 157            using var priceResponse = await SendAsync(HttpMethod.Get, priceUrl, null, null, ct);
 158            if (!priceResponse.IsSuccessStatusCode)
 059                throw new MoskvorechieApiException($"Портал Москворечье вернул ошибку при получении цен (HTTP {(int)pric
 160            var raw = await ReadPortalStringAsync(priceResponse.Content, ct);
 161            return ParseOffers(ExtractBody(raw), article, brand);
 62        }
 663        finally { _gate.Release(); }
 064    }
 65
 66    public async Task AddToBasketAsync(string offerId, decimal quantity, string comment, CancellationToken ct = default)
 67    {
 068        if (!IsConfigured) throw new MoskvorechieApiException("Не задан пароль портала Москворечье.");
 069        await _gate.WaitAsync(ct);
 70        try
 71        {
 72            // Портальный JS to_basket() вызывает именно getMain.lmz?i20; отдельный to_basket.lmz
 73            // у портала не является контрактным endpoint'ом.
 074            var url = $"getMain.lmz?i20&to_basket_gid={Uri.EscapeDataString(offerId)}&to_basket_quantity={quantity.ToStr
 075            for (var attempt = 0; attempt < 2; attempt++)
 76            {
 077                await AuthenticateAsync(ct);
 078                using var response = await SendAsync(HttpMethod.Get, url, null, null, ct);
 079                var body = await ReadPortalStringAsync(response.Content, ct);
 080                var sessionExpired = body.Contains("Вход на портал", StringComparison.OrdinalIgnoreCase);
 081                if (response.IsSuccessStatusCode && !sessionExpired) return;
 082                _authenticated = false;
 083                if (!sessionExpired || attempt == 1)
 084                    throw new MoskvorechieApiException("Портал Москворечье не подтвердил добавление в корзину.", (int)re
 085            }
 086        }
 087        finally { _gate.Release(); }
 088    }
 89
 90    private async Task AuthenticateAsync(CancellationToken ct)
 91    {
 692        if (_authenticated) return;
 693        var config = _config.CurrentValue;
 694        using var content = new FormUrlEncodedContent(new Dictionary<string, string> { ["username"] = config.Login, ["pa
 695        using var response = await SendAsync(HttpMethod.Post, "login.lmz", content,
 696            $"username={config.Login}&password={config.PortalPassword}&come_from=/index.lmz&mp=1", ct);
 297        if (!response.IsSuccessStatusCode) throw new MoskvorechieApiException("Не удалось авторизоваться на портале Моск
 298        _authenticated = true;
 299    }
 100
 101    private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string endpoint, HttpContent? content, string? 
 102    {
 9103        var timer = System.Diagnostics.Stopwatch.StartNew();
 104        try
 105        {
 9106            using var message = new HttpRequestMessage(method, endpoint) { Content = content };
 9107            var response = await _client.SendAsync(message, ct);
 5108            var bytes = await response.Content.ReadAsByteArrayAsync(ct);
 5109            _logs.Add(MoskvorechieIstraHttpClient.ProviderKey, method.Method, endpoint, (int)response.StatusCode, timer,
 5110            response.Content = new ByteArrayContent(bytes);
 5111            return response;
 112        }
 0113        catch (MoskvorechieApiException) { throw; }
 2114        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 115        {
 0116            throw;
 117        }
 4118        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
 119        {
 4120            _logs.Add(MoskvorechieIstraHttpClient.ProviderKey, method.Method, endpoint, null, timer, request, ex.Message
 4121            var message = ex is TaskCanceledException
 4122                ? "Превышено время ожидания ответа от портала Москворечье."
 4123                : "Не удалось выполнить запрос к порталу Москворечье.";
 4124            throw new MoskvorechieApiException(message, innerException: ex);
 125        }
 0126        catch (Exception ex)
 127        {
 0128            _logs.Add(MoskvorechieIstraHttpClient.ProviderKey, method.Method, endpoint, null, timer, request, ex.Message
 0129            throw;
 130        }
 5131    }
 132
 133    /// <summary>
 134    /// Бренд в заголовке товара портала пишется как есть, поэтому проверяется вхождением, а не
 135    /// равенством. Ищем любое известное написание бренда: портал может назвать «KYB» то, что в
 136    /// заказе записано как «Kayaba» (словарь OneCProducerAliases).
 137    /// </summary>
 138    internal static long? FindGid(string html, string article, string? brand, ProducerBrandAliasMap aliases)
 139    {
 8140        var document = new HtmlDocument(); document.LoadHtml(html);
 4141        var matches = document.DocumentNode.SelectNodes("//a[contains(@href, 'good_info.lmc')]")?.AsEnumerable() ?? Enum
 4142        var requestedArticle = Normalize(article);
 4143        var requestedBrands = aliases.GetVariants(brand);
 13144        foreach (var link in matches)
 145        {
 4146            var href = WebUtility.HtmlDecode(link.GetAttributeValue("href", ""));
 4147            if (!string.Equals(Normalize(link.InnerText), requestedArticle, StringComparison.Ordinal)) continue;
 4148            var productHeader = Normalize(link.ParentNode?.InnerText ?? string.Empty);
 4149            var articleIndex = productHeader.IndexOf(requestedArticle, StringComparison.Ordinal);
 4150            var candidateBrand = articleIndex > 0 ? productHeader[..articleIndex] : string.Empty;
 8151            if (requestedBrands.Count > 0 && !requestedBrands.Any(value => candidateBrand.Contains(value, StringComparis
 3152            var query = System.Web.HttpUtility.ParseQueryString(new Uri("https://portal.moskvorechie.ru" + href).Query);
 6153            if (long.TryParse(query["gid"], out var gid)) return gid;
 154        }
 1155        return null;
 3156    }
 157
 158    private static string ExtractBody(string raw)
 159    {
 160        try
 161        {
 2162            var json = raw.TrimStart(); if (json.StartsWith('1')) json = json[1..];
 1163            return JObject.Parse(json).Value<string>("body") ?? string.Empty;
 164        }
 1165        catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or FormatException)
 166        {
 1167            throw new MoskvorechieApiException("Не удалось разобрать ответ портала Москворечье.", innerException: ex);
 168        }
 0169    }
 170
 171    private static IReadOnlyList<MoskvorechiePortalPriceOffer> ParseOffers(string html, string article, string? requeste
 172    {
 0173        var document = new HtmlDocument(); document.LoadHtml(html);
 0174        var result = new List<MoskvorechiePortalPriceOffer>();
 0175        foreach (var row in document.DocumentNode.SelectNodes("//tr[.//a[contains(@href, 'to_basket_gid=')]]")?.AsEnumer
 176        {
 0177            List<string> cells = row.SelectNodes("./td")?.Select(x => WebUtility.HtmlDecode(x.InnerText).Trim()).ToList(
 0178            var href = row.SelectSingleNode(".//a[contains(@href, 'to_basket_gid=')]")?.GetAttributeValue("href", "");
 0179            var query = System.Web.HttpUtility.ParseQueryString(new Uri("https://portal.moskvorechie.ru" + href).Query);
 0180            var offerId = query["to_basket_gid"];
 0181            if (string.IsNullOrWhiteSpace(offerId) || cells.Count < 10 || !Normalize(cells[2]).Contains(Normalize(articl
 0182            if (!int.TryParse(cells[8], out var min) || !decimal.TryParse(cells[9].Replace("руб.", "").Replace(" ", ""),
 0183            var brand = string.IsNullOrWhiteSpace(requestedBrand) ? cells[1] : requestedBrand.Trim();
 0184            result.Add(new MoskvorechiePortalPriceOffer(offerId, brand, cells[2], cells[3], cells[4], cells[6], cells[7]
 185        }
 0186        return result;
 187    }
 188
 15189    public void Dispose() { _client.Dispose(); _gate.Dispose(); }
 190
 12191    private static string Normalize(string value) => new string(value.Where(char.IsLetterOrDigit).ToArray()).ToUpperInva
 192
 193    private static Encoding CreatePortalEncoding()
 194    {
 1195        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
 1196        return Encoding.GetEncoding(1251);
 197    }
 198
 199    private static async Task<string> ReadPortalStringAsync(HttpContent content, CancellationToken ct) =>
 2200        PortalEncoding.GetString(await content.ReadAsByteArrayAsync(ct));
 201}