WeReadPrice

在微信读书书架页面显示书籍价格

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

Advertisement:

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!)

Advertisement:

// ==UserScript==
// @name         WeReadPrice
// @namespace    https://greasyfork.org/zh-CN/scripts/572301-wereadprice
// @homepage     https://github.com/gaelthas/WeReadPrice
// @version      1.0.5
// @description  在微信读书书架页面显示书籍价格
// @author       Galois
// @match        https://weread.qq.com/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      weread.qq.com
// @connect      cdn.jsdelivr.net
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

'use strict';

// ─── 配置 ────────────────────────────────────────────────────────────────────

const PRICE_CLASS = 'viberead-price';
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24h
const BATCH_SIZE = 5;
const BATCH_DELAY_MS = 200;

const CARD_SELECTORS = ['.shelfBook'];
const BOOKID_FROM_HREF_RE = /\/(?:reader|book)\/([^/?#]+)/;

// ─── 内存缓存 ─────────────────────────────────────────────────────────────────

const _cache = new Map();

function getCached(bookId) {
  const entry = _cache.get(bookId);
  if (!entry) return null;
  if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
    _cache.delete(bookId);
    return null;
  }
  return entry.data;
}

function setCached(bookId, data) {
  _cache.set(bookId, { data, timestamp: Date.now() });
}

// ─── 模拟器表单缓存 (localStorage, 无过期) ───────────────────────────────────────

const SIM_CACHE_KEY = 'weread-sim-settings-v1';

function saveSimSettings() {
  const data = {
    days: document.getElementById('wrpInpDays')?.value,
    hours: document.getElementById('wrpInpHours')?.value,
    flipCard: document.getElementById('wrpInpFlipCard')?.value,
    flipCoin: document.getElementById('wrpInpFlipCoin')?.value,
    strategy: {},
    challengeDays: getChallengeDays?.(),
  };
  if (!data.days) return; // UI 尚未创建
  REWARD_TIME.forEach(r => data.strategy[r.key] = getRadioVal('wrpStrat' + r.key));
  REWARD_DAY.forEach(r => data.strategy[r.key] = getRadioVal('wrpStrat' + r.key));
  try { localStorage.setItem(SIM_CACHE_KEY, JSON.stringify(data)); } catch (_) {}
}

function loadSimSettings() {
  try {
    const raw = localStorage.getItem(SIM_CACHE_KEY);
    if (!raw) return null;
    return JSON.parse(raw);
  } catch (_) { return null; }
}

// ─── API ──────────────────────────────────────────────────────────────────────

function fetchPayInfo(bookId) {
  return new Promise((resolve) => {
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://weread.qq.com/web/book/info?bookId=${bookId}`,
      withCredentials: true,
      onload(resp) {
        try {
          const data = JSON.parse(resp.responseText);
          resolve({
            bookId: data.bookId,
            title: data.title,
            bookType: data.type,
            centPrice: data.bookInfo?.centPrice ?? data.centPrice ?? null,
            payingStatus: data.payingStatus,
            paid: data.paid,
            newRating: data.newRating,
            deepVRating: data.deepVRating,
            category: data.category,
            free: data.free === 1,
          });
        } catch {
          resolve(null);
        }
      },
      onerror() { resolve(null); },
      ontimeout() { resolve(null); },
    });
  });
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchPrices(bookIds) {
  const result = {};
  const toFetch = [];

  for (const bookId of bookIds) {
    const cached = getCached(bookId);
    if (cached) {
      result[bookId] = cached;
    } else {
      toFetch.push(bookId);
    }
  }

  for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
    const batch = toFetch.slice(i, i + BATCH_SIZE);
    await Promise.all(batch.map(async bookId => {
      const data = await fetchPayInfo(bookId);
      if (data) {
        result[bookId] = data;
        setCached(bookId, data);
      }
    }));
    if (i + BATCH_SIZE < toFetch.length) {
      await sleep(BATCH_DELAY_MS);
    }
  }

  return result;
}

// ─── DOM 工具 ─────────────────────────────────────────────────────────────────

function queryBookCards() {
  for (const sel of CARD_SELECTORS) {
    const nodes = Array.from(document.querySelectorAll(sel));
    if (nodes.length > 0) return nodes;
  }
  return [];
}

