回复
7
查看
394
收藏
3

33

赠楼

30%

赠楼率

720

蒸汽

161

主题

2988

帖子

2万

积分
发表于 昨天 19:01 · 广东 | 显示全部楼层 |阅读模式
本帖最后由 173470 于 2026-8-4 21:30 编辑

初学油猴脚本记录三:SteamDB加购物车魔改版
https://keylol.com/t861551-1-1
(出处: 其乐 Keylol)

生生不息,以+1养+1——我的阿区号余额增值方法(2023.01更新)
https://keylol.com/t658360-1-1
(出处: 其乐 Keylol)


ai太好用了





  1. // ==UserScript==
  2. // @name         SteamDB Sales 加入 Steam 购物车
  3. // @namespace    https://greasyfork.org/users/935206-shiquda
  4. // @version      2.5.0
  5. // @description  在新版 SteamDB Sales 中单个或批量加入 Steam 购物车,优先使用 package/sub。
  6. // @author       shiquda(原作者 jklujklu),页面兼容修复
  7. // @match        https://steamdb.info/sales/*
  8. // @match        http://steamdb.info/sales/*
  9. // @icon         https://steamdb.info/static/logos/32px.png
  10. // @grant        GM_xmlhttpRequest
  11. // @grant        GM_addStyle
  12. // @connect      store.steampowered.com
  13. // @connect      api.steampowered.com
  14. // @run-at       document-end
  15. // @license      MIT
  16. // ==/UserScript==

  17. (function () {
  18.     'use strict';

  19.     const STEAM_STORE = 'https://store.steampowered.com/';
  20.     const STEAM_APP = 'https://store.steampowered.com/app/';
  21.     const STEAM_CART_API = 'https://api.steampowered.com/IAccountCartService/';
  22.     const skipMultiSubs = false; // true 按自动选择规则加入;false 时手动选择。
  23.     const autoChooseHighestDiscount = false; // true 自动选折扣最高;false 自动选价格最低。
  24.     const addCartInterval = 200; // 批量加入间隔(毫秒)。
  25.     let steamApiToken = '';
  26.     let steamCountry = '';
  27.     let batchRunning = false;
  28.     let batchCancelled = false;

  29.     GM_addStyle(`
  30.         .steamdb-cart-add, .steamdb-cart-batch { margin-left: 8px; padding: 2px 7px; border: 1px solid #5c7e10; border-radius: 3px; color: #d2efa9; background: #365000; cursor: pointer; font: inherit; line-height: 1.4; }
  31.         .steamdb-cart-add:hover:not(:disabled) { background: #527a00; }
  32.         .steamdb-cart-add:disabled { cursor: wait; opacity: .7; }
  33.         .steamdb-cart-tools { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; }
  34.         .steamdb-cart-tools label { display: inline-flex; align-items: center; gap: 4px; }
  35.         .steamdb-cart-select { margin-right: 7px; vertical-align: middle; }
  36.         #steamdb-cart-toast { position: fixed; right: 20px; bottom: 20px; z-index: 2147483647; max-width: 360px; padding: 10px 14px; border: 1px solid #4e7c1e; border-radius: 4px; color: #fff; background: #213145; box-shadow: 0 3px 12px #0008; }
  37.         #steamdb-cart-sub-modal { position: fixed; inset: 0; z-index: 2147483646; display: grid; place-items: center; background: #0009; }
  38.         #steamdb-cart-sub-modal > div { width: min(860px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 18px; border: 1px solid #4e7c1e; border-radius: 4px; background: #213145; color: #fff; }
  39.         #steamdb-cart-sub-modal table { width: 100%; margin-top: 12px; border-collapse: collapse; }
  40.         #steamdb-cart-sub-modal th, #steamdb-cart-sub-modal td { padding: 7px; border-bottom: 1px solid #3d536b; text-align: left; }
  41.         #steamdb-cart-sub-modal .steamdb-cart-package-contents { color: #c7d4e2; font-size: 13px; }
  42.         #steamdb-cart-sub-modal button { padding: 5px 10px; cursor: pointer; color: #d2efa9; background: #365000; border: 1px solid #5c7e10; border-radius: 3px; }
  43.         #steamdb-cart-sub-modal .steamdb-cart-cancel { display: block; margin-top: 12px; color: #ddd; background: #3b3b3b; border-color: #666; }
  44.     `);

  45.     // 显示短暂的操作结果提示。
  46.     function showToast(message, isError) {
  47.         let toast = document.querySelector('#steamdb-cart-toast');
  48.         if (!toast) {
  49.             toast = document.createElement('div');
  50.             toast.id = 'steamdb-cart-toast';
  51.             document.body.appendChild(toast);
  52.         }
  53.         toast.textContent = message;
  54.         toast.style.borderColor = isError ? '#b23b3b' : '#4e7c1e';
  55.         clearTimeout(showToast.timer);
  56.         showToast.timer = window.setTimeout(() => toast.remove(), 4000);
  57.     }

  58.     // 向 Steam 发起跨域请求并统一处理网络错误。
  59.     function steamRequest(details) {
  60.         return new Promise((resolve, reject) => {
  61.             GM_xmlhttpRequest({
  62.                 timeout: 15000,
  63.                 ...details,
  64.                 onload: resolve,
  65.                 onerror: () => reject(new Error('网络请求失败')),
  66.                 ontimeout: () => reject(new Error('请求超时,已跳过该游戏')),
  67.                 onabort: () => reject(new Error('请求已取消'))
  68.             });
  69.         });
  70.     }

  71.     // 读取 Steam Web API 所需的短期令牌与当前商店地区。
  72.     async function getSteamApiAuth() {
  73.         if (steamApiToken && steamCountry) return { token: steamApiToken, country: steamCountry };
  74.         const response = await steamRequest({ method: 'GET', url: STEAM_STORE });
  75.         const token = response.responseText.match(/g_wapit\s*=\s*"([^"]+)"/);
  76.         const country = response.responseText.match(/g_strCountryCode\s*=\s*"([A-Z]{2})"/i);
  77.         if (!token) throw new Error('未能获取 Steam 购物车授权,请确认已登录 Steam 商店');
  78.         steamApiToken = token[1];
  79.         steamCountry = country?.[1]?.toUpperCase() || 'US';
  80.         return { token: steamApiToken, country: steamCountry };
  81.     }

  82.     // 将无符号整数编码为 protobuf varint。
  83.     function encodeVarint(value) {
  84.         let number = BigInt(value);
  85.         if (number < 0n) throw new Error('protobuf 整数不能为负数');
  86.         const bytes = [];
  87.         do {
  88.             let byte = Number(number & 0x7fn);
  89.             number >>= 7n;
  90.             if (number) byte |= 0x80;
  91.             bytes.push(byte);
  92.         } while (number);
  93.         return bytes;
  94.     }

  95.     // 将 protobuf varint 从字节数组中解码。
  96.     function decodeVarint(bytes, offset) {
  97.         let value = 0n;
  98.         let shift = 0n;
  99.         while (offset < bytes.length) {
  100.             const byte = bytes[offset++];
  101.             value |= BigInt(byte & 0x7f) << shift;
  102.             if (!(byte & 0x80)) return { value, offset };
  103.             shift += 7n;
  104.             if (shift > 70n) break;
  105.         }
  106.         throw new Error('Steam 返回了无效的 protobuf 整数');
  107.     }

  108.     // 生成 protobuf 长度分隔字段。
  109.     function encodeLengthField(field, bytes) {
  110.         return [...encodeVarint((field << 3) | 2), ...encodeVarint(bytes.length), ...bytes];
  111.     }

  112.     // 将字节数组转换为 Web API 接受的 Base64 protobuf 文本。
  113.     function bytesToBase64(bytes) {
  114.         let binary = '';
  115.         for (const byte of bytes) binary += String.fromCharCode(byte);
  116.         return btoa(binary);
  117.     }

  118.     // 从 Web API 的 protobuf 加购响应中读取新建购物车行 ID。
  119.     function getLineItemId(response) {
  120.         let text = '';
  121.         try {
  122.             text = response.responseText || '';
  123.         } catch {
  124.             // 二进制响应模式下部分脚本管理器禁止读取 responseText。
  125.         }
  126.         try {
  127.             const json = JSON.parse(text);
  128.             const ids = json.response?.line_item_ids || json.line_item_ids;
  129.             if (ids?.length) return String(ids[0]);
  130.         } catch {
  131.             // Web API 默认返回 protobuf,继续使用二进制解析。
  132.         }
  133.         const bytes = new Uint8Array(response.response || []);
  134.         let offset = 0;
  135.         while (offset < bytes.length) {
  136.             const tag = decodeVarint(bytes, offset);
  137.             offset = tag.offset;
  138.             const field = Number(tag.value >> 3n);
  139.             const wireType = Number(tag.value & 7n);
  140.             if (field === 1 && wireType === 0) return String(decodeVarint(bytes, offset).value);
  141.             if (wireType === 0) {
  142.                 offset = decodeVarint(bytes, offset).offset;
  143.                 continue;
  144.             }
  145.             if (wireType !== 2) throw new Error('Steam 返回了无法识别的购物车数据');
  146.             const length = decodeVarint(bytes, offset);
  147.             offset = length.offset;
  148.             const end = offset + Number(length.value);
  149.             if (end > bytes.length) throw new Error('Steam 返回了截断的购物车数据');
  150.             if (field === 1) return String(decodeVarint(bytes, offset).value);
  151.             offset = end;
  152.         }
  153.         throw new Error('Steam 未返回新购物车项目 ID');
  154.     }

  155.     // 向新版 Steam 购物车 Web API 提交 protobuf 请求。
  156.     async function cartApiRequest(method, protobuf) {
  157.         const { token } = await getSteamApiAuth();
  158.         const boundary = `----SteamDBCart${Date.now().toString(36)}`;
  159.         const data = `--${boundary}\r\nContent-Disposition: form-data; name="input_protobuf_encoded"\r\n\r\n${bytesToBase64(protobuf)}\r\n--${boundary}--\r\n`;
  160.         const response = await steamRequest({
  161.             method: 'POST',
  162.             url: `${STEAM_CART_API}${method}/v1?access_token=${encodeURIComponent(token)}`,
  163.             data,
  164.             responseType: 'arraybuffer',
  165.             headers: {
  166.                 'Content-Type': `multipart/form-data; boundary=${boundary}`,
  167.                 Origin: STEAM_STORE.slice(0, -1),
  168.                 Referer: STEAM_STORE
  169.             }
  170.         });
  171.         if (response.status < 200 || response.status >= 300) throw new Error(`Steam 购物车请求失败(HTTP ${response.status})`);
  172.         return response;
  173.     }

  174.     // 将商店价格统一为千位逗号与小数点的格式。
  175.     function formatPrice(value) {
  176.         const match = value.match(/^(\D*)([\d.,\s]+)(.*)$/);
  177.         if (!match) return value;
  178.         const number = match[2].replace(/\s/g, '');
  179.         const separator = Math.max(number.lastIndexOf('.'), number.lastIndexOf(','));
  180.         const fraction = separator >= 0 && number.length - separator - 1 === 2 ? number.slice(separator + 1) : '';
  181.         const integer = (fraction ? number.slice(0, separator) : number).replace(/[.,]/g, '');
  182.         if (!integer) return value;
  183.         return `${match[1]}${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${fraction ? `.${fraction}` : ''}${match[3]}`;
  184.     }

  185.     // 将 Steam 原始价格文本转换为用于比较的数值。
  186.     function getPriceValue(value) {
  187.         const match = value.match(/[\d.,\s]+/);
  188.         if (!match) return Number.POSITIVE_INFINITY;
  189.         const number = match[0].replace(/\s/g, '');
  190.         const separator = Math.max(number.lastIndexOf('.'), number.lastIndexOf(','));
  191.         const fraction = separator >= 0 && number.length - separator - 1 === 2 ? number.slice(separator + 1) : '';
  192.         const integer = (fraction ? number.slice(0, separator) : number).replace(/[.,]/g, '');
  193.         const parsed = Number(`${integer}${fraction ? `.${fraction}` : ''}`);
  194.         return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
  195.     }

  196.     // 将 Steam 显示的折扣文本转换为便于比较的百分比数值。
  197.     function getDiscountValue(discount) {
  198.         return Number(discount.match(/\d+(?:\.\d+)?/)?.[0] || 0);
  199.     }

  200.     // 读取一个 package/sub 内包含的 Steam 应用名称。
  201.     async function getPackageContents(subId) {
  202.         const response = await steamRequest({
  203.             method: 'GET',
  204.             url: `${STEAM_STORE}api/packagedetails?packageids=${encodeURIComponent(subId)}`
  205.         });
  206.         const result = JSON.parse(response.responseText);
  207.         const data = result[subId]?.data;
  208.         if (!data?.apps?.length) throw new Error('Steam 未返回该 package 的应用列表');
  209.         return data.apps.map(app => app.name).filter(Boolean);
  210.     }

  211.     // 从 package/sub 或 bundle 的独立商店页读取实际显示名称。
  212.     async function getPurchaseTitle(option) {
  213.         const path = option.type === 'bundle' ? 'bundle' : 'sub';
  214.         const response = await steamRequest({ method: 'GET', url: `${STEAM_STORE}${path}/${encodeURIComponent(option.id)}/` });
  215.         const page = new DOMParser().parseFromString(response.responseText, 'text/html');
  216.         const title = page.querySelector('.pageheader, #appHubAppName, h1[id^="add_bundle_to_cart_title_"], h1[id^="bundle_label_"]')?.textContent.trim().replace(/^(购买|Buy)\s*/i, '');
  217.         if (!title) throw new Error(`未能读取 ${path} ${option.id} 的商店名称`);
  218.         return title;
  219.     }

  220.     // 从商店页提取可购买项及其在选择面板中显示的信息。
  221.     async function getPurchaseOptions(appId) {
  222.         const response = await steamRequest({ method: 'GET', url: STEAM_APP + appId });
  223.         if (response.responseText.includes('agegate_birthday_selector')) return 'agegate';

  224.         const page = new DOMParser().parseFromString(response.responseText, 'text/html');
  225.         const options = [];
  226.         const forms = page.querySelectorAll('form input[name="subid"], form input[name="bundleid"]');
  227.         for (const input of forms) {
  228.             const id = input.value;
  229.             const type = input.name === 'subid' ? 'sub' : 'bundle';
  230.             if (!id || options.some(option => option.id === id && option.type === type)) continue;
  231.             const wrapper = input.closest('.game_area_purchase_game_wrapper') || input.closest('.game_area_purchase_game') || input.closest('form')?.parentElement;
  232.             const title = wrapper?.querySelector('.game_area_purchase_game h1, h1')?.textContent.trim().replace(/^(购买|Purchase)\s*/i, '') || page.querySelector('#appHubAppName')?.textContent.trim() || `Package ${id}`;
  233.             const discount = wrapper?.querySelector('.discount_pct')?.textContent.trim() || '-0%';
  234.             const rawPrice = wrapper?.querySelector('.discount_final_price, .game_purchase_price')?.textContent.trim() || '-';
  235.             const price = formatPrice(rawPrice);
  236.             options.push({ id, type, title, discount, price, priceValue: getPriceValue(rawPrice) });
  237.         }
  238.         return options;
  239.     }

  240.     // 在关闭 skipMultiSubs 时展示原脚本风格的购买项选择表格。
  241.     function chooseSub(options) {
  242.         return new Promise(resolve => {
  243.             const modal = document.createElement('div');
  244.             modal.id = 'steamdb-cart-sub-modal';
  245.             const panel = document.createElement('div');
  246.             const title = document.createElement('strong');
  247.             title.textContent = '请选择要加入购物车的购买项';
  248.             panel.appendChild(title);
  249.             const table = document.createElement('table');
  250.             const head = document.createElement('thead');
  251.             const headRow = document.createElement('tr');
  252.             for (const text of ['游戏', '折扣', '价格', 'Sub类型', 'SubId', '']) {
  253.                 const cell = document.createElement('th');
  254.                 cell.textContent = text;
  255.                 headRow.appendChild(cell);
  256.             }
  257.             head.appendChild(headRow);
  258.             table.appendChild(head);
  259.             const body = document.createElement('tbody');
  260.             for (const option of options) {
  261.                 const row = document.createElement('tr');
  262.                 for (const [index, text] of [option.title, option.discount, option.price, option.type === 'sub' ? 'game' : 'bundle', option.id].entries()) {
  263.                     const cell = document.createElement('td');
  264.                     cell.textContent = text;
  265.                     row.appendChild(cell);
  266.                     if (index === 0) {
  267.                         cell.textContent = '读取商店名称...';
  268.                         getPurchaseTitle(option).then(name => {
  269.                             option.title = name;
  270.                             cell.textContent = name;
  271.                         }).catch(() => {
  272.                             cell.textContent = option.title;
  273.                         });
  274.                     }
  275.                 }
  276.                 const action = document.createElement('td');
  277.                 const button = document.createElement('button');
  278.                 button.type = 'button';
  279.                 button.textContent = 'Add';
  280.                 button.addEventListener('click', () => {
  281.                     modal.remove();
  282.                     resolve(option);
  283.                 });
  284.                 action.appendChild(button);
  285.                 const contents = document.createElement('button');
  286.                 contents.type = 'button';
  287.                 contents.textContent = '内容';
  288.                 contents.style.marginLeft = '6px';
  289.                 contents.addEventListener('click', async () => {
  290.                     if (option.type !== 'sub') {
  291.                         showToast('Bundle 的内容请在 Steam 商店页确认', true);
  292.                         return;
  293.                     }
  294.                     const existing = row.nextElementSibling;
  295.                     if (existing?.classList.contains('steamdb-cart-package-contents')) {
  296.                         existing.remove();
  297.                         return;
  298.                     }
  299.                     contents.disabled = true;
  300.                     contents.textContent = '加载中...';
  301.                     try {
  302.                         const names = await getPackageContents(option.id);
  303.                         const details = document.createElement('tr');
  304.                         details.className = 'steamdb-cart-package-contents';
  305.                         const cell = document.createElement('td');
  306.                         cell.colSpan = 6;
  307.                         cell.textContent = `包含 ${names.length} 项:${names.join('、')}`;
  308.                         details.appendChild(cell);
  309.                         row.after(details);
  310.                         contents.textContent = '收起';
  311.                     } catch (error) {
  312.                         contents.textContent = '内容';
  313.                         showToast(error.message, true);
  314.                     } finally {
  315.                         contents.disabled = false;
  316.                     }
  317.                 });
  318.                 action.appendChild(contents);
  319.                 row.appendChild(action);
  320.                 body.appendChild(row);
  321.             }
  322.             table.appendChild(body);
  323.             panel.appendChild(table);
  324.             const cancel = document.createElement('button');
  325.             cancel.type = 'button';
  326.             cancel.className = 'steamdb-cart-cancel';
  327.             cancel.textContent = '取消';
  328.             cancel.addEventListener('click', () => {
  329.                 modal.remove();
  330.                 resolve(null);
  331.             });
  332.             panel.appendChild(cancel);
  333.             modal.appendChild(panel);
  334.             document.body.appendChild(modal);
  335.         });
  336.     }

  337.     // 按配置在可购买项目中自动挑选折扣最高或价格最低的一项。
  338.     function chooseAutomaticOption(options) {
  339.         const subs = options.filter(item => item.type === 'sub');
  340.         const candidates = subs.length ? subs : options;
  341.         return candidates.reduce((best, current) => {
  342.             if (autoChooseHighestDiscount) return getDiscountValue(current.discount) > getDiscountValue(best.discount) ? current : best;
  343.             return current.priceValue < best.priceValue ? current : best;
  344.         });
  345.     }

  346.     // 将指定 package/sub 或 bundle 加入当前 Steam 购物车,并保存新行项目 ID。
  347.     async function addToCart(appId) {
  348.         const options = await getPurchaseOptions(appId);
  349.         if (options === 'agegate') {
  350.             window.open(STEAM_APP + appId, '_blank', 'noopener');
  351.             throw new Error('该游戏需要年龄验证,已在新标签页打开商店页面');
  352.         }
  353.         if (!options.length) throw new Error('未找到可购买的 package');

  354.         const subs = options.filter(item => item.type === 'sub');
  355.         let option = chooseAutomaticOption(options);
  356.         if (!skipMultiSubs && subs.length > 1) {
  357.             option = await chooseSub(options);
  358.             if (!option) throw new Error('已取消选择 package/sub');
  359.         }
  360.         const { country } = await getSteamApiAuth();
  361.         const item = option.type === 'sub'
  362.             ? [...encodeVarint(8), ...encodeVarint(option.id)]
  363.             : [...encodeVarint(16), ...encodeVarint(option.id)];
  364.         const request = [
  365.             ...encodeLengthField(1, Array.from(country, char => char.charCodeAt(0))),
  366.             ...encodeLengthField(2, item)
  367.         ];
  368.         const response = await cartApiRequest('AddItemsToCart', request);
  369.         return { ...option, lineItemId: getLineItemId(response), country };
  370.     }

  371.     // 使用加购时返回的行项目 ID 从当前 Steam 购物车移除项目。
  372.     async function removeFromCart(lineItemId, country) {
  373.         const auth = await getSteamApiAuth();
  374.         const request = [
  375.             ...encodeVarint(8), ...encodeVarint(lineItemId),
  376.             ...encodeLengthField(2, Array.from(country || auth.country, char => char.charCodeAt(0)))
  377.         ];
  378.         await cartApiRequest('RemoveItemFromCart', request);
  379.     }

  380.     // 为一行游戏创建单个加入购物车按钮。
  381.     function createAddButton(appId) {
  382.         const button = document.createElement('button');
  383.         button.className = 'steamdb-cart-add';
  384.         button.type = 'button';
  385.         button.textContent = '加入购物车';
  386.         button.title = `将 App ${appId} 加入 Steam 购物车`;
  387.         button.addEventListener('click', async event => {
  388.             event.preventDefault();
  389.             event.stopPropagation();
  390.             button.disabled = true;
  391.             button.textContent = '处理中...';
  392.             try {
  393.                 if (button.dataset.cartLineItemId) {
  394.                     await removeFromCart(button.dataset.cartLineItemId, button.dataset.cartCountry);
  395.                     delete button.dataset.cartLineItemId;
  396.                     delete button.dataset.cartCountry;
  397.                     button.textContent = '加入购物车';
  398.                     button.title = `将 App ${appId} 加入 Steam 购物车`;
  399.                     showToast(`App ${appId} 已从 Steam 购物车移除`);
  400.                 } else {
  401.                     const option = await addToCart(appId);
  402.                     button.dataset.cartLineItemId = option.lineItemId;
  403.                     button.dataset.cartCountry = option.country;
  404.                     button.textContent = '已加入';
  405.                     button.title = `点击将 App ${appId} 从 Steam 购物车移除`;
  406.                     showToast(`App ${appId} 已加入 Steam 购物车`);
  407.                 }
  408.             } catch (error) {
  409.                 showToast(error.message, true);
  410.             } finally {
  411.                 button.disabled = false;
  412.                 if (button.textContent === '处理中...') button.textContent = button.dataset.cartLineItemId ? '已加入' : '加入购物车';
  413.             }
  414.         });
  415.         return button;
  416.     }

  417.     // 同步全选框状态,避免筛选或翻页后显示过期状态。
  418.     function syncSelectAll() {
  419.         const selectAll = document.querySelector('#steamdb-cart-select-all');
  420.         const boxes = document.querySelectorAll('.steamdb-cart-select');
  421.         if (!selectAll || !boxes.length) return;
  422.         selectAll.checked = Array.from(boxes).every(box => box.checked);
  423.         selectAll.indeterminate = !selectAll.checked && Array.from(boxes).some(box => box.checked);
  424.     }

  425.     // 将当前勾选的游戏按顺序加入购物车,避免短时间内请求过多。
  426.     async function addSelectedToCart() {
  427.         const batchButton = document.querySelector('#steamdb-cart-batch-add');
  428.         if (batchRunning) {
  429.             batchCancelled = true;
  430.             batchButton.textContent = '正在停止...';
  431.             return;
  432.         }
  433.         const rows = Array.from(document.querySelectorAll('#DataTables_Table_0 tbody tr[data-appid]'))
  434.             .filter(row => row.querySelector('.steamdb-cart-select')?.checked);
  435.         if (!rows.length) {
  436.             showToast('请先勾选要加入购物车的游戏', true);
  437.             return;
  438.         }
  439.         batchRunning = true;
  440.         batchCancelled = false;
  441.         batchButton.textContent = '获取 Steam 购物车授权...';
  442.         try {
  443.             await getSteamApiAuth();
  444.         } catch (error) {
  445.             batchRunning = false;
  446.             batchButton.textContent = '加入已选';
  447.             showToast(error.message, true);
  448.             return;
  449.         }
  450.         let success = 0;
  451.         let processed = 0;
  452.         for (const [index, row] of rows.entries()) {
  453.             if (batchCancelled) break;
  454.             batchButton.textContent = `处理中 ${index + 1}/${rows.length}(点击停止)`;
  455.             try {
  456.                 const option = await addToCart(row.dataset.appid);
  457.                 if (!batchCancelled) {
  458.                     success += 1;
  459.                     const button = row.querySelector('.steamdb-cart-add');
  460.                     if (button) {
  461.                         button.disabled = true;
  462.                         button.dataset.cartLineItemId = option.lineItemId;
  463.                         button.dataset.cartCountry = option.country;
  464.                         button.textContent = '已加入';
  465.                         button.title = `点击将 App ${row.dataset.appid} 从 Steam 购物车移除`;
  466.                     }
  467.                 }
  468.             } catch (error) {
  469.                 console.warn(`App ${row.dataset.appid}: ${error.message}`);
  470.             }
  471.             processed += 1;
  472.             if (batchCancelled) break;
  473.             if (index + 1 < rows.length) await new Promise(resolve => window.setTimeout(resolve, addCartInterval));
  474.         }
  475.         const wasCancelled = batchCancelled;
  476.         batchRunning = false;
  477.         batchCancelled = false;
  478.         batchButton.textContent = '加入已选';
  479.         showToast(
  480.             wasCancelled
  481.                 ? `已停止:已处理 ${processed}/${rows.length},成功加入 ${success} 款游戏`
  482.                 : `已加入 ${success}/${rows.length} 款游戏${success === rows.length ? '' : ',失败项目请单独重试'}`,
  483.             success !== rows.length
  484.         );
  485.     }

  486.     // 在当前 Sales 筛选工具栏插入全选与批量加入控件。
  487.     function initTools() {
  488.         if (document.querySelector('#steamdb-cart-tools')) return;
  489.         const host = document.querySelector('#js-filters') || document.querySelector('#DataTables_Table_0')?.parentElement;
  490.         if (!host) return;
  491.         const tools = document.createElement('span');
  492.         tools.id = 'steamdb-cart-tools';
  493.         tools.className = 'steamdb-cart-tools';
  494.         tools.innerHTML = '<label title="全选当前筛选结果页"><input id="steamdb-cart-select-all" type="checkbox">全选当前页</label><button id="steamdb-cart-batch-add" class="steamdb-cart-batch" type="button">加入已选</button>';
  495.         tools.querySelector('#steamdb-cart-select-all').addEventListener('change', event => {
  496.             document.querySelectorAll('.steamdb-cart-select').forEach(box => {
  497.                 box.checked = event.target.checked;
  498.             });
  499.             syncSelectAll();
  500.         });
  501.         tools.querySelector('#steamdb-cart-batch-add').addEventListener('click', addSelectedToCart);
  502.         host.appendChild(tools);
  503.     }

  504.     // 为新版 Sales 表格中尚未处理的游戏行插入复选框和操作按钮。
  505.     function bindRows() {
  506.         initTools();
  507.         const rows = document.querySelectorAll('#DataTables_Table_0 tbody tr[data-appid]');
  508.         for (const row of rows) {
  509.             if (row.querySelector('.steamdb-cart-add')) continue;
  510.             const appId = row.dataset.appid;
  511.             const nameCell = row.querySelector('td:nth-child(3)') || row.querySelector('td');
  512.             if (!appId || !nameCell) continue;

  513.             const select = document.createElement('input');
  514.             select.className = 'steamdb-cart-select';
  515.             select.type = 'checkbox';
  516.             select.title = `选择 App ${appId}`;
  517.             select.addEventListener('change', syncSelectAll);
  518.             nameCell.prepend(select);
  519.             nameCell.appendChild(createAddButton(appId));
  520.         }
  521.         syncSelectAll();
  522.     }

  523.     // 监听页面的筛选、排序和翻页造成的表格重绘。
  524.     function observeTable() {
  525.         bindRows();
  526.         const observer = new MutationObserver(bindRows);
  527.         observer.observe(document.body, { childList: true, subtree: true });
  528.     }

  529.     // 等待 SteamDB 异步初始化 Sales 表格。
  530.     function waitForTable() {
  531.         if (document.querySelector('#DataTables_Table_0 tbody')) {
  532.             observeTable();
  533.             return;
  534.         }
  535.         window.setTimeout(waitForTable, 250);
  536.     }

  537.     waitForTable();
  538. })();


