Add Cloud Tour Libo knowledge graph platform

This commit is contained in:
2026-07-30 10:08:04 +08:00
parent 9e05b09a38
commit bae5197d62
103 changed files with 14036 additions and 160 deletions

View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""单独拉取抖音数据的运行入口。
这个文件不重写采集逻辑,而是复用系统原版:
app/agents/douyin_agent.py
常用命令:
python3 scripts/douyin_login.py
python3 scripts/douyin_fetch_data.py --name "某某酒店" --city "贵阳"
python3 scripts/douyin_fetch_data.py --input hotels.txt --city "贵阳" --save-evidence
输出:
默认写到 data/douyin_probe/fetch_<时间>.json
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.agents.douyin_agent import ( # noqa: E402
_collect,
_parse_dy_comments,
_parse_dy_notes,
)
DEFAULT_OUT_DIR = ROOT / "data" / "douyin_probe"
def _ts() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _read_names(path: str | None) -> list[str]:
if not path:
return []
names: list[str] = []
for line in Path(path).read_text(encoding="utf-8").splitlines():
value = line.strip()
if value and not value.startswith("#"):
names.append(value)
return names
def _dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
deduped: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
for record in records:
key = (
str(record.get("platform") or ""),
str(record.get("kind") or ""),
str(record.get("source_id") or record.get("url") or ""),
)
if key in seen:
continue
seen.add(key)
deduped.append(record)
return deduped
def _record_for_csv(record: dict[str, Any]) -> dict[str, Any]:
fields = [
"platform",
"kind",
"source_id",
"url",
"entity_name",
"place_natural_key",
"keyword",
"title",
"content",
"author",
"author_id",
"likes",
"comments",
"collects",
"shares",
"publish_time",
"location",
]
return {field: record.get(field, "") for field in fields}
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
for record in records:
file.write(json.dumps(record, ensure_ascii=False) + "\n")
def _write_csv(path: Path, records: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
rows = [_record_for_csv(record) for record in records]
fieldnames = list(rows[0].keys()) if rows else list(_record_for_csv({}).keys())
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def _build_keyword(name: str, city: str, suffix: str, keyword: str | None) -> str:
if keyword:
return keyword.strip()
parts = [city.strip(), name.strip(), suffix.strip()]
return " ".join(part for part in parts if part)
def _make_tab_picker(preferred_tab: str):
def pick(labels: list[str]) -> str | None:
if not labels:
return None
if preferred_tab and preferred_tab in labels:
return preferred_tab
if "综合" in labels:
return "综合"
return labels[0]
return pick
def fetch_one(
*,
name: str,
city: str,
suffix: str,
keyword: str | None,
tab: str,
deep: bool,
place_key: str | None,
) -> dict[str, Any]:
query = _build_keyword(name, city, suffix, keyword)
print(f"[douyin] 开始采集: name={name} keyword={query}")
result = _collect(query, _make_tab_picker(tab), deep=deep)
if result.get("logged_in") is False:
return {
"ok": False,
"need_login": True,
"name": name,
"keyword": query,
"summary": "抖音未登录。请先运行 python3 scripts/douyin_login.py 完成一次登录。",
"notes": [],
"comments": [],
"records": [],
"raw_api_count": 0,
"api_url_count": result.get("api_url_count", 0),
"tabs": result.get("tabs", []),
}
if result.get("error"):
return {
"ok": False,
"need_login": False,
"name": name,
"keyword": query,
"summary": f"采集异常: {result.get('error')}",
"notes": [],
"comments": [],
"records": [],
"raw_api_count": 0,
"api_url_count": result.get("api_url_count", 0),
"tabs": result.get("tabs", []),
}
raw_api = result.get("raw_api") or []
notes = _parse_dy_notes(raw_api, name, query)
comments = _parse_dy_comments(raw_api, name, query)
records = _dedupe_records(notes + comments)
for record in records:
if place_key:
record["place_natural_key"] = place_key
return {
"ok": True,
"need_login": False,
"name": name,
"keyword": query,
"summary": f"视频 {len(notes)} 条,评论 {len(comments)} 条,去重后 {len(records)} 条。",
"notes": notes,
"comments": comments,
"records": records,
"raw_api_count": len(raw_api),
"api_url_count": result.get("api_url_count", 0),
"tabs": result.get("tabs", []),
}
async def _save_evidence(records: list[dict[str, Any]]) -> int:
if not records:
return 0
from app.db import close_pool, init_pool, sa_save_evidence
await init_pool()
try:
return await sa_save_evidence(records)
finally:
await close_pool()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="按酒店/关键词单独获取抖音视频与评论数据")
parser.add_argument("--name", help="单个酒店/地点名称")
parser.add_argument("--input", help="批量名称文件,每行一个酒店/地点名称")
parser.add_argument("--city", default="贵阳", help="城市前缀,默认: 贵阳")
parser.add_argument("--suffix", default="", help="附加搜索词,例如: 酒店 入住 评价")
parser.add_argument("--keyword", help="完整搜索关键词;传入后会忽略 --city/--name/--suffix 拼接")
parser.add_argument("--tab", default="综合", help="优先选择的抖音搜索 tab默认: 综合")
parser.add_argument("--place-key", help="写入记录的 place_natural_key单名称时可用")
parser.add_argument("--no-deep", action="store_true", help="只采搜索页,不进入视频详情深采评论")
parser.add_argument("--save-evidence", action="store_true", help="把 records 写入系统 Evidence 表")
parser.add_argument("--out", help="JSON 输出路径")
parser.add_argument("--jsonl-out", help="JSONL records 输出路径")
parser.add_argument("--csv-out", help="CSV records 输出路径")
return parser.parse_args()
def main() -> None:
args = parse_args()
names = []
if args.name:
names.append(args.name.strip())
names.extend(_read_names(args.input))
names = [name for name in names if name]
if args.keyword and not names:
names = [args.keyword.strip()]
if not names:
raise SystemExit("请提供 --name、--input 或 --keyword")
if args.keyword and len(names) > 1:
raise SystemExit("--keyword 是完整单次搜索词,不能和多名称 --input 同时使用")
if args.place_key and len(names) > 1:
raise SystemExit("--place-key 只适合单个 --name 使用,批量请不要传")
all_results: list[dict[str, Any]] = []
all_records: list[dict[str, Any]] = []
for name in names:
result = fetch_one(
name=name,
city=args.city,
suffix=args.suffix,
keyword=args.keyword,
tab=args.tab,
deep=not args.no_deep,
place_key=args.place_key,
)
print(f"[douyin] {result['summary']}")
all_results.append(result)
all_records.extend(result.get("records") or [])
all_records = _dedupe_records(all_records)
saved = 0
if args.save_evidence:
saved = asyncio.run(_save_evidence(all_records))
print(f"[douyin] 已写入 Evidence: {saved}")
out_dir = DEFAULT_OUT_DIR
out_path = Path(args.out) if args.out else out_dir / f"fetch_{_ts()}.json"
jsonl_path = Path(args.jsonl_out) if args.jsonl_out else out_path.with_suffix(".jsonl")
csv_path = Path(args.csv_out) if args.csv_out else out_path.with_suffix(".csv")
payload = {
"ok": all(result.get("ok") for result in all_results),
"need_login": any(result.get("need_login") for result in all_results),
"saved_evidence": saved,
"total_records": len(all_records),
"results": all_results,
}
_write_json(out_path, payload)
_write_jsonl(jsonl_path, all_records)
_write_csv(csv_path, all_records)
print(f"[douyin] JSON: {out_path}")
print(f"[douyin] JSONL: {jsonl_path}")
print(f"[douyin] CSV: {csv_path}")
if __name__ == "__main__":
os.chdir(ROOT)
main()