function extractBookId(card) {
  const fromAttr = card.dataset && card.dataset.bookid;
  if (fromAttr) return fromAttr;

  if (card.href) {
    const m = card.href.match(BOOKID_FROM_HREF_RE);
    if (m) return m[1];
  }

  const link = card.querySelector('a[href]');
  if (link) {
    const m = link.href.match(BOOKID_FROM_HREF_RE);
    if (m) return m[1];
  }

  return null;
}

function parseId(infoId) {
  const type = infoId[3];
  const dataSection = infoId.slice(7, infoId.length - 3);
  const segments = dataSection.split('g');
  const chunks = [];
  for (const seg of segments) {
    const len = parseInt(seg.slice(0, 2), 16);
    chunks.push(seg.slice(2, 2 + len));
  }
  if (type === '3') {
    return chunks.map(c => parseInt(c, 16).toString(10)).join('');
  } else if (type === '4') {
    const hex = chunks[0];
    let result = '';
    for (let i = 0; i < hex.length; i += 2) {
      result += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
    }
    return result;
  }
  throw new Error(`Unknown type flag: ${type}`);
}

function scanNewCards() {
  const cards = queryBookCards();
  const result = [];
  for (const card of cards) {
    if (card.querySelector('.' + PRICE_CLASS)) continue;
    const rawId = extractBookId(card);
    if (!rawId) continue;
    try {
      result.push({ element: card, bookId: parseId(rawId) });
    } catch {
      // 无法解析的 bookId 跳过
    }
  }
  return result;
}

// ─── 价格注入 ─────────────────────────────────────────────────────────────────

function formatRatingValue(rating) {
  if (rating == null || rating === '') return null;
  const num = Number(rating);
  if (!Number.isFinite(num)) return String(rating);
  const score = num > 10 ? num / 10 : num;
  return score % 1 === 0 ? String(score) : score.toFixed(1);
}

function formatCategory(category) {
  if (!category) return '分类未知';

  if (typeof category === 'string') {
    return category;
  }

  if (Array.isArray(category)) {
    const parts = category.map(item => {
      if (typeof item === 'string') return item;
      if (!item || typeof item !== 'object') return '';
      return item.title || item.name || item.categoryName || item.label || '';
    }).filter(Boolean);

    return parts.length > 0 ? parts.join(' / ') : '分类未知';
  }

  if (typeof category === 'object') {
    return category.title || category.name || category.categoryName || category.label || '分类未知';
  }

  return '分类未知';
}

function getPriceDisplay(priceData) {
  if (!priceData) {
    return { text: '暂无价格', color: '#888' };
  }

  if (priceData.bookType == 3) {
    return { text: '公众号', color: '#888' };
  }

  if (priceData.paid == 1) {
    return { text: '已购买', color: '#07c160' };
  }

  if (priceData.payingStatus == 0) {
    return { text: '导入', color: '#888' };
  }

  if (priceData.free) {
    return { text: '免费', color: '#07c160' };
  }

  if (priceData.centPrice != null) {
    const fen = priceData.centPrice;
    const yuan = fen % 100 === 0 ? String(fen / 100) : (fen / 100).toFixed(2);
    return { text: '¥' + yuan, color: '#e64340' };
  }

  return { text: '暂无价格', color: '#888' };
}

function injectPriceLabel(card, priceData) {
  if (card.querySelector('.' + PRICE_CLASS)) return;

  const label = document.createElement('div');
  label.className = PRICE_CLASS;
  label.style.cssText = 'font-size:12px;margin-top:4px;line-height:1.6;pointer-events:none';

  const priceDisplay = getPriceDisplay(priceData);
  const newRating = formatRatingValue(priceData && priceData.newRating);
  const deepVRating = formatRatingValue(priceData && priceData.deepVRating);
  const category = formatCategory(priceData && priceData.category);

  const rowStyle = 'display:flex;justify-content:space-between;align-items:center';
  const leftStyle = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0';
  const rightStyle = 'white-space:nowrap;flex-shrink:0;text-align:right';

  // Row 1: 评分1 / 评分2 | 价格
  const row1 = document.createElement('div');
  row1.style.cssText = rowStyle;
  const ratingSpan = document.createElement('span');
  const ratings = [newRating, deepVRating].filter(Boolean);
  ratingSpan.textContent = ratings.length > 0 ? ratings.join(' / ') : '--';
  ratingSpan.style.cssText = `color:#faad14;${leftStyle}`;
  const priceSpan = document.createElement('span');
  priceSpan.textContent = priceDisplay.text;
  priceSpan.style.cssText = `color:${priceDisplay.color};${rightStyle}`;
  row1.appendChild(ratingSpan);
  row1.appendChild(priceSpan);

  // Row 2: 分类
  const row2 = document.createElement('div');
  row2.style.cssText = rowStyle;
  const catSpan = document.createElement('span');
  catSpan.textContent = category;
  catSpan.style.cssText = `color:#888;${leftStyle}`;
  row2.appendChild(catSpan);

  label.appendChild(row1);
  label.appendChild(row2);
  card.appendChild(label);
}

