diff --git a/.github/workflows/build-with-vault-refresh.yml b/.github/workflows/build-with-vault-refresh.yml
index 9a4d5c6..96de25b 100644
--- a/.github/workflows/build-with-vault-refresh.yml
+++ b/.github/workflows/build-with-vault-refresh.yml
@@ -2,10 +2,20 @@ name: Build With Vault Refresh
on:
workflow_dispatch:
+ inputs:
+ vault_changed_files:
+ description: "바뀐 vault 경로(쉼표 구분). Watch OneDrive 가 채운다. 비우면 강제 리프레시"
+ type: string
+ default: ""
push:
branches:
- dev
+# 생성물을 같은 OneDrive 경로에 쓰므로 두 실행이 겹치지 않게 한다.
+concurrency:
+ group: build-with-vault-refresh-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
build:
runs-on: ubuntu-latest
@@ -24,17 +34,25 @@ jobs:
- name: Install dependencies
run: npm ci
- # vault 콘텐츠의 정본은 개인 OneDrive 의 hsol-info-blob 폴더다(2026-09-27 hsol-info-blob 저장소 폐기).
- # sync-onedrive-to-blob.yml 이 올린 Blob info/vault 를 hsol-info-blob/vault 로 내려받아 기존 스크립트 경로를 그대로 쓴다.
- # 내려받은 직후의 생성물 해시를 적어 두고, 아래 업로드 스텝이 실제로 바뀐 것만 Blob 에 되올린다.
- - name: Pull vault from Blob
+ - name: Install rclone
+ run: |
+ curl -fsSL https://rclone.org/install.sh | sudo bash
+ rclone version | head -1
+
+ # 콘텐츠의 정본이자 유일한 저장소는 개인 OneDrive 의 hsol-info-blob 폴더다(2026-09-27 부터).
+ # vault 를 hsol-info-blob/vault 로 받아 기존 스크립트 경로를 그대로 쓴다. 사이트 생성기가 읽지 않는 경로는 받지 않는다.
+ # 받은 직후의 생성물 해시를 적어 두고, 뒤의 게시 스텝이 실제로 바뀐 것만 OneDrive 에 되쓴다.
+ - name: Pull vault from OneDrive
env:
- BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
- # 사이트 생성기가 읽지 않는 경로는 받지 않는다(예전 Blob 에 남은 datasources 등).
- BLOB_PULL_EXCLUDE: "datasources/,.obsidian/,Templates/,Attachments/"
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }}
+ ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }}
run: |
set -euo pipefail
- node ops/scripts/pull-blob-to-vault.mjs --vault-dir hsol-info-blob/vault --clean
+ node ops/scripts/with-onedrive.mjs -- rclone copy od:hsol-info-blob/vault hsol-info-blob/vault \
+ --exclude "datasources/**" --exclude ".obsidian/**" --exclude "Templates/**" --exclude "Attachments/**" \
+ --exclude "*.base" --exclude ".DS_Store" --transfers 16 --checkers 16 --stats-one-line --stats 30s
+ echo "받은 파일: $(find hsol-info-blob/vault -type f | wc -l)개"
mkdir -p generated
for f in vault/object-views/site-data.json vault/object-views/onepager-ko.html vault/object-views/onepager-en.html; do
if [ -f "hsol-info-blob/$f" ]; then echo "$(sha1sum "hsol-info-blob/$f" | cut -d' ' -f1) $f"; fi
@@ -49,8 +67,10 @@ jobs:
ANTHROPIC_MAX_TOKENS: "64000"
GIT_DIFF_BASE_SHA: ${{ github.event.before }}
GIT_DIFF_HEAD_SHA: ${{ github.sha }}
- # vault(서브모듈) 수정이 있을 때만 자동 실행. 수동 실행(workflow_dispatch)은 명시적 의도라 강제.
- CONTENT_REFRESH_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
+ # vault 원본(OneDrive)이 바뀌면 Watch OneDrive 가 바뀐 경로를 넘겨 디스패치한다 -> 그 목록으로 변경 판단.
+ # 코드 push 에서는 목록이 없어 스킵. 목록 없이 수동 실행하면 명시적 의도로 보고 강제.
+ VAULT_CHANGED_FILES: ${{ inputs.vault_changed_files }}
+ CONTENT_REFRESH_FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.vault_changed_files == '' && '1' || '' }}
# 빌드 로그(build_log 테이블) 적층용. 없으면 로그 적층만 건너뛰고 리프레시는 정상.
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# 파이프라인 종류별 LLM 동작 토글(contentPipeline). 없으면 코드 기본값으로 폴백(빌드 정상).
@@ -73,50 +93,13 @@ jobs:
AI_GATEWAY_MODEL: anthropic/claude-opus-4.7
run: npm run content:translate:onepager
- # 게이트를 여기서 한 번 더 돌린다. prebuild(Build 스텝)에서도 돌지만, 그건 아래 blob push 와
- # PDF 업로드가 끝난 뒤라 위반이 이미 배포된 뒤에야 빌드를 세운다 — PDF 는 Blob 에서 바로
+ # 게이트를 여기서 한 번 더 돌린다. prebuild(Build 스텝)에서도 돌지만, 그건 아래 OneDrive 게시와
+ # PDF 업로드가 끝난 뒤라 위반이 이미 배포된 뒤에야 빌드를 세운다 — PDF 는 OneDrive 에서 바로
# 내려가므로 빌드 실패와 무관하게 살아 있다(PRF-149). 재생성 직후·발행 직전이 유일하게
# 유효한 지점이다.
- name: Gate regenerated one-pager (self-designation)
run: npm run check:self-designation
- # 생성물(site-data, 원페이저)은 Blob 이 정본이다. 바뀐 것만 info/vault 로 올린다.
- # OneDrive 로의 역미러는 hsol-info-blob 동기화 워크플로가 다음 회차에 한다.
- - name: Upload regenerated vault outputs to Blob
- env:
- BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
- run: |
- set -euo pipefail
- node --input-type=module -e '
- import { readFileSync, existsSync } from "node:fs";
- import { put } from "@vercel/blob";
- const token = process.env.BLOB_READ_WRITE_TOKEN;
- const pulled = new Map(
- (existsSync("generated/pulled-generated.sha1") ? readFileSync("generated/pulled-generated.sha1", "utf8") : "")
- .split("\n").filter(Boolean).map((l) => { const [h, f] = l.split(" "); return [f, h]; }),
- );
- const { createHash } = await import("node:crypto");
- const files = ["vault/object-views/site-data.json", "vault/object-views/onepager-ko.html", "vault/object-views/onepager-en.html"];
- const types = { json: "application/json", html: "text/html; charset=utf-8" };
- const changed = [];
- for (const f of files) {
- const p = `hsol-info-blob/${f}`;
- if (!existsSync(p)) continue;
- const buf = readFileSync(p);
- const h = createHash("sha1").update(buf).digest("hex");
- if (pulled.get(f) === h) continue;
- await put(`info/${f}`, buf, { access: "private", addRandomSuffix: false, allowOverwrite: true, contentType: types[f.split(".").pop()], token });
- changed.push(f);
- console.log(`uploaded info/${f}`);
- }
- if (changed.length) {
- const ref = { parentSha: process.env.GITHUB_SHA, parentRef: process.env.GITHUB_REF, workflowRunId: Number(process.env.GITHUB_RUN_ID), recordedAt: new Date().toISOString(), changed };
- await put("info/vault/object-views/hsol-info-parent-ref.json", JSON.stringify(ref, null, 2), { access: "private", addRandomSuffix: false, allowOverwrite: true, contentType: "application/json", token });
- } else {
- console.log("No regenerated vault outputs; skip upload.");
- }
- '
-
- name: Upload content refresh failure dumps
if: failure()
uses: actions/upload-artifact@v4
@@ -128,12 +111,17 @@ jobs:
- name: Generate site.ts from vault
run: npm run content:generate:site-ts
- - name: Generate one-pager PDF (html -> pdf -> Blob)
- env:
- # 없으면 로컬 PDF만 만들고 업로드는 graceful skip(빌드 실패 안 함).
- BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
+ - name: Generate one-pager PDF (html -> pdf)
run: npm run content:generate:onepager-pdf
+ # 사이트는 생성물을 OneDrive 에서 바로 읽는다. 바뀐 것만 vault/object-views 에 쓴다.
+ - name: Publish generated outputs to OneDrive
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }}
+ ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }}
+ run: node ops/scripts/publish-generated.mjs
+
- name: Lint
run: npm run lint
diff --git a/.github/workflows/sync-onedrive-to-blob.yml b/.github/workflows/sync-onedrive-to-blob.yml
deleted file mode 100644
index a5a69b5..0000000
--- a/.github/workflows/sync-onedrive-to-blob.yml
+++ /dev/null
@@ -1,77 +0,0 @@
-# hsol.info 콘텐츠의 정본은 개인 OneDrive 의 hsol-info-blob 폴더다. 이 워크플로가 OneDrive -> Vercel Blob 으로 올린다.
-# vault : OneDrive hsol-info-blob/vault/ -> Blob info/vault/ (site-data, 원페이저, Ask Hansol)
-# assets : OneDrive hsol-info-blob/assets/ -> Blob info/assets/ (/files/<키>)
-# 기사 : vault/objects/news-articles 가 바뀌면 Neon articles 미러 갱신 + dbId 를 OneDrive 원본에 역기록
-# Build With Vault Refresh 가 Blob 에 올린 생성물(site-data.json, onepager-*.html)은 OneDrive 로 역미러한다.
-# 2026-09-27 hsol-info-blob 저장소 폐기와 함께 이 저장소로 옮겼다. 코드: ops/scripts/sync-onedrive-to-blob.mjs
-#
-# Repository secrets
-# BLOB_READ_WRITE_TOKEN Blob 읽기/쓰기
-# ONEDRIVE_TOKEN_KEY OneDrive 토큰 암호화 키(32바이트 base64). 잃으면 재인증 필요
-# ONEDRIVE_DRIVE_ID 개인 OneDrive drive id
-# DATABASE_URL Neon (기사 미러)
-# ONEDRIVE_BOOTSTRAP_TOKEN (선택) 재인증할 때만. rclone authorize onedrive 출력 JSON
-
-name: Sync OneDrive to Vercel Blob
-
-on:
- schedule:
- # 매시 17분(UTC). 리프레시 토큰이 계속 갱신되도록 주기 실행이 필요하다.
- - cron: "17 * * * *"
- workflow_dispatch:
- inputs:
- dry_run:
- description: "업로드/삭제/역기록 없이 차이만 출력"
- type: boolean
- default: false
- tracks:
- description: "동기화할 트랙 (assets,vault)"
- type: string
- default: "assets,vault"
- force_articles:
- description: "변경이 없어도 기사 DB 동기화 실행"
- type: boolean
- default: false
- maint_op:
- description: "정비 작업(stats). 지정하면 동기화 대신 실행"
- type: string
- default: ""
-
-# 토큰이 실행마다 회전하므로 두 실행이 겹치면 한쪽 토큰이 버려진다. 반드시 직렬.
-concurrency:
- group: sync-onedrive-to-blob
- cancel-in-progress: false
-
-jobs:
- sync:
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
-
- - name: Install dependencies
- run: npm ci --ignore-scripts --no-audit --no-fund
-
- - name: Install rclone
- run: |
- curl -fsSL https://rclone.org/install.sh | sudo bash
- rclone version | head -1
-
- - name: Sync OneDrive -> Blob
- env:
- BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
- ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }}
- ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }}
- ONEDRIVE_BOOTSTRAP_TOKEN: ${{ secrets.ONEDRIVE_BOOTSTRAP_TOKEN }}
- DATABASE_URL: ${{ secrets.DATABASE_URL }}
- SYNC_TRACKS: ${{ inputs.tracks || 'assets,vault' }}
- FORCE_ARTICLES: ${{ inputs.force_articles && '1' || '' }}
- MAINT_OP: ${{ inputs.maint_op }}
- run: node ops/scripts/sync-onedrive-to-blob.mjs ${{ inputs.dry_run && '--dry-run' || '' }}
diff --git a/.github/workflows/watch-onedrive.yml b/.github/workflows/watch-onedrive.yml
new file mode 100644
index 0000000..98c101b
--- /dev/null
+++ b/.github/workflows/watch-onedrive.yml
@@ -0,0 +1,77 @@
+# hsol.info 콘텐츠의 정본이자 유일한 저장소는 개인 OneDrive 의 hsol-info-blob 폴더다. 사이트는 OneDrive 를 직접 읽는다.
+# 이 워크플로는 vault 변경을 감시해 뒤따르는 일을 건다. 코드: ops/scripts/watch-onedrive.mjs
+# 기사 : vault/objects/news-articles 가 바뀌면 Neon articles 미러 갱신 + dbId 를 OneDrive 원본에 역기록
+# 빌드 : vault 가 바뀌면 Build With Vault Refresh 를 바뀐 경로 목록과 함께 디스패치(site-data, 원페이저 재생성 판단)
+# 매시 실행이 OneDrive 리프레시 토큰도 살려 둔다(사이트 런타임도 필요할 때 갱신한다).
+#
+# Repository secrets
+# DATABASE_URL Neon (토큰, 변경 기준선, 기사 미러)
+# ONEDRIVE_TOKEN_KEY OneDrive 토큰 암호화 키(32바이트 base64). Vercel env 와 같은 값. 잃으면 재인증 필요
+# ONEDRIVE_DRIVE_ID 개인 OneDrive drive id
+# ONEDRIVE_BOOTSTRAP_TOKEN (선택) 재인증할 때만. rclone authorize onedrive 출력 JSON
+
+name: Watch OneDrive
+
+on:
+ schedule:
+ - cron: "17 * * * *"
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "기록, 역기록, 디스패치 없이 차이만 출력"
+ type: boolean
+ default: false
+ force_articles:
+ description: "변경이 없어도 기사 DB 동기화 실행"
+ type: boolean
+ default: false
+
+# 기준선과 토큰을 같이 쓰므로 직렬로 돈다.
+concurrency:
+ group: watch-onedrive
+ cancel-in-progress: false
+
+jobs:
+ watch:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ # vault 가 바뀌면 Build With Vault Refresh 를 디스패치한다(GITHUB_TOKEN 의 workflow_dispatch 는 새 실행을 만든다).
+ actions: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts --no-audit --no-fund
+
+ - name: Install rclone
+ run: |
+ curl -fsSL https://rclone.org/install.sh | sudo bash
+ rclone version | head -1
+
+ - name: Watch OneDrive vault
+ id: watch
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }}
+ ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }}
+ ONEDRIVE_BOOTSTRAP_TOKEN: ${{ secrets.ONEDRIVE_BOOTSTRAP_TOKEN }}
+ FORCE_ARTICLES: ${{ inputs.force_articles && '1' || '' }}
+ run: node ops/scripts/watch-onedrive.mjs ${{ inputs.dry_run && '--dry-run' || '' }}
+
+ # 예전 서브모듈 포인터 이동을 대신하는 신호. vault 원본이 바뀐 회차에만 사이트 생성물 재생성을 건다.
+ - name: Dispatch site refresh when vault changed
+ if: ${{ steps.watch.outputs.vault_changed_files != '' && !inputs.dry_run }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ FILES: ${{ steps.watch.outputs.vault_changed_files }}
+ run: |
+ set -euo pipefail
+ echo "vault 변경 파일: $(echo "$FILES" | tr ',' '\n' | wc -l)개 -> Build With Vault Refresh 디스패치"
+ gh workflow run build-with-vault-refresh.yml -R "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME" -f vault_changed_files="$FILES"
diff --git a/AGENTS.md b/AGENTS.md
index 1140a95..df4617e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,11 +23,10 @@ vault 에 반영한다. 상세 트리거·절차·예외는 `hsol-info-blob/CLAU
- 이 저장소의 Vercel 배포 대상 프로젝트는 팀 `hsol`의 `hsol-info`이다(`vercel link --scope hsol --project hsol-info`로 맞출 수 있다). Web Analytics를 쓰려면 해당 프로젝트에서 기능을 활성화하고 루트 레이아웃에 `@vercel/analytics/next`의 ``를 둔다([시작 가이드](https://vercel.com/docs/analytics/quickstart?framework=nextjs)). Google Analytics 4(gtag)는 `NEXT_PUBLIC_GA_MEASUREMENT_ID`가 비어 있지 않을 때만 스크립트와 클라이언트 `page_path` 갱신을 넣고, 없으면 넣지 않는다.
- `vercel env pull`은 기본이 development 환경이라, Production·Preview에만 있는 변수는 `.env.local`에 포함되지 않을 수 있다.
- 이 저장소는 Vercel 서버 렌더로 배포한다. `next.config.ts` 에 `output: "export"` 가 없고 `force-dynamic` 라우트(`/resume`, `/api/*`, `/manage/*` 등)가 여럿이라, 정적보내기 전제(산출물 `out/`, `app/api/*` 미배포로 `/api/*` 404, `force-dynamic` 금지, `vercel.json` 의 `outputDirectory` 중복 주의)는 지금 이 저장소에 적용되지 않는다. 다시 정적 export 로 돌린다면 그 넷을 함께 되짚어야 한다.
-- `getSiteData()`는 Blob -> `hsol-info-blob/vault/object-views/site-data.json` -> 커밋된 `src/data/site.ts`의 `HSOL_DATA` 순으로 폴백한다.
-- 2026-09-27 부터 vault 콘텐츠의 정본은 개인 OneDrive 의 `hsol-info-blob` 폴더다(`/Users/hsol/OneDrive/hsol-info-blob`). GitHub `hsol-info-blob` 저장소와 서브모듈은 폐기했다. 로컬의 `hsol-info-blob/` 는 git 이 추적하지 않는 링크 폴더로, `vault` 등은 OneDrive 를, `scripts`, `newsletter-ops`, `ask-hansol-seeding` 은 이 저장소의 `ops/` 를 가리킨다. **OneDrive -> Blob 업로드는 오직** 이 저장소의 `sync-onedrive-to-blob.yml`(매시, `ops/scripts/sync-onedrive-to-blob.mjs`)이 한다. 뉴스룸 기사 Neon 미러와 dbId 역기록도 같은 워크플로가 한다. `build-with-vault-refresh.yml`은 Blob `info/vault` 를 `hsol-info-blob/vault` 로 내려받아(`ops/scripts/pull-blob-to-vault.mjs`) 쓰고, 바뀐 생성물(`site-data.json`, `onepager-*.html`)과 부모 커밋 기록(`hsol-info-parent-ref.json`)은 Blob 에 직접 올린다. OneDrive 로의 역미러는 다음 동기화 회차가 한다. vault 변경만으로는 refresh 가 자동 실행되지 않으니 수동 실행(workflow_dispatch)으로 돌린다. `content:refresh:claude` 실패 시 `generated/content-refresh-failures/`를 아티팩트로 올린다.
+- `getSiteData()`는 OneDrive(`vault/object-views/site-data.json`, Graph) -> 로컬 `hsol-info-blob/vault/object-views/site-data.json` -> 커밋된 `src/data/site.ts`의 `HSOL_DATA` 순으로 폴백한다.
+- 2026-09-27 부터 콘텐츠의 정본이자 유일한 저장소는 개인 OneDrive 의 `hsol-info-blob` 폴더다(`/Users/hsol/OneDrive/hsol-info-blob`). GitHub `hsol-info-blob` 저장소, 서브모듈, Vercel Blob 은 쓰지 않는다. 로컬의 `hsol-info-blob/` 는 git 이 추적하지 않는 링크 폴더로, `vault` 등은 OneDrive 를, `scripts`, `newsletter-ops`, `ask-hansol-seeding` 은 이 저장소의 `ops/` 를 가리킨다. 사이트 런타임은 `src/lib/onedrive/graph.ts` 로 OneDrive 를 직접 읽는다(site-data, 원페이저, 이력서 PDF, Ask Hansol 문맥, `/files/<키>` = `assets/<키>`). 토큰은 Neon `onedrive_token` 에 암호화해 두고 런타임과 CI 가 같이 갱신한다(`ops/scripts/lib/onedrive-token.mjs`). `watch-onedrive.yml`(매시, `ops/scripts/watch-onedrive.mjs`)이 vault 변경을 감지해 뉴스룸 기사 Neon 미러와 dbId 역기록을 하고, 바뀐 경로 목록과 함께 `build-with-vault-refresh.yml` 을 디스패치한다. 빌드는 rclone 으로 vault 를 `hsol-info-blob/vault` 에 받아 쓰고(`ops/scripts/with-onedrive.mjs`), 바뀐 생성물(`site-data.json`, `onepager-*.html`, `onepager-*.pdf`, `hsol-info-parent-ref.json`)을 OneDrive `vault/object-views/` 에 직접 쓴다(`ops/scripts/publish-generated.mjs`). `content:refresh:claude` 실패 시 `generated/content-refresh-failures/`를 아티팩트로 올린다.
- `scripts/refresh-site-data-with-claude.ts`는 `ANTHROPIC_MAX_TOKENS`(기본 64000)로 출력 상한을 두며, 응답이 `stop_reason=max_tokens`로 잘리면 파싱하지 않고 실패·덤프한다. 실패 본문은 기본적으로 `generated/content-refresh-failures/`에 쓴다. `tool_use` 페이로드에서 문자열로 감긴 JSON·루트 래핑(`data`/`siteData` 등)·모델이 자주 내는 대체 키 형태는 정규화·매핑 후 스키마 검증을 다시 시도한다.
-- Blob->vault 풀 스크립트(`ops/scripts/pull-blob-to-vault.mjs`)는 `.DS_Store`(및 `.DS-Store` 파일명)를 내려받지 않고 로컬 정리 시에도 제외한다.
-- Ask Hansol API(`src/app/api/ask-hansol/route.ts`)는 시스템 프롬프트에 `vault/README.md`(vault 탐색·읽기 절차용 지침이며 답변 사실을 채우는 발췌 문서가 아님)와 `vault/object-views/AI-클론-운영-매뉴얼.md`를 넣고, Blob 토큰은 `ASK_HANSOL_BLOB_TOKEN`, `BLOB_READ_WRITE_TOKEN`, `BLOB_READ_TOKEN` 순으로 쓴다. Claude는 `blob_lookup`으로 본문 근거 문서를 추가 조회한다.
+- Ask Hansol API(`src/app/api/ask-hansol/route.ts`)는 시스템 프롬프트에 `vault/README.md`(vault 탐색·읽기 절차용 지침이며 답변 사실을 채우는 발췌 문서가 아님)와 `vault/object-views/AI-클론-운영-매뉴얼.md`를 넣는다. vault 원본은 개인 OneDrive `hsol-info-blob/vault/` 이고 `src/lib/onedrive/graph.ts` 가 Graph 로 읽는다(토큰은 Neon `onedrive_token`). Claude는 `vault_lookup`으로 본문 근거 문서를 추가 조회한다.
- Ask Hansol API가 서버 함수로 실제 배포되는 환경에서는 세션 히스토리 GET이 DB를 읽으므로 `export const dynamic = "force-dynamic"`으로 두고, 클라이언트 히스토리·질문 `fetch`에는 `cache: "no-store"`를 쓴다(`force-static`이면 GET이 빌드·CDN에 고정되어 대화 목록이 비어 보일 수 있다). 순수 정적 export만 쓰는 빌드와는 타깃이 다를 수 있다.
- Ask Hansol 답변 URL 처리는 `src/lib/ask-hansol/answer-linkify.ts`에서 마크다운·괄호 등을 평문으로 정리한 뒤 클라이언트에서 분리 렌더하며, `https`/`http`뿐 아니라 `www.` 접두·스킴 없는 호스트 형태·`mailto:` 등도 링크로 인식한다.
- Preview Deployment Protection 환경에서 `ask-hansol-selection`이 `ask-hansol`로 서버-서버 재호출할 때는 원 요청의 `cookie`/`authorization`을 전달해야 내부 호출 401을 피할 수 있다.
diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md
index 07aaaef..a9c0598 100644
--- a/docs/architecture-diagram.md
+++ b/docs/architecture-diagram.md
@@ -35,7 +35,7 @@
2. **선별** — 후보를 "방문자에게 닿는 데이터 흐름이 바뀌었나?" 기준으로 거른다.
애매하면 뺀다(도식이 커지는 것 자체가 비용).
3. **수정** — 아래 스타일 규칙을 지키며 Mermaid를 고친다. 기존 서브그래프 골격
- (온톨로지 vault → CI/Blob → 외부 인프라 → 서버 → 클라이언트 → 방문자)은 유지하고,
+ (온톨로지 vault → CI/OneDrive → 외부 인프라 → 서버 → 클라이언트 → 방문자)은 유지하고,
새 영역이 필요할 때만 서브그래프를 추가한다.
4. **검증** — dev 서버에서 `/architecture`를 열어 실제 렌더를 확인한다.
Mermaid는 문법 오류 시 조용히 빈 화면이 되므로 **스크린샷까지 확인**한다
diff --git a/ops/scripts/lib/onedrive-token.mjs b/ops/scripts/lib/onedrive-token.mjs
new file mode 100644
index 0000000..965567e
--- /dev/null
+++ b/ops/scripts/lib/onedrive-token.mjs
@@ -0,0 +1,93 @@
+/**
+ * 개인 OneDrive(Microsoft Graph) 토큰 저장소. Neon `onedrive_token` 테이블에 AES-256-GCM 으로 암호화해 둔다.
+ *
+ * 토큰은 rclone 형식 JSON({access_token, token_type, refresh_token, expiry}) 그대로다.
+ * CI(rclone)와 사이트 런타임(src/lib/onedrive/token.ts)이 같은 행을 읽고 쓴다. 리프레시 토큰이 쓸 때마다
+ * 새로 나오므로, 쓰는 쪽은 항상 최신 토큰을 되써야 한다. 앱은 rclone 기본 앱(client id 는 공개값)이다.
+ *
+ * 환경변수
+ * DATABASE_URL (또는 POSTGRES_URL) Neon
+ * ONEDRIVE_TOKEN_KEY 32바이트 base64. 잃으면 재인증(rclone authorize onedrive)
+ * ONEDRIVE_BOOTSTRAP_TOKEN (선택) 저장된 토큰이 없을 때만 쓰는 rclone authorize 출력 JSON
+ */
+import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
+import { neon } from "@neondatabase/serverless";
+
+export const TOKEN_ROW_ID = "personal";
+
+export function getSql() {
+ const url = (process.env.DATABASE_URL || process.env.POSTGRES_URL || "").trim();
+ if (!url) throw new Error("DATABASE_URL 이 없다.");
+ return neon(url);
+}
+
+export function keyFrom(b64, name = "ONEDRIVE_TOKEN_KEY") {
+ if (!b64) throw new Error(`${name} 이 없다.`);
+ const key = Buffer.from(b64, "base64");
+ if (key.length !== 32) throw new Error(`${name} 는 base64 로 인코딩한 32바이트여야 한다.`);
+ return key;
+}
+
+export function encrypt(key, plaintext) {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
+ return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64");
+}
+
+export function decrypt(key, payloadB64) {
+ const buf = Buffer.from(payloadB64, "base64");
+ const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
+ decipher.setAuthTag(buf.subarray(12, 28));
+ return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8");
+}
+
+export async function ensureTables(sql) {
+ await sql`CREATE TABLE IF NOT EXISTS onedrive_token (
+ id text PRIMARY KEY,
+ payload text NOT NULL,
+ expires_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )`;
+ await sql`CREATE TABLE IF NOT EXISTS onedrive_sync_state (
+ key text PRIMARY KEY,
+ value jsonb NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )`;
+}
+
+/** 저장된 토큰(rclone JSON 문자열). 없으면 ONEDRIVE_BOOTSTRAP_TOKEN, 그것도 없으면 에러. */
+export async function loadToken(sql, key) {
+ const rows = await sql`SELECT payload FROM onedrive_token WHERE id = ${TOKEN_ROW_ID}`;
+ if (rows[0]) return decrypt(key, rows[0].payload);
+ const boot = process.env.ONEDRIVE_BOOTSTRAP_TOKEN?.trim();
+ if (boot) {
+ console.log("토큰: 저장된 토큰이 없어 ONEDRIVE_BOOTSTRAP_TOKEN 사용");
+ return boot;
+ }
+ throw new Error("OneDrive 토큰이 없다. ONEDRIVE_BOOTSTRAP_TOKEN 을 넣고 다시 실행해야 한다.");
+}
+
+export async function saveToken(sql, key, tokenJson) {
+ let expiresAt = null;
+ try {
+ const t = JSON.parse(tokenJson);
+ if (t.expiry) expiresAt = new Date(t.expiry).toISOString();
+ } catch {
+ throw new Error("저장하려는 토큰이 JSON 이 아니다.");
+ }
+ const payload = encrypt(key, tokenJson);
+ await sql`INSERT INTO onedrive_token (id, payload, expires_at, updated_at)
+ VALUES (${TOKEN_ROW_ID}, ${payload}, ${expiresAt}, now())
+ ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at, updated_at = now()`;
+}
+
+export async function getState(sql, key) {
+ const rows = await sql`SELECT value FROM onedrive_sync_state WHERE key = ${key}`;
+ return rows[0]?.value ?? null;
+}
+
+export async function setState(sql, key, value) {
+ await sql`INSERT INTO onedrive_sync_state (key, value, updated_at) VALUES (${key}, ${JSON.stringify(value)}::jsonb, now())
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`;
+}
diff --git a/ops/scripts/lib/rclone-onedrive.mjs b/ops/scripts/lib/rclone-onedrive.mjs
new file mode 100644
index 0000000..8badd03
--- /dev/null
+++ b/ops/scripts/lib/rclone-onedrive.mjs
@@ -0,0 +1,53 @@
+/**
+ * CI 에서 rclone 으로 개인 OneDrive 를 여는 헬퍼. 토큰은 Neon(onedrive-token.mjs)에서 읽고,
+ * rclone 이 갱신한 최신 토큰을 닫을 때 되쓴다. 원격 이름은 `od:` 이고 루트는 OneDrive 최상위다.
+ *
+ * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID
+ */
+import { execFile } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { ensureTables, getSql, keyFrom, loadToken, saveToken } from "./onedrive-token.mjs";
+
+const execFileAsync = promisify(execFile);
+
+export async function openOneDrive() {
+ const driveId = process.env.ONEDRIVE_DRIVE_ID?.trim();
+ if (!driveId) throw new Error("ONEDRIVE_DRIVE_ID 가 없다.");
+ const key = keyFrom(process.env.ONEDRIVE_TOKEN_KEY);
+ const sql = getSql();
+ await ensureTables(sql);
+ const tokenBefore = await loadToken(sql, key);
+
+ const dir = await mkdtemp(path.join(os.tmpdir(), "od-"));
+ const conf = path.join(dir, "rclone.conf");
+ await writeFile(conf, `[od]\ntype = onedrive\ndrive_type = personal\ndrive_id = ${driveId}\ntoken = ${tokenBefore}\n`, {
+ mode: 0o600,
+ });
+
+ async function rclone(args, opts = {}) {
+ const { stdout } = await execFileAsync("rclone", ["--config", conf, ...args], {
+ maxBuffer: 256 * 1024 * 1024,
+ ...opts,
+ });
+ return stdout;
+ }
+
+ async function close() {
+ try {
+ const text = await readFile(conf, "utf8").catch(() => "");
+ const line = text.split("\n").find((l) => l.startsWith("token = "));
+ const latest = line ? line.slice("token = ".length).trim() : null;
+ if (latest && latest !== tokenBefore) {
+ await saveToken(sql, key, latest);
+ console.log("OneDrive 토큰 갱신분을 Neon 에 저장");
+ }
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ }
+
+ return { conf, sql, rclone, close };
+}
diff --git a/ops/scripts/publish-generated.mjs b/ops/scripts/publish-generated.mjs
new file mode 100644
index 0000000..058d95c
--- /dev/null
+++ b/ops/scripts/publish-generated.mjs
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+/**
+ * Build With Vault Refresh 가 만든 생성물을 개인 OneDrive `hsol-info-blob/vault/object-views/` 에 쓴다.
+ * 사이트는 이 파일들을 OneDrive 에서 바로 읽는다.
+ *
+ * - site-data.json, onepager-ko.html, onepager-en.html: 내려받은 직후 해시(generated/pulled-generated.sha1)와
+ * 달라진 것만
+ * - onepager-.pdf (generated/onepager-.pdf): 같은 언어 HTML 이 바뀌었거나 OneDrive 에 아직 없을 때만.
+ * PDF 는 렌더할 때마다 바이트가 달라져 해시로 비교하지 않는다
+ * - 하나라도 올렸으면 hsol-info-parent-ref.json(어느 커밋, 어느 실행이 만들었는지)
+ *
+ * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID
+ */
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { openOneDrive } from "./lib/rclone-onedrive.mjs";
+
+const LOCAL_VAULT = process.env.LOCAL_VAULT_DIR || "hsol-info-blob/vault";
+const OD_VIEWS = `${process.env.ONEDRIVE_VAULT_ROOT || "hsol-info-blob/vault"}/object-views`;
+const TEXT_OUTPUTS = ["object-views/site-data.json", "object-views/onepager-ko.html", "object-views/onepager-en.html"];
+
+const pulled = new Map(
+ (existsSync("generated/pulled-generated.sha1") ? readFileSync("generated/pulled-generated.sha1", "utf8") : "")
+ .split("\n")
+ .filter(Boolean)
+ .map((l) => {
+ const [h, f] = l.split(" ");
+ return [f.replace(/^vault\//, ""), h];
+ }),
+);
+const sha1 = (buf) => createHash("sha1").update(buf).digest("hex");
+
+const uploads = [];
+const changedText = new Set();
+for (const rel of TEXT_OUTPUTS) {
+ const p = path.join(LOCAL_VAULT, rel);
+ if (!existsSync(p)) continue;
+ if (pulled.get(rel) === sha1(readFileSync(p))) continue;
+ uploads.push([p, `${OD_VIEWS}/${path.basename(rel)}`]);
+ changedText.add(rel);
+}
+for (const lang of ["ko", "en"]) {
+ const pdf = `generated/onepager-${lang}.pdf`;
+ if (!existsSync(pdf)) continue;
+ const htmlChanged = changedText.has(`object-views/onepager-${lang}.html`);
+ const missing = !existsSync(path.join(LOCAL_VAULT, `object-views/onepager-${lang}.pdf`));
+ if (htmlChanged || missing) uploads.push([pdf, `${OD_VIEWS}/onepager-${lang}.pdf`]);
+}
+
+if (!uploads.length) {
+ console.log("No regenerated vault outputs; skip OneDrive upload.");
+ process.exit(0);
+}
+
+const ref = {
+ parentSha: process.env.GITHUB_SHA,
+ parentRef: process.env.GITHUB_REF,
+ workflowRunId: Number(process.env.GITHUB_RUN_ID) || null,
+ recordedAt: new Date().toISOString(),
+ changed: uploads.map(([, dest]) => `vault/object-views/${path.basename(dest)}`),
+};
+const refPath = path.join(os.tmpdir(), "hsol-info-parent-ref.json");
+writeFileSync(refPath, JSON.stringify(ref, null, 2));
+uploads.push([refPath, `${OD_VIEWS}/hsol-info-parent-ref.json`]);
+
+const od = await openOneDrive();
+try {
+ for (const [src, dest] of uploads) {
+ await od.rclone(["copyto", src, `od:${dest}`]);
+ console.log(`uploaded od:${dest}`);
+ }
+} finally {
+ await od.close();
+}
diff --git a/ops/scripts/pull-blob-to-vault.mjs b/ops/scripts/pull-blob-to-vault.mjs
deleted file mode 100644
index 45b333d..0000000
--- a/ops/scripts/pull-blob-to-vault.mjs
+++ /dev/null
@@ -1,183 +0,0 @@
-/**
- * hsol-info-blob 전용: Vercel Blob prefix에서 로컬 vault로 내려받기(증분 + 상태 파일).
- * 로컬 개발 시 이 저장소 루트에서 실행한다.
- */
-import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
-import path from "node:path";
-import process from "node:process";
-import { list } from "@vercel/blob";
-
-const DEFAULT_VAULT_DIR = "vault";
-const DEFAULT_PREFIX = "info/vault";
-const SYNC_STATE_FILE = ".blob-sync-state.json";
-const IGNORED_FILENAMES = new Set([".DS_Store", ".DS-Store"]);
-const CONCURRENCY = Math.max(1, Number(process.env.BLOB_PULL_CONCURRENCY) || 16);
-/** 내려받지 않을 vault 상대경로 접두(쉼표 구분). CI 는 사이트가 쓰지 않는 datasources/ 등을 뺀다. */
-const EXCLUDED_PREFIXES = (process.env.BLOB_PULL_EXCLUDE || "").split(",").map((s) => s.trim()).filter(Boolean);
-
-function parseArgs() {
- const args = process.argv.slice(2);
- const options = {
- vaultDir: process.env.BLOB_VAULT_DIR || DEFAULT_VAULT_DIR,
- prefix: process.env.BLOB_PATH_PREFIX || DEFAULT_PREFIX,
- clean: false,
- };
-
- for (let i = 0; i < args.length; i += 1) {
- const arg = args[i];
- if (arg === "--vault-dir") options.vaultDir = args[++i];
- else if (arg === "--prefix") options.prefix = args[++i];
- else if (arg === "--clean") options.clean = true;
- }
-
- return options;
-}
-
-function toPosixPath(filePath) {
- return filePath.split(path.sep).join("/");
-}
-
-async function pathExists(targetPath) {
- try {
- await access(targetPath);
- return true;
- } catch {
- return false;
- }
-}
-
-async function loadSyncState(statePath) {
- try {
- const raw = await readFile(statePath, "utf8");
- const parsed = JSON.parse(raw);
- if (!parsed || typeof parsed !== "object") return {};
- return parsed;
- } catch {
- return {};
- }
-}
-
-async function collectFiles(rootDir, currentDir = rootDir) {
- const entries = await readdir(currentDir, { withFileTypes: true });
- const files = [];
-
- for (const entry of entries) {
- if (entry.name === ".git") continue;
- if (IGNORED_FILENAMES.has(entry.name)) continue;
- const fullPath = path.join(currentDir, entry.name);
- if (entry.isDirectory()) {
- const nested = await collectFiles(rootDir, fullPath);
- files.push(...nested);
- continue;
- }
-
- if (entry.isFile()) {
- files.push(fullPath);
- }
- }
-
- return files;
-}
-
-function shouldIgnoreRelativePath(relativePath) {
- const normalized = relativePath.replace(/\\/g, "/");
- const filename = normalized.split("/").pop() ?? "";
- if (EXCLUDED_PREFIXES.some((p) => normalized.startsWith(p))) return true;
- return IGNORED_FILENAMES.has(filename);
-}
-
-async function listAllBlobs(prefix, token) {
- let cursor;
- const blobs = [];
-
- do {
- const page = await list({
- prefix,
- cursor,
- token,
- limit: 1000,
- });
- blobs.push(...page.blobs);
- cursor = page.cursor;
- } while (cursor);
-
- return blobs;
-}
-
-async function main() {
- const { vaultDir, prefix, clean } = parseArgs();
- const token = process.env.BLOB_READ_WRITE_TOKEN;
- if (!token) {
- throw new Error("BLOB_READ_WRITE_TOKEN 환경변수가 필요합니다.");
- }
-
- const normalizedPrefix = `${prefix.replace(/^\/+|\/+$/g, "")}/`;
- const vaultRoot = path.resolve(process.cwd(), vaultDir);
- const statePath = path.join(vaultRoot, SYNC_STATE_FILE);
- await mkdir(vaultRoot, { recursive: true });
- const previousState = await loadSyncState(statePath);
- const nextState = {};
-
- const blobs = await listAllBlobs(normalizedPrefix, token);
- if (blobs.length === 0) {
- console.log(`prefix(${normalizedPrefix}) 하위에 Blob 파일이 없습니다.`);
- return;
- }
-
- const expectedLocalPaths = new Set();
- let downloadedCount = 0;
- let skippedCount = 0;
- const work = [];
- for (const blob of blobs) {
- const relativePath = blob.pathname.slice(normalizedPrefix.length);
- if (!relativePath) continue;
- if (shouldIgnoreRelativePath(relativePath)) continue;
- const destinationPath = path.join(vaultRoot, relativePath);
- expectedLocalPaths.add(path.resolve(destinationPath));
- work.push({ blob, relativePath, destinationPath });
- }
-
- let cursor = 0;
- const runner = async () => {
- while (cursor < work.length) {
- const { blob, relativePath, destinationPath } = work[cursor++];
- const blobTimestamp = blob.uploadedAt ? new Date(blob.uploadedAt).toISOString() : "";
- const previousTimestamp = previousState[relativePath];
- if (blobTimestamp && previousTimestamp === blobTimestamp && (await pathExists(destinationPath))) {
- nextState[relativePath] = blobTimestamp;
- skippedCount += 1;
- continue;
- }
- await mkdir(path.dirname(destinationPath), { recursive: true });
- const response = await fetch(blob.url, { headers: { Authorization: `Bearer ${token}` } });
- if (!response.ok) {
- throw new Error(`Blob 다운로드 실패: ${blob.url} (${response.status})`);
- }
- await writeFile(destinationPath, Buffer.from(await response.arrayBuffer()));
- nextState[relativePath] = blobTimestamp || previousTimestamp || "";
- downloadedCount += 1;
- }
- };
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, work.length) }, runner));
- console.log(`대상 ${work.length}건`);
-
- if (clean) {
- const localFiles = await collectFiles(vaultRoot);
- for (const localFile of localFiles) {
- const resolved = path.resolve(localFile);
- if (!expectedLocalPaths.has(resolved)) {
- await rm(localFile, { force: true });
- const rel = path.relative(vaultRoot, localFile);
- console.log(`로컬 정리: ${toPosixPath(rel)}`);
- }
- }
- }
-
- await writeFile(statePath, `${JSON.stringify(nextState, null, 2)}\n`);
- console.log(`증분 동기화 완료: 다운로드 ${downloadedCount}건, 스킵 ${skippedCount}건`);
-}
-
-main().catch((error) => {
- console.error(error);
- process.exit(1);
-});
diff --git a/ops/scripts/sync-onedrive-to-blob.mjs b/ops/scripts/sync-onedrive-to-blob.mjs
deleted file mode 100644
index 6852bec..0000000
--- a/ops/scripts/sync-onedrive-to-blob.mjs
+++ /dev/null
@@ -1,424 +0,0 @@
-/**
- * OneDrive(개인) -> Vercel Blob 단방향 동기화. 콘텐츠의 정본은 OneDrive 다.
- * hsol.info 저장소의 GitHub Actions(.github/workflows/sync-onedrive-to-blob.yml)에서 실행한다.
- * (2026-09-27 hsol-info-blob 저장소 폐기와 함께 이쪽으로 이전)
- *
- * 트랙
- * - vault : OneDrive `hsol-info-blob/vault/**` -> Blob `info/vault/**`
- * 사이트(site-data, 원페이저)와 Ask Hansol 이 읽는 경로. 배포 사이트가 읽지 않는 경로는 제외.
- * hsol.info CI 가 만드는 생성물(VAULT_GENERATED)은 Blob 이 정본이라 올리지 않고,
- * 반대로 Blob 에서 OneDrive 로 되써서(역미러) OneDrive 에서도 최신본을 볼 수 있게 한다.
- * - assets : OneDrive `hsol-info-blob/assets/{public,private}/**` -> Blob `info/assets/**`
- * 사이트가 /files/<키> 로 서빙한다.
- * - 기사 : vault 트랙에서 `objects/news-articles/` 가 바뀌면 Neon articles 미러를 갱신하고,
- * 새로 INSERT 된 기사의 dbId 를 OneDrive 원본 frontmatter 에 되쓴다.
- *
- * 증분 판정은 트랙별 manifest(OneDrive QuickXorHash)로 한다. vault 트랙은 매 실행 Blob 목록과 대조해
- * OneDrive 에 없는 파일을 지운다(생성물 제외).
- *
- * 인증: rclone OneDrive 토큰. MS 리프레시 토큰은 쓸 때마다 새것으로 바뀌므로 실행이 끝날 때 최신 토큰을
- * AES-256-GCM 으로 암호화해 Blob `info/_system/onedrive-token.enc` 에 되써 둔다. 키는 GitHub secret 에만 있다.
- *
- * 필요 환경변수: BLOB_READ_WRITE_TOKEN, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID
- * 선택: ONEDRIVE_BOOTSTRAP_TOKEN, DATABASE_URL(없으면 기사 동기화 생략), SYNC_TRACKS(기본 "assets,vault"),
- * FORCE_ARTICLES=1(변경 없어도 기사 동기화)
- * 플래그: --dry-run (업로드/삭제/역기록 없이 차이만 출력)
- */
-import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
-import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
-import os from "node:os";
-import path from "node:path";
-import process from "node:process";
-import { execFile } from "node:child_process";
-import { promisify } from "node:util";
-import { fileURLToPath } from "node:url";
-import { del, list, put } from "@vercel/blob";
-
-const execFileAsync = promisify(execFile);
-const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
-
-const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN;
-const KEY_B64 = process.env.ONEDRIVE_TOKEN_KEY;
-const DRIVE_ID = process.env.ONEDRIVE_DRIVE_ID;
-const BOOTSTRAP = process.env.ONEDRIVE_BOOTSTRAP_TOKEN;
-const DATABASE_URL = process.env.DATABASE_URL || process.env.POSTGRES_URL;
-const ACCESS = process.env.BLOB_ACCESS || "private";
-const TRACKS = (process.env.SYNC_TRACKS || "assets,vault").split(",").map((s) => s.trim()).filter(Boolean);
-const TOKEN_BLOB_PATH = "info/_system/onedrive-token.enc";
-const CONCURRENCY = Math.max(1, Number(process.env.BLOB_SYNC_CONCURRENCY) || 8);
-const DRY_RUN = process.argv.includes("--dry-run");
-
-const IGNORED_NAMES = new Set([".DS_Store", ".DS-Store", ".blob-sync-state.json", ".gitkeep", "Icon\r"]);
-
-/** 배포 사이트가 Blob 에서 읽지 않는 vault 경로. 이전 sync-vault-to-blob 과 같은 기준. */
-const VAULT_EXCLUDED_PREFIXES = [".obsidian/", "Templates/", "Attachments/", "datasources/"];
-const VAULT_EXCLUDED_EXTS = new Set([".base"]);
-/** hsol.info CI 가 만들어 Blob 에 직접 올리는 생성물. Blob 이 정본이고 OneDrive 로 역미러한다. */
-const VAULT_GENERATED = new Set([
- "object-views/site-data.json",
- "object-views/onepager-ko.html",
- "object-views/onepager-en.html",
- "object-views/hsol-info-parent-ref.json",
-]);
-
-const TRACK_DEFS = {
- assets: {
- odRoot: process.env.ONEDRIVE_ASSETS_ROOT || "hsol-info-blob/assets",
- prefix: "info/assets",
- manifest: "info/assets/manifest.json",
- include: (rel) => {
- const parts = rel.split("/");
- return parts.length >= 2 && (parts[0] === "public" || parts[0] === "private");
- },
- reconcile: false,
- },
- vault: {
- odRoot: process.env.ONEDRIVE_VAULT_ROOT || "hsol-info-blob/vault",
- prefix: "info/vault",
- manifest: "info/_system/vault-manifest.json",
- include: (rel) =>
- !VAULT_EXCLUDED_PREFIXES.some((p) => rel.startsWith(p)) &&
- !VAULT_EXCLUDED_EXTS.has(path.extname(rel).toLowerCase()) &&
- !VAULT_GENERATED.has(rel),
- reconcile: true,
- },
-};
-
-const CONTENT_TYPES = {
- ".md": "text/markdown; charset=utf-8",
- ".json": "application/json",
- ".html": "text/html; charset=utf-8",
- ".txt": "text/plain; charset=utf-8",
- ".csv": "text/csv; charset=utf-8",
- ".yaml": "text/yaml; charset=utf-8",
- ".yml": "text/yaml; charset=utf-8",
- ".pdf": "application/pdf",
- ".png": "image/png",
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".gif": "image/gif",
- ".webp": "image/webp",
- ".avif": "image/avif",
- ".svg": "image/svg+xml",
- ".mp4": "video/mp4",
- ".mov": "video/quicktime",
- ".mp3": "audio/mpeg",
- ".wav": "audio/wav",
- ".zip": "application/zip",
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- ".hwp": "application/x-hwp",
- ".hwpx": "application/haansofthwpx",
- ".epub": "application/epub+zip",
-};
-
-const contentTypeOf = (key) => CONTENT_TYPES[path.extname(key).toLowerCase()] ?? "application/octet-stream";
-const sha1 = (buf) => createHash("sha1").update(buf).digest("hex");
-
-function requireEnv() {
- const missing = [];
- if (!BLOB_TOKEN) missing.push("BLOB_READ_WRITE_TOKEN");
- if (!KEY_B64) missing.push("ONEDRIVE_TOKEN_KEY");
- if (!DRIVE_ID) missing.push("ONEDRIVE_DRIVE_ID");
- if (missing.length) throw new Error(`환경변수 누락: ${missing.join(", ")}`);
- const key = Buffer.from(KEY_B64, "base64");
- if (key.length !== 32) throw new Error("ONEDRIVE_TOKEN_KEY 는 base64 로 인코딩한 32바이트여야 한다.");
- return key;
-}
-
-function encrypt(key, plaintext) {
- const iv = randomBytes(12);
- const cipher = createCipheriv("aes-256-gcm", key, iv);
- const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
- return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64");
-}
-
-function decrypt(key, payloadB64) {
- const buf = Buffer.from(payloadB64, "base64");
- const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
- decipher.setAuthTag(buf.subarray(12, 28));
- return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8");
-}
-
-async function listAllBlobs(prefix) {
- const blobs = [];
- let cursor;
- do {
- const page = await list({ prefix, cursor, token: BLOB_TOKEN, limit: 1000 });
- blobs.push(...page.blobs);
- cursor = page.cursor;
- } while (cursor);
- return blobs;
-}
-
-async function fetchBlob(url) {
- const res = await fetch(url, { headers: { Authorization: `Bearer ${BLOB_TOKEN}` }, cache: "no-store" });
- if (!res.ok) throw new Error(`Blob 읽기 실패 ${url}: HTTP ${res.status}`);
- return Buffer.from(await res.arrayBuffer());
-}
-
-/** private Blob 을 pathname 으로 읽는다. 없으면 null. */
-async function readBlobBuffer(pathname) {
- const page = await list({ prefix: pathname, token: BLOB_TOKEN, limit: 5 });
- const hit = page.blobs.find((b) => b.pathname === pathname);
- return hit ? fetchBlob(hit.url) : null;
-}
-
-async function putBlob(pathname, body, contentType) {
- await put(pathname, body, {
- access: ACCESS,
- addRandomSuffix: false,
- allowOverwrite: true,
- contentType,
- token: BLOB_TOKEN,
- });
-}
-
-async function loadToken(key) {
- const stored = await readBlobBuffer(TOKEN_BLOB_PATH);
- if (stored) {
- console.log("토큰: Blob 에 저장된 최신 토큰 사용");
- return decrypt(key, stored.toString("utf8").trim());
- }
- if (BOOTSTRAP) {
- console.log("토큰: Blob 에 저장된 토큰이 없어 ONEDRIVE_BOOTSTRAP_TOKEN 사용");
- return BOOTSTRAP.trim();
- }
- throw new Error("OneDrive 토큰이 없다. ONEDRIVE_BOOTSTRAP_TOKEN secret 을 넣고 다시 실행해야 한다.");
-}
-
-let CONF = null;
-async function rclone(args, opts = {}) {
- const { stdout } = await execFileAsync("rclone", ["--config", CONF, ...args], {
- maxBuffer: 256 * 1024 * 1024,
- ...opts,
- });
- return stdout;
-}
-
-async function readTokenFromConf() {
- const text = await readFile(CONF, "utf8");
- const line = text.split("\n").find((l) => l.startsWith("token = "));
- return line ? line.slice("token = ".length).trim() : null;
-}
-
-async function runWithConcurrency(items, limit, worker) {
- let index = 0;
- const runner = async () => {
- while (index < items.length) {
- const i = index++;
- await worker(items[i], i);
- }
- };
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
-}
-
-/** 숨김 파일과 시스템 파일. `.obsidian/` 은 트랙 규칙에서 따로 거른다. */
-function isJunk(rel) {
- return rel.split("/").some((p) => IGNORED_NAMES.has(p) || (p.startsWith(".") && p !== ".obsidian"));
-}
-
-/** OneDrive 트리를 읽어 { rel -> meta } 로 돌려준다. rel 은 NFC. */
-async function listOneDrive(odRoot, include) {
- const raw = await rclone(["lsjson", "-R", "--files-only", "--hash", "--hash-type", "QuickXorHash", `od:${odRoot}`]);
- const out = new Map();
- for (const item of JSON.parse(raw)) {
- const rel = item.Path.normalize("NFC");
- if (isJunk(rel) || !include(rel)) continue;
- out.set(rel, {
- srcPath: item.Path,
- size: item.Size,
- hash: item.Hashes?.quickxor ?? item.Hashes?.QuickXorHash ?? `${item.Size}:${item.ModTime}`,
- modTime: item.ModTime,
- });
- }
- return out;
-}
-
-async function syncTrack(name, workDir) {
- const def = TRACK_DEFS[name];
- const remote = await listOneDrive(def.odRoot, def.include);
- const manifestBuf = await readBlobBuffer(def.manifest);
- const prevFiles = manifestBuf ? JSON.parse(manifestBuf.toString("utf8")).files ?? {} : {};
-
- const uploadKeys = new Set([...remote.entries()].filter(([k, v]) => prevFiles[k]?.hash !== v.hash).map(([k]) => k));
- const deleteKeys = new Set(Object.keys(prevFiles).filter((k) => !remote.has(k)));
-
- if (def.reconcile) {
- // manifest 와 무관하게 Blob 실물과 대조한다. 첫 이관이나 manifest 유실에도 어긋나지 않게.
- const blobs = await listAllBlobs(`${def.prefix}/`);
- const actual = new Set(blobs.map((b) => b.pathname.slice(def.prefix.length + 1).normalize("NFC")));
- // 동기화 대상인 경로만 정리한다. 제외 경로(datasources/ 등)에 남아 있는 예전 파일은 건드리지 않는다.
- for (const k of actual) if (!remote.has(k) && !VAULT_GENERATED.has(k) && def.include(k) && !isJunk(k)) deleteKeys.add(k);
- for (const k of remote.keys()) if (!actual.has(k)) uploadKeys.add(k);
- }
-
- console.log(`[${name}] OneDrive ${remote.size}개 / 업로드 ${uploadKeys.size}개 / 삭제 ${deleteKeys.size}개`);
-
- if (DRY_RUN) {
- for (const k of uploadKeys) console.log(` + ${k}`);
- for (const k of deleteKeys) console.log(` - ${k}`);
- return { changed: [...uploadKeys], deleted: [...deleteKeys] };
- }
-
- // 안전장치: OneDrive 가 비어 보이거나(업로드 미완료, 경로 오류) 절반 넘게 지우려 하면 멈춘다.
- // 사이트와 Ask Hansol 이 Blob 을 읽으므로 대량 삭제는 곧 장애다. 의도한 경우만 ALLOW_MASS_DELETE=1.
- const baseline = Math.max(Object.keys(prevFiles).length, def.reconcile ? remote.size + deleteKeys.size : 0);
- const massDelete = remote.size === 0 ? deleteKeys.size > 0 : deleteKeys.size > Math.max(20, baseline * 0.5);
- if (massDelete && process.env.ALLOW_MASS_DELETE !== "1") {
- throw new Error(
- `[${name}] 대량 삭제 차단: OneDrive ${remote.size}개인데 Blob 에서 ${deleteKeys.size}개를 지우려 한다. ` +
- "OneDrive 업로드가 끝났는지 확인하고, 의도한 삭제면 ALLOW_MASS_DELETE=1 로 실행한다.",
- );
- }
-
- const nextFiles = {};
- for (const [k, v] of Object.entries(prevFiles)) if (remote.has(k)) nextFiles[k] = v;
-
- await runWithConcurrency([...uploadKeys], CONCURRENCY, async (rel) => {
- const meta = remote.get(rel);
- const localPath = path.join(workDir, name, rel);
- await rclone(["copyto", `od:${def.odRoot}/${meta.srcPath}`, localPath]);
- const pathname = `${def.prefix}/${rel}`;
- const contentType = contentTypeOf(rel);
- await putBlob(pathname, await readFile(localPath), contentType);
- await rm(localPath, { force: true });
- nextFiles[rel] = { size: meta.size, hash: meta.hash, modTime: meta.modTime, contentType, pathname };
- console.log(` 업로드: ${pathname}`);
- });
-
- const deletes = [...deleteKeys];
- for (let i = 0; i < deletes.length; i += 500) {
- const chunk = deletes.slice(i, i + 500);
- await del(chunk.map((k) => `${def.prefix}/${k}`), { token: BLOB_TOKEN });
- for (const k of chunk) console.log(` 삭제: ${def.prefix}/${k}`);
- }
-
- if (uploadKeys.size || deleteKeys.size || !manifestBuf) {
- const sorted = Object.fromEntries(Object.entries(nextFiles).sort(([a], [b]) => a.localeCompare(b)));
- await putBlob(
- def.manifest,
- JSON.stringify({ generatedAt: new Date().toISOString(), source: `onedrive:${def.odRoot}`, files: sorted }, null, 2),
- "application/json",
- );
- }
- return { changed: [...uploadKeys], deleted: deletes };
-}
-
-/** hsol.info CI 가 Blob 에 올린 생성물을 OneDrive vault 로 되쓴다. */
-async function mirrorGeneratedToOneDrive(workDir) {
- const odRoot = TRACK_DEFS.vault.odRoot;
- for (const rel of VAULT_GENERATED) {
- const blobBuf = await readBlobBuffer(`${TRACK_DEFS.vault.prefix}/${rel}`).catch(() => null);
- if (!blobBuf) continue;
- const odBuf = await rclone(["cat", `od:${odRoot}/${rel}`], { encoding: "buffer" }).catch(() => null);
- if (odBuf && sha1(odBuf) === sha1(blobBuf)) continue;
- console.log(`[생성물] Blob -> OneDrive: ${rel}${DRY_RUN ? " (dry-run)" : ""}`);
- if (DRY_RUN) continue;
- const local = path.join(workDir, "generated", rel);
- await mkdir(path.dirname(local), { recursive: true });
- await writeFile(local, blobBuf);
- await rclone(["copyto", local, `od:${odRoot}/${rel}`]);
- }
-}
-
-/** 기사 원본을 받아 Neon 에 반영하고, dbId 가 새로 붙은 파일만 OneDrive 로 되쓴다. */
-async function syncArticles(workDir) {
- if (!DATABASE_URL) {
- console.log("[기사] DATABASE_URL 이 없어 건너뜀");
- return;
- }
- const odDir = `${TRACK_DEFS.vault.odRoot}/objects/news-articles`;
- const local = path.join(workDir, "articles");
- await rclone(["copy", `od:${odDir}`, local, "--exclude", ".*"]);
- const names = (await readdir(local)).filter((n) => n.endsWith(".md"));
- const before = new Map();
- for (const n of names) before.set(n, sha1(await readFile(path.join(local, n))));
-
- const args = [path.join(SCRIPT_DIR, "sync-articles-to-neon.mjs"), ...(DRY_RUN ? ["--dry"] : [])];
- const { stdout } = await execFileAsync("node", args, {
- env: { ...process.env, VAULT_ARTICLES_DIR: local, DATABASE_URL },
- maxBuffer: 64 * 1024 * 1024,
- });
- for (const line of stdout.split("\n")) if (line) console.log(`[기사] ${line}`);
-
- if (DRY_RUN) return;
- for (const n of names) {
- const after = sha1(await readFile(path.join(local, n)));
- if (after === before.get(n)) continue;
- await rclone(["copyto", path.join(local, n), `od:${odDir}/${n}`]);
- console.log(`[기사] dbId 역기록 -> OneDrive: ${n}`);
- }
-}
-
-/**
- * 정비 작업. 워크플로 입력 maint_op 로 실행한다(쉼표로 여러 개).
- * - stats : OneDrive 정본 폴더 현황
- * 대량 적재는 Mac OneDrive 앱을 거치지 않는다. 한글 이름 파일을 수천 개 한꺼번에 올리면 서버 이름과 로컬 이름이
- * 어긋나 앱이 멈춘다(2026-09-27). 필요하면 rclone 으로 서버에 직접 올리고 앱은 내려받기만 하게 한다.
- */
-async function maintenance(op) {
- if (op === "stats") {
- for (const p of ["hsol-info-blob", "hsol-info-blob/assets"]) {
- console.log(`$ rclone size od:${p}`);
- console.log((await rclone(["size", `od:${p}`]).catch((e) => e.message)).trim());
- }
- } else {
- throw new Error(`알 수 없는 maint_op: ${op}`);
- }
-}
-
-async function main() {
- const key = requireEnv();
- const workDir = await mkdtemp(path.join(os.tmpdir(), "od-sync-"));
- CONF = path.join(workDir, "rclone.conf");
- let tokenBefore = null;
-
- try {
- tokenBefore = await loadToken(key);
- await writeFile(
- CONF,
- `[od]\ntype = onedrive\ndrive_type = personal\ndrive_id = ${DRIVE_ID}\ntoken = ${tokenBefore}\n`,
- { mode: 0o600 },
- );
-
- if (process.env.MAINT_OP) {
- for (const op of process.env.MAINT_OP.split(",").map((s) => s.trim()).filter(Boolean)) await maintenance(op);
- return;
- }
-
- const results = {};
- for (const name of TRACKS) {
- if (!TRACK_DEFS[name]) throw new Error(`알 수 없는 트랙: ${name}`);
- results[name] = await syncTrack(name, workDir);
- }
-
- if (results.vault) {
- await mirrorGeneratedToOneDrive(workDir);
- const touched = [...results.vault.changed, ...results.vault.deleted].some((k) =>
- k.startsWith("objects/news-articles/"),
- );
- if (touched || process.env.FORCE_ARTICLES === "1") await syncArticles(workDir);
- }
- } finally {
- // 성공이든 실패든 rclone 이 갱신한 토큰은 반드시 보존한다. 잃으면 재인증이 필요하다.
- try {
- const tokenAfter = await readTokenFromConf().catch(() => null);
- const latest = tokenAfter || tokenBefore;
- if (latest && !DRY_RUN) {
- // 키 교체(저장소 이전) 때만 쓴다. 다음 실행부터는 새 키로만 풀린다.
- const saveKey = process.env.ONEDRIVE_TOKEN_KEY_NEXT ? Buffer.from(process.env.ONEDRIVE_TOKEN_KEY_NEXT, "base64") : key;
- await putBlob(TOKEN_BLOB_PATH, encrypt(saveKey, latest), "text/plain");
- console.log(`토큰 저장: ${TOKEN_BLOB_PATH}${tokenAfter && tokenAfter !== tokenBefore ? " (갱신됨)" : ""}`);
- }
- } finally {
- await rm(workDir, { recursive: true, force: true });
- }
- }
-}
-
-main().catch((error) => {
- console.error(error?.stack || String(error));
- process.exit(1);
-});
diff --git a/ops/scripts/watch-onedrive.mjs b/ops/scripts/watch-onedrive.mjs
new file mode 100644
index 0000000..e859ddd
--- /dev/null
+++ b/ops/scripts/watch-onedrive.mjs
@@ -0,0 +1,166 @@
+#!/usr/bin/env node
+/**
+ * 개인 OneDrive `hsol-info-blob/vault/` 변경 감시. 사이트는 OneDrive 를 직접 읽으므로 복사는 하지 않는다.
+ * hsol.info 저장소 GitHub Actions(.github/workflows/watch-onedrive.yml)에서 매시 실행한다.
+ *
+ * - 기사 : `objects/news-articles/` 가 바뀌면 Neon articles 미러를 갱신하고, 새로 INSERT 된 기사의 dbId 를
+ * OneDrive 원본 frontmatter 에 되쓴다.
+ * - 빌드 : 사이트 생성물에 영향이 있을 수 있는 vault 경로가 바뀌면 그 목록을 GITHUB_OUTPUT
+ * `vault_changed_files` 로 넘긴다. 워크플로가 Build With Vault Refresh 를 디스패치한다.
+ *
+ * 변경 판정은 OneDrive QuickXorHash 목록을 Neon `onedrive_sync_state`('vault-manifest')와 비교한다.
+ * 첫 실행(기록 없음)은 기준선만 남기고 변경으로 보지 않는다.
+ *
+ * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID
+ * 선택: FORCE_ARTICLES=1, ONEDRIVE_VAULT_ROOT(기본 hsol-info-blob/vault)
+ * 플래그: --dry-run (기록, 역기록, 출력 없이 차이만 출력)
+ */
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { appendFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { getState, setState } from "./lib/onedrive-token.mjs";
+import { openOneDrive } from "./lib/rclone-onedrive.mjs";
+
+const execFileAsync = promisify(execFile);
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
+const DRY_RUN = process.argv.includes("--dry-run");
+const VAULT_ROOT = process.env.ONEDRIVE_VAULT_ROOT || "hsol-info-blob/vault";
+const ARTICLES_REL = "objects/news-articles/";
+const MANIFEST_KEY = "vault-manifest";
+
+const IGNORED_NAMES = new Set([".DS_Store", ".DS-Store", ".gitkeep", "Icon\r"]);
+/** 사이트 생성에 쓰지 않는 vault 경로. */
+const EXCLUDED_PREFIXES = [".obsidian/", "Templates/", "Attachments/", "datasources/"];
+const EXCLUDED_EXTS = new Set([".base"]);
+/** hsol.info CI(Build With Vault Refresh)가 만들어 OneDrive 에 쓰는 생성물. 자기 트리거를 막으려고 뺀다. */
+export const VAULT_GENERATED = new Set([
+ "object-views/site-data.json",
+ "object-views/onepager-ko.html",
+ "object-views/onepager-en.html",
+ "object-views/onepager-ko.pdf",
+ "object-views/onepager-en.pdf",
+ "object-views/hsol-info-parent-ref.json",
+]);
+
+const sha1 = (buf) => createHash("sha1").update(buf).digest("hex");
+
+function isJunk(rel) {
+ return rel.split("/").some((p) => IGNORED_NAMES.has(p) || (p.startsWith(".") && p !== ".obsidian"));
+}
+
+function include(rel) {
+ return (
+ !isJunk(rel) &&
+ !EXCLUDED_PREFIXES.some((p) => rel.startsWith(p)) &&
+ !EXCLUDED_EXTS.has(path.extname(rel).toLowerCase()) &&
+ !VAULT_GENERATED.has(rel)
+ );
+}
+
+/** OneDrive 트리를 { rel(NFC) -> hash } 로. */
+async function listHashes(od, root) {
+ const raw = await od.rclone(["lsjson", "-R", "--files-only", "--hash", "--hash-type", "QuickXorHash", `od:${root}`]);
+ const out = {};
+ for (const item of JSON.parse(raw)) {
+ const rel = item.Path.normalize("NFC");
+ out[rel] = item.Hashes?.quickxor ?? item.Hashes?.QuickXorHash ?? `${item.Size}:${item.ModTime}`;
+ }
+ return out;
+}
+
+/** 기사 원본을 받아 Neon 에 반영하고, dbId 가 새로 붙은 파일을 OneDrive 에 되쓴다. 되쓴 파일 목록을 돌려준다. */
+async function syncArticles(od) {
+ const odDir = `${VAULT_ROOT}/${ARTICLES_REL.replace(/\/$/, "")}`;
+ const local = await mkdtemp(path.join(os.tmpdir(), "articles-"));
+ try {
+ await od.rclone(["copy", `od:${odDir}`, local, "--exclude", ".*"]);
+ const names = (await readdir(local)).filter((n) => n.endsWith(".md"));
+ const before = new Map();
+ for (const n of names) before.set(n, sha1(await readFile(path.join(local, n))));
+
+ const args = [path.join(SCRIPT_DIR, "sync-articles-to-neon.mjs"), ...(DRY_RUN ? ["--dry"] : [])];
+ const { stdout } = await execFileAsync("node", args, {
+ env: { ...process.env, VAULT_ARTICLES_DIR: local },
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ for (const line of stdout.split("\n")) if (line) console.log(`[기사] ${line}`);
+
+ const written = [];
+ if (DRY_RUN) return written;
+ for (const n of names) {
+ if (sha1(await readFile(path.join(local, n))) === before.get(n)) continue;
+ await od.rclone(["copyto", path.join(local, n), `od:${odDir}/${n}`]);
+ console.log(`[기사] dbId 역기록 -> OneDrive: ${n}`);
+ written.push(`${ARTICLES_REL}${n}`.normalize("NFC"));
+ }
+ return written;
+ } finally {
+ await rm(local, { recursive: true, force: true });
+ }
+}
+
+async function writeOutput(files) {
+ if (!process.env.GITHUB_OUTPUT || !files.length) return;
+ // workflow_dispatch 입력은 합계 65,535자 제한. 힌트 목록이라 앞부분만 넘겨도 변경 여부 판단은 같다.
+ let joined = "";
+ for (const f of files) {
+ if (joined.length + f.length + 1 > 30000) break;
+ joined += (joined ? "," : "") + f;
+ }
+ await appendFile(process.env.GITHUB_OUTPUT, `vault_changed_files=${joined}\n`);
+}
+
+async function main() {
+ const od = await openOneDrive();
+ try {
+ const all = await listHashes(od, VAULT_ROOT);
+ const current = Object.fromEntries(Object.entries(all).filter(([rel]) => include(rel)));
+ const count = Object.keys(current).length;
+ if (count === 0) throw new Error(`OneDrive ${VAULT_ROOT} 가 비어 보인다. 기준선을 덮어쓰지 않고 멈춘다.`);
+
+ const prev = (await getState(od.sql, MANIFEST_KEY))?.files ?? null;
+ const changed = [];
+ if (prev) {
+ for (const [rel, hash] of Object.entries(current)) if (prev[rel] !== hash) changed.push(rel);
+ for (const rel of Object.keys(prev)) if (!(rel in current)) changed.push(rel);
+ changed.sort();
+ }
+ console.log(
+ prev
+ ? `[vault] OneDrive ${count}개 / 변경 ${changed.length}개`
+ : `[vault] OneDrive ${count}개 / 첫 실행: 기준선만 기록`,
+ );
+ for (const rel of changed.slice(0, 50)) console.log(` * ${rel}`);
+ if (changed.length > 50) console.log(` ... 외 ${changed.length - 50}개`);
+
+ const articlesTouched = changed.some((rel) => rel.startsWith(ARTICLES_REL));
+ if (articlesTouched || process.env.FORCE_ARTICLES === "1") {
+ const written = await syncArticles(od);
+ if (written.length) {
+ // 역기록한 파일은 새 해시로 기준선을 맞춰 다음 회차에 다시 변경으로 잡히지 않게 한다.
+ const fresh = await listHashes(od, `${VAULT_ROOT}/${ARTICLES_REL.replace(/\/$/, "")}`);
+ for (const [name, hash] of Object.entries(fresh)) {
+ const rel = `${ARTICLES_REL}${name}`;
+ if (rel in current) current[rel] = hash;
+ }
+ }
+ }
+
+ if (DRY_RUN) return;
+ await setState(od.sql, MANIFEST_KEY, { files: current, updatedAt: new Date().toISOString() });
+ if (changed.length) console.log(`[vault] 사이트 재생성 대상 변경 ${changed.length}개`);
+ await writeOutput(changed);
+ } finally {
+ await od.close();
+ }
+}
+
+main().catch((error) => {
+ console.error(error?.stack || String(error));
+ process.exit(1);
+});
diff --git a/ops/scripts/with-onedrive.mjs b/ops/scripts/with-onedrive.mjs
new file mode 100644
index 0000000..5e5906a
--- /dev/null
+++ b/ops/scripts/with-onedrive.mjs
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+/**
+ * rclone 설정(`od:` = 개인 OneDrive)을 만든 채로 명령을 실행하고, 끝나면 갱신된 토큰을 Neon 에 되쓴다.
+ *
+ * node ops/scripts/with-onedrive.mjs -- bash -c 'rclone copy od:hsol-info-blob/vault ./vault'
+ *
+ * 자식 프로세스에는 RCLONE_CONFIG 가 설정돼 있어 rclone 을 그냥 부르면 된다.
+ * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID
+ */
+import { spawn } from "node:child_process";
+import process from "node:process";
+import { openOneDrive } from "./lib/rclone-onedrive.mjs";
+
+const sep = process.argv.indexOf("--");
+const cmd = sep >= 0 ? process.argv.slice(sep + 1) : process.argv.slice(2);
+if (!cmd.length) {
+ console.error("사용법: node ops/scripts/with-onedrive.mjs -- <명령> [인자...]");
+ process.exit(2);
+}
+
+const od = await openOneDrive();
+let code = 1;
+try {
+ code = await new Promise((resolve) => {
+ const child = spawn(cmd[0], cmd.slice(1), {
+ stdio: "inherit",
+ env: { ...process.env, RCLONE_CONFIG: od.conf },
+ });
+ child.on("close", (c) => resolve(c ?? 1));
+ child.on("error", (e) => {
+ console.error(e.message);
+ resolve(1);
+ });
+ });
+} finally {
+ await od.close();
+}
+process.exit(code);
diff --git a/package-lock.json b/package-lock.json
index 30d1abb..095a2ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,6 @@
"@ai-sdk/anthropic": "^4.0.1",
"@neondatabase/serverless": "^1.1.0",
"@vercel/analytics": "^2.0.1",
- "@vercel/blob": "^2.3.3",
"@vercel/edge-config": "^1.4.3",
"ai": "^7.0.4",
"framer-motion": "^12.41.0",
@@ -1947,7 +1946,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2020,7 +2018,6 @@
"integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.2",
"@typescript-eslint/types": "8.59.2",
@@ -2585,22 +2582,6 @@
}
}
},
- "node_modules/@vercel/blob": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.3.3.tgz",
- "integrity": "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg==",
- "license": "Apache-2.0",
- "dependencies": {
- "async-retry": "^1.3.3",
- "is-buffer": "^2.0.5",
- "is-node-process": "^1.2.0",
- "throttleit": "^2.1.0",
- "undici": "^6.23.0"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"node_modules/@vercel/edge-config": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-1.4.3.tgz",
@@ -2652,7 +2633,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2942,15 +2922,6 @@
"node": ">= 0.4"
}
},
- "node_modules/async-retry": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
- "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
- "license": "MIT",
- "dependencies": {
- "retry": "0.13.1"
- }
- },
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -3111,7 +3082,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3422,7 +3392,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz",
"integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10"
}
@@ -3832,7 +3801,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -4431,7 +4399,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4605,7 +4572,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5654,29 +5620,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-buffer": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
- "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/is-bun-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
@@ -5865,12 +5808,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-node-process": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
- "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
- "license": "MIT"
- },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -6093,7 +6030,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -6696,7 +6632,6 @@
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.2",
@@ -6736,7 +6671,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -7327,8 +7261,7 @@
"url": "https://opencollective.com/unified"
}
],
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -7446,7 +7379,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz",
"integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@next/env": "15.5.18",
"@swc/helpers": "0.5.15",
@@ -7949,7 +7881,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8183,7 +8114,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8193,7 +8123,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8450,15 +8379,6 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
- "node_modules/retry": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
- "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -9218,18 +9138,6 @@
"node": ">=0.8"
}
},
- "node_modules/throttleit": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
- "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
@@ -9280,7 +9188,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -9375,7 +9282,6 @@
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -9486,7 +9392,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9514,15 +9419,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/undici": {
- "version": "6.27.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
- "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
- "license": "MIT",
- "engines": {
- "node": ">=18.17"
- }
- },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -9535,7 +9431,6 @@
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/unist": "^3.0.0",
"bail": "^2.0.0",
@@ -9875,7 +9770,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index a1be03a..6221290 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,6 @@
"@ai-sdk/anthropic": "^4.0.1",
"@neondatabase/serverless": "^1.1.0",
"@vercel/analytics": "^2.0.1",
- "@vercel/blob": "^2.3.3",
"@vercel/edge-config": "^1.4.3",
"ai": "^7.0.4",
"framer-motion": "^12.41.0",
diff --git a/scripts/generate-onepager-pdf.ts b/scripts/generate-onepager-pdf.ts
index cc5df0f..5a8aaea 100644
--- a/scripts/generate-onepager-pdf.ts
+++ b/scripts/generate-onepager-pdf.ts
@@ -1,16 +1,14 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
-import { put } from "@vercel/blob";
import { pdfPageCount, renderOnePagerPdf } from "./lib/onepager-pdf";
/**
- * 원페이저 HTML(onepager-.html) -> A4 PDF 변환 후 Vercel Blob 업로드.
+ * 원페이저 HTML(onepager-.html) -> A4 PDF 변환. 결과는 generated/onepager-.pdf 에 남기고,
+ * OneDrive 업로드는 다음 CI 스텝(ops/scripts/publish-generated.mjs)이 한다.
* refresh(ko html 생성) → translate(en html 생성) 다음 CI 스텝에서 실행.
* Playwright 인쇄 엔진이라 텍스트 선택 가능·벡터 출력.
- * BLOB 토큰이 없으면 로컬 파일만 남기고 업로드는 건너뛴다(빌드 실패시키지 않음).
* EN HTML 이 아직 없으면 EN 만 건너뛴다 — KO 는 그대로 나간다.
*/
-const BLOB_PREFIX = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, "");
type Target = { lang: "ko" | "en"; htmlPath: string; localPdfPath: string };
@@ -31,7 +29,6 @@ const TARGETS: Target[] = [
];
async function main() {
- const token = process.env.BLOB_READ_WRITE_TOKEN ?? process.env.ASK_HANSOL_BLOB_TOKEN;
let rendered = 0;
for (const target of TARGETS) {
@@ -50,34 +47,6 @@ async function main() {
console.log(
`[onepager-pdf] Wrote local PDF: ${target.localPdfPath} (${pdf.length} bytes, ${pdfPageCount(pdf)} pages).`,
);
-
- if (!token) {
- console.log("[onepager-pdf] No BLOB_READ_WRITE_TOKEN; skipped Blob upload (local only).");
- continue;
- }
-
- const pdfResult = await put(`${BLOB_PREFIX}/resume/onepager-${target.lang}.pdf`, pdf, {
- access: "private",
- token,
- allowOverwrite: true,
- addRandomSuffix: false,
- contentType: "application/pdf",
- });
- console.log(`[onepager-pdf] Uploaded ${target.lang} PDF to Blob: ${pdfResult.url}`);
-
- // HTML 도 Blob 에 올려 런타임(getOnePagerHtml)이 submodule 없이도 읽게 한다.
- const htmlResult = await put(
- `${BLOB_PREFIX}/vault/object-views/onepager-${target.lang}.html`,
- fragment,
- {
- access: "private",
- token,
- allowOverwrite: true,
- addRandomSuffix: false,
- contentType: "text/html; charset=utf-8",
- },
- );
- console.log(`[onepager-pdf] Uploaded ${target.lang} HTML to Blob: ${htmlResult.url}`);
}
if (rendered === 0) console.log("[onepager-pdf] Nothing to render.");
diff --git a/scripts/refresh-site-data-with-claude.ts b/scripts/refresh-site-data-with-claude.ts
index 6d5d62f..4ab7037 100644
--- a/scripts/refresh-site-data-with-claude.ts
+++ b/scripts/refresh-site-data-with-claude.ts
@@ -1950,7 +1950,7 @@ async function main() {
}
// vault(서브모듈 hsol-info-blob) 포인터가 안 바뀐 코드-only 푸시에선 스킵 → site-data 유지.
if (hasExistingSiteData && !vaultChange.changed && !forceRefresh) {
- logStep("No vault change (hsol-info-blob submodule pointer unchanged). Keep existing site-data as-is.");
+ logStep("No vault change (no VAULT_CHANGED_FILES from OneDrive sync). Keep existing site-data as-is.");
return;
}
diff --git a/src/app/api/ask-hansol-advice/route.ts b/src/app/api/ask-hansol-advice/route.ts
index 87ed646..d3c2589 100644
--- a/src/app/api/ask-hansol-advice/route.ts
+++ b/src/app/api/ask-hansol-advice/route.ts
@@ -6,7 +6,7 @@ import { chatText } from "@/lib/llm";
import {
fetchComprehensiveProfileContext,
fetchVaultReadmeGuideBody,
-} from "@/lib/ask-hansol/blob-context";
+} from "@/lib/ask-hansol/vault-context";
import { summarizeMemoryMerge } from "@/lib/ask-hansol/memory-summarize";
import { ASK_HANSOL_FALLBACK_MESSAGE, isValidAskHansolSessionId } from "@/lib/ask-hansol/shared";
import {
diff --git a/src/app/api/ask-hansol-jd/route.ts b/src/app/api/ask-hansol-jd/route.ts
index c20ab4f..31d746e 100644
--- a/src/app/api/ask-hansol-jd/route.ts
+++ b/src/app/api/ask-hansol-jd/route.ts
@@ -6,7 +6,7 @@ import { chatText } from "@/lib/llm";
import {
fetchComprehensiveProfileContext,
fetchVaultReadmeGuideBody,
-} from "@/lib/ask-hansol/blob-context";
+} from "@/lib/ask-hansol/vault-context";
import { summarizeMemoryMerge } from "@/lib/ask-hansol/memory-summarize";
import { ASK_HANSOL_FALLBACK_MESSAGE, isValidAskHansolSessionId } from "@/lib/ask-hansol/shared";
import {
diff --git a/src/app/api/ask-hansol/route.ts b/src/app/api/ask-hansol/route.ts
index f73de02..24de56b 100644
--- a/src/app/api/ask-hansol/route.ts
+++ b/src/app/api/ask-hansol/route.ts
@@ -6,9 +6,9 @@ import { chatText } from "@/lib/llm";
import type { SiteData } from "@/content/schema";
import { normalizeAskAnswerPlainText } from "@/lib/ask-hansol/answer-linkify";
import {
- fetchBlobContext,
+ fetchVaultContext,
fetchVaultReadmeGuideBody,
-} from "@/lib/ask-hansol/blob-context";
+} from "@/lib/ask-hansol/vault-context";
import { summarizeMemoryMerge } from "@/lib/ask-hansol/memory-summarize";
import {
ASK_HANSOL_FALLBACK_MESSAGE,
@@ -39,15 +39,15 @@ type AskHansolPageContext = {
detail?: string;
};
-async function runBlobLookupTool(input: unknown): Promise {
+async function runVaultLookupTool(input: unknown): Promise {
const query =
typeof input === "object" && input !== null && "query" in input
? String((input as { query?: unknown }).query ?? "").trim()
: "";
- if (!query) return "Blob 조회 실패: query가 비어 있습니다.";
+ if (!query) return "vault 조회 실패: query가 비어 있습니다.";
- const context = await fetchBlobContext(query);
- if (!context) return "Blob 조회 결과가 없습니다. 운영 매뉴얼 또는 관련 문서를 찾지 못했습니다.";
+ const context = await fetchVaultContext(query);
+ if (!context) return "vault 조회 결과가 없습니다. 운영 매뉴얼 또는 관련 문서를 찾지 못했습니다.";
return context;
}
@@ -135,17 +135,17 @@ function buildSystemPrompt(
const pageCtx = `[현재 방문 화면 컨텍스트]\n${formatPageContext(pageContext)}\n\n`;
const readmeBlock = vaultReadmeGuideBody?.trim()
- ? `\n\n[vault 읽기 지침 — Blob vault/README.md 원문. vault 안에서 문서를 찾고 읽는 방법·순서·범위를 이해하는 데만 쓴다. 방문자 질문의 사실·인물·경력·사건 근거로 인용하거나 답 본문을 채우지 않는다.]\n${vaultReadmeGuideBody.trim()}\n`
+ ? `\n\n[vault 읽기 지침 — vault/README.md 원문. vault 안에서 문서를 찾고 읽는 방법·순서·범위를 이해하는 데만 쓴다. 방문자 질문의 사실·인물·경력·사건 근거로 인용하거나 답 본문을 채우지 않는다.]\n${vaultReadmeGuideBody.trim()}\n`
: "";
return `${mem}${pageCtx}당신은 임한솔(Hansol Lim) 본인을 대신해 포트폴리오 사이트 방문자에게 답하는 어시스턴트입니다. 한국어로 3~5문장 이내로 답하세요.
- 사용자가 보고 있는 화면 문맥을 먼저 고려하세요. 화면 컨텍스트에 "주목영역(스크롤)"이 있으면 그 값은 뷰포트에 가장 많이 보이는 블록의 id이므로, 질문이 짧거나 모호할 때 그 구간을 우선해 답하세요. 예: 상세화면이면 해당 관점을, 홈이면 전체 소개 관점으로 답하세요.
-- vault/README.md는 “무엇이 사실인가”가 아니라 vault를 어떻게 읽을지에 대한 지침이다. 시스템에 붙은 README 원문 블록이 있으면 답하기 전에 먼저 익혀, blob_lookup으로 필요한 본문 문서를 고른다.
-- blob_lookup으로 읽은 내용만 근거로 삼는다. 사실·근거·배경은 전부 vault(Blob) 문서에서만 찾는다. vault에 적혀 있고 조회된 사실은 이 Ask 맥락에서 전부 말해도 된다(비공개 예외를 두지 않는다). 가족·배우자·결혼 여부 등도 vault에 있으면 그대로 답에 넣는다. vault에 없거나 조회되지 않은 것은 모른다고 답한다.
+- vault/README.md는 “무엇이 사실인가”가 아니라 vault를 어떻게 읽을지에 대한 지침이다. 시스템에 붙은 README 원문 블록이 있으면 답하기 전에 먼저 익혀, vault_lookup으로 필요한 본문 문서를 고른다.
+- vault_lookup으로 읽은 내용만 근거로 삼는다. 사실·근거·배경은 전부 vault 문서에서만 찾는다. vault에 적혀 있고 조회된 사실은 이 Ask 맥락에서 전부 말해도 된다(비공개 예외를 두지 않는다). 가족·배우자·결혼 여부 등도 vault에 있으면 그대로 답에 넣는다. vault에 없거나 조회되지 않은 것은 모른다고 답한다.
- 말투·역할·스타일은 "AI-클론-운영-매뉴얼"을 참고하되, 사실·인물·관계는 오직 vault가 우선이다. 매뉴얼에 적힌 "외부 비공개""사이트에 올리지 않음" 같은 문구는 다른 채널용일 수 있으며, 이 포트폴리오 Ask에서는 그것을 이유로 vault에 있는 가족·배우자 관련 사실을 숨기거나 거절하지 않는다.
-- 방문자에게는 조회 과정·저장소 이름을 드러내지 않는다. 예를 들어 "vault에서 확인했습니다", "Blob에서", "운영 매뉴얼상 ~라서 공개하지 않습니다" 같은 메타·출처 설명은 쓰지 말고, 답은 항상 자연스러운 대화 톤만 쓴다.
+- 방문자에게는 조회 과정·저장소 이름을 드러내지 않는다. 예를 들어 "vault에서 확인했습니다", "OneDrive에서", "운영 매뉴얼상 ~라서 공개하지 않습니다" 같은 메타·출처 설명은 쓰지 말고, 답은 항상 자연스러운 대화 톤만 쓴다.
- vault 문서에 나오는 \`[[제목]]\` 같은 위키링크/내부 참조 표기를 답변에 그대로 쓰지 마라. 대괄호 없이 일반 단어·문장으로 풀어 쓴다(예: \`[[프루퍼]]\` → 프루퍼).
-- 확신이 부족하면 답변 전에 반드시 blob_lookup 도구를 호출해 vault 문서를 조회한 뒤 답하세요.
+- 확신이 부족하면 답변 전에 반드시 vault_lookup 도구를 호출해 vault 문서를 조회한 뒤 답하세요.
세션 메모리(위 요약)와 messages의 최근 대화를 함께 참고하되, **가장 마지막 user 메시지**에 직접 답하세요. 이미 말한 내용은 한 줄로만 짚고 중복 설명은 줄이세요.
${readmeBlock}
@@ -173,14 +173,14 @@ function toPlainAnswer(text: string): string {
return normalizeAskAnswerPlainText(text);
}
-/** Ask Hansol용 Blob 조회 툴. AI SDK가 tool_use/tool_result 루프를 자동 처리한다. */
-const blobLookupTool = tool({
+/** Ask Hansol용 vault 조회 툴. AI SDK가 tool_use/tool_result 루프를 자동 처리한다. */
+const vaultLookupTool = tool({
description:
- "Ask Hansol용 Blob 문서를 조회합니다. 결과에 vault/README.md가 있으면 읽는 법 지침일 뿐 사실 근거가 아님. 사실은 objects 등 본문을 따르며, 본문에 가족·배우자 사실이 있으면 방문자에게 그대로 말해도 됨. 방문자 답변 문장에는 vault/Blob/매뉴얼·조회 과정을 언급하지 말 것.",
+ "Ask Hansol용 vault 문서를 조회합니다. 결과에 vault/README.md가 있으면 읽는 법 지침일 뿐 사실 근거가 아님. 사실은 objects 등 본문을 따르며, 본문에 가족·배우자 사실이 있으면 방문자에게 그대로 말해도 됨. 방문자 답변 문장에는 vault/저장소/매뉴얼·조회 과정을 언급하지 말 것.",
inputSchema: z.object({
query: z.string().describe("조회할 주제 또는 질문"),
}),
- execute: async ({ query }) => runBlobLookupTool({ query }),
+ execute: async ({ query }) => runVaultLookupTool({ query }),
});
async function askAnthropicChat(
@@ -195,7 +195,7 @@ async function askAnthropicChat(
...priorTurns.map((t) => ({ role: t.role, content: t.content })),
{ role: "user" as const, content: latestUserText },
],
- tools: { blob_lookup: blobLookupTool },
+ tools: { vault_lookup: vaultLookupTool },
maxSteps: 4,
});
return answer ? toPlainAnswer(answer) : null;
diff --git a/src/app/architecture/page.tsx b/src/app/architecture/page.tsx
index 37125dd..142d672 100644
--- a/src/app/architecture/page.tsx
+++ b/src/app/architecture/page.tsx
@@ -12,7 +12,7 @@ import {
const PAGE_URL = `${SITE_URL}/architecture`;
const PAGE_NAME = "사이트 구조 — hsol.info";
const PAGE_DESC =
- "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·Vercel Blob을 잇는지 보여줍니다.";
+ "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·OneDrive를 잇는지 보여줍니다.";
const ARCHITECTURE_JSON_LD = asGraph([
buildWebPageNode({
@@ -30,7 +30,7 @@ const ARCHITECTURE_JSON_LD = asGraph([
export const metadata: Metadata = {
title: "사이트 구조 — hsol.info",
description:
- "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·Vercel Blob을 잇는지 보여줍니다.",
+ "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·OneDrive를 잇는지 보여줍니다.",
alternates: { canonical: "/architecture" },
openGraph: {
type: "article",
diff --git a/src/app/files/[...key]/route.ts b/src/app/files/[...key]/route.ts
index 31f3361..b9c5868 100644
--- a/src/app/files/[...key]/route.ts
+++ b/src/app/files/[...key]/route.ts
@@ -1,20 +1,18 @@
-import { list } from "@vercel/blob";
import type { NextRequest } from "next/server";
-import { getBlobPrefix, getBlobToken } from "@/lib/content/blob";
+import { getItem, isOneDriveConfigured } from "@/lib/onedrive/graph";
import { MANAGE_COOKIE, verifySession } from "@/lib/manage-auth";
/**
* 바이너리 에셋 서빙: /files//<경로>
*
- * 원본은 개인 OneDrive `hsol.info-assets/` 이고, hsol-info-blob 저장소의
- * sync-onedrive-to-blob 워크플로가 Blob `info/assets/<키>` 로 올린다. store 가 private 이라
- * 브라우저가 Blob URL 을 직접 못 열기 때문에 여기서 토큰을 붙여 대신 받아 흘려보낸다.
+ * 원본은 개인 OneDrive `hsol-info-blob/assets/<키>` 다. OneDrive 는 비공개라 서버가 Graph 로
+ * 파일의 단기 다운로드 URL 을 얻어 대신 받아 흘려보낸다(Range 그대로 전달).
*
* - public/... : 누구나. CDN 캐시 허용
* - private/... : /manage 세션(Sign in with Vercel)이 있을 때만. 없으면 존재 여부도 숨기려고 404
*
- * 규약 정본: hsol-info-blob vault/README.md "바이너리 에셋은 OneDrive 에 둔다"
+ * 규약 정본: OneDrive hsol-info-blob/vault/README.md "바이너리"
*/
export const runtime = "nodejs";
@@ -52,11 +50,6 @@ async function hasManageSession(req: NextRequest): Promise {
return Boolean(secret && token && (await verifySession(token, secret)));
}
-async function resolveAsset(token: string, pathname: string) {
- const page = await list({ prefix: pathname, token, limit: 5 }).catch(() => null);
- return page?.blobs.find((b) => b.pathname === pathname) ?? null;
-}
-
function contentDisposition(key: string, download: boolean): string {
const filename = key.split("/").pop() ?? "file";
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
@@ -71,28 +64,33 @@ async function serve(req: NextRequest, params: Promise<{ key: string[] }>, headO
const isPrivate = key.startsWith("private/");
if (isPrivate && !(await hasManageSession(req))) return notFound();
- const token = getBlobToken();
- if (!token) return notFound();
-
- const blob = await resolveAsset(token, `${getBlobPrefix()}/assets/${key}`);
- if (!blob) return notFound();
+ if (!isOneDriveConfigured()) return notFound();
+ const item = await getItem(`assets/${key}`).catch(() => null);
+ const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
+ if (!item || !downloadUrl) return notFound();
- const upstreamHeaders: Record = { Authorization: `Bearer ${token}` };
+ const upstreamHeaders: Record = {};
const range = req.headers.get("range");
if (range) upstreamHeaders.Range = range;
- const upstream = await fetch(blob.url, {
- method: headOnly ? "HEAD" : "GET",
- headers: upstreamHeaders,
- cache: "no-store",
- }).catch(() => null);
- if (!upstream || (!upstream.ok && upstream.status !== 206)) return notFound();
+ const upstream = headOnly
+ ? null
+ : await fetch(downloadUrl, { headers: upstreamHeaders, cache: "no-store" }).catch(() => null);
+ if (!headOnly && (!upstream || (!upstream.ok && upstream.status !== 206))) return notFound();
const headers = new Headers();
- for (const h of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) {
- const v = upstream.headers.get(h);
- if (v) headers.set(h, v);
+ if (upstream) {
+ for (const h of ["content-length", "content-range", "accept-ranges"]) {
+ const v = upstream.headers.get(h);
+ if (v) headers.set(h, v);
+ }
+ } else {
+ headers.set("content-length", String(item.size));
+ headers.set("accept-ranges", "bytes");
}
+ headers.set("content-type", item.file?.mimeType || upstream?.headers.get("content-type") || "application/octet-stream");
+ if (item.eTag) headers.set("etag", item.eTag);
+ if (item.lastModifiedDateTime) headers.set("last-modified", new Date(item.lastModifiedDateTime).toUTCString());
headers.set("Content-Disposition", contentDisposition(key, req.nextUrl.searchParams.has("download")));
if (isPrivate) {
headers.set("Cache-Control", "private, no-store");
@@ -102,7 +100,7 @@ async function serve(req: NextRequest, params: Promise<{ key: string[] }>, headO
headers.set("Cache-Control", "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400");
}
- return new Response(headOnly ? null : upstream.body, { status: upstream.status, headers });
+ return new Response(upstream ? upstream.body : null, { status: upstream?.status ?? 200, headers });
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ key: string[] }> }) {
diff --git a/src/app/resume/page.tsx b/src/app/resume/page.tsx
index a2b908b..3f2d26d 100644
--- a/src/app/resume/page.tsx
+++ b/src/app/resume/page.tsx
@@ -6,7 +6,7 @@ import { getOnePagerHtml } from "@/lib/content/onepager";
const SITE_URL = "https://hsol.info";
-// 정적 프리렌더 대신 요청 시 렌더 — Blob의 최신 원페이저를 리빌드 없이 반영한다.
+// 정적 프리렌더 대신 요청 시 렌더 — OneDrive 의 최신 원페이저를 리빌드 없이 반영한다.
export const dynamic = "force-dynamic";
const PAGE_TITLE = "임한솔 이력서·포트폴리오 (Hansol Lim Resume)";
diff --git a/src/app/resume/pdf/route.ts b/src/app/resume/pdf/route.ts
index 7c147f8..8ab8856 100644
--- a/src/app/resume/pdf/route.ts
+++ b/src/app/resume/pdf/route.ts
@@ -1,10 +1,9 @@
import { NextResponse } from "next/server";
-import { getBlobPrefix, getBlobToken, resolveBlobUrl } from "@/lib/content/blob";
+import { isOneDriveConfigured, readBytes } from "@/lib/onedrive/graph";
/**
- * /resume/pdf — CI 에서 사전 생성해 Blob 에 올린 원페이저 PDF 를 내려준다.
- * Blob store 가 private 라 브라우저 직접 접근이 안 되므로, 서버가 토큰으로 가져와 스트리밍한다
- * (site-data 읽기와 동일한 private read 패턴).
+ * /resume/pdf - CI 가 사전 생성해 개인 OneDrive `hsol-info-blob/vault/object-views/onepager-.pdf`
+ * 에 올린 원페이저 PDF 를 내려준다. OneDrive 는 비공개라 서버가 Graph 로 받아 흘려보낸다.
*
* `?lang=en` 이면 영문판을 내려준다. 언어 상태는 클라이언트 localStorage 에만 있어서 서버는
* 요청만 보고 언어를 알 수 없다 — 링크에 실어 보내는 게 유일한 수단이다(useResumePdfHref).
@@ -18,16 +17,8 @@ const DOWNLOAD_FILENAME = {
} as const;
async function fetchPdf(lang: "ko" | "en"): Promise {
- const token = getBlobToken();
- if (!token) return null;
- const url = await resolveBlobUrl(token, getBlobPrefix(), `resume/onepager-${lang}.pdf`);
- if (!url) return null;
- const upstream = await fetch(url, {
- headers: { Authorization: `Bearer ${token}` },
- cache: "no-store",
- }).catch(() => null);
- if (!upstream || !upstream.ok) return null;
- return upstream.arrayBuffer();
+ if (!isOneDriveConfigured()) return null;
+ return readBytes(`vault/object-views/onepager-${lang}.pdf`).catch(() => null);
}
export async function GET(request: Request) {
diff --git a/src/components/portfolio/ArchitectureDiagramPage.tsx b/src/components/portfolio/ArchitectureDiagramPage.tsx
index 7223777..5a02146 100644
--- a/src/components/portfolio/ArchitectureDiagramPage.tsx
+++ b/src/components/portfolio/ArchitectureDiagramPage.tsx
@@ -6,7 +6,7 @@ import { BlockList } from "@/components/portfolio/blocks/BlockList";
import { BlockCallbacksProvider } from "@/components/portfolio/blocks/context";
/**
- * /architecture — vault·SiteData·Blob·CI·Ask 연결 구조를 Mermaid 한 장으로.
+ * /architecture — vault·SiteData·OneDrive·CI·Ask 연결 구조를 Mermaid 한 장으로.
* 셸(app-layout/shell/Foot)과 블록 시퀀스(back·plate·viewHead+mermaid)만 조립한다.
* 레이아웃은 site-data.layout.pages.architecture(없으면 DEFAULT_LAYOUT)가 정한다.
*/
diff --git a/src/content/default-layout.ts b/src/content/default-layout.ts
index 3164a05..54e276a 100644
--- a/src/content/default-layout.ts
+++ b/src/content/default-layout.ts
@@ -183,7 +183,7 @@ export const DEFAULT_LAYOUT: SiteLayout = {
room: "META · ARCH",
coord: "Z0",
titleText: "사이트 구조",
- lede: "온톨로지 vault와 SiteData, Blob·CI, Next 런타임·Ask가 서로 어떻게 연결되는지 한 도식으로 정리했습니다.",
+ lede: "온톨로지 vault와 SiteData, OneDrive·CI, Next 런타임·Ask가 서로 어떻게 연결되는지 한 도식으로 정리했습니다.",
media: "architecture-mermaid",
},
},
diff --git a/src/content/site-architecture.mermaid.ts b/src/content/site-architecture.mermaid.ts
index bad4fa7..ca4e2b9 100644
--- a/src/content/site-architecture.mermaid.ts
+++ b/src/content/site-architecture.mermaid.ts
@@ -93,18 +93,16 @@ flowchart TB
SD_COMPOSE[composition 페이지 컴포넌트-트리]
end
- subgraph L5["L5. 저장 및 Blob 동기화"]
+ subgraph L5["L5. 저장 OneDrive 단일 정본"]
direction LR
- BLOB_REPO[vault submodule git
hsol-info-blob origin]
- VBLOB[(원격 Blob 저장소)]
- WF_BLOB_SYNC[hsol-info-blob 자체 CI
Blob 업로드 전담]
+ OD_STORE[(개인 OneDrive
hsol-info-blob 폴더)]
+ WF_WATCH[변경 감시 워크플로
매시 기사 반영·빌드 디스패치]
end
subgraph L6["L6. CI 빌드 - hsol/hsol.info"]
direction LR
GH[(메인 repo git 호스팅)]
WF_REF[빌드 갱신 워크플로]
- WF_SUB[submodule 검증]
SCRIPT_REF[콘텐츠 갱신 스크립트 LLM ETL]
SCRIPT_GEN[폴백 스냅샷 생성]
ZOD[스키마 검증 zod]
@@ -112,7 +110,7 @@ flowchart TB
SITE_TS[빌드 타임 폴백 스냅샷]
COMPOSE_GEN[컴포지션 빌더
레이아웃 자가개선]
ONEPAGER_GEN[원페이저 HTML·PDF 생성]
- SYNC_ART[기사 동기화 수동 ETL]
+ SYNC_ART[기사 동기화 ETL]
end
end
end
@@ -200,31 +198,31 @@ flowchart TB
DS_BLOG --> OV_BLOG_ARCH
DS_MED --> OV_MED_ARCH
- EDIT_HUMAN -- commit·push --> BLOB_REPO
- EDIT_CLAUDE -- commit·push --> BLOB_REPO
-
- PEO --> BLOB_REPO
- CON --> BLOB_REPO
- ORG --> BLOB_REPO
- PRJ --> BLOB_REPO
- ART --> BLOB_REPO
- OV_PERSONA --> BLOB_REPO
- OV_WRIT --> BLOB_REPO
- OV_PORT --> BLOB_REPO
- OV_TIME --> BLOB_REPO
- OV_BACK --> BLOB_REPO
- OV_README --> BLOB_REPO
- OV_NET --> BLOB_REPO
- OV_BLOG_ARCH --> BLOB_REPO
- OV_MED_ARCH --> BLOB_REPO
- SD_META --> BLOB_REPO
- NEWS_ART --> BLOB_REPO
-
- BLOB_REPO -- push event --> WF_BLOB_SYNC
- WF_BLOB_SYNC -- Blob 업로드 --> VBLOB
+ EDIT_HUMAN -- OneDrive 동기화 --> OD_STORE
+ EDIT_CLAUDE -- 파일 편집 --> OD_STORE
+
+ PEO --> OD_STORE
+ CON --> OD_STORE
+ ORG --> OD_STORE
+ PRJ --> OD_STORE
+ ART --> OD_STORE
+ OV_PERSONA --> OD_STORE
+ OV_WRIT --> OD_STORE
+ OV_PORT --> OD_STORE
+ OV_TIME --> OD_STORE
+ OV_BACK --> OD_STORE
+ OV_README --> OD_STORE
+ OV_NET --> OD_STORE
+ OV_BLOG_ARCH --> OD_STORE
+ OV_MED_ARCH --> OD_STORE
+ SD_META --> OD_STORE
+ NEWS_ART --> OD_STORE
+
+ OD_STORE -- 변경 감지 --> WF_WATCH
+ WF_WATCH -- 바뀐 경로와 함께 디스패치 --> WF_REF
+ WF_WATCH --> SYNC_ART
GH --> WF_REF
- GH --> WF_SUB
WF_REF --> SCRIPT_REF
OV_README ==> SCRIPT_REF
OV_WRIT ==> SCRIPT_REF
@@ -234,7 +232,8 @@ flowchart TB
PEO ==> SCRIPT_REF
SCRIPT_REF --> ANTHROPIC
ANTHROPIC --> SD_META
- WF_REF -- submodule commit·push
Blob에는 직접 업로드 안 함 --> BLOB_REPO
+ OD_STORE -- vault 내려받기 --> WF_REF
+ WF_REF -- 생성물 게시 --> OD_STORE
WF_REF --> FAIL
WF_REF --> SCRIPT_GEN
SD_META --> SCRIPT_GEN
@@ -245,7 +244,7 @@ flowchart TB
ANTHROPIC --> SD_COMPOSE
COMPOSE_GEN -- 개선 기록 --> NEON
WF_REF --> ONEPAGER_GEN
- ONEPAGER_GEN --> VBLOB
+ ONEPAGER_GEN --> OD_STORE
NEWS_ART --> SYNC_ART
SYNC_ART -- 기사 미러 --> NEON
WF_REF --> VERCEL
@@ -253,8 +252,7 @@ flowchart TB
VERCEL --> LAYOUT
LAYOUT --> PAGE
PAGE --> LOADER
- LOADER --> VBLOB
- LOADER --> BLOB_REPO
+ LOADER -- Graph API --> OD_STORE
LOADER --> SITE_TS
LOADER --> CACHE
CACHE --> LOADER
@@ -297,7 +295,7 @@ flowchart TB
RETRIEVAL --> TOOL_LOOP
TOOL_LOOP --> ANTHROPIC
- TOOL_LOOP --> VBLOB
+ TOOL_LOOP --> OD_STORE
TOOL_LOOP --> LINKIFY
API_AH --> DB_MSG
@@ -308,7 +306,7 @@ flowchart TB
NEWS_SSR --> NEON
BUILDLOG_SSR --> NEON
- RESUME_SSR --> VBLOB
+ RESUME_SSR --> OD_STORE
CHATDOCK --> IO
IO --> CHATDOCK
@@ -345,8 +343,8 @@ flowchart TB
class PEO,ORG,PRJ,ART,CON,PLA,EVT,ALOG,NEWS_ART,DS_BLOG,DS_MED,DS_LI,DS_BK mermaid-obj
class OV_PERSONA,OV_WRIT,OV_PORT,OV_TIME,OV_BACK,OV_NET,OV_BLOG_ARCH,OV_MED_ARCH,OV_README mermaid-view
class SD_META,SD_HOME,SD_HIRE_COPY,SD_COLLAB_COPY,SD_BUILDER_COPY,SD_CURIOUS_COPY,SD_ASK_UI,SD_RESUME,SD_FAQ,SD_COMPOSE mermaid-hubInner
- class BLOB_REPO,VBLOB,WF_BLOB_SYNC mermaid-store
- class GH,WF_REF,WF_SUB,SCRIPT_REF,SCRIPT_GEN,ZOD,FAIL,SITE_TS,COMPOSE_GEN,ONEPAGER_GEN,SYNC_ART mermaid-ci
+ class OD_STORE,WF_WATCH mermaid-store
+ class GH,WF_REF,SCRIPT_REF,SCRIPT_GEN,ZOD,FAIL,SITE_TS,COMPOSE_GEN,ONEPAGER_GEN,SYNC_ART mermaid-ci
class ANTHROPIC,VERCEL,NEON mermaid-ext
class LAYOUT,PAGE,LOADER,CACHE,API_AH,API_SPEC,API_SEL,RETRIEVAL,TOOL_LOOP,DB_MSG,DB_MEM,LINKIFY,NEWS_SSR,RESUME_SSR,BUILDLOG_SSR mermaid-srv
class PORT_APP,HOME_PAGE,VH,VL,VB,VC,CHATDOCK,ATOMS,IO,LS,ASK_CL mermaid-cli
diff --git a/src/content/site-structure.ts b/src/content/site-structure.ts
index 670edd2..5dce2b5 100644
--- a/src/content/site-structure.ts
+++ b/src/content/site-structure.ts
@@ -112,7 +112,7 @@ export const SITE_STRUCTURE: Record = {
shell: "standalone",
wrapperClass: "view",
inSitemap: false,
- role: "vault·SiteData·Blob·CI·Ask 연결 구조를 Mermaid 한 장으로.",
+ role: "vault·SiteData·OneDrive·CI·Ask 연결 구조를 Mermaid 한 장으로.",
},
};
diff --git a/src/lib/ask-hansol/blob-context.ts b/src/lib/ask-hansol/blob-context.ts
deleted file mode 100644
index 5a42cb2..0000000
--- a/src/lib/ask-hansol/blob-context.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * Ask Hansol — Vercel Blob vault 조회/문맥 구성 공용 모듈.
- * 대화형 Ask(`/api/ask-hansol`)와 JD 적합도 분석(`/api/ask-hansol-jd`)이 함께 쓴다.
- * 방문자 답변에는 vault/Blob/조회 과정을 드러내지 않는다는 정책은 호출부 프롬프트에서 강제한다.
- */
-
-export type BlobEntry = { pathname: string; url: string };
-
-export type RetrievalSkill = {
- id: string;
- keywords: string[];
- blobPaths: string[];
-};
-
-/** 모든 Ask 요청에 항상 포함하는 기본 문맥(읽기 지침 + 운영 매뉴얼). */
-export const BASE_CONTEXT_PATHS = [
- "vault/README.md",
- "vault/object-views/AI-클론-운영-매뉴얼.md",
-];
-
-export const BLOB_CONTEXT_MAX_CHARS = Number(
- process.env.ASK_HANSOL_BLOB_CONTEXT_MAX_CHARS ?? 12_000,
-);
-
-export const RETRIEVAL_SKILLS: RetrievalSkill[] = [
- {
- id: "persona-core",
- keywords: ["페르소나", "성격", "정체성", "톤", "말투", "persona"],
- blobPaths: ["vault/objects/concepts/임한솔-persona.md"],
- },
- {
- id: "profile-career",
- keywords: ["경력", "커리어", "이력", "경험", "요약", "career"],
- blobPaths: ["vault/objects/people/임한솔.md"],
- },
- {
- id: "writing-content",
- keywords: ["블로그", "글", "아카이브", "콘텐츠", "작문", "writing"],
- blobPaths: ["vault/objects/concepts/임한솔-writing-style.md"],
- },
-];
-
-export function keywordTokens(text: string): string[] {
- return text
- .toLowerCase()
- .replace(/[?!.,]/g, " ")
- .split(/\s+/)
- .map((token) =>
- token.replace(/(은|는|이|가|을|를|요|나요|까요|인가요|입니다|있나요|하나요)$/g, ""),
- )
- .filter((token) => token.length >= 2);
-}
-
-export function getBlobToken(): string | null {
- return (
- process.env.ASK_HANSOL_BLOB_TOKEN ??
- process.env.BLOB_READ_WRITE_TOKEN ??
- process.env.BLOB_READ_TOKEN ??
- null
- );
-}
-
-function blobContextLimit(): number {
- return Number.isFinite(BLOB_CONTEXT_MAX_CHARS) && BLOB_CONTEXT_MAX_CHARS > 0
- ? BLOB_CONTEXT_MAX_CHARS
- : 12_000;
-}
-
-async function listAllBlobs(prefix: string, token: string): Promise {
- const { list } = await import("@vercel/blob");
- const page = await list({ prefix, token, limit: 20 });
- return page.blobs.map((blob) => ({
- pathname: blob.pathname,
- url: blob.url,
- }));
-}
-
-async function resolveBlobEntryForRelativePath(
- relativePath: string,
- token: string,
- basePrefix: string,
-): Promise {
- const pathCandidates = [
- `${basePrefix}/${relativePath}`.replace(/\/+/g, "/"),
- relativePath.replace(/^\/+/g, ""),
- ];
- for (const fullPath of pathCandidates) {
- const candidates = await listAllBlobs(fullPath, token).catch(() => []);
- const exact = candidates.find((blob) => blob.pathname === fullPath);
- const picked = exact ?? candidates[0];
- if (picked) return picked;
- }
- return null;
-}
-
-export function sortVaultReadmeFirst(entries: BlobEntry[]): BlobEntry[] {
- const isVaultReadme = (pathname: string) => /(^|\/)vault\/README\.md$/i.test(pathname);
- return [...entries].sort((a, b) => {
- const ar = isVaultReadme(a.pathname) ? 0 : 1;
- const br = isVaultReadme(b.pathname) ? 0 : 1;
- if (ar !== br) return ar - br;
- return a.pathname.localeCompare(b.pathname);
- });
-}
-
-export async function fetchBlobText(url: string, token: string): Promise {
- const attempt = async (withAuth: boolean) => {
- const response = await fetch(url, {
- headers: withAuth ? { Authorization: `Bearer ${token}` } : undefined,
- cache: "no-store",
- });
- if (!response.ok) return null;
- return response.text();
- };
-
- try {
- const text = await attempt(true);
- if (text) return text;
- } catch {}
-
- try {
- return await attempt(false);
- } catch {
- return null;
- }
-}
-
-/** Blob의 vault/README.md — vault를 찾고 읽는 절차·규칙용. 사실 근거 문서가 아님. */
-export async function fetchVaultReadmeGuideBody(): Promise {
- const token = getBlobToken();
- if (!token) return null;
- const basePrefix = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, "");
- const entry = await resolveBlobEntryForRelativePath("vault/README.md", token, basePrefix);
- if (!entry) return null;
- const text = await fetchBlobText(entry.url, token);
- if (!text) return null;
- return text.slice(0, blobContextLimit());
-}
-
-function pickRetrievalSkills(query: string): RetrievalSkill[] {
- const tokens = keywordTokens(query);
- const matched = RETRIEVAL_SKILLS.filter((skill) => {
- const skillTokenSet = skill.keywords.flatMap((k) => keywordTokens(k));
- if (skillTokenSet.length === 0 || tokens.length === 0) return false;
- const overlap = tokens.filter((token) => skillTokenSet.includes(token)).length;
- return overlap >= 1;
- });
-
- if (matched.length > 0) return matched.slice(0, 2);
- // 기본 스킬: 일반 질문에도 핵심 프로필은 항상 참조 가능
- return RETRIEVAL_SKILLS.filter((s) => s.id === "profile-career");
-}
-
-async function resolveSkillBlobs(
- skills: RetrievalSkill[],
- token: string,
- basePrefix: string,
- maxDocs = 4,
-): Promise {
- const dedup = new Map();
- const enqueuePath = async (relativePath: string) => {
- const pathCandidates = [
- `${basePrefix}/${relativePath}`.replace(/\/+/g, "/"),
- relativePath.replace(/^\/+/g, ""),
- ];
-
- for (const fullPath of pathCandidates) {
- const candidates = await listAllBlobs(fullPath, token).catch(() => []);
- const exact = candidates.find((blob) => blob.pathname === fullPath);
- const picked = exact ?? candidates[0];
- if (picked) {
- dedup.set(picked.pathname, picked);
- return;
- }
- }
- };
-
- // Always include base context for every Ask Hansol request.
- for (const basePath of BASE_CONTEXT_PATHS) {
- await enqueuePath(basePath);
- }
-
- for (const skill of skills) {
- for (const relativePath of skill.blobPaths) {
- await enqueuePath(relativePath);
- }
- }
-
- return [...dedup.values()].slice(0, maxDocs);
-}
-
-async function buildContextFromBlobs(entries: BlobEntry[], token: string): Promise {
- const limit = blobContextLimit();
- const chunks = await Promise.all(
- entries.map(async (blob) => {
- const text = await fetchBlobText(blob.url, token);
- if (!text) return null;
- const isVaultReadme = /(^|\/)vault\/README\.md$/i.test(blob.pathname);
- const header = isVaultReadme
- ? `### ${blob.pathname} (vault 읽기 지침 — 사실·인물·경력 근거로 쓰지 말 것)`
- : `### ${blob.pathname}`;
- return `${header}\n${text.slice(0, limit)}`;
- }),
- );
- return chunks.filter(Boolean).join("\n\n");
-}
-
-/** 대화형 Ask용: 질문 키워드로 스킬을 골라 관련 문서만 끌어온다. */
-export async function fetchBlobContext(query: string): Promise {
- const token = getBlobToken();
- if (!token) return "";
-
- const basePrefix = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, "");
- const skills = pickRetrievalSkills(query);
- const selected = sortVaultReadmeFirst(await resolveSkillBlobs(skills, token, basePrefix));
- if (selected.length === 0) return "";
- return buildContextFromBlobs(selected, token);
-}
-
-/**
- * JD 적합도 분석용: 키워드와 무관하게 페르소나·경력·작문 등 핵심 프로필 문서를
- * 종합적으로 끌어온다. 기본 문맥(읽기 지침·운영 매뉴얼)도 함께 포함된다.
- */
-export async function fetchComprehensiveProfileContext(): Promise {
- const token = getBlobToken();
- if (!token) return "";
-
- const basePrefix = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, "");
- const selected = sortVaultReadmeFirst(
- await resolveSkillBlobs(RETRIEVAL_SKILLS, token, basePrefix, 8),
- );
- if (selected.length === 0) return "";
- return buildContextFromBlobs(selected, token);
-}
diff --git a/src/lib/ask-hansol/vault-context.ts b/src/lib/ask-hansol/vault-context.ts
new file mode 100644
index 0000000..d342455
--- /dev/null
+++ b/src/lib/ask-hansol/vault-context.ts
@@ -0,0 +1,122 @@
+import { readOneDriveText } from "@/lib/content/onedrive-text";
+
+/**
+ * Ask Hansol - vault 문맥 구성 공용 모듈. vault 원본은 개인 OneDrive `hsol-info-blob/vault/`.
+ * 대화형 Ask(`/api/ask-hansol`), JD 적합도 분석(`/api/ask-hansol-jd`), 자문(`/api/ask-hansol-advice`)이 함께 쓴다.
+ * 방문자 답변에는 vault/저장소/조회 과정을 드러내지 않는다는 정책은 호출부 프롬프트에서 강제한다.
+ */
+
+export type RetrievalSkill = {
+ id: string;
+ keywords: string[];
+ paths: string[];
+};
+
+/** 모든 Ask 요청에 항상 포함하는 기본 문맥(읽기 지침 + 운영 매뉴얼). */
+export const BASE_CONTEXT_PATHS = [
+ "vault/README.md",
+ "vault/object-views/AI-클론-운영-매뉴얼.md",
+];
+
+export const VAULT_CONTEXT_MAX_CHARS = Number(
+ process.env.ASK_HANSOL_VAULT_CONTEXT_MAX_CHARS ?? process.env.ASK_HANSOL_BLOB_CONTEXT_MAX_CHARS ?? 12_000,
+);
+
+export const RETRIEVAL_SKILLS: RetrievalSkill[] = [
+ {
+ id: "persona-core",
+ keywords: ["페르소나", "성격", "정체성", "톤", "말투", "persona"],
+ paths: ["vault/objects/concepts/임한솔-persona.md"],
+ },
+ {
+ id: "profile-career",
+ keywords: ["경력", "커리어", "이력", "경험", "요약", "career"],
+ paths: ["vault/objects/people/임한솔.md"],
+ },
+ {
+ id: "writing-content",
+ keywords: ["블로그", "글", "아카이브", "콘텐츠", "작문", "writing"],
+ paths: ["vault/objects/concepts/임한솔-writing-style.md"],
+ },
+];
+
+export function keywordTokens(text: string): string[] {
+ return text
+ .toLowerCase()
+ .replace(/[?!.,]/g, " ")
+ .split(/\s+/)
+ .map((token) =>
+ token.replace(/(은|는|이|가|을|를|요|나요|까요|인가요|입니다|있나요|하나요)$/g, ""),
+ )
+ .filter((token) => token.length >= 2);
+}
+
+function contextLimit(): number {
+ return Number.isFinite(VAULT_CONTEXT_MAX_CHARS) && VAULT_CONTEXT_MAX_CHARS > 0
+ ? VAULT_CONTEXT_MAX_CHARS
+ : 12_000;
+}
+
+const isVaultReadme = (p: string) => /(^|\/)vault\/README\.md$/i.test(p);
+
+export function sortVaultReadmeFirst(paths: string[]): string[] {
+ return [...paths].sort((a, b) => {
+ const ar = isVaultReadme(a) ? 0 : 1;
+ const br = isVaultReadme(b) ? 0 : 1;
+ if (ar !== br) return ar - br;
+ return a.localeCompare(b);
+ });
+}
+
+/** vault/README.md - vault 를 찾고 읽는 절차와 규칙용. 사실 근거 문서가 아님. */
+export async function fetchVaultReadmeGuideBody(): Promise {
+ const text = await readOneDriveText("vault/README.md");
+ return text ? text.slice(0, contextLimit()) : null;
+}
+
+function pickRetrievalSkills(query: string): RetrievalSkill[] {
+ const tokens = keywordTokens(query);
+ const matched = RETRIEVAL_SKILLS.filter((skill) => {
+ const skillTokenSet = skill.keywords.flatMap((k) => keywordTokens(k));
+ if (skillTokenSet.length === 0 || tokens.length === 0) return false;
+ const overlap = tokens.filter((token) => skillTokenSet.includes(token)).length;
+ return overlap >= 1;
+ });
+
+ if (matched.length > 0) return matched.slice(0, 2);
+ // 기본 스킬: 일반 질문에도 핵심 프로필은 항상 참조 가능
+ return RETRIEVAL_SKILLS.filter((s) => s.id === "profile-career");
+}
+
+function selectPaths(skills: RetrievalSkill[], maxDocs: number): string[] {
+ const ordered = [...BASE_CONTEXT_PATHS, ...skills.flatMap((s) => s.paths)];
+ return sortVaultReadmeFirst([...new Set(ordered)].slice(0, maxDocs));
+}
+
+async function buildContext(paths: string[]): Promise {
+ const limit = contextLimit();
+ const chunks = await Promise.all(
+ paths.map(async (p) => {
+ const text = await readOneDriveText(p);
+ if (!text) return null;
+ const header = isVaultReadme(p)
+ ? `### ${p} (vault 읽기 지침 - 사실, 인물, 경력 근거로 쓰지 말 것)`
+ : `### ${p}`;
+ return `${header}\n${text.slice(0, limit)}`;
+ }),
+ );
+ return chunks.filter(Boolean).join("\n\n");
+}
+
+/** 대화형 Ask용: 질문 키워드로 스킬을 골라 관련 문서만 끌어온다. */
+export async function fetchVaultContext(query: string): Promise {
+ return buildContext(selectPaths(pickRetrievalSkills(query), 4));
+}
+
+/**
+ * JD 적합도 분석, 자문용: 키워드와 무관하게 페르소나, 경력, 작문 등 핵심 프로필 문서를
+ * 종합적으로 끌어온다. 기본 문맥(읽기 지침, 운영 매뉴얼)도 함께 포함된다.
+ */
+export async function fetchComprehensiveProfileContext(): Promise {
+ return buildContext(selectPaths(RETRIEVAL_SKILLS, 8));
+}
diff --git a/src/lib/content/blob.ts b/src/lib/content/blob.ts
deleted file mode 100644
index 69a782d..0000000
--- a/src/lib/content/blob.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { list } from "@vercel/blob";
-
-/**
- * Vercel Blob 공통 헬퍼. site-data·원페이저 등 vault 산출물을 같은 store 에서 읽는다.
- */
-
-export function getBlobToken(): string | null {
- return (
- process.env.ASK_HANSOL_BLOB_TOKEN ??
- process.env.BLOB_READ_WRITE_TOKEN ??
- process.env.BLOB_READ_TOKEN ??
- null
- );
-}
-
-/** Blob 경로 접두사(기본 "info"). 앞뒤 슬래시 제거. */
-export function getBlobPrefix(): string {
- return (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, "");
-}
-
-/**
- * `/` 와 `` 후보로 Blob 에서 파일 URL 을 해석한다.
- * 정확히 일치하는 pathname 우선, 없으면 첫 결과. 못 찾으면 null.
- */
-export async function resolveBlobUrl(
- token: string,
- basePrefix: string,
- relPath: string,
-): Promise {
- const pathCandidates = [`${basePrefix}/${relPath}`.replace(/\/+/g, "/"), relPath];
-
- for (const fullPath of pathCandidates) {
- const page = await list({ prefix: fullPath, token, limit: 20 }).catch(() => null);
- if (!page) continue;
- const exact = page.blobs.find((blob) => blob.pathname === fullPath);
- const picked = exact ?? page.blobs[0];
- if (picked) return picked.url;
- }
-
- return null;
-}
diff --git a/src/lib/content/onedrive-text.ts b/src/lib/content/onedrive-text.ts
new file mode 100644
index 0000000..295bc97
--- /dev/null
+++ b/src/lib/content/onedrive-text.ts
@@ -0,0 +1,29 @@
+import { unstable_cache } from "next/cache";
+import { isOneDriveConfigured, readText } from "@/lib/onedrive/graph";
+
+/**
+ * 사이트가 읽는 OneDrive 텍스트(site-data, 원페이저, Ask Hansol 문맥)를 Next 데이터 캐시에 얹는다.
+ * Graph 조회는 콜드 스타트에 1~3초 걸려서, 인스턴스 사이에서 공유되는 캐시(5분 재검증, stale-while-revalidate)
+ * 가 페이지 응답 시간을 지킨다. 파일을 고치면 늦어도 약 5분 뒤 반영된다.
+ */
+const REVALIDATE_SECONDS = Number(process.env.ONEDRIVE_TEXT_REVALIDATE_SECONDS ?? 300);
+
+const cachedRead = unstable_cache(async (relPath: string) => readText(relPath), ["onedrive-text-v1"], {
+ revalidate: REVALIDATE_SECONDS,
+ tags: ["onedrive-text"],
+});
+
+/** OneDrive `hsol-info-blob/` 텍스트. 설정이 없거나 실패하면 null. */
+export async function readOneDriveText(relPath: string): Promise {
+ if (!isOneDriveConfigured()) return null;
+ try {
+ return await cachedRead(relPath);
+ } catch {
+ // Next 요청 컨텍스트 밖(스크립트)이면 데이터 캐시를 못 쓴다. 인메모리 캐시만 쓰는 직접 읽기로.
+ try {
+ return await readText(relPath);
+ } catch {
+ return null;
+ }
+ }
+}
diff --git a/src/lib/content/onepager.ts b/src/lib/content/onepager.ts
index 6d8b483..e81259c 100644
--- a/src/lib/content/onepager.ts
+++ b/src/lib/content/onepager.ts
@@ -1,34 +1,23 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
-import { getBlobPrefix, getBlobToken, resolveBlobUrl } from "@/lib/content/blob";
+import { readOneDriveText } from "@/lib/content/onedrive-text";
export { wrapOnePagerHtml } from "@/lib/content/onepager-html";
/**
- * 원페이저 HTML 조각 로더. getSiteData 와 같은 폴백 체인: Blob -> 로컬 vault -> null.
+ * 원페이저 HTML 조각 로더. getSiteData 와 같은 폴백 체인: OneDrive -> 로컬 vault -> null.
* site-data 와 분리된 별도 아티팩트(vault/object-views/onepager-ko.html).
*/
-const ONEPAGER_BLOB_PATH = "vault/object-views/onepager-ko.html";
+const ONEPAGER_ONEDRIVE_PATH = "vault/object-views/onepager-ko.html";
const ONEPAGER_LOCAL_PATH = "hsol-info-blob/vault/object-views/onepager-ko.html";
const CACHE_TTL_MS = Number(process.env.ONEPAGER_CACHE_TTL_MS ?? 5 * 60 * 1000);
let cached: { html: string | null; expiresAt: number } | null = null;
let inflight: Promise | null = null;
-async function fetchFromBlob(): Promise {
- const token = getBlobToken();
- if (!token) return null;
- const url = await resolveBlobUrl(token, getBlobPrefix(), ONEPAGER_BLOB_PATH);
- if (!url) return null;
- // no-store: 가변 Blob 을 force-cache 로 읽으면 Next 데이터 캐시에 고정돼 갱신이 안 보인다.
- // 아래 인메모리 캐시(CACHE_TTL_MS)가 호출량을 억제한다. (site-data 로더와 동일한 이유)
- const response = await fetch(url, {
- headers: { Authorization: `Bearer ${token}` },
- cache: "no-store",
- }).catch(() => null);
- if (!response || !response.ok) return null;
- const text = await response.text();
- return text.trim() ? text : null;
+async function fetchFromOneDrive(): Promise {
+ const text = await readOneDriveText(ONEPAGER_ONEDRIVE_PATH);
+ return text?.trim() ? text : null;
}
async function fetchFromLocalVault(): Promise {
@@ -47,7 +36,7 @@ export async function getOnePagerHtml(): Promise {
if (cached && now < cached.expiresAt) return cached.html;
if (inflight) return inflight;
- inflight = (async () => (await fetchFromBlob()) ?? (await fetchFromLocalVault()))()
+ inflight = (async () => (await fetchFromOneDrive()) ?? (await fetchFromLocalVault()))()
.then((html) => {
cached = { html, expiresAt: Date.now() + CACHE_TTL_MS };
return html;
diff --git a/src/lib/content/site-data.ts b/src/lib/content/site-data.ts
index d7a99c4..e489e70 100644
--- a/src/lib/content/site-data.ts
+++ b/src/lib/content/site-data.ts
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { HSOL_DATA } from "@/data/site";
import { siteDataSchema, type SiteData } from "@/content/schema";
-import { getBlobPrefix, getBlobToken, resolveBlobUrl } from "@/lib/content/blob";
+import { readOneDriveText } from "@/lib/content/onedrive-text";
const SITE_DATA_PATH = "vault/object-views/site-data.json";
const CACHE_TTL_MS = Number(process.env.SITE_DATA_CACHE_TTL_MS ?? 5 * 60 * 1000);
@@ -10,32 +10,11 @@ const CACHE_TTL_MS = Number(process.env.SITE_DATA_CACHE_TTL_MS ?? 5 * 60 * 1000)
let cached: { data: SiteData; expiresAt: number } | null = null;
let inflight: Promise | null = null;
-async function fetchSiteDataFromBlob(): Promise {
- const token = getBlobToken();
- if (!token) {
- throw new Error("Blob token is missing. Set BLOB_READ_WRITE_TOKEN or BLOB_READ_TOKEN.");
- }
-
- const basePrefix = getBlobPrefix();
- const blobUrl = await resolveBlobUrl(token, basePrefix, SITE_DATA_PATH);
- if (!blobUrl) {
- throw new Error(`Blob file not found: ${SITE_DATA_PATH}`);
- }
-
- // no-store: 가변 private Blob 을 force-cache 로 읽으면 Next 데이터 캐시에 무기한 고정돼
- // 콘텐츠 갱신(refresh)이 배포보다 늦게 도착하면 옛 데이터가 계속 서빙된다. 아래 인메모리
- // 캐시(CACHE_TTL_MS)가 호출량을 억제하므로, 원본 fetch 는 항상 신선하게 가져온다.
- const response = await fetch(blobUrl, {
- headers: { Authorization: `Bearer ${token}` },
- cache: "no-store",
- }).catch(() => null);
-
- if (!response || !response.ok) {
- throw new Error(`Failed to fetch blob site data: ${blobUrl}`);
- }
-
- const json = await response.json();
- return siteDataSchema.parse(json);
+/** 정본: 개인 OneDrive `hsol-info-blob/vault/object-views/site-data.json` (CI 가 생성해 올린다). */
+async function fetchSiteDataFromOneDrive(): Promise {
+ const text = await readOneDriveText(SITE_DATA_PATH);
+ if (!text) throw new Error(`OneDrive file not found: ${SITE_DATA_PATH}`);
+ return siteDataSchema.parse(JSON.parse(text));
}
async function fetchSiteDataFromLocalVault(): Promise {
@@ -44,7 +23,7 @@ async function fetchSiteDataFromLocalVault(): Promise {
return siteDataSchema.parse(JSON.parse(raw));
}
-/** Committed baseline (`src/data/site.ts`) — used when Blob + local vault are unavailable (e.g. Vercel CI without submodule). */
+/** Committed baseline (`src/data/site.ts`) - OneDrive 와 로컬 vault 를 모두 못 읽을 때. */
function fetchSiteDataFromBundled(): SiteData {
return HSOL_DATA;
}
@@ -55,7 +34,7 @@ export async function getSiteData(): Promise {
if (inflight) return inflight;
- inflight = fetchSiteDataFromBlob()
+ inflight = fetchSiteDataFromOneDrive()
.catch(async () => fetchSiteDataFromLocalVault())
.catch(async () => fetchSiteDataFromBundled())
.then((data) => {
diff --git a/src/lib/onedrive/graph.ts b/src/lib/onedrive/graph.ts
new file mode 100644
index 0000000..fd125a4
--- /dev/null
+++ b/src/lib/onedrive/graph.ts
@@ -0,0 +1,205 @@
+import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
+import { neon } from "@neondatabase/serverless";
+
+/**
+ * 개인 OneDrive(Microsoft Graph) 읽기 클라이언트. hsol.info 콘텐츠의 정본이자 유일한 저장소다.
+ *
+ * 경로는 OneDrive `hsol-info-blob/` 기준 상대경로("vault/object-views/site-data.json", "assets/public/..").
+ * 토큰은 Neon `onedrive_token` 행(ops/scripts/lib/onedrive-token.mjs 와 같은 형식, AES-256-GCM)을
+ * CI 와 같이 쓴다. 액세스 토큰이 만료되면 여기서 리프레시해 새 토큰을 되쓴다.
+ *
+ * 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID, ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET
+ * (선택) ONEDRIVE_ROOT(기본 hsol-info-blob)
+ */
+
+const GRAPH = "https://graph.microsoft.com/v1.0";
+const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
+const TOKEN_ROW_ID = "personal";
+const EARLY_REFRESH_MS = 3 * 60 * 1000;
+const TEXT_CACHE_TTL_MS = Number(process.env.ONEDRIVE_TEXT_CACHE_TTL_MS ?? 5 * 60 * 1000);
+
+type StoredToken = { access_token: string; token_type?: string; refresh_token: string; expiry: string };
+
+export type DriveItem = {
+ id: string;
+ name: string;
+ size: number;
+ eTag?: string;
+ lastModifiedDateTime?: string;
+ file?: { mimeType?: string };
+ folder?: unknown;
+ "@microsoft.graph.downloadUrl"?: string;
+};
+
+let memToken: { accessToken: string; expiresAt: number } | null = null;
+let inflightToken: Promise | null = null;
+const textCache = new Map();
+
+function env(name: string): string {
+ const v = process.env[name]?.trim();
+ if (!v) throw new Error(`${name} 미설정`);
+ return v;
+}
+
+function databaseUrl(): string {
+ return (process.env.DATABASE_URL || process.env.POSTGRES_URL || "").trim();
+}
+
+export function isOneDriveConfigured(): boolean {
+ const names = ["ONEDRIVE_TOKEN_KEY", "ONEDRIVE_DRIVE_ID", "ONEDRIVE_CLIENT_ID", "ONEDRIVE_CLIENT_SECRET"];
+ return names.every((n) => Boolean(process.env[n]?.trim())) && Boolean(databaseUrl());
+}
+
+function tokenKey(): Buffer {
+ const key = Buffer.from(env("ONEDRIVE_TOKEN_KEY"), "base64");
+ if (key.length !== 32) throw new Error("ONEDRIVE_TOKEN_KEY 는 32바이트 base64 여야 한다.");
+ return key;
+}
+
+function sql() {
+ const url = databaseUrl();
+ if (!url) throw new Error("DATABASE_URL 미설정");
+ return neon(url);
+}
+
+function encrypt(key: Buffer, plaintext: string): string {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
+ return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64");
+}
+
+function decrypt(key: Buffer, payloadB64: string): string {
+ const buf = Buffer.from(payloadB64, "base64");
+ const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
+ decipher.setAuthTag(buf.subarray(12, 28));
+ return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8");
+}
+
+async function loadStoredToken(): Promise {
+ const rows = (await sql()`SELECT payload FROM onedrive_token WHERE id = ${TOKEN_ROW_ID}`) as {
+ payload: string;
+ }[];
+ if (!rows[0]) throw new Error("Neon onedrive_token 에 토큰이 없다.");
+ return JSON.parse(decrypt(tokenKey(), rows[0].payload)) as StoredToken;
+}
+
+async function saveStoredToken(token: StoredToken): Promise {
+ const payload = encrypt(tokenKey(), JSON.stringify(token));
+ await sql()`INSERT INTO onedrive_token (id, payload, expires_at, updated_at)
+ VALUES (${TOKEN_ROW_ID}, ${payload}, ${token.expiry}, now())
+ ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at, updated_at = now()`;
+}
+
+async function refreshToken(stored: StoredToken): Promise {
+ const body = new URLSearchParams({
+ client_id: env("ONEDRIVE_CLIENT_ID"),
+ client_secret: env("ONEDRIVE_CLIENT_SECRET"),
+ grant_type: "refresh_token",
+ refresh_token: stored.refresh_token,
+ });
+ const res = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body,
+ cache: "no-store",
+ });
+ const json = (await res.json().catch(() => ({}))) as {
+ access_token?: string;
+ refresh_token?: string;
+ expires_in?: number;
+ token_type?: string;
+ error?: string;
+ };
+ if (!res.ok || !json.access_token) {
+ throw new Error(`OneDrive 토큰 리프레시 실패: HTTP ${res.status} ${json.error ?? ""}`.trim());
+ }
+ const next: StoredToken = {
+ access_token: json.access_token,
+ token_type: json.token_type ?? "Bearer",
+ refresh_token: json.refresh_token ?? stored.refresh_token,
+ expiry: new Date(Date.now() + (json.expires_in ?? 3600) * 1000).toISOString(),
+ };
+ await saveStoredToken(next);
+ return next;
+}
+
+async function getAccessToken(force = false): Promise {
+ const now = Date.now();
+ if (!force && memToken && memToken.expiresAt - EARLY_REFRESH_MS > now) return memToken.accessToken;
+ if (inflightToken) return inflightToken;
+
+ inflightToken = (async () => {
+ let stored = await loadStoredToken();
+ const exp = Date.parse(stored.expiry);
+ if (force || !Number.isFinite(exp) || exp - EARLY_REFRESH_MS <= Date.now()) {
+ stored = await refreshToken(stored);
+ }
+ memToken = { accessToken: stored.access_token, expiresAt: Date.parse(stored.expiry) };
+ return stored.access_token;
+ })().finally(() => {
+ inflightToken = null;
+ });
+ return inflightToken;
+}
+
+/** 정비용: 저장된 리프레시 토큰으로 즉시 갱신해 Neon 에 되쓴다. */
+export async function refreshOneDriveToken(): Promise {
+ await getAccessToken(true);
+}
+
+function itemUrl(relPath: string): string {
+ const root = (process.env.ONEDRIVE_ROOT || "hsol-info-blob").replace(/^\/+|\/+$/g, "");
+ const full = [root, ...relPath.split("/")].filter(Boolean).map(encodeURIComponent).join("/");
+ return `${GRAPH}/drives/${encodeURIComponent(env("ONEDRIVE_DRIVE_ID"))}/root:/${full}`;
+}
+
+/** Graph 호출. 401 이면 토큰을 한 번 강제 갱신해 다시 시도한다. */
+async function graphFetch(url: string): Promise {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const token = await getAccessToken(attempt > 0);
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" });
+ if (res.status !== 401) return res;
+ }
+ throw new Error("OneDrive 인증 실패(401)");
+}
+
+/** 한글 파일명은 올린 경로에 따라 NFC/NFD 가 섞여 있을 수 있어 둘 다 시도한다. */
+function pathVariants(relPath: string): string[] {
+ const nfc = relPath.normalize("NFC");
+ const nfd = relPath.normalize("NFD");
+ return nfc === nfd ? [nfc] : [nfc, nfd];
+}
+
+/** 파일 메타데이터(다운로드 URL 포함). 없거나 폴더면 null. */
+export async function getItem(relPath: string): Promise {
+ for (const p of pathVariants(relPath)) {
+ const res = await graphFetch(itemUrl(p));
+ if (res.status === 404) continue;
+ if (!res.ok) throw new Error(`OneDrive 조회 실패 ${relPath}: HTTP ${res.status}`);
+ const item = (await res.json()) as DriveItem;
+ return item.folder ? null : item;
+ }
+ return null;
+}
+
+/** 파일 내용을 바이트로. 없으면 null. */
+export async function readBytes(relPath: string): Promise {
+ const item = await getItem(relPath);
+ const url = item?.["@microsoft.graph.downloadUrl"];
+ if (!url) return null;
+ const res = await fetch(url, { cache: "no-store" });
+ if (!res.ok) throw new Error(`OneDrive 다운로드 실패 ${relPath}: HTTP ${res.status}`);
+ return res.arrayBuffer();
+}
+
+/** 텍스트 파일 읽기. 인스턴스 메모리에 5분 캐시(오류는 캐시하지 않는다). 없으면 null. */
+export async function readText(relPath: string): Promise {
+ const now = Date.now();
+ const hit = textCache.get(relPath);
+ if (hit && hit.expiresAt > now) return hit.text;
+ const buf = await readBytes(relPath);
+ const text = buf ? new TextDecoder("utf-8").decode(buf) : null;
+ textCache.set(relPath, { text, expiresAt: now + TEXT_CACHE_TTL_MS });
+ return text;
+}
diff --git a/tsconfig.json b/tsconfig.json
index fba2bf3..29d76a0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,5 +17,5 @@
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "hsol-info-blob", "generated"]
}