Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

Termin-soft

Монітор вільних термінів на порталі словацької поліції (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).


Варіант 1 — API-пінгер

Крок 1. Дістати запит

Спосіб 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:

  1. F12 → вкладка Network, увімкни фільтр Fetch/XHR.
  2. Пройди wizard до кроку з датами.
  3. Знайди запит, який повертає дати (шукай termin, date, free в URL, або просто дивись, у чиїй відповіді сидить Nie sú momentálne dostupné...).
  4. Правий клік → Copy → Copy as cURL.
  5. Перенеси з нього в .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.

Крок 2. Запустити

npm run monitor

Раз на 60 секунд (+ джитер) летить той самий запит. Крапка в консолі = пінг без результату. Частіше не став — портал ріже за CALL_LIMIT, див. нижче. Коли з'явиться слот — банер, безкінечний біп і пуш у Telegram.

Не закривай вкладку з wizard'ом у браузері. Коли скрипт запищить, тобі треба буде за секунди клікнути «Далі» саме в ній.

Скрипт зупиниться сам і скаже про це, якщо сесія помре — і коли це видно по HTTP-статусу (401/403, редірект), і коли портал ховає це в тілі ({"code":"401"} при HTTP 200). Тоді знову npm run capture.


Варіант 2 — автоматизація браузера

Потрібен, якщо сесія Варіанта 1 живе занадто мало.

npm run browser
  1. Відкриється Chromium на START_URL.
  2. Проходиш капчу, вводиш СМС, доходиш до кроку з датами.
  3. Тиснеш ENTER у терміналі.
  4. Далі скрипт кожні 20 секунд змушує сторінку перепитати сервер — без перезавантаження.

Як саме він смикає сторінку — TRIGGER_MODE

Режим Що робить Коли вибирати
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=#stepContent

API_URL_PATTERN вартий того, щоб його заповнити: тоді скрипт читає JSON відповіді напряму, а не вгадує по тексту. Це набагато надійніше.

Коли слот знайдено — біп, скріншот у screenshots/, вікно виводиться наперед і браузер залишається відкритим, щоб ти одразу бронював.


Як він розуміє, що слот є

Порядок перевірок (src/detect.js):

  1. Фраза з NO_SLOTS_TEXT у відповіді → слотів нема. Регістр і діакритика не мають значення: nie su momentalne зловить і Nie sú momentálne.
  2. Фраза з SLOT_TEXT → слот є.
  3. JSON-масив дат (шлях SLOT_JSON_PATH, або перший знайдений масив) → непорожній = слот є.
  4. Нічого не збіглося → 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:

  1. Створи бота в @BotFather, візьми токен.
  2. Напиши своєму боту будь-що.
  3. Відкрий https://api.telegram.org/bot<ТОКЕН>/getUpdates, візьми chat.id.
  4. Впиши TELEGRAM_BOT_TOKEN і TELEGRAM_CHAT_ID у .env.

Пуш прилітає і на знайдений слот, і на смерть сесії.


Твій сетап: словацька сімка в телефоні, ПК далеко на італійському Wi-Fi

СМС. Словацька сімка в ЄС працює в роумінгу без доплат — код прийде миттєво. Проблем нема.

Італійський 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). Обійти її скриптом не вийде і не варто пробувати — саме тому обидва варіанти розраховані на те, що ти проходиш її руками один раз.

Сесія прив'язана до IP — це головна пастка

Портал явно фіксує твою адресу: сторінка сама питає її в api.ipify.org і шле на GetIpAddress / test. Якщо VPN увімкнений тільки в браузері, то monitor.js стукатиме з іншої адреси, ніж та, до якої прив'язана сесія, — і його викинуть.

Саме це й сталося при першому захопленні: браузер бачив 84.245.121.110 (Словаччина), а Node виходив через 151.70.24.101 (Італія).

capture-session.js тепер сам порівнює обидві адреси й голосно попереджає про розбіжність. VPN має бути системний, а не браузерний.

ГОЛОВНЕ: бюджет ~6 викликів на сесію, і його не обійти

Перевірено на живих сесіях, по черзі:

що пробували результат
рідше опитувати (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

.../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 у відповіді на крок з особою — це не час смерті сесії. Перевірено: пінги проходили і після нього. Схоже, то дедлайн на завершення бронювання.

Чому TLS ламався (виправлено, але варто знати)

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  тести детектора

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages