浅论luogu反bot机制 (一)
很久以前,一位同学曾尝试开发一个插件,希望实现通过协议直接登录洛谷。然而,他在与洛谷的反爬虫机制“交手”过程中拼劲全力无法战胜,最终无奈放弃。出于好意,我帮他写了一个基于浏览器自动化的方案,绕开了那些棘手的验证逻辑。
最近突然闲的发慌。经过尝试,其实洛谷登录过程中反爬机制“也就那样”。于是从头开始,一步步拆解整个登录过程,看看背后究竟发生了什么。
:::warning[警告]{open} 仅用于方便更好学习信息且合法的用途。 :::
:::warning[警告]{open} 仅用于方便更好学习信息且合法的用途。 :::
:::warning[警告]{open} 仅用于方便更好学习信息且合法的用途。 :::
0x00 初次尝试
点击下一步时
当我们打开洛谷登录页面并输入用户名后,点击“下一步”,浏览器会立即发起两个请求:
-
/auth/login-methods?login=...
这个请求携带了我们输入的用户名,用于查询该账号支持的登录方式(例如密码登录、第三方登录等)。 -
/lg4/captcha
同时,系统会请求一个图形验证码图片,用于后续的人机验证。
登录
在输入密码和验证码后,点击“登录”按钮,浏览器会向 /do-auth/password 发起一个 POST 请求。该请求体中包含了:
- 用户名
- 密码
- 验证码内容
如果验证通过,服务器会返回登录成功的响应,并在响应头中设置一系列 Cookie(如 __client_id、_uid 等),用于维持后续的会话状态。
大致登录流程
综上所述,洛谷登录流程包含以下三个环节:
- 提交用户名 → 触发
/auth/login-methods?login=... - 获取验证码 → 请求
/lg4/captcha - 提交凭证 → POST 到
/do-auth/password,携带用户名、密码与验证码
至此,我们理清了洛谷浏览器操作登录的过程。但如果你按照这个写出代码,就会得到这个结果:
:::info[代码]{open} 失败的代码没保存。(哭 :::
418
I'm a teapot.
我是一个茶壶。
或者是:
200
I am a teapot. I'd like a cup of coffee.
我是一个茶壶,想要一杯咖啡。
:::info[补充:“418 I'm a teapot”的含义]{open}
HTTP 状态码 418 I'm a teapot(我是茶壶)是一个幽默的状态码,表示服务器是一个茶壶,无法冲泡咖啡 。 它最初是一个愚人节玩笑,并不打算被实际的 HTTP 服务器使用。当服务器返回 418 状态码时,它以一种幽默的方式表明自己是茶壶,无法执行冲泡咖啡的请求。这个状态码属于客户端错误,意味着客户端似乎出现了错误。
除了其原始的幽默含义(表示服务器是一个茶壶,无法冲泡咖啡),在实际的场景中,它也常常被网站用作反爬虫机制的一部分。
当服务器检测到一个请求很可能是由爬虫发出的,而不是来自正常的用户浏览器时,它可能会返回 418 状态码。这意味着服务器拒绝了该请求。因此,当爬虫程序收到 418 状态码时,通常表明目标网站有反爬虫机制,并且检测到了爬虫的访问 。 :::
0x01 第二次尝试
经过尝试,只需要把 user-agent 加上就可以解决 418。
:::info[补充:什么是 user-agent]{open}
User-Agent(用户代理)是 HTTP 请求头中的一个字段,用于标识发起请求的客户端软件信息。当浏览器、爬虫、移动应用或其他客户端向服务器发送请求时,通常会在请求头中包含 User-Agent 字符串,以便服务器识别客户端的类型、操作系统、设备型号、浏览器版本等信息。
User-Agent 的典型格式如下(以桌面浏览器为例):
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36
这段字符串包含了以下信息:
- 浏览器内核(如 AppleWebKit)
- 操作系统(如 Windows 10 64位)
- 浏览器名称和版本(如 Chrome 123.0)
- 渲染引擎(如 WebKit、Gecko)
服务器可以根据 User-Agent 做出不同的响应,例如:
- 向移动设备返回移动版网页;
- 阻止或限制某些爬虫的访问;
- 为旧版浏览器提供兼容性支持。 :::
:::info[代码]{open} 没保存。 :::
然后就会发现 403 了,请求一直被拒绝(或者是最后一步验证码错误)。
0x02 第三次尝试
发现请求头中有一条可疑的 x-csrf-token,猜测是用来判别非正常请求的。搜索后得知:
X-CSRF-Token 是一种用于防范跨站请求伪造(CSRF)的安全机制。
经过全局查找,发现 x-csrf-token 存在于 https://www.luogu.com.cn/auth/login 的 <head> 中
于是只需要每次先正则表达式匹配 csrf-token,然后添加到请求头中。
:::success[代码]{open}
# 注:由于 ddddocr 使用老版本 PIL,所以需要: python <= 3.11。
import httpx
import time
import re
import ddddocr
from fake_useragent import UserAgent
from http.cookiejar import Cookie
_ocr = ddddocr.DdddOcr(beta=True, show_ad=False)
def login_luogu(username: str, password: str) -> dict:
"""
登录洛谷(Luogu)并返回包含完整 Cookie 信息的字典。
Args:
username (str): 洛谷用户名
password (str): 洛谷密码
Returns:
dict: 格式为 {
"cookie_name": {
"value": "xxx",
"domain": ".luogu.com.cn",
"path": "/",
"secure": True,
"expires": 1730000000 # 可能为 None
},
...
}
Raises:
Exception: 登录过程中任何错误(网络、验证码、认证失败等)
"""
ua = UserAgent().random
headers = {"user-agent": ua}
def _extract_csrf_token(html: str) -> str:
"""从 HTML 中提取 CSRF token"""
match = re.search(r'<meta\s+name="csrf-token"\s+content="([^"]+)"', html)
if not match:
raise ValueError("CSRF token not found in login page HTML.")
return match.group(1)
with httpx.Client(headers=headers, timeout=10, follow_redirects=True) as client:
# 1. 获取登录页,提取 CSRF token 和初始 cookies
resp = client.get("https://www.luogu.com.cn/auth/login")
resp.raise_for_status()
csrf_token = _extract_csrf_token(resp.text)
client.headers["x-csrf-token"] = csrf_token
client.cookies = resp.cookies
# 2. (可选)请求登录方式接口
client.get(
"https://www.luogu.com.cn/auth/login-methods",
params={"login": username}
)
# 3. 获取并识别验证码
captcha_resp = client.get(f"https://www.luogu.com.cn/lg4/captcha?_t={time.time()}")
captcha_resp.raise_for_status()
captcha_code = _ocr.classification(captcha_resp.content)
# 4. 提交登录表单
payload = {
"username": username,
"password": password,
"captcha": captcha_code
}
login_resp = client.post(
"https://www.luogu.com.cn/do-auth/password",
json=payload
)
# 5. 检查登录是否成功
if login_resp.status_code != 200:
raise RuntimeError(f"Login failed. Response: {login_resp.status_code} : {login_resp.content}")
# 6. 合并初始 cookies 和登录响应中新增的 cookies
final_cookies = client.cookies
# httpx.Cookies 内部使用 CookieJar,我们遍历它
cookie_dict = {}
for cookie in final_cookies.jar:
assert isinstance(cookie, Cookie)
cookie_dict[cookie.name] = {
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": bool(cookie.secure),
"expires": cookie.expires # 可能为 None 或时间戳(int)
}
return cookie_dict
:::