// ─── 核心流程 ─────────────────────────────────────────────────────────────────

async function scanAndInject() {
  const newCards = scanNewCards();
  if (newCards.length === 0) return;

  const bookIds = newCards.map(c => c.bookId);
  const prices = await fetchPrices(bookIds);

  for (const { element, bookId } of newCards) {
    injectPriceLabel(element, prices[bookId] || null);
  }
}

// ─── MutationObserver ─────────────────────────────────────────────────────────

let _observer = null;
let _running = false;
let _pending = false;

async function _safeScanAndInject() {
  _pending = false;
  if (_running) {
    _pending = true;
    return;
  }
  _running = true;
  try {
    await scanAndInject();
  } finally {
    _running = false;
  }
  if (_pending) _safeScanAndInject();
}

function startObserver() {
  if (_observer) _observer.disconnect();

  _observer = new MutationObserver(() => {
    _safeScanAndInject();
  });

  _observer.observe(document.body, { childList: true, subtree: true });
}

function stopObserver() {
  if (_observer) {
    _observer.disconnect();
    _observer = null;
  }
  _pending = false;
}

// ─── 入口 ─────────────────────────────────────────────────────────────────────

async function init() {
  stopObserver();
  await scanAndInject();
  startObserver();
}

init();
window.addEventListener('popstate', init);
window.addEventListener('hashchange', init);

// ─── 资产模拟器浮窗 ──────────────────────────────────────────────────────────────

const SIM_STYLES = `
.wrp-sim-btn {
  position: fixed;
  bottom: 24px;
  left: 24px;
  z-index: 99990;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: #1890ff;
  color: #fff;
  border: none;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(24,144,255,0.35);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  transition: transform 0.2s, box-shadow 0.2s;
}
.wrp-sim-btn:hover {
  transform: scale(1.1);
  box-shadow: 0 6px 20px rgba(24,144,255,0.45);
}
.wrp-sim-overlay {
  position: fixed;
  inset: 0;
  z-index: 99999;
  background: rgba(0,0,0,0.45);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s;
}
.wrp-sim-overlay.wrp-sim-open {
  opacity: 1;
  pointer-events: auto;
}
.wrp-sim-modal {
  width: 600px;
  max-height: 90vh;
  overflow-y: auto;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(0,0,0,0.18);
  padding: 24px 28px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.wrp-sim-modal h3 {
  margin: 0 0 16px;
  font-size: 17px;
  color: #1a1a1a;
}
.wrp-sim-top-row {
  display: flex;
  gap: 24px;
  margin-bottom: 16px;
}
.wrp-sim-controls {
  display: flex;
  flex-direction: column;
  gap: 12px;
  flex: 0 0 240px;
  min-width: 200px;
}
.wrp-sim-challenges {
  flex: 1;
  min-width: 0;
}
.wrp-sim-ctrl label {
  display: flex;
  justify-content: space-between;
  font-size: 13px;
  color: #333;
  margin-bottom: 4px;
  font-weight: 600;
}
.wrp-sim-ctrl input[type="range"] {
  width: 100%;
  cursor: pointer;
}
.wrp-sim-stats {
  display: flex;
  gap: 16px;
  padding: 12px 16px;
  background: #f0f7ff;
  border-radius: 8px;
  border-left: 4px solid #1890ff;
  margin-bottom: 16px;
  font-size: 13px;
  color: #1a1a1a;
}
.wrp-sim-stats > div {
  flex: 1;
}
.wrp-sim-stats .wrp-stat-val {
  font-weight: 700;
  color: #1890ff;
}
.wrp-sim-stats .wrp-stat-cost {
  color: #d9363e;
}
.wrp-sim-chart {
  width: 100%;
  height: 380px;
}
.wrp-sim-strategy {
  margin-bottom: 14px;
}
.wrp-sim-strategy h4 {
  margin: 0 0 8px;
  font-size: 14px;
  color: #1a1a1a;
}
.wrp-strategy-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px 20px;
}
.wrp-strategy-col-title {
  font-size: 12px;
  font-weight: 700;
  color: #666;
  margin-bottom: 4px;
  padding-bottom: 4px;
  border-bottom: 1px solid #eee;
}
.wrp-strategy-row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 3px;
  font-size: 12px;
}
.wrp-strategy-row .wrp-strat-label {
  min-width: 38px;
  color: #333;
  flex-shrink: 0;
}
.wrp-strategy-row label {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  cursor: pointer;
  color: #555;
  font-weight: 400;
  margin: 0;
  white-space: nowrap;
}
.wrp-strategy-row input[type="radio"] {
  margin: 0;
  cursor: pointer;
}
.wrp-ch-row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 4px;
  font-size: 12px;
  color: #1a1a1a;
}
.wrp-ch-row input[type="number"] {
  width: 52px;
  padding: 2px 4px;
  font-size: 12px;
  text-align: center;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
}
.wrp-ch-remove {
  width: 20px;
  height: 20px;
  padding: 0;
  line-height: 18px;
  text-align: center;
  border: 1px solid #ff4d4f;
  color: #ff4d4f;
  background: #fff;
  border-radius: 50%;
  cursor: pointer;
  font-size: 14px;
  flex-shrink: 0;
}
.wrp-ch-hint {
  font-size: 11px;
  color: #ff4d4f;
  margin-left: 4px;
  white-space: nowrap;
}
`;

