Просмотр исходного кода

feat(multi-platform): 互动数据按平台档案驱动展示(P0+P1),为抖音+视频号双跑就绪

P1 后端:新增 GET /config/platforms,从 platform_profiles/*.json 派生
  {platform:{label, status, heat_fields}};加平台只改 profile JSON、前端零改动。

P0 前端:
- ContentAssetShelf 互动行改 catalog 驱动:只显示该平台真实可得指标(profile.heat_fields),
  每字段带中文标签+图标;抖音=赞/评/分享/收藏、视频号=只点赞、快手=含播放、小红书=收藏在前。
  缺指标不再显示假「0」;无 heat 字段整行不渲染。标签「抖音热度」→「{平台}热度」动态。
- platformLabel 补全 10 平台静态兜底(抖音/视频号/快手/小红书/B站/公众号/头条/知乎/YouTube/GitHub),
  可被 catalog 覆盖;HEAT_FIELD_LABELS 统一键→中文。
- contentUrl 增 item.play_url 兜底:视频号无网页原帖,至少能打开视频流。

视频号就绪实测(经 /config/platforms):label=视频号、互动仅显「点赞」、无 embed 降级为占位+打开原帖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee 1 день назад
Родитель
Сommit
d8fa7d8528

+ 35 - 0
content_agent/api.py

@@ -16,6 +16,8 @@ from content_agent.schemas import (
     ContentItemsResponse,
     DashboardResponse,
     JsonFileResponse,
+    PlatformCatalogResponse,
+    PlatformDescriptor,
     QueryListResponse,
     RecordsResponse,
     RunListResponse,
@@ -161,6 +163,39 @@ def get_config_walk_policy() -> ConfigFileResponse:
     return _config_file_response("walk-policy")
 
 
+_PLATFORM_PROFILE_DIR = "tech_documents/数据接口与来源/platform_profiles"
+
+
+@app.get("/config/platforms", response_model=PlatformCatalogResponse)
+def get_config_platforms() -> PlatformCatalogResponse:
+    # 平台展示目录:从 platform_profiles/*.json 派生每平台的 label + 真实可得互动指标(heat 字段)。
+    # 前端按此渲染平台名与互动数据——加平台只改 profile JSON,前端零改动。
+    from pathlib import Path
+
+    from content_agent.integrations import config_store
+
+    catalog: dict[str, PlatformDescriptor] = {}
+    profile_dir = Path(_PLATFORM_PROFILE_DIR)
+    for path in sorted(profile_dir.glob("*.json")):
+        try:
+            data, _ = config_store.load_json(path)
+        except (FileNotFoundError, OSError, ValueError):
+            continue
+        platform = str(data.get("platform") or path.stem)
+        heat_fields = [
+            str(sig["field"])
+            for sig in ((data.get("heat") or {}).get("signals") or [])
+            if isinstance(sig, dict) and sig.get("field")
+        ]
+        catalog[platform] = PlatformDescriptor(
+            platform=platform,
+            label=str(data.get("platform_label") or platform),
+            status=data.get("status"),
+            heat_fields=heat_fields,
+        )
+    return PlatformCatalogResponse(platforms=catalog)
+
+
 @app.get("/runs/{run_id}/dashboard", response_model=DashboardResponse)
 def get_run_dashboard(run_id: str) -> DashboardResponse:
     _ensure_web_run_exists(run_id)

+ 13 - 0
content_agent/schemas.py

@@ -154,6 +154,19 @@ class ConfigFileResponse(BaseModel):
     data: dict[str, Any]
 
 
+class PlatformDescriptor(BaseModel):
+    # 平台展示元数据(从 platform_profiles 派生,前端按此渲染,不再写死抖音)
+    platform: str
+    label: str
+    status: str | None = None
+    # 该平台真实可得的互动指标(= profile.heat.signals 的 field,按重要度排序;统一键名)
+    heat_fields: list[str] = []
+
+
+class PlatformCatalogResponse(BaseModel):
+    platforms: dict[str, PlatformDescriptor]
+
+
 class TimelineResponse(BaseModel):
     run_id: str
     items: list[dict[str, Any]]

+ 1 - 0
web/app/globals.css

@@ -1860,6 +1860,7 @@ a {
 .asset-stats-label { font-size: 11px; font-weight: 900; color: #8491a6; }
 .asset-stat { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; color: #51607a; }
 .asset-stat svg { color: #9aa6ba; }
+.asset-stat em { font-style: normal; font-weight: 700; color: #8491a6; }
 
 /* 标签 */
 .asset-tags { display: flex; flex-wrap: wrap; gap: 6px; }

+ 50 - 20
web/features/runs/ContentAssetShelf.tsx

