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