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` 的使用逻辑、设备字段缺失的影响以及如何正确抓包。
This commit is contained in:
Hex
2026-10-22 01:20:56 +08:00
parent 9aa6d3dbae
commit 0b71d792b8
5 changed files with 273 additions and 59 deletions

View File

@@ -47,7 +47,7 @@ ql repo https://github.com/hex-ci/smzdm_script.git "smzdm_" "" "env.js|bot.js|se
建议使用 Android 手机抓包(iOS 也可以)域名为 `user-api.smzdm.com` 的任意链接,把**所有** Cookie 取出来放到青龙面板的 `SMZDM_COOKIE` 环境变量中,多用户请添加多个同名环境变量或者用 `&` 符号分隔。
如果手机实在抓不到,也可以用浏览器的 Cookie,但是强烈建议使用手机端的 Cookie。
如果手机实在抓不到,也可以用浏览器的 Cookie,但是强烈建议使用手机端的 Cookie。脚本会自动检查 `device_id`、`device_rid`、`z_dr` 等设备字段,缺失时在日志里提示,缺字段的 Cookie 更容易被风控。
#### 抓包教程
@@ -64,7 +64,7 @@ ql repo https://github.com/hex-ci/smzdm_script.git "smzdm_" "" "env.js|bot.js|se
环境变量请使用环境变量列表直接添加,不要使用 `export xxx=""` 这种方式添加环境变量。
* `SMZDM_COOKIE`: 抓包抓到的 Cookie 内容,需要所有 Cookie 内容,多用户可以用 `&` 分隔,或者使用多个同名环境变量。
* `SMZDM_SK`: 这个值是可选值,会自动计算,如果你一定想用自己的,可以抓取,是从安卓 App 的 `https://user-api.smzdm.com/checkin` 请求参数中抓包抓到的,多用户可以用 `&` 分隔,或者使用多个同名环境变量,顺序要保持与 `SMZDM_COOKIE` 多用户顺序一致。
* `SMZDM_SK`: 签到请求里的设备签名,从安卓 App 的 `https://user-api.smzdm.com/checkin` 请求参数中抓包得到,多用户可以用 `&` 分隔,或者使用多个同名环境变量,顺序要保持与 `SMZDM_COOKIE` 多用户顺序一致。不填时脚本会按账号派生一个固定的设备串来生成 sk(跨运行不变,但与真机的设备串不同);填了之后会解密校验它是否属于对应的 Cookie,不属于会在日志里提示(通常是多账号顺序错位)。无论哪种情况,脚本都会把 Cookie 的 `device_rid`/`device_s` 对齐成 md5(实际使用的设备串)——同机抓的 Cookie 这两个值本来就相等,不会变;不一致时说明 Cookie 和 sk 可能不是同一台设备,日志会提示。
* `SMZDM_USER_AGENT_APP`: 这个值是可选值,是指 APP 的 User-Agent,从 APP 的 API 请求头中抓包得到,建议抓取 Android 的 User-Agent,不填使用脚本默认值。
* `SMZDM_USER_AGENT_WEB`: 这个值是可选值,是指 APP 中访问网页的 User-Agent,一般在 APP 内的转盘网页中抓包得到,建议抓取 Android 的 User-Agent,不填使用脚本默认值。
* `SMZDM_COMMENT`: 如果要完成评论文章的任务请设置这个环境变量,环境变量的内容是评论的文案,文案要大于 10 个汉字,建议用比较个性化的文案,脚本发布评论后会删除这条评论,但是为防止删除失败的情况,请尽量用好一点的文案,防止被判定为恶意灌水。可以写多条文案(用换行或 `|` 分隔,每条都要大于 10 个汉字),脚本每次随机选一条,避免每天发同样的内容。

45
bot.js
View File

