| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text; |
| | | 4 | | using HtmlAgilityPack; |
| | | 5 | | using Microsoft.Extensions.Options; |
| | | 6 | | using Newtonsoft.Json.Linq; |
| | | 7 | | using Services.ApiServices.ApiSupplierRequestLog; |
| | | 8 | | using Services.ProducerAliases; |
| | | 9 | | |
| | | 10 | | namespace Services.ApiServices.MoskvorechieApi; |
| | | 11 | | |
| | | 12 | | /// <summary>Read-only клиент внутренней страницы проценки портала: склад, остаток и id строки корзины.</summary> |
| | | 13 | | public sealed class MoskvorechiePortalPriceClient : IDisposable |
| | | 14 | | { |
| | | 15 | | private readonly IOptionsMonitor<MoskvorechieIstraConfig> _config; |
| | 11 | 16 | | private readonly SemaphoreSlim _gate = new(1, 1); |
| | | 17 | | private readonly HttpClient _client; |
| | | 18 | | private readonly ApiSupplierRequestLogStore _logs; |
| | 1 | 19 | | private static readonly Encoding PortalEncoding = CreatePortalEncoding(); |
| | | 20 | | private bool _authenticated; |
| | | 21 | | |
| | | 22 | | public MoskvorechiePortalPriceClient(IOptionsMonitor<MoskvorechieIstraConfig> config, ApiSupplierRequestLogStore log |
| | 0 | 23 | | : this(config, logs, new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AutomaticDe |
| | 0 | 24 | | { BaseAddress = new Uri("https://portal.moskvorechie.ru/"), Timeout = TimeSpan.FromSeconds(30) }) |
| | | 25 | | { |
| | 0 | 26 | | } |
| | | 27 | | |
| | 11 | 28 | | internal MoskvorechiePortalPriceClient(IOptionsMonitor<MoskvorechieIstraConfig> config, ApiSupplierRequestLogStore l |
| | | 29 | | { |
| | 11 | 30 | | _config = config; |
| | 11 | 31 | | _logs = logs; |
| | 11 | 32 | | _client = client; |
| | 11 | 33 | | } |
| | | 34 | | |
| | 9 | 35 | | 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 | | { |
| | 7 | 40 | | if (!IsConfigured) return []; |
| | 7 | 41 | | await _gate.WaitAsync(ct); |
| | | 42 | | try |
| | | 43 | | { |
| | 6 | 44 | | await AuthenticateAsync(ct); |
| | 2 | 45 | | using var searchContent = new FormUrlEncodedContent(new Dictionary<string, string> |
| | 2 | 46 | | { |
| | 2 | 47 | | ["sv"] = article, |
| | 2 | 48 | | ["dv"] = "1" |
| | 2 | 49 | | }); |
| | 2 | 50 | | using var searchResponse = await SendAsync(HttpMethod.Post, "search.lmz", searchContent, "sv=" + article + " |
| | 2 | 51 | | if (!searchResponse.IsSuccessStatusCode) |
| | 1 | 52 | | throw new MoskvorechieApiException($"Портал Москворечье вернул ошибку при поиске товара (HTTP {(int)sear |
| | 1 | 53 | | var search = await ReadPortalStringAsync(searchResponse.Content, ct); |
| | 1 | 54 | | var gid = FindGid(search, article, brand, brandAliases ?? ProducerBrandAliasMap.Empty); |
| | 1 | 55 | | if (gid is null) return []; |
| | 1 | 56 | | var priceUrl = $"show_price.aj?i4&cat_id=2&gid={gid.Value}&hash={Random.Shared.NextDouble().ToString(Culture |
| | 1 | 57 | | using var priceResponse = await SendAsync(HttpMethod.Get, priceUrl, null, null, ct); |
| | 1 | 58 | | if (!priceResponse.IsSuccessStatusCode) |
| | 0 | 59 | | throw new MoskvorechieApiException($"Портал Москворечье вернул ошибку при получении цен (HTTP {(int)pric |
| | 1 | 60 | | var raw = await ReadPortalStringAsync(priceResponse.Content, ct); |
| | 1 | 61 | | return ParseOffers(ExtractBody(raw), article, brand); |
| | | 62 | | } |
| | 6 | 63 | | finally { _gate.Release(); } |
| | 0 | 64 | | } |
| | | 65 | | |
| | | 66 | | public async Task AddToBasketAsync(string offerId, decimal quantity, string comment, CancellationToken ct = default) |
| | | 67 | | { |
| | 0 | 68 | | if (!IsConfigured) throw new MoskvorechieApiException("Не задан пароль портала Москворечье."); |
| | 0 | 69 | | await _gate.WaitAsync(ct); |
| | | 70 | | try |
| | | 71 | | { |
| | | 72 | | // Портальный JS to_basket() вызывает именно getMain.lmz?i20; отдельный to_basket.lmz |
| | | 73 | | // у портала не является контрактным endpoint'ом. |
| | 0 | 74 | | var url = $"getMain.lmz?i20&to_basket_gid={Uri.EscapeDataString(offerId)}&to_basket_quantity={quantity.ToStr |
| | 0 | 75 | | for (var attempt = 0; attempt < 2; attempt++) |
| | | 76 | | { |
| | 0 | 77 | | await AuthenticateAsync(ct); |
| | 0 | 78 | | using var response = await SendAsync(HttpMethod.Get, url, null, null, ct); |
| | 0 | 79 | | var body = await ReadPortalStringAsync(response.Content, ct); |
| | 0 | 80 | | var sessionExpired = body.Contains("Вход на портал", StringComparison.OrdinalIgnoreCase); |
| | 0 | 81 | | if (response.IsSuccessStatusCode && !sessionExpired) return; |
| | 0 | 82 | | _authenticated = false; |
| | 0 | 83 | | if (!sessionExpired || attempt == 1) |
| | 0 | 84 | | throw new MoskvorechieApiException("Портал Москворечье не подтвердил добавление в корзину.", (int)re |
| | 0 | 85 | | } |
| | 0 | 86 | | } |
| | 0 | 87 | | finally { _gate.Release(); } |
| | 0 | 88 | | } |
| | | 89 | | |
| | | 90 | | private async Task AuthenticateAsync(CancellationToken ct) |
| | | 91 | | { |
| | 6 | 92 | | if (_authenticated) return; |
| | 6 | 93 | | var config = _config.CurrentValue; |
| | 6 | 94 | | using var content = new FormUrlEncodedContent(new Dictionary<string, string> { ["username"] = config.Login, ["pa |
| | 6 | 95 | | using var response = await SendAsync(HttpMethod.Post, "login.lmz", content, |
| | 6 | 96 | | $"username={config.Login}&password={config.PortalPassword}&come_from=/index.lmz&mp=1", ct); |
| | 2 | 97 | | if (!response.IsSuccessStatusCode) throw new MoskvorechieApiException("Не удалось авторизоваться на портале Моск |
| | 2 | 98 | | _authenticated = true; |
| | 2 | 99 | | } |
| | | 100 | | |
| | | 101 | | private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string endpoint, HttpContent? content, string? |
| | | 102 | | { |
| | 9 | 103 | | var timer = System.Diagnostics.Stopwatch.StartNew(); |
| | | 104 | | try |
| | | 105 | | { |
| | 9 | 106 | | using var message = new HttpRequestMessage(method, endpoint) { Content = content }; |
| | 9 | 107 | | var response = await _client.SendAsync(message, ct); |
| | 5 | 108 | | var bytes = await response.Content.ReadAsByteArrayAsync(ct); |
| | 5 | 109 | | _logs.Add(MoskvorechieIstraHttpClient.ProviderKey, method.Method, endpoint, (int)response.StatusCode, timer, |
| | 5 | 110 | | response.Content = new ByteArrayContent(bytes); |
| | 5 | 111 | | return response; |
| | | 112 | | } |
| | 0 | 113 | | catch (MoskvorechieApiException) { throw; } |
| | 2 | 114 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 115 | | { |
| | 0 | 116 | | throw; |
| | | 117 | | } |
| | 4 | 118 | | catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) |
| | | 119 | | { |
| | 4 | 120 | | _logs.Add(MoskvorechieIstraHttpClient.ProviderKey, method.Method, endpoint, null, timer, request, ex.Message |
| | 4 | 121 | | var message = ex is TaskCanceledException |
| | 4 | 122 | | ? "Превышено время ожидания ответа от портала Москворечье." |
| | 4 | 123 | | : "Не удалось выполнить запрос к порталу Москворечье."; |
| | 4 | 124 | | throw new MoskvorechieApiException(message, innerException: ex); |
| | | 125 | | } |
| | 0 | 126 | | catch (Exception ex) |
| | | 127 | | { |
| | 0 | 128 | | _logs.Add(MoskvorechieIstraHttpClient.ProviderKey, method.Method, endpoint, null, timer, request, ex.Message |
| | 0 | 129 | | throw; |
| | | 130 | | } |
| | 5 | 131 | | } |
| | | 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 | | { |
| | 8 | 140 | | var document = new HtmlDocument(); document.LoadHtml(html); |
| | 4 | 141 | | var matches = document.DocumentNode.SelectNodes("//a[contains(@href, 'good_info.lmc')]")?.AsEnumerable() ?? Enum |
| | 4 | 142 | | var requestedArticle = Normalize(article); |
| | 4 | 143 | | var requestedBrands = aliases.GetVariants(brand); |
| | 13 | 144 | | foreach (var link in matches) |
| | | 145 | | { |
| | 4 | 146 | | var href = WebUtility.HtmlDecode(link.GetAttributeValue("href", "")); |
| | 4 | 147 | | if (!string.Equals(Normalize(link.InnerText), requestedArticle, StringComparison.Ordinal)) continue; |
| | 4 | 148 | | var productHeader = Normalize(link.ParentNode?.InnerText ?? string.Empty); |
| | 4 | 149 | | var articleIndex = productHeader.IndexOf(requestedArticle, StringComparison.Ordinal); |
| | 4 | 150 | | var candidateBrand = articleIndex > 0 ? productHeader[..articleIndex] : string.Empty; |
| | 8 | 151 | | if (requestedBrands.Count > 0 && !requestedBrands.Any(value => candidateBrand.Contains(value, StringComparis |
| | 3 | 152 | | var query = System.Web.HttpUtility.ParseQueryString(new Uri("https://portal.moskvorechie.ru" + href).Query); |
| | 6 | 153 | | if (long.TryParse(query["gid"], out var gid)) return gid; |
| | | 154 | | } |
| | 1 | 155 | | return null; |
| | 3 | 156 | | } |
| | | 157 | | |
| | | 158 | | private static string ExtractBody(string raw) |
| | | 159 | | { |
| | | 160 | | try |
| | | 161 | | { |
| | 2 | 162 | | var json = raw.TrimStart(); if (json.StartsWith('1')) json = json[1..]; |
| | 1 | 163 | | return JObject.Parse(json).Value<string>("body") ?? string.Empty; |
| | | 164 | | } |
| | 1 | 165 | | catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or FormatException) |
| | | 166 | | { |
| | 1 | 167 | | throw new MoskvorechieApiException("Не удалось разобрать ответ портала Москворечье.", innerException: ex); |
| | | 168 | | } |
| | 0 | 169 | | } |
| | | 170 | | |
| | | 171 | | private static IReadOnlyList<MoskvorechiePortalPriceOffer> ParseOffers(string html, string article, string? requeste |
| | | 172 | | { |
| | 0 | 173 | | var document = new HtmlDocument(); document.LoadHtml(html); |
| | 0 | 174 | | var result = new List<MoskvorechiePortalPriceOffer>(); |
| | 0 | 175 | | foreach (var row in document.DocumentNode.SelectNodes("//tr[.//a[contains(@href, 'to_basket_gid=')]]")?.AsEnumer |
| | | 176 | | { |
| | 0 | 177 | | List<string> cells = row.SelectNodes("./td")?.Select(x => WebUtility.HtmlDecode(x.InnerText).Trim()).ToList( |
| | 0 | 178 | | var href = row.SelectSingleNode(".//a[contains(@href, 'to_basket_gid=')]")?.GetAttributeValue("href", ""); |
| | 0 | 179 | | var query = System.Web.HttpUtility.ParseQueryString(new Uri("https://portal.moskvorechie.ru" + href).Query); |
| | 0 | 180 | | var offerId = query["to_basket_gid"]; |
| | 0 | 181 | | if (string.IsNullOrWhiteSpace(offerId) || cells.Count < 10 || !Normalize(cells[2]).Contains(Normalize(articl |
| | 0 | 182 | | if (!int.TryParse(cells[8], out var min) || !decimal.TryParse(cells[9].Replace("руб.", "").Replace(" ", ""), |
| | 0 | 183 | | var brand = string.IsNullOrWhiteSpace(requestedBrand) ? cells[1] : requestedBrand.Trim(); |
| | 0 | 184 | | result.Add(new MoskvorechiePortalPriceOffer(offerId, brand, cells[2], cells[3], cells[4], cells[6], cells[7] |
| | | 185 | | } |
| | 0 | 186 | | return result; |
| | | 187 | | } |
| | | 188 | | |
| | 15 | 189 | | public void Dispose() { _client.Dispose(); _gate.Dispose(); } |
| | | 190 | | |
| | 12 | 191 | | private static string Normalize(string value) => new string(value.Where(char.IsLetterOrDigit).ToArray()).ToUpperInva |
| | | 192 | | |
| | | 193 | | private static Encoding CreatePortalEncoding() |
| | | 194 | | { |
| | 1 | 195 | | Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); |
| | 1 | 196 | | return Encoding.GetEncoding(1251); |
| | | 197 | | } |
| | | 198 | | |
| | | 199 | | private static async Task<string> ReadPortalStringAsync(HttpContent content, CancellationToken ct) => |
| | 2 | 200 | | PortalEncoding.GetString(await content.ReadAsByteArrayAsync(ct)); |
| | | 201 | | } |