// ==UserScript== // @name EPIC白嫖小助手 // @description 每1小时检测一次是否有可以白嫖的epic游戏,支持多数据源回退 // @namespace https://bbs.tampermonkey.net.cn/ // @version 0.3.4 // @author CodFrm, Cosil // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_closeNotification // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant GM_setClipboard // @storageName find_epic_free_games // @connect store-site-backend-static.ak.epicgames.com // @connect store-site-backend-static-ipv4.ak.epicgames.com // @connect www.epicgames.com // @connect store.epicgames.com // @crontab 0 * * * * // @license GPLv3 // ==/UserScript== (function () { 'use strict'; // 如需重置存储(例如清空已入库记录和通知记录),请取消下面两行的注释,运行一次后重新注释 //GM_setValue('item_in_library', {}); //GM_setValue('notified_free_games', {}); const API_ENDPOINTS = [ 'https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=zh-CN&country=CN&allowCountries=CN,HK', 'https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions?locale=zh-CN&country=CN&allowCountries=CN,HK' ]; // 基础商店地址,使用无语言后缀的通用路径,会自动跳转到用户语言 const STORE_BASE = "https://store.epicgames.com/p/"; const STORAGE_KEY = "item_in_library"; const NOTIFIED_KEY = "notified_free_games"; const MAX_NOTIFIED_ITEMS = 30; function request(option) { return new Promise((resolve, reject) => { option.timeout = 15000; // 15秒超时 option.onload = (res) => { if (res.status === 200) { resolve(res); } else { reject(new Error(`HTTP ${res.status}`)); } }; option.onerror = (e) => reject(new Error('Network error')); option.ontimeout = () => reject(new Error('Timeout')); GM_xmlhttpRequest(option); }); } // 多源回退请求函数 async function fetchFromEpicAPI(urls) { for (const url of urls) { try { console.log(`尝试请求API: ${url}`); const response = await request({ url: url, method: 'GET', responseType: 'json' }); if (response.status === 200) { return response; } throw new Error(`请求失败,状态码: ${response.status}`); } catch (error) { console.warn(`API请求失败: ${url}`, error); continue; } } throw new Error('所有API端点都无法访问'); } // 清理过期的通知记录 function cleanExpiredNotified(records) { const now = Date.now(); return Object.entries(records).reduce((acc, [id, data]) => { if (!data.promoEnd || new Date(data.promoEnd).getTime() > now) { acc[id] = data; } return acc; }, {}); } // 限制通知记录数量 function limitNotified(records, max) { const entries = Object.entries(records); if (entries.length <= max) return records; entries.sort((a, b) => b[1].notifyTime - a[1].notifyTime); return Object.fromEntries(entries.slice(0, max)); } async function checkFreeGames() { try { // 1. 获取免费游戏列表 const resp = await fetchFromEpicAPI(API_ENDPOINTS); const elements = resp.response?.data?.Catalog?.searchStore?.elements; if (!elements) { console.warn("无法解析游戏数据"); return; } const now = new Date(); const freeGames = []; for (const game of elements) { if (game.status !== "ACTIVE") continue; let isFree = false; let promoEnd = null; // 检查促销信息 const promoGroups = game.promotions?.promotionalOffers; if (promoGroups && Array.isArray(promoGroups)) { for (const group of promoGroups) { for (const offer of group.promotionalOffers || []) { if (offer.discountSetting?.discountPercentage === 0) { const start = new Date(offer.startDate); const end = new Date(offer.endDate); if (start <= now && end > now) { isFree = true; promoEnd = end.toISOString(); break; } } } if (isFree) break; } } // 兜底:原价>0 且 现价=0 if (!isFree && game.price?.totalPrice?.originalPrice > 0 && game.price.totalPrice.discountPrice === 0) { isFree = true; } if (isFree) { // 提取真正的产品页面 slug let realSlug = game.productSlug || game.urlSlug; // 优先使用 productSlug const mappings = game.catalogNs?.mappings; if (mappings && Array.isArray(mappings)) { // 查找 pageType 为 "productHome" 的映射 const productMapping = mappings.find(m => m.pageType === "productHome"); if (productMapping && productMapping.pageSlug) { realSlug = productMapping.pageSlug; } } // 如果仍然没有找到,尝试从 offerMappings 获取 if (!realSlug || realSlug === game.urlSlug) { const offerMappings = game.catalogNs?.offerMappings; if (offerMappings && Array.isArray(offerMappings) && offerMappings.length > 0) { realSlug = offerMappings[0].pageSlug || realSlug; } } console.log(`游戏: ${game.title}, 原始urlSlug: ${game.urlSlug}, 真实slug: ${realSlug}`); freeGames.push({ id: game.id, title: game.title, urlSlug: realSlug, image: game.keyImages?.find(img => img.type === "DieselStoreFrontWide")?.url || "", promoEnd, realUrl: null // 将通过详情页请求获取最终地址 }); } } console.log("找到免费游戏:", freeGames); if (freeGames.length === 0) return; // 2. 读取存储 let itemInLibrary = GM_getValue(STORAGE_KEY, {}); let notifiedGames = GM_getValue(NOTIFIED_KEY, {}); notifiedGames = cleanExpiredNotified(notifiedGames); notifiedGames = limitNotified(notifiedGames, MAX_NOTIFIED_ITEMS); // 3. 筛选需要检测的游戏(未入库且未在有效通知期) const needCheckGames = freeGames.filter(game => { if (itemInLibrary[game.id]) return false; const notified = notifiedGames[game.id]; if (notified && game.promoEnd && notified.promoEnd && notified.promoEnd === game.promoEnd && Date.now() - notified.notifyTime < 7 * 24 * 60 * 60 * 1000) { return false; } return true; }); if (needCheckGames.length === 0) return; // 4. 并行请求游戏详情页,获取真实跳转地址并检测是否已入库 const pageRequests = needCheckGames.map(game => request({ url: STORE_BASE + game.urlSlug }).catch(() => null) ); const pages = await Promise.all(pageRequests); for (let i = 0; i < needCheckGames.length; i++) { const game = needCheckGames[i]; const pageResp = pages[i]; if (!pageResp) continue; // 保存最终跳转地址(详情页请求可能会被重定向到正确地址) game.realUrl = pageResp.finalUrl || STORE_BASE + game.urlSlug; const html = pageResp.responseText; let inLibrary = false; // 方法1:正则匹配本地化按钮文字 const match = html.match(/"diesel\.common\.button\.in_library"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); if (match) { const transText = match[1]; const buttonMatch = html.match(/data-component="PurchaseCTA"[^>]*>([^<]+)]*>([^<]+) !itemInLibrary[game.id]); if (gamesToNotify.length === 0) return; const msg = gamesToNotify.map(g => g.title).join("; "); GM_notification({ title: "🎁 Epic 限时免费游戏", text: msg, image: gamesToNotify[0].image || undefined, buttons: [ { title: "知道了" }, { title: "一键领取" } ], onclick(event) { console.log("onclick triggered, buttonClickIndex:", event?.buttonClickIndex); if (event && event.buttonClickIndex === 1) { gamesToNotify.forEach(game => { const url = game.realUrl || STORE_BASE + game.urlSlug; console.log("即将打开:", url); try { GM_openInTab(url, false); } catch (e) { console.error("GM_openInTab 失败:", e); try { window.open(url, '_blank'); } catch (e2) { console.error("window.open 失败:", e2); try { GM_setClipboard(url, 'text'); console.log('已复制链接到剪贴板:', url); } catch (e3) { console.error('复制失败:', e3); } } } }); } }, timeout: 15 * 1000, ondone() { for (const game of gamesToNotify) { notifiedGames[game.id] = { title: game.title, notifyTime: Date.now(), promoEnd: game.promoEnd }; } GM_setValue(NOTIFIED_KEY, limitNotified(notifiedGames, MAX_NOTIFIED_ITEMS)); } }); } catch (e) { console.error("EPIC白嫖助手出错:", e); GM_notification({ title: "EPIC白嫖助手", text: "检测失败: " + e.message, timeout: 5000 }); } } // 启动检测 return checkFreeGames(); })();