- DASHBOARD
本地跳过概览
查看标记片段、累计跳过和当前运行状态。
本地模式
+ DASHBOARD
跳过概览
查看草稿、社区贡献、累计跳过和当前运行状态。
社区模式
已标记片段0个时间片段
涉及视频0个抖音作品
@@ -37,13 +37,13 @@
- LOCAL LIBRARY
片段管理
查看、上传或删除你手动创建的片段。
+ SEGMENT LIBRARY
片段管理
查看云端贡献,或上传、删除尚未提交的本地草稿。
@@ -53,7 +53,7 @@
@@ -70,13 +70,13 @@
COMMUNITY BETA
社区共享
默认连接项目公共 API;提交成功的广告片段会立即与其他用户共享。
-
+
隐私设计远程查询失败时会立即回退到本地数据。扩展不会自动上传你创建的片段,只有点击播放器提交按钮或片段管理中的上传按钮时才会发送。
@@ -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) => `- ${formatTime(item.start)} – ${formatTime(item.end)}${(item.end - item.start).toFixed(1)} 秒
`).join('')}
-
`;
+
提交前请逐段预览,确认开始和结束时间准确。
+
${pending.map((item,index) => `- ${formatTime(item.start)} – ${formatTime(item.end)}${item.previewed?'✓ 已预览':'尚未预览'}
`).join('')}
+
`;
menu.addEventListener('pointerdown', (event) => event.stopPropagation());
menu.addEventListener('click', async (event) => {
event.stopPropagation();
const action = event.target.closest('button')?.dataset.menuAction;
if (action === 'close' || action === 'keep') closeSubmissionMenu();
+ if (action === 'preview') await previewPendingSegment(video, videoId, pending[Number(event.target.closest('button').dataset.previewIndex)]);
if (action === 'submit') await submitPendingSegments(video, videoId, menu);
});
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 @@
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 `${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 `${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
快捷键
无需移动鼠标即可创建、取消或提交片段。
+
+
+
COMMUNITY BETA
社区共享
默认连接项目公共 API;提交成功的广告片段会立即与其他用户共享。
@@ -74,7 +85,7 @@
-
+ 社区片段处理方式错误片段收到社区反对或举报后会停止下发