Sleazy Fork is available in English.

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 to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

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

(I already have a user script manager, let me install it!)

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.

(I already have a user style manager, let me install it!)

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