From 74fb93652c338fb3979ae52e2932c3b0eca6c777 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 8 Sep 2026 14:37:40 +0800 Subject: [PATCH 1/4] fix: find the app tab by its context instead of a stored id A stored tab id says the tab exists, not that it is still the app. Navigating away inside it made the toolbar icon a no-op with no explanation. runtime.getContexts answers the real question and needs no permission, unlike Tab.url, which would need "tabs" and its browsing-history warning. Costs minimum_chrome_version 114 -> 116. --- CHANGELOG.md | 4 ++++ PRODUCT.md | 2 +- README.md | 4 ++-- README.zh-Hant.md | 4 ++-- docs/design.md | 16 ++++++++++++++++ docs/how-it-works.md | 1 + scripts/smoke.mjs | 11 +++++++++++ src/entrypoints/background.ts | 33 +++++++++++++++++---------------- wxt.config.ts | 5 +++-- 9 files changed, 57 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e36f9..31bbb06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,10 @@ ### Fixed +- **工具列圖示不會再變成點不開。** App 分頁一旦被導去別的網站(表格裡每個帳號與影片都可以點開,很容易發生), + 圖示就只會把那個分頁叫到前面,**點幾次都開不出 App 而且不說為什麼**——它記的是分頁 id,不看那個分頁現在是什麼。 + 現在改用 `runtime.getContexts()` 現場問,被導走或關掉都會重新開一個。副作用:這支 API 要 Chrome 116, + **最低瀏覽器版本從 114 提到 116**(相差三個月,且瀏覽器自動更新)。 - **窄視窗下的勾選框與圖示按鈕點得到了。** 把分頁縮成半個螢幕(並排看 B 站是很自然的用法)之後, 勾選框只有 15×15、頂列的「重新檢查」只有 13×13,觸控筆電上幾乎點不中。現在這個寬度下撐到 24×24, 也就是 WCAG 2.2 AA 的下限;設定用的開關維持原樣。 diff --git a/PRODUCT.md b/PRODUCT.md index be1f197..4e64b33 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -42,7 +42,7 @@ Chrome MV3 擴充功能:WXT + React 19 + TypeScript,建置鏈與前身相同 ## Operating Context -- 執行環境:Chrome/Edge 114+,`chrome://extensions` 載入未封裝項目(是否上架商店未決定)。 +- 執行環境:Chrome/Edge 116+,`chrome://extensions` 載入未封裝項目(是否上架商店未決定)。 - 整個流程在擴充功能自己的分頁跑(App 分頁),service worker 只做 DNR header 規則、開分頁、影片頁「智慧收藏」的單支往返。 - 資料來源全部是 B 站公開 API(`api.bilibili.com`,由 `declarativeNetRequest` 補 Referer/Origin)與使用者自己填的 OpenAI 相容端點(只申請那一個 origin 的 optional host permission)。收藏夾端點見 `docs/design.md` 2,關注端點見 `docs/design.md` 11。 diff --git a/README.md b/README.md index 6d3f574..73b2f87 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ can be undone.

CI License: MIT - Chrome 114+ + Chrome 116+

**English** · [繁體中文](README.zh-Hant.md) @@ -28,7 +28,7 @@ nobody is unfollowed until you press the button. ## Install -Requires Node.js 20+ and Chrome or Edge 114+. +Requires Node.js 20+ and Chrome or Edge 116+. ```bash npm install diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 6ede960..bf176eb 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -6,7 +6,7 @@

CI License: MIT - Chrome 114+ + Chrome 116+

