背景

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 映射