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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ jobs:
- name: Validate extension
run: |
node --check content.js
node --check player-adapter.js
node --check popup.js
node --check options.js
node scripts/validate-extension.mjs
node scripts/test-extension-logic.mjs
node scripts/test-player-adapter.mjs
- name: Install Worker dependencies
working-directory: server
run: npm ci
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@ jobs:
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
node --check content.js
node --check player-adapter.js
node --check popup.js
node --check options.js
node scripts/validate-extension.mjs
node scripts/test-extension-logic.mjs
node scripts/test-player-adapter.mjs
node -e "const v=require('./manifest.json').version;if(process.env.GITHUB_REF_NAME!=='v'+v)throw new Error('Tag 与 manifest 版本不一致')"
- name: Test Worker
working-directory: server
run: npm ci && npm test && npm run check
- name: Build Chrome ZIP
run: |
mkdir -p dist/douyin-ad-skipper
cp manifest.json content.js content.css popup.html popup.js popup.css options.html options.js options.css LICENSE PRIVACY.md SECURITY.md dist/douyin-ad-skipper/
cp manifest.json player-adapter.js content.js content.css popup.html popup.js popup.css options.html options.js options.css LICENSE PRIVACY.md SECURITY.md dist/douyin-ad-skipper/
mkdir -p dist/douyin-ad-skipper/icons
cp icons/icon-16.png icons/icon-32.png icons/icon-48.png icons/icon-128.png dist/douyin-ad-skipper/icons/
cd dist/douyin-ad-skipper
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# 更新日志

## 0.8.0 - 2026-08-23

- 播放器控制栏适配从单一选择器升级为多候选策略,新增切换作品、ID 提取和控制栏回退的自动回归测试。
- 选项页新增脱敏播放器诊断,一键复制扩展版本、挂载状态和页面结构摘要,不包含作品 ID、Cookie 或账号信息。
- 社区查询会聚合时间高度重叠或边界接近的同类投稿,避免进度条出现多条几乎相同的片段。
- 新增社区贡献中心,集中展示有效投稿、帮助人数、节省时间、收到的赞成/反对和争议状态。
- 投稿者可以修改自己的云端片段或主动撤回;修改后旧投票与帮助统计会重置,撤回后立即停止分发。
- D1 新增片段修订历史表,Worker API 升级至 v4,并补充云端修改、撤回和聚合算法测试。

## 0.7.0 - 2026-08-15

- 产品统一为“抖音网页版社区片段助手”,移除页面广告标签识别和整条视频自动翻页,只处理普通作品时间轴中的用户标记片段。
Expand Down
6 changes: 4 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# 隐私说明

最后更新:2026-08-15
最后更新:2026-08-23

## 当前版本(v0.7.0)
## 当前版本(v0.8.0)

扩展只在 `https://www.douyin.com/*` 页面运行,并使用 Chrome `storage` 权限保存:

- 功能开关;
- 累计跳过次数;
- 尚未提交的本地草稿(作品 ID、片段起止时间、标题、作者、作品链接和创建时间)。
- 最近一次播放器适配诊断(扩展版本、挂载结果、页面路径形状、可见播放器数量和视口尺寸,不含作品 ID、Cookie 或账号资料)。

功能开关、累计跳过次数和未提交草稿保存在 Chrome 扩展本地存储中。首次使用时,用户必须明确选择是否启用社区共享;选择仅本地或撤回同意时不会查询社区 API。只有同意社区共享后,扩展才会生成匿名贡献者 ID 并保存到 Chrome 同步存储,以便同一 Chrome 账号在其他电脑识别自己的社区贡献。已提交片段存放在社区服务器,不再重复保存在本地。

Expand All @@ -34,6 +35,7 @@
- 社区片段成功跳过且未立即撤销时,会上传片段 ID;服务器按匿名贡献者哈希、片段和日期去重,用于统计实际帮助人数、跳过次数和节省时间;
- 提供完全关闭远程查询与提交的选项;
- 社区片段、投票、举报和去重后的帮助统计会在公共服务运营期间保存,或在合法删除请求处理后移除;应用层限流记录会抽样清理超过 48 小时的桶。
- 投稿者修改或撤回云端片段时,服务器保存原时间、原分类、操作类型和操作时间作为修订记录;不会在修订记录中保存明文匿名贡献者 ID。

