정부지원사업 공고 수집 데몬(gov-scraper) 추가
- government/ Node 데몬: Open API 우선 + HTML 보조 + 디스커버리 전략 - Strategy 패턴 소스 어댑터: KStartupApiSource(공공데이터 Open API), GenericHtmlSource(config 기반) - sundol 3단계 폴백 크롤러(cheerio→Jina→Playwright CDP) Node 재구현, sundol-chrome(9222) 재사용 - Oracle thick 모드(Instant Client + sso 지갑) 접속, gov_source/gov_opportunity 적재(중복제거) - K-Startup 29,017건 + 중기부(mss) 30건 적재 검증, PM2 gov-daemon 등록(60분 주기) - 기업마당(bizinfo)은 자체 crtfcKey 발급 대기 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
62
government/src/sources/base.js
Normal file
62
government/src/sources/base.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// OpportunitySource — Strategy 인터페이스.
|
||||
// 소스(사이트)별 어댑터는 이 클래스를 상속해 list()/fetchDetail() 을 구현한다.
|
||||
import { crawl } from '../crawler/crawler.js';
|
||||
|
||||
/**
|
||||
* 공고 목록 항목 형태:
|
||||
* {
|
||||
* externalId: string, // 소스 고유 키 (필수, dedup)
|
||||
* title: string, // 제목 (필수)
|
||||
* agency?: string, // 소관/주관기관
|
||||
* category?: string, // 지원분야
|
||||
* target?: string, // 지원대상
|
||||
* applyStart?: Date, // 접수 시작
|
||||
* applyEnd?: Date, // 접수 마감
|
||||
* detailUrl?: string, // 상세 페이지 URL
|
||||
* raw?: object, // 원본 데이터(JSON 저장)
|
||||
* }
|
||||
*/
|
||||
export class OpportunitySource {
|
||||
/** @param {{code:string,name:string,baseUrl?:string,type:'API'|'HTML',config?:object}} meta */
|
||||
constructor(meta) {
|
||||
if (!meta.code || !meta.name || !meta.type) {
|
||||
throw new Error('OpportunitySource meta 에 code/name/type 필수');
|
||||
}
|
||||
this.code = meta.code;
|
||||
this.name = meta.name;
|
||||
this.baseUrl = meta.baseUrl || null;
|
||||
this.type = meta.type;
|
||||
this.config = meta.config || {};
|
||||
}
|
||||
|
||||
meta() {
|
||||
return {
|
||||
code: this.code,
|
||||
name: this.name,
|
||||
baseUrl: this.baseUrl,
|
||||
type: this.type,
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 공고 목록을 수집한다. 하위 클래스에서 반드시 구현.
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async list() {
|
||||
throw new Error(`${this.code}: list() 미구현`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 본문을 수집한다. 기본 구현은 detailUrl 을 3단계 폴백 크롤러로 긁는다.
|
||||
* API 처럼 본문이 이미 raw 에 있는 소스는 이 메서드를 오버라이드한다.
|
||||
* @param {{id:string, externalId:string, detailUrl:string, raw:object|null}} row
|
||||
* @returns {Promise<string>} 본문 텍스트
|
||||
*/
|
||||
async fetchDetail(row) {
|
||||
if (!row.detailUrl) {
|
||||
throw new Error(`${this.code}/${row.externalId}: detailUrl 없음 — 상세 수집 불가`);
|
||||
}
|
||||
return crawl(row.detailUrl);
|
||||
}
|
||||
}
|
||||
126
government/src/sources/genericHtml.js
Normal file
126
government/src/sources/genericHtml.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// GenericHtmlSource — 표(table) 기반 게시판형 공고 목록을 config 로 수집하는 범용 HTML 어댑터.
|
||||
// 새 HTML 사이트는 코드 수정 없이 config 만 바꿔 추가할 수 있다(Strategy + 설정 주입).
|
||||
//
|
||||
// config 예시:
|
||||
// {
|
||||
// listUrl: 'https://.../List.do?cbIdx=310',
|
||||
// pageParam: 'pageIndex', // 페이지 쿼리 파라미터 (없으면 단일 페이지)
|
||||
// maxPages: 5,
|
||||
// rowSelector: 'table tbody tr',
|
||||
// title: { selector: 'td.subject a', attr: 'title' }, // attr 생략 시 text()
|
||||
// externalId: { from: 'onclick', regex: "doBbsFView\\('\\d+','(\\d+)'" },
|
||||
// detailUrl: { template: 'https://.../View.do?cbIdx=310&bcIdx={id}&parentSeq={id}' },
|
||||
// agency: '중소벤처기업부', // 정적 소관기관(선택)
|
||||
// }
|
||||
import * as cheerio from 'cheerio';
|
||||
import { OpportunitySource } from './base.js';
|
||||
import { log } from '../logger.js';
|
||||
import { decodeEntities, nonEmpty } 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';
|
||||
|
||||
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 필수`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#pageUrl(page) {
|
||||
const c = this.config;
|
||||
if (!c.pageParam) return c.listUrl;
|
||||
const url = new URL(c.listUrl);
|
||||
url.searchParams.set(c.pageParam, String(page));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async #fetchHtml(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA },
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} (${url})`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
#extractField($, row, spec) {
|
||||
if (!spec) return null;
|
||||
let el = spec.selector ? row.find(spec.selector).first() : row;
|
||||
if (el.length === 0) return null;
|
||||
let val;
|
||||
if (spec.attr) val = el.attr(spec.attr);
|
||||
else val = el.text();
|
||||
return decodeEntities(nonEmpty(val));
|
||||
}
|
||||
|
||||
#extractByRegex(text, pattern) {
|
||||
if (!text || !pattern) return null;
|
||||
const m = new RegExp(pattern).exec(text);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
#mapRow($, el) {
|
||||
const c = this.config;
|
||||
const row = $(el);
|
||||
|
||||
// externalId: onclick / href / 선택자 텍스트에서 정규식 추출
|
||||
let idSource;
|
||||
if (c.externalId.from === 'onclick') idSource = row.attr('onclick') || row.find('[onclick]').first().attr('onclick');
|
||||
else if (c.externalId.from === 'href') idSource = row.find('a').first().attr('href');
|
||||
else idSource = this.#extractField($, row, c.externalId);
|
||||
const externalId = c.externalId.regex
|
||||
? this.#extractByRegex(idSource, c.externalId.regex)
|
||||
: nonEmpty(idSource);
|
||||
if (!externalId) return null; // 헤더행 등은 스킵
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
externalId,
|
||||
title,
|
||||
agency: c.agency || this.#extractField($, row, c.agencyField) || null,
|
||||
category: this.#extractField($, row, c.categoryField),
|
||||
target: null,
|
||||
applyStart: null,
|
||||
applyEnd: null,
|
||||
detailUrl,
|
||||
raw: { onclick: row.attr('onclick') || null, title },
|
||||
};
|
||||
}
|
||||
|
||||
async list() {
|
||||
const c = this.config;
|
||||
const maxPages = c.maxPages || 1;
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const url = this.#pageUrl(page);
|
||||
const html = await this.#fetchHtml(url);
|
||||
const $ = cheerio.load(html);
|
||||
const rows = $(c.rowSelector);
|
||||
let pageCount = 0;
|
||||
rows.each((_, el) => {
|
||||
const item = this.#mapRow($, el);
|
||||
if (item && !seen.has(item.externalId)) {
|
||||
seen.add(item.externalId);
|
||||
out.push(item);
|
||||
pageCount += 1;
|
||||
}
|
||||
});
|
||||
log.info(`${this.code} page ${page}: ${pageCount}건`);
|
||||
if (pageCount === 0) break; // 더 이상 행이 없으면 종료
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
28
government/src/sources/htmlSources.js
Normal file
28
government/src/sources/htmlSources.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// config 로 정의되는 HTML 게시판 소스 목록.
|
||||
// 새 사이트는 여기 항목을 추가하면 된다(코드 로직 수정 불필요).
|
||||
import { GenericHtmlSource } from './genericHtml.js';
|
||||
|
||||
export const HTML_SOURCE_CONFIGS = [
|
||||
{
|
||||
code: 'mss',
|
||||
name: '중소벤처기업부 사업공고',
|
||||
baseUrl: 'https://www.mss.go.kr',
|
||||
config: {
|
||||
listUrl: 'https://www.mss.go.kr/site/smba/ex/bbs/List.do?cbIdx=310',
|
||||
pageParam: 'pageIndex',
|
||||
maxPages: 3,
|
||||
rowSelector: 'table tbody tr',
|
||||
title: { selector: 'td.subject a', attr: 'title' },
|
||||
externalId: { from: 'onclick', regex: "doBbsFView\\('\\d+','(\\d+)'" },
|
||||
detailUrl: {
|
||||
template:
|
||||
'https://www.mss.go.kr/site/smba/ex/bbs/View.do?cbIdx=310&bcIdx={id}&parentSeq={id}',
|
||||
},
|
||||
agency: '중소벤처기업부',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function buildHtmlSources() {
|
||||
return HTML_SOURCE_CONFIGS.map((cfg) => new GenericHtmlSource(cfg));
|
||||
}
|
||||
92
government/src/sources/kstartup.js
Normal file
92
government/src/sources/kstartup.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// K-Startup 창업지원 사업공고 Open API 어댑터 (data.go.kr 서비스키 사용).
|
||||
// 엔드포인트: getAnnouncementInformation (페이지네이션)
|
||||
import { OpportunitySource } from './base.js';
|
||||
import { config } from '../config.js';
|
||||
import { log } from '../logger.js';
|
||||
import { decodeEntities, parseYmd, nonEmpty } from '../util.js';
|
||||
|
||||
const ENDPOINT =
|
||||
'https://nidapi.k-startup.go.kr/api/kisedKstartupService/v1/getAnnouncementInformation';
|
||||
const PER_PAGE = 100;
|
||||
const MAX_PAGES = Number(process.env.GOV_KSTARTUP_MAX_PAGES || 400); // 안전 상한(약 4만건)
|
||||
|
||||
export class KStartupApiSource extends OpportunitySource {
|
||||
constructor() {
|
||||
super({
|
||||
code: 'kstartup',
|
||||
name: 'K-Startup 창업지원 공고',
|
||||
baseUrl: 'https://www.k-startup.go.kr',
|
||||
type: 'API',
|
||||
config: { endpoint: ENDPOINT, perPage: PER_PAGE },
|
||||
});
|
||||
}
|
||||
|
||||
static isAvailable() {
|
||||
return Boolean(config.dataGoKr.apiKey);
|
||||
}
|
||||
|
||||
async #fetchPage(page) {
|
||||
const url = new URL(ENDPOINT);
|
||||
url.searchParams.set('serviceKey', config.dataGoKr.apiKey);
|
||||
url.searchParams.set('page', String(page));
|
||||
url.searchParams.set('perPage', String(PER_PAGE));
|
||||
url.searchParams.set('returnType', 'json');
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`K-Startup API HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!Array.isArray(json.data)) {
|
||||
throw new Error(`K-Startup API 응답 형식 오류: ${JSON.stringify(json).slice(0, 200)}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// API 가 제공하는 필드들로 본문을 조립한다 (별도 상세 크롤링 불필요).
|
||||
#buildBody(item) {
|
||||
const parts = [];
|
||||
const content = decodeEntities(nonEmpty(item.pbanc_ctnt));
|
||||
if (content) parts.push(content);
|
||||
const target = decodeEntities(nonEmpty(item.aply_trgt_ctnt));
|
||||
if (target) parts.push(`[지원대상]\n${target}`);
|
||||
const exclude = decodeEntities(nonEmpty(item.aply_excl_trgt_ctnt));
|
||||
if (exclude) parts.push(`[제외대상]\n${exclude}`);
|
||||
const online = nonEmpty(item.aply_mthd_onli_rcpt_istc);
|
||||
if (online) parts.push(`[온라인 접수]\n${online}`);
|
||||
const guide = nonEmpty(item.biz_gdnc_url);
|
||||
if (guide) parts.push(`[안내 URL]\n${guide}`);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
#map(item) {
|
||||
const externalId = item.pbanc_sn != null ? String(item.pbanc_sn) : null;
|
||||
const title = decodeEntities(item.biz_pbanc_nm);
|
||||
if (!externalId || !title) {
|
||||
throw new Error(`K-Startup 항목 필수필드 누락: ${JSON.stringify(item).slice(0, 200)}`);
|
||||
}
|
||||
return {
|
||||
externalId,
|
||||
title,
|
||||
agency: decodeEntities(nonEmpty(item.pbanc_ntrp_nm) || nonEmpty(item.sprv_inst)),
|
||||
category: decodeEntities(nonEmpty(item.supt_biz_clsfc)),
|
||||
target: decodeEntities(nonEmpty(item.aply_trgt_ctnt) || nonEmpty(item.aply_trgt)),
|
||||
applyStart: parseYmd(item.pbanc_rcpt_bgng_dt),
|
||||
applyEnd: parseYmd(item.pbanc_rcpt_end_dt),
|
||||
detailUrl: nonEmpty(item.detl_pg_url),
|
||||
body: this.#buildBody(item), // 목록 단계에서 본문까지 적재
|
||||
raw: item,
|
||||
};
|
||||
}
|
||||
|
||||
async list() {
|
||||
const out = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
const json = await this.#fetchPage(page);
|
||||
const rows = json.data;
|
||||
log.info(`K-Startup page ${page}: ${rows.length}건 (totalCount=${json.totalCount})`);
|
||||
for (const item of rows) out.push(this.#map(item));
|
||||
if (rows.length < PER_PAGE) break; // 마지막 페이지
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
32
government/src/sources/registry.js
Normal file
32
government/src/sources/registry.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// 사용 가능한 소스 어댑터 레지스트리.
|
||||
// 키(서비스키 등)가 없는 소스는 자동 제외한다.
|
||||
import { KStartupApiSource } from './kstartup.js';
|
||||
import { buildHtmlSources } from './htmlSources.js';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
// 키/설정 가용성 검사가 있는 API 소스 클래스들
|
||||
const API_SOURCE_CLASSES = [KStartupApiSource];
|
||||
|
||||
/**
|
||||
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.
|
||||
*/
|
||||
export function availableSources() {
|
||||
const out = [];
|
||||
for (const Cls of API_SOURCE_CLASSES) {
|
||||
if (typeof Cls.isAvailable === 'function' && !Cls.isAvailable()) {
|
||||
log.warn(`소스 비활성(키/설정 없음): ${Cls.name}`);
|
||||
continue;
|
||||
}
|
||||
out.push(new Cls());
|
||||
}
|
||||
// config 기반 HTML 소스(항상 가용)
|
||||
out.push(...buildHtmlSources());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 code 의 소스 하나만 가져온다(수동 실행용). 없으면 null.
|
||||
*/
|
||||
export function sourceByCode(code) {
|
||||
return availableSources().find((s) => s.code === code) || null;
|
||||
}
|
||||
Reference in New Issue
Block a user