Compare commits

..

14 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
9569309e49 크롤링 복구: Chrome CDP를 PM2 sundol-chrome로 상시화
Chrome 136+가 기본 프로필 디렉토리에서 원격 디버깅(CDP)을 거부하여
4월 13일 이후 웹크롤링 3차 폴백/유튜브 자막 추출이 전부 실패하던 문제 해결.

- 프로필을 non-default 디렉토리(~/.config/google-chrome-cdp)로 이동해
  로그인 세션 유지한 채 CDP 허용
- start-chrome.sh 신규: 기존 Chrome 정리 + stale lock 제거 후
  --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 로 기동
- ecosystem.config.cjs: sundol-chrome PM2 앱 추가 (수동 실행 금지, PM2 통일)
  ※ frontend script의 /usr/local/bin/node 변경은 이전 작업분이 함께 포함됨
- PlaywrightBrowserService: CDP_URL을 127.0.0.1로 고정 (IPv6 ::1 해석 함정 제거)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 01:06:58 +00:00
20210830cf Fix TTS: switch to 1.7B with ref_audio, speakable text on all lines
- Use 1.7B model (0.6B had tensor mismatch with cached prompts)
- Speak endpoint uses ref_audio directly (not cached pkl) as fallback
- Cache voice clone prompts in memory on startup
- Add SpeakableText component: 🔊 icon on each p and li element
- Remove old TTSReader sequential approach
- Add global exception handler to TTS server
- Fix profile localStorage caching
- inference_mode + bf16 optimization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:14:06 +00:00
1088b23790 Add Notes, Voice Clone TTS, fix auth persistence and maxTokens
Notes:
- notes table with TEXT/AUDIO types, category support
- Audio upload → OpenRouter Gemini STT → OCI GenAI polish/summary
- Raw STT saved separately in raw_content column
- Polish/summary button for manual re-processing
- Async processing with real-time polling

Voice Clone TTS:
- Qwen3-TTS 1.7B model on A10 GPU via FastAPI server
- Voice profile registration (record/upload → save embedding)
- Profile-based TTS generation API
- TTS web page with recording, profile management, generation

Auth fixes:
- Store both access + refresh tokens in localStorage
- Initialize state from localStorage synchronously (no flash)
- Request interceptor reads token from localStorage every request
- Refresh via body (not just cookie)