作品 ID 的 SHA-256 哈希用于减少查询时直接暴露原始 ID,但作品 ID 的取值空间有限,哈希不应被理解为完全匿名或不可反查。公共社区 API 会对写入请求同时按匿名身份哈希与来源 IP 哈希限流;限流记录仅用于防滥用,应用数据库会抽样清理超过 48 小时的限流桶。Cloudflare 基础设施日志的保存与处理还受 Cloudflare 服务配置和政策约束。

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
- 再次播放同一作品时自动跳过本地草稿或社区片段。
- 可从播放器内的提交菜单把待提交片段发送到项目公共社区服务。
- 在选项页查看本地草稿、云端片段和累计贡献时长。
- 在贡献中心查看帮助人数、社区反馈和争议状态,并修改或撤回自己的云端投稿。
- 自动聚合时间高度相近的同类投稿,减少重复色块。
- 播放器适配失败时可复制不含作品 ID 和账号信息的脱敏诊断。
- 导入、导出本地数据。
- 未提交草稿留在本地;已提交片段以社区服务器为准,不上传完整浏览记录。

Expand All @@ -28,7 +31,7 @@
5. 选择本仓库根目录。
6. 打开或刷新 `https://www.douyin.com/`。

也可以从 GitHub Releases 下载自动生成的 Chrome ZIP,解压后按上述方式加载。Release 标签必须与 `manifest.json` 版本一致,例如版本 `0.7.0` 使用标签 `v0.7.0`。
也可以从 GitHub Releases 下载自动生成的 Chrome ZIP,解压后按上述方式加载。Release 标签必须与 `manifest.json` 版本一致,例如版本 `0.8.0` 使用标签 `v0.8.0`。

## 创建片段

