Add HeXi bot codebase: custom plugins, web frontends, tests

- hexi core: message handling, rate limiting, cooldown, plugin manager
- Custom plugins: BF stats, daily check-in, quotes, persona cards, etc.
- Community plugins vendored under hexi/plugins with local fixes
- Web admin frontends (learning-chat, persona-admin), unified hexi/web
- Tests for rate_limit/cooldown/memes/persona; poetry.lock

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,153 @@
"""抖音图文 SSR 静态解析 — 不走浏览器,直接解析服务端渲染页面
与 douyin_api.py 的 playwright 拦截方案互补:
- 视频作品 → fetch_douyin_content(playwright 拦截,可取全部清晰度)
- 图文作品 → fetch_douyin_note_ssr(本模块,按 aweme_id 精准渲染,秒级返回)
SSR 数据(window._ROUTER_DATA / __pace_f RSC)是服务端按作品 ID 渲染的页面数据,
不存在 playwright 拦截 aweme/v1/web/aweme/post 时取到作者其他作品的问题。
注意:必须用移动端 UA 请求 iesdouyin.com/share 端点,桌面 UA 会被 302 回主站
并返回 JS 反爬壳(无任何 SSR 数据)。
"""
import json
import re
from pathlib import Path
from typing import List, Optional
import httpx
from nonebot import logger
from ..models import DouyinFetchError
from ..utils import get_temp_root
from .douyin_api import _process_animated_note, _process_note_with_parsed
from .douyin_parser import parse_douyin_response, parse_note_images, parse_ssr_page
MOBILE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 "
"Mobile/15E148 Safari/604.1"
)
ROUTER_DATA_RE = re.compile(r"window\._ROUTER_DATA\s*=\s*(.*?)</script>", re.S)
# 短链重定向后统一为 iesdouyin.com/share/video/{id} 形态(图文作品也如此),
# 因此 note / video 两种形态都要能提取 ID
AWEME_ID_RE = re.compile(r"/(?:share/)?(?:note|video)/(\d+)")
async def fetch_douyin_note_ssr(
note_url: str,
cookies: List[dict],
) -> tuple[Optional[str], Optional[List[Path]]]:
"""
解析图文作品(SSR 静态解析,秒级返回)
接受 note / video 任意形态的抖音链接(重定向后图文也常是 share/video),
按 SSR 内容判定:有 images → 图文/动图下载;无 images(真视频)→
返回 (None, None) 交由 playwright 链路。
Returns:
(title, file_paths) — 图片/视频本地路径列表;非图文返回 (None, None);
失败抛 DouyinFetchError
"""
m = AWEME_ID_RE.search(note_url)
if not m:
raise DouyinFetchError(f"无法从链接提取作品 ID: {note_url}")
vid = m.group(1)
api_response = await _fetch_ssr_api_response(note_url, vid, cookies)
# 内容判定:SSR 数据无 images → 真视频作品,回落 playwright
aweme = (api_response.get("aweme_list") or [None])[0] or {}
if not aweme.get("images"):
logger.info("SSR 内容为视频作品(无 images),回退 playwright 链路")
return None, None
# 复用原有解析链:标题 / 作者 / 文件名(referer 统一用 note 形态,
# 避免输入是 share/video 时被误判为"视频")
parsed = parse_douyin_response(
api_response, f"https://www.douyin.com/note/{vid}"
)
logger.info(f"SSR 标题: {parsed.raw_title}")
logger.info(f"SSR 作者: {parsed.raw_nickname}")
tmp_root = get_temp_root("douyin")
headers = {
"Referer": "https://www.douyin.com/",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/122.0.0.0 Safari/537.36"
),
}
# 复用原有分派:纯图文 / 图+视频混合 → 并发下载;纯动图 → 逐张下载 mp4
images_urls, video_url = parse_note_images(api_response, None, vid)
if images_urls:
file_paths = await _process_note_with_parsed(
images_urls, video_url, tmp_root, parsed.file_name, headers
)
else:
file_paths = await _process_animated_note(
api_response, tmp_root, parsed.file_name, headers
)
return parsed.file_name, file_paths
async def _fetch_ssr_api_response(
note_url: str, vid: str, cookies: List[dict]
) -> dict:
"""请求 douyin.com note 页面并提取 SSR 数据,规范化为 aweme_list 格式
2026-08-13 起 iesdouyin.com/share/note 端点对所有请求(含登录态)只返回
33KB 壳页(无任何作品数据),改从 www.douyin.com/note/{id} 提取
__pace_f RSC 数据——服务端按作品 id 精确渲染,与 URL 一一对应,
不存在 aweme/post 作者列表"取错作品"的问题。
必须用桌面 UA + 登录 cookie(whale 风控对未登录请求截断作品数据)。
"""
url = f"https://www.douyin.com/note/{vid}"
cookie_map = {c["name"]: c["value"] for c in cookies} if cookies else None
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/151.0.0.0 Safari/537.36"
),
"Referer": "https://www.douyin.com/",
}
async with httpx.AsyncClient(
headers=headers, timeout=20, follow_redirects=True
) as client:
resp = await client.get(url, cookies=cookie_map)
if resp.status_code >= 400:
raise DouyinFetchError(f"SSR 页面请求失败: status={resp.status_code}")
html = resp.text
logger.info(f"SSR 页面: {url} status={resp.status_code} size={len(html)}")
# 1. 旧版 SSR: window._ROUTER_DATA(snake_case 结构,可直接复用原有解析函数)
if m := ROUTER_DATA_RE.search(html):
data = _clean_js_json(m.group(1))
if data:
loader = data.get("loaderData", {})
page = loader.get("note_(id)/page") or loader.get("video_(id)/page")
item_list = (page.get("videoInfoRes") or {}).get("item_list") or []
if item_list:
logger.info("SSR 数据来源: _ROUTER_DATA")
return {"aweme_list": [item_list[0]]}
# 2. 新版 SSR: __pace_f.push RSC 流(parse_ssr_page 已规范化为 aweme_list 格式)
if parsed := parse_ssr_page(html):
logger.info("SSR 数据来源: __pace_f RSC")
return parsed
raise DouyinFetchError("SSR 页面无图文数据(可能已被删除或风控)")
def _clean_js_json(raw: str) -> Optional[dict]:
"""清洗 JS 对象文本为合法 JSON($undefined / $Lx 占位符)"""
text = raw.strip().rstrip(";").strip()
text = re.sub(r"\$undefined", "null", text)
text = re.sub(r"\$L\d+", "null", text)
try:
return json.loads(text)
except json.JSONDecodeError:
logger.warning("SSR JSON 解析失败")
return None