JM Shelf - Profile

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

Este script no debería instalarse directamente. Es una biblioteca que utilizan otros scripts mediante la meta-directiva de inclusión // @require https://update.sleazyfork.org/scripts/581106/1842605/JM%20Shelf%20-%20Profile.js

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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