// ==UserScript==
// @name 维咔ACG增强
// @namespace http://tampermonkey.net/
// @version 2.1
// @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 GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @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增强]';
const urlMap = {};
const SIGN_STATE_KEY = 'vikacgEnhanced.signState';
const AUTH_HEADER_KEY = 'vikacgEnhanced.authorization';
const AUTH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const LOG_LEVEL_KEY = 'vikacgEnhanced.logLevel';
const LOG_LEVELS = { debug: 10, log: 20, info: 20, warn: 30, error: 40, silent: 99 };
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_HIDDEN_CLASS = 'vikacg-enhanced-recommend-hidden';
const DEFAULT_RECOMMEND_KEYWORDS = ['推荐'];
const RECOMMEND_KEYWORDS_KEY = 'recommend.keywords';
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;
debugLog('log', `${LOG_PREFIX} 脚本已加载`);
// 通用工具和日志:日志会同时写入控制台与页面对象,方便在沙盒脚本里排查。
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 getLogConsoleLevel() {
const level = String(gmGet(LOG_LEVEL_KEY, 'warn') || 'warn').toLowerCase();
return Object.prototype.hasOwnProperty.call(LOG_LEVELS, level) ? level : 'warn';
}
function debugLog(level, ...args) {
const method = console[level] ? level : 'log';
const consoleLevel = getLogConsoleLevel();
const shouldPrint = LOG_LEVELS[method] >= LOG_LEVELS[consoleLevel];
if (shouldPrint) {
try {
console[method](...args);
} catch (e) {
// 保持日志函数自身不影响主逻辑。
}
}
try {
const target = getPageWindow();
const targetConsole = target.console;
if (shouldPrint && 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 gmDelete(key) {
if (typeof GM_deleteValue === 'function') {
GM_deleteValue(key);
return;
}
try {
localStorage.removeItem(key);
} catch (e) {
debugLog('warn', `${LOG_PREFIX} 删除脚本状态失败:`, e);
}
}
function gmListKeys() {
if (typeof GM_listValues === 'function') {
return GM_listValues();
}
try {
return Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)).filter(Boolean);
} catch (e) {
debugLog('warn', `${LOG_PREFIX} 读取脚本状态列表失败:`, e);
return [];
}
}
function clearPreviewCacheEntries() {
let removed = 0;
for (const key of gmListKeys()) {
if (!String(key).startsWith(PREVIEW_CACHE_PREFIX)) continue;
gmDelete(key);
removed++;
}
return removed;
}
function clearPreviewCacheAndRefresh() {
const removed = clearPreviewCacheEntries();
resetPreviewDom();
schedulePreviewProcessing(0);
showToast(`已清理预览图片缓存:${removed} 条`, 'info');
debugLog('log', `${LOG_PREFIX} 已清理预览图片缓存:`, removed);
return removed;
}
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, 6), 6, 1, 12);
}
function getRecommendKeywords() {
const value = gmGet(RECOMMEND_KEYWORDS_KEY, DEFAULT_RECOMMEND_KEYWORDS);
const list = Array.isArray(value) ? value : String(value || '').split(/[,,\s]+/);
const keywords = list.map(item => String(item || '').trim()).filter(Boolean);
return keywords.length > 0 ? keywords : DEFAULT_RECOMMEND_KEYWORDS;
}
// 签到时间段与 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) {
if (captured.capturedAt && Date.now() - captured.capturedAt > AUTH_TTL_MS) {
debugLog('warn', `${LOG_PREFIX} 已忽略过期 authorization 缓存:`, captured.source || '');
} else {
return { token: captured.token, source: captured.source || 'captured:authorization' };
}
}
if (typeof captured === 'string') {
const token = cleanTokenText(captured);
if (token) return { token, 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 shouldKeepMappingUrl(url) {
const host = getHost(url);
if (!host) return false;
if (/(\.|^)vikacg\.(com|cc|xyz|net|org)$/i.test(host)) return false;
if (isVikacgImageHost(host) || host === 'picjs.xyz' || host.endsWith('.picjs.xyz')) return false;
if (url.includes('/external?e=') || url.includes('photo://')) return false;
return true;
}
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 (!shouldKeepMappingUrl(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 getRecommendKeywords().some(keyword => text === keyword.replace(/\s+/g, ''));
}
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),
});
container.dataset.failureReason = '预览加载失败';
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';
if (getLogConsoleLevel() === 'debug') {
container.style.display = '';
const reason = container.dataset.failureReason || '未找到非封面预览图';
container.replaceChildren();
const state = document.createElement('span');
state.className = 'vikacg-enhanced-preview-state';
state.textContent = reason;
container.appendChild(state);
} else {
container.style.display = 'none';
container.replaceChildren();
}
return;
}
container.style.display = '';
delete container.dataset.empty;
delete container.dataset.failureReason;
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 getUnmappedExternalLinks(externalLinks) {
return externalLinks.filter(ext => !urlMap[normalizeExternalHref(ext.href)]);
}
function getUnusedJsonUrls(jsonUrls, usedUrls) {
const result = [];
const seen = new Set();
for (const item of jsonUrls) {
if (usedUrls.has(item.url) || seen.has(item.url)) continue;
seen.add(item.url);
result.push(item);
}
return result;
}
function getJsonContextText(item) {
return cleanHtmlText(`${item.before || ''} ${item.after || ''}`).toLowerCase();
}
function addUniqueDomainMappings(externalLinks, jsonUrls, usedUrls) {
let added = 0;
for (const ext of externalLinks) {
if (!ext.domain || urlMap[ext.href]) continue;
const matches = getUnusedJsonUrls(jsonUrls, usedUrls)
.filter(item => isDomainMatch(item.url, ext.domain));
if (matches.length === 1 && addMapping(ext.href, matches[0].url, '域名匹配')) {
usedUrls.add(matches[0].url);
added++;
}
}
return added;
}
function addContextMappings(externalLinks, jsonUrls, usedUrls) {
let added = 0;
for (const ext of externalLinks) {
if (urlMap[ext.href]) continue;
const extText = cleanHtmlText(ext.text || '').toLowerCase();
if (!extText || extText.length < 4) continue;
const matches = getUnusedJsonUrls(jsonUrls, usedUrls).filter(item => {
const context = getJsonContextText(item);
return context && (context.includes(extText) || extText.includes(context));
});
if (matches.length === 1 && addMapping(ext.href, matches[0].url, '文本上下文匹配')) {
usedUrls.add(matches[0].url);
added++;
}
}
return added;
}
function addRemainingOrderMappings(externalLinks, jsonUrls, usedUrls) {
const remainingLinks = getUnmappedExternalLinks(externalLinks);
const remainingUrls = getUnusedJsonUrls(jsonUrls, usedUrls);
if (remainingLinks.length === 0 || remainingLinks.length !== remainingUrls.length) return 0;
let added = 0;
debugLog('log', `${LOG_PREFIX} 剩余数量相同,按顺序对应`);
for (let i = 0; i < remainingLinks.length; i++) {
if (addMapping(remainingLinks[i].href, remainingUrls[i].url, '剩余顺序匹配')) {
usedUrls.add(remainingUrls[i].url);
added++;
}
}
return added;
}
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();
added += addUniqueDomainMappings(externalLinks, jsonUrls, usedUrls);
added += addContextMappings(externalLinks, jsonUrls, usedUrls);
added += addRemainingOrderMappings(externalLinks, jsonUrls, usedUrls);
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];
e.preventDefault();
e.stopPropagation();
if (realUrl) {
debugLog('log', `${LOG_PREFIX} 直链跳转:`, realUrl);
window.open(realUrl, '_blank', 'noopener');
} else {
const resolvedUrl = await resolveRealUrl(href);
if (resolvedUrl) {
debugLog('log', `${LOG_PREFIX} 直链跳转:`, resolvedUrl);
window.open(resolvedUrl, '_blank', 'noopener');
return;
}
const fallbackUrl = link.href || new URL(href, window.location.origin).href;
debugLog('warn', `${LOG_PREFIX} 未找到映射,打开原external链接:`, fallbackUrl);
showToast('暂未找到对应直链,已打开原站跳转链接', 'warning');
window.open(fallbackUrl, '_blank', 'noopener');
}
}
// 调试入口:在控制台使用 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(),
logLevel: getLogConsoleLevel(),
recommendKeywords: getRecommendKeywords(),
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;
},
setLogLevel(level = 'warn') {
const next = String(level || 'warn').toLowerCase();
if (!Object.prototype.hasOwnProperty.call(LOG_LEVELS, next)) {
showToast('日志等级无效,可用:debug/log/warn/error/silent', 'warning');
return getLogConsoleLevel();
}
gmSet(LOG_LEVEL_KEY, next);
showToast(`日志等级已设置为 ${next}`, 'info');
return next;
},
setRecommendKeywords(keywords = DEFAULT_RECOMMEND_KEYWORDS) {
const list = Array.isArray(keywords)
? keywords
: String(keywords || '').split(/[,,\s]+/);
const next = list.map(item => String(item || '').trim()).filter(Boolean);
gmSet(RECOMMEND_KEYWORDS_KEY, next.length ? next : DEFAULT_RECOMMEND_KEYWORDS);
schedulePreviewProcessing(0);
showToast(`推荐屏蔽关键词:${getRecommendKeywords().join('、')}`, 'info');
return getRecommendKeywords();
},
clearPreviewCache() {
return clearPreviewCacheAndRefresh();
},
clearAuthCache() {
gmDelete(AUTH_HEADER_KEY);
showToast('已清理 authorization 缓存,刷新或触发站内请求后会重新捕获', 'info');
return true;
},
};
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('删除图片缓存', () => {
clearPreviewCacheAndRefresh();
});
}
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();
registerMenuCommands();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startApp, { once: true });
} else {
startApp();
}
})();