JM Shelf - Init

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

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.sleazyfork.org/scripts/581113/1842615/JM%20Shelf%20-%20Init.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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));