吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1727|回复: 14
上一主题 下一主题
收起左侧

[其他原创] 52pojie收藏夹助手

  [复制链接]
跳转到指定楼层
楼主
Su007 发表于 2026-9-21 10:23 回帖奖励
本帖最后由 Su007 于 2026-9-22 09:51 编辑
52pojie收藏夹助手 —— 提取失效帖/更新检查

代码参考来源:

  1. ☆千年琥珀☆ https://www.52pojie.cn/thread-2068863-1-1.html

  2. 981930674 https://www.52pojie.cn/thread-2105060-1-1.html


更新内容:

  • 新增「一键提取失效收藏」,自动识别 5 类失效状态:楼主被封禁 / 帖子已删除 / 主题不存在 / 404 页面不存在 / 阅读权限不足
  • 失效帖支持批量删除,删除后可在菜单里一键恢复
  • 检测改为增量扫描,只测还没测过的,不用每次全量重扫
  • 每条的失效状态可手动纠正(点状态标签标记「已忽略」),不用整轮重扫
  • 新增 CSV 导出(8 列,Excel 直接打开),TXT 清单按类型分组
  • 新增「诊断」,取不到页面时一键排查是哪一层被拦
  • 原生弹窗全部换成自绘

修复的问题:

  • 修复 GBK 编码。Discuz 页面是 gbk,所有中文关键词判定之前都在静默失配,失效帖会被归到"未知"而不是"失效"
  • 修复「批量检测更新」永远报 0 个有更新。收藏时间经本地存储往返会退化成字符串,比较恒为 false(原版遗留)
  • 修复 404 帖全部漏判。论坛 404 页是带 HTTP 404 状态码返回的,原先先判状态码后解析正文,直接跳过
  • 修复采集过程中某一页失败会静默截断列表,并用残缺列表覆盖缓存,导致后面条目的检测结果全部丢失
  • 修复三处缺少异常保护导致脚本"假死"(所有操作提示"有其他任务正在执行",只能刷新页面)
  • 修复更新检测把"请求失败"和"帖子没编辑过"混为一谈,被 WAF 拦时静默报 0 条
  • 修复失效删除成功了但列表不更新(成功判定恒为失败)

其他:

  • 帖子页挂在创宇盾 WAF 后面,非正常导航的请求会返回 JS 挑战页。识别到挑战页会立刻中止整轮,不再产生满屏假"异常"
  • 撞到 429 会自动把后续间隔放大(×1.6,最高 8s),顺利时缓慢回落。检测时请勿把间隔调太小
  • 楼主被封禁的屏蔽提示也可能出现在回复里,判定严格限定在首帖内,避免误杀
  • 帖子正文引用 404 文案、标题里带 "404" 的,都不会被误判(有专门的回归测试)

undefined

undefined

undefined

判定优先级:

undefined

这里的代码复制后不生效,建议从蓝奏下载源码使用