function injectSimStyles() {
  if (document.getElementById('wrp-sim-styles')) return;
  const style = document.createElement('style');
  style.id = 'wrp-sim-styles';
  style.textContent = SIM_STYLES;
  document.head.appendChild(style);
}

function createSimUI() {
  // 浮动按钮
  const btn = document.createElement('button');
  btn.className = 'wrp-sim-btn';
  btn.title = '微信读书资产模拟器';
  btn.innerHTML = '📊';

  // 遮罩层
  const overlay = document.createElement('div');
  overlay.className = 'wrp-sim-overlay';

  // 弹窗内容
  const modal = document.createElement('div');
  modal.className = 'wrp-sim-modal';
  modal.innerHTML = `
    <h3>📊 微信读书资产模拟器</h3>
    <div class="wrp-sim-top-row">
      <div class="wrp-sim-controls">
        <div class="wrp-sim-ctrl">
          <label><span>每周阅读天数</span> <span id="wrpValDays">4 天</span></label>
          <input type="range" id="wrpInpDays" min="0" max="7" step="1" value="4">
        </div>
        <div class="wrp-sim-ctrl">
          <label><span>每周阅读时长</span> <span id="wrpValHours">3 小时</span></label>
          <input type="range" id="wrpInpHours" min="0" max="20" step="0.5" value="3">
        </div>
        <div class="wrp-sim-ctrl">
          <label><span>周二翻牌(卡)</span> <span id="wrpValFlipCard">3 张</span></label>
          <input type="range" id="wrpInpFlipCard" min="0" max="10" step="1" value="3">
        </div>
        <div class="wrp-sim-ctrl">
          <label><span>周二翻牌(币)</span> <span id="wrpValFlipCoin">3 个</span></label>
          <input type="range" id="wrpInpFlipCoin" min="0" max="10" step="1" value="3">
        </div>
      </div>
      <div class="wrp-sim-challenges">
        <label style="font-size:13px;font-weight:600;color:#1a1a1a;margin-bottom:6px;display:block">🏆 挑战 (每次 5 元, 30 天, 间隔 ≥30 天)</label>
        <div id="wrpChallengeList"></div>
        <button id="wrpAddChallenge" style="margin-top:6px;padding:4px 12px;font-size:12px;cursor:pointer;border:1px dashed #1890ff;color:#1890ff;background:#fff;border-radius:4px">+ 添加挑战</button>
      </div>
    </div>
    <div class="wrp-sim-strategy">
      <h4>🎁 奖励策略 (体验卡 / 书币)</h4>
      <div style="font-size:11px;color:#999;margin-bottom:8px">📌 书币每周日统一领取,领取后 30 天过期;体验卡即时获得</div>
      <div class="wrp-strategy-grid">
        <div>
          <div class="wrp-strategy-col-title">阅读时长奖励</div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">5分钟</span><label><input type="radio" name="wrpStrat5m" value="card" checked>1天卡</label><label><input type="radio" name="wrpStrat5m" value="coin">1币</label></div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">30分钟</span><label><input type="radio" name="wrpStrat30m" value="card" checked>1天卡</label><label><input type="radio" name="wrpStrat30m" value="coin">1币</label></div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">1小时</span><label><input type="radio" name="wrpStrat1h" value="card" checked>1天卡</label><label><input type="radio" name="wrpStrat1h" value="coin">2币</label></div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">3小时</span><label><input type="radio" name="wrpStrat3h" value="card" checked>2天卡</label><label><input type="radio" name="wrpStrat3h" value="coin">2币</label></div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">5小时</span><label><input type="radio" name="wrpStrat5h" value="card" checked>2天卡</label><label><input type="radio" name="wrpStrat5h" value="coin">2币</label></div>
        </div>
        <div>
          <div class="wrp-strategy-col-title">阅读天数奖励</div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">读2天</span><label><input type="radio" name="wrpStratD2" value="card" checked>2天卡</label><label><input type="radio" name="wrpStratD2" value="coin">2币</label></div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">读4天</span><label><input type="radio" name="wrpStratD4" value="card" checked>2天卡</label><label><input type="radio" name="wrpStratD4" value="coin">4币</label></div>
          <div class="wrp-strategy-row"><span class="wrp-strat-label">读7天</span><label><input type="radio" name="wrpStratD7" value="card" checked>2天卡</label><label><input type="radio" name="wrpStratD7" value="coin">6币</label></div>
        </div>
      </div>
    </div>
    <div class="wrp-sim-stats">
      <div>90天后会员: <span class="wrp-stat-val" id="wrpOutMembership">0 天</span></div>
      <div>体验卡剩余: <span class="wrp-stat-val" id="wrpOutCards">0 张</span></div>
      <div>累计书币: <span class="wrp-stat-val" id="wrpOutCoins">0</span></div>
      <div>周期花费: <span class="wrp-stat-cost" id="wrpOutCost">0 元</span></div>
    </div>
    <div class="wrp-sim-chart" id="wrpChart"></div>
  `;

  overlay.appendChild(modal);
  document.body.appendChild(btn);
  document.body.appendChild(overlay);

  return { btn, overlay, modal };
}

