Files
smzdm_script/bot.js
Hex 0b71d792b8 feat(smzdm): 🚀 优化风控对抗逻辑并完善签到/任务流程
* **风控对抗增强**:
    * 更新 `APP_VERSION` 至 `11.1.98`。
    * 引入 `device_id` 确定性派生机制,通过 `userId` 结合 `DEVICE_SALT` 生成固定的设备串,解决跨运行设备不一致问题。
    * 优化 `sk` (设备签名) 生成逻辑,支持基于派生设备串自动计算。
    * 增加 `Cookie` 完整性检查,自动检测并提示缺失 `device_id`、`device_rid`、`z_dr` 等关键字段。
* **签到流程优化**:
    * 引入 `getCheckinCard` 接口,先渲染签到卡片以获取最新的连签状态,确保奖励判断准确。
    * 移除硬编码的 `IS_INSTALL_ZHANGDAMA` 参数。
* **任务处理优化**:
    * 增加 `getCaptchaType` 逻辑,在领取任务失败时识别验证码类型(滑块/图形),并友好提示用户手动操作。
    * 完善 `getArticleDetail` 接口,支持传递 `hashcode` 以匹配 APP 端跳转逻辑。
* **文档更新**:
    * 更新 `README.md`,详细说明 `SMZDM_SK` 的使用逻辑、设备字段缺失的影响以及如何正确抓包。
2026-10-22 01:20:56 +08:00

