체인지로그

GitHub

릴리스별로 정리된 모든 주요 변경 사항입니다. 저장소에 실린 동일한 파일이며, 19개 언어 전체로 번역되어 있습니다.

[1.237.0] — 2026-09-21

상위 동등성 — career-ops main @ 93c4302b (VERSION 1.33.0, career-ops-hq/main에서 가져온 커밋 75개). 이번에는 새 소스가 없습니다: 미러링된 수정 네 건, 그중 두 건은 여기서 공고를 소리 없이 잃고 있었습니다.

수정

Jobstreet / SEEK — 호주, 뉴질랜드, 싱가포르, 말레이시아, 홍콩의 모든 공고가 죽은 페이지로 연결되었습니다. parseJobstreetItem은 상세 URL을 무조건 ${baseUrl}/id/job/${id}로 만들었습니다. /id/ 세그먼트는 경로가 아니라 인도네시아 로케일 접두사이며, 인도네시아 호스트에만 존재합니다. 그 밖의 모든 SEEK 플랫폼 호스트는 /id/job/<id>에 404로 답합니다. web-ui는 이미 www.seek.com.au, www.seek.co.nz, sg.jobstreet.com, my.jobstreet.com, hk.jobsdb.com을 ALLOWED_JOBSTREET_HOSTS에 추가해 두었으므로, 스캔은 그 공고들을 아무렇지 않게 돌려주었습니다 — 각각이 404를 가리키는 채로. 이제 경로는 호스트에 따라 결정됩니다: ID_LOCALE_HOSTS(id.jobstreet.com, www.jobstreet.co.id, jobstreet.co.id)는 /id/job/을 유지하고, 그 밖의 모든 호스트는 /job/을 받으며, 파싱할 수 없는 origin은 예외를 던지는 대신 공통 경로로 대체됩니다. 우리 자신의 테스트 두 건이 이 버그를 그대로 코드화하고 있었습니다 — hk.jobsdb.com/id/job/…와 www.jobstreet.com/id/job/…을 단언하고 있었고, 이제 수정되었습니다. Oracle Cloud — 목록 중간의 짧은 페이지가 순회를 끝내고 게시판의 나머지를 놓쳤습니다. 중단 조건은 listLen === 0 || listLen < PAGE_SIZE였지만, 일부 행이 서버 측에서 걸러지기 때문에 ORC는 목록 중간에서도 상한보다 적은 행을 돌려줍니다. 상위 저장소에서 American Express를 대상으로 측정한 결과: TotalJobsCount 454, 페이지는 200 / 199 / 54 — 199가 끝으로 읽혀서 마지막 **54건의 공고, 게시판의 12%**가 끝내 가져와지지 않았습니다. 이제 빈 페이지만이 항상 순회를 끝내며, 테넌트가 총계를 알려주면 페이지네이션은 offset + PAGE_SIZE >= total이 될 때까지 계속됩니다. 대조할 총계가 없는 테넌트만 짧은 페이지에서 끝납니다. 이는 API가 “요청한 결과 수보다 적게 반환할 수 있다 … 컬렉션의 끝이 아니더라도”라는 더 넓은 관례(Google AIP-158)와 일치합니다. hasMore는 여전히 의도적으로 무시됩니다 — 일부 테넌트는 7000건 규모 게시판의 모든 페이지에서 hasMore:false를 반환합니다. 짧은 페이지가 더 이상 순회를 끝내지 않게 되었으므로, 이제 MAX_PAGES만이 테넌트가 엉터리 총계를 보고하는 경우에 대한 유일한 방어선이며, 이는 자체 테스트로 다뤄집니다. Liveness — “This role is closed”가 인식되지 않았고, 잘못된 만료 판정은 영구적입니다. 패턴은 /this job (listing )?is closed/i였는데, 이는 그만큼 흔한 role과 position 표현을 놓칩니다 — 상위 저장소가 한 게시판에서 측정한 바로는, “role”을 쓰는 불확실한 공고 111건 중 111건이 여기 해당했습니다. /this (?:job|role|position)(?: listing)? is closed\b(?!-)/i로 넓혔습니다. 끝의 \b(?!-)는 복합 형용사를 막는 가드입니다: \b 단독으로는 closedown은 걸러내지만 “This role is closed-loop control of the platform” 같은 실제 문장은 여전히 일치시키며, 이를 막는 것은 바로 이 전방탐색(lookahead)입니다. Liveness — job expired는 이제 눈에 보이는 Apply 버튼을 이길 수 없는 소프트 등급에 놓입니다. HARD_EXPIRED_PATTERNS는 apply-control 테스트 이전에 검사되므로, 그곳에 놓인 것은 무엇이든 살아 있는 공고를 이겨 버립니다. /\bjob expired\b/i는 분류기가 페이지 전체 텍스트를 받기 때문에 실제로 살아 있는 페이지의 네 가지 형태 — “Similar jobs” 캐러셀 항목, “Hide job expired” 필터 칩, 푸터 FAQ, 그리고 위의 closed-loop 문장 — 에서 오탐합니다. 이제 이는 SOFT_EXPIRED_PATTERNS에 놓여 apply-control 이후, 목록 페이지 휴리스틱 이전에 검사되며 expired_body_soft를 반환합니다. apply-control이 없는 순수한 “JOB EXPIRED” 배너는 여전히 만료로 처리됩니다. 이 방향이 중요한 이유는, 잘못된 expired가 스캔 이력에 skipped_expired로 기록되면 scan_history.recheck_after_days가 설정되어 있지 않은 한 이후의 모든 스캔에서 실제 공고 하나를 영구히 중복 제거 필터로 걸러내기 때문입니다.

추가

Personio — 명시적인 personio: <slug>가 테넌트를 고정합니다. 많은 회사가 자사의 Personio 게시판을 브랜드가 입혀진 채용 페이지에 iframe으로 임베드합니다. 그래서 careers_url은 회사 자신의 도메인을 가리키지만 피드는 <slug>.jobs.personio.de에 있습니다 — 고정 없이는 그런 게시판이 아무것도 찾아내지 못합니다. 슬러그는 앵커된 허용목록(/^[a-z0-9][a-z0-9-]{0,62}$/i)으로 문자 집합이 제한되며, 만들어진 URL은 여전히 기존의 new URL() 파싱, HTTPS 검사, PERSONIO_HOST_RE 호스트 게이트를 그대로 거칩니다 — 이 고정이 다른 호스트에 도달하는 수단이 되는 일은 없습니다. ., /, @, :, .., 공백, 앞에 오는 -를 담고 있거나 63자 DNS 레이블 한도를 넘는 슬러그는 오늘의 api/careers_url 동작으로 대체됩니다. 부모 저장소와 달리 web-ui의 호스트 정규식은 대소문자를 구분하므로, 고정 값은 new URL()을 통해 정규화됩니다 — AcmeGroup은 자신의 허용목록이 결국 거부할 호스트를 만드는 대신 acmegroup.jobs.personio.de로 귀결됩니다. **Jobstreet — 옵트인 방식의 appendWorkType**은 공고의 근무 형태를 제목 뒤에 덧붙입니다. 예: Strategy Consultant [Part time]. 이로써 제목 필터와 트리아지가 스캔 시점에 고용 형태를 볼 수 있습니다. 기본값은 꺼짐이며, workTypes가 없거나 비어 있으면 아무 동작도 하지 않습니다.

참고

소스와 어댑터 개수는 변함없습니다: 94개 소스(89 EN + 5 RU), 89개 EN 어댑터. 이번 릴리스는 테스트 개수만 움직입니다, 3164 → 3201(+13 jobstreet, +7 Oracle Cloud, +9 liveness, +8 Personio). 부모 저장소는 providers/telegram.mjs를 삭제하고 telegram-channel로 통합했습니다. web-ui는 둘 다 사용자에게 노출되는 소스로 등록하고 있으므로, 이 삭제는 따르지 않습니다: 그대로 따르면 레지스트리가 94에서 93으로 줄어들고 telegram을 설정해 둔 누구든 소리 없이 망가뜨리게 됩니다. 의도적으로 공지된 제거로서만 다시 검토할 것입니다. jd-skill-gap.mjs는 축약되지 않은 “what we are looking for” 헤딩 형태와 굵게 표시 마커 제거 기능을 얻었습니다. web-ui는 이 스크립트를 미러링하지 않고 중계하므로, 이 수정은 배포된 부모 저장소와 함께 도착하며 여기서는 변경이 필요 없습니다. 이식하지 않은 항목과 그 이유: lib/latex-escape.mjs, browser-extract.mjs, openai-eval.mjs, update-system.mjs와 스캐폴더(web-ui는 이들을 미러링하지도, 셸 아웃하지도 않습니다); Go 대시보드 TUI와 web/ 설정 폼(별개의 표면); doctor.mjs와 analyze-patterns.mjs(fail-soft가 변경을 흡수하는 읽기 전용 중계). providers/telegram-channel.mjs에 있는 포크의 키릴 문자 \p{L} 분기점은 상위 병합을 온전히 견뎌 냈으며, 병합 이전 사본과 바이트 단위로 대조하여 검증했습니다.

[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)는 게시판의 카드를 스크레이핑하며, 파싱할 수 없는 마크업에서는 큰 소리로 실패합니다 — 카드 형태가 바뀌면 빈 게시판으로 읽히는 대신 예외를 던지는데, 이것이 바로 스크레이핑 소스를 신뢰할 수 없게 만드는 실패 방식입니다. 둘 다 #/scan의 Source 필터에 표시되고, 둘 다 호스트 고정(앵커된 정규식, HTTPS 전용)되어 있으며, 둘 다 가짜 전송 스위트로 처음부터 끝까지 구동됩니다(각각 43건과 36건).

수정

여러 도시에 열려 있는 Workday 공고가 위치 필터에 의해 버려졌습니다. Workday의 목록 엔드포인트는 다른 모든 공고가 장소를 담는 자리에, 다중 위치 공고에 대해서는 개수로 답합니다: "53 Locations". 이것은 장소가 아니며, buildLocationFilter는 대소문자를 구분하지 않는 부분 문자열로 일치시킵니다 — 그래서 Austin과 그 밖의 52개 도시에 동시에 열려 있는 공고는 어떤 allow: [austin] 항목과도 일치하지 않아 버려졌고, 같은 공고가 단독으로 나열되었을 때는 통과했습니다. 상위 저장소에서 테넌트 세 곳에 걸쳐 측정한 결과: 291건 중 53건의 공고가 자리표시자였으므로, 이는 예외적인 사례가 아니라 통상적인 사례입니다. 공고의 실제 장소는 CXS 상세 문서에만 존재하므로, 이제 자리표시자마다 그 문서에 대한 추가 GET 요청이 하나씩 들며, 항목당 200건으로 상한이 걸립니다 — 상한을 넘으면 자리표시자를 꾸며내지 않고 그대로 남겨 둡니다. 해결되지 않은 공고가 해결된 것처럼 읽혀서는 절대 안 되기 때문입니다. 실패했거나 장소 정보가 없는 상세 문서는 공고를 목록이 돌려준 그대로 남겨 둡니다: 눈에 보이는 "53 Locations"는 무엇을 해결하지 못했는지를 정직하게 드러내지만, 비워진 위치였다면 모든 필터를 소리 없이 통과했을 것입니다. 같은 GET 요청은 startDate도 함께 담고 있어서, 보강된 공고는 목록 엔드포인트의 상대적 표현("Posted 30+ Days Ago" → undefined) 대신 정확한 날짜를 갖게 됩니다. 순수한 YYYY-MM-DD 형태가 아닌 값은 Date.parse에 넘기지 않고 그대로 둡니다 — 이 함수의 비-ISO 대체 동작은 구현에 따라 다르기 때문입니다. 명시된 등급이 있으면 더 이상 두 개의 채용 요청을 하나의 재게시로 뭉개지 않습니다. "Insurance Specialist II"는 이 채용 요청이 형제 항목이 아니라는 고용주 자신의 진술입니다 — 하나의 기본 직함 아래 있지만 급여 등급, 범위, 요청 번호가 서로 다릅니다. 토크나이저는 이를 볼 수 없었습니다: w.length > 3은 VIII까지의 모든 로마 숫자와 모든 한 자리 숫자를 걸러내므로, "Insurance Specialist"와 "Insurance Specialist II"는 동일하게 토큰화되어 완벽한 Jaccard 비율을 기록했고, 재게시 탐지기는 후자를 전자의 재등록으로 표시했습니다. web-ui의 role-matcher.mjs에는 등급 처리가 전혀 없었으므로, 이번에는 상위 저장소의 메커니즘 전체를 그대로 들여옵니다: 로마 숫자와 아라비아 숫자를 하나의 숫자로 접어 등급을 추출하고("Nurse II"와 "Nurse 2"는 같은 진술입니다 — 이 짝은 이전에는 잘못 다르게 취급되었습니다), 명시된 두 등급은 반드시 일치해야 하며, 한쪽에만 등급이 있는 경우는 각 제목이 상대에게 없는 단어를 함께 지니고 있지 않은 한 느슨한 재작성으로 간주됩니다 — 이는 장식이 아니라 두 가지 불일치이기 때문입니다. 필터로 고정한 Avature 게시판이 결국 전체 글로벌 게시판을 훑고 있었습니다. Avature의 검색 UI는 불투명한 패싯 파라미터를 생성하며(?42386=[812132]는 국가 필터입니다), portals.yml에서 좁혀진 게시판을 고정한 운영자는 스캔이 그 게시판을 훑기를 기대합니다. resolveSearch는 URL을 ${origin}${path}로 재구성하면서 쿼리 문자열을 통째로 버렸습니다 — 그래서 첫 요청을 포함한 모든 요청이 테넌트의 전체 게시판을 훑었습니다: 더 많은 페이지, 더 많은 요청, 그리고 운영자가 의도적으로 걸러 냈던 공고들까지. 이제 패싯은 페이지네이션되는 모든 요청에 실리며, 우리 자신의 jobOffset을 마지막에 적용해 동일한 이름을 가진 낯선 패싯이 페이지네이션을 가로챌 수 없도록 합니다.

