From f610b14903484c312ad2acf2acb2d6cb75cb8dae Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 15 Aug 2026 21:49:15 +0800 Subject: [PATCH] feat: release privacy-first community v0.7.0 --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 19 +- CHANGELOG.md | 11 + CONTRIBUTING.md | 2 +- PRIVACY.md | 12 +- README.md | 16 +- content.css | 12 +- content.js | 262 ++++++++++-------- docs/API.md | 5 +- docs/ARCHITECTURE.md | 7 +- docs/DATA_MODEL.md | 3 + docs/ROADMAP.md | 4 +- manifest.json | 9 +- options.css | 12 +- options.html | 40 ++- options.js | 98 +++---- popup.css | 10 +- popup.html | 15 +- popup.js | 15 +- scripts/extract-release-notes.mjs | 18 ++ scripts/test-extension-logic.mjs | 33 +++ scripts/validate-extension.mjs | 3 + scripts/validate-package.mjs | 26 ++ server/README.md | 9 +- .../0007_idempotent_submissions.sql | 6 + server/src/index.ts | 63 +++-- server/tests/validation.test.ts | 14 + 27 files changed, 478 insertions(+), 253 deletions(-) create mode 100644 scripts/extract-release-notes.mjs create mode 100644 scripts/test-extension-logic.mjs create mode 100644 scripts/validate-package.mjs create mode 100644 server/migrations/0007_idempotent_submissions.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50d3304..f3827eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, agent/community-api] + branches: [main] pull_request: permissions: @@ -12,8 +12,8 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: node-version: 22 cache: npm @@ -24,6 +24,7 @@ jobs: node --check popup.js node --check options.js node scripts/validate-extension.mjs + node scripts/test-extension-logic.mjs - name: Install Worker dependencies working-directory: server run: npm ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bde8c6a..6b7578e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,15 +11,23 @@ jobs: package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-node@v5 with: node-version: 22 cache: npm cache-dependency-path: server/package-lock.json - name: Verify tag and extension run: | + git fetch origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + node --check content.js + node --check popup.js + node --check options.js node scripts/validate-extension.mjs + node scripts/test-extension-logic.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 @@ -32,7 +40,12 @@ jobs: 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: Validate package and create checksum + run: | + node scripts/validate-package.mjs dist/douyin-ad-skipper + shasum -a 256 "dist/douyin-ad-skipper-${GITHUB_REF_NAME}.zip" > "dist/douyin-ad-skipper-${GITHUB_REF_NAME}.zip.sha256" + node scripts/extract-release-notes.mjs "$GITHUB_REF_NAME" dist/release-notes.md - 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 + run: gh release create "$GITHUB_REF_NAME" "dist/douyin-ad-skipper-${GITHUB_REF_NAME}.zip" "dist/douyin-ad-skipper-${GITHUB_REF_NAME}.zip.sha256" --notes-file dist/release-notes.md --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e8a34..f1ff942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 更新日志 +## 0.7.0 - 2026-08-15 + +- 产品统一为“抖音网页版社区片段助手”,移除页面广告标签识别和整条视频自动翻页,只处理普通作品时间轴中的用户标记片段。 +- 社区共享改为首次明确选择;未同意或选择仅本地时,不查询公共 API,也不生成社区贡献身份。 +- 移除任意 HTTPS 可选权限和自定义 API 普通入口,公共版只允许访问抖音网页与项目公共 API。 +- 普通跳过通知关闭后,手动跳过按钮、提交错误和社区反馈错误仍会正常显示。 +- 社区查询失败改为 5 秒、15 秒、60 秒和 5 分钟逐级重试,不再把失败结果缓存 10 分钟。 +- 查询结果标记自己的投稿;自己的片段不再显示赞成、反对和举报按钮,旧服务端返回的错误码也会转换为明确提示。 +- 投稿请求 ID 写入 D1 并增加唯一约束,同一匿名贡献者的网络重试不会重复创建片段。 +- 发布流程新增前端行为契约、标签主分支校验、ZIP 内容校验、SHA-256 校验文件和基于更新日志的 Release 说明。 + ## 0.6.4 - 2026-08-12 - 重构播放器提交窗口,按片段信息、分类、主要定位、精确编辑和提交操作分层布局,改善窄播放器中的拥挤和遮挡。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc974ed..85c69dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 参与贡献 -感谢你帮助改进抖音广告跳过。项目目前处于早期实验阶段,抖音网页结构变化频繁,小而可验证的改动最容易合并。 +感谢你帮助改进抖音网页版社区片段助手。项目目前处于早期实验阶段,抖音网页结构变化频繁,小而可验证的改动最容易合并。 ## 开始之前 diff --git a/PRIVACY.md b/PRIVACY.md index 3478585..371568a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,8 +1,8 @@ # 隐私说明 -最后更新:2026-08-12 +最后更新:2026-08-15 -## 当前版本(v0.6.4) +## 当前版本(v0.7.0) 扩展只在 `https://www.douyin.com/*` 页面运行,并使用 Chrome `storage` 权限保存: @@ -10,7 +10,9 @@ - 累计跳过次数; - 尚未提交的本地草稿(作品 ID、片段起止时间、标题、作者、作品链接和创建时间)。 -功能开关、累计跳过次数和未提交草稿保存在 Chrome 扩展本地存储中。匿名贡献者 ID 保存在 Chrome 同步存储中,以便同一 Chrome 账号在其他电脑识别自己的社区贡献。已提交片段存放在社区服务器,不再重复保存在本地。社区共享功能可随时在选项页关闭。关闭时: +功能开关、累计跳过次数和未提交草稿保存在 Chrome 扩展本地存储中。首次使用时,用户必须明确选择是否启用社区共享;选择仅本地或撤回同意时不会查询社区 API。只有同意社区共享后,扩展才会生成匿名贡献者 ID 并保存到 Chrome 同步存储,以便同一 Chrome 账号在其他电脑识别自己的社区贡献。已提交片段存放在社区服务器,不再重复保存在本地。 + +社区共享关闭时: - 不向项目维护者或第三方服务器发送数据; - 不收集 Cookie、登录凭据或账号私信; @@ -31,10 +33,10 @@ - 选项页会通过匿名贡献者 ID 拉取该用户已提交的片段和累计贡献时长; - 社区片段成功跳过且未立即撤销时,会上传片段 ID;服务器按匿名贡献者哈希、片段和日期去重,用于统计实际帮助人数、跳过次数和节省时间; - 提供完全关闭远程查询与提交的选项; -- 实际服务器运营者仍需自行公布基础设施日志的保留期限和删除方式。 +- 社区片段、投票、举报和去重后的帮助统计会在公共服务运营期间保存,或在合法删除请求处理后移除;应用层限流记录会抽样清理超过 48 小时的桶。 作品 ID 的 SHA-256 哈希用于减少查询时直接暴露原始 ID,但作品 ID 的取值空间有限,哈希不应被理解为完全匿名或不可反查。公共社区 API 会对写入请求同时按匿名身份哈希与来源 IP 哈希限流;限流记录仅用于防滥用,应用数据库会抽样清理超过 48 小时的限流桶。Cloudflare 基础设施日志的保存与处理还受 Cloudflare 服务配置和政策约束。 匿名贡献者 ID 不是登录账号或安全凭证。能够读取 Chrome 同步数据的人可能以同一匿名身份查询贡献记录,因此扩展不会使用该 ID 保存 Cookie、密码、支付资料或其他敏感信息。 -如发现隐私问题,请按照 [SECURITY.md](SECURITY.md) 私下报告。 +如需更正或删除自己提交的公共片段,可在 GitHub Issue 中只提供片段 ID;不要公开匿名贡献者 ID、IP、Cookie 或其他敏感信息。如发现隐私或安全问题,请按照 [SECURITY.md](SECURITY.md) 私下报告。 diff --git a/README.md b/README.md index f73e755..0d30513 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 抖音网页版社区片段助手(Douyin Ad Skipper) +# 抖音网页版社区片段助手(Douyin Community Segment Assistant) 一个非官方、开源、社区驱动的浏览器扩展,用于在抖音网页版中标记视频内部的赞助、自我推广、互动提醒等可选片段,并按照用户选择显示或跳过这些片段。 @@ -14,7 +14,7 @@ - 在抖音播放器控制栏内创建视频片段的起止时间。 - 在播放器进度条中显示已标记片段:待提交为深绿色,已提交/可信片段为亮绿色。 - 再次播放同一作品时自动跳过本地草稿或社区片段。 -- 可从播放器内的提交菜单把待提交片段发送到自建社区服务。 +- 可从播放器内的提交菜单把待提交片段发送到项目公共社区服务。 - 在选项页查看本地草稿、云端片段和累计贡献时长。 - 导入、导出本地数据。 - 未提交草稿留在本地;已提交片段以社区服务器为准,不上传完整浏览记录。 @@ -28,7 +28,7 @@ 5. 选择本仓库根目录。 6. 打开或刷新 `https://www.douyin.com/`。 -也可以从 GitHub Releases 下载自动生成的 Chrome ZIP,解压后按上述方式加载。Release 标签必须与 `manifest.json` 版本一致,例如版本 `0.6.4` 使用标签 `v0.6.4`。 +也可以从 GitHub Releases 下载自动生成的 Chrome ZIP,解压后按上述方式加载。Release 标签必须与 `manifest.json` 版本一致,例如版本 `0.7.0` 使用标签 `v0.7.0`。 ## 创建片段 @@ -36,7 +36,7 @@ 1. 可选片段开始时点击“片段从当前开始”。 2. 图标切换为红色取消按钮和青色结束按钮。 -3. 广告结束时点击“片段现在结束”,片段先作为本地草稿保存,并以深绿色显示在进度条上。 +3. 片段结束时点击“片段现在结束”,片段先作为本地草稿保存,并以深绿色显示在进度条上。 4. 此时控制栏会出现“提交”和“删除最后一个未提交片段”按钮;点击提交会打开播放器内菜单。 5. 在提交菜单使用“片段开头”“片段结尾”和“精确编辑”校准边界,再逐段点击“预览”;播放器实际越过片段末尾后才允许提交。 6. 提交成功后本地草稿会删除,亮绿色片段和管理列表从社区服务读取。 @@ -53,7 +53,7 @@ ## 社区共享 -扩展默认连接项目公共社区 API,普通用户安装后即可查询和提交。新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错: +首次使用时,扩展会让用户明确选择“启用社区”或“仅本地”。只有同意后才会连接项目公共 API;新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错: 扩展会把随机匿名贡献者 ID 保存到 Chrome 同步存储;使用同一 Chrome 账号安装扩展后,可在另一台电脑看到自己的云端片段与贡献统计。服务器只保存该 ID 的加盐哈希。 @@ -65,7 +65,7 @@ 错误片段被降权或隐藏 ``` -服务端实现位于 `server/`,包含重复片段检测、投票、举报与限流。高级用户仍可在选项中连接自建 API。详见 [服务端说明](server/README.md)、[路线图](docs/ROADMAP.md) 和 [API 草案](docs/API.md)。 +服务端实现位于 `server/`,包含幂等提交、重复片段检测、投票、举报与限流。公共扩展固定连接项目公共 API;需要自建服务的开发者可以在自己的源码分支中替换地址和清单权限。详见 [服务端说明](server/README.md)、[路线图](docs/ROADMAP.md) 和 [API 草案](docs/API.md)。 ## 项目结构 @@ -82,7 +82,7 @@ ## 隐私 -社区查询默认启用并连接项目公共 API,也可随时关闭或改用自建服务。扩展只查询当前作品 ID,并且仅在你主动点击提交时上传片段起止时间。完整说明见 [PRIVACY.md](PRIVACY.md)。 +社区查询默认关闭。用户明确同意后,扩展才会使用当前作品 ID 的 SHA-256 哈希查询公共 API;仅在主动点击提交时上传作品 ID 和片段时间。完整说明见 [PRIVACY.md](PRIVACY.md)。 ## 使用边界 @@ -98,7 +98,7 @@ 本项目的大部分代码与文档由维护者在 AI 辅助下完成,并经过人工确认与测试。AI 生成内容仍可能存在疏漏,因此问题报告、代码审查和真实使用反馈尤其重要。提交代码前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md),安全或隐私问题请按照 [SECURITY.md](SECURITY.md) 私下反馈,不要公开披露敏感细节。 -每次推送和 Pull Request 都会运行扩展语法、清单约束、Worker 测试和 TypeScript 检查;推送 `v版本号` 标签后会自动生成不含服务器配置的 Chrome ZIP,并发布到 GitHub Releases。 +每次推送和 Pull Request 都会运行扩展语法、权限与行为契约、Worker 测试和 TypeScript 检查;推送 `v版本号` 标签后会验证标签来自主分支,生成不含服务器配置的 Chrome ZIP、SHA-256 校验文件和对应版本说明。 ## 许可证 diff --git a/content.css b/content.css index f0410a2..c44452c 100644 --- a/content.css +++ b/content.css @@ -3,9 +3,13 @@ top: 76px; left: 50%; z-index: 2147483647; - padding: 10px 16px; + display: flex; + max-width: min(760px, calc(100vw - 32px)); + align-items: center; + gap: 12px; + padding: 11px 14px; border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 999px; + border-radius: 12px; color: #fff; background: rgba(22, 24, 29, 0.92); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3); @@ -16,6 +20,8 @@ transition: opacity 160ms ease, transform 160ms ease; } +#das-toast > span:first-child { min-width:0; overflow-wrap:anywhere; } + #das-toast.das-visible { opacity: 1; transform: translate(-50%, 0); @@ -178,7 +184,7 @@ .das-submission-actions { grid-template-columns:1fr; } } -.das-toast-actions { display:flex; gap:6px; margin-left:12px; pointer-events:auto; } +.das-toast-actions { display:flex; flex:none; flex-wrap:wrap; gap:6px; 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 4305986..dee3567 100644 --- a/content.js +++ b/content.js @@ -4,9 +4,10 @@ const DEFAULT_COMMUNITY_API = 'https://douyin-ad-skipper-api.douyin-skip-community.workers.dev'; const DEFAULTS = { enabled: true, - skipLabeledAds: true, skipLocalSegments: true, - communityEnabled: true, + communityEnabled: false, + communityConsentGranted: false, + communityConsentPrompted: false, communityApiBase: DEFAULT_COMMUNITY_API, categoryModeSponsor: 'auto', categoryModeSelfpromo: 'manual', @@ -21,13 +22,7 @@ skippedCount: 0, localSegments: {}, }; - const AD_LABELS = new Set(['广告', '商业推广', '广告推广', '推广']); - const CHECK_INTERVAL_MS = 900; - const SKIP_COOLDOWN_MS = 3500; - let settings = { ...DEFAULTS }; - let lastSkipAt = 0; - let lastVideo = null; let draftStart = null; let draftVideoId = null; let lastSegmentSkipKey = ''; @@ -36,10 +31,11 @@ const skipSuppressedUntil = new Map(); const communityCache = new Map(); const COMMUNITY_CACHE_MS = 10 * 60 * 1000; + const COMMUNITY_RETRY_MS = [5000, 15000, 60000, 5 * 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); + const log = (...args) => settings.debug && console.debug('[抖音社区片段助手]', ...args); function isVisible(element) { if (!(element instanceof Element)) return false; @@ -168,25 +164,31 @@ } } + function communityRetryDelay(failureCount) { + return COMMUNITY_RETRY_MS[Math.min(Math.max(1, Number(failureCount) || 1) - 1, COMMUNITY_RETRY_MS.length - 1)]; + } + 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; + if (!settings.communityConsentGranted || !settings.communityEnabled) return; const videoId = extractVideoId(video); const apiBase = normalizedApiBase(); if (!videoId || !apiBase) return; const cached = communityCache.get(videoId); - if (cached && Date.now() - cached.loadedAt < COMMUNITY_CACHE_MS) return; - setCommunityCache(videoId, { loadedAt: Date.now(), segments: cached?.segments || [], loading: true }); + const now = Date.now(); + if (cached?.loading || Number(cached?.retryAt || 0) > now) return; + if (cached?.loadedAt && now - cached.loadedAt < COMMUNITY_CACHE_MS) return; + setCommunityCache(videoId, { ...cached, segments: cached?.segments || [], loading: true }); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 4000); try { const videoHash = await sha256Hex(videoId); const response = await fetch(`${apiBase}/v1/videos/by-hash/${videoHash}/segments`, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'X-Client-ID': settings.communityClientId }, cache: 'no-store', signal: controller.signal, }); @@ -194,12 +196,19 @@ const payload = await response.json(); const segments = Array.isArray(payload.segments) ? payload.segments .filter((item) => CATEGORY_LABELS[item.category] && Number.isFinite(item.start) && Number.isFinite(item.end) && item.end > item.start) - .map((item) => ({ start: item.start, end: item.end, category:item.category, source: 'community', id: item.id, status: item.status })) : []; - setCommunityCache(videoId, { loadedAt: Date.now(), segments }); + .map((item) => ({ start: item.start, end: item.end, category:item.category, source: 'community', id: item.id, status: item.status, ownedByMe: item.ownedByMe === true })) : []; + setCommunityCache(videoId, { loadedAt: Date.now(), retryAt: 0, failureCount: 0, segments }); renderPreviewBar(); log('已加载社区片段', videoId, segments.length); } catch (error) { - setCommunityCache(videoId, { loadedAt: Date.now(), segments: cached?.segments || [] }); + const failureCount = Number(cached?.failureCount || 0) + 1; + const retryDelay = communityRetryDelay(failureCount); + setCommunityCache(videoId, { + loadedAt: cached?.loadedAt || 0, + retryAt: Date.now() + retryDelay, + failureCount, + segments: cached?.segments || [], + }); log('社区片段查询失败,继续使用本地数据', error); } finally { clearTimeout(timer); @@ -221,48 +230,16 @@ }; } - function normalizedLeafText(element) { - if (element.children.length > 0) return ''; - return (element.textContent || '').replace(/\s+/g, '').trim(); - } - - function findAdSignal(container) { - if (!container) return null; - const elements = [container, ...container.querySelectorAll('span, div, p, a, button')]; - for (const element of elements) { - if (!isVisible(element)) continue; - const text = normalizedLeafText(element); - if (AD_LABELS.has(text)) return { type: 'label', text, element }; - - const aria = (element.getAttribute('aria-label') || '').replace(/\s+/g, ''); - if (AD_LABELS.has(aria)) return { type: 'aria-label', text: aria, element }; - } - return null; - } - - function findNextButton() { - const selectors = [ - '[data-e2e="arrow-right"]', - '[data-e2e="feed-next"]', - 'button[aria-label="下一个视频"]', - 'button[aria-label="下一条"]', - '[role="button"][aria-label="下一条"]', - ]; - for (const selector of selectors) { - const match = [...document.querySelectorAll(selector)].find(isVisible); - if (match) return match; - } - return null; - } - - function showToast(message, actions = []) { - if (!settings.showToast) return; + function displayToast(message, actions = [], required = false, persistent = false) { + if (!required && !settings.showToast && actions.length === 0) return; let toast = document.getElementById('das-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'das-toast'; document.documentElement.appendChild(toast); } + toast.setAttribute('role', actions.length ? 'alertdialog' : 'status'); + toast.setAttribute('aria-live', required ? 'assertive' : 'polite'); toast.replaceChildren(); const text = document.createElement('span'); text.textContent = message; @@ -285,8 +262,65 @@ } toast.classList.remove('das-visible'); requestAnimationFrame(() => toast.classList.add('das-visible')); - clearTimeout(showToast.timer); - showToast.timer = setTimeout(() => toast.classList.remove('das-visible'), actions.length ? 6000 : 1800); + clearTimeout(displayToast.timer); + if (!persistent) displayToast.timer = setTimeout(() => toast.classList.remove('das-visible'), actions.length ? 8000 : 2200); + } + + function showToast(message, actions = []) { + displayToast(message, actions, false); + } + + function showRequiredToast(message, actions = [], persistent = false) { + displayToast(message, actions, true, persistent); + } + + async function responseErrorCode(response) { + try { + const payload = await response.clone().json(); + return typeof payload?.error === 'string' ? payload.error : ''; + } catch { + return ''; + } + } + + function communityErrorMessage(code, fallback) { + return ({ + cannot_vote_own_segment: '这是你提交的片段,无需给自己的片段投票', + cannot_report_own_segment: '这是你提交的片段,不能举报自己的投稿', + rate_limited: '操作太频繁,请稍后再试', + segment_not_found: '该社区片段已不存在或已停止共享', + writes_not_configured: '社区服务暂时只读,请稍后再试', + invalid_client_id: '匿名贡献身份无效,请重新打开扩展', + })[code] || fallback; + } + + async function chooseCommunityMode(enabled) { + const update = { + communityConsentPrompted: true, + communityConsentGranted: enabled === true, + communityEnabled: enabled === true, + communityApiBase: DEFAULT_COMMUNITY_API, + }; + Object.assign(settings, update); + await chrome.storage.local.set(update); + if (enabled) { + if (!settings.communityClientId) settings.communityClientId = await getContributorId(); + const video = getActiveVideo(); + if (video) void loadCommunitySegments(video); + showRequiredToast('社区共享已启用'); + } else { + communityCache.clear(); + renderPreviewBar(); + showRequiredToast('已选择仅本地使用,可随时在设置中启用社区'); + } + } + + function showCommunityConsent() { + if (settings.communityConsentPrompted) return; + showRequiredToast('启用社区会发送作品 ID 哈希和匿名贡献 ID 来查询片段;只有主动提交才上传作品 ID 与片段时间', [ + { label: '启用社区', run: () => { void chooseCommunityMode(true); } }, + { label: '仅本地', run: () => { void chooseCommunityMode(false); } }, + ], true); } async function recordSkip(change = 1) { @@ -384,7 +418,7 @@ const video = getActiveVideo(); const videoId = video && extractVideoId(video); if (!video || !videoId) { - showToast('暂时无法取得当前作品 ID'); + showRequiredToast('暂时无法取得当前作品 ID'); return; } @@ -397,11 +431,11 @@ const end = video.currentTime; if (draftStart === null || draftVideoId !== videoId) { cancelDraft(); - showToast('当前视频已变化,请重新开始标记'); + showRequiredToast('当前视频已变化,请重新开始标记'); return; } if (end <= draftStart + 0.2) { - showToast('结束时间必须晚于开始时间'); + showRequiredToast('结束时间必须晚于开始时间'); return; } if (end - draftStart < 1 && !confirm('这个片段不足 1 秒,时间点可能不准确。仍然保存吗?')) return; @@ -588,7 +622,7 @@ const nextStart = field === 'start' ? nextValue : Number(target.start); const nextEnd = field === 'end' ? nextValue : Number(target.end); if (nextStart < 0 || nextEnd <= nextStart + 0.2 || nextEnd > (video.duration || Infinity)) { - showToast('调整后的片段范围无效'); + showRequiredToast('调整后的片段范围无效'); return; } target[field] = nextValue; @@ -622,7 +656,7 @@ const timer = setInterval(async () => { if (extractVideoId(video) !== videoId || Date.now() - startedAt > 15000) { clearInterval(timer); - if (Date.now() - startedAt > 15000) showToast('未完成预览,请保持播放至片段结束'); + if (Date.now() - startedAt > 15000) showRequiredToast('未完成预览,请重新点击预览并保持播放器处于播放状态'); return; } if (video.currentTime < segment.end - 0.15) return; @@ -640,15 +674,15 @@ async function submitPendingSegments(video, videoId, menu) { const apiBase = normalizedApiBase(); - if (!settings.communityEnabled || !apiBase) { - showToast('请先在扩展选项中启用并配置社区服务'); + if (!settings.communityConsentGranted || !settings.communityEnabled || !apiBase) { + showRequiredToast('请先同意并启用社区共享'); chrome.runtime.openOptionsPage?.(); 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('请先预览全部待提交片段'); + showRequiredToast('请先预览全部待提交片段'); return; } button.disabled = true; @@ -657,6 +691,7 @@ const submittedSegments = new Set(); const confirmedCommunitySegments = []; let submitted = 0; + let submissionError = ''; for (const segment of segments) { if ((segment.submissionStatus || 'pending') !== 'pending') continue; try { @@ -665,7 +700,7 @@ headers: { 'Content-Type': 'application/json', 'X-Client-ID': settings.communityClientId }, 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}`); + if (!response.ok) throw new Error(communityErrorMessage(await responseErrorCode(response), '社区提交失败')); const payload = await response.json(); const confirmed = payload.segment; if (confirmed && Number.isFinite(confirmed.start) && Number.isFinite(confirmed.end)) { @@ -676,12 +711,14 @@ id: confirmed.id, status: confirmed.status || 'trusted', category: confirmed.category || segment.category || 'sponsor', + ownedByMe: confirmed.ownedByMe === true, }); } submittedSegments.add(segment); submitted += 1; } catch (error) { log('社区片段提交失败', error); + submissionError = error instanceof Error ? error.message : '社区提交失败'; } } const localSegments = { ...(settings.localSegments || {}) }; @@ -698,7 +735,8 @@ closeSubmissionMenu(); renderPlayerControls(); renderPreviewBar(); - showToast(submitted ? `已提交 ${submitted} 个片段` : '提交失败,片段仍保留在本地'); + if (submitted) showToast(`已提交 ${submitted} 个片段`); + else showRequiredToast(`${submissionError || '提交失败'},片段仍保留在本地`); } function getVideoPlayer(video) { @@ -806,14 +844,15 @@ }]); }; const actions = [{ label:'撤销', run:undo }]; - if (segment.source === 'community' && segment.id) { + if (segment.source === 'community' && segment.id && !segment.ownedByMe) { actions.push( { label:'赞成', run:()=>voteOnSegment(segment,1) }, { label:'反对', run:()=>voteOnSegment(segment,-1) }, { label:'举报', run:()=>showReportChoices(segment) }, ); } - showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}`, actions); + const ownership = segment.ownedByMe ? ' · 你的投稿' : ''; + showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}${ownership}`, actions); log(segment.source === 'community' ? '跳过社区可信片段' : '跳过本地标记片段', videoId, segment); } @@ -834,24 +873,28 @@ async function voteOnSegment(segment, vote) { const apiBase = normalizedApiBase(); if (!apiBase || !settings.communityClientId) return; + if (segment.ownedByMe) { + showRequiredToast('这是你提交的片段,无需给自己的片段投票'); + 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}`); + if (!response.ok) throw new Error(communityErrorMessage(await responseErrorCode(response), '反馈失败,请稍后重试')); const result = await response.json(); segment.status = result.status || segment.status; showToast(vote === 1 ? '感谢确认这个片段' : '已反馈:这个片段有问题'); } catch (error) { log('片段投票失败', error); - showToast('反馈失败,请稍后重试'); + showRequiredToast(error instanceof Error ? error.message : '反馈失败,请稍后重试'); } } function showReportChoices(segment) { showToast('请选择问题类型', [ { label:'时间错误', run:()=>reportSegment(segment,'wrong_time') }, - { label:'不是广告', run:()=>reportSegment(segment,'not_ad') }, + { label:'分类错误', run:()=>reportSegment(segment,'not_ad') }, { label:'视频不符', run:()=>reportSegment(segment,'wrong_video') }, { label:'滥用', run:()=>reportSegment(segment,'abuse') }, ]); @@ -860,53 +903,21 @@ async function reportSegment(segment, reason) { const apiBase = normalizedApiBase(); if (!apiBase || !settings.communityClientId) return; + if (segment.ownedByMe) { + showRequiredToast('这是你提交的片段,不能举报自己的投稿'); + 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}`); + if (!response.ok) throw new Error(communityErrorMessage(await responseErrorCode(response), '举报失败,请稍后重试')); await response.json(); showToast('举报已提交,感谢帮助维护社区质量'); } catch (error) { log('片段举报失败', error); - showToast('举报失败,请稍后重试'); - } - } - - function moveToNextVideo() { - const button = findNextButton(); - if (button) { - button.click(); - log('通过下一条按钮跳过'); - return '按钮'; + showRequiredToast(error instanceof Error ? error.message : '举报失败,请稍后重试'); } - - const target = document.activeElement || document.body; - const eventOptions = { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true }; - target.dispatchEvent(new KeyboardEvent('keydown', eventOptions)); - target.dispatchEvent(new KeyboardEvent('keyup', eventOptions)); - window.dispatchEvent(new WheelEvent('wheel', { deltaY: Math.max(innerHeight * 0.85, 600), bubbles: true })); - log('通过方向键/滚轮跳过'); - return '翻页'; - } - - async function checkCurrentVideo() { - if (!settings.enabled || !settings.skipLabeledAds || document.hidden || Date.now() - lastSkipAt < SKIP_COOLDOWN_MS) return; - const video = getActiveVideo(); - if (!video) return; - if (video !== lastVideo) { - lastVideo = video; - log('检测到当前视频', video.currentSrc || video.src || '(无地址)'); - } - - const signal = findAdSignal(getVideoContainer(video)); - if (!signal) return; - - lastSkipAt = Date.now(); - const method = moveToNextVideo(); - await recordSkip(); - showToast(`已跳过广告 · ${method}`); - log('命中广告标识', signal.type, signal.text, signal.element); } async function getContributorId() { @@ -928,21 +939,42 @@ 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; - await chrome.storage.local.set({ communityApiBase: DEFAULT_COMMUNITY_API, communityEnabled: true }); + const migration = { communityApiBase: DEFAULT_COMMUNITY_API }; + if (!Object.hasOwn(stored, 'communityConsentPrompted')) { + Object.assign(migration, { + communityConsentPrompted: false, + communityConsentGranted: false, + communityEnabled: false, + }); + } else if (!settings.communityConsentGranted && settings.communityEnabled) { + migration.communityEnabled = false; } - settings.communityClientId = await getContributorId(); + Object.assign(settings, migration); + await chrome.storage.local.set(migration); + await chrome.storage.local.remove(['communitySkipMode', 'communityAutoSkipTrusted', 'skipLabeledAds']); + if (settings.communityConsentGranted) settings.communityClientId = await getContributorId(); log('扩展已启动', settings); - checkCurrentVideo(); ensurePlayerControls(); + showCommunityConsent(); })(); chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; for (const [key, change] of Object.entries(changes)) settings[key] = change.newValue; + if (changes.communityConsentPrompted?.newValue === true) { + clearTimeout(displayToast.timer); + document.getElementById('das-toast')?.classList.remove('das-visible'); + } + if (changes.communityConsentGranted?.newValue === true && !settings.communityClientId) { + void getContributorId().then((id) => { + settings.communityClientId = id; + const video = getActiveVideo(); + if (video && settings.communityEnabled) void loadCommunitySegments(video); + }); + } + if (changes.communityConsentGranted?.newValue === false || changes.communityEnabled?.newValue === false) { + communityCache.clear(); + } renderPlayerControls(); renderPreviewBar(); }); @@ -951,7 +983,6 @@ schedulePlayerControls(); clearTimeout(observer.timer); observer.timer = setTimeout(() => { - checkCurrentVideo(); ensurePlayerControls(); }, 180); }); @@ -960,7 +991,6 @@ 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); setInterval(renderPreviewBar, 300); diff --git a/docs/API.md b/docs/API.md index d86f294..5bb74d1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -30,9 +30,10 @@ GET /v1/videos/{videoId}/segments ```http GET /v1/videos/by-hash/{sha256(videoId)}/segments +X-Client-ID: 匿名贡献者 UUID(已启用社区时) ``` -原始作品 ID 查询仅保留用于旧客户端兼容和迁移。 +带合法匿名贡献者 ID 时,每个结果额外返回 `ownedByMe`,供客户端隐藏对自己投稿的投票和举报入口;服务端不会返回提交者哈希。原始作品 ID 查询仅保留用于旧客户端兼容和迁移。 ## 提交片段 @@ -52,7 +53,7 @@ Content-Type: application/json } ``` -服务端必须校验数值范围、片段长度、视频时长、重复请求和提交速率。 +服务端必须校验数值范围、片段长度、视频时长、重复请求和提交速率。`clientRequestId` 与提交者哈希组成唯一键;相同请求重试返回原片段,不会重复创建。 ## 我的社区片段 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 65be80f..eda0fa2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ ```text 抖音页面 - ├─ content.js:活动视频、作品 ID、控制栏按钮、自动跳过 + ├─ content.js:活动视频、作品 ID、控制栏按钮、时间片段跳过 ├─ content.css:播放器按钮和提示 └─ chrome.storage.local ├─ 设置 @@ -17,7 +17,7 @@ options.* → 片段管理、设置和备份 内容脚本优先通过 `data-e2e="feed-active-video"` 获取当前播放器,并兼容详情页路径、`modal_id`、`data-aweme-id` 和 `video_作品ID` 类名。 -## 计划中的社区架构 +## 当前社区架构 ```text Chrome 扩展 @@ -42,7 +42,8 @@ Chrome 扩展 ## 设计约束 - 扩展不依赖抖音私有接口获取视频内容。 -- 共享 API 只以作品 ID 查询,不提交完整浏览历史。 +- 用户明确同意后,共享 API 只以作品 ID 哈希查询,不提交完整浏览历史。 - 未达到信任阈值的片段默认只显示,不自动跳过。 - 所有远程功能必须可关闭。 +- 公共扩展只允许访问清单中声明的固定社区 API,不申请任意 HTTPS 域名权限。 - 客户端不能持有管理员或数据库密钥。 diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 416d51c..7b9268f 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -31,6 +31,7 @@ "status": "trusted", "upvotes": 2, "downvotes": 0, + "ownedByMe": false, "createdAt": "2026-08-05T12:00:00Z" } ``` @@ -45,3 +46,5 @@ - `rejected`:恶意、重复或明显错误。 相同作品中高度重叠的片段应复用已有记录,而不是无限创建重复记录。 + +服务端还保存只用于幂等的 `client_request_id`,并对 `(submitter_hash, client_request_id)` 建立唯一索引。该字段不会在公共查询中返回。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bf93656..65b7b4f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ ## 阶段 1:本地扩展(当前) -- [x] 整条广告识别 +- [x] 明确移除平台整条广告识别,只处理普通作品时间片段 - [x] 播放器内嵌片段标记 - [x] 本地自动跳过 - [x] 片段管理与备份 @@ -23,7 +23,7 @@ - [x] 匿名客户端身份 - [x] 片段提交与幂等校验 - [x] 速率限制 -- [ ] 点赞、点踩与举报 +- [x] 点赞、点踩与举报 - [ ] 相似片段聚类 - [ ] 管理员审核工具 diff --git a/manifest.json b/manifest.json index 35a1070..b95a94a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "抖音广告跳过", - "version": "0.6.4", - "description": "跳过抖音网页版广告,并与社区共享手动标记的广告片段。", + "name": "抖音网页版社区片段助手", + "version": "0.7.0", + "description": "标记、预览和跳过抖音网页版普通作品中的社区时间片段。", "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", @@ -14,9 +14,8 @@ "https://www.douyin.com/*", "https://douyin-ad-skipper-api.douyin-skip-community.workers.dev/*" ], - "optional_host_permissions": ["https://*/*"], "action": { - "default_title": "抖音广告自动跳过", + "default_title": "抖音网页版社区片段助手", "default_popup": "popup.html", "default_icon": { "16": "icons/icon-16.png", diff --git a/options.css b/options.css index 81080cd..bd9cd4e 100644 --- a/options.css +++ b/options.css @@ -89,14 +89,23 @@ h2 { margin:0; font-size:17px; } .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; } +.consent-card { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:16px; margin-bottom:16px; padding:18px 20px; border:1px solid rgba(37,244,238,.25); border-radius:14px; background:linear-gradient(120deg,rgba(37,244,238,.08),rgba(254,44,85,.05)); } +.consent-icon { display:grid; place-items:center; width:42px; height:42px; border-radius:12px; color:#101216; background:var(--cyan); font-size:23px; font-weight:800; } +.consent-copy strong { display:block; font-size:16px; } +.consent-copy p { margin:4px 0 0; color:#aeb4c0; } +.consent-actions { display:flex; align-items:center; gap:8px; } +.consent-actions .primary-button,.consent-actions .secondary-button { margin-top:0; white-space:nowrap; } .community-connect { display:grid; gap:14px; padding:20px 21px; } .community-connect p { margin:3px 0 0; color:var(--muted); } +.community-connect code { display:block; padding:11px 13px; overflow:auto; border:1px solid #363b46; border-radius:9px; color:#c8cdd7; background:#101217; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } .community-input { display:flex; gap:9px; } .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; } +.danger-button { margin-top:16px; padding:9px 13px; border:1px solid #6b3341; border-radius:8px; color:#ff8aa1; background:#2b1920; cursor:pointer; } +.danger-button:hover { background:#3a1d27; } .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); } .data-icon { display:grid; place-items:center; width:40px; height:40px; margin-bottom:17px; border-radius:11px; color:var(--cyan); background:rgba(37,244,238,.1); font-size:22px; } @@ -104,10 +113,11 @@ h2 { margin:0; font-size:17px; } .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; } +.setting-row input:disabled+i { opacity:.42; cursor:not-allowed; } .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); } .about-card img { width:92px; height:92px; border-radius:22px; } #optionsToast { position:fixed; right:24px; bottom:24px; padding:11px 15px; border:1px solid var(--line); border-radius:9px; color:#fff; background:#242832; box-shadow:0 10px 30px #0008; opacity:0; transform:translateY(8px); pointer-events:none; transition:.18s; } #optionsToast.visible { opacity:1; transform:none; } -@media(max-width:950px){.metric-grid{grid-template-columns:1fr 1fr}.app-shell{grid-template-columns:220px 1fr}.content{padding:36px 28px}.brand strong{font-size:14px}} +@media(max-width:950px){.metric-grid{grid-template-columns:1fr 1fr}.app-shell{grid-template-columns:220px 1fr}.content{padding:36px 28px}.brand strong{font-size:14px}.consent-card{grid-template-columns:auto 1fr}.consent-actions{grid-column:1 / -1}} diff --git a/options.html b/options.html index f717a13..9967e3c 100644 --- a/options.html +++ b/options.html @@ -3,13 +3,13 @@ - 抖音广告跳过 · 设置 + 抖音网页版社区片段助手 · 设置
-

