Журнал изменений

GitHub

Каждое значимое изменение, релиз за релизом. Тот же файл публикуется в репозитории и переведён на все 19 языков.

[1.236.0] — 2026-09-18

Паритет с родителем — career-ops main @ 6a9c84c (VERSION 1.33.0, 128 коммитов). Два новых источника и три зеркальных исправления — два из них здесь незаметно теряли вакансии.

Добавлено

Два источника сканирования, 92 → 94 (89 EN + 5 RU). Python.org Jobs (pythonorg) читает публичную ленту доски; атрибуция работодателя обязательна, поэтому строка, в заголовке которой не назван работодатель, отбрасывается, а не восполняется именем записи портала. Generalist World (generalist-world) скрейпит карточки доски и громко падает на разметке, которую не может разобрать — карточка изменившейся формы выбрасывает исключение вместо того, чтобы читаться как пустая доска, а именно этот отказ и делает скрейпинговый источник ненадёжным. Оба появляются в фильтре Source на #/scan, оба закреплены за хостом (заякоренное регулярное выражение, только HTTPS), и оба насквозь проверены наборами с фейковым транспортом (43 и 36 случаев).

Исправлено

Вакансия Workday, открытая в нескольких городах, отбрасывалась фильтром локации. Списочный endpoint Workday отвечает на многолокационную вакансию счётчиком там, где любая другая вакансия несёт место: "53 Locations". Это не локация, а buildLocationFilter сопоставляет по регистронезависимой подстроке — поэтому вакансия, открытая в Остине и ещё в 52 городах, не совпадала ни с одной записью allow: [austin] и отбрасывалась, тогда как та же вакансия, указанная в одном городе, проходила. Замерено выше по течению на трёх тенантах: 53 из 291 вакансии были такими заглушками — то есть это обычный случай, а не краевой. Настоящие места вакансии существуют только в CXS-документе detail, поэтому каждая заглушка теперь стоит один дополнительный GET к нему, с потолком 200 на запись — сверх потолка заглушка остаётся видимой, а не подделывается, поскольку неразрешённая вакансия не должна читаться как разрешённая. Неудачный или безместный detail оставляет вакансию ровно такой, какой её вернул список: видимая "53 Locations" честно говорит о том, что не удалось разрешить, тогда как обнулённая локация молча прошла бы любой фильтр. Тот же GET несёт и startDate, поэтому обогащённая вакансия датируется точно, а не относительной прозой списочного endpoint ("Posted 30+ Days Ago" → undefined); всё, что не является голым YYYY-MM-DD, оставляется как есть, а не прогоняется через Date.parse, чей неISO-фолбэк определяется реализацией. Указанный уровень больше не схлопывает две заявки в один репост. "Insurance Specialist II" — это собственное заявление работодателя о том, что эта заявка не сиблинг другой: другая вилка оплаты, объём и номер заявки под одним базовым названием. Токенизатор этого не видел: w.length > 3 отбрасывает каждую римскую цифру вплоть до VIII и каждую одиночную цифру, поэтому "Insurance Specialist" и "Insurance Specialist II" токенизировались одинаково, давали идеальный коэффициент Жаккара, и детектор репостов помечал вторую как переразмещение первой. В role-matcher.mjs web-ui обработки уровня не было вовсе, поэтому переносится весь механизм родителя целиком: уровни извлекаются со свёрткой римских и арабских цифр в одно число ("Nurse II" и "Nurse 2" — одно и то же заявление, а именно эта пара прежде ошибочно различалась), два указанных уровня обязаны совпасть, а уровень, указанный только на одной стороне, трактуется как вольный рерайт если только каждое название не несёт ещё и слово, которого нет у другого, — это уже два расхождения, а не одно украшение. Доска Avature, закреплённая за фильтром, всё равно обходила всю глобальную доску. Поисковый интерфейс Avature генерирует непрозрачные параметры фасетов (?42386=[812132] — это фильтр по стране), и оператор, закрепивший в portals.yml суженную доску, ожидает, что скан обойдёт именно её. resolveSearch пересобирал URL как ${origin}${path} и напрочь отбрасывал строку запроса — поэтому каждый запрос, включая первый, обходил всю доску тенанта целиком: больше страниц, больше запросов и вакансии, которые оператор сознательно отфильтровал. Теперь фасеты едут в каждом постраничном запросе, а собственный jobOffset применяется последним, чтобы случайный одноимённый фасет никогда не мог перехватить пагинацию.

Безопасность

devalue 5.8.1 → 5.9.2 (транзитивно через astro), закрывает единственное открытое уведомление Dependabot — отказ в обслуживании на некорректном вводе. Зависимость только сайта; рантайм-код не менялся.

Примечания

Не перенесено, каждое — с указанием причины. Родитель выдал lever таймаут 30с, а manfred — 25с против общего умолчания в 10с — это не-порт: транспорт сканирования web-ui и так допускает 60с (DEFAULT_SCAN_TIMEOUT_MS), так что у обеих досок здесь запаса больше, чем даёт та правка там. Удвоенный префикс careers- у iCIMS здесь тоже невозможен: web-ui берёт origin тенанта прямо из api/careers_url и никогда не строит хост из имени тенанта; новый обход запасных хостов у родителя — это функциональность под его более рыхлую конфигурацию, а не починка дефекта. deutschebahn и manfred получили повтор при временном обрыве — на фоне потолка web-ui в 60с это гораздо менее насущно, поэтому взято на заметку, а не перенесено: у будущего таймаута на долгом обходе будет известное первое, что стоит попробовать. Всё остальное в дельте из 128 коммитов — это CLI, верификаторы, дашборд или web/ (собственный фронтенд родителя): liveness-browser, check-jd-archive, verify-ats/verify-portals/verify-cv-facts, update-system, set-status, audit-portals, discover-ats, scaffolder, test-all, манифесты плагинов. Расхождения форка перепроверены после пула: кириллическое исправление \p{L} в providers/telegram-channel.mjs, собственный providers/telegram.mjs форка, hermes в web/src/lib/clis.ts. Апстрим career-ops-hq/main смержен полностью (0 недостающих коммитов). 3066 → 3164 тестов; справка теперь указывает 94 адаптера (89 EN + 5 RU) во всех 17 бандлах, а структура заголовков не изменилась — 32 H2 / 122 H3.

[1.235.0] — 2026-09-16

Паритет с родителем — career-ops main @ 68e6b94 (VERSION 1.33.0, 42 коммита). Два зеркальных исправления; одно из них здесь незаметно теряло вакансии MokaHR.

Исправлено