보안

devalue 5.8.1 → 5.9.2(astro를 통한 전이 의존성), 열려 있던 Dependabot 경고 하나를 닫았습니다 — 잘못된 형식의 입력에 대한 서비스 거부. 사이트 전용 의존성이며 런타임 코드는 변경되지 않았습니다.

참고

이식하지 않은 항목과 그 이유. 부모 저장소는 공유 기본값 10초 대비 lever에는 30초, manfred에는 25초의 타임아웃을 주었습니다 — 이는 이식 대상이 아닙니다: web-ui의 스캔 전송은 이미 60초(DEFAULT_SCAN_TIMEOUT_MS)를 허용하므로, 두 게시판 모두 저쪽에서 그 수정이 주는 것보다 이쪽에서 더 넉넉한 여유를 갖고 있습니다. iCIMS의 이중 careers- 접두사 문제도 여기서는 일어날 수 없습니다: 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, 플러그인 매니페스트. pull 이후 포크 분기점을 다시 확인했습니다: providers/telegram-channel.mjs의 키릴 문자 \p{L} 수정, 포크 고유의 providers/telegram.mjs, web/src/lib/clis.ts의 hermes. 상위 career-ops-hq/main은 완전히 병합되었습니다(누락된 커밋 0개). 3066 → 3164개 테스트. 도움말은 이제 17개 번들 모두에서 94개의 어댑터(89 EN + 5 RU)를 명시하며, 헤딩 구조는 32 H2 / 122 H3로 변함없습니다.

[1.235.0] — 2026-09-16

상위 동등성 — career-ops main @ 68e6b94 (VERSION 1.33.0, 커밋 42개). 미러링된 수정 2건 — 그중 하나는 여기서 MokaHR 공고를 소리 없이 잃고 있었습니다.

수정

