Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
`ui/components/ProgressPanel.tsx`,整理與關注都用它;讀數欄位由各頁自己決定。
- `npm run ui-preview` 一支腳本把四個頁面都走一遍(含互斥狀態與手機寬度、設定六章、時間軸播放頭的拖動),
`npm run gen-icons` 由 SVG 原稿產生工具列圖示。
- 整理的審核表可以**下載這一輪的 CSV**(作業列右段):BV 號、標題、來源夾、AI 建議、你最後選的、也留在原位、
低信心、已失效、理由、依據、狀態。整張表都寫出去——失效影片與你決定不搬的列一起留著才是完整紀錄。
關注那半邊本來就有 UID 與 CSV。

### Changed

Expand Down Expand Up @@ -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 的下限;設定用的開關維持原樣。
Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ can be undone.
<p>
<a href="https://github.com/1morr/biliTidy/actions/workflows/ci.yml"><img src="https://github.com/1morr/biliTidy/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/1morr/biliTidy" alt="License: MIT"></a>
<img src="https://img.shields.io/badge/Chrome-114%2B-4285F4" alt="Chrome 114+">
<img src="https://img.shields.io/badge/Chrome-116%2B-4285F4" alt="Chrome 116+">
</p>

**English** · [繁體中文](README.zh-Hant.md)
Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions README.zh-Hant.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<p>
<a href="https://github.com/1morr/biliTidy/actions/workflows/ci.yml"><img src="https://github.com/1morr/biliTidy/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/1morr/biliTidy" alt="License: MIT"></a>
<img src="https://img.shields.io/badge/Chrome-114%2B-4285F4" alt="Chrome 114+">
<img src="https://img.shields.io/badge/Chrome-116%2B-4285F4" alt="Chrome 116+">
</p>

[English](README.md) · **繁體中文**
Expand All @@ -22,7 +22,7 @@ B 站資料,攤成一張每一列都帶著證據的審核表,讓你勾選、

## 安裝

需要 Node.js 20+ 與 Chrome 或 Edge 114+。
需要 Node.js 20+ 與 Chrome 或 Edge 116+。

```bash
npm install
Expand Down Expand Up @@ -75,6 +75,7 @@ npm run build # 產出 .output/chrome-mv3
所以模型回答的是「這支最後該在哪些收藏夾」,可以同時建議留著*和*複製一份到別處(「也留在原位」)。
- **成本透明。** 設定頁算出這份設定會打哪些請求;整理頁再依這次實際的影片數算一次。
- **沒有刪除收藏夾。** 在 B 站那會連影片一起消失且不可復原。
- **這一輪可以下載成 CSV**:每一列都寫出去,AI 建議了什麼與你最後選了什麼各佔一欄,還有理由與它用了哪些欄位。

關注:

Expand Down
16 changes: 16 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 未解決

還沒驗證、還沒決定的事。做到相關區域時順手收掉。
Expand Down
10 changes: 9 additions & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
另一頁的主按鈕與寫入按鈕變灰,旁邊寫著「另一個任務還在跑——等它跑完,或到那一頁停止它」。
分頁之間切換不會打斷任務,切回來畫面接得上。目前在哪一頁寫在網址裡(`app.html#/follows`),
所以誤按上一頁(側鍵、`Alt+←`、觸控板兩指滑動)退回的是上一個分頁,不是離開整個工具。
工具列圖示只會開**一個** App 分頁:已經開著就把它叫到前面,被你導去別的網站或關掉了就重新開一個。

---

