gov-scraper: 중소벤처24(smes) 사업공고 소스 추가

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 05:51:46 +00:00
parent cbc5ba5663
commit f2a8f30867
7 changed files with 99 additions and 13 deletions

View File

@@ -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 목록전용 — 상세는 팝업 전용이라 목록만 적재).

View File

@@ -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 로 보강 예정.
## 참고 링크

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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 },
};

View File

@@ -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() {

View File

@@ -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();