@@ -3,8 +3,8 @@ const got = require('got');
// ------------------------------------
const APP_VERSION = '11.1.95';
const APP_VERSION_REV = '1195';
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`;
@@ -192,6 +192,41 @@ const removeCookie = (cookie, name) => {
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 = [];
@@ -253,15 +288,17 @@ class SmzdmBot {
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_type',
'device_name'
].forEach(name => {
this.androidCookie = removeCookie(this.androidCookie, name);
});
checkCookie(this.androidCookie);
}
getHeaders() {
@@ -274,8 +311,6 @@ class SmzdmBot {
}
return {
Accept: '*/*',
'Accept-Language': 'zh-Hans-CN;q=1',
'Accept-Encoding': 'gzip',
'request_key': requestKey(),
'User-Agent': userAgent,

View File

@@ -191,6 +191,11 @@ class SmzdmTaskBot extends SmzdmBot {
return article.article_hash_id || article.article_id;
}
// 打开文章时 APP 会把任务 redirect 里的 hashcode 一起带上
getRedirectHashcode(task) {
return (task.task_redirect_url && task.task_redirect_url.hashcode) || '';
}
// 随机取一条评论文案,多条时用换行或 | 分隔
getCommentContent() {
const words = String(process.env.SMZDM_COMMENT || '')
@@ -438,7 +443,7 @@ class SmzdmTaskBot extends SmzdmBot {
articleId = task.task_redirect_url.link_val;
// 获取文章信息
const articleDetail = await this.getArticleDetail(articleId);
const articleDetail = await this.getArticleDetail(articleId, this.getRedirectHashcode(task));
if (articleDetail === false) {
return {
@@ -803,7 +808,7 @@ class SmzdmTaskBot extends SmzdmBot {
await this.getHaojiaDetail(article.article_id);
}
else {
await this.getArticleDetail(article.article_id);
await this.getArticleDetail(article.article_id, this.getRedirectHashcode(task));
}
await wait(8, 20);
@@ -868,7 +873,7 @@ class SmzdmTaskBot extends SmzdmBot {
await this.getHaojiaDetail(article.article_id);
}
else {
await this.getArticleDetail(article.article_id);
await this.getArticleDetail(article.article_id, this.getRedirectHashcode(task));
}
}
@@ -1337,15 +1342,12 @@ class SmzdmTaskBot extends SmzdmBot {
}
// 获取文章详情
async getArticleDetail(id) {
async getArticleDetail(id, hashcode = '') {
const { isSuccess, data, response } = await requestApi(`https://article-api.smzdm.com/article_detail/${id}`, {
headers: this.getHeaders(),
data: {
comment_flow: '',
lastest_update_time: '',
uhome: 0,
imgmode: 0,
article_channel_id: 0
hashcode,
h5hash: ''
}
});

View File

