Compare commits

...

5 Commits

Author SHA1 Message Date
6c2129d42e Add category view, pagination, and persist login across deployments
- Add 2-panel category view: sidebar tree + filtered item list
- Category counts use DISTINCT with descendant inclusion
- Hide empty categories, show category badges on item cards
- Add client-side pagination (10 items/page) for both views
- Persist access token in localStorage to survive page refresh
- Fix token refresh retry on backend restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:11:08 +00:00
f9f710ec90 Add English level settings, improve content structuring and rendering
- Add english_level column to users table (CEFR with TOEIC mapping)
- Add UserController (GET/PATCH /api/users/me) and Settings page
- Enhance structuring prompts: sequential TOC, no summary sections,
  no content overlap, English expression extraction by CEFR level
- Remove sub-TOC analysis (caused content repetition), use simple
  per-section generation with truncation detection and continuation
- Fix CLOB truncation: explicit Clob-to-String conversion in repository
- Replace regex-based markdown rendering with react-markdown
- Add wallet renewal procedure to troubleshooting docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:48:38 +00:00
4cde775809 Switch to user Chrome CDP for YouTube transcript, fix auth and ads
- Replace Playwright standalone browser with CDP connection to user Chrome
  (bypasses YouTube bot detection by using logged-in Chrome session)
