• coderZoe-nodeseek

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

    油猴脚本```
    // ==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));
    })();

    ```图片

    主楼
  • kaibai

    最憋屈的一次只有1🍗 ^-^

    #1
  • coderZoe-nodeseek

    谁不是呢 ^-^

    #3
  • Jacksonaaa

    🍗 我的 NodeSeek 签到运势:🍀 小有欧气

    签到 139 次,靠签到拿了 718 个鸡腿,平均每次 5.17 个

    同样签 139 次,理论上我手气好过 81% 的人;比每次固定拿 5 个多赚 23 个

    单次最多 10 个,最少 1 个,≥8 的大暴击 14 次

    「手气不错,保持住」

    #2
  • geemi

    固定才是最稳的,试过的都知道 ^-^

    #4
  • coderZoe-nodeseek

    老老实实打工何时是个头,不赌怎么赢 ^-^

    #5
  • 丿啦灬啦啦

    统计失败

    max page is 100

    已经翻过的部分都存下了,点重试会接着翻。

    #6
  • coderZoe-nodeseek

    @Wa1ter #7

    应该是论坛不允许翻太深,我自己没那么多页,没出现这个问题

    ^-^

    #8
  • 丿啦灬啦啦

    那能不能就统计这100页

    #10
  • coderZoe-nodeseek

    @Wa1ter #7 @hitajio-724 #15

    编辑帖子了,改了一版,只统计前一百页的,后面的不让翻了。

    #16
  • Wa1ter

    maxpage只能100吗

    #7
  • UniqueXXL

    4.98个

    #9
  • PoliceBoy-nodeseek

    图片

    #11
  • issues-nodeseek

    🍗 我的 NodeSeek 签到运势:✨ 欧气附体

    签到 648 次,靠签到拿了 3326 个鸡腿,平均每次 5.13 个

    同样签 648 次,理论上我手气好过 94% 的人;比每次固定拿 5 个多赚 86 个

    单次最多 13 个,最少 1 个,≥8 的大暴击 81 次

    「随机数都在偏袒你」

    #12
  • coderZoe-nodeseek

    ❓ 就是你抢了我的鸡腿?

    #17
  • conbe-nodeseek

    🍗 我的 NodeSeek 签到运势:😐 平平无奇

    签到 98 次,靠签到拿了 488 个鸡腿,平均每次 4.98 个

    同样签 98 次,理论上我手气好过 46% 的人;比每次固定拿 5 个少赚 2 个

    单次最多 11 个,最少 1 个,≥8 的大暴击 11 次

    「标准的鸡腿打工人」

    #13
  • conbe-nodeseek

    发布于2026/9/29 14:12:10
    🍗 我的 NodeSeek 签到运势:😐 平平无奇
    签到 98 次,靠签到拿了 488 个鸡腿,平均每次 4.98 个
    同样签 98 次,理论上我手气好过 46% 的人;比每次固定拿 5 个少赚 2 个
    单次最多 11 个,最少 1 个,≥8 的大暴击 11 次
    「标准的鸡腿打工人」

    还差两个,回本我就收手拿固定鸡腿 ^-^

    #14
  • hitajio-724

    超100页了,不给翻呀

    #15
  • dwnb

    我昨天1,操

    #18
  • hitajio-724

    不错,刚好1000个

    图片

    #19
  • vico

    图片

    还不如每天 5 个

    #20
  • coderZoe-nodeseek

    ^-^ 不赌就是赢

    #21
  • domingokkk

    保底五个就行了

    #22
  • 三千

    🍗 我的 NodeSeek 签到运势:✨ 欧气附体

    签到 75 次,靠签到拿了 400 个鸡腿,平均每次 5.33 个

    同样签 75 次,理论上我手气好过 90% 的人;比每次固定拿 5 个多赚 25 个

    单次最多 9 个,最少 1 个,≥8 的大暴击 9 次

    「随机数都在偏袒你」

    #23
  • X1a0P4n9

    🍗 我的 NodeSeek 签到运势:😐 平平无奇

    签到 151 次,靠签到拿了 760 个鸡腿,平均每次 5.03 个

    同样签 151 次,理论上我手气好过 57% 的人;比每次固定拿 5 个多赚 5 个

    单次最多 14 个,最少 1 个,≥8 的大暴击 20 次

    「不亏不赚,稳如老狗」

    #24
  • 陌生人

    我不用统计,我固定选5

    #25

发表回复

登录后回复