// ==UserScript== // @name 论坛 GIF 批量下载器 // @namespace https://github.com/zwy/userscripts // @version 1.5 // @description 在论坛列表页批量进入详情页,提取并下载正文中的 GIF 图片,支持去重、黑名单/白名单、导入/导出记录 // @author zwy // @match *://*.e6042m9.cc/* // @match *://e6042m9.cc/* // @include /^https?:\/\/([\w-]+\.)?e6042m9\.cc(:\d+)?\// // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect e6042m9.cc // @connect *.e6042m9.cc // @connect * // @run-at document-end // @updateURL https://raw.githubusercontent.com/zwy/userscripts/main/pw-forum-gif-downloader/pw-forum-gif-downloader.user.js // @downloadURL https://raw.githubusercontent.com/zwy/userscripts/main/pw-forum-gif-downloader/pw-forum-gif-downloader.user.js // ==/UserScript== (function () { 'use strict'; // ─── 配置 ────────────────────────────────────────────────────────── const CONFIG = { listItemSelectors: [ 'a[href*="html_data"]', 'a[href*="read-htm-tid"]', 'a[href*="read.php"]', '.threadlist a[href]', 'td.folder a[href]', 'h3 a[href], h4 a[href]', '.subject a[href]', ], contentSelector: '.t_msgfont, .read-message, .postmessage, .post_message, .message, .threadtext, [id^="postmessage_"], td.t_f, .post-content, .content', pageDelay: 1500, retryMax: 3, retryDelay: 3000, downloadDelay: 500, skipUrlKeywords: ['smilies', 'emoji', 'face', 'avatar', 'emo', 'smiley', '/s/', 'attachicons'], }; // ─── 工具 ───────────────────────────────────────────────────────── function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // ─── URL 解析工具 ─────────────────────────────────────────────────── function resolveUrl(href, base) { try { const resolved = new URL(href, base); if (location.protocol === 'https:' && resolved.protocol === 'http:') { resolved.protocol = 'https:'; } return resolved.href; } catch (e) { return null; } } // ─── 持久化存储 ────────────────────────────────────────────────── function getDownloadedSet() { try { return new Set(JSON.parse(GM_getValue('gif_downloaded', '[]'))); } catch (e) { return new Set(); } } function saveDownloadedSet(set) { GM_setValue('gif_downloaded', JSON.stringify([...set])); } function addToDownloaded(f) { const s = getDownloadedSet(); s.add(f); saveDownloadedSet(s); } function getBlacklist() { try { return JSON.parse(GM_getValue('gif_blacklist', '[]')); } catch (e) { return []; } } function getWhitelist() { try { return JSON.parse(GM_getValue('gif_whitelist', '[]')); } catch (e) { return []; } } function saveBlacklist(arr) { GM_setValue('gif_blacklist', JSON.stringify(arr)); } function saveWhitelist(arr) { GM_setValue('gif_whitelist', JSON.stringify(arr)); } // ─── 去重判断 ───────────────────────────────────────────────────── function shouldSkip(filename) { const lower = filename.toLowerCase(); if (getBlacklist().some(kw => kw && lower.includes(kw.toLowerCase()))) return { skip: true, reason: '黑名单' }; if (getWhitelist().some(kw => kw && lower.includes(kw.toLowerCase()))) return { skip: false, reason: '白名单(强制)' }; if (getDownloadedSet().has(filename)) return { skip: true, reason: '已下载' }; return { skip: false, reason: '' }; } function gifFilenameFromUrl(url) { try { const p = new URL(url).pathname.split('/'); return decodeURIComponent(p[p.length - 1] || 'unnamed.gif'); } catch (e) { return url.split('/').pop().split('?')[0] || 'unnamed.gif'; } } function isDecorativeGif(url) { return CONFIG.skipUrlKeywords.some(kw => url.toLowerCase().includes(kw)); } // ─── 抓取页面 HTML ──────────────────────────────────────────────── async function fetchPage(url, retry = 0) { try { const safeUrl = location.protocol === 'https:' ? url.replace(/^http:/, 'https:') : url; const resp = await fetch(safeUrl, { method: 'GET', credentials: 'include', headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', }, redirect: 'follow', }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const text = await resp.text(); if (text.includes('Error 530') || text.includes('域名未配置') || text.includes('CDN节点')) { throw new Error('CDN拦截页 (530),页面可能需登录或处于不同域名'); } return text; } catch (err) { if (retry < CONFIG.retryMax) { await sleep(CONFIG.retryDelay); return fetchPage(url, retry + 1); } throw err; } } // ─── 从详情页 HTML 提取正文 GIF ────────────────────────────────── function extractGifsFromHtml(html, pageUrl) { const doc = new DOMParser().parseFromString(html, 'text/html'); let contentEl = null, hitSelector = ''; for (const sel of CONFIG.contentSelector.split(',').map(s => s.trim())) { try { const el = doc.querySelector(sel); if (el) { contentEl = el; hitSelector = sel; break; } } catch (e) {} } const root = contentEl || doc.body; const gifs = []; for (const img of root.querySelectorAll('img')) { const src = img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-original') || img.getAttribute('file') || ''; if (!src || !src.toLowerCase().includes('.gif') || isDecorativeGif(src)) continue; const absUrl = resolveUrl(src, pageUrl); if (absUrl) gifs.push(absUrl); } return { gifs, isFullBody: !contentEl, hitSelector }; } // ─── 下载单个 GIF ───────────────────────────────────────────────── function isSameOrigin(url) { try { return new URL(url).origin === location.origin; } catch (e) { return false; } } async function downloadGif(url, filename) { try { let blob; if (isSameOrigin(url)) { const resp = await fetch(url, { credentials: 'include' }); if (!resp.ok) return { ok: false, reason: `HTTP ${resp.status}` }; blob = await resp.blob(); } else { blob = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', headers: { 'Referer': location.href }, onload(r) { r.status === 200 ? resolve(r.response) : reject(new Error(`HTTP ${r.status}`)); }, onerror() { reject(new Error('网络错误')); } }); }); } const a = document.createElement('a'); const objUrl = URL.createObjectURL(blob); a.href = objUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(objUrl), 3000); return { ok: true }; } catch (err) { return { ok: false, reason: err.message }; } } // ─── 从列表页提取帖子链接 ───────────────────────────────────────── function extractDetailLinks() { const seen = new Set(), links = []; let hitSel = ''; for (const sel of CONFIG.listItemSelectors) { try { const nodes = document.querySelectorAll(sel); if (!nodes.length) continue; nodes.forEach(a => { const href = a.getAttribute('href'); if (!href || href === '#' || /login|register|logout|page=|&page|search/i.test(href)) return; const url = resolveUrl(href, location.href); if (!url || seen.has(url)) return; seen.add(url); links.push({ url, title: a.textContent.trim().replace(/\s+/g, ' ').substring(0, 60) || '未知标题' }); }); if (links.length) { hitSel = sel; break; } } catch (e) {} } console.log(`[GIF下载器] 选择器命中: "${hitSel}",找到 ${links.length} 个链接`); return links; } // ─── 页面类型判断 ───────────────────────────────────────────────── const path = location.pathname + location.search; const isListPage = /thread-htm/.test(path) || /[?&]fid=/.test(path); // ─── UI ────────────────────────────────────────────────────────── function createUI() { const fab = document.createElement('button'); fab.id = 'gifFab'; fab.textContent = '🎞 GIF下载'; Object.assign(fab.style, { position: 'fixed', bottom: '24px', left: '24px', zIndex: '99999', padding: '10px 16px', background: '#0e7490', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', fontWeight: 'bold', boxShadow: '0 4px 12px rgba(0,0,0,0.35)', transition: 'background 0.2s' }); fab.onmouseenter = () => fab.style.background = '#155e75'; fab.onmouseleave = () => fab.style.background = '#0e7490'; const panel = document.createElement('div'); panel.id = 'gifPanel'; Object.assign(panel.style, { display: 'none', position: 'fixed', bottom: '80px', left: '24px', zIndex: '99998', width: '400px', background: '#fff', color: '#333', borderRadius: '12px', boxShadow: '0 8px 32px rgba(0,0,0,0.25)', padding: '20px', fontFamily: 'system-ui,sans-serif', fontSize: '14px', maxHeight: '88vh', overflowY: 'auto' }); panel.innerHTML = `