feat(video-analysis): 完成视频分析模块迭代任务

Bug 修复:
- T-019: 修复品牌API响应解析,正确解析 data[0].brand_name
- T-020: 添加品牌API Bearer Token认证

视频分析功能:
- T-021: SessionID池服务,从内部API获取Cookie列表
- T-022: SessionID自动重试,失效时自动切换重试
- T-023: 巨量云图API封装,支持超时和错误处理
- T-024: 视频分析数据接口 GET /api/v1/videos/{item_id}/analysis
- T-025: 数据库A3指标更新
- T-026: 视频分析前端页面,展示6大类25+指标

测试覆盖率:
- brand_api.py: 100%
- session_pool.py: 100%
- yuntu_api.py: 100%
- video_analysis.py: 99%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zfc
2026-01-28 17:51:35 +08:00
co-authored by Claude Opus 4.5
parent cdc364cb2a
commit f123f68be3
17 changed files with 2259 additions and 23 deletions
+16
View File
@@ -0,0 +1,16 @@
import VideoAnalysis from '@/components/VideoAnalysis';
export default function AnalysisPage() {
return (
<main className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-6">
<a href="/" className="text-indigo-600 hover:text-indigo-800">
</a>
</div>
<VideoAnalysis />
</div>
</main>
);
}
+7 -1
View File
@@ -53,7 +53,13 @@ export default function Home() {
{pageState === 'default' && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="text-gray-400 text-6xl mb-4">🔍</div>
<p className="text-gray-500"></p>
<p className="text-gray-500 mb-4"></p>
<a
href="/analysis"
className="text-sm text-indigo-600 hover:text-indigo-800"
>
</a>
</div>
)}
+178
View File
@@ -0,0 +1,178 @@
'use client';
import { useState } from 'react';
import { VideoAnalysisData } from '@/types';
import { getVideoAnalysis } from '@/lib/api';
// 格式化数字(千分位)
function formatNumber(num: number | null | undefined): string {
if (num === null || num === undefined) return '-';
return num.toLocaleString('zh-CN');
}
// 格式化金额(保留2位小数)
function formatCurrency(num: number | null | undefined): string {
if (num === null || num === undefined) return '-';
return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// 指标卡片组件
function MetricCard({ label, value, unit }: { label: string; value: string; unit?: string }) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="text-sm text-gray-500 mb-1">{label}</div>
<div className="text-lg font-semibold text-gray-900">
{value}
{unit && <span className="text-sm font-normal text-gray-500 ml-1">{unit}</span>}
</div>
</div>
);
}
// 指标分组组件
function MetricGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<h3 className="text-md font-semibold text-gray-800 mb-3 pb-2 border-b">{title}</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{children}
</div>
</div>
);
}
export default function VideoAnalysis() {
const [itemId, setItemId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<VideoAnalysisData | null>(null);
const handleSearch = async () => {
if (!itemId.trim()) {
setError('请输入视频ID');
return;
}
setLoading(true);
setError(null);
try {
const response = await getVideoAnalysis(itemId.trim());
if (response.success) {
setData(response.data);
} else {
setError(response.error || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-6xl mx-auto p-4">
{/* 搜索框 */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="flex gap-4">
<input
type="text"
value={itemId}
onChange={(e) => setItemId(e.target.value)}
placeholder="请输入视频ID (item_id)"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={handleSearch}
disabled={loading}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '加载中...' : '查询'}
</button>
</div>
{error && (
<div className="mt-3 text-red-600 text-sm">{error}</div>
)}
</div>
{/* 分析结果 */}
{data && (
<div className="bg-white rounded-lg shadow p-6">
{/* 基础信息 */}
<MetricGroup title="基础信息">
<MetricCard label="视频ID" value={data.base_info.item_id} />
<MetricCard label="达人昵称" value={data.base_info.star_nickname} />
<MetricCard label="达人ID" value={data.base_info.star_unique_id} />
<MetricCard label="星图ID" value={data.base_info.star_id} />
<MetricCard label="发布时间" value={data.base_info.publish_time || '-'} />
<MetricCard label="行业" value={data.base_info.industry_name || '-'} />
<div className="col-span-2">
<MetricCard label="视频标题" value={data.base_info.title || '-'} />
</div>
</MetricGroup>
{/* 触达指标 */}
<MetricGroup title="触达指标">
<MetricCard label="总曝光数" value={formatNumber(data.reach_metrics.total_show_cnt)} />
<MetricCard label="自然曝光数" value={formatNumber(data.reach_metrics.natural_show_cnt)} />
<MetricCard label="加热曝光数" value={formatNumber(data.reach_metrics.ad_show_cnt)} />
<MetricCard label="总播放数" value={formatNumber(data.reach_metrics.total_play_cnt)} />
<MetricCard label="自然播放数" value={formatNumber(data.reach_metrics.natural_play_cnt)} />
<MetricCard label="加热播放数" value={formatNumber(data.reach_metrics.ad_play_cnt)} />
<MetricCard label="有效播放数" value={formatNumber(data.reach_metrics.effective_play_cnt)} />
</MetricGroup>
{/* A3指标 */}
<MetricGroup title="A3指标">
<MetricCard label="新增A3" value={formatNumber(data.a3_metrics.a3_increase_cnt)} />
<MetricCard label="加热新增A3" value={formatNumber(data.a3_metrics.ad_a3_increase_cnt)} />
<MetricCard label="自然新增A3" value={formatNumber(data.a3_metrics.natural_a3_increase_cnt)} />
</MetricGroup>
{/* 搜索指标 */}
<MetricGroup title="搜索指标">
<MetricCard label="看后搜人数" value={formatNumber(data.search_metrics.after_view_search_uv)} />
<MetricCard label="看后搜次数" value={formatNumber(data.search_metrics.after_view_search_pv)} />
<MetricCard label="品牌搜索人数" value={formatNumber(data.search_metrics.brand_search_uv)} />
<MetricCard label="商品搜索人数" value={formatNumber(data.search_metrics.product_search_uv)} />
<MetricCard label="回搜次数" value={formatNumber(data.search_metrics.return_search_cnt)} />
</MetricGroup>
{/* 费用指标 */}
<MetricGroup title="费用指标">
<MetricCard label="总花费" value={formatCurrency(data.cost_metrics_raw.cost)} />
<MetricCard label="自然花费" value={formatCurrency(data.cost_metrics_raw.natural_cost)} />
<MetricCard label="加热花费" value={formatCurrency(data.cost_metrics_raw.ad_cost)} />
</MetricGroup>
{/* 成本指标 */}
<MetricGroup title="成本指标(计算)">
<MetricCard label="CPM" value={formatCurrency(data.cost_metrics_calculated.cpm)} />
<MetricCard label="自然CPM" value={formatCurrency(data.cost_metrics_calculated.natural_cpm)} />
<MetricCard label="CPA3" value={formatCurrency(data.cost_metrics_calculated.cpa3)} />
<MetricCard label="自然CPA3" value={formatCurrency(data.cost_metrics_calculated.natural_cpa3)} />
<MetricCard label="CP搜索" value={formatCurrency(data.cost_metrics_calculated.cp_search)} />
<MetricCard label="预估自然看后搜人数" value={formatNumber(data.cost_metrics_calculated.estimated_natural_search_uv)} />
<MetricCard label="自然CP搜索" value={formatCurrency(data.cost_metrics_calculated.natural_cp_search)} />
</MetricGroup>
{/* 视频链接 */}
{data.base_info.video_url && (
<div className="mt-4 pt-4 border-t">
<a
href={data.base_info.video_url}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800"
>
</a>
</div>
)}
</div>
)}
</div>
);
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { QueryRequest, QueryResponse } from '@/types';
import { QueryRequest, QueryResponse, VideoAnalysisResponse } from '@/types';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
@@ -27,3 +27,17 @@ export async function exportData(format: 'xlsx' | 'csv'): Promise<Blob> {
return response.blob();
}
// 获取视频分析数据 (T-026)
export async function getVideoAnalysis(itemId: string): Promise<VideoAnalysisResponse> {
const response = await fetch(`${API_BASE_URL}/videos/${itemId}/analysis`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('视频不存在');
}
throw new Error(`获取分析数据失败: ${response.statusText}`);
}
return response.json();
}
+55
View File
@@ -61,3 +61,58 @@ export const QUERY_PLACEHOLDER: Record<QueryType, string> = {
unique_id: '请输入达人unique_id,每行一个...',
nickname: '请输入达人昵称关键词...',
};
// 视频分析数据 (T-026)
export interface VideoAnalysisData {
base_info: {
item_id: string;
title: string | null;
video_url: string | null;
star_id: string;
star_unique_id: string;
star_nickname: string;
publish_time: string | null;
industry_name: string | null;
};
reach_metrics: {
total_show_cnt: number;
natural_show_cnt: number;
ad_show_cnt: number;
total_play_cnt: number;
natural_play_cnt: number;
ad_play_cnt: number;
effective_play_cnt: number;
};
a3_metrics: {
a3_increase_cnt: number;
ad_a3_increase_cnt: number;
natural_a3_increase_cnt: number;
};
search_metrics: {
after_view_search_uv: number;
after_view_search_pv: number;
brand_search_uv: number;
product_search_uv: number;
return_search_cnt: number;
};
cost_metrics_raw: {
cost: number;
natural_cost: number;
ad_cost: number;
};
cost_metrics_calculated: {
cpm: number | null;
natural_cpm: number | null;
cpa3: number | null;
natural_cpa3: number | null;
cp_search: number | null;
estimated_natural_search_uv: number | null;
natural_cp_search: number | null;
};
}
export interface VideoAnalysisResponse {
success: boolean;
data: VideoAnalysisData;
error?: string;
}