527 lines
19 KiB
JavaScript
527 lines
19 KiB
JavaScript
/*
|
||
* 农夫山泉 - 全能版 (支持青龙/圈X/Surge/Loon)
|
||
*
|
||
* [功能说明]
|
||
* 1. 自动完成浏览、分享、视频号等任务
|
||
* 2. 自动抽奖并统计
|
||
* 3. 支持 Quantumult X 抓包自动记录账号
|
||
* 4. 支持抓包后自动推送到青龙面板 (需配置面板信息)
|
||
*
|
||
* [环境变量 / 账号配置]
|
||
* 变量名: NFSQ_ACCOUNTS
|
||
* 格式: JSON 数组
|
||
* [
|
||
* {
|
||
* "uid": "xxx",
|
||
* "token": "xxx",
|
||
* "body": { ... },
|
||
* "headers_json": { ... }
|
||
* }
|
||
* ]
|
||
*
|
||
* [Quantumult X 配置]
|
||
*
|
||
* [rewrite_local]
|
||
* # 抓取账号信息
|
||
* ^https:\/\/sxs-consumer\.nfsq\.com\.cn\/geement\.marketinglottery\/api\/v1\/marketinglottery url script-request-body nfsq_ql.js
|
||
*
|
||
* [task_local]
|
||
* # 定时运行 (每天 9:00 运行)
|
||
* 0 9 * * * nfsq_ql.js, tag=农夫山泉, img-url=https://raw.githubusercontent.com/Orz-3/mini/master/Color/nfsq.png, enabled=true
|
||
*
|
||
* [青龙面板自动推送配置]
|
||
* 如果需要抓包后自动推送到青龙,请在下方代码区域修改 QL_CONFIG 变量
|
||
* 或者在本地脚本管理器的“偏好设置”中存储相对应的数据
|
||
*
|
||
*/
|
||
|
||
// ================= 用户配置区域 =================
|
||
|
||
// 青龙面板对接配置 (如果不需要自动推送,可忽略)
|
||
// 建议在 BoxJs 或 脚本编辑器中修改,避免硬编码泄露
|
||
const QL_CONFIG = {
|
||
URL: "http://chickliu.store:5735", // 例如 http://192.168.1.5:5700
|
||
CLIENT_ID: "0g9wa9_Ulcud", // 应用管理 -> 新建应用 -> Client ID
|
||
CLIENT_SECRET: "FWNsG34ttRRTKbCxsa9_2hxh" // 应用管理 -> 新建应用 -> Client Secret
|
||
};
|
||
|
||
const SCRIPT_NAME = "农夫山泉";
|
||
const ENV_NAME = "NFSQ_ACCOUNTS";
|
||
|
||
// ================= 运行环境兼容 =================
|
||
|
||
// 初始化环境
|
||
const $ = new Env(SCRIPT_NAME);
|
||
|
||
// 兼容 Node.js 的 fetch
|
||
let nodeHeaders = {};
|
||
if ($.isNode()) {
|
||
try {
|
||
const https = require("https");
|
||
if (typeof fetch !== "function") {
|
||
global.fetch = function (url, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(url);
|
||
const req = https.request(
|
||
{
|
||
hostname: u.hostname,
|
||
path: u.pathname + u.search,
|
||
method: options.method || "GET",
|
||
headers: options.headers || {},
|
||
},
|
||
(res) => {
|
||
let rawData = "";
|
||
res.on("data", (chunk) => (rawData += chunk));
|
||
res.on("end", () => {
|
||
resolve({
|
||
status: res.statusCode,
|
||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||
headers: res.headers,
|
||
json: () => Promise.resolve(rawData ? JSON.parse(rawData) : {}),
|
||
text: () => Promise.resolve(rawData)
|
||
});
|
||
});
|
||
}
|
||
);
|
||
req.on("error", reject);
|
||
if (options.body) req.write(options.body);
|
||
req.end();
|
||
});
|
||
};
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
|
||
const TASK_LIST = [
|
||
{ name: "浏览得机会", id: "2510301515552" },
|
||
{ name: "分享得机会", id: "2510301516431" },
|
||
{ name: "观看视频号", id: "2510301517291" },
|
||
{ name: "浏览公众号", id: "2510301518121" }
|
||
];
|
||
|
||
const RECORD_URL = "https://sxs-consumer.nfsq.com.cn/geement.actjextra/api/v1/act/win/goods/simple?act_codes=ACT2510301507191%2CACT2510301505581";
|
||
|
||
// ================= 入口逻辑 =================
|
||
|
||
(async () => {
|
||
// 1. 抓包模式
|
||
if (typeof $request !== "undefined") {
|
||
await handleRewrite();
|
||
}
|
||
// 2. 运行模式
|
||
else {
|
||
await main();
|
||
}
|
||
$.done();
|
||
})();
|
||
|
||
// ================= 核心逻辑 =================
|
||
|
||
// 抓包处理
|
||
async function handleRewrite() {
|
||
try {
|
||
$.log("🔔 检测到抽奖请求,开始抓取账号信息...");
|
||
|
||
// 1. 获取请求头和体
|
||
const headers = $request.headers || {};
|
||
const bodyStr = $request.body || "{}";
|
||
|
||
// 兼容不同客户端 Header key 大小写
|
||
const getHeader = (key) => headers[key] || headers[key.toLowerCase()] || headers[Object.keys(headers).find(k => k.toLowerCase() === key.toLowerCase())];
|
||
|
||
const uid = getHeader('unique_identity');
|
||
const token = getHeader('apitoken') || getHeader('apiToken');
|
||
|
||
if (!uid || !token) {
|
||
$.msg(SCRIPT_NAME, "抓包失败", "未找到 unique_identity 或 apitoken");
|
||
return;
|
||
}
|
||
|
||
// 2. 组装账号
|
||
let bodyObj = {};
|
||
try { bodyObj = JSON.parse(bodyStr); } catch (e) { }
|
||
|
||
// 构建标准账号对象 (严格对齐用户提供的标准示例)
|
||
const newAccount = {
|
||
uid: uid,
|
||
token: token,
|
||
body: bodyObj,
|
||
headers_json: headers
|
||
};
|
||
|
||
// 3. 本地存储逻辑 (BoxJs 仍然保持本地全量备份)
|
||
let localAccounts = [];
|
||
const cachedData = $.getdata(ENV_NAME);
|
||
if (cachedData) {
|
||
try {
|
||
const parsed = JSON.parse(cachedData);
|
||
localAccounts = Array.isArray(parsed) ? parsed : [parsed];
|
||
} catch (e) { localAccounts = []; }
|
||
}
|
||
|
||
const idx = localAccounts.findIndex(u => u.uid === uid);
|
||
if (idx > -1) {
|
||
localAccounts[idx] = newAccount; // 更新
|
||
} else {
|
||
localAccounts.push(newAccount); // 新增
|
||
}
|
||
|
||
const saved = $.setdata(JSON.stringify(localAccounts, null, 2), ENV_NAME);
|
||
if (saved) {
|
||
$.msg(SCRIPT_NAME, `✅ 账号抓取成功`, `UID: ${uid.slice(0, 6)}... 已保存到本地`);
|
||
}
|
||
|
||
// 4. 推送到青龙 (智能合并模式)
|
||
if (QL_CONFIG.URL && QL_CONFIG.CLIENT_ID && QL_CONFIG.CLIENT_SECRET) {
|
||
await pushToQingLong(newAccount);
|
||
} else {
|
||
$.log("⚠️ 未配置青龙信息,跳过推送。(请在脚本 QL_CONFIG 中填写,或手动复制 BoxJs 数据)");
|
||
}
|
||
|
||
} catch (e) {
|
||
console.log("❌ 抓包处理异常:", e);
|
||
$.msg(SCRIPT_NAME, "抓包异常", e.message);
|
||
}
|
||
}
|
||
|
||
// 任务运行主函数
|
||
async function main() {
|
||
// 优先读取本地 BoxJs/Prefs 数据,其次读取环境变量
|
||
let accountsStr = $.getdata(ENV_NAME);
|
||
if (!accountsStr && $.isNode()) {
|
||
accountsStr = process.env[ENV_NAME];
|
||
}
|
||
|
||
let list = [];
|
||
if (!accountsStr) {
|
||
console.log(`❌ 未找到账号信息!`);
|
||
console.log(`1. 青龙用户:请设置环境变量 ${ENV_NAME}`);
|
||
console.log(`2. 圈X用户:请先运行一次抓包 (重写已配置)`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
list = JSON.parse(accountsStr);
|
||
if (!Array.isArray(list)) list = [list];
|
||
} catch (e) {
|
||
// 尝试修复单引号
|
||
try {
|
||
list = JSON.parse(accountsStr.replace(/'/g, '"'));
|
||
} catch (e2) {
|
||
console.log("❌ JSON 解析失败,请检查数据格式");
|
||
return;
|
||
}
|
||
}
|
||
|
||
console.log(`🚀 脚本启动: 检测到 ${list.length} 个账号`);
|
||
|
||
for (let i = 0; i < list.length; i++) {
|
||
let acc = list[i];
|
||
console.log(`\n======== 👤 账号 ${i + 1}/${list.length} (${acc.uid?.slice(0, 6) || '未知'}) ========`);
|
||
|
||
if (!acc.token || !acc.uid) {
|
||
console.log(" ⚠️ 账号信息缺失(token/uid),跳过");
|
||
continue;
|
||
}
|
||
|
||
let realUA = extractUA(acc.headers_json);
|
||
|
||
// --- 1. 做任务 ---
|
||
console.log("【1️⃣ 任务环节】");
|
||
for (let task of TASK_LIST) {
|
||
await joinTask(acc.token, acc.uid, task, realUA);
|
||
await $.wait(500);
|
||
}
|
||
|
||
// --- 2. 抽奖 ---
|
||
console.log("\n【2️⃣ 抽奖环节】");
|
||
let count = 0;
|
||
let running = true;
|
||
while (running && count < 50) { // 限制最大50次防止死循环
|
||
count++;
|
||
let status = await doLottery(acc.token, acc.uid, acc.body, acc.headers_json);
|
||
if (status !== "CONTINUE") {
|
||
running = false;
|
||
} else {
|
||
process.stdout.write("."); // 进度条效果
|
||
await $.wait(2000); // 间隔2秒
|
||
}
|
||
}
|
||
console.log(count >= 50 ? "\n ⚠️ 达到最大抽奖次数限制" : "");
|
||
|
||
// --- 3. 查奖 ---
|
||
console.log("\n【3️⃣ 历史奖品】");
|
||
await checkAllPrizes(acc.token, acc.uid, realUA);
|
||
|
||
await $.wait(1500);
|
||
}
|
||
console.log("\n🏁 所有任务执行结束");
|
||
}
|
||
|
||
// ================= 业务函数 =================
|
||
|
||
async function doLottery(token, uid, body, headers_obj) {
|
||
// 准备请求体
|
||
let payload = typeof body === 'string' ? body : JSON.stringify(body || {});
|
||
// 准备请求头
|
||
let headers = {
|
||
'apitoken': token,
|
||
'unique_identity': uid,
|
||
'Host': 'sxs-consumer.nfsq.com.cn',
|
||
'Content-Type': 'application/json',
|
||
'User-Agent': extractUA(headers_obj) || "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.18"
|
||
};
|
||
|
||
try {
|
||
const resp = await $.http.post({
|
||
url: 'https://sxs-consumer.nfsq.com.cn/geement.marketinglottery/api/v1/marketinglottery',
|
||
headers: headers,
|
||
body: payload
|
||
});
|
||
|
||
const res = JSON.parse(resp.body || "{}");
|
||
if (res.code === 200) {
|
||
let pName = res.data?.prizedto?.prize_name || res.data?.prize_name || "未知奖品";
|
||
console.log(` 🎉 中奖: ${pName}`);
|
||
return "CONTINUE";
|
||
} else {
|
||
// 失败或无资格
|
||
const msg = res.msg || "未知错误";
|
||
// 只有特定错误才停止,其他(如网络抖动)可以重试,但这里简单起见,非200一般都停
|
||
if (msg.includes("不足") || msg.includes("上限") || msg.includes("机会")) {
|
||
console.log(` ⏹ 停止: ${msg}`);
|
||
return "STOP";
|
||
}
|
||
console.log(` 💨 未中/继续: ${msg}`);
|
||
return "CONTINUE";
|
||
}
|
||
} catch (e) {
|
||
console.log(` ❌ 抽奖出错: ${e.message}`);
|
||
return "STOP";
|
||
}
|
||
}
|
||
|
||
async function joinTask(token, uid, task, userAgent) {
|
||
const timeStr = encodeURIComponent(getNowFormatDate());
|
||
const url = `https://sxs-consumer.nfsq.com.cn/geement.marketingplay/api/v1/task/join?action_time=${timeStr}&task_id=${task.id}`;
|
||
const headers = {
|
||
'apitoken': token,
|
||
'unique_identity': uid,
|
||
'Host': 'sxs-consumer.nfsq.com.cn',
|
||
'Referer': 'https://servicewechat.com/wxd79ec05386a78727/100/page-frame.html',
|
||
'User-Agent': userAgent || "Mozilla/5.0"
|
||
};
|
||
|
||
try {
|
||
const resp = await $.http.get({ url, headers });
|
||
const res = JSON.parse(resp.body || "{}");
|
||
if (res.code === 200 || (res.msg && res.msg.includes("已参与"))) {
|
||
console.log(` ✅ ${task.name}: 完成`);
|
||
} else {
|
||
console.log(` ⏺ ${task.name}: ${res.msg}`);
|
||
}
|
||
} catch (e) {
|
||
console.log(` ❌ ${task.name}: 请求失败`);
|
||
}
|
||
}
|
||
|
||
async function checkAllPrizes(token, uid, userAgent) {
|
||
const headers = {
|
||
'apitoken': token,
|
||
'unique_identity': uid,
|
||
'Host': 'sxs-consumer.nfsq.com.cn',
|
||
'User-Agent': userAgent || "Mozilla/5.0"
|
||
};
|
||
|
||
try {
|
||
const resp = await $.http.get({ url: RECORD_URL, headers });
|
||
const res = JSON.parse(resp.body || "{}");
|
||
if (res.code === 200 && res.data && res.data.length > 0) {
|
||
console.log(` 📚 共 ${res.data.length} 条记录:`);
|
||
res.data.forEach(item => {
|
||
console.log(` 🎁 [${item.scan_time}] ${item.win_prize_name}`);
|
||
});
|
||
} else {
|
||
console.log(" 💨 暂无中奖记录");
|
||
}
|
||
} catch (e) {
|
||
console.log(" ❌ 查询失败");
|
||
}
|
||
}
|
||
|
||
// ================= 青龙推送相关 =================
|
||
|
||
async function pushToQingLong(accountToPush) {
|
||
$.log(`📤 正在尝试处理青龙推送 (UID: ${accountToPush.uid.slice(0, 6)}...)`);
|
||
const { URL, CLIENT_ID, CLIENT_SECRET } = QL_CONFIG;
|
||
|
||
try {
|
||
// 1. 获取 Token
|
||
const tokenUrl = `${URL}/open/auth/token?client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`;
|
||
const tReq = await $.http.get({ url: tokenUrl });
|
||
const tBody = JSON.parse(tReq.body);
|
||
if (tBody.code !== 200) {
|
||
$.msg(SCRIPT_NAME, "青龙推送失败", "获取 Token 失败,请检查密钥");
|
||
return;
|
||
}
|
||
const qlToken = tBody.data.token;
|
||
|
||
// 2. 获取现有环境变量
|
||
const envsUrl = `${URL}/open/envs?searchValue=${ENV_NAME}`;
|
||
const eReq = await $.http.get({
|
||
url: envsUrl,
|
||
headers: { 'Authorization': `Bearer ${qlToken}` }
|
||
});
|
||
const eBody = JSON.parse(eReq.body);
|
||
|
||
// 3. 准备数据:读取现有 -> 合并
|
||
let remoteList = [];
|
||
let existingEnv = null;
|
||
|
||
if (eBody.data && eBody.data.length > 0) {
|
||
existingEnv = eBody.data[0];
|
||
try {
|
||
// 尝试解析现有的值为 JSON 数组
|
||
// 注意:青龙里可能存的是字符串,也可能已经是 JSON
|
||
let rawValue = existingEnv.value;
|
||
// 有时候青龙返回的 value 是被转义过的字符串,尝试解析
|
||
let parsedValue = JSON.parse(rawValue);
|
||
if (Array.isArray(parsedValue)) {
|
||
remoteList = parsedValue;
|
||
} else {
|
||
// 如果不是数组,可能是单个对象,或者其他格式,清空
|
||
remoteList = [];
|
||
}
|
||
} catch (e) {
|
||
console.log(" ⚠️ 解析青龙现有变量失败,将覆盖为新列表");
|
||
remoteList = [];
|
||
}
|
||
}
|
||
|
||
// 4. 执行合并逻辑
|
||
const targetIdx = remoteList.findIndex(u => u.uid === accountToPush.uid);
|
||
let action = "";
|
||
if (targetIdx > -1) {
|
||
remoteList[targetIdx] = accountToPush;
|
||
action = "更新";
|
||
} else {
|
||
remoteList.push(accountToPush);
|
||
action = "新增";
|
||
}
|
||
|
||
const newValue = JSON.stringify(remoteList); // 保持紧凑格式,或者用 JSON.stringify(remoteList, null, 2) 美化
|
||
|
||
// 5. 推送回青龙
|
||
if (existingEnv) {
|
||
// update
|
||
const updateUrl = `${URL}/open/envs`;
|
||
await $.http.put({
|
||
url: updateUrl,
|
||
headers: {
|
||
'Authorization': `Bearer ${qlToken}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
id: existingEnv.id,
|
||
name: ENV_NAME,
|
||
value: newValue,
|
||
remarks: existingEnv.remarks || (`自动抓包更新: ${new Date().toLocaleString()}`)
|
||
})
|
||
});
|
||
$.msg(SCRIPT_NAME, `✅ 推送成功 [${action}]`, `UID: ${accountToPush.uid.slice(0, 6)}... 已同步至青龙`);
|
||
} else {
|
||
// create
|
||
const addUrl = `${URL}/open/envs`;
|
||
await $.http.post({
|
||
url: addUrl,
|
||
headers: {
|
||
'Authorization': `Bearer ${qlToken}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify([{
|
||
name: ENV_NAME,
|
||
value: newValue,
|
||
remarks: "自动抓包创建"
|
||
}])
|
||
});
|
||
$.msg(SCRIPT_NAME, `✅ 推送成功 [新建]`, `UID: ${accountToPush.uid.slice(0, 6)}... 已创建青龙变量`);
|
||
}
|
||
|
||
} catch (e) {
|
||
$.log(" ❌ 推送异常: " + e.message);
|
||
$.msg(SCRIPT_NAME, "青龙推送异常", e.message);
|
||
}
|
||
}
|
||
|
||
// ================= 工具函数 & Env实现 =================
|
||
|
||
function extractUA(headers) {
|
||
if (!headers) return null;
|
||
if (typeof headers !== 'object') {
|
||
try { headers = JSON.parse(headers); } catch (e) { return null; }
|
||
}
|
||
return headers['User-Agent'] || headers['user-agent'];
|
||
}
|
||
function getNowFormatDate() {
|
||
let d = new Date();
|
||
let pad = (n) => n < 10 ? '0' + n : n;
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||
}
|
||
|
||
// 兼容性封装 (Copy from common env implementations)
|
||
function Env(name) {
|
||
return new class {
|
||
constructor(name) { this.name = name; this.logs = []; }
|
||
isNode() { return "undefined" != typeof module && !!module.exports }
|
||
log(...t) { if (t.length > 0) { this.logs = [...this.logs, ...t]; console.log(t.join(" ")); } }
|
||
msg(t, e, s) {
|
||
if (this.isNode()) { this.log(t, e, s); }
|
||
else { if ("undefined" != typeof $notify) $notify(t, e, s); }
|
||
}
|
||
getdata(t) {
|
||
if (this.isNode()) return process.env[t];
|
||
if ("undefined" != typeof $prefs) return $prefs.valueForKey(t);
|
||
if ("undefined" != typeof $persistentStore) return $persistentStore.read(t);
|
||
return null;
|
||
}
|
||
setdata(t, e) {
|
||
if (this.isNode()) return false;
|
||
if ("undefined" != typeof $prefs) return $prefs.setValueForKey(t, e);
|
||
if ("undefined" != typeof $persistentStore) return $persistentStore.write(t, e);
|
||
return false;
|
||
}
|
||
wait(t) { return new Promise(e => setTimeout(e, t)) }
|
||
done() { if (this.isNode()) { } else { $done({}) } }
|
||
|
||
// 简单封装 HTTP,消除平台差异
|
||
get http() {
|
||
const that = this;
|
||
return {
|
||
get: (opts) => that.sendReq('GET', opts),
|
||
post: (opts) => that.sendReq('POST', opts),
|
||
put: (opts) => that.sendReq('PUT', opts),
|
||
delete: (opts) => that.sendReq('DELETE', opts)
|
||
}
|
||
}
|
||
sendReq(method, opts) {
|
||
return new Promise((resolve, reject) => {
|
||
const reqOpts = { url: opts.url, method: method, headers: opts.headers, body: opts.body };
|
||
if (this.isNode()) {
|
||
// Node fetch 在外部已定义,这里直接用
|
||
fetch(reqOpts.url, reqOpts).then(res => res.text().then(txt => resolve({ body: txt, headers: res.headers }))).catch(reject);
|
||
} else if ("undefined" != typeof $task) {
|
||
$task.fetch(reqOpts).then(res => resolve({ body: res.body, headers: res.headers })).catch(reject);
|
||
} else if ("undefined" != typeof $httpClient) {
|
||
const client = method === 'POST' ? $httpClient.post : (method === 'PUT' ? $httpClient.put : $httpClient.get);
|
||
client(reqOpts, (err, res, body) => {
|
||
if (err) reject(err);
|
||
else resolve({ body: body, headers: res.headers });
|
||
});
|
||
} else {
|
||
reject("Env not supported");
|
||
}
|
||
});
|
||
}
|
||
}(name);
|
||
}
|