From 74a5d4949cfb00498e030e8fbf1d88fc1a7e7a33 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 15 Oct 2026 10:13:32 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20anima-extract=20=E6=94=AF=E6=8F=B4=20wik?= =?UTF-8?q?i=20URL=20=E8=88=87=E8=87=AA=E7=84=B6=E8=AA=9E=E8=A8=80?= =?UTF-8?q?=E5=8F=83=E6=95=B8=EF=BC=8C=E4=B8=A6=E4=BE=9D=E5=BA=8F=E9=80=90?= =?UTF-8?q?=E7=AB=A0=E8=99=95=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- hooks/extract.test.ts | 58 ++++++++++++++++++++++++++++++++++++ hooks/extract.ts | 47 +++++++++++++++++++++++++++++ hooks/extractCommand.ts | 66 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 167 insertions(+), 4 deletions(-) diff --git a/hooks/extract.test.ts b/hooks/extract.test.ts index 0864c5e..b3487ef 100644 --- a/hooks/extract.test.ts +++ b/hooks/extract.test.ts @@ -1,5 +1,6 @@ import { expect, mock, test } from 'claude-code/testing' +import { parseWikiPages, parseWikiSource } from './extract' import type { Extracted } from './extract' const char = (over: Record = {}, tone = '溫柔'): Extracted['characters'][number] => @@ -21,6 +22,7 @@ let cloneMissing = false let registered: string[] = [] const probed = new Set() let sessionCwd = '/repo' +const web = new Map() const setup = (on: never, answers: string[], files = new Map()): Setup => { const s: Setup = { files, cmds: [], prompts: [] } const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void @@ -39,6 +41,10 @@ const setup = (on: never, answers: string[], files = new Map()): o('process.run', ((_$: unknown, e: { argv: string[]; }) => { s.cmds.push(e.argv) const j = e.argv.join(' ') + if (e.argv[0] === 'curl') { + const body = web.get(e.argv[e.argv.length - 1]!) + return { value: { exitCode: body === undefined ? 22 : 0, stdout: body ?? '', stderr: '' } } + } const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' : j.startsWith('tea logins') ? 'admin https://gitea.jsc.idv.tw u' : j.startsWith('tea pulls') ? 'PR#1' : '' return { value: { exitCode: 0, stdout: out, stderr: '' } } }) as never) @@ -182,3 +188,55 @@ test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 { await $.session.start({ source: 'startup', cwd: '/anywhere' } as never) expect(registered).toContain('ts-aoi') }) + +const WIKI = 'https://gitea.jsc.idv.tw/jiantw83/sao/wiki' +const listHtml = ['Home', '第10章', '第2章', '第1章', '_pages'] + .map(n => `${n}`) + .join('') + 'reporev' + +test('parseWikiSource:從自然語言中取出 wiki URL,頁面清單與單頁分開判斷', () => { + const src = parseWikiSource(`請幫我依照順序逐章處理 ${WIKI}/?action=_pages 謝謝`) + expect(src).toMatchObject({ kind: 'list', base: WIKI }) + expect(parseWikiSource(`處理 ${WIKI}/第2章。`)).toMatchObject({ kind: 'page', page: '第2章', base: WIKI }) + expect(parseWikiSource('ch1.txt')).toBeUndefined() + expect(parseWikiSource('請處理 ch 1.txt')).toBeUndefined() +}) + +test('parseWikiPages:略過 Home/_pages/子路徑,並依章節數字自然排序', () => { + expect(parseWikiPages(listHtml, WIKI)).toEqual(['第1章', '第2章', '第10章']) +}) + +test('anima-extract 帶 URL 與自然語言:不當檔案路徑,依序逐章處理', async ($, on) => { + web.clear() + web.set(`${WIKI}/?action=_pages`, listHtml) + for (const n of ['第1章', '第2章', '第10章']) web.set(`${WIKI}/raw/${encodeURIComponent(n)}`, `${n}內容`) + const s = setup(on as never, [reply(char()), reply(char()), reply(char())]) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + const t1 = await run($ as never, `請幫我依照順序逐章處理 ${WIKI}/?action=_pages`) + expect(t1).not.toContain('找不到檔案') + expect(t1).toContain('共 3 章:第1章、第2章、第10章') + expect(t1).toContain('章節「第1章」') + expect(s.prompts[0]).toContain('第1章內容') + await run($ as never, 'confirm') + const t3 = await run($ as never, 'confirm') + expect(t3).toContain('已建立 PR') + expect(t3).toContain('剩餘 1 章') + expect(t3).toContain('章節「第2章」') + expect(s.prompts[1]).toContain('第2章內容') + await run($ as never, 'confirm') + const t5 = await run($ as never, 'confirm') + expect(t5).toContain('章節「第10章」') + await run($ as never, 'confirm') + const t7 = await run($ as never, 'confirm') + expect(t7).toContain('已建立 PR') + expect(t7).not.toContain('接著處理') +}) + +test('wiki 章節讀不到時回報原因與剩餘章數;cancel 清除佇列', async ($, on) => { + web.clear() + web.set(`${WIKI}/?action=_pages`, listHtml) + setup(on as never, []) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + expect(await run($ as never, `${WIKI}/?action=_pages`)).toContain('無法讀取 wiki 章節「第1章」') + expect(await run($ as never, 'cancel')).toContain('已取消') +}) diff --git a/hooks/extract.ts b/hooks/extract.ts index 39ad990..6bc7905 100644 --- a/hooks/extract.ts +++ b/hooks/extract.ts @@ -228,3 +228,50 @@ export const parseRemote = (url: string): { host: string; repo: string } | undef const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/) return m ? { host: m[1]!, repo: m[2]! } : undefined } + +export type WikiSource = { kind: 'list' | 'page'; url: string; origin: string; base: string; page?: string } + +// 從自然語言參數中取出第一個 URL;Gitea wiki 頁面清單(?action=_pages 或 /wiki 首頁)視為整本逐章,單頁網址視為一章。 +export const parseWikiSource = (args: string): WikiSource | undefined => { + const raw = /https?:\/\/[^\s<>"'「」『』()()]+/.exec(args)?.[0]?.replace(/[,。、;,.;:!?!?]+$/, '') + if (!raw) return undefined + let u: URL + try { + u = new URL(raw) + } catch { + return undefined + } + const m = /^(.*\/wiki)(?:\/(.*))?$/.exec(u.pathname.replace(/\/+$/, '')) + if (!m) return { kind: 'page', url: raw, origin: u.origin, base: raw } + const base = `${u.origin}${m[1]}` + const page = m[2] && !m[2].startsWith('raw/') && m[2] !== '_pages' ? decodeURIComponent(m[2]) : undefined + if (u.searchParams.get('action') === '_pages' || !page) return { kind: 'list', url: raw, origin: u.origin, base } + return { kind: 'page', url: raw, origin: u.origin, base, page } +} + +const SKIP_PAGES = new Set(['home', 'sidebar', 'footer', '_sidebar', '_footer', '_pages', 'new']) + +// 解析 Gitea wiki 頁面清單 HTML,依章節名稱自然排序(第2章 < 第10章)。 +export const parseWikiPages = (html: string, base: string): string[] => { + const path = new URL(base).pathname + const names = new Set() + for (const m of html.matchAll(/href="([^"#]+)"/g)) { + let p: string + try { + p = new URL(m[1]!.replace(/&/g, '&'), base).pathname + } catch { + continue + } + if (!p.startsWith(`${path}/`)) continue + const rest = p.slice(path.length + 1) + if (!rest || rest.includes('/')) continue + let name: string + try { + name = decodeURIComponent(rest) + } catch { + continue + } + if (!SKIP_PAGES.has(name.toLowerCase())) names.add(name) + } + return [...names].sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true })) +} diff --git a/hooks/extractCommand.ts b/hooks/extractCommand.ts index 4903d50..bfef8ba 100644 --- a/hooks/extractCommand.ts +++ b/hooks/extractCommand.ts @@ -1,14 +1,15 @@ import type { Engine } from 'claude-code' import { teaToken } from './wiki' -import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract' +import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, parseWikiPages, parseWikiSource, slugify, validateCharacter } from './extract' import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract' export const EXTRACT_COMMAND = 'anima-extract' // PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。 export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' const PENDING_KEY = 'extract:pending' -const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>(或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。` +const QUEUE_KEY = 'extract:queue' +const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑或 wiki 網址>(網址為 Gitea wiki 頁面清單時依序逐章處理,前後可加說明文字;或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。` type Plan = { files: { path: string; json: unknown }[] @@ -18,6 +19,8 @@ type Plan = { workName: string } +type Queue = { base: string; pages: string[] } + type Pending = { phase: 'slug' | 'summary' chapter: string @@ -75,6 +78,57 @@ async function syncRepo($: Engine): Promise { } } +async function giteaToken($: Engine, host: string): Promise { + const home = (await $.env.get('HOME')) ?? '' + try { + const t = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), host) + if (t) return t + } catch {} + return (await $.env.get('GITEA_TOKEN')) || undefined +} + +async function curl($: Engine, url: string): Promise { + const token = await giteaToken($, new URL(url).host) + const r = await $.process.run(['curl', '-fsSL', ...(token ? ['-H', `Authorization: token ${token}`] : []), url], { timeoutMs: 60000 }).catch(() => undefined) + return r && r.exitCode === 0 ? String(r.stdout) : undefined +} + +const rawUrl = (base: string, page: string) => `${base}/raw/${encodeURIComponent(page)}` + +async function stageWiki($: Engine, base: string, page: string, rest: string[]): Promise { + const text = await curl($, rawUrl(base, page)) + if (!text?.trim()) return `無法讀取 wiki 章節「${page}」(${rawUrl(base, page)})。${rest.length ? `剩餘 ${rest.length} 章未處理。` : ''}` + await $.store.set(QUEUE_KEY, { base, pages: rest } satisfies Queue) + return stage($, page, text) +} + +async function stageSource($: Engine, args: string, src: NonNullable>): Promise { + if (src.kind === 'page') { + if (src.page) return stageWiki($, src.base, src.page, []) + const text = await curl($, src.url) + if (!text?.trim()) return `無法讀取網址:${src.url}` + return stage($, decodeURIComponent(src.url.split('/').pop() || 'chapter'), text) + } + const html = await curl($, `${src.base}/?action=_pages`) + if (!html) return `無法讀取 wiki 頁面清單:${src.base}/?action=_pages` + const pages = parseWikiPages(html, src.base) + if (!pages.length) return `wiki 頁面清單中找不到章節:${src.url}` + const [first, ...rest] = pages + const out = await stageWiki($, src.base, first!, rest) + return `依序逐章處理,共 ${pages.length} 章:${pages.join('、')}\n\n${out}` +} + +// 目前這章處理完(PR 已建立)後,接著萃取佇列中的下一章。 +async function nextChapter($: Engine, done: string): Promise { + const q = (await $.store.get(QUEUE_KEY)) as Queue | undefined + if (!q?.pages.length) { + await $.store.delete(QUEUE_KEY) + return done + } + const [next, ...rest] = q.pages + return `${done}\n\n接著處理下一章(剩餘 ${rest.length} 章):\n${await stageWiki($, q.base, next!, rest)}` +} + async function stageFile($: Engine, path: string): Promise { const cwd = await $.session.cwd().catch(() => '') const full = path.startsWith('/') ? path : `${cwd}/${path}` @@ -208,6 +262,7 @@ export async function runExtract($: Engine, args: string): Promise { if (head === 'cancel') { await $.store.delete(PENDING_KEY) + await $.store.delete(QUEUE_KEY) return '已取消,沒有寫入任何檔案。' } if (head === 'paste') { @@ -218,7 +273,10 @@ export async function runExtract($: Engine, args: string): Promise { if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。` return stage($, name, text) } - if (head !== 'confirm') return stageFile($, args.trim()) + if (head !== 'confirm') { + const src = parseWikiSource(args) + return src ? stageSource($, args, src) : stageFile($, args.trim()) + } const p = (await $.store.get(PENDING_KEY)) as Pending | undefined if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。` @@ -245,5 +303,5 @@ export async function runExtract($: Engine, args: string): Promise { const plan = p.plan! await $.store.delete(PENDING_KEY) - return openPr($, p, plan) + return nextChapter($, await openPr($, p, plan)) }