@@ -3,15 +3,25 @@
 // 内容资产「成品货架」:把本次入池(ADD_TO_CONTENT_POOL)的视频以交付清单形式摆出来。
 // 每张卡 = 可播放 iframe + 来历 + Gemini 判定 + 抖音真实热度 + 打开原帖,
 // 卡底预留「我方平台实测表现」位(入库后回填,与 Gemini 预测对照)。数据全部来自已加载的 content-items,无新增取数。
-import { useState } from "react";
-import { ExternalLink, Lock, MessageCircle, PlayCircle, Share2, ShieldCheck, Star, ThumbsUp } from "lucide-react";
-import { contentUrl, embedPlayerUrl, platformLabel } from "@/lib/platform/content";
-import type { ContentItemsResponse } from "@/lib/api/types";
+import { useEffect, useState, type ReactNode } from "react";
+import { Eye, ExternalLink, Lock, MessageCircle, PlayCircle, Share2, ShieldCheck, Star, ThumbsUp } from "lucide-react";
+import { contentUrl, embedPlayerUrl, HEAT_FIELD_LABELS, platformLabel } from "@/lib/platform/content";
+import { getConfigPlatforms } from "@/lib/api/client";
+import type { ContentItemsResponse, PlatformDescriptor } from "@/lib/api/types";
 
 type AnyRec = Record<string, unknown>;
 
 const POOLED = "ADD_TO_CONTENT_POOL";
 
+// 统一互动指标键 → 图标(标签走 HEAT_FIELD_LABELS,均由平台 profile 的 heat 字段驱动)。
+const HEAT_ICON: Record<string, ReactNode> = {
+  digg_count: <ThumbsUp size={13} />,
+  comment_count: <MessageCircle size={13} />,
+  share_count: <Share2 size={13} />,
+  collect_count: <Star size={13} />,
+  play_count: <Eye size={13} />
+};
+
 function decisionAction(it: AnyRec): string {
   const rd = (it.rule_decision as AnyRec) || {};
   const pmr = (it.pattern_match_result as AnyRec) || {};
@@ -61,19 +71,30 @@ function AssetMedia({ item }: { item: AnyRec }) {
   );
 }
 