- Add video playback, ad detection/skip, and play confirmation before transcript extraction
- Extract transcript JS to separate resource files (fix SyntaxError in evaluate)
- Add ytInitialPlayerResponse-based transcript extraction as primary method
- Fix token refresh: retry on network error during backend restart
- Fix null userId logout, CLOB type hint for structured_content
- Disable XFCE screen lock/screensaver
- Add troubleshooting entries (#10-12) and YouTube transcript guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:01:49 +00:00
9abb770e37 Add knowledge structuring feature with incremental LLM processing
- Add structured_content column and STRUCTURING pipeline step
- Split LLM structuring into TOC + per-section calls to avoid token limit
- Save intermediate results to DB for real-time frontend polling (3s)
- Add manual "정리하기" button with async processing
- Fix browser login modal by customizing authentication entry point
- Fix standalone build symlinks for server.js and static files
- Add troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:43:21 +00:00
afc9cdcde6 Refactor Playwright to singleton browser with tab-based crawling
- Add PlaywrightBrowserService: singleton Chromium browser with auto-recovery
- Refactor WebCrawlerService/YouTubeTranscriptService to use shared browser tabs
- Fix YouTube transcript: extract from DOM panel + fmt=json3 fallback
- Keep browser window alive (about:blank instead of page.close)
- Add docs: X Window setup, operation manual, crawling guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:18:33 +00:00
30 changed files with 3013 additions and 262 deletions

223
docs/crawling-guide.md Normal file
View File

@@ -0,0 +1,223 @@
# 웹 크롤링 & YouTube 자막 추출 가이드
## 아키텍처 개요
```
┌─────────────────────────────────────────────────────┐
│ Spring Boot Application │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ PlaywrightBrowserService │ │
│ │ (싱글톤 Chromium 브라우저, 앱 기동 시 1회) │ │
│ │ - @PostConstruct: 브라우저 launch │ │
│ │ - @PreDestroy: 브라우저 close │ │
│ │ - 탭(Page) 단위로 작업 수행 │ │
│ │ - 브라우저 죽으면 자동 재시작 │ │
│ └──────────┬───────────────┬────────────────────┘ │
│ │ │ │
│ ┌──────────▼──────┐ ┌────▼──────────────────┐ │
│ │ WebCrawlerService│ │YouTubeTranscriptService│ │
│ │ (일반 웹페이지) │ │ (YouTube 자막) │ │
│ └─────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
## 1. PlaywrightBrowserService (싱글톤 브라우저)
### 핵심 설계
- **싱글톤 패턴**: 앱 기동 시 Chromium을 한 번 띄우고 종료 시까지 유지
- **탭 기반 작업**: 각 크롤링 요청마다 새 탭(`Page`)을 열고, 완료 후 `about:blank`로 이동 (브라우저 창 유지)
- **자동 복구**: `ensureBrowserAlive()`로 브라우저 crash 시 자동 재시작
- **쿠키 로딩**: `cookies.txt`에서 YouTube/Google 쿠키를 읽어 봇 차단 우회
### 왜 싱글톤인가?
| 항목 | 매번 브라우저 생성 | 싱글톤 브라우저 |
|------|-------------------|----------------|
| 기동 시간 | 요청마다 2~3초 | 최초 1회만 |
| 메모리 | 매번 할당/해제 | 안정적 유지 |
| 쿠키/세션 | 매번 재로딩 | 유지됨 |
| VNC 디버깅 | 창이 뜨고 사라짐 | 항상 떠있어 관찰 가능 |
### 주요 메서드
```java
// 새 탭 열기 (호출자가 사용 후 about:blank 또는 close)
Page openPage(String url, int timeoutMs)
Page openPage(String url) // 기본 30초
// 브라우저 페이지 내에서 JS fetch 실행 (쿠키/세션 유지)
String fetchInPage(Page page, String url)
```
### 탭 정리 방식
```java
// page.close() 대신 about:blank로 이동 → 브라우저 창이 유지됨
try {
page.navigate("about:blank");
} catch (Exception ignored) {
page.close(); // about:blank 실패 시에만 탭 닫기
}
```
### 쿠키 파일 형식 (`cookies.txt`)
Netscape 쿠키 형식 (탭 구분):
```
.youtube.com / FALSE TRUE 1780000000 COOKIE_NAME COOKIE_VALUE
```
필드 순서: `domain`, `path`, `hostOnly(무시)`, `secure`, `expiry(무시)`, `name`, `value`
YouTube/Google 도메인 쿠키만 로드됨.
## 2. WebCrawlerService (일반 웹페이지 크롤링)
### 3단계 Fallback 전략
```
1차: Jsoup (경량 HTML 파서)
↓ 실패 또는 유효하지 않은 콘텐츠
2차: Jina Reader API (외부 서비스)
↓ 실패 또는 유효하지 않은 콘텐츠
3차: Playwright (싱글톤 브라우저 탭)
```
### 1차: Jsoup
- Java 기반 HTML 파서, 가장 빠르고 가벼움
- JavaScript 렌더링 불가 → SPA 사이트에서는 빈 콘텐츠 반환
- `nav, footer, header, script, style` 등 불필요 요소 제거 후 본문 추출
### 2차: Jina Reader
- `https://r.jina.ai/{URL}` 형태로 외부 서비스 호출
- JavaScript 렌더링 지원, Markdown/텍스트 형식 반환
- API 키 설정 시 인증 헤더 추가 가능
### 3차: Playwright
- 싱글톤 브라우저에서 새 탭으로 페이지 로드
- JavaScript 렌더링 완료 후 `NETWORKIDLE` 대기
- `page.evaluate()`로 DOM에서 직접 본문 텍스트 추출:
```javascript
() => {
// 불필요 요소 제거
['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']
.forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));
// 본문 영역 우선, 없으면 body 전체
const article = document.querySelector('article, main, .post-content, .article-body, .entry-content');
return (article || document.body).innerText;
}
```
### 유효성 검증
모든 단계에서 `isValidContent()` 검증:
- 최소 100자 이상
- 에러 페이지 패턴 감지 (403, captcha, "checking your browser" 등)
## 3. YouTubeTranscriptService (YouTube 자막 추출)
### 2단계 Fallback 전략
```
1차: youtube-transcript-api 라이브러리
↓ 실패 (봇 차단 등)
2차: Playwright (싱글톤 브라우저)
├─ 방법 A: 자막 패널 DOM 추출
└─ 방법 B: Caption URL + fmt=json3
```
### 1차: youtube-transcript-api
- `io.github.thoroldvix` 라이브러리 사용
- 수동 자막(manual) 우선, 없으면 자동 생성(generated) 시도
- 언어 우선순위: `ko``en`
- **한계**: YouTube가 서버 IP를 봇으로 판정하면 실패
### 2차: Playwright Fallback
#### 방법 A: 자막 패널 DOM 추출 (우선)
YouTube 페이지에서 '스크립트 표시' 버튼을 클릭하여 자막 패널을 열고 DOM에서 텍스트를 직접 추출한다.
**흐름**:
1. YouTube 동영상 페이지 로드 (NETWORKIDLE 대기)
2. 2초 대기 후 '스크립트 표시' / 'Show transcript' 버튼 탐색 및 클릭
3. 버튼 못 찾으면 설명란 '더보기' 펼치기 → 다시 탐색
4. 3초 대기 (자막 패널 로드)
5. `ytd-transcript-segment-renderer .segment-text` 등 CSS 셀렉터로 자막 텍스트 추출
**장점**: 브라우저의 쿠키/세션이 완전히 유지되어 봇 차단 우회 가능
#### 방법 B: Caption URL + fmt=json3 (보조)
방법 A가 실패하면 HTML에서 `captionTracks`를 파싱하여 caption URL을 추출하고, `&fmt=json3` 파라미터를 추가하여 JSON 형식으로 자막을 요청한다.
**왜 fmt=json3인가?**
- 기본 XML 형식(`fmt` 없음)은 서명된 URL이 서버 IP에 바인딩되어 빈 응답을 반환하는 경우가 있음
- `fmt=json3``{"events":[{"segs":[{"utf8":"text"}]}]}` 형식으로 반환
**Caption URL 추출 과정**:
1. `page.content()`로 HTML 획득
2. `"captionTracks":\s*\[(.*?)]` 정규식으로 JSON 추출
3. 언어 우선순위에 따라 URL 선택 (ko → en → 첫 번째)
4. `\u0026``&` 디코딩
5. `&fmt=json3` 추가
6. `page.evaluate(fetch())` — 브라우저 내 JS fetch로 요청 (쿠키 유지)
### 언어 선택 로직
```
captionTracks JSON에서:
1. languageCode가 "ko"로 시작하는 트랙 → 최우선
2. languageCode가 "en"으로 시작하는 트랙 → 차선
3. 첫 번째 트랙 → 최후
```
## 4. DISPLAY 환경변수와 Head 모드
Playwright가 head 모드(`setHeadless(false)`)로 실행되려면 X Window 디스플레이가 필요하다.
| 환경 | DISPLAY 값 | 설명 |
|------|-----------|------|
| VNC 디버깅 | `:1` | VNC 접속 시 브라우저 보임 |
| 운영 (백그라운드) | `:99` | Xvfb 가상 프레임버퍼 |
설정 위치: `start-backend.sh``export DISPLAY=:1` (또는 `:99`)
## 5. 트러블슈팅
### YouTube 자막이 안 가져와질 때
1. **로그 확인**: `pm2 logs sundol-backend --lines 50`
2. **"봇 판정"**: youtube-transcript-api 실패 → Playwright fallback 정상 동작하는지 확인
3. **쿠키 만료**: `cookies.txt`의 쿠키가 오래되면 YouTube가 차단. 브라우저에서 새 쿠키 export 필요
4. **자막 패널 버튼 변경**: YouTube UI 업데이트로 버튼 텍스트/셀렉터가 바뀌면 `extractTranscriptFromPanel()` 수정 필요
### Playwright 브라우저가 죽었을 때
- `ensureBrowserAlive()`가 자동 재시작
- 로그에서 `Playwright 브라우저가 죽어있습니다. 재시작합니다.` 확인
- 수동 재시작: `pm2 restart sundol-backend`
### 크롤링이 빈 결과를 반환할 때
1. Jsoup: JavaScript 렌더링이 필요한 SPA 사이트인지 확인
2. Jina Reader: API 키 유효한지, rate limit 걸렸는지 확인
3. Playwright: 해당 사이트가 Cloudflare 등 봇 차단을 하는지 확인
## 6. 관련 파일
| 파일 | 역할 |
|------|------|
| `PlaywrightBrowserService.java` | 싱글톤 브라우저 관리 |
| `WebCrawlerService.java` | 일반 웹 크롤링 (3단계 fallback) |
| `YouTubeTranscriptService.java` | YouTube 자막 추출 (2단계 fallback) |
| `cookies.txt` | YouTube/Google 쿠키 (봇 차단 우회) |
| `start-backend.sh` | DISPLAY 환경변수 설정 |

182
docs/operation-manual.md Normal file
View File

@@ -0,0 +1,182 @@
# Sundol 운영 매뉴얼
## 프로세스 구성
pm2로 3개 프로세스를 관리한다.
| id | name | 설명 |
|----|------|------|
| 0 | pdf-ocr | PDF OCR 서비스 |
| 1 | sundol-backend | Spring Boot 백엔드 |
| 2 | sundol-frontend | Next.js 프론트엔드 |
## pm2 기본 명령어
```bash
# 프로세스 목록 확인
pm2 list
# 로그 확인
pm2 logs sundol-backend
pm2 logs sundol-frontend
pm2 logs pdf-ocr
# 특정 프로세스 재시작
pm2 restart sundol-backend
pm2 restart sundol-frontend
pm2 restart pdf-ocr
# 전체 재시작
pm2 restart all
# 프로세스 중지
pm2 stop sundol-backend
# 프로세스 삭제
pm2 delete sundol-backend
```
## 백엔드 시작 스크립트
파일: `start-backend.sh`
```bash
#!/bin/bash
set -a
source /home/opc/sundol/.env
set +a
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
export JAVA_HOME
# Playwright 브라우저 경로
export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# Playwright head 모드용 디스플레이
# :99 = Xvfb 가상 디스플레이 (브라우저 안 보임)
# :1 = VNC 디스플레이 (VNC에서 브라우저 보임)
export DISPLAY=:99
# Xvfb 가상 디스플레이 시작 (DISPLAY=:99일 때만 필요)
if ! pgrep -x Xvfb > /dev/null; then
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
sleep 1
fi
BACKEND_DIR=/home/opc/sundol/sundol-backend
exec $JAVA_HOME/bin/java -cp "$BACKEND_DIR/target/classes:$BACKEND_DIR/target/dependency/*" com.sundol.SundolApplication
```
### DISPLAY 설정 가이드
| DISPLAY 값 | 용도 |
|-------------|------|
| `:99` | Xvfb 가상 디스플레이. 브라우저가 백그라운드에서 동작 (기본값) |
| `:1` | VNC 디스플레이. VNC 접속 시 Playwright 브라우저가 화면에 보임 (디버깅용) |
VNC에서 Playwright 브라우저를 직접 보려면:
1. `start-backend.sh`에서 `export DISPLAY=:1`로 변경
2. `pm2 restart sundol-backend`
## 백엔드 빌드
```bash
cd /home/opc/sundol/sundol-backend
export JAVA_HOME=/usr/lib/jvm/java-21
set -a && source /home/opc/sundol/.env && set +a
mvn package -q -DskipTests
```
> 컴파일만: `mvn compile` (위 환경변수 동일)
## 프론트엔드 빌드/배포
```bash
cd /home/opc/sundol/sundol-frontend
bash build.sh
```
> `.env`에 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수.
## 배포 절차
1. 코드 변경 및 빌드
2. `pm2 restart sundol-backend` (또는 sundol-frontend)
3. `pm2 logs sundol-backend` 로 정상 기동 확인
4. `git push origin main`
## DB 접속 (Oracle Autonomous DB)
```bash
set -a && source /home/opc/sundol/.env && set +a
/home/opc/sqlcl/bin/sql ${ORACLE_USERNAME}/${ORACLE_PASSWORD}@${ORACLE_TNS_NAME}?TNS_ADMIN=${ORACLE_WALLET_PATH}
```
## Playwright 관련
- 브라우저 경로: `/home/opc/.cache/ms-playwright`
- WebCrawlerService: Playwright head 모드 (`setHeadless(false)`)
- YouTubeTranscriptService: Playwright head 모드 (`setHeadless(false)`)
- head 모드 사용 시 DISPLAY 환경변수 필수
## Playwright 의존 라이브러리
Playwright Chromium 실행에 필요한 시스템 라이브러리:
```bash
sudo dnf install -y libicu woff2 harfbuzz-icu libjpeg-turbo libwebp enchant2 hyphen libffi
```
> `libx264`는 WebKit 전용이므로 Chromium만 사용할 경우 불필요.
> 로그에 `validateDependenciesLinux` 에러가 나오면 위 패키지 설치 확인.
### Xvfb vs VNC 디스플레이
| 모드 | DISPLAY | 설명 |
|------|---------|------|
| Xvfb (운영) | `:99` | 가상 프레임버퍼. 화면 없이 백그라운드 동작 |
| VNC (디버깅) | `:1` | VNC 접속 시 브라우저가 화면에 보임 |
Xvfb 모드로 되돌리려면 `start-backend.sh`에서:
```bash
export DISPLAY=:99
if ! pgrep -x Xvfb > /dev/null; then
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
sleep 1
fi
```
## 트러블슈팅
### 백엔드가 안 뜰 때
```bash
pm2 logs sundol-backend --lines 50
```
### Playwright 브라우저가 안 열릴 때
```bash
# DISPLAY 확인
echo $DISPLAY
# Xvfb 실행 여부 확인
pgrep -x Xvfb
# VNC 실행 여부 확인
vncserver -list
# Playwright 브라우저 설치 확인
ls /home/opc/.cache/ms-playwright/
```
### pm2 프로세스가 계속 재시작될 때
```bash
# 재시작 횟수 확인 (↺ 컬럼)
pm2 list
# 에러 로그 확인
pm2 logs sundol-backend --err --lines 100
```

174
docs/setup-xwindow.md Normal file
View File

@@ -0,0 +1,174 @@
# X Window + XFCE + VNC 설치 매뉴얼
OCI(Oracle Cloud Infrastructure) Oracle Linux 9 환경 기준.
## 1. 사전 확인
```bash
# Xorg 설치 여부 확인
which Xorg
rpm -q xorg-x11-server-Xorg
# DISPLAY 환경변수 확인 (비어있으면 X 서버 미실행 상태)
echo $DISPLAY
```
## 2. EPEL 저장소 설치
```bash
sudo dnf install -y epel-release
```
## 3. XFCE 데스크톱 환경 설치
```bash
sudo dnf groupinstall -y "Xfce"
```
> GNOME 대비 경량이라 서버 환경에 적합.
## 4. 기본 타겟을 GUI 모드로 변경
```bash
sudo systemctl set-default graphical.target
```
> 되돌리려면: `sudo systemctl set-default multi-user.target`
## 5. TigerVNC 서버 설치
SSH 원격 접속 환경에서는 VNC를 통해 GUI에 접근한다.
```bash
sudo dnf install -y tigervnc-server
```
## 6. VNC 비밀번호 설정
```bash
vncpasswd
```
> VNC 비밀번호는 최대 8자까지만 유효.
## 7. VNC 시작 스크립트 설정
```bash
mkdir -p ~/.vnc
cat > ~/.vnc/xstartup << 'EOF'
#!/bin/bash
exec startxfce4
EOF
chmod +x ~/.vnc/xstartup
```
## 8. VNC 서버 시작
```bash
vncserver :1 -geometry 1920x1080 -depth 24
```
- `:1` = 포트 5901
- `:2` = 포트 5902 (추가 세션 필요 시)
### VNC 서버 중지
```bash
vncserver -kill :1
```
## 9. 방화벽 포트 개방
```bash
sudo firewall-cmd --permanent --add-port=5901/tcp
sudo firewall-cmd --reload
```
### OCI 보안 목록 설정
OCI 콘솔에서 해당 서브넷의 Security List에 인바운드 규칙 추가:
- **Source**: 접속할 IP (또는 0.0.0.0/0)
- **Protocol**: TCP
- **Destination Port**: 5901
## 10. 클라이언트에서 VNC 접속
### Mac
기본 내장 Screen Sharing 사용:
```bash
# Finder에서 Cmd+K → 아래 주소 입력
vnc://공인IP:5901
# 또는 터미널에서
open vnc://공인IP:5901
```
### Windows
- RealVNC Viewer, TigerVNC Viewer 등 VNC 클라이언트 설치
- 주소: `공인IP:5901`
## 11. Chrome 브라우저 설치
```bash
# Google Chrome 저장소 추가
cat << 'EOF' | sudo tee /etc/yum.repos.d/google-chrome.repo
[google-chrome]
name=google-chrome
baseurl=https://dl.google.com/linux/chrome/rpm/stable/x86_64
enabled=1
gpgcheck=1
gpgkey=https://dl.google.com/linux/linux_signing_key.pub
EOF
# 설치
sudo dnf install -y google-chrome-stable
```
## 12. 한글 폰트 설치
브라우저 등에서 한글이 깨지는 경우 CJK 폰트를 설치한다.
```bash
sudo dnf install -y google-noto-sans-cjk-ttc-fonts google-noto-serif-cjk-ttc-fonts
# 폰트 캐시 갱신
fc-cache -fv
```
> 설치 후 Chrome 등 브라우저를 재시작해야 반영됨.
## 트러블슈팅
### VNC 접속이 안 될 때
```bash
# VNC 서버 실행 확인
vncserver -list
# 로그 확인
cat ~/.vnc/*.log
# 방화벽 확인
sudo firewall-cmd --list-ports
```
### 화면이 회색/빈 화면일 때
`~/.vnc/xstartup` 파일 내용이 올바른지 확인:
```bash
cat ~/.vnc/xstartup
# exec startxfce4 가 있어야 함
```
VNC 서버 재시작:
```bash
vncserver -kill :1
vncserver :1 -geometry 1920x1080 -depth 24
```

309
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,309 @@
# 트러블슈팅 가이드
## 1. 프론트엔드 502 Bad Gateway
### 증상
- 브라우저에서 `502 Bad Gateway nginx/1.20.1` 표시
### 원인
Next.js standalone 빌드 시 `server.js``sundol-frontend/` 서브디렉토리에 생성되는 경우가 있음.
PM2가 `.next/standalone/server.js`를 찾지만 실제 파일은 `.next/standalone/sundol-frontend/server.js`에 위치.
### 확인 방법
```bash
pm2 list # sundol-frontend 상태 확인 (errored 여부)
pm2 logs sundol-frontend --err --lines 20 # 에러 로그 확인
# "Cannot find module .../standalone/server.js" 에러가 나면 이 문제
```
### 해결
```bash
# server.js 심볼릭 링크 생성
ln -sf /home/opc/sundol/sundol-frontend/.next/standalone/sundol-frontend/server.js \
/home/opc/sundol/sundol-frontend/.next/standalone/server.js
pm2 restart sundol-frontend
```
> `build.sh`에 이미 자동 처리 로직 포함되어 있음. 문제가 반복되면 build.sh 확인.
---
## 2. 프론트엔드 static 파일 404
### 증상
- 페이지는 로드되지만 CSS/JS가 404
- 브라우저 콘솔에 `_next/static/chunks/...js net::ERR_ABORTED 404` 다수 표시
### 원인
standalone 빌드에서 `.next/static` 심볼릭 링크가 올바른 위치에 걸리지 않음.
`server.js``standalone/sundol-frontend/` 안에서 실행되므로 static도 그 안에 있어야 함.
### 해결
```bash
# 중첩 디렉토리에도 static 링크 생성
ln -sf /home/opc/sundol/sundol-frontend/.next/static \
/home/opc/sundol/sundol-frontend/.next/standalone/sundol-frontend/.next/static
pm2 restart sundol-frontend
```
> `build.sh`에 자동 처리 로직 포함되어 있음.
---
## 3. 브라우저 로그인 모달 (HTTP Basic Auth 팝업)
### 증상
- 페이지 리프레시 시 브라우저 기본 로그인 팝업(username/password)이 뜸
- 특히 JWT 토큰이 만료되었거나 없을 때 발생
### 원인
Spring Security가 401 응답에 `WWW-Authenticate: Basic` 헤더를 포함.
브라우저가 이 헤더를 감지하면 자동으로 Basic Auth 로그인 다이얼로그를 표시.
### 해결
`SecurityConfig.java`에 커스텀 `authenticationEntryPoint` 설정:
```java
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((exchange, ex) -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
})
)
```
이렇게 하면 `WWW-Authenticate` 헤더 없이 401만 반환되어 브라우저 팝업이 뜨지 않음.
프론트엔드의 axios 인터셉터가 401을 감지하여 자동으로 토큰 갱신 처리.
---
## 4. YouTube 자막 가져오기 실패 (Caption XML 0 chars)
### 증상
- YouTube 트랜스크립트 가져오기 시 "자막 텍스트를 파싱할 수 없습니다" 에러
- 로그에 `Caption XML fetched: 0 chars` 표시
### 원인
YouTube timedtext API의 caption URL이 서명 기반으로, 브라우저 세션 외부에서 요청하면 빈 응답 반환.
`HttpURLConnection`이나 `context.request().get()`으로는 쿠키/세션이 유지되지 않음.
### 해결
두 가지 방법으로 fallback 처리:
1. **방법 A (우선)**: YouTube 페이지에서 '스크립트 표시' 패널을 열어 DOM에서 직접 텍스트 추출
2. **방법 B**: caption URL에 `&fmt=json3` 추가 후 `page.evaluate(fetch())`로 브라우저 내에서 요청
자세한 내용은 [crawling-guide.md](crawling-guide.md) 참조.
---
## 5. Playwright 브라우저 창이 사라짐
### 증상
- VNC에서 Playwright Chromium 창이 보이다가 사라짐
### 원인
`page.close()` 호출 시 마지막 탭이 닫히면 브라우저 창이 빈 상태가 됨.
### 해결
`page.close()` 대신 `page.navigate("about:blank")`으로 변경하여 탭을 유지:
```java
try {
page.navigate("about:blank");
} catch (Exception ignored) {
page.close();
}
```
---
## 6. Playwright 의존 라이브러리 누락 경고
### 증상
- 백엔드 로그에 `Host system is missing dependencies to run browsers` 경고
- `libicudata.so.66`, `libwoff2dec.so.1.0.2` 등 누락 표시
### 원인
Playwright가 WebKit 브라우저용 의존성까지 검사함. Chromium만 사용하면 대부분 문제없음.
### 확인
```bash
# Chromium 실행 가능 여부 직접 테스트
/home/opc/.cache/ms-playwright/chromium-1161/chrome-linux/chrome --version
```
### 해결 (경고 제거하려면)
```bash
sudo dnf install -y libicu woff2 harfbuzz-icu libjpeg-turbo libwebp enchant2 hyphen libffi
```
> `libx264`는 WebKit 전용이므로 Chromium만 사용 시 불필요.
---
## 7. Git Push 인증 실패
### 증상
- `git push origin main``could not read Username` 에러
### 원인
`.netrc`이나 `.git-credentials`에 인증 정보가 없음.
### 해결
`.env``GIT_USER`, `GIT_PASSWORD` 사용 (비밀번호에 특수문자 포함 시 URL 인코딩 필요):
```bash
set -a && source /home/opc/sundol/.env && set +a
ENCODED_PW=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$GIT_PASSWORD', safe=''))")
git push "https://${GIT_USER}:${ENCODED_PW}@gittea.cloud-handson.com/joungmin/sundol.git" main
```
---
## 8. LLM 구조화 내용이 잘림
### 증상
- 지식 정리(구조화) 결과가 중간에 끊김
### 원인
OCI GenAI의 `maxTokens`가 4096으로 제한되어 있어 긴 콘텐츠 정리 시 응답이 잘림.
### 해결
한 번에 전체를 정리하지 않고, 나눠서 요청하는 방식으로 변경:
1. **1차 호출**: Abstract + 목차만 생성
2. **2차~ 호출**: 목차 항목별로 상세 정리 요청 (각각 maxTokens 4096 내에서 충분)
3. **최종 조합**: 모든 결과를 합침
각 섹션 완료 시 DB에 중간 저장하여 프론트엔드에서 실시간 확인 가능.
---
## 9. VNC 접속 안 됨
### 증상
- VNC 클라이언트에서 접속 불가
### 확인
```bash
# VNC 서버 실행 확인
vncserver -list
# 방화벽 확인
sudo firewall-cmd --list-ports
# OCI 보안 목록에서 TCP 5901 인바운드 허용 여부 확인
```
### 해결
```bash
# VNC 서버 재시작
vncserver -kill :1
vncserver :1 -geometry 1920x1080 -depth 24
# 방화벽 포트 열기
sudo firewall-cmd --permanent --add-port=5901/tcp
sudo firewall-cmd --reload
```
자세한 내용은 [setup-xwindow.md](setup-xwindow.md) 참조.
---
## 10. VNC 접속 시 스크린 락(화면 잠금) 걸림
### 증상
- VNC로 접속하면 검은 화면에 로그인 다이얼로그가 뜸
- 잠시 자리를 비운 뒤 접속하면 사용자 비밀번호를 요구
### 원인
XFCE 스크린세이버가 기본 활성화되어 있어, 일정 시간 비활성 후 화면을 잠금.
### 해결
```bash
export DISPLAY=:1
# 스크린세이버 비활성화
xfconf-query -c xfce4-screensaver -p /saver/enabled --create -t bool -s false
# 화면 잠금 비활성화
xfconf-query -c xfce4-screensaver -p /lock/enabled --create -t bool -s false
xfconf-query -c xfce4-screensaver -p /lock/saver-activation/enabled --create -t bool -s false
# 모니터 절전(DPMS) 비활성화
xfconf-query -c xfce4-power-manager -p /xfce4-power-manager/dpms-enabled --create -t bool -s false
xfconf-query -c xfce4-power-manager -p /xfce4-power-manager/lock-screen-suspend-hibernate --create -t bool -s false
# 잠금 명령 제거
xfconf-query -c xfce4-session -p /general/LockCommand --create -t string -s ""
# 실행 중인 screensaver 프로세스 종료
ps aux | grep -E 'xfce4-screensaver|screensaver-dialog' | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null
```
---
## 11. YouTube 자막 추출 시 광고 때문에 실패
### 증상
- Playwright로 YouTube 페이지 로드 후 자막 패널이 열리지 않음
- VNC에서 보면 광고가 재생 중
### 원인
YouTube 동영상 앞에 프리롤 광고가 삽입되면 본 영상 UI(자막 패널 등)가 활성화되지 않음.
### 해결
코드에 `waitForAdsToFinish()` 로직이 포함되어 있음:
- 최대 60초간 2초 간격으로 광고 상태 확인
- 스킵 버튼(`.ytp-skip-ad-button` 등)이 나타나면 자동 클릭
- 광고가 끝나면 자막 추출 진행
수동으로 확인하려면 VNC에서 Playwright 브라우저 창을 직접 관찰.
---
## 12. 백엔드 재시작 시 로그인이 풀림
### 증상
- 백엔드(`pm2 restart sundol-backend`) 후 프론트엔드에서 로그인 화면으로 튕김
- 로그에 `User logged out: null` 표시
### 원인
1. 백엔드 재시작 중 프론트엔드가 API 호출 → 연결 실패(네트워크 에러)
2. axios 인터셉터가 401로 인식 → refresh 시도 → 서버 아직 안 떠서 실패
3. `onRefreshFailed` 콜백 → 자동 로그아웃 실행
### 해결
프론트엔드 axios 인터셉터에서 refresh 실패 시 네트워크 에러이면 3초 후 최대 2회 재시도:
```typescript
const attemptRefresh = async (retryCount: number): Promise<string> => {
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
return res.data.accessToken;
} catch (err) {
const isNetworkError = !((err as AxiosError).response);
if (isNetworkError && retryCount < 2) {
await new Promise((r) => setTimeout(r, 3000));
return attemptRefresh(retryCount + 1);
}
throw err;
}
};
```
백엔드 logout에도 userId null 체크 추가:
```java
if (userId == null) {
log.warn("Logout called with null userId, ignoring");
return;
}
```
### 예방
- 백엔드 재시작 시 기동 완료까지 약 10~25초 소요
- 프론트엔드는 네트워크 에러 시 최대 9초(3초 × 3회) 대기 후 재시도

View File

@@ -0,0 +1,207 @@
# YouTube 트랜스크립트 추출 가이드
## 아키텍처
```
┌──────────────────────────────────────────────────┐
│ 사용자 Chrome 브라우저 │
│ (VNC에서 직접 YouTube 로그인된 상태) │
│ --remote-debugging-port=9222 │
│ --remote-allow-origins=* │
└──────────────┬───────────────────────────────────┘
│ CDP (Chrome DevTools Protocol)
┌──────────────▼───────────────────────────────────┐
│ PlaywrightBrowserService │
│ playwright.chromium().connectOverCDP() │
│ → 사용자 Chrome의 세션/쿠키를 그대로 사용 │
└──────────────┬───────────────────────────────────┘
┌──────────────▼───────────────────────────────────┐
│ YouTubeTranscriptService │
│ 1차: youtube-transcript-api 라이브러리 │
│ 2차: Playwright → 사용자 Chrome 탭으로 추출 │
└──────────────────────────────────────────────────┘
```
## 핵심: 왜 사용자 Chrome인가?
### Playwright 자체 브라우저의 한계
- Playwright가 실행하는 Chromium에는 `--enable-automation` 플래그가 붙음
- YouTube가 이를 감지하여 **봇으로 판정** → 자막 API 차단
- 쿠키를 로드해도, 직접 로그인을 시도해도 차단됨
- Google 로그인 자체가 "안전하지 않은 브라우저"로 거부됨
### 사용자 Chrome + CDP 방식
- VNC에서 일반 Chrome으로 YouTube에 로그인
- 백엔드가 CDP로 해당 Chrome에 연결하여 새 탭을 열어 작업
- **로그인 세션이 그대로 유지**되므로 봇 판정 우회
- Chrome 자체는 종료하지 않고 연결만 관리
## Chrome 실행 방법
### 필수 옵션
```bash
DISPLAY=:1 google-chrome \
--remote-debugging-port=9222 \
--remote-allow-origins=* \
--user-data-dir=/tmp/chrome-debug-profile \
--no-default-browser-check \
"https://www.youtube.com"
```
| 옵션 | 설명 |
|------|------|
| `--remote-debugging-port=9222` | CDP 접속 포트 |
| `--remote-allow-origins=*` | CDP WebSocket 연결 허용 |
| `--user-data-dir` | 프로필 디렉토리 (기본과 다른 경로 필요) |
### Chrome 프로필 복사 (기존 로그인 유지)
```bash
cp -r /home/opc/.config/google-chrome /tmp/chrome-debug-profile
```
### CDP 연결 확인
```bash
curl -s http://localhost:9222/json/version | python3 -m json.tool
```
## YouTube 로그인
1. VNC로 접속 (`vnc://공인IP:5901`)
2. Chrome에서 YouTube가 열려있는지 확인
3. YouTube에 Google 계정으로 로그인
4. 로그인 상태 유지 — Chrome을 닫지 않음
> Chrome을 닫으면 CDP 연결이 끊기고 백엔드가 재연결을 시도함.
> Chrome을 다시 시작하면 다시 로그인 필요.
## 트랜스크립트 추출 흐름
### 1차: youtube-transcript-api 라이브러리
```
videoId → TranscriptApi.listTranscripts()
→ 수동 자막(ko, en) 우선
→ 자동 생성 자막 fallback
```
- 서버 IP가 봇으로 판정되면 실패 (대부분 실패)
### 2차: Playwright + 사용자 Chrome
```
1. 사용자 Chrome에 새 탭 열기 (DOMCONTENTLOADED, 60초 타임아웃)
2. DOM 렌더링 대기 (3초)
3. 동영상 재생 시작 (playVideo)
- 쿠키 동의 팝업 닫기
- 마우스 호버 → 재생 버튼 클릭
4. 광고 대기 (waitForAdsToFinish, 최대 60초)
- 스킵 버튼 자동 클릭
- 광고 끝날 때까지 반복 확인
5. 실제 영상 재생 확인 (waitForVideoPlaying, 최대 15초)
- video.currentTime > 0.5 확인
- 일시정지면 재시도
6. ytInitialPlayerResponse에서 자막 URL 추출 + fetch (fetchTranscriptFromPageJs)
7. 실패 시 자막 패널 열기 (extractTranscriptFromPanel)
- 'Show transcript' 버튼 클릭
- 자막 세그먼트 DOM에서 텍스트 추출
- 최대 30초 반복 확인
8. 완료 후 about:blank로 이동 (탭 유지)
```
## 광고 처리
### 감지 방법
- `#movie_player.ad-showing` 클래스 확인 (가장 신뢰할 수 있음)
- `.ytp-ad-player-overlay` 오버레이 확인
- 광고 텍스트 배지 확인 (`.ytp-ad-text`)
### 스킵 버튼 셀렉터
```css
.ytp-skip-ad-button,
.ytp-ad-skip-button,
.ytp-ad-skip-button-modern,
.ytp-ad-skip-button-container button
```
### 주의사항
- 광고 스킵 후 두 번째 광고가 나올 수 있음 → `no_ad` 상태 확인까지 반복
- 광고 중에는 `NETWORKIDLE`에 도달하지 못함 → `DOMCONTENTLOADED` 사용
## 쿠키 관리
### CDP 방식에서는 쿠키 파일 불필요
- 사용자 Chrome에 직접 연결하므로 `cookies.txt` 불필요
- Chrome 세션의 쿠키가 자동으로 사용됨
### Chrome 쿠키 수동 export (참고용)
Chrome을 `--remote-debugging-port=9222`로 실행 후:
```python
import json, websocket, urllib.request
tabs = json.loads(urllib.request.urlopen("http://localhost:9222/json").read())
ws_url = tabs[0]["webSocketDebuggerUrl"]
ws = websocket.create_connection(ws_url)
ws.send(json.dumps({"id": 1, "method": "Network.getAllCookies"}))
result = json.loads(ws.recv())
cookies = result["result"]["cookies"]
ws.close()
```
> Chrome의 쿠키 DB(`~/.config/google-chrome/Default/Cookies`)는 암호화되어 있어 직접 읽기 어려움.
> CDP를 통해 복호화된 쿠키를 가져오는 것이 가장 확실한 방법.
## PM2 + Chrome 자동 시작
Chrome이 서버 재부팅 시에도 자동 시작되도록:
```bash
# pm2로 Chrome 관리하지 않음 (GUI 앱이라 적합하지 않음)
# 대신 VNC 시작 시 자동 실행하도록 ~/.vnc/xstartup에 추가:
```
`~/.vnc/xstartup`:
```bash
#!/bin/bash
exec startxfce4 &
# Chrome을 CDP 모드로 자동 시작
sleep 5
google-chrome --remote-debugging-port=9222 \
--remote-allow-origins=* \
--user-data-dir=/tmp/chrome-debug-profile \
--no-default-browser-check \
"https://www.youtube.com" &
```
## 트러블슈팅
### Chrome CDP 연결 실패
```
Chrome CDP 연결 실패: Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요.
```
→ Chrome 재시작:
```bash
DISPLAY=:1 google-chrome --remote-debugging-port=9222 --remote-allow-origins=* \
--user-data-dir=/tmp/chrome-debug-profile --no-default-browser-check "https://www.youtube.com" &
```
### 자막 패널이 ghost-cards만 표시
- YouTube가 자막 데이터 로드를 차단한 상태
- Chrome에서 YouTube 로그인이 풀렸는지 VNC에서 확인
- 로그인 상태라면 Chrome 재시작 후 다시 로그인
### "안전하지 않은 브라우저" 로그인 거부
- Playwright 자체 브라우저에서 발생 (정상)
- **사용자 Chrome**에서만 로그인 가능
- CDP 방식으로 전환하면 해결
### 광고 중에 페이지가 닫힘
- YouTube 페이지 로드 시 `NETWORKIDLE` 사용하면 광고 때문에 타임아웃
- **`DOMCONTENTLOADED`** + 60초 타임아웃으로 변경하여 해결
## 관련 파일
| 파일 | 역할 |
|------|------|
| `PlaywrightBrowserService.java` | 사용자 Chrome에 CDP 연결, 탭 관리 |
| `YouTubeTranscriptService.java` | 자막 추출 로직 (API → Playwright fallback) |
| `youtube-transcript-extract.js` | 자막 패널 DOM 추출 JS |
| `youtube-transcript-from-page.js` | ytInitialPlayerResponse 기반 자막 추출 JS |
| `~/.vnc/xstartup` | VNC 시작 시 Chrome 자동 실행 |

View File

@@ -11,11 +11,8 @@ export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# Xvfb virtual display for Playwright head mode (YouTube transcript)
export DISPLAY=:99
if ! pgrep -x Xvfb > /dev/null; then
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
sleep 1
fi
# :99 = Xvfb (백그라운드), :1 = VNC (디버깅용)
export DISPLAY=:1
# Playwright driver-bundle requires exploded classpath (fat JAR extraction fails)
BACKEND_DIR=/home/opc/sundol/sundol-backend

View File

@@ -9,6 +9,8 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.http.HttpStatus;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@@ -41,6 +43,12 @@ public class SecurityConfig {
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((exchange, ex) -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
})
)
.build();
}

