// OpportunitySource — Strategy 인터페이스. // 소스(사이트)별 어댑터는 이 클래스를 상속해 list()/fetchDetail() 을 구현한다. import { crawl } from '../crawler/crawler.js'; /** * 공고 목록 항목 형태: * { * externalId: string, // 소스 고유 키 (필수, dedup) * title: string, // 제목 (필수) * agency?: string, // 소관/주관기관 * category?: string, // 지원분야 * target?: string, // 지원대상 * applyStart?: Date, // 접수 시작 * applyEnd?: Date, // 접수 마감 * detailUrl?: string, // 상세 페이지 URL * raw?: object, // 원본 데이터(JSON 저장) * } */ export class OpportunitySource { /** @param {{code:string,name:string,baseUrl?:string,type:'API'|'HTML',config?:object}} meta */ constructor(meta) { if (!meta.code || !meta.name || !meta.type) { throw new Error('OpportunitySource meta 에 code/name/type 필수'); } this.code = meta.code; this.name = meta.name; this.baseUrl = meta.baseUrl || null; this.type = meta.type; this.config = meta.config || {}; // true 면 파이프라인이 상세 본문 수집 단계를 건너뛴다(목록 전용 소스). this.skipDetail = false; } meta() { return { code: this.code, name: this.name, baseUrl: this.baseUrl, type: this.type, config: this.config, }; } /** * 공고 목록을 수집한다. 하위 클래스에서 반드시 구현. * @returns {Promise} */ async list() { throw new Error(`${this.code}: list() 미구현`); } /** * 상세 본문을 수집한다. 기본 구현은 detailUrl 을 3단계 폴백 크롤러로 긁는다. * API 처럼 본문이 이미 raw 에 있는 소스는 이 메서드를 오버라이드한다. * @param {{id:string, externalId:string, detailUrl:string, raw:object|null}} row * @returns {Promise} 본문 텍스트 */ async fetchDetail(row) { if (!row.detailUrl) { throw new Error(`${this.code}/${row.externalId}: detailUrl 없음 — 상세 수집 불가`); } return crawl(row.detailUrl); } }