Other fixes:
- maxTokens 4096 → 65536 (OCI GenAI Gemini supports up to 65536)
- Fix broken Korean chars in source files
- OpenRouter config for STT
- ffmpeg installed for audio conversion
- Ollama + Gemma 4 E4B installed (STT fallback)
- nginx proxy for TTS server (/api/tts/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:34:18 +00:00
51 changed files with 4990 additions and 124 deletions

3
.gitignore vendored
View File

@@ -68,3 +68,6 @@ oracle_data/
# ======================== # ========================
.claude/ .claude/
cookies.txt cookies.txt
audio-uploads/
voice-profiles/
*.wav

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

@@ -1,5 +1,14 @@
module.exports = { module.exports = {
apps: [ apps: [
{
name: "sundol-chrome",
script: "./start-chrome.sh",
interpreter: "/bin/bash",
cwd: "/home/opc/sundol",
env: {
DISPLAY: ":1",
},
},
{ {
name: "sundol-backend", name: "sundol-backend",
script: "./start-backend.sh", script: "./start-backend.sh",
@@ -12,7 +21,7 @@ module.exports = {
}, },
{ {
name: "sundol-frontend", name: "sundol-frontend",
script: "node", script: "/usr/local/bin/node",
args: "sundol-frontend/.next/standalone/server.js", args: "sundol-frontend/.next/standalone/server.js",
cwd: "/home/opc/sundol", cwd: "/home/opc/sundol",
env: { env: {
@@ -22,5 +31,15 @@ module.exports = {
NEXT_PUBLIC_GOOGLE_CLIENT_ID: "906390686133-vpqsisodkg6uqui469hg8dhupbejoa0d.apps.googleusercontent.com", 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]),
};
}

35
start-chrome.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# 사용자 Chrome을 CDP 디버깅 포트(9222)로 기동한다.
# PM2(sundol-chrome)가 관리하는 전용 스크립트이므로 수동 실행 금지.
# 봇 판정 우회를 위해 사용자 로그인 세션이 담긴 기존 프로필을 그대로 사용한다.
set -u
export DISPLAY=:1
PROFILE_DIR="/home/opc/.config/google-chrome-cdp"
DEBUG_PORT=9222
# 1) 동일 프로필을 쓰는 기존 Chrome 종료.
# 같은 user-data-dir로 Chrome이 이미 떠 있으면 새 인스턴스는
# 디버깅 포트를 열지 못하고 기존 인스턴스에 명령만 전달하고 끝난다.
pkill -TERM -f "/opt/google/chrome/chrome" 2>/dev/null || true
for _ in $(seq 1 10); do
pgrep -f "/opt/google/chrome/chrome" >/dev/null 2>&1 || break
sleep 1
done
pkill -KILL -f "/opt/google/chrome/chrome" 2>/dev/null || true
sleep 1
# 2) 비정상 종료로 남은 stale 싱글톤 락 정리.
rm -f "$PROFILE_DIR/SingletonLock" \
"$PROFILE_DIR/SingletonCookie" \
"$PROFILE_DIR/SingletonSocket" 2>/dev/null || true
# 3) Chrome 기동. exec로 foreground 유지 → PM2 fork 모드가 프로세스를 추적.
# --remote-debugging-address=127.0.0.1 로 IPv4 바인딩을 명시한다.
exec /usr/bin/google-chrome \
--user-data-dir="$PROFILE_DIR" \
--remote-debugging-port="$DEBUG_PORT" \
--remote-debugging-address=127.0.0.1 \
--no-first-run \
--no-default-browser-check \
--start-maximized

View File

@@ -45,11 +45,18 @@ public class AuthController {
} }
@PostMapping("/refresh") @PostMapping("/refresh")
public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) { public Mono<ResponseEntity<LoginResponse>> refresh(
HttpCookie cookie = request.getCookies().getFirst("refreshToken"); ServerHttpRequest request, ServerHttpResponse response,
String refreshToken = cookie != null ? cookie.getValue() : null; @RequestBody(required = false) Map<String, String> body) {
// 1차: body에서 refreshToken
String refreshToken = (body != null) ? body.get("refreshToken") : null;
// 2차: cookie에서 refreshToken
if (refreshToken == null || refreshToken.isBlank()) {
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
refreshToken = cookie != null ? cookie.getValue() : null;
}
if (refreshToken == null) { if (refreshToken == null || refreshToken.isBlank()) {
return Mono.just(ResponseEntity.status(401).build()); return Mono.just(ResponseEntity.status(401).build());
} }

View File

@@ -0,0 +1,561 @@
package com.sundol.controller;
import com.sundol.repository.CategoryRepository;
import com.sundol.repository.NoteRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/notes")
public class NoteController {
private static final Logger log = LoggerFactory.getLogger(NoteController.class);
private static final Path AUDIO_DIR = Path.of(System.getProperty("user.dir"), "audio-uploads");
private static final HttpClient httpClient = HttpClient.newHttpClient();
@Value("${openrouter.api-key:}")
private String openRouterApiKey;
@Value("${openrouter.model:google/gemini-2.5-flash}")
private String openRouterModel;
private final NoteRepository noteRepository;
private final CategoryRepository categoryRepository;
private final com.sundol.service.OciGenAiService genAiService;
public NoteController(NoteRepository noteRepository, CategoryRepository categoryRepository,
com.sundol.service.OciGenAiService genAiService) {
this.noteRepository = noteRepository;
this.categoryRepository = categoryRepository;
this.genAiService = genAiService;
try { Files.createDirectories(AUDIO_DIR); } catch (Exception ignored) {}
}
@GetMapping
public Mono<ResponseEntity<List<Map<String, Object>>>> list(
@AuthenticationPrincipal String userId,
@RequestParam(required = false) String categoryId) {
return Mono.fromCallable(() -> noteRepository.list(userId, categoryId))
.subscribeOn(Schedulers.boundedElastic())
.map(ResponseEntity::ok);
}
@GetMapping("/{id}")
public Mono<ResponseEntity<Map<String, Object>>> getById(
@AuthenticationPrincipal String userId,
@PathVariable String id) {
return Mono.fromCallable(() -> {
Map<String, Object> note = noteRepository.findById(userId, id);
if (note == null) return ResponseEntity.notFound().<Map<String, Object>>build();
return ResponseEntity.ok(note);
}).subscribeOn(Schedulers.boundedElastic());
}
@PostMapping
public Mono<ResponseEntity<Map<String, Object>>> create(
@AuthenticationPrincipal String userId,
@RequestBody Map<String, String> body) {
return Mono.fromCallable(() -> {
String title = body.getOrDefault("title", "");
String content = body.getOrDefault("content", "");
String categoryId = body.get("categoryId");
String id = noteRepository.insert(userId, title, content, "TEXT", null, categoryId);
return ResponseEntity.ok(Map.<String, Object>of("id", id));
}).subscribeOn(Schedulers.boundedElastic());
}
@PatchMapping("/{id}")
public Mono<ResponseEntity<Map<String, Object>>> update(
@AuthenticationPrincipal String userId,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return Mono.fromCallable(() -> {
String title = body.get("title");
String content = body.get("content");
String categoryId = body.get("categoryId");
noteRepository.update(id, userId, title, content, categoryId);
return ResponseEntity.ok(noteRepository.findById(userId, id));
}).subscribeOn(Schedulers.boundedElastic());
}
/**
* 기존 노트의 내용을 LLM으로 교정 + 요약 재실행
*/
@PostMapping("/{id}/polish")
public Mono<ResponseEntity<Map<String, Object>>> polishNote(
@AuthenticationPrincipal String userId,
@PathVariable String id) {
return Mono.fromCallable(() -> {
Map<String, Object> note = noteRepository.findById(userId, id);
if (note == null) return ResponseEntity.notFound().<Map<String, Object>>build();
String content = note.get("CONTENT") != null ? note.get("CONTENT").toString() : "";
if (content.isBlank()) return ResponseEntity.badRequest().<Map<String, Object>>build();
// raw_content가 있으면 그걸 사용, 없으면 content에서 전문 추출
Object rawObj = note.get("RAW_CONTENT");
String rawText = (rawObj != null && !rawObj.toString().isBlank()) ? rawObj.toString() : content;
if (rawText.contains("# 전문")) {
int idx = rawText.indexOf("# 전문");
rawText = rawText.substring(idx + "# 전문".length()).strip();
}
String noteType = note.get("NOTE_TYPE") != null ? note.get("NOTE_TYPE").toString() : "TEXT";
boolean isAudio = "AUDIO".equals(noteType) || note.get("AUDIO_PATH") != null;
noteRepository.updateNoteType(id, "TRANSCRIBING");
final String finalRawText = rawText;
Schedulers.boundedElastic().schedule(() -> {
try {
noteRepository.updateContent(id, finalRawText + "\n\n--- 텍스트 교정 중... ---");
String polished = polishTranscription(finalRawText);
noteRepository.updateContent(id, polished + "\n\n--- 요약 생성 중... ---");
String summary = summarizeTranscription(polished);
String result = "# 요약\n\n" + summary + "\n\n---\n\n# 전문\n\n" + polished;
String newTitle = generateAudioTitle(summary, java.time.LocalDateTime.now());
noteRepository.update(id, null, newTitle, result, null);
noteRepository.updateNoteType(id, isAudio ? "AUDIO" : "TEXT");
log.info("Polish + summary complete for note {}", id);
} catch (Exception e) {
log.error("Polish failed for note {}", id, e);
noteRepository.updateNoteType(id, isAudio ? "AUDIO" : "TEXT");
}
});
return ResponseEntity.ok(Map.<String, Object>of("status", "processing"));
}).subscribeOn(Schedulers.boundedElastic());
}
@DeleteMapping("/{id}")
public Mono<ResponseEntity<Void>> delete(
@AuthenticationPrincipal String userId,
@PathVariable String id) {
return Mono.fromRunnable(() -> noteRepository.delete(id, userId))
.subscribeOn(Schedulers.boundedElastic())
.then(Mono.just(ResponseEntity.ok().<Void>build()));
}
/**
* 오디오 파일 업로드 → Gemma 4 STT → 텍스트 노트 생성
*/
@PostMapping("/audio")
public Mono<ResponseEntity<Map<String, Object>>> uploadAudio(
@AuthenticationPrincipal String userId,
@RequestPart("file") FilePart filePart,
@RequestPart(value = "title", required = false) String title,
@RequestPart(value = "categoryId", required = false) String categoryId) {
final String inputTitle = (title != null && !title.isBlank()) ? title : "음성 변환 중...";
final String inputCategoryId = categoryId;
return Mono.fromCallable(() -> {
// 1. 파일 저장
String fileName = System.currentTimeMillis() + "_" + filePart.filename();
Path audioFile = AUDIO_DIR.resolve(fileName);
filePart.transferTo(audioFile).block();
log.info("Audio file saved: {} ({} bytes)", audioFile, Files.size(audioFile));
// 2. 노트 즉시 생성 (TRANSCRIBING 상태)
String id = noteRepository.insert(userId, inputTitle, "음성 변환을 시작합니다...", "TRANSCRIBING", fileName, inputCategoryId);
// 3. 백그라운드에서 STT 실행
Schedulers.boundedElastic().schedule(() -> {
try {
transcribeAsync(id, audioFile, inputTitle);
} catch (Exception e) {
log.error("Async transcription failed for note {}", id, e);
noteRepository.updateContent(id, "음성 변환에 실패했습니다: " + e.getMessage());
noteRepository.updateNoteType(id, "AUDIO_FAILED");
}
});
return ResponseEntity.ok(Map.<String, Object>of("id", id));
}).subscribeOn(Schedulers.boundedElastic());
}
/**
* 비동기 STT 처리.
* Step 1: OpenRouter (Gemini) STT → raw_content에 저장
* Step 2: OCI GenAI로 교정 → content에 저장
* Step 3: OCI GenAI로 요약 → content 앞에 추가
*/
private void transcribeAsync(String noteId, Path audioFile, String inputTitle) throws IOException, InterruptedException {
// === Step 1: STT (OpenRouter Gemini) ===
String rawResult = null;
if (openRouterApiKey != null && !openRouterApiKey.isBlank()) {
try {
noteRepository.updateContent(noteId, "Gemini로 음성 변환 중...");
rawResult = transcribeWithOpenRouter(audioFile);
log.info("OpenRouter STT: {} chars", rawResult != null ? rawResult.length() : 0);
} catch (Exception e) {
log.warn("OpenRouter STT failed: {}", e.getMessage());
noteRepository.updateContent(noteId, "Gemini STT 실패: " + e.getMessage());
}
}
// Gemma fallback (OpenRouter 실패 시)
if (rawResult == null || rawResult.isBlank()) {
try {
noteRepository.updateContent(noteId, "Gemma로 음성 변환 중...");
Path wavFile = convertToWav(audioFile);
rawResult = transcribeChunk(wavFile);
cleanup(wavFile, audioFile);
} catch (Exception e) {
log.error("All STT failed for note {}", noteId, e);
noteRepository.updateContent(noteId, "모든 음성 변환 실패: " + e.getMessage());
noteRepository.updateNoteType(noteId, "AUDIO_FAILED");
return;
}
}
if (rawResult == null || rawResult.isBlank()) {
noteRepository.updateContent(noteId, "음성 변환 결과가 비어있습니다.");
noteRepository.updateNoteType(noteId, "AUDIO_FAILED");
return;
}
// raw 텍스트를 별도 컬럼에 저장 + content에도 일단 저장
noteRepository.updateRawContent(noteId, rawResult);
noteRepository.updateContent(noteId, rawResult);
String sttTitle = java.time.LocalDateTime.now().format(
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " 음성 메모";
noteRepository.update(noteId, null,
inputTitle.equals("음성 변환 중...") ? sttTitle : inputTitle,
rawResult, null);
noteRepository.updateNoteType(noteId, "AUDIO");
log.info("STT raw saved: {} chars", rawResult.length());
// === Step 2: 교정 (OCI GenAI) ===
try {
noteRepository.updateNoteType(noteId, "TRANSCRIBING");
noteRepository.updateContent(noteId, rawResult + "\n\n--- 텍스트 교정 중 (OCI GenAI)... ---");
String polished = polishTranscription(rawResult);
log.info("Polish complete: {} chars", polished.length());
// === Step 3: 요약 (OCI GenAI) ===
noteRepository.updateContent(noteId, polished + "\n\n--- 요약 생성 중... ---");
String summary = summarizeTranscription(polished);
log.info("Summary complete: {} chars", summary.length());
// 최종 결과 저장
String result = "# 요약\n\n" + summary + "\n\n---\n\n# 전문\n\n" + polished;
String finalTitle = inputTitle.equals("음성 변환 중...")
? generateAudioTitle(summary, java.time.LocalDateTime.now())
: inputTitle;
noteRepository.update(noteId, null, finalTitle, result, null);
log.info("Final note saved: {} chars", result.length());
} catch (Exception e) {
log.warn("Polish/summary failed, keeping raw STT text: {}", e.getMessage());
}
noteRepository.updateNoteType(noteId, "AUDIO");
}
/**
* Gemma 4 E4B를 사용하여 오디오 파일을 텍스트로 변환
*/
/**
* 오디오 파일을 wav로 변환한다 (Ollama 호환성).
*/
private Path convertToWav(Path audioFile) throws IOException, InterruptedException {
String name = audioFile.getFileName().toString();
if (name.toLowerCase().endsWith(".wav")) return audioFile;
Path wavFile = audioFile.getParent().resolve(name.replaceAll("\\.[^.]+$", "") + ".wav");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-i", audioFile.toString(),
"-ar", "16000", "-ac", "1", "-y",
wavFile.toString()
);
pb.redirectErrorStream(true);
Process proc = pb.start();
String output = new String(proc.getInputStream().readAllBytes());
int exitCode = proc.waitFor();
if (exitCode != 0) {
log.error("ffmpeg conversion failed (exit {}): {}", exitCode, output.substring(0, Math.min(500, output.length())));
throw new IOException("오디오 변환 실패 (ffmpeg exit " + exitCode + ")");
}
log.info("Converted {} to wav: {} bytes", name, Files.size(wavFile));
return wavFile;
}
private static final int CHUNK_SECONDS = 180; // 3분 단위 분할
private String transcribeWithGemma(Path audioFile) throws IOException, InterruptedException {
Path wavFile = convertToWav(audioFile);
double duration = getAudioDuration(wavFile);
log.info("Audio duration: {}s", duration);
if (duration <= CHUNK_SECONDS) {
String result = transcribeChunk(wavFile);
cleanup(wavFile, audioFile);
return result;
}
// 긴 오디오: 3분 단위로 분할
int chunks = (int) Math.ceil(duration / CHUNK_SECONDS);
log.info("Splitting audio into {} chunks of {}s", chunks, CHUNK_SECONDS);
StringBuilder fullText = new StringBuilder();
for (int i = 0; i < chunks; i++) {
int start = i * CHUNK_SECONDS;
Path chunkFile = wavFile.getParent().resolve("chunk_" + i + "_" + System.currentTimeMillis() + ".wav");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-i", wavFile.toString(),
"-ss", String.valueOf(start), "-t", String.valueOf(CHUNK_SECONDS),
"-ar", "16000", "-ac", "1", "-y", chunkFile.toString()
);
pb.redirectErrorStream(true);
Process proc = pb.start();
proc.getInputStream().readAllBytes();
proc.waitFor();
log.info("Transcribing chunk {}/{} ({}s-{}s)", i + 1, chunks, start, Math.min(start + CHUNK_SECONDS, (int) duration));
try {
String chunkText = transcribeChunk(chunkFile);
if (!chunkText.isBlank()) {
if (fullText.length() > 0) fullText.append("\n\n");
fullText.append(chunkText);
}
} catch (Exception e) {
log.warn("Chunk {} failed: {}", i + 1, e.getMessage());
fullText.append("\n\n[chunk ").append(i + 1).append(" 변환 실패]");
} finally {
try { Files.deleteIfExists(chunkFile); } catch (Exception ignored) {}
}
}
cleanup(wavFile, audioFile);
String result = fullText.toString().strip();
if (result.isBlank()) throw new IOException("Gemma STT returned empty for all chunks");
return result;
}
private String transcribeChunk(Path wavFile) throws IOException, InterruptedException {
byte[] audioBytes = Files.readAllBytes(wavFile);
String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
log.info("Chunk base64: {} chars ({} MB)", base64Audio.length(), audioBytes.length / 1024 / 1024);
String payload = """
{
"model": "gemma4:e4b",
"messages": [{"role": "user", "content": "Transcribe the following audio to text accurately. Output only the spoken content in its original language. Do not add any description or translation.", "images": ["%s"]}],
"stream": false,
"options": {"num_ctx": 8000}
}
""".formatted(base64Audio);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:11434/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.timeout(java.time.Duration.ofMinutes(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("Gemma STT error {}: {}", response.statusCode(), response.body().substring(0, Math.min(500, response.body().length())));
throw new IOException("Gemma STT failed: HTTP " + response.statusCode());
}
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body());
return root.path("message").path("content").asText("").strip();
}
/**
* STT 결과를 LLM으로 교정한다.
* 발음 오인식 보정, 문장 구분, 불필요한 추임새 제거, 가독성 향상.
*/
private String polishTranscription(String rawText) {
if (!genAiService.isConfigured()) {
log.info("GenAI not configured, skipping polish");
return rawText;
}
try {
String systemMsg =
"당신은 전문 속기사입니다. 음성 인식(STT) 텍스트를 교정해주세요.\n\n" +
"## 규칙\n" +
"1. 발음 오인식 단어를 문맥에 맞게 보정하세요.\n" +
"2. 추임새(어, 음, 그, 아, 뭐, 이제, 근데)를 제거하세요.\n" +
"3. 문장 부호를 넣고 단락을 나누세요.\n" +
"4. 절대 요약하지 마세요. 원문의 모든 내용을 빠짐없이 유지하세요.\n" +
"5. 내용을 추가하거나 삭제하지 마세요. 교정만 하세요.\n" +
"6. 전문 용어와 고유 명사는 올바르게 표기하세요.\n" +
"7. 입력 텍스트와 비슷한 분량으로 출력하세요. 줄이지 마세요.\n" +
"8. Markdown 형식으로 출력하세요.";
// maxTokens 65536이므로 대부분 한 번에 처리 가능
if (rawText.length() <= 30000) {
log.info("Polishing in single call: {} chars", rawText.length());
return genAiService.chat(systemMsg,
"아래 STT 텍스트를 교정해주세요. 전체 내용을 빠짐없이 유지하세요:\n\n" + rawText, null).strip();
}
// 30000자 이상만 분할
StringBuilder polished = new StringBuilder();
int chunkSize = 20000;
int totalChunks = (int) Math.ceil((double) rawText.length() / chunkSize);
for (int i = 0; i < rawText.length(); i += chunkSize) {
int chunkNum = (i / chunkSize) + 1;
String chunk = rawText.substring(i, Math.min(i + chunkSize, rawText.length()));
log.info("Polishing chunk {}/{}: {} chars", chunkNum, totalChunks, chunk.length());
String result = genAiService.chat(systemMsg,
"아래 STT 텍스트를 교정해주세요. 전체 내용을 빠짐없이 유지하세요:\n\n" + chunk, null).strip();
if (polished.length() > 0) polished.append("\n\n");
polished.append(result);
}
return polished.toString();
} catch (Exception e) {
log.warn("Polish transcription failed, returning raw text: {}", e.getMessage());
return rawText;
}
}
/**
* 교정된 텍스트를 요약한다.
*/
private String summarizeTranscription(String polishedText) {
if (!genAiService.isConfigured()) return "";
try {
String systemMsg =
"당신은 회의록/녹음 요약 전문가입니다. 아래 텍스트를 요약해주세요.\n\n" +
"## 규칙\n" +
"1. 주요 논의 주제별로 소제목(##)을 나누어 요약하세요.\n" +
"2. 각 주제 아래 핵심 내용을 불릿 포인트로 정리하세요.\n" +
"3. 주요 결정 사항, 액션 아이템이 있다면 별도로 표시하세요.\n" +
"4. 원문과 같은 언어로 작성하세요.\n" +
"5. Markdown 형식으로 작성하세요.\n" +
"6. 원본 길이에 비례하여 요약하세요. 긴 내용은 상세하게, 짧은 내용은 간결하게.\n" +
"7. 중요한 수치, 이름, 기술명은 빠뜨리지 마세요.";
String content = polishedText.length() > 15000
? polishedText.substring(0, 15000) : polishedText;
return genAiService.chat(systemMsg, "아래 내용을 요약해주세요:\n\n" + content, null).strip();
} catch (Exception e) {
log.warn("Summarization failed: {}", e.getMessage());
return "";
}
}
/**
* LLM으로 음성 메모 제목을 생성한다. "일시 - 핵심 주제" 형태.
*/
private String generateAudioTitle(String summary, java.time.LocalDateTime dateTime) {
if (!genAiService.isConfigured() || summary.isBlank()) {
return dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " 음성 메모";
}
try {
String systemMsg = "음성 메모의 요약을 보고 10자 이내의 짧은 제목을 생성해주세요. " +
"제목만 출력하세요. 따옴표, 설명, 접두사 없이 제목만.";
String title = genAiService.chat(systemMsg, summary, null).strip()
.replaceAll("^\"|\"$", "").replaceAll("^'|'$", "");
if (title.length() > 40) title = title.substring(0, 40);
return dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " " + title;
} catch (Exception e) {
log.warn("Title generation failed: {}", e.getMessage());
return dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " 음성 메모";
}
}
/**
* OpenRouter API (Gemini 2.5 Flash)를 사용하여 오디오 STT. 한 번에 전체 파일 처리 가능.
*/
private String transcribeWithOpenRouter(Path audioFile) throws IOException, InterruptedException {
byte[] audioBytes = Files.readAllBytes(audioFile);
String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
String mimeType = "audio/wav";
String name = audioFile.getFileName().toString().toLowerCase();
if (name.endsWith(".mp3")) mimeType = "audio/mpeg";
else if (name.endsWith(".m4a")) mimeType = "audio/mp4";
else if (name.endsWith(".ogg")) mimeType = "audio/ogg";
else if (name.endsWith(".webm")) mimeType = "audio/webm";
else if (name.endsWith(".flac")) mimeType = "audio/flac";
log.info("OpenRouter STT: {} ({} MB, {})", name, audioBytes.length / 1024 / 1024, mimeType);
// OpenRouter chat/completions API with audio input
String payload = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
"model", openRouterModel,
"messages", List.of(Map.of(
"role", "user",
"content", List.of(
Map.of("type", "input_audio", "input_audio", Map.of(
"data", base64Audio,
"format", mimeType.substring(mimeType.indexOf('/') + 1)
)),
Map.of("type", "text", "text",
"Transcribe the audio accurately. Output only the spoken content in its original language. " +
"Do not add description, annotation, timestamps, or translation. " +
"If the audio contains Korean, output in Korean.")
)
))
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://openrouter.ai/api/v1/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + openRouterApiKey)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.timeout(java.time.Duration.ofMinutes(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("OpenRouter STT error {}: {}", response.statusCode(),
response.body().substring(0, Math.min(500, response.body().length())));
throw new IOException("OpenRouter STT failed: HTTP " + response.statusCode());
}
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body());
String text = root.path("choices").path(0).path("message").path("content").asText("").strip();
if (text.isBlank()) {
throw new IOException("OpenRouter STT returned empty result");
}
return text;
}
private double getAudioDuration(Path audioFile) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder("ffprobe", "-i", audioFile.toString(),
"-show_entries", "format=duration", "-v", "quiet", "-of", "csv=p=0");
pb.redirectErrorStream(true);
Process proc = pb.start();
String output = new String(proc.getInputStream().readAllBytes()).strip();
proc.waitFor();
try { return Double.parseDouble(output); } catch (NumberFormatException e) { return 0; }
}
private void cleanup(Path wavFile, Path originalFile) {
if (!wavFile.equals(originalFile)) {
try { Files.deleteIfExists(wavFile); } catch (Exception ignored) {}
}
}
}

View File

@@ -0,0 +1,148 @@
package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Clob;
import java.util.List;
import java.util.Map;
@Repository
public class NoteRepository {
private final JdbcTemplate jdbcTemplate;
public NoteRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public String insert(String userId, String title, String content, String noteType, String audioPath, String categoryId) {
if (categoryId != null) {
jdbcTemplate.update(
"INSERT INTO notes (id, user_id, title, content, note_type, audio_path, category_id, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, HEXTORAW(?), SYSTIMESTAMP, SYSTIMESTAMP)",
new Object[]{userId, title, content, noteType, audioPath, categoryId},
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
);
} else {
jdbcTemplate.update(
"INSERT INTO notes (id, user_id, title, content, note_type, audio_path, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, SYSTIMESTAMP, SYSTIMESTAMP)",
new Object[]{userId, title, content, noteType, audioPath},
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
);
}
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM notes WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId, String categoryId) {
if (categoryId != null && !categoryId.isBlank()) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(n.id) AS id, n.title, n.note_type, n.audio_path, " +
" RAWTOHEX(n.category_id) AS category_id, c.full_path AS category_path, " +
" n.created_at, n.updated_at " +
"FROM notes n LEFT JOIN categories c ON c.id = n.category_id " +
"WHERE n.user_id = HEXTORAW(?) AND n.category_id = HEXTORAW(?) " +
"ORDER BY n.created_at DESC",
userId, categoryId
);
}
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(n.id) AS id, n.title, n.note_type, n.audio_path, " +
" RAWTOHEX(n.category_id) AS category_id, c.full_path AS category_path, " +
" n.created_at, n.updated_at " +
"FROM notes n LEFT JOIN categories c ON c.id = n.category_id " +
"WHERE n.user_id = HEXTORAW(?) " +
"ORDER BY n.created_at DESC",
userId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(n.id) AS id, n.title, n.content, n.raw_content, n.note_type, n.audio_path, " +
" RAWTOHEX(n.category_id) AS category_id, c.full_path AS category_path, " +
" n.created_at, n.updated_at " +
"FROM notes n LEFT JOIN categories c ON c.id = n.category_id " +
"WHERE RAWTOHEX(n.id) = ? AND n.user_id = HEXTORAW(?)",
id, userId
);
if (results.isEmpty()) return null;
return convertClobFields(results.get(0));
}
public void update(String id, String userId, String title, String content, String categoryId) {
if (userId != null) {
if (categoryId != null) {
jdbcTemplate.update(
"UPDATE notes SET title = ?, content = ?, category_id = HEXTORAW(?), updated_at = SYSTIMESTAMP " +
"WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
new Object[]{title, content, categoryId, id, userId},
new int[]{java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
);
} else {
jdbcTemplate.update(
"UPDATE notes SET title = ?, content = ?, updated_at = SYSTIMESTAMP " +
"WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
new Object[]{title, content, id, userId},
new int[]{java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
);
}
} else {
// 내부 호출 (userId 없음)
jdbcTemplate.update(
"UPDATE notes SET title = ?, content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
new Object[]{title, content, id},
new int[]{java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR}
);
}
}
public void updateRawContent(String id, String rawContent) {
jdbcTemplate.update(
"UPDATE notes SET raw_content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
new Object[]{rawContent, id},
new int[]{java.sql.Types.CLOB, java.sql.Types.VARCHAR}
);
}
public void updateContent(String id, String content) {
jdbcTemplate.update(
"UPDATE notes SET content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
new Object[]{content, id},
new int[]{java.sql.Types.CLOB, java.sql.Types.VARCHAR}
);
}
public void updateNoteType(String id, String noteType) {
jdbcTemplate.update(
"UPDATE notes SET note_type = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
noteType, id
);
}
public void delete(String id, String userId) {
jdbcTemplate.update(
"DELETE FROM notes WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
private Map<String, Object> convertClobFields(Map<String, Object> row) {
for (var entry : row.entrySet()) {
Object val = entry.getValue();
if (val instanceof Clob clob) {
try {
entry.setValue(clob.getSubString(1, (int) clob.length()));
} catch (Exception e) {
entry.setValue(null);
}
}
}
return row;
}
}

View File

@@ -96,7 +96,7 @@ public class OciGenAiService {
Map.of("role", "SYSTEM", "content", List.of(Map.of("type", "TEXT", "text", systemMessage))), Map.of("role", "SYSTEM", "content", List.of(Map.of("type", "TEXT", "text", systemMessage))),
Map.of("role", "USER", "content", List.of(Map.of("type", "TEXT", "text", userMessage))) Map.of("role", "USER", "content", List.of(Map.of("type", "TEXT", "text", userMessage)))
), ),
"maxTokens", 4096, "maxTokens", 65536,
"temperature", 0.3 "temperature", 0.3
) )
); );