// 奖励档位定义
const REWARD_TIME = [
  { key: '5m',  hours: 0.083, card: 1, coin: 1 },
  { key: '30m', hours: 0.5,   card: 1, coin: 1 },
  { key: '1h',  hours: 1,     card: 1, coin: 2 },
  { key: '3h',  hours: 3,     card: 2, coin: 2 },
  { key: '5h',  hours: 5,     card: 2, coin: 2 },
];
const REWARD_DAY = [
  { key: 'D2', days: 2, card: 2, coin: 2 },
  { key: 'D4', days: 4, card: 2, coin: 4 },
  { key: 'D7', days: 7, card: 2, coin: 6 },
];

// 获取当前星期几 (1=周一 ... 7=周日),作为模拟第 1 天的星期
function getCurrentDayOfWeek() {
  const d = new Date().getDay(); // 0=周日 ... 6=周六
  return d === 0 ? 7 : d;       // 1=周一 ... 7=周日
}

// 核心模拟算法
function simulateWeRead(daysPerWeek, hoursPerWeek, flipCards, flipCoins, challengeDays, strategy) {
  let membershipDays = 30;
  let experienceCards = 0;
  let bookCoins = 0;
  let totalCost = 0;
  const totalDays = 90;
  const results = [];

  const startDow = getCurrentDayOfWeek(); // 第 1 天对应的星期
  let weekDays = 0;
  let weekHours = 0;
  let claimedT = {};
  let claimedD = {};
  REWARD_TIME.forEach(r => claimedT[r.key] = false);
  REWARD_DAY.forEach(r => claimedD[r.key] = false);

  // 书币延迟领取 & 30 天过期
  let weeklyCoinPending = 0;
  let coinExpiry = []; // [{ amount, expiryDay }]

  for (let day = 1; day <= totalDays; day++) {
    // 星期偏移: 第 1 天 = 当前真实星期
    let dayOfWeek = ((day + startDow - 2) % 7) + 1;

    // ── 每日: 清理过期书币 ──
    while (coinExpiry.length > 0 && coinExpiry[0].expiryDay <= day) {
      bookCoins -= coinExpiry[0].amount;
      coinExpiry.shift();
    }

    // ── 挑战: 付费 5 元激活, 30 天后完成得 30 天会员 ──
    let inChallenge = false;
    for (const cs of challengeDays) {
      if (day === cs) {
        membershipDays += 2;
        totalCost += 5;
      }
      if (day === cs + 29) {
        membershipDays += 30;
      }
      if (day >= cs && day < cs + 30) {
        inChallenge = true;
      }
    }
    const isChallenge = inChallenge;

    // ── 会员每日抽卡 → 书币暂存 ──
    if (membershipDays > 0) {
      weeklyCoinPending += 1;
    }

    // ── 当日阅读 ──
    let readsToday = false;
    let hoursToday = 0;
    if (isChallenge) {
      // 找到当前活跃的挑战,跳过激活日
      const activeCs = challengeDays.find(cs => day >= cs && day < cs + 30);
      readsToday = day !== activeCs;
      hoursToday = readsToday ? 30 / 29 : 0;
    } else {
      readsToday = dayOfWeek <= daysPerWeek;
      hoursToday = daysPerWeek > 0 ? hoursPerWeek / daysPerWeek : 0;
    }

    if (readsToday) {
      weekDays += 1;
      weekHours += hoursToday;

      // 天数打卡奖励: 卡→即时, 币→暂存
      REWARD_DAY.forEach(r => {
        if (weekDays >= r.days && !claimedD[r.key]) {
          claimedD[r.key] = true;
          if (strategy[r.key] === 'coin') weeklyCoinPending += r.coin;
          else experienceCards += r.card;
        }
      });

      // 时长奖励: 卡→即时, 币→暂存
      REWARD_TIME.forEach(r => {
        if (weekHours >= r.hours && !claimedT[r.key]) {
          claimedT[r.key] = true;
          if (strategy[r.key] === 'coin') weeklyCoinPending += r.coin;
          else experienceCards += r.card;
        }
      });
    }

    // ── 周二翻牌: 卡→即时, 币→暂存 ──
    if (dayOfWeek === 2) {
      experienceCards += flipCards;
      weeklyCoinPending += flipCoins;
    }

    // ── 会员天数消耗 & 兑换 ──
    if (membershipDays > 0) {
      membershipDays -= 1;
    }
    // 体验卡满 60 自动兑换 30 天会员 (不限会员是否到期)
    while (experienceCards >= 60) {
      experienceCards -= 60;
      membershipDays += 30;
      totalCost += 6;
    }

    // ── 周日: 发放本周累积的书币, 记录 30 天过期 ──
    if (dayOfWeek === 7) {
      if (weeklyCoinPending > 0) {
        bookCoins += weeklyCoinPending;
        coinExpiry.push({ amount: weeklyCoinPending, expiryDay: day + 30 });
        weeklyCoinPending = 0;
      }
      // 重置周计数器
      weekDays = 0;
      weekHours = 0;
      REWARD_TIME.forEach(r => claimedT[r.key] = false);
      REWARD_DAY.forEach(r => claimedD[r.key] = false);
    }

    results.push({
      day: day,
      membershipDays: membershipDays,
      experienceCards: experienceCards,
      bookCoins: Math.max(0, bookCoins),
      totalCost: totalCost
    });
  }
  return results;
}