// ==UserScript==
// @name         52pojie全量收藏-右下角菜单+批量可正常启停+更新收藏时间+失效帖一键提取
// @namespace    http://tampermonkey.net/
// @version      5.6
// @description  在 4.3 基础上重构:一键提取失效收藏(楼主被封禁 / 已删除 / 主题不存在 / 404 / 权限不足)。GBK 显式解码、三通道传输、WAF/429 自适应退避、增量扫描、列表轻量校验、手动纠错、删除可恢复、自绘弹窗、CSV/TXT 导出
// @AuThor       专属定制
// @match        *://www.52pojie.cn/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @connect      52pojie.cn
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // ========== 可自定义配置项 ==========
    const DEFAULT_MAX_PAGE = 50;      // 收藏采集最大页数上限
    const DEFAULT_SCAN_DELAY = 1200;  // 失效检测每条间隔(ms)。论坛对高频请求会直接回 429,别调太低
    const DEFAULT_DEL_DELAY = 700;    // 批量删除间隔(ms)
    const DEAD_SCAN_TIMEOUT = 12000;  // 单个帖子页超时(ms)
    // =================================

    function isFavoritePage() {
        return location.href.includes('mod=space') && location.href.includes('do=favorite');
    }

    let favCacheList = null;
    let isLoading = false;
    let stopCheck = false;
    let formhashCache = '';
    let deadOnly = false;          // 列表是否只看失效
    let currentTask = '';          // 'scan' | 'delete' | 'batch' | ''

    function getMaxPage() {
        const val = GM_getValue("favMaxPage", "");
        const num = parseInt(val);
        return isNaN(num) || num < 1 ? DEFAULT_MAX_PAGE : num;
    }

    function getScanDelayBase() {
        const val = parseInt(GM_getValue("favScanDelay", ""));
        return isNaN(val) || val < 200 ? DEFAULT_SCAN_DELAY : val;
    }

    // 自适应间隔:撞到 429 就把后续间隔放大,顺利时缓慢回落。
    // 比"每条都硬撞 5/10/15s"聪明,也不会因为一次限流就把整轮拖垮。
    const DELAY_MAX = 8000;
    let runtimeDelay = 0;

    function getScanDelay() {
        return runtimeDelay || getScanDelayBase();
    }
    function bumpDelay() {
        const base = getScanDelayBase();
        runtimeDelay = Math.min(DELAY_MAX, Math.round((runtimeDelay || base) * 1.6));
        return runtimeDelay;
    }
    function easeDelay() {
        const base = getScanDelayBase();
        if (runtimeDelay > base) runtimeDelay = Math.max(base, Math.round(runtimeDelay * 0.92));
    }
    function resetDelay() { runtimeDelay = 0; }

    function getFormhash() {
        if (formhashCache) return formhashCache;
        const hashInput = document.querySelector('input[name="formhash"]');
        if (hashInput) formhashCache = hashInput.value;
        return formhashCache || '';
    }

    try {
        const cacheStr = GM_getValue("favCacheData", "");
        if (cacheStr) favCacheList = JSON.parse(cacheStr);
    } catch (e) {
        favCacheList = null;
    }

    GM_addStyle(`
        #cfMenuWrap {position:fixed;bottom:20px;right:20px;z-index:10000;font-size:13px;}
        #cfMenuBtn {width:50px;height:50px;background:#007bff;color:#fff;border:none;border-radius:8px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.25);}
        #cfMenuBtn:hover {background:#0056b3;}
        #cfMenuList {display:none;position:absolute;bottom:58px;right:0;background:#fff;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.2);overflow:hidden;min-width:170px;}
        #cfMenuList.show {display:block;}
        .cf-menu-item {padding:10px 12px;cursor:pointer;white-space:nowrap;border-bottom:1px solid #f0f0f0;}
        .cf-menu-item:last-child {border-bottom:none;}
        .cf-menu-item:hover {background:#f5f7fa;}
        .update-indicator {color:#ff0000;font-weight:bold;margin-left:5px;}
        .progress-container {position:fixed;bottom:90px;right:20px;width:240px;background:#fff;border-radius:5px;box-shadow:0 2px 10px rgba(0,0,0,0.2);padding:10px;z-index:100002;display:none;}
        .progress-bar {height:10px;background:#e9ecef;border-radius:5px;overflow:hidden;}
        .progress-fill {height:100%;background:#28a745;width:0%;transition:width 0.3s ease;}
        .progress-text {font-size:12px;margin-top:5px;text-align:center;}
        #favModal {position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:90%;max-width:760px;max-height:78vh;background:#fff;border-radius:10px;box-shadow:0 4px 30px rgba(0,0,0,0.35);z-index:100001;display:none;overflow:hidden;}
        .modal-head {padding:12px 15px;background:#f8f9fa;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;}
        .modal-title {font-size:14px;font-weight:bold;color:#333;}
        .modal-close {color:#f56c6c;cursor:pointer;font-size:18px;font-weight:bold;}
        .head-btn {padding:4px 8px;border:none;border-radius:3px;cursor:pointer;font-size:12px;}
        #refreshFavBtn {background:#007bff;color:#fff;}
        #checkAllFavBtn {background:#dc3545;color:#fff;}
        #checkAllFavBtn.stop-bg {background:#6c757d;}
        #scanDeadBtn {background:#e67e22;color:#fff;}
        #scanDeadBtn.stop-bg {background:#6c757d;}
        #favSearchInput {width:calc(100% - 20px);margin:10px;padding:8px 12px;border:1px solid #007bff;border-radius:4px;outline:none;font-size:14px;background:#fff;}
        .modal-toolbar {display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:0 10px 10px;}
        .tool-btn {padding:4px 9px;border:1px solid #d0d0d0;background:#fff;border-radius:3px;cursor:pointer;font-size:12px;color:#333;}
        .tool-btn:hover {background:#f5f7fa;}
        .tool-btn.active {background:#007bff;border-color:#007bff;color:#fff;}
        .tool-btn.danger {border-color:#ff4444;color:#ff4444;}
        .tool-btn.danger:hover {background:#ff4444;color:#fff;}
        .fav-stats {font-size:12px;color:#666;margin-left:auto;}
        .modal-body {padding:10px;overflow-y:auto;max-height:calc(78vh - 170px);}
        .fav-item {padding:8px 10px;border-bottom:1px solid #f1f1f1;color:#0066cc;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:8px;}
        .fav-item.is-dead {background:#fffafa;}
        .fav-item.is-dead .fav-title {color:#a32d2d;text-decoration:line-through;}
        .fav-title {flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}
        .fav-update {color:red;font-weight:bold;}
        .fav-page-tag {color:#999;font-size:12px;white-space:nowrap;}
        .fav-btn {padding:2px 6px;color:#fff;border:none;border-radius:3px;font-size:12px;cursor:pointer;white-space:nowrap;margin-left:4px;}
        .fav-update-btn {background:#009688;}
        .fav-update-btn:hover {background:#00796b;}
        .fav-del-btn {background:#ff4444;}
        .fav-del-btn:hover {background:#cc0000;}
        .mask-layer {position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:100000;display:none;}
        .load-all-tip {text-align:center;padding:15px;color:#666;}
        .empty-tip {text-align:center;padding:30px;color:#999;}
        .dead-tag {font-size:11px;padding:1px 6px;border-radius:3px;white-space:nowrap;margin-left:6px;font-weight:normal;text-decoration:none;display:inline-block;}
        .dead-tag.t-banned {background:#fff3cd;color:#8a6d3b;border:1px solid #ffe08a;}
        .dead-tag.t-deleted {background:#fdecea;color:#a32d2d;border:1px solid #f5c2be;}
        .dead-tag.t-perm {background:#eef2f6;color:#5f6b7a;border:1px solid #d5dde5;}
        .dead-tag.t-unknown {background:#f4f4f4;color:#777;border:1px solid #e0e0e0;}
        .dead-tag.t-ignored {background:#f1efe8;color:#888780;border:1px solid #d3d1c7;}
        .dead-tag.clickable, .dead-tag[data-toggle] {cursor:pointer;}
        .dead-tag.clickable:hover {filter:brightness(0.94);}
        .diag {font-family:Consolas,Monaco,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-all;background:#fafaf8;border:1px solid #e5e5e0;border-radius:6px;padding:12px;color:#2c2c2a;margin:0;}
        /* ===== 自绘弹窗 ===== */
        #uiMask {position:fixed;inset:0;background:rgba(0,0,0,0.32);z-index:100010;display:none;}
        #uiDialog {position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:90%;max-width:430px;background:#fff;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,0.26);z-index:100011;display:none;overflow:hidden;font-size:13px;color:#2c2c2a;}
        .ui-dlg-head {display:flex;align-items:center;gap:8px;padding:15px 16px 0;}
        .ui-dlg-dot {width:8px;height:8px;border-radius:50%;flex:0 0 auto;}
        .ui-dlg-title {font-size:14px;font-weight:500;flex:1;color:#2c2c2a;}
        .ui-dlg-x {cursor:pointer;color:#b4b2a9;font-size:19px;line-height:1;padding:0 2px;}
        .ui-dlg-x:hover {color:#5f5e5a;}
        .ui-dlg-body {padding:10px 16px 2px;line-height:1.7;white-space:pre-wrap;word-break:break-word;max-height:52vh;overflow-y:auto;color:#444441;}
        .ui-dlg-input {width:calc(100% - 32px);margin:12px 16px 0;padding:8px 10px;border:1px solid #d5dde5;border-radius:6px;font-size:13px;outline:none;color:#2c2c2a;box-sizing:border-box;}
        .ui-dlg-input:focus {border-color:#007bff;}
        .ui-dlg-foot {display:flex;justify-content:flex-end;gap:8px;padding:15px 16px 16px;}
        .ui-dlg-btn {padding:7px 16px;border-radius:6px;border:1px solid #d8d8d2;background:#fff;color:#444441;font-size:13px;cursor:pointer;}
        .ui-dlg-btn:hover {background:#f5f5f2;}
        .ui-dlg-btn.primary {background:#007bff;border-color:#007bff;color:#fff;}
        .ui-dlg-btn.primary:hover {background:#0069d9;}
        .ui-dlg-btn.danger {background:#e24b4a;border-color:#e24b4a;color:#fff;}
        .ui-dlg-btn.danger:hover {background:#c93f3e;}
        #uiToastWrap {position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:100020;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;}
        .ui-toast {background:#2c2c2a;color:#fff;padding:9px 16px;border-radius:8px;font-size:13px;box-shadow:0 6px 18px rgba(0,0,0,0.2);opacity:0;transform:translateY(-6px);transition:opacity .18s ease,transform .18s ease;max-width:70vw;}
        .ui-toast.show {opacity:1;transform:translateY(0);}
        .ui-toast.ok {background:#0f6e56;}
        .ui-toast.err {background:#a32d2d;}
        .ui-toast.warn {background:#ba7517;}
    `);

    const menuWrap = document.createElement("div");
    menuWrap.id = "cfMenuWrap";
    menuWrap.innerHTML = `
        <button id="cfMenuBtn">收藏</button>
        <div id="cfMenuList">
            <div class="cf-menu-item" data-action="openFav">全部收藏列表</div>
            <div class="cf-menu-item" data-action="checkSingle">本页检测更新</div>
            <div class="cf-menu-item" data-action="checkDead">一键提取失效收藏</div>
            <div class="cf-menu-item" data-action="diag">诊断(检测失败排查)</div>
            <div class="cf-menu-item" data-action="restore">恢复误删的收藏</div>
            <div class="cf-menu-item" data-action="setPageLimit">设置采集页数上限</div>
        </div>
    `;
    document.body.appendChild(menuWrap);

    // ====================== 自绘弹窗(替代浏览器原生 alert/confirm/prompt)======================
    const UI = (function () {
        const COLORS = { info: '#007bff', success: '#1d9e75', warn: '#ba7517', error: '#e24b4a', question: '#7f77dd' };
        const TITLES = { info: '提示', success: '完成', warn: '注意', error: '出错了', question: '请确认' };

        const mask = document.createElement('div'); mask.id = 'uiMask';
        const dlg = document.createElement('div'); dlg.id = 'uiDialog';
        dlg.innerHTML = `
            <div class="ui-dlg-head">
                <span class="ui-dlg-dot"></span>
                <span class="ui-dlg-title"></span>
                <span class="ui-dlg-x">×</span>
            </div>
            <div class="ui-dlg-body"></div>
            <input class="ui-dlg-input" type="text">
            <div class="ui-dlg-foot"></div>
        `;
        const toastWrap = document.createElement('div'); toastWrap.id = 'uiToastWrap';
        document.body.appendChild(mask);
        document.body.appendChild(dlg);
        document.body.appendChild(toastWrap);

        const elDot = dlg.querySelector('.ui-dlg-dot');
        const elTitle = dlg.querySelector('.ui-dlg-title');
        const elBody = dlg.querySelector('.ui-dlg-body');
        const elInput = dlg.querySelector('.ui-dlg-input');
        const elFoot = dlg.querySelector('.ui-dlg-foot');
        const elX = dlg.querySelector('.ui-dlg-x');

        let resolveFn = null, cancelValue = null;

        function settle(value) {
            const fn = resolveFn;
            resolveFn = null;
            dlg.style.display = 'none';
            mask.style.display = 'none';
            elInput.style.display = 'none';
            document.removeEventListener('keydown', onKey, true);
            if (fn) fn(value);
        }

        function onKey(e) {
            if (e.key === 'Escape') { e.stopPropagation(); settle(cancelValue); }
            else if (e.key === 'Enter' && elInput.style.display !== 'none') { e.stopPropagation(); settle(elInput.value); }
        }

        elX.onclick = function () { settle(cancelValue); };
        mask.onclick = function () { settle(cancelValue); };

        function open(opts) {
            return new Promise(function (resolve) {
                // 上一个还没关掉就先按"取消"结掉,避免它的 await 永远悬着
                if (resolveFn) settle(cancelValue);

                resolveFn = resolve;
                cancelValue = ('cancelValue' in opts) ? opts.cancelValue : null;

                const type = opts.type || 'info';
                elDot.style.background = COLORS[type] || COLORS.info;
                elTitle.textContent = opts.title || TITLES[type] || TITLES.info;
                elBody.textContent = (opts.message == null) ? '' : String(opts.message);
                elBody.style.display = elBody.textContent ? 'block' : 'none';

                if (opts.input) {
                    elInput.style.display = 'block';
                    elInput.value = (opts.input.value == null) ? '' : opts.input.value;
                    elInput.placeholder = opts.input.placeholder || '';
                } else {
                    elInput.style.display = 'none';
                }

                elFoot.innerHTML = '';
                (opts.buttons || [{ text: '知道了', value: true, primary: true }]).forEach(function (b) {
                    const btn = document.createElement('button');
                    btn.className = 'ui-dlg-btn' + (b.primary ? ' primary' : '') + (b.danger ? ' danger' : '');
                    btn.textContent = b.text;
                    btn.onclick = function () { settle(typeof b.value === 'function' ? b.value() : b.value); };
                    elFoot.appendChild(btn);
                });

                mask.style.display = 'block';
                dlg.style.display = 'block';
                document.addEventListener('keydown', onKey, true);
                if (opts.input) setTimeout(function () { elInput.focus(); elInput.select(); }, 30);
            });
        }

        return {
            alert: function (message, type, title) { return open({ message: message, type: type || 'info', title: title }); },
            confirm: function (message, o) {
                o = o || {};
                return open({
                    message: message,
                    type: o.type || 'question',
                    title: o.title,
                    cancelValue: false,
                    buttons: [
                        { text: o.cancelText || '取消', value: false },
                        { text: o.okText || '确定', value: true, primary: !o.danger, danger: !!o.danger }
                    ]
                });
            },
            prompt: function (message, value, o) {
                o = o || {};
                return open({
                    message: message,
                    type: 'question',
                    title: o.title || '请输入',
                    input: { value: value, placeholder: o.placeholder || '' },
                    cancelValue: null,
                    buttons: [
                        { text: '取消', value: null },
                        { text: '确定', value: function () { return elInput.value; }, primary: true }
                    ]
                });
            },
            // 多选一:buttons = [{text, value, primary, danger}],返回被点按钮的 value
            choose: function (message, buttons, title) {
                return open({ message: message, type: 'question', title: title, buttons: buttons, cancelValue: null });
            },
            toast: function (message, type, ms) {
                const t = document.createElement('div');
                t.className = 'ui-toast' + (type ? ' ' + type : '');
                t.textContent = message;
                toastWrap.appendChild(t);
                requestAnimationFrame(function () { t.classList.add('show'); });
                setTimeout(function () {
                    t.classList.remove('show');
                    setTimeout(function () { t.remove(); }, 260);
                }, ms || 2600);
            }
        };
    })();

    const menuBtn = document.getElementById("cfMenuBtn");
    const menuList = document.getElementById("cfMenuList");
    const menuItems = menuList.querySelectorAll(".cf-menu-item");
    menuBtn.onclick = () => menuList.classList.toggle("show");
    function closeMenu() { menuList.classList.remove("show"); }

    async function setPageLimit() {
        closeMenu();
        const now = getMaxPage();
        const input = await UI.prompt(`请输入收藏采集最大页数上限\n(当前:${now} 页)`, now, { title: '采集页数上限', placeholder: '例如 50' });
        if (input === null || input === undefined) return;
        const num = parseInt(String(input).trim());
        if (isNaN(num) || num < 1) {
            UI.alert("请输入大于 0 的整数。", 'warn');
            return;
        }
        GM_setValue("favMaxPage", num);
        UI.toast(`采集上限已设为 ${num} 页`, 'ok');
    }

    const progressContainer = document.createElement('div');
    progressContainer.className = 'progress-container';
    progressContainer.innerHTML = `<div class="progress-bar"><div class="progress-fill"></div></div><div class="progress-text">准备中...</div>`;
    document.body.appendChild(progressContainer);

    function updateProgress(current, total, text) {
        const fill = progressContainer.querySelector('.progress-fill');
        const txt = progressContainer.querySelector('.progress-text');
        const pct = total ? Math.round((current / total) * 100) : 0;
        fill.style.width = pct + "%";
        txt.textContent = text || `${current}/${total} ${pct}%`;
    }
    function showProgress() { progressContainer.style.display = "block"; }
    function hideProgress() { progressContainer.style.display = "none"; }

    function parseChineseDate(str) {
        const m = str.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
        return m ? new Date(m[1], m[2] - 1, m[3], m[4], m[5]) : null;
    }

    function delay(ms) { return new Promise(r => setTimeout(r, ms)); }

    // ====================== 帖子有效性检测 ======================
    // 判定结果 type 取值:
    //   ok          正常
    //   banned      楼主被封禁 / 内容自动屏蔽
    //   deleted     帖子已删除(违规被删)
    //   notexist    主题不存在 / 已被删除或审核中
    //   notfound    论坛 404 页(页面不存在或已删除,常带 HTTP 404 状态码)
    //   permission  阅读权限不足(没失效,只是当前身份看不到)
    //   pending     审核中
    //   login       未登录(必须中止整轮检测,否则会误判全部失效)
    //   unknown     响应异常 / 未识别页面(不并入失效)
    const DEAD_TYPES = ['banned', 'deleted', 'notexist', 'notfound'];

    function isDeadType(t) { return DEAD_TYPES.indexOf(t) > -1; }

    function tagClassOf(type) {
        if (type === 'banned') return 't-banned';
        if (type === 'deleted' || type === 'notexist' || type === 'notfound') return 't-deleted';
        if (type === 'permission' || type === 'pending') return 't-perm';
        return 't-unknown';
    }

    function classifyMessage(txt) {
        // 注意顺序:Discuz 的"主题不存在或已被删除或正在被审核"同时含"已被删除",
        // 必须先判"不存在",否则会被误标成"帖子已删除"
        if (/不存在|无此主题|找不到|已被移走|已被删除或正在被审核/.test(txt)) {
            return { type: "notexist", label: "主题不存在", detail: txt };
        }
        if (/现已删除|已被删除|违规行为|涉嫌存在|现已被删|内容已删除/.test(txt)) {
            return { type: "deleted", label: "帖子已删除", detail: txt };
        }
        if (/阅读权限|权限不足|无权|用户组|等级不足/.test(txt)) {
            return { type: "permission", label: "阅读权限不足", detail: txt };
        }
        if (/审核/.test(txt)) {
            return { type: "pending", label: "审核中", detail: txt };
        }
        if (/登录|游客|注册/.test(txt)) {
            return { type: "login", label: "需要登录", detail: txt };
        }
        return { type: "unknown", label: "无法访问", detail: txt };
    }

    // ====================== 解码层 ======================
    // 52pojie 的 Discuz 页面是 Content-Type: text/html; charset=gbk。
    // 中文关键词判定("现已删除"/"作者被禁止或删除")全靠文本匹配,
    // 一旦解码错成乱码,失效帖会被静默归到"无法访问"而不是失效 —— 必须自己按 charset 解码。
    function contentTypeOf(headers) {
        if (!headers) return '';
        const m = String(headers).match(/content-type\s*:\s*[^\r\n]*/i);
        return m ? m[0] : '';
    }

    function decodeBytes(buf, contentType) {
        if (typeof buf === 'string') return buf;
        let bytes;
        if (buf instanceof ArrayBuffer) bytes = new Uint8Array(buf);
        else if (buf && buf.buffer) bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
        else return String(buf == null ? '' : buf);

        let cs = (String(contentType || '').match(/charset\s*=\s*["']?([\w-]+)/i) || [])[1] || '';
        if (!cs) {
            // 头里没有就嗅探前 2KB 的 <meta charset=...>
            let head = '';
            try { head = new TextDecoder('utf-8').decode(bytes.slice(0, 2048)); } catch (e) { head = ''; }
            const m = head.match(/<meta[^>]+charset\s*=\s*["']?([\w-]+)/i);
            cs = m ? m[1] : '';
        }
        cs = (cs || 'utf-8').toLowerCase();
        if (cs === 'gb2312' || cs === 'gb-2312') cs = 'gbk';
        try {
            return new TextDecoder(cs).decode(bytes);
        } catch (e) {
            try { return new TextDecoder('gbk').decode(bytes); } catch (e2) { return new TextDecoder('utf-8').decode(bytes); }
        }
    }

    function hasPostStruct(doc) {
        if (!doc) return false;
        return !!doc.querySelector('#postlist, #postlistcontainer, .t_f, div[id^="post_"]');
    }

    // 首帖容器。排除 post_rate_xxx(那是帖内的评分小窗,不是帖子本身)
    const FIRST_POST_SEL = '#postlist div[id^="post_"]:not([id^="post_rate_"])';

    // 反爬 WAF 的硬特征:创宇盾/加速乐一类「JS 挑战页」
    // 例:<noscript>Please enable JavaScript and refresh the page.</noscript>
    //     var c='/WZWSREL3RocmVhZC0xNDMxMzYtMS0xLmh0bWw='
    function isHardChallenge(html, doc) {
        const h = html || "";
        if (/WZWSREL|__jsl_clearance|jsl_clearance/i.test(h)) return true;
        if (/Please enable JavaScript/i.test(h)) return true;
        const ns = doc && doc.querySelector("noscript");
        if (ns && /enable JavaScript|开启\s*JavaScript|需要开启/i.test(ns.textContent || "")) return true;
        return false;
    }

    // 软特征:页面既不是已知错误页、又没有帖子结构,可见文字还极少 —— 基本是空壳/挑战页
    function isSoftChallenge(doc, html) {
        const bodyText = ((doc && doc.body && doc.body.textContent) || "").replace(/\s+/g, "");
        return bodyText.length < 120;
    }

    // 纯判定函数:只吃「已解析的文档 + 原文 + 状态码」,不碰网络,方便离线测试
    function classifyDoc(doc, html, status) {
        // 限流最优先:429 的响应体通常是个短壳,不先拦掉会被误判成挑战页
        if (status === 429) {
            return { type: "ratelimited", label: "被限流(429)", detail: "请求过于频繁,需退避后重试" };
        }
        if (!doc) return { type: "unknown", label: "HTTP " + status, detail: "响应为空或无法解析" };

        const flat = t => (t || "").replace(/\s+/g, " ").trim().slice(0, 200);

        // 0) 反爬挑战页:这不是帖子的状态,绝不能记成"异常",否则整批结果全废
        if (isHardChallenge(html, doc)) {
            return { type: "blocked", label: "被WAF拦截", detail: "返回的是反爬 JS 挑战页,不是帖子内容" };
        }

        const pageTitle = doc.title || "";
        const h1 = doc.querySelector("h1");
        const bodyText = (doc.body && doc.body.textContent) || "";
        const struct = hasPostStruct(doc);

        // 1) 论坛 404 页:
        //    <title>404 - 页面未找到 | 吾爱破解论坛</title>
        //    <div class="container"><h1>404</h1><h2>您所访问的页面不存在或者已删除</h2>
        //    <div id="catch-the-cat"><canvas .../></div>
        const is404 =
            !!doc.querySelector("#catch-the-cat") ||
            (/404/.test(pageTitle) && /页面未找到|不存在|已删除/.test(pageTitle)) ||
            (h1 && h1.textContent.trim() === "404" && !struct) ||
            // 文案兜底必须叠加"没有帖子结构",否则正文里引用该句的正常帖会被误杀
            (/您所访问的页面不存在或者已删除/.test(bodyText) && !struct);
        if (is404) {
            return { type: "notfound", label: "404 页面不存在", detail: "您所访问的页面不存在或者已删除" };
        }

        // 2) 整帖级错误提示:<div id="messagetext" class="alert_error"> ... </div>
        const msg = doc.querySelector("#messagetext");
        if (msg) return classifyMessage(flat(msg.textContent));

        // 3) 首帖被屏蔽:<div class="locked">提示: <em>作者被禁止或删除 内容自动屏蔽</em></div>
        const firstPost = doc.querySelector(FIRST_POST_SEL) || doc.body || doc;
        const locked = firstPost.querySelector(".locked");
        if (locked) {
            const txt = flat(locked.textContent);
            if (/作者被禁止或删除|内容自动屏蔽|禁止或删除/.test(txt)) {
                return { type: "banned", label: "楼主被封禁", detail: txt };
            }
            if (/审核/.test(txt)) {
                return { type: "pending", label: "审核中", detail: txt };
            }
            return { type: "banned", label: "内容被屏蔽", detail: txt };
        }

        // 4) 没有帖子结构 —— 先看是不是软挑战页,否则记异常并带上证据供排查
        if (!struct) {
            if (isSoftChallenge(doc, html)) {
                return { type: "blocked", label: "被WAF拦截", detail: "页面无可读内容(疑似挑战页或 JS 空壳)" };
            }
            return {
                type: "unknown",
                label: status === 200 ? "响应异常" : ("HTTP " + status),
                detail: `标题「${flat(pageTitle) || "(空)"}」 长度 ${(html || "").length} 状态 ${status}`
            };
        }

        return { type: "ok", label: "正常", detail: "" };
    }

    function parseDoc(html) {
        if (!html) return null;
        try { return new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return null; }
    }

    // 判断删除是否成功。返回 { ok, why }。
    // 只认「明确的错误容器」,不对全文做关键词扫描 —— 正常页面里同样可能出现这些词。
    function judgeDeleteResponse(res) {
        if (!res || !res.ok) return { ok: false, why: (res && res.err) || "请求未完成" };
        if (res.status !== 200) return { ok: false, why: "HTTP " + res.status + "(可能被限流,或登录态已失效)" };
        const doc = parseDoc(res.html);
        const msg = doc ? doc.querySelector("#messagetext") : null;
        if (msg) {
            const cls = String(msg.className || "");
            const txt = (msg.textContent || "").replace(/\s+/g, " ").trim().slice(0, 140);
            // Discuz 成功页是 alert_right「收藏删除成功」;错误页是 alert_error 或带错误文案
            if (/alert_error/i.test(cls) || /非法操作|没有权限|不存在|请先登录|您需要登录|删除失败/.test(txt)) {
                return { ok: false, why: txt || "服务器返回错误页" };
            }
        }
        return { ok: true, why: "" };
    }

    // 帖子的最后编辑时间是否晚于收藏时间。纯判定,不碰网络,便于离线测试。
    // 关键:必须区分「请求/页面失败」与「帖子确实没被编辑过」——
    // 老实现两种情况都返回 null,被 WAF 拦时会静默报"0 个有更新"。
    function classifyThreadEdit(doc, html, status, favDate) {
        if (status === 429) return { ok: false, updated: false, why: '被限流(429)' };
        if (status !== 200) return { ok: false, updated: false, why: 'HTTP ' + status };
        if (!doc) return { ok: false, updated: false, why: '无法解析响应' };
        if (isHardChallenge(html, doc)) return { ok: false, updated: false, why: '被WAF拦截' };

        const s = doc.querySelector("i.pstatus");
        if (!s) return { ok: true, updated: false };      // 真·没编辑过
        const m = s.textContent.match(/于\s+(\d{4}-\d{1,2}-\d{1,2}\s+\d{1,2}:\d{1,2})\s+编辑/);
        if (!m) return { ok: true, updated: false };
        const dm = m[1].match(/(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
        if (!dm) return { ok: true, updated: false };
        const editDate = new Date(dm[1], dm[2] - 1, dm[3], dm[4], dm[5]);

        // favDate 在内存里是 Date,但经 GM 存储 JSON 往返后会变成字符串,
        // 直接 `editDate > favDate` 会做 NaN 比较恒为 false —— 批量检测就永远检不出更新。
        // 这里统一归一化成 Date 再比。
        if (!favDate) return { ok: true, updated: false };
        const fav = (favDate instanceof Date) ? favDate : new Date(favDate);
        if (isNaN(fav.getTime())) return { ok: true, updated: false };
        return { ok: true, updated: editDate > fav };
    }

    // 被判为失效、但用户还没手动"忽略"的条目才算真失效
    function isDeadItem(it) {
        return !!(it && it.dead && isDeadType(it.dead.type) && !it.dead.override);
    }

    function computeDeadStats(list) {
        const s = { total: (list || []).length, dead: 0, banned: 0, deleted: 0, notexist: 0, notfound: 0, limited: 0, unknown: 0, ignored: 0, checked: 0 };
        (list || []).forEach(it => {
            const t = it.dead && it.dead.type;
            if (!t) return;
            s.checked++;
            // 用户手动忽略的不计入失效,单独计数(误判不该逼着用户整轮重扫)
            if (isDeadType(t) && it.dead.override) { s.ignored++; return; }
            if (t === 'banned') { s.banned++; s.dead++; }
            else if (t === 'deleted') { s.deleted++; s.dead++; }
            else if (t === 'notexist') { s.notexist++; s.dead++; }
            else if (t === 'notfound') { s.notfound++; s.dead++; }
            else if (t === 'permission' || t === 'pending') s.limited++;
            else if (t !== 'ok') s.unknown++;
        });
        return s;
    }

    // CSV:把整个列表导成表格,方便丢进 Excel 自己筛
    function buildCsvFrom(list) {
        const q = v => {
            const s = String(v == null ? '' : v);
            return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
        };
        const fmtDate = v => {
            if (!v) return '';
            const d = (v instanceof Date) ? v : new Date(v);
            if (isNaN(d.getTime())) return '';
            const p = n => String(n).padStart(2, '0');
            return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
        };
        const rows = [['序号', '状态', '标题', '链接', '帖子ID', '收藏ID', '收藏时间', '所在页']];
        (list || []).forEach((x, i) => {
            const t = x.dead && x.dead.type;
            let status = '未检测';
            if (t) {
                if (isDeadType(t) && x.dead.override) status = '已忽略';
                else status = (t === 'ok') ? '正常' : x.dead.label;
            }
            rows.push([i + 1, status, x.title, x.url, x.tid, x.favid, fmtDate(x.favDate), x.page]);
        });
        return rows.map(r => r.map(q).join(',')).join('\r\n');
    }

    // ====================== 传输层(网络)======================
    // page  : 页面上下文 fetch —— 同源、走浏览器正常网络栈,最可能带上 WAF 通行证
    // gm    : GM_xmlhttpRequest —— 请求头像 XHR,最容易被 WAF 挑战
    // iframe: 真实导航加载 —— 最像人工点击,最重但最稳
    const TRANSPORTS = ['page', 'gm', 'iframe'];
    let activeTransport = GM_getValue("favTransport", "") || "";

    // 统一的 GM 取页:要 arraybuffer 自己按 charset 解码,避免 GBK 页面变乱码
    function gmHtml(opt) {
        return new Promise(resolve => {
            GM_xmlhttpRequest(Object.assign({ responseType: 'arraybuffer' }, opt, {
                onload: r => {
                    const html = (r.response != null)
                        ? decodeBytes(r.response, contentTypeOf(r.responseHeaders))
                        : (r.responseText || '');
                    resolve({ ok: true, status: r.status, finalUrl: r.finalUrl || opt.url, html: html });
                },
                onerror: () => resolve({ ok: false, status: 0, html: '', err: '网络错误' }),
                ontimeout: () => resolve({ ok: false, status: 0, html: '', err: '超时' })
            }));
        });
    }

    function rawFetch(url, mode) {
        const t0 = Date.now();
        if (mode === 'page') {
            return fetch(url, { credentials: 'include', redirect: 'follow', cache: 'no-store' })
                .then(async r => {
                    const buf = await r.arrayBuffer();
                    return {
                        ok: true, status: r.status, finalUrl: r.url,
                        html: decodeBytes(buf, r.headers.get('content-type')),
                        ms: Date.now() - t0
                    };
                })
                .catch(e => ({ ok: false, err: String((e && e.message) || e), ms: Date.now() - t0 }));
        }
        if (mode === 'iframe') {
            return new Promise(resolve => {
                const f = document.createElement('iframe');
                f.style.cssText = 'position:fixed;left:-99999px;top:0;width:1024px;height:768px;';
                let done = false;
                const finish = r => { if (done) return; done = true; clearTimeout(timer); try { f.remove(); } catch (e) {} resolve(r); };
                const timer = setTimeout(() => finish({ ok: false, err: 'iframe 加载超时', ms: Date.now() - t0 }), DEAD_SCAN_TIMEOUT);
                f.onload = () => {
                    // 追加后会先触发一次 about:blank 的 load,忽略掉
                    try {
                        if (f.contentWindow && f.contentWindow.location.href === 'about:blank') return;
                    } catch (e) { /* 跨域读不到就继续 */ }
                    try {
                        const d = f.contentDocument;
                        finish({ ok: true, status: 200, finalUrl: f.contentWindow.location.href, html: d ? d.documentElement.outerHTML : '', doc: d, ms: Date.now() - t0 });
                    } catch (e) {
                        finish({ ok: false, err: '无法读取 iframe 内容:' + e.message, ms: Date.now() - t0 });
                    }
                };
                f.onerror = () => finish({ ok: false, err: 'iframe 加载失败', ms: Date.now() - t0 });
                document.body.appendChild(f);
                f.src = url;
            });
        }
        return gmHtml({
            method: 'GET', url: url, timeout: DEAD_SCAN_TIMEOUT,
            headers: { 'Referer': 'https://www.52pojie.cn/home.php?mod=space&do=favorite&view=me' }
        }).then(r => Object.assign(r, { ms: Date.now() - t0 }));
    }

    async function checkThreadStatus(url, transport) {
        const mode = transport || activeTransport || 'page';
        const res = await rawFetch(url, mode);
        if (!res.ok) return { type: 'unknown', label: '请求失败', detail: res.err || '', transport: mode };
        const doc = res.doc || parseDoc(res.html);
        const v = classifyDoc(doc, res.html, res.status);
        v.transport = mode;
        v.ms = res.ms;
        return v;
    }

    // 依次试三种通道,挑出第一个能拿到真实页面的;lockBest=true 时锁死并持久化
    async function probeTransports(url, lockBest) {
        const tried = [];
        for (const t of TRANSPORTS) {
            const res = await rawFetch(url, t);
            const doc = res.doc || parseDoc(res.html);
            const v = res.ok ? classifyDoc(doc, res.html, res.status) : null;
            const info = {
                transport: t,
                ok: res.ok,
                status: res.status,
                err: res.err,
                ms: res.ms,
                len: (res.html || '').length,
                title: doc ? (doc.title || '') : '',
                struct: hasPostStruct(doc),
                verdict: v ? v.type : '-',
                challenge: v ? v.type === 'blocked' : false,
                snippet: (res.html || '').replace(/\s+/g, ' ').slice(0, 160)
            };
            tried.push(info);
            // 只有真正读到 Discuz 页面才算可用;挑战页和限流都不能锁
            if (lockBest && res.ok && v && v.type !== 'blocked' && v.type !== 'ratelimited') {
                activeTransport = t;
                GM_setValue('favTransport', t);
                return { transport: t, tried };
            }
            await delay(300);
        }
        return { transport: '', tried };
    }

    function resetTransport() {
        activeTransport = '';
        GM_setValue('favTransport', '');
    }

    function transportRows(tried) {
        return tried.map(t => {
            const state = !t.ok
                ? ('失败 ' + (t.err || ''))
                : (t.challenge ? '被WAF挑战页拦截' : ('可用,判定=' + (t.verdict || '-')));
            return `  · ${String(t.transport).padEnd(7)} HTTP=${t.status || '-'}  长度=${t.len || 0}  ${t.ms || 0}ms  ${state}`;
        }).join('\n');
    }

    function showTransportFailure(tried, sampleUrl) {
        UI.alert(
`❌ 三条传输通道都拿不到真实帖子内容,检测已中止。

本次未产生任何判定结果(不会误报,也不会漏掉后又被当成正常)。

样本:${sampleUrl}

通道探测:
${transportRows(tried)}

原因:52pojie 的 /thread-* 页面挂在创宇盾 WAF 后面,对非真实导航的请求
会返回一个「Please enable JavaScript」的 JS 挑战页。抓到挑战页不计入失效,直接中止。

下一步:
  1) 点菜单「诊断(检测失败排查)」,把报告复制发我;
  2) 确认浏览器此刻能正常打开论坛帖子(登录态、已过验证);
  3) 若 iframe 通道也失败,需要换方案。`);
    }

    // ====================== favid 采集 ======================
    function parseFavItem(item, pageNum) {
        const link = item.querySelector('a[href*="thread-"]');
        const dateDom = item.querySelector(".xg1");
        const favDate = dateDom ? parseChineseDate(dateDom.textContent.trim()) : null;

        // 从页面标签 id="fav_18081556" 提取真实收藏ID
        const favid = item.id?.replace("fav_", "") || "";

        if (!link || !favid) return null;
        const tid = link.href.match(/thread-(\d+)-/)?.[1] || "";

        return {
            title: link.title || link.textContent.trim(),
            url: link.href.startsWith("http") ? link.href : "https://www.52pojie.cn/" + link.href,
            favDate: favDate,
            update: false,
            page: pageNum,
            favid: favid,
            tid: tid,
            dead: null
        };
    }

    // ====================== 误删恢复日志 ======================
    // 删除是破坏性操作。记下 tid(重新收藏只需 tid),万一误删还能找回来。
    const UNDO_MAX = 300;

    function readDeletedLog() {
        try { return JSON.parse(GM_getValue("favDeletedLog", "[]")) || []; } catch (e) { return []; }
    }

    function logDeleted(item) {
        if (!item || !item.tid) return;
        let log = readDeletedLog();
        log.unshift({ tid: String(item.tid), title: item.title || '', url: item.url || '', at: Date.now() });
        const seen = new Set();
        log = log.filter(x => {
            if (!x || !x.tid || seen.has(x.tid)) return false;
            seen.add(x.tid);
            return true;
        }).slice(0, UNDO_MAX);
        GM_setValue("favDeletedLog", JSON.stringify(log));
    }

    // 恢复:用 addFavorite 把 tid 重新收藏回去
    async function restoreDeleted() {
        closeMenu();
        const log = readDeletedLog();
        if (log.length === 0) {
            UI.alert('没有可恢复的记录。', 'info');
            return;
        }
        const preview = log.slice(0, 5).map((x, i) => `${i + 1}. ${String(x.title).slice(0, 26)}`).join('\n');
        if (!await UI.confirm(
            `将重新收藏最近删除的 ${log.length} 条:\n\n${preview}${log.length > 5 ? `\n… 还有 ${log.length - 5} 条` : ''}\n\n注意:恢复后的收藏时间是"现在",不会还原原始收藏时间。`,
            { title: '恢复误删的收藏', okText: '开始恢复' }
        )) return;

        if (isLoading) { UI.alert("当前有其他任务正在执行,请稍候", 'warn'); return; }
        isLoading = true;
        currentTask = 'restore';
        showProgress();

        let ok = 0, fail = 0, why = '';
        const doneTids = new Set();
        try {
            for (let i = 0; i < log.length; i++) {
                if (stopCheck) break;
                updateProgress(i + 1, log.length, `恢复收藏 ${i + 1}/${log.length}`);
                const r = await addFavorite(log[i].tid);
                if (r) { ok++; doneTids.add(log[i].tid); }
                else { fail++; if (!why) why = '收藏接口返回异常'; }
                await delay(DEFAULT_DEL_DELAY);
            }
        } catch (e) {
            console.error(e);
            fail++;
            why = (e && e.message) || String(e);
        } finally {
            isLoading = false;
            currentTask = '';
            hideProgress();
        }

        // 只清掉确实恢复成功的那些(可能中间有失败),失败的留着下次再试
        if (doneTids.size > 0) {
            GM_setValue("favDeletedLog", JSON.stringify(log.filter(x => !doneTids.has(x.tid))));
        }
        UI.alert(`恢复完成:成功 ${ok} 条,失败 ${fail} 条。${why ? '\n\n失败原因示例:' + why : ''}\n\n恢复成功的条目已从记录中移除。`, fail ? 'warn' : 'success');
    }

    // ====================== 真实删除接口 ======================
    // 返回 { ok, why }。quiet=true 时不弹窗、不重绘(批量删除用)
    async function deleteFavorite(favid, itemEl, quiet) {
        const formhash = getFormhash();
        if (!formhash || !favid) {
            const why = "formhash 或 favid 缺失(请在收藏页且已登录的状态下操作)";
            if (!quiet) UI.alert("删除失败:" + why, 'error');
            return { ok: false, why: why };
        }

        const res = await gmHtml({
            method: "POST",
            url: "https://www.52pojie.cn/home.php?mod=spacecp&ac=favorite&op=delete&favid=" + favid,
            headers: {
                "Content-Type": "application/x-www-form-urlencoded",
                "Referer": "https://www.52pojie.cn/home.php?mod=space&do=favorite&view=me"
            },
            data: `deletesubmit=true&formhash=${formhash}&handlekey=a_delete_${favid}`
        });

        const verdict = judgeDeleteResponse(res);
        if (!verdict.ok) {
            if (!quiet) UI.alert("删除失败:" + verdict.why, 'error');
            return verdict;
        }

        // 立即同步本地列表:移除这一条,其余条目的检测结果原样保留 —— 不需要重新检测
        const removed = (favCacheList || []).find(x => String(x.favid) === String(favid));
        if (itemEl) itemEl.style.display = "none";
        favCacheList = (favCacheList || []).filter(x => String(x.favid) !== String(favid));
        if (removed) logDeleted(removed);   // 留一份可恢复记录
        GM_setValue("favCacheData", JSON.stringify(favCacheList));
        if (!quiet && favModal.style.display === "block") renderFavList();
        return { ok: true, why: "" };
    }

    // ====================== 收藏接口 ======================
    async function addFavorite(tid) {
        const formhash = getFormhash();
        if (!formhash || !tid) {
            UI.alert("收藏失败:formhash 或 帖子ID 缺失", 'error');
            return false;
        }

        const res = await gmHtml({
            method: "GET",
            url: `https://www.52pojie.cn/home.php?mod=spacecp&ac=favorite&type=thread&id=${tid}&formhash=${formhash}&infloat=yes&handlekey=k_favorite&inajax=1&ajaxtarget=fwin_content_k_favorite`,
            headers: {
                "Referer": "https://www.52pojie.cn/home.php?mod=space&do=favorite&view=me"
            }
        });
        if (res.ok && res.status === 200 && res.html.includes("收藏成功")) return true;
        UI.alert("收藏失败:服务器异常", 'error');
        return false;
    }

    async function fetchFirstPage() {
        const res = await gmHtml({ method: "GET", url: `https://www.52pojie.cn/home.php?mod=space&do=favorite&type=thread&page=1` });
        if (!res.ok) return [];
        const doc = parseDoc(res.html);
        if (!doc) return [];
        const list = [];
        doc.querySelectorAll('li[id^="fav_"]').forEach(it => {
            const obj = parseFavItem(it, 1);
            if (obj) list.push(obj);
        });
        return list;
    }

    // ====================== 更新收藏时间 ======================
    async function updateFavorite(item, itemEl) {
        if (!item.tid || !item.favid) {
            UI.alert("帖子信息不完整,无法更新", 'warn');
            return;
        }

        if (!await UI.confirm("确定要【更新收藏时间】吗?\n操作:删除旧收藏 → 重新收藏")) {
            return;
        }

        const delRes = await deleteFavorite(item.favid, null);
        if (!delRes.ok) return;   // deleteFavorite 内部已弹出失败原因
        await delay(800);

        const addOk = await addFavorite(item.tid);
        if (!addOk) return;

        UI.toast('收藏时间更新成功,正在置顶本条…', 'ok');

        const newList = await fetchFirstPage();
        const newItem = newList.find(x => x.tid === item.tid);

        if (newItem) {
            newItem.page = 1;
            newItem.update = false;
            // 继承原有的失效检测结果(刚收藏完不用重测)
            newItem.dead = item.dead || null;

            favCacheList = favCacheList.filter(x => x.tid !== item.tid);
            favCacheList.unshift(newItem);
            GM_setValue("favCacheData", JSON.stringify(favCacheList));
        }

        renderFavList();
    }

    function resetCheckState() {
        stopCheck = false;
        isLoading = false;
        currentTask = '';
        hideProgress();
        menuItems.forEach(item => {
            if (item.dataset.action === "checkSingle") item.textContent = "本页检测更新";
        });
    }

    function resetBatchBtn() {
        stopCheck = false;
        isLoading = false;
        currentTask = '';
        checkAllBtn.innerText = "批量检测更新";
        checkAllBtn.classList.remove("stop-bg");
        scanDeadBtn.innerText = "检测失效";
        scanDeadBtn.classList.remove("stop-bg");
    }

    async function checkSingleUpdate() {
        closeMenu();
        if (!isFavoritePage()) {
            UI.alert("请进入【我的-收藏】页面再使用", 'warn');
            return;
        }
        const singleItem = [...menuItems].find(i => i.dataset.action === "checkSingle");
        if (singleItem.textContent === "停止检测") {
            stopCheck = true;
            updateProgress(0, 0, "已手动停止");
            setTimeout(resetCheckState, 600);
            return;
        }
        stopCheck = false;
        isLoading = true;
        currentTask = 'batch';
        singleItem.textContent = "停止检测";
        showProgress();
        try {
            const els = [...document.querySelectorAll('li[id^="fav_"]')];
            const total = els.length;
            let cur = 0, upd = 0, failed = 0, firstWhy = '';
            for (const el of els) {
                if (stopCheck) break;
                cur++;
                const dDom = el.querySelector(".xg1");
                const link = el.querySelector('a[href*="thread-"]');
                if (!dDom || !link) continue;
                const favDate = parseChineseDate(dDom.textContent.trim());
                if (!favDate) continue;
                const url = link.href.startsWith("http") ? link.href : "https://www.52pojie.cn/" + link.href;
                updateProgress(cur, total, `检测 ${cur}/${total} · 间隔 ${getScanDelay()}ms`);

                const r = await probeThreadUpdate(url, favDate);
                if (!r.ok) {
                    // 失败要计数并上报,不能当成"没更新"
                    failed++;
                    if (!firstWhy) firstWhy = r.why;
                    if (/429/.test(r.why)) { bumpDelay(); }
                    await delay(getScanDelay());
                    continue;
                }
                easeDelay();

                // 回写缓存,保证弹窗列表与页面上看到的标记一致
                const favid = el.id.replace('fav_', '');
                const cached = (favCacheList || []).find(x => String(x.favid) === String(favid));
                if (cached) cached.update = r.updated;

                if (r.updated) {
                    const nx = dDom.nextElementSibling;
                    if (!nx || !nx.classList.contains("update-indicator")) {
                        const span = document.createElement("span");
                        span.className = "update-indicator";
                        span.innerText = "有更新";
                        dDom.after(span);
                    }
                    upd++;
                }
                await delay(getScanDelay());
            }
            if (favCacheList) GM_setValue("favCacheData", JSON.stringify(favCacheList));
            updateProgress(total, total, `完成:${upd} 个有更新`);
            if (failed) {
                UI.alert(`本页检测完成:${upd} 个有更新,${failed} 条检测失败。\n\n失败原因示例:${firstWhy}\n\n失败的条目按"未检测"处理,可稍后重试。`, 'warn');
            } else {
                UI.toast(`本页检测完成:${upd} 个有更新`, upd ? 'warn' : 'ok');
            }
        } catch (e) {
            console.error(e);
            UI.alert('本页检测出错:' + ((e && e.message) || e), 'error');
        } finally {
            resetCheckState();
        }
    }

    const mask = document.createElement('div'); mask.className = "mask-layer"; document.body.appendChild(mask);
    const favModal = document.createElement('div');
    favModal.id = "favModal";
    favModal.innerHTML = `
        <div class="modal-head">
            <span class="modal-title">全部收藏列表</span>
            <div style="display:flex;gap:6px;flex-wrap:wrap;">
                <button class="head-btn" id="refreshFavBtn" title="只刷新收藏成员,已检测出的失效标记会保留">同步列表</button>
                <button class="head-btn" id="checkAllFavBtn">批量检测更新</button>
                <button class="head-btn" id="scanDeadBtn">检测失效</button>
            </div>
            <span class="modal-close">×</span>
        </div>
        <input id="favSearchInput" placeholder="🔍 搜索全部收藏标题...">
        <div class="modal-toolbar">
            <button class="tool-btn" id="toggleDeadBtn">只看失效</button>
            <button class="tool-btn" id="copyDeadBtn">复制清单</button>
            <button class="tool-btn" id="exportDeadBtn">导出 TXT</button>
            <button class="tool-btn" id="exportCsvBtn">导出 CSV</button>
            <button class="tool-btn danger" id="delAllDeadBtn">删除全部失效</button>
            <span class="fav-stats" id="favStats"></span>
        </div>
        <div class="modal-body"></div>
    `;
    document.body.appendChild(favModal);

    const searchInput = favModal.querySelector("#favSearchInput");
    const refreshBtn = favModal.querySelector("#refreshFavBtn");
    const checkAllBtn = favModal.querySelector("#checkAllFavBtn");
    const scanDeadBtn = favModal.querySelector("#scanDeadBtn");
    const toggleDeadBtn = favModal.querySelector("#toggleDeadBtn");
    const copyDeadBtn = favModal.querySelector("#copyDeadBtn");
    const exportDeadBtn = favModal.querySelector("#exportDeadBtn");
    const exportCsvBtn = favModal.querySelector("#exportCsvBtn");
    const delAllDeadBtn = favModal.querySelector("#delAllDeadBtn");
    const favStats = favModal.querySelector("#favStats");
    const closeBtn = favModal.querySelector(".modal-close");
    const modalBody = favModal.querySelector(".modal-body");
    const modalTitle = favModal.querySelector(".modal-title");

    function closeModal() { favModal.style.display = "none"; mask.style.display = "none"; }
    closeBtn.onclick = closeModal;
    mask.onclick = closeModal;

    // Esc 关闭主弹窗。自绘弹窗的 Esc 处理在捕获阶段 stopPropagation,两者不会互相干扰。
    document.addEventListener('keydown', function (e) {
        if (e.key !== 'Escape') return;
        if (favModal.style.display === 'block') closeModal();
    });

    async function goPage(page) {
        const res = await gmHtml({ method: "GET", url: `https://www.52pojie.cn/home.php?mod=space&do=favorite&type=thread&page=${page}`, timeout: DEAD_SCAN_TIMEOUT });
        return res.ok ? parseDoc(res.html) : null;
    }

    async function collectAllFav(forceRefresh = false) {
        if (isLoading) return favCacheList;
        if (favCacheList && !forceRefresh) return favCacheList;
        // 采集前留一份旧列表:采完后按 favid 继承已有的检测结果,
        // 这样「重新采集」只刷新列表成员,不会把辛苦扫出来的失效标记清空
        const prev = favCacheList || [];
        isLoading = true;
        currentTask = 'collect';
        let allList = [], page = 1;
        const maxPage = getMaxPage();
        let failedPage = 0, reachedLimit = false;
        modalBody.innerHTML = `<div class="load-all-tip">正在采集收藏...</div>`;

        try {
            while (true) {
                if (page > maxPage) { reachedLimit = true; break; }
                const pageDoc = await goPage(page);
                if (!pageDoc) { failedPage = page; break; }
                const items = pageDoc.querySelectorAll('li[id^="fav_"]');
                if (items.length === 0) break;
                modalBody.innerHTML = `<div class="load-all-tip">采集第 ${page} 页 / 上限 ${maxPage} 页</div>`;
                items.forEach(item => {
                    const res = parseFavItem(item, page);
                    res && allList.push(res);
                });
                page++;
                await delay(400);
            }
        } catch (e) {
            console.error(e);
            failedPage = page;
        }

        // 采集中断时绝不能用残缺列表覆盖缓存 —— 否则第 N 页之后那些条目的检测结果会全部丢失
        if (failedPage && prev.length > 0) {
            isLoading = false;
            currentTask = '';
            UI.alert(`采集第 ${failedPage} 页失败,已中止。\n\n为避免丢失已有数据,本次结果未写入缓存(本地仍是上次的 ${prev.length} 条)。\n可稍后点「同步列表」重试。`, 'error');
            return favCacheList;
        }
        // 采到 0 条但本地原本有数据 → 多半是登录态失效,同样不覆盖
        if (allList.length === 0 && prev.length > 0) {
            isLoading = false;
            currentTask = '';
            UI.alert(`本次只采集到 0 条收藏,但本地记录有 ${prev.length} 条。\n\n可能登录态已失效或请求异常,本次结果未写入缓存。请确认已登录后重试。`, 'warn');
            return favCacheList;
        }

        // 继承旧的检测结果:favid 为准,tid 兜底(重建收藏后 favid 会变)
        const byFavid = new Map(), byTid = new Map();
        prev.forEach(x => {
            if (x.favid) byFavid.set(String(x.favid), x);
            if (x.tid) byTid.set(String(x.tid), x);
        });
        let carried = 0;
        allList.forEach(it => {
            const old = byFavid.get(String(it.favid)) || (it.tid && byTid.get(String(it.tid)));
            if (!old) return;
            if (old.dead) { it.dead = old.dead; carried++; }
            if (old.update) it.update = true;
        });

        favCacheList = allList;
        GM_setValue("favCacheData", JSON.stringify(allList));
        GM_setValue("favListSyncedAt", Date.now());
        GM_setValue("favListTruncated", reachedLimit ? 1 : 0);
        isLoading = false;
        currentTask = '';
        if (carried > 0) console.log(`[收藏检查] 列表同步完成,已继承 ${carried} 条历史检测结果`);
        if (reachedLimit) {
            // 因为撞上限而停,说明后面大概率还有 —— 不能让用户以为列表是完整的
            UI.toast(`已达页数上限 ${maxPage} 页(${allList.length} 条),可能还有更多未采集`, 'warn', 4500);
        }
        return favCacheList;
    }

    // 轻量校验:只抓第 1 页和缓存里最后一页,比对 favid 序列。
    // 一致 → 继续用缓存(省掉整轮采集);不一致 → 列表在别处被改过,自动重新采集。
    // 校验拿不到有效页面时一律返回 checked:false(宁可不动,也不误判)。
    async function verifyCacheFresh() {
        if (!favCacheList || favCacheList.length === 0) return { checked: false, ok: true, note: '无缓存' };

        const pageSet = new Set([1]);
        let maxPage = 1;
        favCacheList.forEach(x => { if (x.page && x.page > maxPage) maxPage = x.page; });
        if (maxPage > 1) pageSet.add(maxPage);

        for (const p of pageSet) {
            const res = await gmHtml({
                method: 'GET',
                url: `https://www.52pojie.cn/home.php?mod=space&do=favorite&type=thread&page=${p}`,
                timeout: DEAD_SCAN_TIMEOUT
            });
            if (!res.ok) return { checked: false, ok: true, note: '校验请求失败' };
            const doc = parseDoc(res.html);
            if (!doc || isHardChallenge(res.html, doc)) return { checked: false, ok: true, note: '校验被拦截' };

            const fresh = [];
            doc.querySelectorAll('li[id^="fav_"]').forEach(it => {
                const f = it.id.replace('fav_', '');
                if (f) fresh.push(f);
            });
            if (!fresh.length) return { checked: false, ok: true, note: `第 ${p} 页为空` };

            const cached = favCacheList.filter(x => x.page === p).map(x => String(x.favid));
            const same = fresh.length === cached.length && fresh.every((f, i) => f === cached[i]);
            if (!same) {
                return { checked: true, ok: false, note: `第 ${p} 页已变化(线上 ${fresh.length} 条 / 缓存 ${cached.length} 条)` };
            }
            await delay(300);
        }
        return { checked: true, ok: true };
    }

    function fmtTimeAgo(ts) {
        if (!ts) return '';
        const d = new Date(ts);
        const p = n => String(n).padStart(2, '0');
        return `${p(d.getHours())}:${p(d.getMinutes())}`;
    }

    function fmtTimeAgoFull(ts) {
        if (!ts) return '尚未同步';
        const mins = Math.floor((Date.now() - ts) / 60000);
        if (mins < 1) return '刚刚';
        if (mins < 60) return `${mins} 分钟前`;
        if (mins < 1440) return `${Math.floor(mins / 60)} 小时前`;
        return `${Math.floor(mins / 1440)} 天前`;
    }

    // ====================== 【新增】更新检测(保持原逻辑)======================
    // 【合并后的共享核心】本页检测与批量检测都走这里,两条路径不再各写一套。
    // 关键:必须区分「请求失败」和「帖子确实没被编辑过」——
    // 老实现两种情况都返回 null,被 WAF 拦时就会静默报"0 个有更新"。
    // 返回 { ok, updated, why }
    async function probeThreadUpdate(url, favDate) {
        const res = await gmHtml({ method: "GET", url: url, timeout: 8000 });
        if (!res.ok) return { ok: false, updated: false, why: res.err || '请求未完成' };
        return classifyThreadEdit(res.doc || parseDoc(res.html), res.html, res.status, favDate);
    }

    async function checkBatchUpdate() {
        closeMenu();
        if (checkAllBtn.innerText === "停止") {
            stopCheck = true;
            return;
        }
        if (isLoading || !favCacheList) {
            UI.alert("请先加载收藏列表", 'warn');
            return;
        }
        stopCheck = false;
        isLoading = true;
        currentTask = 'batch';
        checkAllBtn.innerText = "停止";
        checkAllBtn.classList.add("stop-bg");
        scanDeadBtn.innerText = "检测失效";
        scanDeadBtn.classList.remove("stop-bg");

        let upd = 0, failed = 0, firstWhy = '', stopped = false;
        try {
            for (let i = 0; i < favCacheList.length; i++) {
                if (stopCheck) break;
                const item = favCacheList[i];
                modalBody.innerHTML = `<div class="load-all-tip">批量检测 ${i + 1}/${favCacheList.length} · 间隔 ${getScanDelay()}ms<br>${escapeHtml(item.title)}</div>`;

                const r = await probeThreadUpdate(item.url, item.favDate);
                if (!r.ok) {
                    failed++;
                    if (!firstWhy) firstWhy = r.why;
                    if (/429/.test(r.why)) { bumpDelay(); }
                } else {
                    easeDelay();
                    item.update = r.updated;
                    if (r.updated) upd++;
                }
                if (i % 10 === 0) GM_setValue("favCacheData", JSON.stringify(favCacheList));
                await delay(getScanDelay());
            }
            stopped = stopCheck;   // resetBatchBtn 会把 stopCheck 清掉,先记下来
        } catch (e) {
            console.error(e);
            UI.alert('批量检测出错:' + ((e && e.message) || e), 'error');
        } finally {
            // 无论成功失败都要落盘并解锁,否则 isLoading 会一直卡住
            GM_setValue("favCacheData", JSON.stringify(favCacheList));
            renderFavList();
            resetBatchBtn();
        }

        let msg = `批量检测完成:${upd} 个有更新`;
        if (failed) msg += `,${failed} 条检测失败`;
        if (stopped) msg += `(已手动停止,剩余未处理)`;
        if (failed) {
            UI.alert(msg + `\n\n失败原因示例:${firstWhy}\n失败的条目标记未被改动,可稍后重试。`, 'warn');
        } else {
            UI.toast(msg, upd ? 'warn' : 'ok', stopped ? 4000 : 2600);
        }
    }

    checkAllBtn.onclick = checkBatchUpdate;

    // ====================== 【新增】失效检测核心 ======================
    // 统计口径都在纯函数 computeDeadStats 里(见上),这里只负责取当前列表
    function getDeadStats() {
        return computeDeadStats(favCacheList);
    }

    function getDeadList() {
        return (favCacheList || []).filter(isDeadItem);
    }

    async function scanDeadFavorites(autoSwitch) {
        closeMenu();
        if (scanDeadBtn.innerText === "停止检测") {
            stopCheck = true;
            return;
        }
        if (isLoading) { UI.alert("当前有其他任务正在执行,请稍候", 'warn'); return; }
        if (!favCacheList || favCacheList.length === 0) {
            UI.alert("请先点击「同步列表」加载收藏列表", 'warn');
            return;
        }
        // ---- 选择检测范围:默认只查还没有结果的,避免每次都全量重扫 ----
        const allCount = favCacheList.length;
        const unchecked = favCacheList.filter(x => !x.dead).length;
        const est = n => Math.max(1, Math.ceil(n * getScanDelay() / 60000));
        let targets;

        if (autoSwitch) {
            // 「一键提取失效」要快:只查没结果的;全都查过就直接看已有结果
            targets = unchecked > 0 ? favCacheList.filter(x => !x.dead) : favCacheList.slice();
        } else if (unchecked === 0) {
            if (!await UI.confirm(`全部 ${allCount} 条都已有检测结果。\n\n要重新检测一遍吗?(会重新访问 ${allCount} 个帖子页,约 ${est(allCount)} 分钟)`, { title: '检测失效', okText: '全部重检' })) return;
            targets = favCacheList.slice();
        } else {
            const scope = await UI.choose(
                `共 ${allCount} 条收藏,其中 ${unchecked} 条还没检测过。\n\n`
                + `· 仅检测未检过的:${unchecked} 条,约 ${est(unchecked)} 分钟\n`
                + `· 全部重新检测:${allCount} 条,约 ${est(allCount)} 分钟\n\n`
                + `已检测过的结果会保留。只有之前检测失败的条目才需要重检。`,
                [
                    { text: '取消', value: null },
                    { text: `仅检测未检过的(${unchecked})`, value: 'new', primary: true },
                    { text: `全部重检(${allCount})`, value: 'all' }
                ],
                '检测失效'
            );
            if (!scope) return;
            targets = scope === 'all' ? favCacheList.slice() : favCacheList.filter(x => !x.dead);
        }
        if (targets.length === 0) { UI.alert('没有需要检测的条目。', 'info'); return; }

        stopCheck = false;
        isLoading = true;
        currentTask = 'scan';
        scanDeadBtn.innerText = "停止检测";
        scanDeadBtn.classList.add("stop-bg");
        checkAllBtn.innerText = "批量检测更新";
        checkAllBtn.classList.remove("stop-bg");
        resetDelay();                 // 新一轮检测,间隔回到基准
        showProgress();

        const total = targets.length;
        let i = 0, deadN = 0, limN = 0, unkN = 0, blockedN = 0, consecBlocked = 0, aborted = '';

        try {
            // 预检:先用第一条探明哪条通道能拿到真实页面。
            // 放在循环前,避免跑了几百条才发现全军覆没。
            updateProgress(0, total, '正在预检传输通道...');
            modalBody.innerHTML = '<div class="load-all-tip">正在预检传输通道(页面fetch / GM请求 / iframe)...</div>';
            const pr = await probeTransports(targets[0].url, true);
            if (!pr.transport) {
                showTransportFailure(pr.tried, targets[0].url);
                return;
            }
            modalBody.innerHTML = `<div class="load-all-tip">通道就绪:${pr.transport} · 起始间隔 ${getScanDelay()}ms<br>开始检测 ${total} 条…</div>`;

            for (const item of targets) {
                if (stopCheck) break;
                i++;
                updateProgress(i, total, `失效检测 ${i}/${total}`);
                modalBody.innerHTML = `<div class="load-all-tip">正在检测 ${i}/${total}:<br>${escapeHtml(item.title)}</div>`;

                let r = await checkThreadStatus(item.url);

                if (r.type === 'login') {
                    aborted = "检测到「需要登录」的响应,为避免把全部收藏误判为失效,已自动中止。请先登录 52pojie 后重试。";
                    break;
                }

                // 429 限流:先把后续间隔整体调大(治本),再对当前这条退避重试
                if (r.type === 'ratelimited') {
                    bumpDelay();
                    let recovered = false;
                    for (let k = 0; k < 3 && !stopCheck; k++) {
                        const wait = 5000 * (k + 1);
                        updateProgress(i, total, `被限流,等 ${wait / 1000}s 后重试 ${k + 1}/3(间隔已调至 ${getScanDelay()}ms)`);
                        await delay(wait);
                        const r2 = await checkThreadStatus(item.url);
                        if (r2.type !== 'ratelimited') { r = r2; recovered = true; break; }
                        bumpDelay();
                    }
                    if (!recovered) {
                        aborted = `连续被限流(HTTP 429)。已中止,未产生结果。\n\n论坛对高频请求有硬限制。当前间隔已自动调到 ${getScanDelay()}ms,\n可以稍等几分钟再跑;批量越大越容易被限。`;
                        break;
                    }
                }

                // 挑战页不是帖子的状态,不能写进结果污染统计
                if (r.type === 'blocked') {
                    blockedN++;
                    consecBlocked++;
                    if (consecBlocked >= 3) {
                        resetTransport();
                        showTransportFailure([{ transport: r.transport, ok: true, status: 200, challenge: true, verdict: 'blocked' }], item.url);
                        return;
                    }
                } else {
                    consecBlocked = 0;
                    item.dead = r;
                    if (isDeadType(r.type)) deadN++;
                    else if (r.type === 'permission' || r.type === 'pending') limN++;
                    else if (r.type !== 'ok') unkN++;
                }

                // 每 10 条落盘一次,中断也不丢结果
                if (i % 10 === 0) GM_setValue("favCacheData", JSON.stringify(favCacheList));
                // 顺利一条就慢慢把间隔收回基准,兼顾速度和抗限流
                if (r.type !== 'blocked') easeDelay();
                updateProgress(i, total, `失效检测 ${i}/${total} · 间隔 ${getScanDelay()}ms`);
                await delay(getScanDelay());
            }
        } catch (e) {
            console.error(e);
        } finally {
            GM_setValue("favCacheData", JSON.stringify(favCacheList));
            isLoading = false;
            currentTask = '';
            hideProgress();
            scanDeadBtn.innerText = "检测失效";
            scanDeadBtn.classList.remove("stop-bg");
            renderFavList();
        }

        if (aborted) { UI.alert(aborted, 'error'); return; }

        const st = getDeadStats();
        let tip = `检测完成(共 ${i}/${total} 条 · 通道 ${activeTransport})\n\n失效 ${deadN} 条:\n  · 楼主被封禁 ${st.banned}\n  · 帖子已删除 ${st.deleted}\n  · 主题不存在 ${st.notexist}\n  · 404 页面不存在 ${st.notfound}\n受限 ${limN} 条(权限不足/审核中)\n异常 ${unkN} 条(需人工复核,未计入失效)`;
        if (blockedN > 0) tip += `\n被拦截 ${blockedN} 条(未计入任何结果)`;
        // 全盘异常时给出明确指引,而不是让用户对着一堆"响应异常"发懵
        if (unkN > 0 && unkN === i && deadN === 0) {
            tip += `\n\n⚠️ 全部 ${i} 条都是「响应异常」,说明不是帖子的状态问题,\n而是取页面这一步就错了。请点菜单「诊断(检测失败排查)」把报告发我。`;
        }

        // 从「一键提取失效收藏」入口进来:直接切到失效视图并交付结果
        if (autoSwitch) {
            deadOnly = true;
            toggleDeadBtn.classList.add("active");
            renderFavList();
            UI.alert(tip + "\n\n已切换到「只看失效」视图,可点「复制清单」或「导出 TXT」取走结果。", 'success');
            return;
        }

        renderFavList();
        if (deadN > 0 && !deadOnly) {
            if (await UI.confirm(tip + "\n\n是否切换到「只看失效」视图?")) {
                deadOnly = true;
                toggleDeadBtn.classList.add("active");
                renderFavList();
            }
        } else {
            UI.alert(tip);
        }
    }

    scanDeadBtn.onclick = function () { scanDeadFavorites(false); };

    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
    }

    // ====================== 清单导出 ======================
    function fmtNow() {
        const d = new Date();
        const p = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
    }

    function buildDeadReport() {
        const st = getDeadStats();
        const deadList = getDeadList();
        const limList = (favCacheList || []).filter(x => x.dead && (x.dead.type === 'permission' || x.dead.type === 'pending'));
        const unkList = (favCacheList || []).filter(x => x.dead && !isDeadType(x.dead.type) && x.dead.type !== 'ok' && x.dead.type !== 'permission' && x.dead.type !== 'pending');

        const L = [];
        L.push("# 52pojie 收藏失效清单");
        L.push(`# 导出时间:${fmtNow()}`);
        L.push(`# 收藏总数:${st.total}    失效:${deadList.length}    受限:${limList.length}    异常:${unkList.length}`);
        L.push("");
        L.push(`===== 失效收藏(楼主被封禁 ${st.banned} / 帖子已删除 ${st.deleted} / 主题不存在 ${st.notexist} / 404不存在 ${st.notfound})=====`);
        if (deadList.length === 0) L.push("(无)");
        deadList.forEach((x, idx) => {
            L.push(`${idx + 1}. [${x.dead.label}] ${x.title}`);
            L.push(`   ${x.url}`);
            if (x.dead.detail) L.push(`   站点提示:${x.dead.detail}`);
        });
        if (limList.length) {
            L.push("");
            L.push(`===== 受限收藏(权限不足 / 审核中,建议保留)=====`);
            limList.forEach((x, idx) => {
                L.push(`${idx + 1}. [${x.dead.label}] ${x.title}`);
                L.push(`   ${x.url}`);
            });
        }
        if (unkList.length) {
            L.push("");
            L.push(`===== 响应异常(需人工核实)=====`);
            unkList.forEach((x, idx) => {
                L.push(`${idx + 1}. [${x.dead.label}] ${x.title}`);
                L.push(`   ${x.url}`);
            });
        }
        return L.join("\r\n");
    }

    function copyText(text) {
        try {
            if (typeof GM_setClipboard === "function") { GM_setClipboard(text, "text"); return true; }
        } catch (e) { /* fallthrough */ }
        try {
            const ta = document.createElement("textarea");
            ta.value = text;
            ta.style.position = "fixed";
            ta.style.left = "-9999px";
            document.body.appendChild(ta);
            ta.select();
            const ok = document.execCommand("copy");
            ta.remove();
            return ok;
        } catch (e) { return false; }
    }

    function downloadText(filename, text, mime) {
        const blob = new Blob(["\ufeff" + text], { type: (mime || "text/plain") + ";charset=utf-8" });
        const a = document.createElement("a");
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
    }

    copyDeadBtn.onclick = function () {
        if (getDeadStats().checked === 0) { UI.alert("还没有检测结果,请先点「检测失效」", 'warn'); return; }
        const txt = buildDeadReport();
        if (copyText(txt)) {
            UI.toast(`已复制 ${getDeadList().length} 条失效记录`, 'ok');
        } else {
            UI.alert("复制失败,请改用「导出 TXT」", 'error');
        }
    };

    exportDeadBtn.onclick = function () {
        if (getDeadStats().checked === 0) { UI.alert("还没有检测结果,请先点「检测失效」", 'warn'); return; }
        const d = new Date();
        const p = n => String(n).padStart(2, '0');
        const name = `52pojie-失效收藏-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.txt`;
        downloadText(name, buildDeadReport());
    };

    exportCsvBtn.onclick = function () {
        if (!favCacheList || favCacheList.length === 0) { UI.alert("还没有列表数据,请先点「同步列表」。", 'warn'); return; }
        const st = getDeadStats();
        const d = new Date();
        const p = n => String(n).padStart(2, '0');
        const name = `52pojie-收藏清单-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.csv`;
        downloadText(name, buildCsvFrom(favCacheList), "text/csv");
        UI.toast(`已导出 ${favCacheList.length} 条(失效 ${st.dead} 条)`, 'ok');
    };

    toggleDeadBtn.onclick = function () {
        deadOnly = !deadOnly;
        toggleDeadBtn.classList.toggle("active", deadOnly);
        renderFavList();
    };

    // ====================== 【新增】批量删除失效收藏 ======================
    async function deleteAllDead() {
        const list = getDeadList();
        if (list.length === 0) {
            UI.alert("当前没有标记为失效的收藏。请先执行「检测失效」。", 'warn');
            return;
        }
        if (isLoading) { UI.alert("当前有其他任务正在执行,请稍候", 'warn'); return; }
        if (!await UI.confirm(`即将删除 ${list.length} 条失效收藏。\n\n该操作不可撤销(删除的是你在 52pojie 的收藏记录,非帖子本身)。是否继续?`)) return;
        if (!await UI.confirm(`二次确认:确定删除这 ${list.length} 条?`)) return;

        stopCheck = false;
        isLoading = true;
        currentTask = 'delete';
        showProgress();
        delAllDeadBtn.disabled = true;

        let ok = 0, fail = 0, done = 0, stopped = false;
        const reasons = [];
        const snapshot = list.slice();

        try {
            for (const item of snapshot) {
                if (stopCheck) break;
                done++;
                updateProgress(done, snapshot.length, `删除失效收藏 ${done}/${snapshot.length}`);
                const r = await deleteFavorite(item.favid, null, true);
                if (r.ok) {
                    ok++;
                } else {
                    fail++;
                    if (reasons.length < 3) reasons.push(`「${item.title.slice(0, 20)}」${r.why}`);
                }
                await delay(/429/.test(r.why || '') ? Math.min(DELAY_MAX, DEFAULT_DEL_DELAY * 4) : DEFAULT_DEL_DELAY);
            }
            stopped = stopCheck;
        } catch (e) {
            console.error(e);
            reasons.push('执行中出错:' + ((e && e.message) || e));
            fail++;
        } finally {
            // 必须无条件解锁,否则按钮和 isLoading 会一直卡住
            isLoading = false;
            currentTask = '';
            hideProgress();
            delAllDeadBtn.disabled = false;
            renderFavList();
        }

        let msg = `清理完成:成功 ${ok} 条,失败 ${fail} 条。`;
        if (fail > 0) {
            msg += `\n\n失败条目仍保留在列表中,可稍后重试。\n失败原因示例:\n` + reasons.map(x => '  · ' + x).join('\n');
        }
        if (stopped) msg += `\n\n(已手动停止,剩余条目未处理)`;
        if (ok > 0) msg += `\n\n误删可到菜单「恢复误删的收藏」里找回。`;
        UI.alert(msg, fail ? 'warn' : 'success');
    }

    delAllDeadBtn.onclick = deleteAllDead;

    // ====================== 渲染列表 ======================
    function renderFavList() {
        if (!favCacheList) return;
        const key = searchInput.value.trim().toLowerCase();
        const st = getDeadStats();

        const maxPage = getMaxPage();
        modalTitle.textContent = `全部收藏:${favCacheList.length} 条 / 上限:${maxPage} 页`;

        const syncedAt = parseInt(GM_getValue("favListSyncedAt", "")) || 0;
        const truncated = parseInt(GM_getValue("favListTruncated", "0")) === 1;
        favStats.textContent = (st.checked === 0
            ? "未检测失效"
            : `失效 ${st.dead} · 受限 ${st.limited} · 异常 ${st.unknown}`
              + (st.ignored ? ` · 已忽略 ${st.ignored}` : ''))
            + (syncedAt ? ` | 列表 ${fmtTimeAgo(syncedAt)}` : '')
            + (truncated ? ' | ⚠ 未采完' : '');
        favStats.title = (syncedAt
            ? `列表同步于 ${new Date(syncedAt).toLocaleString('zh-CN')}(${fmtTimeAgoFull(syncedAt)})\n点「同步列表」可刷新成员,检测结果会保留`
            : '尚未同步列表')
            + (truncated ? `\n\n⚠ 上次采集撞到页数上限(${getMaxPage()} 页)就停了,后面可能还有收藏没采到。\n用菜单「设置采集页数上限」调大后再同步。` : '');

        let filterList = favCacheList.filter(item => item.title.toLowerCase().includes(key));
        if (deadOnly) {
            filterList = filterList.filter(item => item.dead && item.dead.type !== 'ok');
        }

        modalBody.innerHTML = "";
        if (filterList.length === 0) {
            modalBody.innerHTML = deadOnly
                ? '<div class="empty-tip">没有失效收藏(或尚未检测)</div>'
                : '<div class="empty-tip">无匹配收藏</div>';
            return;
        }

        filterList.forEach(item => {
            const div = document.createElement("div");
            const dt = item.dead && item.dead.type;
            const confirmedDead = isDeadItem(item);
            div.className = "fav-item" + (confirmedDead ? " is-dead" : "");
            if (item.dead && item.dead.detail) div.title = item.dead.detail;

            let deadTag = "";
            if (dt && dt !== 'ok') {
                if (item.dead.override && isDeadType(dt)) {
                    deadTag = `<span class="dead-tag t-ignored" data-toggle="1" title="点这里恢复为失效">已忽略 · 点此恢复</span>`;
                } else {
                    const extra = isDeadType(dt) ? '(点此标记为正常,不再计入失效)' : '';
                    deadTag = `<span class="dead-tag ${tagClassOf(dt)}${isDeadType(dt) ? ' clickable' : ''}"${isDeadType(dt) ? ' data-toggle="1"' : ''} title="${escapeHtml(item.dead.label + extra)}">${escapeHtml(item.dead.label)}</span>`;
                }
            }

            div.innerHTML = `
                <div class="fav-title">
                    ${escapeHtml(item.title)}${item.update ? '<span class="fav-update">●有更新</span>' : ""}${deadTag}
                </div>
                <span class="fav-page-tag">第${item.page}页</span>
                <button class="fav-btn fav-update-btn">更新收藏</button>
                <button class="fav-btn fav-del-btn">删除</button>
            `;

            div.querySelector('.fav-title').onclick = () => window.open(item.url, "_blank");

            // 点状态标签 = 手动纠正误判。不用整轮重扫。
            const tagEl = div.querySelector('.dead-tag[data-toggle]');
            if (tagEl) {
                tagEl.onclick = (e) => {
                    e.stopPropagation();
                    item.dead.override = !item.dead.override;
                    GM_setValue("favCacheData", JSON.stringify(favCacheList));
                    renderFavList();
                    UI.toast(item.dead.override ? '已标记为正常,不再计入失效' : '已恢复为失效', 'ok');
                };
            }

            const updateBtn = div.querySelector('.fav-update-btn');
            updateBtn.onclick = (e) => {
                e.stopPropagation();
                updateFavorite(item, div);
            };

            const delBtn = div.querySelector('.fav-del-btn');
            delBtn.onclick = async (e) => {
                e.stopPropagation();
                if (!await UI.confirm('确定要删除该收藏吗?')) return;
                delBtn.disabled = true;
                delBtn.textContent = '删除中';
                const r = await deleteFavorite(item.favid, div);
                if (!r.ok) {
                    // 失败必须看得见:请求可能已发出,但结果不确定,不能静默
                    delBtn.disabled = false;
                    delBtn.textContent = '删除';
                    UI.alert('删除失败:' + r.why + '\n\n如果刷新后确认已删掉,说明请求其实成功了。点标题栏的「同步列表」即可让本地列表跟上,已检测出的标记不会丢。', 'error');
                }
            };

            modalBody.appendChild(div);
        });
    }

    async function openFavModal() {
        closeMenu();
        getFormhash();
        favModal.style.display = "block";
        mask.style.display = "block";
        searchInput.value = "";
        if (!favCacheList) {
            await collectAllFav(false);
        } else {
            // 有缓存时先做一次轻量校验,避免把「在别处已被删掉」的条目继续展示
            modalBody.innerHTML = '<div class="load-all-tip">正在校验列表是否最新…</div>';
            const v = await verifyCacheFresh();
            if (v.checked && !v.ok) {
                modalBody.innerHTML = `<div class="load-all-tip">${escapeHtml(v.note)}<br>正在同步列表(检测结果会保留)…</div>`;
                await collectAllFav(true);
                UI.toast('列表已自动同步,检测结果已保留', 'ok');
            } else if (v.note) {
                console.log('[收藏检查] 列表校验跳过:' + v.note);
            }
        }
        deadOnly = false;
        toggleDeadBtn.classList.remove("active");
        renderFavList();
    }

    // 【新增】一键提取入口:打开弹窗 → 直接开始失效检测 → 自动切到只看失效
    async function extractDeadFavorites() {
        closeMenu();
        getFormhash();
        favModal.style.display = "block";
        mask.style.display = "block";
        if (!favCacheList) await collectAllFav(false);
        if (!favCacheList || favCacheList.length === 0) {
            UI.alert("未采集到收藏,请确认已登录且收藏不为空。", 'warn');
            return;
        }
        // 全都已有结果就别重复扫了,直接把上次的结果摊开
        if (favCacheList.every(x => x.dead)) {
            deadOnly = true;
            toggleDeadBtn.classList.add("active");
            renderFavList();
            const st = getDeadStats();
            UI.alert(`已有全部 ${favCacheList.length} 条的检测结果:\n\n失效 ${st.dead} 条 · 受限 ${st.limited} 条 · 异常 ${st.unknown} 条`
                + (st.ignored ? ` · 已忽略 ${st.ignored} 条` : '')
                + `\n\n已切到「只看失效」视图。如需重新检测,点标题栏的「检测失效」。`, 'info');
            return;
        }
        await scanDeadFavorites(true);
    }

    // 【新增】诊断:用三种通道各打一次同一条收藏,把原始证据摊出来
    async function runDiagnostics() {
        closeMenu();
        favModal.style.display = 'block';
        mask.style.display = 'block';
        modalTitle.textContent = '传输通道诊断';
        favStats.textContent = '';
        if (!favCacheList || favCacheList.length === 0) {
            modalBody.innerHTML = '<div class="load-all-tip">请先点「同步列表」加载收藏列表,再运行诊断。</div>';
            return;
        }
        const item = favCacheList[0];
        const lines = [];
        lines.push('52pojie 收藏检测 · 传输通道诊断报告');
        lines.push('时间:' + fmtNow());
        lines.push('已锁定通道:' + (activeTransport || '(未锁定)'));
        lines.push('样本收藏:' + item.url);
        lines.push('');

        for (const t of TRANSPORTS) {
            modalBody.innerHTML = `<div class="load-all-tip">正在测试通道 ${t} …</div>`;
            const res = await rawFetch(item.url, t);
            const doc = res.doc || parseDoc(res.html);
            const v = res.ok ? classifyDoc(doc, res.html, res.status) : null;
            lines.push(`【${t}】`);
            lines.push(`  请求结果   : ${res.ok ? 'HTTP ' + res.status : '失败 - ' + (res.err || '')}`);
            lines.push(`  耗时/长度  : ${res.ms}ms / ${(res.html || '').length} 字节`);
            lines.push(`  页面标题   : ${doc ? (doc.title || '(空)') : '(无法解析)'}`);
            lines.push(`  有帖子结构 : ${hasPostStruct(doc) ? '是' : '否'}`);
            lines.push(`  命中判定   : ${v ? v.type + ' - ' + v.label : '-'}`);
            lines.push(`  内容片段   : ${(res.html || '').replace(/\s+/g, ' ').slice(0, 180)}`);
            lines.push('');
            await delay(400);
        }
        lines.push('(把以上内容完整发回即可定位:是 WAF 拦截、登录态问题,还是页面结构变了)');

        const report = lines.join('\n');
        modalBody.innerHTML = `<pre class="diag">${escapeHtml(report)}</pre>`;
        copyText(report);
        UI.toast('诊断报告已复制到剪贴板', 'ok');
    }

    menuItems.forEach(item => {
        item.onclick = function () {
            const act = this.dataset.action;
            switch (act) {
                case "checkSingle": checkSingleUpdate(); break;
                case "openFav": openFavModal(); break;
                case "checkDead": extractDeadFavorites(); break;
                case "diag": runDiagnostics(); break;
                case "restore": restoreDeleted(); break;
                case "setPageLimit": setPageLimit(); break;
            }
        };
    });

    refreshBtn.onclick = async function () {
        if (isLoading) { UI.alert("当前有其他任务正在执行,请稍候", 'warn'); return; }
        await collectAllFav(true);
        renderFavList();
        UI.toast('列表已同步,检测结果已保留', 'ok');
    };
    searchInput.addEventListener("input", renderFavList);

})();

链接:https://wwbfw.lanzoue.com/b00mqnhcgd  密码:52pj

免费评分

参与人数 3威望 +1 吾爱币 +12 热心值 +3 收起 理由
苏紫方璇 + 1 + 10 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
helh0275 + 1 + 1 大家下载链接里的使用,才行!
981930674 + 1 + 1 我很赞同!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
981930674 发表于 2026-9-21 15:55
Su007 发表于 2026-9-21 11:58
我从论坛复制的代码也不行,你重新复制一下,不行的话,你从蓝奏里面下载源码,改个后缀

我复制了代码也没有看到原来的按钮之类,篡改猴
推荐
helh0275 发表于 2026-9-21 11:19
本帖最后由 helh0275 于 2026-9-21 12:01 编辑

试了下,没提示啊,刷新N次也没啥东西出来
难道是我用错了地方?

4#
981930674 发表于 2026-9-21 10:27
5#
czev 发表于 2026-9-21 10:27
能不能开发个浏览器收藏夹整理 常用提前 无效删除
6#
 楼主| Su007 发表于 2026-9-21 10:31 |楼主
981930674 发表于 2026-9-21 10:27
图都挂了,显示不出来

啊,没有啊,我这边还可以看呀
7#
 楼主| Su007 发表于 2026-9-21 10:34 |楼主
czev 发表于 2026-9-21 10:27
能不能开发个浏览器收藏夹整理 常用提前 无效删除

我觉得这个想法可以,我想一下
8#
conaneow 发表于 2026-9-21 10:59
哈哈,这个是不是衬托了官方的无能?
9#
 楼主| Su007 发表于 2026-9-21 11:58 |楼主
helh0275 发表于 2026-9-21 11:19
试了下,没提示啊,刷新N次也没啥东西出来

我从论坛复制的代码也不行,你重新复制一下,不行的话,你从蓝奏里面下载源码,改个后缀
10#
helh0275 发表于 2026-9-21 12:09
用链接里的助手代码,可行,感谢楼主制作分享
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-11 08:50

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表