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:
@@ -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 목록전용 — 상세는 팝업 전용이라 목록만 적재).
|
||||
|
||||
@@ -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 로 보강 예정.
|
||||
|
||||
## 참고 링크
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user