function loadECharts() {
  return new Promise((resolve) => {
    const gw = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    if (gw.echarts) { resolve(gw.echarts); return; }
    // CSP 阻止外部 CDN,通过 GM_xmlhttpRequest 抓取后用页面 eval 执行
    GM_xmlhttpRequest({
      method: 'GET',
      url: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js',
      onload(resp) {
        try {
          gw.eval(resp.responseText);
          resolve(gw.echarts || null);
        } catch (e) {
          console.error('[WeReadPrice] ECharts eval 失败:', e);
          resolve(null);
        }
      },
      onerror() { resolve(null); },
      ontimeout() { resolve(null); },
    });
  });
}

let _simChart = null;

async function initSimulator() {
  injectSimStyles();
  const { btn, overlay, modal } = createSimUI();

  // 打开弹窗
  btn.addEventListener('click', async () => {
    overlay.classList.add('wrp-sim-open');

    const echarts = await loadECharts();
    if (!echarts) { console.warn('[WeReadPrice] ECharts 加载失败'); return; }

    // 等一帧,确保 overlay 布局完成、容器有正确尺寸
    await new Promise(r => requestAnimationFrame(r));

    const chartDom = document.getElementById('wrpChart');
    if (chartDom.offsetWidth === 0 || chartDom.offsetHeight === 0) {
      console.warn('[WeReadPrice] 图表容器尺寸为 0,延迟重试');
      await new Promise(r => setTimeout(r, 100));
    }

    if (!_simChart) {
      _simChart = echarts.init(chartDom);
    } else {
      _simChart.resize();
    }

    updateSimChart(_simChart);
  });

  // 点击遮罩关闭
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) {
      overlay.classList.remove('wrp-sim-open');
    }
  });

  // ESC 关闭
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && overlay.classList.contains('wrp-sim-open')) {
      overlay.classList.remove('wrp-sim-open');
    }
  });

  // 滑块事件 — 始终更新数字,有图表时同步刷新
  ['Days','Hours','FlipCard','FlipCoin'].forEach(key => {
    document.getElementById('wrpInp' + key).addEventListener('input', () => {
      saveSimSettings();
      updateSimLabels();
      if (_simChart) updateSimChart(_simChart);
    });
  });

  // 策略单选按钮事件
  modal.querySelectorAll('input[type="radio"]').forEach(radio => {
    radio.addEventListener('change', () => {
      saveSimSettings();
      updateSimLabels();
      if (_simChart) updateSimChart(_simChart);
    });
  });

  // ── 挑战动态管理 ──
  function validateChallenges() {
    const rows = document.querySelectorAll('.wrp-ch-row');
    let prevDay = -Infinity;
    rows.forEach(row => {
      const inp = row.querySelector('.wrp-ch-day');
      const hint = row.querySelector('.wrp-ch-hint');
      const val = parseInt(inp.value, 10);
      if (!isNaN(val) && val - prevDay < 30 && prevDay > 0) {
        hint.textContent = `距上次挑战仅 ${val - prevDay} 天,需 ≥30 天`;
        hint.style.display = '';
      } else {
        hint.style.display = 'none';
      }
      if (!isNaN(val)) prevDay = val;
    });
  }

  function renderChallengeRow(dayVal) {
    const row = document.createElement('div');
    row.className = 'wrp-ch-row';
    row.innerHTML = `
      <span>第</span>
      <input type="number" class="wrp-ch-day" min="1" max="90" value="${dayVal}">
      <span>天开始</span>
      <button class="wrp-ch-remove" title="删除">×</button>
      <span class="wrp-ch-hint" style="display:none"></span>
    `;
    const inp = row.querySelector('.wrp-ch-day');
    const btn = row.querySelector('.wrp-ch-remove');
    const onChange = () => { saveSimSettings(); validateChallenges(); updateSimLabels(); if (_simChart) updateSimChart(_simChart); };
    inp.addEventListener('input', onChange);
    inp.addEventListener('change', onChange);
    btn.addEventListener('click', () => {
      row.remove();
      saveSimSettings();
      validateChallenges();
      updateSimLabels();
      if (_simChart) updateSimChart(_simChart);
    });
    return row;
  }

  const chList = document.getElementById('wrpChallengeList');

  document.getElementById('wrpAddChallenge').addEventListener('click', () => {
    const existing = getChallengeDays();
    const nextDay = existing.length > 0 ? Math.max(...existing) + 30 : 1;
    chList.appendChild(renderChallengeRow(Math.min(nextDay, 90)));
    saveSimSettings();
    validateChallenges();
    updateSimLabels();
    if (_simChart) updateSimChart(_simChart);
  });

  // 恢复缓存设置
  const saved = loadSimSettings();
  if (saved) {
    if (saved.days) document.getElementById('wrpInpDays').value = saved.days;
    if (saved.hours) document.getElementById('wrpInpHours').value = saved.hours;
    if (saved.flipCard) document.getElementById('wrpInpFlipCard').value = saved.flipCard;
    if (saved.flipCoin) document.getElementById('wrpInpFlipCoin').value = saved.flipCoin;
    if (saved.strategy) {
      Object.entries(saved.strategy).forEach(([key, val]) => {
        const radio = document.querySelector(`input[name="wrpStrat${key}"][value="${val}"]`);
        if (radio) radio.checked = true;
      });
    }
    if (saved.challengeDays && saved.challengeDays.length > 0) {
      chList.innerHTML = '';
      saved.challengeDays.forEach(d => chList.appendChild(renderChallengeRow(d)));
      validateChallenges();
    }
  }
}

