Files
sundol/government/scripts/generate_checklist.js
joungmin 7bc0464afc gov-scraper: 본문 지원자격 지역제한 필터 추가
- generate_checklist.js: 본문에 '비서울 지역 + 거주/소재/관내/재학' 정방향 패턴이면 제외
- 서울/수도권/전국 포함 시 유지(서울 거주자 가능), 서울 기관 사업도 유지
- 역방향(주소+지역)은 기관 연락처 푸터 오탐이라 미검사
- apply-checklist.md: 지역(제목+주관+본문)+연령+성별/대상 → 109건

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:13:29 +00:00

186 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 예비창업자 자격 + 현재 열린 공고를 마감일 그룹별 체크리스트(Markdown)로 생성.
// git 추적 대상인 docs/apply-checklist.md 에 저장 → 신청 완료 시 [x] 체크하며 진행.
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/generate_checklist.js
import { writeFile } 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_end, body_text,
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 region(title) {
const m = /^\[([^\]]{1,8})\]/.exec(title);
return m ? `\`${m[1]}\` ` : '';
}
// 거주지(서울) 기준 지원 가능 판정.
// 타 지역(광역시/도/주요 시) 키워드가 제목 접두 또는 주관기관에 있으면 제거.
// '서울'이 명시돼 있으면 유지. 둘 다 없으면 전국 사업으로 보고 유지.
// 제목 접두 + 주관기관 에서 검사하는 전체 타지역 키워드(축약어 '대전'·'광주' 등 오탐 위험 토큰 포함 가능)
const NON_SEOUL = [
'부산', '대구', '인천', '광주', '대전', '울산', '세종',
'경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주',
'충청', '전라', '경상', '호남', '영남',
'수원', '성남', '용인', '화성', '부천', '안양', '안산', '시흥', '군포', '고양',
'김포', '평택', '파주', '의정부', '남양주', '청주', '충주', '제천', '천안', '아산',
'전주', '군산', '익산', '여수', '순천', '광양', '목포', '나주', '포항', '경주',
'구미', '칠곡', '문경', '안동', '창원', '김해', '진주', '양산', '밀양', '거제',
'춘천', '원주', '강릉', '홍천', '속초', '강화', '서귀포', '달구벌',
];
// 제목 본문까지 검사해도 안전한(흔한 단어에 잘 안 섞이는) 도/권역 키워드.
// 제외: 대전(대전환), 광주(관광주간), 경기(경기침체), 경상(경상비), 세종(세종대왕) 등 오탐 위험.
const NON_SEOUL_BODY = [
'부산', '대구', '인천', '울산', '강원', '충북', '충남', '전북', '전남',
'경북', '경남', '제주', '호남', '영남', '홍천', '청주', '천안', '전주',
'여수', '순천', '포항', '창원', '김해', '춘천', '원주', '강릉',
];
// 신청자 나이(MY_AGE) 기준 지원 가능 판정.
// 본문에서 연령 상한을 추출해 최대 허용연령이 MY_AGE 미만이면 제외.
// '만 40세 이상/중장년/시니어/연령무관' 신호가 있으면(중장년 트랙 포함) 유지.
const MY_AGE = 46;
function ageAllows(x) {
const text = `${x.TITLE}\n${x.BODY_TEXT || ''}`;
// 46세 이상 허용 신호 → 유지 (만 40~59세 이상, 중장년/장년/시니어, 연령 무관/제한없음, 전 연령, 누구나)
if (/만\s*[45]\d\s*세\s*이상|중장년|장년층|시니어|연령\s*무관|연령\s*제한\s*없|나이\s*제한\s*없|제한\s*없음|전\s*연령|누구나/.test(text)) {
return true;
}
// 제목에 '청년/대학생' = 청년 한정 신호 → 제외 (46세 초과)
if (/청년|대학생/.test(x.TITLE)) return false;
// 연령 상한 추출
const uppers = [];
let m;
const reUpper = /(\d{2})\s*세\s*(?:이하|미만|까지)/g;
while ((m = reUpper.exec(text))) uppers.push(Number(m[1]));
const reRange = /(\d{2})\s*세\s*[~\-]\s*만?\s*(\d{2})\s*세/g;
while ((m = reRange.exec(text))) uppers.push(Number(m[2]));
if (uppers.length === 0) return true; // 연령 상한 언급 없음 → 유지
return Math.max(...uppers) >= MY_AGE; // 최대 허용연령이 MY_AGE 이상이어야 지원 가능
}
// 성별/특수대상 기준 지원 가능 판정.
const MY_GENDER = '남성'; // 남성 → 여성 전용 제외
const MY_TARGETS = []; // 본인이 해당하는 특수대상(있으면 그 전용 사업 유지). 예: ['장애인']
const TARGET_PATTERNS = {
장애인: /장애인/,
보훈: /보훈|국가유공자|제대군인/,
다문화: /다문화|결혼이민/,
북한이탈: /북한이탈|탈북|새터민/,
};
function targetGroupOk(x) {
const sig = `${x.TITLE} ${x.AGENCY || ''}`; // 제목 + 주관기관(가점성 본문 언급은 검사 안 함)
if (MY_GENDER === '남성' && /여성/.test(sig)) return false; // 여성 전용 제외
if (MY_GENDER === '여성' && /남성\s*(전용|기업)/.test(sig)) return false;
for (const [name, re] of Object.entries(TARGET_PATTERNS)) {
if (re.test(sig) && !MY_TARGETS.includes(name)) return false; // 특수대상 전용 제외
}
return true;
}
function applicableInSeoul(x) {
const prefix = (/^\[([^\]]{1,8})\]/.exec(x.TITLE) || [])[1] || '';
const strong = `${prefix} ${x.AGENCY || ''}`; // 접두 + 주관기관
const full = `${x.TITLE} ${x.AGENCY || ''}`;
if (full.includes('서울')) return true; // 서울 명시(서울·경기 등 포함) → 유지
if (NON_SEOUL.some((t) => strong.includes(t))) return false; // 접두/주관기관에 타지역 → 제거
if (NON_SEOUL_BODY.some((t) => x.TITLE.includes(t))) return false; // 제목 본문 도/권역 → 제거
return true; // 지역 신호 없음 → 전국 사업으로 유지
}
// 본문 지원자격에 '비서울 지역 + 거주/소재/재학/관내' 가 있으면 지역 제한으로 제외.
// 단 '수도권/서울/전국' 거주·소재면 서울 거주자도 가능 → 유지.
const RES_KW = '거주|소재|관내|재학|주소|소재지|사업장|위치';
const SEOUL_OK_RE = new RegExp(
`(수도권|서울|전국)[가-힣A-Za-z0-9\\s(),·~/]{0,18}(${RES_KW})|(${RES_KW})[가-힣A-Za-z0-9\\s(),·~/]{0,18}(수도권|서울|전국)`
);
function regionRestrictedInBody(x) {
const body = x.BODY_TEXT || '';
if (!body) return false;
// 서울 기관/사업이면(제목·주관기관에 서울/Seoul) 본문에 타지역 언급돼도 유지
if (/서울|seoul/i.test(`${x.TITLE} ${x.AGENCY || ''}`)) return false;
if (SEOUL_OK_RE.test(body)) return false; // 수도권/서울/전국 포함 → 제한 아님
// 정방향만 검사('지역 + 거주/소재/관내'). 역방향('주소 + 지역')은 기관 연락처 푸터 오탐이라 제외.
for (const t of NON_SEOUL) {
if (new RegExp(`${t}[가-힣A-Za-z0-9\\s(),·~/]{0,8}(${RES_KW})`).test(body)) return true;
}
return false;
}
function ymd(d) {
return d ? new Date(d).toISOString().slice(0, 10) : '';
}
function line(x) {
const dtag = x.DLEFT === 9999 ? '상시' : `D-${x.DLEFT}`;
const deadline = x.DLEFT === 9999 ? '상시/예산소진' : ymd(x.APPLY_END);
const cat = x.CATEGORY ? `[${x.CATEGORY}] ` : '';
const title = x.TITLE.replace(/\s+/g, ' ').trim();
return `- [ ] **${dtag}** (${deadline}) ${region(x.TITLE)}${cat}[${title}](${x.DETAIL_URL}) — ${x.AGENCY || ''}`;
}
const allRows = await withConnection(async (conn) => {
const r = await conn.execute(
SQL,
{ k1: '예비창업', k2: '예비 창업' },
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
);
return r.rows;
});
await closePool();
// 서울 거주 기준: 타 지역 한정 공고 제외 (제목/주관기관 + 본문 지원자격)
const afterTitleRegion = allRows.filter(applicableInSeoul);
const seoulRows = afterTitleRegion.filter((x) => !regionRestrictedInBody(x));
const removedRegion = allRows.length - seoulRows.length;
const removedByBody = afterTitleRegion.length - seoulRows.length;
console.log(` (그중 본문 지원자격 지역제한: ${removedByBody}건)`);
// 연령(46세) 기준: 청년 한정 등 연령 초과 공고 제외
const ageRows = seoulRows.filter(ageAllows);
const removedAge = seoulRows.length - ageRows.length;
// 성별/특수대상 기준: 여성 전용·장애인/보훈 등 전용 공고 제외
const rows = ageRows.filter(targetGroupOk);
const removedTarget = ageRows.length - rows.length;
console.log(
`필터: 전체 ${allRows.length}건 → 지역(서울) -${removedRegion} → 연령(${MY_AGE}세) -${removedAge} → 성별/대상 -${removedTarget} → 최종 ${rows.length}`
);
const buckets = {
'🔴 이번 주 마감 (D-0 ~ D-7)': (d) => d <= 7,
'🟡 2주 내 (D-8 ~ D-14)': (d) => d >= 8 && d <= 14,
'🟢 여유 (D-15 이상)': (d) => d >= 15 && d !== 9999,
'⏳ 상시 접수 (마감 압박 없음)': (d) => d === 9999,
};
const today = new Date().toISOString().slice(0, 10);
const out = [];
out.push('# 정부지원사업 신청 체크리스트');
out.push('');
out.push(`> 생성일: ${today} · 대상: **예비창업자 + 현재 신청 가능 + 서울 거주 + 만 ${MY_AGE}세 + 남성**(타 지역·청년·여성/장애인/보훈 등 전용 제외) 공고 (총 ${rows.length}건)`);
out.push('> 신청을 마치면 `[ ]` → `[x]` 로 체크하세요. 갱신: `LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/generate_checklist.js`');
out.push('> ⚠️ 마감 "시각"과 정확한 자격요건은 각 공고 원문에서 반드시 확인하세요.');
out.push('');
for (const [title, pred] of Object.entries(buckets)) {
const group = rows.filter((x) => pred(x.DLEFT));
if (group.length === 0) continue;
out.push(`## ${title}${group.length}`);
out.push('');
for (const x of group) out.push(line(x));
out.push('');
}
const path = new URL('../docs/apply-checklist.md', import.meta.url);
await writeFile(path, out.join('\n'), 'utf8');
console.log(`생성: ${rows.length}건 → government/docs/apply-checklist.md`);