登录

烧饼刮刮乐助手:自动买票刮开、面额张数可选、大奖停止

15215
  • 楼主

    刮刮乐目前每一批都要重复点买票、刮开,再继续买下一批,所以你就需要 烧饼刮刮乐助手,把这些重复操作自动完成。

    支持选择面额和每批张数,统计投入、返还与净额,也可以设置中了百倍大奖或饼王徽奖池后停止。

    当前版本:v1.1.0
    运行方式:浏览器安装 Tampermonkey(油猴)后使用
    适用页面:烧饼刮刮乐

    一、支持哪些功能?

    功能说明
    自动买票、刮开点击开始后,自动购买、刮开本批票,再继续下一批
    面额选择支持 10、25、50、100、200 游戏币
    每批张数选择支持一次购买 1、2、3、5、10 张同一面额的票
    购买参数同步助手面板与游戏页面的选项同步,并显示每批实际花费
    大奖停止百倍烤炉、饼王徽奖池可以分别勾选,命中后停止继续买票
    批次控制可设置批次间隔和批次上限,上限填 0 表示不限批次
    本轮统计显示购买批数、完成批数、完成张数、中奖张数、投入、返还和净额
    旧票处理页面恢复了未刮完的票时,先处理这些票
    悬浮窗支持拖动和收起,方便调整位置

    脚本负责自动执行页面操作,开奖结果和奖金以站点结算为准。

    二、怎么使用?

    默认批次间隔为 3 秒,批次上限为 10 批。间隔支持 2~60 秒,批次上限填 0 表示持续运行,直到手动停止、命中已勾选的停止条件,或遇到异常。

    批次上限按买票次数计算。 例如,每批 5 张、上限 10 批,正常完成时一共购买 50 张。

    运行过程中会锁定面额和张数。需要修改时,先停止并处理完当前批次,再调整参数重新开始。

    三、统计怎么看?

    面板会显示:

    • 新购批次、完成批次:分别表示本轮买下多少批、已经处理完成多少批。
    • 完成张数、中奖张数:统计已完成批次中的票数和有奖票数。
    • 投入、返还、净额:投入包含票价和抽成,返还按实际到账奖金累计,净额为返还减去投入。

    例如,选择 200 面额、每批 2 张:

    每批投入:412 游戏币
    
    如果一张中百倍,另一张没中:
    本批返还:20,000 游戏币
    本批净额:+19,588 游戏币
    

    金额统计以本轮已完成的新购批次为准。 正在处理的批次,会在完成后计入;补刮之前留下的旧票不计入本轮统计。

    “中奖张数” 包含 ×1 奖励,因此有奖不一定代表净赚。停止后再次点击 “开始”,会开启新一轮统计。

    四、“中奖停止” 如何生效?

    可以分别勾选:

    • 中了百倍烤炉停止
    • 中了饼王徽奖池停止

    命中条件后,助手会完成当前批次的刮开与统计,然后停止购买下一批。

    例如一次买了 10 张,其中一张中了目标奖项,其余票已经一起购买,仍会完成这一批的处理。希望按单张判断是否继续,可以把每批张数设为 1 张。

    五、使用时留意这几点

    • 选择面额和张数后,需要手动点击 “开始” 才会运行。
    • 页面切到后台时,助手会暂缓新操作;回到前台后,继续仍在运行的任务。
    • 余额不足、请求异常或页面状态无法识别时,助手会停止并显示原因。
    • 网站改版后,脚本可能需要重新适配。

    效果图

    image.webp

    // ==UserScript==
    // @name         烧饼刮刮乐助手
    // @namespace    sb-scratch-helper
    // @version      1.1.0
    // @description  面板选择面额和每批张数,自动买票刮开,支持限批、中奖停止及可拖动窗口。
    // @match        https://sb.sb/*
    // @run-at       document-idle
    // @sandbox      raw
    // @grant        none
    // @noframes
    // ==/UserScript==
    
    /*
     * 安装:Tampermonkey 添加新脚本,替换全部内容并保存,然后刷新游戏页。
     * 使用:在助手中选择面额与每批张数,再点击“开始”。默认最多新购10批,间隔3秒。
     * 选项与原页面双向同步;运行中或当前票未处理完时锁定,停止且本批完成后可修改。
     * 0批表示不限批次;首次可设为1批检查。百倍/奖池停止默认勾选,可自行取消。
     * 统计:每次开始重置,只累计本轮新购且刮开确认完成的批次;补刮旧票不重复记账。
     * 停止:不再发起新的点击;游戏已经发出的请求、已经开始的动画仍可能完成。
     * 后台:暂停新操作,回前台继续;刷新、跳转或请求异常不会自动续跑。
     * 拖动标题可移动窗口;收起按钮保留可拖动的标题栏。
     */
    
    (() => {
      'use strict';
      if (window.__SB_SCRATCH_HELPER_V1__) return;
      window.__SB_SCRATCH_HELPER_V1__ = true;
    
      const VERSION = '1.1.0';
      const STORE = 'sb-scratch-helper-settings-v1';
      const TIMEOUT = 120000;
      const DEFAULTS = { interval: 3, maxBatches: 10, stopTop: true, stopPool: true };
      let ctx = null;
      let run = null;
      let host = null;
      let ui = null;
      let starting = false;
      let startEpoch = 0;
      let releaseLock = null;
      let serial = 0;
      let saved = {};
      try { saved = JSON.parse(localStorage.getItem(STORE) || '{}'); } catch (_) {}
      if (!saved || typeof saved !== 'object') saved = {};
      const settings = { ...DEFAULTS, ...saved };
    
      const num = value => Number.isFinite(Number(value)) ? Number(value) : NaN;
      const tidy = value => String(value || '').replace(/\s+/g, ' ').trim();
      const onPage = () => location.hostname === 'sb.sb' && /^\/games\/scratch\/?$/.test(location.pathname);
      const q = (selector, parent = ctx?.root) => parent?.querySelector(selector);
      const fmt = value => Number(value).toLocaleString('zh-CN');
      const money = value => (value > 0 ? '+' : '') + fmt(value);
    
      function save() {
        try { localStorage.setItem(STORE, JSON.stringify(settings)); } catch (_) {}
      }
    
      function visible(el) {
        return !!el && !el.hidden && !!el.getClientRects().length &&
          getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden';
      }
    
      function usable(el) {
        return visible(el) && !el.disabled && !el.classList.contains('is-busy') &&
          el.getAttribute('aria-busy') !== 'true' && el.getAttribute('aria-disabled') !== 'true';
      }
    
      function state() {
        const button = ctx?.main;
        if (!button) return 'unknown';
    
        const label = tidy(button.getAttribute('aria-label'));
    
        if (button.getAttribute('aria-busy') === 'true' || /出票中/.test(label)) return 'waiting';
        if (button.classList.contains('is-busy')) return 'wiping';
        if (/^刮开(?:这张)?$/.test(label)) return 'ready';
        if (label === '下一张') return 'shown';
    
        if (!ctx.form.classList.contains('is-batch') && /买\s*\d+\s*张/.test(label)) {
          return label.startsWith('再来一批') ? 'done' : 'idle';
        }
    
        return 'unknown';
      }
    
      function selected() {
        const bet = num(q('input[name="bet"]:checked', ctx.form)?.value);
        const count = num(q('input[name="n"]:checked', ctx.form)?.value);
    
        if (!Number.isInteger(bet) || bet <= 0 || ![1, 2, 3, 5, 10].includes(count)) {
          throw new Error('没有读到有效的面额和数量,请先在游戏中选择。');
        }
    
        return { bet, count };
      }
    
      function nativeChoices(name) {
        if (!ctx?.form || !['bet', 'n'].includes(name)) return [];
    
        return Array.from(ctx.form.querySelectorAll(`input[name="${name}"]`))
          .filter(input => Number.isInteger(Number(input.value)) && Number(input.value) > 0);
      }
    
      function canEditPurchaseChoices() {
        const record = ctx?.current;
    
        const batchReady = !record || record.finished ||
          (record.status !== 'pending' && record.displayDone && allAcknowledged(record) && !record.pollsPending);
    
        return !!ctx?.live && !run?.running && !starting && ctx.buyPending === 0 &&
          batchReady && ['idle', 'done'].includes(state());
      }
    
      function syncPurchaseChoices() {
        if (!ui?.bet || !ui?.count || !ctx) return;
    
        const editable = canEditPurchaseChoices();
    
        for (const [name, field, unit, runKey] of [
          ['bet', ui.bet, ' 游戏币', 'bet'],
          ['n', ui.count, ' 张', 'count']
        ]) {
          const inputs = nativeChoices(name);
          const signature = inputs.map(input => `${input.value}:${!!input.disabled}`).join('|');
    
          if (field.dataset.choices !== signature) {
            field.replaceChildren();
    
            for (const input of inputs) {
              const option = document.createElement('option');
              option.value = String(input.value);
              option.textContent = input.value + unit + (input.disabled ? '(不可选)' : '');
              option.disabled = !!input.disabled;
              field.appendChild(option);
            }
    
            field.dataset.choices = signature;
          }
    
          const current = inputs.find(input => input.checked);
          field.value = String(run?.running ? run[runKey] : current?.value || '');
          field.disabled = !editable || inputs.length === 0;
        }
    
        if (ui.purchaseHint) {
          ui.purchaseHint.textContent = run?.running || starting
            ? '本轮面额和张数已锁定,停止并完成当前批次后可修改。'
            : editable
              ? '与游戏页面同步;仅更改选项,点击“开始”后才会自动买票。'
              : '当前还有票正在处理,完成这一批后可修改面额和张数。';
        }
      }
    
      function changePurchaseChoice(name, value) {
        if (!canEditPurchaseChoices()) {
          draw();
          status('请先停止助手并完成当前批次,再修改面额和张数。');
          return false;
        }
    
        const input = nativeChoices(name).find(item => Number(item.value) === Number(value));
    
        if (!input || input.disabled) {
          draw();
          status('该选项当前不可用,请检查游戏币余额或选择其他档位。', true);
          return false;
        }
    
        const before = selected();
        ctx.syncingChoices = true;
    
        try {
          // 原页面 change 会执行金额重算和 afford;这里只改选项,不提交表单。
          input.checked = true;
          input.dispatchEvent(new Event('change', { bubbles: true }));
    
          const actual = selected();
          draw();
    
          const key = name === 'bet' ? 'bet' : 'count';
          const other = name === 'bet' ? 'count' : 'bet';
    
          if (actual[key] !== Number(value)) {
            status('原页面没有接受该选项,已显示实际可用的面额和张数。', true);
            return false;
          }
    
          const description =
            `${actual.bet} 币 × ${actual.count} 张,每批 ${fmt(cost(actual.bet, actual.count))} 游戏币。`;
    
          if (actual[other] !== before[other]) {
            status('原页面同时调整了另一项,请确认:' + description);
          } else {
            status('已选择:' + description + '点击“开始”运行。');
          }
    
          return true;
        } catch (error) {
          draw();
          status('选项同步失败:' + (error.message || '请检查页面'), true);
          return false;
        } finally {
          ctx.syncingChoices = false;
        }
      }
    
      function fee(bet) {
        return Math.floor(bet * ctx.rake / 100);
      }
    
      function cost(bet, count) {
        return (bet + fee(bet)) * count;
      }
    
      function balance() {
        for (const el of document.querySelectorAll('[data-wheel-coins]')) {
          const text = tidy(el.textContent).replace(/,/g, '');
          if (/^\d+$/.test(text)) return Number(text);
        }
    
        return ctx.coins;
      }
    
      function netOf(record) {
        return record.tickets.reduce(
          (sum, ticket) => sum + ticket.paid - ticket.bet - fee(ticket.bet),
          0
        );
      }
    
      function endpoint(value) {
        if (!value) return '';
    
        const url = new URL(value, location.href);
    
        if (url.origin !== location.origin) {
          throw new Error('游戏接口地址发生变化,请停止并检查页面。');
        }
    
        url.hash = '';
        return url.href;
      }
    
      function status(text, error = false) {
        if (!ui) return;
        ui.status.textContent = text;
        ui.status.classList.toggle('error', error);
      }
    
      function log(text) {
        const line = new Date().toLocaleTimeString('zh-CN', { hour12: false }) + ' ' + text;
    
        if (ui) {
          ui.lines.unshift(line);
          ui.lines = ui.lines.slice(0, 7);
          ui.log.textContent = ui.lines.join('\n');
        }
    
        console.info('[刮刮乐助手]', text);
      }
    
      function draw() {
        if (!ui) return;
    
        const active = !!run?.running;
        ui.start.disabled = active || starting;
        ui.stop.disabled = !active && !starting;
        ui.fields.forEach(el => { el.disabled = active || starting; });
    
        syncPurchaseChoices();
    
        const stats = run?.stats || {
          bought: 0,
          completed: 0,
          tickets: 0,
          wins: 0,
          spent: 0,
          paid: 0
        };
    
        ui.stats.textContent =
          `新购 ${stats.bought} 批 · 完成 ${stats.completed} 批\n` +
          `完成 ${stats.tickets} 张 · 中奖 ${stats.wins} 张\n` +
          `投入 ${fmt(stats.spent)} · 返还 ${fmt(stats.paid)}\n` +
          `净额 ${money(stats.paid - stats.spent)} 游戏币`;
    
        try {
          const plan = active ? run : selected();
          ui.plan.textContent =
            `${plan.bet} 币 × ${plan.count} 张 · 每批 ${fmt(cost(plan.bet, plan.count))} 币(含抽成)`;
        } catch (_) {
          ui.plan.textContent = '等待游戏加载或选择面额';
        }
      }
    
      function stop(reason, error = false) {
        startEpoch++;
    
        const active = run?.running;
        if (run) run.running = false;
        starting = false;
    
        if (releaseLock) {
          const release = releaseLock;
          releaseLock = null;
          release();
        }
    
        status(reason, error);
    
        if (active || error) log(reason);
        draw();
      }
    
      function fail(message, uncertain = false, target = ctx) {
        if (!target || target !== ctx || !target.live) return;
    
        if (uncertain) target.uncertain = true;
        target.lastError = message;
    
        if (run?.running) stop(message, true);
        else status(message, true);
      }
    
      function touch() {
        if (run?.running) run.waitSince = Date.now();
      }
    
      // 只观察原页面的 fetch:原参数、原 Promise 和原响应均原样交还页面。
      // 不创建购买、开奖轮询或揭示请求,不修改 request_id,不读取或导出 Cookie。
      function observeFetch(target) {
        const original = window.fetch;
    
        function watched(input, init) {
          let route = null;
          let method = 'GET';
    
          try {
            const rawURL =
              typeof input === 'string' || input instanceof URL
                ? String(input)
                : input.url;
    
            method = String(init?.method || input?.method || 'GET').toUpperCase();
            route = target.live ? target.routes.get(endpoint(rawURL)) : null;
    
            if (route && method !== (route.kind === 'poll' ? 'GET' : 'POST')) {
              route = null;
            }
          } catch (_) {
            route = null;
          }
    
          const owner = route?.kind === 'buy' ? target.expectedBuy : null;
    
          if (route?.kind === 'buy') {
            target.expectedBuy = null;
            target.buyPending++;
    
            if (run?.running && !owner) {
              stop('检测到手动买票,助手已停止,请等这批完成。');
            }
          }
    
          if (route?.kind === 'reveal') {
            route.record.acks.set(route.url, 'pending');
            route.record.revealStarted = true;
          }
    
          if (route?.kind === 'poll') {
            route.record.pollsPending++;
    
            if (
              route.record.revealClicked ||
              route.record.revealStarted ||
              route.record.finished
            ) {
              fail(
                '页面在刮开后再次查询旧批次,已停止;请刷新以同步票据状态。',
                true,
                target
              );
            }
          }
    
          let promise;
    
          try {
            promise = Reflect.apply(original, this, arguments);
          } catch (error) {
            if (route?.kind === 'buy') target.buyPending--;
            if (route?.kind === 'poll') route.record.pollsPending--;
    
            if (route) {
              fail(
                '游戏请求没有发出:' + (error.message || '未知错误'),
                route.kind !== 'poll',
                target
              );
            }
    
            throw error;
          }
    
          if (route) {
            Promise.resolve(promise).then(async response => {
              if (!target.live || target !== ctx) return;
    
              if (route.kind === 'poll' && route.record !== target.current) {
                fail(
                  '收到了上一批迟到的开奖响应,已停止;请刷新以同步票据状态。',
                  true,
                  target
                );
                return;
              }
    
              if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
              }
    
              if (
                response.redirected &&
                response.url &&
                /\/login(?:\/|\?)/.test(response.url)
              ) {
                throw new Error('登录已失效');
              }
    
              const body = await response.clone().text();
    
              if (!target.live || target !== ctx) return;
    
              if (route.kind === 'reveal') {
                if (/^\s*</.test(body)) {
                  throw new Error('服务器返回了网页,请检查登录状态');
                }
    
                if (body.trim().startsWith('{')) {
                  const data = JSON.parse(body);
    
                  if (data.error || data.ok === false || data.success === false) {
                    throw new Error(
                      String(data.error || data.message || '揭示未成功')
                    );
                  }
                }
    
                route.record.acks.set(route.url, 'ok');
                touch();
                return;
              }
    
              const data = JSON.parse(body);
    
              if (data?.error) {
                if (
                  route.kind === 'buy' &&
                  ['pending', 'unscratched'].includes(data.code) &&
                  data.batch
                ) {
                  const recovered = newRecord(data.batch, null, target);
    
                  if (run?.running) {
                    run.record = recovered;
                    run.awaitingBuy = false;
                    log('站点返回尚未完成的旧票,先补刮;不计为本轮新购。');
                  }
    
                  return;
                }
    
                fail('游戏提示:' + String(data.error), false, target);
                return;
              }
    
              if (route.kind === 'buy') {
                const record = newRecord(data, owner, target);
    
                if (owner) owner.stats.bought++;
    
                if (run?.running && owner === run) {
                  run.record = record;
                  run.awaitingBuy = false;
                  log(`买票已受理:${run.bet} 币 × ${run.count} 张,等待开奖。`);
                }
    
                draw();
              } else {
                updateRecord(route.record, data, target);
              }
            }).catch(error => {
              if (route.kind === 'reveal') {
                route.record.acks.set(route.url, 'failed');
              }
    
              const name =
                route.kind === 'buy'
                  ? '买票'
                  : route.kind === 'poll'
                    ? '开奖查询'
                    : '刮开确认';
    
              fail(
                `${name}异常:${error.message || '网络错误'}。助手已停,请核对页面后刷新。`,
                route.kind !== 'poll',
                target
              );
            }).finally(() => {
              if (route.kind === 'buy') target.buyPending--;
              if (route.kind === 'poll') route.record.pollsPending--;
            }).catch(() => {});
          }
    
          return promise;
        }
    
        window.fetch = watched;
    
        target.restoreFetch = () => {
          if (window.fetch === watched) window.fetch = original;
        };
      }
    
      function updateRecord(record, data, target) {
        if (!data || typeof data !== 'object' || !Array.isArray(data.tickets)) {
          throw new Error('没有识别到批次数据,请刷新页面后重试。');
        }
    
        record.status = data.status;
        record.count = num(data.n);
    
        if (Number.isFinite(num(data.coins))) {
          target.coins = num(data.coins);
        }
    
        const jsonURL = endpoint(data.json_url);
    
        if (jsonURL) {
          target.routes.set(jsonURL, {
            kind: 'poll',
            record,
            url: jsonURL
          });
        }
    
        record.tickets = data.tickets.map(ticket => ({
          id: String(ticket.id ?? ''),
          bet: num(ticket.bet),
          paid: num(ticket.paid),
          symbol: num(ticket.symbol),
          revealed: !!ticket.revealed,
          url: endpoint(ticket.reveal_url)
        }));
    
        for (const ticket of record.tickets) {
          if (ticket.url) {
            target.routes.set(ticket.url, {
              kind: 'reveal',
              record,
              url: ticket.url
            });
    
            if (ticket.revealed) {
              record.acks.set(ticket.url, 'ok');
            } else if (!record.acks.has(ticket.url)) {
              record.acks.set(ticket.url, 'needed');
            }
          }
        }
    
        if (record.status !== 'pending') {
          const ids = new Set(record.tickets.map(ticket => ticket.id));
    
          if (
            !record.tickets.length ||
            record.count !== record.tickets.length ||
            ids.size !== record.count ||
            record.tickets.some(ticket =>
              !ticket.id ||
              !Number.isInteger(ticket.bet) ||
              ticket.bet <= 0 ||
              !Number.isFinite(ticket.paid) ||
              ticket.paid < 0 ||
              !Number.isInteger(ticket.symbol) ||
              ticket.symbol < -1 ||
              ticket.symbol > 8 ||
              (!ticket.revealed && !ticket.url)
            )
          ) {
            throw new Error('开奖字段与当前脚本不兼容,已停止自动操作。');
          }
    
          if (
            record.owner &&
            (
              record.count !== record.owner.count ||
              record.tickets.some(ticket => ticket.bet !== record.owner.bet)
            )
          ) {
            throw new Error('返回的面额或张数与启动设置不同,请检查本批票。');
          }
    
          record.serverDone = record.tickets.every(ticket => ticket.revealed);
    
          if (record.serverDone) {
            record.displayDone = true;
          }
    
          if (record.owner && !record.settledLogged) {
            record.settledLogged = true;
    
            log(
              `开奖完成:${record.count} 张,返还 ` +
              `${fmt(record.tickets.reduce((sum, ticket) => sum + ticket.paid, 0))} 游戏币。`
            );
          }
        }
      }
    
      function newRecord(data, owner, target) {
        // 一个时刻只跟踪当前批次,避免无限运行时积累旧票和旧 URL。
        target.routes.clear();
    
        target.routes.set(target.buyURL, {
          kind: 'buy',
          url: target.buyURL
        });
    
        const record = {
          serial: ++serial,
          owner,
          tickets: [],
          acks: new Map(),
          pollsPending: 0,
          revealStarted: false,
          revealClicked: false,
          displayDone: false,
          serverDone: false,
          finished: false
        };
    
        updateRecord(record, data, target);
        target.current = record;
        touch();
    
        return record;
      }
    
      function allAcknowledged(record) {
        return record.tickets.length > 0 &&
          record.tickets.every(ticket =>
            ticket.revealed ||
            (ticket.url && record.acks.get(ticket.url) === 'ok')
          );
      }
    
      function complete(record) {
        record.finished = true;
    
        if (record.owner === run) {
          const stats = run.stats;
          stats.completed++;
          stats.tickets += record.tickets.length;
          stats.wins += record.tickets.filter(ticket => ticket.symbol >= 0).length;
    
          stats.spent += record.tickets.reduce(
            (sum, ticket) => sum + ticket.bet + fee(ticket.bet),
            0
          );
    
          stats.paid += record.tickets.reduce(
            (sum, ticket) => sum + ticket.paid,
            0
          );
    
          log(`第 ${stats.completed} 批完成,净额 ${money(netOf(record))} 游戏币。`);
        } else {
          log('旧票已补刮完成,不计入本轮投入和返还。');
        }
    
        run.record = null;
        run.waitSince = Date.now();
        run.nextBuyAt = Date.now() + run.interval * 1000;
        draw();
    
        if (run.stopPool && record.tickets.some(ticket => ticket.symbol === 8)) {
          stop('中了饼王徽奖池,已按设置停止。');
        } else if (run.stopTop && record.tickets.some(ticket => ticket.symbol === 7)) {
          stop('中了百倍烤炉,已按设置停止。');
        } else if (
          run.maxBatches > 0 &&
          run.stats.bought >= run.maxBatches
        ) {
          stop(`已完成本轮 ${run.stats.completed} 批,达到批次上限。`);
        }
      }
    
      function begin() {
        if (!ctx?.live || !onPage()) {
          throw new Error('请在刮刮乐页面启动。');
        }
    
        if (ctx.uncertain) {
          throw new Error(
            '上一请求结果不确定,请刷新游戏页,让原页面恢复票据后再开始。'
          );
        }
    
        const plan = selected();
        const interval = Number(ui.interval.value);
        const maxBatches = Number(ui.maxBatches.value);
    
        if (!Number.isFinite(interval) || interval < 2 || interval > 60) {
          throw new Error('批次间隔请填写 2~60 秒。');
        }
    
        if (
          !Number.isInteger(maxBatches) ||
          maxBatches < 0 ||
          maxBatches > 100000
        ) {
          throw new Error(
            '批次上限请填写 0~100000 的整数;0 表示不限批次。'
          );
        }
    
        Object.assign(settings, {
          interval,
          maxBatches,
          stopTop: ui.stopTop.checked,
          stopPool: ui.stopPool.checked
        });
    
        save();
    
        run = {
          ...settings,
          ...plan,
          running: true,
          context: ctx,
          awaitingBuy: false,
          waitSince: Date.now(),
          hiddenAt: 0,
          nextBuyAt: Date.now(),
          record: null,
          stats: {
            bought: 0,
            completed: 0,
            tickets: 0,
            wins: 0,
            spent: 0,
            paid: 0
          }
        };
    
        if (
          ctx.current &&
          !ctx.current.finished &&
          (
            !ctx.current.serverDone ||
            !['idle', 'done'].includes(state())
          )
        ) {
          run.record = ctx.current;
          log('先处理页面已有的票,再开始本轮新购。');
        }
    
        starting = false;
        ctx.lastError = '';
    
        log(
          `开始:固定 ${plan.bet} 币 × ${plan.count} 张,` +
          `${maxBatches ? '最多新购 ' + maxBatches + ' 批' : '不限批次'}。`
        );
    
        status('已开始,正在检查页面状态。');
        draw();
      }
    
      async function start() {
        if (starting || run?.running) return;
    
        const epoch = ++startEpoch;
        const startContext = ctx;
        starting = true;
        draw();
    
        try {
          if (navigator.locks?.request) {
            await navigator.locks.request(
              'sb-scratch-helper-run-v1',
              { ifAvailable: true },
              lock => {
                if (
                  epoch !== startEpoch ||
                  ctx !== startContext ||
                  !starting
                ) {
                  return;
                }
    
                if (!lock) {
                  throw new Error(
                    '另一个标签页的刮刮乐助手正在运行,请先停止那一页。'
                  );
                }
    
                return new Promise(resolve => {
                  releaseLock = resolve;
    
                  try {
                    begin();
                  } catch (error) {
                    stop(error.message, true);
                  }
                });
              }
            );
          } else if (epoch === startEpoch && ctx === startContext) {
            begin();
          }
        } catch (error) {
          if (epoch === startEpoch) {
            stop(error.message || '无法启动助手。', true);
          }
        } finally {
          if (epoch === startEpoch) {
            starting = false;
            draw();
          }
        }
      }
    
      function tick() {
        if (!run?.running || !ctx) return;
    
        if (
          run.context !== ctx ||
          !onPage() ||
          !ctx.root.isConnected
        ) {
          stop('游戏页面已切换,助手已停止。');
          return;
        }
    
        const now = Date.now();
    
        if (document.hidden) {
          if (!run.hiddenAt) run.hiddenAt = now;
          status('页面在后台,暂停新操作;返回页面后继续。');
          return;
        }
    
        if (run.hiddenAt) {
          const elapsed = now - run.hiddenAt;
    
          // 后台可能收到响应并更新 waitSince;
          // 回前台重新给足等待时间,避免双重累加。
          run.waitSince = now;
          run.nextBuyAt += elapsed;
          run.hiddenAt = 0;
        }
    
        if (now - run.waitSince > TIMEOUT) {
          stop(
            '等待超过 120 秒,助手已停止。请检查原页面是否仍在开奖或有错误。',
            true
          );
          return;
        }
    
        const currentState = state();
        const pageMessage = tidy(q('[data-sc-msg]')?.textContent);
    
        if (
          /游戏币不够|网络不太稳|暂停卖票|登录.*(?:失效|过期)|请.*登录|验证码|请求.*频繁|限流/.test(
            pageMessage
          )
        ) {
          stop(pageMessage, true);
          return;
        }
    
        if (run.awaitingBuy) {
          status('买票请求处理中,等待原页面开奖。');
          return;
        }
    
        if (!run.record && !['idle', 'done'].includes(currentState)) {
          if (ctx.current && !ctx.current.finished) {
            run.record = ctx.current;
          } else {
            status('等待游戏返回当前批次数据;长时间无变化请刷新。');
            return;
          }
        }
    
        const record = run.record;
    
        if (record) {
          if (record.pollsPending > 0) {
            status('等待原页面的开奖查询全部返回。');
            return;
          }
    
          if (record.status === 'pending' || currentState === 'waiting') {
            status(
              tidy(q('[data-sc-wait-text]')?.textContent) ||
              '等待开奖,尚未购买下一批。'
            );
            return;
          }
    
          if (
            allAcknowledged(record) &&
            record.displayDone &&
            ['done', 'idle'].includes(currentState)
          ) {
            complete(record);
            return;
          }
    
          const revealPending =
            Array.from(record.acks.values()).includes('pending');
    
          if (
            record.revealClicked ||
            revealPending ||
            ['done', 'wiping'].includes(currentState)
          ) {
            const confirmed = record.tickets.filter(ticket =>
              ticket.revealed ||
              record.acks.get(ticket.url) === 'ok'
            ).length;
    
            status(
              `等待刮开及服务器确认:${confirmed}/${record.tickets.length} 张。`
            );
            return;
          }
    
          if (['ready', 'shown'].includes(currentState)) {
            let button = null;
    
            if (usable(ctx.all)) {
              button = ctx.all;
            } else if (currentState === 'ready' && usable(ctx.main)) {
              button = ctx.main;
            }
    
            if (!button) {
              status('等待刮开按钮就绪。');
              return;
            }
    
            record.revealClicked = true;
            touch();
    
            status(
              button === ctx.all
                ? '正在全部刮开。'
                : '正在刮开当前这张。'
            );
    
            button.click();
            return;
          }
    
          status('等待本批显示完成。');
          return;
        }
    
        if (!['idle', 'done'].includes(currentState)) return;
    
        if (
          run.maxBatches > 0 &&
          run.stats.bought >= run.maxBatches
        ) {
          stop('已达到本轮购买批次上限。');
          return;
        }
    
        const plan = selected();
    
        if (plan.bet !== run.bet || plan.count !== run.count) {
          stop(
            `页面选项已变为 ${plan.bet} 币 × ${plan.count} 张,` +
            '助手已停止。请确认余额和选项后重新开始。',
            true
          );
          return;
        }
    
        const coins = balance();
        const required = cost(run.bet, run.count);
    
        if (!Number.isFinite(coins) || coins < required) {
          stop(
            Number.isFinite(coins)
              ? `余额 ${fmt(coins)},下一批需要 ${fmt(required)} 游戏币,已停止。`
              : '无法确认当前游戏币余额,已停止。',
            true
          );
          return;
        }
    
        const labelCost = tidy(
          ctx.main.getAttribute('aria-label')
        ).match(/共\s*(\d+)\s*游戏币/);
    
        if (!labelCost || Number(labelCost[1]) !== required) {
          stop(
            '页面显示的购买金额与计算结果不同,已停止,请检查抽成规则。',
            true
          );
          return;
        }
    
        if (now < run.nextBuyAt) {
          status(
            `本批已确认,${Math.ceil((run.nextBuyAt - now) / 1000)} 秒后买下一批。`
          );
          return;
        }
    
        if (ctx.main.disabled) {
          stop('当前游戏暂停卖票,助手已停止。', true);
          return;
        }
    
        if (!usable(ctx.main) || ctx.buyPending > 0) {
          status('等待购买按钮就绪。');
          return;
        }
    
        run.awaitingBuy = true;
        ctx.expectedBuy = run;
        touch();
    
        status(`正在购买第 ${run.stats.bought + 1} 批。`);
        ctx.main.click();
    
        // 原页面买票应同步发出 fetch;
        // 不成功时停下,绝不重复连点。
        if (ctx.expectedBuy === run) {
          ctx.expectedBuy = null;
    
          stop(
            tidy(q('[data-sc-msg]')?.textContent) ||
            '原页面没有发出买票请求,已停止。',
            true
          );
        }
      }
    
      function panel() {
        host = document.createElement('div');
        host.id = 'sb-scratch-helper';
    
        host.style.cssText =
          'position:fixed;right:18px;top:90px;' +
          'z-index:2147483646;width:310px;' +
          'max-width:calc(100vw - 16px);';
    
        const shadow = host.attachShadow({ mode: 'open' });
    
        shadow.innerHTML = `
          <style>
            :host {
              font:13px/1.55 system-ui,-apple-system,"Microsoft YaHei",sans-serif;
              color:#eff7f1;
              color-scheme:dark;
            }
    
            * {
              box-sizing:border-box;
            }
    
            .box {
              border:1px solid #426d58;
              border-radius:13px;
              background:#14251f;
              box-shadow:0 8px 32px #0007;
              overflow:hidden;
            }
    
            header {
              display:flex;
              align-items:center;
              gap:8px;
              padding:11px 13px;
              background:#203b2e;
              cursor:move;
              touch-action:none;
              user-select:none;
            }
    
            header b {
              font-size:14px;
              flex:1;
            }
    
            header small {
              color:#b5c9bd;
            }
    
            button,input,select {
              font:inherit;
            }
    
            button {
              cursor:pointer;
              border:0;
              border-radius:7px;
              padding:8px 12px;
            }
    
            button:disabled {
              cursor:default;
              opacity:.45;
            }
    
            .fold {
              padding:0 5px;
              background:transparent;
              color:#e1eddf;
              font-size:20px;
            }
    
            .body {
              padding:12px;
              max-height:calc(100vh - 140px);
              overflow:auto;
            }
    
            .plan {
              color:#eed890;
              margin-bottom:10px;
              font-size:12px;
            }
    
            .row {
              display:flex;
              align-items:center;
              justify-content:space-between;
              margin:7px 0;
            }
    
            input[type=number],select {
              width:118px;
              padding:5px 7px;
              background:#0d1d17;
              border:1px solid #426d58;
              border-radius:6px;
              color:#fff;
            }
    
            select:disabled {
              opacity:.65;
              cursor:not-allowed;
            }
    
            input[type=checkbox] {
              accent-color:#edc369;
            }
    
            .hint {
              font-size:11px;
              color:#a9bcb0;
            }
    
            .checks {
              display:flex;
              gap:12px;
              flex-wrap:wrap;
              margin:10px 0;
            }
    
            .buttons {
              display:flex;
              gap:8px;
            }
    
            .start {
              flex:1;
              background:#e9bd65;
              color:#201709;
              font-weight:700;
            }
    
            .stop {
              flex:1;
              background:#713f3a;
              color:#fff;
            }
    
            .status {
              padding:9px;
              margin-top:10px;
              border-radius:7px;
              background:#0c1b15;
              overflow-wrap:anywhere;
            }
    
            .status.error {
              color:#ffbdac;
            }
    
            .stats {
              white-space:pre-line;
              margin:8px 0;
              color:#d6e8d9;
              font-variant-numeric:tabular-nums;
            }
    
            details {
              border-top:1px solid #345342;
              margin-top:8px;
              padding-top:7px;
            }
    
            summary {
              cursor:pointer;
              color:#b8cbbf;
            }
    
            pre {
              white-space:pre-wrap;
              overflow-wrap:anywhere;
              font-family:inherit;
              font-size:11px;
              line-height:1.6;
              color:#b8cbbf;
              max-height:145px;
              overflow:auto;
            }
    
            [hidden] {
              display:none!important;
            }
          </style>
    
          <section class="box">
            <header>
              <b>刮刮乐助手</b>
              <small>v${VERSION}</small>
              <button class="fold" title="收起/展开">−</button>
            </header>
    
            <div class="body">
              <div class="plan" data-role="plan"></div>
    
              <label class="row">
                面额
                <select
                  data-field="bet"
                  aria-label="面额(游戏币)"
                ></select>
              </label>
    
              <label class="row">
                每批张数
                <select
                  data-field="count"
                  aria-label="每批张数"
                ></select>
              </label>
    
              <div class="hint" data-role="purchaseHint"></div>
    
              <label class="row">
                每批间隔(秒)
                <input
                  data-field="interval"
                  type="number"
                  min="2"
                  max="60"
                  step="1"
                >
              </label>
    
              <label class="row">
                新购批次上限
                <input
                  data-field="maxBatches"
                  type="number"
                  min="0"
                  max="100000"
                  step="1"
                >
              </label>
    
              <div class="hint">
                上限填 0 表示不限批次;总张数 = 每批张数 × 批次数。
              </div>
    
              <div class="checks">
                <label>
                  <input data-field="stopTop" type="checkbox">
                  中百倍即停
                </label>
    
                <label>
                  <input data-field="stopPool" type="checkbox">
                  中奖池即停
                </label>
              </div>
    
              <div class="buttons">
                <button class="start" data-action="start">
                  开始
                </button>
    
                <button class="stop" data-action="stop" disabled>
                  停止
                </button>
              </div>
    
              <div class="status" data-role="status">
                已加载,选择面额和数量后点击开始。
              </div>
    
              <div class="hint" style="margin-top:9px">
                本轮已完成批次的统计(含抽成)
              </div>
    
              <div class="stats" data-role="stats"></div>
    
              <div class="hint">
                拖动标题移动窗口。后台暂停;刷新不自动续跑。
              </div>
    
              <details>
                <summary>运行记录</summary>
                <pre data-role="log"></pre>
              </details>
            </div>
          </section>
        `;
    
        const pick = selector => shadow.querySelector(selector);
    
        ui = {
          start: pick('[data-action=start]'),
          stop: pick('[data-action=stop]'),
          bet: pick('[data-field=bet]'),
          count: pick('[data-field=count]'),
          purchaseHint: pick('[data-role=purchaseHint]'),
          interval: pick('[data-field=interval]'),
          maxBatches: pick('[data-field=maxBatches]'),
          stopTop: pick('[data-field=stopTop]'),
          stopPool: pick('[data-field=stopPool]'),
          plan: pick('[data-role=plan]'),
          status: pick('[data-role=status]'),
          stats: pick('[data-role=stats]'),
          log: pick('[data-role=log]'),
          lines: [],
          fields: Array.from(shadow.querySelectorAll('input,select'))
        };
    
        ui.interval.value = Number.isFinite(num(settings.interval))
          ? settings.interval
          : 3;
    
        ui.maxBatches.value = Number.isInteger(num(settings.maxBatches))
          ? settings.maxBatches
          : 10;
    
        ui.stopTop.checked = !!settings.stopTop;
        ui.stopPool.checked = !!settings.stopPool;
    
        ui.bet.addEventListener('change', () => {
          changePurchaseChoice('bet', ui.bet.value);
        });
    
        ui.count.addEventListener('change', () => {
          changePurchaseChoice('n', ui.count.value);
        });
    
        ui.start.addEventListener('click', start);
    
        ui.stop.addEventListener('click', () => {
          stop(
            '已手动停止。已经发出的请求仍由游戏处理,助手不会继续买票。'
          );
        });
    
        pick('.fold').addEventListener('click', () => {
          const body = pick('.body');
          body.hidden = !body.hidden;
          pick('.fold').textContent = body.hidden ? '+' : '−';
        });
    
        let drag = null;
        const header = pick('header');
    
        header.addEventListener('pointerdown', event => {
          if (event.button !== 0 || event.target.closest('button')) return;
    
          const rect = host.getBoundingClientRect();
    
          drag = {
            x: event.clientX - rect.left,
            y: event.clientY - rect.top
          };
    
          header.setPointerCapture(event.pointerId);
          event.preventDefault();
        });
    
        header.addEventListener('pointermove', event => {
          if (!drag) return;
    
          host.style.right = 'auto';
    
          host.style.left = Math.max(
            4,
            Math.min(
              innerWidth - host.offsetWidth - 4,
              event.clientX - drag.x
            )
          ) + 'px';
    
          host.style.top = Math.max(
            4,
            Math.min(
              innerHeight - 48,
              event.clientY - drag.y
            )
          ) + 'px';
        });
    
        function endDrag() {
          if (!drag) return;
          drag = null;
    
          const rect = host.getBoundingClientRect();
    
          settings.position = {
            left: rect.left,
            top: rect.top
          };
    
          save();
        }
    
        header.addEventListener('pointerup', endDrag);
        header.addEventListener('pointercancel', endDrag);
    
        (document.fullscreenElement || document.body).appendChild(host);
    
        if (
          settings.position &&
          Number.isFinite(settings.position.left) &&
          Number.isFinite(settings.position.top)
        ) {
          host.style.right = 'auto';
    
          host.style.left = Math.max(
            4,
            Math.min(
              innerWidth - host.offsetWidth - 4,
              settings.position.left
            )
          ) + 'px';
    
          host.style.top = Math.max(
            4,
            Math.min(
              innerHeight - 48,
              settings.position.top
            )
          ) + 'px';
        }
    
        draw();
      }
    
      function detach() {
        if (run?.running || starting) {
          stop('页面已切换,助手已停止。');
        }
    
        if (ctx) {
          ctx.live = false;
          ctx.restoreFetch?.();
    
          if (ctx.onChoiceChange) {
            ctx.form.removeEventListener('change', ctx.onChoiceChange);
          }
        }
    
        host?.remove();
        host = ui = ctx = null;
      }
    
      function discover() {
        const root = onPage()
          ? document.querySelector('[data-sc]')
          : null;
    
        if (ctx?.root === root && root?.isConnected) return;
    
        if (ctx) detach();
        if (!root) return;
    
        try {
          const configEl = document.getElementById('scratch-config');
          const config = configEl ? JSON.parse(configEl.textContent) : null;
          const form = root.querySelector('[data-sc-form]');
          const main = root.querySelector('[data-sc-buy]');
    
          if (
            !config?.init ||
            !form ||
            !main ||
            !config.buy ||
            !main.getAttribute('aria-label')
          ) {
            return;
          }
    
          const rake = parseInt(form.getAttribute('data-rake-pct'), 10) || 0;
          if (rake < 0 || rake > 100) return;
    
          ctx = {
            root,
            form,
            main,
            all: root.querySelector('[data-sc-all]'),
            live: true,
            routes: new Map(),
            buyURL: endpoint(config.buy),
            coins: num(config.init.coins),
            rake,
            current: null,
            expectedBuy: null,
            buyPending: 0,
            uncertain: false,
            lastError: ''
          };
    
          ctx.routes.set(ctx.buyURL, {
            kind: 'buy',
            url: ctx.buyURL
          });
    
          if (config.init.open) {
            newRecord(config.init.open, null, ctx);
          }
    
          observeFetch(ctx);
          panel();
    
          const target = ctx;
    
          target.onChoiceChange = event => {
            if (
              !['bet', 'n'].includes(event.target?.name) ||
              target.syncingChoices
            ) {
              return;
            }
    
            queueMicrotask(() => {
              if (ctx === target && target.live) draw();
            });
          };
    
          form.addEventListener('change', target.onChoiceChange);
          log('助手加载完成,等待手动开始。');
        } catch (error) {
          console.warn(
            '[刮刮乐助手] 页面暂未就绪:',
            error.message
          );
    
          if (ctx) detach();
        }
      }
    
      window.addEventListener('bbs:game', event => {
        if (!ctx?.current || !onPage()) return;
    
        const record = ctx.current;
    
        if (
          record.status !== 'pending' &&
          Number.isFinite(event.detail?.net) &&
          netOf(record) === event.detail.net
        ) {
          record.displayDone = true;
          touch();
        }
      });
    
      window.addEventListener('bbs:toast', event => {
        if (
          run?.running &&
          onPage() &&
          event.detail?.error
        ) {
          stop(
            String(event.detail.message || '页面提示错误。'),
            true
          );
        }
      });
    
      document.addEventListener('fullscreenchange', () => {
        if (host) {
          (document.fullscreenElement || document.body).appendChild(host);
        }
      });
    
      window.addEventListener('pagehide', () => {
        if (run?.running) {
          stop('页面已离开,助手已停止。');
        }
      });
    
      let lastDiscover = 0;
    
      function loop() {
        try {
          if (Date.now() - lastDiscover > 900) {
            lastDiscover = Date.now();
            discover();
    
            if (!run?.running) draw();
          }
    
          tick();
        } catch (error) {
          stop(
            '助手已停止:' + (error.message || '未知错误'),
            true
          );
        }
    
        setTimeout(loop, 250);
      }
    
      loop();
    })();
    
    收藏支持 1反对打赏 1
    个性签名

    学习的能力和方法比知识本身更重要

1 人打赏,共 8 饼 心上人 8

15 条回复

发表回复