# 得卡 DECA App 逆向分析文档 > 目标:采集得卡「商家展位列表 / 在售商品列表」,供业务侧统计 **新增 / 商家账号 / 商品售卖进度**。 > 核心难点:所有接口带 `signature` 请求头签名校验,部分接口需 JWT 登录态。 > 结论:signature 已 100% 还原(无加壳、纯 Java),登录+续签闭环打通,可长期无人值守。 > 日期:2026/08/02 | Python 3.12.10 > **⚠️ 2026/09/05 接口变更**:站方把**免 token 的全站在售 `home/search` 砍成「首屏推荐」**(只返前 20 条、`page≥2` 空、`pageSize>20` 报 `10001`),`merchant/exhibition/list` 商家列表也**改为需 token 且只返首屏 30 家**。 > 影响:原「空 query 翻页拿全量在售」失效,**在售全量只能靠逐商家 `groupbuy/merchant/on-sale-list`(需 token)枚举**(配合库内已存商家 ID);`groupbuy/detail` 详情仍免 token。 > 据此改为**一机一账号**部署:购买记录(服务器 B,账号 `18040545025`)、提醒+在售报告(电脑 C,账号 `18086100242`)、已售+随机团(电脑 A,主号不动)。`token.json` 现支持内嵌 `phone`/`password`/`countryCode`,密码登录兜底按本机账号走、互不顶号。详见根 `README.md` 第一节与项目记忆。 --- ## 1. 目标信息 - **App**:得卡 DECA `得卡DECA_1.0.0.apk`(appVersion 1.0.0) - **接口域名**:`https://api.decalive.com` - **网络栈**:OkHttp 5.3.2(`User-Agent: okhttp/5.3.2`) - **鉴权/加密措施**: - 每个请求都带 `signature`(32 位十六进制,双重 MD5)+ `currentTime`(13 位毫秒时间戳)。 - 部分接口需 `Authorization: Bearer `,access token 有效期 **15 分钟**。 - **加固情况**:**无加壳**、无字符串加密、签名不依赖 Native(so);混淆为中等(类名字母混淆,salt 明文硬编码)。 ### 涉及接口 | 用途 | 方法 & 路径 | 是否需登录 | 请求体 | |---|---|---|---| | 商家展位列表 | `POST /api/v1/app/merchant/exhibition/list` | **是**(2026/09/05 起需 token,且只返首屏 30 家) | `{"page":N}` | | 在售商品列表 | `POST /api/v1/app/groupbuy/merchant/on-sale-list` | **是**(在售全量唯一入口) | `{"merchantUserId","page","pageSize":20}` | | 全站在售(首屏) | `POST /api/v1/app/home/search` | 否(**2026/09/05 起被砍成首屏 20、不可翻页**,已弃用于全量采集) | `{"query":"","saleStatus":0,"page","pageSize":20,...}` | | 密码登录 | `POST /api/v1/app/auth/password/login` | 否 | `{"countryCode","password","phone"}` | | Token 续签 | `POST /api/v1/app/auth/token/refresh` | 否 | `{"refreshToken"}` | --- ## 2. 分析过程 1. **抓包**:得到 3 个业务请求 + 1 个登录请求的 curl(含 4 组真实 `signature`/`currentTime`/`body` 样本,用于最终验证)。 2. **反编译**:jadx 1.5.3 反编译 APK(`--no-res`),产物 16044 个 java 文件,`classes.dex`/`classes2.dex` 均为真实业务码,无壳。 3. **定位签名**:从头部装配器 `mi/w.java`(组装全部公共头 + `signature`)顺藤摸瓜到签名计算核心 `mf/c0.java`。 ### 关键类 | 类 / 方法 | 作用 | |---|---| | `mi.w.f(Context, url)` | 头部装配器,组装公共头 + signature | | `mf.c0.a(Map)` | **签名计算核心**:生成 currentTime + 双重 MD5 | | `mf.c0.b(String)` | MD5 → 小写 hex | | `mf.b0` | 每个参数 entry 渲染为 `key`+`value`(无分隔符)的 lambda | | `eg.p` | Retrofit 认证接口(login / sms / wechat / aliyun / token refresh) | --- ## 3. 逆向思路 - **障碍**:所有接口非签名不可用;商品接口还要登录态,且 token 15 分钟即过期,无法直接长期跑。 - **突破**: 1. 在 `mf/c0.a()` 读出签名逻辑——参数排序 + 双重 MD5 + 固定 salt,salt 是明文常量。 2. 用抓包的 4 个样本反推确认拼接细节(key 升序、key 直接拼 value、无 `=` 无分隔符、只取时间戳后 6 位)。 3. 登录接口样本(无 Authorization)也能仅凭 body 算出一致签名 → 证明签名不依赖 token,可脚本自动登录。 4. 在 `eg/*.java` 发现 `token/refresh` + refreshToken 存储 → 续签闭环成立。 --- ## 4. 算法要点 ### 4.1 signature 生成规则(已用 4 样本验证一致) ``` plain = 参数按 key 升序,每项 "key"+"value" 直接拼接(无分隔符),剔除 value 为 null 的项 inner = md5(plain) # 32 位小写 hex signature = md5( inner + currentTime[-6:] + "biu_card_nbclass" ) ``` - 哈希:**MD5**,UTF-8,输出**小写** hex,**双重** MD5。 - **固定 salt**(硬编码于 `mf/c0.java`):`biu_card_nbclass` - `currentTime`:13 位毫秒时间戳,**同一个值**同时放进 `currentTime` 头;签名只用其**后 6 位**。 - 空 body:`plain=""` → `inner=md5("")`,无特殊分支。 - **Authorization / token 不参与**签名。 ### 4.2 Python 复现 ```python import hashlib, time SALT = "biu_card_nbclass" def md5(s): return hashlib.md5(s.encode("utf-8")).hexdigest() def make_signature(params: dict, current_time: str) -> str: clean = {k: v for k, v in params.items() if v is not None} plain = "".join(f"{k}{clean[k]}" for k in sorted(clean)) return md5(md5(plain) + current_time[-6:] + SALT) # current_time = str(int(time.time()*1000)),同一值放进 currentTime 头 ``` ### 4.3 验证样本(4/4 全中) | plain | currentTime | signature | |---|---|---| | `page1` | 1785662750434 | c0670b1219e885fcca8f81631f9f6731 | | `page2` | 1785662751198 | 0dea97b71395f132a7f09a2fcea432f9 | | `merchantUserId593731690page1pageSize20` | 1785664667848 | 5416ca70361e645f75eaf3e960057b53 | | `countryCode86passwordpass2022phone19521500850` | 1785665137653 | 81464e9bf4285adf61e314228c3ae19f | ### 4.4 登录 / Token - `password/login` 响应 `data`:`accessToken` / `refreshToken` / `expiresIn`(秒,900=15分钟) / `userId` 等。 - 续签:`token/refresh` body `{"refreshToken":...}`,返回同结构新 token。 - 脚本策略:access 剩余 <60s 先续签,续签失败回退重新登录(登录已验证,绝对兜底)。 --- ## 5. 踩坑记录 1. **body 必须紧凑 JSON**:抓包 `Content-Length:10` 对应 `{"page":1}`(无空格)。发送用 `json.dumps(..., separators=(",",":"))`,否则 Content-Length 与实际不符。签名本身只用参数串,与 body 序列化格式无关。 2. **Windows 控制台 GBK**:脚本打印 emoji/部分字符会 `UnicodeEncodeError`。运行加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`,日志文件本身写 UTF-8 无碍。 3. **token 15 分钟过期**:一轮采集可能超 15 分钟,必须在请求前动态检查并续签,不能只登录一次。 4. **数据库握手被拒(2013)**:内网库 `100.64.0.25` 需网络/白名单就绪,TCP 通但 MySQL 握手断时,先排查 VPN/授权白名单,非代码问题。 5. **jadx 插件 NPE**:为旧版本编译的 `jadx-ai-mcp` 插件会导致 jadx 启动崩溃,反编译前需临时清空插件配置。 6. **很多商家 `activeGroupbuyCount=0`**:即当前无在售,属正常;采商品时对每个商家都会发一次接口确认。 7. **微信自动发送库 `wxauto` 已下架**:PyPI 上原 `wxauto` 已被作者移除(`pip install wxauto` 报 `from versions: none`),现改推 `wxauto4`(对应微信 4.0 客户端,免费)/ `wxautox4`(付费 plus)。安装 `pip install wxauto4`;import 名即 `wxauto4`,用 `WeChat().SendFiles(filepath, who, exact)` 发文件。若 PC 微信仍是 3.9 旧版,则改用 `wxautox`。发送依赖微信窗口可见的 UI 自动化,无人值守时需保证微信一直登录、窗口不最小化。 --- ## 6. 举一反三 - **同类 App 签名套路**:`sign = hash( 排序参数串 + 时间戳(部分位) + 固定 salt )` 是移动端最常见范式。定位思路统一:先找组装请求头的拦截器/装配器,再找里面调用的 hash 工具方法,salt 常为明文常量。 - **是否 URL 编码**:`mf.b0.a` 声明抛 `UnsupportedEncodingException`,暗示内部可能对 value 做 `URLEncoder.encode(v,"UTF-8")`(空格→`+`)。当前样本均无特殊字符无法区分;若日后遇到搜索关键词等含空格/中文的参数,需对 value 先编码再拼。 - **嵌套/数组参数**:`c0` 会把嵌套对象递归转 TreeMap、数组保序后按 `toString` 拼接。当前接口均为扁平 body,遇到嵌套 body 时需另取样本校准渲染格式。 - **长期无人值守要点**:签名 + 自动登录 + 续签三者齐备才算闭环;只解签名不解 token 续签,跑不过 15 分钟。 --- ## 附:项目文件 | 文件 | 说明 | |---|---| | `deca_daily_spider.py` | 常驻每日爬虫(登录→商家→在售商品+每日快照;采完自动出图并发微信) | | `deca_report.py` | 统计报表出图(KPI + 今日新增商家 + 各商家在售/售卖进度),生成 `reports/` 下 PNG,并按开关自动发微信 | | `deca_wechat.py` | 通过 `wxauto4` 把图片发到 PC 微信好友/群 | | `schema.sql` | 三张表 DDL:`deca_shop_record` / `deca_product_record` / `deca_product_daily_record` | | `stats.sql` | 统计查询 SQL(新增 / 商家账号 / 售卖进度趋势) | | `init_db.py` | 一键建表 | | `requirements.txt` | 依赖清单(版本实测) | **部署**: 1. 装依赖:`pip install -r requirements.txt`(`wxauto4` 若受 `requires-python` 限制加 `--ignore-requires-python`);本地公共库 `charley-utils` 需 editable 安装(见 requirements 尾注)。 2. 建表:`python init_db.py` 3. 常驻运行:`python deca_daily_spider.py`(每天 09:00 / 15:00 及启动各采集一轮,采完自动生成报表并发微信)。 **报表与微信推送**: - 报表图输出到 `reports/deca_report_YYYYMMDD_HHMMSS.png`,命名到秒,一天多张互不覆盖。 - 微信发送依赖 **PC 微信 4.0 已登录且窗口可见**;目标好友备注名须精确等于 `deca_report.WECHAT_TO`(默认 `backup`)。 - 手动补发:`python deca_wechat.py "reports/xxx.png" backup`。 - 关闭自动发送:把 `deca_report.SEND_WECHAT` 置为 `False`。 **登录与 token(避开验证码)**: - 密码登录 `password/login` 受阿里云验证码2.0 风控,脚本无法自动过验证码;改走 `token/refresh` 续期(**无验证码**)。 - 首次:在 APP 正常登录后抓包,把 `refreshToken` 粘入运行目录 `token.json`。**2026/09/05 起推荐直接写本机账号凭据**,程序首次会自动密码登录补 `access`/`refresh`:`{"phone":"18040545025","password":"147258369@ww","countryCode":"86"}`(`deca_sold_core.load_token/save_token/login` 会读取并保留这些字段,兜底登录按本机账号走、便于人工分辨该机用哪个号)。旧格式 `{"access":null,"refresh":"<粘这里>","exp":0}` 仍兼容。 - 之后爬虫用它自动续期并滚动写回 `token.json`;refreshToken 失效(过期/被踢)时日志会报警,需重新抓包更新。 - 商品**上架时间/结束时间**来自详情接口 `POST api/v1/app/groupbuy/detail`(body `{"code":商品code}`,需 token),**仅对今日新增商品拉取**,存入 `deca_product_record` 的 `publish_at`/`sale_start_at`/`sale_end_at`。商家入驻时间接口无,新增按入库时间判定。