Монітор вільних термінів на порталі словацької поліції (portal.minv.sk).
Проблема відома: фронтенд там — це жорсткий wizard. F5 або зміна ключового
параметра вбиває стейт, і капчу зі СМС треба проходити наново. Тому тут немає
жодного reload(). Обидва варіанти працюють усередині вже пройденої сесії.
Варіант 1 — monitor.js. Ти один раз проходиш wizard руками, скрипт забирає
твої куки й довбить той самий XHR по колу. Капча і СМС більше не потрібні,
доки жива сесія.
Варіант 2 — browser-monitor.js. Якщо сесія вмирає швидко або токени
крутяться. Відкривається справжній Chromium, ти вводиш капчу і СМС, тиснеш
ENTER — далі скрипт смикає JS самої сторінки, без перезавантаження.
Плюс capture-session.js — помічник, який сам витягує потрібний запит і генерує
готовий .env для Варіанта 1.
npm install
npx playwright install chromium # тільки для Варіанта 2 і capture-session
cp .env.example .envПотрібен Node.js 18+ (перевірено на 22).
Спосіб A (простіший) — автоматично:
npm run captureВідкриється браузер. Проходиш капчу, вводиш СМС — і йдеш далі: послуга → відділення (pracovisko) → аж поки на екрані не буде календар із датами. Тільки тоді ENTER.
Це найважливіше місце. Якщо натиснути ENTER раніше (наприклад на виборі
послуги), скрипт запише не той запит — потрібного просто ще не було в мережі.
У списку кандидатів дивись на мітку [json, N dates]: правильний той, що
віддає JSON і містить дати.
Скрипт запише:
.env.captured— готовий конфіг (URL, куки, CSRF, заголовки, тіло запиту);captured/requests.jsonl— усі XHR, які робила сторінка.
Далі:
cp .env.captured .env
npm run monitorЯкщо він вибрав не той запит — подивись captured/requests.jsonl і поправ
TARGET_URL руками.
Спосіб B — руками через DevTools:
- F12 → вкладка Network, увімкни фільтр Fetch/XHR.
- Пройди wizard до кроку з датами.
- Знайди запит, який повертає дати (шукай
termin,date,freeв URL, або просто дивись, у чиїй відповіді сидитьNie sú momentálne dostupné...). - Правий клік → Copy → Copy as cURL.
- Перенеси з нього в
.env: URL →TARGET_URL, метод →METHOD, заголовокCookie:цілком →COOKIE_HEADER, CSRF-токен →CSRF_TOKEN(+CSRF_HEADER_NAME, якщо він зветься неX-CSRF-TOKEN),--data→REQUEST_BODY,User-Agent→USER_AGENT,Referer→REFERER.
npm run monitorРаз на 60 секунд (+ джитер) летить той самий запит. Крапка в консолі = пінг без
результату. Частіше не став — портал ріже за CALL_LIMIT, див. нижче. Коли з'явиться слот — банер, безкінечний біп і пуш у Telegram.
Не закривай вкладку з wizard'ом у браузері. Коли скрипт запищить, тобі треба буде за секунди клікнути «Далі» саме в ній.
Скрипт зупиниться сам і скаже про це, якщо сесія помре — і коли це видно по
HTTP-статусу (401/403, редірект), і коли портал ховає це в тілі
({"code":"401"} при HTTP 200). Тоді знову npm run capture.
Потрібен, якщо сесія Варіанта 1 живе занадто мало.
npm run browser- Відкриється Chromium на
START_URL. - Проходиш капчу, вводиш СМС, доходиш до кроку з датами.
- Тиснеш ENTER у терміналі.
- Далі скрипт кожні 20 секунд змушує сторінку перепитати сервер — без перезавантаження.
| Режим | Що робить | Коли вибирати |
|---|---|---|
none |
нічого не чіпає, просто перечитує DOM | сторінка сама себе оновлює |
click |
клікає TRIGGER_SELECTOR |
є кнопка «Skontrolovať» / оновити |
reselect |
перемикає <select> на інше значення і назад |
найнадійніше на цьому порталі |
eval |
виконує TRIGGER_JS у сторінці |
якщо знайшов потрібну JS-функцію |
reselect — це саме той трюк «клікати туди-сюди між містами»: скрипт міняє
значення випадайки на сусіднє і одразу повертає назад, кидаючи change. Обробник
самої сторінки робить AJAX, сесія не рветься.
Селектори треба підставити свої — подивись їх у DevTools:
TRIGGER_MODE=reselect
TRIGGER_SELECTOR=select#pracovisko
API_URL_PATTERN=/getFreeTerms
SCOPE_SELECTOR=#stepContentAPI_URL_PATTERN вартий того, щоб його заповнити: тоді скрипт читає JSON
відповіді напряму, а не вгадує по тексту. Це набагато надійніше.
Коли слот знайдено — біп, скріншот у screenshots/, вікно виводиться наперед
і браузер залишається відкритим, щоб ти одразу бронював.
Порядок перевірок (src/detect.js):
- Фраза з
NO_SLOTS_TEXTу відповіді → слотів нема. Регістр і діакритика не мають значення:nie su momentalneзловить іNie sú momentálne. - Фраза з
SLOT_TEXT→ слот є. - JSON-масив дат (шлях
SLOT_JSON_PATH, або перший знайдений масив) → непорожній = слот є. - Нічого не збіглося → inconclusive, скрипт скаже про це і покаже шматок відповіді, щоб ти поправив конфіг.
Четвертий випадок за замовчуванням не рахується слотом, щоб не будити тебе
даремно. Якщо боїшся проспати — постав TREAT_INCONCLUSIVE_AS_SLOT=true.
Логіка покрита тестами:
npm testЗафіксовано на живому прогоні: portal.minv.sk був недоступний близько трьох
годин (46 таймаутів TCP поспіль, 20:24–23:27), тоді як сусідній
pes.minv.sk у тій самій підмережі відповідав нормально.
Перевірка через сторонні проксі показала в них той самий таймаут — тобто це падіння порталу, а не блок за нашою адресою. Монітор такі збої перечікує нескінченно: обірване з'єднання не витрачає бюджет викликів.
Але сесія цього не переживає. Поки портал лежить, її нічим не підтримати, і
вона протухає від бездіяльності. Коли портал повернувся, перший же запит дав
{"code":"401"}. Тобто після довгого простою готуйся проходити СМС наново.
Головне обмеження — сесія прив'язана до IP, а стабільний словацький IP має ПК. Тому телефон не заходить на портал сам, а керує браузером на ПК через віддалений стіл (RustDesk, для iPhone теж є). Капчу тицяєш на екрані ПК, СМС падає на той самий телефон — код копіюєш у той самий браузер. Увесь трафік іде з IP комп'ютера, збіг автоматичний.
Щоб віддалена сесія була якнайкоротшою, є одна команда:
npm run huntВона відкриває браузер і чекає. Ти робиш лише те, що може людина — капча, СМС,
вибір послуги й відділення до появи дат. Щойно дати завантажились, hunt сам
підхоплює сесію, пише .env і запускає монітор. Не треба вгадувати ENTER, не
треба копіювати файли. Далі ховаєш телефон; Telegram скаже про слот або про те,
що бюджет вичерпано і час на нову капчу+СМС.
За замовчуванням hunt розтягує 6 викликів на годину — одна автентифікація покриває годину без нагляду.
npm run preflight # чекати, поки портал оживе, потім Telegram
npm run preflight -- --once # одне вікно, вердикт, вихід 0/1За замовчуванням не завершується, поки портал не почне стабільно відповідати. Портал падає надовго — заміряні простої по три години — тож корисне питання не «чи він живий зараз», а «скажи мені, коли оживе». Щойно пройде вікно чистих запитів, прилітає пуш у Telegram із тривалістю простою.
Під час очікування невдачі не сиплються в лог щосекунди — зведення приблизно раз на п'ять хвилин. Повернення помічається за один інтервал, а не за ціле вікно.
Смикає тільки публічну сторінку — ні сесії, ні бюджету викликів не витрачає.
Навіщо: дві сесії поспіль загинули однаково — знято в коротке вікно, коли портал працював, а далі він лягав на три години, і сесія протухала, бо підтримати її нічим. Капча і СМС — найдорожча частина, тож перевіряй до, а не після.
Заміряні простої:
| період | стан |
|---|---|
| 20:24 → 23:27 | лежав, 3 год 03 хв |
| 23:27 → 00:03 | працював, 36 хв |
| 00:03 → 03:04 | лежав, 3 год 01 хв |
Уночі знімати сесію — марна трата СМС. Роби це в робочі години, коли портал стабільний, та й терміни звільняються саме тоді.
npm run healthЧитає .monitor-status.json, який монітор переписує на кожному опитуванні.
Показує, чи живий процес, коли був останній запит, що він повернув, чи
налаштований Telegram і скільки разів ловив CALL_LIMIT. Жодного звернення до
порталу не робить — тобто бюджет викликів не витрачає, запускати можна будь-коли.
Код виходу 0 = все гаразд, 1 = потрібна увага.
Це опитувальник, а не тригер. Портал не має ні вебхуків, ні пушів — єдиний
спосіб дізнатися про термін це спитати. Монітор питає раз на INTERVAL_MS
(+ джитер, щоб запити не лягали ідеально рівно).
Термінальний біп марний, якщо ПК в одній країні, а ти в іншій. Постав Telegram:
- Створи бота в @BotFather, візьми токен.
- Напиши своєму боту будь-що.
- Відкрий
https://api.telegram.org/bot<ТОКЕН>/getUpdates, візьмиchat.id. - Впиши
TELEGRAM_BOT_TOKENіTELEGRAM_CHAT_IDу.env.
Пуш прилітає і на знайдений слот, і на смерть сесії.
СМС. Словацька сімка в ЄС працює в роумінгу без доплат — код прийде миттєво. Проблем нема.
Італійський IP. Ось де болітиме. minv.sk може віддавати 403 або
нескінченну капчу на несловацькі адреси. Ознака — 403 на першому ж пінгу
(скрипт про це прямо напише). Лікування: VPN зі словацьким або чеським сервером
на тому ПК. Важливо: капчу і монітор треба проходити з одного IP — якщо
сесію ти отримав з італійської адреси, а пінгувати почнеш з словацької, її
викинуть.
Віддалений доступ.
- Варіант 1 — вистачить SSH. Але сесію треба десь отримати: або запусти
npm run captureна ПК через графічний доступ, або пройди wizard у себе на телефоні/ноуті й перенеси куки в.envруками (спосіб B вище). Другий шлях спрацює тільки якщо IP збігається — тобто через той самий VPN. - Варіант 2 — потрібен графічний доступ (AnyDesk, Chrome Remote Desktop, RustDesk): капчу за тебе ніхто не введе.
- Якщо ПК — headless-сервер:
xvfb-run -a npm run browser+ VNC, і не забудьHEADLESS=false(за замовчуванням саме так).
Тримай телефон зі звуком поруч — і для СМС, і щоб не проспати пуш.
Усі ключі описані в .env.example. Найважливіші:
| Ключ | Що це |
|---|---|
TARGET_URL, METHOD |
той самий XHR, що віддає дати |
COOKIE_HEADER |
заголовок Cookie цілком, одним рядком |
CSRF_TOKEN, CSRF_HEADER_NAME |
анти-CSRF токен, якщо є |
INTERVAL_MS |
пауза між пінгами, дефолт 60000 — нижче не став |
NO_SLOTS_TEXT |
фрази «немає термінів», через кому |
SLOT_JSON_PATH |
шлях до масиву в JSON, для ECU — services |
TRIGGER_MODE, TRIGGER_SELECTOR |
як смикати сторінку (Варіант 2) |
API_URL_PATTERN |
шматок URL AJAX-запиту для підслуховування |
TELEGRAM_* |
пуш у телефон |
Розібрано з живого захоплення сесії (captured/requests.jsonl).
Потрібна гілка — ECU, портлет PA_ditec-ecu-vysys, шлях
portal.minv.sk/wps/portal/domov/ecu/ecu_elektronicke_sluzby/ecu-vysys/.
Це та, де «Dočasné útočisko» і «Termíny na oddelenia cudzineckej polície».
Є ще старіша PA_ditec-vysys-01 (VYSYS01) — то універсальний рез. система
для дорожньої інспекції, не туди.
Усі AJAX летять на ОДНУ URL. Це WebSphere-портлет: адреса
(!ut/p/a1/…) не змінюється між кроками, а дія визначається тілом POST:
| Тіло запиту | Крок |
|---|---|
jsp=main1&lang=sk |
підвантаження HTML-шматка UI (не потрібен) |
person={…}&tida=0 |
перевірка особи, у відповіді maxTime |
pincode={"pincode":"…"}&tida=0 |
код зі СМС |
reservation={…}&tida=0&csrfToken=…&lang=sk |
список послуг, далі дати |
Тому для monitor.js TARGET_URL буде та сама портлетна адреса, а весь сенс —
у REQUEST_BODY. CSRF-токен тут іде полем у тілі, не заголовком.
Справжні фрази «немає термінів» — витягнуті з JS порталу, вони вже стоять
дефолтом у src/detect.js:
noDatesAvailable— «Nie sú momentálne dostupné žiadne termíny. Skúste to prosím neskôr.»noAvailableSlots— «Vo zvolenom dátume nie je prístupný žiaden voľný termín…»missDate— «Pre zvolené pracovisko nie je k dispozícii žiadna voľná rezervácia.»missTime— «Pre zvolený deň nie je k dispozícii žiadna voľná…»
Сесія живе ~45 хвилин. Відповідь на крок person містить maxTime — це
дедлайн. Після нього тільки заново з капчею і СМС.
Капча — reCAPTCHA Enterprise v2 (recaptcha-v2-verify). Обійти її скриптом
не вийде і не варто пробувати — саме тому обидва варіанти розраховані на те, що
ти проходиш її руками один раз.
Портал явно фіксує твою адресу: сторінка сама питає її в api.ipify.org і
шле на GetIpAddress / test. Якщо VPN увімкнений тільки в браузері, то
monitor.js стукатиме з іншої адреси, ніж та, до якої прив'язана сесія, — і
його викинуть.
Саме це й сталося при першому захопленні: браузер бачив 84.245.121.110
(Словаччина), а Node виходив через 151.70.24.101 (Італія).
capture-session.js тепер сам порівнює обидві адреси й голосно попереджає про
розбіжність. VPN має бути системний, а не браузерний.
Перевірено на живих сесіях, по черзі:
| що пробували | результат |
|---|---|
| рідше опитувати (6 с → 60 с) | CALL_LIMIT на тому самому ~6-му виклику |
чергувати 9 різних serviceBranchID |
те саме |
унікальний URL на кожен запит (_=timestamp) |
те саме |
перечекати 10 хв після CALL_LIMIT |
наступний виклик = 401, сесія мертва |
Отже: сесія дозволяє близько шести звернень до
available-offices-service-date, і далі її розлогінює. Ні пауза, ні варіювання
запиту, ні очікування нічого не змінюють. Портал захищений саме від опитування,
і захист працює.
Підтвердження з іншого боку: браузер за весь візард викликає цей ендпоінт рівно один раз.
Що це означає на практиці: одне СМС ≈ 6 перевірок. Автоматичного монітора, який крутиться годинами, тут не побудувати.
Якщо викликів лише шість — не пали їх за шість хвилин. Розтягни на вікно:
CALL_BUDGET=6
BUDGET_WINDOW_MIN=60 # 6 викликів на годину = раз на 10 хвМонітор сам порахує інтервал. Найкраще запускати перед моментом, коли терміни зазвичай звільняються, — тоді шість перевірок накриють саме те вікно.
Найпідступніше з усього. Обидві ці відповіді приходять з HTTP 200:
| Тіло | Що насправді |
|---|---|
{"code":"401"} |
сесія мертва |
{"code":"500","message":"CALL_LIMIT"} |
ліміт запитів |
{"services":[...]} |
справжні дані |
Якщо дивитися лише на HTTP-статус — а саме так робила перша версія — мертва
сесія виглядає як звичайна порожня відповідь, і монітор нескінченно доповідає
«вільних немає». Тепер тіло розбирає src/portal.js, і це покрито тестами.
.../ecu-vysys/!ut/p/a1/…/res/id=available-offices-service-date/…?tida=0&language=sk&kodSluzby=ECUVYSYS2-PovoleniePobytuCudzincov
- метод
POST - тіло
data={"serviceBranchID":"<id послуги>","authValue":"false"} - відповідь
{"services":[...]}— порожній масив означає, що вільних термінів нема - тому
SLOT_JSON_PATH=services
ID послуг (з групи 217 «Termíny na oddelenia cudzineckej polície» і 242
«Dočasné útočisko») лежать у відповіді попереднього кроку — capture-session.js
їх запише в лог.
maxTime у відповіді на крок з особою — це не час смерті сесії. Перевірено:
пінги проходили і після нього. Схоже, то дедлайн на завершення бронювання.
pes.minv.sk віддає тільки листовий сертифікат — без проміжного
CA Disig R2I2 Certification Service. Браузер це не помічає, бо довантажує
проміжний сам через AIA. Node так не вміє, тому monitor.js падав з
UNABLE_TO_VERIFY_LEAF_SIGNATURE — при тому, що сертифікат абсолютно валідний.
Для порівняння, portal.minv.sk і www.minv.sk віддають повний ланцюг із трьох.
Полагоджено файлом certs/ca-disig.pem — він підвантажується автоматично і
дає Node той ланцюг, який мав прислати сервер. Корінь CA Disig Root R2 Node
довіряє й так, тому перевірка лишається повноцінною: це не
rejectUnauthorized: false і не вимкнена валідація.
Якщо колись впреться в той самий UNABLE_TO_VERIFY_LEAF_SIGNATURE на іншому
хості — скрипт сам підкаже, а сертифікат підставляється через EXTRA_CA_FILE.
- Node 24.12, git 2.52, Chromium для Playwright — стоять.
npm test— 11/11 зелених.monitor.jsпроти локального фейкового порталу: ловить слот, коректно вмирає на 403.browser-monitor.jsчерезreselect: смикає AJAX без перезавантаження, читає JSON, робить скріншот.capture-session.js: знаходить потрібний XHR серед решти, витягує куки й CSRF, пише робочий.env.captured.- Доступ до порталу з цього IP (Італія,
151.70.24.101): є, без 403 і без Cloudflare. VPN поки не потрібен — але капчу проходь з того ж IP, з якого потім пінгуватимеш. monitor.jsпроти живогоpes.minv.sk: TLS проходить, на підроблену сесію сервер дає 302, скрипт це правильно розпізнає як «сесію треба переробити».
- Не став
INTERVAL_MSнижче ~10 секунд. Швидше слоти не з'являться, а от рейт-ліміт або бан IP — цілком. 15 секунд це вже частіше, ніж встигає оновлюватися черга. .env*іcaptured/не комітяться (.gitignore) — там живі куки твоєї сесії, це фактично твій логін.captured/requests.jsonlмістить персональні дані — ПІБ, дату народження, номер документа, телефон, email і PIN зі СМС. Воно потрібне лише щоб зібрати.env. Зібрав — видали каталог.- Куки протухають. Зазвичай 15–30 хвилин без активності. Сам цикл пінгів
тримає сесію теплою, але вічно вона не проживе — перезнімай через
npm run capture. - Скрипт нічого не бронює автоматично. Він тільки кричить, коли слот з'явився; останній клік за тобою. Так і задумано: помилкове автобронювання спалить твою чергу.
monitor.js Варіант 1 — API-пінгер
browser-monitor.js Варіант 2 — Playwright
capture-session.js витягує сесію і генерує .env
src/detect.js визначення «є слот / нема слота»
src/alert.js біп, банер, Telegram
src/config.js читання .env
certs/ca-disig.pem проміжний CA, який pes.minv.sk забуває віддавати
test/detect.test.js тести детектора