Files
smallfawnandClaude Opus 5 461c0fa285 feat(wxapp): 新增 4 个小程序脚本,从 GitHub 采集的实现改写为 smallcat 取码登录
来源:GitHub 近 3 个月更新的青龙仓库巡检(134 个仓库 -> 990 个候选脚本 ->
263 个命中小程序特征 -> 三重去重后 143 个我们没有的),从其中"有 code 登录、
可机械转换"的 30 个里先做 4 个。全部改成 wcs.js 取 code -> 业务登录 -> 任务,
统一加了 token 缓存、幂等判定、取码失败独立分支。

  shefuyishou.js  社服益寿活动  wx8ac1f54b8fc39c6c  /api/silenceLogin -> x-token
  niuyougu.js     牛油谷        wxfa2a1ee65e3c9122  /api/user/wxlogin(token 在 body)
  qutaoka.js      趣淘卡        wx479a4a95ec031d79  /api/login(data 直接是 token 串)
  fxh.js          一汽丰田丰享汇 wxdc0171c19d8ff575  /fxh-bff/app/wx/scx/login -> Authorization+sessionKey

实测(单账号,openid 为本机已绑定账号):
  fxh          链路通:登录回 code=200/success=true,但 hasLogin=false、token=null
               -> 该微信号还没在丰享汇注册。已把这种情况单独判出来给准确提示,
                  而不是笼统报"登录未返回 token"。
  shefuyishou  ylapi.luckystarpay.com 的 TLS 证书已过期(服务端问题,实测确认,
               另外三个站证书都正常)。按规则不关闭证书校验,等对方续期。
  niuyougu     app.niuyougu.com.cn 直连与代理均 ReadTimeout,本机不可达。
  qutaoka      登录恒回 {"status":0,"msg":"请求参数错误!"};form/json、只带 code、
               nickname 给值四种形状都试过,需要一次真机抓包才能补齐,不猜参数。

fixture:4 个脚本 × ok/已签到/取码失败(+fxh 未注册) 共 13 组场景、114 条断言全通过,
断言覆盖 appid、UA、Referer、token 放在头还是 body、已签到必须当成成功、
取码失败必须有独立日志且不去打目标站。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:07:00 +08:00

218 lines
7.7 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: 24 8 * * *
------------------------------------------
变量名:niuyougu
变量值:wx_server 里的 openid/账号标识,多账号用 & 或换行分隔(可加 #备注)
依赖变量:
wx_server_url 默认 http://192.168.31.196:8787
wx_auth 必填,wx_server 鉴权值
------------------------------------------
契约(appid wxfa2a1ee65e3c9122,host app.niuyougu.com.cn):
登录 POST /api/user/wxlogin form: token=&code=<code>&channelId=0
-> code==0, data.token / data.nickname
签到 POST /api/user/sign form: token=<token>&eid=1&_t=<毫秒>
-> code==0 成功;code==-2 今日已签到(这是成功语义,不是失败)
资料 POST /api/user/getUserInfo form: token=<token>
注意 token 是放在 body 里的,不是请求头。
------------------------------------------
*/
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 = "niuyougu";
const MINI_APP_ID = "wxfa2a1ee65e3c9122";
const BASE = "https://app.niuyougu.com.cn";
const TOKEN_CACHE_FILE = path.join(__dirname, "niuyougu_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 = "/api/user/wxlogin";
const EP_SIGN = "/api/user/sign";
const EP_USER = "/api/user/getUserInfo";
const CODE_ALREADY_SIGNED = -2;
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) => Number(res?.code) === 0;
const msgOf = (res) => res?.msg || res?.message || short(res);
const isAlreadyDone = (t) => /已签|已经签|签到过|重复|already/i.test(String(t || ""));
const isAuthError = (t) => /登录|token|未授权|失效|过期|重新/i.test(String(t || ""));
class Task {
constructor(raw) {
this.index = $.userIdx++;
this.account = parseAccount(raw);
this.token = "";
this.nickname = "";
}
log(text) {
$.log(`账号[${this.index}]${this.account.remark ? `[${this.account.remark}]` : ""} ${text}`);
}
async request(apiPath, body = {}) {
const res = await axios.request({
method: "POST",
url: `${BASE}${apiPath}`,
data: form(body),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": USER_AGENT,
Referer: `https://servicewechat.com/${MINI_APP_ID}/0/page-frame.html`,
},
timeout: 20000,
validateStatus: () => true,
});
if (res.status !== 200) throw new Error(`${apiPath} HTTP ${res.status}: ${short(res.data)}`);
return res.data;
}
/** wcs.getCode 在 status:false 时也 resolve,必须自己判失败,否则取码限流会被误报成登录失败 */
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, { token: "", code, channelId: 0 });
if (!isOk(res)) throw new Error(`登录失败: ${msgOf(res)}`);
const d = res.data || {};
this.token = d.token || "";
this.nickname = d.nickname || "";
if (!this.token) throw new Error(`登录未返回 token: ${short(res)}`);
const cache = readCache();
cache[this.account.openid] = { token: this.token, nickname: this.nickname, updatedAt: new Date().toISOString() };
writeCache(cache);
this.log(`登录成功${this.nickname ? `: ${this.nickname}` : ""}`);
}
async ensureLogin() {
const cached = readCache()[this.account.openid] || {};
if (!this.token && cached.token) {
this.token = cached.token;
this.nickname = cached.nickname || "";
if (await this.queryUser(false)) {
this.log("使用缓存token");
return;
}
this.log("缓存token失效,重新登录");
this.token = "";
}
if (!this.token) await this.login();
}
async queryUser(needLog = true) {
const res = await this.request(EP_USER, { token: this.token });
if (!isOk(res)) {
if (needLog) this.log(`读取资料失败: ${msgOf(res)}`);
return false;
}
const d = res.data || {};
this.nickname = d.nickname || this.nickname;
if (needLog) {
const bits = [];
if (d.nickname) bits.push(d.nickname);
for (const k of ["integral", "points", "score", "balance", "coin"]) {
if (d[k] !== undefined) 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, { token: this.token, eid: 1, _t: Date.now() });
if (isOk(res)) return this.log("✅ 签到成功");
if (Number(res?.code) === CODE_ALREADY_SIGNED || isAlreadyDone(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());