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]), + }; +}