Godot 使用 GDScript 实现 Bilibili 扫码登录并获取 SESSDATA

本文档提供 Godot 4.x 环境下,通过 Bilibili 官方接口实现二维码扫码登录的完整方案。相比旧版实现,新版增加了 Cookie 多字段保存、用户头像预览、延迟关闭窗口等优化,同时附带了 WBI 签名工具函数供其他 API 调用参考。

功能概述

  • 生成 Bilibili 登录二维码并显示在自定义窗口内
  • 定时轮询扫码状态,检测用户是否扫码确认
  • 扫码成功后自动交换得到 SESSDATAbili_jctDedeUserID 等关键 Cookie
  • 显示用户头像(扫码后自动加载)并延迟关闭登录窗口
  • 提供回调接口,将登录结果(成功/失败)通知调用方
  • 附带 WBI 签名 函数,用于后续需要签名的 Bilibili API 请求

完整代码 (GDScript)

将以下代码放入某个继承自 Node(如 ControlWindow)的脚本中即可。

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_windowQRImage 节点上

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 字段,分别保存 SESSDATAbili_jctDedeUserID
  • 调用 GdScriptFunc.set_data(自定义持久化函数)存储到本地

4. 用户头像预览与窗口关闭

  • 登录成功后,调用 fetch_user_avatar 通过 /x/web-interface/nav 获取头像 URL 并下载
  • 将二维码区域替换为头像(增强用户体验)
  • 延迟 0.5 秒后关闭登录窗口,并回调 success = true

5. 清理资源

  • 用户主动关闭窗口或登录失败时,停止所有定时器并销毁窗口,回调 success = false

新增/优化功能说明

功能点说明
多 Cookie 保存不仅保存 SESSDATA,还保存 bili_jctDedeUserIDsid 等,方便后续 API 调用(如投币、点赞)
buvid3 模拟在换取 Cookie 时携带 buvid3b_nut,提高请求通过率,减少风控拦截
头像加载登录后自动下载用户头像并显示在窗口中,提供视觉反馈
延迟关闭给用户 0.5 秒看到头像变化,避免窗口立即消失带来的突兀感
WBI 签名工具附带 _get_wbi_key()_sign_wbi_url(),可用于其他需要签名的 Bilibili API(如动态发布、视频信息获取)

依赖说明

  1. 外部 API

    • 二维码生成使用 api.qrserver.com,你也可以替换为本地生成库(如 QRCode 插件)
  2. 自定义全局函数

    • GdScriptFunc.set_data(key, subkey, value)GdScriptFunc.get_data(key, subkey)
      需要你自行实现数据持久化(例如使用 ConfigFileFileAccess
    • GdScriptFunc.extract_key_from_url(url)
      从类似 https://i0.hdslb.com/bfs/wbi/7a9b1d1f3c5e6a8b.png 的 URL 中提取 /wbi/ 后面的文件名(不含扩展名)
  3. UI 场景

    • res://Scene/Log_in.tscn 必须包含一个名为 QRImageTextureRect 节点

调用示例

# 在某个按钮的点击事件中
func _on_login_button_pressed():
	start_qr_login(func(success: bool):
		if success:
			print("登录成功!SESSDATA 等已保存")
			# 可以关闭登录界面,跳转到主页等
		else:
			print("登录失败或用户取消")
	)

注意事项

  1. 网络权限:Godot 导出时需要勾选 Internet 权限(Android/HTML5)
  2. 线程安全:所有 HTTP 请求和回调都在主线程执行,无需额外处理
  3. WBI 签名:扫码登录流程本身不需要签名,但如果你在登录后调用需要签名的接口,可以使用提供的 _sign_wbi_url 函数
  4. buvid3 持久化:建议将生成的 buvid3 保存在 user:// 目录下,避免每次启动都变化
  5. Cookie 有效期SESSDATA 的有效期通常为数周,过期后需要重新扫码登录

扩展建议

  • 可将 GdScriptFunc 实现为一个全局单例(AutoLoad),方便各脚本调用数据存取
  • 二维码窗口可以增加“刷新”按钮,以便在二维码过期时重新生成
  • 轮询失败时增加重试机制和指数退避策略

通过以上代码和说明,你可以在 Godot 项目中快速集成 Bilibili 扫码登录功能,并为后续需要鉴权的 API 调用做好准备。