feat: consolidate legacy workflows into gyxx-flow

This commit is contained in:
wangyunlong committed 2026-07-28 14:51:15 +08:00
commit c23b62a8c8
374 files changed
+132990

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
"""Business-module contracts, registry, and default composition root."""
from __future__ import annotations
from typing import Any
from .content_marketing import ContentMarketingModule
from .contracts import BusinessModule
from .product_commerce import ProductCommerceModule
from .registry import ModuleRegistry
from .shop_intelligence import ShopIntelligenceModule
from .supply_chain import SupplyChainModule
EXPECTED_MODULE_IDS = (
"content_marketing",
"product_commerce",
"shop_intelligence",
"supply_chain",
)
def create_default_registry(
*,
catalog: Any | None = None,
shop_command_factory: Any | None = None,
supply_command_factory: Any | None = None,
content_command_factory: Any | None = None,
product_command_factory: Any | None = None,
) -> ModuleRegistry:
"""Compose built-in modules, registering only explicitly supplied migrations."""
shop_module = (
ShopIntelligenceModule()
if catalog is None
else ShopIntelligenceModule.from_catalog(
catalog,
command_factory=shop_command_factory,
)
)
supply_module = (
SupplyChainModule()
if catalog is None
else SupplyChainModule.from_catalog(
catalog, command_factory=supply_command_factory
)
)
content_module = (
ContentMarketingModule()
if catalog is None
else ContentMarketingModule.from_catalog(
catalog, command_factory=content_command_factory
)
)
product_module = (
ProductCommerceModule()
if catalog is None
else ProductCommerceModule.from_catalog(
catalog, command_factory=product_command_factory
)
)
return ModuleRegistry(
(
content_module,
product_module,
shop_module,
supply_module,
)
)
__all__ = [
"BusinessModule",
"EXPECTED_MODULE_IDS",
"ModuleRegistry",
"create_default_registry",
]
@@ -0,0 +1,103 @@
"""Content metrics, creator, comment, report, and login workflows."""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from gyxx_flow.adapters.native import DeferredModuleCommandStep, ModuleCommandFactory
from gyxx_flow.workflow import StepDefinition, WorkflowDefinition
CONTENT_SCHEDULED_WORKFLOW_IDS = (
"content.metrics.daily",
"content.marketing_report.daily",
"content.relogin.weekly",
"content.self_operated.weekly",
"content.creator_report.monthly",
"content.summary.monthly",
"content.cooperations.daily",
"content.comments.weekly",
"content.summary.weekly",
)
CONTENT_MANUAL_WORKFLOW_IDS = (
"content.mapping.refresh",
"content.retry_failed",
"content.metrics.backfill",
)
CONTENT_WORKFLOW_IDS = (*CONTENT_SCHEDULED_WORKFLOW_IDS, *CONTENT_MANUAL_WORKFLOW_IDS)
CONTENT_TIMEOUT_SECONDS = 4 * 60 * 60
CONTENT_RESOURCE = "module:content_marketing"
_RELOGIN_WORKFLOW_ID = "content.relogin.weekly"
class ContentMarketingModule:
"""Content-marketing business module composition boundary."""
module_id = "content_marketing"
def __init__(self, definitions: Iterable[WorkflowDefinition] = ()) -> None:
self._definitions = tuple(definitions)
@classmethod
def from_catalog(
cls,
catalog: Any,
*,
command_factory: ModuleCommandFactory | None = None,
) -> "ContentMarketingModule":
"""Build the scheduled content workflows from validated catalog metadata.
Each launcher resolves from source owned by this installed module.
Registration remains inert and dry-run never starts a subprocess.
"""
entries = {
entry.workflow_id: entry
for entry in catalog.workflows
if entry.module == cls.module_id and entry.trigger in {"scheduled", "manual"}
}
expected = set(CONTENT_WORKFLOW_IDS)
if set(entries) != expected:
raise ValueError(
"content catalog workflows do not match the migration contract: "
f"expected={sorted(expected)}, actual={sorted(entries)}"
)
definitions: list[WorkflowDefinition] = []
for workflow_id in CONTENT_WORKFLOW_IDS:
entry = entries[workflow_id]
action = (
DeferredModuleCommandStep(entry)
if command_factory is None
else DeferredModuleCommandStep(entry, command_factory=command_factory)
)
definitions.append(
WorkflowDefinition(
workflow_id,
(
StepDefinition(
"module_run",
action,
timeout_seconds=CONTENT_TIMEOUT_SECONDS,
max_attempts=1,
resources=(CONTENT_RESOURCE,),
production_sink=True,
official_notification=workflow_id
== _RELOGIN_WORKFLOW_ID,
),
),
)
)
return cls(definitions)
def workflow_definitions(self) -> tuple[WorkflowDefinition, ...]:
return self._definitions
__all__ = [
"CONTENT_WORKFLOW_IDS",
"CONTENT_MANUAL_WORKFLOW_IDS",
"CONTENT_SCHEDULED_WORKFLOW_IDS",
"CONTENT_RESOURCE",
"CONTENT_TIMEOUT_SECONDS",
"ContentMarketingModule",
]
@@ -0,0 +1,500 @@
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from typing import Any
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from playwright.sync_api import Page
from scrapling.fetchers import DynamicSession
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
STATE_DIR = PATHS.state_root
NOTES_DIR = PATHS.raw_root / "notes" / "bilibili"
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
BV_RE = re.compile(r"BV[0-9A-Za-z]{10}")
AV_RE = re.compile(r"(?:av|aid=)(\d+)", re.IGNORECASE)
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def load_cookies() -> list[dict[str, Any]]:
if not COOKIE_FILE.exists():
return []
try:
data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except json.JSONDecodeError:
log(f"Cookie file is invalid, ignoring: {COOKIE_FILE}")
return []
def restore_cookies(page: Page) -> None:
cookies = load_cookies()
if not cookies:
return
try:
page.context.add_cookies(cookies)
log(f"Loaded cookies: {COOKIE_FILE}")
except Exception as exc:
log(f"Failed to load cookies, will continue with browser profile: {exc}")
def save_state(page: Page) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
COOKIE_FILE.write_text(
json.dumps(page.context.cookies(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
PATHS.browser_storage_state_file.write_text(
json.dumps(page.context.storage_state(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
log(f"Saved cookies: {COOKIE_FILE}")
def is_logged_in(page: Page) -> bool:
"""B 站登录态靠 SESSDATA cookie,有它才能调评论 API 拿全量。
没有 SESSDATA 时 API 只返回热门 3-5 条。
"""
for c in page.context.cookies():
if c.get("name") == "SESSDATA" and c.get("value"):
return True
return False
def maybe_wait_for_login(page: Page, login_timeout: int) -> None:
"""检测登录态,未登录就等用户扫码。B 站登录页 URL 含 passport.bilibili.com/login。"""
if is_logged_in(page):
return
log("B 站未登录 (无 SESSDATA cookie)。请在弹出的浏览器窗口扫码登录。")
# 跳到登录页,确保用户能看到二维码
try:
page.goto("https://passport.bilibili.com/login", wait_until="domcontentloaded", timeout=30000)
except Exception as exc:
log(f" 跳登录页失败 (继续等当前页扫码): {exc}")
deadline = time.time() + login_timeout
while time.time() < deadline:
if is_logged_in(page):
log("登录成功,SESSDATA 已写入")
save_state(page)
return
page.wait_for_timeout(2000)
log(f" [WARN] 等待 {login_timeout}s 仍未登录,继续以游客态采集 (评论只会拿到热门 3-5 条)")
def safe_filename(value: str) -> str:
value = re.sub(r"[<>:\"/\\\\|?*\\s]+", "_", value).strip("_")
return value[:90] or "bilibili_comments"
def extract_bvid(url: str) -> str:
match = BV_RE.search(url)
return match.group(0) if match else ""
def extract_aid(url: str) -> str:
match = AV_RE.search(url)
return match.group(1) if match else ""
def ts_to_iso(value: Any) -> str:
if not isinstance(value, (int, float)) or not value:
return ""
try:
return datetime.fromtimestamp(value).isoformat(timespec="seconds")
except Exception:
return str(value)
def with_query_params(url: str, updates: dict[str, Any]) -> str:
parts = urlsplit(url)
params = dict((key, values[-1]) for key, values in parse_qs(parts.query, keep_blank_values=True).items())
for key, value in updates.items():
params[key] = str(value)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(params), parts.fragment))
def parse_reply(reply: dict[str, Any], source_url: str, level: str, parent_rpid: str = "") -> dict[str, Any]:
member = reply.get("member") or {}
content = reply.get("content") or {}
return {
"source_url": source_url,
"level": level,
"rpid": str(reply.get("rpid") or ""),
"parent_rpid": str(parent_rpid or reply.get("parent") or ""),
"root_rpid": str(reply.get("root") or ""),
"mid": str(member.get("mid") or reply.get("mid") or ""),
"nickname": member.get("uname") or "",
"sex": member.get("sex") or "",
"message": content.get("message") or "",
"like_count": reply.get("like") or 0,
"reply_count": reply.get("rcount") or 0,
"location": reply.get("reply_control", {}).get("location") or "",
"created_at": ts_to_iso(reply.get("ctime")),
"raw_ctime": reply.get("ctime") or "",
}
def merge_reply(row: dict[str, Any], comments_by_id: dict[str, dict[str, Any]]) -> None:
key = row.get("rpid") or f"{row.get('level')}:{len(comments_by_id)}"
comments_by_id[str(key)] = row
def collect_main_payload(payload: dict[str, Any], source_url: str, comments_by_id: dict[str, dict[str, Any]]) -> None:
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
replies = data.get("replies") or []
if not isinstance(replies, list):
return
for reply in replies:
if not isinstance(reply, dict):
continue
row = parse_reply(reply, source_url, "comment")
merge_reply(row, comments_by_id)
child_replies = reply.get("replies") or []
if isinstance(child_replies, list):
for child in child_replies:
if isinstance(child, dict):
child_row = parse_reply(child, source_url, "reply", row.get("rpid") or "")
merge_reply(child_row, comments_by_id)
def collect_reply_payload(payload: dict[str, Any], source_url: str, parent_rpid: str, comments_by_id: dict[str, dict[str, Any]]) -> None:
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
replies = data.get("replies") or []
if not isinstance(replies, list):
return
for reply in replies:
if isinstance(reply, dict):
row = parse_reply(reply, source_url, "reply", parent_rpid)
merge_reply(row, comments_by_id)
def api_get(page: Page, url: str) -> dict[str, Any]:
return page.evaluate(
"""
async (url) => {
const res = await fetch(url, {
credentials: 'include',
headers: { 'accept': 'application/json, text/plain, */*' }
});
return await res.json();
}
""",
url,
)
def resolve_video_info(page: Page, url: str) -> dict[str, Any]:
bvid = extract_bvid(url)
aid = extract_aid(url)
if not bvid and not aid:
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(3000)
bvid = extract_bvid(page.url) or extract_bvid(page.locator("body").inner_text(timeout=5000))
aid = extract_aid(page.url)
params = {}
if bvid:
params["bvid"] = bvid
elif aid:
params["aid"] = aid
else:
raise RuntimeError("Could not find BV or AV id from URL.")
api_url = "https://api.bilibili.com/x/web-interface/view?" + urlencode(params)
payload = api_get(page, api_url)
if payload.get("code") != 0:
raise RuntimeError(f"Failed to resolve video info: {payload}")
data = payload.get("data") or {}
stat = data.get("stat") or {}
return {
"aid": str(data.get("aid") or aid),
"bvid": data.get("bvid") or bvid,
"title": data.get("title") or "",
"owner_mid": str((data.get("owner") or {}).get("mid") or ""),
"owner_name": (data.get("owner") or {}).get("name") or "",
"note_metrics": {
"like_count": stat.get("like", ""),
"favorite_count": stat.get("favorite", ""),
"share_count": stat.get("share", ""),
"comment_count": stat.get("reply", ""),
},
}
def fetch_main_comments(
page: Page,
source_url: str,
oid: str,
comments_by_id: dict[str, dict[str, Any]],
max_pages: int,
delay: float,
) -> dict[str, Any]:
stats = {"api_hits": 0, "api_total": None, "last_page": 0}
page_num = 1
page_size = 20
while page_num <= max_pages:
api_url = (
"https://api.bilibili.com/x/v2/reply?"
+ urlencode({"type": 1, "oid": oid, "pn": page_num, "ps": page_size, "sort": 2})
)
payload = api_get(page, api_url)
stats["api_hits"] += 1
if payload.get("code") != 0:
log(f"Main comment API returned code={payload.get('code')}: {payload.get('message')}")
break
data = payload.get("data") or {}
page_info = data.get("page") or {}
stats["api_total"] = page_info.get("count", stats.get("api_total"))
stats["last_page"] = page_num
before = len(comments_by_id)
collect_main_payload(payload, source_url, comments_by_id)
loaded = len(comments_by_id) - before
log(f"Main page {page_num}: +{loaded}, total {len(comments_by_id)}")
replies = data.get("replies") or []
if not replies:
break
if page_info.get("count") and len([r for r in comments_by_id.values() if r.get("level") == "comment"]) >= int(page_info["count"]):
break
page_num += 1
if delay:
page.wait_for_timeout(int(delay * 1000))
return stats
def fetch_sub_replies(
page: Page,
source_url: str,
oid: str,
comments_by_id: dict[str, dict[str, Any]],
delay: float,
max_reply_pages: int,
) -> int:
parents = [
row for row in list(comments_by_id.values())
if row.get("level") == "comment" and int(row.get("reply_count") or 0) > 0
]
if not parents:
return 0
log(f"Fetching sub replies for {len(parents)} comments.")
merged = 0
for parent in parents:
root = parent.get("rpid")
if not root:
continue
pn = 1
ps = 20
while pn <= max_reply_pages:
api_url = (
"https://api.bilibili.com/x/v2/reply/reply?"
+ urlencode({"type": 1, "oid": oid, "root": root, "pn": pn, "ps": ps})
)
payload = api_get(page, api_url)
if payload.get("code") != 0:
break
data = payload.get("data") or {}
before = len(comments_by_id)
collect_reply_payload(payload, source_url, str(root), comments_by_id)
merged += max(0, len(comments_by_id) - before)
replies = data.get("replies") or []
page_info = data.get("page") or {}
if not replies:
break
if page_info.get("count") and pn * ps >= int(page_info["count"]):
break
pn += 1
if delay:
page.wait_for_timeout(int(delay * 1000))
log(f"Sub replies merged: {merged}; total {len(comments_by_id)}")
return merged
def scrape_comments(
url: str,
max_pages: int,
delay: float,
headless: bool,
max_reply_pages: int = 200,
login_timeout: int = 0,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
result: dict[str, Any] = {"comments": [], "stats": {}, "video": {}}
def action(page: Page) -> None:
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(2500)
save_state(page)
# 登录检测:未登录时评论 API 只返回热门 3-5 条
if login_timeout > 0:
maybe_wait_for_login(page, login_timeout)
# 登录后回到视频页(可能在登录页)
if not page.url.startswith("https://www.bilibili.com/video/"):
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(2000)
video = resolve_video_info(page, url)
oid = video["aid"]
comments_by_id: dict[str, dict[str, Any]] = {}
stats = fetch_main_comments(page, url, oid, comments_by_id, max_pages=max_pages, delay=delay)
sub_count = fetch_sub_replies(page, url, oid, comments_by_id, delay=delay, max_reply_pages=max_reply_pages)
save_state(page)
stats["sub_replies_merged"] = sub_count
stats["max_reply_pages"] = max_reply_pages
stats["top_level_count"] = len([row for row in comments_by_id.values() if row.get("level") == "comment"])
stats["reply_count"] = len([row for row in comments_by_id.values() if row.get("level") == "reply"])
stats["note_metrics"] = video.get("note_metrics", {})
stats["logged_in"] = is_logged_in(page)
result["comments"] = list(comments_by_id.values())
result["stats"] = stats
result["video"] = video
result["final_url"] = page.url
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch(url, page_action=action, wait=1000)
return result["comments"], result
def write_outputs(url: str, comments: list[dict[str, Any]], result: dict[str, Any]) -> tuple[Path, Path]:
NOTES_DIR.mkdir(parents=True, exist_ok=True)
bvid = result.get("video", {}).get("bvid") or extract_bvid(url) or safe_filename(url)
json_path = NOTES_DIR / f"bilibili_comments_{bvid}.json"
csv_path = NOTES_DIR / f"bilibili_comments_{bvid}.csv"
if not comments and json_path.exists() and csv_path.exists():
log(f"No comments scraped; keeping existing output files: {json_path}, {csv_path}")
return json_path, csv_path
payload = {
"source_url": url,
"title": result.get("video", {}).get("title", ""),
"final_url": result.get("final_url", ""),
"video": result.get("video", {}),
"scraped_at": datetime.now().isoformat(timespec="seconds"),
"count": len(comments),
"stats": result.get("stats", {}),
"comments": comments,
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
fieldnames = [
"source_url",
"level",
"rpid",
"parent_rpid",
"root_rpid",
"mid",
"nickname",
"sex",
"message",
"like_count",
"reply_count",
"location",
"created_at",
"raw_ctime",
]
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(comments)
return json_path, csv_path
def login_only(login_timeout: int) -> int:
"""只触发登录(headed),登录完成立刻退出。给 relogin_bilibili.py 用。"""
def action(page: Page) -> None:
page.goto("https://passport.bilibili.com/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(1500)
maybe_wait_for_login(page, login_timeout)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=False,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch("https://passport.bilibili.com/login", page_action=action, wait=1000)
log("[login-only] 完成")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape Bilibili video comments with a real browser and API.")
parser.add_argument("url", help="Bilibili video URL (传 dummy 'login' 配合 --login-only 也可)")
parser.add_argument("--max-reply-pages", type=int, default=200, help="max sub-reply pages per top-level comment")
parser.add_argument("--max-pages", type=int, default=200, help="最多抓取一级评论页数")
parser.add_argument("--delay", type=float, default=0.25, help="接口请求间隔秒数")
parser.add_argument("--headless", action="store_true", help="无头模式")
parser.add_argument("--login-timeout", type=int, default=0,
help="未登录时等待扫码秒数(>0 启用登录检测;默认 0 沿用游客态)")
parser.add_argument("--login-only", action="store_true",
help="只触发登录(headed)然后退出,给 relogin_bilibili 用")
args = parser.parse_args()
if args.login_only:
return login_only(args.login_timeout if args.login_timeout > 0 else 300)
comments, result = scrape_comments(
args.url, args.max_pages, args.delay, args.headless,
args.max_reply_pages, args.login_timeout,
)
json_path, csv_path = write_outputs(args.url, comments, result)
log(f"Final URL: {result.get('final_url', '')}")
log(f"Video: {result.get('video', {})}")
log(f"Stats: {result.get('stats', {})}")
log(f"Scraped {len(comments)} comments")
log(f"JSON: {json_path}")
log(f"CSV: {csv_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,854 @@
"""
B 站抓取脚本: 多维表格驱动,按"发布时间到今天的天数差"自动选 7/14/21/28/月槽位
核心规则:
- 槽位由 (今天 - 发布时间) 的天数差决定:
0-7 天 → 7天曝光量
8-14 天 → 14天曝光量
15-21 天 → 21天曝光量
22-28 天 → 28天曝光量
29+ 天 → 月底曝光量
- 同槽覆盖语义: 同一槽多次跑会更新为最新播放量
- 抓取: B 站公开 API (api.bilibili.com/x/web-interface/view) 拿 stat.view
不用登录,不用 cookie
- 字段 ID 全部从 款式_多维表格_对照.json 的 field_map 动态读取
- 平台枚举: B站 / b站 / B 站 都接受 (大小写无关)
用法:
# 跑全 24 款式 (默认)
python bilibili_scraper.py
# 试跑不写
python bilibili_scraper.py --dry-run
# 只跑某个款式
python bilibili_scraper.py --style 1
# 只跑某几条
python bilibili_scraper.py --record recXXXXX
# 模拟任意日期 (调试用)
python bilibili_scraper.py --today 2025-08-15 --dry-run
# 防风控:每条请求间隔 0.5s
python bilibili_scraper.py --delay 0.5
# 首次回填: 所有 B 站记录全部填 7天曝光量 (忽略发布时间)
python bilibili_scraper.py --first-run
# 重置运行状态
python bilibili_scraper.py --reset-state
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime, date
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
PATHS,
resolve_layer_output,
)
import requests
from gyxx_flow.modules.content_marketing.runtime.collection_completeness import (
add_repeatable_style_argument,
atomic_write_json,
merge_global_summaries,
select_requested_styles,
)
BASE_DIR = PATHS.module_root
DEFAULT_DATA_DIR = PATHS.normalized_root
V2_DIR = PATHS.normalized_root / "v2_results"
BILIBILI_CHECKPOINT_DIR = PATHS.state_root / "checkpoints" / "bilibili"
MAPPING_PATH = PATHS.normalized_root / "mappings" / "款式_多维表格_对照.json"
STATE_FILENAME = "weekly_run_state.json"
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
# Scheduled tasks run as SYSTEM; %APPDATA% is not Administrator's dir
LARK_CLI = "lark-cli.cmd"
# 字段 ID 全部从 data/config/款式_多维表格_对照.json 的 field_map 动态读
# 下面是 logical key(各款式表结构一致)
KEY_PLATFORM = "platform" # 投放平台 (select)
KEY_URL = "note_url" # 发布笔记链接 (text)
KEY_TITLE = "note_title" # 发布笔记标题 (text)
KEY_CREATOR = "creator_name" # 达人名称 (text)
KEY_CREATOR_ID = "creator_id" # 达人id (text,选填)
KEY_PUBTIME = "publish_time" # 发布时间 (datetime)
KEY_MONTH = "month" # 月份 (select)
KEY_PARENT = "parent_record" # 父记录 (link,自关联)
KEY_WEEK = "publish_week" # 周发布 (select)
# 5 档曝光量槽位: 7/14/21/28 天 + 月底(第 5 周用) - logical name -> 天数
SLOTS = [
("read_count_7d", 7),
("read_count_14d", 14),
("read_count_21d", 21),
("read_count_28d", 28),
("month_end", "月底"),
]
# 月份枚举(选项里已有)
MONTH_OPTIONS = ["6月", "7月", "8月", "9月", "10月"]
# B 站 API 相关
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
_BV_RE = re.compile(r"BV[0-9A-Za-z]{10}")
_AV_RE = re.compile(r"av(\d+)", re.IGNORECASE)
_B23_RE = re.compile(r"https?://b23\.tv/\S+", re.IGNORECASE)
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_URL_RE = re.compile(r"https?://[^\s)>\]]+")
def log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{datetime.now().strftime('%H:%M:%S')}] {safe}", flush=True)
def finalize_bili_summary(summary: dict, *, dry_run: bool = False) -> dict:
"""Attach one terminal state per Bilibili record and exact completeness counters."""
out = dict(summary)
unique: list[dict] = []
positions: dict[str, int] = {}
duplicates = 0
for raw in out.get("details") or []:
if not isinstance(raw, dict):
continue
row = dict(raw)
rid = str(row.get("record_id") or "")
if rid and rid in positions:
unique[positions[rid]] = row
duplicates += 1
else:
if rid:
positions[rid] = len(unique)
unique.append(row)
total = int(out.get("total_b_records") or 0)
success = sum(row.get("status") == "success" for row in unique)
blocked = sum(row.get("status") == "blocked_input" for row in unique)
retryable = sum(row.get("status") == "retryable_failure" for row in unique)
write_failures = sum(row.get("status") == "write_failure" for row in unique)
missing = max(0, total - len(unique))
unresolved = retryable + write_failures + missing
top_level_error = bool(out.get("error"))
if top_level_error:
retryable = max(retryable, 1)
unresolved = max(unresolved, 1)
out.update({
"details": unique,
"updated": success,
"skipped": blocked + retryable + write_failures,
"success": success,
"blocked_input": blocked,
"retryable_failures": retryable,
"write_failures": write_failures,
"missing_results": missing,
"duplicate_results": duplicates,
"unresolved": unresolved,
"complete": (
not top_level_error
and unresolved == 0
and duplicates == 0
and len(unique) == total
),
"dry_run": bool(dry_run),
})
return out
def merge_bili_summary(existing: dict | None, partial: dict, *, dry_run: bool = False) -> dict:
"""Merge a --record retry without deleting previously successful rows."""
if not existing:
return finalize_bili_summary(partial, dry_run=dry_run)
combined = dict(existing)
for key, value in partial.items():
if key != "details":
combined[key] = value
rows: list[dict] = []
positions: dict[str, int] = {}
for source in (existing.get("details") or [], partial.get("details") or []):
for raw in source:
if not isinstance(raw, dict):
continue
row = dict(raw)
rid = str(row.get("record_id") or "")
if rid and rid in positions:
rows[positions[rid]] = row
else:
if rid:
positions[rid] = len(rows)
rows.append(row)
combined["details"] = rows
combined["total_b_records"] = max(
int(existing.get("total_b_records") or 0),
int(partial.get("total_b_records") or 0),
len(rows),
)
return finalize_bili_summary(combined, dry_run=dry_run)
# ============================================================
# 飞书多维表格
# ============================================================
def call_lark_json(args: list[str]) -> dict:
cmd = [LARK_CLI] + args
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(
cmd, cwd=PATHS.tmp_root, capture_output=True, env=env,
encoding="utf-8", errors="replace",
)
text = proc.stdout
start = text.find("{")
if start < 0:
return {"ok": False, "error": f"no json: {text[:300]}"}
try:
return json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
return {"ok": False, "error": f"json decode: {e}"}
def load_mapping(data_dir: Path, self_operated: bool = False) -> dict:
# 动态:从「合作达人」/「自营达人」多维表格地址表读取款式→各表地址,实时拉字段重建对照表
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
return feishu_mapping.load_mapping(None, self_operated=self_operated)
def list_all_records(base_token: str, table_id: str) -> list[dict]:
"""拉一张表所有记录,自动分页"""
all_records = []
offset = 0
while True:
resp = call_lark_json([
"base", "+record-list",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--limit", "200",
"--offset", str(offset),
])
if not resp.get("ok"):
raise RuntimeError(f"record-list 失败: {resp.get('error')}")
d = resp["data"]
ids = d["field_id_list"]
rids = d["record_id_list"]
rows = d["data"]
for i, row in enumerate(rows):
rec = {"record_id": rids[i]}
for j, fid in enumerate(ids):
rec[fid] = row[j] if j < len(row) else None
all_records.append(rec)
offset += len(rows)
if not d.get("has_more", False) or len(rows) == 0:
break
return all_records
def _write_payload_to_cwd(payload: dict, name: str = "_bili_payload.json") -> str:
"""把 payload 写到当前工作目录,返回相对路径名(lark-cli --json @file 必须是 cwd 相对路径名,不是绝对路径)"""
payload_file = PATHS.tmp_root / name
payload_file.parent.mkdir(parents=True, exist_ok=True)
payload_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return name # 只返回文件名,而不是绝对路径
def write_record(base_token: str, table_id: str, record_id: str,
fields: dict, dry_run: bool) -> bool:
"""更新一条已有记录"""
if dry_run:
log(f" [DRY-RUN] 写入: {json.dumps(fields, ensure_ascii=False)[:200]}")
return True
pf = _write_payload_to_cwd(fields)
cmd = [LARK_CLI, "base", "+record-upsert",
"--base-token", base_token,
"--table-id", table_id,
"--record-id", record_id,
"--as", "user",
"--format", "json",
"--json", f"@{pf}"]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(
cmd, cwd=PATHS.tmp_root, capture_output=True, env=env,
encoding="utf-8", errors="replace",
)
text = proc.stdout
if proc.stderr:
# lark-cli 在 stderr 也会输出,真实响应可能在 stderr
if "ok" in proc.stderr and (text == "" or not text.lstrip().startswith("{")):
text = proc.stderr
start = text.find("{")
if start < 0:
log(f" [ERROR] 写记录失败: no json. stdout={proc.stdout[:200]} stderr={proc.stderr[:200]}")
return False
try:
resp = json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
log(f" [ERROR] 写记录失败: json decode {e}. text={text[:200]}")
return False
if not resp.get("ok"):
log(f" [ERROR] 写记录失败: {resp.get('error')}")
return False
return True
def create_record(base_token: str, table_id: str,
fields: dict, dry_run: bool) -> str | None:
"""创建新记录,返回 record_id"""
if dry_run:
log(f" [DRY-RUN] 新建: {json.dumps(fields, ensure_ascii=False)[:200]}")
return "rec_dryrun"
pf = _write_payload_to_cwd(fields)
cmd = [LARK_CLI, "base", "+record-upsert",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--json", f"@{pf}"]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(
cmd, cwd=PATHS.tmp_root, capture_output=True, env=env,
encoding="utf-8", errors="replace",
)
text = proc.stdout
if proc.stderr:
if "ok" in proc.stderr and (text == "" or not text.lstrip().startswith("{")):
text = proc.stderr
start = text.find("{")
if start < 0:
log(f" [ERROR] 创建记录失败: no json. stderr={proc.stderr[:200]}")
return None
try:
resp = json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
log(f" [ERROR] 创建记录失败: json decode {e}. text={text[:200]}")
return None
if not resp.get("ok"):
log(f" [ERROR] 创建记录失败: {resp.get('error')}")
return None
rids = resp.get("data", {}).get("record", {}).get("record_id_list", [])
return rids[0] if rids else None
# ============================================================
# B 站播放量抓取
# ============================================================
def extract_url(text) -> str | None:
"""从 note_url 字段里抽真实 URL,处理 Markdown / 纯文本 / 短链"""
if not text:
return None
if isinstance(text, list):
text = text[0] if text else None
if not text:
return None
s = str(text).strip()
m = _MD_LINK_RE.search(s)
if m:
return m.group(2).strip()
m = _URL_RE.search(s)
if m:
return m.group(0).strip()
return None
def resolve_b23(url: str, session: requests.Session) -> str | None:
try:
r = session.get(url, allow_redirects=True, timeout=10, headers={"User-Agent": _UA})
return r.url
except Exception as exc:
log(f" [WARN] b23 解析失败: {exc}")
return None
def fetch_play_count(url: str, session: requests.Session) -> int | None:
real = url
if _B23_RE.match(url):
resolved = resolve_b23(url, session)
if not resolved:
return None
real = resolved
bv = _BV_RE.search(real)
av = _AV_RE.search(real)
if not bv and not av:
return None
params = {"bvid": bv.group(0)} if bv else {"aid": av.group(1)}
try:
r = session.get("https://api.bilibili.com/x/web-interface/view",
params=params, timeout=10,
headers={"User-Agent": _UA, "Referer": "https://www.bilibili.com/"})
j = r.json()
if j.get("code") != 0:
return None
v = j.get("data", {}).get("stat", {}).get("view")
return int(v) if v is not None else None
except Exception as exc:
log(f" [WARN] B 站 API: {exc}")
return None
# ============================================================
# 记录关系 + 槽位推进
# ============================================================
def get_parent_id(rec: dict, fid: str) -> str | None:
if not fid:
return None
p = rec.get(fid)
if isinstance(p, list) and p:
item = p[0]
if isinstance(item, dict):
return item.get("id")
if isinstance(item, str):
return item
return None
def get_month(rec: dict, fid: str) -> str | None:
if not fid:
return None
m = rec.get(fid)
if isinstance(m, list) and m:
return m[0]
if isinstance(m, str):
return m
return None
def month_to_num(m: str) -> int:
m = re.match(r"(\d+)月", m or "")
return int(m.group(1)) if m else 0
def num_to_month(n: int) -> str:
return f"{n}月"
def get_week_label(rec: dict, fid: str) -> str | None:
"""从记录的"周发布"字段提取文本(e.g. '7月第二周')"""
if not fid:
return None
w = rec.get(fid)
if isinstance(w, list) and w:
return w[0]
if isinstance(w, str):
return w
return None
def compute_week_slot(today: date) -> tuple[str, str, int | str]:
"""
根据今天日期,返回 (week_label, slot_logical_name, slot_key):
- 1-7 号 → "X月第一周" → 7天
- 8-14 号 → "X月第二周" → 14天
- 15-21 号 → "X月第三周" → 21天
- 22-28 号 → "X月第四周" → 28天
- 29-31 号 → "X月第五周" → 月底曝光量
注: 返回的是 logical name (如 'read_count_7d'),需要从 fmap 查 field_id
"""
day = today.day
month = today.month
month_label = num_to_month(month)
if day <= 7:
return f"{month_label}第一周", SLOTS[0][0], SLOTS[0][1]
elif day <= 14:
return f"{month_label}第二周", SLOTS[1][0], SLOTS[1][1]
elif day <= 21:
return f"{month_label}第三周", SLOTS[2][0], SLOTS[2][1]
elif day <= 28:
return f"{month_label}第四周", SLOTS[3][0], SLOTS[3][1]
else:
return f"{month_label}第五周", SLOTS[4][0], SLOTS[4][1]
def next_slot_in_record(rec: dict, slot_fids: list[str]) -> tuple[str | None, int | str | None]:
"""
返回 (slot_fid, slot_key) — 该记录下一个该填的槽
判定: None / 空字符串 / 0 都视为"未填"
5 槽都填了返回 (None, None)
slot_fids: 5 档槽位的实际 field_id 列表(顺序对应 SLOTS)
"""
for fid, days in zip(slot_fids, [s[1] for s in SLOTS]):
v = rec.get(fid)
if v is None:
return fid, days
if isinstance(v, str) and v.strip() == "":
return fid, days
try:
if float(v) == 0:
return fid, days
except (TypeError, ValueError):
return fid, days
return None, None
def get_slot_fid(fmap: dict, logical_name: str) -> str | None:
"""从 fmap 查某个 logical 槽位(如 'read_count_7d')的实际 field_id"""
return fmap.get(logical_name, {}).get("field_id")
def build_chains(records: list[dict], fid_parent: str, fid_month: str, fid_pubtime: str) -> dict[str, list[dict]]:
"""
把记录组织成"链": key=链根 rid(顶层父记录 rid,即父字段为空的)
value=按月份升序的子记录列表(含根本身)
"""
by_id = {r["record_id"]: r for r in records}
roots: dict[str, list[dict]] = {}
for r in records:
cur = r
while True:
pid = get_parent_id(cur, fid=fid_parent)
if not pid:
root_id = cur["record_id"]
break
if pid not in by_id:
root_id = cur["record_id"] # 父不在表内,自己当根
break
cur = by_id[pid]
roots.setdefault(root_id, []).append(r)
for k in roots:
roots[k].sort(key=lambda x: (
month_to_num(get_month(x, fid=fid_month) or "0月"),
x.get(fid_pubtime) if fid_pubtime else 0
))
return roots
# ============================================================
# 状态持久化(运行历史,可重置)
# ============================================================
def load_state(data_dir: Path) -> dict:
del data_dir
p = BILIBILI_CHECKPOINT_DIR / STATE_FILENAME
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
def save_state(data_dir: Path, state: dict) -> None:
del data_dir
p = BILIBILI_CHECKPOINT_DIR / STATE_FILENAME
p.parent.mkdir(parents=True, exist_ok=True)
p.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(p, state)
# ============================================================
# 单款式处理
# ============================================================
def process_style(style: dict, only_record_ids: set[str] | None,
dry_run: bool, delay: float, session: requests.Session,
state: dict, force_today: date | None,
first_run: bool = False) -> dict:
base_token = style["base_token"]
table_id = style["table_id"]
fmap = style.get("field_map", {})
# 从本款 field_map 解析出实际字段 ID
fid_platform = fmap.get(KEY_PLATFORM, {}).get("field_id", "")
fid_url = fmap.get(KEY_URL, {}).get("field_id", "")
fid_creator = fmap.get(KEY_CREATOR, {}).get("field_id", "")
fid_pubtime = fmap.get(KEY_PUBTIME, {}).get("field_id", "")
fid_month = fmap.get(KEY_MONTH, {}).get("field_id", "")
fid_parent = fmap.get(KEY_PARENT, {}).get("field_id", "")
fid_week = fmap.get(KEY_WEEK, {}).get("field_id", "")
# 5 档槽位的 field_id 列表
slot_fids = [get_slot_fid(fmap, ln) or "" for ln, _ in SLOTS]
log(f"\n========== [{style['index']:02d}] {style['name']} (B 站) ==========")
log(f" 字段映射: platform={fid_platform} url={fid_url} creator={fid_creator or '(无)'} pubtime={fid_pubtime or '(无)'}")
missing_mapping: list[str] = []
if not fid_platform:
missing_mapping.append("platform")
if not fid_url:
missing_mapping.append("note_url")
if first_run:
if not slot_fids[0]:
missing_mapping.append("read_count_7d")
else:
if not fid_pubtime:
missing_mapping.append("publish_time")
missing_mapping.extend(
logical
for field_id, (logical, _label) in zip(slot_fids, SLOTS)
if not field_id
)
if missing_mapping:
error = f"missing field mapping: {','.join(missing_mapping)}"
log(f" [ERROR] {error},跳过该款式")
return {"style": style["name"], "index": style["index"],
"total_b_records": 0, "updated": 0, "skipped": 0, "details": [],
"run_started_at": time.time(),
"error": error, "unresolved": 1,
"retryable_failures": 1, "complete": False}
all_records = list_all_records(base_token, table_id)
# 只看 B 站记录 (大小写无关)
def is_bili_platform(v) -> bool:
if not v:
return False
s = str(v).lower()
return "b站" in s or "b 站" in s
b_records = [r for r in all_records
if r.get(fid_platform) and is_bili_platform(r.get(fid_platform))]
log(f" 全表 {len(all_records)} 条,B 站源记录 {len(b_records)} 条")
if only_record_ids:
b_records = [r for r in b_records if r["record_id"] in only_record_ids]
log(f" --record 过滤后,B 站源记录剩 {len(b_records)} 条")
# 业务触发条件:只有填了发布笔记链接的记录才进入采集清单。
# 无链接通常表示尚未发布,不是 blocked_input,也不进入完整度分母。
source_b_records = len(b_records)
b_records = [r for r in b_records if extract_url(r.get(fid_url))]
skipped_no_url = source_b_records - len(b_records)
log(f" 发布链接过滤: 待采 {len(b_records)} 条,未触发 {skipped_no_url} 条")
today = force_today or date.today()
style_state = state.setdefault(style["name"], {})
summary = {
"style": style["name"], "index": style["index"],
"run_started_at": time.time(),
"source_b_records": source_b_records,
"skipped_no_url": skipped_no_url,
"total_b_records": len(b_records), "updated": 0, "skipped": 0,
"details": [],
}
for rec in b_records:
rid = rec["record_id"]
creator = rec.get(fid_creator) or "?"
pubtime_raw = rec.get(fid_pubtime)
url = extract_url(rec.get(fid_url))
if not url: # 防御性保护;正常情况下已在待采清单生成前过滤。
continue
# 解析发布时间
pub_date = parse_pub_date(pubtime_raw)
if pub_date is None and not first_run:
log(f" [SKIP] {rid[:10]}.. {creator} 发布时间为空,无法判断槽位")
summary["skipped"] += 1
summary["details"].append({
"record_id": rid, "creator": creator, "url": url,
"status": "blocked_input", "matched": False,
"reason": "publish_time_missing", "ok": False,
})
continue
# 算槽位: (今天 - 发布日) 天数差
if first_run:
target_slot_fid, target_slot_key = slot_fids[0] or None, SLOTS[0][1] if slot_fids[0] else None
else:
days_elapsed = (today - pub_date).days
target_slot_fid, target_slot_key = pick_slot_by_days(days_elapsed, slot_fids)
if target_slot_fid is None:
if first_run:
log(f" [SKIP] {rid[:10]}.. {creator} 7天曝光量字段缺失")
else:
log(f" [SKIP] {rid[:10]}.. {creator} 对应槽位字段缺失或发布时间 {pub_date} 在 {today} 之后")
summary["skipped"] += 1
summary["details"].append({
"record_id": rid, "creator": creator, "url": url,
"publish_date": str(pub_date) if pub_date else None,
"status": "blocked_input", "matched": False,
"reason": "slot_missing_or_future_publish_time", "ok": False,
})
continue
# 抓播放量
play = fetch_play_count(url, session)
if play is None:
log(f" [SKIP] {rid[:10]}.. {creator} 抓不到播放量")
summary["skipped"] += 1
summary["details"].append({
"record_id": rid, "creator": creator, "url": url,
"publish_date": str(pub_date) if pub_date else None,
"status": "retryable_failure", "matched": False,
"reason": "play_count_fetch_failed", "ok": False,
})
continue
# 覆盖写入 (同槽覆盖语义,数字更新)
action = "first_run_fill" if first_run else "update_slot"
log(f" {rid[:10]}.. {creator} 发布={pub_date} 距今={(today - pub_date).days if pub_date else '-'}天"
f" → 写 {target_slot_key}天曝光量={play}")
ok = write_record(base_token, table_id, rid,
{target_slot_fid: play}, dry_run)
if ok:
summary["updated"] += 1
style_state[rid] = {"last_run": str(today), "slot": target_slot_key}
summary["details"].append({
"record_id": rid, "creator": creator,
"publish_date": str(pub_date) if pub_date else None,
"days_elapsed": (today - pub_date).days if pub_date else None,
"slot": target_slot_key, "play_count": play,
"action": action, "ok": ok,
"matched": True,
"write_ok": ok if not dry_run else None,
"status": "success" if ok else "write_failure",
**({} if ok else {"reason": "write_back_failed"}),
})
if delay > 0:
time.sleep(delay)
state[style["name"]] = style_state
return finalize_bili_summary(summary, dry_run=dry_run)
# 槽位:天数差 → (slot_fid, slot_key)
# 0-7天→7天, 8-14→14天, 15-21→21天, 22-28→28天, 29+→月底
def pick_slot_by_days(days: int, slot_fids: list[str]) -> tuple[str | None, int | str | None]:
if days < 0:
return None, None
if days <= 7:
fid = slot_fids[0]
elif days <= 14:
fid = slot_fids[1]
elif days <= 21:
fid = slot_fids[2]
elif days <= 28:
fid = slot_fids[3]
else:
fid = slot_fids[4]
if not fid:
return None, None
return fid, SLOTS[slot_fids.index(fid)][1]
def parse_pub_date(v) -> date | None:
"""发布时间字段可能是毫秒时间戳 (int) 或 'YYYY-MM-DD HH:MM:SS' 字符串"""
if v is None:
return None
if isinstance(v, (int, float)):
# 毫秒时间戳
if v > 1e12:
v = v / 1000
try:
return datetime.fromtimestamp(v).date()
except Exception:
return None
if isinstance(v, str):
s = v.strip()
if not s:
return None
# 试常见格式
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y/%m/%d %H:%M:%S"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
# ============================================================
# main
# ============================================================
def main() -> int:
parser = argparse.ArgumentParser(
description="B 站抓取+周更: 自动推进 7/14/21/28/月曝光量槽 + 跨月开新子记录")
add_repeatable_style_argument(parser, "只跑指定款式编号(可多次)")
parser.add_argument("--record", action="append", help="只跑指定 record_id(可多次)")
parser.add_argument("--dry-run", action="store_true", help="只抓不写")
parser.add_argument("--today", help="强制 today 日期,格式 YYYY-MM-DD (调试用)")
parser.add_argument("--delay", type=float, default=0.3,
help="每条请求间隔秒数(防风控,默认 0.3s)")
parser.add_argument("--data-dir", default=str(DEFAULT_DATA_DIR),
help=f"数据目录(默认 {DEFAULT_DATA_DIR})")
parser.add_argument("--reset-state", action="store_true", help="重置运行状态")
parser.add_argument("--first-run", action="store_true",
help="首次回填: 所有 B 站记录全部填 7天曝光量,跳过周次逻辑")
parser.add_argument("--self-operated", action="store_true",
help="抓取自营达人表格(而非合作达人)")
args = parser.parse_args()
data_dir = resolve_layer_output(
args.data_dir, layer_root=PATHS.normalized_root, field="--data-dir"
)
mapping = load_mapping(data_dir, self_operated=args.self_operated)
styles = mapping["tables"]
if args.style:
try:
styles = select_requested_styles(styles, args.style)
except ValueError as exc:
print(str(exc)); return 1
only_rids = set(args.record) if args.record else None
force_today = None
if args.today:
force_today = date.fromisoformat(args.today)
state_path = BILIBILI_CHECKPOINT_DIR / STATE_FILENAME
if args.reset_state and state_path.exists():
state_path.unlink()
log("状态已重置")
state = load_state(data_dir)
session = requests.Session()
session.headers.update({"User-Agent": _UA})
out_dir = V2_DIR if data_dir == PATHS.normalized_root.resolve() else data_dir / "v2_results"
out_dir.mkdir(parents=True, exist_ok=True)
all_summaries = []
self_sfx = "_self" if args.self_operated else ""
final = out_dir / f"_all_summaries{self_sfx}_bilibili.json"
for s in styles:
summary = process_style(s, only_rids, args.dry_run, args.delay,
session, state, force_today, args.first_run)
# 传了 --record 但本款式没匹配到 → 跳过 (不落空盘,不计入总汇总)
if only_rids and summary["total_b_records"] == 0 and summary["updated"] == 0 \
and summary["skipped"] == 0:
continue
# 每款式落盘
out_file = out_dir / f"{s['index']:02d}-{s['name']}{self_sfx}_bilibili_v2.json"
if only_rids and out_file.exists():
try:
previous = json.loads(out_file.read_text(encoding="utf-8"))
except Exception:
previous = None
summary = merge_bili_summary(previous, summary, dry_run=args.dry_run)
else:
summary = finalize_bili_summary(summary, dry_run=args.dry_run)
all_summaries.append(summary)
atomic_write_json(out_file, summary)
log(f" [{s['index']:02d} {s['name']}] B站记录={summary['total_b_records']} "
f"更新={summary['updated']} 跳过={summary['skipped']} "
f"未解决={summary.get('unresolved', 0)} 落盘: {out_file.name}")
if not args.dry_run:
save_state(data_dir, state)
log(f" 状态已存: {state_path}")
# 总汇总按款式合并,单款/局部补跑不能删除其他款式。
try:
existing_global = json.loads(final.read_text(encoding="utf-8")) if final.exists() else []
except Exception:
existing_global = []
atomic_write_json(final, merge_global_summaries(existing_global, all_summaries))
log(f"\n=== 全部完成 ===")
log(f"汇总: {final}")
incomplete = [s.get("index") for s in all_summaries if not s.get("complete")]
if incomplete:
log(f"[ERROR] 以下款式仍有未解决采集/写回结果: {incomplete}")
return 2
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,583 @@
"""Shared completeness, matching, and atomic-result helpers for V2 scrapers."""
from __future__ import annotations
import json
import os
import re
import unicodedata
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import parse_qs, urlparse
SUCCESS = "success"
BLOCKED_INPUT = "blocked_input"
RETRYABLE_FAILURE = "retryable_failure"
WRITE_FAILURE = "write_failure"
TERMINAL_STATUSES = {SUCCESS, BLOCKED_INPUT, RETRYABLE_FAILURE, WRITE_FAILURE}
def add_repeatable_style_argument(parser: Any, help_text: str) -> Any:
"""Add the shared repeatable --style option used by every platform CLI."""
return parser.add_argument(
"--style", type=int, action="append", help=help_text,
)
def select_requested_styles(styles: Iterable[dict], requested: Iterable[int] | None) -> list[dict]:
"""Select every requested style and fail when any requested index is unknown."""
style_list = list(styles)
if not requested:
return style_list
requested_set = {int(index) for index in requested}
selected = [style for style in style_list if int(style.get("index", -1)) in requested_set]
found = {int(style.get("index", -1)) for style in selected}
missing = sorted(requested_set - found)
if missing:
raise ValueError(f"未找到款式: {','.join(str(index) for index in missing)}")
return selected
def atomic_write_json(path: str | Path, value: Any) -> None:
"""Write JSON through a sibling temporary file and atomically replace target."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, target)
def coerce_url(value: Any) -> str:
"""Extract a URL from common Feishu URL-field shapes."""
if value is None:
return ""
if isinstance(value, str):
text = value.strip()
markdown = re.search(r"\[[^\]]*\]\((https?://[^)]+)\)", text, flags=re.IGNORECASE)
if markdown:
return markdown.group(1).strip()
embedded = re.search(r"https?://[^\s)>\]]+", text, flags=re.IGNORECASE)
return embedded.group(0).strip() if embedded else text
if isinstance(value, dict):
for key in ("link", "url", "href", "text"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
return ""
if isinstance(value, list):
for item in value:
candidate = coerce_url(item)
if candidate:
return candidate
return ""
return str(value).strip()
def extract_content_id(url_value: Any, platform: str) -> str | None:
"""Extract a stable Xiaohongshu note ID or Douyin video ID from a URL."""
url = coerce_url(url_value)
if not url:
return None
platform_key = platform.casefold()
if platform_key in {"xhs", "xiaohongshu", "pgy"}:
match = re.search(r"/(?:explore|discovery/item|note)/([A-Za-z0-9_-]+)", url)
return match.group(1) if match else None
if platform_key in {"douyin", "xingtu", "xt"}:
match = re.search(r"/video/(\d+)", url)
if match:
return match.group(1)
try:
query = parse_qs(urlparse(url).query)
except ValueError:
return None
for key in ("modal_id", "item_ids", "item_id", "video_id"):
values = query.get(key) or []
if values:
id_match = re.search(r"\d+", str(values[0]))
if id_match:
return id_match.group(0)
return None
def validate_platform_url(url_value: Any, platform: str) -> tuple[bool, str | None]:
"""Validate URL host without rejecting known short-link domains."""
url = coerce_url(url_value)
if not url:
return False, "url_missing"
try:
host = (urlparse(url).hostname or "").casefold()
except ValueError:
return False, "url_invalid"
if not host:
return False, "url_invalid"
key = platform.casefold()
allowed = {
"xhs": ("xiaohongshu.com", "xhslink.com"),
"pgy": ("xiaohongshu.com", "xhslink.com"),
"xiaohongshu": ("xiaohongshu.com", "xhslink.com"),
"douyin": ("douyin.com", "iesdouyin.com"),
"xingtu": ("douyin.com", "iesdouyin.com"),
"xt": ("douyin.com", "iesdouyin.com"),
}.get(key, ())
if allowed and not any(host == domain or host.endswith("." + domain) for domain in allowed):
return False, "platform_url_mismatch"
return True, None
def normalize_title(value: Any) -> str:
"""Unicode/case normalization followed by punctuation and whitespace removal."""
text = unicodedata.normalize("NFKC", str(value or "")).casefold()
text = re.sub(r"[\u200b-\u200f\u2060\ufeff]", "", text)
return re.sub(r"[\W_]+", "", text, flags=re.UNICODE)
def _title_variants(value: Any) -> list[str]:
"""Build safe title forms for platform-added episode prefixes and hashtags."""
text = unicodedata.normalize("NFKC", str(value or "")).casefold()
text = re.sub(r"[\u200b-\u200f\u2060\ufeff]", "", text).strip()
if not text:
return []
raw_variants = [text]
without_episode = re.sub(
r"^\s*[【\[((]?\s*第\s*[0-9一二三四五六七八九十百零两]+\s*"
r"[集期篇回弹]\s*[】\]))]?\s*[::\-—、.]?\s*",
"",
text,
)
without_episode = re.sub(
r"^\s*(?:ep(?:isode)?|part)\s*\d+\s*[::\-—、.]?\s*",
"",
without_episode,
flags=re.IGNORECASE,
)
if without_episode != text:
raw_variants.append(without_episode)
for candidate in list(raw_variants):
before_hashtag = re.split(r"[##]", candidate, maxsplit=1)[0].strip()
if before_hashtag and before_hashtag != candidate:
raw_variants.append(before_hashtag)
variants: list[str] = []
for candidate in raw_variants:
normalized = normalize_title(candidate)
if normalized and normalized not in variants:
variants.append(normalized)
return variants
def _episode_marker(value: Any) -> str | None:
text = unicodedata.normalize("NFKC", str(value or "")).casefold().strip()
chinese = re.match(
r"^[【\[((]?\s*第\s*([0-9一二三四五六七八九十百零两]+)\s*[集期篇回弹]",
text,
)
english = re.match(r"^(?:ep(?:isode)?|part)\s*(\d+)", text, flags=re.IGNORECASE)
match = chinese or english
if not match:
return None
marker = match.group(1)
return str(int(marker)) if marker.isdigit() else marker
def _normalized_title_similarity(card: str, target: str) -> float:
if not card or not target:
return 0.0
if card == target:
return 1.0
shorter, longer = sorted((card, target), key=len)
if len(shorter) >= 6 and shorter in longer:
coverage = len(shorter) / max(len(longer), 1)
return min(0.98, 0.93 + 0.05 * coverage)
ratio = SequenceMatcher(None, card, target, autojunk=False).ratio()
# Prefixes are common when the UI truncates a title.
common_prefix = os.path.commonprefix([card, target])
if len(common_prefix) >= 8:
ratio = max(ratio, 0.78 + min(0.12, len(common_prefix) / max(len(card), len(target)) * 0.12))
return ratio
def title_similarity(card_title: Any, target_title: Any) -> float:
"""Return a conservative 0..1 score suitable for unique-best matching."""
card_episode = _episode_marker(card_title)
target_episode = _episode_marker(target_title)
if card_episode and target_episode and card_episode != target_episode:
return 0.0
card_variants = _title_variants(card_title)
target_variants = _title_variants(target_title)
if not card_variants or not target_variants:
return 0.0
return max(
_normalized_title_similarity(card, target)
for card in card_variants
for target in target_variants
)
def _metric_number(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
text = str(value or "").strip().replace(",", "")
match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s*(万|w)?", text, flags=re.IGNORECASE)
if not match:
return None
number = float(match.group(1))
return number * 10000 if match.group(2) else number
def deduplicate_observed_cards(cards: Iterable[dict]) -> list[dict]:
"""Merge API/DOM observations of the same video while preserving distinct IDs."""
unique: list[dict] = []
for raw in cards:
if not isinstance(raw, dict):
continue
card = dict(raw)
card_id = str(card.get("note_id") or "")
card_url = coerce_url(card.get("href") or card.get("url")).rstrip("/")
duplicate_index: int | None = None
for index, old in enumerate(unique):
old_id = str(old.get("note_id") or "")
old_url = coerce_url(old.get("href") or old.get("url")).rstrip("/")
if card_id and old_id:
if card_id == old_id:
duplicate_index = index
if duplicate_index is not None or card_id != old_id:
if duplicate_index is not None:
break
continue
if card_url and old_url and card_url == old_url:
duplicate_index = index
break
one_is_api = (
card.get("source") == "show_items_api"
or old.get("source") == "show_items_api"
)
one_missing_id = not card_id or not old_id
same_title = (
normalize_title(card.get("title"))
and normalize_title(card.get("title")) == normalize_title(old.get("title"))
)
card_play = _metric_number(card.get("play_count"))
old_play = _metric_number(old.get("play_count"))
metric_compatible = (
card_play is None
or old_play is None
or abs(card_play - old_play) / max(card_play, old_play, 1.0) <= 0.08
)
if one_is_api and one_missing_id and same_title and metric_compatible:
duplicate_index = index
break
if duplicate_index is None:
unique.append(card)
continue
old = unique[duplicate_index]
card_quality = int(bool(card.get("note_id"))) + int(card.get("source") == "show_items_api")
old_quality = int(bool(old.get("note_id"))) + int(old.get("source") == "show_items_api")
preferred, fallback = (card, old) if card_quality > old_quality else (old, card)
merged = dict(fallback)
merged.update({key: value for key, value in preferred.items() if value not in (None, "")})
unique[duplicate_index] = merged
return unique
def match_tasks_to_cards(
tasks: Iterable[dict],
cards: Iterable[dict],
platform: str,
*,
min_score: float = 0.78,
ambiguity_margin: float = 0.035,
) -> dict[str, dict]:
"""Match by ID first, then safely allocate each observed card to one source note."""
card_list = [dict(card) for card in cards if isinstance(card, dict)]
for card in card_list:
if not card.get("note_id"):
card["note_id"] = extract_content_id(card.get("href") or card.get("url"), platform)
task_list = [dict(task) for task in tasks if isinstance(task, dict) and task.get("record_id")]
def task_source_key(task: dict, content_id: Any = None) -> tuple[str, str]:
stable_id = content_id or task.get("note_id") or extract_content_id(task.get("note_url"), platform)
if stable_id:
return "id", str(stable_id)
url = coerce_url(task.get("note_url")).rstrip("/")
if url:
return "url", url
# Without a source URL, never let separate records reuse one fuzzy match.
return "record", str(task.get("record_id"))
def task_allows_card(task: dict, card: dict) -> bool:
task_id = task.get("note_id") or extract_content_id(task.get("note_url"), platform)
card_id = card.get("note_id")
return not task_id or not card_id or str(task_id) == str(card_id)
def card_key(card: dict, index: int) -> tuple[str, str]:
if card.get("note_id"):
return "id", str(card["note_id"])
url = coerce_url(card.get("href") or card.get("url")).rstrip("/")
return ("url", url) if url else ("index", str(index))
indexed_cards = [(index, card, card_key(card, index)) for index, card in enumerate(card_list)]
matched: dict[str, dict] = {}
claimed: dict[tuple[str, str], tuple[str, str]] = {}
remaining: list[dict] = []
for task in task_list:
record_id = str(task.get("record_id") or "")
content_id = task.get("note_id") or extract_content_id(task.get("note_url"), platform)
if content_id:
id_matches = [
(index, card, key)
for index, card, key in indexed_cards
if str(card.get("note_id") or "") == str(content_id)
]
if id_matches:
_, raw_card, key = id_matches[0]
source_key = task_source_key(task, content_id)
owner = claimed.get(key)
if owner is not None and owner != source_key:
remaining.append(task)
continue
claimed[key] = source_key
selected = dict(raw_card)
selected.update({"match_method": "content_id", "match_score": 1.0})
matched[record_id] = selected
continue
remaining.append(task)
# If two different source URLs have the exact same title, title alone cannot
# identify which record owns the observed card. Leave both unresolved.
conflicted_cards: set[tuple[str, str]] = set()
for _, card, key in indexed_cards:
exact_sources = {
task_source_key(task)
for task in remaining
if task_allows_card(task, card)
if title_similarity(card.get("title"), task.get("target_title")) == 1.0
}
if len(exact_sources) > 1:
conflicted_cards.add(key)
def task_best_score(task: dict) -> float:
return max(
(
title_similarity(card.get("title"), task.get("target_title"))
for _, card, key in indexed_cards
if key not in conflicted_cards
and task_allows_card(task, card)
),
default=0.0,
)
# Exact/strongest tasks claim first, so a looser title cannot steal the one
# card needed by a later exact task.
remaining.sort(key=lambda task: (-task_best_score(task), str(task.get("record_id"))))
for task in remaining:
record_id = str(task.get("record_id") or "")
source_key = task_source_key(task)
scored = sorted(
(
(title_similarity(card.get("title"), task.get("target_title")), index, card, key)
for index, card, key in indexed_cards
if key not in conflicted_cards
and task_allows_card(task, card)
and (claimed.get(key) is None or claimed.get(key) == source_key)
),
key=lambda item: (-item[0], item[1]),
)
if not scored or scored[0][0] < min_score:
continue
best_score, _, best_card, best_key = scored[0]
second_score = scored[1][0] if len(scored) > 1 else 0.0
if (
second_score >= min_score
and best_score - second_score < ambiguity_margin
and (best_score < 1.0 or second_score == 1.0)
):
second_card = scored[1][2]
best_id = str(best_card.get("note_id") or "")
second_id = str(second_card.get("note_id") or "")
if not best_id or best_id != second_id:
continue
claimed[best_key] = source_key
selected = dict(best_card)
selected.update({"match_method": "title", "match_score": round(best_score, 4)})
matched[record_id] = selected
return matched
def result_status(row: dict) -> str:
"""Read new status values and conservatively classify legacy result rows."""
explicit = row.get("status")
if explicit in TERMINAL_STATUSES:
return explicit
if row.get("write_ok") is False or row.get("write_error"):
return WRITE_FAILURE
if row.get("matched") is False or row.get("ok") is False or row.get("error") or row.get("reason"):
return RETRYABLE_FAILURE
if row.get("matched") is True or row.get("ok") is True:
return SUCCESS
metric_keys = ("read_count", "play_count", "view_count", "updated_value")
if any(row.get(key) is not None for key in metric_keys):
return SUCCESS
return RETRYABLE_FAILURE
def upsert_result(summary: dict, row: dict) -> None:
"""Replace the previous result for one record instead of appending duplicates."""
results = summary.setdefault("results", [])
record_id = row.get("record_id")
if record_id:
for index, old in enumerate(results):
if isinstance(old, dict) and old.get("record_id") == record_id:
results[index] = row
return
results.append(row)
def finalize_summary(summary: dict, *, dry_run: bool = False) -> dict:
"""Deduplicate results, recalculate counters, and attach completeness fields."""
out = dict(summary)
unique: list[dict] = []
positions: dict[str, int] = {}
duplicate_results = 0
for raw in out.get("results") or []:
if not isinstance(raw, dict):
continue
row = dict(raw)
status = result_status(row)
if status == SUCCESS and not dry_run and row.get("write_ok") is False:
status = WRITE_FAILURE
row["status"] = status
if status == SUCCESS:
row.setdefault("matched", True)
elif status != BLOCKED_INPUT:
row.setdefault("matched", False)
rid = str(row.get("record_id") or "")
if rid and rid in positions:
unique[positions[rid]] = row
duplicate_results += 1
else:
if rid:
positions[rid] = len(unique)
unique.append(row)
total = int(out.get("total") or 0)
success = sum(result_status(row) == SUCCESS for row in unique)
blocked = sum(result_status(row) == BLOCKED_INPUT for row in unique)
retryable = sum(result_status(row) == RETRYABLE_FAILURE for row in unique)
write_failures = sum(result_status(row) == WRITE_FAILURE for row in unique)
missing = max(0, total - len(unique))
unresolved = retryable + write_failures + missing
top_level_error = bool(out.get("error"))
if top_level_error:
retryable = max(retryable, 1)
unresolved = max(unresolved, 1)
if dry_run:
filled = 0
else:
explicit_write_rows = [row for row in unique if result_status(row) == SUCCESS and "write_ok" in row]
if explicit_write_rows:
filled = sum(row.get("write_ok") is True for row in explicit_write_rows)
# Preserve legacy successes that predate per-row write status.
filled += sum(
1 for row in unique
if result_status(row) == SUCCESS and "write_ok" not in row
)
else:
filled = min(int(out.get("filled") or 0), success)
out.update({
"results": unique,
"matched": success + write_failures,
"filled": filled,
"success": success,
"blocked_input": blocked,
"retryable_failures": retryable,
"write_failures": write_failures,
"missing_results": missing,
"duplicate_results": duplicate_results,
"unresolved": unresolved,
"complete": (
not top_level_error
and unresolved == 0
and duplicate_results == 0
and len(unique) == total
),
"dry_run": bool(dry_run),
})
return out
def merge_style_summary(existing: dict | None, partial: dict, *, dry_run: bool = False) -> dict:
"""Merge a record-limited retry into the canonical per-style summary."""
if not existing:
return finalize_summary(partial, dry_run=dry_run)
combined = dict(existing)
for key, value in partial.items():
if key != "results":
combined[key] = value
rows: list[dict] = []
positions: dict[str, int] = {}
for source in (existing.get("results") or [], partial.get("results") or []):
for raw in source:
if not isinstance(raw, dict):
continue
row = dict(raw)
rid = str(row.get("record_id") or "")
if rid and rid in positions:
rows[positions[rid]] = row
else:
if rid:
positions[rid] = len(rows)
rows.append(row)
combined["results"] = rows
combined["total"] = max(
int(existing.get("total") or 0),
int(partial.get("total") or 0),
len(rows),
)
return finalize_summary(combined, dry_run=dry_run)
def merge_global_summaries(existing: Any, updates: Iterable[dict]) -> list[dict]:
"""Merge selected style summaries into an existing all-style summary by index."""
merged: dict[Any, dict] = {}
order: list[Any] = []
for row in existing if isinstance(existing, list) else []:
if not isinstance(row, dict):
continue
key = row.get("index", row.get("style"))
if key not in merged:
order.append(key)
merged[key] = row
for row in updates:
if not isinstance(row, dict):
continue
key = row.get("index", row.get("style"))
if key not in merged:
order.append(key)
merged[key] = row
return [merged[key] for key in sorted(order, key=lambda value: (not isinstance(value, int), value))]
def requested_records_succeeded(summary: dict, record_ids: Iterable[str]) -> tuple[bool, list[str]]:
"""Validate that every requested record exists and has a successful terminal state."""
by_id = {
str(row.get("record_id")): row
for row in (summary.get("results") or summary.get("details") or [])
if isinstance(row, dict) and row.get("record_id")
}
failed = [str(rid) for rid in record_ids if result_status(by_id.get(str(rid), {})) != SUCCESS]
return not failed, failed
@@ -0,0 +1,53 @@
"""Shared helpers for grouping cross-style scrape tasks by creator."""
from __future__ import annotations
from collections import OrderedDict, defaultdict
def _clean_creator_id(value) -> str:
return str(value or "").strip()
def _clean_creator_name(value) -> str:
return "".join(str(value or "").split()).casefold()
def group_tasks_by_creator(tasks: list[dict]) -> list[dict]:
"""Group tasks globally, preferring creator ID and falling back to name.
A name-only task joins an ID group when that normalized name maps to exactly
one ID in the current run. Conflicting IDs remain separate.
"""
ids_by_name: dict[str, set[str]] = defaultdict(set)
for task in tasks:
creator_id = _clean_creator_id(task.get("creator_id"))
creator_name = _clean_creator_name(task.get("creator_name"))
if creator_id and creator_name:
ids_by_name[creator_name].add(creator_id)
groups: OrderedDict[tuple[str, str], dict] = OrderedDict()
for task in tasks:
creator_id = _clean_creator_id(task.get("creator_id"))
creator_name_key = _clean_creator_name(task.get("creator_name"))
if not creator_id and len(ids_by_name.get(creator_name_key, ())) == 1:
creator_id = next(iter(ids_by_name[creator_name_key]))
key = ("id", creator_id) if creator_id else ("name", creator_name_key)
if key not in groups:
groups[key] = {
"creator_name": str(task.get("creator_name") or "").strip(),
"creator_id": creator_id or None,
"tasks": [],
}
groups[key]["tasks"].append(task)
return list(groups.values())
def chunk_creator_groups(groups: list[dict], batch_size: int) -> list[list[dict]]:
"""Split creator groups into batches; non-positive means unlimited."""
if not groups:
return []
if batch_size <= 0:
return [groups]
return [groups[i:i + batch_size] for i in range(0, len(groups), batch_size)]
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,8 @@
# 分析脚本配置模板
# 复制为 analyze.env 并填真实值 (analyze.env 已被 .gitignore 排除)
# Hermes 分析端 API Token
HERMES_ANALYZER_TOKEN=your_token_here
# 飞书接收人 open_id (不设则必须传 --no-send)
FEISHU_TARGET_OPEN_ID=ou_xxxxxxxxxxxxxxxxx
@@ -0,0 +1,8 @@
# PG 连接信息模板
# 复制为 db.env 并填真实值 (db.env 已被 .gitignore 排除)
PG_HOST=8.148.185.119
PG_PORT=5432
PG_DB=data_hub
PG_USER=data_hub
PG_PASSWORD=${GYXX_PG_PASSWORD}
@@ -0,0 +1,863 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""评论采集数据 → 本地关键词过滤 + Hermes 舆情分析 → 项目 data/summary 汇总 + 飞书通知。
使用本地 Hermes 分析端:
- URL: http://127.0.0.1:8642/v1(~/.hermes/profiles/data-analyzer/.env 里的 API_SERVER_PORT)
- Model: mimo-v2.5-pro
- Profile: data-analyzer
流程:
1. 读取项目采集汇总的评论文件(默认取 data/summary/ 下最新 CSV,支持 txt/json/csv)。
2. 用本地 Python 关键词规则(飞书文档 6 层体系)过滤评论。
3. 用 Hermes 分析端 + 内置舆情分析提示词生成汇总报告。
4. 将汇总写入 data/summary/评论舆论分析汇总.txt。
5. 通过 lark-cli --profile hermes-analyzer 发送汇总给目标 open_id。
"""
import argparse
import concurrent.futures
import csv
import json
import os
from gyxx_flow.adapters import RuntimeServicePolicy
import random
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
PATHS,
resolve_layer_output,
)
# Path setup
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
try:
from dotenv import load_dotenv
_analyze_env = PATHS.state_root / "config/analyze.env"
if _analyze_env.exists():
load_dotenv(_analyze_env, override=False)
except ImportError:
pass
PROJECT_ROOT = _PROJECT_ROOT
DATA_DIR = PATHS.raw_root
SUMMARY_DIR = PATHS.exports_root / "summary"
def _latest_summary_csv() -> Path:
"""自动选取 data/summary/ 下最新的汇总 CSV(项目采集输出)。"""
if not SUMMARY_DIR.exists():
return SUMMARY_DIR / "*.csv"
csv_files = sorted(SUMMARY_DIR.glob("*.csv"), key=lambda p: p.stat().st_mtime, reverse=True)
return csv_files[0] if csv_files else SUMMARY_DIR / "*.csv"
DEFAULT_INPUT = _latest_summary_csv()
OUTPUT_PATH = SUMMARY_DIR / "评论舆论分析汇总.txt"
# ---------------------------------------------------------------------------
# Hermes 分析端配置
# 实际 API Server 端口见 ~/.hermes/profiles/data-analyzer/.env 中的 API_SERVER_PORT(当前为 8642)
# ---------------------------------------------------------------------------
os.environ.update(RuntimeServicePolicy().apply(os.environ))
HERMES_ANALYZER_URL = os.environ["HERMES_ANALYZER_URL"]
HERMES_ANALYZER_TOKEN = os.getenv("HERMES_ANALYZER_TOKEN", "")
HERMES_ANALYZER_MODEL = os.getenv("HERMES_ANALYZER_MODEL", "mimo-v2.5-pro")
TARGET_OPEN_ID = os.getenv("FEISHU_TARGET_OPEN_ID", "")
# 飞书长消息截断阈值(留有余量)
FEISHU_TEXT_LIMIT = 6000
# ---------------------------------------------------------------------------
# 本地代码过滤关键词(飞书文档《评论区关键词抓取》6 层体系)
# ---------------------------------------------------------------------------
# 流程:全量采集评论 → 本地关键词过滤 → 对有效评论进行舆情分析汇总
CODE_FILTER_KEYWORDS = {
# 第1层:购买意向信号(衡量种草效率)
"purchase_intent": [
# 直接购买
"下单", "已买", "入手", "冲了", "付款", "等收货",
# 搜索意向
"怎么买", "链接", "哪里买", "搜什么", "淘宝", "店铺",
# 价格评估
"多少钱", "价格", "划算", "贵不贵", "优惠", "值得买",
# 犹豫对比(用双字词避免单字"比"误匹配[比心]等表情)
"纠结", "哪个好", "容量够吗", "犹豫", "对比", "比较", "和哪个",
],
# 第2层:产品功能反馈(正向)
"positive_feature": [
# 轻量化("轻"用双字避免误匹配"年轻人"等)
"很轻", "超轻", "挺轻", "不重", "没感觉", "毫无负担", "背着不累",
# 收纳秩序
"分区", "隔层", "收纳", "能装", "各就各位",
# 快取便利("快"用双字避免误匹配"快乐"等)
"很方便", "顺手", "很快", "单手", "侧袋", "不用翻",
# 品质感
"质感", "材质", "面料", "五金", "走线", "高级",
],
# 第3层:产品功能反馈(负向)
"negative_feature": [
# 重量负担("重"用双字避免误匹配"重要"等)
"很重", "太重", "超重", "偏重", "背不动", "肩膀疼",
# 收纳不足
"不够装", "太小", "放不下", "没地方", "容量",
# 品质问题
"线头", "脱线", "划痕", "掉色", "起皱", "做工",
# 使用不便("卡""紧"用双字避免误匹配)
"不好拿", "卡住", "太紧", "难开", "不方便",
],
# 第4层:用户场景(判断内容与目标用户是否匹配)
"user_scenario": [
# 通勤
"上班", "地铁", "挤公交", "通勤", "工位", "办公室",
# 旅行/周末
"旅行", "出差", "周末", "短途", "出游", "打包",
# 社交
"约会", "聚会", "探店", "咖啡馆", "拍照",
# 户外
"徒步", "爬山", "露营", "户外",
],
# 第5层:用户审美与风格认同
"aesthetic": [
# 颜值设计
"好看", "颜值", "设计", "高级", "喜欢这个",
# 百搭搭配("配"用双字避免误匹配)
"百搭", "好搭", "搭衣服", "搭配", "风格",
# 子风格认同("潮"用双字避免误匹配)
"机能", "潮流", "潮牌", "科技感", "克制", "简约", "秩序",
],
# 第6层:品牌情感(长期心智指标)
"brand_emotion": [
# 推荐意愿
"推荐", "安利", "值得", "满意", "惊喜",
# 品牌认同
"光影行星", "关注", "忠实", "一直用", "老用户",
# 复购意向
"再买", "第二个", "送人", "给家人", "还会",
],
}
# 包包品类通用词:用于判断评论是否与"我方产品"相关。
# 除了品牌名/款式名,评论中出现这些词也视为与我方产品相关。
BAG_CATEGORY_TERPS = [
# 包类通用
"包", "背包", "双肩包", "单肩包", "斜挎包", "手提包", "托特包",
"邮差包", "胸包", "腰包", "手包", "拎包",
# 容量/装载
"能装", "装得下", "容量", "放电脑", "放平板", "放手机",
"装东西", "装不下", "够装", "收纳",
# 背负/功能
"背着", "背过", "背起来", "背负", "肩带", "减负",
"防泼水", "防水", "防震", "减震",
# 材质/做工
"面料", "材质", "走线", "五金", "拉链", "做工",
# 通勤/出行场景中的包
"通勤包", "出差包", "旅行包", "电脑包", "书包",
"机车包", "头盔包", "健身包", "运动包",
]
# 纯寒暄/无信息词:只含这些词(+标点)且未命中任何关键词的评论丢弃
# 注意:不要把文档中的有效关键词(如"好看")放进来,否则会被误丢
CODE_FILTER_NOISE_WORDS = {
"好", "棒", "支持", "来了", "赞", "厉害", "不错", "优秀",
"喜欢", "可爱", "666", "牛", "太强了", "真棒", "很好",
}
# 判断纯表情/无意义评论:只要包含中文、英文或数字就不是纯表情
_HAS_MEANINGFUL_CHAR_RE = re.compile(r"[\u4e00-\u9fa5a-zA-Z0-9]")
# ---------------------------------------------------------------------------
# 内置提示词(已硬编码,运行时不读取 md 文件)
# ---------------------------------------------------------------------------
ANALYSIS_SYSTEM_PROMPT = """# 角色:小红书评论舆情分析专家
你是一位专注小红书生态5年的舆情分析师,擅长将非结构化评论转化为结构化洞察,并给出可落地的口碑管理策略。
## 核心能力
1. **情绪分层**:正向(赞美/推荐/回购/共鸣)、负向(质量/服务/价格/竞品贬低)、中性(咨询/建议/客观描述);识别情绪烈度(强烈推荐 vs 还行 vs 非常失望)。
2. **主题聚类**:产品(材质/容量/颜色/重量/五金)、服务(发货/包装/客服/售后)、内容(种草来源/预期落差)、场景(通勤/旅行/约会/健身)、竞品(对比品牌/话术)。
3. **用户画像**:推断年龄(学生党/上班族/宝妈)、消费力(价格敏感型 vs 直接下单型)、使用场景、审美偏好;分类用户类型(理性测评/感性颜值/价格敏感)。
4. **风险预警**:≥3人提及同问题 → 品控/服务漏洞;出现"平替""不如XX" → 定位模糊;出现"退货""过敏""坏了""假货" → 严重投诉。分级:P0(安全/假货/严重质量)、P1(批量负面但可控)、P2(偶发吐槽)。
5. **竞品对比**:正/负面率对比、被夸/被骂维度对比、差异化口碑优势与薄弱环节。
6. **营销归因**:评论热点与近期投放/促销/新品动作联动,给出口碑效果归因图。
7. **优化建议**:产品端(高频吐槽改进)、内容端(详情页/笔记关键信息优化)、客服端(FAQ/话术优化)、运营端(种草卖点提炼/补救内容设计)。
8. **报告输出**:标准化舆情周报/月报(数据概览+主题分析+画像+预警+竞品+建议)。
## 工作原则
- 数据说话:每条结论附数据支撑(如"63%负评集中在尺寸偏小")
- 客观中立:不过度美化也不过度恐慌
- 行动导向:给出"下周一就能做的3件事"
- 长线思维:关注长期品牌口碑健康指标
- 用户视角:读出用户"没说出来但藏在语气里的情绪"
## 输出格式
收到评论数据后,按以下结构输出:
### 一、数据概览
- 评论总数 / 有效评论数
- 情绪分布:正面X% / 中性X% / 负面X%
- 互动概览:平均点赞/回复数、高互动Top3
### 二、核心发现(Top 3)
每条附数据支撑
### 三、分维度深度分析
| 排名 | 主题标签 | 提及次数 | 正向占比 | 负向占比 | 典型评论 |
|------|----------|----------|----------|----------|----------|
| 1 | 材质 | 45 | 80% | 10% | "皮料很软" |
- **情绪趋势**:正/负/中性随时间波动,是否有拐点
- **用户画像**:高频身份、核心场景、主要决策因素
- **风险预警**:P0/P1/P2 分级清单
### 四、归因分析
正向评论高峰由哪些营销动作带来?负面集中反馈由哪些动作引发?
### 五、优化建议(按优先级)
- **P0(立即)**:问题 → 行动 → 责任方
- **P1(本周)**:问题 → 行动 → 责任方
- **P2(下月)**:问题 → 行动 → 责任方
### 六、可复用资产
- 评论分类标签体系
- 情绪打分规则
- 舆情周报模板
## 约束
- 数据量<50条时注明"样本量较小,结论仅供参考"
- 涉及隐私的评论需脱敏处理
- 所有建议必须标注责任方(产品/内容/客服/运营)"""
def log(message: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)
# ---------------------------------------------------------------------------
# 文件读取
# ---------------------------------------------------------------------------
def load_text(path: Path) -> str:
if not path.exists():
raise FileNotFoundError(f"找不到文件: {path}")
for enc in ("utf-8-sig", "utf-8", "gbk"):
try:
return path.read_text(encoding=enc)
except UnicodeDecodeError:
continue
return path.read_text(encoding="utf-8", errors="replace")
def _extract_comment_text(item: Any) -> str:
if isinstance(item, str):
return item.strip()
if isinstance(item, dict):
for key in ("comment_text", "content", "text", "message", "comment"):
val = item.get(key)
if val is not None:
return str(val).strip()
return str(item).strip()
def load_comments(path: Path) -> list[str]:
"""从 txt/json/csv 加载评论文本列表。"""
text = load_text(path)
ext = path.suffix.lower()
if ext == ".json":
data = json.loads(text)
if isinstance(data, list):
return [_extract_comment_text(item) for item in data if _extract_comment_text(item)]
if isinstance(data, dict):
for key in ("comments", "data", "list", "rows"):
if key in data and isinstance(data[key], list):
return [_extract_comment_text(item) for item in data[key] if _extract_comment_text(item)]
raise ValueError(f"不支持的 JSON 结构: {path}")
if ext == ".csv":
rows = list(csv.DictReader(text.splitlines()))
candidates = ("comment_text", "content", "text", "message", "评论内容", "评论", "comment")
comments: list[str] = []
for row in rows:
for key in candidates:
val = row.get(key)
if val is not None and str(val).strip():
comments.append(str(val).strip())
break
return comments
# 默认:每行一条评论
return [line.strip() for line in text.splitlines() if line.strip()]
# ---------------------------------------------------------------------------
# 按款式分批处理(用于项目汇总 CSV)
# ---------------------------------------------------------------------------
def load_style_groups(path: Path, max_per_style: int | None = None) -> dict[str, list[str]]:
"""从 CSV 中按款式分组读取评论,可选每款式抽样上限。"""
text = load_text(path)
rows = list(csv.DictReader(text.splitlines()))
candidates = ("comment_text", "content", "text", "message", "评论内容", "评论", "comment")
style_key_candidates = ("style_name", "款式", "style", "产品")
style_col: str | None = None
for row in rows[:1]:
for key in style_key_candidates:
if key in row:
style_col = key
break
groups: dict[str, list[str]] = {}
for row in rows:
style = row.get(style_col, "未知款式") if style_col else "未知款式"
style = style.strip() or "未知款式"
for key in candidates:
val = row.get(key)
if val is not None and str(val).strip():
groups.setdefault(style, []).append(str(val).strip())
break
if max_per_style:
rng = random.Random(42)
for style in groups:
if len(groups[style]) > max_per_style:
groups[style] = rng.sample(groups[style], max_per_style)
return dict(sorted(groups.items(), key=lambda item: -len(item[1])))
def analyze_style(style: str, comments: list[str]) -> tuple[str, int, int]:
"""对单个款式的评论进行过滤 + 分析,返回(汇总文本, 原始数, 过滤后数)。"""
log(f"【{style}】开始处理({len(comments)} 条)...")
filtered = code_filter_comments(comments)
summary = analyze_comments(filtered)
return summary, len(comments), len(filtered)
def build_batch_report(style_results: list[tuple[str, str, int, int]]) -> str:
"""把所有款式汇总合并成一份总报告。"""
total_raw = sum(raw for _, _, raw, _ in style_results)
total_filtered = sum(filt for _, _, _, filt in style_results)
lines = [
"# 多款式评论舆情汇总报告",
"",
f"- 款式总数:{len(style_results)}",
f"- 评论总数(原始):{total_raw}",
f"- 有效评论总数(过滤后):{total_filtered}",
"",
"---",
"",
]
for style, summary, raw, filtered in style_results:
lines.append(f"## 款式:{style}")
lines.append(f"原始评论:{raw} 条 | 过滤后:{filtered} 条")
lines.append("")
lines.append(summary)
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Hermes 分析端调用
# ---------------------------------------------------------------------------
def call_hermes_analyzer(system_prompt: str, user_content: str) -> str:
url = HERMES_ANALYZER_URL.rstrip("/") + "/chat/completions"
payload = {
"model": HERMES_ANALYZER_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
headers={
"Authorization": f"Bearer {HERMES_ANALYZER_TOKEN}",
"Content-Type": "application/json; charset=utf-8",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=1800) as response:
result = json.loads(response.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Hermes HTTP {exc.code}: {body}") from exc
if "choices" not in result or not result["choices"]:
raise RuntimeError(f"Hermes 返回异常: {result}")
return str(result["choices"][0]["message"]["content"])
def _is_pure_emoji(text: str) -> bool:
"""判断是否为纯表情/无意义评论(不含中文、英文、数字)。"""
return not _HAS_MEANINGFUL_CHAR_RE.search(text)
# 抖音/B站表情格式:[比心] [舔屏] 等,匹配前需去掉避免误触发关键词
_EMOJI_BRACKET_RE = re.compile(r'\[[^\[\]]{1,10}\]')
def _strip_emoji_brackets(text: str) -> str:
"""去掉 [xxx] 格式的表情符号,避免表情文本误触发关键词匹配。"""
return _EMOJI_BRACKET_RE.sub('', text).strip()
def _matches_code_keywords(text: str) -> bool:
"""本地关键词匹配,命中任意有效关键词即保留。"""
clean = _strip_emoji_brackets(text)
if not clean:
return False
for keywords in CODE_FILTER_KEYWORDS.values():
for kw in keywords:
if kw in clean:
return True
return False
def classify_comment_keywords(text: str) -> list[str]:
"""返回一条评论命中的所有关键词类别(用于统计分布)。"""
clean = _strip_emoji_brackets(text)
if not clean:
return []
categories: list[str] = []
for category, keywords in CODE_FILTER_KEYWORDS.items():
for kw in keywords:
if kw in clean:
categories.append(category)
break
return categories
def _is_noise_only(text: str) -> bool:
"""判断是否为纯寒暄/无信息评论(仅由常见寒暄词和标点组成)。"""
# 去掉常见寒暄词
cleaned = text
for w in sorted(CODE_FILTER_NOISE_WORDS, key=len, reverse=True):
cleaned = cleaned.replace(w, "")
# 再去掉常见标点、空格、数字
cleaned = re.sub(r"[\s,,。!!??~~.·\d]+", "", cleaned).strip()
return not cleaned
def code_filter_comments(comments: list[str]) -> list[str]:
"""用本地代码规则快速过滤评论,不调用大模型。"""
if not comments:
log("没有评论需要过滤")
return []
log(f"使用本地代码规则进行评论过滤(共 {len(comments)} 条)...")
seen: set[str] = set()
kept: list[str] = []
for text in comments:
t = text.strip()
if not t or t in seen:
continue
if _is_pure_emoji(t):
continue
if _matches_code_keywords(t):
seen.add(t)
kept.append(t)
continue
# 未命中关键词,但属于短句纯寒暄,也丢弃
if len(t) <= 12 and _is_noise_only(t):
continue
# 其余未命中关键词的评论丢弃
log(f"代码过滤完成:{len(comments)} 条 → {len(kept)} 条")
return kept
# ---------------------------------------------------------------------------
# LLM 评论清洗(语义过滤,替代/补充关键词过滤)
# ---------------------------------------------------------------------------
COMMENT_CLEAN_SYSTEM_PROMPT = """你是"光影行星"品牌的评论清洗助手。光影行星主要经营双肩包、单肩包、斜挎包、摄影包、电脑包、手提包及相关包袋配件。你的任务是过滤无效评论,并从评论中提取对品牌经营、产品优化或用户服务有价值的内容。
有效评论包括:
1. 产品评价:涉及外观、颜色、尺寸、容量、重量、材质、做工、气味、拉链、肩带、扣具、分区、收纳、防水性、耐用性、舒适度、便携性等。
2. 使用体验:涉及通勤、旅行、摄影、户外、上学、商务、电脑收纳、相机收纳等真实使用场景。
3. 产品咨询:询问尺寸、颜色、容量、适配设备、材质、库存、价格、使用方法、清洁保养或不同款式区别。
4. 购买意向:明确表达想买、准备下单、等待补货、询问购买链接或推荐款式。
5. 产品问题与售后反馈:涉及破损、开线、掉色、拉链故障、尺寸不符、配件缺失、使用异常、退换货原因等。
6. 品牌反馈:明确提到光影行星,对品牌设计、质量、价格、服务或产品系列提出评价和建议。
无效评论包括:
1. 只有表情、数字、标点或无意义字符。
2. "哈哈哈""路过""支持一下""不错不错"等没有具体信息的泛泛表达。
3. 广告、引流、联系方式、刷单、抽奖、互关或推广其他商品。
4. 与光影行星或包袋产品完全无关的内容。
5. 明显复制粘贴、重复发送或机器生成的灌水内容。
6. 单纯辱骂、攻击他人且没有任何产品事实或具体问题。
7. 无法理解、语义残缺且不能确定评论意图的内容。
8. 只讨论主播、视频背景、音乐或其他无关内容。
9. 仅询问快递到了哪里,且不包含产品或售后问题。
10. 没有真实内容的默认好评或模板评价。
判定原则:
1. 负面评论只要包含具体产品问题,就属于有效评论,不能因为评价负面而删除。
2. 评论同时包含有效和无效内容时,只提取其中与光影行星及包袋产品有关的有效部分。
3. 不得修改评论原意,不得虚构用户没有提到的信息。
4. 可以删除表情、重复语句、无意义语气词和无关内容。
5. "很好""不错""喜欢"等评论,如果没有说明具体原因,判定为无效。
6. "容量很大""肩带舒服""拉链不好用"等虽然简短,但包含具体产品信息,应判定为有效。
7. 无法确定时,优先保留,但标记为"需要人工确认"。
8. 所有"有效"类别(产品评价/使用体验/产品咨询/购买意向/售后问题/品牌反馈)都必须限定在"光影行星品牌"或"包袋类产品"范围内。如果评论只询问/讨论笔记里出现的其他物品(如镜子、指甲刀、U型枕、蚊虫膏、行李箱、自拍支架、护照收纳袋、登山杖等非包袋物品),即使有购买意向或产品咨询,也判定为无效。
9. 评论同时提到包袋和其他物品时,只看包袋相关部分:若包袋部分有具体信息(评价/咨询/购买意向等),按有效处理;若包袋部分只是被顺带提及或无具体信息,判定为无效。
每条评论按照以下格式输出(不要输出其它内容):
评论编号:<编号>
是否有效:有效/无效/需要人工确认
有效评论:<提取并清洗后的有效内容;无有效内容则填写"无">
评论类型:产品评价/使用体验/产品咨询/购买意向/售后问题/品牌反馈/无效内容
情感倾向:正面/中性/负面/无法判断
涉及维度:外观、颜色、尺寸、容量、材质、做工、舒适度、收纳、防水、耐用、价格、配件、售后、其他
无效原因:<有效评论填写"无";无效评论说明具体原因>"""
_CLEAN_FIELD_RE = re.compile(
r"(是否有效|有效评论|评论类型|情感倾向|涉及维度|无效原因)\s*[::]\s*(.*)"
)
def parse_cleaning_response(response: str) -> list[dict]:
"""解析 LLM 结构化输出。每条评论 7 行,按"评论编号:"分块。
返回 [{valid, cleaned, type, sentiment, dimensions, reason}, ...]
"""
items: list[dict] = []
# 按"评论编号:<数字>"分块,跳过首块(分割后的空块)
blocks = re.split(r"评论编号\s*[::]\s*\d+", response)
for block in blocks[1:]:
item = {
"valid": "无效",
"cleaned": "",
"type": "无效内容",
"sentiment": "无法判断",
"dimensions": "其他",
"reason": "无",
}
for line in block.split("\n"):
line = line.strip()
if not line:
continue
m = _CLEAN_FIELD_RE.match(line)
if not m:
continue
key, val = m.group(1), m.group(2).strip()
if key == "是否有效":
item["valid"] = val
elif key == "有效评论":
if val and val != "无":
item["cleaned"] = val
elif key == "评论类型":
item["type"] = val
elif key == "情感倾向":
item["sentiment"] = val
elif key == "涉及维度":
item["dimensions"] = val
elif key == "无效原因":
item["reason"] = val
items.append(item)
return items
def _trivial_prefilter(comments: list[str]) -> list[str]:
"""轻量预过滤:只去掉空、纯表情、重复,其余全留(给 LLM 判断)。"""
seen: set[str] = set()
kept: list[str] = []
for text in comments:
t = str(text or "").strip()
if not t or t in seen:
continue
if _is_pure_emoji(t):
continue
seen.add(t)
kept.append(t)
return kept
def _clean_one_batch(batch: list[str], batch_idx: int,
total_batches: int) -> tuple[int, list[dict]]:
"""清洗一批评论,返回 (batch_idx, results)。
并发安全:不写共享状态,只返回结果。
"""
lines = [f"评论编号:{i+1}\n评论内容:{text}" for i, text in enumerate(batch, 1)]
user_content = (f"请按规则清洗以下 {len(batch)} 条评论,逐条输出结构化结果。\n\n"
+ "\n\n".join(lines))
results: list[dict] = []
try:
response = call_hermes_analyzer(COMMENT_CLEAN_SYSTEM_PROMPT, user_content)
parsed = parse_cleaning_response(response)
for i, item in enumerate(parsed):
if i < len(batch):
item["original"] = batch[i]
if len(parsed) < len(batch):
for i in range(len(parsed), len(batch)):
results.append({
"original": batch[i],
"valid": "需要人工确认",
"cleaned": batch[i],
"type": "无效内容",
"sentiment": "无法判断",
"dimensions": "其他",
"reason": "LLM 响应未解析到该条",
})
results.extend(parsed)
except Exception as exc:
for text in batch:
results.append({
"original": text,
"valid": "需要人工确认",
"cleaned": text,
"type": "无效内容",
"sentiment": "无法判断",
"dimensions": "其他",
"reason": f"LLM 调用失败: {exc}",
})
return batch_idx, results
def llm_clean_comments_detailed(comments: list[str],
batch_size: int = 30,
max_workers: int = 6,
include_invalid: bool = False) -> list[dict]:
"""用 LLM 对评论做语义清洗(并发批处理)。
返回 [{original, valid, cleaned, type, sentiment, dimensions, reason}, ...]
默认只返回 valid != "无效" 的评论;传 include_invalid=True 返回全部。
max_workers: 并发批数(默认 6,Hermes 本地端可承受)
"""
if not comments:
return []
prefiltered = _trivial_prefilter(comments)
log(f"LLM 清洗:预过滤 {len(comments)} -> {len(prefiltered)} 条(去空/去重/去纯表情)")
total = len(prefiltered)
total_batches = (total + batch_size - 1) // batch_size
# 切分批次
batches: list[tuple[int, list[str]]] = []
for batch_idx in range(total_batches):
start = batch_idx * batch_size
end = min(start + batch_size, total)
batches.append((batch_idx, prefiltered[start:end]))
# 并发跑
results_by_idx: dict[int, list[dict]] = {}
completed = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {executor.submit(_clean_one_batch, batch, idx, total_batches): idx
for idx, batch in batches}
for future in concurrent.futures.as_completed(future_to_idx):
idx = future_to_idx[future]
try:
batch_idx, batch_results = future.result()
results_by_idx[batch_idx] = batch_results
except Exception as exc:
log(f" [WARN] 批次 {idx+1} 异常: {exc}")
results_by_idx[idx] = []
completed += 1
if completed % 3 == 0 or completed == total_batches:
log(f" 进度: {completed}/{total_batches} 批完成")
# 按原顺序合并
all_results: list[dict] = []
for idx in range(total_batches):
all_results.extend(results_by_idx.get(idx, []))
kept = [r for r in all_results if r.get("valid") != "无效"]
valid_count = sum(1 for r in all_results if r.get("valid") == "有效")
unsure_count = sum(1 for r in all_results if r.get("valid") == "需要人工确认")
invalid_count = sum(1 for r in all_results if r.get("valid") == "无效")
log(f"LLM 清洗完成: 共 {len(all_results)} 条 -> 有效 {valid_count} / "
f"需人工 {unsure_count} / 无效 {invalid_count}")
return all_results if include_invalid else kept
def llm_clean_comments(comments: list[str], batch_size: int = 30,
use_cleaned: bool = True, max_workers: int = 6) -> list[str]:
"""LLM 清洗评论,返回清洗后的评论文本列表(只含有效+需要人工确认)。
use_cleaned=True 返回清洗后内容,False 返回原文。"""
details = llm_clean_comments_detailed(comments, batch_size=batch_size,
max_workers=max_workers)
out: list[str] = []
for d in details:
text = d.get("cleaned") if use_cleaned else d.get("original")
if not text:
text = d.get("original", "")
if text:
out.append(text)
return out
# ---------------------------------------------------------------------------
# 舆情分析
# ---------------------------------------------------------------------------
def analyze_comments(comments: list[str]) -> str:
if not comments:
return "无有效评论可分析。"
log(f"调用 Hermes 分析端生成舆情汇总(共 {len(comments)} 条)...")
lines = [f"{i + 1}. {text}" for i, text in enumerate(comments)]
user_content = (
f"共有 {len(comments)} 条过滤后的有效评论,请根据角色设定输出完整的舆情分析汇总报告。\n\n"
+ "\n".join(lines)
)
return call_hermes_analyzer(ANALYSIS_SYSTEM_PROMPT, user_content)
# ---------------------------------------------------------------------------
# 输出 & 飞书通知
# ---------------------------------------------------------------------------
def write_summary(summary: str, path: Path) -> Path:
path.write_text(summary, encoding="utf-8-sig")
log(f"汇总报告已写入: {path}")
return path
def send_feishu_summary(summary: str, open_id: str) -> dict[str, Any]:
"""通过 lark-cli --profile hermes-analyzer 以分析端应用身份发送汇总。"""
text = summary
if len(text) > FEISHU_TEXT_LIMIT:
text = text[:FEISHU_TEXT_LIMIT] + "\n\n...(内容过长,已截断,完整内容见项目 data/summary 汇总文件)"
command = [
"lark-cli",
"--profile", "hermes-analyzer",
"im", "+messages-send",
"--user-id", open_id,
"--text", text,
"--as", "bot",
"--format", "json",
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if completed.returncode != 0:
raise RuntimeError(
f"lark-cli 发送失败 (exit {completed.returncode}):\n"
f"stdout: {completed.stdout}\nstderr: {completed.stderr}"
)
try:
return json.loads(completed.stdout)
except json.JSONDecodeError:
return {"raw": completed.stdout}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="评论 → Hermes 过滤/分析 → 桌面汇总 + 飞书通知")
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT, help="原始评论文件路径")
parser.add_argument("--output", type=Path, default=OUTPUT_PATH, help="汇总输出文件路径")
parser.add_argument("--open-id", default=TARGET_OPEN_ID, help="飞书接收人 open_id")
parser.add_argument("--no-send", action="store_true", help="只生成汇总文件,不发送飞书")
parser.add_argument("--batch-by-style", action="store_true", help="CSV 按款式分批分析(用于项目汇总 CSV)")
parser.add_argument("--max-per-style", type=int, default=300, help="每款式最多分析多少条评论(防上下文超长)")
args = parser.parse_args()
args.output = resolve_layer_output(
args.output, layer_root=PATHS.exports_root, field="--output"
)
if not args.no_send:
if not HERMES_ANALYZER_TOKEN:
log("[ERROR] HERMES_ANALYZER_TOKEN 未设置。请配置 data/config/analyze.env 或传 --no-send")
return 1
if not args.open_id:
log("[ERROR] 飞书接收人 open_id 未设置。请配置 FEISHU_TARGET_OPEN_ID 或传 --open-id 或 --no-send")
return 1
if args.batch_by_style:
log(f"按款式分批读取 CSV: {args.input}")
try:
style_groups = load_style_groups(args.input, args.max_per_style)
except Exception as exc:
log(f"读取 CSV 分组失败: {exc}")
return 1
log(f"共 {len(style_groups)} 个款式")
if not style_groups:
log("没有读取到任何评论,流程结束")
return 0
style_results: list[tuple[str, str, int, int]] = []
for style, comments in style_groups.items():
try:
summary, raw_count, filtered_count = analyze_style(style, comments)
style_results.append((style, summary, raw_count, filtered_count))
except Exception as exc:
log(f"【{style}】处理失败: {exc}")
style_results.append((style, f"处理失败: {exc}", len(comments), 0))
summary = build_batch_report(style_results)
else:
log(f"读取原始评论文件: {args.input}")
try:
comments = load_comments(args.input)
except Exception as exc:
log(f"读取评论失败: {exc}")
return 1
log(f"读取到 {len(comments)} 条评论")
if not comments:
log("没有读取到任何评论,流程结束")
return 0
try:
filtered = code_filter_comments(comments)
except Exception as exc:
log(f"评论过滤失败: {exc}")
return 1
try:
summary = analyze_comments(filtered)
except Exception as exc:
log(f"舆情分析失败: {exc}")
return 1
try:
write_summary(summary, args.output)
except Exception as exc:
log(f"写入汇总文件失败: {exc}")
return 1
if args.no_send:
log("已指定 --no-send,跳过飞书发送")
else:
try:
result = send_feishu_summary(summary, args.open_id)
log(f"飞书消息发送成功: {result}")
except Exception as exc:
log(f"飞书消息发送失败(汇总文件仍已生成): {exc}")
return 1
log("全部完成")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\n已中断", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,868 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""单篇笔记分析报告生成器。
读取项目采集的单篇笔记 JSON(data/notes/{platform}/...json),
结合笔记标题、点赞/收藏/评论/分享等基础指标,以及评论内容,
生成本地代码过滤 + Hermes 舆情分析的单篇笔记分析报告。
输出保存到:data/summary/单篇笔记分析报告.txt
"""
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
PATHS,
resolve_layer_output,
)
# Path setup
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import (
HERMES_ANALYZER_MODEL,
HERMES_ANALYZER_TOKEN,
HERMES_ANALYZER_URL,
FEISHU_TEXT_LIMIT,
TARGET_OPEN_ID,
BAG_CATEGORY_TERPS,
code_filter_comments,
llm_clean_comments,
llm_clean_comments_detailed,
classify_comment_keywords,
call_hermes_analyzer,
)
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
PROJECT_ROOT = _PROJECT_ROOT
DATA_DIR = PATHS.raw_root
SUMMARY_DIR = PATHS.exports_root / "summary"
NOTES_DIR = PATHS.raw_root / "notes"
OUTPUT_PATH = SUMMARY_DIR / "单篇笔记分析报告.txt"
CATEGORY_LABELS = {
"purchase_intent": "购买意向",
"positive_feature": "正向功能反馈",
"negative_feature": "负向功能反馈",
"user_scenario": "用户场景",
"aesthetic": "审美与风格",
"brand_emotion": "品牌情感",
}
# 单篇笔记分析:送入 LLM 的有效评论上限
# 超过此数量时按关键词多样性抽样,保留最有信息量的评论
MAX_COMMENTS_FOR_LLM = 80
# ---------------------------------------------------------------------------
# 单篇笔记分析的 LLM 提示词
# ---------------------------------------------------------------------------
SINGLE_NOTE_PROMPT = """你是一位品牌投放复盘分析师。请基于以下单篇笔记的数据与评论区内容,按指定结构输出分析报告。
【笔记信息】
- 标题:{title}
- 平台:{platform}
- 品牌/产品:{brand} / {product}
- 点赞:{likes} | 收藏:{favs} | 评论:{comments} | 分享:{shares}
- 曝光/播放量:{views}(如有)
【有效评论列表】
{有效评论原文,逐条列出}
【输出要求】
请按以下7个模块输出结论,每个模块直接给出判断和建议,不重复提及数据来源或指标定义,引用评论用「」包裹原文:
模块1|笔记表现归因
- 基于互动率结构(评论/收藏/转发)判断这条笔记的传播属性:是认知留存型、社交裂变型还是搜索截流型?
- 结合曝光量(如有)判断流量效率是否匹配投放预期。
- 一句话总结:这条笔记"成也何处,败也何处"。
模块2|评论质量分层
- 将评论按"有效种草 / 连带兴趣 / 无效互动"三档归类,给出各档条数与占比。
- 无效互动的典型表现是什么?(如纯求链接、纯@好友、无意义表情)
- 有效评论中,高频关键词指向哪些用户决策阶段?(规格确认、价值判断、竞品对比、冲动下单、售后反馈)
模块3|品牌安全与控评建议
- 评论区是否存在负面舆情集中点?若有,具体是什么问题?影响面多大?
- 是否出现竞品高频被提及?提及场景是"对比纠结"还是"被替代"?
- 给出具体的控评/引导话术建议,至少2条可挂置顶或评论区回复的原文。
模块4|脚本有效性拆解
- 标题钩子是否有效?是吸引点击还是引发质疑?
- 卖点传达是否精准?评论区讨论的卖点与笔记主推卖点是否一致?如果偏离,偏离方向是什么?
- 用户是否按预期行动?(如搜索品牌词、进店铺、点击评论区链接等)
- 内容调性是否与品牌心智匹配?若匹配度不足,差异点在哪?
模块5|达人匹配度评估
- 该达人的粉丝画像(性别、年龄、兴趣)与我方目标客群的重合度如何?
- 达人的内容风格(硬核测评/温柔种草/剧情演绎/颜值展示)是否适合承载本产品的核心卖点?
- 如果匹配度不足,是"换人"还是"改脚本"?具体建议。
模块6|可沉淀的选题资产
- 从评论区提取至少2条可作为下一期内容选题的真实用户需求或疑问。
- 每条选题需附:内容方向+标题参考+核心脚本建议。
模块7|下一篇笔记可执行优化清单
- 脚本层面(前3秒钩子、卖点顺序、画面设计、结尾引导)逐项给出可落地的修改点。
- 评论区运营层面(预埋评论、置顶引导、舆情应对)给出具体话术。
- 投放策略层面(达人选择、内容方向、流量助推)给出调整建议。
【输出格式要求】
- 不要出现"根据数据""数据显示""通过分析可知"等来源类表述。
- 直接给结论和建议,每条建议须具体可执行,避免"加强""优化"等空泛词汇。
- 如我方产品在笔记中被边缘化,须在模块4末尾明确加粗标注"⚠️ 我方产品边缘化,非核心种草"。
- 整体篇幅控制在1500-2000字,结论前置,层次分明。"""
def log(message: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)
def detect_platform(source_url: str) -> str:
url = source_url.lower()
if "bilibili" in url or "b23.tv" in url or url.startswith("bv"):
return "bilibili"
if "douyin" in url:
return "douyin"
if "xiaohongshu" in url or "xhslink" in url:
return "xiaohongshu"
return "unknown"
def safe_div(numerator: Any, denominator: Any) -> float:
try:
n = float(numerator)
d = float(denominator)
if d == 0:
return 0.0
return n / d
except (TypeError, ValueError):
return 0.0
def load_note_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"找不到文件: {path}")
for enc in ("utf-8-sig", "utf-8", "gbk"):
try:
data = json.loads(path.read_text(encoding=enc))
break
except UnicodeDecodeError:
continue
except json.JSONDecodeError:
raise
else:
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
if not isinstance(data, dict):
raise ValueError(f"不支持的 JSON 结构: {path}")
return data
def extract_note_info(data: dict[str, Any]) -> dict[str, Any]:
"""从笔记 JSON 中提取标题、URL、指标等。"""
source_url = data.get("source_url") or data.get("final_url") or ""
platform = detect_platform(source_url)
# 标题:优先顶层 title,兼容 bilibili 旧数据的 video.title
title = data.get("title") or ""
if not title and isinstance(data.get("video"), dict):
title = data["video"].get("title", "")
stats = data.get("stats") or {}
metrics = stats.get("note_metrics") or {}
return {
"title": title,
"platform": platform,
"source_url": source_url,
"scraped_at": data.get("scraped_at", ""),
"comment_count_total": data.get("count", 0),
"like_count": metrics.get("like_count", ""),
"favorite_count": metrics.get("favorite_count", ""),
"comment_count_metric": metrics.get("comment_count", ""),
"share_count": metrics.get("share_count", ""),
"view_count": metrics.get("view_count", ""),
"collect_count_yxyy": metrics.get("collect_count", ""),
"style_name": "",
"creator_name": "",
}
def extract_comment_texts(data: dict[str, Any]) -> list[str]:
"""从笔记 JSON 中提取评论文本列表。"""
comments = data.get("comments") or []
texts: list[str] = []
for c in comments:
if not isinstance(c, dict):
continue
for key in ("content", "text", "message"):
val = c.get(key)
if val is not None:
text = str(val).strip()
if text:
texts.append(text)
break
return texts
def load_note_from_db(note_id: int | None = None, url: str | None = None) -> tuple[dict[str, Any], list[str]]:
"""从数据库读取笔记信息和评论文本。
返回 (note_info_dict, comment_texts_list)。
只查 cmt_notes(含 cmt_styles / cmt_creators JOIN)+ cmt_comments。
"""
if note_id is None and not url:
raise ValueError("必须提供 note_id 或 url")
if note_id is not None:
note_row = db.get_note_by_id(note_id)
else:
note_row = db.get_note_by_url(url)
if not note_row:
raise ValueError(f"数据库中找不到笔记: note_id={note_id}, url={url}")
note_id = note_row["id"]
comment_rows = db.get_comments_by_note_id(note_id)
info = {
"title": note_row.get("title") or "",
"platform": note_row.get("platform") or "",
"source_url": note_row.get("url") or "",
"scraped_at": note_row.get("scraped_at").isoformat(timespec="seconds") if note_row.get("scraped_at") else "",
"comment_count_total": len(comment_rows),
"like_count": note_row.get("like_count", ""),
"favorite_count": note_row.get("favorite_count", ""),
"comment_count_metric": note_row.get("comment_count", ""),
"share_count": note_row.get("share_count", ""),
"view_count": note_row.get("view_count", ""),
"collect_count_yxyy": note_row.get("collect_count", ""),
"style_name": note_row.get("style_name") or "",
"creator_name": note_row.get("creator_name") or "",
}
texts = [str(row.get("content") or "").strip() for row in comment_rows if str(row.get("content") or "").strip()]
return info, texts
def _to_number(value: Any) -> float:
try:
if value is None or value == "":
return 0.0
return float(value)
except (TypeError, ValueError):
return 0.0
def compute_metrics(info: dict[str, Any]) -> dict[str, Any]:
"""计算互动率等衍生指标。所有互动率统一以「曝光量」为分母,避免多口径混用。"""
likes = info["like_count"]
favorites = info["favorite_count"]
comments = info["comment_count_metric"] or info["comment_count_total"]
shares = info["share_count"]
views_raw = info.get("view_count")
likes_num = _to_number(likes)
favorites_num = _to_number(favorites)
comments_num = _to_number(comments)
shares_num = _to_number(shares)
views_num = _to_number(views_raw)
return {
"view_count": views_num,
"like_rate": safe_div(likes_num, views_num), # 点赞率
"comment_rate": safe_div(comments_num, views_num), # 评论率(评论/曝光,全报告唯一口径)
"favorite_rate": safe_div(favorites_num, views_num), # 收藏率
"share_rate": safe_div(shares_num, views_num), # 分享率
"engagement_rate": safe_div(likes_num + comments_num + favorites_num + shares_num, views_num), # 综合互动率
"favorite_to_comment": safe_div(favorites_num, comments_num), # 收藏评论比(独立口径)
}
def keyword_distribution(comments: list[str], target_terms: list[str] | None = None) -> dict[str, Any]:
"""统计关键词命中分布,区分「全评论」与「我方产品相关」。
target_terms:品牌名/款式名列表;提供时把命中文本先筛出再统计,得到「我方产品相关」分布。
匹配规则:评论中出现品牌名/款式名,或出现 BAG_CATEGORY_TERPS 中的包包品类词,均视为"我方产品相关"。
返回:
{
"overall": {category: count, ...}, # 全评论 6 类命中数
"relevant": {category: count, ...}, # 我方产品相关子集的 6 类命中数
"relevant_total": int, # 我方产品相关评论条数
}
"""
terms = [t for t in (target_terms or []) if t]
bag_terms = BAG_CATEGORY_TERPS
overall: dict[str, int] = {key: 0 for key in CATEGORY_LABELS}
relevant: dict[str, int] = {key: 0 for key in CATEGORY_LABELS}
relevant_total = 0
for text in comments:
cats = classify_comment_keywords(text)
for c in cats:
overall[c] += 1
is_relevant = False
if terms and any(t in text for t in terms):
is_relevant = True
elif any(t in text for t in bag_terms):
is_relevant = True
if is_relevant:
relevant_total += 1
for c in cats:
relevant[c] += 1
return {
"overall": overall,
"relevant": relevant,
"relevant_total": relevant_total,
}
def _brand_block(brand: str, style_name: str, target_term: str) -> str:
if not (brand or style_name):
return ""
return f"""
【实体隔离】
本次分析的目标:品牌「{brand or '未指定'}」、产品「{style_name or '未指定'}」。本笔记可能涉及多品牌联合推广。
请严格将分析对象限定为我方品牌/产品:
- 仅将「{target_term}」相关的信息计入种草指标
- 笔记/评论中涉及的其他品牌或产品,仅作为场景搭配或联合推广的陪衬
- 评论区数据先按以下三类分类再纳入分析:
· 有效种草:明确提及「{target_term}」的卖点/外观/体验,或询问其价格/链接
· 连带兴趣:既讨论了「{target_term}」也讨论了其他产品
· 无效/他品互动:仅讨论其他产品或完全无关闲聊
- 情感倾向、痛点需求、卖点提取,仅基于「有效种草」与「连带兴趣」生成;「无效/他品互动」不计入种草指标
- 关键词分布按「全评论」与「我方产品相关」两个口径分别统计
"""
def _data_table_markdown(info: dict[str, Any], metrics: dict[str, Any], raw_total: int, filtered_total: int) -> str:
"""构造数据表 markdown(用于提示词 / 报告 / 卡片)。"""
has_view = bool(metrics.get("view_count"))
def pct(v: float) -> str:
return f"{v * 100:.2f}%" if v else "—"
def num(v: Any) -> str:
if v in ("", None):
return "—"
try:
return f"{int(float(v)):,}"
except (TypeError, ValueError):
return str(v)
likes = info.get("like_count")
favorites = info.get("favorite_count")
comments = info.get("comment_count_metric") or info.get("comment_count_total")
shares = info.get("share_count")
valid_ratio = safe_div(filtered_total, raw_total)
lines = [
"| 维度 | 指标 | 数值 |",
"| --- | --- | --- |",
f"| 基础 | 点赞 | {num(likes)} |",
f"| 基础 | 收藏 | {num(favorites)} |",
f"| 基础 | 评论 | {num(comments)} |",
f"| 基础 | 分享 | {num(shares)} |",
f"| 传播 | 曝光量 | {num(metrics.get('view_count')) if has_view else '未采集'} |",
f"| 互动率 | 点赞率 | {pct(metrics.get('like_rate', 0))} |",
f"| 互动率 | 评论率 | {pct(metrics.get('comment_rate', 0))} |",
f"| 互动率 | 收藏率 | {pct(metrics.get('favorite_rate', 0))} |",
f"| 互动率 | 分享率 | {pct(metrics.get('share_rate', 0))} |",
f"| 互动率 | 综合互动率 | {pct(metrics.get('engagement_rate', 0))} |",
f"| 互动率 | 收藏评论比 | {metrics.get('favorite_to_comment', 0):.2f} |",
f"| 质量 | 有效率 | {pct(valid_ratio)} |",
]
return "\n".join(lines)
def _keyword_distribution_markdown(
distribution: dict[str, Any],
total_filtered: int,
) -> str:
"""构造关键词分布 markdown,含「全评论」与「我方产品相关」两段。"""
overall = distribution.get("overall", {}) if isinstance(distribution, dict) else {}
relevant = distribution.get("relevant", {}) if isinstance(distribution, dict) else {}
relevant_total = distribution.get("relevant_total", 0) if isinstance(distribution, dict) else 0
if not overall:
return "(无关键词分布数据)"
def fmt(d: dict[str, int]) -> str:
return "\n".join(f"- {CATEGORY_LABELS[k]}:{d.get(k, 0)} 条" for k in CATEGORY_LABELS)
lines = [
f"### 全评论({total_filtered} 条)",
fmt(overall),
"",
f"### 我方产品相关({relevant_total} 条)",
fmt(relevant) if relevant_total else "_(无评论明确提及我方产品/品牌)_",
]
return "\n".join(lines)
def _sample_comments(comments: list[str], max_count: int) -> list[str]:
"""当评论数超过上限时,按关键词多样性抽样保留最有信息量的评论。
策略:优先保留命中更多关键词类别的评论,同类别数时保留更长的评论。
"""
scored: list[tuple[int, int, int, str]] = [] # (负类别数, 负长度, 原始索引, 文本)
for i, text in enumerate(comments):
cats = classify_comment_keywords(text)
scored.append((-len(cats), -len(text), i, text))
scored.sort()
return [text for _, _, _, text in scored[:max_count]]
def build_note_analysis_prompt(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, Any],
comments: list[str],
) -> str:
"""构造 LLM 输入的 user_content。"""
brand = info.get("brand") or ""
style_name = info.get("style_name") or ""
target_label = " / ".join(x for x in (brand, style_name) if x) if (brand or style_name) else "未指定"
target_term = brand or style_name
# 评论数量截断:超过上限时按关键词多样性抽样
total_count = len(comments)
if total_count > MAX_COMMENTS_FOR_LLM:
comments = _sample_comments(comments, MAX_COMMENTS_FOR_LLM)
lines: list[str] = []
if total_count > MAX_COMMENTS_FOR_LLM:
lines.append(f"共有 {total_count} 条过滤后的有效评论(已抽样 {len(comments)} 条),请基于以下单篇笔记的信息和评论内容,按要求输出分析报告。")
else:
lines.append(f"共有 {len(comments)} 条过滤后的有效评论,请基于以下单篇笔记的信息和评论内容,按要求输出分析报告。")
lines.extend([
"",
"## 单篇笔记基础信息",
f"- 标题:{info['title'] or '未获取'}",
f"- 平台:{info['platform']}",
f"- 链接:{info['source_url']}",
f"- 品牌/产品:{target_label}",
f"- 点赞数:{info['like_count']}",
f"- 收藏数:{info['favorite_count']}",
f"- 评论数:{info['comment_count_metric'] or info['comment_count_total']}",
f"- 分享数:{info['share_count']}",
"",
])
if brand or style_name:
lines.extend([
"## 实体隔离规则(产品隔离)",
f"本次分析的目标:品牌「{brand}」、产品「{style_name}」。本笔记可能涉及多品牌联合推广,请严格将分析对象限定为我方品牌/产品。",
f"- 仅将「{target_term}」相关的信息计入种草指标",
"- 笔记/评论中涉及的其他品牌或产品,仅作为场景搭配或联合推广的陪衬",
"- 评论区按以下三类分类后再纳入分析:",
f" · 有效种草:明确提及「{target_term}」的卖点/外观/使用体验,或询问其价格/链接",
f" · 连带兴趣:既讨论了「{target_term}」也讨论了其他产品",
" · 无效/他品互动:仅讨论其他产品或完全无关闲聊",
"- 情感倾向、痛点需求、卖点提取,仅基于「有效种草」与「连带兴趣」生成;「无效/他品互动」不计入种草指标",
"",
])
lines.extend([
"## 数据表(已计算好,可直接引用)",
_data_table_markdown(info, metrics, info.get("comment_count_total", 0), len(comments)),
"",
"## 评论关键词分布(按口径分别给出)",
_keyword_distribution_markdown(distribution, len(comments)),
"",
"## 有效评论内容",
])
for text in comments:
lines.append(f"- {text}")
return "\n".join(lines)
def analyze_note(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, Any],
comments: list[str],
) -> str:
"""调用 Hermes 生成单篇笔记分析。"""
if not comments:
return "该笔记无有效评论可分析。"
log(f"调用 Hermes 生成分析报告({info['title'] or info['source_url']},{len(comments)} 条有效评论)...")
user_content = build_note_analysis_prompt(info, metrics, distribution, comments)
brand = info.get("brand") or ""
style_name = info.get("style_name") or ""
target_term = brand or style_name
brand_block = _brand_block(brand, style_name, target_term)
system_prompt = SINGLE_NOTE_PROMPT + brand_block
return call_hermes_analyzer(system_prompt, user_content)
def build_report(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, Any],
raw_comments: list[str],
filtered_comments: list[str],
analysis: str,
) -> str:
"""构造本地报告文件。"""
return _build_report_concise(info, metrics, distribution, raw_comments, filtered_comments, analysis)
def _build_report_concise(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, Any],
raw_comments: list[str],
filtered_comments: list[str],
analysis: str,
) -> str:
brand = info.get("brand") or ""
style_name = info.get("style_name") or ""
target_label = " / ".join(x for x in (brand, style_name) if x) if (brand or style_name) else "未指定"
lines = [
"# 单篇笔记分析报告(投放复盘版)",
"",
f"**标题**:{info['title'] or '未获取'}",
f"**平台**:{info['platform']}",
f"**品牌/产品**:{target_label}",
f"**达人**:{info.get('creator_name') or '未指定'}",
f"**链接**:{info['source_url']}",
"",
"## 一、数据表",
_data_table_markdown(info, metrics, len(raw_comments), len(filtered_comments)),
"",
"## 二、关键词分布",
_keyword_distribution_markdown(distribution, len(filtered_comments)),
"",
"## 三、投放复盘",
analysis,
]
return "\n".join(lines)
def write_report(report: str, path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(report, encoding="utf-8-sig")
log(f"报告已写入: {path}")
return path
def _md_field(label: str, value: str, is_short: bool = True) -> dict[str, Any]:
return {
"is_short": is_short,
"text": {"tag": "lark_md", "content": f"**{label}**\n{value}"},
}
def _section_header(title: str) -> dict[str, Any]:
return {
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{title}**"},
}
def build_card_payload(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, Any],
raw_comments: list[str],
filtered_comments: list[str],
analysis: str,
report_path: Path,
) -> dict[str, Any]:
"""构造飞书 interactive 卡片 JSON。
布局(精简版默认):
header(蓝色)+ 基本信息 + 数据表 + 关键词分布 + 投放复盘 + 报告路径
"""
brand = info.get("brand") or ""
style_name = info.get("style_name") or ""
creator_name = info.get("creator_name") or ""
target_label = " / ".join(x for x in (brand, style_name) if x) if (brand or style_name) else "未指定"
title = info.get("title") or "未获取"
platform = info.get("platform") or "unknown"
source_url = info.get("source_url") or ""
elements: list[dict[str, Any]] = []
# 基本信息
elements.append({
"tag": "div",
"fields": [
_md_field("标题", title, is_short=False),
_md_field("品牌 / 产品", target_label),
_md_field("平台", platform),
_md_field("达人", creator_name or "未指定"),
],
})
if source_url:
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"🔗 [打开笔记原文]({source_url})"},
})
# 数据表
elements.append({"tag": "hr"})
elements.append(_section_header("📋 数据表"))
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": _data_table_markdown(info, metrics, len(raw_comments), len(filtered_comments))},
})
# 关键词分布(拆分两个口径)
elements.append(_section_header("🔑 关键词分布"))
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": _keyword_distribution_markdown(distribution, len(filtered_comments))},
})
# 投放复盘(LLM 输出)
elements.append(_section_header("💡 投放复盘"))
analysis_text = analysis or "(无分析结果)"
if len(analysis_text) > 4500:
analysis_text = analysis_text[:4500] + "\n\n...(内容过长,已截断,完整分析见报告文件)"
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": analysis_text},
})
return {
"config": {"wide_screen_mode": True},
"header": {
"template": "blue",
"title": {
"tag": "plain_text",
"content": "📊 单篇笔记分析报告",
},
},
"elements": elements,
}
def _fmt_int(value: Any) -> str:
if value is None or value == "":
return "—"
try:
return f"{int(float(value)):,}"
except (TypeError, ValueError):
return str(value)
def send_feishu_report(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, int],
raw_comments: list[str],
filtered_comments: list[str],
analysis: str,
open_id: str,
report_path: Path,
) -> dict[str, Any]:
"""通过 lark-cli 以飞书 interactive 卡片格式发送报告。
卡片包含:标题/平台/达人基础信息、6 个分节指标 + LLM 舆情洞察。
Windows 下直接走 lark-cli.cmd 会撞到命令行长度上限且对 &|<>^% 转义敏感,
改为调 node + run.js,args 用 list 传,由 node 自己处理。
"""
card = build_card_payload(
info, metrics, distribution,
raw_comments, filtered_comments, analysis,
report_path,
)
content_json = json.dumps(card, ensure_ascii=False)
if sys.platform.startswith("win"):
# Windows 下走 lark-cli.cmd 会撞到命令行长度上限(~8K)且对 &|<>^% 转义敏感;
# 直接调 node + run.js,args 用 list 传,由 node 自己处理,避开所有 cmd 转义问题。
run_js = (
Path(os.environ.get("APPDATA", str(Path.home())))
/ "npm"
/ "node_modules"
/ "@larksuite"
/ "cli"
/ "scripts"
/ "run.js"
)
if not run_js.exists():
raise RuntimeError(f"找不到 lark-cli 入口: {run_js}")
command = [
"node", str(run_js),
"--profile", "hermes-analyzer",
"im", "+messages-send",
"--user-id", open_id,
"--content", content_json,
"--msg-type", "interactive",
"--as", "bot",
"--format", "json",
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
stdout = completed.stdout
stderr = completed.stderr
else:
command = [
"lark-cli",
"--profile", "hermes-analyzer",
"im", "+messages-send",
"--user-id", open_id,
"--content", content_json,
"--msg-type", "interactive",
"--as", "bot",
"--format", "json",
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
stdout = completed.stdout
stderr = completed.stderr
if completed.returncode != 0:
raise RuntimeError(
f"lark-cli 发送失败 (exit {completed.returncode}):\n"
f"stdout: {stdout}\nstderr: {stderr}"
)
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {"raw": stdout}
def main() -> int:
parser = argparse.ArgumentParser(description="单篇笔记 → 本地过滤 + Hermes 分析 → 项目 data/summary 报告")
source_group = parser.add_mutually_exclusive_group(required=True)
source_group.add_argument("--input", type=Path, help="单篇笔记 JSON 文件路径")
source_group.add_argument("--note-id", type=int, help="数据库 cmt_notes.id")
source_group.add_argument("--url", type=str, help="数据库 cmt_notes.url")
parser.add_argument("--output", type=Path, default=OUTPUT_PATH, help="报告输出路径")
parser.add_argument("--open-id", default=TARGET_OPEN_ID, help="飞书接收人 open_id")
parser.add_argument("--brand", default="", help="我方品牌名(用于实体隔离/产品隔离规则;不传则只用款式名)")
parser.add_argument("--no-send", action="store_true", help="只生成报告文件,不发送飞书")
parser.add_argument("--llm-filter", action="store_true",
help="用 LLM 语义清洗评论(替代关键词过滤,保留更多有效评论,但慢)")
parser.add_argument("--batch-size", type=int, default=30,
help="LLM 清洗的批大小(默认 30 条/批)")
parser.add_argument("--max-workers", type=int, default=6,
help="LLM 清洗的并发批数(默认 6)")
args = parser.parse_args()
args.output = resolve_layer_output(
args.output, layer_root=PATHS.exports_root, field="--output"
)
if not args.no_send:
if not HERMES_ANALYZER_TOKEN:
log("[ERROR] HERMES_ANALYZER_TOKEN 未设置。请配置 data/config/analyze.env 或传 --no-send")
return 1
if not args.open_id:
log("[ERROR] 飞书接收人 open_id 未设置。请配置 FEISHU_TARGET_OPEN_ID 或传 --open-id 或 --no-send")
return 1
if args.input:
log(f"读取单篇笔记 JSON: {args.input}")
try:
data = load_note_json(args.input)
except Exception as exc:
log(f"读取失败: {exc}")
return 1
info = extract_note_info(data)
raw_comments = extract_comment_texts(data)
elif args.note_id or args.url:
try:
info, raw_comments = load_note_from_db(note_id=args.note_id, url=args.url)
except Exception as exc:
log(f"从数据库读取失败: {exc}")
return 1
else:
log("必须提供 --input、--note-id 或 --url")
return 1
log(f"笔记标题: {info['title'] or '未获取'} | 平台: {info['platform']} | 总评论: {info['comment_count_total']}")
if not raw_comments:
# 无评论只代表评论洞察不可用,不应让整篇笔记及其曝光/互动指标
# 从周汇总中消失。后续会生成数据型报告,并明确标注无评论。
log("该笔记没有评论内容,跳过评论洞察并继续生成数据型报告")
# 先把 brand 注入到 info,再算关键词分布(需要 target_terms 拆分相关/全部)
if args.brand:
info["brand"] = args.brand
else:
info.setdefault("brand", "")
metrics = compute_metrics(info)
try:
if args.llm_filter:
log(f"使用 LLM 语义清洗评论(批大小 {args.batch_size}, 并发 {args.max_workers})...")
filtered_comments = llm_clean_comments(raw_comments,
batch_size=args.batch_size,
max_workers=args.max_workers)
else:
filtered_comments = code_filter_comments(raw_comments)
except Exception as exc:
log(f"评论过滤失败: {exc}")
return 1
target_terms = [t for t in (info.get("brand"), info.get("style_name")) if t]
distribution = keyword_distribution(filtered_comments, target_terms=target_terms)
log(f"关键词分布: 全评论 {len(filtered_comments)} 条,我方产品相关 {distribution['relevant_total']} 条")
try:
analysis = analyze_note(info, metrics, distribution, filtered_comments)
except Exception as exc:
log(f"笔记分析失败: {exc}")
return 1
report = build_report(info, metrics, distribution, raw_comments, filtered_comments, analysis)
try:
write_report(report, args.output)
except Exception as exc:
log(f"写入报告失败: {exc}")
return 1
if args.no_send:
log("已指定 --no-send,跳过飞书发送")
else:
# 卡片永远走精简版(给老板/团队 30 秒看完的复盘视图)
try:
result = send_feishu_report(
info, metrics, distribution,
raw_comments, filtered_comments, analysis,
args.open_id, args.output,
)
log(f"飞书卡片发送成功: {result}")
except Exception as exc:
log(f"飞书消息发送失败(报告文件仍已生成): {exc}")
return 1
log("全部完成")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\n已中断", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,204 @@
"""Batch re-scrape all Bilibili videos from DB using the browser scraper.
Updates:
- note_metrics (like / favorite / comment / share counts, title)
- cmt_comments table (replace existing)
- JSON/CSV files in data/notes/bilibili/
"""
import subprocess
import sys
import time
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper as scraper
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_bilibili.py"
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def collect_urls() -> list[dict]:
with db.get_dict_cursor() as cur:
cur.execute(
"""
SELECT id, url, title
FROM cmt_notes
WHERE platform = 'bilibili' AND url IS NOT NULL AND url != ''
ORDER BY id
"""
)
return cur.fetchall()
def upsert_metrics(note_id: int, title: str, metrics: dict) -> None:
db.update_note_metrics(
note_id,
like_count=metrics.get("like_count") or None,
favorite_count=metrics.get("favorite_count") or None,
comment_count=metrics.get("comment_count") or None,
share_count=metrics.get("share_count") or None,
title=title or None,
)
def _to_int(value) -> int | None:
try:
if value in (None, ""):
return None
return int(float(value))
except (TypeError, ValueError):
return None
def comment_replace_policy(comments: list[dict], stats: dict, metrics: dict) -> tuple[bool, bool, str]:
"""Return (should_replace, allow_empty, reason)."""
logged_in = bool(stats.get("logged_in"))
expected = _to_int(metrics.get("comment_count"))
if expected is None:
expected = _to_int(stats.get("api_total"))
if comments:
if not logged_in and (expected is None or expected > len(comments)):
return False, False, "未登录且可能只采到热门评论"
return True, False, ""
if expected == 0:
return True, True, "平台显示 0 评论"
return False, False, "空评论且无法确认平台为 0 评论"
def do_relogin() -> bool:
"""Run relogin_bilibili.py (headed, waits for QR scan). Returns True if successful."""
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
try:
rc = subprocess.call(
[sys.executable, str(RELOGIN_SCRIPT)],
cwd=str(_PROJECT_ROOT),
)
if rc == 0:
log(" 重登成功,继续采集")
return True
else:
log(f" 重登失败 (exit={rc})")
return False
except Exception as exc:
log(f" 重登脚本执行异常: {exc}")
return False
def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
rows = collect_urls()
if only_ids:
rows = [r for r in rows if r["id"] in set(only_ids)]
log(f"Total bilibili notes to scrape: {len(rows)}")
ok = 0
fail = 0
total_comments = 0
consecutive_fail = 0
MAX_CONSECUTIVE_FAIL = 3
relogin_used = 0
MAX_RELOGINS = 1
t0 = time.time()
for i, row in enumerate(rows, 1):
note_id = row["id"]
url = row["url"]
log(f"[{i}/{len(rows)}] id={note_id} url={url}")
comments = None
result = None
for attempt in range(3):
try:
comments, result = scraper.scrape_comments(
url, max_pages=200, delay=0.25, headless=True,
max_reply_pages=200, login_timeout=0,
)
break
except Exception as exc:
if attempt < 2 and "has been closed" in str(exc):
log(f" Browser closed, retrying ({attempt+2}/3)...")
time.sleep(3)
else:
log(f" ERROR: {exc}")
fail += 1
break
if comments is None:
consecutive_fail += 1
if consecutive_fail >= MAX_CONSECUTIVE_FAIL and relogin_used < MAX_RELOGINS:
if do_relogin():
relogin_used += 1
consecutive_fail = 0
continue
continue
consecutive_fail = 0
video = result.get("video", {}) or {}
title = video.get("title", "") or result.get("title", "")
stats = result.get("stats", {}) or {}
metrics = stats.get("note_metrics", {}) or {}
logged_in = bool(stats.get("logged_in"))
json_path, csv_path = scraper.write_outputs(url, comments, result)
top = len([c for c in comments if c["level"] == "comment"])
replies = len([c for c in comments if c["level"] == "reply"])
warn = "" if logged_in else " [WARN] 未登录,只采到热门评论"
log(
f" ok: title={title[:40]!r} | {len(comments)} comments ({top}+{replies}) | "
f"likes={metrics.get('like_count')}, favs={metrics.get('favorite_count')}, "
f"cmts={metrics.get('comment_count')}"
+ warn
)
if not dry_run:
try:
upsert_metrics(note_id, title, metrics)
except Exception as exc:
log(f" DB metric update error: {exc}")
try:
should_replace, allow_empty, reason = comment_replace_policy(comments, stats, metrics)
if should_replace:
db.replace_comments(note_id, "bilibili", comments, allow_empty=allow_empty)
else:
log(f" skip DB comment replace: {reason}")
except Exception as exc:
log(f" DB comment replace error: {exc}")
ok += 1
total_comments += len(comments)
elapsed = time.time() - t0
log(
f"DONE: {ok} ok, {fail} fail, {total_comments} comments total, "
f"{elapsed:.1f}s ({elapsed / max(ok, 1):.1f}s per note)"
)
return 0 if fail == 0 else 1
if __name__ == "__main__":
args = sys.argv[1:]
only_ids = []
dry_run = False
for a in args:
if a == "--dry-run":
dry_run = True
elif a.isdigit():
only_ids.append(int(a))
if only_ids:
log(f"Restricted to ids: {only_ids}")
if dry_run:
log("DRY RUN: will not touch DB")
raise SystemExit(main(only_ids or None, dry_run))
@@ -0,0 +1,252 @@
"""Batch re-scrape all Douyin notes from DB using the browser-based scraper.
Updates:
- note_metrics (like / favorite / comment / share counts)
- cmt_comments table (replace existing)
- JSON/CSV files in data/notes/douyin/
"""
import subprocess
import sys
import time
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
# Path setup: db.py is in data/tools/, scrapers are in project root
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper as scraper
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_douyin.py"
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def collect_urls() -> list[dict]:
with db.get_dict_cursor() as cur:
cur.execute(
"""
SELECT id, url, title, comment_count
FROM cmt_notes
WHERE platform = 'douyin' AND url IS NOT NULL AND url != ''
ORDER BY id
"""
)
rows = cur.fetchall()
fast = [r for r in rows if (r.get("comment_count") or 0) <= 200]
slow = [r for r in rows if (r.get("comment_count") or 0) > 200]
if fast:
log(f"Sorted: {len(fast)} notes ≤200 comments (fast first), {len(slow)} notes >200 (slow)")
return fast + slow
def upsert_metrics(note_id: int, metrics: dict) -> None:
db.update_note_metrics(
note_id,
like_count=metrics.get("like_count") or None,
favorite_count=metrics.get("favorite_count") or None,
comment_count=metrics.get("comment_count") or None,
share_count=metrics.get("share_count") or None,
title=metrics.get("title") or None,
)
def _to_int(value) -> int | None:
try:
if value in (None, ""):
return None
return int(float(value))
except (TypeError, ValueError):
return None
def comment_replace_policy(comments: list[dict], result: dict, metrics: dict) -> tuple[bool, bool, str]:
"""Return (should_replace, allow_empty, reason)."""
stats = result.get("stats", {}) if isinstance(result, dict) else {}
logged_in = bool(stats.get("logged_in"))
expected = _to_int(stats.get("api_total"))
if expected is None:
expected = _to_int(metrics.get("comment_count"))
if comments:
if not logged_in and (expected is None or expected > len(comments)):
return False, False, "未登录且可能只采到部分评论"
return True, False, ""
if expected == 0:
return True, True, "平台显示 0 评论"
return False, False, "空评论且无法确认平台为 0 评论"
def result_logged_in(result: dict | None) -> bool:
"""Read the login flag from the scraper result's nested stats payload."""
if not isinstance(result, dict):
return False
nested_stats = result.get("stats")
return bool(nested_stats.get("logged_in")) if isinstance(nested_stats, dict) else False
def do_relogin() -> bool:
"""Run relogin_douyin.py (headed, waits for QR scan). Returns True if successful."""
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
try:
rc = subprocess.call(
[sys.executable, str(RELOGIN_SCRIPT)],
cwd=str(_PROJECT_ROOT),
)
if rc == 0:
log(" 重登成功,继续采集")
return True
else:
log(f" 重登失败 (exit={rc})")
return False
except Exception as exc:
log(f" 重登脚本执行异常: {exc}")
return False
def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
rows = collect_urls()
if only_ids:
rows = [r for r in rows if r["id"] in set(only_ids)]
log(f"Total douyin notes to scrape: {len(rows)}")
ok = 0
fail = 0
total_comments = 0
consecutive_fail = 0
MAX_CONSECUTIVE_FAIL = 3
COOLDOWN_SECONDS = 300
cooldown_used = 0
MAX_COOLDOWNS = 3
relogin_used = 0
MAX_RELOGINS = 1 # 每轮 batch 最多自动重登1次
t0 = time.time()
for i, row in enumerate(rows, 1):
note_id = row["id"]
url = row["url"]
log(f"[{i}/{len(rows)}] id={note_id} url={url}")
comments = None
stats = None
for attempt in range(3):
try:
comments, stats = scraper.scrape_comments(url, 300, 60, 10, True)
break
except Exception as exc:
msg = str(exc)
retryable = attempt < 2 and (
"has been closed" in msg
or "page not loaded" in msg
or "Cannot extract aweme_id" in msg
)
if retryable:
wait_s = 3 * (attempt + 1)
label = (
"Browser closed" if "has been closed" in msg
else "Page not loaded" if "page not loaded" in msg
else "aweme_id failed"
)
log(f" {label}, retrying ({attempt+2}/3) after {wait_s}s...")
time.sleep(wait_s)
else:
log(f" ERROR: {exc}")
fail += 1
break
# Check logged_in status even on success
logged_in = result_logged_in(stats)
if comments is None:
consecutive_fail += 1
# 连续失败 + 可能 cookie 过期 → 尝试自动重登
if consecutive_fail >= MAX_CONSECUTIVE_FAIL and relogin_used < MAX_RELOGINS:
if do_relogin():
relogin_used += 1
consecutive_fail = 0
continue # retry the current URL in next iteration
else:
# 重登失败,走冷却逻辑
pass
if consecutive_fail >= MAX_CONSECUTIVE_FAIL:
if cooldown_used < MAX_COOLDOWNS:
cooldown_used += 1
log(
f" {consecutive_fail} consecutive failures, cooling down "
f"{COOLDOWN_SECONDS}s (cooldown {cooldown_used}/{MAX_COOLDOWNS})..."
)
time.sleep(COOLDOWN_SECONDS)
consecutive_fail = 0
continue
else:
log(
f" {MAX_COOLDOWNS} cooldowns exhausted, aborting batch; "
f"{len(rows) - i} notes skipped"
)
break
continue
consecutive_fail = 0
json_path, csv_path = scraper.write_outputs(url, comments, stats)
metrics = stats.get("stats", {}).get("note_metrics", {})
top = len([c for c in comments if c["level"] == "comment"])
replies = len([c for c in comments if c["level"] == "reply"])
warn = "" if logged_in else " [WARN] 未登录,可能只采到部分评论"
log(
f" ok: {len(comments)} comments ({top} top + {replies} replies) | "
f"likes={metrics.get('like_count')}, favs={metrics.get('favorite_count')}, "
f"cmts={metrics.get('comment_count')}, shares={metrics.get('share_count')}"
+ warn
)
if not dry_run:
try:
upsert_metrics(note_id, metrics)
except Exception as exc:
log(f" DB metric update error: {exc}")
try:
should_replace, allow_empty, reason = comment_replace_policy(comments, stats, metrics)
if should_replace:
db.replace_comments(note_id, "douyin", comments, allow_empty=allow_empty)
else:
log(f" skip DB comment replace: {reason}")
except Exception as exc:
log(f" DB comment replace error: {exc}")
ok += 1
total_comments += len(comments)
elapsed = time.time() - t0
log(
f"DONE: {ok} ok, {fail} fail, {total_comments} comments total, "
f"{elapsed:.1f}s ({elapsed / max(ok, 1):.1f}s per note)"
)
return 0 if fail == 0 else 1
if __name__ == "__main__":
args = sys.argv[1:]
only_ids = []
dry_run = False
for a in args:
if a == "--dry-run":
dry_run = True
elif a.isdigit():
only_ids.append(int(a))
if only_ids:
log(f"Restricted to ids: {only_ids}")
if dry_run:
log("DRY RUN: will not touch DB")
raise SystemExit(main(only_ids or None, dry_run))
@@ -0,0 +1,200 @@
"""Batch re-scrape all Xiaohongshu notes from DB using the browser scraper.
Updates:
- note_metrics (like / favorite / comment / share counts, title)
- cmt_comments table (replace existing)
- JSON/CSV files in data/notes/xiaohongshu/
"""
import subprocess
import sys
import time
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper as scraper
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_xiaohongshu.py"
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def collect_urls() -> list[dict]:
with db.get_dict_cursor() as cur:
cur.execute(
"""
SELECT id, url, title
FROM cmt_notes
WHERE platform = 'xiaohongshu' AND url IS NOT NULL AND url != ''
ORDER BY id
"""
)
return cur.fetchall()
def upsert_metrics(note_id: int, title: str, metrics: dict) -> None:
db.update_note_metrics(
note_id,
like_count=metrics.get("like_count") or None,
favorite_count=metrics.get("favorite_count") or None,
comment_count=metrics.get("comment_count") or None,
share_count=metrics.get("share_count") or None,
title=title or None,
)
def _to_int(value) -> int | None:
try:
if value in (None, ""):
return None
return int(float(value))
except (TypeError, ValueError):
return None
def comment_replace_policy(comments: list[dict], stats: dict, metrics: dict) -> tuple[bool, bool, str]:
"""Return (should_replace, allow_empty, reason)."""
logged_in = bool(stats.get("logged_in"))
expected = _to_int(metrics.get("comment_count"))
if expected is None:
expected = _to_int(stats.get("api_total"))
if comments:
if not logged_in and (expected is None or expected > len(comments)):
return False, False, "未登录且可能只采到部分评论"
return True, False, ""
if expected == 0:
return True, True, "平台显示 0 评论"
return False, False, "空评论且无法确认平台为 0 评论"
def do_relogin() -> bool:
"""Run relogin_xiaohongshu.py (headed, waits for QR scan). Returns True if successful."""
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
try:
rc = subprocess.call(
[sys.executable, str(RELOGIN_SCRIPT)],
cwd=str(_PROJECT_ROOT),
)
if rc == 0:
log(" 重登成功,继续采集")
return True
else:
log(f" 重登失败 (exit={rc})")
return False
except Exception as exc:
log(f" 重登脚本执行异常: {exc}")
return False
def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
rows = collect_urls()
if only_ids:
rows = [r for r in rows if r["id"] in set(only_ids)]
log(f"Total xiaohongshu notes to scrape: {len(rows)}")
ok = 0
fail = 0
total_comments = 0
consecutive_fail = 0
MAX_CONSECUTIVE_FAIL = 3
relogin_used = 0
MAX_RELOGINS = 1
t0 = time.time()
for i, row in enumerate(rows, 1):
note_id = row["id"]
url = row["url"]
log(f"[{i}/{len(rows)}] id={note_id} url={url}")
comments = None
result = None
for attempt in range(3):
try:
comments, result = scraper.scrape_comments(url, 60, 30, 4, True)
break
except Exception as exc:
if attempt < 2 and "has been closed" in str(exc):
log(f" Browser closed, retrying ({attempt+2}/3)...")
time.sleep(3)
else:
log(f" ERROR: {exc}")
fail += 1
break
if comments is None:
consecutive_fail += 1
if consecutive_fail >= MAX_CONSECUTIVE_FAIL and relogin_used < MAX_RELOGINS:
if do_relogin():
relogin_used += 1
consecutive_fail = 0
continue
# No relogin or relogin failed → skip this note
continue
consecutive_fail = 0
title = result.get("title", "")
stats = result.get("stats", {})
metrics = stats.get("note_metrics", {}) or {}
logged_in = bool(stats.get("logged_in"))
json_path, csv_path = scraper.write_outputs(url, comments, result)
top = len([c for c in comments if c["level"] == "comment"])
replies = len([c for c in comments if c["level"] == "reply"])
warn = "" if logged_in else " [WARN] 未登录,可能只采到部分评论"
log(
f" ok: title={title[:40]!r} | {len(comments)} comments ({top}+{replies}) | "
f"likes={metrics.get('like_count')}, favs={metrics.get('favorite_count')}, "
f"cmts={metrics.get('comment_count')}"
+ warn
)
if not dry_run:
try:
upsert_metrics(note_id, title, metrics)
except Exception as exc:
log(f" DB metric update error: {exc}")
try:
should_replace, allow_empty, reason = comment_replace_policy(comments, stats, metrics)
if should_replace:
db.replace_comments(note_id, "xiaohongshu", comments, allow_empty=allow_empty)
else:
log(f" skip DB comment replace: {reason}")
except Exception as exc:
log(f" DB comment replace error: {exc}")
ok += 1
total_comments += len(comments)
elapsed = time.time() - t0
log(
f"DONE: {ok} ok, {fail} fail, {total_comments} comments total, "
f"{elapsed:.1f}s ({elapsed / max(ok, 1):.1f}s per note)"
)
return 0 if fail == 0 else 1
if __name__ == "__main__":
args = sys.argv[1:]
only_ids = []
dry_run = False
for a in args:
if a == "--dry-run":
dry_run = True
elif a.isdigit():
only_ids.append(int(a))
if only_ids:
log(f"Restricted to ids: {only_ids}")
if dry_run:
log("DRY RUN: will not touch DB")
raise SystemExit(main(only_ids or None, dry_run))
@@ -0,0 +1,42 @@
"""统计 24 张表里 creator_id / creator_name 填充率"""
import json
import glob
from pathlib import Path
per_table = []
total_records = total_with_id = total_with_name = 0
for path in sorted(
glob.glob("data/feishu_tables/[0-9]*.json"),
key=lambda p: int(Path(p).stem.split("-")[0]),
):
d = json.load(open(path, encoding="utf-8"))
name = d.get("name")
fid_to_name = d.get("field_id_to_name", {})
name_fid = id_fid = None
for fid, fname in fid_to_name.items():
cname = (fname or "").rstrip("?").rstrip()
if cname in ("达人名称", "达人昵称"):
name_fid = fid
elif cname in ("达人id", "达人id号", "达人ID", "星图ID"):
id_fid = fid
n_with_id = n_with_name = 0
n_records = len(d.get("records", []))
for rec in d.get("records", []):
if id_fid and rec.get(id_fid):
n_with_id += 1
if name_fid and rec.get(name_fid):
n_with_name += 1
per_table.append((name, n_records, n_with_id, n_with_name))
total_records += n_records
total_with_id += n_with_id
total_with_name += n_with_name
print(f"{'款式':<14} {'总记录':>5} {'有ID':>5} {'有名称':>6} {'填充率(ID)':>10}")
for name, n, nid, nnm in per_table:
rate = f"{100*nid/n:.1f}%" if n else "-"
print(f"{name:<14} {n:>5} {nid:>5} {nnm:>6} {rate:>10}")
print()
print(f"总计 {total_records} 条")
print(f" 有 creator_id: {total_with_id} ({100*total_with_id/total_records:.1f}%)")
print(f" 有 creator_name:{total_with_name} ({100*total_with_name/total_records:.1f}%)")
@@ -0,0 +1,179 @@
"""查看每个平台 24 款式的采集状态。
判定规则(对单个平台):
OK - v2 JSON 文件存在,且 mtime 在 --stale-hours 小时内
STALE - v2 JSON 文件存在,但 mtime 超过 --stale-hours 小时(需要重跑)
MISSING - v2 JSON 文件不存在(进程崩溃/被中断)
EMPTY - v2 JSON 存在但 matched=0、filled=0、total=0 (该款式确实没数据)
HALF - v2 JSON 存在但 results 里至少有一条 error (部分采集成功)
用法:
python data/tools/check_status.py # 看三个平台
python data/tools/check_status.py --platform xt # 只看星图
python data/tools/check_status.py --stale-hours 4 # 4 小时以上算陈旧
"""
import argparse
import datetime as dt
import json
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
PROJECT_DIR = PATHS.module_root
V2_DIR = PATHS.normalized_root / "v2_results"
# 共享工具
from gyxx_flow.modules.content_marketing.runtime.data.tools.v2_filename import parse_v2_filename # noqa: E402
PLATFORMS = {
"bili": {"name": "B 站", "label": "bili"},
"pgy": {"name": "小红书蒲公英", "label": "xhs"},
"xt": {"name": "星图", "label": "xt"},
}
# 款式总数 1-24
STYLE_INDICES = list(range(1, 25))
def scan_platform(platform_key: str, stale_hours: float) -> dict[int, dict]:
"""扫一个平台 v2_results,返回 {index: {status, mtime, matched, ...}}"""
label = PLATFORMS[platform_key]["label"]
found: dict[int, dict] = {}
for path in V2_DIR.glob("*.json"):
idx, name, plat, _ = parse_v2_filename(path.name)
if plat != label or idx is None:
continue
mtime = dt.datetime.fromtimestamp(path.stat().st_mtime)
age_h = (dt.datetime.now() - mtime).total_seconds() / 3600.0
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = {}
matched = data.get("matched") or data.get("updated") or 0
filled = data.get("filled") or matched
total = (data.get("total") or data.get("total_b_records") or 0)
results = data.get("results") or data.get("details") or []
has_error = any(isinstance(r, dict) and r.get("error") for r in results)
if total == 0 and matched == 0 and not has_error:
status = "EMPTY"
elif has_error and matched > 0:
status = "HALF"
elif has_error:
status = "HALF"
elif age_h > stale_hours:
status = "STALE"
else:
status = "OK"
found[idx] = {
"status": status,
"name": name,
"path": path.name,
"mtime": mtime,
"age_h": age_h,
"matched": matched,
"filled": filled,
"total": total,
"has_error": has_error,
}
return found
def color(s: str, code: str) -> str:
if not sys.stdout.isatty():
return s
return f"\033[{code}m{s}\033[0m"
STATUS_COLOR = {
"OK": ("32",), # green
"STALE": ("33",), # yellow
"MISSING": ("31",), # red
"EMPTY": ("90",), # grey
"HALF": ("33",), # yellow
}
def fmt_status(s: str) -> str:
code = STATUS_COLOR.get(s, ("",))[0]
label_map = {
"OK": " OK ",
"STALE": " STALE ",
"MISSING": "MISSING",
"EMPTY": " EMPTY ",
"HALF": " HALF ",
}
return color(label_map.get(s, s), code)
def print_platform(platform_key: str, info: dict, stale_hours: float) -> tuple[int, int, int]:
name = info["name"]
by_idx = scan_platform(platform_key, stale_hours)
counts = {"OK": 0, "STALE": 0, "MISSING": 0, "EMPTY": 0, "HALF": 0}
print(f"\n{'=' * 78}")
print(f" {name} (stale > {stale_hours}h)")
print(f"{'=' * 78}")
print(f" {'款式':>4} {'状态':<8} {'名称':<14} {'matched':>7} {'total':>5} "
f"{'err':>3} {'mtime':<19} {'age':>5}")
print(" " + "-" * 74)
for idx in STYLE_INDICES:
if idx in by_idx:
r = by_idx[idx]
counts[r["status"]] += 1
err_n = sum(1 for x in (r.get("path"),) if x and "error" in x) # placeholder
# 计算 has_error 计数
try:
data = json.loads((V2_DIR / r["path"]).read_text(encoding="utf-8"))
results = data.get("results") or data.get("details") or []
err_n = sum(1 for x in results if isinstance(x, dict) and x.get("error"))
except Exception:
err_n = 0
mtime_str = r["mtime"].strftime("%Y-%m-%d %H:%M:%S")
print(f" {idx:>4} {fmt_status(r['status'])} {r['name']:<14} "
f"{r['matched']:>7} {r['total']:>5} {err_n:>3} "
f"{mtime_str:<19} {r['age_h']:>4.1f}h")
else:
counts["MISSING"] += 1
print(f" {idx:>4} {fmt_status('MISSING')} {'-':<14} {'-':>7} {'-':>5} "
f"{'-':>3} {'-':<19} {'-':>5}")
print(" " + "-" * 74)
total = sum(counts.values())
print(f" 小计: OK={counts['OK']} STALE={counts['STALE']} "
f"MISSING={counts['MISSING']} EMPTY={counts['EMPTY']} "
f"HALF={counts['HALF']} / 共 {total}")
# 待补跑 = MISSING + STALE + HALF(只算有 matched>0 的)
need_retry = counts["MISSING"] + counts["STALE"]
print(f" 待补跑: {need_retry} 个款式")
return counts["MISSING"] + counts["STALE"], counts["HALF"], total
def main() -> int:
ap = argparse.ArgumentParser(description="查看各平台 24 款式采集状态")
ap.add_argument("--platform", type=str, default="bili,pgy,xt",
help="逗号分隔平台键 bili/pgy/xt,默认全看")
ap.add_argument("--stale-hours", type=float, default=26.0,
help="超过多少小时算 STALE(默认 26h = 每日一次)")
args = ap.parse_args()
keys = [k.strip() for k in args.platform.split(",") if k.strip()]
for k in keys:
if k not in PLATFORMS:
print(f"[ERROR] 未知平台: {k} 可选: {','.join(PLATFORMS.keys())}")
return 1
print(f"扫描目录: {V2_DIR}")
grand_retry = grand_half = grand_total = 0
for k in keys:
retry_n, half_n, total_n = print_platform(k, PLATFORMS[k], args.stale_hours)
grand_retry += retry_n
grand_half += half_n
grand_total += total_n
print(f"\n{'=' * 78}")
print(f" 汇总: 待补跑 {grand_retry} 款 (HALF {grand_half} 款) / 共 {grand_total} 款")
print(f" 补跑命令: python data/tools/retry_failed.py")
print(f"{'=' * 78}\n")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,188 @@
import argparse
import csv
import json
import sys
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
PATHS,
resolve_layer_output,
)
# Path setup: scrapers are in project root, feishu_comment_batch is in data/tools/
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from scrapling.fetchers import DynamicSession
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper
from gyxx_flow.modules.content_marketing.runtime.data.tools.feishu_comment_batch import detect_platform, normalize_note_url
def unique_note_urls(source_csv: Path, platform: str) -> list[str]:
seen: set[str] = set()
urls: list[str] = []
with source_csv.open(encoding="utf-8-sig", newline="") as file:
for row in csv.DictReader(file):
url = normalize_note_url(row.get("note_url") or row.get("笔记链接") or "")
if not url or detect_platform(url) != platform or url in seen:
continue
seen.add(url)
urls.append(url)
return urls
def collect_douyin(urls: list[str], headless: bool) -> list[dict[str, Any]]:
"""Collect Douyin note metrics via browser (Playwright + XHR interception)."""
rows: list[dict[str, Any]] = []
def make_action(url: str):
def action(page):
page.wait_for_timeout(7000)
metrics = douyin_comment_scraper.extract_note_metrics(page)
rows.append({
"platform": "douyin",
"note_url": url,
"like_count": metrics.get("like_count", ""),
"favorite_count": metrics.get("favorite_count", ""),
"share_count": metrics.get("share_count", ""),
"comment_count": metrics.get("comment_count", ""),
})
print(f"douyin {len(rows)}/{len(urls)} {metrics} {url}", flush=True)
return action
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(douyin_comment_scraper.PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=douyin_comment_scraper.restore_cookies,
max_pages=1,
) as session:
for url in urls:
try:
normalized = douyin_comment_scraper.normalize_douyin_url(url)
session.fetch(normalized, page_action=make_action(url), wait=300)
except Exception as exc:
rows.append({"platform": "douyin", "note_url": url, "like_count": "", "favorite_count": "", "share_count": "", "comment_count": "", "error": repr(exc)})
print(f"douyin failed {url} {exc!r}", flush=True)
return rows
def collect_xiaohongshu(urls: list[str], headless: bool) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
def make_action(url: str):
def action(page):
page.wait_for_timeout(7000)
metrics = xiaohongshu_comment_scraper.extract_note_metrics(page)
rows.append({"platform": "xiaohongshu", "note_url": url, **metrics})
print(f"xiaohongshu {len(rows)}/{len(urls)} {metrics} {url}", flush=True)
return action
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(xiaohongshu_comment_scraper.PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=xiaohongshu_comment_scraper.restore_cookies,
max_pages=1,
) as session:
for url in urls:
try:
session.fetch(url, page_action=make_action(url), wait=300)
except Exception as exc:
rows.append({"platform": "xiaohongshu", "note_url": url, "like_count": "", "favorite_count": "", "share_count": "", "comment_count": "", "error": repr(exc)})
print(f"xiaohongshu failed {url} {exc!r}", flush=True)
return rows
def collect_bilibili(urls: list[str], headless: bool) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
def make_action(url: str):
def action(page):
page.wait_for_timeout(1500)
video = bilibili_comment_scraper.resolve_video_info(page, url)
metrics = video.get("note_metrics") or {}
rows.append({"platform": "bilibili", "note_url": url, **metrics})
print(f"bilibili {len(rows)}/{len(urls)} {metrics} {url}", flush=True)
return action
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(bilibili_comment_scraper.PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=bilibili_comment_scraper.restore_cookies,
max_pages=1,
) as session:
for url in urls:
try:
session.fetch(url, page_action=make_action(url), wait=300)
except Exception as exc:
rows.append({"platform": "bilibili", "note_url": url, "like_count": "", "favorite_count": "", "share_count": "", "comment_count": "", "error": repr(exc)})
print(f"bilibili failed {url} {exc!r}", flush=True)
return rows
def write_rows(rows: list[dict[str, Any]], output: Path) -> None:
output.parent.mkdir(exist_ok=True)
fields = ["platform", "note_url", "like_count", "favorite_count", "share_count", "comment_count", "error"]
with output.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field, "") for field in fields})
output.with_suffix(".json").write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source-csv", required=True)
parser.add_argument("--platform", choices=["douyin", "xiaohongshu", "bilibili"], required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--headless", action="store_true")
args = parser.parse_args()
source_csv = Path(args.source_csv)
output = resolve_layer_output(
args.output, layer_root=PATHS.normalized_root, field="--output"
)
urls = unique_note_urls(source_csv, args.platform)
print(f"{args.platform}: {len(urls)} unique notes", flush=True)
if args.platform == "douyin":
rows = collect_douyin(urls, args.headless)
elif args.platform == "xiaohongshu":
rows = collect_xiaohongshu(urls, args.headless)
else:
rows = collect_bilibili(urls, args.headless)
write_rows(rows, output)
print(f"wrote {output}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""构建全款式同权经营看板所需的确定性指标与榜单。"""
from __future__ import annotations
from collections import defaultdict
from datetime import date, timedelta
import os
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
try:
from .db import get_conn
except ImportError:
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
MIN_CONVERSION_RANK_VISITORS = 30
MIN_REFUND_RANK_SALES = 5
ROOT = PATHS.module_root
IMAGE_ROOTS = [
ROOT,
Path(os.environ.get("GYXX_PRODUCT_ASSET_ROOT", str(ROOT))),
]
PLATFORM_IMAGE_CODES = {"天猫": "tm", "京东": "jd", "抖音电商": "dy"}
STYLE_CATEGORY_ORDER = ("都市机能", "户外机能", "都市新贵", "智性通勤", "都市新旅", "都市运动", "未分类")
def load_style_categories(style_names: list[str]) -> dict[str, str]:
if not style_names:
return {}
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT name, COALESCE(NULLIF(BTRIM(style_category), ''), '未分类')
FROM cmt_styles
WHERE name = ANY(%s)
""",
(style_names,),
)
return {name: category for name, category in cur.fetchall()}
def load_creator_connections(metric_date: date, style_names: list[str]) -> dict[str, dict[str, Any]]:
if not style_names:
return {}
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT s.name,
COUNT(DISTINCT c.creator_id) FILTER (WHERE c.creator_id IS NOT NULL) creator_count,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT NULLIF(BTRIM(c.content_direction), '')), NULL) directions
FROM cmt_styles s
LEFT JOIN cmt_cooperations c
ON c.style_id = s.id
AND COALESCE(c.publish_time::date, c.cooperation_date) BETWEEN %s AND %s
WHERE s.name = ANY(%s)
GROUP BY s.name
""",
(metric_date - timedelta(days=29), metric_date, style_names),
)
return {
style_name: {"creator_count": creator_count or 0, "directions": directions or []}
for style_name, creator_count, directions in cur.fetchall()
}
def load_style_images(style_names: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
"""为三平台画像卡统一选择同款天猫高点击率主图。"""
if not style_names:
return {}
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT style_name, platform, collect_date, local_image_path, creative_image_url,
COALESCE(impressions, 0) impressions, click_rate
FROM main_image_creatives
WHERE style_name = ANY(%s)
AND platform = 'tm'
AND image_downloaded = TRUE
AND local_image_path IS NOT NULL
AND COALESCE(impressions, 0) >= 100
ORDER BY COALESCE(click_rate, 0) DESC, COALESCE(impressions, 0) DESC, collect_date DESC
""",
(style_names,),
)
rows = cur.fetchall()
by_style: dict[str, list[dict[str, Any]]] = defaultdict(list)
for style_name, platform, collect_date, local_path, url, impressions, click_rate in rows:
relative = Path(str(local_path))
absolute = next((root / relative for root in IMAGE_ROOTS if (root / relative).exists()), None)
if absolute is None:
continue
by_style[style_name].append({
"local_image_path": str(absolute),
"image_url": url,
"image_platform": {"tm": "天猫", "jd": "京东", "dy": "抖音电商"}.get(platform, platform),
"image_date": collect_date,
"image_impressions": impressions,
"image_ctr": float(click_rate) if click_rate is not None else None,
})
result: dict[tuple[str, str], dict[str, Any]] = {}
for style_name in style_names:
candidates = by_style.get(style_name, [])
for target in ("天猫", "京东", "抖音电商"):
if candidates:
result[(style_name, target)] = candidates[0]
return result
def _rate(numerator: Any, denominator: Any) -> float | None:
if numerator is None or denominator is None or denominator <= 0:
return None
return round(float(numerator) / float(denominator), 4)
def _percentile(values: list[float], fraction: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(len(ordered) - 1, max(0, round((len(ordered) - 1) * fraction)))
return ordered[index]
def _persona_text(persona: dict[str, Any] | None) -> str:
if not persona:
return "无数据"
labels = [persona.get(key) for key in ("gender_top", "age_top", "city_top", "buying_power_top") if persona.get(key)]
return "、".join(labels) if labels else "无数据"
def _interest_hypothesis(persona: dict[str, Any] | None, directions: list[str]) -> str:
if not persona:
return "无数据"
source = " ".join(str(value or "") for value in persona.values())
interests: list[str] = []
if any(term in source for term in ("女性", "女")):
interests.extend(["穿搭", "健身", "旅行摄影"])
if any(term in source for term in ("男性", "男")):
interests.extend(["数码3C", "城市通勤", "户外出行"])
if any(term in source for term in ("18-24", "18-25", "25-29")):
interests.extend(["潮流内容", "社交分享"])
if any(term in source for term in ("26-35", "30-34", "35-39")):
interests.extend(["职场效率", "短途出行"])
if any(term in source for term in ("56岁以上", "46-55", "45-49", "50")):
interests.extend(["品质消费", "舒适出行"])
if any(term in source for term in ("土豪", "高级白领", "L4", "L5")):
interests.extend(["品质材质", "设计感"])
interests.extend(direction for direction in directions if direction)
unique = list(dict.fromkeys(interests))[:4]
return "兴趣推测:" + "、".join(unique) if unique else "兴趣推测:无足够标签"
def _optimization(role: str, has_persona: bool) -> str:
persona_action = "按画像扩展相似人群" if has_persona else "先补采人群画像"
if role == "conversion":
return f"高转化扩量:{persona_action},逐步增加流量并监控退款订单比"
return f"高访客提转化:{persona_action},优化主图/详情页/利益点并做加购再营销"
def _diagnose(row: dict[str, Any], visitor_q75: float, conversion_median: float, cart_median: float, refund_q75: float) -> str:
if row.get("visitors") is None:
return "无数据"
conversion = row.get("conversion_rate")
refund = row.get("refund_rate")
cart_rate = row.get("cart_rate")
if refund is not None and refund >= max(0.3, refund_q75):
return "退款压力"
if row["visitors"] >= visitor_q75 and conversion is not None and conversion >= conversion_median:
return "高流量高转化"
if row["visitors"] >= visitor_q75:
return "高流量·转化待提"
if conversion is not None and conversion >= conversion_median * 1.25:
return "低流量·高转化可放量"
if cart_rate is not None and cart_rate < cart_median * 0.7:
return "加购偏弱"
return "常规观察"
def build_dashboard_facts(
report_date: str,
tracking_rows: list[dict[str, Any]],
platform_metrics: list[dict[str, Any]],
personas: list[dict[str, Any]],
connections: dict[str, dict[str, Any]],
style_images: dict[tuple[str, str], dict[str, Any]] | None = None,
daily_notes: list[dict[str, Any]] | None = None,
style_categories: dict[str, str] | None = None,
style_signals: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
styles = []
for source in tracking_rows:
row = dict(source)
row["cart_rate"] = _rate(row.get("cart_users"), row.get("visitors"))
row["conversion_rate"] = _rate(row.get("sales"), row.get("visitors"))
row["refund_rate"] = _rate(row.get("refund_orders"), row.get("sales"))
connection = connections.get(row["style_name"], {})
row["creator_connections_30d"] = int(connection.get("creator_count") or 0)
row["content_directions_30d"] = connection.get("directions") or []
row["style_category"] = (style_categories or {}).get(row["style_name"]) or "未分类"
row["multi_source_signals"] = (style_signals or {}).get(row["style_name"], {})
row["recent_note_exposure"] = row["multi_source_signals"].get("recent_note_exposure")
styles.append(row)
visitors = [float(row["visitors"]) for row in styles if row.get("visitors") is not None]
conversions = [float(row["conversion_rate"]) for row in styles if row.get("conversion_rate") is not None]
carts = [float(row["cart_rate"]) for row in styles if row.get("cart_rate") is not None]
refunds = [float(row["refund_rate"]) for row in styles if row.get("refund_rate") is not None]
visitor_q75 = _percentile(visitors, 0.75)
conversion_median = _percentile(conversions, 0.5)
cart_median = _percentile(carts, 0.5)
refund_q75 = _percentile(refunds, 0.75)
for row in styles:
base = _diagnose(row, visitor_q75, conversion_median, cart_median, refund_q75)
signal = row["multi_source_signals"]
evidence = ["经营"]
if signal.get("persona"):
evidence.append("画像")
if signal.get("note_count"):
evidence.append("营销笔记")
if signal.get("comment_count"):
evidence.append("评论")
if signal.get("review_count"):
evidence.append("商品评价")
if signal.get("creative_ctr") is not None:
evidence.append("主图")
row["evidence_summary"] = "、".join(evidence)
if (signal.get("review_negative_rate") or 0) >= 0.20:
row["diagnosis"] = f"{base}·评价风险" if base != "无数据" else "评价风险"
elif (signal.get("comment_negative_rate") or 0) >= 0.20:
row["diagnosis"] = f"{base}·评论风险" if base != "无数据" else "评论风险"
else:
row["diagnosis"] = base
category_rank = {name: index for index, name in enumerate(STYLE_CATEGORY_ORDER)}
ordered_styles = sorted(
styles,
key=lambda row: (
category_rank.get(row["style_category"], len(category_rank)),
row["style_category"],
row["style_name"],
),
)
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in ordered_styles:
grouped[row["style_category"]].append(row)
style_groups = [
{"style_category": category, "styles": grouped[category]}
for category in sorted(grouped, key=lambda value: (category_rank.get(value, len(category_rank)), value))
]
def top(metric: str, eligible=lambda row: True) -> list[dict[str, Any]]:
rows = [row for row in styles if row.get(metric) is not None and eligible(row)]
return sorted(rows, key=lambda row: (row[metric], row.get("visitors") or 0), reverse=True)[:5]
rankings = {
"conversion": top("conversion_rate", lambda row: (row.get("visitors") or 0) >= MIN_CONVERSION_RANK_VISITORS),
"visitors": top("visitors"),
"cart_users": top("cart_users"),
"sales": top("sales"),
"refund_rate": top("refund_rate", lambda row: (row.get("sales") or 0) >= MIN_REFUND_RANK_SALES),
}
persona_index = {(row.get("style_name"), row.get("platform")): row for row in personas}
directions = {row["style_name"]: row.get("content_directions_30d", []) for row in styles}
platform_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for source in platform_metrics:
row = dict(source)
row["conversion_rate"] = _rate(row.get("sales"), row.get("visitors"))
platform_groups[row.get("platform")].append(row)
platform_personas = []
preferred_platforms = ["天猫", "京东", "抖音电商"]
ordered_platforms = [name for name in preferred_platforms if name in platform_groups]
ordered_platforms.extend(sorted(name for name in platform_groups if name not in preferred_platforms))
for platform in ordered_platforms:
rows = platform_groups[platform]
conversion_eligible = [row for row in rows if (row.get("visitors") or 0) >= MIN_CONVERSION_RANK_VISITORS and row.get("conversion_rate") is not None]
conversion_winner = max(conversion_eligible, key=lambda row: row["conversion_rate"]) if conversion_eligible else None
visitor_winner = max(rows, key=lambda row: row.get("visitors") or -1) if rows else None
def winner_payload(winner: dict[str, Any] | None, role: str) -> dict[str, Any] | None:
if not winner:
return None
persona = persona_index.get((winner["style_name"], platform))
return {
**winner,
**((style_images or {}).get((winner["style_name"], platform)) or {}),
"persona_text": _persona_text(persona),
"persona_date": persona.get("data_date") if persona else None,
"interest_hypothesis": _interest_hypothesis(persona, directions.get(winner["style_name"], [])),
"optimization": _optimization(role, bool(persona)),
}
platform_personas.append({
"platform": platform,
"top_conversion": winner_payload(conversion_winner, "conversion"),
"top_visitors": winner_payload(visitor_winner, "visitors"),
})
return {
"report_date": report_date,
"styles": ordered_styles,
"style_groups": style_groups,
"rankings": rankings,
"platform_personas": platform_personas,
"daily_new_notes": sorted(daily_notes or [], key=lambda row: row.get("view_count") or 0, reverse=True),
"definitions": {
"conversion_rate": "销量/访客;榜单访客门槛≥30",
"cart_rate": "加购人数/访客",
"refund_rate": "退款订单/当日销量,可能含跨期退款",
"creator_connections": "近30天去重达人数量",
"sales_7d_total": "报告日及前6日累计销量",
"sales_forecast_7d": "最近3日日均销量×7;趋势相对前4日日均,仅作短期估算",
},
}
@@ -0,0 +1,57 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Daily SKU marketing operations report: Hermes analysis + Feishu dashboard and full report
REM Register: schtasks /Create /SC DAILY /TN YingxiaoYunying_DailyMarketingReport /TR %PROJECT_DIR%\data\tools\daily_marketing_report.bat /ST 10:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
set PYTHONIOENCODING=utf-8
set LARK_CLI_NO_PROXY=1
set LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1
set LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\daily_marketing_report_%TS%.log
echo === Daily marketing report started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
REM Wait for today's daily_run to finish (poll every 60s, max 90 min), so that
REM cmt_notes is synced before the report reads yesterday's new notes.
set TODAY=%date:~0,4%%date:~5,2%%date:~8,2%
set /a WAITED=0
:wait_daily_run
set DAILY_DONE=
for %%F in ("%LOG_DIR%\daily_run_%TODAY%_*.log") do findstr /C:"Daily run finished" /C:"Daily run skipped" "%%F" >nul 2>&1 && set DAILY_DONE=1
if defined DAILY_DONE goto daily_run_ready
if %WAITED% GEQ 5400 goto daily_run_timeout
timeout /t 60 /nobreak >nul
set /a WAITED+=60
goto wait_daily_run
:daily_run_timeout
echo WARN: daily_run not finished after 90 min wait, generating report anyway >> "%LOG_FILE%"
:daily_run_ready
cd /d "%PROJECT_DIR%"
call %PYTHON% -u -X utf8 daily_marketing_report.py --send >> "%LOG_FILE%" 2>&1
set RC=%ERRORLEVEL%
echo daily_marketing_report exit=%RC% >> "%LOG_FILE%"
echo === Daily marketing report finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
endlocal & exit /b %RC%
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""构造并发送时尚包袋品牌经营日报飞书 Card 2.0。"""
import hashlib
import json
import os
import re
import shutil
import subprocess
from collections import Counter
from pathlib import Path
from typing import Any, Iterable
from PIL import Image
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
LARK_PROFILE = "hermes-analyzer"
LARK_CLI = shutil.which("lark-cli.cmd") or shutil.which("lark-cli") or "lark-cli"
MAX_CARD_IMAGE_WIDTH = 1500
def _lark_command() -> list[str]:
run_js = (
Path(os.environ.get("APPDATA", str(Path.home())))
/ "npm" / "node_modules" / "@larksuite" / "cli" / "scripts" / "run.js"
)
if not run_js.exists():
run_js = Path(os.path.expandvars(r"%APPDATA%\npm\node_modules\@larksuite\cli\scripts\run.js"))
if run_js.exists():
return [shutil.which("node") or "node", str(run_js)]
return [LARK_CLI]
def _section_lines(report: str, start: str, end: str | None, limit: int = 3) -> list[str]:
start_match = re.search(rf"(?m)^\s*#*\s*{re.escape(start)}\s*$", report)
if not start_match:
return []
tail = report[start_match.end():]
if end:
end_match = re.search(rf"(?m)^\s*#*\s*{re.escape(end)}", tail)
if end_match:
tail = tail[:end_match.start()]
lines = []
for raw in tail.splitlines():
value = re.sub(r"^\s*\d+[\.、]\s*", "", raw).strip()
if not value or value.startswith("![") or value.startswith("|"):
continue
lines.append(value[:180])
if len(lines) >= limit:
break
return lines
def _metric_column(value: int, label: str, emphasis: bool = False) -> dict[str, Any]:
number_size = "heading-1" if emphasis else "heading-2"
return {
"tag": "column",
"width": "weighted",
"weight": 1,
"background_style": "grey-50",
"padding": "12px",
"vertical_spacing": "2px",
"elements": [
{"tag": "markdown", "content": str(value), "text_size": number_size, "text_align": "center"},
{"tag": "markdown", "content": f"<font color='grey'>{label}</font>", "text_size": "notation", "text_align": "center"},
],
}
def build_dashboard_card(
report_date: str,
tracking_rows: list[dict[str, Any]],
image_key: str,
) -> dict[str, Any]:
"""构造只包含核心灯号和看板图片的第一条消息。"""
counts = Counter(row.get("light", "无判断") for row in tracking_rows)
return {
"schema": "2.0",
"config": {
"update_multi": True,
"width_mode": "fill",
"enable_forward": True,
"summary": {"content": f"{report_date} 时尚包袋品牌经营看板"},
},
"header": {
"title": {"tag": "plain_text", "content": "时尚包袋品牌经营看板"},
"subtitle": {"tag": "plain_text", "content": f"{report_date} · Hermes 自动分析"},
"template": "blue",
"icon": {"tag": "standard_icon", "token": "chart_colorful"},
"text_tag_list": [
{"tag": "text_tag", "text": {"tag": "plain_text", "content": "数据看板"}, "color": "blue"}
],
},
"body": {
"direction": "vertical",
"padding": "12px 12px 20px 12px",
"vertical_spacing": "large",
"elements": [
{
"tag": "column_set",
"flex_mode": "none",
"horizontal_spacing": "12px",
"columns": [
_metric_column(counts.get("🔴", 0), "红灯款式", emphasis=True),
_metric_column(counts.get("🟡", 0), "黄灯款式"),
_metric_column(counts.get("🟢", 0), "绿灯款式"),
],
},
{
"tag": "img",
"img_key": image_key,
"alt": {"tag": "plain_text", "content": "全域种草数据看板"},
"scale_type": "fit_horizontal",
"corner_radius": "8px",
"preview": True,
},
],
},
}
def _report_without_dashboard_reference(report: str) -> str:
return re.sub(
r"(?m)^\s*!\[全域种草数据看板\]\([^\n]+\)\s*\n?",
"",
report,
count=1,
).strip()
def build_analysis_card(report_date: str, report: str) -> dict[str, Any]:
"""构造包含完整日报正文的第二条消息,不做行数或字数裁剪。"""
analysis = _report_without_dashboard_reference(report) or "无数据"
return {
"schema": "2.0",
"config": {
"update_multi": True,
"width_mode": "fill",
"enable_forward": True,
"summary": {"content": f"{report_date} SKU营销运营日报分析"},
},
"header": {
"title": {"tag": "plain_text", "content": "SKU营销运营日报分析"},
"subtitle": {"tag": "plain_text", "content": f"{report_date} · 完整文字版"},
"template": "turquoise",
"icon": {"tag": "standard_icon", "token": "doc_colorful"},
"text_tag_list": [
{"tag": "text_tag", "text": {"tag": "plain_text", "content": "完整分析"}, "color": "turquoise"}
],
},
"body": {
"direction": "vertical",
"padding": "12px 16px 20px 16px",
"vertical_spacing": "medium",
"elements": [{"tag": "markdown", "content": analysis}],
},
}
def build_daily_report_card(
report_date: str,
report: str,
tracking_rows: list[dict[str, Any]],
image_key: str,
) -> dict[str, Any]:
counts = Counter(row.get("light", "无判断") for row in tracking_rows)
conclusions = _section_lines(report, "1. 今日经营概览", "2.", 5)
if not conclusions:
conclusions = _section_lines(report, "一、今日经营概览", "二、", 5)
if not conclusions:
conclusions = _section_lines(report, "一、核心经营结论", "二、", 3)
if not conclusions:
conclusions = _section_lines(report, "第一部分 整体经营分析", "第二部分 SKU分析排序", 5)
actions = _section_lines(report, "6. 今日运营建议", None, 5)
if not actions:
actions = _section_lines(report, "七、明日业务动作", None, 5)
if not actions:
actions = _section_lines(report, "四、明日业务动作", None, 3)
if not actions:
actions = _section_lines(report, "第三部分 关键预警与建议", None, 5)
conclusion_text = "\n".join(f"{idx}. {line}" for idx, line in enumerate(conclusions, 1)) or "无数据"
action_text = "\n".join(f"{idx}. {line}" for idx, line in enumerate(actions, 1)) or "无数据"
return {
"schema": "2.0",
"config": {
"update_multi": True,
"width_mode": "fill",
"enable_forward": True,
"summary": {"content": f"{report_date} 时尚包袋品牌经营日报"},
},
"header": {
"title": {"tag": "plain_text", "content": "时尚包袋品牌经营日报"},
"subtitle": {"tag": "plain_text", "content": f"{report_date} · Hermes 自动分析"},
"template": "blue",
"icon": {"tag": "standard_icon", "token": "chart_colorful"},
"text_tag_list": [
{"tag": "text_tag", "text": {"tag": "plain_text", "content": "自动日报"}, "color": "blue"}
],
},
"body": {
"direction": "vertical",
"padding": "12px 12px 20px 12px",
"vertical_spacing": "large",
"elements": [
{
"tag": "column_set",
"flex_mode": "none",
"horizontal_spacing": "12px",
"columns": [
_metric_column(counts.get("🔴", 0), "红灯款式", emphasis=True),
_metric_column(counts.get("🟡", 0), "黄灯款式"),
_metric_column(counts.get("🟢", 0), "绿灯款式"),
],
},
{
"tag": "img",
"img_key": image_key,
"alt": {"tag": "plain_text", "content": "全域种草数据看板"},
"scale_type": "fit_horizontal",
"corner_radius": "8px",
"preview": True,
},
{"tag": "markdown", "content": f"**今日经营概览**\n{conclusion_text}"},
{"tag": "markdown", "content": f"**今日运营建议**\n{action_text}"},
],
},
}
def prepare_feishu_image(source: Path, target: Path) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as image:
image = image.convert("RGB")
if image.width > MAX_CARD_IMAGE_WIDTH:
height = round(image.height * MAX_CARD_IMAGE_WIDTH / image.width)
image = image.resize((MAX_CARD_IMAGE_WIDTH, height), Image.Resampling.LANCZOS)
image.save(target, format="PNG", optimize=True)
return target
def upload_dashboard_image(image_path: Path) -> str:
command = [
*_lark_command(), "--profile", LARK_PROFILE,
"im", "images", "create",
"--data", json.dumps({"image_type": "message"}),
"--file", f"image=./{image_path.name}",
"--as", "bot", "--format", "json",
]
completed = subprocess.run(
command,
cwd=image_path.parent,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if completed.returncode != 0:
raise RuntimeError(f"飞书看板图片上传失败: {completed.stderr or completed.stdout}")
payload = json.loads(completed.stdout)
image_key = payload.get("image_key") or (payload.get("data") or {}).get("image_key")
if not image_key:
raise RuntimeError("飞书看板图片上传成功但未返回 image_key")
return image_key
def send_card_to_recipients(
card: dict[str, Any],
recipients: Iterable[str],
report_date: str,
*,
message_kind: str = "combined",
) -> list[dict[str, Any]]:
content = json.dumps(card, ensure_ascii=False, separators=(",", ":"))
content_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10]
results = []
for open_id in recipients:
kind_token = re.sub(r"[^a-z0-9]", "", message_kind.lower())[:1] or "m"
idempotency_key = f"daily-{report_date}-{kind_token}-{content_digest}-{open_id[-6:]}"
command = [
*_lark_command(), "--profile", LARK_PROFILE,
"im", "+messages-send",
"--user-id", open_id,
"--msg-type", "interactive",
"--content", content,
"--idempotency-key", idempotency_key,
"--as", "bot", "--format", "json",
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if completed.returncode != 0:
raise RuntimeError(f"飞书卡片发送失败({open_id}): {completed.stderr or completed.stdout}")
results.append(json.loads(completed.stdout))
return results
def send_daily_report_cards(
report_date: str,
report: str,
tracking_rows: list[dict[str, Any]],
chart_path: Path,
recipients: Iterable[str],
) -> list[dict[str, Any]]:
prepared_dir = PATHS.tmp_root / "daily_report_card"
prepared_dir.mkdir(parents=True, exist_ok=True)
prepared = prepared_dir / f"{chart_path.stem}_feishu.png"
prepare_feishu_image(chart_path, prepared)
try:
image_key = upload_dashboard_image(prepared)
finally:
prepared.unlink(missing_ok=True)
recipients = tuple(recipients)
dashboard_card = build_dashboard_card(report_date, tracking_rows, image_key)
analysis_card = build_analysis_card(report_date, report)
dashboard_results = send_card_to_recipients(
dashboard_card, recipients, report_date, message_kind="dashboard"
)
analysis_results = send_card_to_recipients(
analysis_card, recipients, report_date, message_kind="analysis"
)
return dashboard_results + analysis_results
@@ -0,0 +1,518 @@
"""使用 Pillow 生成全款式同权经营日报长图看板。"""
from pathlib import Path
from textwrap import wrap
from typing import Any, Callable
from PIL import Image, ImageDraw, ImageFont, ImageOps
try:
from .daily_dashboard_analytics import build_dashboard_facts
except ImportError:
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_dashboard_analytics import build_dashboard_facts
WIDTH, HEIGHT = 1600, 2500
BG = "#F7F8FA"
PANEL = "#FFFFFF"
TEXT = "#18212F"
MUTED = "#667085"
GRID = "#E4E7EC"
RED = "#E5484D"
YELLOW = "#E8A317"
GREEN = "#30A46C"
BLUE = "#3B82F6"
ORANGE = "#F97316"
PURPLE = "#8B5CF6"
LIGHT_BLUE = "#EFF6FF"
LIGHT_RED = "#FFF1F1"
LIGHT_YELLOW = "#FFF8E6"
PLATFORM_COLORS = {"天猫": RED, "京东": BLUE, "抖音电商": TEXT}
def _font(size: int, bold: bool = False):
candidates = [
Path(r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc"),
Path(r"C:\Windows\Fonts\simhei.ttf"),
]
for path in candidates:
if path.exists():
return ImageFont.truetype(str(path), size)
return ImageFont.load_default()
def _pct(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _fmt_int(value: Any) -> str:
if value is None:
return "无数据"
return f"{int(value):,}"
def _fmt_rate(value: Any) -> str:
if value is None:
return "无数据"
return f"{float(value):.1%}"
def _sum_known(rows: list[dict[str, Any]], key: str) -> float | None:
values = [float(row[key]) for row in rows if row.get(key) is not None]
return sum(values) if values else None
def _ratio(numerator: float | None, denominator: float | None) -> float | None:
if numerator is None or denominator is None or denominator <= 0:
return None
return numerator / denominator
def build_overview_cards(rows: list[dict[str, Any]], dashboard: dict[str, Any]) -> list[dict[str, Any]]:
today_sales = _sum_known(rows, "sales")
yesterday_sales = _sum_known(rows, "sales_yesterday")
baseline_sales = _sum_known(rows, "avg_sales_7d")
today_visitors = _sum_known(rows, "visitors")
yesterday_visitors = _sum_known(rows, "visitors_yesterday")
baseline_visitors = _sum_known(rows, "avg_visitors_7d")
notes = dashboard.get("daily_new_notes", [])
note_views = [float(row["view_count"]) for row in notes if row.get("view_count") is not None]
return [
{
"key": "sales", "label": "销量",
"today": today_sales, "yesterday": yesterday_sales, "baseline_7d": baseline_sales,
"formatter": _fmt_int,
},
{
"key": "visitors", "label": "访客",
"today": today_visitors, "yesterday": yesterday_visitors, "baseline_7d": baseline_visitors,
"formatter": _fmt_int,
},
{
"key": "conversion", "label": "整体转化率",
"today": _ratio(today_sales, today_visitors),
"yesterday": _ratio(yesterday_sales, yesterday_visitors),
"baseline_7d": _ratio(baseline_sales, baseline_visitors),
"formatter": _fmt_rate,
},
{
"key": "content", "label": "新发内容曝光",
"today": sum(note_views) if note_views else None,
"yesterday": None, "baseline_7d": None,
"formatter": _fmt_int,
"note": f"昨日新发 {len(notes)} 篇|历史曝光快照无数据",
},
]
def classify_style_action(row: dict[str, Any]) -> tuple[str, str]:
sales = row.get("sales_change_yesterday")
visitors = row.get("visitor_change_yesterday")
conversion = row.get("conversion_change_yesterday")
refund = row.get("refund_change_yesterday")
if sales is not None and conversion is not None and refund is not None and sales < 0 and conversion < 0 and refund > 0:
return "风险型", "查退款/控投放"
if refund is not None and refund >= 0.30:
return "风险型", "查退款/控投放"
if sales is not None and visitors is not None and conversion is not None and sales > 0 and visitors > 0 and conversion >= 0:
return "增长型", "加达人/备货"
if visitors is not None and conversion is not None and visitors > 0 and conversion < 0:
return "流量机会", "优化详情页"
if (row.get("cart_change_yesterday") or 0) >= 0.20 or (row.get("recent_note_exposure") or 0) > 0:
return "潜力型", "加曝光测试"
if row.get("sales") is None:
return "无数据", "补齐数据"
return "稳定型", "维护转化"
def select_chart_styles(rows: list[dict[str, Any]], limit: int = 6) -> list[dict[str, Any]]:
"""保留旧调用兼容;新看板不会再只展示这些款式。"""
anomalies = [row for row in rows if row.get("light") in {"🔴", "🟡"} and row.get("visitors") is not None]
def visitor_delta(row):
return float(row.get("visitors") or 0) - float(row.get("avg_visitors_7d") or 0)
def impact(row):
vd = abs(visitor_delta(row))
cd = abs(float(row.get("cart_users") or 0) - float(row.get("avg_cart_users_7d") or 0))
return vd + cd * 10
selected, used = [], set()
groups = [
sorted((row for row in anomalies if visitor_delta(row) < 0), key=lambda row: abs(visitor_delta(row)), reverse=True),
sorted((row for row in anomalies if visitor_delta(row) > 0 or _pct(row.get("cart_change")) > 0), key=impact, reverse=True),
sorted((row for row in anomalies if float(row.get("visitors") or 0) < 200), key=lambda row: max(abs(_pct(row.get("visitor_change"))), abs(_pct(row.get("cart_change")))), reverse=True),
]
for candidates, target in zip(groups, (2, 2, 1)):
added = 0
for row in candidates:
if row["style_name"] in used:
continue
selected.append(row)
used.add(row["style_name"])
added += 1
if added >= target or len(selected) >= limit:
break
for row in sorted(anomalies, key=impact, reverse=True):
if len(selected) >= limit:
break
if row["style_name"] not in used:
selected.append(row)
used.add(row["style_name"])
return selected[:limit]
def _panel(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, title_size: int = 23) -> None:
draw.rounded_rectangle(box, radius=18, fill=PANEL)
draw.text((box[0] + 20, box[1] + 16), title, font=_font(title_size, True), fill=TEXT)
def _ranking_panel(
draw: ImageDraw.ImageDraw,
box: tuple[int, int, int, int],
title: str,
rows: list[dict[str, Any]],
value_key: str,
formatter: Callable[[Any], str],
empty_text: str = "无数据",
) -> None:
_panel(draw, box, title, 21)
x0, y0, x1, _ = box
if not rows:
lines = wrap(empty_text, width=22) or ["无数据"]
for idx, line in enumerate(lines[:4]):
draw.text((x0 + 20, y0 + 76 + idx * 28), line, font=_font(16), fill=MUTED)
return
max_value = max(float(row.get(value_key) or 0) for row in rows) or 1
for idx, row in enumerate(rows[:5], 1):
y = y0 + 63 + (idx - 1) * 37
draw.text((x0 + 20, y), str(idx), font=_font(16, True), fill=BLUE)
draw.text((x0 + 48, y), str(row.get("display_name") or row.get("style_name") or "无数据")[:11], font=_font(16), fill=TEXT)
bar_x, bar_w = x0 + 190, x1 - x0 - 285
width = max(2, bar_w * float(row.get(value_key) or 0) / max_value)
draw.rounded_rectangle((bar_x, y + 5, bar_x + bar_w, y + 15), radius=5, fill=GRID)
draw.rounded_rectangle((bar_x, y + 5, bar_x + width, y + 15), radius=5, fill=BLUE)
draw.text((x1 - 84, y - 1), formatter(row.get(value_key)), font=_font(15, True), fill=TEXT)
def _matrix_header(draw: ImageDraw.ImageDraw, x: int, y: int) -> None:
headers = [
("款式", 0), ("今日", 108), ("近7日", 150), ("访客", 200),
("转化", 254), ("曝光", 310), ("预测7日", 366), ("判断", 442), ("动作", 536),
]
for label, offset in headers:
draw.text((x + offset, y), label, font=_font(14, True), fill=MUTED)
def _split_style_groups(style_groups: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if len(style_groups) < 2:
return style_groups, []
total = sum(len(group.get("styles", [])) for group in style_groups)
running = 0
best_index = 1
best_gap = total
for index, group in enumerate(style_groups[:-1], 1):
running += len(group.get("styles", []))
gap = abs(total - running * 2)
if gap < best_gap:
best_index, best_gap = index, gap
return style_groups[:best_index], style_groups[best_index:]
def _style_matrix(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], style_groups: list[dict[str, Any]]) -> None:
total_styles = sum(len(group.get("styles", [])) for group in style_groups)
_panel(draw, box, f"三、全部 {total_styles} 款SKU表现|按风格分组", 25)
x0, y0, x1, _ = box
draw.text(
(x1 - 760, y0 + 20),
"近7日含报告日|预测=近3日日均×7,箭头较前4日日均|仅作趋势估算",
font=_font(13),
fill=MUTED,
)
groups = _split_style_groups(style_groups)
column_boxes = [(x0 + 20, x0 + 750), (x0 + 790, x1 - 20)]
for category_groups, (left, right) in zip(groups, column_boxes):
header_y = y0 + 70
draw.rounded_rectangle((left, header_y - 8, right, header_y + 28), radius=8, fill=LIGHT_BLUE)
_matrix_header(draw, left + 10, header_y)
cursor_y = header_y + 38
for category_group in category_groups:
category = category_group.get("style_category") or "未分类"
rows = category_group.get("styles", [])
draw.rounded_rectangle((left, cursor_y, right, cursor_y + 26), radius=7, fill=LIGHT_YELLOW)
draw.text((left + 12, cursor_y + 3), f"{category}({len(rows)}款)", font=_font(14, True), fill=ORANGE)
cursor_y += 31
for idx, row in enumerate(rows):
y = cursor_y + idx * 38
if idx % 2:
draw.rectangle((left, y - 4, right, y + 28), fill="#FAFBFC")
light_color = {"🔴": RED, "🟡": YELLOW, "🟢": GREEN}.get(row.get("light"), MUTED)
draw.ellipse((left + 8, y + 4, left + 18, y + 14), fill=light_color)
values = [
(str(row.get("style_name") or "无数据")[:7], 24, TEXT),
(_fmt_int(row.get("sales")), 114, TEXT),
(_fmt_int(row.get("sales_7d_total")), 156, TEXT),
(_fmt_int(row.get("visitors")), 206, TEXT),
(_fmt_rate(row.get("conversion_rate")), 260, TEXT),
(_fmt_int(row.get("recent_note_exposure")), 316, PURPLE if row.get("recent_note_exposure") else MUTED),
]
forecast = row.get("sales_forecast_7d")
forecast_change = row.get("sales_forecast_change")
if forecast is None:
forecast_text, forecast_color = "无数据", MUTED
elif forecast_change is None:
forecast_text, forecast_color = f"·{_fmt_int(forecast)}", MUTED
elif forecast_change > 0:
forecast_text, forecast_color = f"↑{_fmt_int(forecast)}", GREEN
elif forecast_change < 0:
forecast_text, forecast_color = f"↓{_fmt_int(forecast)}", RED
else:
forecast_text, forecast_color = f"→{_fmt_int(forecast)}", MUTED
diagnosis, action = classify_style_action(row)
values.extend([
(forecast_text, 372, forecast_color),
(diagnosis, 448, RED if diagnosis == "风险型" else GREEN if diagnosis == "增长型" else TEXT),
(action, 542, TEXT),
])
for value, offset, color in values:
draw.text((left + offset, y), str(value)[:10], font=_font(12, offset in (24, 448, 542)), fill=color)
cursor_y += len(rows) * 38 + 7
def _change_text(current: float | None, baseline: float | None) -> tuple[str, str]:
if current is None or baseline is None or baseline == 0:
return "无数据", MUTED
change = (current - baseline) / baseline
if change > 0:
return f"↑{change:.1%}", GREEN
if change < 0:
return f"↓{abs(change):.1%}", RED
return "持平", MUTED
def _overview_section(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], cards: list[dict[str, Any]]) -> None:
_panel(draw, box, "一、今日经营概览|今日 vs 昨日 vs 7日均值", 25)
x0, y0, x1, _ = box
gap = 14
card_width = (x1 - x0 - 40 - gap * 3) // 4
for idx, card in enumerate(cards[:4]):
left = x0 + 20 + idx * (card_width + gap)
right = left + card_width
top = y0 + 62
draw.rounded_rectangle((left, top, right, box[3] - 18), radius=13, fill=LIGHT_BLUE if idx < 3 else "#F8F5FF")
draw.text((left + 14, top + 12), card["label"], font=_font(18, True), fill=BLUE if idx < 3 else PURPLE)
formatter = card["formatter"]
draw.text((left + 14, top + 45), formatter(card.get("today")), font=_font(28, True), fill=TEXT)
vs_yesterday, color_y = _change_text(card.get("today"), card.get("yesterday"))
vs_baseline, color_7 = _change_text(card.get("today"), card.get("baseline_7d"))
draw.text((left + 14, top + 86), f"较昨日 {vs_yesterday}", font=_font(14, True), fill=color_y)
draw.text((left + 154, top + 86), f"较7日 {vs_baseline}", font=_font(14, True), fill=color_7)
values = [("今日", card.get("today"), BLUE), ("昨日", card.get("yesterday"), MUTED), ("7日", card.get("baseline_7d"), YELLOW)]
known = [float(value) for _, value, _ in values if value is not None]
maximum = max(known) if known else 1
for row_index, (label, value, color) in enumerate(values):
y = top + 124 + row_index * 30
draw.text((left + 14, y), label, font=_font(13), fill=MUTED)
draw.rounded_rectangle((left + 56, y + 4, right - 78, y + 14), radius=5, fill=GRID)
if value is not None:
bar_width = max(2, (right - left - 148) * float(value) / maximum)
draw.rounded_rectangle((left + 56, y + 4, left + 56 + bar_width, y + 14), radius=5, fill=color)
draw.text((right - 70, y - 1), formatter(value), font=_font(12, True), fill=TEXT if value is not None else MUTED)
if card.get("note"):
draw.text((left + 14, top + 220), str(card["note"])[:30], font=_font(12), fill=MUTED)
def _focus_styles(styles: list[dict[str, Any]], target: str) -> list[dict[str, Any]]:
candidates = []
for row in styles:
if target == "增长型":
sales_change = row.get("sales_change")
visitor_change = row.get("visitor_change")
refund_rate = row.get("refund_rate")
if sales_change is None or sales_change <= 0 or (visitor_change is not None and visitor_change < -0.05):
continue
if refund_rate is not None and refund_rate >= 0.40:
continue
score = float(sales_change) + max(float(visitor_change or 0), 0)
action = "加达人/备货" if (row.get("conversion_change_7d") or 0) >= -0.002 else "加曝光测试"
else:
sales_change = row.get("sales_change")
conversion_change = row.get("conversion_change_7d")
refund_change = row.get("refund_change")
refund_rate = row.get("refund_rate")
strict_risk = (
sales_change is not None and sales_change < 0
and conversion_change is not None and conversion_change < 0
and refund_change is not None and refund_change > 0
)
if not strict_risk and not (sales_change is not None and sales_change < 0 and (refund_rate or 0) >= 0.40):
continue
score = abs(min(float(sales_change or 0), 0)) + max(float(refund_change or 0), 0) + float(refund_rate or 0)
action = "查退款/控投放"
candidates.append((score, {**row, "prompt_diagnosis": target, "prompt_action": action}))
return [row for _, row in sorted(candidates, key=lambda item: item[0], reverse=True)[:3]]
def _focus_panel(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, rows: list[dict[str, Any]], risk: bool = False) -> None:
_panel(draw, box, title, 22)
x0, y0, x1, _ = box
accent = RED if risk else GREEN
if not rows:
draw.text((x0 + 20, y0 + 74), "无符合规则的SKU", font=_font(16), fill=MUTED)
return
for idx, row in enumerate(rows, 1):
y = y0 + 62 + (idx - 1) * 65
draw.rounded_rectangle((x0 + 18, y, x1 - 18, y + 54), radius=10, fill=LIGHT_RED if risk else "#EFFAF4")
draw.text((x0 + 30, y + 14), str(idx), font=_font(17, True), fill=accent)
draw.text((x0 + 60, y + 8), str(row.get("style_name") or "无数据")[:12], font=_font(17, True), fill=TEXT)
sales_text, sales_color = _change_text(row.get("sales"), row.get("avg_sales_7d"))
visitor_text, visitor_color = _change_text(row.get("visitors"), row.get("avg_visitors_7d"))
draw.text((x0 + 210, y + 10), f"销量较7日 {sales_text}", font=_font(13, True), fill=sales_color)
draw.text((x0 + 350, y + 10), f"访客较7日 {visitor_text}", font=_font(13, True), fill=visitor_color)
draw.text((x0 + 500, y + 10), row.get("prompt_action") or "无数据", font=_font(14, True), fill=accent)
draw.text((x0 + 60, y + 32), f"转化 {_fmt_rate(row.get('conversion_rate'))}|曝光 {_fmt_int(row.get('recent_note_exposure'))}", font=_font(12), fill=MUTED)
def _channel_section(
draw: ImageDraw.ImageDraw,
box: tuple[int, int, int, int],
notes: list[dict[str, Any]],
styles: list[dict[str, Any]],
) -> None:
_panel(draw, box, "四、达人营销效果|昨日新发布内容", 25)
x0, y0, x1, _ = box
platforms = [("小红书", RED), ("B站", BLUE), ("抖音", TEXT)]
gap = 18
width = (x1 - x0 - 40 - gap * 2) // 3
for idx, (platform, color) in enumerate(platforms):
items = [row for row in notes if row.get("platform") == platform]
left = x0 + 20 + idx * (width + gap)
top = y0 + 66
draw.rounded_rectangle((left, top, left + width, top + 230), radius=13, fill=LIGHT_BLUE)
draw.text((left + 16, top + 14), platform, font=_font(20, True), fill=color)
exposure = sum(int(row.get("view_count") or 0) for row in items) if items else None
draw.text((left + 16, top + 54), f"新发 {len(items)} 篇", font=_font(19, True), fill=TEXT)
draw.text((left + 150, top + 56), f"曝光 {_fmt_int(exposure)}", font=_font(16, True), fill=TEXT)
if items:
best = max(items, key=lambda row: row.get("view_count") or 0)
_draw_wrapped(draw, f"最佳:{best.get('style_name') or '无款式'} · {best.get('creator') or '无达人'}", left + 16, top + 96, 28, 2, TEXT, 15, True)
_draw_wrapped(draw, best.get("title") or "标题无数据", left + 16, top + 146, 32, 2, MUTED, 13)
else:
draw.text((left + 16, top + 100), "无新发布内容", font=_font(15), fill=MUTED)
best_note = max(notes, key=lambda row: row.get("view_count") or 0) if notes else None
best_channel = best_note.get("platform") if best_note else "无数据"
best_style = best_note.get("style_name") if best_note else "无数据"
comment_samples = sum(int((row.get("multi_source_signals") or {}).get("comment_count") or 0) for row in styles)
summary_y = y0 + 322
summary_items = [
("最佳渠道", best_channel or "无数据", "优先复盘可量化内容"),
("最佳内容SKU", best_style or "无数据", f"曝光 {_fmt_int(best_note.get('view_count') if best_note else None)}"),
("内容反馈", f"新发 {len(notes)} 篇", "跨渠道曝光仍不完整"),
("用户洞察", f"评论样本 {comment_samples}", "样本不足则不推断"),
]
summary_width = (x1 - x0 - 40 - 14 * 3) // 4
for idx, (label, value, note) in enumerate(summary_items):
left = x0 + 20 + idx * (summary_width + 14)
draw.rounded_rectangle((left, summary_y, left + summary_width, summary_y + 150), radius=12, fill="#F8F5FF")
draw.text((left + 14, summary_y + 14), label, font=_font(15, True), fill=PURPLE)
draw.text((left + 14, summary_y + 48), str(value)[:18], font=_font(20, True), fill=TEXT)
_draw_wrapped(draw, note, left + 14, summary_y + 86, 24, 2, MUTED, 13)
draw.text((x0 + 20, box[3] - 38), "说明:曝光为数据库保存的当前快照;缺少历史快照时不推算单日播放增量。", font=_font(14), fill=MUTED)
def _draw_wrapped(
draw: ImageDraw.ImageDraw,
text: str,
x: int,
y: int,
width: int = 28,
max_lines: int = 3,
color: str = TEXT,
size: int = 14,
bold: bool = False,
) -> int:
lines = wrap(str(text or "无数据"), width=width)[:max_lines] or ["无数据"]
for line in lines:
draw.text((x, y), line, font=_font(size, bold), fill=color)
y += size + 8
return y
def _paste_product_image(canvas: Image.Image, draw: ImageDraw.ImageDraw, winner: dict[str, Any], x: int, y: int) -> None:
size = 112
path = winner.get("local_image_path")
if path:
try:
with Image.open(path) as source:
thumb = ImageOps.fit(source.convert("RGB"), (size, size), method=Image.Resampling.LANCZOS)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size, size), radius=10, fill=255)
canvas.paste(thumb, (x, y), mask)
ctr = winner.get("image_ctr")
ctr_text = f" CTR {float(ctr):.1f}%" if ctr is not None else ""
draw.text((x, y + size + 7), f"图片:天猫{ctr_text}", font=_font(12), fill=MUTED)
return
except (OSError, ValueError):
pass
draw.rounded_rectangle((x, y, x + size, y + size), radius=10, fill=PANEL, outline=GRID)
draw.text((x + 17, y + 46), "图片无数据", font=_font(13), fill=MUTED)
def _winner_block(canvas: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, width: int, title: str, winner: dict[str, Any] | None, role: str) -> int:
draw.rounded_rectangle((x, y, x + width, y + 260), radius=12, fill=LIGHT_BLUE if role == "conversion" else "#F8F5FF")
draw.text((x + 14, y + 12), title, font=_font(17, True), fill=BLUE if role == "conversion" else PURPLE)
if not winner:
draw.text((x + 14, y + 50), "无数据", font=_font(15), fill=MUTED)
return y + 260
metric = _fmt_rate(winner.get("conversion_rate")) if role == "conversion" else _fmt_int(winner.get("visitors"))
unit = "转化率" if role == "conversion" else "访客"
draw.text((x + 14, y + 45), f"{winner['style_name']}|{unit} {metric}", font=_font(16, True), fill=TEXT)
_paste_product_image(canvas, draw, winner, x + 14, y + 80)
text_x = x + 140
persona_date = f"({winner.get('persona_date')})" if winner.get("persona_date") else ""
cursor = _draw_wrapped(draw, f"画像{persona_date}:{winner.get('persona_text') or '无数据'}", text_x, y + 78, 20, 3, TEXT, 13)
cursor = _draw_wrapped(draw, winner.get("interest_hypothesis") or "无数据", text_x, cursor + 2, 20, 2, MUTED, 13)
_draw_wrapped(draw, winner.get("optimization") or "无数据", text_x, cursor + 2, 20, 3, TEXT, 13)
return y + 260
def _persona_section(canvas: Image.Image, draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], cards: list[dict[str, Any]]) -> None:
_panel(draw, box, "三平台高转化 / 高访客款式画像与增长空间", 25)
x0, y0, x1, _ = box
draw.text((x1 - 570, y0 + 20), "兴趣爱好均为画像与内容方向的推测,不是数据库原始标签", font=_font(14), fill=MUTED)
gap = 16
card_width = (x1 - x0 - 40 - gap * 2) // 3
for idx, card in enumerate(cards[:3]):
x = x0 + 20 + idx * (card_width + gap)
draw.text((x, y0 + 66), card.get("platform") or "无数据", font=_font(21, True), fill=PLATFORM_COLORS.get(card.get("platform"), BLUE))
next_y = _winner_block(canvas, draw, x, y0 + 103, card_width, "转化最高", card.get("top_conversion"), "conversion")
_winner_block(canvas, draw, x, next_y + 14, card_width, "访客最高", card.get("top_visitors"), "visitors")
def generate_dashboard(report_date: str, rows: list[dict[str, Any]], enrichment: dict[str, Any], output_path: Path) -> Path:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
dashboard = enrichment.get("dashboard") or build_dashboard_facts(
report_date, rows, enrichment.get("platform_metrics", []), enrichment.get("personas", []), {}
)
image = Image.new("RGB", (WIDTH, HEIGHT), BG)
draw = ImageDraw.Draw(image)
draw.text((42, 24), f"{report_date} 品牌SKU营销运营看板", font=_font(36, True), fill=TEXT)
draw.text((42, 74), "经营双基准|近7日销量与未来趋势预估|全款式同权|达人曝光缺失不推算", font=_font(18), fill=MUTED)
overview_cards = build_overview_cards(rows, dashboard)
_overview_section(draw, (32, 112, 1568, 442), overview_cards)
styles = dashboard.get("styles", [])
_focus_panel(draw, (32, 464, 790, 730), "二、增长SKU TOP3", _focus_styles(styles, "增长型"))
_focus_panel(draw, (810, 464, 1568, 730), "风险SKU TOP3", _focus_styles(styles, "风险型"), risk=True)
style_groups = dashboard.get("style_groups") or [{"style_category": "未分类", "styles": dashboard.get("styles", [])}]
_style_matrix(draw, (32, 752, 1568, 1780), style_groups)
_channel_section(draw, (32, 1802, 1568, 2468), dashboard.get("daily_new_notes", []), styles)
image.save(output_path, format="PNG", optimize=True)
return output_path
@@ -0,0 +1,61 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Daily task: run 3-platform V2 scrape + sync metrics to cmt_notes (INSERT missing notes every day)
REM Register: schtasks /Create /SC DAILY /TN YingxiaoYunying_DailyRun /TR %PROJECT_DIR%\data\tools\daily_run.bat /ST 06:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
REM Task Scheduler (SYSTEM account) has no user PATH -> lock to venv python
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\daily_run_%TS%.log
echo === Daily run started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
echo --- [step 1] run_all.py (includes feishu_mapping refresh) --- >> "%LOG_FILE%"
call %PYTHON% run_all.py >> "%LOG_FILE%" 2>&1
set RC_RUN=%ERRORLEVEL%
echo run_all exit=%RC_RUN% >> "%LOG_FILE%"
echo --- [step 2] sync_metrics_to_cmt_notes.py (主采集后首次落库) --- >> "%LOG_FILE%"
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
set RC_SYNC_PRE=%ERRORLEVEL%
echo sync_pre exit=%RC_SYNC_PRE% >> "%LOG_FILE%"
echo --- [step 3] retry_failed.py (B站 + 蒲公英; 星图每日只搜索一轮) --- >> "%LOG_FILE%"
call %PYTHON% data\tools\retry_failed.py --platform bili,pgy --max-attempts 1 --stale-hours 4 >> "%LOG_FILE%" 2>&1
set RC_RETRY=%ERRORLEVEL%
echo retry_failed exit=%RC_RETRY% >> "%LOG_FILE%"
echo --- [step 4] sync_metrics_to_cmt_notes.py (补跑后再次落库) --- >> "%LOG_FILE%"
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
set RC_SYNC_POST=%ERRORLEVEL%
echo sync_post exit=%RC_SYNC_POST% >> "%LOG_FILE%"
set FINAL_RC=0
if not "%RC_RUN%"=="0" set FINAL_RC=1
if not "%RC_SYNC_PRE%"=="0" set FINAL_RC=1
if not "%RC_RETRY%"=="0" set FINAL_RC=1
if not "%RC_SYNC_POST%"=="0" set FINAL_RC=1
echo === Daily run finished at %date% %time% (run=%RC_RUN%, sync_pre=%RC_SYNC_PRE%, retry=%RC_RETRY%, sync_post=%RC_SYNC_POST%, final=%FINAL_RC%) === >> "%LOG_FILE%"
endlocal & exit /b %FINAL_RC%
@@ -0,0 +1,61 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Monday task: run 3-platform V2 + sync metrics to cmt_notes
REM Register: schtasks /Create /SC WEEKLY /D MON /TN YingxiaoYunying_MondayBackfill /TR %PROJECT_DIR%\data\tools\daily_run_with_backfill.bat /ST 06:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
REM Task Scheduler (SYSTEM account) has no user PATH -> lock to venv python
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\monday_run_%TS%.log
echo === Monday run started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
echo --- [step 1] run_all.py (includes feishu_mapping refresh) --- >> "%LOG_FILE%"
call %PYTHON% run_all.py >> "%LOG_FILE%" 2>&1
set RC_RUN=%ERRORLEVEL%
echo run_all exit=%RC_RUN% >> "%LOG_FILE%"
echo --- [step 2] sync_metrics_to_cmt_notes.py (主采集后首次落库) --- >> "%LOG_FILE%"
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
set RC_SYNC_PRE=%ERRORLEVEL%
echo sync_pre exit=%RC_SYNC_PRE% >> "%LOG_FILE%"
echo --- [step 3] retry_failed.py --- >> "%LOG_FILE%"
call %PYTHON% data\tools\retry_failed.py --max-attempts 1 --stale-hours 4 >> "%LOG_FILE%" 2>&1
set RC_RETRY=%ERRORLEVEL%
echo retry_failed exit=%RC_RETRY% >> "%LOG_FILE%"
echo --- [step 4] sync_metrics_to_cmt_notes.py (补跑后再次落库) --- >> "%LOG_FILE%"
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
set RC_SYNC_POST=%ERRORLEVEL%
echo sync_post exit=%RC_SYNC_POST% >> "%LOG_FILE%"
set FINAL_RC=0
if not "%RC_RUN%"=="0" set FINAL_RC=1
if not "%RC_SYNC_PRE%"=="0" set FINAL_RC=1
if not "%RC_RETRY%"=="0" set FINAL_RC=1
if not "%RC_SYNC_POST%"=="0" set FINAL_RC=1
echo === Monday run finished at %date% %time% (run=%RC_RUN%, sync_pre=%RC_SYNC_PRE%, retry=%RC_RETRY%, sync_post=%RC_SYNC_POST%, final=%FINAL_RC%) === >> "%LOG_FILE%"
endlocal & exit /b %FINAL_RC%
@@ -0,0 +1,515 @@
"""
db.py - PostgreSQL 连接池 + cmt_* 表 CRUD
统一使用 psycopg3,合并了原 yingxiaoyunying 诊断功能 + comment-data-collector 的完整 CRUD。
用法:
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import make_pool, get_conn, get_dict_conn
python db.py ping # 测试连接
python db.py info # 显示 cmt_* 表行数
"""
import os
import sys
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, Mapping
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from gyxx_flow.adapters import RuntimeServicePolicy
try:
import psycopg
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
except ImportError:
print("[FATAL] 请先 pip install 'psycopg[binary]>=3.1' psycopg-pool", file=sys.stderr)
sys.exit(1)
try:
from dotenv import load_dotenv
except ImportError:
print("[FATAL] 请先 pip install python-dotenv", file=sys.stderr)
sys.exit(1)
BASE_DIR = PATHS.tools_root
DATA_DIR = PATHS.raw_root
ENV_PATH = PATHS.state_root / "config/db.env"
ENV_EXAMPLE = PATHS.config_root / "db.env.example"
def load_env() -> None:
if not ENV_PATH.exists():
print(f"[FATAL] 找不到 {ENV_PATH}", file=sys.stderr)
print(f" 请复制 {ENV_EXAMPLE} -> {ENV_PATH} 并填连接信息", file=sys.stderr)
sys.exit(1)
load_dotenv(ENV_PATH, override=False)
os.environ.update(RuntimeServicePolicy().apply(os.environ))
def database_config_from_env(environ: Mapping[str, str]) -> dict[str, Any]:
"""从环境变量构造连接配置;缺项立即失败,绝不回退到本地库。"""
required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
missing = [key for key in required if not str(environ.get(key, "")).strip()]
if missing:
raise RuntimeError(f"数据库配置缺失: {', '.join(missing)}")
try:
port = int(environ["PG_PORT"])
except (TypeError, ValueError) as exc:
raise RuntimeError("数据库配置无效: PG_PORT 必须是整数") from exc
return {
"host": environ["PG_HOST"].strip(),
"port": port,
"dbname": environ["PG_DB"].strip(),
"user": environ["PG_USER"].strip(),
"password": environ["PG_PASSWORD"],
}
def get_db_config() -> dict[str, Any]:
load_env()
return database_config_from_env(os.environ)
def make_pool(min_size: int = 1, max_size: int = 5) -> ConnectionPool:
conninfo = psycopg.conninfo.make_conninfo(**get_db_config())
return ConnectionPool(conninfo, min_size=min_size, max_size=max_size, open=True)
# 全局连接池(懒初始化)
_pool: ConnectionPool | None = None
def _get_pool() -> ConnectionPool:
global _pool
if _pool is None or _pool.closed:
_pool = make_pool()
return _pool
@contextmanager
def get_conn(pool: ConnectionPool | None = None) -> Iterator[psycopg.Connection]:
p = pool or _get_pool()
with p.connection() as conn:
yield conn
@contextmanager
def get_dict_conn(pool: ConnectionPool | None = None) -> Iterator[psycopg.Connection]:
"""返回连接,cursor 需自行指定 row_factory=dict_row。替代 psycopg2 的 RealDictCursor。"""
p = pool or _get_pool()
with p.connection() as conn:
yield conn
@contextmanager
def get_dict_cursor(pool: ConnectionPool | None = None):
"""便捷函数:返回 dict_row cursor,自动 commit/rollback。"""
with get_dict_conn(pool) as conn:
cur = conn.cursor(row_factory=dict_row)
try:
yield cur
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cur.close()
def now() -> datetime:
return datetime.now()
def show_info(pool: ConnectionPool) -> None:
"""显示 cmt_* 表行数"""
with get_conn(pool) as conn:
with conn.cursor(row_factory=dict_row) as cur:
for tbl in ("cmt_styles", "cmt_creators", "cmt_style_creators", "cmt_notes", "cmt_comments"):
cur.execute(f"SELECT COUNT(*) AS n FROM {tbl}")
n = cur.fetchone()["n"]
print(f" {tbl:<25} {n:>8} 行")
# ---------------------------------------------------------------------------
# Styles
# ---------------------------------------------------------------------------
def upsert_style(
name: str,
platform: str = "",
feishu_base_url: str = "",
feishu_base_token: str = "",
feishu_table_id: str = "",
feishu_view_id: str = "",
) -> int:
sql = """
INSERT INTO cmt_styles (name, platform, feishu_base_url, feishu_base_token, feishu_table_id, feishu_view_id)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (name) DO UPDATE SET
platform = EXCLUDED.platform,
feishu_base_url = EXCLUDED.feishu_base_url,
feishu_base_token = EXCLUDED.feishu_base_token,
feishu_table_id = EXCLUDED.feishu_table_id,
feishu_view_id = EXCLUDED.feishu_view_id,
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (name, platform, feishu_base_url, feishu_base_token, feishu_table_id, feishu_view_id))
return cur.fetchone()[0]
def get_style_id(name: str) -> int | None:
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id FROM cmt_styles WHERE name = %s", (name,))
row = cur.fetchone()
return row["id"] if row else None
# ---------------------------------------------------------------------------
# Creators
# ---------------------------------------------------------------------------
def upsert_creator(name: str) -> int:
if not name:
raise ValueError("creator name cannot be empty")
sql = """
INSERT INTO cmt_creators (name)
VALUES (%s)
ON CONFLICT (name) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (name,))
return cur.fetchone()[0]
def link_style_creator(style_id: int, creator_id: int) -> None:
sql = """
INSERT INTO cmt_style_creators (style_id, creator_id)
VALUES (%s, %s)
ON CONFLICT (style_id, creator_id) DO NOTHING
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (style_id, creator_id))
conn.commit()
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
def upsert_note(
style_id: int,
creator_id: int,
platform: str,
url: str,
title: str = "",
feishu_record_id: str = "",
) -> int:
sql = """
INSERT INTO cmt_notes (style_id, creator_id, platform, title, url, feishu_record_id)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (url) DO UPDATE SET
style_id = EXCLUDED.style_id,
creator_id = EXCLUDED.creator_id,
platform = EXCLUDED.platform,
title = EXCLUDED.title,
feishu_record_id = EXCLUDED.feishu_record_id,
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (style_id, creator_id, platform, title, url, feishu_record_id))
return cur.fetchone()[0]
def update_note_metrics(
note_id: int,
like_count: int | None,
favorite_count: int | None,
comment_count: int | None,
share_count: int | None,
title: str | None = None,
) -> None:
sql = """
UPDATE cmt_notes
SET like_count = %s,
favorite_count = %s,
comment_count = %s,
share_count = %s,
title = COALESCE(%s, title),
scraped_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (like_count, favorite_count, comment_count, share_count, title, note_id))
conn.commit()
def update_note_view_metrics(
note_id: int,
view_count: int | None,
collect_count: int | None,
) -> None:
"""更新单笔记的曝光量/播放量、收藏量(来自 yingxiaoyunying 每日采集)。"""
sql = """
UPDATE cmt_notes
SET view_count = %s,
collect_count = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (view_count, collect_count, note_id))
conn.commit()
def get_note_id_by_url(url: str) -> int | None:
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id FROM cmt_notes WHERE url = %s", (url,))
row = cur.fetchone()
return row["id"] if row else None
def get_note_by_id(note_id: int) -> dict[str, Any] | None:
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT n.*, s.name AS style_name, c.name AS creator_name
FROM cmt_notes n
LEFT JOIN cmt_styles s ON n.style_id = s.id
LEFT JOIN cmt_creators c ON n.creator_id = c.id
WHERE n.id = %s
""",
(note_id,),
)
return cur.fetchone()
def get_note_by_url(url: str) -> dict[str, Any] | None:
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT n.*, s.name AS style_name, c.name AS creator_name
FROM cmt_notes n
LEFT JOIN cmt_styles s ON n.style_id = s.id
LEFT JOIN cmt_creators c ON n.creator_id = c.id
WHERE n.url = %s
""",
(url,),
)
return cur.fetchone()
def get_notes_by_platform(
platform: str,
only_with_comments: bool = True,
) -> list[dict[str, Any]]:
"""List all notes for a platform with style/creator joins + comment count."""
sql = """
SELECT n.*, s.name AS style_name, c.name AS creator_name,
(SELECT COUNT(*) FROM cmt_comments WHERE note_id = n.id) AS comment_total
FROM cmt_notes n
LEFT JOIN cmt_styles s ON n.style_id = s.id
LEFT JOIN cmt_creators c ON n.creator_id = c.id
WHERE n.platform = %s
"""
if only_with_comments:
sql += " AND (SELECT COUNT(*) FROM cmt_comments WHERE note_id = n.id) > 0"
sql += " ORDER BY n.id"
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql, (platform,))
return cur.fetchall()
def get_all_styles_with_notes() -> list[dict[str, Any]]:
"""List all styles that have at least one note, with per-style rollup counts."""
sql = """
SELECT s.*,
COUNT(n.id) AS note_total,
COUNT(*) FILTER (WHERE EXISTS(SELECT 1 FROM cmt_comments WHERE note_id = n.id)) AS note_with_comments
FROM cmt_styles s
LEFT JOIN cmt_notes n ON n.style_id = s.id
GROUP BY s.id
HAVING COUNT(n.id) > 0
ORDER BY note_total DESC, s.name ASC
"""
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
return cur.fetchall()
def get_notes_by_style(
style_id: int,
only_with_comments: bool = True,
) -> list[dict[str, Any]]:
"""List all notes for a single style, same field set as get_notes_by_platform."""
sql = """
SELECT n.*, s.name AS style_name, c.name AS creator_name,
(SELECT COUNT(*) FROM cmt_comments WHERE note_id = n.id) AS comment_total
FROM cmt_notes n
LEFT JOIN cmt_styles s ON n.style_id = s.id
LEFT JOIN cmt_creators c ON n.creator_id = c.id
WHERE n.style_id = %s
"""
if only_with_comments:
sql += " AND (SELECT COUNT(*) FROM cmt_comments WHERE note_id = n.id) > 0"
sql += " ORDER BY n.id ASC"
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql, (style_id,))
return cur.fetchall()
def get_comments_by_note_id(note_id: int) -> list[dict[str, Any]]:
with get_dict_conn() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT * FROM cmt_comments
WHERE note_id = %s
ORDER BY comment_created_at ASC, id ASC
""",
(note_id,),
)
return cur.fetchall()
# ---------------------------------------------------------------------------
# Comments
# ---------------------------------------------------------------------------
def replace_comments(note_id: int, platform: str, comments: list[dict[str, Any]], allow_empty: bool = False) -> int:
"""Delete existing comments for the note and insert the latest batch."""
if not comments and not allow_empty:
raise ValueError("refusing to replace existing comments with an empty scrape result")
scraped_at = now()
insert_sql = """
INSERT INTO cmt_comments (
note_id, platform, level, platform_comment_id, parent_comment_id,
reply_to_comment_id, user_id, nickname, content, like_count,
reply_count, ip_location, comment_created_at, raw_create_time, scraped_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (note_id, platform_comment_id) DO UPDATE SET
level = EXCLUDED.level,
parent_comment_id = EXCLUDED.parent_comment_id,
reply_to_comment_id = EXCLUDED.reply_to_comment_id,
user_id = EXCLUDED.user_id,
nickname = EXCLUDED.nickname,
content = EXCLUDED.content,
like_count = EXCLUDED.like_count,
reply_count = EXCLUDED.reply_count,
ip_location = EXCLUDED.ip_location,
comment_created_at = EXCLUDED.comment_created_at,
raw_create_time = EXCLUDED.raw_create_time,
scraped_at = EXCLUDED.scraped_at,
updated_at = CURRENT_TIMESTAMP
"""
def to_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(float(value))
except (ValueError, TypeError):
return None
def parse_dt(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
if not value:
return None
if isinstance(value, (int, float)):
ts = value / 1000 if value > 10_000_000_000 else value
try:
return datetime.fromtimestamp(ts)
except Exception:
return None
if isinstance(value, str):
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(value[:19], fmt)
except ValueError:
continue
return None
rows = []
for c in comments:
platform_comment_id = str(c.get("comment_id") or c.get("cid") or c.get("rpid") or "")
parent = str(c.get("parent_comment_id") or c.get("parent_rpid") or "")
reply_to = str(c.get("reply_to_comment_id") or c.get("reply_id") or c.get("reply_to_reply_id") or "")
if not reply_to and parent:
reply_to = parent
rows.append((
note_id,
platform,
c.get("level") or "comment",
platform_comment_id,
parent,
reply_to,
str(c.get("user_id") or c.get("mid") or c.get("sec_uid") or ""),
str(c.get("nickname") or c.get("uname") or ""),
str(c.get("content") or c.get("text") or c.get("message") or ""),
to_int(c.get("like_count") or c.get("digg_count") or 0),
to_int(c.get("reply_count") or 0),
str(c.get("ip_location") or c.get("ip_label") or c.get("location") or ""),
parse_dt(c.get("created_at") or c.get("raw_create_time")),
str(c.get("raw_create_time") or ""),
scraped_at,
))
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM cmt_comments WHERE note_id = %s", (note_id,))
if rows:
# Use executemany for bulk insert (psycopg3 native)
cur.executemany(insert_sql, rows)
conn.commit()
return len(rows)
def test_connection() -> bool:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT version()")
print(cur.fetchone()[0])
return True
def cli():
if len(sys.argv) < 2:
print("用法: python db.py [ping|info]")
sys.exit(1)
cmd = sys.argv[1]
pool = make_pool()
try:
if cmd == "ping":
with get_conn(pool) as conn:
with conn.cursor() as cur:
cur.execute("SELECT version()")
print(cur.fetchone()[0])
elif cmd == "info":
show_info(pool)
else:
print(f"未知命令: {cmd}")
sys.exit(1)
finally:
pool.close()
if __name__ == "__main__":
cli()
@@ -0,0 +1,716 @@
import argparse
import csv
import json
import os
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from multiprocessing import Pool
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
# Path setup: db.py is in data/tools/, scrapers are in project root
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
SUMMARY_DIR = PATHS.exports_root / "summary"
DEFAULT_INDEX_BASE_TOKEN = "TtoCb1NuQaDy3NsZWTpc0GIvnph"
DEFAULT_INDEX_TABLE_ID = "tblKCjplVAFrRwMC"
DEFAULT_INDEX_VIEW_ID = "vewvy33xEk"
STYLE_NAME_FIELD = "款式"
STYLE_BASE_FIELD = "合作达人多维表格地址"
INDEX_PLATFORM_FIELD = "平台"
NOTE_URL_FIELD = "发布笔记链接"
NOTE_PLATFORM_FIELD = "投放平台"
CREATOR_FIELD = "达人名称"
TITLE_FIELD = "发布笔记标题"
@dataclass(frozen=True)
class BaseRef:
url: str
base_token: str
table_id: str
view_id: str
@dataclass(frozen=True)
class StyleTask:
record_id: str
style_name: str
platform: str
base_ref: BaseRef
@dataclass(frozen=True)
class NoteTask:
style_name: str
style_record_id: str
note_record_id: str
platform_hint: str
creator_name: str
title: str
note_url: str
source_base_url: str
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def run_lark(args: list[str]) -> dict[str, Any]:
lark_cli = shutil.which("lark-cli") or shutil.which("lark-cli.cmd") or "lark-cli"
command = [lark_cli, "base", *args, "--format", "json", "--as", "user"]
completed = subprocess.run(
command,
cwd=BASE_DIR,
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
)
if completed.returncode != 0:
raise RuntimeError(
f"lark-cli failed ({completed.returncode}): {' '.join(command)}\n"
f"stdout:\n{completed.stdout}\n\nstderr:\n{completed.stderr}"
)
try:
return json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Failed to parse lark-cli JSON output: {exc}\n{completed.stdout}") from exc
def list_records(base_token: str, table_id: str, view_id: str = "", limit: int = 200) -> list[dict[str, Any]]:
offset = 0
records: list[dict[str, Any]] = []
while True:
args = [
"+record-list",
"--base-token",
base_token,
"--table-id",
table_id,
"--limit",
str(limit),
"--offset",
str(offset),
]
if view_id:
args.extend(["--view-id", view_id])
payload = run_lark(args)
if not payload.get("ok"):
raise RuntimeError(f"lark-cli returned ok=false: {payload}")
data = payload.get("data") or {}
field_names = data.get("fields") or []
record_ids = data.get("record_id_list") or []
rows = data.get("data") or []
for index, row in enumerate(rows):
record = dict(zip(field_names, row))
if index < len(record_ids):
record["_record_id"] = record_ids[index]
records.append(record)
if not data.get("has_more") or not rows:
return records
offset += len(rows)
def first_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
parts = [first_text(item) for item in value]
return ",".join(part for part in parts if part)
if isinstance(value, dict):
for key in ("text", "name", "link", "url"):
text = first_text(value.get(key))
if text:
return text
return str(value).strip()
def extract_urls(value: Any) -> list[str]:
text = first_text(value)
if not text:
return []
markdown_urls = re.findall(r"\]\((https?://[^)\s]+)\)", text)
plain_urls = re.findall(r"https?://[^\s)\]>]+", text)
urls = markdown_urls + plain_urls
seen: set[str] = set()
unique: list[str] = []
for url in urls:
url = url.replace("&amp;", "&").strip()
if url not in seen:
seen.add(url)
unique.append(url)
return unique
def normalize_note_url(url: str) -> str:
url = url.replace("&amp;", "&").strip()
for marker in ("复制", "打开", ",", "。", " "):
if marker in url:
url = url.split(marker, 1)[0]
parsed = urlparse(url)
host = parsed.netloc.lower()
query = parse_qs(parsed.query)
modal_id = (query.get("modal_id") or [""])[0]
if "douyin.com" in host and modal_id.isdigit():
return f"https://www.douyin.com/video/{modal_id}"
path_parts = [part for part in parsed.path.split("/") if part]
if host == "v.douyin.com" and path_parts:
return f"{parsed.scheme}://{parsed.netloc}/{path_parts[0]}/"
if host in {"xhslink.com", "www.xhslink.com"} and len(path_parts) >= 2:
return f"{parsed.scheme}://{parsed.netloc}/{path_parts[0]}/{path_parts[1]}"
if host == "b23.tv" and path_parts:
return f"{parsed.scheme}://{parsed.netloc}/{path_parts[0]}"
return url
def is_supported_note_url(url: str) -> bool:
host = urlparse(url).netloc.lower()
return any(
domain in host
for domain in (
"douyin.com",
"xiaohongshu.com",
"xhslink.com",
"bilibili.com",
"b23.tv",
)
)
def parse_base_ref(url: str) -> BaseRef | None:
parsed = urlparse(url)
parts = [part for part in parsed.path.split("/") if part]
if len(parts) < 2 or parts[0] != "base":
return None
query = parse_qs(parsed.query)
table_id = (query.get("table") or [""])[0]
view_id = (query.get("view") or [""])[0]
if not table_id:
return None
return BaseRef(url=url, base_token=parts[1], table_id=table_id, view_id=view_id)
def detect_platform(url: str, platform_hint: str = "") -> str:
lowered = url.lower()
if "xiaohongshu.com" in lowered or "xhslink.com" in lowered:
return "xiaohongshu"
if "douyin.com" in lowered:
return "douyin"
if "bilibili.com" in lowered or "b23.tv" in lowered:
return "bilibili"
return "unknown"
def load_style_tasks(
index_base_token: str,
index_table_id: str,
index_view_id: str,
index_platform: str,
max_styles: int | None,
) -> list[StyleTask]:
records = list_records(index_base_token, index_table_id, index_view_id)
tasks: list[StyleTask] = []
for record in records:
platform = first_text(record.get(INDEX_PLATFORM_FIELD))
if index_platform and platform != index_platform:
continue
style_name = first_text(record.get(STYLE_NAME_FIELD))
urls = extract_urls(record.get(STYLE_BASE_FIELD))
if not urls:
continue
base_ref = parse_base_ref(urls[0])
if not base_ref:
log(f"Skip style without valid Base link: {style_name or record.get('_record_id')}")
continue
tasks.append(
StyleTask(
record_id=first_text(record.get("_record_id")),
style_name=style_name or first_text(record.get("_record_id")) or "unknown_style",
platform=platform,
base_ref=base_ref,
)
)
if max_styles and len(tasks) >= max_styles:
break
return tasks
def load_note_tasks(style_tasks: list[StyleTask], max_notes_per_style: int | None) -> list[NoteTask]:
note_tasks: list[NoteTask] = []
for style in style_tasks:
log(f"Read notes: {style.style_name}")
records = list_records(style.base_ref.base_token, style.base_ref.table_id, style.base_ref.view_id)
count_for_style = 0
for record in records:
urls = extract_urls(record.get(NOTE_URL_FIELD))
if not urls:
continue
platform_hint = first_text(record.get(NOTE_PLATFORM_FIELD))
for url in urls:
url = normalize_note_url(url)
if not is_supported_note_url(url):
log(f"Skip unsupported URL: {style.style_name} | {url}")
continue
note_tasks.append(
NoteTask(
style_name=style.style_name,
style_record_id=style.record_id,
note_record_id=first_text(record.get("_record_id")),
platform_hint=platform_hint,
creator_name=first_text(record.get(CREATOR_FIELD)),
title=first_text(record.get(TITLE_FIELD)),
note_url=url,
source_base_url=style.base_ref.url,
)
)
count_for_style += 1
if max_notes_per_style and count_for_style >= max_notes_per_style:
break
if max_notes_per_style and count_for_style >= max_notes_per_style:
break
log(f"Found {count_for_style} note links: {style.style_name}")
return note_tasks
def normalize_comment_rows(
task: NoteTask,
platform: str,
comments: list[dict[str, Any]],
note_metrics: dict[str, Any],
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for comment in comments:
comment_text = first_text(comment.get("text") or comment.get("content") or comment.get("message"))
rows.append(
{
"style_name": task.style_name,
"creator_name": task.creator_name,
"note_title": task.title,
"platform": platform,
"note_url": task.note_url,
"comment_text": comment_text,
"comment_count": note_metrics.get("comment_count", ""),
"like_count": note_metrics.get("like_count", ""),
"favorite_count": note_metrics.get("favorite_count", ""),
"share_count": note_metrics.get("share_count", ""),
}
)
return rows
# ---------------------------------------------------------------------------
# Database sync helpers
# ---------------------------------------------------------------------------
def sync_styles_to_db(style_tasks: list[StyleTask]) -> dict[str, int]:
"""Upsert styles and return {style_name: style_id}."""
style_ids: dict[str, int] = {}
for style in style_tasks:
style_id = db.upsert_style(
name=style.style_name,
platform=style.platform,
feishu_base_url=style.base_ref.url,
feishu_base_token=style.base_ref.base_token,
feishu_table_id=style.base_ref.table_id,
feishu_view_id=style.base_ref.view_id,
)
style_ids[style.style_name] = style_id
log(f"Synced {len(style_ids)} styles to database")
return style_ids
def sync_notes_to_db(note_tasks: list[NoteTask], style_ids: dict[str, int]) -> dict[str, int]:
"""Upsert creators and notes, link styles and creators. Return {url: note_id}."""
creator_ids: dict[str, int] = {}
note_ids: dict[str, int] = {}
for task in note_tasks:
style_id = style_ids.get(task.style_name)
if not style_id:
log(f"Skip DB sync for note without known style: {task.note_url}")
continue
creator_name = task.creator_name or "unknown_creator"
if creator_name not in creator_ids:
creator_ids[creator_name] = db.upsert_creator(creator_name)
creator_id = creator_ids[creator_name]
db.link_style_creator(style_id, creator_id)
platform = detect_platform(task.note_url, task.platform_hint)
note_id = db.upsert_note(
style_id=style_id,
creator_id=creator_id,
platform=platform,
url=task.note_url,
title=task.title,
feishu_record_id=task.note_record_id,
)
note_ids[task.note_url] = note_id
log(f"Synced {len(note_ids)} notes to database")
return note_ids
def save_scrape_to_db(
task: NoteTask,
platform: str,
comments: list[dict[str, Any]],
note_metrics: dict[str, Any],
note_ids: dict[str, int],
) -> int:
"""Update note metrics and replace comments in DB."""
note_id = note_ids.get(task.note_url)
if not note_id:
return 0
db.update_note_metrics(
note_id=note_id,
like_count=_to_int(note_metrics.get("like_count")),
favorite_count=_to_int(note_metrics.get("favorite_count")),
comment_count=_to_int(note_metrics.get("comment_count")),
share_count=_to_int(note_metrics.get("share_count")),
)
allow_empty = _to_int(note_metrics.get("comment_count")) == 0
if not comments and not allow_empty:
log(f"Skip replacing comments for {task.note_url}: empty scrape result without confirmed zero comments")
return 0
inserted = db.replace_comments(note_id, platform, comments, allow_empty=allow_empty)
return inserted
def _to_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(float(value))
except (ValueError, TypeError):
return None
def scrape_note(
task: NoteTask,
args: argparse.Namespace,
note_ids: dict[str, int] | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
platform = detect_platform(task.note_url, task.platform_hint)
started_at = datetime.now().isoformat(timespec="seconds")
result: dict[str, Any] = {
"started_at": started_at,
"style_name": task.style_name,
"style_record_id": task.style_record_id,
"note_record_id": task.note_record_id,
"platform_hint": task.platform_hint,
"platform": platform,
"creator_name": task.creator_name,
"title": task.title,
"note_url": task.note_url,
"source_base_url": task.source_base_url,
"status": "pending",
"comment_count": 0,
"json_path": "",
"csv_path": "",
"error": "",
}
if platform == "unknown":
result["status"] = "skipped"
result["error"] = "Unsupported note URL"
return result, []
if args.dry_run:
result["status"] = "dry_run"
return result, []
try:
if platform == "xiaohongshu":
comments, result = xiaohongshu_comment_scraper.scrape_comments(
task.note_url,
args.login_timeout,
args.max_scrolls,
args.idle_rounds,
args.headless,
)
json_path, csv_path = xiaohongshu_comment_scraper.write_outputs(task.note_url, comments, result)
note_metrics = (result.get("stats") or {}).get("note_metrics", {})
elif platform == "douyin":
comments, result = douyin_comment_scraper.scrape_comments(
task.note_url,
args.login_timeout,
args.max_scrolls,
args.idle_rounds,
args.headless,
)
json_path, csv_path = douyin_comment_scraper.write_outputs(task.note_url, comments, result)
note_metrics = (result.get("stats") or {}).get("note_metrics", {})
else:
comments, result = bilibili_comment_scraper.scrape_comments(
task.note_url,
args.bilibili_max_pages,
args.bilibili_delay,
args.headless,
args.bilibili_max_reply_pages,
)
json_path, csv_path = bilibili_comment_scraper.write_outputs(task.note_url, comments, result)
note_metrics = (result.get("stats") or {}).get("note_metrics", {})
result["status"] = "ok"
result["comment_count"] = len(comments)
result["json_path"] = str(json_path)
result["csv_path"] = str(csv_path)
comment_rows = normalize_comment_rows(task, platform, comments, note_metrics)
if not args.no_db and note_ids:
try:
db_inserted = save_scrape_to_db(task, platform, comments, note_metrics, note_ids)
log(f"Saved {db_inserted} comments to DB for {task.note_url}")
except Exception as exc:
log(f"Failed to save comments to DB: {exc}")
except Exception as exc:
result["status"] = "failed"
result["error"] = repr(exc)
comment_rows = []
result["finished_at"] = datetime.now().isoformat(timespec="seconds")
return result, comment_rows
def scrape_notes_for_platform(
platform: str,
tasks: list[NoteTask],
args_dict: dict[str, Any],
note_ids: dict[str, int] | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Scrape all notes for one platform in a separate process/browser."""
results: list[dict[str, Any]] = []
comment_rows: list[dict[str, Any]] = []
class SimpleArgs:
def __init__(self, data: dict[str, Any]):
self.__dict__.update(data)
platform_args = SimpleArgs(args_dict)
for index, task in enumerate(tasks, 1):
log(f"[{platform} {index}/{len(tasks)}] {task.style_name} | {task.note_url}")
result, rows = scrape_note(task, platform_args, note_ids)
results.append(result)
comment_rows.extend(rows)
if platform_args.between_notes_delay > 0 and index < len(tasks):
time.sleep(platform_args.between_notes_delay)
return results, comment_rows
def current_week_label() -> str:
"""Return a label like '2026_06_week4' for the current date."""
now = datetime.now()
week_of_month = (now.day - 1) // 7 + 1
return f"{now.year}_{now.month:02d}_week{week_of_month}"
def write_manifest(rows: list[dict[str, Any]]) -> tuple[Path, Path]:
SUMMARY_DIR.mkdir(exist_ok=True)
label = current_week_label()
json_path = SUMMARY_DIR / f"feishu_comment_batch_{label}.json"
csv_path = SUMMARY_DIR / f"feishu_comment_batch_{label}.csv"
json_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
fieldnames = [
"started_at",
"finished_at",
"style_name",
"style_record_id",
"note_record_id",
"platform_hint",
"platform",
"creator_name",
"title",
"note_url",
"source_base_url",
"status",
"comment_count",
"json_path",
"csv_path",
"error",
]
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return json_path, csv_path
def write_comment_summary(rows: list[dict[str, Any]]) -> tuple[Path, Path]:
SUMMARY_DIR.mkdir(exist_ok=True)
label = current_week_label()
json_path = SUMMARY_DIR / f"feishu_comments_{label}.json"
csv_path = SUMMARY_DIR / f"feishu_comments_{label}.csv"
note_counts: dict[tuple[str, str, str, str, str], int] = {}
for row in rows:
key = (
first_text(row.get("style_name")),
first_text(row.get("creator_name")),
first_text(row.get("note_title")),
first_text(row.get("platform")),
first_text(row.get("note_url")),
)
note_counts[key] = note_counts.get(key, 0) + 1
for row in rows:
key = (
first_text(row.get("style_name")),
first_text(row.get("creator_name")),
first_text(row.get("note_title")),
first_text(row.get("platform")),
first_text(row.get("note_url")),
)
if row.get("comment_count") in ("", None):
row["comment_count"] = note_counts.get(key, 0)
json_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
columns = [
("style_name", "\u6b3e\u5f0f"),
("creator_name", "\u535a\u4e3b\u540d"),
("note_title", "\u7b14\u8bb0\u540d"),
("platform", "\u5e73\u53f0"),
("note_url", "\u7b14\u8bb0\u94fe\u63a5"),
("comment_text", "\u8bc4\u8bba\u5185\u5bb9"),
("comment_count", "\u8bc4\u8bba\u6570"),
("like_count", "\u70b9\u8d5e\u6570"),
("favorite_count", "\u6536\u85cf\u6570"),
("share_count", "\u5206\u4eab\u91cf"),
]
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=[label for _, label in columns])
writer.writeheader()
for row in rows:
writer.writerow({label: row.get(key, "") for key, label in columns})
return json_path, csv_path
def main() -> int:
parser = argparse.ArgumentParser(description="Read Feishu Base note links and batch scrape comments.")
parser.add_argument("--index-base-token", default=DEFAULT_INDEX_BASE_TOKEN)
parser.add_argument("--index-table-id", default=DEFAULT_INDEX_TABLE_ID)
parser.add_argument("--index-view-id", default=DEFAULT_INDEX_VIEW_ID)
parser.add_argument("--index-platform", default="天猫", help="only read this platform group from the style index; use empty string for all")
parser.add_argument("--max-styles", type=int, default=None, help="limit styles for testing")
parser.add_argument("--max-notes-per-style", type=int, default=None, help="limit note links per style for testing")
parser.add_argument("--platform", choices=["all", "xiaohongshu", "douyin", "bilibili"], default="all")
parser.add_argument("--dry-run", action="store_true", help="only read Feishu and list tasks; do not scrape comments")
parser.add_argument("--headless", action="store_true")
parser.add_argument("--login-timeout", type=int, default=300)
parser.add_argument("--max-scrolls", type=int, default=120)
parser.add_argument("--idle-rounds", type=int, default=8)
parser.add_argument("--bilibili-max-pages", type=int, default=200)
parser.add_argument("--bilibili-max-reply-pages", type=int, default=200)
parser.add_argument("--bilibili-delay", type=float, default=0.25)
parser.add_argument("--between-notes-delay", type=float, default=1.0)
parser.add_argument("--no-db", action="store_true", help="do not write to PostgreSQL database")
parser.add_argument("--parallel", action="store_true", default=True, help="run one browser per platform in parallel (default)")
parser.add_argument("--no-parallel", action="store_true", help="disable parallel scraping")
args = parser.parse_args()
style_tasks = load_style_tasks(
args.index_base_token,
args.index_table_id,
args.index_view_id,
args.index_platform,
args.max_styles,
)
log(f"Found {len(style_tasks)} style Base links")
note_tasks = load_note_tasks(style_tasks, args.max_notes_per_style)
if args.platform != "all":
note_tasks = [task for task in note_tasks if detect_platform(task.note_url, task.platform_hint) == args.platform]
log(f"Prepared {len(note_tasks)} note scrape tasks")
style_ids: dict[str, int] = {}
note_ids: dict[str, int] = {}
if not args.no_db:
style_ids = sync_styles_to_db(style_tasks)
note_ids = sync_notes_to_db(note_tasks, style_ids)
results: list[dict[str, Any]] = []
comment_rows: list[dict[str, Any]] = []
parallel = args.parallel and not args.no_parallel and len(note_tasks) > 1
if parallel:
platform_groups: dict[str, list[NoteTask]] = {}
for task in note_tasks:
platform = detect_platform(task.note_url, task.platform_hint)
platform_groups.setdefault(platform, []).append(task)
active_groups = {p: t for p, t in platform_groups.items() if p in ("xiaohongshu", "douyin", "bilibili")}
if len(active_groups) > 1:
log(f"Running parallel scraping for platforms: {list(active_groups.keys())}")
args_dict = vars(args)
worker_inputs = [
(platform, tasks, args_dict, note_ids if not args.no_db else None)
for platform, tasks in active_groups.items()
]
with Pool(processes=len(worker_inputs)) as pool:
worker_outputs = pool.starmap(scrape_notes_for_platform, worker_inputs)
for worker_results, worker_rows in worker_outputs:
results.extend(worker_results)
comment_rows.extend(worker_rows)
else:
parallel = False
if not parallel:
for index, task in enumerate(note_tasks, 1):
platform = detect_platform(task.note_url, task.platform_hint)
log(f"[{index}/{len(note_tasks)}] {task.style_name} | {platform} | {task.note_url}")
result, rows = scrape_note(task, args, note_ids if not args.no_db else None)
results.append(result)
comment_rows.extend(rows)
if args.between_notes_delay > 0 and index < len(note_tasks):
time.sleep(args.between_notes_delay)
json_path, csv_path = write_manifest(results)
comments_json_path, comments_csv_path = write_comment_summary(comment_rows)
ok_count = sum(1 for row in results if row.get("status") == "ok")
failed_count = sum(1 for row in results if row.get("status") == "failed")
skipped_count = sum(1 for row in results if row.get("status") in {"skipped", "dry_run"})
log(f"Done. ok={ok_count}, failed={failed_count}, skipped/dry_run={skipped_count}")
log(f"Collected comment rows: {len(comment_rows)}")
log(f"Manifest JSON: {json_path}")
log(f"Manifest CSV: {csv_path}")
log(f"Comments JSON: {comments_json_path}")
log(f"Comments CSV: {comments_csv_path}")
return 0 if failed_count == 0 else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,787 @@
"""Feishu Docx (v2 blocks API) writer for note analysis reports.
Wraps `lark-cli docs +create/+update --api-version v2` with:
- atomic state persistence in data/state/feishu_doc.json
- XML escaping + XML-native builders (data table, keyword distribution, etc.)
- a node + run.js Windows pattern (mirrors analyze_note.send_feishu_report) to avoid
cmd 8K argv limit and `&|<>^%` escaping pitfalls on Windows
- permission smoke test before any real write
All lark-cli calls use `--profile hermes-analyzer --as bot --format json`.
"""
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
STATE_DIR = PATHS.state_root
TMP_DIR = PATHS.tmp_root / "feishu_doc_writer"
STATE_FILE = STATE_DIR / "feishu_doc.json"
PROFILE = "hermes-analyzer"
IDENTITY = "bot"
FEISHU_BASE_URL = "https://bu0zgpibak.feishu.cn"
# Cap for the LLM analysis text inside a doc section (matches analyze_comments).
DOC_ANALYSIS_LIMIT = 6000
# Below this size we pass --content inline; above we write to a file and use --content @<rel>.
INLINE_CONTENT_LIMIT = 7000
# Per skill: only escape TEXT inside tags, never the tags themselves.
# "&" must be replaced first to avoid double-escaping "&lt;" etc.
_XML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
}
def xml_escape(text: Any) -> str:
"""Escape XML text content. Converts newlines to <br/>."""
if text is None:
return ""
s = str(text)
for src, dst in _XML_ESCAPE_MAP.items():
s = s.replace(src, dst)
s = s.replace("\r\n", "\n").replace("\n", "<br/>")
return s
def safe_div(numerator: Any, denominator: Any) -> float:
try:
n = float(numerator)
d = float(denominator)
if d == 0:
return 0.0
return n / d
except (TypeError, ValueError):
return 0.0
def _pct(v: float) -> str:
return f"{v * 100:.2f}%" if v else "—"
def _num(v: Any) -> str:
if v in ("", None):
return "—"
try:
return f"{int(float(v)):,}"
except (TypeError, ValueError):
return str(v)
# ---------------------------------------------------------------------------
# lark-cli subprocess wrapper
# ---------------------------------------------------------------------------
def run_lark(args: list[str], cwd: Path | None = None) -> dict[str, Any]:
"""Invoke lark-cli via node + run.js, return parsed JSON dict."""
run_js = (
Path(os.environ.get("APPDATA", str(Path.home())))
/ "npm"
/ "node_modules"
/ "@larksuite"
/ "cli"
/ "scripts"
/ "run.js"
)
# Scheduled tasks run as SYSTEM; %APPDATA% is not Administrator's dir
if not run_js.exists():
admin_run_js = Path(os.path.expandvars(r"%APPDATA%\npm\node_modules\@larksuite\cli\scripts\run.js"))
if admin_run_js.exists():
run_js = admin_run_js
if not run_js.exists():
raise RuntimeError(f"找不到 lark-cli 入口: {run_js}")
cmd = [
"node", str(run_js),
"--profile", PROFILE,
"--as", IDENTITY,
"--format", "json",
*args,
]
try:
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=180,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"lark-cli 调用超时: {exc}") from exc
if proc.returncode != 0:
raise RuntimeError(
f"lark-cli 调用失败 (exit {proc.returncode})\n"
f"cmd head: {' '.join(args[:4])}\n"
f"stderr: {proc.stderr.strip()[:500]}\n"
f"stdout: {proc.stdout[:500]}"
)
out = proc.stdout.strip()
if not out:
return {}
try:
result = json.loads(out)
except json.JSONDecodeError as exc:
raise RuntimeError(f"lark-cli 返回非 JSON: {out[:500]}") from exc
if isinstance(result, dict) and result.get("ok") is False:
err = result.get("error") or {}
raise RuntimeError(
f"lark-cli API error: code={err.get('code')} type={err.get('type')} "
f"msg={err.get('message')}"
)
# Some API errors surface as ok=True + result="failed" + warnings. Detect those.
data = (result.get("data") or {}) if isinstance(result, dict) else {}
op_result = data.get("result") if isinstance(data, dict) else None
if op_result == "failed":
warnings = data.get("warnings") or []
raise RuntimeError(
f"lark-cli 操作失败 (result=failed): warnings={warnings}"
)
return result if isinstance(result, dict) else {}
# ---------------------------------------------------------------------------
# State persistence
# ---------------------------------------------------------------------------
def load_state() -> dict[str, Any]:
if not STATE_FILE.exists():
return {"docs": {}, "written_notes": {}}
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {"docs": {}, "written_notes": {}}
data.setdefault("docs", {})
data.setdefault("written_notes", {})
return data
except json.JSONDecodeError:
backup = STATE_FILE.with_suffix(f".corrupt-{int(time.time())}.json")
shutil.copy2(STATE_FILE, backup)
return {"docs": {}, "written_notes": {}, "_corrupt_backup": str(backup)}
def save_state(state: dict[str, Any]) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(STATE_FILE)
# ---------------------------------------------------------------------------
# Doc API wrappers
# ---------------------------------------------------------------------------
def create_doc(xml_content: str) -> dict[str, Any]:
"""Create a new Docx. Returns {token, url, revision_id}."""
result = run_lark([
"docs", "+create",
"--api-version", "v2",
"--content", xml_content,
])
doc = (result.get("data") or {}).get("document") or {}
return {
"token": doc.get("document_id") or "",
"url": doc.get("url") or "",
"revision_id": doc.get("revision_id"),
}
def _invoke_update_with_content(
doc: str, command: str, xml_content: str,
*, anchor: str | None = None,
) -> dict[str, Any]:
"""Run docs +update with either inline --content or @file content."""
args = [
"docs", "+update",
"--api-version", "v2",
"--doc", doc,
"--command", command,
]
if command.startswith("block_"):
if not anchor:
raise ValueError(f"command {command} requires --block-id")
args.extend(["--block-id", anchor])
if len(xml_content.encode("utf-8")) <= INLINE_CONTENT_LIMIT:
args.extend(["--content", xml_content])
return run_lark(args)
TMP_DIR.mkdir(parents=True, exist_ok=True)
fname = f"_docwriter_{int(time.time() * 1000)}.xml"
fpath = TMP_DIR / fname
fpath.write_text(xml_content, encoding="utf-8")
try:
# lark-cli resolves `@file` relative to cwd. We cwd=STATE_DIR but the
# file lives in STATE_DIR/tmp — pass the relative path from STATE_DIR.
rel_path = f"tmp/{fname}"
args.extend(["--content", f"@{rel_path}"])
return run_lark(args, cwd=STATE_DIR)
finally:
try:
fpath.unlink()
except OSError:
pass
def append_blocks(doc: str, xml_content: str) -> dict[str, Any]:
"""Append XML blocks to end of doc. Returns {revision_id}."""
return _invoke_update_with_content(doc, "append", xml_content)
def insert_blocks_after(doc: str, anchor_block_id: str, xml_content: str) -> dict[str, Any]:
"""Insert XML blocks after a given block. Returns {revision_id}."""
return _invoke_update_with_content(
doc, "block_insert_after", xml_content, anchor=anchor_block_id,
)
def delete_blocks(doc: str, block_ids: list[str]) -> dict[str, Any]:
"""Batch-delete blocks (comma-separated --block-id)."""
if not block_ids:
return {}
args = [
"docs", "+update",
"--api-version", "v2",
"--doc", doc,
"--command", "block_delete",
"--block-id", ",".join(block_ids),
]
return run_lark(args)
def fetch_doc_blocks(doc: str, *, scope: str = "all") -> list[dict[str, Any]]:
"""Fetch block IDs in a doc. Returns list of {block_id, block_type} dicts."""
args = [
"docs", "+fetch",
"--api-version", "v2",
"--doc", doc,
"--detail", "with-ids",
]
if scope and scope != "all":
args.extend(["--scope", scope])
result = run_lark(args)
# The fetch returns content as XML string; parse block IDs from `id="..."` attrs.
content = ((result.get("data") or {}).get("document") or {}).get("content") or ""
out: list[dict[str, Any]] = []
import re
# Extract <tagname id="..."> in document order
for m in re.finditer(r"<(\w+)\s+id=\"([^\"]+)\"", content):
out.append({"block_id": m.group(2), "block_type": m.group(1)})
return out
def find_block_after(doc: str, anchor_text: str) -> str | None:
"""Find the block_id of the first <h*> block whose text contains anchor_text.
Used to discover the h1 anchor after doc creation, since the API response
doesn't include new_blocks.
"""
blocks = fetch_doc_blocks(doc, scope="all")
for b in blocks:
if b["block_type"] in ("h1", "h2", "h3", "h4"):
# We need the actual text; refetch with content
pass
# The fetch returned content embedded; do a full re-fetch with text
result = run_lark([
"docs", "+fetch",
"--api-version", "v2",
"--doc", doc,
"--detail", "with-ids",
])
content = ((result.get("data") or {}).get("document") or {}).get("content") or ""
import re
# Find all <h1 ...>...</h1> or <h2 ...>...</h2>
pattern = re.compile(r"<(h\d)\s+id=\"([^\"]+)\"[^>]*>(.*?)</\1>", re.DOTALL)
for m in pattern.finditer(content):
if anchor_text in m.group(3):
return m.group(2)
return None
def find_blocks_after_anchor(doc: str, anchor_block_id: str) -> list[str]:
"""Return block_ids of every block that appears AFTER anchor_block_id, in order.
Used by populate_master_index to delete stale index content before re-inserting.
"""
blocks = fetch_doc_blocks(doc, scope="all")
out: list[str] = []
found = False
for b in blocks:
if found:
out.append(b["block_id"])
elif b["block_id"] == anchor_block_id:
found = True
return out
def find_index_blocks(doc: str, anchor_block_id: str) -> list[str]:
"""Return block_ids of the index content (blocks between anchor h1 and the next style section).
The index lives immediately after the anchor h1 and ends at the first
subsequent style section marker — either an H1 (the style title) or a
bookmark that precedes it. This avoids deleting style sections.
"""
blocks = fetch_doc_blocks(doc, scope="all")
out: list[str] = []
found_anchor = False
for b in blocks:
if not found_anchor:
if b["block_id"] == anchor_block_id:
found_anchor = True
continue
# Stop at the first style-section marker after the anchor.
if b["block_type"] in ("h1", "bookmark"):
break
out.append(b["block_id"])
return out
# ---------------------------------------------------------------------------
# XML builders
# ---------------------------------------------------------------------------
CATEGORY_LABELS = {
"purchase_intent": "购买意向",
"positive_feature": "正向功能反馈",
"negative_feature": "负向功能反馈",
"user_scenario": "用户场景",
"aesthetic": "审美与风格",
"brand_emotion": "品牌情感",
}
def build_basic_info_xml(info: dict[str, Any]) -> str:
title = info.get("title") or "未获取"
platform = info.get("platform") or "unknown"
creator = info.get("creator_name") or "未指定"
style_name = info.get("style_name") or ""
source_url = info.get("source_url") or ""
brand = info.get("brand") or ""
target_label = " / ".join(x for x in (brand, style_name) if x) if (brand or style_name) else "未指定"
lines = [
f"<p><b>标题</b> {xml_escape(title)}</p>",
f"<p><b>平台</b> {platform} · <b>达人</b> {xml_escape(creator)}</p>",
f"<p><b>品牌 / 产品</b> {xml_escape(target_label)}</p>",
]
if source_url:
lines.append(f'<p><b>链接</b> <a href="{xml_escape(source_url)}">{xml_escape(source_url)}</a></p>')
return "<callout emoji=\"📌\" background-color=\"light-blue\" border-color=\"blue\">" + "".join(lines) + "</callout>"
def build_data_table_xml(
info: dict[str, Any], metrics: dict[str, Any], raw_total: int, filtered_total: int,
) -> str:
"""XML table mirroring analyze_note._data_table_markdown."""
has_view = bool(metrics.get("view_count"))
likes = info.get("like_count")
favorites = info.get("favorite_count")
comments = info.get("comment_count_metric") or info.get("comment_count_total")
shares = info.get("share_count")
valid_ratio = safe_div(filtered_total, raw_total)
rows: list[str] = []
rows.append(_tr(("维度", "指标", "数值"), header=True))
rows.append(_tr(("基础", "点赞", _num(likes))))
rows.append(_tr(("基础", "收藏", _num(favorites))))
rows.append(_tr(("基础", "评论", _num(comments))))
rows.append(_tr(("基础", "分享", _num(shares))))
rows.append(_tr(("传播", "曝光量", _num(metrics.get("view_count")) if has_view else "未采集")))
rows.append(_tr(("互动率", "点赞率", _pct(metrics.get("like_rate", 0)))))
rows.append(_tr(("互动率", "评论率", _pct(metrics.get("comment_rate", 0)))))
rows.append(_tr(("互动率", "收藏率", _pct(metrics.get("favorite_rate", 0)))))
rows.append(_tr(("互动率", "分享率", _pct(metrics.get("share_rate", 0)))))
rows.append(_tr(("互动率", "综合互动率", _pct(metrics.get("engagement_rate", 0)))))
fav_to_cmt = metrics.get("favorite_to_comment", 0) or 0
rows.append(_tr(("互动率", "收藏评论比", f"{fav_to_cmt:.2f}")))
rows.append(_tr(("质量", "有效率", _pct(valid_ratio))))
return (
'<callout emoji="📊" background-color="light-blue" border-color="blue">'
"<table>"
"<colgroup><col span=\"3\" width=\"120\"/></colgroup>"
"<thead>" + rows[0] + "</thead>"
"<tbody>" + "".join(rows[1:]) + "</tbody>"
"</table>"
"</callout>"
)
def _tr(cells: tuple[str, str, str], *, header: bool = False) -> str:
tag = "th" if header else "td"
bg = ' background-color="light-gray"' if header else ""
return (
f"<tr>"
+ "".join(f"<{tag}{bg}>{xml_escape(c)}</{tag}>" for c in cells)
+ "</tr>"
)
def build_keyword_distribution_xml(
distribution: dict[str, Any], total_filtered: int,
) -> str:
overall = distribution.get("overall", {}) if isinstance(distribution, dict) else {}
relevant = distribution.get("relevant", {}) if isinstance(distribution, dict) else {}
relevant_total = distribution.get("relevant_total", 0) if isinstance(distribution, dict) else 0
if not overall:
return "<p>(无关键词分布数据)</p>"
def fmt(d: dict[str, int]) -> str:
return "".join(
f"<li>{xml_escape(CATEGORY_LABELS[k])}:{d.get(k, 0)} 条</li>"
for k in CATEGORY_LABELS
)
overall_block = (
f"<p><b>全评论({total_filtered} 条)</b></p>"
f"<ul>{fmt(overall)}</ul>"
)
if relevant_total:
relevant_block = (
f"<p><b>我方产品相关({relevant_total} 条)</b></p>"
f"<ul>{fmt(relevant)}</ul>"
)
else:
relevant_block = "<p><b>我方产品相关(0 条)</b></p><p><i>(无评论明确提及我方产品/品牌)</i></p>"
return (
'<callout emoji="🔑" background-color="light-cyan" border-color="cyan">'
+ overall_block + relevant_block
+ "</callout>"
)
# 模块 emoji 和颜色映射
_MODULE_STYLES = {
"模块1": ("🎯", "light-blue", "blue"), # 笔记表现归因
"模块2": ("📊", "light-green", "green"), # 评论质量分层
"模块3": ("🛡️", "light-red", "red"), # 品牌安全与控评
"模块4": ("🎬", "light-purple", "purple"), # 脚本有效性拆解
"模块5": ("🤝", "light-yellow", "orange"), # 达人匹配度评估
"模块6": ("💡", "light-cyan", "cyan"), # 可沉淀的选题资产
"模块7": ("✅", "light-green", "green"), # 下一篇可执行优化清单
}
def _split_analysis_modules(analysis_text: str) -> list[str]:
"""将 LLM 输出的 7 模块分析拆分为独立 callout 块。
每个模块用不同颜色的 callout 渲染,视觉层次更分明。
"""
if not analysis_text or analysis_text == "(无分析结果)":
return ['<callout emoji="💡" background-color="light-gray" border-color="gray">'
'<p>(无分析结果)</p></callout>']
# 按模块标记拆分
import re as _re
parts = _re.split(r'(模块\d+[||])', analysis_text)
# parts: [前缀, "模块1|", 内容1, "模块2|", 内容2, ...]
blocks: list[str] = []
# 处理模块前的引言文字(如有)
if parts and parts[0].strip():
intro = parts[0].strip()
if len(intro) > 20:
blocks.append(f'<callout emoji="📋" background-color="light-gray" border-color="gray">'
f'<p>{xml_escape(intro)}</p></callout>')
# 逐模块渲染
i = 1
while i < len(parts) - 1:
module_header = parts[i] # "模块1|"
module_body = parts[i + 1] # 该模块的内容
i += 2
# 提取模块编号
m = _re.match(r'模块(\d+)', module_header)
if not m:
continue
mod_num = m.group(1)
mod_key = f"模块{mod_num}"
emoji, bg, border = _MODULE_STYLES.get(mod_key, ("📌", "light-gray", "gray"))
# 模块标题行 + 正文
body_text = module_body.strip()
if not body_text:
continue
# 截断过长内容
if len(body_text) > 800:
body_text = body_text[:800] + "\n...(已截断)"
blocks.append(
f'<callout emoji="{emoji}" background-color="{bg}" border-color="{border}">'
f'<p>{xml_escape(body_text)}</p></callout>'
)
# 如果没有拆出模块(LLM 没按格式输出),整体兜底
if not blocks:
text = analysis_text
if len(text) > DOC_ANALYSIS_LIMIT:
text = text[:DOC_ANALYSIS_LIMIT] + "\n...(已截断)"
blocks.append(f'<callout emoji="💡" background-color="light-blue" border-color="blue">'
f'<p>{xml_escape(text)}</p></callout>')
return blocks
def build_note_section_xml(
info: dict[str, Any],
metrics: dict[str, Any],
distribution: dict[str, Any],
raw_comments: list[str],
filtered_comments: list[str],
analysis: str,
*,
bookmark_name: str,
doc_token: str,
) -> str:
title = info.get("title") or info.get("source_url") or "未获取"
platform = info.get("platform") or "unknown"
# 截断标题用于 H3,避免超长
short_title = title[:30] + "…" if len(title) > 30 else title
h_text = f"📝 {short_title}({platform})"
analysis_text = analysis or "(无分析结果)"
if len(analysis_text) > DOC_ANALYSIS_LIMIT:
analysis_text = analysis_text[:DOC_ANALYSIS_LIMIT] + "<br/>...(内容过长,已截断)"
# Bookmark requires href attribute; use the doc's own URL (target for incoming #bookmark links).
bookmark_href = f"{FEISHU_BASE_URL}/docx/{doc_token}#{bookmark_name}"
# 将分析文本按模块拆分,逐段渲染
analysis_parts = _split_analysis_modules(analysis_text)
return (
f"<bookmark name=\"{xml_escape(bookmark_name)}\" href=\"{xml_escape(bookmark_href)}\"></bookmark>"
f"<h3>{xml_escape(h_text)}</h3>"
+ build_basic_info_xml(info)
+ build_data_table_xml(info, metrics, len(raw_comments), len(filtered_comments))
+ build_keyword_distribution_xml(distribution, len(filtered_comments))
+ "".join(analysis_parts)
)
def build_master_summary_table_xml(
per_platform: dict[str, dict[str, Any]],
) -> str:
"""Build the 4-row master summary table (平台/笔记数/详情)."""
rows = []
for p, info in per_platform.items():
url = info.get("url") or ""
n = info.get("count", 0)
link_cell = f'<a href="{xml_escape(url)}">打开 →</a>' if url else "—"
rows.append(
f"<tr><td>{xml_escape(p)}</td><td>{n}</td><td>{link_cell}</td></tr>"
)
return (
"<table>"
"<colgroup><col width=\"120\"/><col width=\"80\"/><col width=\"300\"/></colgroup>"
"<thead>" + _tr(("平台", "笔记数", "详情"), header=True) + "</thead>"
"<tbody>" + "".join(rows) + "</tbody>"
"</table>"
)
def build_per_platform_index_table_xml(
platform: str, state: dict[str, Any], platform_doc_url: str,
) -> tuple[str, int]:
"""Build index table XML for one platform section in the master doc.
Returns (xml_string, row_count).
"""
rows: list[str] = []
for nid_str, entry in state.get("written_notes", {}).items():
if entry.get("platform") != platform:
continue
bookmark = entry.get("bookmark_name") or f"note-{nid_str}"
link = f"{platform_doc_url}#{bookmark}"
rows.append(
f'<tr><td>{nid_str}</td>'
f'<td><a href="{xml_escape(link)}">查看 →</a></td></tr>'
)
rows.sort(key=lambda r: int(re.search(r">(\d+)<", r).group(1)) if re.search(r">(\d+)<", r) else 0)
body = "".join(rows)
return (
f"<h2>📑 {platform} 索引({len(rows)} 条)</h2>"
"<table>"
"<colgroup><col width=\"100\"/><col width=\"180\"/></colgroup>"
"<thead>" + _tr(("note_id", "详情"), header=True) + "</thead>"
"<tbody>" + body + "</tbody>"
"</table>"
), len(rows)
# Local re for sort
import re # noqa: E402
# ---------------------------------------------------------------------------
# Per-style doc builders (used by single-archive layout)
# ---------------------------------------------------------------------------
def _doc_skeleton() -> str:
"""Initial skeleton for the single archive doc.
Title + intro + H1 anchor placeholder for the 款式索引. The actual index
table is populated later via populate_index_table() (replace-on-update).
"""
return (
"<title>👜 笔记分析报告 — 按款式汇总</title>"
"<p>本汇总由 write_notes_to_doc.py 自动生成。点击款式名称进入详情。</p>"
+ "<h1>⚠️ 款式索引(点击跳转)</h1>"
)
def build_style_overview_xml(style_name: str, aggregated: dict[str, Any]) -> str:
"""Callout block with style-level rollup metrics."""
plat = aggregated.get("platform_breakdown") or {}
plat_str = " / ".join(f"{k} {v}" for k, v in plat.items() if v) or "—"
creator = aggregated.get("creator_breakdown") or {}
creator_str = " / ".join(f"{k}×{v}" for k, v in list(creator.items())[:3]) or "—"
lines = [
f"<p><b>款式</b> {xml_escape(style_name)}</p>",
f"<p><b>笔记总数</b> {aggregated.get('note_count', 0)} 篇 · "
f"<b>平台分布</b> {xml_escape(plat_str)}</p>",
f"<p><b>头部达人</b> {xml_escape(creator_str)}</p>",
f"<p><b>总互动</b> "
f"点赞 {_num(aggregated.get('total_likes'))} · "
f"收藏 {_num(aggregated.get('total_favorites'))} · "
f"评论 {_num(aggregated.get('total_comments'))} · "
f"分享 {_num(aggregated.get('total_shares'))}</p>",
f"<p><b>总曝光量</b> {_num(aggregated.get('total_views')) if aggregated.get('total_views') else '未采集'} · "
f"<b>综合互动率</b> {_pct(aggregated.get('engagement_rate') or 0)}</p>",
]
return (
'<callout emoji="👜" background-color="light-yellow" border-color="orange">'
+ "".join(lines)
+ "</callout>"
)
def build_style_section_xml(
style_id: int,
style_name: str,
aggregated: dict[str, Any],
rollup_text: str,
note_sections_xml: str,
*,
doc_token: str,
) -> str:
"""Assemble one full style section: bookmark + H1 + overview + rollup H2 + per-note sections.
`note_sections_xml` should be the concatenation of `build_note_section_xml(...)`
outputs for each note in this style (each already has its own bookmark + H3).
"""
bookmark_name = f"style-{style_id}"
bookmark_href = f"{FEISHU_BASE_URL}/docx/{doc_token}#{bookmark_name}"
h1_text = f"👜 {style_name}({aggregated.get('note_count', 0)} 篇笔记)"
note_count = aggregated.get("note_count", 0)
rollup_in_callout = (
'<callout emoji="💼" background-color="light-purple" border-color="purple">'
f'<p>{xml_escape(rollup_text or "(无整体分析结果)")}</p></callout>'
)
parts: list[str] = [
f'<bookmark name="{xml_escape(bookmark_name)}" href="{xml_escape(bookmark_href)}"></bookmark>',
f"<h1>{xml_escape(h1_text)}</h1>",
build_style_overview_xml(style_name, aggregated),
"<h2>💼 款式整体分析</h2>",
rollup_in_callout,
f"<h2>📚 单篇笔记分析({note_count} 篇)</h2>",
]
if note_sections_xml:
parts.append(note_sections_xml)
return "".join(parts)
def build_style_index_table_xml(
written_styles: dict[str, Any],
*,
archive_url: str,
) -> str:
"""Build the 款式索引 table at the top of the archive doc.
Rows are sorted by note count DESC (driven by caller-supplied dict order,
which write_notes_to_doc.py maintains from db.get_all_styles_with_notes).
Each row: 款式名 | 笔记数 | 跳转链接.
"""
rows: list[str] = []
for sid_str, entry in written_styles.items():
if not entry.get("doc_token"):
continue
# Find note count from entry (carried over by writer)
n = entry.get("note_count", 0)
bookmark = entry.get("bookmark_name") or f"style-{sid_str}"
link = f"{archive_url}#{bookmark}"
rows.append(
f"<tr><td>{xml_escape(str(entry.get('style_name', sid_str)))}</td>"
f"<td>{n}</td>"
f'<td><a href="{xml_escape(link)}">跳转 →</a></td></tr>'
)
if not rows:
rows.append("<tr><td>—</td><td>0</td><td>—</td></tr>")
return (
"<table>"
"<colgroup><col width=\"160\"/><col width=\"80\"/><col width=\"300\"/></colgroup>"
"<thead>" + _tr(("款式", "笔记数", "详情"), header=True) + "</thead>"
"<tbody>" + "".join(rows) + "</tbody>"
"</table>"
)
# ---------------------------------------------------------------------------
# Permission smoke test
# ---------------------------------------------------------------------------
def permission_smoke_test() -> bool:
print("→ Running permission smoke test (--as bot, docs:document:create)...")
try:
run_lark([
"docs", "+create",
"--api-version", "v2",
"--content", "<title>__smoke_test__</title><p>please ignore</p>",
])
except RuntimeError as exc:
print(f" ✗ Smoke test failed: {exc}")
print()
print(" The hermes-analyzer bot identity lacks the required scopes.")
print(" Run: lark-cli auth login --profile hermes-analyzer")
print(" And ensure the app is granted these scopes:")
print(" - docs:document:create")
print(" - docs:document:update")
print(" - docs:document:read")
print(" Then retry.")
return False
print(f" ✓ Smoke test passed. (A throwaway doc was created — delete it manually from Drive.)")
return True
if __name__ == "__main__":
sys.exit(0 if permission_smoke_test() else 1)
@@ -0,0 +1,39 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Friday relogin: 4 QR scans — B站, 蒲公英, 星图(+同步到抖音评论), 小红书评论
REM 星图登录完成后自动把 cookie 同步到抖音评论 scraper,不需要单独再扫抖音码
REM Register: schtasks /Create /SC WEEKLY /D FRI /TN YingxiaoYunying_FridayRelogin /TR %PROJECT_DIR%\data\tools\friday_relogin.bat /ST 10:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\friday_relogin_%TS%.log
echo === Friday relogin started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
echo --- Launch 5 QR relogins in parallel; send screenshots; retry up to 3 rounds --- >> "%LOG_FILE%"
call %PYTHON% data\tools\friday_relogin_parallel.py --max-attempts 3 --round-timeout 300 --screenshot-delay 60 >> "%LOG_FILE%" 2>&1
set FINAL_RC=%ERRORLEVEL%
echo === Friday relogin finished at %date% %time% (exit=%FINAL_RC%) === >> "%LOG_FILE%"
endlocal & exit /b %FINAL_RC%
@@ -0,0 +1,408 @@
"""Launch five QR relogins in parallel, notify via analyzer Lark, retry 3 times."""
from __future__ import annotations
import argparse
import ctypes
from ctypes import wintypes
import datetime as dt
import json
import os
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import psutil
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
PROJECT_DIR = PATHS.module_root
ANALYZER_DIR = PATHS.tmp_root / "relogin"
MARKETING_RECIPIENT = "ou_24cc944d6e43c69c59d6560ad4e2ae6e"
TECHNICAL_RECIPIENT = "ou_7ad5fc8012e2f741afc5346e05ffd447"
@dataclass(frozen=True)
class Platform:
label: str
command: tuple[str, ...]
cookie_file: Path
required_cookie: str
PLATFORMS = {
"douyin": Platform(
"\u6296\u97f3", ("data/tools/relogin_douyin.py",),
PATHS.state_root / "cookies/douyin_cookies.json", "sessionid",
),
"bilibili": Platform(
"B\u7ad9", ("data/tools/relogin_bilibili.py",),
PATHS.state_root / "cookies/bilibili_cookies.json", "SESSDATA",
),
"pgy": Platform(
"\u84b2\u516c\u82f1", ("data/tools/relogin_pgy.py",),
PATHS.state_root / "cookies/pgy_cookies.json", "",
),
"xingtu": Platform(
"\u661f\u56fe", ("data/tools/relogin_xingtu.py", "--force"),
PATHS.state_root / "cookies/xingtu_cookies.json", "sessionid",
),
"xiaohongshu": Platform(
"\u5c0f\u7ea2\u4e66", ("data/tools/relogin_xiaohongshu.py",),
PATHS.state_root / "cookies/xiaohongshu_cookies.json", "web_session",
),
}
DEFAULT_RECIPIENTS = {
"pgy": MARKETING_RECIPIENT,
"xingtu": MARKETING_RECIPIENT,
"douyin": TECHNICAL_RECIPIENT,
"bilibili": TECHNICAL_RECIPIENT,
"xiaohongshu": TECHNICAL_RECIPIENT,
}
def recipient_for_platform(platform_name: str, override: str | None = None) -> str:
"""Return the responsible recipient, unless a manual all-platform override is set."""
if override:
return override
try:
return DEFAULT_RECIPIENTS[platform_name]
except KeyError as exc:
raise ValueError(f"no Lark recipient configured for platform: {platform_name}") from exc
def group_platforms_by_recipient(
platform_names: list[str], override: str | None = None,
) -> dict[str, list[str]]:
grouped: dict[str, list[str]] = {}
for name in platform_names:
grouped.setdefault(recipient_for_platform(name, override), []).append(name)
return grouped
def run_with_retries(
platform_names: list[str],
*,
max_attempts: int,
run_round: Callable[[list[str], int], dict[str, bool]],
) -> dict[str, bool]:
status = {name: False for name in platform_names}
pending = list(platform_names)
for attempt in range(1, max_attempts + 1):
if not pending:
break
round_result = run_round(pending, attempt)
for name in pending:
status[name] = bool(round_result.get(name))
pending = [name for name in pending if not status[name]]
return status
def _valid_cookie(platform: Platform) -> bool:
if not platform.cookie_file.is_file():
return False
if not platform.required_cookie:
return platform.cookie_file.stat().st_size > 10
try:
import json
values = json.loads(platform.cookie_file.read_text(encoding="utf-8"))
return any(
item.get("name") == platform.required_cookie and item.get("value")
for item in values if isinstance(item, dict)
)
except Exception:
return False
def _descendant_pids(pid: int) -> set[int]:
try:
return {child.pid for child in psutil.Process(pid).children(recursive=True)}
except (psutil.Error, OSError):
return set()
def _terminate_process_tree(process: subprocess.Popen) -> None:
"""Terminate the whole Windows subprocess tree, including Chrome/Playwright."""
try:
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
capture_output=True,
text=True,
check=False,
)
except OSError:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
def _window_for_process(pid: int) -> int | None:
pids = _descendant_pids(pid) | {pid}
handles: list[int] = []
user32 = ctypes.windll.user32
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
def callback(hwnd, _lparam):
process_id = ctypes.c_ulong()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(process_id))
if process_id.value in pids and user32.IsWindowVisible(hwnd):
handles.append(int(hwnd))
return True
user32.EnumWindows(callback_type(callback), 0)
return handles[0] if handles else None
def _platform_from_window_title(title: str) -> str | None:
"""Identify a login window without relying on Chrome's process tree."""
if "\u84b2\u516c\u82f1" in title:
return "pgy"
if "\u6296\u97f3\u6388\u6743" in title or "\u5de8\u91cf\u661f\u56fe" in title:
return "xingtu"
if "\u6296\u97f3" in title:
return "douyin"
if "bilibili" in title.lower() or "\u8d26\u53f7\u767b\u5f55" in title:
return "bilibili"
if "\u5c0f\u7ea2\u4e66" in title:
return "xiaohongshu"
return None
def _login_windows() -> dict[str, int]:
"""Map visible Chrome login windows to platforms by their page titles."""
user32 = ctypes.windll.user32
found: dict[str, int] = {}
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
def callback(hwnd, _lparam):
if not user32.IsWindowVisible(hwnd):
return True
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return True
buffer = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buffer, length + 1)
platform = _platform_from_window_title(buffer.value)
if platform:
found[platform] = int(hwnd)
return True
user32.EnumWindows(callback_type(callback), 0)
return found
def _capture_window(hwnd: int, output: Path) -> None:
"""Maximize and render one window directly, even when it is not foreground."""
from PIL import Image
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE
time.sleep(1)
rect = wintypes.RECT()
user32.GetWindowRect(hwnd, ctypes.byref(rect))
width, height = rect.right - rect.left, rect.bottom - rect.top
if width <= 0 or height <= 0:
raise RuntimeError(f"invalid window size: {width}x{height}")
window_dc = user32.GetWindowDC(hwnd)
memory_dc = gdi32.CreateCompatibleDC(window_dc)
bitmap = gdi32.CreateCompatibleBitmap(window_dc, width, height)
previous = gdi32.SelectObject(memory_dc, bitmap)
try:
if not user32.PrintWindow(hwnd, memory_dc, 2): # PW_RENDERFULLCONTENT
raise RuntimeError("PrintWindow failed")
class BitmapInfoHeader(ctypes.Structure):
_fields_ = [
("biSize", wintypes.DWORD), ("biWidth", wintypes.LONG),
("biHeight", wintypes.LONG), ("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", wintypes.LONG),
("biYPelsPerMeter", wintypes.LONG),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
byte_count = width * height * 4
pixels = ctypes.create_string_buffer(byte_count)
header = BitmapInfoHeader(
ctypes.sizeof(BitmapInfoHeader), width, -height, 1, 32, 0,
byte_count, 0, 0, 0, 0,
)
rows = gdi32.GetDIBits(
memory_dc, bitmap, 0, height, pixels, ctypes.byref(header), 0
)
if rows != height:
raise RuntimeError(f"GetDIBits returned {rows}/{height} rows")
image = Image.frombuffer(
"RGB", (width, height), pixels, "raw", "BGRX", 0, 1
).copy()
finally:
gdi32.SelectObject(memory_dc, previous)
gdi32.DeleteObject(bitmap)
gdi32.DeleteDC(memory_dc)
user32.ReleaseDC(hwnd, window_dc)
output.parent.mkdir(parents=True, exist_ok=True)
image.save(output, "PNG")
def _tile_windows(handles: list[int]) -> None:
"""Place four login windows in a 2x2 grid so screenshots cannot overlap."""
user32 = ctypes.windll.user32
screen_w = user32.GetSystemMetrics(0)
screen_h = user32.GetSystemMetrics(1)
cell_w = max(640, screen_w // 2)
cell_h = max(450, screen_h // 2)
for index, hwnd in enumerate(handles):
x = (index % 2) * cell_w
y = (index // 2) * cell_h
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.MoveWindow(hwnd, x, y, cell_w, cell_h, True)
def _send_lark(recipient: str, text: str | None = None, image: Path | None = None) -> None:
"""Send a notification through the locally installed Lark CLI."""
lark_env = {
key: value for key, value in os.environ.items()
if not key.upper().startswith("HERMES_")
}
def run_lark_cli(args: list[str], cwd: Path) -> None:
cwd.mkdir(parents=True, exist_ok=True)
command = shutil.which("lark-cli.cmd") or shutil.which("lark-cli") or "lark-cli.cmd"
completed = subprocess.run(
[command, *args],
cwd=cwd,
env=lark_env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if completed.returncode:
detail = (completed.stderr or completed.stdout).strip()
raise RuntimeError(f"lark-cli failed ({completed.returncode}): {detail}")
if text:
# ensure_ascii keeps the Windows subprocess argv ASCII-only while the
# JSON parser restores the original Chinese text for Feishu.
content = json.dumps({"text": text}, ensure_ascii=True)
run_lark_cli(
["im", "+messages-send", "--user-id", recipient,
"--as", "bot", "--content", content],
cwd=ANALYZER_DIR,
)
if image:
run_lark_cli(
["im", "+messages-send", "--user-id", recipient,
"--as", "bot", "--image", f"./{image.name}"], cwd=image.parent,
)
def _run_round_factory(args):
python = sys.executable
screenshot_root = PATHS.tmp_root / "qrcode/friday_relogin"
def run_round(names: list[str], attempt: int) -> dict[str, bool]:
started: dict[str, subprocess.Popen] = {}
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
for name in names:
platform = PLATFORMS[name]
cmd = [python, *platform.command, "--login-timeout", str(args.round_timeout)]
started[name] = subprocess.Popen(cmd, cwd=PROJECT_DIR)
time.sleep(args.screenshot_delay)
title_windows = _login_windows()
windows = {
name: title_windows.get(name) or _window_for_process(process.pid)
for name, process in started.items()
}
_tile_windows([hwnd for hwnd in windows.values() if hwnd])
time.sleep(2)
for name, process in started.items():
platform = PLATFORMS[name]
hwnd = windows[name]
if not hwnd:
print(f"[{name}] no visible browser window for QR screenshot", flush=True)
continue
output = screenshot_root / f"{stamp}_{name}_round{attempt}.png"
try:
_capture_window(hwnd, output)
if not args.no_send:
recipient = recipient_for_platform(name, args.recipient)
_send_lark(
recipient,
text=(
f"{platform.label} \u767b\u5f55\u4e8c\u7ef4\u7801"
f"\uff08\u7b2c {attempt}/3 \u6b21\uff09\uff0c"
"\u8bf7\u5728 3 \u5206\u949f\u5185\u626b\u7801\u3002"
),
)
_send_lark(recipient, image=output)
print(f"[{name}] QR screenshot: {output}", flush=True)
except Exception as exc:
print(f"[{name}] screenshot/send failed: {exc}", flush=True)
# Put the still-open login windows back into a compact layout after
# taking full-size screenshots one at a time.
_tile_windows([hwnd for hwnd in windows.values() if hwnd])
results: dict[str, bool] = {}
for name, process in started.items():
try:
rc = process.wait(timeout=args.round_timeout + 90)
except subprocess.TimeoutExpired:
_terminate_process_tree(process)
rc = 1
results[name] = rc == 0 and _valid_cookie(PLATFORMS[name])
print(f"[{name}] round={attempt} exit={rc} success={results[name]}", flush=True)
return results
return run_round
def main() -> int:
parser = argparse.ArgumentParser(description="Friday five-platform parallel QR relogin")
parser.add_argument(
"--recipient", default=None,
help="Override the configured recipients and send every platform to one open_id",
)
parser.add_argument("--max-attempts", type=int, default=3)
parser.add_argument("--round-timeout", type=int, default=300)
parser.add_argument("--screenshot-delay", type=int, default=60)
parser.add_argument("--no-send", action="store_true", help="Do not send Lark messages")
args = parser.parse_args()
status = run_with_retries(
list(PLATFORMS), max_attempts=args.max_attempts,
run_round=_run_round_factory(args),
)
failed = [name for name, ok in status.items() if not ok]
if failed and not args.no_send:
for recipient, names in group_platforms_by_recipient(failed, args.recipient).items():
labels = "\u3001".join(PLATFORMS[name].label for name in names)
_send_lark(
recipient,
text=(
"\u4ee5\u4e0b\u5e73\u53f0\u8fde\u7eed 3 \u6b21\u672a\u626b\u7801\uff0c"
"\u5df2\u6062\u590d\u5e76\u7ee7\u4f7f\u7528\u65e7 Cookie\uff1a"
f"{labels}"
),
)
print(f"Final status: {status}", flush=True)
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,910 @@
#!/usr/bin/env python
"""
generate_creator_report.py — 达人合作数据筛选与报价分析报告
=====================================================
从 PostgreSQL 读取 cmt_cooperations + cmt_creators + cmt_styles,
按指令要求生成完整的9章分析报告,输出为 Markdown 文件。
用法:
python generate_creator_report.py
python generate_creator_report.py --style 宙斯 # 只分析指定款式
python generate_creator_report.py --output report.md
"""
import argparse
import json
import os
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
PATHS,
resolve_layer_output,
)
import psycopg
from dotenv import load_dotenv
BASE_DIR = PATHS.tools_root
DATA_DIR = PATHS.exports_root
load_dotenv(PATHS.state_root / "config/db.env")
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_db_config # noqa: E402
DB_CONFIG = get_db_config()
LARK = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK):
LARK = "lark-cli.cmd"
PLATFORM_CN = {
"xiaohongshu": "小红书",
"douyin": "抖音",
"bilibili": "B站",
}
def _log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(safe, flush=True)
def infer_level(follower_num: int | None, platform: str) -> str:
if not follower_num:
return "未知"
if follower_num >= 500000:
return "头部"
elif follower_num >= 100000:
return "腰部"
elif follower_num >= 10000:
return "KOC"
else:
return "素人"
def score_creator(stats: dict) -> str:
if not stats.get("has_data"):
return "C"
avg_eng_rate = stats.get("avg_engagement_rate", 0) or 0
avg_cpe = stats.get("avg_cpe", 0) or 0
avg_roi = stats.get("avg_roi", 0) or 0
exposure = stats.get("total_exposure", 0) or 0
score = 0
if avg_eng_rate >= 5: score += 3
elif avg_eng_rate >= 3: score += 2
elif avg_eng_rate >= 1: score += 1
if 0 < avg_cpe <= 2: score += 3
elif 0 < avg_cpe <= 5: score += 2
elif avg_cpe > 0: score += 1
if avg_roi >= 2: score += 3
elif avg_roi >= 1: score += 2
elif avg_roi > 0: score += 1
if exposure >= 500000: score += 2
elif exposure >= 100000: score += 1
if score >= 8: return "S"
elif score >= 5: return "A"
elif score >= 3: return "B"
else: return "C"
def one_line_profile(stats: dict) -> str:
level = stats.get("level", "未知")
platform = stats.get("platform", "")
coop_count = stats.get("coop_count", 0)
total_exp = stats.get("total_exposure", 0) or 0
account_type = stats.get("account_type", "")
if total_exp >= 500000:
exp_desc = "爆款制造者"
elif total_exp >= 100000:
exp_desc = "稳定产出"
else:
exp_desc = "基础曝光"
type_desc = f"{account_type}类" if account_type else ""
return f"{level}{type_desc}达人,{PLATFORM_CN.get(platform, platform)}{coop_count}次合作,{exp_desc}"
def generate_report(style_filter: str | None = None) -> str:
conn = psycopg.connect(**DB_CONFIG)
cur = conn.cursor()
where = "WHERE c.name IS NOT NULL AND c.name != ''"
if style_filter:
where += f" AND s.name = '{style_filter}'"
cur.execute(f"""
SELECT
c.name AS creator_name,
c.platform,
c.follower_count,
c.follower_count_num,
c.platform_id,
c.account_type,
c.creator_level,
c.homepage_url,
s.name AS style_name,
co.feishu_record_id,
co.cooperation_date,
co.cooperation_cost,
co.ad_spend,
co.content_direction,
co.note_title,
co.exposure_count,
co.engagement_count,
co.engagement_count_num,
co.data_performance,
co.cpm,
co.is_paid,
co.publish_time,
n.like_count AS note_like_count,
n.collect_count AS note_collect_count,
n.comment_count AS note_comment_count,
n.favorite_count AS note_favorite_count
FROM cmt_cooperations co
JOIN cmt_creators c ON c.id = co.creator_id
JOIN cmt_styles s ON s.id = co.style_id
LEFT JOIN cmt_notes n ON n.feishu_record_id = co.feishu_record_id
{where}
ORDER BY s.name, c.name
""")
cols = [desc[0] for desc in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
cur.close()
conn.close()
if not rows:
return "# 达人合作数据筛选与报价分析报告\n\n无数据。"
# ─── Build per-creator stats ───
creator_stats = defaultdict(lambda: {
"coop_count": 0,
"platform": "",
"follower_count_num": None,
"follower_count": "",
"account_type": "",
"creator_level": "",
"styles": [],
"costs": [],
"cost_dates": [], # (date, cost, style, content_direction) per coop
"exposures": [],
"engagements": [],
"engagement_nums": [],
"cpms": [],
"data_perfs": [],
"dates": [],
"has_data": False,
"note_engagements": [],
"note_exposures": [],
"content_directions": [],
})
for r in rows:
key = r["creator_name"]
s = creator_stats[key]
s["coop_count"] += 1
s["platform"] = r["platform"] or s["platform"]
if r["follower_count_num"] and not s["follower_count_num"]:
s["follower_count_num"] = r["follower_count_num"]
if r["follower_count"] and not s["follower_count"]:
s["follower_count"] = r["follower_count"]
if r["account_type"] and not s["account_type"]:
s["account_type"] = r["account_type"]
if r["creator_level"] and not s["creator_level"]:
s["creator_level"] = r["creator_level"]
if r["style_name"]:
s["styles"].append(r["style_name"])
if r["content_direction"]:
s["content_directions"].append(r["content_direction"])
# Per-cooperation detail for pricing analysis
cost = float(r["cooperation_cost"]) if r["cooperation_cost"] is not None else None
s["cost_dates"].append({
"date": str(r["cooperation_date"]) if r["cooperation_date"] else "",
"cost": cost,
"style": r["style_name"] or "",
"content_direction": r["content_direction"] or "",
"exposure": int(r["exposure_count"]) if r["exposure_count"] is not None else None,
})
if cost is not None:
s["costs"].append(cost)
if r["exposure_count"] is not None:
s["exposures"].append(int(r["exposure_count"]))
if r["engagement_count_num"] is not None:
s["engagement_nums"].append(int(r["engagement_count_num"]))
if r["cpm"] is not None:
s["cpms"].append(float(r["cpm"]))
if r["data_performance"]:
s["data_perfs"].append(r["data_performance"])
if r["cooperation_date"]:
s["dates"].append(str(r["cooperation_date"]))
if r["cooperation_cost"] is not None and r["exposure_count"] is not None:
s["has_data"] = True
note_eng = (r.get("note_like_count") or 0) + (r.get("note_collect_count") or 0) + (r.get("note_comment_count") or 0) + (r.get("note_favorite_count") or 0)
if note_eng > 0:
s["note_engagements"].append(note_eng)
note_exp = r["exposure_count"] if r["exposure_count"] else None
if note_exp:
s["note_exposures"].append(note_exp)
# Compute derived metrics
for key, s in creator_stats.items():
if not s["creator_level"] and s["follower_count_num"]:
s["creator_level"] = infer_level(s["follower_count_num"], s["platform"])
elif not s["creator_level"]:
s["creator_level"] = "未知"
s["level"] = s["creator_level"]
if s["note_engagements"] and s["note_exposures"] and len(s["note_engagements"]) == len(s["note_exposures"]):
rates = [e / max(x, 1) * 100 for e, x in zip(s["note_engagements"], s["note_exposures"])]
s["avg_engagement_rate"] = round(sum(rates) / len(rates), 2)
elif s["note_engagements"] and s["exposures"]:
total_eng = sum(s["note_engagements"])
total_exp = sum(s["exposures"])
s["avg_engagement_rate"] = round(total_eng / max(total_exp, 1) * 100, 2)
else:
s["avg_engagement_rate"] = None
if s["costs"] and s["note_engagements"]:
total_cost = sum(s["costs"])
total_eng = sum(s["note_engagements"])
s["avg_cpe"] = round(total_cost / max(total_eng, 1), 2)
elif s["costs"] and s["engagement_nums"]:
total_cost = sum(s["costs"])
total_eng = sum(s["engagement_nums"])
s["avg_cpe"] = round(total_cost / max(total_eng, 1), 2)
else:
s["avg_cpe"] = None
s["total_exposure"] = sum(s["exposures"]) if s["exposures"] else 0
s["avg_roi"] = None
s["score"] = score_creator(s)
# ─── Build per-style stats ───
style_stats = defaultdict(lambda: {"coop_count": 0, "creators": set(), "exposures": [], "costs": [], "top_creators": []})
for r in rows:
sn = r["style_name"]
ss = style_stats[sn]
ss["coop_count"] += 1
ss["creators"].add(r["creator_name"])
if r["exposure_count"] is not None:
ss["exposures"].append((r["creator_name"], int(r["exposure_count"])))
if r["cooperation_cost"] is not None:
ss["costs"].append(float(r["cooperation_cost"]))
# ─── Level average cost (for premium/discount analysis) ───
level_costs = defaultdict(list)
for name, s in creator_stats.items():
if s["costs"] and s["creator_level"] != "未知":
level_costs[s["creator_level"]].append(sum(s["costs"]) / len(s["costs"]))
level_avg_cost = {lv: round(sum(v)/len(v), 0) for lv, v in level_costs.items() if v}
# ─── Generate Report ───
lines = []
L = lines.append
L("# 达人合作数据筛选与报价分析报告(自动生成)")
L(f"\n> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}")
if style_filter:
L(f"> 筛选款式:{style_filter}")
L("")
# ═══════════════════════════════════════════════════════════
# 1. 数据质量说明
# ═══════════════════════════════════════════════════════════
L("## 1. 数据质量说明")
L(f"- **总记录数**:{len(rows)}")
L(f"- **涉及达人总数**:{len(creator_stats)}")
L(f"- **涉及款式数**:{len(style_stats)}")
total = len(rows)
has_cost = sum(1 for r in rows if r["cooperation_cost"] is not None)
has_exposure = sum(1 for r in rows if r["exposure_count"] is not None)
has_engagement = sum(1 for r in rows if (r.get("note_like_count") or r.get("note_collect_count") or r.get("note_comment_count") or r.get("note_favorite_count")))
has_follower = sum(1 for r in rows if r["follower_count_num"] is not None)
has_platform = sum(1 for r in rows if r["platform"])
has_level = sum(1 for r in rows if r["creator_level"])
has_content_dir = sum(1 for r in rows if r["content_direction"])
L(f"\n| 字段 | 填充数 | 填充率 |")
L(f"|---|---|---|")
L(f"| 平台 | {has_platform} | {has_platform/total*100:.1f}% |")
L(f"| 粉丝数 | {has_follower} | {has_follower/total*100:.1f}% |")
L(f"| 达人层级 | {has_level} | {has_level/total*100:.1f}% |")
L(f"| 合作花费 | {has_cost} | {has_cost/total*100:.1f}% |")
L(f"| 曝光量 | {has_exposure} | {has_exposure/total*100:.1f}% |")
L(f"| 互动量 | {has_engagement} | {has_engagement/total*100:.1f}% |")
L(f"| 内容方向 | {has_content_dir} | {has_content_dir/total*100:.1f}% |")
L(f"| 进店UV | 0 | 0.0% |")
L(f"| 成交金额 | 0 | 0.0% |")
L(f"| ROI | 0 | 0.0% |")
L(f"| 评论品牌提及率 | 0 | 0.0% |")
L(f"| 差评或舆情风险 | 0 | 0.0% |")
L("")
L('> **说明**:电商转化数据(进店UV、成交金额、ROI)和评论分析数据(品牌提及率、舆情风险)暂未采集,对应分析章节将标注"数据不足"。达人层级根据粉丝数自动推断:>=50万=头部、>=10万=腰部、>=1万=KOC、<1万=素人。')
L("")
# ═══════════════════════════════════════════════════════════
# 2. 整体概览
# ═══════════════════════════════════════════════════════════
L("## 2. 整体概览")
plat_dist = defaultdict(int)
for r in rows:
p = r["platform"] or "未知"
plat_dist[p] += 1
L("\n### 各平台达人分布")
L("| 平台 | 合作记录数 | 达人数 |")
L("|---|---|---|")
plat_creators = defaultdict(set)
for r in rows:
plat_creators[r["platform"] or "未知"].add(r["creator_name"])
for p in sorted(plat_dist, key=plat_dist.get, reverse=True):
cn = PLATFORM_CN.get(p, p)
L(f"| {cn} | {plat_dist[p]} | {len(plat_creators[p])} |")
L("")
level_dist = defaultdict(int)
for s in creator_stats.values():
level_dist[s["creator_level"]] += 1
L("### 各层级分布")
L("| 层级 | 达人数 |")
L("|---|---|")
for lv in ["头部", "腰部", "KOC", "素人", "未知"]:
L(f"| {lv} | {level_dist.get(lv, 0)} |")
L("")
all_costs = [float(r["cooperation_cost"]) for r in rows if r["cooperation_cost"] is not None]
all_exposures = [int(r["exposure_count"]) for r in rows if r["exposure_count"] is not None]
all_engagements_note = []
for r in rows:
ne = (r.get("note_like_count") or 0) + (r.get("note_collect_count") or 0) + (r.get("note_comment_count") or 0) + (r.get("note_favorite_count") or 0)
if ne > 0:
all_engagements_note.append(ne)
total_cost = sum(all_costs) if all_costs else 0
total_exp = sum(all_exposures) if all_exposures else 0
avg_cpe = total_cost / sum(all_engagements_note) if all_costs and all_engagements_note else None
L("### 核心指标汇总")
L("| 指标 | 数值 |")
L("|---|---|")
L(f"| 总合作花费 | ¥{total_cost:,.0f} |")
L(f"| 总曝光量 | {total_exp:,} |")
L(f"| 总互动量 | {sum(all_engagements_note):,} |" if all_engagements_note else "| 总互动量 | 数据不足 |")
L(f"| 平均CPE | ¥{avg_cpe:.2f} |" if avg_cpe else "| 平均CPE | 数据不足 |")
L(f"| 平均ROI | 数据不足 |")
L(f"| 总成交金额 | 数据不足 |")
L("")
# ═══════════════════════════════════════════════════════════
# 3. 达人效能画像(按达人分组汇总)
# ═══════════════════════════════════════════════════════════
L("## 3. 达人效能画像(按达人分组汇总)")
L("")
L("> 按 S/A/B/C 层级分表展示,每个子表不超过飞书 2000 格限制。")
L("")
sorted_creators = sorted(creator_stats.items(),
key=lambda x: ({"S":0,"A":1,"B":2,"C":3}.get(x[1]["score"], 4), -(x[1]["total_exposure"] or 0)))
for tier in ["S", "A", "B", "C"]:
tier_rows = [(n, s) for n, s in sorted_creators if s["score"] == tier]
if not tier_rows:
continue
# Split large tiers to stay under Feishu 2000-cell limit
# 10 cols per row → max 200 rows per table
MAX_ROWS = 200
chunks = [tier_rows[i:i+MAX_ROWS] for i in range(0, len(tier_rows), MAX_ROWS)]
for ci, chunk in enumerate(chunks):
chunk_label = f"({len(tier_rows)}人)" if len(chunks) == 1 else f"({len(tier_rows)}人,第{ci+1}/{len(chunks)}批)"
L(f"### {tier}级达人{chunk_label}")
L("")
L("| 达人昵称 | 平台 | 粉丝数 | 层级 | 合作次数 | 合作单品 | 每次金额 | 平均CPE | 平均互动率 | 综合评分 |")
L("|---|---|---|---|---|---|---|---|---|---|")
for name, s in chunk:
plat_cn = PLATFORM_CN.get(s["platform"], s["platform"] or "未知")
follower_str = f"{s['follower_count_num']:,}" if s["follower_count_num"] else s.get("follower_count", "未知")
unique_styles = list(dict.fromkeys(s["styles"]))
styles_str = ", ".join(unique_styles[:3])
if len(unique_styles) > 3:
styles_str += f"等{len(unique_styles)}款"
cost_entries = [e for e in s["cost_dates"] if e["cost"] is not None]
cost_entries.sort(key=lambda e: e["date"] or "")
costs_str = "→".join(f"¥{e['cost']:.0f}" for e in cost_entries) if cost_entries else "-"
avg_cpe_str = f"¥{s['avg_cpe']:.2f}" if s.get("avg_cpe") else "-"
avg_er_str = f"{s['avg_engagement_rate']:.1f}%" if s.get("avg_engagement_rate") else "-"
L(f"| {name} | {plat_cn} | {follower_str} | {s['creator_level']} | {s['coop_count']} | {styles_str} | {costs_str} | {avg_cpe_str} | {avg_er_str} | {s['score']} |")
L("")
# ═══════════════════════════════════════════════════════════
# 4. 达人报价差异分析
# ═══════════════════════════════════════════════════════════
L("## 4. 达人报价差异分析")
L("")
# 4a. 个体报价波动分析
L("### 个体报价波动分析")
L("")
L("> 针对同一达人不同次合作花费不同的情况,结合合作单品、内容形式、合作日期分析报价变化原因。")
L("")
multi_cost_creators = []
for name, s in creator_stats.items():
cost_entries = [e for e in s["cost_dates"] if e["cost"] is not None]
if len(cost_entries) >= 2:
costs = [e["cost"] for e in cost_entries]
if max(costs) != min(costs):
multi_cost_creators.append((name, s, cost_entries))
if multi_cost_creators:
L("| 达人昵称 | 层级 | 合作次数 | 报价区间 | 报价变化 | 波动归因分析 |")
L("|---|---|---|---|---|---|")
for name, s, entries in multi_cost_creators[:30]:
entries.sort(key=lambda e: e["date"] or "")
costs = [e["cost"] for e in entries]
cost_range = f"¥{min(costs):.0f}~¥{max(costs):.0f}"
cost_trend = "→".join(f"¥{e['cost']:.0f}" for e in entries)
# Analyze reasons
reasons = []
# Check content_direction variation
dirs = [e["content_direction"] for e in entries if e["content_direction"]]
if len(set(dirs)) > 1:
reasons.append(f"内容形式不同({', '.join(set(dirs))})")
# Check style variation
styles = [e["style"] for e in entries if e["style"]]
if len(set(styles)) > 1:
reasons.append(f"合作不同款式({len(set(styles))}款)")
# Check date proximity to promotion periods
dates = [e["date"] for e in entries if e["date"]]
promo_months = ["06", "11", "12"] # 618, 双11, 双12
if any(d[5:7] in promo_months for d in dates):
reasons.append("含大促节点")
if not reasons:
reasons.append("报价自然波动")
L(f"| {name} | {s['creator_level']} | {len(entries)} | {cost_range} | {cost_trend} | {';'.join(reasons)} |")
L("")
else:
L("数据不足,无法分析个体报价波动。")
L("")
# 4b. 同层级溢价/折价分析
L("### 同层级溢价/折价分析")
L("")
L("> 对比同层级、同平台达人的平均合作花费,识别溢价(性价比低)和折价(高性价比洼地)达人。")
L("")
if level_avg_cost:
L("| 达人昵称 | 层级 | 平台 | 个人均价 | 层级均价 | 溢折率 | 判定 |")
L("|---|---|---|---|---|---|---|")
premium_list = []
for name, s in creator_stats.items():
if not s["costs"] or s["creator_level"] == "未知":
continue
personal_avg = sum(s["costs"]) / len(s["costs"])
lv_avg = level_avg_cost.get(s["creator_level"])
if not lv_avg or lv_avg == 0:
continue
ratio = (personal_avg - lv_avg) / lv_avg * 100
if ratio > 30:
tag = "溢价"
elif ratio < -30:
tag = "折价洼地"
else:
tag = "合理"
premium_list.append((name, s, personal_avg, lv_avg, ratio, tag))
premium_list.sort(key=lambda x: -x[4])
for name, s, p_avg, l_avg, ratio, tag in premium_list[:20]:
plat_cn = PLATFORM_CN.get(s["platform"], s["platform"])
L(f"| {name} | {s['creator_level']} | {plat_cn} | ¥{p_avg:.0f} | ¥{l_avg:.0f} | {ratio:+.0f}% | {tag} |")
# Also show top discount
discount_list = [x for x in premium_list if x[5] == "折价洼地"]
for name, s, p_avg, l_avg, ratio, tag in discount_list[:10]:
plat_cn = PLATFORM_CN.get(s["platform"], s["platform"])
L(f"| {name} | {s['creator_level']} | {plat_cn} | ¥{p_avg:.0f} | ¥{l_avg:.0f} | {ratio:+.0f}% | {tag} |")
L("")
else:
L("数据不足,无法进行同层级溢价/折价分析。")
L("")
# ═══════════════════════════════════════════════════════════
# 5. 达人分层与分类
# ═══════════════════════════════════════════════════════════
L("## 5. 达人分层与分类")
tiers = {"S": [], "A": [], "B": [], "C": []}
for name, s in creator_stats.items():
tiers[s["score"]].append(name)
L("\n### 分层矩阵")
for tier in ["S", "A", "B", "C"]:
names = tiers[tier]
if names:
names_str = ', '.join(names[:20]) + ('...' if len(names) > 20 else '')
L(f"**{tier}级**({len(names)}人):{names_str}")
else:
L(f"**{tier}级**(0人):无")
L("\n### 角色分类")
voice_type = sorted(creator_stats.items(), key=lambda x: -(x[1]["total_exposure"] or 0))[:10]
voice_names = [n for n, s in voice_type if s["total_exposure"]]
L(f"**声量型**(高曝光):{', '.join(voice_names)}")
harvest_type = sorted(creator_stats.items(), key=lambda x: x[1].get("avg_cpe", 9999) or 9999)[:10]
L(f"**收割型**(低成本高效):{', '.join(n for n, s in harvest_type if s.get('avg_cpe') and s['avg_cpe'] > 0)}")
volume_type = [(n, s) for n, s in creator_stats.items() if s["costs"]]
volume_type.sort(key=lambda x: sum(x[1]["costs"]) / len(x[1]["costs"]))
L(f"**铺量型**(低成本):{', '.join(n for n, _ in volume_type[:10])}")
# 共创型: high engagement rate + content variety
creative_type = [(n, s) for n, s in creator_stats.items() if s.get("avg_engagement_rate") and s["avg_engagement_rate"] > 0 and len(set(s["content_directions"])) >= 2]
creative_type.sort(key=lambda x: -x[1].get("avg_engagement_rate", 0))
L(f"**共创型**(内容创意强,多方向+高互动):{', '.join(n for n, _ in creative_type[:10])}")
L("")
# ═══════════════════════════════════════════════════════════
# 6. 合作单品交叉分析
# ═══════════════════════════════════════════════════════════
L("## 6. 合作单品交叉分析")
L("")
L("| 款式 | 合作达人数 | 平均曝光 | 平均花费 | Top3达人 | 单品最佳搭档 |")
L("|---|---|---|---|---|---|")
for sn in sorted(style_stats.keys()):
ss = style_stats[sn]
avg_exp = sum(e for _, e in ss["exposures"]) / len(ss["exposures"]) if ss["exposures"] else 0
avg_cost = sum(ss["costs"]) / len(ss["costs"]) if ss["costs"] else 0
top3 = sorted(ss["exposures"], key=lambda x: -x[1])[:3]
top3_str = ", ".join(f"{n}({e:,})" for n, e in top3)
# Best partner: highest exposure creator
best = top3[0][0] if top3 else "-"
L(f"| {sn} | {len(ss['creators'])} | {avg_exp:,.0f} | ¥{avg_cost:,.0f} | {top3_str} | {best} |")
L("")
# ═══════════════════════════════════════════════════════════
# 7. 趋势与风险评估
# ═══════════════════════════════════════════════════════════
L("## 7. 趋势与风险评估")
L("")
multi_coop = {n: s for n, s in creator_stats.items() if s["coop_count"] >= 2 and len(s["exposures"]) >= 2}
rising = []
declining = []
for n, s in multi_coop.items():
if len(s["exposures"]) >= 2:
first_half = s["exposures"][:len(s["exposures"])//2]
second_half = s["exposures"][len(s["exposures"])//2:]
avg_first = sum(first_half) / len(first_half)
avg_second = sum(second_half) / len(second_half)
if avg_second > avg_first * 1.2:
rising.append(n)
elif avg_second < avg_first * 0.8:
declining.append(n)
L(f"### 表现持续上升({len(rising)}人)")
if rising:
L("建议加大投入。")
for n in rising[:20]:
s = creator_stats[n]
L(f"- **{n}**:曝光量从前期上升至后期,{s['coop_count']}次合作数据持续改善")
else:
L("数据不足以判断趋势")
L("")
L(f"### 表现下滑({len(declining)}人)")
if declining:
L("建议减少合作或调整Brief。")
for n in declining[:20]:
s = creator_stats[n]
L(f"- **{n}**:曝光量从前期下降至后期,建议关注内容质量或平台算法变化")
else:
L("数据不足以判断趋势")
L("")
L("### 风险达人")
risk_tags = {"曝光限流": [], "效果一般": [], "挂车不出单": [], "内容卡审": []}
for n, s in creator_stats.items():
for dp in s["data_perfs"]:
for tag in dp.split(","):
tag = tag.strip()
if tag in risk_tags:
risk_tags[tag].append(n)
has_risk = False
for tag, names in risk_tags.items():
if names:
has_risk = True
L(f"- **{tag}**:{', '.join(set(names))}")
if not has_risk:
L("- 暂无风险标记")
L("")
# ═══════════════════════════════════════════════════════════
# 8. 筛选推荐(根据目标场景)
# ═══════════════════════════════════════════════════════════
L("## 8. 筛选推荐(根据目标场景)")
L("")
L("### 场景一:新品上市(重点曝光+种草)")
s_a_creators = [(n, s) for n, s in creator_stats.items() if s["score"] in ("S", "A") and s["total_exposure"]]
s_a_creators.sort(key=lambda x: -x[1]["total_exposure"])
if s_a_creators:
L("| 推荐达人 | 平台 | 粉丝数 | 累计曝光 | 综合评分 | 推荐理由 | 预估CPE |")
L("|---|---|---|---|---|---|---|")
for n, s in s_a_creators[:5]:
plat_cn = PLATFORM_CN.get(s["platform"], s["platform"])
follower_str = f"{s['follower_count_num']:,}" if s["follower_count_num"] else "未知"
reason = f"曝光量{s['total_exposure']:,},{s['coop_count']}次合作经验"
est_cpe = f"¥{s['avg_cpe']:.2f}" if s.get("avg_cpe") else "待估"
L(f"| {n} | {plat_cn} | {follower_str} | {s['total_exposure']:,} | {s['score']} | {reason} | {est_cpe} |")
else:
L("数据不足,无法推荐")
L("")
L("### 场景二:大促收割(重点转化)")
L("> 进店UV、成交金额、ROI 数据暂未采集,以下基于低成本高效(低CPE+高互动率)推荐:")
low_cpe = [(n, s) for n, s in creator_stats.items() if s.get("avg_cpe") and s["avg_cpe"] > 0]
low_cpe.sort(key=lambda x: x[1]["avg_cpe"])
if low_cpe:
L("| 推荐达人 | 平台 | 平均CPE | 平均互动率 | 综合评分 | 推荐理由 |")
L("|---|---|---|---|---|---|")
for n, s in low_cpe[:5]:
plat_cn = PLATFORM_CN.get(s["platform"], s["platform"])
er = f"{s['avg_engagement_rate']:.1f}%" if s.get("avg_engagement_rate") else "-"
L(f"| {n} | {plat_cn} | ¥{s['avg_cpe']:.2f} | {er} | {s['score']} | CPE低,互动率高 |")
else:
L("数据不足,无法推荐")
L("")
L("### 场景三:日常口碑维护")
koc_creators = [(n, s) for n, s in creator_stats.items() if s["creator_level"] in ("KOC", "素人") and s["total_exposure"]]
koc_creators.sort(key=lambda x: -(x[1].get("avg_engagement_rate") or 0))
if koc_creators:
L("| 推荐达人 | 平台 | 粉丝数 | 平均互动率 | 合作次数 | 推荐理由 |")
L("|---|---|---|---|---|---|")
for n, s in koc_creators[:5]:
plat_cn = PLATFORM_CN.get(s["platform"], s["platform"])
follower_str = f"{s['follower_count_num']:,}" if s["follower_count_num"] else "未知"
er = f"{s['avg_engagement_rate']:.1f}%" if s.get("avg_engagement_rate") else "-"
L(f"| {n} | {plat_cn} | {follower_str} | {er} | {s['coop_count']} | 低成本高互动KOC |")
else:
L("数据不足,无法推荐")
L("")
# ═══════════════════════════════════════════════════════════
# 9. 优化建议与下一步行动
# ═══════════════════════════════════════════════════════════
L("## 9. 优化建议与下一步行动")
L("")
for tier, action in [
("S", "建议签长期协议或独家合作,锁定优质达人资源"),
("A", "增加合作频次或尝试新款式,挖掘更大潜力"),
("B", "优化Brief或换内容形式测试,寻找突破口"),
("C", "暂停合作或淘汰,将预算转移至更高层级达人"),
]:
names = tiers[tier]
L(f"- **{tier}级达人**({len(names)}人):{action}")
L("")
# 报价优化建议
L("### 报价优化建议")
L("")
if multi_cost_creators:
L("基于第4章报价差异分析,给出以下议价策略:")
L("")
# Find creators with rising costs
for name, s, entries in multi_cost_creators[:10]:
entries.sort(key=lambda e: e["date"] or "")
costs = [e["cost"] for e in entries]
if costs[-1] > costs[0]:
L(f"- **{name}**:报价呈上升趋势(¥{costs[0]:.0f}→¥{costs[-1]:.0f}),建议锁定当前报价或签长期协议避免继续涨价")
elif costs[-1] < costs[0]:
L(f"- **{name}**:报价有下降空间(¥{costs[0]:.0f}→¥{costs[-1]:.0f}),可尝试进一步议价")
if level_avg_cost:
L("")
L("**层级预算分配建议**:")
for lv in ["头部", "腰部", "KOC", "素人"]:
avg = level_avg_cost.get(lv)
if avg:
L(f"- {lv}达人均价¥{avg:.0f},建议单次合作预算控制在¥{avg*0.8:.0f}~¥{avg*1.2:.0f}")
L("")
L("---")
L(f"*报告由 generate_creator_report.py 自动生成 · {datetime.now().strftime('%Y-%m-%d %H:%M')}*")
return "\n".join(lines), rows, creator_stats, style_stats
def _compute_last_month():
today = datetime.now().date()
first_of_this_month = today.replace(day=1)
month_end = first_of_this_month - timedelta(days=1)
month_start = month_end.replace(day=1)
return month_start, month_end
def create_feishu_doc(report_md: str, month_start: str, month_end: str) -> str | None:
"""创建飞书文档,返回 doc URL;失败返回 None"""
doc_title = f"达人合作数据筛选与报价分析报告({month_start}~{month_end[5:]})"
md_file = PATHS.tmp_root / "creator_report_doc.md"
md_file.parent.mkdir(parents=True, exist_ok=True)
md_file.write_text(report_md, encoding="utf-8")
cmd = [LARK, "docs", "+create",
"--as", "user", "--format", "json",
"--title", doc_title,
"--doc-format", "markdown",
"--content", f"@{md_file.name}"]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout or proc.stderr
start = text.find("{")
if start < 0:
_log(f" [ERR] 创建飞书文档失败: {text[:300]}")
return None
resp = json.loads(text[start:])
if not resp.get("ok"):
_log(f" [ERR] 创建飞书文档失败: {resp.get('error')}")
return None
url = resp.get("data", {}).get("document", {}).get("url", "")
if resp.get("warnings"):
_log(f" [WARN] {resp['warnings']}")
return url
def save_to_db(report_md: str, rows: list, creator_stats: dict,
style_stats: dict, month_start: str, month_end: str,
doc_url: str | None, status: str) -> bool:
"""把月度达人报告结果写入 cmt_creator_report"""
time_label = f"{month_start}~{month_end[5:]}"
total_cooperations = len(rows)
total_creators = len(creator_stats)
total_styles = len(style_stats)
all_costs = [float(r["cooperation_cost"]) for r in rows if r["cooperation_cost"] is not None]
all_exposures = [int(r["exposure_count"]) for r in rows if r["exposure_count"] is not None]
all_engagements = []
for r in rows:
ne = (r.get("note_like_count") or 0) + (r.get("note_collect_count") or 0) + (r.get("note_comment_count") or 0) + (r.get("note_favorite_count") or 0)
if ne > 0:
all_engagements.append(ne)
total_cost = sum(all_costs) if all_costs else None
total_exposure = sum(all_exposures) if all_exposures else None
total_engagement = sum(all_engagements) if all_engagements else None
avg_cpe = (total_cost / total_engagement) if total_cost and total_engagement else None
# Level distribution
level_dist = defaultdict(int)
for s in creator_stats.values():
level_dist[s["creator_level"]] += 1
# Platform distribution
plat_dist = defaultdict(int)
for r in rows:
p = r["platform"] or "未知"
plat_dist[p] += 1
plat_dist_creators = defaultdict(set)
for r in rows:
plat_dist_creators[r["platform"] or "未知"].add(r["creator_name"])
platform_dist = {p: len(plat_dist_creators[p]) for p in plat_dist_creators}
# Score distribution
score_dist = defaultdict(int)
for s in creator_stats.values():
score_dist[s["score"]] += 1
doc_title = f"达人合作数据筛选与报价分析报告({time_label})"
try:
conn = psycopg.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
INSERT INTO cmt_creator_report
(month_start, month_end, time_label,
total_cooperations, total_creators, total_styles,
total_cost, total_exposure, total_engagement, avg_cpe,
level_distribution, platform_distribution, score_distribution,
doc_url, doc_title, report_chars, status)
VALUES
(%s, %s, %s,
%s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s, %s)
ON CONFLICT (month_start) DO UPDATE SET
total_cooperations = EXCLUDED.total_cooperations,
total_creators = EXCLUDED.total_creators,
total_styles = EXCLUDED.total_styles,
total_cost = EXCLUDED.total_cost,
total_exposure = EXCLUDED.total_exposure,
total_engagement = EXCLUDED.total_engagement,
avg_cpe = EXCLUDED.avg_cpe,
level_distribution = EXCLUDED.level_distribution,
platform_distribution = EXCLUDED.platform_distribution,
score_distribution = EXCLUDED.score_distribution,
doc_url = EXCLUDED.doc_url,
doc_title = EXCLUDED.doc_title,
report_chars = EXCLUDED.report_chars,
status = EXCLUDED.status,
updated_at = CURRENT_TIMESTAMP
""", (month_start, month_end, time_label,
total_cooperations, total_creators, total_styles,
total_cost, total_exposure, total_engagement, avg_cpe,
json.dumps(dict(level_dist), ensure_ascii=False),
json.dumps(platform_dist, ensure_ascii=False),
json.dumps(dict(score_dist), ensure_ascii=False),
doc_url, doc_title, len(report_md), status))
conn.commit()
conn.close()
_log(f" 已入库 cmt_creator_report (month={month_start})")
return True
except Exception as exc:
_log(f" [DB ERR] 入库失败: {exc}")
return False
def main():
parser = argparse.ArgumentParser(description="生成达人合作数据筛选与报价分析报告")
parser.add_argument("--style", type=str, help="只分析指定款式 (如 '宙斯')")
parser.add_argument("--output", type=str, default="", help="输出文件路径 (默认 data/reports/ 下自动命名)")
parser.add_argument("--dry-run", action="store_true", help="不写飞书/PG")
args = parser.parse_args()
month_start, month_end = _compute_last_month()
month_start_str = month_start.strftime("%Y-%m-%d")
month_end_str = month_end.strftime("%Y-%m-%d")
_log(f"达人月度分析报告: {month_start_str}~{month_end_str}")
# generate_report returns (report_text, rows, creator_stats, style_stats)
report, rows, creator_stats, style_stats = generate_report(style_filter=args.style)
if args.output:
out_path = resolve_layer_output(
args.output, layer_root=PATHS.exports_root, field="--output"
)
else:
out_dir = DATA_DIR / "reports"
out_dir.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
style_tag = f"_{args.style}" if args.style else "_all"
out_path = out_dir / f"creator_report{style_tag}_{ts}.md"
out_path.write_text(report, encoding="utf-8")
_log(f"报告已生成: {out_path} ({len(report)} 字符)")
if args.dry_run:
_log("[DRY-RUN] 跳过飞书文档创建和入库")
return
# 创建飞书文档
doc_url = create_feishu_doc(report, month_start_str, month_end_str)
if doc_url:
_log(f"飞书文档: {doc_url}")
else:
_log("飞书文档创建失败")
# 入库
status = "ok" if doc_url else "doc_failed"
save_to_db(report, rows, creator_stats, style_stats,
month_start_str, month_end_str, doc_url, status)
if __name__ == "__main__":
main()
@@ -0,0 +1,29 @@
"""Initialize gyxx_super_data tables from schema_gyxx_super_data.sql."""
import re
import sys
from pathlib import Path
# Path setup
_TOOLS_DIR = Path(__file__).resolve().parent
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
def main() -> int:
sql_path = _TOOLS_DIR / "schema_gyxx_super_data.sql"
sql = sql_path.read_text(encoding="utf-8")
# Remove psql meta-commands
sql = re.sub(r"^\\c .*?$", "", sql, flags=re.MULTILINE)
sql = re.sub(r"^\\.*$", "", sql, flags=re.MULTILINE)
with db.get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql)
conn.commit()
print("Schema initialized successfully.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,7 @@
$moduleRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$dataRoot = if ($env:GYXX_DATA_ROOT) { $env:GYXX_DATA_ROOT } else { Join-Path $moduleRoot 'var' }
$profileRoot = Join-Path $dataRoot 'state\content_marketing\browser-profiles'
Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like "*$profileRoot*" } | ForEach-Object { Write-Host "Killing PID $($_.ProcessId)"; Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep -Seconds 3
$procs = Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like "*$profileRoot*" }
if ($procs) { Write-Host 'Remaining project chrome processes:'; $procs | Select-Object ProcessId,CommandLine } else { Write-Host 'No project chrome processes remaining' }
@@ -0,0 +1,4 @@
$moduleRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$dataRoot = if ($env:GYXX_DATA_ROOT) { $env:GYXX_DATA_ROOT } else { Join-Path $moduleRoot 'var' }
$profileRoot = Join-Path $dataRoot 'state\content_marketing\browser-profiles'
Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like "*$profileRoot*" } | Select-Object ProcessId, ParentProcessId, @{Name='CmdStart';Expression={$_.CommandLine.Substring(0, [Math]::Min(200, $_.CommandLine.Length))}}
@@ -0,0 +1,132 @@
-- Migration 001: 扩展 cmt_creators 达人属性 + 新建 cmt_cooperations 合作记录表
-- 日期: 2026-07-10
-- 说明: 从飞书款式合作表同步达人信息和合作数据到数据库
-- ============================================================
-- 1. 扩展 cmt_creators:加达人属性字段
-- ============================================================
-- 加平台字段
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS platform VARCHAR(32) DEFAULT '';
COMMENT ON COLUMN cmt_creators.platform IS '主平台: xiaohongshu/douyin/bilibili';
-- 加平台达人ID
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS platform_id VARCHAR(128) DEFAULT '';
COMMENT ON COLUMN cmt_creators.platform_id IS '平台达人ID (如B站UID、抖音sec_uid等)';
-- 加粉丝数 (保留原始文本,因为飞书数据是"3.7万"这种格式)
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS follower_count VARCHAR(64) DEFAULT '';
COMMENT ON COLUMN cmt_creators.follower_count IS '达人粉丝数(原始文本如3.7万/12.4w)';
-- 加粉丝数(数字解析后)
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS follower_count_num BIGINT DEFAULT NULL;
COMMENT ON COLUMN cmt_creators.follower_count_num IS '达人粉丝数(数字解析后,3.7万=37000)';
-- 加达人主页链接
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS homepage_url TEXT DEFAULT '';
COMMENT ON COLUMN cmt_creators.homepage_url IS '达人主页链接';
-- 加微信号
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS wechat VARCHAR(128) DEFAULT '';
COMMENT ON COLUMN cmt_creators.wechat IS '微信号';
-- 加达人类型 (对应飞书"投放账号类型")
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS account_type VARCHAR(64) DEFAULT '';
COMMENT ON COLUMN cmt_creators.account_type IS '达人类型: 数码/vlog/好物/穿搭/摄影等';
-- 加达人层级
ALTER TABLE cmt_creators ADD COLUMN IF NOT EXISTS creator_level VARCHAR(32) DEFAULT '';
COMMENT ON COLUMN cmt_creators.creator_level IS '达人层级: 头部/腰部/KOC/素人';
-- 修改唯一约束: name 单列唯一 → (name, platform) 联合唯一
-- 因为同一达人可能跨平台存在(如BeiBei贝贝同时在小红书和抖音)
-- 先删除旧约束
ALTER TABLE cmt_creators DROP CONSTRAINT IF EXISTS cmt_creators_name_key;
-- 添加新约束
ALTER TABLE cmt_creators ADD CONSTRAINT cmt_creators_name_platform_key UNIQUE (name, platform);
-- 添加索引
CREATE INDEX IF NOT EXISTS idx_cmt_creators_platform ON cmt_creators (platform);
CREATE INDEX IF NOT EXISTS idx_cmt_creators_platform_id ON cmt_creators (platform_id);
-- ============================================================
-- 2. 新建 cmt_cooperations:合作记录表
-- ============================================================
-- 每条记录 = 一个达人在一个款式下的一次合作
-- 飞书款式合作表的每一行 → 此表一条记录
CREATE TABLE IF NOT EXISTS cmt_cooperations (
id BIGSERIAL PRIMARY KEY,
style_id BIGINT NOT NULL REFERENCES cmt_styles(id) ON DELETE CASCADE,
creator_id BIGINT NOT NULL REFERENCES cmt_creators(id) ON DELETE CASCADE,
feishu_record_id VARCHAR(64) DEFAULT '', -- 飞书行 record_id, 用于回写
-- 合作基本信息
cooperation_date DATE DEFAULT NULL, -- 合作日期 (飞书"制单日期")
cooperation_cost NUMERIC(12,2) DEFAULT NULL, -- 合作花费 (飞书"合作费用")
ad_spend NUMERIC(12,2) DEFAULT NULL, -- 投流金额 (飞书"投流金额")
-- 内容信息
content_direction VARCHAR(255) DEFAULT '', -- 内容方向 (飞书"发布笔记内容方向")
content_format VARCHAR(64) DEFAULT '', -- 内容形式: 单品种草/测评合集/场景植入/开箱Vlog/穿搭变装等
note_title VARCHAR(512) DEFAULT '', -- 发布笔记标题
note_url TEXT DEFAULT '', -- 发布笔记链接
publish_time TIMESTAMPTZ DEFAULT NULL, -- 发布时间
-- 数据表现
exposure_count BIGINT DEFAULT NULL, -- 曝光量 (飞书"曝光量(自动抓取)")
engagement_count VARCHAR(64) DEFAULT '', -- 互动赞藏数 (原始文本如"68.5万")
engagement_count_num BIGINT DEFAULT NULL, -- 互动赞藏数(数字解析后)
data_performance VARCHAR(255) DEFAULT '', -- 数据表现 (逗号分隔: 曝光限流,数据良好)
cpm NUMERIC(10,4) DEFAULT NULL, -- CPM (飞书公式字段)
-- 物流/财务
tracking_number VARCHAR(128) DEFAULT '', -- 快递单号
is_paid BOOLEAN DEFAULT FALSE, -- 是否结款
is_new_direction BOOLEAN DEFAULT FALSE, -- 是否新方向验证
-- 电商效果 (飞书暂无,预留)
shop_uv BIGINT DEFAULT NULL, -- 进店UV
transaction_amount NUMERIC(12,2) DEFAULT NULL, -- 成交金额(元)
roi NUMERIC(10,4) DEFAULT NULL, -- ROI
cpe NUMERIC(10,4) DEFAULT NULL, -- CPE (花费/互动量)
engagement_rate NUMERIC(8,4) DEFAULT NULL, -- 互动率 (互动量/曝光量)
-- 评论分析 (飞书暂无,预留)
brand_mention_rate NUMERIC(8,4) DEFAULT NULL, -- 评论品牌提及率
risk_flag VARCHAR(255) DEFAULT '', -- 差评或舆情风险
--
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_cooperation_feishu_record UNIQUE (feishu_record_id)
);
COMMENT ON TABLE cmt_cooperations IS '合作记录表(每个达人+款式的一次合作)';
COMMENT ON COLUMN cmt_cooperations.cooperation_date IS '合作日期(飞书制单日期)';
COMMENT ON COLUMN cmt_cooperations.cooperation_cost IS '合作花费(元)';
COMMENT ON COLUMN cmt_cooperations.ad_spend IS '投流金额(元)';
COMMENT ON COLUMN cmt_cooperations.content_direction IS '发布笔记内容方向(飞书原始值)';
COMMENT ON COLUMN cmt_cooperations.content_format IS '内容形式: 单品种草/测评合集/场景植入/开箱Vlog等';
COMMENT ON COLUMN cmt_cooperations.exposure_count IS '曝光量(飞书自动抓取)';
COMMENT ON COLUMN cmt_cooperations.engagement_count IS '互动赞藏数(原始文本如68.5万)';
COMMENT ON COLUMN cmt_cooperations.data_performance IS '数据表现(逗号分隔: 曝光限流,数据良好,表现优秀等)';
COMMENT ON COLUMN cmt_cooperations.shop_uv IS '进店UV(预留,飞书暂无)';
COMMENT ON COLUMN cmt_cooperations.transaction_amount IS '成交金额(元,预留)';
COMMENT ON COLUMN cmt_cooperations.roi IS 'ROI=成交金额/花费(预留)';
COMMENT ON COLUMN cmt_cooperations.cpe IS 'CPE=花费/互动量(预留)';
COMMENT ON COLUMN cmt_cooperations.engagement_rate IS '互动率=互动量/曝光量(预留)';
COMMENT ON COLUMN cmt_cooperations.brand_mention_rate IS '评论品牌提及率(预留)';
COMMENT ON COLUMN cmt_cooperations.risk_flag IS '差评或舆情风险(预留)';
CREATE INDEX IF NOT EXISTS idx_cmt_cooperations_style ON cmt_cooperations (style_id);
CREATE INDEX IF NOT EXISTS idx_cmt_cooperations_creator ON cmt_cooperations (creator_id);
CREATE INDEX IF NOT EXISTS idx_cmt_cooperations_date ON cmt_cooperations (cooperation_date);
-- ============================================================
-- 3. 给新表加 updated_at 触发器
-- ============================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_cmt_cooperations_updated_at'
) THEN
CREATE TRIGGER trg_cmt_cooperations_updated_at
BEFORE UPDATE ON cmt_cooperations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END;
$$;
@@ -0,0 +1,43 @@
-- 达人月度分析报告表
-- 每月1号自动跑 generate_creator_report.py,结果入库 + 飞书文档
-- UNIQUE(month_start) — 每月一份全量报告
CREATE TABLE IF NOT EXISTS cmt_creator_report (
id BIGSERIAL PRIMARY KEY,
month_start DATE NOT NULL,
month_end DATE NOT NULL,
time_label VARCHAR(32) NOT NULL,
-- 统计快照
total_cooperations INTEGER DEFAULT 0, -- 合作记录总数
total_creators INTEGER DEFAULT 0, -- 达人总数
total_styles INTEGER DEFAULT 0, -- 款式数
total_cost NUMERIC(14,2), -- 总合作花费
total_exposure BIGINT, -- 总曝光量
total_engagement BIGINT, -- 总互动量
avg_cpe NUMERIC(10,2), -- 平均CPE
-- 层级分布快照 (JSON)
level_distribution JSONB DEFAULT '{}', -- {"头部":2,"腰部":36,"KOC":131,"素人":68,"未知":1}
-- 平台分布快照 (JSON)
platform_distribution JSONB DEFAULT '{}', -- {"douyin":196,"xiaohongshu":164,"bilibili":38}
-- 评分分布快照 (JSON)
score_distribution JSONB DEFAULT '{}', -- {"S":2,"A":62,"B":31,"C":143}
-- 飞书文档
doc_url TEXT,
doc_title VARCHAR(200),
report_chars INTEGER, -- 报告字符数
-- 状态
status VARCHAR(32), -- ok/llm_failed/doc_failed/exception
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_creator_report_month UNIQUE (month_start)
);
CREATE INDEX IF NOT EXISTS idx_cmt_creator_report_month ON cmt_creator_report (month_start);
COMMENT ON TABLE cmt_creator_report IS '达人月度分析报告表(每月1号自动生成,全量达人筛选与报价分析)';
COMMENT ON COLUMN cmt_creator_report.time_label IS '月时间范围标签 如 2026-06-01~06-30';
COMMENT ON COLUMN cmt_creator_report.level_distribution IS '层级分布快照 JSON {"头部":N,"腰部":N,...}';
COMMENT ON COLUMN cmt_creator_report.platform_distribution IS '平台分布快照 JSON {"douyin":N,...}';
COMMENT ON COLUMN cmt_creator_report.score_distribution IS '评分分布快照 JSON {"S":N,"A":N,...}';
COMMENT ON COLUMN cmt_creator_report.doc_url IS '飞书文档链接(达人分析报告)';
COMMENT ON COLUMN cmt_creator_report.status IS 'ok/llm_failed/doc_failed/exception';
@@ -0,0 +1,4 @@
ALTER TABLE cmt_styles
ADD COLUMN IF NOT EXISTS style_category VARCHAR(64) DEFAULT '';
COMMENT ON COLUMN cmt_styles.style_category IS '款式风格,来源于飞书全域事项表的风格单选字段';
@@ -0,0 +1,23 @@
-- Product profile fields for cmt_styles.
ALTER TABLE cmt_styles
ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '',
ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS platform_product_ids JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS product_type VARCHAR(128) DEFAULT '',
ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '',
ADD COLUMN IF NOT EXISTS colors TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS material TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS capacity VARCHAR(128) DEFAULT '',
ADD COLUMN IF NOT EXISTS weight VARCHAR(128) DEFAULT '',
ADD COLUMN IF NOT EXISTS compatible_devices TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS features TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS selling_points TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS product_source_urls TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS product_research_status VARCHAR(32) DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS product_researched_at TIMESTAMPTZ;
COMMENT ON COLUMN cmt_styles.platform_product_ids IS 'Product IDs grouped by Feishu platform';
COMMENT ON COLUMN cmt_styles.product_source_urls IS 'Public pages used to verify product facts';
COMMENT ON COLUMN cmt_styles.product_research_status IS 'verified, partial, or pending';
CREATE INDEX IF NOT EXISTS idx_cmt_styles_brand ON cmt_styles (brand);
@@ -0,0 +1,55 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Monday 13:00 self-operated pipeline: Bilibili + Chanmama -> sync to cmt_notes (with INSERT)
REM Feishu write-back happens inside each scraper; sync step writes PostgreSQL.
REM Register: schtasks /Create /SC WEEKLY /D MON /TN YingxiaoYunying_MondaySelf /TR %PROJECT_DIR%\data\tools\monday_self_run.bat /ST 13:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
if not exist "%PYTHON%" set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\monday_self_%TS%.log
echo === Monday self-operated run started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
echo --- [step 1] self_bilibili_scraper.py --- >> "%LOG_FILE%"
call %PYTHON% self_bilibili_scraper.py >> "%LOG_FILE%" 2>&1
set RC_BILI=%ERRORLEVEL%
echo self_bilibili exit=%RC_BILI% >> "%LOG_FILE%"
echo --- [step 2] chanmama_scraper.py --- >> "%LOG_FILE%"
call %PYTHON% chanmama_scraper.py >> "%LOG_FILE%" 2>&1
set RC_CM=%ERRORLEVEL%
echo chanmama exit=%RC_CM% >> "%LOG_FILE%"
echo --- [step 3] sync_metrics_to_cmt_notes.py (with INSERT) --- >> "%LOG_FILE%"
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
set RC_SYNC=%ERRORLEVEL%
echo sync exit=%RC_SYNC% >> "%LOG_FILE%"
set FINAL_RC=0
if not "%RC_BILI%"=="0" set FINAL_RC=1
if not "%RC_CM%"=="0" set FINAL_RC=1
if not "%RC_SYNC%"=="0" set FINAL_RC=1
echo === Monday self-operated run finished at %date% %time% (bili=%RC_BILI%, cm=%RC_CM%, sync=%RC_SYNC%, final=%FINAL_RC%) === >> "%LOG_FILE%"
endlocal & exit /b %FINAL_RC%
@@ -0,0 +1,41 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Monthly creator report: run on 1st of each month at 08:30
REM 1. Read cmt_cooperations + cmt_creators + cmt_styles from PG
REM 2. Generate 9-section analysis report (Markdown)
REM 3. Create Feishu doc + save to cmt_creator_report
REM Register: schtasks /Create /SC MONTHLY /D 1 /TN YingxiaoYunying_MonthlyCreatorReport /TR %PROJECT_DIR%\data\tools\monthly_creator_report.bat /ST 08:30 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\monthly_creator_report_%TS%.log
echo === Monthly creator report started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
call %PYTHON% -u -X utf8 data/tools/generate_creator_report.py >> "%LOG_FILE%" 2>&1
set RC=%ERRORLEVEL%
echo monthly_creator_report exit=%RC% >> "%LOG_FILE%"
echo === Monthly creator report finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
endlocal
@@ -0,0 +1,41 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Monthly summary: run on 1st of each month at 08:00
REM 1. Compute last month date range
REM 2. Query PG for notes per style
REM 3. For each style: note analysis + LLM cross-compare + Feishu doc + write back + save to DB
REM Register: schtasks /Create /SC MONTHLY /D 1 /TN YingxiaoYunying_MonthlySummary /TR %PROJECT_DIR%\data\tools\monthly_summary.bat /ST 08:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\monthly_summary_%TS%.log
echo === Monthly summary started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
call %PYTHON% -u -X utf8 monthly_summary_all.py --max-workers 4 >> "%LOG_FILE%" 2>&1
set RC=%ERRORLEVEL%
echo monthly_summary exit=%RC% >> "%LOG_FILE%"
echo === Monthly summary finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
endlocal
@@ -0,0 +1,163 @@
"""
拉取 24 张多维表格的全量记录,保存为 JSON。
每张表输出: {base_token, table_id, name, fields, total_records, records}
其中 records 元素为 {record_id, 字段名: 值, ...}
"""
import json
import subprocess
import sys
import time
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
TABLES = [
(1, "UkbabpqRYanmD7sk7ksceVuhnwf", "tbl2BdhIaohZNkOD", "盖亚斜挎"),
(2, "OsYvbUe1iaJYeKs5WvLcU4rVnHc", "tblASZDx24q5Bjwy", "极星pro"),
(3, "Qs4mbzyAaaQ8SasnDpkc9lA5npe", "tblMok3p5E6QydPq", "盖亚微单"),
(4, "KnBRbH9iQaH3PAsAUk5cHLxNnMg", "tblcZ8y5126gTHe7", "盖世m1"),
(5, "Ssp8bXmrwahRBbsko2BcuiLanad", "tblqNsVvKj4EPyzA", "星迹2"),
(6, "BJaWbLEOKaHONMshOidcxvsKngh", "tblR0tCfPNk34ULj", "星云2"),
(7, "N43abi7IlaS8XosnUXec6gH2n7g", "tblasQwwbXwpGbpL", "宙斯"),
(8, "W80vbtPDoaaHTOs5Tipc6CTLn6c", "tblUaRl444G5IhVY", "阿波罗2"),
(9, "CtW5bUcc9a5rqRsZkwucUWDVnHb", "tbl8WgoFrOjkqOD5", "阿波罗x1"),
(10, "JsVLbso8Ba6RjbslOl1cJX8lnyb", "tblwqyhbymn9Aour", "波塞冬edge"),
(11, "IQrbbxAHpa9oiWs631GcPkHznob", "tblmWRjVp0wQiVeG", "逐星"),
(12, "MAEZbKrNaabJbDs5ZY7caq1ynrd", "tblcT5gqVRsPBuOE", "逐星GT"),
(13, "RKfkb5JkIa9FttsgHvNc2nJYnbf", "tblu1yXKkQqwje0d", "星云mini"),
(14, "VrLtbkldsaggOms3F4Bcu3DinCg", "tblO5hzLek6Nk43J", "晨星2"),
(15, "GubZbTrMSahLg5sdonIc8kR8nZe", "tbl1tl5CI0LhKeuk", "极星双肩"),
(16, "VwpibmlakaqHuLsGChccMb3BnNd", "tbl7fPd2dMFKQdih", "凌云air"),
(17, "CQcibpRjgaUCg7sUTMwcnrhunMh", "tblFHe5uONCWxbEh", "觅光"),
(18, "XtHjbqpwcab3J4sWP6Fcfgh5nYt", "tblqRgK6MI5C1H4K", "凌云3"),
(19, "GNOkbKNpna8pDJsLMfzcuZt3ncf", "tbljwMiRxb4SlpGr", "瑞白"),
(20, "U2JGbbWcIal6WvsxURRcTMwEnhf", "tblVEdURLsfRZykJ", "黑曜石吐司包"),
(21, "U9S8bLYsNa8Ul8sPffAcrJ02nHI", "tblhpEmlXGyqCGuh", "黑曜石托特包"),
(22, "UPk3bR9MOaTkgrsG1qLc7IUvnQe", "tbllXFWRI59dLCM2", "曜影"),
(23, "PwrGbXcpRaxJnOsd9cacFou8nAf", "tbludhnt5lkmdvSF", "黑曜石电脑包"),
(24, "CNgPbjuXiaPoxPsShROcYbRDnbb", "tblEymEPk7lVaQN1", "拓界2"),
]
OUT_DIR = PATHS.raw_root / "feishu_tables"
OUT_DIR.mkdir(parents=True, exist_ok=True)
import os
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
LARK_CLI = "lark-cli.cmd"
def call_lark(token: str, table_id: str, offset: int, limit: int) -> dict:
"""调用 lark-cli base +record-list,返回 JSON"""
# LARK_CLI_NO_PROXY=1 避免代理警告污染 stderr
cmd = [
LARK_CLI, "base", "+record-list",
"--base-token", token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--limit", str(limit),
"--offset", str(offset),
]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, encoding="utf-8", shell=False)
if proc.returncode != 0:
raise RuntimeError(f"lark-cli 调用失败(return={proc.returncode}): {proc.stderr}\nstdout: {proc.stdout[:500]}")
# 找到第一个 { 开始
text = proc.stdout
start = text.find("{")
if start < 0:
raise RuntimeError(f"lark-cli 返回无 JSON: {text[:500]}")
# 用 strict=False 容忍控制字符(GBK 字符串残留)
return json.loads(text[start:], strict=False)
def pull_table(idx: int, token: str, table_id: str, name: str) -> dict:
if not table_id:
return {"index": idx, "name": name, "token": token, "table_id": table_id,
"status": "SKIP_NO_TABLE", "total_records": 0}
page_size = 200
offset = 0
fields = None
field_ids = None
records = []
while True:
try:
resp = call_lark(token, table_id, offset, page_size)
except Exception as exc:
return {"index": idx, "name": name, "token": token, "table_id": table_id,
"status": "ERROR", "error": str(exc), "partial_records": len(records)}
if not resp.get("ok"):
return {"index": idx, "name": name, "token": token, "table_id": table_id,
"status": "API_ERROR", "error": resp.get("error", resp),
"partial_records": len(records)}
data = resp["data"]
if fields is None:
fields = data["fields"]
field_ids = data["field_id_list"]
rid_list = data["record_id_list"]
row_data = data["data"]
for i, row in enumerate(row_data):
rec = {"record_id": rid_list[i]}
for j, fid in enumerate(field_ids):
rec[fid] = row[j] if j < len(row) else None
records.append(rec)
offset += len(row_data)
has_more = data.get("has_more", False)
print(f" [{idx:02d}] {name} pulled={len(row_data)} total={len(records)} has_more={has_more}", flush=True)
if not has_more or len(row_data) == 0:
break
time.sleep(0.3)
return {
"index": idx,
"base_token": token,
"table_id": table_id,
"name": name,
"fields": fields,
"field_id_to_name": dict(zip(field_ids, fields)),
"total_records": len(records),
"records": records,
"status": "OK",
}
def main():
summary = []
for idx, token, table_id, name in TABLES:
print(f"[{idx:02d}/24] {name} (table={table_id}) START", flush=True)
result = pull_table(idx, token, table_id, name)
summary.append({
"index": idx, "name": name, "token": token, "table_id": table_id,
"status": result["status"],
"total_records": result.get("total_records") or result.get("partial_records", 0),
})
if result["status"] == "OK":
safe = str(idx) + "-" + name.translate(str.maketrans({
"/": "_", "\\": "_", ":": "_", "*": "_", "?": "_",
"\"": "_", "<": "_", ">": "_", "|": "_", " ": "_"
}))
out_path = OUT_DIR / f"{safe}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f" -> saved {out_path} ({result['total_records']} records)", flush=True)
else:
print(f" -> FAILED: {result.get('error', result.get('status'))}", flush=True)
time.sleep(0.5)
with open(OUT_DIR / "_summary.json", "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print("\n========== 汇总 ==========", flush=True)
for s in summary:
print(f" {s['index']:02d} {s['name']:>16s} {s['status']:>12s} {s['total_records']:>6d}")
if __name__ == "__main__":
main()
@@ -0,0 +1,132 @@
"""
rebuild_mapping.py — 动态重建 款式_多维表格_对照.json
工作原理:调用 feishu_mapping.refresh_mapping() 动态读取「合作达人」地址表,
自动发现所有款式(当前 34 款,新增款式自动往后追加 index)。
用法:
python rebuild_mapping.py # 动态重建 + 写盘 + 打印 diff
python rebuild_mapping.py --diff # 只构建内存 mapping,对比当前 JSON,打印 diff,不写盘
python rebuild_mapping.py --in-place # 等价于默认(直接覆盖 JSON)
python rebuild_mapping.py --print # 打印最新 mapping 到 stdout(不写盘)
依赖: feishu_mapping.py (内部使用), lark-cli (动态读取)
"""
import argparse
import json
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
PROJECT_DIR = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping # noqa: E402
MAPPING_PATH = feishu_mapping.MAPPING_PATH
def diff_entry(new: dict, old: dict | None) -> list[str]:
"""对比单张表的新旧条目"""
if not old:
return [f" + 新增表 [{new['index']:02d}] {new['name']}"]
diffs = []
new_fm = new.get("field_map", {})
old_fm = old.get("field_map", {})
for k, v in new_fm.items():
if k not in old_fm:
diffs.append(f" + [{new['index']:02d}] {new['name']}: 新增字段 {k} = {v['field_name']}({v['field_id']})")
elif old_fm[k].get("field_id") != v["field_id"]:
diffs.append(f" ~ [{new['index']:02d}] {new['name']}: {k} field_id 变更 {old_fm[k]['field_id']} -> {v['field_id']}")
for k in old_fm:
if k not in new_fm:
diffs.append(f" - [{new['index']:02d}] {new['name']}: 字段 {k}({old_fm[k]['field_name']}) 不存在了!")
if new.get("total_records", 0) != old.get("total_records", 0):
diffs.append(f" · [{new['index']:02d}] {new['name']}: total_records {old.get('total_records', 0)} -> {new.get('total_records', 0)}")
return diffs
def diff_mapping(new: dict, old: dict | None) -> list[str]:
"""对比新旧 mapping"""
if old is None:
return [f" + 全新 mapping,共 {len(new.get('tables', []))} 款"]
new_by_name = {t["name"]: t for t in new.get("tables", [])}
old_by_name = {t["name"]: t for t in old.get("tables", [])}
diffs = []
for name in new_by_name:
diffs.extend(diff_entry(new_by_name[name], old_by_name.get(name)))
for name in old_by_name:
if name not in new_by_name:
diffs.append(f" - 表 [{old_by_name[name]['index']:02d}] {name}: 不再出现在地址表!")
return diffs
def print_core_field_check(tables: list[dict]) -> None:
"""打印每张表的核心字段映射情况"""
print(f"\n核心字段映射检查:")
for t in tables:
missing = [k for k in ["creator_name", "note_title", "read_count_7d"] if k not in t["field_map"]]
if missing:
print(f" [WARN] [{t['index']:02d}] {t['name']}: 缺 {missing}")
else:
cn = t['field_map']['creator_name']['field_name']
nt = t['field_map']['note_title']['field_name']
r7 = t['field_map']['read_count_7d']['field_name']
print(f" [OK] [{t['index']:02d}] {t['name']}: creator='{cn}' title='{nt}' read7='{r7}'")
def main():
ap = argparse.ArgumentParser(description="动态重建 款式_多维表格_对照.json")
ap.add_argument("--diff", action="store_true", help="只构建内存 mapping,对比当前 JSON,打印 diff,不写盘")
ap.add_argument("--in-place", action="store_true", help="直接覆盖现有 JSON(等价于默认行为)")
ap.add_argument("--print", action="store_true", help="只打印最新 mapping 到 stdout(不写盘)")
args = ap.parse_args()
# 读旧 JSON
old_mapping = None
if MAPPING_PATH.exists():
try:
old_mapping = json.loads(MAPPING_PATH.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[WARN] 旧 JSON 解析失败: {exc}")
# 决定是否写盘
write = not (args.diff or args.print)
# 动态重建
print(f"动态读取「合作达人」地址表 + 各款式字段定义...\n")
new_mapping = feishu_mapping.refresh_mapping(force=True, write=write)
if new_mapping is None:
print("[FATAL] refresh_mapping 失败")
sys.exit(1)
tables = new_mapping.get("tables", [])
print(f"\n========== 重建结果 ==========")
print(f"表数: {len(tables)}")
if old_mapping:
old_n = len(old_mapping.get("tables", []))
if old_n != len(tables):
print(f" (旧 {old_n} 款 -> 新 {len(tables)} 款)")
# diff
diffs = diff_mapping(new_mapping, old_mapping)
if diffs:
print(f"\n变更 ({len(diffs)} 项):")
for d in diffs:
print(d)
else:
print(f"\n无字段变化")
print_core_field_check(tables)
# 落盘 / 打印
if args.print:
print(f"\n{json.dumps(new_mapping, ensure_ascii=False, indent=2)}")
elif args.diff:
print(f"\n(--diff 模式,未写文件)")
elif write:
print(f"\nsaved (in-place): {MAPPING_PATH}")
else:
print(f"\n(saved): {MAPPING_PATH}")
if __name__ == "__main__":
main()
@@ -0,0 +1,87 @@
"""每周一重登 B 站。
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
任务计划: 每周一 09:00
跑法:
python data/tools/relogin_bilibili.py # 实际跑(会弹二维码)
python data/tools/relogin_bilibili.py --dry-run # 只看会做什么
"""
import argparse
import datetime as dt
import shutil
import subprocess
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from gyxx_flow.modules.content_marketing.runtime.data.tools.relogin_transaction import begin, commit, rollback
PROJECT_DIR = PATHS.module_root
COOKIE_FILE = PATHS.state_root / "cookies/bilibili_cookies.json"
STATE_FILE = PATHS.state_root / "cookies/bilibili_storage_state.json"
PROFILE_DIR = PATHS.state_root / "browser-profiles/bilibili"
SCRAPER = PROJECT_DIR / "bilibili_comment_scraper.py"
def reset_state():
"""删 cookie JSON + storage_state + 清浏览器 profile。"""
state = begin((COOKIE_FILE, STATE_FILE), PROFILE_DIR)
print(" [OK] 旧 Cookie/Profile 已暂存,失败时将自动恢复")
return state
def main() -> int:
ap = argparse.ArgumentParser(description="每周一重登 B 站")
ap.add_argument("--login-timeout", type=int, default=300, help="扫码超时(秒)")
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不删不跑")
args = ap.parse_args()
print("== Relogin: B 站 ==")
print(f" 今天: {dt.date.today()} ({dt.date.today().strftime('%A')})")
print(f" cookie: {COOKIE_FILE}")
print(f" profile: {PROFILE_DIR}")
print()
if args.dry_run:
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
return 0
old_state = reset_state()
cmd = [
sys.executable,
str(SCRAPER),
"login", # dummy url (login-only 模式不需要真实 url)
"--login-only",
"--login-timeout",
str(args.login_timeout),
]
print(f"\n [RUN] {' '.join(cmd)}")
try:
rc = subprocess.call(cmd, cwd=str(PROJECT_DIR))
except BaseException:
rollback(old_state)
raise
print(f"\n exit={rc}")
login_ok = False
if rc == 0:
# 验证 SESSDATA 是否真写入了
try:
import json
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
sessdata = any(c.get("name") == "SESSDATA" and c.get("value") for c in cookies)
login_ok = bool(sessdata)
print(f" SESSDATA 写入: {'OK' if sessdata else 'FAIL — 没有看到 SESSDATA,登录可能没成功'}")
except Exception as exc:
print(f" [WARN] 验证 cookie 时出错: {exc}")
if rc != 0 or not login_ok:
rollback(old_state)
print(" [ROLLBACK] 未完成扫码,已恢复旧登录状态")
return rc or 1
commit(old_state)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,93 @@
"""Force a fresh Douyin login while preserving the previous usable session."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import subprocess
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
try:
from .relogin_transaction import begin, commit, rollback
except ImportError: # Direct script execution from data/tools.
from gyxx_flow.modules.content_marketing.runtime.data.tools.relogin_transaction import begin, commit, rollback
PROJECT_DIR = PATHS.module_root
COOKIE_FILE = PATHS.state_root / "cookies/douyin_cookies.json"
STATE_FILE = PATHS.state_root / "cookies/douyin_storage_state.json"
PROFILE_DIR = PATHS.state_root / "browser-profiles/douyin"
SCRAPER = PROJECT_DIR / "douyin_comment_scraper.py"
LOGIN_COOKIE = "sessionid"
def reset_state():
state = begin((COOKIE_FILE, STATE_FILE), PROFILE_DIR)
print(" [OK] Old Cookie/Profile staged; failures will roll back automatically")
return state
def is_cookie_valid() -> bool:
if not COOKIE_FILE.exists():
return False
try:
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return any(
isinstance(cookie, dict)
and cookie.get("name") == LOGIN_COOKIE
and cookie.get("value")
for cookie in cookies
)
except Exception:
return False
def main() -> int:
parser = argparse.ArgumentParser(description="Relogin Douyin")
parser.add_argument("--login-timeout", type=int, default=300)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
print("== Relogin: Douyin ==")
print(f" date: {dt.date.today()}")
print(f" cookie: {COOKIE_FILE}")
print(f" profile: {PROFILE_DIR}")
print(f" current cookie: {'present' if is_cookie_valid() else 'missing/invalid'}")
if args.dry_run:
print("[DRY-RUN] Would stage Cookie/Profile and open the QR login window")
return 0
old_state = reset_state()
command = [
sys.executable,
str(SCRAPER),
"login",
"--login-only",
"--login-timeout",
str(args.login_timeout),
]
print(f"\n [RUN] {' '.join(command)}")
try:
return_code = subprocess.call(command, cwd=str(PROJECT_DIR))
except BaseException:
rollback(old_state)
raise
login_ok = return_code == 0 and is_cookie_valid()
print(f"\n exit={return_code}")
print(f" {LOGIN_COOKIE} written: {'OK' if login_ok else 'FAIL'}")
if not login_ok:
rollback(old_state)
print(" [ROLLBACK] Login incomplete; restored previous Cookie/Profile")
return return_code or 1
commit(old_state)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,106 @@
"""每周一重登小红书蒲公英。
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
任务计划: 每周一 09:00
跑法:
python data/tools/relogin_pgy.py # 实际跑(会弹二维码)
python data/tools/relogin_pgy.py --dry-run # 只看会做什么
"""
import argparse
import datetime as dt
import shutil
import subprocess
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
PROJECT_DIR = PATHS.module_root
COOKIE_FILE = PATHS.state_root / "cookies/pgy_cookies.json"
PROFILE_DIR = PATHS.state_root / "browser-profiles/pgy"
SCRAPER = PROJECT_DIR / "pgy_xhs_scraper_v2.py"
def reset_state() -> tuple[Path | None, Path | None]:
"""Temporarily move the old login state aside so it can be restored."""
ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
cookie_backup = None
profile_backup = None
if COOKIE_FILE.exists():
backup = COOKIE_FILE.with_suffix(f".json.bak.{ts}")
COOKIE_FILE.rename(backup)
cookie_backup = backup
print(f" [OK] 备份 cookie -> {backup.name}")
else:
print(f" [INFO] {COOKIE_FILE.name} 不存在,跳过")
if PROFILE_DIR.exists():
profile_backup = PROFILE_DIR.with_name(f"{PROFILE_DIR.name}.bak.{ts}")
PROFILE_DIR.rename(profile_backup)
print(f" [OK] 暂存 {PROFILE_DIR.name}/ -> {profile_backup.name}/")
else:
print(f" [INFO] {PROFILE_DIR.name}/ 不存在,跳过")
return cookie_backup, profile_backup
def restore_previous_state(cookie_backup: Path | None, profile_backup: Path | None) -> None:
"""Discard an incomplete login and restore the last known-good state."""
if COOKIE_FILE.exists():
COOKIE_FILE.unlink()
if cookie_backup and cookie_backup.exists():
cookie_backup.rename(COOKIE_FILE)
print(f" [ROLLBACK] 已恢复旧 cookie: {COOKIE_FILE.name}")
if PROFILE_DIR.exists():
shutil.rmtree(PROFILE_DIR)
if profile_backup and profile_backup.exists():
profile_backup.rename(PROFILE_DIR)
print(f" [ROLLBACK] 已恢复旧浏览器状态: {PROFILE_DIR.name}/")
def finish_successful_relogin(profile_backup: Path | None) -> None:
"""Keep the new state and remove the bulky old profile backup."""
if profile_backup and profile_backup.exists():
shutil.rmtree(profile_backup)
def main() -> int:
ap = argparse.ArgumentParser(description="每周一重登小红书蒲公英")
ap.add_argument("--login-timeout", type=int, default=300, help="扫码超时(秒)")
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不删不跑")
args = ap.parse_args()
print("== Relogin: 小红书蒲公英 ==")
print(f" 今天: {dt.date.today()} ({dt.date.today().strftime('%A')})")
print(f" cookie: {COOKIE_FILE}")
print(f" profile: {PROFILE_DIR}")
print()
if args.dry_run:
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
return 0
cookie_backup, profile_backup = reset_state()
cmd = [
sys.executable,
str(SCRAPER),
"--login-only",
"--login-timeout",
str(args.login_timeout),
]
print(f"\n [RUN] {' '.join(cmd)}")
try:
rc = subprocess.call(cmd, cwd=str(PROJECT_DIR))
except BaseException:
restore_previous_state(cookie_backup, profile_backup)
raise
print(f"\n exit={rc}")
if rc != 0 or not COOKIE_FILE.exists():
print(" [WARN] 新登录未完成,恢复旧登录状态")
restore_previous_state(cookie_backup, profile_backup)
return rc or 1
finish_successful_relogin(profile_backup)
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,47 @@
"""Transactional backup/rollback helpers for interactive relogin scripts."""
from __future__ import annotations
import datetime as dt
import shutil
from dataclasses import dataclass
from pathlib import Path
@dataclass
class LoginStateBackup:
files: dict[Path, Path]
profile: Path
profile_backup: Path | None
def begin(files: tuple[Path, ...], profile: Path) -> LoginStateBackup:
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
backups: dict[Path, Path] = {}
for source in files:
if source.exists():
backup = source.with_name(f"{source.name}.bak.{stamp}")
source.rename(backup)
backups[source] = backup
profile_backup = None
if profile.exists():
profile_backup = profile.with_name(f"{profile.name}.bak.{stamp}")
profile.rename(profile_backup)
return LoginStateBackup(backups, profile, profile_backup)
def rollback(state: LoginStateBackup) -> None:
for source, backup in state.files.items():
if source.exists():
source.unlink()
if backup.exists():
backup.rename(source)
if state.profile.exists():
shutil.rmtree(state.profile)
if state.profile_backup and state.profile_backup.exists():
state.profile_backup.rename(state.profile)
def commit(state: LoginStateBackup) -> None:
if state.profile_backup and state.profile_backup.exists():
shutil.rmtree(state.profile_backup)
@@ -0,0 +1,90 @@
"""每周重登小红书。
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
跑法:
python data/tools/relogin_xiaohongshu.py # 实际跑(会弹二维码)
python data/tools/relogin_xiaohongshu.py --dry-run # 只看会做什么
"""
import argparse
import datetime as dt
import json
import shutil
import subprocess
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from gyxx_flow.modules.content_marketing.runtime.data.tools.relogin_transaction import begin, commit, rollback
PROJECT_DIR = PATHS.module_root
COOKIE_FILE = PATHS.state_root / "cookies/xiaohongshu_cookies.json"
STATE_FILE = PATHS.state_root / "cookies/xiaohongshu_storage_state.json"
PROFILE_DIR = PATHS.state_root / "browser-profiles/xiaohongshu"
SCRAPER = PROJECT_DIR / "xiaohongshu_comment_scraper.py"
LOGIN_COOKIE = "web_session"
def reset_state():
state = begin((COOKIE_FILE, STATE_FILE), PROFILE_DIR)
print(" [OK] 旧 Cookie/Profile 已暂存,失败时将自动恢复")
return state
def is_cookie_valid() -> bool:
if not COOKIE_FILE.exists():
return False
try:
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return any(c.get("name") == LOGIN_COOKIE and c.get("value") for c in cookies)
except Exception:
return False
def main() -> int:
ap = argparse.ArgumentParser(description="重登小红书")
ap.add_argument("--login-timeout", type=int, default=300, help="扫码超时(秒)")
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不删不跑")
args = ap.parse_args()
print("== Relogin: 小红书 ==")
print(f" 今天: {dt.date.today()}")
print(f" cookie: {COOKIE_FILE}")
print(f" profile: {PROFILE_DIR}")
print(f" 当前 cookie 状态: {'有效' if is_cookie_valid() else '无效/不存在'}")
print()
if args.dry_run:
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
return 0
old_state = reset_state()
cmd = [
sys.executable,
str(SCRAPER),
"login",
"--login-only",
"--login-timeout",
str(args.login_timeout),
]
print(f"\n [RUN] {' '.join(cmd)}")
try:
rc = subprocess.call(cmd, cwd=str(PROJECT_DIR))
except BaseException:
rollback(old_state)
raise
print(f"\n exit={rc}")
login_ok = rc == 0 and is_cookie_valid()
if rc == 0:
print(f" {LOGIN_COOKIE} 写入: {'OK' if login_ok else 'FAIL — 没有看到 web_session,登录可能没成功'}")
if not login_ok:
rollback(old_state)
print(" [ROLLBACK] 未完成扫码,已恢复旧登录状态")
return rc or 1
commit(old_state)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,299 @@
"""每 3 个周一重登一次抖音/星图。
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
星图登录完成后,自动把登录态 cookie 同步到抖音评论 scraper,避免再扫一次码。
任务计划: 每周一 09:00 (脚本内部判断是否到了该重登的周一,不到就退出)
3 周循环基准: 任意一个周一,后续 (今天 - BASE_DATE) // 7 % 3 == 0 才是星图周。
改 BASE_DATE 就改了重登周期。
跑法:
python data/tools/relogin_xingtu.py # 实际跑
python data/tools/relogin_xingtu.py --dry-run # 看今天是不是星图周
python data/tools/relogin_xingtu.py --force # 忽略 3 周判断,强制重登
"""
import argparse
import datetime as dt
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
import psutil
PROJECT_DIR = PATHS.module_root
COOKIE_FILE = PATHS.state_root / "cookies/xingtu_cookies.json"
PROFILE_DIR = PATHS.state_root / "browser-profiles/xingtu"
SCRAPER = PROJECT_DIR / "xingtu_scraper_v2.py"
# 抖音评论 scraper 的 cookie 文件 (登录态共享目标)
DY_COOKIE_FILE = PATHS.state_root / "cookies/douyin_cookies.json"
DY_STATE_FILE = PATHS.state_root / "cookies/douyin_storage_state.json"
DY_PROFILE_DIR = PATHS.state_root / "browser-profiles/douyin"
# 需要同步的抖音登录 cookie 名
DY_LOGIN_COOKIES = {"sessionid", "sessionid_ss", "sid_guard", "sid_tt", "passport_csrf_token"}
# 3 周循环起点(必须是周一)。星图 cookie ~22 天,每 3 周(21 天)重登正好。
BASE_DATE = dt.date(2026, 6, 22)
LOGIN_COOKIE_NAMES = {"sessionid", "sessionid_ss", "sid_tt", "sid_guard"}
def transaction_file() -> Path:
return COOKIE_FILE.with_name(".xingtu_relogin_transaction.json")
def _write_transaction(cookie_backup: Path | None, profile_backup: Path | None) -> None:
marker = transaction_file()
marker.parent.mkdir(parents=True, exist_ok=True)
temp = marker.with_name(f"{marker.name}.tmp")
temp.write_text(json.dumps({
"cookie_backup": str(cookie_backup) if cookie_backup else "",
"profile_backup": str(profile_backup) if profile_backup else "",
}, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temp, marker)
def _clear_transaction() -> None:
marker = transaction_file()
if marker.exists():
marker.unlink()
def _remove_path(path: Path) -> None:
if path.is_dir():
shutil.rmtree(path)
elif path.exists():
path.unlink()
def recover_interrupted_state() -> bool:
"""Restore a relogin transaction that was killed after moving old state."""
marker = transaction_file()
if not marker.exists():
return False
try:
state = json.loads(marker.read_text(encoding="utf-8"))
except Exception:
return False
cookie_backup = Path(state["cookie_backup"]) if state.get("cookie_backup") else None
profile_backup = Path(state["profile_backup"]) if state.get("profile_backup") else None
terminate_profile_processes()
if cookie_backup and cookie_backup.exists():
_remove_path(COOKIE_FILE)
cookie_backup.rename(COOKIE_FILE)
if profile_backup and profile_backup.exists():
_remove_path(PROFILE_DIR)
profile_backup.rename(PROFILE_DIR)
_clear_transaction()
print(" [RECOVER] 已恢复上次被中断的星图登录状态")
return True
def has_valid_login_cookie_file(path: Path | None = None) -> bool:
path = path or COOKIE_FILE
try:
cookies = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return False
return any(
isinstance(cookie, dict)
and cookie.get("name") in LOGIN_COOKIE_NAMES
and cookie.get("value")
for cookie in cookies
)
def terminate_profile_processes() -> None:
"""Stop only browser processes using Xingtu's dedicated profile."""
profile_text = str(PROFILE_DIR.resolve()).casefold()
matches = []
for process in psutil.process_iter(["pid", "cmdline"]):
try:
command = " ".join(process.info.get("cmdline") or []).casefold()
if process.pid != os.getpid() and profile_text in command:
matches.append(process)
except (psutil.Error, OSError):
continue
for process in matches:
try:
process.terminate()
except (psutil.Error, OSError):
pass
_gone, alive = psutil.wait_procs(matches, timeout=5)
for process in alive:
try:
process.kill()
except (psutil.Error, OSError):
pass
def is_xingtu_week(today: dt.date, base: dt.date) -> bool:
days = (today - base).days
if days < 0:
return False
return (days // 7) % 3 == 0
def reset_state() -> tuple[Path | None, Path | None]:
"""Temporarily move the old login state aside so it can be restored."""
ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
cookie_backup = None
profile_backup = None
if COOKIE_FILE.exists():
cookie_backup = COOKIE_FILE.with_suffix(f".json.bak.{ts}")
if PROFILE_DIR.exists():
profile_backup = PROFILE_DIR.with_name(f"{PROFILE_DIR.name}.bak.{ts}")
_write_transaction(cookie_backup, profile_backup)
if cookie_backup:
COOKIE_FILE.rename(cookie_backup)
print(f" [OK] 备份 cookie -> {cookie_backup.name}")
else:
print(f" [INFO] {COOKIE_FILE.name} 不存在,跳过")
if profile_backup:
PROFILE_DIR.rename(profile_backup)
print(f" [OK] 暂存 {PROFILE_DIR.name}/ -> {profile_backup.name}/")
else:
print(f" [INFO] {PROFILE_DIR.name}/ 不存在,跳过")
return cookie_backup, profile_backup
def restore_previous_state(cookie_backup: Path | None, profile_backup: Path | None) -> None:
"""Discard an incomplete login and restore the last known-good state."""
terminate_profile_processes()
if COOKIE_FILE.exists():
COOKIE_FILE.unlink()
if cookie_backup and cookie_backup.exists():
cookie_backup.rename(COOKIE_FILE)
print(f" [ROLLBACK] 已恢复旧 cookie: {COOKIE_FILE.name}")
if PROFILE_DIR.exists():
shutil.rmtree(PROFILE_DIR)
if profile_backup and profile_backup.exists():
profile_backup.rename(PROFILE_DIR)
print(f" [ROLLBACK] 已恢复旧浏览器状态: {PROFILE_DIR.name}/")
_clear_transaction()
def finish_successful_relogin(profile_backup: Path | None) -> None:
"""Keep the new state and remove the bulky old profile backup."""
if profile_backup and profile_backup.exists():
shutil.rmtree(profile_backup)
_clear_transaction()
def sync_to_douyin_scraper() -> None:
"""把星图的登录态 cookie 同步到抖音评论 scraper,避免再扫一次码。"""
if not COOKIE_FILE.exists():
print(" [SKIP] 星图 cookie 不存在,无法同步")
return
try:
xingtu_cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
except Exception as exc:
print(f" [SKIP] 读取星图 cookie 失败: {exc}")
return
# 提取登录态 cookie
login_cookies = [c for c in xingtu_cookies if isinstance(c, dict) and c.get("name") in DY_LOGIN_COOKIES]
if not login_cookies:
print(" [SKIP] 星图 cookie 中没有找到登录态,跳过同步")
return
# 清除抖音评论 scraper 的旧 profile (强制刷新)
if DY_PROFILE_DIR.exists():
shutil.rmtree(DY_PROFILE_DIR)
print(f" [OK] 清空 {DY_PROFILE_DIR.name}/")
# 读取或创建抖音评论 cookie 文件
dy_cookies = []
if DY_COOKIE_FILE.exists():
try:
dy_cookies = json.loads(DY_COOKIE_FILE.read_text(encoding="utf-8"))
except Exception:
dy_cookies = []
# 合并: 同名 cookie 覆盖,新 cookie 追加
dy_by_name = {c.get("name"): c for c in dy_cookies if isinstance(c, dict)}
for c in login_cookies:
# 修正 domain: 星图的 cookie domain 可能是 .douyin.com 子域,评论采集需要 .douyin.com
domain = c.get("domain", "")
if "xingtu" in domain or "oceanengine" in domain:
c["domain"] = ".douyin.com"
dy_by_name[c["name"]] = c
DY_COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True)
DY_COOKIE_FILE.write_text(
json.dumps(list(dy_by_name.values()), ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f" [OK] 同步 {len(login_cookies)} 个登录 cookie 到 {DY_COOKIE_FILE.name}")
def main() -> int:
ap = argparse.ArgumentParser(description="每 3 周周一重登星图")
ap.add_argument("--base-date", default=BASE_DATE.isoformat(),
help="3 周循环起点(必须是周一),格式 YYYY-MM-DD")
ap.add_argument("--login-timeout", type=int, default=300)
ap.add_argument("--force", action="store_true", help="忽略 3 周判断,强制重登")
ap.add_argument("--dry-run", action="store_true", help="只打印计划")
args = ap.parse_args()
today = dt.date.today()
base = dt.date.fromisoformat(args.base_date)
is_xw = is_xingtu_week(today, base)
print("== Relogin: 抖音/星图 ==")
print(f" 今天: {today} ({today.strftime('%A')})")
print(f" 3 周基准: {base}")
print(f" 星图周? {'是' if is_xw else '否'}")
print()
if not is_xw and not args.force:
print("今天不是星图周,跳过 (传 --force 强制重登)")
return 0
if args.dry_run:
print(f"[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
return 0
recover_interrupted_state()
terminate_profile_processes()
cookie_backup, profile_backup = reset_state()
cmd = [
sys.executable,
str(SCRAPER),
"--login-only",
"--login-timeout",
str(args.login_timeout),
]
print(f"\n [RUN] {' '.join(cmd)}")
try:
rc = subprocess.call(cmd, cwd=str(PROJECT_DIR))
except BaseException:
restore_previous_state(cookie_backup, profile_backup)
raise
finally:
terminate_profile_processes()
print(f"\n exit={rc}")
if rc != 0 or not has_valid_login_cookie_file():
print(" [WARN] 新登录未完成,恢复旧登录状态")
restore_previous_state(cookie_backup, profile_backup)
return rc or 1
finish_successful_relogin(profile_backup)
if rc == 0:
sync_to_douyin_scraper()
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,507 @@
"""补跑缺失 / 陈旧 / 部分失败的款式(自动检测 + 重试,粒度细到 record_id)。
定义"需要补跑":
MISSING - v2 JSON 文件不存在(进程崩了 / 中途中断) -> 整款重跑
STALE - v2 JSON 文件存在,但 mtime 超过 --stale-hours 小时 -> 整款重跑
ALL_FAILED - v2 JSON 文件新鲜,但 matched=0 且 filled=0 -> 整款重跑
HALF - v2 JSON 文件新鲜,部分 record 失败(matched>0 但有 failed) -> 只补失败的 record_id
HALF 默认开启(因为只补失败的单条,不再昂贵);
可用 --no-include-half 关掉。
用法:
python data/tools/retry_failed.py # 三个平台都补
python data/tools/retry_failed.py --platform xt # 只补星图
python data/tools/retry_failed.py --platform pgy,xt
python data/tools/retry_failed.py --dry-run # 只看不跑
python data/tools/retry_failed.py --style 8 # 强制补指定款式(忽略检测)
python data/tools/retry_failed.py --style 8,15,20
python data/tools/retry_failed.py --max-attempts 3 # 每款式最多重试 3 次
python data/tools/retry_failed.py --stale-hours 4 # 4h 以上的算陈旧
python data/tools/retry_failed.py --no-include-half # 不补 HALF
"""
import argparse
import datetime as dt
import json
import subprocess
import sys
import time
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
PROJECT_DIR = PATHS.module_root
V2_DIR = PATHS.normalized_root / "v2_results"
# 复用 run_all.py 的 SCRIPTS 字典,保持单一事实
SCRIPTS = {
"bili": {
"name": "B 站",
"script": "bilibili_scraper.py",
"label": "bili",
},
"pgy": {
"name": "小红书蒲公英",
"script": "pgy_xhs_scraper_v2.py",
"label": "xhs",
},
"xt": {
"name": "星图",
"script": "xingtu_scraper_v2.py",
"label": "xt",
},
}
SELF_SCRIPTS = {
"bili": {**SCRIPTS["bili"], "extra_args": ["--self-operated"]},
"pgy": {**SCRIPTS["pgy"], "extra_args": ["--self-operated"]},
"xt": {
"name": "抖音(自营)",
"script": "self_douyin_scraper.py",
"label": "xt",
"extra_args": [],
},
}
def _mapping_cache_path(self_operated: bool = False) -> Path:
name = "款式_多维表格_对照_自营.json" if self_operated else "款式_多维表格_对照.json"
cache = PATHS.normalized_root / "mappings" / name
return cache if cache.exists() else PATHS.config_root / name
def load_current_styles(self_operated: bool = False) -> list[dict]:
"""只读加载当前款式缓存。
补跑属于结果审计阶段,不能在 import 或 ``--dry-run`` 时触发飞书刷新、
更不能把自营缓存作为副作用写回。映射刷新由 run_all 在采集开始前负责。
"""
path = _mapping_cache_path(self_operated)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log(f"[ERROR] 读取款式缓存失败 {path}: {exc}")
return []
by_index: dict[int, dict] = {}
for raw in data.get("tables", []):
if not isinstance(raw, dict) or not raw.get("index") or not raw.get("name"):
continue
try:
idx = int(raw["index"])
except (TypeError, ValueError):
continue
style = dict(raw)
style["index"] = idx
style["name"] = str(raw["name"])
by_index[idx] = style
return [by_index[idx] for idx in sorted(by_index)]
def canonical_result_filename(style: dict, platform_key: str,
self_operated: bool = False) -> str:
"""返回当前款式唯一允许参与完整度判断的规范结果文件名。"""
if platform_key not in SCRIPTS:
raise ValueError(f"unknown platform: {platform_key}")
idx = int(style["index"])
name = str(style["name"])
self_sfx = "_self" if self_operated else ""
if platform_key == "pgy":
suffix = f"{self_sfx}_v2.json"
elif platform_key == "xt":
suffix = f"{self_sfx}_xingtu_v2.json"
else:
suffix = f"{self_sfx}_bilibili_v2.json"
return f"{idx:02d}-{name}{suffix}"
def log(msg: str) -> None:
ts = dt.datetime.now().strftime("%H:%M:%S")
safe = msg.encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{ts}] {safe}", flush=True)
def _result_rows(data: dict) -> list[dict]:
raw = data.get("results")
if raw is None:
raw = data.get("details")
if not isinstance(raw, list):
return []
return [row for row in raw if isinstance(row, dict)]
def _result_is_unresolved(row: dict) -> bool:
"""兼容新状态契约与旧版 matched/ok/error/reason 字段。"""
status = row.get("status")
if status == "blocked_input":
return False
explicit_failure = bool(
row.get("matched") is False
or row.get("ok") is False
or row.get("write_ok") is False
or row.get("error")
or row.get("reason")
)
if status == "success":
return explicit_failure
if status:
return True
return explicit_failure
def _result_is_success(row: dict) -> bool:
status = row.get("status")
if status is not None:
return status == "success" and not _result_is_unresolved(row)
if _result_is_unresolved(row):
return False
# 旧版 B 站成功行有 ok=True;旧版 pgy/xt 成功行有命中标题或曝光值。
return bool(
row.get("ok") is True
or row.get("matched") is True
or row.get("matched_title")
or row.get("read_count") is not None
or row.get("play_count") is not None
)
def requested_records_succeeded(data: dict,
requested_record_ids: list[str]) -> tuple[bool, list[str]]:
"""核验局部补跑请求的每个 record_id 都恰好有一条 success 终态。"""
rows_by_id: dict[str, list[dict]] = {}
for row in _result_rows(data):
rid = row.get("record_id")
if rid is not None:
rows_by_id.setdefault(str(rid), []).append(row)
failed: list[str] = []
seen: set[str] = set()
for raw_rid in requested_record_ids:
rid = str(raw_rid)
if rid in seen:
continue
seen.add(rid)
candidates = rows_by_id.get(rid, [])
if len(candidates) != 1 or not _result_is_success(candidates[0]):
failed.append(rid)
return not failed, failed
def _summary_completed(data: dict) -> tuple[bool, str]:
"""核验整款结果覆盖、唯一性和终态;blocked_input 是已解释终态。"""
total = data.get("total")
if total is None:
total = data.get("total_b_records")
if total is None:
total = data.get("total_tasks")
try:
total = int(total or 0)
except (TypeError, ValueError):
return False, "invalid total"
if total < 0:
return False, "invalid total"
rows = _result_rows(data)
record_ids = [str(row["record_id"]) for row in rows if row.get("record_id")]
if len(rows) != total:
return False, f"result coverage {len(rows)}/{total}"
if len(record_ids) != len(rows) or len(set(record_ids)) != len(record_ids):
return False, "missing or duplicate record_id"
unresolved = [rid for rid, row in zip(record_ids, rows) if _result_is_unresolved(row)]
if unresolved:
return False, f"unresolved records: {','.join(unresolved[:5])}"
if _summary_declares_incomplete(data):
return False, "summary declares unresolved failures"
return True, ""
def _summary_declares_incomplete(data: dict) -> bool:
if data.get("complete") is False:
return True
for key in (
"unresolved", "retryable_failures", "write_failures",
"missing_results", "duplicate_results",
):
try:
if int(data.get(key) or 0) > 0:
return True
except (TypeError, ValueError):
return True
return False
def extract_failed_records(data: dict, platform_key: str) -> list[str]:
"""从 v2 JSON 内容里提取失败的 record_id 列表。
xingtu/pgy: results 数组,失败标志 matched: false(可能加 reason/error)
bilibili: details 数组,失败标志 ok: false(可能加 action)
"""
rids: list[str] = []
for row in _result_rows(data):
rid = row.get("record_id")
if not rid:
continue
# blocked_input 是可解释但不可重试的终态,不应反复补跑。
if _result_is_unresolved(row):
rids.append(str(rid))
return list(dict.fromkeys(rids))
def find_failed(platform_key: str, stale_hours: float, include_half: bool,
self_operated: bool = False, *,
styles: list[dict] | None = None,
v2_dir: Path | None = None,
now: dt.datetime | None = None) -> dict[int, dict]:
"""扫 v2_results,返回 {idx: {reason, failed_rids}} 字典。
reason 取值: MISSING / STALE / ALL_FAILED / HALF
failed_rids: HALF 时给失败 record_id 列表;其他场景为空(整款重跑)。
self_operated=True 时只扫自营达人结果文件(*_self_*.json)
"""
if platform_key not in SCRIPTS:
raise ValueError(f"unknown platform: {platform_key}")
current_styles = styles if styles is not None else load_current_styles(self_operated)
result_dir = v2_dir or V2_DIR
current_time = now or dt.datetime.now()
out: dict[int, dict] = {}
for style in current_styles:
idx = int(style["index"])
path = result_dir / canonical_result_filename(style, platform_key, self_operated)
if not path.exists():
out[idx] = {"reason": "MISSING", "failed_rids": []}
continue
mtime = dt.datetime.fromtimestamp(path.stat().st_mtime)
age_h = (current_time - mtime).total_seconds() / 3600.0
if age_h > stale_hours:
out[idx] = {"reason": "STALE", "failed_rids": []}
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
out[idx] = {"reason": "INVALID", "failed_rids": []}
continue
total = data.get("total")
if total is None:
total = data.get("total_b_records")
if total is None:
total = data.get("total_tasks")
try:
total = int(total or 0)
except (TypeError, ValueError):
out[idx] = {"reason": "INVALID", "failed_rids": []}
continue
rows = _result_rows(data)
failed_rids = extract_failed_records(data, platform_key)
resolved = sum(1 for row in rows if not _result_is_unresolved(row))
if total > 0 and resolved == 0:
out[idx] = {"reason": "ALL_FAILED", "failed_rids": []}
continue
record_ids = [str(row["record_id"]) for row in rows if row.get("record_id")]
coverage_invalid = (
len(rows) != total
or len(record_ids) != len(rows)
or len(set(record_ids)) != len(record_ids)
)
if coverage_invalid:
# 缺失记录无法从结果中推导 record_id,只能整款重跑。
out[idx] = {"reason": "INCOMPLETE", "failed_rids": []}
continue
if include_half and failed_rids:
out[idx] = {"reason": "HALF", "failed_rids": failed_rids}
continue
if _summary_declares_incomplete(data):
out[idx] = {"reason": "INCOMPLETE", "failed_rids": []}
return out
def run_one_style(platform_key: str, idx: int, max_attempts: int,
self_operated: bool = False,
record_ids: list[str] | None = None, *,
style: dict | None = None,
v2_dir: Path | None = None) -> bool:
"""跑单款式,失败重试。返回最终是否成功。
record_ids 非空时,只跑这些 record_id(--record 参数,粒度细到单条笔记)。
"""
info = SELF_SCRIPTS[platform_key] if self_operated else SCRIPTS[platform_key]
if style is None:
style = next(
(item for item in load_current_styles(self_operated)
if int(item["index"]) == int(idx)),
None,
)
if style is None:
log(f" [ERROR] 当前款式映射中不存在编号 {idx}")
return False
result_path = (v2_dir or V2_DIR) / canonical_result_filename(
style, platform_key, self_operated,
)
cmd = [sys.executable, str(PROJECT_DIR / info["script"]), "--style", str(idx)]
cmd.extend(info.get("extra_args", []))
for rid in record_ids or []:
cmd += ["--record", rid]
label = f"[{platform_key}{'-self' if self_operated else ''}] 款式 {idx:>2}"
desc = f"整款" if not record_ids else f"{len(record_ids)} 条"
for attempt in range(1, max_attempts + 1):
log(f" {label} 尝试 {attempt}/{max_attempts} ({desc}): {info['script']} --style {idx}"
+ (f" --record x{len(record_ids)}" if record_ids else ""))
before_mtime_ns = result_path.stat().st_mtime_ns if result_path.exists() else None
try:
rc = subprocess.call(cmd, cwd=str(PROJECT_DIR))
except KeyboardInterrupt:
raise
except Exception as exc:
log(f" [ERROR] 进程异常: {exc}")
rc = -1
if rc != 0:
log(f" [FAIL] exit={rc}")
else:
try:
after_mtime_ns = result_path.stat().st_mtime_ns
if before_mtime_ns is not None and after_mtime_ns == before_mtime_ns:
raise RuntimeError("规范结果文件未在本次补跑中更新")
payload = json.loads(result_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("规范结果不是 JSON object")
if record_ids:
verified, failed_rids = requested_records_succeeded(payload, record_ids)
reason = f"仍失败/缺失 record: {','.join(failed_rids[:5])}"
else:
verified, reason = _summary_completed(payload)
if verified:
log(" [OK] exit=0 且结果完整度校验通过")
return True
log(f" [INCOMPLETE] exit=0 但{reason}")
except Exception as exc:
log(f" [INCOMPLETE] exit=0 但结果校验失败: {exc}")
if attempt < max_attempts:
time.sleep(2)
return False
def main() -> int:
ap = argparse.ArgumentParser(description="补跑缺失 / 陈旧的款式")
ap.add_argument("--platform", type=str, default="bili,pgy,xt",
help="逗号分隔 bili/pgy/xt,默认全跑")
ap.add_argument("--style", type=str, default="",
help="强制指定要补的款式(逗号分隔),忽略检测")
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不跑")
ap.add_argument("--max-attempts", type=int, default=1,
help="每个款式最多重试次数(默认 1)")
ap.add_argument("--stale-hours", type=float, default=26.0,
help="超过多少小时算陈旧(默认 26h)")
ap.add_argument("--no-include-half", action="store_true",
help="不补 HALF 款式(部分 record 失败的款);默认会补,只跑失败的 record_id")
ap.add_argument("--include-self-operated", action="store_true",
help="同时补自营达人(默认只补合作达人)")
ap.add_argument("--self-operated-only", action="store_true",
help="只补自营达人")
args = ap.parse_args()
include_half = not args.no_include_half
keys = [k.strip() for k in args.platform.split(",") if k.strip()]
for k in keys:
if k not in SCRIPTS:
print(f"[ERROR] 未知平台: {k} 可选: {','.join(SCRIPTS.keys())}")
return 1
forced: list[int] = []
if args.style:
for s in args.style.split(","):
s = s.strip()
if not s:
continue
try:
idx = int(s)
except ValueError:
print(f"[ERROR] --style 解析失败: {s}")
return 1
forced.append(idx)
# 决定要跑哪些轮: 合作达人 + 自营达人
rounds: list[tuple[bool, str]] = [] # (self_operated, label)
if not args.self_operated_only:
rounds.append((False, "合作达人"))
if args.include_self_operated or args.self_operated_only:
rounds.append((True, "自营达人"))
any_failed = False
for self_op, round_label in rounds:
styles = load_current_styles(self_op)
if not styles:
log(f"[ERROR] {round_label} 当前款式缓存为空,无法安全补跑")
any_failed = True
continue
styles_by_index = {int(style["index"]): style for style in styles}
invalid_forced = [idx for idx in forced if idx not in styles_by_index]
if invalid_forced:
log(f"[ERROR] {round_label} 不存在款式编号: {invalid_forced}")
any_failed = True
print(f"{'=' * 78}")
print(f" Retry Failed Styles — {round_label}")
print(f" 平台: {','.join(SCRIPTS[k]['name'] for k in keys)}")
print(f" stale-hours: {args.stale_hours} max-attempts: {args.max_attempts}")
print(f" include-half: {include_half} dry-run: {args.dry_run}")
if forced:
print(f" 强制指定: {forced}")
print(f"{'=' * 78}")
grand_total = grand_ok = grand_fail = 0
grand_failed_list: list[tuple[str, int]] = []
for k in keys:
info = SCRIPTS[k]
if forced:
targets = {idx: {"reason": "强制", "failed_rids": []}
for idx in forced if idx in styles_by_index}
else:
targets = find_failed(
k, args.stale_hours, include_half,
self_operated=self_op, styles=styles,
)
log(f"[{k}{'-self' if self_op else ''}] {info['name']} 待补: {len(targets)} 个 -> {sorted(targets.keys())}")
if not targets:
continue
if args.dry_run:
for idx in sorted(targets.keys()):
info_i = targets[idx]
n = len(info_i["failed_rids"])
detail = f", {n} 条 record" if n else ""
log(f" [DRY-RUN] 款式 {idx:>2} ({info_i['reason']}{detail})")
grand_total += len(targets)
continue
for idx in sorted(targets.keys()):
info_i = targets[idx]
grand_total += 1
ok = run_one_style(k, idx, args.max_attempts,
self_operated=self_op,
record_ids=info_i["failed_rids"] or None,
style=styles_by_index[idx])
if ok:
grand_ok += 1
else:
grand_fail += 1
grand_failed_list.append((k, idx))
any_failed = True
print(f"\n{'=' * 78}")
print(f" {round_label} 补跑汇总: 总 {grand_total} 成功 {grand_ok} 失败 {grand_fail}")
if grand_failed_list:
print(f" 仍失败的:")
for k, idx in grand_failed_list:
print(f" - [{k}] 款式 {idx}")
print(f"{'=' * 78}\n")
return 1 if any_failed else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n[中断]", file=sys.stderr)
sys.exit(130)
@@ -0,0 +1,342 @@
-- 数据库:gyxx_super_data
-- 表结构:cmt_styles / cmt_creators / cmt_style_creators / cmt_notes / cmt_comments
-- 适用于 PostgreSQL 14+
-- 如果数据库还没创建,取消下面一行的注释并执行:
-- CREATE DATABASE gyxx_super_data WITH ENCODING = 'UTF8' LC_COLLATE = 'zh_CN.UTF-8' LC_CTYPE = 'zh_CN.UTF-8';
\c gyxx_super_data;
-- 款式表
CREATE TABLE IF NOT EXISTS cmt_styles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
style_category VARCHAR(64) DEFAULT '',
brand VARCHAR(128) DEFAULT '',
erp_style_codes TEXT[] DEFAULT '{}',
platform_product_ids JSONB DEFAULT '{}'::jsonb,
product_type VARCHAR(128) DEFAULT '',
dimensions VARCHAR(255) DEFAULT '',
colors TEXT[] DEFAULT '{}',
material TEXT DEFAULT '',
capacity VARCHAR(128) DEFAULT '',
weight VARCHAR(128) DEFAULT '',
compatible_devices TEXT DEFAULT '',
features TEXT DEFAULT '',
selling_points TEXT DEFAULT '',
product_source_urls TEXT[] DEFAULT '{}',
product_research_status VARCHAR(32) DEFAULT 'pending',
product_researched_at TIMESTAMPTZ,
platform VARCHAR(64) DEFAULT '',
feishu_base_url TEXT DEFAULT '',
feishu_base_token VARCHAR(64) DEFAULT '',
feishu_table_id VARCHAR(64) DEFAULT '',
feishu_view_id VARCHAR(64) DEFAULT '',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE cmt_styles IS '款式表';
COMMENT ON COLUMN cmt_styles.name IS '款式名称';
COMMENT ON COLUMN cmt_styles.style_category IS '款式风格';
COMMENT ON COLUMN cmt_styles.platform IS '平台分组,如天猫';
CREATE INDEX IF NOT EXISTS idx_cmt_styles_platform ON cmt_styles (platform);
-- 博主/达人表
CREATE TABLE IF NOT EXISTS cmt_creators (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
platform VARCHAR(32) DEFAULT '', -- 主平台: xiaohongshu/douyin/bilibili
platform_id VARCHAR(128) DEFAULT '', -- 平台达人ID (B站UID/抖音sec_uid等)
follower_count VARCHAR(64) DEFAULT '', -- 达人粉丝数(原始文本如3.7万/12.4w)
follower_count_num BIGINT DEFAULT NULL, -- 达人粉丝数(数字解析后,3.7万=37000)
homepage_url TEXT DEFAULT '', -- 达人主页链接
wechat VARCHAR(128) DEFAULT '', -- 微信号
account_type VARCHAR(64) DEFAULT '', -- 达人类型: 数码/vlog/好物/穿搭/摄影等
creator_level VARCHAR(32) DEFAULT '', -- 达人层级: 头部/腰部/KOC/素人
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT cmt_creators_name_platform_key UNIQUE (name, platform)
);
COMMENT ON TABLE cmt_creators IS '博主/达人表';
COMMENT ON COLUMN cmt_creators.name IS '博主/达人名称';
COMMENT ON COLUMN cmt_creators.platform IS '主平台: xiaohongshu/douyin/bilibili';
COMMENT ON COLUMN cmt_creators.platform_id IS '平台达人ID (B站UID/抖音sec_uid等)';
COMMENT ON COLUMN cmt_creators.follower_count IS '达人粉丝数(原始文本如3.7万/12.4w)';
COMMENT ON COLUMN cmt_creators.follower_count_num IS '达人粉丝数(数字解析后,3.7万=37000)';
COMMENT ON COLUMN cmt_creators.homepage_url IS '达人主页链接';
COMMENT ON COLUMN cmt_creators.wechat IS '微信号';
COMMENT ON COLUMN cmt_creators.account_type IS '达人类型: 数码/vlog/好物/穿搭/摄影等';
COMMENT ON COLUMN cmt_creators.creator_level IS '达人层级: 头部/腰部/KOC/素人';
CREATE INDEX IF NOT EXISTS idx_cmt_creators_name ON cmt_creators (name);
CREATE INDEX IF NOT EXISTS idx_cmt_creators_platform ON cmt_creators (platform);
CREATE INDEX IF NOT EXISTS idx_cmt_creators_platform_id ON cmt_creators (platform_id);
-- 款式-博主关系表(多对多)
CREATE TABLE IF NOT EXISTS cmt_style_creators (
id BIGSERIAL PRIMARY KEY,
style_id BIGINT NOT NULL,
creator_id BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (style_id, creator_id),
CONSTRAINT fk_cmt_sc_style FOREIGN KEY (style_id) REFERENCES cmt_styles (id) ON DELETE CASCADE,
CONSTRAINT fk_cmt_sc_creator FOREIGN KEY (creator_id) REFERENCES cmt_creators (id) ON DELETE CASCADE
);
COMMENT ON TABLE cmt_style_creators IS '款式-博主关系表';
CREATE INDEX IF NOT EXISTS idx_cmt_style_creators_creator ON cmt_style_creators (creator_id);
-- 笔记表
CREATE TABLE IF NOT EXISTS cmt_notes (
id BIGSERIAL PRIMARY KEY,
style_id BIGINT NOT NULL,
creator_id BIGINT NOT NULL,
platform VARCHAR(32) NOT NULL,
title VARCHAR(512) DEFAULT '',
url TEXT NOT NULL UNIQUE,
feishu_record_id VARCHAR(64) DEFAULT '',
view_count BIGINT DEFAULT NULL, -- 曝光量 / 播放量
like_count BIGINT DEFAULT NULL,
collect_count BIGINT DEFAULT NULL, -- 小红书 / 抖音收藏
favorite_count BIGINT DEFAULT NULL, -- B 站收藏
comment_count BIGINT DEFAULT NULL,
share_count BIGINT DEFAULT NULL,
scraped_at TIMESTAMPTZ DEFAULT NULL,
self_operated BOOLEAN DEFAULT FALSE, -- TRUE=自营达人笔记, FALSE=合作达人笔记
publish_time TIMESTAMPTZ DEFAULT NULL, -- 笔记发布时间(从飞书 publish_time 字段同步)
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_cmt_notes_style FOREIGN KEY (style_id) REFERENCES cmt_styles (id) ON DELETE CASCADE,
CONSTRAINT fk_cmt_notes_creator FOREIGN KEY (creator_id) REFERENCES cmt_creators (id) ON DELETE CASCADE
);
COMMENT ON COLUMN cmt_notes.view_count IS '曝光量 / 播放量 (来自 yingxiaoyunying 每日采集)';
COMMENT ON COLUMN cmt_notes.collect_count IS '收藏量: 小红书/抖音 (来自 yingxiaoyunying 每日采集)';
COMMENT ON COLUMN cmt_notes.favorite_count IS '收藏量: B 站 (来自 comment-data-collector)';
COMMENT ON TABLE cmt_notes IS '笔记表';
COMMENT ON COLUMN cmt_notes.platform IS '平台:xiaohongshu / douyin / bilibili';
COMMENT ON COLUMN cmt_notes.url IS '笔记链接';
COMMENT ON COLUMN cmt_notes.scraped_at IS '最近一次抓取时间';
COMMENT ON COLUMN cmt_notes.self_operated IS 'TRUE=自营达人笔记, FALSE=合作达人笔记';
COMMENT ON COLUMN cmt_notes.publish_time IS '笔记发布时间(从飞书 publish_time 字段同步)';
CREATE INDEX IF NOT EXISTS idx_cmt_notes_style ON cmt_notes (style_id);
CREATE INDEX IF NOT EXISTS idx_cmt_notes_creator ON cmt_notes (creator_id);
CREATE INDEX IF NOT EXISTS idx_cmt_notes_platform ON cmt_notes (platform);
CREATE INDEX IF NOT EXISTS idx_cmt_notes_scraped_at ON cmt_notes (scraped_at);
-- 评论表
CREATE TABLE IF NOT EXISTS cmt_comments (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL,
platform VARCHAR(32) NOT NULL,
level VARCHAR(16) NOT NULL,
platform_comment_id VARCHAR(64) DEFAULT '',
parent_comment_id VARCHAR(64) DEFAULT '',
reply_to_comment_id VARCHAR(64) DEFAULT '',
user_id VARCHAR(128) DEFAULT '',
nickname VARCHAR(255) DEFAULT '',
content TEXT DEFAULT NULL,
like_count BIGINT DEFAULT 0,
reply_count BIGINT DEFAULT 0,
ip_location VARCHAR(128) DEFAULT '',
comment_created_at TIMESTAMPTZ DEFAULT NULL,
raw_create_time VARCHAR(64) DEFAULT '',
scraped_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (note_id, platform_comment_id),
CONSTRAINT fk_cmt_comments_note FOREIGN KEY (note_id) REFERENCES cmt_notes (id) ON DELETE CASCADE
);
COMMENT ON TABLE cmt_comments IS '评论表';
COMMENT ON COLUMN cmt_comments.level IS '层级:comment 一级评论 / reply 回复';
CREATE INDEX IF NOT EXISTS idx_cmt_comments_note_id ON cmt_comments (note_id);
-- ============================================================
-- 合作记录表 (达人 + 款式维度)
-- ============================================================
-- 每条记录 = 一个达人在一个款式下的一次合作
-- 飞书款式合作表的每一行 → 此表一条记录
CREATE TABLE IF NOT EXISTS cmt_cooperations (
id BIGSERIAL PRIMARY KEY,
style_id BIGINT NOT NULL REFERENCES cmt_styles(id) ON DELETE CASCADE,
creator_id BIGINT NOT NULL REFERENCES cmt_creators(id) ON DELETE CASCADE,
feishu_record_id VARCHAR(64) DEFAULT '', -- 飞书行 record_id, 用于回写
-- 合作基本信息
cooperation_date DATE DEFAULT NULL, -- 合作日期 (飞书"制单日期")
cooperation_cost NUMERIC(12,2) DEFAULT NULL, -- 合作花费 (飞书"合作费用")
ad_spend NUMERIC(12,2) DEFAULT NULL, -- 投流金额 (飞书"投流金额")
-- 内容信息
content_direction VARCHAR(255) DEFAULT '', -- 内容方向 (飞书"发布笔记内容方向")
content_format VARCHAR(64) DEFAULT '', -- 内容形式: 单品种草/测评合集/场景植入/开箱Vlog/穿搭变装等
note_title VARCHAR(512) DEFAULT '', -- 发布笔记标题
note_url TEXT DEFAULT '', -- 发布笔记链接
publish_time TIMESTAMPTZ DEFAULT NULL, -- 发布时间
-- 数据表现
exposure_count BIGINT DEFAULT NULL, -- 曝光量 (飞书"曝光量(自动抓取)")
engagement_count VARCHAR(64) DEFAULT '', -- 互动赞藏数 (原始文本如"68.5万")
engagement_count_num BIGINT DEFAULT NULL, -- 互动赞藏数(数字解析后)
data_performance VARCHAR(255) DEFAULT '', -- 数据表现 (逗号分隔: 曝光限流,数据良好)
cpm NUMERIC(10,4) DEFAULT NULL, -- CPM (飞书公式字段)
-- 物流/财务
tracking_number VARCHAR(128) DEFAULT '', -- 快递单号
is_paid BOOLEAN DEFAULT FALSE, -- 是否结款
is_new_direction BOOLEAN DEFAULT FALSE, -- 是否新方向验证
-- 电商效果 (飞书暂无,预留)
shop_uv BIGINT DEFAULT NULL, -- 进店UV
transaction_amount NUMERIC(12,2) DEFAULT NULL, -- 成交金额(元)
roi NUMERIC(10,4) DEFAULT NULL, -- ROI (成交金额/花费)
cpe NUMERIC(10,4) DEFAULT NULL, -- CPE (花费/互动量)
engagement_rate NUMERIC(8,4) DEFAULT NULL, -- 互动率 (互动量/曝光量)
-- 评论分析 (飞书暂无,预留)
brand_mention_rate NUMERIC(8,4) DEFAULT NULL, -- 评论品牌提及率
risk_flag VARCHAR(255) DEFAULT '', -- 差评或舆情风险
--
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_cooperation_feishu_record UNIQUE (feishu_record_id)
);
COMMENT ON TABLE cmt_cooperations IS '合作记录表(每个达人+款式的一次合作)';
COMMENT ON COLUMN cmt_cooperations.cooperation_date IS '合作日期(飞书制单日期)';
COMMENT ON COLUMN cmt_cooperations.cooperation_cost IS '合作花费(元)';
COMMENT ON COLUMN cmt_cooperations.ad_spend IS '投流金额(元)';
COMMENT ON COLUMN cmt_cooperations.content_direction IS '发布笔记内容方向(飞书原始值)';
COMMENT ON COLUMN cmt_cooperations.content_format IS '内容形式: 单品种草/测评合集/场景植入/开箱Vlog等';
COMMENT ON COLUMN cmt_cooperations.exposure_count IS '曝光量(飞书自动抓取)';
COMMENT ON COLUMN cmt_cooperations.engagement_count IS '互动赞藏数(原始文本如68.5万)';
COMMENT ON COLUMN cmt_cooperations.data_performance IS '数据表现(逗号分隔: 曝光限流,数据良好,表现优秀等)';
COMMENT ON COLUMN cmt_cooperations.shop_uv IS '进店UV(预留,飞书暂无)';
COMMENT ON COLUMN cmt_cooperations.transaction_amount IS '成交金额(元,预留)';
COMMENT ON COLUMN cmt_cooperations.roi IS 'ROI=成交金额/花费(预留)';
COMMENT ON COLUMN cmt_cooperations.cpe IS 'CPE=花费/互动量(预留)';
COMMENT ON COLUMN cmt_cooperations.engagement_rate IS '互动率=互动量/曝光量(预留)';
COMMENT ON COLUMN cmt_cooperations.brand_mention_rate IS '评论品牌提及率(预留)';
COMMENT ON COLUMN cmt_cooperations.risk_flag IS '差评或舆情风险(预留)';
CREATE INDEX IF NOT EXISTS idx_cmt_cooperations_style ON cmt_cooperations (style_id);
CREATE INDEX IF NOT EXISTS idx_cmt_cooperations_creator ON cmt_cooperations (creator_id);
CREATE INDEX IF NOT EXISTS idx_cmt_cooperations_date ON cmt_cooperations (cooperation_date);
-- ============================================================
-- 周笔记汇总表 (款式 + 周维度)
-- ============================================================
CREATE TABLE IF NOT EXISTS cmt_weekly_summary (
id BIGSERIAL PRIMARY KEY,
style_id BIGINT NOT NULL REFERENCES cmt_styles(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
week_end DATE NOT NULL,
time_label VARCHAR(32) NOT NULL,
note_count INTEGER DEFAULT 0,
total_comments INTEGER DEFAULT 0,
total_views BIGINT,
total_engagement BIGINT,
doc_url TEXT,
doc_title VARCHAR(200),
summary_chars INTEGER,
status VARCHAR(32),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_weekly_style_week UNIQUE (style_id, week_start)
);
CREATE INDEX IF NOT EXISTS idx_cmt_weekly_summary_week ON cmt_weekly_summary (week_start);
CREATE INDEX IF NOT EXISTS idx_cmt_weekly_summary_style ON cmt_weekly_summary (style_id);
COMMENT ON TABLE cmt_weekly_summary IS '周笔记汇总表(款式+周维度,LLM 分析报告飞书文档链接)';
COMMENT ON COLUMN cmt_weekly_summary.time_label IS '周时间范围标签 如 2026-06-29~07-05';
COMMENT ON COLUMN cmt_weekly_summary.doc_url IS '飞书文档链接(每周笔记汇总报告)';
COMMENT ON COLUMN cmt_weekly_summary.status IS 'ok/no_reports/no_notes/exception';
-- ============================================================
-- 月笔记汇总表 (款式 + 月维度,横向对比分析)
-- ============================================================
CREATE TABLE IF NOT EXISTS cmt_monthly_summary (
id BIGSERIAL PRIMARY KEY,
style_id BIGINT NOT NULL REFERENCES cmt_styles(id) ON DELETE CASCADE,
month_start DATE NOT NULL,
month_end DATE NOT NULL,
time_label VARCHAR(32) NOT NULL,
note_count INTEGER DEFAULT 0,
total_comments INTEGER DEFAULT 0,
total_views BIGINT,
total_engagement BIGINT,
doc_url TEXT,
doc_title VARCHAR(200),
summary_chars INTEGER,
status VARCHAR(32),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_monthly_style_month UNIQUE (style_id, month_start)
);
CREATE INDEX IF NOT EXISTS idx_cmt_monthly_summary_month ON cmt_monthly_summary (month_start);
CREATE INDEX IF NOT EXISTS idx_cmt_monthly_summary_style ON cmt_monthly_summary (style_id);
COMMENT ON TABLE cmt_monthly_summary IS '月笔记汇总表(款式+月维度,LLM 横向对比分析报告)';
COMMENT ON COLUMN cmt_monthly_summary.time_label IS '月时间范围标签 如 2026-06-01~06-30';
COMMENT ON COLUMN cmt_monthly_summary.doc_url IS '飞书文档链接(月度汇总报告)';
-- ============================================================
-- 达人月度分析报告表 (每月1号自动生成)
-- ============================================================
CREATE TABLE IF NOT EXISTS cmt_creator_report (
id BIGSERIAL PRIMARY KEY,
month_start DATE NOT NULL,
month_end DATE NOT NULL,
time_label VARCHAR(32) NOT NULL,
total_cooperations INTEGER DEFAULT 0,
total_creators INTEGER DEFAULT 0,
total_styles INTEGER DEFAULT 0,
total_cost NUMERIC(14,2),
total_exposure BIGINT,
total_engagement BIGINT,
avg_cpe NUMERIC(10,2),
level_distribution JSONB DEFAULT '{}',
platform_distribution JSONB DEFAULT '{}',
score_distribution JSONB DEFAULT '{}',
doc_url TEXT,
doc_title VARCHAR(200),
report_chars INTEGER,
status VARCHAR(32),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_creator_report_month UNIQUE (month_start)
);
CREATE INDEX IF NOT EXISTS idx_cmt_creator_report_month ON cmt_creator_report (month_start);
COMMENT ON TABLE cmt_creator_report IS '达人月度分析报告表(每月1号自动生成,全量达人筛选与报价分析)';
COMMENT ON COLUMN cmt_creator_report.time_label IS '月时间范围标签 如 2026-06-01~06-30';
COMMENT ON COLUMN cmt_creator_report.level_distribution IS '层级分布快照 JSON {"头部":N,"腰部":N,...}';
COMMENT ON COLUMN cmt_creator_report.platform_distribution IS '平台分布快照 JSON {"douyin":N,...}';
COMMENT ON COLUMN cmt_creator_report.score_distribution IS '评分分布快照 JSON {"S":N,"A":N,...}';
COMMENT ON COLUMN cmt_creator_report.doc_url IS '飞书文档链接(达人分析报告)';
COMMENT ON COLUMN cmt_creator_report.status IS 'ok/doc_failed/exception';
CREATE INDEX IF NOT EXISTS idx_cmt_comments_platform ON cmt_comments (platform);
CREATE INDEX IF NOT EXISTS idx_cmt_comments_level ON cmt_comments (level);
CREATE INDEX IF NOT EXISTS idx_cmt_comments_comment_created_at ON cmt_comments (comment_created_at);
-- 自动更新 updated_at 的触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 为需要自动更新 updated_at 的表创建触发器
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN SELECT unnest(ARRAY['cmt_styles', 'cmt_creators', 'cmt_style_creators', 'cmt_notes', 'cmt_comments', 'cmt_cooperations', 'cmt_weekly_summary', 'cmt_monthly_summary', 'cmt_creator_report'])
LOOP
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_' || tbl || '_updated_at'
) THEN
EXECUTE format('CREATE TRIGGER trg_%I_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()', tbl, tbl);
END IF;
END LOOP;
END;
$$;
@@ -0,0 +1,362 @@
"""Style-level rollup analyzer for the Feishu doc backfill.
Aggregates metrics + keyword distributions across all notes of one style,
then calls Hermes for a cross-note synthesis analysis that becomes the
"💼 款式整体分析" section in the doc.
Sibling of analyze_note.py — same import surface (call_hermes_analyzer,
ANALYSIS_SYSTEM_PROMPT, code_filter_comments) but operates at style scope.
"""
import re
import sys
import time
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
# Path setup
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import (
ANALYSIS_SYSTEM_PROMPT,
call_hermes_analyzer,
)
CATEGORY_LABELS = {
"purchase_intent": "购买意向",
"positive_feature": "正向功能反馈",
"negative_feature": "负向功能反馈",
"user_scenario": "用户场景",
"aesthetic": "审美与风格",
"brand_emotion": "品牌情感",
}
# 5-section deep prompt at style scope (mirrors SINGLE_NOTE_DEEP_PROMPT_TEMPLATE
# but across all notes of one style).
STYLE_ROLLUP_DEEP_PROMPT = """你是一位品牌内容分析师。本次任务是把同一款式的所有笔记的标题、平台、点赞/收藏/评论/分享数据和有效评论内容,汇总成一份**款式整体分析报告**,用于投放团队对这款产品的内容策略做一次通盘复盘。
输出结构(严格 5 大节,总长 800-1200 字):
### 一、执行摘要(不超过 150 字)
用一段话点明这款产品的整体定位和投放现状:覆盖多少篇笔记、整体互动表现、主要用户反馈倾向、是否存在明显短板。
### 二、数据表现
- 该款式总笔记数 / 有效评论总数
- 总点赞 / 总收藏 / 总评论 / 总分享(如曝光量汇总可用则一并列出)
- 综合互动率(基于汇总数据)
- 跨笔记表现差异:高/低表现笔记的标题特征(如"通勤""扫街""旅行"等场景标签)
### 三、受众洞察
基于所有有效评论的合并视角:
- 正向反馈汇总(被夸的核心卖点 / 场景 / 审美点)
- 负向问题/风险点(被吐槽的尺寸 / 材质 / 价格 / 功能)
- 购买意向强弱(直接询问价格/链接 vs 围观闲聊)
- 典型使用场景(被反复提及的目标人群和场景)
- 跨平台差异(如果 bilibili / douyin / xiaohongshu 反馈侧重不同,请分别说明)
### 四、内容策略
基于跨笔记样本,提炼以下三类规律:
- 哪种标题/封面/切入点最受欢迎
- 哪种内容结构(测评 / 街拍 / Vlog / 教程)种草效率最高
- 评论区的高频问题/梗,可作为后续内容选题/卖点提炼的素材
### 五、下一步(按 P0/P1/P2 排序,最多 5 条)
- P0(立即):【问题】→【行动】→【责任方】
- P1(本周):...
- P2(下月):...(如无可省略)
## 硬性约束
1. 引用评论必须直接引述原文(用「」包裹),禁止使用"#N""第N条"等编号引用。
2. 禁止"根据数据/数据显示/数据来源/通过 XX 可知"等出处类表述。
3. 若有效评论 < 30 条,必须在执行摘要后注明"样本量较小,结论仅供参考"。
4. P0/P1/P2 必须明确标注【问题】→【行动】→【责任方】三段式。
5. 若款式涉及多品牌联合推广,请仅聚焦我方品牌/产品相关反馈。
6. 不要重复列出指标定义,专注解读与行动。
"""
def _to_number(value: Any) -> float:
try:
if value is None or value == "":
return 0.0
return float(value)
except (TypeError, ValueError):
return 0.0
def safe_div(num: Any, denom: Any) -> float:
try:
n = float(num)
d = float(denom)
if d == 0:
return 0.0
return n / d
except (TypeError, ValueError):
return 0.0
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------
def aggregate_style_metrics(notes: list[dict[str, Any]]) -> dict[str, Any]:
"""Sum raw engagement counts across all notes of a style.
Returns:
{
"note_count": int,
"total_likes": int, "total_favorites": int, "total_comments": int,
"total_shares": int, "total_views": int,
"engagement_rate": float, # (likes+comments+favorites+shares) / views
"platform_breakdown": {"bilibili": n, "douyin": n, "xiaohongshu": n},
"creator_breakdown": {creator_name: n}, # top creators by note count
"notes_meta": [{"id", "platform", "title", "like_count", "comment_count"}, ...]
}
"""
totals = {"likes": 0.0, "favorites": 0.0, "comments": 0.0, "shares": 0.0, "views": 0.0}
platforms = {"bilibili": 0, "douyin": 0, "xiaohongshu": 0}
creators: dict[str, int] = {}
notes_meta: list[dict[str, Any]] = []
for n in notes:
likes = _to_number(n.get("like_count"))
favs = _to_number(n.get("favorite_count"))
cmts = _to_number(n.get("comment_count")) or _to_number(n.get("comment_count_total"))
shares = _to_number(n.get("share_count"))
views = _to_number(n.get("view_count"))
totals["likes"] += likes
totals["favorites"] += favs
totals["comments"] += cmts
totals["shares"] += shares
totals["views"] += views
plat = n.get("platform") or "unknown"
platforms[plat] = platforms.get(plat, 0) + 1
creator = n.get("creator_name") or "未指定"
creators[creator] = creators.get(creator, 0) + 1
notes_meta.append({
"id": n.get("id"),
"platform": plat,
"title": (n.get("title") or "")[:60],
"like_count": int(likes) if likes else 0,
"comment_count": int(cmts) if cmts else 0,
"creator_name": creator,
})
engagement = safe_div(
totals["likes"] + totals["comments"] + totals["favorites"] + totals["shares"],
totals["views"],
)
# Top creators by note count
top_creators = dict(sorted(creators.items(), key=lambda kv: -kv[1])[:5])
return {
"note_count": len(notes),
"total_likes": int(totals["likes"]),
"total_favorites": int(totals["favorites"]),
"total_comments": int(totals["comments"]),
"total_shares": int(totals["shares"]),
"total_views": int(totals["views"]),
"engagement_rate": engagement,
"platform_breakdown": platforms,
"creator_breakdown": top_creators,
"notes_meta": notes_meta,
}
def merge_style_distribution(per_note_distributions: list[dict[str, Any]]) -> dict[str, Any]:
"""Element-wise sum of overall + relevant dicts across notes."""
overall: dict[str, int] = {k: 0 for k in CATEGORY_LABELS}
relevant: dict[str, int] = {k: 0 for k in CATEGORY_LABELS}
relevant_total = 0
for d in per_note_distributions:
if not isinstance(d, dict):
continue
for k, v in (d.get("overall") or {}).items():
overall[k] = overall.get(k, 0) + int(v or 0)
for k, v in (d.get("relevant") or {}).items():
relevant[k] = relevant.get(k, 0) + int(v or 0)
relevant_total += int(d.get("relevant_total") or 0)
return {"overall": overall, "relevant": relevant, "relevant_total": relevant_total}
def _pct(v: float) -> str:
return f"{v * 100:.2f}%" if v else "—"
def _num(v: float) -> str:
try:
return f"{int(v):,}"
except (TypeError, ValueError):
return "—"
def _format_distribution_md(d: dict[str, Any], total_filtered: int) -> str:
"""Mirror analyze_note._keyword_distribution_markdown."""
overall = d.get("overall", {})
relevant = d.get("relevant", {})
relevant_total = d.get("relevant_total", 0)
if not overall:
return "(无关键词分布数据)"
def fmt(m: dict[str, int]) -> str:
return "\n".join(f"- {CATEGORY_LABELS[k]}:{m.get(k, 0)} 条" for k in CATEGORY_LABELS)
return "\n".join([
f"### 全评论({total_filtered} 条)",
fmt(overall),
"",
f"### 我方产品相关({relevant_total} 条)",
fmt(relevant) if relevant_total else "_(无评论明确提及我方产品/品牌)_",
])
def _format_metrics_md(agg: dict[str, Any]) -> str:
plat = agg["platform_breakdown"]
plat_str = " / ".join(f"{k} {v}" for k, v in plat.items() if v) or "—"
creator_str = " / ".join(f"{k} {v}" for k, v in agg["creator_breakdown"].items()) or "—"
return "\n".join([
"| 维度 | 指标 | 数值 |",
"| --- | --- | --- |",
f"| 规模 | 笔记总数 | {agg['note_count']} |",
f"| 规模 | 平台分布 | {plat_str} |",
f"| 规模 | 主要达人 | {creator_str} |",
f"| 基础 | 总点赞 | {_num(agg['total_likes'])} |",
f"| 基础 | 总收藏 | {_num(agg['total_favorites'])} |",
f"| 基础 | 总评论 | {_num(agg['total_comments'])} |",
f"| 基础 | 总分享 | {_num(agg['total_shares'])} |",
f"| 传播 | 总曝光量 | {_num(agg['total_views']) if agg['total_views'] else '未采集'} |",
f"| 互动率 | 综合互动率 | {_pct(agg['engagement_rate'])} |",
])
# ---------------------------------------------------------------------------
# Prompt building
# ---------------------------------------------------------------------------
def sample_representative_comments(
per_note_comments: list[list[str]],
per_note_meta: list[dict[str, Any]],
*,
max_total: int = 30,
per_note_cap: int = 2,
) -> list[str]:
"""Pick up to `max_total` filtered comments across notes.
Tries to take `per_note_cap` from each note, prioritizing longer comments
(proxy for substance). If still under `max_total`, fills with remaining
longer comments.
"""
all_comments: list[tuple[str, dict[str, Any]]] = []
for comments, meta in zip(per_note_comments, per_note_meta):
# Sort by length desc; take up to per_note_cap
sorted_c = sorted(comments, key=len, reverse=True)[:per_note_cap]
for c in sorted_c:
all_comments.append((c, meta))
# Final sort: longer first, then by note id asc for stability
all_comments.sort(key=lambda cm: (-len(cm[0]), cm[1].get("id") or 0))
return [c for c, _ in all_comments[:max_total]]
def build_style_rollup_prompt(
style_name: str,
brand: str,
aggregated: dict[str, Any],
merged_distribution: dict[str, Any],
sample_comments: list[str],
note_summaries: list[str],
) -> str:
"""Build user_content for the style-level LLM call.
`note_summaries` are 1-2 line "title | platform | likes | comments" snippets
to give the LLM a sense of which notes exist.
"""
target_label = brand or style_name
lines: list[str] = [
f"本款式「{style_name}」共采集 {aggregated['note_count']} 篇笔记,跨 {sum(1 for v in aggregated['platform_breakdown'].values() if v)} 个平台。",
f"以下是基于这 {aggregated['note_count']} 篇笔记的合并数据 + 代表性评论样本,请输出款式整体分析报告。",
"",
"## 款式汇总数据",
_format_metrics_md(aggregated),
"",
"## 关键词分布(合并所有笔记)",
_format_distribution_md(
merged_distribution,
sum((m or {}).get("overall", {}).get(k, 0)
for m in [merged_distribution]
for k in CATEGORY_LABELS),
),
"",
f"## {aggregated['note_count']} 篇笔记概览(标题 | 平台 | 点赞 | 评论)",
]
for meta in aggregated["notes_meta"]:
lines.append(
f"- #{meta['id']} | {meta['platform']} | 点赞 {meta['like_count']} | 评论 {meta['comment_count']} | {meta['title']}"
)
if sample_comments:
lines.extend([
"",
f"## 代表性评论样本(共 {len(sample_comments)} 条,来自多篇笔记)",
])
for c in sample_comments:
lines.append(f"- {c}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# LLM call
# ---------------------------------------------------------------------------
def analyze_style(
style_name: str,
brand: str,
aggregated: dict[str, Any],
merged_distribution: dict[str, Any],
sample_comments: list[str],
note_summaries: list[str],
*,
doc_text_limit: int = 6000,
) -> str:
"""Call Hermes for the style-level rollup analysis."""
if not aggregated["note_count"]:
return "(该款式暂无笔记可分析)"
log(
f"调用 Hermes 生成款式整体分析({style_name},{aggregated['note_count']} 篇笔记,"
f"{aggregated['total_comments']} 条总评论)..."
)
user_content = build_style_rollup_prompt(
style_name, brand, aggregated, merged_distribution, sample_comments, note_summaries,
)
target_term = brand or style_name
brand_block = ""
if target_term:
brand_block = f"""
【实体隔离】
本次分析的目标:品牌「{brand or '未指定'}」、产品「{style_name}」。
本款式可能涉及多品牌联合推广,请严格将分析对象限定为我方品牌/产品:
- 仅将「{target_term}」相关的信息计入种草指标
- 评论中涉及的其他品牌或产品,仅作为场景搭配或联合推广的陪衬
"""
system_prompt = ANALYSIS_SYSTEM_PROMPT + brand_block + "\n\n" + STYLE_ROLLUP_DEEP_PROMPT
result = call_hermes_analyzer(system_prompt, user_content)
if len(result) > doc_text_limit:
result = result[:doc_text_limit] + "\n\n...(内容过长,已截断)"
return result
@@ -0,0 +1,37 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Sync creator attributes and cooperation records from Feishu to PostgreSQL
REM Runs after daily scrape to pick up new creators/notes/cooperations
REM Register: schtasks /Create /SC DAILY /TN YingxiaoYunying_SyncCooperations /TR %PROJECT_DIR%\data\tools\sync_cooperations.bat /ST 09:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\sync_cooperations_%TS%.log
echo === sync_cooperations started at %date% %time% === > "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
call %PYTHON% data\tools\sync_cooperations.py --refresh-mapping >> "%LOG_FILE%" 2>&1
set RC=%ERRORLEVEL%
echo === sync_cooperations finished at %date% %time% exit=%RC% === >> "%LOG_FILE%"
endlocal & exit /b %RC%
@@ -0,0 +1,463 @@
#!/usr/bin/env python
"""
sync_cooperations.py — 从飞书款式合作表同步达人属性和合作记录到数据库
====================================================================
遍历 35 个款式的飞书多维表格,对每一行:
1. upsert cmt_creators (达人属性: 名称/平台/粉丝数/主页链接/微信号/类型)
2. upsert cmt_cooperations (合作记录: 费用/内容方向/曝光量/数据表现等)
幂等: feishu_record_id 为唯一键,重复运行只更新不重复插入。
用法:
python sync_cooperations.py # 正式同步
python sync_cooperations.py --dry-run # 仅预览
python sync_cooperations.py --style 7 # 只同步指定款式
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
import psycopg
from dotenv import load_dotenv
BASE_DIR = PATHS.tools_root
DATA_DIR = PATHS.raw_root
load_dotenv(PATHS.state_root / "config/db.env")
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping # noqa: E402
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_db_config # noqa: E402
DB_CONFIG = get_db_config()
PLATFORM_MAP = {
"小红书": "xiaohongshu",
"抖音": "douyin",
"B站": "bilibili",
}
# 飞书逻辑字段 → cmt_creators 列
CREATOR_FIELD_MAP = {
"creator_name": "name",
"platform": "platform",
"creator_id": "platform_id",
"follower_count": "follower_count",
"profile_url": "homepage_url",
"wechat": "wechat",
"account_type": "account_type",
}
# 飞书逻辑字段 → cmt_cooperations 列
COOP_FIELD_MAP = {
"cooperation_date": "cooperation_date",
"cost": "cooperation_cost",
"ad_spend": "ad_spend",
"content_direction": "content_direction",
"note_title": "note_title",
"note_url": "note_url",
"publish_time": "publish_time",
"read_count_auto": "exposure_count",
"engagement_count": "engagement_count",
"data_performance": "data_performance",
"cpm": "cpm",
"tracking_no": "tracking_number",
"is_paid": "is_paid",
"is_new_direction": "is_new_direction",
}
def _log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(safe, flush=True)
def parse_follower_count(text: str) -> int | None:
"""解析粉丝数文本: '3.7万' → 37000, '12.4w' → 124000, '5600' → 5600"""
if not text or not text.strip():
return None
text = text.strip()
m = re.match(r"([\d.]+)\s*万", text)
if m:
return int(float(m.group(1)) * 10000)
m = re.match(r"([\d.]+)\s*w", text, re.IGNORECASE)
if m:
return int(float(m.group(1)) * 10000)
m = re.match(r"([\d,]+)", text)
if m:
return int(m.group(1).replace(",", ""))
return None
def parse_engagement_count(text: str) -> int | None:
"""解析互动赞藏数: '68.5万' → 685000"""
return parse_follower_count(text)
def cell_text(cell) -> str:
"""从飞书单元格提取纯文本"""
if cell is None:
return ""
if isinstance(cell, str):
return cell.strip()
if isinstance(cell, list):
# select/multiselect/link 等返回数组
parts = []
for item in cell:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
parts.append(item.get("text", item.get("name", str(item))))
else:
parts.append(str(item))
return ",".join(parts)
if isinstance(cell, dict):
return cell.get("text", str(cell))
return str(cell).strip()
def cell_number(cell) -> float | None:
"""从飞书单元格提取数字"""
if cell is None:
return None
if isinstance(cell, (int, float)):
return float(cell)
if isinstance(cell, str):
try:
return float(cell.replace(",", ""))
except ValueError:
return None
return None
def cell_bool(cell) -> bool:
"""从飞书单元格提取布尔值"""
if cell is None:
return False
if isinstance(cell, bool):
return cell
if isinstance(cell, str):
return cell.lower() in ("true", "1", "yes")
return bool(cell)
def cell_date(cell) -> str | None:
"""从飞书单元格提取日期 (返回 YYYY-MM-DD 或 None)"""
text = cell_text(cell)
if not text:
return None
# 飞书 datetime 格式: "2026-05-06 00:00:00"
m = re.match(r"(\d{4}-\d{2}-\d{2})", text)
if m:
return m.group(1)
return None
def resolve_platform(raw: str) -> str:
"""飞书投放平台 → 数据库 platform 值"""
for cn, en in PLATFORM_MAP.items():
if cn in raw:
return en
return raw.lower() if raw else ""
def get_style_id(cur, style_name: str) -> int | None:
"""按款式名查 style_id"""
cur.execute("SELECT id FROM cmt_styles WHERE name = %s", (style_name,))
row = cur.fetchone()
return row[0] if row else None
def ensure_style(cur, style_name: str) -> int:
"""确保款式存在,不存在则插入"""
sid = get_style_id(cur, style_name)
if sid:
return sid
cur.execute(
"INSERT INTO cmt_styles (name) VALUES (%s) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id",
(style_name,),
)
return cur.fetchone()[0]
def upsert_creator(cur, name: str, platform: str, attrs: dict) -> int:
"""upsert 达人,返回 creator_id"""
# 尝试按 (name, platform) 查找
cur.execute(
"SELECT id FROM cmt_creators WHERE name = %s AND platform = %s",
(name, platform),
)
row = cur.fetchone()
if row:
creator_id = row[0]
# 更新属性(只更新非空值)
updates = {}
for col, val in attrs.items():
if val and val != "":
updates[col] = val
if updates:
set_clause = ", ".join(f"{k} = %s" for k in updates)
vals = list(updates.values()) + [creator_id]
cur.execute(f"UPDATE cmt_creators SET {set_clause} WHERE id = %s", vals)
return creator_id
# 插入新达人
cols = ["name", "platform"] + list(attrs.keys())
placeholders = ", ".join(["%s"] * len(cols))
col_str = ", ".join(cols)
vals = [name, platform] + list(attrs.values())
cur.execute(
f"INSERT INTO cmt_creators ({col_str}) VALUES ({placeholders}) RETURNING id",
vals,
)
return cur.fetchone()[0]
def upsert_cooperation(cur, style_id: int, creator_id: int,
feishu_record_id: str, attrs: dict) -> bool:
"""upsert 合作记录,返回是否插入(True)或更新(False)"""
if not feishu_record_id:
return False
# 检查是否已存在
cur.execute(
"SELECT id FROM cmt_cooperations WHERE feishu_record_id = %s",
(feishu_record_id,),
)
row = cur.fetchone()
if row:
# 更新
updates = {"style_id": style_id, "creator_id": creator_id}
for col, val in attrs.items():
updates[col] = val
set_clause = ", ".join(f"{k} = %s" for k in updates)
vals = list(updates.values()) + [row[0]]
cur.execute(f"UPDATE cmt_cooperations SET {set_clause} WHERE id = %s", vals)
return False
# 插入
cols = ["style_id", "creator_id", "feishu_record_id"] + list(attrs.keys())
placeholders = ", ".join(["%s"] * len(cols))
col_str = ", ".join(cols)
vals = [style_id, creator_id, feishu_record_id] + list(attrs.values())
cur.execute(
f"INSERT INTO cmt_cooperations ({col_str}) VALUES ({placeholders})",
vals,
)
return True
def sync_table(cur, table_info: dict, dry_run: bool = False) -> dict:
"""同步一个款式表的所有记录"""
style_name = table_info.get("name", "")
base_token = table_info.get("base_token", "")
table_id = table_info.get("table_id", "")
field_map = table_info.get("field_map", {})
if not base_token or not table_id:
return {"style": style_name, "error": "missing base_token/table_id"}
style_id = ensure_style(cur, style_name)
stats = {"style": style_name, "creators": 0, "cooperations_new": 0, "cooperations_updated": 0, "skipped": 0, "skipped_no_creator": 0}
# 拉取全部记录
records = feishu_mapping._record_list_all(base_token, table_id)
_log(f" [{style_name}] 拉到 {len(records)} 条记录")
# 构建 field_id → logical_key 反向映射
fid_to_lkey = {}
for lkey, finfo in field_map.items():
fid_to_lkey[finfo["field_id"]] = lkey
for rec in records:
record_id = rec.get("record_id", "")
# _record_list_all 返回扁平结构: record_id + 各 field_id 直接在顶层
# 提取逻辑字段值
row = {}
for fid, val in rec.items():
if fid == "record_id":
continue
lkey = fid_to_lkey.get(fid)
if lkey:
row[lkey] = val
# 达人名称
creator_name = cell_text(row.get("creator_name", ""))
if not creator_name:
stats["skipped_no_creator"] += 1
continue
# 平台
raw_platform = cell_text(row.get("platform", ""))
platform = resolve_platform(raw_platform)
# 达人属性
creator_attrs = {}
raw_follower = cell_text(row.get("follower_count", ""))
if raw_follower:
creator_attrs["follower_count"] = raw_follower
follower_num = parse_follower_count(raw_follower)
if follower_num is not None:
creator_attrs["follower_count_num"] = follower_num
platform_id = cell_text(row.get("creator_id", ""))
if platform_id:
creator_attrs["platform_id"] = platform_id
homepage = cell_text(row.get("profile_url", ""))
if homepage:
creator_attrs["homepage_url"] = homepage
wechat = cell_text(row.get("wechat", ""))
if wechat:
creator_attrs["wechat"] = wechat
account_type = cell_text(row.get("account_type", ""))
if account_type:
creator_attrs["account_type"] = account_type
if not dry_run:
creator_id = upsert_creator(cur, creator_name, platform, creator_attrs)
stats["creators"] += 1
else:
stats["creators"] += 1
creator_id = -1 # dry-run placeholder
# 合作记录属性
coop_attrs = {}
coop_date = cell_date(row.get("cooperation_date", ""))
if coop_date:
coop_attrs["cooperation_date"] = coop_date
cost = cell_number(row.get("cost", ""))
if cost is not None:
coop_attrs["cooperation_cost"] = cost
ad_spend = cell_number(row.get("ad_spend", ""))
if ad_spend is not None:
coop_attrs["ad_spend"] = ad_spend
content_dir = cell_text(row.get("content_direction", ""))
if content_dir:
coop_attrs["content_direction"] = content_dir
note_title = cell_text(row.get("note_title", ""))
if note_title:
coop_attrs["note_title"] = note_title
note_url = cell_text(row.get("note_url", ""))
if note_url:
coop_attrs["note_url"] = note_url
pub_time = cell_text(row.get("publish_time", ""))
if pub_time:
# 飞书格式 "2026-06-09 00:00:00"
coop_attrs["publish_time"] = pub_time
exposure = cell_number(row.get("read_count_auto", ""))
if exposure is not None:
coop_attrs["exposure_count"] = int(exposure)
raw_engagement = cell_text(row.get("engagement_count", ""))
if raw_engagement:
coop_attrs["engagement_count"] = raw_engagement
eng_num = parse_engagement_count(raw_engagement)
if eng_num is not None:
coop_attrs["engagement_count_num"] = eng_num
data_perf = cell_text(row.get("data_performance", ""))
if data_perf:
coop_attrs["data_performance"] = data_perf
cpm_val = cell_number(row.get("cpm", ""))
if cpm_val is not None:
coop_attrs["cpm"] = cpm_val
tracking = cell_text(row.get("tracking_no", ""))
if tracking:
coop_attrs["tracking_number"] = tracking
is_paid = cell_bool(row.get("is_paid", False))
coop_attrs["is_paid"] = is_paid
is_new_dir = cell_bool(row.get("is_new_direction", False))
coop_attrs["is_new_direction"] = is_new_dir
if not dry_run:
is_new = upsert_cooperation(cur, style_id, creator_id, record_id, coop_attrs)
if is_new:
stats["cooperations_new"] += 1
else:
stats["cooperations_updated"] += 1
else:
stats["cooperations_new"] += 1
return stats
def main():
parser = argparse.ArgumentParser(description="从飞书款式合作表同步达人属性和合作记录到数据库")
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入数据库")
parser.add_argument("--style", type=int, help="只同步指定款式编号 (如 7=宙斯)")
parser.add_argument("--refresh-mapping", action="store_true",
help="强制刷新 mapping (发现新款/新表/新字段); 不加则用缓存")
args = parser.parse_args()
_log("=== sync_cooperations.py 开始 ===")
if args.dry_run:
_log("[DRY-RUN] 仅预览模式")
# 加载 mapping; 加 --refresh-mapping 时强制刷新以发现新款/新表/新字段
if args.refresh_mapping:
_log("[step 0] 刷新 feishu_mapping (发现新款/新表/新字段)...")
mapping = feishu_mapping.refresh_mapping(force=True)
else:
mapping = feishu_mapping.load_mapping()
tables = mapping.get("tables", [])
if args.style:
tables = [t for t in tables if t.get("index") == args.style]
if not tables:
_log(f"未找到款式编号 {args.style}")
return
_log(f"共 {len(tables)} 个款式待同步")
conn = psycopg.connect(**DB_CONFIG)
cur = conn.cursor()
total_creators = 0
total_coop_new = 0
total_coop_upd = 0
for t in tables:
try:
stats = sync_table(cur, t, dry_run=args.dry_run)
_log(f" [{stats['style']}] 达人={stats['creators']}, "
f"合作新增={stats['cooperations_new']}, 更新={stats['cooperations_updated']}, "
f"跳过={stats['skipped']}")
total_creators += stats["creators"]
total_coop_new += stats["cooperations_new"]
total_coop_upd += stats["cooperations_updated"]
except Exception as e:
_log(f" [ERROR] {t.get('name', '?')}: {e}")
conn.rollback()
continue
if not args.dry_run:
conn.commit()
_log(f"=== 同步完成: 达人={total_creators}, 合作新增={total_coop_new}, 更新={total_coop_upd} ===")
else:
_log(f"=== DRY-RUN 预览: 达人={total_creators}, 合作={total_coop_new} ===")
cur.close()
conn.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,472 @@
#!/usr/bin/env python
"""
把 yingxiaoyunying 每日采集的曝光量(view_count)同步到
comment-data-collector 的 cmt_notes 表。
数据源:data/v2_results/*_v2.json / *_xingtu_v2.json / *_bilibili_v2.json
(由 xingtu_scraper_v2.py / pgy_xhs_scraper_v2.py / bilibili_scraper.py 每天产出)
目标表:cmt_notes(由 D:\\comment-data-collector 维护)
本脚本只写 view_count 一个指标。like_count / comment_count / favorite_count /
share_count / scraped_at 全部由 comment-data-collector 维护,yingxiaoyunying 不碰,
避免双写冲突 / 列名歧义(收藏在 xhs 里叫 collect_count,在 cmt_notes 里对应
favorite_count,跨表语义容易错位)。
平台映射:
xingtu_v2 -> douyin
_v2(无后缀) -> xiaohongshu
_bilibili_v2 -> bilibili
INSERT 路径:对 v2_results 有但 cmt_notes 没的 record_id,先调 feishu +record-list
回填 url / title / creator_name(用款式对应多维表格的 base_token),resolve
style_id / creator_id 后 INSERT cmt_notes 行(只写必要字段)。同一物理笔记不同
record_id(URL 已存在)走 merge-by-url:把这次的 view_count 写进已存在的行。
用法:
python sync_metrics_to_cmt_notes.py # 正式同步(含 INSERT)
python sync_metrics_to_cmt_notes.py --dry-run # 仅预览
python sync_metrics_to_cmt_notes.py --no-insert-missing # 只 UPDATE 不 INSERT
"""
import argparse
import datetime as dt
import json
import os
import re
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
import psycopg
from dotenv import load_dotenv
def configure_console_output(stdout, stderr) -> None:
"""Keep Windows GBK logs alive when creator names contain Unicode symbols."""
for stream in (stdout, stderr):
if stream is None:
continue
try:
stream.reconfigure(errors="replace")
except (AttributeError, OSError, ValueError):
pass
configure_console_output(sys.stdout, sys.stderr)
BASE_DIR = PATHS.tools_root
DATA_DIR = PATHS.raw_root
V2_DIR = PATHS.normalized_root / "v2_results"
load_dotenv(PATHS.state_root / "config/db.env")
from gyxx_flow.modules.content_marketing.runtime.data.tools.v2_filename import parse_v2_filename # noqa: E402
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping # noqa: E402
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_db_config # noqa: E402
DB_CONFIG = get_db_config()
def parse_int(v) -> int | None:
"""'1.2万' / '6,592' / '2422' / 2422 -> int"""
if v is None:
return None
if isinstance(v, (int, float)):
return int(v)
txt = str(v).strip()
if not txt:
return None
m = re.match(r"^([\d,.]+)\s*(万|w|W)?", txt)
if not m:
return None
num = float(m.group(1).replace(",", ""))
if m.group(2):
num *= 10000
return int(num)
def get_conn():
return psycopg.connect(**DB_CONFIG)
# ============================================================
# INSERT 路径辅助:回填 feishu + 解析 FK
# ============================================================
_URL_RE = re.compile(r"https?://[^\s)>\]]+")
def _extract_url(text) -> str | None:
"""从飞书链接单元格 '[url](url)' 或裸 URL 中提取纯 URL"""
if not text:
return None
s = str(text).strip()
if not s:
return None
m = _URL_RE.search(s)
return m.group(0) if m else s
def _parse_publish_time(text) -> dt.datetime | None:
"""飞书发布时间 '2026-06-09 00:00:00' / '2026-06-09' / 毫秒时间戳 -> datetime"""
if text is None:
return None
if isinstance(text, (int, float)):
try:
return dt.datetime.fromtimestamp(int(text) / 1000)
except Exception:
return None
s = str(text).strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"):
try:
return dt.datetime.strptime(s, fmt)
except ValueError:
continue
return None
# (base_token, table_id) -> {record_id: record_dict}
_BACKFILL_CACHE: dict[tuple[str, str], dict[str, dict]] = {}
def _backfill_record(mapping: dict, style_name: str, rid: str) -> dict | None:
"""从飞书合作达人多维表格拉指定 record_id 的完整字段。
返回 {title, url, publish_time, creator_name} 或 None(找不到/拉取失败)。
同一 (base_token, table_id) 在本进程内只拉一次。
"""
style = next((t for t in mapping.get("tables", []) if t["name"] == style_name), None)
if not style:
return None
bt, tid = style["base_token"], style["table_id"]
fm = style.get("field_map", {})
# note_url 必须有; creator_name 自营表可能没有,用款式名兜底
if not fm.get("note_url"):
return None
cache_key = (bt, tid)
if cache_key not in _BACKFILL_CACHE:
try:
rows = feishu_mapping._record_list_all(bt, tid)
except Exception as exc:
print(f" [backfill-err] {style_name}({bt}/{tid}): {exc}")
return None
_BACKFILL_CACHE[cache_key] = {r.get("record_id"): r for r in rows}
rec = _BACKFILL_CACHE[cache_key].get(rid)
if not rec:
return None
def gv(key: str):
fid = fm.get(key, {}).get("field_id")
return rec.get(fid) if fid else None
creator_name = gv("creator_name") or f"自营-{style_name}"
# title 剥掉 markdown 链接 [text](url) -> 只留 text,再剥裸 URL,避免超 varchar(512)
raw_title = gv("note_title")
if isinstance(raw_title, list):
raw_title = " ".join(str(x) for x in raw_title if x)
title_clean = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", str(raw_title or ""))
title_clean = re.sub(r"https?://[^\s))\]]+", "", title_clean).strip()
return {
"title": title_clean[:500],
"url": _extract_url(gv("note_url")),
"creator_name": creator_name,
"publish_time": _parse_publish_time(gv("publish_time")),
}
def _style_id_lookup(cur) -> dict[str, int]:
"""name -> cmt_styles.id 映射(一次查全表)"""
cur.execute("SELECT id, name FROM cmt_styles")
return {name: id for id, name in cur.fetchall()}
def _ensure_creator(cur, name: str | None) -> int | None:
"""按 name 查找或新建 cmt_creators 行,返回 id。name 为空返回 None。"""
if not name:
return None
cur.execute("SELECT id FROM cmt_creators WHERE name = %s", (name,))
row = cur.fetchone()
if row:
return row[0]
cur.execute(
"INSERT INTO cmt_creators (name, created_at, updated_at) "
"VALUES (%s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id",
(name,),
)
return cur.fetchone()[0] if cur.rowcount else None
def detect_platform(path: Path) -> tuple[str | None, bool]:
"""文件名后缀 -> (cmt_notes.platform, self_operated)"""
_, _, plat, self_op = parse_v2_filename(path.name)
return {"xhs": "xiaohongshu", "xt": "douyin", "bili": "bilibili"}.get(plat), self_op
def _is_syncable_item(item: dict) -> bool:
"""Only sync rows that represent a real matched scrape result."""
if not isinstance(item, dict):
return False
if item.get("error"):
return False
if item.get("matched") is False:
return False
if item.get("ok") is False:
return False
return True
def collect_metrics_from_v2() -> list[dict]:
"""扫一遍 v2_results,聚合每个 record_id 的最新指标"""
out: list[dict] = []
for path in sorted(V2_DIR.glob("*.json")):
result = detect_platform(path)
if not result or result[0] is None:
continue
platform, self_operated = result
idx, style_name, _, _ = parse_v2_filename(path.name)
if idx is None:
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if platform == "bilibili":
items = data.get("details") or []
for item in items:
if not _is_syncable_item(item):
continue
rid = item.get("record_id")
view_count = parse_int(item.get("play_count"))
if not rid or view_count is None:
continue
out.append({
"src_platform": "bili",
"dst_platform": platform,
"style_idx": idx,
"style_name": style_name,
"record_id": rid,
"creator_name": item.get("creator"),
"view_count": view_count,
"like_count": None,
"collect_count": None,
"comment_count": None,
"self_operated": self_operated,
})
else:
items = data.get("results") or []
for item in items:
if not _is_syncable_item(item):
continue
rid = item.get("record_id")
view_count = parse_int(item.get("play_count") or item.get("read_count"))
if not rid or view_count is None:
continue
out.append({
"src_platform": "xt" if platform == "douyin" else "xhs",
"dst_platform": platform,
"style_idx": idx,
"style_name": style_name,
"record_id": rid,
"creator_name": item.get("creator"),
"view_count": view_count,
"like_count": parse_int(item.get("like_count")),
"collect_count": parse_int(item.get("collect_count")),
"comment_count": parse_int(item.get("comment_count")),
"self_operated": self_operated,
})
return out
def sync(dry_run: bool = False, insert_missing: bool = True) -> dict:
metrics = collect_metrics_from_v2()
print(f"[sync] v2_results 中可同步的命中记录: {len(metrics)} 条")
matched = updated = skipped_not_found = 0
inserted = skipped_insert = 0
with get_conn() as conn:
with conn.cursor() as cur:
style_name_to_id: dict[str, int] = _style_id_lookup(cur)
creator_id_cache: dict[str, int | None] = {}
mapping = None
self_mapping = None
if insert_missing:
try:
mapping = feishu_mapping.load_mapping(force_refresh=False)
except Exception as exc:
print(f"[WARN] feishu_mapping 加载失败,INSERT 路径禁用: {exc}")
insert_missing = False
try:
self_mapping = feishu_mapping.load_mapping(force_refresh=False, self_operated=True)
except Exception as exc:
print(f"[WARN] feishu_mapping(自营) 加载失败: {exc}")
for row in metrics:
record_id = row["record_id"]
dst_platform = row["dst_platform"]
cur.execute(
"""
SELECT id, view_count FROM cmt_notes
WHERE feishu_record_id = %s AND platform = %s
""",
(record_id, dst_platform),
)
note = cur.fetchone()
if not note:
skipped_not_found += 1
if not insert_missing:
continue
# 选择正确的 mapping: 自营用 self_mapping, 合作用 mapping
active_mapping = self_mapping if row.get("self_operated") else mapping
if not active_mapping:
continue
# ---- INSERT path ----
style_name = row.get("style_name")
style_id = style_name_to_id.get(style_name or "")
if not style_id:
print(f" [skip-insert] rid={record_id}: style '{style_name}' 不在 cmt_styles")
skipped_insert += 1
continue
bf = _backfill_record(active_mapping, style_name, record_id)
if not bf or not bf.get("url"):
print(f" [skip-insert] rid={record_id}: feishu 回填失败或无 URL")
skipped_insert += 1
continue
creator_name = (bf.get("creator_name") or row.get("creator_name") or "").strip()
if not creator_name:
print(f" [skip-insert] rid={record_id}: creator 名为空")
skipped_insert += 1
continue
if dry_run:
cur.execute("SELECT id FROM cmt_creators WHERE name = %s", (creator_name,))
creator_row = cur.fetchone()
creator_id = creator_row[0] if creator_row else None
else:
if creator_name not in creator_id_cache:
creator_id_cache[creator_name] = _ensure_creator(cur, creator_name)
creator_id = creator_id_cache[creator_name]
if not creator_id:
reason = "creator 不存在(dry-run 不新建)" if dry_run else "creator 名为空"
print(f" [skip-insert] rid={record_id}: {reason}")
skipped_insert += 1
continue
# 先查 URL 是否已存在(避免 INSERT 触发 UNIQUE 整事务回滚)
cur.execute(
"SELECT id, view_count FROM cmt_notes WHERE url = %s LIMIT 1",
(bf["url"],),
)
url_row = cur.fetchone()
if url_row:
old_id, old_view = url_row
new_view = row.get("view_count")
if new_view is not None and new_view != old_view:
if dry_run:
print(f" [dry-merge-by-url] note_id={old_id} view {old_view}->{new_view} (新 rid={record_id})")
else:
cur.execute(
"UPDATE cmt_notes SET view_count = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(new_view, old_id),
)
print(f" [merge-by-url] note_id={old_id} view {old_view}->{new_view} (新 rid={record_id})")
updated += 1
else:
print(f" [skip-merge] rid={record_id}: url 已存在(note_id={old_id}),view 无变化")
skipped_insert += 1
continue
if dry_run:
print(f" [dry-insert] rid={record_id} style={style_name} creator={creator_name} "
f"view={row.get('view_count')} title={str(bf.get('title') or '')[:32]}")
inserted += 1
continue
# 只写必要字段:FK + title + url + feishu_record_id + view_count
# like/comment/favorite/share/scraped_at 全留给 comment-data-collector
cur.execute(
"""
INSERT INTO cmt_notes
(style_id, creator_id, platform, title, url,
feishu_record_id, view_count, self_operated,
publish_time,
created_at, updated_at)
VALUES
(%s, %s, %s, %s, %s,
%s, %s, %s,
%s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(style_id, creator_id, dst_platform,
bf.get("title"), bf["url"], record_id,
row.get("view_count"), row.get("self_operated", False),
bf.get("publish_time")),
)
print(f" [insert] rid={record_id} style={style_name} creator={creator_name} view={row.get('view_count')}")
inserted += 1
continue
# ---- UPDATE path: 只写 view_count,其它字段(like/comment/favorite/scraped)留给 cdc ----
note_id, old_view = note
matched += 1
new_view = row["view_count"]
# 缺 publish_time 时从飞书补
cur.execute("SELECT publish_time FROM cmt_notes WHERE id = %s", (note_id,))
old_pub_row = cur.fetchone()
if old_pub_row and old_pub_row[0] is None:
try:
bf_update = _backfill_record(mapping, style_name, record_id) or \
(self_mapping and _backfill_record(self_mapping, style_name, record_id)) or {}
new_pub = bf_update.get("publish_time")
except Exception:
new_pub = None
if new_pub is not None:
cur.execute(
"UPDATE cmt_notes SET publish_time = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(new_pub, note_id),
)
if new_view is not None and new_view != old_view:
if dry_run:
print(f" [dry-run] note_id={note_id} view {old_view}->{new_view}")
else:
cur.execute(
"UPDATE cmt_notes SET view_count = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(new_view, note_id),
)
print(f" [update] note_id={note_id} view={new_view}")
updated += 1
if dry_run:
conn.rollback()
else:
conn.commit()
mode = "DRY-RUN" if dry_run else "WRITE"
insert_mode = "ON" if insert_missing else "OFF"
print(f"\n[sync] 完成 ({mode}, insert={insert_mode}): "
f"匹配={matched}, 更新={updated}, 新增={inserted}, "
f"cmt_notes 未找到={skipped_not_found}, 跳过 INSERT={skipped_insert}")
return {
"total": len(metrics),
"matched": matched,
"updated": updated,
"inserted": inserted,
"skipped_not_found": skipped_not_found,
"skipped_insert": skipped_insert,
}
def main():
parser = argparse.ArgumentParser(description="同步 v2_results 曝光/收藏量到 cmt_notes")
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入")
parser.add_argument("--no-insert-missing", action="store_true",
help="关闭 INSERT 路径(默认开启:对 v2_results 有但 cmt_notes 缺的行,回填 feishu 后 INSERT)")
args = parser.parse_args()
print(f"[sync] 模式: {'DRY-RUN' if args.dry_run else '正式写入'} "
f"insert-missing: {'OFF' if args.no_insert_missing else 'ON'}")
sync(dry_run=args.dry_run, insert_missing=not args.no_insert_missing)
if __name__ == "__main__":
main()
@@ -0,0 +1,142 @@
"""Sync the Feishu product-style classification into ``cmt_styles``.
The source Base is read with the authenticated lark-cli user identity because
the Hermes bot is not a member of that table.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
from pathlib import Path
from typing import Any
try:
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
except ModuleNotFoundError: # direct execution: python data/tools/sync_style_categories.py
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
BASE_TOKEN = "TH1JbxhfUaxetis6F4pcoqvWncd"
TABLE_ID = "tbl8q0hbIPMNn0k8"
VIEW_ID = "vew7j57rli"
NAME_FIELD_ID = "fldRqiyAJW"
CATEGORY_FIELD_ID = "fldsEuUtYb"
PROFILE = "hermes-analyzer"
NAME_ALIASES = {
"盖亚微单Pro": "盖亚微单",
"星迹&星迹2": "星迹2",
"宙斯双肩包": "宙斯",
"极星Pro": "极星pro",
"瑞白双肩包": "瑞白",
"逐星gt": "逐星GT",
"觅光双肩包": "觅光",
"云栖相机双肩包": "云栖",
"拾影双肩电脑包": "拾影双肩",
"拾影相机包": "拾影斜挎相机包",
"极星单肩包": "极星单肩",
}
def normalize_style_name(value: str) -> str:
"""Convert the Feishu lifecycle-document label to the DB canonical name."""
label = re.sub(r"^\[|\]\(.*\)$", "", (value or "").strip())
label = re.sub(r"\s*生命进程\s*", "", label)
label = re.sub(r"\s*[((]\s*新\s*[))]\s*", "", label)
label = re.sub(r"\s+", "", label).strip()
return NAME_ALIASES.get(label, label)
def extract_style_mapping(payload: dict[str, Any]) -> dict[str, str]:
"""Extract ``{canonical product name: style category}`` from CLI JSON."""
mapping: dict[str, str] = {}
for row in payload.get("data", {}).get("data", []):
if not isinstance(row, list) or len(row) < 2 or not row[0] or not row[1]:
continue
name = normalize_style_name(str(row[0]))
category_value = row[1]
category = (
str(category_value[0]).strip()
if isinstance(category_value, list) and category_value
else str(category_value).strip()
)
if not name or not category:
continue
previous = mapping.get(name)
if previous and previous != category:
raise ValueError(f"款式 {name} 同时出现多个风格:{previous} / {category}")
mapping[name] = category
return mapping
def _lark_cli_path() -> str:
appdata = os.environ.get("APPDATA")
if not appdata:
raise RuntimeError("APPDATA 未设置,无法定位 lark-cli.cmd")
return str(Path(appdata) / "npm" / "lark-cli.cmd")
def load_mapping_from_feishu() -> dict[str, str]:
command = [
_lark_cli_path(), "--profile", PROFILE, "base", "+record-list",
"--as", "user", "--base-token", BASE_TOKEN, "--table-id", TABLE_ID,
"--view-id", VIEW_ID, "--field-id", NAME_FIELD_ID,
"--field-id", CATEGORY_FIELD_ID, "--limit", "200", "--format", "json",
]
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", check=True)
payload = json.loads(result.stdout)
if not payload.get("ok"):
raise RuntimeError(f"读取飞书款式风格失败:{payload}")
mapping = extract_style_mapping(payload)
if not mapping:
raise RuntimeError("飞书表未返回任何有效的款式—风格记录")
return mapping
def sync_mapping(mapping: dict[str, str], *, dry_run: bool = False) -> dict[str, Any]:
"""Update existing styles and insert new source styles without overwriting Base links."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT name FROM cmt_styles")
existing = {row[0] for row in cur.fetchall()}
inserted = sorted(set(mapping) - existing)
updated = sorted(set(mapping) & existing)
if not dry_run:
cur.execute(
"ALTER TABLE cmt_styles ADD COLUMN IF NOT EXISTS style_category VARCHAR(64) DEFAULT ''"
)
cur.execute(
"COMMENT ON COLUMN cmt_styles.style_category IS "
"'款式风格,来源于飞书全域事项表的风格单选字段'"
)
for name, category in sorted(mapping.items()):
cur.execute(
"""
INSERT INTO cmt_styles (name, style_category)
VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE SET
style_category = EXCLUDED.style_category,
updated_at = CURRENT_TIMESTAMP
""",
(name, category),
)
conn.commit()
return {"source_count": len(mapping), "updated": updated, "inserted": inserted}
def main() -> int:
parser = argparse.ArgumentParser(description="同步飞书款式风格到 cmt_styles")
parser.add_argument("--dry-run", action="store_true", help="只核对,不写数据库")
args = parser.parse_args()
mapping = load_mapping_from_feishu()
result = sync_mapping(mapping, dry_run=args.dry_run)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,50 @@
"""v2_results/*.json 文件名解析(共享给 check_status / retry_failed / sync_metrics_to_cmt_notes)"""
import re
# 后缀越长优先级越高(避免 _bilibili_v2 被 _v2 误吃)
# 自营达人文件带 _self_ 前缀: *_self_bilibili_v2.json, *_self_xingtu_v2.json, *_self_v2.json
RE_V2_SELF_BILI = re.compile(r"^(?P<idx>\d+)-.+_self_bilibili_v2\.json$")
RE_V2_SELF_XT = re.compile(r"^(?P<idx>\d+)-.+_self_xingtu_v2\.json$")
RE_V2_SELF_PGY = re.compile(r"^(?P<idx>\d+)-.+_self_v2\.json$")
RE_V2_BILI = re.compile(r"^(?P<idx>\d+)-.+_bilibili_v2\.json$")
RE_V2_XT = re.compile(r"^(?P<idx>\d+)-.+_xingtu_v2\.json$")
RE_V2_PGY = re.compile(r"^(?P<idx>\d+)-.+_v2\.json$") # 必须放最后兜底
SUFFIX_LEN = {
"self_bili": len("_self_bilibili_v2.json"),
"self_xt": len("_self_xingtu_v2.json"),
"self_xhs": len("_self_v2.json"),
"bili": len("_bilibili_v2.json"),
"xt": len("_xingtu_v2.json"),
"xhs": len("_v2.json"),
}
def parse_v2_filename(name: str) -> tuple[int | None, str, str | None, bool]:
"""01-盖亚斜挎_v2.json -> (1, '盖亚斜挎', 'xhs', False)
02-极星pro_self_xingtu_v2.json -> (2, '极星pro', 'xt', True)
03-盖亚微单_bilibili_v2.json -> (3, '盖亚微单', 'bili', False)
不匹配返回 (None, '', None, False)
第 4 个返回值: self_operated (True=自营达人)
"""
if RE_V2_SELF_BILI.match(name):
m, plat, self_op = RE_V2_SELF_BILI.match(name), "bili", True
elif RE_V2_SELF_XT.match(name):
m, plat, self_op = RE_V2_SELF_XT.match(name), "xt", True
elif RE_V2_SELF_PGY.match(name):
m, plat, self_op = RE_V2_SELF_PGY.match(name), "xhs", True
elif RE_V2_BILI.match(name):
m, plat, self_op = RE_V2_BILI.match(name), "bili", False
elif RE_V2_XT.match(name):
m, plat, self_op = RE_V2_XT.match(name), "xt", False
elif RE_V2_PGY.match(name):
m, plat, self_op = RE_V2_PGY.match(name), "xhs", False
else:
return None, "", None, False
idx = int(m.group("idx"))
suffix_key = f"self_{plat}" if self_op else plat
stem = name[: -SUFFIX_LEN[suffix_key]]
parts = stem.split("-", 1)
name_only = parts[1] if len(parts) == 2 else ""
return idx, name_only, plat, self_op
@@ -0,0 +1,105 @@
"""
validate_mapping.py
-------------------
校验 data/config/款式_多维表格_对照.json 中的 field_id 跟飞书实际字段是否匹配。
不匹配报警(但允许差异,因为有些表故意没字段)。
用法:
python validate_mapping.py # 检查 + 打印结果
python validate_mapping.py --strict # 严格模式:任何差异都返回非 0
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
LARK = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK):
LARK = "lark-cli.cmd"
MAPPING_CACHE_PATH = PATHS.normalized_root / "mappings" / "款式_多维表格_对照.json"
MAPPING_SEED_PATH = PATHS.config_root / "款式_多维表格_对照.json"
MAPPING_PATH = MAPPING_CACHE_PATH if MAPPING_CACHE_PATH.exists() else MAPPING_SEED_PATH
ENV = {**os.environ, "LARK_CLI_NO_PROXY": "1", "PYTHONIOENCODING": "utf-8"}
def fetch_fields(base_token: str, table_id: str) -> list[dict]:
r = subprocess.run(
[LARK, "base", "+field-list",
"--base-token", base_token,
"--table-id", table_id,
"--format", "json"],
capture_output=True, env=ENV, timeout=60
)
if r.returncode != 0:
return []
raw = r.stdout
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
try:
d = json.loads(raw)
return d.get("data", {}).get("fields", []) if d.get("ok") else []
except Exception:
return []
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--strict", action="store_true", help="任何差异都返回非 0 退出码")
args = ap.parse_args()
if not MAPPING_PATH.exists():
print(f"[FATAL] 找不到 {MAPPING_PATH}")
sys.exit(1)
mapping = json.loads(MAPPING_PATH.read_text(encoding="utf-8"))
tables = mapping.get("tables", [])
print(f"校验 {len(tables)} 张表...\n")
issues = []
for t in tables:
idx = t["index"]
name = t["name"]
fmap = t.get("field_map", {})
# 拉取实际字段
actual_fields = fetch_fields(t["base_token"], t["table_id"])
if not actual_fields:
issues.append(f"[{idx:02d}] {name}: 拉取失败,跳过")
continue
actual_by_id = {f["id"]: f["name"] for f in actual_fields}
actual_by_name = {f["name"]: f["id"] for f in actual_fields}
# 1) 校验 JSON 里的 field_id 仍然存在
for logical, info in fmap.items():
fid = info.get("field_id")
fname = info.get("field_name")
if fid not in actual_by_id:
issues.append(
f"[{idx:02d}] {name}: 字段 {logical}({fname}) 的 field_id={fid} 不存在了!"
)
elif actual_by_id[fid] != fname:
issues.append(
f"[{idx:02d}] {name}: 字段 {logical}: JSON 说 {fname}({fid}) 但飞书是 {actual_by_id[fid]}"
)
# 2) 校验核心字段都映射上了
for key in ["creator_name", "read_count_7d"]:
if key not in fmap:
issues.append(f"[{idx:02d}] {name}: 缺核心字段 {key}")
# 报告
if not issues:
print("✓ 全部 24 张表字段映射正确,核心字段都存在")
sys.exit(0)
else:
print(f"发现 {len(issues)} 个问题:\n")
for i in issues:
print(f" ✗ {i}")
print(f"\n建议: python rebuild_mapping.py --in-place")
sys.exit(1 if args.strict else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Weekly three-platform comment scraper wrapper.
REM Runs bilibili, xiaohongshu, douyin in parallel via PowerShell.
REM Register: schtasks /Create /SC WEEKLY /D SUN /TN YingxiaoYunying_WeeklyCommentScrape /TR %PROJECT_DIR%\data\tools\weekly_comment_scrape.bat /ST 12:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set "PYTHON_EXE=%GYXX_PYTHON%"
set "LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing"
set "TMP_DIR=%GYXX_DATA_ROOT%\tmp\content_marketing"
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
if not exist "%TMP_DIR%" mkdir "%TMP_DIR%"
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set "NOW=%%I"
set "STAMP=%NOW:~0,4%-%NOW:~4,2%-%NOW:~6,2%_%NOW:~8,2%-%NOW:~10,2%-%NOW:~12,2%"
set "MASTER_LOG=%LOG_DIR%\weekly_%STAMP%.log"
echo === weekly scrape started %STAMP% > "%MASTER_LOG%"
set "DOUYIN_LOG=%LOG_DIR%\weekly_%STAMP%_douyin.log"
set "XHS_LOG=%LOG_DIR%\weekly_%STAMP%_xiaohongshu.log"
set "BILI_LOG=%LOG_DIR%\weekly_%STAMP%_bilibili.log"
echo [%STAMP%] launching douyin, xiaohongshu, bilibili in parallel >> "%MASTER_LOG%"
set "PS_SCRIPT=%GYXX_DATA_ROOT%\tmp\content_marketing\weekly_scrape_%STAMP%.ps1"
> "%PS_SCRIPT%" echo $ErrorActionPreference = 'Continue'
>> "%PS_SCRIPT%" echo $env:PYTHONIOENCODING = 'utf-8'
>> "%PS_SCRIPT%" echo $env:PYTHONUTF8 = '1'
>> "%PS_SCRIPT%" echo Set-Location -LiteralPath '%PROJECT_DIR%'
>> "%PS_SCRIPT%" echo $py = '%GYXX_PYTHON%'
>> "%PS_SCRIPT%" echo $stamp = '%STAMP%'
>> "%PS_SCRIPT%" echo $logDir = '%GYXX_DATA_ROOT%\logs\content_marketing'
>> "%PS_SCRIPT%" echo $jobs = @(
>> "%PS_SCRIPT%" echo [pscustomobject]@{ Name='bilibili'; Script='%PROJECT_DIR%\data\tools\batch_rescrape_bilibili.py'; Log=Join-Path $logDir ("weekly_${stamp}_bilibili.log") },
>> "%PS_SCRIPT%" echo [pscustomobject]@{ Name='xiaohongshu'; Script='%PROJECT_DIR%\data\tools\batch_rescrape_xiaohongshu.py'; Log=Join-Path $logDir ("weekly_${stamp}_xiaohongshu.log") },
>> "%PS_SCRIPT%" echo [pscustomobject]@{ Name='douyin'; Script='%PROJECT_DIR%\data\tools\batch_rescrape_douyin.py'; Log=Join-Path $logDir ("weekly_${stamp}_douyin.log") }
>> "%PS_SCRIPT%" echo )
>> "%PS_SCRIPT%" echo $started = @{}
>> "%PS_SCRIPT%" echo foreach ($job in $jobs) {
>> "%PS_SCRIPT%" echo $logPath = $job.Log
>> "%PS_SCRIPT%" echo $header = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] === $($job.Name) launch ==="
>> "%PS_SCRIPT%" echo $cmd = "`"$py`" -X utf8 `"$($job.Script)`" >> `"$logPath`" 2>&1"
>> "%PS_SCRIPT%" echo Add-Content -LiteralPath $logPath -Value $header -Encoding utf8
>> "%PS_SCRIPT%" echo $proc = Start-Process -FilePath cmd.exe -ArgumentList '/c', "`"$cmd`"" -PassThru -WindowStyle Hidden
>> "%PS_SCRIPT%" echo if ($null -eq $proc) {
>> "%PS_SCRIPT%" echo Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] !!! Start-Process returned null for $($job.Name) !!!" -Encoding utf8
>> "%PS_SCRIPT%" echo continue
>> "%PS_SCRIPT%" echo }
>> "%PS_SCRIPT%" echo $started[$job.Name] = @{ Proc=$proc; Log=$logPath }
>> "%PS_SCRIPT%" echo }
>> "%PS_SCRIPT%" echo $exitCodes = @{}
>> "%PS_SCRIPT%" echo foreach ($job in $jobs) {
>> "%PS_SCRIPT%" echo $entry = $started[$job.Name]
>> "%PS_SCRIPT%" echo $entry.Proc.WaitForExit()
>> "%PS_SCRIPT%" echo $rc = $entry.Proc.ExitCode
>> "%PS_SCRIPT%" echo $exitCodes[$job.Name] = $rc
>> "%PS_SCRIPT%" echo Add-Content -LiteralPath $entry.Log -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] === $($job.Name) done, exit=$rc ===" -Encoding utf8
>> "%PS_SCRIPT%" echo }
>> "%PS_SCRIPT%" echo foreach ($k in $exitCodes.Keys) { Write-Output ("{0}={1}" -f $k, $exitCodes[$k]) }
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS_SCRIPT%" >> "%MASTER_LOG%" 2>&1
set "RC=%ERRORLEVEL%"
del "%PS_SCRIPT%" 2>nul
echo [%STAMP%] all three scrapers finished, ps exit=%RC% >> "%MASTER_LOG%"
echo === weekly scrape done %STAMP% >> "%MASTER_LOG%"
endlocal & exit /b %RC%
@@ -0,0 +1,42 @@
@echo off
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
REM Weekly summary: 每周一 10:00 跑上周各款周笔记汇总
REM 1. 动态算上周一到上周日时间范围
REM 2. PG 查上周有笔记的款
REM 3. 对每个款:跑单品分析 + LLM 4 维度综合 + 创建飞书文档 + 写入生命进程表
REM Register: schtasks /Create /SC WEEKLY /D TUE /TN YingxiaoYunying_WeeklySummary /TR %PROJECT_DIR%\data\tools\weekly_summary.bat /ST 10:00 /F
setlocal
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
if not defined GYXX_DATA_ROOT (
if not defined GYXX_PROJECT_ROOT (
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
exit /b 3
)
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
)
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
set PYTHON=%GYXX_PYTHON%
where %PYTHON% >nul 2>&1 || set PYTHON=python
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set TS=%TS: =0%
set LOG_FILE=%LOG_DIR%\weekly_summary_%TS%.log
echo === Weekly summary started at %date% %time% === > "%LOG_FILE%"
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
cd /d "%PROJECT_DIR%"
REM 跑周汇总(4 路并发,动态时间范围)
call %PYTHON% -u -X utf8 weekly_summary_all.py --max-workers 4 >> "%LOG_FILE%" 2>&1
set RC=%ERRORLEVEL%
echo weekly_summary exit=%RC% >> "%LOG_FILE%"
echo === Weekly summary finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
endlocal
@@ -0,0 +1,437 @@
"""Backfill per-note + per-style analyses into a single Feishu Docx archive.
Layout:
- ONE archive doc (token stored under state.docs.archive)
- Top: H1 款式索引 + table (sorted by note count DESC, intra-doc #style-N anchors)
- Body: per-style H1 sections, each with:
- H2 款式整体分析 (LLM rollup across all notes of the style)
- H2 单篇笔记分析 (N 篇) containing per-note H3 sections
Idempotent: re-runs read state/feishu_doc.json and skip already-written notes + styles.
"""
import argparse
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
# Path setup
_TOOLS_DIR = PATHS.tools_root
_PROJECT_ROOT = PATHS.module_root
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
from gyxx_flow.modules.content_marketing.runtime.data.tools import db # noqa: E402
from gyxx_flow.modules.content_marketing.runtime.data.tools import feishu_doc_writer as fdw # noqa: E402
from gyxx_flow.modules.content_marketing.runtime.data.tools import analyze_note # noqa: E402
from gyxx_flow.modules.content_marketing.runtime.data.tools import analyze_comments # noqa: E402
from gyxx_flow.modules.content_marketing.runtime.data.tools import style_analyzer # noqa: E402
# Serializes state mutation + state.json writes across worker threads.
_state_lock = threading.Lock()
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
# ---------------------------------------------------------------------------
# Archive doc lifecycle
# ---------------------------------------------------------------------------
def get_or_create_archive(state: dict[str, Any]) -> dict[str, Any]:
docs = state.setdefault("docs", {})
existing = docs.get("archive")
if existing and existing.get("token"):
return existing
log(" Creating archive doc ...")
info = fdw.create_doc(fdw._doc_skeleton())
docs["archive"] = {
"token": info["token"],
"url": info["url"],
"revision_id": info.get("revision_id"),
}
return docs["archive"]
# ---------------------------------------------------------------------------
# Per-note processing (within a style)
# ---------------------------------------------------------------------------
def process_note(
note_row: dict[str, Any], *, archive_token: str, force: bool,
state: dict[str, Any],
) -> dict[str, Any]:
"""Per-note pipeline: load → filter → metrics → LLM analysis.
Returns the data needed for `process_style` to assemble the full style
section XML. Does NOT append anything to the doc — per-note sections are
appended only as part of the style section to keep doc order tidy.
"""
note_id = note_row["id"]
with _state_lock:
written = state.setdefault("written_notes", {}).get(str(note_id))
if written and not force:
return {"note_id": note_id, "status": "skipped", "reason": "already-written"}
try:
info, raw_comments = analyze_note.load_note_from_db(note_id=note_id)
except Exception as exc:
return {"note_id": note_id, "status": "error", "stage": "load", "error": str(exc)}
info["note_id"] = note_id
try:
filtered_comments = analyze_comments.code_filter_comments(raw_comments)
except Exception as exc:
return {"note_id": note_id, "status": "error", "stage": "filter", "error": str(exc)}
if not filtered_comments:
return {"note_id": note_id, "status": "skipped", "reason": "no-filtered-comments"}
metrics = analyze_note.compute_metrics(info)
target_terms = [t for t in (info.get("brand"), info.get("style_name")) if t]
distribution = analyze_note.keyword_distribution(filtered_comments, target_terms=target_terms)
try:
analysis = analyze_note.analyze_note(
info, metrics, distribution, filtered_comments,
)
except Exception as exc:
return {"note_id": note_id, "status": "error", "stage": "llm", "error": str(exc)}
# Persist the analysis text in state so re-runs can rebuild the doc without
# re-calling the LLM (idempotency + crash recovery).
with _state_lock:
state["written_notes"][str(note_id)] = {
"platform": info.get("platform"),
"doc_token": archive_token,
"bookmark_name": f"note-{note_id}",
"style_id": note_row.get("style_id"),
"analysis_text": analysis,
"written_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
fdw.save_state(state)
return {
"note_id": note_id,
"status": "ok",
"title": info.get("title", ""),
"filtered_comments": filtered_comments,
"info": info,
"metrics": metrics,
"distribution": distribution,
"analysis": analysis,
}
# ---------------------------------------------------------------------------
# Per-style processing
# ---------------------------------------------------------------------------
def process_style(
style_row: dict[str, Any], state: dict[str, Any], archive_token: str, *,
max_notes: int | None, force: bool, force_rollup: bool, workers: int,
) -> tuple[int, int, int, bool]:
style_id = style_row["id"]
style_name = style_row["name"]
notes = db.get_notes_by_style(style_id, only_with_comments=True)
if max_notes is not None:
notes = notes[:max_notes]
log(f"[{style_name}] notes to process: {len(notes)} (workers={workers})")
# ---- Phase 1: per-note parallel ----
note_results: list[dict[str, Any]] = []
ok = fail = skipped = 0
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
future_to_note = {
pool.submit(
process_note,
note, archive_token=archive_token, force=force,
state=state,
): note
for note in notes
}
for future in as_completed(future_to_note):
result = future.result()
status = result.get("status")
nid = result.get("note_id")
if status == "ok":
ok += 1
log(f" [{style_name}] ok note_id={nid} title={result.get('title', '')[:30]!r}")
note_results.append(result)
elif status == "skipped":
skipped += 1
reason = result.get("reason")
log(f" [{style_name}] skip note_id={nid} reason={reason}")
# If it was previously written, we still want its data for the
# rollup + style section rebuild. Re-derive from DB and use the
# saved analysis_text from state (no LLM re-call).
if reason == "already-written":
try:
with _state_lock:
prev_entry = state.get("written_notes", {}).get(str(nid)) or {}
prev_analysis = prev_entry.get("analysis_text")
prev_info, prev_raw = analyze_note.load_note_from_db(note_id=nid)
prev_info["note_id"] = nid
prev_filtered = analyze_comments.code_filter_comments(prev_raw)
if prev_filtered and prev_analysis:
prev_metrics = analyze_note.compute_metrics(prev_info)
prev_target = [t for t in (prev_info.get("brand"), prev_info.get("style_name")) if t]
prev_dist = analyze_note.keyword_distribution(prev_filtered, target_terms=prev_target)
note_results.append({
"note_id": nid,
"status": "ok",
"title": prev_info.get("title", ""),
"filtered_comments": prev_filtered,
"info": prev_info,
"metrics": prev_metrics,
"distribution": prev_dist,
"analysis": prev_analysis,
})
except Exception as exc:
log(f" [{style_name}] WARN: could not re-derive note_id={nid} for rollup: {exc}")
else:
fail += 1
log(f" [{style_name}] FAIL note_id={nid} stage={result.get('stage')} error={result.get('error')}")
if not note_results:
log(f"[{style_name}] no successful notes — skipping rollup + style section")
with _state_lock:
state.setdefault("written_styles", {})[str(style_id)] = {
"style_name": style_name,
"doc_token": archive_token,
"note_count": 0,
"bookmark_name": f"style-{style_id}",
"skipped": True,
"written_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
fdw.save_state(state)
return ok, fail, skipped, True
# ---- Phase 2: style-level rollup ----
notes_meta = note_results # already has {id, platform, title, like_count, comment_count, ...} via info
# Reuse info rows (which already have id/platform/title/etc) for aggregation.
notes_as_rows = [
{
"id": r["info"].get("note_id"),
"platform": r["info"].get("platform"),
"title": r["info"].get("title", ""),
"like_count": r["info"].get("like_count"),
"favorite_count": r["info"].get("favorite_count"),
"comment_count": r["info"].get("comment_count_metric") or r["info"].get("comment_count_total"),
"share_count": r["info"].get("share_count"),
"view_count": r["info"].get("view_count"),
"creator_name": r["info"].get("creator_name", ""),
}
for r in note_results
]
aggregated = style_analyzer.aggregate_style_metrics(notes_as_rows)
merged_distribution = style_analyzer.merge_style_distribution(
[r.get("distribution") or {} for r in note_results]
)
# ---- Phase 3: decide if rollup needed ----
skip_rollup = False
with _state_lock:
existing_style = state.setdefault("written_styles", {}).get(str(style_id))
if existing_style and not force_rollup and not force:
log(f"[{style_name}] rollup already written; skipping LLM call")
rollup_text = existing_style.get("rollup_text", "(已有整体分析)")
skip_rollup = True
else:
sample_comments = style_analyzer.sample_representative_comments(
[r.get("filtered_comments") or [] for r in note_results],
notes_as_rows,
max_total=30,
per_note_cap=2,
)
try:
rollup_text = style_analyzer.analyze_style(
style_name,
brand="", # brand not currently carried in style_row; left empty
aggregated=aggregated,
merged_distribution=merged_distribution,
sample_comments=sample_comments,
note_summaries=[],
)
except Exception as exc:
log(f"[{style_name}] FAIL rollup LLM: {exc}")
rollup_text = f"(整体分析生成失败:{exc})"
# ---- Phase 4: build style section XML and append to archive ----
# The per-note XML sections must be built here (not in worker) because we
# need the XML to assemble into the style section in one append call.
# We can however reuse the `analysis` text already produced.
note_sections_xml_parts: list[str] = []
for r in note_results:
info = r["info"]
if not r.get("analysis"):
log(f" [{style_name}] WARN: note_id={r['note_id']} has no analysis text; skipping in doc")
continue
note_xml = fdw.build_note_section_xml(
info,
r["metrics"],
r["distribution"],
# raw_comments not preserved across runs; pass filtered as fallback
r.get("filtered_comments") or [],
r.get("filtered_comments") or [],
r["analysis"],
bookmark_name=f"note-{r['note_id']}",
doc_token=archive_token,
)
note_sections_xml_parts.append(note_xml)
style_section_xml = fdw.build_style_section_xml(
style_id=style_id,
style_name=style_name,
aggregated=aggregated,
rollup_text=rollup_text,
note_sections_xml="".join(note_sections_xml_parts),
doc_token=archive_token,
)
try:
fdw.append_blocks(archive_token, style_section_xml)
except Exception as exc:
log(f"[{style_name}] FAIL append style section: {exc}")
return ok, fail, skipped, skip_rollup
with _state_lock:
state["written_styles"][str(style_id)] = {
"style_name": style_name,
"doc_token": archive_token,
"note_count": aggregated["note_count"],
"bookmark_name": f"style-{style_id}",
"rollup_written_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"rollup_text": rollup_text,
}
fdw.save_state(state)
log(f"[{style_name}] DONE: notes={aggregated['note_count']} rollup={'skipped' if skip_rollup else 'new'}")
return ok, fail, skipped, skip_rollup
# ---------------------------------------------------------------------------
# Index table (replace-on-update)
# ---------------------------------------------------------------------------
def populate_index_table(state: dict[str, Any], archive_token: str) -> None:
archive = state.get("docs", {}).get("archive")
if not archive:
log(" WARN: no archive doc in state; skipping index population")
return
written_styles = state.get("written_styles", {})
# Sort by note_count DESC; alphabetical fallback for ties (already sorted in db.get_all_styles_with_notes)
sorted_styles = dict(
sorted(
written_styles.items(),
key=lambda kv: (-(kv[1].get("note_count") or 0), kv[1].get("style_name", "")),
)
)
index_table_xml = fdw.build_style_index_table_xml(sorted_styles, archive_url=archive["url"])
# Anchor: H1 "款式索引" — we'll insert AFTER it.
anchor = fdw.find_block_after(archive_token, "款式索引")
if not anchor:
log(" WARN: could not find anchor h1 '款式索引'; appending to end of archive doc")
fdw.append_blocks(archive_token, index_table_xml)
return
# Replace-on-update: delete ONLY the index blocks (between anchor and the
# first H1 after it). Deleting everything after the anchor would nuke the
# style sections we just wrote.
stale_block_ids = fdw.find_index_blocks(archive_token, anchor)
if stale_block_ids:
log(f" Clearing {len(stale_block_ids)} stale index blocks ...")
try:
fdw.delete_blocks(archive_token, stale_block_ids)
except Exception as exc:
log(f" WARN: stale-block delete failed: {exc}; new index will be appended (may produce duplicates)")
fdw.insert_blocks_after(archive_token, anchor, index_table_xml)
log(f" Inserted index table with {len(sorted_styles)} style rows")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_all(args: argparse.Namespace) -> int:
state = fdw.load_state()
log(f"Loaded state: docs={list(state.get('docs', {}).keys())} "
f"written_notes={len(state.get('written_notes', {}))} "
f"written_styles={len(state.get('written_styles', {}))}")
archive = get_or_create_archive(state)
fdw.save_state(state)
log(f"Archive doc: {archive['url']}")
# Decide which styles to process.
if args.style == "all":
styles = db.get_all_styles_with_notes()
else:
# Single style by id
style_id = int(args.style)
styles = [s for s in db.get_all_styles_with_notes() if s["id"] == style_id]
if not styles:
log(f" WARN: style_id={style_id} not found or has no notes")
return 1
total_ok = total_fail = total_skip = 0
for style_row in styles:
ok, fail, skipped, _ = process_style(
style_row, state, archive["token"],
max_notes=args.max_notes, force=args.force,
force_rollup=args.force_rollup, workers=args.workers,
)
total_ok += ok
total_fail += fail
total_skip += skipped
log("Populating index table ...")
try:
populate_index_table(state, archive["token"])
except Exception as exc:
log(f" WARN: index table population failed: {exc}")
fdw.save_state(state)
log(f"DONE: notes ok={total_ok} fail={total_fail} skipped={total_skip} "
f"styles={len(state.get('written_styles', {}))}")
log(f"Archive: {archive['url']}")
return 0 if total_fail == 0 else 1
def main() -> int:
parser = argparse.ArgumentParser(description="Backfill note + style analyses into one Feishu Docx")
parser.add_argument("--style", default="all",
help="款式 id(如 8 = 宙斯),或 'all' 处理所有款式(默认)")
parser.add_argument("--max-notes", type=int, default=None,
help="每个款式最多处理 N 条(测试用)")
parser.add_argument("--force", action="store_true",
help="忽略 state 中已写入标记,强制重写所有笔记 + 整体分析")
parser.add_argument("--force-rollup", action="store_true",
help="仅重写款式整体分析(保留单篇笔记)")
parser.add_argument("--workers", type=int, default=3,
help="并行 worker 数(默认 3;设为 1 退化为串行)")
parser.add_argument("--smoke-test", action="store_true",
help="只跑权限冒烟测试,不写任何笔记")
args = parser.parse_args()
if args.smoke_test:
return 0 if fdw.permission_smoke_test() else 1
return run_all(args)
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,766 @@
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from playwright.sync_api import Page
from scrapling.fetchers import DynamicSession
import httpx
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
STATE_DIR = PATHS.state_root
NOTES_DIR = PATHS.raw_root / "notes" / "douyin"
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
# Douyin comment API endpoints to intercept
COMMENT_API_MARKS = (
"/aweme/v1/web/comment/list/",
"/aweme/v1/web/comment/list/reply/",
)
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def load_cookies() -> list[dict[str, Any]]:
if not COOKIE_FILE.exists():
return []
try:
data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except json.JSONDecodeError:
log(f"Cookie file is invalid, ignoring: {COOKIE_FILE}")
return []
def restore_cookies(page: Page) -> None:
cookies = load_cookies()
if not cookies:
return
try:
page.context.add_cookies(cookies)
log(f"Loaded cookies: {COOKIE_FILE}")
except Exception as exc:
log(f"Failed to load cookies, will continue with browser profile: {exc}")
def save_state(page: Page) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
COOKIE_FILE.write_text(
json.dumps(page.context.cookies(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
PATHS.browser_storage_state_file.write_text(
json.dumps(page.context.storage_state(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
log(f"Saved cookies: {COOKIE_FILE}")
def normalize_douyin_url(url: str) -> str:
# Strip trailing Chinese text that sometimes gets appended to share URLs
url = re.sub(r'(https?://v\.douyin\.com/[A-Za-z0-9_-]+/).*', r'\1', url)
parts = urlsplit(url)
params = dict(parse_qsl(parts.query, keep_blank_values=True))
modal_id = params.get("modal_id")
if modal_id and modal_id.isdigit():
return f"https://www.douyin.com/video/{modal_id}"
return url
def resolve_aweme_id(url: str, *, page: Page | None = None) -> tuple[str, bool]:
"""Extract aweme_id and is_note from a Douyin URL.
Returns (aweme_id, is_note). If *page* is provided and httpx redirect
fails, falls back to navigating the short link inside the browser.
"""
parts = urlsplit(url)
netloc = parts.netloc.lower()
is_note = "/note/" in parts.path
m = re.search(r"/(?:video|note)/(\d+)", parts.path)
if m:
is_note = "/note/" in parts.path
return m.group(1), is_note
if netloc.startswith("v.douyin.com") or netloc.startswith("www.douyin.com"):
try:
r = httpx.get(url, follow_redirects=True, timeout=15)
final_url = str(r.url)
m = re.search(r"/(?:video|note)/(\d+)", final_url)
if m:
is_note = "/note/" in final_url
return m.group(1), is_note
except Exception as exc:
log(f"resolve_aweme_id: httpx redirect failed: {exc}")
# Fallback: resolve inside the browser
if page is not None:
try:
log("resolve_aweme_id: trying browser redirect fallback...")
page.goto(url, wait_until="domcontentloaded", timeout=30000)
final_url = page.url
m = re.search(r"/(?:video|note)/(\d+)", final_url)
if m:
is_note = "/note/" in final_url
log(f"resolve_aweme_id: browser fallback ok, aweme_id={m.group(1)}, is_note={is_note}")
return m.group(1), is_note
except Exception as exc:
log(f"resolve_aweme_id: browser redirect also failed: {exc}")
modal_id = dict(parse_qsl(parts.query, keep_blank_values=True)).get("modal_id")
if modal_id and modal_id.isdigit():
return modal_id, False
raise ValueError(f"Cannot extract aweme_id from URL: {url}")
def parse_comment(comment: dict[str, Any], source_url: str, level: str, parent_id: str = "") -> dict[str, Any]:
user = comment.get("user") or {}
create_time = comment.get("create_time")
created_at = ""
if isinstance(create_time, (int, float)) and create_time:
try:
created_at = datetime.fromtimestamp(create_time).isoformat(timespec="seconds")
except Exception:
created_at = str(create_time)
return {
"source_url": source_url,
"level": level,
"comment_id": comment.get("cid") or comment.get("comment_id") or "",
"reply_to_comment_id": parent_id or comment.get("reply_id") or comment.get("reply_to_reply_id") or "",
"nickname": user.get("nickname") or "",
"user_id": user.get("uid") or "",
"sec_uid": user.get("sec_uid") or "",
"text": comment.get("text") or "",
"digg_count": comment.get("digg_count") or 0,
"reply_count": comment.get("reply_comment_total") or comment.get("reply_comment_count") or 0,
"ip_label": comment.get("ip_label") or "",
"created_at": created_at,
"raw_create_time": create_time or "",
}
def merge_comment(
row: dict[str, Any],
comments_by_id: dict[str, dict[str, Any]],
) -> None:
comment_id = str(row.get("comment_id") or f"{row.get('level')}:{len(comments_by_id)}")
comments_by_id[comment_id] = row
def collect_from_payload(
payload: dict[str, Any],
source_url: str,
comments_by_id: dict[str, dict[str, Any]],
stats: dict[str, Any],
) -> None:
if not isinstance(payload, dict):
return
api_url = str(payload.get("__source_url", ""))
is_reply = "/comment/list/reply/" in api_url
if not is_reply:
stats["api_total"] = payload.get("total", stats.get("api_total"))
stats["last_has_more"] = payload.get("has_more", stats.get("last_has_more"))
stats["last_cursor"] = payload.get("cursor", stats.get("last_cursor"))
comments = payload.get("comments") or []
if not isinstance(comments, list):
return
parent_id = ""
if is_reply:
params = dict(parse_qsl(urlsplit(api_url).query))
parent_id = params.get("comment_id") or ""
for comment in comments:
if not isinstance(comment, dict):
continue
row = parse_comment(comment, source_url, "reply" if is_reply else "comment", parent_id)
merge_comment(row, comments_by_id)
# Inline replies embedded in top-level comment responses
inline_replies = comment.get("reply_comment") or []
if isinstance(inline_replies, list):
parent_cid = str(row.get("comment_id") or "")
for reply in inline_replies:
if isinstance(reply, dict):
sub_row = parse_comment(reply, source_url, "reply", parent_cid)
merge_comment(sub_row, comments_by_id)
def setup_response_capture(page: Page, source_url: str) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
comments_by_id: dict[str, dict[str, Any]] = {}
stats: dict[str, Any] = {"api_hits": 0, "api_total": None, "last_has_more": None, "last_cursor": None}
def handle_response(response: Any) -> None:
url = response.url
if not any(mark in url for mark in COMMENT_API_MARKS):
return
try:
payload = response.json()
except Exception:
return
if isinstance(payload, dict):
payload["__source_url"] = url
stats["api_hits"] += 1
# Save first-seen URL as template for direct reply fetching
if "/comment/list/" in url and "/reply/" not in url and not stats.get("comment_url_template"):
stats["comment_url_template"] = url
if "/comment/list/reply/" in url and not stats.get("reply_url_template"):
stats["reply_url_template"] = url
collect_from_payload(payload, source_url, comments_by_id, stats)
page.on("response", handle_response)
return comments_by_id, stats
def click_if_visible(page: Page, text: str, timeout: int = 1200) -> bool:
try:
locator = page.get_by_text(text, exact=False).first
if locator.count():
locator.click(timeout=timeout)
page.wait_for_timeout(500)
return True
except Exception:
return False
return False
def dismiss_popups(page: Page) -> None:
for text in ("我知道了", "稍后再说", "暂不登录", "取消", "跳过"):
click_if_visible(page, text)
page.evaluate(
"""
() => {
const close = [...document.querySelectorAll('button, div, span')]
.filter((el) => {
const t = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return ['×', 'x', 'X'].includes(t) &&
r.width > 0 && r.width <= 52 &&
r.height > 0 && r.height <= 52 &&
r.top >= 0 && r.top < window.innerHeight * 0.75;
})[0];
close?.click();
}
"""
)
def maybe_wait_for_login(page: Page, login_timeout: int) -> None:
deadline = time.time() + login_timeout
while time.time() < deadline:
try:
body = page.locator("body").inner_text(timeout=5000)
except Exception:
body = ""
if "扫码登录" not in body and "登录后" not in body:
return
log("Douyin may require login. Please finish login in the opened Chrome window.")
page.wait_for_timeout(3000)
def is_logged_in(page: Page) -> bool:
"""抖音登录态靠 sessionid cookie,有它才能采全量评论。"""
for c in page.context.cookies():
if c.get("name") == "sessionid" and c.get("value"):
return True
return False
def expand_visible_replies(page: Page) -> int:
try:
clicked = page.evaluate(
"""
() => {
const visible = (el) => {
const r = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return r.width > 0 && r.height > 0 &&
r.bottom > 0 && r.top < window.innerHeight &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
style.opacity !== '0';
};
const candidates = [...document.querySelectorAll('button, div, span')]
.filter((el) => {
const text = (el.innerText || '').trim();
return visible(el) &&
text.length <= 40 &&
(/展开.*回复/.test(text) || /查看.*回复/.test(text) || /共.*条回复/.test(text) || /还有.*回复/.test(text));
});
let count = 0;
for (const el of candidates.slice(0, 8)) {
el.click();
count++;
}
return count;
}
"""
)
if clicked:
page.wait_for_timeout(1200)
return int(clicked or 0)
except Exception:
return 0
def scroll_comments(page: Page, max_scrolls: int, idle_rounds: int, comments_by_id: dict[str, Any]) -> None:
last_count = len(comments_by_id)
idle = 0
page.mouse.move(1030, 620)
for index in range(max_scrolls):
dismiss_popups(page)
expand_visible_replies(page)
page.mouse.wheel(0, 1000)
page.evaluate(
"""
() => {
const candidates = [...document.querySelectorAll('div, section, aside')]
.filter((el) => {
const r = el.getBoundingClientRect();
return r.height > 160 &&
r.width > 220 &&
el.scrollHeight > el.clientHeight + 80 &&
r.right > window.innerWidth * 0.35;
})
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight));
for (const el of candidates.slice(0, 6)) {
el.scrollTop += 1000;
}
}
"""
)
page.wait_for_timeout(1800)
count = len(comments_by_id)
if count == last_count:
idle += 1
else:
log(f"Loaded comments: {count}")
idle = 0
last_count = count
if idle >= idle_rounds:
log(f"No new comments after {idle_rounds} rounds, stopping scroll.")
break
log(f"Scroll finished after {index + 1} rounds.")
def with_query_params(url: str, updates: dict[str, Any]) -> str:
parts = urlsplit(url)
params = dict(parse_qsl(parts.query, keep_blank_values=True))
for key, value in updates.items():
params[key] = str(value)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(params), parts.fragment))
def fetch_missing_top_comments(
page: Page,
source_url: str,
aweme_id: str,
comments_by_id: dict[str, dict[str, Any]],
stats: dict[str, Any],
) -> None:
"""After scrolling, proactively paginate remaining top-level comments via API."""
template = stats.get("comment_url_template")
if not template:
log("No comment API template captured; skipped direct top-level fetch.")
return
api_total = stats.get("api_total")
# Fall back to note_metrics.comment_count if api_total is missing
if not api_total:
metrics = stats.get("note_metrics") or {}
api_total = metrics.get("comment_count")
if api_total and len(comments_by_id) >= int(api_total):
return
last_cursor = stats.get("last_cursor")
if not last_cursor:
return
log(f"Top-level: collected {len(comments_by_id)} / api_total {api_total}; fetching remainder.")
fetched = 0
cursor = last_cursor
for _ in range(100):
top_url = with_query_params(template, {"cursor": cursor, "aweme_id": aweme_id})
try:
payload = page.evaluate(
"""
async (url) => {
const res = await fetch(url, { credentials: 'include' });
return await res.json();
}
""",
top_url,
)
except Exception:
break
if not isinstance(payload, dict):
break
payload["__source_url"] = top_url
before = len(comments_by_id)
collect_from_payload(payload, source_url, comments_by_id, stats)
fetched += max(0, len(comments_by_id) - before)
has_more = payload.get("has_more")
next_cursor = payload.get("cursor")
if not has_more or next_cursor in (None, cursor):
break
cursor = next_cursor
page.wait_for_timeout(350)
log(f"Top-level direct fetch: {fetched} new comments; total now: {len(comments_by_id)}")
def fetch_missing_replies(
page: Page,
source_url: str,
comments_by_id: dict[str, dict[str, Any]],
stats: dict[str, Any],
) -> None:
template = stats.get("reply_url_template")
if not template:
log("No reply API template captured; skipped direct reply fetch.")
return
parents = [
row for row in list(comments_by_id.values())
if row.get("level") == "comment" and int(row.get("reply_count") or 0) > 0
]
if not parents:
return
log(f"Fetching replies directly for {len(parents)} comments with replies.")
fetched = 0
for row in parents:
parent_id = str(row.get("comment_id") or "")
if not parent_id:
continue
cursor = 0
for _ in range(30):
reply_url = with_query_params(template, {
"item_id": stats.get("aweme_id", ""),
"comment_id": parent_id,
"cursor": cursor,
})
try:
payload = page.evaluate(
"""
async (url) => {
const res = await fetch(url, { credentials: 'include' });
return await res.json();
}
""",
reply_url,
)
except Exception:
break
if not isinstance(payload, dict):
break
payload["__source_url"] = reply_url
before = len(comments_by_id)
collect_from_payload(payload, source_url, comments_by_id, stats)
fetched += max(0, len(comments_by_id) - before)
has_more = payload.get("has_more")
next_cursor = payload.get("cursor")
if not has_more or next_cursor in (None, cursor):
break
cursor = next_cursor
page.wait_for_timeout(350)
log(f"Direct replies fetched/merged: {fetched}; total comments now: {len(comments_by_id)}")
def extract_note_metrics(page: Page) -> dict[str, Any]:
"""Extract video metrics from the Douyin page via DOM data-e2e attributes."""
try:
metrics = page.evaluate(
r"""
() => {
const compactNumber = (text) => {
text = String(text || '').replace(/,/g, '').trim();
const match = text.match(/(\d+(?:\.\d+)?)(\s*[万wW])?/);
if (!match) return 0;
const value = parseFloat(match[1]);
if (!Number.isFinite(value)) return 0;
return match[2] ? Math.round(value * 10000) : Math.round(value);
};
const titleEl = document.querySelector('[data-e2e="video-title"]') || document.querySelector('h1');
const title = titleEl ? titleEl.innerText.trim() : '';
const likeEl = document.querySelector('[data-e2e="video-player-digg"]');
const commentEl = document.querySelector('[data-e2e="feed-comment-icon"]');
const collectEl = document.querySelector('[data-e2e="video-player-collect"]');
const shareEl = document.querySelector('[data-e2e="video-player-share"]');
const like_count = likeEl ? compactNumber(likeEl.innerText) : 0;
const comment_count = commentEl ? compactNumber(commentEl.innerText) : 0;
const favorite_count = collectEl ? compactNumber(collectEl.innerText) : 0;
const share_count = shareEl ? compactNumber(shareEl.innerText) : 0;
if (!like_count && !comment_count) return {};
return {title, like_count, comment_count, favorite_count, share_count, view_count: 0};
}
"""
)
return metrics if isinstance(metrics, dict) else {}
except Exception:
return {}
def safe_filename(value: str) -> str:
value = re.sub(r"[<>:\"/\\|?*\s]+", "_", value).strip("_")
return value[:80] or "douyin_comments"
def scrape_comments(url: str, login_timeout: int, max_scrolls: int, idle_rounds: int, headless: bool) -> tuple[list[dict[str, Any]], dict[str, Any]]:
url = normalize_douyin_url(url)
result: dict[str, Any] = {"comments": [], "stats": {}}
# Try httpx first (fast); if it fails, we'll retry inside the browser
aweme_id: str | None = None
is_note: bool = False
try:
aweme_id, is_note = resolve_aweme_id(url)
except ValueError as exc:
log(str(exc))
if aweme_id:
log(f"aweme_id: {aweme_id}{' (note)' if is_note else ''}")
def action(page: Page) -> None:
nonlocal aweme_id, is_note
# If httpx couldn't resolve the short link, use the browser as fallback
if aweme_id is None:
try:
aweme_id, is_note = resolve_aweme_id(url, page=page)
log(f"aweme_id (browser fallback): {aweme_id}{' (note)' if is_note else ''}")
except ValueError as exc:
log(str(exc))
return # Cannot proceed without aweme_id
# Always set up response capture first to intercept API calls
comments_by_id, stats = setup_response_capture(page, url)
stats["aweme_id"] = aweme_id
# Use the resolved full URL instead of the original short link
resolved_url = f"https://www.douyin.com/note/{aweme_id}" if is_note else f"https://www.douyin.com/video/{aweme_id}"
page.goto(resolved_url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(6000)
maybe_wait_for_login(page, login_timeout)
dismiss_popups(page)
save_state(page)
# Wait briefly for the first comment API response to capture template URLs
for _ in range(10):
if stats.get("comment_url_template") and stats.get("api_total") is not None:
break
page.wait_for_timeout(1000)
total_comments = stats.get("api_total")
if total_comments is not None:
log(f"Comment count from API: {total_comments}")
else:
log("Could not determine comment count from API response")
# Scroll to collect comments (same pattern as xiaohongshu scraper)
scroll_comments(page, max_scrolls, idle_rounds, comments_by_id)
# After scrolling, extract metrics and use comment_count as fallback for api_total
stats["note_metrics"] = extract_note_metrics(page)
result["title"] = stats["note_metrics"].get("title", "")
# Proactively paginate remaining top-level comments via API
fetch_missing_top_comments(page, url, aweme_id, comments_by_id, stats)
# Fetch missing sub-replies via API
fetch_missing_replies(page, url, comments_by_id, stats)
page.wait_for_timeout(1000)
result["comments"] = list(comments_by_id.values())
result["stats"] = stats
# logged_in 判定:cookie 实际有效但抖音反爬可能在抓取过程中清掉 sessionid,
# 改成"采集率 >= 90% 就算登录有效",避免误报噪音。
actual_count = stats.get("api_total")
if actual_count is None:
note_metrics = stats.get("note_metrics") or {}
actual_count = note_metrics.get("comment_count")
collected_count = len(result["comments"])
if actual_count and collected_count >= int(actual_count * 0.9):
result["stats"]["logged_in"] = True
else:
result["stats"]["logged_in"] = is_logged_in(page)
result["final_url"] = page.url
save_state(page)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch(url, page_action=action, wait=1000)
# aweme_id 解析完全失败 → 抛异常让 batch 层处理
if aweme_id is None:
raise RuntimeError(f"Cannot extract aweme_id from URL: {url}")
# 静默失败检测:0 评论 + 空 note_metrics → 页面没加载成功
stats = result.get("stats", {})
note_metrics = stats.get("note_metrics", {})
if not result["comments"] and not note_metrics:
raise RuntimeError(
"page not loaded: 0 comments and no metrics "
"(likely cookie expired or rate-limited)"
)
return result["comments"], result
def write_outputs(url: str, comments: list[dict[str, Any]], result: dict[str, Any]) -> tuple[Path, Path]:
NOTES_DIR.mkdir(parents=True, exist_ok=True)
m = re.search(r"/(?:video|note)/(\d+)", urlsplit(url).path)
aweme_id = m.group(1) if m else ""
modal = re.search(r"modal_id=(\d+)", url)
if aweme_id:
name = f"douyin_comments_{aweme_id}"
elif modal:
name = f"douyin_comments_{modal.group(1)}"
else:
name = safe_filename(url)
json_path = NOTES_DIR / f"{name}.json"
csv_path = NOTES_DIR / f"{name}.csv"
if not comments and json_path.exists() and csv_path.exists() and not result.get("title"):
log(f"No comments and no new title; keeping existing output files: {json_path}, {csv_path}")
return json_path, csv_path
stats = result.get("stats", {})
public_stats = {key: value for key, value in stats.items() if not key.endswith("_template")}
payload = {
"source_url": url,
"title": result.get("title", ""),
"scraped_at": datetime.now().isoformat(timespec="seconds"),
"count": len(comments),
"stats": public_stats,
"comments": comments,
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
fieldnames = [
"source_url",
"level",
"comment_id",
"reply_to_comment_id",
"nickname",
"user_id",
"sec_uid",
"text",
"digg_count",
"reply_count",
"ip_label",
"created_at",
"raw_create_time",
]
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(comments)
return json_path, csv_path
def login_only(login_timeout: int) -> int:
"""只触发登录(headed),登录完成立刻退出。给 relogin_douyin.py 用。"""
def action(page: Page) -> None:
page.goto("https://www.douyin.com/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(2500)
# 主动触发登录弹窗(点登录按钮)
try:
login_btn = page.get_by_text("登录", exact=True).first
if login_btn.count():
login_btn.click(timeout=3000)
page.wait_for_timeout(1500)
except Exception:
pass
maybe_wait_for_login(page, login_timeout)
save_state(page)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=False,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch("https://www.douyin.com/", page_action=action, wait=1000)
log("[login-only] 完成")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape Douyin video comments via browser (Playwright + XHR interception).")
parser.add_argument("url", help="Douyin video/user URL (传 'login' 配合 --login-only)")
parser.add_argument("--login-timeout", type=int, default=300, help="等待扫码/登录秒数")
parser.add_argument("--max-scrolls", type=int, default=120, help="最多滚动加载次数")
parser.add_argument("--idle-rounds", type=int, default=8, help="连续几轮无新评论后停止")
parser.add_argument("--headless", action="store_true", help="无头模式;首次登录不要使用")
parser.add_argument("--login-only", action="store_true",
help="只触发登录(headed)然后退出,给 relogin_douyin 用")
args = parser.parse_args()
if args.login_only:
return login_only(args.login_timeout)
comments, result = scrape_comments(args.url, args.login_timeout, args.max_scrolls, args.idle_rounds, args.headless)
json_path, csv_path = write_outputs(args.url, comments, result)
log(f"Final URL: {result.get('final_url', '')}")
log(f"API hits: {result.get('stats', {}).get('api_hits', 0)}")
log(f"Scraped {len(comments)} comments")
log(f"JSON: {json_path}")
log(f"CSV: {csv_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,427 @@
"""
feishu_mapping.py — 动态读取「合作达人」/「自营达人」多维表格地址,重建款式对照表
=================================================================================
之前每个款对应的飞书表格是写死在 data/config/款式_多维表格_对照.json 里的,
现在改成动态读取下面这张"合作达人"多维表格地址表:
https://bu0zgpibak.feishu.cn/base/TtoCb1NuQaDy3NsZWTpc0GIvnph?table=tblKCjplVAFrRwMC
这张表里每个款式一行,包含「款式」和「合作达人多维表格地址」两个字段。
凡是款式 和 合作达人多维表格地址 都不为空的一行,就去解析地址里的 base_token + table_id,
再拉该表的字段定义 (+field-list) 构建 field_map,最终拼出和原 款式_多维表格_对照.json
完全相同结构的 mapping:
{ "_comment":..., "platform":..., "tables":[ {index,name,base_token,table_id,url,field_map,all_field_names,total_records}, ... ] }
自营达人: 同一张索引表新增了「自营合作达人表格地址」列,结构完全相同,
输出到 data/config/款式_多维表格_对照_自营.json。
策略:
- 拉取后写回 data/config/款式_多维表格_对照.json (合作) 或 款式_多维表格_对照_自营.json (自营)
- 带 TTL 缓存(默认 6 小时):缓存新鲜就直接读缓存,避免每次跑都打几十个 lark-cli 请求
- lark-cli 拉取失败时退回缓存文件,保证可运行
- index 编号:已知款式沿用旧 JSON 里的编号,新款式按出现顺序往后追加(保证历史文件名稳定)
被三方脚本 (bilibili / pgy / xingtu) 通过 load_mapping() 调用;
run_all.py 启动前可调 refresh_mapping(force=True) 统一刷新一次,避免 3 个并行进程重复刷新打架。
"""
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
PATHS,
resolve_layer_output,
)
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
MAPPING_DIR = PATHS.normalized_root / "mappings"
MAPPING_PATH = MAPPING_DIR / "款式_多维表格_对照.json"
SELF_MAPPING_PATH = MAPPING_DIR / "款式_多维表格_对照_自营.json"
MAPPING_SEED_PATH = PATHS.config_root / "款式_多维表格_对照.json"
SELF_MAPPING_SEED_PATH = PATHS.config_root / "款式_多维表格_对照_自营.json"
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
LARK_CLI = "lark-cli.cmd"
# 「合作达人」多维表格地址表(款式 → 各自合作达人多维表格 URL)
INDEX_BASE_TOKEN = "TtoCb1NuQaDy3NsZWTpc0GIvnph"
INDEX_TABLE_ID = "tblKCjplVAFrRwMC"
# 这张地址表里的关键字段名
INDEX_STYLE_FIELD = "款式"
INDEX_URL_FIELD = "合作达人多维表格地址"
INDEX_SELF_URL_FIELD = "自营合作达人表格地址"
# 缓存新鲜阈值(秒),默认 6 小时
CACHE_TTL = 6 * 3600
# 标准字段名 → 逻辑名 (与 rebuild_mapping.py 保持一致)
STD = {
"达人名称": "creator_name",
"达人昵称": "creator_name",
"达人ID": "creator_id",
"达人id": "creator_id",
"达人id号": "creator_id",
"星图ID": "creator_id",
"小红书号": "xhs_id",
"发布笔记标题": "note_title",
"笔记标题": "note_title",
"标题": "note_title",
"主页链接": "profile_url",
"发布笔记链接": "note_url",
"发布链接": "note_url",
"笔记链接": "note_url",
"发布时间": "publish_time",
"投放平台": "platform",
"合作费用": "cost",
"快递单号": "tracking_no",
"7天曝光量": "read_count_7d",
"14天曝光量": "read_count_14d",
"21天曝光量": "read_count_21d",
"28天曝光量": "read_count_28d",
"曝光量(自动抓取)": "read_count_auto",
"60天曝光量": "read_count_60d",
"90天曝光量": "read_count_90d",
"120天曝光量": "read_count_120d",
"150天曝光量": "read_count_150d",
"180天曝光量": "read_count_180d",
"月底曝光量": "month_end",
"达人粉丝": "follower_count",
"微信号": "wechat",
"投放账号类型": "account_type",
"观看画像": "audience_profile",
"数据表现": "data_performance",
"发布笔记内容方向": "content_direction",
"互动赞藏数": "engagement_count",
"投流金额": "ad_spend",
"制单日期": "cooperation_date",
"审稿时间": "review_time",
"是否结款": "is_paid",
"是否新方向验证": "is_new_direction",
"cpm": "cpm",
}
_URL_RE = re.compile(r"https?://[^\s)>\]]+")
def _log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{datetime.now().strftime('%H:%M:%S')}] [feishu_mapping] {safe}", flush=True)
# ============================================================
# lark-cli 调用
# ============================================================
def call_lark_json(args: list[str]) -> dict:
cmd = [LARK_CLI] + args
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout if proc.stdout else (proc.stderr or "")
start = text.find("{")
if start < 0:
return {"ok": False, "error": f"no json: {text[:300]}"}
try:
return json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
return {"ok": False, "error": f"json decode: {e}"}
def _field_list(base_token: str, table_id: str) -> list[dict]:
resp = call_lark_json([
"base", "+field-list",
"--base-token", base_token,
"--table-id", table_id,
"--format", "json",
])
if not resp.get("ok"):
_log(f" [WARN] +field-list 失败 {base_token}/{table_id}: {resp.get('error')}")
return []
return resp.get("data", {}).get("fields", [])
def _record_list_all(base_token: str, table_id: str,
field_ids: list[str] | None = None) -> list[dict]:
"""拉一张表全部记录,自动分页。field_ids 不传=全字段。"""
all_records = []
offset = 0
base_args = [
"base", "+record-list",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--limit", "200",
]
while True:
args = base_args + ["--offset", str(offset)]
if field_ids:
for fid in field_ids:
args += ["--field-id", fid]
resp = call_lark_json(args)
if not resp.get("ok"):
raise RuntimeError(f"record-list 失败: {resp.get('error')}")
d = resp["data"]
ids = d["field_id_list"]
rids = d["record_id_list"]
rows = d["data"]
for i, row in enumerate(rows):
rec = {"record_id": rids[i]}
for j, fid in enumerate(ids):
rec[fid] = row[j] if j < len(row) else None
all_records.append(rec)
offset += len(rows)
if not d.get("has_more", False) or len(rows) == 0:
break
return all_records
# ============================================================
# 地址解析 & field_map 构建
# ============================================================
def parse_style_url(raw) -> tuple[str | None, str | None, str]:
"""从飞书多维表格地址单元格(可能是 markdown 链接或纯 URL)解析出
(base_token, table_id, url)。没有 table 参数时返回 (bt, None, url)。"""
if raw is None:
return None, None, ""
if isinstance(raw, list):
raw = " ".join(str(x) for x in raw if x)
s = str(raw).strip()
if not s:
return None, None, ""
m = _URL_RE.search(s)
url = m.group(0) if m else s
bt = None
mb = re.search(r"/base/([A-Za-z0-9]+)", url)
if mb:
bt = mb.group(1)
tid = None
mt = re.search(r"[?&]table=([A-Za-z0-9]+)", url)
if mt:
tid = mt.group(1)
return bt, tid, url
def build_field_map(fields: list[dict]) -> tuple[dict, list[str]]:
field_map = {}
all_field_names = []
for f in fields:
fid = f.get("id")
fname = f.get("name")
all_field_names.append(fname)
clean_name = fname.rstrip("?").rstrip() if fname else ""
logical = STD.get(fname) or STD.get(clean_name)
if not logical and clean_name:
for k, v in STD.items():
# 只做正向匹配: 字段名以已知 key 开头(如 "7天曝光量(自动)" 以 "7天曝光量" 开头)
# 不做反向匹配(k.startswith(clean_name)): 单字如"曝"会误匹配多个字段
if clean_name.startswith(k):
logical = v
break
if logical and logical not in field_map:
field_map[logical] = {"field_id": fid, "field_name": fname}
return field_map, all_field_names
def _norm_url(base_token: str, table_id: str, url: str) -> str:
if url:
return url
return (f"https://bu0zgpibak.feishu.cn/base/{base_token}"
f"?table={table_id}&view=vewStRNML4")
# ============================================================
# 重建 mapping
# ============================================================
def refresh_mapping(force: bool = True, ttl: float = CACHE_TTL,
mapping_path: Path | None = None,
write: bool = True,
url_field: str = INDEX_URL_FIELD) -> dict | None:
"""动态读取地址表,重建 mapping 并写缓存。
返回新的 mapping;失败返回 None(缓存仍可用时下游自行读缓存)。
write=False 时只构建内存中的 mapping,不落盘(供 rebuild_mapping.py --diff 用)。
url_field: 从索引表读哪一列 URL — 默认 INDEX_URL_FIELD(合作达人),
传 INDEX_SELF_URL_FIELD 则读自营达人列。
"""
is_self = url_field == INDEX_SELF_URL_FIELD
path = resolve_layer_output(
mapping_path or (SELF_MAPPING_PATH if is_self else MAPPING_PATH),
layer_root=PATHS.normalized_root,
field="mapping_path",
)
seed_path = SELF_MAPPING_SEED_PATH if is_self else MAPPING_SEED_PATH
if write:
path.parent.mkdir(parents=True, exist_ok=True)
if not force and path.exists():
age = time.time() - path.stat().st_mtime
if age < ttl:
return json.loads(path.read_text(encoding="utf-8"))
# 旧缓存用于:已知款式的 index 编号沿用 + platform 字段保留
old_by_name: dict[str, dict] = {}
old_platform = "xhs_pgy"
previous_path = path if path.exists() else seed_path
if previous_path.exists():
try:
old = json.loads(previous_path.read_text(encoding="utf-8"))
old_platform = old.get("platform", "xhs_pgy")
for t in old.get("tables", []):
if t.get("name"):
old_by_name[t["name"]] = t
except Exception as exc:
_log(f"读旧缓存失败(忽略): {exc}")
_log(f"动态读取地址表 {INDEX_BASE_TOKEN}/{INDEX_TABLE_ID} ...")
try:
index_records = _record_list_all(INDEX_BASE_TOKEN, INDEX_TABLE_ID)
except Exception as exc:
_log(f"读取地址表失败: {exc}")
return None
_log(f"地址表共 {len(index_records)} 行")
# 需要先知道字段名→field_id 映射,取「款式」和「合作达人多维表格地址」两列
fields = _field_list(INDEX_BASE_TOKEN, INDEX_TABLE_ID)
name2id = {}
for f in fields:
name2id[f.get("name")] = f.get("id")
style_fid = name2id.get(INDEX_STYLE_FIELD)
url_fid = name2id.get(url_field)
# 兜底: 合作达人列历史曾叫「飞书多维表格地址」
if not url_fid and url_field == INDEX_URL_FIELD:
url_fid = name2id.get("飞书多维表格地址")
if not style_fid or not url_fid:
_log(f" [ERR] 地址表找不到字段 '{INDEX_STYLE_FIELD}'/'{url_field}'")
return None
# 去重:同一款式只保留第一条 款式+地址 都有且地址里能解析出 table_id 的行
seen_styles: set[str] = set()
raw_styles: list[dict] = [] # {name, base_token, table_id, url}
for rec in index_records:
name = rec.get(style_fid)
if isinstance(name, list):
name = " ".join(str(x) for x in name if x).strip()
if isinstance(name, str):
name = name.strip()
if not name:
continue
bt, tid, url = parse_style_url(rec.get(url_fid))
if not bt:
continue
# 没 table_id 的行先跳过;若有同款式的后续行带 table_id 会补上
if not tid:
continue
if name in seen_styles:
continue
seen_styles.add(name)
raw_styles.append({"name": name, "base_token": bt,
"table_id": tid, "url": _norm_url(bt, tid, url)})
_log(f"去重后款式 {len(raw_styles)} 个,开始拉各表字段定义...")
# index 编号:已知款式用旧编号,新款式往后追加
used_idx: set[int] = set()
for s in old_by_name.values():
idx = s.get("index")
if isinstance(idx, int):
used_idx.add(idx)
def next_idx() -> int:
n = 1
while n in used_idx:
n += 1
used_idx.add(n)
return n
tables = []
for s in raw_styles:
fields = _field_list(s["base_token"], s["table_id"])
if not fields:
_log(f" [WARN] [{s['name']}] 拉不到字段,跳过")
continue
field_map, all_field_names = build_field_map(fields)
old_entry = old_by_name.get(s["name"])
if old_entry and isinstance(old_entry.get("index"), int):
idx = old_entry["index"]
used_idx.add(idx)
else:
idx = next_idx()
total_records = old_entry.get("total_records", 0) if old_entry else 0
tables.append({
"index": idx,
"name": s["name"],
"base_token": s["base_token"],
"table_id": s["table_id"],
"url": s["url"],
"field_map": field_map,
"all_field_names": all_field_names,
"total_records": total_records,
})
_log(f" [{idx:02d}] {s['name']}: {len(field_map)} 逻辑字段 / {len(all_field_names)} 列")
tables.sort(key=lambda t: t["index"])
label = "自营" if is_self else "合作达人"
mapping = {
"_comment": (f"款式 → 多维表格 对照表({label}); 由 feishu_mapping.py 从{label}地址表动态生成。"
" field_map 给出本表核心字段的 field_id (实时从飞书拉取)"),
"platform": old_platform,
"tables": tables,
}
# 原子写:先写临时文件再 rename,避免并行进程读到半截 JSON
if write:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(mapping, ensure_ascii=False, indent=2),
encoding="utf-8")
os.replace(tmp, path)
_log(f"已写回缓存 ({len(tables)} 款式): {path}")
else:
_log(f"[--diff] 仅构建内存 mapping ({len(tables)} 款式),未落盘")
return mapping
def load_mapping(data_dir: Path | None = None,
force_refresh: bool = False,
ttl: float = CACHE_TTL,
self_operated: bool = False) -> dict:
"""供三个 scraper 调用:保证有 mapping 可用。
优先:缓存新鲜就用缓存;否则动态刷新;刷新失败退回缓存;再不行报错退出。
self_operated=True 时读自营达人 mapping。"""
del data_dir # Legacy compatibility: runtime mappings have one canonical cache.
path = SELF_MAPPING_PATH if self_operated else MAPPING_PATH
seed_path = SELF_MAPPING_SEED_PATH if self_operated else MAPPING_SEED_PATH
# 缓存是否新鲜
fresh = path.exists() and (time.time() - path.stat().st_mtime) < ttl
if not force_refresh and fresh:
return json.loads(path.read_text(encoding="utf-8"))
url_field = INDEX_SELF_URL_FIELD if self_operated else INDEX_URL_FIELD
new = refresh_mapping(force=True, ttl=0, mapping_path=path, url_field=url_field)
if new is not None:
return new
if not path.exists() and seed_path.exists():
_log(f"dynamic refresh failed; using read-only seed: {seed_path}")
return json.loads(seed_path.read_text(encoding="utf-8"))
# 刷新失败 → 退回缓存(无论新旧)
if path.exists():
_log(f"动态刷新失败,使用缓存: {path}")
return json.loads(path.read_text(encoding="utf-8"))
_log("[FATAL] 动态刷新失败且无缓存可用")
raise RuntimeError("无法获取款式对照表(动态读取失败且无缓存)")
if __name__ == "__main__":
# 手动跑一次:python feishu_mapping.py
m = refresh_mapping(force=True)
if m:
ts = m.get("tables", [])
print(f"\n款式数: {len(ts)}")
for t in ts:
print(f" [{t['index']:02d}] {t['name']} bt={t['base_token']} tid={t['table_id']}")
@@ -0,0 +1,112 @@
"""Open Xiaohongshu and Douyin in real Chrome so you can log in manually.
Cookies and browser profiles are saved to the same locations used by the scrapers.
"""
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from playwright.sync_api import Page
from scrapling.fetchers import DynamicSession
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def wait_for_login(page: Page, platform: str, timeout: int = 300) -> None:
"""Wait until login prompts disappear from the page."""
login_markers = {
"xiaohongshu": ("扫码登录", "登录后", "验证码"),
"douyin": ("扫码登录", "登录后"),
"bilibili": ("扫码登录", "短信登录", "密码登录"),
}
markers = login_markers.get(platform, ())
deadline = time.time() + timeout
while time.time() < deadline:
try:
body = page.locator("body").inner_text(timeout=5000)
except Exception:
body = ""
if not any(marker in body for marker in markers):
log(f"{platform}: login state detected (no login prompt)")
return
log(f"{platform}: waiting for login... ({int(deadline - time.time())}s left)")
page.wait_for_timeout(3000)
log(f"{platform}: timed out waiting for login")
def login_platform(platform: str, url: str, profile_dir: Path, cookie_file: Path, save_state_fn: Any, restore_cookies_fn: Any, timeout: int = 300) -> None:
log(f"Opening {platform} for manual login: {url}")
profile_dir.mkdir(exist_ok=True)
def action(page: Page) -> None:
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(4000)
wait_for_login(page, platform, timeout)
save_state_fn(page)
log(f"Saved {platform} cookies to {cookie_file}")
with DynamicSession(
headless=False,
real_chrome=True,
user_data_dir=str(profile_dir),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies_fn,
max_pages=1,
) as session:
session.fetch(url, page_action=action, wait=1000)
def main() -> int:
# 1. Xiaohongshu
login_platform(
platform="xiaohongshu",
url="https://www.xiaohongshu.com/explore",
profile_dir=xiaohongshu_comment_scraper.PROFILE_DIR,
cookie_file=xiaohongshu_comment_scraper.COOKIE_FILE,
save_state_fn=xiaohongshu_comment_scraper.save_state,
restore_cookies_fn=xiaohongshu_comment_scraper.restore_cookies,
)
# 2. Douyin
login_platform(
platform="douyin",
url="https://www.douyin.com/",
profile_dir=douyin_comment_scraper.PROFILE_DIR,
cookie_file=douyin_comment_scraper.COOKIE_FILE,
save_state_fn=douyin_comment_scraper.save_state,
restore_cookies_fn=douyin_comment_scraper.restore_cookies,
)
# 3. Bilibili
login_platform(
platform="bilibili",
url="https://passport.bilibili.com/login",
profile_dir=bilibili_comment_scraper.PROFILE_DIR,
cookie_file=bilibili_comment_scraper.COOKIE_FILE,
save_state_fn=bilibili_comment_scraper.save_state,
restore_cookies_fn=bilibili_comment_scraper.restore_cookies,
)
log("All platforms logged in and cookies saved.")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,450 @@
"""月度笔记汇总:对上月各款所有笔记做横向对比分析(曝光分层 × 平台特性)。
与 weekly_summary_all.py 的区别:
- 时间范围:上月 1 号到月底(动态算)
- Prompt:曝光分层 × 平台特性修正效率评级(S/A/B/C + 平台价值定位)
- 入库:cmt_monthly_summary 表(UNIQUE(style_id, month_start))
- 飞书写入:生命进程表的"笔记分析汇总"字段,时间标月度范围
流程:
1. 读索引表拿每款生命进程表地址(复用 weekly 的 load_index_styles)
2. PG 查上月该款所有笔记(按 publish_time 过滤)
3. 对每款:跑单品分析(复用 note_<id>.txt)+ LLM 横向对比 + 创建飞书文档 + 写入生命进程表 + 入库
用法:
python monthly_summary_all.py # 跑所有上月有笔记的款
python monthly_summary_all.py --style 宙斯 # 只跑某款
python monthly_summary_all.py --dry-run # 不写飞书/PG
python monthly_summary_all.py --max-workers 4 # 并发数
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
sys.stdout.reconfigure(encoding='utf-8')
# 复用 weekly_summary_all 的工具函数
from gyxx_flow.modules.content_marketing.runtime.weekly_summary_all import (
LARK, DATA_DIR, REPORTS_DIR, dry_run_global,
URL_RE, log, call_lark_json, parse_url, parse_bt_tid,
load_index_styles, find_target_fields, read_report,
run_single_note_analysis, create_feishu_doc, write_to_target_table,
)
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import call_hermes_analyzer
# ============================================================
# 月时间范围:动态算上月 1 号到月底
# ============================================================
def _compute_last_month():
"""返回 (month_start, month_end) 都是 date 对象"""
today = datetime.now().date()
# 上月 1 号 = 本月 1 号 - 1 天
first_of_this_month = today.replace(day=1)
month_end = first_of_this_month - timedelta(days=1)
month_start = month_end.replace(day=1)
return month_start, month_end
_DEFAULT_START, _DEFAULT_END = _compute_last_month()
MONTH_START = os.environ.get("MONTH_SINCE", _DEFAULT_START.strftime("%Y-%m-%d"))
MONTH_END = os.environ.get("MONTH_UNTIL", _DEFAULT_END.strftime("%Y-%m-%d"))
TIME_LABEL = f"{MONTH_START}~{MONTH_END[5:]}"
# ============================================================
# PG 查上月笔记
# ============================================================
def get_last_month_notes(style_name):
"""从 PG 拿上月该款的所有笔记(按 publish_time 过滤)"""
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
SELECT n.id, n.platform, n.title, n.url, n.view_count,
n.comment_count, n.like_count, n.favorite_count, n.share_count,
c.name AS creator_name
FROM cmt_notes n
JOIN cmt_styles s ON n.style_id = s.id
LEFT JOIN cmt_creators c ON n.creator_id = c.id
WHERE s.name = %s AND n.self_operated = FALSE
AND n.publish_time IS NOT NULL
AND n.publish_time >= %s AND n.publish_time < %s
ORDER BY n.id
""", (style_name, MONTH_START, MONTH_END + " 23:59:59"))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, r)) for r in cur.fetchall()]
# ============================================================
# LLM 横向对比 prompt(曝光分层 × 平台特性)
# ============================================================
MONTHLY_PROMPT = """你是光影行星品牌「{style}」款式的批量内容横向分析师。
请对以下【{n} 篇笔记清单】进行横向对比分析,严格遵循"先按曝光量级分层,再按平台特性修正效率评级"的双维逻辑。
【本月笔记清单】
{notes}
【第一步:曝光量级分层(硬性门槛)】
| 平台 | 曝光"高"阈值 | 曝光"低"阈值 |
| :--- | :--- | :--- |
| 抖音 | ≥ 50万 | < 50万 |
| 小红书 | ≥ 10万 | < 10万 |
| B站 | ≥ 30万 | < 30万 |
- 高曝光 + 高效率 = **S级(明星爆款)**
- 低曝光 + 高效率 = **A级(潜力黑马)**
- 高曝光 + 低效率 = **B级(虚假繁荣)**
- 低曝光 + 低效率 = **C级(失败品)**
【第二步:平台特性修正系数(判断"效率高/低"时,标准各不同)】
### 1. 抖音(公域推荐 × 情绪爆发)
- 互动率 ≥ 5% 为高;完播率 ≥ 40% 为高;品牌提及率 ≥ 12% 为高
- 核心观察窗口:发布后 24 小时
- 主要价值定位:爆款驱动、即时冲动、快速拉新
- 抖音特有分析维度:前 3 秒留存率 / 评论区情绪词 vs 理性词占比
### 2. 小红书(搜索心智 × 长尾效应)
- 互动率 ≥ 6% 为高;收藏率 ≥ 2% 为高;品牌提及率 ≥ 20% 为高
- 核心观察窗口:发布后 3-7 天
- 主要价值定位:深度种草、搜索拦截、心智沉淀
- 小红书特有分析维度:搜索词关联度(品类词+场景词) / 评论区"求链接"比例
### 3. B站(深度信任 × 长决策周期)
- 互动率 ≥ 4% 为高;平均观看时长 ≥ 3 分钟为高;品牌提及率 ≥ 25% 为高
- 核心观察窗口:发布后 7-14 天
- 主要价值定位:深度测评、信任建立、高客单价转化
- B站特有分析维度:评论区提问深度(竞品对比 vs 简单咨询) / 弹幕密度曲线
【输出要求】
按以下结构输出(Markdown 格式,适合贴到飞书文本字段):
# {style} 月度笔记汇总({time_range})
## 一、本月投放概览
- 投放笔记数:{n} 篇
- 涉及平台:[列出各平台笔记数]
- 总曝光 / 总互动:[汇总数据]
- 整体表现:[一句话定性]
## 二、分层排序总表
按 S -> A -> B -> C 输出,同一层级内按"曝光量×互动率"降序排列。
| 层级 | 笔记标题 | 平台 | 达人 | 曝光 | 互动率 | 品牌提及率 | 平台价值定位 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| S级 | ... | ... | ... | ... | ... | ... | 爆款拉新/深度种草/信任转化 |
| A级 | ... | ... | ... | ... | ... | ... | ... |
| B级 | ... | ... | ... | ... | ... | ... | ... |
| C级 | ... | ... | ... | ... | ... | ... | ... |
## 三、跨平台对比洞察
- 哪个平台的高效笔记占比最高? -> 重点倾斜预算的平台
- 哪个平台的 S 级笔记内容公式最清晰? -> 优先复制到其他平台进行跨平台移植测试
- 各平台笔记数分布是否均衡
## 四、各平台 Top3 笔记内容特征总结
### 抖音 Top3(如有)
- 是否都是"前3秒强钩子+情绪共鸣"型?
- 列出 3 篇标题 + 共性特征
### 小红书 Top3(如有)
- 是否都包含"搜索关键词+高收藏价值"?
- 列出 3 篇标题 + 共性特征
### B站 Top3(如有)
- 是否都是"深度信息密度+竞品对比"型?
- 列出 3 篇标题 + 共性特征
若发现某平台 Top3 不符合该平台典型特征(如小红书 Top3 全是情绪段子而非实用内容),说明该平台选号或 Brief 方向已跑偏,需明确指出。
## 五、预算再分配建议
结合平台特性,给出下月预算比例建议:
- 抖音:X%(理由)
- 小红书:X%(理由)
- B站:X%(理由)
## 六、下轮 Brief 平台差异化修改指令
针对各平台分别输出 1 条 Brief 修改指令:
- 抖音 Brief 补什么:强调"前3秒留存"和"品牌名口播重复2次"
- 小红书 Brief 补什么:强调"标题含品类搜索词"和"收藏率优化"
- B站 Brief 补什么:强调"深度对比实测"和"评论区答疑准备"
【约束】
- 分层必须严格按曝光阈值(抖音50万/小红书10万/B站30万)判定,不放宽
- 效率判定必须按各平台特性标准(抖音5%/小红书6%/B站4%),不混用
- 表格必须填充完整数据,不要留空或写"无"
- 每条建议须具体可执行,避免"加强""优化"等空泛词汇
- 整体篇幅 2500-4000 字
- **必须把完整的 Markdown 报告作为响应内容直接返回,不要写到本地文件,不要只给摘要,不要提及"文件已保存"或"月报已生成"等元信息。响应正文就是最终报告本身。**"""
def generate_monthly_summary(style, notes):
"""调 LLM 生成月度横向对比汇总"""
notes_block = []
for i, n in enumerate(notes, 1):
views = n.get('view_count')
views_str = f"{views:,}" if views else "未采集"
notes_block.append(
f"### 笔记{i}: {n['title']}\n"
f"- 平台: {n['platform']}\n- 达人: {n.get('creator_name', '?')}\n"
f"- 曝光: {views_str} / 点赞: {n.get('like_count') or 0} / "
f"评论: {n.get('comment_count') or 0} / 收藏: {n.get('favorite_count') or 0} / "
f"分享: {n.get('share_count') or 0}\n"
)
prompt = MONTHLY_PROMPT.format(
style=style,
notes="\n---\n".join(notes_block),
time_range=TIME_LABEL,
n=len(notes),
)
system = "你是光影行星品牌的批量内容横向分析师,擅长按曝光分层 × 平台特性双维逻辑做笔记横向对比分析。"
return call_hermes_analyzer(system, prompt)
# ============================================================
# 入库 cmt_monthly_summary
# ============================================================
def save_to_db_monthly(style, notes, summary, doc_url, doc_title, status):
"""把月度汇总结果写入 PG cmt_monthly_summary(UNIQUE(style_id, month_start))"""
if dry_run_global:
return False
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT id FROM cmt_styles WHERE name = %s", (style,))
row = cur.fetchone()
if not row:
log(f" [DB] 款式 {style} 不在 cmt_styles,跳过入库")
return False
style_id = row[0]
total_views = sum((n.get('view_count') or 0) for n in notes) or None
total_comments = sum((n.get('comment_count') or 0) for n in notes)
total_engagement = sum(
((n.get('like_count') or 0) + (n.get('favorite_count') or 0) +
(n.get('comment_count') or 0) + (n.get('share_count') or 0))
for n in notes) or None
cur.execute("""
INSERT INTO cmt_monthly_summary
(style_id, month_start, month_end, time_label,
note_count, total_comments, total_views, total_engagement,
doc_url, doc_title, summary_chars, status,
created_at, updated_at)
VALUES
(%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (style_id, month_start) DO UPDATE SET
note_count = EXCLUDED.note_count,
total_comments = EXCLUDED.total_comments,
total_views = EXCLUDED.total_views,
total_engagement = EXCLUDED.total_engagement,
doc_url = EXCLUDED.doc_url,
doc_title = EXCLUDED.doc_title,
summary_chars = EXCLUDED.summary_chars,
status = EXCLUDED.status,
updated_at = CURRENT_TIMESTAMP
""", (style_id, MONTH_START, MONTH_END, TIME_LABEL,
len(notes), total_comments, total_views, total_engagement,
doc_url, doc_title, len(summary) if summary else None, status))
conn.commit()
log(f" ✅ 已入库 cmt_monthly_summary (style_id={style_id}, month={MONTH_START})")
return True
except Exception as exc:
log(f" [DB ERR] {style} 入库失败: {exc}")
return False
# ============================================================
# 处理一个款
# ============================================================
def process_one_style_monthly(style, index_info):
"""处理一个款的月度汇总"""
bt, tid, url = index_info
log(f"\n========== {style} (月度) ==========")
log(f" 生命进程表: {bt}/{tid}")
def _save(notes_list, summary, doc_url, doc_title, status):
save_to_db_monthly(style, notes_list, summary, doc_url, doc_title, status)
time_fid, summary_fid = find_target_fields(bt, tid)
if not time_fid or not summary_fid:
log(f" [ERR] 找不到 时间/笔记分析汇总 字段,跳过")
_save([], None, None, None, "no_fields")
return {"style": style, "status": "no_fields", "notes": 0}
notes = get_last_month_notes(style)
log(f" 上月笔记: {len(notes)} 篇")
if not notes:
log(f" [SKIP] 上月无笔记")
_save([], None, None, None, "no_notes")
return {"style": style, "status": "no_notes", "notes": 0}
for n in notes:
n["report_path"] = run_single_note_analysis(n["id"])
valid_notes = [n for n in notes if n.get("report_path")]
if not valid_notes:
log(f" [ERR] 没有有效报告,跳过")
_save(notes, None, None, None, "no_reports")
return {"style": style, "status": "no_reports", "notes": len(notes)}
log(f" 调 LLM 横向对比 {len(valid_notes)} 篇报告...")
summary = generate_monthly_summary(style, valid_notes)
if not summary:
log(f" [ERR] LLM 综合失败")
_save(notes, None, None, None, "llm_failed")
return {"style": style, "status": "llm_failed", "notes": len(notes)}
out_path = REPORTS_DIR / f"{style}_月度汇总_{TIME_LABEL}.txt"
out_path.write_text(summary, encoding="utf-8")
log(f" 本地副本: {out_path.name} ({len(summary)} 字符)")
if dry_run_global:
log(f" [DRY] 跳过飞书文档创建和写入")
_save(notes, summary, None, None, "dry_run")
return {"style": style, "status": "dry_run", "notes": len(notes), "chars": len(summary)}
doc_url, err = create_feishu_doc_monthly(style, summary)
if not doc_url:
log(f" [ERR] {err}")
_save(notes, summary, None, None, "doc_failed")
return {"style": style, "status": "doc_failed", "notes": len(notes)}
log(f" 飞书文档: {doc_url}")
ok = write_to_target_table(bt, tid, time_fid, summary_fid, style, doc_url, dry_run_global)
doc_title = f"{style} 月度汇总({TIME_LABEL})"
_save(notes, summary, doc_url, doc_title, "ok" if ok else "feishu_write_failed")
if ok:
log(f" ✅ 已写入生命进程表")
return {"style": style, "status": "ok", "notes": len(notes),
"doc_url": doc_url, "chars": len(summary)}
log(f" [ERR] 写入表失败")
return {"style": style, "status": "write_failed", "notes": len(notes)}
def create_feishu_doc_monthly(style, summary):
"""创建飞书文档(月度),返回 url"""
doc_title = f"{style} 月度汇总({TIME_LABEL})"
md_file = PATHS.tmp_root / "monthly_summary_doc.md"
md_file.parent.mkdir(parents=True, exist_ok=True)
md_file.write_text(summary, encoding="utf-8")
cmd = [LARK, "docs", "+create",
"--as", "user", "--format", "json",
"--title", doc_title,
"--doc-format", "markdown",
"--content", f"@{md_file.name}"]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout or proc.stderr
start = text.find("{")
if start < 0:
return None, f"创建失败: {text[:300]}"
resp = json.loads(text[start:])
if not resp.get("ok"):
return None, f"创建失败: {resp.get('error')}"
url = resp.get("data", {}).get("document", {}).get("url", "")
return url, None
# ============================================================
# main
# ============================================================
def main():
parser = argparse.ArgumentParser(description="月度笔记汇总:对上月各款所有笔记做横向对比分析")
parser.add_argument("--style", help="只跑指定款式名")
parser.add_argument("--dry-run", action="store_true", help="不写飞书/PG")
parser.add_argument("--max-workers", type=int, default=4, help="款之间并发数(默认 4)")
args = parser.parse_args()
global dry_run_global
# 引用 weekly 的全局变量(改它会影响 weekly 模块,但 dry_run_global 在 weekly 顶部定义)
from gyxx_flow.modules.content_marketing.runtime import weekly_summary_all
weekly_summary_all.dry_run_global = args.dry_run
# 本模块的 dry_run_global 是 import 时的副本,需要同步
globals()['dry_run_global'] = args.dry_run
log(f"时间范围: {TIME_LABEL} (月度)")
index_styles = load_index_styles()
if not index_styles:
log("索引表无有效数据,退出")
return 1
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
SELECT s.name, COUNT(n.id)
FROM cmt_styles s
JOIN cmt_notes n ON n.style_id = s.id AND n.self_operated = FALSE
WHERE n.publish_time IS NOT NULL
AND n.publish_time >= %s AND n.publish_time < %s
GROUP BY s.name
ORDER BY s.name
""", (MONTH_START, MONTH_END + " 23:59:59"))
pg_styles = [(r[0], r[1]) for r in cur.fetchall()]
log(f"PG 上月有笔记的款: {len(pg_styles)} 个")
to_run = []
for name, note_count in pg_styles:
if args.style and name != args.style:
continue
if name in index_styles:
to_run.append((name, index_styles[name], note_count))
else:
log(f" [SKIP] {name}: 索引表无'每周笔记分析'地址")
log(f"待跑款式: {len(to_run)} 个")
if not to_run:
return 0
import concurrent.futures
log(f"并发跑 {len(to_run)} 款, max_workers={args.max_workers}")
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.max_workers) as executor:
future_to_style = {
executor.submit(process_one_style_monthly, name, info): (name, ncount)
for name, info, ncount in to_run
}
for future in concurrent.futures.as_completed(future_to_style):
name, ncount = future_to_style[future]
try:
r = future.result()
r["note_count"] = ncount
results.append(r)
log(f" ✅ 完成: {name} -> {r['status']}")
except Exception as exc:
log(f" [ERR] {name} 异常: {exc}")
results.append({"style": name, "status": "exception",
"error": str(exc), "note_count": ncount})
results.sort(key=lambda r: r.get("style", ""))
log(f"\n{'=' * 50}")
log(f"全部完成,汇总:")
for r in results:
log(f" {r['style']}: {r['status']} (笔记 {r.get('note_count', 0)} 篇)")
log(f"{'=' * 50}")
out = REPORTS_DIR / f"_monthly_summary_all_{TIME_LABEL}.json"
out.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"结果已存: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,424 @@
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from typing import Any
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
from scrapling.fetchers import DynamicSession
LOGIN_URL = "https://pgy.xiaohongshu.com/"
HOME_URL = "https://pgy.xiaohongshu.com/solar/pre-trade/home"
DETAIL_URL_MARK = "/solar/pre-trade/blogger-detail/"
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def load_cookies() -> list[dict[str, Any]]:
if not COOKIE_FILE.exists():
return []
try:
data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except json.JSONDecodeError:
log(f"Cookie file is invalid, ignoring: {COOKIE_FILE}")
return []
def save_state(page: Page) -> None:
DATA_DIR.mkdir(exist_ok=True)
cookies = page.context.cookies()
COOKIE_FILE.write_text(
json.dumps(cookies, ensure_ascii=False, indent=2),
encoding="utf-8",
)
storage_state = page.context.storage_state()
PATHS.browser_storage_state_file.write_text(
json.dumps(storage_state, ensure_ascii=False, indent=2),
encoding="utf-8",
)
log(f"Saved cookies: {COOKIE_FILE}")
def restore_cookies(page: Page) -> None:
cookies = load_cookies()
if not cookies:
return
try:
page.context.add_cookies(cookies)
log(f"Loaded cookies: {COOKIE_FILE}")
except Exception as exc:
log(f"Failed to load cookies, will continue with browser profile: {exc}")
def is_logged_in(page: Page) -> bool:
if "/solar/pre-trade/" in page.url:
return True
try:
page.get_by_text("找博主", exact=True).wait_for(timeout=2500)
return True
except PlaywrightTimeoutError:
return False
def wait_for_scan_login(page: Page, timeout_seconds: int) -> None:
log("Not logged in. Please scan the QR code or finish login in the opened Chrome window.")
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if is_logged_in(page):
save_state(page)
return
try:
login_button = page.get_by_text(re.compile("登录|账号登录")).first
if login_button.count():
login_button.click(timeout=1000)
except Exception:
pass
page.wait_for_timeout(2000)
raise TimeoutError(f"Login timed out after {timeout_seconds} seconds.")
def ensure_login(page: Page, login_timeout: int) -> None:
page.goto(HOME_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2500)
if is_logged_in(page):
log("Already logged in, using saved browser session.")
dismiss_onboarding(page)
save_state(page)
return
page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(1500)
wait_for_scan_login(page, login_timeout)
page.goto(HOME_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_load_state("domcontentloaded")
dismiss_onboarding(page)
def dismiss_onboarding(page: Page) -> None:
for text in ("跳过", "不再提示", "我知道了"):
try:
button = page.get_by_text(text, exact=True).first
if button.count():
button.click(timeout=1500)
page.wait_for_timeout(800)
except Exception:
pass
def fill_search_box(page: Page, blogger: str) -> None:
dismiss_onboarding(page)
switch_search_type_to_blogger(page)
input_locator = page.locator("input").filter(
has_not_text=re.compile("有什么问题尽管问我")
).first
input_locator.wait_for(state="visible", timeout=30000)
input_locator.click()
input_locator.fill(blogger)
input_locator.press("Enter")
try:
page.wait_for_url(re.compile(r".*/solar/pre-trade/note/kol.*"), timeout=15000)
except PlaywrightTimeoutError:
# Some versions require clicking the red search icon after entering text.
page.evaluate(
"""
() => {
const input = [...document.querySelectorAll('input')]
.find((el) => /博主昵称|小红书号/.test(el.placeholder || '') || el.value);
const box = input?.closest('div');
const candidates = [...document.querySelectorAll('button, [role="button"], div')]
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width >= 24 && r.width <= 80 && r.height >= 24 && r.height <= 80;
});
const near = candidates.find((el) => {
const er = el.getBoundingClientRect();
const br = box?.getBoundingClientRect();
return br && er.left > br.right - 20 && Math.abs(er.top - br.top) < 80;
});
near?.click();
}
"""
)
page.wait_for_url(re.compile(r".*/solar/pre-trade/note/kol.*"), timeout=30000)
page.wait_for_timeout(3000)
def switch_search_type_to_blogger(page: Page) -> None:
current_type = page.get_by_text(re.compile("笔记关键词|博主昵称"), exact=False).first
try:
current_type.click(timeout=3000)
page.wait_for_timeout(600)
option = page.get_by_text("博主昵称", exact=True).last
if option.count():
option.click(timeout=3000)
page.wait_for_timeout(600)
except Exception:
page.evaluate(
"""
() => {
const nodes = [...document.querySelectorAll('div, span, button')];
const current = nodes.find((el) => /笔记关键词|博主昵称/.test(el.innerText || ''));
current?.click();
setTimeout(() => {
const option = [...document.querySelectorAll('div, span, button')]
.reverse()
.find((el) => (el.innerText || '').trim() === '博主昵称');
option?.click();
}, 300);
}
"""
)
page.wait_for_timeout(1000)
def open_blogger_detail(page: Page, blogger: str) -> Page:
log(f"Opening blogger detail: {blogger}")
pages_before = list(page.context.pages)
clicked = page.evaluate(
"""
(name) => {
const textHasName = (el) => (el.innerText || '').includes(name);
const rows = [...document.querySelectorAll(
'tr, [class*="row"], [class*="table"], [class*="card"], [class*="item"]'
)].filter(textHasName);
const row = rows.find((el) => /粉丝|阅读|报价|合作/.test(el.innerText || '')) || rows[0];
if (!row) return false;
const target = row.querySelector('img, a, [class*="avatar"], [class*="name"]') || row;
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
return true;
}
""",
blogger,
)
if not clicked:
page.get_by_text(blogger, exact=False).first.click(timeout=10000)
detail_page = wait_for_detail_page(page, pages_before, timeout_ms=30000)
if detail_page is None:
# If the first click only focused the row, try the visible name once more.
page.get_by_text(blogger, exact=False).first.click(timeout=10000)
detail_page = wait_for_detail_page(page, pages_before, timeout_ms=30000)
if detail_page is None:
raise TimeoutError("Clicked blogger result, but no blogger-detail page was detected.")
detail_page.bring_to_front()
detail_page.wait_for_load_state("domcontentloaded")
detail_page.get_by_text("笔记数据", exact=False).first.wait_for(timeout=30000)
detail_page.get_by_text("笔记数据", exact=False).first.click(timeout=5000)
detail_page.get_by_text("笔记数据", exact=False).first.scroll_into_view_if_needed()
detail_page.wait_for_timeout(2500)
return detail_page
def wait_for_detail_page(page: Page, pages_before: list[Page], timeout_ms: int) -> Page | None:
deadline = time.time() + timeout_ms / 1000
known = set(pages_before)
while time.time() < deadline:
all_pages = list(page.context.pages)
candidates = [p for p in all_pages if p not in known] + [page] + all_pages
for candidate in candidates:
try:
if DETAIL_URL_MARK in candidate.url:
return candidate
except Exception:
pass
page.wait_for_timeout(500)
return None
def parse_notes_from_dom(page: Page) -> list[dict[str, str]]:
return page.evaluate(
"""
() => {
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const parseCard = (card) => {
const rawLines = (card.innerText || '')
.split('\\n')
.map((line) => line.trim())
.filter(Boolean);
const text = rawLines.join('\\n');
if (!/阅读/.test(text) || !/点赞/.test(text) || !/收藏/.test(text) || !/发布时间/.test(text)) return null;
const readAfter = (label) => {
const index = rawLines.findIndex((line) => line === label || line.includes(label));
if (index < 0) return '';
const sameLine = rawLines[index].replace(label, '').trim();
if (sameLine) return sameLine;
return rawLines[index + 1] || '';
};
const dateMatch = text.match(/20\\d{2}[./-]\\d{1,2}[./-]\\d{1,2}/);
const readIndex = rawLines.findIndex((line) => line === '阅读' || line.includes('阅读'));
const titleCandidates = rawLines.slice(0, readIndex >= 0 ? readIndex : rawLines.length).filter((line) => {
if (['阅读', '点赞', '收藏', '发布时间'].includes(line)) return false;
if (/^20\\d{2}[./-]/.test(line)) return false;
if (/^(合作|流量|图文笔记|视频笔记|全部类型|首页|笔记主页|数据概览|笔记数据|粉丝分析|笔记案例)$/.test(line)) return false;
if (/^[\\d,.wW万]+$/.test(line)) return false;
if (/中位数|获赞与|粉丝数|报价|筛选|全部流量|最新发布/.test(line)) return false;
return line.length >= 2;
});
const title = titleCandidates.slice(-2).join(' ').trim();
return {
title: clean(title),
read_count: clean(readAfter('阅读')),
like_count: clean(readAfter('点赞')),
collect_count: clean(readAfter('收藏')),
publish_time: clean(readAfter('发布时间') || (dateMatch ? dateMatch[0] : '')),
};
};
const all = [...document.querySelectorAll('div')];
const cards = all.filter((el) => {
const text = el.innerText || '';
const rect = el.getBoundingClientRect();
return rect.width >= 120 &&
rect.width <= 280 &&
rect.height >= 220 &&
rect.height <= 520 &&
/阅读/.test(text) &&
/点赞/.test(text) &&
/收藏/.test(text) &&
/发布时间/.test(text) &&
el.querySelector('img');
});
const parsed = cards.map(parseCard).filter(Boolean);
const seen = new Set();
return parsed.filter((item) => {
const key = [item.title, item.read_count, item.like_count, item.collect_count, item.publish_time].join('|');
if (seen.has(key)) return false;
seen.add(key);
return item.title || item.publish_time;
});
}
"""
)
def write_outputs(blogger: str, notes: list[dict[str, str]]) -> tuple[Path, Path]:
DATA_DIR.mkdir(exist_ok=True)
safe_name = re.sub(r'[<>:"/\\\\|?*\\s]+', "_", blogger).strip("_") or "blogger"
json_path = DATA_DIR / f"{safe_name}_notes.json"
csv_path = DATA_DIR / f"{safe_name}_notes.csv"
payload = {
"blogger": blogger,
"scraped_at": datetime.now().isoformat(timespec="seconds"),
"count": len(notes),
"notes": notes,
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(
file,
fieldnames=["title", "read_count", "like_count", "collect_count", "publish_time"],
)
writer.writeheader()
writer.writerows(notes)
return json_path, csv_path
def load_names(cli_names: list[str], names_file: str | None) -> list[str]:
names: list[str] = []
names.extend(name.strip() for name in cli_names if name.strip())
if names_file:
path = Path(names_file)
for line in path.read_text(encoding="utf-8-sig").splitlines():
name = line.strip()
if name and not name.startswith("#"):
names.append(name)
deduped: list[str] = []
seen = set()
for name in names:
if name not in seen:
seen.add(name)
deduped.append(name)
return deduped
def scrape(blogger: str, login_timeout: int, headless: bool) -> list[dict[str, str]]:
result: dict[str, Any] = {"notes": []}
def action(page: Page) -> None:
ensure_login(page, login_timeout)
fill_search_box(page, blogger)
detail_page = open_blogger_detail(page, blogger)
result["detail_url"] = detail_page.url
result["notes"] = parse_notes_from_dom(detail_page)
save_state(detail_page)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch(HOME_URL, page_action=action, wait=1000)
log(f"Detail URL: {result.get('detail_url', '')}")
return result["notes"]
def main() -> int:
parser = argparse.ArgumentParser(
description="Scrape XiaoHongShu Pgy blogger note metrics with Scrapling DynamicSession.",
)
parser.add_argument("bloggers", nargs="*", help="博主昵称,可传多个,例如:博主A 博主B")
parser.add_argument("--names-file", help="批量博主名单文件,一行一个昵称")
parser.add_argument("--login-timeout", type=int, default=300, help="首次扫码登录等待秒数")
parser.add_argument("--headless", action="store_true", help="无头模式;首次登录不要使用")
args = parser.parse_args()
bloggers = load_names(args.bloggers, args.names_file)
if not bloggers:
parser.error("Please provide at least one blogger name or --names-file.")
for index, blogger in enumerate(bloggers, start=1):
log(f"=== Blogger {index}/{len(bloggers)}: {blogger} ===")
notes = scrape(blogger, args.login_timeout, args.headless)
json_path, csv_path = write_outputs(blogger, notes)
log(f"Scraped {len(notes)} notes")
log(f"JSON: {json_path}")
log(f"CSV: {csv_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
scrapling[fetchers]>=0.4.8
playwright>=1.59.0
psycopg[binary]>=3.1
python-dotenv>=1.0
@@ -0,0 +1,622 @@
#!/usr/bin/env python
"""
一键跑 3 个平台 (B 站 + 小红书蒲公英 + 星图) 的 V2 抓取,真并行。
并行原理:
- bilibili_scraper.py: 纯 HTTP (requests.Session),独立进程无冲突
- pgy_xhs_scraper_v2.py: Scrapling 启独立 Chrome (profile=.pgy_chrome_profile)
- xingtu_scraper_v2.py: Scrapling 启独立 Chrome (profile=.xingtu_chrome_profile)
三个进程 stdin/stdout 各自走独立文件,互不干扰。
汇总报告 = 3 个 _all_summaries*.json 聚合。
v2_results/*.json 由 daily_run.bat step4 的 sync_metrics_to_cmt_notes.py 同步到 PG。
用法:
python run_all.py # 只跑合作达人(实写)
python run_all.py --dry-run # 只抓不写
python run_all.py --skip pgy # 跳过小红书
python run_all.py --skip bili,xt # 跳过 B 站和星图
python run_all.py --style 7 # 只跑款式 7
python run_all.py --style 7,8,15 # 跑多款
python run_all.py --include-self-operated # 合作+自营都跑
python run_all.py --self-operated-only # 只跑自营达人
"""
import argparse
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
BASE_DIR = PATHS.module_root
V2_DIR = PATHS.normalized_root / "v2_results"
RUN_REPORT_DIR = PATHS.curated_root / "run_all"
LOG_DIR = PATHS.logs_root / "run_all"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# 三个脚本定义
SCRIPTS = {
"bili": {
"name": "B 站",
"script": "bilibili_scraper.py",
"summary_file": "_all_summaries_bilibili.json",
"result_prefix": "_bilibili_v2",
},
"pgy": {
"name": "小红书蒲公英",
"script": "pgy_xhs_scraper_v2.py",
"summary_file": "_all_summaries.json",
"result_prefix": "_v2",
},
"xt": {
"name": "星图",
"script": "xingtu_scraper_v2.py",
"summary_file": "_all_summaries_xingtu.json",
"result_prefix": "_xingtu_v2",
},
}
# 自营达人脚本: 抖音用 self_douyin_scraper.py, B站/小红书复用现有脚本加 --self-operated
SELF_SCRIPTS = {
"bili": {
"name": "B 站(自营)",
"script": "bilibili_scraper.py",
"summary_file": "_all_summaries_self_bilibili.json",
"extra_args": ["--self-operated"],
},
"pgy": {
"name": "小红书(自营)",
"script": "pgy_xhs_scraper_v2.py",
"summary_file": "_all_summaries_self.json",
"extra_args": ["--self-operated"],
},
"xt": {
"name": "抖音(自营)",
"script": "self_douyin_scraper.py",
"summary_file": "_all_summaries_self_xingtu.json",
"extra_args": [],
},
}
def log(msg: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
safe = msg.encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{ts}] [run_all] {safe}", flush=True)
def build_cmd(key: str, args: argparse.Namespace) -> list[str]:
"""构造单个脚本的命令行"""
info = SCRIPTS[key]
cmd = [sys.executable, str(BASE_DIR / info["script"])]
if args.dry_run:
cmd.append("--dry-run")
if args.style:
# 多个款式循环传
pass # 脚本自身支持 --style N
return cmd
def start_process(key: str, args: argparse.Namespace,
self_operated: bool = False) -> tuple[subprocess.Popen, Path, Path]:
"""启动一个抓取进程,返回 (process, stdout_log_path, stderr_log_path)"""
info = SELF_SCRIPTS[key] if self_operated else SCRIPTS[key]
cmd = [sys.executable, str(BASE_DIR / info["script"])]
if args.dry_run:
cmd.append("--dry-run")
if args.style:
for s in args.style:
cmd += ["--style", str(s)]
cmd.extend(info.get("extra_args", []))
stdout_path = LOG_DIR / f"{key}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.stdout.log"
stderr_path = LOG_DIR / f"{key}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.stderr.log"
stdout_f = open(stdout_path, "w", encoding="utf-8")
stderr_f = open(stderr_path, "w", encoding="utf-8")
# Windows 用 CREATE_NEW_PROCESS_GROUP 方便 kill
creationflags = 0
if sys.platform == "win32":
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
env = {
**os.environ,
"PYTHONIOENCODING": "utf-8",
"PYTHONUNBUFFERED": "1",
}
log(f"启动 [{key}] ({info['name']}): {info['script']} 日志: {stdout_path.name}")
p = subprocess.Popen(
cmd,
cwd=str(BASE_DIR),
stdout=stdout_f,
stderr=stderr_f,
env=env,
creationflags=creationflags,
)
return p, stdout_path, stderr_path
def kill_all(procs: dict) -> None:
"""Ctrl+C 或异常时,杀掉所有子进程"""
for key, p in procs.items():
if p.poll() is None:
log(f" kill [{key}] (pid={p.pid})")
try:
if sys.platform == "win32":
p.terminate() # Windows: CTRL_BREAK_EVENT
else:
p.terminate()
except Exception as exc:
log(f" kill [{key}] 失败: {exc}")
def wait_all(procs: dict) -> dict[str, int]:
"""等待所有进程结束,返回 {key: returncode}"""
rc = {}
while procs:
for key in list(procs.keys()):
p = procs[key]
ret = p.poll()
if ret is not None:
rc[key] = ret
log(f" [{key}] 完成, exit={ret}")
del procs[key]
if procs:
time.sleep(2)
return rc
def _summary_path(key: str, self_operated: bool = False) -> Path:
info = SCRIPTS[key]
name = info["summary_file"]
if self_operated:
name = name.replace("_all_summaries", "_all_summaries_self", 1)
return V2_DIR / name
def _item_total(item: dict) -> int | None:
raw = item.get("total")
if raw is None:
raw = item.get("total_b_records")
if raw is None:
raw = item.get("total_tasks")
try:
value = int(raw or 0)
except (TypeError, ValueError):
return None
return value if value >= 0 else None
def _item_rows(item: dict) -> list[dict]:
raw = item.get("results")
if raw is None:
raw = item.get("details")
if not isinstance(raw, list):
return []
return [row for row in raw if isinstance(row, dict)]
def _row_is_unresolved(row: dict) -> bool:
status = row.get("status")
if status == "blocked_input":
return False
explicit_failure = bool(
row.get("matched") is False
or row.get("ok") is False
or row.get("write_ok") is False
or row.get("error")
or row.get("reason")
)
if status == "success":
return explicit_failure
if status:
return True
return explicit_failure
def _item_unresolved_count(item: dict) -> int:
total = _item_total(item) or 0
rows = _item_rows(item)
record_ids = [str(row["record_id"]) for row in rows if row.get("record_id")]
missing = max(0, total - len(rows))
missing_ids = max(0, len(rows) - len(record_ids))
duplicates = max(0, len(record_ids) - len(set(record_ids)))
row_failures = sum(1 for row in rows if _row_is_unresolved(row))
derived = missing + missing_ids + duplicates + row_failures
declared_parts = 0
for key in (
"retryable_failures", "write_failures", "missing_results",
"duplicate_results",
):
try:
declared_parts += max(0, int(item.get(key) or 0))
except (TypeError, ValueError):
declared_parts += 1
try:
declared_total = max(0, int(item.get("unresolved") or 0))
except (TypeError, ValueError):
declared_total = 1
declared = max(declared_parts, declared_total)
if item.get("complete") is False:
declared = max(declared, 1)
return max(derived, declared)
def validate_summary_payload(payload, expected_indices: set[int], path: Path,
started_at: float) -> tuple[bool, str]:
"""验证汇总确实属于本轮且完整覆盖所选款式。"""
try:
if not path.exists():
return False, "summary file missing"
if path.stat().st_mtime < started_at:
return False, "summary file is stale"
except OSError as exc:
return False, f"summary stat failed: {exc}"
if not isinstance(payload, list):
return False, "summary payload is not a list"
indexed_items: list[tuple[int, dict]] = []
for item in payload:
if not isinstance(item, dict):
return False, "summary contains non-object item"
try:
idx = int(item.get("index"))
except (TypeError, ValueError):
return False, "summary item has invalid index"
indexed_items.append((idx, item))
indices = [idx for idx, _item in indexed_items]
if len(indices) != len(set(indices)):
return False, "summary has duplicate style index"
actual_indices = set(indices)
missing_indices = set(expected_indices) - actual_indices
if missing_indices:
return False, (
f"style coverage mismatch expected={sorted(expected_indices)} "
f"actual={sorted(actual_indices)}"
)
# 采集器会按款式合并全局汇总;单款运行后文件中可能保留其他款式的
# 历史结果。这里只校验本轮明确选择的款式,完整运行时 expected_indices
# 本身就是全部当前款式,仍会严格校验全量。
for idx, item in indexed_items:
if idx not in expected_indices:
continue
if item.get("error"):
return False, f"style {idx} reports error: {item.get('error')}"
if started_at > 0:
try:
item_started_at = float(item.get("run_started_at"))
except (TypeError, ValueError):
return False, f"style {idx} has no current run marker"
if item_started_at < started_at:
return False, f"style {idx} was not generated in current run"
total = _item_total(item)
if total is None:
return False, f"style {idx} has invalid total"
rows = _item_rows(item)
record_ids = [str(row["record_id"]) for row in rows if row.get("record_id")]
if len(rows) != total:
return False, f"style {idx} result coverage {len(rows)}/{total}"
if len(record_ids) != len(rows):
return False, f"style {idx} has result without record_id"
if len(set(record_ids)) != len(record_ids):
return False, f"style {idx} has duplicate record_id"
unresolved = _item_unresolved_count(item)
if unresolved:
return False, f"style {idx} has {unresolved} unresolved result(s)"
return True, ""
def load_summary(key: str, self_operated: bool = False, *,
started_at: float | None = None,
expected_indices: set[int] | None = None) -> dict | list | None:
"""加载并可选地验证单个平台本轮汇总 JSON。"""
p = _summary_path(key, self_operated)
if not p.exists():
return None
try:
with open(p, "r", encoding="utf-8") as f:
payload = json.load(f)
except Exception as exc:
return {"error": f"load summary failed: {exc}"}
if started_at is not None and expected_indices is not None:
valid, reason = validate_summary_payload(payload, expected_indices, p, started_at)
if not valid:
return {"error": f"invalid summary: {reason}", "payload": payload}
return payload
def aggregate(summaries: dict[str, dict]) -> dict:
"""聚合三个平台的汇总"""
out = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"platforms": {},
"totals": {
"styles": 0, "tasks": 0, "updated": 0, "skipped": 0,
"unresolved": 0, "errors": 0, "hit_rate_pct": 0.0,
},
}
for key, s in summaries.items():
if s is None:
out["platforms"][key] = {"name": SCRIPTS[key]["name"], "loaded": False}
continue
validation_error = None
if isinstance(s, dict) and "error" in s:
validation_error = s["error"]
if isinstance(s.get("payload"), list):
s = s["payload"]
else:
out["platforms"][key] = {
"name": SCRIPTS[key]["name"], "error": validation_error
}
continue
# 三个脚本的 summary 结构略有不同,做归一化
# pgy: [{style, results, total, matched, filled, ...}]
# xt: 同 pgy
# bili: [{style, total_b_records, updated, skipped, details}]
platform = {
"name": SCRIPTS[key]["name"],
"loaded": True,
"styles": len(s) if isinstance(s, list) else 0,
"tasks": 0,
"updated": 0,
"skipped": 0,
"unresolved": 0,
"errors": 0,
"hit_rate_pct": 0.0,
}
if validation_error:
platform["validation_error"] = validation_error
if isinstance(s, list):
for item in s:
t = _item_total(item) or 0
# 命中:pgy/xt 用 "matched",bili 用 "updated"
u = (item.get("matched") if item.get("matched") is not None
else item.get("updated") if item.get("updated") is not None
else item.get("hits") if item.get("hits") is not None
else 0)
# 跳过:只有 bili 用 "skipped",pgy/xt 没用
sk = (item.get("skipped") if item.get("skipped") is not None
else item.get("misses") if item.get("misses") is not None
else 0)
declared_errors = (item.get("error_count") if item.get("error_count") is not None
else item.get("errors") if item.get("errors") is not None
else 0)
try:
declared_errors = int(declared_errors or 0)
except (TypeError, ValueError):
declared_errors = 1
unresolved = _item_unresolved_count(item)
err = max(declared_errors, unresolved)
platform["tasks"] += t
platform["updated"] += u
platform["skipped"] += sk
platform["unresolved"] += unresolved
platform["errors"] += err
if platform["tasks"] > 0:
platform["hit_rate_pct"] = round(100.0 * platform["updated"] / platform["tasks"], 1)
out["platforms"][key] = platform
out["totals"]["styles"] += platform["styles"]
out["totals"]["tasks"] += platform["tasks"]
out["totals"]["updated"] += platform["updated"]
out["totals"]["skipped"] += platform["skipped"]
out["totals"]["unresolved"] += platform["unresolved"]
out["totals"]["errors"] += platform["errors"]
if out["totals"]["tasks"] > 0:
out["totals"]["hit_rate_pct"] = round(
100.0 * out["totals"]["updated"] / out["totals"]["tasks"], 1
)
return out
def print_report(agg: dict, dur_sec: float) -> None:
"""打印汇总报告"""
print()
print("=" * 70)
print(f" V2 全平台汇总报告 (总耗时 {dur_sec:.1f}s)")
print("=" * 70)
print(f" {'平台':<14} {'款式':>5} {'任务':>6} {'命中':>6} {'跳过':>6} {'未完成':>6} {'错误':>5} {'命中率':>7}")
print(" " + "-" * 72)
for key, p in agg["platforms"].items():
if p.get("loaded"):
print(f" {p['name']:<14} {p['styles']:>5} {p['tasks']:>6} "
f"{p['updated']:>6} {p['skipped']:>6} {p['unresolved']:>6} {p['errors']:>5} "
f"{p['hit_rate_pct']:>6.1f}%")
if p.get("validation_error"):
print(f" [不完整] {p['validation_error']}")
else:
err = p.get('error', 'no summary file')
print(f" {SCRIPTS[key]['name']:<14} -- ({err})")
t = agg["totals"]
print(" " + "-" * 72)
print(f" {'合计':<14} {t['styles']:>5} {t['tasks']:>6} "
f"{t['updated']:>6} {t['skipped']:>6} {t['unresolved']:>6} {t['errors']:>5} "
f"{t['hit_rate_pct']:>6.1f}%")
print("=" * 70)
print(f" 详细日志: {LOG_DIR}")
print(f" 详细结果: {V2_DIR}/<款式>{['_v2', '_xingtu_v2', '_bilibili_v2'][0]}.json 等")
print()
def _expected_style_indices(args: argparse.Namespace,
self_operated: bool = False) -> set[int]:
if args.style:
return {int(idx) for idx in args.style}
try:
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
mapping = feishu_mapping.load_mapping(self_operated=self_operated)
return {
int(style["index"])
for style in mapping.get("tables", [])
if isinstance(style, dict) and style.get("index")
}
except Exception as exc:
log(f"[ERROR] 无法读取期望款式列表: {exc}")
return set()
def _atomic_write_json(path: Path, payload) -> None:
tmp = path.with_name(path.name + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def run_round(args: argparse.Namespace, active: list[str],
self_operated: bool = False) -> dict:
"""跑一轮(合作达人或自营达人),返回聚合报告 dict。"""
label = "自营达人" if self_operated else "合作达人"
log(f"===== 开始 {label} 抓取 =====")
print("=" * 70)
print(f" V2 三平台并行抓取 — {label} ({len(active)}/{len(SCRIPTS)} 个平台)")
print(f" 模式: {'DRY-RUN (不写)' if args.dry_run else '正式跑 (写回多维表格)'}")
if args.style:
print(f" 款式: {','.join(str(s) for s in args.style)}")
else:
try:
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping as _fm
n = len(_fm.load_mapping(self_operated=self_operated).get("tables", []))
print(f" 款式: 全部 {n} 款")
except Exception:
print(f" 款式: 全部")
print(f" 平台: {','.join(SCRIPTS[k]['name'] for k in active)}")
print("=" * 70)
expected_indices = _expected_style_indices(args, self_operated)
if not expected_indices:
reason = "expected style mapping is empty"
log(f"[ERROR] {reason}")
return {"error": reason, "_non_zero_platforms": list(active)}
procs = {}
log_files = {}
start = time.time()
try:
for key in active:
p, out_path, err_path = start_process(key, args, self_operated=self_operated)
procs[key] = p
log_files[key] = (out_path, err_path)
except Exception as exc:
log(f"启动失败: {exc}")
kill_all(procs)
return {"error": str(exc), "_non_zero_platforms": list(active)}
try:
rc = wait_all(procs)
except KeyboardInterrupt:
log("Ctrl+C - 终止所有子进程...")
kill_all(procs)
return {"error": "interrupted", "_non_zero_platforms": list(active)}
dur = time.time() - start
log(f"{label} 完成,耗时 {dur:.1f}s exit codes: {rc}")
summaries = {
k: load_summary(
k, self_operated=self_operated,
started_at=start, expected_indices=expected_indices,
)
for k in active
}
agg = aggregate(summaries)
print_report(agg, dur)
report_suffix = "_self" if self_operated else ""
report_path = RUN_REPORT_DIR / f"_run_all_report{report_suffix}.json"
report_path.parent.mkdir(parents=True, exist_ok=True)
agg["duration_sec"] = round(dur, 1)
agg["exit_codes"] = rc
agg["self_operated"] = self_operated
agg["log_files"] = {
k: {"stdout": str(v[0].name), "stderr": str(v[1].name)}
for k, v in log_files.items()
}
non_zero = {k for k, v in rc.items() if v != 0}
non_zero.update(
k for k, summary in summaries.items()
if summary is None or (isinstance(summary, dict) and summary.get("error"))
)
agg["_non_zero_platforms"] = sorted(non_zero)
_atomic_write_json(report_path, agg)
log(f"{label} 汇总报告: {report_path}")
return agg
def main() -> int:
parser = argparse.ArgumentParser(
description="一键跑 3 个平台 V2 抓取(真并行,带汇总报告)"
)
parser.add_argument("--dry-run", action="store_true", help="三个脚本都只抓不写")
parser.add_argument("--style", type=int, action="append",
help="只跑指定款式编号(可多次)。不传 = 全跑")
parser.add_argument("--skip", type=str, default="",
help="跳过平台,逗号分隔。可选: bili,pgy,xt。例如 --skip xt,xt")
parser.add_argument("--include-self-operated", action="store_true",
help="同时跑自营达人(默认只跑合作达人)")
parser.add_argument("--self-operated-only", action="store_true",
help="只跑自营达人")
args = parser.parse_args()
skip_set = set(s.strip() for s in args.skip.split(",") if s.strip())
for s in skip_set:
if s not in SCRIPTS:
print(f"[ERROR] --skip 未知平台: {s} (可选: {','.join(SCRIPTS.keys())})")
return 1
active = [k for k in SCRIPTS.keys() if k not in skip_set]
if not active:
print("[ERROR] --skip 全部平台,没东西可跑")
return 1
# 启动并行子进程前,先刷新款式对照表,
# 避免 3 个子进程各自刷新时重复打 lark-cli 并抢写缓存文件。
run_self = args.include_self_operated or args.self_operated_only
try:
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
m = feishu_mapping.refresh_mapping(force=True)
if m:
log(f"合作达人款式对照表已动态刷新: {len(m.get('tables', []))} 个款式")
except Exception as exc:
log(f"[WARN] 动态刷新合作达人款式对照表失败(将退回缓存): {exc}")
if run_self:
try:
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
m = feishu_mapping.refresh_mapping(force=True, url_field=feishu_mapping.INDEX_SELF_URL_FIELD)
if m:
log(f"自营达人款式对照表已动态刷新: {len(m.get('tables', []))} 个款式")
except Exception as exc:
log(f"[WARN] 动态刷新自营达人款式对照表失败(将退回缓存): {exc}")
any_error = False
# 第一轮: 合作达人(除非 --self-operated-only)
if not args.self_operated_only:
agg = run_round(args, active, self_operated=False)
if agg.get("_non_zero_platforms"):
log(f"[WARN] 合作达人: 以下平台 exit != 0: {agg['_non_zero_platforms']}")
any_error = True
# 第二轮: 自营达人(仅 --include-self-operated 或 --self-operated-only 时跑)
if args.include_self_operated or args.self_operated_only:
agg = run_round(args, active, self_operated=True)
if agg.get("_non_zero_platforms"):
log(f"[WARN] 自营达人: 以下平台 exit != 0: {agg['_non_zero_platforms']}")
any_error = True
return 1 if any_error else 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,87 @@
"""Portable path boundary for the migrated content-marketing runtime.
Code and immutable resources live below ``GYXX_MODULE_ROOT``. All mutable
state is kept below ``GYXX_DATA_ROOT`` so a deployment can move either tree
without editing business scripts.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from gyxx_flow.core import DataLayout, Settings
def _path_from_env(name: str, default: Path) -> Path:
value = os.environ.get(name, "").strip()
return Path(value).expanduser().resolve() if value else default.resolve()
module_root = _path_from_env("GYXX_MODULE_ROOT", Path(__file__).resolve().parent)
data_root = _path_from_env("GYXX_DATA_ROOT", Settings.from_env().data_root)
_DATA_PATHS = DataLayout(data_root).for_module("content_marketing")
state_root = _DATA_PATHS.state_root
raw_root = _DATA_PATHS.raw_root
normalized_root = _DATA_PATHS.normalized_root
curated_root = _DATA_PATHS.curated_root
exports_root = _DATA_PATHS.exports_root
evidence_root = _DATA_PATHS.evidence_root
logs_root = _DATA_PATHS.logs_root
tmp_root = _DATA_PATHS.tmp_root
@dataclass(frozen=True)
class RuntimePaths:
module_root: Path = module_root
data_root: Path = data_root
state_root: Path = state_root
raw_root: Path = raw_root
normalized_root: Path = normalized_root
curated_root: Path = curated_root
exports_root: Path = exports_root
evidence_root: Path = evidence_root
logs_root: Path = logs_root
tmp_root: Path = tmp_root
config_root: Path = module_root / "data" / "config"
tools_root: Path = module_root / "data" / "tools"
browser_profile_dir: Path = _path_from_env(
"GYXX_BROWSER_PROFILE_DIR", state_root / "browser-profiles" / "default"
)
browser_cookie_file: Path = _path_from_env(
"GYXX_BROWSER_COOKIE_FILE", state_root / "cookies" / "default.json"
)
browser_storage_state_file: Path = _path_from_env(
"GYXX_BROWSER_STORAGE_STATE_FILE",
state_root / "cookies" / "default_storage_state.json",
)
PATHS = RuntimePaths()
def resolve_layer_output(
configured: str | Path,
*,
layer_root: Path,
field: str = "output path",
) -> Path:
"""Resolve a caller-supplied output without allowing a data-layer escape."""
if not isinstance(configured, (str, Path)) or not str(configured).strip():
raise ValueError(f"{field} must be a non-empty path")
root = Path(layer_root).expanduser().resolve()
requested = Path(configured).expanduser()
candidate = (
requested.resolve()
if requested.is_absolute()
else (root / requested).resolve()
)
if not candidate.is_relative_to(root):
raise ValueError(f"{field} must stay inside {root}")
return candidate
__all__ = ["PATHS", "RuntimePaths", "resolve_layer_output"]
@@ -0,0 +1,455 @@
"""
自营 B 站采集: B 站公开 API (api.bilibili.com/x/web-interface/view) 抓播放量
============================================================================
- 不用登录,不用 cookie
- 动态读取自营 mapping (feishu_mapping.load_mapping self_operated=True)
- 遍历每个有 base_token+table_id 的款式表
- 过滤 platform=B站 的记录,必须有 note_url
- 从 note_url 抽 BV/AV 号,调 API 拿 stat.view + data.pubdate
- 飞书无发布时间 → 用 API pubdate (毫秒时间戳) 填进去,再按时槽回填播放量
- 时槽规则跟 chanmama_scraper.py / bilibili_scraper.py 一致:
0-7 天 → 7天曝光量
8-14 天 → 14天曝光量
15-21 天 → 21天曝光量
22-28 天 → 28天曝光量
29+ 天 → 月底曝光量
用法:
python self_bilibili_scraper.py # 全跑
python self_bilibili_scraper.py --dry-run # 只抓不写
python self_bilibili_scraper.py --style 1 # 只跑某款
python self_bilibili_scraper.py --delay 0.5 # 请求间隔
"""
import argparse
import io
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
# Windows 控制台输出用 UTF-8,避免 emoji 编码失败
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
import requests
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
LARK_CLI = "lark-cli.cmd"
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
_BV_RE = re.compile(r"BV[0-9A-Za-z]{10}")
_AV_RE = re.compile(r"av(\d+)", re.IGNORECASE)
_B23_RE = re.compile(r"https?://b23\.tv/\S+", re.IGNORECASE)
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_URL_RE = re.compile(r"https?://[^\s)>\]]+")
def log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{datetime.now().strftime('%H:%M:%S')}] {safe}", flush=True)
# ============================================================
# 飞书多维表格
# ============================================================
def call_lark_json(args: list[str]) -> dict:
cmd = [LARK_CLI] + args
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout if proc.stdout else (proc.stderr or "")
start = text.find("{")
if start < 0:
return {"ok": False, "error": f"no json: {text[:300]}"}
try:
return json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
return {"ok": False, "error": f"json decode: {e}"}
def list_records_by_table(base_token: str, table_id: str) -> list[dict]:
all_records = []
offset = 0
while True:
resp = call_lark_json([
"base", "+record-list",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--limit", "200",
"--offset", str(offset),
])
if not resp.get("ok"):
raise RuntimeError(f"record-list 失败: {resp.get('error')}")
d = resp["data"]
ids = d["field_id_list"]
rids = d["record_id_list"]
rows = d["data"]
for i, row in enumerate(rows):
rec = {"record_id": rids[i]}
for j, fid in enumerate(ids):
rec[fid] = row[j] if j < len(row) else None
all_records.append(rec)
offset += len(rows)
if not d.get("has_more", False) or len(rows) == 0:
break
return all_records
def write_back(base_token: str, table_id: str, record_id: str,
field_id: str, value) -> bool:
payload = {field_id: value}
resp = call_lark_json([
"base", "+record-upsert",
"--base-token", base_token,
"--table-id", table_id,
"--record-id", record_id,
"--as", "user",
"--format", "json",
"--json", json.dumps(payload, ensure_ascii=False),
])
if not resp.get("ok"):
log(f" ❌ 回填失败 record_id={record_id}: {resp.get('error')}")
return False
return True
# ============================================================
# URL / B 站 API
# ============================================================
def extract_url(text) -> str | None:
"""从 note_url 字段抽真实 URL,处理 markdown / 纯文本 / b23 短链"""
if text is None:
return None
if isinstance(text, list):
text = text[0] if text else None
if not text:
return None
s = str(text).strip()
m = _MD_LINK_RE.search(s)
if m:
return m.group(2).strip()
m = _URL_RE.search(s)
if m:
return m.group(0).strip()
return None
def resolve_b23(url: str, session: requests.Session) -> str | None:
try:
r = session.get(url, allow_redirects=True, timeout=10,
headers={"User-Agent": _UA})
return r.url
except Exception as exc:
log(f" [WARN] b23 解析失败: {exc}")
return None
def fetch_bili_stat(url: str, session: requests.Session) -> tuple[int | None, int | None]:
"""调 B 站 API,返回 (play_count, pubdate_unix_seconds)。
pubdate 是视频发布时间(Unix 秒),用于补飞书的发布时间字段。
"""
real = url
if _B23_RE.match(url):
resolved = resolve_b23(url, session)
if not resolved:
return None, None
real = resolved
bv = _BV_RE.search(real)
av = _AV_RE.search(real)
if not bv and not av:
return None, None
params = {"bvid": bv.group(0)} if bv else {"aid": av.group(1)}
try:
r = session.get("https://api.bilibili.com/x/web-interface/view",
params=params, timeout=10,
headers={"User-Agent": _UA,
"Referer": "https://www.bilibili.com/"})
j = r.json()
if j.get("code") != 0:
return None, None
data = j.get("data", {})
stat = data.get("stat", {})
view = stat.get("view")
pubdate = data.get("pubdate")
return (int(view) if view is not None else None,
int(pubdate) if pubdate else None)
except Exception as exc:
log(f" [WARN] B 站 API: {exc}")
return None, None
# ============================================================
# 时间 / 槽位
# ============================================================
def parse_publish_time(value) -> datetime | None:
"""飞书发布时间字段可能是毫秒时间戳 / 字符串 / datetime。"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
try:
if value > 1e12:
return datetime.fromtimestamp(value / 1000)
return datetime.fromtimestamp(value)
except (ValueError, OSError):
return None
if isinstance(value, str):
v = value.strip()
if not v:
return None
if v.isdigit():
try:
iv = int(v)
if iv > 1e12:
return datetime.fromtimestamp(iv / 1000)
return datetime.fromtimestamp(iv)
except (ValueError, OSError):
return None
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d", "%Y/%m/%d"):
try:
return datetime.strptime(v[:len(fmt) + 2], fmt)
except ValueError:
continue
return None
def pick_read_field(fmap: dict, pub_time: datetime | None,
today: datetime | None = None) -> tuple[str, str, str] | None:
if pub_time is None:
return None
today = today or datetime.now()
days = (today.date() - pub_time.date()).days
if days < 0:
return "FUTURE"
if days <= 7:
logical = "read_count_7d"
elif days <= 14:
logical = "read_count_14d"
elif days <= 21:
logical = "read_count_21d"
elif days <= 28:
logical = "read_count_28d"
else:
logical = "month_end"
info = fmap.get(logical)
if info:
return (logical, info["field_id"], info["field_name"])
return None
# ============================================================
# 采集一个款式
# ============================================================
def process_style(style: dict, dry_run: bool, delay: float,
session: requests.Session) -> dict:
base_token = style["base_token"]
table_id = style["table_id"]
fmap = style.get("field_map", {})
platform_fid = (fmap.get("platform") or {}).get("field_id")
url_fid = (fmap.get("note_url") or {}).get("field_id")
title_fid = (fmap.get("note_title") or {}).get("field_id")
pub_fid = (fmap.get("publish_time") or {}).get("field_id")
summary = {
"style": style["name"], "index": style["index"],
"total": 0, "matched": 0, "filled": 0,
"skipped_no_url": 0, "skipped_no_pubtime": 0,
"details": [],
}
if not platform_fid or not url_fid or not title_fid:
log(f" [WARN] platform/note_url/note_title 字段缺失,跳过")
return summary
try:
all_records = list_records_by_table(base_token, table_id)
except Exception as exc:
log(f" [ERR] 拉记录失败: {exc}")
return summary
def is_bili(v) -> bool:
if not v:
return False
if isinstance(v, list):
vals = [str(x).lower() for x in v if x]
if not vals:
return False
return any("b站" in s or "b 站" in s or "bilibili" in s for s in vals)
s = str(v).lower()
return "b站" in s or "b 站" in s or "bilibili" in s
bili_records = [r for r in all_records if is_bili(r.get(platform_fid))]
log(f" 全表 {len(all_records)} 条,B 站记录 {len(bili_records)} 条")
today = datetime.now()
for rec in bili_records:
title = rec.get(title_fid)
if isinstance(title, list):
title = " ".join(str(x) for x in title if x)
title = str(title or "").strip()
if not title:
continue
summary["total"] += 1
url = extract_url(rec.get(url_fid))
if not url:
log(f" [SKIP] 无 URL: '{title[:20]}'")
summary["skipped_no_url"] += 1
continue
# 抓 B 站 API:播放量 + pubdate
play, pubdate_unix = fetch_bili_stat(url, session)
if play is None:
log(f" [SKIP] API 抓不到: '{title[:20]}' (url={url[:50]})")
continue
summary["matched"] += 1
# 飞书发布时间
pub_time = parse_publish_time(rec.get(pub_fid))
# 飞书无发布时间 → 用 API pubdate 补,再做时槽判断
if pub_time is None and pubdate_unix is not None:
api_pub = datetime.fromtimestamp(pubdate_unix)
pub_str = api_pub.strftime("%Y-%m-%d %H:%M:%S")
if dry_run:
log(f" [DRY] '{title[:20]}' → 先填发布时间={pub_str}")
else:
ts_ms = int(api_pub.timestamp() * 1000)
ok = write_back(base_token, table_id, rec["record_id"],
pub_fid, ts_ms)
if ok:
log(f" ✅ '{title[:20]}' → 先填发布时间={pub_str}")
pub_time = api_pub
if pub_time is None:
log(f" [SKIP] 飞书+API 都无发布时间: '{title[:20]}'")
summary["skipped_no_pubtime"] += 1
continue
pick = pick_read_field(fmap, pub_time, today)
if pick == "FUTURE":
log(f" [SKIP] 发布时间在未来: '{title[:20]}'")
continue
if not pick:
log(f" [SKIP] 无对应曝光槽位字段: '{title[:20]}'")
continue
logical, fill_fid, fill_fname = pick
if dry_run:
log(f" [DRY] '{title[:20]}' → {fill_fname}={play}")
summary["filled"] += 1
summary["details"].append({
"record_id": rec["record_id"],
"title": title[:50],
"play_count": play,
"slot": logical,
"pub_time": pub_time.isoformat() if pub_time else None,
"matched": True,
})
if delay > 0:
time.sleep(delay)
continue
ok = write_back(base_token, table_id, rec["record_id"], fill_fid, play)
if ok:
summary["filled"] += 1
summary["details"].append({
"record_id": rec["record_id"],
"title": title[:50],
"play_count": play,
"slot": logical,
"pub_time": pub_time.isoformat() if pub_time else None,
"matched": True,
"ok": True,
})
log(f" ✅ '{title[:20]}' → {fill_fname}={play}")
if delay > 0:
time.sleep(delay)
return summary
# ============================================================
# main
# ============================================================
def main() -> int:
parser = argparse.ArgumentParser(
description="自营 B 站采集: B 站公开 API 抓播放量,飞书无发布时间用 API pubdate 补")
parser.add_argument("--style", type=int, help="只跑指定款式编号")
parser.add_argument("--dry-run", action="store_true", help="不写回飞书")
parser.add_argument("--delay", type=float, default=0.3,
help="每条请求间隔秒数(防风控)")
args = parser.parse_args()
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
try:
mapping = feishu_mapping.load_mapping(self_operated=True)
except Exception as exc:
log(f"❌ 加载自营 mapping 失败: {exc}")
return 1
styles = mapping.get("tables", [])
if args.style:
styles = [s for s in styles if s.get("index") == args.style]
if not styles:
log(f"⚠️ 未找到款式 {args.style}")
return 1
out_dir = PATHS.normalized_root / "v2_results"
out_dir.mkdir(parents=True, exist_ok=True)
session = requests.Session()
session.headers.update({"User-Agent": _UA})
print("=" * 50)
print(f"🔄 自营 B 站采集 (dry_run={args.dry_run})")
print(f" 款式数: {len(styles)}")
print("=" * 50)
all_summaries = []
for s in styles:
log(f"\n--- [{s['index']:02d}] {s['name']} ---")
summary = process_style(s, args.dry_run, args.delay, session)
all_summaries.append(summary)
log(f" 小计: 任务={summary['total']} 命中={summary['matched']} 回填={summary['filled']}")
out_file = out_dir / f"{s['index']:02d}-{s['name']}_self_bilibili_v2.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
final_path = out_dir / "_all_summaries_self_bilibili.json"
with open(final_path, "w", encoding="utf-8") as f:
json.dump(all_summaries, f, ensure_ascii=False, indent=2)
total = sum(s["total"] for s in all_summaries)
matched = sum(s["matched"] for s in all_summaries)
filled = sum(s["filled"] for s in all_summaries)
print(f"\n{'=' * 50}")
print(f"📊 完成: 任务={total} 命中={matched} 回填={filled}")
if args.dry_run:
print(" (dry-run,未写飞书)")
print(f"{'=' * 50}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,692 @@
"""
自营抖音采集器: 登录 douyin.com, 打开自己账号作品页, 按标题匹配飞书记录, 回填曝光量
与星图采集器(xingtu_scraper_v2.py)的核心区别:
- 星图: 搜索达人 → 打开详情页 → 找视频
- 自营抖音: 直接打开自己账号的作品列表 → 滚动加载 → 匹配标题
Cookie/登录: 复用 douyin_comment_scraper 的 .douyin_chrome_profile + douyin_cookies.json
飞书写回: 复用 lark-cli + field_map 体系, 结果文件用 _self_xingtu_v2.json 后缀
用法:
python self_douyin_scraper.py # 全跑
python self_douyin_scraper.py --dry-run # 只抓不写
python self_douyin_scraper.py --style 3 # 只跑款式3
python self_douyin_scraper.py --login-only # 只触发登录
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from playwright.sync_api import Page
from scrapling.fetchers import DynamicSession
from gyxx_flow.modules.content_marketing.runtime.collection_completeness import (
BLOCKED_INPUT,
RETRYABLE_FAILURE,
SUCCESS,
WRITE_FAILURE,
add_repeatable_style_argument,
atomic_write_json,
finalize_summary,
match_tasks_to_cards,
merge_global_summaries,
merge_style_summary,
select_requested_styles,
)
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
STATE_DIR = PATHS.state_root
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
SELF_POST_URL = "https://www.douyin.com/user/self?showTab=post"
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
LARK_CLI = "lark-cli.cmd"
def log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{datetime.now().strftime('%H:%M:%S')}] {safe}", flush=True)
# ============================================================
# 飞书多维表格
# ============================================================
def call_lark_json(args: list[str]) -> dict:
cmd = [LARK_CLI] + args
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env, encoding="utf-8", errors="replace")
text = proc.stdout if proc.stdout else (proc.stderr or "")
start = text.find("{")
if start < 0:
return {"ok": False, "error": f"no json: {text[:300]}"}
try:
return json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
return {"ok": False, "error": f"json decode: {e}"}
def load_mapping() -> dict:
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
return feishu_mapping.load_mapping(DATA_DIR, self_operated=True)
def list_records_by_table(base_token: str, table_id: str) -> list[dict]:
all_records = []
offset = 0
while True:
resp = call_lark_json([
"base", "+record-list",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--limit", "200",
"--offset", str(offset),
])
if not resp.get("ok"):
raise RuntimeError(f"record-list 失败: {resp.get('error')}")
d = resp["data"]
ids = d["field_id_list"]
rids = d["record_id_list"]
rows = d["data"]
for i, row in enumerate(rows):
rec = {"record_id": rids[i]}
for j, fid in enumerate(ids):
rec[fid] = row[j] if j < len(row) else None
all_records.append(rec)
offset += len(rows)
if not d.get("has_more", False) or len(rows) == 0:
break
return all_records
def write_back(base_token: str, table_id: str, record_id: str,
field_id: str, value) -> bool:
payload = {field_id: value}
resp = call_lark_json([
"base", "+record-upsert",
"--base-token", base_token,
"--table-id", table_id,
"--record-id", record_id,
"--as", "user",
"--format", "json",
"--json", json.dumps(payload, ensure_ascii=False),
])
if not resp.get("ok"):
log(f" 回填失败 record_id={record_id}: {resp.get('error')}")
return False
return True
# ============================================================
# 时间/槽位
# ============================================================
def parse_publish_time(value) -> datetime | None:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(value / 1000)
except (ValueError, OSError):
return None
if isinstance(value, str):
v = value.strip()
if not v:
return None
if v.isdigit():
try:
return datetime.fromtimestamp(int(v) / 1000)
except (ValueError, OSError):
return None
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"):
try:
return datetime.strptime(v[:len(fmt) + 2], fmt)
except ValueError:
continue
return None
def pick_read_field(fmap: dict, pub_time: datetime | None,
today: datetime | None = None) -> tuple[str, str, str] | None:
if pub_time is None:
return None
today = today or datetime.now()
days = (today.date() - pub_time.date()).days
if days < 0:
return "FUTURE"
if days <= 7:
logical = "read_count_7d"
elif days <= 14:
logical = "read_count_14d"
elif days <= 21:
logical = "read_count_21d"
elif days <= 28:
logical = "read_count_28d"
else:
logical = "month_end"
info = fmap.get(logical)
if info:
return (logical, info["field_id"], info["field_name"])
return None
# ============================================================
# 数字解析
# ============================================================
def parse_int(v) -> int | None:
if v is None:
return None
if isinstance(v, (int, float)):
return int(v)
txt = str(v).strip()
if not txt:
return None
m = re.match(r"^([\d,.]+)\s*(万|w|W)?", txt)
if not m:
return None
num = float(m.group(1).replace(",", ""))
if m.group(2):
num *= 10000
return int(num)
# ============================================================
# Cookie / 登录
# ============================================================
def load_cookies() -> list[dict[str, Any]]:
if not COOKIE_FILE.exists():
return []
try:
data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def restore_cookies(page: Page) -> None:
cookies = load_cookies()
if not cookies:
return
try:
page.context.add_cookies(cookies)
log(f"Loaded cookies: {COOKIE_FILE}")
except Exception as exc:
log(f"Failed to load cookies: {exc}")
def save_state(page: Page) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
COOKIE_FILE.write_text(
json.dumps(page.context.cookies(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
PATHS.browser_storage_state_file.write_text(
json.dumps(page.context.storage_state(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
log(f"Saved cookies: {COOKIE_FILE}")
def maybe_wait_for_login(page: Page, login_timeout: int) -> None:
deadline = time.time() + login_timeout
while time.time() < deadline:
try:
body = page.locator("body").inner_text(timeout=5000)
except Exception:
body = ""
if "扫码登录" not in body and "登录后" not in body:
return
log("抖音需要登录,请在打开的 Chrome 窗口中完成登录")
page.wait_for_timeout(3000)
log("[WARN] 登录等待超时")
def dismiss_popups(page: Page) -> None:
for text in ("我知道了", "稍后再说", "暂不登录", "取消", "跳过"):
try:
btn = page.locator(f"text={text}").first
if btn.is_visible(timeout=1000):
btn.click(timeout=1000)
except Exception:
pass
try:
page.evaluate("""
() => {
const close = [...document.querySelectorAll('button, div, span')]
.filter((el) => {
const t = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return ['×', 'x', 'X'].includes(t) &&
r.width > 0 && r.width <= 52 &&
r.height > 0 && r.height <= 52 &&
r.top >= 0 && r.top < window.innerHeight * 0.75;
})[0];
close?.click();
}
""")
except Exception:
pass
# ============================================================
# 标题匹配
# ============================================================
def title_match(haystack: str, needle: str) -> bool:
"""模糊标题匹配:去掉空格/标点后比较,任一方包含另一方即命中"""
def norm(s: str) -> str:
return re.sub(r"[\s \-_.,;:!?!?,。;:、/\\()()【】《》''\"\"'']+", "", str(s))
nh = norm(haystack)
nn = norm(needle)
if not nh or not nn:
return False
return nn in nh or nh in nn
# ============================================================
# 抖音作品页抓取
# ============================================================
def scroll_and_collect_posts(page: Page, max_scrolls: int = 50) -> list[dict]:
"""在 /user/self?showTab=post 页面滚动加载,收集所有作品的标题和播放量。
返回 [{"title": str, "play_count": int | None}, ...]
"""
posts = []
seen_titles = set()
no_new_count = 0
for scroll_idx in range(max_scrolls):
# 解析当前页面上的作品卡片
items = page.evaluate(r"""
() => {
const cards = document.querySelectorAll('li[data-e2e="user-post-item"], li[class*="user-post"], div[data-e2e="user-post-item"]');
const results = [];
for (const card of cards) {
// 标题: 通常在 <a> 或 <p> 里
let title = '';
const links = card.querySelectorAll('a');
for (const a of links) {
const t = (a.textContent || '').trim();
if (t.length > title.length) title = t;
}
if (!title) {
const p = card.querySelector('p, span[class*="title"], div[class*="title"]');
if (p) title = (p.textContent || '').trim();
}
// 播放量: 通常在卡片底部,格式如 "4.5万次播放" 或 "1234"
let playText = '';
const spans = card.querySelectorAll('span');
for (const sp of spans) {
const t = (sp.textContent || '').trim();
if (t.includes('播放') || t.includes('次') || /^[\d,.]+$/.test(t) ||
/[\d,.]+\s*(万|w|W)/.test(t)) {
if (t.length > playText.length) playText = t;
}
}
if (!playText) {
const lastSpan = card.querySelector('span:last-of-type');
if (lastSpan) {
const t = (lastSpan.textContent || '').trim();
if (/[\d]/.test(t)) playText = t;
}
}
if (title) {
results.push({title: title, playText: playText});
}
}
return results;
}
""")
new_count = 0
for item in items:
t = item.get("title", "").strip()
if not t or t in seen_titles:
continue
seen_titles.add(t)
play = parse_play_text(item.get("playText", ""))
posts.append({"title": t, "play_count": play})
new_count += 1
if new_count == 0:
no_new_count += 1
if no_new_count >= 3:
break
else:
no_new_count = 0
# 向下滚动
page.evaluate("window.scrollBy(0, window.innerHeight * 1.5)")
page.wait_for_timeout(800)
return posts
def parse_play_text(text: str) -> int | None:
"""从 '4.5万次播放' / '1234' / '1.2w' 等文本中提取播放量数字"""
if not text:
return None
# 先尝试提取 "X次播放" 格式
m = re.search(r"([\d,.]+)\s*(万|w|W)?\s*次播放", text)
if m:
return parse_int(m.group(1) + (m.group(2) or ""))
# 直接 parse_int
return parse_int(text)
# ============================================================
# 采集一个款式
# ============================================================
def scrape_one_style(style: dict, login_timeout: int, headless: bool,
only_record_ids: set[str] | None, dry_run: bool) -> dict:
base_token = style["base_token"]
table_id = style["table_id"]
fmap = style.get("field_map", {})
summary = {"style": style["name"], "index": style["index"],
"run_started_at": time.time(),
"total": 0, "matched": 0, "filled": 0,
"skipped_no_pubtime": 0, "results": []}
# 从飞书读取自营达人表记录,只看抖音
platform_fid = fmap.get("platform", {}).get("field_id")
name_fid = fmap.get("creator_name", {}).get("field_id")
id_fid = fmap.get("creator_id", {}).get("field_id")
title_fid = fmap.get("note_title", {}).get("field_id")
url_fid = fmap.get("note_url", {}).get("field_id")
pub_fid = fmap.get("publish_time", {}).get("field_id")
if not platform_fid or not title_fid or not url_fid or not pub_fid:
summary["error"] = "missing platform/note_title/note_url/publish_time field mapping"
log(f" [ERROR] platform/note_title/note_url/publish_time 字段缺失,跳过")
return summary
all_records = list_records_by_table(base_token, table_id)
# 只看抖音记录
def is_douyin(v) -> bool:
if not v:
return False
s = str(v).strip()
return s in ("抖音", "douyin")
def is_nonempty(v) -> bool:
if v is None:
return False
if isinstance(v, list):
return len(v) > 0
if isinstance(v, str):
return v.strip() != ""
return True
douyin_records = [r for r in all_records if is_douyin(r.get(platform_fid))]
log(f" 全表 {len(all_records)} 条,抖音记录 {len(douyin_records)} 条")
# 构建任务列表:需要标题+链接+发布时间
tasks = []
for rec in douyin_records:
rid = rec["record_id"]
if only_record_ids and rid not in only_record_ids:
continue
title = rec.get(title_fid)
if isinstance(title, list):
title = " ".join(str(x) for x in title if x)
title = str(title or "").strip()
if not title:
continue
url_raw = rec.get(url_fid)
pub_raw = rec.get(pub_fid)
creator = rec.get(name_fid) or rec.get(id_fid) or "?"
pub_time = parse_publish_time(pub_raw)
tasks.append({
"record_id": rid,
"creator_name": str(creator),
"target_title": title,
"note_url": str(url_raw or ""),
"publish_time": pub_time,
})
summary["total"] = len(tasks)
if not tasks:
log(f" 无抖音待抓任务,跳过")
return summary
# 启动浏览器:所有浏览器操作都在 page_action 里完成
collected_posts: list[dict] = []
login_ok = False
def page_action(page: Page):
nonlocal login_ok
dismiss_popups(page)
restore_cookies(page)
page.goto(SELF_POST_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(3000)
dismiss_popups(page)
maybe_wait_for_login(page, login_timeout)
save_state(page)
login_ok = True
log(" 等待作品列表加载...")
page.wait_for_timeout(3000)
dismiss_popups(page)
# 滚动加载所有作品
log(" 滚动加载作品列表...")
posts = scroll_and_collect_posts(page, max_scrolls=50)
log(f" 共加载 {len(posts)} 个作品")
collected_posts.extend(posts)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=3,
) as session:
session.fetch("https://www.douyin.com/", page_action=page_action, wait=1000)
if not login_ok:
log(f" [ERROR] 登录失败,跳过该款式")
return summary
# 用收集到的作品匹配飞书记录并回填
today = datetime.now()
posts = collected_posts
matched_posts = match_tasks_to_cards(tasks, posts, "douyin")
for task in tasks:
matched_post = matched_posts.get(str(task["record_id"]))
if matched_post is None:
log(f" [MISS] {task['creator_name']}: '{task['target_title'][:20]}' 未匹配到作品")
summary["results"].append({
"record_id": task["record_id"],
"creator": task["creator_name"],
"target": task["target_title"],
"status": RETRYABLE_FAILURE,
"matched": False,
"reason": "title_not_found",
})
continue
summary["matched"] += 1
play_count = matched_post.get("play_count")
log(f" {task['creator_name']}: '{task['target_title'][:20]}' → 播放={play_count}")
pick = pick_read_field(fmap, task.get("publish_time"), today)
outcome: dict[str, Any]
if play_count is None:
outcome = {
"status": RETRYABLE_FAILURE,
"write_ok": False,
"reason": "metric_parse_failed",
}
elif dry_run:
outcome = {"status": SUCCESS, "write_ok": None, "dry_run": True}
elif pick and pick != "FUTURE":
logical, fill_fid, fill_fname = pick
ok = write_back(base_token, table_id, task["record_id"], fill_fid, play_count)
if ok:
log(f" 回填 {fill_fname}={play_count} (key={logical})")
summary["filled"] += 1
outcome = {"status": SUCCESS, "write_ok": True}
else:
outcome = {
"status": WRITE_FAILURE,
"write_ok": False,
"reason": "write_back_failed",
}
elif pick == "FUTURE":
log(f" [SKIP] record={task['record_id']} 发布时间在未来,跳过回填")
outcome = {
"status": BLOCKED_INPUT,
"write_ok": None,
"reason": "publish_time_in_future",
}
elif not dry_run and not task.get("publish_time"):
log(f" [SKIP] record={task['record_id']} 发布时间缺失,无法判断槽位")
summary["skipped_no_pubtime"] += 1
outcome = {
"status": BLOCKED_INPUT,
"write_ok": None,
"reason": "publish_time_missing",
}
else:
outcome = {
"status": WRITE_FAILURE,
"write_ok": False,
"reason": "write_field_missing",
}
summary["results"].append({
"record_id": task["record_id"],
"creator": task["creator_name"],
"target": task["target_title"],
"matched_title": matched_post["title"],
"play_count": play_count,
"matched": True,
**outcome,
})
return summary
def login_only(login_timeout: int) -> int:
"""只触发登录"""
login_done = False
def page_action(page: Page):
restore_cookies(page)
page.goto("https://www.douyin.com/", wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2000)
maybe_wait_for_login(page, login_timeout)
save_state(page)
login_done = True
log("登录完成")
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=False,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60000,
page_setup=restore_cookies,
) as session:
session.fetch("https://www.douyin.com/", page_action=page_action, wait=1000)
return 0 if login_done else 1
# ============================================================
# main
# ============================================================
def main() -> int:
parser = argparse.ArgumentParser(description="自营抖音采集: douyin.com 自己账号作品曝光量")
add_repeatable_style_argument(parser, "只跑指定款式编号(可多次)")
parser.add_argument("--record", action="append", help="只跑指定 record_id")
parser.add_argument("--headless", action="store_true")
parser.add_argument("--login-timeout", type=int, default=300)
parser.add_argument("--dry-run", action="store_true", help="不写回多维表格")
parser.add_argument("--login-only", action="store_true", help="只触发登录")
args = parser.parse_args()
if args.login_only:
return login_only(args.login_timeout)
mapping = load_mapping()
styles = mapping["tables"]
if args.style:
try:
styles = select_requested_styles(styles, args.style)
except ValueError as exc:
print(str(exc))
return 1
only_rids = set(args.record) if args.record else None
out_dir = PATHS.normalized_root / "v2_results"
out_dir.mkdir(parents=True, exist_ok=True)
self_sfx = "_self"
all_summaries = []
for s in styles:
log(f"\n========== [{s['index']:02d}] {s['name']} (自营抖音) ==========")
summary = finalize_summary(
scrape_one_style(s, args.login_timeout, args.headless, only_rids, args.dry_run),
dry_run=args.dry_run,
)
out_file = out_dir / f"{s['index']:02d}-{s['name']}{self_sfx}_xingtu_v2.json"
if only_rids and out_file.exists():
try:
previous = json.loads(out_file.read_text(encoding="utf-8"))
except Exception:
previous = None
summary = merge_style_summary(previous, summary, dry_run=args.dry_run)
all_summaries.append(summary)
atomic_write_json(out_file, summary)
log(f" 落盘: {out_file}")
log(
f" 统计: 总任务={summary['total']} 命中={summary['matched']} "
f"回填={summary['filled']} 未解决={summary.get('unresolved', 0)}"
)
final_path = out_dir / f"_all_summaries{self_sfx}_xingtu.json"
try:
existing_global = json.loads(final_path.read_text(encoding="utf-8")) if final_path.exists() else []
except Exception:
existing_global = []
atomic_write_json(final_path, merge_global_summaries(existing_global, all_summaries))
log(f"\n=== 全部完成 ===")
log(f"汇总: {final_path}")
incomplete = [summary.get("index") for summary in all_summaries if not summary.get("complete")]
if incomplete:
log(f"[ERROR] 以下款式仍有未解决采集/写回结果: {incomplete}")
return 2
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,5 @@
"""Apply the unified per-script runtime binding to nested Python launches."""
from gyxx_flow.adapters.bootstrap import safe_bootstrap_current_process
safe_bootstrap_current_process()
@@ -0,0 +1,57 @@
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
TOOLS_DIR = PROJECT_ROOT / "data" / "tools"
from gyxx_flow.modules.content_marketing.runtime.data.tools import analyze_note # noqa: E402
class AnalyzeNoteWithoutCommentsTest(unittest.TestCase):
def test_no_comments_still_writes_metrics_report(self):
info = {
"title": "无评论测试笔记",
"platform": "xiaohongshu",
"source_url": "https://example.com/note",
"scraped_at": "",
"comment_count_total": 0,
"like_count": 12,
"favorite_count": 3,
"comment_count_metric": 0,
"share_count": 1,
"view_count": 1000,
"collect_count_yxyy": 0,
"style_name": "测试款",
"creator_name": "测试达人",
}
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "note.txt"
argv = [
"analyze_note.py",
"--note-id",
"1",
"--no-send",
"--brand",
"光影行星",
"--output",
str(output),
]
with patch.object(analyze_note, "load_note_from_db", return_value=(info, [])), patch.object(
sys, "argv", argv
):
result = analyze_note.main()
self.assertEqual(result, 0)
self.assertTrue(output.exists())
report = output.read_text(encoding="utf-8-sig")
self.assertIn("无有效评论可分析", report)
self.assertIn("1,000", report)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,9 @@
from gyxx_flow.modules.content_marketing.runtime.data.tools import batch_rescrape_douyin as batch
def test_result_logged_in_reads_nested_scraper_stats():
result = {"stats": {"logged_in": True}}
assert batch.result_logged_in(result) is True
assert batch.result_logged_in({"stats": {"logged_in": False}}) is False
assert batch.result_logged_in({}) is False
@@ -0,0 +1,840 @@
import argparse
from pathlib import Path
import pytest
from gyxx_flow.modules.content_marketing.runtime import collection_completeness as cc
import bilibili_scraper as bili
import pgy_xhs_scraper_v2 as pgy
import run_all
import self_douyin_scraper as self_dy
import xingtu_scraper_v2 as xingtu
def test_normalize_title_handles_nfkc_case_and_zero_width():
assert cc.normalize_title("MacBook\u200b 通勤包!") == "macbook通勤包"
def test_coerce_url_unwraps_feishu_markdown_links():
value = "[查看笔记](https://www.xiaohongshu.com/discovery/item/abc123?x=1)"
assert cc.coerce_url(value) == "https://www.xiaohongshu.com/discovery/item/abc123?x=1"
def test_match_tasks_prefers_note_id_over_changed_title():
tasks = [{
"record_id": "r1",
"target_title": "飞书中的旧标题",
"note_url": "https://www.xiaohongshu.com/explore/abc123",
"note_id": "abc123",
}]
cards = [
{"title": "完全不同的新标题", "href": "https://www.xiaohongshu.com/explore/abc123", "note_id": "abc123"},
{"title": "飞书中的旧标题", "href": "https://www.xiaohongshu.com/explore/other", "note_id": "other"},
]
matched = cc.match_tasks_to_cards(tasks, cards, platform="xhs")
assert matched["r1"]["note_id"] == "abc123"
assert matched["r1"]["match_method"] == "content_id"
def test_match_tasks_accepts_unique_truncated_title_but_rejects_ambiguous_title():
task = {"record_id": "r1", "target_title": "男生长期主义通勤双肩包分享"}
unique = [
{"title": "男生长期主义通勤双肩包"},
{"title": "夏日轻量斜挎包"},
]
ambiguous = [
{"title": "男生长期主义通勤双肩包"},
{"title": "男生长期主义通勤双肩包"},
]
assert cc.match_tasks_to_cards([task], unique, platform="douyin")["r1"]
assert "r1" not in cc.match_tasks_to_cards([task], ambiguous, platform="douyin")
def test_title_similarity_ignores_episode_prefix_and_hashtag_suffix():
target = "第20集:Pocket4拍旋焦,无后期也能出片?"
platform_title = (
"Pocket4拍旋焦,无后期也能出片? #旋焦 #pocket4 #摄影装备 "
"无论Pocket3还是Pocket4都可以轻松拍出旋焦效果!"
)
assert cc.title_similarity(platform_title, target) >= 0.98
def test_title_similarity_rejects_different_episode_numbers():
assert cc.title_similarity(
"第19集:Pocket4拍旋焦,无后期也能出片?",
"第20集:Pocket4拍旋焦,无后期也能出片?",
) == 0.0
def test_match_tasks_rejects_duplicate_exact_titles_with_different_ids():
task = {"record_id": "r1", "target_title": "完全相同的目标标题"}
cards = [
{"title": "完全相同的目标标题", "note_id": "video-1"},
{"title": "完全相同的目标标题", "note_id": "video-2"},
]
assert "r1" not in cc.match_tasks_to_cards([task], cards, platform="douyin")
def test_one_card_is_not_reused_for_two_different_creator_tasks():
tasks = [
{
"record_id": "loose",
"target_title": "commuter backpack review today",
"note_url": "https://v.douyin.com/loose-source/",
},
{
"record_id": "exact",
"target_title": "commuter backpack review",
"note_url": "https://v.douyin.com/exact-source/",
},
]
cards = [{"title": "commuter backpack review", "note_id": "observed-1"}]
matched = cc.match_tasks_to_cards(tasks, cards, platform="douyin")
assert set(matched) == {"exact"}
def test_same_source_url_can_share_one_card_across_styles():
tasks = [
{
"record_id": "style-1",
"target_title": "同一篇跨款式笔记",
"note_url": "https://v.douyin.com/same-short-link/",
},
{
"record_id": "style-2",
"target_title": "同一篇跨款式笔记",
"note_url": "https://v.douyin.com/same-short-link/",
},
]
cards = [{"title": "同一篇跨款式笔记", "note_id": "observed-1"}]
assert set(cc.match_tasks_to_cards(tasks, cards, platform="douyin")) == {
"style-1", "style-2",
}
def test_known_mismatched_content_ids_cannot_fall_back_to_same_title():
task = {
"record_id": "r1",
"target_title": "完全相同标题",
"note_id": "video-a",
}
card = {"title": "完全相同标题", "note_id": "video-b"}
assert cc.match_tasks_to_cards([task], [card], platform="douyin") == {}
def test_xingtu_api_and_dom_duplicate_are_merged_before_matching():
cards = [
{
"title": "同一条视频",
"play_count": 19481,
"note_id": "video-1",
"source": "show_items_api",
},
{
"title": "同一条视频",
"play_count": "1.9w",
"note_id": "",
"page": 1,
},
]
deduped = cc.deduplicate_observed_cards(cards)
matched = cc.match_tasks_to_cards(
[{"record_id": "r1", "target_title": "同一条视频"}],
deduped,
platform="douyin",
)
assert len(deduped) == 1
assert matched["r1"]["note_id"] == "video-1"
def test_merge_partial_summary_keeps_existing_success_rows():
existing = {
"style": "款式A", "index": 1, "total": 2, "matched": 1, "filled": 1,
"results": [
{"record_id": "ok", "status": "success", "matched": True, "write_ok": True},
{"record_id": "retry", "status": "retryable_failure", "matched": False},
],
}
partial = {
"style": "款式A", "index": 1, "total": 1, "matched": 1, "filled": 1,
"results": [
{"record_id": "retry", "status": "success", "matched": True, "write_ok": True},
],
}
merged = cc.merge_style_summary(existing, partial)
assert merged["total"] == 2
assert {row["record_id"] for row in merged["results"]} == {"ok", "retry"}
assert merged["matched"] == 2
assert merged["filled"] == 2
assert merged["complete"] is True
def test_summary_contract_detects_missing_or_write_failure():
summary = {
"total": 2,
"results": [
{"record_id": "ok", "status": "success", "matched": True, "write_ok": True},
{"record_id": "bad", "status": "write_failure", "matched": True, "write_ok": False},
],
}
finalized = cc.finalize_summary(summary, dry_run=False)
assert finalized["complete"] is False
assert finalized["unresolved"] == 1
missing = cc.finalize_summary({"total": 2, "results": summary["results"][:1]}, dry_run=False)
assert missing["complete"] is False
assert missing["missing_results"] == 1
def _style_for_platform(platform: str):
return {
"index": 1,
"name": "款式A",
"base_token": "base",
"table_id": "table",
"field_map": {
"creator_name": {"field_id": "name"},
"creator_id": {"field_id": "creator_id"},
"note_title": {"field_id": "title"},
"publish_time": {"field_id": "pub"},
"note_url": {"field_id": "url"},
"platform": {"field_id": "platform"},
"read_count_7d": {"field_id": "read7"},
},
}
def test_extractors_preserve_note_url_and_content_id(monkeypatch):
xhs_row = {
"record_id": "xhs-r", "name": "达人", "creator_id": "xhs-id",
"title": "标题", "pub": "2026-07-20", "platform": "小红书",
"url": "https://www.xiaohongshu.com/explore/abc123", "read7": None,
}
dy_row = {
"record_id": "dy-r", "name": "达人", "creator_id": "dy-id",
"title": "标题", "pub": "2026-07-20", "platform": "抖音",
"url": "https://www.douyin.com/video/7654321", "read7": None,
}
monkeypatch.setattr(pgy, "list_records_by_table", lambda *_: [xhs_row])
monkeypatch.setattr(xingtu, "list_records_by_table", lambda *_: [dy_row])
xhs_task = pgy.extract_target_tasks(_style_for_platform("小红书"))[0]
dy_task = xingtu.extract_target_tasks(_style_for_platform("抖音"))[0]
assert xhs_task["note_url"].endswith("/abc123")
assert xhs_task["note_id"] == "abc123"
assert dy_task["note_url"].endswith("/7654321")
assert dy_task["note_id"] == "7654321"
@pytest.mark.parametrize(
("module", "platform", "filled_url"),
[
(pgy, "小红书", "https://www.douyin.com/video/7654321"),
(xingtu, "抖音", "http://8.99 分享文案 https://v.douyin.com/abc123/"),
],
)
def test_filled_publish_link_triggers_collection_without_platform_validation(
monkeypatch, module, platform, filled_url,
):
row = {
"record_id": "r1",
"name": "达人",
"creator_id": "creator-id",
"title": "已发布标题",
"pub": "2026-07-20",
"platform": platform,
"url": filled_url,
"read7": None,
}
monkeypatch.setattr(module, "list_records_by_table", lambda *_: [row])
task = module.extract_target_tasks(_style_for_platform(platform))[0]
assert task["input_error"] is None
def test_extractors_can_collect_missing_title_when_direct_url_has_content_id(monkeypatch):
xhs_style = _style_for_platform("小红书")
dy_style = _style_for_platform("抖音")
xhs_row = {
"record_id": "xhs-r", "name": "达人", "creator_id": "xhs-id",
"title": None, "pub": "2026-07-20", "platform": "小红书",
"url": "https://www.xiaohongshu.com/explore/abc123", "read7": None,
}
dy_row = {
"record_id": "dy-r", "name": "达人", "creator_id": "dy-id",
"title": None, "pub": "2026-07-20", "platform": "抖音",
"url": "https://www.douyin.com/video/7654321", "read7": None,
}
monkeypatch.setattr(pgy, "list_records_by_table", lambda *_: [xhs_row])
monkeypatch.setattr(xingtu, "list_records_by_table", lambda *_: [dy_row])
assert pgy.extract_target_tasks(xhs_style)[0]["note_id"] == "abc123"
assert xingtu.extract_target_tasks(dy_style)[0]["note_id"] == "7654321"
assert xhs_style["_extract_stats"]["skipped_incomplete"] == 0
assert dy_style["_extract_stats"]["skipped_incomplete"] == 0
def test_missing_critical_mapping_is_an_incomplete_summary(monkeypatch):
style = _style_for_platform("抖音")
del style["field_map"]["note_url"]
monkeypatch.setattr(
xingtu,
"list_records_by_table",
lambda *_: (_ for _ in ()).throw(AssertionError("mapping should fail first")),
)
_, summaries = xingtu.collect_tasks_across_styles([style], None)
finalized = cc.finalize_summary(summaries[1], dry_run=True)
assert "note_url" in finalized["error"]
assert finalized["complete"] is False
assert finalized["unresolved"] >= 1
class _FakeInput:
def __init__(self):
self.filled = []
def click(self):
return None
def fill(self, value):
self.filled.append(value)
def press(self, _key):
return None
class _FakeLocator:
def __init__(self, input_):
self.first = input_
def filter(self, **_kwargs):
return self
class _FakeSearchPage:
def __init__(self):
self.input = _FakeInput()
self.has_result_calls = 0
def wait_for_timeout(self, _ms):
return None
def evaluate(self, script, arg=None):
if "const rows" in script:
self.has_result_calls += 1
return self.has_result_calls >= 2
if "const inputs" in script:
return ""
raise AssertionError(script)
def locator(self, _selector):
return _FakeLocator(self.input)
def wait_for_url(self, *_args, **_kwargs):
return None
def test_pgy_search_retry_keeps_creator_id_query():
page = _FakeSearchPage()
pgy._ensure_search_results(page, query="xhs-id-123", result_name="达人昵称", max_retries=2)
assert page.input.filled == ["xhs-id-123"]
def test_apply_matched_task_reports_write_failure(monkeypatch):
task = {
"record_id": "r1", "publish_time": object(),
"style_context": {
"index": 1, "base_token": "base", "table_id": "table", "field_map": {},
},
}
summary = {"matched": 0, "filled": 0, "skipped_no_pubtime": 0, "results": []}
monkeypatch.setattr(pgy, "pick_read_field", lambda *_: ("read_count_7d", "fid", "7天曝光量"))
monkeypatch.setattr(pgy, "write_back", lambda *_: False)
outcome = pgy.apply_matched_task(task, {"read_count": "123", "title": "标题"}, summary, dry_run=False)
assert outcome["status"] == "write_failure"
assert outcome["write_ok"] is False
def test_default_creator_batch_has_no_forced_hour_pause():
assert pgy.DEFAULT_BATCH_SIZE == 0
assert xingtu.DEFAULT_BATCH_SIZE == 0
def test_repeatable_style_argument_and_selection_keep_every_requested_style():
parser = argparse.ArgumentParser()
cc.add_repeatable_style_argument(parser, "styles")
args = parser.parse_args(["--style", "1", "--style", "3"])
styles = [{"index": 1}, {"index": 2}, {"index": 3}]
assert args.style == [1, 3]
assert cc.select_requested_styles(styles, args.style) == [styles[0], styles[2]]
with pytest.raises(ValueError, match="99"):
cc.select_requested_styles(styles, [1, 99])
def test_pgy_stable_cards_retry_an_initial_empty_render(monkeypatch):
sequence = [[], [{"title": "标题", "read_count": "1"}], [{"title": "标题", "read_count": "1"}]]
monkeypatch.setattr(pgy, "parse_cards_on_page", lambda _page: sequence.pop(0))
class Page:
waits = 0
def wait_for_timeout(self, _ms):
self.waits += 1
page = Page()
assert pgy.parse_cards_stable(page) == [{"title": "标题", "read_count": "1"}]
assert page.waits == 2
def test_xingtu_paginates_before_falling_back_to_search(monkeypatch):
pages = [
[{"title": "无关视频", "play_count": "10"}],
[{"title": "目标视频标题", "play_count": "20"}],
]
calls = {"next": 0}
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
monkeypatch.setattr(xingtu, "parse_videos_stable", lambda _page: pages.pop(0))
def next_page(_page):
calls["next"] += 1
return True
monkeypatch.setattr(xingtu, "go_next_page", next_page)
monkeypatch.setattr(xingtu, "submit_video_search", lambda *_: False)
found, _, _ = xingtu.find_videos_for_tasks(
object(), [{"record_id": "r1", "target_title": "目标视频标题"}], max_pages=2,
)
assert found["r1"]["play_count"] == "20"
assert calls["next"] == 1
def test_xingtu_normalizes_show_items_api_video():
card = xingtu.normalize_xingtu_api_item({
"item_id": 7654321,
"item_title": "目标视频标题",
"play": 168000,
"like": 1200,
"comment": 34,
"share": 56,
"url": "https://www.douyin.com/video/7654321",
})
assert card == {
"title": "目标视频标题",
"play_count": 168000,
"like_count": 1200,
"comment_count": 34,
"share_count": 56,
"href": "https://www.douyin.com/video/7654321",
"note_id": "7654321",
"source": "show_items_api",
}
def test_xingtu_show_items_capture_collects_and_deduplicates_cards():
class Response:
url = "https://www.xingtu.cn/gw/api/author/get_author_show_items_v2"
def json(self):
return {
"latest_item_info": [
{"item_id": "1", "title": "视频一", "play": 10},
{"item_id": "2", "item_title": "视频二", "play": 20},
],
"latest_star_item_info": [
{"item_id": "2", "item_title": "视频二", "play": 20},
],
}
class Page:
def on(self, event, callback):
assert event == "response"
self.callback = callback
page = Page()
cards = xingtu.setup_show_items_capture(page)
page.callback(Response())
assert [card["note_id"] for card in cards] == ["1", "2"]
def test_xingtu_matches_api_cards_without_dom_pagination(monkeypatch):
monkeypatch.setattr(
xingtu,
"parse_videos_stable",
lambda _page: (_ for _ in ()).throw(AssertionError("DOM should not be needed")),
)
monkeypatch.setattr(
xingtu,
"is_blocked",
lambda _page: (_ for _ in ()).throw(AssertionError("page should not be needed")),
)
tasks = [{
"record_id": "r1",
"target_title": "飞书旧标题",
"note_url": "https://www.douyin.com/video/7654321",
"note_id": "7654321",
}]
api_cards = [{
"title": "平台上的新标题",
"play_count": 168000,
"note_id": "7654321",
"source": "show_items_api",
}]
found, _, page_limit_hit = xingtu.find_videos_for_tasks(
object(), tasks, max_pages=5, api_cards=api_cards,
)
assert found["r1"]["play_count"] == 168000
assert found["r1"]["match_method"] == "content_id"
assert page_limit_hit is False
def test_xingtu_api_fuzzy_candidate_does_not_preempt_later_exact_dom(monkeypatch):
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
monkeypatch.setattr(
xingtu,
"parse_videos_stable",
lambda _page: [{"title": "commuter backpack review", "note_id": "exact-card"}],
)
monkeypatch.setattr(xingtu, "go_next_page", lambda _page: False)
monkeypatch.setattr(xingtu, "submit_video_search", lambda *_: False)
task = {"record_id": "r1", "target_title": "commuter backpack review"}
api_cards = [{
"title": "commuter backpack review today",
"note_id": "fuzzy-card",
"source": "show_items_api",
}]
found, _, _ = xingtu.find_videos_for_tasks(
object(), [task], max_pages=2, api_cards=api_cards,
)
assert found["r1"]["note_id"] == "exact-card"
def test_browser_session_loss_detection_is_specific():
closed = RuntimeError("Target page, context or browser has been closed")
assert pgy.is_browser_session_lost(closed)
assert xingtu.is_browser_session_lost(closed)
assert not pgy.is_browser_session_lost(RuntimeError("ordinary parse failure"))
def test_xingtu_nested_session_loss_is_reraised():
closed = RuntimeError("Target page, context or browser has been closed")
with pytest.raises(RuntimeError, match="has been closed"):
xingtu.reraise_if_browser_session_lost(closed)
xingtu.reraise_if_browser_session_lost(RuntimeError("ordinary lookup miss"))
def test_xingtu_detail_and_video_search_propagate_closed_session():
closed = RuntimeError("Target page, context or browser has been closed")
class Detail:
def goto(self, *_args, **_kwargs):
raise closed
class Context:
pages = []
def new_page(self):
return Detail()
class SearchPage:
context = Context()
def evaluate(self, _script, _arg=None):
return {"found": True, "url": "https://www.xingtu.cn/ad/creator/author-homepage/douyin-video/1"}
with pytest.raises(RuntimeError, match="has been closed"):
xingtu.open_creator_detail(SearchPage(), "达人", "creator-id")
class Locator:
first = None
def __init__(self):
self.first = self
def count(self):
return 1
def wait_for(self, **_kwargs):
raise closed
class VideoPage:
def locator(self, _selector):
return Locator()
with pytest.raises(RuntimeError, match="has been closed"):
xingtu.submit_video_search(VideoPage(), "标题")
def test_pgy_fallback_detail_click_propagates_closed_session():
closed = RuntimeError("Target page, context or browser has been closed")
class Clicker:
first = None
def __init__(self):
self.first = self
def click(self, **_kwargs):
raise closed
class Context:
pages = []
class Page:
context = Context()
def evaluate(self, *_args, **_kwargs):
return {"ok": False, "reason": "no-row"}
def get_by_text(self, *_args, **_kwargs):
return Clicker()
with pytest.raises(RuntimeError, match="has been closed"):
pgy.open_blogger_detail(Page(), "达人", None)
def test_bilibili_triggered_blocked_inputs_are_visible_terminal_rows():
summary = bili.finalize_bili_summary({
"style": "款式A", "index": 1, "total_b_records": 1,
"details": [{
"record_id": "r1", "status": "blocked_input",
"matched": False, "reason": "publish_time_missing", "ok": False,
}],
})
assert summary["blocked_input"] == 1
assert summary["unresolved"] == 0
assert summary["complete"] is True
def test_bilibili_missing_url_does_not_trigger_collection(monkeypatch):
style = {
"style": "款式A",
"name": "款式A",
"index": 1,
"base_token": "base",
"table_id": "table",
"field_map": {
"platform": {"field_id": "platform"},
"note_url": {"field_id": "url"},
"creator_name": {"field_id": "creator"},
"publish_time": {"field_id": "pub"},
"read_count_7d": {"field_id": "s7"},
"read_count_14d": {"field_id": "s14"},
"read_count_21d": {"field_id": "s21"},
"read_count_28d": {"field_id": "s28"},
"month_end": {"field_id": "sm"},
},
}
rows = [
{
"record_id": "no-link",
"platform": "B站",
"creator": "未发布达人",
"url": "",
"pub": "2026-07-20",
},
{
"record_id": "published",
"platform": "B站",
"creator": "已发布达人",
"url": "https://www.bilibili.com/video/BV1234567890",
"pub": "2026-07-20",
},
]
monkeypatch.setattr(bili, "list_all_records", lambda *_args: rows)
monkeypatch.setattr(bili, "fetch_play_count", lambda *_args: 321)
monkeypatch.setattr(bili, "write_record", lambda *_args, **_kwargs: True)
summary = bili.process_style(
style,
only_record_ids=None,
dry_run=True,
delay=0,
session=object(),
state={},
force_today=bili.date(2026, 7, 23),
first_run=False,
)
assert summary["source_b_records"] == 2
assert summary["skipped_no_url"] == 1
assert summary["total_b_records"] == 1
assert summary["success"] == 1
assert summary["blocked_input"] == 0
assert [row["record_id"] for row in summary["details"]] == ["published"]
def test_bilibili_partial_retry_preserves_old_success():
existing = {
"style": "款式A", "index": 1, "total_b_records": 2,
"details": [
{"record_id": "ok", "status": "success", "ok": True},
{"record_id": "retry", "status": "retryable_failure", "ok": False},
],
}
partial = {
"style": "款式A", "index": 1, "total_b_records": 1,
"details": [{"record_id": "retry", "status": "success", "ok": True}],
}
merged = bili.merge_bili_summary(existing, partial)
assert merged["total_b_records"] == 2
assert {row["record_id"] for row in merged["details"]} == {"ok", "retry"}
assert merged["complete"] is True
def test_bilibili_top_level_error_cannot_finalize_as_complete():
finalized = bili.finalize_bili_summary({
"style": "款式A",
"index": 1,
"total_b_records": 0,
"details": [],
"error": "missing platform/url field_id",
"unresolved": 1,
"retryable_failures": 1,
"complete": False,
})
assert finalized["complete"] is False
assert finalized["unresolved"] >= 1
def test_bilibili_missing_slot_mapping_is_system_error_not_blocked_input():
summary = bili.process_style(
{
"style": "款式A",
"name": "款式A",
"index": 1,
"base_token": "base",
"table_id": "table",
"field_map": {
"platform": {"field_id": "platform"},
"note_url": {"field_id": "url"},
},
},
only_record_ids=None,
dry_run=True,
delay=0,
session=object(),
state={},
force_today=None,
first_run=False,
)
finalized = bili.finalize_bili_summary(summary, dry_run=True)
assert "publish_time" in finalized["error"]
assert "read_count_7d" in finalized["error"]
assert finalized["complete"] is False
def test_self_douyin_missing_mapping_and_write_failure_are_not_complete(monkeypatch):
missing = self_dy.scrape_one_style(
{"name": "款式A", "index": 1, "base_token": "base", "table_id": "table", "field_map": {}},
login_timeout=1,
headless=True,
only_record_ids=None,
dry_run=False,
)
assert cc.finalize_summary(missing)["complete"] is False
style = _style_for_platform("抖音")
row = {
"record_id": "r1",
"platform": "抖音",
"name": "自营达人",
"title": "目标作品",
"url": "https://www.douyin.com/video/1",
"pub": "2026-07-20",
}
class Page:
def goto(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, _ms):
return None
class Session:
def __init__(self, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def fetch(self, _url, page_action, wait):
page_action(Page())
monkeypatch.setattr(self_dy, "DynamicSession", Session)
monkeypatch.setattr(self_dy, "list_records_by_table", lambda *_: [row])
monkeypatch.setattr(self_dy, "dismiss_popups", lambda *_: None)
monkeypatch.setattr(self_dy, "restore_cookies", lambda *_: None)
monkeypatch.setattr(self_dy, "maybe_wait_for_login", lambda *_: None)
monkeypatch.setattr(self_dy, "save_state", lambda *_: None)
monkeypatch.setattr(
self_dy,
"scroll_and_collect_posts",
lambda *_args, **_kwargs: [{"title": "目标作品", "play_count": 123}],
)
monkeypatch.setattr(self_dy, "pick_read_field", lambda *_: ("read_count_7d", "read7", "7天曝光量"))
monkeypatch.setattr(self_dy, "write_back", lambda *_: False)
failed = cc.finalize_summary(
self_dy.scrape_one_style(
style,
login_timeout=1,
headless=True,
only_record_ids=None,
dry_run=False,
)
)
assert failed["results"][0]["status"] == "write_failure"
assert failed["complete"] is False
def test_run_all_rejects_stale_or_incomplete_summary(tmp_path: Path):
path = tmp_path / "summary.json"
path.write_text("[]", encoding="utf-8")
started_at = path.stat().st_mtime + 1
assert run_all.validate_summary_payload([], {1}, path, started_at)[0] is False
path.write_text('[{"index": 1, "total": 2, "results": [{"record_id": "a", "status": "success"}]}]', encoding="utf-8")
assert run_all.validate_summary_payload(
[{"index": 1, "total": 2, "results": [{"record_id": "a", "status": "success"}]}],
{1}, path, path.stat().st_mtime - 1,
)[0] is False
@@ -0,0 +1,103 @@
import unittest
from datetime import date
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_dashboard_analytics import build_dashboard_facts
class DailyDashboardAnalyticsTests(unittest.TestCase):
def setUp(self):
self.tracking = [
{"style_name": "大流量", "visitors": 1000, "cart_users": 100, "sales": 20, "refund_orders": 2, "light": "🟡"},
{"style_name": "高转化", "visitors": 100, "cart_users": 30, "sales": 20, "refund_orders": 8, "light": "🔴"},
{"style_name": "极小样本", "visitors": 10, "cart_users": 5, "sales": 10, "refund_orders": 0, "light": "🔴"},
{"style_name": "缺数据", "visitors": None, "cart_users": None, "sales": None, "refund_orders": None, "light": "无判断"},
]
self.platform = [
{"style_name": "大流量", "platform": "天猫", "visitors": 900, "cart_users": 90, "sales": 18, "refund_orders": 2},
{"style_name": "高转化", "platform": "天猫", "visitors": 100, "cart_users": 30, "sales": 20, "refund_orders": 8},
{"style_name": "极小样本", "platform": "天猫", "visitors": 10, "cart_users": 5, "sales": 10, "refund_orders": 0},
]
self.personas = [
{
"style_name": "大流量", "platform": "天猫", "data_date": date(2026, 7, 15),
"gender_top": "女性 60%", "age_top": "26-35岁 45%",
"city_top": "一线城市 35%", "buying_power_top": "高级白领 40%",
}
]
def test_all_styles_receive_same_metrics_and_diagnosis(self):
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas,
{"大流量": {"creator_count": 3, "directions": ["通勤"]}},
)
self.assertEqual(len(facts["styles"]), 4)
high = next(row for row in facts["styles"] if row["style_name"] == "高转化")
self.assertEqual(high["conversion_rate"], 0.2)
self.assertEqual(high["cart_rate"], 0.3)
self.assertEqual(high["refund_rate"], 0.4)
missing = next(row for row in facts["styles"] if row["style_name"] == "缺数据")
self.assertEqual(missing["diagnosis"], "无数据")
def test_top_conversion_excludes_tiny_visitor_base(self):
facts = build_dashboard_facts("2026-07-15", self.tracking, self.platform, self.personas, {})
self.assertEqual(facts["rankings"]["conversion"][0]["style_name"], "高转化")
self.assertEqual(facts["rankings"]["visitors"][0]["style_name"], "大流量")
self.assertEqual(facts["rankings"]["refund_rate"][0]["style_name"], "高转化")
def test_platform_winners_keep_missing_persona_explicit(self):
facts = build_dashboard_facts("2026-07-15", self.tracking, self.platform, self.personas, {})
card = facts["platform_personas"][0]
self.assertEqual(card["platform"], "天猫")
self.assertEqual(card["top_conversion"]["style_name"], "高转化")
self.assertEqual(card["top_conversion"]["persona_text"], "无数据")
self.assertEqual(card["top_visitors"]["style_name"], "大流量")
self.assertIn("推测", card["top_visitors"]["interest_hypothesis"])
self.assertIn("提转化", card["top_visitors"]["optimization"])
def test_daily_new_notes_overview_replaces_view_delta_and_creator_ranking(self):
notes = [{"note_id": 7, "style_name": "大流量", "creator": "达人A", "platform": "小红书", "view_count": 500}]
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas, {}, daily_notes=notes
)
self.assertEqual(facts["daily_new_notes"], notes)
self.assertNotIn("creator_connections", facts["rankings"])
self.assertNotIn("daily_views", facts["rankings"])
def test_daily_view_ranking_and_platform_cards_receive_stored_images(self):
images = {
("高转化", "天猫"): {"local_image_path": "same.jpg", "image_platform": "天猫"},
("大流量", "天猫"): {"local_image_path": "visitor.jpg", "image_platform": "天猫"},
}
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas, {},
style_images=images,
)
card = facts["platform_personas"][0]
self.assertEqual(card["top_conversion"]["local_image_path"], "same.jpg")
self.assertEqual(card["top_visitors"]["local_image_path"], "visitor.jpg")
def test_matrix_groups_by_style_category_without_total_exposure(self):
signals = {
"高转化": {"persona": True, "note_count": 3, "recent_note_exposure": 43000, "comment_count": 80,
"review_count": 10, "review_negative_rate": 0.3, "creative_ctr": 0.08},
}
facts = build_dashboard_facts(
"2026-07-15", self.tracking, self.platform, self.personas, {},
style_categories={
"大流量": "都市机能", "高转化": "智性通勤",
"极小样本": "智性通勤", "缺数据": "都市机能",
},
style_signals=signals,
)
self.assertEqual([group["style_category"] for group in facts["style_groups"]], ["都市机能", "智性通勤"])
self.assertEqual([row["style_name"] for row in facts["style_groups"][0]["styles"]], ["大流量", "缺数据"])
high = next(row for row in facts["styles"] if row["style_name"] == "高转化")
self.assertNotIn("total_note_exposure", high)
self.assertEqual(high["recent_note_exposure"], 43000)
self.assertNotIn("total_note_exposure", facts["definitions"])
self.assertIn("评价风险", high["diagnosis"])
self.assertIn("画像", high["evidence_summary"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,429 @@
import unittest
from datetime import date
from unittest.mock import patch
import daily_marketing_report as daily
class DailyMarketingReportTests(unittest.TestCase):
def test_sales_forecast_uses_seven_day_total_and_recent_three_day_velocity(self):
forecast = daily.estimate_sales_forecast([10, 12, 14, 16, 18, 20, 22])
self.assertEqual(forecast["sales_7d_total"], 112)
self.assertEqual(forecast["sales_forecast_7d"], 140)
self.assertAlmostEqual(forecast["sales_forecast_change"], 7 / 13, places=4)
def test_sales_forecast_requires_complete_seven_day_history(self):
self.assertEqual(
daily.estimate_sales_forecast([10, 12, 14]),
{
"sales_7d_total": None,
"sales_forecast_7d": None,
"sales_forecast_change": None,
},
)
def test_default_recipients_include_all_four_daily_report_owners(self):
self.assertEqual(
daily.DEFAULT_RECIPIENT_OPEN_IDS,
(
"ou_7ad5fc8012e2f741afc5346e05ffd447",
"ou_fa8d81a16527ad06352dbecc575285b8",
"ou_2eda5eec112109ae6d19a1f6813eadcb",
"ou_89bcff110ccbb09a23548dc0fb3d880c",
),
)
def test_hermes_transport_error_is_not_accepted_as_report(self):
with self.assertRaisesRegex(RuntimeError, "Hermes 分析失败"):
daily.require_valid_hermes_report("API call failed after 3 retries: Connection error.")
self.assertEqual(daily.require_valid_hermes_report("整体经营分析\n销量保持稳定"), "整体经营分析\n销量保持稳定")
def test_light_uses_highest_absolute_visitor_or_cart_change(self):
self.assertEqual(daily.classify_light(0.05, -0.08), "🟢")
self.assertEqual(daily.classify_light(0.12, 0.02), "🟡")
self.assertEqual(daily.classify_light(-0.31, 0.01), "🔴")
self.assertEqual(daily.classify_light(None, None), "无判断")
def test_comment_signal_requires_minimum_sample(self):
comments = ["光影行星宙斯怎么买"] * 49
self.assertIsNone(daily.calculate_comment_signal("宙斯", comments))
comments.append("好看")
signal = daily.calculate_comment_signal("宙斯", comments)
self.assertEqual(signal["sample_size"], 50)
self.assertEqual(signal["brand_mention_rate"], 0.98)
self.assertEqual(signal["purchase_intent_rate"], 0.98)
def test_prompt_uses_only_the_fresh_user_instruction_and_database_facts(self):
prompt = daily.build_daily_prompt(
report_date="2026-07-15",
today_notes=[],
tracking_rows=[],
next_day_rows=[],
comment_rows=[],
)
self.assertIn("以下为数据库查询结果", prompt)
self.assertIn('"报告日期": "2026-07-15"', prompt)
self.assertIn("增长型SKU", daily.SYSTEM_PROMPT)
self.assertIn("流量机会型SKU", daily.SYSTEM_PROMPT)
self.assertIn("潜力型SKU", daily.SYSTEM_PROMPT)
self.assertIn("风险型SKU", daily.SYSTEM_PROMPT)
self.assertNotIn("A类:爆款增长SKU", daily.SYSTEM_PROMPT)
def test_prompt_uses_fashion_bag_data_analyst_role(self):
self.assertIn("专业的时尚包袋品牌电商运营数据分析师", daily.SYSTEM_PROMPT)
self.assertIn("销售、电商运营及达人营销数据", daily.SYSTEM_PROMPT)
self.assertIn("发现增长机会和风险", daily.SYSTEM_PROMPT)
self.assertIn("SKU营销运营日报", daily.SYSTEM_PROMPT)
self.assertIn("展示关键数据", daily.SYSTEM_PROMPT)
def test_fresh_prompt_contains_the_complete_requested_analysis_structure(self):
prompt = daily.SYSTEM_PROMPT
for phrase in (
"今日整体经营判断",
"SKU经营表现分析",
"达人营销效果分析",
"产品节奏判断",
"蓄水期",
"放量期",
"稳定期",
"衰退期",
"增长SKU TOP3",
"风险SKU TOP3",
"比较昨日、七日基准",
"用图表展示",
"曝光量",
"建议动作",
"对销量影响",
"所有建议必须具体到SKU",
):
self.assertIn(phrase, prompt)
for retired in ("A类:爆款增长SKU", "B类:潜力增长SKU", "D类:需要优化SKU"):
self.assertNotIn(retired, prompt)
def test_database_payload_keeps_all_available_sources(self):
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], {"reviews": [{"sample_size": 3}]})
for phrase in ("所有SKU电商运营数据", "达人营销内容数据", "评论与补充经营数据", "reviews"):
self.assertIn(phrase, prompt)
def test_note_label_ignores_placeholder_creator_name(self):
note = {"id": 7, "creator_name": "/", "title": "瑞白通勤包实测"}
self.assertEqual(daily.note_label(note), "瑞白通勤包实测")
def test_cooperation_metrics_hide_unverifiable_engagement(self):
invalid = daily._sanitize_cooperation_row({
"publish_time": None,
"exposure_count": 38000,
"engagement_count_num": 21430000,
})
self.assertIsNone(invalid["engagement_count_num"])
self.assertEqual(invalid["publish_status"], "发布时间无数据")
self.assertIn("口径异常", invalid["engagement_data_status"])
valid = daily._sanitize_cooperation_row({
"publish_time": "2026-07-14",
"exposure_count": 38000,
"engagement_count_num": 2143,
})
self.assertEqual(valid["engagement_count_num"], 2143)
self.assertEqual(valid["publish_status"], "已记录发布时间")
def test_load_report_facts_tracks_all_styles_for_report_date(self):
metrics = {
"宙斯": {"light": "🟢"},
}
with patch.object(daily, "_note_rows", return_value=[]), \
patch.object(daily, "_all_style_names", return_value=["宙斯", "蓝鹊"]) as all_styles, \
patch.object(daily, "_style_metrics", return_value=metrics) as style_metrics:
with patch.object(daily, "_load_enrichment", return_value={"platform_metrics": []}), \
patch.object(daily, "_platform_metrics", return_value=[]), \
patch.object(daily, "_persona_context", return_value=[]), \
patch.object(daily, "load_creator_connections", return_value={}), \
patch.object(daily, "load_style_images", return_value={}), \
patch.object(daily, "load_style_categories", return_value={}), \
patch.object(daily, "_cooperation_context", return_value=[]), \
patch.object(daily, "_style_comment_context", return_value=[]), \
patch.object(daily, "_review_context", return_value=[]), \
patch.object(daily, "_creative_context", return_value=[]), \
patch.object(daily, "build_dashboard_facts", return_value={"styles": ["宙斯", "蓝鹊"]}):
_, tracking, _, _, enrichment = daily.load_report_facts(date(2026, 7, 15))
all_styles.assert_called_once_with(date(2026, 7, 15))
self.assertEqual([row["style_name"] for row in tracking], ["宙斯", "蓝鹊"])
self.assertEqual(tracking[1]["light"], "无判断")
self.assertEqual(style_metrics.call_args_list[0].args[1], ["宙斯", "蓝鹊"])
self.assertEqual(enrichment["platform_metrics"], [])
self.assertEqual(enrichment["candidate_styles"], [])
self.assertEqual(enrichment["dashboard"]["styles"], ["宙斯", "蓝鹊"])
def test_report_date_uses_yesterday_notes_for_daily_new_notes(self):
notes = [
{"id": 1, "publish_date": "2026-07-15", "style_name": "宙斯", "creator_name": "达人甲", "platform": "小红书", "publish_time": "2026-07-15 10:00", "title": "今天", "view_count": 10, "engagement_rate": None},
{"id": 2, "publish_date": "2026-07-14", "style_name": "宙斯", "creator_name": "达人乙", "platform": "抖音", "publish_time": "2026-07-14 11:00", "title": "昨天", "view_count": 20, "engagement_rate": None},
]
with patch.object(daily, "_note_rows", return_value=notes), \
patch.object(daily, "_all_style_names", return_value=["宙斯"]), \
patch.object(daily, "_style_metrics", return_value={}), \
patch.object(daily, "_load_enrichment", return_value={}), \
patch.object(daily, "_platform_metrics", return_value=[]), \
patch.object(daily, "_persona_context", return_value=[]), \
patch.object(daily, "load_creator_connections", return_value={}), \
patch.object(daily, "load_style_images", return_value={}), \
patch.object(daily, "load_style_categories", return_value={}), \
patch.object(daily, "_cooperation_context", return_value=[]), \
patch.object(daily, "_style_comment_context", return_value=[]), \
patch.object(daily, "_review_context", return_value=[]), \
patch.object(daily, "_creative_context", return_value=[]), \
patch.object(daily, "_comment_rows", return_value=[]), \
patch.object(daily, "build_dashboard_facts", return_value={}) as dashboard:
daily_notes, _, _, _, _ = daily.load_report_facts(date(2026, 7, 15))
self.assertEqual([row["note_id"] for row in daily_notes], [2])
self.assertEqual([row["note_id"] for row in dashboard.call_args.kwargs["daily_notes"]], [2])
def test_prompt_requires_all_styles_without_top_ten_truncation(self):
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [])
self.assertIn("所有SKU", prompt)
self.assertNotIn("只保留异常程度最高的10个", prompt)
self.assertIn("SKU经营表现分析", daily.SYSTEM_PROMPT)
self.assertIn("加购率", daily.SYSTEM_PROMPT)
def test_prompt_uses_the_new_daily_output_contract(self):
self.assertIn("电商运营数据", daily.SYSTEM_PROMPT)
self.assertIn("达人营销数据", daily.SYSTEM_PROMPT)
self.assertNotIn("TOP20", daily.SYSTEM_PROMPT)
self.assertIn("一页A4", daily.SYSTEM_PROMPT)
self.assertIn("↑↓展示趋势", daily.SYSTEM_PROMPT)
self.assertNotIn("→稳定", daily.SYSTEM_PROMPT)
self.assertIn("电商销量表现 + 访客数 + 转化率 + 达人笔记曝光 + 用户反馈", daily.SYSTEM_PROMPT)
def test_prompt_payload_keeps_yesterday_and_seven_day_comparisons(self):
rows = [{
"style_name": "蓝鹊",
"sales": 10,
"sales_yesterday": 8,
"avg_sales_7d": 7.5,
"sales_change_yesterday": 0.25,
"sales_change": 0.3333,
}]
prompt = daily.build_daily_prompt("2026-07-16", [], rows, [], [])
self.assertIn('"sales_yesterday": 8', prompt)
self.assertIn('"avg_sales_7d": 7.5', prompt)
self.assertIn('"sales_change_yesterday": 0.25', prompt)
def test_style_metrics_calculates_yesterday_and_seven_day_changes(self):
class FakeCursor:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, query, params):
self.query = query
self.params = params
def fetchall(self):
return [(
"蓝鹊",
120, 24, 10, 2,
100, 20, 8, 1,
90, 18, 7.5, 1.2,
[4, 5, 6, 7, 8, 9, 10],
)]
class FakeConnection:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def cursor(self):
return FakeCursor()
with patch.object(daily, "get_conn", return_value=FakeConnection()):
row = daily._style_metrics(date(2026, 7, 16), ["蓝鹊"])["蓝鹊"]
self.assertEqual(row["sales_yesterday"], 8)
self.assertEqual(row["sales_change_yesterday"], 0.25)
self.assertEqual(row["sales_change"], 0.3333)
self.assertEqual(row["visitor_change_yesterday"], 0.2)
self.assertEqual(row["conversion_rate"], round(10 / 120, 4))
self.assertEqual(row["conversion_rate_yesterday"], round(8 / 100, 4))
self.assertEqual(row["sales_7d_total"], 49)
self.assertEqual(row["sales_forecast_7d"], 63)
def test_prompt_does_not_reuse_retired_dashboard_sections(self):
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], {"dashboard": {
"rankings": {"sales": []},
"platform_personas": [{"platform": "天猫"}],
"daily_new_notes": [],
"styles": [{"style_name": "蓝鹊", "total_note_exposure": 1000, "evidence_summary": "经营、画像、营销笔记"}],
"definitions": {"conversion_rate": "销量/访客"},
}})
self.assertNotIn("按风格分组的综合经营矩阵", prompt)
self.assertNotIn("平台高转化与高访客画像", prompt)
self.assertNotIn("全款式经营看板摘要", prompt)
def test_report_normalization_and_all_style_validation(self):
report = daily.normalize_report(
"健康度:偏弱。36 个款式中\n蓝鹊同比增长79%\n宙斯环比下降16%",
35,
)
self.assertNotIn("同比", report)
self.assertIn("环比下降16%", report)
self.assertIn("35个款式中", report)
self.assertEqual(daily.normalize_report("销量 0 / 退款 0(无数据)"), "销量 0 / 退款 0(当日均为0)")
cleaned = daily.normalize_report("投放尚未发布,带动效应快速衰减;待发布后核对")
self.assertNotIn("未发布", cleaned)
self.assertNotIn("带动效应", cleaned)
self.assertIn("发布时间无数据", cleaned)
self.assertNotIn("驱动访客", daily.normalize_report("该内容未驱动访客回升"))
daily.validate_all_styles(report, [{"style_name": "蓝鹊"}, {"style_name": "宙斯"}])
with self.assertRaisesRegex(RuntimeError, "缺少款式"):
daily.validate_all_styles(report, [{"style_name": "白鹭"}])
def test_report_normalization_removes_database_and_code_style_language(self):
raw = """数据已收到,我先用脚本做一遍聚合。
# 品牌SKU电商运营日报
• 最佳渠道:无法判定。今日 `昨日新发布内容` 为空,cooperations 仅1条,publish_time=null、engagement_data_status=无数据。
5. 数据治理与内容补位:(a)对齐 publish_time;(b)修复互动数据;(c)补齐数据。
报告字数提示:如需压缩请告知。
完整数据索引:如需导出 CSV 请告诉我。
"""
report = daily.normalize_report(raw)
self.assertTrue(report.startswith("# 品牌SKU电商运营日报"))
for forbidden in ("我先用脚本", "cooperations", "publish_time", "engagement_data_status", "null", "`", "(a)", "报告字数提示", "请告诉我"):
self.assertNotIn(forbidden, report)
self.assertIn("达人合作记录", report)
self.assertIn("发布时间无数据", report)
self.assertIn("互动数据无数据", report)
self.assertIn("(1)", report)
def test_system_prompt_forbids_database_fields_and_conversational_tail(self):
for phrase in (
"禁止输出数据库表名、字段名、JSON键名",
"禁止出现反引号、null",
"禁止描述生成过程",
"禁止在结尾追问",
):
self.assertIn(phrase, daily.SYSTEM_PROMPT)
def test_status_groups_are_replaced_with_complete_deterministic_lists(self):
report = "二、种草追踪池\n\nB. 全量状态(固定4行)\n🔴(1):蓝鹊\n\n三、昨日笔记次日追踪"
rows = [
{"style_name": "蓝鹊", "light": "🔴"},
{"style_name": "宙斯", "light": "🟡"},
{"style_name": "白鹭", "light": "🟢"},
{"style_name": "云卷2", "light": "无判断"},
]
result = daily.replace_status_groups(report, rows)
self.assertIn("B. 全量状态(4款)", result)
self.assertIn("🔴(1):蓝鹊", result)
self.assertIn("🟡(1):宙斯", result)
self.assertIn("🟢(1):白鹭", result)
self.assertIn("无判断(1):云卷2", result)
self.assertIn("三、昨日笔记次日追踪", result)
def test_status_groups_are_inserted_when_model_omits_marker(self):
report = "一、核心经营结论\n正常\n\n三、达人投放与电商同期关联\n无数据"
rows = [{"style_name": "蓝鹊", "light": "🔴"}, {"style_name": "白鹭", "light": "🟢"}]
result = daily.replace_status_groups(report, rows, "C. 补充经营信号\n- 店铺大盘:无数据")
self.assertIn("B. 全量状态(2款)", result)
self.assertIn("🔴(1):蓝鹊", result)
self.assertIn("🟢(1):白鹭", result)
self.assertLess(result.index("B. 全量状态"), result.index("三、达人投放"))
def test_prompt_passes_available_enrichment_without_restoring_old_sections(self):
enrichment = {
"platform_metrics": [{"style_name": "蓝鹊", "platform": "天猫", "sales": 14}],
"cooperations": [{"style_name": "蓝鹊", "content_direction": "通勤"}],
"personas": [{"style_name": "蓝鹊", "gender_top": "女性用户 60%"}],
"creatives": [{"style_name": "蓝鹊", "ctr": 4.2}],
"reviews": [{"style_name": "蓝鹊", "sample_size": 6}],
"shop_overview": [{"platform": "天猫", "week_end": "2026-07-12"}],
}
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], enrichment)
for source_key in ("platform_metrics", "cooperations", "personas", "creatives", "reviews", "shop_overview"):
self.assertIn(source_key, prompt)
self.assertIn("评论与补充经营数据", prompt)
self.assertNotIn("补充经营信号", prompt)
self.assertNotIn("全量状态", prompt)
def test_enrichment_section_validation_requires_available_categories(self):
enrichment = {"personas": [{}], "creatives": [{}], "reviews": [{}], "shop_overview": [{}]}
complete = "条件信号:人物画像;主图;商品评价;店铺大盘"
daily.validate_enrichment_sections(complete, enrichment)
with self.assertRaisesRegex(RuntimeError, "主图"):
daily.validate_enrichment_sections("人物画像 商品评价 店铺大盘", enrichment)
def test_build_condition_signals_covers_all_available_sources(self):
enrichment = {
"candidate_styles": ["蓝鹊"],
"personas": [{"style_name": "蓝鹊", "platform": "天猫", "data_date": "2026-07-15", "gender_top": "女性 60%"}],
"creatives": [{"style_name": "蓝鹊", "platform": "天猫", "data_date": "2026-07-12", "impressions": 1000, "clicks": 50, "ctr": 5.0}],
"reviews": [{"style_name": "蓝鹊", "sample_size": 6, "negative_count": 1, "negative_rate": 0.1667, "window_start": "2026-07-09", "window_end": "2026-07-15"}],
"shop_overview": [{"platform": "天猫", "week_end": "2026-07-12", "visitors": 10000, "deal_amount": 20000}],
}
text = daily.build_condition_signals(enrichment)
for phrase in ("C. 补充经营信号", "人物画像", "主图", "商品评价", "店铺大盘", "2026-07-12"):
self.assertIn(phrase, text)
def test_persona_top_value_ignores_invalid_percentages(self):
payload = {"buying_power": [{"name": "异常", "value": 133}, {"name": "L4", "value": 42}]}
self.assertEqual(daily._top_persona_value(payload, "buying_power"), "L4 42.0%")
def test_enriched_report_rejects_missing_platform_claim_and_invented_new_product(self):
daily.validate_enriched_report("平台拆分完整,均为同期合作")
with self.assertRaisesRegex(RuntimeError, "平台拆分"):
daily.validate_enriched_report("无平台拆分数据")
with self.assertRaisesRegex(RuntimeError, "新品"):
daily.validate_enriched_report("同期出现多个新品合作")
def test_platform_persona_section_uses_database_winners_not_model_text(self):
report = ("### 三、平台人群与营销机会\n天猫高转化款为错误款式\n\n"
"### 四、明日业务动作\n复查数据\n\n"
"三、平台人群与营销机会\n重复错误款式")
enrichment = {"dashboard": {
"daily_views_status": "无数据:未保存每日增量快照",
"platform_personas": [{
"platform": "天猫",
"top_conversion": {
"style_name": "布谷", "conversion_rate": 0.0259, "visitors": 617,
"persona_text": "无数据", "interest_hypothesis": "兴趣推测:无数据",
"optimization": "先补采人群画像",
},
"top_visitors": {
"style_name": "星云2", "conversion_rate": 0.0114, "visitors": 3504,
"persona_text": "男性用户 62.7%", "interest_hypothesis": "兴趣推测:数码3C",
"optimization": "优化详情页",
},
}],
}}
result = daily.replace_platform_persona_section(report, enrichment)
self.assertIn("转化最高为布谷(转化率2.6%", result)
self.assertIn("访客最高为星云2(访客3,504", result)
self.assertIn("兴趣推测:数码3C", result)
self.assertNotIn("错误款式", result)
self.assertNotIn("重复错误款式", result)
self.assertIn("四、明日业务动作", result)
self.assertEqual(result.count("三、平台人群与营销机会"), 1)
def test_platform_persona_section_supports_new_daily_report_boundary(self):
report = ("### 三、平台人群与营销机会\n模型内容\n\n"
"### 四、今日重点SKU分析\n重点款内容\n")
result = daily.replace_platform_persona_section(report, {"dashboard": {}})
self.assertIn("三、平台人群与营销机会", result)
self.assertIn("四、今日重点SKU分析", result)
self.assertNotIn("模型内容", result)
self.assertEqual(result.count("三、平台人群与营销机会"), 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,18 @@
import unittest
from pathlib import Path
class DailyMarketingReportScheduleTests(unittest.TestCase):
def test_batch_runs_latest_daily_report_and_propagates_exit_code(self):
path = Path(__file__).resolve().parents[1] / "data" / "tools" / "daily_marketing_report.bat"
content = path.read_text(encoding="utf-8")
self.assertIn("daily_marketing_report.py --send", content)
self.assertNotIn("--date", content)
self.assertIn("daily_marketing_report_%TS%.log", content)
self.assertIn("LARK_CLI_NO_PROXY=1", content)
self.assertIn("exit /b %RC%", content)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,189 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_report_card import (
build_analysis_card,
build_dashboard_card,
build_daily_report_card,
prepare_feishu_image,
send_card_to_recipients,
send_daily_report_cards,
upload_dashboard_image,
)
class DailyReportCardTests(unittest.TestCase):
def test_dashboard_and_analysis_are_separate_cards_with_full_text(self):
report = """![全域种草数据看板](charts/dashboard.png)
第一部分 整体经营分析
总销量下降15%。
整体趋势判断:下滑状态。
第二部分 SKU分析排序
1. 蓝鹊:销量第一。
2. 星云2:转化领先。
3. 宙斯:访客增长。
4. 南斯:退款偏高。
5. 凌云3:需要优化。
6. 波塞冬:继续观察。
第三部分 关键预警与建议
退款订单比上升,48小时内排查。
"""
rows = [{"style_name": "蓝鹊", "light": "🔴"}]
dashboard = build_dashboard_card("2026-07-17", rows, "img_v3_test")
analysis = build_analysis_card("2026-07-17", report)
dashboard_text = json.dumps(dashboard, ensure_ascii=False)
analysis_text = json.dumps(analysis, ensure_ascii=False)
self.assertIn("img_v3_test", dashboard_text)
self.assertNotIn("总销量下降15%", dashboard_text)
self.assertNotIn("img_v3_test", analysis_text)
self.assertNotIn("![全域种草数据看板]", analysis_text)
self.assertIn("波塞冬:继续观察", analysis_text)
self.assertIn("退款订单比上升", analysis_text)
def test_build_card_uses_card_2_schema_and_dashboard(self):
report = """一、核心经营结论
1. 波塞冬edge访客明显下滑。
2. 蓝鹊销量同期上行。
二、产品经营分析
略
四、明日业务动作
1. 复查波塞冬edge天猫流量。
2. 跟进蓝鹊投放承接。
"""
rows = [
{"style_name": "波塞冬edge", "light": "🔴"},
{"style_name": "宙斯", "light": "🟡"},
{"style_name": "白鹭", "light": "🟢"},
]
card = build_daily_report_card("2026-07-15", report, rows, "img_v3_test")
self.assertEqual(card["schema"], "2.0")
self.assertEqual(card["config"]["width_mode"], "fill")
self.assertIn("Hermes", card["header"]["subtitle"]["content"])
elements = card["body"]["elements"]
self.assertEqual([item["tag"] for item in elements], ["column_set", "img", "markdown", "markdown"])
self.assertEqual(elements[1]["img_key"], "img_v3_test")
metrics_text = json.dumps(elements[0], ensure_ascii=False)
self.assertIn("红灯款式", metrics_text)
self.assertIn("1", metrics_text)
self.assertNotIn("二、产品经营分析", json.dumps(card, ensure_ascii=False))
def test_prepare_feishu_image_resizes_to_supported_width(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "source.png"
target = Path(tmp) / "target.png"
Image.new("RGB", (1600, 1000), "white").save(source)
prepare_feishu_image(source, target)
with Image.open(target) as image:
self.assertEqual(image.width, 1500)
self.assertLessEqual(image.height / image.width, 16 / 9)
def test_build_card_reads_new_management_report_headings(self):
report = """## 1. 今日经营概览
销售:↑12%,蓝鹊贡献主要增量。
整体判断:增长状态。
## 2. SKU健康排名 TOP20
略
## 6. 今日运营建议
1. 商品动作:复核蓝鹊库存。
2. 电商动作:优化宙斯详情页。
"""
card = build_daily_report_card("2026-07-17", report, [], "img_v3_test")
card_text = json.dumps(card, ensure_ascii=False)
self.assertIn("蓝鹊贡献主要增量", card_text)
self.assertIn("复核蓝鹊库存", card_text)
def test_build_card_reads_fresh_partial_prompt_report_headings(self):
report = """第一部分 整体经营分析
总销量下降15%。
整体趋势判断:下滑状态。
第二部分 SKU分析排序
略
第三部分 关键预警与建议
退款订单比上升,48小时内排查。
追加增长型SKU预算。
"""
card = build_daily_report_card("2026-07-17", report, [], "img_v3_test")
card_text = json.dumps(card, ensure_ascii=False)
self.assertIn("总销量下降15%", card_text)
self.assertIn("48小时内排查", card_text)
@patch("data.tools.daily_report_card.subprocess.run")
def test_upload_dashboard_uses_named_image_file_field(self, run):
run.return_value = type("Result", (), {
"returncode": 0, "stdout": json.dumps({"image_key": "img_v3_test"}), "stderr": ""
})()
key = upload_dashboard_image(Path("charts/dashboard.png"))
self.assertEqual(key, "img_v3_test")
command = run.call_args.args[0]
self.assertEqual(command[command.index("--file") + 1], "image=./dashboard.png")
@patch("data.tools.daily_report_card.subprocess.run")
def test_send_card_to_all_recipients(self, run):
run.side_effect = [
type("Result", (), {"returncode": 0, "stdout": json.dumps({"message_id": f"om_{i}"}), "stderr": ""})()
for i in range(3)
]
recipients = ["ou_one", "ou_two", "ou_three"]
results = send_card_to_recipients({"schema": "2.0"}, recipients, "2026-07-15")
self.assertEqual([item["message_id"] for item in results], ["om_0", "om_1", "om_2"])
self.assertEqual(run.call_count, 3)
commands = [call.args[0] for call in run.call_args_list]
self.assertEqual([cmd[cmd.index("--user-id") + 1] for cmd in commands], recipients)
self.assertTrue(all("--msg-type" in cmd and "interactive" in cmd for cmd in commands))
keys = [cmd[cmd.index("--idempotency-key") + 1] for cmd in commands]
self.assertTrue(all(key.startswith("daily-2026-07-15-") for key in keys))
self.assertTrue(all(len(key) <= 50 for key in keys))
@patch("data.tools.daily_report_card.send_card_to_recipients")
@patch("data.tools.daily_report_card.upload_dashboard_image", return_value="img_v3_test")
@patch("data.tools.daily_report_card.prepare_feishu_image")
def test_daily_report_sends_dashboard_then_analysis_as_two_messages_per_recipient(
self, prepare, upload, send
):
send.side_effect = [
[{"message_id": "om_board_1"}, {"message_id": "om_board_2"}],
[{"message_id": "om_text_1"}, {"message_id": "om_text_2"}],
]
recipients = ["ou_one", "ou_two"]
results = send_daily_report_cards(
"2026-07-17",
"第一部分 整体经营分析\n完整分析内容",
[{"light": "🟢"}],
Path("charts/dashboard.png"),
recipients,
)
self.assertEqual(
[item["message_id"] for item in results],
["om_board_1", "om_board_2", "om_text_1", "om_text_2"],
)
self.assertEqual(send.call_count, 2)
first_card = send.call_args_list[0].args[0]
second_card = send.call_args_list[1].args[0]
self.assertIn("img_v3_test", json.dumps(first_card, ensure_ascii=False))
self.assertIn("完整分析内容", json.dumps(second_card, ensure_ascii=False))
self.assertEqual(send.call_args_list[0].kwargs["message_kind"], "dashboard")
self.assertEqual(send.call_args_list[1].kwargs["message_kind"], "analysis")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,104 @@
import tempfile
import unittest
from pathlib import Path
from PIL import Image
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_report_charts import (
_split_style_groups,
build_overview_cards,
classify_style_action,
generate_dashboard,
select_chart_styles,
)
class DailyReportChartTests(unittest.TestCase):
def setUp(self):
self.rows = [
{"style_name": "大盘下滑", "light": "🔴", "visitors": 1000, "avg_visitors_7d": 2000, "cart_users": 80, "avg_cart_users_7d": 100, "visitor_change": -0.5, "cart_change": -0.2, "sales_change": -0.4, "refund_change": 0.1},
{"style_name": "正向放量", "light": "🔴", "visitors": 1500, "avg_visitors_7d": 1000, "cart_users": 180, "avg_cart_users_7d": 100, "visitor_change": 0.5, "cart_change": 0.8, "sales_change": 0.3, "refund_change": -0.1},
{"style_name": "小基数", "light": "🔴", "visitors": 20, "avg_visitors_7d": 100, "cart_users": 1, "avg_cart_users_7d": 2, "visitor_change": -0.8, "cart_change": -0.5, "sales_change": None, "refund_change": None},
{"style_name": "黄灯A", "light": "🟡", "visitors": 800, "avg_visitors_7d": 1000, "cart_users": 70, "avg_cart_users_7d": 80, "visitor_change": -0.2, "cart_change": -0.125, "sales_change": -0.1, "refund_change": 0.2},
{"style_name": "绿灯", "light": "🟢", "visitors": 500, "avg_visitors_7d": 510, "cart_users": 40, "avg_cart_users_7d": 41, "visitor_change": -0.02, "cart_change": -0.02, "sales_change": 0.0, "refund_change": 0.0},
]
def test_selection_covers_negative_positive_and_small_base(self):
names = [row["style_name"] for row in select_chart_styles(self.rows, limit=4)]
self.assertIn("大盘下滑", names)
self.assertIn("正向放量", names)
self.assertIn("小基数", names)
def test_generate_dashboard_writes_png(self):
enrichment = {"platform_metrics": [
{"style_name": "大盘下滑", "platform": "天猫", "visitors": 700},
{"style_name": "大盘下滑", "platform": "京东", "visitors": 300},
]}
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "dashboard.png"
generate_dashboard("2026-07-15", self.rows, enrichment, path)
self.assertTrue(path.exists())
self.assertGreater(path.stat().st_size, 10_000)
with Image.open(path) as image:
self.assertEqual(image.size, (1600, 2500))
def test_style_groups_are_split_into_balanced_category_columns(self):
groups = [
{"style_category": "都市机能", "styles": [{}] * 12},
{"style_category": "户外机能", "styles": [{}] * 3},
{"style_category": "都市新贵", "styles": [{}] * 3},
{"style_category": "智性通勤", "styles": [{}] * 14},
{"style_category": "都市新旅", "styles": [{}]},
{"style_category": "都市运动", "styles": [{}] * 3},
]
left, right = _split_style_groups(groups)
self.assertEqual([group["style_category"] for group in left], ["都市机能", "户外机能", "都市新贵"])
self.assertEqual([group["style_category"] for group in right], ["智性通勤", "都市新旅", "都市运动"])
groups.append({"style_category": "未分类", "styles": [{}] * 2})
left, right = _split_style_groups(groups)
self.assertEqual(sum(len(group["styles"]) for group in left), 18)
self.assertEqual(sum(len(group["styles"]) for group in right), 20)
def test_overview_compares_today_yesterday_and_seven_day_average(self):
rows = [
{
"style_name": "蓝鹊", "sales": 12, "sales_yesterday": 10, "avg_sales_7d": 8,
"visitors": 120, "visitors_yesterday": 100, "avg_visitors_7d": 90,
},
{
"style_name": "宙斯", "sales": 18, "sales_yesterday": 20, "avg_sales_7d": 15,
"visitors": 180, "visitors_yesterday": 200, "avg_visitors_7d": 150,
},
]
cards = build_overview_cards(rows, {"daily_new_notes": [{"view_count": 515}]})
sales = next(card for card in cards if card["key"] == "sales")
conversion = next(card for card in cards if card["key"] == "conversion")
content = next(card for card in cards if card["key"] == "content")
self.assertEqual((sales["today"], sales["yesterday"], sales["baseline_7d"]), (30, 30, 23))
self.assertEqual(conversion["today"], 0.1)
self.assertEqual(content["today"], 515)
self.assertIsNone(content["baseline_7d"])
def test_style_action_matches_prompt_growth_opportunity_and_risk_rules(self):
growth = classify_style_action({
"sales_change_yesterday": 0.2, "visitor_change_yesterday": 0.1,
"conversion_change_yesterday": 0.001, "refund_change_yesterday": -0.1,
})
opportunity = classify_style_action({
"sales_change_yesterday": -0.1, "visitor_change_yesterday": 0.2,
"conversion_change_yesterday": -0.01, "refund_change_yesterday": 0,
})
risk = classify_style_action({
"sales_change_yesterday": -0.2, "visitor_change_yesterday": -0.1,
"conversion_change_yesterday": -0.01, "refund_change_yesterday": 0.4,
})
self.assertEqual(growth, ("增长型", "加达人/备货"))
self.assertEqual(opportunity, ("流量机会", "优化详情页"))
self.assertEqual(risk, ("风险型", "查退款/控投放"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,130 @@
from gyxx_flow.modules.content_marketing.runtime.data.tools import friday_relogin_parallel as relogin
from gyxx_flow.modules.content_marketing.runtime.data.tools import relogin_transaction
def test_five_expected_platforms_are_configured():
assert set(relogin.PLATFORMS) == {
"bilibili", "douyin", "pgy", "xingtu", "xiaohongshu"
}
assert relogin.PLATFORMS["xingtu"].required_cookie == "sessionid"
assert relogin.PLATFORMS["douyin"].required_cookie == "sessionid"
def test_default_screenshot_recipients_are_routed_by_platform():
assert relogin.DEFAULT_RECIPIENTS == {
"pgy": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
"xingtu": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
"douyin": "ou_7ad5fc8012e2f741afc5346e05ffd447",
"bilibili": "ou_7ad5fc8012e2f741afc5346e05ffd447",
"xiaohongshu": "ou_7ad5fc8012e2f741afc5346e05ffd447",
}
assert relogin.recipient_for_platform("pgy") == "ou_24cc944d6e43c69c59d6560ad4e2ae6e"
assert relogin.recipient_for_platform("douyin") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
assert relogin.recipient_for_platform("bilibili") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
assert relogin.recipient_for_platform("pgy", "ou_override") == "ou_override"
def test_failed_platforms_are_grouped_by_their_responsible_recipient():
grouped = relogin.group_platforms_by_recipient(
["pgy", "douyin", "bilibili", "xingtu", "xiaohongshu"]
)
assert grouped == {
"ou_24cc944d6e43c69c59d6560ad4e2ae6e": ["pgy", "xingtu"],
"ou_7ad5fc8012e2f741afc5346e05ffd447": [
"douyin", "bilibili", "xiaohongshu"
],
}
def test_login_windows_are_classified_by_title():
assert relogin._platform_from_window_title("账号登录 - Google Chrome") == "bilibili"
assert relogin._platform_from_window_title("小红书蒲公英 - Google Chrome") == "pgy"
assert relogin._platform_from_window_title("抖音授权 - Google Chrome") == "xingtu"
assert relogin._platform_from_window_title("抖音 - 记录美好生活") == "douyin"
assert relogin._platform_from_window_title("小红书 - 你的生活兴趣社区") == "xiaohongshu"
assert relogin._platform_from_window_title("Codex") is None
def test_retry_only_failed_platforms_for_at_most_three_rounds():
calls = []
def fake_round(names, attempt):
calls.append((tuple(names), attempt))
if attempt == 1:
return {name: name != "xingtu" for name in names}
if attempt == 2:
return {name: False for name in names}
return {name: True for name in names}
result = relogin.run_with_retries(
["bilibili", "pgy", "xingtu", "xiaohongshu"],
max_attempts=3,
run_round=fake_round,
)
assert calls == [
(("bilibili", "pgy", "xingtu", "xiaohongshu"), 1),
(("xingtu",), 2),
(("xingtu",), 3),
]
assert all(result.values())
def test_stops_after_third_failed_round():
calls = []
def always_fail(names, attempt):
calls.append(attempt)
return {name: False for name in names}
result = relogin.run_with_retries(
["pgy"], max_attempts=3, run_round=always_fail
)
assert calls == [1, 2, 3]
assert result == {"pgy": False}
def test_timeout_terminates_the_complete_process_tree(monkeypatch):
calls = []
class Process:
pid = 1234
def terminate(self):
calls.append("fallback-terminate")
def wait(self, timeout):
calls.append(("wait", timeout))
monkeypatch.setattr(
relogin.subprocess,
"run",
lambda command, **kwargs: calls.append((command, kwargs)),
)
relogin._terminate_process_tree(Process())
assert calls[0][0] == ["taskkill", "/PID", "1234", "/T", "/F"]
assert calls[1] == ("wait", 10)
def test_transaction_rolls_back_files_and_profile(tmp_path):
cookie = tmp_path / "cookies.json"
storage = tmp_path / "storage.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
storage.write_text("old-storage", encoding="utf-8")
profile.mkdir()
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
state = relogin_transaction.begin((cookie, storage), profile)
cookie.write_text("partial", encoding="utf-8")
profile.mkdir()
(profile / "partial.txt").write_text("partial", encoding="utf-8")
relogin_transaction.rollback(state)
assert cookie.read_text(encoding="utf-8") == "old-cookie"
assert storage.read_text(encoding="utf-8") == "old-storage"
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
assert not (profile / "partial.txt").exists()
@@ -0,0 +1,305 @@
import json
from pathlib import Path
import pytest
from gyxx_flow.modules.content_marketing.runtime.creator_task_grouping import chunk_creator_groups, group_tasks_by_creator
import pgy_xhs_scraper_v2 as pgy
import xingtu_scraper_v2 as xingtu
def test_group_tasks_uses_creator_id_and_merges_missing_id_by_unique_name():
tasks = [
{"creator_name": "同一达人", "creator_id": " 123 ", "record_id": "a"},
{"creator_name": "同一达人 ", "creator_id": None, "record_id": "b"},
{"creator_name": "另一个达人", "creator_id": None, "record_id": "c"},
]
groups = group_tasks_by_creator(tasks)
assert len(groups) == 2
assert groups[0]["creator_id"] == "123"
assert [task["record_id"] for task in groups[0]["tasks"]] == ["a", "b"]
def test_group_tasks_does_not_guess_when_same_name_has_conflicting_ids():
tasks = [
{"creator_name": "重名达人", "creator_id": "1", "record_id": "a"},
{"creator_name": "重名达人", "creator_id": "2", "record_id": "b"},
{"creator_name": "重名达人", "creator_id": None, "record_id": "c"},
]
groups = group_tasks_by_creator(tasks)
assert len(groups) == 3
def test_zero_batch_size_means_one_unlimited_batch():
groups = [{"creator_name": str(i), "tasks": []} for i in range(3)]
assert chunk_creator_groups(groups, 0) == [groups]
def _assert_cross_style_collection(module, monkeypatch):
styles = [
{"index": 1, "name": "款式A", "base_token": "base-a", "table_id": "table-a", "field_map": {"a": 1}},
{"index": 2, "name": "款式B", "base_token": "base-b", "table_id": "table-b", "field_map": {"b": 2}},
]
def fake_extract(style, only_record_ids):
return [{
"record_id": f"record-{style['index']}",
"creator_name": "同一达人",
"creator_id": "creator-1",
"target_title": f"标题{style['index']}",
}]
monkeypatch.setattr(module, "extract_target_tasks", fake_extract)
tasks, summaries = module.collect_tasks_across_styles(styles, None)
assert len(tasks) == 2
assert len(group_tasks_by_creator(tasks)) == 1
assert tasks[0]["style_context"]["table_id"] == "table-a"
assert tasks[1]["style_context"]["table_id"] == "table-b"
assert summaries[1]["total"] == 1
assert summaries[2]["total"] == 1
def test_pgy_collects_tasks_across_styles(monkeypatch):
_assert_cross_style_collection(pgy, monkeypatch)
def test_xingtu_collects_tasks_across_styles(monkeypatch):
_assert_cross_style_collection(xingtu, monkeypatch)
def test_xingtu_record_scope_is_applied_per_style(monkeypatch):
styles = [
{"index": 1, "name": "款式A", "base_token": "a", "table_id": "a", "field_map": {}},
{"index": 2, "name": "款式B", "base_token": "b", "table_id": "b", "field_map": {}},
]
observed = {}
def fake_extract(style, only_record_ids):
observed[style["index"]] = only_record_ids
return []
monkeypatch.setattr(xingtu, "extract_target_tasks", fake_extract)
xingtu.collect_tasks_across_styles(styles, {1: {"same-id"}, 2: {"other-id"}})
assert observed == {1: {"same-id"}, 2: {"other-id"}}
def test_xingtu_load_retry_scope_keeps_style_and_record_pair(tmp_path):
summary_path = tmp_path / "summary.json"
summary_path.write_text(json.dumps([
{"index": 1, "results": [
{"record_id": "same-id", "reason": "creator_search_circuit_open"},
]},
{"index": 2, "results": [
{"record_id": "same-id", "reason": "title_unmatched"},
{"record_id": "other-id", "reason": "creator_search_circuit_open"},
]},
]), encoding="utf-8")
scope = xingtu.load_retry_scope(summary_path, "creator_search_circuit_open")
assert scope == {1: {"same-id"}, 2: {"other-id"}}
def _assert_matched_task_uses_own_writeback_context(module, metric_key, monkeypatch):
calls = []
task = {
"record_id": "record-b",
"publish_time": object(),
"style_context": {
"index": 2,
"base_token": "base-b",
"table_id": "table-b",
"field_map": {"slot": "style-b-slot"},
},
}
summary = {"matched": 0, "filled": 0, "skipped_no_pubtime": 0, "results": []}
monkeypatch.setattr(module, "pick_read_field", lambda fmap, publish_time: ("read_count_7d", "field-b", "7天曝光量"))
monkeypatch.setattr(module, "write_back", lambda *args: calls.append(args) or True)
module.apply_matched_task(task, {metric_key: "1.2万", "title": "命中标题"}, summary, dry_run=False)
assert calls == [("base-b", "table-b", "record-b", "field-b", 12000)]
assert summary["matched"] == 1
assert summary["filled"] == 1
def test_pgy_match_writes_to_task_style_context(monkeypatch):
_assert_matched_task_uses_own_writeback_context(pgy, "read_count", monkeypatch)
def test_xingtu_match_writes_to_task_style_context(monkeypatch):
_assert_matched_task_uses_own_writeback_context(xingtu, "play_count", monkeypatch)
def test_xingtu_search_and_open_creator_submits_only_one_search(monkeypatch):
calls = []
def fail_search(_page, creator_name, creator_id):
calls.append((creator_name, creator_id))
raise TimeoutError("no result")
monkeypatch.setattr(xingtu, "search_creator", fail_search)
monkeypatch.setattr(
xingtu,
"open_creator_detail",
lambda *_args, **_kwargs: pytest.fail("detail must not open after search failure"),
)
with pytest.raises(TimeoutError, match="no result"):
xingtu.search_and_open_creator_once(object(), "同一达人", "creator-1")
assert calls == [("同一达人", "creator-1")]
def test_xingtu_search_budget_blocks_duplicate_without_failure_circuit():
budget = xingtu.CreatorSearchBudget(max_consecutive_failures=3)
assert budget.begin(("id", "1")) is True
assert budget.begin(("id", "1")) is False
budget.failed("first")
assert budget.begin(("id", "2")) is True
budget.failed("second")
assert budget.begin(("id", "3")) is True
assert budget.failed("third") is False
assert budget.begin(("id", "4")) is True
assert budget.calls == 4
assert budget.circuit_reason is None
budget.exhaust("search_quota_exhausted")
assert budget.begin(("id", "5")) is False
def test_xingtu_wait_for_results_detects_search_quota_exhausted():
class Page:
def evaluate(self, *_args):
return "quota"
def wait_for_timeout(self, _milliseconds):
pytest.fail("quota detection must return immediately")
with pytest.raises(xingtu.SearchQuotaExhausted):
xingtu.wait_for_visible_creator_result(Page(), "达人", timeout_ms=1000)
def test_xingtu_missing_creation_tab_is_not_treated_as_global_block():
class Page:
def evaluate(self, _script):
return "no_creation_tab"
assert xingtu.is_blocked(Page()) is False
@pytest.mark.parametrize("reason", ["verify_text", "verify_iframe", "verify_div"])
def test_xingtu_explicit_verification_still_opens_global_block(reason):
class Page:
def evaluate(self, _script):
return reason
assert xingtu.is_blocked(Page()) is True
def test_xingtu_missing_creation_tab_falls_back_to_current_creator_page():
class Locator:
@property
def first(self):
return self
def click(self, **_kwargs):
raise TimeoutError("missing tab")
class Page:
def get_by_text(self, *_args, **_kwargs):
return Locator()
def evaluate(self, _script):
return False
def wait_for_timeout(self, _milliseconds):
pass
assert xingtu.open_creation_ability(Page()) is False
def test_xingtu_current_creator_page_cards_are_used_as_fallback(monkeypatch):
class Page:
def wait_for_timeout(self, _milliseconds):
pass
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
monkeypatch.setattr(
xingtu,
"parse_videos_stable",
lambda _page: [{
"title": "MacBook党必看!提升幸福感的开工好物",
"play_count": "1.2万",
"note_id": "",
}],
)
monkeypatch.setattr(xingtu, "go_next_page", lambda _page: False)
monkeypatch.setattr(xingtu, "submit_video_search", lambda _page, _title: False)
found, _candidates, page_limit_hit = xingtu.find_videos_for_tasks(
Page(),
[{
"record_id": "record-1",
"target_title": "MacBook党必看!提升幸福感的开工好物",
}],
)
assert found["record-1"]["play_count"] == "1.2万"
assert found["record-1"]["match_method"] == "title"
assert page_limit_hit is False
@pytest.mark.parametrize(
"message",
[
"BrowserContext.new_page: Protocol error (Target.createTarget): Failed to open a new tab",
"Protocol error (Target.createTarget)",
],
)
def test_xingtu_new_tab_creation_failure_rebuilds_browser_session(message):
assert xingtu.is_browser_session_lost(RuntimeError(message)) is True
def test_daily_retry_does_not_search_xingtu_again_same_day():
batch = Path("data/tools/daily_run.bat").read_text(encoding="utf-8")
retry_lines = [line for line in batch.splitlines() if "retry_failed.py" in line and "call" in line]
assert len(retry_lines) == 1
assert "--platform bili,pgy" in retry_lines[0]
@pytest.mark.parametrize(
"batch_path",
[
Path("data/tools/daily_run.bat"),
Path("data/tools/daily_run_with_backfill.bat"),
],
)
def test_scheduled_collection_syncs_pg_before_and_after_retry(batch_path):
batch = batch_path.read_text(encoding="utf-8")
calls = [
line.strip().lower()
for line in batch.splitlines()
if line.strip().lower().startswith("call %python%")
]
run_index = next(i for i, line in enumerate(calls) if "run_all.py" in line)
retry_index = next(i for i, line in enumerate(calls) if "retry_failed.py" in line)
sync_indices = [
i for i, line in enumerate(calls)
if "sync_metrics_to_cmt_notes.py" in line
]
assert len(sync_indices) == 2
assert run_index < sync_indices[0] < retry_index < sync_indices[1]
@@ -0,0 +1,240 @@
from pathlib import Path
import pytest
from gyxx_flow.modules.content_marketing.runtime.data.tools import relogin_douyin, relogin_pgy, relogin_xingtu
import xingtu_scraper_v2
import pgy_xhs_scraper_v2
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
def test_failed_relogin_restores_cookie_and_profile(tmp_path, monkeypatch, module):
cookie = tmp_path / "cookies.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
profile.mkdir()
(profile / "state.txt").write_text("old-profile", encoding="utf-8")
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
monkeypatch.setattr(module, "PROFILE_DIR", profile)
cookie_backup, profile_backup = module.reset_state()
assert not cookie.exists()
assert not profile.exists()
cookie.write_text("partial-new-cookie", encoding="utf-8")
profile.mkdir()
(profile / "partial.txt").write_text("partial", encoding="utf-8")
module.restore_previous_state(cookie_backup, profile_backup)
assert cookie.read_text(encoding="utf-8") == "old-cookie"
assert (profile / "state.txt").read_text(encoding="utf-8") == "old-profile"
assert not (profile / "partial.txt").exists()
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
def test_successful_relogin_keeps_new_cookie(tmp_path, monkeypatch, module):
cookie = tmp_path / "cookies.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
profile.mkdir()
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
monkeypatch.setattr(module, "PROFILE_DIR", profile)
cookie_backup, profile_backup = module.reset_state()
cookie.write_text("new-cookie", encoding="utf-8")
profile.mkdir()
module.finish_successful_relogin(profile_backup)
assert cookie.read_text(encoding="utf-8") == "new-cookie"
assert cookie_backup.exists()
assert not profile_backup.exists()
def test_xingtu_scan_login_opens_customer_sso_and_clicks_douyin():
class FakeLocator:
def __init__(self):
self.first = self
self.clicked = False
self.evaluated = False
def count(self):
return 1
def is_visible(self):
return True
def click(self, timeout):
self.clicked = True
def evaluate(self, script):
assert script == "e => e.click()"
self.evaluated = True
class FakePage:
url = "https://www.xingtu.cn/?redirect_uri=/ad/creator/market"
def __init__(self):
self.goto_url = None
self.selectors = []
self.douyin = FakeLocator()
def goto(self, url, **kwargs):
self.goto_url = url
self.url = url
def wait_for_timeout(self, _milliseconds):
pass
def locator(self, selector):
self.selectors.append(selector)
return self.douyin
def get_by_text(self, _pattern):
return FakeLocator()
page = FakePage()
xingtu_scraper_v2.open_scan_login(page)
assert page.goto_url == xingtu_scraper_v2.CUSTOMER_LOGIN_URL
assert 'img[src*="aweme.png"]' in page.selectors
assert page.douyin.clicked
def test_xingtu_index_page_is_recognized_as_logged_in():
class Locator:
@property
def first(self):
return self
def wait_for(self, timeout):
assert timeout == 1200
class Page:
url = "https://www.xingtu.cn/ad/creator/index"
def get_by_text(self, _text, exact=False):
return Locator()
assert xingtu_scraper_v2.is_logged_in(Page()) is True
def test_xingtu_cookie_validation_requires_real_session_cookie(tmp_path):
cookie = tmp_path / "xingtu.json"
cookie.write_text('[{"name":"other","value":"1"}]', encoding="utf-8")
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is False
cookie.write_text('[{"name":"sessionid","value":"fresh"}]', encoding="utf-8")
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is True
def test_xingtu_login_only_does_not_report_success_when_page_action_was_swallowed(
tmp_path, monkeypatch
):
cookie = tmp_path / "xingtu.json"
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
class Session:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def fetch(self, *_args, **_kwargs):
# Scrapling can log a page_action error and still return a response.
return object()
monkeypatch.setattr(xingtu_scraper_v2, "COOKIE_FILE", cookie)
monkeypatch.setattr(xingtu_scraper_v2, "DynamicSession", lambda **_kwargs: Session())
monkeypatch.setattr(xingtu_scraper_v2, "_force_cleanup_session", lambda _session: None)
assert xingtu_scraper_v2.login_only(1) == 1
def test_xingtu_interrupted_transaction_is_recovered(tmp_path, monkeypatch):
cookie = tmp_path / "xingtu_cookies.json"
profile = tmp_path / "profile"
cookie.write_text("old-cookie", encoding="utf-8")
profile.mkdir()
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
monkeypatch.setattr(relogin_xingtu, "COOKIE_FILE", cookie)
monkeypatch.setattr(relogin_xingtu, "PROFILE_DIR", profile)
cookie_backup, profile_backup = relogin_xingtu.reset_state()
profile.mkdir()
(profile / "partial.txt").write_text("partial", encoding="utf-8")
assert relogin_xingtu.recover_interrupted_state() is True
assert cookie.read_text(encoding="utf-8") == "old-cookie"
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
assert not (profile / "partial.txt").exists()
assert cookie_backup is not None
assert profile_backup is not None
def test_pgy_scan_login_clicks_qr_switch_image():
class FakeLocator:
def __init__(self):
self.first = self
self.clicked = False
self.evaluated = False
def count(self):
return 1
def is_visible(self):
return True
def click(self, timeout):
self.clicked = True
def evaluate(self, script):
assert script == "e => e.click()"
self.evaluated = True
class FakePage:
def __init__(self):
self.selectors = []
self.qr_switch = FakeLocator()
def wait_for_timeout(self, _milliseconds):
pass
def get_by_text(self, _pattern):
return FakeLocator()
def locator(self, selector):
self.selectors.append(selector)
return self.qr_switch
page = FakePage()
pgy_xhs_scraper_v2.open_scan_login(page)
assert 'img[src*="qr_code"]' in page.selectors
assert page.qr_switch.evaluated
def test_failed_douyin_relogin_restores_cookie_storage_and_profile(tmp_path, monkeypatch):
cookie = tmp_path / "douyin_cookies.json"
storage = tmp_path / "douyin_storage_state.json"
profile = tmp_path / "profile"
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
storage.write_text("old-storage", encoding="utf-8")
profile.mkdir()
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
monkeypatch.setattr(relogin_douyin, "COOKIE_FILE", cookie)
monkeypatch.setattr(relogin_douyin, "STATE_FILE", storage)
monkeypatch.setattr(relogin_douyin, "PROFILE_DIR", profile)
monkeypatch.setattr(relogin_douyin.subprocess, "call", lambda *_args, **_kwargs: 1)
monkeypatch.setattr(relogin_douyin.sys, "argv", ["relogin_douyin.py"])
assert relogin_douyin.main() == 1
assert cookie.read_text(encoding="utf-8") == '[{"name":"sessionid","value":"old"}]'
assert storage.read_text(encoding="utf-8") == "old-storage"
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
@@ -0,0 +1,72 @@
import io
import os
import unittest
from pathlib import Path
from unittest.mock import patch
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
from gyxx_flow.modules.content_marketing.runtime.data.tools import sync_metrics_to_cmt_notes as sync_metrics
ROOT = Path(__file__).resolve().parents[1]
class RemoteDatabaseConfigTests(unittest.TestCase):
def test_config_requires_every_connection_value_without_local_fallback(self):
complete = {
"PG_HOST": "db.example.com",
"PG_PORT": "5432",
"PG_DB": "analytics",
"PG_USER": "reporter",
"PG_PASSWORD": "secret",
}
self.assertEqual(
db.database_config_from_env(complete),
{
"host": "db.example.com",
"port": 5432,
"dbname": "analytics",
"user": "reporter",
"password": "secret",
},
)
for missing in complete:
values = {key: value for key, value in complete.items() if key != missing}
with self.subTest(missing=missing), self.assertRaisesRegex(RuntimeError, missing):
db.database_config_from_env(values)
def test_runtime_config_points_to_remote_data_hub(self):
config = db.get_db_config()
self.assertEqual(config["host"], "8.148.185.119")
self.assertEqual(config["dbname"], "data_hub")
self.assertEqual(config["user"], "data_hub")
self.assertNotIn(config["host"], {"localhost", "127.0.0.1"})
def test_database_scripts_do_not_define_local_connection_defaults(self):
paths = (
ROOT / "data" / "tools" / "db.py",
ROOT / "data" / "tools" / "sync_metrics_to_cmt_notes.py",
ROOT / "data" / "tools" / "sync_cooperations.py",
ROOT / "data" / "tools" / "generate_creator_report.py",
)
for path in paths:
source = path.read_text(encoding="utf-8-sig")
with self.subTest(path=path.name):
self.assertNotIn('"localhost"', source)
self.assertNotIn("'localhost'", source)
self.assertNotIn('"gyxx_super_data"', source)
self.assertNotIn("'gyxx_super_data'", source)
def test_sync_console_replaces_characters_unsupported_by_gbk(self):
raw = io.BytesIO()
stream = io.TextIOWrapper(raw, encoding="gbk", errors="strict")
sync_metrics.configure_console_output(stream, None)
stream.write("𝑻")
stream.flush()
self.assertTrue(raw.getvalue())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,385 @@
import json
import os
import time
from pathlib import Path
from types import SimpleNamespace
import gyxx_flow.modules.content_marketing.runtime.data.tools.retry_failed as retry
import run_all
def test_requested_records_must_be_successful_even_when_process_exits_zero():
data = {
"results": [
{"record_id": "a", "status": "success", "matched": True},
{"record_id": "b", "status": "retryable_failure", "matched": False},
]
}
ok, failed = retry.requested_records_succeeded(data, ["a", "b"])
assert ok is False
assert failed == ["b"]
def test_success_status_cannot_hide_write_failure_and_blocked_input_is_terminal():
contradictory = {
"results": [{
"record_id": "a", "status": "success", "matched": True,
"write_ok": False,
}]
}
assert retry.requested_records_succeeded(contradictory, ["a"]) == (False, ["a"])
blocked = {
"total": 1,
"details": [{
"record_id": "b", "status": "blocked_input", "ok": False,
"reason": "url_missing",
}],
}
assert retry.extract_failed_records(blocked, "bili") == []
def test_canonical_filename_requires_current_style_name():
style = {"index": 2, "name": "极星pro"}
assert retry.canonical_result_filename(style, "pgy", False) == "02-极星pro_v2.json"
assert retry.canonical_result_filename(style, "xt", False) == "02-极星pro_xingtu_v2.json"
assert retry.canonical_result_filename(style, "bili", True) == "02-极星pro_self_bilibili_v2.json"
def _write_json(path: Path, payload: dict | list) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def test_find_failed_uses_exact_current_style_file_and_ignores_legacy_name(tmp_path):
style = {"index": 2, "name": "极星pro"}
canonical = tmp_path / "02-极星pro_v2.json"
legacy = tmp_path / "02-盖亚微单_v2.json"
payload = {
"index": 2,
"total": 1,
"matched": 1,
"filled": 1,
"results": [{"record_id": "ok", "status": "success", "matched": True}],
}
_write_json(canonical, payload)
_write_json(legacy, {"index": 2, "total": 1, "matched": 0, "results": []})
old = time.time() - 48 * 3600
os.utime(legacy, (old, old))
failed = retry.find_failed(
"pgy", stale_hours=4, include_half=True,
styles=[style], v2_dir=tmp_path,
)
assert failed == {}
def test_find_failed_treats_missing_result_rows_as_incomplete(tmp_path):
style = {"index": 1, "name": "款式A"}
path = tmp_path / retry.canonical_result_filename(style, "xt", False)
_write_json(path, {"index": 1, "total": 2, "matched": 0, "filled": 0, "results": []})
failed = retry.find_failed(
"xt", stale_hours=4, include_half=True,
styles=[style], v2_dir=tmp_path,
)
assert failed[1]["reason"] == "ALL_FAILED"
assert failed[1]["failed_rids"] == []
def test_run_one_style_rechecks_payload_and_retries_exit_zero(monkeypatch, tmp_path):
style = {"index": 1, "name": "款式A"}
result_path = tmp_path / retry.canonical_result_filename(style, "pgy", False)
calls = []
def fake_call(_cmd, cwd):
calls.append(cwd)
_write_json(result_path, {
"index": 1,
"total": 1,
"matched": 0,
"filled": 0,
"results": [{
"record_id": "bad",
"status": "retryable_failure",
"matched": False,
}],
})
return 0
monkeypatch.setattr(retry.subprocess, "call", fake_call)
monkeypatch.setattr(retry.time, "sleep", lambda _seconds: None)
ok = retry.run_one_style(
"pgy", 1, max_attempts=2, record_ids=["bad"],
style=style, v2_dir=tmp_path,
)
assert ok is False
assert len(calls) == 2
def test_run_one_style_accepts_exit_zero_only_after_requested_record_succeeds(monkeypatch, tmp_path):
style = {"index": 1, "name": "款式A"}
result_path = tmp_path / retry.canonical_result_filename(style, "pgy", False)
def fake_call(_cmd, cwd):
_write_json(result_path, {
"index": 1,
"total": 1,
"matched": 1,
"filled": 1,
"results": [{
"record_id": "ok",
"status": "success",
"matched": True,
"write_ok": True,
}],
})
return 0
monkeypatch.setattr(retry.subprocess, "call", fake_call)
assert retry.run_one_style(
"pgy", 1, max_attempts=1, record_ids=["ok"],
style=style, v2_dir=tmp_path,
) is True
def test_self_operated_xingtu_retry_uses_self_douyin_scraper(monkeypatch, tmp_path):
style = {"index": 1, "name": "款式A"}
result_path = tmp_path / retry.canonical_result_filename(style, "xt", True)
observed = {}
def fake_call(cmd, cwd):
observed["cmd"] = cmd
_write_json(result_path, {
"index": 1,
"total": 1,
"results": [{"record_id": "ok", "status": "success", "matched": True}],
})
return 0
monkeypatch.setattr(retry.subprocess, "call", fake_call)
assert retry.run_one_style(
"xt", 1, max_attempts=1, self_operated=True,
record_ids=["ok"], style=style, v2_dir=tmp_path,
) is True
assert observed["cmd"][1].endswith("self_douyin_scraper.py")
assert "--self-operated" not in observed["cmd"]
def test_full_summary_declared_failure_cannot_be_treated_as_complete():
complete, reason = retry._summary_completed({
"total": 1,
"results": [{
"record_id": "ok",
"status": "success",
"matched": True,
"write_ok": True,
}],
"write_failures": 1,
})
assert complete is False
assert reason == "summary declares unresolved failures"
def test_retry_main_returns_nonzero_when_verified_retry_still_fails(monkeypatch):
style = {"index": 1, "name": "款式A"}
monkeypatch.setattr(retry, "load_current_styles", lambda self_operated=False: [style])
monkeypatch.setattr(
retry, "find_failed",
lambda *_args, **_kwargs: {1: {"reason": "HALF", "failed_rids": ["bad"]}},
)
monkeypatch.setattr(retry, "run_one_style", lambda *_args, **_kwargs: False)
monkeypatch.setattr(retry.sys, "argv", ["retry_failed.py", "--platform", "pgy"])
assert retry.main() == 1
def test_validate_summary_payload_rejects_stale_missing_duplicate_and_unresolved(tmp_path):
path = tmp_path / "summary.json"
valid = [{
"index": 1,
"run_started_at": time.time() + 10,
"total": 2,
"matched": 2,
"filled": 2,
"results": [
{"record_id": "a", "status": "success", "matched": True},
{"record_id": "b", "status": "success", "matched": True},
],
}]
_write_json(path, valid)
assert run_all.validate_summary_payload(
valid, {1}, path, path.stat().st_mtime - 1,
)[0] is True
assert run_all.validate_summary_payload(
valid, {1}, path, path.stat().st_mtime + 1,
)[0] is False
assert run_all.validate_summary_payload(valid, {1, 2}, path, 0)[0] is False
duplicate = [{
"index": 1, "total": 2,
"results": [
{"record_id": "a", "status": "success"},
{"record_id": "a", "status": "success"},
],
}]
assert run_all.validate_summary_payload(duplicate, {1}, path, 0)[0] is False
unresolved = [{
"index": 1, "total": 1,
"results": [{
"record_id": "a", "status": "retryable_failure", "matched": False,
}],
}]
assert run_all.validate_summary_payload(unresolved, {1}, path, 0)[0] is False
def test_validate_selected_style_allows_unrelated_rows_in_merged_global_summary(tmp_path):
path = tmp_path / "summary.json"
payload = [
{
"index": 1,
"run_started_at": time.time() + 10,
"total": 1,
"results": [{"record_id": "ok", "status": "success"}],
},
{
"index": 2,
"total": 1,
"results": [{
"record_id": "old-failure",
"status": "retryable_failure",
}],
},
]
_write_json(path, payload)
valid, reason = run_all.validate_summary_payload(
payload, {1}, path, path.stat().st_mtime - 1,
)
assert valid is True, reason
def test_validate_summary_rejects_expected_style_left_over_from_old_run(tmp_path):
path = tmp_path / "summary.json"
started_at = 100.0
payload = [
{
"index": 1,
"run_started_at": 101.0,
"total": 1,
"results": [{"record_id": "new", "status": "success"}],
},
{
"index": 2,
"run_started_at": 90.0,
"total": 1,
"results": [{"record_id": "old", "status": "success"}],
},
]
_write_json(path, payload)
valid, reason = run_all.validate_summary_payload(
payload, {1, 2}, path, started_at,
)
assert valid is False
assert "style 2" in reason
assert "current run" in reason
def test_aggregate_counts_all_unresolved_rows_as_errors():
aggregate = run_all.aggregate({
"pgy": [{
"index": 1,
"total": 3,
"matched": 1,
"filled": 1,
"results": [
{"record_id": "ok", "status": "success"},
{"record_id": "miss", "matched": False},
{"record_id": "detail", "reason": "no_detail_page"},
],
}],
})
assert aggregate["platforms"]["pgy"]["unresolved"] == 2
assert aggregate["platforms"]["pgy"]["errors"] == 2
def test_aggregate_sums_declared_failure_categories_when_unresolved_is_absent():
aggregate = run_all.aggregate({
"xt": [{
"index": 1,
"total": 2,
"matched": 2,
"results": [
{"record_id": "a", "status": "success"},
{"record_id": "b", "status": "success"},
],
"retryable_failures": 1,
"write_failures": 1,
}],
})
assert aggregate["platforms"]["xt"]["unresolved"] == 2
def test_aggregate_keeps_partial_counts_when_validation_marks_payload_invalid():
aggregate = run_all.aggregate({
"pgy": {
"error": "invalid summary: unresolved",
"payload": [{
"index": 1,
"total": 1,
"matched": 0,
"results": [{
"record_id": "bad", "status": "retryable_failure",
}],
}],
},
})
platform = aggregate["platforms"]["pgy"]
assert platform["loaded"] is True
assert platform["unresolved"] == 1
assert platform["validation_error"] == "invalid summary: unresolved"
def test_run_round_marks_exit_zero_platform_failed_when_summary_is_invalid(monkeypatch, tmp_path):
args = SimpleNamespace(dry_run=True, style=[1])
stdout = tmp_path / "stdout.log"
stderr = tmp_path / "stderr.log"
observed = {}
monkeypatch.setattr(
run_all, "start_process",
lambda *_args, **_kwargs: (object(), stdout, stderr),
)
monkeypatch.setattr(run_all, "wait_all", lambda _procs: {"pgy": 0})
def fake_load(key, self_operated=False, **kwargs):
observed.update(kwargs)
return {"error": "invalid summary: stale"}
monkeypatch.setattr(run_all, "load_summary", fake_load)
monkeypatch.setattr(run_all, "print_report", lambda *_args: None)
monkeypatch.setattr(run_all, "_atomic_write_json", lambda *_args: None)
result = run_all.run_round(args, ["pgy"])
assert result["exit_codes"] == {"pgy": 0}
assert result["_non_zero_platforms"] == ["pgy"]
assert observed["expected_indices"] == {1}
assert isinstance(observed["started_at"], float)
@@ -0,0 +1,55 @@
import unittest
from gyxx_flow.modules.content_marketing.runtime.data.tools.sync_style_categories import extract_style_mapping, normalize_style_name
class SyncStyleCategoriesTests(unittest.TestCase):
def test_normalizes_feishu_lifecycle_names_to_database_names(self):
cases = {
"盖亚微单Pro生命进程(新)": "盖亚微单",
"星迹&星迹2生命进程(新)": "星迹2",
"宙斯双肩包生命进程(新)": "宙斯",
"极星Pro生命进程(新)": "极星pro",
"瑞白双肩包生命进程(新)": "瑞白",
"逐星gt生命进程(新)": "逐星GT",
"拾影相机包生命进程 (新)": "拾影斜挎相机包",
"极星托特生命进程(新)": "极星托特",
}
for source, expected in cases.items():
with self.subTest(source=source):
self.assertEqual(normalize_style_name(source), expected)
def test_extracts_mapping_and_ignores_blank_rows(self):
payload = {
"data": {
"data": [
[
"[盖亚斜挎生命进程(新)](https://example.feishu.cn/base/TokenA)",
["都市机能"],
],
[None, ["都市运动"]],
["[极星托特生命进程(新)](https://example.feishu.cn/base/TokenB)", ["智性通勤"]],
]
}
}
self.assertEqual(
extract_style_mapping(payload),
{"盖亚斜挎": "都市机能", "极星托特": "智性通勤"},
)
def test_rejects_conflicting_categories_for_same_style(self):
payload = {
"data": {
"data": [
["[布谷生命进程(新)](https://example/a)", ["都市运动"]],
["[布谷生命进程(新)](https://example/b)", ["都市机能"]],
]
}
}
with self.assertRaisesRegex(ValueError, "布谷"):
extract_style_mapping(payload)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
import unittest
from unittest.mock import patch
from gyxx_flow.modules.content_marketing.runtime import weekly_summary_all as weekly
class WeeklyTmallPersonaTests(unittest.TestCase):
def test_missing_persona_forbids_invented_percentages(self):
text = weekly.format_tmall_persona(None)
self.assertIn("不得虚构具体比例", text)
def test_format_tmall_persona_includes_date_and_dimensions(self):
persona = {
"collect_date": "2026-07-15",
"item_id": "900176117674",
"payload": {
"gender": [
{"name": "男性用户", "value": 66.97},
{"name": "女性用户", "value": 28.26},
],
"age": [{"name": "25-29岁", "value": 26.97}],
"city_tier": [{"name": "准一线城市", "value": 31.14}],
"buying_power": [{"name": "购买力L5", "value": 30.65}],
},
}
text = weekly.format_tmall_persona(persona)
self.assertIn("2026-07-15", text)
self.assertIn("男性用户 66.97%", text)
self.assertIn("25-29岁 26.97%", text)
self.assertIn("准一线城市 31.14%", text)
self.assertIn("购买力L5 30.65%", text)
def test_generate_summary_injects_latest_tmall_persona_into_single_note_prompt(self):
persona = {
"collect_date": "2026-07-15",
"item_id": "900176117674",
"payload": {
"gender": [{"name": "男性用户", "value": 66.97}],
"age": [{"name": "25-29岁", "value": 26.97}],
"city_tier": [],
"buying_power": [],
},
}
notes = [{
"title": "测试笔记",
"platform": "douyin",
"creator_name": "达人甲",
"view_count": 100,
"like_count": 10,
"comment_count": 2,
"favorite_count": 3,
"share_count": 1,
"report_path": None,
}]
with patch.object(weekly, "get_latest_tmall_persona", return_value=persona) as get_persona, \
patch.object(weekly, "call_hermes_analyzer", side_effect=["单篇分析", "## 三、周度总结"]) as call:
weekly.generate_summary("星云2", notes)
get_persona.assert_called_once_with("星云2")
single_prompt = call.call_args_list[0].args[1]
self.assertIn("天猫该款最新人物画像", single_prompt)
self.assertIn("男性用户 66.97%", single_prompt)
self.assertIn("内容触达人群", single_prompt)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,766 @@
"""通用周笔记汇总脚本:对所有上周有笔记的款,生成周汇总飞书文档并写入对应生命进程表。
流程:
1. 读索引表(TtoCb1NuQaDy3NsZWTpc0GIvnph / tblKCjplVAFrRwMC)拿"每周笔记分析"字段(每款生命进程表地址)
2. PG 查上周(2026-06-29~07-05)新增笔记,按款分组
3. 对每个款:
a. 跑单品分析(调用 analyze_note.py --note-id N --output ...)
b. LLM 综合评级(用 weekly_summary 的 prompt)
c. 创建飞书文档(markdown)
d. 写入该款生命进程表的"笔记分析汇总"字段(时间=上周范围)
用法:
python weekly_summary_all.py # 跑所有上周有笔记的款
python weekly_summary_all.py --style 宙斯 # 只跑某个款
python weekly_summary_all.py --dry-run # 不写飞书,只生成报告
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
sys.stdout.reconfigure(encoding='utf-8')
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import call_hermes_analyzer
LARK = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK):
LARK = "lark-cli.cmd"
# 索引表
INDEX_BT = "TtoCb1NuQaDy3NsZWTpc0GIvnph"
INDEX_TID = "tblKCjplVAFrRwMC"
INDEX_STYLE_FIELD = "fldr8T4ynL"
INDEX_WEEKLY_FIELD = "fldgfS6I4a"
# 周时间范围:动态算上周一到上周日
# 支持 --week-since/--week-until 参数覆盖(调试用,格式 YYYY-MM-DD)
def _compute_last_week():
"""返回 (week_start, week_end, time_label) 都是字符串"""
today = datetime.now().date()
# 上周一 = 今天 - (今天weekday + 7) 天;周一 weekday=0
last_monday = today - timedelta(days=today.weekday() + 7)
last_sunday = last_monday + timedelta(days=6)
return last_monday, last_sunday
_DEFAULT_START, _DEFAULT_END = _compute_last_week()
WEEK_START = os.environ.get("WEEK_SINCE", _DEFAULT_START.strftime("%Y-%m-%d"))
WEEK_END = os.environ.get("WEEK_UNTIL", _DEFAULT_END.strftime("%Y-%m-%d"))
TIME_LABEL = f"{WEEK_START}~{WEEK_END[5:]}"
DATA_DIR = PATHS.raw_root
REPORTS_DIR = PATHS.exports_root / "summary"
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
# dry_run 全局开关(在 main 设置,save_to_db 读取)
dry_run_global = False
URL_RE = re.compile(r"https?://[^\s)>\]]+")
def log(msg: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
def call_lark_json(args):
cmd = [LARK] + args
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout if proc.stdout else (proc.stderr or "")
start = text.find("{")
if start < 0:
return {"ok": False, "error": f"no json: {text[:300]}"}
try:
return json.loads(text[start:], strict=False)
except json.JSONDecodeError as e:
return {"ok": False, "error": f"json decode: {e}"}
def parse_url(raw):
if not raw:
return None
if isinstance(raw, list):
raw = " ".join(str(x) for x in raw if x)
s = str(raw).strip()
m = URL_RE.search(s)
return m.group(0) if m else None
def parse_bt_tid(url):
if not url:
return None, None
bt = re.search(r'/base/([A-Za-z0-9]+)', url)
tid = re.search(r'[?&]table=([A-Za-z0-9]+)', url)
return (bt.group(1) if bt else None,
tid.group(1) if tid else None)
def load_index_styles():
"""读索引表,返回 {款式名: (base_token, table_id, url)}"""
cmd = ["base", "+record-list",
"--base-token", INDEX_BT, "--table-id", INDEX_TID,
"--as", "user", "--format", "json", "--limit", "200",
"--field-id", INDEX_STYLE_FIELD, "--field-id", INDEX_WEEKLY_FIELD]
resp = call_lark_json(cmd)
if not resp.get("ok"):
log(f"读索引表失败: {resp.get('error')}")
return {}
styles = {}
for row in resp["data"]["data"]:
name = row[0] if row else ""
weekly = row[1] if len(row) > 1 else ""
if isinstance(name, list):
name = " ".join(str(x) for x in name if x)
name = str(name).strip()
if not name or not weekly:
continue
url = parse_url(weekly)
bt, tid = parse_bt_tid(url)
if bt and tid:
styles[name] = (bt, tid, url)
log(f"索引表读到 {len(styles)} 个款有'每周笔记分析'字段")
return styles
def find_target_fields(bt, tid):
"""动态读生命进程表的字段,返回 (时间字段_id, 笔记分析汇总字段_id)"""
cmd = ["base", "+field-list",
"--base-token", bt, "--table-id", tid, "--format", "json"]
resp = call_lark_json(cmd)
if not resp.get("ok"):
return None, None
time_fid = summary_fid = None
for f in resp["data"]["fields"]:
if f.get("name") == "时间":
time_fid = f.get("id")
elif f.get("name") == "笔记分析汇总":
summary_fid = f.get("id")
return time_fid, summary_fid
def get_last_week_notes(style_name):
"""从 PG 拿上周该款的新增笔记(按 publish_time 过滤),返回 [{id, platform, title, ...}]"""
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
SELECT n.id, n.platform, n.title, n.url, n.view_count,
n.comment_count, n.like_count, n.favorite_count, n.share_count,
c.name AS creator_name
FROM cmt_notes n
JOIN cmt_styles s ON n.style_id = s.id
LEFT JOIN cmt_creators c ON n.creator_id = c.id
WHERE s.name = %s AND n.self_operated = FALSE
AND n.publish_time IS NOT NULL
AND n.publish_time >= %s AND n.publish_time < %s
ORDER BY n.id
""", (style_name, WEEK_START, WEEK_END + " 23:59:59"))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, r)) for r in cur.fetchall()]
def get_latest_tmall_persona(style_name):
"""读取不晚于本周结束日的最新天猫画像,失败或无数据时返回 None。"""
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
SELECT collect_date, item_id, payload
FROM daily_persona_metrics
WHERE platform = 'tm' AND style_name = %s AND collect_date <= %s
ORDER BY collect_date DESC, collected_at DESC
LIMIT 1
""", (style_name, WEEK_END))
row = cur.fetchone()
if not row:
return None
return {
"collect_date": row[0],
"item_id": row[1],
"payload": row[2] or {},
}
except Exception as exc:
log(f" [WARN] 读取 {style_name} 天猫人物画像失败: {exc}")
return None
def _format_persona_dimension(rows):
"""把画像维度格式化为适合提示词引用的百分比分布。"""
parts = []
for row in rows or []:
if not isinstance(row, dict) or not row.get("name"):
continue
value = row.get("value")
try:
value_text = f"{float(value):.2f}".rstrip("0").rstrip(".")
except (TypeError, ValueError):
value_text = str(value or "未知")
parts.append(f"{row['name']} {value_text}%")
return ";".join(parts) if parts else "暂无"
def format_tmall_persona(persona):
"""将天猫画像记录转成结构化提示词文本。"""
if not persona:
return (
"未找到该款式的天猫人物画像。受众部分只能结合内容和评论做谨慎的定性判断,"
"不得虚构具体比例。"
)
payload = persona.get("payload") or {}
return "\n".join([
f"- 数据日期:{persona.get('collect_date') or '未知'}",
f"- 天猫商品ID:{persona.get('item_id') or '未知'}",
f"- 性别:{_format_persona_dimension(payload.get('gender'))}",
f"- 年龄:{_format_persona_dimension(payload.get('age'))}",
f"- 城市等级:{_format_persona_dimension(payload.get('city_tier'))}",
f"- 消费能力:{_format_persona_dimension(payload.get('buying_power'))}",
])
def run_single_note_analysis(note_id):
"""跑单品分析,输出到 data/summary/note_<id>.txt。已存在则跳过。"""
out_path = REPORTS_DIR / f"note_{note_id}.txt"
if out_path.exists() and out_path.stat().st_size > 100:
log(f" 复用已有报告: {out_path.name}")
return out_path
log(f" 跑单品分析 note_id={note_id} ...")
cmd = [sys.executable, "-X", "utf8",
str(Path(__file__).parent / "data" / "tools" / "analyze_note.py"),
"--note-id", str(note_id), "--no-send", "--brand", "光影行星",
"--output", str(out_path)]
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace", timeout=600)
if proc.returncode != 0:
log(f" [WARN] note_id={note_id} 分析失败: {proc.stderr[:200]}")
return None
if out_path.exists() and out_path.stat().st_size > 100:
return out_path
log(f" [WARN] note_id={note_id} 未生成报告")
return None
def read_report(path):
if not path or not path.exists():
return ""
for enc in ("utf-8-sig", "utf-8"):
try:
return path.read_text(encoding=enc)
except UnicodeDecodeError:
continue
return path.read_text(encoding="utf-8", errors="replace")
# LLM 综合 prompt - 单篇笔记四维度分析(逐篇调用,保证深度不打折)
SINGLE_NOTE_PROMPT = """你是光影行星品牌「{style}」款式的内容种草分析师,聚焦前端表现。
请对以下笔记从"内容质量 -> 受众匹配 -> 互动效果 -> 评论区信号"四个维度进行深度分析。
【笔记信息】
{note_info}
【单品分析报告全文】
{report}
【天猫该款最新人物画像(受众推算基准)】
{tmall_persona}
说明:天猫画像代表该款实际购买/浏览人群,笔记标题、达人和评论反映内容触达人群。
请比较两类人群是否吻合,不要把天猫购买人群直接等同于内容触达人群。
【输出要求】
严格按以下结构输出(Markdown 格式),每项都必须填充具体数据和判断:
### 笔记{idx}:{title}
#### 基础信息
- 平台/达人:{platform} / {creator}
- 曝光:{views} | 互动总量:{engagement} | 互动率:{rate}%
#### 1. 标题与内容拆解
(详细分析,不只列结论)
- 标题钩子类型(痛点/价值观/利益点/悬念):判断类型 + 解释为什么 + 评估有效性
- 内容结构:前3秒抓人能力 / 中间痛点铺陈 / 产品品牌植入方式 / 结尾引导,逐项给具体判断
- 品牌/SKU记忆度:用户看完能否复述品牌或产品名,结合评论区信号佐证
#### 2. 数据表现评级
(算多个比值,不只一个)
- 互动率对比平台均值(抖音3-5% / 小红书3-8% / B站3-5%),判断优劣
- 互动结构:算赞藏比/评赞比/分享点赞比/收藏评论比,每个比值都解读含义,识别认知留存/争议共鸣/实用收藏型
#### 3. 受众画像推算
- 先引用天猫画像的真实性别、年龄、城市等级和消费力分布,并注明数据日期
- 再结合标题词、达人人设和评论区用词,判断本篇内容触达人群与天猫购买人群是吻合还是偏离
- 核心受众标签:年龄/消费场景/兴趣圈层/消费力,每项给判断和依据
- 天猫画像没有覆盖的维度才允许推断,并明确标注为推断
#### 4. 评论区关键信号
(每项都给具体词/数字,不要泛泛)
- 高频实义词TOP5(排除语气词):列出 5 个具体词
- 购买意向信号("求链接""已下单"等)占比估算:给百分比 + 条数
- 品牌提及率(主动提到品牌/产品名的比例):给百分比
- 用户顾虑点TOP3(尺寸/价格/质量等):每条顾虑解释为什么
- 有无竞品提及:有则列出,无则说明
#### 5. 总结评级与优化建议
- 最终评级(S/A/B/C):给评级 + 判定依据(哪几条指标支撑这个评级)
- 是否值得继续投放或放大:给结论 + 理由
- 针对内容本身的修改建议(非电商承接):至少 4-5 条具体可执行建议,涵盖标题/前3秒/卖点顺序/品牌植入/结尾引导
【评级标准】
- S:互动率显著高于平台均值,品牌记忆度高,购买意向信号强,受众精准
- A:互动率达平台均值以上,品牌有记忆,有购买意向信号
- B:互动率接近均值,品牌记忆模糊,购买意向弱
- C:互动率低于均值,品牌无记忆,无购买信号,受众错位
【约束】
- 本篇分析必须详尽(800-1200 字),数据驱动,直接给结论
- 有天猫画像时,男女比例必须原样引用画像数据;无画像时不得虚构具体比例
- 高频词TOP5必须给具体词,不要写"无有效数据"
- 评级严格按标准,不放宽
- **只输出本篇笔记的分析,不要输出周度总结,不要提及"文件已保存"等元信息。响应正文就是本篇分析本身。**"""
# LLM 综合 prompt - 周度总结(输入所有单篇分析摘要 + 数据)
WEEKLY_SUMMARY_PROMPT = """你是光影行星品牌「{style}」款式的内容种草分析师。
请基于以下 {n} 篇笔记的单篇分析结果,输出周度总结。
【本周单篇分析汇总】
{all_analyses}
【本周投放概览数据】
- 投放笔记数:{n} 篇
- 涉及平台:{platforms}
- 总曝光:{total_views} / 总互动:{total_engagement}
【输出要求】
严格按以下结构输出(Markdown 格式):
## 三、周度总结与建议
### 1. 整体评级分布
- 各评级(S/A/B/C)笔记数及占比
- 本周整体内容种草效率判断
### 2. 值得继续投放/放大的笔记
[列出 S/A 级笔记,给出可复用经验,每篇 2-3 句]
### 3. 需要规避的问题
[列出 B/C 级笔记的问题,每篇 2-3 句,给规避建议]
### 4. 下周内容优化清单
- 标题钩子方向:[基于本周表现给具体建议]
- 内容结构:[前3秒/中间铺陈/品牌植入/结尾引导]
- 达人选择:[优先/规避类型]
- 评论区运营:[具体话术]
【约束】
- 周度总结 800-1500 字
- 每条建议须具体可执行,避免"加强""优化"等空泛词汇
- **只输出周度总结部分(从"## 三"开始),不要重复输出单篇分析,不要提及"文件已保存"等元信息。响应正文就是周度总结本身。**"""
def generate_summary(style, notes):
"""调 LLM 综合评级,返回汇总文本(两步:逐篇分析 + 周度总结)"""
system = "你是光影行星品牌的内容种草分析师,擅长从内容质量、受众匹配、互动效果、评论区信号四个维度分析笔记表现,输出可落地的优化建议。"
tmall_persona = get_latest_tmall_persona(style)
tmall_persona_text = format_tmall_persona(tmall_persona)
if tmall_persona:
log(f" 天猫人物画像: {tmall_persona.get('collect_date')} / item_id={tmall_persona.get('item_id')}")
else:
log(f" [WARN] {style} 暂无天猫人物画像")
# ---- 第一步:逐篇生成单篇四维度分析 ----
single_analyses = []
for i, n in enumerate(notes, 1):
report = read_report(n.get("report_path"))
views = n.get('view_count') or '无'
likes = n.get('like_count') or 0
cmts = n.get('comment_count') or 0
favs = n.get('favorite_count') or 0
shares = n.get('share_count') or 0
engagement = (likes or 0) + (favs or 0) + (cmts or 0) + (shares or 0)
if isinstance(views, (int, float)) and views > 0:
rate = round(engagement * 100 / views, 2)
else:
rate = "无法计算(曝光未采集)"
note_info = (
f"标题: {n['title']}\n"
f"平台: {n['platform']} / 达人: {n.get('creator_name', '?')}\n"
f"曝光: {views} / 点赞: {likes} / 评论: {cmts} / 收藏: {favs} / 分享: {shares}\n"
f"互动总量: {engagement} / 互动率: {rate}%"
)
prompt = SINGLE_NOTE_PROMPT.format(
style=style, idx=i, title=n['title'],
note_info=note_info, report=report, tmall_persona=tmall_persona_text,
platform=n['platform'], creator=n.get('creator_name', '?'),
views=views, engagement=engagement, rate=rate,
)
log(f" 调 LLM 单篇分析 {i}/{len(notes)}: {n['title'][:30]}...")
analysis = call_hermes_analyzer(system, prompt)
if analysis:
single_analyses.append(analysis)
else:
single_analyses.append(
f"### 笔记{i}:{n['title']}\n**基础信息**\n"
f"- 平台/达人:{n['platform']} / {n.get('creator_name', '?')}\n"
f"- 曝光:{views} | 互动总量:{engagement} | 互动率:{rate}%\n\n"
f"*(LLM 分析失败,数据仅供参考)*"
)
# ---- 拼接投放概览 ----
platform_counts = {}
total_views = 0
total_engagement = 0
for n in notes:
p = n['platform']
platform_counts[p] = platform_counts.get(p, 0) + 1
v = n.get('view_count')
if isinstance(v, (int, float)) and v > 0:
total_views += v
likes = n.get('like_count') or 0
cmts = n.get('comment_count') or 0
favs = n.get('favorite_count') or 0
shares = n.get('share_count') or 0
total_engagement += (likes + cmts + favs + shares)
platforms_str = " / ".join(f"{p} {c} 篇" for p, c in sorted(platform_counts.items()))
views_str = f"{total_views:,}" if total_views else "部分未采集"
overview = (
f"# {style} 周笔记汇总({TIME_LABEL})\n\n"
f"## 一、本周投放概览\n\n"
f"- 投放笔记数:{len(notes)} 篇\n"
f"- 涉及平台:{platforms_str}\n"
f"- 总曝光 / 总互动:{views_str} / {total_engagement:,}\n"
f"- 整体表现:[见下方周度总结]\n\n"
f"---\n\n"
f"## 二、单篇笔记四维度分析\n\n"
)
# ---- 第二步:生成周度总结 ----
all_analyses_text = "\n\n---\n\n".join(single_analyses)
summary_prompt = WEEKLY_SUMMARY_PROMPT.format(
style=style, n=len(notes),
all_analyses=all_analyses_text,
platforms=platforms_str,
total_views=views_str,
total_engagement=f"{total_engagement:,}",
)
log(f" 调 LLM 周度总结...")
weekly_summary = call_hermes_analyzer(system, summary_prompt)
if not weekly_summary:
weekly_summary = "## 三、周度总结与建议\n\n*(LLM 周度总结生成失败)*"
return overview + all_analyses_text + "\n\n---\n\n" + weekly_summary
def create_feishu_doc(style, summary):
"""创建飞书文档,返回 url"""
doc_title = f"{style} 周笔记汇总({TIME_LABEL})"
md_file = PATHS.tmp_root / "weekly_summary_doc.md"
md_file.parent.mkdir(parents=True, exist_ok=True)
md_file.write_text(summary, encoding="utf-8")
cmd = [LARK, "docs", "+create",
"--as", "user", "--format", "json",
"--title", doc_title,
"--doc-format", "markdown",
"--content", f"@{md_file.name}"]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout or proc.stderr
start = text.find("{")
if start < 0:
return None, f"创建失败: {text[:300]}"
resp = json.loads(text[start:])
if not resp.get("ok"):
return None, f"创建失败: {resp.get('error')}"
url = resp.get("data", {}).get("document", {}).get("url", "")
return url, None
def save_to_db(style, notes, summary, doc_url, doc_title, status):
"""把周汇总结果写入 PG cmt_weekly_summary(UNIQUE(style_id, week_start),同款同周重跑时 UPDATE)"""
if dry_run_global:
return False
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
try:
with get_conn() as conn:
cur = conn.cursor()
# 查 style_id
cur.execute("SELECT id FROM cmt_styles WHERE name = %s", (style,))
row = cur.fetchone()
if not row:
log(f" [DB] 款式 {style} 不在 cmt_styles,跳过入库")
return False
style_id = row[0]
# 算聚合数据
total_views = sum((n.get('view_count') or 0) for n in notes) or None
total_comments = sum((n.get('comment_count') or 0) for n in notes)
total_engagement = sum(
((n.get('like_count') or 0) + (n.get('favorite_count') or 0) +
(n.get('comment_count') or 0) + (n.get('share_count') or 0))
for n in notes) or None
# UPSERT (style_id, week_start)
cur.execute("""
INSERT INTO cmt_weekly_summary
(style_id, week_start, week_end, time_label,
note_count, total_comments, total_views, total_engagement,
doc_url, doc_title, summary_chars, status,
created_at, updated_at)
VALUES
(%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (style_id, week_start) DO UPDATE SET
note_count = EXCLUDED.note_count,
total_comments = EXCLUDED.total_comments,
total_views = EXCLUDED.total_views,
total_engagement = EXCLUDED.total_engagement,
doc_url = EXCLUDED.doc_url,
doc_title = EXCLUDED.doc_title,
summary_chars = EXCLUDED.summary_chars,
status = EXCLUDED.status,
updated_at = CURRENT_TIMESTAMP
""", (style_id, WEEK_START, WEEK_END, TIME_LABEL,
len(notes), total_comments, total_views, total_engagement,
doc_url, doc_title, len(summary) if summary else None, status))
conn.commit()
log(f" ✅ 已入库 cmt_weekly_summary (style_id={style_id}, week={WEEK_START})")
return True
except Exception as exc:
log(f" [DB ERR] {style} 入库失败: {exc}")
return False
def write_to_target_table(bt, tid, time_fid, summary_fid, style, doc_url, dry_run):
"""把文档链接写入生命进程表(已有同时间记录则更新,否则新建)"""
md_link = f"[{style} 周笔记汇总({TIME_LABEL})]({doc_url})"
payload = {time_fid: TIME_LABEL, summary_fid: md_link}
pf = PATHS.tmp_root / "weekly_payload.json"
pf.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
if dry_run:
log(f" [DRY] 跳过写入 {bt}/{tid}")
return True
# 先查是否已有同时间的记录
existing_rid = None
list_cmd = ["base", "+record-list",
"--base-token", bt, "--table-id", tid,
"--as", "user", "--format", "json", "--page-size", "100"]
list_resp = call_lark_json(list_cmd)
if list_resp.get("ok"):
rid_list = list_resp.get("data", {}).get("record_id_list", [])
data_rows = list_resp.get("data", {}).get("data", [])
# 找字段索引: 用 field_id_list 定位 time_fid 的列号
fid_list = list_resp.get("data", {}).get("field_id_list", [])
time_col = fid_list.index(time_fid) if time_fid in fid_list else 0
for rid, row in zip(rid_list, data_rows):
row_time = row[time_col] if len(row) > time_col else ""
# 时间字段是 select, 返回值可能是 ["2026-06-29~07-05"]
if isinstance(row_time, list):
row_time = row_time[0] if row_time else ""
if str(row_time) == TIME_LABEL:
existing_rid = rid
break
if existing_rid:
cmd = ["base", "+record-upsert",
"--base-token", bt, "--table-id", tid,
"--as", "user", "--format", "json",
"--record-id", existing_rid,
"--json", f"@{pf.name}"]
log(f" 更新已有记录 {existing_rid}")
else:
cmd = ["base", "+record-upsert",
"--base-token", bt, "--table-id", tid,
"--as", "user", "--format", "json",
"--json", f"@{pf.name}"]
log(f" 新建记录")
resp = call_lark_json(cmd)
return resp.get("ok", False)
def process_one_style(style, index_info, dry_run):
"""处理一个款:跑单品分析+综合+创建文档+写表"""
bt, tid, url = index_info
log(f"\n========== {style} ==========")
log(f" 生命进程表: {bt}/{tid}")
def _save(notes_list, summary, doc_url, doc_title, status):
"""统一入库辅助"""
save_to_db(style, notes_list, summary, doc_url, doc_title, status)
# 动态读字段
time_fid, summary_fid = find_target_fields(bt, tid)
if not time_fid or not summary_fid:
log(f" [ERR] 找不到 时间/笔记分析汇总 字段,跳过")
_save([], None, None, None, "no_fields")
return {"style": style, "status": "no_fields", "notes": 0}
# 拿上周笔记
notes = get_last_week_notes(style)
log(f" 上周笔记: {len(notes)} 篇")
if not notes:
log(f" [SKIP] 上周无笔记")
_save([], None, None, None, "no_notes")
return {"style": style, "status": "no_notes", "notes": 0}
# 跑单品分析
for n in notes:
n["report_path"] = run_single_note_analysis(n["id"])
# 过滤掉没生成报告的
valid_notes = [n for n in notes if n.get("report_path")]
if not valid_notes:
log(f" [ERR] 没有有效报告,跳过")
_save(notes, None, None, None, "no_reports")
return {"style": style, "status": "no_reports", "notes": len(notes)}
# LLM 综合
log(f" 调 LLM 综合 {len(valid_notes)} 篇报告...")
summary = generate_summary(style, valid_notes)
if not summary:
log(f" [ERR] LLM 综合失败")
_save(notes, None, None, None, "llm_failed")
return {"style": style, "status": "llm_failed", "notes": len(notes)}
# 保存本地
out_path = REPORTS_DIR / f"{style}_周笔记汇总_{TIME_LABEL}.txt"
out_path.write_text(summary, encoding="utf-8")
log(f" 本地副本: {out_path.name} ({len(summary)} 字符)")
if dry_run_global:
log(f" [DRY] 跳过飞书文档创建和写入")
_save(notes, summary, None, None, "dry_run")
return {"style": style, "status": "dry_run", "notes": len(notes), "chars": len(summary)}
# 创建飞书文档
doc_url, err = create_feishu_doc(style, summary)
if not doc_url:
log(f" [ERR] {err}")
_save(notes, summary, None, None, "doc_failed")
return {"style": style, "status": "doc_failed", "notes": len(notes)}
log(f" 飞书文档: {doc_url}")
# 写入生命进程表
ok = write_to_target_table(bt, tid, time_fid, summary_fid, style, doc_url, dry_run_global)
# 写入 PG cmt_weekly_summary
doc_title = f"{style} 周笔记汇总({TIME_LABEL})"
_save(notes, summary, doc_url, doc_title, "ok" if ok else "feishu_write_failed")
if ok:
log(f" ✅ 已写入生命进程表")
return {"style": style, "status": "ok", "notes": len(notes),
"doc_url": doc_url, "chars": len(summary)}
log(f" [ERR] 写入表失败")
return {"style": style, "status": "write_failed", "notes": len(notes)}
def main():
parser = argparse.ArgumentParser(description="通用周笔记汇总:对所有上周有笔记的款生成飞书文档并写入生命进程表")
parser.add_argument("--style", help="只跑指定款式名")
parser.add_argument("--dry-run", action="store_true", help="不写飞书,只生成本地报告")
parser.add_argument("--max-workers", type=int, default=4,
help="款之间并发数(默认 4)")
args = parser.parse_args()
global dry_run_global
dry_run_global = args.dry_run
log(f"时间范围: {TIME_LABEL}")
# 读索引表
index_styles = load_index_styles()
if not index_styles:
log("索引表无有效数据,退出")
return 1
# PG 查上周有笔记的款
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
SELECT s.name, COUNT(n.id)
FROM cmt_styles s
JOIN cmt_notes n ON n.style_id = s.id AND n.self_operated = FALSE
WHERE n.publish_time IS NOT NULL
AND n.publish_time >= %s AND n.publish_time < %s
GROUP BY s.name
ORDER BY s.name
""", (WEEK_START, WEEK_END + " 23:59:59"))
pg_styles = [(r[0], r[1]) for r in cur.fetchall()]
log(f"PG 上周有笔记的款: {len(pg_styles)} 个")
# 取交集:PG 里有 + 索引表里有飞书表地址
to_run = []
for name, note_count in pg_styles:
if args.style and name != args.style:
continue
if name in index_styles:
to_run.append((name, index_styles[name], note_count))
else:
log(f" [SKIP] {name}: 索引表无'每周笔记分析'地址")
log(f"待跑款式: {len(to_run)} 个")
if not to_run:
return 0
# 并发跑款
import concurrent.futures
log(f"并发跑 {len(to_run)} 款, max_workers={args.max_workers}")
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.max_workers) as executor:
future_to_style = {
executor.submit(process_one_style, name, info, args.dry_run): (name, ncount)
for name, info, ncount in to_run
}
for future in concurrent.futures.as_completed(future_to_style):
name, ncount = future_to_style[future]
try:
r = future.result()
r["note_count"] = ncount
results.append(r)
log(f" ✅ 完成: {name} -> {r['status']}")
except Exception as exc:
log(f" [ERR] {name} 异常: {exc}")
results.append({"style": name, "status": "exception",
"error": str(exc), "note_count": ncount})
# 按款式名排序输出
results.sort(key=lambda r: r.get("style", ""))
# 汇总
log(f"\n{'=' * 50}")
log(f"全部完成,汇总:")
for r in results:
log(f" {r['style']}: {r['status']} (笔记 {r.get('note_count', 0)} 篇)")
log(f"{'=' * 50}")
# 保存结果
out = REPORTS_DIR / f"_weekly_summary_all_{TIME_LABEL}.json"
out.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"结果已存: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,244 @@
"""综合 3 篇笔记分析报告,生成星云2 周笔记汇总 -> 写入飞书表"""
import sys
import json
import os
import subprocess
from pathlib import Path
from datetime import datetime
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
sys.stdout.reconfigure(encoding='utf-8')
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import call_hermes_analyzer
LARK = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK):
LARK = "lark-cli.cmd"
TARGET_BT = "BJaWbLEOKaHONMshOidcxvsKngh"
TARGET_TID = "tbltzBjbdiW4GrUd"
TIME_FIELD = "fldpKSfCCC"
SUMMARY_FIELD = "fldro5fLrb"
SUMMARY_PROMPT = """你是光影行星品牌「星云2」款式的周投放复盘分析师。
请基于本周 3 篇笔记的单品分析报告,生成周笔记汇总报告。
【本周笔记清单】
{notes}
【输出要求】
按以下结构输出(Markdown 格式,适合贴到飞书文本字段):
# 星云2 周笔记汇总({time_range})
## 一、本周投放概览
- 投放笔记数:{n} 篇
- 涉及平台:[列出]
- 总曝光 / 总互动:[汇总数据]
- 整体表现:[一句话定性]
## 二、投放效果多维分析
### 1. 流量效率
- 各平台曝光表现:[逐平台分析,抖音/小红书/B站]
- 互动率结构:[点赞率/收藏率/评论率/分享率,识别认知留存型/社交裂变型/搜索截流型]
- 流量是否匹配投放预期:[判断 + 理由]
### 2. 互动质量
- 评论区有效评论占比:[统计]
- 评论质量分层:[有效种草/连带兴趣/无效互动 占比]
- 用户决策阶段:[规格确认/价值判断/竞品对比/冲动下单/售后反馈,哪个阶段为主]
### 3. 品牌词沉淀
- 品牌词"光影行星"提及情况:[条数 + 上下文]
- 款式词"星云2"提及情况
- 我方产品相关评论占比
- 品牌词是否被自然讨论:[是/否 + 理由]
### 4. 卖点传达
- 笔记主推卖点 vs 评论区讨论卖点:[一致性分析]
- 卖点偏离方向:[若偏离,说明偏向哪里]
- 三大核心卖点(磁吸扣/分层收纳/容量/颜值/重量)在评论区的渗透情况
### 5. 达人匹配度
- 各达人与星云2 目标客群重合度
- 内容风格是否适合承载星云2 核心卖点
- 哪些达人值得复用,哪些应淘汰
### 6. 负面舆情
- 评论区负面集中点:[如自重/价格/做工]
- 影响面大小
- 是否需要控评
### 7. 平台差异
- 同主题不同平台表现对比(如有)
- 各平台用户关注点差异
- 平台化适配建议
## 三、单篇笔记提炼(精简)
### 笔记1:{title1}
- 平台/达人:{platform1} / {creator1}
- 核心数据:曝光 {views1} | 互动率 [X%]
- 评级:[优秀/良好/不好]
- 一句话提炼:[20-30 字,点出成败关键]
- 值得推荐:[是/否]
### 笔记2:{title2}
[同上格式]
### 笔记3:{title3}
[同上格式]
## 四、值得推荐的笔记
[列出评级良好及以上的笔记,给出可复用经验,每篇 2-3 句]
## 五、需要规避的问题
[列出"不好"评级笔记的问题,每篇 2-3 句,给规避建议]
## 六、下周投放优化清单
- 内容方向:[基于本周反馈的具体建议]
- 达人选择:[优先 / 规避类型]
- 评论区运营:[具体话术]
- 投放策略:[预算 / 流量助推 / 平台侧重]
【评级标准】
- 优秀:互动率高于行业基准(抖音≥6% / 小红书≥8% / B站≥5%)、评论中有产品讨论、品牌词被自然提及
- 良好:互动率达标但评论质量一般,或评论质量好但互动率刚达标
- 不好:互动率不达标、评论区无产品讨论、品牌词未沉淀
【约束】
- 第二部分"投放效果多维分析"必须详尽,每子模块给具体数据和判断
- 第三部分"单篇笔记提炼"精简,每篇 5-6 行内
- 直接给结论,不写"根据数据显示"
- 每条建议须具体可执行
- 评级严格按标准,不放宽
- 整体篇幅 1500-2500 字(单篇精简,多维分析详尽)"""
def read_report(path: Path) -> str:
if not path.exists():
return ""
for enc in ("utf-8-sig", "utf-8"):
try:
return path.read_text(encoding=enc)
except UnicodeDecodeError:
continue
return path.read_text(encoding="utf-8", errors="replace")
def main():
reports_dir = PATHS.exports_root / "summary"
notes = [
{"id": 1662, "platform": "bilibili", "creator": "裴念南",
"title": "SwitchLite开箱验机教程📦➕全套配件分享✨",
"views": 1318, "likes": 64, "cmts": 2, "favs": 21,
"report": read_report(reports_dir / "note_1662.txt")},
{"id": 1663, "platform": "xiaohongshu", "creator": "不爱出门的阿宅",
"title": "六月switch配件购买分享~实用又好看",
"views": 2224, "likes": 140, "cmts": 6, "favs": 59,
"report": read_report(reports_dir / "note_1663.txt")},
{"id": 1664, "platform": "douyin", "creator": "不爱出门的阿宅",
"title": "六月switch配件购买分享~实用又好看",
"views": None, "likes": 63, "cmts": 7, "favs": 16,
"report": read_report(reports_dir / "note_1664.txt")},
]
# 拼笔记清单
notes_block = []
for i, n in enumerate(notes, 1):
notes_block.append(
f"### 笔记{i}: {n['title']}\n"
f"- 平台: {n['platform']}\n- 达人: {n['creator']}\n"
f"- 曝光: {n['views'] or '无'} / 点赞: {n['likes']} / 评论: {n['cmts']} / 收藏: {n['favs']}\n\n"
f"单品分析报告全文:\n{n['report']}\n"
)
notes_text = "\n---\n".join(notes_block)
# 填 prompt
prompt = SUMMARY_PROMPT.format(notes=notes_text,
time_range="2026-06-29~07-05",
n=len(notes),
title1=notes[0]['title'], platform1=notes[0]['platform'],
creator1=notes[0]['creator'], views1=notes[0]['views'] or '无',
likes1=notes[0]['likes'], cmts1=notes[0]['cmts'], favs1=notes[0]['favs'],
title2=notes[1]['title'], platform2=notes[1]['platform'],
creator2=notes[1]['creator'], views2=notes[1]['views'] or '无',
likes2=notes[1]['likes'], cmts2=notes[1]['cmts'], favs2=notes[1]['favs'],
title3=notes[2]['title'], platform3=notes[2]['platform'],
creator3=notes[2]['creator'], views3=notes[2]['views'] or '无',
likes3=notes[2]['likes'], cmts3=notes[2]['cmts'], favs3=notes[2]['favs'])
print("调用 LLM 综合评级...")
summary = call_hermes_analyzer("你是光影行星品牌的周投放复盘分析师,擅长综合多篇笔记分析报告,生成款式级别的多维投放复盘。", prompt)
print(f"汇总报告生成完成,长度 {len(summary)} 字符")
# 保存本地副本
time_label = "2026-06-29~07-05"
style_label = "星云2"
out_path = reports_dir / f"{style_label}_周笔记汇总_{time_label}.txt"
out_path.write_text(summary, encoding="utf-8")
print(f"本地副本: {out_path}")
# 创建飞书文档(markdown 格式)
# LLM 输出的 summary 自身已以 "# 标题" 开头,不需要额外加;--title 会被 lark-cli 自动设为文档标题
print("创建飞书文档...")
doc_title = f"{style_label} 周笔记汇总({time_label})"
md_file = PATHS.tmp_root / "weekly_summary_doc.md"
md_file.parent.mkdir(parents=True, exist_ok=True)
md_file.write_text(summary, encoding="utf-8")
cmd = [LARK, "docs", "+create",
"--as", "user",
"--format", "json",
"--title", doc_title,
"--doc-format", "markdown",
"--content", f"@{md_file.name}"]
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env, encoding="utf-8", errors="replace")
text = proc.stdout or proc.stderr
start = text.find("{")
if start < 0:
print(f"创建文档失败: {text[:500]}")
return 1
import json as _json
resp = _json.loads(text[start:])
if not resp.get("ok"):
print(f"创建文档失败: {resp.get('error')}")
return 1
doc_url = resp.get("data", {}).get("document", {}).get("url", "")
doc_token = resp.get("data", {}).get("document", {}).get("document_id", "")
print(f"飞书文档已创建: {doc_url}")
print(f" doc_token: {doc_token}")
# 在飞书表里写 markdown 链接(标题+URL)
md_link = f"[{doc_title}]({doc_url})"
payload = {
TIME_FIELD: time_label,
SUMMARY_FIELD: md_link,
}
pf = PATHS.tmp_root / "weekly_summary_payload.json"
pf.write_text(_json.dumps(payload, ensure_ascii=False), encoding="utf-8")
cmd = [LARK, "base", "+record-upsert",
"--base-token", TARGET_BT,
"--table-id", TARGET_TID,
"--as", "user",
"--format", "json",
"--json", f"@{pf.name}"]
proc = subprocess.run(cmd, capture_output=True, env=env, encoding="utf-8", errors="replace")
text = proc.stdout or proc.stderr
start = text.find("{")
if start < 0:
print(f"插入表失败: {text[:300]}")
return 1
resp = _json.loads(text[start:])
if resp.get("ok"):
print(f"已写入飞书表: 时间={time_label}, 笔记分析汇总=文档链接")
return 0
print(f"插入表失败: {resp.get('error')}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,695 @@
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
from scrapling.fetchers import DynamicSession
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
STATE_DIR = PATHS.state_root
NOTES_DIR = PATHS.raw_root / "notes" / "xiaohongshu"
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
COMMENT_API_MARKS = (
"/api/sns/web/v2/comment/page",
"/api/sns/web/v2/comment/sub/page",
)
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def load_cookies() -> list[dict[str, Any]]:
if not COOKIE_FILE.exists():
return []
try:
data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except json.JSONDecodeError:
log(f"Cookie file is invalid, ignoring: {COOKIE_FILE}")
return []
def restore_cookies(page: Page) -> None:
cookies = load_cookies()
if not cookies:
return
try:
page.context.add_cookies(cookies)
log(f"Loaded cookies: {COOKIE_FILE}")
except Exception as exc:
log(f"Failed to load cookies, will continue with browser profile: {exc}")
def save_state(page: Page) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
COOKIE_FILE.write_text(
json.dumps(page.context.cookies(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
PATHS.browser_storage_state_file.write_text(
json.dumps(page.context.storage_state(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
log(f"Saved cookies: {COOKIE_FILE}")
def note_id_from_url(url: str) -> str:
match = re.search(r"/explore/([0-9a-fA-F]+)", url)
return match.group(1) if match else ""
def safe_filename(value: str) -> str:
value = re.sub(r"[<>:\"/\\\\|?*\\s]+", "_", value).strip("_")
return value[:90] or "xiaohongshu_comments"
def ts_to_iso(value: Any) -> str:
if not isinstance(value, (int, float)) or not value:
return ""
timestamp = value / 1000 if value > 10_000_000_000 else value
try:
return datetime.fromtimestamp(timestamp).isoformat(timespec="seconds")
except Exception:
return str(value)
def parse_comment(comment: dict[str, Any], source_url: str, level: str, parent_id: str = "") -> dict[str, Any]:
user = comment.get("user_info") or comment.get("user") or {}
comment_id = comment.get("id") or comment.get("comment_id") or ""
create_time = comment.get("create_time") or comment.get("time")
return {
"source_url": source_url,
"level": level,
"comment_id": comment_id,
"reply_to_comment_id": parent_id or comment.get("target_comment", {}).get("id", ""),
"nickname": user.get("nickname") or "",
"user_id": user.get("user_id") or user.get("id") or "",
"content": comment.get("content") or comment.get("text") or "",
"like_count": comment.get("like_count") or 0,
"reply_count": comment.get("sub_comment_count") or comment.get("sub_comment_total") or 0,
"ip_location": comment.get("ip_location") or comment.get("ip_label") or "",
"created_at": ts_to_iso(create_time),
"raw_create_time": create_time or "",
}
def merge_comment(
row: dict[str, Any],
comments_by_id: dict[str, dict[str, Any]],
) -> None:
comment_id = str(row.get("comment_id") or f"{row.get('level')}:{len(comments_by_id)}")
comments_by_id[comment_id] = row
def collect_from_payload(
payload: dict[str, Any],
source_url: str,
comments_by_id: dict[str, dict[str, Any]],
stats: dict[str, Any],
) -> None:
if not isinstance(payload, dict):
return
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
api_url = str(payload.get("__source_url", ""))
is_sub = "/comment/sub/page" in api_url
if not is_sub:
stats["api_total"] = data.get("total", stats.get("api_total"))
stats["last_has_more"] = data.get("has_more", stats.get("last_has_more"))
stats["last_cursor"] = data.get("cursor", stats.get("last_cursor"))
comments = data.get("comments") or data.get("comment_list") or []
if not isinstance(comments, list):
return
parent_id = ""
if is_sub:
params = dict(parse_qsl(urlsplit(api_url).query))
parent_id = params.get("root_comment_id") or params.get("comment_id") or params.get("target_comment_id") or ""
for comment in comments:
if not isinstance(comment, dict):
continue
row = parse_comment(comment, source_url, "reply" if is_sub else "comment", parent_id)
merge_comment(row, comments_by_id)
sub_comments = comment.get("sub_comments") or []
if isinstance(sub_comments, list):
for sub in sub_comments:
if isinstance(sub, dict):
sub_row = parse_comment(sub, source_url, "reply", str(row.get("comment_id") or ""))
merge_comment(sub_row, comments_by_id)
def setup_response_capture(page: Page, source_url: str) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
comments_by_id: dict[str, dict[str, Any]] = {}
stats: dict[str, Any] = {"api_hits": 0, "api_total": None, "last_has_more": None, "last_cursor": None}
def handle_response(response: Any) -> None:
url = response.url
if not any(mark in url for mark in COMMENT_API_MARKS):
return
try:
payload = response.json()
except Exception:
return
if isinstance(payload, dict):
payload["__source_url"] = url
stats["api_hits"] += 1
if "/comment/page" in url and not stats.get("comment_url_template"):
stats["comment_url_template"] = url
if "/comment/sub/page" in url and not stats.get("sub_url_template"):
stats["sub_url_template"] = url
collect_from_payload(payload, source_url, comments_by_id, stats)
page.on("response", handle_response)
return comments_by_id, stats
def click_if_visible(page: Page, text: str, timeout: int = 1200) -> bool:
try:
locator = page.get_by_text(text, exact=False).first
if locator.count():
locator.click(timeout=timeout)
page.wait_for_timeout(500)
return True
except Exception:
return False
return False
def dismiss_popups(page: Page) -> None:
for text in ("我知道了", "稍后再说", "暂不登录", "取消", "跳过"):
click_if_visible(page, text)
page.evaluate(
"""
() => {
const close = [...document.querySelectorAll('button, div, span')]
.filter((el) => {
const t = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return ['×', 'x', 'X'].includes(t) &&
r.width > 0 && r.width <= 52 &&
r.height > 0 && r.height <= 52 &&
r.top >= 0 && r.top < window.innerHeight * 0.75;
})[0];
close?.click();
}
"""
)
def maybe_wait_for_login(page: Page, login_timeout: int) -> None:
deadline = time.time() + login_timeout
while time.time() < deadline:
try:
body = page.locator("body").inner_text(timeout=5000)
except Exception:
body = ""
if "扫码登录" not in body and "登录后" not in body and "验证码" not in body:
return
log("Xiaohongshu may require login. Please finish login in the opened Chrome window.")
page.wait_for_timeout(3000)
def is_logged_in(page: Page) -> bool:
"""小红书登录态靠 web_session cookie,有它才能采全量评论。"""
for c in page.context.cookies():
if c.get("name") == "web_session" and c.get("value"):
return True
return False
def expand_visible_replies(page: Page) -> int:
try:
clicked = page.evaluate(
"""
() => {
const visible = (el) => {
const r = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return r.width > 0 && r.height > 0 &&
r.bottom > 0 && r.top < window.innerHeight &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
style.opacity !== '0';
};
const candidates = [...document.querySelectorAll('button, div, span')]
.filter((el) => {
const text = (el.innerText || '').trim();
return visible(el) &&
text.length <= 40 &&
(/展开.*回复/.test(text) || /查看.*回复/.test(text) || /共.*条回复/.test(text) || /还有.*回复/.test(text));
});
let count = 0;
for (const el of candidates.slice(0, 8)) {
el.click();
count++;
}
return count;
}
"""
)
if clicked:
page.wait_for_timeout(1200)
return int(clicked or 0)
except Exception:
return 0
def scroll_comments(page: Page, max_scrolls: int, idle_rounds: int, comments_by_id: dict[str, Any]) -> None:
last_count = len(comments_by_id)
idle = 0
page.mouse.move(1030, 620)
for index in range(max_scrolls):
dismiss_popups(page)
expand_visible_replies(page)
page.mouse.wheel(0, 1000)
# Scroll the comment panel from the inside — the right-side panel
# is a fixed-height container with its own scroll; wheel events on
# the page body don't propagate into it.
page.evaluate(
"""
() => {
// First try the dedicated comment/interaction panel
const commentPanel = document.querySelector('.comment-inner-container')
|| document.querySelector('[class*="commentListContainer"]')
|| document.querySelector('[class*="comments-container"]');
if (commentPanel) {
commentPanel.scrollTop += 1200;
return;
}
// Fallback: find tall scrollable containers on the right half
const candidates = [...document.querySelectorAll('div, section, aside')]
.filter((el) => {
const r = el.getBoundingClientRect();
return r.height > 160 &&
r.width > 220 &&
el.scrollHeight > el.clientHeight + 80 &&
r.right > window.innerWidth * 0.35;
})
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight));
for (const el of candidates.slice(0, 6)) {
el.scrollTop += 1000;
}
}
"""
)
page.wait_for_timeout(1800)
count = len(comments_by_id)
if count == last_count:
idle += 1
else:
log(f"Loaded comments: {count}")
idle = 0
last_count = count
if idle >= idle_rounds:
log(f"No new comments after {idle_rounds} rounds, stopping scroll.")
break
log(f"Scroll finished after {index + 1} rounds.")
def with_query_params(url: str, updates: dict[str, Any]) -> str:
parts = urlsplit(url)
params = dict(parse_qsl(parts.query, keep_blank_values=True))
for key, value in updates.items():
params[key] = str(value)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(params), parts.fragment))
def fetch_missing_top_comments(
page: Page,
source_url: str,
comments_by_id: dict[str, dict[str, Any]],
stats: dict[str, Any],
) -> None:
"""After scrolling, proactively paginate remaining top-level comments via page.evaluate."""
template = stats.get("comment_url_template")
if not template:
log("No top-level comment API template captured; skipped direct fetch.")
return
api_total = stats.get("api_total")
# api_total may be None if the first XHR response didn't include it;
# fall back to note_metrics.comment_count from the page
if not api_total:
metrics = stats.get("note_metrics") or {}
api_total = metrics.get("comment_count")
if api_total and len(comments_by_id) >= int(api_total):
return
last_cursor = stats.get("last_cursor")
# Always attempt pagination if we have a cursor — the scroll may have
# stopped early even though has_more was true in an earlier response.
if not last_cursor:
return
log(f"Top-level comments: collected {len(comments_by_id)} / api_total {api_total}; fetching remainder.")
fetched = 0
cursor = last_cursor
for _ in range(100):
top_url = with_query_params(template, {"cursor": cursor})
try:
payload = page.evaluate(
"""
async (url) => {
const res = await fetch(url, { credentials: 'include' });
return await res.json();
}
""",
top_url,
)
except Exception:
break
if not isinstance(payload, dict):
break
payload["__source_url"] = top_url
before = len(comments_by_id)
collect_from_payload(payload, source_url, comments_by_id, stats)
fetched += max(0, len(comments_by_id) - before)
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
next_has_more = data.get("has_more")
next_cursor = data.get("cursor")
if not next_has_more or next_cursor in (None, cursor):
break
cursor = next_cursor
page.wait_for_timeout(350)
log(f"Top-level direct fetch: {fetched} new comments; total now: {len(comments_by_id)}")
def fetch_missing_replies(
page: Page,
source_url: str,
comments_by_id: dict[str, dict[str, Any]],
stats: dict[str, Any],
) -> None:
template = stats.get("sub_url_template")
if not template:
log("No sub-comment API template captured; skipped direct reply fetch.")
return
parents = [
row for row in list(comments_by_id.values())
if row.get("level") == "comment" and int(row.get("reply_count") or 0) > 0
]
if not parents:
return
log(f"Fetching replies directly for {len(parents)} comments with replies.")
fetched = 0
for row in parents:
parent_id = str(row.get("comment_id") or "")
if not parent_id:
continue
cursor = ""
for _ in range(30):
reply_url = with_query_params(template, {"root_comment_id": parent_id, "comment_id": parent_id, "cursor": cursor})
try:
payload = page.evaluate(
"""
async (url) => {
const res = await fetch(url, { credentials: 'include' });
return await res.json();
}
""",
reply_url,
)
except Exception:
break
if not isinstance(payload, dict):
break
payload["__source_url"] = reply_url
before = len(comments_by_id)
collect_from_payload(payload, source_url, comments_by_id, stats)
fetched += max(0, len(comments_by_id) - before)
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
has_more = data.get("has_more")
next_cursor = data.get("cursor")
if not has_more or next_cursor in (None, cursor):
break
cursor = next_cursor
page.wait_for_timeout(350)
log(f"Direct replies fetched/merged: {fetched}; total comments now: {len(comments_by_id)}")
def extract_note_metrics(page: Page) -> dict[str, Any]:
try:
metrics = page.evaluate(
"""
() => {
const compactNumber = (text) => {
text = String(text || '').replace(/,/g, '').trim();
const match = text.match(/(\d+(?:\.\d+)?)(\s*[\u4e07wW])?/);
if (!match) return '';
const value = parseFloat(match[1]);
if (!Number.isFinite(value)) return '';
return match[2] ? Math.round(value * 10000) : Math.round(value);
};
const metricFromState = () => {
const noteId = location.href.match(/(?:explore|discovery\/item|profile\/[^/]+)\/(\w+)/)?.[1];
const detailMap = window.__INITIAL_STATE__?.note?.noteDetailMap || {};
const detail = noteId ? detailMap[noteId] : Object.values(detailMap)[0];
const note = detail?.note || {};
const info = note.interactInfo;
if (!info) return null;
return {
title: note.title || note.desc || '',
like_count: compactNumber(info.likedCount),
favorite_count: compactNumber(info.collectedCount),
comment_count: compactNumber(info.commentCount),
share_count: '',
};
};
const stateMetrics = metricFromState();
if (stateMetrics) return stateMetrics;
const visible = (el) => {
const r = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return r.width > 0 && r.height > 0 &&
r.bottom > 0 && r.top < window.innerHeight &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
style.opacity !== '0';
};
const center = (r) => ({ x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 });
const dedupe = (items) => {
const kept = [];
for (const item of items.sort((a, b) => a.r.left - b.r.left || a.r.top - b.r.top)) {
const c = center(item.r);
const duplicate = kept.some((prev) => {
const pc = center(prev.r);
return item.value === prev.value &&
Math.abs(c.x - pc.x) < 80 &&
Math.abs(c.y - pc.y) < 40;
});
if (!duplicate) kept.push(item);
}
return kept;
};
const textOf = (el) => (el.innerText || el.textContent || '').trim();
const nodes = [...document.querySelectorAll('button, div, span, p')]
.filter(visible)
.map((el) => ({ el, text: textOf(el), r: el.getBoundingClientRect() }))
.filter(({ text }) => text && text.length <= 120);
const commentText = nodes.find(({ text }) =>
/\u5171\s*\d+(?:\.\d+)?\s*[\u4e07wW]?\s*\u6761\u8bc4\u8bba/.test(text)
)?.text || '';
const commentByText = compactNumber(commentText);
const bottomNums = dedupe(nodes
.map((item) => ({ ...item, value: compactNumber(item.text) }))
.filter(({ text, r, value }) => /^(\d+(?:\.\d+)?\s*[\u4e07wW]?)$/.test(text) &&
value !== '' &&
r.top > window.innerHeight - 140 &&
r.left > window.innerWidth * 0.55 &&
r.width < 180 &&
r.height < 110))
.map(({ value }) => value);
return {
like_count: bottomNums.length >= 1 ? bottomNums[0] : '',
favorite_count: bottomNums.length >= 2 ? bottomNums[1] : '',
comment_count: commentByText || (bottomNums.length >= 3 ? bottomNums[2] : ''),
share_count: '',
};
}
"""
)
return metrics if isinstance(metrics, dict) else {}
except Exception:
return {}
def scrape_comments(url: str, login_timeout: int, max_scrolls: int, idle_rounds: int, headless: bool) -> tuple[list[dict[str, Any]], dict[str, Any]]:
result: dict[str, Any] = {"comments": [], "stats": {}, "final_url": ""}
def action(page: Page) -> None:
comments_by_id, stats = setup_response_capture(page, url)
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(6000)
maybe_wait_for_login(page, login_timeout)
dismiss_popups(page)
save_state(page)
scroll_comments(page, max_scrolls, idle_rounds, comments_by_id)
# Extract metrics early so fetch_missing_top_comments can use comment_count as fallback
stats["note_metrics"] = extract_note_metrics(page)
result["title"] = stats["note_metrics"].get("title", "")
fetch_missing_top_comments(page, url, comments_by_id, stats)
fetch_missing_replies(page, url, comments_by_id, stats)
page.wait_for_timeout(1000)
save_state(page)
result["comments"] = list(comments_by_id.values())
result["stats"] = stats
result["stats"]["logged_in"] = is_logged_in(page)
result["final_url"] = page.url
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch(url, page_action=action, wait=1000)
return result["comments"], result
def write_outputs(url: str, comments: list[dict[str, Any]], result: dict[str, Any]) -> tuple[Path, Path]:
NOTES_DIR.mkdir(parents=True, exist_ok=True)
note_id = note_id_from_url(url)
name = f"xiaohongshu_comments_{note_id}" if note_id else safe_filename(url)
json_path = NOTES_DIR / f"{name}.json"
csv_path = NOTES_DIR / f"{name}.csv"
if not comments and json_path.exists() and csv_path.exists():
log(f"No comments scraped; keeping existing output files: {json_path}, {csv_path}")
return json_path, csv_path
stats = result.get("stats", {})
public_stats = {key: value for key, value in stats.items() if not key.endswith("_template")}
payload = {
"source_url": url,
"title": result.get("title", ""),
"scraped_at": datetime.now().isoformat(timespec="seconds"),
"count": len(comments),
"stats": public_stats,
"comments": comments,
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
fieldnames = [
"source_url",
"level",
"comment_id",
"reply_to_comment_id",
"nickname",
"user_id",
"content",
"like_count",
"reply_count",
"ip_location",
"created_at",
"raw_create_time",
]
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(comments)
return json_path, csv_path
def login_only(login_timeout: int) -> int:
"""只触发登录(headed),登录完成立刻退出。给 relogin_xiaohongshu.py 用。"""
def action(page: Page) -> None:
page.goto("https://www.xiaohongshu.com/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(2500)
# 主动点登录按钮触发二维码
try:
login_btn = page.get_by_text("登录", exact=True).first
if login_btn.count():
login_btn.click(timeout=3000)
page.wait_for_timeout(1500)
except Exception:
pass
maybe_wait_for_login(page, login_timeout)
save_state(page)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=False,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=90000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch("https://www.xiaohongshu.com/", page_action=action, wait=1000)
log("[login-only] 完成")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape Xiaohongshu note comments with a real browser.")
parser.add_argument("url", help="Xiaohongshu explore/note URL (传 'login' 配合 --login-only)")
parser.add_argument("--login-timeout", type=int, default=300, help="等待扫码/登录秒数")
parser.add_argument("--max-scrolls", type=int, default=120, help="最多滚动加载次数")
parser.add_argument("--idle-rounds", type=int, default=8, help="连续几轮无新评论后停止")
parser.add_argument("--headless", action="store_true", help="无头模式;首次登录不要使用")
parser.add_argument("--login-only", action="store_true",
help="只触发登录(headed)然后退出,给 relogin_xiaohongshu 用")
args = parser.parse_args()
if args.login_only:
return login_only(args.login_timeout)
comments, result = scrape_comments(args.url, args.login_timeout, args.max_scrolls, args.idle_rounds, args.headless)
json_path, csv_path = write_outputs(args.url, comments, result)
log(f"Final URL: {result.get('final_url', '')}")
log(f"API hits: {result.get('stats', {}).get('api_hits', 0)}")
log(f"Scraped {len(comments)} comments")
log(f"JSON: {json_path}")
log(f"CSV: {csv_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
@@ -0,0 +1,479 @@
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
from typing import Any
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
from scrapling.fetchers import DynamicSession
MARKET_URL = "https://www.xingtu.cn/ad/creator/market"
DETAIL_URL_MARK = "/ad/creator/author-homepage/"
BASE_DIR = PATHS.module_root
DATA_DIR = PATHS.raw_root
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
def log(message: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}", flush=True)
def load_cookies() -> list[dict[str, Any]]:
if not COOKIE_FILE.exists():
return []
try:
data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except json.JSONDecodeError:
log(f"Cookie file is invalid, ignoring: {COOKIE_FILE}")
return []
def restore_cookies(page: Page) -> None:
cookies = load_cookies()
if not cookies:
return
try:
page.context.add_cookies(cookies)
log(f"Loaded cookies: {COOKIE_FILE}")
except Exception as exc:
log(f"Failed to load cookies, will continue with browser profile: {exc}")
def save_state(page: Page) -> None:
DATA_DIR.mkdir(exist_ok=True)
cookies = page.context.cookies()
COOKIE_FILE.write_text(
json.dumps(cookies, ensure_ascii=False, indent=2),
encoding="utf-8",
)
storage_state = page.context.storage_state()
PATHS.browser_storage_state_file.write_text(
json.dumps(storage_state, ensure_ascii=False, indent=2),
encoding="utf-8",
)
log(f"Saved cookies: {COOKIE_FILE}")
def is_logged_in(page: Page) -> bool:
if "/ad/creator/market" not in page.url:
return False
for text in ("内容找人", "昵称找人", "达人信息", "达人清单", "发布任务"):
try:
page.get_by_text(text, exact=False).first.wait_for(timeout=1200)
return True
except PlaywrightTimeoutError:
pass
return False
def wait_for_scan_login(page: Page, timeout_seconds: int) -> None:
log("Not logged in. Please scan/login in the opened Chrome window, then make sure it enters the creator market page.")
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if "redirect_uri=/ad/creator/market" in page.url:
try:
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2000)
except Exception:
pass
if is_logged_in(page):
save_state(page)
return
page.wait_for_timeout(2000)
raise TimeoutError(f"Login timed out after {timeout_seconds} seconds.")
def ensure_login(page: Page, login_timeout: int) -> None:
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(3000)
if is_logged_in(page):
log("Already logged in, using saved browser session.")
save_state(page)
return
wait_for_scan_login(page, login_timeout)
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(3000)
if not is_logged_in(page):
wait_for_scan_login(page, login_timeout)
def dismiss_popups(page: Page) -> None:
for text in ("跳过", "我知道了", "稍后再说"):
click_by_text(page, text, exact=True, timeout=1000)
page.evaluate(
"""
() => {
const close = [...document.querySelectorAll('button, span, div')]
.filter((el) => {
const text = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return (text === '×' || text === 'x' || text === 'X') &&
r.width <= 40 && r.height <= 40 &&
r.left > window.innerWidth * 0.45 &&
r.top < window.innerHeight * 0.45;
})[0];
close?.click();
}
"""
)
page.wait_for_timeout(500)
def click_by_text(page: Page, text: str, exact: bool = True, timeout: int = 5000) -> bool:
try:
locator = page.get_by_text(text, exact=exact).first
if locator.count():
locator.click(timeout=timeout)
return True
except Exception:
return False
return False
def select_nickname_search(page: Page) -> None:
log("Switching to nickname search.")
if click_by_text(page, "昵称找人", exact=True, timeout=5000):
page.wait_for_timeout(800)
return
page.evaluate(
"""
() => {
const target = [...document.querySelectorAll('button, div, span')]
.find((el) => (el.innerText || '').trim() === '昵称找人');
target?.click();
}
"""
)
page.wait_for_timeout(800)
def search_creator(page: Page, creator_name: str) -> None:
dismiss_popups(page)
select_nickname_search(page)
input_locator = page.locator("input:visible").first
input_locator.wait_for(state="visible", timeout=30000)
input_locator.click()
input_locator.fill(creator_name)
input_locator.press("Enter")
try:
page.wait_for_timeout(2000)
wait_for_visible_creator_result(page, creator_name, timeout_ms=8000)
except PlaywrightTimeoutError:
page.evaluate(
"""
() => {
const input = [...document.querySelectorAll('input')].find((el) => el.value);
const box = input?.closest('div');
const candidates = [...document.querySelectorAll('button, [role="button"], div, span')]
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width >= 24 && r.width <= 80 && r.height >= 24 && r.height <= 80;
});
const near = candidates.find((el) => {
const er = el.getBoundingClientRect();
const br = box?.getBoundingClientRect();
return br && er.left > br.right - 30 && Math.abs(er.top - br.top) < 60;
});
near?.click();
}
"""
)
wait_for_visible_creator_result(page, creator_name, timeout_ms=30000)
page.wait_for_timeout(2500)
def wait_for_visible_creator_result(page: Page, creator_name: str, timeout_ms: int) -> None:
deadline = time.time() + timeout_ms / 1000
while time.time() < deadline:
found = page.evaluate(
"""
(name) => {
const visible = (el) => {
const r = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return r.width > 0 && r.height > 0 &&
r.bottom > 0 && r.top < window.innerHeight &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
style.opacity !== '0';
};
return [...document.querySelectorAll('tr, [class*="row"], [class*="table"], [class*="item"], div')]
.some((el) => visible(el) && (el.innerText || '').includes(name) && /达人|粉丝|播放|报价|下单|内容主题/.test(el.innerText || ''));
}
""",
creator_name,
)
if found:
return
page.wait_for_timeout(500)
raise PlaywrightTimeoutError(f"Timed out waiting for visible creator result: {creator_name}")
def open_creator_detail(page: Page, creator_name: str) -> Page:
log(f"Opening creator detail: {creator_name}")
pages_before = list(page.context.pages)
clicked = page.evaluate(
"""
(name) => {
const textHasName = (el) => (el.innerText || '').includes(name);
const rows = [...document.querySelectorAll('tr, [class*="row"], [class*="table"], [class*="item"]')]
.filter(textHasName);
const exactRow = rows.find((el) => {
const lines = (el.innerText || '').split('\\n').map((line) => line.trim()).filter(Boolean);
return lines.includes(name);
});
const row = exactRow || rows[0];
if (!row) return false;
const target = row.querySelector('img, a, [class*="avatar"], [class*="name"]') || row;
target.scrollIntoView({ block: 'center', inline: 'center' });
target.click();
return true;
}
""",
creator_name,
)
if not clicked:
page.get_by_text(creator_name, exact=True).first.click(timeout=10000)
detail_page = wait_for_detail_page(page, pages_before, timeout_ms=30000)
if detail_page is None:
page.get_by_text(creator_name, exact=False).first.click(timeout=10000)
detail_page = wait_for_detail_page(page, pages_before, timeout_ms=30000)
if detail_page is None:
raise TimeoutError("Clicked creator result, but no author-homepage page was detected.")
detail_page.bring_to_front()
detail_page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(1000)
return detail_page
def wait_for_detail_page(page: Page, pages_before: list[Page], timeout_ms: int) -> Page | None:
deadline = time.time() + timeout_ms / 1000
known = set(pages_before)
while time.time() < deadline:
all_pages = list(page.context.pages)
candidates = [p for p in all_pages if p not in known] + [page] + all_pages
for candidate in candidates:
try:
if DETAIL_URL_MARK in candidate.url or "/ad/creator/author-homepage/" in candidate.url:
return candidate
except Exception:
pass
page.wait_for_timeout(500)
return None
def open_creation_ability(page: Page) -> None:
log("Opening creation ability tab.")
try:
page.get_by_text("创作能力", exact=True).first.click(timeout=10000)
except Exception:
page.evaluate(
"""
() => {
const target = [...document.querySelectorAll('button, div, span, a')]
.find((el) => (el.innerText || '').trim() === '创作能力');
target?.click();
}
"""
)
page.wait_for_timeout(3000)
try:
all_video = page.get_by_text("全部视频", exact=False).first
all_video.wait_for(timeout=30000)
all_video.scroll_into_view_if_needed()
page.wait_for_timeout(1200)
page.mouse.wheel(0, 600)
page.wait_for_timeout(1500)
except PlaywrightTimeoutError:
pass
def parse_video_cards(page: Page) -> list[dict[str, str]]:
return page.evaluate(
"""
() => {
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const valueAfter = (lines, label) => {
const index = lines.findIndex((line) => line === label || line.includes(label));
if (index < 0) return '';
const sameLine = lines[index].replace(label, '').trim();
if (sameLine) return sameLine;
return lines[index + 1] || '';
};
const parseCard = (card) => {
const lines = (card.innerText || '')
.split('\\n')
.map((line) => line.trim())
.filter(Boolean);
const text = lines.join('\\n');
if (!/播放量/.test(text) || !/点赞量/.test(text) || !/评论量/.test(text)) return null;
const firstMetric = lines.findIndex((line) => /播放量/.test(line));
const titleCandidates = lines.slice(0, firstMetric >= 0 ? firstMetric : lines.length).filter((line) => {
if (/^(全部视频|全部|个人视频|星图视频|播放量|点赞量|评论量)$/.test(line)) return false;
if (/请输入关键词搜索|开始日期|结束日期|展开/.test(line)) return false;
if (/^[\\d,.wW万]+$/.test(line)) return false;
return line.length >= 2;
});
const title = titleCandidates.slice(-2).join(' ').trim();
return {
title: clean(title),
play_count: clean(valueAfter(lines, '播放量')),
like_count: clean(valueAfter(lines, '点赞量')),
comment_count: clean(valueAfter(lines, '评论量')),
};
};
const cards = [...document.querySelectorAll('div')].filter((el) => {
const text = el.innerText || '';
const rect = el.getBoundingClientRect();
return rect.width >= 140 &&
rect.width <= 340 &&
rect.height >= 260 &&
rect.height <= 620 &&
/播放量/.test(text) &&
/点赞量/.test(text) &&
/评论量/.test(text) &&
el.querySelector('img');
});
const parsed = cards.map(parseCard).filter(Boolean);
const seen = new Set();
return parsed.filter((item) => {
const key = [item.title, item.play_count, item.like_count, item.comment_count].join('|');
if (seen.has(key)) return false;
seen.add(key);
return item.title && (item.play_count || item.like_count || item.comment_count);
});
}
"""
)
def write_outputs(creator_name: str, videos: list[dict[str, str]]) -> tuple[Path, Path]:
DATA_DIR.mkdir(exist_ok=True)
safe_name = re.sub(r'[<>:"/\\\\|?*\\s]+', "_", creator_name).strip("_") or "creator"
json_path = DATA_DIR / f"xingtu_{safe_name}_videos.json"
csv_path = DATA_DIR / f"xingtu_{safe_name}_videos.csv"
payload = {
"creator": creator_name,
"scraped_at": datetime.now().isoformat(timespec="seconds"),
"count": len(videos),
"videos": videos,
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(
file,
fieldnames=["title", "play_count", "like_count", "comment_count"],
)
writer.writeheader()
writer.writerows(videos)
return json_path, csv_path
def load_names(cli_names: list[str], names_file: str | None) -> list[str]:
names: list[str] = []
names.extend(name.strip() for name in cli_names if name.strip())
if names_file:
path = Path(names_file)
for line in path.read_text(encoding="utf-8-sig").splitlines():
name = line.strip()
if name and not name.startswith("#"):
names.append(name)
deduped: list[str] = []
seen = set()
for name in names:
if name not in seen:
seen.add(name)
deduped.append(name)
return deduped
def scrape(creator_name: str, login_timeout: int, headless: bool) -> list[dict[str, str]]:
result: dict[str, Any] = {"videos": [], "detail_url": ""}
def action(page: Page) -> None:
ensure_login(page, login_timeout)
search_creator(page, creator_name)
detail_page = open_creator_detail(page, creator_name)
open_creation_ability(detail_page)
result["detail_url"] = detail_page.url
result["videos"] = parse_video_cards(detail_page)
save_state(detail_page)
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=1,
) as session:
session.fetch(MARKET_URL, page_action=action, wait=1000)
log(f"Detail URL: {result.get('detail_url', '')}")
return result["videos"]
def main() -> int:
parser = argparse.ArgumentParser(
description="Scrape Xingtu creator video metrics with Scrapling DynamicSession.",
)
parser.add_argument("creator_names", nargs="*", help="达人昵称,可传多个,例如:达人A 达人B")
parser.add_argument("--names-file", help="批量达人名单文件,一行一个昵称")
parser.add_argument("--login-timeout", type=int, default=300, help="首次扫码登录等待秒数")
parser.add_argument("--headless", action="store_true", help="无头模式;首次登录不要使用")
args = parser.parse_args()
creator_names = load_names(args.creator_names, args.names_file)
if not creator_names:
parser.error("Please provide at least one creator name or --names-file.")
for index, creator_name in enumerate(creator_names, start=1):
log(f"=== Creator {index}/{len(creator_names)}: {creator_name} ===")
videos = scrape(creator_name, args.login_timeout, args.headless)
json_path, csv_path = write_outputs(creator_name, videos)
log(f"Scraped {len(videos)} videos")
log(f"JSON: {json_path}")
log(f"CSV: {csv_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
raise SystemExit(130)
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
"""Stable contract implemented by every business module."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from gyxx_flow.workflow import WorkflowDefinition
@runtime_checkable
class BusinessModule(Protocol):
"""A business capability that contributes workflows to the application."""
module_id: str
def workflow_definitions(self) -> tuple[WorkflowDefinition, ...]:
"""Return immutable workflow definitions owned by this module."""
__all__ = ["BusinessModule"]
@@ -0,0 +1,97 @@
"""Product, ERP, analysis, main-image, and alert workflows."""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from gyxx_flow.adapters.native import DeferredModuleCommandStep, ModuleCommandFactory
from gyxx_flow.workflow import StepDefinition, WorkflowDefinition
PRODUCT_SCHEDULED_WORKFLOW_IDS = (
"product.persona.daily",
"product.daily",
"product.alert.daily",
"product.import.daily",
"product.style_analysis.interval",
"product.main_image.jd.weekly",
"product.main_image.weekly",
)
PRODUCT_MANUAL_WORKFLOW_IDS = (
"product.backfill",
"product.market_rank",
"product.review_collection",
)
PRODUCT_WORKFLOW_IDS = (
*PRODUCT_SCHEDULED_WORKFLOW_IDS,
*PRODUCT_MANUAL_WORKFLOW_IDS,
)
PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60
PRODUCT_RESOURCE = "module:product_commerce"
class ProductCommerceModule:
"""Product-commerce business module composition boundary."""
module_id = "product_commerce"
def __init__(self, definitions: Iterable[WorkflowDefinition] = ()) -> None:
self._definitions = tuple(definitions)
@classmethod
def from_catalog(
cls,
catalog: Any,
*,
command_factory: ModuleCommandFactory | None = None,
) -> "ProductCommerceModule":
"""Build all available product workflows, excluding unavailable metadata."""
entries = {
entry.workflow_id: entry
for entry in catalog.workflows
if entry.module == cls.module_id and entry.trigger in {"scheduled", "manual"}
}
expected = set(PRODUCT_WORKFLOW_IDS)
if set(entries) != expected:
raise ValueError(
"product catalog workflows do not match the migration contract: "
f"expected={sorted(expected)}, actual={sorted(entries)}"
)
definitions: list[WorkflowDefinition] = []
for workflow_id in PRODUCT_WORKFLOW_IDS:
entry = entries[workflow_id]
action = (
DeferredModuleCommandStep(entry)
if command_factory is None
else DeferredModuleCommandStep(entry, command_factory=command_factory)
)
definitions.append(
WorkflowDefinition(
workflow_id,
(
StepDefinition(
"module_execute",
action,
timeout_seconds=PRODUCT_TIMEOUT_SECONDS,
max_attempts=1,
resources=(PRODUCT_RESOURCE,),
production_sink=True,
),
),
)
)
return cls(definitions)
def workflow_definitions(self) -> tuple[WorkflowDefinition, ...]:
return self._definitions
__all__ = [
"PRODUCT_RESOURCE",
"PRODUCT_TIMEOUT_SECONDS",
"PRODUCT_MANUAL_WORKFLOW_IDS",
"PRODUCT_SCHEDULED_WORKFLOW_IDS",
"PRODUCT_WORKFLOW_IDS",
"ProductCommerceModule",
]
@@ -0,0 +1,19 @@
# ── PostgreSQL 连接 (data_hub 库) ───────────────────────────────────
# 从 .env 文件加载;不要把真实密码提交到 git
PG_HOST=8.148.185.119
PG_PORT=5432
PG_DB=data_hub
PG_USER=data_hub
PG_PASSWORD=
# ── 京东采集账号 (jd_self_inventory_sales_collector 等) ─────────────────
JD_SELF_SHOP=光影自营研发
JD_SELF_PASSWORD=
# ── 淘宝光合平台视频上传账号 ─────────────────────────────────────────
GUANGHE_USERNAME=
GUANGHE_PASSWORD=
GUANGHE_USER_DATA_DIR=${GYXX_DATA_ROOT}\profiles\product_commerce\guanghe
GUANGHE_LUGGAGE_USERNAME=
GUANGHE_LUGGAGE_PASSWORD=
GUANGHE_LUGGAGE_USER_DATA_DIR=${GYXX_DATA_ROOT}\profiles\product_commerce\guanghe-luggage
@@ -0,0 +1,20 @@
# Product-commerce source snapshot
This directory contains the reviewed working-tree snapshot of the legacy
product-commerce runtime. It is now physically owned by `gyxx-flow`; no file in
this directory needs the legacy checkout to exist.
The snapshot is not yet the public workflow entry point. Native packaging must
first replace root-relative imports, subprocess script names, data-root
assumptions, browser-profile defaults, and dynamic vendor-root injection.
`launchers_reference/` is provenance only. Those files document the old task
arguments and must never be registered as final scheduled-task actions.
`runtime_tests/` preserves the 21 legacy regression tests without adding them to
the current repository's automatic pytest collection. They will be moved and
adapted as each native runtime slice becomes importable.
The authoritative source/target hashes, transformations, and six intentionally
excluded diagnostic entries are recorded in
`config/source-manifests/product_commerce.json`.
@@ -0,0 +1,6 @@
"""Vendored product-commerce source snapshot awaiting native package refactoring.
Files in this package are copied into ``gyxx-flow`` so migration work no longer
depends on the legacy checkout. They are provenance-preserving runtime sources;
the public workflow registry is switched to native entries in a separate phase.
"""
@@ -0,0 +1,280 @@
"""按日期优先汇总三平台 + ERP 的每日款式数据。
输出:
data/<日期>/<款>/final_summary.json
data/<日期>/<款>/final_summary.md
不计算转化率/加购率/退款率(这三个比率只在周汇总里算)。
每平台字段: sales / refund_orders / visitors / cart_users(来自平台层原始值)
ERP 字段: style_codes / sales / refund_orders / source
"""
import argparse
import json
from datetime import datetime
from pathlib import Path
from erp_metric_overrides import DATA_ROOT, PLATFORM_DIRS, apply_erp_override, parse_number
from runtime_paths import CURATED_DATA_ROOT, managed_data_path
PLATFORMS = list(PLATFORM_DIRS)
PLATFORM_STYLE_FILENAME = {
"jd": lambda style, date: f"{safe_name(style)}_{date}.json",
"dy": lambda style, date: f"{safe_name(style)}_{date}.json",
"tm": lambda style, date: f"业务款式_{date}.json",
}
def safe_name(value: str) -> str:
return "".join("_" if ch in '\\/:*?"<>|' else ch for ch in str(value)).strip(" ._") or "未命名"
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8-sig"))
def _extract_platform_metrics(platform: str, payload: dict) -> dict:
"""从平台采集器产出的 <款>_<日期>.json 提取 4 个原始值。"""
if platform == "jd":
metrics = payload.get("指标汇总") or {}
return {
"visitors": parse_number(metrics.get("访客数")),
"cart_users": parse_number(metrics.get("加购客户数") or metrics.get("加购人数")),
"sales": parse_number(metrics.get("销量")),
"refund_orders": parse_number(metrics.get("退款订单数")),
}
if platform == "dy":
raw = payload.get("raw_totals") or {}
platform_raw = payload.get("platform_raw_totals") or raw
return {
"visitors": parse_number(platform_raw.get("访客数") or payload.get("访客数")),
"cart_users": parse_number(platform_raw.get("加购人数") or payload.get("加购人数")),
"sales": parse_number(platform_raw.get("销量") or payload.get("销量")),
"refund_orders": parse_number(platform_raw.get("退款订单数")),
}
if platform == "tm":
style = payload.get("style") or payload
return {
"visitors": parse_number(style.get("访客量") or style.get("访客数")),
"cart_users": parse_number(style.get("加购人数")),
"sales": parse_number(style.get("销量")),
"refund_orders": parse_number(style.get("退款订单数")),
}
raise ValueError(f"unknown platform: {platform}")
def _extract_erp_metadata(payload: dict) -> dict:
"""ERP 层只携带元信息(编码 + 源),销量/退货量已在各平台 sales/refund_orders 字段中(ERP 覆盖后)。"""
return {
"style_codes": payload.get("erp_style_codes") or [],
}
def collect_style_names(target_date: str, data_root: Path) -> list[str]:
"""扫描三平台数据目录,得到 (日期, 款) 全部去重款式名。"""
names: set[str] = set()
for platform in PLATFORMS:
base = data_root / PLATFORM_DIRS[platform]
if not base.exists():
continue
for style_dir in base.glob(f"*_{target_date}"):
if not style_dir.is_dir():
continue
for json_path in style_dir.glob("*.json"):
if json_path.name in {"data.json"}:
continue
if json_path.name == "final_summary.json":
continue
if not (json_path.name.endswith(f"_{target_date}.json")
or json_path.name.startswith("业务款式_")):
continue
try:
payload = read_json(json_path)
except Exception:
continue
if platform == "jd":
name = payload.get("款式")
elif platform == "dy":
name = payload.get("style_name")
else:
style = payload.get("style") or {}
name = style.get("款式") or payload.get("款式")
if name:
names.add(name)
return sorted(names)
def load_platform_block(platform: str, style_name: str, target_date: str, data_root: Path) -> dict | None:
filename = PLATFORM_STYLE_FILENAME[platform](style_name, target_date)
style_dir = data_root / PLATFORM_DIRS[platform] / f"{safe_name(style_name)}_{target_date}"
json_path = style_dir / filename
if not json_path.exists():
return None
try:
payload = read_json(json_path)
except Exception:
return None
metrics = _extract_platform_metrics(platform, payload)
# 不要相信平台文件里"已应用 ERP 覆盖"的旧值 —— 平台采集在 ERP 写 data.json 之前
# 跑的话,summarize_styles_from_excel 拿到 all_failed 标记, 销量/退货量回退到平台原值
# (TM 0/99 / DY 138/833) 并被冻进平台 JSON。这里用最新的 data.json 再覆盖一次。
try:
override = apply_erp_override(
platform, style_name,
metrics["visitors"], metrics["cart_users"],
target_date=target_date,
platform_sales=metrics.get("sales"),
platform_refunds=metrics.get("refund_orders"),
)
metrics["sales"] = override["销量"]
metrics["refund_orders"] = override["退款订单数"]
metrics["erp_unavailable"] = override.get("erp_unavailable", False)
except Exception as exc:
metrics["erp_override_error"] = f"{type(exc).__name__}: {exc}"
metrics["source"] = str(json_path)
return metrics
def load_erp_block(platform: str, style_name: str, target_date: str, data_root: Path) -> dict | None:
style_dir = data_root / PLATFORM_DIRS[platform] / f"{safe_name(style_name)}_{target_date}"
json_path = style_dir / "data.json"
if not json_path.exists():
return None
try:
payload = read_json(json_path)
except Exception:
return None
block = _extract_erp_metadata(payload)
block["source"] = str(json_path)
return block
def build_payload(date: str, style_name: str, data_root: Path) -> dict:
payload = {
"date": date,
"style_name": style_name,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"metrics_note": {
"platform.sales": "汇总时实时应用 ERP 覆盖后的销量(不要看平台 JSON 里的旧值)",
"platform.refund_orders": "汇总时实时应用 ERP 覆盖后的退货量(不要看平台 JSON 里的旧值)",
"platform.visitors": "平台商品访客数之和",
"platform.cart_users": "平台商品加购人数之和",
"erp.style_codes": "ERP 款式编码",
"erp_unavailable": "True 表示本平台 ERP data.json 不存在或全部查询失败, 此时销量/退货量用平台原值",
},
"erp": {},
"jd": {},
"tm": {},
"dy": {},
}
erp_seen: dict | None = None
for platform in PLATFORMS:
platform_block = load_platform_block(platform, style_name, date, data_root)
if platform_block:
payload[platform] = platform_block
erp_block = load_erp_block(platform, style_name, date, data_root)
if erp_block and erp_seen is None:
erp_seen = erp_block
if erp_seen is not None:
payload["erp"] = erp_seen
return payload
def render_markdown(payload: dict) -> str:
style = payload["style_name"]
date = payload["date"]
erp = payload.get("erp") or {}
lines = [
f"# {style} - 每日最终汇总 - {date}",
"",
f"- 日期: {date}",
f"- 款式: {style}",
f"- ERP款式编码: {', '.join(erp.get('style_codes') or []) or '-'}",
"",
"| 平台 | 销量 | 退货量 | 访客数 | 加购人数 |",
"|---|---:|---:|---:|---:|",
]
for platform in PLATFORMS:
block = payload.get(platform) or {}
if not block:
lines.append(f"| {platform} | - | - | - | - |")
continue
lines.append(
f"| {platform} | {int(parse_number(block.get('sales')))} | "
f"{int(parse_number(block.get('refund_orders')))} | "
f"{int(parse_number(block.get('visitors')))} | "
f"{int(parse_number(block.get('cart_users')))} |"
)
lines.append("")
lines.append(f"_生成时间: {payload['generated_at']}_")
return "\n".join(lines)
def write_outputs(payload: dict, data_root: Path) -> tuple[Path, Path]:
style_dir = data_root / payload["date"] / safe_name(payload["style_name"])
style_dir.mkdir(parents=True, exist_ok=True)
json_path = style_dir / "final_summary.json"
md_path = style_dir / "final_summary.md"
json_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
md_path.write_text(render_markdown(payload), encoding="utf-8-sig")
return json_path, md_path
def main():
parser = argparse.ArgumentParser(description="按日期+款式汇总三平台 + ERP 每日数据(无比率字段)")
parser.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
parser.add_argument("--data-root", default=str(DATA_ROOT))
args = parser.parse_args()
data_root = managed_data_path(args.data_root)
style_names = collect_style_names(args.date, data_root)
print(f"[INFO] {args.date} 共发现 {len(style_names)} 个款")
written = 0
payloads = []
for style_name in style_names:
payload = build_payload(args.date, style_name, data_root)
json_path, md_path = write_outputs(payload, CURATED_DATA_ROOT)
written += 1
payloads.append(payload)
print(f"[OK] {args.date} 写入 {written} 个款 -> {data_root / args.date}")
# 写库(库不可用时 WARN,不阻塞主流程)
try:
from db import get_conn, upsert_daily_metrics
rows = []
for p in payloads:
erp_codes = (p.get("erp") or {}).get("style_codes") or []
for platform in ("jd", "dy", "tm"):
block = p.get(platform) or {}
if not block:
continue
rows.append({
"metric_date": args.date,
"style_name": p.get("style_name"),
"platform": platform,
"sales": int(parse_number(block.get("sales"))),
"refund_orders": int(parse_number(block.get("refund_orders"))),
"visitors": int(parse_number(block.get("visitors"))),
"cart_users": int(parse_number(block.get("cart_users"))),
"erp_style_codes": erp_codes,
"source": block.get("source"),
})
if rows:
with get_conn() as conn:
n = upsert_daily_metrics(conn, rows)
print(f"[PG] daily_style_metrics upsert: {n} 行")
except Exception as exc:
print(f"[WARN] PG upsert 失败 (库未配/不可达),主流程不受影响: {exc}")
if __name__ == "__main__":
main()
Loaded 100 of 374 files, more files were not shown because too many files have changed in this diff. Show more