View File

@@ -20,7 +20,7 @@ import java.util.List;
public class PlaywrightBrowserService { public class PlaywrightBrowserService {
private static final Logger log = LoggerFactory.getLogger(PlaywrightBrowserService.class); private static final Logger log = LoggerFactory.getLogger(PlaywrightBrowserService.class);
private static final String CDP_URL = "http://localhost:9222"; private static final String CDP_URL = "http://127.0.0.1:9222";
private Playwright playwright; private Playwright playwright;
private Browser browser; private Browser browser;

View File

@@ -29,6 +29,10 @@ oci:
model: ${OCI_GENAI_MODEL:google.gemini-2.5-flash} model: ${OCI_GENAI_MODEL:google.gemini-2.5-flash}
base-url: ${OCI_GENAI_BASE_URL:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions} base-url: ${OCI_GENAI_BASE_URL:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions}
openrouter:
api-key: ${OPENROUTER_API_KEY:}
model: ${OPENROUTER_MODEL:google/gemini-2.5-flash}
jina: jina:
reader: reader:
api-key: ${JINA_READER_API_KEY:} api-key: ${JINA_READER_API_KEY:}

View File

@@ -6,6 +6,7 @@ import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api"; import { useApi } from "@/lib/use-api";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import SpeakableText from "@/components/speakable-text";
interface Category { interface Category {
ID: string; ID: string;
@@ -314,10 +315,18 @@ export default function KnowledgeDetailPage() {
h1: ({children}) => <h1 className="text-xl font-bold mt-6 mb-3">{children}</h1>, h1: ({children}) => <h1 className="text-xl font-bold mt-6 mb-3">{children}</h1>,
h2: ({children}) => <h2 className="text-lg font-bold mt-5 mb-2">{children}</h2>, h2: ({children}) => <h2 className="text-lg font-bold mt-5 mb-2">{children}</h2>,
h3: ({children}) => <h3 className="text-base font-bold mt-4 mb-2">{children}</h3>, h3: ({children}) => <h3 className="text-base font-bold mt-4 mb-2">{children}</h3>,
p: ({children}) => <p className="mb-3">{children}</p>, p: ({children, node}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const txt = node?.children?.map((c: any) => c.type === 'text' ? c.value : '').join('') || '';
return <p className="mb-3"><SpeakableText text={txt}>{children}</SpeakableText></p>;
},
ul: ({children}) => <ul className="list-disc ml-5 mb-3 space-y-1">{children}</ul>, ul: ({children}) => <ul className="list-disc ml-5 mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal ml-5 mb-3 space-y-1">{children}</ol>, ol: ({children}) => <ol className="list-decimal ml-5 mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="leading-relaxed">{children}</li>, li: ({children, node}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const txt = node?.children?.map((c: any) => c.type === 'text' ? c.value : '').join('') || '';
return <li className="leading-relaxed"><SpeakableText text={txt}>{children}</SpeakableText></li>;
},
strong: ({children}) => <strong className="font-bold">{children}</strong>, strong: ({children}) => <strong className="font-bold">{children}</strong>,
blockquote: ({children}) => <blockquote className="border-l-2 border-[var(--color-primary)] pl-4 my-3 italic text-[var(--color-text-muted)]">{children}</blockquote>, blockquote: ({children}) => <blockquote className="border-l-2 border-[var(--color-primary)] pl-4 my-3 italic text-[var(--color-text-muted)]">{children}</blockquote>,
code: ({children}) => <code className="bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded text-xs">{children}</code>, code: ({children}) => <code className="bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded text-xs">{children}</code>,

View File

@@ -0,0 +1,284 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
import ReactMarkdown from "react-markdown";
import SpeakableText from "@/components/speakable-text";
interface NoteDetail {
ID: string;
TITLE: string;
CONTENT: string;
RAW_CONTENT: string | null;
NOTE_TYPE: string;
AUDIO_PATH: string | null;
CATEGORY_PATH: string | null;
CREATED_AT: string;
UPDATED_AT: string;
}
export default function NoteDetailPage() {
const { request } = useApi();
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [note, setNote] = useState<NoteDetail | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editContent, setEditContent] = useState("");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [polishing, setPolishing] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const fetchNote = async () => {
try {
const data = await request<NoteDetail>({ method: "GET", url: `/api/notes/${id}` });
setNote(data);
setEditTitle(data.TITLE || "");
setEditContent(data.CONTENT || "");
} catch (err) {
console.error("Failed to load note:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchNote();
}, [id]);
// TRANSCRIBING 상태면 3초 폴링
useEffect(() => {
if (!note || note.NOTE_TYPE !== "TRANSCRIBING") return;
const interval = setInterval(fetchNote, 3000);
return () => clearInterval(interval);
}, [note?.NOTE_TYPE]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await request<NoteDetail>({
method: "PATCH",
url: `/api/notes/${id}`,
data: { title: editTitle, content: editContent },
});
setNote(updated);
setEditing(false);
} catch (err) {
console.error("Failed to update note:", err);
alert("저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm("정말 삭제하시겠습니까?")) return;
setDeleting(true);
try {
await request({ method: "DELETE", url: `/api/notes/${id}` });
router.push("/notes");
} catch (err) {
console.error("Failed to delete note:", err);
setDeleting(false);
}
};
if (loading) {
return (
<AuthGuard><NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<p className="text-[var(--color-text-muted)]">Loading...</p>
</main>
</AuthGuard>
);
}
if (!note) {
return (
<AuthGuard><NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<p className="text-red-400"> .</p>
<button onClick={() => router.push("/notes")} className="mt-4 text-sm text-[var(--color-primary)] hover:underline">
&larr; Back to Notes
</button>
</main>
</AuthGuard>
);
}
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<button
onClick={() => router.push("/notes")}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
>
&larr; Back to Notes
</button>
{/* 헤더 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded ${
note.NOTE_TYPE === "TRANSCRIBING" ? "bg-yellow-500/20 text-yellow-400" :
note.NOTE_TYPE === "AUDIO_FAILED" ? "bg-red-500/20 text-red-400" :
note.NOTE_TYPE === "AUDIO" ? "bg-purple-500/20 text-purple-400" : "bg-blue-500/20 text-blue-400"
}`}>
{note.NOTE_TYPE === "TRANSCRIBING" ? "변환 중..." :
note.NOTE_TYPE === "AUDIO_FAILED" ? "변환 실패" :
note.NOTE_TYPE === "AUDIO" ? "음성" : "텍스트"}
</span>
{note.CATEGORY_PATH && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{note.CATEGORY_PATH}
</span>
)}
</div>
{editing ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full px-3 py-1 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-xl font-bold mb-2"
/>
) : (
<h1 className="text-xl font-bold mb-2">{note.TITLE || "제목 없음"}</h1>
)}
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
<span>: {new Date(note.CREATED_AT).toLocaleString("ko-KR")}</span>
<span>: {new Date(note.UPDATED_AT).toLocaleString("ko-KR")}</span>
</div>
</div>
{/* 변환 중 인디케이터 */}
{note.NOTE_TYPE === "TRANSCRIBING" && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
<div className="w-4 h-4 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-yellow-400"> . .</span>
</div>
)}
{/* 내용 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
{editing ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={20}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
/>
) : (
<div className="text-sm leading-relaxed">
<ReactMarkdown
components={{
h1: ({children}) => <h1 className="text-xl font-bold mt-6 mb-3">{children}</h1>,
h2: ({children}) => <h2 className="text-lg font-bold mt-5 mb-2">{children}</h2>,
h3: ({children}) => <h3 className="text-base font-bold mt-4 mb-2">{children}</h3>,
p: ({children, node}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const txt = node?.children?.map((c: any) => c.type === 'text' ? c.value : '').join('') || '';
return <p className="mb-3"><SpeakableText text={txt}>{children}</SpeakableText></p>;
},
ul: ({children}) => <ul className="list-disc ml-5 mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal ml-5 mb-3 space-y-1">{children}</ol>,
li: ({children, node}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const txt = node?.children?.map((c: any) => c.type === 'text' ? c.value : '').join('') || '';
return <li className="leading-relaxed"><SpeakableText text={txt}>{children}</SpeakableText></li>;
},
strong: ({children}) => <strong className="font-bold">{children}</strong>,
blockquote: ({children}) => <blockquote className="border-l-2 border-[var(--color-primary)] pl-4 my-3 italic text-[var(--color-text-muted)]">{children}</blockquote>,
}}
>
{note.CONTENT || "내용 없음"}
</ReactMarkdown>
</div>
)}
</div>
{/* 원본 텍스트 (STT raw) */}
{note.RAW_CONTENT && (
<div className="mb-6">
<button
onClick={() => setShowRaw(!showRaw)}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-2"
>
{showRaw ? "▼ 원본 텍스트 숨기기" : "▶ STT 원본 텍스트 보기"}
</button>
{showRaw && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] opacity-70">
<p className="text-xs text-[var(--color-text-muted)] mb-2"> ( )</p>
<p className="text-sm whitespace-pre-wrap">{note.RAW_CONTENT}</p>
</div>
)}
</div>
)}
{/* 액션 */}
<div className="flex items-center gap-4">
{editing ? (
<>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm bg-[var(--color-primary)] rounded-lg disabled:opacity-40"
>
{saving ? "저장 중..." : "저장"}
</button>
<button
onClick={() => { setEditing(false); setEditTitle(note.TITLE || ""); setEditContent(note.CONTENT || ""); }}
className="px-4 py-2 text-sm bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg"
>
</button>
</>
) : (
<>
<button
onClick={() => setEditing(true)}
className="text-sm text-[var(--color-primary)] hover:underline"
>
</button>
<button
onClick={async () => {
setPolishing(true);
try {
await request({ method: "POST", url: `/api/notes/${id}/polish` });
await fetchNote();
} catch (err) {
console.error("Failed to polish:", err);
alert("교정/요약에 실패했습니다.");
} finally {
setPolishing(false);
}
}}
disabled={polishing || note.NOTE_TYPE === "TRANSCRIBING"}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{polishing ? "처리 중..." : "교정/요약"}
</button>
</>
)}
<button
onClick={handleDelete}
disabled={deleting}
className="text-sm text-red-400 hover:text-red-300 disabled:opacity-40"
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useState, useRef, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
export default function NewNotePageWrapper() {
return (
<Suspense fallback={<div className="p-8 text-center">Loading...</div>}>
<NewNotePage />
</Suspense>
);
}
function NewNotePage() {
const { request } = useApi();
const router = useRouter();
const searchParams = useSearchParams();
const isAudio = searchParams.get("type") === "audio";
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [transcribing, setTranscribing] = useState(false);
const [transcription, setTranscription] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSaveText = async () => {
if (!title.trim() && !content.trim()) return;
setSaving(true);
try {
await request({ method: "POST", url: "/api/notes", data: { title, content } });
router.push("/notes");
} catch (err) {
console.error("Failed to save note:", err);
alert("노트 저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
const handleUploadAudio = async () => {
if (!audioFile) return;
setTranscribing(true);
try {
const formData = new FormData();
formData.append("file", audioFile);
if (title.trim()) formData.append("title", title);
const result = await request<{ id: string; transcription: string }>({
method: "POST",
url: "/api/notes/audio",
data: formData,
headers: { "Content-Type": "multipart/form-data" },
});
setTranscription(result.transcription);
router.push(`/notes/${result.id}`);
} catch (err) {
console.error("Failed to transcribe audio:", err);
alert("음성 변환에 실패했습니다.");
} finally {
setTranscribing(false);
}
};
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<button
onClick={() => router.push("/notes")}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
>
&larr; Back to Notes
</button>
<h1 className="text-2xl font-bold mb-6">
{isAudio ? "음성 노트 작성" : "텍스트 노트 작성"}
</h1>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] space-y-4">
{/* 제목 */}
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isAudio ? "자동 생성됩니다 (선택)" : "노트 제목"}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
</div>
{isAudio ? (
/* 음성 업로드 */
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> </label>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
className="hidden"
/>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-[var(--color-border)] rounded-lg p-8 text-center cursor-pointer hover:border-[var(--color-primary)] transition-colors"
>
{audioFile ? (
<div>
<p className="font-medium">{audioFile.name}</p>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
{(audioFile.size / 1024 / 1024).toFixed(1)} MB
</p>
</div>
) : (
<div>
<p className="text-[var(--color-text-muted)]"> </p>
<p className="text-xs text-[var(--color-text-muted)] mt-1">MP3, WAV, M4A, OGG, WebM</p>
</div>
)}
</div>
{transcription && (
<div className="mt-4 p-4 bg-[var(--color-bg-hover)] rounded-lg">
<p className="text-sm text-[var(--color-text-muted)] mb-2"> :</p>
<p className="text-sm whitespace-pre-wrap">{transcription}</p>
</div>
)}
<button
onClick={handleUploadAudio}
disabled={!audioFile || transcribing}
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
>
{transcribing ? "변환 중..." : "음성 변환 및 저장"}
</button>
</div>
) : (
/* 텍스트 입력 */
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> (Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="노트 내용을 입력하세요..."
rows={15}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
/>
<button
onClick={handleSaveText}
disabled={saving || (!title.trim() && !content.trim())}
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
)}
</div>
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Note {
ID: string;
TITLE: string;
NOTE_TYPE: string;
CATEGORY_PATH: string | null;
CREATED_AT: string;
UPDATED_AT: string;
}
export default function NotesPage() {
const { request } = useApi();
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const data = await request<Note[]>({ method: "GET", url: "/api/notes" });
setNotes(data);
} catch (err) {
console.error("Failed to load notes:", err);
} finally {
setLoading(false);
}
})();
}, []);
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Notes</h1>
<div className="flex gap-2">
<Link
href="/notes/new"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
>
+
</Link>
<Link
href="/notes/new?type=audio"
className="px-4 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] rounded-lg transition-colors text-sm"
>
+
</Link>
</div>
</div>
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : notes.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]"> . .</p>
</div>
) : (
<div className="space-y-3">
{notes.map((note) => (
<Link
key={note.ID}
href={`/notes/${note.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
note.NOTE_TYPE === "TRANSCRIBING" ? "bg-yellow-500/20 text-yellow-400" :
note.NOTE_TYPE === "AUDIO_FAILED" ? "bg-red-500/20 text-red-400" :
note.NOTE_TYPE === "AUDIO" ? "bg-purple-500/20 text-purple-400" :
"bg-blue-500/20 text-blue-400"
}`}>
{note.NOTE_TYPE === "TRANSCRIBING" ? "변환 중..." :
note.NOTE_TYPE === "AUDIO_FAILED" ? "변환 실패" :
note.NOTE_TYPE === "AUDIO" ? "음성" : "텍스트"}
</span>
{note.CATEGORY_PATH && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{note.CATEGORY_PATH}
</span>
)}
</div>
<h3 className="font-medium truncate">{note.TITLE || "제목 없음"}</h3>
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
{new Date(note.CREATED_AT).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,317 @@
"use client";
import { useState, useRef, useEffect } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
interface VoiceProfile {
id: string;
name: string;
ref_text?: string;
}
export default function TTSPage() {
// 녹음
const [isRecording, setIsRecording] = useState(false);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// 프로필
const [profiles, setProfiles] = useState<VoiceProfile[]>([]);
const [selectedProfile, setSelectedProfile] = useState<string>("");
const [profileName, setProfileName] = useState("");
const [refText, setRefText] = useState("");
const [registering, setRegistering] = useState(false);
// TTS
const [text, setText] = useState("");
const [language, setLanguage] = useState("korean");
const [generating, setGenerating] = useState(false);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [serverStatus, setServerStatus] = useState("checking...");
// 탭
const [tab, setTab] = useState<"generate" | "register">("generate");
useEffect(() => {
fetch("/api/tts/health").then(r => r.json())
.then(d => setServerStatus(d.model_loaded ? "ready" : "loading..."))
.catch(() => setServerStatus("offline"));
fetchProfiles();
}, []);
const fetchProfiles = () => {
// 캐시 먼저
const cached = localStorage.getItem("tts_profiles");
if (cached) {
try {
const data = JSON.parse(cached);
setProfiles(data);
if (data.length > 0 && !selectedProfile) setSelectedProfile(data[0].id);
} catch {}
}
fetch("/api/tts/profiles").then(r => r.json())
.then(data => {
setProfiles(data);
localStorage.setItem("tts_profiles", JSON.stringify(data));
if (data.length > 0 && !selectedProfile) setSelectedProfile(data[0].id);
}).catch(() => {});
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
mediaRecorderRef.current = mr;
chunksRef.current = [];
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
mr.onstop = () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
setRecordedBlob(blob);
setRecordedUrl(URL.createObjectURL(blob));
setUploadedFile(null);
stream.getTracks().forEach(t => t.stop());
};
mr.start();
setIsRecording(true);
} catch (err) {
setError("마이크 접근 실패");
}
};
const stopRecording = () => { mediaRecorderRef.current?.stop(); setIsRecording(false); };
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) { setUploadedFile(file); setRecordedBlob(null); setRecordedUrl(URL.createObjectURL(file)); }
};
// 프로필 등록
const handleRegister = async () => {
const audio = uploadedFile || recordedBlob;
if (!audio || !profileName.trim()) return;
setRegistering(true);
setError(null);
try {
const fd = new FormData();
fd.append("name", profileName);
fd.append("ref_audio", audio, uploadedFile?.name || "recording.webm");
if (refText.trim()) fd.append("ref_text", refText);
const res = await fetch("/api/tts/profiles", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const result = await res.json();
setProfileName("");
setRefText("");
setRecordedBlob(null);
setRecordedUrl(null);
setUploadedFile(null);
fetchProfiles();
localStorage.removeItem("tts_profiles"); // 캐시 강제 갱신
setSelectedProfile(result.id);
setTab("generate");
} catch (err) {
setError("프로필 등록 실패: " + (err instanceof Error ? err.message : ""));
} finally {
setRegistering(false);
}
};
// 프로필 삭제
const handleDeleteProfile = async (id: string) => {
if (!confirm("삭제하시겠습니까?")) return;
await fetch(`/api/tts/profiles/${id}`, { method: "DELETE" });
fetchProfiles();
if (selectedProfile === id) setSelectedProfile("");
};
// TTS 생성 (프로필 기반)
const handleGenerate = async () => {
if (!text.trim() || !selectedProfile) return;
setGenerating(true);
setError(null);
setOutputUrl(null);
try {
const fd = new FormData();
fd.append("text", text);
fd.append("profile_id", selectedProfile);
fd.append("language", language);
const res = await fetch("/api/tts/speak", { method: "POST", body: fd });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (blob.size < 100) throw new Error("Empty audio");
setOutputUrl(URL.createObjectURL(blob));
} catch (err) {
setError("생성 실패: " + (err instanceof Error ? err.message : ""));
} finally {
setGenerating(false);
}
};
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Voice Clone (TTS)</h1>
<span className={`text-xs px-2 py-1 rounded ${
serverStatus === "ready" ? "bg-green-500/20 text-green-400" :
serverStatus === "offline" ? "bg-red-500/20 text-red-400" :
"bg-yellow-500/20 text-yellow-400"
}`}>{serverStatus}</span>
</div>
{/* 탭 */}
<div className="flex gap-2 mb-6">
<button onClick={() => setTab("generate")}
className={`px-4 py-2 text-sm rounded-lg ${tab === "generate" ? "bg-[var(--color-primary)] text-white" : "bg-[var(--color-bg-card)] border border-[var(--color-border)]"}`}>
</button>
<button onClick={() => setTab("register")}
className={`px-4 py-2 text-sm rounded-lg ${tab === "register" ? "bg-[var(--color-primary)] text-white" : "bg-[var(--color-bg-card)] border border-[var(--color-border)]"}`}>
</button>
</div>
{tab === "register" ? (
/* 프로필 등록 */
<div className="space-y-6">
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4"> </h2>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
15~30 .
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> </label>
<input type="text" value={profileName} onChange={e => setProfileName(e.target.value)}
placeholder="예: 내 목소리"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm" />
</div>
<div className="flex gap-3">
{isRecording ? (
<button onClick={stopRecording}
className="px-4 py-2 bg-red-500 hover:bg-red-600 rounded-lg text-sm flex items-center gap-2">
<span className="w-3 h-3 bg-white rounded-full animate-pulse" />
</button>
) : (
<button onClick={startRecording}
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg text-sm">
</button>
)}
<label className="px-4 py-2 bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg text-sm cursor-pointer hover:border-[var(--color-primary)]">
<input type="file" accept="audio/*" onChange={handleFileUpload} className="hidden" />
</label>
</div>
{recordedUrl && (
<audio controls src={recordedUrl} className="w-full" />
)}
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> ( - )</label>
<input type="text" value={refText} onChange={e => setRefText(e.target.value)}
placeholder="녹음에서 말한 내용"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm" />
</div>
<button onClick={handleRegister}
disabled={registering || !profileName.trim() || !(uploadedFile || recordedBlob)}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg disabled:opacity-40">
{registering ? "등록 중..." : "프로필 등록"}
</button>
</div>
</div>
{/* 등록된 프로필 목록 */}
{profiles.length > 0 && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="space-y-2">
{profiles.map(p => (
<div key={p.id} className="flex items-center justify-between p-3 bg-[var(--color-bg-hover)] rounded-lg">
<span className="text-sm font-medium">{p.name}</span>
<button onClick={() => handleDeleteProfile(p.id)}
className="text-xs text-red-400 hover:text-red-300"></button>
</div>
))}
</div>
</div>
)}
</div>
) : (
/* 음성 생성 */
<div className="space-y-6">
{/* 프로필 선택 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4">1. </h2>
{profiles.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">
.{" "}
<button onClick={() => setTab("register")} className="text-[var(--color-primary)] hover:underline">
</button>
</p>
) : (
<div className="flex flex-wrap gap-2">
{profiles.map(p => (
<button key={p.id} onClick={() => setSelectedProfile(p.id)}
className={`px-4 py-2 text-sm rounded-lg transition-colors ${
selectedProfile === p.id
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-hover)] border border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}>
{p.name}
</button>
))}
</div>
)}
</div>
{/* 텍스트 입력 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4">2. </h2>
<select value={language} onChange={e => setLanguage(e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm mb-3">
<option value="korean"></option>
<option value="english">English</option>
<option value="japanese"></option>
<option value="chinese"></option>
</select>
<textarea value={text} onChange={e => setText(e.target.value)}
placeholder="음성으로 변환할 텍스트를 입력하세요..." rows={5}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm resize-y" />
</div>
{/* 생성 */}
<button onClick={handleGenerate}
disabled={generating || !text.trim() || !selectedProfile || serverStatus !== "ready"}
className="w-full py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-xl text-lg font-semibold disabled:opacity-40">
{generating ? "생성 중..." : "음성 생성"}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">{error}</div>
)}
{outputUrl && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4"> </h2>
<audio controls src={outputUrl} className="w-full mb-3" />
<a href={outputUrl} download="tts_output.wav"
className="text-sm text-[var(--color-primary)] hover:underline"></a>
</div>
)}
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -7,10 +7,12 @@ import { useAuth } from "@/lib/auth-context";
const navItems = [ const navItems = [
{ href: "/dashboard", label: "Dashboard" }, { href: "/dashboard", label: "Dashboard" },
{ href: "/knowledge", label: "Knowledge" }, { href: "/knowledge", label: "Knowledge" },
{ href: "/notes", label: "Notes" },
{ href: "/chat", label: "Chat" }, { href: "/chat", label: "Chat" },
{ href: "/study", label: "Study" }, { href: "/study", label: "Study" },
{ href: "/todos", label: "Todos" }, { href: "/todos", label: "Todos" },
{ href: "/habits", label: "Habits" }, { href: "/habits", label: "Habits" },
{ href: "/tts", label: "TTS" },
{ href: "/settings", label: "Settings" }, { href: "/settings", label: "Settings" },
]; ];

