移植自 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 带网关原话 / 正常返回)。
394 lines
15 KiB
Python
394 lines
15 KiB
Python
#!/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)) |