정부지원사업 공고 수집 데몬(gov-scraper) 추가

- government/ Node 데몬: Open API 우선 + HTML 보조 + 디스커버리 전략
- Strategy 패턴 소스 어댑터: KStartupApiSource(공공데이터 Open API), GenericHtmlSource(config 기반)
- sundol 3단계 폴백 크롤러(cheerio→Jina→Playwright CDP) Node 재구현, sundol-chrome(9222) 재사용
- Oracle thick 모드(Instant Client + sso 지갑) 접속, gov_source/gov_opportunity 적재(중복제거)
- K-Startup 29,017건 + 중기부(mss) 30건 적재 검증, PM2 gov-daemon 등록(60분 주기)
- 기업마당(bizinfo)은 자체 crtfcKey 발급 대기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 04:36:50 +00:00
parent 5700449bfd
commit cbc5ba5663
23 changed files with 1639 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
// 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 || {};
}
meta() {
return {
code: this.code,
name: this.name,
baseUrl: this.baseUrl,
type: this.type,
config: this.config,
};
}
/**
* 공고 목록을 수집한다. 하위 클래스에서 반드시 구현.
* @returns {Promise<Array>}
*/
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<string>} 본문 텍스트
*/
async fetchDetail(row) {
if (!row.detailUrl) {
throw new Error(`${this.code}/${row.externalId}: detailUrl 없음 — 상세 수집 불가`);
}
return crawl(row.detailUrl);
}
}

View File

