Greasy Fork is available in English.
基于标签为 LANraragi 阅读器下方推荐区:猜你喜欢 & 同作者
// ==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
// ==/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 (
`<span class="${spanCls}"` +
` data-ns="${ns}"` +
` data-val="${raw}"` +
` search="${raw}"` +
` title="${raw}">` +
`${displayText}` +
`</span>`
);
};
const rowsHtml = [];
if (topRow.length > 0) {
rowsHtml.push(
`<div class="lrr-rec-row lrr-rec-row-top">` +
topRow.map(buildTag).join('') +
`</div>`
);
}
if (bottomRow.length > 0) {
rowsHtml.push(
`<div class="lrr-rec-row lrr-rec-row-bottom">` +
bottomRow.map(buildTag).join('') +
`</div>`
);
}
return rowsHtml.join('');
}
function createCardHTML(archive, matchedSet, sourceTagsLower, sourceDisplayTextMap) {
const id = archive.arcid;
const title = archive.title;
const thumbUrl = `/api/archives/${id}/thumbnail`;
const readerUrl = `/reader?id=${id}`;
const tagsHtml = renderTags(
archive.tags || '',
sourceTagsLower,
sourceDisplayTextMap,
matchedSet || new Set()
);
// 阅读进度显示逻辑
const pagecount = parseInt(archive.pagecount, 10) || 0;
const progress = parseInt(archive.progress, 10) || 0;
let progressHtml = '';
if (progress === 0) {
progressHtml = `<span class="lrr-rec-progress prog-new">未读</span>`;
} else if (pagecount > 0 && progress >= pagecount) {
progressHtml = `<span class="lrr-rec-progress prog-end">已读</span>`;
} else if (pagecount > 0) {
progressHtml = `<span class="lrr-rec-progress">${progress}/${pagecount}</span>`;
}
return `
<div class="lrr-rec-card" data-arcid="${id}">
<a href="${readerUrl}" title="${title}">
<div class="lrr-rec-thumb">
<img src="${thumbUrl}" loading="lazy" alt="" class="lrr-rec-thumb-img" onerror="this.removeAttribute('onerror'); this.src='/img/no_thumb.png';">
${progressHtml}
<div class="lrr-rec-tags">
${tagsHtml}
</div>
</div>
<div class="lrr-rec-title">${title}</div>
</a>
</div>
`;
}
// ==========================================
// 主逻辑
// ==========================================
async function init() {
// --- 0. 扩展配置项:自动展开 ---
if (typeof CONFIG.autoExpand === 'undefined') {
CONFIG.autoExpand = false;
}
const currentId = new URLSearchParams(window.location.search).get('id');
if (!currentId) return;
// --- 1. 构建布局(默认收起) ---
const mainContainer = document.querySelector('.ido') || document.querySelector('#ido') || document.body;
const wrapper = document.createElement('div');
wrapper.id = 'lrr-rec-app-wrapper';
const arrowSvg = `<svg class="lrr-rec-arrow-icon" viewBox="0 0 24 24"><path d="M6 15l6-6 6 6z"></path></svg>`;
const refreshSvg = `<svg class="lrr-rec-refresh-icon" viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"></path></svg>`;
const app = document.createElement('div');
app.id = 'lrr-rec-app';
app.innerHTML = `
<div id="lrr-rec-container" class="collapsed">
<div class="lrr-rec-header" id="lrr-rec-header-bar">
<div class="lrr-rec-tabs">
<button class="lrr-rec-tab-btn active" data-target="sim">猜你喜欢</button>
<button class="lrr-rec-tab-btn" data-target="artist" id="lrr-rec-btn-artist" style="display:none">同作者</button>
</div>
<span id="lrr-rec-status-msg">正在生成推荐...</span>
<div class="lrr-rec-controls">
<div class="lrr-rec-refresh" id="lrr-rec-refresh-btn" title="清理缓存并刷新">${refreshSvg}</div>
<div class="lrr-rec-toggle" title="展开/收起">
${arrowSvg}
</div>
</div>
</div>
<div id="lrr-rec-view-sim" class="lrr-rec-scroll-view">
<div class="lrr-rec-loading">正在生成推荐...</div>
</div>
<div id="lrr-rec-view-artist" class="lrr-rec-scroll-view lrr-rec-view-hidden"></div>
</div>
`;
wrapper.appendChild(app);
const footerElement = document.querySelector('p.ip');
if (footerElement && footerElement.parentNode) {
footerElement.parentNode.insertBefore(wrapper, footerElement);
} else {
mainContainer.appendChild(wrapper);
}
const container = document.getElementById('lrr-rec-container');
const headerBar = document.getElementById('lrr-rec-header-bar');
const btnSim = app.querySelector('[data-target="sim"]');
const btnArtist = document.getElementById('lrr-rec-btn-artist');
const viewSim = document.getElementById('lrr-rec-view-sim');
const viewArtist = document.getElementById('lrr-rec-view-artist');
const btnRefresh = document.getElementById('lrr-rec-refresh-btn');
// 初始收起
viewSim.classList.add('lrr-rec-view-hidden');
viewArtist.classList.add('lrr-rec-view-hidden');
container.style.maxHeight = `${headerBar.offsetHeight}px`;
const switchTab = (target) => {
if (target === 'sim') {
btnSim.classList.add('active');
btnArtist.classList.remove('active');
viewSim.classList.remove('lrr-rec-view-hidden');
viewArtist.classList.add('lrr-rec-view-hidden');
} else {
btnSim.classList.remove('active');
btnArtist.classList.add('active');
viewSim.classList.add('lrr-rec-view-hidden');
viewArtist.classList.remove('lrr-rec-view-hidden');
}
};
// --- 点击标签时若面板收起则自动展开 ---
const togglePanel = () => {
const isCollapsed = container.classList.contains('collapsed');
if (isCollapsed) {
container.classList.remove('collapsed');
const activeTarget = btnArtist.classList.contains('active') ? 'artist' : 'sim';
switchTab(activeTarget);
container.style.maxHeight = '';
} else {
container.classList.add('collapsed');
container.style.maxHeight = '';
}
};
// 点击 Tab 按钮逻辑:
btnSim.onclick = () => {
const isCollapsed = container.classList.contains('collapsed');
const isActive = btnSim.classList.contains('active');
if (!isCollapsed && isActive) {
togglePanel(); // 收起
} else {
if (isCollapsed) togglePanel(); // 展开
switchTab('sim');
}
};
btnArtist.onclick = () => {
const isCollapsed = container.classList.contains('collapsed');
const isActive = btnArtist.classList.contains('active');
if (!isCollapsed && isActive) {
togglePanel(); // 收起
} else {
if (isCollapsed) togglePanel(); // 展开
switchTab('artist');
}
};
const enableHorizontalScroll = (el) => {
el.addEventListener('wheel', (evt) => {
if (evt.deltaY !== 0) {
evt.preventDefault();
el.scrollLeft += evt.deltaY;
}
}, { passive: false });
};
enableHorizontalScroll(viewSim);
enableHorizontalScroll(viewArtist);
headerBar.addEventListener('click', (e) => {
if (e.target.closest('.lrr-rec-tab-btn')) return;
if (e.target.closest('.lrr-rec-refresh')) return; // 避免触发 toggle
togglePanel();
});
await new Promise(r => setTimeout(r, 800));
// --- 数据准备 ---
const sourceData = getCurrentArchiveTags();
const sourceTagsLower = sourceData.allLower;
const sourceArtist = Array.from(sourceData.artists);
const sourceCategoryLower = sourceData.categoriesLower;
const sourceDisplayTextMap = sourceData.displayTextMap;
// 获取 Config 中的自定义权重标签对象 (Map结构)
const customWeightMap = CONFIG.customhWeightTags || {};
logDebug('Current tags:', sourceData);
// --- 渲染辅助函数 ---
const renderArchiveList = (archives, containerEl) => {
if (!archives || archives.length === 0) return false;
const html = archives.map(arc => {
const matchedSet = getMatchedSetForArchive(arc.tags || '', sourceTagsLower);
return createCardHTML(arc, matchedSet, sourceTagsLower, sourceDisplayTextMap);
}).join('');
containerEl.innerHTML = html;
return true;
};
let remainingTotal = CONFIG.totalLimit;
// ==========================================
// 核心构建逻辑
// ==========================================
async function buildYouMayLikeData() {
if (remainingTotal <= 0) return [];
const viewLimit = Math.min(CONFIG.perViewLimit, remainingTotal);
const pickTags = (namespaces, fallbackNamespaces) => {
const primary = [];
const fallback = [];
sourceData.all.forEach(raw => {
const parts = raw.split(':');
if (parts.length <= 1) return;
const ns = parts[0].toLowerCase();
if (namespaces.includes(ns)) primary.push(raw);
else if (fallbackNamespaces.includes(ns)) fallback.push(raw);
});
let base = primary.length > 0 ? primary : fallback;
if (base.length === 0) return [];
let maxSearchCount = 3;
const totalTagsCount = sourceData.all.size;
if (totalTagsCount > 40) {
maxSearchCount = 7;
} else if (totalTagsCount > 20) {
maxSearchCount = 5;
}
const count = Math.min(maxSearchCount, Math.max(1, base.length));
return sample(base, count);
};
const queryTags = pickTags(CONFIG.likeNamespaces, CONFIG.likeFallbackNamespaces);
logDebug('Like query tags:', queryTags);
if (!queryTags || queryTags.length === 0) return [];
const allResultsMap = new Map();
// 需要获取 pagecount 和 progress,API 默认已返回
for (const tag of queryTags) {
const filter = `${tag}$`;
const data = await searchArchives(filter);
data.forEach(arc => {
if (arc.arcid === currentId) return;
if (!allResultsMap.has(arc.arcid)) allResultsMap.set(arc.arcid, arc);
});
}
let allResults = Array.from(allResultsMap.values());
if (allResults.length === 0) return [];
// 计算相似度,传入整个 arc 对象(含阅读进度) 以及 customWeightMap(自定义权重)
allResults.forEach(arc => {
arc._simScore = calculateArchiveSimilarity(sourceTagsLower, arc, customWeightMap);
});
const sameCategory = [];
const otherCategory = [];
allResults.forEach(arc => {
if (archiveHasSameCategory(arc.tags || '', sourceCategoryLower)) {
sameCategory.push(arc);
} else {
otherCategory.push(arc);
}
});
const sortByScoreDesc = (a, b) => b._simScore - a._simScore;
let picked = [];
if (sameCategory.length > 0) {
sameCategory.sort(sortByScoreDesc);
if (sameCategory.length >= viewLimit) {
picked = sameCategory.slice(0, viewLimit);
} else {
picked = [...sameCategory];
const needMore = viewLimit - picked.length;
if (otherCategory.length > 0) {
otherCategory.sort(sortByScoreDesc);
picked = picked.concat(otherCategory.slice(0, needMore));
}
}
} else {
otherCategory.sort(sortByScoreDesc);
picked = otherCategory.slice(0, viewLimit);
}
remainingTotal = Math.max(0, remainingTotal - picked.length);
return picked;
}
// 修改后的 buildSameAuthorData 逻辑
async function buildSameAuthorData() {
if (remainingTotal <= 0) return [];
if (!Array.isArray(sourceArtist) || sourceArtist.length === 0) return [];
const viewLimit = Math.min(CONFIG.perViewLimit, remainingTotal);
if (viewLimit <= 0) return [];
// 1. 乱序所有 artist 标签,准备随机抓取
const shuffledTags = shuffle(sourceArtist);
const collectedArchivesMap = new Map();
let collectedCount = 0;
// 2. 循环标签进行搜索,直到数量足够
for (const tag of shuffledTags) {
if (collectedCount >= viewLimit * 2) break; // 适当多抓取一点供后续过滤排序,防止只抓 limit 个导致排序没意义
try {
const filter = `${tag}$`;
const data = await searchArchives(filter);
if (Array.isArray(data)) {
for (const arc of data) {
if (!arc || arc.arcid === currentId) continue;
// 去重
if (!collectedArchivesMap.has(arc.arcid)) {
collectedArchivesMap.set(arc.arcid, arc);
collectedCount++;
}
}
}
} catch (e) {
logDebug('buildSameAuthor search error for tag:', tag, e);
}
// 如果当前收集的数量已经达到要求,就不再请求下一个标签了
if (collectedCount >= viewLimit) break;
}
let allResults = Array.from(collectedArchivesMap.values());
if (allResults.length === 0) return [];
// 3. 排序逻辑:
// 优先级 1: 未读在前,已读在后
// 优先级 2: 归档名称排序
allResults.sort((a, b) => {
const isReadA = (parseInt(a.pagecount) > 0 && parseInt(a.progress) >= parseInt(a.pagecount));
const isReadB = (parseInt(b.pagecount) > 0 && parseInt(b.progress) >= parseInt(b.pagecount));
if (isReadA !== isReadB) {
// 如果 A 是已读,B 是未读,则 A 放后面 (返回 1)
return isReadA ? 1 : -1;
}
// 同状态下按标题排序
return (a.title || '').localeCompare(b.title || '');
});
// 4. 截取最终数量
const picked = allResults.slice(0, viewLimit);
remainingTotal = Math.max(0, remainingTotal - picked.length);
return picked;
}
// ==========================================
// 执行逻辑封装
// ==========================================
const cacheKey = `lrr_rec_cache_v1_${currentId}`;
// 刷新按钮事件
btnRefresh.onclick = (e) => {
e.stopPropagation();
localStorage.removeItem(cacheKey);
generateAndRender(true);
};
async function generateAndRender(isRefresh = false) {
// 重置状态
remainingTotal = CONFIG.totalLimit;
if (isRefresh) {
viewSim.innerHTML = `<div class="lrr-rec-loading">正在刷新推荐...</div>`;
viewArtist.innerHTML = '';
viewArtist.classList.add('lrr-rec-view-hidden');
setVisible(btnArtist, false, 'block'); // 隐藏同作者按钮
setVisible(btnRefresh, false, 'flex'); // 隐藏刷新按钮
if (btnArtist.classList.contains('active')) {
switchTab('sim');
}
}
try {
let cachedData = null;
// 1. 尝试读取缓存 (如果不是强制刷新)
if (!isRefresh) {
try {
const raw = localStorage.getItem(cacheKey);
if (raw) {
const parsed = JSON.parse(raw);
const now = Date.now();
if (parsed && (now - parsed.timestamp < CONFIG.cacheExpiry)) {
cachedData = parsed;
logDebug('Loaded recommendations from cache');
} else {
logDebug('Cache expired or invalid');
}
}
} catch (e) {
console.warn('LRR Rec: Cache read error', e);
}
}
let simResult = [];
let artistResult = [];
if (cachedData) {
// 2A. 命中缓存:直接使用数据
simResult = cachedData.sim || [];
artistResult = cachedData.artist || [];
} else {
// 2B. 未命中缓存:执行构建逻辑
simResult = await buildYouMayLikeData();
artistResult = await buildSameAuthorData();
// 写入缓存 (仅当有数据时)
if (simResult.length > 0 || artistResult.length > 0) {
try {
const payload = {
timestamp: Date.now(),
sim: simResult,
artist: artistResult
};
localStorage.setItem(cacheKey, JSON.stringify(payload));
} catch (e) {
console.warn('LRR Rec: Cache write error', e);
}
}
}
// 3. 渲染视图
if (simResult.length > 0) {
renderArchiveList(simResult, viewSim);
} else {
viewSim.innerHTML = `<div class="lrr-rec-loading">暂无推荐结果。</div>`;
}
if (artistResult.length > 0) {
renderArchiveList(artistResult, viewArtist);
btnArtist.innerText = '同作者';
setVisible(btnArtist, true, 'block'); // 渐显同作者按钮
}
// 4. 后续 UI 状态更新
setVisible(btnRefresh, true, 'flex'); // 渐显刷新按钮
if (CONFIG.autoExpand && !isRefresh) { // 刷新时不自动展开,避免干扰
if (container.classList.contains('collapsed')) {
container.classList.remove('collapsed');
// 优先显示同作者
const activeTarget = btnArtist.classList.contains('active') ? 'artist' : 'sim';
switchTab(activeTarget);
container.style.maxHeight = '';
}
const statusMsg = document.getElementById('lrr-rec-status-msg');
if (statusMsg) statusMsg.style.display = 'none';
} else {
const statusMsg = document.getElementById('lrr-rec-status-msg');
if (statusMsg) {
// 直接隐藏状态栏,不显示“已就绪”或“刷新完成”
statusMsg.style.display = 'none';
}
}
} catch (e) {
console.error(e);
viewSim.innerHTML = `<div class="lrr-rec-loading">加载失败</div>`;
setVisible(btnRefresh, true, 'flex'); // 即使失败也显示刷新按钮以便重试
}
}
// 启动首次生成
generateAndRender(false);
}
// 启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();