View File

@@ -1,6 +1,7 @@
package com.sundol.controller;
import com.sundol.dto.IngestRequest;
import com.sundol.repository.CategoryRepository;
import com.sundol.service.KnowledgeService;
import com.sundol.service.YouTubeTranscriptService;
import org.slf4j.Logger;
@@ -22,11 +23,14 @@ public class KnowledgeController {
private final KnowledgeService knowledgeService;
private final YouTubeTranscriptService youTubeTranscriptService;
private final CategoryRepository categoryRepository;
public KnowledgeController(KnowledgeService knowledgeService,
YouTubeTranscriptService youTubeTranscriptService) {
YouTubeTranscriptService youTubeTranscriptService,
CategoryRepository categoryRepository) {
this.knowledgeService = knowledgeService;
this.youTubeTranscriptService = youTubeTranscriptService;
this.categoryRepository = categoryRepository;
}
@GetMapping
@@ -86,6 +90,38 @@ public class KnowledgeController {
.then(Mono.just(ResponseEntity.ok().<Void>build()));
}
@PostMapping("/{id}/structure")
public Mono<ResponseEntity<Map<String, Object>>> structure(
@AuthenticationPrincipal String userId,
@PathVariable String id,
@RequestBody(required = false) Map<String, String> body) {
String modelId = body != null ? body.get("modelId") : null;
String englishLevel = body != null ? body.get("englishLevel") : null;
return knowledgeService.structureContent(userId, id, modelId, englishLevel)
.map(ResponseEntity::ok);
}
@GetMapping("/categories")
public Mono<ResponseEntity<Map<String, Object>>> categories(@AuthenticationPrincipal String userId) {
return Mono.fromCallable(() -> {
var categories = categoryRepository.findAllByUserWithCount(userId);
var uncategorized = categoryRepository.findUncategorizedItems(userId);
return ResponseEntity.ok(Map.<String, Object>of(
"categories", categories,
"uncategorized", uncategorized
));
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
}
@GetMapping("/categories/{categoryId}/items")
public Mono<ResponseEntity<List<Map<String, Object>>>> itemsByCategory(
@AuthenticationPrincipal String userId,
@PathVariable String categoryId) {
return Mono.fromCallable(() -> categoryRepository.findItemsByCategoryId(userId, categoryId))
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
.map(ResponseEntity::ok);
}
@GetMapping("/{id}/chunks")
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
@AuthenticationPrincipal String userId,

View File

@@ -0,0 +1,51 @@
package com.sundol.controller;
import com.sundol.repository.UserRepository;
import org.springframework.http.ResponseEntity;
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.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/me")
public Mono<ResponseEntity<Map<String, Object>>> me(@AuthenticationPrincipal String userId) {
return Mono.fromCallable(() -> {
Map<String, Object> user = userRepository.findById(userId);
if (user == null) {
return ResponseEntity.notFound().<Map<String, Object>>build();
}
// refresh_token은 응답에서 제외
user.remove("REFRESH_TOKEN");
return ResponseEntity.ok(user);
}).subscribeOn(Schedulers.boundedElastic());
}
@PatchMapping("/me")
public Mono<ResponseEntity<Map<String, Object>>> updateMe(
@AuthenticationPrincipal String userId,
@RequestBody Map<String, Object> updates) {
return Mono.fromCallable(() -> {
if (updates.containsKey("englishLevel")) {
String level = (String) updates.get("englishLevel");
if (level != null && level.matches("^(A1|A2|B1|B2|C1|C2)$")) {
userRepository.updateEnglishLevel(userId, level);
}
}
Map<String, Object> user = userRepository.findById(userId);
if (user != null) user.remove("REFRESH_TOKEN");
return ResponseEntity.ok(user);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -1,3 +1,3 @@
package com.sundol.dto;
public record IngestRequest(String type, String url, String title, String rawText, String modelId) {}
public record IngestRequest(String type, String url, String title, String rawText, String modelId, String englishLevel) {}

View File

@@ -110,6 +110,62 @@ public class CategoryRepository {
);
}
/**
* 카테고리 트리 + 해당 카테고리 및 하위 카테고리에 속한 고유 항목 수 조회
*/
public List<Map<String, Object>> findAllByUserWithCount(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(c.id) AS id, c.name, RAWTOHEX(c.parent_id) AS parent_id, c.depth, c.full_path, " +
" (SELECT COUNT(DISTINCT kic.knowledge_item_id) " +
" FROM knowledge_item_categories kic " +
" JOIN categories c2 ON c2.id = kic.category_id " +
" WHERE c2.user_id = HEXTORAW(?) AND (c2.full_path = c.full_path OR c2.full_path LIKE c.full_path || '/%')) AS item_count " +
"FROM categories c WHERE c.user_id = HEXTORAW(?) ORDER BY c.full_path",
userId, userId
);
}
/**
* 특정 카테고리 및 하위 카테고리에 속한 knowledge_items 조회.
* full_path LIKE 'parent_path%' 로 하위 카테고리를 포함한다.
*/
public List<Map<String, Object>> findItemsByCategoryId(String userId, String categoryId) {
// 먼저 선택된 카테고리의 full_path를 가져옴
var catResult = jdbcTemplate.queryForList(
"SELECT full_path FROM categories WHERE RAWTOHEX(id) = ?", categoryId
);
if (catResult.isEmpty()) return List.of();
String fullPath = (String) catResult.get(0).get("FULL_PATH");
return jdbcTemplate.queryForList(
"SELECT DISTINCT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " +
" (SELECT LISTAGG(c2.full_path, ', ') WITHIN GROUP (ORDER BY c2.full_path) " +
" FROM knowledge_item_categories kic2 JOIN categories c2 ON c2.id = kic2.category_id " +
" WHERE kic2.knowledge_item_id = ki.id) AS categories " +
"FROM knowledge_items ki " +
"JOIN knowledge_item_categories kic ON kic.knowledge_item_id = ki.id " +
"JOIN categories c ON c.id = kic.category_id " +
"WHERE ki.user_id = HEXTORAW(?) AND (c.full_path = ? OR c.full_path LIKE ?) " +
"ORDER BY ki.created_at DESC",
userId, fullPath, fullPath + "/%"
);
}
/**
* 카테고리가 없는 knowledge_items 조회
*/
public List<Map<String, Object>> findUncategorizedItems(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " +
" NULL AS categories " +
"FROM knowledge_items ki " +
"WHERE ki.user_id = HEXTORAW(?) " +
"AND NOT EXISTS (SELECT 1 FROM knowledge_item_categories kic WHERE kic.knowledge_item_id = ki.id) " +
"ORDER BY ki.created_at DESC",
userId
);
}
/**
* knowledge_item의 카테고리 매핑 전체 삭제
*/

View File

@@ -3,6 +3,7 @@ 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;
@@ -31,44 +32,69 @@ public class KnowledgeRepository {
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, type, title, source_url, status, created_at, updated_at FROM knowledge_items WHERE user_id = HEXTORAW(?)"
"SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, ki.updated_at, " +
" (SELECT LISTAGG(c.full_path, ', ') WITHIN GROUP (ORDER BY c.full_path) " +
" FROM knowledge_item_categories kic JOIN categories c ON c.id = kic.category_id " +
" WHERE kic.knowledge_item_id = ki.id) AS categories " +
"FROM knowledge_items ki WHERE ki.user_id = HEXTORAW(?)"
);
java.util.List<Object> params = new java.util.ArrayList<>();
params.add(userId);
if (type != null && !type.isEmpty()) {
sql.append(" AND type = ?");
sql.append(" AND ki.type = ?");
params.add(type);
}
if (status != null && !status.isEmpty()) {
sql.append(" AND status = ?");
sql.append(" AND ki.status = ?");
params.add(status);
}
if (search != null && !search.isEmpty()) {
sql.append(" AND UPPER(title) LIKE UPPER(?)");
sql.append(" AND UPPER(ki.title) LIKE UPPER(?)");
params.add("%" + search + "%");
}
sql.append(" ORDER BY created_at DESC");
sql.append(" ORDER BY ki.created_at DESC");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, structured_content, status, created_at, updated_at " +
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
if (results.isEmpty()) return null;
return convertClobFields(results.get(0));
}
public Map<String, Object> findByIdInternal(String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, structured_content, status, created_at, updated_at " +
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
id
);
return results.isEmpty() ? null : results.get(0);
if (results.isEmpty()) return null;
return convertClobFields(results.get(0));
}
/**
* CLOB 필드를 String으로 변환한다.
* Oracle JDBC는 4000바이트 이상의 CLOB을 java.sql.Clob 객체로 반환하므로
* API 응답 전에 String으로 변환해야 한다.
*/
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;
}
public void updateStatus(String id, String status) {
@@ -85,6 +111,14 @@ public class KnowledgeRepository {
);
}
public void updateStructuredContent(String id, String structuredContent) {
jdbcTemplate.update(
"UPDATE knowledge_items SET structured_content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
new Object[]{ structuredContent, id },
new int[]{ java.sql.Types.CLOB, java.sql.Types.VARCHAR }
);
}
public void delete(String userId, String id) {
jdbcTemplate.update(
"DELETE FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",

View File

@@ -16,7 +16,7 @@ public class UserRepository {
public Map<String, Object> findByGoogleSub(String googleSub) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub FROM users WHERE google_sub = ?",
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, english_level FROM users WHERE google_sub = ?",
googleSub
);
return results.isEmpty() ? null : results.get(0);
@@ -48,9 +48,16 @@ public class UserRepository {
public Map<String, Object> findById(String userId) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token FROM users WHERE RAWTOHEX(id) = ?",
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token, english_level FROM users WHERE RAWTOHEX(id) = ?",
userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateEnglishLevel(String userId, String englishLevel) {
jdbcTemplate.update(
"UPDATE users SET english_level = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
englishLevel, userId
);
}
}

View File

@@ -130,6 +130,10 @@ public class AuthService {
public Mono<Void> logout(String userId) {
return Mono.fromRunnable(() -> {
if (userId == null) {
log.warn("Logout called with null userId, ignoring");
return;
}
userRepository.updateRefreshToken(userId, null);
log.info("User logged out: {}", userId);
}).subscribeOn(Schedulers.boundedElastic()).then();

View File

@@ -24,6 +24,7 @@ public class IngestPipelineService {
private final KnowledgeChunkRepository chunkRepository;
private final ChunkEmbeddingRepository embeddingRepository;
private final CategoryRepository categoryRepository;
private final com.sundol.repository.UserRepository userRepository;
private final ChunkingService chunkingService;
private final WebCrawlerService webCrawlerService;
private final OciGenAiService genAiService;
@@ -34,6 +35,7 @@ public class IngestPipelineService {
KnowledgeChunkRepository chunkRepository,
ChunkEmbeddingRepository embeddingRepository,
CategoryRepository categoryRepository,
com.sundol.repository.UserRepository userRepository,
ChunkingService chunkingService,
WebCrawlerService webCrawlerService,
OciGenAiService genAiService,
@@ -42,6 +44,7 @@ public class IngestPipelineService {
this.chunkRepository = chunkRepository;
this.embeddingRepository = embeddingRepository;
this.categoryRepository = categoryRepository;
this.userRepository = userRepository;
this.chunkingService = chunkingService;
this.webCrawlerService = webCrawlerService;
this.genAiService = genAiService;
@@ -50,6 +53,256 @@ public class IngestPipelineService {
private static final int TITLE_MAX_LENGTH = 80;
private static final int TEXT_PREVIEW_LENGTH = 3000;
private static final int STRUCTURING_MIN_LENGTH = 1000;
/**
* LLM으로 콘텐츠를 구조화: Abstract + 목차 + 목차별 상세 정리.
* 1000자 이상일 때만 실행.
* 1차 호출: Abstract + 목차 생성, 2차~ 호출: 목차별 상세 정리, 최종 조합.
*/
public String structureContent(String text, String modelId, String knowledgeItemId) {
return structureContent(text, modelId, knowledgeItemId, "B2");
}
public String structureContent(String text, String modelId, String knowledgeItemId, String englishLevel) {
if (!genAiService.isConfigured()) {
log.info("OCI GenAI not configured, skipping structuring");
return null;
}
if (text.length() < STRUCTURING_MIN_LENGTH) {
log.info("Content too short for structuring ({} chars), skipping", text.length());
return null;
}
try {
String content = text.length() > 30000 ? text.substring(0, 30000) : text;
boolean isEnglish = isEnglishContent(content);
String level = (englishLevel == null || englishLevel.isBlank()) ? "B2" : englishLevel;
log.info("Structuring content: isEnglish={}, englishLevel={}", isEnglish, level);
// === 1차 호출: Abstract + 목차 생성 ===
String tocSystemMsg = buildTocSystemMsg(isEnglish);
String tocUserMsg = "아래 원본 텍스트의 요약과 목차를 생성해주세요:\n\n" + content;
String tocResult = genAiService.chat(tocSystemMsg, tocUserMsg, modelId).strip();
log.info("Phase 1 - TOC generated: {} chars", tocResult.length());
// 1차 결과 중간 저장
if (knowledgeItemId != null) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, tocResult);
}
// 목차 항목 파싱
List<String> tocItems = parseTocItems(tocResult);
if (tocItems.isEmpty()) {
log.warn("No TOC items parsed, returning TOC-only result");
return tocResult;
}
log.info("Parsed {} TOC items: {}", tocItems.size(), tocItems);
// === 2차~ 호출: 목차별 상세 정리 (잘리면 이어쓰기) ===
StringBuilder fullResult = new StringBuilder(tocResult).append("\n\n");
String sectionSystemMsg = buildSectionSystemMsg(isEnglish, level);
for (int i = 0; i < tocItems.size(); i++) {
String tocItem = tocItems.get(i);
int sectionNum = i + 1;
log.info("Phase 2 - Processing section {}: '{}'", sectionNum, tocItem);
try {
String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n" +
"응답이 잘릴 경우 문장 중간에 끊지 말고, 완성된 문장까지만 작성하세요.\n\n" +
"## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" +
"## 원본 텍스트\n" + content;
String sectionResult = genAiService.chat(sectionSystemMsg, sectionUserMsg, modelId).strip();
fullResult.append(sectionResult).append("\n\n");
log.info("Phase 2 - Section {} '{}' generated: {} chars", sectionNum, tocItem, sectionResult.length());
// 응답이 잘린 것 같으면 이어쓰기 (최대 2회)
for (int cont = 0; cont < 2; cont++) {
if (sectionResult.length() < 3500) break;
String lastChars = sectionResult.substring(Math.max(0, sectionResult.length() - 50));
if (lastChars.endsWith(".") || lastChars.endsWith("다.") || lastChars.endsWith("니다.") ||
lastChars.endsWith("음.") || lastChars.endsWith("임.") || lastChars.endsWith("함.") ||
lastChars.endsWith("습니다.") || lastChars.endsWith("됩니다.")) {
break;
}
log.info("Phase 2 - Section {} may be truncated, continuation {}", sectionNum, cont + 1);
String contUserMsg = "이전 응답이 잘렸습니다. 아래 마지막 부분부터 이어서 작성해주세요.\n" +
"이미 작성된 내용을 반복하지 마세요.\n\n" +
"## 이전 응답의 마지막 200자\n" +
sectionResult.substring(Math.max(0, sectionResult.length() - 200)) + "\n\n" +
"## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" +
"## 원본 텍스트\n" + content;
String contResult = genAiService.chat(sectionSystemMsg, contUserMsg, modelId).strip();
if (contResult.isBlank()) break;
fullResult.append(contResult).append("\n\n");
sectionResult = contResult;
log.info("Phase 2 - Section {} continuation {}: {} chars", sectionNum, cont + 1, contResult.length());
if (knowledgeItemId != null) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
}
}
} catch (Exception e) {
log.warn("Failed to process section {}: {}", sectionNum, e.getMessage());
fullResult.append("# ").append(sectionNum).append(". ").append(tocItem).append("\n\n(정리 실패)\n\n");
}
// 섹션 완료마다 중간 저장
if (knowledgeItemId != null) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
}
}
String result = fullResult.toString().strip();
log.info("Structured content generated: {} chars ({} sections)", result.length(), tocItems.size());
return result;
} catch (Exception e) {
log.warn("Content structuring failed", e);
return null;
}
}
/**
* 영어 컨텐츠인지 판단한다 (ASCII 알파벳 비중 60% 이상).
*/
private boolean isEnglishContent(String text) {
if (text == null || text.isBlank()) return false;
int alpha = 0, total = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (Character.isWhitespace(c)) continue;
total++;
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) alpha++;
}
if (total == 0) return false;
double ratio = (double) alpha / total;
return ratio >= 0.6;
}
/**
* CEFR 레벨에 대한 설명을 반환한다 (TOEIC 매핑 포함).
*/
private String cefrDescription(String level) {
return switch (level) {
case "A1" -> "A1 (TOEIC 120-225, 입문)";
case "A2" -> "A2 (TOEIC 225-550, 초급, 기초 회화)";
case "B1" -> "B1 (TOEIC 550-785, 중급, 일상 의사소통)";
case "B2" -> "B2 (TOEIC 785-945, 중상급, 업무 영어)";
case "C1" -> "C1 (TOEIC 945-990, 고급, 유창함)";
case "C2" -> "C2 (원어민 수준)";
default -> "B2 (TOEIC 785-945, 중상급)";
};
}
/**
* 1차 호출용 시스템 메시지 (Abstract + 목차).
*/
private String buildTocSystemMsg(boolean isEnglish) {
StringBuilder sb = new StringBuilder();
sb.append("당신은 콘텐츠 분석 전문가입니다. 주어진 원본 텍스트를 분석하여 요약과 목차만 생성해주세요.\n\n");
sb.append("## 출력 형식 규칙\n");
sb.append("1. 한국어로 작성하세요 (원본이 영어여도).\n");
sb.append("2. 순수 Markdown 형식으로 작성하세요. 코드 블록(```)으로 감싸지 마세요.\n");
sb.append("3. 아래 구조를 정확히 따르세요:\n\n");
sb.append("# 요약 (Abstract)\n");
sb.append("(핵심 내용을 3~5문장으로 요약)\n\n");
sb.append("# 목차\n");
sb.append("1. 첫 번째 주제\n");
sb.append("2. 두 번째 주제\n");
sb.append("...\n\n");
sb.append("## 작성 규칙\n");
sb.append("- 목차는 원본 텍스트의 **서술 순서(흐름)**에 따라 나누세요. 주제별 분류가 아닙니다.\n");
sb.append("- 원본에서 앞부분에 나오는 내용이 1번, 뒷부분이 마지막 번호가 되어야 합니다.\n");
sb.append("- 각 목차 항목이 원본의 서로 다른 구간을 담당해야 합니다. 같은 내용이 여러 항목에 걸치면 안 됩니다.\n");
sb.append("- 목차 개수는 원본 길이에 비례하세요: 3000자 미만이면 3~5개, 3000~10000자이면 5~10개, 10000자 이상이면 8~15개.\n");
sb.append("- 목차 항목은 번호와 제목만 포함하세요. 상세 설명은 넣지 마세요.\n");
sb.append("- 원본에 없는 내용을 추가하지 마세요.\n");
sb.append("- 목차 항목 앞에 불릿 마커(`-`, `*`)를 붙이지 마세요. 숫자만 사용하세요.\n");
sb.append("- **금지**: 전체 요약, 결론, 마무리, 종합 정리 같은 성격의 섹션을 만들지 마세요. 각 섹션은 원본의 고유한 구간만 담당해야 합니다.\n");
return sb.toString();
}
/**
* 2차+ 호출용 시스템 메시지 (목차별 상세 정리).
* 영어 컨텐츠인 경우 영어 표현 학습 섹션 추가.
*/
private String buildSectionSystemMsg(boolean isEnglish, String englishLevel) {
StringBuilder sb = new StringBuilder();
sb.append("당신은 콘텐츠 정리 전문가입니다. 주어진 원본 텍스트에서 지정된 섹션에 해당하는 내용만 상세히 정리해주세요.\n\n");
sb.append("## 출력 형식 규칙 (반드시 지킬 것)\n");
sb.append("1. 순수 Markdown 형식으로 작성하세요. 코드 블록(```)으로 감싸지 마세요.\n");
sb.append("2. 섹션 제목은 정확히 `# 번호. 제목` 형식으로 시작하세요. (예: `# 1. 첫 번째 주제`)\n");
sb.append("3. 하위 제목이 필요하면 `## 소제목` 또는 `### 세부제목`을 사용하세요.\n");
sb.append("4. 불릿 포인트는 `-`만 사용하세요. `*`는 사용하지 마세요.\n");
sb.append("5. 굵은 글씨는 `**텍스트**` 형식만 사용하세요.\n");
sb.append("6. 각 불릿 포인트는 한 줄로 시작하고, 들여쓰기는 2칸(공백 2개)으로 일관되게 하세요.\n");
sb.append("7. 빈 줄은 섹션/소제목 구분에만 사용하세요. 불릿 사이에 빈 줄을 넣지 마세요.\n");
sb.append("8. 한국어로 작성하세요 (원본이 영어여도).\n\n");
sb.append("## 내용 작성 규칙\n");
sb.append("- 원본 텍스트에 있는 내용만 정리하세요. 원본에 없는 내용을 절대 추가하지 마세요.\n");
sb.append("- 부연 설명, 해석, 의견을 추가하지 마세요. 요약과 정리만 하세요.\n");
sb.append("- 원본의 의미를 왜곡하지 마세요. 디테일을 살리되, 원본의 범위를 넘지 마세요.\n");
sb.append("- 해당 섹션이 담당하는 원본 구간의 내용만 정리하세요. 다른 섹션의 내용을 포함하지 마세요.\n");
sb.append("- 가독성을 위해 하위 항목, 굵은 글씨, 불릿 포인트를 적절히 활용하세요.\n");
sb.append("- 원본이 짧으면 정리도 짧게 하세요. 불필요하게 늘리지 마세요.\n");
if (isEnglish) {
sb.append("\n## 영어 학습 보조 (원본이 영어 컨텐츠임)\n");
sb.append("사용자의 영어 수준: **").append(cefrDescription(englishLevel)).append("**\n\n");
sb.append("이 섹션 정리 마지막에 다음 형식으로 영어 학습 박스를 추가하세요:\n\n");
sb.append("### 핵심 영어 표현\n");
sb.append("- **expression** (한글 번역) — 간단한 설명 또는 사용 맥락\n");
sb.append(" - 예문: \"Original English sentence from the text\" (한글 번역)\n\n");
sb.append("### 작성 규칙:\n");
sb.append("- 사용자의 수준(").append(englishLevel).append(")보다 약간 어려운 표현을 우선 추출하세요.\n");
sb.append("- 일상적인 단어(go, eat, see 등)는 추출하지 마세요.\n");
sb.append("- 학습할 가치가 있는 관용 표현, 숙어, 학술/전문 용어, 콜로케이션을 우선하세요.\n");
sb.append("- 섹션당 3~7개 표현을 추출하세요.\n");
sb.append("- 예문은 반드시 원본 텍스트에서 인용하세요.\n");
sb.append("- 추출할 만한 표현이 없으면 이 박스를 생략하세요.\n");
}
return sb.toString();
}
/**
* 목차 텍스트에서 항목들을 파싱한다.
* "1. 첫 번째 주제" 형태의 줄을 추출.
*/
private List<String> parseTocItems(String tocText) {
List<String> items = new java.util.ArrayList<>();
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("^\\d+\\.\\s+(.+)$", java.util.regex.Pattern.MULTILINE);
java.util.regex.Matcher matcher = pattern.matcher(tocText);
// "# 목차" 이후의 내용만 파싱
int tocStart = tocText.indexOf("# 목차");
if (tocStart == -1) tocStart = tocText.indexOf("# Table of Contents");
if (tocStart == -1) tocStart = 0;
String tocSection = tocText.substring(tocStart);
// 목차 섹션 이후 다음 '#'이 나오기 전까지만 파싱
int nextSection = tocSection.indexOf("\n#", 2);
if (nextSection > 0) {
tocSection = tocSection.substring(0, nextSection);
}
matcher = pattern.matcher(tocSection);
while (matcher.find()) {
String item = matcher.group(1).strip();
if (!item.isBlank()) {
items.add(item);
}
}
return items;
}
/**
* LLM으로 내용 기반 제목 생성. 실패 시 텍스트 앞부분으로 폴백.
@@ -178,6 +431,24 @@ public class IngestPipelineService {
}
}
/**
* 수동 구조화 요청 (비동기). 프론트엔드 버튼에서 호출.
*/
@Async
public void runStructuring(String knowledgeItemId, String text, String modelId, String englishLevel) {
try {
String structured = structureContent(text, modelId, knowledgeItemId, englishLevel);
if (structured != null && !structured.isBlank()) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
}
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
log.info("Manual structuring complete for item {}", knowledgeItemId);
} catch (Exception e) {
log.error("Manual structuring failed for item {}", knowledgeItemId, e);
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
}
}
@Async
public void runPipeline(String knowledgeItemId, String modelId) {
try {
@@ -243,7 +514,29 @@ public class IngestPipelineService {
knowledgeRepository.updateTitle(knowledgeItemId, autoTitle);
}
// Step 2: Chunk
// Step 2: Structure content (1000자 이상일 때만)
knowledgeRepository.updateStatus(knowledgeItemId, "STRUCTURING");
try {
// 사용자의 영어 수준 가져오기
String userId = (String) item.get("USER_ID");
String englishLevel = "B2";
try {
Map<String, Object> user = userRepository.findById(userId);
if (user != null && user.get("ENGLISH_LEVEL") != null) {
englishLevel = (String) user.get("ENGLISH_LEVEL");
}
} catch (Exception ignored) {}
String structured = structureContent(extractedText, modelId, knowledgeItemId, englishLevel);
if (structured != null && !structured.isBlank()) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
log.info("Item {} structured: {} chars", knowledgeItemId, structured.length());
}
} catch (Exception e) {
log.warn("Structuring failed for item {}, continuing pipeline", knowledgeItemId, e);
}
// Step 3: Chunk
knowledgeRepository.updateStatus(knowledgeItemId, "CHUNKING");
List<String> chunks = chunkingService.chunk(extractedText);
log.info("Item {} chunked into {} pieces", knowledgeItemId, chunks.size());
@@ -254,11 +547,11 @@ public class IngestPipelineService {
chunkRepository.insertChunk(knowledgeItemId, i, chunkContent, tokenCount);
}
// Step 3: Categorize
// Step 4: Categorize
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
// Step 4: Embedding
// Step 5: Embedding
knowledgeRepository.updateStatus(knowledgeItemId, "EMBEDDING");
embedChunks(knowledgeItemId, chunks);

View File

@@ -5,6 +5,7 @@ import com.sundol.exception.AppException;
import com.sundol.repository.CategoryRepository;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository;
import com.sundol.repository.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@@ -19,19 +20,35 @@ public class KnowledgeService {
private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository;
private final CategoryRepository categoryRepository;
private final UserRepository userRepository;
private final IngestPipelineService pipelineService;
public KnowledgeService(
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
CategoryRepository categoryRepository,
UserRepository userRepository,
IngestPipelineService pipelineService) {
this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.categoryRepository = categoryRepository;
this.userRepository = userRepository;
this.pipelineService = pipelineService;
}
private String resolveEnglishLevel(String userId, String requestLevel) {
if (requestLevel != null && requestLevel.matches("^(A1|A2|B1|B2|C1|C2)$")) {
return requestLevel;
}
try {
Map<String, Object> user = userRepository.findById(userId);
if (user != null && user.get("ENGLISH_LEVEL") != null) {
return (String) user.get("ENGLISH_LEVEL");
}
} catch (Exception ignored) {}
return "B2";
}
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
.subscribeOn(Schedulers.boundedElastic());
@@ -76,6 +93,45 @@ public class KnowledgeService {
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> structureContent(String userId, String id, String modelId, String englishLevel) {
return Mono.fromCallable(() -> {
Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
// raw_text 또는 청크에서 원본 텍스트 가져오기
Object rawTextObj = item.get("RAW_TEXT");
String text = rawTextObj != null ? rawTextObj.toString() : null;
if (text == null || text.isBlank()) {
// WEB 타입은 raw_text가 없을 수 있으므로 청크에서 조합
var chunks = chunkRepository.findByKnowledgeItemId(id);
if (!chunks.isEmpty()) {
text = chunks.stream()
.map(c -> c.get("CONTENT").toString())
.collect(java.util.stream.Collectors.joining("\n\n"));
}
}
if (text == null || text.isBlank()) {
throw new AppException(HttpStatus.BAD_REQUEST, "No content to structure");
}
// STRUCTURING 상태로 변경 (프론트엔드 폴링에서 진행 중 표시)
knowledgeRepository.updateStatus(id, "STRUCTURING");
// 영어 수준 결정 (요청 값 > 사용자 프로필 > B2 기본)
String level = resolveEnglishLevel(userId, englishLevel);
// 비동기로 구조화 실행 (중간 결과는 pipelineService가 DB에 직접 저장)
final String finalText = text;
pipelineService.runStructuring(id, finalText, modelId, level);
return knowledgeRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Void> delete(String userId, String id) {
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
.subscribeOn(Schedulers.boundedElastic()).then();

View File

@@ -0,0 +1,129 @@
package com.sundol.service;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
/**
* 사용자 Chrome 브라우저에 CDP(Chrome DevTools Protocol)로 연결하여 사용.
* Chrome은 --remote-debugging-port=9222로 실행되어 있어야 한다.
* VNC에서 사용자가 직접 로그인한 세션을 그대로 사용하므로 봇 판정을 우회할 수 있다.
*/
@Service
public class PlaywrightBrowserService {
private static final Logger log = LoggerFactory.getLogger(PlaywrightBrowserService.class);
private static final String CDP_URL = "http://localhost:9222";
private Playwright playwright;
private Browser browser;
@PostConstruct
public void init() {
try {
playwright = Playwright.create();
connectToChrome();
log.info("사용자 Chrome에 CDP 연결 완료");
} catch (Exception e) {
log.error("Chrome CDP 연결 실패: {}. Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요.", e.getMessage());
}
}
private void connectToChrome() {
browser = playwright.chromium().connectOverCDP(CDP_URL);
log.info("CDP 연결: {} contexts, {} pages",
browser.contexts().size(),
browser.contexts().stream().mapToInt(c -> c.pages().size()).sum());
}
@PreDestroy
public void destroy() {
try {
// CDP 연결만 끊음 (Chrome 자체는 종료하지 않음)
if (browser != null) browser.close();
if (playwright != null) playwright.close();
log.info("Chrome CDP 연결 해제 완료");
} catch (Exception e) {
log.warn("CDP 연결 해제 중 오류: {}", e.getMessage());
}
}
/**
* 사용자 Chrome의 기본 컨텍스트를 가져온다.
*/
private BrowserContext getDefaultContext() throws IOException {
ensureBrowserAlive();
List<BrowserContext> contexts = browser.contexts();
if (contexts.isEmpty()) {
throw new IOException("Chrome에 활성 컨텍스트가 없습니다.");
}
return contexts.get(0);
}
/**
* 새 탭을 열고 URL로 이동한다.
*/
public Page openPage(String url, int timeoutMs, WaitUntilState waitUntil) throws IOException {
BrowserContext ctx = getDefaultContext();
Page page = ctx.newPage();
try {
page.navigate(url, new Page.NavigateOptions()
.setTimeout(timeoutMs)
.setWaitUntil(waitUntil));
return page;
} catch (Exception e) {
page.close();
throw new IOException("페이지 로드 실패 (" + url + "): " + e.getMessage(), e);
}
}
public Page openPage(String url, int timeoutMs) throws IOException {
return openPage(url, timeoutMs, WaitUntilState.NETWORKIDLE);
}
public Page openPage(String url) throws IOException {
return openPage(url, 30_000);
}
/**
* 브라우저 페이지 내에서 JavaScript fetch로 URL 내용을 가져온다.
*/
public String fetchInPage(Page page, String url) throws IOException {
try {
Object result = page.evaluate("async (url) => {" +
" const res = await fetch(url, { credentials: 'include' });" +
" if (!res.ok) throw new Error('HTTP ' + res.status);" +
" return await res.text();" +
"}", url);
return result != null ? result.toString() : "";
} catch (Exception e) {
throw new IOException("브라우저 내 fetch 실패 (" + url + "): " + e.getMessage(), e);
}
}
/**
* Chrome 연결이 끊겼으면 재연결한다.
*/
private synchronized void ensureBrowserAlive() throws IOException {
if (browser != null && browser.isConnected()) {
return;
}
log.warn("Chrome CDP 연결이 끊겼습니다. 재연결합니다.");
try {
if (browser != null) {
try { browser.close(); } catch (Exception ignored) {}
}
connectToChrome();
log.info("Chrome CDP 재연결 완료");
} catch (Exception e) {
throw new IOException("Chrome CDP 재연결 실패. Chrome이 실행 중인지 확인하세요: " + e.getMessage(), e);
}
}
}

View File

@@ -1,9 +1,6 @@
package com.sundol.service;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
@@ -31,11 +28,13 @@ public class WebCrawlerService {
);
private final WebClient webClient;
private final PlaywrightBrowserService browserService;
@Value("${jina.reader.api-key:}")
private String jinaApiKey;
public WebCrawlerService() {
public WebCrawlerService(PlaywrightBrowserService browserService) {
this.browserService = browserService;
this.webClient = WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(5 * 1024 * 1024))
.build();
@@ -66,7 +65,7 @@ public class WebCrawlerService {
log.warn("Jina Reader failed for {}: {}, falling back to Playwright", url, e.getMessage());
}
// 3차: Playwright headless browser (최후의 수단)
// 3차: Playwright (싱글톤 브라우저 탭)
String playwrightText = crawlWithPlaywright(url);
if (!isValidContent(playwrightText)) {
throw new IOException("All crawl methods failed for " + url + " (error page detected from all 3 sources)");
@@ -141,27 +140,8 @@ public class WebCrawlerService {
private String crawlWithPlaywright(String url) throws IOException {
log.info("Crawling with Playwright: {}", url);
try (Playwright playwright = Playwright.create()) {
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(java.util.List.of(
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu"
));
try (Browser browser = playwright.chromium().launch(launchOptions)) {
Browser.NewContextOptions contextOptions = new Browser.NewContextOptions()
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
var context = browser.newContext(contextOptions);
Page page = context.newPage();
page.navigate(url, new Page.NavigateOptions()
.setTimeout(30_000)
.setWaitUntil(com.microsoft.playwright.options.WaitUntilState.NETWORKIDLE));
Page page = browserService.openPage(url);
try {
// JS 실행으로 본문 텍스트 추출
String text = page.evaluate("() => {" +
" ['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']" +
@@ -177,11 +157,12 @@ public class WebCrawlerService {
}
return text;
} finally {
try {
page.navigate("about:blank");
} catch (Exception ignored) {
page.close();
}
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("Playwright failed for " + url + ": " + e.getMessage(), e);
}
}
@@ -205,17 +186,16 @@ public class WebCrawlerService {
return firstLine.length() > 80 ? firstLine.substring(0, 77) + "..." : firstLine;
} catch (Exception e2) {
log.warn("Jina Reader title extraction also failed, trying Playwright", e2);
// Playwright로 제목 추출
try (Playwright playwright = Playwright.create()) {
try (Browser browser = playwright.chromium().launch(
new BrowserType.LaunchOptions().setHeadless(true)
.setArgs(java.util.List.of("--no-sandbox", "--disable-setuid-sandbox")))) {
Page page = browser.newPage();
page.navigate(url, new Page.NavigateOptions().setTimeout(30_000));
// Playwright 싱글톤 브라우저로 제목 추출
Page page = browserService.openPage(url);
try {
return page.title();
} finally {
try {
page.navigate("about:blank");
} catch (Exception ignored2) {
page.close();
}
} catch (Exception e3) {
throw new IOException("All title extraction methods failed for: " + url, e3);
}
}
}

View File

@@ -1,6 +1,6 @@
package com.sundol.service;
import com.microsoft.playwright.*;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.WaitUntilState;
import io.github.thoroldvix.api.TranscriptApiFactory;
import io.github.thoroldvix.api.TranscriptContent;
@@ -12,12 +12,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -39,6 +36,24 @@ public class YouTubeTranscriptService {
Pattern.compile("<text[^>]*>(.*?)</text>", Pattern.DOTALL);
private final YoutubeTranscriptApi transcriptApi = TranscriptApiFactory.createDefault();
private final PlaywrightBrowserService browserService;
private final String extractTranscriptJs;
private final String transcriptFromPageJs;
public YouTubeTranscriptService(PlaywrightBrowserService browserService) {
this.browserService = browserService;
this.extractTranscriptJs = loadResource("youtube-transcript-extract.js");
this.transcriptFromPageJs = loadResource("youtube-transcript-from-page.js");
}
private String loadResource(String name) {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(name)) {
if (is == null) throw new RuntimeException("Resource not found: " + name);
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("Failed to load resource: " + name, e);
}
}
public String fetchTranscript(String youtubeUrl) throws IOException {
String videoId = extractVideoId(youtubeUrl);
@@ -59,7 +74,7 @@ public class YouTubeTranscriptService {
log.warn("youtube-transcript-api failed for {}: {}", videoId, e.getMessage());
}
// 2차 fallback: Playwright head 모드
// 2차 fallback: Playwright head 모드 (싱글톤 브라우저 탭)
log.info("Falling back to Playwright for videoId: {}", videoId);
return fetchWithPlaywright(videoId);
}
@@ -107,87 +122,398 @@ public class YouTubeTranscriptService {
private String fetchWithPlaywright(String videoId) throws IOException {
String watchUrl = "https://www.youtube.com/watch?v=" + videoId;
String html;
try (Playwright playwright = Playwright.create()) {
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of(
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage"
));
try (Browser browser = playwright.chromium().launch(launchOptions)) {
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.setLocale("ko-KR"));
// YouTube 쿠키 로딩 (봇 차단 우회)
loadCookies(context);
Page page = context.newPage();
page.navigate(watchUrl, new Page.NavigateOptions()
.setTimeout(30_000)
.setWaitUntil(WaitUntilState.NETWORKIDLE));
html = page.content();
log.info("Playwright fetched YouTube page: {} chars", html.length());
context.close();
}
} catch (Exception e) {
throw new IOException("YouTube 페이지를 가져올 수 없습니다 (Playwright): " + e.getMessage(), e);
}
// captionTracks JSON 추출
Matcher captionMatcher = CAPTION_TRACK_PATTERN.matcher(html);
if (!captionMatcher.find()) {
throw new IOException("이 영상에는 자막(caption)이 없습니다.");
}
String captionTracksJson = captionMatcher.group(1);
String captionUrl = selectCaptionUrl(captionTracksJson);
if (captionUrl == null) {
throw new IOException("자막 트랙 URL을 추출할 수 없습니다.");
}
captionUrl = captionUrl.replace("\\u0026", "&");
log.info("Fetching caption XML from: {}", captionUrl);
// 자막 XML 가져오기
log.info("Attempting to fetch caption XML...");
String xml;
// YouTube는 광고 때문에 NETWORKIDLE에 도달하지 못할 수 있으므로 DOMCONTENTLOADED 사용
Page page = browserService.openPage(watchUrl, 60_000, WaitUntilState.DOMCONTENTLOADED);
try {
var conn = (java.net.HttpURLConnection) new java.net.URI(captionUrl).toURL().openConnection();
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
conn.setConnectTimeout(15_000);
conn.setReadTimeout(15_000);
int responseCode = conn.getResponseCode();
log.info("Caption XML response code: {}", responseCode);
if (responseCode != 200) {
String errorBody = new String(conn.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
log.error("Caption XML error response: {}", errorBody);
throw new IOException("자막 XML 응답 코드: " + responseCode);
}
xml = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
log.info("Caption XML fetched: {} chars", xml.length());
} catch (Exception e) {
log.error("Failed to fetch caption XML: {}", e.getMessage(), e);
throw new IOException("자막 XML을 가져올 수 없습니다: " + e.getMessage(), e);
}
log.info("Playwright fetched YouTube page for videoId: {}", videoId);
// DOM 로드 후 추가 대기 (JS 렌더링)
page.waitForTimeout(3000);
String transcript = parseTranscriptXml(xml);
log.info("Parsed transcript: {} chars (blank={})", transcript.length(), transcript.isBlank());
if (transcript.isBlank()) {
log.error("Transcript XML content (first 500 chars): {}", xml.substring(0, Math.min(500, xml.length())));
throw new IOException("자막 텍스트를 파싱할 수 없습니다.");
}
// Step 0: 동영상 재생 시작 (봇 판정 우회)
playVideo(page);
log.info("Successfully fetched transcript via Playwright: {} chars", transcript.length());
// Step 1: 광고 대기 (최대 60초)
waitForAdsToFinish(page);
// Step 2: 실제 영상 재생 확인 (최대 15초)
waitForVideoPlaying(page);
// Step 3: 페이지 내 JS에서 ytInitialPlayerResponse로 자막 데이터 직접 추출
String transcript = fetchTranscriptFromPageJs(page);
if (transcript != null && !transcript.isBlank()) {
log.info("Successfully fetched transcript via page JS: {} chars", transcript.length());
return transcript;
}
// Step 4: 자막 패널 열어서 텍스트 추출 (확인 루프)
transcript = extractTranscriptFromPanel(page);
if (transcript != null && !transcript.isBlank()) {
log.info("Successfully fetched transcript via panel: {} chars", transcript.length());
return transcript;
}
throw new IOException("자막 텍스트를 가져올 수 없습니다.");
} finally {
try {
page.navigate("about:blank");
} catch (Exception ignored) {
page.close();
}
}
}
/**
* 페이지 내 ytInitialPlayerResponse에서 자막 데이터를 직접 추출한다.
* 브라우저 JS 컨텍스트에서 fetch하므로 쿠키/세션 유지.
*/
private String fetchTranscriptFromPageJs(Page page) {
try {
Object result = page.evaluate(transcriptFromPageJs);
if (result == null) return null;
String resultStr = result.toString();
log.info("Page JS transcript result: {} chars", resultStr.length());
if (resultStr.startsWith("{")) {
// JSON 응답 파싱
var jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().readTree(resultStr);
if (jsonNode.has("error")) {
log.warn("Page JS transcript error: {}", jsonNode.get("error").asText());
return null;
}
String type = jsonNode.has("type") ? jsonNode.get("type").asText() : "";
String data = jsonNode.has("data") ? jsonNode.get("data").asText() : "";
String lang = jsonNode.has("lang") ? jsonNode.get("lang").asText() : "";
log.info("Page JS transcript type={}, lang={}, data={} chars", type, lang, data.length());
if (data.isEmpty()) return null;
if ("json3".equals(type)) {
return parseTranscriptJson(data);
} else if ("xml".equals(type)) {
return parseTranscriptXml(data);
}
return null;
}
// 문자열 직접 반환된 경우
return resultStr.isBlank() ? null : resultStr;
} catch (Exception e) {
log.warn("fetchTranscriptFromPageJs failed: {}", e.getMessage());
return null;
}
}
/**
* 동영상 재생을 시작한다. 실제 사용자처럼 행동하여 봇 판정을 우회.
*/
private void playVideo(Page page) {
try {
// 쿠키 동의 팝업 닫기
page.evaluate(
"() => { " +
" var agreeBtn = document.querySelector('button[aria-label*=\"Accept\"], button[aria-label*=\"동의\"], tp-yt-paper-button[aria-label*=\"Agree\"]'); " +
" if (agreeBtn) agreeBtn.click(); " +
"}"
);
page.waitForTimeout(1000);
// 마우스를 플레이어 위로 이동 (호버 효과)
page.evaluate(
"() => { " +
" var player = document.querySelector('#movie_player, .html5-video-player'); " +
" if (player) player.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); " +
"}"
);
page.waitForTimeout(500);
// 재생 버튼 클릭 또는 영상 영역 클릭
page.evaluate(
"() => { " +
" var playBtn = document.querySelector('.ytp-play-button, .ytp-large-play-button'); " +
" if (playBtn) { playBtn.click(); return 'play_clicked'; } " +
" var video = document.querySelector('video'); " +
" if (video) { video.play(); return 'video_play'; } " +
" var player = document.querySelector('#movie_player'); " +
" if (player) { player.click(); return 'player_clicked'; } " +
" return 'nothing'; " +
"}"
);
log.info("Video play initiated");
page.waitForTimeout(2000);
} catch (Exception e) {
log.warn("playVideo error (non-fatal): {}", e.getMessage());
}
}
/**
* 실제 영상이 재생 중인지 확인한다. (최대 15초)
* 재생 시간이 변화하면 재생 중으로 판단.
*/
private void waitForVideoPlaying(Page page) {
log.info("Waiting for video to start playing...");
try {
for (int i = 0; i < 5; i++) {
Object result = page.evaluate(
"() => { " +
" var video = document.querySelector('video'); " +
" if (!video) return 'no_video'; " +
" if (video.paused) return 'paused:' + video.currentTime; " +
" return 'playing:' + video.currentTime; " +
"}"
);
String status = result != null ? result.toString() : "unknown";
log.info("Video status: {}", status);
if (status.startsWith("playing:")) {
// 재생 시간이 0보다 크면 실제 재생 중
String timeStr = status.substring(8);
try {
double time = Double.parseDouble(timeStr);
if (time > 0.5) {
log.info("Video is playing at {}s", time);
return;
}
} catch (NumberFormatException ignored) {}
}
if (status.startsWith("paused")) {
// 일시정지 상태면 다시 재생 시도
page.evaluate(
"() => { " +
" var video = document.querySelector('video'); " +
" if (video) video.play(); " +
"}"
);
}
page.waitForTimeout(3000);
}
log.info("Video play wait finished");
} catch (Exception e) {
log.warn("waitForVideoPlaying error (non-fatal): {}", e.getMessage());
}
}
/**
* YouTube 광고가 재생 중이면 끝날 때까지 대기한다. (최대 60초)
* 스킵 버튼이 나타나면 클릭한다.
*/
private void waitForAdsToFinish(Page page) {
log.info("Checking for YouTube ads...");
for (int i = 0; i < 30; i++) { // 최대 60초 (2초 간격)
try {
Object adResult = page.evaluate(
"() => { " +
" // 스킵 버튼 찾기 (여러 버전 대응) " +
" var skipBtns = document.querySelectorAll('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-ad-skip-button-container button, [class*=\"skip\"][class*=\"ad\"] button'); " +
" for (var i = 0; i < skipBtns.length; i++) { " +
" if (skipBtns[i].offsetParent !== null) { skipBtns[i].click(); return 'skipped'; } " +
" } " +
" // 광고 재생 중 확인: #movie_player에 ad-showing 클래스 " +
" var player = document.querySelector('#movie_player'); " +
" if (player && player.classList.contains('ad-showing')) return 'ad_playing'; " +
" // 광고 오버레이 확인 " +
" var adOverlay = document.querySelector('.ytp-ad-player-overlay'); " +
" if (adOverlay && adOverlay.offsetHeight > 0) return 'ad_playing'; " +
" // video duration으로 확인 (광고는 보통 짧음) " +
" var video = document.querySelector('video'); " +
" if (video && video.duration > 0 && video.duration < 120 && !video.paused) { " +
" var adText = document.querySelector('.ytp-ad-text, .ytp-ad-preview-text, .ytp-ad-simple-ad-badge'); " +
" if (adText) return 'ad_playing'; " +
" } " +
" return 'no_ad'; " +
"}"
);
String status = adResult != null ? adResult.toString() : "no_ad";
if ("skipped".equals(status)) {
log.info("Ad skipped at attempt {}", i + 1);
page.waitForTimeout(3000);
continue;
}
if ("no_ad".equals(status)) {
if (i == 0) {
log.info("No ads detected");
} else {
log.info("Ads finished after {} attempts", i + 1);
}
return;
}
if (i % 5 == 0) log.info("Ad still playing, waiting... ({}s)", i * 2);
page.waitForTimeout(2000);
} catch (Exception e) {
log.warn("Ad check error: {}", e.getMessage());
return;
}
}
log.warn("Ad wait timeout (60s), proceeding anyway");
}
/**
* YouTube 페이지에서 '자막 표시' 패널을 열고 텍스트를 추출한다.
*/
private String extractTranscriptFromPanel(Page page) {
try {
page.waitForTimeout(2000);
// Step 1: 설명란 펼치기
page.evaluate(
"() => { " +
" var exp = document.querySelector('#expand, tp-yt-paper-button#expand, #description-inline-expander #expand'); " +
" if (exp) exp.click(); " +
"}"
);
page.waitForTimeout(1500);
// Step 2: '스크립트 표시' / 'Show transcript' 버튼 찾아 클릭
Object result = page.evaluate(
"() => { " +
" var keywords = ['Show transcript', 'transcript', '스크립트 표시', '자막 보기', '자막']; " +
" var allBtns = document.querySelectorAll('button, ytd-button-renderer, tp-yt-paper-button'); " +
" for (var i = 0; i < allBtns.length; i++) { " +
" var t = (allBtns[i].innerText || allBtns[i].textContent || '').trim().toLowerCase(); " +
" for (var j = 0; j < keywords.length; j++) { " +
" if (t.indexOf(keywords[j].toLowerCase()) !== -1) { " +
" allBtns[i].click(); " +
" return 'clicked:' + t; " +
" } " +
" } " +
" } " +
" return 'not_found'; " +
"}"
);
log.info("Transcript panel button result: {}", result);
// 패널이 HIDDEN 상태일 수 있으므로 직접 visibility를 변경하고 데이터 로드 트리거
page.waitForTimeout(1500);
page.evaluate(
"() => { " +
" var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " +
" if (panel) { " +
" panel.setAttribute('visibility', 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED'); " +
" panel.removeAttribute('hidden'); " +
" panel.style.display = ''; " +
" } " +
"}"
);
page.waitForTimeout(1000);
if (result != null && result.toString().startsWith("not_found")) {
// 대안: 동영상 메뉴(...) 버튼 → 스크립트 열기
page.evaluate(
"() => { " +
" var menuBtn = document.querySelector('ytd-menu-renderer yt-icon-button button, #menu button'); " +
" if (menuBtn) menuBtn.click(); " +
"}"
);
page.waitForTimeout(1000);
page.evaluate(
"() => { " +
" var items = document.querySelectorAll('tp-yt-paper-item, ytd-menu-service-item-renderer'); " +
" for (var i = 0; i < items.length; i++) { " +
" var t = (items[i].innerText || '').toLowerCase(); " +
" if (t.indexOf('transcript') !== -1 || t.indexOf('스크립트') !== -1) { " +
" items[i].click(); " +
" return 'menu_clicked'; " +
" } " +
" } " +
" return 'not_found'; " +
"}"
);
}
// Step 3: 자막 텍스트가 나타날 때까지 확인 루프 (최대 30초)
String transcript = null;
for (int attempt = 0; attempt < 15; attempt++) {
page.waitForTimeout(2000);
// 디버그: 트랜스크립트 패널 DOM 상태 확인
if (attempt == 0 || attempt == 3) {
Object debugInfo = page.evaluate(
"() => { " +
" var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " +
" if (!panel) return 'panel:NOT_FOUND'; " +
" var vis = panel.getAttribute('visibility'); " +
" var segs = panel.querySelectorAll('[class*=\"segment\"], [class*=\"cue\"], yt-formatted-string'); " +
" var body = panel.querySelector('#body, #content'); " +
" var bodyHtml = body ? body.innerHTML.substring(0, 500) : 'no_body'; " +
" return 'panel:' + vis + ' segs:' + segs.length + ' html:' + bodyHtml; " +
"}"
);
log.info("Debug transcript panel (attempt {}): {}", attempt + 1, debugInfo);
}
Object transcriptObj = page.evaluate(extractTranscriptJs);
transcript = transcriptObj != null ? transcriptObj.toString() : "";
if (!transcript.isBlank()) {
log.info("Transcript from panel: {} chars (attempt {})", transcript.length(), attempt + 1);
return transcript;
}
// 패널이 아직 안 열렸으면 다시 시도
if (attempt == 2 || attempt == 5 || attempt == 9) {
log.info("Retrying panel open at attempt {}", attempt + 1);
// 패널 visibility 강제 변경 + Show transcript 버튼 재클릭
page.evaluate(
"() => { " +
" var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " +
" if (panel) { " +
" panel.setAttribute('visibility', 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED'); " +
" panel.removeAttribute('hidden'); " +
" panel.style.display = ''; " +
" var cont = panel.querySelector('ytd-continuation-item-renderer'); " +
" if (cont) cont.click(); " +
" } " +
" var keywords = ['Show transcript', 'transcript', '스크립트 표시', '자막 보기']; " +
" var allBtns = document.querySelectorAll('button, ytd-button-renderer, tp-yt-paper-button'); " +
" for (var i = 0; i < allBtns.length; i++) { " +
" var t = (allBtns[i].innerText || '').trim().toLowerCase(); " +
" for (var j = 0; j < keywords.length; j++) { " +
" if (t.indexOf(keywords[j].toLowerCase()) !== -1) { " +
" allBtns[i].click(); return 'retried'; " +
" } " +
" } " +
" } " +
"}"
);
}
}
log.info("Transcript panel extraction timed out after 30s");
return null;
} catch (Exception e) {
log.warn("Failed to extract transcript from panel: {}", e.getMessage());
return null;
}
}
/**
* YouTube timedtext API의 fmt=json3 응답을 파싱한다.
*/
private String parseTranscriptJson(String json) {
try {
StringBuilder sb = new StringBuilder();
// json3 형식: {"events":[{"segs":[{"utf8":"text"}]},...]}
Pattern segPattern = Pattern.compile("\"utf8\":\\s*\"(.*?)\"");
Matcher matcher = segPattern.matcher(json);
while (matcher.find()) {
String text = matcher.group(1)
.replace("\\n", " ")
.replace("\\\"", "\"")
.trim();
if (!text.isEmpty() && !text.equals("\n")) {
if (sb.length() > 0) sb.append(" ");
sb.append(text);
}
}
return sb.toString();
} catch (Exception e) {
log.warn("Failed to parse transcript JSON: {}", e.getMessage());
return null;
}
}
private String selectCaptionUrl(String captionTracksJson) {
String[] tracks = captionTracksJson.split("\\},\\s*\\{");
@@ -236,37 +562,6 @@ public class YouTubeTranscriptService {
return sb.toString();
}
private void loadCookies(BrowserContext context) {
Path cookieFile = Path.of(System.getProperty("user.dir"), "cookies.txt");
if (!Files.exists(cookieFile)) {
log.warn("cookies.txt not found at: {}", cookieFile);
return;
}
try {
List<String> lines = Files.readAllLines(cookieFile);
List<com.microsoft.playwright.options.Cookie> cookies = new ArrayList<>();
for (String line : lines) {
if (line.startsWith("#") || line.isBlank()) continue;
String[] parts = line.split("\t");
if (parts.length < 7) continue;
String domain = parts[0];
if (!domain.contains("youtube") && !domain.contains("google")) continue;
cookies.add(new com.microsoft.playwright.options.Cookie(parts[5], parts[6])
.setDomain(domain)
.setPath(parts[2])
.setSecure("TRUE".equalsIgnoreCase(parts[3]))
.setHttpOnly(false));
}
if (!cookies.isEmpty()) {
context.addCookies(cookies);
log.info("Loaded {} YouTube cookies", cookies.size());
}
} catch (Exception e) {
log.warn("Failed to load cookies: {}", e.getMessage());
}
}
private String extractVideoId(String url) {
if (url == null || url.isBlank()) return null;
try {

View File

@@ -0,0 +1,33 @@
() => {
var selectors = [
'ytd-transcript-segment-renderer .segment-text',
'yt-formatted-string.segment-text',
'#segments-container yt-formatted-string',
'ytd-transcript-segment-list-renderer .segment-text',
'ytd-transcript-segment-renderer yt-formatted-string',
'ytd-engagement-panel-section-list-renderer yt-formatted-string.ytd-transcript-segment-renderer'
];
for (var s = 0; s < selectors.length; s++) {
try {
var segs = document.querySelectorAll(selectors[s]);
if (segs.length > 0) {
var texts = [];
for (var i = 0; i < segs.length; i++) {
var txt = segs[i].textContent.trim();
if (txt.length > 0) texts.push(txt);
}
if (texts.length > 0) return texts.join(' ');
}
} catch(e) {}
}
// 최후 수단: engagement panel 내 모든 텍스트
var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"]');
if (panel) {
var body = panel.querySelector('#body, #content');
if (body) {
var allText = body.innerText.trim();
if (allText.length > 100) return allText;
}
}
return '';
}

View File

@@ -0,0 +1,72 @@
() => {
try {
// 방법 1: ytInitialPlayerResponse에서 직접 자막 데이터 추출
var playerResponse = null;
if (typeof ytInitialPlayerResponse !== 'undefined') {
playerResponse = ytInitialPlayerResponse;
}
if (!playerResponse) {
// 페이지 소스에서 추출 시도
var scripts = document.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) {
var text = scripts[i].textContent;
if (text && text.indexOf('ytInitialPlayerResponse') !== -1) {
var match = text.match(/ytInitialPlayerResponse\s*=\s*(\{.*?\});/s);
if (match) {
try { playerResponse = JSON.parse(match[1]); } catch(e) {}
}
}
}
}
if (!playerResponse) return JSON.stringify({error: 'no_player_response'});
// captionTracks 추출
var captions = playerResponse.captions;
if (!captions) return JSON.stringify({error: 'no_captions'});
var renderer = captions.playerCaptionsTracklistRenderer;
if (!renderer) return JSON.stringify({error: 'no_renderer'});
var tracks = renderer.captionTracks;
if (!tracks || tracks.length === 0) return JSON.stringify({error: 'no_tracks'});
// 언어 우선순위: ko > en > 첫 번째
var selectedTrack = null;
for (var t = 0; t < tracks.length; t++) {
if (tracks[t].languageCode === 'ko') { selectedTrack = tracks[t]; break; }
}
if (!selectedTrack) {
for (var t = 0; t < tracks.length; t++) {
if (tracks[t].languageCode === 'en') { selectedTrack = tracks[t]; break; }
}
}
if (!selectedTrack) selectedTrack = tracks[0];
var baseUrl = selectedTrack.baseUrl;
if (!baseUrl) return JSON.stringify({error: 'no_base_url'});
// fmt=json3 추가
if (baseUrl.indexOf('fmt=') === -1) {
baseUrl += '&fmt=json3';
}
// 브라우저 내에서 fetch로 자막 데이터 가져오기
return fetch(baseUrl, {credentials: 'include'})
.then(function(res) { return res.text(); })
.then(function(text) {
if (!text || text.length === 0) {
// fmt=json3 실패 시 fmt 없이 재시도 (XML)
var xmlUrl = baseUrl.replace('&fmt=json3', '');
return fetch(xmlUrl, {credentials: 'include'})
.then(function(res2) { return res2.text(); })
.then(function(xmlText) {
return JSON.stringify({type: 'xml', data: xmlText, lang: selectedTrack.languageCode});
});
}
return JSON.stringify({type: 'json3', data: text, lang: selectedTrack.languageCode});
});
} catch(e) {
return JSON.stringify({error: e.message});
}
}

View File

@@ -25,16 +25,33 @@ echo "=== [1/3] Next.js 빌드 ==="
npx next build
echo "=== [2/3] 심볼릭 링크 생성 ==="
STATIC_SRC="$SCRIPT_DIR/.next/static"
STATIC_DST="$SCRIPT_DIR/.next/standalone/.next/static"
STANDALONE_DIR="$SCRIPT_DIR/.next/standalone"
# static 링크
STATIC_SRC="$SCRIPT_DIR/.next/static"
STATIC_DST="$STANDALONE_DIR/.next/static"
mkdir -p "$STANDALONE_DIR/.next"
if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then
rm -rf "$STATIC_DST"
fi
ln -s "$STATIC_SRC" "$STATIC_DST"
echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC"
# standalone output이 subdirectory에 생성되는 경우 대응
if [ -d "$STANDALONE_DIR/sundol-frontend" ]; then
# server.js 링크
if [ ! -f "$STANDALONE_DIR/server.js" ] && [ -f "$STANDALONE_DIR/sundol-frontend/server.js" ]; then
ln -sf "$STANDALONE_DIR/sundol-frontend/server.js" "$STANDALONE_DIR/server.js"
echo "server.js 링크 생성 완료"
fi
# nested static 링크
NESTED_STATIC="$STANDALONE_DIR/sundol-frontend/.next/static"
if [ ! -L "$NESTED_STATIC" ] && [ ! -e "$NESTED_STATIC" ]; then
ln -s "$STATIC_SRC" "$NESTED_STATIC"
echo "nested static 링크 생성 완료"
fi
fi
echo "=== [3/3] PM2 재시작 ==="
pm2 restart sundol-frontend

View File

@@ -13,7 +13,7 @@
"next": "^15.3.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^9.0.3",
"react-markdown": "^9.1.0",
"zustand": "^5.0.3"
},
"devDependencies": {

View File

@@ -9,23 +9,23 @@
"lint": "next lint"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^0.469.0",
"next": "^15.3.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"axios": "^1.7.9",
"zustand": "^5.0.3",
"react-markdown": "^9.0.3",
"lucide-react": "^0.469.0"
"react-markdown": "^9.1.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"typescript": "^5.7.3",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.10.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@tailwindcss/postcss": "^4.1.0",
"tailwindcss": "^4.1.0",
"eslint": "^9.17.0",
"eslint-config-next": "^15.3.1",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4.1.0",
"typescript": "^5.7.3"
}
}

View File

@@ -5,6 +5,7 @@ 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";
interface Category {
ID: string;
@@ -19,6 +20,7 @@ interface KnowledgeItem {
TITLE: string;
SOURCE_URL: string;
RAW_TEXT: string;
STRUCTURED_CONTENT: string | null;
STATUS: string;
CREATED_AT: string;
UPDATED_AT: string;
@@ -37,6 +39,7 @@ const statusColors: Record<string, string> = {
EXTRACTING: "bg-blue-500/20 text-blue-400",
CHUNKING: "bg-purple-500/20 text-purple-400",
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
STRUCTURING: "bg-orange-500/20 text-orange-400",
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
READY: "bg-green-500/20 text-green-400",
FAILED: "bg-red-500/20 text-red-400",
@@ -74,6 +77,8 @@ export default function KnowledgeDetailPage() {
const [showChunks, setShowChunks] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
const [structuring, setStructuring] = useState(false);
const [showStructured, setShowStructured] = useState(true);
const fetchItem = async () => {
try {
@@ -103,7 +108,7 @@ export default function KnowledgeDetailPage() {
// Poll while processing
useEffect(() => {
if (!item) return;
const processing = ["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
const processing = ["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
if (!processing) return;
const interval = setInterval(fetchItem, 3000);
return () => clearInterval(interval);
@@ -278,12 +283,13 @@ export default function KnowledgeDetailPage() {
</div>
)}
{["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING"].includes(item.STATUS) && (
{["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS) && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-blue-400">
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
{item.STATUS === "STRUCTURING" && "내용 정리 중..."}
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
@@ -291,6 +297,40 @@ export default function KnowledgeDetailPage() {
</div>
)}
{/* Structured Content */}
{item.STRUCTURED_CONTENT && (
<div className="mb-6">
<button
onClick={() => setShowStructured(!showStructured)}
className="text-sm text-[var(--color-primary)] hover:underline mb-3"
>
{showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"}
</button>
{showStructured && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] max-w-none">
<div className="structured-content 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}) => <p className="mb-3">{children}</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}) => <li className="leading-relaxed">{children}</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>,
code: ({children}) => <code className="bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded text-xs">{children}</code>,
}}
>
{item.STRUCTURED_CONTENT}
</ReactMarkdown>
</div>
</div>
)}
</div>
)}
{/* Chunks toggle */}
{item.STATUS === "READY" && (
<div className="mb-6">
@@ -331,6 +371,34 @@ export default function KnowledgeDetailPage() {
{/* Actions */}
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
{item.STATUS === "READY" && (
<>
<button
onClick={async () => {
setStructuring(true);
setShowStructured(true);
try {
// 비동기 요청: 서버가 STRUCTURING 상태로 변경 후 백그라운드 처리
// 폴링이 자동으로 진행 상태를 갱신함
await request({
method: "POST",
url: `/api/knowledge/${id}/structure`,
data: {},
});
// 완료 후 최종 상태 갱신
await fetchItem();
} catch (err) {
console.error("Failed to structure:", err);
alert("내용 정리에 실패했습니다.");
await fetchItem();
} finally {
setStructuring(false);
}
}}
disabled={structuring}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{structuring ? "정리 중..." : item.STRUCTURED_CONTENT ? "다시 정리하기" : "내용 정리하기"}
</button>
<button
onClick={async () => {
setGenerating(true);
@@ -352,6 +420,7 @@ export default function KnowledgeDetailPage() {
>
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
</button>
</>
)}
<button
onClick={handleDelete}

View File

@@ -13,11 +13,26 @@ interface KnowledgeItem {
SOURCE_URL: string;
STATUS: string;
CREATED_AT: string;
CATEGORIES?: string;
}
interface Category {
ID: string;
NAME: string;
PARENT_ID: string | null;
DEPTH: number;
FULL_PATH: string;
ITEM_COUNT: number;
}
interface CategoryTree extends Category {
children: CategoryTree[];
}
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-500/20 text-yellow-400",
EXTRACTING: "bg-blue-500/20 text-blue-400",
STRUCTURING: "bg-orange-500/20 text-orange-400",
CHUNKING: "bg-purple-500/20 text-purple-400",
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
@@ -31,10 +46,111 @@ const typeLabels: Record<string, string> = {
TEXT: "Text",
};
function buildTree(categories: Category[]): CategoryTree[] {
const map = new Map<string, CategoryTree>();
const roots: CategoryTree[] = [];
for (const cat of categories) {
map.set(cat.ID, { ...cat, children: [] });
}
for (const cat of categories) {
const node = map.get(cat.ID)!;
if (cat.PARENT_ID && map.has(cat.PARENT_ID)) {
map.get(cat.PARENT_ID)!.children.push(node);
} else {
roots.push(node);
}
}
// 항목이 0개인 카테고리 제거
function prune(nodes: CategoryTree[]): CategoryTree[] {
return nodes
.map((n) => ({ ...n, children: prune(n.children) }))
.filter((n) => n.ITEM_COUNT > 0 || n.children.length > 0);
}
return prune(roots);
}
// ITEM_COUNT는 백엔드에서 하위 카테고리 포함 고유 항목 수를 이미 계산함
function CategoryNode({
node,
selectedId,
onSelect,
expanded,
onToggle,
}: {
node: CategoryTree;
selectedId: string | null;
onSelect: (id: string, path: string) => void;
expanded: Set<string>;
onToggle: (id: string) => void;
}) {
const isExpanded = expanded.has(node.ID);
const isSelected = selectedId === node.ID;
const hasChildren = node.children.length > 0;
const totalCount = node.ITEM_COUNT;
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors ${
isSelected
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
style={{ paddingLeft: `${(node.DEPTH - 1) * 16 + 8}px` }}
onClick={() => {
onSelect(node.ID, node.FULL_PATH);
if (hasChildren) onToggle(node.ID);
}}
>
<span className="w-4 text-center text-xs">
{hasChildren ? (isExpanded ? "▼" : "▶") : "·"}
</span>
<span className="flex-1 truncate">{node.NAME}</span>
{totalCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">
{totalCount}
</span>
)}
</div>
{isExpanded && hasChildren && (
<div>
{node.children.map((child) => (
<CategoryNode
key={child.ID}
node={child}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
export default function KnowledgePage() {
const { request } = useApi();
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<"list" | "category">("category");
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 10;
// 카테고리 뷰 상태
const [categoryTree, setCategoryTree] = useState<CategoryTree[]>([]);
const [uncategorized, setUncategorized] = useState<KnowledgeItem[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>("__all__");
const [selectedCategoryPath, setSelectedCategoryPath] = useState<string>("전체");
const [categoryItems, setCategoryItems] = useState<KnowledgeItem[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [catLoading, setCatLoading] = useState(false);
const fetchItems = async () => {
try {
@@ -47,19 +163,117 @@ export default function KnowledgePage() {
}
};
const fetchCategories = async () => {
try {
const data = await request<{ categories: Category[]; uncategorized: KnowledgeItem[] }>({
method: "GET",
url: "/api/knowledge/categories",
});
const tree = buildTree(data.categories);
setCategoryTree(tree);
setUncategorized(data.uncategorized);
// 최상위 카테고리를 펼침
setExpanded(new Set(tree.map((n) => n.ID)));
} catch (err) {
console.error("Failed to load categories:", err);
}
};
const fetchCategoryItems = async (categoryId: string) => {
setCatLoading(true);
try {
const data = await request<KnowledgeItem[]>({
method: "GET",
url: `/api/knowledge/categories/${categoryId}/items`,
});
setCategoryItems(data);
} catch (err) {
console.error("Failed to load category items:", err);
} finally {
setCatLoading(false);
}
};
const handleSelectCategory = (id: string, path: string) => {
setSelectedCategoryId(id);
setSelectedCategoryPath(path);
setCurrentPage(1);
fetchCategoryItems(id);
};
const handleSelectUncategorized = () => {
setSelectedCategoryId("__uncategorized__");
setSelectedCategoryPath("미분류");
setCategoryItems(uncategorized as KnowledgeItem[]);
setCurrentPage(1);
};
const handleSelectAll = () => {
setSelectedCategoryId("__all__");
setSelectedCategoryPath("전체");
setCategoryItems(items);
setCurrentPage(1);
};
const handleToggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
useEffect(() => {
fetchItems();
// Poll for status updates every 5 seconds
fetchCategories();
const interval = setInterval(fetchItems, 5000);
return () => clearInterval(interval);
}, []);
// 카테고리 뷰에서 표시할 항목 결정
const allDisplayItems =
viewMode === "list"
? items
: selectedCategoryId === "__all__"
? items
: selectedCategoryId === "__uncategorized__"
? uncategorized
: categoryItems;
const totalPages = Math.max(1, Math.ceil(allDisplayItems.length / PAGE_SIZE));
const displayItems = allDisplayItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Knowledge</h1>
<div className="flex gap-1 bg-[var(--color-bg-card)] rounded-lg p-0.5 border border-[var(--color-border)]">
<button
onClick={() => setViewMode("list")}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
viewMode === "list"
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-white"
}`}
>
</button>
<button
onClick={() => { setViewMode("category"); fetchCategories(); }}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
viewMode === "category"
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-white"
}`}
>
</button>
</div>
</div>
<Link
href="/knowledge/add"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
@@ -70,15 +284,113 @@ export default function KnowledgePage() {
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : items.length === 0 ? (
) : viewMode === "list" ? (
/* 목록 뷰 */
items.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)]">No knowledge items yet. Add your first item to get started.</p>
</div>
) : (
<>
<div className="space-y-3">
{items.map((item) => (
{displayItems.map((item) => (
<KnowledgeItemCard key={item.ID} item={item} />
))}
</div>
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
</>
)
) : (
/* 카테고리 뷰 — 2단 레이아웃 */
<div className="flex gap-4" style={{ minHeight: "60vh" }}>
{/* 왼쪽: 카테고리 트리 */}
<div className="w-64 shrink-0 bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] p-3 overflow-y-auto" style={{ maxHeight: "75vh" }}>
{/* 전체 보기 */}
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mb-1 ${
selectedCategoryId === "__all__"
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={handleSelectAll}
>
<span className="w-4 text-center text-xs">*</span>
<span className="flex-1"></span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{items.length}</span>
</div>
{/* 카테고리 트리 */}
{categoryTree.map((node) => (
<CategoryNode
key={node.ID}
node={node}
selectedId={selectedCategoryId}
onSelect={handleSelectCategory}
expanded={expanded}
onToggle={handleToggle}
/>
))}
{/* 미분류 */}
{uncategorized.length > 0 && (
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mt-2 ${
selectedCategoryId === "__uncategorized__"
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={handleSelectUncategorized}
>
<span className="w-4 text-center text-xs">?</span>
<span className="flex-1"></span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{uncategorized.length}</span>
</div>
)}
</div>
{/* 오른쪽: 선택된 카테고리의 항목 목록 */}
<div className="flex-1 min-w-0">
{/* 브레드크럼 */}
<div className="text-sm text-[var(--color-text-muted)] mb-3">
{selectedCategoryPath.split("/").map((part, i, arr) => (
<span key={i}>
{i > 0 && <span className="mx-1">{">"}</span>}
<span className={i === arr.length - 1 ? "text-white" : ""}>{part}</span>
</span>
))}
</div>
{catLoading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : (displayItems as KnowledgeItem[]).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)]">
{selectedCategoryId === "__all__"
? "등록된 지식이 없습니다."
: "이 카테고리에 항목이 없습니다."}
</p>
</div>
) : (
<>
<div className="space-y-3">
{(displayItems as KnowledgeItem[]).map((item) => (
<KnowledgeItemCard key={item.ID} item={item} />
))}
</div>
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
</>
)}
</div>
</div>
)}
</main>
</AuthGuard>
);
}
function KnowledgeItemCard({ item }: { item: KnowledgeItem }) {
return (
<Link
key={item.ID}
href={`/knowledge/${item.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
@@ -95,8 +407,14 @@ export default function KnowledgePage() {
<h3 className="font-medium truncate">
{item.TITLE || item.SOURCE_URL || "Untitled"}
</h3>
{item.SOURCE_URL && (
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
{item.CATEGORIES && (
<div className="flex flex-wrap gap-1 mt-1">
{item.CATEGORIES.split(", ").map((cat, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{cat}
</span>
))}
</div>
)}
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
@@ -104,10 +422,54 @@ export default function KnowledgePage() {
</span>
</div>
</Link>
))}
</div>
)}
</main>
</AuthGuard>
);
}
function Pagination({ currentPage, totalPages, onPageChange }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) {
const pages: number[] = [];
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, currentPage + 2);
for (let i = start; i <= end; i++) pages.push(i);
return (
<div className="flex items-center justify-center gap-1 mt-6">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
>
</button>
{start > 1 && (
<>
<button onClick={() => onPageChange(1)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">1</button>
{start > 2 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
</>
)}
{pages.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`px-3 py-1.5 text-sm rounded-lg ${
p === currentPage ? "bg-[var(--color-primary)] text-white" : "hover:bg-[var(--color-bg-hover)]"
}`}
>
{p}
</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
<button onClick={() => onPageChange(totalPages)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">{totalPages}</button>
</>
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface UserProfile {
ID: string;
EMAIL: string;
DISPLAY_NAME: string;
AVATAR_URL: string;
ENGLISH_LEVEL: string;
}
const ENGLISH_LEVELS = [
{ value: "A1", label: "A1 - 입문 (TOEIC 120~225)" },
{ value: "A2", label: "A2 - 초급 (TOEIC 225~550, 기초 회화)" },
{ value: "B1", label: "B1 - 중급 (TOEIC 550~785, 일상 의사소통)" },
{ value: "B2", label: "B2 - 중상급 (TOEIC 785~945, 업무 영어)" },
{ value: "C1", label: "C1 - 고급 (TOEIC 945~990, 유창함)" },
{ value: "C2", label: "C2 - 원어민 수준" },
];
export default function SettingsPage() {
const { request } = useApi();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState("");
useEffect(() => {
(async () => {
try {
const data = await request<UserProfile>({ method: "GET", url: "/api/users/me" });
setProfile(data);
} catch (err) {
console.error("Failed to load profile:", err);
} finally {
setLoading(false);
}
})();
}, []);
const handleEnglishLevelChange = async (newLevel: string) => {
if (!profile) return;
setSaving(true);
setSavedMsg("");
try {
const updated = await request<UserProfile>({
method: "PATCH",
url: "/api/users/me",
data: { englishLevel: newLevel },
});
setProfile(updated);
setSavedMsg("저장되었습니다");
setTimeout(() => setSavedMsg(""), 2000);
} catch (err) {
console.error("Failed to update level:", err);
alert("저장에 실패했습니다");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AuthGuard>
<NavBar />
<main className="max-w-2xl mx-auto px-4 py-8">
<p className="text-[var(--color-text-muted)]">Loading...</p>
</main>
</AuthGuard>
);
}
return (
<AuthGuard>
<NavBar />
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6"></h1>
{/* 프로필 정보 */}
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-3 text-sm">
<div className="flex">
<span className="w-24 text-[var(--color-text-muted)]"></span>
<span>{profile?.DISPLAY_NAME || "-"}</span>
</div>
<div className="flex">
<span className="w-24 text-[var(--color-text-muted)]"></span>
<span>{profile?.EMAIL || "-"}</span>
</div>
</div>
</section>
{/* 영어 학습 수준 */}
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-2"> </h2>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
.
</p>
<select
value={profile?.ENGLISH_LEVEL || "B2"}
onChange={(e) => handleEnglishLevelChange(e.target.value)}
disabled={saving}
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 disabled:opacity-50"
>
{ENGLISH_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
{savedMsg && (
<p className="text-sm text-green-400 mt-2">{savedMsg}</p>
)}
</section>
</main>
</AuthGuard>
);
}

View File

@@ -11,6 +11,7 @@ const navItems = [
{ href: "/study", label: "Study" },
{ href: "/todos", label: "Todos" },
{ href: "/habits", label: "Habits" },
{ href: "/settings", label: "Settings" },
];
export default function NavBar() {

View File

@@ -68,9 +68,23 @@ api.interceptors.response.use(
isRefreshing = true;
originalRequest._retry = true;
const attemptRefresh = async (retryCount: number): Promise<string> => {
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
const newToken = res.data.accessToken;
return res.data.accessToken;
} catch (err) {
const isNetworkError = !((err as AxiosError).response);
if (isNetworkError && retryCount < 2) {
// 네트워크 에러(서버 재시작 등)면 3초 후 재시도
await new Promise((r) => setTimeout(r, 3000));
return attemptRefresh(retryCount + 1);
}
throw err;
}
};
try {
const newToken = await attemptRefresh(0);
api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`;
onTokenRefreshed?.(newToken);

View File

@@ -22,21 +22,40 @@ const AuthContext = createContext<AuthContextType>({
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [accessToken, setAccessTokenState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logoutRef = useRef<() => void>(() => {});
// localStorage와 동기화하는 setter
const setAccessToken = useCallback((token: string | null) => {
setAccessTokenState(token);
if (token) {
localStorage.setItem("accessToken", token);
} else {
localStorage.removeItem("accessToken");
}
}, []);
// interceptor 콜백 등록
useEffect(() => {
setAuthCallbacks(
(token: string) => setAccessToken(token),
() => logoutRef.current()
);
}, []);
}, [setAccessToken]);
useEffect(() => {
// Try to restore session from refresh token cookie
const tryRefresh = async () => {
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;
@@ -48,8 +67,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsLoading(false);
}
};
tryRefresh();
}, []);
restoreSession();
}, [setAccessToken]);
useEffect(() => {
if (accessToken) {
@@ -61,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback((response: LoginResponse) => {
setAccessToken(response.accessToken);
}, []);
}, [setAccessToken]);
const logout = useCallback(async () => {
try {
@@ -71,7 +90,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
setAccessToken(null);
window.location.href = "/login";
}, []);
}, [setAccessToken]);
// ref로 최신 logout 유지 (interceptor에서 사용)
useEffect(() => {