В предыдущей статье я писал обёртку на языке C# к API Saures. Это позволяет мне получить показания водосчётчиков и значит самое время создать API для приёмника этих данных. На данный момент Томский расчётный центр не предоставляет какого бы то ни было API для подобного взаимодействия, но это не беда — нужно лишь программно эмулировать взаимодействие с сайтом аналогично тому, как это происходит через браузер. Запросы от клиента к серверу для API и вебсайта идут похожим образом и могут отличаться только форматы сообщений: вместо JSON будет обычная строка. С получением данных всё немного сложнее — вместо JSON прилетит HTML, который нужно будет разобрать и вытащить оттуда нужные данные. Приступим.
Для анализа процесса взаимодействия с сайтом потребуется 2 инструмента: средства разработчика, доступные в большинстве браузеров по клавише F12, а также классический Fiddler от Telerik для анализа трафика. Начну с запуска браузера Microsoft Edge с единственной вкладкой и адресом https://tomrc.ru. После загрузки страницы запущу инструменты разработчика и перемещу их вниз для удобства. На странице справа сверху бросается в глаза кнопка авторизации, нажатие на которую приводит к появлению всплывающей формы авторизации. Проанализировав HTML формы с помощью инструментов разработчика становиться понятным, что имеем дело с элементом <form> и методом POST по адресу /?login=yes
.
<form name="system_auth_form6zOYVN" id="rrAuthForm" method="post" target="_top" action="/?login=yes" class="j-ajaxForm"> <input type="hidden" name="backurl" value="/"> <input type="hidden" name="AUTH_FORM" value="Y"> <input type="hidden" name="TYPE" value="AUTH"> <div class="login-form-field"> <label class="login-form-label" for="login">Телефон</label> <input type="text" class="control-input login-form-input j-phone-mask j-err_rrAuthForm_USER_LOGIN j-err_rrAuthForm_USER_PASSWORD" name="USER_LOGIN" id="login"> <p class="form-error j-err_rrAuthForm_NAME_TEXT"></p> </div> <div class="login-form-field"> <label class="login-form-label" for="pass">Ваш пароль</label> <input type="password" class="control-input login-form-input j-err_rrAuthForm_USER_PASSWORD j-err_rrAuthForm_USER_LOGIN" name="USER_PASSWORD" id="pass"> <p class="form-error j-err_rrAuthFormForm_NAME_TEXT"></p> </div> <div class="login-form-forget"> <a href="#" class="login-form-forget-link j-form-second-show">Я забыл пароль</a> </div> <input type="submit" name="Login" value="Войти" style="display:none;" id="frontAuthLoginSubmit"> <a class="btn btn--green j-rrAuthForm-loader" href="#" onclick="$('#frontAuthLoginSubmit').trigger('click');">Войти в личный кабинет</a> </form>
Далее нужно запустить Fiddler и обновив страницу в браузере отфильтровать через контекстное меню Fiddler’а все процессы кроме браузера с единственной страничкой. В этом случае паразитного трафика всё-равно будет достаточно много, но это гарантирует отображение всех адресов, с которыми взаимодействует страничка. С другой стороны, поскольку форма авторизации ведёт на тот же адрес, я могу в настройках Fiddler’а на вкладке Filters
поставить флаг Use Filters
и прописать адрес сайта, чтобы видеть только трафик до него — это более наглядно.
После включения фильтрации в Fiddler возвращаемся в браузер и выполняем авторизацию. Запрос от браузера будет виден в Fiddler и в текстовом виде будет выглядеть следующим образом:
POST https://tomrc.ru/?login=yes HTTP/1.1 Host: tomrc.ru Connection: keep-alive Content-Length: 131 sec-ch-ua: " Not;A Brand";v="99", "Microsoft Edge";v="91", "Chromium";v="91" Accept: application/json, text/javascript, */*; q=0.01 X-Requested-With: XMLHttpRequest sec-ch-ua-mobile: ?0 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36 Edg/91.0.864.54 Content-Type: application/x-www-form-urlencoded; charset=UTF-8 Origin: https://tomrc.ru Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: https://tomrc.ru/ Accept-Encoding: gzip, deflate, br Accept-Language: ru,en;q=0.9,en-GB;q=0.8,en-US;q=0.7 Cookie: PHPSESSID=<абра-кадабра>; _ym_uid=<какие-то цифры>; _ym_d=<ещё цифры>; _ym_isad=2; BX_USER_ID=<некоторый GUID> backurl=%2F&AUTH_FORM=Y&TYPE=AUTH&USER_LOGIN=<имя пользователя>&USER_PASSWORD=<пароль>&Login=%D0%92%D0%BE%D0%B9%D1%82%D0%B8
Переключаясь по вкладкам Fiddler’а можно получить более удобное отображение той или иной части запроса, а также понять, к какой части запроса относится та или иная информация чтобы при написании программы подставить в нужное место.
Помимо запроса Fiddler аналогичным образом позволяет анализировать ответ от сервера:
HTTP/1.1 200 OK Server: nginx Date: Fri, 25 Jun 2021 16:45:36 GMT Content-Type: application/json Content-Length: 56 Connection: keep-alive X-Powered-By: PHP/7.1.31 P3P: policyref="/bitrix/p3p.xml", CP="NON DSP COR CUR ADM DEV PSA PSD OUR UNR BUS UNI COM NAV INT DEM STA" X-Powered-CMS: Bitrix Site Manager (<опять GUID>) Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate Pragma: no-cache Set-Cookie: BITRIX_SM_LOGIN=<имя пользователя>; expires=Sat, 30-May-2026 16:45:36 GMT; Max-Age=155520000; path=/ Set-Cookie: BITRIX_SM_SOUND_LOGIN_PLAYED=Y; path=/ X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN {"submitOn":true,"redirectUrl":"\/personal\/accounts\/"}
Супер! Теперь к коду: добавлю к решению проект библиотеки TomRcApi с классом Api. Реализацию метода Login буду делать с помощью уже упомянутой в прошлой статье библиотеки Flurl:
public class Api { private readonly object _headers; private readonly string _host; private CookieJar _cookieJar; public Api(string host) { _host = host; _headers = new { Accept = "text/html, application/xhtml+xml, image/jxr, */*", Accept_Encoding = "gzip, deflate", Accept_Language = "ru,en-US;q=0.7,en;q=0.3", User_Agent = "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko" }; } public async Task LoginAsync(string login, string password) { var result = await _host .WithHeaders(_headers) .SetQueryParam("login", "yes") .WithCookies(out _cookieJar) .PostUrlEncodedAsync(new { AUTH_FORM = "Y", TYPE = "AUTH", backurl = @"/personal/accounts/", USER_LOGIN = login, USER_PASSWORD = password }); if (result.ResponseMessage.StatusCode != HttpStatusCode.OK) throw new ApplicationException($"StatusCode is {result.ResponseMessage.StatusCode}"); if (result.ResponseMessage.Content.Headers.ContentType == null) throw new ApplicationException("ContentType is null"); if (result.ResponseMessage.Content.Headers.ContentType.MediaType != "application/json") throw new ApplicationException($"ContentType is {result.ResponseMessage.Content.Headers.ContentType.MediaType}"); var loginResult = await result.GetJsonAsync<LoginResult>(); if (!loginResult.submitOn) throw new ApplicationException("submitOn is false"); } }
Заголовки (Headers), параметры запроса и данные формы были созданы на основе запроса через Fiddler. После запроса к серверу полученные от него Cookie будут сохранены в поле _cookieJar для дальнейшего использования. Класс LoginResult был создан на основе JSON’а с помощью сервиса json2csharp. Обратите внимание на обилие проверок и исключений: если что-то пойдёт не по плану — кидаю исключение.
Аналогичным образом был создан метод Logout:
public async Task LogoutAsync() { if (_cookieJar == null) throw new ApplicationException("CookieJar is null"); var result = await _host .WithHeaders(_headers) .SetQueryParam("logout", "yes") .WithCookies(_cookieJar) .GetAsync(); if (result.ResponseMessage.StatusCode != HttpStatusCode.Found) throw new ApplicationException($"StatusCode is {result.ResponseMessage.StatusCode}"); }
Теперь можно проверить работоспособность методов и для этого добавлю в решение проект XUnit TomRcApi.IntegrationTests с классом TestApi:
[Trait("Category", "IntegrationTests")] public class TestApi { private readonly Api _api; private readonly string _tomRcApiLogin; private readonly string _tomRcApiPassword; public TestApi() { _tomRcApiLogin = Environment.GetEnvironmentVariable("TomRcApiLogin", EnvironmentVariableTarget.Process); _tomRcApiPassword = Environment.GetEnvironmentVariable("TomRcApiPassword", EnvironmentVariableTarget.Process); _api = new Api("https://tomrc.ru/"); } [Fact] public async Task TestLoginLogoutAsync() { await _api.LoginAsync(_tomRcApiLogin, _tomRcApiPassword); await Task.Delay(1000); await _api.LogoutAsync(); }
Интеграционные тесты по большей части нужны мне для отладки кода, а не для автоматизации тестирования. Поэтому такая простая реализация: «дёрнуть» метод входа, подождать секунду, чтобы не смущать сервер слишком частыми запросами и выйти после этого. Имя пользователя и пароль беру из переменных окружения. Чтобы тесты не запускались вместе с модульными пометил класс атрибутом [Trait("Category", "IntegrationTests")]
Теперь, когда реализована возможность авторизоваться на сайте, можно продолжить анализ на предмет «как отправить показания счётчиков». Методом научного тыка анализа личного кабинета было установлено, что отправка данных выполняется на странице с относительным адресом /personal/accounts/
. Но перед непосредственно отправкой необходимо сначала загрузить страницу с вышеупомянутого адреса чтобы получить идентификатор аккаунта. Интересующий меня кусок HTML выглядит следующим образом:
<form class="j-ajaxForm" method="POST" action="/personal/accounts/" id="trcInvoicePaymentForm"> <input type="hidden" name="postOn" value="on" /> <input type="hidden" name="action" value="payInvoice" /> <input type="hidden" name="save_email" value="" /> <input type="hidden" name="email" value="konstantin.ogorodov@gmail.com"> <input type="hidden" name="USER_ID" value='<какая-то цифирка>' /> <input type="hidden" name="cmp" value="trc.invoices" /> <div class="j-get-lsByAccount invoice-loading-content _loading" data-account="<мой идентификатор>"></div>
Пользовательский атрибут data-account — хранит как раз то, что мне нужно. Значит я могу создать метод для получения этого HTML:
public async Task<string> GetAccountHtmlAsync() { if(_cookieJar == null) throw new ApplicationException("CookieJar is null"); var result = await _host.AppendPathSegment("personal").AppendPathSegment("accounts/") .WithHeaders(_headers) .WithCookies(_cookieJar) .GetAsync(); if (result.ResponseMessage.StatusCode != HttpStatusCode.OK) throw new ApplicationException($"StatusCode is {result.ResponseMessage.StatusCode}"); if (result.ResponseMessage.Content.Headers.ContentType == null) throw new ApplicationException("ContentType is null"); if (result.ResponseMessage.Content.Headers.ContentType.MediaType != "text/html") throw new ApplicationException($"ContentType is {result.ResponseMessage.Content.Headers.ContentType.MediaType}"); return await result.GetStringAsync(); }
И тестовый метод:
[Fact] public async Task TestGetAccountHtmlAsync() { await _api.LoginAsync(_tomRcApiLogin, _tomRcApiPassword); await Task.Delay(1000); var html = await _api.GetAccountHtmlAsync(); await Task.Delay(1000); await _api.LogoutAsync(); html.Should().NotBeNullOrWhiteSpace(); html.Should().Contain(@"<div class=""j-get-lsByAccount invoice-loading-content _loading"" data-account=""<мой идентификатор>""></div>"); }
Однако моя цель получить не HTML, а идентификатор аккаунта и значит нужно разобрать HTML и найти нужную информацию. Конечно это можно было бы сделать и вручную, но я был уверен, что уже есть готовые решения для этого. Беглый поиск по «интернетам» вывел меня на статью на хабре с описанием 5 подходящих библиотек, одна из которых (AngleSharp) целиком и полностью написана на C#. И раз уж теперь у меня есть готовый инструмент, разбор HTML можно было бы реализовать внутри метода GetAccountHtmlAsync
, но это противоречит правилам хорошего тога. Получение HTML и разбор этого HTML — это 2 разные задачи и решаться они должны разными классами. К тому же написанный код нужно как-то тестировать и при тестировании метода получения HTML меня не интересует как в дальнейшем этот HTML будет использоваться, а сам тест при этом интеграционный т.е. подразумевает обращение к реальному серверу. В то же время методу разбора HTML не важно откуда этот HTML взялся и его можно сохранить в теле метода в виде константы, следовательно речь идёт о модульном тесте. Поэтому добавлю класс DocumentParser в проект TomRcApi:
public class DocumentParser { public async Task<int> GetIdAccountFromHtmlAsync(string html) { const string selector = "#trcInvoicePaymentForm > div.invoice-loading-content"; var parser = new HtmlParser(); var document = await parser.ParseDocumentAsync(html); var element = document.QuerySelector(selector); if (element == null) throw new ApplicationException($"Can't find element {selector}"); var attributeValue = element.GetAttribute("data-account"); if (attributeValue == null) throw new ApplicationException("Can't find attribute data-account"); if(!int.TryParse(attributeValue, out var idAccount)) throw new ApplicationException($"Can't parse data-account attribute value {attributeValue}"); return idAccount; }
Как видно из реализации поиск нужного элемента в HTML идет с помощью селекторов. Эх, давненько я не работал с CSS и jQuery, но это как с велосипедом — уже не забудешь.
Для модульных тестов создам XUnit проект TomRcApi.UnitTests с классом TestDocumentParser:
public class TestDocumentParser { private readonly DocumentParser _parser; public TestDocumentParser() { _parser = new DocumentParser(); } [Fact] public async Task TestGetAccountIdFromHtmlAsync() { #region html const string html = @"<!DOCTYPE html>..."; #endregion var idAccount = await _parser.GetIdAccountFromHtmlAsync(html); idAccount.Should().Be(<мой идентификатор>); }
Далее очередная итерация к Fiddler’у, который какбе намекает, что после загрузки страницы по адресу /personal/accounts/
был выполнен POST AJAX запрос по адресу /personal/accounts/get.php
с передачей идентификатора аккаунта в теле запроса. В ответ на запрос сервер вернул HTML с квитанцией об оплате, поэтому создам метод GetInvoiceHtmlAsync следующего содержания:
public async Task<string> GetInvoiceHtmlAsync(int idAccount) { if (_cookieJar == null) throw new ApplicationException("CookieJar is null"); var result = await _host.AppendPathSegment("personal").AppendPathSegment("accounts").AppendPathSegment("get.php") .WithHeaders(_headers) .WithCookies(_cookieJar) .PostUrlEncodedAsync(new { account = idAccount }); if (result.ResponseMessage.StatusCode != HttpStatusCode.OK) throw new ApplicationException($"StatusCode is {result.ResponseMessage.StatusCode}"); if (result.ResponseMessage.Content.Headers.ContentType == null) throw new ApplicationException("ContentType is null"); if (result.ResponseMessage.Content.Headers.ContentType.MediaType != "text/html") throw new ApplicationException($"ContentType is {result.ResponseMessage.Content.Headers.ContentType.MediaType}"); return await result.GetStringAsync(); }
Опять-таки половина метода это проверки и бросание исключений если что-то пошло не так.
Интеграционный тест:
[Fact] public async Task TestGetInvoiceHtmlAsync() { const string idAccount = "<идентификатор аккаунта>"; await _api.LoginAsync(_tomRcApiLogin, _tomRcApiPassword); await Task.Delay(1000); var html = await _api.GetInvoiceHtmlAsync(idAccount); await Task.Delay(1000); await _api.LogoutAsync(); html.Should().NotBeNullOrWhiteSpace(); html.Should().StartWith(@"<div class=""account-item j-account j-ac<цифирки>"" data-ls=""<идентификатор аккаунта>"">"); }
Интересующий меня кусок HTML состоит из двух частей, первая из которых это идентификатор квитанции об оплате:
<input type="checkbox" class="checkbox-input j-account-check" name="accountCheck[]" value="<идентификатор квитанции>" id="ac<идентификатор квитанции>">
А вторая — непосредственно поле для ввода новых показаний водосчётчиков
<input type="text" name="ipu[<идентификатор квитанции>][1848819]" class="control-input control-input--low j-account-row-current j-err_trcInvoicePaymentForm_ipu<идентификатор квитанции>_1848819" data-device="1848819" data-total="<идентификатор квитанции>1" value="16.495" data-service="2">
data-device="1848819"
— это номер прибора, т.е. для каждого прибора учёта будет своя запись и в моём случае их 4.
Метод разбора HTML вместе с классами DTO созданными на основе JSON:
public async Task<Invoice> GetInvoiceFromHtmlAsync(string html) { var parser = new HtmlParser(); var document = await parser.ParseDocumentAsync(html); const string idInvoiceElementSelector = "input[name='accountCheck[]']"; var idInvoiceElement = document.QuerySelector(idInvoiceElementSelector); if (idInvoiceElement == null) throw new ApplicationException($"Can't find element {idInvoiceElementSelector}"); var invoice = new Invoice(idInvoiceElement.GetAttribute("value")); var meterValueInputsSelector = $"input[name^='ipu[{invoice.Id}][']"; var meterValueInputs = document.QuerySelectorAll(meterValueInputsSelector); invoice.WaterMeters = meterValueInputs.Select(o => new WaterMeter(o.GetAttribute("data-device"), o.GetAttribute("value"))).ToArray(); return invoice; } public class Invoice { public Invoice(string id) { if (string.IsNullOrWhiteSpace(id)) throw new ApplicationException("Identifier is null or empty"); Id = id; } public string Id { get; set; } public IList<WaterMeter> WaterMeters { get; set; } } public class WaterMeter { public WaterMeter(string id, string value) { if (string.IsNullOrWhiteSpace(id)) throw new ApplicationException("Identifier is null or empty"); if (string.IsNullOrWhiteSpace(value)) throw new ApplicationException("Value is null or empty"); if (!decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) throw new ApplicationException($"Can't convert value {value} to decimal"); Id = id; Value = decimalValue; } public string Id { get; set; } public decimal Value { get; set; } }
И тест к нему:
[Fact] public async Task TestGetInvoiceFromHtmlAsync() { #region html const string html = @"<div class=""account-item j-account ..."; #endregion var invoice = await _parser.GetInvoiceFromHtmlAsync(html); invoice.Should().NotBeNull(); invoice.Id.Should().NotBeNullOrEmpty(); invoice.WaterMeters.Count.Should().Be(4); invoice.WaterMeters.Select(o => o.Id).Should().BeEquivalentTo(new[] {"1848819", "1848818", "1848822", "1848821"}); }
В этом тесте FluentAssertion при небольшой помощи LINQ раскрывает свой потенциал в отношении проверки массивов — просто и наглядно.
Что ж, похоже настал момент реализовать то, ради чего API затевался — метод передачи показаний. Как и прежде для выяснения подробностей операции воспользуюсь связкой браузера и Fiddler’а и проанализирую трафик. Передача показаний идёт при помощи метода POST на адрес /personal/accounts/
, вот только имена параметров счётчиков имеют формат ipu[<индекс>][name] и ipu[<индекс>][val], т.е. я не могу передать библиотеке Flurl объект с именами свойств такого формата для сериализации в JSON. И хотя я могу передать строку — мне не хотелось изобретать «собственный велосипед» для формировании этой строки. Я надеялся, что уже есть какое-то подходящее для меня решение. Google вывел меня на страничку Ronald Rosier с примером использования класса FormUrlEncodedContent, который принимает данные в формате перечислимого пар ключ-значение, а выдаёт готовую для передачи на сервер строку параметров — то что нужно. И итоге метод SendWaterMeterValuesAsync принял следующий вид:
public async Task SendWaterMeterValuesAsync(string idAccount, Invoice invoice) { if (_cookieJar == null) throw new ApplicationException("CookieJar is null"); var parameters = new Dictionary<string, string> { {"postOn", "on"}, {"action", "sendLastIpuValues"} }; for (var i = 0; i < invoice.WaterMeters.Count; i++) { parameters.Add($"ipu[{i}][name]", $"[{invoice.Id}][{invoice.WaterMeters[i].Id}]"); parameters.Add($"ipu[{i}][val]", invoice.WaterMeters[i].Value.ToString(CultureInfo.InvariantCulture)); } parameters.Add("account", idAccount); parameters.Add("barcode", invoice.Id); var content = new FormUrlEncodedContent(parameters); var result = await _host.AppendPathSegment("personal").AppendPathSegment("accounts/") .WithHeaders(_headers) .WithCookies(_cookieJar) .SendAsync(HttpMethod.Post, content); if (result.ResponseMessage.StatusCode != HttpStatusCode.OK) throw new ApplicationException($"StatusCode is {result.ResponseMessage.StatusCode}"); if (result.ResponseMessage.Content.Headers.ContentType == null) throw new ApplicationException("ContentType is null"); if (result.ResponseMessage.Content.Headers.ContentType.MediaType != "application/json") throw new ApplicationException($"ContentType is {result.ResponseMessage.Content.Headers.ContentType.MediaType}"); var loginResult = await result.GetJsonAsync<SendWaterMeterResult>(); if (!loginResult.submitOn) throw new ApplicationException("submitOn is false"); }
Напоследок хочу обратить внимание читателя вот на какой момент: на протяжении всей реализации у меня не было приложения для запуска и тестирования моего кода — не было единой точки входа и привычного метода main
. Однако используя модульные и интеграционные тесты с самого начала разработки у меня была возможность отлаживать интересующие блоки кода удобным и быстрым способом. Т.е. я хочу сказать, что модульные тесты это далеко не всегда автоматизация проверки реализации логики отдельных классов и поиска регрессий от версии к версии. В простейшем случае это замечательный инструмент разработчика для отладки отдельных блоков кода в изоляции от других компонентов. При этом продуманная архитектура позволяет сделать реализацию простой и наглядной, но в то же время легко тестируемой с помощью модульных и интеграционных тестов.