DASHBOARD

跳过概览

查看草稿、社区贡献、累计跳过和当前运行状态。

社区模式
+

DASHBOARD

跳过概览

查看草稿、社区贡献、累计跳过和当前运行状态。

仅本地
已标记片段0个时间片段
涉及视频0个抖音作品
@@ -50,10 +50,9 @@
-

BEHAVIOR

跳过行为

决定哪些内容自动跳过。

+

BEHAVIOR

跳过行为

设置社区时间片段和本地草稿的处理方式。

- - +
@@ -79,19 +78,30 @@
-

COMMUNITY BETA

社区共享

默认连接项目公共 API;提交成功的广告片段会立即与其他用户共享。

未连接
+

COMMUNITY BETA

社区共享

与其他用户共享手动标记的时间片段。未明确同意前不会连网查询。

等待选择
+
-
社区 API 地址

公共 API 已随扩展授权并默认启用。只有改用自定义服务器时,Chrome 才会询问新域名的访问权限。

-
+
项目公共 API

所有用户使用同一个社区服务。未同意或已撤回同意时,扩展不会访问该地址。

+ https://douyin-ad-skipper-api.douyin-skip-community.workers.dev
- +
赞助 / 广告

付费推广、推荐和直接广告

自我推广

作者推广自己的商品、账号或服务

