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

@@ -71,12 +71,13 @@ pm2 logs gov-daemon
데몬 자체는 웹 검색을 못 하므로, 신규 소스 발굴은 Claude(WebSearch)가 수행해
`htmlSources.js` 또는 `gov_source` 에 등록한다. 후보 목록은 `docs/sources-catalog.md` 참조.
## 미완 / TODO
- **기업마당(bizinfo)**: 자체 인증키(`crtfcKey`, bizinfo.go.kr 별도 신청) 필요.
`.env``BIZINFO_CRTFC_KEY` 발급 후 어댑터 추가 예정. (data.go.kr 키와 별개)
- 지자체/부처 게시판 추가(GenericHtmlSource config).
## 구현된 소스
- `kstartup`(API), `mss`(HTML 게시판), `smes`(HTML 목록전용 — 상세는 팝업 전용이라 목록만 적재).
- `kstartup`(K-Startup Open API, ~29k건)
- `bizinfo`(기업마당 Open API, ~1.5k건 — 본문 포함 단일패스)
- `mss`(중기부 게시판 HTML)
- `smes`(중소벤처24 HTML 목록전용 — 상세는 팝업 전용이라 목록만 적재)
## TODO
- 지자체/부처 게시판 추가(GenericHtmlSource config).

View File

@@ -6,7 +6,7 @@ Claude WebSearch 로 수집한 공고 소스 후보. 상태가 `구현`인 것
|---|---|---|---|---|---|
| kstartup | K-Startup 창업지원 공고 | k-startup.go.kr | Open API | data.go.kr 서비스키 | ✅ 구현·검증 |
| mss | 중소벤처기업부 사업공고 | mss.go.kr (cbIdx=310) | HTML 게시판 | 불필요 | ✅ 구현·검증 |
| bizinfo | 기업마당 지원사업정보 | bizinfo.go.kr | Open API(자체) | bizinfo `crtfcKey`(별도신청) | ⏳ 키 대기 |
| bizinfo | 기업마당 지원사업정보 | bizinfo.go.kr | Open API(자체) | bizinfo `crtfcKey` | ✅ 구현·검증 |
| smes | 중소벤처24 사업공고 | smes.go.kr (bizApply) | HTML(목록전용) | 불필요 | ✅ 구현·검증 |
| g2b | 나라장터(입찰/조달) | g2b.go.kr | Open API(data.go.kr) | 서비스키 | 🔲 후보 |
| 부처/지자체 | 각 부처·지자체 게시판 | 다수 | HTML(GenericHtml) | 불필요 | 🔲 디스커버리 확장 |

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];
/**
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.

View File

@@ -57,3 +57,30 @@ export function nonEmpty(s) {
const t = String(s).trim();
return t === '' ? null : t;
}
/**
* HTML 태그 제거 후 엔티티 디코드. <br>/<p>/</div> 는 줄바꿈으로.
*/
export function stripHtml(s) {
if (s == null) return null;
const text = String(s)
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6])\s*>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return decodeEntities(text);
}
/**
* "A ~ B" 형식 기간 문자열을 {start, end} Date 로. 날짜형이 아니면 null.
*/
export function parsePeriodRange(s, sep = '~') {
if (s == null) return { start: null, end: null };
const segs = String(s).split(sep).map((x) => x.trim());
return {
start: parseFlexibleDate(segs[0]),
end: parseFlexibleDate(segs[1] || segs[0]),
};
}