gov-scraper: 기업마당(bizinfo) Open API 소스 추가

- BizinfoApiSource: bizinfo.go.kr 자체 crtfcKey 사용, /uss/rss/bizinfoApi.do
- 페이지네이션 없음 → totCnt 파악 후 전체 일괄 요청(1,463건 검증)
- bsnsSumryCn(HTML) 본문 → stripHtml 로 태그 제거, 단일패스 적재(전건 DETAILED)
- reqstBeginEndDe "YYYY-MM-DD ~ ..." → 신청기간 파싱(706건), 텍스트형은 null
- util: stripHtml, parsePeriodRange 추가
- 데몬 4소스 가동: kstartup/bizinfo/mss/smes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 06:33:27 +00:00
parent f2a8f30867
commit 82504e2261
5 changed files with 134 additions and 9 deletions

View File

@@ -0,0 +1,96 @@
// 기업마당 지원사업정보 Open API 어댑터 (bizinfo.go.kr 자체 인증키 crtfcKey 사용).
// 엔드포인트: /uss/rss/bizinfoApi.do — 페이지네이션 없음. searchCnt >= totCnt 면 전체 반환.
import { OpportunitySource } from './base.js';
import { config } from '../config.js';
import { log } from '../logger.js';
import {
decodeEntities,
nonEmpty,
stripHtml,
parsePeriodRange,
} from '../util.js';
const ENDPOINT = 'https://www.bizinfo.go.kr/uss/rss/bizinfoApi.do';
const MAX_CNT = 10_000; // 안전 상한(현재 totCnt ~1500)
export class BizinfoApiSource extends OpportunitySource {
constructor() {
super({
code: 'bizinfo',
name: '기업마당 지원사업',
baseUrl: 'https://www.bizinfo.go.kr',
type: 'API',
config: { endpoint: ENDPOINT },
});
}
static isAvailable() {
return Boolean(config.bizinfo.crtfcKey);
}
async #fetch(searchCnt) {
const url = new URL(ENDPOINT);
url.searchParams.set('crtfcKey', config.bizinfo.crtfcKey);
url.searchParams.set('dataType', 'json');
url.searchParams.set('searchCnt', String(searchCnt));
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
if (!res.ok) throw new Error(`기업마당 API HTTP ${res.status}`);
const json = await res.json();
if (json.reqErr) throw new Error(`기업마당 API 오류: ${json.reqErr}`);
if (!Array.isArray(json.jsonArray)) {
throw new Error(`기업마당 API 응답 형식 오류: ${JSON.stringify(json).slice(0, 200)}`);
}
return json.jsonArray;
}
#buildBody(item) {
const parts = [];
const summary = stripHtml(item.bsnsSumryCn);
if (summary) parts.push(summary);
const target = decodeEntities(nonEmpty(item.trgetNm));
if (target) parts.push(`[지원대상]\n${target}`);
const period = nonEmpty(item.reqstBeginEndDe);
if (period) parts.push(`[신청기간]\n${period}`);
const method = stripHtml(item.reqstMthPapersCn);
if (method) parts.push(`[신청방법]\n${method}`);
const exec = nonEmpty(item.excInsttNm);
if (exec) parts.push(`[수행기관]\n${exec}`);
const ref = nonEmpty(item.refrncNm);
if (ref) parts.push(`[문의처]\n${ref}`);
const files = nonEmpty(item.fileNm);
if (files) parts.push(`[첨부]\n${files.replace(/@/g, '\n')}`);
return parts.join('\n\n');
}
#map(item) {
const externalId = nonEmpty(item.pblancId);
const title = decodeEntities(nonEmpty(item.pblancNm));
if (!externalId || !title) {
throw new Error(`기업마당 항목 필수필드 누락: ${JSON.stringify(item).slice(0, 200)}`);
}
const { start, end } = parsePeriodRange(item.reqstBeginEndDe);
return {
externalId,
title,
agency: decodeEntities(nonEmpty(item.jrsdInsttNm)),
category: decodeEntities(nonEmpty(item.pldirSportRealmLclasCodeNm)),
target: decodeEntities(nonEmpty(item.trgetNm)),
applyStart: start,
applyEnd: end,
detailUrl: nonEmpty(item.pblancUrl),
body: this.#buildBody(item),
raw: item,
};
}
async list() {
// 1차: totCnt 파악
const probe = await this.#fetch(1);
const totCnt = probe[0]?.totCnt ? Number(probe[0].totCnt) : 0;
const cnt = Math.min(totCnt || MAX_CNT, MAX_CNT);
log.info(`기업마당 totCnt=${totCnt}${cnt}건 요청`);
const rows = await this.#fetch(cnt);
log.info(`기업마당 ${rows.length}건 수신`);
return rows.map((item) => this.#map(item));
}
}

View File

@@ -1,11 +1,12 @@
// 사용 가능한 소스 어댑터 레지스트리.
// 키(서비스키 등)가 없는 소스는 자동 제외한다.
import { KStartupApiSource } from './kstartup.js';
import { BizinfoApiSource } from './bizinfo.js';
import { buildHtmlSources } from './htmlSources.js';
import { log } from '../logger.js';
// 키/설정 가용성 검사가 있는 API 소스 클래스들
const API_SOURCE_CLASSES = [KStartupApiSource];
const API_SOURCE_CLASSES = [KStartupApiSource, BizinfoApiSource];
/**
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.