diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..50d3304
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [main, agent/community-api]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: server/package-lock.json
+ - name: Validate extension
+ run: |
+ node --check content.js
+ node --check popup.js
+ node --check options.js
+ node scripts/validate-extension.mjs
+ - name: Install Worker dependencies
+ working-directory: server
+ run: npm ci
+ - name: Test Worker
+ working-directory: server
+ run: npm test
+ - name: Type-check Worker
+ working-directory: server
+ run: npm run check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..bde8c6a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,38 @@
+name: Package release
+
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: write
+
+jobs:
+ package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: server/package-lock.json
+ - name: Verify tag and extension
+ run: |
+ node scripts/validate-extension.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/
+ 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
+ zip -r ../douyin-ad-skipper-${GITHUB_REF_NAME}.zip .
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release create "$GITHUB_REF_NAME" "dist/douyin-ad-skipper-${GITHUB_REF_NAME}.zip" --generate-notes --verify-tag
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f64e27..d72801e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,40 @@
# 更新日志
+## 0.6.2 - 2026-08-12
+
+- 新增 GitHub Actions 持续集成,自动检查扩展脚本、清单、Worker 测试与类型。
+- 新增基于 `v版本号` 标签的 Chrome ZIP 自动打包与 GitHub Release 发布流程。
+- 更新隐私、安全和服务端文档,明确匿名身份、IP 限流和数据保留边界。
+
+## 0.6.1 - 2026-08-12
+
+- 修复 D1 分类约束,使自我推广和互动提醒可正常提交。
+- 社区写接口限制 JSON 请求体大小,并增加身份与 IP 双层限流。
+- 禁止投稿者给自己的片段投票或举报,并自动清理过期限流记录。
+- 导入备份时限制文件、作品和片段数量,严格校验时间、分类、设置与文本长度。
+- 完成旧版全局社区策略到分类策略的数据迁移,并移除废弃设置。
+
+## 0.6.0 - 2026-08-09
+
+- 提交成功后立即把服务器确认片段写入当前页面缓存,进度条颜色无需刷新即可连续显示。
+- 提交社区前必须逐段完成播放器预览。
+- 跳过片段后可撤销;社区片段可直接赞成或反对。
+- 撤销后短时间暂停该片段自动跳过,避免立即重复触发。
+- 社区服务器记录去重后的有效跳过,展示实际帮助人数、跳过次数和节省时间。
+- 自动合并重叠或几乎相邻的本地草稿,并要求重新预览合并结果。
+- 社区片段仅保留最近 100 个作品的页面内存缓存,不写入本地磁盘。
+- 片段管理支持修改本地草稿时间,编辑后必须重新预览。
+- 跳过社区片段后可分别赞成、反对或按原因举报。
+- 社区片段支持自动跳过、手动提示和不处理三种行为模式。
+- 新增播放器快捷键总开关及开始/结束、取消、提交快捷操作。
+- 进度条片段提供状态与起止时间说明;不足 1 秒的片段保存前会二次确认。
+- 播放查询改用作品 ID 的 SHA-256 哈希,不再发送原始作品 ID。
+- 快捷键支持在选项页重新录制;撤销跳过后可一键重新跳过。
+- 新增赞助、自我推广、互动提醒三种片段分类及进度条颜色。
+- 片段管理支持起点和终点 ±0.1 秒精细调整。
+- 按分类分别设置跳过行为:赞助默认自动跳过,自我推广和互动提醒默认显示提示。
+- 关于页版本号改为从扩展清单自动读取,发布更新时同步升级至 0.6.0。
+
## 0.4.0 - 2026-08-05
- 修复切换视频后播放器标记按钮延迟或缺失的问题。
diff --git a/PRIVACY.md b/PRIVACY.md
index ddc5c50..71a06a9 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,16 +1,16 @@
# 隐私说明
-最后更新:2026-08-05
+最后更新:2026-08-12
-## 当前版本(v0.5.0)
+## 当前版本(v0.6.2)
扩展只在 `https://www.douyin.com/*` 页面运行,并使用 Chrome `storage` 权限保存:
- 功能开关;
- 累计跳过次数;
-- 用户手动创建的作品 ID、片段起止时间、标题、作者、作品链接和创建时间。
+- 尚未提交的本地草稿(作品 ID、片段起止时间、标题、作者、作品链接和创建时间)。
-以上数据保存在 Chrome 扩展本地存储中。社区共享功能默认启用并连接项目公共 API,可随时在选项页关闭。关闭时:
+功能开关、累计跳过次数和未提交草稿保存在 Chrome 扩展本地存储中。匿名贡献者 ID 保存在 Chrome 同步存储中,以便同一 Chrome 账号在其他电脑识别自己的社区贡献。已提交片段存放在社区服务器,不再重复保存在本地。社区共享功能可随时在选项页关闭。关闭时:
- 不向项目维护者或第三方服务器发送数据;
- 不收集 Cookie、登录凭据或账号私信;
@@ -23,11 +23,18 @@
社区功能启用时:
-- 浏览视频时会用作品 ID 查询该作品的可用片段,不上传完整观看历史;
+- 浏览视频时会在本地计算作品 ID 的 SHA-256 哈希,并使用哈希查询片段;不发送原始作品 ID,也不上传完整观看历史;
- 只有用户在播放器提交菜单中明确点击“提交到社区”时,才上传作品 ID、片段起止时间、视频时长、分类和随机请求 ID;
- 公共数据不包含抖音用户名或账号标识;
-- 请求使用扩展随机生成的匿名客户端标识;服务端示例会将其与服务端盐、来源 IP 一起散列后用于限流,不保存原始客户端标识;
+- 请求使用扩展随机生成、由 Chrome 同步的匿名贡献者 ID;服务端只保存加盐哈希,不接收 Chrome、QQ 或抖音账号信息;
+- 来源 IP 仅参与限流和基础设施安全日志,不再作为贡献者身份的一部分;
+- 选项页会通过匿名贡献者 ID 拉取该用户已提交的片段和累计贡献时长;
+- 社区片段成功跳过且未立即撤销时,会上传片段 ID;服务器按匿名贡献者哈希、片段和日期去重,用于统计实际帮助人数、跳过次数和节省时间;
- 提供完全关闭远程查询与提交的选项;
- 实际服务器运营者仍需自行公布基础设施日志的保留期限和删除方式。
+作品 ID 的 SHA-256 哈希用于减少查询时直接暴露原始 ID,但作品 ID 的取值空间有限,哈希不应被理解为完全匿名或不可反查。公共社区 API 会对写入请求同时按匿名身份哈希与来源 IP 哈希限流;限流记录仅用于防滥用,应用数据库会抽样清理超过 48 小时的限流桶。Cloudflare 基础设施日志的保存与处理还受 Cloudflare 服务配置和政策约束。
+
+匿名贡献者 ID 不是登录账号或安全凭证。能够读取 Chrome 同步数据的人可能以同一匿名身份查询贡献记录,因此扩展不会使用该 ID 保存 Cookie、密码、支付资料或其他敏感信息。
+
如发现隐私问题,请按照 [SECURITY.md](SECURITY.md) 私下报告。
diff --git a/README.md b/README.md
index 155bde1..2230ed9 100644
--- a/README.md
+++ b/README.md
@@ -10,11 +10,11 @@
- 识别“广告 / 商业推广 / 广告推广 / 推广”等明确页面标识并切换到下一条。
- 在抖音播放器控制栏内创建广告片段起止时间。
- 在播放器进度条中显示已标记片段:待提交为深绿色,已提交/可信片段为亮绿色。
-- 再次播放同一作品时自动跳过本地标记片段。
+- 再次播放同一作品时自动跳过本地草稿或社区片段。
- 可从播放器内的提交菜单把待提交片段发送到自建社区服务。
-- 在选项页查看、搜索、打开和删除本地片段。
+- 在选项页查看本地草稿、云端片段和累计贡献时长。
- 导入、导出本地数据。
-- 完全本地运行,不上传浏览记录。
+- 未提交草稿留在本地;已提交片段以社区服务器为准,不上传完整浏览记录。
## 安装本地测试版
@@ -25,20 +25,35 @@
5. 选择本仓库根目录。
6. 打开或刷新 `https://www.douyin.com/`。
+也可以从 GitHub Releases 下载自动生成的 Chrome ZIP,解压后按上述方式加载。Release 标签必须与 `manifest.json` 版本一致,例如版本 `0.6.2` 使用标签 `v0.6.2`。
+
## 创建片段
播放器底部清晰度按钮左侧会显示片段标记图标:
1. 广告开始时点击“片段从当前开始”。
2. 图标切换为红色取消按钮和青色结束按钮。
-3. 广告结束时点击“片段现在结束”,片段立即保存在本地,并以深绿色显示在进度条上。
+3. 广告结束时点击“片段现在结束”,片段先作为本地草稿保存,并以深绿色显示在进度条上。
4. 此时控制栏会出现“提交”和“删除最后一个未提交片段”按钮;点击提交会打开播放器内菜单。
-5. 提交成功后片段改为亮绿色;点击扩展图标 →“片段管理”也可查看记录。
+5. 在提交菜单逐段点击“预览”;播放器实际越过片段末尾后才允许提交。
+6. 提交成功后本地草稿会删除,亮绿色片段和管理列表从社区服务读取。
+
+自动跳过时间片段后,提示条会短暂提供“撤销”按钮;社区片段还可直接赞成、反对或举报。
+
+片段管理页可以修改本地草稿的开始和结束时间。修改会清除预览状态,必须返回对应视频重新预览;社区片段跳过后可赞成、反对或按“时间错误、不是广告、视频不符、滥用”举报。
+
+社区片段支持“自动跳过、显示提示手动跳过、不处理”三种模式。播放器快捷键默认为:`Alt+Z` 开始/结束片段、`Alt+X` 取消创建、`Alt+Enter` 打开提交菜单;可在选项页的“快捷键”中重新录制,在输入框中不会触发。
+
+片段可标记为“赞助/广告”“自我推广”或“互动提醒”,进度条会使用不同颜色。片段管理页支持对起点和终点进行 ±0.1 秒微调;时间变化后需要重新预览才能上传。
+
+和 BilibiliSponsorBlock 的默认行为一致:赞助/广告自动跳过,自我推广与互动提醒先显示提示,也可以在“社区共享”页分别改成自动跳过或不处理。
## 社区共享
扩展默认连接项目公共社区 API,普通用户安装后即可查询和提交。新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错:
+扩展会把随机匿名贡献者 ID 保存到 Chrome 同步存储;使用同一 Chrome 账号安装扩展后,可在另一台电脑看到自己的云端片段与贡献统计。服务器只保存该 ID 的加盐哈希。
+
```text
用户提交片段 → API 校验 → 立即共享并自动跳过
↓
@@ -70,6 +85,8 @@
欢迎提交问题、页面适配、交互改进和共享服务设计。开始前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和 [SECURITY.md](SECURITY.md)。
+每次推送和 Pull Request 都会运行扩展语法、清单约束、Worker 测试和 TypeScript 检查;推送 `v版本号` 标签后会自动生成不含服务器配置的 Chrome ZIP,并发布到 GitHub Releases。
+
## 许可证
扩展代码使用 [GNU GPL v3](LICENSE) 发布。共享服务端使用 [AGPL-3.0](server/LICENSE)。社区片段数据的开放许可将在服务上线前确定。
diff --git a/SECURITY.md b/SECURITY.md
index a58987c..381e291 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,7 +2,7 @@
## 支持范围
-项目仍处于早期阶段,安全修复优先应用于最新版本。
+项目仍处于早期阶段,安全修复只保证优先应用于最新版本。报告问题前请先在 `chrome://extensions` 确认版本号,并避免在生产社区 API 上进行批量或破坏性测试。
## 报告漏洞
@@ -14,4 +14,13 @@
在 GitHub 仓库启用 Private vulnerability reporting 后,请使用仓库 Security 页面私下报告。在该功能启用前,请联系仓库维护者,并只提供最少必要信息。
+私密报告入口:https://github.com/bbnotcode/douyin-ad-skipper/security/advisories/new
+
报告中请包含受影响版本、复现步骤、影响和建议修复方式。请勿访问他人数据或进行破坏性测试。
+
+## 安全边界
+
+- `X-Client-ID` 是匿名贡献标识,不是认证令牌;不得用于保存或授权敏感数据。
+- 社区 API 是公开服务,主要通过输入校验、请求体限制、身份/IP 限流和社区纠错降低滥用风险。
+- `.dev.vars`、Cloudflare 密钥、数据库导出和浏览器 Cookie 不得提交到仓库或 Issue。
+- 扩展发布包只包含运行所需文件,不包含 Worker 配置、数据库迁移或本地开发文件。
diff --git a/content.css b/content.css
index 7ab1b0b..e9d4e21 100644
--- a/content.css
+++ b/content.css
@@ -98,10 +98,14 @@
min-width: 1px;
background: #00d400;
opacity: .82;
+ pointer-events: auto;
}
-.das-preview-segment.das-pending { background: #007800; }
-.das-preview-segment.das-candidate { background: #b8a900; }
+.das-preview-segment.das-category-sponsor { background:#00d400; }
+.das-preview-segment.das-category-selfpromo { background:#f4d03f; }
+.das-preview-segment.das-category-interaction { background:#d15cff; }
+.das-preview-segment.das-pending { opacity:.5; }
+.das-preview-segment.das-candidate { opacity:.68; background-image:repeating-linear-gradient(135deg,transparent 0 4px,rgba(0,0,0,.28) 4px 8px); }
.das-submission-menu {
position: absolute;
@@ -122,9 +126,18 @@
.das-submission-menu header button { border: 0; color: #aaa; background: transparent; font-size: 24px; cursor: pointer; }
.das-submission-menu p { margin: 10px 0; color: #b8bac1; }
.das-submission-menu ol { max-height: 180px; margin: 0; padding: 0; overflow: auto; list-style: none; }
-.das-submission-menu li { display: flex; justify-content: space-between; padding: 8px 0; border-top: 1px solid rgba(255,255,255,.08); }
+.das-submission-menu li { display: flex; align-items:center; justify-content: space-between; gap:12px; padding: 8px 0; border-top: 1px solid rgba(255,255,255,.08); }
+.das-submission-menu li span { display:flex; flex-direction:column; }
+.das-submission-menu li small { margin-top:2px; color:#8f939d; }
+.das-submission-menu li button { padding:5px 10px; border:1px solid rgba(37,244,238,.3); border-radius:6px; color:#25f4ee; background:rgba(37,244,238,.08); cursor:pointer; }
+.das-submission-menu li select { max-width:105px; padding:5px 6px; border:1px solid rgba(255,255,255,.18); border-radius:6px; color:#fff; background:#292c33; }
.das-submission-menu li em { color: #9da0a9; font-style: normal; }
.das-submission-actions { display: flex; gap: 8px; margin-top: 14px; }
.das-submission-actions button { flex: 1; padding: 9px 10px; border: 0; border-radius: 7px; color: #fff; background: #34363d; cursor: pointer; }
.das-submission-actions button:first-child { color: #161823; background: #25f4ee; font-weight: 700; }
.das-submission-actions button:disabled { opacity: .55; cursor: wait; }
+
+.das-toast-actions { display:flex; gap:6px; margin-left:12px; pointer-events:auto; }
+.das-toast-actions button { padding:4px 8px; border:1px solid rgba(255,255,255,.24); border-radius:6px; color:#fff; background:rgba(255,255,255,.1); cursor:pointer; }
+.das-toast-actions button:hover { border-color:#25f4ee; color:#25f4ee; }
+.das-toast-actions button:disabled { opacity:.55; cursor:default; }
diff --git a/content.js b/content.js
index fbb48f8..303c59a 100644
--- a/content.js
+++ b/content.js
@@ -8,10 +8,16 @@
skipLocalSegments: true,
communityEnabled: true,
communityApiBase: DEFAULT_COMMUNITY_API,
- communityAutoSkipTrusted: true,
+ categoryModeSponsor: 'auto',
+ categoryModeSelfpromo: 'manual',
+ categoryModeInteraction: 'manual',
communityClientId: '',
showToast: true,
debug: false,
+ shortcutsEnabled: true,
+ shortcutCreate: 'Alt+KeyZ',
+ shortcutCancel: 'Alt+KeyX',
+ shortcutSubmit: 'Alt+Enter',
skippedCount: 0,
localSegments: {},
};
@@ -26,8 +32,12 @@
let draftVideoId = null;
let lastSegmentSkipKey = '';
let mountScheduled = false;
+ let manualNoticeKey = '';
+ const skipSuppressedUntil = new Map();
const communityCache = new Map();
const COMMUNITY_CACHE_MS = 10 * 60 * 1000;
+ const CATEGORY_LABELS = { sponsor:'赞助/广告', selfpromo:'自我推广', interaction:'互动提醒' };
+ const CATEGORY_SETTING_KEYS = { sponsor:'categoryModeSponsor', selfpromo:'categoryModeSelfpromo', interaction:'categoryModeInteraction' };
const log = (...args) => settings.debug && console.debug('[抖音广告跳过]', ...args);
@@ -116,6 +126,33 @@
return id ? (settings.localSegments?.[id] || []) : [];
}
+ function mergeOverlappingDrafts(segments) {
+ const sorted = [...segments].sort((a, b) => a.start - b.start);
+ const merged = [];
+ for (const segment of sorted) {
+ const previous = merged[merged.length - 1];
+ if (!previous || segment.start > previous.end + 0.25) {
+ merged.push(segment);
+ continue;
+ }
+ merged[merged.length - 1] = {
+ ...previous,
+ ...segment,
+ start: Math.min(previous.start, segment.start),
+ end: Math.max(previous.end, segment.end),
+ createdAt: Date.now(),
+ previewed: false,
+ };
+ }
+ return merged;
+ }
+
+ function setCommunityCache(videoId, value) {
+ communityCache.delete(videoId);
+ communityCache.set(videoId, value);
+ while (communityCache.size > 100) communityCache.delete(communityCache.keys().next().value);
+ }
+
function communitySegments(video) {
const id = extractVideoId(video);
const cached = id && communityCache.get(id);
@@ -131,6 +168,11 @@
}
}
+ async function sha256Hex(value) {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
+ }
+
async function loadCommunitySegments(video) {
if (!settings.communityEnabled) return;
const videoId = extractVideoId(video);
@@ -138,24 +180,25 @@
if (!videoId || !apiBase) return;
const cached = communityCache.get(videoId);
if (cached && Date.now() - cached.loadedAt < COMMUNITY_CACHE_MS) return;
- communityCache.set(videoId, { loadedAt: Date.now(), segments: cached?.segments || [], loading: true });
+ setCommunityCache(videoId, { loadedAt: Date.now(), segments: cached?.segments || [], loading: true });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
- const response = await fetch(`${apiBase}/v1/videos/${videoId}/segments`, {
+ const videoHash = await sha256Hex(videoId);
+ const response = await fetch(`${apiBase}/v1/videos/by-hash/${videoHash}/segments`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const segments = Array.isArray(payload.segments) ? payload.segments
- .filter((item) => item.category === 'sponsor' && Number.isFinite(item.start) && Number.isFinite(item.end) && item.end > item.start)
- .map((item) => ({ start: item.start, end: item.end, source: 'community', id: item.id, status: item.status })) : [];
- communityCache.set(videoId, { loadedAt: Date.now(), 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 })) : [];
+ setCommunityCache(videoId, { loadedAt: Date.now(), segments });
renderPreviewBar();
log('已加载社区片段', videoId, segments.length);
} catch (error) {
- communityCache.set(videoId, { loadedAt: Date.now(), segments: cached?.segments || [] });
+ setCommunityCache(videoId, { loadedAt: Date.now(), segments: cached?.segments || [] });
log('社区片段查询失败,继续使用本地数据', error);
} finally {
clearTimeout(timer);
@@ -211,7 +254,7 @@
return null;
}
- function showToast(message) {
+ function showToast(message, actions = []) {
if (!settings.showToast) return;
let toast = document.getElementById('das-toast');
if (!toast) {
@@ -219,15 +262,34 @@
toast.id = 'das-toast';
document.documentElement.appendChild(toast);
}
- toast.textContent = message;
+ toast.replaceChildren();
+ const text = document.createElement('span');
+ text.textContent = message;
+ toast.appendChild(text);
+ if (actions.length) {
+ const controls = document.createElement('span');
+ controls.className = 'das-toast-actions';
+ for (const action of actions) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = action.label;
+ button.addEventListener('click', (event) => {
+ event.stopPropagation();
+ controls.querySelectorAll('button').forEach((item) => { item.disabled = true; });
+ action.run();
+ });
+ controls.appendChild(button);
+ }
+ toast.appendChild(controls);
+ }
toast.classList.remove('das-visible');
requestAnimationFrame(() => toast.classList.add('das-visible'));
clearTimeout(showToast.timer);
- showToast.timer = setTimeout(() => toast.classList.remove('das-visible'), 1800);
+ showToast.timer = setTimeout(() => toast.classList.remove('das-visible'), actions.length ? 6000 : 1800);
}
- async function recordSkip() {
- settings.skippedCount = Number(settings.skippedCount || 0) + 1;
+ async function recordSkip(change = 1) {
+ settings.skippedCount = Math.max(0, Number(settings.skippedCount || 0) + change);
await chrome.storage.local.set({ skippedCount: settings.skippedCount, lastSkippedAt: Date.now() });
}
@@ -294,6 +356,25 @@
});
}
+ function handleShortcut(event) {
+ if (!settings.shortcutsEnabled || event.repeat) return;
+ const target = event.target;
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable) return;
+ let action='';
+ const signature=shortcutSignature(event);
+ if(signature===settings.shortcutCreate)action=draftStart===null?'start':'end';
+ if(signature===settings.shortcutCancel&&draftStart!==null)action='cancel';
+ if(signature===settings.shortcutSubmit&&draftStart===null)action='submit';
+ if(!action)return;
+ const controls=ensurePlayerControls();const button=controls?.querySelector(`[data-action="${action}"]`);
+ if(!button)return;
+ event.preventDefault();event.stopPropagation();button.click();
+ }
+
+ function shortcutSignature(event) {
+ return [event.ctrlKey?'Ctrl':'',event.altKey?'Alt':'',event.shiftKey?'Shift':'',event.metaKey?'Meta':'',event.code].filter(Boolean).join('+');
+ }
+
async function handlePlayerControl(event) {
event.preventDefault();
event.stopPropagation();
@@ -322,10 +403,10 @@
showToast('结束时间必须晚于开始时间');
return;
}
+ if (end - draftStart < 1 && !confirm('这个片段不足 1 秒,时间点可能不准确。仍然保存吗?')) return;
const savedStart = draftStart;
const metadata = getVideoMetadata(video, videoId);
- const segments = [...currentSegments(video), { start: savedStart, end, createdAt: Date.now(), submissionStatus: 'pending', ...metadata }]
- .sort((a, b) => a.start - b.start);
+ const segments = mergeOverlappingDrafts([...currentSegments(video), { start: savedStart, end, category:'sponsor', createdAt: Date.now(), submissionStatus: 'pending', previewed:false, ...metadata }]);
const localSegments = { ...(settings.localSegments || {}), [videoId]: segments };
settings.localSegments = localSegments;
await chrome.storage.local.set({ localSegments });
@@ -381,20 +462,54 @@
if (!pending.length) return;
const menu = document.createElement('section');
menu.className = 'das-submission-menu';
+ const allPreviewed = pending.every((item) => item.previewed === true);
menu.innerHTML = `
-
以下片段会作为“赞助/广告”提交;审核通过后,其他用户也能自动跳过。
- ${pending.map((item) => `- ${formatTime(item.start)} – ${formatTime(item.end)}${(item.end - item.start).toFixed(1)} 秒
`).join('')}
- `;
+ 提交前请逐段预览,确认开始和结束时间准确。
+ ${pending.map((item,index) => `- ${formatTime(item.start)} – ${formatTime(item.end)}${item.previewed?'✓ 已预览':'尚未预览'}
`).join('')}
+ `;
menu.addEventListener('pointerdown', (event) => event.stopPropagation());
menu.addEventListener('click', async (event) => {
event.stopPropagation();
const action = event.target.closest('button')?.dataset.menuAction;
if (action === 'close' || action === 'keep') closeSubmissionMenu();
+ if (action === 'preview') await previewPendingSegment(video, videoId, pending[Number(event.target.closest('button').dataset.previewIndex)]);
if (action === 'submit') await submitPendingSegments(video, videoId, menu);
});
+ menu.addEventListener('change', async (event) => {
+ const select=event.target.closest('select[data-menu-action="category"]');if(!select)return;
+ const target=pending[Number(select.dataset.previewIndex)];if(!target||!CATEGORY_LABELS[select.value])return;
+ const segments=[...(settings.localSegments?.[videoId]||[])];const stored=segments.find((item)=>item.createdAt===target.createdAt);
+ if(stored)stored.category=select.value;target.category=select.value;
+ settings.localSegments={...(settings.localSegments||{}),[videoId]:segments};await chrome.storage.local.set({localSegments:settings.localSegments});renderPreviewBar();
+ });
player.appendChild(menu);
}
+ async function previewPendingSegment(video, videoId, segment) {
+ if (!segment) return;
+ video.currentTime = Math.max(0, segment.start - 2);
+ try { await video.play(); } catch {}
+ showToast(`正在预览 ${formatTime(segment.start)}–${formatTime(segment.end)}`);
+ const startedAt = Date.now();
+ const timer = setInterval(async () => {
+ if (extractVideoId(video) !== videoId || Date.now() - startedAt > 15000) {
+ clearInterval(timer);
+ if (Date.now() - startedAt > 15000) showToast('未完成预览,请保持播放至片段结束');
+ return;
+ }
+ if (video.currentTime < segment.end - 0.15) return;
+ clearInterval(timer);
+ const segments = [...(settings.localSegments?.[videoId] || [])];
+ const target = segments.find((item) => item.createdAt === segment.createdAt && item.start === segment.start && item.end === segment.end);
+ if (target) target.previewed = true;
+ settings.localSegments = { ...(settings.localSegments || {}), [videoId]: segments };
+ await chrome.storage.local.set({ localSegments: settings.localSegments });
+ closeSubmissionMenu();
+ openSubmissionMenu(video, videoId);
+ showToast('片段预览完成');
+ }, 150);
+ }
+
async function submitPendingSegments(video, videoId, menu) {
const apiBase = normalizedApiBase();
if (!settings.communityEnabled || !apiBase) {
@@ -403,9 +518,16 @@
return;
}
const button = menu.querySelector('[data-menu-action="submit"]');
+ const pending = currentSegments(video).filter((segment) => (segment.submissionStatus || 'pending') === 'pending');
+ if (!pending.length || pending.some((segment) => segment.previewed !== true)) {
+ showToast('请先预览全部待提交片段');
+ return;
+ }
button.disabled = true;
button.textContent = '提交中…';
const segments = [...currentSegments(video)];
+ const submittedSegments = new Set();
+ const confirmedCommunitySegments = [];
let submitted = 0;
for (const segment of segments) {
if ((segment.submissionStatus || 'pending') !== 'pending') continue;
@@ -413,19 +535,37 @@
const response = await fetch(`${apiBase}/v1/segments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Client-ID': settings.communityClientId },
- body: JSON.stringify({ videoId, start: segment.start, end: segment.end, duration: video.duration, category: 'sponsor', clientRequestId: crypto.randomUUID() }),
+ body: JSON.stringify({ videoId, start: segment.start, end: segment.end, duration: video.duration, category: segment.category || 'sponsor', clientRequestId: crypto.randomUUID() }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
- segment.submissionStatus = 'submitted';
- segment.communityId = payload.segment?.id || payload.id || '';
+ const confirmed = payload.segment;
+ if (confirmed && Number.isFinite(confirmed.start) && Number.isFinite(confirmed.end)) {
+ confirmedCommunitySegments.push({
+ start: confirmed.start,
+ end: confirmed.end,
+ source: 'community',
+ id: confirmed.id,
+ status: confirmed.status || 'trusted',
+ category: confirmed.category || segment.category || 'sponsor',
+ });
+ }
+ submittedSegments.add(segment);
submitted += 1;
} catch (error) {
log('社区片段提交失败', error);
}
}
- const localSegments = { ...(settings.localSegments || {}), [videoId]: segments };
+ const localSegments = { ...(settings.localSegments || {}) };
+ const remaining = segments.filter((segment) => !submittedSegments.has(segment));
+ if (remaining.length) localSegments[videoId] = remaining;
+ else delete localSegments[videoId];
settings.localSegments = localSegments;
+ if (confirmedCommunitySegments.length) {
+ const cached = communityCache.get(videoId);
+ const byId = new Map([...(cached?.segments || []), ...confirmedCommunitySegments].map((item) => [item.id || `${item.start}:${item.end}`, item]));
+ setCommunityCache(videoId, { loadedAt: Date.now(), segments: [...byId.values()] });
+ }
await chrome.storage.local.set({ localSegments });
closeSubmissionMenu();
renderPlayerControls();
@@ -476,7 +616,11 @@
items.sort((a, b) => (b.end - b.start) - (a.end - a.start));
bar.replaceChildren(...items.map((item) => {
const segment = document.createElement('span');
- segment.className = `das-preview-segment das-${item.previewState}`;
+ const category=item.category||'sponsor';
+ segment.className = `das-preview-segment das-${item.previewState} das-category-${category}`;
+ const stateName = item.previewState === 'pending' ? '待提交' : item.previewState === 'candidate' ? '待确认' : '社区';
+ segment.title = `${stateName}${CATEGORY_LABELS[category]||'片段'} ${formatTime(item.start)}–${formatTime(item.end)}`;
+ segment.setAttribute('aria-label', segment.title);
segment.style.left = `${Math.max(0, item.start / video.duration * 100)}%`;
segment.style.width = `${Math.max(0.08, (Math.min(video.duration, item.end) - Math.max(0, item.start)) / video.duration * 100)}%`;
return segment;
@@ -484,28 +628,123 @@
}
async function checkLocalSegments() {
- if (!settings.enabled || !settings.skipLocalSegments || document.hidden) return;
+ if (!settings.enabled || document.hidden) return;
const video = getActiveVideo();
if (!video || video.paused || video.seeking) return;
const videoId = extractVideoId(video);
if (!videoId) return;
loadCommunitySegments(video);
const now = video.currentTime;
- const available = [
- ...currentSegments(video),
- ...(settings.communityAutoSkipTrusted ? communitySegments(video).filter((segment) => segment.status === 'trusted') : []),
- ];
+ const localAvailable = settings.skipLocalSegments ? currentSegments(video) : [];
+ const trustedCommunity = communitySegments(video).filter((segment) => segment.status === 'trusted');
+ const modeFor=(segment)=>settings[CATEGORY_SETTING_KEYS[segment.category||'sponsor']]||'manual';
+ const manualSegment = trustedCommunity.filter((segment)=>modeFor(segment)==='manual').find(({start,end}) => now >= start - 0.12 && now < end - 0.05);
+ if (manualSegment) {
+ const noticeKey=`${videoId}:${manualSegment.id}:${manualSegment.start}`;
+ if(manualNoticeKey!==noticeKey){
+ manualNoticeKey=noticeKey;
+ showToast(`发现${CATEGORY_LABELS[manualSegment.category||'sponsor']} ${formatTime(manualSegment.start)}–${formatTime(manualSegment.end)}`, [
+ {label:'立即跳过',run:()=>skipKnownSegment(video,videoId,manualSegment)},
+ {label:'本次忽略',run:()=>skipSuppressedUntil.set(`${videoId}:${manualSegment.start}:${manualSegment.end}`,Date.now()+Math.max(1000,(manualSegment.end-video.currentTime+1)*1000))},
+ ]);
+ }
+ } else manualNoticeKey='';
+ const available = [...localAvailable, ...trustedCommunity.filter((segment)=>modeFor(segment)==='auto')];
const segment = available.find(({ start, end }) => now >= start - 0.12 && now < end - 0.05);
if (!segment) return;
const key = `${videoId}:${segment.start}:${segment.end}`;
+ if (Number(skipSuppressedUntil.get(key) || 0) > Date.now()) return;
if (lastSegmentSkipKey === key && Math.abs(now - segment.start) > 0.5) return;
+ await skipKnownSegment(video,videoId,segment,key);
+ }
+
+ async function skipKnownSegment(video, videoId, segment, knownKey) {
+ const key=knownKey||`${videoId}:${segment.start}:${segment.end}`;
+ if (Number(skipSuppressedUntil.get(key) || 0) > Date.now()) return;
lastSegmentSkipKey = key;
video.currentTime = Math.min(segment.end, video.duration || segment.end);
await recordSkip();
- showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}`);
+ const impactTimer = segment.source === 'community' && segment.id
+ ? setTimeout(() => { void recordCommunitySkip(segment); }, 3000)
+ : null;
+ const undo = async () => {
+ if (impactTimer) clearTimeout(impactTimer);
+ skipSuppressedUntil.set(key, Date.now() + 12000);
+ video.currentTime = Math.max(0, segment.start);
+ await recordSkip(-1);
+ showToast('已撤销跳过,12 秒内不会再次自动跳过', [{
+ label:'重新跳过',
+ run:()=>{skipSuppressedUntil.delete(key);lastSegmentSkipKey='';void skipKnownSegment(video,videoId,segment,key)},
+ }]);
+ };
+ const actions = [{ label:'撤销', run:undo }];
+ if (segment.source === 'community' && segment.id) {
+ actions.push(
+ { label:'赞成', run:()=>voteOnSegment(segment,1) },
+ { label:'反对', run:()=>voteOnSegment(segment,-1) },
+ { label:'举报', run:()=>showReportChoices(segment) },
+ );
+ }
+ showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}`, actions);
log(segment.source === 'community' ? '跳过社区可信片段' : '跳过本地标记片段', videoId, segment);
}
+ async function recordCommunitySkip(segment) {
+ const apiBase = normalizedApiBase();
+ if (!apiBase || !settings.communityClientId) return;
+ try {
+ const response = await fetch(`${apiBase}/v1/segments/${segment.id}/skips`, {
+ method:'POST', headers:{'X-Client-ID':settings.communityClientId},
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ log('已记录社区片段贡献', segment.id);
+ } catch (error) {
+ log('记录社区片段贡献失败', error);
+ }
+ }
+
+ async function voteOnSegment(segment, vote) {
+ const apiBase = normalizedApiBase();
+ if (!apiBase || !settings.communityClientId) return;
+ try {
+ const response = await fetch(`${apiBase}/v1/segments/${segment.id}/votes`, {
+ method:'POST', headers:{'Content-Type':'application/json','X-Client-ID':settings.communityClientId}, body:JSON.stringify({vote}),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const result = await response.json();
+ segment.status = result.status || segment.status;
+ showToast(vote === 1 ? '感谢确认这个片段' : '已反馈:这个片段有问题');
+ } catch (error) {
+ log('片段投票失败', error);
+ showToast('反馈失败,请稍后重试');
+ }
+ }
+
+ function showReportChoices(segment) {
+ showToast('请选择问题类型', [
+ { label:'时间错误', run:()=>reportSegment(segment,'wrong_time') },
+ { label:'不是广告', run:()=>reportSegment(segment,'not_ad') },
+ { label:'视频不符', run:()=>reportSegment(segment,'wrong_video') },
+ { label:'滥用', run:()=>reportSegment(segment,'abuse') },
+ ]);
+ }
+
+ async function reportSegment(segment, reason) {
+ const apiBase = normalizedApiBase();
+ if (!apiBase || !settings.communityClientId) return;
+ try {
+ const response = await fetch(`${apiBase}/v1/segments/${segment.id}/reports`, {
+ method:'POST', headers:{'Content-Type':'application/json','X-Client-ID':settings.communityClientId}, body:JSON.stringify({reason}),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ await response.json();
+ showToast('举报已提交,感谢帮助维护社区质量');
+ } catch (error) {
+ log('片段举报失败', error);
+ showToast('举报失败,请稍后重试');
+ }
+ }
+
function moveToNextVideo() {
const button = findNextButton();
if (button) {
@@ -542,21 +781,36 @@
log('命中广告标识', signal.type, signal.text, signal.element);
}
- chrome.storage.local.get(DEFAULTS, (stored) => {
+ async function getContributorId() {
+ const synced = await chrome.storage.sync.get({ communityContributorId: '' });
+ if (/^[0-9a-f-]{16,64}$/i.test(synced.communityContributorId)) return synced.communityContributorId;
+ const legacy = await chrome.storage.local.get({ communityClientId: '' });
+ const id = /^[0-9a-f-]{16,64}$/i.test(legacy.communityClientId) ? legacy.communityClientId : crypto.randomUUID();
+ await chrome.storage.sync.set({ communityContributorId: id });
+ await chrome.storage.local.remove('communityClientId');
+ return id;
+ }
+
+ (async () => {
+ const stored = await chrome.storage.local.get(null);
settings = { ...DEFAULTS, ...stored };
- if (/^https:\/\/douyin-ad-skipper-api\.\d+\.workers\.dev\/?$/.test(settings.communityApiBase)) {
+ if (!Object.hasOwn(stored, 'categoryModeSponsor')) {
+ const legacyMode = ['auto', 'manual', 'disabled'].includes(stored.communitySkipMode) ? stored.communitySkipMode : 'auto';
+ const categoryModes = { categoryModeSponsor: legacyMode, categoryModeSelfpromo: legacyMode, categoryModeInteraction: legacyMode };
+ settings = { ...settings, ...categoryModes };
+ await chrome.storage.local.set(categoryModes);
+ }
+ await chrome.storage.local.remove(['communitySkipMode', 'communityAutoSkipTrusted']);
+ if (!settings.communityApiBase || /^https:\/\/douyin-ad-skipper-api\.\d+\.workers\.dev\/?$/.test(settings.communityApiBase)) {
settings.communityApiBase = DEFAULT_COMMUNITY_API;
settings.communityEnabled = true;
- chrome.storage.local.set({ communityApiBase: DEFAULT_COMMUNITY_API, communityEnabled: true });
- }
- if (!settings.communityClientId) {
- settings.communityClientId = crypto.randomUUID();
- chrome.storage.local.set({ communityClientId: settings.communityClientId });
+ await chrome.storage.local.set({ communityApiBase: DEFAULT_COMMUNITY_API, communityEnabled: true });
}
+ settings.communityClientId = await getContributorId();
log('扩展已启动', settings);
checkCurrentVideo();
ensurePlayerControls();
- });
+ })();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
@@ -577,6 +831,7 @@
document.addEventListener('play', schedulePlayerControls, true);
document.addEventListener('loadedmetadata', schedulePlayerControls, true);
document.addEventListener('pointermove', schedulePlayerControls, { passive: true });
+ document.addEventListener('keydown', handleShortcut, true);
setInterval(checkCurrentVideo, CHECK_INTERVAL_MS);
setInterval(checkLocalSegments, 250);
setInterval(schedulePlayerControls, 300);
diff --git a/docs/API.md b/docs/API.md
index 3a512be..d86f294 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1,6 +1,6 @@
# 社区共享 API 草案
-本文档用于讨论,不代表已部署服务。
+本文档描述当前公共服务接口。
## 查询片段
@@ -26,6 +26,14 @@ GET /v1/videos/{videoId}/segments
}
```
+扩展正常播放查询使用隐私版本:
+
+```http
+GET /v1/videos/by-hash/{sha256(videoId)}/segments
+```
+
+原始作品 ID 查询仅保留用于旧客户端兼容和迁移。
+
## 提交片段
```http
@@ -46,6 +54,30 @@ Content-Type: application/json
服务端必须校验数值范围、片段长度、视频时长、重复请求和提交速率。
+## 我的社区片段
+
+```http
+GET /v1/me/segments
+X-Client-ID: 匿名贡献者 UUID
+```
+
+返回该匿名贡献者已提交的片段,以及 `submittedCount` 和 `contributedSeconds`。服务端只使用 `X-Client-ID` 的加盐哈希查询,不保存原始值。
+
+统计中还包含:
+
+- `skipCount`:这些片段实际被其他匿名用户跳过的次数;
+- `helpedPeople`:去重后的匿名用户数;
+- `secondsSaved`:实际累计节省秒数。
+
+## 记录一次有效跳过
+
+```http
+POST /v1/segments/{segmentId}/skips
+X-Client-ID: 匿名贡献者 UUID
+```
+
+同一匿名用户对同一片段每天最多计入一次,提交者自己跳过自己的片段不计入。
+
## 投票
```http
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 8de40c0..416d51c 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -1,6 +1,6 @@
# 数据模型草案
-## 本地片段(当前)
+## 本地片段(仅未提交草稿)
```json
{
diff --git a/manifest.json b/manifest.json
index 81c43c0..a90a8d8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
- "name": "抖音广告自动跳过(本地版)",
- "version": "0.5.0",
- "description": "跳过抖音网页版广告,并在播放器控制栏中手动标记广告片段。",
+ "name": "抖音广告跳过",
+ "version": "0.6.2",
+ "description": "跳过抖音网页版广告,并与社区共享手动标记的广告片段。",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
diff --git a/options.css b/options.css
index 3120729..81080cd 100644
--- a/options.css
+++ b/options.css
@@ -29,6 +29,11 @@ nav button i { min-width:22px; padding:1px 7px; border-radius:20px; color:#e1e4e
.metric-grid article { display:flex; flex-direction:column; min-height:145px; padding:20px; border:1px solid var(--line); border-radius:14px; background:linear-gradient(145deg,#191c23,#14171c); }
.metric-grid span,.metric-grid small { color:var(--muted); }
.metric-grid strong { margin:auto 0 1px; font-size:29px; font-variant-numeric:tabular-nums; }
+.community-impact-card { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:16px; margin-bottom:18px; padding:18px 21px; border:1px solid rgba(37,244,238,.22); border-radius:14px; background:linear-gradient(110deg,rgba(37,244,238,.09),rgba(254,44,85,.06)); }
+.impact-mark { display:grid; place-items:center; width:43px; height:43px; border-radius:12px; color:#101216; background:linear-gradient(135deg,var(--cyan),#7af8f3); font-size:23px; font-weight:800; }
+.community-impact-card span,.community-impact-card small { display:block; color:var(--muted); }
+.community-impact-card strong { display:block; margin:2px 0; font-size:17px; }
+.community-impact-card b { color:var(--cyan); font-size:22px; font-variant-numeric:tabular-nums; }
.panel,.settings-card { border:1px solid var(--line); border-radius:15px; background:var(--card); }
.panel { padding:22px; }
.panel-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
@@ -55,8 +60,14 @@ h2 { margin:0; font-size:17px; }
.video-head span { display:block; color:var(--muted); font-size:12px; }
.video-head em { padding:4px 9px; border-radius:99px; color:var(--cyan); background:rgba(37,244,238,.09); font-size:11px; font-style:normal; }
.segment-row { display:grid; grid-template-columns:120px 1fr auto; align-items:center; gap:18px; padding:14px 19px; border-top:1px solid var(--line); }
+.nudge-controls { display:grid; grid-template-columns:repeat(2,1fr); gap:4px; margin-top:7px; }
+.nudge-controls button { padding:3px 5px; border:1px solid #3a404b; border-radius:5px; color:#abb1bd; background:#22262e; cursor:pointer; font-size:10px; }
+.nudge-controls button:hover { border-color:var(--cyan); color:var(--cyan); }
+.category-select { max-width:118px; padding:7px 8px; border:1px solid #3a3f4a; border-radius:7px; color:var(--text); background:#20232a; }
.time-range { color:#fff; font-weight:650; font-variant-numeric:tabular-nums; }
.duration-pill { color:var(--muted); font-size:12px; }
+.edit-button { padding:7px 10px; border:1px solid #3b4350; border-radius:8px; color:#c7cbd3; background:#252932; cursor:pointer; }
+.edit-button:hover { color:#fff; background:#303641; }
.delete-button { padding:7px 10px; border:1px solid #46303a; border-radius:8px; color:#ff718d; background:#261a20; cursor:pointer; }
.delete-button:hover { background:#372029; }
.segment-status { display:inline-block; margin-left:8px; padding:2px 7px; border-radius:99px; color:#8d929f; background:#272b34; font-size:10px; font-weight:600; }
@@ -69,6 +80,12 @@ h2 { margin:0; font-size:17px; }
.setting-row i::after { content:""; position:absolute; top:3px; left:3px; width:19px; height:19px; border-radius:50%; background:white; transition:.18s; }
.setting-row input:checked+i { background:var(--pink); }
.setting-row input:checked+i::after { transform:translateX(19px); }
+.setting-row select { min-width:190px; padding:9px 34px 9px 11px; border:1px solid #3a3f4a; border-radius:8px; color:var(--text); background:#20232a; }
+.shortcut-row { display:grid; grid-template-columns:1fr auto; align-items:center; gap:16px; min-height:74px; padding:14px 21px; border-top:1px solid var(--line); }
+.shortcut-row p { margin:3px 0 0; color:var(--muted); }
+.shortcut-capture { min-width:132px; padding:8px 11px; border:1px solid #4a505c; border-bottom-width:3px; border-radius:7px; color:#fff; background:#292d36; cursor:pointer; font-weight:650; }
+.shortcut-capture.capturing { border-color:var(--cyan); color:var(--cyan); }
+.shortcut-help { margin:14px 20px 20px; }
.info-card { margin-top:16px; padding:18px 20px; border:1px solid rgba(37,244,238,.2); border-radius:12px; background:rgba(37,244,238,.06); }
.info-card span { color:var(--cyan); font-weight:700; }
.info-card p { margin:5px 0 0; color:#b8bdc8; }
@@ -78,6 +95,7 @@ h2 { margin:0; font-size:17px; }
.community-input input { flex:1; padding:11px 13px; border:1px solid #363b46; border-radius:9px; outline:0; color:var(--text); background:#101217; }
.community-input input:focus { border-color:var(--cyan); }
.community-input button { padding:10px 15px; border:0; border-radius:9px; color:#101216; background:var(--cyan); font-weight:700; cursor:pointer; }
+.community-input button:disabled { color:var(--cyan); border:1px solid rgba(37,244,238,.28); background:rgba(37,244,238,.1); cursor:default; }
.community-actions { display:flex; justify-content:flex-end; }
.data-grid { display:grid; grid-template-columns:1fr 1fr; gap:15px; }
.data-grid article { padding:24px; border:1px solid var(--line); border-radius:14px; background:var(--card); }
@@ -85,6 +103,7 @@ h2 { margin:0; font-size:17px; }
.primary-button,.secondary-button { margin-top:22px; padding:10px 15px; border-radius:9px; cursor:pointer; }
.primary-button { border:0; color:#101216; background:var(--cyan); font-weight:700; }
.secondary-button { border:1px solid #3a3f4a; color:#fff; background:#242832; }
+.secondary-button:disabled { opacity:.45; cursor:default; }
.danger-zone { display:flex; align-items:center; justify-content:space-between; margin-top:18px; padding:20px 22px; border:1px solid #4c2831; border-radius:14px; background:#211419; }
.danger-zone button { padding:9px 13px; border:1px solid #7c3344; border-radius:8px; color:#ff8aa1; background:#351923; cursor:pointer; }
.about-card { display:flex; gap:23px; align-items:center; padding:28px; border:1px solid var(--line); border-radius:16px; background:var(--card); }
diff --git a/options.html b/options.html
index 7c10e1b..f717a13 100644
--- a/options.html
+++ b/options.html
@@ -9,36 +9,42 @@
- DASHBOARD
本地跳过概览
查看标记片段、累计跳过和当前运行状态。
本地模式
+ DASHBOARD
跳过概览
查看草稿、社区贡献、累计跳过和当前运行状态。
社区模式
已标记片段0个时间片段
涉及视频0个抖音作品
累计跳过0次自动跳过
标记总时长0:00预计可节省时间
+
- LOCAL LIBRARY
片段管理
查看、上传或删除你手动创建的片段。
+ SEGMENT LIBRARY
片段管理
查看云端贡献,或编辑、预览、上传和删除本地草稿。
@@ -48,7 +54,7 @@
@@ -61,17 +67,30 @@
提示播放器标记按钮会嵌入清晰度按钮左侧。点击一次开始创建,再点击青色结束按钮即可保存;红色按钮用于取消。
+
+ KEYBOARD
快捷键
无需移动鼠标即可创建、取消或提交片段。
+
+
+
+
+
+
设置方法点击一个快捷键按钮,再按下新的组合键;按 Esc 取消。快捷键必须包含 Ctrl、Alt、Shift 或 Command 中至少一个修饰键。
+
+
+
COMMUNITY BETA
社区共享
默认连接项目公共 API;提交成功的广告片段会立即与其他用户共享。
-
+
+
+
-
+
隐私设计远程查询失败时会立即回退到本地数据。扩展不会自动上传你创建的片段,只有点击播放器提交按钮或片段管理中的上传按钮时才会发送。
@@ -85,8 +104,8 @@
- ABOUT
关于抖音广告跳过
一个完全本地运行的 Chrome 实验扩展。
- 
抖音广告跳过
版本 0.5.0
支持本地片段和社区共享。社区查询默认启用,本地片段仍只会在你主动点击提交时上传。
+ ABOUT
关于抖音广告跳过
一个开源、社区驱动的 Chrome 扩展。
+ 
抖音广告跳过
支持本地片段和社区共享。社区查询默认启用,本地片段仍只会在你主动点击提交时上传。
diff --git a/options.js b/options.js
index 282b711..7145275 100644
--- a/options.js
+++ b/options.js
@@ -1,16 +1,27 @@
const DEFAULT_COMMUNITY_API='https://douyin-ad-skipper-api.douyin-skip-community.workers.dev';
-const DEFAULTS = { enabled:true, skipLabeledAds:true, skipLocalSegments:true, showToast:true, debug:false, skippedCount:0, localSegments:{}, communityEnabled:true, communityApiBase:DEFAULT_COMMUNITY_API, communityAutoSkipTrusted:true, communityClientId:'' };
+const CATEGORY_LABELS={sponsor:'赞助/广告',selfpromo:'自我推广',interaction:'互动提醒'};
+const DEFAULTS = { enabled:true, skipLabeledAds:true, skipLocalSegments:true, showToast:true, debug:false, shortcutsEnabled:true, shortcutCreate:'Alt+KeyZ', shortcutCancel:'Alt+KeyX', shortcutSubmit:'Alt+Enter', skippedCount:0, localSegments:{}, communityEnabled:true, communityApiBase:DEFAULT_COMMUNITY_API, categoryModeSponsor:'auto', categoryModeSelfpromo:'manual', categoryModeInteraction:'manual', communityClientId:'' };
let state = { ...DEFAULTS };
+let capturingShortcut='';
+let communitySegments = [];
+let communityStats = { submittedCount:0, contributedSeconds:0, skipCount:0, helpedPeople:0, secondsSaved:0 };
const $ = (selector) => document.querySelector(selector);
const escapeHtml = (value='') => String(value).replace(/[&<>'"]/g, (char) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
const formatTime = (seconds=0) => `${Math.floor(seconds/60)}:${Math.floor(seconds%60).toString().padStart(2,'0')}`;
-const allSegments = () => Object.entries(state.localSegments || {}).flatMap(([videoId,segments]) => segments.map((segment,index) => ({...segment,videoId,index})));
+const formatContribution = (seconds=0) => {
+ const rounded=Math.max(0,Math.round(seconds));
+ if(rounded<60)return `${rounded} 秒`;
+ const hours=Math.floor(rounded/3600),minutes=Math.round((rounded%3600)/60);
+ return hours ? `${hours} 小时${minutes?` ${minutes} 分钟`:''}` : `${minutes} 分钟`;
+};
+const localSegmentItems = () => Object.entries(state.localSegments || {}).flatMap(([videoId,segments]) => segments.map((segment,index) => ({...segment,videoId,index,storageSource:'local'})));
+const allSegments = () => [...localSegmentItems(), ...communitySegments];
function showPage(name) {
document.querySelectorAll('[data-page]').forEach((element) => element.classList.toggle('active', element.dataset.page === name));
history.replaceState(null,'',`#${name}`);
- if (name === 'segments') renderSegments();
+ if (name === 'segments') { renderSegments(); fetchMyContributions(); }
}
function toast(message) {
@@ -20,16 +31,19 @@ function toast(message) {
function segmentTitle(segment) { return segment.title || `抖音作品 ${segment.videoId}`; }
function segmentAuthor(segment) { return segment.author || '未记录作者'; }
-function isPending(segment) { return (segment.submissionStatus || 'pending') === 'pending'; }
-function segmentStatus(segment) { return isPending(segment) ? ['pending','待提交'] : ['submitted','已提交']; }
+function isPending(segment) { return segment.storageSource !== 'community' && (segment.submissionStatus || 'pending') === 'pending'; }
+function isUploadReady(segment) { return isPending(segment) && segment.previewed === true; }
+function segmentStatus(segment) { return isPending(segment) ? ['pending','待提交'] : ['submitted','社区保存']; }
function renderOverview() {
const segments = allSegments();
const duration = segments.reduce((sum,item) => sum + Math.max(0,Number(item.end)-Number(item.start)),0);
$('#metricSegments').textContent = segments.length;
- $('#metricVideos').textContent = Object.keys(state.localSegments || {}).filter((id) => state.localSegments[id]?.length).length;
+ $('#metricVideos').textContent = new Set(segments.map((item)=>item.videoId)).size;
$('#metricSkips').textContent = Number(state.skippedCount || 0).toLocaleString('zh-CN');
$('#metricDuration').textContent = formatTime(duration);
+ $('#metricContributionDuration').textContent = formatContribution(communityStats.secondsSaved);
+ $('#metricContributionCount').textContent = `帮助 ${communityStats.helpedPeople} 人跳过 ${communityStats.skipCount} 次 · 已提交 ${communityStats.submittedCount} 个片段`;
$('#navSegmentCount').textContent = segments.length;
const recent = [...segments].sort((a,b)=>(b.createdAt||0)-(a.createdAt||0)).slice(0,5);
$('#recentSegments').innerHTML = recent.length ? recent.map((item) => `${escapeHtml(segmentTitle(item))}${escapeHtml(segmentAuthor(item))} · ${formatTime(item.start)}–${formatTime(item.end)}
${item.createdAt ? new Date(item.createdAt).toLocaleDateString('zh-CN') : '旧版片段'} `).join('') : '还没有创建片段。请在抖音播放器控制栏点击标记图标。
';
@@ -37,20 +51,56 @@ function renderOverview() {
function renderSegments() {
const query = ($('#segmentSearch').value || '').trim().toLowerCase();
- const groups = Object.entries(state.localSegments || {}).map(([videoId,segments]) => ({videoId,segments})).filter(({videoId,segments}) => {
+ const grouped=new Map();
+ for(const segment of allSegments()){if(!grouped.has(segment.videoId))grouped.set(segment.videoId,[]);grouped.get(segment.videoId).push(segment)}
+ const groups = [...grouped.entries()].map(([videoId,segments]) => ({videoId,segments})).filter(({videoId,segments}) => {
const haystack = `${videoId} ${segments[0]?.title||''} ${segments[0]?.author||''}`.toLowerCase();
return segments.length && (!query || haystack.includes(query));
});
const count = groups.reduce((sum,group)=>sum+group.segments.length,0);
- const pendingCount = groups.reduce((sum,group)=>sum+group.segments.filter(isPending).length,0);
+ const pendingCount = localSegmentItems().filter(isPending).length;
+ const readyCount = localSegmentItems().filter(isUploadReady).length;
$('#segmentSummary').textContent = `${groups.length} 个视频 · ${count} 个片段`;
- $('#uploadAllSegments').disabled = pendingCount === 0;
- $('#uploadAllSegments').textContent = pendingCount ? `上传全部待提交(${pendingCount})` : '没有待提交片段';
+ $('#uploadAllSegments').disabled = readyCount === 0;
+ $('#uploadAllSegments').textContent = readyCount ? `上传全部已预览(${readyCount})` : pendingCount ? '请先在播放器预览' : '没有待提交片段';
$('#segmentList').innerHTML = groups.length ? groups.map(({videoId,segments}) => {
const info = segments.find((item)=>item.title||item.author||item.url) || {};
const url = info.url || `https://www.douyin.com/video/${videoId}`;
- return `${segments.map((item,index)=>{const [statusClass,statusText]=segmentStatus(item);return `${formatTime(item.start)} → ${formatTime(item.end)}广告片段 ${statusText}${item.createdAt?new Date(item.createdAt).toLocaleString('zh-CN'):'从旧版本保存'}
`}).join('')}`;
- }).join('') : '没有找到本地片段。
';
+ return `${segments.map((item)=>{const [statusClass,statusText]=segmentStatus(item);const category=item.category||'sponsor';return `${formatTime(item.start)} → ${formatTime(item.end)}${item.storageSource==='community'?'':`
`}
${CATEGORY_LABELS[category]||'片段'} ${statusText}${item.storageSource==='community'?'已保存在社区':item.previewed?'✓ 已预览':'修改或创建后需预览'} · ${item.createdAt?new Date(item.createdAt).toLocaleString('zh-CN'):'旧版片段'}
${item.storageSource==='community'?'':``}
`}).join('')}`;
+ }).join('') : '没有找到片段。
';
+}
+
+async function getContributorId() {
+ const synced=await chrome.storage.sync.get({communityContributorId:''});
+ if(/^[0-9a-f-]{16,64}$/i.test(synced.communityContributorId))return synced.communityContributorId;
+ const legacy=await chrome.storage.local.get({communityClientId:''});
+ const id=/^[0-9a-f-]{16,64}$/i.test(legacy.communityClientId)?legacy.communityClientId:crypto.randomUUID();
+ await chrome.storage.sync.set({communityContributorId:id});
+ await chrome.storage.local.remove('communityClientId');
+ return id;
+}
+
+async function fetchMyContributions() {
+ if(!state.communityEnabled||!state.communityApiBase||!state.communityClientId)return;
+ try{
+ const response=await fetch(`${new URL(state.communityApiBase).origin}/v1/me/segments?apiVersion=2`,{headers:{Accept:'application/json','X-Client-ID':state.communityClientId}});
+ if(!response.ok)throw new Error(`HTTP ${response.status}`);
+ const payload=await response.json();
+ communitySegments=Array.isArray(payload.segments)?payload.segments.map((segment)=>({...segment,storageSource:'community',submissionStatus:'submitted'})):[];
+ communityStats={submittedCount:Number(payload.stats?.submittedCount||0),contributedSeconds:Number(payload.stats?.contributedSeconds||0),skipCount:Number(payload.stats?.skipCount||0),helpedPeople:Number(payload.stats?.helpedPeople||0),secondsSaved:Number(payload.stats?.secondsSaved||0)};
+ const remoteIds=new Set(communitySegments.map((item)=>item.id));
+ const remoteTimes=new Set(communitySegments.map((item)=>`${item.videoId}:${Number(item.start).toFixed(3)}:${Number(item.end).toFixed(3)}`));
+ let changed=false;const localSegments={};
+ for(const [videoId,segments] of Object.entries(state.localSegments||{})){
+ const kept=segments.filter((item)=>{
+ const uploaded=(item.submissionStatus==='submitted')&&(remoteIds.has(item.communityId)||remoteTimes.has(`${videoId}:${Number(item.start).toFixed(3)}:${Number(item.end).toFixed(3)}`));
+ if(uploaded)changed=true;return !uploaded;
+ });
+ if(kept.length)localSegments[videoId]=kept;
+ }
+ if(changed){state.localSegments=localSegments;await chrome.storage.local.set({localSegments})}
+ renderOverview();renderSegments();
+ }catch(error){console.error('[抖音广告跳过] 获取我的社区片段失败',error)}
}
async function deleteSegment(videoId,index) {
@@ -59,6 +109,39 @@ async function deleteSegment(videoId,index) {
state.localSegments=localSegments; await chrome.storage.local.set({localSegments}); renderOverview(); renderSegments(); toast('片段已删除');
}
+function parseTimeInput(value) {
+ const text=String(value||'').trim();
+ if(/^\d+(?:\.\d+)?$/.test(text))return Number(text);
+ const match=text.match(/^(\d+):(\d{1,2}(?:\.\d+)?)$/);
+ return match?Number(match[1])*60+Number(match[2]):NaN;
+}
+
+async function editSegment(videoId,index) {
+ const current=state.localSegments?.[videoId]?.[index];if(!current)return;
+ const startInput=prompt('开始时间(秒或 分:秒)',formatTime(current.start));if(startInput===null)return;
+ const start=parseTimeInput(startInput);if(!Number.isFinite(start))return toast('开始时间格式不正确');
+ const endInput=prompt('结束时间(秒或 分:秒)',formatTime(current.end));if(endInput===null)return;
+ const end=parseTimeInput(endInput);if(!Number.isFinite(end))return toast('结束时间格式不正确');
+ if(start<0||end<=start+0.2||end-start>600)return toast('时间范围无效,片段最长 10 分钟');
+ const localSegments={...state.localSegments};const segments=[...(localSegments[videoId]||[])];
+ segments[index]={...current,start,end,previewed:false,createdAt:Date.now()};segments.sort((a,b)=>a.start-b.start);localSegments[videoId]=segments;
+ state.localSegments=localSegments;await chrome.storage.local.set({localSegments});renderOverview();renderSegments();toast('时间已修改,请回到播放器重新预览');
+}
+
+async function updateLocalSegment(videoId,index,changes,message) {
+ const localSegments={...state.localSegments};const segments=[...(localSegments[videoId]||[])];const current=segments[index];if(!current)return;
+ const next={...current,...changes,previewed:false};
+ if(next.start<0||next.end<=next.start+0.2||next.end-next.start>600)return toast('调整后的时间范围无效');
+ segments[index]=next;segments.sort((a,b)=>a.start-b.start);localSegments[videoId]=segments;state.localSegments=localSegments;
+ await chrome.storage.local.set({localSegments});renderOverview();renderSegments();toast(message);
+}
+
+async function adjustSegment(videoId,index,field,delta) {
+ const current=state.localSegments?.[videoId]?.[index];if(!current)return;
+ const value=Math.round((Number(current[field])+delta)*10)/10;
+ await updateLocalSegment(videoId,index,{[field]:value},'已调整 0.1 秒,请重新预览');
+}
+
async function ensureCommunityReady() {
if (!state.communityEnabled || !state.communityApiBase) {
toast('请先在“社区共享”中授权并连接 API');
@@ -81,17 +164,19 @@ async function uploadSegment(videoId,index) {
if (!await ensureCommunityReady()) return false;
const segment=state.localSegments?.[videoId]?.[index];
if (!segment || !isPending(segment)) return true;
+ if (!isUploadReady(segment)){toast('请先在播放器完成片段预览');return false}
try {
const response=await fetch(`${new URL(state.communityApiBase).origin}/v1/segments`,{
method:'POST',
headers:{'Content-Type':'application/json','X-Client-ID':state.communityClientId},
- body:JSON.stringify({videoId,start:Number(segment.start),end:Number(segment.end),category:'sponsor',clientRequestId:crypto.randomUUID()}),
+ body:JSON.stringify({videoId,start:Number(segment.start),end:Number(segment.end),category:segment.category||'sponsor',clientRequestId:crypto.randomUUID()}),
});
if(!response.ok) throw new Error(`HTTP ${response.status}`);
- const payload=await response.json();
- segment.submissionStatus='submitted';
- segment.communityId=payload.segment?.id||payload.id||'';
- await chrome.storage.local.set({localSegments:state.localSegments});
+ await response.json();
+ const localSegments={...state.localSegments};const segments=[...(localSegments[videoId]||[])];segments.splice(index,1);
+ if(segments.length)localSegments[videoId]=segments;else delete localSegments[videoId];
+ state.localSegments=localSegments;await chrome.storage.local.set({localSegments});
+ await fetchMyContributions();
return true;
} catch(error) {
console.error('[抖音广告跳过] 上传社区失败',error);
@@ -102,7 +187,7 @@ async function uploadSegment(videoId,index) {
async function uploadAllPending() {
if (!await ensureCommunityReady()) return;
const button=$('#uploadAllSegments');
- const pending=allSegments().filter(isPending);
+ const pending=localSegmentItems().filter(isUploadReady).sort((a,b)=>a.videoId===b.videoId?b.index-a.index:0);
if(!pending.length)return;
button.disabled=true;
let success=0;
@@ -115,40 +200,89 @@ async function uploadAllPending() {
}
function exportData() {
- const payload = {format:'douyin-ad-skipper-backup',version:1,exportedAt:new Date().toISOString(),settings:{enabled:state.enabled,skipLabeledAds:state.skipLabeledAds,skipLocalSegments:state.skipLocalSegments,showToast:state.showToast,debug:state.debug},localSegments:state.localSegments};
+ const payload = {format:'douyin-ad-skipper-backup',version:4,exportedAt:new Date().toISOString(),settings:{enabled:state.enabled,skipLabeledAds:state.skipLabeledAds,skipLocalSegments:state.skipLocalSegments,showToast:state.showToast,debug:state.debug,shortcutsEnabled:state.shortcutsEnabled,shortcutCreate:state.shortcutCreate,shortcutCancel:state.shortcutCancel,shortcutSubmit:state.shortcutSubmit,categoryModeSponsor:state.categoryModeSponsor,categoryModeSelfpromo:state.categoryModeSelfpromo,categoryModeInteraction:state.categoryModeInteraction},localSegments:state.localSegments};
const url=URL.createObjectURL(new Blob([JSON.stringify(payload,null,2)],{type:'application/json'})); const a=document.createElement('a'); a.href=url; a.download=`douyin-ad-skipper-${new Date().toISOString().slice(0,10)}.json`; a.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); toast('备份已导出');
}
async function importData(file) {
try {
- const payload=JSON.parse(await file.text()); if(payload.format!=='douyin-ad-skipper-backup'||!payload.localSegments||typeof payload.localSegments!=='object') throw new Error('invalid');
- const merged={...state.localSegments}; for(const [id,segments] of Object.entries(payload.localSegments)){if(Array.isArray(segments)) merged[id]=[...(merged[id]||[]),...segments].sort((a,b)=>a.start-b.start)}
- const update={...payload.settings,localSegments:merged}; await chrome.storage.local.set(update); state={...state,...update}; syncSettings(); renderOverview(); renderSegments(); toast('备份已导入');
- } catch { toast('无法导入:文件格式不正确'); }
+ if(file.size>2*1024*1024)throw new Error('too_large');
+ const payload=JSON.parse(await file.text());
+ if(payload.format!=='douyin-ad-skipper-backup'||!payload.localSegments||typeof payload.localSegments!=='object'||Array.isArray(payload.localSegments))throw new Error('invalid');
+ const entries=Object.entries(payload.localSegments);if(entries.length>1000)throw new Error('too_many');
+ const merged={...state.localSegments};let total=0;
+ for(const [id,segments] of entries){
+ if(!/^\d{10,24}$/.test(id)||!Array.isArray(segments))throw new Error('invalid_segment');
+ const valid=segments.map((item)=>normalizeImportedSegment(item,id));total+=valid.length;if(total>10000)throw new Error('too_many');
+ merged[id]=[...(merged[id]||[]),...valid].sort((a,b)=>a.start-b.start);
+ }
+ const allowedSettings=['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled','shortcutCreate','shortcutCancel','shortcutSubmit','categoryModeSponsor','categoryModeSelfpromo','categoryModeInteraction'];
+ const importedSettings=Object.fromEntries(Object.entries(payload.settings||{}).filter(([key])=>allowedSettings.includes(key)));
+ for(const key of ['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled'])if(key in importedSettings)importedSettings[key]=importedSettings[key]===true;
+ for(const key of ['categoryModeSponsor','categoryModeSelfpromo','categoryModeInteraction'])if(key in importedSettings&&!['auto','manual','disabled'].includes(importedSettings[key]))delete importedSettings[key];
+ for(const key of ['shortcutCreate','shortcutCancel','shortcutSubmit'])if(key in importedSettings&&!/^(?:(?:Ctrl|Alt|Shift|Meta)\+)+(?:Key[A-Z]|Digit\d|Enter|Space|Arrow(?:Up|Down|Left|Right))$/.test(String(importedSettings[key])))delete importedSettings[key];
+ const update={...importedSettings,localSegments:merged};await chrome.storage.local.set(update);state={...state,...update};syncSettings();renderOverview();renderSegments();toast('备份已导入');
+ } catch { toast('无法导入:文件格式不正确或数据过大'); }
+}
+
+function normalizeImportedSegment(item,videoId){
+ if(!item||typeof item!=='object')throw new Error('invalid_segment');
+ const start=Number(item.start),end=Number(item.end),category=item.category||'sponsor';
+ if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end<=start||end-start>600||!CATEGORY_LABELS[category])throw new Error('invalid_segment');
+ return {start,end,category,createdAt:Number.isFinite(Number(item.createdAt))?Number(item.createdAt):Date.now(),submissionStatus:item.submissionStatus==='submitted'?'submitted':'pending',previewed:item.previewed===true,title:String(item.title||`抖音作品 ${videoId}`).slice(0,300),author:String(item.author||'').slice(0,100),url:`https://www.douyin.com/video/${videoId}`};
}
function syncSettings() {
- ['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','communityEnabled','communityAutoSkipTrusted'].forEach((key)=>{ $(`#${key}`).checked=Boolean(state[key]); });
+ ['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled','communityEnabled'].forEach((key)=>{ $(`#${key}`).checked=Boolean(state[key]); });
+ document.querySelectorAll('.category-mode').forEach((select)=>{select.value=state[select.dataset.setting]||DEFAULTS[select.dataset.setting]});
+ document.querySelectorAll('.shortcut-capture').forEach((button)=>{button.textContent=formatShortcut(state[button.dataset.setting]||DEFAULTS[button.dataset.setting])});
$('#communityApiBase').value = state.communityApiBase || '';
renderCommunityStatus();
}
+function shortcutSignature(event){return [event.ctrlKey?'Ctrl':'',event.altKey?'Alt':'',event.shiftKey?'Shift':'',event.metaKey?'Meta':'',event.code].filter(Boolean).join('+')}
+function formatShortcut(value){return String(value||'').replace(/Key([A-Z])/,'$1').replace(/Digit(\d)/,'$1').split('+').join(' + ')}
+
async function communityOriginPattern() {
try { const url=new URL($('#communityApiBase').value.trim()); return url.protocol==='https:' ? `${url.origin}/*` : ''; } catch { return ''; }
}
async function renderCommunityStatus() {
const status=$('#communityStatus');
- if(!state.communityApiBase){status.textContent='未连接';return}
- let pattern;
- try { pattern=`${new URL(state.communityApiBase).origin}/*`; } catch { status.textContent='地址无效'; return; }
+ const button=$('#connectCommunity');
+ const reset=$('#disconnectCommunity');
+ const typed=$('#communityApiBase').value.trim();
+ let typedOrigin,configuredOrigin;
+ try { typedOrigin=new URL(typed).origin;configuredOrigin=new URL(state.communityApiBase).origin; } catch { status.textContent='地址无效';button.textContent='授权并连接';button.disabled=false;return; }
+ const pattern=`${typedOrigin}/*`;
const granted=await chrome.permissions.contains({origins:[pattern]});
- status.textContent=granted&&state.communityEnabled?'已启用':granted?'已授权':'权限缺失';
+ const connected=granted&&typedOrigin===configuredOrigin;
+ status.textContent=connected&&state.communityEnabled?'已启用':connected?'已停用':granted?'待连接':'待授权';
+ button.textContent=connected?'已授权':'授权并连接';
+ button.disabled=connected;
+ reset.disabled=typedOrigin===DEFAULT_COMMUNITY_API;
}
document.querySelectorAll('nav button[data-page]').forEach((button)=>button.addEventListener('click',()=>showPage(button.dataset.page)));
document.querySelectorAll('[data-goto]').forEach((button)=>button.addEventListener('click',()=>showPage(button.dataset.goto)));
-['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','communityAutoSkipTrusted'].forEach((key)=>$(`#${key}`).addEventListener('change',(event)=>{state[key]=event.target.checked;chrome.storage.local.set({[key]:state[key]});toast('设置已保存')}));
+document.querySelectorAll('.shortcut-capture').forEach((button)=>button.addEventListener('click',()=>{
+ document.querySelectorAll('.shortcut-capture').forEach((item)=>item.classList.remove('capturing'));
+ capturingShortcut=button.dataset.setting;button.classList.add('capturing');button.textContent='请按组合键…';button.focus();
+}));
+document.addEventListener('keydown',async(event)=>{
+ if(!capturingShortcut)return;
+ event.preventDefault();event.stopPropagation();
+ const button=document.querySelector(`.shortcut-capture[data-setting="${capturingShortcut}"]`);
+ if(event.code==='Escape'){button?.classList.remove('capturing');capturingShortcut='';syncSettings();return}
+ if(['ControlLeft','ControlRight','AltLeft','AltRight','ShiftLeft','ShiftRight','MetaLeft','MetaRight'].includes(event.code))return;
+ if(!event.ctrlKey&&!event.altKey&&!event.shiftKey&&!event.metaKey){toast('快捷键必须包含至少一个修饰键');return}
+ const signature=shortcutSignature(event);
+ const conflict=['shortcutCreate','shortcutCancel','shortcutSubmit'].find((key)=>key!==capturingShortcut&&state[key]===signature);
+ if(conflict){toast('这个组合键已经用于其他操作');return}
+ const key=capturingShortcut;capturingShortcut='';state[key]=signature;await chrome.storage.local.set({[key]:signature});button?.classList.remove('capturing');syncSettings();toast('快捷键已保存');
+},true);
+['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled'].forEach((key)=>$(`#${key}`).addEventListener('change',(event)=>{state[key]=event.target.checked;chrome.storage.local.set({[key]:state[key]});toast('设置已保存')}));
+document.querySelectorAll('.category-mode').forEach((select)=>select.addEventListener('change',async(event)=>{const key=event.target.dataset.setting;state[key]=event.target.value;const update={[key]:state[key]};if(state[key]==='manual'){state.showToast=true;update.showToast=true;$('#showToast').checked=true}await chrome.storage.local.set(update);toast('分类处理方式已保存')}));
$('#communityEnabled').addEventListener('change',async(event)=>{
if(event.target.checked&&!state.communityApiBase){event.target.checked=false;toast('请先授权并连接 API');return}
state.communityEnabled=event.target.checked;await chrome.storage.local.set({communityEnabled:state.communityEnabled});renderCommunityStatus();toast('社区查询设置已保存');
@@ -159,20 +293,30 @@ $('#connectCommunity').addEventListener('click',async()=>{
const communityApiBase=new URL($('#communityApiBase').value.trim()).origin;state.communityApiBase=communityApiBase;state.communityEnabled=true;
await chrome.storage.local.set({communityApiBase,communityEnabled:true});syncSettings();toast('社区 API 已连接');
});
+$('#communityApiBase').addEventListener('input',renderCommunityStatus);
$('#disconnectCommunity').addEventListener('click',async()=>{
- if(state.communityApiBase){const pattern=`${new URL(state.communityApiBase).origin}/*`;await chrome.permissions.remove({origins:[pattern]})}
- state.communityApiBase='';state.communityEnabled=false;await chrome.storage.local.set({communityApiBase:'',communityEnabled:false});syncSettings();toast('已断开社区 API');
+ let oldOrigin='';try{oldOrigin=new URL(state.communityApiBase).origin}catch{}
+ if(oldOrigin&&oldOrigin!==DEFAULT_COMMUNITY_API)await chrome.permissions.remove({origins:[`${oldOrigin}/*`]});
+ state.communityApiBase=DEFAULT_COMMUNITY_API;state.communityEnabled=true;
+ await chrome.storage.local.set({communityApiBase:DEFAULT_COMMUNITY_API,communityEnabled:true});syncSettings();await fetchMyContributions();toast('已恢复默认公共 API');
});
$('#segmentSearch').addEventListener('input',renderSegments);
$('#segmentList').addEventListener('click',async(event)=>{
+ const adjustButton=event.target.closest('[data-adjust]');if(adjustButton){await adjustSegment(adjustButton.dataset.videoId,Number(adjustButton.dataset.index),adjustButton.dataset.adjust,Number(adjustButton.dataset.delta));return}
+ const editButton=event.target.closest('.edit-button');if(editButton){await editSegment(editButton.dataset.videoId,Number(editButton.dataset.index));return}
const deleteButton=event.target.closest('.delete-button');if(deleteButton){deleteSegment(deleteButton.dataset.videoId,Number(deleteButton.dataset.index));return}
const uploadButton=event.target.closest('.upload-button');if(uploadButton&&!uploadButton.disabled){uploadButton.disabled=true;uploadButton.textContent='上传中…';const ok=await uploadSegment(uploadButton.dataset.videoId,Number(uploadButton.dataset.index));renderOverview();renderSegments();toast(ok?'片段已上传社区':'上传失败,片段仍保留在本地')}
});
+$('#segmentList').addEventListener('change',async(event)=>{
+ const select=event.target.closest('.category-select');if(!select||!CATEGORY_LABELS[select.value])return;
+ const videoId=select.dataset.videoId;const index=Number(select.dataset.index);if(!state.localSegments?.[videoId]?.[index])return;
+ await updateLocalSegment(videoId,index,{category:select.value},'片段分类已保存');
+});
$('#uploadAllSegments').addEventListener('click',uploadAllPending);
$('#exportData').addEventListener('click',exportData);
$('#importData').addEventListener('click',()=>$('#importFile').click());
$('#importFile').addEventListener('change',(event)=>{if(event.target.files[0])importData(event.target.files[0]);event.target.value=''});
$('#clearSegments').addEventListener('click',async()=>{if(confirm('确定清空所有本地片段吗?此操作无法撤销。')){state.localSegments={};await chrome.storage.local.set({localSegments:{}});renderOverview();renderSegments();toast('本地片段已清空')}});
-chrome.storage.local.get(DEFAULTS,(stored)=>{state={...DEFAULTS,...stored};if(/^https:\/\/douyin-ad-skipper-api\.\d+\.workers\.dev\/?$/.test(state.communityApiBase)){state.communityApiBase=DEFAULT_COMMUNITY_API;state.communityEnabled=true;chrome.storage.local.set({communityApiBase:DEFAULT_COMMUNITY_API,communityEnabled:true})}if(!state.communityClientId){state.communityClientId=crypto.randomUUID();chrome.storage.local.set({communityClientId:state.communityClientId})}syncSettings();renderOverview();renderSegments();showPage(location.hash.slice(1)||'overview')});
+(async()=>{const stored=await chrome.storage.local.get(null);state={...DEFAULTS,...stored};if(!Object.hasOwn(stored,'categoryModeSponsor')){const legacyMode=['auto','manual','disabled'].includes(stored.communitySkipMode)?stored.communitySkipMode:'auto';const categoryModes={categoryModeSponsor:legacyMode,categoryModeSelfpromo:legacyMode,categoryModeInteraction:legacyMode};state={...state,...categoryModes};await chrome.storage.local.set(categoryModes)}await chrome.storage.local.remove(['communitySkipMode','communityAutoSkipTrusted']);if(!state.communityApiBase||/^https:\/\/douyin-ad-skipper-api\.\d+\.workers\.dev\/?$/.test(state.communityApiBase)){state.communityApiBase=DEFAULT_COMMUNITY_API;state.communityEnabled=true;await chrome.storage.local.set({communityApiBase:DEFAULT_COMMUNITY_API,communityEnabled:true})}state.communityClientId=await getContributorId();$('#extensionVersion').textContent=`版本 ${chrome.runtime.getManifest().version}`;syncSettings();renderOverview();renderSegments();showPage(location.hash.slice(1)||'overview');await fetchMyContributions()})();
chrome.storage.onChanged.addListener((changes,area)=>{if(area!=='local')return;for(const [key,change] of Object.entries(changes))state[key]=change.newValue;renderOverview()});
diff --git a/popup.html b/popup.html
index 5e399a8..26528f1 100644
--- a/popup.html
+++ b/popup.html
@@ -10,11 +10,11 @@
diff --git a/scripts/validate-extension.mjs b/scripts/validate-extension.mjs
new file mode 100644
index 0000000..8f3be30
--- /dev/null
+++ b/scripts/validate-extension.mjs
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { access, readFile } from 'node:fs/promises';
+
+const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url), 'utf8'));
+assert.equal(manifest.manifest_version, 3, '必须使用 Manifest V3');
+assert.match(manifest.version, /^\d+\.\d+\.\d+$/, '扩展版本必须使用 x.y.z');
+assert.deepEqual(manifest.permissions, ['storage'], '新增 Chrome 权限前必须经过安全审查');
+assert.deepEqual(manifest.host_permissions, [
+ 'https://www.douyin.com/*',
+ 'https://douyin-ad-skipper-api.douyin-skip-community.workers.dev/*',
+]);
+
+const requiredFiles = new Set([
+ 'content.js', 'content.css', 'popup.html', 'popup.js', 'popup.css',
+ 'options.html', 'options.js', 'options.css', 'PRIVACY.md', 'SECURITY.md',
+]);
+for (const file of requiredFiles) await access(new URL(`../${file}`, import.meta.url));
+for (const script of manifest.content_scripts || []) {
+ for (const file of [...(script.js || []), ...(script.css || [])]) await access(new URL(`../${file}`, import.meta.url));
+}
+for (const file of Object.values(manifest.icons || {})) await access(new URL(`../${file}`, import.meta.url));
+
+const changelog = await readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
+assert.match(changelog, new RegExp(`^## ${manifest.version.replaceAll('.', '\\.')}(?: |$)`, 'm'), 'CHANGELOG 缺少当前版本');
+
+const source = await readFile(new URL('../content.js', import.meta.url), 'utf8');
+assert(!/\beval\s*\(|new\s+Function\s*\(/.test(source), '内容脚本禁止动态执行代码');
+console.log(`扩展校验通过 v${manifest.version}`);
diff --git a/server/README.md b/server/README.md
index 9644520..a9beed6 100644
--- a/server/README.md
+++ b/server/README.md
@@ -32,11 +32,16 @@ curl http://localhost:8787/v1/videos/7669344658548047311/segments
- `GET /health`
- `GET /v1/videos/:videoId/segments`
+- `GET /v1/videos/by-hash/:sha256/segments`
+- `GET /v1/me/segments`
- `POST /v1/segments`
- `POST /v1/segments/:segmentId/votes`
- `POST /v1/segments/:segmentId/reports`
+- `POST /v1/segments/:segmentId/skips`
-写请求必须携带随机生成的 `X-Client-ID`。服务端只保存客户端 ID 与 IP 组合后的不可逆哈希,不保存原值。
+写请求必须携带随机生成的 `X-Client-ID`。服务端使用加盐哈希保存稳定的匿名贡献身份,不保存客户端 ID 原值;来源 IP 会单独加盐哈希,仅用于第二层限流和基础设施安全,不参与贡献身份。`X-Client-ID` 不是登录凭证。
+
+写接口的 JSON 请求体最大为 8 KiB,并同时按匿名身份和来源 IP 限流。D1 中的限流桶会抽样清理超过 48 小时的数据。生产部署前应先导出 D1 备份,再应用迁移。
## 可信规则(初版)
diff --git a/server/migrations/0003_submitter_lookup.sql b/server/migrations/0003_submitter_lookup.sql
new file mode 100644
index 0000000..a3d71c0
--- /dev/null
+++ b/server/migrations/0003_submitter_lookup.sql
@@ -0,0 +1,2 @@
+CREATE INDEX IF NOT EXISTS idx_segments_submitter_created
+ON segments(submitter_hash, created_at DESC);
diff --git a/server/migrations/0004_segment_impact.sql b/server/migrations/0004_segment_impact.sql
new file mode 100644
index 0000000..ddc555a
--- /dev/null
+++ b/server/migrations/0004_segment_impact.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS segment_skips (
+ segment_id TEXT NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
+ viewer_hash TEXT NOT NULL,
+ day TEXT NOT NULL,
+ seconds_saved INTEGER NOT NULL CHECK (seconds_saved > 0),
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (segment_id, viewer_hash, day)
+);
+
+CREATE INDEX IF NOT EXISTS idx_segment_skips_segment
+ON segment_skips(segment_id);
diff --git a/server/migrations/0005_video_hash.sql b/server/migrations/0005_video_hash.sql
new file mode 100644
index 0000000..e3f91f2
--- /dev/null
+++ b/server/migrations/0005_video_hash.sql
@@ -0,0 +1,4 @@
+ALTER TABLE segments ADD COLUMN video_hash TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_segments_video_hash_status
+ON segments(video_hash, status, start_ms);
diff --git a/server/migrations/0006_expand_segment_categories.sql b/server/migrations/0006_expand_segment_categories.sql
new file mode 100644
index 0000000..18378d8
--- /dev/null
+++ b/server/migrations/0006_expand_segment_categories.sql
@@ -0,0 +1,66 @@
+PRAGMA defer_foreign_keys = ON;
+
+CREATE TABLE segments_new (
+ id TEXT PRIMARY KEY,
+ video_id TEXT NOT NULL,
+ start_ms INTEGER NOT NULL CHECK (start_ms >= 0),
+ end_ms INTEGER NOT NULL CHECK (end_ms > start_ms),
+ duration_ms INTEGER,
+ category TEXT NOT NULL DEFAULT 'sponsor' CHECK (category IN ('sponsor', 'selfpromo', 'interaction')),
+ status TEXT NOT NULL DEFAULT 'candidate' CHECK (status IN ('candidate', 'trusted', 'disputed', 'rejected')),
+ upvotes INTEGER NOT NULL DEFAULT 0,
+ downvotes INTEGER NOT NULL DEFAULT 0,
+ submitter_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ video_hash TEXT
+);
+
+INSERT INTO segments_new
+SELECT id, video_id, start_ms, end_ms, duration_ms, category, status, upvotes, downvotes,
+ submitter_hash, created_at, updated_at, video_hash
+FROM segments;
+
+CREATE TABLE votes_new (
+ segment_id TEXT NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
+ voter_hash TEXT NOT NULL,
+ vote INTEGER NOT NULL CHECK (vote IN (-1, 1)),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (segment_id, voter_hash)
+);
+INSERT INTO votes_new SELECT * FROM votes;
+
+CREATE TABLE reports_new (
+ segment_id TEXT NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
+ reporter_hash TEXT NOT NULL,
+ reason TEXT NOT NULL CHECK (reason IN ('wrong_video', 'wrong_time', 'not_ad', 'abuse', 'other')),
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (segment_id, reporter_hash)
+);
+INSERT INTO reports_new SELECT * FROM reports;
+
+CREATE TABLE segment_skips_new (
+ segment_id TEXT NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
+ viewer_hash TEXT NOT NULL,
+ day TEXT NOT NULL,
+ seconds_saved INTEGER NOT NULL CHECK (seconds_saved > 0),
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (segment_id, viewer_hash, day)
+);
+INSERT INTO segment_skips_new SELECT * FROM segment_skips;
+
+DROP TABLE segment_skips;
+DROP TABLE reports;
+DROP TABLE votes;
+DROP TABLE segments;
+
+ALTER TABLE segments_new RENAME TO segments;
+ALTER TABLE votes_new RENAME TO votes;
+ALTER TABLE reports_new RENAME TO reports;
+ALTER TABLE segment_skips_new RENAME TO segment_skips;
+
+CREATE INDEX idx_segments_video_status ON segments(video_id, status, start_ms);
+CREATE INDEX idx_segments_submitter_created ON segments(submitter_hash, created_at DESC);
+CREATE INDEX idx_segments_video_hash_status ON segments(video_hash, status, start_ms);
+CREATE INDEX idx_segment_skips_segment ON segment_skips(segment_id);
diff --git a/server/src/index.ts b/server/src/index.ts
index 4d54ab8..ad7c869 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -15,7 +15,10 @@ const CORS = {
'Access-Control-Allow-Headers': 'Content-Type, X-Client-ID',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Max-Age': '86400',
+ 'X-Content-Type-Options': 'nosniff',
};
+const MAX_JSON_BODY_BYTES = 8 * 1024;
+const BODY_TOO_LARGE = Symbol('body_too_large');
function json(data: unknown, status = 200, extra: Record = {}): Response {
return Response.json(data, { status, headers: { ...CORS, 'Cache-Control': 'no-store', ...extra } });
@@ -23,7 +26,15 @@ function json(data: unknown, status = 200, extra: Record = {}):
async function bodyJson(request: Request): Promise {
if (!request.headers.get('content-type')?.toLowerCase().includes('application/json')) return null;
- try { return await request.json(); } catch { return null; }
+ const declaredSize = Number(request.headers.get('content-length') || 0);
+ if (declaredSize > MAX_JSON_BODY_BYTES) return BODY_TOO_LARGE;
+ try {
+ const bytes = await request.arrayBuffer();
+ if (bytes.byteLength > MAX_JSON_BODY_BYTES) return BODY_TOO_LARGE;
+ return JSON.parse(new TextDecoder().decode(bytes));
+ } catch {
+ return null;
+ }
}
async function sha256(value: string): Promise {
@@ -31,12 +42,46 @@ async function sha256(value: string): Promise {
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
-async function identityHash(request: Request, env: Env, requireClient = true): Promise {
+function clientIdFrom(request: Request): string {
+ return request.headers.get('X-Client-ID') || '';
+}
+
+async function contributorHash(request: Request, env: Env): Promise {
if (!env.CLIENT_HASH_SALT) return null;
- const clientId = request.headers.get('X-Client-ID') || '';
- if (requireClient && !/^[0-9a-f-]{16,64}$/i.test(clientId)) return '';
+ const clientId = clientIdFrom(request);
+ if (!/^[0-9a-f-]{16,64}$/i.test(clientId)) return '';
+ return sha256(`${env.CLIENT_HASH_SALT}:contributor:${clientId}`);
+}
+
+async function rateIdentityHash(request: Request, env: Env): Promise {
+ const contributor = await contributorHash(request, env);
+ if (!contributor) return contributor;
+ const ip = request.headers.get('CF-Connecting-IP') || 'local';
+ return sha256(`${env.CLIENT_HASH_SALT}:rate:${contributor}:${ip}`);
+}
+
+async function ipRateIdentityHash(request: Request, env: Env): Promise {
+ if (!env.CLIENT_HASH_SALT) return null;
+ const ip = request.headers.get('CF-Connecting-IP') || 'local';
+ return sha256(`${env.CLIENT_HASH_SALT}:rate-ip:${ip}`);
+}
+
+async function allowedWriteRate(request: Request, env: Env, action: string, identityLimit: number, ipLimit: number): Promise {
+ const identity = await rateIdentityHash(request, env);
+ const ipIdentity = await ipRateIdentityHash(request, env);
+ return Boolean(identity && ipIdentity &&
+ await withinRateLimit(env, identity, action, identityLimit) &&
+ await withinRateLimit(env, ipIdentity, `${action}:ip`, ipLimit));
+}
+
+async function migrateLegacyIdentity(request: Request, env: Env, stableHash: string): Promise {
+ const clientId = clientIdFrom(request);
const ip = request.headers.get('CF-Connecting-IP') || 'local';
- return sha256(`${env.CLIENT_HASH_SALT}:${clientId}:${ip}`);
+ const legacyHash = await sha256(`${env.CLIENT_HASH_SALT}:${clientId}:${ip}`);
+ if (legacyHash !== stableHash) {
+ await env.DB.prepare('UPDATE segments SET submitter_hash = ? WHERE submitter_hash = ?')
+ .bind(stableHash, legacyHash).run();
+ }
}
async function withinRateLimit(env: Env, identity: string, action: string, limit: number): Promise {
@@ -46,6 +91,12 @@ async function withinRateLimit(env: Env, identity: string, action: string, limit
ON CONFLICT(identity_hash, action, bucket) DO UPDATE SET count = count + 1
RETURNING count
`).bind(identity, action, bucket).first<{ count: number }>();
+ const sample = new Uint8Array(1);
+ crypto.getRandomValues(sample);
+ if (sample[0] === 0) {
+ const cutoff = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString().slice(0, 13);
+ await env.DB.prepare('DELETE FROM rate_limits WHERE bucket < ?').bind(cutoff).run();
+ }
return Boolean(row && row.count <= limit);
}
@@ -60,6 +111,9 @@ function segmentJson(row: SegmentRow) {
async function getSegments(videoId: string, env: Env): Promise {
if (!VIDEO_ID_PATTERN.test(videoId)) return json({ error: 'invalid_video_id' }, 400);
+ const videoHash = await sha256(videoId);
+ await env.DB.prepare('UPDATE segments SET video_hash = ? WHERE video_id = ? AND video_hash IS NULL')
+ .bind(videoHash, videoId).run();
const result = await env.DB.prepare(`
SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at
FROM segments WHERE video_id = ? AND status IN ('candidate', 'trusted') ORDER BY start_ms ASC LIMIT 200
@@ -67,12 +121,78 @@ async function getSegments(videoId: string, env: Env): Promise {
return json({ videoId, segments: result.results.map(segmentJson) }, 200, { 'Cache-Control': 'public, max-age=60' });
}
+async function getSegmentsByHash(videoHash: string, env: Env): Promise {
+ if (!/^[0-9a-f]{64}$/i.test(videoHash)) return json({ error: 'invalid_video_hash' }, 400);
+ const result = await env.DB.prepare(`
+ SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at
+ FROM segments WHERE video_hash = ? AND status IN ('candidate', 'trusted') ORDER BY start_ms ASC LIMIT 200
+ `).bind(videoHash.toLowerCase()).all();
+ const segments = result.results.map((row) => {
+ const { videoId: _videoId, ...segment } = segmentJson(row);
+ return segment;
+ });
+ return json({ segments }, 200, { 'Cache-Control': 'public, max-age=60' });
+}
+
+async function getMySegments(request: Request, env: Env): Promise {
+ const identity = await contributorHash(request, env);
+ if (identity === null) return json({ error: 'writes_not_configured' }, 503);
+ if (!identity) return json({ error: 'invalid_client_id' }, 400);
+ await migrateLegacyIdentity(request, env, identity);
+ const result = await env.DB.prepare(`
+ SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at
+ FROM segments WHERE submitter_hash = ? AND status != 'rejected'
+ ORDER BY created_at DESC LIMIT 500
+ `).bind(identity).all();
+ const contributedMs = result.results.reduce((sum, row) => sum + Math.max(0, row.end_ms - row.start_ms), 0);
+ const impact = await env.DB.prepare(`
+ SELECT COUNT(*) AS skip_count, COUNT(DISTINCT skips.viewer_hash) AS helped_people,
+ COALESCE(SUM(skips.seconds_saved), 0) AS seconds_saved
+ FROM segment_skips AS skips
+ INNER JOIN segments ON segments.id = skips.segment_id
+ WHERE segments.submitter_hash = ?
+ `).bind(identity).first<{ skip_count: number; helped_people: number; seconds_saved: number }>();
+ return json({
+ segments: result.results.map(segmentJson),
+ stats: {
+ submittedCount: result.results.length,
+ contributedSeconds: contributedMs / 1000,
+ skipCount: Number(impact?.skip_count || 0),
+ helpedPeople: Number(impact?.helped_people || 0),
+ secondsSaved: Number(impact?.seconds_saved || 0),
+ },
+ });
+}
+
+async function recordSegmentSkip(request: Request, env: Env, segmentId: string): Promise {
+ const viewer = await contributorHash(request, env);
+ if (viewer === null) return json({ error: 'writes_not_configured' }, 503);
+ if (!viewer) return json({ error: 'invalid_client_id' }, 400);
+ if (!await allowedWriteRate(request, env, 'skip', 300, 2000)) return json({ error: 'rate_limited' }, 429);
+ const segment = await env.DB.prepare(`
+ SELECT start_ms, end_ms, submitter_hash FROM segments
+ WHERE id = ? AND status = 'trusted'
+ `).bind(segmentId).first<{ start_ms: number; end_ms: number; submitter_hash: string }>();
+ if (!segment) return json({ error: 'segment_not_found' }, 404);
+ if (segment.submitter_hash === viewer) return json({ recorded: false, reason: 'own_segment' });
+ const secondsSaved = Math.max(1, Math.round((segment.end_ms - segment.start_ms) / 1000));
+ const day = new Date().toISOString().slice(0, 10);
+ const result = await env.DB.prepare(`
+ INSERT OR IGNORE INTO segment_skips (segment_id, viewer_hash, day, seconds_saved, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).bind(segmentId, viewer, day, secondsSaved, new Date().toISOString()).run();
+ return json({ recorded: Number(result.meta.changes || 0) > 0, secondsSaved });
+}
+
async function submitSegment(request: Request, env: Env): Promise {
- const identity = await identityHash(request, env);
+ const identity = await contributorHash(request, env);
if (identity === null) return json({ error: 'writes_not_configured' }, 503);
if (!identity) return json({ error: 'invalid_client_id' }, 400);
- if (!await withinRateLimit(env, identity, 'submit', 20)) return json({ error: 'rate_limited' }, 429);
- const input = parseSegmentInput(await bodyJson(request));
+ if (!await allowedWriteRate(request, env, 'submit', 20, 100)) return json({ error: 'rate_limited' }, 429);
+ await migrateLegacyIdentity(request, env, identity);
+ const body = await bodyJson(request);
+ if (body === BODY_TOO_LARGE) return json({ error: 'payload_too_large' }, 413);
+ const input = parseSegmentInput(body);
if (!input) return json({ error: 'invalid_segment' }, 400);
const startMs = Math.round(input.start * 1000), endMs = Math.round(input.end * 1000);
const existing = await env.DB.prepare(`
@@ -82,23 +202,26 @@ async function submitSegment(request: Request, env: Env): Promise {
`).bind(input.videoId, input.category, startMs, endMs).first();
if (existing) return json({ segment: segmentJson(existing), duplicate: true });
const id = crypto.randomUUID(), now = new Date().toISOString();
+ const videoHash = await sha256(input.videoId);
await env.DB.prepare(`
- INSERT INTO segments (id, video_id, start_ms, end_ms, duration_ms, category, status, submitter_hash, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, 'trusted', ?, ?, ?)
- `).bind(id, input.videoId, startMs, endMs, input.duration == null ? null : Math.round(input.duration * 1000), input.category, identity, now, now).run();
+ INSERT INTO segments (id, video_id, video_hash, start_ms, end_ms, duration_ms, category, status, submitter_hash, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'trusted', ?, ?, ?)
+ `).bind(id, input.videoId, videoHash, startMs, endMs, input.duration == null ? null : Math.round(input.duration * 1000), input.category, identity, now, now).run();
return json({ segment: { id, videoId: input.videoId, start: input.start, end: input.end, category: input.category, status: 'trusted', upvotes: 0, downvotes: 0, score: 0, createdAt: now } }, 201);
}
async function vote(request: Request, env: Env, segmentId: string): Promise {
- const identity = await identityHash(request, env);
+ const identity = await contributorHash(request, env);
if (identity === null) return json({ error: 'writes_not_configured' }, 503);
if (!identity) return json({ error: 'invalid_client_id' }, 400);
- if (!await withinRateLimit(env, identity, 'vote', 60)) return json({ error: 'rate_limited' }, 429);
- const body = await bodyJson(request) as { vote?: unknown } | null;
+ if (!await allowedWriteRate(request, env, 'vote', 60, 300)) return json({ error: 'rate_limited' }, 429);
+ const body = await bodyJson(request) as { vote?: unknown } | null | typeof BODY_TOO_LARGE;
+ if (body === BODY_TOO_LARGE) return json({ error: 'payload_too_large' }, 413);
const value = Number(body?.vote);
if (value !== 1 && value !== -1) return json({ error: 'invalid_vote' }, 400);
- const exists = await env.DB.prepare('SELECT id FROM segments WHERE id = ?').bind(segmentId).first();
+ const exists = await env.DB.prepare('SELECT id, submitter_hash FROM segments WHERE id = ?').bind(segmentId).first<{id:string;submitter_hash:string}>();
if (!exists) return json({ error: 'segment_not_found' }, 404);
+ if (exists.submitter_hash === identity) return json({ error: 'cannot_vote_own_segment' }, 403);
const now = new Date().toISOString();
await env.DB.prepare(`INSERT INTO votes (segment_id, voter_hash, vote, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(segment_id, voter_hash) DO UPDATE SET vote = excluded.vote, updated_at = excluded.updated_at`
@@ -115,15 +238,17 @@ async function vote(request: Request, env: Env, segmentId: string): Promise {
- const identity = await identityHash(request, env);
+ const identity = await contributorHash(request, env);
if (identity === null) return json({ error: 'writes_not_configured' }, 503);
if (!identity) return json({ error: 'invalid_client_id' }, 400);
- if (!await withinRateLimit(env, identity, 'report', 10)) return json({ error: 'rate_limited' }, 429);
- const body = await bodyJson(request) as { reason?: unknown } | null;
+ if (!await allowedWriteRate(request, env, 'report', 10, 50)) return json({ error: 'rate_limited' }, 429);
+ const body = await bodyJson(request) as { reason?: unknown } | null | typeof BODY_TOO_LARGE;
+ if (body === BODY_TOO_LARGE) return json({ error: 'payload_too_large' }, 413);
const reason = String(body?.reason || '');
if (!REPORT_REASONS.has(reason)) return json({ error: 'invalid_reason' }, 400);
- const exists = await env.DB.prepare('SELECT id FROM segments WHERE id = ?').bind(segmentId).first();
+ const exists = await env.DB.prepare('SELECT id, submitter_hash FROM segments WHERE id = ?').bind(segmentId).first<{id:string;submitter_hash:string}>();
if (!exists) return json({ error: 'segment_not_found' }, 404);
+ if (exists.submitter_hash === identity) return json({ error: 'cannot_report_own_segment' }, 403);
await env.DB.prepare(`INSERT INTO reports (segment_id, reporter_hash, reason, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT(segment_id, reporter_hash) DO UPDATE SET reason = excluded.reason`
).bind(segmentId, identity, reason, new Date().toISOString()).run();
@@ -137,12 +262,17 @@ export default {
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS });
const url = new URL(request.url), path = url.pathname;
try {
- if (request.method === 'GET' && path === '/health') return json({ ok: true, service: 'douyin-ad-skipper-api', version: 1 });
+ if (request.method === 'GET' && path === '/health') return json({ ok: true, service: 'douyin-ad-skipper-api', version: 2 });
const videoMatch = path.match(/^\/v1\/videos\/(\d+)\/segments$/);
if (request.method === 'GET' && videoMatch) return getSegments(videoMatch[1], env);
+ const videoHashMatch = path.match(/^\/v1\/videos\/by-hash\/([0-9a-f]{64})\/segments$/i);
+ if (request.method === 'GET' && videoHashMatch) return getSegmentsByHash(videoHashMatch[1], env);
+ if (request.method === 'GET' && path === '/v1/me/segments') return getMySegments(request, env);
if (request.method === 'POST' && path === '/v1/segments') return submitSegment(request, env);
const voteMatch = path.match(/^\/v1\/segments\/([0-9a-f-]+)\/votes$/i);
if (request.method === 'POST' && voteMatch) return vote(request, env, voteMatch[1]);
+ const skipMatch = path.match(/^\/v1\/segments\/([0-9a-f-]+)\/skips$/i);
+ if (request.method === 'POST' && skipMatch) return recordSegmentSkip(request, env, skipMatch[1]);
const reportMatch = path.match(/^\/v1\/segments\/([0-9a-f-]+)\/reports$/i);
if (request.method === 'POST' && reportMatch) return report(request, env, reportMatch[1]);
return json({ error: 'not_found' }, 404);
diff --git a/server/src/validation.ts b/server/src/validation.ts
index 325c3f8..48719fe 100644
--- a/server/src/validation.ts
+++ b/server/src/validation.ts
@@ -1,12 +1,13 @@
export const VIDEO_ID_PATTERN = /^\d{10,24}$/;
export const REPORT_REASONS = new Set(['wrong_video', 'wrong_time', 'not_ad', 'abuse', 'other']);
+export const SEGMENT_CATEGORIES = new Set(['sponsor', 'selfpromo', 'interaction']);
export type SegmentInput = {
videoId: string;
start: number;
end: number;
duration?: number;
- category: 'sponsor';
+ category: 'sponsor' | 'selfpromo' | 'interaction';
clientRequestId: string;
};
@@ -19,11 +20,11 @@ export function parseSegmentInput(value: unknown): SegmentInput | null {
const duration = body.duration == null ? undefined : Number(body.duration);
const category = body.category == null ? 'sponsor' : body.category;
const clientRequestId = String(body.clientRequestId || '');
- if (!VIDEO_ID_PATTERN.test(videoId) || category !== 'sponsor') return null;
+ if (!VIDEO_ID_PATTERN.test(videoId) || typeof category !== 'string' || !SEGMENT_CATEGORIES.has(category)) return null;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start || end - start > 600) return null;
if (duration !== undefined && (!Number.isFinite(duration) || duration <= 0 || end > duration + 1)) return null;
if (!/^[0-9a-f-]{16,64}$/i.test(clientRequestId)) return null;
- return { videoId, start, end, duration, category: 'sponsor', clientRequestId };
+ return { videoId, start, end, duration, category: category as SegmentInput['category'], clientRequestId };
}
export function statusFromVotes(upvotes: number, downvotes: number): 'trusted' | 'disputed' {
diff --git a/server/tests/validation.test.ts b/server/tests/validation.test.ts
index 253d577..91a64b7 100644
--- a/server/tests/validation.test.ts
+++ b/server/tests/validation.test.ts
@@ -7,6 +7,13 @@ test('accepts a valid sponsor segment', () => {
assert.equal(parsed?.start, 10.2);
});
+test('accepts supported categories and rejects unknown categories', () => {
+ const base={videoId:'7669344658548047311',start:10,end:20,clientRequestId:'123e4567-e89b-12d3-a456-426614174000'};
+ assert.equal(parseSegmentInput({...base,category:'selfpromo'})?.category,'selfpromo');
+ assert.equal(parseSegmentInput({...base,category:'interaction'})?.category,'interaction');
+ assert.equal(parseSegmentInput({...base,category:'unknown'}),null);
+});
+
test('rejects invalid ranges and oversized segments', () => {
assert.equal(parseSegmentInput({ videoId:'7669344658548047311', start:30, end:20, clientRequestId:'123e4567-e89b-12d3-a456-426614174000' }), null);
assert.equal(parseSegmentInput({ videoId:'7669344658548047311', start:0, end:601, clientRequestId:'123e4567-e89b-12d3-a456-426614174000' }), null);