Patreon 网站单个、当前页面所有图片下载
// ==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();
})();