From c037c57f3c047d2cfcb915bc9e79dafc0e91b4ee Mon Sep 17 00:00:00 2001 From: jhjh0512 Date: Tue, 29 Sep 2026 21:15:11 +0900 Subject: [PATCH] feat(funding): collect SEMAS policy funds from ols.semas.or.kr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the SEMAS policy fund collector to funding:sync, the crawled policy loan source of ADR 0006. - Read the policy fund overview table, the home page "정책자금 신청하기" status cards, the notice list (only to find notices) and notice details. Check robots.txt first, wait at least 1 second between requests, name the service and FUNDING_SYNC_CONTACT in the User-Agent, and stop on 403, 429 or a CAPTCHA page. - Make one LOAN product per fund row, identified by loan section and fund name. Mark a fund OPEN only when the home page shows it as "접수중" with no date contradiction. Keep variable or ambiguous rates unconfirmed, so no fund reaches repayment math. - Keep the SHA-256 of each crawled page on the evidence, and let validation accept web page checksums on automated products only. - Add ADR 0007 for the status source and fund-level identity, and record the first funding:sync run in ADR 0006. Co-Authored-By: Claude Opus 5.5 --- app/.env.example | 2 + app/scripts/sync-funding.ts | 8 +- app/src/features/funding/funding.test.ts | 17 + .../funding/sources/semas-ols.test.ts | 947 ++++++++++++++++++ app/src/features/funding/sources/semas-ols.ts | 944 +++++++++++++++++ app/src/features/funding/validation.ts | 8 +- docs/ARCHITECTURE.md | 2 +- docs/DEVELOPMENT.md | 4 +- .../0006-funding-discovery-automation.md | 23 + ...0007-semas-ols-status-and-fund-identity.md | 68 ++ docs/domains/funding.md | 38 +- 11 files changed, 2053 insertions(+), 8 deletions(-) create mode 100644 app/src/features/funding/sources/semas-ols.test.ts create mode 100644 app/src/features/funding/sources/semas-ols.ts create mode 100644 docs/decisions/0007-semas-ols-status-and-fund-identity.md diff --git a/app/.env.example b/app/.env.example index 5c8f716..d42a402 100644 --- a/app/.env.example +++ b/app/.env.example @@ -11,6 +11,8 @@ SEMAS_SERVICE_KEY= REB_API_KEY= # 자금 동기화(funding:sync)의 기업마당 공고 탐색에 필요하다. 기업마당 지원사업정보 API 인증키를 넣는다. BIZINFO_API_KEY= +# 자금 동기화(funding:sync)의 소진공 정책자금 사이트 수집에 필요하다. 요청의 User-Agent에 밝힐 운영자 연락처(메일 주소나 웹 주소, ASCII)를 넣는다. +FUNDING_SYNC_CONTACT= # TEST_DATABASE_URL은 이 파일이 아니라 app/.env.test.local에 둔다. 하네스와 Vitest는 환경변수나 그 파일에서만 읽는다. # TEST_DATABASE_URL=postgresql://trendbench:change-this-local-password@127.0.0.1:5433/trendbench_mvp_test diff --git a/app/scripts/sync-funding.ts b/app/scripts/sync-funding.ts index 1406b46..bdc2c73 100644 --- a/app/scripts/sync-funding.ts +++ b/app/scripts/sync-funding.ts @@ -5,6 +5,7 @@ import { PrismaClient } from '../src/generated/prisma/client.ts'; import { secretMasker } from '../src/features/market/seoul-api.ts'; import { todayInKst } from '../src/features/funding/dates.ts'; import { createBizinfoAdapter } from '../src/features/funding/sources/bizinfo.ts'; +import { createSemasOlsAdapter } from '../src/features/funding/sources/semas-ols.ts'; import { runFundingSync, type FundingSourceAdapter } from '../src/features/funding/sync.ts'; import { FundingCatalogError } from '../src/features/funding/validation.ts'; @@ -12,8 +13,9 @@ const DEFAULT_CATALOG = fileURLToPath(new URL('../catalog/funding/catalog.json', const DEFAULT_OUTPUT_DIR = fileURLToPath(new URL('../../data/funding-sync/', import.meta.url)); // funding:sync가 공고를 모으는 출처별 수집기. ADR 0006의 출처 확인(robots.txt·약관)을 거친 출처만 등록한다. -// 키가 없어도 등록한다. 그러면 그 출처는 실패로 기록되고, 직전 릴리스의 상품이 그대로 남는다. +// 키·연락처가 없어도 등록한다. 그러면 그 출처는 실패로 기록되고, 직전 릴리스의 상품이 그대로 남는다. const SOURCE_ADAPTERS: readonly FundingSourceAdapter[] = [ + createSemasOlsAdapter({ contact: process.env.FUNDING_SYNC_CONTACT }), createBizinfoAdapter({ apiKey: process.env.BIZINFO_API_KEY }), ]; @@ -30,6 +32,9 @@ const USAGE = `공식 출처에서 자금 공고를 모아 사람이 관리하 환경변수: DATABASE_URL PostgreSQL 연결 문자열 BIZINFO_API_KEY 기업마당 지원사업정보 API 인증키. 없으면 기업마당 출처는 실패로 기록됩니다 + FUNDING_SYNC_CONTACT + 소진공 정책자금 사이트 요청의 User-Agent에 밝힐 운영자 연락처(메일 주소나 웹 주소, ASCII). + 없으면 소진공 출처는 실패로 기록됩니다 옵션: --catalog <경로> 사람이 관리하는 카탈로그 JSON (기본값: app/catalog/funding/catalog.json) @@ -39,6 +44,7 @@ const USAGE = `공식 출처에서 자금 공고를 모아 사람이 관리하 동작: - 웹 요청 중에는 실행하지 않습니다. 운영자나 정기 실행이 하루 한 번 실행합니다. + - 소진공 정책자금 사이트는 요청 사이 1초 이상 쉬며 읽고, 403·429·자동 입력 방지 화면이나 robots.txt 변경을 만나면 그 출처를 멈춥니다. - 출처가 실패하면(장애, 빈 응답, 직전 성공 대비 절반 미만, 검증 실패) 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 두고 마지막 성공일도 그대로 둡니다. - 자동 상품은 출처의 마지막 성공 동기화가 3일을 넘기면 현재 후보에서 빠집니다. - 사람이 기록한 상품이 같은 공고를 가리키면 사람 기록이 우선합니다. diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index e772356..9d57bde 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -380,6 +380,23 @@ describe('automated products in the catalog (ADR 0006)', () => { expect(stale.reviewOverdue).toEqual(['test-loan@1.0.0']); }); + it('lets an automated product keep the checksum of a crawled web page, but not a manual product', () => { + const crawled = (BASE_PRODUCT.evidence as Record[]).map((entry) => ({ + ...entry, + checksum: 'a'.repeat(64), + })); + const automated = validate( + catalog([product({ reviewer: AUTO_REVIEWER, sourceRef, nextReviewAt: null, evidence: crawled })], { + automation: automation(), + }), + ); + expect(issueCodes(automated)).not.toContain('EVIDENCE_CHECKSUM_NOT_APPLICABLE'); + expect(automated.ok).toBe(true); + + const manual = validate(catalog([product({ evidence: crawled })])); + expect(issueCodes(manual)).toContain('EVIDENCE_CHECKSUM_NOT_APPLICABLE'); + }); + it('rejects an automated reviewer without a source reference', () => { const validation = validate(catalog([product({ reviewer: AUTO_REVIEWER, nextReviewAt: null })])); expect(issueCodes(validation)).toContain('AUTO_REVIEWER_WITHOUT_SOURCE'); diff --git a/app/src/features/funding/sources/semas-ols.test.ts b/app/src/features/funding/sources/semas-ols.test.ts new file mode 100644 index 0000000..2ebea3b --- /dev/null +++ b/app/src/features/funding/sources/semas-ols.test.ts @@ -0,0 +1,947 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { evaluateProduct } from '../eligibility.ts'; +import type { FundingSourceCollection } from '../sync.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from '../types.ts'; +import { validateFundingCatalog } from '../validation.ts'; +import { + SEMAS_OLS_MIN_REQUEST_INTERVAL_MS, + SEMAS_OLS_URLS, + createSemasOlsAdapter, + isSemasFundNotice, + mapSemasFunds, + parseSemasHomeCards, + parseSemasNoticeDetail, + parseSemasNoticePage, + parseSemasOverview, + parseSemasPeriod, + parseSemasSingleLimit, + selectSemasFundNotices, + semasFundExternalId, + semasFundProductKey, + semasNoticeUrl, + semasUserAgent, + type SemasFetch, + type SemasNotice, + type SemasFundNotice, +} from './semas-ols.ts'; + +// 사이트 원문이 아니라 파서가 기대는 구조만 남긴 HTML 조각이다(ADR 0006 Validation). +const AS_OF = '2026-09-29'; +const CONTACT = 'ops@example.com'; + +function fundRow(name: string, requirement: string, term: string, limit: string, rate: string): string { + return `${name}${requirement}${term}${limit}${rate}`; +} + +function fundTable( + heading: string, + rows: readonly string[], + columns = ['자금명', '신청요건', '대출기간', '대출한도', '대출금리'], +): string { + const [first, second, ...rest] = columns; + return `

${heading}

+
+ + +${rest.map((column) => ``).join('')} + +${rows.join('\n')} +
세부 지원요건 목록
${first}${second}
* 세부 신청요건은 반드시 공지사항을 참고하시기 바랍니다.
${column}
`; +} + +const QUALIFICATION = `

공통 지원자격

+
    +
  • [소상공인 보호 및 지원에 관한 법률]상 소상공인: 상시근로자 5인 미만 업체
    (제조업, 건설업, 운수업, 광업: 상시근로자 10인 미만 업체)
  • +
  • 제외업종: 유흥 향락 업종, 전문업종, 금융업, 보험업, 부동산업 등
  • +
`; + +const RATE_NOTES = `

금리 안내사항

