gov-scraper: 마스터 사업계획서 + 공고 매칭/추출 스크립트 추가
- docs/business-plans.md: Tasteby/Lyricsy/Parents Story 3개 앱 PSST 사업계획서 초안 - scripts/match.js: 앱별 주제 키워드 매칭 조회 - scripts/eligible.js: 예비창업자 자격 + 현재 열린 공고 목록 - scripts/export_eligible_csv.js: 신청 추적용 CSV(exports/) 생성 - exports/ gitignore Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
government/scripts/eligible.js
Normal file
37
government/scripts/eligible.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// 예비창업자가 지원 가능(자격에 '예비창업' 명시)하고 현재 열린 공고 전체를 마감일 순으로.
|
||||
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/eligible.js
|
||||
import { withConnection, closePool, oracledb } from '../src/db.js';
|
||||
|
||||
const SQL = `
|
||||
SELECT * FROM (
|
||||
SELECT title, category, agency, source_code, detail_url, apply_end,
|
||||
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
|
||||
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
|
||||
FROM gov_opportunity
|
||||
WHERE (apply_end IS NULL OR apply_end >= TRUNC(SYSDATE))
|
||||
AND (
|
||||
target LIKE '%' || :k1 || '%'
|
||||
OR target LIKE '%' || :k2 || '%'
|
||||
OR DBMS_LOB.INSTR(body_text, :k1) > 0
|
||||
OR DBMS_LOB.INSTR(body_text, :k2) > 0
|
||||
)
|
||||
)
|
||||
WHERE rn = 1
|
||||
ORDER BY dleft ASC
|
||||
FETCH FIRST 80 ROWS ONLY`;
|
||||
|
||||
await withConnection(async (conn) => {
|
||||
const r = await conn.execute(
|
||||
SQL,
|
||||
{ k1: '예비창업', k2: '예비 창업' },
|
||||
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||
);
|
||||
console.log(`현재 신청가능(예비창업자 자격) 공고: ${r.rows.length}건\n`);
|
||||
for (const x of r.rows) {
|
||||
const tag = x.DLEFT === 9999 ? '[상시]' : `[D-${x.DLEFT}]`;
|
||||
console.log(
|
||||
`${tag}\t${x.SOURCE_CODE}\t${(x.CATEGORY || '-').slice(0, 8)}\t${x.TITLE.slice(0, 52)}\t${x.DETAIL_URL}`
|
||||
);
|
||||
}
|
||||
});
|
||||
await closePool();
|
||||
68
government/scripts/export_eligible_csv.js
Normal file
68
government/scripts/export_eligible_csv.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// 예비창업자 자격 + 현재 열린 공고 전체를 CSV 로 내보낸다(신청 추적용).
|
||||
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/export_eligible_csv.js
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { withConnection, closePool, oracledb } from '../src/db.js';
|
||||
|
||||
const SQL = `
|
||||
SELECT * FROM (
|
||||
SELECT title, category, agency, source_code, detail_url, apply_start, apply_end,
|
||||
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
|
||||
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
|
||||
FROM gov_opportunity
|
||||
WHERE (apply_end IS NULL OR apply_end >= TRUNC(SYSDATE))
|
||||
AND (
|
||||
target LIKE '%' || :k1 || '%' OR target LIKE '%' || :k2 || '%'
|
||||
OR DBMS_LOB.INSTR(body_text, :k1) > 0 OR DBMS_LOB.INSTR(body_text, :k2) > 0
|
||||
)
|
||||
)
|
||||
WHERE rn = 1
|
||||
ORDER BY dleft ASC, source_code`;
|
||||
|
||||
function csvCell(v) {
|
||||
if (v == null) return '';
|
||||
const s = String(v).replace(/"/g, '""').replace(/\r?\n/g, ' ');
|
||||
return `"${s}"`;
|
||||
}
|
||||
function ymd(d) {
|
||||
return d ? new Date(d).toISOString().slice(0, 10) : '';
|
||||
}
|
||||
function region(title) {
|
||||
const m = /^\[([^\]]{1,8})\]/.exec(title);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
const rows = await withConnection(async (conn) => {
|
||||
const r = await conn.execute(
|
||||
SQL,
|
||||
{ k1: '예비창업', k2: '예비 창업' },
|
||||
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||
);
|
||||
return r.rows;
|
||||
});
|
||||
await closePool();
|
||||
|
||||
const header = ['신청완료', 'D-day', '마감일', '지역', '분야', '주관기관', '소스', '제목', '링크'];
|
||||
const lines = [header.map(csvCell).join(',')];
|
||||
for (const x of rows) {
|
||||
lines.push(
|
||||
[
|
||||
'',
|
||||
x.DLEFT === 9999 ? '상시' : `D-${x.DLEFT}`,
|
||||
x.DLEFT === 9999 ? '상시/예산소진' : ymd(x.APPLY_END),
|
||||
region(x.TITLE),
|
||||
x.CATEGORY || '',
|
||||
x.AGENCY || '',
|
||||
x.SOURCE_CODE,
|
||||
x.TITLE,
|
||||
x.DETAIL_URL || '',
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(new URL('../exports/', import.meta.url), { recursive: true });
|
||||
const out = new URL('../exports/eligible_opportunities.csv', import.meta.url);
|
||||
// 엑셀 한글 깨짐 방지 BOM
|
||||
await writeFile(out, '' + lines.join('\n'), 'utf8');
|
||||
console.log(`내보냄: ${rows.length}건 → government/exports/eligible_opportunities.csv`);
|
||||
70
government/scripts/match.js
Normal file
70
government/scripts/match.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// 앱 후보별로 매칭되는 정부지원사업을 gov_opportunity 에서 조회한다.
|
||||
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/match.js
|
||||
import { withConnection, closePool, oracledb } from '../src/db.js';
|
||||
|
||||
// 앱별 주제 키워드(제목/분야/대상/주관기관 대상으로 LIKE)
|
||||
const APPS = {
|
||||
Tasteby: ['외식', '맛집', '음식', '푸드', '소상공인', '관광', '로컬', '지역특화', '상권', '데이터바우처', 'AI바우처', '빅데이터'],
|
||||
Lyricsy: ['콘텐츠', '한류', '케이팝', 'K-팝', '음악', '뮤직', '어학', '한국어', '에듀테크', '웹툰', '문화산업', '글로벌진출'],
|
||||
ParentsStory: ['시니어', '고령', '노인', '실버', '돌봄', '복지', '사회적가치', '사회문제', '헬스케어', '웰니스', '온디바이스', '기록'],
|
||||
};
|
||||
|
||||
async function queryApp(conn, keywords) {
|
||||
// 키워드 OR 조건 (title/category/target/agency)
|
||||
const binds = {};
|
||||
const ors = keywords.map((kw, i) => {
|
||||
binds[`k${i}`] = `%${kw}%`;
|
||||
return `(title LIKE :k${i} OR category LIKE :k${i} OR target LIKE :k${i} OR agency LIKE :k${i})`;
|
||||
});
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT title, category, agency, source_code, detail_url,
|
||||
apply_end,
|
||||
CASE WHEN apply_end IS NULL OR apply_end >= TRUNC(SYSDATE) THEN 1 ELSE 0 END AS is_open,
|
||||
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
|
||||
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
|
||||
FROM gov_opportunity
|
||||
WHERE ${ors.join(' OR ')}
|
||||
)
|
||||
WHERE rn = 1
|
||||
ORDER BY is_open DESC, dleft ASC
|
||||
FETCH FIRST 14 ROWS ONLY`;
|
||||
const r = await conn.execute(sql, binds, { outFormat: oracledb.OUT_FORMAT_OBJECT });
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
async function countOpen(conn, keywords) {
|
||||
const binds = {};
|
||||
const ors = keywords.map((kw, i) => {
|
||||
binds[`k${i}`] = `%${kw}%`;
|
||||
return `(title LIKE :k${i} OR category LIKE :k${i} OR target LIKE :k${i} OR agency LIKE :k${i})`;
|
||||
});
|
||||
const r = await conn.execute(
|
||||
`SELECT COUNT(DISTINCT title) total,
|
||||
COUNT(DISTINCT CASE WHEN apply_end IS NULL OR apply_end >= TRUNC(SYSDATE) THEN title END) open_now
|
||||
FROM gov_opportunity WHERE ${ors.join(' OR ')}`,
|
||||
binds,
|
||||
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||
);
|
||||
return r.rows[0];
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '상시/별도';
|
||||
return new Date(d).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
await withConnection(async (conn) => {
|
||||
for (const [app, keywords] of Object.entries(APPS)) {
|
||||
const cnt = await countOpen(conn, keywords);
|
||||
console.log(`\n##### ${app} — 주제 매칭 총 ${cnt.TOTAL}건 (현재 신청가능 ${cnt.OPEN_NOW}건) #####`);
|
||||
const rows = await queryApp(conn, keywords);
|
||||
for (const r of rows) {
|
||||
const tag = r.IS_OPEN === 1 ? (r.DLEFT === 9999 ? '[상시]' : `[D-${r.DLEFT}]`) : '[마감]';
|
||||
console.log(
|
||||
`${tag}\t${r.SOURCE_CODE}\t${r.CATEGORY || '-'}\t${(r.AGENCY || '-').slice(0, 16)}\t${r.TITLE.slice(0, 50)}\t${r.DETAIL_URL || '-'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
await closePool();
|
||||
Reference in New Issue
Block a user