用GDScript实现扫码登陆Bilibili获取到SESSDATA
Godot 使用 GDScript 实现 Bilibili 扫码登录并获取 SESSDATA 本文档提供 Godot 4.x 环境下,通过 Bilibili 官方接口实现二维码扫码登录的完整方案。相比旧版实现,新版增加了 Cookie 多字段保存、用户头像预览、延迟关闭窗口等优化,同时附带了 WBI 签名工具函数供其他 API 调用参考。 功能概述 生成 Bilibili 登录二维码并显示在自定义窗口内 定时轮询扫码状态,检测用户是否扫码确认 扫码成功后自动交换得到 SESSDATA、bili_jct、DedeUserID 等关键 Cookie 显示用户头像(扫码后自动加载)并延迟关闭登录窗口 提供回调接口,将登录结果(成功/失败)通知调用方 附带 WBI 签名 函数,用于后续需要签名的 Bilibili API 请求 完整代码 (GDScript) 将以下代码放入某个继承自 Node(如 Control 或 Window)的脚本中即可。 extends Node # ---------- 扫码登录相关变量 ---------- var qr_window: Window = null var _poll_timer: Timer var _close_delay_timer: Timer var on_qr_login_result: Callable # ---------- WBI 签名相关常量 ---------- const MIXIN_KEY_ENC_TAB = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 52, 34, 44 ] var _wbi_key_cache = { "img_key": "", "sub_key": "", "cached_time": 0 } # ---------- 对外接口 ---------- ## 开始扫码登录流程 ## login_callback: Callable 登录结束后的回调,参数 (success: bool) func start_qr_login(login_callback: Callable) -> void: on_qr_login_result = login_callback var http = HTTPRequest.new() add_child(http) http.request_completed.connect(_on_qr_generated) var err = http.request( "https://passport.bilibili.com/x/passport-login/web/qrcode/generate", PackedStringArray(), HTTPClient.METHOD_GET ) if err != OK: push_error("生成二维码请求失败: ", err) # ---------- 二维码生成与展示 ---------- func _on_qr_generated(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if response_code != 200: return var json = JSON.new() if json.parse(body.get_string_from_utf8()) != OK: return var data = json.get_data()["data"] var url = data["url"] var qrcode_key = data["qrcode_key"] _display_qrcode(url) _poll_login_status(qrcode_key) func _display_qrcode(content: String) -> void: # 假设你已经创建好了场景 res://Scene/Log_in.tscn,其中包含一个名为 QRImage 的 TextureRect 节点 qr_window = preload("res://Scene/Log_in.tscn").instantiate() qr_window.close_requested.connect(_on_qr_window_closed) add_child(qr_window) var encoded = content.uri_encode() var qr_api = "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=" + encoded var img_request = HTTPRequest.new() add_child(img_request) img_request.request_completed.connect(func(_r, _c, _h, body): if not is_instance_valid(qr_window): return var img = Image.new() if img.load_png_from_buffer(body) == OK: var tex = ImageTexture.create_from_image(img) qr_window.get_node("QRImage").texture = tex else: push_error("二维码图片加载失败") ) img_request.request(qr_api, PackedStringArray(), HTTPClient.METHOD_GET) # ---------- 轮询扫码状态 ---------- func _poll_login_status(qrcode_key: String) -> void: _poll_timer = Timer.new() _poll_timer.wait_time = 2.0 _poll_timer.autostart = true _poll_timer.timeout.connect(_check_qr_status.bind(qrcode_key)) add_child(_poll_timer) func _check_qr_status(qrcode_key: String) -> void: var http = HTTPRequest.new() add_child(http) http.request_completed.connect(func(result, response_code, headers, body): if response_code != 200: return var json = JSON.new() if json.parse(body.get_string_from_utf8()) != OK: return var data = json.get_data()["data"] var code = data["code"] if code == 0: # 用户已扫码并确认登录 if _poll_timer: _poll_timer.stop() _exchange_cookie(data["url"]) elif code == 86038: # 二维码已失效 if on_qr_login_result: on_qr_login_result.call(false) _close_qr_window() # 其他 code 继续轮询 ) http.request( "https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=" + qrcode_key, PackedStringArray(), HTTPClient.METHOD_GET ) # ---------- 换取 Cookie ---------- ## 注意:这里需要实现 get_or_generate_buvid() 函数来维护 buvid3 func _exchange_cookie(login_url: String) -> void: var http = HTTPRequest.new() add_child(http) http.max_redirects = 0 # 禁止自动重定向,以便直接获取 Set-Cookie var buvid3 = get_or_generate_buvid() var cookie_str = "buvid3=" + buvid3 + "; b_nut=" + str(Time.get_unix_time_from_system()) var headers = PackedStringArray([ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer: https://www.bilibili.com", "Cookie: " + cookie_str ]) http.request_completed.connect(func(result, response_code, resp_headers, body): # 遍历响应头,提取多个 Set-Cookie for header in resp_headers: if header.begins_with("Set-Cookie: "): var cookie_part = header.trim_prefix("Set-Cookie: ") var parts = cookie_part.split(";") if parts.size() > 0: var kv = parts[0].strip_edges() var eq_pos = kv.find("=") if eq_pos != -1: var key = kv.substr(0, eq_pos) var value = kv.substr(eq_pos + 1) match key: "SESSDATA": GdScriptFunc.set_data("AccountData", "SESSDATA", value) "bili_jct": GdScriptFunc.set_data("AccountData", "bili_jct", value) "DedeUserID": GdScriptFunc.set_data("AccountData", "DedeUserID", value) "DedeUserID__ckMd5": GdScriptFunc.set_data("AccountData", "DedeUserID__ckMd5", value) "sid": GdScriptFunc.set_data("AccountData", "sid", value) print("登录 Cookie 已保存") # 保存完 Cookie 后,加载用户头像并延迟关闭窗口 _load_avatar_and_delayed_close() ) var err = http.request(login_url, headers, HTTPClient.METHOD_GET) if err != OK: push_error("换取 Cookie 请求失败: ", err) _close_qr_window() if on_qr_login_result: on_qr_login_result.call(false) # ---------- 头像加载与延迟关闭 ---------- func _load_avatar_and_delayed_close() -> void: fetch_user_avatar(func(texture: ImageTexture): if is_instance_valid(qr_window) and texture != null: qr_window.get_node("QRImage").texture = texture _start_delayed_close() ) func _start_delayed_close() -> void: if not is_instance_valid(qr_window): return _close_delay_timer = Timer.new() _close_delay_timer.wait_time = 0.5 _close_delay_timer.one_shot = true _close_delay_timer.timeout.connect(_on_delayed_close_timeout) add_child(_close_delay_timer) _close_delay_timer.start() func _on_delayed_close_timeout() -> void: if on_qr_login_result: on_qr_login_result.call(true) # 登录成功 _close_qr_window() # ---------- 获取用户头像 ---------- ## 需要已经保存了 SESSDATA 等 Cookie func fetch_user_avatar(callback: Callable) -> void: var sessdata = GdScriptFunc.get_data("AccountData", "SESSDATA") if sessdata == null or sessdata == "": callback.call(null) return var cookie_str = "SESSDATA=" + sessdata var bili_jct = GdScriptFunc.get_data("AccountData", "bili_jct") if bili_jct != null: cookie_str += "; bili_jct=" + bili_jct var dedeuserid = GdScriptFunc.get_data("AccountData", "DedeUserID") if dedeuserid != null: cookie_str += "; DedeUserID=" + dedeuserid var nav_headers = PackedStringArray([ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer: https://www.bilibili.com", "Cookie: " + cookie_str ]) var http_nav = HTTPRequest.new() add_child(http_nav) http_nav.request_completed.connect(func(result, response_code, headers, body): http_nav.queue_free() if response_code != 200: callback.call(null) return var json = JSON.new() if json.parse(body.get_string_from_utf8()) != OK: callback.call(null) return var data = json.get_data() var face_url = data.get("data", {}).get("face", "") if face_url == "": callback.call(null) return var img_request = HTTPRequest.new() add_child(img_request) img_request.request_completed.connect(func(_r, _c, _h, img_body): img_request.queue_free() var img = Image.new() if img.load_png_from_buffer(img_body) == OK or img.load_jpg_from_buffer(img_body) == OK: var tex = ImageTexture.create_from_image(img) callback.call(tex) else: callback.call(null) ) img_request.request(face_url, PackedStringArray(), HTTPClient.METHOD_GET) ) http_nav.request("https://api.bilibili.com/x/web-interface/nav", nav_headers, HTTPClient.METHOD_GET) # ---------- 窗口清理 ---------- func _on_qr_window_closed() -> void: _close_qr_window() if on_qr_login_result: on_qr_login_result.call(false) # 用户主动关闭,视为取消登录 func _close_qr_window() -> void: if qr_window: qr_window.queue_free() qr_window = null if _poll_timer: _poll_timer.stop() _poll_timer.queue_free() _poll_timer = null if _close_delay_timer: _close_delay_timer.stop() _close_delay_timer.queue_free() _close_delay_timer = null # ---------- WBI 签名工具(供其他 API 使用)---------- func _get_wbi_key() -> Dictionary: var now: int = Time.get_unix_time_from_system() if now - _wbi_key_cache.get("cached_time", 0) < 1800 and not _wbi_key_cache.get("img_key", "").is_empty(): return _wbi_key_cache var http := HTTPRequest.new() add_child(http) var headers: PackedStringArray = [ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ] var error := http.request("https://api.bilibili.com/x/web-interface/nav", headers, HTTPClient.METHOD_GET) if error != OK: push_error("获取 WBI 密钥请求失败: ", error) http.queue_free() return _wbi_key_cache var result: Array = await http.request_completed http.queue_free() var response_code: int = result[1] var body_str: String = (result[3] as PackedByteArray).get_string_from_utf8() if response_code != 200: push_error("WBI 密钥接口 HTTP ", response_code) return _wbi_key_cache var json := JSON.new() if json.parse(body_str) != OK: push_error("WBI 密钥 JSON 解析失败") return _wbi_key_cache var root: Dictionary = json.get_data() var data: Dictionary = root.get("data", {}) var wbi_img: Dictionary = data.get("wbi_img", {}) var img_key: String = GdScriptFunc.extract_key_from_url(wbi_img.get("img_url", "")) var sub_key: String = GdScriptFunc.extract_key_from_url(wbi_img.get("sub_url", "")) if img_key.is_empty() or sub_key.is_empty(): push_error("WBI 密钥提取失败") return _wbi_key_cache _wbi_key_cache["img_key"] = img_key _wbi_key_cache["sub_key"] = sub_key _wbi_key_cache["cached_time"] = now return _wbi_key_cache func _sign_wbi_url(url: String) -> String: var key_data: Dictionary = await _get_wbi_key() var img_key: String = key_data.get("img_key", "") var sub_key: String = key_data.get("sub_key", "") if img_key.is_empty() or sub_key.is_empty(): push_error("WBI 密钥不完整,无法签名 URL") return url var combined_key: String = img_key + sub_key if combined_key.length() < 64: push_error("WBI 密钥总长度不足 64,实际长度:%d" % combined_key.length()) return url var wbi_key: String = "" for idx: int in MIXIN_KEY_ENC_TAB: wbi_key += combined_key[idx] var uri: String = url.replace("https://api.bilibili.com", "") var query_split: PackedStringArray = uri.split("?", false, 1) var query_string: String = query_split[1] if query_split.size() > 1 else "" var params: Dictionary = {} for param: String in query_string.split("&"): var kv: PackedStringArray = param.split("=") if kv.size() == 2: params[kv[0]] = kv[1] var wts: String = str(Time.get_unix_time_from_system()) params["wts"] = wts var sorted_params: Array[String] = [] for key: String in params: sorted_params.append(key) sorted_params.sort() var sorted_query: String = "" for key: String in sorted_params: if not sorted_query.is_empty(): sorted_query += "&" sorted_query += key + "=" + params[key] var sign_str: String = sorted_query + wbi_key var w_rid: String = sign_str.md5_text() return url + "&w_rid=" + w_rid + "&wts=" + wts # ---------- 辅助函数(需要根据你的项目实现)---------- ## 生成或获取 buvid3,可存储在 user:// 文件中 func get_or_generate_buvid() -> String: # 示例:从文件中读取,若无则生成一个随机值并保存 # 具体实现略,可参考 Bilibili 第三方客户端常见做法 return "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # 占位 核心流程详解 1. 生成二维码 调用 start_qr_login(callback) → 请求 https://passport.bilibili.com/x/passport-login/web/qrcode/generate 解析返回的 data.url(登录确认链接)和 data.qrcode_key(轮询凭证) 使用第三方 API(api.qrserver.com)将 URL 生成 PNG 二维码图片,显示在 qr_window 的 QRImage 节点上 2. 轮询扫码状态 新建 Timer 每 2 秒请求 /x/passport-login/web/qrcode/poll?qrcode_key=xxx 根据返回的 code 决定行为: 0:用户已扫码并确认 → 进入 Cookie 交换 86038:二维码过期 → 关闭窗口,回调失败 其他:继续轮询 3. 换取登录凭证 使用登录确认 URL(data["url"])发起 GET 请求,禁止自动重定向(max_redirects = 0) 从响应头中提取多个 Set-Cookie 字段,分别保存 SESSDATA、bili_jct、DedeUserID 等 调用 GdScriptFunc.set_data(自定义持久化函数)存储到本地 4. 用户头像预览与窗口关闭 登录成功后,调用 fetch_user_avatar 通过 /x/web-interface/nav 获取头像 URL 并下载 将二维码区域替换为头像(增强用户体验) 延迟 0.5 秒后关闭登录窗口,并回调 success = true 5. 清理资源 用户主动关闭窗口或登录失败时,停止所有定时器并销毁窗口,回调 success = false 新增/优化功能说明 功能点 说明 多 Cookie 保存 不仅保存 SESSDATA,还保存 bili_jct、DedeUserID、sid 等,方便后续 API 调用(如投币、点赞) buvid3 模拟 在换取 Cookie 时携带 buvid3 和 b_nut,提高请求通过率,减少风控拦截 头像加载 登录后自动下载用户头像并显示在窗口中,提供视觉反馈 延迟关闭 给用户 0.5 秒看到头像变化,避免窗口立即消失带来的突兀感 WBI 签名工具 附带 _get_wbi_key() 和 _sign_wbi_url(),可用于其他需要签名的 Bilibili API(如动态发布、视频信息获取) 依赖说明 外部 API ...