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
- 二维码生成使用
api.qrserver.com,你也可以替换为本地生成库(如QRCode插件)
- 二维码生成使用
自定义全局函数
GdScriptFunc.set_data(key, subkey, value)和GdScriptFunc.get_data(key, subkey)
需要你自行实现数据持久化(例如使用ConfigFile或FileAccess)GdScriptFunc.extract_key_from_url(url)
从类似https://i0.hdslb.com/bfs/wbi/7a9b1d1f3c5e6a8b.png的 URL 中提取/wbi/后面的文件名(不含扩展名)
UI 场景
res://Scene/Log_in.tscn必须包含一个名为QRImage的TextureRect节点
调用示例
# 在某个按钮的点击事件中
func _on_login_button_pressed():
start_qr_login(func(success: bool):
if success:
print("登录成功!SESSDATA 等已保存")
# 可以关闭登录界面,跳转到主页等
else:
print("登录失败或用户取消")
)
注意事项
- 网络权限:Godot 导出时需要勾选
Internet权限(Android/HTML5) - 线程安全:所有 HTTP 请求和回调都在主线程执行,无需额外处理
- WBI 签名:扫码登录流程本身不需要签名,但如果你在登录后调用需要签名的接口,可以使用提供的
_sign_wbi_url函数 - buvid3 持久化:建议将生成的 buvid3 保存在
user://目录下,避免每次启动都变化 - Cookie 有效期:
SESSDATA的有效期通常为数周,过期后需要重新扫码登录
扩展建议
- 可将
GdScriptFunc实现为一个全局单例(AutoLoad),方便各脚本调用数据存取 - 二维码窗口可以增加“刷新”按钮,以便在二维码过期时重新生成
- 轮询失败时增加重试机制和指数退避策略
通过以上代码和说明,你可以在 Godot 项目中快速集成 Bilibili 扫码登录功能,并为后续需要鉴权的 API 调用做好准备。