diff --git a/government/README.md b/government/README.md
index 90e9e94..41488bf 100644
--- a/government/README.md
+++ b/government/README.md
@@ -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).
diff --git a/government/docs/sources-catalog.md b/government/docs/sources-catalog.md
index 5c6f697..51e90ae 100644
--- a/government/docs/sources-catalog.md
+++ b/government/docs/sources-catalog.md
@@ -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) | 불필요 | 🔲 디스커버리 확장 |
diff --git a/government/src/sources/bizinfo.js b/government/src/sources/bizinfo.js
new file mode 100644
index 0000000..9eeb997
--- /dev/null
+++ b/government/src/sources/bizinfo.js
@@ -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));
+ }
+}
diff --git a/government/src/sources/registry.js b/government/src/sources/registry.js
index 76305fd..adc295e 100644
--- a/government/src/sources/registry.js
+++ b/government/src/sources/registry.js
@@ -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];
/**
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.
diff --git a/government/src/util.js b/government/src/util.js
index 041c2ea..42ff6d9 100644
--- a/government/src/util.js
+++ b/government/src/util.js
@@ -57,3 +57,30 @@ export function nonEmpty(s) {
const t = String(s).trim();
return t === '' ? null : t;
}
+
+/**
+ * HTML 태그 제거 후 엔티티 디코드.
/
/ 는 줄바꿈으로. + */ +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]), + }; +}