| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | |
| | | 6 | | namespace Services.ApiServices.ArmtekEtp; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// HTTP-клиент личного кабинета Armtek (etp.armtek.ru) без браузера: вход формой, затем те же AJAX-запросы, |
| | | 10 | | /// что делает сайт. Сессия (cookie) живёт в экземпляре, поэтому регистрируется как transient — один на запуск синка. |
| | | 11 | | /// Cookie ведутся вручную: у именованного клиента UseCookies=false, обработчик фабрики общий для всех. |
| | | 12 | | /// </summary> |
| | | 13 | | public class ArmtekEtpClient |
| | | 14 | | { |
| | | 15 | | private const int MaxAttempts = 3; |
| | | 16 | | private const string BrowserUserAgent = |
| | | 17 | | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 |
| | | 18 | | |
| | | 19 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 20 | | private readonly ArmtekEtpConfig _config; |
| | | 21 | | private readonly ILogger<ArmtekEtpClient> _logger; |
| | | 22 | | private readonly CookieContainer _cookies = new(); |
| | | 23 | | private readonly Uri _baseUri; |
| | | 24 | | private bool _loggedIn; |
| | | 25 | | |
| | | 26 | | public ArmtekEtpClient(IHttpClientFactory httpClientFactory, ArmtekEtpConfig config, ILogger<ArmtekEtpClient> logger |
| | | 27 | | { |
| | | 28 | | _httpClientFactory = httpClientFactory; |
| | | 29 | | _config = config; |
| | | 30 | | _logger = logger; |
| | | 31 | | _baseUri = new Uri(config.BaseUrl.TrimEnd('/') + "/"); |
| | | 32 | | } |
| | | 33 | | |
| | | 34 | | public async Task LoginAsync(CancellationToken ct = default) |
| | | 35 | | { |
| | | 36 | | // Как в браузере: сбытовая организация задаётся cookie VKORG ещё до входа (иначе сайт берёт её по домену). |
| | | 37 | | if (!string.IsNullOrWhiteSpace(_config.Vkorg)) |
| | | 38 | | _cookies.Add(new Cookie("VKORG", _config.Vkorg.Trim(), "/", _baseUri.Host)); |
| | | 39 | | |
| | | 40 | | // Первый GET выдаёт сессионную cookie, без неё вход не принимается. |
| | | 41 | | using (await SendAsync(HttpMethod.Get, "authorization", null, ct)) { } |
| | | 42 | | |
| | | 43 | | // Как браузер: KUNNR при входе пустой, клиент передаётся только при выборе из нескольких (clientChoice). |
| | | 44 | | var resp = await PostAuthAsync(null, ct); |
| | | 45 | | if (!resp.Status && resp.ClientChoice.Count > 0) |
| | | 46 | | { |
| | | 47 | | var kunnr = _config.Kunnr; |
| | | 48 | | if (string.IsNullOrWhiteSpace(kunnr) || !resp.ClientChoice.Contains(kunnr)) |
| | | 49 | | kunnr = resp.ClientChoice[0]; |
| | | 50 | | _logger.LogInformation("Armtek ETP: на логин заведено несколько клиентов ({Clients}), вход под {Kunnr}", |
| | | 51 | | string.Join(", ", resp.ClientChoice), kunnr); |
| | | 52 | | resp = await PostAuthAsync(kunnr, ct); |
| | | 53 | | } |
| | | 54 | | |
| | | 55 | | if (resp.NeedCaptcha) |
| | | 56 | | throw new ArmtekEtpException( |
| | | 57 | | "Armtek ETP: для входа с адреса сервера сайт требует капчу (обычно после частых входов подряд). " + |
| | | 58 | | "Автоматически её не пройти — подождите несколько часов до следующей загрузки или войдите в кабинет вруч |
| | | 59 | | if (!resp.Status) |
| | | 60 | | throw new ArmtekEtpException("Armtek ETP: вход не выполнен. " + resp.Message); |
| | | 61 | | // Сайт считает вход завершённым, только когда в ответе есть redirectUrl; иначе страница перезагружается |
| | | 62 | | // без входа. Без этой проверки сессия оставалась анонимной, и API отвечал «Нет доступа к функционалу». |
| | | 63 | | if (string.IsNullOrWhiteSpace(resp.RedirectUrl)) |
| | | 64 | | throw new ArmtekEtpException("Armtek ETP: сайт не завершил вход (нет перехода после входа). " + |
| | | 65 | | (string.IsNullOrWhiteSpace(resp.Message) ? "Попробуйте позже или войдите в кабинет вручную." : resp.Mess |
| | | 66 | | |
| | | 67 | | // Браузер после входа переходит по redirectUrl: там сайт достраивает сессию (выбор клиента, cookie). |
| | | 68 | | // Без этого шага API отвечает 302 на страницу входа. |
| | | 69 | | var landing = await FollowAsync(resp.RedirectUrl!, ct); |
| | | 70 | | _logger.LogInformation("Armtek ETP: вход выполнен, переход {Redirect} → {Landing}, cookie: {Cookies}", |
| | | 71 | | resp.RedirectUrl ?? "/", landing.AbsolutePath, CookieNames()); |
| | | 72 | | |
| | | 73 | | await SelectClientAsync(ct); |
| | | 74 | | _loggedIn = true; |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | /// <summary> |
| | | 78 | | /// Выбор клиента (KUNNR_AG, только если на логине их несколько — /authorization/setag) и плательщика |
| | | 79 | | /// (KUNNR_RG — /authorization/setrg, как клик в списке плательщиков в шапке кабинета). После входа сайт берёт |
| | | 80 | | /// плательщика по умолчанию; если это, например, «Розница», API взаиморасчётов отвечает «Нет доступа к функционалу» |
| | | 81 | | /// </summary> |
| | | 82 | | private async Task SelectClientAsync(CancellationToken ct) |
| | | 83 | | { |
| | | 84 | | if (!string.IsNullOrWhiteSpace(_config.Kunnr)) |
| | | 85 | | await SetAsync("authorization/setag", _config.Kunnr.Trim(), ct); |
| | | 86 | | if (!string.IsNullOrWhiteSpace(_config.KunnrRg)) |
| | | 87 | | await SetAsync("authorization/setrg", _config.KunnrRg.Trim(), ct); |
| | | 88 | | if (!string.IsNullOrWhiteSpace(_config.Kunnr) || !string.IsNullOrWhiteSpace(_config.KunnrRg)) |
| | | 89 | | _logger.LogInformation("Armtek ETP: выбран клиент {Kunnr}, плательщик {KunnrRg}, cookie: {Cookies}", |
| | | 90 | | string.IsNullOrWhiteSpace(_config.Kunnr) ? "—" : _config.Kunnr, |
| | | 91 | | string.IsNullOrWhiteSpace(_config.KunnrRg) ? "—" : _config.KunnrRg, CookieNames()); |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | /// <summary> |
| | | 95 | | /// setag/setrg так, как их шлёт сайт: AJAX со страницы кабинета (Referer — взаиморасчёты) и со случайным |
| | | 96 | | /// параметром в адресе (getApiAjax добавляет «?Math.random()»). Редирект — не ошибка: сайт после выбора |
| | | 97 | | /// уводит на главную; его проходим, а принятие выбора проверит следующий запрос API. |
| | | 98 | | /// </summary> |
| | | 99 | | private async Task SetAsync(string path, string kunnr, CancellationToken ct) |
| | | 100 | | { |
| | | 101 | | var form = new Dictionary<string, string> { ["KUNNR"] = kunnr, ["FORMAT"] = "json", ["LANG"] = "ru" }; |
| | | 102 | | var url = path + "?" + Random.Shared.NextDouble().ToString(CultureInfo.InvariantCulture); |
| | | 103 | | var (json, redirect) = await PostFormAsync(url, form, ct, referer: "settlements"); |
| | | 104 | | if (json == null) |
| | | 105 | | { |
| | | 106 | | _logger.LogWarning("Armtek ETP: {Path} ({Kunnr}) ответил переходом на {Redirect}, прохожу его", path, kunnr, |
| | | 107 | | if (redirect != null) |
| | | 108 | | await FollowAsync(redirect.ToString(), ct); |
| | | 109 | | return; |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | var root = ParseRoot(json); |
| | | 113 | | if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("status", out var status) && status.ValueKind |
| | | 114 | | throw new ArmtekEtpException($"Armtek ETP: не удалось выбрать клиента {kunnr} ({path}). " + ArmtekEtpParser. |
| | | 115 | | } |
| | | 116 | | |
| | | 117 | | /// <summary>GET с ручным проходом редиректов (до 5): cookie каждого шага сохраняются. Возвращает конечный адрес.</s |
| | | 118 | | private async Task<Uri> FollowAsync(string path, CancellationToken ct) |
| | | 119 | | { |
| | | 120 | | var uri = new Uri(_baseUri, path); |
| | | 121 | | for (var hop = 0; hop < 5; hop++) |
| | | 122 | | { |
| | | 123 | | using var response = await SendAsync(HttpMethod.Get, uri.ToString(), null, ct); |
| | | 124 | | if ((int)response.StatusCode is < 300 or >= 400 || response.Headers.Location == null) |
| | | 125 | | return uri; |
| | | 126 | | uri = new Uri(uri, response.Headers.Location); |
| | | 127 | | } |
| | | 128 | | return uri; |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | private string CookieNames() => |
| | | 132 | | string.Join(", ", _cookies.GetAllCookies().Select(c => c.Name + "@" + c.Domain).Distinct()); |
| | | 133 | | |
| | | 134 | | /// <summary>Строки «Взаиморасчётов» за период как есть (элементы массива data).</summary> |
| | | 135 | | public async Task<IReadOnlyList<JsonElement>> GetSettlementsAsync(DateOnly from, DateOnly to, CancellationToken ct = |
| | | 136 | | { |
| | | 137 | | var form = new Dictionary<string, string> |
| | | 138 | | { |
| | | 139 | | ["SDATE"] = from.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture), |
| | | 140 | | ["EDATE"] = to.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture), |
| | | 141 | | ["FORMAT"] = "json", |
| | | 142 | | ["LANG"] = "ru" |
| | | 143 | | }; |
| | | 144 | | if (!string.IsNullOrWhiteSpace(_config.KunnrRg)) |
| | | 145 | | form["KUNNR_RG"] = _config.KunnrRg; |
| | | 146 | | |
| | | 147 | | var json = await PostApiWithReloginAsync("settlements/settlements_search", form, ct); |
| | | 148 | | return ArmtekEtpParser.ParseSettlements(json); |
| | | 149 | | } |
| | | 150 | | |
| | | 151 | | /// <summary>HTML страницы фактуры /invoice/index/{kunnr}/{vbeln}.</summary> |
| | | 152 | | public async Task<string> GetInvoicePageAsync(string kunnr, string vbeln, CancellationToken ct = default) |
| | | 153 | | { |
| | | 154 | | var path = $"invoice/index/{Uri.EscapeDataString(kunnr)}/{Uri.EscapeDataString(vbeln)}"; |
| | | 155 | | var html = await GetStringAsync(path, ct); |
| | | 156 | | if (!ArmtekEtpParser.LooksLikeLoginPage(html)) |
| | | 157 | | return html; |
| | | 158 | | |
| | | 159 | | await LoginAsync(ct); |
| | | 160 | | html = await GetStringAsync(path, ct); |
| | | 161 | | if (ArmtekEtpParser.LooksLikeLoginPage(html)) |
| | | 162 | | throw new ArmtekEtpException($"Armtek ETP: фактура {vbeln} недоступна — сессия не принимается"); |
| | | 163 | | return html; |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | private async Task<AuthResult> PostAuthAsync(string? kunnr, CancellationToken ct) |
| | | 167 | | { |
| | | 168 | | var form = new Dictionary<string, string> |
| | | 169 | | { |
| | | 170 | | ["LOGIN"] = _config.Login.TrimStart('+'), |
| | | 171 | | ["PASSWORD"] = _config.Password, |
| | | 172 | | ["CAPTCHA"] = string.Empty, |
| | | 173 | | ["KUNNR"] = kunnr ?? string.Empty, |
| | | 174 | | ["CAPTCHA_HASH"] = string.Empty |
| | | 175 | | }; |
| | | 176 | | var (json, redirect) = await PostFormAsync("authorization/auth/", form, ct); |
| | | 177 | | if (json == null) |
| | | 178 | | throw new ArmtekEtpException($"Armtek ETP: вход перенаправлен на {redirect} — форма входа изменилась"); |
| | | 179 | | return ParseAuth(json); |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | private async Task<string> PostApiWithReloginAsync(string path, Dictionary<string, string> form, CancellationToken c |
| | | 183 | | { |
| | | 184 | | if (!_loggedIn) |
| | | 185 | | await LoginAsync(ct); |
| | | 186 | | |
| | | 187 | | var (json, _) = await PostFormAsync(path, form, ct); |
| | | 188 | | if (json != null && !ArmtekEtpParser.IsSessionLost(json)) |
| | | 189 | | return json; |
| | | 190 | | if (json != null) |
| | | 191 | | _logger.LogWarning("Armtek ETP: {Path} — сайт завершил сессию: {Messages}", path, ArmtekEtpParser.ReadMessag |
| | | 192 | | |
| | | 193 | | _logger.LogWarning("Armtek ETP: {Path} — сессия не принята, повторный вход (cookie: {Cookies})", path, CookieNam |
| | | 194 | | await LoginAsync(ct); |
| | | 195 | | var (retry, redirect) = await PostFormAsync(path, form, ct); |
| | | 196 | | if (retry == null) |
| | | 197 | | throw new ArmtekEtpException($"Armtek ETP: после входа {path} перенаправляет на {redirect} — сайт не принима |
| | | 198 | | return retry; |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | /// <summary>POST формы. Редирект (сессия не принята — сайт отправляет на вход) возвращается как (null, адрес).</sum |
| | | 202 | | private async Task<(string? Body, Uri? Redirect)> PostFormAsync(string path, Dictionary<string, string> form, Cancel |
| | | 203 | | string? referer = null) |
| | | 204 | | { |
| | | 205 | | using var response = await SendAsync(HttpMethod.Post, path, form, ct, referer); |
| | | 206 | | if ((int)response.StatusCode is >= 300 and < 400) |
| | | 207 | | return (null, response.Headers.Location == null ? null : new Uri(new Uri(_baseUri, path), response.Headers.L |
| | | 208 | | EnsureSuccess(response); |
| | | 209 | | return (await response.Content.ReadAsStringAsync(ct), null); |
| | | 210 | | } |
| | | 211 | | |
| | | 212 | | private async Task<string> GetStringAsync(string path, CancellationToken ct) |
| | | 213 | | { |
| | | 214 | | if (!_loggedIn) |
| | | 215 | | await LoginAsync(ct); |
| | | 216 | | |
| | | 217 | | using var response = await SendAsync(HttpMethod.Get, path, null, ct); |
| | | 218 | | // Неавторизованный запрос редиректит на вход — отдаём тело как есть, вызывающий распознает страницу входа. |
| | | 219 | | if ((int)response.StatusCode is >= 300 and < 400) |
| | | 220 | | return string.Empty; |
| | | 221 | | EnsureSuccess(response); |
| | | 222 | | return await response.Content.ReadAsStringAsync(ct); |
| | | 223 | | } |
| | | 224 | | |
| | | 225 | | /// <summary> |
| | | 226 | | /// Запрос «как браузер»: без User-Agent браузера защита сайта отвечает 403. Обрыв соединения или TLS |
| | | 227 | | /// (защита периодически сбрасывает рукопожатие) повторяется до <see cref="MaxAttempts"/> раз с паузой. |
| | | 228 | | /// </summary> |
| | | 229 | | private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, Dictionary<string, string>? form, |
| | | 230 | | string? referer = null) |
| | | 231 | | { |
| | | 232 | | var uri = new Uri(_baseUri, path); |
| | | 233 | | for (var attempt = 1; ; attempt++) |
| | | 234 | | { |
| | | 235 | | using var request = new HttpRequestMessage(method, uri) { Content = form == null ? null : new FormUrlEncoded |
| | | 236 | | request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); |
| | | 237 | | request.Headers.TryAddWithoutValidation("Accept", form == null |
| | | 238 | | ? "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" |
| | | 239 | | : "application/json, text/javascript, */*; q=0.01"); |
| | | 240 | | request.Headers.TryAddWithoutValidation("Accept-Language", "ru-RU,ru;q=0.9"); |
| | | 241 | | if (form != null) |
| | | 242 | | { |
| | | 243 | | request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); |
| | | 244 | | request.Headers.TryAddWithoutValidation("Origin", _baseUri.GetLeftPart(UriPartial.Authority)); |
| | | 245 | | // Referer — страница, с которой сайт сам шлёт этот AJAX: authorization/auth → /authorization, settlemen |
| | | 246 | | request.Headers.TryAddWithoutValidation("Referer", new Uri(_baseUri, referer ?? path.Split('/')[0]).ToSt |
| | | 247 | | } |
| | | 248 | | var cookieHeader = _cookies.GetCookieHeader(uri); |
| | | 249 | | if (!string.IsNullOrEmpty(cookieHeader)) |
| | | 250 | | request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); |
| | | 251 | | |
| | | 252 | | HttpResponseMessage response; |
| | | 253 | | try |
| | | 254 | | { |
| | | 255 | | var client = _httpClientFactory.CreateClient(ArmtekEtpConfig.HttpClientName); |
| | | 256 | | response = await client.SendAsync(request, ct); |
| | | 257 | | } |
| | | 258 | | catch (HttpRequestException ex) when (attempt < MaxAttempts && !ct.IsCancellationRequested) |
| | | 259 | | { |
| | | 260 | | _logger.LogWarning("Armtek ETP: {Method} {Path} — сбой соединения ({Error}), попытка {Attempt}/{Max}", |
| | | 261 | | method, path, ex.GetBaseException().Message, attempt, MaxAttempts); |
| | | 262 | | await Task.Delay(TimeSpan.FromSeconds(3 * attempt), ct); |
| | | 263 | | continue; |
| | | 264 | | } |
| | | 265 | | |
| | | 266 | | if (response.Headers.TryGetValues("Set-Cookie", out var setCookies)) |
| | | 267 | | { |
| | | 268 | | foreach (var setCookie in setCookies) |
| | | 269 | | { |
| | | 270 | | try { _cookies.SetCookies(uri, setCookie); } |
| | | 271 | | catch (CookieException ex) { _logger.LogDebug(ex, "Armtek ETP: cookie не разобрана"); } |
| | | 272 | | } |
| | | 273 | | } |
| | | 274 | | return response; |
| | | 275 | | } |
| | | 276 | | } |
| | | 277 | | |
| | | 278 | | private static JsonElement ParseRoot(string json) |
| | | 279 | | { |
| | | 280 | | try { using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); } |
| | | 281 | | catch (JsonException) { return default; } |
| | | 282 | | } |
| | | 283 | | |
| | | 284 | | private static void EnsureSuccess(HttpResponseMessage response) |
| | | 285 | | { |
| | | 286 | | if (response.StatusCode == HttpStatusCode.Forbidden) |
| | | 287 | | throw new ArmtekEtpException("Armtek ETP: доступ запрещён (403) — защита сайта не пропустила запрос сервера" |
| | | 288 | | response.EnsureSuccessStatusCode(); |
| | | 289 | | } |
| | | 290 | | |
| | | 291 | | internal static AuthResult ParseAuth(string json) |
| | | 292 | | { |
| | | 293 | | using var doc = JsonDocument.Parse(json); |
| | | 294 | | var root = doc.RootElement; |
| | | 295 | | var status = root.TryGetProperty("status", out var s) && s.ValueKind == JsonValueKind.True; |
| | | 296 | | var clients = new List<string>(); |
| | | 297 | | var needCaptcha = false; |
| | | 298 | | if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object) |
| | | 299 | | { |
| | | 300 | | if (data.TryGetProperty("clientChoice", out var choice) && choice.ValueKind == JsonValueKind.Array) |
| | | 301 | | { |
| | | 302 | | foreach (var c in choice.EnumerateArray()) |
| | | 303 | | { |
| | | 304 | | if (c.TryGetProperty("KUNNR", out var k) && ArmtekEtpParser.AsString(k) is { Length: > 0 } kunnr) |
| | | 305 | | clients.Add(kunnr); |
| | | 306 | | } |
| | | 307 | | } |
| | | 308 | | // Капча: явный not_valid_captcha или status=true с captchaHash (сайт показывает картинку вместо входа). |
| | | 309 | | needCaptcha = (data.TryGetProperty("status", out var ds) && ds.ValueKind == JsonValueKind.String |
| | | 310 | | && ds.GetString() == "not_valid_captcha") |
| | | 311 | | || (data.TryGetProperty("captchaHash", out var ch) && ch.ValueKind == JsonValueKind.String && ch.GetStri |
| | | 312 | | } |
| | | 313 | | string? redirectUrl = null; |
| | | 314 | | if (root.TryGetProperty("data", out var d2) && d2.ValueKind == JsonValueKind.Object |
| | | 315 | | && d2.TryGetProperty("redirectUrl", out var r) && r.ValueKind == JsonValueKind.String) |
| | | 316 | | redirectUrl = r.GetString(); |
| | | 317 | | return new AuthResult(status, clients, needCaptcha, ArmtekEtpParser.ReadMessages(root), redirectUrl); |
| | | 318 | | } |
| | | 319 | | |
| | | 320 | | internal sealed record AuthResult(bool Status, List<string> ClientChoice, bool NeedCaptcha, string Message, string? |
| | | 321 | | } |
| | | 322 | | |
| | | 323 | | public sealed class ArmtekEtpException : Exception |
| | | 324 | | { |
| | 16 | 325 | | public ArmtekEtpException(string message) : base(message) { } |
| | | 326 | | } |