JM Shelf - Profile

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

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.sleazyfork.org/scripts/581106/1842605/JM%20Shelf%20-%20Profile.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 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;
    },
  };