// ==UserScript== // @name AssesPhoto 键盘切换图片 + 下载原图 // @namespace https://github.com/userscript/assesphoto-keyboard-nav // @version 1.5.0 // @description 在演示模式(Lightbox)中用键盘左右箭头切换上一张/下一张图片,Esc 关闭,D 键/按钮下载原图(Referer+魔数双校验) // @author 朱焱伟 // @match https://www.assesphoto.com/*.shtml // @match https://assesphoto.com/*.shtml // @connect img.assesphoto.com // @grant GM.xmlHttpRequest // @run-at document-end // ==/UserScript== (function () { 'use strict'; const IMG_HOST = 'img.assesphoto.com'; // 判断演示模式(Lightbox)当前是否打开 function isModalOpen() { const modal = document.getElementById('myModal'); return !!modal && modal.style.display !== 'none' && getComputedStyle(modal).display !== 'none'; } // 调用页面自带的切换函数;若页面结构变化导致函数不存在,则回退到点击箭头按钮 function goPrev() { if (typeof window.prevImage === 'function') { window.prevImage(); return; } document.querySelector('#myModal .nav-button.left')?.click(); } function goNext() { if (typeof window.nextImage === 'function') { window.nextImage(); return; } document.querySelector('#myModal .nav-button.right')?.click(); } function closeModal() { if (typeof window.closeModal === 'function') { window.closeModal(); return; } document.querySelector('#myModal .close')?.click(); } // ---------- 下载原图 ---------- // 下载进行中标志:防止连按 D / 按住 D 键自动重复导致同名文件并发下载产生损坏文件 let downloading = false; // 图片 URL → 文件名,带上图集名避免不同图集重名冲突 function buildFilename(url) { const base = url.split('/').pop() || 'image.jpg'; const album = (document.querySelector('h1')?.textContent || '').trim() .replace(/[\\/:*?"<>|]/g, '') // 去掉文件名非法字符 .replace(/\s+/g, ' ') // 压缩连续空格 .replace(/[.\s]+$/, '') // 去掉结尾的点/空格(Windows 不允许) .slice(0, 50); return album ? `${album} - ${base}` : base; } // 简单的按钮状态反馈 function setBtnState(text, disabled) { const btn = document.querySelector('.kb-dl-btn'); if (!btn) return; btn.textContent = text; btn.disabled = disabled; btn.style.opacity = disabled ? '.5' : '1'; btn.style.pointerEvents = disabled ? 'none' : 'auto'; } // 校验 blob 开头魔数:JPEG = FF D8 FF,PNG = 89 50 4E 47,GIF = 47 49 46, // WebP = "RIFF....WEBP"。Cloudflare 挑战页/错误页以 '<' (0x3C) 开头,会被判为无效 function looksLikeImage(blob) { return new Promise(resolve => { const fr = new FileReader(); fr.onload = () => { const a = new Uint8Array(fr.result); const jpeg = a[0] === 0xFF && a[1] === 0xD8 && a[2] === 0xFF; const png = a[0] === 0x89 && a[1] === 0x50 && a[2] === 0x4E && a[3] === 0x47; const gif = a[0] === 0x47 && a[1] === 0x49 && a[2] === 0x46; const webp = a[0] === 0x52 && a[1] === 0x49 && a[2] === 0x46 && a[3] === 0x46 && a[8] === 0x57 && a[9] === 0x45; resolve(jpeg || png || gif || webp); }; fr.onerror = () => resolve(false); fr.readAsArrayBuffer(blob.slice(0, 16)); }); } // 用页面 预加载指定 URL:浏览器原生图片请求(Sec-Fetch-Dest: image) // 永远不会被 Cloudflare 挑战,成功即完成两件事: // 1. 验证了该 URL 当前返回的确实是可解码的图片 // 2. 把响应写入了浏览器 HTTP 缓存与 CF 边缘缓存 function preloadViaImg(url) { return new Promise(resolve => { const im = new Image(); im.onload = () => resolve({ ok: true, w: im.naturalWidth, h: im.naturalHeight }); im.onerror = () => resolve({ ok: false }); im.src = url; // 不带额外参数:必须与后续 GM 请求的 URL 完全一致才能命中缓存 }); } // 单次取图请求:Promise 化 GM.xmlHttpRequest,带超时。 // Referer 至关重要:图床做热链校验,无 Referer 一律返回 404 文本("404 Not Found"), // GM 请求从扩展后台发出时不会自动携带页面 Referer,必须显式声明。 function fetchImageBlob(url) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'GET', url, responseType: 'blob', timeout: 15000, headers: { 'Referer': 'https://www.assesphoto.com/', 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' }, onload: (res) => resolve(res), onerror: (err) => reject({ kind: 'error', err }), ontimeout: () => reject({ kind: 'timeout' }) }); }); } async function downloadImage() { const img = document.getElementById('img01'); if (!img || !img.src) return; if (downloading) return; // 上一次还没完成,忽略 const url = new URL(img.src); if (url.hostname !== IMG_HOST) { window.open(img.src, '_blank'); return; } const name = buildFilename(img.src); downloading = true; // 第一级: 预热。原生图片请求不受 CF 挑战,且会把 URL 写入双层缓存, // 让随后的 GM 请求(XHR 指纹,会被随机挑战)直接命中缓存拿到已验证的字节。 // modal 里正在显示的这张图本身已加载完成时,缓存里已有该 URL,跳过预热。 if (img.complete && img.naturalWidth > 0) { // 已在浏览器缓存中 } else { setBtnState('⏳ 预热中…', true); const pre = await preloadViaImg(img.src); if (!pre.ok) { // 连原生加载都失败:URL 本身失效,直接兜底 downloading = false; setBtnState('⚠ 已开新页', false); setTimeout(() => setBtnState('⬇ 下载', false), 1500); window.open(img.src, '_blank'); return; } } setBtnState('⏳ 下载中…', true); const MAX_ATTEMPTS = 3; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { const res = await fetchImageBlob(img.src); const blob = res.response; const magicOk = blob instanceof Blob && await looksLikeImage(blob); if (res.status === 200 && magicOk) { // blob URL 是同源的,a[download] 不受 CORS 限制 const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); downloading = false; setBtnState('✓ 已保存', false); setTimeout(() => setBtnState('⬇ 下载', false), 1500); setTimeout(() => URL.revokeObjectURL(a.href), 60000); return; } console.warn(`[kb-nav] 第 ${attempt}/${MAX_ATTEMPTS} 次响应无效 (status=${res.status}, ` + `type=${blob && blob.type}),可能被 Cloudflare 拦截,重试…`); } catch (e) { console.warn(`[kb-nav] 第 ${attempt}/${MAX_ATTEMPTS} 次请求失败 (${e.kind}),重试…`, e.err || ''); } if (attempt < MAX_ATTEMPTS) { // 重试前重新预热一次(刷新缓存,也给 CF 边缘节点一点时间收敛) setBtnState(`⏳ 重试 ${attempt}/${MAX_ATTEMPTS - 1}…`, true); await preloadViaImg(img.src); await new Promise(r => setTimeout(r, 400 * attempt)); } } // 兜底:新标签页打开。浏览器导航会自动携带页面 Referer(图床要求),必能加载; // 不用 GM_download —— chrome.downloads 发起的请求不带 Referer,对这个 // 做热链校验的图床只会拿到 404 文本,存成损坏文件(v1.4.0 的实际故障)。 downloading = false; setBtnState('⚠ 已开新页', false); setTimeout(() => setBtnState('⬇ 下载', false), 1500); window.open(img.src, '_blank'); } // 在 modal 右下角加一个下载按钮 function injectDownloadButton() { const modal = document.getElementById('myModal'); if (!modal || modal.querySelector('.kb-dl-btn')) return; const btn = document.createElement('button'); btn.className = 'kb-dl-btn'; btn.textContent = '⬇ 下载'; btn.title = '下载原图 (D)'; // 放在右下角、底部缩略图条上方(缩略图条高约 97px),避开箭头按钮 btn.style.cssText = [ 'position:fixed', 'right:20px', 'bottom:130px', 'z-index:10001', 'padding:10px 18px', 'border:none', 'border-radius:8px', 'background:rgba(0,0,0,.65)', 'color:#fff', 'font-size:14px', 'cursor:pointer', 'backdrop-filter:blur(4px)', 'transition:background .2s' ].join(';'); btn.addEventListener('mouseenter', () => btn.style.background = 'rgba(0,0,0,.9)'); btn.addEventListener('mouseleave', () => btn.style.background = 'rgba(0,0,0,.65)'); btn.addEventListener('click', downloadImage); modal.appendChild(btn); } // ---------- 键盘监听 ---------- document.addEventListener('keydown', function (e) { // 输入框中获得焦点时不拦截按键 const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) { return; } if (!isModalOpen()) return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); goPrev(); break; case 'ArrowRight': e.preventDefault(); goNext(); break; case 'Escape': e.preventDefault(); closeModal(); break; case 'd': case 'D': // Ctrl+D / Alt+D / Cmd+D 是浏览器快捷键(书签等),不接管;按住不放的自动重复也忽略 if (e.ctrlKey || e.metaKey || e.altKey || e.repeat) break; e.preventDefault(); downloadImage(); break; } }, true); // 使用捕获阶段,确保先于页面其它逻辑处理 // modal 是页面静态 HTML,脚本运行时已存在,直接注入即可 injectDownloadButton(); })(); // https://www.assesphoto.com/granny-exposing-her-asshole.shtml