From f2a8f3086777913bf397a714cd75225a27d8c9a0 Mon Sep 17 00:00:00 2001 From: joungmin Date: Wed, 10 Jun 2026 05:51:46 +0000 Subject: [PATCH] =?UTF-8?q?gov-scraper:=20=EC=A4=91=EC=86=8C=EB=B2=A4?= =?UTF-8?q?=EC=B2=9824(smes)=20=EC=82=AC=EC=97=85=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GenericHtmlSource 확장: 신청기간(period) 날짜 파싱, listOnly(목록 전용) 모드 - smes(중소벤처24 bizApply) config 추가 — href의 PBLN 공고ID 추출, 제목/분야/주관기관/신청기간 적재 - smes 상세는 팝업 전용(JS 다이얼로그)이라 직접 크롤 불가 → 목록 전용으로 적재(18건 검증) - util: parseFlexibleDate(YY-MM-DD/YYYYMMDD 대응) - pipeline: skipDetail 소스는 상세 단계 건너뜀 Co-Authored-By: Claude Opus 4.8 (1M context) --- government/README.md | 6 ++++- government/docs/sources-catalog.md | 4 ++- government/src/pipeline.js | 17 ++++++++++++- government/src/sources/base.js | 2 ++ government/src/sources/genericHtml.js | 36 +++++++++++++++++++-------- government/src/sources/htmlSources.js | 22 ++++++++++++++++ government/src/util.js | 25 +++++++++++++++++++ 7 files changed, 99 insertions(+), 13 deletions(-) diff --git a/government/README.md b/government/README.md index 876fa59..90e9e94 100644 --- a/government/README.md +++ b/government/README.md @@ -75,4 +75,8 @@ pm2 logs gov-daemon - **기업마당(bizinfo)**: 자체 인증키(`crtfcKey`, bizinfo.go.kr 별도 신청) 필요. `.env` 의 `BIZINFO_CRTFC_KEY` 발급 후 어댑터 추가 예정. (data.go.kr 키와 별개) -- 중소벤처24(smes), 지자체/부처 게시판 추가. +- 지자체/부처 게시판 추가(GenericHtmlSource config). + +## 구현된 소스 + +- `kstartup`(API), `mss`(HTML 게시판), `smes`(HTML 목록전용 — 상세는 팝업 전용이라 목록만 적재). diff --git a/government/docs/sources-catalog.md b/government/docs/sources-catalog.md index f7e4e40..5c6f697 100644 --- a/government/docs/sources-catalog.md +++ b/government/docs/sources-catalog.md @@ -7,7 +7,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`(별도신청) | ⏳ 키 대기 | -| smes | 중소벤처24 사업공고 | smes.go.kr | HTML | 불필요 | 🔲 후보 | +| smes | 중소벤처24 사업공고 | smes.go.kr (bizApply) | HTML(목록전용) | 불필요 | ✅ 구현·검증 | | g2b | 나라장터(입찰/조달) | g2b.go.kr | Open API(data.go.kr) | 서비스키 | 🔲 후보 | | 부처/지자체 | 각 부처·지자체 게시판 | 다수 | HTML(GenericHtml) | 불필요 | 🔲 디스커버리 확장 | @@ -19,6 +19,8 @@ Claude WebSearch 로 수집한 공고 소스 후보. 상태가 `구현`인 것 data.go.kr 서비스키와 별개. 엔드포인트: `https://www.bizinfo.go.kr/uss/rss/bizinfoApi.do?crtfcKey=...&dataType=json` - **HTML 확장**: 부처/지자체 게시판은 대부분 정적 렌더링 표(table)라 `GenericHtmlSource` config 로 코드 수정 없이 추가 가능(mss 사례 참조). +- **smes 상세 제약**: smes 상세는 팝업 전용(JS 다이얼로그)이라 단독 크롤 불가 → 목록 전용(`listOnly`)으로 + 적재. 목록에 제목·기관·분야·신청기간이 모두 있어 충분. 본문은 동일 PBLN 의 기업마당 API 로 보강 예정. ## 참고 링크 diff --git a/government/src/pipeline.js b/government/src/pipeline.js index ee72fd9..e665070 100644 --- a/government/src/pipeline.js +++ b/government/src/pipeline.js @@ -38,7 +38,22 @@ export async function runSource(source) { `${source.code}: 적재 처리=${upsert.processed} 신규=${upsert.inserted} 갱신=${upsert.updated}` ); - // 3) 상세 본문 수집 (LISTED 상태만) + // 3) 상세 본문 수집 (LISTED 상태만). 목록 전용 소스는 건너뜀. + if (source.skipDetail) { + await markSourceCrawled(sourceId); + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + log.info( + `==== 소스 완료(목록전용): ${source.code} | 신규 ${upsert.inserted} 갱신 ${upsert.updated} | ${elapsed}s ====` + ); + return { + source: source.code, + listed: items.length, + inserted: upsert.inserted, + updated: upsert.updated, + detailOk: 0, + detailErr: 0, + }; + } const pending = await findPendingDetail(source.code, DETAIL_BATCH); log.info(`${source.code}: 상세 수집 대상 ${pending.length}건`); let detailOk = 0; diff --git a/government/src/sources/base.js b/government/src/sources/base.js index e2d3ac5..4bb63b1 100644 --- a/government/src/sources/base.js +++ b/government/src/sources/base.js @@ -27,6 +27,8 @@ export class OpportunitySource { this.baseUrl = meta.baseUrl || null; this.type = meta.type; this.config = meta.config || {}; + // true 면 파이프라인이 상세 본문 수집 단계를 건너뛴다(목록 전용 소스). + this.skipDetail = false; } meta() { diff --git a/government/src/sources/genericHtml.js b/government/src/sources/genericHtml.js index 44cfba0..558f5f8 100644 --- a/government/src/sources/genericHtml.js +++ b/government/src/sources/genericHtml.js @@ -15,7 +15,7 @@ import * as cheerio from 'cheerio'; import { OpportunitySource } from './base.js'; import { log } from '../logger.js'; -import { decodeEntities, nonEmpty } from '../util.js'; +import { decodeEntities, nonEmpty, parseFlexibleDate } from '../util.js'; const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; @@ -24,11 +24,11 @@ export class GenericHtmlSource extends OpportunitySource { constructor(meta) { super({ ...meta, type: 'HTML' }); const c = this.config; - if (!c.listUrl || !c.rowSelector || !c.externalId || !c.detailUrl) { - throw new Error( - `${this.code}: config 에 listUrl/rowSelector/externalId/detailUrl 필수` - ); + if (!c.listUrl || !c.rowSelector || !c.externalId) { + throw new Error(`${this.code}: config 에 listUrl/rowSelector/externalId 필수`); } + // listOnly: 목록만 적재하고 상세 본문 수집은 건너뛴다(상세가 팝업/JS 라 직접 크롤 불가한 사이트). + this.skipDetail = c.listOnly === true; } #pageUrl(page) { @@ -82,9 +82,25 @@ export class GenericHtmlSource extends OpportunitySource { const title = this.#extractField($, row, c.title); if (!title) return null; - const detailUrl = c.detailUrl.template - ? c.detailUrl.template.replace(/\{id\}/g, externalId) - : this.#extractField($, row, c.detailUrl); + let detailUrl = null; + if (c.detailUrl) { + detailUrl = c.detailUrl.template + ? c.detailUrl.template.replace(/\{id\}/g, externalId) + : this.#extractField($, row, c.detailUrl); + } + + // 신청기간 "26-06-10 ~ 26-06-24" → applyStart/applyEnd + let applyStart = null; + let applyEnd = null; + if (c.period) { + const periodText = this.#extractField($, row, c.period); + if (periodText) { + const sep = c.period.sep || '~'; + const segs = periodText.split(sep).map((x) => x.trim()); + applyStart = parseFlexibleDate(segs[0]); + applyEnd = parseFlexibleDate(segs[1] || segs[0]); + } + } return { externalId, @@ -92,8 +108,8 @@ export class GenericHtmlSource extends OpportunitySource { agency: c.agency || this.#extractField($, row, c.agencyField) || null, category: this.#extractField($, row, c.categoryField), target: null, - applyStart: null, - applyEnd: null, + applyStart, + applyEnd, detailUrl, raw: { onclick: row.attr('onclick') || null, title }, }; diff --git a/government/src/sources/htmlSources.js b/government/src/sources/htmlSources.js index 4c3bf65..e2860ba 100644 --- a/government/src/sources/htmlSources.js +++ b/government/src/sources/htmlSources.js @@ -21,6 +21,28 @@ export const HTML_SOURCE_CONFIGS = [ agency: '중소벤처기업부', }, }, + { + code: 'smes', + name: '중소벤처24 사업공고', + baseUrl: 'https://www.smes.go.kr', + config: { + listUrl: 'https://www.smes.go.kr/main/bizApply', + maxPages: 1, // 최신 목록(ajax 페이징이라 1페이지). 데몬이 주기적으로 신규 포착. + rowSelector: 'table tbody tr', + // 행 앵커: javascript:fn_include_popOpen2('seq','idx','cd','PBLN_...','기관','상태') + externalId: { from: 'href', regex: '(PBLN_\\d+)' }, + title: { selector: 'td:nth-child(2) a' }, + categoryField: { selector: 'td:nth-child(5)' }, + agencyField: { selector: 'td:nth-child(6)' }, + period: { selector: 'td:nth-child(3)', sep: '~' }, + // 상세는 팝업/JS(다이얼로그)라 직접 크롤 불가 → 목록 전용. URL은 참조용으로만 저장. + detailUrl: { + template: + 'https://www.smes.go.kr/sii/siia/selectSIIA200Detail.do?pblancId={id}', + }, + listOnly: true, + }, + }, ]; export function buildHtmlSources() { diff --git a/government/src/util.js b/government/src/util.js index 5f4ec04..041c2ea 100644 --- a/government/src/util.js +++ b/government/src/util.js @@ -27,6 +27,31 @@ export function parseYmd(s) { return new Date(Date.UTC(y, m - 1, d)); } +/** + * 'YY-MM-DD' / 'YYYY-MM-DD' / 'YYYYMMDD' / 'YYMMDD' 를 Date 로. 불일치면 null. + * 6자리는 20YY 로 간주한다. + */ +export function parseFlexibleDate(s) { + if (s == null) return null; + const d = String(s).replace(/[^0-9]/g, ''); + let y; + let mo; + let day; + if (d.length === 8) { + y = Number(d.slice(0, 4)); + mo = Number(d.slice(4, 6)); + day = Number(d.slice(6, 8)); + } else if (d.length === 6) { + y = 2000 + Number(d.slice(0, 2)); + mo = Number(d.slice(2, 4)); + day = Number(d.slice(4, 6)); + } else { + return null; + } + if (mo < 1 || mo > 12 || day < 1 || day > 31) return null; + return new Date(Date.UTC(y, mo - 1, day)); +} + export function nonEmpty(s) { if (s == null) return null; const t = String(s).trim();