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,394 @@
#!/usr/bin/env python3
"""
LLM 分类器模块 - 使用 oMLX 本地大模型进行知识分类
功能:
1. 根据内容判断 nature(fact/method/concept/opinion/reference 等)
2. 根据内容判断 domain(技术/金融/产品/生活 等)
3. 提取关键信息(标题、摘要、标签)
配置:
- oMLX API: http://localhost:8000/v1/chat/completions
- 模型: Qwen3-8B-MLX-4bit
- API Key: omlx
"""
import json
import re
import time
from typing import Dict, List, Optional, Tuple
from pathlib import Path
# ─── oMLX 配置 ──────────────────────────────────────────────────
OLLAMA_API_URL = "http://localhost:8000/v1/chat/completions"
OLLAMA_MODEL = "Qwen2.5-VL-3B-Instruct-8bit" # 使用 VLM 模型,因为 Qwen3-8B-MLX-4bit 有 500 错误
OLLAMA_API_KEY = "omlx"
# 备份模型选项(如果 Qwen2.5 不可用)
FALLBACK_MODEL = "Qwen3-8B-MLX-4bit"
# ─── 分类体系 ──────────────────────────────────────────────────
NATURE_OPTIONS = [
"fact", # 客观事实、数据、统计结果
"method", # 方法、步骤、教程、最佳实践
"concept", # 概念、定义、理论、原理
"opinion", # 观点、看法、评价、推测
"reference", # 参考文献、论文、书籍、资料
"quote", # 引用、名言、摘录
"axiom", # 公理、定律、定理
"regulation", # 法规、政策、标准
"data", # 原始数据、数据集、代码片段
]
DOMAIN_OPTIONS = [
"technology", # 技术、编程、AI、开发
"finance", # 金融、投资、股票、经济
"product", # 产品、设计、UX、PM
"life", # 生活、健康、个人管理
"academic", # 学术、研究、教育
"business", # 商业、管理、创业
"culture", # 文化、艺术、娱乐
"other", # 其他
]
# ─── LLM 调用函数 ──────────────────────────────────────────────
def call_llm(prompt: str, system_prompt: str = None, temperature: float = 0.3) -> str:
"""
调用 oMLX 本地大模型 API
Args:
prompt: 用户提示
system_prompt: 系统提示(可选)
temperature: 温度参数,越低越确定
Returns:
LLM 回复文本
"""
import requests
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
try:
# 首先尝试 VLM 模型
resp = requests.post(
OLLAMA_API_URL,
json={
"model": OLLAMA_MODEL,
"messages": messages,
"temperature": temperature,
"stream": False
},
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {OLLAMA_API_KEY}"
},
timeout=30
)
# 如果 VLM 模型失败(500 错误),尝试备选模型
if resp.status_code == 500 and OLLAMA_MODEL == "Qwen2.5-VL-3B-Instruct-8bit":
print(f" ⚠️ VLM 模型失败,尝试备选模型 {FALLBACK_MODEL}")
resp = requests.post(
OLLAMA_API_URL,
json={
"model": FALLBACK_MODEL,
"messages": messages,
"temperature": temperature,
"stream": False
},
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {OLLAMA_API_KEY}"
},
timeout=30
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
except Exception as e:
print(f"[WARN] LLM 调用失败: {e}")
return ""
# ─── 分类函数 ──────────────────────────────────────────────────
def classify_with_llm(text: str) -> Dict:
"""
使用 LLM 对文本内容进行分类
Args:
text: 要分类的文本内容(Markdown 格式)
Returns:
{
"nature": "fact/method/concept/...",
"domain": "technology/finance/...",
"confidence": 0.0-1.0,
"title": "建议的标题",
"summary": "简短摘要",
"tags": ["标签1", "标签2", ...]
}
"""
# 限制文本长度(防止 token 过长)
preview = text[:2000] if len(text) > 2000 else text
system_prompt = """你是一个知识分类专家。请根据提供的文本内容进行以下分析:
1. 判断知识类型(nature):从以下选项中选择最合适的:fact(事实数据)、method(方法步骤)、concept(概念理论)、opinion(观点看法)、reference(参考文献)、quote(引用摘录)、axiom(公理定律)、regulation(法规政策)、data(原始数据)
2. 判断所属领域(domain):从以下选项中选择1-2个:technology(技术)、finance(金融)、product(产品)、life(生活)、academic(学术)、business(商业)、culture(文化)、other(其他)
3. 提取关键信息:建议一个简洁的标题、生成50字内的摘要、提取2-5个关键词标签
请以 JSON 格式返回结果,严格遵循以下结构:
{
"nature": "选择的类型",
"domain": ["领域1", "领域2"],
"confidence": 0.0-1.0之间的数字(表示分类信心度),
"title": "建议标题",
"summary": "简短摘要",
"tags": ["标签1", "标签2", "标签3"]
}"""
user_prompt = f"""请对以下文本内容进行分类分析:
{preview}
注意:文本可能是截断的,请基于现有内容进行判断。"""
response = call_llm(user_prompt, system_prompt, temperature=0.1)
# 解析 JSON 响应
result = {
"nature": "concept",
"domain": ["other"],
"confidence": 0.5,
"title": "",
"summary": "",
"tags": []
}
if not response:
return result
try:
# 查找 JSON 部分
json_match = re.search(r'\{.*\}', response, re.DOTALL)
if json_match:
parsed = json.loads(json_match.group())
if isinstance(parsed, dict):
if "nature" in parsed and parsed["nature"] in NATURE_OPTIONS:
result["nature"] = parsed["nature"]
if "domain" in parsed:
if isinstance(parsed["domain"], str):
result["domain"] = [parsed["domain"]]
elif isinstance(parsed["domain"], list):
result["domain"] = [d for d in parsed["domain"] if d in DOMAIN_OPTIONS]
if "confidence" in parsed:
try:
conf = float(parsed["confidence"])
if 0 <= conf <= 1:
result["confidence"] = conf
except:
pass
if "title" in parsed:
result["title"] = str(parsed["title"])
if "summary" in parsed:
result["summary"] = str(parsed["summary"])
if "tags" in parsed:
if isinstance(parsed["tags"], list):
result["tags"] = [str(tag) for tag in parsed["tags"]]
except Exception as e:
print(f"[WARN] LLM 响应解析失败: {e}")
return result
# ─── 启发式分类备用方案 ────────────────────────────────────────
def classify_heuristic(text: str, fm: dict) -> Dict:
"""
启发式分类(当 LLM 不可用时使用)
Args:
text: 文本内容
fm: 已有的 frontmatter
Returns:
与 classify_with_llm 相同结构的字典
"""
result = {
"nature": "concept",
"domain": ["other"],
"confidence": 0.4,
"title": "",
"summary": "",
"tags": []
}
# 优先使用已有 frontmatter
if fm.get("nature"):
result["nature"] = fm["nature"]
if fm.get("domain"):
if isinstance(fm["domain"], str):
result["domain"] = [fm["domain"]]
elif isinstance(fm["domain"], list):
result["domain"] = fm["domain"]
# 启发式规则
lower = text.lower()
# nature 判断
if not fm.get("nature"):
method_kw = ["步骤", "教程", "如何", "how to", "方法", "指南", "最佳实践"]
fact_kw = ["数据", "统计", "报告", "结果", "事实", "date", "report"]
opinion_kw = ["认为", "觉得", "观点", "推测", "假设", "opinion", "think"]
reference_kw = ["论文", "文献", "引用", "参考", "paper", "reference"]
if any(kw in lower for kw in method_kw):
result["nature"] = "method"
elif any(kw in lower for kw in fact_kw):
result["nature"] = "fact"
elif any(kw in lower for kw in opinion_kw):
result["nature"] = "opinion"
elif any(kw in lower for kw in reference_kw):
result["nature"] = "reference"
# domain 判断
if not fm.get("domain"):
tech_kw = ["编程", "代码", "开发", "python", "javascript", "ai", "llm", "机器学习", "技术"]
finance_kw = ["投资", "股票", "基金", "finance", "交易", "财经", "金融"]
product_kw = ["产品", "设计", "ux", "product", "用户体验"]
if any(kw in lower for kw in tech_kw):
result["domain"] = ["technology"]
elif any(kw in lower for kw in finance_kw):
result["domain"] = ["finance"]
elif any(kw in lower for kw in product_kw):
result["domain"] = ["product"]
# 生成简单标题(使用文件名或第一行)
if fm.get("title"):
result["title"] = fm["title"]
else:
first_line = text.split('\n')[0].strip('# ')
if len(first_line) < 50:
result["title"] = first_line
# 生成简单摘要(前 100 字符)
clean_text = re.sub(r'[#*\-`]', '', text)
result["summary"] = clean_text[:100] + ("..." if len(clean_text) > 100 else "")
return result
# ─── 主分类函数 ──────────────────────────────────────────────────
def classify_content_enhanced(text: str, fm: dict = None, use_llm: bool = True) -> Dict:
"""
增强版分类函数:优先使用 LLM,失败时降级到启发式
Args:
text: 文本内容
fm: 已有的 frontmatter(可选)
use_llm: 是否尝试使用 LLM(默认 True)
Returns:
分类结果字典
"""
if fm is None:
fm = {}
result = {
"nature": "concept",
"domain": ["other"],
"confidence": 0.5,
"title": "",
"summary": "",
"tags": [],
"source_type": "unknown",
"source": ""
}
# 优先使用已有 frontmatter
if fm.get("nature"):
result["nature"] = fm["nature"]
result["confidence"] = max(result["confidence"], 0.7) # 已有信息提升信心
if fm.get("domain"):
if isinstance(fm["domain"], str):
result["domain"] = [fm["domain"]]
elif isinstance(fm["domain"], list):
result["domain"] = fm["domain"]
# 尝试使用 LLM 分类
llm_result = None
if use_llm and text.strip():
try:
print(" 🤖 使用 LLM 进行智能分类...")
llm_result = classify_with_llm(text)
# 合并 LLM 结果(降低阈值要求,因为 VLM 模型可能返回较低的 confidence)
confidence_threshold = 0.4 # 降低阈值,因为 VLM 模型可能返回较低的 confidence
if llm_result.get("confidence", 0) > confidence_threshold:
if not fm.get("nature") and llm_result.get("nature"):
result["nature"] = llm_result["nature"]
if not fm.get("domain") and llm_result.get("domain"):
result["domain"] = llm_result["domain"]
if llm_result.get("title"):
result["title"] = llm_result["title"]
if llm_result.get("summary"):
result["summary"] = llm_result["summary"]
if llm_result.get("tags"):
result["tags"] = llm_result["tags"]
result["confidence"] = llm_result.get("confidence", 0.6)
print(f" ✅ LLM 分类成功 (confidence: {llm_result.get('confidence', 0.6):.2f})")
else:
print(f" ⚠️ LLM 分类信心度低 ({llm_result.get('confidence', 0):.2f} < {confidence_threshold}),使用启发式规则")
llm_result = None
except Exception as e:
print(f" ⚠️ LLM 分类失败: {e}")
llm_result = None
# LLM 失败或信心不足,使用启发式规则
if llm_result is None:
heuristic_result = classify_heuristic(text, fm)
# 合并启发式结果
if not fm.get("nature") and heuristic_result.get("nature"):
result["nature"] = heuristic_result["nature"]
if not fm.get("domain") and heuristic_result.get("domain"):
result["domain"] = heuristic_result["domain"]
if not result["title"] and heuristic_result.get("title"):
result["title"] = heuristic_result["title"]
if not result["summary"] and heuristic_result.get("summary"):
result["summary"] = heuristic_result["summary"]
# 来源信息
if fm.get("source"):
result["source"] = fm["source"]
if fm.get("source_type"):
result["source_type"] = fm["source_type"]
# 防止空列表
if not result["domain"]:
result["domain"] = ["other"]
if not result["tags"]:
result["tags"] = ["待补充"]
return result
# ─── 测试函数 ──────────────────────────────────────────────────
if __name__ == "__main__":
# 测试代码
test_text = """Python 中的装饰器是一种强大的工具,它允许在不修改原函数代码的情况下,为函数添加额外的功能。
装饰器的基本语法:
@decorator
def my_function():
pass
常见的装饰器应用场景包括:日志记录、性能测试、权限验证等。"""
print("测试 LLM 分类:")
result = classify_content_enhanced(test_text, use_llm=True)
print(json.dumps(result, ensure_ascii=False, indent=2))
print("\n测试启发式分类(关闭 LLM):")
result2 = classify_content_enhanced(test_text, use_llm=False)
print(json.dumps(result2, ensure_ascii=False, indent=2))