정부지원사업 공고 수집 데몬(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:
19
government/src/bootstrap.js
vendored
Normal file
19
government/src/bootstrap.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// Oracle Instant Client(thick 모드)는 libnnz 등 의존 라이브러리를 LD_LIBRARY_PATH 로 찾는다.
|
||||
// LD_LIBRARY_PATH 는 프로세스 시작 시점에만 읽히므로, 누락 시 동일 인자로 한 번 재실행한다.
|
||||
// 진입점(daemon.js, cli.js) 최상단에서 가장 먼저 import 할 것.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const IC = process.env.ORACLE_IC_LIB_DIR || '/home/opc/oracle-ic/instantclient_23_26';
|
||||
const current = (process.env.LD_LIBRARY_PATH || '').split(':').filter(Boolean);
|
||||
|
||||
if (!current.includes(IC)) {
|
||||
const env = {
|
||||
...process.env,
|
||||
LD_LIBRARY_PATH: [IC, ...current].join(':'),
|
||||
};
|
||||
const result = spawnSync(process.execPath, process.argv.slice(1), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
57
government/src/cli.js
Normal file
57
government/src/cli.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// 수동 실행 CLI.
|
||||
// node src/cli.js test-db DB 접속 확인
|
||||
// node src/cli.js test-crawl <url> 3단계 크롤러 단독 테스트
|
||||
// node src/cli.js run-once [sourceCode] 1회 수집 (코드 생략 시 전체)
|
||||
import './bootstrap.js'; // LD_LIBRARY_PATH 보정 (가장 먼저)
|
||||
import { log } from './logger.js';
|
||||
import { withConnection, closePool } from './db.js';
|
||||
import { crawl } from './crawler/crawler.js';
|
||||
import { disconnectBrowser } from './crawler/browser.js';
|
||||
import { availableSources, sourceByCode } from './sources/registry.js';
|
||||
import { runAll } from './pipeline.js';
|
||||
|
||||
async function cleanup() {
|
||||
await disconnectBrowser();
|
||||
await closePool();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, arg] = process.argv.slice(2);
|
||||
switch (cmd) {
|
||||
case 'test-db': {
|
||||
const r = await withConnection((c) =>
|
||||
c.execute('SELECT COUNT(*) FROM gov_source')
|
||||
);
|
||||
log.info('DB OK, gov_source 행수 =', r.rows[0][0]);
|
||||
break;
|
||||
}
|
||||
case 'test-crawl': {
|
||||
if (!arg) throw new Error('사용법: test-crawl <url>');
|
||||
const text = await crawl(arg);
|
||||
log.info(`크롤 결과 ${text.length}자:\n${text.slice(0, 500)}`);
|
||||
break;
|
||||
}
|
||||
case 'run-once': {
|
||||
const sources = arg
|
||||
? [sourceByCode(arg)].filter(Boolean)
|
||||
: availableSources();
|
||||
if (sources.length === 0) {
|
||||
throw new Error(arg ? `소스 없음/비활성: ${arg}` : '가용 소스 없음');
|
||||
}
|
||||
const results = await runAll(sources);
|
||||
log.info('수집 결과:', JSON.stringify(results));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log.info('사용법: test-db | test-crawl <url> | run-once [sourceCode]');
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(cleanup)
|
||||
.then(() => process.exit(0))
|
||||
.catch(async (e) => {
|
||||
log.error('CLI 오류:', e.stack || e.message);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
43
government/src/config.js
Normal file
43
government/src/config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// 환경설정 로더. 프로젝트 루트(.env)를 읽어 데몬 전역 설정으로 노출한다.
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..', '..'); // /home/opc/sundol
|
||||
|
||||
dotenv.config({ path: path.join(ROOT, '.env') });
|
||||
|
||||
function required(name) {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === null || v === '') {
|
||||
throw new Error(`필수 환경변수 누락: ${name}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
root: ROOT,
|
||||
oracle: {
|
||||
user: required('ORACLE_USERNAME'),
|
||||
password: required('ORACLE_PASSWORD'),
|
||||
connectString: required('ORACLE_TNS_NAME'),
|
||||
walletPath: required('ORACLE_WALLET_PATH'),
|
||||
// thick 모드: Instant Client 라이브러리 + sso 지갑을 읽을 net 설정 디렉터리
|
||||
icLibDir: process.env.ORACLE_IC_LIB_DIR || '/home/opc/oracle-ic/instantclient_23_26',
|
||||
netConfigDir:
|
||||
process.env.ORACLE_NET_CONFIG_DIR ||
|
||||
path.join(ROOT, 'government', 'oracle-net'),
|
||||
},
|
||||
dataGoKr: {
|
||||
apiKey: process.env.DATA_GO_KR_API_KEY || '',
|
||||
},
|
||||
bizinfo: {
|
||||
crtfcKey: process.env.BIZINFO_CRTFC_KEY || '',
|
||||
},
|
||||
jina: {
|
||||
apiKey: process.env.JINA_READER_API_KEY || '',
|
||||
},
|
||||
cdpUrl: process.env.GOV_CDP_URL || 'http://127.0.0.1:9222',
|
||||
pollIntervalMinutes: Number(process.env.GOV_POLL_INTERVAL_MINUTES || 60),
|
||||
};
|
||||
68
government/src/crawler/browser.js
Normal file
68
government/src/crawler/browser.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// 기존 sundol-chrome(PM2, CDP 9222)에 연결해 새 탭을 여는 싱글톤.
|
||||
// VNC 에서 사용자가 로그인한 세션을 그대로 사용하므로 봇 판정 우회에 유리하다.
|
||||
// (백엔드 PlaywrightBrowserService 와 동일한 전략)
|
||||
import { chromium } from 'playwright-core';
|
||||
import { config } from '../config.js';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
let browser = null;
|
||||
|
||||
async function ensureBrowser() {
|
||||
if (browser && browser.isConnected()) return browser;
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
// 끊긴 연결 정리 실패는 무시 가능 — 곧바로 재연결한다
|
||||
}
|
||||
}
|
||||
log.info(`Chrome CDP 연결 시도: ${config.cdpUrl}`);
|
||||
browser = await chromium.connectOverCDP(config.cdpUrl);
|
||||
log.info(`CDP 연결 완료: contexts=${browser.contexts().length}`);
|
||||
return browser;
|
||||
}
|
||||
|
||||
function defaultContext(b) {
|
||||
const contexts = b.contexts();
|
||||
if (contexts.length === 0) {
|
||||
throw new Error('Chrome 에 활성 컨텍스트가 없습니다.');
|
||||
}
|
||||
return contexts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 탭을 열어 URL 로 이동한다. 호출자는 사용 후 반드시 closePage(page) 할 것.
|
||||
*/
|
||||
export async function openPage(url, { timeoutMs = 30_000, waitUntil = 'networkidle' } = {}) {
|
||||
const b = await ensureBrowser();
|
||||
const ctx = defaultContext(b);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(url, { timeout: timeoutMs, waitUntil });
|
||||
return page;
|
||||
} catch (e) {
|
||||
await closePage(page);
|
||||
throw new Error(`페이지 로드 실패 (${url}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closePage(page) {
|
||||
if (!page) return;
|
||||
try {
|
||||
await page.close();
|
||||
} catch (e) {
|
||||
log.warn('탭 닫기 실패:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectBrowser() {
|
||||
if (browser) {
|
||||
try {
|
||||
// CDP 연결만 해제 (Chrome 자체는 종료하지 않음)
|
||||
await browser.close();
|
||||
} catch (e) {
|
||||
log.warn('CDP 연결 해제 실패:', e.message);
|
||||
}
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
117
government/src/crawler/crawler.js
Normal file
117
government/src/crawler/crawler.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// 3단계 폴백 크롤러 (sundol WebCrawlerService 의 Node 재구현)
|
||||
// 1차: 정적 fetch + cheerio 본문 추출
|
||||
// 2차: Jina Reader (r.jina.ai)
|
||||
// 3차: Playwright (sundol-chrome CDP) 로 실제 렌더링 후 innerText
|
||||
// Facade: 호출자는 crawl(url) 만 사용한다.
|
||||
import * as cheerio from 'cheerio';
|
||||
import { config } from '../config.js';
|
||||
import { log } from '../logger.js';
|
||||
import { openPage, closePage } from './browser.js';
|
||||
|
||||
const JINA_BASE = 'https://r.jina.ai/';
|
||||
const MIN_CONTENT_LENGTH = 100;
|
||||
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';
|
||||
const ERROR_PATTERNS = [
|
||||
'access denied', '403 forbidden', "you don't have permission",
|
||||
'error 403', 'error 401', 'unauthorized', 'captcha',
|
||||
'please enable javascript', 'checking your browser',
|
||||
'attention required', 'just a moment',
|
||||
'technical difficulty', 'page not found', '404 not found',
|
||||
];
|
||||
const REMOVE_SELECTORS = 'nav, footer, header, script, style, .ad, #cookie-banner, .sidebar, .comments';
|
||||
const ARTICLE_SELECTORS = 'article, main, .post-content, .article-body, .entry-content';
|
||||
|
||||
function isValidContent(text) {
|
||||
if (!text || text.length < MIN_CONTENT_LENGTH) return false;
|
||||
const preview = text.slice(0, 500).toLowerCase();
|
||||
for (const pattern of ERROR_PATTERNS) {
|
||||
if (preview.includes(pattern)) {
|
||||
log.warn(`에러 페이지 패턴 감지: '${pattern}'`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function crawlWithCheerio(url) {
|
||||
log.info(`정적 크롤링(cheerio): ${url}`);
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA },
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const $ = cheerio.load(html);
|
||||
$(REMOVE_SELECTORS).remove();
|
||||
const article = $(ARTICLE_SELECTORS).first();
|
||||
const text = (article.length ? article : $('body')).text().replace(/\s+\n/g, '\n').trim();
|
||||
log.info(`cheerio 추출: ${text.length} chars`);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function crawlWithJina(url) {
|
||||
log.info(`Jina Reader 크롤링: ${url}`);
|
||||
const headers = { Accept: 'text/plain' };
|
||||
if (config.jina.apiKey) headers.Authorization = `Bearer ${config.jina.apiKey}`;
|
||||
const res = await fetch(JINA_BASE + url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Jina HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (!text || !text.trim()) throw new Error('Jina Reader 빈 응답');
|
||||
log.info(`Jina 추출: ${text.length} chars`);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function crawlWithPlaywright(url) {
|
||||
log.info(`Playwright 크롤링: ${url}`);
|
||||
const page = await openPage(url);
|
||||
try {
|
||||
const text = await page.evaluate(
|
||||
({ removeSel, articleSel }) => {
|
||||
removeSel.split(',').forEach((sel) =>
|
||||
document.querySelectorAll(sel.trim()).forEach((el) => el.remove())
|
||||
);
|
||||
const article = document.querySelector(articleSel);
|
||||
return (article || document.body).innerText;
|
||||
},
|
||||
{ removeSel: REMOVE_SELECTORS, articleSel: ARTICLE_SELECTORS }
|
||||
);
|
||||
if (!text || !text.trim()) throw new Error('Playwright 빈 본문');
|
||||
log.info(`Playwright 추출: ${text.length} chars`);
|
||||
return text;
|
||||
} finally {
|
||||
await closePage(page);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 텍스트를 3단계 폴백으로 수집한다. 모두 실패하면 throw.
|
||||
*/
|
||||
export async function crawl(url) {
|
||||
// 1차
|
||||
try {
|
||||
const text = await crawlWithCheerio(url);
|
||||
if (isValidContent(text)) return text;
|
||||
log.warn(`cheerio 무효 콘텐츠(${text?.length || 0}자) → Jina 폴백`);
|
||||
} catch (e) {
|
||||
log.warn(`cheerio 실패(${url}): ${e.message} → Jina 폴백`);
|
||||
}
|
||||
// 2차
|
||||
try {
|
||||
const text = await crawlWithJina(url);
|
||||
if (isValidContent(text)) return text;
|
||||
log.warn(`Jina 무효 콘텐츠(${text?.length || 0}자) → Playwright 폴백`);
|
||||
} catch (e) {
|
||||
log.warn(`Jina 실패(${url}): ${e.message} → Playwright 폴백`);
|
||||
}
|
||||
// 3차
|
||||
const text = await crawlWithPlaywright(url);
|
||||
if (!isValidContent(text)) {
|
||||
throw new Error(`모든 크롤링 방법 실패: ${url}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
62
government/src/daemon.js
Normal file
62
government/src/daemon.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// 정부지원사업 수집 데몬. 주기적으로 가용 소스 전체를 1회 수집한다.
|
||||
// PM2 로 상시 구동: pm2 start ecosystem.config.cjs --only gov-daemon
|
||||
import './bootstrap.js'; // LD_LIBRARY_PATH 보정 (가장 먼저)
|
||||
import { config } from './config.js';
|
||||
import { log } from './logger.js';
|
||||
import { closePool } from './db.js';
|
||||
import { disconnectBrowser } from './crawler/browser.js';
|
||||
import { availableSources } from './sources/registry.js';
|
||||
import { runAll } from './pipeline.js';
|
||||
|
||||
let stopping = false;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function cycle() {
|
||||
const sources = availableSources();
|
||||
if (sources.length === 0) {
|
||||
log.warn('가용 소스가 없습니다. (서비스키/설정 확인)');
|
||||
return;
|
||||
}
|
||||
log.info(`수집 사이클 시작: 소스 ${sources.length}개 [${sources.map((s) => s.code).join(', ')}]`);
|
||||
const results = await runAll(sources);
|
||||
log.info('수집 사이클 종료:', JSON.stringify(results));
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
log.info(`${signal} 수신 — 데몬 종료 중`);
|
||||
try {
|
||||
await disconnectBrowser();
|
||||
await closePool();
|
||||
} catch (e) {
|
||||
log.warn('종료 정리 중 오류:', e.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
async function main() {
|
||||
const intervalMs = Math.max(1, config.pollIntervalMinutes) * 60_000;
|
||||
log.info(`gov-daemon 시작. 폴링 주기 ${config.pollIntervalMinutes}분.`);
|
||||
while (!stopping) {
|
||||
try {
|
||||
await cycle();
|
||||
} catch (e) {
|
||||
log.error('수집 사이클 오류:', e.stack || e.message);
|
||||
}
|
||||
if (stopping) break;
|
||||
log.info(`다음 사이클까지 ${config.pollIntervalMinutes}분 대기`);
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
log.error('데몬 치명적 오류:', e.stack || e.message);
|
||||
await shutdown('FATAL');
|
||||
});
|
||||
60
government/src/db.js
Normal file
60
government/src/db.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Oracle Autonomous DB 접속 (node-oracledb thick 모드).
|
||||
// Instant Client + sso 지갑(cwallet.sso)을 사용하므로 지갑 비밀번호가 필요 없다.
|
||||
// (백엔드 JDBC 와 동일하게 자동로그인 지갑을 재사용)
|
||||
import oracledb from 'oracledb';
|
||||
import { config } from './config.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
oracledb.fetchAsString = [oracledb.CLOB];
|
||||
oracledb.autoCommit = false;
|
||||
|
||||
let pool = null;
|
||||
let clientInitialized = false;
|
||||
|
||||
function initClient() {
|
||||
if (clientInitialized) return;
|
||||
oracledb.initOracleClient({
|
||||
libDir: config.oracle.icLibDir,
|
||||
configDir: config.oracle.netConfigDir, // tnsnames.ora + WALLET_LOCATION 보정 sqlnet.ora
|
||||
});
|
||||
clientInitialized = true;
|
||||
}
|
||||
|
||||
export async function initPool() {
|
||||
if (pool) return pool;
|
||||
initClient();
|
||||
pool = await oracledb.createPool({
|
||||
user: config.oracle.user,
|
||||
password: config.oracle.password,
|
||||
connectString: config.oracle.connectString,
|
||||
poolMin: 1,
|
||||
poolMax: 4,
|
||||
poolIncrement: 1,
|
||||
});
|
||||
log.info('Oracle 풀 생성 완료');
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function withConnection(fn) {
|
||||
if (!pool) await initPool();
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
return await fn(conn);
|
||||
} finally {
|
||||
try {
|
||||
await conn.close();
|
||||
} catch (e) {
|
||||
log.warn('연결 반환 실패:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.close(10);
|
||||
pool = null;
|
||||
log.info('Oracle 풀 종료');
|
||||
}
|
||||
}
|
||||
|
||||
export { oracledb };
|
||||
10
government/src/logger.js
Normal file
10
government/src/logger.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// 간단한 타임스탬프 로거. PM2 로그로 그대로 흘러간다.
|
||||
function ts() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info: (...args) => console.log(`[${ts()}] [INFO]`, ...args),
|
||||
warn: (...args) => console.warn(`[${ts()}] [WARN]`, ...args),
|
||||
error: (...args) => console.error(`[${ts()}] [ERROR]`, ...args),
|
||||
};
|
||||
91
government/src/pipeline.js
Normal file
91
government/src/pipeline.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// 수집 파이프라인: 소스별로 목록 수집 → 적재 → 상세 본문 수집.
|
||||
import {
|
||||
ensureSource,
|
||||
upsertOpportunities,
|
||||
findPendingDetail,
|
||||
saveDetail,
|
||||
markDetailError,
|
||||
markSourceCrawled,
|
||||
} from './store/opportunityStore.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
const DETAIL_BATCH = Number(process.env.GOV_DETAIL_BATCH || 200); // 한 사이클에 상세 수집할 최대 건수(소스당)
|
||||
const DETAIL_DELAY_MS = 300; // 상세 수집 간 간격(서버 부담 완화)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 소스 1회 수집.
|
||||
*/
|
||||
export async function runSource(source) {
|
||||
const startedAt = Date.now();
|
||||
log.info(`==== 소스 수집 시작: ${source.code} (${source.name}) ====`);
|
||||
|
||||
// 1) 소스 등록/갱신
|
||||
const { id: sourceId, active } = await ensureSource(source.meta());
|
||||
if (!active) {
|
||||
log.warn(`소스 비활성 상태(DB active=0): ${source.code} — 건너뜀`);
|
||||
return { source: source.code, skipped: true };
|
||||
}
|
||||
|
||||
// 2) 목록 수집 → 적재
|
||||
const items = await source.list();
|
||||
log.info(`${source.code}: 목록 ${items.length}건 수집`);
|
||||
const upsert = await upsertOpportunities(sourceId, source.code, items);
|
||||
log.info(
|
||||
`${source.code}: 적재 처리=${upsert.processed} 신규=${upsert.inserted} 갱신=${upsert.updated}`
|
||||
);
|
||||
|
||||
// 3) 상세 본문 수집 (LISTED 상태만)
|
||||
const pending = await findPendingDetail(source.code, DETAIL_BATCH);
|
||||
log.info(`${source.code}: 상세 수집 대상 ${pending.length}건`);
|
||||
let detailOk = 0;
|
||||
let detailErr = 0;
|
||||
for (const row of pending) {
|
||||
try {
|
||||
const body = await source.fetchDetail(row);
|
||||
if (!body || !body.trim()) {
|
||||
throw new Error('빈 본문');
|
||||
}
|
||||
await saveDetail(row.id, body);
|
||||
detailOk += 1;
|
||||
} catch (e) {
|
||||
log.warn(`${source.code}/${row.externalId} 상세 실패: ${e.message}`);
|
||||
await markDetailError(row.id);
|
||||
detailErr += 1;
|
||||
}
|
||||
if (DETAIL_DELAY_MS > 0) await sleep(DETAIL_DELAY_MS);
|
||||
}
|
||||
|
||||
await markSourceCrawled(sourceId);
|
||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
log.info(
|
||||
`==== 소스 완료: ${source.code} | 신규 ${upsert.inserted} 갱신 ${upsert.updated} | 상세 OK ${detailOk} 실패 ${detailErr} | ${elapsed}s ====`
|
||||
);
|
||||
return {
|
||||
source: source.code,
|
||||
listed: items.length,
|
||||
inserted: upsert.inserted,
|
||||
updated: upsert.updated,
|
||||
detailOk,
|
||||
detailErr,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 가용 소스 1회 수집.
|
||||
*/
|
||||
export async function runAll(sources) {
|
||||
const results = [];
|
||||
for (const source of sources) {
|
||||
try {
|
||||
results.push(await runSource(source));
|
||||
} catch (e) {
|
||||
log.error(`소스 ${source.code} 수집 중 오류: ${e.message}`);
|
||||
results.push({ source: source.code, error: e.message });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
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;
|
||||
}
|
||||
197
government/src/store/opportunityStore.js
Normal file
197
government/src/store/opportunityStore.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// gov_source / gov_opportunity 적재 로직. 중복 제거는 (source_code, external_id) 유니크 키로 한다.
|
||||
import { withConnection, oracledb } from '../db.js';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
function clobBind(val) {
|
||||
return { dir: oracledb.BIND_IN, type: oracledb.DB_TYPE_CLOB, val: val ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스를 upsert 하고 RAWTOHEX(id) 를 반환한다.
|
||||
*/
|
||||
export async function ensureSource({ code, name, baseUrl, type, config }) {
|
||||
return withConnection(async (conn) => {
|
||||
await conn.execute(
|
||||
`MERGE INTO gov_source t
|
||||
USING (SELECT :code AS code FROM dual) s
|
||||
ON (t.code = s.code)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
name = :name, base_url = :baseUrl, type = :type,
|
||||
config = :config, updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT (id, code, name, base_url, type, config, active, created_at, updated_at)
|
||||
VALUES (SYS_GUID(), :code, :name, :baseUrl, :type, :config, 1, SYSTIMESTAMP, SYSTIMESTAMP)`,
|
||||
{
|
||||
code,
|
||||
name,
|
||||
baseUrl: baseUrl ?? null,
|
||||
type,
|
||||
config: clobBind(config ? JSON.stringify(config) : null),
|
||||
}
|
||||
);
|
||||
await conn.commit();
|
||||
const r = await conn.execute(
|
||||
`SELECT RAWTOHEX(id) AS id, active FROM gov_source WHERE code = :code`,
|
||||
{ code }
|
||||
);
|
||||
return { id: r.rows[0][0], active: r.rows[0][1] === 1 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 소스 목록을 반환한다.
|
||||
*/
|
||||
export async function listActiveSources() {
|
||||
return withConnection(async (conn) => {
|
||||
const r = await conn.execute(
|
||||
`SELECT RAWTOHEX(id) AS id, code, name, base_url, type, config
|
||||
FROM gov_source WHERE active = 1 ORDER BY code`,
|
||||
{},
|
||||
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||
);
|
||||
return r.rows.map((row) => ({
|
||||
id: row.ID,
|
||||
code: row.CODE,
|
||||
name: row.NAME,
|
||||
baseUrl: row.BASE_URL,
|
||||
type: row.TYPE,
|
||||
config: row.CONFIG ? JSON.parse(row.CONFIG) : {},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 단계 공고들을 dedup-merge 한다. 기존 행의 본문/상세 상태는 보존한다.
|
||||
* @returns {{inserted:number, updated:number}}
|
||||
*/
|
||||
export async function upsertOpportunities(sourceIdHex, sourceCode, items) {
|
||||
if (!items || items.length === 0) return { inserted: 0, updated: 0 };
|
||||
return withConnection(async (conn) => {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
// 신규/갱신 판별을 위해 기존 external_id 를 한 번에 로드(행당 SELECT 제거).
|
||||
const existing = new Set();
|
||||
{
|
||||
const r = await conn.execute(
|
||||
`SELECT external_id FROM gov_opportunity WHERE source_code = :sc`,
|
||||
{ sc: sourceCode }
|
||||
);
|
||||
for (const row of r.rows) existing.add(String(row[0]));
|
||||
}
|
||||
for (const it of items) {
|
||||
if (!it.externalId || !it.title) {
|
||||
throw new Error(
|
||||
`필수 필드 누락 (externalId/title): ${JSON.stringify(it).slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
const isNew = !existing.has(String(it.externalId));
|
||||
const hasBody = it.body && it.body.trim() ? 1 : 0;
|
||||
// body 가 있으면(API 처럼) 목록 단계에서 바로 본문 저장 → 상태 DETAILED.
|
||||
// 기존 행 갱신 시 body 가 없으면 기존 본문/상태를 보존한다.
|
||||
await conn.execute(
|
||||
`MERGE INTO gov_opportunity t
|
||||
USING (SELECT :sourceCode AS source_code, :externalId AS external_id FROM dual) s
|
||||
ON (t.source_code = s.source_code AND t.external_id = s.external_id)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
title = :title, agency = :agency, category = :category, target = :target,
|
||||
apply_start = :applyStart, apply_end = :applyEnd, detail_url = :detailUrl,
|
||||
raw_json = :rawJson,
|
||||
body_text = CASE WHEN :hasBody = 1 THEN :body ELSE body_text END,
|
||||
status = CASE WHEN :hasBody = 1 THEN 'DETAILED' ELSE status END,
|
||||
detail_collected_at = CASE WHEN :hasBody = 1 THEN SYSTIMESTAMP ELSE detail_collected_at END,
|
||||
updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(id, source_id, source_code, external_id, title, agency, category, target,
|
||||
apply_start, apply_end, detail_url, raw_json, body_text, status,
|
||||
list_collected_at, detail_collected_at, created_at, updated_at)
|
||||
VALUES (SYS_GUID(), HEXTORAW(:sourceId), :sourceCode, :externalId, :title, :agency,
|
||||
:category, :target, :applyStart, :applyEnd, :detailUrl, :rawJson, :body,
|
||||
CASE WHEN :hasBody = 1 THEN 'DETAILED' ELSE 'LISTED' END,
|
||||
SYSTIMESTAMP, CASE WHEN :hasBody = 1 THEN SYSTIMESTAMP ELSE NULL END,
|
||||
SYSTIMESTAMP, SYSTIMESTAMP)`,
|
||||
{
|
||||
sourceId: sourceIdHex,
|
||||
sourceCode,
|
||||
externalId: String(it.externalId),
|
||||
title: it.title.slice(0, 1000),
|
||||
agency: it.agency ? it.agency.slice(0, 300) : null,
|
||||
category: it.category ? it.category.slice(0, 200) : null,
|
||||
target: it.target ? it.target.slice(0, 1000) : null,
|
||||
applyStart: it.applyStart ?? null,
|
||||
applyEnd: it.applyEnd ?? null,
|
||||
detailUrl: it.detailUrl ? it.detailUrl.slice(0, 1000) : null,
|
||||
rawJson: clobBind(it.raw ? JSON.stringify(it.raw) : null),
|
||||
body: clobBind(hasBody ? it.body : null),
|
||||
hasBody,
|
||||
}
|
||||
);
|
||||
if (isNew) inserted += 1;
|
||||
else updated += 1;
|
||||
}
|
||||
await conn.commit();
|
||||
return { processed: items.length, inserted, updated };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 본문 미수집(LISTED) 공고를 가져온다.
|
||||
*/
|
||||
export async function findPendingDetail(sourceCode, limit) {
|
||||
return withConnection(async (conn) => {
|
||||
const r = await conn.execute(
|
||||
`SELECT RAWTOHEX(id) AS id, external_id, detail_url
|
||||
FROM gov_opportunity
|
||||
WHERE source_code = :sourceCode AND status = 'LISTED' AND detail_url IS NOT NULL
|
||||
ORDER BY created_at
|
||||
FETCH FIRST :lim ROWS ONLY`,
|
||||
{ sourceCode, lim: limit },
|
||||
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||
);
|
||||
return r.rows.map((row) => ({
|
||||
id: row.ID,
|
||||
externalId: row.EXTERNAL_ID,
|
||||
detailUrl: row.DETAIL_URL,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 본문을 저장하고 상태를 DETAILED 로 갱신한다.
|
||||
*/
|
||||
export async function saveDetail(idHex, bodyText) {
|
||||
return withConnection(async (conn) => {
|
||||
await conn.execute(
|
||||
`UPDATE gov_opportunity
|
||||
SET body_text = :body, status = 'DETAILED',
|
||||
detail_collected_at = SYSTIMESTAMP, updated_at = SYSTIMESTAMP
|
||||
WHERE id = HEXTORAW(:id)`,
|
||||
{ body: clobBind(bodyText), id: idHex }
|
||||
);
|
||||
await conn.commit();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 수집 실패 표시.
|
||||
*/
|
||||
export async function markDetailError(idHex) {
|
||||
return withConnection(async (conn) => {
|
||||
await conn.execute(
|
||||
`UPDATE gov_opportunity
|
||||
SET status = 'ERROR', updated_at = SYSTIMESTAMP
|
||||
WHERE id = HEXTORAW(:id)`,
|
||||
{ id: idHex }
|
||||
);
|
||||
await conn.commit();
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSourceCrawled(sourceIdHex) {
|
||||
return withConnection(async (conn) => {
|
||||
await conn.execute(
|
||||
`UPDATE gov_source SET last_crawled_at = SYSTIMESTAMP, updated_at = SYSTIMESTAMP
|
||||
WHERE id = HEXTORAW(:id)`,
|
||||
{ id: sourceIdHex }
|
||||
);
|
||||
await conn.commit();
|
||||
});
|
||||
}
|
||||
34
government/src/util.js
Normal file
34
government/src/util.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// 공용 유틸: HTML 엔티티 디코드, YYYYMMDD 날짜 파싱.
|
||||
|
||||
const ENTITIES = {
|
||||
'&': '&', '<': '<', '>': '>', '"': '"',
|
||||
''': "'", ''': "'", ' ': ' ',
|
||||
};
|
||||
|
||||
export function decodeEntities(s) {
|
||||
if (s == null) return null;
|
||||
return String(s)
|
||||
.replace(/&|<|>|"|'|'| /g, (m) => ENTITIES[m])
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 'YYYYMMDD' 또는 'YYYY-MM-DD' 를 Date 로. 형식 불일치면 null.
|
||||
*/
|
||||
export function parseYmd(s) {
|
||||
if (s == null) return null;
|
||||
const digits = String(s).replace(/[^0-9]/g, '');
|
||||
if (digits.length !== 8) return null;
|
||||
const y = Number(digits.slice(0, 4));
|
||||
const m = Number(digits.slice(4, 6));
|
||||
const d = Number(digits.slice(6, 8));
|
||||
if (m < 1 || m > 12 || d < 1 || d > 31) return null;
|
||||
return new Date(Date.UTC(y, m - 1, d));
|
||||
}
|
||||
|
||||
export function nonEmpty(s) {
|
||||
if (s == null) return null;
|
||||
const t = String(s).trim();
|
||||
return t === '' ? null : t;
|
||||
}
|
||||
Reference in New Issue
Block a user