Разбор архитектуры локального клиента для агрегации вакансий, который я, senior backend-инженер, написал для себя — потому что искать работу через классические job-борды оказалось неожиданно трудоёмкой инженерной задачей. Внутри: фронтенд без сборки и без virtual DOM, маршрутизация с управляемым фокусом, клиентский API-слой поверх SSE, двухфазный сканер с детерминированным завершением, SSRF-envelope на DNS-pinning, локализация на 13 локалей с RTL, запуск всей системы одной командой и тестовая пирамида из 1543 кейсов. Отдельно — метод разработки: Spec-Driven Development и закрепление инвариантов тестами.

Проект: github.com/Fighter90/career-ops-ui

Базовый движок: github.com/santifer/career-ops

Роадмап (Issue #29): github.com/Fighter90/career-ops-ui/issues/29

Контекст и постановка задачи: почему сеньор пишет себе job-агрегатор

Я backend-инженер (PHP, Go). В какой-то момент я оказался в позиции, знакомой многим: активный поиск работы при полной занятости. И быстро упёрся в то, что сам процесс поиска устроен неэффективно — не «морально тяжело», а именно инженерно неэффективно.

Возьмём типичный сценарий на hh.ru. Поисковая выдача перемешивает релевантные роли с шумом: на запрос «Senior PHP» прилетают вакансии джунов, фронтендеров и «PHP-разработчик со знанием 1С». Одна и та же позиция публикуется повторно под разными URL — компания «освежает» вакансию, и она снова всплывает в выдаче как новая; отследить, что это репост роли, на которую ты уже откликался месяц назад, вручную практически невозможно. Зарплатные вилки скрыты или указаны в разных валютах и форматах. А главное — hh.ru покрывает только часть рынка: значительная доля интересных позиций живёт в ATS западных компаний (Greenhouse, Ashby, Lever, Workable, SmartRecruiters, Workday), на Habr Career, в GetMatch, GeekJob, Trudvsem — и у каждого источника свой API, своя схема данных, свой формат зарплаты и локации.

Отдельный слой проблемы — ИИ, который в 2026 году стоит по обе стороны воронки найма и на каждом её этапе. Со стороны работодателя резюме первым читает не человек, а ATS-скринер: он парсит PDF, матчит ключевые слова и режет кандидата до того, как рекрутёр вообще увидит отклик, — значит, резюме должно быть машиночитаемым (типографика, структура, эмбеддинг шрифтов), а не просто красивым. Со стороны кандидатов рынок захлестнула волна массовых AI-генерированных откликов: конкуренция на позицию исчисляется сотнями заявок, а рекрутёры в ответ закручивают фильтры и учатся детектировать «роботизированные» резюме — генеричный AI-текст без фактуры теперь работает против кандидата (эта боль зафиксирована прямо в issue #1 базового движка). Дальше по воронке — AI-скрининговые звонки, алгоритмическое ранжирование, автоответы-отказы без обратной связи. Игнорировать этот слой нельзя, но и слепо «генерить откликов побольше» — проигрышная стратегия. Разумный ответ — использовать ИИ не для массовости, а для точности: скоринг каждой вакансии против конкретного резюме до отклика, адаптация резюме под конкретную JD с сохранением фактуры, исследование компании перед интервью — при этом каждый отклик остаётся ручным и осознанным решением человека.

Если декомпозировать задачу честно, получается типовой ETL-конвейер над неоднородными источниками: агрегация вакансий из множества job-board API и ATS, нормализация и дедупликация разнородных схем в единую модель, скоринг каждой позиции против резюме и трекинг состояния отклика во времени. Ровно то, что backend-инженеры строят на работе — только над данными о собственном трудоустройстве.

Готовые продукты на рынке эту задачу не закрывают. Это либо энтерпрайзные ATS, ориентированные на рекрутёра, а не на кандидата, либо изолированные ленты отдельных бордов — каждая со своим кабинетом, без сквозной дедупликации и без скоринга под конкретное резюме. Кандидату остаётся Excel-таблица откликов и десяток открытых вкладок. Меня как инженера такое положение дел не устроило — и я просто сделал инструмент под себя.

career-ops-ui — локальный клиент, который агрегирует 41 источник в единую модель, выполняет оценку каждой вакансии под резюме, ведёт воронку откликов и генерирует PDF. Ключевое проектное требование — приватность: сервер слушает только loopback (127.0.0.1:4317); PII (резюме, история откликов, зарплатные ожидания) не покидает машину. Никаких облачных аккаунтов, телеметрии и авто-откликов. Клиент построен как companion поверх терминального движка santifer/career-ops и использует тот же файловый источник истины. Дальше — разбор подсистем, с акцентом на фронтенд.

Базовый движок: файловая модель состояния и мульти-CLI

В основе — santifer/career-ops, терминальный AI-движок поиска работы (под шестьдесят тысяч звёзд на GitHub; самоописание — «14 skill modes, Go dashboard, PDF generation, batch processing»). Движок оценивает каждую вакансию против резюме по шестимерной рубрике 0.0–5.0, генерирует адаптированные PDF-резюме и ведёт локальный трекер откликов.

Ключевое архитектурное свойство, которое я переиспользую: состояние хранится в плоских файлах. Резюме — cv.md, профиль (целевые роли, локация, дил-брейкеры) — config/profile.yml, трекер откликов — data/applications.md. СУБД как первоисточник отсутствует — и это осознанное решение, а не упрощение: состояние git-friendly и диффуемо (любое изменение трекера видно в git diff), переносимо между машинами простым копированием, читаемо человеком без специального клиента; индекс (при наличии) — производный кэш, который всегда можно перестроить из файлов.

Движок CLI-агностичен: в репозитории лежат изоморфные скилл-паки под .claude, .qwen, .grok, .kimi, .opencode. Эту модель-агностичность я воспроизвёл на уровне провайдеров оценки: Anthropic, Gemini, OpenAI, Qwen, OpenRouter, GitHub Models — выбор провайдера вынесен в настройки. Логика простая: LLM-провайдер — это зависимость с самым коротким жизненным циклом в проекте, привязываться к одному вендору в 2026 году неразумно.

Архитектурный инвариант, унаследованный жёстко: базовый движок — read-only. Клиент читает те же файлы, но запись в родителя выполняется исключительно по явному пользовательскому действию (POST /api/pipeline, POST /api/tracker, PUT /api/cv); ни один фоновый code-path в родителя не пишет. Это защищает пользовательские данные от повреждения багом фонового процесса и делает поведение системы предсказуемым: если файл изменился — это сделал пользователь.

Дополнительный источник требований — открытые issue движка: ATS-фильтрация AI-резюме (#1), отказ мейнтейнера вливать веб-дашборды в ядро (#12, ядро сознательно terminal-first), спрос на мульти-CLI (#14). #12 и определил решение строить отдельный клиент, а не патчить движок: форк с веб-морда внутри обречён на вечный конфликт с upstream, а companion-репозиторий живёт своим жизненным циклом.

Разделение engine/UI

Граница ответственности проведена явно: базовый движок — доменная логика и LLM-режимы; клиент — агрегация источников, оркестрация скана и презентация. Оба процесса читают общий файловый стейт; родитель read-only. Это даёт независимые жизненные циклы (движок остаётся terminal-first, клиент релизится в своём темпе) и единый источник истины без синхронизации между двумя БД — класс проблем «рассинхронизировались две копии состояния» отсутствует по построению.

Рис. 1. Разделение engine/UI: общий файловый источник истины, базовый движок read-only, сервер на loopback.
Рис. 1. Разделение engine/UI: общий файловый источник истины, базовый движок read-only, сервер на loopback.

Запуск одной командой

Отдельно про developer experience установки. Система состоит из двух репозиториев (движок + клиент), и «клонируй одно, клонируй второе внутрь, настрой ключи» — это барьер входа, который отсекает часть пользователей на первом же шаге. Поэтому весь бутстрап сведён к одной команде:

curl -fsSL https://raw.githubusercontent.com/Fighter90/career-ops-ui/main/bin/setup.sh | bash

Скрипт клонирует оба репозитория (родительский career-ops и career-ops-ui внутрь него), ставит зависимости, прогоняет диагностику (doctor) и поднимает сервер на http://127.0.0.1:4317 — вкладка дашборда открывается и выводится на передний план автоматически (отключается NO_OPEN=1 для headless/CI).

Для тех, кто предпочитает контролировать каждый шаг, тот же конвейер разобран на глаголы CLI:

git clone https://github.com/Fighter90/career-ops-ui
cd career-ops-ui
npm link                 # даёт команду `career-ops-ui` (или npx career-ops-ui <verb>)

career-ops-ui setup      # бутстрап: deps → doctor → run (SKIP_START=1 — остановиться до запуска)
career-ops-ui init       # интерактивный визард: выбор LLM-провайдера + ключ → в родительский .env
career-ops-ui doctor     # проверка Node / проекта / ключей / Playwright (exit 0 ⇔ всё required зелёное)
career-ops-ui run        # сервер на http://127.0.0.1:4317
career-ops-ui open       # открыть и поднять вкладку дашборда

init заслуживает отдельного абзаца, потому что это точка входа секретов. Визард предлагает выбор провайдера (Claude / Gemini / Codex-OpenCode / Auto с фолбэком Anthropic → Gemini); ключ вводится с подавленным echo — ничего не оседает в скроллбэке шелла — и записывается в родительский career-ops/.env через тот же валидированный путь, что использует вкладка API-ключей в #/config. Для CI есть неинтерактивная форма (career-ops-ui init --provider claude --anthropic-key … --yes). Выбранный провайдер выставляет LLM_PROVIDER, который уважают все live-маршруты оценки; сменить его можно в любой момент из UI без рестарта сервера.

Смысл этой обвязки не в удобстве ради удобства: диагностика doctor с бинарным exit-кодом — это тот же контрактный подход, что и в остальном проекте. Установка либо полностью зелёная, либо явно называет, что сломано.

Фронтенд: рантайм без сборки и без virtual DOM

Решение по фронтенду принято в пользу ванильного ES-модульного JavaScript без сборки: модули отдаются статикой, отсутствуют bundler, транспиляция и runtime-фреймворк. Обоснование инженерное, а не идеологическое, и складывается из трёх аргументов.

Первый — аудируемость. Клиент оперирует PII и выполняет egress в произвольные внешние эндпоинты; кодовая база, работающая с такими данными, должна быть полностью читаема как есть, без слоёв трансформации между исходником и тем, что исполняет браузер. Второй — supply chain. Каждая транзитивная зависимость фронтенд-стека — потенциальный вектор атаки на данные резюме; минимизация зависимостей сокращает эту поверхность радикально (у сервера их три: Express, js-yaml, multer). Третий — CSP. Отсутствие инлайнового кода позволяет держать строгую политику script-src 'self' без unsafe-inline — большинство фреймворковых экосистем такой строгости не переживают без ухищрений.

Цена решения понятна: нет реактивности из коробки, нет экосистемы компонентов, часть инфраструктуры пишется руками. Оправдана ли цена — один из вопросов к читателю в конце.

Маршрутизация и управление фокусом

Маршрутизация — hash-based SPA (router.js). Реализованы: таблица ALIASES для устаревших путей (напр. #/settings → #/profile, #/portals → #/config) — старые закладки пользователей не ломаются при переименовании маршрутов; отдельная вьюха __not_found__ — неизвестный hash резолвится в честную 404, а не молча в дашборд; router.current() срезает ?query до резолва маршрута.

Управление фокусом вынесено в focusNewView: после рендера фокус переносится на новый <h1> (fallback — на контент), и это выполняется на обоих путях рендера — успешном и ошибочном. Пользователь скринридера при навигации всегда оказывается в начале нового контента, включая страницы ошибок. Первый paint пропускается, чтобы перенос фокуса не конфликтовал со skip-link (WCAG 2.4.3, focus order).

Слой представления: прямое построение DOM

Virtual DOM отсутствует. Вьюхи (public/js/views/* — по модулю на маршрут) строят поддерево напрямую через хелперы-конструкторы элементов; форменные поля создаются через общий field()-хелпер, который связывает <label> и контрол через htmlFor/id либо aria-labelledby. Целостность связки покрыта тестом unbound-label-sweep: каждый htmlFor/aria-labelledby обязан иметь соответствующий id в том же файле — «оторванный» лейбл ломает CI, а не тихо деградирует доступность.

Локализуемый текст и aria-метки проставляются декларативно через атрибуты data-i18n и data-i18n-aria-label; при смене языка setLang повторно проходит по дереву и переписывает значения — без полного ре-рендера приложения, то есть смена локали не сбрасывает состояние форм и позицию скролла. Тяжёлые списки виртуализируются: вьюха pipeline при числе строк свыше ~1000 рендерит оконный слайс с scroll-листенером (порог и чистая оконная математика покрыты тестами pipeline-virtualize); таблица скана пагинируется по 200 строк (см. ниже).

Клиентский API-слой и потребление SSE

Сетевой слой инкапсулирован в api.js. API.stream — обёртка над EventSource с управляемым закрытием по терминальному событию (контракт done.final разобран в разделе о сканере). Буферные запросы идут через единый fetch-хелпер, который гуманизирует серверные ошибки и эскалирует сетевой сбой в role=alert-блок, отдельный от тела превью. Рендер markdown — UI.md(): это клиентская XSS-граница, выполняющая инлайн-разметку (bold/code/links, в т. ч. внутри blockquote) в санитизированный DOM. Пагинатор — UI.paginate (PAGE_SIZE, слайс по полному отсортированному набору, controls() для сводки диапазона). Подтверждения — focus-trapped UI.confirm() (нативный confirm() из проекта исключён — он неуправляем ни для стилизации, ни для a11y); UI.modal() при открытии гасит прогресс-тост (defence-in-depth от наложения слоёв).

Потребление SSE на вьюхе скана: runScanAll поднимает live-поллинг каждые 2.5 с и делает дополнительный refresh через 300 мс после терминального done — таблица обновляется во время скана без ручной перезагрузки. Лог-консоль скана — aria-live role=log, клавиатурно-скроллируемая; терминальные анонсы выведены в отдельный assertive-регион, чтобы важные события не тонули в потоке лога. Run-state дизейблит кнопку Scan и выставляет aria-busy; Stop закрывает активный EventSource; при сетевой ошибке — persistent role=alert-баннер с действием Retry.

Локализация на клиенте

i18n реализован в i18n.js + словари i18n-dict.<locale>.js (13 локалей, per-locale split-загрузка — пользователь тянет только свой словарь, а не все тринадцать). setLang выставляет document.documentElement.lang при boot и при смене языка, детектит navigator.language на старте, и персистит выбор в localStorage['career-ops-ui:lang']. Для ar выставляется dir="rtl" и применяется [dir="rtl"]-блок CSS, зеркалящий хром; при переходе на любую LTR-локаль dir сбрасывается в "ltr". Относительные временные метки — Intl.RelativeTimeFormat с активной локалью: «2 часа назад» на ru, «vor 2 Stunden»-эквиваленты на остальных — без самописных плюрализаций.

Состояние, кэш и предпочтения

Клиентское состояние — в localStorage: lang, theme, career-ops-ui:scan:favorites (избранные вакансии по URL), career-ops-ui:scan:saved-searches (именованные наборы фильтров). Подсистема scan-prefs.js валидирует кэш на чтении: повреждённый или вручную отредактированный JSON сбрасывается в пустое состояние, а не роняет вьюху — localStorage доступен пользователю через DevTools, значит это недоверенный вход. Кэш результатов скана сбрасывается на старте скана и дозаполняется по ходу. Детекция страны для country-фильтра — countries.js, консервативная (не угадывает; pure-remote и неразрешённые локации остаются под «All countries» — ложноположительная страна хуже отсутствующей). Клиентская фильтрация результатов — хелперы skills.js (salaryInRange, detectTech, detectLevel, rowMatches, computeFacets).

Доступность и CSP

Все обработчики — addEventListener; инлайновых on*=-атрибутов нет, что и позволяет держать script-src 'self' без unsafe-inline. Темизация — на CSS custom properties (дизайн-токены), а не на хардкод-значениях; light/dark персистится, prefers-reduced-motion учитывается. Мобильный дравер (<900px) скрывается реально: для компонентов с авторским display:flex|grid атрибут [hidden] — no-op, поэтому добавлено явное .sel[hidden]{display:none}. Таблицы доступны: th[scope=col], сортируемые заголовки — button-in-th с aria-sort и индикатором ▲/▼ (aria-hidden). Тач-таргеты соответствуют WCAG 2.5.5/2.5.8, фокус — :focus-visible, управляемый перенос фокуса не рисует лишний ring.

Бэкенд

Node.js, ESM. server/index.mjs монтирует роуты server/lib/routes/* (scan, pipeline, tracker, llm, openrouter, reports, runners, config, health, help, activity). Подсистемы server/lib/: safe-fetch.mjs (DNS-pinned egress), security.mjs, scan-sanitize.mjs, rate-limit.mjs, file-lock.mjs, http-json.mjs, runner.mjs (runNodeScript/streamNodeScript), paths.mjs (PATHS резолвится единожды на процесс — исключает класс багов, когда середина запроса видит другой корень проекта). PDF — generate-pdf.mjs (headless-chromium, A4, embedded-шрифты, ATS-нормализация типографики — сгенерированное резюме обязано парситься роботами-скринерами, а не только красиво выглядеть). CI — GitHub Actions, матрица Node 18/20/22 + CodeQL; единственный hard gate — ci.yml.

Сканер: два реестра адаптеров и двухфазный SSE

Рис. 2. Конвейер: scan → evaluate → apply → track. Над ним — детектор репостов, re-apply cooldown, compensation → pipeline.md.
Рис. 2. Конвейер: scan → evaluate → apply → track. Над ним — детектор репостов, re-apply cooldown, compensation → pipeline.md.

Все источники проходят единый конвейер: normalize → filter (title_filter.positive/negative) → dedup против data/scan-history.tsv + data/pipeline.md + data/applications.md → append в data/pipeline.md. Дедупликация против трёх файлов сразу решает исходную боль: вакансия, на которую уже был отклик, не всплывёт как «новая», под каким бы URL её ни перепубликовали.

Реестр auto-discovery и дублирующий fetch-реестр

Источники разведены на два реестра. server/lib/sources/registry.mjs — auto-discovery по meta-экспорту: драйвит дропдаун #/scan, GET /api/scan/sources и RU-диспатч. server/lib/portals/registry.mjs (ALL_ADAPTERS, hand-maintained) — то, что EN-сканер обходит при фетче: matches → buildEndpoint → fetch. Контракт: buildEndpoint(company) обязан вернуть строку-URL; falsy-результат resolveAdapter трактует как «нет матча» и выбрасывает источник из обхода.

Класс дефекта: источник присутствует в meta-реестре (виден в дропдауне), но отсутствует в ALL_ADAPTERS — и не фетчится. Пользователь выбирает источник, скан «успешно» завершается, вакансий нет — worst case тихой деградации. Инвариант закреплён adapter-registry.test.mjs: длина ALL_ADAPTERS, sorted-id, assert полного EN-набора. Регистрация нового EN-борда без правки обоих реестров приводит к красному CI.

Рис. 3. sources/registry (dropdown, auto-discovery) и portals/registry ALL_ADAPTERS (fetch, hand-maintained).
Рис. 3. sources/registry (dropdown, auto-discovery) и portals/registry ALL_ADAPTERS (fetch, hand-maintained).

Двухфазный SSE с детерминированным завершением

GET /api/stream/scan эмитит start / log / progress / done / error. При source=both фаза ATS и фаза региональных порталов идут одним стримом; завершение детерминируется флагом final в done:

source=both:
  done { final:false }   // конец ATS-фазы, поток НЕ закрывается
  start                  // ru-scanner стартует строго ПОСЛЕ done ATS
  done { final:true }    // терминальное событие

клиент API.stream:
  done.final === false  -> EventSource остаётся открытым
  done без поля final   -> закрытие (backward-compat со старым сервером)

Контракт устраняет дефект, при котором клиент закрывал EventSource на первом done и обрывал вторую фазу: пользователь получал только EN-результаты и не знал, что RU-фаза не выполнилась. Ветка «done без final» сохранена намеренно — клиент новой версии обязан корректно работать против сервера старой. Межстраничные паузы пагинации реализованы через abort-aware delay(ms, signal): таймаут и {once:true}-листенер на AbortSignal снимаются по первому сработавшему событию (нет orphan-таймеров при Stop).

Пагинация и устойчивость фетча

Символ MAX_STORED_RESULTS удалён: сканер хранит весь матч-сет — обрезка на этапе хранения означала бы, что фильтры пользователя работают по усечённым данным. scan.js использует pager.slice(sortedAll) с PAGE_SIZE=200 по полному отсортированному набору (не rows.slice(0,200)); фильтр сбрасывает пагинатор на страницу 1. Фетч fail-soft — один упавший источник не срывает скан по 40 остальным: fetchJson оборачивает не-JSON 2xx как non-JSON 2xx response from <url>; Workday CXS на 403/429/HTML возвращает [] с причиной (strict:true восстанавливает throw); title_filter матчит короткие акронимы по word-boundary (негатив coo не дропает «Coordinator»), пустые ключи триммятся до length-чека.

SSRF-envelope и санитайзеры

Сервер по своей природе фетчит произвольные URL вакансий, которые пользователь вставляет в pipeline — классическая поверхность SSRF. isValidJobUrl() гейтит /api/pipeline и .../preview: реджектит 127/8, RFC1918, CGNAT 100.64/10, link-local + IMDS 169.254.169.254, 0.0.0.0, IPv6 loopback/ULA/link-local, схемы file:/data:/javascript:, парные плейсхолдеры ${...}/{{...}}. Egress — только через safeGet():

safeGet(url):
  1. один DNS-lookup; резолв в private/loopback/IMDS -> reject (fail-CLOSED)
  2. PIN: коннект на залогированный IP — нет TOCTOU-rebind между lookup и connect
  3. на каждом редиректе цель ре-резолвится и ре-валидируется; cap 3 хопа
  4. тело усекается по opts.maxBytes (streaming-cap)

все 41 fetcher: redirect:'error'; per-tenant ATS пинят host анкоренным regex до фетча

DNS-pinning здесь — не паранойя: без пина между валидирующим lookup и фактическим connect остаётся окно для rebind-атаки, когда домен резолвится в публичный IP на проверке и в 169.254.169.254 на коннекте.

Санитайзеры разнесены по границам: XSS-ingress — stripDangerousMarkdown() + UI.md() (CV/markdown), sanitizeJobDescription() (JD), sanitizePathName() (slug: стрип leading-dots, drop slashes/NUL/control, 200-char cap). Egress в scan-history.tsv: normalizeScanScalar схлопывает \r \n \t \v \f U+2028 U+2029; sanitizeTsvField исключает инъекцию TSV-строки через newline и нейтрализует formula-injection-префиксы (= + - @) — историю сканов пользователи открывают в Excel, а вакансия с тайтлом =HYPERLINK(...) приходит из недоверенного интернета.

Граница зафиксирована в спеке как инвариант: cleanLlmMarkdown — не XSS-санитайзер; scan-sanitize — egress, а не XSS-граница. Смешение функций образует уязвимость («этот вход уже почищен» — тем санитайзером, который чистит другое), поэтому каждая граница именована и покрыта тестом.

HTTP-заголовки: script-src 'self' (без unsafe-*), frame-ancestors 'none', object-src 'none', nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin; присутствие CSP проверяется на loopback, ::1, localhost, 0.0.0.0 (security-headers.test.mjs). llmRateLimit — no-op на loopback, активен при HOST=0.0.0.0 (11-й запрос → 429, бакеты по IP): локальному пользователю лимиты не нужны, а выставленный в LAN инстанс уже нуждается в защите. Запись трекера — под withFileLock (тест на конкурентную запись).

i18n: 13 локалей, RTL и инвариант справки

Локали: en, es, pt-BR, ko, ja, ru, zh-CN, zh-TW, fr, pl, uk, da, ar. Гейты паритета: i18n-locale-files (snapshot + key parity), i18n-coverage, i18n-no-latin-leaks, i18n-no-personal-data (в словари не должен утечь ни один фрагмент реального резюме), lang-switcher-rtl. Справка — 13 markdown-бандлов (GET /api/help/<lang>) с инвариантом 19 H2 / 75 H3 в каждом (canonical-docs-coverage + help-ui), фиксирующим единую структуру справки между языками: перевод не имеет права молча потерять раздел.

Нетривиальный дефект: заголовок страницы health в pl/da фолбэчил в английский («Health» вместо «Kondycja»/«Systemtilstand»). Гейт no-latin-leaks срабатывает только на нелатинских локалях, поэтому латинские pl/da проходили его — автоматика структурно слепа к этому классу утечек. Часть i18n-проверок требует ручной сверки с эталоном.

Spec-Driven Development

Метод: первоисточник — спека, не код. SDD (Spec-Driven Development) в данной реализации — это релизный документ, фиксирующий наблюдаемые инварианты («что и где видит пользователь») и гейты. Код пишется под инварианты, каждый инвариант получает тест-лок, релиз подписывается только при зелёных гейтах. Для solo-проекта это замена командного ревью: дисциплина, которую в команде обеспечивают процессы и коллеги, здесь обеспечивается документом и автоматикой. Структура спеки фиксирована:

Секция

Содержимое

§0 Gates

npm test ≥ N · coverage ≥ floor · Playwright locale-sweep ×13 · E2E · CI-матрица

§1 Footguns

методологические инварианты теста (grep маскирует exit-code; PATHS-once; raw-path SSRF — через curl --path-as-is)

§2 Deltas

изменения релиза; каждая строка привязана к экрану/эндпоинту

§3 Security

CSP, SSRF, XSS-границы, rate-limit, file-lock

§4–§6

функционал по страницам · cross-cutting controls · i18n-приёмка ×13

§7–§8

docs/branding/release mechanics · exit criteria

Инвариант метода: каждая строка спеки обязана быть реализуемой, а не декларативной. Формулировка «Enter на non-URL → #/scan с предзаполненным фильтром; если активен #/scan — форс-ре-рендер, чтобы one-shot-prefill не утёк в следующий визит» одновременно реализуема и верифицируема. Строки вида «поиск должен работать быстро и удобно» в спеку не допускаются — их невозможно ни закодить однозначно, ни проверить.

Рис. 4. Цикл SDD: спека → код → тест-лок → гейты → релиз → дельта.
Рис. 4. Цикл SDD: спека → код → тест-лок → гейты → релиз → дельта.

Доктрина релизов — one-fix-per-release (приоритет HIGH → MEDIUM → LOW): малый diff обеспечивает ревьюабельность и предотвращает накопление регрессий — при откате версии откатывается ровно один фикс, а не пачка перемешанных изменений. Пример: фолбэк health.title на pl/da закрыт отдельным релизом с тестом-локом. Перманентные инварианты вынесены в ledger REGRESSION-FINAL.md, переживающий отдельные релизы: спека версии описывает дельту, ledger — то, что не имеет права сломаться никогда.

Тестовая пирамида: 1543 кейса и агентный E2E

v1.84.0: 1543 node:test (~180 файлов), покрытие ~96% строк / ~87% веток, Playwright (85 кейсов), 20 smoke + 23 comprehensive E2E, CI-матрица Node 18/20/22 + CodeQL. Маппинг «инвариант спеки → тест-лок» буквальный: каждый источник — sources-<slug>.test.mjs; SSRF — url-validation + ssrf-redirect-rebind; изоляция рантайма — paths-once + test-root-isolation (CI-тест бутстрапит собственный CAREER_OPS_ROOT и грузит носители paths.mjs через динамический import() внутри before() — тесты не имеют права видеть реальные данные пользователя).

Рис. 5. node:test → Playwright (locale-sweep ×13) → агентный E2E.
Рис. 5. node:test → Playwright (locale-sweep ×13) → агентный E2E.

Верхний слой — агентный E2E, и это самая нестандартная часть пирамиды. Спека-регрессия передаётся AI-агенту, исполняющему её в реальном браузере: обход всех маршрутов на 13 локалях (матрица «23 × 13 = 299 ячеек»), детект непереведённых ключей с дизамбигуацией ложного срабатывания на литерале profile.yml, live-скан с maxPerSource, композиция фильтров (страна × формат), панель репостов, проверка SSRF-гейта реальными fetch на loopback/IMDS — с формированием отчёта и вердикта GO/NO-GO. Playwright проверяет то, что автор догадался заскриптовать; агент, идущий по спеке, находит то, чего в скриптах нет. Это второй контур ревью, отсутствующий у solo-разработки.

Методологические footguns, закреплённые в §1 спеки: grep маскирует exit-code (capture $?); [hidden] — no-op против авторского display:flex|grid; тела ошибок сервера — English-by-policy; cross-realm vm-массивы спредить [...] перед deepEqual; query-параметр окна репостов — window, а не windowDays (правило: assert behaviour, not filenames). Каждый пункт — оплаченный дефектом урок, зафиксированный, чтобы не платить дважды.

Эволюция релизов: 19 → 41 адаптер

Рис. 6. Каждая версия — зелёный гейт.
Рис. 6. Каждая версия — зелёный гейт.

v1.76 (6 ATS + снятие лимита результатов) → v1.77 (датский, 13-я локаль) → v1.78.x (country-фильтр, ребрендинг, scan auto-refresh, фикс health.title pl/da) → v1.79–1.82 (We Work Remotely, Teamtailor, NoDesk → 41 адаптер) → v1.83 (repost-детектор, зачистка служебного комментария из parentVersion) → v1.84 (re-apply cooldown, compensation → pipeline.md).

Роадмап — v1.59 → v2.13: онбординг и CV→profile auto-fill, карточка-«экран решения», two-pager предпочтений (питает скоринг), мок-интервью, human-approved авто-отклики (человек утверждает каждый — принцип «никаких авто-сабмитов» сохраняется), Chrome-расширение, интеграции (Notion/calendar/email), CV Studio с Word-экспортом и anti-robotic-чеком (ответ на issue #1 движка), scam-скрининг, persistent-agent с памятью.

Интерфейс

Кокпит в светлой теме (локаль ru). Дашборд агрегирует состояние конвейера; страница скана несёт фильтры и таблицу результатов; панель детектора репостов работает поверх истории сканов.

Дашборд «Командный центр»: стат-карточки (заявки / pipeline / отчёты / средний score), быстрые действия, глобальный поиск с бейджем Enter.
Дашборд «Командный центр»: стат-карточки (заявки / pipeline / отчёты / средний score), быстрые действия, глобальный поиск с бейджем Enter.
Страница скана: панель фильтров (поиск · формат · зарплата · источник · страна · «опубликовано за»), сохранённые поиски, избранное, summary-чипы «N new / M matching», таблица с бейджами «↑ буст».
Страница скана: панель фильтров (поиск · формат · зарплата · источник · страна · «опубликовано за»), сохранённые поиски, избранное, summary-чипы «N new / M matching», таблица с бейджами «↑ буст».

Выводы

Проект начинался как утилитарный: нужно было найти работу, а существующие инструменты решали задачу плохо. Но в процессе он стал демонстрацией прикладных архитектурных решений: фронтенд без сборки и без virtual DOM с декларативной i18n и управляемым фокусом; чистая граница engine/UI поверх read-only-источника; бутстрап двух репозиториев одной командой с диагностируемым результатом; два реестра адаптеров с тестами на согласованность; SSRF-envelope на DNS-pinning; разнесённые санитайзеры на каждой границе; детерминированный двухфазный SSE; локализация на 13 локалей с RTL; тестовая пирамида из 1543 кейсов под спеко-ориентированным процессом.

Отдельный вывод — методологический. SDD + one-fix-per-release + агентный E2E оказались рабочей заменой командных процессов для solo-разработки: инварианты живут в документе, дисциплину обеспечивают гейты, вторую пару глаз — агент. Насколько это масштабируется — открытый вопрос, но на горизонте двадцати с лишним релизов регрессий, дошедших до пользователя, не было.

Вопросы к читателю

  1. Дублирующий fetch-реестр адаптеров — оправданное разделение ответственности (display vs fetch) или smell, требующий единого источника истины?

  2. SDD + one-fix-per-release для solo-проекта — оправданная дисциплина или оверинжиниринг?

  3. Companion-архитектура поверх стороннего read-only-движка — устойчивый паттерн; как страховаться от дрейфа upstream-схемы?

  4. Фронтенд без сборки на нетривиальном объёме UI — обоснованный размен или техдолг?

  5. Какие подсистемы вы бы спроектировали иначе?