Patreon 图片下载工具

Patreon 网站单个、当前页面所有图片下载

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name                Patreon 图片下载工具
// @name:zh-CN          Patreon 图片下载工具
// @name:zh-TW          Patreon 圖片下載工具
// @name:ja             Patreon 画像ダウンローダー
// @name:en             Patreon Image Downloader
// @name:ko             Patreon 이미지 다운로더
// @namespace           https://greasyfork.org/users/1271023
// @version             1.7.1
// @author              朧月猫
// @description         Patreon 网站单个、当前页面所有图片下载
// @description:zh-CN   Patreon 网站单个、当前页面所有图片下载
// @description:zh-TW   Patreon 網站單個、當前頁面所有圖片下載
// @description:ja      Patreonサイトの現在のページにある個々の画像またはすべての画像をダウンロードします
// @description:en      Download individual or all images on the current Patreon page
// @description:ko      Patreon 웹사이트의 현재 페이지에 있는 단일 또는 모든 이미지를 다운로드합니다
// @match               https://www.patreon.com/*
// @icon                https://www.patreon.com/favicon.ico
// @license             MIT
// @grant               GM_xmlhttpRequest
// @grant               GM_setValue
// @grant               GM_getValue
// @grant               GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 词典与探测逻辑
    // ==========================================
    const i18n = {
        'zh-CN': {
            noImages: "当前页面没有需要下载的图片!",
            confirmBulk: "发现 {count} 个下载目标,是否开始全部下载?",
            dirUpdated: "下载目录已更新,后续图片将保存在: [所选目录]/patreon/[作者名]/",
            toastSuccess: "✅ Post: {pid} 全部下载成功!",
            toastPartial: "❌ Post: {pid} 下载完成,但有 {fail} 个失败",
            btnTitleBulk: "一键下载所有图片 (右键切换功能)",
            btnTitleBulkAlt: "一键下载所有图片 (右键切换'设置盘符')",
            btnTitleDir: "设置/更改保存盘符 (右键切换'全部下载')",
            dashTitle: "下载总览",
            dashClearTip: "清除成功记录",
            dashExpandTip: "展开面板",
            dashCollapseTip: "折叠面板",
            dashSettingsTip: "下载器设置",
            dashRetry: "重试",
            dashRetrying: "重试中",
            dashFailTip: "{fail} 张失败",
            bulkBtnIdle: "全部下载",
            bulkBtnLoading: "下载中...",
            bulkBtnSuccess: "已完成",
            // 新增面板设置词典
            settingTitle: "下载设置",
            settingToast: "底部下载提示框",
            settingJpeg: "自动将后缀 jpeg 转为 jpg",
            settingFormat: "自定义图片命名规则",
            settingVars: "可用变量占位符",
            varCreator: "作者名称",
            varDate: "发布日期 (YYYYMMDD)",
            varPostId: "帖子 ID",
            varNum: "图片序号",
            varName: "原图片名 / Web / Preview",
            settingPreview: "预览生成的图片名称",
            btnReset: "恢复默认",
            btnCancel: "取消",
            btnSave: "保存设置",
            menuSettings: "⚙️ 下载设置"
        },
        'zh-HK': {
            noImages: "目前頁面沒有需要下載的圖片!",
            confirmBulk: "發現 {count} 個下載目標,是否開始全部下載?",
            dirUpdated: "下載目錄已更新,後續圖片將儲存在: [所選目錄]/patreon/[作者名]/",
            toastSuccess: "✅ Post: {pid} 全部下載成功!",
            toastPartial: "❌ Post: {pid} 下載完成,但有 {fail} 個失敗",
            btnTitleBulk: "一鍵下載所有圖片 (右鍵切換功能)",
            btnTitleBulkAlt: "一鍵下載所有圖片 (右鍵切換'設定磁碟機')",
            btnTitleDir: "設定/變更儲存磁碟機 (右鍵切換'全部下載')",
            dashTitle: "下載總覽",
            dashClearTip: "清除成功紀錄",
            dashExpandTip: "展開面板",
            dashCollapseTip: "折疊面板",
            dashSettingsTip: "下載器設定",
            dashRetry: "重試",
            dashRetrying: "重試中",
            dashFailTip: "{fail} 張失敗",
            bulkBtnIdle: "全部下載",
            bulkBtnLoading: "下載中...",
            bulkBtnSuccess: "已完成",
            settingTitle: "下載設定",
            settingToast: "底部下載提示框",
            settingJpeg: "自動將後綴 jpeg 轉為 jpg",
            settingFormat: "自訂圖片命名規則",
            settingVars: "可用變數佔位符",
            varCreator: "作者名稱",
            varDate: "發布日期 (YYYYMMDD)",
            varPostId: "貼文 ID",
            varNum: "圖片序號",
            varName: "原圖片名 / Web / Preview",
            settingPreview: "預覽產生的圖片名稱",
            btnReset: "恢復預設",
            btnCancel: "取消",
            btnSave: "儲存設定",
            menuSettings: "⚙️ 下載設定"
        },
        'ja-JP': {
            noImages: "現在のページにダウンロードする画像はありません!",
            confirmBulk: "{count} 個のダウンロード対象が見つかりました。すべてダウンロードしますか?",
            dirUpdated: "ダウンロード先が更新されました。今後の画像は [選択先]/patreon/[クリエイター名]/ に保存されます",
            toastSuccess: "✅ Post: {pid} すべてダウンロード完了!",
            toastPartial: "❌ Post: {pid} ダウンロード完了しましたが、{fail} 個のエラーがあります",
            btnTitleBulk: "すべての画像をワンクリックダウンロード (右クリックで機能切り替え)",
            btnTitleBulkAlt: "すべての画像をワンクリックダウンロード (右クリックで「保存先設定」に切り替え)",
            btnTitleDir: "保存先の設定/変更 (右クリックで「すべてダウンロード」に切り替え)",
            dashTitle: "ダウンロード概要",
            dashClearTip: "成功履歴をクリア",
            dashExpandTip: "パネルを展開",
            dashCollapseTip: "パネルを折りたたむ",
            dashSettingsTip: "設定",
            dashRetry: "再試行",
            dashRetrying: "再試行中",
            dashFailTip: "{fail} 枚失敗",
            bulkBtnIdle: "すべてダウンロード",
            bulkBtnLoading: "ダウンロード中...",
            bulkBtnSuccess: "完了",
            settingTitle: "ダウンロード設定",
            settingToast: "下部のトースト通知",
            settingJpeg: "拡張子 jpeg を jpg に自動変換",
            settingFormat: "カスタム画像命名規則",
            settingVars: "利用可能な変数プレースホルダー",
            varCreator: "クリエイター名",
            varDate: "公開日 (YYYYMMDD)",
            varPostId: "ポスト ID",
            varNum: "画像番号",
            varName: "元のファイル名 / Web / Preview",
            settingPreview: "生成されるファイル名のプレビュー",
            btnReset: "デフォルトに戻す",
            btnCancel: "キャンセル",
            btnSave: "設定を保存",
            menuSettings: "⚙️ ダウンロード設定"
        },
        'en': {
            noImages: "No images to download on the current page!",
            confirmBulk: "Found {count} download targets. Start downloading all?",
            dirUpdated: "Download directory updated. Future images will be saved in: [Selected Dir]/patreon/[Creator Name]/",
            toastSuccess: "✅ Post: {pid} all downloaded successfully!",
            toastPartial: "❌ Post: {pid} downloaded, but {fail} failed",
            btnTitleBulk: "One-click download all images (Right-click to toggle function)",
            btnTitleBulkAlt: "One-click download all images (Right-click to toggle 'Set Save Drive')",
            btnTitleDir: "Set/change save drive (Right-click to toggle 'Download All')",
            dashTitle: "Download Overview",
            dashClearTip: "Clear success records",
            dashExpandTip: "Expand panel",
            dashCollapseTip: "Collapse panel",
            dashSettingsTip: "Settings",
            dashRetry: "Retry",
            dashRetrying: "Retrying",
            dashFailTip: "{fail} failed",
            bulkBtnIdle: "Download All",
            bulkBtnLoading: "Downloading...",
            bulkBtnSuccess: "Completed",
            settingTitle: "Download Settings",
            settingToast: "Bottom Toast Notifications",
            settingJpeg: "Auto-convert .jpeg to .jpg",
            settingFormat: "Custom Image Naming Format",
            settingVars: "Available Variable Placeholders",
            varCreator: "Creator Name",
            varDate: "Publish Date (YYYYMMDD)",
            varPostId: "Post ID",
            varNum: "Image Index",
            varName: "Original Name / Web / Preview",
            settingPreview: "Preview Generated File Name",
            btnReset: "Restore Defaults",
            btnCancel: "Cancel",
            btnSave: "Save Settings",
            menuSettings: "⚙️ Download Settings"
        },
        'ko-KR': {
            noImages: "현재 페이지에 다운로드할 이미지가 없습니다!",
            confirmBulk: "{count}개의 다운로드 대상을 찾았습니다. 모두 다운로드하시겠습니까?",
            dirUpdated: "다운로드 경로가 업데이트되었습니다. 이후 이미지는 [선택한 폴더]/patreon/[크리에이터 이름]/ 에 저장됩니다.",
            toastSuccess: "✅ Post: {pid} 모두 다운로드 완료!",
            toastPartial: "❌ Post: {pid} 다운로드 완료, 하지만 {fail}개 실패",
            btnTitleBulk: "모든 이미지 원클릭 다운로드 (우클릭하여 기능 전환)",
            btnTitleBulkAlt: "모든 이미지 원클릭 다운로드 (우클릭하여 '저장 드라이브 설정' 전환)",
            btnTitleDir: "저장 드라이브 설정/변경 (우클릭하여 '모두 다운로드' 전환)",
            dashTitle: "다운로드 개요",
            dashClearTip: "성공 기록 지우기",
            dashExpandTip: "패널 펼치기",
            dashCollapseTip: "패널 접기",
            dashSettingsTip: "설정",
            dashRetry: "재시도",
            dashRetrying: "재시도 중",
            dashFailTip: "{fail}개 실패",
            bulkBtnIdle: "모두 다운로드",
            bulkBtnLoading: "다운로드 중...",
            bulkBtnSuccess: "완료",
            settingTitle: "다운로드 설정",
            settingToast: "하단 토스트 알림",
            settingJpeg: "jpeg 확장자를 jpg로 자동 변환",
            settingFormat: "사용자 정의 이미지 명명 규칙",
            settingVars: "사용 가능한 변수 자리 표시자",
            varCreator: "크리에이터 이름",
            varDate: "게시 날짜 (YYYYMMDD)",
            varPostId: "포스트 ID",
            varNum: "이미지 번호",
            varName: "원본 파일명 / Web / Preview",
            settingPreview: "생성된 파일명 미리보기",
            btnReset: "기본값 복원",
            btnCancel: "취소",
            btnSave: "설정 저장",
            menuSettings: "⚙️ 다운로드 설정"
        }
    };

    const userLang = document.documentElement.lang || navigator.language || navigator.userLanguage || 'en';
    let currentLang = 'en';
    if (userLang.startsWith('zh')) {
        currentLang = (userLang.includes('TW') || userLang.includes('HK') || userLang.includes('MO') || userLang.toLowerCase().includes('hant')) ? 'zh-HK' : 'zh-CN';
    } else if (userLang.startsWith('ja')) {
        currentLang = 'ja-JP';
    } else if (userLang.startsWith('ko')) {
        currentLang = 'ko-KR';
    }

    const t = (key, params = {}) => {
        let str = i18n[currentLang][key] || i18n['en'][key] || key;
        for (const [k, v] of Object.entries(params)) {
            str = str.replace(`{${k}}`, v);
        }
        return str;
    };

    const apiCache = new Map();

    // ==========================================
    // 队列管理器
    // ==========================================
    window.ptrQueueManager = {
        process: async function(buttons, concurrency = 10) {
            const groups = [];
            buttons.forEach(btn => {
                const pid = btn.dataset.postid || 'Unknown';
                let group = groups.find(g => g.pid === pid);
                if (!group) {
                    group = { pid: pid, btns: [] };
                    groups.push(group);
                }
                group.btns.push(btn);
            });

            for (const group of groups) {
                await new Promise(resolve => {
                    let i = 0;
                    const timer = setInterval(() => {
                        let active = group.btns.filter(b => b.dataset.state === 'loading').length;
                        while (active < concurrency && i < group.btns.length) {
                            const btn = group.btns[i];
                            if (btn.dataset.state !== 'loading' && btn.dataset.state !== 'success') {
                                btn.click();
                                active++;
                            }
                            i++;
                        }
                        const doneCount = group.btns.filter(b => b.dataset.state === 'success' || b.dataset.state === 'error').length;
                        if (doneCount === group.btns.length) {
                            clearInterval(timer);
                            resolve();
                        }
                    }, 300);
                });
            }
        }
    };

    // ==========================================
    // 设置中心
    // ==========================================
    window.PtrSettings = (function() {
        const DEFAULT_CONFIG = {
            showToast: true,
            convertJpeg: true,
            nameFormat: "Patreon_{creator}_{date}_{postid}_{num}_{name}"
        };

        function getConfig() {
            try {
                const conf = GM_getValue('Patreon_FSA_Config');
                return conf ? { ...DEFAULT_CONFIG, ...JSON.parse(conf) } : DEFAULT_CONFIG;
            } catch (e) {
                return DEFAULT_CONFIG;
            }
        }

        function saveConfig(conf) {
            GM_setValue('Patreon_FSA_Config', JSON.stringify(conf));
        }

        function open() {
            const existingPanel = document.getElementById('ptr-settings-panel-demo');
            if (existingPanel) existingPanel.remove();
            const existingBackdrop = document.getElementById('ptr-settings-backdrop');
            if (existingBackdrop) existingBackdrop.remove();

            const currentConf = getConfig();

            const mockData = {
                creator: 'Artist',
                date: '20230318',
                postid: '80207727',
                num: '0',
                name: 'NAMELESS',
                originalExt: 'jpeg'
            };

            const DEFAULT_FORMAT = DEFAULT_CONFIG.nameFormat;

            const backdrop = document.createElement('div');
            backdrop.id = 'ptr-settings-backdrop';
            backdrop.style.cssText = "position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.15); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); z-index:2147483646;";
            document.body.appendChild(backdrop);

            const panel = document.createElement('div');
            panel.id = 'ptr-settings-panel-demo';
            panel.style.cssText = "position:fixed; top:50%; left:50%; transform:translate(-50%, -50%); width:425px; background:rgba(28,28,30,0.95); color:#fff; border-radius:16px; border:1px solid rgba(255,255,255,0.08); font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; z-index:2147483647; box-shadow:0 16px 40px rgba(0,0,0,0.5); padding:24px; backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px); transition: all 0.2s;";

panel.innerHTML = `
                <div style="font-weight:600; font-size:16px; padding-bottom:16px; margin-bottom:16px; border-bottom:1px solid rgba(255,255,255,0.08); display:flex; align-items:center;">
                    <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px;"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
                    ${t('settingTitle')}
                </div>

                <div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; padding: 4px 16px; margin-bottom: 20px;">
                    <label style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); cursor: pointer; user-select: none;">
                        <div style="display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500;">
                            <span style="font-size: 16px; opacity: 0.9;">🔔</span> ${t('settingToast')}
                        </div>
                        <div style="position: relative; width: 40px; height: 22px;">
                            <input type="checkbox" id="ptr-demo-toast" style="opacity:0; width:0; height:0; position:absolute;">
                            <div class="ptr-toggle-bg" style="position:absolute; top:0; left:0; right:0; bottom:0; border-radius:11px; transition:.3s;"></div>
                            <div class="ptr-toggle-dot" style="position:absolute; top:2px; left:20px; width:18px; height:18px; background:#fff; border-radius:50%; transition:.3s; box-shadow:0 2px 4px rgba(0,0,0,0.2);"></div>
                        </div>
                    </label>

                    <label style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; cursor: pointer; user-select: none;">
                        <div style="display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500;">
                            <span style="font-size: 16px; opacity: 0.9;">🔄</span> ${t('settingJpeg')}
                        </div>
                        <div style="position: relative; width: 40px; height: 22px;">
                            <input type="checkbox" id="ptr-demo-jpeg" style="opacity:0; width:0; height:0; position:absolute;">
                            <div class="ptr-toggle-bg" style="position:absolute; top:0; left:0; right:0; bottom:0; border-radius:11px; transition:.3s;"></div>
                            <div class="ptr-toggle-dot" style="position:absolute; top:2px; left:20px; width:18px; height:18px; background:#fff; border-radius:50%; transition:.3s; box-shadow:0 2px 4px rgba(0,0,0,0.2);"></div>
                        </div>
                    </label>
                </div>

                <div style="margin-bottom: 12px;">
                    <label style="font-size: 14px; display: block; margin-bottom: 8px; font-weight: 500;">📄 ${t('settingFormat')}</label>
                    <input type="text" id="ptr-demo-name-input" spellcheck="false" style="width: 100%; box-sizing: border-box; padding: 12px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.15); color: #fff; border-radius: 8px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; font-weight: 600; letter-spacing: 0.5px; outline: none; transition: border-color 0.2s;">
                </div>

                <div style="font-size: 12px; color: rgba(255,255,255,0.6); margin-bottom: 20px; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 8px; line-height: 1.6; border: 1px dashed rgba(255,255,255,0.1);">
                    <b style="color: rgba(255,255,255,0.9);">${t('settingVars')}</b><br>
                    <span style="display:inline-block; width:65px; color:#4cd964; font-family: monospace;">{creator}</span> : ${t('varCreator')}<br>
                    <span style="display:inline-block; width:65px; color:#4cd964; font-family: monospace;">{date}</span> : ${t('varDate')}<br>
                    <span style="display:inline-block; width:65px; color:#4cd964; font-family: monospace;">{postid}</span> : ${t('varPostId')}<br>
                    <span style="display:inline-block; width:65px; color:#4cd964; font-family: monospace;">{num}</span> : ${t('varNum')}<br>
                    <span style="display:inline-block; width:65px; color:#4cd964; font-family: monospace;">{name}</span> : ${t('varName')}
                </div>

                <div style="margin-bottom: 24px;">
                    <div style="font-size: 13px; margin-bottom: 8px; color: rgba(255,255,255,0.6); display: flex; align-items: center; gap: 6px; font-weight: 500;">
                        <svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
                        ${t('settingPreview')}
                    </div>
                    <div id="ptr-demo-preview" style="background: rgba(0,0,0,0.5); padding: 14px 12px; border-radius: 8px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; font-weight: 600; letter-spacing: 0.5px; color: #82CFFF; word-break: break-all; border-left: 3px solid #FF9FB5;">
                    </div>
                </div>

                <div style="display: flex; align-items: center; gap: 12px;">
                    <div id="ptr-demo-reset" style="padding: 10px 14px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s; margin-right: auto; text-align: center;">${t('btnReset')}</div>
                    <div id="ptr-demo-close" style="padding: 10px 18px; background: transparent; border: 1px solid rgba(255,255,255,0.2); color: #fff; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background 0.2s; text-align: center;">${t('btnCancel')}</div>
                    <div id="ptr-demo-save" style="padding: 10px 24px; background: #FF9FB5; border: none; color: #fff; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 13px; transition: background 0.2s; text-align: center;">${t('btnSave')}</div>
                </div>
            `;

            document.body.appendChild(panel);

            const inputElem = document.getElementById('ptr-demo-name-input');
            const previewElem = document.getElementById('ptr-demo-preview');
            const closeBtn = document.getElementById('ptr-demo-close');
            const saveBtn = document.getElementById('ptr-demo-save');
            const resetBtn = document.getElementById('ptr-demo-reset');
            const toastToggle = document.getElementById('ptr-demo-toast');
            const jpegToggle = document.getElementById('ptr-demo-jpeg');

            const closeSettings = () => {
                if (panel) panel.remove();
                if (backdrop) backdrop.remove();
            };

            backdrop.addEventListener('mousedown', closeSettings);

            inputElem.value = currentConf.nameFormat;
            toastToggle.checked = currentConf.showToast;
            jpegToggle.checked = currentConf.convertJpeg;

            const bindToggle = (id, onChange) => {
                const inp = document.getElementById(id);
                const bg = inp.nextElementSibling;
                const dot = bg.nextElementSibling;
                const updateUI = () => {
                    bg.style.background = inp.checked ? '#4cd964' : 'rgba(255,255,255,0.15)';
                    dot.style.left = inp.checked ? '20px' : '2px';
                };
                inp.addEventListener('change', () => { updateUI(); if(onChange) onChange(); });
                updateUI();
            };

            bindToggle('ptr-demo-toast');
            bindToggle('ptr-demo-jpeg', () => updatePreview());

            inputElem.addEventListener('focus', () => inputElem.style.borderColor = '#FF9FB5');
            inputElem.addEventListener('blur', () => inputElem.style.borderColor = 'rgba(255,255,255,0.15)');

            const updatePreview = () => {
                let text = inputElem.value;
                text = text.replace(/{creator}/g, mockData.creator)
                           .replace(/{date}/g, mockData.date)
                           .replace(/{postid}/g, mockData.postid)
                           .replace(/{num}/g, mockData.num)
                           .replace(/{name}/g, mockData.name);

                const finalExt = jpegToggle.checked ? 'jpg' : mockData.originalExt;
                previewElem.innerText = text + '.' + finalExt;
            };

            inputElem.addEventListener('input', updatePreview);
            updatePreview();

            resetBtn.addEventListener('click', () => {
                inputElem.value = DEFAULT_FORMAT;
                ['ptr-demo-toast', 'ptr-demo-jpeg'].forEach(id => {
                    const cb = document.getElementById(id);
                    if (!cb.checked) {
                        cb.checked = true;
                        cb.dispatchEvent(new Event('change'));
                    }
                });
                updatePreview();
            });
            resetBtn.addEventListener('mouseenter', () => resetBtn.style.color = '#fff');
            resetBtn.addEventListener('mouseleave', () => resetBtn.style.color = 'rgba(255,255,255,0.7)');

            closeBtn.addEventListener('click', closeSettings);
            closeBtn.addEventListener('mouseenter', () => closeBtn.style.background = 'rgba(255,255,255,0.1)');
            closeBtn.addEventListener('mouseleave', () => closeBtn.style.background = 'transparent');

            saveBtn.addEventListener('click', () => {
                saveConfig({
                    showToast: toastToggle.checked,
                    convertJpeg: jpegToggle.checked,
                    nameFormat: inputElem.value.trim()
                });
                closeSettings();
            });

            saveBtn.addEventListener('mouseenter', () => { saveBtn.style.background = '#FF7A9A' });
            saveBtn.addEventListener('mouseleave', () => { saveBtn.style.background = '#FF9FB5' });
        }

        return { getConfig, saveConfig, open };
    })();

    GM_registerMenuCommand(t('menuSettings'), window.PtrSettings.open);

    // ==========================================
    // FSA API 与记忆持久化逻辑
    // ==========================================
    let currentRootHandle = null;
    const dbName = 'PatreonFSA_DB';
    const storeName = 'dirHandles';

    async function initDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(dbName, 1);
            request.onupgradeneeded = (e) => { e.target.result.createObjectStore(storeName); };
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async function saveHandle(handle) {
        const db = await initDB();
        return new Promise((resolve) => {
            const tx = db.transaction(storeName, 'readwrite');
            tx.objectStore(storeName).put(handle, 'root');
            tx.oncomplete = resolve;
        });
    }

    async function getHandle() {
        const db = await initDB();
        return new Promise((resolve) => {
            const tx = db.transaction(storeName, 'readonly');
            const req = tx.objectStore(storeName).get('root');
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => resolve(null);
        });
    }

    async function verifyPermission(fileHandle, readWrite) {
        const options = { mode: readWrite ? 'readwrite' : 'read' };
        if ((await fileHandle.queryPermission(options)) === 'granted') return true;
        if ((await fileHandle.requestPermission(options)) === 'granted') return true;
        return false;
    }

    async function ensureRootHandle() {
        if (currentRootHandle) return true;
        let stored = await getHandle();
        if (stored) {
            if (await verifyPermission(stored, true)) {
                currentRootHandle = stored;
                return true;
            }
        }
        try {
            currentRootHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
            await saveHandle(currentRootHandle);
            return true;
        } catch (e) {
            console.warn("未选取保存目录", e);
            return false;
        }
    }

    async function fetchBlobAndType(url) {
        try {
            const res = await fetch(url);
            if (!res.ok) throw new Error("Fetch failed: " + res.status);
            const blob = await res.blob();
            const type = res.headers.get('content-type');
            return { blob, type };
        } catch (e) {
            console.warn("原生 fetch 失败,尝试降级使用 GM_xmlhttpRequest", e);
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'blob',
                    onload: (res) => {
                        if (res.status === 200) {
                            const match = res.responseHeaders.match(/content-type:\s*(.*)/i);
                            const type = match ? match[1].trim() : '';
                            resolve({ blob: res.response, type });
                        } else {
                            reject(new Error("HTTP " + res.status));
                        }
                    },
                    onerror: reject,
                    ontimeout: reject
                });
            });
        }
    }

    window.ToastManager = (function() {
        return {
            show: function(msg, isError = false) {
                if (!window.PtrSettings.getConfig().showToast) return;

                const toast = document.createElement('div');
                toast.innerText = msg;
                toast.style.cssText = `position:fixed; bottom:-60px; left:50%; transform:translateX(-50%); background:${isError ? '#FF424D' : '#4CAF50'}; color:#fff; padding:12px 24px; border-radius:30px; font-size:15px; font-weight:bold; box-shadow:0 4px 16px rgba(0,0,0,0.25); z-index:2147483640; transition:bottom 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); pointer-events:none;`;
                document.body.appendChild(toast);
                requestAnimationFrame(() => toast.style.bottom = '40px');
                setTimeout(() => {
                    toast.style.bottom = '-60px';
                    setTimeout(() => toast.remove(), 400);
                }, 3500);
            }
        };
    })();

    let customTooltip = document.getElementById('ptr-custom-tooltip');
    if (!customTooltip) {
        customTooltip = document.createElement('div');
        customTooltip.id = 'ptr-custom-tooltip';
        customTooltip.style.cssText = "position:fixed; background:rgba(40,40,40,0.6); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); border:1px solid rgba(255,255,255,0.15); color:#ff6b6b; padding:6px 12px; border-radius:8px; font-size:12px; pointer-events:none; z-index:2147483647; opacity:0; visibility:hidden; transition:opacity 0.2s ease, transform 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28); transform:translateY(4px); white-space:nowrap; box-shadow:0 8px 24px rgba(0,0,0,0.2); font-weight:500;";
        document.body.appendChild(customTooltip);
    }

    window.ptrShowTip = function(el, msg) {
        customTooltip.textContent = msg;
        customTooltip.style.visibility = 'visible';
        customTooltip.style.opacity = '1';
        customTooltip.style.transform = 'translateY(0)';
        const rect = el.getBoundingClientRect();
        customTooltip.style.left = (rect.left + rect.width / 2 - customTooltip.offsetWidth / 2) + 'px';
        customTooltip.style.top = (rect.top - customTooltip.offsetHeight - 8) + 'px';
    };

    window.ptrHideTip = function() {
        customTooltip.style.opacity = '0';
        customTooltip.style.transform = 'translateY(4px)';
        setTimeout(() => { if (customTooltip.style.opacity === '0') customTooltip.style.visibility = 'hidden'; }, 200);
    };

    window.DownloadDashboard = (function() {
        let container = null;
        let trackedPosts = new Map();
        let isMinimized = false;

        function init() {
            if(document.getElementById('ptr-download-dashboard')) return;
            container = document.createElement('div');
            container.id = 'ptr-download-dashboard';
            container.style.cssText = "position:fixed; top:20px; left:50%; transform:translateX(-50%); width:320px; background:rgba(25,25,25,0.65); color:#fff; border-radius:16px; border:1px solid rgba(255,255,255,0.08); font-size:13px; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; z-index:2147483630; display:none; flex-direction:column; backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px); box-shadow:0 12px 32px rgba(0,0,0,0.3); padding:16px;";

            container.addEventListener('click', (e) => {
                if (e.target.closest('.ptr-dash-btn-min')) {
                    isMinimized = !isMinimized;
                    render();
                }
                else if (e.target.closest('.ptr-dash-btn-clear')) {
                    for (let [pid, btnSet] of trackedPosts.entries()) {
                        let doneCount = 0; let failCount = 0;
                        btnSet.forEach(b => {
                            if (b.dataset.state === 'success') doneCount++;
                            if (b.dataset.state === 'error') { doneCount++; failCount++; }
                        });
                        if (doneCount === btnSet.size && failCount === 0) {
                            trackedPosts.delete(pid);
                        }
                    }
                    render();
                }
                else if (e.target.closest('.ptr-dash-btn-settings')) {
                    window.PtrSettings.open();
                }
                else if (e.target.closest('.ptr-dash-btn-retry')) {
                    const retryBtn = e.target.closest('.ptr-dash-btn-retry');
                    const pid = retryBtn.dataset.pid;
                    const btnSet = trackedPosts.get(pid);

                    if (btnSet) {
                        retryBtn.innerText = t('dashRetrying');
                        retryBtn.style.opacity = '0.5';
                        retryBtn.style.pointerEvents = 'none';

                        const errBtns = Array.from(btnSet).filter(b => b.dataset.state === 'error');
                        window.ptrQueueManager.process(errBtns, 5);
                    }
                }
            });

            document.body.appendChild(container);
        }

        function render() {
            if (!container) init();
            if (trackedPosts.size === 0) { container.style.display = 'none'; return; }
            container.style.display = 'flex';
            container.style.padding = isMinimized ? '12px 16px' : '16px';

            let html = `<div style="font-weight:600; font-size:15px; padding-bottom:${isMinimized ? '0' : '12px'}; margin-bottom:${isMinimized ? '0' : '4px'}; border-bottom:${isMinimized ? 'none' : '1px solid rgba(255,255,255,0.08)'}; display:flex; justify-content:space-between; align-items:center;">
                <div style="display:flex; align-items:center;">
                    <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px; opacity:0.85;">
                        <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
                        <polyline points="17 21 17 13 7 13 7 21"></polyline>
                        <polyline points="7 3 7 8 15 8"></polyline>
                    </svg>
                    <span style="letter-spacing:0.5px;">${t('dashTitle')}</span>
                </div>
                <div style="display:flex; gap:16px; align-items:center;">
                    <span class="ptr-dash-icon ptr-dash-btn-settings" style="cursor:pointer; color:rgba(255,255,255,0.5); transition:all 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,0.5)'" onmousedown="this.style.transform='scale(0.85)'" onmouseup="this.style.transform='scale(1)'" title="${t('dashSettingsTip') || '设置'}">
                        <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
                            <circle cx="12" cy="12" r="3"></circle>
                            <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
                        </svg>
                    </span>
                    <span class="ptr-dash-icon ptr-dash-btn-clear" style="cursor:pointer; color:rgba(255,255,255,0.5); transition:all 0.2s;" onmouseover="this.style.color='#ff6b6b'" onmouseout="this.style.color='rgba(255,255,255,0.5)'" onmousedown="this.style.transform='scale(0.85)'" onmouseup="this.style.transform='scale(1)'" title="${t('dashClearTip')}">
                        <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
                            <polyline points="3 6 5 6 21 6"></polyline>
                            <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2-2v2"></path>
                            <line x1="10" y1="11" x2="10" y2="17"></line>
                            <line x1="14" y1="11" x2="14" y2="17"></line>
                        </svg>
                    </span>
                    <span class="ptr-dash-icon ptr-dash-btn-min" style="cursor:pointer; color:rgba(255,255,255,0.5); transition:all 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,0.5)'" onmousedown="this.style.transform='scale(0.85)'" onmouseup="this.style.transform='scale(1)'" title="${isMinimized ? t('dashExpandTip') : t('dashCollapseTip')}">
                        <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
                            <line x1="5" y1="12" x2="19" y2="12"></line>
                            ${isMinimized ? '<line x1="12" y1="5" x2="12" y2="19"></line>' : ''}
                        </svg>
                    </span>
                </div>
            </div>`;

            if (!isMinimized) {
                html += `<div style="display:flex; flex-direction:column; gap:8px; margin-top:8px; max-height:300px; overflow-y:auto; padding-right:4px;">`;

                trackedPosts.forEach((btnSet, postId) => {
                    const total = btnSet.size;
                    let success = 0; let fail = 0;

                    btnSet.forEach(btn => {
                        if(btn.dataset.state === 'success') success++;
                        if(btn.dataset.state === 'error') fail++;
                    });

                    const done = success + fail;
                    const isDone = (done === total);

                    let statusStr = `<span style="color:rgba(255,255,255,0.6); font-weight:500;">${success}/${total}</span>`;
                    let actionBtn = '';

                    if (isDone) {
                        if (fail > 0) {
                            statusStr = `<span onmouseenter="ptrShowTip(this, '${t('dashFailTip', {fail: fail})}')" onmouseleave="ptrHideTip()" style="color:#ff6b6b; cursor:help; font-weight:600;">❌ ${success}/${total}</span>`;
                            actionBtn = `<button class="ptr-dash-btn-retry" data-pid="${postId}" style="margin-left:12px; padding:4px 10px; font-size:12px; border-radius:6px; border:1px solid rgba(255,107,107,0.3); background:rgba(255,107,107,0.15); color:#ff6b6b; cursor:pointer; font-weight:600; flex-shrink:0; transition:all 0.2s;" onmouseover="this.style.background='rgba(255,107,107,0.25)'" onmouseout="this.style.background='rgba(255,107,107,0.15)'">${t('dashRetry')}</button>`;
                        } else {
                            statusStr = `<span style="color:#4cd964; font-weight:600;">✅ ${success}/${total}</span>`;
                        }
                    }

                    let linkStr = postId !== 'External'
                        ? `<a href="https://www.patreon.com/posts/${postId}" target="_blank" style="color:#fff; text-decoration:none; border-bottom:1px solid rgba(255,255,255,0.3); padding-bottom:1px; transition:border-color 0.2s;" onmouseover="this.style.borderColor='#fff'" onmouseout="this.style.borderColor='rgba(255,255,255,0.3)'">${postId}</a>`
                        : `External`;

                    html += `<div style="display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.06); padding:10px 12px; border-radius:10px; transition:background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.09)'" onmouseout="this.style.background='rgba(255,255,255,0.06)'">
                        <div style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-right:8px; opacity:${isDone?0.6:1}; font-weight:500;">Post: ${linkStr}</div>
                        <div style="display:flex; align-items:center; font-variant-numeric:tabular-nums; flex-shrink:0;">${statusStr}${actionBtn}</div>
                    </div>`;
                });
                html += `</div>`;
            }
            container.innerHTML = html;
        }

        return {
            init,
            addTaskButtons: function(buttonsArray) {
                buttonsArray.forEach(btn => {
                    const pid = btn.dataset.postid || 'Unknown';
                    if (!trackedPosts.has(pid)) {
                        const newSet = new Set();
                        newSet._hasNotified = false;
                        trackedPosts.set(pid, newSet);
                    }
                    trackedPosts.get(pid).add(btn);
                });
                render();
            },
            checkCompletion: function(pid) {
                const btnSet = trackedPosts.get(pid);
                if (!btnSet) return;

                let done = 0; let fail = 0;
                btnSet.forEach(b => {
                    if (b.dataset.state === 'success') done++;
                    if (b.dataset.state === 'error') { done++; fail++; }
                });

                if (done === btnSet.size) {
                    if (!btnSet._hasNotified) {
                        btnSet._hasNotified = true;
                        if (fail === 0) window.ToastManager.show(t('toastSuccess', {pid: pid}));
                        else window.ToastManager.show(t('toastPartial', {pid: pid, fail: fail}), true);
                    }
                }
                render();
            }
        };
    })();

    function extractPostId(img) {
        const src = img.src;
        const match = src.match(/\/post\/(\d+)\//);
        if (match) return match[1];

        const postWrapper = img.closest('[data-tag="post-card"]') || img.closest('.ContentCard-module__4Ygkza__postWrapper');
        if (postWrapper) {
            const postLink = postWrapper.querySelector('a[data-tag="post-published-at"]');
            if (postLink && postLink.href) {
                const m = postLink.href.match(/-(\d+)(?:\?|#|$)/) || postLink.href.match(/\/(\d+)(?:\?|#|$)/);
                if (m) return m[1];
            }
        }

        const canonical = document.querySelector('link[rel="canonical"]')?.href || window.location.href;
        const m = canonical.match(/-(\d+)(?:\?|#|$)/) || canonical.match(/\/(\d+)(?:\?|#|$)/);
        if (m) return m[1];

        return "External";
    }

    const FloatingConfigManager = (function() {
        let isDragging = false;
        let hasDragged = false;
        let isProcessingBulk = false;
        let currentBtnState = 0;

        return {
            init: function() {
                if (document.getElementById('ptr-fsa-config')) return;

                const fsaBtn = document.createElement('div');
                fsaBtn.id = 'ptr-fsa-config';
                fsaBtn.innerHTML = `💾`;
                fsaBtn.title = t('btnTitleBulk');

                fsaBtn.style.cssText = "position:fixed; z-index:2147483647; font-size:24px; cursor:grab; background:rgba(255,255,255,0.9); border-radius:50%; padding:10px; box-shadow:0 2px 10px rgba(0,0,0,0.2); transition:transform 0.2s; user-select:none; touch-action:none;";

                const savedPos = localStorage.getItem('Patreon_FSA_Btn_Pos');
                if (savedPos) {
                    try {
                        const pos = JSON.parse(savedPos);
                        fsaBtn.style.left = pos.left;
                        fsaBtn.style.top = pos.top;
                    } catch(e) {
                        fsaBtn.style.bottom = '20px';
                        fsaBtn.style.left = '20px';
                    }
                } else {
                    fsaBtn.style.bottom = '20px';
                    fsaBtn.style.left = '20px';
                }

                fsaBtn.addEventListener('mouseover', () => { if (!isDragging) fsaBtn.style.transform = "scale(1.1)"; });
                fsaBtn.addEventListener('mouseout', () => { if (!isDragging) fsaBtn.style.transform = "scale(1)"; });

                let startX, startY, initialX, initialY;
                fsaBtn.addEventListener('mousedown', (e) => {
                    if (e.button !== 0) return;
                    isDragging = false;
                    hasDragged = false;
                    startX = e.clientX;
                    startY = e.clientY;

                    const rect = fsaBtn.getBoundingClientRect();
                    initialX = rect.left;
                    initialY = rect.top;

                    const onMouseMove = (moveEvent) => {
                        const dx = moveEvent.clientX - startX;
                        const dy = moveEvent.clientY - startY;

                        if (!isDragging && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
                            isDragging = true;
                            hasDragged = true;
                            fsaBtn.style.left = initialX + 'px';
                            fsaBtn.style.top = initialY + 'px';
                            fsaBtn.style.bottom = 'auto';
                            fsaBtn.style.cursor = 'grabbing';
                            fsaBtn.style.transition = 'none';
                        }

                        if (isDragging) {
                            let targetX = initialX + dx;
                            let targetY = initialY + dy;
                            const btnWidth = fsaBtn.offsetWidth;
                            const btnHeight = fsaBtn.offsetHeight;
                            const maxX = window.innerWidth - btnWidth;
                            const maxY = window.innerHeight - btnHeight;

                            targetX = Math.max(0, Math.min(targetX, maxX));
                            targetY = Math.max(0, Math.min(targetY, maxY));

                            fsaBtn.style.left = `${targetX}px`;
                            fsaBtn.style.top = `${targetY}px`;
                        }
                    };

                    const onMouseUp = () => {
                        document.removeEventListener('mousemove', onMouseMove);
                        document.removeEventListener('mouseup', onMouseUp);

                        if (hasDragged) {
                            fsaBtn.style.cursor = 'grab';
                            fsaBtn.style.transition = 'transform 0.2s';
                            localStorage.setItem('Patreon_FSA_Btn_Pos', JSON.stringify({ left: fsaBtn.style.left, top: fsaBtn.style.top }));
                            isDragging = false;
                            setTimeout(() => { hasDragged = false; }, 100);
                        }
                    };

                    document.addEventListener('mousemove', onMouseMove);
                    document.addEventListener('mouseup', onMouseUp);
                });

                fsaBtn.addEventListener('click', async (e) => {
                    if (hasDragged) { e.preventDefault(); return; }
                    if (isProcessingBulk) return;

                    if (currentBtnState === 0) {
                        if (!(await ensureRootHandle())) return;

                        const rawSingles = Array.from(document.querySelectorAll('.ptr-download-btn:not([data-state="success"]):not([data-state="loading"])'));

                        const uniqueMap = new Map();
                        rawSingles.forEach(btn => {
                            const fp = btn.dataset.fingerprint;
                            if (fp && !uniqueMap.has(fp)) uniqueMap.set(fp, btn);
                            else updateUI(btn, 'success', true);
                        });

                        const singles = Array.from(uniqueMap.values()).reverse();

                        if (singles.length === 0) {
                            alert(t('noImages'));
                            return;
                        }

                        if (confirm(t('confirmBulk', {count: singles.length}))) {
                            isProcessingBulk = true;
                            window.DownloadDashboard.addTaskButtons(singles);
                            await window.ptrQueueManager.process(singles);
                            isProcessingBulk = false;
                        }
                    } else if (currentBtnState === 1) {
                        try {
                            currentRootHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
                            await saveHandle(currentRootHandle);
                            alert(t('dirUpdated'));
                        } catch (err) { console.warn("已取消更改目录"); }
                    }
                });

                fsaBtn.addEventListener('contextmenu', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    if (isDragging) return;

                    currentBtnState = currentBtnState === 0 ? 1 : 0;
                    if (currentBtnState === 0) {
                        fsaBtn.innerHTML = `💾`;
                        fsaBtn.title = t('btnTitleBulkAlt');
                    } else {
                        fsaBtn.innerHTML = `📁`;
                        fsaBtn.title = t('btnTitleDir');
                    }
                });

                document.body.appendChild(fsaBtn);

                if (fsaBtn.style.bottom !== '20px') {
                    requestAnimationFrame(() => {
                        const rect = fsaBtn.getBoundingClientRect();
                        const maxX = window.innerWidth - rect.width;
                        const maxY = window.innerHeight - rect.height;
                        let safeX = Math.max(0, Math.min(rect.left, maxX));
                        let safeY = Math.max(0, Math.min(rect.top, maxY));
                        if (safeX !== rect.left || safeY !== rect.top) {
                            fsaBtn.style.left = safeX + 'px';
                            fsaBtn.style.top = safeY + 'px';
                            localStorage.setItem('Patreon_FSA_Btn_Pos', JSON.stringify({ left: safeX + 'px', top: safeY + 'px' }));
                        }
                    });
                }
            }
        };
    })();

    const injectConfigBtn = () => FloatingConfigManager.init();

    const startDownload = async (img, btn) => {
        try {
            const src = img.src;
            let isPreview = false;
            let isPatreonNative = src.includes('patreonusercontent.com');

            const inContainer = img.closest('div[data-image-container="true"]');
            if (inContainer) {
                if (src.includes('c10.patreonusercontent.com')) { isPreview = true; } else { isPatreonNative = false; }
            }

            const match = src.match(/\/post\/(\d+)\/([a-f0-9]{32})/);
            let postId = null; let uuid = null;
            const postWrapper = img.closest('[data-tag="post-card"]') || img.closest('.ContentCard-module__4Ygkza__postWrapper') || document.body;

            if (match) { postId = match[1]; uuid = match[2]; } else {
                const postLink = postWrapper?.querySelector('a[data-tag="post-published-at"]') || document.querySelector('a[data-tag="post-published-at"]');
                if (postLink && postLink.href) { const m = postLink.href.match(/-(\d+)(?:\?|#|$)/) || postLink.href.match(/\/(\d+)(?:\?|#|$)/); if (m) postId = m[1]; }
                if (!postId) { const canonical = document.querySelector('link[rel="canonical"]')?.href || window.location.href; const m = canonical.match(/-(\d+)(?:\?|#|$)/) || canonical.match(/\/(\d+)(?:\?|#|$)/); if (m) postId = m[1]; }
            }

            if (!postId) {
                if (!(await ensureRootHandle())) throw new Error("Directory access denied");

                let ext = src.split('?')[0].split('.').pop().toLowerCase();
                if (ext === 'jpeg') ext = 'jpg';
                if (!ext || ext.length > 4) ext = "jpg";

                const finalName = `[External]_${Date.now()}.${ext}`;
                const patreonDir = await currentRootHandle.getDirectoryHandle('patreon', { create: true });
                const creatorDir = await patreonDir.getDirectoryHandle('External', { create: true });
                const fileHandle = await creatorDir.getFileHandle(finalName, { create: true });

                const { blob } = await fetchBlobAndType(src);
                const writable = await fileHandle.createWritable();
                await writable.write(blob); await writable.close();
                updateUI(btn, 'success');
                return;
            }

            let finalUrl = src; let originalFileName = ""; let creatorName = "Unknown"; let dateStr = "00000000"; let canView = true;

            if (apiCache.has(postId)) {
                const json = apiCache.get(postId);
                canView = json.data?.attributes?.current_user_can_view;
                const pubDate = json.data?.attributes?.published_at; if (pubDate) dateStr = new Date(pubDate).toISOString().split('T')[0].replace(/-/g, '');
                const creator = json.included?.find(i => i.type === 'user')?.attributes?.full_name; if (creator) creatorName = creator.trim().replace(/[\\/:*?"<>|]/g, "_");
                if (canView !== false && isPatreonNative && uuid) { const mediaData = json.included?.find(i => i.type === 'media' && (i.attributes?.image_urls?.original?.includes(uuid) || i.id === uuid)); if (mediaData?.attributes?.image_urls?.original) { finalUrl = mediaData.attributes.image_urls.original; originalFileName = mediaData.attributes.file_name; } }
            } else {
                try {
                    const resp = await fetch(`https://www.patreon.com/api/posts/${postId}?include=media,user`, { credentials: 'include' });
                    if (resp.ok) {
                        const json = await resp.json(); apiCache.set(postId, json);
                        canView = json.data?.attributes?.current_user_can_view;
                        const pubDate = json.data?.attributes?.published_at; if (pubDate) dateStr = new Date(pubDate).toISOString().split('T')[0].replace(/-/g, '');
                        const creator = json.included?.find(i => i.type === 'user')?.attributes?.full_name; if (creator) creatorName = creator.trim().replace(/[\\/:*?"<>|]/g, "_");
                        if (canView !== false && isPatreonNative && uuid) { const mediaData = json.included?.find(i => i.type === 'media' && (i.attributes?.image_urls?.original?.includes(uuid) || i.id === uuid)); if (mediaData?.attributes?.image_urls?.original) { finalUrl = mediaData.attributes.image_urls.original; originalFileName = mediaData.attributes.file_name; } }
                    }
                } catch (e) { console.warn("API 限制或访问失败"); }
            }

            if (creatorName === "Unknown" || dateStr === "00000000") {
                console.warn(`[拦截] 帖子 ${postId} 数据获取失败 (可能是API限流)`);
                throw new Error("Missing Meta Data");
            }

            if (!(await ensureRootHandle())) throw new Error("Directory permission denied or not selected");

            // ==========================================
            // 架构优化:提前解析后缀,以便在下载前先验证文件句柄
            // ==========================================
            let ext = finalUrl.split('?')[0].split('.').pop().toLowerCase();
            if (ext === 'jpeg' && window.PtrSettings.getConfig().convertJpeg) {
                ext = 'jpg';
            }
            if (ext.length > 4 || !['jpg', 'png', 'gif', 'webp', 'jpeg'].includes(ext)) ext = 'jpg';

            let numVar = "";
            if (inContainer) {
                const galleryImgs = Array.from(postWrapper.querySelectorAll('div[data-image-container="true"] img')).filter(i => {
                    const b64 = i.src.split('/').find(s => s.startsWith('ey')) || "";
                    return !b64.includes('3IjozMjB9') && !b64.includes('eyJiI');
                });
                const currentIdx = galleryImgs.indexOf(img);
                if (currentIdx !== -1) { numVar = String(currentIdx + 1); }
            } else {
                const coverOrGridImgs = Array.from(postWrapper.querySelectorAll('img[src*="patreon-media/p/post/"]')).filter(i => {
                    const b64 = i.src.split('/').find(s => s.startsWith('ey')) || "";
                    if (b64.includes('3IjozMjB9') || b64.includes('eyJiI')) return false;
                    return !i.closest('div[data-image-container="true"]');
                });
                const currentIdx = coverOrGridImgs.indexOf(img);
                if (currentIdx !== -1) { numVar = String(currentIdx); }
            }

            let origVar = "";
            if (canView === false) { origVar = `Preview`; }
            else if (originalFileName) { origVar = originalFileName.replace(/\.[^/.]+$/, ""); }
            else if (!isPatreonNative) { origVar = `Web`; }
            else if (isPreview) { origVar = `Preview`; }

            const formatTemplate = window.PtrSettings.getConfig().nameFormat;
            let finalName = formatTemplate
                .replace(/{creator}/g, creatorName)
                .replace(/{date}/g, dateStr)
                .replace(/{postid}/g, postId)
                .replace(/{num}/g, numVar)
                .replace(/{name}/g, origVar);

            finalName = finalName.replace(/_+/g, '_').replace(/_$/, '');
            finalName += `.${ext}`;
            finalName = finalName.replace(/[\\/:*?"<>|]/g, "_");

            const patreonDir = await currentRootHandle.getDirectoryHandle('patreon', { create: true });
            const creatorDir = await patreonDir.getDirectoryHandle(creatorName, { create: true });

            let fileHandle;
            let fileExists = false;
            try {
                await creatorDir.getFileHandle(finalName);
                fileExists = true;
            } catch (e) {
                if (e.name === 'NotFoundError') {
                    fileExists = false;
                } else throw e;
            }

            if (fileExists) {
                try {
                    // 调用浏览器的“另存为”
                    const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
                    fileHandle = await win.showSaveFilePicker.call(win, {
                        suggestedName: finalName
                    });
                } catch (saveErr) {
                    // 如果用户在弹窗中点击了“取消”
                    if (saveErr.name === 'AbortError') {
                        console.log('检测到重复,且用户取消了手动保存');
                        updateUI(btn, 'idle');
                        return;
                    }
                    throw saveErr;
                }
            } else {
                fileHandle = await creatorDir.getFileHandle(finalName, { create: true });
            }

            // ==========================================
            // 获取完可用句柄后,再去执行耗时的 Blob 下载
            // 避免下载过长导致 User Activation 超时拦截
            // ==========================================
            const { blob } = await fetchBlobAndType(finalUrl);

            const writable = await fileHandle.createWritable();
            await writable.write(blob);
            await writable.close();
            updateUI(btn, 'success');

        } catch (e) {
            console.error("FSA Download Error:", e);
            updateUI(btn, 'error');
        }
    };

    const updateUI = (btn, state, silent = false) => {
        const svg = { idle: `<path d="M12 3v13m0 0l4-4m-4 4l-4-4" stroke-width="2.5"/><path d="M5 20h14" stroke-width="2.5"/>`, loading: `<circle cx="12" cy="12" r="10" stroke-width="3" opacity=".3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-width="3" class="ptr-spin"/>`, success: `<path d="M5 12l5 5L20 7" stroke-width="3.5"/>`, error: `<path d="M18 6L6 18M6 6l12 12" stroke-width="3.5"/>` };
        const ok = state === 'success';
        const err = state === 'error';
        btn.innerHTML = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:none;stroke:${ok?'#FFF':(err?'#FFF':'#FF424D')};stroke-linecap:round;stroke-linejoin:round;">${svg[state]}</svg>`;
        btn.style.backgroundColor = ok ? '#FF424D' : (err ? '#FF8C00' : 'rgba(255,255,255,0.95)');
        btn.dataset.state = state;

        if(!silent && (state === 'success' || state === 'error')) {
            const postId = btn.dataset.postid || "Unknown";
            if(window.DownloadDashboard) window.DownloadDashboard.checkCompletion(postId);
        }
    };

    const updateBulkUI = (btn, state) => {
        const svgIcon = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;margin-right:6px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`;
        const svgSpin = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;margin-right:6px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><circle cx="12" cy="12" r="10" stroke-width="3" opacity=".3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-width="3" class="ptr-spin"/></svg>`;
        const svgCheck = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;margin-right:6px;fill:none;stroke:#FF424D;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;"><path d="M5 12l5 5L20 7"/></svg>`;

        btn.dataset.bulkState = state;
        if (state === 'idle') {
            btn.innerHTML = `${svgIcon} <span style="font-size:14px; font-weight:600;">${t('bulkBtnIdle')}</span>`;
        } else if (state === 'loading') {
            btn.innerHTML = `${svgSpin} <span style="font-size:14px; font-weight:600;">${t('bulkBtnLoading')}</span>`;
        } else if (state === 'success') {
            btn.innerHTML = `${svgCheck} <span style="font-size:14px; font-weight:600; color:#FF424D;">${t('bulkBtnSuccess')}</span>`;
        }
    };

    const scan = () => {
        injectConfigBtn();
        const isHomePage = window.location.pathname === '/' || window.location.pathname.startsWith('/home');

        const processImg = (img) => {
            const src = img.src;
            const b64Segment = src.split('/').find(s => s.startsWith('ey')) || "";
            if (b64Segment.includes('3IjozMjB9') || b64Segment.includes('eyJiI')) { img.dataset.p = "skip"; return; }

            if (!img.parentElement || img.parentElement.querySelector('.ptr-download-btn')) { return; }
            if (window.getComputedStyle(img.parentElement).position === 'static') { img.parentElement.style.position = 'relative'; }

            const btn = document.createElement('div');
            btn.className = 'ptr-download-btn';

            btn.dataset.fingerprint = src.split('?')[0];
            btn.dataset.postid = extractPostId(img);

            updateUI(btn, 'idle');

            btn.addEventListener('click', (e) => {
                e.stopPropagation(); e.preventDefault();
                if (btn.dataset.state === 'loading') return;
                updateUI(btn, 'loading');
                startDownload(img, btn);
            }, true);
            img.parentElement.appendChild(btn);

            const wrapper = img.closest('.ContentCard-module__4Ygkza__postWrapper') || img.closest('[data-tag="post-card"]');
            if (wrapper) {
                const shareIcon = wrapper.querySelector('svg[data-tag="IconShare"]');
                if (shareIcon) {
                    const actionBtn = shareIcon.closest('button') || shareIcon.closest('div[role="button"]') || shareIcon.parentElement.parentElement;
                    if (actionBtn && actionBtn.parentElement && !actionBtn.parentElement.querySelector('.ptr-bulk-btn')) {
                        const actionBar = actionBtn.parentElement;

                        const bulkBtn = document.createElement('div');
                        bulkBtn.className = 'ptr-bulk-btn';
                        bulkBtn.style.cssText = "display:flex; align-items:center; justify-content:center; padding:6px 12px; border-radius:8px; background:transparent; color:rgb(36, 30, 18); cursor:pointer; transition:background 0.2s; margin-right:auto;";
                        bulkBtn.onmouseover = () => bulkBtn.style.background = "rgba(0,0,0,0.05)";
                        bulkBtn.onmouseout = () => bulkBtn.style.background = "transparent";

                        updateBulkUI(bulkBtn, 'idle');

                        bulkBtn.onclick = async (e) => {
                            e.stopPropagation(); e.preventDefault();
                            if (bulkBtn.dataset.bulkState === 'loading' || bulkBtn.dataset.bulkState === 'success') return;
                            if (!(await ensureRootHandle())) return;

                            const rawSingles = Array.from(wrapper.querySelectorAll('.ptr-download-btn:not([data-state="success"]):not([data-state="loading"])'));

                            const uniqueMap = new Map();
                            rawSingles.forEach(btn => {
                                const fp = btn.dataset.fingerprint;
                                if (fp && !uniqueMap.has(fp)) uniqueMap.set(fp, btn);
                                else updateUI(btn, 'success', true);
                            });

                            const singles = Array.from(uniqueMap.values()).reverse();

                            if (singles.length === 0) {
                                updateBulkUI(bulkBtn, 'success');
                                return;
                            }

                            updateBulkUI(bulkBtn, 'loading');
                            window.DownloadDashboard.addTaskButtons(singles);

                            await window.ptrQueueManager.process(singles);

                            const hasError = singles.some(b => b.dataset.state === 'error');
                            updateBulkUI(bulkBtn, hasError ? 'idle' : 'success');
                        };
                        actionBar.prepend(bulkBtn);
                    }
                }
            }
        };

        if (isHomePage) {
            document.querySelectorAll('img[src*="patreon-media/p/post/"]:not([data-p="skip"]), div[data-image-container="true"] img:not([data-p="skip"])').forEach(processImg);
        } else {
            const wrappers = document.querySelectorAll('.ContentCard-module__4Ygkza__postWrapper, [data-tag="post-card"]');
            wrappers.forEach(wrapper => {
                wrapper.querySelectorAll('img[src*="patreon-media/p/post/"]:not([data-p="skip"]), div[data-image-container="true"] img:not([data-p="skip"])').forEach(processImg);
            });
        }
    };

    const s = document.createElement('style');
    s.innerHTML = `
        .ptr-download-btn { position: absolute; right: 10px; bottom: 10px; z-index: 999; width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; background: rgba(255,255,255,0.95); box-shadow: 0 2px 8px rgba(0,0,0,0.2); transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
        .ptr-download-btn:not([data-state="success"]):hover { transform: scale(1.15); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
        .ptr-spin { animation: ptr-spin 1s linear infinite; transform-origin: center; }
        @keyframes ptr-spin { to { transform: rotate(360deg); } }
        .ptr-dash-icon { cursor: pointer; opacity: 0.7; transition: opacity 0.2s; user-select: none; }
        .ptr-dash-icon:hover { opacity: 1; }
    `;
    document.head.appendChild(s);
    new MutationObserver(scan).observe(document.body, { childList: true, subtree: true }); scan();
})();