JM Shelf - Init

入口初始化 + 样式 + XHR/fetch网络拦截 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。

Ce script ne doit pas être installé directement. C'est une librairie destinée à être incluse dans d'autres scripts avec la méta-directive // @require https://update.sleazyfork.org/scripts/581113/1842615/JM%20Shelf%20-%20Init.js

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         JM Shelf - Init
// @namespace    jmshelf-lib
// @version      1.0.0
// @author       Kesdi
// @description  入口初始化 + 样式 + XHR/fetch网络拦截 — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。
// @license      MIT
// ==/UserScript==
// 
// 此文件是 GreasyFork 库(library),不直接安装。
// 请安装主脚本: JM Shelf 给杂鱼的个性化推荐
//

// ═══ [15] INIT ═══
  // ============================================================

  // ═══ 网络拦截: 监控收藏/爱心 XHR ═══
  (function() {
    const origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function(body) {
      const url = this._url || '';
      if (url.includes('/ajax/favorite_album') || url.includes('/ajax/delete_favorite_album') ||
          url.includes('/ajax/love_album') || url.includes('/ajax/unlove_album')) {
        try {
          const formData = new URLSearchParams(body);
          const albumId = formData.get('album_id');
          if (albumId) {
            if (url.includes('delete_favorite_album')) {
              LOG.info(`📌 网络监测: 取消收藏 album ${albumId}`);
              const favs = State.getFavorites();
              const idx = favs.findIndex(f => String(f.id) === String(albumId));
              if (idx >= 0) { favs.splice(idx, 1); State.saveFavorites(favs); showNotification('📌 已取消收藏'); }
            } else if (url.includes('favorite_album')) {
              LOG.info(`📌 网络监测: 收藏 album ${albumId}`);
              const favs = State.getFavorites();
              if (!favs.find(f => String(f.id) === String(albumId))) {
                const albumData = State.getAlbumCache()[String(albumId)] || { id: albumId, title: '', tags: [], authors: [], typeTags: [] };
                favs.push({ ...albumData, id: String(albumId) });
                State.saveFavorites(favs);
                showNotification('📌 已收藏!(network)');
              }
              try { _insertTitleToTrie(_initTrie(), albumId, albumData.title || '', 'fav'); _titleTrie.rebuildAfterInsert(); _titleTrie.save(); } catch(e) {}
            } else if (url.includes('unlove_album')) {
              LOG.info(`❤ 网络监测: 取消喜欢 album ${albumId}`);
              const liked = State.getLikedAlbums();
              const idx = liked.findIndex(f => String(f.id) === String(albumId));
              if (idx >= 0) { liked.splice(idx, 1); State.saveLikedAlbums(liked); showNotification('💔 已取消喜欢'); }
            } else if (url.includes('love_album')) {
              LOG.info(`❤ 网络监测: 喜欢 album ${albumId}`);
              State.addLikedAlbum(albumId);
              showNotification('❤️ 已记录喜欢!');
            }
          }
        } catch(e) {}
      }
      return origSend.apply(this, arguments);
    };
    const origOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url) {
      this._url = url;
      return origOpen.apply(this, arguments);
    };
  })();

  // ═══ fetch拦截: 兜底监控 ═══
  (function() {
    const origFetch = window.fetch;
    window.fetch = function(url, options) {
      const urlStr = typeof url === 'string' ? url : (url.url || '');
      const method = (options && options.method) || 'GET';
      if (method === 'POST' && (urlStr.includes('/ajax/favorite_album') || urlStr.includes('/ajax/delete_favorite_album') ||
          urlStr.includes('/ajax/love_album') || urlStr.includes('/ajax/unlove_album'))) {
        const body = (options && options.body) ? new URLSearchParams(options.body) : new URLSearchParams();
        const albumId = body.get('album_id');
        if (albumId) {
          if (urlStr.includes('delete_favorite_album')) {
            const favs = State.getFavorites();
            const idx = favs.findIndex(f => String(f.id) === String(albumId));
            if (idx >= 0) { favs.splice(idx, 1); State.saveFavorites(favs); showNotification('📌 已取消收藏'); }
          } else if (urlStr.includes('favorite_album')) {
            const favs = State.getFavorites();
            if (!favs.find(f => String(f.id) === String(albumId))) {
              const albumData = State.getAlbumCache()[String(albumId)] || { id: albumId, title: '', tags: [], authors: [], typeTags: [] };
              favs.push({ ...albumData, id: String(albumId) });
              State.saveFavorites(favs);
              showNotification('📌 已收藏!(network)');
            }
            try { const ad = State.getAlbumCache()[String(albumId)]; if (ad && ad.title) { _insertTitleToTrie(_initTrie(), albumId, ad.title, 'fav'); _titleTrie.rebuildAfterInsert(); _titleTrie.save(); } } catch(e) {}
          } else if (urlStr.includes('unlove_album')) {
            const liked = State.getLikedAlbums();
            const idx = liked.findIndex(f => String(f.id) === String(albumId));
            if (idx >= 0) { liked.splice(idx, 1); State.saveLikedAlbums(liked); showNotification('💔 已取消喜欢'); }
          } else if (urlStr.includes('love_album')) {
            State.addLikedAlbum(albumId);
            showNotification('❤️ 已记录喜欢!');
          }
        }
      }
      return origFetch.apply(this, arguments);
    };
  })();

  // 全局click监听: 喜欢按钮
  let _currentAlbumData = null;
  document.addEventListener('click', (e) => {
    const btn = e.target.closest('[id^="love_likes_"], [id^="love_heart_"], [id^="favorite_album_"]');
    if (!btn) return;
    const btnId = btn.id || '';
    const isLike = btnId.startsWith('love_likes_') || btnId.startsWith('love_heart_');
    const isFav = btnId.startsWith('favorite_album_');
    const idMatch = btnId.match(/\d+$/);
    const albumId = idMatch ? idMatch[0] : null;
    if (!albumId) return;
    if (!_currentAlbumData) { _currentAlbumData = { id: albumId, title: '', tags: [], authors: [] }; }

    if (isLike) {
      LOG.info(`❤ 检测到喜欢: album ${albumId}`);
      const oldProfile = State.getProfile();
      const liked = State.getLikedAlbums();
      const likedEntry = liked.find(f => String(f.id) === String(albumId));
      const daysSinceLiked = likedEntry ? (Date.now() - (likedEntry.likedAt||0)) / 86400000 : 0;
      const likeDecay = 0.1 + 0.9 * Math.exp(-daysSinceLiked / 30);
      const effectiveWeight = CONFIG.LIKE_WEIGHT * likeDecay;
      const newProfile = ProfileManager.update({ ...oldProfile }, _currentAlbumData, effectiveWeight);
      State.saveProfile(newProfile);
      State.addLikedAlbum(albumId);
      if (ProfileManager.changeRatio(oldProfile, newProfile) > CONFIG.PROFILE_CHANGE_RECALC) {
        recalcRecommendations();
      }
      showNotification('❤️ 已记录喜欢!');
    }

    if (isFav) return; // 收藏由XHR网络监控处理
  });

  // ═══ STYLES ═══
  function injectStyles() {
    GM_addStyle(`
      #jms-panel {
        position: fixed; bottom: 20px; right: 20px; width: 380px;
        background: #1a1a2e; color: #e0e0e0; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
        z-index: 99999; font-size: 13px; overflow: hidden;
        display: none;
      }
      #jms-panel-header {
        background: #16213e; padding: 10px 16px; font-weight: bold; font-size: 14px;
        display: flex; justify-content: space-between; align-items: center;
      }
      #jms-panel-header span { opacity: 0.9; }
      #jms-panel-close-x {
        background: none; border: none; color: #888; cursor: pointer; font-size: 20px; padding: 0 6px; line-height: 1;
      }
      #jms-panel-close-x:hover { color: #fff; }
      #jms-panel-body { padding: 16px; max-height: 65vh; overflow-y: auto; }
      #jms-status { color: #0f9; margin-bottom: 8px; font-size: 12px; }
      #jms-progress-container { margin: 10px 0; }
      #jms-progress-bar { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
      #jms-progress-fill { height: 100%; background: linear-gradient(90deg, #0f9, #0cf); width: 0; transition: width 0.3s; }
      #jms-progress-text { font-size: 11px; color: #aaa; margin-top: 4px; }
      #jms-stats { font-size: 12px; color: #aaa; margin: 8px 0; line-height: 1.6; }
      #jms-actions { display: flex; gap: 8px; margin: 10px 0; }
      #jms-settings-details { margin-top: 12px; font-size: 12px; }
      #jms-settings-details summary { cursor: pointer; color: #aaa; }
      #jms-history-details { margin-top: 12px; font-size: 12px; }
      #jms-history-details summary { cursor: pointer; color: #aaa; }
      #jms-log-details { margin-top: 12px; font-size: 12px; }
      #jms-log-details summary { cursor: pointer; color: #aaa; }
      #jms-settings { padding: 8px 0; }
      #jms-float-btn {
        position: fixed; bottom: 30px; right: 30px; width: 48px; height: 48px;
        border-radius: 50%; display: flex; align-items: center; justify-content: center;
        cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
        z-index: 99998; transition: transform 0.2s; overflow: hidden;
        animation: jms-float-rgb 20s ease-in-out infinite;
      }
      @keyframes jms-float-rgb {
        0%   { background: #ff4d6a; }
        16%  { background: #ff8c42; }
        33%  { background: #ffd166; }
        50%  { background: #4ecdc4; }
        66%  { background: #6c5ce7; }
        83%  { background: #e056a0; }
        100% { background: #ff4d6a; }
      }
      #jms-float-btn:hover { transform: scale(1.1); }
      #jms-notification {
        position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
        background: #1a1a2e; color: #fff; padding: 12px 24px; border-radius: 8px;
        z-index: 100000; font-size: 14px; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
        animation: jms-fadeIn 0.3s ease;
      }
      @keyframes jms-fadeIn {
        from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
        to { opacity: 1; transform: translateX(-50%) translateY(0); }
      }
    `);
  }

  // ═══ 数据迁移 ═══
  function migrateData(from) {
    if (from < 1) {
      try {
        const p = State.getProfile();
        if (p && !p.live) { p.live = { tags: {}, authors: {}, types: {}, lastUpdate: 0 }; State.saveProfile(p); }
      } catch(e) {}
      try {
        const v = State.getViewedAlbums();
        if (v.length > 0 && typeof v[0] === 'number') State.saveViewedAlbums(v.map(String));
      } catch(e) {}
      try { GMStore.set('scan_state', null); } catch(e) {}
    }
  }

  // ═══ 页面类型检测 ═══
  function detectPageType() {
    const path = location.pathname;
    if (path === '/' || path === '') return 'homepage';
    if (/\/album\/\d+/.test(path)) return 'album';
    if (/\/photo\/\d+/.test(path)) return 'photo';
    if (/\/albums/.test(path)) return 'listing';
    if (/\/search\/photos/.test(path)) return 'listing';
    if (/\/user\//.test(path)) return 'user';
    if (/\/theme\//.test(path)) return 'theme';
    return 'other';
  }

  // ═══ INIT ═══
  async function init() {
    LOG._restore();
    LOG.info('──────────────');

    const savedTemp = Storage.get('temperature');
    if (savedTemp != null) CONFIG.TEMPERATURE = savedTemp;

    const storedVersion = GMStore.get('version', 0);
    if (storedVersion < CONFIG.VERSION) {
      LOG.info(`版本升级 ${storedVersion} → ${CONFIG.VERSION}, 保留现有数据`);
      GMStore.set('version', CONFIG.VERSION);
    }

    const storedDataVer = GMStore.get('dataVersion', 0);
    if (storedDataVer < CONFIG.DATA_VERSION) {
      LOG.info(`数据格式迁移 ${storedDataVer} → ${CONFIG.DATA_VERSION}`);
      migrateData(storedDataVer);
      GMStore.set('dataVersion', CONFIG.DATA_VERSION);
    }

    injectStyles();

    const pageType = detectPageType();
    LOG.info(`当前页面类型: ${pageType}`);

    if (!isLoggedIn()) {
      LOG.info('未登录,仅注入UI框架');
      createPanel();
      return;
    }

    let username = detectUsername();
    if (!username) username = State.getUsername();
    if (username) {
      const lastUser = GMStore.get('lastUser', '');
      if (username !== lastUser) {
        GMStore.set('lastUser', username);
        State.saveUsername(username);
        LOG.info(`检测到用户: ${username}${lastUser ? ' (切换自 ' + lastUser + ')' : ''}`);
      }
      setAccount(username);
    }

    createPanel();

    if (!document.getElementById('jms-float-btn')) {
      const fb = document.createElement('div');
      fb.id = 'jms-float-btn';
      fb.title = 'JM Shelf';
      fb.innerHTML = '<img src="https://i.postimg.cc/BQ8vkqZv/JM-メスガキ.png" style="width:36px;height:36px;border-radius:50%">';
      fb.addEventListener('click', () => {
        const p = document.getElementById('jms-panel') || createPanel();
        p.style.display = p.style.display === 'block' ? 'none' : 'block';
      });
      document.body.appendChild(fb);
    }

    switch (pageType) {
      case 'homepage': handleHomepage(); break;
      case 'album': handleAlbumDetail(); break;
      case 'photo': handlePhotoPage(); break;
      case 'listing': handleListingPage(); break;
      case 'user': handleUserPage(); break;
    }

    LOG.info(`📌 已收藏/喜欢${State.getFavorites().length}本, 推荐${State.getRecommendations().length}条, 已看${State.getViewedAlbums().length}`);
    LOG.info('初始化完成');
  }

  // ═══ 调试工具 ═══
  try {
    unsafeWindow.jmsDebug = {
      score(id) {
        const cache = State.getAlbumCache();
        const album = cache[String(id)];
        if (!album) return `Album ${id} not in cache`;
        const profile = ProfileManager.getEffective(State.getProfile()||{});
        const favs = State.getFavorites();
        const hist = State.getHistory();
        const s = Recommender.score(album, profile, favs, hist, ProfileManager.deriveWeights(profile.popularityFingerprint||0.7));
        const maxTagW = Math.max(...Object.values(profile.tags), 1);
        const tf = State.getTagFreq();
        const fv = Object.values(tf).filter(v=>v>0);
        const mf = Math.max(...fv,1);
        const detail = (album.tags||[]).map(t => {
          const nt = normalizeTag(t);
          const w = profile.tags[nt]||0;
          if(w<=0) return null;
          const gc = tf[nt]||0;
          let idf = gc>0 ? clamp(1.0+Math.log10(Math.max(mf/gc,1))*0.6,0.6,2.0) : 1.0;
          const raw = w/maxTagW;
          return `${nt}(w${w} idf${idf.toFixed(2)} raw${raw.toFixed(2)}→${(raw*idf).toFixed(3)})`;
        }).filter(Boolean);
        return { id, title:album.title, score:s.score.toFixed(4), detail };
      },
      top(n) {
        const recs = State.getRecommendations().slice(0,n||10);
        const cache = State.getAlbumCache();
        return recs.map((r,i)=>{const al=cache[r.id];return `${i+1}. [${r.id}] ${(r._wm||'def')} s${r.score.toFixed(2)} ${(al?.title||'').slice(0,30)}`});
      }
    };
    LOG.info('调试工具已就绪: jmsDebug.score(albumId) / jmsDebug.top(N)');
  } catch (e) {}

  // ═══ START ═══
  init().catch(e => LOG.error('初始化失败', e));