| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- """帖子级「创作知识 / 非创作知识」分类 + 提取知识点。
- 判据是「内容作品创作知识」,覆盖图文、视频、脚本、游戏视频、历史视频等内容创作场景,
- 并显式排除三类越界(见 prompts):① 应试/学术写作
- ② 制作/工具操作(=制作知识)③ 学科知识/评论/作品本身。两套真实提示词:
- 图文(小红书/微信):prompts/classify_imgtext.txt(标题+正文+全部图喂多模态模型)
- 视频(抖音):prompts/classify_video.txt(OSS/CDN URL 直喂;本地大视频 fallback 时先压 480p 保音轨)
- 都输出 {is_empty, reason, knowledge}:is_empty 即创作闸;is_empty=false 时连带提炼出具体创作知识点。
- 结果按 url 写 app.db 的 post_class(upsert 覆盖,可重判)。并发;与微信补下载并行安全(busy_timeout)。
- 只读 prompts、走 OpenRouter / Ark / Qwen——不改 skill/creation_knowledge,不解构、不入 ingest。
- 用法:PYTHONPATH=. CK_ENV_FILE=.env python -m acquisition.classify [平台名...]
- """
- from __future__ import annotations
- import base64
- import concurrent.futures as cf
- import json
- import os
- import subprocess
- import sys
- import tempfile
- import threading
- import time
- from pathlib import Path
- from urllib.parse import urlparse
- import httpx
- from acquisition import store
- from core.config import Settings, load_env_file
- from core.prompts import load_prompt
- ROOT = Path(__file__).resolve().parent.parent
- PLATFORMS = ["xiaohongshu", "weixin", "douyin"] # 默认重判全部;可传平台名覆盖
- IMG_WORKERS = 8 # 图文并发
- VID_WORKERS = 3 # 视频并发(含 ffmpeg 压制,别太高)
- MAX_CARDS = 12 # 图文最多送几张图
- COMPRESS_OVER_MB = 12 # 视频超过此大小先压再喂
- COMPRESS_H = 480
- DEFAULT_PROVIDER_MIN_INTERVAL_SECONDS = 0.5
- DEFAULT_429_BACKOFF_SECONDS = [30.0, 90.0, 180.0]
- RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
- class _ProviderThrottle:
- def __init__(self) -> None:
- self._lock = threading.Lock()
- self._next_allowed = 0.0
- def wait(self, min_interval_seconds: float) -> None:
- with self._lock:
- now = time.monotonic()
- sleep_for = max(0.0, self._next_allowed - now)
- self._next_allowed = max(now, self._next_allowed) + min_interval_seconds
- if sleep_for > 0:
- time.sleep(sleep_for)
- def penalize(self, seconds: float) -> None:
- if seconds <= 0:
- return
- with self._lock:
- self._next_allowed = max(self._next_allowed, time.monotonic() + seconds)
- _PROVIDER_THROTTLES: dict[str, _ProviderThrottle] = {}
- _PROVIDER_THROTTLES_LOCK = threading.Lock()
- def _data_url(public_path: str):
- fs = ROOT / public_path.lstrip("/")
- if not fs.exists():
- return None
- return "data:image/jpeg;base64," + base64.b64encode(fs.read_bytes()).decode()
- def _is_http_url(value: str) -> bool:
- try:
- parsed = urlparse(value)
- except Exception:
- return False
- return parsed.scheme in ("http", "https") and bool(parsed.netloc)
- def _compress(mp4: Path) -> Path:
- """大视频压到 480p(保留口播音轨)→ 临时 mp4;失败回原文件。"""
- out = Path(tempfile.gettempdir()) / f"ck_{mp4.parent.name}_480.mp4"
- try:
- import imageio_ffmpeg
- ff = imageio_ffmpeg.get_ffmpeg_exe()
- subprocess.run([ff, "-y", "-i", str(mp4), "-vf", f"scale=-2:{COMPRESS_H}",
- "-c:v", "libx264", "-crf", "30", "-preset", "veryfast",
- "-c:a", "aac", "-b:a", "64k", str(out)],
- capture_output=True, timeout=300)
- if out.exists() and out.stat().st_size > 0:
- return out
- except Exception:
- pass
- return mp4
- def _parse_judge_content(content: str) -> tuple:
- d = json.loads(content)
- if bool(d.get("is_empty")):
- return 0, str(d.get("reason", ""))[:60], "", ""
- return 1, str(d.get("reason", ""))[:60], str(d.get("knowledge", "") or ""), ""
- def _env_first(env: dict, *keys: str) -> str:
- for key in keys:
- value = os.getenv(key) or env.get(key)
- if value:
- return value
- return ""
- def _is_qwen_model(model: str) -> bool:
- lower = model.lower()
- return lower.startswith("qwen") or lower.startswith("qwq")
- def _provider_key(name: str) -> str:
- return name.split(":", 1)[0].lower()
- def _provider_throttle(provider: str) -> _ProviderThrottle:
- with _PROVIDER_THROTTLES_LOCK:
- throttle = _PROVIDER_THROTTLES.get(provider)
- if throttle is None:
- throttle = _ProviderThrottle()
- _PROVIDER_THROTTLES[provider] = throttle
- return throttle
- def _env_float(env: dict, default: float, *keys: str) -> float:
- value = _env_first(env, *keys)
- if not value:
- return default
- try:
- return max(0.0, float(value))
- except ValueError:
- return default
- def _provider_min_interval(provider: str, env: dict) -> float:
- prefix = provider.upper()
- return _env_float(
- env,
- DEFAULT_PROVIDER_MIN_INTERVAL_SECONDS,
- f"CLASSIFY_{prefix}_MIN_INTERVAL_SECONDS",
- "CLASSIFY_PROVIDER_MIN_INTERVAL_SECONDS",
- )
- def _parse_backoff_list(value: str) -> list[float]:
- out = []
- for part in value.split(","):
- try:
- seconds = float(part.strip())
- except ValueError:
- continue
- if seconds > 0:
- out.append(seconds)
- return out or DEFAULT_429_BACKOFF_SECONDS
- def _retry_after_seconds(resp: httpx.Response) -> float | None:
- value = resp.headers.get("retry-after")
- if not value:
- return None
- try:
- return max(0.0, float(value))
- except ValueError:
- return None
- def _provider_429_backoff(provider: str, env: dict, attempt: int, resp: httpx.Response) -> float:
- retry_after = _retry_after_seconds(resp)
- if retry_after is not None:
- return retry_after
- prefix = provider.upper()
- values = _parse_backoff_list(
- _env_first(
- env,
- f"CLASSIFY_{prefix}_429_BACKOFF_SECONDS",
- "CLASSIFY_429_BACKOFF_SECONDS",
- ) or ",".join(str(v) for v in DEFAULT_429_BACKOFF_SECONDS)
- )
- return values[min(attempt, len(values) - 1)]
- def _providers(settings: Settings, messages: list) -> list[tuple[str, str, dict, dict]]:
- body = {"model": settings.video_model, "messages": messages,
- "response_format": {"type": "json_object"}}
- env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
- out: list[tuple[str, str, dict, dict]] = []
- provider = (_env_first(env, "CLASSIFY_PROVIDER") or "auto").lower()
- explicit_model = _env_first(env, "CLASSIFY_MODEL")
- bailian_key = _env_first(
- env,
- "ALIYUN_BAILIAN_API_KEY",
- )
- if bailian_key and provider in ("auto", "qwen", "bailian"):
- bailian_url = _env_first(
- env,
- "ALIYUN_BAILIAN_BASE_URL",
- ) or "https://dashscope.aliyuncs.com/compatible-mode/v1"
- models = []
- for model in [
- explicit_model,
- _env_first(env, "ALIYUN_BAILIAN_MODEL"),
- _env_first(env, "OPENROUTER_MODEL"),
- "qwen3.7-plus",
- "qwen-vl-plus",
- ]:
- if model and _is_qwen_model(model) and model not in models:
- models.append(model)
- for model in models:
- out.append((
- f"qwen:{model}",
- bailian_url.rstrip("/") + "/chat/completions",
- {"Authorization": f"Bearer {bailian_key}", "Content-Type": "application/json"},
- {"model": model, "messages": messages, "response_format": {"type": "json_object"}},
- ))
- if provider in ("qwen", "bailian"):
- return out
- if settings.openrouter_api_key and provider in ("auto", "openrouter"):
- out.append((
- "openrouter",
- settings.openrouter_base_url.rstrip("/") + "/chat/completions",
- {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"},
- body,
- ))
- ark_key = _env_first(env, "ARK_API_KEY")
- if ark_key and provider in ("auto", "ark"):
- ark_url = _env_first(env, "ARK_CHAT_URL") or "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
- models = []
- explicit = explicit_model or _env_first(env, "ARK_CHAT_MODEL")
- if explicit:
- models.append(explicit)
- # Seed 2 Mini 接入点/1.6 Vision 更适合图文+视频理解;flash 作为轻量兜底。
- models.extend(["ep-20260506151915-jqvw7", "doubao-seed-1-6-vision-250815", "doubao-seed-1-6-flash-250615"])
- seen = set()
- for model in models:
- if model in seen:
- continue
- seen.add(model)
- out.append((
- f"ark:{model}",
- ark_url,
- {"Authorization": f"Bearer {ark_key}", "Content-Type": "application/json"},
- {"model": model, "messages": messages, "response_format": {"type": "json_object"}},
- ))
- return out
- def _judge(messages: list, settings: Settings, timeout: float) -> tuple:
- """调多模态模型(Qwen / OpenRouter / Ark),解析 {is_empty, reason, knowledge},带重试。
- 返回 (is_creation 1/0/None, reason, knowledge, points)。"""
- last = ""
- env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
- for name, api, headers, payload in _providers(settings, messages):
- provider = _provider_key(name)
- throttle = _provider_throttle(provider)
- min_interval = _provider_min_interval(provider, env)
- for attempt in range(3):
- try:
- throttle.wait(min_interval)
- resp = httpx.post(api, headers=headers, json=payload, timeout=timeout)
- if resp.status_code == 200:
- return _parse_judge_content(resp.json()["choices"][0]["message"]["content"])
- last = f"{name} http {resp.status_code}"
- if resp.status_code == 429:
- throttle.penalize(_provider_429_backoff(provider, env, attempt, resp))
- if resp.status_code in (401, 403, 404):
- break
- except Exception as exc:
- last = f"{name} {str(exc)[:50]}"
- if "http " not in last or any(f"http {code}" in last for code in RETRYABLE_STATUS_CODES):
- time.sleep(2 * (attempt + 1))
- else:
- break
- if not last:
- last = "missing Qwen/OpenRouter/Ark credentials"
- return None, f"判定失败: {last}", "", ""
- def classify_imgtext(p: dict, settings: Settings) -> tuple:
- """图文:标题+正文+全部图,用收紧的 classify_imgtext.txt 判 is_empty 并提取知识点。"""
- user = [{"type": "text", "text": f"平台:{p.get('platform')}\n标题:{p.get('title', '')}\n"
- f"正文:{(p.get('body_text') or '')[:1500]}\n(下附帖子图片,请一并看完)"}]
- for im in (p.get("images") or [])[:MAX_CARDS]:
- u = _data_url(im)
- if u:
- user.append({"type": "image_url", "image_url": {"url": u}})
- messages = [{"role": "system", "content": load_prompt("classify_imgtext")},
- {"role": "user", "content": user}]
- return _judge(messages, settings, timeout=120)
- def classify_video(p: dict, settings: Settings) -> tuple:
- """视频:看完整段视频,用 classify_video.txt 判 is_empty 并提取知识点。
- 新采集链路里抖音视频会先转存 OSS,传入 HTTP(S) CDN URL;老链路传 /data 本地 mp4。
- """
- rel = p.get("video") or ""
- if _is_http_url(rel):
- media = rel
- else:
- mp4 = ROOT / rel.lstrip("/")
- if not rel or not mp4.exists():
- return None, "无视频", "", ""
- use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
- try:
- media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
- finally:
- if use != mp4:
- try:
- use.unlink()
- except Exception:
- pass
- messages = [{"role": "system", "content": load_prompt("classify_video")},
- {"role": "user", "content": [{"type": "text", "text": "判断这条视频是不是创作知识。"},
- {"type": "video_url", "video_url": {"url": media}}]}]
- return _judge(messages, settings, timeout=300)
- def _safe(fn, *a) -> tuple:
- try:
- return fn(*a)
- except Exception as exc:
- return None, f"判定失败: {str(exc)[:60]}", "", ""
- def main() -> None:
- settings = Settings.from_env()
- conn = store.connect()
- platforms = sys.argv[1:] or PLATFORMS
- posts = store.posts_to_classify(conn, platforms)
- imgs = [p for p in posts if p["platform"] != "douyin"]
- vids = [p for p in posts if p["platform"] == "douyin"]
- total = len(posts)
- print(f"收紧重判:图文 {len(imgs)}(并发{IMG_WORKERS})+ 抖音视频 {len(vids)}(并发{VID_WORKERS},大视频先压)")
- ts = int(time.time())
- done = {"n": 0, "fail": 0}
- def _write(p, res):
- ic, reason, knowledge, points = res
- if ic is None:
- done["fail"] += 1
- else:
- store.upsert_class(conn, p["url"], ic, reason, ts, knowledge, points)
- done["n"] += 1
- if done["n"] % 20 == 0:
- print(f" {done['n']}/{total}(失败 {done['fail']})")
- with cf.ThreadPoolExecutor(IMG_WORKERS) as ex:
- futs = {ex.submit(_safe, classify_imgtext, p, settings): p for p in imgs}
- for fut in cf.as_completed(futs):
- _write(futs[fut], fut.result())
- with cf.ThreadPoolExecutor(VID_WORKERS) as ex:
- futs = {ex.submit(_safe, classify_video, p, settings): p for p in vids}
- for fut in cf.as_completed(futs):
- _write(futs[fut], fut.result())
- c = store.class_counts(conn)
- conn.close()
- print(f"完成:创作知识 {c['creation']} / 非创作知识 {c['non_creation']}(本轮失败 {done['fail']},可重跑补判)")
- if __name__ == "__main__":
- main()
|