Compare commits

..

11 Commits

Author SHA1 Message Date
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
f3587eb130 gov-scraper: 신청 체크리스트 성별/특수대상 필터 추가
- generate_checklist.js: 남성 기준 여성 전용 제외, 특수대상(장애인/보훈/다문화/북한이탈) 전용 제외
- 제목+주관기관 기준(본문 '우대' 가점 언급은 미검사로 오제거 방지)
- 지역 보완: 달구벌(=대구) 추가
- apply-checklist.md: 지역+연령+성별/대상 누적 적용 → 117건

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:03:07 +00:00
ffdcea009d gov-scraper: 신청 체크리스트 연령(46세) 필터 추가
- generate_checklist.js: 본문 연령 상한 추출(만 N세 이하/범위) → 46세 미만이면 제외
- 제목 '청년/대학생' = 청년한정 제외, 단 '중장년/만40+이상/연령무관' 신호 있으면 유지
- apply-checklist.md: 지역(서울) + 연령(46세) 적용 → 252→122건

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:57:23 +00:00
ad8d200474 gov-scraper: 신청 체크리스트 서울 거주 지역필터 적용
- generate_checklist.js: 서울 거주 기준 타 지역 한정 공고 제외(접두/주관기관 + 안전한 도·권역은 제목 본문까지)
- apply-checklist.md: 252→137건(타지역 115건 제외), 서울+전국 공고만 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:52:33 +00:00
afff7a4703 gov-scraper: 신청 체크리스트(apply-checklist.md) 추가
- docs/apply-checklist.md: 예비창업자 자격 + 현재 열린 공고 252건, 마감일 그룹별 체크박스 + URL
- scripts/generate_checklist.js: DB에서 체크리스트 재생성(추적 대상 docs/에 출력)
- 신청 완료 시 [x] 체크하며 진행, 스크립트로 갱신 가능

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:41:47 +00:00
e48a45bf71 gov-scraper: 제출용 사업계획서 완성본 추가(시장수치·경쟁사·출처 포함)
- docs/business-plans-full.md: 3개 앱 PSST + TAM/SAM/SOM + 경쟁사 비교표
- 시장조사(병렬 리서치) 반영: 출처·연도 병기, 추정치 명시
  - Tasteby: 외식 153조, 캐치테이블/식신, 숏폼 맛집 통계, 데이터바우처
  - Lyricsy: 언어학습 $837억, 한류 2.25억명, Duolingo, 가사 라이선스(LyricFind/KOMCA)
  - Parents Story: 초고령사회 20.3%, 고령친화 80조, 온디바이스 AI, 경쟁사 전부 클라우드

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:36:52 +00:00
cdce7b86bb gov-scraper: 마스터 사업계획서 + 공고 매칭/추출 스크립트 추가
- docs/business-plans.md: Tasteby/Lyricsy/Parents Story 3개 앱 PSST 사업계획서 초안
- scripts/match.js: 앱별 주제 키워드 매칭 조회
- scripts/eligible.js: 예비창업자 자격 + 현재 열린 공고 목록
- scripts/export_eligible_csv.js: 신청 추적용 CSV(exports/) 생성
- exports/ gitignore

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:24:39 +00:00
82504e2261 gov-scraper: 기업마당(bizinfo) Open API 소스 추가
- BizinfoApiSource: bizinfo.go.kr 자체 crtfcKey 사용, /uss/rss/bizinfoApi.do
- 페이지네이션 없음 → totCnt 파악 후 전체 일괄 요청(1,463건 검증)
- bsnsSumryCn(HTML) 본문 → stripHtml 로 태그 제거, 단일패스 적재(전건 DETAILED)
- reqstBeginEndDe "YYYY-MM-DD ~ ..." → 신청기간 파싱(706건), 텍스트형은 null
- util: stripHtml, parsePeriodRange 추가
- 데몬 4소스 가동: kstartup/bizinfo/mss/smes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:33:27 +00:00
f2a8f30867 gov-scraper: 중소벤처24(smes) 사업공고 소스 추가
- GenericHtmlSource 확장: 신청기간(period) 날짜 파싱, listOnly(목록 전용) 모드
- smes(중소벤처24 bizApply) config 추가 — href의 PBLN 공고ID 추출, 제목/분야/주관기관/신청기간 적재
- smes 상세는 팝업 전용(JS 다이얼로그)이라 직접 크롤 불가 → 목록 전용으로 적재(18건 검증)
- util: parseFlexibleDate(YY-MM-DD/YYYYMMDD 대응)
- pipeline: skipDetail 소스는 상세 단계 건너뜀

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 05:51:46 +00:00
cbc5ba5663 정부지원사업 공고 수집 데몬(gov-scraper) 추가
- government/ Node 데몬: Open API 우선 + HTML 보조 + 디스커버리 전략
- Strategy 패턴 소스 어댑터: KStartupApiSource(공공데이터 Open API), GenericHtmlSource(config 기반)
- sundol 3단계 폴백 크롤러(cheerio→Jina→Playwright CDP) Node 재구현, sundol-chrome(9222) 재사용
- Oracle thick 모드(Instant Client + sso 지갑) 접속, gov_source/gov_opportunity 적재(중복제거)
- K-Startup 29,017건 + 중기부(mss) 30건 적재 검증, PM2 gov-daemon 등록(60분 주기)
- 기업마당(bizinfo)은 자체 crtfcKey 발급 대기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 04:36:50 +00:00
5700449bfd docs: Chrome CDP 크롤링 장애 인시던트 보고서 추가
2026-05-18 웹크롤링/유튜브 자막 장애의 증상·진단·근본원인
(Chrome 136+ 기본 프로필 CDP 거부)·조치·검증·재발 점검
체크리스트를 docs/incident-2026-05-18-chrome-cdp-crawling.md 로 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 01:17:11 +00:00
32 changed files with 2797 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
# 인시던트 보고서: 웹크롤링/YouTube 자막 장애 (Chrome CDP)
- **발생 인지일**: 2026-05-18
- **장애 시작 추정일**: 2026-04-13 이후 (Chrome 자동 업데이트 시점)
- **영향 범위**: 웹크롤링 3차 폴백(Playwright), YouTube 자막 추출 전면 실패
- **상태**: 해결 완료 (배포 커밋 `9569309`)
---
## 1. 증상
웹크롤링이 예전에는 정상 동작했으나 어느 시점부터 "이상"해짐.
- 일반 웹페이지(Jsoup / Jina Reader로 처리 가능한 사이트)는 **정상** 동작 → "되긴 되는데"
- 봇 차단 사이트 및 **YouTube 자막 추출**은 **전부 실패** → "이상하네"
- 백엔드 자체는 정상 기동 (PM2 online, 장애 인지 시점 기준 21h+ uptime)
---
## 2. 시스템 구조 (배경)
웹크롤링은 3단계 폴백 구조입니다.
```
crawl(url)
├─ 1차: Jsoup (정적 HTML 파싱)
├─ 2차: Jina Reader API (https://r.jina.ai/)
└─ 3차: Playwright → PlaywrightBrowserService → 사용자 Chrome (CDP, 9222)
```
- `WebCrawlerService.crawl()` : 3단계 폴백 오케스트레이션
- `PlaywrightBrowserService` : `connectOverCDP("http://...:9222")`로 사용자 Chrome에 연결
- `YouTubeTranscriptService.fetchWithPlaywright()` : 자막 추출 시 동일 CDP 경로 사용
3차 폴백(Playwright)은 봇 판정 우회를 위해 **사용자가 로그인한 Chrome 세션**에 CDP로 붙는 것이 핵심 설계입니다. 따라서 Chrome이 `--remote-debugging-port=9222`로 떠 있고, 거기에 로그인 세션(쿠키)이 살아 있어야 합니다.
---
## 3. 진단 과정
| 확인 항목 | 결과 |
|---|---|
| `curl http://localhost:9222/json/version` | 연결 실패 |
| `pgrep -af remote-debugging-port` | 디버깅 포트로 뜬 Chrome 없음 |
| 백엔드 프로세스 | 정상 (PM2 online) |
| 백엔드 로그 | `connect ECONNREFUSED ::1:9222`, `Chrome CDP 재연결 실패` 반복 |
| 마지막 정상 CDP 연결 로그 | **2026-04-13** 이후 없음 |
| 실제 Chrome 프로세스 | **떠 있음** (단, `--remote-debugging-port` 인자 **없이** 일반 실행) |
| 재기동 시도 시 Chrome 출력 | `DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir.` |
진단 중 1차 가설(Chrome 프로세스 죽음, IPv6 `::1` resolve 문제)을 거쳐, 최종적으로 Chrome 콘솔 출력에서 **결정적 메시지**를 확인.
---
## 4. 근본 원인
**Chrome 136+ (장애 시점 147) 의 보안 정책**: 기본 프로필 디렉토리(`~/.config/google-chrome`)에서는 원격 디버깅(CDP)을 거부한다.
- `--user-data-dir`**기본 경로를 그대로 지정해도** 거부됨 → 반드시 **non-default(별도)** 디렉토리여야 함
- 2026-04-13 마지막 정상 동작 이후 **Chrome 자동 업데이트**로 이 정책이 적용됨
- Chrome 프로세스가 죽은 것이 아니라, **버전업으로 기본 프로필 + 원격 디버깅 조합이 영구 차단**된 것
- `--remote-debugging-pipe`는 Playwright `connectOverCDP`(HTTP/WS endpoint 필요)와 호환되지 않아 우회 불가
**부가 발견**
1. **자동 복구 불가 구조**: Chrome을 PM2가 관리하지 않아, 한번 죽거나 잘못 뜨면 영구 장애가 됨
2. **IPv6 함정**: 에러가 `::1:9222` (IPv6 localhost). `localhost`가 IPv6로 resolve되는데 Chrome은 기본적으로 IPv4 `127.0.0.1`에만 바인딩 → 표기 불일치 잠재 위험
---
## 5. 조치 내역
세 가지를 모두 적용했습니다.
### 5-1. 프로필 이동 + 기동 스크립트 (`start-chrome.sh` 신규)
봇 우회용 로그인 세션을 보존하기 위해 기존 프로필을 **이동(mv)**:
```bash
mv ~/.config/google-chrome ~/.config/google-chrome-cdp
```
`/home/opc/sundol/start-chrome.sh` 신규 작성:
- `DISPLAY=:1` (VNC 세션)
- 기존 동일 프로필 Chrome 종료 (graceful → 강제)
- 비정상 종료로 남은 stale 싱글톤 락(`SingletonLock` / `SingletonCookie` / `SingletonSocket`) 정리
- `exec`로 foreground 유지 → PM2 fork 모드가 프로세스 추적
- 기동 인자: `--user-data-dir=/home/opc/.config/google-chrome-cdp --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --no-first-run --no-default-browser-check --start-maximized`
### 5-2. PM2 상시화 (`ecosystem.config.cjs`)
`sundol-chrome` 앱 추가하여 PM2가 관리·자동 재기동:
```js
{
name: "sundol-chrome",
script: "./start-chrome.sh",
interpreter: "/bin/bash",
cwd: "/home/opc/sundol",
env: { DISPLAY: ":1" },
}
```
- `pm2 start ecosystem.config.cjs --only sundol-chrome``pm2 save`
- `pm2-opc` systemd unit **enabled** 확인 → 재부팅 시 자동 복원
### 5-3. IPv6 함정 제거 (`PlaywrightBrowserService.java`)
```diff
- private static final String CDP_URL = "http://localhost:9222";
+ private static final String CDP_URL = "http://127.0.0.1:9222";
```
Chrome 측도 `--remote-debugging-address=127.0.0.1`로 IPv4 명시 바인딩 → 양쪽을 IPv4로 통일하여 `localhost`의 IPv6 해석 변수 자체를 제거.
> 비고: Chrome `--remote-debugging-address`는 단일 주소만 지원하여 IPv4/IPv6 동시 바인딩 불가. 따라서 "양쪽 통일"은 IPv4(`127.0.0.1`)로 일치시키는 방식으로 달성.
---
## 6. 배포 (CLAUDE.md 배포 전 필수 절차 준수)
| 단계 | 결과 |
|---|---|
| 1. 컴파일 | 통과 (종료코드 0) |
| 2. PMD 정적 분석 | 6개 위반 검출, **전부 기존 코드** (변경 파일 `PlaywrightBrowserService.java` 위반 0). 규칙상 기존 위반은 보고만, 미수정 |
| 3. 코드 리뷰 | 변경은 상수 1줄. 재시도/중복적재/null/카운터 등 로직 영향 없음 |
| 4. 사용자 승인 | 승인 후 배포 진행 |
- 백엔드 재시작 (`pm2 restart sundol-backend`) → 부팅 로그 `사용자 Chrome에 CDP 연결 완료` 확인
- 커밋 `9569309``git push origin main` 완료
- 커밋 범위: `start-chrome.sh`, `ecosystem.config.cjs`, `PlaywrightBrowserService.java` 3개 파일만 (무관한 프론트엔드 변경 제외)
> `ecosystem.config.cjs`에는 이전 작업분(`frontend script: node → /usr/local/bin/node`)이 한 파일에 섞여 분리 불가하여 함께 커밋, 커밋 메시지에 명시함.
---
## 7. 검증 결과
| 검증 | 결과 |
|---|---|
| `curl http://127.0.0.1:9222/json/version` | `Chrome/147.0.7727.55` 정상 응답 |
| 포트 바인딩 | `LISTEN 127.0.0.1:9222` — IPv4 명시 바인딩 확인 |
| 백엔드 부팅 로그 | `CDP 연결: 1 contexts, 1 pages` / `사용자 Chrome에 CDP 연결 완료` |
| PM2 `sundol-chrome` | `status=online, restarts=0` (재시작 루프 없음), PPID=PM2 데몬 |
| CDP 페이지 제어 스모크 | `example.com` 새 탭 생성(type=page) → 제어 → 종료 성공 (Playwright `openPage`와 동일 메커니즘) |
| `/api/knowledge/youtube-transcript` (무인증) | HTTP 401 → 엔드포인트 정상 가동(인증만 필요) |
**미완 검증 (사용자 액션 필요)**: 실제 end-to-end(로그인 인증이 필요한 유튜브 자막/봇차단 페이지 본문 추출)는 인증 장벽으로 자동 수행 불가. 프론트엔드에서 1건 시도 시 백엔드 로그로 최종 확정 가능.
---
## 8. 운영 규칙 (재발 방지)
> **Chrome은 PM2 `sundol-chrome`만 관리한다. 수동으로 `google-chrome`를 실행하지 말 것.**
수동 인스턴스가 `~/.config/google-chrome-cdp` 프로필 락을 먼저 잡으면, PM2가 띄우는 Chrome이 CDP 포트를 열지 못해 **동일 장애가 재발**한다. 사용자가 평소 VNC에서 사용하는 Chrome도 **PM2가 띄운 그 창을 그대로 사용**한다.
---
## 9. 재발 시 점검 체크리스트
```bash
# 1) CDP 포트 응답 확인
curl -s http://127.0.0.1:9222/json/version
# 2) 디버깅 포트로 뜬 Chrome 프로세스 확인
pgrep -af 'remote-debugging-port=9222'
# 3) PM2 sundol-chrome 상태 (재시작 루프 여부)
npx pm2 list | grep sundol-chrome # status=online, ↺(restart) 비정상 증가 여부
# 4) 백엔드 로그에서 CDP 연결 상태
grep -aE 'CDP 연결 완료|CDP 연결 실패|ECONNREFUSED.*9222' ~/.pm2/logs/sundol-backend-out.log | tail
# 5) Chrome 콘솔 출력 (프로필 정책 위반 메시지 확인)
grep -a 'non-default data directory' ~/.pm2/logs/sundol-chrome-*.log
# 6) 수동 Chrome 인스턴스가 프로필 락을 잡고 있지 않은지
ls -la ~/.config/google-chrome-cdp/SingletonLock
pgrep -af '/opt/google/chrome/chrome' | grep -v -E 'crashpad|--type='
```
**복구 절차**: 수동 Chrome 전부 종료 → `npx pm2 restart sundol-chrome` → 위 1~4번 재확인.
---
## 10. 관련 파일·위치
| 항목 | 경로 |
|---|---|
| Chrome 기동 스크립트 | `/home/opc/sundol/start-chrome.sh` |
| PM2 설정 | `/home/opc/sundol/ecosystem.config.cjs` (`sundol-chrome` 앱) |
| CDP 연결 코드 | `sundol-backend/src/main/java/com/sundol/service/PlaywrightBrowserService.java` (`CDP_URL`) |
| 크롤링 폴백 | `sundol-backend/.../service/WebCrawlerService.java` |
| YouTube 자막 | `sundol-backend/.../service/YouTubeTranscriptService.java` |
| CDP 전용 프로필 | `/home/opc/.config/google-chrome-cdp` (로그인 세션 포함, 824MB) |
| 백엔드 로그 | `~/.pm2/logs/sundol-backend-out.log` |
| Chrome 로그 | `~/.pm2/logs/sundol-chrome-out.log` / `-error.log` |
| 백엔드 포트 | `8080` |
| VNC 디스플레이 | `:1` |
</content>
</invoke>

