Недавно на Хабре вышла статья «HabraTab — девайс для хаброзависимых», которая вызвала неподдельный интерес у хабропользователей и, можно сказать, произвела своего рода фурор (на данный момент рейтинг статьи +137).
Действительно, проект довольно интересный как своей концепцией, так и исполнением, как программным, так железным и даже дизайнерским — девайс выглядит весьма своеобразно и оригинально.
Каждый нашёл в нём что-то своё, сам девайс меня не заинтересовал, но зато заинтересовал код, который может получать данные (кроме Хабра) с различных сайтов в интернете и затем эти данные использовать в IoT системах. Также этот код можно использовать для получения данных со встроенных веб-интерфейсов различных устройств в локальной сети, чему можно найти множество применений в реальных проектах по автоматизации (и не только).
Автор любезно открыл код, что сделало возможным его исследование и модернизацию, чем я с большим удовольствием и занялся. Далее я представлю результаты своих изысканий по этой теме.
❯ План статьи
Статья будет поделена на 5 частей:
- Извлечение движка парсинга данных из оригинального кода
- Добавление подсистемы сбора статистики и анализ её работы
- Приколы в коде
- Пример получения данных о статье (статьях) с Хабра
- Общие вопросы и планы на будущее
Вообще, каждой из этих частей можно посвятить отдельную статью, тут всё зависит от степени детализации и количества объяснений. Я постараюсь осветить все эти вопросы в одной статье, надеюсь мне удастся уложиться в эти рамки.
❯ 1. Извлечение движка
В принципе, HabraTab является законченным и довольно гармоничным устройством и многих он в таком виде вполне устроит — спаял плату, залил прошивку — всё работает и больше от устройства ничего не нужно.
С другой стороны, кому-то не нужны показания температуры и влажности на Хабро-шильдике, а кому-то не нужно на нём текущее время и т. д. А у кого-то есть в наличии другой дисплей, а кому-то нужен крупный шрифт на огромном дисплее и т. п.
Мне, так вообще нужен только движок для использования в IoT системах для получения данных с различных сайтов и веб-интерфейсов сетевых устройств. Поэтому первым естественным желанием у меня было «отделить мух от котлет» и извлечь движок из прошивки HabraTab, чтобы потом можно было его использовать в других проектах.
Ломать, как говорится, — не строить. Или, как любил говаривать старина Микеланджело, — создать шедевр нетрудно, нужно только отсечь всё лишнее. В данном случае операция не очень сложная, но нужно, конечно, иметь какое-то представление о том, что делаешь.
После нескольких взмахов скальпелем и некоторых доработок кода, в моём распоряжении оказался сам движок. Положительным побочным эффектом этой операции стало отсутствие необходимости в дополнительных библиотеках — теперь проект компилируется без них.
Код движка. Нужно понимать, что это не законченное решение, а только первый шаг на долгом пути совершенствования подсистемы получения данных со страниц сайтов при помощи контроллеров на ESP32.
/* Parsing Engine test */ #include <HTTPClient.h> const char ssid[] = "ssid"; // <--- актуализировать const char pass[] = "pass"; // <--- актуализировать String sURL = "https://habr.com/ru/users/"; String userName = "ENGIN33RRR"; WiFiClientSecure client; HTTPClient http; String karma = "000"; String ratin = "000.0"; String posit = "000"; void setup() { xTaskCreatePinnedToCore( FileUpdate, // функция потока "Task1", // название потока 10000, // стек потока NULL, // параметры потока 2, // приоритет потока NULL, // идентифкатор потока 1); // ядро для выполнения потока delay(500); xTaskCreatePinnedToCore( Graph, "Task2", 16000, NULL, 1, NULL, 0); delay(500); } // setup void connectWifi() { WiFi.mode(WIFI_STA); delay(10); Serial.print(F("Connecting to Wi-Fi")); WiFi.begin(ssid, pass); delay(10); byte count = 0; while (WiFi.status() != WL_CONNECTED && count < 15) { Serial.print('.'); count++; delay(500); } Serial.println(); delay(10); } void reconnectWifi() { WiFi.disconnect(); vTaskDelay(1000); Serial.print(F("Reconnecting to Wi-Fi")); WiFi.begin(ssid, pass); byte count = 0; while (WiFi.status() != WL_CONNECTED && count < 15 ) { Serial.print('.'); count++; delay(500); } Serial.println(); delay(10); } void printValuesFilter() { Serial.print(karma.toInt()); Serial.print('/'); Serial.print(ratin.toFloat(), 1); Serial.print('/'); Serial.print(posit.toInt()); Serial.println(); } void getValues() { Serial.println(F("Request...")); http.begin(client, sURL + userName + "/"); // открываем HTTP соединение delay(10); int httpCode = http.GET(); delay(10); Serial.print(F(" code: ")); Serial.println(httpCode); if (httpCode == 200) { WiFiClient* stream = http.getStreamPtr(); // пребразуем данные в поток Stream if (stream->available()) { // Karma stream->find(R"rawliteral(karma__votes_positive">)rawliteral"); for (int i = 0; i < 5; i++) { stream->read(); } for (byte i = 0; i < 5; i++) { karma[i] = stream->read(); } // Rating stream->find(R"rawliteral(tm-rating__counter">)rawliteral"); for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();} // Position stream->find("В рейтинге"); for (int i = 0; i < 118; i++) {stream->read();} for (byte i = 0; i < 4; i++) {posit[i] = stream->read();} printValuesFilter(); } delay(10); } else { Serial.printf(" (%s)\n", http.errorToString(httpCode).c_str()); } http.end(); delay(10); } void requestWorks() { if (WiFi.status() == WL_CONNECTED) { getValues(); } else { reconnectWifi(); } } void FileUpdate(void* pvParameters) { Serial.begin(115200); Serial.println(); Serial.println(F("Starting Parsing Engine test...")); http.setTimeout(3000); //http.setReuse(true); connectWifi(); client.setInsecure(); // игнорирование HTTPS сертификатов for (;;) { requestWorks(); vTaskDelay(10000); } } void printData() { //... } void Graph(void* pvParameters) { // поток отрисовки for (;;) { printData(); vTaskDelay(1); } } void loop() { }
Этот движок работает и работает вполне прилично, то есть его уже в таком виде можно «засунуть» в прошивку на ESP32 и использовать для своих целей от получения данных о курсах валют до парсинга данных с различных устройств в локальной сети (сетевые принтеры, UPS-ы и т. д.), которые штатно не имеют API интерфейсов и не предусматривают выдачу внутренних данных по запросам из сети.
Скриншот тестовой работы движка. Всё работает хорошо, но есть моменты, о которых мы поговорим далее.

