我的js脚本
04 知乎文章下载脚本
// ==UserScript==
// @name Zhihu2Markdown
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Download Zhihu content (articles, answers, videos, columns) as Markdown
// @author Glenn
// @match *://zhuanlan.zhihu.com/p/*
// @match *://www.zhihu.com/question/*/answer/*
// @match *://www.zhihu.com/zvideo/*
// @match *://www.zhihu.com/column/*
// @match *://blog.csdn.net/*/article/*
// @match *://blog.csdn.net/*/category_*.html
// @match *://mp.weixin.qq.com/s*
// @match *://juejin.cn/post/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @grant GM_addStyle
// @require https://cdn.jsdelivr.net/npm/turndown@7.1.1/dist/turndown.js
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// Add CSS for UI elements
GM_addStyle(`
.zhihu-dl-button {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 10000;
padding: 12px 16px;
background: #0084ff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.zhihu-dl-button:hover {
background: #0077e6;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
.zhihu-dl-button:before {
content: "⬇️";
margin-right: 6px;
font-size: 16px;
}
.zhihu-dl-progress {
position: fixed;
bottom: 90px;
right: 30px;
z-index: 10000;
padding: 10px 16px;
background: white;
border: 1px solid #eee;
border-radius: 8px;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: none;
}
`);
// Get valid filename (replace invalid characters)
const getValidFilename = (str) => {
return str.replace(/[\\/:*?"<>|]/g, '_').trim();
};
// Get article date from the page
const getArticleDate = (selector) => {
const dateElement = document.querySelector(selector);
if (!dateElement) return '';
const dateText = dateElement.textContent.trim();
const match = dateText.match(/(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : '';
};
// Create a Turndown service instance for HTML to Markdown conversion
const createTurndownService = () => {
const service = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-'
});
// Custom rules for Zhihu content
// Handle math formulas
service.addRule('mathFormulas', {
filter: (node) => {
return node.nodeName === 'SPAN' &&
node.classList.contains('ztext-math') &&
node.hasAttribute('data-tex');
},
replacement: (content, node) => {
const formula = node.getAttribute('data-tex');
if (formula.includes('\\tag')) {
return `\n$$${formula}$$\n`;
} else {
return `$${formula}$`;
}
}
});
// Improve heading handling
service.addRule('headings', {
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
replacement: function (content, node) {
const level = Number(node.nodeName.charAt(1));
return `\n${'#'.repeat(level)} ${content}\n\n`;
}
});
// Handle tables
service.addRule('tables', {
filter: ['table'],
replacement: function(content, node) {
// Create arrays to store each row of the table
const rows = Array.from(node.querySelectorAll('tr'));
if (rows.length === 0) return content;
// Process each row
const markdownRows = rows.map(row => {
// Get all cells in the row (th or td)
const cells = Array.from(row.querySelectorAll('th, td'));
// Convert each cell to text and trim whitespace
return '| ' + cells.map(cell => {
const cellText = cell.textContent.trim().replace(/\n/g, ' ');
return cellText || ' ';
}).join(' | ') + ' |';
});
// If the first row contains th elements, add a separator row
if (rows[0] && rows[0].querySelector('th')) {
const headerCells = Array.from(rows[0].querySelectorAll('th'));
const separatorRow = '| ' + headerCells.map(() => '---').join(' | ') + ' |';
markdownRows.splice(1, 0, separatorRow);
} else if (rows.length > 0) {
// If no header row but we have rows, add a separator after the first row anyway
const firstRowCells = Array.from(rows[0].querySelectorAll('td')).length;
const separatorRow = '| ' + Array(firstRowCells).fill('---').join(' | ') + ' |';
markdownRows.splice(1, 0, separatorRow);
}
return '\n\n' + markdownRows.join('\n') + '\n\n';
}
});
return service;
};
// Process content for download
const processContent = (title, contentElement, author, date, url) => {
if (!contentElement) {
throw new Error('Content element not found');
}
// Clone the node to prevent modifying the page
const content = contentElement.cloneNode(true);
// Remove style tags
content.querySelectorAll('style').forEach(style => style.remove());
// Remove lazy loaded images
content.querySelectorAll('img.lazy').forEach(img => img.remove());
let markdown;
// Try to use TurndownService if available, otherwise use our simple converter
if (isTurndownServiceAvailable()) {
showProgress('Converting with TurndownService...');
const turndownService = createTurndownService();
markdown = turndownService.turndown(content.innerHTML);
} else {
showProgress('Using fallback converter...');
// Pre-process for our simple converter
markdown = simpleHtmlToMarkdown(content.innerHTML);
}
// Create the full markdown document
let fullMarkdown = `# ${title}\n\n`;
fullMarkdown += `**Author:** ${author}\n\n`;
if (date) {
fullMarkdown += `**Date:** ${date}\n\n`;
}
fullMarkdown += `**Link:** ${url}\n\n`;
fullMarkdown += markdown;
return fullMarkdown;
};
// Download markdown function
const downloadMarkdownFile = (title, author, markdown, date) => {
const filename = date ?
getValidFilename(`(${date})${title}_${author}.md`) :
getValidFilename(`${title}_${author}.md`);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// Clean up
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
return filename;
};
// Download article function
const downloadArticle = async () => {
try {
showProgress('Processing article...');
const title = document.querySelector('h1.Post-Title')?.textContent.trim() || 'Untitled';
const content = document.querySelector('div.Post-RichTextContainer');
const author = document.querySelector('div.AuthorInfo meta[itemprop="name"]')?.getAttribute('content') || 'Unknown';
const date = getArticleDate('div.ContentItem-time');
const url = window.location.href;
if (!content) {
throw new Error('Could not find content on this page');
}
// Process content
const markdown = processContent(title, content, author, date, url);
// Download the markdown
const filename = downloadMarkdownFile(title, author, markdown, date);
showProgress(`Downloaded: ${filename}`, 3000);
} catch (error) {
console.error('Error downloading article:', error);
showProgress(`Error: ${error.message}`, 3000);
}
};
// Download answer function
const downloadAnswer = async () => {
try {
showProgress('Processing answer...');
const title = document.querySelector('h1.QuestionHeader-title')?.textContent.trim() || 'Untitled';
const content = document.querySelector('div.RichContent-inner');
const author = document.querySelector('div.AuthorInfo meta[itemprop="name"]')?.getAttribute('content') || 'Unknown';
const date = getArticleDate('div.ContentItem-time');
const url = window.location.href;
if (!content) {
throw new Error('Could not find content on this page');
}
// Process content
const markdown = processContent(title, content, author, date, url);
// Download the markdown
const filename = downloadMarkdownFile(title, author, markdown, date);
showProgress(`Downloaded: ${filename}`, 3000);
} catch (error) {
console.error('Error downloading answer:', error);
showProgress(`Error: ${error.message}`, 3000);
}
};
// Download video function
const downloadVideo = async () => {
try {
showProgress('Processing video...');
const videoDataElement = document.querySelector('div.ZVideo-video');
if (!videoDataElement) {
throw new Error('Could not find video data');
}
const videoData = JSON.parse(videoDataElement.getAttribute('data-zop') || '{}');
const title = videoData.title || 'Untitled Video';
const author = videoData.authorName || 'Unknown';
const date = getArticleDate('div.ZVideo-meta');
const url = window.location.href;
// For videos, we need to extract the video URL
const scriptContent = document.querySelector('script#js-initialData')?.textContent;
if (!scriptContent) {
throw new Error('Could not find video data script');
}
const data = JSON.parse(scriptContent);
const videoId = window.location.pathname.split('/').pop();
let videoUrl = null;
try {
const videos = data.initialState.entities.zvideos;
if (videos && videos[videoId] && videos[videoId].video && videos[videoId].video.playlist) {
const playlist = videos[videoId].video.playlist;
// Get the highest quality video
const qualities = Object.keys(playlist);
videoUrl = playlist[qualities[0]].playUrl;
}
} catch (error) {
console.error('Error extracting video URL:', error);
}
if (!videoUrl) {
throw new Error('Could not find video URL');
}
// Create a markdown file with video information
const markdown = `# ${title}\n\n` +
`**Author:** ${author}\n\n` +
`**Date:** ${date}\n\n` +
`**Link:** ${url}\n\n` +
`**Video URL:** [Download Video](${videoUrl})\n\n` +
`Note: You can download the video by clicking the link above or copying the URL.`;
// Download markdown file
const filename = downloadMarkdownFile(title, author, markdown, date);
// Open video in new tab for downloading
window.open(videoUrl, '_blank');
showProgress(`Downloaded info: ${filename}. Video opened in new tab.`, 5000);
} catch (error) {
console.error('Error downloading video:', error);
showProgress(`Error: ${error.message}`, 3000);
}
};
// Download column function
const downloadColumn = () => {
alert('Column download is not supported in the browser extension. Please use the server application for downloading columns.');
};
// Download CSDN article function
const downloadCsdnArticle = async () => {
try {
showProgress('Processing CSDN article...');
const title = document.querySelector('h1.title-article')?.textContent.trim() || 'Untitled';
const content = document.querySelector('div#content_views');
const authorElement = document.querySelector('div.bar-content');
let author = 'Unknown';
let date = '';
if (authorElement && authorElement.querySelectorAll('a').length > 0) {
author = authorElement.querySelectorAll('a')[0].textContent.trim();
// Try to get date from time element or text content
const timeElement = authorElement.querySelector('span.time');
if (timeElement) {
const dateMatch = timeElement.textContent.match(/(\d{4}-\d{2}-\d{2})/);
date = dateMatch ? dateMatch[1] : '';
}
}
const url = window.location.href;
if (!content) {
throw new Error('Could not find content on this page');
}
// Process content
const markdown = processContent(title, content, author, date, url);
// Download the markdown
const filename = downloadMarkdownFile(title, author, markdown, date);
showProgress(`Downloaded: ${filename}`, 3000);
} catch (error) {
console.error('Error downloading CSDN article:', error);
showProgress(`Error: ${error.message}`, 3000);
}
};
// Download CSDN category function
const downloadCsdnCategory = () => {
alert('CSDN Category download is not supported in the browser extension. Please use the server application for downloading categories.');
};
// Download WeChat article function
const downloadWechatArticle = async () => {
try {
showProgress('Processing WeChat article...');
const title = document.querySelector('h1#activity-name')?.textContent.trim() || 'Untitled';
const content = document.querySelector('div#js_content');
// Updated author extraction - looking in meta_content div first for links
const authorElement = document.querySelector('div#meta_content');
let author = 'Unknown';
if (authorElement && authorElement.querySelectorAll('a').length > 0) {
author = authorElement.querySelectorAll('a')[0].textContent.trim();
}
// Extract date from script tags (similar to Python version)
let date = '';
try {
const scripts = document.querySelectorAll('script[type="text/javascript"]');
for (const script of scripts) {
if (script.textContent.includes('var ct =')) {
const match = script.textContent.match(/var ct = "([^"]+)"/);
if (match && match[1]) {
// Convert Unix timestamp to YYYY-MM-DD format
const timestamp = parseInt(match[1]) * 1000;
const dateObj = new Date(timestamp);
date = dateObj.toISOString().split('T')[0]; // YYYY-MM-DD
break;
}
}
}
} catch (err) {
console.error('Error extracting date:', err);
}
const url = window.location.href;
if (!content) {
throw new Error('Could not find content on this page');
}
// Process content
const markdown = processContent(title, content, author, date, url);
// Download the markdown
const filename = downloadMarkdownFile(title, author, markdown, date);
showProgress(`Downloaded: ${filename}`, 3000);
} catch (error) {
console.error('Error downloading WeChat article:', error);
showProgress(`Error: ${error.message}`, 3000);
}
};
// Download Juejin article function
const downloadJuejinArticle = async () => {
try {
showProgress('Processing Juejin article...');
const title = document.querySelector('h1.article-title')?.textContent.trim() || 'Untitled';
const content = document.querySelector('div.main');
const authorElement = document.querySelector('span.name');
let author = 'Unknown';
if (authorElement) {
author = authorElement.textContent.trim();
}
// Extract date from time element
const date = document.querySelector('time.time')?.textContent.trim() || '';
const url = window.location.href;
if (!content) {
throw new Error('Could not find content on this page');
}
// Process content
const markdown = processContent(title, content, author, date, url);
// Download the markdown
const filename = downloadMarkdownFile(title, author, markdown, date);
showProgress(`Downloaded: ${filename}`, 3000);
} catch (error) {
console.error('Error downloading Juejin article:', error);
showProgress(`Error: ${error.message}`, 3000);
}
};
// Show progress message
const showProgress = (message, timeout = 0) => {
let progress = document.querySelector('.zhihu-dl-progress');
if (!progress) {
progress = document.createElement('div');
progress.className = 'zhihu-dl-progress';
document.body.appendChild(progress);
}
progress.textContent = message;
progress.style.display = 'block';
if (timeout > 0) {
setTimeout(() => {
progress.style.display = 'none';
}, timeout);
}
};
// Simple HTML to Markdown converter as fallback if TurndownService fails to load
const simpleHtmlToMarkdown = (html) => {
let div = document.createElement('div');
div.innerHTML = html;
// Process headings
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach(tag => {
div.querySelectorAll(tag).forEach(heading => {
const level = parseInt(tag.substring(1));
const text = heading.textContent.trim();
const markdown = document.createTextNode(`\n${'#'.repeat(level)} ${text}\n\n`);
heading.parentNode.replaceChild(markdown, heading);
});
});
// Process bold text
div.querySelectorAll('strong, b').forEach(bold => {
const text = bold.textContent;
const markdown = document.createTextNode(`**${text}**`);
bold.parentNode.replaceChild(markdown, bold);
});
// Process italic text
div.querySelectorAll('em, i').forEach(italic => {
const text = italic.textContent;
const markdown = document.createTextNode(`*${text}*`);
italic.parentNode.replaceChild(markdown, italic);
});
// Process links
div.querySelectorAll('a').forEach(link => {
if (link.href) {
const text = link.textContent || link.href;
const markdown = document.createTextNode(`[${text}](${link.href})`);
link.parentNode.replaceChild(markdown, link);
}
});
// Process images
div.querySelectorAll('img').forEach(img => {
if (img.src) {
const alt = img.alt || 'image';
const markdown = document.createTextNode(`\n\n`);
img.parentNode.replaceChild(markdown, img);
}
});
// Process paragraphs
div.querySelectorAll('p').forEach(p => {
const text = p.innerHTML.trim();
if (text) {
p.innerHTML = text + '\n\n';
}
});
// Process code blocks
div.querySelectorAll('pre').forEach(pre => {
const code = pre.textContent.trim();
const markdown = document.createTextNode(`\n\`\`\`\n${code}\n\`\`\`\n\n`);
pre.parentNode.replaceChild(markdown, pre);
});
// Process inline code
div.querySelectorAll('code').forEach(code => {
if (code.parentNode.tagName !== 'PRE') {
const text = code.textContent;
const markdown = document.createTextNode(`\`${text}\``);
code.parentNode.replaceChild(markdown, code);
}
});
return div.textContent;
};
// Function to check if TurndownService is available and working
const isTurndownServiceAvailable = () => {
try {
if (typeof TurndownService !== 'undefined') {
// Try a simple conversion to verify it works
const test = new TurndownService();
test.turndown('<p>test</p>');
return true;
}
return false;
} catch (error) {
console.error('TurndownService check failed:', error);
return false;
}
};
// Handle download based on page type
const handleDownload = () => {
const url = window.location.href;
if (url.includes('zhuanlan.zhihu.com/p/')) {
downloadArticle();
} else if (url.includes('zhihu.com/question/') && url.includes('/answer/')) {
downloadAnswer();
} else if (url.includes('zhihu.com/zvideo/')) {
downloadVideo();
} else if (url.includes('zhihu.com/column/')) {
downloadColumn();
} else if (url.includes('blog.csdn.net') && url.includes('/article/')) {
downloadCsdnArticle();
} else if (url.includes('blog.csdn.net') && url.includes('/category_')) {
downloadCsdnCategory();
} else if (url.includes('mp.weixin.qq.com/s')) {
downloadWechatArticle();
} else if (url.includes('juejin.cn/post/')) {
downloadJuejinArticle();
} else {
alert('This page type is not supported for download.');
}
};
// Add download button
const addDownloadButton = () => {
// Remove any existing buttons first
const existingButton = document.querySelector('.zhihu-dl-button');
if (existingButton) {
existingButton.remove();
}
const button = document.createElement('button');
button.textContent = 'Download as Markdown';
button.className = 'zhihu-dl-button';
button.addEventListener('click', handleDownload);
document.body.appendChild(button);
};
// Initialize
const init = () => {
// Add button after a short delay to ensure page is loaded
setTimeout(addDownloadButton, 1500);
// Re-add button when URL changes (for SPA navigation)
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
setTimeout(addDownloadButton, 1500);
}
}).observe(document, {subtree: true, childList: true});
};
init();
})();
03 抖音小红书-ai评论获取
https://github.com/Kerinlin/EchoDistill/blob/main/ai-comment-summarizer.user.js
// ==UserScript==
// @name AI 评论总结助手
// @namespace http://tampermonkey.net/
// @version 1.8.1
// @description 自动抓取当前页面的评论并使用 AI 进行总结(Markdown + 主题摘要 + AI 独立洞察)
// @author Kerinlin
// @match *://*.youtube.com/*
// @match *://*.reddit.com/*
// @match *://*.bilibili.com/*
// @match *://*.zhihu.com/*
// @match *://*.xiaohongshu.com/*
// @match *://*.twitter.com/*
// @match *://*.x.com/*
// @match *://news.ycombinator.com/*
// @match https://linux.do/*
// @match https://*.linux.do/*
// @match https://tieba.baidu.com/*
// @match https://*.douyin.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js
// @connect *
// @connect localhost
// @connect 127.0.0.1
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// === 基础工具:trustedTypes 策略 + 安全 HTML 注入 ===
// CSP 严格站点(如 GitHub、Hacker News)要求 trustedTypes 才能写 innerHTML;
// 创建一次性 policy 后所有 innerHTML 写入都走 safeHTML,无 policy 则直接赋值。
const _ttPolicy = (typeof trustedTypes !== 'undefined' && trustedTypes.createPolicy)
? trustedTypes.createPolicy('ai-comment-summarizer', { createHTML: s => s })
: null;
function safeHTML(el, html) {
if (_ttPolicy) el.innerHTML = _ttPolicy.createHTML(html);
else el.innerHTML = html;
}
// === 配置与 Prompt 常量 ===
const CONFIG_KEY = 'ai_comment_summarizer_config';
const DEFAULT_PROMPT = `你是一位资深的社区舆情分析师。请阅读我提供的「帖子标题 + 评论列表」,输出一份结构化的中文分析报告。
【输出格式(严格使用 Markdown,不要输出任何多余的解释或开场白)】
## 讨论主题
用 1-2 句话概括本次讨论的核心议题,需结合标题与评论整体判断,避免复述标题。
## 核心观点
列出评论中反复出现、最具代表性的观点(3-6 条),格式:
- 一句话观点:补充说明该观点的依据或背景(可标注大致占比 / 热度,如「多数人认为」「少数声音」)。
## 共识
- 评论者普遍认同的 2-4 个点,尽量具体,不要空泛。
## 分歧与争议
列出 2-4 个存在对立的话题,每个话题单独成段,严格按以下格式输出(正方、反方各占一行,不要合并):
- 话题标题:简述争议点。
- 正方:正方核心立场,1 句话。
- 反方:反方核心立场,1 句话。
## 情绪与风向
用 2-4 个关键词概括整体情绪(如:期待、吐槽、质疑、玩梗……),再用 1-2 句话说明原因;若情绪有明显变化或分裂,也请点明。
## 高赞金句
挑选 1-3 条最有代表性、信息量或话题度的原文,用引用块展示(保留原文语言),并在括号内标注点赞数:
> 原文内容(👍 点赞数)
【硬性要求】
1. 全程使用简体中文输出(金句保留原文)。
2. 严格使用 Markdown 语法(##、-、**、>)。
3. 只输出中立归纳,不要给出个人立场或判断(独立判断由系统另行生成)。
4. 不要客气、不要奉承、不要"以上只是个人浅见"之类的自我消解。
5. 证据不足的部分可如实说明「样本有限」。`;
const INSIGHT_PROMPT = `你是对抗性测试员,不是反方辩手。系统已完成对某帖子评论的中立归纳,现在请对它执行对抗性测试:尝试挑翻高赞、挖出群体盲区,再基于测试结果和全部证据下判断。判断方向不预设——证据支持主流就认同,不支持就反对,但不许为了对抗而对抗。
【输出格式(严格 Markdown,不要任何开场白,不要输出顶层 # 或 ## 标题,直接从 ### 开始)】
### 质疑高赞
挑出归纳中最被高赞但不成立(或有重大遗漏)的一条,1-2 句话直接驳它。如果高赞都站得住,如实说"未找到明显漏洞"。
### 指出盲点
评论中集体遗漏的一个变量(事实、视角、风险),1-2 句话说清。
### AI 评论
基于以上测试和全部证据,1-2 句话给出你的真实判断。可以是认同主流、反对主流、或综合判断,但必须押注一方,不许骑墙、不许"从多个角度看"、不许"仁者见仁"。
### 信心程度
用「高 / 中 / 低」三档之一标注你对上述判断的自信度,格式:信心程度:高
【硬性要求】
1. 全程使用简体中文。
2. 仅基于归纳内容推理,不引入归纳之外的未知事实。
3. 不要客气、不要自我消解、不要"以上仅供参考"。
4. 判断方向不预设——证据支持主流就认同,证据不支持就反对,不允许为了对抗而对抗。`;
// 默认配置:首次安装时的回退值,apiKey/apiEndpoint 为本地代理默认值。
const DEFAULT_CONFIG = {
apiKey: 'sk-sOisCHwyDG3GIhUvL',
apiEndpoint: 'http://localhost:8317',
model: 'MiniMax-M3',
maxComments: 500,
minLikes: 0,
customPrompt: DEFAULT_PROMPT
};
// HTML 转义:md 渲染前先转义文本,避免 XSS / 显示乱码
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
// 极简 Markdown → HTML 渲染器(无依赖)
// 流程:抽取 code block/inline 占位 → escape → 块级元素(标题/引用/列表/分隔线)→ 行内(粗斜体/链接)→ 段落 → 还原 code 占位
// 用 \x00.. 占位符避免内联代码被 markdown 语法误伤
function md(input) {
if (!input) return '';
const codeBlocks = [];
input = input.replace(/```([\s\S]*?)```/g, (_, code) => {
codeBlocks.push(code);
return '\x00CB' + (codeBlocks.length - 1) + '\x00';
});
const inlineCodes = [];
input = input.replace(/`([^\n]+)`/g, (_, code) => {
inlineCodes.push(code);
return '\x00IC' + (inlineCodes.length - 1) + '\x00';
});
let html = escapeHtml(input);
// 标题:从 h6 → h1 依次匹配,避免 # 前缀被更高层级规则吞掉
html = html.replace(/^######\s?(.*)$/gm, '<h6>$1</h6>')
.replace(/^#####\s?(.*)$/gm, '<h5>$1</h5>')
.replace(/^####\s?(.*)$/gm, '<h4>$1</h4>')
.replace(/^###\s?(.*)$/gm, '<h3>$1</h3>')
.replace(/^##\s?(.*)$/gm, '<h2>$1</h2>')
.replace(/^#\s?(.*)$/gm, '<h1>$1</h1>');
html = html.replace(/^\s*---\s*$/gm, '<hr>');
// 引用块:连续 > 开头行合并为 blockquote,内部用 <br> 换行
html = html.replace(/(^|\n)((?:> .*(?:\n|$))+)/g, (m, pre, block) => {
const inner = block.trim().split('\n').map(l => l.replace(/^>\s?/, '')).join('<br>');
return pre + '<blockquote>' + inner + '</blockquote>';
});
// 无序列表:连续 - 或 * 开头行合并为 ul/li
html = html.replace(/(^|\n)((?:[-*]\s+.*(?:\n|$))+)/g, (m, pre, block) => {
const items = block.trim().split('\n').map(l => '<li>' + l.replace(/^[-*]\s+/, '') + '</li>').join('');
return pre + '<ul>' + items + '</ul>';
});
// 行内:粗体 → 斜体(避免吞粗体的 **)→ 删除线
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
// 链接:仅 http(s),强制 target=_blank + rel=noopener 防止反向 tabnabbing
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>');
// 段落:以空行分隔的文本块包 <p>,已识别的块级标签直接保留
html = html.split(/\n{2,}/).map(p => {
p = p.trim();
if (!p) return '';
if (/^<(h\d|ul|ol|blockquote|hr|pre)/.test(p)) return p;
return '<p>' + p.replace(/\n/g, '<br>') + '</p>';
}).join('\n');
// 最后还原 code 占位为 <pre><code> / <code>,对代码内容再做一次 escape
html = html.replace(/\x00CB(\d+)\x00/g, (_, i) =>
'<pre><code>' + escapeHtml(codeBlocks[+i]) + '</code></pre>');
html = html.replace(/\x00IC(\d+)\x00/g, (_, i) =>
'<code>' + escapeHtml(inlineCodes[+i]) + '</code>');
return html;
}
// 配置持久化:合并默认值 + 用户存储,避免老版本缺字段崩溃
function loadConfig() {
try { return Object.assign({}, DEFAULT_CONFIG, JSON.parse(GM_getValue(CONFIG_KEY, '{}'))); }
catch { return Object.assign({}, DEFAULT_CONFIG); }
}
function saveConfig(cfg) { GM_setValue(CONFIG_KEY, JSON.stringify(cfg)); }
let config = loadConfig();
// === 全局样式注入 ===
// 配色系统:parchment 纸感(#faf5ea 底 / #f5efe0 区块 / #e0d5c0 描边)
// + ink-blue 主色(#1B365D)+ 红砖强调(#8b3a1f 用于 insight/警告)
// 分区:FAB 按钮 / panel 容器 / 头部+话题+正文 / 骨架屏+加载态 /
// 操作行按钮 / 对抗性审视 insight 块 / 正文 markdown 排版 /
// 设置面板 / 模型下拉 / 复制方式弹窗
GM_addStyle(`
#ai-fab{position:fixed;bottom:24px;right:24px;z-index:999999;width:52px;height:52px;border-radius:50%;background:#1B365D;color:#faf5ea;border:1px solid #14263f;cursor:pointer;box-shadow:0 2px 8px rgba(27,54,93,.22);font-size:22px;display:flex;align-items:center;justify-content:center;transition:transform .2s,box-shadow .2s;font-family:Georgia,"Songti SC","STSong",serif}
#ai-fab:hover{transform:scale(1.06);box-shadow:0 4px 14px rgba(27,54,93,.32)}
#ai-fab:disabled{opacity:.5;cursor:wait}
#ai-panel{position:fixed;bottom:86px;right:24px;z-index:999999;width:460px;max-width:calc(100vw - 40px);max-height:76vh;background:#faf5ea;color:#2a2520;border-radius:4px;box-shadow:0 10px 32px rgba(42,37,32,.18);display:none;flex-direction:column;overflow:hidden;font-family:Georgia,"Songti SC","STSong","Source Han Serif SC",serif;border:1px solid #e0d5c0;animation:ai-pop .18s ease-out}
@keyframes ai-pop{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
#ai-panel.open{display:flex}
#ai-panel .ai-hd{padding:12px 16px;background:#f5efe0;border-bottom:1px solid #e0d5c0;display:flex;justify-content:space-between;align-items:center;font-weight:600;font-size:14px;min-height:44px;letter-spacing:.01em}
#ai-panel .ai-hd .ai-title{display:flex;align-items:center;gap:7px;line-height:1;color:#1B365D}
#ai-panel .ai-hd .ai-title em{font-style:normal;font-size:11px;background:#1B365D;color:#faf5ea;padding:2px 7px;border-radius:2px;line-height:1.4;display:inline-flex;align-items:center;font-family:Georgia,serif}
#ai-panel .ai-hd .ai-actions{display:flex;align-items:center;gap:5px}
#ai-panel .ai-hd button{background:transparent;border:1px solid transparent;color:#5a5248;cursor:pointer;font-size:13px;width:27px;height:27px;border-radius:3px;display:inline-flex;align-items:center;justify-content:center;transition:background .15s,border-color .15s;font-family:inherit}
#ai-panel .ai-hd button:hover{background:#faf5ea;border-color:#e0d5c0;color:#1B365D}
#ai-panel .ai-meta{padding:10px 16px;background:#faf5ea;border-bottom:1px solid #ecd9b8;font-size:12px;color:#8a7f70;display:flex;align-items:center;gap:8px;font-style:italic}
#ai-panel .ai-meta .ai-topic{flex:1;min-width:0;color:#3a342c;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-style:normal}
#ai-panel .ai-meta .ai-topic::before{content:"§ "}
#ai-panel .ai-body{padding:18px 22px;overflow-y:auto;line-height:1.75;font-size:14px;flex:1;min-height:150px;color:#3a342c;scrollbar-width:thin;scrollbar-color:#c9bea8 transparent}
#ai-panel .ai-body::-webkit-scrollbar{width:8px}
#ai-panel .ai-body::-webkit-scrollbar-thumb{background:#c9bea8;border-radius:4px}
#ai-panel .ai-body.status{display:flex;align-items:center;justify-content:center;color:#8a7f70;font-style:italic;text-align:center;padding:40px 24px}
#ai-panel .ai-skeleton{display:flex;flex-direction:column;gap:11px}
#ai-panel .ai-skeleton .ai-bar{height:11px;background:linear-gradient(90deg,#ece3d0 25%,#dfd4bc 50%,#ece3d0 75%);background-size:200% 100%;border-radius:2px;animation:sk-shine 1.4s infinite}
#ai-panel .ai-skeleton .ai-bar.w70{width:70%}
#ai-panel .ai-skeleton .ai-bar.w90{width:90%}
#ai-panel .ai-skeleton .ai-bar.w50{width:50%}
@keyframes sk-shine{0%{background-position:200% 0}100%{background-position:-200% 0}}
#ai-panel .ai-actions-row{padding:12px 16px;border-top:1px solid #e0d5c0;display:flex;gap:10px;background:#f5efe0}
#ai-panel .ai-actions-row button{flex:1;display:flex;align-items:center;justify-content:center;gap:5px;padding:9px 10px;border:1px solid #e0d5c0;border-radius:3px;cursor:pointer;font-size:13.5px;font-weight:500;line-height:1;transition:background .15s,transform .1s;font-family:inherit}
#ai-panel .ai-actions-row button:active{transform:scale(.97)}
#ai-panel .ai-actions-row button.ai-primary{background:#1B365D;color:#faf5ea;border-color:#14263f}
#ai-panel .ai-actions-row button.ai-primary:hover{background:#14263f}
#ai-panel .ai-actions-row button.ai-ghost{background:#faf5ea;color:#3a342c}
#ai-panel .ai-actions-row button.ai-ghost:hover{background:#f0e9d8}
#ai-panel .ai-actions-row button.ai-stopping{background:#8b3a1f;color:#faf5ea;border-color:#6b2c17;animation:ai-pulse 1.2s ease-in-out infinite}
#ai-panel .ai-actions-row button.ai-stopping:hover{background:#6b2c17}
@keyframes ai-pulse{0%,100%{opacity:1}50%{opacity:.72}}
.ai-insight{margin:18px -22px 0;padding:14px 22px 16px;background:#fbf2e8;border-top:1px dashed #d4a574;border-bottom:1px dashed #d4a574;position:relative}
.ai-insight::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:#8b3a1f}
.ai-insight .ai-insight-tag{display:inline-flex;align-items:center;gap:5px;background:#8b3a1f;color:#faf5ea;padding:3px 9px;border-radius:2px;font-size:11px;font-weight:600;letter-spacing:.05em;margin-bottom:10px;font-family:Georgia,serif}
.ai-insight .ai-insight-notice{font-size:11.5px;color:#8b3a1f;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid rgba(139,58,31,.18)}
.ai-insight h2{color:#8b3a1f !important;margin-top:14px}
.ai-insight h2:first-child{margin-top:0}
.ai-insight h2::before{background:#8b3a1f !important}
.ai-insight li::before{color:#8b3a1f !important}
.ai-insight blockquote{border-left-color:#8b3a1f !important;background:#f5e8d8 !important}
#ai-panel .ai-body h1,#ai-panel .ai-body h2,#ai-panel .ai-body h3{margin:16px 0 8px;font-weight:700;line-height:1.3;letter-spacing:.005em}
#ai-panel .ai-body h1{font-size:18px;color:#1B365D;border-bottom:1px solid #e0d5c0;padding-bottom:5px}
#ai-panel .ai-body h2{font-size:15.5px;color:#1B365D;display:flex;align-items:center;gap:7px;margin-top:18px}
#ai-panel .ai-body h2::before{content:"";display:inline-block;width:3px;height:14px;background:#1B365D;flex-shrink:0}
#ai-panel .ai-body h2:first-child{margin-top:0}
#ai-panel .ai-body h3{font-size:14px;color:#2c5278}
#ai-panel .ai-body p{margin:7px 0}
#ai-panel .ai-body ul{margin:8px 0;padding-left:22px}
#ai-panel .ai-body li{margin:6px 0;list-style:none;position:relative;padding-left:4px}
#ai-panel .ai-body li::before{content:"▸";color:#1B365D;position:absolute;left:-15px;top:0}
#ai-panel .ai-body strong{color:#8b3a1f;font-weight:700}
#ai-panel .ai-body em{color:#2c5278;font-style:italic}
#ai-panel .ai-body blockquote{margin:10px 0;padding:10px 14px;background:#f5efe0;border-left:3px solid #1B365D;border-radius:0 2px 2px 0;color:#5a5248;font-style:italic}
#ai-panel .ai-body hr{border:none;border-top:1px dashed #c9bea8;margin:16px 0}
#ai-panel .ai-body code{background:#f0e9d8;padding:2px 6px;border-radius:2px;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:#8b3a1f;border:1px solid #e0d5c0}
#ai-panel .ai-body pre{background:#f5efe0;padding:12px;border-radius:3px;overflow-x:auto;border:1px solid #e0d5c0}
#ai-panel .ai-body pre code{background:none;border:none;color:#3a342c}
#ai-panel .ai-body a{color:#1B365D;text-decoration:none;border-bottom:1px solid rgba(27,54,93,.4)}
#ai-panel .ai-body a:hover{border-bottom-color:#1B365D}
#ai-panel .ai-body del{color:#a89e8a}
#ai-settings{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:1000000;width:520px;max-width:calc(100vw - 40px);max-height:82vh;overflow:auto;background:#faf5ea;color:#2a2520;border-radius:4px;box-shadow:0 14px 44px rgba(42,37,32,.24);padding:24px;display:none;font-family:Georgia,"Songti SC","STSong","Source Han Serif SC",serif;border:1px solid #e0d5c0}
#ai-settings.open{display:block}
#ai-settings h3{margin-top:0;color:#1B365D;border-bottom:1px solid #e0d5c0;padding-bottom:8px;font-size:16px}
#ai-settings label{display:block;margin:12px 0 5px;font-size:13px;color:#5a5248;font-style:italic}
#ai-settings input,#ai-settings textarea{width:100%;padding:9px 11px;background:#fffdf7;color:#2a2520;border:1px solid #e0d5c0;border-radius:3px;font-size:13px;box-sizing:border-box;font-family:inherit;transition:border-color .15s,box-shadow .15s}
#ai-settings input:focus,#ai-settings textarea:focus{outline:none;border-color:#1B365D;box-shadow:0 0 0 2px rgba(27,54,93,.12)}
#ai-settings textarea{min-height:200px;resize:vertical;font-family:ui-monospace,Menlo,monospace;line-height:1.6}
#ai-settings .ai-row{display:flex;gap:12px}
#ai-settings .ai-row>div{flex:1}
#ai-settings .ai-footer{margin-top:20px;display:flex;gap:8px;justify-content:flex-end}
#ai-settings button{padding:9px 18px;border:1px solid #e0d5c0;border-radius:3px;cursor:pointer;font-size:13px;font-family:inherit;transition:background .15s;display:inline-flex;align-items:center;justify-content:center;line-height:1;vertical-align:middle;box-sizing:border-box}
#ai-settings .ai-save{background:#1B365D;color:#faf5ea;border-color:#14263f}
#ai-settings .ai-save:hover{background:#14263f}
#ai-settings .ai-cancel{background:#faf5ea;color:#3a342c}
#ai-settings .ai-cancel:hover{background:#f0e9d8}
#ai-settings .ai-reset{background:#faf5ea;color:#8b3a1f;border-color:#d4b8a8;margin-right:auto}
#ai-settings .ai-reset:hover{background:#f5efe0}
.ai-model-dropdown{position:absolute;left:0;right:0;top:100%;max-height:200px;overflow-y:auto;background:#fffdf7;border:1px solid #e0d5c0;border-top:none;border-radius:0 0 3px 3px;box-shadow:0 4px 12px rgba(42,37,32,.12);z-index:10;display:none}
.ai-model-item{padding:7px 11px;cursor:pointer;font-size:13px;color:#2a2520}
.ai-model-item:hover{background:#f0e9d8}
.ai-model-loading,.ai-model-error{font-style:italic;color:#8a7f70;cursor:default}
.ai-model-loading:hover,.ai-model-error:hover{background:transparent}
#ai-copy-modal{position:fixed;inset:0;z-index:1000001;display:none;align-items:center;justify-content:center;background:rgba(42,37,32,.42);backdrop-filter:blur(2px);animation:ai-pop .16s ease-out}
#ai-copy-modal.open{display:flex}
#ai-copy-modal .ai-cm-card{position:relative;width:280px;max-width:calc(100vw - 40px);background:#faf5ea;color:#2a2520;border-radius:5px;box-shadow:0 14px 44px rgba(42,37,32,.28);padding:22px 20px 18px;border:1px solid #e0d5c0;font-family:Georgia,"Songti SC","STSong","Source Han Serif SC",serif}
#ai-copy-modal .ai-cm-title{font-size:14px;font-weight:600;color:#1B365D;text-align:center;margin-bottom:16px;letter-spacing:.02em}
#ai-copy-modal .ai-cm-btn{width:100%;display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 12px;border:1px solid #e0d5c0;border-radius:3px;cursor:pointer;font-size:13.5px;font-weight:500;line-height:1;font-family:inherit;transition:background .15s,transform .1s;margin-bottom:10px}
#ai-copy-modal .ai-cm-btn:last-of-type{margin-bottom:0}
#ai-copy-modal .ai-cm-btn:active{transform:scale(.97)}
#ai-copy-modal .ai-cm-text{background:#1B365D;color:#faf5ea;border-color:#14263f}
#ai-copy-modal .ai-cm-text:hover{background:#14263f}
#ai-copy-modal .ai-cm-image{background:#faf5ea;color:#3a342c}
#ai-copy-modal .ai-cm-image:hover{background:#f0e9d8}
#ai-copy-modal .ai-cm-x{position:absolute;top:8px;right:10px;width:24px;height:24px;border:none;background:transparent;color:#8a7f70;font-size:16px;cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center;border-radius:3px}
#ai-copy-modal .ai-cm-x:hover{background:#f0e9d8;color:#1B365D}
`);
// === 平台爬虫集合 ===
// 每个方法同步抓取当前 DOM 上的评论,返回统一结构 { author, text, likes }。
// 特殊字段:zhihu.accepted(被采纳答案)、hackernews.depth(嵌套层级)、linuxdo.isOP(楼主标记)。
// 复杂平台(Twitter、Linux.do)支持外部 __ai_*_comments 缓存,由 autoLoad* 异步填充。
const Scrapers = {
youtube() {
return Array.from(document.querySelectorAll('ytd-comment-thread-renderer')).map(el => ({
author: (el.querySelector('#author-text')?.textContent || '').trim(),
text: (el.querySelector('#content-text')?.innerText || '').trim(),
likes: parseInt(el.querySelector('#vote-count-middle')?.textContent || '0', 10) || 0
})).filter(c => c.text);
},
reddit() {
return Array.from(document.querySelectorAll('shreddit-comment')).map(el => ({
author: el.getAttribute('author') || '',
text: (el.querySelector('[slot="comment"]')?.innerText || el.querySelector('div[md]')?.innerText || '').trim(),
likes: parseInt(el.getAttribute('score') || '0', 10) || 0
})).filter(c => c.text);
},
bilibili() {
const root = document.querySelector('bili-comments')?.shadowRoot;
if (!root) return [];
return Array.from(root.querySelectorAll('bili-comment-thread-renderer')).map(t => {
const comment = t?.shadowRoot?.querySelector('bili-comment-renderer');
const cr = comment?.shadowRoot;
if (!cr) return { author: '', text: '', likes: 0 };
const userInfo = cr.querySelector('#header bili-comment-user-info');
const author = userInfo?.shadowRoot?.querySelector('#user-name')?.textContent?.trim()
|| userInfo?.textContent?.trim() || '';
const rich = cr.querySelector('#content bili-rich-text');
const text = rich?.shadowRoot?.querySelector('#contents')?.textContent?.trim()
|| rich?.textContent?.trim() || '';
const actions = cr.querySelector('#footer bili-comment-action-buttons-renderer');
const likes = parseInt(actions?.shadowRoot?.querySelector('#like')?.textContent?.trim() || '0', 10) || 0;
return { author, text, likes };
}).filter(c => c.text);
},
zhihu() {
return Array.from(document.querySelectorAll('.ContentItem.AnswerItem')).map(el => {
const authorEl = el.querySelector('.AuthorInfo-name, .UserLink-link, a[href*="/people/"]');
const contentEl = el.querySelector('.RichText, .AnswerContent .RichText, .ContentItem.RichText');
const voteBtn = el.querySelector('button[aria-label^="赞同"]');
let likes = 0;
if (voteBtn) {
const m = (voteBtn.getAttribute('aria-label') || voteBtn.innerText || '').match(/\d+/);
if (m) likes = parseInt(m[0], 10) || 0;
}
return {
author: (authorEl?.textContent || '').trim() || '匿名',
text: (contentEl?.innerText || '').trim(),
likes,
accepted: el.getAttribute('itemprop') === 'acceptedAnswer'
};
}).filter(c => c.text);
},
xiaohongshu() {
const items = Array.from(document.querySelectorAll('.comment-item, .comment-inner, .comment-item-sub'));
const seen = new Set();
return items.map(el => {
const author = (el.querySelector('.user-name, .author, .author-wrapper [class*="name"]')?.textContent || '').trim();
const text = (el.querySelector('.content, .note-text, .text, [class*="content"]')?.innerText || '').trim();
if (!text || seen.has(text)) return { author: '', text: '', likes: 0 };
seen.add(text);
const likeEl = el.querySelector('.like-count, .like, [class*="like"]');
const likes = parseInt(likeEl?.textContent || '0', 10) || 0;
return { author, text, likes };
}).filter(c => c.text);
},
twitter() {
if (window.__ai_twitter_comments) return window.__ai_twitter_comments;
return Array.from(document.querySelectorAll('article[data-testid="tweet"]')).map(el => {
const userNameEl = el.querySelector('[data-testid="User-Name"]');
let author = '';
if (userNameEl) {
const firstLink = userNameEl.querySelector('a span') || userNameEl.querySelector('a');
if (firstLink) author = firstLink.textContent.trim();
if (!author || author.startsWith('@')) {
author = userNameEl.textContent
.replace(/@[A-Za-z0-9_]+/, '')
.replace(/·\s*[\dhms]+(\s*(AM|PM|hours?|hrs?|mins?|minutes?|days?))?\s*$/i, '')
.replace(/·\s*\d+[hm]\s*$/i, '')
.trim();
}
}
const textEl = el.querySelector('[data-testid="tweetText"]');
let text = '';
if (textEl) {
text = textEl.innerText.trim();
} else {
const langEls = el.querySelectorAll('[lang]');
text = [...langEls].map(l => l.textContent.trim()).join(' ').trim();
}
const likeAria = el.querySelector('[data-testid="like"]')?.getAttribute('aria-label') || '';
let likes = 0;
const lm = likeAria.match(/([\d,.]+)\s*Likes?/i);
if (lm) {
const raw = lm[1].replace(/,/g, '');
if (/k$/i.test(raw)) likes = Math.round(parseFloat(raw) * 1000);
else if (/m$/i.test(raw)) likes = Math.round(parseFloat(raw) * 1000000);
else likes = parseInt(raw, 10) || 0;
}
return { author, text, likes };
}).filter(c => c.text);
},
hackernews() {
return Array.from(document.querySelectorAll('tr.athing.comtr')).map(el => {
const author = el.querySelector('.hnuser')?.textContent?.trim() || '';
const text = el.querySelector('.commtext')?.innerText?.trim() || '';
const scoreText = el.querySelector('.score')?.textContent || '';
const likes = parseInt(scoreText.replace(/[^\d]/g, ''), 10) || 0;
const indent = el.querySelector('.ind')?.getAttribute('indent') || '0';
const depth = parseInt(indent, 10) || 0;
return { author, text, likes, depth };
}).filter(c => c.text);
},
linuxdo() {
if (window.__ai_linuxdo_comments) return window.__ai_linuxdo_comments;
return Array.from(document.querySelectorAll('.topic-post')).map(p => {
const cooked = p.querySelector('.cooked');
const user = p.querySelector('a[data-user-card]');
const counter = p.querySelector('.discourse-reactions-counter .reactions-counter');
return {
author: (user?.textContent || '').trim() || '匿名',
text: (cooked?.innerText || '').trim(),
likes: parseInt((counter?.textContent || '0').trim(), 10) || 0,
isOP: p.classList.contains('post--topic-owner')
};
}).filter(c => c.text);
},
tieba() {
const vueEl = document.querySelector('.thread-container');
if (vueEl && vueEl.__vue__) {
const list = vueEl.__vue__.$props?.list;
// 【动态楼中楼采样上限】
// 硬约束:主楼层 + 楼中楼 总评论数 ≤ 500(用户配置 maxComments)
// 计算公式:每楼中楼 = floor(maxComments / 主楼层数)
// 边界:主楼层数 > maxComments 时,每楼取 1 条(保证热门长贴仍可解析)
// 兜底:主楼层数 ≤ 0 或缺失时,每楼取 3 条
const MAX_TOTAL = config.maxComments || 500;
const mainCount = list?.length || 0;
const lzlLimit = mainCount > 0
? Math.max(1, Math.floor(MAX_TOTAL / mainCount))
: 3;
if (Array.isArray(list) && list.length) {
// Vue 数据中只有 author_id(数字),用其作为 author
function extractText(contentArr) {
if (!Array.isArray(contentArr)) return '';
return contentArr.map(c => {
if (c.type === 0) return c.text || '';
if (c.type === 2) return c.c ? `[${c.c}]` : (c.text || '');
if (c.type === 3) return '[图片]';
return '';
}).join('');
}
const comments = [];
for (const item of list) {
const text = extractText(item.content);
if (!text) continue;
const agree = item.agree?.agree_num || 0;
comments.push({
author: '用户' + item.author_id,
text,
likes: agree
});
// 楼中楼(按主楼层数动态采样上限 lzlLimit)
const subObj = item.sub_post_list;
if (subObj && typeof subObj === 'object' && Array.isArray(subObj.sub_post_list)) {
for (const lzl of subObj.sub_post_list.slice(0, lzlLimit)) {
const lzlText = extractText(lzl.content);
if (lzlText) {
comments.push({
author: '',
text: lzlText,
likes: lzl.agree?.agree_num || 0
});
}
}
}
}
if (comments.length) return comments;
}
}
// fallback:DOM 抓取
return Array.from(document.querySelectorAll('.pb-comment-item')).map(el => {
const author = (el.querySelector('.head-name')?.textContent || '').trim() || '匿名';
const text = (el.querySelector('.pb-rich-text')?.innerText
|| el.querySelector('.comment-content')?.innerText || '').trim();
const likes = parseInt(el.querySelector('.zan-container span')?.textContent?.replace(/[^\d]/g, ''), 10) || 0;
return { author, text, likes };
}).filter(c => c.text);
},
douyin() {
if (window.__ai_douyin_comments) return window.__ai_douyin_comments;
return Array.from(document.querySelectorAll('[data-e2e="comment-item"]')).map(el => {
const author = (el.querySelector('.comment-item-info-wrap a')?.innerText || '').trim();
// 正文:info-wrap 之后、stats-container 之前的第一个非空文本块
const infoWrap = el.querySelector('.comment-item-info-wrap');
const stats = el.querySelector('.comment-item-stats-container');
let text = '';
if (infoWrap && stats) {
let node = infoWrap.nextElementSibling;
while (node && node !== stats) {
const t = (node.innerText || '').trim();
if (t) { text = t; break; }
node = node.nextElementSibling;
}
}
const likeText = (el.querySelector('.comment-item-stats-container p')?.textContent || '0').trim();
let likes = 0;
if (/万$/.test(likeText)) {
likes = Math.round(parseFloat(likeText) * 10000);
} else {
likes = parseInt(likeText, 10) || 0;
}
return { author, text, likes };
}).filter(c => c.text);
}
};
// 根据当前 hostname 选定爬虫;未匹配站点返回 null
function detectScraper() {
const h = location.hostname;
if (h.includes('youtube.com')) return 'youtube';
if (h.includes('reddit.com')) return 'reddit';
if (h.includes('bilibili.com')) return 'bilibili';
if (h.includes('zhihu.com')) return 'zhihu';
if (h.includes('xiaohongshu.com')) return 'xiaohongshu';
if (h.includes('twitter.com') || h.includes('x.com')) return 'twitter';
if (h.includes('news.ycombinator.com')) return 'hackernews';
if (h.includes('linux.do')) return 'linuxdo';
if (h.includes('tieba.baidu.com')) return 'tieba';
if (h.includes('douyin.com')) return 'douyin';
return null;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// === 通用自动加载(YouTube / Reddit 共用)===
// 通过反复滚动 + 点击"更多"按钮直到评论数稳定或达到 max。
// 两阶段:先快速滚动 40 次(连续 4 次无新增视为稳定),再 8 次慢速兜底。
async function autoLoadComments(scraper, max, cb, token) {
let last = 0, stable = 0;
for (let i = 0; i < 40; i++) {
if (token?.aborted) return;
clickMoreButtons();
const ytCont = document.querySelector(
'ytd-comments #contents > ytd-continuation-item-renderer, ' +
'ytd-item-section-renderer#sections #contents > ytd-continuation-item-renderer'
);
if (ytCont) {
ytCont.scrollIntoView({ behavior: 'instant', block: 'center' });
} else {
const c = document.querySelector('shreddit-app')
|| document.scrollingElement;
if (c) c.scrollTop = c.scrollHeight;
window.scrollTo(0, document.documentElement.scrollHeight);
}
await sleep(1200);
if (token?.aborted) return;
const items = scraper();
if (items.length >= max) break;
if (items.length === last) { stable++; if (stable >= 4) break; }
else stable = 0;
last = items.length;
cb && cb(items.length);
}
for (let k = 0; k < 8; k++) {
if (token?.aborted) return;
const before = scraper().length;
clickMoreButtons();
await sleep(1500);
if (token?.aborted) return;
const after = scraper().length;
cb && cb(after);
if (after >= max || after === before) break;
}
}
// === Twitter/X 自动加载 ===
// 用 Map 按 text 去重(同一条推文会反复出现)。
// 两阶段:先滚到底 40 次(含偶尔回滚触发懒加载),再从底部 6 等分往上扫一遍捞漏。
async function autoLoadTwitterComments(scraper, max, cb, token) {
const collected = new Map();
let batch = scraper();
for (const c of batch) {
if (c.text) collected.set(c.text, c);
}
cb && cb(collected.size);
let prevSize = collected.size;
let stable = 0;
for (let i = 0; i < 40; i++) {
if (token?.aborted) return;
if (i % 2 === 0) {
window.scrollTo(0, document.documentElement.scrollHeight);
} else {
window.scrollTo(0, Math.max(0, document.documentElement.scrollHeight - window.innerHeight * 1.5));
await sleep(300);
window.scrollTo(0, document.documentElement.scrollHeight);
}
await sleep(1500);
if (token?.aborted) return;
batch = scraper();
for (const c of batch) {
if (c.text) collected.set(c.text, c);
}
if (collected.size >= max) break;
if (collected.size === prevSize) {
stable++;
if (stable >= 4) break;
} else {
stable = 0;
}
prevSize = collected.size;
cb && cb(collected.size);
}
stable = 0;
let lastSize = collected.size;
for (let i = 0; i < 10; i++) {
if (token?.aborted) return;
const scrollStep = Math.floor(document.documentElement.scrollHeight / 6);
const target = document.documentElement.scrollHeight - (i + 1) * scrollStep;
window.scrollTo(0, Math.max(0, target));
await sleep(1200);
if (token?.aborted) return;
batch = scraper();
for (const c of batch) {
if (c.text) collected.set(c.text, c);
}
if (collected.size === lastSize) {
stable++;
if (stable >= 3) break;
} else {
stable = 0;
}
lastSize = collected.size;
cb && cb(collected.size);
}
window.__ai_twitter_comments = [...collected.values()];
cb && cb(collected.size);
}
// 小红书评论在独立滚动容器内,向上找第一个 overflow:auto/scroll 且有滚动空间的祖先
function findXHSCommentScroller() {
const item = document.querySelector('.comment-item, .comment-item-sub');
let node = item?.parentElement;
while (node && node !== document.body) {
const style = getComputedStyle(node);
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.scrollHeight > node.clientHeight + 20) {
return node;
}
node = node.parentElement;
}
return document.scrollingElement;
}
// === 小红书 / B 站自动加载 ===
// SAFE 上限 500 避免无限滚动把内存撑爆;二者结构类似:滚动到底 → 等加载 → 数稳定即停。
async function autoLoadXHSComments(scraper, max, cb, token) {
const SAFE = 500;
let last = 0, stable = 0;
for (let i = 0; i < 30; i++) {
if (token?.aborted) return;
const scroller = findXHSCommentScroller();
if (scroller && scroller !== document.scrollingElement) {
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
}
window.scrollTo(0, document.body.scrollHeight);
await sleep(1800 + Math.random() * 700);
if (token?.aborted) return;
const items = scraper();
if (items.length >= max || items.length >= SAFE) break;
if (items.length === last) { stable++; if (stable >= 4) break; }
else stable = 0;
last = items.length;
cb && cb(items.length);
}
}
async function autoLoadBiliComments(scraper, max, cb, token) {
const SAFE = 500;
let last = 0, stable = 0;
for (let i = 0; i < 30; i++) {
if (token?.aborted) return;
const c = document.scrollingElement;
if (c) c.scrollTop = c.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
await sleep(1500 + Math.random() * 600);
if (token?.aborted) return;
const items = scraper();
if (items.length >= max || items.length >= SAFE) break;
if (items.length === last) { stable++; if (stable >= 4) break; }
else stable = 0;
last = items.length;
cb && cb(items.length);
}
}
// === Shadow DOM 全局扫描 + 通用"更多"按钮点击 ===
// YouTube/B 站评论藏在 closed shadow root,必须递归收集 host 才能命中按钮。
// _clickedBtns 用 WeakSet 避免按钮被回收后内存泄漏;_scanTick 每 8 轮重新扫描一次 shadow 树(成本高)。
const _shadowHosts = new Set();
const _clickedBtns = new WeakSet();
let _scanTick = 0;
// 递归收集所有 shadowRoot 的 host(含嵌套 shadow)
function collectShadowHosts() {
_shadowHosts.clear();
function walk(root) {
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
_shadowHosts.add(el);
walk(el.shadowRoot);
}
}
}
walk(document);
}
// 在 document + 所有已知 shadow root 内找匹配"更多/展开/View more"的按钮并点击
// 关键过滤:排除侧边栏(避免误点关注/Trends)、限制文字长度(避免点到段落)、显式黑名单
function clickMoreButtons() {
if ((_scanTick++ & 7) === 0) collectShadowHosts();
const RE = /查看更多|更多评论|更多回复|加载更多|展开更多|更多结果|展开.*条回复|View more|more replies|load more|see more|show more/i;
const EXCLUDE_RE = /^Follow$|^关注$|^Unfollow$|^取消关注$/i;
const SIDEBAR = '[data-testid="sidebarColumn"], [aria-label="Trends for you"], [aria-label="Who to follow"]';
const roots = [document];
for (const host of _shadowHosts) {
if (host.isConnected && host.shadowRoot) roots.push(host.shadowRoot);
}
let clicked = 0;
for (const root of roots) {
let btns;
try { btns = root.querySelectorAll('button, a, [role="button"]'); }
catch { continue; }
for (const b of btns) {
if (_clickedBtns.has(b)) continue;
if (b.closest(SIDEBAR)) continue;
const txt = (b.innerText || b.textContent || '').trim();
if (!txt || txt.length >= 40) continue;
if (EXCLUDE_RE.test(txt)) continue;
if (RE.test(txt)) {
try { b.click(); _clickedBtns.add(b); clicked++; } catch {}
}
}
}
return clicked;
}
// HN 翻页用 GM_xmlhttpRequest 绕过跨域,anonymous 不带 cookie 减少服务器压力
function fetchHNPage(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
anonymous: true,
onload: (res) => {
if (res.status >= 200 && res.status < 300) resolve(res.responseText);
else reject(new Error('HN page HTTP ' + res.status));
},
onerror: (e) => reject(new Error('HN page fetch failed: ' + (e.error || 'network')))
});
});
}
// === Hacker News 自动加载 ===
// HN 是服务端分页(?p=2,3...),通过 a.morelink 拿下一页 HTML,DOMParser 解析后把评论 table 追加到当前页
async function autoLoadHNComments(scraper, max, cb, token) {
let collected = scraper().length;
cb && cb(collected);
if (collected >= max) return;
const isItemPage = /\/item\?id=\d+/.test(location.pathname + location.search);
if (!isItemPage) return;
for (let page = 2; page <= 30; page++) {
if (token?.aborted) return;
if (collected >= max) break;
const moreHref = document.querySelector('a.morelink')?.getAttribute('href');
if (!moreHref) break;
let html;
try { html = await fetchHNPage('https://news.ycombinator.com/' + moreHref); }
catch (e) { console.warn('[AI总结] HN 翻页失败:', e); break; }
if (token?.aborted) return;
const doc = new DOMParser().parseFromString(html, 'text/html');
const tables = doc.querySelectorAll('table');
for (const t of tables) {
const clone = document.importNode(t, true);
const target = document.querySelector('table.comment-tree') || document.body;
target.appendChild(clone);
}
const newMore = doc.querySelector('a.morelink')?.getAttribute('href');
const curMore = document.querySelector('a.morelink');
if (curMore && newMore) curMore.setAttribute('href', newMore);
else if (curMore && !newMore) curMore.remove();
collected = scraper().length;
cb && cb(collected);
if (collected >= max) break;
await sleep(150);
}
}
// === 知乎回答自动加载 ===
// 先点开所有"展开全文",再循环点"更多回答"/滚动到底,stable=3 即停
async function autoLoadZHAnswers(scraper, max, cb, token) {
let last = 0, stable = 0;
for (let i = 0; i < 30; i++) {
if (token?.aborted) return;
document.querySelectorAll('button').forEach(b => {
if (/展开全文|显示全部|展开/.test(b.innerText)) b.click();
});
const items = scraper();
if (items.length >= max) break;
if (items.length === last) { stable++; if (stable >= 3) break; }
else stable = 0;
last = items.length;
cb && cb(items.length);
const moreBtn = [...document.querySelectorAll('button')]
.find(b => /更多回答|查看更多/.test(b.innerText));
if (moreBtn) {
moreBtn.click();
} else {
window.scrollTo(0, document.body.scrollHeight);
}
await sleep(1500);
if (token?.aborted) return;
}
}
// === Linux.do 自动加载(走 Discourse JSON API)===
// 走 /t/{id}.json?page=N 翻页比 DOM 滚动可靠,按 post_number 建索引后递归算 depth 还原嵌套层级。
// 失败回退到 DOM 抓取(scraper 直接读 .topic-post)。结果写入 window.__ai_linuxdo_comments 供 scraper 取。
async function autoLoadLinuxdoComments(scraper, max, cb, token) {
const m = location.pathname.match(/\/t\/(?:[^\/]+\/)?(\d+)/);
if (!m) return;
const topicId = m[1];
try {
const d1 = await fetch(`/t/${topicId}.json?page=1`, { credentials: 'include' }).then(r => r.json());
if (token?.aborted) return;
const totalPosts = d1.posts_count || d1.post_stream?.stream?.length || 0;
const allPosts = [...(d1.post_stream?.posts || [])];
cb && cb(allPosts.length);
const totalPages = Math.ceil(totalPosts / 20);
for (let page = 2; page <= totalPages; page++) {
if (token?.aborted) return;
if (allPosts.length >= max) break;
try {
const d = await fetch(`/t/${topicId}.json?page=${page}`, { credentials: 'include' }).then(r => r.json());
if (token?.aborted) return;
const posts = d.post_stream?.posts || [];
if (!posts.length) break;
allPosts.push(...posts);
cb && cb(allPosts.length);
} catch (e) { break; }
await sleep(300);
}
// 通过 reply_to_post_number 链式追溯祖先算嵌套深度,visited 防环,d>20 兜底
const map = new Map(allPosts.map(p => [p.post_number, p]));
function getDepth(p) {
let d = 0, cur = p, visited = new Set();
while (cur && cur.reply_to_post_number != null && !visited.has(cur.post_number)) {
visited.add(cur.post_number);
d++;
cur = map.get(cur.reply_to_post_number);
if (d > 20) break;
}
return d;
}
const comments = allPosts
.filter(p => p.post_number !== 1)
.map(p => {
const tmp = document.createElement('div');
safeHTML(tmp, p.cooked || '');
return {
author: p.username || '匿名',
text: (tmp.innerText || '').trim(),
likes: p.actions_summary?.find(a => a.id === 2)?.count || 0,
postNumber: p.post_number,
depth: getDepth(p),
isOP: false
};
})
.filter(c => c.text);
window.__ai_linuxdo_comments = comments;
cb && cb(comments.length);
} catch (e) {
console.warn('[AI总结] linux.do JSON API 失败,回退 DOM', e);
}
}
// === 百度贴吧自动加载 ===
// 两阶段:
// 1. 循环 emit('load-more') 加载全部主楼层(虚拟列表初始只预加载 ~15 条)
// 2. 逐步滚动 + 点击 .show-more-lzl 展开楼中楼(初始只有 4-5 条,需点击展开)
async function autoLoadTiebaComments(scraper, max, cb, token) {
const scrollDom = document.querySelector('.pc-pb-box.styled-scrollbar.deep')
|| document.scrollingElement;
// 阶段 1:等待 Vue 组件就绪
for (let i = 0; i < 10; i++) {
if (token?.aborted) return;
const vueEl = document.querySelector('.thread-container');
if (vueEl && vueEl.__vue__ && vueEl.__vue__.$props?.list) break;
await sleep(500);
}
// 阶段 2:循环触发 load-more 加载全部主楼层
const vueEl = document.querySelector('.thread-container');
if (vueEl && vueEl.__vue__) {
const vm = vueEl.__vue__;
for (let i = 0; i < 100; i++) {
if (token?.aborted) return;
if (!vm.$props?.hasMore) break;
if (scraper().length >= max) break;
vm.$emit('load-more');
await sleep(800);
cb && cb(scraper().length);
}
}
// 阶段 3:逐步滚动展开楼中楼
// 虚拟列表只渲染可视区域附近的楼层,必须步进式滚动让每层 DOM 渲染出来
if (scrollDom) {
for (let pos = 0; pos <= scrollDom.scrollHeight; pos += 400) {
if (token?.aborted) return;
if (scraper().length >= max) break;
scrollDom.scrollTop = pos;
await sleep(300);
// 点击当前可视区域内的 show-more-lzl 按钮
const btns = document.querySelectorAll('.show-more-lzl');
for (const btn of btns) {
if (token?.aborted) return;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await sleep(800);
cb && cb(scraper().length);
}
}
}
const items = scraper();
cb && cb(items.length);
}
// === 抖音自动加载 ===
// 评论列表虚拟化,真正的滚动祖先通过 getComputedStyle 向上遍历定位。
// 滚动到底触发懒加载,配合 Map 去重避免虚拟列表回收节点导致丢失。
async function autoLoadDouyinComments(scraper, max, cb, token) {
const SAFE = 500;
function findScroller() {
let node = document.querySelector('[data-e2e="comment-list"]');
while (node && node !== document.body) {
const style = getComputedStyle(node);
if ((style.overflowY === 'auto' || style.overflowY === 'scroll')
&& node.scrollHeight > node.clientHeight + 10) {
return node;
}
node = node.parentElement;
}
return document.scrollingElement;
}
const scroller = findScroller();
if (!scroller) return;
const collected = new Map();
let batch = scraper();
for (const c of batch) { if (c.text) collected.set(c.text, c); }
cb && cb(collected.size);
let prevSize = collected.size;
let stable = 0;
for (let i = 0; i < 40; i++) {
if (token?.aborted) return;
if (collected.size >= max || collected.size >= SAFE) break;
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
await sleep(2000 + Math.random() * 800);
if (token?.aborted) return;
batch = scraper();
for (const c of batch) { if (c.text) collected.set(c.text, c); }
if (collected.size >= max || collected.size >= SAFE) break;
if (collected.size === prevSize) { stable++; if (stable >= 5) break; }
else stable = 0;
prevSize = collected.size;
cb && cb(collected.size);
}
window.__ai_douyin_comments = [...collected.values()];
cb && cb(collected.size);
}
// === 模型列表自动补全(OpenAI 兼容 /v1/models)===
function deriveModelsUrl(endpoint) {
return endpoint.replace(/\/+$/, '') + '/v1/models';
}
// 拉取可用模型列表,兼容 {data:[...]} / {models:[...]} / [str] 三种返回格式
async function fetchModels(endpoint, apiKey) {
const url = deriveModelsUrl(endpoint);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { 'Authorization': 'Bearer ' + (apiKey || '') },
onload: (res) => {
if (res.status < 200 || res.status >= 300) {
reject(new Error('HTTP ' + res.status));
return;
}
try {
const d = JSON.parse(res.responseText);
const list = (d.data || d.models || d || [])
.map(m => (typeof m === 'string' ? m : (m.id || m.name || '')))
.filter(s => s);
resolve(list);
} catch (e) {
reject(new Error('解析失败: ' + e.message));
}
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时'))
});
});
}
// 简单缓存:endpoint+apiKey 不变就复用上次结果,避免每次 focus 都打一次 API
let _modelsCache = null;
let _modelsCacheKey = '';
// 模型输入框自动补全:focus 时拉模型列表,input 时按子串过滤,blur 延迟 200ms 关闭(让 click 先触发)
function attachModelAutocomplete(input) {
const wrap = input.parentElement;
wrap.style.position = 'relative';
const list = document.createElement('div');
list.className = 'ai-model-dropdown';
wrap.appendChild(list);
const hide = () => { list.style.display = 'none'; };
const render = (items, filter) => {
const f = (filter || '').toLowerCase();
const filtered = f ? items.filter(m => m.toLowerCase().includes(f)) : items;
if (!filtered.length) { hide(); return; }
safeHTML(list, filtered.map(m => `<div class="ai-model-item">${escapeHtml(m)}</div>`).join(''));
list.style.display = 'block';
list.querySelectorAll('.ai-model-item').forEach((item, i) => {
item.onclick = () => { input.value = filtered[i]; hide(); input.focus(); };
});
};
input.addEventListener('focus', async () => {
const ep = document.querySelector('[data-k="apiEndpoint"]').value.trim();
const ak = document.querySelector('[data-k="apiKey"]').value.trim();
if (!ep) return;
const key = ep + '|' + ak;
if (key !== _modelsCacheKey) { _modelsCache = null; _modelsCacheKey = key; }
if (_modelsCache) { render(_modelsCache, input.value); return; }
safeHTML(list, '<div class="ai-model-loading">加载模型列表...</div>');
list.style.display = 'block';
try {
_modelsCache = await fetchModels(ep, ak);
render(_modelsCache, input.value);
} catch (e) {
safeHTML(list, '<div class="ai-model-error">获取失败:' + escapeHtml(e.message) + '(可手动输入)</div>');
}
});
input.addEventListener('input', () => {
if (_modelsCache) render(_modelsCache, input.value);
});
input.addEventListener('blur', () => setTimeout(hide, 200));
}
// === 中止令牌 + 错误处理 ===
// 自实现 token 而非 AbortController:兼容老浏览器,且 onAbort 回调可在 abort 后注册时立即触发。
function createToken() {
const t = { aborted: false, _cbs: [] };
t.onAbort = (fn) => { if (t.aborted) { try { fn(); } catch {} } else t._cbs.push(fn); };
t.abort = () => {
if (t.aborted) return;
t.aborted = true;
const cbs = t._cbs; t._cbs = [];
cbs.forEach(fn => { try { fn(); } catch {} });
};
return t;
}
function isAbortErr(e) { return e && e.__aborted === true; }
function abortErr() { const e = new Error('已终止'); e.__aborted = true; return e; }
// === 通用 Chat Completions 调用 ===
// 兼容多种返回结构(choices[0].message / text / content[0].text 等),JSON 解析失败时回退到正则提取 content
function sendChat(systemMsg, userMsg, token) {
if (!config.apiKey) return Promise.reject(new Error('请先在设置中填入 API Key'));
return new Promise((resolve, reject) => {
if (token && token.aborted) { reject(abortErr()); return; }
const xhr = GM_xmlhttpRequest({
method: 'POST',
url: config.apiEndpoint.replace(/\/+$/, '') + '/v1/chat/completions',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + config.apiKey
},
data: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: systemMsg },
{ role: 'user', content: userMsg }
],
temperature: 0.5
}),
timeout: 120000,
onload: (res) => {
if (token && token.aborted) { reject(abortErr()); return; }
if (res.status < 200 || res.status >= 300) {
reject(new Error('HTTP ' + res.status + ': ' + res.responseText.slice(0, 300)));
return;
}
let d;
try { d = JSON.parse(res.responseText); }
catch (e) {
const raw = res.responseText.trim();
if (raw.length > 0) {
const m = raw.match(/"content"\s*:\s*"([^"]+)"/);
if (m) { resolve(m[1]); return; }
resolve(raw.slice(0, 2000));
return;
}
reject(new Error('解析失败: ' + e.message));
return;
}
const content = d.choices?.[0]?.message?.content
|| d.choices?.[0]?.text
|| d.content?.[0]?.text
|| d.message?.content
|| d.text
|| JSON.stringify(d);
resolve(content);
},
onerror: (e) => {
if (token && token.aborted) { reject(abortErr()); return; }
reject(new Error('请求失败: ' + (e.error || e.statusText || '网络错误')));
},
ontimeout: () => {
if (token && token.aborted) { reject(abortErr()); return; }
reject(new Error('请求超时'));
}
});
if (token && xhr && typeof xhr.abort === 'function') {
token.onAbort(() => { try { xhr.abort(); } catch {} });
}
});
}
// 主总结调用:把评论编号 + 点赞数 + 深度(仅 HN/Linux.do 有嵌套)拼成文本,注入 customPrompt
async function callAI(title, comments, opts = {}, token) {
const useDepth = opts.platform === 'hackernews' || opts.platform === 'linuxdo';
const text = comments.map((c, i) => {
const head = (useDepth && c.depth)
? `${i + 1}. [${c.likes}赞 · 深度${c.depth}] ${c.author}: `
: `${i + 1}. [${c.likes}赞] ${c.author}: `;
return head + c.text;
}).join('\n');
const userMsg = `讨论主题(帖子标题):${title || '(无)'}\n\n${config.customPrompt}\n\n评论内容:\n${text}`;
const sysMsg = '你是一个专业的评论分析助手,严格按用户指定的 Markdown 结构输出。';
return sendChat(sysMsg, userMsg, token);
}
// 对抗性审视调用:把主总结喂回去,让 AI 站到反方位置挑刺 + 下判断
async function callAIInsight(mainText, token) {
const userMsg = `以下是针对某帖子评论的中立归纳报告:\n\n${mainText}\n\n请基于以上归纳,给出你的独立判断。`;
return sendChat(INSIGHT_PROMPT, userMsg, token);
}
// 拆分 AI 原始输出:以"## AI 独立判断"为界,前半为 main(中立归纳),后半为 insight(对抗性审视)
// 额外从 insight 提取信心程度(高/中/低),但目前 confidence 字段未被 UI 使用,预留给未来扩展
function splitInsight(raw) {
const m = raw.match(/^#{2,3}\s*AI\s*独立判断/m);
if (!m) return { main: raw, insight: '', confidence: '' };
const idx = m.index;
const main = raw.slice(0, idx).trim();
const insight = raw.slice(idx).trim();
const cm = insight.match(/信心[程度]*[::]\s*(高|中|低)/);
const confidence = cm ? cm[1] : '';
return { main, insight, confidence };
}
// === UI 构建:FAB / panel / settings / copy-modal ===
// 创建顺序固定:fab → panel → settings → modal,挂到 body 后再统一绑事件。
function createUI() {
const fab = Object.assign(document.createElement('button'), { id: 'ai-fab', textContent: '✨', title: 'AI 评论总结' });
document.body.appendChild(fab);
const panel = document.createElement('div');
panel.id = 'ai-panel';
safeHTML(panel, `
<div class="ai-hd">
<span class="ai-title">✨ AI 评论总结 <em id="ai-count">0</em></span>
<span class="ai-actions">
<button id="ai-set" title="设置">⚙</button>
<button id="ai-x" title="关闭">×</button>
</span>
</div>
<div class="ai-meta">
<span class="ai-topic" id="ai-topic">未开始</span>
</div>
<div class="ai-body status">点击「开始总结」按钮开始。</div>
<div class="ai-actions-row">
<button id="ai-run" class="ai-primary">✨ 开始总结</button>
<button id="ai-copy" class="ai-ghost">📋 复制</button>
</div>`);
document.body.appendChild(panel);
const set = document.createElement('div');
set.id = 'ai-settings';
safeHTML(set, `
<h3 style="margin-top:0">设置</h3>
<label>API Key</label><input type="password" data-k="apiKey" placeholder="sk-...">
<label>API Endpoint</label><input type="text" data-k="apiEndpoint" placeholder="http://localhost:8317(不含 /v1 路径)">
<div class="ai-row">
<div><label>模型</label><input type="text" data-k="model"></div>
<div><label>最大评论数</label><input type="number" data-k="maxComments" min="10" max="1000"></div>
</div>
<label>最小点赞数(过滤)</label><input type="number" data-k="minLikes" min="0">
<label>自定义 Prompt</label><textarea data-k="customPrompt"></textarea>
<div class="ai-footer">
<button class="ai-reset" data-act="reset">重置 Prompt</button>
<button class="ai-cancel" data-act="cancel">取消</button>
<button class="ai-save" data-act="save">保存</button>
</div>`);
set.querySelectorAll('[data-k]').forEach(i => { i.value = config[i.dataset.k] ?? ''; });
document.body.appendChild(set);
attachModelAutocomplete(set.querySelector('[data-k="model"]'));
// 复制方式弹窗:上下两个按钮(文本/图片),点遮罩或 × 关闭
const modal = document.createElement('div');
modal.id = 'ai-copy-modal';
safeHTML(modal, `
<div class="ai-cm-card">
<button class="ai-cm-x" title="关闭">×</button>
<div class="ai-cm-title">选择复制方式</div>
<button class="ai-cm-btn ai-cm-text">📋 复制为文本</button>
<button class="ai-cm-btn ai-cm-image">🖼️ 复制为图片</button>
</div>`);
document.body.appendChild(modal);
const closeCopyModal = () => modal.classList.remove('open');
modal.querySelector('.ai-cm-x').onclick = closeCopyModal;
modal.onclick = e => { if (e.target === modal) closeCopyModal(); };
fab.onclick = () => panel.classList.toggle('open');
panel.querySelector('#ai-x').onclick = () => panel.classList.remove('open');
panel.querySelector('#ai-set').onclick = () => {
set.classList.add('open');
set.querySelectorAll('[data-k]').forEach(i => { i.value = config[i.dataset.k] ?? ''; });
};
panel.querySelector('#ai-run').onclick = () => runSummary(panel);
// 主复制按钮:仅作为弹窗触发,无内容时不响应
panel.querySelector('#ai-copy').onclick = () => {
const src = panel.dataset.rawCopy || panel.dataset.raw || '';
if (!src) return;
modal.classList.add('open');
};
// 复制文本:navigator.clipboard.writeText,成功后 flash 提示
modal.querySelector('.ai-cm-text').onclick = async () => {
const src = panel.dataset.rawCopy || panel.dataset.raw || '';
if (!src) return;
try {
await navigator.clipboard.writeText(src);
flash(panel.querySelector('#ai-copy'), '已复制文本 ✓');
closeCopyModal();
} catch (e) {
flash(panel.querySelector('#ai-copy'), '复制失败');
}
};
// 复制图片:按钮变 loading 状态 → 调 copyAsImage → 按返回状态显示"已复制/已下载"
modal.querySelector('.ai-cm-image').onclick = async () => {
const btn = modal.querySelector('.ai-cm-image');
const original = btn.textContent;
btn.textContent = '⏳ 生成图片中...';
btn.disabled = true;
try {
const status = await copyAsImage(panel);
flash(panel.querySelector('#ai-copy'), status === 'download' ? '已下载图片 ✓' : '已复制图片 ✓');
closeCopyModal();
} catch (e) {
console.error('[AI评论总结] 复制图片失败:', e);
flash(panel.querySelector('#ai-copy'), '图片生成失败');
} finally {
btn.textContent = original;
btn.disabled = false;
}
};
set.querySelector('[data-act="cancel"]').onclick = () => set.classList.remove('open');
set.querySelector('[data-act="save"]').onclick = () => {
set.querySelectorAll('[data-k]').forEach(i => {
const k = i.dataset.k;
config[k] = (i.type === 'number') ? parseInt(i.value, 10) : i.value;
});
saveConfig(config);
set.classList.remove('open');
};
set.querySelector('[data-act="reset"]').onclick = () => {
config.customPrompt = DEFAULT_CONFIG.customPrompt;
set.querySelector('[data-k="customPrompt"]').value = DEFAULT_CONFIG.customPrompt;
};
}
// 按钮临时反馈:1.2s 内把按钮文案 + 颜色换成 txt 后恢复
// 注意 background 和 color 都要清空(内联 style 不会被 class 覆盖)
function flash(btn, txt) {
const old = btn.textContent;
btn.textContent = txt;
btn.style.background = '#1B365D';
btn.style.color = '#faf5ea';
setTimeout(() => {
btn.textContent = old;
btn.style.background = '';
btn.style.color = '';
}, 1200);
}
// === panel 截图为 PNG ===
// 流程:备份原 inline 样式 → mask 全屏遮挡(用户看不到变形)→ 临时把 panel 移到 (0,0) 并撑开 max-height
// → 隐藏顶栏按钮和操作行 → 等两帧 → html2canvas → canvas.toBlob
// → 优先写剪贴板,不支持/失败则降级为下载 PNG
// → finally 恢复所有 inline 样式 + 移除 mask
// 关键决策(用注释保留 WHY):
// - 用 mask 而非 clone:html2canvas 渲染时 visibility:hidden/opacity:0 会被当作"不绘制",
// clone + 任何隐藏手段都会输出空图;只能用真实可见元素 + 遮罩遮挡
// - 不传 windowWidth/windowHeight:会让 html2canvas 内部 iframe 宽度 = panel 宽度,
// panel CSS max-width:calc(100vw-40px) 在小 iframe 内被压缩,canvas 右侧出现空白
// - 必须把 panel 移到 left:0:html2canvas 按元素 boundingRect.left 渲染到 canvas,
// 原 panel right:24px 会让 canvas 左侧出现等宽空白
async function copyAsImage(panel) {
const html2canvas = window.html2canvas;
if (!html2canvas || typeof html2canvas !== 'function') {
throw new Error('html2canvas 库未加载');
}
const body = panel.querySelector('.ai-body');
const actions = panel.querySelector('.ai-hd .ai-actions');
const actionsRow = panel.querySelector('.ai-actions-row');
const backup = {
panelMaxHeight: panel.style.maxHeight,
panelHeight: panel.style.height,
panelLeft: panel.style.left,
panelTop: panel.style.top,
panelRight: panel.style.right,
panelBottom: panel.style.bottom,
bodyOverflow: body ? body.style.overflow : '',
bodyMaxHeight: body ? body.style.maxHeight : '',
bodyFlex: body ? body.style.flex : '',
bodyHeight: body ? body.style.height : '',
actionsDisplay: actions ? actions.style.display : '',
actionsRowDisplay: actionsRow ? actionsRow.style.display : '',
};
// 不透明遮罩:截图期间盖住 panel 防止用户看到变形
const mask = document.createElement('div');
mask.style.cssText = 'position:fixed;inset:0;background:#faf5ea;z-index:1000001;';
document.body.appendChild(mask);
try {
// 临时撑开 panel + body,隐藏非内容元素
panel.style.maxHeight = 'none';
panel.style.height = 'auto';
panel.style.left = '0';
panel.style.top = '0';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
if (body) {
body.style.overflow = 'visible';
body.style.maxHeight = 'none';
body.style.flex = 'none';
body.style.height = 'auto';
}
if (actions) actions.style.display = 'none';
if (actionsRow) actionsRow.style.display = 'none';
// 等两帧让浏览器完成自然布局
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const w = panel.offsetWidth;
const h = panel.offsetHeight;
const canvas = await html2canvas(panel, {
scale: 2,
backgroundColor: '#faf5ea',
useCORS: true,
logging: false,
width: w,
height: h,
});
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('canvas.toBlob 返回空');
if (navigator.clipboard && window.ClipboardItem) {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
return 'clipboard';
} catch (e) {
console.warn('[AI评论总结] 剪贴板写入失败,降级下载:', e);
}
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-summary-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return 'download';
} finally {
panel.style.maxHeight = backup.panelMaxHeight;
panel.style.height = backup.panelHeight;
panel.style.left = backup.panelLeft;
panel.style.top = backup.panelTop;
panel.style.right = backup.panelRight;
panel.style.bottom = backup.panelBottom;
if (body) {
body.style.overflow = backup.bodyOverflow;
body.style.maxHeight = backup.bodyMaxHeight;
body.style.flex = backup.bodyFlex;
body.style.height = backup.bodyHeight;
}
if (actions) actions.style.display = backup.actionsDisplay;
if (actionsRow) actionsRow.style.display = backup.actionsRowDisplay;
mask.remove();
}
}
// 加载骨架屏 + 已抓取评论数提示
function renderSkeleton(n) {
return `<div class="ai-skeleton">
<div class="ai-bar w90"></div>
<div class="ai-bar w70"></div>
<div class="ai-bar w90"></div>
<div class="ai-bar w50"></div>
<div class="ai-bar w90"></div>
</div>
<div style="margin-top:12px;color:#8a7f70;font-size:12px;text-align:center;font-family:Georgia,'Songti SC',serif">已抓取 <b style="color:#1B365D">${n}</b> 条评论,正在请求 AI 分析...</div>`;
}
// 按平台从 DOM 提取帖子标题;HN/Linux.do/知乎 有专用选择器,其它回退到 h1/document.title
function getPageTitle(name) {
if (name === 'hackernews') {
const link = document.querySelector('.storylink, .titleline > a')
|| document.querySelector('.titleline');
return (link?.textContent || document.title || '').trim();
}
if (name === 'linuxdo') {
return (document.querySelector('.fancy-title, .topic-title, header h1')?.innerText
|| document.title.split(' - ')[0]
|| document.title || '').trim();
}
if (name === 'zhihu') {
const t = document.querySelector('.QuestionHeader-title')?.innerText?.trim()
|| document.title.split(' - ')[0].trim()
|| document.title;
return t;
}
if (name === 'tieba') {
return (document.title.split(' - ')[0].trim() || document.title);
}
if (name === 'douyin') {
return (document.querySelector('[data-e2e="video-desc"]')?.textContent?.trim()
|| document.title.split(' - ')[0].trim()
|| document.title);
}
return (document.querySelector('h1')?.innerText?.trim()
|| document.title.split('|')[0].split('-')[0].trim()
|| document.title);
}
// === 主流程:抓评论 → AI 总结 → 渲染 → 对抗性审视 ===
// 状态机:未抓 / 抓取中(按钮变"停止总结",点按钮可 token.abort 中止)/ 已生成 / 出错
// panel.__aiToken 用于多次 run 之间的版本号校验,避免旧 run 的回调污染新 run 的 UI
async function runSummary(panel) {
const body = panel.querySelector('.ai-body');
const btn = panel.querySelector('#ai-run');
const topic = panel.querySelector('#ai-topic');
const cnt = panel.querySelector('#ai-count');
const name = detectScraper();
if (!name || !Scrapers[name]) {
body.className = 'ai-body status';
body.textContent = '当前网站暂不支持: ' + location.hostname;
return;
}
const token = createToken();
panel.__aiToken = token;
const origText = btn.textContent;
btn.disabled = false;
btn.textContent = '⏹ 停止总结';
btn.classList.add('ai-stopping');
body.className = 'ai-body';
safeHTML(body, renderSkeleton(0));
const restoreBtn = () => {
if (panel.__aiToken !== token) return;
panel.__aiToken = null;
btn.textContent = origText;
btn.classList.remove('ai-stopping');
btn.onclick = () => runSummary(panel);
};
const finishAbort = () => {
restoreBtn();
body.className = 'ai-body status';
body.textContent = '已终止,可重新点击「开始总结」。';
topic.textContent = '已终止';
};
btn.onclick = () => { token.abort(); finishAbort(); };
// 按平台选对应的自动加载函数;twitter/linuxdo 启动前要清掉旧缓存
try {
const scraper = Scrapers[name];
const title = getPageTitle(name);
if (name === 'hackernews') {
await autoLoadHNComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'zhihu') {
await autoLoadZHAnswers(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'linuxdo') {
window.__ai_linuxdo_comments = null;
await autoLoadLinuxdoComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'xiaohongshu') {
await autoLoadXHSComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'bilibili') {
await autoLoadBiliComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'tieba') {
await autoLoadTiebaComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'douyin') {
window.__ai_douyin_comments = null;
await autoLoadDouyinComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else if (name === 'twitter') {
window.__ai_twitter_comments = null;
await autoLoadTwitterComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
} else {
await autoLoadComments(scraper, config.maxComments, n => {
safeHTML(body, renderSkeleton(n));
cnt.textContent = n;
}, token);
}
if (token.aborted) throw abortErr();
// 评论过滤:minLikes 阈值 → linuxdo 排除楼主(楼主是问题不是评论)→ 按点赞排序 → 截断 maxComments
let comments = scraper()
.filter(c => c.likes >= (config.minLikes || 0));
if (name === 'linuxdo') {
comments = comments.filter(c => !c.isOP);
}
comments = comments
.sort((a, b) => b.likes - a.likes)
.slice(0, config.maxComments);
if (!comments.length) {
body.className = 'ai-body status';
body.textContent = '未抓取到评论,请确认页面有评论且已加载。';
return;
}
cnt.textContent = comments.length;
topic.textContent = title;
safeHTML(body, renderSkeleton(comments.length));
const summary = await callAI(title, comments, { platform: name }, token);
const { main } = splitInsight(summary);
panel.dataset.raw = summary;
const tMatch = main.match(/##\s*讨论主题\s*\n([\s\S]*?)(?=\n##|\n---|$)/);
if (tMatch) {
const t = tMatch[1].replace(/\*\*/g, '').replace(/\n+/g, ' ').trim();
topic.textContent = t.slice(0, 80);
}
// 渲染主总结 + 占位 insight 框,先把 main 显示出来,insight 异步再填
body.className = 'ai-body';
safeHTML(body, md(main) +
`<div class="ai-insight" id="ai-insight-loading">` +
`<div class="ai-insight-tag">🤖 对抗性审视</div>` +
`<div class="ai-insight-notice">正在生成对抗性审视...</div>` +
`</div>`);
// 对抗性审视调用:失败时等 2s 重试一次;用户主动 abort 不重试(直接向上抛)
let insight = '';
let insightErr = null;
try {
await sleep(1000);
const insightRaw = await callAIInsight(main, token);
insight = insightRaw.trim();
} catch (e) {
if (isAbortErr(e)) throw e;
insightErr = e.message;
if (!token?.aborted) {
try {
await sleep(2000);
const retryRaw = await callAIInsight(main, token);
insight = retryRaw.trim();
insightErr = null;
} catch (e2) {
if (isAbortErr(e2)) throw e2;
insightErr = e2.message;
}
}
}
const insightBox = body.querySelector('#ai-insight-loading');
if (insightBox) {
if (insight) {
safeHTML(insightBox,
`<div class="ai-insight-tag">🤖 对抗性审视</div>` +
`<div class="ai-insight-notice">以下是 AI 主动站到反方位置给出的审视——挑高赞、揭盲点、下判断。它不是中立总结的一部分。</div>` +
md(insight));
} else {
safeHTML(insightBox,
`<div class="ai-insight-tag">🤖 对抗性审视</div>` +
`<div class="ai-insight-notice">对抗性审视生成失败:${insightErr || '未知错误'}。可重新点击「开始总结」重试。</div>`);
}
insightBox.removeAttribute('id');
}
// 组装最终复制源:main + 分隔线 + insight;insight 缺失时只复制 main
// 分隔线里的警告文案确保复制出去时读者知道 insight 是 AI 主观判断
if (insight) {
const divider = '\n\n---\n\n> ⚠️ 以下为 AI 的对抗性审视——刻意找茬、刻意立论,仅供参考,非社区共识。引用时请注明来源为 AI 生成。\n\n';
panel.dataset.rawCopy = main + divider + insight;
} else {
panel.dataset.rawCopy = main;
}
} catch (e) {
// __aiToken 不匹配:用户已点了新一次 run,旧 run 静默退出避免覆盖新 UI
if (panel.__aiToken !== token) {
// 已被新 run 接管,静默退出
} else if (!isAbortErr(e)) {
body.className = 'ai-body status';
body.textContent = '出错: ' + e.message;
}
} finally {
restoreBtn();
}
}
// === 入口:等 DOM 就绪后构建 UI ===
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createUI);
} else {
createUI();
}
})();
02 抖音下载
// ==UserScript==
// @name Douyin 抖音浏览助手
// @name:zh-CN Douyin 抖音浏览助手
// @name:en Douyin Enhancer Tools
// @description 增强功能:查看封面、下载音乐、下载视频、下载图文、下载文章、用户主页批量下载、个人收藏页批量下载。
// @description:zh-CN 增强功能:查看封面、下载音乐、下载视频、下载图文、下载文章、用户主页批量下载、个人收藏页批量下载。
// @description:en Enhanced Features: View cover art, download music, download videos, download photo albums, download articles, batch download from user profiles, batch download from personal favorites page.
// @namespace https://www.runningcheese.com/userscripts
// @author RunningCheese
// @version 2.7
// @match http*://www.douyin.com/*
// @match http*://douyin.com/*
// @icon https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://www.douyin.com
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.9.1/jszip.min.js
// @connect iesdouyin.com
// @connect douyinpic.com
// @connect *.douyinpic.com
// @connect ibytedtos.com
// @connect *.ibytedtos.com
// @run-at document-start
// @license MIT
// @downloadURL https://update.greasyfork.org/scripts/577421/Douyin%20%E6%8A%96%E9%9F%B3%E6%B5%8F%E8%A7%88%E5%8A%A9%E6%89%8B.user.js
// @updateURL https://update.greasyfork.org/scripts/577421/Douyin%20%E6%8A%96%E9%9F%B3%E6%B5%8F%E8%A7%88%E5%8A%A9%E6%89%8B.meta.js
// ==/UserScript==
(function() {
'use strict';
let ttPolicy;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
ttPolicy = window.trustedTypes.createPolicy('dy-assistant-' + Math.random().toString(36).substring(2, 9), {
createHTML: (string) => string
});
} catch (e) {
ttPolicy = { createHTML: (string) => string };
}
} else {
ttPolicy = { createHTML: (string) => string };
}
// 简化的元素创建工具
const elements = {
createAs(nodeType, config, appendTo) {
const element = document.createElement(nodeType);
if (config) {
Object.entries(config).forEach(([key, value]) => {
if (key === 'innerHTML') {
element.innerHTML = ttPolicy.createHTML(value);
} else {
element[key] = value;
}
});
}
if (appendTo) appendTo.appendChild(element);
return element;
}
};
// ═══════════════════════════════════════════
// Module A: 主页批量下载
// ═══════════════════════════════════════════
(function() {
// ── constants ──
const uRe = /^\/user\//;
const E = {
A: "/aweme/v1/web/user/profile/other",
B: "/aweme/v1/web/user/profile/self",
C: "/aweme/v1/web/aweme/post/",
D: "/aweme/v1/web/aweme/article/",
F: "/aweme/v1/web/aweme/favorite/",
G: "/aweme/v1/web/aweme/like/",
};
const Fw = ["favorite", "collection", "fav_", "/fav/"];
const Rw = ["/feed/", "recommend", "/rec/", "record", "watch_later"];
const Rs = () => { const q = window.location.search; return q.includes("showTab=record") || q.includes("showTab=watch_later"); };
const T = { v: "视频", i: "图文", a: "文章" };
const ZS = 100 * 1024 * 1024;
const DC = 3;
const S = {
cp: window.location.pathname,
cid: "",
vs: new Map(),
dl: false,
enabled: false,
ver: 0, // page version: guards against stale API responses from previous user page
};
// ── helpers ──
const nt = (v, fb = "") => v === null || v === undefined ? fb : String(v);
const up = () => uRe.test(window.location.pathname);
const sp = () => window.location.pathname === "/user/self";
const sf = (v, fb = "未命名", ml = 80) => { const c = nt(v, fb).replace(/[\\/:*?"<>|\s]+/g, "").replace(/\.+$/g, "").slice(0, ml); return c || fb; };
const gE = (url, fb) => { try { const m = /\.([a-z0-9]{2,5})$/i.exec(new URL(url, window.location.href).pathname); return m ? m[1].toLowerCase() : fb; } catch (_) { return fb; } };
const sl = (ms) => new Promise((r) => setTimeout(r, ms));
const fu = (inp) => { if (typeof inp === "string") return inp; if (inp instanceof URL) return inp.href; return nt(inp?.url); };
const pj = (txt, _) => { try { return JSON.parse(txt); } catch (e) { return null; } };
const fU = (c) => Array.isArray(c?.url_list) ? nt(c.url_list[0]) : "";
const lU = (c) => { if (!Array.isArray(c?.url_list) || c.url_list.length === 0) return ""; return nt(c.url_list[c.url_list.length - 1]); };
const gn = (aw = {}) => { const r = aw.itl || aw.cap || aw.dsc || aw.aid || "未命名作品"; return sf(r, nt(aw.aid, "未命名作品"), 27).replace(/\.\d+$/g, ""); };
const fa = (it = {}) => {
const vd = it.video || {};
const imgs = Array.isArray(it.images) ? it.images.map(lU).filter(Boolean) : null;
const hi = Boolean(imgs?.length);
const hv = Boolean(fU(vd.play_addr));
let tp;
if (hi) tp = T.i;
else if (hv) tp = T.v;
else tp = T.a;
return {
aid: nt(it.aweme_id),
itl: nt(it.item_title),
cap: nt(it.caption),
dsc: nt(it.desc),
tp,
url: hv ? fU(vd.play_addr) : "",
imgs,
uid: nt(it.author?.uid),
nick: nt(it.author?.nickname, "未知作者"),
};
};
const fd = (ui = {}) => ({ uid: nt(ui.uid), nick: nt(ui.nickname) });
// ── state management ──
const rs = () => { S.cp = window.location.pathname; S.cid = ""; S.vs.clear(); rv(); };
const es = () => { if (S.cp !== window.location.pathname) rs(); };
// ── toast ──
let _t = null;
const tst = (msg, sticky) => {
let el = document.getElementById("dy-toast");
if (!el) {
el = document.createElement("div");
el.id = "dy-toast";
el.style.cssText = "position:fixed;top:5%;left:50%;transform:translateX(-50%);background:#3F7FEA;color:#fff;padding:16px 18px;min-height:20px;min-width:180px;line-height:1.2;border-radius:13px;font-size:14px;font-family:sans-serif;z-index:2147483647;pointer-events:none;opacity:0;transition:opacity 0.25s ease;box-shadow:0 2px 8px rgba(0,122,255,0.25),0 0 0 0.5px rgba(0,122,255,0.1);box-sizing:border-box;display:flex;align-items:center;justify-content:center;";
document.body.appendChild(el);
}
clearTimeout(_t);
el.textContent = msg;
el.style.opacity = "1";
if (!sticky) { _t = setTimeout(() => { el.style.opacity = "0"; }, 1500); }
};
// ── network hooks ──
const su = (url) => {
if (!up()) return false;
if (Object.values(E).some((p) => url.includes(p))) {
// "喜欢"内容仅限自己的主页,避免在别人主页时混入自己的喜欢数据
if (url.includes(E.G) && !sp()) return false;
return true;
}
if (sp()) {
if (Fw.some((kw) => url.includes(kw))) return true;
if (Rw.some((kw) => url.includes(kw))) return true;
if (Rs() && url.includes("/aweme/")) return true;
}
return false;
};
const ha = (url, txt) => {
if (!su(url)) return;
es();
const d = pj(txt, url);
if (!d) return;
if (url.includes(E.A) || url.includes(E.B)) {
// 在别人主页时忽略 self profile 响应(返回的是登录用户自己的 uid,会清空当前页面数据)
if (url.includes(E.B) && !sp()) return;
si(fd(d.user || {}));
} else if (url.includes(E.D)) {
const lst = d.aweme_list || d.article_list || [];
if (!Array.isArray(lst)) return;
lst.map((i) => { const x = fa(i); x.itl = x.itl || nt(i.article_title) || nt(i.title); x.dsc = x.dsc || nt(i.article_desc) || nt(i.desc_abstract); return x; }).forEach((a) => { if (!sp() && S.cid && a.uid && a.uid !== S.cid) return; if (a.aid) S.vs.set(a.aid, a); });
rv();
} else if (url.includes(E.C) || url.includes(E.G)) {
if (!Array.isArray(d.aweme_list)) return;
d.aweme_list.map(fa).forEach((v) => { if (!sp() && S.cid && v.uid && v.uid !== S.cid) return; if (v.aid) S.vs.set(v.aid, v); });
rv();
} else if (url.includes(E.F) || Fw.some((kw) => url.includes(kw))) {
const lst = d.aweme_list || d.media_list || d.collection_list || d.fav_list || [];
if (!Array.isArray(lst)) return;
lst.map((i) => { const aw = i.aweme_info || i.aweme || i; const v = fa(aw); v.itl = v.itl || nt(i.title) || nt(i.collection_name); return v; }).forEach((v) => { if (v.aid) S.vs.set(v.aid, v); });
rv();
} else if (Rw.some((kw) => url.includes(kw))) {
if (!Array.isArray(d.aweme_list)) return;
d.aweme_list.map(fa).forEach((v) => { if (v.aid) S.vs.set(v.aid, v); });
rv();
} else if (Rs() && url.includes("/aweme/")) {
const lst = d.aweme_list || d.media_list || d.history_list || [];
if (!Array.isArray(lst)) return;
lst.map((i) => { const aw = i.aweme_info || i.aweme || i; const v = fa(aw); v.itl = v.itl || nt(i.title); return v; }).forEach((v) => { if (v.aid) S.vs.set(v.aid, v); });
rv();
}
};
const si = (ui) => {
if (!ui.uid) return;
// S.cid 已被当前页面的第一个 profile 响应设置后,忽略任何其他 uid 的
// profile 响应(过期响应、self profile API 等),防止清空已加载数据
if (S.cid && S.cid !== ui.uid) return;
S.cid = ui.uid;
Array.from(S.vs.entries()).forEach(([k, v]) => { if (v.uid && v.uid !== ui.uid) S.vs.delete(k); });
rv();
};
const hx = () => {
const _o = XMLHttpRequest.prototype.open;
const _s = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, url) { this._u = nt(url); return _o.apply(this, arguments); };
XMLHttpRequest.prototype.send = function (body) { this.addEventListener("load", function () { try { ha(this._u || "", nt(this.responseText)); } catch (_) {} }); return _s.apply(this, arguments); };
};
const hf = () => {
if (typeof window.fetch !== "function") return;
const _f = window.fetch;
window.fetch = async function (...args) {
const r = await _f.apply(this, args);
const url = fu(args[0]);
if (su(url)) { r.clone().text().then((t) => ha(url, t)).catch(() => ha(url, "")); }
return r;
};
};
// ── download helpers ──
const fA = async (url) => { if (!url) throw new Error("下载地址为空"); const r = await fetch(url); if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`); return new Uint8Array(await r.arrayBuffer()); };
const fL = async (url) => { if (!url) throw new Error("下载地址为空"); const r = await fetch(url); if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`); return r.blob(); };
const fB = async (v) => { const imgs = (Array.isArray(v.imgs) ? v.imgs.filter(Boolean) : []).slice(0, 1); if (imgs.length === 0) throw new Error("无可用下载资源"); return fL(imgs[0]); };
const dB = (blob, fn) => { const sn = (fn || "download").replace(/[\\/:*?"<>|]+/g, "_").trim() || "download"; const u = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = u; a.download = sn; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 1000); };
const uz = (zo, bn) => {
if (!Object.prototype.hasOwnProperty.call(zo, bn)) return bn;
const di = bn.lastIndexOf(".");
const b = di > 0 ? bn.slice(0, di) : bn;
const e = di > 0 ? bn.slice(di) : "";
let n = 2;
let c = `${b}_${n}${e}`;
while (Object.prototype.hasOwnProperty.call(zo, c)) { n++; c = `${b}_${n}${e}`; }
return c;
};
const zA = async (zo, label) => {
if (typeof JSZip === "undefined") throw new Error("JSZip 未加载,无法打包文件");
const zip = new JSZip();
Object.entries(zo).forEach(([name, data]) => { zip.file(name, data); });
return await zip.generateAsync({ type: "blob", compression: "STORE" }, (meta) => {
tst(`${label} ${meta.percent.toFixed(0)}%`, true);
});
};
const sZ = (zo, cs) => {
const chunks = [];
let cc = {}, sz = 0;
Object.entries(zo).forEach(([k, v]) => { const es = v.length || v.byteLength || 0; if (sz > 0 && sz + es > cs) { chunks.push(cc); cc = {}; sz = 0; } cc[k] = v; sz += es; });
if (Object.keys(cc).length > 0) chunks.push(cc);
return chunks;
};
const zC = async (zo, an) => {
const chunks = sZ(zo, ZS);
const sa = sf(an, "未知作者", 40);
for (let idx = 0; idx < chunks.length; idx++) {
const cur = idx + 1;
const pre = chunks.length > 1 ? `压缩 ${cur}/${chunks.length}` : "压缩中";
tst(pre);
try {
const zd = await zA(chunks[idx], pre);
const zn = chunks.length > 1 ? `${sa}_part${cur}.zip` : `${sa}.zip`;
dB(zd, zn);
if (chunks.length > 1 && cur < chunks.length) await sl(1000);
} catch (e) { throw e; }
}
return chunks.length;
};
const rC = async (items, limit, wrk) => { let cur = 0; const ws = Array.from({ length: Math.min(limit, items.length) }, async () => { while (cur < items.length) { const i = cur++; await wrk(items[i], i); } }); await Promise.all(ws); };
const sc = (items) => {
if (items.length <= 1) return items;
const ns = items.map((v) => gn(v));
let pl = 0;
for (let i = 0; i < ns[0].length; i++) { if (ns.some((n) => n[i] !== ns[0][i])) break; pl++; }
const tr = ns[0].slice(0, pl).replace(/[_\-\s]+$/, "");
if (tr.length < 3) return items;
return items.map((v) => ({ ...v, _s: gn(v).slice(tr.length).replace(/^[_\-\s]+/, "") }));
};
const uP = (p) => { tst(`获取进度 ${p.completed}/${p.total}`, true); };
const dS = async (v) => {
try {
tst("开始获取资源...");
const blob = v.url ? await fL(v.url) : await fB(v);
const ext = v.url ? gE(v.url, "mp4") : "zip";
dB(blob, `${gn(v)}.${ext}`);
tst("✔ 获取成功");
} catch (_) { tst("✘ 获取失败,再试试"); }
};
const dZ = async (vids) => {
const items = sc(vids);
const failed = [];
const zo = {};
const tasks = [];
items.forEach((v) => {
if (v.url) tasks.push({ ty: "v", v, url: v.url });
const imgs = Array.isArray(v.imgs) ? v.imgs.filter(Boolean) : [];
imgs.forEach((img, i) => { tasks.push({ ty: "i", v, url: img, idx: i, total: imgs.length }); });
});
if (tasks.length === 0) { tst("✘ 未找到可用资源"); return; }
const prog = { completed: 0, total: tasks.length };
uP(prog);
await rC(tasks, DC, async (tk) => {
try {
const data = await fA(tk.url);
const nm = tk.ty === "v"
? `${tk.v._s || gn(tk.v)}.${gE(tk.url, "mp4")}`
: `${tk.v._s || gn(tk.v)}/image_${String(tk.idx + 1).padStart(2, "0")}.${gE(tk.url, "jpg")}`;
zo[uz(zo, nm)] = data;
} catch (_) { failed.push(tk.v.itl || tk.v.dsc || tk.v.aid); }
prog.completed++; uP(prog);
});
if (Object.keys(zo).length === 0) { tst("✘ 全部获取失败"); return; }
try {
const chunks = await zC(zo, nt(vids[0]?.nick));
if (failed.length) { tst(`${failed.length}个作品获取失败,其余已保存`); } else { const sfx = chunks > 1 ? `(共 ${chunks} 个压缩包)` : ""; tst(`✔ 全部保存完毕${sfx}`); }
} catch (_) { tst("✘ 压缩异常,请重试"); }
};
const gs = () => Array.from(document.querySelectorAll(".dy-sel-cb:checked")).map((cb) => S.vs.get(cb.dataset.id)).filter(Boolean);
const sd = (v) => { S.dl = v; us(); };
const di = async () => {
if (S.dl) return;
const sel = gs();
if (sel.length === 0) { alert("请选择要下载的内容。"); return; }
sd(true);
try { if (sel.length === 1 && sel[0].tp === T.v) { await dS(sel[0]); } else { await dZ(sel); } } finally { sd(false); }
};
// ── UI: inject checkboxes on page ──
const icl = () => {
if (!S.enabled) return;
const links = document.querySelectorAll('a[href*="/video/"]:not([data-dy-checked]), a[href*="/note/"]:not([data-dy-checked]), a[href*="/article/"]:not([data-dy-checked])');
links.forEach((link) => {
link.setAttribute('data-dy-checked', '1');
const m = /\/(video|note|article)\/(\d+)/.exec(link.getAttribute('href') || '');
if (!m) return;
const aid = m[2];
if (!S.vs.has(aid)) return;
const card = link.closest('li, [class*="waterfall_item"], [class*="waterfallItem"]');
if (!card || card.querySelector('.dy-sel-cb')) return;
const cs = getComputedStyle(card);
if (cs.position === 'static') card.style.position = 'relative';
// 卡片内链接阻止跳转
link.addEventListener('click', (e) => {
if (!S.enabled) return;
e.preventDefault();
e.stopPropagation();
}, true);
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'dy-sel-cb';
cb.dataset.id = aid;
cb.addEventListener('change', us);
// checkbox 点击时阻止冒泡,避免 card 重复翻转
cb.addEventListener('click', (e) => {
if (!S.enabled) return;
e.stopPropagation();
});
// 点击卡片区域时切换复选框
card.addEventListener('click', (e) => {
if (!S.enabled) return;
// 如果点击的是 checkbox 本身,由浏览器原生处理,card 不干预
if (e.target === cb) return;
e.preventDefault();
e.stopPropagation();
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change', { bubbles: true }));
}, true);
card.appendChild(cb);
});
us();
};
const te = () => {
S.enabled = !S.enabled;
document.querySelectorAll('.dy-sel-cb').forEach((c) => c.remove());
document.querySelectorAll('[data-dy-checked]').forEach((el) => el.removeAttribute('data-dy-checked'));
us();
icl();
};
const rv = () => { icl(); };
const us = () => {
const cbs = document.querySelectorAll('.dy-sel-cb');
const cnt = Array.from(cbs).filter((c) => c.checked).length;
const ce = document.getElementById('dy-count');
const db = document.getElementById('dy-dl-btn');
const sa = document.getElementById('dy-sel-all');
const iv = document.getElementById('dy-invert');
if (ce) ce.textContent = '已选 ' + cnt + ' 项';
if (db) {
db.disabled = S.dl || cnt === 0;
if (cnt > 0) db.classList.add('dy-tb-primary');
else db.classList.remove('dy-tb-primary');
}
if (sa) sa.disabled = S.dl || !S.enabled;
if (iv) iv.disabled = S.dl || !S.enabled;
cbs.forEach((c) => { c.disabled = S.dl; });
};
const hm = () => window.location.search.includes("modal_id");
// 使工具栏可拖拽移动(整个菜单可拖动,移动超过阈值才算拖拽,不影响按钮点击)
const initDrag = (el) => {
el.addEventListener('pointerdown', function(e) {
if (e.button !== 0) return; // 仅左键/触摸
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const rect = el.getBoundingClientRect();
const offX = e.clientX - rect.left;
const offY = e.clientY - rect.top;
let dragging = false;
const onMove = (ev) => {
if (!dragging) {
// 移动未超过阈值视为点击,不启动拖拽
if (Math.abs(ev.clientX - startX) < 4 && Math.abs(ev.clientY - startY) < 4) return;
dragging = true;
// 从 transform 居中定位切换为显式 left/top,并清除 bottom 固定,否则元素会被上下两端钉住无法移动
el.style.left = rect.left + 'px';
el.style.top = rect.top + 'px';
el.style.bottom = 'auto';
el.style.transform = 'none';
el.style.margin = '0';
}
el.style.left = Math.max(0, Math.min(ev.clientX - offX, window.innerWidth - el.offsetWidth)) + 'px';
el.style.top = Math.max(0, Math.min(ev.clientY - offY, window.innerHeight - el.offsetHeight)) + 'px';
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
});
};
const itb = () => {
if (!document.body) return;
if (hm()) { const tb = document.getElementById('dy-toolbar'); if (tb) tb.remove(); const tgl = document.getElementById('dy-toolbar-toggle'); if (tgl) tgl.remove(); return; }
const exist = document.getElementById('dy-toolbar');
if (exist && exist.parentNode) return;
const tb = exist || document.createElement('div');
tb.id = 'dy-toolbar';
tb.innerHTML = '<button id="dy-sel-all" class="dy-tb-btn" disabled>全选</button><button id="dy-invert" class="dy-tb-btn" disabled>反选</button><span id="dy-count" class="dy-tb-count">已选 0 项</span><button id="dy-dl-btn" class="dy-tb-btn" disabled>下载选中</button><button id="dy-collapse" class="dy-tb-btn dy-tb-primary" title="关闭工具栏"><span style="display:inline-block;">\u2715</span></button>';
// 折叠状态下的展开按钮(默认折叠,点击展开工具栏)
// 样式与工具栏一致,仅位置改为底部居中
const tgl = document.createElement('button');
tgl.id = 'dy-toolbar-toggle';
tgl.innerHTML = '<span style="display:inline-block;transform:rotate(90deg);">\u276E</span>';
tgl.title = '展开下载工具栏';
tgl.style.cssText = 'position:fixed;z-index:2147483647;bottom:0px;left:50%;transform:translateX(-50%);box-sizing:border-box;width:55px;height:25px;padding:4px 10px;border-radius:8px 8px 0 0;background:rgba(0,0,0,0.6);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);color:#fff;border:1px solid #333;cursor:pointer;font-size:14px;line-height:1;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;display:flex;align-items:center;justify-content:center;transition:background 0.2s,height 0.2s;user-select:none;box-shadow:0 4px 20px rgba(0,0,0,0.3);';
tgl.addEventListener('mouseenter', () => { tgl.style.height = '30px'; tgl.style.background = 'rgba(0,0,0,0.75)'; });
tgl.addEventListener('mouseleave', () => { tgl.style.height = '25px'; tgl.style.background = 'rgba(0,0,0,0.6)'; });
tgl.addEventListener('click', () => {
if (!S.enabled) te(); // 展开时自动启用下载
tb.style.display = 'flex';
tgl.style.display = 'none';
});
if (!tb.parentNode) document.body.appendChild(tb);
document.body.appendChild(tgl);
initDrag(tb);
document.getElementById('dy-sel-all').addEventListener('click', () => { document.querySelectorAll('.dy-sel-cb').forEach((c) => { c.checked = true; }); us(); });
document.getElementById('dy-invert').addEventListener('click', () => { document.querySelectorAll('.dy-sel-cb').forEach((c) => { c.checked = !c.checked; }); us(); });
document.getElementById('dy-dl-btn').addEventListener('click', di);
document.getElementById('dy-collapse').addEventListener('click', () => {
if (S.enabled) te(); // 收起时自动停用下载
tb.style.display = 'none';
tgl.style.display = 'flex';
});
};
const rB = (cb) => { if (document.body) { cb(); return; } document.addEventListener("DOMContentLoaded", cb, { once: true }); };
// ── home page init (only when on /user/ page) ──
if (!up()) return;
const nu = () => { const u = new URL(window.location.href); u.searchParams.delete("modal_id"); return u.pathname + u.search; };
let _lp = nu();
const ot = () => {
const cur = nu();
if (cur !== _lp) {
_lp = cur;
S.ver++; // bump version so in-flight stale responses are discarded
S.cid = ""; // reset current user id (previously missing, allowing stale uid to persist)
S.vs.clear(); S.enabled = false;
document.querySelectorAll('.dy-sel-cb').forEach((c) => c.remove());
document.querySelectorAll('[data-dy-checked]').forEach((el) => el.removeAttribute('data-dy-checked'));
us();
}
// 离开 /user/ 页面时移除工具栏(SPA 路由切换不会销毁 body 上的 DOM)
if (!up()) {
const tb = document.getElementById('dy-toolbar');
if (tb) tb.remove();
const tgl = document.getElementById('dy-toolbar-toggle');
if (tgl) tgl.remove();
return;
}
// 立即根据 modal_id 状态显示/隐藏工具栏
const tb = document.getElementById('dy-toolbar');
if (hm()) { if (tb) tb.remove(); const tgl = document.getElementById('dy-toolbar-toggle'); if (tgl) tgl.remove(); }
else { if (!tb) itb(); }
};
const opH = history.pushState;
const rpH = history.replaceState;
history.pushState = function () { opH.apply(this, arguments); ot(); };
history.replaceState = function () { rpH.apply(this, arguments); ot(); };
window.addEventListener("popstate", ot);
setInterval(ot, 500);
setInterval(() => { itb(); icl(); }, 600);
GM_addStyle(`
.dy-sel-cb {
appearance: none;
position: absolute;
top: 8px;
left: 8px;
z-index: 99;
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.85);
border-radius: 5px;
cursor: pointer;
background: rgba(0,0,0,0.35);
transition: all .15s;
margin: 0;
}
.dy-sel-cb:checked {
background: #fe2c55;
border-color: #fe2c55;
}
.dy-sel-cb:checked::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
#dy-toolbar {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483646;
display: none; /* 默认折叠 */
align-items: center;
gap: 10px;
padding: 10px 18px;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(16px);
border-radius: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
border: 1px solid #333;
cursor: grab;
user-select: none;
}
#dy-toolbar:active { cursor: grabbing; }
.dy-tb-btn {
height: 32px;
padding: 0 14px;
border: none;
border-radius: 16px;
background: rgba(255,255,255,0.18);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background .15s;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
}
.dy-tb-btn:hover { background: rgba(255,255,255,0.28); }
.dy-tb-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.dy-tb-on { background: #fe2c55; }
.dy-tb-on:hover { background: #e0264d; }
.dy-tb-primary { background: #fe2c55; }
.dy-tb-primary:hover:not(:disabled) { background: #e0264d; }
.dy-tb-count {
color: rgba(255,255,255,0.7);
font-size: 12px;
font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
white-space: nowrap;
min-width: 60px;
text-align: center;
}
#dy-collapse {
width: 36px;
padding: 0;
}
`);
hx();
hf();
rB(itb);
})();
// ═══════════════════════════════════════════
// Module B: 视频/图文/文章增强
// ═══════════════════════════════════════════
// 创建预览图片元素
const preview = elements.createAs("img", {
id: "preview",
style: `
position: absolute;
z-index: 2000;
max-width: 60vw;
max-height: 60vh;
border: 1px solid #fff;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
display: none;
z-index: 1000000000;
`
}, document.body);
// 添加点击事件监听器
preview.addEventListener('click', function() {
this.style.display = 'none';
});
// 点击网页其他区域时关闭缩略图
document.addEventListener('click', function(e) {
if (e.target.closest('#preview') || e.target.closest('#dy-thumbnail-btn')) {
return;
}
preview.style.display = 'none';
});
// 添加CSS样式
elements.createAs('style', {
textContent: `
.dy-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s ease;
background-color: #444;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-decoration: none;
}
.dy-icon-btn svg {
width: 14px;
height: 14px;
fill: white;
}
.dy-icon-btn img {
width: 14px;
height: 14px;
filter: brightness(0) invert(1);
}
.dy-icon-btn:hover {
transform: scale(1.1);
}
`
}, document.head);
// 抖音浏览助手主体
const douyinViewer = {
buttonCheckInterval: null,
// 检查是否在有视频的页面
isVideoPage() {
// 抖音绝大部分页面都有视频
return document.querySelector('video') !== null;
},
// 检查是否在图文页面
isImageTextPage() {
// URL 检测
if (window.location.pathname.includes('/note/')) return true;
// DOM 检测:查找图文专用的容器元素
return document.querySelector('[data-e2e*="note"], .note-item, .note-detail, [class*="note-item"]') !== null;
},
// 检查是否在文章页面
isArticlePage() {
return window.location.pathname.includes('/article/');
},
// 获取当前视频的 aweme_id
getAwemeId(targetElement) {
// 1. 从目标元素向上查找 (针对 Feed 流)
if (targetElement) {
const container = targetElement.closest('[data-e2e="video-item"], .video-item, xg-video-container');
if (container) {
const link = container.querySelector('a[href*="/video/"]');
if (link) {
const match = link.getAttribute('href').match(/video\/(\d+)/);
if (match && match[1]) return match[1];
}
}
}
// 2. 从 URL pathname 中提取视频ID
let match = window.location.pathname.match(/video\/(\d+)/);
if (match && match[1]) return match[1];
// 2.5 从 URL pathname 中提取图文ID
match = window.location.pathname.match(/note\/(\d+)/);
if (match && match[1]) return match[1];
// 3. 从 URL search params 中提取
match = window.location.search.match(/modal_id=(\d+)/);
if (match && match[1]) return match[1];
// 4. 从当前播放的 video 父容器中提取 (针对 Feed 流)
const activeVideo = document.querySelector('video');
if (activeVideo) {
const container = activeVideo.closest('[data-e2e="video-item"]');
if (container) {
const link = container.querySelector('a[href*="/video/"]');
if (link) {
match = link.getAttribute('href').match(/video\/(\d+)/);
if (match && match[1]) return match[1];
}
}
}
return null;
},
// 获取抖音缩略图URL
getThumbnailUrl(targetElement) {
return new Promise((resolve) => {
const awemeId = this.getAwemeId(targetElement);
if (!awemeId) {
// 后备方案
const activeVideo = document.querySelector('video');
if (activeVideo && activeVideo.poster && activeVideo.poster.trim() !== '') {
resolve(activeVideo.poster);
return;
}
resolve(null);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.iesdouyin.com/share/video/${awemeId}/`,
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
},
onload: (response) => {
try {
const match = response.responseText.match(/window\._ROUTER_DATA\s*=\s*(\{.*?\});?<\/script>/);
if (match && match[1]) {
const data = JSON.parse(match[1]);
const loaderKey = Object.keys(data.loaderData || {}).find(k => k.includes('_(id)/page'));
if (loaderKey) {
const item = data.loaderData[loaderKey].videoInfoRes.item_list[0];
if (item) {
if (item.video && item.video.cover && item.video.cover.url_list) {
resolve(item.video.cover.url_list[0]);
return;
}
if (item.images && item.images.length > 0 && item.images[0].url_list) {
resolve(item.images[0].url_list[0]);
return;
}
}
}
}
} catch (e) {
console.error("解析抖音封面API失败:", e);
}
// 失败后的后备方案
const activeVideo = document.querySelector('video');
if (activeVideo && activeVideo.poster && activeVideo.poster.trim() !== '') {
resolve(activeVideo.poster);
} else {
resolve(null);
}
},
onerror: () => {
const activeVideo = document.querySelector('video');
if (activeVideo && activeVideo.poster && activeVideo.poster.trim() !== '') {
resolve(activeVideo.poster);
} else {
resolve(null);
}
}
});
});
},
// 获取图文的所有图片URL
getNoteImageUrls(targetElement) {
return new Promise((resolve) => {
const awemeId = this.getAwemeId(targetElement);
if (!awemeId) {
resolve([]);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.iesdouyin.com/share/video/${awemeId}/`,
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
},
onload: (response) => {
try {
const match = response.responseText.match(/window\._ROUTER_DATA\s*=\s*(\{.*?\});?<\/script>/);
if (match && match[1]) {
const data = JSON.parse(match[1]);
const loaderKey = Object.keys(data.loaderData || {}).find(k => k.includes('_(id)/page'));
if (loaderKey) {
const item = data.loaderData[loaderKey].videoInfoRes.item_list[0];
if (item && item.images && item.images.length > 0) {
const urls = item.images.map(img => img.url_list[0]);
resolve(urls);
return;
}
}
}
} catch (e) {
console.error("解析图文API失败:", e);
}
resolve([]);
},
onerror: () => {
resolve([]);
}
});
});
},
// 获取图片后弹出选择界面(或回退到视频下载)
async batchDownloadImages(event) {
const target = event ? event.currentTarget : null;
const imageUrls = await this.getNoteImageUrls(target);
if (imageUrls.length === 0) {
// 不是图文内容,回退到视频下载
this.downloadVideo(event);
return;
}
// 弹出选择界面,让用户手动选择要下载的图片
this.showImageSelector(imageUrls);
},
// 获取播放器中的活跃媒体对象
getActiveMedia() {
const player = window.player || (typeof unsafeWindow !== 'undefined' && unsafeWindow.player);
return player?.config?.awemeInfo || null;
},
// 文件命名逻辑
_composeFilename(media, prefix = '') {
const { authorInfo: { nickname } = {}, desc, textExtra } = media;
const hashtags = (textExtra || []).map((t) => '#' + t.hashtagName).filter(Boolean).join('_');
let cleanDesc = (desc || '').trim();
(textExtra || []).forEach((t) => { cleanDesc = cleanDesc.replace(new RegExp('#' + t.hashtagName + '\\s*', 'g'), ''); });
cleanDesc = cleanDesc.replace(/[#\/\?<>\\:\*\|":]/g, '');
let name = (prefix ? prefix + '_' : '') + [nickname, hashtags, cleanDesc].filter(Boolean).join('_');
if (name.length > 64) name = name.slice(0, 64);
return name.replace(/\./g, '_');
},
// 通过 fetch 下载文件
async _downloadViaFetch(url, filename) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
return true;
} catch (e) {
console.error('下载失败:', e);
return false;
}
},
// 下载视频
async downloadVideo(event) {
const media = this.getActiveMedia();
if (!media) {
this.showToast('未检测到播放中的视频');
return;
}
const video = media.video;
const links = [];
if (video) {
if (video.playApi) links.push(video.playApi);
if (video.playApiH265) links.push(video.playApiH265);
if (video.playAddr) {
(Array.isArray(video.playAddr) ? video.playAddr : [video.playAddr]).forEach(a => {
if (a.src) links.push(a.src);
});
}
if (video.bitRateList) {
video.bitRateList.filter(b => b.format !== 'dash').forEach(b => {
if (b.playApi) links.push(b.playApi);
if (Array.isArray(b.playAddr)) b.playAddr.forEach(a => { if (a.src) links.push(a.src); });
});
}
}
const uniqueLinks = [...new Set(links.filter(Boolean))];
if (uniqueLinks.length === 0) {
this.showToast('未找到可下载的视频链接');
return;
}
this.showToast('正在下载视频...');
const name = this._composeFilename(media) + '.mp4';
for (const url of uniqueLinks) {
const ok = await this._downloadViaFetch(url, name);
if (ok) {
this.showToast('视频下载完成');
return;
}
}
this.showToast('视频下载失败');
},
// 下载背景音乐
async downloadMusic(event) {
const media = this.getActiveMedia();
if (!media) {
this.showToast('未检测到播放中的视频');
return;
}
const music = media.music;
if (!music) {
this.showToast('当前视频无背景音乐');
return;
}
const url = music.playUrl?.urlList?.[0];
if (!url) {
this.showToast('背景音乐链接无效');
return;
}
this.showToast('正在下载背景音乐...');
const name = this._composeFilename(media) + '.mp3';
const ok = await this._downloadViaFetch(url, name);
if (ok) {
this.showToast('背景音乐下载完成');
return;
}
// 尝试备用链接
const fallbacks = music.playUrl?.urlList?.slice(1) || [];
for (const fbUrl of fallbacks) {
const fbOk = await this._downloadViaFetch(fbUrl, name);
if (fbOk) {
this.showToast('背景音乐下载完成');
return;
}
}
this.showToast('背景音乐下载失败');
},
// 从页面标题元素提取文件名
extractName() {
const titleEl = document.querySelector('.account-name-text');
let name = (titleEl && titleEl.textContent) ? titleEl.textContent.trim() : (document.title || '');
// 去除抖音后缀
name = name.replace(/[\s\-–—|]*抖音[\s\-–—|]*/g, '');
name = name.replace(/[\s\-–—|]*Douyin[\s\-–—|]*/gi, '');
// 去除非法文件名字符
name = name.replace(/[\\\/:*?"<>|]/g, '');
// 去除首尾空白和多余空格
name = name.trim().replace(/\s+/g, ' ');
// 截断过长名称
if (name.length > 64) {
name = name.substring(0, 64);
}
// 兜底
if (!name) {
name = 'douyin_' + Date.now();
}
return name;
},
// 下载选中的图片(ZIP 打包)
downloadSelectedImages(imageUrls) {
const self = this;
const total = imageUrls.length;
if (total === 0) return;
const baseName = this.extractName();
self.showToast(`正在下载并打包 ${total} 张图片...`);
// 并行下载所有图片
const downloadPromises = imageUrls.map((url, index) => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
headers: {
'Referer': 'https://www.douyin.com/',
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
},
onload: (response) => {
const blob = response.response;
if (blob && blob.size > 0) {
resolve({ blob, index, name: `${baseName}_${String(index + 1).padStart(2, '0')}.jpg` });
} else {
console.error('图片下载失败(空响应):', url);
resolve(null);
}
},
onerror: (err) => {
console.error('图片下载失败:', url, err);
resolve(null);
}
});
});
});
Promise.all(downloadPromises).then(async (results) => {
const validResults = results.filter(r => r !== null);
if (validResults.length === 0) {
self.showToast('所有图片下载失败');
return;
}
// 单张图片直接下载
if (validResults.length === 1) {
const item = validResults[0];
const blobUrl = URL.createObjectURL(item.blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = item.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
self.showToast('下载完成');
return;
}
// 多张图片打包成 ZIP
try {
if (typeof JSZip === 'undefined') {
throw new Error('JSZip not loaded');
}
const zip = new JSZip();
validResults.forEach((item) => {
zip.file(item.name, item.blob);
});
const content = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
const blobUrl = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${baseName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
self.showToast('ZIP 打包下载完成');
} catch (e) {
console.error('ZIP 打包失败,改为逐个下载:', e);
validResults.forEach((item, i) => {
setTimeout(() => {
const blobUrl = URL.createObjectURL(item.blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = item.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}, i * 400);
});
}
});
},
// 查找文章正文容器(类名是混淆的且会变动,使用已知类名 + 结构回退)
getArticleContentEl() {
const selectors = ['.afVneZqM', '.H9UzUzLP'];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) return el;
}
// 结构回退:在 leftContainer 中找直接 <p> 子元素最多的容器
const left = document.querySelector('.leftContainer');
if (left) {
let best = null, bestCount = 0;
left.querySelectorAll('div').forEach(div => {
const cnt = div.querySelectorAll(':scope > p').length;
if (cnt > bestCount) { bestCount = cnt; best = div; }
});
if (best && bestCount >= 3) return best;
}
return null;
},
// 查找文章标题元素
getArticleTitleEl() {
const selectors = ['.PGCkj7_z', '.ZuAUZjxr'];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) return el;
}
// 结构回退:leftContainer 中第一个有文字且无子元素的 div
const left = document.querySelector('.leftContainer');
if (left) {
const divs = left.querySelectorAll('div');
for (const div of divs) {
const text = (div.innerText || '').trim();
if (div.children.length === 0 && text.length >= 5 && text.length <= 100) {
return div;
}
}
}
return null;
},
// 获取文章正文中的所有图片URL
getArticleImageUrls() {
const contentEl = this.getArticleContentEl();
if (!contentEl) return [];
const imgs = contentEl.querySelectorAll('img');
return Array.from(imgs)
.map(img => img.src || img.getAttribute('data-src') || '')
.filter(url => url && (url.startsWith('http') || url.startsWith('//')))
.map(url => url.startsWith('//') ? 'https:' + url : url);
},
// 文章页面 - 查看正文图片并下载
viewArticleImages(event) {
event && event.preventDefault && event.preventDefault();
const imageUrls = this.getArticleImageUrls();
if (imageUrls.length === 0) {
this.showToast('文章正文中没有找到图片');
return;
}
this.showImageSelector(imageUrls);
},
// HTML 转 Markdown
htmlToMarkdown(element) {
const getTextContent = (node) => {
let text = '';
Array.from(node.childNodes).forEach(child => {
if (child.nodeType === 3) {
text += child.textContent.replace(/[^\S\n]+/g, ' ');
} else if (child.nodeType === 1) {
const nn = child.nodeName.toLowerCase();
if (nn !== 'script' && nn !== 'style') {
text += getTextContent(child);
}
}
});
return text.trim();
};
const processChildNodes = (node) => {
let content = '';
Array.from(node.childNodes).forEach(child => {
content += processNode(child);
});
return content;
};
const processNode = (node) => {
if (node.nodeType === 3) {
return node.textContent.replace(/[^\S\n]+/g, ' ');
}
if (node.nodeType !== 1) return '';
const nodeName = node.nodeName.toLowerCase();
if (/^h[1-6]$/.test(nodeName)) {
const level = parseInt(nodeName.charAt(1));
return '#'.repeat(level) + ' ' + getTextContent(node) + '\n\n';
} else if (nodeName === 'p') {
return processChildNodes(node) + '\n\n';
} else if (nodeName === 'br') {
return '\n';
} else if (nodeName === 'hr') {
return '---\n\n';
} else if (nodeName === 'ul') {
let listContent = '';
Array.from(node.children).forEach(item => {
if (item.nodeName.toLowerCase() === 'li') {
listContent += '- ' + processChildNodes(item) + '\n';
}
});
return listContent + '\n';
} else if (nodeName === 'ol') {
let listContent = '';
Array.from(node.children).forEach((item, index) => {
if (item.nodeName.toLowerCase() === 'li') {
listContent += (index + 1) + '. ' + processChildNodes(item) + '\n';
}
});
return listContent + '\n';
} else if (nodeName === 'blockquote') {
const text = processChildNodes(node);
return '> ' + text.replace(/\n/g, '\n> ') + '\n\n';
} else if (nodeName === 'pre') {
return '```\n' + node.textContent + '\n```\n\n';
} else if (nodeName === 'code') {
return '`' + node.textContent + '`';
} else if (nodeName === 'strong' || nodeName === 'b') {
return '**' + processChildNodes(node) + '**';
} else if (nodeName === 'em' || nodeName === 'i') {
return '*' + processChildNodes(node) + '*';
} else if (nodeName === 'a') {
const href = node.getAttribute('href') || '';
return '[' + processChildNodes(node) + '](' + href + ')';
} else if (nodeName === 'img') {
const src = node.getAttribute('src') || node.getAttribute('data-src') || '';
const alt = node.getAttribute('alt') || '';
const fullSrc = src.startsWith('//') ? 'https:' + src : src;
return '';
} else if (nodeName === 'table') {
let tableContent = '';
const rows = node.querySelectorAll('tr');
if (rows.length > 0) {
const headerCells = rows[0].querySelectorAll('th,td');
let headerContent = '|';
let separatorRow = '|';
headerCells.forEach(cell => {
headerContent += ' ' + getTextContent(cell) + ' |';
separatorRow += ' --- |';
});
tableContent += headerContent + '\n' + separatorRow + '\n';
for (let i = 1; i < rows.length; i++) {
const cells = rows[i].querySelectorAll('td');
let rowContent = '|';
cells.forEach(cell => {
rowContent += ' ' + getTextContent(cell) + ' |';
});
tableContent += rowContent + '\n';
}
}
return tableContent + '\n';
} else {
return processChildNodes(node);
}
};
return processNode(element);
},
// 文章页面 - 复制全文(Markdown 格式)
copyArticleText(event) {
event && event.preventDefault && event.preventDefault();
const contentEl = this.getArticleContentEl();
if (!contentEl) {
this.showToast('未找到文章正文');
return;
}
const markdown = this.htmlToMarkdown(contentEl);
if (!markdown.trim()) {
this.showToast('文章正文为空');
return;
}
navigator.clipboard.writeText(markdown).then(() => {
this.showToast('已复制全文(Markdown)');
}).catch(() => {
this.showToast('复制失败,请重试');
});
},
// 文章页面 - 下载全文为 MD 文件
downloadArticleMd(event) {
event && event.preventDefault && event.preventDefault();
const titleEl = this.getArticleTitleEl();
const contentEl = this.getArticleContentEl();
if (!contentEl) {
this.showToast('未找到文章正文');
return;
}
const title = titleEl ? titleEl.innerText.trim() : (document.title || '文章');
// 日期格式化
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}:${pad(d.getMinutes())}`;
// 元数据
const metadata = '```\n' + title + '\n' + window.location.href + '\n' + dateStr + '\n```\n\n';
// HTML 转 Markdown
const markdown = this.htmlToMarkdown(contentEl);
const fullMarkdown = metadata + markdown;
// 下载文件
const safeTitle = document.title.replace(/[\\/:*?"<>|]/g, '_') || 'douyin_article';
const blob = new Blob([fullMarkdown], { type: 'text/markdown' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = safeTitle + '.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
this.showToast('已下载 MD 文件');
},
// 弹出图片选择界面
showImageSelector(imageUrls) {
const existing = document.getElementById('dy-image-selector');
if (existing) existing.remove();
const self = this;
const overlay = elements.createAs('div', {
id: 'dy-image-selector',
style: `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 1000000001;
display: flex; align-items: center; justify-content: center;
`
}, document.body);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) overlay.remove();
});
const container = elements.createAs('div', {
style: `
background: #fff; border-radius: 12px;
width: 90vw; max-width: 560px; max-height: 80vh;
display: flex; flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
`
}, overlay);
// 头部
const header = elements.createAs('div', {
style: `
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid #eee; flex-shrink: 0;
`
}, container);
elements.createAs('span', {
textContent: '选择要下载的图片(共 ' + imageUrls.length + ' 张)',
style: 'font-size: 15px; font-weight: bold; color: #333;'
}, header);
elements.createAs('span', {
textContent: '\u2715',
style: 'font-size: 20px; cursor: pointer; color: #999; padding: 0 4px; line-height: 1;',
onclick: () => overlay.remove()
}, header);
// 图片网格
const listContainer = elements.createAs('div', {
style: `
flex: 1; overflow-y: auto; padding: 14px 20px;
min-height: 100px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
align-content: start;
`
}, container);
const checkboxes = [];
imageUrls.forEach((url, i) => {
const card = elements.createAs('div', {
style: `
position: relative; border-radius: 8px; overflow: hidden;
cursor: pointer; border: 2px solid #fe2c55;
background: #fff; transition: border-color 0.2s;
display: flex; flex-direction: column;
`
}, listContainer);
const imgWrap = elements.createAs('div', {
style: `
position: relative; width: 100%; aspect-ratio: 1;
overflow: hidden;
`
}, card);
elements.createAs('img', {
src: url,
style: `
width: 100%; height: 100%; object-fit: cover; display: block;
`
}, imgWrap);
const cb = elements.createAs('input', {
type: 'checkbox',
checked: true,
style: `
position: absolute; top: 6px; right: 6px;
width: 20px; height: 20px; cursor: pointer;
accent-color: #fe2c55; z-index: 2; margin: 0;
`
}, imgWrap);
checkboxes.push(cb);
elements.createAs('span', {
textContent: '图片 ' + (i + 1),
style: `
display: block; text-align: center; padding: 6px 4px;
font-size: 12px; color: #666; user-select: none; flex-shrink: 0;
`
}, card);
function updateSelectionStyle() {
card.style.borderColor = cb.checked ? '#fe2c55' : '#ddd';
}
card.addEventListener('click', function(e) {
if (e.target !== cb) {
cb.checked = !cb.checked;
updateSelectionStyle();
updateDownloadBtn();
}
});
cb.addEventListener('change', function() {
updateSelectionStyle();
updateDownloadBtn();
});
});
// 底部工具栏
const footer = elements.createAs('div', {
style: `
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-top: 1px solid #eee; gap: 10px; flex-shrink: 0;
`
}, container);
const btnBase = 'padding: 7px 15px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; transition: opacity 0.2s;';
const leftGroup = elements.createAs('div', {
style: 'display: flex; gap: 8px;'
}, footer);
elements.createAs('button', {
textContent: '全选',
style: btnBase + 'background: #f0f0f0; color: #333;',
onclick: () => {
checkboxes.forEach(cb => { cb.checked = true; });
updateDownloadBtn();
}
}, leftGroup);
elements.createAs('button', {
textContent: '反选',
style: btnBase + 'background: #f0f0f0; color: #333;',
onclick: () => {
checkboxes.forEach(cb => { cb.checked = !cb.checked; });
updateDownloadBtn();
}
}, leftGroup);
const rightGroup = elements.createAs('div', {
style: 'display: flex; gap: 8px;'
}, footer);
elements.createAs('button', {
textContent: '取消',
style: btnBase + 'background: #f0f0f0; color: #333;',
onclick: () => overlay.remove()
}, rightGroup);
const downloadBtn = elements.createAs('button', {
textContent: '下载选中 (' + imageUrls.length + ')',
style: btnBase + 'background: #fe2c55; color: #fff;',
onclick: () => {
const selected = imageUrls.filter((_, idx) => checkboxes[idx].checked);
if (selected.length === 0) {
self.showToast('请至少选择一张图片');
return;
}
overlay.remove();
self.downloadSelectedImages(selected);
}
}, rightGroup);
function updateDownloadBtn() {
const count = checkboxes.filter(cb => cb.checked).length;
downloadBtn.textContent = '下载选中 (' + count + ')';
if (count === 0) {
downloadBtn.disabled = true;
downloadBtn.style.opacity = '0.45';
} else {
downloadBtn.disabled = false;
downloadBtn.style.opacity = '1';
}
}
},
// 显示提示消息
showToast(message) {
const toast = elements.createAs("div", {
style: `
position: fixed;
top: 5%;
left: 50%;
transform: translateX(-50%);
background: #3F7FEA;
color: #fff;
padding: 16px 18px;
min-height: 20px;
min-width: 180px;
line-height: 1.2;
border-radius: 13px;
font-size: 14px;
font-family: sans-serif;
z-index: 2147483647;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
box-shadow: 0 2px 8px rgba(0,122,255,0.25), 0 0 0 0.5px rgba(0,122,255,0.1);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
`,
textContent: message
}, document.body);
// 触发显示
requestAnimationFrame(() => { toast.style.opacity = '1'; });
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
if (document.body.contains(toast)) document.body.removeChild(toast);
}, 300);
}, 1000);
},
// 注入按钮到指定目标元素后
injectButtonsTo(targetEl) {
// 如果该元素后已经添加了按钮,或者父元素已经包含了按钮,则跳过
if (targetEl.querySelector('.dy-helper-buttons') || targetEl.parentNode?.querySelector('.dy-helper-buttons') || targetEl.closest('.video-info-detail, [data-e2e="video-item"], .video-item')?.querySelector('.dy-helper-buttons')) {
return;
}
// 按容器判断是否为图文/笔记
const isNote = targetEl.closest('[data-e2e="note-item"], .note-item, .note-detail') !== null;
// 创建按钮容器
const buttonContainer = elements.createAs('span', {
className: 'dy-helper-buttons',
style: `
display: inline-flex;
flex-direction: row;
gap: 10px;
vertical-align: middle;
margin-left: 15px;
z-index: 99999;
`
});
// 将按钮容器附加到目标容器的末尾(或其父级内部,取决于目标类型)
if (targetEl.tagName.toLowerCase() === 'span' || targetEl.tagName.toLowerCase() === 'div' || targetEl.tagName.toLowerCase() === 'h1') {
targetEl.appendChild(buttonContainer);
} else {
targetEl.parentNode.insertBefore(buttonContainer, targetEl.nextSibling);
}
// 创建下载背景音乐按钮
elements.createAs('a', {
href: 'javascript:void(0);',
className: 'dy-icon-btn',
title: '下载背景音乐',
innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M0 0h16v16H0z" fill="none"/><g fill="currentColor"><path d="M10 16v-5.401A2.999 2.999 0 0 0 10 5.4V0L4.667 4H0v8h4.667z"/><path d="m13.6 3.2l-.799-.6L11.6 4.199l.8.6a4 4 0 0 1 .8.802c.503.668.8 1.497.8 2.399s-.297 1.73-.8 2.4a4 4 0 0 1-.8.8l-.8.601l1.201 1.6l.8-.601a6 6 0 0 0 1.198-1.2A5.98 5.98 0 0 0 16 8c0-1.35-.447-2.598-1.2-3.6a6 6 0 0 0-1.2-1.2"/></g></svg>',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.downloadMusic(e);
}
}, buttonContainer);
// 判断是否为图文:容器级检测 + 页面级 URL 兜底
const isNotePage = isNote || this.isImageTextPage();
// 创建下载视频/批量下载图片按钮
// 始终走 batchDownloadImages,由该方法内部判断是否有图片
elements.createAs('a', {
href: 'javascript:void(0);',
className: 'dy-icon-btn',
title: isNotePage ? '批量下载图片' : '下载视频',
innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><path fill="#fff" fill-rule="evenodd" d="M7 14A7 7 0 1 0 7 0a7 7 0 0 0 0 14m3.146-5.69l-2.793 2.793a.5.5 0 0 1-.707 0L3.854 8.31a.5.5 0 0 1 .353-.853H6V3.75a1 1 0 0 1 2 0v3.707h1.793a.5.5 0 0 1 .353.853" clip-rule="evenodd" /></svg>',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.batchDownloadImages(e);
}
}, buttonContainer);
},
// 为文章页面添加专属按钮
addArticleButtons() {
// 使用稳定的 leftContainer 类名定位(XJUH_Qra 等哈希类名会变动,leftContainer 不会变)
const leftContainer = document.querySelector('.leftContainer');
if (!leftContainer) return;
// 如果已经添加过按钮则跳过
if (leftContainer.querySelector('.dy-helper-buttons') || leftContainer.parentNode?.querySelector('.dy-helper-buttons')) {
return;
}
const buttonContainer = elements.createAs('span', {
className: 'dy-helper-buttons',
style: `
display: inline-flex;
flex-direction: row;
gap: 10px;
vertical-align: middle;
margin-bottom: 10px;
z-index: 99999;
`
});
// 将按钮容器插入到 leftContainer 内部的最上方(图标显示在标题上方)
leftContainer.insertBefore(buttonContainer, leftContainer.firstChild);
// 图标1:查看正文图片并下载
elements.createAs('a', {
href: 'javascript:void(0);',
className: 'dy-icon-btn',
title: '查看正文图片并下载',
innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/><path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/></svg>',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.viewArticleImages(e);
}
}, buttonContainer);
// 图标2:复制全文
elements.createAs('a', {
href: 'javascript:void(0);',
className: 'dy-icon-btn',
title: '复制全文',
innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.5a1 1 0 0 0-.8.4l-1.9 2.533a1 1 0 0 1-1.6 0L5.3 12.4a1 1 0 0 0-.8-.4H2a2 2 0 0 1-2-2V2zm7.194 2.766a1.688 1.688 0 0 0-.227-.272 1.467 1.467 0 0 0-.469-.324l-.008-.004A1.785 1.785 0 0 0 5.734 4C4.776 4 4 4.746 4 5.667c0 .92.776 1.666 1.734 1.666.343 0 .662-.095.931-.26-.137.389-.39.804-.81 1.22a.405.405 0 0 0 .011.59c.173.16.447.155.614-.01 1.334-1.329 1.37-2.758.941-3.706a2.461 2.461 0 0 0-.227-.4zM11 7.073c-.136.389-.39.804-.81 1.22a.405.405 0 0 0 .012.59c.172.16.446.155.613-.01 1.334-1.329 1.37-2.758.942-3.706a2.466 2.466 0 0 0-.228-.4 1.686 1.686 0 0 0-.227-.273 1.466 1.466 0 0 0-.469-.324l-.008-.004A1.785 1.785 0 0 0 10.07 4c-.957 0-1.734.746-1.734 1.667 0 .92.777 1.666 1.734 1.666.343 0 .662-.095.931-.26z"/></svg>',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.copyArticleText(e);
}
}, buttonContainer);
// 图标3:下载全文为 MD
elements.createAs('a', {
href: 'javascript:void(0);',
className: 'dy-icon-btn',
title: '下载全文为 MD',
innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><path fill="#fff" fill-rule="evenodd" d="M7 14A7 7 0 1 0 7 0a7 7 0 0 0 0 14m3.146-5.69l-2.793 2.793a.5.5 0 0 1-.707 0L3.854 8.31a.5.5 0 0 1 .353-.853H6V3.75a1 1 0 0 1 2 0v3.707h1.793a.5.5 0 0 1 .353.853" clip-rule="evenodd" /></svg>',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.downloadArticleMd(e);
}
}, buttonContainer);
},
// 添加功能按钮悬浮窗
addButtons() {
if (!this.isVideoPage() && !this.isImageTextPage() && !this.isArticlePage()) {
return;
}
// 文章页面:特殊处理
if (this.isArticlePage()) {
this.addArticleButtons();
return;
}
// 查找所有可能的视频/图文容器 (针对 Feed 流和详情页)
const videoContainers = document.querySelectorAll('[data-e2e="video-item"], .video-item, xg-video-container, #video-info-wrap, .video-info-detail, [data-e2e="note-item"], .note-item, .note-detail, [data-e2e="detail-video-info"]');
if (videoContainers.length === 0) {
// 回退方案:如果找不到明确的容器,直接全局查找时间或标题
const fallbackContainers = document.querySelectorAll('.account-time, .video-info-time, [data-e2e="video-publish-time"], [data-e2e="video-time"], [data-e2e="video-desc"], [data-e2e="feed-video-desc"], h1[data-e2e="aweme-title"], .account-name, [data-e2e="note-title"], .note-title');
fallbackContainers.forEach(container => this.injectButtonsTo(container));
} else {
videoContainers.forEach(container => {
// 详情页 (data-e2e="detail-video-info"):按钮放在标题后面
if (container.matches('[data-e2e="detail-video-info"]')) {
const titleEl = container.querySelector('h1[data-e2e="aweme-title"], [data-e2e="video-desc"], [data-e2e="feed-video-desc"], h1');
if (titleEl) {
this.injectButtonsTo(titleEl);
}
return;
}
// 优先查找时间元素 (覆盖 Feed 和 详情页的常见时间 class)
let targetEl = container.querySelector('.account-time, .video-info-time, [data-e2e="video-publish-time"], [data-e2e="video-time"], .time-info, span.time, .video-info-detail-time, [data-e2e="note-title"], .note-title');
// 如果没有时间元素,回退到查找标题或作者名称
if (!targetEl) {
targetEl = container.querySelector('[data-e2e="video-desc"], [data-e2e="feed-video-desc"], h1[data-e2e="aweme-title"], .account-name, [data-e2e="note-title"], .note-title');
}
if (targetEl) {
this.injectButtonsTo(targetEl);
}
});
}
},
// 显示缩略图预览
async showThumbnailPreview(event) {
const target = event.currentTarget;
const thumbnailUrl = await this.getThumbnailUrl(target);
if (thumbnailUrl) {
preview.src = thumbnailUrl;
const rect = target.getBoundingClientRect();
// 预览图显示在按钮下方或上方
preview.style.left = rect.left + 'px';
preview.style.right = 'auto';
preview.style.bottom = '120px';
preview.style.width = 'auto';
preview.style.height = 'auto'
preview.onload = () => {
const screenWidth = window.innerWidth * 0.4;
const screenHeight = window.innerHeight * 0.6;
if (preview.naturalWidth > screenWidth || preview.naturalHeight > screenHeight) {
const widthRatio = screenWidth / preview.naturalWidth;
const heightRatio = screenHeight / preview.naturalHeight;
const ratio = Math.min(widthRatio, heightRatio);
preview.style.width = (preview.naturalWidth * ratio) + 'px';
preview.style.height = (preview.naturalHeight * ratio) + 'px';
}
const previewRect = preview.getBoundingClientRect();
if (previewRect.bottom > window.innerHeight) {
preview.style.top = (rect.top - previewRect.height - 10) + 'px';
}
preview.style.display = 'block';
};
}
},
// 隐藏缩略图预览
hideThumbnailPreview() {
preview.style.display = 'none';
},
// 点击切换缩略图预览的显示与隐藏
async toggleThumbnailPreview(event) {
if (preview.style.display === 'block') {
this.hideThumbnailPreview();
} else {
const target = event.currentTarget;
const thumbnailUrl = await this.getThumbnailUrl(target);
if (thumbnailUrl) {
this.showThumbnailPreview(event);
} else {
this.showToast('无法获取视频封面');
}
}
},
// 清理按钮
cleanupButtons() {
const containers = document.querySelectorAll('.dy-helper-buttons');
containers.forEach(container => container.remove());
},
// 启动按钮检查
startButtonCheck() {
if (this.buttonCheckInterval) {
clearInterval(this.buttonCheckInterval);
}
this.buttonCheckInterval = setInterval(() => {
if (this.isVideoPage() || this.isImageTextPage() || this.isArticlePage()) {
this.addButtons();
} else {
this.cleanupButtons();
}
}, 500);
},
// 初始化
init() {
this.addButtons();
this.startButtonCheck();
console.log('抖音浏览助手初始化成功');
let lastUrl = location.href;
new MutationObserver(() => {
if (lastUrl !== location.href) {
lastUrl = location.href;
setTimeout(() => {
this.addButtons();
}, 1000);
}
}).observe(document.body, {
childList: true,
subtree: true
});
}
};
// 等待页面加载完成后初始化(立即执行,图标第一时间显示)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
douyinViewer.init();
});
} else {
douyinViewer.init();
}
})();
01 x下载文章
安装油猴先。

// ==UserScript==
// @name X → Markdown 一键下载
// @namespace https://github.com/local/x2md
// @version 1.4.1
// @description 在 x.com / twitter.com 把推文或 X 长文一键保存为 Markdown。支持粘贴链接批量下载。
// @author -
// @match *://x.com/*
// @match *://*.x.com/*
// @match *://twitter.com/*
// @match *://*.twitter.com/*
// @include *://x.com/*
// @include https://mobile.twitter.com/*
// @grant GM_openInTab
// @grant GM_download
// @grant GM_registerMenuCommand
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
const ICON_LINK = /\bhttps?:\/\/(?:t\.co|twitter\.com|x\.com)\//i;
// ---------------------------------------------------------------- utilities
function escapeInline(text) {
return String(text).replace(/([\\`*_[\]])/g, '\\$1');
}
function clean(text) {
return String(text == null ? '' : text)
.replace(/ /g, ' ')
.replace(/[ \t]+\n/g, '\n');
}
/** Resolve a possibly relative href against the current location. */
function absHref(href) {
if (!href) return '';
try {
return new URL(href, location.href).href;
} catch {
return '';
}
}
/** Wrap text in markdown emphasis, idempotently (avoids **** when nested). */
function wrap(text, marker) {
if (!text) return '';
const t = text.trim();
if (!t) return '';
if (t.length < 2) return t;
if (t.startsWith(marker) && t.endsWith(marker) && t.length >= marker.length * 2 + 1) return t;
return marker + t + marker;
}
/** Is this element rendered in bold? (X uses font-weight on spans.) */
function isBold(el) {
try {
const weight = getComputedStyle(el).fontWeight;
return weight === 'bold' || weight === 'bolder' || Number(weight) >= 600;
} catch {
return false;
}
}
function inlineRich(node) {
if (node == null) return '';
if (node.nodeType === Node.TEXT_NODE) return escapeInline(node.nodeValue);
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName;
if (tag === 'BR') return ' \n';
let content = '';
for (const child of node.childNodes) content += inlineRich(child);
if (!content) return '';
if (tag === 'A') {
const href = absHref(node.getAttribute('href'));
const label = content.trim();
if (!href || !label) return content;
if (/^https?:\/\/(?:www\.)?(?:x|twitter)\.com/i.test(href)) return label;
return '[' + label + '](' + href + ')';
}
if (tag === 'CODE') return content.trim() ? '`' + content.trim() + '`' : content;
if (tag === 'STRONG' || tag === 'B' || isBold(node)) return wrap(content, '**');
if (tag === 'EM' || tag === 'I') return wrap(content, '*');
return content;
}
// ------------------------------------------------------------ block mapping
function headingMarker(tag, baseLevel) {
const level = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }[tag] || 2;
const depth = Math.min(6, baseLevel + level - 1);
return '#'.repeat(Math.max(1, depth));
}
/**
* Media (image / video) blocks rendered by X articles live inside <section>.
* Images are lazily loaded, so <img> may have no src yet: fall back to the
* /media/<id> permalink that X always renders around the media.
*/
function mediaFromSection(section) {
const img = section.querySelector('img[src*="twimg.com/media"], img[src^="https://pbs.twimg.com"]');
const src = img ? absHref(img.getAttribute('src')) : '';
const alt = img ? clean(img.getAttribute('alt') || '') : '';
if (src) return '';
const link = section.querySelector('a[href*="/media/"]');
const href = link ? absHref(link.getAttribute('href')) : '';
if (href) return '[图片/视频](' + href + ')';
const video = section.querySelector('video');
const poster = video ? absHref(video.getAttribute('poster')) : '';
if (poster) return '[视频封面](' + poster + ')';
return '';
}
/** Collect a stable URL for a media section, whether or not the img loaded. */
function mediaUrl(section) {
const img = section.querySelector('img[src*="twimg.com/media"], img[src^="https://pbs.twimg.com"]');
if (img && img.getAttribute('src')) return absHref(img.getAttribute('src'));
const link = section.querySelector('a[href*="/media/"]');
if (link) return absHref(link.getAttribute('href'));
const video = section.querySelector('video[poster]');
if (video) return absHref(video.getAttribute('poster'));
return '';
}
function listMarkdown(listEl) {
const ordered = listEl.tagName === 'OL';
const items = [...listEl.querySelectorAll(':scope > li')];
const lines = items.map((li, i) => {
const marker = ordered ? (i + 1) + '. ' : '- ';
const text = clean(inlineRich(li)).replace(/\n+/g, ' ').trim();
return marker + text;
});
return lines.join('\n');
}
/**
* Convert one DraftJS block (an element with data-block="true") to markdown.
*/
function blockToMarkdown(block, baseLevel) {
const tag = block.tagName;
if (tag === 'SECTION') return mediaFromSection(block);
// X renders every list item as its own top-level <li data-block="true">.
if (tag === 'LI') {
const text = clean(inlineRich(block)).replace(/\n+/g, ' ').trim();
return text ? '- ' + text : '';
}
if (tag === 'UL' || tag === 'OL') return listMarkdown(block);
if (tag === 'PRE') {
const code = clean(block.innerText || block.textContent || '');
const lang = (block.querySelector('code')?.className || '').match(/language-([\w-]+)/);
return '```' + (lang ? lang[1] : '') + '\n' + code.replace(/^```[\s\S]*?\n/, '').replace(/\n```$/, '') + '\n```';
}
if (tag === 'BLOCKQUOTE') {
const text = clean(inlineRich(block));
return text
.split('\n')
.map((l) => (l.trim() ? '> ' + l : '>'))
.join('\n');
}
if (/^H[1-6]$/.test(tag)) {
// Heading text is styled bold via CSS; emitting ** as well double-marks it.
const text = clean(plainText(block));
return text ? headingMarker(tag, baseLevel) + ' ' + text : '';
}
const listEl = block.querySelector(':scope > ul, :scope > ol');
if (listEl) return listMarkdown(listEl);
const media = block.querySelector(':scope img[src*="twimg.com"]');
const text = clean(inlineRich(block));
if (!text) return media ? mediaFromMarkdownImg(media) : '';
return text;
}
/** Plain text of a subtree, ignoring markdown emphasis. */
function plainText(node) {
return node.textContent || '';
}
function mediaFromMarkdownImg(img) {
const src = absHref(img.getAttribute('src'));
if (!src) return '';
return '';
}
// ------------------------------------------------------------------ locator
function statusIdFromUrl(url) {
const m = String(url || '').match(/\/(?:status|article|statuses)\/(\d+)/);
return m ? m[1] : '';
}
/** Find the <article> element that holds the post currently being viewed. */
function findMainArticle() {
const articles = [...document.querySelectorAll('article[data-testid="tweet"], article[role="article"]')];
if (!articles.length) return null;
const id = statusIdFromUrl(location.href);
if (id) {
const exact = articles.find((a) =>
[...a.querySelectorAll('a[href*="/status/"]')].some((l) => l.getAttribute('href').includes(id))
);
if (exact) return exact;
}
// Prefer the one that actually carries text (article views render a lead article).
const withBody = articles.find(
(a) => a.querySelector('[data-testid="longformRichTextComponent"]') || a.querySelector('[data-testid="tweetText"]')
);
return withBody || articles[0];
}
function readMeta(article) {
const userNameEl = article.querySelector('[data-testid="User-Name"]');
const userNameRaw = clean(userNameEl ? userNameEl.innerText : '');
const lines = userNameRaw.split('\n').map((s) => s.trim()).filter(Boolean);
const displayName = lines[0] || '';
const handleLine = lines.find((l) => l.startsWith('@')) || '';
const handleLink = userNameEl ? userNameEl.querySelector('a[href^="/"]') : null;
let handle = handleLink ? handleLink.getAttribute('href').replace(/^\//, '') : handleLine.replace(/^@/, '');
const verified = !!article.querySelector('[data-testid="icon-verified"]');
const timeEl = article.querySelector('time[datetime]');
const datetime = timeEl ? timeEl.getAttribute('datetime') : '';
const timeText = timeEl ? clean(timeEl.innerText) : '';
const statusLink = [...article.querySelectorAll('a[href*="/status/"]')]
.map((a) => absHref(a.getAttribute('href')))
.find((h) => statusIdFromUrl(h));
const id = statusIdFromUrl(statusLink || '') || statusIdFromUrl(location.href);
// X links to /status/<id>/analytics as well; rebuild a canonical permalink.
if (!handle && statusLink) {
const m = statusLink.match(/\/([^/]+)\/status\//);
if (m) handle = m[1];
}
const url = handle && id ? 'https://x.com/' + handle + '/status/' + id : statusLink || location.href;
const analytics = article.querySelector('a[href*="/analytics"]');
const stats = {
replies: readCount(article, 'reply'),
reposts: readCount(article, 'retweet'),
likes: readCount(article, 'like'),
bookmarks: readCount(article, 'bookmark'),
views: analytics ? clean(analytics.innerText).replace(/\s*查看\s*$/, '') : '',
};
return { displayName, handle, verified, datetime, timeText, id, url, stats };
}
function readCount(article, testid) {
const el = article.querySelector('[data-testid="' + testid + '"]');
if (!el) return '';
const raw = clean(el.innerText || '');
return raw ? raw.replace(/\s+/g, '') : '';
}
function formatDateTime(datetime, timeText) {
if (datetime) {
const d = new Date(datetime);
if (!Number.isNaN(d.getTime())) {
const pad = (n) => String(n).padStart(2, '0');
return (
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
);
}
}
return timeText || '';
}
// -------------------------------------------------------------------- build
/**
* Build the markdown document for the post currently on screen.
* @param {{includeImages?:boolean, includeStats?:boolean, includeQuote?:boolean}} opts
*/
function build(opts) {
opts = opts || {};
const includeImages = opts.includeImages !== false;
const includeStats = opts.includeStats !== false;
const includeQuote = opts.includeQuote !== false;
const article = findMainArticle();
if (!article) {
return { ok: false, error: '当前页面没有找到推文内容(article[data-testid="tweet"])。请先打开一条推文或 X 长文。' };
}
const meta = readMeta(article);
const titleEl = article.querySelector('[data-testid="twitter-article-title"]');
const title = clean(titleEl ? titleEl.innerText : '');
const richView = article.querySelector('[data-testid="longformRichTextComponent"]');
const tweetTextEl = article.querySelector('[data-testid="tweetText"]');
const parts = [];
const partTags = [];
const images = [];
const pushPart = (md, tag) => {
if (!md) return;
parts.push(md);
partTags.push(tag || '');
};
if (richView) {
const blocks = [...richView.querySelectorAll('[data-block="true"]')];
for (const block of blocks) {
if (includeImages && block.tagName === 'SECTION') {
const url = mediaUrl(block);
if (url && !images.includes(url)) images.push(url);
}
const md = blockToMarkdown(block, 2);
if (!md) continue;
const inlineImgs = md.match(/!\[[^\]]*\]\((https:\/\/pbs\.twimg\.com\/media\/[^)]+)\)/g) || [];
for (const i of inlineImgs) {
const url = i.replace(/^.*\]\((.*)\)$/, '$1');
if (!images.includes(url)) images.push(url);
}
pushPart(md, block.tagName);
}
} else if (tweetTextEl) {
pushPart(clean(inlineRich(tweetTextEl)), 'P');
} else {
return { ok: false, error: '没有识别到正文内容。可能是页面还没加载完,或这是一条纯图片/视频推文。' };
}
// Quoted post
if (includeQuote) {
const quote = article.querySelector('[data-testid="quoteTweet"]');
if (quote) {
const qAuthor = clean(
(quote.querySelector('[data-testid="User-Name"]') || {}).innerText || ''
).split('\n')[0];
const qText = clean(inlineRich(quote.querySelector('[data-testid="tweetText"]') || quote));
const qLines = [qText, qAuthor ? '— ' + qAuthor : ''].filter(Boolean).join('\n');
pushPart(
qLines
.split('\n')
.map((l) => (l.trim() ? '> ' + l : '>'))
.join('\n'),
'QUOTE'
);
}
}
// Media outside the rich body (cover image, plain photo/video posts) and a
// safety net for article sections whose <img> never got a src.
if (includeImages) {
const seen = new Set(images);
const push = (url) => {
if (url && !seen.has(url)) {
seen.add(url);
images.push(url);
}
};
for (const img of article.querySelectorAll('img[src*="pbs.twimg.com/media"]')) {
push(absHref(img.getAttribute('src')));
}
for (const video of article.querySelectorAll('video[poster]')) {
push(absHref(video.getAttribute('poster')));
}
// Only fall back to the /media/<id> permalink for sections that never
// resolved a real image URL, otherwise each image is listed twice.
for (const section of article.querySelectorAll('section[data-block="true"]')) {
if (section.querySelector('img[src*="pbs.twimg.com/media"]')) continue;
const link = section.querySelector('a[href*="/media/"]');
if (link) push(absHref(link.getAttribute('href')));
}
// Article cover sits above the rich text view, outside any section.
if (richView) {
for (const a of article.querySelectorAll('a[href*="/media/"]')) {
if (a.closest('section')) continue;
push(absHref(a.getAttribute('href')));
}
}
}
// Join blocks: keep consecutive list items tight (no blank line between them).
let body = '';
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
body = parts[i];
continue;
}
const prevTag = partTags[i - 1];
const curTag = partTags[i];
const bothList = (prevTag === 'LI' || prevTag === 'UL' || prevTag === 'OL') &&
(curTag === 'LI' || curTag === 'UL' || curTag === 'OL');
body += (bothList ? '\n' : '\n\n') + parts[i];
}
body = body.replace(/\n{3,}/g, '\n\n').trim();
const head = [];
head.push('# ' + (title || meta.displayName || 'X 帖子'));
head.push('');
const quoteLines = [];
quoteLines.push('> Author: ' + (meta.displayName || '未知') + (meta.handle ? ' (@' + meta.handle + ')' : '') + (meta.verified ? ' ✓' : ''));
const when = formatDateTime(meta.datetime, meta.timeText);
if (when) quoteLines.push('> Date: ' + when);
quoteLines.push('> URL: ' + meta.url);
if (includeStats && (meta.stats.replies || meta.stats.views)) {
const s = [];
if (meta.stats.replies) s.push(meta.stats.replies + ' replies');
if (meta.stats.reposts) s.push(meta.stats.reposts + ' reposts');
if (meta.stats.likes) s.push(meta.stats.likes + ' likes');
if (meta.stats.bookmarks) s.push(meta.stats.bookmarks + ' bookmarks');
if (meta.stats.views) s.push(meta.stats.views + ' views');
quoteLines.push('> Stats: ' + s.join(' · '));
}
head.push(quoteLines.join('\n'));
head.push('');
head.push('');
const tail = [];
if (includeImages && images.length) {
tail.push('## 配图');
tail.push('');
for (const src of images) tail.push('- ');
tail.push('');
}
tail.push('---');
tail.push('');
tail.push('<!-- Saved with X → Markdown on ' + new Date().toISOString().slice(0, 10) + ' -->');
const markdown = head.join('\n') + body + '\n\n' + tail.join('\n');
const words = body.replace(/\s+/g, '').length;
const filename = buildFilename(meta, title);
return {
ok: true,
markdown: markdown,
filename: filename,
title: title,
displayName: meta.displayName,
handle: meta.handle,
url: meta.url,
chars: markdown.length,
bodyChars: words,
imageCount: images.length,
isArticle: !!richView,
stats: meta.stats,
};
}
/** Filesystem-safe slug: no Windows-illegal characters, no markdown noise. */
function slugify(text, maxLen) {
return String(text || '')
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\\/:*?"<>|]/g, '') // illegal on Windows/macOS
.replace(/[\u0000-\u001f\u007f]/g, '') // control chars
.replace(/[#`*_[\]]/g, '')
.replace(/[。、,;:?!「」『』()【】《》…—~·]/g, '') // CJK punctuation
.trim()
.replace(/\s+/g, '-')
.slice(0, maxLen || 60)
.replace(/-+$/, '')
.replace(/^-+/, '');
}
function buildFilename(meta, title) {
const who = meta.handle ? meta.handle.replace(/[^\w.-]/g, '') : 'x-post';
const short = meta.id || 'untitled';
if (!title) return who + '_' + short + '.md';
const slug = slugify(title, 36);
return slug ? who + '_' + short + '_' + slug + '.md' : who + '_' + short + '.md';
}
/**
* Scroll media blocks into view so lazily loaded <img> tags get a real src.
* Returns a promise; capped so it never runs long.
*/
async function primeLazyImages(maxSections, waitMs) {
const article = findMainArticle();
if (!article) return 0;
const rich = article.querySelector('[data-testid="longformRichTextComponent"]');
if (!rich) return 0;
const sections = [...rich.querySelectorAll('section[data-block="true"]')]
.filter((s) => !s.querySelector('img[src*="pbs.twimg.com"]'));
if (!sections.length) return 0;
const limit = Math.min(sections.length, maxSections || 12);
let loaded = 0;
for (let i = 0; i < limit; i++) {
sections[i].scrollIntoView({ block: 'center', behavior: 'instant' in document.body.style ? 'instant' : 'auto' });
await new Promise((r) => setTimeout(r, waitMs || 220));
if (sections[i].querySelector('img[src*="pbs.twimg.com"]')) loaded++;
}
return loaded;
}
/** Click a "Show more" affordance so truncated text is fully rendered. */
function expandTruncated() {
const candidates = [
...document.querySelectorAll('article[data-testid="tweet"] span, article[data-testid="tweet"] a, article[data-testid="tweet"] div'),
];
for (const el of candidates) {
const t = clean(el.innerText || '');
if ((t === '显示更多' || t === 'Show more' || t === '顯示更多') && el.children.length === 0) {
el.click();
return true;
}
}
return false;
}
/**
* Userscript-specific glue: UI, saving via GM_download, and the paste-a-link
* job queue. Combined with content.js by dev/build-userscript.js.
*/
/* ------------------------------------------------------------------ version */
/**
* Hardcoded on purpose. GM_info is not dependable across userscript managers
* (Tampermonkey v5 moved it to GM.info, Violentmonkey kept GM_info), and showing
* "v?" made it look broken. The builder injects the real version from
* manifest.json, so this cannot drift from the build.
*/
const VERSION = '1.4.1';
/* ================================================================== LOADED
* Runs FIRST and cannot fail. Its only job is to prove the script was
* injected: see this green badge -> Tampermonkey is running the script and any
* later problem is ours. No badge -> the script was never injected at all.
* ====================================================================== */
(function markLoaded() {
try {
var host = document.body || document.documentElement;
if (!host) return;
var b = document.createElement('button');
b.id = 'x2md-alive';
b.type = 'button';
b.textContent = '⬇ X2MD v' + VERSION;
b.title = 'X → Markdown 已加载 · 点击显示/隐藏操作按钮';
b.style.cssText = [
'position:fixed', 'top:12px', 'right:12px', 'z-index:2147483647',
'padding:5px 11px', 'border:1px solid rgba(0,0,0,.1)', 'border-radius:999px',
'background:#0f9d58', 'color:#fff',
'font:700 11.5px/1.3 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
'cursor:pointer', 'box-shadow:0 3px 12px rgba(0,0,0,.2)',
].join(';');
b.addEventListener('click', function () {
var fab = document.getElementById('x2md-fab');
if (fab) fab.style.display = fab.style.display === 'none' ? '' : 'none';
else if (typeof window.X2MD !== 'undefined') window.X2MD.togglePanel();
});
host.appendChild(b);
} catch (e) {
/* never let the marker break the script */
}
})();
/* ------------------------------------------------------------------ options */
/**
* Storage: localStorage, NOT GM_getValue.
*
* Tampermonkey v5 dropped the synchronous GM_getValue(key, default) API — it
* returns undefined, which silently turned every setting into "undefined" and
* every checkbox into "unchecked". localStorage is synchronous everywhere and
* is shared by all same-origin tabs, which also covers the job queue below.
*/
const DEFAULTS = {
folder: 'hj',
includeImages: true,
includeStats: true,
includeQuote: true,
floatingButton: true,
};
const STORE_PREFIX = 'x2md:';
function opt(key) {
try {
const raw = window.localStorage.getItem(STORE_PREFIX + key);
if (raw === null) return DEFAULTS[key];
const parsed = JSON.parse(raw);
return parsed === undefined || parsed === null ? DEFAULTS[key] : parsed;
} catch {
return DEFAULTS[key];
}
}
function setOpt(key, value) {
try {
window.localStorage.setItem(STORE_PREFIX + key, JSON.stringify(value));
} catch {
/* private mode / storage disabled: settings just won't persist */
}
}
function buildOptions() {
return {
folder: String(opt('folder') || ''),
includeImages: opt('includeImages') !== false,
includeStats: opt('includeStats') !== false,
includeQuote: opt('includeQuote') !== false,
floatingButton: opt('floatingButton') !== false,
};
}
/* --------------------------------------------------------------- toast / UI */
function toast(message, kind) {
let el = document.getElementById('x2md-toast');
if (!el) {
el = document.createElement('div');
el.id = 'x2md-toast';
el.style.cssText = [
'position:fixed', 'left:50%', 'bottom:34px',
'transform:translateX(-50%) translateY(8px)', 'z-index:2147483647',
'max-width:520px', 'padding:10px 16px', 'border-radius:10px',
'font:500 14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif',
'color:#fff', 'box-shadow:0 8px 28px rgba(0,0,0,.28)',
'opacity:0', 'transition:opacity .18s ease,transform .18s ease', 'pointer-events:none',
].join(';');
document.body.appendChild(el);
}
el.style.background = kind === 'error' ? '#c0392b' : kind === 'info' ? '#1d9bf0' : '#0f9d58';
el.textContent = message;
requestAnimationFrame(() => {
el.style.opacity = '1';
el.style.transform = 'translateX(-50%) translateY(0)';
});
clearTimeout(el._t);
el._t = setTimeout(() => {
el.style.opacity = '0';
el.style.transform = 'translateX(-50%) translateY(8px)';
}, kind === 'error' ? 6000 : 3000);
}
/* ------------------------------------------------------------------ saving */
/**
* Save markdown text. Prefers GM_download because it supports a subfolder in
* the name; falls back to an <a download> click.
* Resolves with { ok, name, path } — path is the real on-disk path when the
* browser reports it, so the UI never has to make the user go hunting.
*/
function saveMarkdown(markdown, filename) {
const folder = String(opt('folder') || '').replace(/^[\\/]+|[\\/]+$/g, '');
const safeName = String(filename || 'post.md').replace(/[\\/:*?"<>|]/g, '_');
const fullName = folder ? folder + '/' + safeName : safeName;
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
return new Promise((resolve) => {
let settled = false;
const finish = (r) => {
if (settled) return;
settled = true;
setTimeout(() => URL.revokeObjectURL(url), 30000);
resolve(r);
};
const anchorFallback = () => {
const a = document.createElement('a');
a.href = url;
a.download = safeName;
document.body.appendChild(a);
a.click();
a.remove();
finish({ ok: true, name: fullName, path: '' });
};
if (typeof GM_download === 'function') {
try {
GM_download({
url: url,
name: fullName,
onload: (d) => finish({ ok: true, name: fullName, path: (d && d.path) || '' }),
onerror: () => anchorFallback(),
ontimeout: () => anchorFallback(),
});
// Some GM_download builds never fire onload; assume success eventually.
setTimeout(() => finish({ ok: true, name: fullName, path: '' }), 6000);
return;
} catch {
anchorFallback();
return;
}
}
anchorFallback();
});
}
/* ------------------------------------------------------- extract-and-save */
/** Wait for X to actually render the post, not just for the DOM to exist. */
async function waitForArticle(timeoutMs) {
const deadline = Date.now() + (timeoutMs || 60000);
while (Date.now() < deadline) {
try {
if (findMainArticle()) return true;
} catch {
/* not ready */
}
await new Promise((r) => setTimeout(r, 400));
}
return false;
}
/* --------------------------------------------------------------- copying */
/** Copy markdown to the clipboard. Uses the async API, falls back to execCommand. */
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
/* fall through to the legacy path */
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch {
return false;
}
}
/** Extract the current page and return the markdown, or throw with a reason. */
async function markdownOfCurrentPage(quiet) {
if (!(await waitForArticle(quiet ? 30000 : 12000))) {
throw new Error('没等到推文渲染(X 页面没加载出正文)');
}
const options = buildOptions();
let result = build(options);
if (result.ok && options.includeImages && typeof primeLazyImages === 'function') {
await primeLazyImages(12, 220);
result = build(options);
}
if (!result.ok) throw new Error(result.error || '未识别到推文内容');
return result;
}
/** Extract the current page and save it. Used by both the button and the job worker. */
async function saveCurrentPage(quiet) {
let result;
try {
result = await markdownOfCurrentPage(quiet);
} catch (e) {
return { ok: false, error: e && e.message ? e.message : String(e) };
}
const saved = await saveMarkdown(result.markdown, result.filename);
return { ok: saved.ok, filename: saved.name, path: saved.path, title: result.title };
}
async function copyCurrentPage() {
let result;
try {
result = await markdownOfCurrentPage(false);
} catch (e) {
return { ok: false, error: e && e.message ? e.message : String(e) };
}
const ok = await copyToClipboard(result.markdown);
const chars = result.markdown.replace(/\s+/g, '').length;
return { ok, chars: chars, error: ok ? '' : '复制失败,请手动选中正文复制' };
}
/* ------------------------------------------------------- paste-a-link queue */
const JOB_KEY = 'x2md:job';
function readJob() {
try {
const raw = window.localStorage.getItem(JOB_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function writeJob(job) {
try {
if (job) window.localStorage.setItem(JOB_KEY, JSON.stringify(job));
else window.localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}
function sameUrl(a, b) {
try {
const ua = new URL(a);
const ub = new URL(b);
return ua.hostname.replace(/^www\./, '') === ub.hostname.replace(/^www\./, '') && ua.pathname === ub.pathname;
} catch {
return false;
}
}
/** Parse pasted text into URLs (links embedded in other text are fine). */
function parseUrls(text) {
const out = [];
for (const line of String(text || '').split(/[\r\n]+/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const found = trimmed.match(/https?:\/\/[^\s,。;、))】」』"']+/gi);
if (found && found.length) {
for (const u of found) out.push(u);
} else if (/^(?:www\.|mobile\.)?(?:x|twitter)\.com\/\S+$/i.test(trimmed)) {
out.push('https://' + trimmed);
}
}
return out;
}
function isPostUrl(url) {
try {
return /\/(?:status|statuses|article)\/\d+/i.test(new URL(url).pathname);
} catch {
return false;
}
}
/**
* Queue: one background tab at a time. Background tabs render x.com much slower
* (~30s measured), so the panel shows real progress instead of a spinner.
*/
async function runQueue(urls, statusEl) {
const list = urls.filter((u) => isPostUrl(u));
const skipped = urls.length - list.length;
if (!list.length) {
toast('没有可抓取的推文链接(需要 /status/ 或 /article/)', 'error');
return;
}
const log = [];
for (let i = 0; i < list.length; i++) {
const url = list[i];
statusEl.textContent = '抓取中 (' + (i + 1) + '/' + list.length + '):后台标签页加载中(X 在后台渲染较慢,约 30 秒)…';
const job = { url: url, options: buildOptions(), state: 'loading', at: Date.now() };
writeJob(job);
let tab = null;
try {
if (typeof GM_openInTab === 'function') {
tab = GM_openInTab(url, { active: false, insert: true, setParent: true });
} else {
// No GM_openInTab: open a real window so the script still runs there.
tab = window.open(url, '_blank', 'noopener');
}
} catch (e) {
job.state = 'error';
job.error = '无法打开标签页:' + (e && e.message);
writeJob(job);
}
// Wait for the worker tab to report back.
const deadline = Date.now() + 90000;
let done = false;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 600));
const cur = readJob();
if (cur && cur.url === url && cur.state !== 'loading') {
done = true;
if (cur.state === 'ok') {
log.push('✓ ' + (cur.filename || '') + (cur.path ? '\n ' + cur.path : ''));
toast('已保存:' + (cur.filename || ''), 'ok');
} else {
log.push('✗ ' + url + ' — ' + (cur.error || '未知错误'));
toast('失败:' + (cur.error || '未知错误'), 'error');
}
break;
}
}
if (!done) {
log.push('✗ ' + url + ' — 超时');
toast('抓取超时:' + url, 'error');
}
writeJob(null);
}
const okCount = log.filter((l) => l.startsWith('✓')).length;
statusEl.textContent =
'完成:成功 ' + okCount + ' / ' + list.length + (skipped ? ',跳过非推文链接 ' + skipped + ' 条' : '');
}
/** Runs inside the background worker tab. */
async function runJobIfMine() {
const job = readJob();
if (!job || !job.url || !sameUrl(job.url, location.href)) return;
document.title = 'X → Markdown 抓取中…';
const res = await saveCurrentPage(true);
writeJob({
url: job.url,
state: res.ok ? 'ok' : 'error',
filename: res.filename || '',
path: res.path || '',
error: res.error || '',
at: Date.now(),
});
// Leave the user with a blank tab rather than a stolen x.com page.
setTimeout(() => {
try {
window.close();
} catch {
/* blocked by the browser */
}
if (!window.closed) location.replace('about:blank');
}, 900);
}
/* --------------------------------------------------------------- main entry */
function isPostPage() {
return /\/(?:status|statuses|article)\/\d+/i.test(location.pathname);
}
/** Small helper for the three pill buttons in the floating group. */
function pill(text, bg, color) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.style.cssText = [
'border:none', 'border-radius:999px', 'padding:7px 13px',
'background:' + bg, 'color:' + color,
'font:600 12.5px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
'cursor:pointer', 'white-space:nowrap',
].join(';');
b.addEventListener('mouseenter', () => (b.style.filter = 'brightness(0.94)'));
b.addEventListener('mouseleave', () => (b.style.filter = 'none'));
return b;
}
/** Floating group: 复制 / 下载 / 面板. */
function makeButton() {
const wrap = document.createElement('div');
wrap.id = 'x2md-fab';
wrap.style.cssText = [
'position:fixed', 'right:20px', 'bottom:24px', 'z-index:2147483646',
'display:flex', 'gap:5px', 'align-items:center',
'padding:5px', 'background:#fff', 'border:1px solid rgba(0,0,0,.08)',
'border-radius:999px', 'box-shadow:0 6px 20px rgba(0,0,0,.16)',
].join(';');
const copyBtn = pill('📋 复制', '#eef3f7', '#0f1419');
copyBtn.title = '复制这条推文的 Markdown (Alt+C)';
const dlBtn = pill('⬇ 下载', '#1d9bf0', '#fff');
dlBtn.title = '下载为 Markdown 文件 (Alt+S)';
const gearBtn = pill('⚙', '#eef3f7', '#0f1419');
gearBtn.title = '批量抓取面板 (Alt+M)';
const busy = (btn, label) => {
btn.disabled = true;
const old = btn.textContent;
btn.textContent = label;
btn.style.opacity = '0.7';
return () => {
btn.disabled = false;
btn.textContent = old;
btn.style.opacity = '1';
};
};
copyBtn.addEventListener('click', async () => {
const restore = busy(copyBtn, '复制中…');
try {
const r = await copyCurrentPage();
if (r.ok) toast('已复制 Markdown 到剪贴板(' + r.chars + ' 字)', 'ok');
else toast(r.error || '复制失败', 'error');
} finally {
restore();
}
});
dlBtn.addEventListener('click', async () => {
const restore = busy(dlBtn, '保存中…');
try {
const r = await saveCurrentPage(false);
if (r.ok) toast('已保存:' + (r.path || r.filename), 'ok');
else toast(r.error || '保存失败', 'error');
} finally {
restore();
}
});
gearBtn.addEventListener('click', togglePanel);
wrap.appendChild(copyBtn);
wrap.appendChild(dlBtn);
wrap.appendChild(gearBtn);
return wrap;
}
function makePanel() {
const panel = document.createElement('div');
panel.id = 'x2md-panel';
panel.style.cssText = [
'position:fixed', 'right:20px', 'bottom:70px', 'z-index:2147483646', 'width:340px',
'background:#fff', 'color:#0f1419', 'border:1px solid #e1e8ed', 'border-radius:12px',
'box-shadow:0 10px 30px rgba(0,0,0,.18)', 'padding:12px',
'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
].join(';');
panel.innerHTML = [
'<div style="font-weight:700;margin-bottom:8px">X → Markdown <span style="font-weight:500;color:#536471;font-size:11px" id="x2md-ver"></span></div>',
'<div style="font-size:12px;color:#536471;margin-bottom:8px" id="x2md-where"></div>',
'<textarea id="x2md-urls" rows="3" spellcheck="false" placeholder="粘贴 X 链接,每行一个(可批量)" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #e1e8ed;border-radius:8px;font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;resize:vertical"></textarea>',
'<button id="x2md-fetch" style="margin-top:8px;width:100%;padding:9px;border:0;border-radius:9px;background:#1d9bf0;color:#fff;font-weight:700;cursor:pointer">批量抓取并下载</button>',
'<div id="x2md-status" style="font-size:12px;color:#536471;margin-top:8px;min-height:1em"></div>',
'<div id="x2md-log" style="font-size:11.5px;margin-top:6px;white-space:pre-wrap;word-break:break-all;color:#0f1419"></div>',
'<hr style="border:none;border-top:1px solid #e1e8ed;margin:10px 0">',
'<label style="display:flex;justify-content:space-between;align-items:center;font-size:12.5px;margin-bottom:8px">保存子目录<input id="x2md-folder" type="text" style="width:110px;padding:4px 6px;border:1px solid #e1e8ed;border-radius:6px"></label>',
'<label style="display:flex;gap:7px;align-items:center;font-size:12.5px;margin-bottom:6px"><input id="x2md-images" type="checkbox">包含配图链接</label>',
'<label style="display:flex;gap:7px;align-items:center;font-size:12.5px;margin-bottom:6px"><input id="x2md-stats" type="checkbox">包含互动数据</label>',
'<label style="display:flex;gap:7px;align-items:center;font-size:12.5px"><input id="x2md-showfab" type="checkbox">显示右下角浮动按钮</label>',
].join('');
document.body.appendChild(panel);
const $ = (id) => panel.querySelector('#' + id);
$('x2md-ver').textContent = 'v' + VERSION;
const folder = $('x2md-folder');
folder.value = opt('folder');
$('x2md-images').checked = opt('includeImages');
$('x2md-stats').checked = opt('includeStats');
$('x2md-showfab').checked = opt('floatingButton') !== false;
const where = () => {
const f = String(opt('folder') || '').replace(/^[\\/]+|[\\/]+$/g, '');
$('x2md-where').textContent = f
? '保存位置:浏览器下载目录下的 ' + f + '/ 子文件夹'
: '保存位置:浏览器下载目录根目录';
};
where();
const persist = () => {
setOpt('folder', folder.value);
setOpt('includeImages', $('x2md-images').checked);
setOpt('includeStats', $('x2md-stats').checked);
setOpt('includeQuote', true);
setOpt('floatingButton', $('x2md-showfab').checked);
where();
applyFloatingButton();
};
for (const id of ['x2md-folder', 'x2md-images', 'x2md-stats', 'x2md-showfab']) {
$(id).addEventListener('change', persist);
}
$('x2md-fetch').addEventListener('click', async () => {
const urls = parseUrls($('x2md-urls').value);
if (!urls.length) {
toast('请先粘贴 X 链接', 'error');
return;
}
$('x2md-log').textContent = '';
await runQueue(urls, $('x2md-status'));
});
return panel;
}
function applyFloatingButton() {
const existing = document.getElementById('x2md-fab');
if (opt('floatingButton') && isPostPage() && !existing && document.body) {
document.body.appendChild(makeButton());
} else if ((!opt('floatingButton') || !isPostPage()) && existing) {
existing.remove();
}
}
function togglePanel() {
const existing = document.getElementById('x2md-panel');
if (existing) {
existing.remove();
return;
}
makePanel();
}
/* ------------------------------------------------------------------- start */
// Public handle: handy from the browser console, and used by the smoke test.
window.X2MD = {
version: VERSION,
build: build,
findMainArticle: findMainArticle,
saveCurrentPage: saveCurrentPage,
copyCurrentPage: copyCurrentPage,
copyToClipboard: copyToClipboard,
markdownOfCurrentPage: markdownOfCurrentPage,
saveMarkdown: saveMarkdown,
parseUrls: parseUrls,
makePanel: makePanel,
togglePanel: togglePanel,
runQueue: runQueue,
options: buildOptions,
};
(function start() {
try {
// A crash after injection must not leave a dead, unexplained button.
window.addEventListener('error', function (e) {
try {
toast('脚本出错:' + (e && e.message ? e.message : '未知错误'), 'error');
} catch (x) {}
});
if (!document.body) return;
// A worker tab opened by the queue grabs its job and exits when done.
runJobIfMine();
applyFloatingButton();
} catch (e) {
try {
toast('脚本初始化失败:' + (e && e.message ? e.message : e), 'error');
} catch (x) {}
}
async function doCopy() {
if (!isPostPage()) return togglePanel();
const r = await copyCurrentPage();
if (r.ok) toast('已复制 Markdown 到剪贴板(' + r.chars + ' 字)', 'ok');
else toast(r.error || '复制失败', 'error');
}
async function doDownload() {
if (!isPostPage()) return togglePanel();
const r = await saveCurrentPage(false);
if (r.ok) toast('已保存:' + (r.path || r.filename), 'ok');
else toast(r.error || '保存失败', 'error');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const panel = document.getElementById('x2md-panel');
if (panel) panel.remove();
return;
}
const alt = e.altKey && !e.ctrlKey && !e.metaKey;
if (alt && e.key.toLowerCase() === 'c') {
e.preventDefault();
doCopy();
} else if (alt && e.key.toLowerCase() === 's') {
e.preventDefault();
doDownload();
} else if (alt && e.key.toLowerCase() === 'm') {
e.preventDefault();
togglePanel();
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
doDownload();
}
});
// Menu fallback: works even if the floating group is hidden.
try {
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('📋 复制这条推文的 Markdown', doCopy);
GM_registerMenuCommand('⬇ 下载为 Markdown 文件', doDownload);
GM_registerMenuCommand('⚙ 批量抓取面板', togglePanel);
}
} catch (e) {
/* some managers restrict menu commands; the buttons still work */
}
})();
})();
评论