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:
@@ -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).
|
||||
|
||||
@@ -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) | 불필요 | 🔲 디스커버리 확장 |
|
||||
|
||||
96
government/src/sources/bizinfo.js
Normal file
96
government/src/sources/bizinfo.js
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
/**
|
||||
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user