WeReadPrice

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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();