-function StatPill({ icon, value }: { icon: React.ReactNode; value: string }) {
+// 互动数据行:只显示该平台真实可得的指标(profile.heat_fields 驱动);
+// 抖音=赞/评/分享/收藏,视频号=只点赞,快手=含播放…缺指标不显示「0」。无 heat 字段则整行不渲染。
+function HeatStats({ item, descriptor }: { item: AnyRec; descriptor?: PlatformDescriptor }) {
+  const st = (item.statistics as AnyRec) || {};
+  const fields = descriptor?.heat_fields || [];
+  if (!fields.length) return null;
+  const label = platformLabel(item.platform);
   return (
-    <span className="asset-stat">
-      {icon}
-      {value}
-    </span>
+    <div className="asset-stats">
+      <span className="asset-stats-label">{label}热度</span>
+      {fields.map((f) => (
+        <span className="asset-stat" key={f} title={HEAT_FIELD_LABELS[f] || f}>
+          {HEAT_ICON[f] || null}
+          <em>{HEAT_FIELD_LABELS[f] || f}</em>
+          {fmtStat(st[f])}
+        </span>
+      ))}
+    </div>
   );
 }
 
-function AssetCard({ item }: { item: AnyRec }) {
+function AssetCard({ item, descriptor }: { item: AnyRec; descriptor?: PlatformDescriptor }) {
   const pmr = (item.pattern_match_result as AnyRec) || {};
   const rd = (item.rule_decision as AnyRec) || {};
-  const st = (item.statistics as AnyRec) || {};
   const title = String(item.description || item.title || `${platformLabel(item.platform)}视频`);
   const author = String(item.author_display_name || item.platform_author_id || "未知作者");
   const queries = (item.matched_search_queries as unknown[]) || [];
@@ -118,13 +139,7 @@ function AssetCard({ item }: { item: AnyRec }) {
 
         {reason ? <p className="asset-reason">{reason}</p> : null}
 
-        <div className="asset-stats">
-          <span className="asset-stats-label">抖音热度</span>
-          <StatPill icon={<ThumbsUp size={13} />} value={fmtStat(st.digg_count)} />
-          <StatPill icon={<MessageCircle size={13} />} value={fmtStat(st.comment_count)} />
-          <StatPill icon={<Star size={13} />} value={fmtStat(st.collect_count)} />
-          <StatPill icon={<Share2 size={13} />} value={fmtStat(st.share_count)} />
-        </div>
+        <HeatStats item={item} descriptor={descriptor} />
 
         {tags.length ? (
           <div className="asset-tags">
@@ -180,6 +195,17 @@ export function ContentAssetShelf({
 }) {
   const items = (contentItems.items as AnyRec[]) || [];
   const pooled = items.filter((it) => decisionAction(it) === POOLED);
+  // 平台展示目录(label + 每平台真实互动指标),加平台只改 profile JSON、前端零改动。
+  const [catalog, setCatalog] = useState<Record<string, PlatformDescriptor>>({});
+  useEffect(() => {
+    let alive = true;
+    getConfigPlatforms()
+      .then((r) => alive && setCatalog(r.platforms || {}))
+      .catch(() => {});
+    return () => {
+      alive = false;
+    };
+  }, []);
 
   return (
     <div className="asset-shelf">
@@ -196,13 +222,17 @@ export function ContentAssetShelf({
           <span>淘汰</span>
           <strong>{rejected}</strong>
         </div>
-        <p className="asset-summary-note">本次交付的「成品」=入池资产;下面逐条可播放、看判定与抖音热度。</p>
+        <p className="asset-summary-note">本次交付的「成品」=入池资产;下面逐条可播放、看判定与各平台互动热度。</p>
       </div>
 
       {pooled.length ? (
         <div className="asset-shelf-list">
           {pooled.map((it, i) => (
-            <AssetCard item={it} key={String(it.platform_content_id || i)} />
+            <AssetCard
+              item={it}
+              descriptor={catalog[String(it.platform || "").toLowerCase()]}
+              key={String(it.platform_content_id || i)}
+            />
           ))}
         </div>
       ) : (

+ 5 - 0
web/lib/api/client.ts

@@ -2,6 +2,7 @@ import type {
   ConfigFileResponse,
   ContentItemsResponse,
   DashboardResponse,
+  PlatformCatalogResponse,
   QueryListResponse,
   RunListResponse,
   RuntimeFileResponse,
@@ -95,3 +96,7 @@ export function getConfigQueryPrompts() {
 export function getConfigWalkPolicy() {
   return request<ConfigFileResponse>("/config/walk-policy");
 }
+
+export function getConfigPlatforms() {
+  return request<PlatformCatalogResponse>("/config/platforms");
+}

+ 12 - 0
web/lib/api/types.ts

@@ -179,6 +179,18 @@ export type ConfigFileResponse = {
   data: Record<string, unknown>;
 };
 
+// 平台展示目录(/config/platforms):从 platform_profiles 派生,前端按此渲染平台名与互动指标。
+export type PlatformDescriptor = {
+  platform: string;
+  label: string;
+  status?: string | null;
+  heat_fields: string[];
+};
+
+export type PlatformCatalogResponse = {
+  platforms: Record<string, PlatformDescriptor>;
+};
+
 // walk_policy.json 的拍板值可能裸值,也可能带 {value, provenance, tbd} 留痕包裹。
 export type PolicyValue =
   | string

+ 27 - 6
web/lib/platform/content.ts

@@ -7,15 +7,35 @@ function str(v: unknown): string | null {
   return null;
 }
 
-export function platformLabel(platform: unknown): string {
-  const labels: Record<string, string> = {
-    douyin: "抖音",
-    shipinhao: "视频号"
-  };
+// 平台名静态兜底(与 platform_profiles 的 platform_label 对齐)。
+// 动态可由 /config/platforms 覆盖;此表保证 catalog 未加载时各处(列表卡、顶栏 chip)仍显示中文。
+const PLATFORM_LABELS: Record<string, string> = {
+  douyin: "抖音",
+  shipinhao: "视频号",
+  kuaishou: "快手",
+  xiaohongshu: "小红书",
+  bilibili: "B站",
+  weixin: "公众号",
+  toutiao: "头条",
+  zhihu: "知乎",
+  youtube: "YouTube",
+  github: "GitHub"
+};
+
+export function platformLabel(platform: unknown, catalog?: Record<string, { label: string }>): string {
   const key = String(platform || "").toLowerCase();
-  return labels[key] || (key ? key : "未知平台");
+  return catalog?.[key]?.label || PLATFORM_LABELS[key] || (key ? key : "未知平台");
 }
 
+// 统一互动指标键 → 中文标签(后端各平台 normalizer 已把原生字段映射到这 5 个键)。
+export const HEAT_FIELD_LABELS: Record<string, string> = {
+  digg_count: "点赞",
+  comment_count: "评论",
+  share_count: "分享",
+  collect_count: "收藏",
+  play_count: "播放"
+};
+
 // 原帖链接:优先用记录里真实落库的 url;无则仅抖音可由 content_id 拼回,其余平台返回 null(不伪造)。
 export function contentUrl(item: Item): string | null {
   const media = (item.media_record as Item) || {};
@@ -25,6 +45,7 @@ export function contentUrl(item: Item): string | null {
     str(item.url) ||
     str(item.aweme_url) ||
     str(media.share_url) ||
+    str(item.play_url) || // 视频号无网页原帖,仅有视频流地址,兜底用它
     str(media.play_url);
   if (direct) return direct;
   const platform = String(item.platform || "").toLowerCase();