// ==UserScript== // @name LANraragi 推荐栏 // @namespace https://github.com/Kelcoin // @version 1.4 // @description 基于标签为 LANraragi 阅读器下方推荐区:猜你喜欢 & 同作者 // @author Kelcoin // @match *://*/reader?id=* // @grant none // @icon https://github.com/Difegue/LANraragi/raw/dev/public/favicon.ico // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/561661/LANraragi%20%E6%8E%A8%E8%8D%90%E6%A0%8F.user.js // @updateURL https://update.greasyfork.icu/scripts/561661/LANraragi%20%E6%8E%A8%E8%8D%90%E6%A0%8F.meta.js // ==/UserScript== (function () { 'use strict'; // ========================================== // 配置 // ========================================== const CONFIG = { // 每个视图最多显示多少个归档 perViewLimit: 15, // 总共最多显示多少个归档(猜你喜欢 + 同作者) totalLimit: 30, // 是否在加载完成后自动展开 autoExpand: false, // 用于“猜你喜欢”中随机选取标签的命名空间 likeNamespaces: ['female', 'male', 'others'], // 如果 likeNamespaces 里一个都没有,就退而求其次 likeFallbackNamespaces: ['character', 'parody'], // 自定义权重 可自行按照偏好新增和修改 customhWeightTags: { 'female:ahegao': 1.5, 'female:anal intercourse': 2, 'female:anal': 2, 'female:bbw': 4, 'female:beauty mark': 1.5, 'female:big ass': 1.5, 'female:big breast': 2, 'female:bikini': 1.5, 'female:blowjob': 1.5, 'female:bondage': 2, 'female:cheating': 2, 'female:corruption': 2, 'female:dark skin': 2, 'female:defloration': 2, 'female:dickgirl on female': 3, 'female:double penetration': 2, 'female:exhibitionism': 1.5, 'female:femdom': 3, 'female:fingering': 1.5, 'female:futanari': 5, 'female:glasses': 1.5, 'female:gloves': 1.5, 'female:gyaru': 3, 'female:hairy': 2, 'female:handjob': 1.5, 'female:harem': 3, 'female:huge breasts': 2, 'female:impregnation': 2, 'female:kemonomimi': 2, 'female:kissing': 1.5, 'female:lactation': 2, 'female:lingerie': 2, 'female:lolicon': 5, 'female:masturbation': 1.5, 'female:milf': 3, 'female:mind control': 3, 'female:mother': 3, 'female:nakadashi': 2, 'female:netorare': 3, 'female:paizuri': 1.5, 'female:pantyhose': 2, 'female:ponytail': 1.5, 'female:public use': 3, 'female:rape': 3, 'female:schoolgirl uniform': 1.5, 'female:sex toys': 1.5, 'female:shemale': 4, 'female:sister': 2, 'female:squirting': 1.5, 'female:stockings': 2, 'female:sweating': 1.5, 'female:swimsuit': 1.5, 'female:tomboy': 4, 'female:yuri': 3, 'male:anal': 3, 'male:bbm': 3, 'male:big penis': 1.5, 'male:condom': 1.5, 'male:crossdressing': 3, 'male:dark skin': 3, 'male:dilf': 3, 'male:gender change': 4, 'male:harem': 3, 'male:netorare': 3, 'male:shotacon': 3, 'male:tomgirl': 5, 'male:virginity': 3, 'male:yaoi': 4, 'mixed:ffm threesome': 2, 'mixed:group': 2, 'mixed:incest': 3, 'mixed:mmf threesome': 2, 'other:3d': 3, 'parody:': 2, 'character:': 2, 'cosplayer:': 3, 'group:': 0.1, 'artist:': 0.1, 'category:': 0.1, 'other:AI 超分': 0, 'language:': 0, 'uploader:': 0, 'timestamp:': 0, 'source:': 0, 'dateadded:': 0 }, // 缓存时间(毫秒),默认 24 小时 cacheExpiry: 24 * 60 * 60 * 1000, // Search API 基础路径(相对当前站点) apiBase: '/api/search', // 是否在加载时打印 debug 信息 debug: false }; // ========================================== // 样式:阅读器下方的卡片区域 // ========================================== const style = document.createElement('style'); style.textContent = ` .lrr-rec-progress { position: absolute; top: 4px; left: 4px; font-size: 10px; font-weight: bold; color: #fff; background: rgba(0, 0, 0, 0.65); padding: 2px 6px; border-radius: 4px; backdrop-filter: blur(4px); z-index: 2; box-shadow: 0 1px 3px rgba(0,0,0,0.3); pointer-events: none; } .lrr-rec-progress.prog-new { background: rgba(46, 204, 113, 0.85); /* Green */ color: #fff; } .lrr-rec-progress.prog-end { background: rgba(52, 73, 94, 0.85); /* Dark Blue/Gray */ color: #bdc3c7; } #lrr-rec-app-wrapper { width: 100%; margin: 24px 0 0 0; box-sizing: border-box; } #lrr-rec-app { width: 100%; display: flex; justify-content: center; box-sizing: border-box; } #lrr-rec-container { width: 100%; max-width: 1400px; box-sizing: border-box; background: #1C1E24; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(140, 160, 190, 0.2); border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; overflow: hidden; transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; will-change: max-height; max-height: 340px; opacity: 1; transform: translateY(0); } #lrr-rec-container.collapsed { max-height: 46px; transition: max-height 0.35s cubic-bezier(0, 1, 0.5, 1), opacity 0.3s ease; } .lrr-rec-header { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 0 16px; height: 44px; min-height: 44px; flex: 0 0 44px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); background: #1C1E24; font-size: 14px; color: #e3e9f3; user-select: none; pointer-events: none; } #lrr-rec-container.collapsed .lrr-rec-header { border-bottom: 1px solid transparent; } .lrr-rec-tabs { display: flex; gap: 10px; align-items: center; height: 100%; pointer-events: auto; } #lrr-rec-status-msg { position: absolute; right: 80px; /* 调整位置给刷新按钮腾空间 */ top: 50%; transform: translateY(-50%); font-size: 12px; color: #ffd54f; opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: nowrap; } #lrr-rec-status-msg.visible { opacity: 1; } .lrr-rec-tab-btn { background: transparent; border: 1px solid; color: var(--text-secondary, #a7b1c2); font-size: 13px; font-weight: 600; cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: all 0.2s; } .lrr-rec-tab-btn:hover { background: #4a9ff0; color: #fff; border-color: rgba(206, 224, 255, 0.55) !important; transform: translateY(-1px); } .lrr-rec-tab-btn.active { background: #4a9ff0; color: #f7fbff !important; border-color: rgba(206, 224, 255, 0.55) !important; font-weight: 600 !important; } /* 针对同作者按钮的隐显动画 */ #lrr-rec-btn-artist { opacity: 0; transition: opacity 0.3s ease; } .lrr-rec-controls { display: flex; align-items: center; gap: 2px; pointer-events: auto; } .lrr-rec-toggle, .lrr-rec-refresh { pointer-events: auto; cursor: pointer; color: #fff; opacity: 0.8; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 4px; transition: all 0.2s; } .lrr-rec-toggle:hover, .lrr-rec-refresh:hover { opacity: 1; background: rgba(255, 255, 255, 0.1); } .lrr-rec-arrow-icon, .lrr-rec-refresh-icon { fill: currentColor; width: 24px; height: 24px; } .lrr-rec-refresh { display: none; /* 默认隐藏,加载完显示 */ opacity: 0; transition: opacity 0.3s ease; } .lrr-rec-refresh-icon { width: 20px; height: 20px; } /* 箭头图标动画与旋转 */ .lrr-rec-arrow-icon { transition: transform 0.3s ease; transform: rotate(0deg); /* 默认朝上 (展开状态) */ } /* 收起时旋转180度 (朝下) */ #lrr-rec-container.collapsed .lrr-rec-arrow-icon { transform: rotate(180deg); } .lrr-rec-scroll-view { display: flex; flex-direction: row; overflow-x: auto; overflow-y: hidden; gap: 10px; padding: 12px 16px 14px; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; scrollbar-width: none; } .lrr-rec-scroll-view::-webkit-scrollbar { display: none; } .lrr-rec-card { flex: 0 0 140px; display: flex; flex-direction: column; gap: 6px; text-decoration: none; position: relative; transition: transform 0.2s; color: inherit; } .lrr-rec-card:hover { transform: translateY(-3px); } .lrr-rec-thumb { width: 140px; height: 200px; border-radius: 8px; overflow: hidden; position: relative; background: #222; border: 1px solid rgba(255, 255, 255, 0.1); } .lrr-rec-thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.3s; } .lrr-rec-card:hover .lrr-rec-thumb-img { transform: scale(1.05); } .lrr-rec-title { font-size: 12px; color: #e3e9f3; line-height: 1.3; max-height: 32px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } .lrr-rec-tags { position: absolute; bottom: 0; left: 0; width: 100%; height: auto; max-height: 100%; padding: 24px 4px 4px 4px; background: linear-gradient(to top, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0.0) 100%); display: flex; flex-direction: column-reverse; gap: 3px; box-sizing: border-box; pointer-events: none; } .lrr-rec-row { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 3px; width: 100%; min-width: 0; } .lrr-rec-row:empty { display: none; } .lrr-rec-tag { display: inline-flex; align-items: center; font-size: 9px; padding: 1px 4px; border-radius: 3px; color: #fff; background: rgba(255, 255, 255, 0.18); backdrop-filter: blur(4px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto; min-width: 0; max-width: 100%; } .lrr-rec-tag.lrr-rec-tag-match { box-shadow: 0 0 4px rgba(255, 255, 255, 0.35); background: rgba(255, 255, 255, 0.25); } .lrr-rec-view-hidden { display: none !important; } .lrr-rec-loading { padding: 24px 12px; color: #aaa; font-style: italic; font-size: 13px; } `; document.head.appendChild(style); // ========================================== // 工具函数 // ========================================== function logDebug(...args) { if (CONFIG.debug) { console.log('[LRR Rec]', ...args); } } // 显隐动画助手函数 function setVisible(el, visible, displayStyle = 'flex') { if (visible) { el.style.display = displayStyle; // 强制重绘以触发 transition el.offsetHeight; el.style.opacity = '1'; } else { el.style.opacity = '0'; setTimeout(() => { if (el.style.opacity === '0') { el.style.display = 'none'; } }, 300); // 匹配 CSS transition 时间 } } // 从 DOM 获取当前归档标签 + 显示文本 function getCurrentArchiveTags() { const tagElements = document.querySelectorAll('#tagContainer .gt a'); const tags = new Set(); const tagsLower = new Set(); const artistTags = new Set(); const artistTagsLower = new Set(); const categoryTags = new Set(); const categoryTagsLower = new Set(); const displayTextMap = new Map(); // --- 黑名单标签列表 --- const blacklistedTags = [ 'other:extraneous ads', // 你可以在这里添加更多想屏蔽的具体标签 ]; tagElements.forEach(el => { let rawTag = el.getAttribute('search') || ''; if (!rawTag && el.href && el.href.includes('q=')) { try { rawTag = decodeURIComponent(el.href.split('q=')[1]); } catch (e) { /* ignore */ } } rawTag = rawTag.replace(/"/g, '').trim(); if (!rawTag) return; const lowerKey = rawTag.toLowerCase(); // --- 修改:检查是否在黑名单中 --- if (blacklistedTags.includes(lowerKey)) { return; // 如果在黑名单中,直接跳过,不添加到集合中 } tags.add(rawTag); tagsLower.add(lowerKey); const prefix = rawTag.split(':')[0].toLowerCase(); if (prefix === 'artist' || prefix === 'group') { artistTags.add(rawTag); artistTagsLower.add(lowerKey); } if (prefix === 'category') { categoryTags.add(rawTag); categoryTagsLower.add(lowerKey); } const displayText = (el.textContent || '').trim() || rawTag.split(':')[1] || rawTag; displayTextMap.set(lowerKey, displayText); }); return { all: tags, allLower: tagsLower, artists: artistTags, artistsLower: artistTagsLower, categories: categoryTags, categoriesLower: categoryTagsLower, displayTextMap }; } // 基于 Search API 搜索归档 async function searchArchives(filter) { const params = new URLSearchParams(); params.set('category', ''); if (filter) params.set('filter', filter); params.set('start', '-1'); params.set('sortby', 'title'); params.set('order', 'asc'); const url = `${CONFIG.apiBase}?${params.toString()}`; logDebug('Search:', url); try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); return Array.isArray(json.data) ? json.data : []; } catch (e) { console.error('LRR Rec: searchArchives error', e); return []; } } function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function sample(arr, n) { if (arr.length <= n) return arr.slice(); return shuffle(arr).slice(0, n); } function archiveHasSameCategory(archiveTagsStr, currentCategoryLowerSet) { if (!archiveTagsStr || currentCategoryLowerSet.size === 0) return false; const lowers = archiveTagsStr.split(',').map(t => t.trim().toLowerCase()).filter(Boolean); return lowers.some(t => currentCategoryLowerSet.has(t)); } function getMatchedSetForArchive(archiveTagsStr, sourceTagsLower) { const matched = new Set(); if (!archiveTagsStr) return matched; archiveTagsStr.split(',').map(t => t.trim()).filter(Boolean).forEach(t => { const lowerKey = t.toLowerCase(); if (sourceTagsLower.has(lowerKey)) { matched.add(lowerKey); } }); return matched; } // --- 相似度计算函数 --- function calculateArchiveSimilarity(sourceTagsLower, archive, customWeightMap) { const tagsStr = archive.tags; if (!tagsStr) return 0; const candidateTags = tagsStr.split(','); const len = candidateTags.length; let totalScore = 0; const hasWeightMap = !!customWeightMap; for (let i = 0; i < len; i++) { const rawTag = candidateTags[i]; if (!rawTag) continue; const tag = rawTag.trim().toLowerCase(); if (!tag) continue; if (sourceTagsLower.has(tag)) { let rawPoints = 1; if (hasWeightMap) { const exactWeight = customWeightMap[tag]; if (exactWeight !== undefined) { rawPoints = exactWeight; } else { const colonIndex = tag.indexOf(':'); if (colonIndex > 0) { const namespaceKey = tag.slice(0, colonIndex + 1); const nsWeight = customWeightMap[namespaceKey]; if (nsWeight !== undefined) { rawPoints = nsWeight; } } } } totalScore += rawPoints * (0.8 + Math.random() * 0.4); } } if (totalScore === 0) return 0; const pagecount = +archive.pagecount; const progress = +archive.progress; if (pagecount > 0 && progress >= pagecount) { totalScore *= 0.5; } return totalScore; } // 渲染标签 HTML function renderTags(tagsStr, sourceTagsLower, sourceDisplayTextMap, matchedSet) { if (!tagsStr) return ''; const rawTags = tagsStr.split(',').map(t => t.trim()).filter(Boolean); if (rawTags.length === 0) return ''; // 归一化命名空间 const normalizeNs = ns => (ns || 'other') .toLowerCase() .trim() .replace(/\s+/g, '') // "date added" -> "dateadded" .replace(/_/g, ''); // "date_added" -> "dateadded" // 不显示的命名空间 const hiddenNamespaces = [ 'category', 'uploader', 'source', 'language', 'timestamp', 'dateadded' // 覆盖 date added / date-added / date_added ]; const primaryNamespaces = ['female', 'male', 'others']; const secondaryNamespaces = ['parody', 'character', 'artist', 'group']; // 预处理 + 过滤隐藏命名空间 const processed = rawTags .map((tag, index) => { const lowerKey = tag.toLowerCase(); const parts = tag.split(':'); const rawNs = parts.length > 1 ? parts[0] : 'other'; const ns = normalizeNs(rawNs); if (hiddenNamespaces.includes(ns)) { return null; } const rawValue = parts.length > 1 ? parts.slice(1).join(':') : tag; let displayText = rawValue || tag; if (sourceDisplayTextMap && sourceDisplayTextMap.has(lowerKey)) { const mapped = sourceDisplayTextMap.get(lowerKey); if (mapped && mapped !== rawValue) { displayText = mapped; } } return { raw: tag, ns, displayText, index, length: displayText.length }; }) .filter(Boolean); if (processed.length === 0) return ''; // 简单洗牌 function shuffleLocal(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // 分组:primary / secondary / others const primary = []; const secondary = []; const others = []; for (const item of processed) { if (primaryNamespaces.includes(item.ns)) { primary.push(item); } else if (secondaryNamespaces.includes(item.ns)) { secondary.push(item); } else { others.push(item); } } const MAX_TAGS_PER_CARD = 5; const picked = []; // 1. 从 primary 随机取 const primaryShuffled = shuffleLocal(primary); for (const t of primaryShuffled) { if (picked.length >= MAX_TAGS_PER_CARD) break; picked.push(t); } // 2. 不足 5 个,从 secondary 随机补 if (picked.length < MAX_TAGS_PER_CARD && secondary.length > 0) { const secondaryShuffled = shuffleLocal(secondary); for (const t of secondaryShuffled) { if (picked.length >= MAX_TAGS_PER_CARD) break; picked.push(t); } } if (picked.length === 0) return ''; // 长标签优先,便于“下行更满” picked.sort((a, b) => b.length - a.length); // —— 分成最多两行 —— // const bottomRow = []; const topRow = []; let bottomLen = 0; let topLen = 0; for (const item of picked) { // 简单装箱:总长度更短的那行先放,保证底行尽量更满 if (bottomLen <= topLen) { bottomRow.push(item); bottomLen += item.length; } else { topRow.push(item); topLen += item.length; } } // 行内顺序按原 index 排一下,避免完全乱序 bottomRow.sort((a, b) => a.index - b.index); topRow.sort((a, b) => a.index - b.index); // 构造单个标签 HTML const buildTag = info => { const { raw, ns, displayText } = info; const nsClass = `${ns}-tag lrr-tag-${ns}`; const isMatch = matchedSet && matchedSet.has(raw.toLowerCase()); const matchClass = isMatch ? 'lrr-rec-tag-match' : ''; const spanCls = `${nsClass} lrr-rec-tag ${matchClass}`.trim(); return ( `` + `${displayText}` + `` ); }; const rowsHtml = []; if (topRow.length > 0) { rowsHtml.push( `