// ==UserScript== // @name 国家中小学智慧教育平台教材下载 // @namespace https://smartedu-textbook-downloader.local/ // @version 1.0.0 // @description 在国家中小学智慧教育平台的教材详情页,提供 PDF 下载和新标签页预览功能。 // @author smartedu userscript // @match https://basic.smartedu.cn/* // @match https://www.smartedu.cn/* // @match https://*.smartedu.cn/* // @match https://www.zxx.edu.cn/* // @match https://teacher.vocational.smartedu.cn/* // @match https://core.teacher.vocational.smartedu.cn/* // @icon https://basic.smartedu.cn/favicon.ico // @noframes // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect s-file-2.ykt.cbern.com.cn // @connect *.cbern.com.cn // @connect picui.ogmua.cn // @run-at document-end // ==/UserScript== (() => { 'use strict'; const PANEL_ID = 'smartedu-textbook-panel'; const STYLE_ID = 'smartedu-textbook-style'; const POSITION_KEY = 'smartedu-textbook-panel-position'; const COLLAPSED_KEY = 'smartedu-textbook-panel-collapsed'; const TOAST_ID = 'smartedu-textbook-toast'; const DETAIL_API = 'https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/'; const QR_IMAGES = [ 'https://picui.ogmua.cn/s1/2026/08/16/6a810d336bc46.webp', 'https://picui.ogmua.cn/s1/2026/08/16/6a810d3374c0e.webp' ]; const state = { collapsed: GM_getValue(COLLAPSED_KEY, false), dragging: false, busy: false, progress: null, source: null, sourceCheckedAt: 0, refreshing: false, status: '等待教材加载', error: '' }; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` #${PANEL_ID} { --nex-text: #f7f4ef; --nex-muted: #aaa5b0; --nex-line: rgba(255,255,255,.11); --nex-hot: #ff8a68; position: fixed; z-index: 2147483000; width: min(430px, calc(100vw - 24px)); overflow: hidden; color: var(--nex-text); background: linear-gradient(135deg, rgba(24,24,32,.94), rgba(13,14,20,.9) 68%, rgba(47,25,31,.92)); border: 1px solid rgba(255,157,116,.28); border-radius: 24px; box-shadow: 0 24px 70px rgba(5,6,12,.5), 0 0 30px rgba(255,105,82,.1), inset 0 1px 0 rgba(255,255,255,.1); backdrop-filter: blur(22px) saturate(125%); -webkit-backdrop-filter: blur(22px) saturate(125%); font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID}.nex-is-collapsed { width: min(214px, calc(100vw - 24px)); } .nex-titlebar { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 0 15px 0 18px; cursor: move; } .nex-heading { display: flex; align-items: center; gap: 11px; min-width: 0; } .nex-orbit { position: relative; width: 25px; height: 25px; flex: 0 0 auto; border: 1px solid rgba(255,164,126,.75); border-radius: 50%; box-shadow: 0 0 20px rgba(255,110,83,.32); } .nex-orbit::after { position: absolute; top: 4px; right: -2px; width: 6px; height: 6px; border-radius: 50%; background: var(--nex-hot); box-shadow: 0 0 9px var(--nex-hot); content: ""; } .nex-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 760; } .nex-kicker { display: block; margin-top: 2px; color: var(--nex-muted); font-size: 10px; letter-spacing: .12em; } #${PANEL_ID} button { border: 0; font: inherit; cursor: pointer; } .nex-toggle { display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; min-height: 32px; padding: 7px 10px; color: #ffd0bc; background: rgba(255,119,85,.1); border: 1px solid rgba(255,159,125,.3); border-radius: 999px; font-size: 11px; transition: background .2s ease, transform .2s ease; } .nex-toggle:hover { background: rgba(255,119,85,.18); } .nex-toggle:active, .nex-btn:active { transform: translateY(1px) scale(.98); } .nex-icon, .nex-icon svg { display: inline-flex; width: 15px; height: 15px; align-items: center; justify-content: center; } .nex-icon svg { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; } .nex-body { position: relative; display: grid; grid-template-columns: minmax(0, 1.22fr) minmax(130px, .78fr); gap: 12px; padding: 0 14px 14px; } .nex-status, .nex-actions, .nex-progress { grid-column: 1; } .nex-status { display: flex; align-items: center; gap: 8px; min-height: 34px; overflow: hidden; padding: 8px 11px; color: var(--nex-text); background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.1); border-radius: 13px; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .nex-status::before { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: currentColor; box-shadow: 0 0 11px currentColor; content: ""; } .nex-status--ok { color: #62d7a1; } .nex-status--warn { color: #f2b866; } .nex-status--error { color: #ff7483; } .nex-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .nex-btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: 100%; min-height: 39px; padding: 8px 10px; border-radius: 13px; font-size: 12px; transition: transform .15s ease, background .2s ease, border-color .2s ease; } .nex-btn--primary { grid-column: 1 / -1; min-height: 43px; color: #2b1715; background: linear-gradient(112deg, #ff8665, #ffbd83); box-shadow: 0 10px 24px rgba(255,112,84,.2); font-weight: 760; } .nex-btn--secondary { color: #e9e1dd; background: rgba(255,255,255,.065); border: 1px solid rgba(255,255,255,.13); } .nex-btn--secondary:hover { background: rgba(255,255,255,.12); border-color: rgba(255,178,151,.35); } .nex-btn:disabled { color: #8d8993; background: rgba(255,255,255,.045); border-color: rgba(255,255,255,.08); cursor: wait; opacity: .72; } .nex-spin { animation: nex-spin .85s linear infinite; } @keyframes nex-spin { to { transform: rotate(360deg); } } .nex-progress { padding: 12px 12px 11px; background: rgba(4,5,9,.23); border: 1px solid var(--nex-line); border-radius: 17px; } .nex-progress-head, .nex-progress-foot { display: flex; justify-content: space-between; gap: 8px; color: var(--nex-muted); font-size: 11px; } .nex-progress-percent { color: #fff6ed; font-size: 21px; font-weight: 780; font-variant-numeric: tabular-nums; } .nex-progress-speed { align-self: end; color: #ffae86; font-variant-numeric: tabular-nums; } .nex-track { height: 9px; margin: 9px 0 8px; overflow: hidden; background: rgba(255,255,255,.1); border-radius: 999px; } .nex-fill { position: relative; height: 100%; min-width: 2px; background: linear-gradient(90deg, #ff765b, #ffc187); border-radius: inherit; transition: width .2s ease; } .nex-fill::after { position: absolute; inset: 0; background: linear-gradient(105deg, transparent 20%, rgba(255,255,255,.58) 50%, transparent 80%); content: ""; animation: nex-shimmer 1.65s linear infinite; } @keyframes nex-shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } } .nex-support { grid-column: 2; grid-row: 1 / span 3; display: grid; align-content: start; gap: 9px; min-width: 0; padding: 12px 10px 10px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.09); border-radius: 18px; } .nex-support-title { color: #f3e8e1; font-size: 12px; font-weight: 700; } .nex-support-note { color: var(--nex-muted); font-size: 10px; line-height: 1.45; } .nex-qr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } .nex-qr-item { display: grid; justify-items: center; gap: 5px; min-width: 0; } .nex-qr { width: 100%; aspect-ratio: 1; padding: 4px; object-fit: contain; background: #fff; border-radius: 11px; } .nex-qr-label { color: #f2e8e0; font-size: 10px; } @media (max-width: 520px) { #${PANEL_ID} { width: min(360px, calc(100vw - 16px)); border-radius: 20px; } #${PANEL_ID}.nex-is-collapsed { width: min(206px, calc(100vw - 16px)); } .nex-titlebar { min-height: 57px; padding-left: 14px; } .nex-body { grid-template-columns: 1fr; gap: 10px; padding: 0 10px 10px; } .nex-status, .nex-actions, .nex-progress, .nex-support { grid-column: 1; } .nex-support { grid-row: auto; grid-template-columns: auto 1fr; align-items: center; gap: 8px 10px; padding: 10px; } .nex-support-title { grid-column: 1 / -1; } .nex-support-note { grid-column: 1; } .nex-qr-grid { grid-column: 2; grid-row: 2; } .nex-qr { width: 58px; } } `.replace(/\s+/g, ' ').replace(/\s*([{}:;,])\s*/g, '$1').replace(/;}/g, '}'); document.head.appendChild(style); } const getContentId = () => { const id = new URLSearchParams(location.search).get('contentId'); return id && /^[\w-]{8,128}$/i.test(id) ? id : null; }; const isDetailPage = () => { const hasPdfFrame = [...document.querySelectorAll('iframe')].some(frame => /(?:file=|pdf)/i.test(frame.src || '')); return Boolean(getContentId()) || /\/(tchMaterial|elecedu|resource)(\/|$)/i.test(location.pathname) || hasPdfFrame; }; function getAccessToken() { const extract = outer => { let value = outer?.value; if (typeof value === 'string') { try { value = JSON.parse(value); } catch { if (value.length > 20) return value.replace(/^"|"$/g, ''); } } return value?.access_token || outer?.access_token || null; }; for (const [key, raw] of Object.entries(localStorage)) { if (!/nd[_-]?uc[_-]?auth/i.test(key)) continue; try { const token = extract(JSON.parse(raw)); if (token) return token; } catch { const match = raw.match(/"access_token"\s*:\s*"([^"]+)"/); if (match) return match[1]; } } for (const [key, value] of Object.entries(localStorage)) if (/access[_-]?token/i.test(key) && value && value.length > 20) return value.replace(/^"|"$/g, ''); return null; } const gmRequest = details => new Promise((resolve, reject) => GM_xmlhttpRequest({ ...details, onload: resolve, onerror: reject, ontimeout: () => reject(new Error('请求超时')) })); function flattenStorage(value) { return (Array.isArray(value) ? value.flatMap(flattenStorage) : value == null ? [] : [String(value)]) .join('`').split('`') .map(part => part.replace(/\s+/g, ' ').trim()) .filter(Boolean); } function findPdfUrl(storages) { return flattenStorage(storages) .map(part => (/^https?:\/\//i.test(part) ? '\n' : '') + part) .join('').split('\n') .filter(part => /^https?:\/\//i.test(part)) .find(url => /\.pdf(?:$|[?#])/i.test(url)) || null; } const withAccessToken = (pdfUrl, token) => { if (!token) return pdfUrl; try { const url = new URL(pdfUrl); if (!url.searchParams.has('accessToken')) url.searchParams.set('accessToken', token); return url.href; } catch { return `${pdfUrl}${pdfUrl.includes('?') ? '&' : '?'}accessToken=${encodeURIComponent(token)}`; } }; async function getApiSource() { const contentId = getContentId(); const token = getAccessToken(); if (!contentId || !token) return null; const response = await gmRequest({ method: 'GET', url: `${DETAIL_API}${encodeURIComponent(contentId)}.json`, timeout: 12000 }); if (response.status !== 200) throw new Error(`详情接口返回 HTTP ${response.status}`); const data = JSON.parse(response.responseText); const item = data.ti_items?.find(entry => entry.ti_file_flag === 'source') || data.ti_items?.find(entry => entry.ti_storages); const pdfUrl = item && findPdfUrl(item.ti_storages); if (!pdfUrl) throw new Error('详情接口中没有找到 PDF 源文件'); return { type: 'url', url: withAccessToken(pdfUrl, token), fileName: item.ti_file_name || `${getCleanTitle()}.pdf`, label: '接口源文件已就绪' }; } function getViewerSource() { const verdict = { source: null }; [...document.querySelectorAll('iframe')].some(frame => { try { const app = frame.contentWindow?.PDFViewerApplication; if (app?.pdfDocument) { verdict.source = { type: 'pdf_app', app, fileName: `${getCleanTitle()}.pdf`, label: `查看器已就绪(${app.pdfDocument.numPages} 页)` }; return true; } } catch {} try { if (!frame.src) return false; const params = new URL(frame.src).searchParams; const file = params.get('file'); if (!file) return false; let headers = {}; const rawHeaders = params.get('headers'); if (rawHeaders) { try { headers = JSON.parse(rawHeaders); } catch {} } verdict.source = { type: 'url', url: decodeURIComponent(file), headers, fileName: `${getCleanTitle()}.pdf`, label: '查看器源文件已就绪' }; return true; } catch { return false; } }); return verdict.source; } // 第二份脚本的查看器解析是主引擎:优先读取已加载的 PDF 或 iframe 源流。 // 只有查看器尚未暴露源文件时,才使用第一份脚本的详情接口兜底。 async function resolveSource(force = false) { if (!force && state.source && Date.now() - state.sourceCheckedAt < 5000) return state.source; state.sourceCheckedAt = Date.now(); state.error = ''; const viewerSource = getViewerSource(); if (viewerSource) { state.source = viewerSource; state.status = viewerSource.label; return viewerSource; } try { const apiSource = await getApiSource(); if (apiSource) { state.source = apiSource; state.status = apiSource.label; return apiSource; } } catch (error) { state.error = error.message; } state.source = null; state.status = getAccessToken() ? '等待教材加载' : '请先登录平台'; return null; } function getCleanTitle() { const probes = [ () => [...document.querySelectorAll('.fish-breadcrumb-link')].pop()?.textContent, () => document.querySelector('h1, h2, .header-title')?.textContent, () => document.title.replace(/[-_]国家中小学智慧教育平台.*/i, '') ]; let name = '电子课本'; for (const probe of probes) { const hit = probe(); if (hit && hit.trim()) { name = hit.trim(); break; } } const unsafe = '\\/:*?"<>|'; return [...name].map(ch => unsafe.includes(ch) ? '_' : ch).join('').slice(0, 120); } const formatBytes = bytes => bytes ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : '--'; const toastBox = () => { const box = document.getElementById(TOAST_ID) ?? Object.assign(document.createElement('div'), { id: TOAST_ID }); if (!box.isConnected) { box.style.cssText = 'position:fixed;z-index:2147483001;top:20px;left:50%;transform:translateX(-50%);padding:9px 16px;border-radius:7px;color:#fff;background:rgba(16,28,48,.92);font:13px sans-serif;pointer-events:none;transition:opacity .2s'; document.body.appendChild(box); } return box; }; function showToast(message, duration = 3200) { const box = toastBox(); box.textContent = message; box.style.opacity = '1'; clearTimeout(box._hideTimer); box._hideTimer = setTimeout(() => { box.style.opacity = '0'; }, duration); } const directDownload = (url, fileName) => { const link = document.createElement('a'); link.href = url; link.download = fileName; link.target = '_blank'; link.rel = 'noopener'; document.body.appendChild(link); link.click(); link.remove(); }; // 探测服务器分片能力;不可用时按单流处理。 async function probeDownload(url, headers) { try { const head = await fetch(url, { method: 'HEAD', headers }); if (head.ok) { return { size: parseInt(head.headers.get('content-length') || '0', 10), ranges: /bytes/i.test(head.headers.get('accept-ranges') || '') }; } } catch {} return { size: 0, ranges: false }; } async function slurpStream(response, knownSize, onProgress) { if (!response.body) return response.blob(); const reader = response.body.getReader(); const parts = []; let fetched = 0; const startStamp = Date.now(); for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); fetched += value.byteLength; onProgress(fetched, knownSize, fetched / Math.max(1, Date.now() - startStamp) * 1000); } return new Blob(parts, { type: 'application/pdf' }); } async function fetchSlice(url, begin, end, headers, ledger, slot, report) { const response = await fetch(url, { method: 'GET', headers: { ...headers, Range: `bytes=${begin}-${end}` } }); if (response.status !== 206) throw new Error(`服务器不支持分片响应 (HTTP ${response.status})`); const reader = response.body?.getReader(); if (!reader) throw new Error('无法读取分片响应'); const parts = []; let got = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); got += value.byteLength; ledger[slot] = got; report(); } const merged = new Uint8Array(got); let offset = 0; for (const part of parts) { merged.set(part, offset); offset += part.byteLength; } return merged; } async function parallelFetch(url, headers, size, onProgress) { const SLOTS = 4; const span = Math.ceil(size / SLOTS); const pieces = new Array(SLOTS); const ledger = new Array(SLOTS).fill(0); let lastAccrued = 0; let lastTick = Date.now(); const report = () => { const accrued = ledger.reduce((sum, value) => sum + value, 0); const tick = Date.now(); if (tick - lastTick >= 150 || accrued === size) { onProgress(accrued, size, (accrued - lastAccrued) / Math.max(0.001, (tick - lastTick) / 1000)); lastAccrued = accrued; lastTick = tick; } }; await Promise.all(Array.from({ length: SLOTS }, async (_, slot) => { const begin = slot * span; const end = Math.min(size - 1, (slot + 1) * span - 1); for (let attempt = 0; ; attempt++) { try { pieces[slot] = await fetchSlice(url, begin, end, headers, ledger, slot, report); return; } catch (error) { if (attempt === 2) throw error; ledger[slot] = 0; await new Promise(resolve => setTimeout(resolve, 400 * (attempt + 1))); } } })); onProgress(size, size, 0); return new Blob(pieces, { type: 'application/pdf' }); } async function downloadWithProgress(url, headers, onProgress) { const { size, ranges } = await probeDownload(url, headers); if (!size || !ranges || size < 15 * 1024 * 1024) { const response = await fetch(url, { method: 'GET', headers }); if (!response.ok) throw new Error(`网络响应异常 (HTTP ${response.status})`); return slurpStream(response, size || parseInt(response.headers.get('content-length') || '0', 10), onProgress); } return parallelFetch(url, headers, size, onProgress); } const downloadBinary = (url, headers, onProgress) => new Promise((resolve, reject) => GM_xmlhttpRequest({ method: 'GET', url, headers, responseType: 'blob', timeout: 180000, onprogress: event => onProgress?.(event.loaded || 0, event.total || 0), onload: response => { if (response.status >= 200 && response.status < 300 && response.response) resolve(response.response); else reject(new Error(`文件请求返回 HTTP ${response.status}`)); }, onerror: () => reject(new Error('文件网络请求失败')), ontimeout: () => reject(new Error('文件下载超时')) })); function saveBlob(blob, fileName, openInTab) { const blobUrl = URL.createObjectURL(blob); const keepFor = openInTab ? 60000 : 30000; if (openInTab) window.open(blobUrl, '_blank', 'noopener'); else directDownload(blobUrl, fileName); setTimeout(() => URL.revokeObjectURL(blobUrl), keepFor); showToast(openInTab ? '已在新标签页打开 PDF' : `开始保存:${fileName}(${formatBytes(blob.size)})`); } async function downloadPdf(openInTab = false) { if (state.busy) return; state.busy = true; state.progress = { percent: 0, loaded: 0, total: 0, speed: 0 }; renderPanel(); const updateProgress = (loaded, total, speed = 0) => { state.progress = { percent: total ? Math.min(99, Math.floor(loaded / total * 100)) : 0, loaded, total, speed }; renderPanel(); }; try { const source = state.source || await resolveSource(true); if (!source) throw new Error(state.error || '未找到 PDF 源文件,请刷新页面后重试'); const fileName = source.fileName || `${getCleanTitle()}.pdf`; let blob; if (source.type === 'pdf_app') { blob = new Blob([await source.app.pdfDocument.getData()], { type: 'application/pdf' }); } else { try { let lastLoaded = 0; let lastTime = Date.now(); blob = await downloadWithProgress(source.url, source.headers || {}, (loaded, total, reportedSpeed) => { const now = Date.now(); updateProgress(loaded, total, reportedSpeed || (loaded - lastLoaded) / Math.max(1, now - lastTime) * 1000); lastLoaded = loaded; lastTime = now; }); } catch (error) { console.warn('[smartedu] 查看器流式下载失败,改用 GM 请求', error); try { blob = await downloadBinary(source.url, source.headers || {}, updateProgress); } catch (gmError) { console.warn('[smartedu] GM 请求也失败,回退浏览器直链下载', gmError); if (openInTab) window.open(source.url, '_blank', 'noopener'); else directDownload(source.url, fileName); showToast('已交给浏览器下载;若未开始,请允许此站点的下载'); return; } } } saveBlob(blob, fileName, openInTab); state.progress = { percent: 100, loaded: blob.size, total: blob.size, speed: 0 }; } catch (error) { console.error('[smartedu] 下载失败', error); state.error = error.message || '未知错误'; showToast(`下载失败:${state.error}`); } finally { state.busy = false; setTimeout(() => { state.progress = null; renderPanel(); }, 1200); renderPanel(); } } const statusClass = () => state.error ? 'nex-status--error' : state.source ? 'nex-status--ok' : 'nex-status--warn'; function renderPanel() { if (!document.body || !isDetailPage()) { document.getElementById(PANEL_ID)?.remove(); return; } injectStyle(); let panel = document.getElementById(PANEL_ID); if (!panel) { panel = document.createElement('section'); panel.id = PANEL_ID; document.body.appendChild(panel); restorePosition(panel); bindDrag(panel); } panel.classList.toggle('nex-is-collapsed', state.collapsed); if (state.collapsed) { panel.innerHTML = '
教材下载
'; bindActions(panel); return; } const progress = state.progress || { percent: 0, loaded: 0, total: 0, speed: 0 }; const remaining = state.busy ? (progress.total ? `剩余 ${formatBytes(Math.max(0, progress.total - progress.loaded))}` : '正在建立下载连接') : (progress.percent === 100 ? '文件已准备好' : '等待开始下载'); const loginHint = !getAccessToken() && !state.source ? '未检测到登录令牌,请先登录' : state.error || state.status; panel.innerHTML = `
教材下载助手PDF TOOL / ${state.source ? 'READY' : 'SCANNING'}
${loginHint}
${progress.percent}%${(progress.speed / 1024 / 1024).toFixed(1)} MB/s
${formatBytes(progress.loaded)} / ${formatBytes(progress.total)}${remaining}
给维护留盏灯
你的支持会用于持续修复和适配。
微信收款码微信
支付宝收款码支付宝
`; bindActions(panel); } const setCollapsed = value => { state.collapsed = value; GM_setValue(COLLAPSED_KEY, value); renderPanel(); }; function bindActions(panel) { const actions = { collapse: () => setCollapsed(true), expand: () => setCollapsed(false), download: () => downloadPdf(false), preview: () => downloadPdf(true), refresh: async event => { event.currentTarget.disabled = true; state.refreshing = true; state.source = null; state.sourceCheckedAt = 0; renderPanel(); try { await resolveSource(true); } finally { state.refreshing = false; renderPanel(); } } }; for (const button of panel.querySelectorAll('[data-action]')) button.addEventListener('click', actions[button.dataset.action]); } function restorePosition(panel) { const saved = GM_getValue(POSITION_KEY, null); const hasAnchor = saved && Number.isFinite(saved.left) && Number.isFinite(saved.top); if (hasAnchor) { const maxLeft = Math.max(0, window.innerWidth - panel.offsetWidth); const maxTop = Math.max(0, window.innerHeight - panel.offsetHeight); panel.style.left = `${Math.max(0, Math.min(maxLeft, saved.left))}px`; panel.style.top = `${Math.max(0, Math.min(maxTop, saved.top))}px`; return; } panel.style.right = '20px'; panel.style.bottom = '20px'; } function bindDrag(panel) { panel.addEventListener('mousedown', event => { if (!event.target.closest('.nex-titlebar') || event.target.closest('button')) return; const anchor = panel.getBoundingClientRect(); const offsetX = event.clientX; const offsetY = event.clientY; state.dragging = true; panel.style.left = `${anchor.left}px`; panel.style.top = `${anchor.top}px`; panel.style.right = 'auto'; panel.style.bottom = 'auto'; const onMove = event2 => { if (!state.dragging) return; const x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, anchor.left + event2.clientX - offsetX)); const y = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, anchor.top + event2.clientY - offsetY)); panel.style.left = `${x}px`; panel.style.top = `${y}px`; }; const onUp = () => { state.dragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); GM_setValue(POSITION_KEY, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) }); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); event.preventDefault(); }); } document.addEventListener('keydown', event => { if (event.key.toLowerCase() !== 'm' || event.ctrlKey || event.metaKey || event.altKey) return; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName) || event.target.isContentEditable) return; if (!isDetailPage()) return; event.preventDefault(); if (state.source) downloadPdf(false); else resolveSource(true).then(renderPanel); }); async function init() { if (!isDetailPage()) return; renderPanel(); await resolveSource(); renderPanel(); setInterval(() => { if (isDetailPage() && !state.busy) resolveSource().then(renderPanel); }, 1500); } init(); })();