같은 MokaHR 게시판의 두 번째 공고가 추가되지 않고 사라졌습니다. url-key.mjs는 공고 URL을 비교용 키로 축약하는데, 프래그먼트를 무조건 버렸습니다 — 대부분의 프래그먼트가 순전히 표시용(#apply, #section-2)이라는 점에서는 합리적입니다. 하지만 MokaHR은 우리 레지스트리 안에서 예외입니다: 이곳 공고는 …/social-recruitment/{tenant}/{id}#/job/{n} 형태이며, 한 테넌트의 모든 공고가 경로를 공유하고 공고 id는 오직 프래그먼트 안에만 존재합니다. 그래서 이들 모두가 하나의 키로 정규화되었습니다. 수정 전 측정 결과: 서로 다른 MokaHR 공고 두 건을 파이프라인에 붙여넣으면 한 줄만 남습니다 — 오류도 경고도 없이, 알아챌 만한 어떤 흔적도 없이. 스캐너의 신선도 필터(en-scanner.mjs)가 같은 키를 읽기 때문에, 해당 테넌트의 공고 하나가 스캔 이력에 들어가는 순간부터 나머지 전부가 “이미 본 것”으로 버려졌습니다. 이제 normalizeUrl은 인식된 #/job/{id} 또는 #/jobs/{id} 라우트를, 프래그먼트가 버려지기 전에 비교 전용 쿼리 키로 승격시킵니다 — MokaHR은 기존에 써 오던 이름인 mokahr_job_id를 그대로 유지해 이전에 기록된 키와 계속 비교 가능하도록 하고, 그 외 호스트는 내부용 _career_ops_fragment_job_id를 받습니다. 승격된 id는 설정이 아니라 추가됩니다: 이미 그 파라미터를 갖고 있는 URL은 다른 공고의 id를 담고 있는 것이므로, 이를 덮어쓰면 이 키가 애초에 갈라놓으려던 바로 그 두 가지를 하나로 합쳐버리게 됩니다. 인식되지 않는 프래그먼트는 여전히 모두 붕괴되며, 이는 원래부터 옳았던 절반입니다. accept-encoding이 이제 모든 소스 요청에 고정됩니다. 설정하지 않으면 Node는 자신의 undici 빌드가 제공하는 대로 협상하는데, Node ≥ 23부터는 여기에 zstd가 포함되며, amazon.jobs의 zstd 응답은 상태 코드 200을 유지한 채 정확히 1024바이트에서 잘려서 돌아옵니다 — 그래서 실패가 전송 계층 버그가 아니라 손상된 API인 것처럼 Unterminated string in JSON at position 1024로 읽힙니다. 이제 모든 요청은 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 인스턴스를 넘깁니다. 이는 실수가 아니라 의도적인 분기입니다: web-ui의 fetch 헬퍼들은 주입 가능한 fetchImpl을 받고, 45개의 소스 스위트가 자신이 캡처한 값에서 opts.headers['User-Agent']를 읽습니다 — Headers 인스턴스에는 대괄호 접근이 없으므로, 충실한 이식 대상은 구현이 아니라 동작(대소문자 구분 없는 오버라이드, 호출자가 우선함)입니다. 호출자의 객체는 복사될 뿐 절대 변형되지 않는데, 여러 소스가 하나의 헤더 상수를 공유하기 때문입니다. 부모 저장소의 짝을 이루는 수정 — 기본 user-agent를 대소문자 구분 없이 적용하는 것 — 은 이식하지 않습니다: web-ui는 애초에 기본 UA를 주입하지 않고, 각 소스가 자신의 게시판이 요구하는 UA를 직접 설정하므로, 호출자의 User-Agent와 충돌할 기본값 자체가 없습니다. 새 스위트 두 개와 케이스 세 개가 이 쌍을 고정합니다: tests/http-accept-encoding.test.mjs(정확히 고정된 값, 네 개의 fetch 헬퍼 각각, 그리고 세 가지 대소문자 표기 아래에서의 호출자 오버라이드 — 병합 버그가 두 개를 만들어냈던 것과 달리 단 하나의 accept-encoding 키만 살아남는지 검증)와, 키가 아니라 사용자가 보는 접점을 다루는 tests/parsers-pipeline-hash-route.test.mjs(서로 다른 MokaHR 공고 두 건이 모두 파이프라인에 도달해야 하고, 같은 공고를 네 가지 표기로 다시 붙여넣어도 여전히 한 줄로 붕괴해야 하며, 장식용 프래그먼트가 식별자가 되어서는 안 됨)가 그렇습니다. 모두 먼저 실패함을 확인했습니다. 이 고정은 해당 헬퍼뿐 아니라 전송 계층에 있습니다. 92개 소스 중 25개는 주입된 fetchImpl을 직접 호출하며 헬퍼를 전혀 거치지 않으므로, 두 스캐너가 실제로 주입하는 래퍼인 makeTimeoutFetch가 이를 적용합니다 — 모든 소스가 공유하는 유일한 통과 지점입니다. (부모에는 대응물이 필요 없습니다. providers/_http.mjs가 유일한 전송 계층이기 때문입니다.) 이제 구조적 가드가, 새 소스가 건네받은 전송 대신 fetch를 직접 호출하면 CI를 실패시키고, 동작 검증 케이스는 25개 중 하나인 greenhouse를 래퍼로 구동해 헤더가 실제로 전송되는지 증명합니다. 이식하지 않은 항목과 그 이유: liveness-browser.mjs(헤드풀 Playwright 재사용 가드 — web-ui는 헤드풀 브라우저를 구동하지 않음), check-jd-archive.mjs(web-ui 중계 없음), test-all.mjs(부모 저장소의 하네스; 포크의 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는 이제 프로세스가 어디서 실행되었든 상관없이 데이터 루트를 기준으로 해석됩니다. pull 이후 포크 분기점을 다시 확인했습니다: providers/telegram-channel.mjs의 키릴 문자 \p{L} 수정, 포크 고유의 providers/telegram.mjs, web/src/lib/clis.ts의 hermes. 소스 개수는 92개(EN 87 + RU 5)로 변함없어 도움말도 그대로입니다. 3042 → 3066개.

[1.234.0] — 2026-09-15

상위 동등성 — career-ops main @ 56cce8f (VERSION은 여전히 1.32.0, 1.32.0 이후 커밋 58개). 15개 소스에 이식된 수정 1건과, 재발을 막는 가드.

수정

잘못된 공고 id 하나가 더 이상 페이지 전체를 비우지 않습니다. encodeURIComponent는 UTF-16 서로게이트 단독 문자에서 URIError를 던지며, JSON 페이로드는 그런 문자를 담을 수 있습니다 — JSON.parse('"\\uD800"')가 그것을 그대로 유지합니다. 열다섯 개 소스는 파싱 루프 안에서 호스트가 제어하는 id나 슬러그로 각 공고의 URL을 만들었기 때문에, 잘못된 공고 하나가 루프를 중단시켜 그 페이지의 모든 공고를 잃었습니다. 예외가 회사별 catch가 뭔가 보고하기도 전에 풀려나갔으므로 소스 실패로도 기록되지 않았습니다. 부모 저장소는 이를 providers/_safe-url.mjs에서 고쳤습니다(safeEncodeURIComponent → URIError에는 null을 반환하고 그 외에는 다시 던짐) 그리고 루프의 각 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를 갖고 있었습니다. 범위는 의도적입니다: API에서 온 id만 이 헬퍼를 거칩니다. 설정에서 파생된 값은 엄격한 인코딩을 유지하며 크게 실패합니다 — garena의 office(portals.yml 세그먼트)는 throw하는 urlSegment를 그대로 쓰고, API id는 null을 반환하는 idUrlSegment를 씁니다. csod의 corpName과 4dayweek의 SLUG_RE로 검증된 슬러그는 이유와 함께 이름으로 허용목록에 올랐습니다. rheinmetall의 제목 대체 로직이 잘못된 href에서 throw할 수 있었습니다. 카드에 제목이 없으면 URL 슬러그로부터 decodeURIComponent를 통해 제목을 재구성하는데, 스크래핑한 href에 잘못된 퍼센트 시퀀스가 있으면 여기서 throw합니다 — 디코딩 쪽에서 벌어지는, 한 개의 잘못된 행이 페이지 전체를 죽이는 같은 형태입니다. 이제 방어적으로 디코딩하며 실패하면 원본 슬러그로 대체합니다. 소스 레지스트리가 _로 시작하는 파일을 건너뜁니다. 자동 탐색은 server/lib/sources/ 안의 모든 .mjs를 자기 자신만 제외하고 임포트했고, export const meta가 없는 파일에는 경고를 냈습니다 — 그래서 이 헬퍼가 부팅 시 경고를 만들어냈습니다. 이제 부모 저장소의 관례를 그대로 따릅니다: 앞에 밑줄이 붙으면 소스가 아님을 뜻하며, 그런 파일은 임포트되지도 경고를 받지도 않습니다. meta가 없는, 선언되지 않은 파일은 여전히 경고를 받으며, 탐색 테스트는 이제 양쪽 모두를 검증합니다(옛 필터로 실패함을 확인했습니다). 사이트 빌드의 sync-assets.mjs도 같은 규칙으로 디스크의 어댑터 파일을 세므로, 헬퍼가 더 이상 조용한 누락 감지 가드를 건드리지 않습니다.

참고

tests/sources-url-encoding-surrogate.test.mjs는 부모 저장소의 테스트 스위트를 세 부분으로 그대로 옮깁니다: 헬퍼의 계약(짝을 이룬 서로게이트 — 평범한 이모지 — 는 여전히 인코딩되며, 값 자신의 toString에서 나온 throw는 호출자의 버그이므로 그대로 전파됨), 열다섯 개 전체 소스별 동작(잘못된 id 하나 + 정상 id 하나 → throw 없음, 정상은 유지되고 잘못된 것은 버려짐 — 각 소스가 내보내는 순수 파서를 통해, thehub의 경우 가짜 전송으로 그 fetcher를 통해 구동), 그리고 소스 수준 가드: 변환된 모든 파일은 헬퍼를 임포트하고 그리고 호출해야 하며, 임포트 없이 이를 참조할 수 없고, 검토되지 않은 파일은 순수 encodeURIComponent만으로 채용 URL을 만들 수 없습니다 — 옛 형태를 가진 새 소스는 문제가 된 줄 번호와 함께 CI에서 실패합니다. 이식 전에 열다섯 개 소스별 사례 모두가 실패함을 확인했습니다. 부모 저장소 델타의 나머지는 이식하지 않았으며, 이유는 다음과 같습니다: batch/batch-runner.sh --cli는 CLI 전용 플래그입니다(web-ui는 배치 러너로 셸 아웃하지 않음); invite-match.mjs는 이제 코드 루트에서 set-status.mjs를 찾는데 — web-ui는 이를 중계하지 않습니다; company-funded.mjs는 내부적으로 같은 safe-url 변경을 얻었지만 /api/company-funded가 파싱하는 --json 출력 형태는 그대로입니다; test-all.mjs는 타임아웃 예산만 옮겼습니다(포크의 vpFixtureEnv 분기는 그대로 유지됩니다); web/은 부모 저장소 자체의 프런트엔드이며 미러링 대상이 아닙니다; AGENTS.md/SIGNATURES.md는 저장소 전용입니다. pull 이후 포크 분기점을 다시 확인했습니다: providers/telegram-channel.mjs의 키릴 문자 \p{L} 수정, 포크 고유의 providers/telegram.mjs, web/src/lib/clis.ts의 hermes. 소스 개수는 92개(EN 87 + RU 5)로 변함없어 도움말도 그대로입니다. 3022 → 3042개.

[1.233.2] — 2026-09-10

수정 — 심각도 높음 CodeQL js/remote-property-injection 두 건을, 이미 있던 불변식을 주장이 아니라 구조로 만들어 닫았습니다.

수정

POST /api/config는 재시작 없이 반영되도록 저장값을 process.env로 옮기는데, 그 루프는 요청 본문에서 만들어진 Object.entries(safe)를 돌면서 안쪽에 __proto__ / constructor / prototype 방어를 두고 있었습니다. 코드는 안전했습니다 — safe는 스무 줄 위에서 KNOWN_KEYS만 훑어 채워지므로 속성 이름이 사용자 손에 들어갈 일이 없습니다. 그러나 루프만 떼어 읽으면 요청 본문으로 만든 객체에서 나온 이름의 속성에 대입한다로 읽히고, CodeQL은 두 루프에 걸친 제약을 따라가지 못합니다. 이제 상수 배열을 직접 돌며 safe에서 값을 꺼내므로 속성 이름이 모듈 수준 리터럴임이 증명됩니다. 프로토타입 방어도 함께 사라졌습니다. 동작은 동일하며, 경고 날짜가 9월 9일인 것은 v1.232/v1.233이 줄 번호를 옮겼기 때문일 뿐입니다.

참고

두 건을 오탐으로 기각하는 길도 있었고 저장소에는 그 관행도 있습니다. 여기서는 고치는 쪽을 골랐습니다 — 주석 속 단언은, 분석기와 다음 독자가 스무 줄을 따라가지 않고도 검증할 수 있는 형태보다 값이 낮습니다. 옛 방어를 고정하던 테스트는 소스 텍스트 grep이라 문자열의 존재만 증명했습니다. 더 강한 둘로 교체했습니다: process.env[k]에 이르는 경로가 KNOWN_KEYS 순회임을(그리고 요청 유래 객체를 순회하지 않음을) 보는 구조 테스트 — 옛 형태에서 빨간불 확인 — 과, __proto__·constructor·prototype·미등록 이름을 원시 JSON으로 보내는 동작 테스트. 객체 리터럴은 안 됩니다: 소스의 { __proto__: … }는 속성이 아니라 프로토타입을 설정하므로 JSON.stringify가 {}를 보내 아무것도 증명하지 못했을 것입니다. 3021 → 3022개.

[1.233.1] — 2026-09-10

수정 — 안내서에 v1.233.0이 더한 컨트롤 설명이 전혀 없었고, 같은 절에는 200개 릴리스 전에 사실이 아니게 된 지시가 남아 있었습니다.

수정

DOC-1 — 새로 더한 컨트롤이 안내서 어디에도 없었습니다. 이제 모든 드롭다운은 ’기본값 사용 (…)’으로 시작해, UI가 이전에는 나타낼 방법이 없던 상태 — 키가 설정되지 않음 — 을 표현합니다. 아래 목록에서 같은 값을 고르는 것과의 차이는 보이지 않으면서도 중대합니다: ’기본값 사용 (llama3.2)’은 아무것도 쓰지 않고 프로젝트가 기본값을 옮길 때마다 새 값을 계속 건네주지만, llama3.2를 명시적으로 고르면 OLLAMA_MODEL=llama3.2이 기록되어 다음에 기본값이 바뀔 때 조용히 옛 값에 남습니다. CONFIG-2와 CONFIG-3이 다룬 것이 바로 이 조용한 표류였고, 메커니즘은 코드에서 고쳤는데 읽을 곳이 없었습니다. 기존 ### Behavior 목록에 세 항목을 17개 번들 모두에 넣었습니다. DOC-2 — 표의 한 행이 어떤 키도 이름하지 않았고, 그 안의 유일한 구체적 지시는 틀렸습니다. Key 칸에는 (server uses default UA)라는 괄호 주석이 들어가 v1.19.0이 제거한 HH_USER_AGENT를 설명했고, dev.hh.ru에 앱을 등록해 UA 문자열을 쓰라고 안내했습니다. v1.65.0에서 hh.ru 어댑터가 자체 브라우저 UA로 공개 검색 사이트를 읽도록 바뀐 뒤로 그 일은 아무 소용이 없습니다. 같은 정도로 낡은 언급이 둘 더 있었고, 셋 모두 17개 번들에서 사라졌습니다. 문제 해결 행은 삭제가 아니라 다시 썼습니다 — 절반은 여전히 참이기 때문입니다: 2026년 7월부터 hh.ru는 러시아 밖 IP에 HTTP 451을 돌려주므로 러시아 가정용 IP가 실제로 필요합니다. 키 표가 자기가 무엇인지 밝힙니다. ### Recognized keys는 45개 중 6개만 싣는데 제목은 등록부를 약속합니다. 한 문장 ×17로 선별임을 밝히고 완전한 목록은 #/config에 있다고 가리킵니다.

참고

tests/help-recognized-keys.test.mjs가 빠져 있던 게이트입니다. 실제로 키를 나열하는 도움말 표의 첫 열을 살아 있는 KNOWN_KEYS와 대조합니다. 범위 한정이 중요합니다 — 내보내기 표의 PDF처럼 첫 열이 백틱 대문자인 다른 표도 있어서, 자기 행 중 하나라도 진짜 키를 이름할 때만 키 표로 봅니다. 수정 전 번들에서 세 건 모두 실패함을 확인했습니다. 3018 → 3021개; 도움말은 17개 모두 32 H2 / 122 H3 그대로입니다.

[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을 보여 주는데, 바로 그 값을 입력하고 저장하면 {}가 전송되고 키는 생기지 않았습니다. 고정하는 유일한 길은 틀린 값을 넣고 저장한 뒤 옳은 값으로 되돌리는 것 — 아무도 찾지 못할 막다른 길이었습니다. initial 옆에 **seededFromFile**을 기록해 해결했습니다. 키 삭제가 ’· 0’으로 보고되었습니다. 서버가 쓴 것만 돌려주어 삭제는 셀 것이 없었고, 실제로는 지워졌는데 ’아무 일도 없었다’로 읽혔습니다. 이제 removed도 응답하고 토스트가 둘 다 셉니다. 셀렉트는 설정은 되어도 비울 수 없었습니다. 18개 중 어느 것도 빈 항목이 없었습니다. 이제 각 셀렉트는 **‘기본값 사용 (…)’**으로 시작하고, 설정되지 않은 키는 기본값 자체가 아니라 그 항목을 선택합니다. i18n 키 하나 ×17.

참고

이번에 함께 확인하고 일부러 그대로 둔 것: lang은 접두 규칙이 아니라 문자 그대로의 허용 항목(langx, lang_evil, LANG 모두 400). facts.json은 설계상 배포물에 없습니다 — 22개 파일이 빌드 시 가져와 값이 HTML에 인라인됩니다. 확장자 없는 업로드를 텍스트로 다루는 것은 문서화된 의도이며, 쓰지도 실행하지도 않고 .exe/.zip은 명시적으로 거부됩니다. 유닛 기준선은 3018 그대로. 게이트는 브라우저 쪽이며 playwright-config-save-scope.mjs가 6건에서 11건으로(브라우저 합계 111 → 116), 새 5건 모두 옛 코드에서 먼저 실패함을 확인했습니다.

[1.232.1] — 2026-09-10

수정 — #/config의 저장이 사용자가 건드리지 않은 필드까지 기록했습니다.

수정

CONFIG-2. v1.57.1은 설정되지 않은 컨트롤에 defaultValue를 심어 서버가 실제로 쓰는 값을 보여 줍니다(옳고, 그대로 둡니다). 반면 save()는 dirty를 비밀 필드에만 물었고 나머지는 언제나 보냈습니다. 그래서 보여 주려고 넣은 값이 사용자의 선택인 양 서버로 갔습니다. 키 두 개가 설정된 픽스처에서 측정하니, 드롭다운 하나를 바꾸자 28개 키가 전송되고 아무도 열지 않은 18개가 고정되었습니다. 아무것도 바꾸지 않아도 27개가 갔습니다. 그날의 동작은 달라지지 않습니다 — 기록된 값이 곧 기본값이니까요. 달라지는 것은 각 설정의 상태입니다: ’설정되지 않음, 프로젝트 기본을 따름’에서 ’.env에 고정됨’으로. 이 목록들은 릴리스 사이에 움직이므로, 다음에 기본값을 바꾸면 한 번이라도 저장을 누른 사람에게는 조용히 닿지 않습니다.

참고

눈여겨볼 곳은 비교의 기준입니다. 불러온 cfg.values와 비교하는 것 — 가장 먼저 시도한 명백한 수정 — 으로는 부족합니다. 설정되지 않은 select는 자기 defaultValue를 보여 주고 저장값은 ''이라 둘은 정직하게 다르며, 열일곱 개 모델 드롭다운은 여전히 기록됩니다. 본문이 27개에서 19개로 줄었을 뿐 1개가 되지 않았습니다. 이제는 각 컨트롤이 무엇으로 심어졌는지와 비교합니다. dirty도 기준이 될 수 없습니다 — 값을 입력했다가 원래대로 되돌려도 켜지니까요. 함께 수정: 토스트의 키 개수가 t()의 폴백 인자 안에 있었고 config.saved는 17개 사전 모두에 있어서, 그 숫자는 한 번도 그려지지 않았습니다. 12개를 저장해도 1개를 저장한 것과 똑같아 보였습니다. 나머지는 그대로입니다. v1.57.1의 사전 채움은 유지되고, 비밀 필드는 여전히 건드렸을 때만 전송되며, v1.232.0의 서버측 유예는 폼을 거치지 않는 직접 API 호출을 위한 방어로 남습니다. 수정이 클라이언트 쪽이라 테스트 기준선은 3018 그대로이고, 게이트는 tests/playwright-config-save-scope.mjs의 브라우저 6건입니다(105 → 111). 여섯 건 모두 옛 코드에서 먼저 실패함을 확인했습니다.

[1.232.0] — 2026-09-09

수정 — 브라우저 QA에서 나온 두 건. 둘 다 v1.231.5에서 온 것이 아닙니다.

수정

PROFILE-1 — #/profile이 페이지 전체를 옆으로 스크롤시켰습니다. 값은 인라인 글꼴 스타일만 지닌 익명 <div>여서 계산된 overflow-wrap이 normal로 남았습니다. LinkedIn URL은 CSS에 적법한 줄바꿈 지점을 주지 않으므로 min-content 너비가 문자열 전체가 되고, 그리드 항목의 기본 min-width: auto는 그 아래로 줄어들기를 거부합니다 — 카드가 트랙을, 이어서 행과 본문 열과 문서를 밀어냈습니다(1280 px에서 실측 23 px의 가로 스크롤). 공유 클래스 .card-value에 overflow-wrap: anywhere를 주고 .card-row > * { min-width: 0 }을 받침으로 두어 해결했습니다. **break-word가 아니라 anywhere**입니다 — min-content 계산에 들어가는 것은 anywhere뿐입니다. CONFIG-1 — POST /api/config가 select 필드에 어떤 값이든 받아들였습니다. {"LLM_PROVIDER":"not-a-provider"}가 200을 반환하고 .env에 그대로 기록되었습니다. 이후에도 아무것도 실패하지 않습니다 — 리졸버가 조용히 폴백하고, 사용자가 고른 설정은 효력 없이 멀쩡해 보입니다. 바로 그 19개 값을 담은 LLM_PROVIDERS는 줄곧 env-config.mjs에서 내보내지고 있었습니다. 검증기가 참조하지 않았을 뿐입니다.

참고

검증 대상은 LLM_PROVIDER 하나뿐이며, 이는 보고에 대한 의도적인 축소입니다. 모델 목록의 진짜 도메인은 공급자의 카탈로그에 있고 우리 릴리스와 무관하게 움직입니다. 잘못된 모델은 호출 시점에 큰 소리로 실패하고, 조용히 실패하는 것은 공급자 이름뿐입니다. 이미 저장된 값은 눈감아 줍니다. 엄격한 판본이 쓸 수 없다는 것을 브라우저 테스트가 보여 주었기 때문입니다. 설정 폼은 저장할 때마다 비밀이 아닌 모든 필드를 다시 보내므로, 기존의 잘못된 값을 거부하면 페이지 전체가 얼어붙습니다. 이제는 변경만 거부합니다. 함께 해결: OLLAMA_API_KEY는 API로는 쓸 수 있었지만 #/config에 필드가 없어 인증 프록시 뒤의 Ollama 사용자는 앱에서 설정할 수 없었습니다. 디스크립터를 추가했고(비밀, i18n 키 2개 ×17), 서버의 45개 키와 UI의 45개 디스크립터가 계속 같음을 테스트가 보장합니다. 3013 → 3018개.

[1.231.5] — 2026-09-09

보안 — 권고 일곱 건을 닫았고, 그중 하나는 치명적인 원격 코드 실행입니다. 소스 파일은 하나도 바뀌지 않았습니다.

보안

Dependabot에서 병합: multer 2.2.0 → 2.3.0(높음 — 중단 시 파일 디스크립터 누수로 인한 서비스 거부)과 사이트의 svgo 4.0.2 → 4.1.0(높음 및 보통 — removeScripts가 네임스페이스와 콘텐츠 처리를 통해 실행 가능한 링크를 통과시켰고, foreignObject 안의 실행 가능 HTML도 불완전하게 정화했습니다). 이어서 그 병합이 촉발한 재검사가 다섯 건을 더 드러냈습니다. 영향 범위는 모두 기존 ^ 지정자로 이미 충족되어 매니페스트 수정이 아니라 업데이트로 충분했습니다: 사이트의 astro 7.1.0 → 7.3.2 — 치명적, AVIF 이미지 최적화를 통한 원격 코드 실행(7.2.8에서 수정)과 설정된 base를 벗겨낼 때 경로 세그먼트 경계 검사 누락으로 인한 인가 우회(7.2.4); sharp 0.35.3 → 0.35.4 — 높음, libheif 취약점 두 건; 두 매니페스트 모두의 js-yaml 4.3.1 → 4.3.2 — 높음.

참고

여기서 “의존성뿐”은 “위험 없음”과 같지 않아, 감사 결과만으로 내보내지 않았습니다. js-yaml은 앱이 읽는 모든 설정을 파싱하고 multer는 모든 업로드를 받으며, Astro는 마이너 버전을 건넜습니다. 확인: npm run test:ci 3013/3013, 브라우저 스위트 4/4, 그리고 동일한 86개 페이지와 17개 미러, 올바른 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에서 아이콘을 모래시계로 바꾸므로 형상이 달라질 수 없습니다: 320 px에서 대기 36 px, 진행 중 36 px, 이후 36 px.

참고

교훈은 코드가 아니라 단언에 있습니다. v1.231.3의 게이트는 aria-busy가 사라지기를 기다린 뒤에야 측정했으니, 구조상 finally가 이미 고쳐 놓은 상태만 볼 수 있었습니다. 새 브라우저 검사는 스스로 프로미스를 열어 둔 채 비행 중에 표본을 뜹니다. 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개 호출 지점의 대부분은 이전과 완전히 동일하게 동작합니다.

  • FIND-3 — 320 px 화면에서 검색 필드가 8 px로 찌그러져 있었습니다. 상단 바를 한 줄로 만들면서 searchbar가 유일한 신축 요소로 남았고, 지난 릴리스의 변경 기록이 그대로 적어 두었습니다 — “아무것도 남지 않을 때까지 줄어들 수 있다”. 정말로 그렇게 됐습니다: 320 px에서 입력 8 px, 340 px에서 28 px — 21자 자리표시자 중 한 글자. 누를 수도 입력할 수도 있지만 더는 필드로 읽히지 않습니다. min-width로는 안 됩니다. 320 px 바에는 여백 32 px, 메뉴 40 px, 동작 162 px가 들어 있어 내줄 120 px이 없습니다. 그래서 420 px 미만 — 필드가 스스로 100 px를 넘기는 폭 — 에서는 돋보기 뒤로 숨고, 누르면 바 전체로 펼쳐집니다: 320 px에서 182 px, 360 px에서 222 px, 390 px에서 252 px, 어느 폭에서도 넘침이 없습니다. 이 버튼은 접근 가능한 이름을 하나로 유지하고 상태는 aria-expanded로 알립니다. 🔍/✕ 전환은 순수 CSS입니다 — 이 버튼에 JS가 쓰지 않으며, 같은 릴리스에서 Doctor를 망가뜨린 것이 바로 그 쓰기였습니다. 420 px 위로는 달라지는 것이 없습니다. 라이브 빌드를 상대로 한 브라우저 회귀에서 발견되었습니다.

참고

이는 v1.231.2에서 적어 둔 applyI18n() 함정과 같은 결함 부류입니다 — 이제 요소 자식을 가진 요소에 textContent를 대입하는 코드 — 다만 다른 호출자를 통해 닿았습니다. 교훈은 일반화됩니다: 어떤 요소의 내용을 자식 <span>으로 쪼개는 순간, 그 요소를 향한 기존의 모든 textContent 기록자가 잠복 버그가 됩니다. 그리고 그 기록자가 지금 편집 중인 파일에 있으리라는 보장은 없습니다. 두 겹으로 잠갔습니다. node:vm에서 가짜 버튼에 진짜 withSpinner를 돌리고, 320 px 실제 브라우저에서 Doctor를 클릭합니다. 둘 다 이전 코드에서 실패함을 확인했습니다. 3009 → 3012개 (브라우저 케이스는 선택이며 test:ci에 없습니다).

[1.231.2] — 2026-09-06

수정 — 모바일 상단 바가 두 줄로 접히던 문제, 서버가 더 오래된 상위 프로젝트로 돌던 문제, 그리고 cvstart.ru 리디렉션이 이미 언어를 포함한 모든 경로에서 404를 내던 문제.

수정

  • 모바일 상단 바가 다시 한 줄이 되고, 동작 버튼은 아이콘만 남았습니다. 동작 버튼들이 전체 너비의 두 번째 줄로 강제되어, 휴대폰에서는 [☰ · 검색] 아래에 [🔔 🌙 Doctor Scan 열기] 라는 넓은 알약 두 개가 별도의 줄을 차지했습니다. 이제는 종과 테마 전환 옆의 36 px 정사각형(🩺 / ⚡)이며, 320 px에서도 전부 한 줄에 들어갑니다. 눈대중이 아니라 실측입니다: 문서 너비가 320·360·390·430 px에서, 그리고 예전에 버튼이 검색창을 덮던 560~760 px 구간 전체에서 뷰포트와 같습니다. 로케일은 이제 모바일 레이아웃에 전혀 영향을 주지 않습니다 — 레이블이 숨겨지고 버튼이 고정 정사각형이기 때문입니다. 900 px를 넘으면 레이블이 그대로 돌아옵니다. 접근 가능한 이름은 aria-label에서 옵니다. display:none 레이블은 이름 계산에서 빠지기 때문입니다.
  • 릴리스는 상위 1.32.0을 기준으로 만들었는데 서버는 상위 1.31.0으로 돌고 있었습니다. 프로덕션 서버의 /api/health가 parentVersion: 1.31.0을 보고했습니다. 런타임 95개 파일을 동기화하고 2개를 삭제했습니다. 운영 환경을 대상으로 한 브라우저 QA가 찾아냈습니다.
  • cvstart.ru는 경로에 무엇이 있든 보지 않고 /ru/를 앞에 붙였습니다. /ru/help는 /ru/ru/help가 되고 /en/help는 /ru/en/help가 되어 둘 다 404였습니다. 앞의 것은 본 사이트에서 URL을 복사하면 생기고, 뒤의 것은 더 심각해서 영어권 방문자가 영어판에 도달할 길을 완전히 막았습니다. 이제 16개 로케일 접두사 중 하나를 이미 가진 경로는 그대로 통과하고, en은 제거되며(영어는 루트에 있고 /en/ 디렉터리가 없습니다), 나머지는 여전히 /ru/를 받습니다.

추가

  • 대체된 QA 프롬프트 98개를 qa/archive/superseded-prompts/로 옮겼습니다. qa/README.md는 v1.137.0부터 최상위에는 현재 릴리스의 프롬프트만 둔다고 기록해 왔지만, 이 규칙은 95개 릴리스 동안 지켜지지 않았습니다.
  • 다섯 개의 프롬프트가 더 최상위에서 내려왔습니다 — 스스로를 현행이라 칭했지만 아니었습니다. REGRESSION-FINAL.md는 자신을 “유일하게 권위 있는, 버전 비의존 회귀 프롬프트”라 불렀지만, §§11–15는 닫힌 다섯 개의 사이클 대장(v1.55.x → v1.59.7)이고 명시한 도움말 기준은 16개 로케일에 28 H2 / 103 H3 — 오늘의 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/로 옮겼습니다. 드라이버 넷이 남았고, 각자의 기준은 v1.231.2 실측값으로 갱신했습니다. qa/README.md는 실제로 존재하는 폴더를 설명하도록 다시 썼습니다.

참고

  • 상단 바를 고치며 빠졌던 명시도 함정 두 가지를 app.css에 주석으로 남겼습니다: 데스크톱 규칙이 max-width: 900px 블록보다 아래에 선언되어 있어 명시도가 같으면 뒤가 이깁니다 — 모바일 gap은 조용히 24 px로 남았고(이것이 320 px를 넘치게 했습니다) 아이콘은 계속 숨겨져 버튼이 빈 정사각형이 되었습니다.
  • applyI18n()이 el.textContent를 할당하므로 i18n 키를 안쪽 <span>으로 옮겼습니다. 버튼에 두면 언어를 바꿀 때마다 아이콘이 지워집니다.
  • 상위 프로젝트의 첫 rsync는 로컬에 더 이상 없는 파일에서 전송 도중 중단되고도 성공을 보고했습니다. 상위 배포는 이제 종료 코드가 아니라 체크섬으로 검증합니다.

[1.231.1] — 2026-09-06

수정 — v1.231.0의 카운트 일괄 치환이 모든 링크의 계정 이름을 바꿔 놓았습니다.

수정

  • 모든 Fighter90 링크가 Fighter92가 되었습니다. v1.231.0의 소스 카운트 치환은 숫자 경계 보호 아래 90을 92로 바꿉니다. 이 보호는 290과 901은 올바르게 건너뛰지만, Fighter90 안의 90에는 일치합니다. 앞은 알파벳이고 뒤는 그냥 토큰이 끝나기 때문입니다. 17개 README 전부, 17개 도움말 묶음 전부, 사이트의 도움말 미러에 걸쳐 562개 링크와 위키의 138개가 존재하지 않는 계정을 가리켰고, providers.png 배너와 모든 “GitHub에서 편집” 링크가 404였습니다. 이미지가 보이지 않는다는 사용자 제보로 발견했습니다.
  • 과거 릴리스 참조 5건이 어긋났습니다. 같은 릴리스의 85 → 87 치환이 v1.85.0을 v1.87.0으로 바꿔, 낸 적 없는 릴리스를 만들어냈습니다.

참고

  • 숫자 경계만으로는 부족합니다. 이웃한 숫자로부터는 지켜 주지만, 그 숫자가 식별자나 버전 문자열의 일부인 경우는 막지 못합니다. 카운트 자체는 정확하며 바뀌지 않았습니다: 소스 92, EN 어댑터 87, 테스트 3009.
  • 상위 저장소는 영향을 받지 않았습니다.

[1.231.0] — 2026-09-05

추가 — 상위 career-ops v1.32.0에서 온 스캔 소스 두 개(Collage, Telegram 엄격), Gem의 REST 모드, 그리고 이식 과정이 상위 자체 코드에서 찾아낸 결함 하나.

추가

  • 새 소스 두 개, 90 → 92(EN 87 + RU 5), ALL_ADAPTERS 85 → 87. 둘 다 토큰이 필요 없습니다.
    • Collage(provider: collage) — Collage HR의 공개 채용 사이트 API. 경로 구간은 테넌트가 직접 고른 사이트 주소이지 회사명에서 만든 슬러그가 아니므로, api: 또는 secure.collage.co/jobs/<주소> 채용 URL에서 읽어오며 결코 추측하지 않습니다. 추측한 주소는 남의 게시판을 스캔하게 됩니다.
    • Telegram(엄격)(provider: telegram-channel) — 기존 telegram 소스와 같은 t.me/s/<채널> 미리보기를 읽지만 반대의 교환을 합니다. 게시물이 행이 되는 것은 고용주를 명시하고 동시에 채용 페이지로 연결될 때뿐입니다. 모음 글, 고용주를 감춘 글, 연락처만 있는 글은 버립니다. 낮은 포괄률은 의도된 것으로, 상위에서 HR 운영 채널 기준 게시물의 32~77%가 통과하는 것으로 측정되었습니다. 대신 모든 행이 실제 고용주와 실제 링크를 가집니다. 상위와 마찬가지로 둘 다 제공됩니다.
  • Gem에 선택형 REST 모드가 추가되었습니다(GraphQL 경로와 병존, api: https://api.gem.com/job_board/v0/<board>/job_posts). 게시판 id에서 유도하는 일은 결코 없으며 GraphQL 경로가 기본으로 남습니다. 상위 #3783에서 이식.

수정

  • telegram-channel의 위치 필터가 키릴 문자를 보지 못해 도시 이름을 고용주로 반환했습니다. 상위의 LOCATIONISH_RE는 ASCII 전용인 \b를 사용했고, 이는 키릴 문자 옆에서는 결코 발동하지 않습니다. 그 결과 안에 있던 러시아어 후보(москва, спб, офис, удалёнк*)는 모두 도달 불가였고, Senior Engineer | Москва는 “Москва”를 고용주로 반환했습니다. 이는 귀속 정책 전체가 막으려는 “틀린 필드를 사실로 기록하는” 바로 그 동작이며, 하필 이 소스의 주 사용자층을 정면으로 칩니다. 러시아어 채널은 위치를 키릴 문자로 적기 때문입니다. web-ui 이식본은 유니코드 검사(\p{L} / \p{N})를 사용하며, 이는 상위가 바로 아래 ROLE_WORD_RE에서 이미 하고 있는 것과 같습니다. ASCII 형태에서 실패하는 테스트로 고정했습니다. 상위에서는 아직 수정되지 않았습니다.

참고

  • 상위의 Workday 패싯 복구 작업(#3851, #3874)은 이식이 필요 없었습니다. web-ui는 Workday를 페이징하거나 패싯으로 나누지 않습니다 — offset: 0으로 POST 한 번뿐입니다. v1.229.0과 같은 결론이며, 가정하지 않고 다시 확인했습니다.
  • **providers/_types.js**는 문서만 변경되었습니다.
  • 테스트: 2962 → 3009(+47).

[1.230.0] — 2026-09-05

변경 — 상위 career-ops v1.32.0에서 온 프로바이더 수정 3건과, 이식 과정 자체가 드러낸 결함 1건.

추가

  • Welcome to the Jungle이 서버 측 Algolia filters 식을 받습니다(wttj: { filters: "offices.country_code:FR AND contract_type:full_time" }). 키워드만으로는 전역 게시판을 좁힐 수 없습니다. 어떤 max_hits 결과가 돌아올지는 스캐너의 필터가 아니라 Algolia의 관련도 순위가 정하며, 우리 필터는 그 뒤에 이미 도착한 것에 대해서만 작동하므로 상한을 넘어선 것은 아무리 잘 맞아도 보이지 않습니다. 필터는 재정렬이 아니라 결과 집합 자체를 줄이므로 질의당 상한이 200에서 1000으로 올라갑니다. 필터가 있으면 queries는 선택 사항이 되고, 빈 질의는 “필터를 통과하는 모든 것”을 뜻합니다. 상위 #3757에서 이식.

수정

  • 테넌트가 자체 배너의 주장보다 단지 더 적은 공고를 제공할 뿐인 경우, Radancy는 더 이상 “truncated” 경고를 내지 않습니다. 상위 프로젝트는 data-total-results를 실제 테넌트 9곳과 대조했습니다. 5곳은 일치했지만 4곳은 — 작은 곳과 아주 큰 곳 모두 — 중복 행이 단 하나도 없는 상태로 공표치보다 10~56% 적은 고유 공고를 제공했습니다. 배너는 테넌트 자신의 검색 색인이 실제로 제공하는 양을 과장하므로, 누적 집계를 그것과 비교하는 방식은 대다수 게시판에서 오탐이었습니다. 이제 경고는 우리 자신의 max_jobs/max_pages가 순회를 멈춘 경우에만 나옵니다. 호출자가 조치할 수 있는 경우는 그것뿐이기 때문입니다. totalResults는 맥락으로 메시지에 남지만 판단에는 결코 관여하지 않습니다. #3839에서 이식.
  • Radancy는 모든 JSON 프래그먼트 요청에서 캐시를 우회합니다. 해당 경로 앞의 캐시 계층이 일부 테넌트에서 오래된 응답을 되돌려줍니다. 같은 공고가 몇 페이지 뒤에 다시 나타나는 반면 다른 공고는 한 번도 제공되지 않았고, 상류에서 9회 중 9회 재현되었습니다. Cache-Control/Pragma 헤더는 아무 차이도 없었고, 요청마다 무작위 매개변수를 붙이자 해결되었습니다. 이 값은 호출마다 고유해야 하며 페이지 번호에서 유도해서는 안 됩니다. 결정적인 매개변수는 캐시에 여전히 안정적인 키를 주고, 따라서 똑같이 안정적이면서 잘못된 대응을 남기기 때문입니다. #3839에서 이식.
  • Greenhouse 오피스 목록이 결정적 순서를 갖습니다. Greenhouse는 응답 간 offices[]의 순서를 보장하지 않으며, /offices 트리에서 복원한 집합은 그 순회 순서를 따릅니다. 둘 다 공고의 location 필드에 도달하므로, 게시판이 오피스 순서를 바꾸면 그 문자열이 다시 쓰였고 — 아무것도 바뀌지 않은 공고가 새 공고로 읽혔습니다. 상위 프로젝트는 집합을 고쳤고(#3839), web-ui에는 상위에 없는 동일 결함의 두 번째 사례가 있었습니다 — 공고 자체에 위치가 없을 때 쓰이는 offices[0] 폴백입니다. 이제 둘 다 정렬됩니다.

참고

  • 상위의 iCIMS 수정(#3728)은 이식이 필요 없었습니다. 이는 상세 페이지 JSON-LD의 모든 jobLocation 항목을 읽습니다. 일부 테넌트가 실제 주소 앞에 전부 UNAVAILABLE인 항목을 내보내기 때문입니다. web-ui의 iCIMS는 목록 페이지 HTML에서 위치를 가져오며 상세 페이지를 열지 않습니다.
  • 상위의 _http.mjs 변경도 이식이 필요 없었습니다. isRefusedRedirectError를 분리해 두 번째 소비자와 판단을 공유하기 위한 것인데, web-ui에는 그 두 번째 소비자가 없고 isRetryableError의 동작은 그대로입니다.
  • 위치 인식 중복 제거(#3751)는 반영하지 않습니다 — 상위 CLI의 scan.mjs 파이프라인에 있는 기능입니다.
  • 레지스트리 변동 없음: 90개 소스(EN 85 + RU 5). 테스트: 2956 → 2962(+6).

[1.229.0] — 2026-09-03

추가 — 상위 career-ops v1.31.0에서 스캔 소스 네 개: Built In, Feishu Jobs, Garena, MokaHR.

추가

  • 새 소스 4개, 86 → 90(EN 85 + RU 5), ALL_ADAPTERS 81 → 85. 모두 토큰이 필요 없습니다. Built In은 미국 기술 채용 보드의 아홉 개 시장을 아우르지만 기본 질의를 담지 않습니다: queries:도 categories:도 없는 항목은 아무것도 스캔하지 않고 그렇다고 말합니다 — 공유 소스가 특정인의 검색어를 지녀서는 안 되기 때문입니다. 허용 목록에 없는 시장은 거부되며, 조용히 전국 보드로 대체되지 않습니다. Feishu Jobs는 호스트 형태를 둘만 받아들이고, 둘은 채용 페이지 경로가 서로 다릅니다. Garena는 단일 기업이며 office는 링크를 만들 뿐 목록에는 영향을 주지 않습니다. MokaHR는 암호화된 응답(키가 함께 오는 AES-128-CBC 봉투)을 돌려줍니다 — 보안이 아니라 난독화지만, 복호화하지 않으면 아무것도 읽을 수 없습니다.

수정

  • htmlToText가 불완전한 태그 여는 문자를 텍스트로 흘릴 수 있었습니다. safe &lt;img src=x onerror=1이 safe <img src=x onerror=1로 나왔습니다. 이제 매 디코드 뒤에 제거가 이어지고, 끝의 여는 문자는 꺾쇠를 잃습니다. 상위 프로젝트에서 이식(#3491).

참고

  • 상위의 Workday 수정에서는 아무것도 이식하지 않았습니다. 그것은 백엔드가 오프셋 2000에서 페이지네이션을 막는 테넌트를 구제하지만, web-ui는 Workday를 아예 페이지네이션하지 않습니다(offset: 0에서 POST 한 번, 100행).
  • verify-cv-facts와 merge-tracker 수정은 이식이 필요 없었습니다: web-ui는 그 스크립트들을 상위 체크아웃에서 실행합니다.
  • 테스트: 2909 → 2956(+47).

[1.228.5] — 2026-08-30

수정 — 보고되는 버전은 실행 중인 코드가 아니라 디스크의 파일을 가리켰습니다.

수정

  • /api/health가 매 요청마다 package.json을 다시 읽어, 오래된 프로세스가 자신이 실행하지 않는 버전을 보고했습니다. QA는 version: 1.228.4라고 답하면서 1.228.3에서 추가된 /api/ping에는 404를 반환하는 로컬 인스턴스를 찾았습니다. 배포 전에 시작되어 옛 코드를 서비스하면서 새 파일의 버전을 보고하고 있었던 것입니다. 파일만 복사하고 재시작하지 않은 배포는 바로 이렇게 성공처럼 보입니다 — 운영자가 확인하는 유일한 문자열이 그 문제를 볼 수 없는 문자열입니다. v1.228.1의 심볼릭 링크와 같은 부류로, 그때도 rsync는 전송 도중 중단하고서 성공을 보고했습니다.

    버전은 이제 모듈 로드 시점에 포착되어 실행 중인 코드를 가리킵니다. 오래된 프로세스는 옛 버전을 보고합니다 — 그것이 진실이며 첫 요청에서 바로 보입니다. parentVersion은 의도적으로 요청마다 읽습니다. 이 프로세스가 로드하지 않고 실행 중인 서버 아래에서 정당하게 바뀔 수 있는 상위 체크아웃을 가리키기 때문입니다.

[1.228.4] — 2026-08-30

수정 — 인증 없는 활성 상태 프로브가 매 요청마다 디스크 작업을 했습니다.

수정

  • GET /api/ping이 매 요청마다 package.json을 읽고 파싱했습니다. 자격 증명 없이 도달할 수 있는 유일한 엔드포인트이므로, 요청당 디스크 읽기와 JSON 파싱은 누구에게나 건네진 서비스 거부 지렛대입니다 — CodeQL은 이를 속도 제한 누락으로 표시했습니다. 버전은 이제 라우트 등록 시 한 번만 읽습니다. 재시작 없이는 바뀔 수 없으므로 핸들러는 I/O를 전혀 하지 않습니다. 속도 제한은 피해에 상한을 두지만, 작업을 없애면 지렛대 자체가 사라집니다.
  • 루프백 외부에서 프로필 소유자의 실명을 가리는 동작이 이제 테스트로 고정되었습니다. /api/health는 이미 hidden으로 대체하고 있었지만, 그 행이 프로젝트 루트와 hidden ?? value 가드를 공유하기 때문일 뿐이었습니다. 아무것도 이를 검증하지 않았으므로, 그 행에 자체 값을 주는 수정이 있었다면 실존 인물의 이름이 아무도 모르게 LAN으로 새어 나갔을 것입니다.

[1.228.3] — 2026-08-30

Fixed — the docs assistant could not answer in English, Telegram company names arrived truncated, and a monitor had no way to see the service was up.

Fixed

  • The docs assistant answered “no matching help sections were found” in English while the same question worked in Russian. Retrieval was fine; the context builder was not. buildAskPrompt did if (ctx.length + sec.body.length > MAX_CONTEXT) break — a break, so one oversized section ended the loop and left the context empty, and the assistant truthfully reported it had nothing. The English §5 had grown to 16 081 bytes against a 14 336-byte budget (partly because v1.228.0 added telegram_channels to it); the Russian §5 was 13 009 and fit. Same ranking in both languages — §5 was first — only the size differed.

    Two changes: a section past 6 KB is now split at its ### boundaries, so telegram_channels is its own retrievable chunk (1 340 bytes) that ranks first in both languages; and an oversized section is skipped or truncated rather than ending the loop. The guide went from 32 chunks to 75.

  • A Telegram company label yielded the sentence after it, not the name. Компания: FinCore Technology (продуктовая разработка, высоконагруженные системы) became FinCore Technology (продуктовая разработка, высоконагруженны — capped at 60 characters, cut mid-word. Three of 141 rows. The name now ends at the first separator that introduces a description — a spaced dash (so Coca-Cola survives), a comma, semicolon, pipe or opening parenthesis — and any remaining cap falls on a word boundary. This source attributes a company as fact; a truncated sentence is as wrong as a guessed name, only less obviously so.

Added

  • GET /api/ping — a liveness probe that leaks nothing. { ok, version } and no more. /api/health reports absolute paths, the profile owner’s real name and which API keys are set: legitimate for an authenticated operator, never for the public internet. A monitor behind a Basic-auth gate previously had no way to see the service was up at all.

[1.228.2] — 2026-08-30

Fixed — a two-word search in /api/scan-results matched the phrase, not the words, and under-reported badly.

Fixed

  • ?q= now matches every term independently instead of the literal phrase. The query was a single includes(q) over title + company + location, so продакт менеджер found only rows carrying those two words in that exact adjacency — 32 of the 162 that carry both. Менеджер продукта and Менеджер по развитию продукта were invisible to it: the same words, reordered or with filler between them.

    This is how a narrow match becomes a wrong answer. The Telegram assistant asked for all Продуктовый менеджер reported 9 — faithfully, because 9 was what the endpoint said. The count it quoted was honest; the endpoint’s was not. Word order and the words between are not something a person typing two words intended to pin.

    Matching is AND across terms, so widening precision costs nothing: a row must still carry every term. менеджер разработчик returns zero rather than the union.

[1.228.1] — 2026-08-29

Fixed — a dangling symlink in the repo that silently truncated deploys.

Fixed

  • .claude/skills/refero-design was a tracked symlink to a path that has never existed here. It pointed at ../../.agents/skills/refero-design, which resolves to .agents/ at the repo root; there is no such directory, and the link arrived in the v1.118.0 parity pack from a machine where the surrounding folders nested differently. Anyone cloning the repo has been getting a broken link ever since.

    It was not inert. rsync aborts on it mid-transfer — a deploy today reported success while the server kept running the previous version, and the mismatch only surfaced because the version was checked afterwards. The parent project’s node test-all.mjs also crashes outright when a web-ui checkout sits inside its tree, because its fixture copy walks the working directory and cannot stat the link. Nothing referenced it: the only refero matches in the repo are the Polish word preferowany.

[1.228.0] — 2026-08-29

추가 — 스캔 소스로서의 Telegram 채널과 /api/scan-results의 페이지네이션 질의 모드.

추가

  • Telegram 채널이 스캔 대상이 되었습니다. portals.yml의 telegram_channels: 블록에 나열하며, 각 채널은 공개 웹 미리보기 https://t.me/s/<채널>에서 읽습니다. 레지스트리: 85 → 86개 소스(EN 81 + RU 5), ALL_ADAPTERS 80 → 81.

    Telegram은 RSS를 제공하지 않고, Bot API는 봇이 관리하지 않는 채널을 읽을 수 없습니다. 즉 뻔한 경로 두 개가 모두 막혀 있습니다. /s/ 미리보기는 서버가 렌더링한 순수 HTML로 인증도 JS도 쿠키도 필요 없습니다. 16개 채널에서 검증: 15개가 맨 GET에 20건씩 반환했습니다.

    channel:은 어떤 표기든 받습니다. 파서는 Telegram 리디자인이 가장 나중에 옮길 세 가지 — data-post, 본문 컨테이너, ISO 날짜 <time datetime> — 에 기댑니다.

  • GET /api/scan-results에 페이지네이션 질의 모드가 생겼습니다. ?q=<텍스트>&limit=50[&region=ru|en][&set=filtered|fresh][&offset=N]가 { total, returned, offset, limit, set, rows }를 반환하며, total은 페이지네이션 이전의 개수입니다. 파라미터가 없으면 예전처럼 스냅샷 전체를 반환합니다 — #/scan 테이블이 그 형태에 의존하기 때문입니다.

수정

  • Telegram 어시스턴트는 데이터의 일부만 보고 답했습니다. 스킬은 이미 /api/scan-results를 가리키고 있었지만, 이 라우트는 스냅샷 전체만 반환할 수 있었고 실제 장비에서 약 2MB — 에이전트 컨텍스트에 들어가지 않습니다. “Продуктовый менеджер 전부 찾아줘”에 스냅샷에 수백 건이 있는데도 9건이라 답하고, “제한된 로컬 인덱스로 동작한다”고 말했습니다. 사실이었고, 원인은 엔드포인트가 보이지만 소화할 수 없다는 것이었습니다. 페이지네이션 모드로 같은 질문이 2KB로 끝나고 정직한 total을 돌려줍니다. 스킬도 다시 썼습니다: 새 게시물을 요약하라고 되어 있어 filtered(남은 전부)가 아니라 fresh(직전 실행분)를 가리켰기 때문입니다.

참고

  • 채널 게시물은 산문이지 채용 레코드가 아닙니다. 구조화된 필드가 없습니다: 제목은 첫 실질적 줄에서, 회사·근무지·연봉은 명시적 라벨에서 취하고 본문은 설명에 넣습니다. 진짜 공고를 광고·다이제스트와 가르는 일은 기존 title_filter — 다른 소스용으로 이미 조정된 바로 그것 — 에 맡깁니다. 단서이지 목록이 아닙니다.
  • 회사는 절대 추측하지 않습니다. 라벨이 없으면 행은 채널(@rabotaphp)에 귀속됩니다. 잘못된 고용주는 트래커에 사실로 들어가기 때문입니다.
  • 파싱 결과 0은 오류이지 “공고 없음”이 아닙니다. 비공개·부재 채널에 t.me는 리디렉션으로 답합니다. 여기서 0건을 반환하면 조용한 하루로 읽히고 설정의 오타를 영원히 감춥니다. 원래 목록의 jobGeeks가 정확히 그러하며(HTTP 302) 의도적으로 설정하지 않았습니다.
  • 원격 판정은 \b 대신 유니코드 단어 경계를 씁니다. \b는 ASCII 전용이라 키릴 문자 앞의 공백은 경계가 되지 않습니다 — v1.227.3에서 제목 필터가 밟은 바로 그 함정이며, 이번엔 출시 전에 테스트가 잡았습니다.
  • 채널 15개 설정 및 실데이터 검증: 299개 게시물, 15/15 채널. 신규 테스트 28개. 테스트: 2865 → 2895.
  • The README test counts jumped by hundreds, and no, this release did not add them. The localized READMEs carried unit-test totals frozen at 1856 / 1945 / 1955 / 549 / 419 / 284 / 2527 depending on the file — each stale since a different release, and several contradicting the badge at the top of the same page. They now all read 2895, verified by running the suite rather than by copying a badge. Playwright (70 / 90 / 12 → 101), e2e smoke (20 → 21), route modules (12 / 32 → 37), smoke flows (12 → 22) and the test-file count (218 → 329) were stale in the same way. The Total row also repeated the unit count, so it under-summed the three suites below it; it now reads the real sum (3040).

[1.227.5] — 2026-08-29

수정 — 지역 스캔이 서버 메모리를 고갈시켰고, Playwright 스위트 하나가 자체 teardown에서 불안정하게 실패했습니다.

수정

  • 지역 스캔이 OOM으로 서버를 죽였습니다. ru-scanner는 모든 쿼리의 원시 결과를 전부 쌓아두고 중복 제거와 필터링을 맨 마지막에만 수행했습니다. 쿼리 목록은 의도적으로 유의어투성이라(Golang, Go разработчик, Golang разработчик, Senior Go …) 같은 공고가 쿼리마다 돌아오고, 배열은 고유 건수의 몇 배를 들고 있었습니다 — 게다가 채용 객체의 대부분을 차지하고 필터가 거의 다 버리는 설명문까지 함께. 실측: 21개 쿼리 실행에서 힙 742MB. 서버는 Node 힙을 490MB로 제한하며(V8이 머신의 956MB RAM에서 산정), 스캔은 FATAL ERROR: Reached heap limit로 서비스를 죽였습니다 — 하루에 네 번. 이제 중복 제거와 필터링이 쿼리마다 이뤄져 같은 실행이 177MB로 내려갔습니다(76% 감소). 보고되는 수치는 그대로입니다: 고유 합계는 URL 문자열 Set이 담습니다.
  • net::ERR_SOCKET_NOT_CONNECTED가 CI를 무작위로 떨어뜨렸습니다. 세 개의 Playwright 스위트가 콘솔 오류가 없음을 확인하기 전에 window.stop()으로 진행 중인 요청을 중단합니다. Chromium은 이 하나의 의도적 동작을 소켓 상태에 따라 세 가지 이름으로 보고합니다: 취소 시 ERR_ABORTED, 전송 중 ERR_EMPTY_RESPONSE, 소켓이 이미 사라졌으면 ERR_SOCKET_NOT_CONNECTED. 공용 필터는 앞의 둘만 알고 있었습니다. 로케일 스윕의 tr에서 드러났고, 역시 window.stop()을 부르는 playwright-forms와 playwright-smoke도 운 나쁜 한 번이면 같은 실패였습니다.

참고

  • 왜 이제서야 실패했는가. 프로덕트 매니저 검색을 추가하며 쿼리가 14개에서 21개가 되었습니다. 이전 구현의 메모리는 총 결과 수에 비례했기에 쿼리 +50%가 머신의 한계를 넘겼습니다.
  • 중복 제거 변경은 동작을 보존하며, 주장이 아니라 테스트로 담보합니다. tests/ru-scanner-dedup-order.test.mjs가 까다로운 방향 — 뒤에 온 중복이 필터를 통과하지 못하면 앞서 통과한 것을 밀어내야 한다 — 을 고정하고, 옛 구현과의 등가성을 200회 무작위 시행으로 검증합니다.
  • 콘솔 필터는 좁게 유지됩니다. 취소된 요청 계열만 무해합니다; 연결 거부, DNS 실패, 리셋, 모든 5xx, 잡히지 않은 JS 예외는 여전히 단언을 실패시킵니다.
  • 두 가설은 검증 후 폐기했고 릴리스에 넣지 않았습니다: Caddy 타임아웃(설정은 깨끗하고 SSE용 flush_interval -1도 정확)과 Node 기본 5분 requestTimeout(축소 재현 결과 긴 스트리밍 응답을 끊지 않음). 실제 증거는 저널의 Reached heap limit이었습니다. 테스트: 2860 → 2865.

[1.227.4] — 2026-08-29

수정 — word: / stem:가 content_filter에서는 여전히 무시되고 있었습니다. v1.227.3은 제목 필터만 고쳤습니다.

수정

  • content_filter가 접두사를 존중하지 않았습니다. v1.227.3은 title_filter에 상위의 word: / stem: 접두사를 가르쳤지만, content_filter는 자체의 날것 lower.includes(k) 그대로여서 접두사가 붙은 항목이 여전히 문자 그대로 "word:java"로 대조되었습니다 — 같은 조용한 no-op이 필터 하나 건너에 남아 있었던 셈입니다. content_filter는 채용 공고의 설명을 읽으며 기본값은 늘 단순 부분 문자열이었습니다. 그래서 맨 부정 java는 「JavaScript」를 언급만 해도 전부 걸러냅니다. 접두사는 그 기본값에서 특정 항목 하나만 빼는 수단입니다. 상위의 compileContentKeyword를 이식했습니다. 접두사 machinery는 공유하되 짧은 키워드는 의도적으로 자동 고정하지 않습니다 — 제목 필터가 2~3글자 약어를 고정하는 이유는 「Coordinator」 속 「COO」가 항상 오답이기 때문이지만, 설명문 단락 속 짧은 문자열은 보통 의도된 것이기 때문입니다(aws, gcp, sql, go).

참고

  • v1.227.3 PR의 AI 리뷰에서 발견되었습니다. 코드가 자기 주석이 주장하는 바를 충족하지 못한다는 지적이었습니다 — 주석은 「title_filter / content_filter」라고 적혀 있었지만 연결된 것은 제목 경로뿐이었습니다. 이제 주석이 명시적으로 구분합니다: 접두사는 두 필터 모두에서 동작하고, 약어 자동 고정은 제목에서만.
  • 같은 리뷰에서 검증했고 변경 없음: 2~3글자 약어 규칙을 \b에서 유니코드 경계로 옮긴 것은 ASCII에서 동작이 동일하며 필요한 곳에서만 더 엄격합니다 — COO_lead와 coo1은 계속 유지(밑줄과 숫자는 양쪽 모두 단어 문자), Coordinator 유지, 「Директор COO」와 「VP «Продукт»」는 올바르게 제외. 14개 경계 사례 확인.
  • 접두사 없는 content_filter 항목은 이전 일치를 바이트 단위로 유지하고, 비었거나 없는 설명은 여전히 통과하며, 내용 없는 word: / stem:는 전부가 아니라 아무것과도 일치하지 않습니다. tests/content-filter.test.mjs에 6개 케이스 추가. 테스트: 2852 → 2858.

[1.227.3] — 2026-08-29

수정 — 제목 필터의 word: / stem: 접두사가 조용히 무시되어 해당 필터 줄이 사실상 무효였습니다.

수정

  • web-ui는 상위 프로젝트의 word: / stem: 접두사를 구현하지 않았습니다. title_filter(및 content_filter)는 기본적으로 대소문자를 구분하지 않는 부분 문자열 일치라서, 맨 intern 부정 항목은 「International Product Manager」와 「Internal Tools Engineer」까지 걸러냅니다. 이 기본값을 뒤집으면 설정된 모든 설치가 깨지므로, 상위 프로젝트는 항목별로 정밀도를 선택하게 했습니다: word:intern은 온전한 단어만, stem:agent는 단어의 시작에 일치하되 이어질 수 있습니다(「Agentforce」와 「Reagents」를 구분). web-ui에는 둘 다 없어 항목을 문자 그대로 "word:intern"으로 대조했고, 그런 문자열이 든 채용 제목은 없습니다. 그 줄은 조용한 no-op이 되어, 같은 portals.yml이 CLI에서는 제대로 걸러지고 여기서는 전혀 걸러지지 않아, 제외했다고 여긴 인턴 공고가 모두 #/scan 표에 도달했습니다. 상위의 title-keywords.mjs에서 이식했으며, ASCII 전용이라 악센트·키릴 제목에서 단어 중간에 일치하던 \b 대신 유니코드 인식 단어 경계(\p{L}\p{M}\p{N}_)도 함께 가져왔습니다.

참고

  • 실제 portals.yml을 확인하다 발견했습니다. 맨 intern이 「International Product Manager」를 떨어뜨리지 않게 하려고 바로 그 목적으로 word:intern을 막 도입한 상태였습니다. CLI에서는 접두사가 작동했고 web-ui는 조용히 모든 인턴 공고를 남겼습니다. 이제 동작이 동일합니다.
  • 기본값은 그대로입니다. 접두사 없는 키워드는 이전 일치 방식을 유지합니다: 구절이나 비문자를 포함한 항목(.NET, SAP , L&D)은 부분 문자열, 2~3글자 약어는 단어 경계 고정. 내용이 없는 word: / stem:은 오타로 보고 아무것과도 일치하지 않습니다 — 부정으로 쓰이면 빈 패턴이 모든 제목에 일치해 콜론 하나로 스캔 전체를 무효화하기 때문입니다.
  • 2~3글자 약어 규칙도 같은 유니코드 경계를 쓰도록 통일했습니다. 이전에는 \b를 써서 비ASCII 제목에서 coo와 word:coo가 어긋났습니다 — 한 규칙의 두 가지 표기였습니다.
  • tests/title-filter.test.mjs에 6개 케이스 추가. 테스트: 2846 → 2852.

[1.227.2] — 2026-08-29

수정 — 폭이 좁은 휴대폰에서 #/config가 가로로 밀려났습니다.

수정

  • MOBILE-1: 352 px 이하 뷰포트에서 #/config가 가로로 넘쳤습니다. 모든 select 계열 필드가 인라인 min-width: 300px를 달고 있었습니다. 인라인 스타일은 스타일시트를 이기므로 컨트롤이 컨테이너에 맞춰 줄어들 수 없었고, 320 px 뷰포트에서는 select의 오른쪽 끝이 353 px에 놓여 페이지에 가로 스크롤이 생기고 컨트롤이 오른쪽 밖으로 밀려났습니다. 프로바이더/모델 select 18개 전부(cfg-llm-provider와 모든 cfg-*-model)가 영향을 받았습니다. 이제 min-width: min(300px, 100%) + max-width: 100%로, 공간이 있을 때만 300 px 폭이 적용됩니다. 임계값 이하 기기: iPhone SE(1세대, 320 px), 구형 Android, Galaxy Fold 외부 화면.

참고

  • 이전 모바일 점검이 모두 놓친 이유. 360 px와 375 px에는 넘침을 가릴 만큼의 여유가 딱 있고, Playwright 스위트는 1280 px 창에서 돕니다. CI에서 375 px보다 좁은 뷰포트를 측정한 적은 한 번도 없었습니다. 새 tests/playwright-narrow-viewport.mjs는 폼이 있는 라우트를 320 px에서 훑고 가로 넘침이 있으면 실패하며, 메시지에 가장 오른쪽으로 튀어나온 요소를 지목합니다. 두 번째 케이스는 #/config의 어떤 select도 뷰포트를 넘지 않음을 고정합니다. 먼저 버그를 재현하는지 확인했습니다(scrollWidth 353 > clientWidth 320, 오른쪽 끝 353인 select 18개). Playwright: 99 → 101.
  • 알려진 사항이며 의도적으로 아직 게이트 대상 아님: 280 px(Galaxy Fold 외부 화면)는 여전히 약 5 px 넘칩니다. 원인은 다릅니다 — 설정 카드의 <details> 블록 안의 긴 콘텐츠와 280 px 뷰포트에 대한 256 px 사이드바이며, 이번 릴리스가 고친 인라인 min-width가 아닙니다. 주류 휴대폰이 보고하지 않는 폭에 맞춘 재배치는 별도 작업이고, 스위트의 WIDTHS 목록은 준비되어 있습니다.
  • v1.227.1 QA 프롬프트도 수정했습니다. 팔로업 라우트를 #/modes/followup이라 적었지만, mode 페이지는 슬러그를 그대로 등록하므로(Router.register(cfg.slug)) 라우트는 #/followup이며 #/modes/followup은 정상적으로 404입니다. 문서만 변경.
  • 유닛 스위트는 2846로 변동 없음(이번 추가 커버리지는 브라우저 레벨).

[1.227.1] — 2026-08-29

수정 — 도움말의 소스 내역이 스스로 맞지 않았고, GitHub용 배너가 앱에서 원시 마크업으로 출력되었습니다.

수정

  • §17의 합계와 내역이 맞지 않았습니다. 모든 도움말 번들이 “server/lib/sources/ 레지스트리는 85개의 어댑터를 포함합니다 — 영문 78개 + 러시아어 5개”라고 적고 있었습니다. 78 + 5 = 83이지 85가 아니며, ja/ko는 더 벌어져 77(= 82)이었습니다. 새 소스가 추가될 때마다 합계만 올리고 항은 그대로 두어, 두 숫자는 여러 릴리스에 걸쳐 어긋나 있었습니다. 이제 내역은 17개 로케일 모두에서 EN 80 + RU 5입니다. 이는 미관 이상의 문제입니다. docs/help/<lang>.md는 “Ask the docs”가 근거로 삼는 유일한 코퍼스이므로, 지원 소스 수를 물은 사용자는 한 문장에서 서로 모순되는 두 숫자를 받았습니다.
  • 같은 문장이 오래된 버전 앵커를 고정하고 있었습니다. “v1.213.0 기준으로 레지스트리는 …”이 16개 로케일에 남아 있는 동안 카운터는 두 번 움직였습니다(v1.219.0에서 83, v1.226.0에서 85). 이 앵커는 v1.210.1에서 이미 수작업으로 고쳐졌다가 다시 밀렸습니다. 버전 없는 표현으로 대체해 제거했습니다 — 카운터 자체는 살아 있는 레지스트리와 대조해 검증되며, 낡을 수 없습니다.
  • 공급자 배너가 #/help 맨 위에 이스케이프된 마크업으로 출력되었습니다. v1.225.0/v1.225.1은 “UI.md()에 이미지 지원이 없어 앱에서 제거된다”고 기록했습니다. 이는 서로 다른 두 가지를 뒤섞은 것입니다. UI.md()는 이스케이프 우선이라 markdown 변환 전에 소스의 모든 바이트를 이스케이프하며, 이는 올바른 XSS 경계지만 제거와는 반대입니다. 아무도 제거를 시도하지 않았기에 아무도 제거하지 않았고, 17개 로케일 모두가 #/help를 268자의 literal <p align="center"><img src="https://…로 열었습니다. 새로운 공용 입구(server/lib/help-markdown.mjs::stripGithubOnlyBlocks)가 읽는 시점에 단독 HTML 이미지 블록을 제거합니다 — GET /api/help/:lang과 docs-assistant 코퍼스 양쪽 모두에서. 파일에는 배너가 남으므로 GitHub 쇼케이스는 그대로 렌더링됩니다.
  • 케이던스 보드의 “#” 열은 v1.117.0 이후 계속 비어 있었습니다. entry.appNum을 읽었지만 followup-cadence.mjs가 내보내는 것은 num이며, appNum은 페이로드에 존재한 적이 없습니다. 이제 둘 다 읽습니다. v1.227.0의 “Next up” 줄도 같은 실수를 물려받았고 함께 고쳤습니다.

참고

  • 네 건 모두 게이트가 없어 발견되지 못했습니다. 도움말 게이트(canonical-docs-coverage, help-ui, help-ru-config-section)는 제목을 셉니다 — 32 H2 / 121 H3 — 그리고 문단 안의 숫자를 읽거나 도움말 입구가 실제로 무엇을 제공하는지 본 테스트는 없었습니다. 새 스위트 두 개가 이를 막습니다: tests/help-source-counts.test.mjs(명시된 합계가 SOURCES.length와 같고, 항이 살아 있는 EN/RU 분할과 같으며 합계가 되고, 문단이 버전을 고정하지 않음)와 tests/help-banner-strip.test.mjs(배너는 GitHub용으로 디스크에 남고, strip 후에는 절대 살아남지 않으며, 각 번들의 첫 렌더 줄이 제목이고, strip이 좁게 유지되며, 두 소비자가 모두 그곳을 통과함). 둘 다 커밋 전에 바로 그 버그들에 대해 실패하는지 확인했습니다. 테스트 스위트: 2837 → 2846.
  • 레지스트리·스캐너·라우트 계약 변경 없음. 소스는 85개(EN 80 + RU 5), ALL_ADAPTERS는 80 그대로입니다.

[1.227.0] — 2026-08-28

추가 — SEEK 홍콩(JobsDB), 팔로업 기한 도래만 보기, 그리고 비공개 고용주가 이를 중개한 에이전시를 표시합니다.

추가

  • 기존 Jobstreet/SEEK 소스를 통한 JobsDB 홍콩. 홍콩은 JobsDB 브랜드로 운영되지만 같은 v5 엔드포인트 뒤의 동일한 SEEK 플랫폼 위에 있어 별도 소스가 필요 없습니다 — 호스트 허용 목록의 hk.jobsdb.com과 siteKey: HK-Main이면 충분합니다. 레지스트리는 그대로 85개 소스(EN 80 + RU 5), ALL_ADAPTERS 80 — 기존 어댑터에 추가된 새 시장이지 새 어댑터가 아닙니다.
  • 케이던스 보드의 기한 도래만 보기(#/modes/followup). 새 체크박스가 보드를 지금 조치가 필요한 항목으로 좁힙니다. 기한 도래 여부는 상위 프로젝트의 followup-cadence.mjs가 이미 계산한 urgency(urgent / overdue)에서 읽으며, 트래커의 status는 절대 쓰지 않습니다 — 그것은 applied/responded/interview라 아무것도 일치하지 않습니다. 기한 도래 항목이 없으면 보드는 빈 화면 대신 가장 가까운 예정 팔로업을 알려 줍니다. 로직은 상위의 followup-view.mjs를 옮긴 순수 모듈 public/js/lib/followup-view.js에 있습니다.
  • 비공개 고용주는 이를 중개한 에이전시로 표시됩니다. 고용주가 비공개인 공고는 트래커에 ?로 기록되고 리크루터는 Via 열에 들어갑니다. #/tracker는 맨 ?만 보여 주어 여러 비공개 행을 구분할 수 없었고 로고 리졸버도 맞출 대상이 없었습니다. 이제 그런 행은 **“비공개 · <에이전시> 경유”**로 표시되고, 로고를 에이전시 이름으로 해석하며, 검색창에서 그 이름으로 찾을 수 있습니다. 표시 전용입니다 — 정본 company 필드는 리크루터로 덮어쓰지 않습니다. 상위의 company-presentation.mjs를 옮긴 새 순수 모듈 public/js/lib/company-presentation.js.

수정

  • Jobstreet/SEEK가 폐기된 엔드포인트를 호출하고 있었습니다. 소스가 여전히 SEEK가 중단한 chalice-search v4(/api/chalice-search/v4/search)를 가리키고 있었습니다. 상위 프로젝트는 /api/jobsearch/v5/search로 이전했지만 web-ui는 따라가지 않았습니다. 이제 모든 Jobstreet/SEEK 항목이 v5를 조회합니다. v5 항목 구조를 기본으로 읽고(id로 URL 생성, locations[0].label, advertiser.description, salaryLabel), v4 전용 solrFields 프로젝션 매개변수는 사라졌으며, portals.yml이 아직 죽은 v4 경로를 고정한 항목도 시장 호스트명은 유지한 채 경로만 v5로 재구성됩니다 — 사용자가 손댈 필요 없습니다.

참고

  • career-ops 1.30.0 동등성(상위 HEAD 8d64f65 → 6cc46a4; 상위의 VERSION은 아직 1.30.0 — release-please가 main보다 뒤처집니다). 미이식 및 사유: iCIMS JSON-LD 위치 채우기 — web-ui의 iCIMS 소스에는 enrichDate() 상세 페이지 훅이 아예 없어(목록 페이지 전용) 고칠 경로가 없음; 비치명적 CLI stderr(#1974) — web-ui 러너는 종료 코드만으로 실패를 판정하며 그 휴리스틱이 없었음; Turbopack 경로 추적 — web-ui에는 번들러가 없음; cv-sync-check 수정 — 읽기 전용 fail-soft 중계; scan.mjs의 Playwright 순차 실행, Block H 답변, updater/doctor/update-system 가드, 재귀 문법 검사, Go 대시보드, jd-skill-gap/verify-cv-facts 자체 테스트 추가 — web-ui가 호출하지 않는 CLI 전용 표면이거나 동작 변화가 없는 자체 테스트. 테스트: 2818 → 2837.

[1.226.0] — 2026-08-27

추가 — 베트남 채용 소스 2종: ITviec와 CareerViet.

추가

  • ITviec + CareerViet(베트남). 호스트 고정된 베트남 보드 2종이 provider: itviec / provider: careerviet 항목으로 스캐너에 합류합니다: ITviec(itviec.com, 기술 중심)와 CareerViet(careerviet.vn, 광범위). 둘 다 토큰 없는 HTML 스크레이퍼로 SSRF 호스트 고정·HTTPS 전용·회사별 fail-soft·페이지 상한을 갖춥니다. 레지스트리는 이제 85개 소스(80 EN + 5 RU), ALL_ADAPTERS 80 — 둘 다 #/scan 소스 필터에 표시됩니다.

참고

  • career-ops 1.30.0 패리티(parentVersion 1.29.0 → 1.30.0). 미포팅(CLI/커뮤니티 전용 또는 이미 반영): Hired Wall, /calibrate, clean-markers, 템플릿 팩, doctor, pdf/LaTeX, web-ui가 호출하지 않는 eval/tracker/scan CLI들; _html-to-text 수정과 detect-reposts roleFuzzyMatch는 이미 있었음; title-filter stem: 접두사는 보류. 테스트 스위트: 2818.

[1.225.1] — 2026-08-27

수정 — 제공자 로고 배너가 이제 README와 도움말에 표시됩니다.

수정

  • v1.225.0 배너가 GitHub에서 표시되지 않았습니다: 독립형 images/providers.svg의 font-family에 중첩된 큰따옴표(GitHub가 거부)가 있었고, <img>가 저장소 상대 src를 사용했으며(GitHub는 raw HTML <img>의 상대 경로를 해석하지 않음), <p> 블록에 끝 빈 줄이 없어 다음 줄과 합쳐졌습니다. 이제 렌더링된 images/providers.png를 절대 raw URL로 가리키며 빈 줄로 분리됩니다. SVG도 유효한 XML로 수정했습니다.

참고

  • 문서만; 코드/테스트 변경 없음. cvstart.org 쇼케이스와 앱의 제공자 타일은 이미 정상이었습니다(인라인 SVG, 이 파일 아님). 2784

[1.225.0] — 2026-08-27

추가 — README와 도움말 가이드에 제공자 로고 배너.

추가

  • 제공자 로고 배너. 하나의 자립형 images/providers.svg — 18개 LLM 제공자를 담아, 아이콘을 게시하는 11개는 실제 브랜드 로고(simple-icons, CC0), 나머지 7개는 브랜드 색상 모노그램 — 가 이제 README(×17)와 인앱 도움말 가이드 상단에 표시됩니다. (앱은 실제 로고 타일을 직접 렌더링하며, 배너는 GitHub에서 렌더링되고 앱의 XSS-안전 렌더러가 제거합니다.)

참고

  • 문서만 — 코드/테스트 변경 없음; SVG는 인라인(원격 자원 없음). 스캔 소스 변경 없음: 83. 테스트 스위트: 2784.

[1.224.0] — 2026-08-26

변경됨 — LLM 제공자 타일이 이제 실제 브랜드 로고를 표시합니다.

변경됨

  • 실제 제공자 로고. 제공자 타일 — 설정의 API 키 필드, 사용량 행, 대시보드 칩, 설정 “활성” 요약, ⚡ 평가 결과, 온보딩 배너 — 이 이제 오픈소스 아이콘을 게시하는 11개 제공자(Anthropic · Gemini · OpenAI · Qwen · OpenRouter · GitHub · DeepSeek · Kimi · MiniMax · Mistral · Ollama; simple-icons의 단일 경로 SVG, CC0)에 대해 실제 브랜드 로고를 렌더링합니다. 게시된 아이콘이 없는 7개(Hermes · GLM/Z.ai · Grok · Together · Fireworks · BytePlus Ark · Volcengine Ark)는 브랜드 색상 모노그램을 유지합니다. 여전히 구성상 CSP 안전: 로고 경로는 인라인 정적 상수 — 원격 자원 없음, innerHTML 없음.

참고

  • 라우트/동작 변경 없음; 동일한 ProviderLogo.el(slug) API. provider-logo.js가 커졌습니다(인라인 로고 경로). 스캔 소스 변경 없음: 83. 테스트 스위트: 2784.

[1.223.0] — 2026-08-26

수정 — 성공한 ⚡ 라이브 평가가 오류로 표시되던 문제; 또한 OpenWorker coworker를 GitHub URL 또는 .zip에서 바로 설치.

수정

  • ⚡ 평가 결과 배지 색상. 성공한 라이브 평가가 더 이상 실패로 표시되지 않습니다. 인프로세스 제공자(OpenRouter, DeepSeek, …)는 서브프로세스 종료 code를 반환하지 않으므로 엄격한 code === 0이 거짓이 되어 모든 성공이 빨갛게(“exit 0”으로 표시되면서도) 나왔습니다. 이제 색상은 결과를 따릅니다 — 0이 아닌 코드로 종료된 서브프로세스만 빨갛게, 코드 없는 라이브 결과는 badge-ok, “· exit N” 접미사는 실제 종료 코드가 있을 때만 표시됩니다.

변경됨

  • OpenWorker coworker를 GitHub URL / .zip에서 설치. coworker 문서에 이제 한 줄 명령 설치기(curl … | bash, 멱등 — 기존 career-ops / web-ui 재사용)와 OpenWorker의 세 가지 설치 경로(GitHub URL · .zip · 단일 파일 가져오기)가 docs/integrations/openworker.md, 인앱 도움말 §32(×17), README에 설명됩니다.

참고

  • 오래된 커버리지 공백을 닫았습니다: Recruitee 소스에 v1.214.2 인용부호-각괄호 수정(태그 속성 안의 >가 설명으로 새면 안 됨)에 대한 종단 간 테스트가 추가되었습니다. 스캔 소스 변경 없음: 83. 테스트 스위트: 2783.

[1.222.0] — 2026-08-26

변경됨 — 확장 제공자(DeepSeek … Volcengine Ark) 필드 힌트가 이제 17개 언어로 현지화되었습니다.

변경됨

  • 제공자 필드 힌트 현지화. v1.216.0–v1.217.0에서 추가된 11개 OpenAI 호환 제공자(DeepSeek · GLM/Z.ai · Kimi · MiniMax · Mistral · Grok · Together · Fireworks · Ollama · BytePlus Ark · Volcengine Ark)의 모든 config.<slug>Hint — 설정 → API 키의 API 키·모델·기본 URL 힌트 — 가 이제 영어로 폴백하지 않고 17개 언어 전체로 번역됩니다. 가입 URL, 모델 id, ⚡ 라이브 평가 마커는 그대로 유지됩니다.

참고

  • 동작 변경 없음 — field-specs.js의 영어 hintFallback은 키 누락 시 여전히 보호하며, 새 계약 테스트가 모든 필드 서술자 hintKey가 17개 언어에서 해석됨을 검증합니다. 스캔 소스 변경 없음: 83. 테스트 스위트: 2779.

[1.221.0] — 2026-08-26

보안 — 스캐너 fetch 경로의 DNS-rebinding 심층 방어.

보안

  • 스캐너 HTTP 코어(server/lib/http-json.mjs의 fetchJson/fetchText)가 이제 실제 네트워크 경로에서 각 소스의 호스트를 해석하고 사설/루프백/링크로컬/CGNAT/클라우드 메타데이터 주소로 해석되면 연결을 거부합니다 — DNS-rebinding 벡터를 차단합니다. 스캐너 호스트는 이미 공개 도메인에 고정되어 있어 심층 방어이며, 사용자 URL 경로는 이미 더 강한 연결 고정(safe-fetch.mjs)이 있었습니다. 가드는 실제 fetch 전송에서만 실행되며, 주입된 테스트 fetchImpl은 절대 해석되지 않습니다.

참고

  • 정상 스캔에는 동작 변화 없음. 스캔 소스 변동 없음: 83. 테스트: 2775.

[1.220.0] — 2026-08-26

추가 — 하나의 항목으로 여러 Get on Board 카테고리를 스캔합니다.

추가

  • getonbrd 항목에서 programming 피드만이 아니라 categories: [programming, operations-management, machine-learning-ai](또는 단일 category:)를 지정할 수 있습니다. 게시물은 카테고리 간 URL로 중복 제거되며 최대 12개로 제한됩니다. 기존 항목은 동일합니다(기본값은 여전히 programming).

참고

  • 서버 전용; SSRF 호스트 고정(www.getonbrd.com)과 redirect:'error'는 그대로이고, 잘못된 카테고리 슬러그는 요청 전에 거부됩니다. 스캔 소스 변동 없음: 83. 테스트: 2771.

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