Вторая вакансия с той же доски MokaHR исчезала вместо того, чтобы добавиться. url-key.mjs сводит URL вакансии к ключу сравнения и безусловно отбрасывал фрагмент — разумно, поскольку почти каждый фрагмент носит чисто оформительский характер (#apply, #section-2). MokaHR — исключение в нашем собственном реестре: её вакансии имеют вид …/social-recruitment/{tenant}/{id}#/job/{n}, где все вакансии одного тенанта делят один и тот же путь, а идентификатор вакансии существует только во фрагменте. Поэтому все они нормализовались к одному ключу. Замер до исправления: вставьте в конвейер две разные вакансии MokaHR — и на выходе останется одна строка — без ошибки, без предупреждения, незаметно; фильтр свежести сканера (en-scanner.mjs) читает тот же ключ, поэтому стоило одной вакансии этого тенанта попасть в историю сканирования, как все остальные отбрасывались как «уже виденные». Теперь normalizeUrl продвигает распознанный маршрут #/job/{id} или #/jobs/{id} в ключ сравнения (только для запроса) до того, как фрагмент отбрасывается — MokaHR сохраняет своё устоявшееся имя mokahr_job_id, чтобы ранее записанные ключи оставались сравнимыми, а любой другой хост получает внутреннее _career_ops_fragment_job_id. Продвинутый идентификатор добавляется, а не устанавливается: URL, уже несущий этот параметр, хранит идентификатор другой вакансии, и перезапись слила бы ровно то, ради разделения чего этот ключ существует. Каждый нераспознанный фрагмент по-прежнему схлопывается — и это та половина, что и так была верна. accept-encoding теперь фиксируется на каждом запросе к источнику. Если заголовок не задан, Node согласует всё, что предлагает его сборка undici; начиная с Node ≥ 23 в это входит zstd, а zstd-ответ amazon.jobs возвращается обрезанным ровно на 1024 байтах со статусом 200 — так что сбой выглядит как Unterminated string in JSON at position 1024, будто это испорченный API, а не проблема транспорта. Теперь каждый запрос отправляет gzip, deflate, br. На наших рантаймах это превентивная мера, а не починка: Node 18/20/22 — матрица CI и сервер (22.22.1) — отправляют gzip, deflate, что проверено напрямую, а не предположено, так что здесь ничего не обрезалось; сегодня меняется то, что теперь предлагается br, который эти версии по умолчанию не запрашивают, но корректно декодируют. Умолчание применяется регистронезависимо, и именно эту часть стоило проверить: имена заголовков регистронезависимы, а fetch объединяет две одноимённые записи в одно значение через запятую, поэтому вызывающий код, передающий Accept-Encoding: identity, иначе получил бы отправленным gzip, deflate, br, identity.

Примечания

На границе fetchImpl заголовки остаются обычным объектом, тогда как родитель передаёт в fetch экземпляр Headers. Это осознанное расхождение, а не недосмотр: fetch-хелперы web-ui принимают инжектируемый fetchImpl, а 45 наборов тестов источников читают opts.headers['User-Agent'] из того, что они перехватывают, — у экземпляра Headers нет доступа по скобкам, поэтому добросовестно перенесено именно поведение (регистронезависимая перезапись, приоритет вызывающего кода), а не реализация. Объект вызывающего кода копируется, а не мутируется, поскольку несколько источников делят одну и ту же константу заголовков. Сопутствующее исправление родителя — применение User-Agent по умолчанию регистронезависимо — здесь не переносится: web-ui вообще не подставляет UA по умолчанию, каждый источник задаёт тот, что требует его доска, поэтому нет умолчания, с которым мог бы столкнуться User-Agent вызывающего кода. Два новых набора тестов и три новых случая фиксируют эту пару: tests/http-accept-encoding.test.mjs (точное зафиксированное значение, каждый из четырёх fetch-хелперов и переопределение вызывающим кодом в трёх вариантах регистра — подтверждая, что выживает единственный ключ accept-encoding, тогда как баг слияния порождал два) и tests/parsers-pipeline-hash-route.test.mjs, который проверяет видимый пользователю стык, а не сам ключ: обе вакансии MokaHR должны дойти до конвейера, повторная вставка одной и той же вакансии в четырёх вариантах написания по-прежнему должна схлопываться в одну строку, а оформительский фрагмент не должен становиться идентификатором. Все они были подтверждённо красными до исправления. Сама фиксация живёт на уровне транспорта, а не только в этих хелперах: 25 из 92 источников вызывают внедрённый fetchImpl напрямую и через хелперы вообще не проходят, поэтому её применяет makeTimeoutFetch — обёртка, которую оба сканера действительно внедряют, и единственная точка прохода, общая для всех источников. (Родителю эквивалент не нужен: providers/_http.mjs — его единственный транспорт.) Структурная проверка теперь роняет CI, если новый источник вызывает fetch напрямую вместо переданного ему транспорта, а поведенческий кейс прогоняет greenhouse — один из этих 25 — через обёртку и доказывает, что заголовок доходит до провода. Не перенесено, с указанием причины: liveness-browser.mjs (защита от повторного использования headed-Playwright — web-ui не управляет headed-браузером), check-jd-archive.mjs (в web-ui нет соответствующего relay), test-all.mjs (harness родителя; расхождение форка vpFixtureEnv сохранено нетронутым), web/ (собственный фронтенд родителя, не зеркалируется), манифесты плагинов и SIGNATURES.md (существуют только в репозитории). batch/batch-runner.sh (амперсанды экранированы до интерполяции sed) и jd-skill-gap.mjs (резолвит cv.md относительно корня данных) являются ретрансляцией только для чтения: web-ui обращается через шелл к копиям родителя, поэтому оба ложатся без изменений кода здесь, а правка в jd-skill-gap делает релей /api/jds/:id/skill-gap более точным: cv.md теперь резолвится от корня данных, а не от той директории, в которой был запущен процесс. Расхождения форка перепроверены после пула: кириллическое исправление \p{L} в providers/telegram-channel.mjs, собственный providers/telegram.mjs форка, hermes в web/src/lib/clis.ts. Число источников не изменилось, 92 (87 EN + 5 RU), поэтому справка не тронута. 3042 → 3066 тест.

[1.234.0] — 2026-09-15

Паритет с родителем — career-ops main @ 56cce8f (VERSION всё ещё 1.32.0, что на 58 коммитов позже 1.32.0). Одно зеркальное исправление, применённое к 15 источникам, плюс защита от его повторения.

Исправлено

Один-единственный некорректный идентификатор вакансии больше не опустошает всю страницу. encodeURIComponent выбрасывает URIError на одиночном суррогате UTF-16, а JSON-полезная нагрузка может его нести — JSON.parse('"\\uD800"') сохраняет его как есть. Пятнадцать источников собирали URL каждой вакансии из id или slug, полученных от хоста, внутри цикла разбора, поэтому одна плохая вакансия прерывала весь цикл и теряла все вакансии на этой странице; ничего не попадало в лог как отказ источника, потому что исключение разворачивало стек ещё до того, как catch на уровне компании успевал что-либо зафиксировать. Родитель исправил это в providers/_safe-url.mjs (safeEncodeURIComponent → null при URIError, в остальных случаях — повторный выброс) и пропустил через него id в каждом цикле, отбрасывая при null лишь одну вакансию. Этот хелпер теперь называется server/lib/sources/_safe-url.mjs, и то же изменение внесено в тринадцать зеркалированных источников — alibaba, arbeitsagentur, bamboohr, feishu-jobs, garena, jibeapply, manfred, meituan, mokahr, phenom, thehub, tkms, vdab — а также в два источника, существующих только в web-ui, которые новая защита выявила при первом же прогоне, jobstreet и trudvsem, — у них была та же форма цикла и тот же скрытый throw. Область применения выбрана осознанно: через хелпер проходят только id, полученные из API. Значения, производные от конфигурации, сохраняют строгое кодирование и падают громко — office у garena (сегмент из portals.yml) остаётся на выбрасывающем исключение urlSegment, тогда как её же API-id получает возвращающий null idUrlSegment; corpName у csod и провалидированный SLUG_RE slug у 4dayweek внесены в список исключений поимённо, с указанием причины. Резервное восстановление заголовка у rheinmetall могло упасть на некорректном href. Когда у карточки нет заголовка, он восстанавливается из slug в URL через decodeURIComponent, который выбрасывает исключение на некорректной percent-последовательности в спарсенном href — та же форма «одна плохая строка убивает всю страницу», только на стороне декодирования. Теперь декодирование выполняется защищённо, с откатом на исходный slug. Реестр источников пропускает файлы с префиксом _. Автообнаружение импортировало каждый .mjs в server/lib/sources/, кроме себя самого, и предупреждало о любом файле без export const meta — так что хелпер порождал предупреждение при старте. Принята конвенция родителя: ведущее подчёркивание означает не источник; такие файлы не импортируются, и предупреждений по ним не возникает. Необъявленный файл без meta по-прежнему получает предупреждение, и тест обнаружения теперь проверяет обе половины (подтверждённо красный на старом фильтре). Скрипт сборки сайта sync-assets.mjs считает файлы адаптеров на диске по тому же правилу, так что хелпер больше не срабатывает на его защиту от тихой потери источника.

Примечания

tests/sources-url-encoding-surrogate.test.mjs зеркалирует набор родителя в трёх частях: контракт хелпера (парный суррогат — обычный эмодзи — по-прежнему кодируется; throw из собственного toString значения — это баг вызывающего кода, и он пробрасывается дальше), поведение каждого из пятнадцати источников по отдельности (один плохой id + один чистый id → без throw, чистый сохраняется, плохой отбрасывается, каждый прогоняется через экспортируемый чистый парсер источника либо, для thehub, через его fetcher с фейковым транспортом), и защита на уровне источников: каждый переведённый файл обязан импортировать хелпер и вызывать его, ни один файл не может ссылаться на него без импорта, и ни один непроверенный файл не может строить URL вакансии голым encodeURIComponent — новый источник со старой формой падает в CI с указанием номера строки-нарушителя. Все пятнадцать посистемных случаев были подтверждённо красными до переноса. Всё остальное в дельте родителя не перенесено, с указанием причины: batch/batch-runner.sh --cli — флаг только для CLI (web-ui не вызывает batch-runner через шелл); invite-match.mjs теперь резолвит set-status.mjs от корня кода — web-ui его не ретранслирует; company-funded.mjs получил то же изменение safe-url внутри себя, а форма его вывода --json, которую парсит /api/company-funded, не изменилась; test-all.mjs лишь сдвинул бюджет таймаута (расхождение форка vpFixtureEnv сохранено нетронутым); web/ — это собственный фронтенд родителя, он не зеркалируется; AGENTS.md/SIGNATURES.md существуют только в репозитории. Расхождения форка перепроверены после пула: кириллическое исправление \p{L} в providers/telegram-channel.mjs, собственный providers/telegram.mjs форка, hermes в web/src/lib/clis.ts. Число источников не изменилось, 92 (87 EN + 5 RU), поэтому справка не тронута. 3022 → 3042 теста.

[1.233.2] — 2026-09-10

Исправлено — две находки CodeQL высокой серьёзности в пути сохранения #/config. Поведение не менялось.

Исправлено

Две находки CodeQL js/remote-property-injection высокой серьёзности, закрытые тем, что существовавший инвариант сделан структурным, а не декларируемым. POST /api/config переносит сохранённые значения в process.env, чтобы не требовался перезапуск, и этот цикл шёл по Object.entries(safe) — по объекту, собранному из тела запроса, — с явной защитой от __proto__ / constructor / prototype внутри. Код был безопасен: safe наполняется двадцатью строками выше обходом KNOWN_KEYS и копированием только их, так что имя свойства никогда не контролировалось пользователем. Но сам по себе цикл читается как «присвоить свойству, имя которого пришло из объекта, собранного по телу запроса», а перенести это ограничение через два цикла анализатор не может. Теперь цикл идёт прямо по константному массиву, доставая значение из safe, и имя свойства доказуемо является литералом уровня модуля. Защита от прототипных ключей ушла вместе с ним: в жёстко заданном списке имён в верхнем регистре её быть не может, а проверка невозможного случая — шум. Поведение идентично; даты находок (9 сентября) объясняются лишь тем, что v1.232/v1.233 сдвинули номера строк, а не новизной цикла.

Примечания

Альтернативой было отклонить обе как ложноположительные — в репозитории для этого есть заведённая практика. Здесь предпочтительнее починить: утверждение в комментарии стоит меньше, чем форма, которую и анализатор, и следующий читатель могут проверить, не прослеживая ограничение через двадцать строк. Тест, закреплявший прежнюю защиту, был грепом по тексту исходника в поисках буквального выражения k === '__proto__' || …, а это доказывает наличие строки, а не безопасность маршрута. Он заменён двумя более сильными: структурным — что цикл доходит до process.env[k], обходя KNOWN_KEYS, и что он не обходит объект, построенный по запросу (подтверждённо падает на старой форме); и поведенческим в config-endpoint.test.mjs, который отправляет __proto__, constructor, prototype и незарегистрированное имя сырым JSON — литерал объекта не годится, поскольку { __proto__: … } в исходнике задаёт прототип, а не создаёт свойство, так что JSON.stringify отправил бы {} и тест ничего бы не доказал. 3021 → 3022 теста.

[1.233.1] — 2026-09-10

Исправлено — руководство не описывало элемент, добавленный в v1.233.0, а тот же раздел нёс указания, переставшие быть верными 200 релизов назад.

Исправлено

DOC-1 — элемент, появившийся в v1.233.0, нигде в руководстве не описан. Каждый выпадающий список теперь начинается с пункта «Использовать умолчание (…)», который выражает состояние, невыразимое в интерфейсе прежде: ключ не задан. Отличие от выбора того же значения из списка ниже невидимо и существенно — «Использовать умолчание (llama3.2)» не пишет ничего и продолжает отдавать вам умолчание проекта по мере его изменения, тогда как явный выбор llama3.2 записывает OLLAMA_MODEL=llama3.2 и молча оставляет вас на старом значении, когда умолчание сдвинется. Это ровно тот тихий дрейф, ради которого делались CONFIG-2 и CONFIG-3: механику починили в коде, а прочитать о ней было негде. Три пункта добавлены в существующий список ### Behavior во всех 17 бандлах — про сам пункт, про правило «поле, которого вы не касались, не записывается», и про то, что тост считает и удаления. Гейты по заголовкам этого поймать не могли: они считают H2/H3, а покрытие — не счётчик. DOC-2 — строка таблицы ### Recognized keys не называла ключа, а единственное конкретное указание в ней было неверным. В колонке «Key» стояло (server uses default UA) — пояснение в скобках там, где место имени ключа, — и описывало HH_USER_AGENT, который v1.19.0 убрал из KNOWN_KEYS и из интерфейса. Дальше строка предлагала зарегистрировать приложение на dev.hh.ru и подставить его UA, что не даёт ничего с v1.65.0, когда адаптер hh.ru перешёл на чтение публичного сайта поиска с собственным браузерным User-Agent. Ещё два упоминания были столь же устаревшими: пункт в Optional checks, описывающий health-строку, удалённую в v1.28.1, и troubleshooting-строка, повторяющая совет про dev.hh.ru. Все три убраны из всех 17 бандлов. Troubleshooting-строка при этом переписана, а не удалена, потому что половина её верна: с июля 2026 hh.ru отдаёт HTTP 451 не-российским адресам, так что российский домашний IP действительно нужен; неверными были шаг с регистрацией и ссылка на несуществующий ключ. Таблица ключей теперь говорит, чем она является. ### Recognized keys перечисляет 6 ключей из 45, тогда как заголовок обещает реестр; провайдерские семейства при этом описаны прозой в том же разделе. Одна фраза ×17 теперь прямо называет её выборкой и указывает на #/config как на полный сгруппированный список. Ключи не добавлялись — это снимает двусмысленность, а не раздувает таблицу.

Примечания

tests/help-recognized-keys.test.mjs — тот самый гейт, которого не хватало. Он сверяет первую колонку каждой справочной таблицы, действительно перечисляющей ключи, с живым KNOWN_KEYS, падает на любом бандле, всё ещё упоминающем dev.hh.ru, и на любом, где не описан пункт «Использовать умолчание». Сужение области важно: в бандлах есть и другие таблицы, у которых первая колонка — заглавное слово в обратных кавычках (PDF в матрице экспорта), поэтому таблица считается ключевой, только если хотя бы одна её собственная строка называет настоящий ключ. Проверено на бандлах до правки: все три случая красные. 3018 → 3021 тестов; справка остаётся 32 H2 / 122 H3 во всех 17, потому что всё легло внутрь существующих заголовков.

[1.233.0] — 2026-09-10

Исправлено — три находки в пути сохранения #/config. CONFIG-3 порождён моей же правкой v1.232.1.

Исправлено

CONFIG-3 — осознанный выбор, совпавший с засеянным значением, отбрасывался как «не трогали». Порождён моей же правкой v1.232.1, комментарий к которой утверждал, что код различает «значение для показа» и «пользователь сознательно выбрал то же самое». Он их не различал. initial смешивает два происхождения: засев из .env (равенство значит, что поле не редактировали, — пропустить верно) и засев из defaultValue (равенство неоднозначно: либо не открывали, либо открыли и согласились). При снятом HOST контрол показывает 127.0.0.1; ввод ровно этого значения и Save отправляли {}, и ключ так и не появлялся. Единственный путь закрепить его — ввести заведомо неверное значение, сохранить и вернуть правильное; тупик, которого никто не найдёт. Поведение приложения при этом не ломалось — отсутствующий ключ и ключ, равный умолчанию, дают один результат, — но интерфейс показывал состояние, которого нет в файле, и повторный Save это рассогласование не устранял. Исправлено флагом seededFromFile рядом с initial: где засев пришёл из файла, ничего не меняется; где из defaultValue, вторым признаком становится dirty. Общим основанием он быть не может — срабатывает и при «ввёл и вернул», — но здесь это единственная оставшаяся улика, и цена ей самое большее одно поле, в котором человек работал, а не восемнадцать из CONFIG-2, для которых dirty пуст, потому что их никто не открывал. Снятие ключа показывалось как «· 0». updateEnvFile всегда возвращал только записанное, поэтому удалению нечего было считать, и тост читался как «Настройки сохранены · 0» — что разумно понять как «ничего не произошло», хотя ключ был удалён. Теперь POST /api/config отдаёт и removed, вычисленный по снимку до записи, так что он называет ключи, которые действительно были, а не все, которые просили очистить; тост считает и то и другое. Select-поле можно было задать, но нельзя очистить. Ни у одного из 18 дропдаунов не было пустого пункта, поэтому форма не могла отправить для него '' — текстовые поля очищаются, выпадающие списки нет. Вместе с CONFIG-3 это замыкало круг для незаданного селекта: закрепить нельзя, потому что значение совпадает с засеянным, а закрепив иным путём — не снять. Теперь каждый селект начинается с явного пункта «Использовать умолчание (…)», и незаданный ключ выбирает именно его, а не значение умолчания. Замысел v1.57.1 сохранён — подпись по-прежнему называет значение, которое использует сервер, — но теперь она это говорит, а не выдаёт себя за выбор, чем и был возможен CONFIG-3. С select-remote потребовалась аккуратность: живой каталог OpenRouter вызывает replaceChildren и стирал пункт, поэтому он там пересоздаётся. Один новый ключ i18n, config.useDefault, во всех 17 словарях.

Примечания

Проверено в этом же проходе и намеренно оставлено как есть, чтобы запись была полной. lang в теле каждого POST — точечная запись в allowlist, а не префиксное правило: проверено поведением, lang → 200, тогда как langx, lang_evil и LANG дают 400, как любой неизвестный ключ. facts.json отсутствует на задеплоенном сайте по замыслу — его импортируют 22 файла на этапе сборки, значения инлайнятся в HTML, поэтому проверять надо версию на странице, а не файл; §2.3 промпта v1.231.5 в этом вводил в заблуждение. Трактовка файла без расширения как текста в /api/cv/import документирована и осознанна, чтобы вставка из буфера без расширения продолжала работать; маршрут только конвертирует и возвращает, не пишет и не исполняет, а .exe и .zip отвергаются явно. База юнит-тестов не изменилась и равна 3018 — все правки клиентские либо в форме ответа одного маршрута. Гейты браузерные: tests/playwright-config-save-scope.mjs вырос с шести случаев до одиннадцати (111 → 116 браузерных), и все пять новых подтверждённо падали на старом коде. Один из них существует ровно затем, чтобы CONFIG-2 не вернулся: сохранение без единой правки по-прежнему обязано не отправлять ничего.

[1.232.1] — 2026-09-10

Исправлено — сохранение на #/config записывало поля, которых пользователь не касался. Это не регресс v1.232.0, дефект старше.

Исправлено

CONFIG-2 — сохранение на #/config записывало поля, которых пользователь не касался. Два по отдельности разумных решения вместе давали запись: v1.57.1 засевает незаданный контрол его defaultValue, чтобы поле показывало значение, которое сервер и вправду использует (это верно и не тронуто), а save() спрашивал dirty только для секретов и отправлял все прочие поля независимо ни от чего. Значение, подставленное для ПОКАЗА, уходило на сервер как выбор пользователя. Замерено на фикстуре с двумя заданными ключами: смена одного дропдауна отправляла 28 ключей и закрепляла 18, которых человек не открывал, каждый — своим умолчанием; а Save вообще без изменений отправлял 27. В день записи ничего не меняется, потому что записанное и есть умолчания. Меняется статус настройки: из «не задана — следуй умолчанию проекта» в «закреплена в .env». Курируемые списки двигаются между релизами — в примечаниях к v1.232.0 сам приведён выпавший gemini-2.0-flash, — поэтому при следующей смене умолчания каждый, кто хоть раз нажал Save, молча останется на старом значении. Это тот же класс, что и CONFIG-1: там писался мусор, здесь пишется правдоподобное, и потому оно незаметнее.

Примечания

Основание сравнения — та часть, которую стоит смотреть внимательно. Сравнения с загруженным cfg.values — очевидная правка, и первая испробованная — недостаточно: незаданный select показывает свой defaultValue, тогда как сохранённое значение пустое, они честно различаются, и все семнадцать модельных дропдаунов по-прежнему пишутся. Тело запроса сократилось с 27 ключей до 19, а не до одного. Теперь сохранение сравнивает с тем, чем контрол был проинициализирован: это единственное выражение «не трогали», которое отличает значение, поставленное для показа, от пользователя, сознательно выбравшего то же самое. dirty основанием тоже не годится — он срабатывает, когда человек ввёл значение и вернул исходное. Заодно исправлено: число записанных ключей жило внутри fallback-аргумента t(), а ключ config.saved есть во всех 17 словарях — поэтому число не показывалось никогда, и сохранение двенадцати ключей выглядело так же, как сохранение одного. Теперь тост читается как «Настройки сохранены · 1». Больше ничего не сдвинулось. Префилл v1.57.1 на месте, секреты по-прежнему уходят только после касания, а серверная поблажка из v1.232.0 остаётся защитой для прямых вызовов API, которые через форму не идут. База тестов не изменилась и равна 3018, потому что правка клиентская; её гейт — tests/playwright-config-save-scope.mjs, шесть браузерных случаев, зарегистрированных в test:e2e:browser (105 → 111); все шесть подтверждённо падали на старом коде.

[1.232.0] — 2026-09-09

Исправлено — два дефекта, найденные браузерным прогоном; ни один из них не пришёл из v1.231.5.

Исправлено

PROFILE-1 — #/profile прокручивал вбок всю страницу. Каждое поле профиля — это .card в сетке .card-row с repeat(auto-fit, minmax(220px, 1fr)), а значение было безымянным <div> с одними лишь инлайновыми шрифтовыми стилями, поэтому вычисленный overflow-wrap оставался normal. URL LinkedIn не даёт CSS ни одной допустимой точки переноса, а значит min-content ширина элемента равна всей строке; grid-элементы по умолчанию имеют min-width: auto и отказываются ужиматься ниже неё — карточка распирала свою колонку, затем строку, главную колонку и наконец документ. На фикстуре карточки переполнялись на 768, 1024, 1280 и 1440 px, а на 1280 px это доходило до страницы как 23 px настоящего горизонтального скролла. Зависимость от ширины и объясняет, почему дефект так долго жил: до 420 px медиазапрос даёт одну достаточно широкую колонку, а между 768 и 1024 px текст лишь вылезает из карточки, не сдвигая документ. Исправлено общим классом .card-value с overflow-wrap: anywhere и подстраховкой .card-row > * { min-width: 0 }. Именно anywhere, а не break-word — только anywhere учитывается при расчёте min-content, поэтому break-word перенёс бы текст, оставив колонку ровно такой же распёртой. dashboard.js переведён на тот же класс; в health.js уже стоял word-break: break-all, и он не потребовал ничего. CONFIG-1 — POST /api/config принимал любое значение для select-поля. {"LLM_PROVIDER":"not-a-provider"} возвращал 200 и записывался в ваш .env. Дальше ничего не падало: резолвер уходил в фолбэк на тот ключ, что был настроен, /api/status/providers показывал рабочего провайдера, а выбранная пользователем настройка молча не действовала — и обнаружилась бы много позже, когда фолбэк перестал бы совпадать с намерением. Валидатор проверял имена ключей, тип, длину, переводы строк и формат трёх конкретных полей, но не принадлежность значения домену. LLM_PROVIDERS — ровно этот список из 19 значений — всё это время экспортировался из env-config.mjs; валидатор просто к нему не обращался. Теперь обращается.

Примечания

Проверяется только LLM_PROVIDER, и это осознанное сужение отчёта. Шестнадцать списков моделей оставлены без проверки: их настоящий домен — каталог вендора, который меняется между нашими релизами, поэтому применение курируемого списка запретило бы выбрать модель, которую провайдер уже отдаёт. В самом репозитории тесты используют gemini-2.0-flash, а курируемый список её не содержит. Анализ вреда ведёт туда же: неверная модель падает громко, в момент вызова, и только неверный провайдер падает молча. select-remote исключён по той же причине, а сверка дрейфа по-прежнему доказывает, что сервер и браузер не расходятся в том, что предлагает выпадающий список. Уже сохранённое значение получает поблажку — строгий вариант оказался неработоспособным, и это показал браузерный тест. public/js/views/config.js отправляет все несекретные поля при каждом сохранении, тронутые и нет, поэтому отказ по ранее сохранённому значению замораживал всю страницу: пользователь не мог изменить ничего, пока не исправит поле, которого не касался. Теперь отвергаются только изменения — заявленный дефект по-прежнему закрыт, новое плохое значение не пройдёт, а старое чинится через выпадающий список, где есть лишь допустимые варианты. Заодно закрыто: OLLAMA_API_KEY принимался на запись через API, но не имел поля в #/config, то есть за проксирующей аутентификацией Ollama его нельзя было выставить из приложения вовсе. Дескриптор добавлен (секретный, два новых ключа i18n ×17), а тест следит, чтобы 45 записываемых ключей сервера и 45 дескрипторов интерфейса совпадали. Ни один из дефектов не пришёл из v1.231.5 — в том релизе менялись только package.json, локфайлы и документация. 3013 → 3018 тестов, плюс два браузерных случая в новом зарегистрированном tests/playwright-card-overflow.mjs.

[1.231.5] — 2026-09-09

Безопасность — закрыто семь уведомлений, одно из них — критическое удалённое выполнение кода. Ни один файл исходников не изменён.

Безопасность

Смержено из Dependabot: multer 2.2.0 → 2.3.0 (high — отказ в обслуживании из-за утечки файловых дескрипторов при обрыве) и svgo 4.0.2 → 4.1.0 в сайте (high и medium — removeScripts пропускал исполняемые ссылки через пространства имён и обработку содержимого, а также не до конца обеззараживал исполняемый HTML внутри foreignObject). Затем ещё пять, вскрытых пересканированием, которое эти мержи и запустили. Каждый затронутый диапазон уже допускался существующим спецификатором ^, поэтому потребовалось обновление, а не правка манифеста: astro 7.1.0 → 7.3.2 в сайте — критично, удалённое выполнение кода через оптимизацию изображений AVIF (исправлено в 7.2.8), а заодно обход авторизации из-за отсутствия проверки границы сегмента пути при отсечении настроенного base (7.2.4); sharp 0.35.3 → 0.35.4 — high, две уязвимости libheif; и js-yaml 4.3.1 → 4.3.2 в обоих манифестах — high, maxTotalMergeKeys не ограничивал расход процессора на пустых источниках слияния.

Примечания

«Только зависимости» здесь не равно «без риска», поэтому релиз не выпускался по одному лишь результату аудита. js-yaml разбирает каждый конфиг, который читает приложение, multer принимает каждую загрузку, а Astro сместился на минорную версию. Проверено: npm run test:ci 3013/3013, браузерный набор 4/4 и полная пересборка сайта, давшая те же 86 страниц, все 17 зеркал changelog и корректный facts.json. npm audit сообщает о 0 уязвимостях в обоих манифестах. База тестов не изменилась, потому что не изменился ни один файл исходников.

[1.231.4] — 2026-09-07

Исправлено — v1.231.3 починил последствие клика по Doctor, а не сам момент. Сообщено снова, с телефона и с десктопа.

Исправлено

withSpinner всё ещё переписывал кнопку на время запроса. v1.231.3 научил его сохранять узлы-детей и возвращать их в finally — и они действительно возвращались, но индикатор занятости по-прежнему выполнял button.replaceChildren(document.createTextNode('⏳ ' + original)). Поэтому на всё время работы doctor.mjs квадрат 36 px держал текст ⏳ 🩺Doctor и превращался в широкую «таблетку», ломавшую строку; на десктопе это выглядело как слипшееся ⏳🩺Doctor. Восстановление детей чинило последствие, а не момент, и проверка, смотревшая только на конечное состояние, это пропускала. Кнопка, у которой есть дети-элементы, теперь не переписывается вовсе. Её индикатор — класс .is-loading, который таблица стилей и так приглушала и который теперь подменяет иконку песочными часами средствами CSS, так что геометрия измениться не может: 36 px в покое, 36 px во время, 36 px после на 320 px. Кнопки с одним текстом — 23 из 24 мест вызова — сохраняют прежние песочные часы перед подписью. На десктопе кнопка на время работы читается как ⏳Doctor, расширяясь на ширину значка и возвращаясь обратно — ровно так же ведут себя все прочие кнопки приложения уже много лет.

Примечания

Урок здесь про проверку, а не про код. Гейт v1.231.3 кликал по Doctor, ждал снятия aria-busy и только потом мерил — то есть по построению видел лишь то состояние, которое блок finally уже привёл в порядок. Новая браузерная проверка сама удерживает промис открытым, а не гонится за настоящим запросом: она снимает замер в середине полёта и требует, чтобы ширина не изменилась, оба <span> были на месте и ничего не переполнялось. Первый черновик замерял после настоящего клика и пропускал проверку, если запрос успевал раньше, — такой замок срабатывал бы по удаче. 3012 → 3013 тестов. Обе новые проверки подтверждённо падали на коде v1.231.3 до внесения правки.

[1.231.3] — 2026-09-07

Исправлено — клик по Doctor ломал верхнюю панель на телефоне. Сообщено с телефона в день выхода v1.231.2.

Исправлено

UI.withSpinner уничтожал детей той кнопки, которую оформлял. Индикатор занятости работал через textContent: сохранить button.textContent, записать '⏳ ' + original, записать обратно. Присваивание textContent заменяет всех детей одним текстовым узлом — а v1.231.2 как раз разделил каждую кнопку верхней панели на .btn-ico и .btn-label, скрыв подпись под max-width: 900px. Поэтому первый же тап по Doctor уничтожал оба <span> навсегда: мобильным правилам нечего стало сопоставлять, подпись возвращалась голым текстом, а квадрат 36 px разрастался в «таблетку» 🩺Doctor, наезжавшую на переключатель темы. Само не восстанавливалось — только перезагрузка страницы возвращала разметку. Исправление сохраняет узлы-детей и возвращает их через replaceChildren; кнопки с одним текстом, а это большинство из 24 мест вызова, ведут себя ровно как прежде. Флаг dataset.spinnerBusy не даёт повторному вызову принять песочные часы за исходное содержимое.

  • FIND-3 — поле поиска схлопывалось до 8 px на экране 320 px. Приведя панель к одной строке, я оставил searchbar единственным гибким элементом — и прошлый выпуск прямо это записал: он «может сжаться до нуля, поэтому отрицательного свободного места не возникает». Он и сжался до нуля: 8 px ввода на 320 px, 28 px на 340 px — один символ из двадцати одного в подсказке. Кликается и печатается, но как поле больше не читается. Никакой min-width тут не помогает: на 320 px панель держит 32 px отступов, 40 px меню и 162 px действий, и взять 120 px попросту неоткуда. Поэтому ниже 420 px — ширины, на которой поле само наконец превышает 100 px — оно прячется за лупу и по тапу разворачивается на всю панель: 182 px на 320 px, 222 px на 360 px, 252 px на 390 px, меню и остальные действия уступают место, переполнения нет ни на одной ширине. Кнопка-раскрытие сохраняет одно доступное имя и сообщает состояние через aria-expanded, так что ничто не переименовывается под пользователем, а смена 🔍/✕ сделана чистым CSS — JS в эту кнопку не пишет, ведь именно запись сломала Doctor в этом же выпуске. Выше 420 px не меняется ничего: 109 px на 421 px, 447 px на 1280 px. Найдено браузерным регрессом по живой сборке.

Примечания

Это тот же класс дефекта, что и ловушка с applyI18n(), отмеченная в v1.231.2 — присваивание textContent элементу, у которого теперь есть дети-элементы, — но через другого вызывающего. v1.231.2 починил того вызывающего, о котором знал, и проехал мимо этого. Урок обобщается: разделив содержимое элемента на дочерние <span>, вы превращаете каждого существующего писателя textContent по этому элементу в скрытый баг, и писатели не всегда лежат в том файле, который вы правите. Закрыто двумя замками. tests/with-spinner-preserves-children.test.mjs исполняет настоящий withSpinner над поддельной кнопкой в node:vm и проверяет, что <span> переживают полный цикл, а заодно контракт занятости (возвращает результат, блокирует кнопку на время запроса, ставит и снимает aria-busy, восстанавливает прежнее disabled). tests/playwright-narrow-viewport.mjs кликает по Doctor в настоящем браузере на 320 px и проверяет, что ширина кнопки не изменилась, подпись по-прежнему display:none, действия в одной строке и ничего не прокручивается. Оба подтверждённо падали на старом коде до внесения правки. 3009 → 3012 тестов (браузерный — opt-in, в test:ci не входит).

[1.231.2] — 2026-09-06

Исправлено — мобильная верхняя панель переносилась на вторую строку, сервер работал на старом родителе, а редирект cvstart.ru давал 404 на любом пути, где уже указан язык.

Исправлено

  • Мобильная панель снова в одну строку, кнопки — иконками. Кнопки действий принудительно уходили на отдельную строку во всю ширину, и на телефоне получалось [☰ · поиск] над [🔔 🌙 Диагностика Открыть Scan] — две широкие пилюли на своей строке. Теперь «Диагностика» и «Открыть Scan» — квадраты 36 px (🩺 / ⚡) рядом с колокольчиком и переключателем темы, и всё помещается в одну строку при 320 px. Замерено, а не на глаз: ширина документа равна вьюпорту при 320, 360, 390 и 430 px и на всей полосе 560–760 px — там, где кнопки прежде наезжали на поиск. Локаль больше не влияет на мобильную раскладку вовсе, потому что подпись скрыта, а кнопка фиксированного размера; выше 900 px подписи возвращаются без изменений. Доступное имя берётся из aria-label, так как подпись с display:none выпадает из вычисления имени.
  • Сервер работал на родителе 1.31.0, хотя релиз собран под 1.32.0. v1.231.0 был паритетным, но в /opt/career-ops/src лежали файлы 1.31.0, и /api/health на прод-сервере отдавал parentVersion: 1.31.0. Выложено 95 файлов рантайма и удалены два (plugins-registry/theirstack.json — снят выше по течению; lib/context-budget.test.mjs — переехал в tests/). Найдено браузерным регрессом на живой поверхности.
  • cvstart.ru приклеивал /ru/ к любому пути, не глядя на то, что там уже есть. cvstart.ru/ru/help превращался в cvstart.org/ru/ru/help, а cvstart.ru/en/help — в cvstart.org/ru/en/help; оба 404. Первое получается, если скопировать адрес с основного сайта; второе полностью закрывало англоязычному посетителю доступ к английской версии, потому что любой языковой префикс перезаписывался. Теперь путь, уже называющий одну из 16 локалей, проходит как есть, en отбрасывается (на cvstart.org английский живёт в корне, каталога /en/ нет), остальное по-прежнему получает /ru/. Проверка локали заякорена, поэтому /enterprise/ не принимается за en.

Изменено

  • 98 устаревших QA-промптов перенесены в qa/archive/superseded-prompts/. В qa/README.md с версии v1.137.0 записано, что в корне остаётся только текущий релизный промпт, — и правило не применялось 95 релизов. Теперь в README сказано, что архивация происходит при отгрузке релиза, и исправлены его собственные устаревшие показатели (≥1238 → ≥3009 тестов, 13 → 17 локалей, 19 H2 / 75 H3 → 32 H2 / 122 H3).
  • С верхнего уровня ушли ещё пять промптов — они называли себя актуальными, не будучи таковыми. REGRESSION-FINAL.md объявлял себя «единственным авторитетным, версионно-независимым регрессионным промптом», но его §§11–15 — пять закрытых журналов циклов (v1.55.x → v1.59.7), а заявленная база хелпа — 28 H2 / 103 H3 на 16 локалей против 32 / 122 и 17 сегодня: QA-агент, идущий по нему, упал бы на §9 первым же шагом. У QA-REGRESSION-PROMPT.md имя без версии, а содержимое от v1.226.0. QA-REGRESSION-PROMPT-v1.76.0-FULL.md закреплён на 155 релизов назад, REGRESSION-PROMPT-FINAL.md закрывает цикл v1.58.52 → v1.59.10, а QA-FULL-REGRESSION.md дублировал их всех на базе v1.131.1. Туда же ушёл PARENT-SYNC-WORKLIST-v1.26.0.md (родитель уже 1.32.0), а датированный UX-AUDIT-2026-07-06.md переехал в reports/. Осталось четыре драйвера: промпт этого релиза плюс FUNCTIONALITY-CHECK.md, UX-AUDIT-PROMPT.md и DESIGNER-EXPORT-PROMPT.md, чьи собственные базы обновлены до фактических значений v1.231.2. qa/README.md переписан под ту папку, которая существует.

Примечания

  • Две ловушки специфичности, в которые я попал при правке панели, теперь описаны в app.css: десктопные правила .topbar / .topbar-actions / .btn-ico объявлены ниже блока max-width: 900px, поэтому при равной специфичности выигрывают они — мобильный gap молча оставался 24 px (это и переполняло 320 px), а иконка оставалась скрытой (кнопки выходили пустыми квадратами). Мобильные правила на класс глубже намеренно.
  • applyI18n() присваивает el.textContent, поэтому ключ i18n переехал с <button> на внутренний <span class="btn-label">: на кнопке он стирал бы иконку при каждой смене языка. Тест BUG-008-tb теперь проверяет, что ключ находится внутри #btn-doctor, а не является его атрибутом — тот же инвариант, проверенный там, где он теперь живёт.
  • Первый rsync родителя оборвался на полпути на файле, которого больше нет локально, и оставил частичную выкладку, отрапортовавшую успех, — та же форма отказа, что записана про висячий симлинк в QA-REGRESSION-PROMPT-v1.228.3.md. Выкладки родителя теперь проверяются контрольными суммами, а не кодом возврата.

[1.231.1] — 2026-09-06

Исправлено — свип счётчиков из v1.231.0 переписал имя аккаунта во всех ссылках.

Исправлено

  • Каждая ссылка Fighter90 стала Fighter92. Свип счётчика источников в v1.231.0 заменял 90 на 92 под защитой цифровой границы — (?<![0-9])90(?![0-9]), — которая верно пропускает 290 и 901, но совпадает с 90 внутри Fighter90: перед ним буква, а после токен просто кончается. 562 ссылки во всех 17 README, всех 17 наборах справки и зеркалах справки на сайте, плюс 138 в вики, вели на несуществующий аккаунт; баннер providers.png и все ссылки «править на GitHub» отдавали 404. Заметил пользователь — по пропавшей картинке.
  • Пять исторических отсылок к релизам сдвинулись. Свип 85 → 87 в том же релизе превратил v1.85.0 в v1.87.0 в README и справке, придумав версию, которой не выпускали.

Примечания

  • Цифровой границы недостаточно. Она защищает число от соседних цифр, но не от того, что оно часть идентификатора или номера версии, — а обоих в этих файлах много. Сами счётчики верны и не менялись: 92 источника, 87 EN-адаптеров, 3009 тестов.
  • Родительский репозиторий не пострадал: его счётчики я не свипал.

[1.231.0] — 2026-09-05

Добавлено — два источника сканирования из родительского career-ops v1.32.0 (Collage, Telegram строгий), режим REST у Gem и один дефект, который порт вскрыл в коде самого родителя.

Добавлено

  • Два новых источника, 90 → 92 (87 EN + 5 RU), ALL_ADAPTERS 85 → 87. Оба без токенов.
    • Collage (provider: collage) — публичный API джоб-сайтов Collage HR. Сегмент пути — это адрес джоб-сайта, который арендатор выбрал сам, а не слаг из названия компании, поэтому он читается из api: либо из careers-адреса secure.collage.co/jobs/<адрес> и никогда не угадывается: угаданный адрес привёл бы к сканированию чужой доски. Запись, не называющая ни того ни другого, падает громко.
    • Telegram (строгий) (provider: telegram-channel) — читает те же превью t.me/s/<канал>, что и существующий источник telegram, но с обратным разменом. telegram берёт каждый пост и приписывает неподписанный хэндлу канала; telegram-channel выдаёт строку только если пост называет работодателя И ссылается на страницу вакансии. Дайджесты, посты со скрытым работодателем («название скрыто», «our client») и посты «пишите в личку» отбрасываются. Низкая полнота по замыслу — выше по течению измерено 32–77 % проходящих постов на HR-каналах — но каждая строка несёт настоящего работодателя и настоящую ссылку. Оба остаются, как и у родителя: это разные продукты, а не дубликаты.
  • У Gem появился режим REST по выбору рядом с путём GraphQL (api: https://api.gem.com/job_board/v0/<board>/job_posts). Gem документирует этот неаутентифицированный интерфейс для собственных карьерных страниц. Он никогда не выводится из идентификатора доски — выведенный адрес был бы догадкой о том, какую поверхность открыл арендатор, — а путь GraphQL остаётся умолчанием, так что для записи без api: ничего не меняется. Перенесено из родительского #3783.

Исправлено

  • Фильтр локаций у telegram-channel не видел кириллицу и называл города работодателями. В родительском LOCATIONISH_RE стояла \b — граница только по ASCII, рядом с кириллицей она не срабатывает никогда. Поэтому все русские варианты в нём (москва, спб, офис, удалёнк*) были недостижимы, и Senior Engineer | Москва возвращал «Москва» как работодателя. Это ровно та подстановка неверного поля, ради предотвращения которой вся политика атрибуции и написана, и бьёт она по основной аудитории провайдера: русскоязычный канал пишет локацию кириллицей. В порте web-ui использованы юникодные проверки (\p{L} / \p{N}) — то же самое, что родитель уже делает строкой ниже в ROLE_WORD_RE. Закреплено тестом, который падает на ASCII-форме. Выше по течению пока не исправлено.

Примечания

  • Работа родителя по восстановлению Workday через фасеты (#3851, #3874) переноса не потребовала. Она отдаёт приоритет настроенным локациям при выборе разбиения и помечает разбиение, чей фасет не покрывает доску. web-ui вообще не листает и не разбивает Workday по фасетам — один POST при offset: 0 — так что ни того ни другого пути кода здесь нет. Тот же вывод, что и в v1.229.0, перепроверен, а не принят на веру.
  • providers/_types.js изменился только в документации (необязательное поле Job.salary).
  • Тесты: 2962 → 3009 (+47).

[1.230.0] — 2026-09-05

Изменено — три исправления провайдеров из родительского career-ops v1.32.0 и один дефект, который вскрыл сам перенос.

Добавлено

  • Welcome to the Jungle принимает серверное выражение filters для Algolia (wttj: { filters: "offices.country_code:FR AND contract_type:full_time" }). Ключевое слово само по себе глобальную доску не сужает: какие именно max_hits результатов вернутся, решает ранжирование Algolia, а не фильтры сканера — они срабатывают позже, уже по пришедшему, поэтому всё за пределами предела невидимо, как бы хорошо оно ни подходило. Фильтр сокращает сам набор, а не переупорядочивает его, и потому предел на запрос поднимается с 200 до 1000. При заданном фильтре queries становится необязательным: пустой запрос означает «всё, что проходит фильтр». Перенесено из родительского #3757.

Исправлено

  • Radancy больше не пишет «truncated», когда у арендатора просто меньше вакансий, чем заявляет его собственный баннер. Родитель сверил data-total-results с девятью живыми арендаторами: пять совпали, а четыре — и маленький, и очень крупный — отдали на 10–56 % меньше уникальных вакансий, чем объявлено, без единой дублирующейся строки. Баннер завышает то, что реально отдаёт поисковый индекс арендатора, поэтому сравнение с накопленным счётчиком давало ложное срабатывание на большинстве досок. Теперь предупреждение возникает только когда walk остановил наш собственный max_jobs/max_pages — единственный случай, с которым вызывающая сторона может что-то сделать; totalResults остаётся в тексте как контекст и ничего не решает. Перенесено из родительского #3839.
  • Radancy обходит кеш на каждом JSON-запросе фрагмента. Кеширующий слой перед этим маршрутом у части арендаторов отдаёт устаревшие ответы: одна и та же вакансия появляется страницами позже, а другая не приходит вовсе — воспроизведено выше по течению девять прогонов из девяти. Заголовки Cache-Control/Pragma не изменили ничего, случайный параметр на запрос — исправил. Он обязан быть уникальным на вызов, а не выводиться из номера страницы: детерминированный параметр всё равно даёт кешу устойчивый ключ, а значит то же устойчивое неверное соответствие. Перенесено из родительского #3839.
  • Списки офисов Greenhouse упорядочены детерминированно. Greenhouse не обещает порядок offices[] между ответами, а набор, восстановленный из дерева /offices, идёт в порядке обхода. Оба попадают в поле location вакансии, поэтому перестановка офисов на стороне доски переписывала эту строку — и вакансия, в которой ничего не менялось, читалась как новая. Родитель исправил набор (#3839); в web-ui обнаружился второй экземпляр того же дефекта, которого у родителя нет — запасной вариант offices[0], используемый когда у вакансии нет своей локации, — и отсортированы оба.

Примечания

  • Исправление iCIMS (#3728) переносить не потребовалось. Оно читает все записи jobLocation из JSON-LD страницы вакансии, потому что часть арендаторов ставит запись со сплошным UNAVAILABLE перед настоящим адресом. web-ui берёт локацию из HTML страницы списка и страницу вакансии не открывает вовсе — такого пути кода здесь нет.
  • Изменение _http.mjs переносить не потребовалось. Оно выносит isRefusedRedirectError, чтобы discover-ats.mjs и слой повторов перестали расходиться в оценке одной и той же ошибки. Поведение isRetryableError не меняется, а второго потребителя в web-ui нет — выносить хелпер ради одного вызывающего было бы шумом.
  • Дедупликация по локации (#3751) не зеркалится. Она живёт в конвейере scan.mjs CLI родителя; у маршрута сканирования web-ui своя.
  • Реестр без изменений: 90 источников (85 EN + 5 RU). Тесты: 2956 → 2962 (+6).

[1.229.0] — 2026-09-03

Добавлено — четыре источника сканирования из родительского career-ops v1.31.0: Built In, Feishu Jobs, Garena и MokaHR.

Добавлено

  • Четыре новых источника, 86 → 90 (85 EN + 5 RU), ALL_ADAPTERS 81 → 85. Все четыре без токенов: ни ключа, ни входа.
    • Built In (provider: builtin) — американская техническая доска, по всем девяти её рынкам. Работодатели публикуются там напрямую, поэтому это агрегатор вроде RemoteOK, а не источник по одной компании. Дефолтного запроса у него нет: запись без queries: и categories: не сканирует ничего и прямо об этом сообщает — общий источник не должен нести чьи-то личные поисковые слова. Рынок вне списка разрешённых отклоняется, а не подменяется тихо национальной доской: опечатка не должна незаметно расширять скан.
    • Feishu Jobs (provider: feishu-jobs) — 飞书招聘, тот самый эндпоинт, который вызывает страница вакансий самого тенанта. Две формы хоста и никаких других: ровно jobs.bytedance.com либо тенант *.jobs.feishu.cn. Пути к странице вакансии у них разные, так что один шаблон URL дал бы половине битые ссылки.
    • Garena (provider: garena) — одна компания, один фиксированный хост. office формирует ссылку на вакансию, а не выдачу: апстрим проверил живьём, что любой код офиса, включая выдуманный, возвращает ту же доску.
    • MokaHR (provider: mokahr) — китайская HR SaaS. Ответ зашифрован: конверт AES-128-CBC, ключ к которому приезжает рядом с шифротекстом. Это обфускация, а не защита, но обычный разбор JSON не увидит ничего, поэтому расшифровка — часть чтения доски как таковой. Два тенанта отклоняются на этапе конфигурации: их robots.txt закрывает путь к вакансиям.

Исправлено

  • htmlToText мог пропустить незакрытый открывающий тег в текст. safe &lt;img src=x onerror=1 выходил как safe <img src=x onerror=1: закрывающей скобки нет, снимать разметке нечего, и опенер доживал до вывода. Теперь за каждым декодированием следует снятие разметки, а у концевого опенера убирается угловая скобка — текст остаётся читаемым и становится безвредным. Портировано от родителя (#3491).

Примечания

  • Из фикса Workday у родителя не портировано ничего. Он спасает тенантов, у которых бэкенд CXS зажимает пагинацию на offset 2000, — а web-ui Workday вообще не пагинирует (один POST с offset: 0, 100 строк), так что зажимать нечего. Собственный потолок web-ui в 100 строк — отдельное, более грубое ограничение, и не тема этого релиза.
  • Фиксы verify-cv-facts и merge-tracker у родителя порта не потребовали: web-ui запускает эти скрипты из чекаута родителя, а не зеркалит их логику, поэтому они приезжают вместе с ним.
  • Зарплата из Built In приводится здесь к строке. Родительский parseSalary отдаёт { min, max } для своих фильтров, а контракт web-ui хранит salary текстом — пропустив объект, мы получили бы [object Object] в трекере.
  • Тесты: 2909 → 2956 (+47).

[1.228.5] — 2026-08-30

Исправлено — сообщаемая версия описывала файл на диске, а не исполняемый код.

Исправлено

  • /api/health перечитывал package.json на каждый запрос, поэтому устаревший процесс рапортовал версию, которую не исполнял. QA нашёл локальный инстанс, отвечавший version: 1.228.4 и при этом дававший 404 на /api/ping — маршрут, добавленный в 1.228.3: процесс был запущен до развёртывания, обслуживал старый код и сообщал версию нового файла. Именно так выглядит успешным развёртывание, которое скопировало файлы, но не перезапустило процесс: единственная строка, на которую смотрит оператор, — та самая, что не способна увидеть проблему. Это тот же класс, что симлинк из v1.228.1, где rsync обрывался посреди передачи и всё равно отчитывался об успехе.

    Версия теперь фиксируется при загрузке модуля и описывает работающий код. Устаревший процесс сообщит старую версию — правду, и сразу заметную. parentVersion остаётся почитанным на каждый запрос: он описывает родительский чекаут, который этот процесс не загружает и который законно меняется под работающим сервером.

[1.228.4] — 2026-08-30

Исправлено — проба живости без авторизации обращалась к файловой системе на каждый запрос.

Исправлено

  • GET /api/ping читал и разбирал package.json при каждом обращении. Это единственная точка, доступная без учётных данных, поэтому чтение с диска и разбор JSON на каждый запрос — рычаг отказа в обслуживании, выданный кому угодно; CodeQL пометил это как отсутствие ограничения частоты. Версия теперь читается один раз при регистрации маршрута — измениться без перезапуска она всё равно не может, — и обработчик не делает ввода-вывода вовсе. Ограничение частоты урезало бы ущерб; удаление работы убирает сам рычаг.
  • Маскирование имени владельца профиля вне loopback теперь закреплено тестом. /api/health и раньше заменял его на hidden при не-loopback привязке — но лишь потому, что эта строка делит охранник hidden ?? value с корнем проекта. Тест этого не проверял, и правка, давшая строке собственное значение, утекла бы настоящим именем в локальную сеть незамеченной. Замечание из ревью v1.228.3.

career-ops-ui v1.267.1 · page 4/13 · mirror of the project changelog for cvstart.org