feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,295 @@
"""
🎯 AI 评论生成器 — 基于本地 oMLX 模型
通过本地 oMLX API (localhost:8000) 生成小红书风格 AI 评论。
作为静态语料库的增强替代,支持上下文感知(基于笔记标题/内容)。
用法:
from matrix_modules.comment.ai_generator import AICommentGenerator
gen = AICommentGenerator()
comment = gen.generate(topic="美食", style="种草")
comment = gen.generate_with_context(note_title="周末去苏州旅游攻略")
comment = gen.generate_from_note(page=page) # 从当前页面提取内容
"""
import json
import random
import time
import urllib.request
import re
from typing import Optional, List
# 默认 oMLX API 地址
OLLAMA_BASE = "http://localhost:8000/v1"
API_KEY = "5omlx"
# 系统提示词 — 小红书风格评论生成
SYSTEM_PROMPT = """你是一个小红书重度用户,正在给笔记写评论。
你的评论风格要求:
1. 亲切自然,像真人用户的真实感受(不是营销号)
2. 长度10-25字,简洁但有信息量
3. 可以带少量 emoji(但要自然,不要堆砌)
4. 语气因人而异:种草类热情、经验类实用、旅行美景类温暖
5. 避免重复、套路化的表达
你生成的每条评论必须:
- 与笔记主题相关(基于给定的主题/标题/内容)
- 语气自然真实
- 不营销、不推销、不带链接
- 不涉及政治、色情、暴力
只输出评论文本本身,不要加引号、前缀或其他格式。"""
def _call_omlx(messages: list, max_tokens: int = 64,
temperature: float = 0.9, timeout: int = 10) -> Optional[str]:
"""
调用 oMLX 本地 API 生成文本。
Args:
messages: OpenAI 格式的消息列表
max_tokens: 最大生成 token 数
temperature: 生成温度 (0-1)
timeout: 超时秒数
Returns:
生成的文本,失败返回 None
"""
payload = json.dumps({
"model": "Qwen3.5-4B-MLX-4bit", # 可用模型
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
"max_completion_tokens": 100,
}).encode("utf-8")
req = urllib.request.Request(
f"{OLLAMA_BASE}/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
},
method="POST",
)
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(req, timeout=timeout) as resp:
result = json.loads(resp.read().decode("utf-8"))
text = result["choices"][0]["message"]["content"].strip()
# 清理可能的引号
text = text.strip("\"'「」『』")
return text
except Exception as e:
print(f" ⚠️ oMLX 调用失败: {e}")
return None
# ════════════════════════════════════════════════════════════
# 主类
# ════════════════════════════════════════════════════════════
class AICommentGenerator:
"""AI 评论生成器 — 基于 oMLX 本地模型"""
def __init__(self, base_url: str = OLLAMA_BASE, api_key: str = API_KEY,
model: str = "Qwen3.5-4B-MLX-4bit"):
self.base_url = base_url
self.api_key = api_key
self.model = model
self._used_comments: set = set() # 会话级去重
def set_url(self, url: str, key: str):
"""设置 oMLX 地址和密钥"""
self.base_url = url
self.api_key = key
def reset_session(self):
"""重置去重记录"""
self._used_comments.clear()
def _is_duplicate(self, text: str) -> bool:
"""检查是否与已用评论重复"""
text_clean = text.strip().lower()
if text_clean in self._used_comments:
return True
# 相似度检查:如果已有的评论包含当前文本或反之
for used in self._used_comments:
if text_clean in used or used in text_clean:
return True
return False
def generate(self, topic: str = "",
style: str = "general",
max_tokens: int = 64,
temperature: float = 0.9,
retries: int = 3) -> str:
"""
生成一条小红书风格评论。
Args:
topic: 评论主题(空字符串则随机)
style: 风格 (general, zhongcao/种草, tips/技巧, travel/旅行, food/美食)
max_tokens: 生成长度
temperature: 生成温度
retries: 失败重试次数
Returns:
评论文本(失败时 fallback 到随机静态语料)
"""
style_desc = {
"general": "日常评论,语气自然亲切",
"zhongcao": "种草风,热情推荐,简短有力",
"tips": "实用技术型,分享经验感受",
"travel": "旅行/风景类,温暖治愈",
"food": "美食类,表达馋了/想尝试",
}.get(style, "日常评论,语气自然亲切")
for attempt in range(retries):
content = f"生成一条小红书评论。\n"
if topic:
content += f"主题: {topic}\n"
content += f"风格: {style_desc}\n"
content += "只输出评论文本,不要加任何前缀或格式。"
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content},
]
text = _call_omlx(messages, max_tokens=max_tokens,
temperature=temperature)
if text and not self._is_duplicate(text):
self._used_comments.add(text.strip().lower())
return text
time.sleep(0.3)
# 所有重试失败,fallback 到静态语料
return self._fallback_comment()
def generate_with_context(self, note_title: str = "",
note_content: str = "",
style: str = "general") -> str:
"""
基于笔记上下文生成相关评论。
Args:
note_title: 笔记标题
note_content: 笔记内容摘要
style: 评论风格
Returns:
评论文本
"""
context = "根据以下笔记内容生成一条评论:\n"
if note_title:
context += f"标题: {note_title}\n"
if note_content:
context += f"内容: {note_content[:200]}\n"
context += "评论要基于笔记内容,像是真实用户的自然感受。"
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": context},
]
text = _call_omlx(messages, max_tokens=80, temperature=0.85)
if text and not self._is_duplicate(text):
self._used_comments.add(text.strip().lower())
return text
return self._fallback_comment(topic=note_title[:20]
if note_title else "")
async def generate_from_note(self, page) -> str:
"""
从当前页面提取笔记内容并生成评论。
Args:
page: Playwright Page 对象
Returns:
评论文本
"""
try:
note_info = await page.evaluate("""
() => {
const title = document.querySelector('.title, h1, [class*=note-title]');
const content = document.querySelector('.content, .desc, [class*=content]');
return {
title: title ? title.textContent.trim().substring(0, 100) : '',
content: content ? content.textContent.trim().substring(0, 200) : '',
};
}
""")
except Exception:
note_info = {"title": "", "content": ""}
return self.generate_with_context(
note_title=note_info.get("title", ""),
note_content=note_info.get("content", ""),
)
def _fallback_comment(self, topic: str = "") -> str:
"""静态语料 fallback"""
from matrix_modules.comment.xhs.corpus import get_comment
return get_comment(length="medium")
# ════════════════════════════════════════════════════════════
# 便捷函数(与 comment_corpus.py 接口一致)
# ════════════════════════════════════════════════════════════
_global_generator: AICommentGenerator = None
def _get_generator() -> AICommentGenerator:
"""获取全局生成器实例"""
global _global_generator
if _global_generator is None:
_global_generator = AICommentGenerator()
return _global_generator
def ai_comment(topic: str = "", style: str = "general") -> str:
"""获取一条 AI 生成的小红书评论"""
return _get_generator().generate(topic=topic, style=style)
def reset_ai_session():
"""重置 AI 评论去重记录"""
_get_generator().reset_session()
# ════════════════════════════════════════════════════════════
# 测试入口
# ════════════════════════════════════════════════════════════
if __name__ == "__main__":
print("🎯 AI 评论生成器测试\n")
gen = AICommentGenerator()
# 测试:不同主题
for topic in ["美食探店心得", "周末旅行攻略", "护肤好物分享", "穿搭技巧总结"]:
comment = gen.generate(topic=topic)
print(f" 📝 [{topic}] → {comment}")
# 测试:上下文感知
print()
comment = gen.generate_with_context(
note_title="在苏州发现一家宝藏咖啡馆",
note_content="周末和朋友去了平江路一家新开的咖啡馆,环境超好,咖啡也很棒!",
)
print(f" 📝 [上下文] → {comment}")
# 测试:oMLX 不可用时 fallback
print()
gen.set_url("http://localhost:9999", "invalid_key")
fallback = gen.generate(topic="测试")
print(f" 📝 [fallback] → {fallback}")
@@ -0,0 +1,6 @@
"""
小红书评论语料模块
"""
from .corpus import get_comment, get_comments, random_comment, short_comment, long_comment
__all__ = ["get_comment", "get_comments", "random_comment", "short_comment", "long_comment"]
@@ -0,0 +1,321 @@
"""
小红书评论语料库
风格: 种草/经验分享/生活方式/美食/旅行/美妆/穿搭
特点: 语气亲切、emoji 适度、实用性强、避免营销感
分类标签:
- lifestyle: 生活方式/日常
- food: 美食/探店
- travel: 旅行/出游
- beauty: 美妆/护肤
- fashion: 穿搭/时尚
- tips: 经验/技巧
- emotion: 情感共鸣
"""
import random
# ════════════════════════════════════════════════════════════
# 会话级去重 — 同一运行中不重复发送相同评论
# ════════════════════════════════════════════════════════════
_used_comments: set = set()
def reset_session():
"""清空已用评论记录(新账号/新运行开始时调用)"""
_used_comments.clear()
# ════════════════════════════════════════════════════════════
# 按分类组织的语料
# ════════════════════════════════════════════════════════════
CORPUS = {
"lifestyle": [
"这就是我理想的生活状态",
"看完立刻去试了,真的有用",
"收藏了,周末就安排",
"这个思路太棒了,学到了",
"已经开始期待了",
"这就是我想要的感觉",
"看完心情都变好了",
"太真实了,完全说到心坎里",
"这个分享太及时了",
"已经转发给闺蜜了",
"这就是生活该有的样子",
"看完立刻动手试了一下",
"这个角度从来没想过",
"太治愈了,反复看了好几遍",
"这就是我最近在找的东西",
],
"food": [
"看着就很有食欲",
"下次去一定要试试",
"这个做法收藏了",
"看起来好好吃",
"已经流口水了",
"这个搭配绝了",
"周末就去做",
"求具体做法",
"这个店在哪里",
"看起来就很正宗",
"已经加入收藏夹",
"这个摆盘太美了",
"食材都很新鲜的样子",
"这个口味应该很适合我",
"已经截图保存了",
],
"travel": [
"这个地方太美了",
"已经加入旅行清单",
"下次假期就去",
"这个机位太绝了",
"攻略做得很详细",
"风景太治愈了",
"已经分享给朋友了",
"这个路线规划得很好",
"看完立刻想去",
"这个地方人不多吧",
"住宿看起来不错",
"这个角度拍出来太好看了",
"已经收藏了,下次参考",
"这个景点值得去吗",
"交通方便吗",
],
"beauty": [
"这个效果太明显了",
"已经种草了",
"求链接",
"这个颜色很适合我",
"肤质看起来变好了",
"这个步骤学到了",
"已经加入购物车",
"这个手法很关键",
"效果能维持多久",
"敏感肌可以用吗",
"这个搭配思路很好",
"已经截图保存教程",
"这个产品的确不错",
"步骤很详细,收藏了",
"这个技巧太实用了",
],
"fashion": [
"这套搭配太绝了",
"颜色搭配得很好",
"这个风格很适合你",
"已经截图保存",
"求衣服链接",
"这个配饰点睛了",
"整体比例很好",
"这个单品很百搭",
"已经加入灵感库",
"这个配色可以学",
"风格很独特",
"这套日常也能穿",
"细节处理得很好",
"这个搭配思路学到了",
"已经转发给姐妹了",
],
"tips": [
"这个技巧太实用了",
"已经收藏,下次用",
"步骤写得很清楚",
"这个坑我也踩过",
"感谢分享,省了很多时间",
"这个方法效率很高",
"已经试过了,确实好用",
"这个思路可以举一反三",
"细节讲得很到位",
"这个工具我也在用",
"已经加入知识库",
"这个经验太宝贵了",
"看完立刻去实践了",
"这个总结很到位",
"已经分享给同事了",
],
"emotion": [
"太真实了",
"完全共鸣",
"这就是我想说的",
"看完很有感触",
"太懂这种感觉了",
"说得太对了",
"这就是生活",
"看完沉默了",
"太戳心了",
"这个视角很独特",
"完全同意",
"这就是成长吧",
"看完想了很多",
"这个分享很有力量",
"感谢分享",
],
}
# ════════════════════════════════════════════════════════════
# 通用短评(不分类,随机使用)
# ════════════════════════════════════════════════════════════
GENERAL_SHORT = [
"学到了",
"收藏了",
"确实如此",
"很有道理",
"太棒了",
"感谢分享",
"已三连",
"好文",
"mark",
"码住",
"实用",
"干货",
"受教了",
"涨知识了",
"这个好",
"不错",
"可以",
"666",
"绝了",
"牛",
]
# ── 三级接力语料(评论→回复→再回复的自然互动链)──
# 每个分类含 first/reply/second 三层
CHAIN_CORPUS = {
"food": [
{"first": "看着就很有食欲,周末去试试", "reply": "同意!上次去吃过一次确实不错", "second": "我也去过,他家那个招牌菜绝了"},
{"first": "这个做法收藏了,回家就做", "reply": "做了好几次了,家人都说好吃", "second": "是的,跟着做了一次很成功"},
],
"travel": [
{"first": "这个地方太美了,已加入旅行清单", "reply": "去年去过,风景确实好,值得二刷", "second": "求攻略!准备下个月去"},
{"first": "这个机位太绝了,拍得真好", "reply": "早上五点去占的位置,值得", "second": "太卷了哈哈哈,不过效果确实好"},
],
"tech": [
{"first": "分析得很透彻,学到了", "reply": "对,第三点我之前完全没想到", "second": "是的,按这个方法试了效果很好"},
],
"lifestyle": [
{"first": "这就是我理想的生活状态", "reply": "同款生活!每天都很充实", "second": "羡慕了,我也要这样过"},
],
"emotion": [
{"first": "太真实了,完全说到心坎里", "reply": "是啊,经历过的人才懂", "second": "抱抱,都会好起来的"},
],
}
def get_chain(group: str = "food", position: str = "first") -> str:
"""获取三级接力语料。group=分类, position=first/reply/second"""
import random
pool = CHAIN_CORPUS.get(group, CHAIN_CORPUS["food"])
item = random.choice(pool)
return item.get(position, item["first"])
def get_chain_group(group: str = "food") -> dict:
"""获取一组完整的三级接力语料"""
import random
pool = CHAIN_CORPUS.get(group, CHAIN_CORPUS["food"])
return random.choice(pool)
# ════════════════════════════════════════════════════════════
# 评论生成器
# ════════════════════════════════════════════════════════════
def get_comment(category: str = None, length: str = "medium") -> str:
"""
获取一条小红书风格评论(自动去重:同一运行中不会重复返回相同评论)
Args:
category: 分类标签 (lifestyle/food/travel/beauty/fashion/tips/emotion)
None 时随机选择分类
length: 长度控制
- short: 2~8 字短评
- medium: 10~25 字常规评论 (默认)
- long: 25~50 字长评论
Returns:
评论文本
"""
global _used_comments
if length == "short":
available = [c for c in GENERAL_SHORT if c not in _used_comments]
if not available:
available = GENERAL_SHORT # 用完了就复用
comment = random.choice(available)
_used_comments.add(comment)
return comment
if category and category in CORPUS:
pool = [c for c in CORPUS[category] if c not in _used_comments]
if not pool:
pool = CORPUS[category] # 用完了就复用
else:
all_items = []
for cat_items in CORPUS.values():
all_items.extend(cat_items)
pool = [c for c in all_items if c not in _used_comments]
if not pool:
pool = all_items # 用完了就复用
comment = random.choice(pool)
_used_comments.add(comment)
# 长度控制
if length == "long" and len(comment) < 20:
# 追加一条短评组合
comment += "。" + random.choice(GENERAL_SHORT)
elif length == "medium" and len(comment) > 30:
# 截断到合适长度
comment = comment[:28] + "..."
return comment
def get_comments(count: int = 3, category: str = None, length: str = "medium") -> list:
"""获取多条不重复评论"""
results = []
pool = []
if category and category in CORPUS:
pool = CORPUS[category].copy()
else:
for cat_items in CORPUS.values():
pool.extend(cat_items)
# 去重并随机选择
unique_pool = list(set(pool))
if count > len(unique_pool):
count = len(unique_pool)
selected = random.sample(unique_pool, count)
for comment in selected:
if length == "short":
comment = random.choice(GENERAL_SHORT)
elif length == "long" and len(comment) < 20:
comment += "。" + random.choice(GENERAL_SHORT)
results.append(comment)
return results
# ════════════════════════════════════════════════════════════
# 快捷函数
# ════════════════════════════════════════════════════════════
def random_comment() -> str:
"""随机获取一条中等长度评论(养号默认使用)"""
return get_comment(length="medium")
def short_comment() -> str:
"""获取短评"""
return get_comment(length="short")
def long_comment() -> str:
"""获取长评"""
return get_comment(length="long")