diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b95dbe..518378b 100644 --- a/CHANGELOG.md +++ b/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 ### 新增 diff --git a/README.md b/README.md index 9a5c2ec..05cdecc 100644 --- a/README.md +++ b/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 方式。 ## ⚠️ 注意事项 diff --git a/_conf_schema.json b/_conf_schema.json index 7e4bf63..82f44cf 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -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)。" } } diff --git a/main.py b/main.py index c8129ad..dd2fc03 100644 --- a/main.py +++ b/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: diff --git a/metadata.yaml b/metadata.yaml index 07ad80e..bc61bae 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -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"