[English](README.md) · **繁體中文** @@ -22,7 +22,7 @@ B 站資料,攤成一張每一列都帶著證據的審核表,讓你勾選、 ## 安裝 -需要 Node.js 20+ 與 Chrome 或 Edge 114+。 +需要 Node.js 20+ 與 Chrome 或 Edge 116+。 ```bash npm install diff --git a/docs/design.md b/docs/design.md index e8a6c91..0ea3a39 100644 --- a/docs/design.md +++ b/docs/design.md @@ -429,6 +429,22 @@ DOM 依實測(2026-08):`.video-toolbar-left-main` 底下每個功能是一 | `resource/clean`(一次清掉整夾失效內容) | [2](#2-bilibili-api-查證)、[6.3](#63-失效影片) | | 逐夾打 `folder/info` 取簡介與封面 | [2](#2-bilibili-api-查證)(`folder/created/list` 一個請求就有) | +### 9.5 用記下來的 tab id 找 App 分頁 + +點工具列圖示要「已經開著就聚焦、沒開就新建」,第一版把 `tabs.create()` 回傳的 id 存進 `storage.session`, +下次用 `tabs.get(id)` 確認分頁還在不在。**這個判斷是錯的**:使用者在 App 分頁裡打開 B 站(很自然—— +表格裡每個帳號與影片都可以點開)之後,那個分頁的 id 還在、`tabs.get()` 照樣成功,於是圖示只是把一個 +B 站分頁叫到前面,**點幾次都開不出 App,而且完全不說為什麼**。 + +要修就得知道那個分頁現在的網址,而 `Tab.url` 在 MV3 需要 `tabs` 權限或相符的 host permission +(`chrome-extension://` 不可能出現在 host permission 裡)。加 `tabs` 權限會在安裝時跳出「讀取瀏覽記錄」的警告, +和本專案刻意選 `declarativeNetRequestWithHostAccess` 的理由(§8)互相牴觸,為了一個單例判斷不值得。 + +改用 `runtime.getContexts({ contextTypes: ['TAB'] })`:它就是為了回答「本擴充功能現在有哪些 context、在哪個分頁」 +而存在的,**不需要任何權限**(只回報自己的 context),`documentUrl` 直接可比。代價是它要 Chrome 116 +(`minimum_chrome_version` 從 114 提上去;114 與 116 只差三個月,且瀏覽器自動更新)。 +session storage 那份 id 一併刪掉,不留 fallback。 + ## 10. 未解決 還沒驗證、還沒決定的事。做到相關區域時順手收掉。 diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 2b23215..fc833d6 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -31,6 +31,7 @@ 另一頁的主按鈕與寫入按鈕變灰,旁邊寫著「另一個任務還在跑——等它跑完,或到那一頁停止它」。 分頁之間切換不會打斷任務,切回來畫面接得上。目前在哪一頁寫在網址裡(`app.html#/follows`), 所以誤按上一頁(側鍵、`Alt+←`、觸控板兩指滑動)退回的是上一個分頁,不是離開整個工具。 +工具列圖示只會開**一個** App 分頁:已經開著就把它叫到前面,被你導去別的網站或關掉了就重新開一個。 --- diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 5aeef3a..a5c8d6f 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -229,6 +229,17 @@ try { await page.screenshot({ path: path.join(outDir, 'app.png'), fullPage: true }); check(pageErrors.length === 0, `無頁面錯誤${pageErrors.length ? `:${pageErrors.join(' | ')}` : ''}`); + // 工具列圖示的單例判斷(background.ts 的 openAppTab)。放在最後,因為第二條要把分頁導走。 + // 舊寫法記 tab id 再用 tabs.get 確認,分頁被導去別的網站時照樣成功——圖示於是永遠開不出 App。 + const appContexts = async () => + (await sw.evaluate(() => chrome.runtime.getContexts({ contextTypes: ['TAB'] }))).filter((c) => + c.documentUrl?.startsWith(`chrome-extension://${extId}/app.html`), + ); + check((await appContexts()).length === 1, 'getContexts 找得到 App 分頁'); + await page.goto('about:blank'); + check((await appContexts()).length === 0, '導去別的網址之後就不算 App 分頁了(圖示會開新的)'); + await page.goto(`chrome-extension://${extId}/app.html`); + if (process.env.SMOKE_KEEP) { console.log('SMOKE_KEEP 已設定,瀏覽器保持開啟;關閉視窗即結束。'); await new Promise((resolve) => context.on('close', resolve)); diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 1011361..470e92c 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,6 +1,5 @@ import { defineBackground } from 'wxt/utils/define-background'; import { browser } from 'wxt/browser'; -import { storage } from 'wxt/utils/storage'; import { quickFavMessageSchema, type QuickFavResult } from '@/core/messages'; import { applyFavorite, suggestFavorite } from '@/core/quickFav'; import { applyRateSettings } from '@/core/scheduler'; @@ -54,23 +53,25 @@ async function installHeaderRules(): Promise { } } -const appTabItem = storage.defineItem('session:appTabId', { fallback: null }); - -/** App 分頁單例:已開啟就聚焦,否則新建 */ +/** + * App 分頁單例:已開啟就聚焦,否則新建。 + * + * 用 `runtime.getContexts()` 現場問「我現在有哪些分頁」,而不是把 tab id 記在 session storage 裡: + * 分頁被導去別的網站時 id 還在、`tabs.get()` 也還會成功,於是圖示只會把一個空白分頁叫到前面, + * 點幾次都開不出 App 而且完全不說為什麼。`getContexts` 只回報本擴充功能自己的 context, + * **不需要任何權限**(改用 `tabs` 權限的話,安裝時會跳出「讀取瀏覽記錄」的警告)。 + * 分頁網址帶片段(`app.html#/run`,見 `ui/route.ts`),所以用前綴比對,不用 `documentUrls` 的完全相等。 + */ async function openAppTab(): Promise { - const existingId = await appTabItem.getValue(); - if (existingId !== null) { - try { - const tab = await browser.tabs.get(existingId); - await browser.tabs.update(existingId, { active: true }); - if (tab.windowId !== undefined) await browser.windows.update(tab.windowId, { focused: true }); - return; - } catch { - // 分頁已被關閉,往下新建 - } + const url = browser.runtime.getURL('/app.html'); + const contexts = await browser.runtime.getContexts({ contextTypes: ['TAB'] }); + const open = contexts.find((c) => c.documentUrl?.startsWith(url)); + if (open) { + await browser.tabs.update(open.tabId, { active: true }); + await browser.windows.update(open.windowId, { focused: true }); + return; } - const tab = await browser.tabs.create({ url: browser.runtime.getURL('/app.html') }); - if (tab.id !== undefined) await appTabItem.setValue(tab.id); + await browser.tabs.create({ url }); } /** diff --git a/wxt.config.ts b/wxt.config.ts index f63e167..608c122 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -9,9 +9,10 @@ export default defineConfig({ // 應用程式內文才是 `src/i18n/` 那套可切換語系(見 README「Getting started」)。 description: 'Tidy your Bilibili: sort favourites into folders with AI you choose, and unfollow accounts that went quiet — review first, undo after', - // 目前用得到的 API(declarativeNetRequestWithHostAccess/storage.session 等)都在 114 以後才有; + // 目前用得到的 API 裡最晚出現的是 `runtime.getContexts()`(Chrome 116,background.ts 用它找 App 分頁); + // declarativeNetRequestWithHostAccess 與 storage.session 是 114。 // 沒有這個宣告的話,太舊的 Chrome 照樣裝得上,卻在跑到那些 API 時才當場失敗。 - minimum_chrome_version: '114', + minimum_chrome_version: '116', // 沒有 popup:點工具列圖示由 background 開啟 App 分頁(整理收藏/收藏夾/關注/設定四個分頁都在裡面) action: { default_title: 'Open biliTidy' }, // declarativeNetRequestWithHostAccess 而不是 declarativeNetRequest:兩者能力一致(Chrome 文件), From d6a8d97b61e29ebd5541b7bd41066dfc7ed79579 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 8 Sep 2026 14:40:22 +0800 Subject: [PATCH 2/4] fix: say what the threshold accepts and what counts as an upload Typing 9999 silently bounced back to the last valid value with nothing on screen, so the number just looked eaten. The hint line also said "upload" while arc/search only returns videos, which makes an account that posts articles read as "never uploaded". --- CHANGELOG.md | 3 +++ docs/how-it-works.md | 5 ++++- scripts/ui-preview.mjs | 11 +++++++++++ src/i18n/en.ts | 4 +++- src/i18n/zh-Hant.ts | 3 ++- src/ui/pages/follows/FollowReviewRail.tsx | 2 ++ 6 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bbb06..dfe3d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,9 @@ 圖示就只會把那個分頁叫到前面,**點幾次都開不出 App 而且不說為什麼**——它記的是分頁 id,不看那個分頁現在是什麼。 現在改用 `runtime.getContexts()` 現場問,被導走或關掉都會重新開一個。副作用:這支 API 要 Chrome 116, **最低瀏覽器版本從 114 提到 116**(相差三個月,且瀏覽器自動更新)。 +- **門檻欄位說得出自己收什麼。** 填 `9999` 或 `0` 會被彈回上一個合法值,之前畫面上一個字都沒有, + 看起來就像數字被吃掉;現在單位旁邊常駐寫著收得的範圍 `1–3,650`。同一行的說明也講清楚天數是 + **從最新一支影片**算起——圖文與動態不算,所以只發圖文的帳號會顯示「從未投稿」。 - **窄視窗下的勾選框與圖示按鈕點得到了。** 把分頁縮成半個螢幕(並排看 B 站是很自然的用法)之後, 勾選框只有 15×15、頂列的「重新檢查」只有 13×13,觸控筆電上幾乎點不中。現在這個寬度下撐到 24×24, 也就是 WCAG 2.2 AA 的下限;設定用的開關維持原樣。 diff --git a/docs/how-it-works.md b/docs/how-it-works.md index fc833d6..953179b 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -73,7 +73,7 @@ | 狀態 | 意思 | 畫面上 | 能不能勾選 | |---|---|---|---| | 有影片 | B 站回了 `code 0` 且有影片清單 | 顯示最後一支影片的標題、日期、安靜天數 | 可以 | -| 確認無影片 | B 站回了 `code 0` 但清單是空的 | 「沒有影片」、天數欄顯示「從未投稿」並排在最不活躍的那端 | 可以(它比任何門檻都不活躍) | +| 確認無影片 | B 站回了 `code 0` 但影片清單是空的(**只算影片**,圖文與動態不算) | 「沒有影片」、天數欄顯示「從未投稿」並排在最不活躍的那端 | 可以(它比任何門檻都不活躍) | | 未知 | 請求失敗、B 站回非 0 code、或風控 | 「查不到」+原因,整列變淡、勾選框鎖著 | **不行**,也不會進匯出 | 還有一種「還沒查」——這一輪被你停掉或被風控停下時剩下的帳號。它們跟「未知」一樣鎖著,左邊分面裡都算在「查不到」, @@ -224,6 +224,9 @@ relation/whispers ×⌈M/50⌉ →(勾了「包含悄悄關注」才會) 有快取且還新鮮(30 天內、而且不是「未知」)的直接沿用。畫面上是儀表:目前階段、`done / total`、已用與預估剩餘、 實際速率、快取命中、查不到的數量,加上最近幾筆請求(端點、參數、狀態碼與耗時)。 +**這支端點只回影片。** 只發圖文、專欄或動態的帳號會被算成「從未投稿」,即使 B 站個人空間的「投稿」頁籤有東西。 +這是刻意的——活躍度看的是「還在不在產出影片」;審核表門檻下面那一行也寫著。 + 遇到 `-352`/`-412`/`-799` 或 HTTP 412:等 60 秒重試、再 120、再 240,還是不行就**整輪停下**, 把已經查到的帶進審核表,剩下的標成「還沒查」。按「停止」也是一樣的結果。 diff --git a/scripts/ui-preview.mjs b/scripts/ui-preview.mjs index 5dae2d0..c2516ab 100644 --- a/scripts/ui-preview.mjs +++ b/scripts/ui-preview.mjs @@ -558,6 +558,17 @@ await page.waitForTimeout(400); await phone('follows-review-mobile', 'table.grid'); copyFileSync(path.join(out, 'follows-review-mobile.png'), path.join(review, 'mobile.png')); +// 門檻收得的範圍要常駐寫在畫面上:填 9999 會被彈回上一個合法值,不說的話那個數字看起來就是被吃掉了 +const rangeHint = (await page.locator('.threshold .mono').textContent())?.trim() ?? ''; +console.log('threshold range hint:', rangeHint || '(none)'); +if (!/1.*3,?650/.test(rangeHint)) errors.push(`the threshold field should say what it accepts, got "${rangeHint}"`); +await page.locator('#threshold').fill('9999'); +await page.locator('#threshold').blur(); +await page.waitForTimeout(400); +const bounced = await page.locator('#threshold').inputValue(); +console.log('threshold after typing 9999:', bounced); +if (bounced === '9999') errors.push('an out-of-range threshold should bounce back, not be applied'); + // 門檻改成 180 → 「安靜超過門檻」的數字要跟著變 await page.getByRole('button', { name: '180', exact: true }).click(); await page.waitForTimeout(400); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4a35542..426ad08 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -991,7 +991,9 @@ export const en = { checkRemaining: (n: number) => `Check the remaining ${n.toLocaleString('en-US')} (starts a new run)`, thresholdLabel: 'Quiet for more than', thresholdUnit: 'days', - thresholdHint: 'Counted from the newest upload. Refilters at once; nothing is fetched again.', + thresholdRange: '1–3,650', + thresholdHint: + 'Counted from the newest video — articles and posts do not count. Refilters at once; nothing is fetched again.', statusTitle: 'Status', status: { all: 'All accounts', diff --git a/src/i18n/zh-Hant.ts b/src/i18n/zh-Hant.ts index ab486c4..ba032d4 100644 --- a/src/i18n/zh-Hant.ts +++ b/src/i18n/zh-Hant.ts @@ -893,7 +893,8 @@ export const zhHant: Messages = { checkRemaining: (count) => `補查剩下的 ${num(count)} 個(會重新開始一輪)`, thresholdLabel: '安靜超過', thresholdUnit: '天', - thresholdHint: '從最新一支投稿算起;改了立刻重篩,不重新抓。', + thresholdRange: '1–3,650', + thresholdHint: '從最新一支影片算起(圖文與動態不算);改了立刻重篩,不重新抓。', statusTitle: '狀態', status: { all: '全部帳號', diff --git a/src/ui/pages/follows/FollowReviewRail.tsx b/src/ui/pages/follows/FollowReviewRail.tsx index b6c40dc..8cde0f0 100644 --- a/src/ui/pages/follows/FollowReviewRail.tsx +++ b/src/ui/pages/follows/FollowReviewRail.tsx @@ -120,6 +120,8 @@ export function FollowReviewRail({ }} /> {m.follows.review.thresholdUnit} + {/* 收得的範圍常駐寫著:填 9999 會被 onBlur 彈回上一個合法值,不說的話那個數字看起來就是被吃掉了 */} + {m.follows.review.thresholdRange}
{PRESETS.map((p) => ( From 92b771e7d5cc25e0eface753257a1f36441bf932 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 8 Sep 2026 14:46:17 +0800 Subject: [PATCH 3/4] feat: let the organise review table download a CSV Follows could already export; the classification could not, so there was no way to keep what the model decided and why. Exports every row, not just the ticked ones: stale videos and the ones you chose not to move are part of the record, and nothing here is ever written back. --- CHANGELOG.md | 3 ++ README.md | 2 + README.zh-Hant.md | 1 + docs/how-it-works.md | 4 ++ scripts/ui-preview.mjs | 16 +++++++ src/core/exportRows.test.ts | 78 ++++++++++++++++++++++++++++-- src/core/exportRows.ts | 96 +++++++++++++++++++++++++++++++++++-- src/i18n/en.ts | 19 ++++++++ src/i18n/zh-Hant.ts | 19 ++++++++ src/ui/pages/RunPage.tsx | 24 +++++++++- 10 files changed, 254 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe3d98..cbfde0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ `ui/components/ProgressPanel.tsx`,整理與關注都用它;讀數欄位由各頁自己決定。 - `npm run ui-preview` 一支腳本把四個頁面都走一遍(含互斥狀態與手機寬度、設定六章、時間軸播放頭的拖動), `npm run gen-icons` 由 SVG 原稿產生工具列圖示。 +- 整理的審核表可以**下載這一輪的 CSV**(作業列右段):BV 號、標題、來源夾、AI 建議、你最後選的、也留在原位、 + 低信心、已失效、理由、依據、狀態。整張表都寫出去——失效影片與你決定不搬的列一起留著才是完整紀錄。 + 關注那半邊本來就有 UID 與 CSV。 ### Changed diff --git a/README.md b/README.md index 73b2f87..2f2c9ab 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ Favourites: will make; the Organise page recomputes it for the actual number of videos. - **Deleting a folder is not implemented.** On Bilibili that destroys the videos inside it and cannot be undone. +- **Download the run as a CSV** — every row, with what the model suggested next + to what you chose, its reason and the fields it used. Follows: diff --git a/README.zh-Hant.md b/README.zh-Hant.md index bf176eb..59f07d9 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -75,6 +75,7 @@ npm run build # 產出 .output/chrome-mv3 所以模型回答的是「這支最後該在哪些收藏夾」,可以同時建議留著*和*複製一份到別處(「也留在原位」)。 - **成本透明。** 設定頁算出這份設定會打哪些請求;整理頁再依這次實際的影片數算一次。 - **沒有刪除收藏夾。** 在 B 站那會連影片一起消失且不可復原。 +- **這一輪可以下載成 CSV**:每一列都寫出去,AI 建議了什麼與你最後選了什麼各佔一欄,還有理由與它用了哪些欄位。 關注: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 953179b..cc3b867 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -279,6 +279,10 @@ relation/whispers ×⌈M/50⌉ →(勾了「包含悄悄關注」才會) 沒有剪貼簿權限時改成下載 `.txt`);「下載 CSV」給名稱、分組、安靜天數、最新投稿、影片連結、個人空間。 兩者都再排除一次「未知」——不管畫面上勾了什麼,未能確認的帳號不會出現在檔案裡。 +整理那半邊的作業列也有一顆「下載 CSV」,存的是這一輪的分類結果:BV 號、標題、來源夾、**AI 建議了什麼**、 +**你最後選了什麼**、也留在原位、低信心、已失效、理由、依據、狀態。這裡**不**過濾:失效影片與你決定不搬的列 +一起留著才是完整紀錄——它不會被拿去執行任何寫入。 + ### 結果快照 跑完會把整張表(含勾選)存進 `chrome.storage.local`,關掉分頁再開會直接回到審核;「清除結果」才會丟掉。 diff --git a/scripts/ui-preview.mjs b/scripts/ui-preview.mjs index c2516ab..10de71a 100644 --- a/scripts/ui-preview.mjs +++ b/scripts/ui-preview.mjs @@ -469,6 +469,22 @@ await shot('review-copy', { fullPage: true }); await page.getByRole('button', { name: 'Move all instead' }).click(); await page.waitForSelector('text=To move', { timeout: 5000 }); +// 匯出這一輪的分類結果:整張表,不是只有選了目標的那幾列 +const rowsInTable = await page.locator('table.grid tbody tr').count(); +const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 15000 }), + page.getByRole('button', { name: /^Download CSV/ }).click(), +]); +const csvPath = path.join(out, 'classified-videos.csv'); +await download.saveAs(csvPath); +const csvLines = readFileSync(csvPath, 'utf8').trim().split('\r\n'); +console.log(`downloaded ${download.suggestedFilename()}: ${csvLines.length - 1} rows for ${rowsInTable} in the table`); +if (!/^classified-videos-\d{8}-\d{6}\.csv$/.test(download.suggestedFilename())) + errors.push(`unexpected export filename: ${download.suggestedFilename()}`); +if (!csvLines[0].includes('AI suggested') || !csvLines[0].includes('You chose')) + errors.push(`the export should record both the suggestion and the choice, header was: ${csvLines[0]}`); +if (csvLines.length - 1 !== rowsInTable) errors.push(`exported ${csvLines.length - 1} rows but the table shows ${rowsInTable}`); + await page.getByRole('button', { name: /^Move \d/ }).click(); await page.waitForSelector('.banner.ok', { timeout: 10000 }); await shot('moved', { fullPage: true }); diff --git a/src/core/exportRows.test.ts b/src/core/exportRows.test.ts index af2fd47..ae850f7 100644 --- a/src/core/exportRows.test.ts +++ b/src/core/exportRows.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import type { FollowEntry, FollowRow } from '@/shared/types'; +import type { FollowEntry, FollowRow, ReviewRow } from '@/shared/types'; import { classifyActivity, unknownActivity } from './activity'; import { DAY_MS } from './cache'; -import { exportFilename, toCsv, uidList } from './exportRows'; +import { exportFilename, toCsv, toReviewCsv, uidList } from './exportRows'; const NOW = Date.UTC(2026, 8, 5); const entry = (mid: number, over: Partial = {}): FollowEntry => ({ @@ -51,16 +51,88 @@ describe('toCsv', () => { it('RFC 4180 跳脫、BOM、無影片的文字', () => { const csv = toCsv(rows, (id) => ({ 7: '遊戲', [-10]: '特别关注' })[id], LABELS, NOW); const lines = csv.split('\r\n'); - expect(lines[0]).toBe('UID,Name,Group,Days,Latest,Link,Space'); + expect(lines[0]).toBe('\ufeffUID,Name,Group,Days,Latest,Link,Space'); expect(lines[1]).toBe('1,"quote ""me"", please",,10,"a,b",https://www.bilibili.com/video/BV1/,https://space.bilibili.com/1'); expect(lines[2]).toBe('2,u2,特别关注 / 遊戲,No videos,No videos,,https://space.bilibili.com/2'); expect(lines).toHaveLength(4); // 兩列資料 + 表頭 + 結尾空字串 }); }); +const REVIEW_LABELS = { + headers: { + bvid: 'BV', + title: 'Title', + source: 'Source folder', + suggested: 'AI suggested', + chosen: 'You chose', + keepInPlace: 'Also keep in place', + lowConfidence: 'Low confidence', + stale: 'Stale', + reason: 'Reason', + basis: 'Basis', + status: 'Status', + error: 'Error', + }, + yes: 'yes', +}; + +const FOLDERS: Record = { 2: '遊戲', 3: '料理' }; +const reviewRow = (over: Partial = {}): ReviewRow => ({ + bvid: 'BV1', + aid: 1, + type: 2, + title: 't', + cover: '', + suggested: [], + chosen: [], + reason: '', + status: 'pending', + ...over, +}); + +describe('toReviewCsv', () => { + const csvOf = (given: ReviewRow[]) => toReviewCsv(given, 'Watch later', (id) => FOLDERS[id], REVIEW_LABELS); + + it('建議與你選的各佔一欄,多個目標以斜線相接', () => { + const csv = csvOf([reviewRow({ suggested: [2, 3], chosen: [3], reason: 'r', basis: ['tag', 'title'] })]); + const lines = csv.split('\r\n'); + expect(lines[0]).toBe( + '\ufeffBV,Title,Source folder,AI suggested,You chose,Also keep in place,Low confidence,Stale,Reason,Basis,Status,Error', + ); + expect(lines[1]).toBe('BV1,t,Watch later,遊戲 / 料理,料理,,,,r,tag / title,pending,'); + expect(lines).toHaveLength(3); // 一列資料 + 表頭 + 結尾空字串 + }); + + it('認不得的收藏夾 id 退回數字,不是空白', () => { + expect(csvOf([reviewRow({ chosen: [99] })]).split('\r\n')[1]).toContain(',99,'); + }); + + it('三個旗標為真時填 yes、為假時留空', () => { + const [, on, off] = csvOf([reviewRow({ keepSource: true, lowConfidence: true, invalid: true }), reviewRow()]).split('\r\n'); + expect(on).toContain(',yes,yes,yes,'); + expect(off).toContain(',,,,'); + }); + + it('狀態寫原字串、錯誤訊息跟著那一列', () => { + const [, line] = csvOf([reviewRow({ status: 'failed', error: 'boom' })]).split('\r\n'); + expect(line?.endsWith(',failed,boom')).toBe(true); + }); + + it('每一列都寫出去,沒有 confirmedOnly 那種過濾', () => { + const csv = csvOf([reviewRow({ invalid: true }), reviewRow(), reviewRow({ status: 'done' })]); + expect(csv.split('\r\n')).toHaveLength(5); + }); + + it('含逗號與引號的標題照 RFC 4180 跳脫', () => { + const line = csvOf([reviewRow({ title: 'quote "me", please' })]).split('\r\n')[1]; + expect(line).toContain('"quote ""me"", please"'); + }); +}); + describe('exportFilename', () => { it('帶時間戳', () => { expect(exportFilename('uids', new Date(2026, 0, 2, 3, 4, 5))).toBe('inactive-uids-20260102-030405.txt'); expect(exportFilename('csv', new Date(2026, 0, 2, 3, 4, 5))).toBe('inactive-accounts-20260102-030405.csv'); + expect(exportFilename('review', new Date(2026, 0, 2, 3, 4, 5))).toBe('classified-videos-20260102-030405.csv'); }); }); diff --git a/src/core/exportRows.ts b/src/core/exportRows.ts index 7cd3777..d606ffe 100644 --- a/src/core/exportRows.ts +++ b/src/core/exportRows.ts @@ -1,4 +1,4 @@ -import type { FollowRow } from '@/shared/types'; +import type { FollowRow, ReviewRow } from '@/shared/types'; import { daysInactive } from './activity'; /** @@ -68,13 +68,101 @@ export function toCsv( .join(','), ); } - return `${lines.join('\r\n')}\r\n`; + return `\ufeff${lines.join('\r\n')}\r\n`; } -export function exportFilename(kind: 'uids' | 'csv', now = new Date()): string { +export interface ReviewCsvLabels { + headers: { + bvid: string; + title: string; + source: string; + suggested: string; + chosen: string; + keepInPlace: string; + lowConfidence: string; + stale: string; + reason: string; + basis: string; + status: string; + error: string; + }; + /** 是/否欄位為真時填的字;為假時留空——CSV 讀起來一眼看得出哪幾列有標記 */ + yes: string; +} + +/** + * 整理結果的存檔:這次 AI 把哪支影片分到哪個夾、理由是什麼、你最後選了什麼。 + * + * 兩件與關注那半邊不同的事: + * - **每一列都寫出去**,不是只寫「你勾的」。關注匯出的是要餵回別的工具的名單、等同一次批次, + * 所以有 `confirmedOnly()` 那道防線;這份檔案不會被拿去執行任何寫入,失效影片與你決定不搬的列 + * 一起留著才是完整紀錄。 + * - `status` 直接寫 `ReviewStatus` 的原字串、不翻譯。CSV 是給工具與往後的自己讀的,穩定的機器值 + * 比畫面上的句子有用,而且不必跟 `ReviewTable` 那個「done + keepSource = 已複製」的組合邏輯 + * 維持同步——那些條件在這裡是各自獨立的欄位。 + */ +export function toReviewCsv( + rows: readonly ReviewRow[], + sourceTitle: string, + folderNameOf: (id: number) => string | undefined, + labels: ReviewCsvLabels, +): string { + const h = labels.headers; + const names = (ids: readonly number[]) => ids.map((id) => folderNameOf(id) ?? String(id)).join(' / '); + const flag = (on: boolean | undefined) => (on ? labels.yes : ''); + const lines = [ + [ + h.bvid, + h.title, + h.source, + h.suggested, + h.chosen, + h.keepInPlace, + h.lowConfidence, + h.stale, + h.reason, + h.basis, + h.status, + h.error, + ] + .map(csvCell) + .join(','), + ]; + for (const r of rows) { + lines.push( + [ + r.bvid, + r.title, + sourceTitle, + names(r.suggested), + names(r.chosen), + flag(r.keepSource), + flag(r.lowConfidence), + flag(r.invalid), + r.reason, + (r.basis ?? []).join(' / '), + r.status, + r.error ?? '', + ] + .map(csvCell) + .join(','), + ); + } + return `\ufeff${lines.join('\r\n')}\r\n`; +} + +export type ExportKind = 'uids' | 'csv' | 'review'; + +const FILENAMES: Record string> = { + uids: (s) => `inactive-uids-${s}.txt`, + csv: (s) => `inactive-accounts-${s}.csv`, + review: (s) => `classified-videos-${s}.csv`, +}; + +export function exportFilename(kind: ExportKind, now = new Date()): string { const pad = (n: number) => String(n).padStart(2, '0'); const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; - return kind === 'uids' ? `inactive-uids-${stamp}.txt` : `inactive-accounts-${stamp}.csv`; + return FILENAMES[kind](stamp); } export function downloadText(filename: string, text: string, mime = 'text/plain;charset=utf-8'): void { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 426ad08..da5545e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -85,6 +85,25 @@ export const en = { retryFailed: 'Retry failed', changeSettingsRerun: 'Change settings & rerun', clearResults: 'Clear results', + downloadCsv: (n: number) => `Download CSV (${n.toLocaleString('en-US')})`, + downloadCsvHint: 'Every row: what the AI suggested, what you chose, and why', + csv: { + headers: { + bvid: 'BV', + title: 'Title', + source: 'Source folder', + suggested: 'AI suggested', + chosen: 'You chose', + keepInPlace: 'Also keep in place', + lowConfidence: 'Low confidence', + stale: 'Stale', + reason: 'Reason', + basis: 'Basis', + status: 'Status', + error: 'Error', + }, + yes: 'yes', + }, targetFoldersTitle: 'Target folders', targetFoldersDesc: "Tick the folders the AI is allowed to move videos into. It classifies using each folder's description — write those on the Folders page.", diff --git a/src/i18n/zh-Hant.ts b/src/i18n/zh-Hant.ts index ba032d4..3ab13fe 100644 --- a/src/i18n/zh-Hant.ts +++ b/src/i18n/zh-Hant.ts @@ -73,6 +73,25 @@ export const zhHant: Messages = { retryFailed: '失敗的重新排入', changeSettingsRerun: '改設定重跑', clearResults: '清除結果', + downloadCsv: (n: number) => `下載 CSV(${n.toLocaleString('en-US')})`, + downloadCsvHint: '每一列:AI 建議了什麼、你選了什麼、理由是什麼', + csv: { + headers: { + bvid: 'BV 號', + title: '標題', + source: '來源收藏夾', + suggested: 'AI 建議', + chosen: '你選的', + keepInPlace: '也留在原位', + lowConfidence: '低信心', + stale: '已失效', + reason: '理由', + basis: '依據', + status: '狀態', + error: '錯誤', + }, + yes: '是', + }, targetFoldersTitle: '目標收藏夾', targetFoldersDesc: '勾選允許 AI 搬進去的收藏夾。它照每個夾子的描述來分——描述在「收藏夾」分頁寫。', filterTargetsPlaceholder: '篩選目標…', diff --git a/src/ui/pages/RunPage.tsx b/src/ui/pages/RunPage.tsx index 48ca439..54082cf 100644 --- a/src/ui/pages/RunPage.tsx +++ b/src/ui/pages/RunPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { estimateRun } from '@/core/estimate'; import { planFlow } from '@/core/flow'; +import { downloadText, exportFilename, toReviewCsv } from '@/core/exportRows'; import { effectiveDescription } from '@/core/folderStore'; import type { BatchRecord } from '@/core/organizer'; import { planOf } from '@/core/plan'; @@ -8,7 +9,7 @@ import { DETAIL_TTL_DAYS } from '@/core/settings'; import type { Messages } from '@/i18n'; import type { JobPhase } from '@/shared/types'; import { FolderCover } from '../components/FolderCover'; -import { IconArrowRight, IconCheck, IconSearch, IconSparkle } from '../components/icons'; +import { IconArrowRight, IconCheck, IconDownload, IconSearch, IconSparkle } from '../components/icons'; import { ProgressBar, ProgressPanel } from '../components/ProgressPanel'; import { ReviewTable } from '../components/ReviewTable'; import { TargetFolderTable } from '../components/TargetFolderTable'; @@ -100,6 +101,17 @@ export function RunPage({ const moving = job.phase === 'moving'; const jobTargets = folders.filter((f) => job.targetIds.includes(f.id)); const jobSource = folders.find((f) => f.id === job.sourceId) ?? null; + // 匯出整張表而不是「你選了目標的那些」:使用者要的是這一輪的完整紀錄, + // 包含 AI 建議了什麼、你決定不搬的是哪幾支、哪幾支已經失效 + const downloadReviewCsv = () => + downloadText( + exportFilename('review'), + toReviewCsv(job.rows, job.sourceTitle, (id) => folders.find((f) => f.id === id)?.title, { + headers: m.run.csv.headers, + yes: m.run.csv.yes, + }), + 'text/csv;charset=utf-8', + ); const limit = runScope.mode === 'latest' ? runScope.count : undefined; const planned = source ? (limit ? Math.min(limit, source.mediaCount) : source.mediaCount) : 0; @@ -331,6 +343,16 @@ export function RunPage({ {m.run.retryFailed} )} +