复制代码





回复

使用道具 举报

浏览本版块需要:
1. 初阶会员或更高等级;
2. (点击此处)绑定Steam账号
您需要登录后才可以回帖 登录 | 注册

本版积分规则

欢迎发帖参与讨论 o(*≧▽≦)ツ,请注意
1. 寻求帮助或答案的帖子请发到问题互助版块,悬赏有助于问题解决的速度。发错可能失去在该板块发布主题的权限(了解更多
2. 表达观点可以,也请务必注意语气和用词,以免影响他人浏览,特别是针对其他会员的内容。如觉得违规可使用举报功能 交由管理人员处理,请勿引用对方的内容。
3. 开箱晒物交易中心游戏互鉴福利放送版块请注意额外的置顶版规。
4. 除了提问帖和交易帖以外,不确认发在哪个版块的帖子可以先发在谈天说地

  作为民间站点,自 2004 年起为广大中文 Steam 用户提供技术支持与讨论空间。历经二十余载风雨,如今已发展为国内最大的正版玩家据点。

列表模式 · · 微博 · Bilibili频道 · Steam 群组 · 贴吧 · QQ群 
Keylol 其乐 ©2004-2026 Chinese Steam User Fan Site.
Designed by Lee in Balestier, Powered by Discuz!
推荐使用 ChromeMicrosoft Edge 来浏览本站
广告投放|手机版|广州数趣信息科技有限公司 版权所有|其乐 Keylol ( 粤ICP备17068105号 )
GMT+8, 2026-8-5 04:33
快速回复 返回顶部 返回列表