JM Shelf - Profile

用户画像构建与战斗力标签解析 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.sleazyfork.org/scripts/581106/1842605/JM%20Shelf%20-%20Profile.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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;
    },
  };