// ==UserScript==
// @name AssesPhoto 键盘切换图片 + 下载原图
// @namespace https://github.com/userscript/assesphoto-keyboard-nav
// @version 1.6.0
// @description 在演示模式(Lightbox)中用键盘左右箭头切换上一张/下一张图片,Esc 关闭,D 键/按钮下载原图(Referer+魔数双校验;三级切换回退兼容 Edge 沙箱)
// @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';
}
// 页面全局函数挂在真实页面上下文上。Tampermonkey 在不同浏览器/沙箱模式下
// window 可能是隔离的包装对象:函数"存在但一调用就抛错"或干脆不可见
// (Chrome 常页面上下文注入直接可见,Edge 常隔离沙箱不可见)。
// 因此每条路径都必须 try/catch,失败则落到下一级:
// 1. unsafeWindow 上的页面函数(标准路径)
// 2. window 上的页面函数(@grant none / 无沙箱时的路径)
// 3. 点击页面真实的箭头/关闭按钮(DOM 层,与沙箱完全无关,最终兜底)
function callPageFn(name, fallbackSelector) {
for (const w of [typeof unsafeWindow !== 'undefined' ? unsafeWindow : null, window]) {
if (!w) continue;
try {
if (typeof w[name] === 'function') { w[name](); return true; }
} catch (e) { /* 沙箱代理上 typeof/调用都可能抛错,落到下一级 */ }
}
try {
const btn = document.querySelector(fallbackSelector);
if (btn) { btn.click(); return true; }
} catch (e) { /* ignore */ }
return false;
}
function goPrev() { return callPageFn('prevImage', '#myModal .nav-button.left'); }
function goNext() { return callPageFn('nextImage', '#myModal .nav-button.right'); }
function closeModal() { return callPageFn('closeModal', '#myModal .close'); }
// ---------- 下载原图 ----------
// 下载进行中标志:防止连按 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);
}
// ---------- 键盘监听 ----------
function onKeyDown(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':
if (!goPrev()) console.warn('[kb-nav] prev 失败:页面函数与 .nav-button.left 均不可用');
e.preventDefault(); // 无论切换成功与否都吞掉按键,防止方向键滚动缩略图条/页面
break;
case 'ArrowRight':
if (!goNext()) console.warn('[kb-nav] next 失败:页面函数与 .nav-button.right 均不可用');
e.preventDefault();
break;
case 'Escape':
closeModal();
e.preventDefault();
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;
}
}
// 挂到 window 并用捕获阶段:即使页面或广告脚本在 document 上注册了冒泡/捕获
// 监听器并 stopPropagation,这里也能先拿到按键。window/document 双挂载,
// 兼容个别 manager 只代理其中一处事件的注入模式。
window.addEventListener('keydown', onKeyDown, true);
document.addEventListener('keydown', onKeyDown, true);
// modal 是页面静态 HTML,脚本运行时已存在,直接注入即可
injectDownloadButton();
// 启动自检:打印三级回退各自可用与否,Edge 下排障时看这行即可定位断在哪一级
const pageWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
console.info(
'[kb-nav] v1.6.0 就绪 | prevImage@unsafeWindow:', typeof pageWin.prevImage,
'| prevImage@window:', typeof window.prevImage,
'| 按钮:', !!document.querySelector('#myModal .nav-button.left')
);
})();
// https://www.assesphoto.com/granny-exposing-her-asshole.shtml