View File

@@ -31,5 +31,15 @@ module.exports = {
NEXT_PUBLIC_GOOGLE_CLIENT_ID: "906390686133-vpqsisodkg6uqui469hg8dhupbejoa0d.apps.googleusercontent.com",
},
},
{
name: "gov-daemon",
script: "src/daemon.js",
interpreter: "/usr/local/bin/node",
cwd: "/home/opc/sundol/government",
env: {
// Oracle Instant Client(thick 모드) 의존 라이브러리 경로
LD_LIBRARY_PATH: "/home/opc/oracle-ic/instantclient_23_26",
},
},
],
};

6
government/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
*.log
# DB 접속 net 설정(지갑 경로/접속 디스크립터) — 환경별 재생성
oracle-net/
# 생성 데이터(공고 추출 CSV 등) — 매일 갱신
exports/

83
government/README.md Normal file
View File

@@ -0,0 +1,83 @@
# 정부지원사업 수집 데몬 (gov-scraper)
한국 정부지원사업 공고를 주기적으로 수집해 Oracle DB(`gov_opportunity`)에 적재하는 Node.js 데몬.
## 전략
**Open API 우선 + HTML 보조 + 디스커버리 확장.** 정부지원사업 공고는 소수 허브 포털에
대부분 집계되므로, API가 있는 곳은 API로(안 깨짐), 없는 곳은 HTML로 긁고, 부족분은
디스커버리로 소스를 넓힌다.
## 아키텍처
```
src/
├── config.js 환경설정(루트 .env 로드)
├── bootstrap.js LD_LIBRARY_PATH(Instant Client) 보정 후 재실행
├── db.js Oracle thick 모드 접속(sso 지갑 재사용)
├── crawler/
│ ├── browser.js sundol-chrome(CDP 9222) 연결 — 기존 인프라 재사용
│ └── crawler.js 3단계 폴백(cheerio → Jina → Playwright) [Facade]
├── sources/ [Strategy] 소스별 어댑터
│ ├── base.js OpportunitySource 인터페이스
│ ├── kstartup.js K-Startup Open API (data.go.kr 서비스키)
│ ├── genericHtml.js config 기반 범용 HTML 게시판 스크래퍼
│ ├── htmlSources.js HTML 소스 config 목록(여기에 추가)
│ └── registry.js 가용 소스 집계(키 없는 소스 자동 제외)
├── store/ gov_source/gov_opportunity 적재(중복제거)
├── pipeline.js 목록 수집 → 적재 → 상세 본문 수집
├── daemon.js 주기 폴링 데몬(PM2)
└── cli.js 수동 실행(test-db / test-crawl / run-once)
```
- **중복 제거**: `(source_code, external_id)` 유니크 키. external_id 는 API 고유키(pbanc_sn 등)
또는 게시판 글번호.
- **상세 본문**: API 소스는 목록 단계에서 본문까지 한 번에 적재(단일 패스). HTML 소스는
목록 적재 후 detail_url 을 3단계 크롤러로 긁는 2-패스.
## DB 접속 (중요)
node-oracledb **thick 모드** + Oracle Instant Client 를 쓴다. 백엔드 JDBC 와 동일하게
자동로그인 지갑(`cwallet.sso`)을 재사용하므로 **지갑 비밀번호가 필요 없다**.
(thin 모드는 sso 를 못 읽어 지갑 비밀번호가 필요한데, 그 비밀번호는 어디에도 저장돼 있지 않음)
- Instant Client: `/home/opc/oracle-ic/instantclient_23_26` (`.env``ORACLE_IC_LIB_DIR`)
- net 설정: `government/oracle-net/` — 지갑의 `sqlnet.ora``WALLET_LOCATION`
`?/network/admin` 로 가리켜 instant client 가 sso 를 못 여는 문제를 보정한 전용 설정.
## 실행
```bash
cd government
node src/cli.js test-db # DB 접속 확인
node src/cli.js run-once kstartup # K-Startup 1회 수집
node src/cli.js run-once mss # 중기부 게시판 1회 수집
node src/cli.js run-once # 가용 소스 전체 1회
node src/cli.js test-crawl <url> # 크롤러 단독 테스트
# 데몬(PM2)
pm2 start /home/opc/sundol/ecosystem.config.cjs --only gov-daemon
pm2 logs gov-daemon
```
## 새 소스 추가
- **HTML 게시판**: `src/sources/htmlSources.js``HTML_SOURCE_CONFIGS` 에 항목 추가
(listUrl, rowSelector, externalId 정규식, detailUrl 템플릿). 코드 로직 수정 불필요.
- **API**: `src/sources/``OpportunitySource` 상속 어댑터 작성 후 `registry.js` 등록.
## 디스커버리 (소스 발굴)
데몬 자체는 웹 검색을 못 하므로, 신규 소스 발굴은 Claude(WebSearch)가 수행해
`htmlSources.js` 또는 `gov_source` 에 등록한다. 후보 목록은 `docs/sources-catalog.md` 참조.
## 구현된 소스
- `kstartup`(K-Startup Open API, ~29k건)
- `bizinfo`(기업마당 Open API, ~1.5k건 — 본문 포함 단일패스)
- `mss`(중기부 게시판 HTML)
- `smes`(중소벤처24 HTML 목록전용 — 상세는 팝업 전용이라 목록만 적재)
## TODO
- 지자체/부처 게시판 추가(GenericHtmlSource config).

54
government/db/schema.sql Normal file
View File

@@ -0,0 +1,54 @@
-- 정부지원사업 스크래퍼 스키마
-- 기존 sundol 컨벤션 준수: snake_case 테이블, RAW(16) id(SYS_GUID()), TIMESTAMP(SYSTIMESTAMP)
-- 실행: SQLcl 에서 @government/db/schema.sql
-- ============================================================
-- gov_source : 공고 소스(사이트) 목록. Strategy 어댑터가 이 행을 읽어 동작한다.
-- ============================================================
CREATE TABLE gov_source (
id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
code VARCHAR2(50) NOT NULL, -- 어댑터 식별자 (예: kstartup, bizinfo, smes)
name VARCHAR2(200) NOT NULL, -- 표시명
base_url VARCHAR2(500), -- 기준 URL
type VARCHAR2(20) NOT NULL, -- API | HTML
config CLOB, -- 어댑터 설정(JSON): endpoint, params, selectors 등
active NUMBER(1) DEFAULT 1 NOT NULL, -- 1=활성, 0=비활성
last_crawled_at TIMESTAMP,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT gov_source_code_uq UNIQUE (code),
CONSTRAINT gov_source_type_ck CHECK (type IN ('API', 'HTML')),
CONSTRAINT gov_source_active_ck CHECK (active IN (0, 1))
);
-- ============================================================
-- gov_opportunity : 수집된 공고. (source_code, external_id) 로 중복 제거.
-- external_id 는 항상 채운다. HTML 소스는 detail_url 해시로 채운다.
-- ============================================================
CREATE TABLE gov_opportunity (
id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
source_id RAW(16) NOT NULL,
source_code VARCHAR2(50) NOT NULL, -- 비정규화(조회 편의)
external_id VARCHAR2(200) NOT NULL, -- 소스 고유 키(pbancSn 등) 또는 detail_url 해시
title VARCHAR2(1000 CHAR) NOT NULL,
agency VARCHAR2(300 CHAR), -- 소관/주관기관
category VARCHAR2(200 CHAR), -- 지원분야
target VARCHAR2(1000 CHAR), -- 지원대상
apply_start DATE,
apply_end DATE,
detail_url VARCHAR2(1000),
body_text CLOB, -- 상세 본문(스크랩)
raw_json CLOB, -- 원본 API/스크랩 데이터
status VARCHAR2(20) DEFAULT 'LISTED' NOT NULL, -- LISTED | DETAILED | CLOSED | ERROR
list_collected_at TIMESTAMP, -- 목록 수집 시각
detail_collected_at TIMESTAMP, -- 상세 수집 시각
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT gov_opp_source_fk FOREIGN KEY (source_id) REFERENCES gov_source (id),
CONSTRAINT gov_opp_dedup_uq UNIQUE (source_code, external_id),
CONSTRAINT gov_opp_status_ck CHECK (status IN ('LISTED', 'DETAILED', 'CLOSED', 'ERROR'))
);
CREATE INDEX gov_opp_status_ix ON gov_opportunity (status);
CREATE INDEX gov_opp_apply_end_ix ON gov_opportunity (apply_end);
CREATE INDEX gov_opp_source_ix ON gov_opportunity (source_id);

View File

