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:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
||||
|
||||
### 新增
|
||||
|
||||
30
README.md
30
README.md
@@ -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. 打开 DevTools(F12)→ Application → Cookies → linux.do
|
||||
3. 复制 `_forum_session` cookie 的 Value
|
||||
2. 打开 DevTools(F12)→ 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 方式。
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
|
||||
@@ -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)。"
|
||||
}
|
||||
}
|
||||
|
||||
215
main.py
215
main.py
@@ -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):
|
||||
self._auth_check_done = True
|
||||
self._logged_in = True
|
||||
logger.info("[LinuxDoPreview] Cookie 登录验证成功")
|
||||
return True
|
||||
else:
|
||||
logger.warning("[LinuxDoPreview] Cookie 无效或已过期")
|
||||
if not self._inject_session_cookie(session, cookie_value):
|
||||
self._auth_check_done = 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 登录验证成功")
|
||||
else:
|
||||
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):
|
||||
self._auth_check_done = True
|
||||
self._logged_in = True
|
||||
logger.info("[LinuxDoPreview] 自动登录完成,cookie 有效")
|
||||
return True
|
||||
logger.warning("[LinuxDoPreview] 自动登录失败,降级为匿名访问")
|
||||
# ── 仅用户名/密码:受 hCaptcha 限制,无法自动登录(仅提示一次) ──
|
||||
if self._has_auto_login() and not self._auth_check_done:
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user