// 读取当前单选按钮值
function getRadioVal(name) {
  const el = document.querySelector(`input[name="${name}"]:checked`);
  return el ? el.value : 'card';
}

// 读取策略配置
function getStrategy() {
  const s = {};
  REWARD_TIME.forEach(r => s[r.key] = getRadioVal('wrpStrat' + r.key));
  REWARD_DAY.forEach(r => s[r.key] = getRadioVal('wrpStrat' + r.key));
  return s;
}

// 读取挑战开始日数组
function getChallengeDays() {
  const inputs = document.querySelectorAll('.wrp-ch-day');
  return Array.from(inputs).map(inp => parseInt(inp.value, 10)).filter(n => !isNaN(n) && n >= 1).sort((a, b) => a - b);
}

// 读取当前滑块值并运行模拟,返回结果数据(不依赖图表实例)
function getSimData() {
  const d = parseFloat(document.getElementById('wrpInpDays').value);
  const h = parseFloat(document.getElementById('wrpInpHours').value);
  const fCard = parseFloat(document.getElementById('wrpInpFlipCard').value);
  const fCoin = parseFloat(document.getElementById('wrpInpFlipCoin').value);
  const challengeDays = getChallengeDays();
  const strategy = getStrategy();
  return { params: { d, h, fCard, fCoin, challengeDays }, data: simulateWeRead(d, h, fCard, fCoin, challengeDays, strategy) };
}

