// ==UserScript== // @name 维咔ACG增强 // @namespace http://tampermonkey.net/ // @version 2.0 // @description 维咔ACG的每日签到、预览图增强、直链跳转、隐藏搜索结果的广告帖子 // @author 临渊 // @match *://*.vikacg.com/* // @match *://*.vikacg.cc/* // @match *://*.vikacg.xyz/* // @match *://*.vikacg.net/* // @match *://*.vikacg.org/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @connect * // @run-at document-start // @license AGPL-3.0 // ==/UserScript== /* ==UserConfig== preview: count: title: 预览图片数 description: 设置为 0 可关闭列表预览增强 type: number default: 4 min: 0 max: 6 concurrent: title: 预览请求并发 description: 同时请求详情页图片的数量 type: number default: 6 min: 1 max: 12 ==/UserConfig== */ (function() { 'use strict'; const LOG_PREFIX = '[维咔ACG增强]'; debugLog('log', `${LOG_PREFIX} 脚本已加载`); const urlMap = {}; const SIGN_STATE_KEY = 'vikacgEnhanced.signState'; const AUTH_HEADER_KEY = 'vikacgEnhanced.authorization'; const PREVIEW_CACHE_PREFIX = 'vikacgEnhanced.preview.v4.'; const PREVIEW_COUNT_KEY = 'preview.count'; const PREVIEW_CONCURRENT_KEY = 'preview.concurrent'; const PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const PREVIEW_DEFAULT_COUNT = 4; const PREVIEW_MAX_COUNT = 12; const PREVIEW_AUTH_RETRY_MAX = 8; const PREVIEW_AUTH_RETRY_DELAY_MS = 1000; const SIGN_LOCK_MS = 2 * 60 * 1000; const SIGN_MAX_DAILY = 2; const MAX_MAPPING_RETRY_WITHOUT_PROGRESS = 3; const RECOMMEND_BADGE_TEXT = '推荐'; const RECOMMEND_HIDDEN_CLASS = 'vikacg-enhanced-recommend-hidden'; let isLoading = false; let isSigning = false; let mappedPostId = null; let routePath = getRouteKey(); let rawContentCache = { postId: null, content: '' }; let mappingRetryTimer = null; let mappingRetryState = { postId: null, signature: '', attempts: 0, stopped: false }; let previewRetryTimer = null; let previewFetchObserver = null; let previewPending = 0; let previewQueue = []; let previewScanLogState = { signature: '', time: 0 }; let mutationObserver = null; let appStarted = false; // 通用工具和日志:日志会同时写入控制台与页面对象,方便在沙盒脚本里排查。 function getPageWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function serializeLogArg(arg) { if (arg instanceof Error) return `${arg.name}: ${arg.message}`; if (typeof arg === 'string') return arg; try { return JSON.parse(JSON.stringify(arg)); } catch (e) { return String(arg); } } function debugLog(level, ...args) { const method = console[level] ? level : 'log'; try { console[method](...args); } catch (e) { // 保持日志函数自身不影响主逻辑。 } try { const target = getPageWindow(); const targetConsole = target.console; if (targetConsole && targetConsole !== console && targetConsole[method]) { targetConsole[method](...args); } const logs = target.__vikacgEnhancedLogs || []; logs.push({ time: new Date().toISOString(), level: method, args: args.map(serializeLogArg), }); if (logs.length > 300) logs.splice(0, logs.length - 300); target.__vikacgEnhancedLogs = logs; } catch (e) { // 页面窗口不可写时忽略。 } } function getPostId() { const match = window.location.pathname.match(/\/p\/(\d+)/); return match ? match[1] : null; } function getApiBase() { return window.location.origin; } function getRouteKey() { return `${window.location.pathname}${window.location.search}`; } function clearMappings() { Object.keys(urlMap).forEach(key => delete urlMap[key]); mappedPostId = null; rawContentCache = { postId: null, content: '' }; resetMappingRetryState(); } function ensureToastContainer() { let container = document.getElementById('vikacg-enhanced-toast'); if (container) return container; if (!document.getElementById('vikacg-enhanced-toast-style')) { const style = document.createElement('style'); style.id = 'vikacg-enhanced-toast-style'; style.textContent = ` #vikacg-enhanced-toast { position: fixed; top: 18px; right: 18px; z-index: 2147483647; display: flex; flex-direction: column; gap: 10px; max-width: min(360px, calc(100vw - 32px)); pointer-events: none; } .vikacg-enhanced-toast-item { padding: 10px 12px; border-radius: 8px; color: #fff; font-size: 14px; line-height: 1.45; box-shadow: 0 8px 24px rgba(0, 0, 0, .18); pointer-events: auto; opacity: .98; } .vikacg-enhanced-toast-success { background: #18a058; } .vikacg-enhanced-toast-info { background: #2080f0; } .vikacg-enhanced-toast-warning { background: #f0a020; } .vikacg-enhanced-toast-error { background: #d03050; } `; document.head.appendChild(style); } container = document.createElement('div'); container.id = 'vikacg-enhanced-toast'; document.body.appendChild(container); return container; } function showToast(message, type = 'info', timeout = 5000) { if (!document.body) return; const container = ensureToastContainer(); const item = document.createElement('div'); item.className = `vikacg-enhanced-toast-item vikacg-enhanced-toast-${type}`; item.textContent = message; container.appendChild(item); setTimeout(() => { item.remove(); if (!container.children.length) container.remove(); }, timeout); } function gmGet(key, fallback) { if (typeof GM_getValue === 'function') { return GM_getValue(key, fallback); } try { const value = localStorage.getItem(key); return value ? JSON.parse(value) : fallback; } catch (e) { return fallback; } } function gmSet(key, value) { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { debugLog('warn', `${LOG_PREFIX} 保存脚本状态失败:`, e); } } function clampNumber(value, fallback, min, max) { const number = parseInt(value, 10); if (!Number.isFinite(number)) return fallback; return Math.min(max, Math.max(min, number)); } function getPreviewCount() { return clampNumber(gmGet(PREVIEW_COUNT_KEY, PREVIEW_DEFAULT_COUNT), PREVIEW_DEFAULT_COUNT, 0, PREVIEW_MAX_COUNT); } function getPreviewMaxConcurrent() { return clampNumber(gmGet(PREVIEW_CONCURRENT_KEY, 3), 3, 1, 6); } // 签到时间段与 authorization 复用:站内 API、详情页、图床请求都统一补这个 header。 function getTodayKey() { const date = new Date(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${date.getFullYear()}-${month}-${day}`; } function getSignSlot() { return new Date().getHours() < 12 ? 'am' : 'pm'; } function getCookieValue(name) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : ''; } function cleanTokenText(value) { if (!value || typeof value !== 'string') return ''; return value.trim() .replace(/^Bearer\s+/i, '') .replace(/^["']|["']$/g, ''); } function saveCapturedAuthorization(value, source) { const token = cleanTokenText(value); if (!token) return; gmSet(AUTH_HEADER_KEY, { token, source, capturedAt: Date.now() }); debugLog('log', `${LOG_PREFIX} 已捕获 authorization:`, source, `length=${token.length}`); } function extractAuthorizationFromHeaders(headers) { if (!headers) return ''; try { if (typeof Headers !== 'undefined' && headers instanceof Headers) { return headers.get('authorization') || headers.get('Authorization') || ''; } } catch (e) { // Headers 可能来自页面上下文,instanceof 在沙盒里不可靠,继续用通用分支。 } if (Array.isArray(headers)) { const item = headers.find(([key]) => String(key).toLowerCase() === 'authorization'); return item ? item[1] : ''; } if (typeof headers === 'object') { for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === 'authorization') return value; } } return ''; } function extractAuthorizationFromPayload(payload) { if (!payload || typeof payload !== 'string' || !/authorization/i.test(payload)) return ''; try { const data = JSON.parse(payload); if (data && typeof data === 'object') { const value = data.authorization || data.Authorization; if (typeof value === 'string') return value; } } catch (e) { const match = payload.match(/["']authorization["']\s*:\s*["']([^"']+)["']/i); if (match) return match[1]; } return ''; } function installAuthorizationCapture() { const target = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; if (target.__vikacgEnhancedAuthCaptureInstalled) return; target.__vikacgEnhancedAuthCaptureInstalled = true; const originalFetch = target.fetch; if (typeof originalFetch === 'function') { target.fetch = function(input, init) { const headerAuth = extractAuthorizationFromHeaders(init && init.headers) || extractAuthorizationFromHeaders(input && input.headers); if (headerAuth) saveCapturedAuthorization(headerAuth, 'fetch:header'); const bodyAuth = extractAuthorizationFromPayload(init && init.body); if (bodyAuth) saveCapturedAuthorization(bodyAuth, 'fetch:body'); return originalFetch.apply(this, arguments); }; } const XHR = target.XMLHttpRequest; if (XHR && XHR.prototype) { const originalSetRequestHeader = XHR.prototype.setRequestHeader; const originalSend = XHR.prototype.send; XHR.prototype.setRequestHeader = function(name, value) { if (String(name).toLowerCase() === 'authorization') { saveCapturedAuthorization(value, 'xhr:header'); } return originalSetRequestHeader.apply(this, arguments); }; XHR.prototype.send = function(body) { const bodyAuth = extractAuthorizationFromPayload(body); if (bodyAuth) saveCapturedAuthorization(bodyAuth, 'xhr:body'); return originalSend.apply(this, arguments); }; } } function parseStoredToken(value, acceptPlainToken = false) { const text = cleanTokenText(value); if (!text) return ''; if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(text)) { return text; } if (acceptPlainToken && /^[^\s{}[\]"']{20,}$/.test(text)) { return text; } try { const data = JSON.parse(value); return findTokenInObject(data); } catch (e) { return ''; } } function findTokenInObject(value) { if (!value) return ''; if (typeof value === 'string') return parseStoredToken(value); if (Array.isArray(value)) { for (const item of value) { const token = findTokenInObject(item); if (token) return token; } return ''; } if (typeof value !== 'object') return ''; for (const [key, item] of Object.entries(value)) { if (/authorization|token/i.test(key)) { const token = findTokenInObject(item); if (token) return token; } } return ''; } function getAuthTokenInfo() { const captured = gmGet(AUTH_HEADER_KEY, null); if (captured && captured.token) { return { token: captured.token, source: captured.source || 'captured:authorization' }; } const authCookie = getCookieValue('authorization'); if (authCookie) return { token: cleanTokenText(authCookie), source: 'cookie:authorization' }; for (const storage of [localStorage, sessionStorage]) { const exactAuth = storage.getItem('authorization'); if (exactAuth) { const token = cleanTokenText(exactAuth); if (token) return { token, source: `${storage === localStorage ? 'localStorage' : 'sessionStorage'}:authorization` }; } for (let i = 0; i < storage.length; i++) { const key = storage.key(i); if (!key || !/authorization|token|auth|user/i.test(key)) continue; const acceptPlainToken = /authorization|(^|[._-])token$|access[_-]?token/i.test(key); const token = parseStoredToken(storage.getItem(key), acceptPlainToken); if (token) return { token, source: `${storage === localStorage ? 'localStorage' : 'sessionStorage'}:${key}` }; } } return { token: '', source: '' }; } function getAuthToken() { return getAuthTokenInfo().token; } function isVikacgRequestUrl(url) { try { const host = new URL(url, window.location.origin).hostname.toLowerCase(); return /(?:^|\.)vikacg\.(?:com|cc|xyz|net|org)$/i.test(host); } catch (e) { return false; } } function withVikacgAuthorization(url, headers = {}, token = '') { const next = { ...headers }; if (!isVikacgRequestUrl(url)) return next; const authToken = cleanTokenText(token || getAuthToken()); if (!authToken) return next; const hasAuthorization = Object.keys(next).some(key => key.toLowerCase() === 'authorization'); if (!hasAuthorization) { next.authorization = `Bearer ${authToken}`; } return next; } function getTokenId(token) { let hash = 0; for (let i = 0; i < token.length; i++) { hash = ((hash << 5) - hash + token.charCodeAt(i)) | 0; } return String(Math.abs(hash)); } // 签到:按上午/下午记录尝试,成功和失败都会避免同一时间段反复请求。 function getSignState(token) { const today = getTodayKey(); const tokenId = getTokenId(token); const state = gmGet(SIGN_STATE_KEY, {}); const userState = state[tokenId]; if (userState && userState.date === today) { userState.slots = userState.slots || {}; userState.attemptedSlots = userState.attemptedSlots || {}; Object.keys(userState.slots).forEach(slot => { userState.attemptedSlots[slot] = true; }); userState.doneCount = Object.keys(userState.slots).length; userState.attemptCount = Object.keys(userState.attemptedSlots).length; return { state, tokenId, userState }; } return { state, tokenId, userState: { date: today, slots: {}, attemptedSlots: {}, doneCount: 0, attemptCount: 0, lastAttemptAt: 0, lockUntil: 0 }, }; } function saveSignState(state, tokenId, userState) { state[tokenId] = userState; gmSet(SIGN_STATE_KEY, state); } function acquireSignLock(token) { const now = Date.now(); const slot = getSignSlot(); const { state, tokenId, userState } = getSignState(token); if (userState.attemptCount >= SIGN_MAX_DAILY || userState.attemptedSlots[slot]) { return { skipped: true, reason: `今日已尝试${userState.attemptCount}/${SIGN_MAX_DAILY}个时段,本时段不再重复请求` }; } if (userState.lockUntil && userState.lockUntil > now) { return { skipped: true, reason: '签到请求正在进行中,跳过重复触发' }; } userState.attemptedSlots[slot] = true; userState.attemptCount = Object.keys(userState.attemptedSlots).length; userState.lockUntil = now + SIGN_LOCK_MS; userState.lastAttemptAt = now; saveSignState(state, tokenId, userState); return { state, tokenId, userState, slot }; } function markSignDone(lock, message) { const { state, tokenId, userState, slot } = lock; userState.attemptedSlots = userState.attemptedSlots || {}; userState.attemptedSlots[slot] = true; userState.attemptCount = Object.keys(userState.attemptedSlots).length; if (!userState.slots[slot]) { userState.slots[slot] = true; userState.doneCount = Object.keys(userState.slots).length; } userState.lockUntil = 0; userState.lastMessage = message; saveSignState(state, tokenId, userState); } function releaseSignLock(lock, message) { const { state, tokenId, userState } = lock; userState.lockUntil = 0; userState.lastMessage = message; saveSignState(state, tokenId, userState); } function requestSign(token) { return new Promise((resolve, reject) => { const apiBase = getApiBase(); const url = `${apiBase}/api/vikacg/v1/userMission`; GM_xmlhttpRequest({ method: 'POST', url, headers: withVikacgAuthorization(url, { 'content-type': 'application/json', 'user-agent': navigator.userAgent, 'referer': `${apiBase}/wallet/mission`, }, token), cookie: document.cookie, data: JSON.stringify({}), timeout: 15000, onload(response) { debugLog('log', `${LOG_PREFIX} 签到响应:`, response.status, response.responseText); let data; try { data = JSON.parse(response.responseText); } catch (e) { data = null; } if (response.status < 200 || response.status >= 300) { reject(formatSignResult(data, `HTTP ${response.status}: ${(response.responseText || '').slice(0, 120)}`)); return; } if (data) { resolve(data); } else { reject('解析签到响应失败'); } }, onerror: reject, ontimeout() { reject('签到请求超时'); }, }); }); } function getResponseMessage(data) { if (!data || typeof data !== 'object') return ''; return data.message || data.msg || data.statusMessage || ''; } function formatSignResult(data, fallback = '') { if (!data || typeof data !== 'object') return fallback || '签到响应为空'; const parts = []; const responseMessage = getResponseMessage(data); if (responseMessage) parts.push(responseMessage); if (Object.prototype.hasOwnProperty.call(data, 'credit')) { parts.push(`获得积分:${data.credit}`); } if (data.mission && Object.prototype.hasOwnProperty.call(data.mission, 'my_credit')) { parts.push(`当前积分:${data.mission.my_credit}`); } return parts.length > 0 ? parts.join(',') : JSON.stringify(data); } async function runDailySign() { if (isSigning) return; const tokenInfo = getAuthTokenInfo(); const token = tokenInfo.token; if (!token) { const message = '签到跳过:未找到 authorization,请确认已登录且脚本运行的是维咔ACG增强 2.0'; debugLog('log', `${LOG_PREFIX} ${message}`); showToast(message, 'warning', 8000); return; } const lock = acquireSignLock(token); if (lock && lock.skipped) { debugLog('log', `${LOG_PREFIX} 签到跳过:${lock.reason}`); return; } isSigning = true; try { debugLog('log', `${LOG_PREFIX} 开始签到,token来源: ${tokenInfo.source}, API: ${getApiBase()}`); const data = await requestSign(token); const message = formatSignResult(data); markSignDone(lock, message); debugLog('log', `${LOG_PREFIX} ${message}`); showToast(message, Object.prototype.hasOwnProperty.call(data || {}, 'credit') ? 'success' : 'info'); } catch (e) { const message = `签到失败:${String(e)}`; releaseSignLock(lock, message); debugLog('warn', `${LOG_PREFIX} 签到失败:`, e); showToast(message, 'error'); } finally { isSigning = false; } } async function fetchPostContent(postId) { return new Promise((resolve, reject) => { const url = `${getApiBase()}/api/vikacg/v1/getPost`; GM_xmlhttpRequest({ method: 'POST', url, headers: withVikacgAuthorization(url, { 'Content-Type': 'application/json' }), data: JSON.stringify({ id: parseInt(postId) }), onload: function(response) { try { const data = JSON.parse(response.responseText); if (data.status === 'success' && data.data) { const contents = collectPostContents(data.data); if (contents.length > 0) { resolve(contents.join('\n')); } else { reject('API响应中没有可用内容'); } } else { reject('API返回失败'); } } catch (e) { reject('解析API响应失败'); } }, onerror: reject }); }); } async function fetchPostDetailHtml(postId) { return new Promise((resolve, reject) => { const url = `${getApiBase()}/p/${postId}`; GM_xmlhttpRequest({ method: 'GET', url, headers: withVikacgAuthorization(url, { accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', referer: window.location.href, }), timeout: 20000, onload(response) { if (response.status >= 200 && response.status < 300 && response.responseText) { resolve(response.responseText); } else { reject(`详情页加载失败:HTTP ${response.status}`); } }, onerror: reject, ontimeout() { reject('详情页请求超时'); }, }); }); } // 内容提取:API 和详情页 HTML 都会被用于预览图与 external 直链匹配。 function collectPostContents(post) { const contents = []; const seen = new Set(); function add(value) { if (!value) return; if (typeof value === 'string') { const text = value.trim(); if (text && !seen.has(text)) { seen.add(text); contents.push(text); } return; } if (Array.isArray(value)) { value.forEach(add); return; } if (typeof value === 'object') { add(value.content); add(value.html); add(value.text); add(value.value); } } add(post.content); add(post.hidden_content); return contents; } function decodeHtmlText(text) { const textarea = document.createElement('textarea'); textarea.innerHTML = text || ''; return textarea.value; } function cleanHtmlText(html) { return decodeHtmlText(html) .replace(/<[^>]*>/g, '') .replace(/\s+/g, ' ') .trim(); } function normalizeUrl(url) { return decodeHtmlText(url) .trim() .replace(/[.,!?,。!?、;;::))\]】>]+$/g, '') .replace(/#$/, ''); } function normalizeExternalHref(href) { if (!href) return ''; const raw = href.trim(); const match = raw.match(/\/external\?e=[^&#"']+/i); if (match) return match[0]; try { const url = new URL(raw, window.location.origin); if (url.pathname === '/external' && url.searchParams.has('e')) { return `/external?e=${url.searchParams.get('e')}`; } } catch (e) { // 忽略非URL字符串,交给原链接兜底。 } return raw; } function getHost(url) { try { return new URL(url).hostname.toLowerCase(); } catch (e) { return ''; } } function isImageUrl(url) { try { return /\.(?:jpe?g|png|gif|webp|bmp|svg|avif|ico)$/i.test(new URL(url).pathname); } catch (e) { return /\.(?:jpe?g|png|gif|webp|bmp|svg|avif|ico)(?:[?#].*)?$/i.test(url); } } function shouldKeepUrl(url) { const host = getHost(url); if (!host) return false; if (/(\.|^)vikacg\.(com|cc|xyz|net|org)$/i.test(host)) return false; if (host === 'image.acg.lol' || isVikacgImageHost(host) || host === 'picjs.xyz' || host.endsWith('.picjs.xyz')) return false; if (url.includes('/external?e=') || url.includes('photo://')) return false; return !isImageUrl(url); } function extractJsonUrls(html) { const cleanHtml = html .replace(/]*>/gi, '') .replace(/]*>/gi, ''); const urls = []; const seen = new Set(); const urlRegex = /https?:\/\/[^\s"'<>]+/gi; let match; while ((match = urlRegex.exec(cleanHtml)) !== null) { const rawUrl = match[0]; const url = normalizeUrl(rawUrl); if (!shouldKeepUrl(url) || seen.has(url)) { continue; } seen.add(url); const beforeHtml = cleanHtml.substring(Math.max(0, match.index - 150), match.index); const afterEnd = match.index + rawUrl.length; const afterHtml = cleanHtml.substring(afterEnd, Math.min(cleanHtml.length, afterEnd + 150)); const before = cleanHtmlText(beforeHtml).slice(-20); const after = cleanHtmlText(afterHtml).slice(0, 20); urls.push({ url, before, after, index: match.index }); } debugLog('log', `${LOG_PREFIX} JSON中找到URL数量:`, urls.length); return urls; } function createHtmlRoot(html) { const template = document.createElement('template'); template.innerHTML = html || ''; return template.content; } // 列表增强样式:预览图横排放在摘要下方,推荐广告卡片直接隐藏。 function injectPreviewStyles() { if (document.getElementById('vikacg-enhanced-preview-style')) return; const style = document.createElement('style'); style.id = 'vikacg-enhanced-preview-style'; style.textContent = ` .vikacg-enhanced-preview { display: flex; gap: 8px; margin-top: 8px; width: 100%; flex: 0 0 100%; align-self: stretch; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px; scrollbar-width: thin; } .vikacg-enhanced-preview-thumb { flex: 0 0 128px; height: 88px; border-radius: 6px; overflow: hidden; background: rgba(148, 163, 184, .16); cursor: zoom-in; display: flex; align-items: center; justify-content: center; } .vikacg-enhanced-preview-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; opacity: 0; transition: opacity .2s ease; } .vikacg-enhanced-preview-thumb img.is-loaded { opacity: 1; } .vikacg-enhanced-preview-state { color: #8a8f98; font-size: 12px; line-height: 1.5; padding: 4px 0; } .${RECOMMEND_HIDDEN_CLASS} { display: none !important; } @media (min-width: 768px) { .vikacg-enhanced-preview-thumb { flex-basis: 154px; height: 108px; } } @media (max-width: 640px) { .vikacg-enhanced-preview-thumb { flex-basis: 96px; height: 72px; } } `; document.head.appendChild(style); } // 预览缓存:缓存过滤后的非封面图片,减少列表反复滚动时的详情页请求。 function getPreviewCache(postId) { const cached = gmGet(`${PREVIEW_CACHE_PREFIX}${postId}`, null); if (!cached || !Array.isArray(cached.images)) return null; if (Date.now() - (cached.time || 0) > PREVIEW_CACHE_TTL_MS) return null; return cached; } function setPreviewCache(postId, images) { gmSet(`${PREVIEW_CACHE_PREFIX}${postId}`, { images, time: Date.now() }); } function extractPostIdFromHref(href) { try { const url = new URL(href, window.location.origin); const match = url.pathname.match(/\/p\/(\d+)/); return match ? match[1] : ''; } catch (e) { const match = String(href || '').match(/\/p\/(\d+)/); return match ? match[1] : ''; } } function isRecommendationBadge(el) { if (!el || el.tagName !== 'SPAN') return false; const text = cleanHtmlText(el.textContent || '').replace(/\s+/g, ''); return text === RECOMMEND_BADGE_TEXT; } function findRecommendationBadge(host) { if (!host) return null; return Array.from(host.querySelectorAll('span')).find(isRecommendationBadge) || null; } function findRecommendationCardHost(badge) { const link = badge.closest('a[href]'); if (link) { return extractPostIdFromHref(link.getAttribute('href') || link.href) ? findPostCardHost(link) : link; } for (let el = badge.parentElement; el && el !== document.body; el = el.parentElement) { const nestedLink = el.matches('a[href]') ? el : el.querySelector('a[href]'); if (!nestedLink) continue; return extractPostIdFromHref(nestedLink.getAttribute('href') || nestedLink.href) ? findPostCardHost(nestedLink) : nestedLink; } return null; } function hideRecommendedPostCard(host) { if (!host || host.classList.contains(RECOMMEND_HIDDEN_CLASS)) return false; revokePreviewObjectUrls(host); host.querySelectorAll('.vikacg-enhanced-preview').forEach(el => el.remove()); delete host.dataset.vikacgEnhancedPreviewId; host.classList.add(RECOMMEND_HIDDEN_CLASS); host.style.setProperty('display', 'none', 'important'); debugLog('log', `${LOG_PREFIX} 已屏蔽推荐帖子`); return true; } function hideRecommendedPostCards() { const seenHosts = new Set(); let hidden = 0; for (const badge of Array.from(document.querySelectorAll('span')).filter(isRecommendationBadge)) { const host = findRecommendationCardHost(badge); if (!host || seenHosts.has(host)) continue; seenHosts.add(host); if (hideRecommendedPostCard(host)) hidden++; } return hidden; } // 列表扫描:同时承担推荐广告屏蔽和多图预览挂载,MutationObserver 会重复调度它。 function getPostListLinks() { const links = []; for (const link of Array.from(document.querySelectorAll('a[href]'))) { const postId = extractPostIdFromHref(link.getAttribute('href') || link.href); if (!postId) continue; if (link.closest('.prose, .vikacg-enhanced-preview')) continue; if (link.closest(`.${RECOMMEND_HIDDEN_CLASS}`)) continue; links.push({ link, postId }); } return links; } function findPreviewInsertTarget(host) { const textBlocks = Array.from(host.querySelectorAll('p, [class*="line-clamp"], [class*="desc"], [class*="summary"]')); const summary = textBlocks.find(el => { if (/^H[1-6]$/i.test(el.tagName)) return false; const text = el.textContent.trim(); const className = String(el.className || ''); return text.length > 8 && /line-clamp|overflow-hidden|text-gray|text-xs|text-sm|desc|summary/i.test(className); }); if (summary) return summary; return textBlocks.find(el => !/^H[1-6]$/i.test(el.tagName) && el.textContent.trim().length > 12) || null; } function findPreviewInsertAnchor(host) { const summary = findPreviewInsertTarget(host); if (!summary) return null; for (let el = summary; el && el !== host; el = el.parentElement) { const parent = el.parentElement; if (!parent || parent === host) continue; const className = String(parent.className || ''); if (/flex/i.test(className) && /justify-between|items-start|gap-/i.test(className) && parent.querySelector('img')) { return parent; } } return summary; } function findCardCoverSrc(host) { const images = Array.from(host.querySelectorAll('img[src], img[data-src]')); for (const img of images) { const raw = img.getAttribute('data-src') || img.getAttribute('src'); const src = normalizePreviewImageSrc(raw); if (shouldKeepPreviewImage(src)) return src; } return ''; } function findPostCardHost(link) { const wrappingLink = link.closest('a[href*="/p/"]') || link; if (findPreviewInsertTarget(wrappingLink)) return wrappingLink; for (let el = link.parentElement; el && el !== document.body; el = el.parentElement) { const hasPostLink = el.matches('a[href*="/p/"]') || el.querySelector('a[href*="/p/"]'); if (!hasPostLink) continue; const className = String(el.className || ''); if (findPreviewInsertTarget(el) || /shadow-card|result|post|item|rounded/i.test(className)) { return el; } } return wrappingLink; } function isElementNearViewport(el, margin = 360) { const rect = el.getBoundingClientRect(); const height = window.innerHeight || document.documentElement.clientHeight || 0; return rect.bottom >= -margin && rect.top <= height + margin; } function resetPreviewDom() { document.querySelectorAll('.vikacg-enhanced-preview').forEach(el => { revokePreviewObjectUrls(el); el.remove(); }); document.querySelectorAll('[data-vikacg-enhanced-preview-id]').forEach(el => { delete el.dataset.vikacgEnhancedPreviewId; }); } function ensurePreviewObservers() { if (!previewFetchObserver && typeof IntersectionObserver !== 'undefined') { previewFetchObserver = new IntersectionObserver(entries => { entries.forEach(entry => { if (!entry.isIntersecting) return; const container = entry.target; previewFetchObserver.unobserve(container); loadPreviewForContainer(container); }); }, { rootMargin: '360px 0px' }); } } function schedulePreviewProcessing(delay = 300) { clearTimeout(previewRetryTimer); previewRetryTimer = setTimeout(() => { previewRetryTimer = null; processPreviewCards(); }, delay); } function processPreviewCards() { if (!document.body) return; injectPreviewStyles(); const hiddenRecommendations = hideRecommendedPostCards(); const count = getPreviewCount(); if (count <= 0) { debugLog('log', `${LOG_PREFIX} 预览已关闭,跳过扫描`); resetPreviewDom(); return; } ensurePreviewObservers(); const seenHosts = new Set(); const links = getPostListLinks(); const summary = { candidateLinks: links.length, cardHosts: 0, duplicateLinks: 0, created: 0, reused: 0, noHost: 0, noAnchor: 0, hiddenRecommendations, }; for (const { link, postId } of links) { const host = findPostCardHost(link); if (!host) { summary.noHost++; continue; } if (findRecommendationBadge(host)) { if (hideRecommendedPostCard(host)) summary.hiddenRecommendations++; continue; } if (seenHosts.has(host)) { summary.duplicateLinks++; continue; } seenHosts.add(host); summary.cardHosts++; const insertAnchor = findPreviewInsertAnchor(host); if (!insertAnchor) { summary.noAnchor++; continue; } const oldPreview = host.querySelector(`.vikacg-enhanced-preview[data-post-id="${postId}"]`); if (oldPreview) { if (oldPreview.previousElementSibling !== insertAnchor) { insertAnchor.after(oldPreview); } summary.reused++; continue; } host.dataset.vikacgEnhancedPreviewId = postId; const preview = document.createElement('div'); preview.className = 'vikacg-enhanced-preview'; preview.dataset.postId = postId; preview.dataset.coverSrc = findCardCoverSrc(host); preview.innerHTML = '预览排队中...'; insertAnchor.after(preview); if (previewFetchObserver) { previewFetchObserver.observe(preview); } loadPreviewForContainer(preview); summary.created++; } logPreviewScanSummary(summary); } function logPreviewScanSummary(summary) { summary.totalPreviewCards = document.querySelectorAll('.vikacg-enhanced-preview').length; summary.pending = previewPending; summary.queued = previewQueue.length; const signature = [ getRouteKey(), summary.candidateLinks, summary.cardHosts, summary.created, summary.reused, summary.noHost, summary.noAnchor, summary.hiddenRecommendations, summary.totalPreviewCards, ].join('|'); if (signature === previewScanLogState.signature) return; previewScanLogState = { signature, time: Date.now() }; debugLog('log', `${LOG_PREFIX} 预览扫描汇总:`, summary); } async function loadPreviewForContainer(container) { if (!container.isConnected || container.dataset.loading || container.dataset.loaded) return; const postId = container.dataset.postId; const count = getPreviewCount(); if (!postId || count <= 0) { container.remove(); return; } container.dataset.loading = '1'; container.innerHTML = '预览加载中...'; try { logPreviewImage('info', '开始加载帖子', { postId, cover: Boolean(container.dataset.coverSrc), }); const coverSrc = container.dataset.coverSrc || ''; const cached = getPreviewCache(postId); const previewImages = cached ? cached.images : await fetchPreviewImagesWithSlot(postId, coverSrc); if (!cached) setPreviewCache(postId, previewImages); logPreviewImage('info', '图片就绪', { postId, renderImages: previewImages.length, }); renderPreviewImages(container, previewImages.slice(0, count)); container.dataset.loaded = '1'; } catch (e) { debugLog('warn', `${LOG_PREFIX} 预览加载失败:`, postId, e); logPreviewImage('warn', '接口失败,跳过封面兜底', { postId, error: String(e), }); renderPreviewImages(container, []); container.dataset.loaded = '1'; } finally { delete container.dataset.loading; } } async function fetchPreviewImagesWithSlot(postId, coverSrc) { await acquirePreviewSlot(); try { const apiContent = await fetchPostContent(postId); const apiImages = extractPreviewImages(apiContent, PREVIEW_MAX_COUNT * 2); let images = filterNonCoverPreviewImages(apiImages, coverSrc); logPreviewImage('info', 'API图片提取完成', { postId, apiImages: apiImages.length, nonCoverImages: images.length, }); if (images.length >= getPreviewCount()) { return images; } const pageHtml = await fetchPostDetailHtml(postId); const pageImages = extractPreviewImages(pageHtml, PREVIEW_MAX_COUNT * 3); images = filterNonCoverPreviewImages([...apiImages, ...pageImages], coverSrc); logPreviewImage('info', '详情页图片提取完成', { postId, pageImages: pageImages.length, nonCoverImages: images.length, }); return images; } finally { releasePreviewSlot(); } } function acquirePreviewSlot() { if (previewPending < getPreviewMaxConcurrent()) { previewPending++; return Promise.resolve(); } return new Promise(resolve => { previewQueue.push(() => { previewPending++; resolve(); }); }); } function releasePreviewSlot() { previewPending = Math.max(0, previewPending - 1); const next = previewQueue.shift(); if (next) setTimeout(next, 120); } function parseSrcset(srcset) { if (!srcset) return ''; const first = srcset.split(',')[0] || ''; return first.trim().split(/\s+/)[0] || ''; } function getCurrentVikacgSuffix() { const match = window.location.hostname.match(/(?:^|\.)vikacg\.(com|cc|xyz|net|org)$/i); return match ? match[1].toLowerCase() : ''; } function isVikacgImageHost(host) { return /^image\.vikacg\.(com|cc|xyz|net|org)$/i.test(host || ''); } function normalizePreviewImageSrc(src) { const text = decodeHtmlText(src || '').trim(); if (!text || /^(?:data|blob|photo):/i.test(text)) return ''; try { return new URL(text, window.location.origin).href; } catch (e) { return ''; } } function getComparableImageKey(src) { try { const url = new URL(src, window.location.origin); return `${url.hostname.toLowerCase()}${url.pathname}`.toLowerCase(); } catch (e) { return String(src || '').split(/[?#]/)[0].toLowerCase(); } } function isSamePreviewImage(a, b) { if (!a || !b) return false; return getComparableImageKey(a) === getComparableImageKey(b); } function filterNonCoverPreviewImages(images, coverSrc) { const result = []; const seen = new Set(); for (const src of images) { if (!src || isSamePreviewImage(src, coverSrc)) continue; const key = getComparableImageKey(src); if (seen.has(key)) continue; seen.add(key); result.push(src); } return result; } function getPreviewImageFallbacks(src) { const fallbacks = []; try { const url = new URL(src); if (!isVikacgImageHost(url.hostname)) return fallbacks; const currentSuffix = getCurrentVikacgSuffix(); for (const suffix of [currentSuffix, 'cc', 'xyz', 'com', 'net', 'org']) { if (!suffix) continue; const next = new URL(url.href); next.hostname = `image.vikacg.${suffix}`; if (next.href !== url.href && !fallbacks.includes(next.href)) { fallbacks.push(next.href); } } } catch (e) { // 非标准 URL 不做兜底。 } return fallbacks; } function getPreviewLogUrl(src) { try { const url = new URL(src, window.location.origin); return `${url.origin}${url.pathname}${url.search}`; } catch (e) { return String(src || ''); } } function logPreviewImage(level, message, data = {}) { debugLog(level, `${LOG_PREFIX} 预览图片${message}`, data); } function shouldKeepPreviewImage(url) { if (!url || !isImageUrl(url)) return false; const host = getHost(url); const lower = url.toLowerCase(); if (/avatar|weblogo|favicon|apple-touch|maskable|default\.|site_head|emoji|smiley|iconfont|\/icon|\/assets\/moonlight\//i.test(lower)) { return false; } if (isVikacgImageHost(host)) return true; if (/(^|\.)vikacg\.(com|cc|xyz|net|org)$/i.test(host)) return false; return true; } function revokePreviewObjectUrl(img) { const objectUrl = img.dataset.objectUrl; if (objectUrl) { URL.revokeObjectURL(objectUrl); delete img.dataset.objectUrl; } } function revokePreviewObjectUrls(root) { root.querySelectorAll('img[data-object-url]').forEach(revokePreviewObjectUrl); } function setPreviewImageSrc(img, src) { if (!src) return; img.dataset.rawSrc = src; img.dataset.pendingSrc = src; img.classList.remove('is-loaded'); revokePreviewObjectUrl(img); if (!isVikacgRequestUrl(src)) { img.src = src; return; } loadAuthorizedPreviewImage(img, src); } function loadAuthorizedPreviewImage(img, src) { const tokenInfo = getAuthTokenInfo(); const token = tokenInfo.token; if (!token) { const retry = parseInt(img.dataset.authRetry || '0', 10); if (retry < PREVIEW_AUTH_RETRY_MAX) { img.dataset.authRetry = String(retry + 1); logPreviewImage('warn', '等待 authorization', { url: getPreviewLogUrl(src), retry: retry + 1, max: PREVIEW_AUTH_RETRY_MAX, }); setTimeout(() => { if (img.isConnected && img.dataset.pendingSrc === src && !img.src) { loadAuthorizedPreviewImage(img, src); } }, PREVIEW_AUTH_RETRY_DELAY_MS); return; } logPreviewImage('error', '缺少 authorization,停止站内图片加载', { url: getPreviewLogUrl(src), }); return; } delete img.dataset.authRetry; logPreviewImage('info', '开始授权请求', { url: getPreviewLogUrl(src), tokenSource: tokenInfo.source || '', tokenLength: token.length, }); GM_xmlhttpRequest({ method: 'GET', url: src, headers: withVikacgAuthorization(src, { accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', referer: window.location.href, }, token), responseType: 'blob', timeout: 20000, onload(response) { if (img.dataset.pendingSrc !== src) return; const contentType = response.responseHeaders?.match(/content-type:\s*([^\r\n]+)/i)?.[1] || ''; const size = response.response instanceof Blob ? response.response.size : response.response ? String(response.response).length : 0; logPreviewImage(response.status >= 200 && response.status < 300 ? 'info' : 'warn', '授权响应', { url: getPreviewLogUrl(src), status: response.status, contentType, size, }); if (response.status < 200 || response.status >= 300 || !response.response) { img.dispatchEvent(new Event('error')); return; } const blob = response.response instanceof Blob ? response.response : new Blob([response.response], { type: contentType || 'image/*' }); const objectUrl = URL.createObjectURL(blob); img.dataset.objectUrl = objectUrl; img.src = objectUrl; }, onerror(error) { if (img.dataset.pendingSrc === src) { logPreviewImage('error', '授权请求失败', { url: getPreviewLogUrl(src), error, }); img.dispatchEvent(new Event('error')); } }, ontimeout() { if (img.dataset.pendingSrc === src) { logPreviewImage('error', '授权请求超时', { url: getPreviewLogUrl(src), }); img.dispatchEvent(new Event('error')); } }, }); } function extractPreviewImages(html, limit) { const root = createHtmlRoot(html); const images = []; const seen = new Set(); const nodes = Array.from(root.querySelectorAll('img, source[srcset]')); for (const node of nodes) { const raw = node.getAttribute('zoomfile') || node.getAttribute('file') || node.getAttribute('data-actualsrc') || node.getAttribute('actualsrc') || node.getAttribute('data-original') || node.getAttribute('data-src') || node.getAttribute('src') || parseSrcset(node.getAttribute('srcset')); const src = normalizePreviewImageSrc(raw); if (!shouldKeepPreviewImage(src) || seen.has(src)) continue; seen.add(src); images.push(src); if (images.length >= limit) break; } const vikacgImages = images.filter(url => isVikacgRequestUrl(url)); if (vikacgImages.length > 0) { logPreviewImage('info', '提取到站内图片', { count: vikacgImages.length, sample: vikacgImages.slice(0, 3).map(getPreviewLogUrl), }); } return images; } function renderPreviewImages(container, images) { if (!images.length) { revokePreviewObjectUrls(container); container.dataset.loaded = '1'; container.dataset.empty = '1'; container.style.display = 'none'; container.replaceChildren(); return; } container.style.display = ''; delete container.dataset.empty; revokePreviewObjectUrls(container); container.replaceChildren(); const fragment = document.createDocumentFragment(); images.forEach(src => { const thumb = document.createElement('div'); thumb.className = 'vikacg-enhanced-preview-thumb'; thumb.title = '打开预览图'; const img = document.createElement('img'); img.alt = ''; img.loading = 'eager'; img.referrerPolicy = 'origin'; img.dataset.src = src; img.dataset.fallbacks = JSON.stringify(getPreviewImageFallbacks(src)); img.onload = () => { img.classList.add('is-loaded'); if (isVikacgRequestUrl(img.dataset.rawSrc || src)) { logPreviewImage('info', '渲染成功', { url: getPreviewLogUrl(img.dataset.rawSrc || src), renderedSrc: img.src.startsWith('blob:') ? 'blob:' : getPreviewLogUrl(img.src), }); } }; img.onerror = () => { let fallbacks = []; try { fallbacks = JSON.parse(img.dataset.fallbacks || '[]'); } catch (e) { fallbacks = []; } const next = fallbacks.shift(); if (next) { logPreviewImage('warn', '加载失败,尝试备用域名', { failedUrl: getPreviewLogUrl(img.dataset.rawSrc || src), nextUrl: getPreviewLogUrl(next), remaining: fallbacks.length, }); img.dataset.fallbacks = JSON.stringify(fallbacks); setPreviewImageSrc(img, next); return; } logPreviewImage('error', '所有备用地址均失败,移除缩略图', { url: getPreviewLogUrl(img.dataset.rawSrc || src), currentSrc: img.src.startsWith('blob:') ? 'blob:' : getPreviewLogUrl(img.src), }); revokePreviewObjectUrl(img); thumb.remove(); if (!container.querySelector('.vikacg-enhanced-preview-thumb')) { container.dataset.empty = '1'; container.style.display = 'none'; } }; thumb.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); window.open(img.dataset.rawSrc || src, '_blank', 'noopener'); }); thumb.appendChild(img); fragment.appendChild(thumb); setPreviewImageSrc(img, src); }); container.appendChild(fragment); } function extractDomain(text) { const match = text.match(/网页链接\(([^)]+)\)/) || text.match(/\(([^)]+\.[^)]+)\)/); return match ? match[1].trim().toLowerCase() : null; } // 直链映射:不访问 external 跳转页,直接用页面/API 内容还原真实链接。 function extractExternalLinks(html) { const links = []; const seen = new Set(); const root = createHtmlRoot(html); const anchors = Array.from(root.querySelectorAll('a[href*="external?e="]')); for (const anchor of anchors) { const href = normalizeExternalHref(anchor.getAttribute('href') || anchor.href); if (!href || seen.has(href)) continue; seen.add(href); const text = cleanHtmlText(anchor.innerHTML || anchor.textContent || ''); links.push({ href, domain: extractDomain(text), text }); } debugLog('log', `${LOG_PREFIX} HTML中找到external数量:`, links.length); return links; } function addMapping(href, url, reason) { const key = normalizeExternalHref(href); if (!key || !url) return false; if (urlMap[key] && urlMap[key] !== url) { debugLog('warn', `${LOG_PREFIX} 映射冲突,保留已有值:`, key, urlMap[key], '忽略:', url); return false; } if (!urlMap[key]) { urlMap[key] = url; debugLog('log', `${LOG_PREFIX} ${reason}:`, key, '->', url); return true; } return false; } function isDomainMatch(url, domain) { if (!domain) return false; const host = getHost(url); return host === domain || host.endsWith(`.${domain}`) || url.includes(domain); } function buildMapping(jsonContent, htmlContent, sourceName = '内容') { const jsonUrls = extractJsonUrls(jsonContent); const externalLinks = extractExternalLinks(htmlContent); let added = 0; debugLog('log', LOG_PREFIX, sourceName, 'URL数量:', jsonUrls.length, 'HTML external数量:', externalLinks.length); if (jsonUrls.length === 0 || externalLinks.length === 0) { return 0; } // 策略1: 数量相同,按顺序对应 if (jsonUrls.length === externalLinks.length) { debugLog('log', `${LOG_PREFIX} 数量相同,按顺序对应`); for (let i = 0; i < externalLinks.length; i++) { if (addMapping(externalLinks[i].href, jsonUrls[i].url, '顺序匹配')) { added++; } } } else { // 策略2: 数量不同,用域名和剩余顺序匹配 debugLog('log', `${LOG_PREFIX} 数量不同,使用域名/剩余顺序匹配`); const usedUrls = new Set(); for (const ext of externalLinks) { if (!ext.domain || urlMap[ext.href]) continue; const uniqueUrls = [...new Set(jsonUrls .map(u => u.url) .filter(url => !usedUrls.has(url) && isDomainMatch(url, ext.domain)))]; if (uniqueUrls.length === 1 && addMapping(ext.href, uniqueUrls[0], '域名匹配')) { usedUrls.add(uniqueUrls[0]); added++; } } const remainingLinks = externalLinks.filter(ext => !urlMap[ext.href]); const remainingUrls = jsonUrls.map(u => u.url).filter(url => !usedUrls.has(url)); if (remainingLinks.length > 0 && remainingLinks.length === remainingUrls.length) { debugLog('log', `${LOG_PREFIX} 剩余数量相同,按顺序对应`); for (let i = 0; i < remainingLinks.length; i++) { if (addMapping(remainingLinks[i].href, remainingUrls[i], '剩余顺序匹配')) { added++; } } } for (const ext of externalLinks) { if (!urlMap[ext.href]) { debugLog('log', `${LOG_PREFIX} 未匹配:`, ext.href, ext.text || ''); } } } debugLog('log', `${LOG_PREFIX} === 匹配完成,共`, Object.keys(urlMap).length, '条 ==='); return added; } function resetMappingRetryState() { mappingRetryState = { postId: null, signature: '', attempts: 0, stopped: false }; } function getMappingRetrySignature(postId) { const externalHrefs = getCurrentExternalHrefs().join('|'); const cachedContent = getCachedRawContent(postId); return `${postId}:${externalHrefs}:${cachedContent.length}`; } function isMappingRetryStopped(postId) { const signature = getMappingRetrySignature(postId); return mappingRetryState.postId === postId && mappingRetryState.signature === signature && mappingRetryState.stopped; } function recordMappingRetryResult(postId, added) { const signature = getMappingRetrySignature(postId); if (mappingRetryState.postId !== postId || mappingRetryState.signature !== signature) { mappingRetryState = { postId, signature, attempts: 0, stopped: false }; } if (added > 0) { mappingRetryState.attempts = 0; mappingRetryState.stopped = false; return; } mappingRetryState.attempts++; if (mappingRetryState.attempts >= MAX_MAPPING_RETRY_WITHOUT_PROGRESS) { mappingRetryState.stopped = true; debugLog('warn', `${LOG_PREFIX} 直链补匹配连续无新增,停止本轮懒加载重试`); } } function cacheRawContent(postId, content) { if (!postId || !content) return; if (rawContentCache.postId !== postId) { rawContentCache = { postId, content: '' }; } if (!rawContentCache.content.includes(content)) { rawContentCache.content = rawContentCache.content ? `${rawContentCache.content}\n${content}` : content; resetMappingRetryState(); } } function getCachedRawContent(postId) { return rawContentCache.postId === postId ? rawContentCache.content : ''; } function getCurrentExternalHrefs() { const hrefs = Array.from(document.querySelectorAll('a[href*="external?e="]')) .map(link => normalizeExternalHref(link.getAttribute('href') || link.href)) .filter(Boolean); return [...new Set(hrefs)]; } function hasUnmappedExternalLinks() { return getCurrentExternalHrefs().some(href => !urlMap[href]); } function buildMappingFromCache(sourceName) { const postId = getPostId(); const cachedContent = getCachedRawContent(postId); if (!cachedContent || !hasUnmappedExternalLinks()) return 0; if (isMappingRetryStopped(postId)) return 0; const added = buildMapping(cachedContent, getRenderedContentHtml(), sourceName); recordMappingRetryResult(postId, added); return added; } function scheduleMappingFromDom(delay = 300) { clearTimeout(mappingRetryTimer); mappingRetryTimer = setTimeout(() => { mappingRetryTimer = null; const postId = getPostId(); if (!postId) return; if (getCachedRawContent(postId)) { if (!isMappingRetryStopped(postId)) { buildMappingFromCache('懒加载DOM'); } } else if (!isLoading && getCurrentExternalHrefs().length > 0) { loadMappings(); } }, delay); } // 页面监听:兼容 Nuxt 路由切换和瀑布流懒加载,新增内容后重新扫描。 function startContentObserver() { if (mutationObserver || !document.body) return; mutationObserver = new MutationObserver(mutations => { const changed = mutations.some(mutation => mutation.addedNodes && mutation.addedNodes.length > 0); if (changed && getPostId()) { scheduleMappingFromDom(); } if (changed) { schedulePreviewProcessing(); } }); mutationObserver.observe(document.body, { childList: true, subtree: true }); } function getRenderedContentHtml() { const prose = document.querySelector('.prose'); if (prose && prose.querySelector('a[href*="external?e="]')) { return prose.innerHTML; } return document.body.innerHTML; } function extractPageRawContents() { const script = document.querySelector('#__NUXT_DATA__'); if (!script || !script.textContent) return []; try { const payload = JSON.parse(script.textContent); if (!Array.isArray(payload)) return []; const contents = []; const seen = new Set(); for (const value of payload) { if (typeof value !== 'string') continue; if (!/https?:\/\//i.test(value)) continue; if (!/<\s*(?:a|p|br|div|span|strong|h[1-6]|hidden_content)/i.test(value)) continue; const content = value.trim(); if (content && !seen.has(content)) { seen.add(content); contents.push(content); } } debugLog('log', `${LOG_PREFIX} Nuxt数据中找到内容段:`, contents.length); return contents; } catch (e) { debugLog('warn', `${LOG_PREFIX} Nuxt数据解析失败:`, e); return []; } } async function loadMappings() { const postId = getPostId(); if (!postId) { clearMappings(); return; } if (isLoading) return; isLoading = true; if (mappedPostId !== postId) { clearMappings(); mappedPostId = postId; } debugLog('log', `${LOG_PREFIX} 加载文章ID:`, postId); let mappedCount = 0; try { const htmlContent = getRenderedContentHtml(); const pageContents = extractPageRawContents(); if (pageContents.length > 0) { cacheRawContent(postId, pageContents.join('\n')); mappedCount += buildMapping(getCachedRawContent(postId), htmlContent, 'Nuxt数据'); } const apiContent = await fetchPostContent(postId); if (getPostId() === postId) { cacheRawContent(postId, apiContent); mappedCount += buildMapping(getCachedRawContent(postId), getRenderedContentHtml(), 'API数据'); } else { debugLog('log', `${LOG_PREFIX} 页面已切换,忽略旧API响应:`, postId); } } catch (e) { if (mappedCount > 0) { debugLog('warn', `${LOG_PREFIX} API加载失败,已使用页面数据完成部分映射:`, e); } else { debugLog('error', `${LOG_PREFIX} 加载失败:`, e); } } isLoading = false; if (getCachedRawContent(postId) && hasUnmappedExternalLinks() && !isMappingRetryStopped(postId)) { scheduleMappingFromDom(800); } else if (getCachedRawContent(postId) && getCurrentExternalHrefs().length === 0) { debugLog('log', `${LOG_PREFIX} 当前DOM未找到external,等待懒加载内容渲染`); } const currentPostId = getPostId(); if (currentPostId && currentPostId !== mappedPostId) { setTimeout(loadMappings, 0); } } async function resolveRealUrl(href) { if (urlMap[href]) return urlMap[href]; const postId = getPostId(); if (!postId) return ''; if (getCachedRawContent(postId)) { buildMappingFromCache('点击前补映射'); if (urlMap[href]) return urlMap[href]; if (isMappingRetryStopped(postId)) return ''; } if (!isLoading) { await loadMappings(); if (urlMap[href]) return urlMap[href]; } scheduleMappingFromDom(500); return ''; } async function handleClick(e) { const link = e.target.closest('a[href*="external?e="]'); if (!link) return; const href = normalizeExternalHref(link.getAttribute('href') || link.href); const realUrl = urlMap[href]; if (realUrl) { e.preventDefault(); e.stopPropagation(); debugLog('log', `${LOG_PREFIX} 直链跳转:`, realUrl); window.open(realUrl, '_blank', 'noopener'); } else { e.preventDefault(); e.stopPropagation(); const resolvedUrl = await resolveRealUrl(href); if (resolvedUrl) { debugLog('log', `${LOG_PREFIX} 直链跳转:`, resolvedUrl); window.open(resolvedUrl, '_blank', 'noopener'); return; } debugLog('log', `${LOG_PREFIX} 未找到映射,等待懒加载内容渲染`); showToast('暂未找到对应直链,正在等待页面内容加载', 'warning'); } } // 调试入口:在控制台使用 vikacgEnhanced.* 查看签到、预览和日志状态。 function exposeDebugTools() { const target = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; target.vikacgEnhanced = { signNow: runDailySign, resetSignState() { gmSet(SIGN_STATE_KEY, {}); showToast('已清空签到防重复状态,可重新尝试签到', 'info'); debugLog('log', `${LOG_PREFIX} 已清空签到防重复状态`); }, signStatus() { const tokenInfo = getAuthTokenInfo(); const status = { script: '维咔ACG增强', version: '2.0', signApi: `${getApiBase()}/api/vikacg/v1/userMission`, hasToken: Boolean(tokenInfo.token), tokenSource: tokenInfo.source || '', tokenLength: tokenInfo.token ? tokenInfo.token.length : 0, state: tokenInfo.token ? getSignState(tokenInfo.token).userState : null, }; debugLog('log', `${LOG_PREFIX} 签到状态:`, status); showToast(status.hasToken ? `已找到 authorization:${status.tokenSource}` : '未找到 authorization', status.hasToken ? 'success' : 'warning'); return status; }, toast(message = 'toast 测试') { showToast(message, 'info'); }, previewStatus() { const status = { count: getPreviewCount(), concurrent: getPreviewMaxConcurrent(), pending: previewPending, queued: previewQueue.length, candidates: getPostListLinks().length, cards: document.querySelectorAll('.vikacg-enhanced-preview').length, }; debugLog('log', `${LOG_PREFIX} 预览状态:`, status); showToast(`预览图片数:${status.count},并发:${status.concurrent}`, 'info'); return status; }, logs() { const logs = getPageWindow().__vikacgEnhancedLogs || []; debugLog('log', `${LOG_PREFIX} 最近日志:`, logs.slice(-50)); return logs; }, setPreviewCount(count = PREVIEW_DEFAULT_COUNT) { const next = clampNumber(count, PREVIEW_DEFAULT_COUNT, 0, PREVIEW_MAX_COUNT); gmSet(PREVIEW_COUNT_KEY, next); resetPreviewDom(); schedulePreviewProcessing(0); showToast(next > 0 ? `预览图片数已设置为 ${next}` : '已关闭列表预览增强', 'info'); return next; }, }; } function startApp() { if (appStarted) return; appStarted = true; debugLog('log', `${LOG_PREFIX} 启动增强功能:`, { route: getRouteKey(), readyState: document.readyState, previewCount: getPreviewCount(), previewConcurrent: getPreviewMaxConcurrent(), }); loadMappings(); schedulePreviewProcessing(500); setTimeout(runDailySign, 2000); startContentObserver(); document.addEventListener('click', handleClick, true); setInterval(() => { const currentRoute = getRouteKey(); if (currentRoute !== routePath) { routePath = currentRoute; clearMappings(); setTimeout(loadMappings, 500); setTimeout(runDailySign, 2000); scheduleMappingFromDom(1200); schedulePreviewProcessing(800); } }, 1000); } installAuthorizationCapture(); exposeDebugTools(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startApp, { once: true }); } else { startApp(); } })();