Compare commits
14 Commits
6c2129d42e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc0464afc | |||
| f3587eb130 | |||
| ffdcea009d | |||
| ad8d200474 | |||
| afff7a4703 | |||
| e48a45bf71 | |||
| cdce7b86bb | |||
| 82504e2261 | |||
| f2a8f30867 | |||
| cbc5ba5663 | |||
| 5700449bfd | |||
| 9569309e49 | |||
| 20210830cf | |||
| 1088b23790 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -68,3 +68,6 @@ oracle_data/
|
|||||||
# ========================
|
# ========================
|
||||||
.claude/
|
.claude/
|
||||||
cookies.txt
|
cookies.txt
|
||||||
|
audio-uploads/
|
||||||
|
voice-profiles/
|
||||||
|
*.wav
|
||||||
|
|||||||
203
docs/incident-2026-05-18-chrome-cdp-crawling.md
Normal file
203
docs/incident-2026-05-18-chrome-cdp-crawling.md
Normal 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>
|
||||||
@@ -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
6
government/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
# DB 접속 net 설정(지갑 경로/접속 디스크립터) — 환경별 재생성
|
||||||
|
oracle-net/
|
||||||
|
# 생성 데이터(공고 추출 CSV 등) — 매일 갱신
|
||||||
|
exports/
|
||||||
83
government/README.md
Normal file
83
government/README.md
Normal 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
54
government/db/schema.sql
Normal 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);
|
||||||
126
government/docs/apply-checklist.md
Normal file
126
government/docs/apply-checklist.md
Normal 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) — 공공기관
|
||||||
165
government/docs/business-plans-full.md
Normal file
165
government/docs/business-plans-full.md
Normal 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 ~8–10% (추정) | [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 ~8–9% | [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 고령자통계 원문.
|
||||||
91
government/docs/business-plans.md
Normal file
91
government/docs/business-plans.md
Normal 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 자문, (인쇄 연계) 출판 파트너.
|
||||||
29
government/docs/sources-catalog.md
Normal file
29
government/docs/sources-catalog.md
Normal 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
349
government/package-lock.json
generated
Normal 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
19
government/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
government/scripts/eligible.js
Normal file
37
government/scripts/eligible.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// 예비창업자가 지원 가능(자격에 '예비창업' 명시)하고 현재 열린 공고 전체를 마감일 순으로.
|
||||||
|
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/eligible.js
|
||||||
|
import { withConnection, closePool, oracledb } from '../src/db.js';
|
||||||
|
|
||||||
|
const SQL = `
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT title, category, agency, source_code, detail_url, apply_end,
|
||||||
|
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
|
||||||
|
FROM gov_opportunity
|
||||||
|
WHERE (apply_end IS NULL OR apply_end >= TRUNC(SYSDATE))
|
||||||
|
AND (
|
||||||
|
target LIKE '%' || :k1 || '%'
|
||||||
|
OR target LIKE '%' || :k2 || '%'
|
||||||
|
OR DBMS_LOB.INSTR(body_text, :k1) > 0
|
||||||
|
OR DBMS_LOB.INSTR(body_text, :k2) > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
ORDER BY dleft ASC
|
||||||
|
FETCH FIRST 80 ROWS ONLY`;
|
||||||
|
|
||||||
|
await withConnection(async (conn) => {
|
||||||
|
const r = await conn.execute(
|
||||||
|
SQL,
|
||||||
|
{ k1: '예비창업', k2: '예비 창업' },
|
||||||
|
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||||
|
);
|
||||||
|
console.log(`현재 신청가능(예비창업자 자격) 공고: ${r.rows.length}건\n`);
|
||||||
|
for (const x of r.rows) {
|
||||||
|
const tag = x.DLEFT === 9999 ? '[상시]' : `[D-${x.DLEFT}]`;
|
||||||
|
console.log(
|
||||||
|
`${tag}\t${x.SOURCE_CODE}\t${(x.CATEGORY || '-').slice(0, 8)}\t${x.TITLE.slice(0, 52)}\t${x.DETAIL_URL}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await closePool();
|
||||||
68
government/scripts/export_eligible_csv.js
Normal file
68
government/scripts/export_eligible_csv.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// 예비창업자 자격 + 현재 열린 공고 전체를 CSV 로 내보낸다(신청 추적용).
|
||||||
|
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/export_eligible_csv.js
|
||||||
|
import { writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { withConnection, closePool, oracledb } from '../src/db.js';
|
||||||
|
|
||||||
|
const SQL = `
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT title, category, agency, source_code, detail_url, apply_start, apply_end,
|
||||||
|
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
|
||||||
|
FROM gov_opportunity
|
||||||
|
WHERE (apply_end IS NULL OR apply_end >= TRUNC(SYSDATE))
|
||||||
|
AND (
|
||||||
|
target LIKE '%' || :k1 || '%' OR target LIKE '%' || :k2 || '%'
|
||||||
|
OR DBMS_LOB.INSTR(body_text, :k1) > 0 OR DBMS_LOB.INSTR(body_text, :k2) > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
ORDER BY dleft ASC, source_code`;
|
||||||
|
|
||||||
|
function csvCell(v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
const s = String(v).replace(/"/g, '""').replace(/\r?\n/g, ' ');
|
||||||
|
return `"${s}"`;
|
||||||
|
}
|
||||||
|
function ymd(d) {
|
||||||
|
return d ? new Date(d).toISOString().slice(0, 10) : '';
|
||||||
|
}
|
||||||
|
function region(title) {
|
||||||
|
const m = /^\[([^\]]{1,8})\]/.exec(title);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await withConnection(async (conn) => {
|
||||||
|
const r = await conn.execute(
|
||||||
|
SQL,
|
||||||
|
{ k1: '예비창업', k2: '예비 창업' },
|
||||||
|
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||||
|
);
|
||||||
|
return r.rows;
|
||||||
|
});
|
||||||
|
await closePool();
|
||||||
|
|
||||||
|
const header = ['신청완료', 'D-day', '마감일', '지역', '분야', '주관기관', '소스', '제목', '링크'];
|
||||||
|
const lines = [header.map(csvCell).join(',')];
|
||||||
|
for (const x of rows) {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
x.DLEFT === 9999 ? '상시' : `D-${x.DLEFT}`,
|
||||||
|
x.DLEFT === 9999 ? '상시/예산소진' : ymd(x.APPLY_END),
|
||||||
|
region(x.TITLE),
|
||||||
|
x.CATEGORY || '',
|
||||||
|
x.AGENCY || '',
|
||||||
|
x.SOURCE_CODE,
|
||||||
|
x.TITLE,
|
||||||
|
x.DETAIL_URL || '',
|
||||||
|
]
|
||||||
|
.map(csvCell)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(new URL('../exports/', import.meta.url), { recursive: true });
|
||||||
|
const out = new URL('../exports/eligible_opportunities.csv', import.meta.url);
|
||||||
|
// 엑셀 한글 깨짐 방지 BOM
|
||||||
|
await writeFile(out, '' + lines.join('\n'), 'utf8');
|
||||||
|
console.log(`내보냄: ${rows.length}건 → government/exports/eligible_opportunities.csv`);
|
||||||
185
government/scripts/generate_checklist.js
Normal file
185
government/scripts/generate_checklist.js
Normal 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`);
|
||||||
70
government/scripts/match.js
Normal file
70
government/scripts/match.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// 앱 후보별로 매칭되는 정부지원사업을 gov_opportunity 에서 조회한다.
|
||||||
|
// 실행: LD_LIBRARY_PATH=$ORACLE_IC_LIB_DIR node scripts/match.js
|
||||||
|
import { withConnection, closePool, oracledb } from '../src/db.js';
|
||||||
|
|
||||||
|
// 앱별 주제 키워드(제목/분야/대상/주관기관 대상으로 LIKE)
|
||||||
|
const APPS = {
|
||||||
|
Tasteby: ['외식', '맛집', '음식', '푸드', '소상공인', '관광', '로컬', '지역특화', '상권', '데이터바우처', 'AI바우처', '빅데이터'],
|
||||||
|
Lyricsy: ['콘텐츠', '한류', '케이팝', 'K-팝', '음악', '뮤직', '어학', '한국어', '에듀테크', '웹툰', '문화산업', '글로벌진출'],
|
||||||
|
ParentsStory: ['시니어', '고령', '노인', '실버', '돌봄', '복지', '사회적가치', '사회문제', '헬스케어', '웰니스', '온디바이스', '기록'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function queryApp(conn, keywords) {
|
||||||
|
// 키워드 OR 조건 (title/category/target/agency)
|
||||||
|
const binds = {};
|
||||||
|
const ors = keywords.map((kw, i) => {
|
||||||
|
binds[`k${i}`] = `%${kw}%`;
|
||||||
|
return `(title LIKE :k${i} OR category LIKE :k${i} OR target LIKE :k${i} OR agency LIKE :k${i})`;
|
||||||
|
});
|
||||||
|
const sql = `
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT title, category, agency, source_code, detail_url,
|
||||||
|
apply_end,
|
||||||
|
CASE WHEN apply_end IS NULL OR apply_end >= TRUNC(SYSDATE) THEN 1 ELSE 0 END AS is_open,
|
||||||
|
CASE WHEN apply_end IS NULL THEN 9999 ELSE (apply_end - TRUNC(SYSDATE)) END AS dleft,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY title ORDER BY CASE source_code WHEN 'kstartup' THEN 1 WHEN 'bizinfo' THEN 2 ELSE 3 END) rn
|
||||||
|
FROM gov_opportunity
|
||||||
|
WHERE ${ors.join(' OR ')}
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
ORDER BY is_open DESC, dleft ASC
|
||||||
|
FETCH FIRST 14 ROWS ONLY`;
|
||||||
|
const r = await conn.execute(sql, binds, { outFormat: oracledb.OUT_FORMAT_OBJECT });
|
||||||
|
return r.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countOpen(conn, keywords) {
|
||||||
|
const binds = {};
|
||||||
|
const ors = keywords.map((kw, i) => {
|
||||||
|
binds[`k${i}`] = `%${kw}%`;
|
||||||
|
return `(title LIKE :k${i} OR category LIKE :k${i} OR target LIKE :k${i} OR agency LIKE :k${i})`;
|
||||||
|
});
|
||||||
|
const r = await conn.execute(
|
||||||
|
`SELECT COUNT(DISTINCT title) total,
|
||||||
|
COUNT(DISTINCT CASE WHEN apply_end IS NULL OR apply_end >= TRUNC(SYSDATE) THEN title END) open_now
|
||||||
|
FROM gov_opportunity WHERE ${ors.join(' OR ')}`,
|
||||||
|
binds,
|
||||||
|
{ outFormat: oracledb.OUT_FORMAT_OBJECT }
|
||||||
|
);
|
||||||
|
return r.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '상시/별도';
|
||||||
|
return new Date(d).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
await withConnection(async (conn) => {
|
||||||
|
for (const [app, keywords] of Object.entries(APPS)) {
|
||||||
|
const cnt = await countOpen(conn, keywords);
|
||||||
|
console.log(`\n##### ${app} — 주제 매칭 총 ${cnt.TOTAL}건 (현재 신청가능 ${cnt.OPEN_NOW}건) #####`);
|
||||||
|
const rows = await queryApp(conn, keywords);
|
||||||
|
for (const r of rows) {
|
||||||
|
const tag = r.IS_OPEN === 1 ? (r.DLEFT === 9999 ? '[상시]' : `[D-${r.DLEFT}]`) : '[마감]';
|
||||||
|
console.log(
|
||||||
|
`${tag}\t${r.SOURCE_CODE}\t${r.CATEGORY || '-'}\t${(r.AGENCY || '-').slice(0, 16)}\t${r.TITLE.slice(0, 50)}\t${r.DETAIL_URL || '-'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await closePool();
|
||||||
19
government/src/bootstrap.js
vendored
Normal file
19
government/src/bootstrap.js
vendored
Normal 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
57
government/src/cli.js
Normal 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
43
government/src/config.js
Normal 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),
|
||||||
|
};
|
||||||
68
government/src/crawler/browser.js
Normal file
68
government/src/crawler/browser.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
government/src/crawler/crawler.js
Normal file
117
government/src/crawler/crawler.js
Normal 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
62
government/src/daemon.js
Normal 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
60
government/src/db.js
Normal 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
10
government/src/logger.js
Normal 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
106
government/src/pipeline.js
Normal 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;
|
||||||
|
}
|
||||||
64
government/src/sources/base.js
Normal file
64
government/src/sources/base.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
government/src/sources/bizinfo.js
Normal file
96
government/src/sources/bizinfo.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
142
government/src/sources/genericHtml.js
Normal file
142
government/src/sources/genericHtml.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
government/src/sources/htmlSources.js
Normal file
50
government/src/sources/htmlSources.js
Normal 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));
|
||||||
|
}
|
||||||
92
government/src/sources/kstartup.js
Normal file
92
government/src/sources/kstartup.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
government/src/sources/registry.js
Normal file
33
government/src/sources/registry.js
Normal 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;
|
||||||
|
}
|
||||||
197
government/src/store/opportunityStore.js
Normal file
197
government/src/store/opportunityStore.js
Normal 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
86
government/src/util.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 공용 유틸: HTML 엔티티 디코드, YYYYMMDD 날짜 파싱.
|
||||||
|
|
||||||
|
const ENTITIES = {
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"',
|
||||||
|
''': "'", ''': "'", ' ': ' ',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decodeEntities(s) {
|
||||||
|
if (s == null) return null;
|
||||||
|
return String(s)
|
||||||
|
.replace(/&|<|>|"|'|'| /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
35
start-chrome.sh
Executable 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
|
||||||
@@ -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(
|
||||||
|
ServerHttpRequest request, ServerHttpResponse response,
|
||||||
|
@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");
|
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
|
||||||
String refreshToken = cookie != null ? cookie.getValue() : null;
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
284
sundol-frontend/src/app/notes/[id]/page.tsx
Normal file
284
sundol-frontend/src/app/notes/[id]/page.tsx
Normal 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">
|
||||||
|
← 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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
sundol-frontend/src/app/notes/new/page.tsx
Normal file
166
sundol-frontend/src/app/notes/new/page.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
sundol-frontend/src/app/notes/page.tsx
Normal file
104
sundol-frontend/src/app/notes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
sundol-frontend/src/app/tts/page.tsx
Normal file
317
sundol-frontend/src/app/tts/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
85
sundol-frontend/src/components/speakable-text.tsx
Normal file
85
sundol-frontend/src/components/speakable-text.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
sundol-frontend/src/components/tts-reader.tsx
Normal file
159
sundol-frontend/src/components/tts-reader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
try {
|
||||||
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
const rt = localStorage.getItem("refreshToken");
|
||||||
return res.data.accessToken;
|
if (!rt) throw new Error("No refresh token");
|
||||||
} 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 {
|
const res = await api.post<LoginResponse>("/api/auth/refresh", { refreshToken: rt });
|
||||||
const newToken = await attemptRefresh(0);
|
const newAccess = res.data.accessToken;
|
||||||
|
const newRefresh = res.data.refreshToken;
|
||||||
|
|
||||||
api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`;
|
localStorage.setItem("accessToken", newAccess);
|
||||||
onTokenRefreshed?.(newToken);
|
if (newRefresh) localStorage.setItem("refreshToken", newRefresh);
|
||||||
|
api.defaults.headers.common["Authorization"] = `Bearer ${newAccess}`;
|
||||||
|
|
||||||
// 대기 중인 요청들 처리
|
onTokenRefreshed?.(newAccess);
|
||||||
processQueue(newToken, null);
|
processQueue(newAccess, null);
|
||||||
|
|
||||||
// 원래 요청 retry
|
originalRequest.headers["Authorization"] = `Bearer ${newAccess}`;
|
||||||
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
|
||||||
return api.request(originalRequest);
|
return api.request(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
processQueue(null, refreshError);
|
processQueue(null, refreshError);
|
||||||
|
|||||||
@@ -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.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("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
216
tts-server.py
Normal 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)
|
||||||
Reference in New Issue
Block a user