mirror of
https://github.com/hex-ci/smzdm_script.git
synced 2026-10-28 19:25:04 +08:00
* **风控对抗增强**:
* 更新 `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` 的使用逻辑、设备字段缺失的影响以及如何正确抓包。
368 lines
10 KiB
JavaScript
368 lines
10 KiB
JavaScript
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
|
||
};
|