Expand Down Expand Up @@ -65,7 +68,7 @@
错误片段被降权或隐藏
```

服务端实现位于 `server/`,包含幂等提交、重复片段检测、投票、举报与限流。公共扩展固定连接项目公共 API;需要自建服务的开发者可以在自己的源码分支中替换地址和清单权限。详见 [服务端说明](server/README.md)、[路线图](docs/ROADMAP.md) 和 [API 草案](docs/API.md)。
服务端实现位于 `server/`,包含幂等提交、相似片段聚合、投稿修改/撤回、投票、举报与限流。公共扩展固定连接项目公共 API;需要自建服务的开发者可以在自己的源码分支中替换地址和清单权限。详见 [服务端说明](server/README.md)、[路线图](docs/ROADMAP.md) 和 [API 草案](docs/API.md)。

## 项目结构

Expand Down
60 changes: 46 additions & 14 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
'use strict';

const DEFAULT_COMMUNITY_API = 'https://douyin-ad-skipper-api.douyin-skip-community.workers.dev';
const adapter = globalThis.DouyinSegmentAdapter;
const DEFAULTS = {
enabled: true,
skipLocalSegments: true,
Expand All @@ -28,6 +29,8 @@
let lastSegmentSkipKey = '';
let mountScheduled = false;
let manualNoticeKey = '';
let diagnosticFingerprint = '';
let diagnosticWrittenAt = 0;
const skipSuppressedUntil = new Map();
const communityCache = new Map();
const COMMUNITY_CACHE_MS = 10 * 60 * 1000;
Expand Down Expand Up @@ -75,12 +78,10 @@
}

function extractVideoId(video) {
const urlMatches = location.pathname.match(/\/video\/(\d+)/);
if (urlMatches) return urlMatches[1];

// 精选、推荐等页面会以弹层打开视频,ID 位于查询参数中。
const queryId = new URLSearchParams(location.search).get('modal_id');
if (/^\d{10,}$/.test(queryId || '')) return queryId;
const locationId = adapter?.idFromLocation(location.pathname, location.search);
if (locationId) return locationId;
const nodeId = adapter?.idFromNode(video);
if (nodeId) return nodeId;

// 播放器节点常见形式:data-e2e="feed-active-video" class="video_123..."。
let node = video;
Expand Down Expand Up @@ -111,6 +112,30 @@
return null;
}

function writeAdapterDiagnostic(reason, video = null, strategy = '') {
const route = location.pathname.startsWith('/video/') ? '/video/:id'
: location.pathname.startsWith('/user/') ? '/user/:redacted'
: /^\/(recommend|discover|follow|hot|channel)(?:\/|$)/.test(location.pathname) ? `/${location.pathname.split('/')[1]}`
: '/other';
const snapshot = {
recordedAt: new Date().toISOString(),
extensionVersion: chrome.runtime.getManifest().version,
reason,
strategy: strategy || 'none',
pathShape: route,
modalRoute: new URLSearchParams(location.search).has('modal_id'),
visibleVideoCount: [...document.querySelectorAll('video')].filter(isVisible).length,
videoIdDetected: Boolean(video && extractVideoId(video)),
controlsMounted: Boolean(document.querySelector('.das-player-controls')),
viewport: `${innerWidth}x${innerHeight}`,
};
const fingerprint = JSON.stringify({ ...snapshot, recordedAt: '' });
if (fingerprint === diagnosticFingerprint && Date.now() - diagnosticWrittenAt < 30000) return;
diagnosticFingerprint = fingerprint;
diagnosticWrittenAt = Date.now();
void chrome.storage.local.set({ lastAdapterDiagnostic: snapshot });
}

function formatTime(seconds) {
if (!Number.isFinite(seconds)) return '--:--.-';
const minutes = Math.floor(seconds / 60);
Expand Down Expand Up @@ -196,7 +221,7 @@
const payload = await response.json();
const segments = Array.isArray(payload.segments) ? payload.segments
.filter((item) => CATEGORY_LABELS[item.category] && Number.isFinite(item.start) && Number.isFinite(item.end) && item.end > item.start)
.map((item) => ({ start: item.start, end: item.end, category:item.category, source: 'community', id: item.id, status: item.status, ownedByMe: item.ownedByMe === true })) : [];
.map((item) => ({ start: item.start, end: item.end, category:item.category, source: 'community', id: item.id, status: item.status, ownedByMe: item.ownedByMe === true, clusterSize:Math.max(1,Number(item.clusterSize||1)) })) : [];
setCommunityCache(videoId, { loadedAt: Date.now(), retryAt: 0, failureCount: 0, segments });
renderPreviewBar();
log('已加载社区片段', videoId, segments.length);
Expand Down Expand Up @@ -353,13 +378,19 @@

function ensurePlayerControls() {
const video = getActiveVideo();
if (!video) return null;
if (!video) {
writeAdapterDiagnostic('no-visible-video');
return null;
}
loadCommunitySegments(video);
if (draftStart !== null && draftVideoId !== extractVideoId(video)) cancelDraft();
let player = video.parentElement;
while (player && !player.querySelector?.('xg-right-grid')) player = player.parentElement;
const rightGrid = player?.querySelector('xg-right-grid');
if (!rightGrid) return null;
const resolved = adapter?.findControlsHost(video);
const player = resolved?.player;
const rightGrid = resolved?.host;
if (!player || !rightGrid) {
writeAdapterDiagnostic('controls-host-missing', video);
return null;
}

// 抖音会预加载前后多个播放器,只在当前活动播放器保留一组按钮。
document.querySelectorAll('.das-player-controls').forEach((element) => {
Expand All @@ -371,13 +402,14 @@
controls.className = 'das-player-controls';
controls.addEventListener('pointerdown', (event) => event.stopPropagation());
controls.addEventListener('click', handlePlayerControl);
const clarity = rightGrid.querySelector('.xgplayer-playclarity-setting');
const clarity = rightGrid.querySelector('.xgplayer-playclarity-setting,[class*="playclarity"],[class*="clarity-setting"]');
// 抖音右侧控制栏使用 row-reverse;放在清晰度节点之后,视觉上才位于其左侧。
if (clarity) rightGrid.insertBefore(controls, clarity.nextSibling);
else rightGrid.appendChild(controls);
renderPlayerControls();
log('标记按钮已嵌入播放器控制栏');
}
writeAdapterDiagnostic('mounted', video, resolved.strategy);
ensurePreviewBar(video, player);
return controls;
}
Expand Down Expand Up @@ -851,7 +883,7 @@
{ label:'举报', run:()=>showReportChoices(segment) },
);
}
const ownership = segment.ownedByMe ? ' · 你的投稿' : '';
const ownership = segment.ownedByMe ? ' · 你的投稿' : segment.clusterSize > 1 ? ` · ${segment.clusterSize} 人相近投稿` : '';
showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}${ownership}`, actions);
log(segment.source === 'community' ? '跳过社区可信片段' : '跳过本地标记片段', videoId, segment);
}
Expand Down
28 changes: 27 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ GET /v1/videos/{videoId}/segments
"end": 31.2,
"category": "sponsor",
"status": "trusted",
"score": 0.92
"score": 0.92,
"clusterSize": 2
}
]
}
Expand All @@ -35,6 +36,8 @@ X-Client-ID: 匿名贡献者 UUID(已启用社区时)

带合法匿名贡献者 ID 时,每个结果额外返回 `ownedByMe`,供客户端隐藏对自己投稿的投票和举报入口;服务端不会返回提交者哈希。原始作品 ID 查询仅保留用于旧客户端兼容和迁移。

同一作品、同一分类中时间高度重叠或起止边界接近的投稿会聚合为一个代表片段;`clusterSize` 大于 1 时表示该结果包含多条相近投稿。

## 提交片段