@@ -0,0 +1,126 @@
# 정부지원사업 신청 체크리스트
> 생성일: 2026-06-11 · 대상: **예비창업자 + 현재 신청 가능 + 서울 거주 + 만 46세 + 남성**(타 지역·청년·여성/장애인/보훈 등 전용 제외) 공고 (총 109건)
> 신청을 마치면 `[ ]` → `[x]` 로 체크하세요. 갱신: `LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/generate_checklist.js`
> ⚠️ 마감 "시각"과 정확한 자격요건은 각 공고 원문에서 반드시 확인하세요.
## 🔴 이번 주 마감 (D-0 ~ D-7) — 17건
- [ ] **D-0** (2026-06-11) [시설ㆍ공간ㆍ보육] [2026년 키친인큐베이터 도쿄 외식산업 박람회 참가기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177683) — (재)서울경제진흥원
- [ ] **D-0** (2026-06-11) [사업화] [2026년 하반기 「IBK창공(創工)UNIST캠프」 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177498) — IBK기업은행
- [ ] **D-1** (2026-06-12) `벙커샷 파트너스` [사업화] [[벙커샷 파트너스] 벤처스튜디오 프로그램 참가기업 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177666) — (주)벙커샷컴퍼니
- [ ] **D-3** (2026-06-14) [멘토링ㆍ컨설팅ㆍ교육] [창업특강:AI를 활용한 사업 운영 효율화](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177903) — 관악구청
- [ ] **D-3** (2026-06-14) [시설ㆍ공간ㆍ보육] [[경희대학교 창업보육센터(서울)] 2026년 상반기 신규 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177914) — 경희창업보육센터
- [ ] **D-3** (2026-06-14) `국비지원` [멘토링ㆍ컨설팅ㆍ교육] [[국비지원] AI 기반 서비스 개발·사업화 1인 창업가 캠프 5기 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177662) — 넥스트러너스 주식회사
- [ ] **D-3** (2026-06-14) `서울소셜벤처허브` [시설ㆍ공간ㆍ보육] [[서울소셜벤처허브] 2026년 서울소셜벤처허브 2차 신규 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177952) — 서울소셜벤처허브
- [ ] **D-3** (2026-06-14) [시설ㆍ공간ㆍ보육] [2026년 하반기 광진경제허브센터 신규 입주기업 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177650) — 광진경제허브센터장
- [ ] **D-4** (2026-06-15) [창업] [2026년 로봇 기반 공간컴퓨팅 창업지원 예비창업 및 초기창업기업 지원기업 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000122035) — 과학기술정보통신부
- [ ] **D-4** (2026-06-15) [멘토링ㆍ컨설팅ㆍ교육] [2026 KAIST OverEdge(오엣) 참가자 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177854) — 한국과학기술원(KAIST) 창업원
- [ ] **D-4** (2026-06-15) [시설ㆍ공간ㆍ보육] [중앙대-금천구 창업 협력공간(WB가산타워 7층) 입주자 모집 공고(4차)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177924) — 중앙대학교 산학협력단
- [ ] **D-4** (2026-06-15) [시설ㆍ공간ㆍ보육] [2026년도 2차 동대문구 창업지원센터 입주자 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177932) — 동대문구 창업지원센터
- [ ] **D-4** (2026-06-15) [사업화] [26년 6월 스타트업 언론 홍보 지원사업 참가사 모집 공고(1차)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177857) — 스타트업 데일리
- [ ] **D-5** (2026-06-16) [행사ㆍ네트워크] [클리마 살롱 : 공정 혁신과 제조 에너지 최적화](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177945) — (주) 블루포인트파트너스
- [ ] **D-6** (2026-06-17) [시설ㆍ공간ㆍ보육] [중장년 아이디어(기술) 창업, 정부지원사업으로 시작하고 생성형 AI로 준비하기](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178035) — 성북구 중장년 기술창업센터
- [ ] **D-7** (2026-06-18) [창업교육] [[서울과학기술대학교]레이저커팅기 장비교육](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177989) — 서울과학기술대학교
- [ ] **D-7** (2026-06-18) [글로벌] [2026 한-아프리카 스타트업 경진대회 참가자(팀) 모집 (~6.18.(목))](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178068) — 한·아프리카재단
## 🟡 2주 내 (D-8 ~ D-14) — 13건
- [ ] **D-8** (2026-06-19) [사업화] [2026 강남구청 미래산업기반 지원사업](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177840) — 더인벤션랩
- [ ] **D-8** (2026-06-19) [사업화] [기술자료 임치 지원사업 1차 참여기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=176520) — 한국고용정보원
- [ ] **D-8** (2026-06-19) [사업화] [2026 대우건설 Hyper Safety & AI 오픈 이노베이션](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177849) — 대우건설
- [ ] **D-10** (2026-06-21) [시설ㆍ공간ㆍ보육] [2026년 영산대 중장년 기술창업센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178020) — 영산대 중장년 기술창업센터장
- [ ] **D-10** (2026-06-21) [시설ㆍ공간ㆍ보육] [[홍익대학교 아트앤디자인테크 창업보육센터] 2026년 1차 신규 입주기업 모집(~6/21(일)23:59까지)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178064) — 홍익대학교
- [ ] **D-11** (2026-06-22) [사업화] [2026년 디지털 기업 창업·투자 및 글로벌 역량강화 프로그램 참가기업 모집공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178038) — (주)비티비벤처스
- [ ] **D-11** (2026-06-22) [창업교육] [생성형 AI 활용 사업개발자(창업가) 육성과정 7기 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177487) — 디지플래닛
- [ ] **D-11** (2026-06-22) [창업교육] [[서울과학기술대학교] 제조창업 장비교육](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177990) — 서울과학기술대학교
- [ ] **D-11** (2026-06-22) [사업화] [2026 KAIST 기후테크 전 국민 오디션 참가자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177969) — 한국과학기술원
- [ ] **D-12** (2026-06-23) [시설ㆍ공간ㆍ보육] [NEST 해운대 5기 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177982) — 신용보증기금 스케일업금융센터
- [ ] **D-13** (2026-06-24) [멘토링ㆍ컨설팅ㆍ교육] [2026년 강동 K-ISS창업멘토링센터 2차 교육 및 튜토링 과정](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178050) — 주식회사 이노시아
- [ ] **D-14** (2026-06-25) [시설ㆍ공간ㆍ보육] [2026 관악S밸리 성장 지원 세미나 2회](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178060) — (재)관악중소벤처진흥원
- [ ] **D-14** (2026-06-25) `교육안내` [시설ㆍ공간ㆍ보육] [[교육안내]서울용산시제품제작소 메이커 장비교육 일정 및 신청_6월](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177884) — 한국전자정보통신산업진흥회
## 🟢 여유 (D-15 이상) — 38건
- [ ] **D-15** (2026-06-26) [창업] [2026년 WoW! 메이커스 투자유치 아카데미 모집 공고(메이커스페이스 전문랩 운영사업)](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000123044) — 중소벤처기업부
- [ ] **D-15** (2026-06-26) [창업] [2026년 서울뷰티위크 비즈니스 밋업 피칭대회 참가기업 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000122398) — 서울특별시
- [ ] **D-15** (2026-06-26) [멘토링ㆍ컨설팅ㆍ교육] [「2026 업사이클·친환경 창업경진대회」 참가자 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178061) — 광명업사이클아트센터
- [ ] **D-15** (2026-06-26) [창업교육] [「2026 국방기술을 활용한 창업경진대회」 사업설명회 참가자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177939) — 주식회사 베타랩
- [ ] **D-15** (2026-06-26) [멘토링ㆍ컨설팅ㆍ교육] [[도봉구청년창업센터] 제5차 스케일업 아카데미 : 선택받는 아이디어의 비밀, 모두의창업 2기 실전 지원 전략](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178053) — 도봉구 청년창업센터장
- [ ] **D-15** (2026-06-26) [사업화] [2026년 대덕특구 이노폴리스캠퍼스 액셀러레이팅 지원사업](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177983) — (주)로우파트너스
- [ ] **D-19** (2026-06-30) [창업] [2026년 공공기술ㆍ대학기술 기반 컴퍼니 빌딩 배치 프로그램 TechBridge Batch 프로그램 1기 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000122978) — 과학기술정보통신부
- [ ] **D-19** (2026-06-30) [창업] [2026년 국방기술을 활용한 창업경진대회 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000122898) — 방위사업청
- [ ] **D-19** (2026-06-30) [기술] [2026년 2차 서울테크노파크 수요기술조사사업 참여기업 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000121027) — 서울특별시
- [ ] **D-19** (2026-06-30) [내수] [2026년 ICT 스마트 디바이스 전국 공모전 참가 모집 공고(지능 온디바이스 혁신 아이디어 발굴)](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000122618) — 과학기술정보통신부
- [ ] **D-19** (2026-06-30) [사업화] [2026 벤처확인 인증준비기업 맞춤형 무료 진단 지원사업](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177892) — (주)엠비즈플래닛 산하 혁신기술경영인증지원센터
- [ ] **D-19** (2026-06-30) [사업화] [제5회 원자력 창업 아카데미「Atomic NEST」참가팀 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178051) — 블루포인트파트너스
- [ ] **D-19** (2026-06-30) [시설ㆍ공간ㆍ보육] [한국외국어대학교 창업보육센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178069) — 한국외국어대학교 연구산학협력단
- [ ] **D-19** (2026-06-30) [행사ㆍ네트워크] [「2026 국방기술을 활용한 창업경진대회」 참여기업 및 참여팀 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177937) — 주식회사 베타랩
- [ ] **D-19** (2026-06-30) [시설ㆍ공간ㆍ보육] [2026년도 국가물산업클러스터 창업보육실 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177953) — 국가물산업클러스터
- [ ] **D-19** (2026-06-30) [사업화] [2026년 ICT 스마트 디바이스 전국 공모전](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177880) — 스마트기술진흥협회
- [ ] **D-19** (2026-06-30) [행사ㆍ네트워크] [2026년 스타 IR 데모데이 참여기업 모집공고(2차)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177826) — 스타에셋파트너스 주식회사
- [ ] **D-19** (2026-06-30) [사업화] [2026년 공공기술·대학기술 기반 컴퍼니 빌딩 배치 프로그램(TechBridge Batch) 1기 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178040) — 씨엔티테크
- [ ] **D-21** (2026-07-02) [사업화] [[한국투자액셀러레이터] 바른동행 SEED 투자 프로그램 참여기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=178006) — 한국투자액셀러레이터
- [ ] **D-24** (2026-07-05) [행사ㆍ네트워크] [[BMC창업보육센터] 2026년 바이오·메디컬 히든스타 창업아이템 경진대회 모집 공고(~7/5(일), 자정까지)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177685) — 동국대학교 BMC창업보육센터
- [ ] **D-26** (2026-07-07) [창업] [2026년 병무청ㆍ방위사업청ㆍ질병관리청 합동 공공데이터ㆍAI 활용 경진대회 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000122113) — 병무청
- [ ] **D-29** (2026-07-10) [사업화] [2026-2027 혁신적 기술 프로그램(CTS) 신규사업 공모 안내](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177526) — KOICA 기업협력사업팀
- [ ] **D-36** (2026-07-17) [창업] [2026년 해양수산 창업 콘테스트 참가자 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000123062) — 해양수산부
- [ ] **D-50** (2026-07-31) [사업화] [12대 국가전략기술 분야 기술이전 수요 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177978) — (주)윕스
- [ ] **D-50** (2026-07-31) [사업화] [서울바이오허브 '2026 서울-BMS 이노베이션 스퀘어 챌린지' 참여기업 모집공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177877) — 한국과학기술연구원 서울바이오허브
- [ ] **D-81** (2026-08-31) [사업화] [2026 전략기술 딥테크 창업 촉진 참여기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=176179) — (주)제이엔피글로벌
- [ ] **D-81** (2026-08-31) [행사ㆍ네트워크] [『올해의 K-스타트업 2026 (舊 도전! K-스타트업)』 부처 통합 창업경진대회 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=176910) — 9개 관계부처 합동
- [ ] **D-81** (2026-08-31) [사업화] [2026 관악S밸리 GROW-UP 기술컨설팅 참여기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177505) — (재)관악중소벤처진흥원장
- [ ] **D-111** (2026-09-30) [기술개발(R&D)] [2026 위치정보(위치기반) 사업자를 위한 클라우드 지원사업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=175808) — 디엔에이클라우드
- [ ] **D-172** (2026-11-30) [시설ㆍ공간ㆍ보육] [2026년 창업·벤처 녹색융합클러스터 그린아이디어랩 비상주오피스 이용자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=176316) — 한국환경산업기술원
- [ ] **D-203** (2026-12-31) [창업] [2026년 농식품 크라우드펀딩 지원사업 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000119567) — 농림축산식품부
- [ ] **D-203** (2026-12-31) [멘토링ㆍ컨설팅ㆍ교육] [2026년 보건의료빅데이터 창업 인큐베이팅 랩 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177072) — 건강보험심사평가원
- [ ] **D-203** (2026-12-31) [시설ㆍ공간ㆍ보육] [남서울대학교 창업보육센터 입주기업 모집(천안소재)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177357) — 남서울대학교 창업보육센터
- [ ] **D-203** (2026-12-31) [시설ㆍ공간ㆍ보육] [「한동대학교 제네시스랩」 스타트업 모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177837) — 한동대학교 총장
- [ ] **D-203** (2026-12-31) [멘토링ㆍ컨설팅ㆍ교육] [2026년 스타트업 원스톱 지원센터 참여기업 모집공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=177328) — 중소벤처기업부장관
- [ ] **D-203** (2026-12-31) [기술개발(R&D)] [2026년 3D프린팅 전문기술 활용지원 사업](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=176442) — 3D프린팅혁신성장센터
- [ ] **D-203** (2026-12-31) [사업화] [2026년 중앙부처 및 지자체 창업지원사업 통합공고](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=175783) — 중소벤처기업부장관
- [ ] **D-203** (2026-12-31) [멘토링ㆍ컨설팅ㆍ교육] [2026년 스타트업 법률지원사업 참여기업 모집공고(수정)](https://www.k-startup.go.kr/web/contents/bizpbanc-ongoing.do?schM=view&pbancSn=176938) — 중소벤처기업부
## ⏳ 상시 접수 (마감 압박 없음) — 41건
- [ ] **상시** (상시/예산소진) [경영] [2026년 비즈니스지원단(현장클리닉) 사업 운영계획 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000121291) — 중소벤처기업부
- [ ] **상시** (상시/예산소진) [창업] [넷제로 챌린지X 통합 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000116975) — 탄소중립녹색성장위원회
- [ ] **상시** (상시/예산소진) [창업] [로봇분야 예비창업자 및 재창업자를 위한 창업 성장 프로그램 참가자 모집 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000121523) — 산업통상부
- [ ] **상시** (상시/예산소진) [창업] [2026년 스타트업 법률지원사업 참여기업 모집 수정 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000121175) — 중소벤처기업부
- [ ] **상시** (상시/예산소진) [창업] [2026년 Startup:D 창업 BuS 참가자 사전 모집 공고(대전창조경제혁신센터)](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000119051) — 중소벤처기업부
- [ ] **상시** (상시/예산소진) [창업] [2026년 중앙부처 및 지자체 창업지원사업 통합 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000116904) — 중소벤처기업부
- [ ] **상시** (상시/예산소진) [경영] [2026년 중소벤처기업부 소상공인 지원사업 통합 공고](https://www.bizinfo.go.kr/sii/siia/selectSIIA200Detail.do?pblancId=PBLN_000000000117016) — 중소벤처기업부
- [ ] **상시** (상시/예산소진) [멘토링ㆍ컨설팅ㆍ교육] [2017년도 수요피칭데이100번가의 톡’ 참가자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=78783) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [(사)벤처기업협회 SVI 신규입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=85126) — 민간
- [ ] **상시** (상시/예산소진) [사업화] [2018년 창업성장기술개발사업 크라우드펀딩 연계형 기술창업지원과제 시행계획 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=85355) — 공공기관
- [ ] **상시** (상시/예산소진) [사업화] [2018년도 경상북도 IP디딤돌 프로그램 사업공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=85849) — 지자체
- [ ] **상시** (상시/예산소진) [창업교육] [2018년도 소상공인 유망업종 희망아카데미 교육생 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=87316) — 지자체
- [ ] **상시** (상시/예산소진) [행사ㆍ네트워크] [2019 해양수산 창업설명회 사전수요조사 설문 안내](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=101255) — 공공기관
- [ ] **상시** (상시/예산소진) [사업화] [2017년도 정부 창업지원사업 통합공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=77617) — 공공기관
- [ ] **상시** (상시/예산소진) [판로ㆍ해외진출] [2018년 국제 지재권분쟁 예방컨설팅 지원사업 변경공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=87485) — 공공기관
- [ ] **상시** (상시/예산소진) [사업화] [2018년도 과학기술정보통신부 창업·벤처 지원 K-Global 프로젝트 사업 통합 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=84741) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [2016 라이프케어 1인 창조기업 비즈니스센터 신규 입주업체 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=73314) — 지자체
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [2016년 라이프케어 1인 창조기업 비즈니스센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=72387) — 교육기관
- [ ] **상시** (상시/예산소진) [멘토링ㆍ컨설팅ㆍ교육] [2018년 IP 디딤돌 프로그램 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=85097) — 공공기관
- [ ] **상시** (상시/예산소진) [정책자금] [신용보증기금 1인 창조기업 키움보증 프로그램 신청 안내](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=90260) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [아이빌트 창업보육센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=103166) — 민간
- [ ] **상시** (상시/예산소진) [행사ㆍ네트워크] [Next Rise Seoul 2019 참가자 및 상담기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=100248) — 민간
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [경기벤처창업지원센터(파주) 개방형 창업공간 이용자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=75554) — 공공기관
- [ ] **상시** (상시/예산소진) [사업화] [국토교통 창업인큐베이팅 참가(입주)자 상시모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=97556) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [목원대학교 창업진흥센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=92396) — 교육기관
- [ ] **상시** (상시/예산소진) [멘토링ㆍ컨설팅ㆍ교육] [서울창조경제혁신센터 멘토링 프로그램 참가자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=76444) — 공공기관
- [ ] **상시** (상시/예산소진) [창업교육] [『2018년 창업보육 전문인력 역량강화 계획』공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=85258) — 공공기관
- [ ] **상시** (상시/예산소진) [판로ㆍ해외진출] [국제 지재권분쟁 예방컨설팅 지원사업 수시 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=75274) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [메트로 1인창조기업 지원센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=100531) — 민간
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [수출BI 활용 글로벌 창업특화BI 입주지원사업](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=88105) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [칠곡군 시니어 기술창업센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=73066) — 지자체
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [한국산업개발연구원 창업보육센터 BLUE BI 입주업체 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=71208) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [한국전기연구원 창업보육센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=88883) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [한성케이에스콘(주) 1인 창조기업 비즈니스센터 입주기업 모집공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=77660) — 민간
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [경기벤처창업지원센터(양주) 개방형 창업공간 이용자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=75553) — 공공기관
- [ ] **상시** (상시/예산소진) [사업화] [서울산업진흥원 3D 프린터 활용 시제품 제작지원사업 참가기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=75148) — 공공기관
- [ ] **상시** (상시/예산소진) [멘토링ㆍ컨설팅ㆍ교육] [원스톱 창업상담 신청 안내](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=98570) — 지자체
- [ ] **상시** (상시/예산소진) [멘토링ㆍ컨설팅ㆍ교육] [창업/창작 심층컨설팅- 멘토가먼데이 참가자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=77073) — 공공기관
- [ ] **상시** (상시/예산소진) [멘토링ㆍ컨설팅ㆍ교육] [하드웨어창업 수요멘토링 Focus-Day 참가자 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=82447) — 공공기관
- [ ] **상시** (상시/예산소진) [시설ㆍ공간ㆍ보육] [한국세라믹기술원 이천분원 창업보육센터 입주기업 모집](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=88395) — 공공기관
- [ ] **상시** (상시/예산소진) [판로ㆍ해외진출] [한국저작권위원회 마닐라사무소 인큐베이팅센터 입주모집 공고](https://www.k-startup.go.kr/web/contents/bizpbanc-deadline.do?schM=view&pbancSn=76509) — 공공기관

View File

@@ -0,0 +1,165 @@
# 제출용 사업계획서 완성본 (3개 앱)
PSST(문제인식–실현가능성–성장전략–팀구성) 구조 + 시장규모(TAM/SAM/SOM)·경쟁사 분석 포함. 모든 수치는 **출처·연도**를 병기했고, 시장조사기관 추정치는 "추정"으로 표기. `[ ]`는 본인 정보·검증 후 확정할 자리.
> 데이터 인용 원칙: ① 시장규모는 기관별 편차가 크므로 보수치 또는 범위로 인용 ② 경쟁사 이용자수는 "공개 기준" 명시 ③ TAM/SAM/SOM의 침투율·ARPU는 추정 가정이며 근거(벤치마크) 각주 권장.
---
# 1. Tasteby — 인플루언서 영상 기반 맛집 정보 검색 플랫폼
**아이템명:** Tasteby — "유튜버가 다녀간 그 맛집, 검색되다"
**한 줄 정의:** 유튜버·인플루언서 영상의 자막을 LLM이 분석해 식당명·위치·메뉴·맥락을 구조화하고, 신뢰하는 채널 기준으로 검색·지도·예약까지 연결하는 맛집 발견 플랫폼.
**정부지원 트랙:** 관광테크 · 로컬데이터 · 데이터/AI바우처 · 소상공인 상생
## ① 문제인식 (Problem)
- **휘발되는 신뢰 신호:** 소비자는 "성시경 맛집", "쯔양 다녀온 곳"을 영상으로 소비하지만, 영상 속 식당 정보는 **검색·재방문이 불가능**하다. 가장 강력한 추천 신호(신뢰하는 인플루언서의 실제 방문)가 구조화되지 않는다.
- **기존 맛집앱의 신뢰 문제:** 네이버 플레이스·캐치테이블 등은 광고·상위노출 어뷰징 논란이 지속된다. 소비자는 "광고가 아닌 진짜 방문"을 원한다.
- **시장 공백:** 맛집 리뷰 강자였던 **망고플레이트가 2023.10.31 서비스 종료**([이코노믹리뷰](https://www.econovill.com/news/articleView.html?idxno=625591))로, 큐레이션형 맛집 발견 시장에 공백이 생겼다.
- **목적:** 영상이라는 비정형 데이터를 신뢰 가능한 맛집 DB로 전환해, "내가 신뢰하는 채널이 간 곳"을 검색·재방문 가능하게 한다.
## ② 실현가능성 (Solution)
- **핵심 기술 파이프라인:** ⓐ 영상 자막(transcript) 수집 → ⓑ **LLM 기반 식당명·위치·메뉴·맥락 추출 및 정규화** → ⓒ 지도·검색 인덱싱 → ⓓ 채널/인물별 필터·타임스탬프 연결.
- **차별성:** 점포 광고가 아닌 **인플루언서 방문 사실** 기반(객관 데이터 명분) / 기존 "유튜버 맛집 지도"류(먹방로드·맛튜브맵·유튜브플레이스 등)는 대부분 **수작업 매핑**인 반면, Tasteby는 **LLM 자동 추출·검색 고도화**로 규모화·자동화 공백을 메운다.
- **개발 현황(핵심 강점):** **작동하는 MVP + 신규 영상 자동 백필 백오피스 보유.** 아이디어가 아닌 **실증된 제품**으로 평가 가능.
- **리스크 대응(저작권):** ▲원문 인용 최소화, **출처(채널) 명시 및 트래픽 환원** ▲사실정보(상호·주소) 위주 추출 ▲창작자 어필리에이트로 상생 구조화.
## ③ 성장전략 (Scale-up)
### 시장규모 (TAM/SAM/SOM)
| 구분 | 정의 | 규모 | 출처(연도) |
|---|---|---|---|
| **TAM** | 한국 외식산업 시장 | 약 **153조 원** (2024 전망·추정) | [KREI 2024](https://www.krei.re.kr/namo/binary/files/000025/2401_Leflet.pdf) |
| 참조(글로벌) | 글로벌 푸드테크 | $222억(2024) → $499억(2034), CAGR ~810% (추정) | [Emergen Research](https://www.emergenresearch.com/industry-report/food-tech-market) |
| **SAM** | 맛집 검색·발견·예약 | 캐치테이블 MAU 500만(2024)·식신 MAU 300만(2023) 활성풀 기반 | [머니투데이 2025](https://www.mt.co.kr/future/2025/09/01/2025090114285797688), [이코노믹리뷰 2023](https://www.econovill.com/news/articleView.html?idxno=632184) |
| **SOM** | 숏폼·인플루언서 기반 맛집 발견 세그먼트 | 초기 타깃 = 숏폼 맛집 콘텐츠 소비층 + 망고플레이트 이탈 수요(MAU ~12만 공백) | [오픈서베이 2024](https://blog.opensurvey.co.kr/article/socialmedia-2024-2/) |
### 핵심 통계 (근거)
- **숏폼 맛집 콘텐츠가 1위 카테고리**: 인스타 44.1%, 유튜브 쇼츠 33.8% ([오픈서베이 2024](https://blog.opensurvey.co.kr/article/socialmedia-2024-2/))
- 숏폼 시청 경험률 **56.5%(2022)→82.7%(2024)** 급증, 쇼츠 이용률 ~90% (동 출처)
- 2024년 온라인 음식배달 거래액 **21.4조 원**(1~3Q, 사상 최대 전망) ([한국경제 2024](https://www.hankyung.com/article/2024111396611))
- 글로벌 레스토랑 테크에서 **모바일 앱이 2024년 매출의 ~34%** 최대 비중 ([Global Growth Insights](https://www.globalgrowthinsights.com/market-reports/restaurant-technology-market-116768))
### 경쟁사 비교
| 서비스 | 포지셔닝 | 강점 | 약점 | 지표(출처) |
|---|---|---|---|---|
| 캐치테이블 | 프리미엄 예약·웨이팅 1위 | 제휴 1만+·예약 인프라 | 동네 맛집 커버리지 약함, 제휴 기반 | MAU 500만(2024), 가입 1,000만(2025) [출처](https://www.mt.co.kr/future/2025/09/01/2025090114285797688) |
| 식신 | 검색·추천+식권 B2B | DB 75만, MAU 300만 | 숏폼·인플루언서 연계 약함 | [이코노믹리뷰 2023](https://www.econovill.com/news/articleView.html?idxno=632184) |
| 네이버 플레이스 | 포털 내장 디폴트 | 압도적 트래픽·통합 | 광고·어뷰징 신뢰 이슈 | MAU 비공개 |
| 망고플레이트 | (과거) 리뷰 커뮤니티 | — | **2023.10 종료** → 시장 공백 | [이코노믹리뷰](https://www.econovill.com/news/articleView.html?idxno=625591) |
| 유튜버 맛집 지도류 | 영상 맛집 매핑 | 수요 입증 | **수작업·LLM 자동화 부재**, 영세 | 먹방로드·맛튜브맵·유튜브플레이스 등 |
### 수익모델·자금·일정
- **BM:** ⓐ 예약·웨이팅 제휴 수수료 ⓑ 채널 어필리에이트 ⓒ 지역/관광 데이터 B2B ⓓ 프리미엄(저장·알림)
- **자금 연계:** 데이터바우처(2025 최대 4,500만 원, [공고](https://www.bizinfo.go.kr/web/lay1/bbs/S1T122C128/AS/74/view.do?pblancId=PBLN_000000000104783))로 영상→맛집 구조화 데이터 구축비 조달
- **일정:** 영상 커버리지 확대 → 지도/예약 연동 → 관광(외국인) 특화 → B2B 데이터
## ④ 팀구성 (Team)
- **대표 [본인]:** LLM 파이프라인·풀스택 개발, **MVP+백오피스 단독 구현** 실행력(자체 서비스 다수 구축 이력)
- **보강 계획:** 외식 도메인 자문, 제휴영업 [공동창업자/멘토]
---
# 2. Lyricsy — K-pop·팝 차트 기반 음악 언어학습 앱
**아이템명:** Lyricsy — "좋아하는 노래로 배우는 한국어·영어"
**한 줄 정의:** 빌보드·멜론 차트 음악으로 영어/한국어를 배우는 앱. 뮤직비디오 + 가사(원문·발음표기·뜻 3중 병기) + 표현 플래시카드(SRS) + 개인화(최애 아티스트/곡).
**정부지원 트랙:** 콘텐츠진흥원(콘진원)·문체부 한류 · 에듀테크 · 글로벌 진출
## ① 문제인식 (Problem)
- **양방향 미충족 수요:** 전 세계 K-pop 팬은 "가사 뜻을 알고 싶다 → 한국어를 배우고 싶다"는 강한 동기를 갖지만 Duolingo류는 음악·문화 맥락이 없고, 가사 사이트는 학습 기능이 없다. 반대로 한국 사용자는 **팝송으로 영어 학습** 수요가 크다.
- **시장 근거(폭발적 한국어 수요):** 전 세계 한류 팬 **2억 2,500만 명**(2023, [Korea.net](https://www.korea.net/NewsFocus/Society/view?articleId=248349)), TOPIK 응시 **~55만 명**(2025, 역대 최대, [Korea Times](https://www.koreatimes.co.kr/southkorea/society/20251010/no-of-people-taking-korean-language-proficiency-test-hits-record-high-as-interest-surges)), Duolingo 내 **한국어 세계 6위 학습 언어**(2025, [Duolingo](https://blog.duolingo.com/2025-duolingo-language-report/)).
- **목적:** "좋아하는 노래"라는 최강의 동기를 발음·뜻·표현 학습으로 전환.
## ② 실현가능성 (Solution)
- **핵심 기능:** 차트·개인화 → MV(공식 임베드) + **가사 3중 병기(원문/발음/뜻)** → 표현을 **플래시카드(SRS)**로 반복.
- **차별성(빈 시장):** 빌보드·멜론 차트 + K-pop 특화 + 가사 3중 병기 + SRS + 개인화를 **모두 결합한 경쟁자 없음.** LyricsTraining은 빈칸 게임에 그치고, Lirica는 한국어 미지원.
- **개발 현황:** 차트·개인화·가사·플래시카드 **MVP 구현**.
- **음악 기반 학습 효과(학술):** 멜로디·리듬이 어휘 기억의 mnemonic 역할, 발음 향상 보고([Busuu](https://www.busuu.com/en/languages/music-language-learning)). 단, 어휘습득 효과는 "긍정적이나 추가연구 필요"로 표현 권장([ScienceDirect 2024](https://www.sciencedirect.com/science/article/pii/S0346251X24001325)).
- **리스크 대응(가사 라이선스 — 사업 핵심):** ▲글로벌 팝/영어 → **LyricFind/Musixmatch API**(이중 소싱) ▲K-pop 한국어 → **KOMCA 이용계약** + 비신탁곡은 하이브 등 퍼블리셔 직접 계약 ▲MV는 **YouTube 공식 임베드**(권리자 수익 보장). → "라이선스 기반 설계"를 명시해 전문성으로 전환.
## ③ 성장전략 (Scale-up)
### 시장규모 (TAM/SAM/SOM)
| 구분 | 정의 | 규모 | 출처(연도) |
|---|---|---|---|
| **TAM** | 글로벌 언어학습 시장 | 약 **$837억**, CAGR 17%+ (2025, 보수치) | [Mordor Intelligence](https://www.mordorintelligence.com/industry-reports/language-learning-market) |
| 참조 | 글로벌 에듀테크 | 약 $1,870억, CAGR 10.8% (2025) | [Grand View Research](https://www.grandviewresearch.com/industry-analysis/education-technology-market) |
| **SAM** | K-pop으로 한국어 배우려는 한류 팬 | 한류 팬 2.25억 × K-pop 68% ≈ **1.5억 명** (추정 모집단) | [Korea.net 2024](https://www.korea.net/NewsFocus/Society/view?articleId=248349) |
| **SOM** | 초기 3년 확보 가능 활성/유료 | SAM의 0.1~1%(추정), 결제전환 벤치마크 Duolingo ~89% | [Duolingo SEC Q3'25](https://www.sec.gov/Archives/edgar/data/0001562088/000162828025049514/q3fy25duolingo9-30x25share.htm) |
### 경쟁사 비교
| 서비스 | 포지셔닝 | 강점 | 약점 | 이용자(출처) |
|---|---|---|---|---|
| Duolingo | 게이미피케이션 종합 | 압도적 규모, 한국어 정식 코스 | 음악/콘텐츠 기반 아님 | MAU 1억 3,530만·유료 1,150만·매출 $7.48억 [SEC](https://www.sec.gov/Archives/edgar/data/0001562088/000162828025049514/q3fy25duolingo9-30x25share.htm) |
| Babbel | 실용 회화 유료 | 체계적 커리큘럼 | **한국어 코스 없음** | 누적 구독 1,600만+ [출처](https://www.businessofapps.com/data/babbel-statistics/) |
| LyricsTraining | 가사 빈칸 채우기 | 음악 기반 원조, 14개 언어 | 뜻·발음·SRS·개인화 약함, K-pop 특화 아님 | ~200만(추정) [Play](https://play.google.com/store/apps/details?id=com.elasthink.lyricstraining) |
| Lirica | 팝송 학습 | 차트 히트곡 구조화 | **한국어 미지원**, 3개 언어 | 비공개 [lirica.io](https://www.lirica.io/) |
### 수익모델·일정
- **BM:** 구독(에듀테크 표준) + 아티스트별 콘텐츠팩 + 제휴(엔터/교육)
- **일정:** 가사 라이선스 1차 확보 → 학습 루프 고도화 → 글로벌(영어권) 출시 → 아티스트 IP 제휴
- **정부지원 연계:** 콘진원 음악·콘텐츠 스타트업 육성, 문체부 K-콘텐츠 글로벌(해외진출·엑스포)
## ④ 팀구성 (Team)
- **대표 [본인]:** 풀스택·LLM·콘텐츠 파이프라인, 다수 앱 자체 구축(빠른 실증)
- **보강 계획:** 음악 저작권 법무 자문, 언어교육 콘텐츠 자문
---
# 3. Parents Story — 온디바이스 AI 자서전(부모님 이야기) 앱
**아이템명:** Parents Story — "말로 남기는, 폰 안에서 완성되는 부모님 자서전"
**한 줄 정의:** 부모님께 시기별 질문을 던져 **음성·사진으로 기억을 모으고**, 온디바이스 AI(Gemma 4)가 **한 권의 책처럼** 엮는 앱. 데이터가 폰을 떠나지 않는 완전 프라이버시.
**정부지원 트랙:** 고령친화산업 · 온디바이스 AI · 사회문제해결·사회적가치 · AI헬스케어
## ① 문제인식 (Problem)
- **초고령사회의 잃어버리는 기억:** 한국은 **2025년 공식 초고령사회 진입 — 65세 이상 20.3%, 1,051만 명**([통계청 2025 고령자통계](https://kostat.go.kr/board.es?mid=a10301010000&bid=10820&act=view&list_no=438832)). 2036년 30.9%, 2050년 40%+ 전망. 부모 세대의 인생 이야기는 기록되지 못한 채 사라진다.
- **기존 서비스의 한계:** 자서전 대필은 비싸고(수백만 원), 기존 앱은 **클라우드 기반**이라 민감한 가족사를 외부 서버에 맡겨야 한다. 어르신은 **타이핑이 어렵다**.
- **접근성 근거:** 고령층 스마트폰 보유율 **91%**이나 활용 역량은 낮음(70세 이상 디지털 역량 36.9/100, [NIA 2024](https://www.iitp.kr/kr/1/knowledge/statisticsView.it?masterCode=publication&searClassCode=K_STAT_01&identifier=02-008-250328-000002)) → **음성 입력 UI가 타이핑 장벽을 우회**하는 강력한 정당성.
- **목적:** 누구나 **말로** 기억을 남기고, **데이터가 폰을 떠나지 않게** 하면서, AI가 읽기 좋은 책으로 만든다.
## ② 실현가능성 (Solution)
- **핵심 기술:** 시기별 질문 → **음성 입력(어르신이 말로)** + 사진 → **온디바이스 Gemma 4**가 챕터/아웃라인으로 구조화·서술 → 책 형태 생성.
- **Gemma 4 기반 차별성(시의성):** 2026.4 출시된 Gemma 4 **E2B/E4B**는 ▲**네이티브 음성 입력**(어르신 접근성) ▲**비전·OCR**(옛 사진·편지 편입) ▲**256K 컨텍스트**(생애 전체를 일관된 책으로) ▲**~1GB·저전력**으로 보급형 폰 동작([Google](https://blog.google/innovation-and-ai/technology/developers-tools/gemma-4/), [ai.google.dev](https://ai.google.dev/gemma/docs/core)). → "클라우드 없이 폰 안에서, 음성으로 자서전"이 비로소 가능.
- **프라이버시 = 핵심 셀링포인트:** 전 과정 온디바이스 → 민감 가족 데이터 유출 원천 차단. 성인 81%가 AI 오용 우려([Cisco 2025](https://newsroom.cisco.com/c/r/newsroom/en/us/a/y2025/m04/cisco-2025-data-privacy-benchmark-study-privacy-landscape-grows-increasingly-complex-in-the-age-of-ai.html)).
- **개발 현황:** 기획 단계 → **예비창업(아이디어 단계 허용) 사업에 적합.** 장문 일관성은 챕터 분할 생성으로 확보.
- **사회적 임팩트:** 회상치료(reminiscence therapy)는 고립감 완화·심리적 웰빙 개선 효과([ageinplacetech](https://www.ageinplacetech.com/pressrelease/storii-creates-persons-life-memoir-over-phone)) → 정부지원 평가 가점 근거.
## ③ 성장전략 (Scale-up)
### 시장규모 (TAM/SAM/SOM)
| 구분 | 정의 | 규모 | 출처(연도) |
|---|---|---|---|
| **TAM** | 한국 65세 이상 인구(또는 그 자녀 가구) | **1,051만 명** (2025) | [통계청 2025](https://kostat.go.kr/board.es?mid=a10301010000&bid=10820&act=view&list_no=438832) |
| 참조 | 한국 고령친화산업 | 약 **80조 원**(2021) → 120조+ 전망 | [KHIDI 인용](https://www.youthdaily.co.kr/news/article.html?no=144991) |
| 참조(글로벌) | 디지털 레거시 / 온디바이스 AI | $260억(2025, CAGR 13%) / $130억(2025)→$928억(2033, CAGR 28%) | [Custom Market Insights](https://www.globenewswire.com/news-release/2025/06/04/3093344/0/en/Latest-Global-Digital-Legacy-Market-Size-Share-Worth-USD-77-959-8-Million-by-2034-at-a-12-97-CAGR-Custom-Market-Insights-Analysis-Outlook-Leaders-Report-Trends-Forecast-Segmentatio.html), [Coherent](https://www.coherentmarketinsights.com/industry-reports/on-device-ai-market) |
| **SAM** | 스마트폰 보유 고령자(또는 그 자녀) | 1,051만 × 91% ≈ **957만 명** | [NIA 2024](https://www.iitp.kr/kr/1/knowledge/statisticsView.it?masterCode=publication&searClassCode=K_STAT_01&identifier=02-008-250328-000002) |
| **SOM** | 초기 3년 침투 | SAM의 0.1~1%(추정) + B2B(요양시설·지자체 AI케어센터) 레버 | — |
### 경쟁사 비교 (전부 클라우드 → 온디바이스 공백)
| 서비스 | 포지셔닝 | 강점 | 약점(본 앱 대비) | 가격 |
|---|---|---|---|---|
| StoryWorth | 이메일 주간질문→양장본 | 단순·인지도 | **클라우드**, 텍스트 위주(타이핑 부담) | $99/년 [출처](https://www.remento.co/journal/storyworth) |
| Remento | 음성/영상→AI전사→책 | 현대적 UI, 음성 | **클라우드 AI**, 영어권 | $99/년 [출처](https://www.remento.co/) |
| Storii | 전화통화 회고 녹음 | 인터넷 불필요 접근성 | **클라우드**, 사진 결합 약함 | $99~149/년 |
| 엄마의 인터뷰(국내) | AI 자서전 인터뷰 서비스 | 한국어 | 고가·사람 개입, 클라우드 | ~100만 원(추정) |
| 리본북(국내) | 셀프 온라인 자서전 | 한국어·저렴 | AI/음성·온디바이스 약함 | 웹 |
**"완전 온디바이스 + 음성 + 사진 + 한국어"를 모두 충족하는 직접 경쟁자는 조사 범위 내 없음** (명확한 white space).
### 수익모델·일정
- **BM:** 앱 인앱구매 + **실물 인쇄책 제작 연계(객단가↑)** + 가족 공유/추모 프리미엄 + B2B(요양시설·지자체)
- **일정:** 음성·사진 수집 UX → 온디바이스 책 생성 품질화 → 인쇄 연계 → 다국어
- **정부지원 연계:** 고령친화산업 육성(복지부·보건산업진흥원, 고령친화산업진흥법 2026 시행), 사회문제해결형 창업, AI 온디바이스
## ④ 팀구성 (Team)
- **대표 [본인]:** 온디바이스 LLM·앱 개발 역량, 다수 서비스 실증 이력
- **보강 계획:** 시니어 UX 자문, (인쇄 연계) 출판 파트너
---
## 부록 — 공통 전략 메모
- **기술 정체성 한 줄(상단 배치 권장):** "LLM으로 비정형 데이터(영상·가사·구술)를 구조화하는 역량" → 3개 앱은 그 역량의 도메인별 응용. 심사위원에게 검증된 기술 한 줄로 각인.
- **수치 인용 주의:** ① 글로벌 시장규모는 기관별 2~3배 편차 → 보수치/범위로 ② 한국 고령친화산업 80조는 2021 조사(시점 명시) ③ 경쟁사 이용자수는 "공개 기준" 명시 ④ TAM/SAM/SOM 침투율·ARPU·전환율은 추정 가정 → 벤치마크 각주.
- **제출 전 재확인 권장 수치:** 외식산업 153조(전망치), 온라인식품 거래액(통계청 원자료), KOSTAT 고령자통계 원문.

View File

@@ -0,0 +1,91 @@
# 마스터 사업계획서 (3개 앱)
한국 정부 창업지원(예비창업패키지·창업사업화)의 표준 **PSST 구조**(문제인식–실현가능성–성장전략–팀구성)로 작성한 마스터 초안. 공고별로 가볍게 변형해 재사용한다.
> 표기 규칙: `[ ]`는 본인 정보·검증 수치를 채울 자리. 통계는 임의로 채우지 않았으니 제출 전 출처 확인 후 삽입할 것.
> 공통 정체성(상단에 쓰면 강함): **"LLM으로 비정형 데이터(영상·가사·구술)를 구조화하는 역량"** 을 핵심으로, 각 앱은 그 역량의 도메인별 응용.
---
## 1. Tasteby — 인플루언서 영상 기반 맛집 정보 검색 플랫폼
**한 줄 소개:** "유튜버·인플루언서가 다녀간 그 맛집, 어디였지?" — LLM이 영상 자막에서 식당 정보를 추출·구조화해 검색 가능하게 만드는 서비스.
**정부지원 트랙:** 관광테크 / 로컬데이터 / 데이터·AI바우처 / 소상공인 상생
### ① 문제인식 (Problem)
- 소비자는 "성시경 맛집", "쯔양 다녀온 곳"을 영상으로 보지만, **영상 속 정보는 검색·재방문이 불가능**하다. 영상은 휘발되고 위치·메뉴·영업정보는 흩어져 있다.
- 기존 맛집 앱(네이버·캐치테이블·다이닝코드)은 **광고·리뷰 기반**이라 신뢰도 논란이 있는 반면, **신뢰하는 인플루언서의 실제 방문**이라는 강력한 추천 신호는 어디에도 구조화돼 있지 않다.
- **목적:** 영상이라는 비정형 데이터를 신뢰 가능한 맛집 DB로 전환해, "내가 좋아하는 채널이 간 곳"을 검색·지도·예약으로 연결.
### ② 실현가능성 (Solution)
- **핵심 기술:** ⓐ 영상 자막(STT/transcript) 수집 → ⓑ **LLM으로 식당명·위치·메뉴·맥락 추출 및 정규화** → ⓒ 지도·검색 인덱싱.
- **차별성:** 점포 광고가 아닌 **인플루언서 방문 사실** 기반 / 채널·인물별 필터 / 영상 타임스탬프 연결.
- **개발 현황(강점):** **MVP + 백오피스 보유** — 신규 영상 자동 백필 파이프라인이 이미 동작. (심사에서 "작동하는 제품"으로 어필)
- **리스크 대응:** 영상 저작권·플랫폼 약관 이슈는 ▲원문 인용 최소화·**출처(채널) 명시 및 트래픽 환원** ▲사실정보(상호·주소) 위주 추출 ▲창작자 제휴(어필리에이트) 모델로 상생 구조 설계.
### ③ 성장전략 (Scale-up)
- **시장:** 국내 외식 O2O·맛집검색 [시장규모 수치 확인] / 1차 타깃 = 맛집 영상 소비층(2030).
- **수익모델:** ⓐ 식당 예약·웨이팅 제휴 수수료 ⓑ 인플루언서·채널 어필리에이트 ⓒ 지역/관광 데이터 B2B 판매 ⓓ 프리미엄(저장·알림).
- **추진일정:** 영상 커버리지 확대 → 지도/예약 연동 → 지역(관광) 특화 → B2B 데이터.
- **정부지원 연계:** 관광플러스테크·로컬 데이터·데이터바우처로 데이터 구축비 조달.
### ④ 팀구성 (Team)
- **대표:** [본인] — LLM 파이프라인·풀스택 개발 역량(자체 서비스 다수 구축 이력). **이미 MVP를 혼자 구현**한 실행력.
- **필요 역량/계획:** 외식 도메인 자문, 제휴영업. [공동창업자/멘토 계획].
---
## 2. Lyricsy — K-pop·팝 차트 기반 음악 언어학습 앱
**한 줄 소개:** 빌보드·멜론 차트를 좋아하는 노래로 **영어·한국어를 배우는** 앱 — 뮤직비디오 + 가사(한/영·발음·뜻) + 표현 플래시카드.
**정부지원 트랙:** 콘텐츠진흥원(콘진원)·문체부 한류 / 에듀테크 / 글로벌 진출
### ① 문제인식 (Problem)
- 전 세계 K-pop 팬은 **"가사 뜻을 알고 싶다 → 한국어를 배우고 싶다"**는 강한 동기를 갖지만, Duolingo류는 음악·문화 맥락이 없고, 가사 사이트는 학습 기능이 없다.
- 반대로 한국 사용자에게는 **팝송으로 영어 학습** 수요가 크다. **양방향(영↔한)**으로 음악을 학습 콘텐츠로 만든 서비스는 드물다.
- **목적:** "좋아하는 노래"라는 최강의 학습 동기를 발음·뜻·표현 학습으로 전환.
### ② 실현가능성 (Solution)
- **핵심 기능:** 차트 연동(개인화: 최애 아티스트/곡) → MV + **가사(원문·발음표기·뜻 병기)** → 표현을 **플래시카드(SRS)**로 반복 학습.
- **차별성:** 음악×양방향 언어학습×개인화. K-pop 글로벌 팬덤이라는 **거대 유입 동기** 보유.
- **개발 현황:** 차트·개인화·가사·플래시카드 **MVP 구현**.
- **리스크 대응(사업 핵심):** 가사 저작권은 신뢰도를 가르는 지점. ▲가사는 **LyricFind 등 합법 라이선스/한국음악저작권협회 신탁 경로**로 확보 ▲MV는 **공식 YouTube 임베드**(권리자 수익 보장) ▲차트 데이터는 약관 준수. → "법적 리스크를 인지하고 라이선스 기반으로 설계"를 명시해 감점이 아닌 **전문성**으로 전환.
### ③ 성장전략 (Scale-up)
- **시장:** 글로벌 언어학습 [시장규모 수치 확인] × K-콘텐츠 팬덤 → **해외 TAM이 본질**. SOM = 영어권·동남아 K-pop 학습자.
- **수익모델:** 구독(에듀테크 표준) + 아티스트별 콘텐츠팩 + 제휴(엔터/교육).
- **추진일정:** 가사 라이선스 1차 확보 → 학습 루프 고도화 → 글로벌(영어권) 출시 → 아티스트 IP 제휴.
- **정부지원 연계:** 콘진원 음악·콘텐츠 스타트업 육성, 문체부 K-콘텐츠 글로벌(엑스포·해외진출).
### ④ 팀구성 (Team)
- **대표:** [본인] — 풀스택·LLM·콘텐츠 파이프라인. 다수 앱 자체 구축 이력 → **빠른 실증 능력**.
- **필요 역량/계획:** 음악 라이선스/저작권 자문(법무), 언어교육 콘텐츠 자문.
---
## 3. Parents Story — 온디바이스 AI 자서전(부모님 이야기) 앱
**한 줄 소개:** 부모님께 인생의 시기별 질문을 던져 **말·사진으로 기억을 모으고**, 온디바이스 AI(Gemma 4)가 **한 권의 책처럼** 엮어주는 앱 — 클라우드 없이, 완전 프라이버시.
**정부지원 트랙:** 고령친화산업 / 온디바이스 AI / 사회문제 해결·사회적가치 / AI헬스케어
### ① 문제인식 (Problem)
- **초고령사회 한국** — 부모 세대의 인생 이야기는 기록되지 않은 채 사라지고, 자녀·후손은 "그때 더 여쭤볼걸"이라는 후회를 남긴다.
- 기존 자서전 서비스는 **비싸고(대필), 사생활을 외부에 맡겨야** 한다. 어르신은 **타이핑이 어렵고**, 민감한 가족사를 **클라우드에 올리길 꺼린다**.
- **목적:** 누구나 **말로** 기억을 남기고, **데이터가 폰을 떠나지 않게** 하면서, AI가 그것을 읽기 좋은 책으로.
### ② 실현가능성 (Solution)
- **핵심 기술:** 시기별 질문 → **음성 입력(어르신이 말로)** + 사진 → **온디바이스 Gemma 4**가 챕터/아웃라인으로 구조화·서술 → 책 형태 생성.
- **차별성(시의성):** 2026.4 출시된 **Gemma 4 E2B/E4B**는 ▲**네이티브 음성 입력**(어르신 접근성) ▲**비전·OCR**(옛 사진·편지 편입) ▲**256K 컨텍스트**(생애 전체를 일관된 책으로) ▲**~1GB·저전력**으로 보급형 폰 동작. → **"클라우드 없이 폰 안에서, 음성으로 자서전"**이 비로소 가능해진 것이 핵심 차별점.
- **프라이버시:** 전 과정 온디바이스 = 민감한 가족 이야기 유출 우려 원천 차단(곧 셀링포인트).
- **개발 현황:** 기획 단계 → **예비창업 단계에 적합**(아이디어 단계 허용 사업 타깃). 챕터 분할 생성으로 장문 일관성 확보.
### ③ 성장전략 (Scale-up)
- **시장:** 시니어테크 + 가족/추모 시장 [수치 확인]. 1차 = 부모님 선물을 원하는 4050 자녀.
- **수익모델:** 앱 인앱구매 + **실물 인쇄책 제작 연계(객단가↑)** + 가족 공유/추모 프리미엄.
- **추진일정:** 음성·사진 수집 UX → 온디바이스 책 생성 품질화 → 인쇄 연계 → 다국어.
- **정부지원 연계:** 고령친화산업 육성(복지부·보건산업진흥원), 사회문제해결형 창업, AI 온디바이스.
### ④ 팀구성 (Team)
- **대표:** [본인] — 온디바이스 LLM·앱 개발 역량, 다수 서비스 실증 이력.
- **필요 역량/계획:** 시니어 UX 자문, (인쇄 연계) 출판 파트너.

View File

@@ -0,0 +1,29 @@
# 정부지원사업 소스 카탈로그 (디스커버리 결과)
Claude WebSearch 로 수집한 공고 소스 후보. 상태가 `구현`인 것만 데몬이 수집한다.
| 코드 | 소스 | URL | 방식 | 키 | 상태 |
|---|---|---|---|---|---|
| kstartup | K-Startup 창업지원 공고 | k-startup.go.kr | Open API | data.go.kr 서비스키 | ✅ 구현·검증 |
| mss | 중소벤처기업부 사업공고 | mss.go.kr (cbIdx=310) | HTML 게시판 | 불필요 | ✅ 구현·검증 |
| bizinfo | 기업마당 지원사업정보 | bizinfo.go.kr | Open API(자체) | bizinfo `crtfcKey` | ✅ 구현·검증 |
| smes | 중소벤처24 사업공고 | smes.go.kr (bizApply) | HTML(목록전용) | 불필요 | ✅ 구현·검증 |
| g2b | 나라장터(입찰/조달) | g2b.go.kr | Open API(data.go.kr) | 서비스키 | 🔲 후보 |
| 부처/지자체 | 각 부처·지자체 게시판 | 다수 | HTML(GenericHtml) | 불필요 | 🔲 디스커버리 확장 |
## 핵심 메모
- **커버리지**: 기업마당 + K-Startup 두 API 가 정부지원사업 공고의 대부분을 집계.
기업마당 키 확보가 다음 우선순위.
- **키 체계 주의**: 기업마당은 data.go.kr 가 아니라 bizinfo.go.kr 자체 인증키(`crtfcKey`)를 쓴다.
data.go.kr 서비스키와 별개. 엔드포인트: `https://www.bizinfo.go.kr/uss/rss/bizinfoApi.do?crtfcKey=...&dataType=json`
- **HTML 확장**: 부처/지자체 게시판은 대부분 정적 렌더링 표(table)라 `GenericHtmlSource`
config 로 코드 수정 없이 추가 가능(mss 사례 참조).
- **smes 상세 제약**: smes 상세는 팝업 전용(JS 다이얼로그)이라 단독 크롤 불가 → 목록 전용(`listOnly`)으로
적재. 목록에 제목·기관·분야·신청기간이 모두 있어 충분. 본문은 동일 PBLN 의 기업마당 API 로 보강 예정.
## 참고 링크
- 기업마당 API: https://www.bizinfo.go.kr/web/lay1/program/S1T175C174/apiList.do
- K-Startup API(data.go.kr): https://www.data.go.kr/data/15125364/openapi.do
- 중소벤처24: https://www.smes.go.kr/main/bizApply

349
government/package-lock.json generated Normal file
View File

@@ -0,0 +1,349 @@
{
"name": "gov-scraper",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gov-scraper",
"version": "0.1.0",
"dependencies": {
"cheerio": "^1.0.0",
"dotenv": "^16.4.5",
"oracledb": "^6.5.1",
"playwright-core": "^1.49.0"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/oracledb": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.10.0.tgz",
"integrity": "sha512-kGUumXmrEWbSpBuKJyb9Ip3rXcNgKK6grunI3/cLPzrRvboZ6ZoLi9JQ+z6M/RIG924tY8BLflihL4CKKQAYMA==",
"hasInstallScript": true,
"license": "(Apache-2.0 OR UPL-1.0)",
"engines": {
"node": ">=14.17"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz",
"integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
}
}
}

19
government/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "gov-scraper",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "정부지원사업 공고 수집 데몬 (Open API 우선 + HTML 보조)",
"scripts": {
"daemon": "node src/daemon.js",
"run-once": "node src/cli.js run-once",
"test-db": "node src/cli.js test-db",
"test-crawl": "node src/cli.js test-crawl"
},
"dependencies": {
"cheerio": "^1.0.0",
"dotenv": "^16.4.5",
"oracledb": "^6.5.1",
"playwright-core": "^1.49.0"
}
}

View File

@@ -0,0 +1,37 @@
// 예비창업자가 지원 가능(자격에 '예비창업' 명시)하고 현재 열린 공고 전체를 마감일 순으로.
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/eligible.js
import { withConnection, closePool, oracledb } from '../src/db.js';
const SQL = `
SELECT * FROM (
SELECT title, category, agency, source_code, detail_url, apply_end,
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
FROM gov_opportunity
WHERE (apply_end IS NULL OR apply_end >= TRUNC(SYSDATE))
AND (
target LIKE '%' || :k1 || '%'
OR target LIKE '%' || :k2 || '%'
OR DBMS_LOB.INSTR(body_text, :k1) > 0
OR DBMS_LOB.INSTR(body_text, :k2) > 0
)
)
WHERE rn = 1
ORDER BY dleft ASC
FETCH FIRST 80 ROWS ONLY`;
await withConnection(async (conn) => {
const r = await conn.execute(
SQL,
{ k1: '예비창업', k2: '예비 창업' },
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
);
console.log(`현재 신청가능(예비창업자 자격) 공고: ${r.rows.length}\n`);
for (const x of r.rows) {
const tag = x.DLEFT === 9999 ? '[상시]' : `[D-${x.DLEFT}]`;
console.log(
`${tag}\t${x.SOURCE_CODE}\t${(x.CATEGORY || '-').slice(0, 8)}\t${x.TITLE.slice(0, 52)}\t${x.DETAIL_URL}`
);
}
});
await closePool();

View File

@@ -0,0 +1,68 @@
// 예비창업자 자격 + 현재 열린 공고 전체를 CSV 로 내보낸다(신청 추적용).
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/export_eligible_csv.js
import { writeFile, mkdir } from 'node:fs/promises';
import { withConnection, closePool, oracledb } from '../src/db.js';
const SQL = `
SELECT * FROM (
SELECT title, category, agency, source_code, detail_url, apply_start, apply_end,
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
FROM gov_opportunity
WHERE (apply_end IS NULL OR apply_end >= TRUNC(SYSDATE))
AND (
target LIKE '%' || :k1 || '%' OR target LIKE '%' || :k2 || '%'
OR DBMS_LOB.INSTR(body_text, :k1) > 0 OR DBMS_LOB.INSTR(body_text, :k2) > 0
)
)
WHERE rn = 1
ORDER BY dleft ASC, source_code`;
function csvCell(v) {
if (v == null) return '';
const s = String(v).replace(/"/g, '""').replace(/\r?\n/g, ' ');
return `"${s}"`;
}
function ymd(d) {
return d ? new Date(d).toISOString().slice(0, 10) : '';
}
function region(title) {
const m = /^\[([^\]]{1,8})\]/.exec(title);
return m ? m[1] : '';
}
const rows = await withConnection(async (conn) => {
const r = await conn.execute(
SQL,
{ k1: '예비창업', k2: '예비 창업' },
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
);
return r.rows;
});
await closePool();
const header = ['신청완료', 'D-day', '마감일', '지역', '분야', '주관기관', '소스', '제목', '링크'];
const lines = [header.map(csvCell).join(',')];
for (const x of rows) {
lines.push(
[
'',
x.DLEFT === 9999 ? '상시' : `D-${x.DLEFT}`,
x.DLEFT === 9999 ? '상시/예산소진' : ymd(x.APPLY_END),
region(x.TITLE),
x.CATEGORY || '',
x.AGENCY || '',
x.SOURCE_CODE,
x.TITLE,
x.DETAIL_URL || '',
]
.map(csvCell)
.join(',')
);
}
await mkdir(new URL('../exports/', import.meta.url), { recursive: true });
const out = new URL('../exports/eligible_opportunities.csv', import.meta.url);
// 엑셀 한글 깨짐 방지 BOM
await writeFile(out, '' + lines.join('\n'), 'utf8');
console.log(`내보냄: ${rows.length}건 → government/exports/eligible_opportunities.csv`);

View File

@@ -0,0 +1,185 @@
// 예비창업자 자격 + 현재 열린 공고를 마감일 그룹별 체크리스트(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`);

View File

@@ -0,0 +1,70 @@
// 앱 후보별로 매칭되는 정부지원사업을 gov_opportunity 에서 조회한다.
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/match.js
import { withConnection, closePool, oracledb } from '../src/db.js';
// 앱별 주제 키워드(제목/분야/대상/주관기관 대상으로 LIKE)
const APPS = {
Tasteby: ['외식', '맛집', '음식', '푸드', '소상공인', '관광', '로컬', '지역특화', '상권', '데이터바우처', 'AI바우처', '빅데이터'],
Lyricsy: ['콘텐츠', '한류', '케이팝', 'K-팝', '음악', '뮤직', '어학', '한국어', '에듀테크', '웹툰', '문화산업', '글로벌진출'],
ParentsStory: ['시니어', '고령', '노인', '실버', '돌봄', '복지', '사회적가치', '사회문제', '헬스케어', '웰니스', '온디바이스', '기록'],
};
async function queryApp(conn, keywords) {
// 키워드 OR 조건 (title/category/target/agency)
const binds = {};
const ors = keywords.map((kw, i) => {
binds[`k${i}`] = `%${kw}%`;
return `(title LIKE :k${i} OR category LIKE :k${i} OR target LIKE :k${i} OR agency LIKE :k${i})`;
});
const sql = `
SELECT * FROM (
SELECT title, category, agency, source_code, detail_url,
apply_end,
CASE WHEN apply_end IS NULL OR apply_end >= TRUNC(SYSDATE) THEN 1 ELSE 0 END AS is_open,
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
FROM gov_opportunity
WHERE ${ors.join(' OR ')}
)
WHERE rn = 1
ORDER BY is_open DESC, dleft ASC
FETCH FIRST 14 ROWS ONLY`;
const r = await conn.execute(sql, binds, { outFormat: oracledb.OUT_FORMAT_OBJECT });
return r.rows;
}
async function countOpen(conn, keywords) {
const binds = {};
const ors = keywords.map((kw, i) => {
binds[`k${i}`] = `%${kw}%`;
return `(title LIKE :k${i} OR category LIKE :k${i} OR target LIKE :k${i} OR agency LIKE :k${i})`;
});
const r = await conn.execute(
`SELECT COUNT(DISTINCT title) total,
COUNT(DISTINCT CASE WHEN apply_end IS NULL OR apply_end >= TRUNC(SYSDATE) THEN title END) open_now
FROM gov_opportunity WHERE ${ors.join(' OR ')}`,
binds,
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
);
return r.rows[0];
}
function fmtDate(d) {
if (!d) return '상시/별도';
return new Date(d).toISOString().slice(0, 10);
}
await withConnection(async (conn) => {
for (const [app, keywords] of Object.entries(APPS)) {
const cnt = await countOpen(conn, keywords);
console.log(`\n##### ${app} — 주제 매칭 총 ${cnt.TOTAL}건 (현재 신청가능 ${cnt.OPEN_NOW}건) #####`);
const rows = await queryApp(conn, keywords);
for (const r of rows) {
const tag = r.IS_OPEN === 1 ? (r.DLEFT === 9999 ? '[상시]' : `[D-${r.DLEFT}]`) : '[마감]';
console.log(
`${tag}\t${r.SOURCE_CODE}\t${r.CATEGORY || '-'}\t${(r.AGENCY || '-').slice(0, 16)}\t${r.TITLE.slice(0, 50)}\t${r.DETAIL_URL || '-'}`
);
}
}
});
await closePool();

19
government/src/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,19 @@
// Oracle Instant Client(thick 모드)는 libnnz 등 의존 라이브러리를 LD_LIBRARY_PATH 로 찾는다.
// LD_LIBRARY_PATH 는 프로세스 시작 시점에만 읽히므로, 누락 시 동일 인자로 한 번 재실행한다.
// 진입점(daemon.js, cli.js) 최상단에서 가장 먼저 import 할 것.
import { spawnSync } from 'node:child_process';
const IC = process.env.ORACLE_IC_LIB_DIR || '/home/opc/oracle-ic/instantclient_23_26';
const current = (process.env.LD_LIBRARY_PATH || '').split(':').filter(Boolean);
if (!current.includes(IC)) {
const env = {
...process.env,
LD_LIBRARY_PATH: [IC, ...current].join(':'),
};
const result = spawnSync(process.execPath, process.argv.slice(1), {
stdio: 'inherit',
env,
});
process.exit(result.status ?? 1);
}

57
government/src/cli.js Normal file
View File

@@ -0,0 +1,57 @@
// 수동 실행 CLI.
// node src/cli.js test-db DB 접속 확인
// node src/cli.js test-crawl <url> 3단계 크롤러 단독 테스트
// node src/cli.js run-once [sourceCode] 1회 수집 (코드 생략 시 전체)
import './bootstrap.js'; // LD_LIBRARY_PATH 보정 (가장 먼저)
import { log } from './logger.js';
import { withConnection, closePool } from './db.js';
import { crawl } from './crawler/crawler.js';
import { disconnectBrowser } from './crawler/browser.js';
import { availableSources, sourceByCode } from './sources/registry.js';
import { runAll } from './pipeline.js';
async function cleanup() {
await disconnectBrowser();
await closePool();
}
async function main() {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case 'test-db': {
const r = await withConnection((c) =>
c.execute('SELECT COUNT(*) FROM gov_source')
);
log.info('DB OK, gov_source 행수 =', r.rows[0][0]);
break;
}
case 'test-crawl': {
if (!arg) throw new Error('사용법: test-crawl <url>');
const text = await crawl(arg);
log.info(`크롤 결과 ${text.length}자:\n${text.slice(0, 500)}`);
break;
}
case 'run-once': {
const sources = arg
? [sourceByCode(arg)].filter(Boolean)
: availableSources();
if (sources.length === 0) {
throw new Error(arg ? `소스 없음/비활성: ${arg}` : '가용 소스 없음');
}
const results = await runAll(sources);
log.info('수집 결과:', JSON.stringify(results));
break;
}
default:
log.info('사용법: test-db | test-crawl <url> | run-once [sourceCode]');
}
}
main()
.then(cleanup)
.then(() => process.exit(0))
.catch(async (e) => {
log.error('CLI 오류:', e.stack || e.message);
await cleanup();
process.exit(1);
});

43
government/src/config.js Normal file
View File

@@ -0,0 +1,43 @@
// 환경설정 로더. 프로젝트 루트(.env)를 읽어 데몬 전역 설정으로 노출한다.
import dotenv from 'dotenv';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..'); // /home/opc/sundol
dotenv.config({ path: path.join(ROOT, '.env') });
function required(name) {
const v = process.env[name];
if (v === undefined || v === null || v === '') {
throw new Error(`필수 환경변수 누락: ${name}`);
}
return v;
}
export const config = {
root: ROOT,
oracle: {
user: required('ORACLE_USERNAME'),
password: required('ORACLE_PASSWORD'),
connectString: required('ORACLE_TNS_NAME'),
walletPath: required('ORACLE_WALLET_PATH'),
// thick 모드: Instant Client 라이브러리 + sso 지갑을 읽을 net 설정 디렉터리
icLibDir: process.env.ORACLE_IC_LIB_DIR || '/home/opc/oracle-ic/instantclient_23_26',
netConfigDir:
process.env.ORACLE_NET_CONFIG_DIR ||
path.join(ROOT, 'government', 'oracle-net'),
},
dataGoKr: {
apiKey: process.env.DATA_GO_KR_API_KEY || '',
},
bizinfo: {
crtfcKey: process.env.BIZINFO_CRTFC_KEY || '',
},
jina: {
apiKey: process.env.JINA_READER_API_KEY || '',
},
cdpUrl: process.env.GOV_CDP_URL || 'http://127.0.0.1:9222',
pollIntervalMinutes: Number(process.env.GOV_POLL_INTERVAL_MINUTES || 60),
};

View File

@@ -0,0 +1,68 @@
// 기존 sundol-chrome(PM2, CDP 9222)에 연결해 새 탭을 여는 싱글톤.
// VNC 에서 사용자가 로그인한 세션을 그대로 사용하므로 봇 판정 우회에 유리하다.
// (백엔드 PlaywrightBrowserService 와 동일한 전략)
import { chromium } from 'playwright-core';
import { config } from '../config.js';
import { log } from '../logger.js';
let browser = null;
async function ensureBrowser() {
if (browser && browser.isConnected()) return browser;
if (browser) {
try {
await browser.close();
} catch {
// 끊긴 연결 정리 실패는 무시 가능 — 곧바로 재연결한다
}
}
log.info(`Chrome CDP 연결 시도: ${config.cdpUrl}`);
browser = await chromium.connectOverCDP(config.cdpUrl);
log.info(`CDP 연결 완료: contexts=${browser.contexts().length}`);
return browser;
}
function defaultContext(b) {
const contexts = b.contexts();
if (contexts.length === 0) {
throw new Error('Chrome 에 활성 컨텍스트가 없습니다.');
}
return contexts[0];
}
/**
* 새 탭을 열어 URL 로 이동한다. 호출자는 사용 후 반드시 closePage(page) 할 것.
*/
export async function openPage(url, { timeoutMs = 30_000, waitUntil = 'networkidle' } = {}) {
const b = await ensureBrowser();
const ctx = defaultContext(b);
const page = await ctx.newPage();
try {
await page.goto(url, { timeout: timeoutMs, waitUntil });
return page;
} catch (e) {
await closePage(page);
throw new Error(`페이지 로드 실패 (${url}): ${e.message}`);
}
}
export async function closePage(page) {
if (!page) return;
try {
await page.close();
} catch (e) {
log.warn('탭 닫기 실패:', e.message);
}
}
export async function disconnectBrowser() {
if (browser) {
try {
// CDP 연결만 해제 (Chrome 자체는 종료하지 않음)
await browser.close();
} catch (e) {
log.warn('CDP 연결 해제 실패:', e.message);
}
browser = null;
}
}

View File

@@ -0,0 +1,117 @@
// 3단계 폴백 크롤러 (sundol WebCrawlerService 의 Node 재구현)
// 1차: 정적 fetch + cheerio 본문 추출
// 2차: Jina Reader (r.jina.ai)
// 3차: Playwright (sundol-chrome CDP) 로 실제 렌더링 후 innerText
// Facade: 호출자는 crawl(url) 만 사용한다.
import * as cheerio from 'cheerio';
import { config } from '../config.js';
import { log } from '../logger.js';
import { openPage, closePage } from './browser.js';
const JINA_BASE = 'https://r.jina.ai/';
const MIN_CONTENT_LENGTH = 100;
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const ERROR_PATTERNS = [
'access denied', '403 forbidden', "you don't have permission",
'error 403', 'error 401', 'unauthorized', 'captcha',
'please enable javascript', 'checking your browser',
'attention required', 'just a moment',
'technical difficulty', 'page not found', '404 not found',
];
const REMOVE_SELECTORS = 'nav, footer, header, script, style, .ad, #cookie-banner, .sidebar, .comments';
const ARTICLE_SELECTORS = 'article, main, .post-content, .article-body, .entry-content';
function isValidContent(text) {
if (!text || text.length < MIN_CONTENT_LENGTH) return false;
const preview = text.slice(0, 500).toLowerCase();
for (const pattern of ERROR_PATTERNS) {
if (preview.includes(pattern)) {
log.warn(`에러 페이지 패턴 감지: '${pattern}'`);
return false;
}
}
return true;
}
async function crawlWithCheerio(url) {
log.info(`정적 크롤링(cheerio): ${url}`);
const res = await fetch(url, {
headers: { 'User-Agent': UA },
redirect: 'follow',
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const $ = cheerio.load(html);
$(REMOVE_SELECTORS).remove();
const article = $(ARTICLE_SELECTORS).first();
const text = (article.length ? article : $('body')).text().replace(/\s+\n/g, '\n').trim();
log.info(`cheerio 추출: ${text.length} chars`);
return text;
}
async function crawlWithJina(url) {
log.info(`Jina Reader 크롤링: ${url}`);
const headers = { Accept: 'text/plain' };
if (config.jina.apiKey) headers.Authorization = `Bearer ${config.jina.apiKey}`;
const res = await fetch(JINA_BASE + url, {
headers,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Jina HTTP ${res.status}`);
const text = await res.text();
if (!text || !text.trim()) throw new Error('Jina Reader 빈 응답');
log.info(`Jina 추출: ${text.length} chars`);
return text;
}
async function crawlWithPlaywright(url) {
log.info(`Playwright 크롤링: ${url}`);
const page = await openPage(url);
try {
const text = await page.evaluate(
({ removeSel, articleSel }) => {
removeSel.split(',').forEach((sel) =>
document.querySelectorAll(sel.trim()).forEach((el) => el.remove())
);
const article = document.querySelector(articleSel);
return (article || document.body).innerText;
},
{ removeSel: REMOVE_SELECTORS, articleSel: ARTICLE_SELECTORS }
);
if (!text || !text.trim()) throw new Error('Playwright 빈 본문');
log.info(`Playwright 추출: ${text.length} chars`);
return text;
} finally {
await closePage(page);
}
}
/**
* 본문 텍스트를 3단계 폴백으로 수집한다. 모두 실패하면 throw.
*/
export async function crawl(url) {
// 1차
try {
const text = await crawlWithCheerio(url);
if (isValidContent(text)) return text;
log.warn(`cheerio 무효 콘텐츠(${text?.length || 0}자) → Jina 폴백`);
} catch (e) {
log.warn(`cheerio 실패(${url}): ${e.message} → Jina 폴백`);
}
// 2차
try {
const text = await crawlWithJina(url);
if (isValidContent(text)) return text;
log.warn(`Jina 무효 콘텐츠(${text?.length || 0}자) → Playwright 폴백`);
} catch (e) {
log.warn(`Jina 실패(${url}): ${e.message} → Playwright 폴백`);
}
// 3차
const text = await crawlWithPlaywright(url);
if (!isValidContent(text)) {
throw new Error(`모든 크롤링 방법 실패: ${url}`);
}
return text;
}

62
government/src/daemon.js Normal file
View File

@@ -0,0 +1,62 @@
// 정부지원사업 수집 데몬. 주기적으로 가용 소스 전체를 1회 수집한다.
// PM2 로 상시 구동: pm2 start ecosystem.config.cjs --only gov-daemon
import './bootstrap.js'; // LD_LIBRARY_PATH 보정 (가장 먼저)
import { config } from './config.js';
import { log } from './logger.js';
import { closePool } from './db.js';
import { disconnectBrowser } from './crawler/browser.js';
import { availableSources } from './sources/registry.js';
import { runAll } from './pipeline.js';
let stopping = false;
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function cycle() {
const sources = availableSources();
if (sources.length === 0) {
log.warn('가용 소스가 없습니다. (서비스키/설정 확인)');
return;
}
log.info(`수집 사이클 시작: 소스 ${sources.length}개 [${sources.map((s) => s.code).join(', ')}]`);
const results = await runAll(sources);
log.info('수집 사이클 종료:', JSON.stringify(results));
}
async function shutdown(signal) {
if (stopping) return;
stopping = true;
log.info(`${signal} 수신 — 데몬 종료 중`);
try {
await disconnectBrowser();
await closePool();
} catch (e) {
log.warn('종료 정리 중 오류:', e.message);
}
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
async function main() {
const intervalMs = Math.max(1, config.pollIntervalMinutes) * 60_000;
log.info(`gov-daemon 시작. 폴링 주기 ${config.pollIntervalMinutes}분.`);
while (!stopping) {
try {
await cycle();
} catch (e) {
log.error('수집 사이클 오류:', e.stack || e.message);
}
if (stopping) break;
log.info(`다음 사이클까지 ${config.pollIntervalMinutes}분 대기`);
await sleep(intervalMs);
}
}
main().catch(async (e) => {
log.error('데몬 치명적 오류:', e.stack || e.message);
await shutdown('FATAL');
});

60
government/src/db.js Normal file
View File

@@ -0,0 +1,60 @@
// Oracle Autonomous DB 접속 (node-oracledb thick 모드).
// Instant Client + sso 지갑(cwallet.sso)을 사용하므로 지갑 비밀번호가 필요 없다.
// (백엔드 JDBC 와 동일하게 자동로그인 지갑을 재사용)
import oracledb from 'oracledb';
import { config } from './config.js';
import { log } from './logger.js';
oracledb.fetchAsString = [oracledb.CLOB];
oracledb.autoCommit = false;
let pool = null;
let clientInitialized = false;
function initClient() {
if (clientInitialized) return;
oracledb.initOracleClient({
libDir: config.oracle.icLibDir,
configDir: config.oracle.netConfigDir, // tnsnames.ora + WALLET_LOCATION 보정 sqlnet.ora
});
clientInitialized = true;
}
export async function initPool() {
if (pool) return pool;
initClient();
pool = await oracledb.createPool({
user: config.oracle.user,
password: config.oracle.password,
connectString: config.oracle.connectString,
poolMin: 1,
poolMax: 4,
poolIncrement: 1,
});
log.info('Oracle 풀 생성 완료');
return pool;
}
export async function withConnection(fn) {
if (!pool) await initPool();
const conn = await pool.getConnection();
try {
return await fn(conn);
} finally {
try {
await conn.close();
} catch (e) {
log.warn('연결 반환 실패:', e.message);
}
}
}
export async function closePool() {
if (pool) {
await pool.close(10);
pool = null;
log.info('Oracle 풀 종료');
}
}
export { oracledb };

10
government/src/logger.js Normal file
View File

@@ -0,0 +1,10 @@
// 간단한 타임스탬프 로거. PM2 로그로 그대로 흘러간다.
function ts() {
return new Date().toISOString();
}
export const log = {
info: (...args) => console.log(`[${ts()}] [INFO]`, ...args),
warn: (...args) => console.warn(`[${ts()}] [WARN]`, ...args),
error: (...args) => console.error(`[${ts()}] [ERROR]`, ...args),
};

106
government/src/pipeline.js Normal file
View File

@@ -0,0 +1,106 @@
// 수집 파이프라인: 소스별로 목록 수집 → 적재 → 상세 본문 수집.
import {
ensureSource,
upsertOpportunities,
findPendingDetail,
saveDetail,
markDetailError,
markSourceCrawled,
} from './store/opportunityStore.js';
import { log } from './logger.js';
const DETAIL_BATCH = Number(process.env.GOV_DETAIL_BATCH || 200); // 한 사이클에 상세 수집할 최대 건수(소스당)
const DETAIL_DELAY_MS = 300; // 상세 수집 간 간격(서버 부담 완화)
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/**
* 단일 소스 1회 수집.
*/
export async function runSource(source) {
const startedAt = Date.now();
log.info(`==== 소스 수집 시작: ${source.code} (${source.name}) ====`);
// 1) 소스 등록/갱신
const { id: sourceId, active } = await ensureSource(source.meta());
if (!active) {
log.warn(`소스 비활성 상태(DB active=0): ${source.code} — 건너뜀`);
return { source: source.code, skipped: true };
}
// 2) 목록 수집 → 적재
const items = await source.list();
log.info(`${source.code}: 목록 ${items.length}건 수집`);
const upsert = await upsertOpportunities(sourceId, source.code, items);
log.info(
`${source.code}: 적재 처리=${upsert.processed} 신규=${upsert.inserted} 갱신=${upsert.updated}`
);
// 3) 상세 본문 수집 (LISTED 상태만). 목록 전용 소스는 건너뜀.
if (source.skipDetail) {
await markSourceCrawled(sourceId);
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
log.info(
`==== 소스 완료(목록전용): ${source.code} | 신규 ${upsert.inserted} 갱신 ${upsert.updated} | ${elapsed}s ====`
);
return {
source: source.code,
listed: items.length,
inserted: upsert.inserted,
updated: upsert.updated,
detailOk: 0,
detailErr: 0,
};
}
const pending = await findPendingDetail(source.code, DETAIL_BATCH);
log.info(`${source.code}: 상세 수집 대상 ${pending.length}`);
let detailOk = 0;
let detailErr = 0;
for (const row of pending) {
try {
const body = await source.fetchDetail(row);
if (!body || !body.trim()) {
throw new Error('빈 본문');
}
await saveDetail(row.id, body);
detailOk += 1;
} catch (e) {
log.warn(`${source.code}/${row.externalId} 상세 실패: ${e.message}`);
await markDetailError(row.id);
detailErr += 1;
}
if (DETAIL_DELAY_MS > 0) await sleep(DETAIL_DELAY_MS);
}
await markSourceCrawled(sourceId);
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
log.info(
`==== 소스 완료: ${source.code} | 신규 ${upsert.inserted} 갱신 ${upsert.updated} | 상세 OK ${detailOk} 실패 ${detailErr} | ${elapsed}s ====`
);
return {
source: source.code,
listed: items.length,
inserted: upsert.inserted,
updated: upsert.updated,
detailOk,
detailErr,
};
}
/**
* 모든 가용 소스 1회 수집.
*/
export async function runAll(sources) {
const results = [];
for (const source of sources) {
try {
results.push(await runSource(source));
} catch (e) {
log.error(`소스 ${source.code} 수집 중 오류: ${e.message}`);
results.push({ source: source.code, error: e.message });
}
}
return results;
}

View File

@@ -0,0 +1,64 @@
// OpportunitySource — Strategy 인터페이스.
// 소스(사이트)별 어댑터는 이 클래스를 상속해 list()/fetchDetail() 을 구현한다.
import { crawl } from '../crawler/crawler.js';
/**
* 공고 목록 항목 형태:
* {
* externalId: string, // 소스 고유 키 (필수, dedup)
* title: string, // 제목 (필수)
* agency?: string, // 소관/주관기관
* category?: string, // 지원분야
* target?: string, // 지원대상
* applyStart?: Date, // 접수 시작
* applyEnd?: Date, // 접수 마감
* detailUrl?: string, // 상세 페이지 URL
* raw?: object, // 원본 데이터(JSON 저장)
* }
*/
export class OpportunitySource {
/** @param {{code:string,name:string,baseUrl?:string,type:'API'|'HTML',config?:object}} meta */
constructor(meta) {
if (!meta.code || !meta.name || !meta.type) {
throw new Error('OpportunitySource meta 에 code/name/type 필수');
}
this.code = meta.code;
this.name = meta.name;
this.baseUrl = meta.baseUrl || null;
this.type = meta.type;
this.config = meta.config || {};
// true 면 파이프라인이 상세 본문 수집 단계를 건너뛴다(목록 전용 소스).
this.skipDetail = false;
}
meta() {
return {
code: this.code,
name: this.name,
baseUrl: this.baseUrl,
type: this.type,
config: this.config,
};
}
/**
* 공고 목록을 수집한다. 하위 클래스에서 반드시 구현.
* @returns {Promise<Array>}
*/
async list() {
throw new Error(`${this.code}: list() 미구현`);
}
/**
* 상세 본문을 수집한다. 기본 구현은 detailUrl 을 3단계 폴백 크롤러로 긁는다.
* API 처럼 본문이 이미 raw 에 있는 소스는 이 메서드를 오버라이드한다.
* @param {{id:string, externalId:string, detailUrl:string, raw:object|null}} row
* @returns {Promise<string>} 본문 텍스트
*/
async fetchDetail(row) {
if (!row.detailUrl) {
throw new Error(`${this.code}/${row.externalId}: detailUrl 없음 — 상세 수집 불가`);
}
return crawl(row.detailUrl);
}
}

View File

@@ -0,0 +1,96 @@
// 기업마당 지원사업정보 Open API 어댑터 (bizinfo.go.kr 자체 인증키 crtfcKey 사용).
// 엔드포인트: /uss/rss/bizinfoApi.do — 페이지네이션 없음. searchCnt >= totCnt 면 전체 반환.
import { OpportunitySource } from './base.js';
import { config } from '../config.js';
import { log } from '../logger.js';
import {
decodeEntities,
nonEmpty,
stripHtml,
parsePeriodRange,
} from '../util.js';
const ENDPOINT = 'https://www.bizinfo.go.kr/uss/rss/bizinfoApi.do';
const MAX_CNT = 10_000; // 안전 상한(현재 totCnt ~1500)
export class BizinfoApiSource extends OpportunitySource {
constructor() {
super({
code: 'bizinfo',
name: '기업마당 지원사업',
baseUrl: 'https://www.bizinfo.go.kr',
type: 'API',
config: { endpoint: ENDPOINT },
});
}
static isAvailable() {
return Boolean(config.bizinfo.crtfcKey);
}
async #fetch(searchCnt) {
const url = new URL(ENDPOINT);
url.searchParams.set('crtfcKey', config.bizinfo.crtfcKey);
url.searchParams.set('dataType', 'json');
url.searchParams.set('searchCnt', String(searchCnt));
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
if (!res.ok) throw new Error(`기업마당 API HTTP ${res.status}`);
const json = await res.json();
if (json.reqErr) throw new Error(`기업마당 API 오류: ${json.reqErr}`);
if (!Array.isArray(json.jsonArray)) {
throw new Error(`기업마당 API 응답 형식 오류: ${JSON.stringify(json).slice(0, 200)}`);
}
return json.jsonArray;
}
#buildBody(item) {
const parts = [];
const summary = stripHtml(item.bsnsSumryCn);
if (summary) parts.push(summary);
const target = decodeEntities(nonEmpty(item.trgetNm));
if (target) parts.push(`[지원대상]\n${target}`);
const period = nonEmpty(item.reqstBeginEndDe);
if (period) parts.push(`[신청기간]\n${period}`);
const method = stripHtml(item.reqstMthPapersCn);
if (method) parts.push(`[신청방법]\n${method}`);
const exec = nonEmpty(item.excInsttNm);
if (exec) parts.push(`[수행기관]\n${exec}`);
const ref = nonEmpty(item.refrncNm);
if (ref) parts.push(`[문의처]\n${ref}`);
const files = nonEmpty(item.fileNm);
if (files) parts.push(`[첨부]\n${files.replace(/@/g, '\n')}`);
return parts.join('\n\n');
}
#map(item) {
const externalId = nonEmpty(item.pblancId);
const title = decodeEntities(nonEmpty(item.pblancNm));
if (!externalId || !title) {
throw new Error(`기업마당 항목 필수필드 누락: ${JSON.stringify(item).slice(0, 200)}`);
}
const { start, end } = parsePeriodRange(item.reqstBeginEndDe);
return {
externalId,
title,
agency: decodeEntities(nonEmpty(item.jrsdInsttNm)),
category: decodeEntities(nonEmpty(item.pldirSportRealmLclasCodeNm)),
target: decodeEntities(nonEmpty(item.trgetNm)),
applyStart: start,
applyEnd: end,
detailUrl: nonEmpty(item.pblancUrl),
body: this.#buildBody(item),
raw: item,
};
}
async list() {
// 1차: totCnt 파악
const probe = await this.#fetch(1);
const totCnt = probe[0]?.totCnt ? Number(probe[0].totCnt) : 0;
const cnt = Math.min(totCnt || MAX_CNT, MAX_CNT);
log.info(`기업마당 totCnt=${totCnt}${cnt}건 요청`);
const rows = await this.#fetch(cnt);
log.info(`기업마당 ${rows.length}건 수신`);
return rows.map((item) => this.#map(item));
}
}

View File

@@ -0,0 +1,142 @@
// GenericHtmlSource — 표(table) 기반 게시판형 공고 목록을 config 로 수집하는 범용 HTML 어댑터.
// 새 HTML 사이트는 코드 수정 없이 config 만 바꿔 추가할 수 있다(Strategy + 설정 주입).
//
// config 예시:
// {
// listUrl: 'https://.../List.do?cbIdx=310',
// pageParam: 'pageIndex', // 페이지 쿼리 파라미터 (없으면 단일 페이지)
// maxPages: 5,
// rowSelector: 'table tbody tr',
// title: { selector: 'td.subject a', attr: 'title' }, // attr 생략 시 text()
// externalId: { from: 'onclick', regex: "doBbsFView\\('\\d+','(\\d+)'" },
// detailUrl: { template: 'https://.../View.do?cbIdx=310&bcIdx={id}&parentSeq={id}' },
// agency: '중소벤처기업부', // 정적 소관기관(선택)
// }
import * as cheerio from 'cheerio';
import { OpportunitySource } from './base.js';
import { log } from '../logger.js';
import { decodeEntities, nonEmpty, parseFlexibleDate } from '../util.js';
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
export class GenericHtmlSource extends OpportunitySource {
constructor(meta) {
super({ ...meta, type: 'HTML' });
const c = this.config;
if (!c.listUrl || !c.rowSelector || !c.externalId) {
throw new Error(`${this.code}: config 에 listUrl/rowSelector/externalId 필수`);
}
// listOnly: 목록만 적재하고 상세 본문 수집은 건너뛴다(상세가 팝업/JS 라 직접 크롤 불가한 사이트).
this.skipDetail = c.listOnly === true;
}
#pageUrl(page) {
const c = this.config;
if (!c.pageParam) return c.listUrl;
const url = new URL(c.listUrl);
url.searchParams.set(c.pageParam, String(page));
return url.toString();
}
async #fetchHtml(url) {
const res = await fetch(url, {
headers: { 'User-Agent': UA },
redirect: 'follow',
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status} (${url})`);
return res.text();
}
#extractField($, row, spec) {
if (!spec) return null;
let el = spec.selector ? row.find(spec.selector).first() : row;
if (el.length === 0) return null;
let val;
if (spec.attr) val = el.attr(spec.attr);
else val = el.text();
return decodeEntities(nonEmpty(val));
}
#extractByRegex(text, pattern) {
if (!text || !pattern) return null;
const m = new RegExp(pattern).exec(text);
return m ? m[1] : null;
}
#mapRow($, el) {
const c = this.config;
const row = $(el);
// externalId: onclick / href / 선택자 텍스트에서 정규식 추출
let idSource;
if (c.externalId.from === 'onclick') idSource = row.attr('onclick') || row.find('[onclick]').first().attr('onclick');
else if (c.externalId.from === 'href') idSource = row.find('a').first().attr('href');
else idSource = this.#extractField($, row, c.externalId);
const externalId = c.externalId.regex
? this.#extractByRegex(idSource, c.externalId.regex)
: nonEmpty(idSource);
if (!externalId) return null; // 헤더행 등은 스킵
const title = this.#extractField($, row, c.title);
if (!title) return null;
let detailUrl = null;
if (c.detailUrl) {
detailUrl = c.detailUrl.template
? c.detailUrl.template.replace(/\{id\}/g, externalId)
: this.#extractField($, row, c.detailUrl);
}
// 신청기간 "26-06-10 ~ 26-06-24" → applyStart/applyEnd
let applyStart = null;
let applyEnd = null;
if (c.period) {
const periodText = this.#extractField($, row, c.period);
if (periodText) {
const sep = c.period.sep || '~';
const segs = periodText.split(sep).map((x) => x.trim());
applyStart = parseFlexibleDate(segs[0]);
applyEnd = parseFlexibleDate(segs[1] || segs[0]);
}
}
return {
externalId,
title,
agency: c.agency || this.#extractField($, row, c.agencyField) || null,
category: this.#extractField($, row, c.categoryField),
target: null,
applyStart,
applyEnd,
detailUrl,
raw: { onclick: row.attr('onclick') || null, title },
};
}
async list() {
const c = this.config;
const maxPages = c.maxPages || 1;
const out = [];
const seen = new Set();
for (let page = 1; page <= maxPages; page += 1) {
const url = this.#pageUrl(page);
const html = await this.#fetchHtml(url);
const $ = cheerio.load(html);
const rows = $(c.rowSelector);
let pageCount = 0;
rows.each((_, el) => {
const item = this.#mapRow($, el);
if (item && !seen.has(item.externalId)) {
seen.add(item.externalId);
out.push(item);
pageCount += 1;
}
});
log.info(`${this.code} page ${page}: ${pageCount}`);
if (pageCount === 0) break; // 더 이상 행이 없으면 종료
}
return out;
}
}

View File

@@ -0,0 +1,50 @@
// config 로 정의되는 HTML 게시판 소스 목록.
// 새 사이트는 여기 항목을 추가하면 된다(코드 로직 수정 불필요).
import { GenericHtmlSource } from './genericHtml.js';
export const HTML_SOURCE_CONFIGS = [
{
code: 'mss',
name: '중소벤처기업부 사업공고',
baseUrl: 'https://www.mss.go.kr',
config: {
listUrl: 'https://www.mss.go.kr/site/smba/ex/bbs/List.do?cbIdx=310',
pageParam: 'pageIndex',
maxPages: 3,
rowSelector: 'table tbody tr',
title: { selector: 'td.subject a', attr: 'title' },
externalId: { from: 'onclick', regex: "doBbsFView\\('\\d+','(\\d+)'" },
detailUrl: {
template:
'https://www.mss.go.kr/site/smba/ex/bbs/View.do?cbIdx=310&bcIdx={id}&parentSeq={id}',
},
agency: '중소벤처기업부',
},
},
{
code: 'smes',
name: '중소벤처24 사업공고',
baseUrl: 'https://www.smes.go.kr',
config: {
listUrl: 'https://www.smes.go.kr/main/bizApply',
maxPages: 1, // 최신 목록(ajax 페이징이라 1페이지). 데몬이 주기적으로 신규 포착.
rowSelector: 'table tbody tr',
// 행 앵커: javascript:fn_include_popOpen2('seq','idx','cd','PBLN_...','기관','상태')
externalId: { from: 'href', regex: '(PBLN_\\d+)' },
title: { selector: 'td:nth-child(2) a' },
categoryField: { selector: 'td:nth-child(5)' },
agencyField: { selector: 'td:nth-child(6)' },
period: { selector: 'td:nth-child(3)', sep: '~' },
// 상세는 팝업/JS(다이얼로그)라 직접 크롤 불가 → 목록 전용. URL은 참조용으로만 저장.
detailUrl: {
template:
'https://www.smes.go.kr/sii/siia/selectSIIA200Detail.do?pblancId={id}',
},
listOnly: true,
},
},
];
export function buildHtmlSources() {
return HTML_SOURCE_CONFIGS.map((cfg) => new GenericHtmlSource(cfg));
}

View File

@@ -0,0 +1,92 @@
// K-Startup 창업지원 사업공고 Open API 어댑터 (data.go.kr 서비스키 사용).
// 엔드포인트: getAnnouncementInformation (페이지네이션)
import { OpportunitySource } from './base.js';
import { config } from '../config.js';
import { log } from '../logger.js';
import { decodeEntities, parseYmd, nonEmpty } from '../util.js';
const ENDPOINT =
'https://nidapi.k-startup.go.kr/api/kisedKstartupService/v1/getAnnouncementInformation';
const PER_PAGE = 100;
const MAX_PAGES = Number(process.env.GOV_KSTARTUP_MAX_PAGES || 400); // 안전 상한(약 4만건)
export class KStartupApiSource extends OpportunitySource {
constructor() {
super({
code: 'kstartup',
name: 'K-Startup 창업지원 공고',
baseUrl: 'https://www.k-startup.go.kr',
type: 'API',
config: { endpoint: ENDPOINT, perPage: PER_PAGE },
});
}
static isAvailable() {
return Boolean(config.dataGoKr.apiKey);
}
async #fetchPage(page) {
const url = new URL(ENDPOINT);
url.searchParams.set('serviceKey', config.dataGoKr.apiKey);
url.searchParams.set('page', String(page));
url.searchParams.set('perPage', String(PER_PAGE));
url.searchParams.set('returnType', 'json');
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) {
throw new Error(`K-Startup API HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
const json = await res.json();
if (!Array.isArray(json.data)) {
throw new Error(`K-Startup API 응답 형식 오류: ${JSON.stringify(json).slice(0, 200)}`);
}
return json;
}
// API 가 제공하는 필드들로 본문을 조립한다 (별도 상세 크롤링 불필요).
#buildBody(item) {
const parts = [];
const content = decodeEntities(nonEmpty(item.pbanc_ctnt));
if (content) parts.push(content);
const target = decodeEntities(nonEmpty(item.aply_trgt_ctnt));
if (target) parts.push(`[지원대상]\n${target}`);
const exclude = decodeEntities(nonEmpty(item.aply_excl_trgt_ctnt));
if (exclude) parts.push(`[제외대상]\n${exclude}`);
const online = nonEmpty(item.aply_mthd_onli_rcpt_istc);
if (online) parts.push(`[온라인 접수]\n${online}`);
const guide = nonEmpty(item.biz_gdnc_url);
if (guide) parts.push(`[안내 URL]\n${guide}`);
return parts.join('\n\n');
}
#map(item) {
const externalId = item.pbanc_sn != null ? String(item.pbanc_sn) : null;
const title = decodeEntities(item.biz_pbanc_nm);
if (!externalId || !title) {
throw new Error(`K-Startup 항목 필수필드 누락: ${JSON.stringify(item).slice(0, 200)}`);
}
return {
externalId,
title,
agency: decodeEntities(nonEmpty(item.pbanc_ntrp_nm) || nonEmpty(item.sprv_inst)),
category: decodeEntities(nonEmpty(item.supt_biz_clsfc)),
target: decodeEntities(nonEmpty(item.aply_trgt_ctnt) || nonEmpty(item.aply_trgt)),
applyStart: parseYmd(item.pbanc_rcpt_bgng_dt),
applyEnd: parseYmd(item.pbanc_rcpt_end_dt),
detailUrl: nonEmpty(item.detl_pg_url),
body: this.#buildBody(item), // 목록 단계에서 본문까지 적재
raw: item,
};
}
async list() {
const out = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const json = await this.#fetchPage(page);
const rows = json.data;
log.info(`K-Startup page ${page}: ${rows.length}건 (totalCount=${json.totalCount})`);
for (const item of rows) out.push(this.#map(item));
if (rows.length < PER_PAGE) break; // 마지막 페이지
}
return out;
}
}

View File

@@ -0,0 +1,33 @@
// 사용 가능한 소스 어댑터 레지스트리.
// 키(서비스키 등)가 없는 소스는 자동 제외한다.
import { KStartupApiSource } from './kstartup.js';
import { BizinfoApiSource } from './bizinfo.js';
import { buildHtmlSources } from './htmlSources.js';
import { log } from '../logger.js';
// 키/설정 가용성 검사가 있는 API 소스 클래스들
const API_SOURCE_CLASSES = [KStartupApiSource, BizinfoApiSource];
/**
* 현재 환경에서 사용 가능한 소스 인스턴스 목록.
*/
export function availableSources() {
const out = [];
for (const Cls of API_SOURCE_CLASSES) {
if (typeof Cls.isAvailable === 'function' && !Cls.isAvailable()) {
log.warn(`소스 비활성(키/설정 없음): ${Cls.name}`);
continue;
}
out.push(new Cls());
}
// config 기반 HTML 소스(항상 가용)
out.push(...buildHtmlSources());
return out;
}
/**
* 특정 code 의 소스 하나만 가져온다(수동 실행용). 없으면 null.
*/
export function sourceByCode(code) {
return availableSources().find((s) => s.code === code) || null;
}

View File

@@ -0,0 +1,197 @@
// gov_source / gov_opportunity 적재 로직. 중복 제거는 (source_code, external_id) 유니크 키로 한다.
import { withConnection, oracledb } from '../db.js';
import { log } from '../logger.js';
function clobBind(val) {
return { dir: oracledb.BIND_IN, type: oracledb.DB_TYPE_CLOB, val: val ?? null };
}
/**
* 소스를 upsert 하고 RAWTOHEX(id) 를 반환한다.
*/
export async function ensureSource({ code, name, baseUrl, type, config }) {
return withConnection(async (conn) => {
await conn.execute(
`MERGE INTO gov_source t
USING (SELECT :code AS code FROM dual) s
ON (t.code = s.code)
WHEN MATCHED THEN UPDATE SET
name = :name, base_url = :baseUrl, type = :type,
config = :config, updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (id, code, name, base_url, type, config, active, created_at, updated_at)
VALUES (SYS_GUID(), :code, :name, :baseUrl, :type, :config, 1, SYSTIMESTAMP, SYSTIMESTAMP)`,
{
code,
name,
baseUrl: baseUrl ?? null,
type,
config: clobBind(config ? JSON.stringify(config) : null),
}
);
await conn.commit();
const r = await conn.execute(
`SELECT RAWTOHEX(id) AS id, active FROM gov_source WHERE code = :code`,
{ code }
);
return { id: r.rows[0][0], active: r.rows[0][1] === 1 };
});
}
/**
* 활성 소스 목록을 반환한다.
*/
export async function listActiveSources() {
return withConnection(async (conn) => {
const r = await conn.execute(
`SELECT RAWTOHEX(id) AS id, code, name, base_url, type, config
FROM gov_source WHERE active = 1 ORDER BY code`,
{},
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
);
return r.rows.map((row) => ({
id: row.ID,
code: row.CODE,
name: row.NAME,
baseUrl: row.BASE_URL,
type: row.TYPE,
config: row.CONFIG ? JSON.parse(row.CONFIG) : {},
}));
});
}
/**
* 목록 단계 공고들을 dedup-merge 한다. 기존 행의 본문/상세 상태는 보존한다.
* @returns {{inserted:number, updated:number}}
*/
export async function upsertOpportunities(sourceIdHex, sourceCode, items) {
if (!items || items.length === 0) return { inserted: 0, updated: 0 };
return withConnection(async (conn) => {
let inserted = 0;
let updated = 0;
// 신규/갱신 판별을 위해 기존 external_id 를 한 번에 로드(행당 SELECT 제거).
const existing = new Set();
{
const r = await conn.execute(
`SELECT external_id FROM gov_opportunity WHERE source_code = :sc`,
{ sc: sourceCode }
);
for (const row of r.rows) existing.add(String(row[0]));
}
for (const it of items) {
if (!it.externalId || !it.title) {
throw new Error(
`필수 필드 누락 (externalId/title): ${JSON.stringify(it).slice(0, 200)}`
);
}
const isNew = !existing.has(String(it.externalId));
const hasBody = it.body && it.body.trim() ? 1 : 0;
// body 가 있으면(API 처럼) 목록 단계에서 바로 본문 저장 → 상태 DETAILED.
// 기존 행 갱신 시 body 가 없으면 기존 본문/상태를 보존한다.
await conn.execute(
`MERGE INTO gov_opportunity t
USING (SELECT :sourceCode AS source_code, :externalId AS external_id FROM dual) s
ON (t.source_code = s.source_code AND t.external_id = s.external_id)
WHEN MATCHED THEN UPDATE SET
title = :title, agency = :agency, category = :category, target = :target,
apply_start = :applyStart, apply_end = :applyEnd, detail_url = :detailUrl,
raw_json = :rawJson,
body_text = CASE WHEN :hasBody = 1 THEN :body ELSE body_text END,
status = CASE WHEN :hasBody = 1 THEN 'DETAILED' ELSE status END,
detail_collected_at = CASE WHEN :hasBody = 1 THEN SYSTIMESTAMP ELSE detail_collected_at END,
updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT
(id, source_id, source_code, external_id, title, agency, category, target,
apply_start, apply_end, detail_url, raw_json, body_text, status,
list_collected_at, detail_collected_at, created_at, updated_at)
VALUES (SYS_GUID(), HEXTORAW(:sourceId), :sourceCode, :externalId, :title, :agency,
:category, :target, :applyStart, :applyEnd, :detailUrl, :rawJson, :body,
CASE WHEN :hasBody = 1 THEN 'DETAILED' ELSE 'LISTED' END,
SYSTIMESTAMP, CASE WHEN :hasBody = 1 THEN SYSTIMESTAMP ELSE NULL END,
SYSTIMESTAMP, SYSTIMESTAMP)`,
{
sourceId: sourceIdHex,
sourceCode,
externalId: String(it.externalId),
title: it.title.slice(0, 1000),
agency: it.agency ? it.agency.slice(0, 300) : null,
category: it.category ? it.category.slice(0, 200) : null,
target: it.target ? it.target.slice(0, 1000) : null,
applyStart: it.applyStart ?? null,
applyEnd: it.applyEnd ?? null,
detailUrl: it.detailUrl ? it.detailUrl.slice(0, 1000) : null,
rawJson: clobBind(it.raw ? JSON.stringify(it.raw) : null),
body: clobBind(hasBody ? it.body : null),
hasBody,
}
);
if (isNew) inserted += 1;
else updated += 1;
}
await conn.commit();
return { processed: items.length, inserted, updated };
});
}
/**
* 상세 본문 미수집(LISTED) 공고를 가져온다.
*/
export async function findPendingDetail(sourceCode, limit) {
return withConnection(async (conn) => {
const r = await conn.execute(
`SELECT RAWTOHEX(id) AS id, external_id, detail_url
FROM gov_opportunity
WHERE source_code = :sourceCode AND status = 'LISTED' AND detail_url IS NOT NULL
ORDER BY created_at
FETCH FIRST :lim ROWS ONLY`,
{ sourceCode, lim: limit },
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
);
return r.rows.map((row) => ({
id: row.ID,
externalId: row.EXTERNAL_ID,
detailUrl: row.DETAIL_URL,
}));
});
}
/**
* 상세 본문을 저장하고 상태를 DETAILED 로 갱신한다.
*/
export async function saveDetail(idHex, bodyText) {
return withConnection(async (conn) => {
await conn.execute(
`UPDATE gov_opportunity
SET body_text = :body, status = 'DETAILED',
detail_collected_at = SYSTIMESTAMP, updated_at = SYSTIMESTAMP
WHERE id = HEXTORAW(:id)`,
{ body: clobBind(bodyText), id: idHex }
);
await conn.commit();
});
}
/**
* 상세 수집 실패 표시.
*/
export async function markDetailError(idHex) {
return withConnection(async (conn) => {
await conn.execute(
`UPDATE gov_opportunity
SET status = 'ERROR', updated_at = SYSTIMESTAMP
WHERE id = HEXTORAW(:id)`,
{ id: idHex }
);
await conn.commit();
});
}
export async function markSourceCrawled(sourceIdHex) {
return withConnection(async (conn) => {
await conn.execute(
`UPDATE gov_source SET last_crawled_at = SYSTIMESTAMP, updated_at = SYSTIMESTAMP
WHERE id = HEXTORAW(:id)`,
{ id: sourceIdHex }
);
await conn.commit();
});
}

86
government/src/util.js Normal file
View File

@@ -0,0 +1,86 @@
// 공용 유틸: HTML 엔티티 디코드, YYYYMMDD 날짜 파싱.
const ENTITIES = {
'&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
'&#39;': "'", '&apos;': "'", '&nbsp;': ' ',
};
export function decodeEntities(s) {
if (s == null) return null;
return String(s)
.replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;|&nbsp;/g, (m) => ENTITIES[m])
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.trim();
}
/**
* 'YYYYMMDD' 또는 'YYYY-MM-DD' 를 Date 로. 형식 불일치면 null.
*/
export function parseYmd(s) {
if (s == null) return null;
const digits = String(s).replace(/[^0-9]/g, '');
if (digits.length !== 8) return null;
const y = Number(digits.slice(0, 4));
const m = Number(digits.slice(4, 6));
const d = Number(digits.slice(6, 8));
if (m < 1 || m > 12 || d < 1 || d > 31) return null;
return new Date(Date.UTC(y, m - 1, d));
}
/**
* 'YY-MM-DD' / 'YYYY-MM-DD' / 'YYYYMMDD' / 'YYMMDD' 를 Date 로. 불일치면 null.
* 6자리는 20YY 로 간주한다.
*/
export function parseFlexibleDate(s) {
if (s == null) return null;
const d = String(s).replace(/[^0-9]/g, '');
let y;
let mo;
let day;
if (d.length === 8) {
y = Number(d.slice(0, 4));
mo = Number(d.slice(4, 6));
day = Number(d.slice(6, 8));
} else if (d.length === 6) {
y = 2000 + Number(d.slice(0, 2));
mo = Number(d.slice(2, 4));
day = Number(d.slice(4, 6));
} else {
return null;
}
if (mo < 1 || mo > 12 || day < 1 || day > 31) return null;
return new Date(Date.UTC(y, mo - 1, day));
}
export function nonEmpty(s) {
if (s == null) return null;
const t = String(s).trim();
return t === '' ? null : t;
}
/**
* HTML 태그 제거 후 엔티티 디코드. <br>/<p>/</div> 는 줄바꿈으로.
*/
export function stripHtml(s) {
if (s == null) return null;
const text = String(s)
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6])\s*>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return decodeEntities(text);
}
/**
* "A ~ B" 형식 기간 문자열을 {start, end} Date 로. 날짜형이 아니면 null.
*/
export function parsePeriodRange(s, sep = '~') {
if (s == null) return { start: null, end: null };
const segs = String(s).split(sep).map((x) => x.trim());
return {
start: parseFlexibleDate(segs[0]),
end: parseFlexibleDate(segs[1] || segs[0]),
};
}