我的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![${alt}](${img.src})\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 => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[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>');
    // 引用块:连续 &gt; 开头行合并为 blockquote,内部用 <br> 换行
    html = html.replace(/(^|\n)((?:&gt; .*(?:\n|$))+)/g, (m, pre, block) => {
      const inner = block.trim().split('\n').map(l => l.replace(/^&gt;\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 '![' + alt + '](' + fullSrc + ')';
                } 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下载文章

安装油猴先。

img-2026-10-10-1605.png


// ==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 '![' + alt + '](' + src + ')';
    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 '![' + clean(img.getAttribute('alt') || '') + '](' + src + ')';
  }

  // ------------------------------------------------------------------ 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('- ![](' + src + ')');
      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 */
  }
})();
})();