Files
smallfawnandClaude Opus 5 bc36bcbad6 feat(wxapp): 新增 zippo会员 / 万家乐会员俱乐部,并修掉模板"业务结论在 4xx 体里就被抛掉"的坑
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>
2026-08-18 21:53:04 +08:00

243 lines
9.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
------------------------------------------
@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());