В этой версии кода парсинг сделан на «педальной тяге», здесь вручную в скетче задаются «якоря» и вручную же задаётся алгоритм поиска на странице нужных значений. Недостаток этого метода и его «ахиллесова пята» очевидны: стоит сайту, с которого получают данные, немного изменить HTML код — и работа системы мгновенно «сломается».
stream->find(R"rawliteral(karma__votes_positive">)rawliteral"); for (int i = 0; i < 5; i++) { stream->read(); } for (byte i = 0; i < 5; i++) { karma[i] = stream->read(); }
Для каких-то экспериментов это допустимо, но для готовой системы нужно, конечно, предусматривать решение этой проблемы. Первое, что приходит в голову — это создание веб-интерфейса, в котором можно будет задать якоря и отступы без перекомпиляции самого кода прошивки ESP32.
Примечание. Особо продвинутые программисты могут попытаться автоматизировать этот процесс или вообще переложить бремя поиска новых якорей и отступов на ChatGPT.
Вторая часть проблемы заключается в том, что с сайта мы получаем сырые данные с вкраплениями по(ту)стороннего мусора. Это происходит потому, что мы заранее не можем определить количество разрядов в получаемых данных. Например, рейтинг пользователя может иметь один разряд, а может и три; может быть целым числом, а может иметь дробную часть и т. д.
void printValuesFilter() { Serial.print(karma.toInt()); Serial.print('/'); Serial.print(ratin.toFloat(), 1); Serial.print('/'); Serial.print(posit.toInt()); Serial.println(); }
Поэтому в текущей версии кода в качестве «фильтра» применяется преобразование строковых значений (сырых данных, полученных с сайта) в значения типов Int и Float. Это паллиативное решение, которое как-то работает, но в дальнейшем, конечно, должно быть заменено на нормальный фильтр.
Следующая проблема заключается в том, что движок в нынешнем его виде, не имеет 100% эффективности, то есть часть запросов выполняется успешно, а часть по тем или иным причинам оканчивается неудачей.
Это конечно «не дело» и так быть не должно. Далее мы попробуем разобраться с причинами возникновения ошибок и вообще глубиной этой проблемы.
❯ Добавление подсистемы сбора статистики и анализ её работы
Все ошибки получения данных и работы движка можно подразделить на два типа:
- Ошибки доступа к сайту и веб-странице.
- Ошибки парсинга и фильтрации данных.
Из просмотра листингов вывода телеметрии в Serial невозможно ничего понять и невозможно сделать какие-то осмысленные и объективные выводы о причинах возникновения ошибок парсинга. Поэтому в код пришлось добавить специальную подсистему сбора статистики работы движка.
Эта подсистема автоматически собирает статистику по произведённым запросам и в реальном времени выводит процентные соотношения по всем типам ошибок. А вот уже на основании этой (объективной) статистики можно будет сделать какие-то осмысленные выводы о качестве работы движка и причинах возникновения ошибок.
Код движка с добавленной подсистемой сбора статистики:
/* Parsing Engine Stat */ #include <HTTPClient.h> const char ssid[] = "ssid"; // актуализировать const char pass[] = "pass"; // актуализировать String sURL = "https://habr.com/ru/users/"; String userName = "ENGIN33RRR"; WiFiClientSecure client; HTTPClient http; String karma = "000"; String ratin = "000.0"; String posit = "000"; int kma = 68; // актуализировать float rtg = 134.3; // актуализировать int pos = 21; // актуализировать unsigned long counter1 = 0; long cntReq = 0; long cntSuc = 0; long cntErr = 0; long cntBad = 0; void setup() { xTaskCreatePinnedToCore( FileUpdate, // функция потока "Task1", // название потока 10000, // стек потока NULL, // параметры потока 2, // приоритет потока NULL, // идентифкатор потока 1); // ядро для выполнения потока delay(500); xTaskCreatePinnedToCore( Graph, "Task2", 16000, NULL, 1, NULL, 0); delay(500); } // setup void FileUpdate(void* pvParameters) { Serial.begin(115200); Serial.println(); Serial.println(F("Starting Parsing Engine Stat...")); http.setTimeout(3000); //http.setReuse(true); connectWifi(); client.setInsecure(); // игнорирование HTTPS сертификатов for (;;) { requestWorks(); vTaskDelay(10000); } } void Graph(void* pvParameters) { // поток отрисовки for (;;) { //counter1Works(); printData(); vTaskDelay(1); } } void loop() { }
/* Module Request */ void checkBad() { if (karma.toInt() != kma || ratin.toFloat() != rtg || posit.toInt() != pos) { cntBad++; } } void getValues() { Serial.println(F("Request...")); http.begin(client, sURL + userName + "/"); // открываем HTTP соединение delay(10); int httpCode = http.GET(); delay(10); cntReq++; Serial.print(F(" code: ")); Serial.println(httpCode); if (httpCode == 200) { WiFiClient* stream = http.getStreamPtr(); // пребразуем данные в поток Stream if (stream->available()) { // Karma stream->find(R"rawliteral(karma__votes_positive">)rawliteral"); for (int i = 0; i < 5; i++) { stream->read(); } for (byte i = 0; i < 5; i++) { karma[i] = stream->read(); } // Rating stream->find(R"rawliteral(tm-rating__counter">)rawliteral"); for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();} // Rating stream->find(R"rawliteral(tm-rating__counter">)rawliteral"); for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();} // Position stream->find("В рейтинге"); for (int i = 0; i < 118; i++) {stream->read();} for (byte i = 0; i < 4; i++) {posit[i] = stream->read();} //printValuesRaw(); printValuesFilter(); } cntSuc++; checkBad(); delay(10); } else { Serial.printf(" (%s)\n", http.errorToString(httpCode).c_str()); cntErr++; } http.end(); delay(10); } void requestWorks() { if (WiFi.status() == WL_CONNECTED) { getValues(); printStat(); } else { reconnectWifi(); } }
/* Module Print */ void printStat() { float perc = (float)cntReq / 100.0; float percSuc = (float)cntSuc / 100.0; long cntOk = cntSuc - cntBad; Serial.print(F(" Req:")); Serial.print(cntReq); Serial.print(F(" Suc:")); Serial.print(cntSuc); Serial.print(F("(")); Serial.print((float)cntSuc/perc, 0); Serial.print(F("%) Err:")); Serial.print(cntErr); Serial.print(F("(")); Serial.print((float)cntErr/perc, 0); Serial.print(F("%) Bad:")); Serial.print(cntBad); Serial.print(F("(")); Serial.print((float)cntBad/percSuc, 0); Serial.print(F("%) Ok:")); Serial.print(cntOk); Serial.print(F("(")); Serial.print((float)cntOk/perc, 0); Serial.print(F("%)")); Serial.println(); } void printValuesFilter() { Serial.print(karma.toInt()); Serial.print('/'); Serial.print(ratin.toFloat(), 1); Serial.print('/'); Serial.print(posit.toInt()); Serial.println(); } void printValuesRaw() { Serial.println(karma); Serial.println(ratin); Serial.println(posit); } void counter1Works() { if (millis() > counter1 + 1000) { Serial.println('.'); counter1 = millis(); } }
/* Module Wi-Fi */ void connectWifi() { WiFi.mode(WIFI_STA); delay(10); Serial.print(F("Connecting to Wi-Fi")); WiFi.begin(ssid, pass); delay(10); byte count = 0; while (WiFi.status() != WL_CONNECTED && count < 15) { Serial.print('.'); count++; delay(500); } Serial.println(); delay(10); } void reconnectWifi() { WiFi.disconnect(); vTaskDelay(1000); Serial.print(F("Reconnecting to Wi-Fi")); WiFi.begin(ssid, pass); byte count = 0; while (WiFi.status() != WL_CONNECTED && count < 15 ) { Serial.print('.'); count++; delay(500); } Serial.println(); delay(10); }
Стартуем движок и наблюдаем за статистикой. В начале всё нормально, но уже третий запрос заканчивается ошибкой. Для каких-то выводов ждём сбора статистики с нескольких сотен запросов.

Расшифровка сокращений в строке статистики:
Req — общее количество произведённых запросов и номер текущего запроса.
Suc — количество успешных запросов к серверу и их процентное соотношение. «Успешных» технически, то есть вообще полученных от сервера. Качество ответов не учитывается, данные в ответе могут быть и «битыми».
Err — количество запросов к серверу, которые завершились ошибкой (то есть ответ вообще не получен) и их процентное соотношение.
Bad — данные получены, но они «битые» и их процентное отношение ко всем полученным данным (но не к количеству всех запросов).
Ok — количество успешно завершённых запросов и их процентное соотношение к количеству всех произведённых запросов (по существу «главный» параметр, который определяет качество работы всей системы в целом).
Небольшие пояснения по новому варианту кода.
Добавляем в скетч переменные для подсчёта статистики:
long cntReq = 0; long cntSuc = 0; long cntErr = 0; long cntBad = 0;
Для определения количества полученных «битых» данных от сервера вручную добавляем в скетч заведомо правильные значения (на момент запуска теста).
int kma = 68; // актуализировать float rtg = 137.3; // актуализировать int pos = 20; // актуализировать
В функции printStat() подсчитываем процентные соотношения и выводим в Serial статистику по запросам.
void printStat() { float perc = (float)cntReq / 100.0; float percSuc = (float)cntSuc / 100.0; long cntOk = cntSuc - cntBad; Serial.print(F(" Req:")); Serial.print(cntReq); Serial.print(F(" Suc:")); Serial.print(cntSuc); Serial.print(F("(")); Serial.print((float)cntSuc/perc, 0); Serial.print(F("%) Err:")); Serial.print(cntErr); Serial.print(F("(")); Serial.print((float)cntErr/perc, 0); Serial.print(F("%) Bad:")); Serial.print(cntBad); Serial.print(F("(")); Serial.print((float)cntBad/percSuc, 0); Serial.print(F("%) Ok:")); Serial.print(cntOk); Serial.print(F("(")); Serial.print((float)cntOk/perc, 0); Serial.print(F("%)")); Serial.println(); }
Ниже представлен скриншот со статистикой работы движка после 200 запросов к серверу Хабра. Цифры процентов могут немного «гулять» из-за округления до целых значений. Это округление сделано умышленно — тут нам не важны десятые и сотые доли процентов — нам нужно понять общую картину качества работы движка.

Из представленного скриншота видно, что около 9% запросов к серверу Хабра оканчиваются неудачей. Как правило это код 7 (no HTTP server) и код 11 (read Timeout). Иногда встречаются и более экзотические ошибки. Трудно сказать в чём причина этих ошибок — возможно это связано с тем, что сервер Хабра не справляется с пиковыми нагрузками от множества клиентов. Кстати, различные «глюки» загрузки страниц Хабра наблюдаются и при работе с обычным веб-браузером.
Нужно сказать, вышеприведённая статистика работы движка довольно благостная — ошибок относительно немного и такие ошибки хорошо детектируются и обходятся в коде. Но не всё так радужно: иногда (по пока невыясненным мной причинам) начинают «сыпаться» Bad ошибки парсинга переменных. То ли это связано с сервером Хабра, то ли с самим кодом движка, но я бы поставил на какие-то глюки работы FreeRTOS, её алгоритмов распределения памяти и работы со стеком и кучей — по косвенным признакам очень похоже на подобную природу «глюков».
Сетевое взаимодействие и работа движка — это динамические процессы, то есть определённым (нелинейным) образом растянутые по времени, поэтому для более полного понимания работы системы нужно каким-то образом отображать временные интервалы происходящих событий.
Для этого в Serial вывод добавляются секундные маркеры (в виде точек) — и сразу становится видна динамика сетевого взаимодействия движка и сайта.
void counter1Works() { if (millis() > counter1 + 1000) { Serial.println('.'); counter1 = millis(); } }
Динамический режим вывода можно включать и отключать в скетче — он не всегда нужен, иногда важна не динамика, а только последовательность событий и текущие значения параметров и элементов системы.
void Graph(void* pvParameters) { // поток отрисовки for (;;) { //counter1Works(); printData(); vTaskDelay(1); } }
Например, в динамическом режиме хорошо видно, что на получение ответа (страницы) от сервера Хабра, после посылки запроса к нему, уходит около трёх секунд, а вот поиск значений в HTML коде страницы и их обработка происходит практически «мгновенно».

❯ Приколы в коде
В процессе экспериментов я столкнулся с необъяснимым для меня поведением компилятора, который я иначе как «приколом» назвать не могу.
Вышеприведённый код движка с подсистемой сбора статистики прекрасно компилируется и работает и в нём есть такой (совершенно безобидный) фрагмент:
// Rating stream->find(R"rawliteral(tm-rating__counter">)rawliteral"); for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}
Но, если продублировать этот фрагмент в коде
// Rating stream->find(R"rawliteral(tm-rating__counter">)rawliteral"); for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();} // Rating stream->find(R"rawliteral(tm-rating__counter">)rawliteral"); for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}
то скетч перестаёт компилироваться, чего никак не может быть в принципе — если этот участок кода один раз прошёл проверку компилятором, то и второй раз обязан её пройти.
Я пока не успел разобраться с этим вопросом, но у меня есть два предположения:
- Это ошибка компилятора (что маловероятно и в это с трудом верится).
- Сам код содержит синтаксическую ошибку, которая по каким-то причинам «пропускается» компилятором.
Надеюсь, «старшие товарищи» внесут ясность в этот вопрос и объяснят как такое вообще может быть.
❯ Пример получения данных о статье (статьях) с Хабра
Здесь нас ожидает «облом» в самом неожиданном месте. У меня уже был готов код для этого раздела и я начал его описание, но меня вдруг посетила мысль:
«Ё! Когда мы получаем данные со страницы пользователя на Хабре, то это не очень изящное, но более-менее допустимое действие, но когда мы получаем данные о параметрах статьи со страницы самой этой статьи, то каждый раз заново загружаем всю страницу и тем самым… попутно увеличиваем количество просмотров!»
Очевидно, что это уже (хоть и ненамеренно) выходит за рамки безобидных экспериментов с электричеством и может быть неоднозначно воспринято администрацией Хабра.
Поэтому публикацию этого раздела и кода я остановил и обратился к администрации Хабра за официальными комментариями и разъяснениями её позиции по этому вопросу.
Официального ответа я пока не получил, возможно он последует после публикации этой статьи.
А пока администрация Хабра размышляет над свой позицией по этому вопросу, мы можем немного порассуждать об ещё одной интересной теме — API Хабра.
Насколько я понял из неофициальных контактов с официальными представителями Хабра, API у Хабра есть, но он непубличный. Мне со стороны трудно рассуждать о технических и организационных проблемах, не дающих сделать его публичным, но, если всё-таки есть такая возможность, то может быть настало время Хабру, наконец, запустить в полноценную работу этот сервис.
Ждём-с…
❯ Общие вопросы и планы на будущее
Ну и напоследок несколько вопросов, которые у меня возникли в процессе экспериментов с движком HabraTab:
- Почему одни и те же запросы на одних и тех же страницах иногда дают различные результаты? По идее, так не должно быть и сырые данные всегда должны быть одинаковыми (при одинаковых исходных условиях).
- В чём причина спорадического возникновения Bad ошибок?
- Как на ESP32 получать данные с сайтов, которые требуют для своей работы Javascript?
- Как на ESP32 получать данные с сайтов, которые требуют авторизации?
Планы на будущее:
Основная проблема на данный момент — возникновения Bad ошибок, поэтому в планах в первую очередь разобраться с причинами этого явления и сделать движок на 100% стабильным.
Затем можно поэкспериментировать с сетевым взаимодействием и поднять его эффективность с 90 до 100%.
Далее можно будет упростить или автоматизировать определение якорей и отступов на целевых страницах.
Ну и т. д. и т. п., усовершенствовать движок можно до бесконечности или в соответствии с требованием конкретных проектов.
❯ Заключение
Как пример: в моём хозяйстве есть замечательный сетевой блок бесперебойного питания APC Back-UPS HS 500, который имеет веб-интерфейс, но не имеет сетевого API, через которое я бы мог получать данные о его текущих параметрах для интеграции в систему «умного дома».
Раньше эту проблему я решал при помощи инструментария системы MajorDoMo, теперь можно попробовать отказаться от её услуг и решить проблему с помощью маломощного и недорогого контроллера на ESP32.
Я надеюсь, что на этих примерах мне удалось донести до вас потенциал движка HabraTab для использования в IoT проектах и проектах по автоматизации.
ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/718254/

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