mirror of
https://github.com/smallfawn/QLScriptPublic.git
synced 2026-11-06 06:46:48 +08:00
feat(wxapp): 新增 毛铺草本荟(两处 md5 签名已完整逆出,卡"完善个人信息")
契约(host mpb.jingjiu.com/proxy-he,信封 {code,data,message} 成功码 0):
登录 POST /jp/api/loginauto {code, unionid:"", user_id:"", user_sources:"0",
system:{...}, itime, isource},其中 isource 见解包 utils/parse.js:
md5(code+"DI9ynKTdfWqF").slice(0,16) + md5(code+itime+"DI9ynKTdfWqF").slice(-16)
-> data.access_token(之后 Authorization 放裸值,不是 Bearer)+ x-version:0.0.1。
状态 POST /api/FlanSignInDaily/mains {}(**不需要签名**)-> sign_in_today(实测未签是 -1)、
user.point、sign_in_day_continue。签到 POST /api/FlanSignInDaily/adds {date:"YYYY-MM-DD"}
**需要签名头**,见解包 utils/sign/getAppSign.js:只有白名单里的 key 参与拼接,
appsign = md5(apptime + "date" + date + "DYSHJS^M&.YXZRGS" + access_token) 取后 10 位。
注意这个包自带的 md5 输出是**大写**(utils/md5.js 末尾 toUpperCase),签名必须跟着大写。
实测:登录成功(服务端自动建会员「会员97f81d95」,mobile 为 null)→ 状态读取正常
(积分 0、今日未签)→ 签到回 {code:-1000,"完善个人信息后才能参与"}。**签名是对的**
(错了会是签名类报错),是账号资料没填全,脚本按 ⚠️ 提示先去小程序完善个人信息,
不代填资料、不代过手机号授权。阅读文章赚积分/抽奖不做。
fixture wxwork/test_maopu_mock.js 5 场景 77 条断言全通过 —— 其中 isource 和 appsign
都是在 fixture 里**独立重算后逐字节比对**脚本实际发出的值。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed58c556ea
commit
b8235d370a
1 file changed
+289
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
------------------------------------------
|
||||
@Description: 毛铺草本荟 - 微信小程序静默登录 + 每日签到
|
||||
cron: 43 8 * * *
|
||||
------------------------------------------
|
||||
变量名:maopu
|
||||
变量值:wx_server 里的 openid/账号标识,多账号用 & 或换行分隔(可加 #备注)
|
||||
|
||||
依赖变量:
|
||||
wx_server_url 默认 http://192.168.31.196:8787
|
||||
wx_auth 必填,wx_server 鉴权值
|
||||
------------------------------------------
|
||||
契约(appid wxefd0fe341e06b815,host mpb.jingjiu.com/proxy-he):
|
||||
|
||||
信封 {code, data, message},成功码 0。
|
||||
|
||||
登录 POST /jp/api/loginauto
|
||||
{code, unionid:"", user_id:"", user_sources:"0", system:{...}, itime, isource}
|
||||
itime = 秒级时间戳;isource 见解包 utils/parse.js 的 parseTokenParams:
|
||||
isource = md5(code + "DI9ynKTdfWqF").slice(0,16) + md5(code + itime + "DI9ynKTdfWqF").slice(-16)
|
||||
(这个包里的 md5 输出是**大写**十六进制,见 utils/md5.js 末尾的 toUpperCase())
|
||||
-> data.{access_token, session_key, user_id, unionid, mobile, name}
|
||||
之后所有请求带请求头 Authorization: <access_token>(裸值)+ x-version: 0.0.1
|
||||
|
||||
状态 POST /api/FlanSignInDaily/mains {} ← 不需要签名
|
||||
-> data.{user.point, sign_in_today, sign_in_day_continue, rule}
|
||||
实测未签时 sign_in_today = -1(>0 视为今天已签)
|
||||
|
||||
签到 POST /api/FlanSignInDaily/adds {date:"YYYY-MM-DD"} ← **需要签名头**
|
||||
见解包 utils/sign/getAppSign.js:只把白名单里的 key 参与拼接
|
||||
c = apptime + ("date" + date) + "DYSHJS^M&.YXZRGS" + access_token
|
||||
appsign = md5(c) 取**后 10 位**(大写),apptime = 秒级时间戳
|
||||
同一套签名头也用于 /api/FlanArticle/articleView 等(白名单换成 article_id)
|
||||
|
||||
账号态:本测试号登录后服务端自动建了会员(name 形如「会员xxxxxxxx」、mobile 为 null),
|
||||
但签到回 {code:-1000,"完善个人信息后才能参与"} —— 要先在小程序里补个人信息(手机号等)。
|
||||
脚本按 ⚠️ 提示,不代填资料、不代过手机号授权。
|
||||
|
||||
不做:阅读文章赚积分(/api/FlanArticle/articleView,属刷任务)、抽奖。
|
||||
------------------------------------------
|
||||
*/
|
||||
|
||||
const { Env } = require("../tools/env.js");
|
||||
const $ = new Env("毛铺草本荟");
|
||||
const axios = require("axios");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const WeChatServer = require("./wcs.js");
|
||||
|
||||
const ckName = "maopu";
|
||||
const MINI_APP_ID = "wxefd0fe341e06b815";
|
||||
const BASE = "https://mpb.jingjiu.com/proxy-he";
|
||||
const X_VERSION = "0.0.1";
|
||||
const SALT_SIGN = "DYSHJS^M&.YXZRGS";
|
||||
const SALT_LOGIN = "DI9ynKTdfWqF";
|
||||
const TOKEN_CACHE_FILE = path.join(__dirname, "maopu_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 = "/jp/api/loginauto";
|
||||
const EP_STATE = "/api/FlanSignInDaily/mains";
|
||||
const EP_SIGN = "/api/FlanSignInDaily/adds";
|
||||
|
||||
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 || "",
|
||||
});
|
||||
|
||||
/** 包里的 md5 输出大写,签名要跟它一致 */
|
||||
const md5 = (s) => crypto.createHash("md5").update(String(s), "utf8").digest("hex").toUpperCase();
|
||||
|
||||
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 today() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
}
|
||||
|
||||
const isOk = (res) => res && Number(res.code) === 0;
|
||||
const codeOf = (res) => Number(res?.code);
|
||||
const msgOf = (res) => res?.message || res?.msg || short(res);
|
||||
const isAlreadyDone = (t) => /已签|已经签|签到过|重复|已完成|already/i.test(String(t || ""));
|
||||
const isAuthError = (res) => /登录|token|未授权|未登录|失效|过期/.test(msgOf(res)) || codeOf(res) === 401;
|
||||
/** -1000 完善个人信息后才能参与:账号态门槛,不是脚本缺陷 */
|
||||
const NEED_PROFILE = -1000;
|
||||
|
||||
class Task {
|
||||
constructor(raw) {
|
||||
this.index = $.userIdx++;
|
||||
this.account = parseAccount(raw);
|
||||
this.token = "";
|
||||
}
|
||||
|
||||
log(text) {
|
||||
$.log(`账号[${this.index}]${this.account.remark ? `[${this.account.remark}]` : ""} ${text}`);
|
||||
}
|
||||
|
||||
/** 只有需要签名的接口才传 signKeys(白名单里的 key 才参与拼接,顺序按 body 的 key 顺序) */
|
||||
async request(apiPath, { body = null, withAuth = true, signKeys = null } = {}) {
|
||||
const payload = body || {};
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"x-version": X_VERSION,
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: `https://servicewechat.com/${MINI_APP_ID}/318/page-frame.html`,
|
||||
xweb_xhr: "1",
|
||||
};
|
||||
if (withAuth && this.token) headers.Authorization = this.token;
|
||||
if (signKeys && withAuth) {
|
||||
const apptime = Math.round(Date.now() / 1000);
|
||||
let joined = "";
|
||||
for (const k of Object.keys(payload)) {
|
||||
if (signKeys.includes(k)) joined += `${k}${payload[k]}`;
|
||||
}
|
||||
headers.apptime = String(apptime);
|
||||
headers.appsign = md5(`${apptime}${joined}${SALT_SIGN}${this.token}`).slice(-10);
|
||||
}
|
||||
const res = await axios.request({
|
||||
method: "POST",
|
||||
url: `${BASE}${apiPath}`,
|
||||
data: payload,
|
||||
headers,
|
||||
timeout: 20000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
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,必须自己判失败,否则取码限流会被误报成登录失败 */
|
||||
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 itime = Math.floor(Date.now() / 1000);
|
||||
const isource = md5(`${code}${SALT_LOGIN}`).slice(0, 16) + md5(`${code}${itime}${SALT_LOGIN}`).slice(-16);
|
||||
const res = await this.request(EP_LOGIN, {
|
||||
withAuth: false,
|
||||
body: {
|
||||
code,
|
||||
unionid: "",
|
||||
user_id: "",
|
||||
user_sources: "0",
|
||||
system: { platform: "android", system: "Android 12", model: "M2012K11AC", SDKVersion: "3.4.5" },
|
||||
itime,
|
||||
isource,
|
||||
},
|
||||
});
|
||||
if (!isOk(res)) throw new Error(`登录失败: ${msgOf(res)}`);
|
||||
const d = res.data || {};
|
||||
this.token = String(d.access_token || "");
|
||||
if (!this.token) throw new Error(`登录未返回 access_token: ${short(res)}`);
|
||||
const cache = readCache();
|
||||
cache[this.account.openid] = { token: this.token, updatedAt: new Date().toISOString() };
|
||||
writeCache(cache);
|
||||
this.log(`登录成功${d.name ? `: ${d.name}` : ""}`);
|
||||
}
|
||||
|
||||
/** 返回 {signedToday, point, days};会话失效返回 null */
|
||||
async state(needLog = true) {
|
||||
const res = await this.request(EP_STATE, { body: {} });
|
||||
if (!isOk(res)) {
|
||||
if (needLog && !isAuthError(res)) this.log(`读取签到状态失败: ${msgOf(res)}`);
|
||||
return null;
|
||||
}
|
||||
const d = res.data || {};
|
||||
const t = Number(d.sign_in_today);
|
||||
const info = {
|
||||
// 实测未签时是 -1;>0 视为今天已签
|
||||
signedToday: Number.isFinite(t) && t > 0,
|
||||
point: (d.user || {}).point,
|
||||
days: d.sign_in_day_continue,
|
||||
};
|
||||
if (needLog) {
|
||||
this.log(`积分 ${info.point ?? "-"},连签 ${info.days ?? "-"} 天,今日${info.signedToday ? "已签" : "未签"}`);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
async ensureLogin() {
|
||||
const cached = readCache()[this.account.openid] || {};
|
||||
if (!this.token && cached.token) {
|
||||
this.token = cached.token;
|
||||
const info = await this.state(false);
|
||||
if (info !== null) {
|
||||
this.log("使用缓存token");
|
||||
return info;
|
||||
}
|
||||
this.log("缓存token失效,重新登录");
|
||||
this.token = "";
|
||||
}
|
||||
if (!this.token) await this.login();
|
||||
return null;
|
||||
}
|
||||
|
||||
async sign(info) {
|
||||
if (info === null || info === undefined) info = await this.state();
|
||||
if (info === null) {
|
||||
this.log("❌ 会话无效,签到跳过");
|
||||
return;
|
||||
}
|
||||
if (info.signedToday) {
|
||||
this.log("✅ 今日已签到");
|
||||
return;
|
||||
}
|
||||
const date = today();
|
||||
const res = await this.request(EP_SIGN, { body: { date }, signKeys: ["date"] });
|
||||
if (isOk(res)) {
|
||||
const gained = (res.data || {}).point ?? (res.data || {}).points;
|
||||
this.log(`✅ 签到成功${gained ? `,+${gained} 积分` : ""}`);
|
||||
await this.state();
|
||||
return;
|
||||
}
|
||||
if (isAlreadyDone(msgOf(res))) return this.log(`✅ 今日已签到(${msgOf(res)})`);
|
||||
if (codeOf(res) === NEED_PROFILE) {
|
||||
this.log(`⚠️ ${msgOf(res)} —— 登录和签名都通了,是账号资料没填全(手机号等),先在小程序里完善个人信息再跑`);
|
||||
return;
|
||||
}
|
||||
this.log(`❌ 签到失败: ${msgOf(res)}`);
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (!this.account.openid) {
|
||||
this.log("跳过:变量值里没有 openid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const info = await this.ensureLogin();
|
||||
await this.sign(info);
|
||||
} 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());
|
||||
Reference in new issue
Block a user