368 lines
10 KiB
JavaScript
Raw 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.
const crypto = require('crypto');
const got = require('got');
// ------------------------------------
const APP_VERSION = '11.1.98';
const APP_VERSION_REV = '1198';
const DEFAULT_USER_AGENT_APP = `smzdm_android_V${APP_VERSION} rv:${APP_VERSION_REV} (Redmi Note 3;Android10.0;zh)smzdmapp`;
const DEFAULT_USER_AGENT_WEB = `Mozilla/5.0 (Linux; Android 10.0; Redmi Build/Redmi Note 3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/95.0.4638.74 Mobile Safari/537.36 smzdm_android_V${APP_VERSION} rv:${APP_VERSION_REV} (Redmi;Android10.0;zh) jsbv_1.0.0 webv_2.0 smzdmapp`;
const SIGN_KEY = 'apr1$AwP!wRRT$gJ/q.X24poeBInlUJC';
const ZHUANZAI_AB = '';
// 无法从设备侧还原的本地开关取值,与全新安装的 APP 行为一致
const CREATE_CENTER_VIEW = '';
const IS_INSTALL_ZHANGDAMA = '0';
// APP 用 HTTP Date 头作为签名时间,且偏差超过 5 分钟会重新获取
const SERVER_TIME_URL = 'https://app-api.smzdm.com/get_server_time';
const SERVER_TIME_MAX_AGE = 5 * 60 * 1000;
// ------------------------------------
let serverTime = 0;
let serverTimeFetchedAt = 0;
const reVersion = /(smzdm_android_V|smzdm\s|iphone_smzdmapp\/)([\d.]+)/i;
const reRev = /rv:([\d.]+)/i;
// request_key = 8 位随机数 + 秒级时间戳
const requestKey = () => `${Math.floor(Math.random() * 90000000) + 10000000}${Math.floor(Date.now() / 1000)}`;
const getServerTime = async () => {
if (serverTime && Date.now() - serverTimeFetchedAt <= SERVER_TIME_MAX_AGE) {
return serverTime;
}
try {
// APP 直接读 HTTP Date 头,不关心状态码,取不到才回退本地时间
const response = await got(SERVER_TIME_URL, {
method: 'GET',
throwHttpErrors: false,
retry: {
limit: 0
}
});
const date = new Date(response.headers.date).getTime();
if (date) {
serverTime = date;
serverTimeFetchedAt = Date.now();
}
}
catch (error) {
console.log(`获取服务器时间失败,使用本地时间: ${error.message}`);
}
return serverTime || Date.now();
};
const parseJSON = (str) => {
try {
return JSON.parse(str);
}
catch (e) {
return {};
}
};
const removeTags = (str) => str.replace(/<[^<]+?>/g, '');
// 添加公共参数并签名数据
const signFormData = async (data) => {
const newData = {
weixin: 1,
basic_v: 0,
f: 'android',
v: APP_VERSION,
time: `${Math.round((await getServerTime()) / 1000)}000`,
zhuanzai_ab: ZHUANZAI_AB,
...data
};
const keys = Object.keys(newData).filter(key => newData[key] !== '').sort();
const signData = keys.map(key => `${key}=${newData[key]}`).join('&');
const sign = crypto.createHash('md5').update(`${signData.replace(/ /g, '')}&key=${SIGN_KEY}`).digest('hex').toUpperCase();
return {
...newData,
sign
};
};
// 公共请求函数
const requestApi = async (url, inputOptions = {}) => {
const options = { ...inputOptions };
if (!options.method) {
options.method = 'get';
}
if (!options.data) {
options.data = {};
}
Object.keys(options.data).forEach(key => options.data[key] === undefined && delete options.data[key]);
if (options.sign !== false) {
options.data = await signFormData(options.data);
}
const gotOptions = {
method: options.method.toUpperCase(),
headers: options.headers,
retry: {
limit: 2,
methods: [
'GET',
'POST'
],
statusCodes: [
],
errorCodes: [
'ECONNRESET',
'EAI_AGAIN'
]
}
};
if (options.method === 'get') {
gotOptions.searchParams = options.data;
}
else {
gotOptions.form = options.data;
}
return got(url, gotOptions).then((response) => {
const data = options.parseJSON === false ? response.body : parseJSON(response.body);
if (options.debug) {
console.log('------------------------');
console.log(url);
console.log('------------------------');
console.log(JSON.stringify(gotOptions, null, 2));
console.log('------------------------');
console.log(options.parseJSON === false ? response.body : JSON.stringify(data, null, 2));
console.log('------------------------');
}
return {
isSuccess: options.parseJSON === false ? true : (data.error_code == '0'),
response: options.parseJSON === false ? response.body : JSON.stringify(data, null, 2),
data
};
}).catch((error) => {
if (options.debug) {
console.log('------------------------');
console.log(url);
console.log('------------------------');
console.log(JSON.stringify(gotOptions, null, 2));
console.log('------------------------');
console.log(error);
console.log('------------------------');
}
return {
isSuccess: false,
response: error,
data: error
};
})
};
// APP 每次请求都用自己维护的字段重建 Cookie,缺失的字段需要补上
const setCookie = (cookie, name, value) => {
const re = new RegExp(`(^|;)\\s*${name}=[^;]+`, 'i');
if (re.test(cookie)) {
return cookie.replace(re, `$1${name}=${encodeURIComponent(value)}`);
}
return `${cookie.replace(/;?\s*$/, ';')} ${name}=${encodeURIComponent(value)};`;
};
// APP 不发送这些历史字段,保留会导致请求与 APP 不一致
const removeCookie = (cookie, name) => {
const re = new RegExp(`(^|;)\\s*${name}=[^;]+;?`, 'ig');
return cookie.replace(re, '$1');
};
const hasCookie = (cookie, name) => new RegExp(`(^|;)\\s*${name}=`, 'i').test(cookie);
// 值为空也是 APP 的合法字段,已存在就不要覆盖
const addCookieIfAbsent = (cookie, name, value = '') => {
if (hasCookie(cookie, name)) {
return cookie;
}
return `${cookie.replace(/;?\s*$/, ';')} ${name}=${value};`;
};
// APP 会用本地设备信息重建整条 Cookie,缺字段的请求一眼可辨
const REQUIRED_COOKIE_FIELDS = [
'sess',
'smzdm_id',
'device_id',
'device_rid',
'z_dr'
];
const warnedCookieFields = new Set();
const checkCookie = (cookie) => {
const missing = REQUIRED_COOKIE_FIELDS.filter(name => !hasCookie(cookie, name));
const key = missing.join(',');
if (missing.length < 1 || warnedCookieFields.has(key)) {
return;
}
warnedCookieFields.add(key);
console.log(`Cookie 缺少 APP 会发送的字段: ${missing.join('、')},建议重新从 APP 抓取完整 Cookie`);
};
const getEnvCookies = () => {
let cookies = [];
// 判断环境变量里面是否有 cookie
if (process.env.SMZDM_COOKIE) {
if (process.env.SMZDM_COOKIE.indexOf('&') > -1) {
cookies = process.env.SMZDM_COOKIE.split('&');
}
else if (process.env.SMZDM_COOKIE.indexOf('\n') > -1) {
cookies = process.env.SMZDM_COOKIE.split('\n');
}
else {
cookies = [process.env.SMZDM_COOKIE];
}
}
return cookies[0] ? cookies : false;
};
const randomDecimal = (min, max, decimal) => {
const rand = Math.random() * (max - min + 1) + min;
return Math.floor(rand * decimal) / decimal;
};
const wait = (minSecond, maxSecond) => {
// 生成随机小数秒数
const randomSecond = randomDecimal(minSecond, maxSecond, 1000);
console.log(`等候 ${minSecond}-${maxSecond}(${randomSecond}) 秒`);
return new Promise(resolve => setTimeout(resolve, randomSecond * 1000));
};
const waitHuman = async (minSecond, maxSecond) => {
await wait(minSecond, maxSecond);
if (Math.random() < 0.05) {
await wait(15, 45);
}
};
// ------------------------------------
class SmzdmBot {
constructor(cookie) {
this.cookie = cookie.trim();
const match = this.cookie.match(/sess=(.*?);/);
this.token = match ? match[1] : '';
// 处理 cookie
this.androidCookie = this.cookie.replace('iphone', 'android').replace('iPhone', 'Android');
this.androidCookie = setCookie(this.androidCookie, 'v', APP_VERSION);
this.androidCookie = setCookie(this.androidCookie, 'device_smzdm_version_code', APP_VERSION_REV);
this.androidCookie = setCookie(this.androidCookie, 'device_system_version', '10.0');
this.androidCookie = setCookie(this.androidCookie, 'apk_partner_name', 'smzdm_download');
this.androidCookie = setCookie(this.androidCookie, 'partner_name', 'smzdm_download');
this.androidCookie = setCookie(this.androidCookie, 'device_smzdm', 'android');
this.androidCookie = setCookie(this.androidCookie, 'f', 'android');
this.androidCookie = setCookie(this.androidCookie, 'basic_v', '0');
this.androidCookie = addCookieIfAbsent(this.androidCookie, 'device_type');
[
'smzdm_version',
'device_smzdm_version',
'device_name'
].forEach(name => {
this.androidCookie = removeCookie(this.androidCookie, name);
});
checkCookie(this.androidCookie);
}
getHeaders() {
let userAgent = DEFAULT_USER_AGENT_APP;
if (process.env.SMZDM_USER_AGENT_APP) {
userAgent = process.env.SMZDM_USER_AGENT_APP
.replace(reVersion, `$1${APP_VERSION}`)
.replace(reRev, `rv:${APP_VERSION_REV}`);
}
return {
'Accept-Encoding': 'gzip',
'request_key': requestKey(),
'User-Agent': userAgent,
Cookie: this.androidCookie
};
}
getHeadersForWeb() {
let userAgent = DEFAULT_USER_AGENT_WEB;
if (process.env.SMZDM_USER_AGENT_WEB) {
userAgent = process.env.SMZDM_USER_AGENT_WEB
.replace(reVersion, `$1${APP_VERSION}`)
.replace(reRev, `rv:${APP_VERSION_REV}`);
}
return {
Accept: '*/*',
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
'Accept-Encoding': 'gzip',
'User-Agent': userAgent,
Cookie: this.androidCookie
};
}
getOneByRandom(listing) {
return listing[Math.floor(Math.random() * listing.length)];
}
// 打乱顺序
getShuffled(listing) {
const result = listing.slice();
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
}
module.exports = {
SmzdmBot,
requestApi,
removeTags,
parseJSON,
getEnvCookies,
getServerTime,
CREATE_CENTER_VIEW,
IS_INSTALL_ZHANGDAMA,
wait,
waitHuman
};