Files
MediaCrawler/mac-agent-os-main/02_skills/inbox_refine/llm_classifier.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

395 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
LLM 分类器模块 - 使用 oMLX 本地大模型进行知识分类
功能:
1. 根据内容判断 nature(fact/method/concept/opinion/reference 等)
2. 根据内容判断 domain(技术/金融/产品/生活 等)
3. 提取关键信息(标题、摘要、标签)
配置:
- oMLX API: http://localhost:8000/v1/chat/completions
- 模型: Qwen2.5-VL-3B-Instruct-8bit
- 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))