背景
B 站「硬核会员」答题分为两个阶段:
| 阶段 | 题数 | 通过条件 |
|---|---|---|
| 挑战转正(Lv0 → 正式会员) | 60 题 | 全部答对 |
| 硬核会员 Lv1 | 100 题 | 答对 ≥ 60 |
| 硬核会员 Lv2 | 100 题 | 答对 ≥ 80 |
接口逆向
答题体系使用 REST + gRPC 混合架构:
GET /x/answer/v4/guide
→ 获取题目列表、answer_token、answer_id、当前进度
gRPC bilibili.main.community.reply.v1.ReplyMoss/AnswerQuestion
→ 提交单道题答案(Protobuf 格式)
获取题目
from bilibili.sign import sign_app, APP_APPKEY, APP_SECRET
def get_guide(access_key: str) -> dict:
params = sign_app({
'access_key': access_key,
'quiz_type': 'senior', # 'challenge' 或 'senior'
'lang': 'hans',
}, APP_APPKEY, APP_SECRET)
r = requests.get(
'https://api.bilibili.com/x/answer/v4/guide',
params=params
)
data = r.json()['data']
# data 包含 answer_token, answer_id, questions[]
return data
gRPC 提交答案(需 Frida session)
答题提交接口使用 gRPC/Protobuf,通过 Frida hook ReplyMoss 类实现:
def submit_answer_frida(session, question_id: int, option_id: int, answer_token: str):
script = session.create_script("""
rpc.exports = {
submitAnswer: function(questionId, optionId, token) {
return new Promise(function(resolve) {
Java.perform(function() {
var ReplyMoss = Java.use(
'bilibili.main.community.reply.v1.ReplyMossGrpc$ReplyMossBlockingStub'
);
// 构造 AnswerQuestionReq proto
var req = buildAnswerReq(questionId, optionId, token);
var resp = stub.answerQuestion(req);
resolve(resp.getCode());
});
});
}
};
""")
script.load()
return script.exports_sync.submit_answer(question_id, option_id, answer_token)
全自动答题(题库缓存)
import json, os
CACHE_FILE = 'answer_cache.json'
def load_cache() -> dict:
if os.path.exists(CACHE_FILE):
return json.load(open(CACHE_FILE))
return {}
def auto_quiz(access_key, frida_session):
cache = load_cache()
guide = get_guide(access_key)
answer_token = guide['answer_token']
correct = 0
for q in guide['questions']:
qid = str(q['question_id'])
# 优先查缓存
if qid in cache:
option_id = cache[qid]
else:
# 调用 LLM 或题库查询
option_id = query_answer_db(q['question'], q['options'])
cache[qid] = option_id
code = submit_answer_frida(frida_session, int(qid), option_id, answer_token)
if code == 0:
correct += 1
print(f'题 {qid}: {"正确" if code == 0 else "错误"} ({correct}/{len(guide["questions"])})')
json.dump(cache, open(CACHE_FILE, 'w'), ensure_ascii=False, indent=2)
return correct
题型判断
def detect_quiz_type(guide: dict) -> str:
count = guide.get('answer_count', 0) or len(guide.get('questions', []))
return 'challenge' if count <= 60 else 'senior'
注意事项
- gRPC 接口需要
x-bili-ticket票据(见上一篇文章) answer_token是一次性的,答题中途不能重启- 挑战转正必须全对,一题错误即失败重来
- 题库有时效性,建议本地缓存 question_id → option_id 映射