+
  • 대출금리 : 기준금리(분기별 변동)+ 가산금리
  • \`26년 3/4 분기 정책자금 기준금리 : 3.85%
`; + +const DIRECT_ROWS = [ + fundRow( + '혁신성장촉진자금', + '(혁신형) 수출, 스마트 공장 도입
(일반형) 백년소상공인', + '(운전) 5년
(비거치 또는 거치 2년 이내)
(시설) 8년', + '(일반형)
운전 1억원,시설 5억원', + '기준금리+0.4%P', + ), + fundRow( + '일시적경영애로자금', + '연매출 1억 4백만원 미만이고 업력 7년 미만이면서 일시적경영애로 사유가 있는 소상공인', + '5년(거치 2년)', + '7천만원', + '기준금리+0.0%P', + ), +]; + +const AGENCY_ROWS = [ + fundRow('일반경영안정자금', '업력무관 소상공인', '5년(거치 2년)', '7천만원', '기준금리+0.6%P'), + fundRow( + '소공인특화자금', + '제조업을 영위하는 상시근로자수 10인 미만의 소공인', + '(운전) 5년(거치 2년)
(시설) 8년(거치 3년)', + '(운전) 1억원
(시설) 5억원', + '기준금리+0.6%P', + ), + fundRow('장애인기업지원자금', '장애인기업확인서를 소지한 장애 소상공인', '7년(거치2년)', '1억원', '고정금리(2.0%P)'), + fundRow( + '대환대출', + '중∙저신용 소상공인', + '10년(비거치 또는 거치2년)', + '5천만원', + '고정금리(4.5%P)', + ), +]; + +function overviewHtml(options: { qualification?: string; direct?: string; agency?: string } = {}): string { + return `
+

정책자금한눈에보기

+${options.qualification ?? QUALIFICATION} +${RATE_NOTES} +${options.direct ?? fundTable('2026년 정책자금 직접대출 세부 지원요건', DIRECT_ROWS)} +${options.agency ?? fundTable('2026년 정책자금 대리대출 세부 지원요건', AGENCY_ROWS)} +
  • 각 자금별 접수 순서대로 처리, 예산 소진 시 마감
+
`; +} + +function card(kind: 'jd' | 'dd' | 'repay', name: string, period: string, status = '접수중'): string { + const label = kind === 'jd' ? '직접대출' : kind === 'dd' ? '대리대출' : '상환연장'; + const className = kind === 'repay' ? 'jd_loan' : `${kind}_loan`; + return `
+

${label}

${status}

+

${name}

${period}

+
`; +} + +const HOME_CARDS = [ + card('repay', '정책자금 상환연장', '상시접수'), + card('dd', '소상공인 대환대출', '접수기간 : 2026-01-05 ~ 자금소진시까지'), + card('dd', '소공인특화자금', '접수기간 : 2026-01-05 ~ 자금소진시까지'), + card('jd', '일시적경영애로자금(홈플러스 피해 소상공인)', '접수기간 : 2026-07-15 ~ 자금소진시까지'), + card('jd', '혁신성장촉진자금(사회연대경제조직)', '접수기간 : 2026-01-12 ~ 자금소진시까지'), +]; + +function homeHtml(cards: readonly string[] = HOME_CARDS): string { + return `
+

정책자금 신청하기 소상공인시장진흥공단에서 지원하는 정책자금을 확인하세요

+
+${cards.join('\n')} +
+
`; +} + +function listItem(seq: number, section: string, title: string, overrides: Record = {}) { + return { + bltwtrTitNm: title, + rnum: 1, + bltwtrClcd: '대출정보', + bltwtrSeq: seq, + bbsTypeCd: '01', + loanSeCdNm: section, + bbsFxnYn: 'N', + frstRegDt: '2026-09-11', + ...overrides, + }; +} + +const LIST_ITEMS = [ + listItem(404, '대리대출', '2026년 4분기 일반경영안정자금(대리대출) 접수 안내'), + listItem(386, '직접대출', '2026년 일시적경영애로자금(홈플러스 피해 소상공인) 직접대출 신청안내(수정)'), + listItem(338, '직접대출', '신용취약소상공인자금 금리인하제도 신청안내'), + listItem(326, '대리대출', '2026년 소공인특화자금(일반)(대리대출) 접수 안내'), + listItem(325, '대리대출', '2026년 소상공인 대환대출(대리대출) 접수 안내'), + listItem(400, '직접대출', '소상공인 정책자금 직접대출 신청서류 웹폼 전환 안내', { bltwtrClcd: '서비스안내' }), + listItem(402, '직접대출', '2026년 9월 일시적경영애로자금(직접대출) 신청안내'), + listItem(393, '직접대출', "[필독] 혁신성장촉진자금 접수방식 변경 안내 ('26.8월~)"), + listItem(396, '직접대출', '2026년 3분기 혁신성장촉진자금 신청안내(수정)'), + listItem(380, '대리대출', '2026년 3분기 일반경영안정자금(대리대출) 접수 안내'), + listItem(354, '대리대출', '2026년 2분기 일반경영안정자금(대리대출) 접수 안내'), +]; + +function listPage(items: readonly unknown[], pageNo: number, pageSize = 10, totalCount = items.length): string { + const slice = items.slice((pageNo - 1) * pageSize, pageNo * pageSize); + return JSON.stringify({ + result: slice, + pagination: { vTotalPage: Math.ceil(totalCount / pageSize), totalCount, vPageNo: pageNo, pageSize }, + }); +} + +function detailHtml(title: string, lines: readonly string[]): string { + return `

${title}

+
  • 구분 :  대출정보
+ +
${lines.join('
')}
+
`; +} + +const DETAILS: Record = { + 404: detailHtml('2026년 4분기 일반경영안정자금(대리대출) 접수 안내', [ + '□ 신청대상 : 업력 무관 소상공인', + '', + '□ 접수기간 : 2026. 10. 6.(화) 10:00 ~ 예산소진 시까지', + ' * 예산 소진시 조기마감', + ]), + 402: detailHtml('2026년 9월 일시적경영애로자금(직접대출) 신청안내', [ + '일시적경영애로자금(직접대출)의 지원대상은 연매출 1억 4백만원 미만이고 업력 7년 미만이면서 일시적경영애로 사유가 있는 소상공인입니다.', + '□ 접수기간 : 2026년 9월 7일 (월) 10:00 ~ 예산 소진 시까지', + ]), + 396: detailHtml('2026년 3분기 혁신성장촉진자금 신청안내(수정)', ['□ 신청기간 : ‘26. 8. 10.(월), 10:00 ~ 18:00']), + 326: detailHtml('2026년 소공인특화자금(일반)(대리대출) 접수 안내', [ + '□ 신청대상 : 제조업을 영위하는 상시근로자수 10인 미만 소공인', + '□ 접수기간 : 2026. 1. 5.(월) 10:00 ~ 예산소진 시까지', + ]), + 325: detailHtml('2026년 소상공인 대환대출(대리대출) 접수 안내', [ + '□ 신청대상 : NCB개인신용평점 919점 이하 중·저신용 소상공인', + '□ 접수기간 : 2026. 1. 5.(월) 10:00 ~ 예산소진 시까지', + ]), +}; + +type Reply = { status: number; body: string } | Error; +type Call = { url: string; method: string; body: string | undefined; headers: Record }; +type Routes = { + robots: () => Reply; + overview: () => Reply; + home: () => Reply; + list: (pageNo: number) => Reply; + detail: (seq: number) => Reply; +}; + +const DEFAULT_ROUTES: Routes = { + robots: () => ({ status: 404, body: 'Not Found' }), + overview: () => ({ status: 200, body: overviewHtml() }), + home: () => ({ status: 200, body: homeHtml() }), + list: (pageNo) => ({ status: 201, body: listPage(LIST_ITEMS, pageNo) }), + detail: (seq) => (DETAILS[seq] ? { status: 200, body: DETAILS[seq] } : { status: 404, body: '' }), +}; + +function siteFetch(overrides: Partial = {}): { fetchImpl: SemasFetch; calls: Call[] } { + const routes = { ...DEFAULT_ROUTES, ...overrides }; + const calls: Call[] = []; + const fetchImpl: SemasFetch = async (url, init) => { + calls.push({ url, method: init.method, body: init.body, headers: init.headers }); + const parsed = new URL(url); + const target = `${parsed.origin}${parsed.pathname}`; + let reply: Reply; + if (target === SEMAS_OLS_URLS.robots) reply = routes.robots(); + else if (target === SEMAS_OLS_URLS.overview) reply = routes.overview(); + else if (target === SEMAS_OLS_URLS.home) reply = routes.home(); + else if (target === SEMAS_OLS_URLS.noticeList) { + reply = routes.list(Number(new URLSearchParams(init.body ?? '').get('pageNo'))); + } else if (target === SEMAS_OLS_URLS.noticeDetail) { + reply = routes.detail(Number(parsed.searchParams.get('bltwtrSeq'))); + } else reply = { status: 404, body: '' }; + if (reply instanceof Error) throw reply; + const { status, body } = reply; + return { status, text: async () => body }; + }; + return { fetchImpl, calls }; +} + +// options.contact를 undefined로 넘기면 연락처가 없는 경우다. options를 생략해야 기본 연락처를 쓴다. +function collect( + overrides: Partial = {}, + options: { contact: string | undefined } = { contact: CONTACT }, +): { promise: Promise; calls: Call[]; sleeps: number[]; logs: string[] } { + const { contact } = options; + const { fetchImpl, calls } = siteFetch(overrides); + const sleeps: number[] = []; + const logs: string[] = []; + const adapter = createSemasOlsAdapter({ + contact, + fetchImpl, + retryDelayMs: 0, + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + return { promise: adapter.collect({ asOfDate: AS_OF, log: (message) => logs.push(message) }), calls, sleeps, logs }; +} + +const FRESH_SYNC = { + source: 'SEMAS_OLS', + status: 'SUCCEEDED', + attemptedAt: `${AS_OF}T01:00:00.000Z`, + lastSucceededOn: AS_OF, + fetchedCount: 6, + responseChecksum: 'c'.repeat(64), + failureReason: null, +}; + +function catalogWith(products: unknown[], automation: Record = {}) { + return { + catalogKey: 'test-semas-catalog', + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion: `${AS_OF}.1`, + basisDate: AS_OF, + reviewer: 'tester', + notes: [], + automation: { + policyLoanPromotion: false, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [FRESH_SYNC], + ...automation, + }, + products, + }; +} + +function sha256(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +function detailsFor( + rows: ReturnType['rows'], + notices: readonly SemasNotice[], +): Map { + const selected = selectSemasFundNotices(rows, notices); + const details = new Map(); + for (const [key, notice] of selected) { + const html = DETAILS[notice.seq]; + details.set(key, { notice, detail: parseSemasNoticeDetail(html), checksum: sha256(html) }); + } + return details; +} + +function mapDefault(options: { cards?: readonly string[]; asOfDate?: string; overview?: string } = {}) { + const overviewPage = options.overview ?? overviewHtml(); + const homePage = homeHtml(options.cards); + const overview = parseSemasOverview(overviewPage); + const notices = parseSemasNoticePage(listPage(LIST_ITEMS, 1, 100)).notices; + const mapping = mapSemasFunds( + { + overview, + cards: parseSemasHomeCards(homePage), + notices: detailsFor(overview.rows, notices), + pageChecksums: { overview: sha256(overviewPage), home: sha256(homePage) }, + }, + { asOfDate: options.asOfDate ?? AS_OF }, + ); + const byName = new Map(mapping.products.map((product) => [product.name, product])); + return { mapping, byName }; +} + +const POST_REGISTRATION_PROFILE = { + businessStage: 'POST_REGISTRATION', + districtCode: '11140', + industryCode: 'CS100010', + purpose: 'OPERATING_FUNDS', +} as const; + +describe('소진공 접수기간 해석', () => { + it('종료일 없는 접수, 날짜 두 개, 하루 접수를 읽는다', () => { + expect(parseSemasPeriod('2026. 10. 6.(화) 10:00 ~ 예산소진 시까지')).toEqual({ + start: '2026-10-06', + end: null, + openEndedNote: '예산소진 시까지', + }); + expect(parseSemasPeriod('2026년 9월 7일 (월) 10:00 ~ 예산 소진 시까지')).toMatchObject({ + start: '2026-09-07', + end: null, + }); + expect(parseSemasPeriod('‘26년 1월 12일(월) 10:00 ~ 예산 소진 시까지')).toMatchObject({ + start: '2026-01-12', + end: null, + }); + expect(parseSemasPeriod('2026-01-05 ~ 자금소진시까지')).toEqual({ + start: '2026-01-05', + end: null, + openEndedNote: '자금소진시까지', + }); + expect(parseSemasPeriod('‘26. 8. 18.(화) ~ 8. 19.(수), 10:00 ~ 18:00')).toEqual({ + start: '2026-08-18', + end: '2026-08-19', + openEndedNote: null, + }); + expect(parseSemasPeriod('2026-01-05 ~ 2026-10-30')).toMatchObject({ start: '2026-01-05', end: '2026-10-30' }); + expect(parseSemasPeriod('2026. 12. 28.(월) ~ 1. 5.(화)')).toMatchObject({ start: '2026-12-28', end: '2027-01-05' }); + expect(parseSemasPeriod('‘26. 8. 10.(월), 10:00 ~ 18:00')).toEqual({ + start: '2026-08-10', + end: '2026-08-10', + openEndedNote: null, + }); + }); + + it('날짜가 없거나 모호한 표기는 추측하지 않는다', () => { + expect(parseSemasPeriod('상시접수')).toBeNull(); + expect(parseSemasPeriod('예산 소진 시까지')).toBeNull(); + expect(parseSemasPeriod('2026. 2. 30.(월) ~ 예산소진 시까지')).toBeNull(); + expect(parseSemasPeriod('2026. 10. 6.(화) 10:00부터')).toBeNull(); + expect(parseSemasPeriod('8. 18. ~ 8. 19.')).toBeNull(); + expect(parseSemasPeriod('2026. 10. 12. ~ 10. 13. / 2026. 10. 26. ~ 예산 소진 시까지')).toBeNull(); + expect(parseSemasPeriod('2026-10-30 ~ 2026-10-01')).toBeNull(); + }); +}); + +describe('정책자금 한눈에 보기 표', () => { + it('섹션별 자금 행, 공통 지원자격, 기준금리를 읽는다', () => { + const overview = parseSemasOverview(overviewHtml()); + expect(overview.rows.map((row) => [row.section, row.name])).toEqual([ + ['직접대출', '혁신성장촉진자금'], + ['직접대출', '일시적경영애로자금'], + ['대리대출', '일반경영안정자금'], + ['대리대출', '소공인특화자금'], + ['대리대출', '장애인기업지원자금'], + ['대리대출', '대환대출'], + ]); + expect(overview.rows[0]).toMatchObject({ + heading: '2026년 정책자금 직접대출 세부 지원요건', + requirement: '(혁신형) 수출, 스마트 공장 도입 (일반형) 백년소상공인', + term: '(운전) 5년 (비거치 또는 거치 2년 이내) (시설) 8년', + limit: '(일반형) 운전 1억원,시설 5억원', + rate: '기준금리+0.4%P', + }); + expect(overview.rows[5].requirement).toBe('중∙저신용 소상공인'); + expect(overview.baseRate).toBe('`26년 3/4 분기 정책자금 기준금리 : 3.85%'); + expect(overview.excludedIndustries).toBe('제외업종: 유흥 향락 업종, 전문업종, 금융업, 보험업, 부동산업 등'); + expect(overview.qualification[0]).toContain('소상공인 보호 및 지원에 관한 법률'); + }); + + it('필수 항목을 찾지 못하면 실패한다', () => { + expect(() => parseSemasOverview(overviewHtml({ qualification: '' }))).toThrow('공통 지원자격'); + expect(() => + parseSemasOverview( + overviewHtml({ + direct: fundTable('2026년 정책자금 직접대출 세부 지원요건', DIRECT_ROWS, [ + '자금명', + '신청요건', + '대출기간', + '대출한도', + '금리', + ]), + }), + ), + ).toThrow('머리글이 예상과 다릅니다'); + expect(() => + parseSemasOverview(overviewHtml({ direct: fundTable('2026년 정책자금 세부 지원요건', DIRECT_ROWS) })), + ).toThrow('직접대출인지 대리대출인지'); + expect(() => + parseSemasOverview( + overviewHtml({ + direct: fundTable('2026년 정책자금 직접대출 세부 지원요건', [ + '혁신성장촉진자금요건', + ]), + }), + ), + ).toThrow('다섯 칸'); + expect(() => parseSemasOverview(`${QUALIFICATION}`)).toThrow('자금 표를 찾지 못했습니다'); + }); + + it('금액 하나로 적힌 한도만 원 단위로 읽는다', () => { + expect(parseSemasSingleLimit('7천만원')).toBe('70000000'); + expect(parseSemasSingleLimit('1억원')).toBe('100000000'); + expect(parseSemasSingleLimit('5 억원')).toBe('500000000'); + expect(parseSemasSingleLimit('1억 4백만원')).toBe('104000000'); + expect(parseSemasSingleLimit('(운전) 1억원 (시설) 5억원')).toBeNull(); + expect(parseSemasSingleLimit('(일반형) 7천만원 (희망형) 1억원')).toBeNull(); + expect(parseSemasSingleLimit('원')).toBeNull(); + expect(parseSemasSingleLimit('')).toBeNull(); + }); +}); + +describe('홈 화면 접수 표시와 공지', () => { + it('대출 카드만 읽고 상환연장 카드는 뺀다', () => { + expect(parseSemasHomeCards(homeHtml())).toEqual([ + { + section: '대리대출', + name: '소상공인 대환대출', + status: '접수중', + periodText: '접수기간 : 2026-01-05 ~ 자금소진시까지', + }, + { + section: '대리대출', + name: '소공인특화자금', + status: '접수중', + periodText: '접수기간 : 2026-01-05 ~ 자금소진시까지', + }, + { + section: '직접대출', + name: '일시적경영애로자금(홈플러스 피해 소상공인)', + status: '접수중', + periodText: '접수기간 : 2026-07-15 ~ 자금소진시까지', + }, + { + section: '직접대출', + name: '혁신성장촉진자금(사회연대경제조직)', + status: '접수중', + periodText: '접수기간 : 2026-01-12 ~ 자금소진시까지', + }, + ]); + // 접수 중인 자금이 하나도 없어도 영역이 있으면 빈 목록이다. + expect(parseSemasHomeCards(homeHtml([]))).toEqual([]); + }); + + it("'정책자금 신청하기' 영역이 없으면 접수 상태를 확인할 수 없어 실패한다", () => { + expect(() => parseSemasHomeCards('
공지
')).toThrow( + '정책자금 신청하기', + ); + expect(() => + parseSemasHomeCards(homeHtml(['

직접대출

'])), + ).toThrow('자금명이나 접수 상태'); + }); + + it('공지 목록 JSON을 읽고, 형식이 깨진 항목은 세기만 한다', () => { + const page = parseSemasNoticePage( + JSON.stringify({ + result: [ + listItem(404, '대리대출', '2026년 4분기 일반경영안정자금(대리대출) 접수 안내', { bbsTypeCd: 1 }), + { bltwtrSeq: 'x' }, + listItem(300, '직접대출', '<안내> 2026년 공지'), + ], + pagination: { vTotalPage: 8, totalCount: 78 }, + }), + ); + expect(page).toMatchObject({ totalPages: 8, totalCount: 78, malformed: 1 }); + expect(page.notices.map((notice) => [notice.seq, notice.bbsTypeCd, notice.title])).toEqual([ + [404, '01', '2026년 4분기 일반경영안정자금(대리대출) 접수 안내'], + [300, '01', '<안내> 2026년 공지'], + ]); + expect(() => parseSemasNoticePage('오류')).toThrow('JSON이 아닙니다'); + expect(() => parseSemasNoticePage(JSON.stringify({ result: [] }))).toThrow('pagination'); + }); + + it('공지 상세에서 접수기간·신청대상·제외 문장을 읽는다', () => { + expect(parseSemasNoticeDetail(DETAILS[404])).toEqual({ + title: '2026년 4분기 일반경영안정자금(대리대출) 접수 안내', + periodText: '2026. 10. 6.(화) 10:00 ~ 예산소진 시까지', + target: '업력 무관 소상공인', + exclusionLines: [], + }); + expect(parseSemasNoticeDetail(DETAILS[402])).toMatchObject({ + periodText: '2026년 9월 7일 (월) 10:00 ~ 예산 소진 시까지', + target: expect.stringContaining('의 지원대상은 연매출 1억 4백만원 미만'), + }); + expect(parseSemasNoticeDetail(DETAILS[396]).periodText).toBe('‘26. 8. 10.(월), 10:00 ~ 18:00'); + // '지원대상은' 뒤에서 줄이 바뀌면 다음 줄까지 잇는다. + const wrapped = detailHtml('2026년 8월 신용취약소상공인자금 신청안내', [ + '신용취약소상공인자금의 지원대상은', + '신용관리교육을 사전 이수한 업력 90일 이상의 중·저신용(NCB 839점 이하) 소상공인입니다.', + ]); + expect(parseSemasNoticeDetail(wrapped).target).toBe( + '신용취약소상공인자금의 지원대상은 신용관리교육을 사전 이수한 업력 90일 이상의 중·저신용(NCB 839점 이하) 소상공인입니다.', + ); + // 본문 안에 div가 중첩되어도 본문 끝을 제대로 찾는다. + const nested = detailHtml('공지', [ + '□ 접수기간 : 2026. 1. 5.(월) 10:00 ~ 예산소진 시까지', + '
□ 제외대상 : 유흥주점업
', + ]); + expect(parseSemasNoticeDetail(nested).exclusionLines).toEqual(['□ 제외대상 : 유흥주점업']); + expect(() => parseSemasNoticeDetail('세션이 만료되었습니다')).toThrow('제목이나 본문'); + }); + + it('자금의 접수·신청 안내 공지 중 가장 최근 것을 고른다', () => { + const overview = parseSemasOverview(overviewHtml()); + const notices = parseSemasNoticePage(listPage(LIST_ITEMS, 1, 100)).notices; + const selected = selectSemasFundNotices(overview.rows, notices); + expect(Object.fromEntries([...selected].map(([key, notice]) => [key, notice.seq]))).toEqual({ + '직접대출:혁신성장촉진자금': 396, + '직접대출:일시적경영애로자금': 402, + '대리대출:일반경영안정자금': 404, + '대리대출:소공인특화자금': 326, + '대리대출:대환대출': 325, + }); + const row = { section: '직접대출' as const, name: '신용취약소상공인자금' }; + const notice = (title: string, section = '직접대출', category = '대출정보'): SemasNotice => ({ + seq: 1, + bbsTypeCd: '01', + section, + category, + title, + registeredOn: '2026-08-14', + }); + expect(isSemasFundNotice(row, notice('2026년 8월 신용취약소상공인자금 신청안내'))).toBe(true); + expect(isSemasFundNotice(row, notice('신용취약소상공인자금 금리인하제도 신청안내'))).toBe(false); + expect(isSemasFundNotice(row, notice('2026년 신용취약소상공인자금 사전 이수 교육 안내'))).toBe(false); + expect(isSemasFundNotice(row, notice('2026년 8월 신용취약소상공인자금 신청안내', '대리대출'))).toBe(false); + expect(isSemasFundNotice(row, notice('2026년 8월 신용취약소상공인자금 신청안내', '직접대출', '기타'))).toBe(false); + }); +}); + +describe('소진공 자금을 카탈로그 상품으로 바꾸기', () => { + it('접수중 카드와 공지가 맞는 자금은 OPEN이고, 금리·상환 조건을 추측하지 않는다', () => { + const { byName } = mapDefault(); + const product = byName.get('소공인특화자금(대리대출)'); + expect(product).toMatchObject({ + productKey: semasFundProductKey({ section: '대리대출', name: '소공인특화자금' }), + organization: '소상공인시장진흥공단', + supportType: 'LOAN', + eligibleBusinessStages: ['POST_REGISTRATION'], + region: { scope: 'NATIONWIDE', districtCodes: [], note: null }, + purpose: { included: [], excluded: [] }, + industryConditions: { scope: 'UNKNOWN', included: [], excluded: [] }, + applicationPeriod: { start: '2026-01-05', end: null, note: '2026-01-05 ~ 자금소진시까지' }, + observedApplicationStatus: 'OPEN', + observedAt: AS_OF, + reviewedAt: AS_OF, + nextReviewAt: null, + officialUrl: 'https://ols.semas.or.kr/ols/man/SMAN052M/page.do?bltwtrSeq=326&bbsTypeCd=01', + sourceDocumentRetrieved: false, + sourceChecksum: null, + publicLimit: null, + interestCondition: '대출금리 기준금리+0.6%P (`26년 3/4 분기 정책자금 기준금리 : 3.85%)', + interestRateConfirmed: false, + interestRatePercent: null, + repaymentCondition: '대출기간 (운전) 5년(거치 2년) (시설) 8년(거치 3년)', + repaymentMethod: 'UNKNOWN', + repaymentTermMonths: null, + repaymentGraceMonths: null, + sourceRef: { source: 'SEMAS_OLS', externalId: '대리대출:소공인특화자금' }, + }); + expect(product?.unsupportedCalculationReasons[0]).toContain('기준금리에 연동'); + expect(product?.additionalChecks[0]).toBe('신청대상(공지): 제조업을 영위하는 상시근로자수 10인 미만 소공인'); + // 근거마다 그 근거를 읽은 페이지 응답의 checksum을 남긴다(ADR 0006 2절). + const overviewChecksum = sha256(overviewHtml()); + const homeChecksum = sha256(homeHtml()); + expect(product?.evidence.map((entry) => [entry.id, entry.subject, entry.retrievalMethod, entry.checksum])).toEqual([ + ['semas-identity', 'IDENTITY', 'OFFICIAL_WEB_PAGE', overviewChecksum], + ['semas-financial', 'FINANCIAL_CONDITION', 'OFFICIAL_WEB_PAGE', overviewChecksum], + ['semas-stage', 'BUSINESS_STAGE', 'OFFICIAL_WEB_PAGE', overviewChecksum], + ['semas-region', 'REGION', 'OFFICIAL_WEB_PAGE', overviewChecksum], + ['semas-eligibility', 'ELIGIBILITY_CONDITION', 'OFFICIAL_WEB_PAGE', overviewChecksum], + ['semas-period-home', 'APPLICATION_PERIOD', 'OFFICIAL_WEB_PAGE', homeChecksum], + ['semas-period-notice', 'APPLICATION_PERIOD', 'OFFICIAL_WEB_PAGE', sha256(DETAILS[326])], + ]); + // '소상공인 대환대출' 카드는 표의 '대환대출'과 같은 자금이다. + expect(byName.get('대환대출(대리대출)')).toMatchObject({ + observedApplicationStatus: 'OPEN', + publicLimit: '50000000', + }); + }); + + it('만든 상품은 카탈로그 검사를 통과하고, 예비 창업자에게는 사업자등록 이후 후보다', () => { + const { mapping } = mapDefault(); + const versions = mapping.products.map((product) => ({ ...product, version: '1.0.0', reviewer: AUTO_REVIEWER })); + const validation = validateFundingCatalog(catalogWith(versions), { asOfDate: AS_OF }); + expect(validation.issues).toEqual([]); + const catalog = validation.catalog; + if (catalog === null) throw new Error('카탈로그 검증에 실패했습니다.'); + const product = catalog.products.find((entry) => entry.name === '소공인특화자금(대리대출)'); + if (product === undefined) throw new Error('상품이 없습니다.'); + const preFounder = evaluateProduct( + product, + { ...POST_REGISTRATION_PROFILE, businessStage: 'PRE_REGISTRATION' }, + { asOfDate: AS_OF, automation: catalog.automation }, + ); + expect(preFounder.candidateStatus).toBe('POST_REGISTRATION'); + expect(preFounder.branch).toBe('POLICY_FUND'); + expect(preFounder.automation).toMatchObject({ source: 'SEMAS_OLS', lastSucceededOn: AS_OF }); + }); + + it('정책자금 자동 승격이 꺼져 있으면 추가 확인이고, 켜면 접수 중인 자금만 현재 후보가 된다', () => { + const { mapping } = mapDefault(); + const versions = mapping.products.map((product) => ({ ...product, version: '1.0.0', reviewer: AUTO_REVIEWER })); + for (const promotion of [false, true]) { + const validation = validateFundingCatalog( + catalogWith(versions, { policyLoanPromotion: promotion, repaymentPromotion: promotion }), + { asOfDate: AS_OF }, + ); + const catalog = validation.catalog; + if (catalog === null) throw new Error('카탈로그 검증에 실패했습니다.'); + const statuses = Object.fromEntries( + catalog.products.map((product) => { + const evaluation = evaluateProduct(product, POST_REGISTRATION_PROFILE, { + asOfDate: AS_OF, + automation: catalog.automation, + }); + // 변동·모호한 금리와 상환방식이 없어 스위치를 켜도 상환 계산 대상이 아니다. + expect(evaluation.repayment.supported).toBe(false); + return [product.name, evaluation.candidateStatus]; + }), + ); + expect(statuses).toEqual({ + // 최근 공지의 하루 접수(8. 10.)가 끝났다. 세부 유형 접수는 확인 항목에 남는다. + '혁신성장촉진자금(직접대출)': 'CLOSED', + '일시적경영애로자금(직접대출)': 'NEEDS_CONFIRMATION', + '일반경영안정자금(대리대출)': 'NEEDS_CONFIRMATION', + '소공인특화자금(대리대출)': promotion ? 'CURRENT_CANDIDATE' : 'NEEDS_CONFIRMATION', + '장애인기업지원자금(대리대출)': 'NEEDS_CONFIRMATION', + '대환대출(대리대출)': promotion ? 'CURRENT_CANDIDATE' : 'NEEDS_CONFIRMATION', + }); + } + // 출처 확인이 3일 넘게 끊기면 스위치를 켜도 현재 후보에서 빠진다. + const stale = validateFundingCatalog( + catalogWith(versions, { + policyLoanPromotion: true, + sourceSyncs: [{ ...FRESH_SYNC, lastSucceededOn: '2026-09-25' }], + }), + { asOfDate: AS_OF }, + ).catalog; + if (stale === null) throw new Error('카탈로그 검증에 실패했습니다.'); + const evaluation = evaluateProduct(stale.products[3], POST_REGISTRATION_PROFILE, { + asOfDate: AS_OF, + automation: stale.automation, + }); + expect(evaluation.candidateStatus).toBe('REVIEW_OVERDUE'); + }); + + it("'고정금리(2.0%P)'처럼 모호한 금리와 여러 금액이 적힌 한도는 확정 값으로 옮기지 않는다", () => { + const { byName } = mapDefault(); + const disabled = byName.get('장애인기업지원자금(대리대출)'); + expect(disabled).toMatchObject({ + interestRateConfirmed: false, + interestRatePercent: null, + publicLimit: '100000000', + }); + expect(disabled?.unsupportedCalculationReasons[0]).toContain('연 금리인지 가산폭인지 모호'); + expect(byName.get('혁신성장촉진자금(직접대출)')?.publicLimit).toBeNull(); + }); + + it('세부 유형 카드만 있으면 자금 전체를 접수 중으로 보지 않고 확인 항목에 적는다', () => { + const { byName } = mapDefault(); + const innovation = byName.get('혁신성장촉진자금(직접대출)'); + expect(innovation?.observedApplicationStatus).toBe('UNKNOWN'); + expect(innovation?.additionalChecks).toContain( + '현재 접수 중으로 표시된 세부 유형: 혁신성장촉진자금(사회연대경제조직) (접수중, 접수기간 : 2026-01-12 ~ 자금소진시까지)', + ); + // 하루 접수 공지가 끝났으면 그 기간을 남겨 판정이 접수 종료로 본다. + expect(innovation?.applicationPeriod).toEqual({ start: '2026-08-10', end: '2026-08-10', note: null }); + const hardship = byName.get('일시적경영애로자금(직접대출)'); + expect(hardship?.observedApplicationStatus).toBe('UNKNOWN'); + expect(hardship?.additionalChecks.join('\n')).toContain('홈플러스 피해 소상공인'); + expect(hardship?.additionalChecks.join('\n')).toContain("'정책자금 신청하기'에 이 자금의 접수 표시가 없습니다"); + }); + + it('접수 시작 전인 공고와 공지를 찾지 못한 자금은 접수 중이 아니다', () => { + const { byName } = mapDefault(); + expect(byName.get('일반경영안정자금(대리대출)')).toMatchObject({ + observedApplicationStatus: 'UNKNOWN', + applicationPeriod: { start: '2026-10-06', end: null, note: '2026. 10. 6.(화) 10:00 ~ 예산소진 시까지' }, + officialUrl: 'https://ols.semas.or.kr/ols/man/SMAN052M/page.do?bltwtrSeq=404&bbsTypeCd=01', + }); + const disabled = byName.get('장애인기업지원자금(대리대출)'); + expect(disabled).toMatchObject({ + observedApplicationStatus: 'UNKNOWN', + applicationPeriod: { start: null, end: null }, + officialUrl: SEMAS_OLS_URLS.overview, + }); + expect(disabled?.evidence.find((entry) => entry.subject === 'APPLICATION_PERIOD')?.summary).toContain( + '접수 안내 공지도 찾지 못했습니다', + ); + }); + + it('홈 화면 표시가 공지·기준일과 모순되면 접수 중으로 기록하지 않는다', () => { + // 카드 시작일이 공지 시작일과 다르다. + const mismatch = mapDefault({ + cards: [card('dd', '소공인특화자금', '접수기간 : 2026-02-02 ~ 자금소진시까지')], + }).byName.get('소공인특화자금(대리대출)'); + expect(mismatch?.observedApplicationStatus).toBe('UNKNOWN'); + expect(mismatch?.additionalChecks).toContain( + '홈 화면 접수 시작일 2026-02-02과 공지 접수 시작일 2026-01-05이 다릅니다.', + ); + + // 접수중이라고 표시했지만 시작일이 기준일 이후다. + const early = mapDefault({ + cards: [card('dd', '일반경영안정자금', '접수기간 : 2026-10-06 ~ 자금소진시까지')], + }).byName.get('일반경영안정자금(대리대출)'); + expect(early?.observedApplicationStatus).toBe('UNKNOWN'); + + // 접수중이라고 표시했지만 종료일이 이미 지났다. + const ended = mapDefault({ + cards: [card('jd', '일시적경영애로자금', '접수기간 : 2026-09-07 ~ 2026-09-28')], + }).byName.get('일시적경영애로자금(직접대출)'); + expect(ended?.observedApplicationStatus).toBe('UNKNOWN'); + expect(ended?.additionalChecks).toContain( + '홈 화면은 접수중이지만 접수 종료일 2026-09-28이 기준일 2026-09-29 이전입니다.', + ); + + // 홈 화면은 '자금소진시까지'인데 공지에 종료일이 있으면 더 좁은 공지 종료일을 쓴다. + const overview = parseSemasOverview(overviewHtml()); + const noticeWithEnd: SemasFundNotice = { + checksum: 'd'.repeat(64), + notice: { + seq: 330, + bbsTypeCd: '01', + section: '대리대출', + category: '대출정보', + title: '공지', + registeredOn: AS_OF, + }, + detail: { + title: '2026년 소공인특화자금(대리대출) 접수 안내', + periodText: '2026. 1. 5.(월) 10:00 ~ 2026. 10. 30.(금) 18:00', + target: null, + exclusionLines: [], + }, + }; + const narrowed = mapSemasFunds( + { + overview, + cards: parseSemasHomeCards(homeHtml([card('dd', '소공인특화자금', '접수기간 : 2026-01-05 ~ 자금소진시까지')])), + notices: new Map([['대리대출:소공인특화자금', noticeWithEnd]]), + pageChecksums: { overview: 'a'.repeat(64), home: 'b'.repeat(64) }, + }, + { asOfDate: AS_OF }, + ).products.find((product) => product.name === '소공인특화자금(대리대출)'); + expect(narrowed).toMatchObject({ + observedApplicationStatus: 'OPEN', + applicationPeriod: { start: '2026-01-05', end: '2026-10-30', note: null }, + }); + + // 접수 마감 표시는 CLOSED, 알 수 없는 표시는 UNKNOWN이다. + const closed = mapDefault({ + cards: [card('dd', '소공인특화자금', '접수기간 : 2026-01-05 ~ 자금소진시까지', '접수마감')], + }).byName.get('소공인특화자금(대리대출)'); + expect(closed?.observedApplicationStatus).toBe('CLOSED'); + const planned = mapDefault({ + cards: [card('dd', '소공인특화자금', '접수기간 : 2026-01-05 ~ 자금소진시까지', '접수예정')], + }).byName.get('소공인특화자금(대리대출)'); + expect(planned?.observedApplicationStatus).toBe('UNKNOWN'); + + // 날짜 없는 '상시접수'는 출처가 접수중으로 표시할 때만 인정한다. + const always = mapDefault({ cards: [card('dd', '장애인기업지원자금', '상시접수')] }).byName.get( + '장애인기업지원자금(대리대출)', + ); + expect(always).toMatchObject({ + observedApplicationStatus: 'OPEN', + applicationPeriod: { start: null, end: null, note: '상시접수' }, + }); + }); + + it('제외 업종에 음식점 관련 표현이 보이면 접수 중으로 올리지 않고 확인 항목에 남긴다', () => { + const qualification = QUALIFICATION.replace('부동산업 등', '부동산업, 일반음식점업 등'); + const { byName } = mapDefault({ overview: overviewHtml({ qualification }) }); + const product = byName.get('소공인특화자금(대리대출)'); + expect(product?.observedApplicationStatus).toBe('UNKNOWN'); + expect(product?.additionalChecks.join('\n')).toContain( + "제외 신호: '제외업종: 유흥 향락 업종, 전문업종, 금융업, 보험업, 부동산업, 일반음식점업 등'", + ); + }); + + it('출처 식별자와 상품 키는 대출구분과 자금명으로 정해지고, 표의 중복 행과 표에 없는 카드는 따로 센다', () => { + expect(semasFundExternalId({ section: '대리대출', name: '긴급경영안정자금 (일시적 경영애로)' })).toBe( + '대리대출:긴급경영안정자금(일시적경영애로)', + ); + expect(semasFundProductKey({ section: '대리대출', name: '소상공인 대환대출' })).toBe( + semasFundProductKey({ section: '대리대출', name: '대환대출' }), + ); + expect(semasFundProductKey({ section: '직접대출', name: '대환대출' })).toMatch(/^semas-ols-direct-[0-9a-f]{10}$/); + expect(semasNoticeUrl({ seq: 404, bbsTypeCd: '01' })).toBe( + 'https://ols.semas.or.kr/ols/man/SMAN052M/page.do?bltwtrSeq=404&bbsTypeCd=01', + ); + + const agency = fundTable('2026년 정책자금 대리대출 세부 지원요건', [...AGENCY_ROWS, AGENCY_ROWS[0]]); + const { mapping } = mapDefault({ + overview: overviewHtml({ agency }), + cards: [...HOME_CARDS, card('dd', '신규특별자금', '접수기간 : 2026-09-01 ~ 자금소진시까지')], + }); + expect(mapping.duplicateRows).toBe(1); + expect(mapping.products).toHaveLength(6); + expect(mapping.unmatchedCards).toEqual(["대리대출 '신규특별자금' (접수중)"]); + }); +}); + +describe('소진공 정책자금 수집기', () => { + it('robots.txt 확인 뒤 표·홈 화면·공지 목록·공지 상세를 차례로 읽고, 요청마다 1초 이상 쉰다', async () => { + const { promise, calls, sleeps, logs } = collect(); + const result = await promise; + expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}${new URL(call.url).search}`)).toEqual([ + 'GET /robots.txt', + 'GET /ols/man/SMAN018M/page.do', + 'GET /ols/man/SMAN010M/page.do', + 'POST /ols/man/SMAN051M/search.do', + 'POST /ols/man/SMAN051M/search.do', + 'GET /ols/man/SMAN052M/page.do?bltwtrSeq=396&bbsTypeCd=01', + 'GET /ols/man/SMAN052M/page.do?bltwtrSeq=402&bbsTypeCd=01', + 'GET /ols/man/SMAN052M/page.do?bltwtrSeq=404&bbsTypeCd=01', + 'GET /ols/man/SMAN052M/page.do?bltwtrSeq=326&bbsTypeCd=01', + 'GET /ols/man/SMAN052M/page.do?bltwtrSeq=325&bbsTypeCd=01', + ]); + expect(calls.every((call) => call.headers['User-Agent'] === semasUserAgent(CONTACT))).toBe(true); + expect(semasUserAgent(CONTACT)).toBe('TrendBench-funding-sync/1.0 (+ops@example.com)'); + expect(new URLSearchParams(calls[3].body).get('pageNo')).toBe('1'); + expect(new URLSearchParams(calls[4].body).get('pageNo')).toBe('2'); + expect(sleeps).toHaveLength(calls.length - 1); + expect(sleeps.every((ms) => ms >= SEMAS_OLS_MIN_REQUEST_INTERVAL_MS)).toBe(true); + expect(result.fetchedCount).toBe(6); + expect(result.products).toHaveLength(6); + expect(result.responseChecksum).toMatch(/^[0-9a-f]{64}$/); + // 근거 checksum은 수집기가 실제로 받은 응답 본문의 SHA-256이다. + const special = result.products.find((product) => product.name === '소공인특화자금(대리대출)'); + expect(special?.evidence.find((entry) => entry.id === 'semas-identity')?.checksum).toBe(sha256(overviewHtml())); + expect(special?.evidence.find((entry) => entry.id === 'semas-period-home')?.checksum).toBe(sha256(homeHtml())); + expect(special?.evidence.find((entry) => entry.id === 'semas-period-notice')?.checksum).toBe(sha256(DETAILS[326])); + expect(logs.join('\n')).toContain('자금 6건으로 상품 6건'); + expect(logs.join('\n')).toContain('접수 중 2건'); + }); + + it('요청 간격 옵션은 1초보다 짧게 줄일 수 없다', async () => { + const { fetchImpl } = siteFetch(); + const sleeps: number[] = []; + const adapter = createSemasOlsAdapter({ + contact: CONTACT, + fetchImpl, + requestIntervalMs: 10, + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + await adapter.collect({ asOfDate: AS_OF, log: () => {} }); + expect(Math.min(...sleeps)).toBe(SEMAS_OLS_MIN_REQUEST_INTERVAL_MS); + }); + + it('운영자 연락처가 없거나 헤더에 쓸 수 없으면 요청하지 않는다', async () => { + for (const contact of [undefined, ' ', '운영자 메일']) { + const { promise, calls } = collect({}, { contact }); + await expect(promise).rejects.toThrow('FUNDING_SYNC_CONTACT'); + expect(calls).toHaveLength(0); + } + }); + + it('robots.txt가 생기면 다른 페이지를 읽지 않고 멈춘다', async () => { + const { promise, calls } = collect({ robots: () => ({ status: 200, body: 'User-agent: *\nDisallow: /ols/' }) }); + await expect(promise).rejects.toThrow('robots.txt가 생겼습니다'); + expect(calls).toHaveLength(1); + }); + + it('403·429·자동 입력 방지 화면은 다시 시도하지 않고 멈춘다', async () => { + for (const reply of [ + { status: 403, body: 'Forbidden' }, + { status: 429, body: 'Too Many Requests' }, + { status: 200, body: '자동입력 방지 문자를 입력하세요' }, + ]) { + const { promise, calls } = collect({ overview: () => reply }); + await expect(promise).rejects.toThrow('멈춥니다'); + expect(calls).toHaveLength(2); + } + }); + + it('서버 오류와 연결 오류는 두 번까지 다시 시도한다', async () => { + const replies: Reply[] = [{ status: 503, body: '' }, new TypeError('fetch failed')]; + const recovered = collect({ home: () => replies.shift() ?? { status: 200, body: homeHtml() } }); + await expect(recovered.promise).resolves.toMatchObject({ fetchedCount: 6 }); + expect(recovered.calls.filter((call) => call.url === SEMAS_OLS_URLS.home)).toHaveLength(3); + + const failed = collect({ home: () => ({ status: 500, body: '' }) }); + await expect(failed.promise).rejects.toThrow('3번 실패'); + expect(failed.calls.filter((call) => call.url === SEMAS_OLS_URLS.home)).toHaveLength(3); + }); + + it('페이지 형식이 바뀌어 필수 항목을 찾지 못하면 실패한다', async () => { + const noTable = collect({ overview: () => ({ status: 200, body: `${QUALIFICATION}` }) }); + await expect(noTable.promise).rejects.toThrow('자금 표를 찾지 못했습니다'); + + const noHome = collect({ home: () => ({ status: 200, body: '점검 중입니다' }) }); + await expect(noHome.promise).rejects.toThrow('정책자금 신청하기'); + + const badDetail = collect({ detail: () => ({ status: 200, body: detailHtml('다른 공지', ['본문']) }) }); + await expect(badDetail.promise).rejects.toThrow('목록 제목'); + + const missingDetail = collect({ detail: () => ({ status: 404, body: '' }) }); + await expect(missingDetail.promise).rejects.toThrow('HTTP 404'); + }); + + it('공지 목록이 비거나 전체 건수보다 적게 오면 실패한다', async () => { + const empty = collect({ + list: (pageNo) => ({ status: 201, body: pageNo === 1 ? listPage(LIST_ITEMS, 1) : listPage([], 1, 10, 11) }), + }); + await expect(empty.promise).rejects.toThrow('2쪽이 비어 있습니다'); + + const truncated = collect({ list: (pageNo) => ({ status: 201, body: listPage(LIST_ITEMS, pageNo, 10, 12) }) }); + await expect(truncated.promise).rejects.toThrow('전체 12건 중 11건'); + + const notJson = collect({ list: () => ({ status: 200, body: '세션 만료' }) }); + await expect(notJson.promise).rejects.toThrow('JSON이 아닙니다'); + }); +}); diff --git a/app/src/features/funding/sources/semas-ols.ts b/app/src/features/funding/sources/semas-ols.ts new file mode 100644 index 0000000..141f2e1 --- /dev/null +++ b/app/src/features/funding/sources/semas-ols.ts @@ -0,0 +1,944 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import type { CollectedFundingProduct, FundingSourceAdapter, FundingSourceCollection } from '../sync.ts'; +import { compareIsoDates, isRealDate } from '../types.ts'; + +// 소상공인시장진흥공단 정책자금 사이트(ols.semas.or.kr)의 정책자금 수집기(ADR 0006 1·3절). +// 로그인 없이 공개된 서버 HTML과, 공지 상세를 찾는 데만 쓰는 공지 목록 JSON을 읽는다. +// - 정책자금 한눈에 보기: 자금별 신청요건·대출기간·한도·금리, 공통 지원자격, 기준금리 +// - 홈 화면 "정책자금 신청하기": 자금별 접수 상태("접수중")와 접수기간 +// - 공지사항 목록(JSON): 자금별 최신 접수 안내 공지를 찾는 데만 쓴다 +// - 공지 상세: 접수기간·신청대상 문장 +// 첨부(PDF)는 읽지 않고, 페이지 원문은 저장하지 않는다. 뽑은 값과 주소, 응답 checksum만 남긴다. +export const SEMAS_OLS_ORIGIN = 'https://ols.semas.or.kr'; +export const SEMAS_OLS_URLS = { + robots: `${SEMAS_OLS_ORIGIN}/robots.txt`, + home: `${SEMAS_OLS_ORIGIN}/ols/man/SMAN010M/page.do`, + overview: `${SEMAS_OLS_ORIGIN}/ols/man/SMAN018M/page.do`, + noticeList: `${SEMAS_OLS_ORIGIN}/ols/man/SMAN051M/search.do`, + noticeDetail: `${SEMAS_OLS_ORIGIN}/ols/man/SMAN052M/page.do`, +} as const; + +// 요청 사이 최소 간격(ADR 0006 3절: 1초 이상). 옵션으로 더 줄일 수 없다. +export const SEMAS_OLS_MIN_REQUEST_INTERVAL_MS = 1000; +// 공지 목록은 이 쪽수까지만 읽는다(한 쪽 10건). 그보다 오래된 공지는 현재 접수 안내가 아니다. +export const SEMAS_OLS_MAX_NOTICE_PAGES = 30; + +const ORGANIZATION = '소상공인시장진흥공단'; +const OVERVIEW_DOCUMENT = '소진공 정책자금 한눈에 보기'; +const HOME_DOCUMENT = '소진공 정책자금 사이트 홈 화면 · 정책자금 신청하기'; +const OVERVIEW_COLUMNS: readonly string[] = ['자금명', '신청요건', '대출기간', '대출한도', '대출금리']; + +// 정상 페이지에는 없는 차단 신호. 만나면 우회하지 않고 멈춘다(ADR 0006 3절). +const BLOCK_PAGE_PATTERN = /captcha|자동\s*입력\s*방지|보안\s*문자/i; + +// 제외 업종·제외 대상 문장에 이 제품의 업종(음식점·카페)이 들어 있는지 보는 신호. 유흥주점 같은 +// 표현이 음식점 전체를 막지는 않으므로 '주점'은 넣지 않는다. 이 신호는 올리는 데 쓰지 않고 내리는 데만 쓴다. +const FOOD_EXCLUSION_PATTERN = /음식|외식|식당|요식|카페|커피|제과|베이커리/; + +export type SemasLoanSection = '직접대출' | '대리대출'; +const LOAN_SECTIONS: readonly SemasLoanSection[] = ['직접대출', '대리대출']; + +export type SemasFundRow = { + section: SemasLoanSection; + // 표 위 제목(예: '2026년 정책자금 직접대출 세부 지원요건'). + heading: string; + name: string; + requirement: string; + term: string; + limit: string; + rate: string; +}; + +export type SemasOverview = { + rows: SemasFundRow[]; + // 공통 지원자격 항목. + qualification: string[]; + // 공통 지원자격의 '제외업종: …' 항목. 없으면 null이다. + excludedIndustries: string | null; + // 금리 안내사항의 분기 기준금리 문장. 없으면 null이다. + baseRate: string | null; +}; + +// 홈 화면 "정책자금 신청하기"의 대출 카드 하나. +export type SemasHomeCard = { + section: SemasLoanSection; + name: string; + // 카드의 상태 표시(예: '접수중'). + status: string; + // 카드의 접수기간 표시(예: '접수기간 : 2026-01-05 ~ 자금소진시까지'). + periodText: string; +}; + +export type SemasNotice = { + seq: number; + bbsTypeCd: string; + // 대출구분(직접대출·대리대출). + section: string; + // 게시 구분(대출정보·서비스안내·기타). + category: string; + title: string; + registeredOn: string; +}; + +export type SemasNoticeDetail = { + title: string; + // '□ 접수기간 : …' 문장의 값. 없으면 null이다. + periodText: string | null; + // '□ 신청대상 : …' 또는 '…의 지원대상은 …' 문장. 없으면 null이다. + target: string | null; + // '제외'가 들어간 본문 줄. + exclusionLines: string[]; +}; + +export type SemasPeriod = { + start: string; + // 종료일. '예산 소진 시까지'처럼 날짜가 없으면 null이다. + end: string | null; + // 종료일이 없을 때 원문 표기. + openEndedNote: string | null; +}; + +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', + middot: '·', + lsquo: '‘', + rsquo: '’', + ldquo: '“', + rdquo: '”', +}; + +function decodeEntities(value: string): string { + return value.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (match, entity: string) => { + if (entity.startsWith('#x') || entity.startsWith('#X')) return String.fromCodePoint(parseInt(entity.slice(2), 16)); + if (entity.startsWith('#')) return String.fromCodePoint(Number(entity.slice(1))); + return ENTITIES[entity.toLowerCase()] ?? match; + }); +} + +// 태그를 걷어 내고 줄바꿈(
, 문단·목록 끝)을 줄 단위로 남긴다. +function htmlToLines(html: string): string[] { + return decodeEntities( + html + .replace(//gi, '\n') + .replace(/<\/(?:p|li|div|tr|h\d)>/gi, '\n') + .replace(/<[^>]*>/g, ''), + ) + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter((line) => line.length > 0); +} + +function htmlToText(html: string): string { + return htmlToLines(html).join(' '); +} + +function compact(value: string): string { + return value.replace(/\s+/g, ''); +} + +// 자금명 비교용. 공백을 빼고, 카드·공지에 붙는 앞머리 '소상공인'(예: '소상공인 대환대출')을 뗀다. +export function normalizeSemasFundName(value: string): string { + return compact(value).replace(/^소상공인/, ''); +} + +// openTagIndex 위치의 여는 태그부터 짝이 맞는 닫는 태그까지의 안쪽 HTML. 같은 태그가 중첩되어도 짝을 센다. +function innerHtmlAt(html: string, openTagIndex: number, tag: string): string | null { + const openEnd = html.indexOf('>', openTagIndex); + if (openEnd < 0) return null; + const pattern = new RegExp(`<(/?)${tag}\\b[^>]*>`, 'gi'); + pattern.lastIndex = openEnd + 1; + let depth = 1; + for (let match = pattern.exec(html); match !== null; match = pattern.exec(html)) { + depth += match[1] === '/' ? -1 : 1; + if (depth === 0) return html.slice(openEnd + 1, match.index); + } + return null; +} + +// 제목이 title인

다음, 다음

전까지의 첫 목록 항목들. 제목이 없으면 null이다. +function listAfterHeading(html: string, title: string): string[] | null { + const headings = [...html.matchAll(/]*>([\s\S]*?)<\/h3>/gi)]; + for (const [index, heading] of headings.entries()) { + if (compact(htmlToText(heading[1])) !== compact(title)) continue; + const from = heading.index + heading[0].length; + const to = headings[index + 1]?.index ?? html.length; + const section = html.slice(from, to); + const listStart = section.search(/]*>([\s\S]*?)<\/li>/gi)].map((item) => htmlToText(item[1])).filter(Boolean); + } + return null; +} + +// 정책자금 한눈에 보기. 표 머리글·섹션 제목·공통 지원자격이 예상과 다르면 파서가 필수 항목을 찾지 +// 못한 것으로 보고 오류를 던진다. 그 출처는 실패로 기록된다(ADR 0006 2절). +export function parseSemasOverview(html: string): SemasOverview { + const qualification = listAfterHeading(html, '공통 지원자격'); + // 사업 단계(사업자등록 이후)는 공통 지원자격의 소상공인 문장에서 정한다. 없으면 필수 항목 누락이다. + if (qualification === null || !qualification.some((item) => item.includes('소상공인'))) { + throw new Error('정책자금 한눈에 보기의 공통 지원자격에서 소상공인 대상 문장을 찾지 못했습니다.'); + } + const excludedIndustries = qualification.find((item) => /^제외\s*업종/.test(item)) ?? null; + const rateNotes = listAfterHeading(html, '금리 안내사항') ?? []; + const baseRate = rateNotes.find((item) => /기준금리\s*[::]?\s*\d+(?:\.\d+)?\s*%/.test(item)) ?? null; + + const rows: SemasFundRow[] = []; + const headings = [...html.matchAll(/]*>([\s\S]*?)<\/h3>/gi)]; + for (const [index, match] of headings.entries()) { + const heading = htmlToText(match[1]); + const from = match.index + match[0].length; + const to = headings[index + 1]?.index ?? html.length; + const section = html.slice(from, to); + const tableStart = section.search(/]*>([\s\S]*?)<\/th>/gi)].map((cell) => htmlToLines(cell[1])[0] ?? ''); + const isFundTable = + columns.length === OVERVIEW_COLUMNS.length && + columns.every((column, position) => compact(column) === OVERVIEW_COLUMNS[position]); + if (!isFundTable) { + if (heading.includes('지원요건')) { + throw new Error(`'${heading}' 표의 머리글이 예상과 다릅니다: ${columns.join(', ') || '없음'}`); + } + continue; + } + const loanSection = LOAN_SECTIONS.find((candidate) => compact(heading).includes(candidate)); + if (loanSection === undefined) { + throw new Error(`정책자금 표 '${heading}'이(가) 직접대출인지 대리대출인지 알 수 없습니다.`); + } + const bodyStart = table.search(/]*>([\s\S]*?)<\/tr>/gi)) { + const cells = [...row[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map((cell) => htmlToText(cell[1])); + if (cells.length !== OVERVIEW_COLUMNS.length || cells[0] === '') { + throw new Error(`'${heading}' 표의 행에서 다섯 칸(자금명·신청요건·대출기간·한도·금리)을 찾지 못했습니다.`); + } + const [name, requirement, term, limit, rate] = cells; + rows.push({ section: loanSection, heading, name, requirement, term, limit, rate }); + } + } + if (rows.length === 0) throw new Error('정책자금 한눈에 보기에서 자금 표를 찾지 못했습니다.'); + return { rows, qualification, excludedIndustries, baseRate }; +} + +// 홈 화면 "정책자금 신청하기"의 대출 카드. 상환연장·조건변경 카드는 대출 상품이 아니라 뺀다. +// 영역 자체가 없으면 접수 상태를 확인할 수 없으므로 오류를 던진다. +export function parseSemasHomeCards(html: string): SemasHomeCard[] { + const blockStart = html.search(/]*class="[^"]*\bloan_wrap\b[^"]*"/i); + const block = blockStart < 0 ? null : innerHtmlAt(html, blockStart, 'div'); + if (block === null || !/정책자금\s*(?:<\/i>)?\s*신청하기/.test(block)) { + throw new Error("홈 화면에서 '정책자금 신청하기' 영역을 찾지 못했습니다."); + } + const starts = [...block.matchAll(/]*class="[^"]*\b(?:jd|dd)_loan\b[^"]*"[^>]*>/gi)].map( + (match) => match.index, + ); + const cards: SemasHomeCard[] = []; + for (const [index, start] of starts.entries()) { + const card = block.slice(start, starts[index + 1] ?? block.length); + const label = /]*class="[^"]*\b[a-z]+_title\b[^"]*"[^>]*>([\s\S]*?)<\/p>/i.exec(card); + const section = LOAN_SECTIONS.find((candidate) => label !== null && compact(htmlToText(label[1])) === candidate); + if (section === undefined) continue; + const status = /]*class="[^"]*\binfo_[a-z]+\b[^"]*"[^>]*>([\s\S]*?)<\/p>/i.exec(card); + const name = /]*class="[^"]*\bloan_type\b[^"]*"[^>]*>([\s\S]*?)<\/p>/i.exec(card); + const period = /]*class="[^"]*\bloan_date\b[^"]*"[^>]*>([\s\S]*?)<\/p>/i.exec(card); + const cardName = name === null ? '' : htmlToText(name[1]); + const cardStatus = status === null ? '' : htmlToText(status[1]); + if (cardName === '' || cardStatus === '') { + throw new Error('홈 화면 대출 카드에서 자금명이나 접수 상태를 찾지 못했습니다.'); + } + cards.push({ + section, + name: cardName, + status: cardStatus, + periodText: period === null ? '' : htmlToText(period[1]), + }); + } + return cards; +} + +const listText = z + .union([z.string(), z.number(), z.null(), z.undefined()]) + .transform((value) => (value === null || value === undefined ? '' : decodeEntities(String(value)).trim())); + +const noticeItemSchema = z.object({ + bltwtrSeq: z.coerce.number().int().positive(), + bbsTypeCd: z + .union([z.string(), z.number()]) + .transform((value) => String(value).trim()) + .pipe(z.string().regex(/^\d{1,2}$/)), + loanSeCdNm: listText, + bltwtrClcd: listText, + bltwtrTitNm: listText, + frstRegDt: listText, +}); + +const noticePageSchema = z.object({ + result: z.array(z.unknown()), + pagination: z.object({ + vTotalPage: z.coerce.number().int().nonnegative(), + totalCount: z.coerce.number().int().nonnegative(), + }), +}); + +export type SemasNoticePage = { notices: SemasNotice[]; totalPages: number; totalCount: number; malformed: number }; + +// 공지 목록 JSON 한 쪽. 공지 상세를 찾는 데만 쓰고 조건 값은 읽지 않는다(ADR 0006 3절). +export function parseSemasNoticePage(body: string): SemasNoticePage { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error(`소진공 공지 목록 응답이 JSON이 아닙니다: ${snippet(body)}`); + } + const page = noticePageSchema.safeParse(parsed); + if (!page.success) throw new Error('소진공 공지 목록 응답에 공지 목록(result)이나 쪽 정보(pagination)가 없습니다.'); + const notices: SemasNotice[] = []; + let malformed = 0; + for (const raw of page.data.result) { + const item = noticeItemSchema.safeParse(raw); + if (!item.success || item.data.bltwtrTitNm === '') { + malformed += 1; + continue; + } + notices.push({ + seq: item.data.bltwtrSeq, + bbsTypeCd: item.data.bbsTypeCd.padStart(2, '0'), + section: item.data.loanSeCdNm, + category: item.data.bltwtrClcd, + title: item.data.bltwtrTitNm.replace(/\s+/g, ' '), + registeredOn: item.data.frstRegDt, + }); + } + return { + notices, + totalPages: page.data.pagination.vTotalPage, + totalCount: page.data.pagination.totalCount, + malformed, + }; +} + +const PERIOD_LINE = /^[□■○◇◆▶·\-\s]*(?:접수|신청)\s*기간\s*[::]\s*(.+)$/; +const TARGET_LINE = /^[□■○◇◆▶·\-\s]*신청\s*대상\s*[::]\s*(.+)$/; + +// 공지 상세. 제목이나 본문이 없으면 페이지 형식이 바뀐 것으로 보고 오류를 던진다. +export function parseSemasNoticeDetail(html: string): SemasNoticeDetail { + const titleMatch = /]*class="[^"]*\bview_title\b[^"]*"[^>]*>([\s\S]*?)<\/p>/i.exec(html); + const title = titleMatch === null ? '' : htmlToText(titleMatch[1]); + const bodyStart = html.search(/]*id="cntnDiv"/i); + const body = bodyStart < 0 ? null : innerHtmlAt(html, bodyStart, 'div'); + if (title === '' || body === null) throw new Error('소진공 공지 상세에서 제목이나 본문을 찾지 못했습니다.'); + const lines = htmlToLines(body); + const periodText = lines.map((line) => PERIOD_LINE.exec(line)?.[1].trim()).find(Boolean) ?? null; + // '…의 지원대상은' 뒤에서 줄이 바뀌는 공지가 있어, 그 줄이 '지원대상은'으로 끝나면 다음 줄까지 잇는다. + const sentence = lines.findIndex((line) => line.includes('지원대상은')); + const sentenceTarget = + sentence < 0 + ? null + : lines[sentence].endsWith('지원대상은') && sentence + 1 < lines.length + ? `${lines[sentence]} ${lines[sentence + 1]}` + : lines[sentence]; + const target = lines.map((line) => TARGET_LINE.exec(line)?.[1].trim()).find(Boolean) ?? sentenceTarget; + return { title, periodText, target, exclusionLines: lines.filter((line) => line.includes('제외')) }; +} + +const DATE_TOKEN = + /(?:(\d{4})|[‘’'`](\d{2}))\s*(?:년|[.-])\s*(\d{1,2})\s*(?:월|[.-])\s*(\d{1,2})(?!\d)\s*(?:일|\.)?|(\d{1,2})\s*(?:월|\.)\s*(\d{1,2})\s*(?:일|\.)/g; + +function isoDate(year: number, month: number, day: number): string | null { + const value = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return isRealDate(value) ? value : null; +} + +// 소진공 접수기간 표기를 날짜로 읽는다. 읽을 수 없으면 추측하지 않고 null이다. +// - '2026. 10. 6.(화) 10:00 ~ 예산소진 시까지', '2026-01-05 ~ 자금소진시까지': 종료일 없는 접수 +// - '‘26. 8. 18.(화) ~ 8. 19.(수), 10:00 ~ 18:00', '2026-01-05 ~ 2026-10-30': 시작일과 종료일 +// - '‘26. 8. 10.(월), 10:00 ~ 18:00': 그날 하루 +export function parseSemasPeriod(raw: string): SemasPeriod | null { + const text = raw.replace(/\s+/g, ' ').trim(); + const tokens = [...text.matchAll(DATE_TOKEN)]; + if (tokens.length === 0 || tokens.length > 2) return null; + const [first, second] = tokens; + if (first[3] === undefined) return null; + const startYear = first[1] !== undefined ? Number(first[1]) : 2000 + Number(first[2]); + const start = isoDate(startYear, Number(first[3]), Number(first[4])); + if (start === null) return null; + if (second !== undefined) { + let end: string | null; + if (second[3] !== undefined) { + const endYear = second[1] !== undefined ? Number(second[1]) : 2000 + Number(second[2]); + end = isoDate(endYear, Number(second[3]), Number(second[4])); + } else { + end = isoDate(startYear, Number(second[5]), Number(second[6])); + // 연도 없이 적힌 종료일이 시작일보다 앞이면 해를 넘긴 것이다(예: 12. 28. ~ 1. 5.). + if (end !== null && compareIsoDates(end, start) < 0) + end = isoDate(startYear + 1, Number(second[5]), Number(second[6])); + } + if (end === null || compareIsoDates(end, start) < 0) return null; + return { start, end, openEndedNote: null }; + } + const afterStart = text.slice(first.index + first[0].length); + const tilde = afterStart.search(/[~∼~]/); + if (tilde < 0) return null; + const tail = afterStart.slice(tilde + 1).trim(); + if (/^\d{1,2}:\d{2}(?:\s*까지)?[\s,.)]*$/.test(tail)) return { start, end: start, openEndedNote: null }; + if (!/\d/.test(tail) && /소진|마감|상시|까지/.test(tail)) return { start, end: null, openEndedNote: tail }; + return null; +} + +function stripPeriodLabel(value: string): string { + return value.replace(/^\s*(?:접수|신청)\s*기간\s*[::]\s*/, '').trim(); +} + +// 공지 제목이 이 자금의 접수·신청 안내인지. 자금명 바로 뒤에는 (직접대출)·(대리대출)·(일반)과 +// '접수 안내'·'신청안내'만 올 수 있다. '금리인하제도 신청안내'처럼 다른 제도의 안내나 +// '(홈플러스 피해 소상공인)'처럼 대상을 좁힌 세부 공고는 자금 전체의 접수 안내로 보지 않는다. +export function isSemasFundNotice(row: Pick, notice: SemasNotice): boolean { + if (notice.section !== row.section || notice.category !== '대출정보') return false; + const title = compact(notice.title); + const fund = normalizeSemasFundName(row.name); + for (let index = title.indexOf(fund); index >= 0; index = title.indexOf(fund, index + 1)) { + const rest = title + .slice(index + fund.length) + .replace(/^(?:\((?:직접대출|대리대출|일반)\))+/, '') + .replace(/^(?:직접대출|대리대출)/, ''); + if (/^(?:접수|신청)안내/.test(rest)) return true; + } + return false; +} + +// 자금 하나를 가리키는 출처 식별자. 공지는 분기·월마다 새로 올라오므로 공지 번호가 아니라 +// 대출구분과 자금명으로 식별한다. 차단 목록과 사람 기록 우선도 이 식별자로 맞춘다. +export function semasFundExternalId(row: Pick): string { + return `${row.section}:${normalizeSemasFundName(row.name)}`; +} + +export function semasFundProductKey(row: Pick): string { + const digest = createHash('sha256').update(semasFundExternalId(row), 'utf8').digest('hex').slice(0, 10); + return `semas-ols-${row.section === '직접대출' ? 'direct' : 'agency'}-${digest}`; +} + +// 자금마다 가장 최근의 접수·신청 안내 공지를 고른다. +export function selectSemasFundNotices( + rows: readonly SemasFundRow[], + notices: readonly SemasNotice[], +): Map { + const selected = new Map(); + for (const row of rows) { + const key = semasFundExternalId(row); + for (const notice of notices) { + if (!isSemasFundNotice(row, notice)) continue; + const current = selected.get(key); + if (current === undefined || notice.seq > current.seq) selected.set(key, notice); + } + } + return selected; +} + +export function semasNoticeUrl(notice: Pick): string { + const url = new URL(SEMAS_OLS_URLS.noticeDetail); + url.searchParams.set('bltwtrSeq', String(notice.seq)); + url.searchParams.set('bbsTypeCd', notice.bbsTypeCd); + return url.toString(); +} + +// 대출한도 칸이 금액 하나일 때만 원 단위 정수 문자열로 읽는다(예: '7천만원', '1억원', '1억 4백만원'). +// 유형·용도별로 여러 금액이 적힌 칸은 공개 한도 하나로 정할 수 없어 null이다. +export function parseSemasSingleLimit(raw: string): string | null { + const match = /^(?:(\d+)억)?(?:(?:(\d+)천)?(?:(\d+)백)?(\d+)?만)?원$/.exec(compact(raw)); + if (match === null) return null; + const [, eok, cheon, baek, rest] = match; + const man = BigInt(cheon ?? 0) * 1000n + BigInt(baek ?? 0) * 100n + BigInt(rest ?? 0); + const won = BigInt(eok ?? 0) * 100_000_000n + man * 10_000n; + return won > 0n ? won.toString() : null; +} + +function rateReason(rate: string): string { + if (/기준금리/.test(rate)) { + return `대출금리 '${rate}'는 분기마다 바뀌는 정책자금 기준금리에 연동되어 확정 금리가 아닙니다.`; + } + if (/고정금리\s*\(/.test(rate)) { + return `대출금리 표기 '${rate}'는 연 금리인지 가산폭인지 모호해 확정 금리로 보지 않습니다.`; + } + return `대출금리 표기 '${rate}'를 확정 연 금리로 읽지 않았습니다.`; +} + +type ApplicationState = { + status: 'OPEN' | 'CLOSED' | 'UNKNOWN'; + period: { start: string | null; end: string | null; note: string | null }; + checks: string[]; +}; + +// 접수 상태는 홈 화면의 명시적인 상태 표시로만 OPEN·CLOSED를 정한다(ADR 0006 4절 1번, 6절). +// 날짜가 있으면 판정 기준일과 공지 접수기간이 그 표시와 모순이 없어야 하고, 모순이 있으면 UNKNOWN이다. +function resolveApplication( + card: SemasHomeCard | null, + notice: { detail: SemasNoticeDetail } | null, + asOfDate: string, +): ApplicationState { + const noticePeriod = notice?.detail.periodText ? parseSemasPeriod(notice.detail.periodText) : null; + const noticeText = notice?.detail.periodText ?? null; + if (card === null) { + return { + status: 'UNKNOWN', + period: + noticePeriod === null + ? { + start: null, + end: null, + note: noticeText ?? '접수 안내 공지와 홈 화면 접수 표시에서 신청기간을 확인하지 못했습니다.', + } + : { start: noticePeriod.start, end: noticePeriod.end, note: noticePeriod.end === null ? noticeText : null }, + checks: [ + "소진공 홈 화면 '정책자금 신청하기'에 이 자금의 접수 표시가 없습니다. 접수 여부를 원 공고에서 확인해야 합니다.", + ], + }; + } + + const cardText = stripPeriodLabel(card.periodText); + const cardPeriod = cardText === '' ? null : parseSemasPeriod(cardText); + const checks: string[] = []; + let start = cardPeriod?.start ?? noticePeriod?.start ?? null; + let end = cardPeriod?.end ?? null; + const note = cardText === '' ? noticeText : cardText; + if (cardPeriod === null && /\d/.test(cardText)) { + checks.push(`홈 화면 접수기간 표시 '${cardText}'를 날짜로 읽지 못했습니다.`); + } + if (cardPeriod !== null && noticePeriod !== null) { + if (noticePeriod.start !== cardPeriod.start) { + checks.push(`홈 화면 접수 시작일 ${cardPeriod.start}과 공지 접수 시작일 ${noticePeriod.start}이 다릅니다.`); + } + if (cardPeriod.end !== null && noticePeriod.end !== null && cardPeriod.end !== noticePeriod.end) { + checks.push(`홈 화면 접수 종료일 ${cardPeriod.end}과 공지 접수 종료일 ${noticePeriod.end}이 다릅니다.`); + } + } + // 홈 화면은 '자금소진시까지'인데 공지에 종료일이 있으면 더 좁은 공지 종료일을 쓴다. + if (end === null && noticePeriod?.end && (cardPeriod === null || noticePeriod.start === cardPeriod.start)) { + end = noticePeriod.end; + } + if (start !== null && end !== null && compareIsoDates(end, start) < 0) { + checks.push(`접수 종료일 ${end}이 시작일 ${start}보다 앞섭니다.`); + start = null; + end = null; + } + const period = { start, end, note: end === null ? (note ?? '홈 화면과 공지에 접수 종료 표시가 없습니다.') : null }; + + const status = compact(card.status); + if (/마감|종료/.test(status)) { + return { status: 'CLOSED', period, checks }; + } + if (status !== '접수중') { + checks.push(`홈 화면 접수 상태 표시가 '${card.status}'입니다. 접수 중으로 확인되지 않았습니다.`); + return { status: 'UNKNOWN', period, checks }; + } + if (start !== null && compareIsoDates(asOfDate, start) < 0) { + checks.push(`홈 화면은 접수중이지만 접수 시작일 ${start}이 기준일 ${asOfDate} 이후입니다.`); + } + if (end !== null && compareIsoDates(end, asOfDate) < 0) { + checks.push(`홈 화면은 접수중이지만 접수 종료일 ${end}이 기준일 ${asOfDate} 이전입니다.`); + } + return { status: checks.length === 0 ? 'OPEN' : 'UNKNOWN', period, checks }; +} + +// 공지와 그 상세, 상세 페이지 응답의 SHA-256. +export type SemasFundNotice = { notice: SemasNotice; detail: SemasNoticeDetail; checksum: string }; + +export type SemasMappingInput = { + overview: SemasOverview; + cards: readonly SemasHomeCard[]; + // 자금 식별자(semasFundExternalId)별로 고른 접수 안내 공지와 그 상세. + notices: ReadonlyMap; + // 정책자금 한눈에 보기와 홈 화면 응답의 SHA-256. 근거마다 그 근거를 읽은 페이지의 checksum을 남긴다(ADR 0006 2절). + pageChecksums: { overview: string; home: string }; +}; + +export type SemasMapping = { + products: CollectedFundingProduct[]; + // 표에서 같은 자금이 두 번 나와 뺀 행 수. + duplicateRows: number; + // 표의 어느 자금과도 맞지 않는 홈 화면 대출 카드. 운영자가 새 자금을 알아채는 데 쓴다. + unmatchedCards: string[]; +}; + +// 정책자금 표의 자금마다 카탈로그 상품 하나를 만든다. 원문이 알려 주지 않는 용도·업종은 추측하지 않고 +// 미확인으로 남기며, 금리가 기준금리 연동이거나 모호하면 확정 금리로 기록하지 않는다(ADR 0006 5절). +export function mapSemasFunds(input: SemasMappingInput, context: { asOfDate: string }): SemasMapping { + const { overview, cards, notices, pageChecksums } = input; + const products: CollectedFundingProduct[] = []; + const seen = new Set(); + const matchedCards = new Set(); + let duplicateRows = 0; + const stageBasis = overview.qualification.find((item) => item.includes('소상공인')) ?? overview.qualification[0]; + const commonExclusions = overview.excludedIndustries === null ? [] : [overview.excludedIndustries]; + + for (const row of overview.rows) { + const externalId = semasFundExternalId(row); + if (seen.has(externalId)) { + duplicateRows += 1; + continue; + } + seen.add(externalId); + const fund = normalizeSemasFundName(row.name); + const sectionCards = cards.filter((card) => card.section === row.section); + const exactCard = sectionCards.find((card) => normalizeSemasFundName(card.name) === fund) ?? null; + const partialCards = sectionCards.filter((card) => normalizeSemasFundName(card.name).startsWith(`${fund}(`)); + for (const card of [...(exactCard === null ? [] : [exactCard]), ...partialCards]) matchedCards.add(card); + const notice = notices.get(externalId) ?? null; + + const application = resolveApplication(exactCard, notice, context.asOfDate); + const exclusionSignals = [...commonExclusions, ...(notice?.detail.exclusionLines ?? [])].filter((line) => + FOOD_EXCLUSION_PATTERN.test(line), + ); + if (exclusionSignals.length > 0 && application.status === 'OPEN') { + // 제외 신호는 내리는 데만 쓴다. 접수 중으로 기록하지 않아 현재 후보가 되지 않는다(ADR 0006 4절 3번, 6절). + application.status = 'UNKNOWN'; + } + + const noticeUrl = notice === null ? null : semasNoticeUrl(notice.notice); + const base = { observedAt: context.asOfDate, retrievalMethod: 'OFFICIAL_WEB_PAGE' as const }; + const homeEvidence = { + ...base, + sourceUrl: SEMAS_OLS_URLS.home, + sourceDocumentName: HOME_DOCUMENT, + checksum: pageChecksums.home, + }; + const overviewEvidence = { + ...base, + sourceUrl: SEMAS_OLS_URLS.overview, + sourceDocumentName: OVERVIEW_DOCUMENT, + checksum: pageChecksums.overview, + }; + const evidence: CollectedFundingProduct['evidence'] = [ + { + ...overviewEvidence, + id: 'semas-identity', + subject: 'IDENTITY', + summary: `'${row.heading}' 표의 자금명 '${row.name}'(${row.section})`, + }, + { + ...overviewEvidence, + id: 'semas-financial', + subject: 'FINANCIAL_CONDITION', + summary: `대출기간 '${row.term}', 대출한도 '${row.limit}', 대출금리 '${row.rate}'${overview.baseRate === null ? '' : `, 금리 안내 '${overview.baseRate}'`}`, + }, + { + ...overviewEvidence, + id: 'semas-stage', + subject: 'BUSINESS_STAGE', + summary: `공통 지원자격 '${stageBasis}'. 소상공인(사업자) 대상이라 사업자등록 이후 단계로 기록했습니다.`, + }, + { + ...overviewEvidence, + id: 'semas-region', + subject: 'REGION', + summary: '공통 지원자격에 지역 제한이 없어, 소진공 정책자금 수집 규칙에 따라 전국 대상으로 기록했습니다.', + }, + { + ...overviewEvidence, + id: 'semas-eligibility', + subject: 'ELIGIBILITY_CONDITION', + summary: `신청요건: ${row.requirement}`, + }, + ]; + if (exactCard !== null) { + evidence.push({ + ...homeEvidence, + id: 'semas-period-home', + subject: 'APPLICATION_PERIOD', + summary: `${exactCard.section} '${exactCard.name}' 상태 '${exactCard.status}', ${exactCard.periodText || '접수기간 표시 없음'}`, + }); + } + if (notice !== null && noticeUrl !== null) { + evidence.push({ + ...base, + id: 'semas-period-notice', + subject: 'APPLICATION_PERIOD', + sourceUrl: noticeUrl, + sourceDocumentName: `소진공 정책자금 공지사항 · ${notice.detail.title}`, + checksum: notice.checksum, + summary: `공지 '${notice.detail.title}' 접수기간: ${notice.detail.periodText ?? '표기 없음'}`, + }); + } + if (exactCard === null && notice === null) { + evidence.push({ + ...homeEvidence, + id: 'semas-period-home', + subject: 'APPLICATION_PERIOD', + summary: `'정책자금 신청하기'에 ${row.section} '${row.name}' 접수 표시가 없고, 접수 안내 공지도 찾지 못했습니다.`, + }); + } + + const additionalChecks = [ + ...(notice?.detail.target ? [`신청대상(공지): ${notice.detail.target}`] : []), + ...partialCards.map( + (card) => + `현재 접수 중으로 표시된 세부 유형: ${card.name} (${card.status}, ${card.periodText || '접수기간 표시 없음'})`, + ), + ...application.checks, + ...exclusionSignals.map( + (line) => `제외 신호: '${line}'. 음식점·카페가 제외 대상인지 원 공고에서 확인해야 합니다.`, + ), + '대출한도·금리·기간은 기업평가와 보증·금융기관 심사로 정해집니다. 신청안내자료(첨부)에서 확인해야 합니다.', + ]; + + products.push({ + productKey: semasFundProductKey(row), + name: `${row.name.replace(/\s+\(/g, '(')}(${row.section})`, + organization: ORGANIZATION, + supportType: 'LOAN', + eligibleBusinessStages: ['POST_REGISTRATION'], + region: { scope: 'NATIONWIDE', districtCodes: [], note: null }, + purpose: { + included: [], + excluded: [], + note: '소진공 정책자금 표는 자금별 지원 용도를 구조화해 주지 않습니다. 신청안내자료에서 확인해야 합니다.', + }, + industryConditions: { + scope: 'UNKNOWN', + included: [], + excluded: [], + note: `소진공 공통 ${overview.excludedIndustries ?? '제외업종 표기 없음'}. 상권 업종 코드로 옮기지 않았으므로 신청안내자료에서 확인해야 합니다.`, + }, + applicationPeriod: application.period, + observedApplicationStatus: application.status, + observedAt: context.asOfDate, + reviewedAt: context.asOfDate, + nextReviewAt: null, + officialUrl: noticeUrl ?? SEMAS_OLS_URLS.overview, + sourceDocumentName: notice === null ? OVERVIEW_DOCUMENT : `소진공 정책자금 공지사항 · ${notice.detail.title}`, + sourceDocumentRetrieved: false, + sourceChecksum: null, + publicLimit: parseSemasSingleLimit(row.limit), + interestCondition: + overview.baseRate === null ? `대출금리 ${row.rate}` : `대출금리 ${row.rate} (${overview.baseRate})`, + interestRateConfirmed: false, + interestRatePercent: null, + repaymentCondition: `대출기간 ${row.term}`, + repaymentMethod: 'UNKNOWN', + repaymentTermMonths: null, + repaymentGraceMonths: null, + unsupportedCalculationReasons: [ + rateReason(row.rate), + '소진공 정책자금 표에는 상환방식이 없어 상환 계산 대상이 아닙니다.', + ], + additionalChecks, + evidence, + sourceRef: { source: 'SEMAS_OLS', externalId }, + }); + } + + const unmatchedCards = cards + .filter((card) => !matchedCards.has(card)) + .map((card) => `${card.section} '${card.name}' (${card.status})`); + return { products, duplicateRows, unmatchedCards }; +} + +function snippet(body: string): string { + return body.replace(/\s+/g, ' ').trim().slice(0, 200); +} + +export type SemasFetch = ( + url: string, + init: { method: 'GET' | 'POST'; headers: Record; body?: string; signal?: AbortSignal }, +) => Promise<{ status: number; text(): Promise }>; + +export type SemasOlsAdapterOptions = { + // User-Agent에 밝히는 운영자 연락처(FUNDING_SYNC_CONTACT, 예: 메일 주소나 웹 주소). 없으면 수집하지 않는다. + contact: string | undefined; + fetchImpl?: SemasFetch; + // 요청 사이 간격. SEMAS_OLS_MIN_REQUEST_INTERVAL_MS보다 짧게 줄 수 없다. + requestIntervalMs?: number; + retries?: number; + retryDelayMs?: number; + timeoutMs?: number; + // 테스트가 실제로 기다리지 않도록 바꿔 끼운다. + sleep?: (ms: number) => Promise; +}; + +class RetryableSemasError extends Error {} + +// fetch가 응답을 받기 전에 실패한 경우(연결·시간 초과)만 다시 시도한다. +function isNetworkError(error: Error): boolean { + return error.name === 'TypeError' || error.name === 'TimeoutError' || error.name === 'AbortError'; +} + +export function semasUserAgent(contact: string): string { + return `TrendBench-funding-sync/1.0 (+${contact})`; +} + +function normalizeContact(raw: string | undefined): string { + const contact = raw?.trim() ?? ''; + if (contact === '') { + throw new Error( + 'FUNDING_SYNC_CONTACT가 없어 소진공 정책자금 사이트를 수집하지 않았습니다. 크롤링 요청에는 서비스 이름과 운영자 연락처를 밝힙니다(ADR 0006 3절).', + ); + } + if (!/^[\x21-\x7e][\x20-\x7e]{0,199}$/.test(contact)) { + throw new Error('FUNDING_SYNC_CONTACT는 200자 이하의 영문·숫자·기호(ASCII)여야 합니다. 예: 메일 주소나 웹 주소'); + } + return contact; +} + +export function createSemasOlsAdapter(options: SemasOlsAdapterOptions): FundingSourceAdapter { + const fetchImpl: SemasFetch = options.fetchImpl ?? ((url, init) => fetch(url, init)); + const interval = Math.max(SEMAS_OLS_MIN_REQUEST_INTERVAL_MS, options.requestIntervalMs ?? 1500); + const retries = options.retries ?? 2; + const retryDelayMs = options.retryDelayMs ?? 5000; + const timeoutMs = options.timeoutMs ?? 30000; + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + return { + source: 'SEMAS_OLS', + async collect({ asOfDate, log }): Promise { + const userAgent = semasUserAgent(normalizeContact(options.contact)); + let requestCount = 0; + const pageDigests: string[] = []; + + async function request( + url: string, + init: { method: 'GET' | 'POST'; body?: string; headers?: Record }, + allowNotFound = false, + ): Promise<{ status: number; body: string }> { + let lastError = ''; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (requestCount > 0) + await sleep(attempt === 0 ? interval : Math.max(interval, retryDelayMs * 2 ** (attempt - 1))); + requestCount += 1; + try { + const response = await fetchImpl(url, { + method: init.method, + headers: { 'User-Agent': userAgent, ...init.headers }, + body: init.body, + signal: AbortSignal.timeout(timeoutMs), + }); + const body = await response.text(); + if (response.status === 403 || response.status === 429) { + // 차단은 우회하지 않고 다시 시도하지도 않는다(ADR 0006 3절). + throw new Error( + `소진공 정책자금 사이트가 요청을 거부했습니다(HTTP ${response.status}, ${url}). 이 출처를 실패로 기록하고 멈춥니다.`, + ); + } + if (response.status >= 500) throw new RetryableSemasError(`HTTP ${response.status}`); + if (allowNotFound && response.status === 404) return { status: response.status, body }; + if (response.status < 200 || response.status >= 300) { + throw new Error(`소진공 정책자금 사이트가 HTTP ${response.status}로 응답했습니다(${url}).`); + } + if (BLOCK_PAGE_PATTERN.test(body)) { + throw new Error( + `소진공 정책자금 사이트가 자동 입력 방지 화면을 보냈습니다(${url}). 이 출처를 실패로 기록하고 멈춥니다.`, + ); + } + return { status: response.status, body }; + } catch (error) { + if (!(error instanceof RetryableSemasError) && error instanceof Error && !isNetworkError(error)) + throw error; + lastError = error instanceof Error ? error.message : String(error); + } + } + throw new Error(`소진공 정책자금 사이트 요청이 ${retries + 1}번 실패했습니다(${url}): ${lastError}`); + } + + // 응답 본문과 그 SHA-256. 응답 전체의 checksum은 읽은 순서대로 페이지 checksum을 모아 만든다. + async function page( + url: string, + init: { method: 'GET' | 'POST'; body?: string; headers?: Record } = { method: 'GET' }, + ): Promise<{ body: string; checksum: string }> { + const { body } = await request(url, init); + const checksum = createHash('sha256').update(body, 'utf8').digest('hex'); + pageDigests.push(`${init.method} ${url} ${init.body ?? ''} ${checksum}`); + return { body, checksum }; + } + + // 2026-09-28 확인 때 robots.txt가 없었다. 생기거나 바뀌면 사람이 허용 여부를 다시 확인할 때까지 멈춘다. + const robots = await request(SEMAS_OLS_URLS.robots, { method: 'GET' }, true); + if (robots.status !== 404) { + throw new Error( + 'ols.semas.or.kr에 robots.txt가 생겼습니다(2026-09-28 확인 때는 없음). 허용 여부를 사람이 확인해 docs/domains/funding.md에 기록하기 전에는 수집하지 않습니다.', + ); + } + + const overviewPage = await page(SEMAS_OLS_URLS.overview); + const overview = parseSemasOverview(overviewPage.body); + const homePage = await page(SEMAS_OLS_URLS.home); + const cards = parseSemasHomeCards(homePage.body); + + const notices: SemasNotice[] = []; + let malformedNotices = 0; + let totalPages = 1; + let totalNotices = 0; + for (let pageNo = 1; pageNo <= Math.min(totalPages, SEMAS_OLS_MAX_NOTICE_PAGES); pageNo += 1) { + const form = new URLSearchParams({ bltwtrClcd: '', bltwtrTitNm: '', searchStd: '', pageNo: String(pageNo) }); + const listPage = await page(SEMAS_OLS_URLS.noticeList, { + method: 'POST', + body: form.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + }); + const parsed = parseSemasNoticePage(listPage.body); + if (pageNo === 1) { + totalPages = parsed.totalPages; + totalNotices = parsed.totalCount; + } + if (parsed.notices.length + parsed.malformed === 0 && pageNo <= totalPages) { + throw new Error( + `소진공 공지 목록 ${pageNo}쪽이 비어 있습니다(전체 ${totalPages}쪽). 잘린 응답은 실패로 기록합니다.`, + ); + } + notices.push(...parsed.notices); + malformedNotices += parsed.malformed; + } + if (totalPages > SEMAS_OLS_MAX_NOTICE_PAGES) { + log(`소진공: 공지 목록 ${totalPages}쪽 중 최근 ${SEMAS_OLS_MAX_NOTICE_PAGES}쪽만 읽었습니다.`); + } else if (notices.length + malformedNotices < totalNotices) { + throw new Error( + `소진공 공지 전체 ${totalNotices}건 중 ${notices.length + malformedNotices}건만 받았습니다. 잘린 응답은 실패로 기록합니다.`, + ); + } + + const selected = selectSemasFundNotices(overview.rows, notices); + const details = new Map(); + for (const row of overview.rows) { + const key = semasFundExternalId(row); + const notice = selected.get(key); + if (notice === undefined || details.has(key)) continue; + const detailPage = await page(semasNoticeUrl(notice)); + const detail = parseSemasNoticeDetail(detailPage.body); + if (!isSemasFundNotice(row, { ...notice, title: detail.title })) { + throw new Error( + `소진공 공지 ${notice.seq}의 상세 제목 '${detail.title}'이 목록 제목 '${notice.title}'과 맞지 않습니다.`, + ); + } + details.set(key, { notice, detail, checksum: detailPage.checksum }); + } + + const mapping = mapSemasFunds( + { + overview, + cards, + notices: details, + pageChecksums: { overview: overviewPage.checksum, home: homePage.checksum }, + }, + { asOfDate }, + ); + const open = mapping.products.filter((product) => product.observedApplicationStatus === 'OPEN').length; + log( + `소진공: 정책자금 표의 자금 ${overview.rows.length}건으로 상품 ${mapping.products.length}건을 만들었습니다(접수 중 ${open}건, 접수 안내 공지 연결 ${details.size}건, 공지 ${notices.length}건 확인${malformedNotices > 0 ? `, 형식 오류 공지 ${malformedNotices}건` : ''}${mapping.duplicateRows > 0 ? `, 중복 행 ${mapping.duplicateRows}건` : ''}).`, + ); + if (mapping.unmatchedCards.length > 0) { + log(`소진공: 정책자금 표에 없는 홈 화면 대출 카드: ${mapping.unmatchedCards.join(', ')}`); + } + const responseChecksum = createHash('sha256').update(pageDigests.join('\n'), 'utf8').digest('hex'); + return { products: mapping.products, fetchedCount: overview.rows.length, responseChecksum }; + }, + }; +} diff --git a/app/src/features/funding/validation.ts b/app/src/features/funding/validation.ts index 0293bf5..d079589 100644 --- a/app/src/features/funding/validation.ts +++ b/app/src/features/funding/validation.ts @@ -205,10 +205,12 @@ function checkProduct( `근거 '${entry.id}'의 확인일 ${entry.observedAt}이(가) 기준일 ${basisDate} 이후입니다.`, ); } - // 첨부·API만 원문 파일 checksum을 남길 수 있다. 웹 페이지 확인과 검색 요약은 - // 내려받은 파일이 없으므로 checksum이 null이어야 한다. + // 첨부·API만 원문 파일 checksum을 남길 수 있다. 사람이 확인한 웹 페이지와 검색 요약은 + // 내려받은 파일이 없으므로 checksum이 null이어야 한다. 자동 상품의 웹 페이지 근거는 + // funding:sync가 실제로 받은 응답이라 그 페이지의 checksum을 남길 수 있다(ADR 0006 2절). const fileBacked = entry.retrievalMethod === 'OFFICIAL_ATTACHMENT' || entry.retrievalMethod === 'OFFICIAL_API'; - if (!fileBacked && entry.checksum !== null) { + const crawledPage = automated && entry.retrievalMethod === 'OFFICIAL_WEB_PAGE'; + if (!fileBacked && !crawledPage && entry.checksum !== null) { collector.add( 'EVIDENCE_CHECKSUM_NOT_APPLICABLE', source, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d00295f..39cdf77 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,7 +33,7 @@ Operator load commands (*:load) | 임대료 참고 | `app/src/features/rent-benchmark` | rent-benchmarks API, `/`의 임대료 참고 패널 | `rent-benchmark.test.ts`, `rent-benchmark.spec.ts` | [rent-benchmark.md](domains/rent-benchmark.md) | | 프랜차이즈 참고 | `app/src/features/franchise` | franchises API, `/`의 프랜차이즈 참고 패널 | `franchise.test.ts`, `franchise.spec.ts` | [franchise.md](domains/franchise.md) | | 사업 조건 | `app/src/features/business-profile` | 계획 business-profile API, 입력 플로우 | `business-profile.test.ts`, `form.test.ts` | [business-profile.md](domains/business-profile.md) | -| 자금 카탈로그·후보 | `app/src/features/funding`, `app/catalog/funding` | `/funding`, candidates API, `funding:sync` | `funding.test.ts`, `candidates.test.ts`, `sync.test.ts`, `sources/bizinfo.test.ts` | [funding.md](domains/funding.md) | +| 자금 카탈로그·후보 | `app/src/features/funding`, `app/catalog/funding` | `/funding`, candidates API, `funding:sync` | `funding.test.ts`, `candidates.test.ts`, `sync.test.ts`, `sources/bizinfo.test.ts`, `sources/semas-ols.test.ts` | [funding.md](domains/funding.md) | | 인증·요청 보안 | `app/src/lib/auth.ts`, `session.ts`, `request-security.ts`, `rate-limit.ts` | `/api/auth/*`, 업무 API 공통 경계 | `request-security.test.ts`, 계획 API 테스트 | [security.md](domains/security.md) | ## 그 밖의 위치 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6b434b8..4ef0b06 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -26,7 +26,7 @@ npm --prefix app run dev | 파일 | 값 | |---|---| | `infra/.env` | `POSTGRES_PASSWORD`, `POSTGRES_PORT` | -| `app/.env.local` | `DATABASE_URL`, `BETTER_AUTH_SECRET`(32자 이상), `BETTER_AUTH_URL`, 상권 적재 시 `SEOUL_OPEN_API_KEY`, 점포·프랜차이즈 적재 시 `SEMAS_SERVICE_KEY`, 임대료 적재 시 `REB_API_KEY`, 자금 동기화 시 `BIZINFO_API_KEY` | +| `app/.env.local` | `DATABASE_URL`, `BETTER_AUTH_SECRET`(32자 이상), `BETTER_AUTH_URL`, 상권 적재 시 `SEOUL_OPEN_API_KEY`, 점포·프랜차이즈 적재 시 `SEMAS_SERVICE_KEY`, 임대료 적재 시 `REB_API_KEY`, 자금 동기화 시 `BIZINFO_API_KEY`·`FUNDING_SYNC_CONTACT` | | `app/.env.test.local` | `TEST_DATABASE_URL`(DB명 `trendbench_mvp_test`) | 직접 만들 때는 세 파일을 모두 만든다. 비밀번호는 같은 값을 쓰고 URL의 특수문자는 인코딩한다. 예시는 `infra/.env.example`과 `app/.env.example`에 있다. Prisma CLI와 적재 명령도 `app/.env.local`을 읽는다. @@ -43,7 +43,7 @@ npm --prefix app run dev | 공정위 프랜차이즈 | `SEMAS_SERVICE_KEY`(공공데이터포털 인증키)로 [가맹점 현황](https://www.data.go.kr/data/15110241/openapi.do)·[창업 금액](https://www.data.go.kr/data/15110265/openapi.do)을 활용신청한다. 약 20~30초 걸린다 | `npm --prefix app run franchise:load` | | 자금 카탈로그 | 준비 없음. 원본은 `app/catalog/funding/catalog.json`이다 | `npm --prefix app run funding:load` | -자동 수집 출처와 합쳐 자금 카탈로그를 적재하려면 `npm --prefix app run funding:sync`를 실행한다. 기업마당(www.bizinfo.go.kr)에서 지원사업정보 API 인증키를 발급받아 `BIZINFO_API_KEY`에 넣는다. 키가 없으면 기업마당 출처는 실패로 기록되고 직전 릴리스의 기업마당 상품이 그대로 남는다. `-- --dry-run`을 붙이면 합친 카탈로그를 쓰고 검증만 한다. 합친 카탈로그는 `data/funding-sync/`(Git 제외)에 남는다. 규칙은 [funding.md](domains/funding.md#동기화)에 있다. +자동 수집 출처와 합쳐 자금 카탈로그를 적재하려면 `npm --prefix app run funding:sync`를 실행한다. 기업마당(www.bizinfo.go.kr)에서 지원사업정보 API 인증키를 발급받아 `BIZINFO_API_KEY`에 넣는다. 키가 없으면 기업마당 출처는 실패로 기록되고 직전 릴리스의 기업마당 상품이 그대로 남는다. 소진공 정책자금 사이트(ols.semas.or.kr)는 키 없이 읽지만, 요청의 User-Agent에 밝힐 운영자 연락처(메일 주소나 웹 주소)를 `FUNDING_SYNC_CONTACT`에 넣어야 한다. 없으면 소진공 출처가 실패로 기록된다. 소진공 수집은 요청 사이를 1초 이상 띄워 약 40초 걸린다. `-- --dry-run`을 붙이면 합친 카탈로그를 쓰고 검증만 한다. 합친 카탈로그는 `data/funding-sync/`(Git 제외)에 남는다. 규칙은 [funding.md](domains/funding.md#동기화)에 있다. 2026-09-09 검증본 ZIP으로 적재하려면 [verification/README.md](verification/README.md)의 파일 5개를 `data/raw/`(Git 제외)에 두고 `npm --prefix app run market:load -- --source-dir ../data/raw`를 실행한다. `data/raw/`가 없으면 원본 대조 테스트 6개가 건너뛰어진다. diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index 6a1149f..1386422 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -285,3 +285,26 @@ - **중단 조건:** - 한 주라도 정책자금 오판이 표본의 20%를 넘으면 정책자금 자동 승격 스위치를 끈다. - 상환 계산 가능으로 올린 상품에서 금리·기간·한도 오추출이 한 건이라도 나오면, 상환 계산 자동 승격 스위치를 즉시 끈다. + +### 첫 실행 기록 (2026-09-29) + +- **실행:** 정기 실행 위치(#15)가 정해지기 전이라 운영자 로컬 DB에서 `funding:sync`를 실행했다. + - 결과는 `trendbench-funding-catalog@2026-09-29.1`, ACTIVATED다. + - 두 스위치(정책자금 자동 승격, 상환 계산 자동 승격)는 꺼져 있다. + - 소진공 출처의 접수 상태와 식별 규칙은 [ADR 0007](0007-semas-ols-status-and-fund-identity.md)을 따른다. + +| 출처 | 결과 | 받은 공고 수 | 자동 상품 수 | 그중 접수 OPEN | +|---|---|---|---|---| +| 소진공 정책자금 사이트(`SEMAS_OLS`) | 성공 | 13(정책자금 표의 자금 행) | 13 | 4 | +| 기업마당(`BIZINFO`) | 성공 | 1,514 | 84 | 38 | + +- **카탈로그 구성:** 사람이 기록한 상품 5건과 자동 상품 97건, 모두 102건이다. +- **갈래별 현재 후보 수:** 판정 기준일 2026-09-29, 자치구 11140, 업종 CS100001로 셌다. + +| 프로필 | 정책자금 | 지원사업 | 유형 미확인 | +|---|---|---|---| +| 예비 창업자(창업 준비 비용) | 현재 후보 0: 추가 확인 3, 사업자등록 이후 11, 접수 종료 2 | 현재 후보 0: 접수 종료 1, 검수 기한 경과 1 | 현재 후보 0: 추가 확인 84 | +| 사업자등록 이후(운전자금) | 현재 후보 0: 추가 확인 14, 접수 종료 2 | 현재 후보 0: 접수 종료 1, 검수 기한 경과 1 | 현재 후보 0: 추가 확인 84 | + +- **정책자금 자동 승격을 켰다면:** 사업자등록 이후 프로필의 정책자금 현재 후보는 4건이다(소공인특화자금, 대환대출, 상생성장지원자금, 민간투자연계형 매칭융자). 예비 창업자는 소진공 자금이 모두 사업자등록 이후 대상이라 그대로 0건이다. +- **상환 계산 가능:** 0건. 소진공 금리는 모두 기준금리 연동이거나 "고정금리(2.0%P)"처럼 모호하고, 표에 상환방식이 없다(5절). diff --git a/docs/decisions/0007-semas-ols-status-and-fund-identity.md b/docs/decisions/0007-semas-ols-status-and-fund-identity.md new file mode 100644 index 0000000..dbc9aef --- /dev/null +++ b/docs/decisions/0007-semas-ols-status-and-fund-identity.md @@ -0,0 +1,68 @@ +# ADR 0007: 소진공 정책자금은 홈 화면 접수 표시로 접수 상태를 정하고, 자금 단위로 식별한다 + +- Status: Accepted +- Date: 2026-09-29 +- Owners: jhjh0512 (자동 규칙·수집 출처·차단 목록 책임자) +- Related issue/PR: #5, [ADR 0006](0006-funding-discovery-automation.md) +- Supersedes: ADR 0006의 일부. 소진공 정책자금 사이트(`SEMAS_OLS`) 출처에 한해 2절의 "자동 상품은 출처와 공고 식별자로 식별한다"와 Consequences의 "4절 1번(신청기간)은 공지 상세 HTML의 접수 안내로 채운다"를 이 ADR로 바꾼다. ADR 0006의 나머지 결정은 그대로다. + +## Context + +ADR 0006은 소진공 정책자금 사이트(`ols.semas.or.kr`)를 정책자금 갈래의 크롤링 출처로 정했다. 수집기를 만들며 2026-09-29에 실제 페이지를 확인한 결과는 다음과 같다. + +- **공지로는 접수 중인지 알 수 없다.** + - 접수 안내 공지는 거의 모두 "□ 접수기간 : 2026. 10. 6.(화) 10:00 ~ 예산소진 시까지"처럼 종료일이 없다. ADR 0006 4절 1번은 이런 경우 "출처의 접수 상태가 '접수 중'으로 확인될 때만" 인정한다. 공지에는 그런 표시가 없다. + - 예산 소진으로 마감돼도 공지는 게시판에 남는다. 3분기 일반경영안정자금 공지(7/6 ~ 예산소진)가 그대로 있지만, 홈 화면 배너는 "1분기, 2분기, 3분기 접수는 예산 소진으로 마감"이라고 알린다. 공지만 보면 마감된 자금을 접수 중으로 판정하게 된다. +- **홈 화면에 접수 상태 표시가 있다.** + - 홈 화면(`/ols/man/SMAN010M/page.do`)의 "정책자금 신청하기"는 서버가 그린 HTML이다. 지금 접수 중인 자금마다 대출구분(직접·대리), 자금명, "접수중" 표시, 접수기간(예: "2026-01-05 ~ 자금소진시까지")을 보여 준다. + - 2026-09-29에는 소공인특화자금, 소상공인 대환대출, 상생성장지원자금, 민간투자연계형매칭융자와, 대상을 좁힌 세부 유형 3개(혁신성장촉진자금 2개, 일시적경영애로자금 1개)가 표시됐다. ADR 0006 3절이 허용한 같은 호스트의 공개 페이지다. +- **공지는 분기·월마다 새로 올라온다.** + - 같은 자금의 접수 안내가 새 공지 번호로 다시 게시된다. 예를 들어 일반경영안정자금(대리대출)은 320(1분기) → 354 → 380 → 404(4분기)다. + - 공지 번호로 상품을 식별하면 분기마다 같은 자금이 새 상품이 된다. 그러면 차단 목록(ADR 0006 4절 4번)과 사람 기록 우선(2절)이 분기마다 끊긴다. + +## Decision + +소진공 정책자금 사이트 출처에만 적용한다. 기업마당과 앞으로 추가할 출처는 ADR 0006을 따른다. + +1. **접수 상태의 근거는 홈 화면 접수 표시다.** + - 홈 화면 "정책자금 신청하기"에 그 자금이 자금 이름 그대로 "접수중"으로 표시될 때만 OPEN이다. + - 날짜가 있으면 판정 기준일과 공지 상세의 접수기간이 그 표시와 모순이 없어야 한다. 모순이 있으면 UNKNOWN이다. 모순은 시작일이 기준일 이후인 경우, 종료일이 지난 경우, 홈 화면과 공지의 시작일이나 종료일이 다른 경우다. + - 날짜 없는 '상시접수'도 "접수중" 표시가 있으면 OPEN이다(ADR 0006 4절 1번). + - 마감·종료 표시는 CLOSED다. + - 표시가 없거나 세부 유형 카드만 있으면 UNKNOWN이다. 세부 유형은 신청 전 확인 항목에 적는다. + - 공지 상세는 접수기간 교차 확인, 신청대상 문장, 원 공고 링크에 쓴다. 홈 화면 종료일이 없고 공지에 종료일이 있으면 공지 종료일을 쓴다. +2. **상품은 자금 단위로 식별한다.** + - 상품 하나는 "정책자금 한눈에 보기" 표의 자금 한 행이다. + - 출처 식별자는 `대출구분:자금명`이다. 자금명은 공백과 앞머리 '소상공인'을 뺀다(예: `대리대출:일반경영안정자금`). + - 차단 목록과 사람 기록 우선도 이 식별자로 맞춘다. + - 공지 번호는 공식 링크와 근거에 남긴다. + +## Alternatives considered + +- **공지 상세만으로 판정하고 엄격하게 적용:** 종료일 없는 공지는 4절 1번을 통과하지 못해 OPEN이 거의 0건이다. 정책자금 자동화의 효과가 없다. +- **공지 상세만으로 판정하고 "공지가 있으면 접수 중"으로 봄:** 예산 소진으로 마감된 자금이 현재 후보로 남는다. AGENTS.md §3의 8번과 충돌한다. +- **공지 번호로 식별(ADR 0006 2절 그대로):** 분기마다 상품이 바뀌어 차단 목록과 사람 기록 우선이 끊긴다. +- **소상공인24 내부 JSON API의 접수 상태 사용:** ADR 0006이 쓰지 않기로 한 출처다. + +## Consequences + +- **장점:** + - 예산 소진으로 마감된 자금이 접수 중으로 남지 않는다. 출처가 "접수중"을 명시할 때만 올린다. + - 자금 식별이 분기가 바뀌어도 유지되어, 차단과 사람 기록 우선이 계속 적용된다. +- **비용·위험:** + - 홈 화면은 배너가 자주 바뀌는 페이지다. "정책자금 신청하기" 영역을 찾지 못하면 파서가 필수 항목을 찾지 못한 것으로 보고 출처를 실패로 기록한다. 그러면 3일 뒤 소진공 상품이 현재 후보에서 빠진다. + - 홈 화면 카드의 자금명이 표와 다르게 바뀌면 그 자금은 UNKNOWN이 된다. 오판 방향이 아니라 보류 방향이다. + - 소진공이 자금 이름을 바꾸면 새 상품으로 잡힌다. 차단 목록도 다시 넣어야 한다. + - 상품 키는 식별자의 SHA-256 앞 10자리를 쓴다(`semas-ols-agency-…`). 사람이 키만 보고 자금을 알 수는 없다. +- **migration·호환성:** 스키마와 DB는 바뀌지 않는다. 식별자는 기존 `sourceRef.externalId` 자리에 들어간다. +- **운영·보안·데이터 영향:** 요청이 홈 화면 1회만큼 늘어난다. 한 번 동기화할 때 약 24회, 요청 간격 1.5초로 약 36초가 걸린다. 수집 규칙은 [funding.md](../domains/funding.md#소진공-정책자금-수집)에 있다. + +## Validation + +- `app/src/features/funding/sources/semas-ols.test.ts`가 다음을 검사한다. + - 접수중 카드와 공지가 맞을 때만 OPEN + - 세부 유형 카드만 있으면 UNKNOWN + - 날짜 모순, 마감 표시, 상시접수 + - 공지 고르기와 자금 단위 식별자 +- ADR 0006의 표본 점검 때 소진공 현재 후보마다 홈 화면 표시와 공지를 원문과 함께 대조한다. +- 홈 화면 영역을 찾지 못해 실패하는 일이 한 달에 두 번 이상 생기면, 접수 상태 근거를 다시 검토한다. diff --git a/docs/domains/funding.md b/docs/domains/funding.md index 6d22847..1a9bdcf 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -34,6 +34,7 @@ - 지원 유형 `UNKNOWN`(DB enum `FundingSupportType`에도 있다). 상환방식은 `UNKNOWN`이어야 하고 확정 금리·상환기간·거치를 기록할 수 없다. 금융 조건 근거를 요구하지 않는다. - `eligibleBusinessStages`의 빈 배열은 사업 단계 미확인이다. 이때 사업 단계 근거를 요구하지 않는다. - 자동 상품은 검수자에 사람 대신 자동 규칙 버전(`AUTO:funding-auto-v1`)을 적는다. 규칙 검사는 다음을 오류로 본다: 출처 식별자 없는 자동 검수자, 같은 공고를 가리키는 상품 두 개, 같은 출처의 동기화 결과 두 개. 자동 상품에는 다음 검토일이 없어도 된다. +- 웹 페이지 근거(`OFFICIAL_WEB_PAGE`)의 checksum은 사람이 기록한 상품에서는 오류다(내려받은 파일이 없다). 자동 상품에서는 funding:sync가 실제로 받은 응답이므로 그 페이지의 checksum을 남길 수 있다. ### 릴리스와 상품 버전 @@ -67,9 +68,43 @@ | 출처 | 대상 | 확인일 | 확인 결과 | 수집기 | |---|---|---|---|---| -| `SEMAS_OLS` | 소진공 정책자금 사이트 `ols.semas.or.kr` | 2026-09-28 | robots.txt 없음(404). 약관에 자동 수집 금지 조항 없음. 담당자가 출처 표시 조건으로 수집 허용 | 아직 등록하지 않음 | +| `SEMAS_OLS` | 소진공 정책자금 사이트 `ols.semas.or.kr` | 2026-09-28 | robots.txt 없음(404). 약관에 자동 수집 금지 조항 없음. 담당자가 출처 표시 조건으로 수집 허용. 2026-09-29에 robots.txt 404 재확인. 페이지의 ``는 검색 색인 지시라 수집 허용 여부와 무관하다 | `sources/semas-ols.ts` | | `BIZINFO` | 기업마당 지원사업정보 Open API `www.bizinfo.go.kr/uss/rss/bizinfoApi.do` | 2026-09-28 | 인증키를 발급하는 공식 Open API. 실제 응답 확인(ADR 0006 Context) | `sources/bizinfo.ts` | +#### 소진공 정책자금 수집 + +정책자금 갈래의 자동 수집 출처다(ADR 0006 1·3절). 접수 상태를 홈 화면 접수 표시로 정하고 상품을 자금 단위로 식별하는 것은 [ADR 0007](../decisions/0007-semas-ols-status-and-fund-identity.md)이 정한다. 첨부(PDF)는 읽지 않고, AI로 본문을 해석하지 않는다. 뽑은 값과 주소만 상품에 남기고 페이지 원문은 저장하지 않는다. + +- 읽는 곳(모두 로그인 없는 공개 페이지): + - 정책자금 한눈에 보기(`/ols/man/SMAN018M/page.do`): 직접대출·대리대출 표의 자금명·신청요건·대출기간·대출한도·대출금리, 공통 지원자격, 분기 기준금리. + - 홈 화면(`/ols/man/SMAN010M/page.do`)의 "정책자금 신청하기": 자금별 접수 상태 표시("접수중")와 접수기간. ADR 0006 4절 1번의 "출처의 접수 상태 표시"로 쓴다. + - 공지사항 목록 JSON(`/ols/man/SMAN051M/search.do`, POST): 자금별 최신 접수 안내 공지를 찾는 데만 쓴다. 조건 값은 읽지 않는다. 최근 30쪽까지 읽는다. + - 공지 상세(`/ols/man/SMAN052M/page.do?bltwtrSeq=…&bbsTypeCd=…`): 접수기간·신청대상 문장과 '제외'가 들어간 줄. +- 요청 규칙: + - 수집 전에 `/robots.txt`를 요청한다. 확인 때처럼 404가 아니면(새로 생기면) 멈춘다. 사람이 허용 여부를 확인해 위 표에 기록한 뒤 수집기를 고친다. + - 요청 사이 1초 이상 쉰다(기본 1.5초). User-Agent는 `TrendBench-funding-sync/1.0 (+FUNDING_SYNC_CONTACT)`다. 연락처가 없으면 요청하지 않고 실패한다. + - 403·429·자동 입력 방지 화면을 만나면 다시 시도하지 않고 멈춘다. 서버 오류·연결 오류만 두 번까지 다시 시도한다. +- 실패(필수 항목 없음): 자금 표·표 머리글·직접/대리 구분·공통 지원자격의 소상공인 문장·"정책자금 신청하기" 영역을 찾지 못함, 공지 목록이 JSON이 아니거나 비었거나 전체 건수보다 적음, 공지 상세의 제목·본문이 없거나 제목이 목록과 맞지 않음. 받은 공고 수(`fetchedCount`)는 표의 자금 행 수다. +- 상품 하나 = 표의 자금 한 행이다. 공지는 분기·월마다 새로 올라오므로 공지 번호 대신 대출구분과 자금명으로 식별한다. + - 출처 식별자는 `SEMAS_OLS:대출구분:자금명`(공백과 앞머리 '소상공인'을 뺀 이름, 예: `대리대출:일반경영안정자금`)이다. 차단 목록과 사람 기록 우선도 이 식별자로 맞춘다. + - 상품 키는 `semas-ols-direct-…`·`semas-ols-agency-…`와 식별자 SHA-256 앞 10자리다. + - 같은 자금이 표에 두 번 나오면 뒤 행을 빼고 센다. 표에 없는 홈 화면 대출 카드는 명령 출력에 나열한다. +- 공지 고르기: 같은 대출구분의 '대출정보' 공지 중 제목에서 자금명 바로 뒤에 (직접대출)·(대리대출)·(일반)과 '접수 안내'·'신청안내'만 오는 공지 가운데 번호가 가장 큰 것. '금리인하제도 신청안내'처럼 다른 제도의 안내와 '(홈플러스 피해 소상공인)'처럼 대상을 좁힌 세부 공고는 고르지 않는다. +- 상품으로 옮기는 값: + - 지원 유형 LOAN, 사업 단계 사업자등록 이후(공통 지원자격이 「소상공인 보호 및 지원에 관한 법률」상 소상공인), 지역 전국(공통 지원자격에 지역 제한이 없다는 이 출처의 규칙), 용도·업종 UNKNOWN(신청 전 확인), 기관 소상공인시장진흥공단. + - 금리는 `interestCondition`에 표의 금리와 기준금리 문장을 그대로 적고 확정 금리로 기록하지 않는다. '기준금리+가산'은 변동금리이고, '고정금리(2.0%P)'는 연 금리인지 가산폭인지 모호하다. 표에 상환방식이 없어 상환방식 UNKNOWN, 상환기간·거치 null이다. 따라서 상환 계산 대상이 아니다(ADR 0006 5절). + - 공개 한도는 대출한도 칸이 금액 하나일 때만 원 단위로 옮긴다(예: '7천만원'). 유형·용도별 금액이 여럿이면 null이다. + - 공식 링크는 고른 공지 상세, 없으면 정책자금 한눈에 보기다. + - 근거는 모두 `OFFICIAL_WEB_PAGE`이며, 근거마다 그 근거를 읽은 페이지 응답(한눈에 보기·홈 화면·공지 상세)의 SHA-256을 checksum으로 남긴다(ADR 0006 2절). 공지 목록까지 포함해 읽은 페이지 전체의 checksum은 출처별 동기화 결과의 `responseChecksum`에 남는다. 날마다 바뀌는 checksum은 상품 버전 비교에서 빠지므로 버전이 늘지 않는다. + - 신청대상 문장, 세부 유형만 접수 중인 카드, 상태 판정의 모순, 제외 신호는 신청 전 확인 항목에 적는다. +- 접수 상태: + - OPEN: 홈 화면에 그 자금(세부 유형이 아니라 자금 이름 그대로)이 "접수중"으로 표시되고, 시작일이 판정 기준일 이전이며, 종료일이 지나지 않았고, 공지의 접수 시작일·종료일과 어긋나지 않을 때만이다. 날짜 없는 '상시접수'도 "접수중" 표시가 있으면 OPEN이다. + - CLOSED: 홈 화면 표시가 '마감'·'종료'일 때. + - 그 밖은 UNKNOWN이다: 홈 화면에 표시가 없음, 세부 유형 카드만 있음, 알 수 없는 표시, 날짜 모순. 신청기간은 공지 접수기간으로 남기므로, 종료일이 지난 공지는 판정에서 접수 종료가 된다. + - 홈 화면 종료일이 없고(자금소진시까지) 공지에 종료일이 있으면 공지 종료일을 쓴다. +- 제외 신호: 공통 제외업종 또는 공지의 '제외' 줄에 음식점·외식·카페 등이 보이면 OPEN으로 기록하지 않고 확인 항목에 남긴다(ADR 0006 4절 3번·6절). 유흥주점 같은 표현은 신호로 보지 않는다. +- 판정: 사업자등록 이후 대상이라 예비 창업자에게는 사업자등록 이후 검토 그룹에 나온다. 정책자금 자동 승격 스위치가 켜지고 출처가 최신이며 OPEN일 때만 현재 후보가 된다. + #### 기업마당 공고 탐색 기업마당은 새 공고를 찾는 데만 쓴다(ADR 0006 1·2절). 여기서 만든 상품은 사업 단계·지원 유형을 알 수 없어 사람이 확인하기 전에는 추가 확인에 머문다. @@ -167,5 +202,6 @@ - DB 없이: 카탈로그 스키마·규칙 위반, 실제 카탈로그 5건의 규칙 통과와 모두 상환 계산 대상이 아님(`supported=false`), 조건 판정, 상환 계산 가능 여부, 상품 조건과 사용자 원금의 결합. 후보 조회·계획 조건 판정·상품 조건 적용 API의 경계(상태 코드)는 Prisma를 mock해 DB 없이 검증한다. - DB 없이: 자동 상품의 최신성·승격 보류·상환 계산 스위치, v1.0.0 릴리스 읽기, 자동 상품 규칙 검사, 동기화의 상품 버전 재사용 규칙, 사업 단계·지원 유형 미확인 상품의 규칙 검사와 판정, 지원 유형별 갈래와 갈래별 개수, 자동 상품의 출처 정보. - DB 없이(`sources/bizinfo.test.ts`): 기업마당 신청기간·지역 해석, 수집 범위와 사유별 제외, 만든 상품의 규칙 검사 통과와 추가 확인 판정, 요청 인자, 키 없음·403·429·재시도·JSON 아님·잘린 응답, 오류 문장의 키 가림. +- DB 없이(`sources/semas-ols.test.ts`, 사이트 원문이 아닌 HTML 조각 fixture): 소진공 접수기간 표기 해석과 모호한 표기 거부, 정책자금 표·홈 화면 접수 표시·공지 목록·공지 상세 해석과 필수 항목 누락 실패, 공지 고르기, 접수 상태 판정(세부 유형 카드, 날짜 모순, 마감 표시, 상시접수, 제외 신호), 모호한 금리·여러 금액 한도를 확정 값으로 옮기지 않음, 만든 상품의 규칙 검사 통과와 스위치·최신성에 따른 판정, 근거별 페이지 checksum, 요청 순서와 1초 이상 간격, 연락처 없음·robots.txt 생김·403·429·자동 입력 방지 화면·재시도·잘린 공지 목록. - `TEST_DATABASE_URL` 전용 DB: 카탈로그 적재, 실제 DB에서의 후보 조회·계획 조건 판정·상품 조건 적용, 출처 저장과 과거 결과 불변성. 합성 카탈로그만 만들고 끝나면 지우며 실행 전 ACTIVE 상태를 복원한다. - `TEST_DATABASE_URL` 전용 DB의 동기화(`sync.test.ts`): 합성 수집기로 적재, 버전 재사용과 새 버전, 출처 실패 시 직전 상품 유지, 빈 응답·급감, 검증 실패, 사람 기록 우선, dry run, 새 공고 목록, 기업마당 수집기(가짜 응답)로 유형 미확인 상품 적재와 추가 확인 판정.