mirror of
https://github.com/smallfawn/QLScriptPublic.git
synced 2026-11-06 14:56:48 +08:00
zippo(zippo.js,模板生成,**实测签到成功 +1 积分**):这家没有业务成功码——
成功就是 HTTP 2xx(登录/签到都回 **201**)且体里没有 code;失败才带 code,
而且**放在 4xx 的 JSON 体里**:重复签到 = HTTP 400 {"code":"already_signed",
"message":"今日已签到"}。登录 POST /api/users/auth {code,scene,platform:"wxmp"}
-> {token(JWT),sid,openId,unionId},之后 Authorization: Bearer <token>;固定头
x-app-id:zippo / x-platform:wxmp / x-platform-id:<appid> / x-platform-env:release。
签到 POST /api/daily-signin 空 body。实测:会员 vip1 → 签到 +1 → 复签 400
already_signed → 复跑走缓存判「今日已签到」。
万家乐(wanjiale.js,手写):信封成功码是 **100**。登录
POST /wd-member/member/login {code,tenantId:473,appBuId:2065,wxAppId,i3rdSystemCode:"CSP2.0"}
三个租户常量来自解包 app-config.json 的 ext;源码里的 loginType 实测可省。
**sessionId 在 data.loginInfo.sessionId(data.sessionId 恒为 null),而且服务端不下
Set-Cookie,要自己拼 Cookie: sessionId=<...>**。状态 GET member/checkSigned(布尔)。
签到 GET member/sign。**没有会员档案时签到只回 {code:105,"系统繁忙,请稍后再试"}**
——这是后端兜底文案不是真忙,所以脚本先查 member/detail,用 130004
「查询不到指定的会员信息」把原因翻译成人话,按 ⚠️ 提示先去小程序注册。
实测:登录成功→未注册会员→⚠️ 分支(本账号没在万家乐注册过)。抽奖
(/mtool/app/luckywheel/draw,采集脚本一天抽 3 次) 不做。
模板通用修复:请求层原来 `status !== 200` 直接抛,会把 4xx/5xx 体里的业务结论
("今日已签到" 见过 400 也见过 500)变成"执行失败"。现在非 2xx 但有 JSON 体时
交给下游按业务码判。另外 queryUser 的数据取值补到 `res.data || res.datas ||
res.body || res`(zippo 的 profile 就是没有包装的裸对象)。
测试:spec fixture 11 个脚本 × 最多 5 场景 **564** 条断言全通过(新增 dup4xx 场景
专门守住 4xx 体里的幂等判定);万家乐独立 fixture 6 场景 73 条全通过
(含 Cookie 会话、loginInfo.sessionId、未注册不写、105 兜底码说明、不碰抽奖)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
9.4 KiB
JavaScript
243 lines
9.4 KiB
JavaScript
/*
|
||
------------------------------------------
|
||
@Description: 中粮悦喜荟 - 微信小程序静默登录 + 每日签到
|
||
cron: 30 8 * * *
|
||
------------------------------------------
|
||
变量名:yuexihui
|
||
变量值:wx_server 里的 openid/账号标识,多账号用 & 或换行分隔(可加 #备注)
|
||
|
||
依赖变量:
|
||
wx_server_url 默认 http://192.168.31.196:8787
|
||
wx_auth 必填,wx_server 鉴权值
|
||
------------------------------------------
|
||
契约(appid wx4a9b92a0680e7b29,host yxh.cofco.com):
|
||
登录 POST /auth/login {code, koOpenId:null} -> success===true, data.token
|
||
之后所有请求带请求头 token: <token>
|
||
资料 POST /member/base {}
|
||
签到 POST /mission/sign/in {} (状态查询是 /mission/sign/list)
|
||
成功判定是 success===true(不是 code),失败原因在 message/msg
|
||
------------------------------------------
|
||
*/
|
||
|
||
const { Env } = require("../tools/env.js");
|
||
const $ = new Env("中粮悦喜荟");
|
||
const axios = require("axios");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const WeChatServer = require("./wcs.js");
|
||
|
||
const ckName = "yuexihui";
|
||
const MINI_APP_ID = "wx4a9b92a0680e7b29";
|
||
const BASE = "https://yxh.cofco.com";
|
||
|
||
const TOKEN_CACHE_FILE = path.join(__dirname, "yuexihui_token_cache.json");
|
||
const USER_AGENT =
|
||
"Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.220303.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||
"Version/4.0 Chrome/134.0.6998.136 Mobile Safari/537.36 MicroMessenger/8.0.48.2580(0x28003036) MiniProgramEnv/android";
|
||
|
||
const EP_LOGIN = "/auth/login";
|
||
const EP_SIGN = "/mission/sign/in";
|
||
const EP_USER = "/member/base";
|
||
|
||
const wechat = new WeChatServer({
|
||
url: process.env.wx_server_url || "http://192.168.31.196:8787",
|
||
appid: MINI_APP_ID,
|
||
auth: process.env.wx_auth || "",
|
||
});
|
||
|
||
function readCache() {
|
||
try {
|
||
if (!fs.existsSync(TOKEN_CACHE_FILE)) return {};
|
||
return JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, "utf8")) || {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeCache(cache) {
|
||
try {
|
||
fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cache, null, 2), "utf8");
|
||
} catch (e) {
|
||
$.log(`写入token缓存失败: ${e.message || e}`);
|
||
}
|
||
}
|
||
|
||
function parseAccount(raw = "") {
|
||
const [id, remark] = String(raw).split("#").map((s) => (s || "").trim());
|
||
return { openid: id, remark: remark || "" };
|
||
}
|
||
|
||
function short(v, n = 200) {
|
||
const t = typeof v === "string" ? v : JSON.stringify(v);
|
||
return !t ? "" : t.length > n ? `${t.slice(0, n)}...` : t;
|
||
}
|
||
|
||
function form(obj) {
|
||
return Object.entries(obj)
|
||
.map(([k, v]) => `${k}=${encodeURIComponent(v === undefined || v === null ? "" : v)}`)
|
||
.join("&");
|
||
}
|
||
|
||
/** 该后端的成功判定 */
|
||
const isOk = (res) => res?.success === true;
|
||
const msgOf = (res) => res?.message || res?.message || res?.msg || short(res);
|
||
/** 每天跑一次,「已签到」必须当成成功而不是失败 */
|
||
const isAlreadyDone = (t) => /已签|已经签|签到过|重复|已完成|already/i.test(String(t || ""));
|
||
const isAuthError = (t) => /登录|token|未授权|未登录|失效|过期|重新|401/i.test(String(t || ""));
|
||
/** 账号态:这个微信号还没在该平台注册/绑定 —— 不是脚本缺陷,别打 ❌ */
|
||
const isNotRegistered = (t) => /未注册|未绑定|请先注册|请先绑定|not regist/i.test(String(t || ""));
|
||
|
||
class Task {
|
||
constructor(raw) {
|
||
this.index = $.userIdx++;
|
||
this.account = parseAccount(raw);
|
||
this.token = "";
|
||
this.signedToday = false;
|
||
// 设备号按 openid 稳定派生:同一账号每次跑都一样,避免被当成新设备
|
||
this.deviceId = "d_" + require("crypto").createHash("md5")
|
||
.update(String(this.account.openid || raw)).digest("hex").slice(0, 16);
|
||
}
|
||
|
||
log(text) {
|
||
$.log(`账号[${this.index}]${this.account.remark ? `[${this.account.remark}]` : ""} ${text}`);
|
||
}
|
||
|
||
async request(apiPath, body = null, withAuth = true, method = "POST", query = null, epHeaders = null) {
|
||
const isForm = false;
|
||
const headers = {
|
||
"Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
|
||
"User-Agent": USER_AGENT,
|
||
Referer: `https://servicewechat.com/${MINI_APP_ID}/0/page-frame.html`,
|
||
Accept: "application/json, text/plain, */*",
|
||
xweb_xhr: "1",
|
||
...(epHeaders || {}),
|
||
};
|
||
if (withAuth && this.token) headers["token"] = this.token;
|
||
const payload = body || {};
|
||
|
||
const isGet = String(method).toUpperCase() === "GET";
|
||
// query 独立于 body:有些接口是 POST 但参数只在查询串上
|
||
const qs = query ? form(query) : (isGet && Object.keys(payload).length ? form(payload) : "");
|
||
const res = await axios.request({
|
||
method: isGet ? "GET" : "POST",
|
||
url: `${BASE}${apiPath}${qs ? `?${qs}` : ""}`,
|
||
data: isGet ? undefined : (isForm ? form(payload) : payload),
|
||
headers,
|
||
timeout: 20000,
|
||
validateStatus: () => true,
|
||
});
|
||
if (res.status < 200 || res.status >= 300) {
|
||
// 业务结论常常躺在 4xx/5xx 的 JSON 体里("今日已签到" 见过 400 也见过 500),
|
||
// 有 JSON 体就交给下游按业务码判,别在这一层抛掉
|
||
if (res.data && typeof res.data === "object") return res.data;
|
||
throw new Error(`${apiPath} HTTP ${res.status}: ${short(res.data)}`);
|
||
}
|
||
return res.data;
|
||
}
|
||
|
||
/**
|
||
* wcs.getCode 在 status:false 时也会 resolve,必须自己判失败,
|
||
* 否则 wx_server 的取码限流会被误报成目标站登录失败。
|
||
*/
|
||
async getCode() {
|
||
const { data } = await wechat.getCode(this.account.openid);
|
||
if (data && data.status === false) {
|
||
throw new Error(`wx_server 取code失败: ${data.message || short(data)}`);
|
||
}
|
||
const code = data?.data?.code || data?.code;
|
||
if (!code || typeof code !== "string") throw new Error(`wx_server 未返回 code: ${short(data)}`);
|
||
return code;
|
||
}
|
||
|
||
async login() {
|
||
const code = await this.getCode();
|
||
const res = await this.request(EP_LOGIN, { code, koOpenId: null }, false, "POST", null, null);
|
||
if (!isOk(res)) throw new Error(`登录失败: ${msgOf(res)}`);
|
||
this.token = (res.data || {}).token || "";
|
||
|
||
if (!this.token) throw new Error(`登录未返回 token: ${short(res)}`);
|
||
const cache = readCache();
|
||
cache[this.account.openid] = { token: this.token, updatedAt: new Date().toISOString() };
|
||
writeCache(cache);
|
||
this.log("登录成功");
|
||
}
|
||
|
||
async ensureLogin() {
|
||
const cached = readCache()[this.account.openid] || {};
|
||
if (!this.token && cached.token) {
|
||
this.token = cached.token;
|
||
if (await this.queryUser(false)) {
|
||
this.log("使用缓存token");
|
||
return;
|
||
}
|
||
this.log("缓存token失效,重新登录");
|
||
this.token = "";
|
||
}
|
||
if (!this.token) await this.login();
|
||
}
|
||
|
||
async queryUser(needLog = true) {
|
||
if (!EP_USER) return true;
|
||
const res = await this.request(EP_USER, {}, true, "POST", null, null);
|
||
if (!isOk(res)) {
|
||
if (needLog) this.log(`读取资料失败: ${msgOf(res)}`);
|
||
return false;
|
||
}
|
||
// 有的家没有 data/body 包装,响应体本身就是数据(zippo 的 profile 就是)
|
||
const d = res.data || res.datas || res.body || res || {};
|
||
if (needLog) {
|
||
const bits = [];
|
||
for (const k of ["nickname", "nickName", "name", "memberId", "integral", "points",
|
||
"point", "score", "credits", "balance", "coin", "amount"]) {
|
||
if (d && d[k] !== undefined && d[k] !== null && d[k] !== "") bits.push(`${k}=${d[k]}`);
|
||
}
|
||
this.log(`会员: ${bits.join(" ") || short(d, 120)}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async sign(retry = true) {
|
||
const res = await this.request(EP_SIGN, {}, true, "POST", null, null);
|
||
if (isOk(res)) return this.log("✅ 签到成功");
|
||
if (isAlreadyDone(msgOf(res))) return this.log(`✅ 今日已签到(${msgOf(res)})`);
|
||
if (isNotRegistered(msgOf(res))) {
|
||
return this.log(`⚠️ ${msgOf(res)} —— 该微信号还没在该平台注册会员,先在小程序里注册一次再跑`);
|
||
}
|
||
if (retry && isAuthError(msgOf(res))) {
|
||
this.log("会话失效,重新登录后重试");
|
||
this.token = "";
|
||
await this.login();
|
||
return this.sign(false);
|
||
}
|
||
this.log(`❌ 签到失败: ${msgOf(res)}`);
|
||
}
|
||
|
||
async run() {
|
||
if (!this.account.openid) {
|
||
this.log("跳过:变量值里没有 openid");
|
||
return;
|
||
}
|
||
try {
|
||
await this.ensureLogin();
|
||
await this.queryUser();
|
||
await this.sign();
|
||
} catch (e) {
|
||
this.log(`执行失败: ${e.message || e}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
!(async () => {
|
||
$.checkEnv(ckName);
|
||
if (!$.userCount) {
|
||
$.log(`未找到变量 ${ckName}`);
|
||
return;
|
||
}
|
||
for (let i = 0; i < $.userList.length; i++) {
|
||
await new Task($.userList[i]).run();
|
||
if (i < $.userList.length - 1) await $.wait(1500, 3000);
|
||
}
|
||
})()
|
||
.catch((e) => $.log(e.message || e))
|
||
.finally(() => $.done());
|