Сетевой дневник одного программиста

Персональный блог Константина Огородова

Создание API для существующего сайта

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

Создание API для существующего сайта

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Пролистать наверх