Sleazy Fork is available in English.
用户画像构建与战斗力标签解析 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。
สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.sleazyfork.org/scripts/581106/1842605/JM%20Shelf%20-%20Profile.js
// ==UserScript==
// @name JM Shelf - Profile
// @namespace jmshelf-lib
// @version 1.0.0
// @author Kesdi
// @description 用户画像构建与战斗力标签解析 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。
// @license MIT
// ==/UserScript==
//
// 此文件是 GreasyFork 库(library),不直接安装。
// 请安装主脚本: JM Shelf 给杂鱼的个性化推荐
//
// ═══ [8] PROFILE MANAGER ═══
// ============================================================
const ProfileManager = {
/**
* Build user profile from favorites + history data
*/
build(favorites, history) {
const profile = this._buildProfile(favorites, history);
profile.popularityFingerprint = this._computeFingerprint(favorites);
return profile;
},
_buildProfile(favorites, history) {
const profile = { tags: {}, authors: {}, types: {} };
const favRaw = {}, histRaw = {}, authorRaw = {};
for (const album of favorites) {
for (const tag of (album.tags || [])) {
const nt = normalizeTag(tag);
if (getAllBlacklistedTags().includes(nt)) continue;
favRaw[nt] = (favRaw[nt] || 0) + 1;
}
for (const author of (album.authors || [])) {
authorRaw[author] = (authorRaw[author] || 0) + 1;
}
for (const t of (album.typeTags || [])) {
profile.types[t] = (profile.types[t] || 0) + CONFIG.FAVORITE_WEIGHT;
}
}
const histLen = history.length || 1;
for (let idx = 0; idx < history.length; idx++) {
const album = history[idx];
const posWeight = CONFIG.HISTORY_WEIGHT * Math.max(0.1, 1 - idx / histLen);
for (const tag of (album.tags || [])) {
const nt = normalizeTag(tag);
if (getAllBlacklistedTags().includes(nt)) continue;
histRaw[nt] = (histRaw[nt] || 0) + posWeight;
}
for (const author of (album.authors || [])) {
authorRaw[author] = (authorRaw[author] || 0) + posWeight;
}
}
const favMax = Math.max(...Object.values(favRaw), 1);
const histMax = Math.max(...Object.values(histRaw), 1);
const auMax = Math.max(...Object.values(authorRaw), 1);
for (const [tag, cnt] of Object.entries(favRaw)) {
profile.tags[tag] = (profile.tags[tag] || 0) + Math.sqrt(cnt / favMax) * CONFIG.FAVORITE_WEIGHT;
}
for (const [tag, cnt] of Object.entries(histRaw)) {
profile.tags[tag] = (profile.tags[tag] || 0) + Math.sqrt(cnt / histMax) * CONFIG.HISTORY_WEIGHT;
}
for (const [author, cnt] of Object.entries(authorRaw)) {
profile.authors[author] = Math.sqrt(cnt / auMax) * CONFIG.FAVORITE_WEIGHT;
}
profile.live = { tags: {}, authors: {}, types: {}, lastUpdate: Date.now() };
return profile;
},
/**
* Merge incremental update into profile
*/
update(profile, album, weight) {
if (!profile.live) profile.live = { tags: {}, authors: {}, types: {}, lastUpdate: 0 };
const now = Date.now();
const hours = profile.live.lastUpdate ? Math.max(0, (now - profile.live.lastUpdate) / 3600000) : 0;
if (hours > 0.05) {
const df = Math.pow(CONFIG.LIVE_DECAY_PER_HOUR, hours);
for (const k of ['tags', 'authors', 'types']) {
for (const key of Object.keys(profile.live[k])) {
profile.live[k][key] *= df;
if (profile.live[k][key] < 0.1) delete profile.live[k][key];
}
}
profile.live.lastUpdate = now;
}
const maxBase = Math.max(...Object.values(profile.tags), 1);
for (const tag of (album.tags || [])) {
const nt = normalizeTag(tag);
if (getAllBlacklistedTags().includes(nt)) continue;
const baseW = profile.tags[nt] || 0;
const cap = Math.max(baseW * CONFIG.LIVE_MAX_RATIO, CONFIG.FAVORITE_WEIGHT);
const novelty = 1 - Math.min(baseW / maxBase, 1);
const boost = CONFIG.LIVE_NOVELTY_BOOST * novelty + CONFIG.LIVE_EXISTING_BOOST * (1 - novelty);
const convTarget = weight >= 6 ? CONFIG.LIVE_CONVERGE_LIKE : CONFIG.LIVE_CONVERGE_READ;
const adaptiveP = Math.max(0.2, 1 - (boost / convTarget));
const liveOld = profile.live.tags[nt] || 0;
profile.live.tags[nt] = Math.min(liveOld * adaptiveP + boost, cap);
}
const auMax = Math.max(...Object.values(profile.authors), 1);
for (const author of (album.authors || [])) {
const baseW = profile.authors[author] || 0;
const novelty = 1 - Math.min(baseW / auMax, 1);
const boost = CONFIG.LIVE_NOVELTY_BOOST * novelty + CONFIG.LIVE_EXISTING_BOOST * (1 - novelty);
const cap = Math.max(baseW * CONFIG.LIVE_MAX_RATIO, CONFIG.FAVORITE_WEIGHT);
const convTarget = weight >= 6 ? CONFIG.LIVE_CONVERGE_LIKE : CONFIG.LIVE_CONVERGE_READ;
const adaptiveP = Math.max(0.2, 1 - (boost / convTarget));
const auOld = profile.live.authors[author] || 0;
profile.live.authors[author] = Math.min(auOld * adaptiveP + weight * boost, cap);
}
for (const t of (album.typeTags || [])) {
profile.live.types[t] = (profile.live.types[t] || 0) + weight;
}
return profile;
},
/** 合并 base + live 得到有效画像 */
getEffective(profile) {
if (!profile.live) return profile;
const eff = { ...profile, tags: { ...profile.tags }, authors: { ...profile.authors }, types: { ...profile.types } };
for (const [tag, w] of Object.entries(profile.live.tags || {})) {
eff.tags[tag] = (eff.tags[tag] || 0) + Math.min(w, Math.max((eff.tags[tag] || 0) * CONFIG.LIVE_MAX_RATIO, CONFIG.FAVORITE_WEIGHT));
}
for (const [author, w] of Object.entries(profile.live.authors || {})) {
eff.authors[author] = (eff.authors[author] || 0) + Math.min(w, Math.max((eff.authors[author] || 0) * CONFIG.LIVE_MAX_RATIO, CONFIG.FAVORITE_WEIGHT));
}
return eff;
},
getTopTags(profile, n) {
return Object.entries(profile.tags)
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(e => e[0]);
},
_computeFingerprint(favorites) {
if (favorites.length === 0) return 0.7;
let totalLogViews = 0, count = 0;
for (const album of favorites) {
const v = album.views || album.likes || 0;
if (v > 0) { totalLogViews += Math.log10(v + 1); count++; }
}
if (count === 0) return 0.7;
const avgLog = totalLogViews / count;
const sig = Math.max(0.5, Math.min(0.95, avgLog / 6));
return Math.round(sig * 100) / 100;
},
deriveWeights(fingerprint) {
const s = fingerprint;
return {
wTag: clamp(CONFIG.W_TAG_BASE + (1 - s) * 0.08, 0.32, 0.48),
wAuthor: clamp(CONFIG.W_AUTHOR_BASE + (1 - s) * 0.08, 0.22, 0.38),
wPop: clamp(CONFIG.W_POP_BASE + (s - 0.5) * 0.03, 0.02, 0.08),
wQuality: CONFIG.W_QUALITY,
wFresh: CONFIG.W_FRESH,
};
},
getTopAuthors(profile, minWorks) {
return Object.entries(profile.authors)
.filter(([, w]) => w >= minWorks * CONFIG.FAVORITE_WEIGHT)
.sort((a, b) => b[1] - a[1])
.map(e => e[0]);
},
changeRatio(oldProfile, newProfile) {
const oldEntries = Object.entries(oldProfile.tags || {});
const newEntries = Object.entries(newProfile.tags || {});
if (oldEntries.length === 0 && newEntries.length === 0) return 0;
if (oldEntries.length === 0) return 1;
const oldMap = new Map(oldEntries);
let diff = 0;
let total = 0;
for (const [tag, weight] of newEntries) {
const oldWeight = oldMap.get(tag) || 0;
diff += Math.abs(weight - oldWeight);
total += Math.max(weight, oldWeight);
}
for (const [tag, weight] of oldEntries) {
if (!(tag in (newProfile.tags || {}))) {
diff += weight;
total += weight;
}
}
return total === 0 ? 0 : diff / total;
},
parseBattlePowerTags(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const rows = doc.querySelectorAll('.header-profile-row');
if (rows.length > 0) {
const result = {};
for (const row of rows) {
const nameEl = row.querySelector('.header-profile-row-name');
const valueEl = row.querySelector('.header-profile-row-value');
const name = nameEl?.textContent?.trim();
const value = valueEl?.textContent?.trim();
const nonTagPatterns = /^(J Coins|Coins|exp|EXP|經驗|金幣|金幣|Lv\.?|LV|等級|成就|收集|在線|在線時長|簽到|勳章)/i;
if (name && value && /^\d+$/.test(value) && !nonTagPatterns.test(name) && name.length >= 2 && parseInt(value) > 0) {
result[name] = parseInt(value, 10);
}
}
if (Object.keys(result).length > 0) return result;
}
const bodyText = doc.body?.textContent || '';
const idx = bodyText.indexOf('戰鬥力');
if (idx < 0) return {};
const after = bodyText.substring(idx, idx + 400);
const tokens = after.split(/\s+/).filter(Boolean);
const startIdx = (tokens[0] === '戰鬥力' && tokens[1] && /^\d+$/.test(tokens[1])) ? 2 : 1;
const stopTokens = ['個人', '成就', '信箱', '簽到', '動態', '漫畫收藏', '收藏漫畫'];
const result = {};
for (let i = startIdx; i < tokens.length; i++) {
const tag = tokens[i];
if (/^\d+$/.test(tag)) continue;
if (stopTokens.some(s => tag.includes(s) || tag === s)) break;
let weight = 5;
const next = tokens[i + 1];
if (next && /^\d+$/.test(next)) { weight = parseInt(next, 10); i++; }
if (tag) result[tag] = (result[tag] || 0) + weight;
}
return result;
},
};