From 0b71d792b84a8550e4b729053fe0da06838efc2d Mon Sep 17 00:00:00 2001 From: Hex Date: Thu, 22 Oct 2026 01:20:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(smzdm):=20=F0=9F=9A=80=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A3=8E=E6=8E=A7=E5=AF=B9=E6=8A=97=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E7=AD=BE=E5=88=B0/=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **风控对抗增强**: * 更新 `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` 的使用逻辑、设备字段缺失的影响以及如何正确抓包。 --- README.md | 4 +- bot.js | 45 ++++++++-- library_task.js | 20 +++-- smzdm_checkin.js | 218 ++++++++++++++++++++++++++++++++++++++--------- smzdm_task.js | 45 ++++++++-- 5 files changed, 273 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 7ff99a8..87af9d8 100644 --- a/README.md +++ b/README.md @@ -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 个汉字),脚本每次随机选一条,避免每天发同样的内容。 diff --git a/bot.js b/bot.js index 5629595..0a4ceba 100644 --- a/bot.js +++ b/bot.js @@ -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, diff --git a/library_task.js b/library_task.js index 62661d1..9772fae 100644 --- a/library_task.js +++ b/library_task.js @@ -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: '' } }); diff --git a/smzdm_checkin.js b/smzdm_checkin.js index 8cff56e..64e4f66 100644 --- a/smzdm_checkin.js +++ b/smzdm_checkin.js @@ -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) { diff --git a/smzdm_task.js b/smzdm_task.js index d809cb7..bf1ef98 100644 --- a/smzdm_task.js +++ b/smzdm_task.js @@ -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 ''; } }