Greasy Fork

Greasy Fork is available in English.

X Likes 下载器

下载 X (Twitter) 点赞列表中的图片、GIF和视频

当前为 2026-01-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         X Likes 下载器
// @namespace    https://github.com/K4F7/x-like-downloader
// @version      2.1.0
// @description  下载 X (Twitter) 点赞列表中的图片、GIF和视频
// @author       You
// @icon         https://abs.twimg.com/favicons/twitter.3.ico
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_download
// @connect      pbs.twimg.com
// @connect      video.twimg.com
// @connect      abs.twimg.com
// @connect      *
// @require      https://unpkg.com/[email protected]/umd/index.js
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ========== 样式 ==========
    GM_addStyle(`
        .xld-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.5);
            z-index: 9998;
            display: none;
        }
        .xld-overlay.active {
            display: block;
        }
        .xld-panel {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 360px;
            background: #15202b;
            border-radius: 16px;
            box-shadow: 0 0 30px rgba(0,0,0,0.5);
            z-index: 9999;
            display: none;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            color: #e7e9ea;
        }
        .xld-panel.active {
            display: block;
        }
        .xld-header {
            padding: 16px 20px;
            border-bottom: 1px solid #38444d;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .xld-title {
            font-size: 18px;
            font-weight: 700;
        }
        .xld-close {
            width: 32px;
            height: 32px;
            border-radius: 50%;
            border: none;
            background: transparent;
            color: #e7e9ea;
            font-size: 18px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .xld-close:hover {
            background: rgba(239, 243, 244, 0.1);
        }
        .xld-body {
            padding: 20px;
        }
        .xld-section {
            margin-bottom: 20px;
        }
        .xld-label {
            font-size: 14px;
            font-weight: 600;
            margin-bottom: 10px;
            color: #8b98a5;
        }
        .xld-date-row {
            display: flex;
            gap: 10px;
            align-items: center;
        }
        .xld-select, .xld-date-input {
            flex: 1;
            padding: 10px 12px;
            background: #273340;
            border: 1px solid #38444d;
            border-radius: 8px;
            color: #e7e9ea;
            font-size: 14px;
        }
        .xld-select:focus, .xld-date-input:focus {
            outline: none;
            border-color: #1d9bf0;
        }
        .xld-checkbox-group {
            display: flex;
            gap: 16px;
        }
        .xld-checkbox-label {
            display: flex;
            align-items: center;
            gap: 8px;
            cursor: pointer;
            font-size: 14px;
        }
        .xld-checkbox-label input {
            width: 18px;
            height: 18px;
            accent-color: #1d9bf0;
        }
        .xld-input-row {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            align-items: center;
            margin-top: 10px;
        }
        .xld-input-label {
            font-size: 12px;
            color: #8b98a5;
            min-width: 84px;
        }
        .xld-input-note {
            margin-top: 6px;
            font-size: 12px;
            color: #8b98a5;
        }
        .xld-resume-info {
            margin-top: 10px;
            padding: 10px 12px;
            background: #273340;
            border: 1px solid #38444d;
            border-radius: 8px;
            font-size: 12px;
            color: #e7e9ea;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
        }
        .xld-btn {
            width: 100%;
            padding: 12px;
            border-radius: 9999px;
            border: none;
            font-size: 15px;
            font-weight: 700;
            cursor: pointer;
            transition: background 0.2s;
        }
        .xld-btn-primary {
            background: #1d9bf0;
            color: #fff;
        }
        .xld-btn-primary:hover {
            background: #1a8cd8;
        }
        .xld-btn-primary:disabled {
            background: #1d9bf0;
            opacity: 0.5;
            cursor: not-allowed;
        }
        .xld-btn-secondary {
            background: transparent;
            color: #1d9bf0;
            border: 1px solid #536471;
            margin-top: 10px;
        }
        .xld-btn-secondary:hover {
            background: rgba(29, 155, 240, 0.1);
        }
        .xld-foreground-warning {
            position: fixed;
            top: 16px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10000;
            max-width: 92%;
            padding: 12px 16px;
            border-radius: 12px;
            background: #f4212e;
            color: #fff;
            font-size: 14px;
            font-weight: 700;
            box-shadow: 0 10px 24px rgba(0,0,0,0.35);
            display: none;
            text-align: center;
        }
        .xld-foreground-warning.active {
            display: block;
        }
        .xld-foreground-warning span {
            font-weight: 500;
        }
        .xld-status {
            margin-top: 16px;
            padding: 12px;
            background: #273340;
            border-radius: 8px;
            font-size: 13px;
            text-align: center;
            display: none;
        }
        .xld-status.active {
            display: block;
        }
        .xld-progress {
            margin-top: 8px;
            height: 4px;
            background: #38444d;
            border-radius: 2px;
            overflow: hidden;
        }
        .xld-progress-bar {
            height: 100%;
            background: #1d9bf0;
            width: 0%;
            transition: width 0.3s;
        }
        .xld-date-custom {
            display: none;
            margin-top: 10px;
        }
        .xld-date-custom.active {
            display: flex;
            gap: 10px;
        }
        .xld-marker-info {
            padding: 12px;
            background: #273340;
            border: 1px solid #38444d;
            border-radius: 8px;
            font-size: 13px;
            color: #e7e9ea;
            display: flex;
            align-items: center;
            gap: 12px;
        }
        .xld-marker-thumb {
            width: 48px;
            height: 48px;
            border-radius: 6px;
            object-fit: cover;
            flex-shrink: 0;
        }
        .xld-marker-text {
            flex: 1;
            overflow: hidden;
        }
        .xld-marker-title {
            font-size: 13px;
            color: #e7e9ea;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            margin-bottom: 4px;
        }
        .xld-marker-id {
            font-size: 11px;
            color: #8b98a5;
        }
        .xld-marker-empty {
            color: #8b98a5;
        }
        .xld-marker-hint {
            margin-top: 8px;
            font-size: 12px;
            color: #8b98a5;
        }
        .xld-marker-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 10px;
        }
        .xld-marker-actions {
            display: flex;
            gap: 8px;
        }
        .xld-btn-small {
            padding: 4px 10px;
            font-size: 12px;
            border-radius: 9999px;
            border: 1px solid #536471;
            background: transparent;
            color: #8b98a5;
            cursor: pointer;
            transition: all 0.2s;
        }
        .xld-btn-small:hover {
            background: rgba(239, 243, 244, 0.1);
            color: #e7e9ea;
        }
        .xld-btn-danger:hover {
            border-color: #f4212e;
            color: #f4212e;
        }
        .xld-btn-group {
            display: flex;
            flex-direction: column;
            gap: 12px;
            margin-top: 16px;
        }
        .xld-select-mode-bar {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            background: #1d9bf0;
            color: #fff;
            padding: 12px 20px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            z-index: 10000;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
        }
        .xld-select-mode-bar span {
            font-size: 14px;
            font-weight: 500;
        }
        .xld-select-mode-bar button {
            padding: 6px 16px;
            border-radius: 9999px;
            border: none;
            background: rgba(255,255,255,0.2);
            color: #fff;
            font-size: 13px;
            cursor: pointer;
        }
        .xld-select-mode-bar button:hover {
            background: rgba(255,255,255,0.3);
        }
        .xld-tweet-selectable {
            cursor: pointer !important;
            transition: outline 0.2s;
        }
        .xld-tweet-selectable:hover {
            outline: 3px solid #1d9bf0;
            outline-offset: -3px;
        }
        .xld-init-notice {
            padding: 12px;
            background: rgba(29, 155, 240, 0.1);
            border: 1px solid rgba(29, 155, 240, 0.3);
            border-radius: 8px;
            font-size: 13px;
            color: #8b98a5;
            margin-bottom: 12px;
            line-height: 1.5;
        }
    `);

    // ========== 状态 ==========
    const RESUME_ANCHOR_COUNT = 10;
    let isScanning = false;
    let collectedMedia = [];
    let lastScanMode = 'marker';
    let lastScanStopReason = null;
    let pendingResumeSnapshot = null;
    let isDownloading = false;
    let foregroundWarningEl = null;

    // ========== UI ==========
    function createPanel() {
        const overlay = document.createElement('div');
        overlay.className = 'xld-overlay';
        overlay.addEventListener('click', closePanel);

        const panel = document.createElement('div');
        panel.className = 'xld-panel';
        panel.innerHTML = `
            <div class="xld-header">
                <span class="xld-title">X Likes 下载器</span>
                <button class="xld-close">✕</button>
            </div>
            <div class="xld-body">
                <div class="xld-section">
                    <div class="xld-marker-header">
                        <div class="xld-label" style="margin-bottom:0">标记点</div>
                        <div class="xld-marker-actions" id="xld-marker-actions" style="display:none">
                            <button class="xld-btn-small" id="xld-select-marker-btn">选择</button>
                            <button class="xld-btn-small xld-btn-danger" id="xld-clear-marker-btn">清除</button>
                        </div>
                    </div>
                    <div class="xld-marker-info" id="xld-marker-info">
                        <span class="xld-marker-empty">未设置标记点</span>
                    </div>
                    <div class="xld-marker-hint">
                        扫描到标记点会自动停止,只下载新内容
                    </div>
                </div>
                <div class="xld-section">
                    <div class="xld-label">下载模式</div>
                    <div class="xld-checkbox-group">
                        <label class="xld-checkbox-label">
                            <input type="radio" name="xld-mode" value="marker" checked>
                            标记点
                        </label>
                        <label class="xld-checkbox-label">
                            <input type="radio" name="xld-mode" value="full">
                            全量下载
                        </label>
                    </div>
                    <div class="xld-input-row">
                        <span class="xld-input-label">单次上限</span>
                        <input type="number" id="xld-download-limit" class="xld-date-input" min="1" step="1">
                    </div>
                    <div class="xld-input-note">建议 200 个媒体/次,可自行调整</div>
                    <div class="xld-input-row">
                        <label class="xld-checkbox-label">
                            <input type="checkbox" id="xld-safe-mode">
                            安全模式(慢速定位)
                        </label>
                        <label class="xld-checkbox-label">
                            <input type="checkbox" id="xld-auto-pause">
                            后台自动暂停
                        </label>
                    </div>
                    <div class="xld-resume-info" id="xld-resume-info" style="display:none">
                        <span id="xld-resume-text">续传点:未设置</span>
                        <button class="xld-btn-small" id="xld-clear-resume-btn">清除</button>
                    </div>
                </div>
                <div class="xld-section" id="xld-init-section" style="display:none">
                    <div class="xld-init-notice">
                        首次使用,请先设置标记点。这会记住当前位置,之后只下载新点赞的内容。
                    </div>
                    <button class="xld-btn xld-btn-primary" id="xld-init-btn">自动设置(第一条)</button>
                    <button class="xld-btn xld-btn-secondary" id="xld-init-select-btn">手动选择推文</button>
                </div>
                <div class="xld-section">
                    <div class="xld-label">下载类型</div>
                    <div class="xld-checkbox-group">
                        <label class="xld-checkbox-label">
                            <input type="checkbox" id="xld-type-image" checked>
                            图片
                        </label>
                        <label class="xld-checkbox-label">
                            <input type="checkbox" id="xld-type-gif" checked>
                            GIF
                        </label>
                        <label class="xld-checkbox-label">
                            <input type="checkbox" id="xld-type-video">
                            视频
                        </label>
                    </div>
                </div>
                <div class="xld-btn-group">
                    <button class="xld-btn xld-btn-primary" id="xld-scan-btn">开始扫描</button>
                    <button class="xld-btn xld-btn-primary" id="xld-download-btn" style="display:none">
                        下载全部
                    </button>
                </div>
                <div class="xld-status" id="xld-status">
                    <span id="xld-status-text">准备就绪</span>
                    <div class="xld-progress">
                        <div class="xld-progress-bar" id="xld-progress-bar"></div>
                    </div>
                </div>
            </div>
        `;

        document.body.appendChild(overlay);
        document.body.appendChild(panel);

        // 事件绑定
        panel.querySelector('.xld-close').addEventListener('click', closePanel);
        panel.querySelector('#xld-scan-btn').addEventListener('click', startScan);
        panel.querySelector('#xld-download-btn').addEventListener('click', downloadAll);
        panel.querySelector('#xld-clear-marker-btn').addEventListener('click', clearMarker);
        panel.querySelector('#xld-init-btn').addEventListener('click', initMarker);
        panel.querySelector('#xld-init-select-btn').addEventListener('click', () => enterSelectMode());
        panel.querySelector('#xld-select-marker-btn').addEventListener('click', () => enterSelectMode());
        panel.querySelector('#xld-clear-resume-btn').addEventListener('click', clearResumePoint);

        const modeRadios = panel.querySelectorAll('input[name="xld-mode"]');
        modeRadios.forEach(radio => {
            radio.addEventListener('change', () => {
                GM_setValue('downloadMode', radio.value);
                updateModeDisplay();
            });
        });

        const savedMode = GM_getValue('downloadMode', 'marker');
        const savedRadio = panel.querySelector(`input[name="xld-mode"][value="${savedMode}"]`);
        if (savedRadio) {
            savedRadio.checked = true;
        }

        const limitInput = panel.querySelector('#xld-download-limit');
        if (limitInput) {
            const savedLimit = GM_getValue('downloadLimit', 200);
            limitInput.value = Number.isFinite(savedLimit) && savedLimit > 0 ? savedLimit : 200;
            limitInput.addEventListener('change', () => {
                const value = parseInt(limitInput.value, 10);
                const normalized = Number.isFinite(value) && value > 0 ? value : 200;
                limitInput.value = normalized;
                GM_setValue('downloadLimit', normalized);
            });
        }

        const safeModeCheckbox = panel.querySelector('#xld-safe-mode');
        if (safeModeCheckbox) {
            safeModeCheckbox.checked = GM_getValue('safeMode', false);
            safeModeCheckbox.addEventListener('change', () => {
                GM_setValue('safeMode', safeModeCheckbox.checked);
            });
        }

        const autoPauseCheckbox = panel.querySelector('#xld-auto-pause');
        if (autoPauseCheckbox) {
            autoPauseCheckbox.checked = GM_getValue('autoPause', true);
            autoPauseCheckbox.addEventListener('change', () => {
                GM_setValue('autoPause', autoPauseCheckbox.checked);
            });
        }

        // 初始化显示
        updateModeDisplay();

        return { overlay, panel };
    }

    let panelElements = null;

    function openPanel() {
        if (!panelElements) {
            panelElements = createPanel();
        }
        panelElements.overlay.classList.add('active');
        panelElements.panel.classList.add('active');
    }

    document.addEventListener('visibilitychange', () => {
        updateForegroundWarning();
    });

    function closePanel() {
        if (panelElements) {
            panelElements.overlay.classList.remove('active');
            panelElements.panel.classList.remove('active');
        }
    }

    function ensureForegroundWarning() {
        if (foregroundWarningEl) return;
        foregroundWarningEl = document.createElement('div');
        foregroundWarningEl.className = 'xld-foreground-warning';
        document.body.appendChild(foregroundWarningEl);
    }

    function showForegroundWarning(message) {
        ensureForegroundWarning();
        foregroundWarningEl.innerHTML = message;
        foregroundWarningEl.classList.add('active');
    }

    function hideForegroundWarning() {
        if (foregroundWarningEl) {
            foregroundWarningEl.classList.remove('active');
        }
    }

    function updateForegroundWarning() {
        if (!isScanning && !isDownloading) {
            hideForegroundWarning();
            return;
        }

        if (document.hidden) {
            showForegroundWarning('当前标签页在后台,扫描/下载可能停滞。<span>请切回前台或单独拉出窗口。</span>');
            return;
        }

        showForegroundWarning('请保持当前标签页在前台以保证扫描和下载正常进行。<span>建议单独拉出窗口。</span>');
    }

    function getDownloadMode() {
        const selected = document.querySelector('input[name="xld-mode"]:checked');
        if (selected && selected.value) return selected.value;
        return GM_getValue('downloadMode', 'marker');
    }

    function getDownloadLimit() {
        const input = document.getElementById('xld-download-limit');
        const value = input ? parseInt(input.value, 10) : GM_getValue('downloadLimit', 200);
        if (Number.isFinite(value) && value > 0) return value;
        return 200;
    }

    function getSafeMode() {
        const input = document.getElementById('xld-safe-mode');
        if (input) return input.checked;
        return GM_getValue('safeMode', false);
    }

    function getAutoPause() {
        const input = document.getElementById('xld-auto-pause');
        if (input) return input.checked;
        return GM_getValue('autoPause', true);
    }

    function updateResumeDisplay() {
        const resumeInfo = document.getElementById('xld-resume-info');
        const resumeText = document.getElementById('xld-resume-text');
        if (!resumeInfo || !resumeText) return;

        const mode = getDownloadMode();
        if (mode !== 'full') {
            resumeInfo.style.display = 'none';
            return;
        }

        const savedSnapshot = GM_getValue('fullResumeSnapshot', null);
        const savedResume = savedSnapshot?.resumePoint || GM_getValue('fullResumePoint', null);
        if (savedResume && savedResume.id) {
            const shortId = savedResume.id.substring(0, 8) + '...';
            const displayText = savedResume.text || '(无文字内容)';
            resumeText.textContent = `续传点:${displayText} (ID: ${shortId})`;
        } else {
            resumeText.textContent = '续传点:未设置';
        }
        resumeInfo.style.display = 'flex';
    }

    function updateModeDisplay() {
        updateMarkerDisplay();
        updateResumeDisplay();
    }

    function updateMarkerDisplay() {
        const markerInfo = document.getElementById('xld-marker-info');
        const markerActions = document.getElementById('xld-marker-actions');
        const initSection = document.getElementById('xld-init-section');
        const scanBtn = document.getElementById('xld-scan-btn');
        const savedMarker = GM_getValue('markerTweetId', null);
        const mode = getDownloadMode();
        const isMarkerMode = mode === 'marker';

        if (savedMarker && savedMarker.id) {
            // 显示缩略图和标题
            let thumbHtml = '';
            if (savedMarker.thumbnail) {
                thumbHtml = `<img class="xld-marker-thumb" src="${savedMarker.thumbnail}" alt="缩略图">`;
            }

            const displayText = savedMarker.text || '(无文字内容)';
            const shortId = savedMarker.id.substring(0, 8) + '...';

            markerInfo.innerHTML = `
                ${thumbHtml}
                <div class="xld-marker-text">
                    <div class="xld-marker-title" title="${savedMarker.text || ''}">${displayText}</div>
                    <div class="xld-marker-id">ID: ${shortId}</div>
                </div>
            `;

            if (markerActions) markerActions.style.display = 'flex';
            if (initSection) initSection.style.display = 'none';
            if (scanBtn) scanBtn.style.display = 'block';
        } else {
            markerInfo.innerHTML = `<span class="xld-marker-empty">未设置标记点</span>`;
            if (markerActions) markerActions.style.display = 'none';
            if (initSection) initSection.style.display = isMarkerMode ? 'block' : 'none';
            if (scanBtn) scanBtn.style.display = isMarkerMode ? 'none' : 'block';
        }
    }

    function clearMarker() {
        if (confirm('确定要清除标记点吗?需要重新设置才能使用。')) {
            GM_setValue('markerTweetId', null);
            updateMarkerDisplay();
            updateStatus('标记点已清除');
        }
    }

    function clearResumePoint() {
        if (!confirm('确定要清除续传点吗?此操作会让全量下载从头开始。')) {
            return;
        }
        const confirmText = prompt('请输入“清除”以确认继续:');
        if (confirmText !== '清除') {
            updateStatus('已取消清除续传点');
            return;
        }
        if (!confirm('最后确认:是否清除续传点?')) {
            updateStatus('已取消清除续传点');
            return;
        }
        GM_setValue('fullResumePoint', null);
        GM_setValue('fullResumeSnapshot', null);
        updateResumeDisplay();
        updateStatus('续传点已清除');
    }

    // ========== 选择模式 ==========
    let isSelectMode = false;
    let selectModeBar = null;

    function enterSelectMode() {
        // 检查是否在likes页面
        const currentUrl = window.location.href;
        if (!currentUrl.includes('/likes')) {
            const username = getCurrentUsername();
            if (username) {
                alert('请先打开你的 Likes 页面,然后再选择标记点');
                window.location.href = `https://x.com/${username}/likes`;
                return;
            } else {
                alert('请先登录');
                return;
            }
        }

        isSelectMode = true;
        closePanel();

        // 创建顶部提示条
        selectModeBar = document.createElement('div');
        selectModeBar.className = 'xld-select-mode-bar';
        selectModeBar.innerHTML = `
            <span>点击任意推文将其设为标记点</span>
            <button id="xld-cancel-select">取消</button>
        `;
        document.body.appendChild(selectModeBar);

        selectModeBar.querySelector('#xld-cancel-select').addEventListener('click', exitSelectMode);

        // 给所有推文添加可选样式和点击事件
        const tweets = document.querySelectorAll('[data-testid="tweet"]');
        tweets.forEach(tweet => {
            tweet.classList.add('xld-tweet-selectable');
            tweet.addEventListener('click', handleTweetSelect, true);
        });

        // 监听新加载的推文
        startTweetObserver();
    }

    function exitSelectMode() {
        isSelectMode = false;

        // 移除顶部提示条
        if (selectModeBar) {
            selectModeBar.remove();
            selectModeBar = null;
        }

        // 移除推文的可选样式和事件
        const tweets = document.querySelectorAll('.xld-tweet-selectable');
        tweets.forEach(tweet => {
            tweet.classList.remove('xld-tweet-selectable');
            tweet.removeEventListener('click', handleTweetSelect, true);
        });

        // 停止监听
        stopTweetObserver();

        // 重新打开面板
        openPanel();
    }

    function handleTweetSelect(event) {
        if (!isSelectMode) return;

        event.preventDefault();
        event.stopPropagation();

        const tweet = event.currentTarget;
        const markerData = extractTweetInfo(tweet);

        if (markerData.id) {
            GM_setValue('markerTweetId', markerData);
            exitSelectMode();
            updateMarkerDisplay();
            updateStatus('标记点已设置');
        } else {
            alert('无法获取该推文的ID,请选择其他推文');
        }
    }

    let tweetObserver = null;

    function startTweetObserver() {
        tweetObserver = new MutationObserver((mutations) => {
            if (!isSelectMode) return;

            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1) {
                        const tweets = node.querySelectorAll ? node.querySelectorAll('[data-testid="tweet"]') : [];
                        tweets.forEach(tweet => {
                            if (!tweet.classList.contains('xld-tweet-selectable')) {
                                tweet.classList.add('xld-tweet-selectable');
                                tweet.addEventListener('click', handleTweetSelect, true);
                            }
                        });
                        // 检查节点本身是否是推文
                        if (node.matches && node.matches('[data-testid="tweet"]') && !node.classList.contains('xld-tweet-selectable')) {
                            node.classList.add('xld-tweet-selectable');
                            node.addEventListener('click', handleTweetSelect, true);
                        }
                    }
                });
            });
        });

        tweetObserver.observe(document.body, { childList: true, subtree: true });
    }

    function stopTweetObserver() {
        if (tweetObserver) {
            tweetObserver.disconnect();
            tweetObserver = null;
        }
    }

    async function initMarker() {
        const initBtn = document.getElementById('xld-init-btn');
        initBtn.disabled = true;
        initBtn.textContent = '正在初始化...';

        // 检查是否在likes页面
        const currentUrl = window.location.href;
        if (!currentUrl.includes('/likes')) {
            const username = getCurrentUsername();
            if (username) {
                updateStatus('请先打开你的 Likes 页面');
                initBtn.disabled = false;
                initBtn.textContent = '初始化标记点';
                window.location.href = `https://x.com/${username}/likes`;
                return;
            } else {
                updateStatus('请先登录');
                initBtn.disabled = false;
                initBtn.textContent = '初始化标记点';
                return;
            }
        }

        // 等待页面加载
        await sleep(1000);

        // 获取第一条推文
        const tweets = document.querySelectorAll('[data-testid="tweet"]');
        if (tweets.length > 0) {
            const firstTweet = tweets[0];
            const markerData = extractTweetInfo(firstTweet);

            if (markerData.id) {
                GM_setValue('markerTweetId', markerData);
                updateMarkerDisplay();
                updateStatus('初始化成功!标记点已设置,现在可以开始扫描');
            } else {
                updateStatus('无法获取推文ID,请刷新页面重试');
            }
        } else {
            updateStatus('未找到推文,请确保页面已加载完成');
        }

        initBtn.disabled = false;
        initBtn.textContent = '初始化标记点';
    }

    // 提取推文的完整信息(ID、文本、缩略图)- 用于显示和保存
    function extractTweetInfo(tweet) {
        const id = extractTweetId(tweet);

        // 提取推文作者名
        let authorName = '';
        // 方法1: 从用户头像旁边的链接获取
        const userNameEl = tweet.querySelector('[data-testid="User-Name"]');
        if (userNameEl) {
            // 第一个 span 通常是显示名称
            const nameSpan = userNameEl.querySelector('a span');
            if (nameSpan) {
                authorName = nameSpan.textContent.trim();
            }
        }

        // 提取推文文本(完整版用于匹配)
        let fullText = '';
        let text = '';
        const tweetTextEl = tweet.querySelector('[data-testid="tweetText"]');
        if (tweetTextEl) {
            fullText = tweetTextEl.textContent.trim();
            text = fullText;
            // 显示用的截断版本
            if (text.length > 50) {
                text = text.substring(0, 50) + '...';
            }
        }

        // 如果没有文字内容,用作者名填充
        if (!text && authorName) {
            text = `@${authorName} 的推文`;
            fullText = text;
        }

        // 提取缩略图URL(用于显示)
        let thumbnail = '';
        // 提取媒体ID(用于匹配)
        let mediaId = '';
        const img = tweet.querySelector('[data-testid="tweetPhoto"] img');
        if (img && img.src) {
            // 使用小尺寸缩略图用于显示
            thumbnail = img.src.replace(/&name=\w+/, '&name=small');
            // 提取媒体ID用于匹配
            const mediaMatch = img.src.match(/\/media\/([A-Za-z0-9_-]+)/);
            if (mediaMatch) {
                mediaId = mediaMatch[1];
            }
        }

        return { id, text, fullText, thumbnail, mediaId, authorName };
    }

    function updateStatus(text, progress = null) {
        const statusDiv = document.getElementById('xld-status');
        const statusText = document.getElementById('xld-status-text');
        const progressBar = document.getElementById('xld-progress-bar');

        statusDiv.classList.add('active');
        statusText.textContent = text;

        if (progress !== null) {
            progressBar.style.width = `${progress}%`;
        }
    }

    // ========== 日期工具 ==========
    function getSelectedTypes() {
        return {
            image: document.getElementById('xld-type-image').checked,
            gif: document.getElementById('xld-type-gif').checked,
            video: document.getElementById('xld-type-video').checked
        };
    }

    // ========== 扫描逻辑 ==========
    let firstTweetInfo = null; // 记录本次扫描的第一条推文信息,用于设置新标记

    async function startScan() {
        if (isScanning) return;

        // 检查是否在likes页面
        const currentUrl = window.location.href;
        if (!currentUrl.includes('/likes')) {
            const username = getCurrentUsername();
            if (username) {
                updateStatus('正在跳转到 Likes 页面...');
                window.location.href = `https://x.com/${username}/likes`;
                return;
            } else {
                updateStatus('请先登录或手动打开 Likes 页面');
                return;
            }
        }

        const mode = getDownloadMode();
        const types = getSelectedTypes();
        const limit = getDownloadLimit();
        const scanOptions = { mode, limit, safetyMode: getSafeMode(), autoPause: getAutoPause() };
        let statusText = '开始扫描...';

        if (mode === 'marker') {
            const savedMarker = GM_getValue('markerTweetId', null);
            if (!savedMarker || !savedMarker.id) {
                updateStatus('请先设置标记点');
                return;
            }
            scanOptions.savedMarker = savedMarker;
            statusText = '开始扫描(到标记点停止)...';
        } else {
            const resumeSnapshot = GM_getValue('fullResumeSnapshot', null);
            const resumePoint = resumeSnapshot?.resumePoint || GM_getValue('fullResumePoint', null);
            scanOptions.resumePoint = resumePoint;
            scanOptions.anchors = resumeSnapshot?.anchors || null;
            statusText = resumePoint ? '开始扫描(从上次进度继续)...' : '开始扫描(全量下载)...';
        }

        isScanning = true;
        lastScanMode = mode;
        lastScanStopReason = null;
        pendingResumeSnapshot = null;
        collectedMedia = [];
        firstTweetInfo = null;

        const scanBtn = document.getElementById('xld-scan-btn');
        const downloadBtn = document.getElementById('xld-download-btn');

        scanBtn.disabled = true;
        scanBtn.textContent = '扫描中...';
        downloadBtn.style.display = 'none';

        updateStatus(statusText, 0);
        updateForegroundWarning();

        try {
            const scanResult = await scanLikes(types, scanOptions);
            lastScanStopReason = scanResult.stopReason;
            pendingResumeSnapshot = scanResult.resumeSnapshot || null;

            let completionMsg = '';
            if (scanResult.stopReason === 'marker') {
                completionMsg = `扫描完成!找到 ${collectedMedia.length} 个新文件(已到达标记点)`;
            } else if (scanResult.stopReason === 'limit') {
                completionMsg = `扫描完成!已达到单次上限(${limit} 个媒体)`;
            } else if (scanResult.stopReason === 'resume-missing') {
                completionMsg = '未找到续传点,请清除续传点后重试';
                if (mode === 'full') {
                    GM_setValue('fullResumePoint', null);
                    GM_setValue('fullResumeSnapshot', null);
                    updateResumeDisplay();
                }
            } else {
                completionMsg = `扫描完成!找到 ${collectedMedia.length} 个文件`;
            }

            if (scanResult.fallbackUsed) {
                completionMsg += '(续传点未找到,已使用锚点继续下载,可能有少量重复)';
            }

            updateStatus(completionMsg, 100);

            if (collectedMedia.length > 0) {
                downloadBtn.style.display = 'block';
                downloadBtn.textContent = `下载全部 (${collectedMedia.length} 个文件)`;
            } else {
                const emptyMsg = mode === 'full'
                    ? '没有找到可下载的媒体文件'
                    : '没有找到新的媒体文件';
                updateStatus(emptyMsg, 100);
            }
        } catch (error) {
            updateStatus(`扫描出错: ${error.message}`, 0);
            console.error('扫描错误:', error);
        }

        isScanning = false;
        scanBtn.disabled = false;
        scanBtn.textContent = '重新扫描';
        updateForegroundWarning();
    }

    function getCurrentUsername() {
        // 尝试从页面获取当前登录用户名
        const accountSwitcher = document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]');
        if (accountSwitcher) {
            const spans = accountSwitcher.querySelectorAll('span');
            for (const span of spans) {
                if (span.textContent.startsWith('@')) {
                    return span.textContent.slice(1);
                }
            }
        }
        return null;
    }

    async function scanLikes(types, options) {
        const seenUrls = new Set();
        const seenTweetIds = new Set();
        let noNewContentCount = 0;
        let reachedMarker = false;
        let reachedLimit = false;
        let totalScanned = 0;
        let lastSeenCount = 0;
        const mode = options?.mode || 'marker';
        const savedMarker = options?.savedMarker || null;
        const resumePoint = options?.resumePoint || null;
        const anchors = options?.anchors || null;
        const limit = Number.isFinite(options?.limit) && options.limit > 0 ? options.limit : Infinity;
        const safetyMode = !!options?.safetyMode;
        const autoPause = !!options?.autoPause;
        let resumeFound = !resumePoint;
        let fallbackUsed = false;
        let seekStatusShown = false;
        let limitResumeSnapshot = null;
        let seekMode = resumePoint ? (safetyMode ? 'lock' : 'fast') : 'none';
        let lockNoticeShown = false;

        console.log('[XLD] ========== 开始扫描 ==========');
        if (mode === 'marker') {
            console.log('[XLD] 标记点信息:', JSON.stringify(savedMarker, null, 2));
        } else {
            console.log('[XLD] 全量下载模式,续传点:', JSON.stringify(resumePoint, null, 2));
        }

        while (noNewContentCount < 8 && !reachedMarker && !reachedLimit) {
            await waitForForegroundIfNeeded(autoPause);

            // 获取当前可见的推文
            const tweets = document.querySelectorAll('[data-testid="tweet"]');

            for (const tweet of tweets) {
                await waitForForegroundIfNeeded(autoPause);
                const tweetId = extractTweetId(tweet);

                // 如果无法提取ID,跳过
                if (!tweetId) continue;

                // 【关键】无论是否处理过,都要检查是否是标记点
                if (mode === 'marker' && savedMarker) {
                    const isMarker = isMarkerTweet(tweet, savedMarker);
                    if (isMarker) {
                        console.log('[XLD] ✓✓✓ 找到标记点!停止扫描 ✓✓✓');
                        reachedMarker = true;
                        break;
                    }
                }

                // 跳过已处理的推文(只用于媒体收集)
                if (seenTweetIds.has(tweetId)) continue;
                seenTweetIds.add(tweetId);
                totalScanned++;

                if (mode === 'full' && !resumeFound) {
                    if (!seekStatusShown) {
                        updateStatus('正在定位续传点...', null);
                        seekStatusShown = true;
                    }
                    let anchorSide = null;
                    if (anchors) {
                        anchorSide = matchAnchorTweet(tweet, anchors);
                    }
                    if (anchorSide === 'before') {
                        fallbackUsed = true;
                        resumeFound = true;
                        updateStatus('续传点未出现,已使用锚点继续下载(可能有少量重复)', null);
                        continue;
                    }
                    if (seekMode === 'fast' && anchorSide) {
                        seekMode = 'lock';
                        if (!lockNoticeShown) {
                            updateStatus('已定位到快照区间,正在精确定位续传点...', null);
                            lockNoticeShown = true;
                        }
                    }
                    if (isResumeTweet(tweet, resumePoint)) {
                        resumeFound = true;
                        updateStatus('已定位续传点,开始下载...', null);
                        continue;
                    }
                    if (totalScanned % 30 === 0) {
                        updateStatus(`正在定位续传点... 已扫描 ${totalScanned} 条`, null);
                    }
                    continue;
                }

                // 每处理10条推文输出一次日志
                if (totalScanned % 10 === 0) {
                    console.log(`[XLD] 已扫描 ${totalScanned} 条推文,找到 ${collectedMedia.length} 个媒体`);
                }

                // 记录第一条推文信息(最新的点赞)
                if (!firstTweetInfo) {
                    firstTweetInfo = extractTweetInfo(tweet);
                    console.log('[XLD] 第一条推文:', firstTweetInfo.id, firstTweetInfo.text);
                }

                // 提取媒体(DOM优先,API兜底)
                const mediaItems = await extractMediaWithApiFallback(tweet, types);
                for (const item of mediaItems) {
                    if (!seenUrls.has(item.url)) {
                        seenUrls.add(item.url);
                        collectedMedia.push(item);
                    }
                }

                if (collectedMedia.length >= limit) {
                    reachedLimit = true;
                    if (mode === 'full') {
                        limitResumeSnapshot = buildResumeSnapshot(tweet);
                    }
                    break;
                }

                updateStatus(`已扫描 ${totalScanned} 条推文,找到 ${collectedMedia.length} 个文件...`, null);
            }

            if (reachedMarker || reachedLimit) break;

            // 根据是否在定位续传点调整滚动速度
            const seeking = mode === 'full' && resumePoint && !resumeFound;
            const fastSeeking = seeking && seekMode === 'fast' && !safetyMode;
            const slowSeeking = seeking && !fastSeeking;
            const scrollStep = fastSeeking
                ? window.innerHeight * 2.2
                : slowSeeking
                    ? window.innerHeight * 0.6
                    : window.innerHeight * 0.8;
            const delayMs = fastSeeking ? 200 : slowSeeking ? 900 : 800;
            window.scrollBy(0, scrollStep);
            await sleep(delayMs); // 等待推文加载

            // 检查是否有新推文加载
            const currentSeenCount = seenTweetIds.size;
            if (currentSeenCount === lastSeenCount) {
                noNewContentCount++;
                console.log(`[XLD] 没有新推文 (${noNewContentCount}/8)`);
            } else {
                noNewContentCount = 0;
            }
            lastSeenCount = currentSeenCount;
        }

        console.log('[XLD] ========== 扫描结束 ==========');
        console.log(`[XLD] 共扫描 ${totalScanned} 条,找到 ${collectedMedia.length} 个媒体,到达标记点: ${reachedMarker}, 达到上限: ${reachedLimit}`);

        if (mode === 'full' && resumePoint && !resumeFound) {
            return { stopReason: 'resume-missing', resumePoint: null, resumeSnapshot: null, fallbackUsed };
        }
        if (reachedMarker) return { stopReason: 'marker', resumePoint: null, fallbackUsed };
        if (reachedLimit) return { stopReason: 'limit', resumePoint: null, resumeSnapshot: limitResumeSnapshot, fallbackUsed };
        return { stopReason: 'end', resumePoint: null, resumeSnapshot: null, fallbackUsed };
    }

    function extractMediaFromTweet(tweet, types) {
        const media = [];
        const tweetId = extractTweetId(tweet);

        // 图片
        if (types.image || types.gif) {
            const images = tweet.querySelectorAll('[data-testid="tweetPhoto"] img');
            images.forEach((img, index) => {
                let url = img.src;

                // 获取原图质量
                if (url.includes('pbs.twimg.com/media/')) {
                    url = url.replace(/\?format=\w+/, '?format=jpg')
                             .replace(/&name=\w+/, '&name=orig');
                    if (!url.includes('?format=')) {
                        url = url.split('?')[0] + '?format=jpg&name=orig';
                    }
                }

                // 判断是否为GIF
                const isGif = img.closest('[data-testid="tweetPhoto"]')?.querySelector('video') != null ||
                              url.includes('tweet_video_thumb');

                if (isGif && types.gif) {
                    media.push({
                        type: 'gif',
                        url: url,
                        filename: `${tweetId}_gif_${index}.jpg`,
                        tweetId
                    });
                } else if (!isGif && types.image) {
                    media.push({
                        type: 'image',
                        url: url,
                        filename: `${tweetId}_img_${index}.jpg`,
                        tweetId
                    });
                }
            });
        }

        // 视频
        if (types.video) {
            const videos = tweet.querySelectorAll('video');
            videos.forEach((video, index) => {
                let url = video.src;
                if (url && url.includes('video.twimg.com')) {
                    media.push({
                        type: 'video',
                        url: url,
                        filename: `${tweetId}_video_${index}.mp4`,
                        tweetId
                    });
                }
            });
        }

        return media;
    }

    async function extractMediaWithApiFallback(tweet, types) {
        const domMedia = extractMediaFromTweet(tweet, types);
        const tweetId = extractTweetId(tweet);
        if (!tweetId) return domMedia;

        const shouldFetchApi = types.video || domMedia.length === 0;
        if (!shouldFetchApi) return domMedia;

        try {
            const tweetData = await fetchTweetByApi(tweetId);
            if (!tweetData) return domMedia;

            const apiResult = extractMediaFromApi(tweetData);
            const apiMedia = Array.isArray(apiResult?.media) ? apiResult.media : [];
            const filteredApi = apiMedia
                .filter(item => {
                    if (item.type === 'image') return types.image;
                    if (item.type === 'gif') return types.gif;
                    if (item.type === 'video') return types.video;
                    return false;
                })
                .map((item, index) => ({
                    type: item.type,
                    url: item.url,
                    filename: item.filename || `${tweetId}_${item.type}_${index}`,
                    tweetId
                }));

            if (types.video) {
                const apiVideos = filteredApi.filter(item => item.type === 'video');
                if (apiVideos.length > 0) {
                    const nonVideoDom = domMedia.filter(item => item.type !== 'video');
                    return [...nonVideoDom, ...apiVideos];
                }
            }

            if (domMedia.length > 0) return domMedia;
            return filteredApi;
        } catch (error) {
            console.warn('[XLD] API媒体提取失败:', tweetId, error);
            return domMedia;
        }
    }

    function extractTweetId(tweet) {
        // 方法1:从推文时间戳链接提取(最可靠)
        // 时间戳链接通常是 /username/status/123456 格式,且在推文主体内
        const timeLink = tweet.querySelector('time')?.closest('a[href*="/status/"]');
        if (timeLink) {
            const match = timeLink.href.match(/\/status\/(\d+)/);
            if (match) return match[1];
        }

        // 方法2:从推文内的所有status链接中找到属于推文作者的
        const links = tweet.querySelectorAll('a[href*="/status/"]');
        for (const link of links) {
            // 排除引用推文(通常在一个嵌套的article或特定容器内)
            const isQuoteTweet = link.closest('[data-testid="tweet"]') !== tweet;
            if (!isQuoteTweet) {
                const match = link.href.match(/\/status\/(\d+)/);
                if (match) return match[1];
            }
        }

        // 方法3:兜底,使用第一个找到的
        const anyLink = tweet.querySelector('a[href*="/status/"]');
        if (anyLink) {
            const match = anyLink.href.match(/\/status\/(\d+)/);
            if (match) return match[1];
        }

        return null; // 不再返回时间戳,返回null表示提取失败
    }

    // 提取推文的完整信息用于标记点匹配
    function extractFullTweetInfo(tweet) {
        const id = extractTweetId(tweet);

        // 提取完整推文文本(不截断,用于匹配)
        let fullText = '';
        const tweetTextEl = tweet.querySelector('[data-testid="tweetText"]');
        if (tweetTextEl) {
            fullText = tweetTextEl.textContent.trim();
        }

        // 提取缩略图的媒体ID(从URL中提取,更稳定)
        let mediaId = '';
        const img = tweet.querySelector('[data-testid="tweetPhoto"] img');
        if (img && img.src) {
            // 从 pbs.twimg.com/media/xxxxx 提取媒体ID
            const mediaMatch = img.src.match(/\/media\/([A-Za-z0-9_-]+)/);
            if (mediaMatch) {
                mediaId = mediaMatch[1];
            }
        }

        // 提取推文作者用户名
        let authorUsername = '';
        const authorLink = tweet.querySelector('a[href^="/"][role="link"]');
        if (authorLink) {
            const usernameMatch = authorLink.href.match(/x\.com\/([^\/]+)/);
            if (usernameMatch) {
                authorUsername = usernameMatch[1];
            }
        }

        return { id, fullText, mediaId, authorUsername };
    }

    function buildResumeSnapshot(targetTweet) {
        const resumePoint = extractTweetInfo(targetTweet);
        const snapshot = {
            resumePoint: resumePoint || null,
            anchors: { before: [], after: [] },
            timestamp: Date.now()
        };

        if (!resumePoint || !resumePoint.id) return snapshot;

        const tweets = Array.from(document.querySelectorAll('[data-testid="tweet"]'));
        if (tweets.length === 0) return snapshot;

        const targetIndex = tweets.findIndex(item => extractTweetId(item) === resumePoint.id);
        if (targetIndex === -1) return snapshot;

        const beforeTweets = tweets.slice(Math.max(0, targetIndex - RESUME_ANCHOR_COUNT), targetIndex);
        const afterTweets = tweets.slice(targetIndex + 1, targetIndex + 1 + RESUME_ANCHOR_COUNT);

        snapshot.anchors.before = beforeTweets
            .map(extractFullTweetInfo)
            .filter(info => info && info.id);
        snapshot.anchors.after = afterTweets
            .map(extractFullTweetInfo)
            .filter(info => info && info.id);

        return snapshot;
    }

    function matchAnchorTweet(tweet, anchors) {
        if (!anchors) return null;
        const before = Array.isArray(anchors.before) ? anchors.before : [];
        const after = Array.isArray(anchors.after) ? anchors.after : [];

        for (const anchor of before) {
            if (isMatchTweet(tweet, anchor, '快照(前)')) return 'before';
        }
        for (const anchor of after) {
            if (isMatchTweet(tweet, anchor, '快照(后)')) return 'after';
        }
        return null;
    }

    function isMatchTweet(tweet, savedPoint, label) {
        if (!savedPoint) return false;

        const currentInfo = extractFullTweetInfo(tweet);
        let matchScore = 0;
        let matchReasons = [];

        // 1. ID精确匹配(权重最高)
        if (currentInfo.id && savedPoint.id && currentInfo.id === savedPoint.id) {
            matchScore += 3;
            matchReasons.push('ID匹配');
        }

        // 2. 媒体ID匹配(非常可靠,媒体ID是唯一的)
        if (currentInfo.mediaId && savedPoint.mediaId && currentInfo.mediaId === savedPoint.mediaId) {
            matchScore += 2;
            matchReasons.push('媒体ID匹配');
        }

        // 3. 文本匹配(检查是否包含,因为保存时可能被截断)
        if (currentInfo.fullText && savedPoint.fullText) {
            // 如果保存的文本是完整文本的前缀,或者完全相同
            if (currentInfo.fullText === savedPoint.fullText ||
                currentInfo.fullText.startsWith(savedPoint.fullText) ||
                savedPoint.fullText.startsWith(currentInfo.fullText)) {
                matchScore += 1;
                matchReasons.push('文本匹配');
            }
        }

        // 判断逻辑:
        // - ID匹配 → 直接认定(分数>=3)
        // - 媒体ID + 文本匹配 → 认定(分数>=3)
        // - 仅媒体ID匹配 → 认定(分数>=2,媒体ID本身就很可靠)
        const isMatch = matchScore >= 2;

        // 调试:显示每个推文的匹配情况(只显示有部分匹配的)
        if (matchScore > 0 || currentInfo.id === savedPoint.id) {
            console.log(`[XLD] ${label}匹配检查: ID=${currentInfo.id}, 分数=${matchScore}, 原因=[${matchReasons.join(',')}]`);
            console.log(`[XLD]   当前: mediaId=${currentInfo.mediaId}, text=${currentInfo.fullText?.substring(0,30)}`);
            console.log(`[XLD]   ${label}: mediaId=${savedPoint.mediaId}, text=${savedPoint.fullText?.substring(0,30)}`);
        }

        return isMatch;
    }

    // 检查是否是标记点推文(多重验证)
    function isMarkerTweet(tweet, savedMarker) {
        return isMatchTweet(tweet, savedMarker, '标记点');
    }

    // 检查是否是续传点推文(多重验证)
    function isResumeTweet(tweet, resumePoint) {
        return isMatchTweet(tweet, resumePoint, '续传点');
    }

    // ========== 下载逻辑 ==========
    async function downloadAll() {
        if (collectedMedia.length === 0) {
            updateStatus('没有可下载的文件');
            return;
        }

        const downloadBtn = document.getElementById('xld-download-btn');
        const autoPause = getAutoPause();
        downloadBtn.disabled = true;
        isDownloading = true;
        updateForegroundWarning();

        // 生成文件名:[Xlike]2024-01-08.zip
        const dateStr = new Date().toISOString().split('T')[0];
        const zipFileName = `[Xlike]${dateStr}.zip`;

        let completed = 0;
        let failed = 0;

        // 检查 fflate 是否可用
        if (typeof fflate === 'undefined') {
            updateStatus('fflate 未加载,请刷新页面重试');
            downloadBtn.disabled = false;
            isDownloading = false;
            updateForegroundWarning();
            return;
        }

        const files = {};

        updateStatus(`正在下载文件...`, 0);

        // 第一步:下载所有文件到内存
        for (const item of collectedMedia) {
            await waitForForegroundIfNeeded(autoPause);
            try {
                updateStatus(`下载中 (${completed + 1}/${collectedMedia.length}): ${item.filename}`, (completed / collectedMedia.length) * 70);

                const blob = await fetchMedia(item.url);
                if (blob && blob.size > 0) {
                    // 转换 Blob 为 Uint8Array
                    const arrayBuffer = await blob.arrayBuffer();
                    files[item.filename] = new Uint8Array(arrayBuffer);
                } else {
                    failed++;
                }
            } catch (error) {
                console.error(`下载失败: ${item.url}`, error);
                failed++;
            }

            completed++;
        }

        if (Object.keys(files).length === 0) {
            updateStatus('所有文件下载失败,请检查网络');
            downloadBtn.disabled = false;
            isDownloading = false;
            updateForegroundWarning();
            return;
        }

        // 第二步:使用 fflate 生成 ZIP
        updateStatus(`正在打包 ${Object.keys(files).length} 个文件...`, 75);

        try {
            // fflate.zipSync 同步打包(不压缩,速度快)
            const zipped = fflate.zipSync(files, { level: 0 });

            // 转换为 Blob
            const blob = new Blob([zipped], { type: 'application/zip' });

            updateStatus('正在保存 ZIP 文件...', 90);

            // 第三步:下载 ZIP
            const blobUrl = URL.createObjectURL(blob);

            // 使用 a 标签下载(更可靠)
            const a = document.createElement('a');
            a.href = blobUrl;
            a.download = zipFileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            // 延迟释放 URL
            setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);

            // 更新标记点(仅标记点模式且确实到达标记点)
            if (lastScanMode === 'marker' && lastScanStopReason === 'marker' && firstTweetInfo && firstTweetInfo.id) {
                GM_setValue('markerTweetId', firstTweetInfo);
                updateMarkerDisplay();
            }

            // 更新续传点(全量下载模式)
            if (lastScanMode === 'full') {
                if (lastScanStopReason === 'limit' && pendingResumeSnapshot && pendingResumeSnapshot.resumePoint?.id) {
                    GM_setValue('fullResumeSnapshot', pendingResumeSnapshot);
                    GM_setValue('fullResumePoint', pendingResumeSnapshot.resumePoint);
                } else if (lastScanStopReason === 'end') {
                    GM_setValue('fullResumeSnapshot', null);
                    GM_setValue('fullResumePoint', null);
                }
                updateResumeDisplay();
            }

            const failMsg = failed > 0 ? ` (${failed} 个失败)` : '';
            updateStatus(`下载完成!已保存为 ${zipFileName}${failMsg}`, 100);

        } catch (error) {
            updateStatus(`打包失败: ${error.message}`, 0);
            console.error('ZIP生成错误:', error);
        }

        isDownloading = false;
        downloadBtn.disabled = false;
        updateForegroundWarning();
    }

    function fetchMedia(url) {
        return new Promise((resolve, reject) => {
            const timeout = setTimeout(() => {
                reject(new Error('下载超时'));
            }, 30000); // 30秒超时

            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'blob',
                timeout: 30000,
                onload: function(response) {
                    clearTimeout(timeout);
                    if (response.status === 200) {
                        resolve(response.response);
                    } else {
                        reject(new Error(`HTTP ${response.status}`));
                    }
                },
                onerror: function(error) {
                    clearTimeout(timeout);
                    reject(error);
                },
                ontimeout: function() {
                    clearTimeout(timeout);
                    reject(new Error('请求超时'));
                }
            });
        });
    }

    // ========== 工具函数 ==========
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function waitForForegroundIfNeeded(autoPause) {
        if (!autoPause) return;
        while (document.hidden) {
            updateStatus('标签页在后台,已暂停。请切回前台继续。', null);
            await sleep(1000);
        }
    }

    // ========== API 相关 ==========
    function getCookies() {
        const cookies = {};
        document.cookie.split(';').filter(n => n.indexOf('=') > 0).forEach(n => {
            n.replace(/^([^=]+)=(.+)$/, (match, name, value) => {
                cookies[name.trim()] = value.trim();
            });
        });
        return cookies;
    }

    async function fetchTweetByApi(tweetId) {
        const baseUrl = 'https://x.com/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId';
        const variables = {
            'tweetId': tweetId,
            'with_rux_injections': false,
            'includePromotedContent': true,
            'withCommunity': true,
            'withQuickPromoteEligibilityTweetFields': true,
            'withBirdwatchNotes': true,
            'withVoice': true,
            'withV2Timeline': true
        };
        const features = {
            'articles_preview_enabled': true,
            'c9s_tweet_anatomy_moderator_badge_enabled': true,
            'communities_web_enable_tweet_community_results_fetch': false,
            'creator_subscriptions_quote_tweet_preview_enabled': false,
            'creator_subscriptions_tweet_preview_api_enabled': false,
            'freedom_of_speech_not_reach_fetch_enabled': true,
            'graphql_is_translatable_rweb_tweet_is_translatable_enabled': true,
            'longform_notetweets_consumption_enabled': false,
            'longform_notetweets_inline_media_enabled': true,
            'longform_notetweets_rich_text_read_enabled': false,
            'premium_content_api_read_enabled': false,
            'profile_label_improvements_pcf_label_in_post_enabled': true,
            'responsive_web_edit_tweet_api_enabled': false,
            'responsive_web_enhance_cards_enabled': false,
            'responsive_web_graphql_exclude_directive_enabled': false,
            'responsive_web_graphql_skip_user_profile_image_extensions_enabled': false,
            'responsive_web_graphql_timeline_navigation_enabled': false,
            'responsive_web_media_download_video_enabled': false,
            'responsive_web_twitter_article_tweet_consumption_enabled': true,
            'rweb_tipjar_consumption_enabled': true,
            'rweb_video_screen_enabled': false,
            'standardized_nudges_misinfo': true,
            'tweet_awards_web_tipping_enabled': false,
            'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': true,
            'tweetypie_unmention_optimization_enabled': false,
            'verified_phone_label_enabled': false,
            'view_counts_everywhere_api_enabled': true
        };

        const url = encodeURI(`${baseUrl}?variables=${JSON.stringify(variables)}&features=${JSON.stringify(features)}`);
        const cookies = getCookies();
        const headers = {
            'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': cookies.lang || 'en',
            'x-csrf-token': cookies.ct0
        };

        if (cookies.ct0 && cookies.ct0.length === 32) {
            headers['x-guest-token'] = cookies.gt;
        }

        const response = await fetch(url, { headers });
        const json = await response.json();

        if (json.errors) {
            throw new Error(json.errors[0].message);
        }

        const tweetResult = json.data?.tweetResult?.result;
        return tweetResult?.tweet || tweetResult;
    }

    function extractMediaFromApi(tweetData) {
        const media = [];
        const tweet = tweetData.legacy;
        const user = tweetData.core?.user_results?.result?.legacy;
        const extendedMedia = tweet?.extended_entities?.media || [];

        extendedMedia.forEach((item, index) => {
            if (item.type === 'photo') {
                media.push({
                    type: 'image',
                    url: item.media_url_https + ':orig',
                    filename: `${tweet.id_str}_img_${index}.jpg`
                });
            } else if (item.type === 'video' || item.type === 'animated_gif') {
                // 获取最高码率的视频
                const variants = item.video_info?.variants || [];
                const mp4Variants = variants.filter(v => v.content_type === 'video/mp4');
                const bestVariant = mp4Variants.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0];

                if (bestVariant) {
                    const ext = item.type === 'animated_gif' ? 'gif.mp4' : 'mp4';
                    media.push({
                        type: item.type === 'animated_gif' ? 'gif' : 'video',
                        url: bestVariant.url.split('?')[0], // 移除查询参数
                        bitrate: bestVariant.bitrate,
                        filename: `${tweet.id_str}_${item.type === 'animated_gif' ? 'gif' : 'video'}_${index}.${ext}`
                    });
                }
            }
        });

        return {
            user: user ? `${user.name} (@${user.screen_name})` : 'Unknown',
            text: tweet?.full_text?.substring(0, 100) || '',
            media
        };
    }

    // ========== 初始化 ==========
    GM_registerMenuCommand('打开 X Likes 下载器', openPanel);

})();