View File

@@ -0,0 +1,85 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface SpeakableProps {
children: React.ReactNode;
text: string;
}
let cachedProfileId: string | null = null;
let profileChecked = false;
export default function SpeakableText({ children, text }: SpeakableProps) {
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
const [hasProfile, setHasProfile] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (profileChecked) {
setHasProfile(!!cachedProfileId);
return;
}
try {
const profiles = JSON.parse(localStorage.getItem("tts_profiles") || "[]");
if (profiles.length > 0) {
cachedProfileId = profiles[0].id;
setHasProfile(true);
}
profileChecked = true;
} catch {}
}, []);
const handleSpeak = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (playing) {
audioRef.current?.pause();
setPlaying(false);
return;
}
if (!cachedProfileId || text.length < 5) return;
setLoading(true);
try {
const fd = new FormData();
fd.append("text", text);
fd.append("profile_id", cachedProfileId);
fd.append("language", "Korean");
const res = await fetch("/api/tts/speak", { method: "POST", body: fd });
if (!res.ok) { setLoading(false); return; }
const blob = await res.blob();
if (blob.size < 200) { setLoading(false); return; }
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => setPlaying(false);
setPlaying(true);
setLoading(false);
audio.play();
} catch {
setLoading(false);
}
};
if (!hasProfile || text.length < 5) return <>{children}</>;
return (
<>
{children}
<button
onClick={handleSpeak}
disabled={loading}
className="inline-flex items-center ml-1 text-[var(--color-text-muted)] hover:text-[var(--color-primary)] disabled:opacity-30 align-middle"
title={playing ? "중지" : "읽어주기"}
style={{ fontSize: "0.85em", verticalAlign: "middle", cursor: "pointer" }}
>
{loading ? "⏳" : playing ? "⏹" : "🔊"}
</button>
</>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect, useRef } from "react";
interface TTSReaderProps {
text: string;
}
interface VoiceProfile {
id: string;
name: string;
}
export default function TTSReader({ text }: TTSReaderProps) {
const [profiles, setProfiles] = useState<VoiceProfile[]>([]);
const [selectedProfile, setSelectedProfile] = useState("");
const [generating, setGenerating] = useState(false);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState("");
const audioRef = useRef<HTMLAudioElement | null>(null);
const stoppedRef = useRef(false);
const audioUrlsRef = useRef<string[]>([]);
useEffect(() => {
// localStorage 캐시
const cached = localStorage.getItem("tts_profiles");
if (cached) {
try {
const data = JSON.parse(cached);
setProfiles(data);
if (data.length > 0) setSelectedProfile(data[0].id);
} catch {}
}
// 백그라운드에서 갱신 (블록 안 됨)
fetch("/api/tts/profiles").then(r => r.json()).then(data => {
setProfiles(data);
if (data.length > 0 && !selectedProfile) setSelectedProfile(data[0].id);
localStorage.setItem("tts_profiles", JSON.stringify(data));
}).catch(() => {});
}, []);
const toSentences = (md: string): string[] => {
return md
.replace(/^#+\s+.*$/gm, "")
.replace(/\*\*/g, "")
.replace(/^[-*]\s+/gm, "")
.replace(/^>\s+/gm, "")
.replace(/---+/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.split("\n")
.map(s => s.trim())
.filter(s => s.length >= 10);
};
// 직접 동기 호출 — 바로 wav 반환
const speak = async (chunk: string): Promise<string | null> => {
const fd = new FormData();
fd.append("text", chunk);
fd.append("profile_id", selectedProfile);
fd.append("language", "Korean");
const res = await fetch("/api/tts/speak", { method: "POST", body: fd });
if (!res.ok) return null;
const blob = await res.blob();
return blob.size > 100 ? URL.createObjectURL(blob) : null;
};
const handleGenerate = async () => {
if (!selectedProfile || !text.trim()) return;
setGenerating(true);
setPlaying(true);
stoppedRef.current = false;
audioUrlsRef.current = [];
const sentences = toSentences(text);
let isAudioPlaying = false;
let playIdx = 0;
const playNext = () => {
if (stoppedRef.current) return;
if (playIdx >= audioUrlsRef.current.length) { isAudioPlaying = false; return; }
isAudioPlaying = true;
const a = new Audio(audioUrlsRef.current[playIdx++]);
audioRef.current = a;
a.onended = () => {
if (stoppedRef.current) return;
playIdx < audioUrlsRef.current.length ? playNext() : (isAudioPlaying = false);
};
a.play();
};
for (let i = 0; i < sentences.length; i++) {
if (stoppedRef.current) break;
setProgress(`${i + 1}/${sentences.length}`);
const url = await speak(sentences[i]);
if (url && !stoppedRef.current) {
audioUrlsRef.current.push(url);
if (!isAudioPlaying) playNext();
}
}
setGenerating(false);
setProgress("");
if (!isAudioPlaying) setPlaying(false);
};
const handleStop = () => {
stoppedRef.current = true;
audioRef.current?.pause();
setPlaying(false);
setGenerating(false);
setProgress("");
};
const handleReplay = () => {
if (audioUrlsRef.current.length === 0) return;
stoppedRef.current = false;
setPlaying(true);
let idx = 0;
const play = () => {
if (idx >= audioUrlsRef.current.length || stoppedRef.current) { setPlaying(false); return; }
const audio = new Audio(audioUrlsRef.current[idx]);
audioRef.current = audio;
idx++;
audio.onended = play;
audio.play();
};
play();
};
if (profiles.length === 0) return null;
return (
<div className="flex items-center gap-2 flex-wrap">
<select value={selectedProfile} onChange={e => setSelectedProfile(e.target.value)}
className="text-xs px-2 py-1 rounded bg-[var(--color-bg-hover)] border border-[var(--color-border)]">
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{playing || generating ? (
<button onClick={handleStop}
className="text-xs px-3 py-1 bg-red-500/20 text-red-400 rounded hover:bg-red-500/30">
{progress || "중지"}
</button>
) : (
<button onClick={handleGenerate} disabled={!selectedProfile}
className="text-xs px-3 py-1 bg-[var(--color-primary)]/20 text-[var(--color-primary)] rounded hover:bg-[var(--color-primary)]/30 disabled:opacity-40">
</button>
)}
{audioUrlsRef.current.length > 0 && !playing && !generating && (
<button onClick={handleReplay}
className="text-xs px-3 py-1 bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded">
</button>
)}
</div>
);
}

View File

@@ -5,7 +5,7 @@ export const api = axios.create({
withCredentials: true, withCredentials: true,
}); });
// --- 공통 토큰 refresh 로직 (mutex 패턴) --- // --- refresh 로직 ---
let isRefreshing = false; let isRefreshing = false;
let pendingQueue: { let pendingQueue: {
@@ -13,7 +13,6 @@ let pendingQueue: {
reject: (error: unknown) => void; reject: (error: unknown) => void;
}[] = []; }[] = [];
// auth-context에서 주입하는 콜백
let onTokenRefreshed: ((token: string) => void) | null = null; let onTokenRefreshed: ((token: string) => void) | null = null;
let onRefreshFailed: (() => void) | null = null; let onRefreshFailed: (() => void) | null = null;
@@ -27,21 +26,27 @@ export function setAuthCallbacks(
function processQueue(token: string | null, error: unknown) { function processQueue(token: string | null, error: unknown) {
pendingQueue.forEach(({ resolve, reject }) => { pendingQueue.forEach(({ resolve, reject }) => {
if (token) { if (token) resolve(token);
resolve(token); else reject(error);
} else {
reject(error);
}
}); });
pendingQueue = []; pendingQueue = [];
} }
// 요청 인터셉터: 매 요청마다 localStorage에서 최신 토큰 읽기
api.interceptors.request.use((config) => {
const token = localStorage.getItem("accessToken");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 401이면 refresh 후 재시도
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// 401이 아니거나, refresh 요청 자체가 실패한 경우, 이미 retry한 경우 → 그냥 throw
if ( if (
error.response?.status !== 401 || error.response?.status !== 401 ||
originalRequest.url?.includes("/api/auth/") || originalRequest.url?.includes("/api/auth/") ||
@@ -50,7 +55,6 @@ api.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
// 이미 refresh 진행 중이면 큐에 대기
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
pendingQueue.push({ pendingQueue.push({
@@ -64,36 +68,25 @@ api.interceptors.response.use(
}); });
} }
// refresh 시작
isRefreshing = true; isRefreshing = true;
originalRequest._retry = true; originalRequest._retry = true;
const attemptRefresh = async (retryCount: number): Promise<string> => {
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
return res.data.accessToken;
} catch (err) {
const isNetworkError = !((err as AxiosError).response);
if (isNetworkError && retryCount < 2) {
// 네트워크 에러(서버 재시작 등)면 3초 후 재시도
await new Promise((r) => setTimeout(r, 3000));
return attemptRefresh(retryCount + 1);
}
throw err;
}
};
try { try {
const newToken = await attemptRefresh(0); const rt = localStorage.getItem("refreshToken");
if (!rt) throw new Error("No refresh token");
api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`; const res = await api.post<LoginResponse>("/api/auth/refresh", { refreshToken: rt });
onTokenRefreshed?.(newToken); const newAccess = res.data.accessToken;
const newRefresh = res.data.refreshToken;
// 대기 중인 요청들 처리 localStorage.setItem("accessToken", newAccess);
processQueue(newToken, null); if (newRefresh) localStorage.setItem("refreshToken", newRefresh);
api.defaults.headers.common["Authorization"] = `Bearer ${newAccess}`;
// 원래 요청 retry onTokenRefreshed?.(newAccess);
originalRequest.headers["Authorization"] = `Bearer ${newToken}`; processQueue(newAccess, null);
originalRequest.headers["Authorization"] = `Bearer ${newAccess}`;
return api.request(originalRequest); return api.request(originalRequest);
} catch (refreshError) { } catch (refreshError) {
processQueue(null, refreshError); processQueue(null, refreshError);

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react"; import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import { api, LoginResponse, setAuthCallbacks } from "./api"; import { api, LoginResponse, setAuthCallbacks } from "./api";
interface AuthContextType { interface AuthContextType {
@@ -21,93 +21,60 @@ const AuthContext = createContext<AuthContextType>({
setAccessToken: () => {}, setAccessToken: () => {},
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { function getStoredToken(): string | null {
const [accessToken, setAccessTokenState] = useState<string | null>(null); if (typeof window === "undefined") return null;
const [isLoading, setIsLoading] = useState(true); return localStorage.getItem("accessToken");
const logoutRef = useRef<() => void>(() => {}); }
// localStorage와 동기화하는 setter export function AuthProvider({ children }: { children: React.ReactNode }) {
const setAccessToken = useCallback((token: string | null) => { const [accessToken, setAccessTokenState] = useState<string | null>(getStoredToken);
setAccessTokenState(token); const [isLoading, setIsLoading] = useState(false);
if (token) {
localStorage.setItem("accessToken", token); const saveTokens = useCallback((access: string, refresh?: string) => {
} else { setAccessTokenState(access);
localStorage.removeItem("accessToken"); localStorage.setItem("accessToken", access);
if (refresh) localStorage.setItem("refreshToken", refresh);
api.defaults.headers.common["Authorization"] = `Bearer ${access}`;
}, []);
const clearTokens = useCallback(() => {
setAccessTokenState(null);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
delete api.defaults.headers.common["Authorization"];
}, []);
const login = useCallback((response: LoginResponse) => {
saveTokens(response.accessToken, response.refreshToken);
}, [saveTokens]);
const logout = useCallback(async () => {
try { await api.post("/api/auth/logout"); } catch {}
clearTokens();
window.location.href = "/login";
}, [clearTokens]);
// 인터셉터 콜백: 토큰 갱신 성공/실패 처리
useEffect(() => {
setAuthCallbacks(
(token: string) => {
setAccessTokenState(token);
localStorage.setItem("accessToken", token);
},
() => logout()
);
}, [logout]);
// 앱 로드 시 localStorage 토큰으로 헤더 설정
useEffect(() => {
const access = localStorage.getItem("accessToken");
if (access) {
api.defaults.headers.common["Authorization"] = `Bearer ${access}`;
} }
}, []); }, []);
// interceptor 콜백 등록
useEffect(() => {
setAuthCallbacks(
(token: string) => setAccessToken(token),
() => logoutRef.current()
);
}, [setAccessToken]);
useEffect(() => {
const restoreSession = async () => {
// 1차: localStorage에서 복원
const stored = localStorage.getItem("accessToken");
if (stored) {
setAccessTokenState(stored);
api.defaults.headers.common["Authorization"] = `Bearer ${stored}`;
setIsLoading(false);
return;
}
// 2차: refresh token cookie로 복원
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
const token = res.data.accessToken;
setAccessToken(token);
api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} catch {
// No valid session
} finally {
setIsLoading(false);
}
};
restoreSession();
}, [setAccessToken]);
useEffect(() => {
if (accessToken) {
api.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;
} else {
delete api.defaults.headers.common["Authorization"];
}
}, [accessToken]);
const login = useCallback((response: LoginResponse) => {
setAccessToken(response.accessToken);
}, [setAccessToken]);
const logout = useCallback(async () => {
try {
await api.post("/api/auth/logout");
} catch {
// Ignore logout errors
}
setAccessToken(null);
window.location.href = "/login";
}, [setAccessToken]);
// ref로 최신 logout 유지 (interceptor에서 사용)
useEffect(() => {
logoutRef.current = logout;
}, [logout]);
return ( return (
<AuthContext.Provider <AuthContext.Provider value={{ isAuthenticated: !!accessToken, isLoading, accessToken, login, logout, setAccessToken: (t) => saveTokens(t) }}>
value={{
isAuthenticated: !!accessToken,
isLoading,
accessToken,
login,
logout,
setAccessToken,
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

216
tts-server.py Normal file
View File

@@ -0,0 +1,216 @@
"""
Qwen3-TTS Voice Clone API Server (최적화 버전)
- 0.6B 모델 사용 (A10 속도 최적화)
- 모델 1회 로드, voice clone prompt 캐시
- inference_mode, bf16
- 문장 단위 분할
"""
import os
import io
import json
import pickle
import re
import tempfile
import time
import uuid
import threading
import numpy as np
import soundfile as sf
import torch
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import StreamingResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
PROFILES_DIR = os.path.join(os.path.dirname(__file__), "voice-profiles")
os.makedirs(PROFILES_DIR, exist_ok=True)
MODEL_NAME = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
model = None
prompt_cache = {} # profile_id → voice_clone_prompt
def get_model():
global model
if model is None:
from qwen_tts import Qwen3TTSModel
print(f"Loading {MODEL_NAME}...")
torch.set_grad_enabled(False)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
model = Qwen3TTSModel.from_pretrained(
MODEL_NAME, device_map="cuda:0", dtype=dtype,
)
# 프로필 프롬프트 캐시 로드
load_all_prompts()
print("Model loaded!")
return model
def load_all_prompts():
"""모든 프로필의 voice clone prompt를 메모리에 캐시"""
global prompt_cache
for f in os.listdir(PROFILES_DIR):
if f.endswith(".pkl"):
pid = f.replace(".pkl", "")
try:
with open(os.path.join(PROFILES_DIR, f), "rb") as fh:
prompt_cache[pid] = pickle.load(fh)
print(f" Cached prompt: {pid}")
except Exception as e:
print(f" Failed to cache {pid}: {e}")
def get_prompt(profile_id: str):
"""캐시에서 프롬프트 가져오기, 없으면 파일에서 로드"""
if profile_id in prompt_cache:
return prompt_cache[profile_id]
pkl_path = os.path.join(PROFILES_DIR, f"{profile_id}.pkl")
if os.path.exists(pkl_path):
with open(pkl_path, "rb") as f:
prompt = pickle.load(f)
prompt_cache[profile_id] = prompt
return prompt
return None
# === API ===
@app.get("/health")
@app.get("/api/tts/health")
def health():
return {"status": "ok", "model": MODEL_NAME, "model_loaded": model is not None}
@app.get("/api/tts/profiles")
def list_profiles():
profiles = []
for f in os.listdir(PROFILES_DIR):
if f.endswith(".json"):
with open(os.path.join(PROFILES_DIR, f)) as fh:
profiles.append(json.load(fh))
return profiles
@app.post("/api/tts/profiles")
async def create_profile(
name: str = Form(...),
ref_audio: UploadFile = File(...),
ref_text: str = Form(""),
):
m = get_model()
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
content = await ref_audio.read()
tmp.write(content)
tmp_path = tmp.name
try:
if not ref_audio.filename.endswith(".wav"):
wav_path = tmp_path + "_converted.wav"
os.system(f'ffmpeg -i "{tmp_path}" -ar 16000 -ac 1 -y "{wav_path}" 2>/dev/null')
os.unlink(tmp_path)
tmp_path = wav_path
kwargs = {"ref_audio": tmp_path}
if ref_text and ref_text.strip():
kwargs["ref_text"] = ref_text
else:
kwargs["x_vector_only_mode"] = True
with torch.inference_mode():
prompt = m.create_voice_clone_prompt(**kwargs)
profile_id = name.replace(" ", "_").lower()
# wav, pkl, json 저장
import shutil
shutil.copy2(tmp_path, os.path.join(PROFILES_DIR, f"{profile_id}.wav"))
with open(os.path.join(PROFILES_DIR, f"{profile_id}.pkl"), "wb") as f:
pickle.dump(prompt, f)
with open(os.path.join(PROFILES_DIR, f"{profile_id}.json"), "w") as f:
json.dump({"id": profile_id, "name": name, "ref_text": ref_text}, f, ensure_ascii=False)
# 캐시에 추가
prompt_cache[profile_id] = prompt
return {"id": profile_id, "name": name, "status": "created"}
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
@app.delete("/api/tts/profiles/{profile_id}")
def delete_profile(profile_id: str):
for ext in [".pkl", ".json", ".wav"]:
p = os.path.join(PROFILES_DIR, f"{profile_id}{ext}")
if os.path.exists(p):
os.unlink(p)
prompt_cache.pop(profile_id, None)
return {"status": "deleted"}
@app.post("/api/tts/speak")
async def speak(
text: str = Form(...),
profile_id: str = Form(...),
language: str = Form("Korean"),
):
"""한 문장 TTS — 캐시된 프롬프트 사용, 바로 wav 반환"""
m = get_model()
prompt = get_prompt(profile_id)
if prompt is None:
# 프롬프트가 없으면 ref_audio로 직접
meta_path = os.path.join(PROFILES_DIR, f"{profile_id}.json")
ref_audio_path = os.path.join(PROFILES_DIR, f"{profile_id}.wav")
if not os.path.exists(ref_audio_path):
return {"error": "Profile not found"}, 404
with open(meta_path) as f:
meta = json.load(f)
kwargs = {"text": text, "language": language, "ref_audio": ref_audio_path}
if meta.get("ref_text"):
kwargs["ref_text"] = meta["ref_text"]
else:
kwargs["x_vector_only_mode"] = True
start = time.perf_counter()
with torch.inference_mode():
wavs, sr = m.generate_voice_clone(**kwargs)
elapsed = time.perf_counter() - start
else:
start = time.perf_counter()
with torch.inference_mode():
wavs, sr = m.generate_voice_clone(
text=text, language=language, voice_clone_prompt=prompt,
)
elapsed = time.perf_counter() - start
audio_data = np.array(wavs[0], dtype=np.float32)
print(f"speak: {len(text)} chars → {len(audio_data)/sr:.1f}s audio in {elapsed:.1f}s")
buf = io.BytesIO()
sf.write(buf, audio_data, sr, format="WAV")
buf.seek(0)
return StreamingResponse(buf, media_type="audio/wav")
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
import traceback
traceback.print_exc()
return JSONResponse(status_code=500, content={"error": str(exc)})
if __name__ == "__main__":
import uvicorn
get_model()
uvicorn.run(app, host="0.0.0.0", port=8090)