diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34e36f9..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
@@ -64,6 +67,13 @@
### Fixed
+- **工具列圖示不會再變成點不開。** App 分頁一旦被導去別的網站(表格裡每個帳號與影片都可以點開,很容易發生),
+ 圖示就只會把那個分頁叫到前面,**點幾次都開不出 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/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..2f2c9ab 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ can be undone.
-
+
**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
@@ -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 6ede960..59f07d9 100644
--- a/README.zh-Hant.md
+++ b/README.zh-Hant.md
@@ -6,7 +6,7 @@
-
+
[English](README.md) · **繁體中文**
@@ -22,7 +22,7 @@ B 站資料,攤成一張每一列都帶著證據的審核表,讓你勾選、
## 安裝
-需要 Node.js 20+ 與 Chrome 或 Edge 114+。
+需要 Node.js 20+ 與 Chrome 或 Edge 116+。
```bash
npm install
@@ -75,6 +75,7 @@ npm run build # 產出 .output/chrome-mv3
所以模型回答的是「這支最後該在哪些收藏夾」,可以同時建議留著*和*複製一份到別處(「也留在原位」)。
- **成本透明。** 設定頁算出這份設定會打哪些請求;整理頁再依這次實際的影片數算一次。
- **沒有刪除收藏夾。** 在 B 站那會連影片一起消失且不可復原。
+- **這一輪可以下載成 CSV**:每一列都寫出去,AI 建議了什麼與你最後選了什麼各佔一欄,還有理由與它用了哪些欄位。
關注:
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..cc3b867 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -31,6 +31,7 @@
另一頁的主按鈕與寫入按鈕變灰,旁邊寫著「另一個任務還在跑——等它跑完,或到那一頁停止它」。
分頁之間切換不會打斷任務,切回來畫面接得上。目前在哪一頁寫在網址裡(`app.html#/follows`),
所以誤按上一頁(側鍵、`Alt+←`、觸控板兩指滑動)退回的是上一個分頁,不是離開整個工具。
+工具列圖示只會開**一個** App 分頁:已經開著就把它叫到前面,被你導去別的網站或關掉了就重新開一個。
---
@@ -72,7 +73,7 @@
| 狀態 | 意思 | 畫面上 | 能不能勾選 |
|---|---|---|---|
| 有影片 | B 站回了 `code 0` 且有影片清單 | 顯示最後一支影片的標題、日期、安靜天數 | 可以 |
-| 確認無影片 | B 站回了 `code 0` 但清單是空的 | 「沒有影片」、天數欄顯示「從未投稿」並排在最不活躍的那端 | 可以(它比任何門檻都不活躍) |
+| 確認無影片 | B 站回了 `code 0` 但影片清單是空的(**只算影片**,圖文與動態不算) | 「沒有影片」、天數欄顯示「從未投稿」並排在最不活躍的那端 | 可以(它比任何門檻都不活躍) |
| 未知 | 請求失敗、B 站回非 0 code、或風控 | 「查不到」+原因,整列變淡、勾選框鎖著 | **不行**,也不會進匯出 |
還有一種「還沒查」——這一輪被你停掉或被風控停下時剩下的帳號。它們跟「未知」一樣鎖著,左邊分面裡都算在「查不到」,
@@ -223,6 +224,9 @@ relation/whispers ×⌈M/50⌉ →(勾了「包含悄悄關注」才會)
有快取且還新鮮(30 天內、而且不是「未知」)的直接沿用。畫面上是儀表:目前階段、`done / total`、已用與預估剩餘、
實際速率、快取命中、查不到的數量,加上最近幾筆請求(端點、參數、狀態碼與耗時)。
+**這支端點只回影片。** 只發圖文、專欄或動態的帳號會被算成「從未投稿」,即使 B 站個人空間的「投稿」頁籤有東西。
+這是刻意的——活躍度看的是「還在不在產出影片」;審核表門檻下面那一行也寫著。
+
遇到 `-352`/`-412`/`-799` 或 HTTP 412:等 60 秒重試、再 120、再 240,還是不行就**整輪停下**,
把已經查到的帶進審核表,剩下的標成「還沒查」。按「停止」也是一樣的結果。
@@ -275,6 +279,10 @@ relation/whispers ×⌈M/50⌉ →(勾了「包含悄悄關注」才會)
沒有剪貼簿權限時改成下載 `.txt`);「下載 CSV」給名稱、分組、安靜天數、最新投稿、影片連結、個人空間。
兩者都再排除一次「未知」——不管畫面上勾了什麼,未能確認的帳號不會出現在檔案裡。
+整理那半邊的作業列也有一顆「下載 CSV」,存的是這一輪的分類結果:BV 號、標題、來源夾、**AI 建議了什麼**、
+**你最後選了什麼**、也留在原位、低信心、已失效、理由、依據、狀態。這裡**不**過濾:失效影片與你決定不搬的列
+一起留著才是完整紀錄——它不會被拿去執行任何寫入。
+
### 結果快照
跑完會把整張表(含勾選)存進 `chrome.storage.local`,關掉分頁再開會直接回到審核;「清除結果」才會丟掉。
diff --git a/docs/reviews/2026-09-06-full/report.md b/docs/reviews/2026-09-06-full/report.md
index 20ed2b4..17d6c3a 100644
--- a/docs/reviews/2026-09-06-full/report.md
+++ b/docs/reviews/2026-09-06-full/report.md
@@ -402,6 +402,14 @@ T1 從 README 到能開始用、T2 把一個塞滿的收藏夾分類進正確的
- **改法**:確認 `.app` 或 `.app-body` 沒有多餘的 `tabindex="0"`/`tabindex="-1"` 造成的落點;當前分頁按鈕改成
`aria-current="page"` 而不是 `disabled`,讓它留在 Tab 順序裡。
- **工作量**:S
+- **2026-09-08 覆查:一半修掉、另一半是誤判。** 分頁按鈕已加上 `aria-current="page"`(F08 那一批)。
+ `` 那一站則不是缺陷:`scripts/ui-preview.mjs` 現在會在審核畫面從第一個分頁按鈕開始按 Tab 走完一整圈
+ (回到起點才停),量到 **42 站、其中恰好 1 站在 body**,位置在作業列最後一顆「Clear results」與頂列
+ 「Organise」之間——那是「文件最後一個可聚焦元素之後」的瀏覽器預設落點,任何網頁都有。
+ 另外「當前分頁按鈕不在 Tab 順序裡」也不成立:它是普通 ``、沒有 `disabled`,
+ 報告量到的「Tab 1 落在 Folders」只是因為焦點本來就停在 Organise 上。
+ 全 repo 沒有任何 `tabIndex`,焦點樣式(`styles.css` 的全域 `:focus-visible`)也是完整的。
+ 那段走查留著當回歸守門:中間多出任何一站 body 就會讓 `ui-preview` 失敗。
### F17 · S1 · H · app chunk 552 kB,超過 Vite 警告線且沒有分割
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/scripts/ui-preview.mjs b/scripts/ui-preview.mjs
index 5dae2d0..42bff74 100644
--- a/scripts/ui-preview.mjs
+++ b/scripts/ui-preview.mjs
@@ -456,6 +456,32 @@ await page.waitForTimeout(300);
await shot('review', { fullPage: true });
copyFileSync(path.join(out, 'review.png'), path.join(review, 'desktop.png'));
console.log('mixed button:', await page.getByRole('button', { name: /^Write changes/ }).textContent());
+// 只用鍵盤走一圈:審查報告量到作業列末尾之後焦點會落在 。走一輪印出完整順序,
+// 並斷言一圈裡最多只有一站是 body——那一站是「文件最後一個可聚焦元素之後」的瀏覽器預設落點,
+// 每個網頁都有;多於一站就代表版面裡真的多了一個沒有內容的落點。
+const describeFocus = () =>
+ page.evaluate(() => {
+ const el = document.activeElement;
+ if (!el || el === document.body) return 'BODY';
+ const cls = typeof el.className === 'string' && el.className ? `.${el.className.split(' ')[0]}` : '';
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 22);
+ return `${el.tagName.toLowerCase()}${cls}${text ? `[${text}]` : ''}`;
+ });
+const firstTab = page.locator('.tabs .tab').first();
+await firstTab.focus();
+const start = await describeFocus();
+const walk = [];
+for (let i = 0; i < 150; i++) {
+ await page.keyboard.press('Tab');
+ const at = await describeFocus();
+ walk.push(at);
+ if (at === start) break;
+}
+const bodyStops = walk.filter((w) => w === 'BODY').length;
+console.log(`tab order (${walk.length} stops, ${bodyStops} on body):`, walk.join(' → '));
+if (walk[walk.length - 1] !== start) errors.push(`tabbing never came back to ${start} within 150 stops`);
+if (bodyStops > 1) errors.push(`${bodyStops} focus stops land on ; only the end-of-document one is expected`);
+
await page.getByRole('button', { name: 'What was sent' }).first().click();
await page.waitForSelector('.dlg', { timeout: 5000 });
await page.waitForTimeout(200);
@@ -469,6 +495,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 });
@@ -558,6 +600,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/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/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/src/i18n/en.ts b/src/i18n/en.ts
index 4a35542..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.",
@@ -991,7 +1010,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..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: '篩選目標…',
@@ -893,7 +912,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/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}
)}
+
+
+ {m.run.downloadCsv(job.rows.length)}
+
setShowSetup(true)}>
{m.run.changeSettingsRerun}
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) => (
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 文件),