@@ -0,0 +1,126 @@
// GenericHtmlSource — 표(table) 기반 게시판형 공고 목록을 config 로 수집하는 범용 HTML 어댑터.
// 새 HTML 사이트는 코드 수정 없이 config 만 바꿔 추가할 수 있다(Strategy + 설정 주입).
//
// config 예시:
// {
// listUrl: 'https://.../List.do?cbIdx=310',
// pageParam: 'pageIndex', // 페이지 쿼리 파라미터 (없으면 단일 페이지)
// maxPages: 5,
// rowSelector: 'table tbody tr',
// title: { selector: 'td.subject a', attr: 'title' }, // attr 생략 시 text()
// externalId: { from: 'onclick', regex: "doBbsFView\\('\\d+','(\\d+)'" },
// detailUrl: { template: 'https://.../View.do?cbIdx=310&bcIdx={id}&parentSeq={id}' },
// agency: '중소벤처기업부', // 정적 소관기관(선택)
// }
import * as cheerio from 'cheerio';
import { OpportunitySource } from './base.js';
import { log } from '../logger.js';
import { decodeEntities, nonEmpty } from '../util.js';
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
export class GenericHtmlSource extends OpportunitySource {
constructor(meta) {
super({ ...meta, type: 'HTML' });
const c = this.config;
if (!c.listUrl || !c.rowSelector || !c.externalId || !c.detailUrl) {
throw new Error(
`${this.code}: config 에 listUrl/rowSelector/externalId/detailUrl 필수`
);
}
}
#pageUrl(page) {
const c = this.config;
if (!c.pageParam) return c.listUrl;
const url = new URL(c.listUrl);
url.searchParams.set(c.pageParam, String(page));
return url.toString();
}
async #fetchHtml(url) {
const res = await fetch(url, {
headers: { 'User-Agent': UA },
redirect: 'follow',
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status} (${url})`);
return res.text();
}
#extractField($, row, spec) {
if (!spec) return null;
let el = spec.selector ? row.find(spec.selector).first() : row;
if (el.length === 0) return null;
let val;
if (spec.attr) val = el.attr(spec.attr);
else val = el.text();
return decodeEntities(nonEmpty(val));
}
#extractByRegex(text, pattern) {
if (!text || !pattern) return null;
const m = new RegExp(pattern).exec(text);
return m ? m[1] : null;
}
#mapRow($, el) {
const c = this.config;
const row = $(el);
// externalId: onclick / href / 선택자 텍스트에서 정규식 추출
let idSource;
if (c.externalId.from === 'onclick') idSource = row.attr('onclick') || row.find('[onclick]').first().attr('onclick');
else if (c.externalId.from === 'href') idSource = row.find('a').first().attr('href');
else idSource = this.#extractField($, row, c.externalId);
const externalId = c.externalId.regex
? this.#extractByRegex(idSource, c.externalId.regex)
: nonEmpty(idSource);
if (!externalId) return null; // 헤더행 등은 스킵
const title = this.#extractField($, row, c.title);
if (!title) return null;
const detailUrl = c.detailUrl.template
? c.detailUrl.template.replace(/\{id\}/g, externalId)
: this.#extractField($, row, c.detailUrl);
return {
externalId,
title,
agency: c.agency || this.#extractField($, row, c.agencyField) || null,
category: this.#extractField($, row, c.categoryField),
target: null,
applyStart: null,
applyEnd: null,
detailUrl,
raw: { onclick: row.attr('onclick') || null, title },
};
}
async list() {
const c = this.config;
const maxPages = c.maxPages || 1;
const out = [];
const seen = new Set();
for (let page = 1; page <= maxPages; page += 1) {
const url = this.#pageUrl(page);
const html = await this.#fetchHtml(url);
const $ = cheerio.load(html);
const rows = $(c.rowSelector);
let pageCount = 0;
rows.each((_, el) => {
const item = this.#mapRow($, el);
if (item && !seen.has(item.externalId)) {
seen.add(item.externalId);
out.push(item);
pageCount += 1;
}
});
log.info(`${this.code} page ${page}: ${pageCount}`);
if (pageCount === 0) break; // 더 이상 행이 없으면 종료
}
return out;
}
}

View File

@@ -0,0 +1,28 @@
// config 로 정의되는 HTML 게시판 소스 목록.
// 새 사이트는 여기 항목을 추가하면 된다(코드 로직 수정 불필요).
import { GenericHtmlSource } from './genericHtml.js';
export const HTML_SOURCE_CONFIGS = [
{
code: 'mss',
name: '중소벤처기업부 사업공고',
baseUrl: 'https://www.mss.go.kr',
config: {
listUrl: 'https://www.mss.go.kr/site/smba/ex/bbs/List.do?cbIdx=310',
pageParam: 'pageIndex',
maxPages: 3,
rowSelector: 'table tbody tr',
title: { selector: 'td.subject a', attr: 'title' },
externalId: { from: 'onclick', regex: "doBbsFView\\('\\d+','(\\d+)'" },
detailUrl: {
template:
'https://www.mss.go.kr/site/smba/ex/bbs/View.do?cbIdx=310&bcIdx={id}&parentSeq={id}',
},
agency: '중소벤처기업부',
},
},
];
export function buildHtmlSources() {
return HTML_SOURCE_CONFIGS.map((cfg) => new GenericHtmlSource(cfg));
}

View File

@@ -0,0 +1,92 @@
// K-Startup 창업지원 사업공고 Open API 어댑터 (data.go.kr 서비스키 사용).
// 엔드포인트: getAnnouncementInformation (페이지네이션)
import { OpportunitySource } from './base.js';
import { config } from '../config.js';
import { log } from '../logger.js';
import { decodeEntities, parseYmd, nonEmpty } from '../util.js';
const ENDPOINT =
'https://nidapi.k-startup.go.kr/api/kisedKstartupService/v1/getAnnouncementInformation';
const PER_PAGE = 100;
const MAX_PAGES = Number(process.env.GOV_KSTARTUP_MAX_PAGES || 400); // 안전 상한(약 4만건)
export class KStartupApiSource extends OpportunitySource {
constructor() {
super({
code: 'kstartup',
name: 'K-Startup 창업지원 공고',
baseUrl: 'https://www.k-startup.go.kr',
type: 'API',
config: { endpoint: ENDPOINT, perPage: PER_PAGE },
});
}
static isAvailable() {
return Boolean(config.dataGoKr.apiKey);
}
async #fetchPage(page) {
const url = new URL(ENDPOINT);
url.searchParams.set('serviceKey', config.dataGoKr.apiKey);
url.searchParams.set('page', String(page));
url.searchParams.set('perPage', String(PER_PAGE));
url.searchParams.set('returnType', 'json');
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) {
throw new Error(`K-Startup API HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
const json = await res.json();
if (!Array.isArray(json.data)) {
throw new Error(`K-Startup API 응답 형식 오류: ${JSON.stringify(json).slice(0, 200)}`);
}
return json;
}
// API 가 제공하는 필드들로 본문을 조립한다 (별도 상세 크롤링 불필요).
#buildBody(item) {
const parts = [];
const content = decodeEntities(nonEmpty(item.pbanc_ctnt));
if (content) parts.push(content);
const target = decodeEntities(nonEmpty(item.aply_trgt_ctnt));
if (target) parts.push(`[지원대상]\n${target}`);
const exclude = decodeEntities(nonEmpty(item.aply_excl_trgt_ctnt));
if (exclude) parts.push(`[제외대상]\n${exclude}`);
const online = nonEmpty(item.aply_mthd_onli_rcpt_istc);
if (online) parts.push(`[온라인 접수]\n${online}`);
const guide = nonEmpty(item.biz_gdnc_url);
if (guide) parts.push(`[안내 URL]\n${guide}`);
return parts.join('\n\n');
}
#map(item) {
const externalId = item.pbanc_sn != null ? String(item.pbanc_sn) : null;
const title = decodeEntities(item.biz_pbanc_nm);
if (!externalId || !title) {
throw new Error(`K-Startup 항목 필수필드 누락: ${JSON.stringify(item).slice(0, 200)}`);
}
return {
externalId,
title,
agency: decodeEntities(nonEmpty(item.pbanc_ntrp_nm) || nonEmpty(item.sprv_inst)),
category: decodeEntities(nonEmpty(item.supt_biz_clsfc)),
target: decodeEntities(nonEmpty(item.aply_trgt_ctnt) || nonEmpty(item.aply_trgt)),
applyStart: parseYmd(item.pbanc_rcpt_bgng_dt),
applyEnd: parseYmd(item.pbanc_rcpt_end_dt),
detailUrl: nonEmpty(item.detl_pg_url),
body: this.#buildBody(item), // 목록 단계에서 본문까지 적재
raw: item,
};
}
async list() {
const out = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const json = await this.#fetchPage(page);
const rows = json.data;
log.info(`K-Startup page ${page}: ${rows.length}건 (totalCount=${json.totalCount})`);
for (const item of rows) out.push(this.#map(item));
if (rows.length < PER_PAGE) break; // 마지막 페이지
}
return out;
}
}

View File

@@ -0,0 +1,32 @@
// 사용 가능한 소스 어댑터 레지스트리.
// 키(서비스키 등)가 없는 소스는 자동 제외한다.
import { KStartupApiSource } from './kstartup.js';
import { buildHtmlSources } from './htmlSources.js';
import { log } from '../logger.js';
// 키/설정 가용성 검사가 있는 API 소스 클래스들
const API_SOURCE_CLASSES = [KStartupApiSource];
/**
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.
*/
export function availableSources() {
const out = [];
for (const Cls of API_SOURCE_CLASSES) {
if (typeof Cls.isAvailable === 'function' && !Cls.isAvailable()) {
log.warn(`소스 비활성(키/설정 없음): ${Cls.name}`);
continue;
}
out.push(new Cls());
}
// config 기반 HTML 소스(항상 가용)
out.push(...buildHtmlSources());
return out;
}
/**
* 특정 code 의 소스 하나만 가져온다(수동 실행용). 없으면 null.
*/
export function sourceByCode(code) {
return availableSources().find((s) => s.code === code) || null;
}