Expand Down Expand Up @@ -72,7 +73,7 @@
| 狀態 | 意思 | 畫面上 | 能不能勾選 |
|---|---|---|---|
| 有影片 | B 站回了 `code 0` 且有影片清單 | 顯示最後一支影片的標題、日期、安靜天數 | 可以 |
| 確認無影片 | B 站回了 `code 0` 但清單是空的 | 「沒有影片」、天數欄顯示「從未投稿」並排在最不活躍的那端 | 可以(它比任何門檻都不活躍) |
| 確認無影片 | B 站回了 `code 0` 但影片清單是空的(**只算影片**,圖文與動態不算) | 「沒有影片」、天數欄顯示「從未投稿」並排在最不活躍的那端 | 可以(它比任何門檻都不活躍) |
| 未知 | 請求失敗、B 站回非 0 code、或風控 | 「查不到」+原因,整列變淡、勾選框鎖著 | **不行**,也不會進匯出 |

還有一種「還沒查」——這一輪被你停掉或被風控停下時剩下的帳號。它們跟「未知」一樣鎖著,左邊分面裡都算在「查不到」,
Expand Down Expand Up @@ -223,6 +224,9 @@ relation/whispers ×⌈M/50⌉ →(勾了「包含悄悄關注」才會)
有快取且還新鮮(30 天內、而且不是「未知」)的直接沿用。畫面上是儀表:目前階段、`done / total`、已用與預估剩餘、
實際速率、快取命中、查不到的數量,加上最近幾筆請求(端點、參數、狀態碼與耗時)。

**這支端點只回影片。** 只發圖文、專欄或動態的帳號會被算成「從未投稿」,即使 B 站個人空間的「投稿」頁籤有東西。
這是刻意的——活躍度看的是「還在不在產出影片」;審核表門檻下面那一行也寫著。

遇到 `-352`/`-412`/`-799` 或 HTTP 412:等 60 秒重試、再 120、再 240,還是不行就**整輪停下**,
把已經查到的帶進審核表,剩下的標成「還沒查」。按「停止」也是一樣的結果。

Expand Down Expand Up @@ -275,6 +279,10 @@ relation/whispers ×⌈M/50⌉ →(勾了「包含悄悄關注」才會)
沒有剪貼簿權限時改成下載 `.txt`);「下載 CSV」給名稱、分組、安靜天數、最新投稿、影片連結、個人空間。
兩者都再排除一次「未知」——不管畫面上勾了什麼,未能確認的帳號不會出現在檔案裡。

整理那半邊的作業列也有一顆「下載 CSV」,存的是這一輪的分類結果:BV 號、標題、來源夾、**AI 建議了什麼**、
**你最後選了什麼**、也留在原位、低信心、已失效、理由、依據、狀態。這裡**不**過濾:失效影片與你決定不搬的列
一起留著才是完整紀錄——它不會被拿去執行任何寫入。

### 結果快照

跑完會把整張表(含勾選)存進 `chrome.storage.local`,關掉分頁再開會直接回到審核;「清除結果」才會丟掉。
Expand Down
8 changes: 8 additions & 0 deletions docs/reviews/2026-09-06-full/report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 那一批)。
`<body>` 那一站則不是缺陷:`scripts/ui-preview.mjs` 現在會在審核畫面從第一個分頁按鈕開始按 Tab 走完一整圈
(回到起點才停),量到 **42 站、其中恰好 1 站在 body**,位置在作業列最後一顆「Clear results」與頂列
「Organise」之間——那是「文件最後一個可聚焦元素之後」的瀏覽器預設落點,任何網頁都有。
另外「當前分頁按鈕不在 Tab 順序裡」也不成立:它是普通 `<button>`、沒有 `disabled`,
報告量到的「Tab 1 落在 Folders」只是因為焦點本來就停在 Organise 上。
全 repo 沒有任何 `tabIndex`,焦點樣式(`styles.css` 的全域 `:focus-visible`)也是完整的。
那段走查留著當回歸守門:中間多出任何一站 body 就會讓 `ui-preview` 失敗。

### F17 · S1 · H · app chunk 552 kB,超過 Vite 警告線且沒有分割

Expand Down
11 changes: 11 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
53 changes: 53 additions & 0 deletions scripts/ui-preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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>。走一輪印出完整順序,
// 並斷言一圈裡最多只有一站是 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 <body>; 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);
Expand All @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down
Loading