JM Shelf - Profile

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

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.sleazyfork.org/scripts/581106/1842605/JM%20Shelf%20-%20Profile.js

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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