fix: 修复登录假成功,Cookie 改为每会话注入

根因:linux.do 登录表单启用 hCaptcha 人机验证,自动化浏览器无法通过,
旧自动登录永远不可能成功;而抓取的 _forum_session 是匿名会话本就存在的
cookie,误报「自动登录成功」,导致受限主题一直 404。

修复:
- 移除无效的账号密码自动登录(_auto_login_and_capture)
- Cookie 改为每个 StealthySession 会话都重新注入(旧代码跨请求丢失)
- 登录校验端点改用 /notifications.json(匿名 403 / 登录 200),
  弃用对匿名也返回 404 的 /session/current_user.json
- Cookie 配置支持多格式:完整 Cookie 头、单 name=value、裸值(向后兼容)

linuxdo_username/password 保留仅为兼容,不再生效。
This commit is contained in:
RainySY
2026-06-16 23:52:59 +08:00
parent d27c006217
commit c67b7779ab
5 changed files with 140 additions and 132 deletions

View File

@@ -3,6 +3,19 @@
本项目所有显著变更都记录在此文件中。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [1.2.1] - 2026-06-16
### 修复
- **修复登录始终“假成功”问题**linux.do 登录表单启用了 hCaptcha 人机验证,自动化浏览器无法通过,原自动登录永远不可能成功;而旧代码抓取的是匿名会话本就存在的 `_forum_session` cookie 并误报“自动登录成功”,导致受限主题一直返回 404。现在改为明确提示自动登录不可用降级为匿名访问
- **修复 Cookie 注入跨请求丢失**StealthySession 每次请求都是新建的浏览器上下文,旧代码首次校验后缓存了登录态却不再向新会话注入 Cookie导致只有插件加载后第一条消息能登录、之后全部匿名。现在改为【每个会话都重新注入】配置的 Cookie
- **更换可靠的登录校验端点**`/session/current_user.json` 对匿名用户也返回 404无法区分登录与否改用 `/notifications.json`(匿名 403、登录 200
- **Cookie 配置支持多格式**`linuxdo_session_cookie` 现支持完整 Cookie 头(`_t=xxx; _forum_session=yyy`)、单个 `name=value`(已知 cookie 名)、以及裸值(向后兼容当作 `_forum_session`。Discourse 会话值是 base64 常带 `=` 填充,解析器已正确区分裸值与 name=value
### 变更
- 移除无效的账号密码自动登录代码(`_auto_login_and_capture``linuxdo_username` / `linuxdo_password` 配置项保留仅为兼容,不再生效
- 推荐改用长效的 `_t` cookie约 1 年有效期)而非短效 `_forum_session`(约 2 周)
- 配置项 hint、README 登录说明同步更新
## [1.2.0] - 2026-06-16
### 新增

View File

@@ -9,7 +9,7 @@
- 🛡️ **绕过 Cloudflare** — 使用 [Scrapling](https://github.com/D4Vinci/Scrapling) 的 StealthySession 自动解 Turnstile
- 📸 **智能截图** — 自适应卡片渲染,完整楼主内容,无空白/截断
- 📝 **内容摘要** — 通过 Discourse JSON API 提取完整楼主内容(无截断)
- 🔒 **账户登录** — 可选配置账号密码,访问受限分类、私信等非公开内容
- 🔒 **会话 Cookie** — 可选配置浏览器 Cookie,访问受限分类、私信等非公开内容(账号密码自动登录因 hCaptcha 已移除)
-**异步非阻塞** — Scrapling 在独立线程池运行,不阻塞 AstrBot 主循环
- 💾 **缓存机制** — 30 分钟内相同链接直接返回缓存截图
- 🧹 **缓存管理**`/linuxdo_stats` 查看统计,`/linuxdo_clean` 清理缓存
@@ -101,28 +101,28 @@ https://linux.do/t/topic/1378383
| `screenshot_timeout` | 截图超时(秒) | 15 |
| `screenshot_full_page` | 全页截图模式true=完整帖子false=仅视口) | true |
| `use_api_render` | 使用 API + 自定义 HTML 渲染(推荐) | true |
| `linuxdo_session_cookie` | LinuxDo 会话 Cookie用于访问受限内容 | (空) |
| `linuxdo_username` | LinuxDo 用户名,自动登录获取 Cookie | (空) |
| `linuxdo_password` | LinuxDo 密码,配合用户名自动登录 | (空) |
| `linuxdo_session_cookie` | LinuxDo 会话 Cookie(推荐填 `_t`,访问受限内容必填) | (空) |
| `linuxdo_username` | LinuxDo 用户名(已弃用,受 hCaptcha 限制无法自动登录) | (空) |
| `linuxdo_password` | LinuxDo 密码(已弃用,配合用户名自动登录 | (空) |
## 🔑 访问受限内容(可选)
默认以匿名身份访问 linux.do。如需查看受限分类、私信等非公开内容有两种方式:
默认以匿名身份访问 linux.do。如需查看受限分类、私信等非公开内容请使用手动 Cookie。
### 方式一:自动登录(推荐
1. 在 AstrBot WebUI 插件配置中填写 `linuxdo_username``linuxdo_password`
2. 插件自动通过 Playwright 登录并获取会话 Cookie
3. 登录失败时自动降级为匿名访问
### 方式二:手动复制 Cookie
### 方式一:手动复制 Cookie推荐唯一可用方式
1. 在浏览器中登录 linux.do
2. 打开 DevToolsF12→ Application → Cookies → linux.do
3. 复制 `_forum_session` cookie 的 Value
2. 打开 DevToolsF12→ Application → Cookies → `https://linux.do`
3. 复制 **`_t`**(推荐,长效约 1 年)或 `_forum_session`(短期)的 Value
4. 在 AstrBot WebUI 插件配置中粘贴到 `linuxdo_session_cookie`
**注意**Cookie 有效期约 2 周,过期后需重新获取。自动登录模式下插件会自动刷新
也支持一次粘贴完整 Cookie 头,例如:`_t=xxx; _forum_session=yyy`
**有效期**`_t` 约 1 年;`_forum_session` 约 2 周。过期后重新获取即可,无需重启。
### 关于账号密码自动登录(已不可用)
> ⚠️ linux.do 的登录表单启用了 **hCaptcha 人机验证**,自动化浏览器无法通过,因此账号密码自动登录已被移除。`linuxdo_username` / `linuxdo_password` 配置项保留仅为兼容,不再生效。请改用上方的 Cookie 方式。
## ⚠️ 注意事项

View File

@@ -30,21 +30,21 @@
"hint": "启用后用 Discourse JSON API 拉数据+自定义 HTML 模板渲染(推荐,完整、干净、不受页面截断/懒加载影响);关闭则走传统页面截图方案。"
},
"linuxdo_session_cookie": {
"description": "LinuxDo 会话 Cookie可选",
"description": "LinuxDo 会话 Cookie访问受限主题必填",
"type": "string",
"default": "",
"hint": "从浏览器 DevTools 复制 _forum_session cookie 值。登录 linux.do → F12 → Application → Cookies → linux.do → _forum_session → 复制 Value。留空则保持匿名访问。"
"hint": "登录 linux.do → F12 → Application → Cookies → linux.do → 复制 _t推荐长效约 1 年)或 _forum_session(短期)的 Value 粘贴到这里。也支持完整 Cookie 头格式如『_t=xxx; _forum_session=yyy』。留空则匿名访问(无法读取需登录才能看的主题)。"
},
"linuxdo_username": {
"description": "LinuxDo 用户名(可选,自动登录",
"description": "LinuxDo 用户名(已弃用",
"type": "string",
"default": "",
"hint": "配置后插件自动登录获取会话 Cookie无需手动复制。留空则使用 linuxdo_session_cookie 或匿名访问。"
"hint": "⚠️ linux.do 登录启用了 hCaptcha 人机验证,账号密码自动登录已不可用(保留此项仅为兼容)。请改用上方 linuxdo_session_cookie。"
},
"linuxdo_password": {
"description": "LinuxDo 密码(可选,自动登录",
"description": "LinuxDo 密码(已弃用",
"type": "string",
"default": "",
"hint": "配合 linuxdo_username 使用,插件自动登录并获取会话 Cookie。密码以明文存储建议使用专用低权限账户。"
"hint": "⚠️ 配合 linuxdo_username 的自动登录已因 hCaptcha 失效。请改用 linuxdo_session_cookie浏览器复制 _t。"
}
}

213
main.py
View File

@@ -447,78 +447,55 @@ class LinuxDoPreviewPlugin(Star):
p = self.config.get("linuxdo_password", "") or ""
return bool(u.strip() and p.strip())
def _auto_login_and_capture(self, session) -> str | None:
"""用 Playwright 自动登录 linux.do 并抓取 _forum_session cookie
_COOKIE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
# linux.do / Discourse / Cloudflare 可能出现的 cookie
_KNOWN_COOKIE_NAMES = {
"_t", "_forum_session", "cf_clearance", "_bypass_cache", "dosp",
"_pf", "_bblean", "theme_ids", "previousVisitAt", "messages-last-modified",
"_ga", "_gid", "_gcl_au",
}
Returns: cookie value 或 None
def _parse_cookie_pairs(self, cookie_str: str) -> list[dict]:
"""将用户配置的 cookie 字符串解析为 (name, value) 列表。
支持三种输入:
- 完整 Cookie 头(含分号):'_t=xxx; _forum_session=yyy'
- 单个 'name=value'name 须是已知 cookie 名):'_t=xxx'
- 单个裸值(直接当作 _forum_session 的值,向后兼容)
说明Discourse 的 _forum_session 值是 base64常带 '=' 填充,因此不能
仅凭是否含 '=' 判断格式,否则会把裸值误判成 name=value。
"""
username = (self.config.get("linuxdo_username", "") or "").strip()
password = (self.config.get("linuxdo_password", "") or "").strip()
ctx = session.context
if not ctx or not username or not password:
return None
page = None
try:
page = ctx.new_page()
# 先访问主页建立 CF clearance
page.goto("https://linux.do/", wait_until="domcontentloaded", timeout=30000)
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
page.wait_for_timeout(2000)
# 导航到 /login 页面Discourse SPA 内部导航,不触发 CF
page.evaluate("window.location.href = 'https://linux.do/login'")
page.wait_for_timeout(5000)
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
# 等待登录表单出现
try:
page.wait_for_selector('#login-account-name', timeout=15000)
except Exception:
logger.warning("[LinuxDoPreview] 登录表单未出现")
return None
page.fill('#login-account-name', username)
page.fill('#login-account-password', password)
page.click('#login-button')
page.wait_for_timeout(8000) # 等待登录完成
# 从 ctx.cookies() 获取 HttpOnly _forum_session cookie
cookie_val = None
try:
all_cookies = session.context.cookies()
logger.info(f"[LinuxDoPreview] ctx.cookies() 返回 {len(all_cookies)} 个 cookie")
for c in (all_cookies or []):
cname = c.get("name", "") if isinstance(c, dict) else ""
if cname == "_forum_session":
cookie_val = c.get("value", "")
break
except Exception as e:
logger.warning(f"[LinuxDoPreview] ctx.cookies() 异常: {type(e).__name__}: {e}")
if cookie_val:
logger.info(f"[LinuxDoPreview] 自动登录成功, cookie={len(cookie_val)} chars")
return cookie_val
logger.warning("[LinuxDoPreview] 登录成功但未找到 _forum_session cookie")
return None
except Exception as e:
logger.warning(f"[LinuxDoPreview] 自动登录异常: {type(e).__name__}: {str(e)[:150]}")
return None
finally:
if page:
try:
page.close()
except Exception:
pass
pairs: list[dict] = []
s = (cookie_str or "").strip()
if not s:
return pairs
if ";" in s:
# 完整 Cookie 头:按分号拆分
for part in s.split(";"):
part = part.strip()
if "=" not in part:
continue
name, value = part.split("=", 1)
name = name.strip()
if self._COOKIE_NAME_RE.match(name):
pairs.append({"name": name, "value": value.strip()})
elif "=" in s:
# 无分号但含 '=':仅当前缀是已知 cookie 名时才按 name=value 解析
name, value = s.split("=", 1)
name = name.strip()
if name in self._KNOWN_COOKIE_NAMES and self._COOKIE_NAME_RE.match(name):
pairs.append({"name": name, "value": value.strip()})
if not pairs:
# 裸值 → 当作 _forum_session向后兼容
pairs.append({"name": "_forum_session", "value": s})
return pairs
def _inject_session_cookie(self, session, cookie_value: str = "") -> bool:
"""将会话 cookie 注入到浏览器上下文
"""将会话 cookie 注入到当前浏览器上下文
注意StealthySession 每次请求都是新建的浏览器上下文cookie 不会跨
请求保留,因此【每个会话都必须重新注入】。
Returns: True 表示注入成功False 表示失败
"""
@@ -531,75 +508,93 @@ class LinuxDoPreviewPlugin(Star):
if not ctx:
return False
try:
ctx.add_cookies([{
"name": "_forum_session",
"value": cookie_value,
pairs = self._parse_cookie_pairs(cookie_value)
if not pairs:
return False
cookies = []
for p in pairs:
# _t / _forum_session 是 HttpOnly其余 cookie 按普通处理
http_only = p["name"] in ("_t", "_forum_session")
cookies.append({
"name": p["name"],
"value": p["value"],
"domain": "linux.do",
"path": "/",
"httpOnly": True,
"httpOnly": http_only,
"secure": True,
"sameSite": "Lax",
}])
logger.info("[LinuxDoPreview] 已注入会话 cookie")
})
try:
ctx.add_cookies(cookies)
logger.info(
f"[LinuxDoPreview] 已注入会话 cookie: {[c['name'] for c in cookies]}"
)
return True
except Exception as e:
logger.warning(f"[LinuxDoPreview] Cookie 注入失败: {type(e).__name__}: {e}")
return False
def _check_login_state(self, session) -> bool:
"""检查当前会话是否已登录"""
"""检查当前会话是否已登录
使用 /notifications.json已登录返回 200匿名返回 403。
/session/current_user.json 对匿名用户也返回 404无法区分故弃用。
"""
try:
resp = session.fetch(
"https://linux.do/session/current_user.json", timeout=30000
"https://linux.do/notifications.json", timeout=30000
)
if resp.status != 200:
return False
import json
data = json.loads(resp.body.decode("utf-8", errors="replace"))
return bool(data.get("current_user"))
return resp.status == 200
except Exception:
return False
def _ensure_authenticated(self, session) -> bool:
"""在已绕过 CF 的上下文中按需认证。后续所有 fetch 都会复用登录态。
"""在已绕过 CF 的上下文中按需认证。
重要StealthySession 每次请求都会新建,浏览器上下文不跨请求保留,因此
配置的 Cookie 必须【每次都注入】当前会话;而【是否登录】的校验结果可以
缓存Cookie 有效性不会在请求间变化)。
逻辑:
1) 已检查过 → 返回缓存结果
2) 配置了 linuxdo_session_cookie → 直接注入
3) 配置了 linuxdo_username + linuxdo_password → 自动登录抓取 cookie
4) 都没配置 → 匿名访问
1) 配置了 linuxdo_session_cookie → 每次注入;首次校验后缓存结果
2) 配置了用户名/密码 → linux.do 登录受 hCaptcha 保护,无法自动登录,
仅提示一次并降级为匿名访问
3) 都没配置 → 匿名访问
"""
if self._auth_check_done:
return self._logged_in
# 优先使用手动配置的 cookie
# ── 手动 Cookie每次请求都注入上下文是新建的 ──
if self._has_session_cookie():
cookie_value = (self.config.get("linuxdo_session_cookie", "") or "").strip()
if self._inject_session_cookie(session, cookie_value) and self._check_login_state(session):
if not self._inject_session_cookie(session, cookie_value):
self._auth_check_done = True
self._logged_in = True
self._logged_in = False
return False
# 校验结果只算一次Cookie 有效性跨请求稳定)
if not self._auth_check_done:
self._logged_in = self._check_login_state(session)
self._auth_check_done = True
if self._logged_in:
logger.info("[LinuxDoPreview] Cookie 登录验证成功")
return True
else:
logger.warning("[LinuxDoPreview] Cookie 无效或已过期")
self._auth_check_done = True
return False
logger.warning(
"[LinuxDoPreview] 会话 Cookie 无效或已过期,将匿名访问。"
"请在浏览器重新获取 Cookie推荐 _t长效后填入配置。"
)
return self._logged_in
# 其次尝试自动登录抓取 cookie
if self._has_auto_login():
cookie_value = self._auto_login_and_capture(session)
if cookie_value:
if self._inject_session_cookie(session, cookie_value):
# ── 仅用户名/密码:受 hCaptcha 限制,无法自动登录(仅提示一次) ──
if self._has_auto_login() and not self._auth_check_done:
self._auth_check_done = True
self._logged_in = True
logger.info("[LinuxDoPreview] 自动登录完成cookie 有效")
return True
logger.warning("[LinuxDoPreview] 自动登录失败,降级为匿名访问")
self._auth_check_done = True
return False
logger.warning(
"[LinuxDoPreview] linux.do 登录启用了 hCaptcha 人机验证,账号密码"
"自动登录不可用。请在浏览器登录 linux.do 后F12 → Application → "
"Cookies → 复制 _t推荐长效或 _forum_session 的值,填入 "
"linuxdo_session_cookie 配置项。本次降级为匿名访问。"
)
# 都没配置 → 匿名
# 都没配置 / 自动登录不可用 → 匿名
self._logged_in = False
return False
def _fetch_topic_data(self, session, url: str) -> dict | None:

View File

@@ -8,6 +8,6 @@ desc: >
2) 传统方案:访问原页 + JS 隐藏非楼主 + 展开截断后截图。
支持截图缓存避免重复请求,异步非阻塞设计。
author: RainySY
version: 1.2.0
version: 1.2.1
repo: https://github.com/sakuradairong/astrbot_plugin_linuxdo
astrbot_version: ">=4.16"