From fc007d34e273f901da7a7cd7433ec39630d93ed4 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Wed, 5 Aug 2026 23:45:05 +0800 Subject: [PATCH 01/15] feat: show community contribution impact --- options.css | 5 +++++ options.html | 5 +++++ options.js | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/options.css b/options.css index 3120729..64c0b07 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; } diff --git a/options.html b/options.html index 7c10e1b..c899067 100644 --- a/options.html +++ b/options.html @@ -31,6 +31,11 @@
累计跳过0次自动跳过
标记总时长0:00预计可节省时间
+
+
↗
+
社区贡献你贡献的片段可帮大家节省 0 秒已提交 0 个片段
+ +

最近创建

最近保存到本地的广告片段

diff --git a/options.js b/options.js index 282b711..b38c0cd 100644 --- a/options.js +++ b/options.js @@ -5,6 +5,12 @@ let state = { ...DEFAULTS }; 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 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 allSegments = () => Object.entries(state.localSegments || {}).flatMap(([videoId,segments]) => segments.map((segment,index) => ({...segment,videoId,index}))); function showPage(name) { @@ -26,10 +32,14 @@ function segmentStatus(segment) { return isPending(segment) ? ['pending','待提 function renderOverview() { const segments = allSegments(); const duration = segments.reduce((sum,item) => sum + Math.max(0,Number(item.end)-Number(item.start)),0); + const submittedSegments=segments.filter((item)=>!isPending(item)); + const contributedDuration=submittedSegments.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; $('#metricSkips').textContent = Number(state.skippedCount || 0).toLocaleString('zh-CN'); $('#metricDuration').textContent = formatTime(duration); + $('#metricContributionDuration').textContent = formatContribution(contributedDuration); + $('#metricContributionCount').textContent = `已提交 ${submittedSegments.length} 个片段`; $('#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('') : '
还没有创建片段。请在抖音播放器控制栏点击标记图标。
'; From e2fa90cb5ed62b2c8f4bb8af35092a94417c230d Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Thu, 6 Aug 2026 00:01:36 +0800 Subject: [PATCH 02/15] feat: make community storage authoritative --- PRIVACY.md | 8 ++- README.md | 12 ++-- content.js | 34 +++++++--- docs/API.md | 11 ++- docs/DATA_MODEL.md | 2 +- options.js | 75 +++++++++++++++------ server/migrations/0003_submitter_lookup.sql | 2 + server/src/index.ts | 62 ++++++++++++++--- 8 files changed, 156 insertions(+), 50 deletions(-) create mode 100644 server/migrations/0003_submitter_lookup.sql diff --git a/PRIVACY.md b/PRIVACY.md index ddc5c50..ffdc500 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -8,9 +8,9 @@ - 功能开关; - 累计跳过次数; -- 用户手动创建的作品 ID、片段起止时间、标题、作者、作品链接和创建时间。 +- 尚未提交的本地草稿(作品 ID、片段起止时间、标题、作者、作品链接和创建时间)。 -以上数据保存在 Chrome 扩展本地存储中。社区共享功能默认启用并连接项目公共 API,可随时在选项页关闭。关闭时: +功能开关、累计跳过次数和未提交草稿保存在 Chrome 扩展本地存储中。匿名贡献者 ID 保存在 Chrome 同步存储中,以便同一 Chrome 账号在其他电脑识别自己的社区贡献。已提交片段存放在社区服务器,不再重复保存在本地。社区共享功能可随时在选项页关闭。关闭时: - 不向项目维护者或第三方服务器发送数据; - 不收集 Cookie、登录凭据或账号私信; @@ -26,7 +26,9 @@ - 浏览视频时会用作品 ID 查询该作品的可用片段,不上传完整观看历史; - 只有用户在播放器提交菜单中明确点击“提交到社区”时,才上传作品 ID、片段起止时间、视频时长、分类和随机请求 ID; - 公共数据不包含抖音用户名或账号标识; -- 请求使用扩展随机生成的匿名客户端标识;服务端示例会将其与服务端盐、来源 IP 一起散列后用于限流,不保存原始客户端标识; +- 请求使用扩展随机生成、由 Chrome 同步的匿名贡献者 ID;服务端只保存加盐哈希,不接收 Chrome、QQ 或抖音账号信息; +- 来源 IP 仅参与限流和基础设施安全日志,不再作为贡献者身份的一部分; +- 选项页会通过匿名贡献者 ID 拉取该用户已提交的片段和累计贡献时长; - 提供完全关闭远程查询与提交的选项; - 实际服务器运营者仍需自行公布基础设施日志的保留期限和删除方式。 diff --git a/README.md b/README.md index 155bde1..4fb784b 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ - 识别“广告 / 商业推广 / 广告推广 / 推广”等明确页面标识并切换到下一条。 - 在抖音播放器控制栏内创建广告片段起止时间。 - 在播放器进度条中显示已标记片段:待提交为深绿色,已提交/可信片段为亮绿色。 -- 再次播放同一作品时自动跳过本地标记片段。 +- 再次播放同一作品时自动跳过本地草稿或社区片段。 - 可从播放器内的提交菜单把待提交片段发送到自建社区服务。 -- 在选项页查看、搜索、打开和删除本地片段。 +- 在选项页查看本地草稿、云端片段和累计贡献时长。 - 导入、导出本地数据。 -- 完全本地运行,不上传浏览记录。 +- 未提交草稿留在本地;已提交片段以社区服务器为准,不上传完整浏览记录。 ## 安装本地测试版 @@ -31,14 +31,16 @@ 1. 广告开始时点击“片段从当前开始”。 2. 图标切换为红色取消按钮和青色结束按钮。 -3. 广告结束时点击“片段现在结束”,片段立即保存在本地,并以深绿色显示在进度条上。 +3. 广告结束时点击“片段现在结束”,片段先作为本地草稿保存,并以深绿色显示在进度条上。 4. 此时控制栏会出现“提交”和“删除最后一个未提交片段”按钮;点击提交会打开播放器内菜单。 -5. 提交成功后片段改为亮绿色;点击扩展图标 →“片段管理”也可查看记录。 +5. 提交成功后本地草稿会删除,亮绿色片段和管理列表从社区服务读取。 ## 社区共享 扩展默认连接项目公共社区 API,普通用户安装后即可查询和提交。新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错: +扩展会把随机匿名贡献者 ID 保存到 Chrome 同步存储;使用同一 Chrome 账号安装扩展后,可在另一台电脑看到自己的云端片段与贡献统计。服务器只保存该 ID 的加盐哈希。 + ```text 用户提交片段 → API 校验 → 立即共享并自动跳过 ↓ diff --git a/content.js b/content.js index fbb48f8..c90c2c6 100644 --- a/content.js +++ b/content.js @@ -406,6 +406,7 @@ button.disabled = true; button.textContent = '提交中…'; const segments = [...currentSegments(video)]; + const submittedSegments = new Set(); let submitted = 0; for (const segment of segments) { if ((segment.submissionStatus || 'pending') !== 'pending') continue; @@ -416,17 +417,20 @@ body: JSON.stringify({ videoId, start: segment.start, end: segment.end, duration: video.duration, 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 response.json(); + 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; await chrome.storage.local.set({ localSegments }); + if (submitted) communityCache.delete(videoId); closeSubmissionMenu(); renderPlayerControls(); renderPreviewBar(); @@ -542,21 +546,29 @@ 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(DEFAULTS); settings = { ...DEFAULTS, ...stored }; if (/^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; diff --git a/docs/API.md b/docs/API.md index 3a512be..a3ed252 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # 社区共享 API 草案 -本文档用于讨论,不代表已部署服务。 +本文档描述当前公共服务接口。 ## 查询片段 @@ -46,6 +46,15 @@ Content-Type: application/json 服务端必须校验数值范围、片段长度、视频时长、重复请求和提交速率。 +## 我的社区片段 + +```http +GET /v1/me/segments +X-Client-ID: 匿名贡献者 UUID +``` + +返回该匿名贡献者已提交的片段,以及 `submittedCount` 和 `contributedSeconds`。服务端只使用 `X-Client-ID` 的加盐哈希查询,不保存原始值。 + ## 投票 ```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/options.js b/options.js index b38c0cd..67e1933 100644 --- a/options.js +++ b/options.js @@ -1,6 +1,8 @@ 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:'' }; let state = { ...DEFAULTS }; +let communitySegments = []; +let communityStats = { submittedCount:0, contributedSeconds:0 }; const $ = (selector) => document.querySelector(selector); const escapeHtml = (value='') => String(value).replace(/[&<>'"]/g, (char) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); @@ -11,12 +13,13 @@ const formatContribution = (seconds=0) => { const hours=Math.floor(rounded/3600),minutes=Math.round((rounded%3600)/60); return hours ? `${hours} 小时${minutes?` ${minutes} 分钟`:''}` : `${minutes} 分钟`; }; -const allSegments = () => Object.entries(state.localSegments || {}).flatMap(([videoId,segments]) => segments.map((segment,index) => ({...segment,videoId,index}))); +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) { @@ -26,20 +29,18 @@ 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 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); - const submittedSegments=segments.filter((item)=>!isPending(item)); - const contributedDuration=submittedSegments.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(contributedDuration); - $('#metricContributionCount').textContent = `已提交 ${submittedSegments.length} 个片段`; + $('#metricContributionDuration').textContent = formatContribution(communityStats.contributedSeconds); + $('#metricContributionCount').textContent = `已提交 ${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('') : '
还没有创建片段。请在抖音播放器控制栏点击标记图标。
'; @@ -47,20 +48,55 @@ 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; $('#segmentSummary').textContent = `${groups.length} 个视频 · ${count} 个片段`; $('#uploadAllSegments').disabled = pendingCount === 0; $('#uploadAllSegments').textContent = pendingCount ? `上传全部待提交(${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 `
${escapeHtml(info.title||`抖音作品 ${videoId}`)}${escapeHtml(info.author||'未记录作者')} · ID ${videoId}
${segments.length} 段
${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 `
${escapeHtml(info.title||`抖音作品 ${videoId}`)}${escapeHtml(info.author||'未记录作者')} · ID ${videoId}
${segments.length} 段
${segments.map((item)=>{const [statusClass,statusText]=segmentStatus(item);return `
${formatTime(item.start)} → ${formatTime(item.end)}
广告片段 ${statusText}${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`,{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)}; + 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) { @@ -98,10 +134,11 @@ async function uploadSegment(videoId,index) { body:JSON.stringify({videoId,start:Number(segment.start),end:Number(segment.end),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); @@ -112,7 +149,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(isPending).sort((a,b)=>a.videoId===b.videoId?b.index-a.index:0); if(!pending.length)return; button.disabled=true; let success=0; @@ -184,5 +221,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('本地片段已清空')}}); -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(DEFAULTS);state={...DEFAULTS,...stored};if(/^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();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/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/src/index.ts b/server/src/index.ts index 4d54ab8..4ab516a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,12 +31,32 @@ 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 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 { @@ -67,11 +87,30 @@ 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 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); + return json({ + segments: result.results.map(segmentJson), + stats: { submittedCount: result.results.length, contributedSeconds: contributedMs / 1000 }, + }); +} + 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 rateIdentity = await rateIdentityHash(request, env); + if (!rateIdentity || !await withinRateLimit(env, rateIdentity, 'submit', 20)) return json({ error: 'rate_limited' }, 429); + await migrateLegacyIdentity(request, env, identity); const input = parseSegmentInput(await bodyJson(request)); if (!input) return json({ error: 'invalid_segment' }, 400); const startMs = Math.round(input.start * 1000), endMs = Math.round(input.end * 1000); @@ -90,10 +129,11 @@ async function submitSegment(request: Request, env: Env): Promise { } 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 rateIdentity = await rateIdentityHash(request, env); + if (!rateIdentity || !await withinRateLimit(env, rateIdentity, 'vote', 60)) return json({ error: 'rate_limited' }, 429); const body = await bodyJson(request) as { vote?: unknown } | null; const value = Number(body?.vote); if (value !== 1 && value !== -1) return json({ error: 'invalid_vote' }, 400); @@ -115,10 +155,11 @@ 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 rateIdentity = await rateIdentityHash(request, env); + if (!rateIdentity || !await withinRateLimit(env, rateIdentity, 'report', 10)) return json({ error: 'rate_limited' }, 429); const body = await bodyJson(request) as { reason?: unknown } | null; const reason = String(body?.reason || ''); if (!REPORT_REASONS.has(reason)) return json({ error: 'invalid_reason' }, 400); @@ -140,6 +181,7 @@ export default { if (request.method === 'GET' && path === '/health') return json({ ok: true, service: 'douyin-ad-skipper-api', version: 1 }); const videoMatch = path.match(/^\/v1\/videos\/(\d+)\/segments$/); if (request.method === 'GET' && videoMatch) return getSegments(videoMatch[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]); From 9b8958b4823b67a7b120f7766f65ab30820f7472 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Thu, 6 Aug 2026 00:13:50 +0800 Subject: [PATCH 03/15] fix: streamline default community connection --- content.js | 2 +- manifest.json | 4 ++-- options.css | 2 ++ options.html | 18 +++++++++--------- options.js | 24 +++++++++++++++++------- popup.html | 8 ++++---- 6 files changed, 35 insertions(+), 23 deletions(-) diff --git a/content.js b/content.js index c90c2c6..158762b 100644 --- a/content.js +++ b/content.js @@ -559,7 +559,7 @@ (async () => { const stored = await chrome.storage.local.get(DEFAULTS); settings = { ...DEFAULTS, ...stored }; - if (/^https:\/\/douyin-ad-skipper-api\.\d+\.workers\.dev\/?$/.test(settings.communityApiBase)) { + 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 }); diff --git a/manifest.json b/manifest.json index 81c43c0..897b304 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "抖音广告自动跳过(本地版)", + "name": "抖音广告跳过", "version": "0.5.0", - "description": "跳过抖音网页版广告,并在播放器控制栏中手动标记广告片段。", + "description": "跳过抖音网页版广告,并与社区共享手动标记的广告片段。", "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", diff --git a/options.css b/options.css index 64c0b07..481250a 100644 --- a/options.css +++ b/options.css @@ -83,6 +83,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); } @@ -90,6 +91,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 c899067..b112c3a 100644 --- a/options.html +++ b/options.html @@ -9,7 +9,7 @@
-

DASHBOARD

本地跳过概览

查看标记片段、累计跳过和当前运行状态。

本地模式
+

DASHBOARD

跳过概览

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

社区模式
已标记片段0个时间片段
涉及视频0个抖音作品
@@ -37,13 +37,13 @@
-

最近创建

最近保存到本地的广告片段

+

最近片段

本地草稿与已提交的社区片段

-

LOCAL LIBRARY

片段管理

查看、上传或删除你手动创建的片段。

+

SEGMENT LIBRARY

片段管理

查看云端贡献,或上传、删除尚未提交的本地草稿。

@@ -53,7 +53,7 @@
- +
@@ -70,13 +70,13 @@

COMMUNITY BETA

社区共享

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

未连接
-
社区 API 地址

必须是 HTTPS 地址。连接时 Chrome 会询问该域名的访问权限。

+
社区 API 地址

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

-
+
隐私设计

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

@@ -90,7 +90,7 @@
-

ABOUT

关于抖音广告跳过

一个完全本地运行的 Chrome 实验扩展。

+

ABOUT

关于抖音广告跳过

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

抖音广告跳过

版本 0.5.0

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

diff --git a/options.js b/options.js index 67e1933..95f6d01 100644 --- a/options.js +++ b/options.js @@ -186,11 +186,18 @@ async function communityOriginPattern() { 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))); @@ -206,9 +213,12 @@ $('#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)=>{ @@ -221,5 +231,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(DEFAULTS);state={...DEFAULTS,...stored};if(/^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();syncSettings();renderOverview();renderSegments();showPage(location.hash.slice(1)||'overview');await fetchMyContributions()})(); +(async()=>{const stored=await chrome.storage.local.get(DEFAULTS);state={...DEFAULTS,...stored};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();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 @@
跳 -

抖音广告跳过

整条广告 + 本地片段标记

+

抖音广告跳过

整条广告 + 社区片段

From 6c9225371ddac83917bc88f381a037e7d019fc21 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 13:21:11 +0800 Subject: [PATCH 04/15] feat: add preview and skip feedback workflow --- CHANGELOG.md | 6 ++++ README.md | 5 ++- content.css | 10 +++++- content.js | 98 +++++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f64e27..5b36757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 更新日志 +## 未发布 + +- 提交社区前必须逐段完成播放器预览。 +- 跳过片段后可撤销;社区片段可直接赞成或反对。 +- 撤销后短时间暂停该片段自动跳过,避免立即重复触发。 + ## 0.4.0 - 2026-08-05 - 修复切换视频后播放器标记按钮延迟或缺失的问题。 diff --git a/README.md b/README.md index 4fb784b..1e4468d 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ 2. 图标切换为红色取消按钮和青色结束按钮。 3. 广告结束时点击“片段现在结束”,片段先作为本地草稿保存,并以深绿色显示在进度条上。 4. 此时控制栏会出现“提交”和“删除最后一个未提交片段”按钮;点击提交会打开播放器内菜单。 -5. 提交成功后本地草稿会删除,亮绿色片段和管理列表从社区服务读取。 +5. 在提交菜单逐段点击“预览”;播放器实际越过片段末尾后才允许提交。 +6. 提交成功后本地草稿会删除,亮绿色片段和管理列表从社区服务读取。 + +自动跳过时间片段后,提示条会短暂提供“撤销”按钮;社区片段还可直接赞成或反馈“有问题”。 ## 社区共享 diff --git a/content.css b/content.css index 7ab1b0b..ff20b4f 100644 --- a/content.css +++ b/content.css @@ -122,9 +122,17 @@ .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 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 158762b..2891a5f 100644 --- a/content.js +++ b/content.js @@ -26,6 +26,7 @@ let draftVideoId = null; let lastSegmentSkipKey = ''; let mountScheduled = false; + const skipSuppressedUntil = new Map(); const communityCache = new Map(); const COMMUNITY_CACHE_MS = 10 * 60 * 1000; @@ -211,7 +212,7 @@ return null; } - function showToast(message) { + function showToast(message, actions = []) { if (!settings.showToast) return; let toast = document.getElementById('das-toast'); if (!toast) { @@ -219,15 +220,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() }); } @@ -381,20 +401,47 @@ 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) => `
  1. ${formatTime(item.start)} – ${formatTime(item.end)}${(item.end - item.start).toFixed(1)} 秒
  2. `).join('')}
-
`; +

提交前请逐段预览,确认开始和结束时间准确。

+
    ${pending.map((item,index) => `
  1. ${formatTime(item.start)} – ${formatTime(item.end)}${item.previewed?'✓ 已预览':'尚未预览'}
  2. `).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); }); 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,6 +450,11 @@ 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)]; @@ -502,14 +554,42 @@ 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; lastSegmentSkipKey = key; video.currentTime = Math.min(segment.end, video.duration || segment.end); await recordSkip(); - showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}`); + const undo = async () => { + skipSuppressedUntil.set(key, Date.now() + 12000); + video.currentTime = Math.max(0, segment.start); + await recordSkip(-1); + showToast('已撤销跳过,12 秒内不会再次跳过此片段'); + }; + const actions = [{ label:'撤销', run:undo }]; + if (segment.source === 'community' && segment.id) { + actions.push({ label:'赞成', run:()=>voteOnSegment(segment,1) }, { label:'有问题', run:()=>voteOnSegment(segment,-1) }); + } + showToast(`已跳过片段 ${formatTime(segment.start)}–${formatTime(segment.end)}`, actions); log(segment.source === 'community' ? '跳过社区可信片段' : '跳过本地标记片段', videoId, segment); } + 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 moveToNextVideo() { const button = findNextButton(); if (button) { From 7239ab308410d947de43eed969d3953fa6949950 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 14:04:29 +0800 Subject: [PATCH 05/15] feat: track real community impact --- CHANGELOG.md | 3 ++ PRIVACY.md | 1 + content.js | 54 ++++++++++++++++++++--- docs/API.md | 15 +++++++ options.html | 2 +- options.js | 10 ++--- server/migrations/0004_segment_impact.sql | 11 +++++ server/src/index.ts | 38 +++++++++++++++- 8 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 server/migrations/0004_segment_impact.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b36757..2227044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ - 提交社区前必须逐段完成播放器预览。 - 跳过片段后可撤销;社区片段可直接赞成或反对。 - 撤销后短时间暂停该片段自动跳过,避免立即重复触发。 +- 社区服务器记录去重后的有效跳过,展示实际帮助人数、跳过次数和节省时间。 +- 自动合并重叠或几乎相邻的本地草稿,并要求重新预览合并结果。 +- 社区片段仅保留最近 100 个作品的页面内存缓存,不写入本地磁盘。 ## 0.4.0 - 2026-08-05 diff --git a/PRIVACY.md b/PRIVACY.md index ffdc500..9231844 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -29,6 +29,7 @@ - 请求使用扩展随机生成、由 Chrome 同步的匿名贡献者 ID;服务端只保存加盐哈希,不接收 Chrome、QQ 或抖音账号信息; - 来源 IP 仅参与限流和基础设施安全日志,不再作为贡献者身份的一部分; - 选项页会通过匿名贡献者 ID 拉取该用户已提交的片段和累计贡献时长; +- 社区片段成功跳过且未立即撤销时,会上传片段 ID;服务器按匿名贡献者哈希、片段和日期去重,用于统计实际帮助人数、跳过次数和节省时间; - 提供完全关闭远程查询与提交的选项; - 实际服务器运营者仍需自行公布基础设施日志的保留期限和删除方式。 diff --git a/content.js b/content.js index 2891a5f..ffd6bc9 100644 --- a/content.js +++ b/content.js @@ -117,6 +117,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); @@ -139,7 +166,7 @@ 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 { @@ -152,11 +179,11 @@ 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 }); + 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); @@ -344,8 +371,7 @@ } 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, createdAt: Date.now(), submissionStatus: 'pending', previewed:false, ...metadata }]); const localSegments = { ...(settings.localSegments || {}), [videoId]: segments }; settings.localSegments = localSegments; await chrome.storage.local.set({ localSegments }); @@ -559,7 +585,11 @@ lastSegmentSkipKey = key; video.currentTime = Math.min(segment.end, video.duration || segment.end); await recordSkip(); + 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); @@ -573,6 +603,20 @@ 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; diff --git a/docs/API.md b/docs/API.md index a3ed252..4f27d00 100644 --- a/docs/API.md +++ b/docs/API.md @@ -55,6 +55,21 @@ 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/options.html b/options.html index b112c3a..00d99d1 100644 --- a/options.html +++ b/options.html @@ -33,7 +33,7 @@
↗
-
社区贡献你贡献的片段可帮大家节省 0 秒已提交 0 个片段
+
社区贡献你的片段已实际帮大家节省 0 秒帮助 0 人跳过 0 次 · 已提交 0 个片段
diff --git a/options.js b/options.js index 95f6d01..1c4fdbb 100644 --- a/options.js +++ b/options.js @@ -2,7 +2,7 @@ const DEFAULT_COMMUNITY_API='https://douyin-ad-skipper-api.douyin-skip-community const DEFAULTS = { enabled:true, skipLabeledAds:true, skipLocalSegments:true, showToast:true, debug:false, skippedCount:0, localSegments:{}, communityEnabled:true, communityApiBase:DEFAULT_COMMUNITY_API, communityAutoSkipTrusted:true, communityClientId:'' }; let state = { ...DEFAULTS }; let communitySegments = []; -let communityStats = { submittedCount:0, contributedSeconds:0 }; +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])); @@ -39,8 +39,8 @@ function renderOverview() { $('#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.contributedSeconds); - $('#metricContributionCount').textContent = `已提交 ${communityStats.submittedCount} 个片段`; + $('#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('') : '
还没有创建片段。请在抖音播放器控制栏点击标记图标。
'; @@ -79,11 +79,11 @@ async function getContributorId() { async function fetchMyContributions() { if(!state.communityEnabled||!state.communityApiBase||!state.communityClientId)return; try{ - const response=await fetch(`${new URL(state.communityApiBase).origin}/v1/me/segments`,{headers:{Accept:'application/json','X-Client-ID':state.communityClientId}}); + 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)}; + 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={}; 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/src/index.ts b/server/src/index.ts index 4ab516a..f19fb05 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -98,12 +98,46 @@ async function getMySegments(request: Request, env: Env): Promise { 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 }, + 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); + const rateIdentity = await rateIdentityHash(request, env); + if (!rateIdentity || !await withinRateLimit(env, rateIdentity, 'skip', 300)) 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 contributorHash(request, env); if (identity === null) return json({ error: 'writes_not_configured' }, 503); @@ -185,6 +219,8 @@ export default { 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); From 564f549e6d3370a025a7e3710b6a854eeaef0f46 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:27:04 +0800 Subject: [PATCH 06/15] feat: add segment editing and reporting --- CHANGELOG.md | 2 ++ README.md | 4 +++- content.js | 31 ++++++++++++++++++++++++++++++- options.css | 2 ++ options.html | 2 +- options.js | 31 +++++++++++++++++++++++++++---- 6 files changed, 65 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2227044..af3e74d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - 社区服务器记录去重后的有效跳过,展示实际帮助人数、跳过次数和节省时间。 - 自动合并重叠或几乎相邻的本地草稿,并要求重新预览合并结果。 - 社区片段仅保留最近 100 个作品的页面内存缓存,不写入本地磁盘。 +- 片段管理支持修改本地草稿时间,编辑后必须重新预览。 +- 跳过社区片段后可分别赞成、反对或按原因举报。 ## 0.4.0 - 2026-08-05 diff --git a/README.md b/README.md index 1e4468d..489453a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ 5. 在提交菜单逐段点击“预览”;播放器实际越过片段末尾后才允许提交。 6. 提交成功后本地草稿会删除,亮绿色片段和管理列表从社区服务读取。 -自动跳过时间片段后,提示条会短暂提供“撤销”按钮;社区片段还可直接赞成或反馈“有问题”。 +自动跳过时间片段后,提示条会短暂提供“撤销”按钮;社区片段还可直接赞成、反对或举报。 + +片段管理页可以修改本地草稿的开始和结束时间。修改会清除预览状态,必须返回对应视频重新预览;社区片段跳过后可赞成、反对或按“时间错误、不是广告、视频不符、滥用”举报。 ## 社区共享 diff --git a/content.js b/content.js index ffd6bc9..987ef17 100644 --- a/content.js +++ b/content.js @@ -597,7 +597,11 @@ }; const actions = [{ label:'撤销', run:undo }]; if (segment.source === 'community' && segment.id) { - actions.push({ label:'赞成', run:()=>voteOnSegment(segment,1) }, { label:'有问题', run:()=>voteOnSegment(segment,-1) }); + 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); @@ -634,6 +638,31 @@ } } + 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) { diff --git a/options.css b/options.css index 481250a..4a2b9f8 100644 --- a/options.css +++ b/options.css @@ -62,6 +62,8 @@ h2 { margin:0; font-size:17px; } .segment-row { display:grid; grid-template-columns:120px 1fr auto; align-items:center; gap:18px; padding:14px 19px; border-top:1px solid var(--line); } .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; } diff --git a/options.html b/options.html index 00d99d1..1e5a55e 100644 --- a/options.html +++ b/options.html @@ -43,7 +43,7 @@
-

SEGMENT LIBRARY

片段管理

查看云端贡献,或上传、删除尚未提交的本地草稿。

+

SEGMENT LIBRARY

片段管理

查看云端贡献,或编辑、预览、上传和删除本地草稿。

diff --git a/options.js b/options.js index 1c4fdbb..6db9848 100644 --- a/options.js +++ b/options.js @@ -30,6 +30,7 @@ function toast(message) { function segmentTitle(segment) { return segment.title || `抖音作品 ${segment.videoId}`; } function segmentAuthor(segment) { return segment.author || '未记录作者'; } 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() { @@ -56,13 +57,14 @@ function renderSegments() { }); const count = groups.reduce((sum,group)=>sum+group.segments.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 `
${escapeHtml(info.title||`抖音作品 ${videoId}`)}${escapeHtml(info.author||'未记录作者')} · ID ${videoId}
${segments.length} 段
${segments.map((item)=>{const [statusClass,statusText]=segmentStatus(item);return `
${formatTime(item.start)} → ${formatTime(item.end)}
广告片段 ${statusText}${item.createdAt?new Date(item.createdAt).toLocaleString('zh-CN'):'从旧版本保存'}
${item.storageSource==='community'?'':``}
`}).join('')}
`; + return `
${escapeHtml(info.title||`抖音作品 ${videoId}`)}${escapeHtml(info.author||'未记录作者')} · ID ${videoId}
${segments.length} 段
${segments.map((item)=>{const [statusClass,statusText]=segmentStatus(item);return `
${formatTime(item.start)} → ${formatTime(item.end)}
广告片段 ${statusText}${item.storageSource==='community'?'已保存在社区':item.previewed?'✓ 已预览':'修改或创建后需预览'} · ${item.createdAt?new Date(item.createdAt).toLocaleString('zh-CN'):'旧版片段'}
${item.storageSource==='community'?'':``}
`}).join('')}
`; }).join('') : '
没有找到片段。
'; } @@ -105,6 +107,25 @@ 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 ensureCommunityReady() { if (!state.communityEnabled || !state.communityApiBase) { toast('请先在“社区共享”中授权并连接 API'); @@ -127,6 +148,7 @@ 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', @@ -149,7 +171,7 @@ async function uploadSegment(videoId,index) { async function uploadAllPending() { if (!await ensureCommunityReady()) return; const button=$('#uploadAllSegments'); - const pending=localSegmentItems().filter(isPending).sort((a,b)=>a.videoId===b.videoId?b.index-a.index:0); + 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; @@ -222,6 +244,7 @@ $('#disconnectCommunity').addEventListener('click',async()=>{ }); $('#segmentSearch').addEventListener('input',renderSegments); $('#segmentList').addEventListener('click',async(event)=>{ + 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?'片段已上传社区':'上传失败,片段仍保留在本地')} }); From aca9815489d2153a16557b49997390b1aa30654f Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:30:13 +0800 Subject: [PATCH 07/15] fix: keep submitted segments visible immediately --- CHANGELOG.md | 1 + content.js | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af3e74d..3843295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 未发布 +- 提交成功后立即把服务器确认片段写入当前页面缓存,进度条颜色无需刷新即可连续显示。 - 提交社区前必须逐段完成播放器预览。 - 跳过片段后可撤销;社区片段可直接赞成或反对。 - 撤销后短时间暂停该片段自动跳过,避免立即重复触发。 diff --git a/content.js b/content.js index 987ef17..8086862 100644 --- a/content.js +++ b/content.js @@ -485,6 +485,7 @@ 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; @@ -495,7 +496,17 @@ body: JSON.stringify({ videoId, start: segment.start, end: segment.end, duration: video.duration, category: 'sponsor', clientRequestId: crypto.randomUUID() }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - await response.json(); + const payload = await response.json(); + 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', + }); + } submittedSegments.add(segment); submitted += 1; } catch (error) { @@ -507,8 +518,12 @@ 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 }); - if (submitted) communityCache.delete(videoId); closeSubmissionMenu(); renderPlayerControls(); renderPreviewBar(); From 56d4dfbfab31147515a87fea455ca1de75c0d073 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:34:05 +0800 Subject: [PATCH 08/15] feat: add community modes and shortcuts --- CHANGELOG.md | 2 ++ README.md | 2 ++ content.js | 50 ++++++++++++++++++++++++++++++++++++++++++++------ options.css | 5 +++++ options.html | 13 ++++++++++++- options.js | 12 +++++++----- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3843295..30cf5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - 社区片段仅保留最近 100 个作品的页面内存缓存,不写入本地磁盘。 - 片段管理支持修改本地草稿时间,编辑后必须重新预览。 - 跳过社区片段后可分别赞成、反对或按原因举报。 +- 社区片段支持自动跳过、手动提示和不处理三种行为模式。 +- 新增播放器快捷键总开关及开始/结束、取消、提交快捷操作。 ## 0.4.0 - 2026-08-05 diff --git a/README.md b/README.md index 489453a..9071d23 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ 片段管理页可以修改本地草稿的开始和结束时间。修改会清除预览状态,必须返回对应视频重新预览;社区片段跳过后可赞成、反对或按“时间错误、不是广告、视频不符、滥用”举报。 +社区片段支持“自动跳过、显示提示手动跳过、不处理”三种模式。播放器快捷键默认为:`Alt+Z` 开始/结束片段、`Alt+X` 取消创建、`Alt+Enter` 打开提交菜单;在输入框中不会触发。 + ## 社区共享 扩展默认连接项目公共社区 API,普通用户安装后即可查询和提交。新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错: diff --git a/content.js b/content.js index 8086862..9152897 100644 --- a/content.js +++ b/content.js @@ -9,9 +9,11 @@ communityEnabled: true, communityApiBase: DEFAULT_COMMUNITY_API, communityAutoSkipTrusted: true, + communitySkipMode: 'auto', communityClientId: '', showToast: true, debug: false, + shortcutsEnabled: true, skippedCount: 0, localSegments: {}, }; @@ -26,6 +28,7 @@ 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; @@ -145,6 +148,7 @@ } function communitySegments(video) { + if (settings.communitySkipMode === 'disabled') return []; const id = extractVideoId(video); const cached = id && communityCache.get(id); return cached?.segments || []; @@ -160,7 +164,7 @@ } async function loadCommunitySegments(video) { - if (!settings.communityEnabled) return; + if (!settings.communityEnabled || settings.communitySkipMode === 'disabled') return; const videoId = extractVideoId(video); const apiBase = normalizedApiBase(); if (!videoId || !apiBase) return; @@ -341,6 +345,20 @@ }); } + function handleShortcut(event) { + if (!settings.shortcutsEnabled || !event.altKey || event.ctrlKey || event.metaKey || event.repeat) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable) return; + let action=''; + if(event.code==='KeyZ')action=draftStart===null?'start':'end'; + if(event.code==='KeyX'&&draftStart!==null)action='cancel'; + if(event.code==='Enter'&&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(); + } + async function handlePlayerControl(event) { event.preventDefault(); event.stopPropagation(); @@ -581,22 +599,40 @@ } 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 = settings.communitySkipMode === 'disabled' ? [] : communitySegments(video).filter((segment) => segment.status === 'trusted'); + if (settings.communitySkipMode === 'manual') { + const manualSegment = trustedCommunity.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(`发现广告片段 ${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, ...(settings.communitySkipMode === 'auto' ? trustedCommunity : [])]; 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(); @@ -727,6 +763,7 @@ (async () => { const stored = await chrome.storage.local.get(DEFAULTS); settings = { ...DEFAULTS, ...stored }; + if (!stored.communitySkipMode) settings.communitySkipMode = stored.communityAutoSkipTrusted === false ? 'manual' : 'auto'; if (!settings.communityApiBase || /^https:\/\/douyin-ad-skipper-api\.\d+\.workers\.dev\/?$/.test(settings.communityApiBase)) { settings.communityApiBase = DEFAULT_COMMUNITY_API; settings.communityEnabled = true; @@ -757,6 +794,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/options.css b/options.css index 4a2b9f8..585c148 100644 --- a/options.css +++ b/options.css @@ -76,6 +76,11 @@ 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 auto auto; align-items:center; gap:8px; min-height:74px; padding:14px 21px; border-top:1px solid var(--line); } +.shortcut-row p { margin:3px 0 0; color:var(--muted); } +.shortcut-row kbd { min-width:30px; padding:5px 8px; border:1px solid #4a505c; border-bottom-width:3px; border-radius:6px; color:#fff; background:#292d36; text-align:center; font:600 12px/1.2 inherit; } +.shortcut-row>span { color:var(--muted); } .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; } diff --git a/options.html b/options.html index 1e5a55e..4187afa 100644 --- a/options.html +++ b/options.html @@ -16,6 +16,7 @@ + @@ -66,6 +67,16 @@
提示

播放器标记按钮会嵌入清晰度按钮左侧。点击一次开始创建,再点击青色结束按钮即可保存;红色按钮用于取消。

+
+

KEYBOARD

快捷键

无需移动鼠标即可创建、取消或提交片段。

+
+ +
开始 / 结束片段

第一次开始,第二次结束

Alt+Z
+
取消创建

仅在正在创建片段时生效

Alt+X
+
打开提交菜单

存在待提交草稿时生效

Alt+Enter
+
+
+

COMMUNITY BETA

社区共享

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

未连接
@@ -74,7 +85,7 @@
- +
社区片段处理方式

错误片段收到社区反对或举报后会停止下发

隐私设计

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

diff --git a/options.js b/options.js index 6db9848..cb6427d 100644 --- a/options.js +++ b/options.js @@ -1,5 +1,5 @@ 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 DEFAULTS = { enabled:true, skipLabeledAds:true, skipLocalSegments:true, showToast:true, debug:false, shortcutsEnabled:true, skippedCount:0, localSegments:{}, communityEnabled:true, communityApiBase:DEFAULT_COMMUNITY_API, communityAutoSkipTrusted:true, communitySkipMode:'auto', communityClientId:'' }; let state = { ...DEFAULTS }; let communitySegments = []; let communityStats = { submittedCount:0, contributedSeconds:0, skipCount:0, helpedPeople:0, secondsSaved:0 }; @@ -184,7 +184,7 @@ 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:2,exportedAt:new Date().toISOString(),settings:{enabled:state.enabled,skipLabeledAds:state.skipLabeledAds,skipLocalSegments:state.skipLocalSegments,showToast:state.showToast,debug:state.debug,shortcutsEnabled:state.shortcutsEnabled,communitySkipMode:state.communitySkipMode},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('备份已导出'); } @@ -197,7 +197,8 @@ async function importData(file) { } 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]); }); + $('#communitySkipMode').value=state.communitySkipMode||'auto'; $('#communityApiBase').value = state.communityApiBase || ''; renderCommunityStatus(); } @@ -224,7 +225,8 @@ async function renderCommunityStatus() { 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('设置已保存')})); +['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('设置已保存')})); +$('#communitySkipMode').addEventListener('change',async(event)=>{state.communitySkipMode=event.target.value;state.communityAutoSkipTrusted=state.communitySkipMode==='auto';const update={communitySkipMode:state.communitySkipMode,communityAutoSkipTrusted:state.communityAutoSkipTrusted};if(state.communitySkipMode==='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('社区查询设置已保存'); @@ -254,5 +256,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(DEFAULTS);state={...DEFAULTS,...stored};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();syncSettings();renderOverview();renderSegments();showPage(location.hash.slice(1)||'overview');await fetchMyContributions()})(); +(async()=>{const stored=await chrome.storage.local.get(DEFAULTS);state={...DEFAULTS,...stored};if(!stored.communitySkipMode)state.communitySkipMode=stored.communityAutoSkipTrusted===false?'manual':'auto';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();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()}); From b7d3bb18e8337de73e762445b0a57b812bd44e8f Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:34:36 +0800 Subject: [PATCH 09/15] feat: improve segment validation and timeline labels --- CHANGELOG.md | 1 + content.css | 1 + content.js | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30cf5ee..5b20180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 跳过社区片段后可分别赞成、反对或按原因举报。 - 社区片段支持自动跳过、手动提示和不处理三种行为模式。 - 新增播放器快捷键总开关及开始/结束、取消、提交快捷操作。 +- 进度条片段提供状态与起止时间说明;不足 1 秒的片段保存前会二次确认。 ## 0.4.0 - 2026-08-05 diff --git a/content.css b/content.css index ff20b4f..8fdfb35 100644 --- a/content.css +++ b/content.css @@ -98,6 +98,7 @@ min-width: 1px; background: #00d400; opacity: .82; + pointer-events: auto; } .das-preview-segment.das-pending { background: #007800; } diff --git a/content.js b/content.js index 9152897..5347a04 100644 --- a/content.js +++ b/content.js @@ -387,6 +387,7 @@ showToast('结束时间必须晚于开始时间'); return; } + if (end - draftStart < 1 && !confirm('这个片段不足 1 秒,时间点可能不准确。仍然保存吗?')) return; const savedStart = draftStart; const metadata = getVideoMetadata(video, videoId); const segments = mergeOverlappingDrafts([...currentSegments(video), { start: savedStart, end, createdAt: Date.now(), submissionStatus: 'pending', previewed:false, ...metadata }]); @@ -592,6 +593,9 @@ bar.replaceChildren(...items.map((item) => { const segment = document.createElement('span'); segment.className = `das-preview-segment das-${item.previewState}`; + const stateName = item.previewState === 'pending' ? '待提交广告片段' : item.previewState === 'candidate' ? '待确认广告片段' : '社区广告片段'; + segment.title = `${stateName} ${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; From 1f4c315cb57d7f712d3057be79d56ec647366c36 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:39:25 +0800 Subject: [PATCH 10/15] feat: query community segments by video hash --- CHANGELOG.md | 1 + PRIVACY.md | 2 +- content.js | 8 +++++++- docs/API.md | 8 ++++++++ server/migrations/0005_video_hash.sql | 4 ++++ server/src/index.ts | 27 +++++++++++++++++++++++---- 6 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 server/migrations/0005_video_hash.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b20180..4fdb61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 社区片段支持自动跳过、手动提示和不处理三种行为模式。 - 新增播放器快捷键总开关及开始/结束、取消、提交快捷操作。 - 进度条片段提供状态与起止时间说明;不足 1 秒的片段保存前会二次确认。 +- 播放查询改用作品 ID 的 SHA-256 哈希,不再发送原始作品 ID。 ## 0.4.0 - 2026-08-05 diff --git a/PRIVACY.md b/PRIVACY.md index 9231844..b93140b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -23,7 +23,7 @@ 社区功能启用时: -- 浏览视频时会用作品 ID 查询该作品的可用片段,不上传完整观看历史; +- 浏览视频时会在本地计算作品 ID 的 SHA-256 哈希,并使用哈希查询片段;不发送原始作品 ID,也不上传完整观看历史; - 只有用户在播放器提交菜单中明确点击“提交到社区”时,才上传作品 ID、片段起止时间、视频时长、分类和随机请求 ID; - 公共数据不包含抖音用户名或账号标识; - 请求使用扩展随机生成、由 Chrome 同步的匿名贡献者 ID;服务端只保存加盐哈希,不接收 Chrome、QQ 或抖音账号信息; diff --git a/content.js b/content.js index 5347a04..b3d9c8d 100644 --- a/content.js +++ b/content.js @@ -163,6 +163,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 || settings.communitySkipMode === 'disabled') return; const videoId = extractVideoId(video); @@ -174,7 +179,8 @@ 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, }); diff --git a/docs/API.md b/docs/API.md index 4f27d00..d86f294 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,6 +26,14 @@ GET /v1/videos/{videoId}/segments } ``` +扩展正常播放查询使用隐私版本: + +```http +GET /v1/videos/by-hash/{sha256(videoId)}/segments +``` + +原始作品 ID 查询仅保留用于旧客户端兼容和迁移。 + ## 提交片段 ```http 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/src/index.ts b/server/src/index.ts index f19fb05..798dd2c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -80,6 +80,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 @@ -87,6 +90,19 @@ 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); @@ -155,10 +171,11 @@ 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); } @@ -212,9 +229,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: 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); From 3d6d87076eca839973f7a872550ca53b525a2edb Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:46:41 +0800 Subject: [PATCH 11/15] feat: customize shortcuts and improve skip undo --- CHANGELOG.md | 1 + README.md | 2 +- content.js | 21 ++++++++++++++++----- options.css | 7 ++++--- options.html | 7 ++++--- options.js | 25 +++++++++++++++++++++++-- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdb61d..9b99dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 新增播放器快捷键总开关及开始/结束、取消、提交快捷操作。 - 进度条片段提供状态与起止时间说明;不足 1 秒的片段保存前会二次确认。 - 播放查询改用作品 ID 的 SHA-256 哈希,不再发送原始作品 ID。 +- 快捷键支持在选项页重新录制;撤销跳过后可一键重新跳过。 ## 0.4.0 - 2026-08-05 diff --git a/README.md b/README.md index 9071d23..087ffa8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 片段管理页可以修改本地草稿的开始和结束时间。修改会清除预览状态,必须返回对应视频重新预览;社区片段跳过后可赞成、反对或按“时间错误、不是广告、视频不符、滥用”举报。 -社区片段支持“自动跳过、显示提示手动跳过、不处理”三种模式。播放器快捷键默认为:`Alt+Z` 开始/结束片段、`Alt+X` 取消创建、`Alt+Enter` 打开提交菜单;在输入框中不会触发。 +社区片段支持“自动跳过、显示提示手动跳过、不处理”三种模式。播放器快捷键默认为:`Alt+Z` 开始/结束片段、`Alt+X` 取消创建、`Alt+Enter` 打开提交菜单;可在选项页的“快捷键”中重新录制,在输入框中不会触发。 ## 社区共享 diff --git a/content.js b/content.js index b3d9c8d..5dc0ff6 100644 --- a/content.js +++ b/content.js @@ -14,6 +14,9 @@ showToast: true, debug: false, shortcutsEnabled: true, + shortcutCreate: 'Alt+KeyZ', + shortcutCancel: 'Alt+KeyX', + shortcutSubmit: 'Alt+Enter', skippedCount: 0, localSegments: {}, }; @@ -352,19 +355,24 @@ } function handleShortcut(event) { - if (!settings.shortcutsEnabled || !event.altKey || event.ctrlKey || event.metaKey || event.repeat) return; + if (!settings.shortcutsEnabled || event.repeat) return; const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable) return; let action=''; - if(event.code==='KeyZ')action=draftStart===null?'start':'end'; - if(event.code==='KeyX'&&draftStart!==null)action='cancel'; - if(event.code==='Enter'&&draftStart===null)action='submit'; + 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(); @@ -654,7 +662,10 @@ skipSuppressedUntil.set(key, Date.now() + 12000); video.currentTime = Math.max(0, segment.start); await recordSkip(-1); - showToast('已撤销跳过,12 秒内不会再次跳过此片段'); + 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) { diff --git a/options.css b/options.css index 585c148..f59fc58 100644 --- a/options.css +++ b/options.css @@ -77,10 +77,11 @@ h2 { margin:0; font-size:17px; } .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 auto auto; align-items:center; gap:8px; min-height:74px; padding:14px 21px; border-top:1px solid var(--line); } +.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-row kbd { min-width:30px; padding:5px 8px; border:1px solid #4a505c; border-bottom-width:3px; border-radius:6px; color:#fff; background:#292d36; text-align:center; font:600 12px/1.2 inherit; } -.shortcut-row>span { 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; } diff --git a/options.html b/options.html index 4187afa..a2b1f37 100644 --- a/options.html +++ b/options.html @@ -71,9 +71,10 @@

KEYBOARD

快捷键

无需移动鼠标即可创建、取消或提交片段。

-
开始 / 结束片段

第一次开始,第二次结束

Alt+Z
-
取消创建

仅在正在创建片段时生效

Alt+X
-
打开提交菜单

存在待提交草稿时生效

Alt+Enter
+
开始 / 结束片段

第一次开始,第二次结束

+
取消创建

仅在正在创建片段时生效

+
打开提交菜单

存在待提交草稿时生效

+
设置方法

点击一个快捷键按钮,再按下新的组合键;按 Esc 取消。快捷键必须包含 Ctrl、Alt、Shift 或 Command 中至少一个修饰键。

diff --git a/options.js b/options.js index cb6427d..f42da96 100644 --- a/options.js +++ b/options.js @@ -1,6 +1,7 @@ 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, shortcutsEnabled:true, skippedCount:0, localSegments:{}, communityEnabled:true, communityApiBase:DEFAULT_COMMUNITY_API, communityAutoSkipTrusted:true, communitySkipMode:'auto', communityClientId:'' }; +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, communityAutoSkipTrusted:true, communitySkipMode:'auto', communityClientId:'' }; let state = { ...DEFAULTS }; +let capturingShortcut=''; let communitySegments = []; let communityStats = { submittedCount:0, contributedSeconds:0, skipCount:0, helpedPeople:0, secondsSaved:0 }; @@ -184,7 +185,7 @@ async function uploadAllPending() { } function exportData() { - const payload = {format:'douyin-ad-skipper-backup',version:2,exportedAt:new Date().toISOString(),settings:{enabled:state.enabled,skipLabeledAds:state.skipLabeledAds,skipLocalSegments:state.skipLocalSegments,showToast:state.showToast,debug:state.debug,shortcutsEnabled:state.shortcutsEnabled,communitySkipMode:state.communitySkipMode},localSegments:state.localSegments}; + const payload = {format:'douyin-ad-skipper-backup',version:3,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,communitySkipMode:state.communitySkipMode},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('备份已导出'); } @@ -199,10 +200,14 @@ async function importData(file) { function syncSettings() { ['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled','communityEnabled'].forEach((key)=>{ $(`#${key}`).checked=Boolean(state[key]); }); $('#communitySkipMode').value=state.communitySkipMode||'auto'; + 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 ''; } } @@ -225,6 +230,22 @@ async function renderCommunityStatus() { 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))); +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('设置已保存')})); $('#communitySkipMode').addEventListener('change',async(event)=>{state.communitySkipMode=event.target.value;state.communityAutoSkipTrusted=state.communitySkipMode==='auto';const update={communitySkipMode:state.communitySkipMode,communityAutoSkipTrusted:state.communityAutoSkipTrusted};if(state.communitySkipMode==='manual'){state.showToast=true;update.showToast=true;$('#showToast').checked=true}await chrome.storage.local.set(update);toast('社区片段处理方式已保存')}); $('#communityEnabled').addEventListener('change',async(event)=>{ From 2f61672f60f98090a40490fe5fdbe618ea5e9fd2 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 17:54:02 +0800 Subject: [PATCH 12/15] feat: add segment categories and fine timing controls --- CHANGELOG.md | 2 ++ README.md | 2 ++ content.css | 8 ++++++-- content.js | 26 ++++++++++++++++++-------- options.css | 4 ++++ options.js | 25 +++++++++++++++++++++++-- server/src/validation.ts | 7 ++++--- server/tests/validation.test.ts | 7 +++++++ 8 files changed, 66 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b99dec..7a43e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - 进度条片段提供状态与起止时间说明;不足 1 秒的片段保存前会二次确认。 - 播放查询改用作品 ID 的 SHA-256 哈希,不再发送原始作品 ID。 - 快捷键支持在选项页重新录制;撤销跳过后可一键重新跳过。 +- 新增赞助、自我推广、互动提醒三种片段分类及进度条颜色。 +- 片段管理支持起点和终点 ±0.1 秒精细调整。 ## 0.4.0 - 2026-08-05 diff --git a/README.md b/README.md index 087ffa8..286be69 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ 社区片段支持“自动跳过、显示提示手动跳过、不处理”三种模式。播放器快捷键默认为:`Alt+Z` 开始/结束片段、`Alt+X` 取消创建、`Alt+Enter` 打开提交菜单;可在选项页的“快捷键”中重新录制,在输入框中不会触发。 +片段可标记为“赞助/广告”“自我推广”或“互动提醒”,进度条会使用不同颜色。片段管理页支持对起点和终点进行 ±0.1 秒微调;时间变化后需要重新预览才能上传。 + ## 社区共享 扩展默认连接项目公共社区 API,普通用户安装后即可查询和提交。新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错: diff --git a/content.css b/content.css index 8fdfb35..e9d4e21 100644 --- a/content.css +++ b/content.css @@ -101,8 +101,11 @@ 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; @@ -127,6 +130,7 @@ .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; } diff --git a/content.js b/content.js index 5dc0ff6..770566e 100644 --- a/content.js +++ b/content.js @@ -35,6 +35,7 @@ const skipSuppressedUntil = new Map(); const communityCache = new Map(); const COMMUNITY_CACHE_MS = 10 * 60 * 1000; + const CATEGORY_LABELS = { sponsor:'赞助/广告', selfpromo:'自我推广', interaction:'互动提醒' }; const log = (...args) => settings.debug && console.debug('[抖音广告跳过]', ...args); @@ -190,8 +191,8 @@ 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 })) : []; + .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); @@ -404,7 +405,7 @@ if (end - draftStart < 1 && !confirm('这个片段不足 1 秒,时间点可能不准确。仍然保存吗?')) return; const savedStart = draftStart; const metadata = getVideoMetadata(video, videoId); - const segments = mergeOverlappingDrafts([...currentSegments(video), { start: savedStart, end, createdAt: Date.now(), submissionStatus: 'pending', previewed:false, ...metadata }]); + 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 }); @@ -463,7 +464,7 @@ const allPreviewed = pending.every((item) => item.previewed === true); menu.innerHTML = `
提交广告片段

提交前请逐段预览,确认开始和结束时间准确。

-
    ${pending.map((item,index) => `
  1. ${formatTime(item.start)} – ${formatTime(item.end)}${item.previewed?'✓ 已预览':'尚未预览'}
  2. `).join('')}
+
    ${pending.map((item,index) => `
  1. ${formatTime(item.start)} – ${formatTime(item.end)}${item.previewed?'✓ 已预览':'尚未预览'}
  2. `).join('')}
`; menu.addEventListener('pointerdown', (event) => event.stopPropagation()); menu.addEventListener('click', async (event) => { @@ -473,6 +474,13 @@ 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); } @@ -526,7 +534,7 @@ 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(); @@ -538,6 +546,7 @@ source: 'community', id: confirmed.id, status: confirmed.status || 'trusted', + category: confirmed.category || segment.category || 'sponsor', }); } submittedSegments.add(segment); @@ -606,9 +615,10 @@ 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 stateName = item.previewState === 'pending' ? '待提交广告片段' : item.previewState === 'candidate' ? '待确认广告片段' : '社区广告片段'; - segment.title = `${stateName} ${formatTime(item.start)}–${formatTime(item.end)}`; + 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)}%`; diff --git a/options.css b/options.css index f59fc58..81080cd 100644 --- a/options.css +++ b/options.css @@ -60,6 +60,10 @@ 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; } diff --git a/options.js b/options.js index f42da96..e2a5750 100644 --- a/options.js +++ b/options.js @@ -1,4 +1,5 @@ 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, communityAutoSkipTrusted:true, communitySkipMode:'auto', communityClientId:'' }; let state = { ...DEFAULTS }; let capturingShortcut=''; @@ -65,7 +66,7 @@ function renderSegments() { $('#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 `
${escapeHtml(info.title||`抖音作品 ${videoId}`)}${escapeHtml(info.author||'未记录作者')} · ID ${videoId}
${segments.length} 段
${segments.map((item)=>{const [statusClass,statusText]=segmentStatus(item);return `
${formatTime(item.start)} → ${formatTime(item.end)}
广告片段 ${statusText}${item.storageSource==='community'?'已保存在社区':item.previewed?'✓ 已预览':'修改或创建后需预览'} · ${item.createdAt?new Date(item.createdAt).toLocaleString('zh-CN'):'旧版片段'}
${item.storageSource==='community'?'':``}
`}).join('')}
`; + return `
${escapeHtml(info.title||`抖音作品 ${videoId}`)}${escapeHtml(info.author||'未记录作者')} · ID ${videoId}
${segments.length} 段
${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('') : '
没有找到片段。
'; } @@ -127,6 +128,20 @@ async function editSegment(videoId,index) { 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'); @@ -154,7 +169,7 @@ async function uploadSegment(videoId,index) { 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}`); await response.json(); @@ -267,10 +282,16 @@ $('#disconnectCommunity').addEventListener('click',async()=>{ }); $('#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()); 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); From 9ed139a2f9b6498d0fbc4fdfb81d47f78742c176 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 18:20:22 +0800 Subject: [PATCH 13/15] feat: align category behavior and bump version --- CHANGELOG.md | 4 +++- README.md | 2 ++ content.js | 33 ++++++++++++++++++--------------- manifest.json | 2 +- options.html | 6 ++++-- options.js | 10 +++++----- 6 files changed, 33 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a43e6a..a1ccf32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 更新日志 -## 未发布 +## 0.6.0 - 2026-08-09 - 提交成功后立即把服务器确认片段写入当前页面缓存,进度条颜色无需刷新即可连续显示。 - 提交社区前必须逐段完成播放器预览。 @@ -18,6 +18,8 @@ - 快捷键支持在选项页重新录制;撤销跳过后可一键重新跳过。 - 新增赞助、自我推广、互动提醒三种片段分类及进度条颜色。 - 片段管理支持起点和终点 ±0.1 秒精细调整。 +- 按分类分别设置跳过行为:赞助默认自动跳过,自我推广和互动提醒默认显示提示。 +- 关于页版本号改为从扩展清单自动读取,发布更新时同步升级至 0.6.0。 ## 0.4.0 - 2026-08-05 diff --git a/README.md b/README.md index 286be69..43bcbde 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ 片段可标记为“赞助/广告”“自我推广”或“互动提醒”,进度条会使用不同颜色。片段管理页支持对起点和终点进行 ±0.1 秒微调;时间变化后需要重新预览才能上传。 +和 BilibiliSponsorBlock 的默认行为一致:赞助/广告自动跳过,自我推广与互动提醒先显示提示,也可以在“社区共享”页分别改成自动跳过或不处理。 + ## 社区共享 扩展默认连接项目公共社区 API,普通用户安装后即可查询和提交。新片段提交成功后立即供其他用户使用,投票与举报用于事后纠错: diff --git a/content.js b/content.js index 770566e..b80fe3f 100644 --- a/content.js +++ b/content.js @@ -10,6 +10,9 @@ communityApiBase: DEFAULT_COMMUNITY_API, communityAutoSkipTrusted: true, communitySkipMode: 'auto', + categoryModeSponsor: 'auto', + categoryModeSelfpromo: 'manual', + categoryModeInteraction: 'manual', communityClientId: '', showToast: true, debug: false, @@ -36,6 +39,7 @@ 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); @@ -635,21 +639,20 @@ loadCommunitySegments(video); const now = video.currentTime; const localAvailable = settings.skipLocalSegments ? currentSegments(video) : []; - const trustedCommunity = settings.communitySkipMode === 'disabled' ? [] : communitySegments(video).filter((segment) => segment.status === 'trusted'); - if (settings.communitySkipMode === 'manual') { - const manualSegment = trustedCommunity.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(`发现广告片段 ${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, ...(settings.communitySkipMode === 'auto' ? trustedCommunity : [])]; + 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}`; diff --git a/manifest.json b/manifest.json index 897b304..73d4f1c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "抖音广告跳过", - "version": "0.5.0", + "version": "0.6.0", "description": "跳过抖音网页版广告,并与社区共享手动标记的广告片段。", "icons": { "16": "icons/icon-16.png", diff --git a/options.html b/options.html index a2b1f37..f717a13 100644 --- a/options.html +++ b/options.html @@ -86,7 +86,9 @@
-
社区片段处理方式

错误片段收到社区反对或举报后会停止下发

+
赞助 / 广告

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

+
自我推广

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

+
互动提醒

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

隐私设计

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

@@ -103,7 +105,7 @@

ABOUT

关于抖音广告跳过

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

-

抖音广告跳过

版本 0.5.0

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

+

抖音广告跳过

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

diff --git a/options.js b/options.js index e2a5750..9a3e455 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, communityAutoSkipTrusted:true, communitySkipMode:'auto', communityClientId:'' }; +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, communityAutoSkipTrusted:true, communitySkipMode:'auto', categoryModeSponsor:'auto', categoryModeSelfpromo:'manual', categoryModeInteraction:'manual', communityClientId:'' }; let state = { ...DEFAULTS }; let capturingShortcut=''; let communitySegments = []; @@ -200,7 +200,7 @@ async function uploadAllPending() { } function exportData() { - const payload = {format:'douyin-ad-skipper-backup',version:3,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,communitySkipMode:state.communitySkipMode},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('备份已导出'); } @@ -214,7 +214,7 @@ async function importData(file) { function syncSettings() { ['enabled','skipLabeledAds','skipLocalSegments','showToast','debug','shortcutsEnabled','communityEnabled'].forEach((key)=>{ $(`#${key}`).checked=Boolean(state[key]); }); - $('#communitySkipMode').value=state.communitySkipMode||'auto'; + 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(); @@ -262,7 +262,7 @@ document.addEventListener('keydown',async(event)=>{ 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('设置已保存')})); -$('#communitySkipMode').addEventListener('change',async(event)=>{state.communitySkipMode=event.target.value;state.communityAutoSkipTrusted=state.communitySkipMode==='auto';const update={communitySkipMode:state.communitySkipMode,communityAutoSkipTrusted:state.communityAutoSkipTrusted};if(state.communitySkipMode==='manual'){state.showToast=true;update.showToast=true;$('#showToast').checked=true}await chrome.storage.local.set(update);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('社区查询设置已保存'); @@ -298,5 +298,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(DEFAULTS);state={...DEFAULTS,...stored};if(!stored.communitySkipMode)state.communitySkipMode=stored.communityAutoSkipTrusted===false?'manual':'auto';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();syncSettings();renderOverview();renderSegments();showPage(location.hash.slice(1)||'overview');await fetchMyContributions()})(); +(async()=>{const stored=await chrome.storage.local.get(DEFAULTS);state={...DEFAULTS,...stored};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()}); From 0fd9eb7fcb45a72d477e673820518e36bc967bb4 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Wed, 12 Aug 2026 10:49:41 +0800 Subject: [PATCH 14/15] fix: harden community API and data imports --- CHANGELOG.md | 8 +++ content.js | 15 +++-- manifest.json | 2 +- options.js | 32 +++++++-- .../0006_expand_segment_categories.sql | 66 +++++++++++++++++++ server/src/index.ts | 61 +++++++++++++---- 6 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 server/migrations/0006_expand_segment_categories.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ccf32..e052f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 更新日志 +## 0.6.1 - 2026-08-12 + +- 修复 D1 分类约束,使自我推广和互动提醒可正常提交。 +- 社区写接口限制 JSON 请求体大小,并增加身份与 IP 双层限流。 +- 禁止投稿者给自己的片段投票或举报,并自动清理过期限流记录。 +- 导入备份时限制文件、作品和片段数量,严格校验时间、分类、设置与文本长度。 +- 完成旧版全局社区策略到分类策略的数据迁移,并移除废弃设置。 + ## 0.6.0 - 2026-08-09 - 提交成功后立即把服务器确认片段写入当前页面缓存,进度条颜色无需刷新即可连续显示。 diff --git a/content.js b/content.js index b80fe3f..303c59a 100644 --- a/content.js +++ b/content.js @@ -8,8 +8,6 @@ skipLocalSegments: true, communityEnabled: true, communityApiBase: DEFAULT_COMMUNITY_API, - communityAutoSkipTrusted: true, - communitySkipMode: 'auto', categoryModeSponsor: 'auto', categoryModeSelfpromo: 'manual', categoryModeInteraction: 'manual', @@ -156,7 +154,6 @@ } function communitySegments(video) { - if (settings.communitySkipMode === 'disabled') return []; const id = extractVideoId(video); const cached = id && communityCache.get(id); return cached?.segments || []; @@ -177,7 +174,7 @@ } async function loadCommunitySegments(video) { - if (!settings.communityEnabled || settings.communitySkipMode === 'disabled') return; + if (!settings.communityEnabled) return; const videoId = extractVideoId(video); const apiBase = normalizedApiBase(); if (!videoId || !apiBase) return; @@ -795,9 +792,15 @@ } (async () => { - const stored = await chrome.storage.local.get(DEFAULTS); + const stored = await chrome.storage.local.get(null); settings = { ...DEFAULTS, ...stored }; - if (!stored.communitySkipMode) settings.communitySkipMode = stored.communityAutoSkipTrusted === false ? 'manual' : 'auto'; + 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; diff --git a/manifest.json b/manifest.json index 73d4f1c..ebe97b6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "抖音广告跳过", - "version": "0.6.0", + "version": "0.6.1", "description": "跳过抖音网页版广告,并与社区共享手动标记的广告片段。", "icons": { "16": "icons/icon-16.png", diff --git a/options.js b/options.js index 9a3e455..7145275 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, communityAutoSkipTrusted:true, communitySkipMode:'auto', categoryModeSponsor:'auto', categoryModeSelfpromo:'manual', categoryModeInteraction:'manual', communityClientId:'' }; +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 = []; @@ -206,10 +206,30 @@ function exportData() { 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() { @@ -298,5 +318,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(DEFAULTS);state={...DEFAULTS,...stored};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()})(); +(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/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 798dd2c..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 { @@ -49,6 +60,20 @@ async function rateIdentityHash(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'; @@ -66,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); } @@ -137,8 +168,7 @@ async function recordSegmentSkip(request: Request, env: Env, segmentId: string): 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); - const rateIdentity = await rateIdentityHash(request, env); - if (!rateIdentity || !await withinRateLimit(env, rateIdentity, 'skip', 300)) return json({ error: 'rate_limited' }, 429); + 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' @@ -158,10 +188,11 @@ async function submitSegment(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); - const rateIdentity = await rateIdentityHash(request, env); - if (!rateIdentity || !await withinRateLimit(env, rateIdentity, 'submit', 20)) return json({ error: 'rate_limited' }, 429); + if (!await allowedWriteRate(request, env, 'submit', 20, 100)) return json({ error: 'rate_limited' }, 429); await migrateLegacyIdentity(request, env, identity); - const input = parseSegmentInput(await bodyJson(request)); + 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(` @@ -183,13 +214,14 @@ async function vote(request: Request, env: Env, segmentId: string): Promise(); 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` @@ -209,13 +241,14 @@ async function report(request: Request, env: Env, segmentId: string): Promise(); 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(); From 73d5f71351d566cf0c65197d598dd05f6d732dac Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Wed, 12 Aug 2026 21:48:00 +0800 Subject: [PATCH 15/15] ci: automate validation and release packaging --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 38 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 6 ++++++ PRIVACY.md | 8 +++++-- README.md | 4 ++++ SECURITY.md | 11 +++++++++- manifest.json | 2 +- scripts/validate-extension.mjs | 28 +++++++++++++++++++++++++ server/README.md | 7 ++++++- 9 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 scripts/validate-extension.mjs 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 e052f7e..d72801e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 更新日志 +## 0.6.2 - 2026-08-12 + +- 新增 GitHub Actions 持续集成,自动检查扩展脚本、清单、Worker 测试与类型。 +- 新增基于 `v版本号` 标签的 Chrome ZIP 自动打包与 GitHub Release 发布流程。 +- 更新隐私、安全和服务端文档,明确匿名身份、IP 限流和数据保留边界。 + ## 0.6.1 - 2026-08-12 - 修复 D1 分类约束,使自我推广和互动提醒可正常提交。 diff --git a/PRIVACY.md b/PRIVACY.md index b93140b..71a06a9 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,8 +1,8 @@ # 隐私说明 -最后更新:2026-08-05 +最后更新:2026-08-12 -## 当前版本(v0.5.0) +## 当前版本(v0.6.2) 扩展只在 `https://www.douyin.com/*` 页面运行,并使用 Chrome `storage` 权限保存: @@ -33,4 +33,8 @@ - 提供完全关闭远程查询与提交的选项; - 实际服务器运营者仍需自行公布基础设施日志的保留期限和删除方式。 +作品 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 43bcbde..2230ed9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ 5. 选择本仓库根目录。 6. 打开或刷新 `https://www.douyin.com/`。 +也可以从 GitHub Releases 下载自动生成的 Chrome ZIP,解压后按上述方式加载。Release 标签必须与 `manifest.json` 版本一致,例如版本 `0.6.2` 使用标签 `v0.6.2`。 + ## 创建片段 播放器底部清晰度按钮左侧会显示片段标记图标: @@ -83,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/manifest.json b/manifest.json index ebe97b6..a90a8d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "抖音广告跳过", - "version": "0.6.1", + "version": "0.6.2", "description": "跳过抖音网页版广告,并与社区共享手动标记的广告片段。", "icons": { "16": "icons/icon-16.png", 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 备份,再应用迁移。 ## 可信规则(初版)