互动提醒

点赞、关注、评论等简短提醒

-
-
隐私设计

远程查询失败时会立即回退到本地数据。扩展不会自动上传你创建的片段,只有点击播放器提交按钮或片段管理中的上传按钮时才会发送。

+
+
隐私设计

远程查询失败时会回退到本地数据。本地草稿不会自动上传;只有你点击播放器的提交按钮,或片段管理中的上传按钮时才会发送。撤回同意后会立即停止社区查询。

@@ -104,8 +114,8 @@
-

ABOUT

关于抖音广告跳过

一个开源、社区驱动的 Chrome 扩展。

-

抖音广告跳过

支持本地片段和社区共享。社区查询默认启用,本地片段仍只会在你主动点击提交时上传。

+

ABOUT

关于抖音网页版社区片段助手

一个开源、社区驱动的 Chrome 扩展。

+

抖音网页版社区片段助手

支持本地时间片段和社区共享。社区查询需要你明确同意,本地草稿只会在你主动点击提交时上传。

diff --git a/options.js b/options.js index 7145275..7a0931b 100644 --- a/options.js +++ b/options.js @@ -1,6 +1,6 @@ const DEFAULT_COMMUNITY_API='https://douyin-ad-skipper-api.douyin-skip-community.workers.dev'; 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:'' }; +const DEFAULTS = { enabled:true, skipLocalSegments:true, showToast:true, debug:false, shortcutsEnabled:true, shortcutCreate:'Alt+KeyZ', shortcutCancel:'Alt+KeyX', shortcutSubmit:'Alt+Enter', skippedCount:0, localSegments:{}, communityEnabled:false, communityConsentGranted:false, communityConsentPrompted:false, communityApiBase:DEFAULT_COMMUNITY_API, categoryModeSponsor:'auto', categoryModeSelfpromo:'manual', categoryModeInteraction:'manual', communityClientId:'' }; let state = { ...DEFAULTS }; let capturingShortcut=''; let communitySegments = []; @@ -81,7 +81,7 @@ async function getContributorId() { } async function fetchMyContributions() { - if(!state.communityEnabled||!state.communityApiBase||!state.communityClientId)return; + if(!state.communityConsentGranted||!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}`); @@ -100,7 +100,7 @@ async function fetchMyContributions() { } if(changed){state.localSegments=localSegments;await chrome.storage.local.set({localSegments})} renderOverview();renderSegments(); - }catch(error){console.error('[抖音广告跳过] 获取我的社区片段失败',error)} + }catch(error){console.error('[抖音社区片段助手] 获取我的社区片段失败',error)} } async function deleteSegment(videoId,index) { @@ -143,21 +143,11 @@ async function adjustSegment(videoId,index,field,delta) { } async function ensureCommunityReady() { - if (!state.communityEnabled || !state.communityApiBase) { - toast('请先在“社区共享”中授权并连接 API'); - return false; - } - try { - const pattern=`${new URL(state.communityApiBase).origin}/*`; - if (!await chrome.permissions.contains({origins:[pattern]})) { - toast('社区 API 域名权限缺失,请重新授权连接'); - return false; - } - return true; - } catch { - toast('社区 API 地址无效'); + if (!state.communityConsentGranted || !state.communityEnabled) { + toast('请先在“社区共享”中同意并启用社区'); return false; } + return state.communityApiBase === DEFAULT_COMMUNITY_API; } async function uploadSegment(videoId,index) { @@ -179,7 +169,7 @@ async function uploadSegment(videoId,index) { await fetchMyContributions(); return true; } catch(error) { - console.error('[抖音广告跳过] 上传社区失败',error); + console.error('[抖音社区片段助手] 上传社区失败',error); return false; } } @@ -200,7 +190,7 @@ async function uploadAllPending() { } function exportData() { - 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 payload = {format:'douyin-ad-skipper-backup',version:5,exportedAt:new Date().toISOString(),settings:{enabled:state.enabled,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('备份已导出'); } @@ -216,9 +206,9 @@ async function importData(file) { 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 allowedSettings=['enabled','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 ['enabled','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('备份已导入'); @@ -233,34 +223,42 @@ function normalizeImportedSegment(item,videoId){ } function syncSettings() { - ['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled','communityEnabled'].forEach((key)=>{ $(`#${key}`).checked=Boolean(state[key]); }); + ['enabled','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 ''; } +function renderCommunityStatus() { + const status=$('#communityStatus'); + const granted=state.communityConsentGranted===true; + const enabled=granted&&state.communityEnabled===true; + status.textContent=enabled?'已启用':granted?'已暂停':state.communityConsentPrompted?'仅本地':'等待选择'; + $('#overviewStatus').textContent=enabled?'社区已启用':granted?'社区已暂停':'仅本地'; + $('#sidebarPrivacy').innerHTML=enabled?'社区查询已启用
草稿需手动提交':'本地草稿只保存在此浏览器'; + $('#communityApiDisplay').textContent=DEFAULT_COMMUNITY_API; + $('#communityEnabled').disabled=!granted; + $('#grantCommunityConsent').hidden=granted; + $('#useLocalOnly').hidden=granted; + $('#revokeCommunityConsent').hidden=!granted; + $('#consentTitle').textContent=granted?'社区共享已授权':state.communityConsentPrompted?'当前为仅本地模式':'选择社区模式'; + $('#consentDescription').textContent=granted + ? '扩展只在社区查询启用时发送当前作品 ID 的哈希;片段内容仍只会在你主动提交时上传。' + : '启用后,扩展会发送当前作品 ID 的哈希来查询共享片段;不会上传完整观看历史,本地草稿只有在你主动提交时才会上传。'; } -async function renderCommunityStatus() { - const status=$('#communityStatus'); - 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]}); - const connected=granted&&typedOrigin===configuredOrigin; - status.textContent=connected&&state.communityEnabled?'已启用':connected?'已停用':granted?'待连接':'待授权'; - button.textContent=connected?'已授权':'授权并连接'; - button.disabled=connected; - reset.disabled=typedOrigin===DEFAULT_COMMUNITY_API; +async function setCommunityConsent(granted) { + const update={communityConsentPrompted:true,communityConsentGranted:granted===true,communityEnabled:granted===true,communityApiBase:DEFAULT_COMMUNITY_API}; + Object.assign(state,update); + if(granted&&!state.communityClientId)state.communityClientId=await getContributorId(); + if(!granted){communitySegments=[];communityStats={submittedCount:0,contributedSeconds:0,skipCount:0,helpedPeople:0,secondsSaved:0}} + await chrome.storage.local.set(update); + syncSettings();renderOverview();renderSegments(); + if(granted)await fetchMyContributions(); + toast(granted?'社区共享已启用':'已切换为仅本地使用'); } document.querySelectorAll('nav button[data-page]').forEach((button)=>button.addEventListener('click',()=>showPage(button.dataset.page))); @@ -281,25 +279,15 @@ document.addEventListener('keydown',async(event)=>{ 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('设置已保存')})); +['enabled','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} + if(event.target.checked&&!state.communityConsentGranted){event.target.checked=false;await setCommunityConsent(true);return} state.communityEnabled=event.target.checked;await chrome.storage.local.set({communityEnabled:state.communityEnabled});renderCommunityStatus();toast('社区查询设置已保存'); }); -$('#connectCommunity').addEventListener('click',async()=>{ - const pattern=await communityOriginPattern();if(!pattern){toast('请输入有效的 HTTPS API 地址');return} - const granted=await chrome.permissions.request({origins:[pattern]});if(!granted){toast('未授予域名访问权限');return} - 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()=>{ - 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'); -}); +$('#grantCommunityConsent').addEventListener('click',()=>{void setCommunityConsent(true)}); +$('#useLocalOnly').addEventListener('click',()=>{void setCommunityConsent(false)}); +$('#revokeCommunityConsent').addEventListener('click',()=>{void setCommunityConsent(false)}); $('#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} @@ -318,5 +306,5 @@ $('#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('本地片段已清空')}}); -(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()}); +(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)}const migration={communityApiBase:DEFAULT_COMMUNITY_API};if(!Object.hasOwn(stored,'communityConsentPrompted'))Object.assign(migration,{communityConsentPrompted:false,communityConsentGranted:false,communityEnabled:false});else if(!state.communityConsentGranted&&state.communityEnabled)migration.communityEnabled=false;Object.assign(state,migration);await chrome.storage.local.set(migration);await chrome.storage.local.remove(['communitySkipMode','communityAutoSkipTrusted','skipLabeledAds']);if(state.communityConsentGranted)state.communityClientId=await getContributorId();const version=chrome.runtime.getManifest().version;$('#extensionVersion').textContent=`版本 ${version}`;$('#sidebarVersion').textContent=`社区片段助手 · v${version}`;syncSettings();renderOverview();renderSegments();showPage(location.hash.slice(1)||'overview');if(state.communityConsentGranted&&state.communityEnabled)await fetchMyContributions()})(); +chrome.storage.onChanged.addListener((changes,area)=>{if(area!=='local')return;for(const [key,change] of Object.entries(changes))state[key]=change.newValue;syncSettings();renderOverview()}); diff --git a/popup.css b/popup.css index 27991e3..8da4fc9 100644 --- a/popup.css +++ b/popup.css @@ -1,10 +1,10 @@ :root { color-scheme: dark; } * { box-sizing: border-box; } -body { margin: 0; width: 322px; color: #f5f5f7; background: #111318; font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +body { margin: 0; width: 348px; color: #f5f5f7; background: #111318; font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } main { padding: 18px; } header { display: flex; gap: 11px; align-items: center; margin-bottom: 18px; } .mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 13px; color: #111318; background: linear-gradient(135deg, #25f4ee, #fe2c55); font-size: 20px; font-weight: 900; } -h1 { margin: 0; font-size: 16px; } +h1 { margin: 0; font-size: 15px; line-height: 1.3; } p { margin: 2px 0 0; color: #969ba8; font-size: 12px; } .setting { display: flex; align-items: center; justify-content: space-between; min-height: 47px; padding: 10px 0; border-top: 1px solid #262933; cursor: pointer; } .setting.primary { padding: 13px 12px; border: 1px solid #303440; border-radius: 12px; background: #1a1d24; margin-bottom: 8px; } @@ -23,4 +23,10 @@ input:checked + i::after { transform: translateX(16px); } .quick-actions button { padding:10px 7px; border:1px solid #303440; border-radius:9px; color:#e9eaf0; background:#1a1d24; cursor:pointer; } .quick-actions button:hover { background:#252933; } .quick-actions span { margin-right:4px; color:#25f4ee; } +.consent-notice { margin-top:12px; padding:12px; border:1px solid rgba(37,244,238,.26); border-radius:11px; background:linear-gradient(135deg,rgba(37,244,238,.08),rgba(254,44,85,.05)); } +.consent-notice[hidden] { display:none; } +.consent-notice strong { font-size:13px; } +.consent-notice p { margin:4px 0 9px; line-height:1.55; } +.consent-notice button { width:100%; padding:8px 10px; border:1px solid rgba(37,244,238,.35); border-radius:8px; color:#25f4ee; background:rgba(37,244,238,.08); cursor:pointer; } +.consent-notice button:hover { background:rgba(37,244,238,.15); } .note { margin-top: 12px; text-align: center; } diff --git a/popup.html b/popup.html index 26528f1..fdabc01 100644 --- a/popup.html +++ b/popup.html @@ -3,22 +3,22 @@ - 抖音广告自动跳过 + 抖音网页版社区片段助手
跳 -

抖音广告跳过

整条广告 + 社区片段

+

抖音网页版社区片段助手

仅本地模式

diff --git a/popup.js b/popup.js index b93759e..4be40c6 100644 --- a/popup.js +++ b/popup.js @@ -1,13 +1,23 @@ -const DEFAULTS = { enabled: true, showToast: true, debug: false, skippedCount: 0, localSegments: {} }; +const DEFAULTS = { enabled: true, showToast: true, debug: false, skippedCount: 0, localSegments: {}, communityEnabled: false, communityConsentGranted: false, communityConsentPrompted: false }; const settingKeys = ['enabled', 'showToast', 'debug']; +function renderCommunityState(settings) { + const connected = settings.communityConsentGranted && settings.communityEnabled; + document.getElementById('communitySummary').textContent = connected ? '社区共享已启用' : settings.communityConsentGranted ? '社区查询已暂停' : '仅本地模式'; + document.getElementById('consentNotice').hidden = settings.communityConsentPrompted && settings.communityConsentGranted; +} + chrome.storage.local.get(DEFAULTS, (settings) => { for (const key of settingKeys) document.getElementById(key).checked = Boolean(settings[key]); document.getElementById('count').textContent = Number(settings.skippedCount || 0).toLocaleString('zh-CN'); document.getElementById('segmentCount').textContent = Object.values(settings.localSegments || {}).reduce((sum, segments) => sum + segments.length, 0).toLocaleString('zh-CN'); + renderCommunityState(settings); }); document.getElementById('openOptions').addEventListener('click', () => chrome.runtime.openOptionsPage()); +document.getElementById('reviewConsent').addEventListener('click', () => { + chrome.tabs.create({ url: `${chrome.runtime.getURL('options.html')}#community` }); +}); document.getElementById('openSegments').addEventListener('click', () => { chrome.tabs.create({ url: `${chrome.runtime.getURL('options.html')}#segments` }); }); @@ -25,4 +35,7 @@ chrome.storage.onChanged.addListener((changes, area) => { if (area === 'local' && changes.localSegments) { document.getElementById('segmentCount').textContent = Object.values(changes.localSegments.newValue || {}).reduce((sum, segments) => sum + segments.length, 0).toLocaleString('zh-CN'); } + if (area === 'local' && (changes.communityEnabled || changes.communityConsentGranted || changes.communityConsentPrompted)) { + chrome.storage.local.get(DEFAULTS, renderCommunityState); + } }); diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs new file mode 100644 index 0000000..7d5f860 --- /dev/null +++ b/scripts/extract-release-notes.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; + +const tag = process.argv[2] || ''; +const output = process.argv[3] || ''; +assert.match(tag, /^v\d+\.\d+\.\d+$/, '标签必须为 vX.Y.Z'); +assert.ok(output, '缺少发布说明输出路径'); +const changelog = await readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8'); +const version = tag.slice(1); +const heading = `## ${version}`; +const start = changelog.split('\n').findIndex((line) => line.startsWith(heading)); +assert.notEqual(start, -1, `CHANGELOG 缺少 ${tag} 的发布内容`); +const lines = changelog.split('\n').slice(start + 1); +const end = lines.findIndex((line) => line.startsWith('## ')); +const section = lines.slice(0, end === -1 ? undefined : end).join('\n').trim(); +assert.ok(section, `CHANGELOG 缺少 ${tag} 的发布内容`); +await writeFile(output, section + '\n'); +console.log(`已生成 ${tag} 发布说明`); diff --git a/scripts/test-extension-logic.mjs b/scripts/test-extension-logic.mjs new file mode 100644 index 0000000..d503462 --- /dev/null +++ b/scripts/test-extension-logic.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const content = await readFile(new URL('../content.js', import.meta.url), 'utf8'); +const options = await readFile(new URL('../options.js', import.meta.url), 'utf8'); +const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url), 'utf8')); +const worker = await readFile(new URL('../server/src/index.ts', import.meta.url), 'utf8'); +const idempotencyMigration = await readFile(new URL('../server/migrations/0007_idempotent_submissions.sql', import.meta.url), 'utf8'); + +assert.doesNotMatch(content, /settings\.skipLabeledAds|const AD_LABELS|function checkCurrentVideo/, '不得恢复整条平台广告识别'); +assert.doesNotMatch(options, /chrome\.permissions|state\.skipLabeledAds|skipLabeledAds:/, '选项页不得申请任意 API 域名或恢复旧广告开关'); +assert.equal(manifest.optional_host_permissions, undefined, '公共版不得申请任意 HTTPS 域名权限'); + +assert.match(content, /communityConsentGranted:\s*false/, '社区同意默认必须关闭'); +assert.match(content, /if \(!settings\.communityConsentGranted \|\| !settings\.communityEnabled\) return;/, '远程查询必须受同意状态保护'); +assert.match(content, /actions\.length === 0/, '带操作按钮的必要提示不能被普通提示开关隐藏'); + +const delaysSource = content.match(/const COMMUNITY_RETRY_MS = (\[[^;]+\]);/)?.[1]; +const retryFunction = content.match(/function communityRetryDelay\(failureCount\) \{[\s\S]*?\n \}/)?.[0]; +assert.ok(delaysSource && retryFunction, '缺少社区查询退避函数'); +const retryContext = {}; +vm.runInNewContext(`const COMMUNITY_RETRY_MS=${delaysSource};${retryFunction};globalThis.retry=communityRetryDelay;`, retryContext); +assert.deepEqual([1, 2, 3, 4, 99].map(retryContext.retry), [5000, 15000, 60000, 300000, 300000]); + +assert.match(content, /cannot_vote_own_segment:\s*'这是你提交的片段/, '自己的投稿必须显示明确反馈'); +assert.match(content, /ownedByMe:\s*item\.ownedByMe === true/, '社区查询必须保留投稿归属标识'); +assert.match(content, /failureCount[\s\S]*retryAt/, '查询失败必须使用短期退避而不是成功缓存时长'); +assert.match(worker, /ownedByMe/, '服务端查询必须返回当前匿名投稿归属'); +assert.match(worker, /ON CONFLICT\(submitter_hash, client_request_id\)/, '投稿必须按请求 ID 幂等写入'); +assert.match(idempotencyMigration, /CREATE UNIQUE INDEX[\s\S]*submitter_hash, client_request_id/, 'D1 必须有投稿幂等唯一索引'); + +console.log('扩展行为契约测试通过'); diff --git a/scripts/validate-extension.mjs b/scripts/validate-extension.mjs index 8f3be30..f981fae 100644 --- a/scripts/validate-extension.mjs +++ b/scripts/validate-extension.mjs @@ -3,12 +3,14 @@ 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.equal(manifest.name, '抖音网页版社区片段助手'); 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/*', ]); +assert.equal(manifest.optional_host_permissions, undefined, '公共版不得申请任意 HTTPS 域名权限'); const requiredFiles = new Set([ 'content.js', 'content.css', 'popup.html', 'popup.js', 'popup.css', @@ -25,4 +27,5 @@ assert.match(changelog, new RegExp(`^## ${manifest.version.replaceAll('.', '\\.' const source = await readFile(new URL('../content.js', import.meta.url), 'utf8'); assert(!/\beval\s*\(|new\s+Function\s*\(/.test(source), '内容脚本禁止动态执行代码'); +assert(!/settings\.skipLabeledAds|const AD_LABELS|function checkCurrentVideo/.test(source), '不得恢复整条平台广告识别'); console.log(`扩展校验通过 v${manifest.version}`); diff --git a/scripts/validate-package.mjs b/scripts/validate-package.mjs new file mode 100644 index 0000000..31fa988 --- /dev/null +++ b/scripts/validate-package.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { access, readFile, readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = resolve(process.argv[2] || ''); +assert.ok(process.argv[2], '请提供解压后的扩展目录'); +const rootUrl = pathToFileURL(`${root}/`); +const manifest = JSON.parse(await readFile(new URL('manifest.json', rootUrl), 'utf8')); +const required = new Set([ + manifest.action?.default_popup, + manifest.options_ui?.page, + ...Object.values(manifest.icons || {}), +]); +for (const entry of manifest.content_scripts || []) { + for (const file of [...(entry.js || []), ...(entry.css || [])]) required.add(file); +} +for (const file of required) { + assert.equal(typeof file, 'string'); + await access(new URL(file, rootUrl)); +} +const topLevel = await readdir(root); +for (const forbidden of ['server', '.git', '.github', 'wrangler.jsonc', '.dev.vars']) { + assert.ok(!topLevel.includes(forbidden), `发布包不应包含 ${forbidden}`); +} +console.log(`发布包校验通过 v${manifest.version}`); diff --git a/server/README.md b/server/README.md index a9beed6..b00fbda 100644 --- a/server/README.md +++ b/server/README.md @@ -22,9 +22,10 @@ curl http://localhost:8787/v1/videos/7669344658548047311/segments 1. 登录 Cloudflare:`npx wrangler login`。 2. 设置匿名身份哈希盐:`npx wrangler secret put CLIENT_HASH_SALT`。 -3. 首次部署:`npm run deploy`。Wrangler 会自动配置 `DB` 绑定。 +3. 首次使用先运行 `npx wrangler d1 create douyin_ad_skipper`,把返回的数据库 ID 填入本地 `wrangler.jsonc` 的 `d1_databases[0].database_id`。 4. 应用远程迁移:`npm run db:migrate:remote`。 -5. 将 Worker 的 HTTPS 地址填写到扩展“社区共享”设置页并授权。 +5. 部署 Worker:`npm run deploy`。 +6. 自建分支需要同时替换扩展中的 API 常量和 `manifest.json` 精确域名权限;公共发布版不提供任意服务器地址输入框。 生产环境必须设置 `CLIENT_HASH_SALT`,否则所有写接口返回 `503`。不要提交 `.dev.vars`。 @@ -39,9 +40,9 @@ curl http://localhost:8787/v1/videos/7669344658548047311/segments - `POST /v1/segments/:segmentId/reports` - `POST /v1/segments/:segmentId/skips` -写请求必须携带随机生成的 `X-Client-ID`。服务端使用加盐哈希保存稳定的匿名贡献身份,不保存客户端 ID 原值;来源 IP 会单独加盐哈希,仅用于第二层限流和基础设施安全,不参与贡献身份。`X-Client-ID` 不是登录凭证。 +写请求和需要识别“自己的投稿”的查询必须携带随机生成的 `X-Client-ID`。服务端使用加盐哈希保存稳定的匿名贡献身份,不保存客户端 ID 原值;来源 IP 会单独加盐哈希,仅用于第二层限流和基础设施安全,不参与贡献身份。`X-Client-ID` 不是登录凭证。 -写接口的 JSON 请求体最大为 8 KiB,并同时按匿名身份和来源 IP 限流。D1 中的限流桶会抽样清理超过 48 小时的数据。生产部署前应先导出 D1 备份,再应用迁移。 +写接口的 JSON 请求体最大为 8 KiB,并同时按匿名身份和来源 IP 限流。提交使用 `(submitter_hash, client_request_id)` 唯一约束保证网络重试幂等。D1 中的限流桶会抽样清理超过 48 小时的数据。生产部署前应先导出 D1 备份,再应用迁移。 ## 可信规则(初版) diff --git a/server/migrations/0007_idempotent_submissions.sql b/server/migrations/0007_idempotent_submissions.sql new file mode 100644 index 0000000..c6f3b51 --- /dev/null +++ b/server/migrations/0007_idempotent_submissions.sql @@ -0,0 +1,6 @@ +ALTER TABLE segments ADD COLUMN client_request_id TEXT; + +-- Legacy rows keep NULL, while every new request is unique for its anonymous contributor. +-- SQLite permits multiple NULL values in a UNIQUE index, so this is safe for existing data. +CREATE UNIQUE INDEX IF NOT EXISTS idx_segments_submitter_request +ON segments(submitter_hash, client_request_id); diff --git a/server/src/index.ts b/server/src/index.ts index e1c453f..5496b44 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,4 +1,4 @@ -import { REPORT_REASONS, VIDEO_ID_PATTERN, parseSegmentInput, statusFromVotes } from './validation'; +import { REPORT_REASONS, VIDEO_ID_PATTERN, parseSegmentInput, statusFromVotes } from './validation.ts'; interface Env { DB: D1Database; @@ -10,6 +10,9 @@ type SegmentRow = { status: string; upvotes: number; downvotes: number; created_at: string; }; +type OwnedSegmentRow = SegmentRow & { owned_by_me: number }; +type SubmitterSegmentRow = SegmentRow & { submitter_hash: string }; + const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, X-Client-ID', @@ -100,13 +103,14 @@ async function withinRateLimit(env: Env, identity: string, action: string, limit return Boolean(row && row.count <= limit); } -function segmentJson(row: SegmentRow) { - return { +export function segmentJson(row: SegmentRow, ownedByMe?: boolean) { + const segment = { id: row.id, videoId: row.video_id, start: row.start_ms / 1000, end: row.end_ms / 1000, category: row.category, status: row.status, upvotes: row.upvotes, downvotes: row.downvotes, score: row.upvotes + row.downvotes ? row.upvotes / (row.upvotes + row.downvotes) : 0, createdAt: row.created_at, }; + return ownedByMe === undefined ? segment : { ...segment, ownedByMe }; } async function getSegments(videoId: string, env: Env): Promise { @@ -118,18 +122,21 @@ async function getSegments(videoId: string, env: Env): Promise { 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 `).bind(videoId).all(); - return json({ videoId, segments: result.results.map(segmentJson) }); + return json({ videoId, segments: result.results.map((row) => segmentJson(row)) }); } -async function getSegmentsByHash(videoHash: string, env: Env): Promise { +async function getSegmentsByHash(request: Request, videoHash: string, env: Env): Promise { if (!/^[0-9a-f]{64}$/i.test(videoHash)) return json({ error: 'invalid_video_hash' }, 400); + const identity = await contributorHash(request, env); + const ownershipIdentity = identity || ''; const result = await env.DB.prepare(` - SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at + SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at, + CASE WHEN ? != '' AND submitter_hash = ? THEN 1 ELSE 0 END AS owned_by_me FROM segments WHERE video_hash = ? AND status IN ('candidate', 'trusted') ORDER BY start_ms ASC LIMIT 200 - `).bind(videoHash.toLowerCase()).all(); + `).bind(ownershipIdentity, ownershipIdentity, videoHash.toLowerCase()).all(); const segments = result.results.map((row) => { const { videoId: _videoId, ...segment } = segmentJson(row); - return segment; + return { ...segment, ownedByMe: Boolean(row.owned_by_me) }; }); return json({ segments }); } @@ -153,7 +160,7 @@ async function getMySegments(request: Request, env: Env): Promise { WHERE segments.submitter_hash = ? `).bind(identity).first<{ skip_count: number; helped_people: number; seconds_saved: number }>(); return json({ - segments: result.results.map(segmentJson), + segments: result.results.map((row) => segmentJson(row)), stats: { submittedCount: result.results.length, contributedSeconds: contributedMs / 1000, @@ -195,19 +202,37 @@ async function submitSegment(request: Request, env: Env): Promise { 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 idempotent = await env.DB.prepare(` + SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at + FROM segments WHERE submitter_hash = ? AND client_request_id = ? LIMIT 1 + `).bind(identity, input.clientRequestId).first(); + if (idempotent) return json({ segment: segmentJson(idempotent, true), duplicate: true, idempotent: true }); const existing = await env.DB.prepare(` - SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at FROM segments + SELECT id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at, submitter_hash FROM segments WHERE video_id = ? AND category = ? AND ABS(start_ms - ?) <= 1500 AND ABS(end_ms - ?) <= 1500 AND status != 'rejected' LIMIT 1 - `).bind(input.videoId, input.category, startMs, endMs).first(); - if (existing) return json({ segment: segmentJson(existing), duplicate: true }); + `).bind(input.videoId, input.category, startMs, endMs).first(); + if (existing) return json({ segment: segmentJson(existing, existing.submitter_hash === identity), 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, 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); + const inserted = await env.DB.prepare(` + INSERT INTO segments ( + id, video_id, video_hash, start_ms, end_ms, duration_ms, category, status, + submitter_hash, client_request_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'trusted', ?, ?, ?, ?) + ON CONFLICT(submitter_hash, client_request_id) DO UPDATE SET + client_request_id = excluded.client_request_id + RETURNING id, video_id, start_ms, end_ms, category, status, upvotes, downvotes, created_at + `).bind( + id, input.videoId, videoHash, startMs, endMs, + input.duration == null ? null : Math.round(input.duration * 1000), + input.category, identity, input.clientRequestId, now, now, + ).first(); + if (!inserted) throw new Error('segment_insert_returned_no_row'); + if (inserted.id !== id) { + return json({ segment: segmentJson(inserted, true), duplicate: true, idempotent: true }); + } + return json({ segment: segmentJson(inserted, true), duplicate: false, idempotent: false }, 201); } async function vote(request: Request, env: Env, segmentId: string): Promise { @@ -262,11 +287,11 @@ 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: 2 }); + if (request.method === 'GET' && path === '/health') return json({ ok: true, service: 'douyin-ad-skipper-api', version: 3 }); 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' && videoHashMatch) return getSegmentsByHash(request, 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); diff --git a/server/tests/validation.test.ts b/server/tests/validation.test.ts index 91a64b7..a14cff8 100644 --- a/server/tests/validation.test.ts +++ b/server/tests/validation.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { parseSegmentInput, statusFromVotes } from '../src/validation.ts'; +import { segmentJson } from '../src/index.ts'; test('accepts a valid sponsor segment', () => { const parsed = parseSegmentInput({ videoId:'7669344658548047311', start:10.2, end:25.4, duration:60, category:'sponsor', clientRequestId:'123e4567-e89b-12d3-a456-426614174000' }); @@ -19,6 +20,19 @@ test('rejects invalid ranges and oversized segments', () => { assert.equal(parseSegmentInput({ videoId:'7669344658548047311', start:0, end:601, clientRequestId:'123e4567-e89b-12d3-a456-426614174000' }), null); }); +test('requires a valid idempotency key', () => { + const base={videoId:'7669344658548047311',start:10,end:20,category:'sponsor'}; + assert.equal(parseSegmentInput(base), null); + assert.equal(parseSegmentInput({...base,clientRequestId:'too-short'}), null); +}); + +test('exposes ownership without leaking the submitter hash', () => { + const segment=segmentJson({id:'segment-id',video_id:'7669344658548047311',start_ms:1000,end_ms:2000,category:'sponsor',status:'trusted',upvotes:0,downvotes:0,created_at:'2026-08-15T00:00:00Z'},true); + assert.ok('ownedByMe' in segment); + assert.equal(segment.ownedByMe,true); + assert.equal('submitter_hash' in segment,false); +}); + test('trusts submissions immediately and disputes bad segments', () => { assert.equal(statusFromVotes(0,0), 'trusted'); assert.equal(statusFromVotes(1,0), 'trusted');