@@ -5,6 +5,7 @@ smzdm 签到脚本
cron: 10 8 * * *
*/
const crypto = require('crypto');
const Env = require('./env');
const {
SmzdmBot,
@@ -13,8 +14,7 @@ const {
getEnvCookies,
wait,
waitHuman,
CREATE_CENTER_VIEW,
IS_INSTALL_ZHANGDAMA
CREATE_CENTER_VIEW
} = require('./bot');
const notify = require('./sendNotify');
const CryptoJS = require('crypto-js');
@@ -31,6 +31,9 @@ class SmzdmCheckinBot extends SmzdmBot {
}
async run() {
// 客户端先渲染签到卡片,用户点签到后才发 /checkin
await this.getCheckinCard();
const { msg: msg1 } = await this.checkin();
const { msg: msg2 } = await this.allReward();
@@ -46,7 +49,7 @@ class SmzdmCheckinBot extends SmzdmBot {
headers: this.getHeaders(),
data: {
touchstone_event: '',
sk: this.sk || '1',
sk: this.sk,
token: this.token,
captcha: ''
}
@@ -127,7 +130,8 @@ class SmzdmCheckinBot extends SmzdmBot {
}
async extraReward() {
const isContinue = await this.isContinueCheckin();
// 连签奖励状态以签到后的卡片为准,所以这里重新拉一次
const isContinue = this.isContinueCheckin(await this.getCheckinCard());
if (!isContinue) {
const msg = '今天没有额外奖励';
@@ -167,26 +171,31 @@ class SmzdmCheckinBot extends SmzdmBot {
}
}
async isContinueCheckin() {
// 签到卡片,APP 进签到页时先渲染它
async getCheckinCard() {
const { isSuccess, data, response } = await requestApi('https://user-api.smzdm.com/checkin/show_view_v2', {
method: 'post',
headers: this.getHeaders(),
data: {
is_install_zhangdama: IS_INSTALL_ZHANGDAMA
}
headers: this.getHeaders()
});
if (isSuccess) {
// 连续签到的卡片,直接按有没有 checkin_continue 来找
const result = data.data.rows.find(item => item.cell_data && item.cell_data.checkin_continue);
return !!(result && result.cell_data.checkin_continue.continue_checkin_reward_show);
return data.data;
}
else {
$.log(`查询是否有额外奖励失败!${response}`);
$.log(`查询签到卡片失败!${response}`);
return false;
}
// 连续签到的卡片,直接按有没有 checkin_continue 来找
isContinueCheckin(card) {
if (!card || !card.rows) {
return false;
}
const result = card.rows.find(item => item.cell_data && item.cell_data.checkin_continue);
return !!(result && result.cell_data.checkin_continue.continue_checkin_reward_show);
}
async getVipInfo() {
@@ -210,27 +219,44 @@ class SmzdmCheckinBot extends SmzdmBot {
}
}
function random32() {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const SK_KEY = 'geZm53XAspb02exN';
const warned = new Set();
function warnOnce(key, message) {
if (warned.has(key)) {
return;
}
warned.add(key);
$.log(message);
}
function getUserId(cookie) {
const match = cookie.match(/smzdm_id=([^;]*)/);
return match ? match[1] : '';
}
const DEVICE_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
// 盐值一旦定下就不能改,改了等于所有账号换设备
const DEVICE_SALT = 'smzdm_script_device_v1';
// APP 的设备串是 62 字符集 32 位随机串,这里按账号确定性派生,保证跨运行固定不变
function makeDeviceId(userId) {
const digest = crypto.createHash('sha256').update(`${DEVICE_SALT}:${userId}`).digest();
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
result += DEVICE_ALPHABET.charAt(digest[i] % DEVICE_ALPHABET.length);
}
return result;
}
function getSk(cookie) {
const matchUserId = cookie.match(/smzdm_id=([^;]*)/);
if (!matchUserId) {
return ''
}
const userId = matchUserId[1];
const deviceId = getDeviceId(cookie);
const key = CryptoJS.enc.Utf8.parse('geZm53XAspb02exN');
function makeSk(userId, deviceId) {
const key = CryptoJS.enc.Utf8.parse(SK_KEY);
const cipherText = CryptoJS.DES.encrypt(userId + deviceId, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
@@ -239,14 +265,81 @@ function getSk(cookie) {
return cipherText.toString();
}
function getDeviceId(cookie) {
const matchDeviceId = cookie.match(/device_id=([^;]*)/);
if (matchDeviceId) {
return matchDeviceId[1];
// sk 是 DES(userId + 设备 id) 的 Base64,可逆;返回设备段,不属于这个账号就返回空串
function decodeSkDevice(sk, userId) {
if (!userId) {
return '';
}
return random32();
try {
const plain = CryptoJS.DES.decrypt(
{ ciphertext: CryptoJS.enc.Base64.parse(sk) },
CryptoJS.enc.Utf8.parse(SK_KEY),
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}
).toString(CryptoJS.enc.Utf8);
return plain.indexOf(userId) === 0 ? plain.slice(userId.length) : '';
}
catch (e) {
return '';
}
}
// cookie 缺 smzdm_id 时,/vip 的 data.base.user_smzdm_id 还能拿到(APP 也用它回写 sp,sk 的 userId 正是来自这里)
async function fetchUserId(cookie) {
const bot = new SmzdmBot(cookie);
const { isSuccess, data } = await requestApi('https://user-api.smzdm.com/vip', {
method: 'post',
headers: bot.getHeaders(),
data: {
token: bot.token,
create_center_view: CREATE_CENTER_VIEW
}
});
// smzdm_id 在响应信封顶层(每个响应都有),base.user_smzdm_id 作为兜底
const userId = isSuccess
? (data.smzdm_id || (data.data && data.data.base ? data.data.base.user_smzdm_id : ''))
: '';
return userId && userId != '0' ? String(userId) : '';
}
const md5 = (str) => crypto.createHash('md5').update(str).digest('hex');
function getCookieValue(cookie, name) {
const match = cookie.match(new RegExp(`(?:^|;)\\s*${name}=([^;]*)`, 'i'));
return match ? match[1] : '';
}
// 覆盖或补上 cookie 字段,值为空也要能覆盖
function upsertCookie(cookie, name, value) {
const re = new RegExp(`(^|;)\\s*${name}=[^;]*;?`, 'i');
if (re.test(cookie)) {
return cookie.replace(re, `$1${name}=${value};`);
}
return `${cookie.replace(/;?\s*$/, ';')} ${name}=${value};`;
}
// APP 的 device_rid/device_s 就是 md5(设备串),把 cookie 对齐到 sk 实际用的设备段
function alignDeviceCookie(cookie, deviceId) {
const hash = md5(deviceId);
let result = cookie;
if (getCookieValue(result, 'device_rid') !== hash) {
result = upsertCookie(result, 'device_rid', hash);
}
if (getCookieValue(result, 'device_s') !== hash) {
result = upsertCookie(result, 'device_s', hash);
}
return result;
}
!(async () => {
@@ -275,15 +368,64 @@ function getDeviceId(cookie) {
let notifyContent = '';
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
let cookie = cookies[i];
if (!cookie) {
continue;
}
let sk = sks[i];
if (!sk) {
sk = getSk(cookie)
let userId = getUserId(cookie);
let sk = (sks[i] || '').trim();
let deviceId = '';
if (!userId) {
// 少数 Cookie 只有 sess 没 smzdm_id,用 /vip 把它补回来,避免退化成 sk=1
userId = await fetchUserId(cookie);
if (userId) {
warnOnce('vip-id', 'Cookie 里没有 smzdm_id,已从 /vip 接口取到');
}
}
if (sk) {
// 用配置的真 sk,并把 cookie 对齐到它自己的设备段(同机抓的两个值本来就相等)
deviceId = decodeSkDevice(sk, userId);
if (!deviceId) {
$.log(`🟡第 ${i + 1} 个 SMZDM_SK 解不出设备段(不是有效的 sk 或不属于这个账号),签到很可能失败,请检查多账号顺序`);
}
}
else if (userId) {
// 没有真值就按账号派生一个固定设备串,sk 和 cookie 用同一个
deviceId = makeDeviceId(userId);
sk = makeSk(userId, deviceId);
warnOnce(
'auto',
'未配置 SMZDM_SK,改用按账号派生的固定设备串生成 sk;如需与真机完全一致,请在 APP 的签到请求里抓 sk 填入 SMZDM_SK'
);
}
else {
// cookie 和 /vip 都拿不到 userId,sk 拼不出来;发 1 是最后一档,至少保持参数存在
sk = '1';
warnOnce(
'empty',
'Cookie 里没有 smzdm_id,/vip 也没取到,只能发 sk=1;这种 Cookie 很可能被判定异常,建议重新从 APP 抓包'
);
}
if (deviceId) {
const aligned = alignDeviceCookie(cookie, deviceId);
if (aligned !== cookie) {
warnOnce(
'align',
'cookie 的 device_rid/device_s 与 sk 的设备段不一致,已按 sk 对齐(cookie 和 sk 可能不是同一台设备)'
);
}
cookie = aligned;
}
if (i > 0) {

View File

@@ -12,6 +12,7 @@ const {
getEnvCookies,
wait,
waitHuman,
CREATE_CENTER_VIEW,
IS_INSTALL_ZHANGDAMA
} = require('./bot');
const notify = require('./sendNotify');
@@ -135,11 +136,14 @@ class SmzdmNormalTaskBot extends SmzdmTaskBot {
if (robotToken === false) {
return {
isSuccess,
isSuccess: false,
msg: '领取任务奖励失败!'
};
}
// 客户端初始化验证码组件时就会查一次,用来说明被要求的是哪种验证码
const captchaType = await this.getCaptchaType();
const { isSuccess, data, response } = await requestApi('https://user-api.smzdm.com/task/activity_task_receive', {
method: 'post',
headers: this.getHeaders(),
@@ -163,14 +167,45 @@ class SmzdmNormalTaskBot extends SmzdmTaskBot {
msg
};
}
else {
$.log(`领取任务奖励失败!${response}`);
// 110202 = 服务端要求人机验证,空验证码一定过不了
if (data.error_code == '110202') {
const type = captchaType === 'geetest' ? '滑块' : '图形';
$.log(`🟡[${taskId}] 领取需要${type}验证码,脚本无法通过,请到 APP 内手动领取`);
return {
isSuccess,
msg: '领取任务奖励失败!'
isSuccess: false,
msg: '需要验证码'
};
}
$.log(`领取任务奖励失败!${response}`);
return {
isSuccess,
msg: '领取任务奖励失败!'
};
}
// 验证码类型:geetest 走滑块,其余走图形
async getCaptchaType() {
const { isSuccess, data, response } = await requestApi('https://user-api.smzdm.com/getcaptcha/switch', {
method: 'post',
headers: this.getHeaders(),
data: {
token: this.token,
create_center_view: CREATE_CENTER_VIEW
}
});
if (isSuccess && data.data) {
return data.data.type || '';
}
$.log(`获取验证码配置失败!${response}`);
return '';
}
}