浅论luogu反bot机制 (一)

· · 科技·工程

很久以前,一位同学曾尝试开发一个插件,希望实现通过协议直接登录洛谷。然而,他在与洛谷的反爬虫机制“交手”过程中拼劲全力无法战胜,最终无奈放弃。出于好意,我帮他写了一个基于浏览器自动化的方案,绕开了那些棘手的验证逻辑。

最近突然闲的发慌。经过尝试,其实洛谷登录过程中反爬机制“也就那样”。于是从头开始,一步步拆解整个登录过程,看看背后究竟发生了什么。

:::warning[警告]{open} 仅用于方便更好学习信息且合法的用途。 :::

:::warning[警告]{open} 仅用于方便更好学习信息且合法的用途。 :::

:::warning[警告]{open} 仅用于方便更好学习信息且合法的用途。 :::

0x00 初次尝试

点击下一步时

当我们打开洛谷登录页面并输入用户名后,点击“下一步”,浏览器会立即发起两个请求:

  1. /auth/login-methods?login=...
    这个请求携带了我们输入的用户名,用于查询该账号支持的登录方式(例如密码登录、第三方登录等)。

  2. /lg4/captcha
    同时,系统会请求一个图形验证码图片,用于后续的人机验证。

登录

在输入密码和验证码后,点击“登录”按钮,浏览器会向 /do-auth/password 发起一个 POST 请求。该请求体中包含了:

如果验证通过,服务器会返回登录成功的响应,并在响应头中设置一系列 Cookie(如 __client_id_uid 等),用于维持后续的会话状态。


大致登录流程

综上所述,洛谷登录流程包含以下三个环节:

  1. 提交用户名 → 触发 /auth/login-methods?login=...
  2. 获取验证码 → 请求 /lg4/captcha
  3. 提交凭证 → 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

这段字符串包含了以下信息:

服务器可以根据 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

:::