// ==UserScript==
// @name 网页抖音体验增强
// @namespace Violentmonkey Scripts
// @match https://www.douyin.com/?*
// @match *://*.douyin.com/*
// @match *://*.iesdouyin.com/*
// @exclude *://lf-zt.douyin.com*
// @grant none
// @version 2.6
// @description 自动跳过直播、智能屏蔽关键字(自动不感兴趣)、跳过广告、最高分辨率、分辨率筛选、AI智能筛选(自动点赞)、极速模式
// @author Frequenk
// @license GPL-3.0 License
// @run-at document-start
// ==/UserScript==
/*
==== 网页抖音体验增强 ====
核心功能:
1. 跳过直播功能
- 自动检测并跳过直播内容
- 通过设置面板开关控制
2. 智能屏蔽关键字 ⭐
- 自动检测账号名称中的屏蔽关键字(默认:"店"、"甄选")
- 智能处理:可选择"不感兴趣"(R键)或直接跳过,默认使用R键
- 按R键后视频直接消失,更符合抖音行为逻辑
- 📁 导入导出功能:支持txt文件批量管理关键字列表
- 点击按钮文字打开管理弹窗,支持增删改查
3. 跳过广告功能
- 自动识别并跳过广告视频
- 通过设置面板开关控制
4. 自动最高分辨率
- 智能选择最高可用分辨率(4K > 2K > 1080P > 720P > 540P > 智能)
- 找到4K分辨率后自动关闭功能
5. 分辨率筛选 ⭐
- 只看指定分辨率的视频(4K、2K、1080P、720P、540P)
- 没有指定分辨率时自动跳过
- 点击按钮文字选择想要的分辨率
6. AI智能筛选(需本地AI)⭐
- 🤖 内容识别:自定义想看的内容类型(如"露脸的美女"、"搞笑视频"等)
- 👍 智能点赞:AI判定喜欢内容时可选择自动点赞,默认开启
- 🔧 模型支持:支持多种AI模型,默认qwen2.5vl:7b
- ⚡ 快速决策:多时间点检测(0s、1s、2.5s、4s、6s、8s)
- 🎯 精准判断:连续1次不符合立即跳过,连续2次符合停止检测
- 📋 环境要求:需安装Ollama并下载视觉模型
7. 极速模式
- 每个视频播放指定时间后自动切换到下一个
- 可自定义播放时间(1-60秒,默认6秒)
- 适合快速浏览大量内容
8. 用户界面
- 🎛️ 统一控制:所有功能集成在播放器设置面板中
- ⚙️ 详细设置:点击按钮文字打开功能专属设置弹窗
- 📊 状态显示:实时显示各功能开关状态和参数
- 💾 自动保存:所有设置自动保存到本地存储
*/
(function() {
'use strict';
// ========== 配置管理模块 ==========
class ConfigManager {
constructor() {
this.config = {
skipLive: { enabled: true, key: 'skipLive' },
autoHighRes: { enabled: true, key: 'autoHighRes' },
blockKeywords: {
enabled: true,
key: 'blockKeywords',
keywords: this.loadKeywords(),
pressR: this.loadPressRSetting()
},
skipAd: { enabled: true, key: 'skipAd' },
onlyResolution: {
enabled: false,
key: 'onlyResolution',
resolution: this.loadTargetResolution()
},
aiPreference: {
enabled: false,
key: 'aiPreference',
content: this.loadAiContent(),
model: this.loadAiModel(),
autoLike: this.loadAutoLikeSetting()
},
speedMode: {
enabled: false,
key: 'speedMode',
seconds: this.loadSpeedSeconds()
}
};
}
loadKeywords() {
return JSON.parse(localStorage.getItem('douyin_blocked_keywords') || '["店", "甄选"]');
}
loadSpeedSeconds() {
return parseInt(localStorage.getItem('douyin_speed_mode_seconds') || '6');
}
loadAiContent() {
return localStorage.getItem('douyin_ai_content') || '露脸的美女';
}
loadAiModel() {
return localStorage.getItem('douyin_ai_model') || 'qwen2.5vl:7b';
}
loadTargetResolution() {
return localStorage.getItem('douyin_target_resolution') || '4K';
}
loadPressRSetting() {
return localStorage.getItem('douyin_press_r_enabled') !== 'false'; // 默认开启
}
loadAutoLikeSetting() {
return localStorage.getItem('douyin_auto_like_enabled') !== 'false'; // 默认开启
}
saveKeywords(keywords) {
this.config.blockKeywords.keywords = keywords;
localStorage.setItem('douyin_blocked_keywords', JSON.stringify(keywords));
}
saveSpeedSeconds(seconds) {
this.config.speedMode.seconds = seconds;
localStorage.setItem('douyin_speed_mode_seconds', seconds.toString());
}
saveAiContent(content) {
this.config.aiPreference.content = content;
localStorage.setItem('douyin_ai_content', content);
}
saveAiModel(model) {
this.config.aiPreference.model = model;
localStorage.setItem('douyin_ai_model', model);
}
saveTargetResolution(resolution) {
this.config.onlyResolution.resolution = resolution;
localStorage.setItem('douyin_target_resolution', resolution);
}
savePressRSetting(enabled) {
this.config.blockKeywords.pressR = enabled;
localStorage.setItem('douyin_press_r_enabled', enabled.toString());
}
saveAutoLikeSetting(enabled) {
this.config.aiPreference.autoLike = enabled;
localStorage.setItem('douyin_auto_like_enabled', enabled.toString());
}
get(key) {
return this.config[key];
}
setEnabled(key, value) {
if (this.config[key]) {
this.config[key].enabled = value;
}
}
isEnabled(key) {
return this.config[key]?.enabled || false;
}
}
// ========== DOM选择器常量 ==========
const SELECTORS = {
activeVideo: "[data-e2e='feed-active-video']",
resolutionOptions: ".xgplayer-playing div.virtual > div.item",
accountName: '[data-e2e="feed-video-nickname"]',
settingsPanel: 'xg-icon.xgplayer-autoplay-setting',
adIndicator: 'svg[viewBox="0 0 30 16"]',
videoElement: 'video'
};
// ========== 视频控制器 ==========
class VideoController {
constructor() {
this.skipCheckInterval = null;
this.skipAttemptCount = 0;
}
skip() {
console.log('跳过视频');
if (!document.body) return;
const videoBefore = this.getCurrentVideoUrl();
this.sendKeyEvent('ArrowDown');
this.clearSkipCheck();
this.startSkipCheck(videoBefore);
}
like() {
console.log('【自动点赞】喜好内容');
this.sendKeyEvent('z', 'KeyZ', 90);
}
pressR() {
console.log('【不感兴趣】屏蔽关键字内容');
this.sendKeyEvent('r', 'KeyR', 82);
}
sendKeyEvent(key, code = null, keyCode = null) {
try {
const event = new KeyboardEvent('keydown', {
key: key,
code: code || (key === 'ArrowDown' ? 'ArrowDown' : code),
keyCode: keyCode || (key === 'ArrowDown' ? 40 : keyCode),
which: keyCode || (key === 'ArrowDown' ? 40 : keyCode),
bubbles: true,
cancelable: true
});
document.body.dispatchEvent(event);
} catch (error) {
console.log('发送键盘事件失败:', error);
}
}
getCurrentVideoUrl() {
const videoEl = document.querySelector(`${SELECTORS.activeVideo} ${SELECTORS.videoElement}`);
return videoEl?.src || '';
}
clearSkipCheck() {
if (this.skipCheckInterval) {
clearInterval(this.skipCheckInterval);
this.skipCheckInterval = null;
}
this.skipAttemptCount = 0;
}
startSkipCheck(urlBefore) {
this.skipCheckInterval = setInterval(() => {
this.skipAttemptCount++;
const urlAfter = this.getCurrentVideoUrl();
if (urlAfter && urlAfter !== urlBefore) {
console.log('视频已成功切换');
this.clearSkipCheck();
return;
}
console.log(`视频未切换,第${this.skipAttemptCount + 1}次尝试跳过`);
this.sendKeyEvent('ArrowDown');
}, 300);
}
}
// ========== UI组件工厂 ==========
class UIFactory {
static createDialog(className, title, content, onSave, onCancel) {
const existingDialog = document.querySelector(`.${className}`);
if (existingDialog) {
existingDialog.remove();
return;
}
const dialog = document.createElement('div');
dialog.className = className;
Object.assign(dialog.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.9)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '20px',
zIndex: '10000',
minWidth: '250px'
});
dialog.innerHTML = `
<div style="color: white; margin-bottom: 15px; font-size: 14px;">${title}</div>
${content}
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button class="dialog-confirm" style="flex: 1; padding: 5px; background: #fe2c55;
color: white; border: none; border-radius: 4px; cursor: pointer;">确定</button>
<button class="dialog-cancel" style="flex: 1; padding: 5px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; cursor: pointer;">取消</button>
</div>
`;
document.body.appendChild(dialog);
dialog.querySelector('.dialog-confirm').addEventListener('click', () => {
if (onSave()) dialog.remove();
});
dialog.querySelector('.dialog-cancel').addEventListener('click', () => {
dialog.remove();
if (onCancel) onCancel();
});
setTimeout(() => {
document.addEventListener('click', function closeDialog(e) {
if (!dialog.contains(e.target)) {
dialog.remove();
document.removeEventListener('click', closeDialog);
}
});
}, 100);
return dialog;
}
static createToggleButton(text, className, isEnabled, onToggle, onClick = null) {
const btnContainer = document.createElement('xg-icon');
btnContainer.className = `xgplayer-autoplay-setting ${className}`;
btnContainer.innerHTML = `
<div class="xgplayer-icon">
<div class="xgplayer-setting-label">
<button aria-checked="${isEnabled}" class="xg-switch ${isEnabled ? 'xg-switch-checked' : ''}">
<span class="xg-switch-inner"></span>
</button>
<span class="xgplayer-setting-title" style="${onClick ? 'cursor: pointer; text-decoration: underline;' : ''}">${text}</span>
</div>
</div>`;
btnContainer.querySelector('button').addEventListener('click', (e) => {
const newState = e.currentTarget.getAttribute('aria-checked') === 'false';
UIManager.updateToggleButtons(className, newState);
onToggle(newState);
});
if (onClick) {
btnContainer.querySelector('.xgplayer-setting-title').addEventListener('click', (e) => {
e.stopPropagation();
onClick();
});
}
return btnContainer;
}
static showErrorDialog() {
const dialog = document.createElement('div');
dialog.className = 'error-dialog-' + Date.now();
dialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.95);
border: 2px solid rgba(254, 44, 85, 0.8);
color: white;
padding: 20px;
border-radius: 8px;
z-index: 10001;
max-width: 400px;
text-align: center;
font-size: 14px;
`;
dialog.innerHTML = `
<div style="margin-bottom: 20px;">
<div style="color: #fe2c55; font-size: 40px; margin-bottom: 15px;">⚠️</div>
<div style="text-align: left; line-height: 1.6;">
<div style="margin-bottom: 12px;">
<strong>请检查以下配置:</strong>
</div>
<div style="margin-bottom: 8px;">
1. 安装 <a href="https://ollama.com/" target="_blank" style="color: #fe2c55; text-decoration: underline;">Ollama</a>
并下载视觉模型(默认:qwen2.5vl:7b)
</div>
<div>
2. 开启Ollama跨域模式,设置环境变量:
<div style="margin-left: 20px; margin-top: 5px; font-family: monospace; background: rgba(255, 255, 255, 0.1); padding: 5px; border-radius: 4px;">
OLLAMA_HOST=0.0.0.0<br>
OLLAMA_ORIGINS=*
</div>
<div style="margin-top: 8px;">
参考配置教程:<a href="https://lobehub.com/zh/docs/self-hosting/examples/ollama" target="_blank"
style="color: #fe2c55; text-decoration: underline;">Ollama跨域设置指南</a>
</div>
</div>
</div>
</div>
<button class="error-dialog-confirm" style="padding: 8px 20px; background: #fe2c55; color: white;
border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">确定</button>
`;
document.body.appendChild(dialog);
dialog.querySelector('.error-dialog-confirm').addEventListener('click', () => {
dialog.remove();
});
}
}
// ========== UI管理器 ==========
class UIManager {
constructor(config, videoController) {
this.config = config;
this.videoController = videoController;
this.initButtons();
}
initButtons() {
this.buttonConfigs = [
{
text: '跳直播',
className: 'skip-live-button',
configKey: 'skipLive'
},
{
text: '跳广告',
className: 'skip-ad-button',
configKey: 'skipAd'
},
{
text: '账号屏蔽',
className: 'block-account-keyword-button',
configKey: 'blockKeywords',
onClick: () => this.showKeywordDialog()
},
{
text: '最高清',
className: 'auto-high-resolution-button',
configKey: 'autoHighRes'
},
{
text: `${this.config.get('onlyResolution').resolution}筛选`,
className: 'resolution-filter-button',
configKey: 'onlyResolution',
onClick: () => this.showResolutionDialog()
},
{
text: 'AI喜好',
className: 'ai-preference-button',
configKey: 'aiPreference',
onClick: () => this.showAiPreferenceDialog()
},
{
text: `${this.config.get('speedMode').seconds}秒切`,
className: 'speed-mode-button',
configKey: 'speedMode',
onClick: () => this.showSpeedDialog()
}
];
}
insertButtons() {
document.querySelectorAll(SELECTORS.settingsPanel).forEach(panel => {
const parent = panel.parentNode;
if (!parent) return;
let lastButton = panel;
this.buttonConfigs.forEach(config => {
let button = parent.querySelector(`.${config.className}`);
if (!button) {
button = UIFactory.createToggleButton(
config.text,
config.className,
this.config.isEnabled(config.configKey),
(state) => this.config.setEnabled(config.configKey, state),
config.onClick
);
parent.insertBefore(button, lastButton.nextSibling);
}
lastButton = button;
});
});
}
static updateToggleButtons(className, isEnabled) {
document.querySelectorAll(`.${className} .xg-switch`).forEach(sw => {
sw.classList.toggle('xg-switch-checked', isEnabled);
sw.setAttribute('aria-checked', String(isEnabled));
});
}
updateSpeedModeText() {
const seconds = this.config.get('speedMode').seconds;
document.querySelectorAll('.speed-mode-button .xgplayer-setting-title').forEach(el => {
el.textContent = `${seconds}秒切`;
});
}
updateResolutionText() {
const resolution = this.config.get('onlyResolution').resolution;
document.querySelectorAll('.resolution-filter-button .xgplayer-setting-title').forEach(el => {
el.textContent = `${resolution}筛选`;
});
}
showSpeedDialog() {
const seconds = this.config.get('speedMode').seconds;
const content = `
<input type="number" class="speed-input" min="1" max="60" value="${seconds}"
style="width: 100%; padding: 5px; margin-bottom: 15px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px;">
`;
UIFactory.createDialog('speed-mode-time-dialog', '设置极速模式时间(秒)', content, () => {
const input = document.querySelector('.speed-input');
const value = parseInt(input.value);
if (value >= 1 && value <= 60) {
this.config.saveSpeedSeconds(value);
this.updateSpeedModeText();
return true;
}
return false;
});
}
showAiPreferenceDialog() {
const currentContent = this.config.get('aiPreference').content;
const currentModel = this.config.get('aiPreference').model;
const autoLikeEnabled = this.config.get('aiPreference').autoLike;
const content = `
<div style="margin-bottom: 15px;">
<label style="color: rgba(255, 255, 255, 0.7); font-size: 12px; display: block; margin-bottom: 5px;">
想看什么内容?(例如:露脸的美女、搞笑视频、猫咪)
</label>
<input type="text" class="ai-content-input" value="${currentContent}" placeholder="输入你想看的内容"
style="width: 100%; padding: 8px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px;">
</div>
<div style="margin-bottom: 15px;">
<label style="color: rgba(255, 255, 255, 0.7); font-size: 12px; display: block; margin-bottom: 5px;">
AI模型选择
</label>
<div style="position: relative;">
<select class="ai-model-select"
style="width: 100%; padding: 8px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px;
appearance: none; cursor: pointer;">
<option value="qwen2.5vl:7b" style="background: rgba(0, 0, 0, 0.9); color: white;" ${currentModel === 'qwen2.5vl:7b' ? 'selected' : ''}>qwen2.5vl:7b (推荐)</option>
<option value="custom" style="background: rgba(0, 0, 0, 0.9); color: white;" ${currentModel !== 'qwen2.5vl:7b' ? 'selected' : ''}>自定义模型</option>
</select>
<span style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
pointer-events: none; color: rgba(255, 255, 255, 0.5);">▼</span>
</div>
<input type="text" class="ai-model-input" value="${currentModel !== 'qwen2.5vl:7b' ? currentModel : ''}"
placeholder="输入自定义模型名称"
style="width: 100%; padding: 8px; margin-top: 10px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px;
display: ${currentModel !== 'qwen2.5vl:7b' ? 'block' : 'none'};">
</div>
<div style="margin-bottom: 15px; padding: 10px; background: rgba(255, 255, 255, 0.05); border-radius: 6px;">
<label style="display: flex; align-items: center; cursor: pointer; color: white; font-size: 13px;">
<input type="checkbox" class="auto-like-checkbox" ${autoLikeEnabled ? 'checked' : ''}
style="margin-right: 8px; transform: scale(1.2);">
AI判定为喜欢的内容将自动点赞(Z键)
</label>
<div style="color: rgba(255, 255, 255, 0.5); font-size: 11px; margin-top: 5px; margin-left: 24px;">
帮助抖音算法了解你喜欢此类内容
</div>
</div>
<div style="color: rgba(255, 255, 255, 0.5); font-size: 11px; margin-bottom: 10px;">
提示:需要安装 <a href="https://ollama.com/" target="_blank" style="color: #fe2c55;">Ollama</a> 并下载视觉模型
</div>
`;
const dialog = UIFactory.createDialog('ai-preference-dialog', '设置AI喜好', content, () => {
const contentInput = dialog.querySelector('.ai-content-input');
const modelSelect = dialog.querySelector('.ai-model-select');
const modelInput = dialog.querySelector('.ai-model-input');
const autoLikeCheckbox = dialog.querySelector('.auto-like-checkbox');
const content = contentInput.value.trim();
let model = modelSelect.value === 'custom'
? modelInput.value.trim()
: modelSelect.value;
if (!content) {
alert('请输入想看的内容');
return false;
}
if (!model) {
alert('请选择或输入模型名称');
return false;
}
this.config.saveAiContent(content);
this.config.saveAiModel(model);
this.config.saveAutoLikeSetting(autoLikeCheckbox.checked);
console.log('AI喜好设置已更新:', { content, model, autoLike: autoLikeCheckbox.checked });
return true;
});
// 处理模型选择切换
const modelSelect = dialog.querySelector('.ai-model-select');
const modelInput = dialog.querySelector('.ai-model-input');
modelSelect.addEventListener('change', (e) => {
if (e.target.value === 'custom') {
modelInput.style.display = 'block';
} else {
modelInput.style.display = 'none';
modelInput.value = '';
}
});
// 防止复选框点击时关闭弹窗
dialog.querySelector('.auto-like-checkbox').addEventListener('click', (e) => {
e.stopPropagation();
});
}
showKeywordDialog() {
const keywords = this.config.get('blockKeywords').keywords;
let tempKeywords = [...keywords];
const updateList = () => {
const container = document.querySelector('.keyword-list');
if (!container) return;
container.innerHTML = tempKeywords.length === 0
? '<div style="color: rgba(255, 255, 255, 0.5); text-align: center;">暂无关键字</div>'
: tempKeywords.map((keyword, index) => `
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<span style="flex: 1; color: white; padding: 5px 10px; background: rgba(255, 255, 255, 0.1);
border-radius: 4px; margin-right: 10px;">${keyword}</span>
<button data-index="${index}" class="delete-keyword" style="padding: 5px 10px; background: #ff4757;
color: white; border: none; border-radius: 4px; cursor: pointer;">删除</button>
</div>
`).join('');
// 使用事件委托来处理删除按钮点击
container.onclick = (e) => {
if (e.target.classList.contains('delete-keyword')) {
e.stopPropagation(); // 阻止事件冒泡,防止触发弹窗关闭
const index = parseInt(e.target.dataset.index);
tempKeywords.splice(index, 1);
updateList();
}
};
};
const pressREnabled = this.config.get('blockKeywords').pressR;
const content = `
<div style="color: rgba(255, 255, 255, 0.7); margin-bottom: 15px; font-size: 12px;">
包含这些关键字的账号将被自动跳过
</div>
<div style="margin-bottom: 15px; padding: 10px; background: rgba(255, 255, 255, 0.05); border-radius: 6px;">
<label style="display: flex; align-items: center; cursor: pointer; color: white; font-size: 13px;">
<input type="checkbox" class="press-r-checkbox" ${pressREnabled ? 'checked' : ''}
style="margin-right: 8px; transform: scale(1.2);">
跳过时自动按R键(不感兴趣)
</label>
<div style="color: rgba(255, 255, 255, 0.5); font-size: 11px; margin-top: 5px; margin-left: 24px;">
帮助抖音算法了解你不喜欢此类内容
</div>
</div>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" class="keyword-input" placeholder="输入新关键字"
style="flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px;">
<button class="add-keyword" style="padding: 8px 15px; background: #00d639;
color: white; border: none; border-radius: 4px; cursor: pointer;">添加</button>
</div>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<button class="import-keywords" style="flex: 1; padding: 8px 12px; background: rgba(52, 152, 219, 0.8);
color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">
📁 导入关键字
</button>
<button class="export-keywords" style="flex: 1; padding: 8px 12px; background: rgba(155, 89, 182, 0.8);
color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">
💾 导出关键字
</button>
</div>
<div class="keyword-list" style="margin-bottom: 15px; max-height: 200px; overflow-y: auto;"></div>
`;
const dialog = UIFactory.createDialog('keyword-setting-dialog', '管理屏蔽关键字', content, () => {
const pressRCheckbox = dialog.querySelector('.press-r-checkbox');
this.config.saveKeywords(tempKeywords);
this.config.savePressRSetting(pressRCheckbox.checked);
console.log('屏蔽关键字已更新:', tempKeywords);
console.log('自动按R键设置已更新:', pressRCheckbox.checked);
return true;
});
const addKeyword = () => {
const input = dialog.querySelector('.keyword-input');
const keyword = input.value.trim();
if (keyword && !tempKeywords.includes(keyword)) {
tempKeywords.push(keyword);
updateList();
input.value = '';
}
};
dialog.querySelector('.add-keyword').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡,防止触发弹窗关闭
addKeyword();
});
dialog.querySelector('.keyword-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.stopPropagation(); // 阻止事件冒泡
addKeyword();
}
});
// 防止在输入框内点击时关闭弹窗
dialog.querySelector('.keyword-input').addEventListener('click', (e) => {
e.stopPropagation();
});
// 防止复选框点击时关闭弹窗
dialog.querySelector('.press-r-checkbox').addEventListener('click', (e) => {
e.stopPropagation();
});
// 导出功能
const exportKeywords = () => {
const content = tempKeywords.join('\n');
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `抖音屏蔽关键字_${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('关键字已导出:', tempKeywords);
};
dialog.querySelector('.export-keywords').addEventListener('click', (e) => {
e.stopPropagation();
exportKeywords();
});
// 导入功能
const importKeywords = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt';
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
const importedKeywords = content.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (importedKeywords.length > 0) {
// 合并关键字,去重
const allKeywords = [...new Set([...tempKeywords, ...importedKeywords])];
tempKeywords.splice(0, tempKeywords.length, ...allKeywords);
updateList();
console.log('关键字导入完成:', importedKeywords);
console.log('当前关键字列表:', tempKeywords);
} else {
alert('文件内容为空或格式不正确!');
}
};
reader.onerror = () => {
alert('文件读取失败!');
};
reader.readAsText(file, 'utf-8');
}
});
input.click();
};
dialog.querySelector('.import-keywords').addEventListener('click', (e) => {
e.stopPropagation();
importKeywords();
});
updateList();
}
showResolutionDialog() {
const currentResolution = this.config.get('onlyResolution').resolution;
const resolutions = ['4K', '2K', '1080P', '720P', '540P'];
const content = `
<div style="margin-bottom: 15px;">
<label style="color: rgba(255, 255, 255, 0.7); font-size: 12px; display: block; margin-bottom: 5px;">
选择要筛选的分辨率
</label>
<div style="position: relative;">
<select class="resolution-select"
style="width: 100%; padding: 8px; background: rgba(255, 255, 255, 0.1);
color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px;
appearance: none; cursor: pointer;">
${resolutions.map(res =>
`<option value="${res}" style="background: rgba(0, 0, 0, 0.9); color: white;" ${currentResolution === res ? 'selected' : ''}>${res}</option>`
).join('')}
</select>
<span style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
pointer-events: none; color: rgba(255, 255, 255, 0.5);">▼</span>
</div>
</div>
<div style="color: rgba(255, 255, 255, 0.5); font-size: 11px; margin-bottom: 10px;">
提示:只播放包含所选分辨率关键字的视频,没有找到则自动跳过
</div>
`;
const dialog = UIFactory.createDialog('resolution-dialog', '分辨率筛选设置', content, () => {
const resolutionSelect = dialog.querySelector('.resolution-select');
const resolution = resolutionSelect.value;
this.config.saveTargetResolution(resolution);
this.updateResolutionText();
console.log('分辨率筛选已更新:', resolution);
return true;
});
}
}
// ========== AI检测器 ==========
class AIDetector {
constructor(videoController, config) {
this.videoController = videoController;
this.config = config;
this.API_URL = 'http://localhost:11434/api/generate';
this.checkSchedule = [0, 1000, 2500, 4000, 6000, 8000];
this.reset();
}
reset() {
this.currentCheckIndex = 0;
this.checkResults = [];
this.consecutiveYes = 0;
this.consecutiveNo = 0;
this.hasSkipped = false;
this.stopChecking = false;
this.hasLiked = false;
this.isProcessing = false;
}
shouldCheck(videoPlayTime) {
return !this.isProcessing &&
!this.stopChecking &&
!this.hasSkipped &&
this.currentCheckIndex < this.checkSchedule.length &&
videoPlayTime >= this.checkSchedule[this.currentCheckIndex];
}
async processVideo(videoEl) {
if (this.isProcessing || this.stopChecking || this.hasSkipped) return;
this.isProcessing = true;
try {
const base64Image = await this.captureVideoFrame(videoEl);
const aiResponse = await this.callAI(base64Image);
this.handleResponse(aiResponse);
this.currentCheckIndex++;
} catch (error) {
console.error('AI判断功能出错:', error);
// 显示错误提示
UIFactory.showErrorDialog();
// 关闭AI喜好模式
this.config.setEnabled('aiPreference', false);
UIManager.updateToggleButtons('ai-preference-button', false);
this.stopChecking = true;
} finally {
this.isProcessing = false;
}
}
async captureVideoFrame(videoEl) {
const canvas = document.createElement('canvas');
const maxSize = 500;
const aspectRatio = videoEl.videoWidth / videoEl.videoHeight;
let targetWidth, targetHeight;
if (videoEl.videoWidth > videoEl.videoHeight) {
targetWidth = Math.min(videoEl.videoWidth, maxSize);
targetHeight = Math.round(targetWidth / aspectRatio);
} else {
targetHeight = Math.min(videoEl.videoHeight, maxSize);
targetWidth = Math.round(targetHeight * aspectRatio);
}
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(videoEl, 0, 0, targetWidth, targetHeight);
return canvas.toDataURL('image/jpeg', 0.8).split(',')[1];
}
async callAI(base64Image) {
const content = this.config.get('aiPreference').content;
const model = this.config.get('aiPreference').model;
const response = await fetch(this.API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: model,
prompt: `这是${content}吗?回答『是』或者『不是』,不要说任何多余的字符`,
images: [base64Image],
stream: false
})
});
if (!response.ok) {
throw new Error(`AI请求失败: ${response.status}`);
}
const result = await response.json();
return result.response?.trim();
}
handleResponse(aiResponse) {
const content = this.config.get('aiPreference').content;
this.checkResults.push(aiResponse);
console.log(`AI检测结果[${this.checkResults.length}]:${aiResponse}`);
if (aiResponse === '是') {
this.consecutiveYes++;
this.consecutiveNo = 0;
} else {
this.consecutiveYes = 0;
this.consecutiveNo++;
}
if (this.consecutiveNo >= 1) {
console.log(`【立即跳过】判定为非${content}`);
this.hasSkipped = true;
this.stopChecking = true;
this.videoController.skip();
} else if (this.consecutiveYes >= 2) {
console.log(`【停止检测】连续2次判定为${content},安心观看`);
this.stopChecking = true;
// 检查是否开启了自动点赞功能
const autoLikeEnabled = this.config.get('aiPreference').autoLike;
if (!this.hasLiked && autoLikeEnabled) {
this.videoController.like();
this.hasLiked = true;
} else if (!autoLikeEnabled) {
console.log('【自动点赞】功能已关闭,跳过点赞');
}
}
}
}
// ========== 视频检测策略 ==========
class VideoDetectionStrategies {
constructor(config, videoController) {
this.config = config;
this.videoController = videoController;
}
checkAd(container) {
if (!this.config.isEnabled('skipAd')) return false;
const adIndicator = container.querySelector(SELECTORS.adIndicator);
if (adIndicator) {
console.log("检测到广告,已跳过");
this.videoController.skip();
return true;
}
return false;
}
checkBlockedAccount(container) {
if (!this.config.isEnabled('blockKeywords')) return false;
const accountEl = container.querySelector(SELECTORS.accountName);
const accountName = accountEl?.textContent.trim();
const keywords = this.config.get('blockKeywords').keywords;
const pressREnabled = this.config.get('blockKeywords').pressR;
if (accountName && keywords.some(kw => accountName.includes(kw))) {
console.log(`检测到屏蔽关键字,已跳过账号: ${accountName}`);
// 如果开启了按R键功能,只按R键(视频会直接消失)
if (pressREnabled) {
this.videoController.pressR();
} else {
// 如果没开启R键功能,则使用下键跳过
this.videoController.skip();
}
return true;
}
return false;
}
checkResolution(container) {
if (!this.config.isEnabled('autoHighRes') && !this.config.isEnabled('onlyResolution')) return false;
const priorityOrder = ["4K", "2K", "1080P", "720P", "540P", "智能"];
const options = Array.from(container.querySelectorAll(SELECTORS.resolutionOptions))
.map(el => {
const text = el.textContent.trim().toUpperCase();
return {
element: el,
text,
priority: priorityOrder.findIndex(p => text.includes(p))
};
})
.filter(opt => opt.priority !== -1)
.sort((a, b) => a.priority - b.priority);
// 只看指定分辨率模式:只选择指定分辨率,没有就跳过
if (this.config.isEnabled('onlyResolution')) {
const targetResolution = this.config.get('onlyResolution').resolution.toUpperCase();
const hasTarget = options.some(opt => opt.text.includes(targetResolution));
if (!hasTarget) {
console.log(`【分辨率筛选】未找到${targetResolution}分辨率,跳过视频`);
this.videoController.skip();
return true;
}
const targetOption = options.find(opt => opt.text.includes(targetResolution));
if (targetOption && !targetOption.element.classList.contains("selected")) {
targetOption.element.click();
console.log(`【分辨率筛选】已切换至${targetResolution}分辨率`);
return true;
}
return false;
}
// 原有的最高分辨率逻辑
if (this.config.isEnabled('autoHighRes')) {
if (options.length > 0 && !options[0].element.classList.contains("selected")) {
const bestOption = options[0];
bestOption.element.click();
console.log(`已切换至最高分辨率: ${bestOption.element.textContent}`);
if (bestOption.text.includes("4K")) {
this.config.setEnabled('autoHighRes', false);
UIManager.updateToggleButtons('auto-high-resolution-button', false);
console.log("已找到4K分辨率,自动关闭功能");
}
return true;
}
}
return false;
}
}
// ========== 主应用程序 ==========
class DouyinEnhancer {
constructor() {
this.config = new ConfigManager();
this.videoController = new VideoController();
this.uiManager = new UIManager(this.config, this.videoController);
this.aiDetector = new AIDetector(this.videoController, this.config);
this.strategies = new VideoDetectionStrategies(this.config, this.videoController);
this.lastVideoUrl = '';
this.videoStartTime = 0;
this.speedModeSkipped = false;
this.init();
}
init() {
this.injectStyles();
setInterval(() => this.mainLoop(), 300);
}
injectStyles() {
const style = document.createElement('style');
style.innerHTML = `
/* 让右侧按钮容器高度自适应,防止按钮换行时被隐藏 */
.xg-right-grid {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
/* 确保按钮容器可以正确换行显示 */
.xg-right-grid xg-icon {
display: inline-block !important;
margin: -12px 0 !important;
}
/* 防止父容器限制高度导致内容被裁剪 */
.xgplayer-controls {
overflow: visible !important;
}
/* 让控制栏底部区域高度自适应 */
.xgplayer-controls-bottom {
height: auto !important;
min-height: 50px !important;
}
`;
document.head.appendChild(style);
}
mainLoop() {
this.uiManager.insertButtons();
const activeContainer = document.querySelector(SELECTORS.activeVideo);
if (!activeContainer) {
if (this.config.isEnabled('skipLive')) {
this.videoController.skip();
}
return;
}
const videoEl = activeContainer.querySelector(SELECTORS.videoElement);
if (!videoEl || !videoEl.src) return;
const currentVideoUrl = videoEl.src;
if (this.handleNewVideo(currentVideoUrl)) {
return;
}
if (this.handleSpeedMode()) {
return;
}
if (this.handleAIDetection(videoEl)) {
return;
}
if (this.strategies.checkAd(activeContainer)) return;
if (this.strategies.checkBlockedAccount(activeContainer)) return;
this.strategies.checkResolution(activeContainer);
}
handleNewVideo(currentVideoUrl) {
if (currentVideoUrl !== this.lastVideoUrl) {
this.lastVideoUrl = currentVideoUrl;
this.videoStartTime = Date.now();
this.speedModeSkipped = false;
this.aiDetector.reset();
console.log('===== 新视频开始 =====');
if (this.config.isEnabled('speedMode')) {
const seconds = this.config.get('speedMode').seconds;
console.log(`【极速模式】已开启,${seconds}秒后自动切换`);
}
if (this.config.isEnabled('aiPreference')) {
const content = this.config.get('aiPreference').content;
console.log(`【AI喜好模式】已开启,筛选:${content}`);
}
return true;
}
return false;
}
handleSpeedMode() {
if (!this.config.isEnabled('speedMode') || this.speedModeSkipped || this.aiDetector.hasSkipped) {
return false;
}
const videoPlayTime = Date.now() - this.videoStartTime;
const seconds = this.config.get('speedMode').seconds;
if (videoPlayTime >= seconds * 1000) {
console.log(`【极速模式】视频已播放${seconds}秒,自动切换`);
this.speedModeSkipped = true;
this.videoController.skip();
return true;
}
return false;
}
handleAIDetection(videoEl) {
if (!this.config.isEnabled('aiPreference')) return false;
const videoPlayTime = Date.now() - this.videoStartTime;
if (this.aiDetector.shouldCheck(videoPlayTime)) {
if (videoEl.readyState >= 2 && !videoEl.paused) {
const timeInSeconds = (this.aiDetector.checkSchedule[this.aiDetector.currentCheckIndex] / 1000).toFixed(1);
console.log(`【AI检测】第${this.aiDetector.currentCheckIndex + 1}次检测,时间点:${timeInSeconds}秒`);
this.aiDetector.processVideo(videoEl);
return true;
}
}
if (videoPlayTime >= 10000 && !this.aiDetector.stopChecking) {
console.log('【超时停止】视频播放已超过10秒,停止AI检测');
this.aiDetector.stopChecking = true;
}
return false;
}
}
// 启动应用
const app = new DouyinEnhancer();
})();