[BE-09] 인크루트 HTML 소스 어댑터 (EUC-KR · 상세 전건 조회)

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “소스별 어댑터 설계”, “수집 실패 경로” 근거 조사: 20260722-채용소스-조사-브리프.md §3·§4 (실제 확인: recruit.incruit.com/{slug}/ 서버 렌더링 HTML, 마감일 2026.07.16 00:00 ~ 2026.07.30 17:00 형식 노출, EUC-KR 계열 인코딩, recruit.incruit.com/wooribank/job/2507310011이 비공개 공고 로그인 페이지로 동작)

변경 사항

인크루트 채용대행을 사용하는 회사(초기: 수협은행 신입, 우리은행 신입)를 커버합니다. 세 어댑터 중 난이도와 리스크가 가장 높습니다.

능력 선언(SourceCapabilities):

항목근거
changeSignalKindDETAIL_BODY_HASH목록에 변경 감지용 필드가 없어 상세 본문 해시 비교 (FR-12)
providesDeadlinetrue목록에 마감일 노출
providesStructuredWorkArrangementfalse구조화 필드 없음 → ②만
requiresDetailFetchtrue상세 페이지 전건 조회
detailRequestDelayMillis1000대상 사이트 부하 관리 (Operations)
detailRequestLimitPerRun200회차당 상한

핵심 설계 의도:

  • EUC-KR 처리(NFR-4): Content-Type의 charset을 신뢰하지 않고 EUC-KR(MS949 상위호환)로 명시 디코딩합니다 — Jsoup.parse(inputStream, "EUC-KR", url). 문자 깨짐 0건이 완료 기준이며, 고정 바이트 fixture로 테스트를 강제합니다.
  • 상세 전건 조회 + 요청량 관리: 목록 파싱 후 각 공고 상세를 조회하되 요청 간 1,000ms 지연, 회차당 상한 200건, connect 5s / read 10s를 적용합니다. 상한 초과분은 목록 정보만 갱신하고 detailSkippedCount로 기록합니다.
  • 부분 실패 격리: 목록은 성공했는데 상세 일부가 실패하면 회차 전체를 실패로 만들지 않습니다. Fetched(postings, detailFailureCount)를 반환하고, 실패 건은 changeSignature를 갱신하지 않아 다음 회차에 재시도합니다. 목록에 존재했으므로 미발견 판정에는 영향이 없습니다 — 상세 조회 실패가 마감 오판정으로 번지지 않게 하는 것이 이 설계의 목적입니다.
  • 로그인 필요 공고 감지(시나리오 6): 상세 응답이 로그인 페이지로 리다이렉트되거나 로그인 폼 마커를 포함하면 accessRestricted=true로 표시합니다. 이 공고는 자동 매칭·마감 판정 대상에서 제외되고 목록에 “접근 제한” 배지로 표시됩니다(P0에서 알림은 발송하지 않습니다 — TDD Open Questions #1).
  • 마감일 파싱: 2026.07.16 00:00 ~ 2026.07.30 17:00에서 종료 시각을 추출합니다. 파싱 실패 시 예외 대신 null(상시채용 취급)로 처리하고 경고 로그를 남깁니다 — 파싱 실패로 회차 전체를 실패시키면 그 소스의 모든 공고가 판정 불가가 됩니다.
  • 본문 해시: 광고·타임스탬프 같은 변동 요소를 제거한 정규화 본문의 SHA-256을 시그니처로 사용합니다. 정규화 규칙이 불안정하면 매 회차 “변경됨”으로 오탐되므로, 텍스트 추출 후 공백 정규화 + 스크립트/스타일 제거를 적용합니다.
  • robots.txt·이용약관 확인 결과를 어댑터 KDoc에 기록합니다(NFR-9).

SPI 정합(BE-02): SourcePlatformAdapter를 구현하며 fetchList(CollectionDescriptor.CompanyBound)를 받습니다(sourceType=COMPANY_BOUND). 외부 호출은 SourceRequestExecutor(EUC-KR 디코딩·상세 지연 1000ms·상한 200건 정책)를 경유합니다.

의존

  • BE-02

다이어그램

처리 흐름

sequenceDiagram
    participant R as JobSourceGatewayImpl
    participant A as IncruitJobSourceAdapter
    participant C as IncruitHtmlClient
    participant I as recruit.incruit.com
    R->>A: fetchList(descriptor)
    A->>C: fetchListPage(slug)
    C->>I: GET /{slug}/
    I-->>C: HTML (EUC-KR)
    C->>C: EUC-KR 명시 디코딩
    C-->>A: 공고 목록 (id, title, url, 마감일)
    loop 공고별 (상한 200건, 1000ms 지연)
        A->>C: fetchDetail(url)
        alt 로그인 리다이렉트
            C-->>A: accessRestricted=true
        else 정상
            C-->>A: 본문 → 정규화 → SHA-256
        else 상세 실패
            C-->>A: 실패 (detailFailureCount++)
        end
    end
    A-->>R: Fetched(postings, detailFailureCount)

클래스 의존

flowchart LR
    subgraph Domain["domain/posting"]
        Raw[RawJobPosting]
        Sig[ChangeSignature.BodyHash]
        Outcome[SourceCollectionOutcome]
        Cap[SourceCapabilities]
    end
    subgraph Adapter["infrastructure/posting/collector/incruit"]
        Ad[IncruitJobSourceAdapter]
        Cl[IncruitHtmlClient]
        ListP[IncruitListParser]
        DetailP[IncruitDetailParser]
        Hash[BodyHashCalculator]
    end
    Ad --> Cl
    Ad --> ListP
    Ad --> DetailP
    DetailP --> Hash
    Hash --> Sig
    ListP --> Raw
    Ad --> Outcome
    Ad --> Cap

테스트 케이스

  • EUC-KR 바이트 fixture를 파싱하면 한글 제목이 깨짐 없이 정확히 일치한다 (NFR-4)
  • 응답 헤더의 charset이 잘못 표기돼 있어도 EUC-KR로 강제 디코딩해 정상 파싱한다
  • 목록의 2026.07.16 00:00 ~ 2026.07.30 17:00에서 종료 시각이 deadlineAt으로 파싱된다
  • 마감일 형식이 예상과 다르면 deadlineAt = null + 경고 로그로 처리되고 회차는 실패하지 않는다
  • 상세 본문이 동일하면 같은 해시가 생성되어 변경 없음으로 판정된다
  • 본문의 공백·스크립트만 다른 경우 정규화로 같은 해시가 생성된다(오탐 방지)
  • 상세 응답이 로그인 페이지면 accessRestricted=true로 표시된다
  • 상세 조회 일부가 실패해도 Fetched를 반환하며 detailFailureCount가 증가한다
  • 상세 조회 실패 건은 changeSignature가 갱신되지 않아 다음 회차에 재시도된다
  • 공고가 250건이면 상세 조회는 200건만 수행하고 나머지는 목록 정보만 갱신한다
  • 상세 요청 사이에 설정된 지연이 적용된다
  • 목록 페이지 조회 자체가 실패하면 Failed를 반환한다
  • supports(INCRUIT)가 true를 반환한다