// ==UserScript== // @name 超星学习通课件下载归类 // @namespace https://docs.scriptcat.org/ // @version 2.1.3 // @description 一键下载学习通课件 // @author ChatGPT // @match *://*.chaoxing.com/* // @match *://pan-yz.chaoxing.com/* // @match *://*.xueyinonline.com/* // @icon http://pan-yz.chaoxing.com/favicon.ico // @run-at document-end // @license MIT // @grant GM_download // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect * // ==/UserScript== (() => { 'use strict'; const ID = 'cx-fileinfo-dl'; const DEPTH = 5; const GAP = 650; const MAX_SCAN_ROUNDS = 10; const MSG_REQ = '__CX_FILEINFO_DL_SCAN_REQ__'; const MSG_RES = '__CX_FILEINFO_DL_SCAN_RES__'; const TYPE_EXTS = { PPT: ['ppt', 'pptx', 'pps', 'ppsx'], DOC: ['doc', 'docx', 'wps', 'rtf', 'txt'], PDF: ['pdf'], MP4: ['mp4', 'm4v', 'mov', 'flv', 'avi', 'wmv', 'webm'], AUDIO: ['mp3', 'm4a', 'wav', 'aac', 'ogg', 'flac'], SHEET: ['xls', 'xlsx', 'csv'], ARCHIVE: ['zip', 'rar', '7z'], IMAGE: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'], OTHER: [] }; const HTML_EXTS = ['html', 'htm', 'shtml', 'xhtml', 'jsp', 'asp', 'aspx', 'php']; const DEFAULTS = { settingsVersion: 4, subjectName: '', waitMs: 1800, gapMs: 500, concurrent: 1, dedupe: true, selectedTypes: { PPT: true, DOC: true, PDF: true, MP4: false, AUDIO: false, SHEET: false, ARCHIVE: false, IMAGE: false, OTHER: false } }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const isTop = () => { try { return window.top === window; } catch (_) { return true; } }; const topWindow = () => { try { return window.top || window; } catch (_) { return window; } }; const storeKey = (key) => `${ID}:${key}`; const safeJSON = (text, fallback) => { try { return JSON.parse(text); } catch (_) { return fallback; } }; const gmGet = (key, fallback) => { try { if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); } catch (_) {} const raw = localStorage.getItem(storeKey(key)); return raw == null ? fallback : safeJSON(raw, fallback); }; const gmSet = (key, value) => { try { if (typeof GM_setValue === 'function') return GM_setValue(key, value); } catch (_) { localStorage.setItem(storeKey(key), JSON.stringify(value)); } }; const mergeSettings = (saved) => { const s = Object.assign({}, DEFAULTS, saved || {}); s.selectedTypes = Object.assign({}, DEFAULTS.selectedTypes, (saved || {}).selectedTypes || {}); return s; }; const savedSettings = gmGet('settings', {}); let settings = mergeSettings(savedSettings); if (!savedSettings.settingsVersion || savedSettings.settingsVersion < DEFAULTS.settingsVersion) { settings.selectedTypes.MP4 = false; settings.subjectName = ''; settings.settingsVersion = DEFAULTS.settingsVersion; gmSet('settings', settings); } let history = new Set(gmGet('history', [])); let activeSubjectName = ''; let lastConfirmedSubjectName = ''; let previewList = []; let queue = []; let running = 0; let stopping = false; let panel = null; let lastLog = '等待点击开始。不会自动下载。'; const pendingByToken = new Map(); function saveSettings() { gmSet('settings', Object.assign({}, settings, { subjectName: '' })); } function saveHistory() { gmSet('history', Array.from(history).slice(-8000)); } function isTargetPage() { const href = location.href; const host = location.hostname; return /chaoxing\.com|xueyinonline\.com|pan-yz\.chaoxing\.com/i.test(host) || ['/mycourse/studentstudy', '/mooc-ans/knowledge/cards', '/mooc2-ans/knowledge/cards', 'pan-yz.chaoxing.com/screen/file_'] .some((p) => href.includes(p)); } function normalizeUrl(input, base) { if (!input || typeof input !== 'string') return ''; let url = input.trim().replace(/&/g, '&').replace(/\\\//g, '/').replace(/^['"]|['"]$/g, ''); if (!url || /^(javascript|data|blob):/i.test(url)) return ''; if (url.startsWith('//')) url = location.protocol + url; try { return new URL(url, base || location.href).href; } catch (_) { return ''; } } function decodeMaybe(text) { if (!text) return ''; let v = String(text); for (let i = 0; i < 2; i++) { try { const d = decodeURIComponent(v); if (d === v) break; v = d; } catch (_) { break; } } return v; } function extFromText(text) { const v = decodeMaybe(String(text || '')).split(/[?#]/)[0].trim(); const m = v.match(/\.([a-zA-Z0-9]{1,8})$/); return m ? m[1].toLowerCase() : ''; } function stripDotExt(ext) { return String(ext || '').replace(/^\./, '').toLowerCase().trim(); } function typeFromExt(ext) { const e = stripDotExt(ext); for (const [type, exts] of Object.entries(TYPE_EXTS)) { if (type !== 'OTHER' && exts.includes(e)) return type; } return 'OTHER'; } function allowedExt(ext) { return typeFromExt(ext) !== 'OTHER'; } function sanitize(text, fallback) { const s = String(text || '') .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/[\\/:*?"<>|]+/g, '_') .replace(/\s+/g, ' ') .replace(/^\.+|\.+$/g, '') .trim(); const v = s || fallback || '未命名'; return v.length > 90 ? v.slice(0, 90).trim() : v; } function fileNameFromUrl(url) { try { const u = new URL(url); const p = u.searchParams; const fromParam = p.get('filename') || p.get('fileName') || p.get('name') || p.get('fname') || p.get('attname'); if (fromParam) return decodeMaybe(fromParam); const last = decodeMaybe(u.pathname.split('/').pop() || ''); return last.includes('.') ? last : ''; } catch (_) { return ''; } } function compactUrlCandidates(list, base) { const seen = new Set(); return list .flat() .filter(Boolean) .map((u) => normalizeUrl(String(u), base)) .filter(Boolean) .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; }); } function infoUrlCandidates(info, doc) { const base = doc?.location?.href || location.href; return compactUrlCandidates([ info?.download, info?.downloadUrl, info?.downurl, info?.fileUrl, info?.fileurl, info?.url, info?.http, info?.https, info?.mp4, info?.videoUrl, info?.videourl, info?.playUrl, info?.playurl, info?.m3u8 ], base); } function extFromUrlCandidate(url) { return stripDotExt(extFromText(fileNameFromUrl(url)) || extFromText(url)); } function isHtmlLikeUrl(url) { const plain = decodeMaybe(String(url || '')).split(/[?#]/)[0].toLowerCase(); const urlExt = stripDotExt(extFromText(plain)); return HTML_EXTS.includes(urlExt) || /\/screen\/file_/i.test(plain) || /\/preview\//i.test(plain) || /\/view\//i.test(plain); } function isDirectVideoUrl(url) { const ext = extFromUrlCandidate(url); return TYPE_EXTS.MP4.includes(ext) && !isHtmlLikeUrl(url); } function detectedSubjectFromPage() { const doc = (() => { try { return topWindow().document || document; } catch (_) { return document; } })(); const selectors = [ '.courseName', '.course-name', '.coursename', '#courseName', '#course_name', '.course_title', '.course-title', '.posCourseName', '.curriculum-title', '[class*="courseName"]', '[class*="course-name"]' ]; for (const sel of selectors) { const el = doc.querySelector(sel); const text = el?.getAttribute('title') || el?.textContent; const name = sanitize(text, ''); if (name && !isBadSubjectName(name)) return name; } const titleName = (doc.title || document.title || '') .replace(/学习通|超星|课程学习|章节|任务点|[-_]/g, ' ') .replace(/\s+/g, ' ') .trim(); return titleName && !isBadSubjectName(titleName) ? titleName : ''; } function isBadSubjectName(name) { const v = String(name || '').replace(/\s+/g, ' ').trim(); return !v || /^(未知科目|未命名|学生学习页面|学习页面|课程门户)$/i.test(v); } function currentSubject() { const active = sanitize(activeSubjectName, ''); if (!isBadSubjectName(active)) return active; const manual = sanitize(settings.subjectName, ''); if (!isBadSubjectName(manual)) return manual; return detectedSubjectFromPage() || '未知科目'; } function ensureSubjectNameBeforeDownload() { const subjectInput = document.getElementById(`${ID}-subject`); const typed = sanitize(subjectInput?.value, ''); const suggested = detectedSubjectFromPage(); const defaultValue = !isBadSubjectName(suggested) ? suggested : (typed && typed !== lastConfirmedSubjectName ? typed : ''); const input = prompt( '请填写/确认本次下载的课程名称。下载目录将使用:课程名 / 文件类型 / 章节名_文件名。\n\n为避免切换课程后沿用上一次课程名,本提示每次开始下载都会出现。', defaultValue ); const subject = sanitize(input, ''); if (isBadSubjectName(subject)) { log('已取消下载:请先填写有效课程名称,不能使用“学生学习页面”。'); subjectInput?.focus(); return ''; } activeSubjectName = subject; lastConfirmedSubjectName = subject; settings.subjectName = subject; if (subjectInput) subjectInput.value = subject; log(`本次下载课程名:${subject}`); return subject; } function currentChapter(doc = document) { const topDoc = (() => { try { return topWindow().document || document; } catch (_) { return document; } })(); const selectors = [ '.posCatalog_active .posCatalog_name', '.posCatalog_active', '.prev_ul li.active', '#prev_tab .prev_ul li.active', '.chapterText.active', '.chapterText' ]; for (const sel of selectors) { const el = topDoc.querySelector(sel) || doc.querySelector(sel); const text = el?.getAttribute('title') || el?.textContent; if (text && text.trim()) return text.replace(/\s+/g, ' ').trim(); } return '未分章节'; } function resourceFromFileinfo(info, doc) { const urlCandidates = infoUrlCandidates(info, doc); if (!urlCandidates.length) return null; const nameCandidates = [ info.name, info.filename, info.fileName, info.title, info.extname, info.fileNameNoExt && info.suffix ? `${info.fileNameNoExt}.${stripDotExt(info.suffix)}` : '', ...urlCandidates.map((u) => fileNameFromUrl(u)) ].filter(Boolean); let name = decodeMaybe(nameCandidates[0] || '未命名资源'); let ext = stripDotExt( extFromText(name) || info.suffix || info.ext || info.filetype || info.type || urlCandidates.map((u) => extFromUrlCandidate(u)).find(Boolean) ); const nameExt = extFromText(name); const type = typeFromExt(ext); if (HTML_EXTS.includes(stripDotExt(nameExt))) return null; if (type === 'OTHER' && !settings.selectedTypes.OTHER) return null; if (!settings.selectedTypes[type]) return null; let url = urlCandidates.find((u) => !isHtmlLikeUrl(u)) || ''; if (type === 'MP4') { url = urlCandidates.find(isDirectVideoUrl) || ''; if (!url) return null; } const urlExt = extFromUrlCandidate(url); if (HTML_EXTS.includes(urlExt)) return null; if (ext && !extFromText(name) && !HTML_EXTS.includes(ext)) name += `.${ext}`; if (!ext && type !== 'OTHER') ext = TYPE_EXTS[type][0] || ''; return { url, name: sanitize(name, `resource.${ext || 'bin'}`), ext, type, subject: sanitize(currentSubject(), '未知科目'), chapter: sanitize(currentChapter(doc), '未分章节') }; } function scanFileinfoOnly(doc = document, depth = 0) { if (!doc || depth > DEPTH) return []; const result = []; try { const w = doc.defaultView || window; if (w?.fileinfo?.download) { const resource = resourceFromFileinfo(w.fileinfo, doc); if (resource) result.push(resource); } } catch (_) {} for (const frame of Array.from(doc.getElementsByTagName('iframe'))) { try { const w = frame.contentWindow; if (w?.fileinfo?.download) { const resource = resourceFromFileinfo(w.fileinfo, w.document || doc); if (resource) result.push(resource); } if (w?.document) result.push(...scanFileinfoOnly(w.document, depth + 1)); } catch (_) {} } return dedupe(result); } function dedupe(list) { const seen = new Set(); return list.filter((item) => { if (!item?.url) return false; const key = `${item.url}::${item.name}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function hashKey(text) { let h = 2166136261; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36); } function sendScanResult(token) { const list = scanFileinfoOnly(); try { window.parent?.postMessage({ type: MSG_RES, token, resources: list }, '*'); } catch (_) {} } function askChildFrames(token) { for (const frame of Array.from(document.getElementsByTagName('iframe'))) { try { frame.contentWindow?.postMessage({ type: MSG_REQ, token }, '*'); } catch (_) {} } } async function scanWithScript1Logic(reason) { const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`; pendingByToken.set(token, []); const all = []; for (let i = 0; i < MAX_SCAN_ROUNDS; i++) { all.push(...scanFileinfoOnly()); if (isTop()) askChildFrames(token); await sleep(i === 0 ? 450 : GAP); all.push(...(pendingByToken.get(token) || [])); const got = dedupe(all); if (got.length) { pendingByToken.delete(token); log(`${reason}:发现 ${got.length} 个 fileinfo.download 资源。`); return got; } } const finalList = dedupe(all); pendingByToken.delete(token); log(finalList.length ? `${reason}:发现 ${finalList.length} 个资源。` : `${reason}:没有发现 fileinfo.download。请先打开课件预览页/章节任务点。`); return finalList; } function pathFor(res) { const subject = sanitize(res.subject || activeSubjectName || currentSubject(), '未知科目'); const type = sanitize(res.type || typeFromExt(res.ext), 'OTHER'); const chapter = sanitize(res.chapter || currentChapter(), '未分章节'); const originalName = sanitize(res.name, `resource.${res.ext || 'bin'}`); const ext = extFromText(originalName) || stripDotExt(res.ext); const extPart = ext ? `.${ext}` : ''; const baseName = extPart && originalName.toLowerCase().endsWith(extPart.toLowerCase()) ? originalName.slice(0, -extPart.length) : originalName; const shouldPrefixChapter = chapter && chapter !== '未分章节' && !baseName.includes(chapter); const finalBaseName = shouldPrefixChapter ? `${chapter}_${baseName}` : baseName; const filename = `${sanitize(finalBaseName, '未命名')}${extPart}`; return `${subject}/${type}/${filename}`; } function enqueue(list) { let added = 0; let skipped = 0; for (const res of dedupe(list)) { const fixedRes = Object.assign({}, res, { subject: sanitize(res.subject || activeSubjectName || currentSubject(), '未知科目') }); const key = hashKey(fixedRes.url); if (settings.dedupe && history.has(key)) { skipped++; continue; } queue.push({ res: fixedRes, key }); added++; } log(`加入下载队列 ${added} 个,跳过 ${skipped} 个。`); updateStats(); pumpQueue(); } function downloadOne(task) { return new Promise((resolve) => { const res = task.res; const filename = pathFor(res); if (typeof GM_download !== 'function') { const a = document.createElement('a'); a.href = res.url; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); history.add(task.key); saveHistory(); resolve({ ok: true, mode: 'a' }); return; } try { GM_download({ url: res.url, name: filename, saveAs: false, conflictAction: 'uniquify', headers: { Referer: location.href }, onload: () => { history.add(task.key); saveHistory(); resolve({ ok: true, mode: 'gm' }); }, onerror: (err) => resolve({ ok: false, err }), ontimeout: () => resolve({ ok: false, err: 'timeout' }) }); } catch (err) { resolve({ ok: false, err }); } }); } async function pumpQueue() { while (!stopping && running < Math.max(1, Number(settings.concurrent) || 1) && queue.length) { const task = queue.shift(); running++; updateStats(); downloadOne(task).then(async (result) => { running--; if (result.ok) log(`已提交下载:${pathFor(task.res)}`); else log(`下载失败:${task.res.name},可稍后重试。`); updateStats(); await sleep(Math.max(200, Number(settings.gapMs) || 900)); pumpQueue(); }); } } async function scanPageOnly() { previewList = await scanWithScript1Logic('扫描本页'); renderPreview(previewList); } async function startCurrentPageDownload() { const subject = ensureSubjectNameBeforeDownload(); if (!subject) return; stopping = false; const list = await scanWithScript1Logic('开始下载本页'); const withSubject = list.map((r) => Object.assign({}, r, { subject })); previewList = withSubject; renderPreview(withSubject); enqueue(withSubject); } function getChapterItems() { const doc = document; const candidates = Array.from(doc.querySelectorAll('[onclick^="getTeacherAjax"], [onclick*="getTeacherAjax"], .posCatalog_name, .chapterText')); const items = []; const seen = new Set(); for (const el of candidates) { const clickable = el.matches?.('[onclick^="getTeacherAjax"], [onclick*="getTeacherAjax"]') ? el : (el.closest?.('[onclick^="getTeacherAjax"], [onclick*="getTeacherAjax"]') || el); const container = clickable.closest?.('.posCatalog_select, .posCatalog_active, li, .chapter, .chapterText') || clickable.parentElement || clickable; const textEl = container.querySelector?.('.posCatalog_name, .chapterText') || clickable; const title = (textEl.getAttribute?.('title') || textEl.textContent || '').replace(/\s+/g, ' ').trim(); const onclick = clickable.getAttribute?.('onclick') || container.getAttribute?.('onclick') || ''; const m = onclick.match(/getTeacherAjax\(['"]?([^'")]+)['"]?\s*,\s*['"]?([^'")]+)['"]?\s*,\s*['"]?([^'")]+)['"]?/); const id = m?.[3] || container.id || title; if (!title || seen.has(id)) continue; seen.add(id); items.push({ title, element: clickable, courseId: m?.[1], classId: m?.[2], chapterId: m?.[3] }); } return items; } async function openChapter(item) { log(`进入章节:${item.title}`); try { if (typeof window.getTeacherAjax === 'function' && item.courseId && item.classId && item.chapterId) { window.getTeacherAjax(item.courseId, item.classId, item.chapterId); } else { item.element.scrollIntoView?.({ block: 'center' }); item.element.click(); } } catch (_) { try { item.element.click(); } catch (__) {} } await sleep(Math.max(1000, Number(settings.waitMs) || 3000)); } async function startCourseDownload() { const subject = ensureSubjectNameBeforeDownload(); if (!subject) return; const chapters = getChapterItems(); if (!chapters.length) { log('没找到章节列表。请进入课程章节页后再点“整课下载”。'); return; } stopping = false; log(`开始整课下载:共 ${chapters.length} 个章节。`); for (let i = 0; i < chapters.length; i++) { if (stopping) break; await openChapter(chapters[i]); const list = await scanWithScript1Logic(`章节 ${i + 1}/${chapters.length}`); const withChapter = list.map((r) => Object.assign({}, r, { subject, chapter: sanitize(chapters[i].title, '未分章节') })); previewList = dedupe(previewList.concat(withChapter)).slice(-80); renderPreview(previewList); enqueue(withChapter); await sleep(600); } log(stopping ? '已停止整课下载。' : '整课扫描已结束,剩余队列会继续提交下载。'); stopping = false; } function stopAll() { stopping = true; queue = []; log('已停止后续扫描和队列提交;已经交给浏览器的下载不会撤回。'); updateStats(); } function clearHistory() { history = new Set(); gmSet('history', []); log('已清空去重记录。'); updateStats(); } function escapeHtml(text) { return String(text || '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function log(text) { lastLog = text; console.log('[CX fileinfo downloader]', text); const el = document.getElementById(`${ID}-log`); if (el) el.textContent = text; updateStats(); } function updateStats() { const el = document.getElementById(`${ID}-stats`); if (!el) return; el.textContent = `队列 ${queue.length} / 下载中 ${running} / 已记录 ${history.size}`; } function renderPreview(list) { const box = document.getElementById(`${ID}-preview`); if (!box) return; const rows = dedupe(list).slice(0, 12); if (!rows.length) { box.innerHTML = '
暂无资源。先打开课件预览页或点击开始扫描。
'; return; } box.innerHTML = rows.map((r) => `
${escapeHtml(r.type)} ${escapeHtml(r.name)}
${escapeHtml(pathFor(r))}
`).join(''); } function bindDrag(el, handle) { let sx = 0, sy = 0, ox = 0, oy = 0; const move = (e) => { e.preventDefault(); const x = Math.max(0, Math.min(ox + e.clientX - sx, innerWidth - el.offsetWidth)); const y = Math.max(0, Math.min(oy + e.clientY - sy, innerHeight - el.offsetHeight)); el.style.left = `${x}px`; el.style.top = `${y}px`; el.style.right = 'auto'; el.style.bottom = 'auto'; }; const up = (e) => { try { handle.releasePointerCapture(e.pointerId); } catch (_) {} handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); handle.removeEventListener('pointercancel', up); }; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); sx = e.clientX; sy = e.clientY; const rect = el.getBoundingClientRect(); ox = rect.left; oy = rect.top; try { handle.setPointerCapture(e.pointerId); } catch (_) {} handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up); handle.addEventListener('pointercancel', up); }); } function renderUI() { if (!isTop()) return; document.getElementById(ID)?.remove(); panel = document.createElement('div'); panel.id = ID; const checks = Object.keys(settings.selectedTypes).map((type) => { const checked = settings.selectedTypes[type] ? 'checked' : ''; return ``; }).join(''); panel.innerHTML = `
⇩ 课件下载 fileinfo版
不会自动下载;点击开始后会再次确认课程名。目录:课程名 / 类型 / 章节_文件名
${checks}
${escapeHtml(lastLog)}
暂无资源。先点击扫描或开始。
`; document.body.appendChild(panel); bindDrag(panel, panel.querySelector('.hd')); bindUI(); updateStats(); } function bindUI() { const $ = (suffix) => document.getElementById(`${ID}-${suffix}`); panel.querySelector('.toggle').addEventListener('click', () => panel.classList.toggle('min')); $('subject').addEventListener('change', (e) => { settings.subjectName = e.target.value.trim(); log(`待确认课程名:${settings.subjectName || '下载前填写'}`); }); $('wait').addEventListener('change', (e) => { settings.waitMs = Math.max(1000, Number(e.target.value) || DEFAULTS.waitMs); saveSettings(); }); $('gap').addEventListener('change', (e) => { settings.gapMs = Math.max(200, Number(e.target.value) || DEFAULTS.gapMs); saveSettings(); }); $('dedupe').addEventListener('change', (e) => { settings.dedupe = e.target.checked; saveSettings(); }); panel.querySelectorAll('input[data-type]').forEach((box) => { box.addEventListener('change', (e) => { settings.selectedTypes[e.target.dataset.type] = e.target.checked; saveSettings(); }); }); $('scan').addEventListener('click', scanPageOnly); $('start').addEventListener('click', startCurrentPageDownload); $('course').addEventListener('click', startCourseDownload); $('stop').addEventListener('click', stopAll); $('clear').addEventListener('click', clearHistory); } function registerMessages() { window.addEventListener('message', (event) => { const data = event.data; if (!data || typeof data !== 'object') return; if (data.type === MSG_REQ && data.token && !isTop()) { sendScanResult(data.token); setTimeout(() => sendScanResult(data.token), 700); return; } if (data.type === MSG_RES && data.token && isTop()) { const list = pendingByToken.get(data.token); if (list) pendingByToken.set(data.token, list.concat(Array.isArray(data.resources) ? data.resources : [])); } }); } function registerMenus() { if (typeof GM_registerMenuCommand !== 'function' || !isTop()) return; GM_registerMenuCommand('显示/隐藏课件下载面板', () => { const old = document.getElementById(ID); if (old) old.remove(); else renderUI(); }); GM_registerMenuCommand('扫描本页 fileinfo.download', scanPageOnly); GM_registerMenuCommand('清空下载去重记录', clearHistory); } async function init() { if (!isTargetPage()) return; registerMessages(); await sleep(250); if (isTop()) { renderUI(); registerMenus(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();