// ==UserScript== // @name AssesPhoto 键盘切换图片 + 下载原图 // @namespace https://scriptcat.org/userscript/assesphoto-keyboard-nav // @version 1.2.0 // @description 在演示模式(Lightbox)中用键盘左右箭头切换上一张/下一张图片,Esc 关闭,D 键/按钮下载原图(带内容校验) // @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)); }); } 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; setBtnState('⏳ 下载中…', true); // 先用 GM_xmlhttpRequest 取回 blob 并校验内容确实是图片, // 避免 Cloudflare 偶发返回 HTML 挑战页被存成 .jpg(= 无效图片) GM.xmlHttpRequest({ method: 'GET', url: img.src, responseType: 'blob', onload: async (res) => { const blob = res.response; const headerSaysImage = /^content-type:\s*image\//im.test(res.responseHeaders || ''); const magicOk = blob instanceof Blob && await looksLikeImage(blob); if (!magicOk || res.status !== 200) { downloading = false; setBtnState('⚠ 失败,已开新页', false); console.warn('[kb-nav] 响应不是有效图片,可能被 Cloudflare 拦截:', res.status, headerSaysImage, blob && blob.type, (res.responseHeaders || '').slice(0, 200)); window.open(img.src, '_blank'); // 回退:让浏览器直接打开原图 return; } // 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); }, onerror: (err) => { downloading = false; setBtnState('⚠ 失败,已开新页', false); console.warn('[kb-nav] 下载请求失败:', err); 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