华为云开发者成长中心签到自动化:从抓包到纯接口调用
前言
华为云开发者成长中心这个页面,我每天都会打开一次。签个到,10 积分,顺便看看任务列表里哪几项能补。
手动做这件事成本极低,两秒钟。但我真正想要的不是"签到",而是把整条链路上的所有动作都变成可以无人值守执行的接口调用。因为一旦签到能自动化,任务清单、积分流水、奖品库存这些东西就都能自动化,而它们才是真正费时间的部分。
这篇文章只讲第一块砖:签到。
一、先搞清楚签到这个动作到底发生了什么
1.1 页面入口和三个域
成长中心的入口是 developer.huaweicloud.com/grow。但页面上真正发请求的不是这个域。
打开开发者工具,切到 Network,点一次签到,你会看到请求打到了:
https://devdata.huaweicloud.com/rest/developer/fwdu/rest/developer/user/hdcommunitysoservice/v1/growth/sign
这里有第一个值得记下来的细节:路径里 rest/developer/fwdu/rest/developer/user 出现了两次。看起来像拼接 bug,但它就是实际路径,少写一个就 404。我一开始以为是复制粘贴错了,试了三遍才接受现实。
域名分三是华为云这套体系的常态:
| 域 | 干什么 |
|---|---|
developer.huaweicloud.com | 页面宿主,登录态种在这里 |
devdata.huaweicloud.com | 业务接口,数据都在这个域后面 |
auth.huaweicloud.com | 统一登录,SSO 跳转 |
这意味着一件事:不能只在 Node 里直连接口。因为 cookie 是种在 developer 域、由浏览器带着去访问 devdata 的,Node 直连时没有这套会话。所以后面所有脚本都采用同一个模式——用 Playwright 打开页面拿到登录态,然后在页面上下文里 fetch,让浏览器自己带 cookie。
1.2 签到其实是两个接口
抓包抓全一点会发现,签到不是一次请求:
GET /growth/sign → 查今天签了没 POST /growth/sign → 真的签
GET 返回的字段里关键是 sign_flag:
{ "sign_flag": 0 }
0 是没签,1 是签过了。这个设计是对的——先查后签,避免重复提交。但它在写脚本时带来一个坑:如果只看 POST 的返回,你没法知道"这次没签成功"是因为接口挂了还是因为本来就已经签过了。
所以脚本里的顺序必须是先 GET,再决定要不要 POST。
二、三个卡点
2.1 卡点一:csrf 是页面运行时生成的
所有 POST 请求都带一个 csrf 请求头。问题在于这个值不在 cookie 里,也不在 HTML 里,是页面 JS 跑起来之后才算出来的。
我试过从 localStorage 里找、从 meta 标签里找,都没有。最后用的办法最土也最有效:监听页面的真实请求,把请求头里的 csrf 抄下来。
let csrf = '';
pg.on('request', (r) => {
const h = r.headers();
const v = h['csrf'] || h['hd-csrf'] || h['x-csrf-token'];
if (v) csrf = v;
});
注意这里用了三个候选头名。不同页面用的名字不一样,全都监听一遍比较省事。
还有一个附带问题:页面加载完之后不一定马上发带 csrf 的请求。第一次跑的时候 csrf 拿到空字符串,POST 直接被拒。解决办法是再 reload 一次,等 6 秒,基本就稳了。如果还是没有,就去点一下页面上的「积分明细」,强制它发一次请求。
这段看起来很不优雅,但它是唯一稳定的办法。csrf 的设计目的就是防脚本,所以任何"读懂它"的尝试都是白费,只能"借用"。
2.2 卡点二:POST 要带 account_name
第二个坑是 POST 的 body 不是空的:
{ "account_name": "你的账号名" }
这个值要从另一个接口拿:
GET .../hdcommunityservice/v1/member/get-personal-info
→ { "memName": "xxx" }
注意服务名从 hdcommunitysoservice 变成了 hdcommunityservice——少了 so 两个字母。这种命名我是没法推理的,只能一个个试。所以如果你要复现,接口路径必须照抄,不要试图按规律推断。
2.3 卡点三:"已签到"返回的是错误码
这是最反直觉的地方。
正常思维:签到成功返回 {"code": "SUCCESS"},失败返回错误。实际不是。
重复签到时,接口返回的是一个业务错误码,但它走了 HTTP 200:
| error_code | 含义 |
|---|---|
HD.67510032 | 今天已经签过了 |
HD.67520011 | 同上,另一个分支 |
也就是说,"已经签过"在服务端眼里是一次失败的请求。如果你的脚本用 resp.ok(HTTP 200) 判断成功,它会认为签到成功了,但实际上什么都没发生。
正确的判断逻辑是看 error_code 是不是空:
const ec = r.data?.error_code;
if (ec === 'HD.67510032' || ec === 'HD.67520011') {
console.log('ℹ️ 已签过');
} else if (r.ok && !ec) {
console.log('🎉 签到请求成功');
}
写完之后我又加了一步:POST 之后再 GET 一次 sign_flag,确认真的变成 1 了。理由很简单——不要相信任何"我以为成功了"的信号,只相信状态字段。
三、把签到扩展到任务清单
签到只是 10 分。真正有价值的是任务清单,因为每日任务加起来是几十到上百分。
任务清单接口:
POST .../growth/mission/query-mission-list-by-condition
{ "page_no": 1, "page_size": 8, "category_key": "DAILY_MISSIONS" }
返回字段里的 QueryMissionDtoList 是列表。每一条里重要的有三个:
mission_name— 任务名bonus— 完成给多少积分is_finish— 完成了没
外加一个 cycle_type,取值是四种:
| cycle_type | 含义 | 能不能每天做 |
|---|---|---|
DAILY | 每日 | 能 |
WEEKLY | 每周 | 一周一次 |
MONTHLY | 每月 | 一个月一次 |
LIMIT | 限次 | 一辈子一次 |
这个字段非常关键。因为页面上不显示周期,你只看页面会以为所有任务都是每日的,然后每天重复去做那些"每月一次"的任务,白白浪费动作。
我第一版脚本就是因为没区分周期,把「发布作品」当成每日任务天天跑,结果平台一直返回「积分每日仅限一次」,而任务本身其实是 MONTHLY。这类误解只能靠读接口字段解决,光看前端看不出来。
分页也要注意:一次 page_size: 8 拿不全,得循环到返回空列表为止。我写的是最多 6 页,实际 2 页就够了。
四、登录态怎么复用
这是整套方案能不能跑起来的前提。
华为云的会话 cookie 全部是 session 类型——也就是没有 expires,关掉浏览器理论上就没。普通 Playwright 的 user-data-dir 持久化对 session cookie 不生效。
用的办法是 storageState:
// 存
const fresh = await ctx.storageState();
writeFileSync(AUTH_FILE, JSON.stringify(fresh));
// 用
const ctx = await browser.newContext({ storageState: AUTH_FILE });
但这里有个必须处理的问题:华为云是一域一套 session。developer、bbs、auth、gallery 各有自己的 cookie,直接整体覆盖会把别的域的登录态冲掉。
所以保存时要做按 (domain + path + name) 的并集合并,不能整体替换:
const key = (c) => `${c.domain}||${c.path}||${c.name}`;
const map = new Map(base.cookies.map(c => [key(c), c]));
for (const c of fresh.cookies) map.set(key(c), c);
早期版本我用 domain||name 做 key,结果 devcloud 域下同名的会话 cookie 被其它域覆盖,出现了"页面能打开但纯接口请求被判未登录"的怪现象。加上 path 之后才稳定。
五、跑起来的效果
签到那一步是这样的:
✅ 今日已签到 当前积分 3720
任务清单拉到 12 项,总积分 3720 分里已完成 4 项。剩下的每日任务里能自动化的有签到、提交建议、发布文章三项。
顺手把每日可自动化部分算了一下:
| 任务 | 积分 | 能否自动 |
|---|---|---|
| 每日签到 | 10 | ✅ |
| 提交建议 | 30 | ✅ 提交后可自动,审核不可控 |
| 每日发布文章 | 60 | ✅ 发布可自动,过审不可控 |
| 每日回答被采纳 | 60 | ⚠️ 能发回答,采纳权在别人 |
日上限大约 160 分,其中真正"发出去就算"的是 100 分。
做了什么
- 抓到了签到的两个真实接口,搞清楚了
sign_flag的语义 - 解决了 csrf 拿不到的问题——监听页面请求抄下来
- 发现"已签到"走的是 HTTP 200 + 业务错误码,不能用
resp.ok判断 - 拿到任务清单接口,并用
cycle_type区分每日/每周/每月/限次 - 用 storageState 做登录态复用,按 domain+path+name 合并避免跨域覆盖
一些体会
"看起来简单"的自动化,难点从来不在主流程。 签到这个动作,点一下就是一次 POST,但真正让脚本能稳定跑起来的是 csrf、错误码语义、登录态合并这些边角。
不要用返回值判断成功,要用状态字段。 这是我这次最大的收获。HTTP 200 只说明服务器收到了请求,不说明业务成功;error_code 为空也不说明落库了。最可靠的做法永远是:改完之后再查一次状态。
接口路径不要推理,只能照抄。 hdcommunitysoservice 和 hdcommunityservice 这种差异,以及路径里重复的 rest/developer,都不存在任何"规律"可循。
周期字段比任务名更重要。 页面不显示周期,但接口给。读字段能省掉大量无用功。
代码
完整的签到部分,grow.mjs 的核心逻辑:
const BASE = 'https://devdata.huaweicloud.com/rest/developer/fwdu/rest/developer/user/hdcommunitysoservice/v1/growth';
// 1. 先查
const s1 = await api(pg, '/sign', 'GET');
if (s1.data.sign_flag !== 1) {
// 2. 拿账号名
const pi = await pg.evaluate(async () => {
const r = await fetch('https://devdata.huaweicloud.com/rest/developer/fwdu/rest/developer/user/hdcommunityservice/v1/member/get-personal-info', { credentials: 'include' });
const j = await r.json();
return j.memName || '';
});
// 3. 再签
const r = await api(pg, '/sign', 'POST', { account_name: pi });
const ec = r.data?.error_code;
if (ec === 'HD.67510032' || ec === 'HD.67520011') {
console.log('ℹ️ 已签过');
} else if (r.ok && !ec) {
console.log('🎉 签到请求成功');
}
// 4. 复查状态,不信返回值
const s2 = await api(pg, '/sign', 'GET');
console.log('复查 sign_flag =', s2.data.sign_flag);
}
页面上下文 fetch 的封装:
const api = (pg, path, method, body) => pg.evaluate(async ({ base, path, method, body, csrf }) => {
const H = {
'Content-Type': 'application/json',
'Referer': 'https://developer.huaweicloud.com/grow',
'Origin': 'https://developer.huaweicloud.com'
};
if (csrf) H['csrf'] = csrf;
const opt = { method, credentials: 'include', headers: H };
if (method === 'POST') opt.body = JSON.stringify(body || {});
const resp = await fetch(base + path, opt);
const t = await resp.text();
try { return { ok: true, status: resp.status, data: JSON.parse(t) }; }
catch { return { ok: false, status: resp.status, raw: t.substring(0, 300) }; }
}, { base: BASE, path, method, body, csrf });
credentials: 'include' 是这行的灵魂——没有它,cookie 不会带上,再正确的路径也只会返回登录页 HTML。
- 点赞
- 收藏
- 关注作者
评论(0)