// 只更新标签和统计数字(不更新图表)
function updateSimLabels() {
  const { params, data } = getSimData();
  document.getElementById('wrpValDays').innerText = `${params.d} 天`;
  document.getElementById('wrpValHours').innerText = `${params.h} 小时`;
  document.getElementById('wrpValFlipCard').innerText = `${params.fCard} 张`;
  document.getElementById('wrpValFlipCoin').innerText = `${params.fCoin} 个`;

  const finalState = data[data.length - 1];
  document.getElementById('wrpOutMembership').innerText = `${finalState.membershipDays} 天`;
  document.getElementById('wrpOutCards').innerText = `${finalState.experienceCards} 张`;
  document.getElementById('wrpOutCoins').innerText = `${finalState.bookCoins}`;
  document.getElementById('wrpOutCost').innerText = `${finalState.totalCost} 元`;
  return data;
}

function updateSimChart(chart) {
  const data = updateSimLabels();
  const xData = data.map(item => `第${item.day}天`);
  const memData = data.map(item => item.membershipDays);
  const cardData = data.map(item => item.experienceCards);
  const coinData = data.map(item => item.bookCoins);

  chart.setOption({
    title: { text: '资产消耗与产出趋势 (90天)', textStyle: { fontSize: 14 } },
    tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
    legend: { data: ['体验卡余额', '会员剩余天数', '书币'], right: 10 },
    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: xData,
      axisTick: { alignWithLabel: true },
      axisLabel: { interval: 14 }
    },
    yAxis: [
      {
        type: 'value',
        name: '天数',
        position: 'left',
        axisLine: { show: true, lineStyle: { color: '#5470C6' } }
      },
      {
        type: 'value',
        name: '币数',
        position: 'right',
        axisLine: { show: true, lineStyle: { color: '#ee6666' } }
      }
    ],
    series: [
      {
        name: '体验卡余额',
        type: 'line',
        step: 'end',
        yAxisIndex: 0,
        data: cardData,
        itemStyle: { color: '#5470C6' },
        areaStyle: { opacity: 0.1 }
      },
      {
        name: '会员剩余天数',
        type: 'line',
        step: 'end',
        yAxisIndex: 0,
        data: memData,
        itemStyle: { color: '#91CC75' },
        areaStyle: { opacity: 0.1 }
      },
      {
        name: '书币',
        type: 'line',
        step: 'end',
        yAxisIndex: 1,
        data: coinData,
        itemStyle: { color: '#ee6666' },
        areaStyle: { opacity: 0.1 }
      }
    ]
  });
}

initSimulator();