logo NodeSeekbeta

随机签到得鸡腿?测测你是非酋还是欧皇

统计你的签到鸡腿平均数据

油猴脚本
// ==UserScript==
// @name         NodeSeek 签到运势
// @namespace    https://www.nodeseek.com/
// @version      1.1.1
// @description  统计你在 NodeSeek 靠签到拿了多少鸡腿、平均每次几个、运气超过多少人,生成一张方便截图炫耀的卡片
// @author       coderZoe
// @match        https://www.nodeseek.com/*
// @match        https://nodeseek.com/*
// @icon         https://www.nodeseek.com/static/image/favicon/favicon-32x32.png
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_setClipboard
// @grant        unsafeWindow
// @noframes
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ================= 可调参数 =================

  const SIGNIN_RE = /^签到收益(\d+)个鸡腿$/;
  const FIXED_REWARD = 5;   // 点「鸡腿 x 5」固定拿 5 个
  const LUCK_MEAN = 5;      // 随机签到单次的参考值:拿它和固定档比,高于它就算赌赢了
  const LUCK_SD = 2.22;     // 随机签到单次的波动(标准差),按 148 次真实随机签到估算
  const BIG_WIN = 8;        // 单次 ≥ 8 个算大暴击
  const PACE_MS = 340;      // 服务器每秒只放行 3 个请求(nginx 限速,超出的直接回 503),按这个间隔发正好不被拦
  const MAX_AHEAD = 8;      // 最多提前排队多少页
  const SAVE_EVERY = 10;    // 第一次翻整个账簿时,每翻 10 页存一次进度
  const MAX_PAGE = 100;     // 论坛只让翻到第 100 页(最近 2000 条),再往后接口会回「max page is 100」
  const CACHE_KEY = uid => `signin-cache-v2-${uid}`;

  // 按「运气超过了多少人」分档,从高往低匹配
  const TIERS = [
    { min: 0.99, emoji: '👑', title: '天选欧皇', theme: 'gold',
      quotes: ['这手气,建议立刻去买张彩票', '鸡腿之神是你亲戚吧', '服务器的随机数在偷偷给你开后门'] },
    { min: 0.90, emoji: '✨', title: '欧气附体', theme: 'purple',
      quotes: ['随机数都在偏袒你', '每天睁眼先吸一口欧气', '别人试手气,你是收手气'] },
    { min: 0.70, emoji: '🍀', title: '小有欧气', theme: 'green',
      quotes: ['比大多数人多啃了几口鸡腿', '手气不错,保持住', '运气站在你这边,但没站太近'] },
    { min: 0.30, emoji: '😐', title: '平平无奇', theme: 'blue',
      quotes: ['随机的尽头是平均,你就是那个平均', '不亏不赚,稳如老狗', '标准的鸡腿打工人'] },
    { min: 0.10, emoji: '🌧️', title: '略微背运', theme: 'gray',
      quotes: ['差一点点,明天一定', '运气在攒着,准备憋个大的', '就差那么一口鸡腿'] },
    { min: 0.01, emoji: '🌚', title: '非酋', theme: 'dark',
      quotes: ['建议改点「鸡腿 x 5」保平安', '随机数对你有意见', '每次试手气都在给系统交学费'] },
    { min: 0, emoji: '💀', title: '究极非酋', theme: 'abyss',
      quotes: ['服务器是不是针对你?', '建议先去庙里拜拜再签到', '这种手气,统计学都要为你破例'] },
  ];
  // 每次都是 5 个:多半一直点的固定档,不参与运气评比
  const ZEN_TIER = { emoji: '🧘', title: '佛系固定党', theme: 'zen',
    quotes: ['从不赌运气,稳稳拿满每一次', '随机数?与我无关', '不求暴击,只求安稳'] };

  const THEMES = {
    gold:   { bg: ['#fff4c2', '#ffd166', '#f59f5a'], text: '#3d2200', panel: 'rgba(255,255,255,.45)', track: 'rgba(61,34,0,.16)', accent: '#c2410c', bar: 'rgba(61,34,0,.32)' },
    purple: { bg: ['#fcd3f0', '#c7a8f5', '#8f7cf2'], text: '#26124a', panel: 'rgba(255,255,255,.42)', track: 'rgba(38,18,74,.16)', accent: '#6d28d9', bar: 'rgba(38,18,74,.3)' },
    green:  { bg: ['#e2fca0', '#9de8a8', '#5ccfa6'], text: '#0b3a2a', panel: 'rgba(255,255,255,.45)', track: 'rgba(11,58,42,.16)', accent: '#047857', bar: 'rgba(11,58,42,.3)' },
    blue:   { bg: ['#eaf2ff', '#cadcf6', '#a7c2ea'], text: '#172a45', panel: 'rgba(255,255,255,.55)', track: 'rgba(23,42,69,.15)', accent: '#2563eb', bar: 'rgba(23,42,69,.28)' },
    gray:   { bg: ['#e0e5ea', '#bcc6cf', '#98a6b3'], text: '#1b2530', panel: 'rgba(255,255,255,.42)', track: 'rgba(27,37,48,.16)', accent: '#475569', bar: 'rgba(27,37,48,.3)' },
    dark:   { bg: ['#40454e', '#272b32', '#15171b'], text: '#e8eaed', panel: 'rgba(255,255,255,.07)', track: 'rgba(255,255,255,.13)', accent: '#a5b4c8', bar: 'rgba(255,255,255,.24)' },
    abyss:  { bg: ['#33215a', '#1a1330', '#08070f'], text: '#e6defe', panel: 'rgba(255,255,255,.06)', track: 'rgba(255,255,255,.11)', accent: '#a78bfa', bar: 'rgba(230,222,254,.24)' },
    zen:    { bg: ['#faf7f0', '#ede6d6', '#d9ceb6'], text: '#3a3122', panel: 'rgba(255,255,255,.5)', track: 'rgba(58,49,34,.15)', accent: '#8a6d3b', bar: 'rgba(58,49,34,.28)' },
  };

  const CARD_W = 420;
  const CARD_H = 716;
  const SCALE = 3;
  const FONT = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Segoe UI", sans-serif';
  const EMOJI_FONT = '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif';

  // ================= 取数据 =================

  class Cancelled extends Error {}
  const sleep = ms => new Promise(r => setTimeout(r, ms));

  function getUser() {
    const cfg = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).__config__;
    const u = cfg && cfg.user;
    if (u && u.member_id) return { id: u.member_id, name: u.member_name };
    const a = document.querySelector('.user-card a.Username');
    const m = a && a.getAttribute('href').match(/\/space\/(\d+)/);
    return m ? { id: Number(m[1]), name: a.textContent.trim() } : null;
  }

  // 所有请求共用一张发送时间表:每个请求预约一个比上一个晚 pace 毫秒的时间点再发,不用等上一个返回。
  // 这样翻页速度只取决于服务器的限速,和网络来回要多久无关
  let nextSlot = 0;
  let pace = PACE_MS;

  // 账簿接口:/api/account/credit/page-N,每页 20 行,每行是 [鸡腿变动, 鸡腿总计, 理由, 时间],新的在前
  async function fetchPage(page, stopped, onRetry) {
    for (let attempt = 1; ; attempt++) {
      const at = Math.max(Date.now(), nextSlot);
      nextSlot = at + pace;
      await sleep(at - Date.now());
      if (stopped()) throw new Cancelled();
      let problem;
      try {
        const res = await fetch(`${location.origin}/api/account/credit/page-${page}`, { credentials: 'include' });
        const body = await res.text();
        // 被限速时返回的是一张 503 网页,而不是数据
        if (body.trimStart().startsWith('{')) {
          const json = JSON.parse(body);
          // 翻过了论坛允许的最大页数:当作账簿到头了,而不是出错
          if (!json.success && /max page/i.test(json.message || '')) return { data: [], total: 0, pageLimited: true };
          if (!json.success) throw Object.assign(new Error(json.message || '账簿接口返回失败,可能没有登录'), { fatal: true });
          pace = Math.max(PACE_MS, pace * 0.9);
          return json;
        }
        problem = `第 ${page} 页被服务器限速了`;
      } catch (e) {
        if (e.fatal) throw e;
        problem = `第 ${page} 页请求失败(${e.message})`;
      }
      if (attempt >= 8) throw new Error(`${problem},重试多次仍然失败`);
      // 被拦了就放慢节奏,并且整体停一秒,让服务器那边的计数降下来
      pace = Math.min(pace * 1.5, 2000);
      nextSlot = Math.max(nextSlot, Date.now() + 1000);
      onRetry(problem);
    }
  }

  // 按页码顺序读账簿,后面的页提前排队发出去;visit 返回 false 就停。
  // 提前量从 1 页开始逐步翻倍,这样平时只需要看第 1 页的时候,不会白白多发一堆请求。
  // 返回的 cut 表示账簿超过了论坛允许翻的页数,最早的那部分读不到
  async function readPages(from, isCancelled, onRetry, visit) {
    const info = { cut: false };
    if (from > MAX_PAGE) return { cut: true };
    let stop = false;
    const stopped = () => stop || isCancelled();
    const queued = new Map();
    let pages = from, pageSize = 0, sent = from - 1;
    try {
      for (let page = from; page <= pages; page++) {
        const ahead = Math.min(MAX_AHEAD, 2 ** (page - from));
        while (sent < pages && sent < page + ahead - 1) {
          const p = ++sent;
          const job = fetchPage(p, stopped, onRetry);
          job.catch(() => {});
          queued.set(p, job);
        }
        const json = await queued.get(page);
        queued.delete(page);
        if (isCancelled()) throw new Cancelled();
        if (json.pageLimited) {
          info.cut = true;
          break;
        }
        if (!json.data.length) break;
        pageSize = pageSize || json.data.length;
        const allPages = Math.ceil(json.total / pageSize);
        info.cut = allPages > MAX_PAGE;
        pages = Math.max(page, Math.min(MAX_PAGE, allPages));
        if (visit(json.data, page, pages, info.cut) === false) break;
      }
    } finally {
      stop = true; // 还没发出去的请求就不发了
    }
    return info;
  }

  // 缓存里记着:已统计到的最新一条、所有签到记录;第一次翻整个账簿没翻完时,还记着翻到了哪里(tail);
  // truncated 表示有一部分签到因为超出论坛允许翻的页数而没读到
  async function collect(uid, force, isCancelled, ui) {
    const saved = force ? null : GM_getValue(CACHE_KEY(uid), null);
    const cache = saved || { newestKey: null, newestTime: null, records: [], tail: { page: 1, lastTime: null, lastKeys: [] }, truncated: false };
    const save = () => GM_setValue(CACHE_KEY(uid), cache);

    // 1) 上次统计之后的新记录:从第 1 页往后翻,翻到上次看过的最新一条就停,通常只要一页
    if (cache.newestKey) {
      const fresh = [];
      let first = null, reached = false;
      const { cut } = await readPages(1, isCancelled, ui.retry, (rows, page) => {
        ui.progress(null, `正在检查新的记录(第 ${page} 页)`);
        for (const row of rows) {
          if (row.join('|') === cache.newestKey || row[3] < cache.newestTime) {
            reached = true;
            return false;
          }
          first = first || row;
          if (SIGNIN_RE.test(row[2])) fresh.push([Number(row[0]), row[3]]);
        }
      });
      // 隔太久没统计,新记录超过了 100 页,中间有一段读不到
      if (!reached && cut) cache.truncated = true;
      if (first) {
        cache.newestKey = first.join('|');
        cache.newestTime = first[3];
        cache.records = fresh.concat(cache.records);
        save();
      }
    }

    // 2) 第一次翻整个账簿,或者接着上次没翻完的地方往后翻
    const tail = cache.tail;
    if (tail) {
      let unsaved = 0;
      try {
        const { cut } = await readPages(tail.page, isCancelled, ui.retry, (rows, page, pages, cut) => {
          for (const row of rows) {
            const key = row.join('|'), time = row[3];
            // 翻页期间有新记录插到最前面,后面的行都会往后挪,已经处理过的行会再出现一次;账簿按时间排序,比处理过的最后一行新的都跳过
            if (tail.lastTime && (time > tail.lastTime || (time === tail.lastTime && tail.lastKeys.includes(key)))) continue;
            if (!cache.newestKey) {
              cache.newestKey = key;
              cache.newestTime = time;
            }
            if (SIGNIN_RE.test(row[2])) cache.records.push([Number(row[0]), time]);
            if (time === tail.lastTime) tail.lastKeys.push(key);
            else Object.assign(tail, { lastTime: time, lastKeys: [key] });
          }
          tail.page = page + 1;
          if (++unsaved >= SAVE_EVERY) {
            save();
            unsaved = 0;
          }
          const limitNote = cut ? `\n论坛只开放最近 ${MAX_PAGE} 页账簿,更早的记录读不到` : '';
          ui.progress(page / pages, `第 ${page} / ${pages} 页,预计还要 ${fmtDuration((pages - page) * pace)}${limitNote}\n进度会自动保存,中途关掉也没关系,下次接着翻`);
        });
        if (cut) cache.truncated = true;
        cache.tail = null;
      } finally {
        save();
      }
    }
    return { records: cache.records, truncated: !!cache.truncated };
  }

  function fmtDuration(ms) {
    const s = Math.ceil(ms / 1000);
    return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`;
  }

  // ================= 统计 =================

  const DAY = 86400000;
  const BEIJING = 8 * 3600000;
  const dayNum = t => Math.floor((Date.parse(t) + BEIJING) / DAY);
  const pad2 = x => String(x).padStart(2, '0');
  function fmtDay(d, sep = '.') {
    const x = new Date(d * DAY);
    return [x.getUTCFullYear(), pad2(x.getUTCMonth() + 1), pad2(x.getUTCDate())].join(sep);
  }

  // 标准正态分布的累积概率(Abramowitz-Stegun 近似,误差 < 1.5e-7)
  function normCdf(z) {
    const x = Math.abs(z) / Math.SQRT2;
    const t = 1 / (1 + 0.3275911 * x);
    const poly = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t;
    const erf = 1 - poly * Math.exp(-x * x);
    return z >= 0 ? (1 + erf) / 2 : (1 - erf) / 2;
  }

  function analyze(records) {
    const n = records.length;
    const dist = new Map();
    let sum = 0, max = -Infinity, min = Infinity, bigWins = 0;
    for (const [amount] of records) {
      sum += amount;
      max = Math.max(max, amount);
      min = Math.min(min, amount);
      if (amount >= BIG_WIN) bigWins++;
      dist.set(amount, (dist.get(amount) || 0) + 1);
    }
    const avg = sum / n;

    const days = [...new Set(records.map(r => dayNum(r[1])))].sort((a, b) => a - b);
    let streak = 0, run = 0;
    days.forEach((d, i) => {
      run = i > 0 && d === days[i - 1] + 1 ? run + 1 : 1;
      streak = Math.max(streak, run);
    });

    // 所有人都从同一个随机规则里抽,所以不需要别人的数据:
    // 签 n 次的平均值会在 LUCK_MEAN 附近浮动,浮动幅度是 LUCK_SD / √n,
    // 算出你偏离了几个浮动单位,再查正态分布,就是「同样签 n 次的人里平均值比你低的比例」
    // 注意 LUCK_MEAN 本身是估出来的(148 次记录算出 4.89,误差约 ±0.18),所以这个百分比只能当娱乐
    const z = (avg - LUCK_MEAN) / (LUCK_SD / Math.sqrt(n));
    const luck = normCdf(z);
    const allFixed = n >= 3 && min === FIXED_REWARD && max === FIXED_REWARD;
    const tier = allFixed ? ZEN_TIER : TIERS.find(t => luck >= t.min);

    return {
      n, sum, avg, max, min, bigWins, dist, streak, luck, allFixed, tier,
      vsFixed: sum - FIXED_REWARD * n,
      quote: tier.quotes[n % tier.quotes.length],
      firstDay: fmtDay(days[0]),
      lastDay: fmtDay(days[days.length - 1]),
    };
  }

  function luckText(p) {
    const v = p * 100;
    if (v >= 99) return Math.min(99.9, v).toFixed(1);
    if (v < 1) return Math.max(0.1, v).toFixed(1);
    return String(Math.round(v));
  }

  const signed = x => (x > 0 ? `+${x}` : String(x));

  // ================= 画卡片 =================

  function roundRect(ctx, x, y, w, h, r) {
    const [tl, tr, br, bl] = (Array.isArray(r) ? r : [r, r, r, r]).map(v => Math.min(v, w / 2, h / 2));
    ctx.beginPath();
    ctx.moveTo(x + tl, y);
    ctx.arcTo(x + w, y, x + w, y + h, tr);
    ctx.arcTo(x + w, y + h, x, y + h, br);
    ctx.arcTo(x, y + h, x, y, bl);
    ctx.arcTo(x, y, x + w, y, tl);
    ctx.closePath();
  }

  function drawText(ctx, str, x, y, { size = 14, weight = 400, color, alpha = 1, align = 'left', baseline = 'alphabetic', font = FONT } = {}) {
    ctx.font = `${weight} ${size}px ${font}`;
    ctx.fillStyle = color;
    ctx.globalAlpha = alpha;
    ctx.textAlign = align;
    ctx.textBaseline = baseline;
    ctx.fillText(str, x, y);
    ctx.globalAlpha = 1;
    ctx.textBaseline = 'alphabetic';
  }

  function measure(ctx, str, size, weight = 400) {
    ctx.font = `${weight} ${size}px ${FONT}`;
    return ctx.measureText(str).width;
  }

  function fitText(ctx, str, size, weight, maxWidth) {
    if (measure(ctx, str, size, weight) <= maxWidth) return str;
    let s = str;
    while (s.length > 1 && measure(ctx, s + '…', size, weight) > maxWidth) s = s.slice(0, -1);
    return s + '…';
  }

  function drawCard(s, user, avatar) {
    const t = THEMES[s.tier.theme];
    const W = CARD_W, H = CARD_H, P = 28, CW = W - 2 * P, CX = W / 2;
    const canvas = document.createElement('canvas');
    canvas.width = W * SCALE;
    canvas.height = H * SCALE;
    const ctx = canvas.getContext('2d');
    ctx.scale(SCALE, SCALE);

    // 背景:圆角 + 斜向渐变 + 角落里一只很淡的大鸡腿
    roundRect(ctx, 0, 0, W, H, 24);
    ctx.save();
    ctx.clip();
    const bg = ctx.createLinearGradient(0, 0, W, H);
    t.bg.forEach((c, i) => bg.addColorStop(i / (t.bg.length - 1), c));
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);
    drawText(ctx, '🍗', W + 30, 250, { size: 170, font: EMOJI_FONT, align: 'right', alpha: 0.07, color: '#000' });

    // 头部:头像、用户名、签到时间范围、右上角标签
    const AV = 46;
    ctx.save();
    ctx.beginPath();
    ctx.arc(P + AV / 2, P + AV / 2, AV / 2, 0, Math.PI * 2);
    ctx.clip();
    ctx.fillStyle = t.panel;
    ctx.fillRect(P, P, AV, AV);
    if (avatar) ctx.drawImage(avatar, P, P, AV, AV);
    else drawText(ctx, user.name.slice(0, 1).toUpperCase(), P + AV / 2, P + AV / 2 + 8, { size: 22, weight: 700, color: t.text, align: 'center' });
    ctx.restore();

    const tag = 'NodeSeek 签到运势';
    const tagW = measure(ctx, tag, 11, 600) + 20;
    roundRect(ctx, W - P - tagW, P + 2, tagW, 22, 11);
    ctx.fillStyle = t.panel;
    ctx.fill();
    drawText(ctx, tag, W - P - tagW / 2, P + 17, { size: 11, weight: 600, color: t.text, align: 'center' });

    const nameX = P + AV + 12;
    const name = fitText(ctx, user.name, 18, 700, W - P - tagW - 10 - nameX);
    drawText(ctx, name, nameX, P + 20, { size: 18, weight: 700, color: t.text });
    const range = s.firstDay === s.lastDay ? s.firstDay : `${s.firstDay} → ${s.lastDay}`;
    drawText(ctx, range, nameX, P + 40, { size: 12, color: t.text, alpha: 0.7 });

    // 评级
    drawText(ctx, s.tier.emoji, CX, 150, { size: 54, font: EMOJI_FONT, align: 'center', color: t.text });
    drawText(ctx, s.tier.title, CX, 196, { size: 34, weight: 800, color: t.text, align: 'center' });
    drawText(ctx, s.quote, CX, 223, { size: 14, color: t.text, alpha: 0.8, align: 'center' });

    // 平均值
    drawText(ctx, '平均每次签到', CX, 262, { size: 13, color: t.text, alpha: 0.7, align: 'center' });
    const avgStr = s.avg.toFixed(2);
    const unit = '个 🍗';
    const avgW = measure(ctx, avgStr, 62, 800);
    const unitW = measure(ctx, unit, 18, 600);
    const avgX = CX - (avgW + 6 + unitW) / 2;
    drawText(ctx, avgStr, avgX, 322, { size: 62, weight: 800, color: t.text });
    drawText(ctx, unit, avgX + avgW + 6, 322, { size: 18, weight: 600, color: t.text });

    // 运气条:左端非酋、右端欧皇,颜色从灰到金,白底小鸡腿标出你的位置
    const luckMid = 345, luckH = 8, luckX = P + 64, luckW = CW - 128;
    ctx.font = `16px ${EMOJI_FONT}`;
    const moonW = ctx.measureText('🌚').width, crownW = ctx.measureText('👑').width;
    drawText(ctx, '🌚', P, luckMid, { size: 16, font: EMOJI_FONT, baseline: 'middle', color: t.text });
    drawText(ctx, '非酋', P + moonW + 4, luckMid + 0.5, { size: 11, weight: 600, color: t.text, alpha: 0.75, baseline: 'middle' });
    drawText(ctx, '👑', W - P, luckMid, { size: 16, font: EMOJI_FONT, baseline: 'middle', align: 'right', color: t.text });
    drawText(ctx, '欧皇', W - P - crownW - 4, luckMid + 0.5, { size: 11, weight: 600, color: t.text, alpha: 0.75, baseline: 'middle', align: 'right' });
    const spectrum = ctx.createLinearGradient(luckX, 0, luckX + luckW, 0);
    spectrum.addColorStop(0, '#4b5567');
    spectrum.addColorStop(0.5, '#aab4c1');
    spectrum.addColorStop(1, '#ffbd2e');
    roundRect(ctx, luckX, luckMid - luckH / 2, luckW, luckH, luckH / 2);
    ctx.fillStyle = spectrum;
    ctx.fill();
    // 圆钮半径 11,两头各收 6,免得运气极端时圆钮压到「非酋」「欧皇」两个字
    const knobX = Math.min(luckX + luckW - 6, Math.max(luckX + 6, luckX + luckW * (s.allFixed ? 0.5 : s.luck)));
    ctx.save();
    ctx.shadowColor = 'rgba(0, 0, 0, .28)';
    ctx.shadowBlur = 6;
    ctx.shadowOffsetY = 1;
    ctx.beginPath();
    ctx.arc(knobX, luckMid, 11, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
    ctx.restore();
    drawText(ctx, '🍗', knobX, luckMid + 1, { size: 12, font: EMOJI_FONT, baseline: 'middle', align: 'center', color: '#000' });
    // 没有别人的数据,这个百分比是按概率推算的:同样签 n 次的人里,平均值比你低的占多少
    const luckLine = s.allFixed ? '一直拿固定档,运气与你无关' : `同样签 ${s.n} 次,理论上你手气好过 ${luckText(s.luck)}% 的人`;
    drawText(ctx, luckLine, CX, luckMid + 31, { size: 13, weight: 600, color: t.text, align: 'center' });

    // 六个小格子
    const cells = [
      [s.n, '次', '签到次数'],
      [s.sum, '个', '签到总收益'],
      [signed(s.vsFixed), '个', `对比固定拿 ${FIXED_REWARD} 个`],
      [s.max, '个', '单次最多'],
      [s.min, '个', '单次最少'],
      [s.streak, '天', '最长连签'],
    ];
    const gap = 10, cellW = (CW - 2 * gap) / 3, cellH = 64, gridY = 390;
    cells.forEach(([value, u, label], i) => {
      const x = P + (i % 3) * (cellW + gap);
      const y = gridY + Math.floor(i / 3) * (cellH + gap);
      roundRect(ctx, x, y, cellW, cellH, 12);
      ctx.fillStyle = t.panel;
      ctx.fill();
      const v = String(value);
      const vw = measure(ctx, v, 22, 800);
      const uw = measure(ctx, u, 12, 500);
      const vx = x + cellW / 2 - (vw + 3 + uw) / 2;
      drawText(ctx, v, vx, y + 31, { size: 22, weight: 800, color: t.text });
      drawText(ctx, u, vx + vw + 3, y + 31, { size: 12, weight: 500, color: t.text, alpha: 0.75 });
      drawText(ctx, label, x + cellW / 2, y + 51, { size: 12, color: t.text, alpha: 0.7, align: 'center' });
    });

    // 手气分布柱状图
    const histTitleY = gridY + 2 * cellH + gap + 34;
    drawText(ctx, '手气分布', P, histTitleY, { size: 13, weight: 700, color: t.text });
    drawText(ctx, `≥${BIG_WIN} 的大暴击共 ${s.bigWins} 次`, W - P, histTitleY, { size: 12, color: t.text, alpha: 0.7, align: 'right' });

    const lo = Math.min(1, s.min), hi = Math.max(10, s.max);
    const slots = hi - lo + 1;
    const slotW = CW / slots;
    const barW = Math.min(22, slotW * 0.64);
    const baseY = histTitleY + 84, maxBarH = 56;
    const maxCount = Math.max(...s.dist.values());
    ctx.fillStyle = t.track;
    ctx.fillRect(P, baseY, CW, 1);
    for (let v = lo; v <= hi; v++) {
      const count = s.dist.get(v) || 0;
      const bx = P + (v - lo) * slotW + (slotW - barW) / 2;
      const mid = bx + barW / 2;
      if (count) {
        const h = Math.max(3, (count / maxCount) * maxBarH);
        roundRect(ctx, bx, baseY - h, barW, h, [5, 5, 0, 0]);
        ctx.fillStyle = v >= BIG_WIN ? t.accent : t.bar;
        ctx.fill();
        drawText(ctx, String(count), mid, baseY - h - 4, { size: 10, weight: 600, color: t.text, alpha: 0.75, align: 'center' });
      }
      if (slots <= 16 || v % 2 === 0) {
        drawText(ctx, String(v), mid, baseY + 15, { size: 11, color: t.text, alpha: 0.65, align: 'center' });
      }
    }

    // 底部
    const note = s.truncated ? `论坛账簿只开放最近 ${MAX_PAGE} 页,更早的签到未计入`
      : s.n < 10 ? 'NodeSeek 签到运势 · 签到次数还少,结果仅供娱乐'
      : `NodeSeek 签到运势 · 统计于 ${fmtDay(dayNum(new Date().toISOString()), '-')}`;
    drawText(ctx, note, CX, H - 18, { size: 11, color: t.text, alpha: 0.5, align: 'center' });

    ctx.restore();
    return canvas;
  }

  function loadImage(src) {
    return new Promise(resolve => {
      const img = new Image();
      const timer = setTimeout(() => resolve(null), 3000);
      img.onload = () => { clearTimeout(timer); resolve(img); };
      img.onerror = () => { clearTimeout(timer); resolve(null); };
      img.src = src;
    });
  }

  // ================= 弹窗 =================

  const STYLE = `
    #nss-overlay { position: fixed; inset: 0; z-index: 2147483000; display: flex; align-items: center; justify-content: center;
      padding: 16px; background: rgba(10, 12, 16, .62); backdrop-filter: blur(3px); font-family: ${FONT}; }
    #nss-overlay * { box-sizing: border-box; }
    .nss-box { display: flex; flex-direction: column; align-items: center; gap: 14px; }
    .nss-card { display: block; border-radius: 24px; box-shadow: 0 24px 60px rgba(0, 0, 0, .45); }
    .nss-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
    .nss-btn { appearance: none; border: 0; margin: 0; border-radius: 999px; padding: 9px 16px; cursor: pointer;
      font: 500 13px/1 ${FONT}; color: #1f2328; background: rgba(255, 255, 255, .92); transition: transform .1s, background .15s; }
    .nss-btn:hover { background: #fff; }
    .nss-btn:active { transform: scale(.96); }
    .nss-btn.primary { background: #2ea44f; color: #fff; }
    .nss-btn.primary:hover { background: #2c974b; }
    .nss-panel { width: 320px; max-width: 100%; padding: 22px 24px; border-radius: 18px; background: #1f2328; color: #e6edf3;
      box-shadow: 0 24px 60px rgba(0, 0, 0, .45); }
    .nss-panel-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
    .nss-panel-text { font-size: 13px; line-height: 1.6; color: #b1bac4; white-space: pre-line; }
    .nss-progress { height: 8px; border-radius: 4px; background: rgba(255, 255, 255, .12); overflow: hidden; margin-bottom: 10px; }
    .nss-progress i { display: block; height: 100%; width: 0; border-radius: 4px; background: #f59f5a; transition: width .2s; }
    .nss-panel .nss-actions { justify-content: flex-end; margin-top: 16px; }
    .nss-toast { position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%); z-index: 2147483001; padding: 10px 18px;
      border-radius: 999px; background: rgba(31, 35, 40, .95); color: #fff; font: 500 13px/1.4 ${FONT}; pointer-events: none; }
  `;

  let overlay = null;
  let runSeq = 0;

  function el(tag, className, text) {
    const e = document.createElement(tag);
    if (className) e.className = className;
    if (text != null) e.textContent = text;
    return e;
  }

  function button(label, onClick, { primary = false, title } = {}) {
    const b = el('button', primary ? 'nss-btn primary' : 'nss-btn', label);
    b.type = 'button';
    if (title) b.title = title;
    b.addEventListener('click', onClick);
    return b;
  }

  function onKey(e) {
    if (e.key === 'Escape') closeOverlay();
  }

  function fitCard() {
    const card = overlay && overlay.querySelector('.nss-card');
    if (!card) return;
    const k = Math.min(1, (innerHeight - 100) / CARD_H, (innerWidth - 32) / CARD_W);
    card.style.width = `${CARD_W * k}px`;
    card.style.height = `${CARD_H * k}px`;
  }

  function openOverlay() {
    closeOverlay();
    if (!document.getElementById('nss-style')) {
      const style = el('style', null, STYLE);
      style.id = 'nss-style';
      document.head.appendChild(style);
    }
    overlay = el('div');
    overlay.id = 'nss-overlay';
    overlay.addEventListener('click', e => { if (e.target === overlay) closeOverlay(); });
    document.body.appendChild(overlay);
    document.addEventListener('keydown', onKey);
    window.addEventListener('resize', fitCard);
  }

  function closeOverlay() {
    runSeq++; // 让还在翻页的那次统计停下来
    if (overlay) overlay.remove();
    overlay = null;
    document.removeEventListener('keydown', onKey);
    window.removeEventListener('resize', fitCard);
  }

  function showPanel(title, text, actions = []) {
    overlay.replaceChildren();
    const panel = el('div', 'nss-panel');
    panel.append(el('div', 'nss-panel-title', title));
    const progress = el('div', 'nss-progress');
    progress.append(el('i'));
    progress.hidden = true;
    const body = el('div', 'nss-panel-text', text);
    panel.append(progress, body);
    if (actions.length) {
      const bar = el('div', 'nss-actions');
      bar.append(...actions);
      panel.append(bar);
    }
    overlay.append(panel);
    return {
      set(ratio, msg) {
        progress.hidden = ratio == null;
        if (ratio != null) progress.firstChild.style.width = `${Math.round(ratio * 100)}%`;
        body.textContent = msg;
      },
      note(msg) {
        body.textContent = msg;
      },
    };
  }

  function toast(msg) {
    const t = el('div', 'nss-toast', msg);
    document.body.appendChild(t);
    setTimeout(() => t.remove(), 2200);
  }

  function shareText(s) {
    const lines = [
      `🍗 我的 NodeSeek 签到运势:${s.tier.emoji} ${s.tier.title}`,
      `签到 ${s.n} 次,靠签到拿了 ${s.sum} 个鸡腿,平均每次 ${s.avg.toFixed(2)} 个`,
    ];
    if (s.allFixed) {
      lines.push('一直拿固定档,从不赌运气');
    } else {
      const diff = s.vsFixed >= 0 ? `多赚 ${s.vsFixed}` : `少赚 ${-s.vsFixed}`;
      lines.push(`同样签 ${s.n} 次,理论上我手气好过 ${luckText(s.luck)}% 的人;比每次固定拿 ${FIXED_REWARD} 个${diff} 个`);
      lines.push(`单次最多 ${s.max} 个,最少 ${s.min} 个,≥${BIG_WIN} 的大暴击 ${s.bigWins} 次`);
    }
    lines.push(`「${s.quote}」`);
    if (s.truncated) lines.push(`(论坛账簿只开放最近 ${MAX_PAGE} 页,只统计了 ${s.firstDay} 以来的签到)`);
    return lines.join('\n');
  }

  async function copyImage(canvas) {
    try {
      const blob = new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
      await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
      toast('图片已复制,可以直接粘贴');
    } catch (e) {
      toast('浏览器不让直接复制图片,请用「下载图片」');
    }
  }

  function downloadImage(canvas, user) {
    canvas.toBlob(blob => {
      const url = URL.createObjectURL(blob);
      const a = el('a');
      a.href = url;
      a.download = `NodeSeek签到运势-${user.name}.png`;
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    }, 'image/png');
  }

  function showCard(canvas, s, user) {
    overlay.replaceChildren();
    const box = el('div', 'nss-box');
    canvas.className = 'nss-card';
    const actions = el('div', 'nss-actions');
    actions.append(
      button('复制图片', () => copyImage(canvas), { primary: true }),
      button('下载图片', () => downloadImage(canvas, user)),
      button('复制文字', () => { GM_setClipboard(shareText(s), 'text'); toast('文字已复制'); }),
      button('重新统计', () => run(true), { title: '清掉缓存,从第一页重新翻整个账簿' }),
      button('关闭', closeOverlay),
    );
    box.append(canvas, actions);
    overlay.append(box);
    fitCard();
  }

  // ================= 主流程 =================

  async function run(force = false) {
    openOverlay();
    const mine = runSeq;
    const isCancelled = () => mine !== runSeq;
    const user = getUser();
    if (!user) {
      showPanel('没找到登录信息', '请先登录 NodeSeek 再来统计。', [button('关闭', closeOverlay)]);
      return;
    }

    const loading = showPanel('🍗 正在翻你的鸡腿账本…', '准备中');
    try {
      const { records, truncated } = await collect(user.id, force, isCancelled, {
        progress: (ratio, msg) => { if (!isCancelled()) loading.set(ratio, msg); },
        retry: msg => { if (!isCancelled()) loading.note(`${msg},放慢一点再试…`); },
      });
      if (isCancelled()) return;
      if (!records.length) {
        showPanel('还没有签到记录', '账簿里没找到「签到收益」的记录,先去签个到吧。', [button('关闭', closeOverlay)]);
        return;
      }
      const s = { ...analyze(records), truncated };
      const avatar = await loadImage(`${location.origin}/avatar/${user.id}.png`);
      if (isCancelled()) return;
      showCard(drawCard(s, user, avatar), s, user);
    } catch (e) {
      if (e instanceof Cancelled || isCancelled()) return;
      console.error('[NodeSeek 签到运势]', e);
      // 服务器明确拒绝(比如没登录)时重试没用,只有网络、限速这类问题才提示接着翻
      const hint = e.fatal ? '' : '\n已经翻过的部分都存下了,点重试会接着翻。';
      showPanel('统计失败', `${e.message || e}${hint}`,
        [button('重试', () => run(false), { primary: true }), button('关闭', closeOverlay)]);
    }
  }

  GM_registerMenuCommand('🍗 统计签到运势', () => run(false));
})();

image

1234
  • 最憋屈的一次只有1🍗 xhj017

  • 🍗 我的 NodeSeek 签到运势:🍀 小有欧气
    签到 139 次,靠签到拿了 718 个鸡腿,平均每次 5.17 个
    同样签 139 次,理论上我手气好过 81% 的人;比每次固定拿 5 个多赚 23 个
    单次最多 10 个,最少 1 个,≥8 的大暴击 14 次
    「手气不错,保持住」

  • 固定才是最稳的,试过的都知道 xhj001

  • @geemi #4 老老实实打工何时是个头,不赌怎么赢 xhj020

  • 统计失败
    max page is 100
    已经翻过的部分都存下了,点重试会接着翻。

  • maxpage只能100吗

  • 4.98个

1234

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有72116位seeker

🎉欢迎新用户🎉