[BE-30B] 잡코리아 회사 채용관(JRS) 파서 · 마감일 정규화

상태: 게이트 통과(PASS, 2026-07-27) — 파서 구현 진행. 근거 TDD: 20260727-jobkorea-dual-source-type-tdd.md “Open Questions #1”. 타당성 게이트 결과(PASS): 실채용관 5곳(wooribank·ibk·hanjucorp·nhbank·jobkorea) 실측 검증. “커스텀 셸이라 안정 ID 미노출” 우려는 반증 — 마케팅 셸(/{slug})이 아니라 **표준 라우트 /{slug}/Main/AgiList**가 서버 렌더 HTML로 안정 식별자·마감일·상태를 노출한다. 본사이트(www.jobkorea.co.kr, robots 차단) 링크아웃 없음.

작업 내용 (설계 의도)

근거 TDD: 20260727-jobkorea-dual-source-type-tdd.md 근거 실측(2026-07-27): jrs.jobkorea.co.kr/{slug}/Main/AgiList가 공고 목록을 서버 렌더 HTML로 노출. 상세 href /{slug}/{sub-slug}/Agi/Index의 sub-slug가 회사 포털 내 유니크·영속 식별자. 마감일 YYYY.MM.DD HH:mm ~ YYYY.MM.DD HH:mm 클린 포맷.

변경 사항

jrs.jobkorea.co.kr/{slug}/Main/AgiList 회사 채용관 리스트순수 파싱·정규화 로직만 담당합니다(외부 호출·SPI 결선은 BE-30C). fixture HTML로 테스트해 SPI 의존 없이 병렬 개발 가능합니다.

  • 진입점은 표준 리스트 라우트 — 마케팅 셸 /{slug}(이미지 플레이스홀더뿐, ID 없음)가 아니라 /{slug}/Main/AgiList를 파싱 대상으로 한다.
  • JobkoreaCompanyBoundListParser — 채용관 목록에서 (sourceJobId, title, postingUrl, deadlineRaw, status) 추출.
    • sourceJobId(안정 식별자): 상세 href 정규식 ^/(?i){slug}/([A-Za-z0-9]+)/Agi/(Index|Invite)$ → group(1) = sub-slug. 키 = "{slug}:{sub-slug}"(예: wooribank:wooribank26) 또는 정규화 URL. slug 대소문자 정규화 필요(IBK 포털은 href에 대문자 /IBK/).
    • status: 라벨 접수중/마감/심사.
  • JobkoreaDeadlineNormalizerYYYY.MM.DD HH:mm ~ YYYY.MM.DD HH:mm종료 datetime(group2)을 ZonedDateTime으로 파싱. 상시채용·마감일 부재는 null(FR-13). (게이트 실측 결과 연·시각이 모두 명시돼 연도 보정은 대개 불필요하나, 연도 없는 변형이 나오면 시작 연도 기준 보정·12→01 경계 +1년.)
  • ChangeSignature 선택: 상세 본문은 수명주기에 따라 변하므로(종료 후 다른 페이지로 전환) 리스트의 status·마감일 기반으로 판정 — 상세 last-modified 신뢰 불가. sub-slug 집합 변화 = 신규, 동일 sub-slug의 status 전이/접수마감 경과 = 종료. 목록 FieldHash(sub-slug+title+status+마감일).
  • 종료 공고 href 소실 예외: 일부 포털(hanjucorp)은 종료 공고 href가 javascript:alert(...)로 치환돼 sub-slug 소실. 신규 수집 스킵, 이미 수집된 공고는 기존 sub-slug 유지 + status=‘마감’ 기록. (진행 중 공고는 항상 실제 href라 수집 무영향.)
  • 순수 함수 — RestClient·SourceRequestExecutor 주입 없음(BE-30C에서 결선).

롤백: 파서 단독은 부작용 없음. 사용처(BE-30C)에서 disable()로 소프트 오프.

의존

  • BE-30 (JobPlatform 겸용 — 파서가 반환 유형 판정에 참조)

다이어그램

처리 흐름

sequenceDiagram
    participant A as (호출부 BE-30C)
    participant L as JobkoreaCompanyBoundListParser
    participant N as JobkoreaDeadlineNormalizer
    A->>L: parse(html)
    L->>N: normalize("2026.05.26(화) - 06.08(월) 14:00까지")
    N-->>L: deadlineAt 또는 null(상시)
    L-->>A: List(sourceJobId, title, url, deadlineAt)

클래스 의존

flowchart LR
    ListP[JobkoreaCompanyBoundListParser] --> Norm[JobkoreaDeadlineNormalizer]
    ListP --> Raw[RawJobPosting]
    Norm --> Sig[ChangeSignature]

테스트 케이스

  • fixture AgiList HTML에서 공고 목록이 (sourceJobId=slug:sub-slug, title, postingUrl, status)로 추출된다
  • 상세 href /{slug}/{sub-slug}/Agi/Index에서 sub-slug가 안정 식별자로 추출되고, 대문자 slug(/IBK/)도 정규화돼 매칭된다
  • 2025.12.11 17:00 ~ 2025.12.26 13:00의 종료 datetime이 ZonedDateTime으로 파싱된다
  • 상시채용 문자열 또는 마감일 부재는 deadlineAt=null로 정규화된다
  • 종료 공고 href가 javascript:alert(...)인 행은 신규 수집에서 스킵되고(빈 목록 조용 반환 아님), 진행 중 공고는 정상 추출된다
  • status 라벨(접수중/마감/심사)이 추출된다
  • 동일 목록 필드(sub-slug+title+status+마감일)는 같은 FieldHash로 변경 없음으로 판정된다
  • 마케팅 셸(/{slug}, ID 없는 HTML)을 파싱 대상으로 넘기면 명시적 예외로 실패한다(진입점은 AgiList여야 함)