В предыдущей статье я писал обёртку на языке 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. Однако используя модульные и интеграционные тесты с самого начала разработки у меня была возможность отлаживать интересующие блоки кода удобным и быстрым способом. Т.е. я хочу сказать, что модульные тесты это далеко не всегда автоматизация проверки реализации логики отдельных классов и поиска регрессий от версии к версии. В простейшем случае это замечательный инструмент разработчика для отладки отдельных блоков кода в изоляции от других компонентов. При этом продуманная архитектура позволяет сделать реализацию простой и наглядной, но в то же время легко тестируемой с помощью модульных и интеграционных тестов.