弹窗库
Цей скрипт не слід встановлювати безпосередньо. Це - бібліотека для інших скриптів для включення в мета директиву // @require https://update.greasyfork.org/scripts/590010/1904541/popup-viewer-kit.js
(() => {
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/kit/kit.css
var kit_default = "/* PopupKit 独立样式:pvk- 前缀,与 popup-viewer-v2 主脚本(popup-/pv-)\r\n 及 page-picker-kit(ppk-)完全隔离,避免同页多脚本 ID/CSS 冲突。\r\n 自包含 loading/error/spinner,不依赖主脚本 Loading.js/ErrorView.js 的样式。 */\r\n@layer pvk-reset {\r\n #pvk-panel,\r\n #pvk-panel *,\r\n #pvk-overlay,\r\n #pvk-loading,\r\n #pvk-loading *,\r\n #pvk-error,\r\n #pvk-error *,\r\n .pvk-btn {\r\n box-sizing: border-box;\r\n margin: 0;\r\n padding: 0;\r\n border: 0;\r\n outline: 0;\r\n background: transparent;\r\n vertical-align: baseline;\r\n text-decoration: none;\r\n text-shadow: none;\r\n font-family: inherit;\r\n font-size: inherit;\r\n font-weight: inherit;\r\n font-style: inherit;\r\n line-height: inherit;\r\n letter-spacing: inherit;\r\n color: inherit;\r\n text-align: left;\r\n }\r\n}\r\n\r\n#pvk-panel {\r\n box-sizing: border-box;\r\n}\r\n\r\n:root {\r\n --pvk-accent: #2563eb;\r\n --pvk-accent-hover: #1d4ed8;\r\n --pvk-accent-soft: rgba(37, 99, 235, 0.1);\r\n --pvk-danger: #dc2626;\r\n --pvk-danger-soft: rgba(220, 38, 38, 0.1);\r\n --pvk-header-bg: #fafbfc;\r\n --pvk-border: #e4e7ec;\r\n --pvk-text: #1a2233;\r\n --pvk-muted: #64748b;\r\n --pvk-faint: #94a3b8;\r\n --pvk-bg: #ffffff;\r\n --pvk-surface: #ffffff;\r\n --pvk-btn-bg: rgba(15, 23, 42, 0.07);\r\n --pvk-btn-hover: rgba(15, 23, 42, 0.12);\r\n --pvk-btn-active: rgba(15, 23, 42, 0.17);\r\n --pvk-focus-ring: rgba(37, 99, 235, 0.4);\r\n --pvk-shadow:\r\n 0 0 0 1px rgba(15, 23, 42, 0.04),\r\n 0 24px 60px -18px rgba(15, 23, 42, 0.28),\r\n 0 8px 24px -12px rgba(15, 23, 42, 0.16);\r\n --pvk-overlay: rgba(15, 23, 42, 0.28);\r\n}\r\n\r\n@media (prefers-color-scheme: dark) {\r\n :root {\r\n --pvk-accent: #60a5fa;\r\n --pvk-accent-hover: #93c5fd;\r\n --pvk-accent-soft: rgba(96, 165, 250, 0.14);\r\n --pvk-danger: #f87171;\r\n --pvk-danger-soft: rgba(248, 113, 113, 0.14);\r\n --pvk-header-bg: #121a2b;\r\n --pvk-border: #263247;\r\n --pvk-text: #e2e8f0;\r\n --pvk-muted: #94a3b8;\r\n --pvk-faint: #64748b;\r\n --pvk-bg: #0f172a;\r\n --pvk-surface: #151e30;\r\n --pvk-btn-bg: rgba(148, 163, 184, 0.14);\r\n --pvk-btn-hover: rgba(148, 163, 184, 0.22);\r\n --pvk-btn-active: rgba(148, 163, 184, 0.28);\r\n --pvk-focus-ring: rgba(96, 165, 250, 0.5);\r\n --pvk-shadow:\r\n 0 0 0 1px rgba(255, 255, 255, 0.04),\r\n 0 28px 70px -20px rgba(0, 0, 0, 0.72),\r\n 0 10px 28px -14px rgba(0, 0, 0, 0.5);\r\n --pvk-overlay: rgba(0, 0, 0, 0.52);\r\n }\r\n}\r\n\r\n/* 面板核心 */\r\n#pvk-panel {\r\n position: fixed;\r\n z-index: 10000;\r\n width: 50%;\r\n height: 75%;\r\n max-width: min(2560px, calc(100vw - 24px));\r\n max-height: min(1440px, calc(100vh - 24px));\r\n max-height: min(1440px, calc(100dvh - 24px));\r\n top: 50%;\r\n left: 50%;\r\n overscroll-behavior: contain;\r\n transform: translate(-50%, -50%) scale(0.96);\r\n transform-origin: center;\r\n background-color: var(--pvk-surface);\r\n color: var(--pvk-text);\r\n box-shadow: var(--pvk-shadow);\r\n border: 1px solid var(--pvk-border);\r\n border-radius: 12px;\r\n overflow: hidden;\r\n display: flex;\r\n flex-direction: column;\r\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',\r\n 'Microsoft YaHei', Roboto, 'Helvetica Neue', Arial, sans-serif;\r\n opacity: 0;\r\n pointer-events: none;\r\n transition: opacity 0.25s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);\r\n}\r\n#pvk-panel.visible {\r\n opacity: 1;\r\n pointer-events: auto;\r\n transform: translate(-50%, -50%) scale(1);\r\n}\r\n\r\n/* 头部 */\r\n#pvk-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 12px;\r\n padding: 0 10px 0 14px;\r\n background-color: var(--pvk-header-bg);\r\n border-bottom: 1px solid var(--pvk-border);\r\n height: 46px;\r\n min-height: 46px;\r\n flex: 0 0 auto;\r\n cursor: grab;\r\n user-select: none;\r\n touch-action: none;\r\n}\r\n#pvk-header:active {\r\n cursor: grabbing;\r\n}\r\n#pvk-title {\r\n display: flex;\r\n align-items: center;\r\n gap: 9px;\r\n min-width: 0;\r\n flex: 1;\r\n}\r\n.pvk-title-mark {\r\n flex: 0 0 auto;\r\n width: 26px;\r\n height: 26px;\r\n border-radius: 8px;\r\n background: var(--pvk-accent-soft);\r\n color: var(--pvk-accent);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n.pvk-title-mark svg {\r\n width: 15px;\r\n height: 15px;\r\n fill: none;\r\n stroke: currentColor;\r\n}\r\n.pvk-title-text {\r\n font-size: 14px;\r\n font-weight: 600;\r\n letter-spacing: 0.1px;\r\n color: var(--pvk-text);\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n#pvk-actions {\r\n display: flex;\r\n align-items: center;\r\n gap: 4px;\r\n flex: 0 0 auto;\r\n}\r\n.pvk-btn {\r\n width: 32px;\r\n height: 32px;\r\n padding: 0;\r\n border: 1px solid transparent;\r\n border-radius: 9px;\r\n background-color: var(--pvk-btn-bg);\r\n color: var(--pvk-muted);\r\n cursor: pointer;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex: 0 0 auto;\r\n transition: background-color 0.15s ease, color 0.15s ease, transform 0.1s ease;\r\n}\r\n.pvk-btn svg {\r\n width: 16px;\r\n height: 16px;\r\n fill: none;\r\n stroke: currentColor;\r\n pointer-events: none;\r\n}\r\n.pvk-btn:hover {\r\n background-color: var(--pvk-btn-hover);\r\n color: var(--pvk-text);\r\n}\r\n.pvk-btn:active {\r\n transform: scale(0.92);\r\n}\r\n.pvk-btn:focus-visible {\r\n outline: 2px solid var(--pvk-focus-ring);\r\n outline-offset: 1px;\r\n}\r\n#pvk-close:hover {\r\n color: var(--pvk-danger);\r\n background-color: var(--pvk-danger-soft);\r\n}\r\n#pvk-actions .pvk-btn:hover {\r\n color: var(--pvk-accent);\r\n background-color: var(--pvk-accent-soft);\r\n}\r\n\r\n/* 内容区 */\r\n#pvk-content {\r\n flex: 1;\r\n overflow-y: auto;\r\n overscroll-behavior: contain;\r\n position: relative;\r\n background-color: var(--pvk-bg);\r\n padding: 20px;\r\n box-sizing: border-box;\r\n scroll-behavior: smooth;\r\n scrollbar-width: thin;\r\n scrollbar-color: var(--pvk-faint) transparent;\r\n}\r\n#pvk-content::-webkit-scrollbar {\r\n width: 8px;\r\n}\r\n#pvk-content::-webkit-scrollbar-thumb {\r\n background-color: var(--pvk-faint);\r\n border-radius: 8px;\r\n border: 2px solid transparent;\r\n background-clip: padding-box;\r\n}\r\n#pvk-content.iframe-direct-load {\r\n padding: 0;\r\n}\r\n#pvk-iframe {\r\n width: 100%;\r\n height: 100%;\r\n border: none;\r\n background-color: #fff;\r\n}\r\n\r\n/* 遮罩 */\r\n#pvk-overlay {\r\n position: fixed;\r\n inset: 0;\r\n z-index: 9998;\r\n background-color: var(--pvk-overlay);\r\n opacity: 0;\r\n pointer-events: none;\r\n transition: opacity 0.3s ease-in-out;\r\n backdrop-filter: blur(6px);\r\n -webkit-backdrop-filter: blur(6px);\r\n}\r\n#pvk-overlay.visible {\r\n opacity: 1;\r\n pointer-events: auto;\r\n}\r\n\r\n/* 加载状态 */\r\n#pvk-loading {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n height: 100%;\r\n color: var(--pvk-muted);\r\n padding: 25px;\r\n}\r\n.pvk-spinner {\r\n width: 38px;\r\n height: 38px;\r\n margin-bottom: 20px;\r\n border: 3px solid var(--pvk-accent-soft);\r\n border-radius: 50%;\r\n border-top: 3px solid var(--pvk-accent);\r\n animation: pvk-spin 0.8s linear infinite;\r\n}\r\n@keyframes pvk-spin {\r\n 0% {\r\n transform: rotate(0deg);\r\n }\r\n 100% {\r\n transform: rotate(360deg);\r\n }\r\n}\r\n.pvk-loading-hint {\r\n font-size: 15px;\r\n font-weight: 500;\r\n color: var(--pvk-text);\r\n}\r\n.pvk-loading-sub {\r\n margin-top: 8px;\r\n font-size: 13px;\r\n color: var(--pvk-faint);\r\n}\r\n\r\n/* 错误状态 */\r\n#pvk-error {\r\n padding: 35px;\r\n color: var(--pvk-text);\r\n text-align: center;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n height: 100%;\r\n box-sizing: border-box;\r\n}\r\n#pvk-error .pvk-error-icon {\r\n color: var(--pvk-danger);\r\n margin-bottom: 6px;\r\n}\r\n#pvk-error h3 {\r\n margin-top: 16px;\r\n margin-bottom: 10px;\r\n font-weight: 600;\r\n font-size: 1.15em;\r\n}\r\n#pvk-error p {\r\n margin-bottom: 24px;\r\n color: var(--pvk-muted);\r\n max-width: 420px;\r\n line-height: 1.6;\r\n word-break: break-all;\r\n}\r\n#pvk-error button {\r\n padding: 10px 20px;\r\n background: var(--pvk-accent);\r\n color: #fff;\r\n border: none;\r\n border-radius: 8px;\r\n cursor: pointer;\r\n font-weight: 500;\r\n font-size: 14px;\r\n transition: background 0.15s ease-in-out, transform 0.1s ease-in-out;\r\n}\r\n#pvk-error button:hover {\r\n background: var(--pvk-accent-hover);\r\n}\r\n#pvk-error button:active {\r\n transform: scale(0.97);\r\n}\r\n\r\n/* 拖拽反馈(共享 setupDrag 会添加 pv-dragging 类) */\r\n#pvk-panel.pv-dragging {\r\n user-select: none;\r\n box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.4), 0 28px 70px -20px rgba(0, 0, 0, 0.5);\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n #pvk-panel,\r\n #pvk-overlay {\r\n transition: none;\r\n }\r\n .pvk-spinner {\r\n animation-duration: 1.6s;\r\n }\r\n}\r\n@media (max-width: 560px), (max-height: 560px) {\r\n #pvk-panel {\r\n max-width: calc(100vw - 16px);\r\n max-height: calc(100vh - 16px);\r\n }\r\n}\r\n\r\n/* 触屏适配:加大按钮触控目标;小屏触屏铺近全屏 */\r\n@media (pointer: coarse) {\r\n .pvk-btn {\r\n min-width: 44px;\r\n min-height: 44px;\r\n }\r\n}\r\n@media (pointer: coarse) and (max-width: 560px), (pointer: coarse) and (max-height: 560px) {\r\n #pvk-panel {\r\n width: calc(100vw - 12px);\r\n height: calc(100vh - 12px);\r\n height: calc(100dvh - 12px);\r\n }\r\n}\r\n";
// src/utils/gm.js
function gmApi(name) {
try {
const fn = globalThis[name];
return typeof fn === "function" ? fn : null;
} catch {
return null;
}
}
function injectStyle(css) {
const style = document.createElement("style");
style.type = "text/css";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return style;
}
function localGet(key, fallback) {
try {
const raw = localStorage.getItem("popup-viewer:" + key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback;
}
}
function localSet(key, value) {
try {
localStorage.setItem("popup-viewer:" + key, JSON.stringify(value));
} catch {
}
}
var gm = {
addStyle(css) {
const fn = gmApi("GM_addStyle");
if (fn) {
try {
return fn(css);
} catch (err) {
console.warn("[PopupViewer] GM_addStyle failed, fallback to <style>", err);
}
}
try {
return injectStyle(css);
} catch (err) {
console.warn("[PopupViewer] injectStyle failed", err);
return null;
}
},
xmlhttpRequest(options) {
const fn = gmApi("GM_xmlhttpRequest");
if (fn) {
return fn({
timeout: options.timeout || 15e3,
...options
});
}
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), options.timeout || 15e3) : null;
const clearTimer = () => {
if (timer) clearTimeout(timer);
};
const promise = fetch(options.url, {
method: options.method || "GET",
signal: controller ? controller.signal : void 0
}).then(async (res) => {
var _a;
clearTimer();
(_a = options.onload) == null ? void 0 : _a.call(options, {
status: res.status,
statusText: res.statusText,
responseText: await res.text(),
finalUrl: res.url
});
}).catch((err) => {
var _a;
clearTimer();
(_a = options.onerror) == null ? void 0 : _a.call(options, err);
});
if (controller) promise.abort = () => controller.abort();
return promise;
},
getValue(key, fallback) {
const fn = gmApi("GM_getValue");
if (fn) {
try {
return fn(key, fallback);
} catch {
}
}
return localGet(key, fallback);
},
setValue(key, value) {
const fn = gmApi("GM_setValue");
if (fn) {
try {
return fn(key, value);
} catch {
}
}
return localSet(key, value);
}
};
// src/config.js
var config = {
popup: {
// 窗体大小预设(可在设置面板切换)
sizes: {
small: { width: "40%", height: "60%" },
medium: { width: "50%", height: "75%" },
large: { width: "72%", height: "90%" },
phone: { width: "min(92vw, 400px)", height: "min(85vh, 720px)" }
},
defaultSize: "medium",
scrollbarVisible: true
},
// 手机模式预置(尺寸 + 对应移动端 UA,仅影响抓取加载路径)
phone: {
sizes: {
"iphone": {
label: "iPhone",
width: "393px",
height: "852px",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
},
"iphone-max": {
label: "Max",
width: "430px",
height: "932px",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
},
"android": {
label: "安卓",
width: "412px",
height: "915px",
ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
},
"small": {
label: "小屏",
width: "360px",
height: "780px",
ua: "Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
}
},
defaultModel: "iphone"
},
loader: {
timeout: 15e3,
// 默认加载方式: 'auto' | 'iframe' | 'request' | 'parser'
defaultMode: "auto"
},
cache: {
enabled: true,
ttl: 30 * 60 * 1e3,
// 30 分钟
maxEntries: 50
},
prefetch: {
enabled: true,
hoverDebounceMs: 150
// 悬停后延迟开始预热,避免鼠标扫过列表时频繁请求
},
observer: {
debounceMs: 300
},
// 站点加载策略:
// iframe: true → 使用 iframe 直接加载(页面自带脚本与登录态)
// scripts: true → 请求模式下保留目标页 <script> 以正常渲染(仅限信任站点)
sitePolicy: {
"linux.do": { iframe: true, scripts: true },
"1cili.com": { iframe: false, scripts: true },
"s.9cili.mom": { iframe: false, scripts: true },
unknown: { iframe: false, scripts: false }
},
storage: {
historyKey: "pv2:history",
favoritesKey: "pv2:favorites"
},
maxHistoryEntries: 100
};
// src/utils/logger.js
var PREFIX = "[PopupViewer]";
function configDebug() {
try {
return location.search.includes("pv2_debug");
} catch {
return false;
}
}
var logger = {
log(...args) {
if (configDebug()) console.log(PREFIX, ...args);
},
warn(...args) {
console.warn(PREFIX, ...args);
},
error(...args) {
console.error(PREFIX, ...args);
},
debug(...args) {
if (configDebug()) console.debug(PREFIX, ...args);
}
};
// src/security/Sandbox.js
var Sandbox = class {
constructor(policy = {}) {
this.policy = policy;
}
/**
* 获取站点策略,未知站点回退到 unknown 策略。
* 支持子域名匹配(如 www.1cili.com 命中 1cili.com 策略)。
*/
policyFor(hostname) {
if (!hostname) return this.policy.unknown || { iframe: false, scripts: false };
if (this.policy[hostname]) return this.policy[hostname];
for (const key of Object.keys(this.policy)) {
if (key === "unknown") continue;
if (hostname.endsWith("." + key)) return this.policy[key];
}
return this.policy.unknown || { iframe: false, scripts: false };
}
/**
* 生成 sandbox 属性字符串。
* scripts=true 时允许脚本执行;否则不包含 allow-scripts/allow-same-origin。
*/
buildSandboxAttrs(hostname, extra = []) {
const p = this.policyFor(hostname);
const attrs = [
"allow-forms",
"allow-modals",
"allow-pointer-lock",
"allow-popups",
"allow-presentation"
];
if (p.scripts) {
attrs.push("allow-same-origin", "allow-scripts");
}
return [...attrs, ...extra].join(" ");
}
/**
* 该站点是否允许直接 iframe 加载。
*/
allowsIframe(hostname) {
return this.policyFor(hostname).iframe === true;
}
};
function createDefaultSandbox(policy) {
return new Sandbox(policy);
}
function contentSandboxAttrs(keepScripts = false) {
const attrs = [
"allow-forms",
"allow-modals",
"allow-pointer-lock",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-presentation",
"allow-same-origin"
];
if (keepScripts) attrs.push("allow-scripts");
return attrs.join(" ");
}
// src/utils/dom.js
var ICON_PATHS = {
article: {
path: '<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>'
},
close: { path: '<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>' },
plus: { path: '<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>' },
info: {
path: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>'
},
smartphone: {
path: '<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line>'
},
external: {
path: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line>'
},
refresh: {
path: '<path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"></path><path d="M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>'
},
maximize: {
path: '<polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line x1="3" y1="21" x2="10" y2="14"></line>'
},
minimize: {
path: '<polyline points="4 14 10 14 10 20"></polyline><polyline points="20 10 14 10 14 4"></polyline><line x1="14" y1="10" x2="21" y2="3"></line><line x1="3" y1="21" x2="10" y2="14"></line>'
},
arrowLeft: {
path: '<line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline>'
},
arrowRight: {
path: '<line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline>'
},
settings: {
path: '<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-2.83 2.83l-.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-4 0v-.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-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 0-4h.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 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 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 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>'
}
};
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === void 0) continue;
if (key === "class") node.className = value;
else if (key === "text") node.textContent = value;
else if (key === "style" && typeof value === "object") Object.assign(node.style, value);
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child == null) continue;
node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
return node;
}
function svg(inner, attrs = {}) {
const ns = "http://www.w3.org/2000/svg";
const node = document.createElementNS(ns, "svg");
node.setAttribute("viewBox", "0 0 24 24");
node.setAttribute("fill", "none");
node.setAttribute("stroke", "currentColor");
node.setAttribute("stroke-width", "2");
node.setAttribute("stroke-linecap", "round");
node.setAttribute("stroke-linejoin", "round");
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
const holder = document.createElement("div");
holder.innerHTML = inner.trim();
moveSvgChildren(node, holder);
return node;
}
function moveSvgChildren(svgParent, container) {
for (const child of [...container.childNodes]) {
if (child.nodeType !== 1) continue;
const el2 = document.createElementNS(svgParent.namespaceURI, child.localName);
for (const attr of [...child.attributes]) el2.setAttribute(attr.name, attr.value);
moveSvgChildren(el2, child);
svgParent.appendChild(el2);
}
}
function svgIcon(iconName, { size = 18 } = {}) {
const icon = ICON_PATHS[iconName];
if (!icon) return svg("", { width: size, height: size });
const node = svg(icon.path, { width: size, height: size });
for (const [key, value] of Object.entries(icon.attrs || {})) node.setAttribute(key, value);
return node;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
return node;
}
// src/utils/debugFlag.js
var _cached = null;
function debugEnabled() {
if (_cached !== null) return _cached;
_cached = false;
try {
if (/(^|[?&#])pv2_debug([&#]|$)/.test(location.search + "#" + location.hash)) {
_cached = true;
} else {
let raw = null;
try {
const fn = globalThis["GM_getValue"];
if (typeof fn === "function") raw = fn("pv2:debug", null);
} catch (e) {
}
if (raw === null || raw === void 0) {
try {
raw = localStorage.getItem("popup-viewer:pv2:debug");
} catch (e2) {
}
}
_cached = raw === true || raw === "true" || raw === "1";
}
} catch (e) {
}
return _cached;
}
var modSeq = 0;
function markMod(name) {
if (!debugEnabled()) return;
try {
modSeq++;
const d = document.createElement("div");
d.textContent = modSeq + ". " + name;
d.style.cssText = "position:fixed;top:" + (12 + (modSeq - 1) * 22) + "px;right:12px;z-index:2147483647;background:#334155;color:#fff;padding:2px 8px;font-size:11px;border-radius:4px;font-family:sans-serif";
(document.body || document.documentElement).appendChild(d);
} catch (e) {
}
}
// src/core/SettingsManager.js
var KEY = "pv2:settings";
var SITE_KEY = "pv2:siteSettings";
var GLOBAL_KEYS = ["theme", "allowInFrame", "handedness"];
function defaultGlobal() {
return { theme: "auto", allowInFrame: false, handedness: "right" };
}
function defaultSite() {
return {
scrollbarVisible: config.popup.scrollbarVisible,
panelSize: config.popup.defaultSize,
windowMode: "coupled",
linkIntercept: true,
phoneModel: config.phone.defaultModel,
phonePosition: null,
customSize: null,
locked: false
};
}
function currentSiteKey() {
try {
return window.location.hostname || "unknown";
} catch {
return "unknown";
}
}
markMod("SettingsManager");
var SettingsManager = class {
constructor() {
this.global = defaultGlobal();
this.site = defaultSite();
this.siteKey = "unknown";
this._loaded = false;
}
load() {
if (this._loaded) return this.get();
this._loaded = true;
this.siteKey = currentSiteKey();
const g = gm.getValue(KEY, null);
if (g && typeof g === "object") this.global = { ...defaultGlobal(), ...g };
const all = gm.getValue(SITE_KEY, null);
if (all && typeof all === "object" && all[this.siteKey]) {
this.site = { ...defaultSite(), ...all[this.siteKey] };
}
if (!config.popup.sizes[this.site.panelSize] && this.site.panelSize !== "custom") {
this.site.panelSize = defaultSite().panelSize;
}
if (!config.phone.sizes[this.site.phoneModel]) {
this.site.phoneModel = defaultSite().phoneModel;
}
this._applyInvariants();
return this.get();
}
/** 当前生效设置 = 全局 + 当前站点设置。 */
get() {
return { ...this.global, ...this.site };
}
set(partial) {
for (const [key, value] of Object.entries(partial)) {
if (GLOBAL_KEYS.includes(key)) this.global[key] = value;
else this.site[key] = value;
}
this._applyInvariants();
this._persist();
return this.get();
}
reset() {
this.global = defaultGlobal();
this.site = defaultSite();
this._applyInvariants();
this._persist();
return this.get();
}
_applyInvariants() {
if (this.site.linkIntercept === false) this.site.windowMode = "float";
}
_persist() {
gm.setValue(KEY, this.global);
const all = gm.getValue(SITE_KEY, null) || {};
all[this.siteKey] = this.site;
gm.setValue(SITE_KEY, all);
}
};
var settingsManager = new SettingsManager();
// src/security/Sanitizer.js
markMod("Sanitizer");
var REAL_SRC_ATTRS = [
"zoomfile",
"file",
"data-src",
"data-original",
"data-lazy-src",
"data-actualsrc",
"data-src-real",
"data-real-src",
"data-url",
"data-large",
"data-big",
"data-hd-src",
"data-original-src",
"data-echo",
"data-lazyload",
"data-lazy-load",
"data-full",
"data-img"
];
var _Sanitizer = class _Sanitizer {
constructor() {
this.removedTags = /* @__PURE__ */ new Set([
"script",
"iframe",
"frame",
"frameset",
"object",
"embed",
"applet",
"form",
"link",
"meta",
"base",
"noscript"
]);
this.dangerousEventAttrs = /^on[a-z]+$/i;
}
/**
* 净化 HTML 字符串。
* @param {string} html 原始 HTML
* @param {string} [baseUrl] 用于绝对化样式链接
* @param {Object} [options]
* @param {boolean} [options.keepScripts=false] 保留 <script>(仅信任站点,需在沙箱 iframe 中渲染)
* @returns {{head:string, body:string}} head 为保留的样式标签,body 为净化后的内容。
*/
sanitize(html, baseUrl, options = {}) {
if (!html) return { head: "", body: "" };
const doc = new DOMParser().parseFromString(html, "text/html");
const head = this._collectHead(doc, baseUrl);
this._purge(doc, options);
this._resolveLazyImages(doc, baseUrl);
return { head, body: doc.body ? doc.body.innerHTML : "" };
}
_resolveLazyImages(doc, baseUrl) {
doc.querySelectorAll("img").forEach((img) => {
const real = this._firstRealAttr(img);
if (real) {
const resolved = this._absolutize(real, baseUrl);
if (resolved) img.setAttribute("src", resolved);
this._clearRealAttrs(img);
img.classList.remove("lazy");
img.loading = "lazy";
return;
}
if (this._resolveSrcset(img, baseUrl)) {
this._clearRealAttrs(img);
img.classList.remove("lazy");
img.loading = "lazy";
}
});
}
_firstRealAttr(img) {
for (const attr of REAL_SRC_ATTRS) {
const value = img.getAttribute(attr);
if (value && value.trim()) return value.trim();
}
return null;
}
_absolutize(value, baseUrl) {
if (!value) return null;
if (/^(javascript|vbscript|file):/i.test(value.trim())) return null;
try {
return new URL(value, baseUrl || void 0).href;
} catch {
return value;
}
}
/**
* 处理 data-srcset / data-original-set:绝对化并写回 srcset;
* src 仍是占位图时用第一个候选作为兜底 src。返回是否处理过。
*/
_resolveSrcset(img, baseUrl) {
const raw = img.getAttribute("data-srcset") || img.getAttribute("data-original-set");
if (!raw) return false;
const absolute = raw.split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
const parts = entry.split(/\s+/);
const url = parts[0];
if (!url) return entry;
try {
return new URL(url, baseUrl || void 0).href + (parts.length > 1 ? " " + parts.slice(1).join(" ") : "");
} catch {
return entry;
}
});
if (absolute.length) img.setAttribute("srcset", absolute.join(", "));
img.removeAttribute("data-srcset");
img.removeAttribute("data-original-set");
const cur = (img.getAttribute("src") || "").trim();
const first = absolute[0] ? absolute[0].split(/\s+/)[0] : null;
if (first && (!cur || _Sanitizer.PLACEHOLDER_RE.test(cur))) img.setAttribute("src", first);
return true;
}
_clearRealAttrs(img) {
for (const attr of REAL_SRC_ATTRS) img.removeAttribute(attr);
}
/**
* 收集原页面的样式(stylesheet 链接与 head 中的 <style> 块),
* 并对样式链接做绝对化,避免因丢失 <base> 导致 CSS 404。
* 样式链接可能位于 head 或 body(如淘股吧),需全文档收集。
*/
_collectHead(doc, baseUrl) {
const parts = [];
const seen = /* @__PURE__ */ new Set();
const pick = (node) => {
const key = node.outerHTML;
if (seen.has(key)) return;
seen.add(key);
const clone = node.cloneNode(true);
if (clone.tagName === "LINK" && clone.getAttribute("href")) {
try {
clone.setAttribute("href", new URL(clone.getAttribute("href"), baseUrl).href);
} catch {
}
}
parts.push(clone.outerHTML);
};
doc.querySelectorAll("link[rel], head style").forEach((node) => {
if (node.tagName === "LINK") {
const rel = (node.getAttribute("rel") || "").toLowerCase();
const href = (node.getAttribute("href") || "").toLowerCase();
const isStyle = rel.includes("stylesheet") || href.endsWith(".css") || rel.includes("preload") && (node.getAttribute("as") || "").toLowerCase() === "style";
if (!isStyle) return;
}
pick(node);
});
return parts.join("");
}
_purge(doc, { keepScripts = false } = {}) {
var _a;
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT);
const toRemove = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (node.nodeType === Node.COMMENT_NODE) {
toRemove.push(node);
continue;
}
const tag = node.tagName ? node.tagName.toLowerCase() : "";
const isScript = tag === "script";
if (this.removedTags.has(tag) && !(keepScripts && isScript)) {
toRemove.push(node);
continue;
}
this._sanitizeAttrs(node);
}
for (const node of toRemove) (_a = node.parentNode) == null ? void 0 : _a.removeChild(node);
}
_sanitizeAttrs(node) {
const attrs = node.attributes;
if (!attrs || !attrs.length) return;
for (let i = attrs.length - 1; i >= 0; i--) {
const name = attrs[i].name.toLowerCase();
const value = attrs[i].value;
if (this.dangerousEventAttrs.test(name)) {
node.removeAttribute(attrs[i].name);
continue;
}
if (name === "srcdoc") {
node.removeAttribute(attrs[i].name);
continue;
}
if (name === "href" || name === "xlink:href") {
const scheme = String(value).trim().split(":")[0].toLowerCase();
if (["javascript", "data", "vbscript", "file"].includes(scheme)) {
node.removeAttribute(attrs[i].name);
}
}
if (name === "src" || name === "poster" || name === "background") {
const scheme = String(value).trim().split(":")[0].toLowerCase();
if (["javascript", "vbscript", "file"].includes(scheme)) {
node.removeAttribute(attrs[i].name);
}
}
}
}
};
/**
* 解析懒加载图片:真实地址常放在 data-original/data-src/data-srcset 等各类 data 属性里,
* src 多为占位图(懒加载 JS 被净化后不会执行)。只要找到真实地址就换到 src 并绝对化,
* 不依赖对「占位图文件名」的精确识别——各站点占位图命名千差万别,正则穷举必然漏。
*/
__publicField(_Sanitizer, "PLACEHOLDER_RE", /^(data:|about:|blob:)/i);
var Sanitizer = _Sanitizer;
var sanitizer = new Sanitizer();
// src/loaders/IframeRenderer.js
function readIframeLocation(iframe) {
try {
const win = iframe.contentWindow;
if (!win || !win.location || !win.location.href) return null;
const href = win.location.href;
if (!href || href === "about:blank") return null;
let title = "";
try {
title = iframe.contentDocument && iframe.contentDocument.title || "";
} catch (e) {
}
return { url: href, title };
} catch (e) {
return null;
}
}
function renderIntoIframe({ html, url, container, sandboxAttrs, head = "", linkIntercept, loadingSelector = "#popup-panel-loading", onNavigate }) {
var _a;
(_a = container.querySelector(loadingSelector)) == null ? void 0 : _a.remove();
container.classList.add("iframe-direct-load");
const iframe = el("iframe", {
id: "popup-panel-iframe",
sandbox: sandboxAttrs
});
iframe.style.cssText = "width:100%;height:100%;border:none;background:#fff;";
container.appendChild(iframe);
const iframeDoc = iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(buildDocument(html, url, head));
iframeDoc.close();
const runFixes = () => {
if (!iframe.isConnected || !iframe.contentWindow) return;
try {
const doc = iframe.contentDocument;
if (!doc) return;
fixLinks(doc, linkIntercept);
injectReadStyle(doc);
fixImages(doc);
} catch (err) {
logger.error("[renderIntoIframe] manipulate error", err);
}
};
let firstLoad = true;
iframe.addEventListener("load", () => {
runFixes();
if (firstLoad) {
firstLoad = false;
return;
}
if (onNavigate) {
const loc = readIframeLocation(iframe);
if (loc) onNavigate(loc.url, loc.title);
}
});
if (iframeDoc.readyState === "complete") iframe.dispatchEvent(new Event("load"));
[1500, 3500].forEach((delay) => setTimeout(runFixes, delay));
return iframe;
}
function buildDocument(html, url, head = "") {
const baseHref = buildBaseHref(url);
return '<!DOCTYPE html><html><head><base href="' + baseHref + '"><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Content</title>' + head + "</head><body>" + html + "</body></html>";
}
function buildBaseHref(url) {
try {
const u = new URL(url);
let base = u.origin ? u.origin + u.pathname : u.pathname;
if (!/\.[a-z0-9]+$/i.test(u.pathname) && !u.pathname.endsWith("/")) base += "/";
return base;
} catch {
return url;
}
}
function fixLinks(iframeDoc, linkIntercept) {
const openNewTab = linkIntercept !== void 0 ? linkIntercept : settingsManager.get().linkIntercept !== false;
iframeDoc.querySelectorAll("a[href]").forEach((link) => {
if (openNewTab) link.target = "_blank";
const href = link.getAttribute("href");
if (!href || href.trim().startsWith("javascript:")) return;
try {
link.href = new URL(href, iframeDoc.baseURI).href;
} catch {
}
});
}
function injectReadStyle(iframeDoc) {
const style = iframeDoc.createElement("style");
style.textContent = "body{font-family:Segoe UI,sans-serif;padding:10px;word-wrap:break-word;overflow-wrap:break-word;overscroll-behavior:contain;}img,video,iframe{max-width:100%;height:auto;}a{color:#007bff;text-decoration:none;}a:hover{text-decoration:underline;}a:visited{color:#6a0dad;}";
iframeDoc.head.insertBefore(style, iframeDoc.head.firstChild);
}
function fixImages(iframeDoc) {
iframeDoc.querySelectorAll("img").forEach((img) => {
img.loading = "eager";
const cur = img.getAttribute("src") || "";
const isPlaceholderSrc = !cur || /(?:none|placeholder|loading|blank|spacer|1x1|pixel)(?:\.gif|\.png|\.jpg|\.jpeg|\.webp)?$/i.test(cur);
if (isPlaceholderSrc || img.complete && img.naturalWidth === 0) {
for (const attr of REAL_SRC_ATTRS) {
const value = img.getAttribute(attr);
if (value && value.trim()) {
try {
img.src = new URL(value.trim(), iframeDoc.baseURI).href;
} catch {
img.src = value.trim();
}
img.removeAttribute(attr);
break;
}
}
}
const dsrcset = img.getAttribute("data-srcset") || img.getAttribute("data-original-set");
if (dsrcset) {
const first = dsrcset.split(",")[0].trim().split(/\s+/)[0];
if (first) {
try {
img.src = new URL(first, iframeDoc.baseURI).href;
} catch {
img.src = first;
}
}
img.removeAttribute("data-srcset");
img.removeAttribute("data-original-set");
}
if (img.complete && img.naturalWidth === 0 && img.src && !/^data:/i.test(img.src)) {
const s = img.src;
img.src = "";
img.src = s;
}
});
}
// src/loaders/IframeLoader.js
var IframeLoader = class {
constructor(sandbox) {
this.sandbox = sandbox;
}
load({ url, hostname, container, onError, onLoad, mobileUA = null, loadingSelector = "#popup-panel-loading", onNavigate }) {
var _a;
logger.debug(`[IframeLoader] direct load ${url}`);
if (mobileUA) {
logger.debug("[IframeLoader] 直接 iframe 无法设置移动端 UA:" + url);
}
const iframe = el("iframe", {
id: "popup-panel-iframe",
sandbox: this.sandbox.buildSandboxAttrs(hostname)
});
iframe.style.cssText = "width:100%;height:100%;border:none;background:#fff;";
let settled = false;
let firstLoad = true;
let lastUrl = url;
const finish = (fn, ...args) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(...args);
};
const timer = setTimeout(() => {
logger.warn(`[IframeLoader] timeout loading ${url}`);
onError(`加载 ${url} 超时或被阻止,请尝试在新标签页打开。`);
}, config.loader.timeout);
const pollTimer = setInterval(() => {
if (!iframe.isConnected) {
clearInterval(pollTimer);
return;
}
const loc = readIframeLocation(iframe);
if (loc && loc.url !== lastUrl) {
lastUrl = loc.url;
onNavigate == null ? void 0 : onNavigate(loc.url, loc.title);
}
}, 800);
iframe.addEventListener("load", () => {
var _a2;
if (firstLoad) {
firstLoad = false;
const loc2 = readIframeLocation(iframe);
if (loc2) lastUrl = loc2.url;
if (!settled) {
(_a2 = container.querySelector(loadingSelector)) == null ? void 0 : _a2.remove();
container.classList.add("iframe-direct-load");
finish(onLoad);
}
return;
}
const loc = readIframeLocation(iframe);
if (loc && loc.url !== lastUrl) {
lastUrl = loc.url;
onNavigate == null ? void 0 : onNavigate(loc.url, loc.title);
}
});
iframe.addEventListener("error", () => finish(onError, `加载 ${url} 失败。`));
(_a = container.querySelector(loadingSelector)) == null ? void 0 : _a.remove();
container.appendChild(iframe);
iframe.src = url;
return () => {
clearTimeout(timer);
clearInterval(pollTimer);
iframe.src = "about:blank";
iframe.remove();
};
}
};
// src/loaders/RequestLoader.js
var RequestLoader = class {
load({ url, hostname, keepScripts = false, container, onError, onLoad, mobileUA = null, linkIntercept, loadingSelector, onNavigate }) {
logger.debug(`[RequestLoader] fetch ${url}`);
const abort = gm.xmlhttpRequest({
method: "GET",
url,
timeout: config.loader.timeout,
...mobileUA ? { headers: { "User-Agent": mobileUA } } : {},
onload: (response) => {
if (response.status !== 200) {
onError(`加载失败 (HTTP ${response.status})`);
return;
}
try {
const result = sanitizer.sanitize(response.responseText, url, { keepScripts });
renderIntoIframe({
html: result.body,
head: result.head,
url,
container,
sandboxAttrs: contentSandboxAttrs(keepScripts),
linkIntercept,
loadingSelector,
onNavigate
});
onLoad == null ? void 0 : onLoad();
} catch (error) {
logger.error("[RequestLoader] process content error", error);
onError("内容解析失败: " + error.message);
}
},
onerror: (error) => {
logger.error("[RequestLoader] request error", error);
onError("网络请求失败");
}
});
return () => {
var _a;
try {
(_a = abort == null ? void 0 : abort.abort) == null ? void 0 : _a.call(abort);
} catch {
}
const iframe = container.querySelector("#popup-panel-iframe");
iframe == null ? void 0 : iframe.remove();
};
}
};
// src/loaders/ParserLoader.js
var ParserLoader = class {
load({ url, hostname, keepScripts, container, onError, onLoad, mobileUA = null, linkIntercept }) {
logger.debug(`[ParserLoader] fetch & parse ${url}`);
const abort = gm.xmlhttpRequest({
method: "GET",
url,
timeout: config.loader.timeout,
...mobileUA ? { headers: { "User-Agent": mobileUA } } : {},
onload: (response) => {
if (response.status !== 200) {
onError(`加载失败 (HTTP ${response.status})`);
return;
}
try {
const { body: sanitizedBody } = sanitizer.sanitize(response.responseText, url);
const doc = new DOMParser().parseFromString(
`<!DOCTYPE html><html><body>${sanitizedBody}</body></html>`,
"text/html"
);
this._absolutize(doc, url, linkIntercept);
const body = clear(container);
const wrap = doc.body;
while (wrap.firstChild) body.appendChild(wrap.firstChild);
container.classList.remove("iframe-direct-load");
onLoad == null ? void 0 : onLoad();
} catch (error) {
logger.error("[ParserLoader] parse error", error);
onError("内容解析失败: " + error.message);
}
},
onerror: (error) => {
logger.error("[ParserLoader] request error", error);
onError("网络请求失败");
}
});
return () => {
var _a;
try {
(_a = abort == null ? void 0 : abort.abort) == null ? void 0 : _a.call(abort);
} catch {
}
};
}
_absolutize(doc, baseUrl, linkIntercept) {
const base = baseUrl;
doc.querySelectorAll("[href],[src],[poster],[data-src]").forEach((node) => {
["href", "src", "poster", "data-src"].forEach((attr) => {
const raw = node.getAttribute(attr);
if (!raw || /^(javascript|data|mailto|tel|#):/i.test(raw.trim())) return;
try {
node.setAttribute(attr, new URL(raw, base).href);
} catch {
}
});
});
const openNewTab = linkIntercept !== void 0 ? linkIntercept : settingsManager.get().linkIntercept !== false;
doc.querySelectorAll("a[href]").forEach((link) => {
if (openNewTab) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
});
doc.querySelectorAll("img").forEach((img) => {
img.loading = img.loading || "lazy";
});
}
};
// src/loaders/CacheLoader.js
var CacheLoader = class {
constructor() {
this.cache = /* @__PURE__ */ new Map();
this.inflight = /* @__PURE__ */ new Map();
}
has(url) {
return this._readCache(url) != null;
}
_readCache(url) {
const entry = this.cache.get(url);
if (!entry) return null;
if (Date.now() - entry.time > config.cache.ttl) {
this.cache.delete(url);
return null;
}
return entry;
}
_writeCache(url, result) {
if (this.cache.size >= config.cache.maxEntries) {
const oldest = this.cache.keys().next().value;
this.cache.delete(oldest);
}
this.cache.set(url, { head: result.head, body: result.body, time: Date.now() });
}
/**
* 发起抓取并缓存,返回 { promise, abort }。
* 同一 URL 的并发请求会去重复用。
*/
prefetch(url, keepScripts = false, mobileUA = null) {
const cached = this._readCache(url);
if (cached != null) return { promise: Promise.resolve(cached), abort: () => {
} };
const existing = this.inflight.get(url);
if (existing) return existing;
const entry = this._fetch(url, keepScripts, mobileUA);
entry.promise.finally(() => this.inflight.delete(url));
this.inflight.set(url, entry);
return entry;
}
_fetch(url, keepScripts, mobileUA = null) {
let abort = () => {
};
const promise = new Promise((resolve, reject) => {
const req = gm.xmlhttpRequest({
method: "GET",
url,
timeout: config.loader.timeout,
...mobileUA ? { headers: { "User-Agent": mobileUA } } : {},
onload: (response) => {
if (response.status !== 200) {
reject(new Error(`加载失败 (HTTP ${response.status})`));
return;
}
try {
const result = sanitizer.sanitize(response.responseText, url, { keepScripts });
if (config.cache.enabled) this._writeCache(url, result);
resolve(result);
} catch (error) {
logger.error("[CacheLoader] process error", error);
reject(new Error("内容解析失败: " + error.message));
}
},
onerror: (error) => {
logger.error("[CacheLoader] request error", error);
reject(new Error("网络请求失败"));
}
});
abort = () => {
var _a;
try {
(_a = req == null ? void 0 : req.abort) == null ? void 0 : _a.call(req);
} catch {
}
};
});
return { promise, abort };
}
load({ url, keepScripts = false, container, onError, onLoad, mobileUA = null, linkIntercept, loadingSelector, onNavigate }) {
logger.debug(`[CacheLoader] ${url}`);
const cached = this._readCache(url);
if (cached != null) {
logger.debug(`[CacheLoader] cache hit: ${url}`);
renderIntoIframe({
html: cached.body,
head: cached.head,
url,
container,
sandboxAttrs: contentSandboxAttrs(keepScripts),
linkIntercept,
loadingSelector,
onNavigate
});
onLoad == null ? void 0 : onLoad();
return () => {
var _a;
(_a = container.querySelector("#popup-panel-iframe")) == null ? void 0 : _a.remove();
};
}
const { promise, abort } = this.prefetch(url, keepScripts, mobileUA);
promise.then((result) => {
renderIntoIframe({
html: result.body,
head: result.head,
url,
container,
sandboxAttrs: contentSandboxAttrs(keepScripts),
linkIntercept,
loadingSelector,
onNavigate
});
onLoad == null ? void 0 : onLoad();
}).catch((error) => onError == null ? void 0 : onError(error.message));
return () => {
var _a;
try {
abort == null ? void 0 : abort();
} catch {
}
(_a = container.querySelector("#popup-panel-iframe")) == null ? void 0 : _a.remove();
};
}
};
// src/core/LoaderManager.js
markMod("LoaderManager");
var LoaderManager = class {
constructor() {
this.sandbox = createDefaultSandbox(config.sitePolicy);
this.loaders = {
iframe: new IframeLoader(this.sandbox),
request: new RequestLoader(),
parser: new ParserLoader(),
cache: new CacheLoader()
};
}
/**
* 解析应使用的加载方式。
*/
resolveMode(url, hostname) {
const policy = this.sandbox.policyFor(hostname);
if (policy.iframe) return "iframe";
const mode = config.loader.defaultMode;
if (mode === "iframe") return "iframe";
if (mode === "parser") return "parser";
if (mode === "request") return "request";
if (config.cache.enabled) return "cache";
return "request";
}
/**
* 内容是否已在缓存中。
*/
hasCached(url) {
return this.loaders.cache.has(url);
}
/**
* 预热缓存:后台抓取并净化 HTML,点击时可立即渲染。
* 仅当解析结果为 cache 模式时有效。
*/
prefetch(url) {
const hostname = this._hostnameOf(url);
const keepScripts = this.keepScriptsFor(hostname);
return this.loaders.cache.prefetch(url, keepScripts, this.mobileUA());
}
/**
* 手机模式下返回对应的移动端 UA,否则 null。
*/
mobileUA() {
const s = settingsManager.get();
if (s.panelSize !== "phone") return null;
const m = config.phone.sizes[s.phoneModel] || config.phone.sizes[config.phone.defaultModel];
return m ? m.ua : null;
}
_hostnameOf(url) {
try {
return new URL(url).hostname;
} catch {
return "unknown";
}
}
/** 该站点抓取净化时是否保留 <script>(仅信任站点)。 */
keepScriptsFor(hostname) {
return this.sandbox.policyFor(hostname).scripts === true;
}
/**
* 加载内容到容器。
* @param {string} url
* @param {Object} ctx { container, hostname, onError, onLoad, keepScripts?, mobileUA?, linkIntercept?, loadingSelector? }
* @returns {Function} abort 函数
*/
load(url, ctx) {
const hostname = ctx.hostname || this._hostnameOf(url);
const mode = this.resolveMode(url, hostname);
const loader = this.loaders[mode] || this.loaders.request;
const keepScripts = ctx.keepScripts !== void 0 ? ctx.keepScripts : this.keepScriptsFor(hostname);
const mobileUA = ctx.mobileUA !== void 0 ? ctx.mobileUA : this.mobileUA();
const linkIntercept = ctx.linkIntercept !== void 0 ? ctx.linkIntercept : void 0;
logger.log(`[LoaderManager] mode=${mode} scripts=${keepScripts} ${url}`);
const abort = loader.load({
url,
hostname,
keepScripts,
mobileUA,
linkIntercept,
loadingSelector: ctx.loadingSelector,
container: ctx.container,
onError: ctx.onError,
onLoad: ctx.onLoad,
onNavigate: ctx.onNavigate
});
return () => {
try {
abort == null ? void 0 : abort();
} catch (err) {
logger.warn("[LoaderManager] abort error", err);
}
};
}
};
var loaderManager = new LoaderManager();
// src/utils/panelBehavior.js
function clampToViewport(panel, { isFullScreen = false, pad = 8 } = {}) {
if (!panel || isFullScreen) return;
const rect = panel.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = rect.left;
let top = rect.top;
if (rect.width > vw - pad * 2) {
left = pad;
} else {
if (left < pad) left = pad;
else if (left + rect.width > vw - pad) left = vw - pad - rect.width;
}
if (rect.height > vh - pad * 2) {
top = pad;
} else {
if (top < pad) top = pad;
else if (top + rect.height > vh - pad) top = vh - pad - rect.height;
}
if (left !== rect.left || top !== rect.top) {
panel.style.transition = "none";
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.transform = "none";
void panel.offsetWidth;
panel.style.transition = "";
}
}
var POINTER_EVENTS = typeof PointerEvent !== "undefined";
function setupDrag({ header, panel, isFullScreen = () => false, locked = () => false, onDragEnd }) {
const DOWN = POINTER_EVENTS ? "pointerdown" : "mousedown";
const MOVE = POINTER_EVENTS ? "pointermove" : "mousemove";
const UP = POINTER_EVENTS ? "pointerup" : "mouseup";
let dragging = false;
let dragLocked = false;
let pointerId = null;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let lastX = 0;
let lastY = 0;
let raf = null;
const applyMove = () => {
raf = null;
if (!dragging || dragLocked) return;
panel.style.left = `${startLeft + (lastX - startX)}px`;
panel.style.top = `${startTop + (lastY - startY)}px`;
};
const finish = (commit) => {
if (!dragging) return;
const info = { locked: dragLocked };
dragging = false;
if (raf) {
cancelAnimationFrame(raf);
raf = null;
}
panel.classList.remove("pv-dragging");
document.body.style.cursor = "";
panel.style.transition = "";
if (POINTER_EVENTS && pointerId != null) {
try {
header.releasePointerCapture(pointerId);
} catch {
}
pointerId = null;
}
if (commit) onDragEnd == null ? void 0 : onDragEnd(info);
};
header.addEventListener(DOWN, (e) => {
if (e.target.closest("button")) return;
if (isFullScreen()) return;
if (e.button !== 0) return;
dragging = true;
dragLocked = locked();
const rect = panel.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
lastX = e.clientX;
lastY = e.clientY;
if (!dragLocked) {
panel.style.transition = "none";
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.transform = "none";
panel.classList.add("pv-dragging");
document.body.style.cursor = "grabbing";
}
if (POINTER_EVENTS) {
pointerId = e.pointerId;
try {
header.setPointerCapture(e.pointerId);
} catch {
}
}
e.preventDefault();
});
const move = (e) => {
if (!dragging) return;
lastX = e.clientX;
lastY = e.clientY;
if (!raf) raf = requestAnimationFrame(applyMove);
};
document.addEventListener(MOVE, move);
document.addEventListener(UP, () => finish(true));
if (POINTER_EVENTS) document.addEventListener("pointercancel", () => finish(false));
}
function setupWheelScrollChain({ panel, contentArea, isFullScreen = () => false }) {
document.addEventListener(
"wheel",
(e) => {
if (!panel || !panel.classList.contains("visible")) return;
if (isFullScreen()) {
e.preventDefault();
e.stopPropagation();
} else {
const content = contentArea();
if (content && content.contains(e.target)) {
const { scrollTop, scrollHeight, clientHeight } = content;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
const canScroll = scrollHeight > clientHeight;
if (!canScroll || atBottom) {
e.preventDefault();
e.stopPropagation();
}
}
}
},
true
);
}
// src/kit/KitPopupPanel.js
var KitPopupPanel = class {
constructor() {
this.panel = null;
this.overlay = null;
this.titleTextEl = null;
this.contentArea = null;
this.maximizeBtn = null;
this.currentUrl = "";
this.currentTitle = "";
this.currentSize = null;
this.isFullScreen = false;
this.preFullScreen = {};
this._abort = null;
this._onOpen = null;
this._onClose = null;
this._onRefresh = null;
}
setHandlers({ onOpen, onClose, onRefresh } = {}) {
this._onOpen = onOpen;
this._onClose = onClose;
this._onRefresh = onRefresh;
}
ensure() {
if (this.panel) return this.panel;
this.overlay = el("div", { id: "pvk-overlay", onclick: () => this.close() });
document.body.appendChild(this.overlay);
const titleMark = el("span", { class: "pvk-title-mark" });
titleMark.appendChild(svgIcon("article", { size: 15 }));
this.titleTextEl = el("span", { class: "pvk-title-text", text: "查看内容" });
const title = el("div", { id: "pvk-title" }, titleMark, this.titleTextEl);
const actions = el("div", { id: "pvk-actions" });
const makeBtn = (icon, titleText, onClick) => {
const btn = el("button", { type: "button", class: "pvk-btn", title: titleText, onclick: onClick });
btn.appendChild(svgIcon(icon, { size: 16 }));
actions.appendChild(btn);
return btn;
};
makeBtn("refresh", "刷新内容 (R)", () => this.refresh());
this.maximizeBtn = makeBtn("maximize", "全屏 (F)", () => this.toggleFullScreen());
makeBtn("external", "在新标签页打开", () => {
if (this.currentUrl) window.open(this.currentUrl, "_blank", "noopener");
});
const closeBtn = makeBtn("close", "关闭 (Esc)", () => this.close());
closeBtn.id = "pvk-close";
const header = el("div", { id: "pvk-header" }, title, actions);
this.contentArea = el("div", { id: "pvk-content" });
this.panel = el("div", { id: "pvk-panel" }, header, this.contentArea);
document.body.appendChild(this.panel);
this._setupDrag(header);
this._setupKeyboard();
return this.panel;
}
/**
* @param {{url:string,title?:string,width?:string,height?:string,load:Function}} opts
* load(container,{onError,onLoad}) 返回 abort
*/
open(opts) {
this.ensure();
this.currentUrl = opts.url || "";
this.currentTitle = opts.title || "查看内容";
this.currentSize = { width: opts.width, height: opts.height };
this._lastOpen = opts;
this.titleTextEl.textContent = this.currentTitle;
this.panel.classList.remove("visible");
this.isFullScreen = false;
this._updateMaximizeIcon();
if (this.currentSize) {
this.panel.style.setProperty("width", this.currentSize.width || "50%");
this.panel.style.setProperty("height", this.currentSize.height || "75%");
} else {
this.panel.style.removeProperty("width");
this.panel.style.removeProperty("height");
}
if (!this._dragged) {
this.panel.style.left = "";
this.panel.style.top = "";
this.panel.style.transform = "";
}
requestAnimationFrame(() => {
var _a;
this.panel.classList.add("visible");
this.overlay.classList.add("visible");
(_a = this._onOpen) == null ? void 0 : _a.call(this, opts);
});
this._load(opts);
}
_load(opts) {
var _a;
(_a = this._abort) == null ? void 0 : _a.call(this);
this._abort = null;
const content = this.getContentArea();
content.classList.remove("iframe-direct-load");
content.innerHTML = "";
this._showLoading(content);
const abort = opts.load(content, {
onError: (message) => {
this._abort = null;
this._showError(content, message);
},
onLoad: () => {
this._abort = null;
}
});
this._abort = abort || null;
}
refresh() {
var _a, _b;
if (!this.currentUrl || !this._lastOpen) return;
const prev = this._lastOpen;
(_a = this._abort) == null ? void 0 : _a.call(this);
this._abort = null;
const content = this.getContentArea();
content.classList.remove("iframe-direct-load");
content.innerHTML = "";
this._showLoading(content);
(_b = this._onRefresh) == null ? void 0 : _b.call(this, this.currentUrl);
this._load(prev);
}
close() {
var _a, _b;
if (!this.panel) return;
(_a = this._abort) == null ? void 0 : _a.call(this);
this._abort = null;
this.panel.classList.remove("visible");
this.overlay.classList.remove("visible");
const url = this.currentUrl;
setTimeout(() => {
if (this.contentArea) this.contentArea.innerHTML = "";
this.currentUrl = "";
}, 350);
(_b = this._onClose) == null ? void 0 : _b.call(this, { url });
}
getContentArea() {
this.ensure();
return this.contentArea;
}
toggleFullScreen() {
if (!this.panel) return;
if (!this.isFullScreen) {
this.preFullScreen = {
width: this.panel.style.width,
height: this.panel.style.height,
top: this.panel.style.top,
left: this.panel.style.left,
transform: this.panel.style.transform,
borderRadius: this.panel.style.borderRadius,
maxWidth: this.panel.style.maxWidth,
maxHeight: this.panel.style.maxHeight
};
this.panel.style.transition = "none";
Object.assign(this.panel.style, {
width: "100vw",
height: "100vh",
top: "0px",
left: "0px",
transform: "none",
borderRadius: "0px",
maxWidth: "none",
maxHeight: "none",
zIndex: "10002"
});
void this.panel.offsetWidth;
this.panel.style.transition = "";
this.isFullScreen = true;
} else {
this.panel.style.transition = "none";
Object.assign(this.panel.style, {
width: this.preFullScreen.width || "",
height: this.preFullScreen.height || "",
top: this.preFullScreen.top || "",
left: this.preFullScreen.left || "",
transform: this.preFullScreen.transform || "",
borderRadius: this.preFullScreen.borderRadius || "",
maxWidth: this.preFullScreen.maxWidth || "",
maxHeight: this.preFullScreen.maxHeight || "",
zIndex: "10000"
});
void this.panel.offsetWidth;
this.panel.style.transition = "";
this.isFullScreen = false;
}
this._updateMaximizeIcon();
}
_updateMaximizeIcon() {
if (!this.maximizeBtn) return;
this.maximizeBtn.innerHTML = "";
this.maximizeBtn.appendChild(svgIcon(this.isFullScreen ? "minimize" : "maximize", { size: 16 }));
this.maximizeBtn.title = this.isFullScreen ? "恢复 (F)" : "全屏 (F)";
}
_setupDrag(header) {
setupDrag({
header,
panel: this.panel,
isFullScreen: () => this.isFullScreen,
onDragEnd: () => {
this._dragged = true;
this._clampToViewport();
}
});
}
/**
* 把弹窗约束回视口内(DevTools 占位 / 窗口缩放后调用),
* 避免窗体被压缩出可视区域。
*/
_clampToViewport() {
clampToViewport(this.panel, { isFullScreen: this.isFullScreen });
}
_setupKeyboard() {
document.addEventListener("keydown", (e) => {
if (!this.panel || !this.panel.classList.contains("visible")) return;
if (e.key === "Escape") this.close();
else if (e.key === "f" || e.key === "F") this.toggleFullScreen();
else if (e.key === "r" || e.key === "R") {
if (this.currentUrl && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
this.refresh();
}
}
});
window.addEventListener("resize", () => {
if (this.panel && this.panel.classList.contains("visible")) {
requestAnimationFrame(() => this._clampToViewport());
}
});
setupWheelScrollChain({
panel: this.panel,
contentArea: () => this.contentArea,
isFullScreen: () => this.isFullScreen
});
}
_showLoading(container) {
container.replaceChildren(
el(
"div",
{ id: "pvk-loading" },
el("div", { class: "pvk-spinner" }),
el("div", { class: "pvk-loading-hint", text: "正在加载内容..." }),
el("div", { class: "pvk-loading-sub", text: "请稍候片刻" })
)
);
}
_showError(container, message) {
const openBtn = el("button", { text: "在新标签页打开" });
if (this.currentUrl) {
openBtn.addEventListener("click", () => window.open(this.currentUrl, "_blank", "noopener"));
} else {
openBtn.disabled = true;
}
container.replaceChildren(
el(
"div",
{ id: "pvk-error" },
svg(
'<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line>',
{ class: "pvk-error-icon", width: 52, height: 52, "stroke-width": 1.5 }
),
el("h3", { text: "加载失败" }),
el("p", { text: message || "无法加载请求的内容" }),
openBtn
)
);
}
};
// src/kit/popupKit.js
function createPopupKit() {
gm.addStyle(kit_default);
const loaderManager2 = new LoaderManager();
const panel = new KitPopupPanel();
panel.setHandlers({
onOpen: (opts) => {
var _a;
(_a = kit2._onOpen) == null ? void 0 : _a.call(kit2, { url: opts.url, title: opts.title });
},
onClose: (info) => {
var _a;
(_a = kit2._onClose) == null ? void 0 : _a.call(kit2, info);
}
});
function hostnameOf(url) {
try {
return new URL(url).hostname;
} catch {
return "unknown";
}
}
const kit2 = {
version: "1.1.0",
/**
* 在页内弹窗打开一个 URL(多加载器自动选择:iframe 直载 / 抓取净化 / 缓存)。
* @param {{url:string,title?:string,width?:string,height?:string,linkIntercept?:boolean}} opts
*/
open(opts) {
const url = opts && opts.url;
if (!url) return false;
const title = (opts.title || "").trim() || "查看内容";
panel.open({
url,
title,
width: opts.width,
height: opts.height,
load: (container, { onError, onLoad }) => {
const hostname = hostnameOf(url);
return loaderManager2.load(url, {
container,
hostname,
keepScripts: loaderManager2.keepScriptsFor(hostname),
linkIntercept: opts.linkIntercept,
loadingSelector: "#pvk-loading",
onError,
onLoad
});
}
});
return true;
},
close() {
panel.close();
},
/** 刷新当前内容(保持窗体位置与尺寸) */
refresh() {
panel.refresh();
},
/** 当前是否已打开 */
isOpen() {
return !!panel.panel && panel.panel.classList.contains("visible");
},
/** 是否有缓存内容(供宿主脚本悬停预热判断) */
hasCached(url) {
return loaderManager2.hasCached(url);
},
/** 后台预热某个 URL(点击时立即渲染) */
prefetch(url) {
loaderManager2.prefetch(url);
},
get config() {
return config;
},
// 生命周期回调(由宿主脚本设置)
_onOpen: null,
_onClose: null
};
return kit2;
}
var kit = createPopupKit();
if (typeof window !== "undefined") {
window.PopupKit = kit;
}
})();