```http
Expand Down Expand Up @@ -69,6 +72,29 @@ X-Client-ID: 匿名贡献者 UUID
- `skipCount`:这些片段实际被其他匿名用户跳过的次数;
- `helpedPeople`:去重后的匿名用户数;
- `secondsSaved`:实际累计节省秒数。
- `receivedUpvotes` / `receivedDownvotes`:投稿收到的赞成与反对总数;
- `disputedCount`:当前处于争议状态的投稿数量。

## 修改或撤回自己的投稿

```http
PATCH /v1/me/segments/{segmentId}
X-Client-ID: 匿名贡献者 UUID
Content-Type: application/json
```

```json
{ "start": 19.2, "end": 30.8, "category": "sponsor" }
```

修改成功后会清除该片段原有的投票、举报和帮助统计,并记录修改前后的时间与分类。

```http
DELETE /v1/me/segments/{segmentId}
X-Client-ID: 匿名贡献者 UUID
```

撤回会把片段标记为 `rejected` 并停止分发,不直接删除修订记录。

## 记录一次有效跳过

Expand Down
10 changes: 7 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

```text
抖音页面
├─ content.js:活动视频、作品 ID、控制栏按钮、时间片段跳过
├─ player-adapter.js:可测试的作品 ID 与控制栏适配策略
├─ content.js:活动视频、控制栏按钮、脱敏诊断、时间片段跳过
├─ content.css:播放器按钮和提示
└─ chrome.storage.local
├─ 设置
Expand All @@ -15,7 +16,7 @@ popup.* → 快速开关和入口
options.* → 片段管理、设置和备份
```

内容脚本优先通过 `data-e2e="feed-active-video"` 获取当前播放器,并兼容详情页路径、`modal_id`、`data-aweme-id` 和 `video_作品ID` 类名。
内容脚本优先通过 `data-e2e="feed-active-video"` 获取当前播放器,并兼容详情页路径、`modal_id`、`data-aweme-id` 和 `video_作品ID` 类名。控制栏适配支持 `xg-right-grid` 及多个结构候选;最近一次挂载结果以脱敏摘要保存在本地,便于问题反馈。

## 当前社区架构

Expand All @@ -29,7 +30,8 @@ Chrome 扩展
边缘 API / Serverless
├─ Schema 校验
├─ 限流与匿名客户端身份
├─ 片段合并
├─ 相似片段聚合
├─ 投稿修改、撤回与修订历史
├─ 投票与信誉
└─ 审核操作日志
│
Expand All @@ -39,6 +41,8 @@ Chrome 扩展

远程查询失败时必须静默回退到本地片段,不能阻塞视频播放。

查询响应会把时间高度重叠或边界接近的同类投稿聚合为一个代表片段。投稿者修改片段时,旧投票、举报和帮助统计会重置;撤回通过 `rejected` 状态停止分发,并保留修订记录。

## 设计约束

- 扩展不依赖抖音私有接口获取视频内容。
Expand Down
3 changes: 3 additions & 0 deletions docs/DATA_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"upvotes": 2,
"downvotes": 0,
"ownedByMe": false,
"clusterSize": 2,
"createdAt": "2026-08-05T12:00:00Z"
}
```
Expand All @@ -48,3 +49,5 @@
相同作品中高度重叠的片段应复用已有记录,而不是无限创建重复记录。

服务端还保存只用于幂等的 `client_request_id`,并对 `(submitter_hash, client_request_id)` 建立唯一索引。该字段不会在公共查询中返回。

`segment_revisions` 保存投稿者对云端片段执行的修改或撤回操作,包括修改前后的时间和分类。撤回采用 `rejected` 状态而不是直接删除主记录;修改会清理旧投票、举报和帮助统计,使反馈只对应最新边界。
6 changes: 4 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- [x] 本地自动跳过
- [x] 片段管理与备份
- [x] 开源协作文档
- [ ] 自动化 DOM 适配测试
- [x] 自动化 DOM 适配测试
- [x] 进度条显示本地片段

## 阶段 2:只读社区试验
Expand All @@ -24,7 +24,9 @@
- [x] 片段提交与幂等校验
- [x] 速率限制
- [x] 点赞、点踩与举报
- [ ] 相似片段聚类
- [x] 相似片段聚类
- [x] 投稿者修改和撤回自己的云端片段
- [x] 社区贡献中心与反馈状态
- [ ] 管理员审核工具

## 阶段 4:社区治理
Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "抖音网页版社区片段助手",
"version": "0.7.0",
"version": "0.8.0",
"description": "标记、预览和跳过抖音网页版普通作品中的社区时间片段。",
"icons": {
"16": "icons/icon-16.png",
Expand Down Expand Up @@ -30,7 +30,7 @@
"content_scripts": [
{
"matches": ["https://www.douyin.com/*"],
"js": ["content.js"],
"js": ["player-adapter.js", "content.js"],
"css": ["content.css"],
"run_at": "document_idle"
}
Expand Down
Loading