|
@@ -0,0 +1,999 @@
|
|
|
|
|
+#!/usr/bin/env python
|
|
|
|
|
+# coding=utf-8
|
|
|
|
|
+"""
|
|
|
|
|
+标题相关性分析可视化(v3 裂变率版)
|
|
|
|
|
+两个视角:
|
|
|
|
|
+1. 分享标题 → 视频标题 → 视频ID → 渠道
|
|
|
|
|
+2. 视频 → 分享标题 → 视频ID → 渠道
|
|
|
|
|
+支持多天数据和日期筛选
|
|
|
|
|
+新增:裂变率指标、进入小程序场景/人群包/人群创意筛选
|
|
|
|
|
+"""
|
|
|
|
|
+import pandas as pd
|
|
|
|
|
+import numpy as np
|
|
|
|
|
+import json
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+task_dir = Path(__file__).parent
|
|
|
|
|
+output_dir = task_dir / "output"
|
|
|
|
|
+
|
|
|
|
|
+# 读取所有含相似度的数据文件
|
|
|
|
|
+sim_files = sorted(output_dir.glob("*_含相似度.csv"))
|
|
|
|
|
+if not sim_files:
|
|
|
|
|
+ print("没有找到数据文件,请先运行 analyze.py")
|
|
|
|
|
+ exit(1)
|
|
|
|
|
+
|
|
|
|
|
+print(f"找到 {len(sim_files)} 个数据文件")
|
|
|
|
|
+
|
|
|
|
|
+# 合并所有数据(跳过空文件)
|
|
|
|
|
+dfs = []
|
|
|
|
|
+dates = []
|
|
|
|
|
+for f in sim_files:
|
|
|
|
|
+ dt = f.stem.replace('_含相似度', '')
|
|
|
|
|
+ df_tmp = pd.read_csv(f)
|
|
|
|
|
+ if len(df_tmp) == 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+ dates.append(dt)
|
|
|
|
|
+ df_tmp['dt'] = dt
|
|
|
|
|
+ dfs.append(df_tmp)
|
|
|
|
|
+ print(f" {dt}: {len(df_tmp):,} 条")
|
|
|
|
|
+
|
|
|
|
|
+df = pd.concat(dfs, ignore_index=True)
|
|
|
|
|
+print(f"合并后总记录数: {len(df):,}")
|
|
|
|
|
+
|
|
|
|
|
+# 日期列表(用于前端筛选)
|
|
|
|
|
+dates_json = json.dumps(sorted(dates), ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+# 渠道列表(按数量排序)
|
|
|
|
|
+channel_counts = df['channel'].value_counts()
|
|
|
|
|
+channels = channel_counts.index.tolist()
|
|
|
|
|
+channels_json = json.dumps(channels, ensure_ascii=False)
|
|
|
|
|
+print(f"渠道数: {len(channels)}")
|
|
|
|
|
+
|
|
|
|
|
+# 进入小程序场景列表
|
|
|
|
|
+if '进入小程序场景' in df.columns:
|
|
|
|
|
+ scene_counts = df['进入小程序场景'].value_counts()
|
|
|
|
|
+ scenes = [s for s in scene_counts.index.tolist() if pd.notna(s) and s]
|
|
|
|
|
+ scenes_json = json.dumps(scenes, ensure_ascii=False)
|
|
|
|
|
+ print(f"场景数: {len(scenes)}")
|
|
|
|
|
+else:
|
|
|
|
|
+ scenes = []
|
|
|
|
|
+ scenes_json = '[]'
|
|
|
|
|
+
|
|
|
|
|
+# 人群包列表
|
|
|
|
|
+if '人群包' in df.columns:
|
|
|
|
|
+ crowd_counts = df['人群包'].value_counts()
|
|
|
|
|
+ crowds = [c for c in crowd_counts.index.tolist() if pd.notna(c) and c]
|
|
|
|
|
+ crowds_json = json.dumps(crowds, ensure_ascii=False)
|
|
|
|
|
+ print(f"人群包数: {len(crowds)}")
|
|
|
|
|
+else:
|
|
|
|
|
+ crowds = []
|
|
|
|
|
+ crowds_json = '[]'
|
|
|
|
|
+
|
|
|
|
|
+# 人群创意名称列表
|
|
|
|
|
+if '人群_创意名称' in df.columns:
|
|
|
|
|
+ creative_counts = df['人群_创意名称'].value_counts()
|
|
|
|
|
+ creatives = [c for c in creative_counts.index.tolist() if pd.notna(c) and c][:100] # 限制100个
|
|
|
|
|
+ creatives_json = json.dumps(creatives, ensure_ascii=False)
|
|
|
|
|
+ print(f"人群创意数: {len(creatives)}")
|
|
|
|
|
+else:
|
|
|
|
|
+ creatives = []
|
|
|
|
|
+ creatives_json = '[]'
|
|
|
|
|
+
|
|
|
|
|
+# 品类列表(按数量排序)
|
|
|
|
|
+if 'merge二级品类' in df.columns:
|
|
|
|
|
+ category_counts = df['merge二级品类'].value_counts()
|
|
|
|
|
+ categories = [c for c in category_counts.index.tolist() if pd.notna(c) and c]
|
|
|
|
|
+ categories_json = json.dumps(categories, ensure_ascii=False)
|
|
|
|
|
+ print(f"品类数: {len(categories)}")
|
|
|
|
|
+else:
|
|
|
|
|
+ categories = []
|
|
|
|
|
+ categories_json = '[]'
|
|
|
|
|
+
|
|
|
|
|
+# 检查必要列
|
|
|
|
|
+sim_col = '分享标题_视频标题_相似度'
|
|
|
|
|
+if sim_col not in df.columns:
|
|
|
|
|
+ print(f"缺少相似度列: {sim_col}")
|
|
|
|
|
+ exit(1)
|
|
|
|
|
+
|
|
|
|
|
+# 计算裂变率指标的辅助函数
|
|
|
|
|
+def calc_rates(group, click):
|
|
|
|
|
+ """计算裂变率、头部裂变率、头部原视频裂变率、推荐裂变率、进入推荐率"""
|
|
|
|
|
+ fission_uv = group['裂变uv'].sum() if '裂变uv' in group.columns else 0
|
|
|
|
|
+ head_fission_uv = group['头部裂变uv'].sum() if '头部裂变uv' in group.columns else 0
|
|
|
|
|
+ head_video_fission_uv = group['头部原视频裂变uv'].sum() if '头部原视频裂变uv' in group.columns else 0
|
|
|
|
|
+ rec_fission_uv = group['推荐裂变uv'].sum() if '推荐裂变uv' in group.columns else 0
|
|
|
|
|
+ enter_rec_uv = group['进入推荐uv'].sum() if '进入推荐uv' in group.columns else 0
|
|
|
|
|
+
|
|
|
|
|
+ divisor = click + 10 # 加 10 平滑
|
|
|
|
|
+ return {
|
|
|
|
|
+ 'fission_uv': int(fission_uv),
|
|
|
|
|
+ 'fission_rate': round(float(fission_uv / divisor) if divisor > 0 else 0, 4),
|
|
|
|
|
+ 'head_fission_uv': int(head_fission_uv),
|
|
|
|
|
+ 'head_fission_rate': round(float(head_fission_uv / divisor) if divisor > 0 else 0, 4),
|
|
|
|
|
+ 'head_video_fission_uv': int(head_video_fission_uv),
|
|
|
|
|
+ 'head_video_fission_rate': round(float(head_video_fission_uv / divisor) if divisor > 0 else 0, 4),
|
|
|
|
|
+ 'rec_fission_uv': int(rec_fission_uv),
|
|
|
|
|
+ 'rec_fission_rate': round(float(rec_fission_uv / divisor) if divisor > 0 else 0, 4),
|
|
|
|
|
+ 'enter_rec_uv': int(enter_rec_uv),
|
|
|
|
|
+ 'enter_rec_rate': round(float(enter_rec_uv / click) if click > 0 else 0, 4), # 进入推荐率不加平滑
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# ========== 视角1:分享标题 → 视频 ==========
|
|
|
|
|
+def build_share_title_view(input_df, min_video_titles=2, top_n=1000):
|
|
|
|
|
+ """
|
|
|
|
|
+ 构建正向视角数据:分享标题 → 视频标题 → 视频ID → 渠道
|
|
|
|
|
+ 筛选:同一分享标题链接到至少 min_video_titles 个不同视频标题
|
|
|
|
|
+ """
|
|
|
|
|
+ # 筛选有相似度数据的记录
|
|
|
|
|
+ data = input_df[input_df[sim_col].notna()].copy()
|
|
|
|
|
+
|
|
|
|
|
+ # 按分享标题统计视频标题数(而非视频ID)
|
|
|
|
|
+ title_video_count = data.groupby('分享标题')['title'].nunique()
|
|
|
|
|
+ valid_titles = title_video_count[title_video_count >= min_video_titles].index
|
|
|
|
|
+ data = data[data['分享标题'].isin(valid_titles)]
|
|
|
|
|
+
|
|
|
|
|
+ # 按总点击排序,取 top_n
|
|
|
|
|
+ title_clicks = data.groupby('分享标题')['点击uv'].sum().nlargest(top_n)
|
|
|
|
|
+ valid_titles = title_clicks.index.tolist()
|
|
|
|
|
+
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for share_title in valid_titles:
|
|
|
|
|
+ title_data = data[data['分享标题'] == share_title]
|
|
|
|
|
+
|
|
|
|
|
+ # 获取封面(取第一条)
|
|
|
|
|
+ cover = ''
|
|
|
|
|
+ if '分享封面' in title_data.columns:
|
|
|
|
|
+ first_cover = title_data['分享封面'].dropna().iloc[0] if not title_data['分享封面'].dropna().empty else ''
|
|
|
|
|
+ cover = str(first_cover) if first_cover else ''
|
|
|
|
|
+
|
|
|
|
|
+ # 按视频标题分组
|
|
|
|
|
+ video_titles_data = []
|
|
|
|
|
+ for video_title, vt_group in title_data.groupby('title'):
|
|
|
|
|
+ # 按视频ID分组
|
|
|
|
|
+ videos_data = []
|
|
|
|
|
+ for videoid, vid_group in vt_group.groupby('videoid'):
|
|
|
|
|
+ # 按渠道分组
|
|
|
|
|
+ channels_data = []
|
|
|
|
|
+ for channel, ch_group in vid_group.groupby('channel'):
|
|
|
|
|
+ click = int(ch_group['点击uv'].sum())
|
|
|
|
|
+ rates = calc_rates(ch_group, click)
|
|
|
|
|
+ ch_dates = ch_group['dt'].unique().tolist() if 'dt' in ch_group.columns else []
|
|
|
|
|
+ channels_data.append({
|
|
|
|
|
+ 'channel': str(channel) if pd.notna(channel) else '未知',
|
|
|
|
|
+ 'click': click,
|
|
|
|
|
+ 'dates': ch_dates,
|
|
|
|
|
+ **rates
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ vid_click = int(vid_group['点击uv'].sum())
|
|
|
|
|
+ vid_rates = calc_rates(vid_group, vid_click)
|
|
|
|
|
+ # 获取二级品类(取第一条)
|
|
|
|
|
+ category = ''
|
|
|
|
|
+ if 'merge二级品类' in vid_group.columns:
|
|
|
|
|
+ cat_val = vid_group['merge二级品类'].dropna().iloc[0] if not vid_group['merge二级品类'].dropna().empty else ''
|
|
|
|
|
+ category = str(cat_val) if cat_val else ''
|
|
|
|
|
+ videos_data.append({
|
|
|
|
|
+ 'videoid': str(int(videoid)) if pd.notna(videoid) else '',
|
|
|
|
|
+ 'category': category,
|
|
|
|
|
+ 'total_click': vid_click,
|
|
|
|
|
+ **vid_rates,
|
|
|
|
|
+ 'channels': sorted(channels_data, key=lambda x: x['click'], reverse=True)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ # 计算该视频标题的相似度(取平均)和回流率
|
|
|
|
|
+ sim = vt_group[sim_col].mean()
|
|
|
|
|
+ vt_click = int(vt_group['点击uv'].sum())
|
|
|
|
|
+ vt_rates = calc_rates(vt_group, vt_click)
|
|
|
|
|
+
|
|
|
|
|
+ # 统计品类分布(每个品类有多少个不同视频ID)
|
|
|
|
|
+ categories_dist = []
|
|
|
|
|
+ if 'merge二级品类' in vt_group.columns:
|
|
|
|
|
+ cat_video_counts = vt_group.groupby('merge二级品类')['videoid'].nunique()
|
|
|
|
|
+ for cat, cnt in cat_video_counts.items():
|
|
|
|
|
+ if pd.notna(cat) and cat:
|
|
|
|
|
+ categories_dist.append({'name': str(cat), 'count': int(cnt)})
|
|
|
|
|
+ categories_dist.sort(key=lambda x: x['count'], reverse=True)
|
|
|
|
|
+
|
|
|
|
|
+ video_titles_data.append({
|
|
|
|
|
+ 'video_title': str(video_title)[:60] if pd.notna(video_title) else '',
|
|
|
|
|
+ 'sim': round(float(sim), 2),
|
|
|
|
|
+ 'categories': categories_dist,
|
|
|
|
|
+ 'total_click': vt_click,
|
|
|
|
|
+ **vt_rates,
|
|
|
|
|
+ 'videos': sorted(videos_data, key=lambda x: x['total_click'], reverse=True)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ st_click = int(title_data['点击uv'].sum())
|
|
|
|
|
+ st_rates = calc_rates(title_data, st_click)
|
|
|
|
|
+
|
|
|
|
|
+ result.append({
|
|
|
|
|
+ 'share_title': str(share_title)[:80] if pd.notna(share_title) else '',
|
|
|
|
|
+ 'cover': cover,
|
|
|
|
|
+ 'total_click': st_click,
|
|
|
|
|
+ **st_rates,
|
|
|
|
|
+ 'video_titles': sorted(video_titles_data, key=lambda x: x['sim'], reverse=True)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {'share_titles': result, 'count': len(result)}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ========== 视角2:视频 → 分享标题 ==========
|
|
|
|
|
+def build_video_view(input_df, min_share_titles=2, top_n=1000):
|
|
|
|
|
+ """
|
|
|
|
|
+ 构建反向视角数据:视频 → 分享标题 → 视频ID → 渠道
|
|
|
|
|
+ 筛选:同一视频被至少 min_share_titles 个不同分享标题引用
|
|
|
|
|
+ """
|
|
|
|
|
+ data = input_df[input_df[sim_col].notna()].copy()
|
|
|
|
|
+
|
|
|
|
|
+ # 按视频统计分享标题数
|
|
|
|
|
+ video_title_count = data.groupby('videoid')['分享标题'].nunique()
|
|
|
|
|
+ valid_videos = video_title_count[video_title_count >= min_share_titles].index
|
|
|
|
|
+ data = data[data['videoid'].isin(valid_videos)]
|
|
|
|
|
+
|
|
|
|
|
+ # 按总点击排序,取 top_n
|
|
|
|
|
+ video_clicks = data.groupby('videoid')['点击uv'].sum().nlargest(top_n)
|
|
|
|
|
+ valid_videos = video_clicks.index.tolist()
|
|
|
|
|
+
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for videoid in valid_videos:
|
|
|
|
|
+ video_data = data[data['videoid'] == videoid]
|
|
|
|
|
+
|
|
|
|
|
+ # 获取视频标题(取第一条)
|
|
|
|
|
+ video_title = video_data['title'].iloc[0] if not video_data.empty else ''
|
|
|
|
|
+
|
|
|
|
|
+ # 按分享标题分组
|
|
|
|
|
+ share_titles_data = []
|
|
|
|
|
+ for share_title, st_group in video_data.groupby('分享标题'):
|
|
|
|
|
+ # 获取封面
|
|
|
|
|
+ cover = ''
|
|
|
|
|
+ if '分享封面' in st_group.columns:
|
|
|
|
|
+ first_cover = st_group['分享封面'].dropna().iloc[0] if not st_group['分享封面'].dropna().empty else ''
|
|
|
|
|
+ cover = str(first_cover) if first_cover else ''
|
|
|
|
|
+
|
|
|
|
|
+ # 计算相似度
|
|
|
|
|
+ sim = st_group[sim_col].mean()
|
|
|
|
|
+
|
|
|
|
|
+ # 按渠道分组(这里简化,不再按videoid分,因为videoid已固定)
|
|
|
|
|
+ channels_data = []
|
|
|
|
|
+ for channel, ch_group in st_group.groupby('channel'):
|
|
|
|
|
+ click = int(ch_group['点击uv'].sum())
|
|
|
|
|
+ ch_rates = calc_rates(ch_group, click)
|
|
|
|
|
+ channels_data.append({
|
|
|
|
|
+ 'channel': str(channel) if pd.notna(channel) else '未知',
|
|
|
|
|
+ 'click': click,
|
|
|
|
|
+ **ch_rates
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ st_click = int(st_group['点击uv'].sum())
|
|
|
|
|
+ st_rates = calc_rates(st_group, st_click)
|
|
|
|
|
+ share_titles_data.append({
|
|
|
|
|
+ 'share_title': str(share_title)[:80] if pd.notna(share_title) else '',
|
|
|
|
|
+ 'cover': cover,
|
|
|
|
|
+ 'sim': round(float(sim), 2),
|
|
|
|
|
+ 'total_click': st_click,
|
|
|
|
|
+ **st_rates,
|
|
|
|
|
+ 'channels': sorted(channels_data, key=lambda x: x['click'], reverse=True)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ v_click = int(video_data['点击uv'].sum())
|
|
|
|
|
+ v_rates = calc_rates(video_data, v_click)
|
|
|
|
|
+ # 获取二级品类(取第一条)
|
|
|
|
|
+ category = ''
|
|
|
|
|
+ if 'merge二级品类' in video_data.columns:
|
|
|
|
|
+ cat_val = video_data['merge二级品类'].dropna().iloc[0] if not video_data['merge二级品类'].dropna().empty else ''
|
|
|
|
|
+ category = str(cat_val) if cat_val else ''
|
|
|
|
|
+ result.append({
|
|
|
|
|
+ 'videoid': str(int(videoid)) if pd.notna(videoid) else '',
|
|
|
|
|
+ 'video_title': str(video_title)[:60] if pd.notna(video_title) else '',
|
|
|
|
|
+ 'category': category,
|
|
|
|
|
+ 'total_click': v_click,
|
|
|
|
|
+ **v_rates,
|
|
|
|
|
+ 'share_titles': sorted(share_titles_data, key=lambda x: x['sim'], reverse=True)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {'videos': result, 'count': len(result)}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# 构建数据(按日期 + 全部)
|
|
|
|
|
+print("构建数据...")
|
|
|
|
|
+share_data_by_date = {}
|
|
|
|
|
+video_data_by_date = {}
|
|
|
|
|
+
|
|
|
|
|
+# 全部数据
|
|
|
|
|
+print(" [全部] 构建中...")
|
|
|
|
|
+share_data_by_date['all'] = build_share_title_view(df)
|
|
|
|
|
+video_data_by_date['all'] = build_video_view(df)
|
|
|
|
|
+print(f" 分享标题: {share_data_by_date['all']['count']}, 视频: {video_data_by_date['all']['count']}")
|
|
|
|
|
+
|
|
|
|
|
+# 按日期构建
|
|
|
|
|
+for dt in sorted(dates):
|
|
|
|
|
+ df_dt = df[df['dt'] == dt]
|
|
|
|
|
+ share_view = build_share_title_view(df_dt)
|
|
|
|
|
+ video_view = build_video_view(df_dt)
|
|
|
|
|
+ if share_view['count'] > 0 or video_view['count'] > 0:
|
|
|
|
|
+ share_data_by_date[dt] = share_view
|
|
|
|
|
+ video_data_by_date[dt] = video_view
|
|
|
|
|
+ print(f" [{dt}] 分享标题: {share_view['count']}, 视频: {video_view['count']}")
|
|
|
|
|
+
|
|
|
|
|
+# 转 JSON
|
|
|
|
|
+share_data_json = json.dumps(share_data_by_date, ensure_ascii=False)
|
|
|
|
|
+video_data_json = json.dumps(video_data_by_date, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+# 生成日期选项 HTML
|
|
|
|
|
+date_options = ['<option value="all" selected>全部</option>']
|
|
|
|
|
+for dt in sorted(dates):
|
|
|
|
|
+ label = f"{dt[4:6]}-{dt[6:8]}"
|
|
|
|
|
+ date_options.append(f'<option value="{dt}">{label}</option>')
|
|
|
|
|
+date_options_html = '\n'.join(date_options)
|
|
|
|
|
+
|
|
|
|
|
+html_content = f"""<!DOCTYPE html>
|
|
|
|
|
+<html>
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="utf-8">
|
|
|
|
|
+ <title>标题相关性分析 - 分层下钻</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
|
|
|
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
+ background: #f5f5f5; padding: 20px; }}
|
|
|
|
|
+ .container {{ width: 100%; max-width: none; }}
|
|
|
|
|
+ h1 {{ font-size: 24px; margin-bottom: 10px; color: #333; }}
|
|
|
|
|
+ .subtitle {{ color: #666; margin-bottom: 20px; font-size: 14px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* Tab 切换 */
|
|
|
|
|
+ .tabs {{ display: flex; gap: 10px; margin-bottom: 20px; }}
|
|
|
|
|
+ .tab {{ padding: 10px 20px; background: #e0e0e0; border: none; border-radius: 8px 8px 0 0;
|
|
|
|
|
+ cursor: pointer; font-size: 14px; color: #666; }}
|
|
|
|
|
+ .tab.active {{ background: white; color: #333; font-weight: bold; }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-content {{ display: none; background: white; border-radius: 0 8px 8px 8px;
|
|
|
|
|
+ padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
|
|
|
|
+ .tab-content.active {{ display: block; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 层级样式 */
|
|
|
|
|
+ .level {{ margin-left: 20px; border-left: 2px solid #e0e0e0; padding-left: 15px; }}
|
|
|
|
|
+ .level-0 {{ margin-left: 0; border-left: none; padding-left: 0; }}
|
|
|
|
|
+
|
|
|
|
|
+ .item {{ margin: 8px 0; }}
|
|
|
|
|
+ .item-header {{ display: flex; align-items: center; gap: 10px; padding: 10px;
|
|
|
|
|
+ background: #fafafa; border-radius: 6px; cursor: pointer;
|
|
|
|
|
+ border: 1px solid #e8e8e8; transition: background 0.2s; }}
|
|
|
|
|
+ .item-header:hover {{ background: #f0f0f0; }}
|
|
|
|
|
+ .item-header.expanded {{ background: #e8f4fc; border-color: #b3d9f2; }}
|
|
|
|
|
+
|
|
|
|
|
+ .toggle {{ width: 20px; color: #999; font-size: 12px; flex-shrink: 0; }}
|
|
|
|
|
+ .cover-thumb {{ width: 50px; height: 38px; object-fit: cover; border-radius: 4px;
|
|
|
|
|
+ flex-shrink: 0; background: #eee; cursor: pointer; }}
|
|
|
|
|
+ .item-title {{ flex: 1; font-size: 13px; color: #333;
|
|
|
|
|
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
|
|
|
|
+ .item-meta {{ display: flex; gap: 12px; font-size: 12px; color: #666; flex-shrink: 0; }}
|
|
|
|
|
+ .count {{ color: #999; font-weight: 500; }}
|
|
|
|
|
+ .sim {{ font-weight: bold; }}
|
|
|
|
|
+ .sim.high {{ color: #2e7d32; }}
|
|
|
|
|
+ .sim.mid {{ color: #f57c00; }}
|
|
|
|
|
+ .sim.low {{ color: #c62828; }}
|
|
|
|
|
+ .rate {{ font-weight: bold; padding: 2px 6px; border-radius: 3px; }}
|
|
|
|
|
+
|
|
|
|
|
+ .item-children {{ display: none; margin-top: 5px; }}
|
|
|
|
|
+ .item-children.show {{ display: block; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 渠道明细(最内层)*/
|
|
|
|
|
+ .channel-row {{ display: flex; gap: 15px; padding: 6px 10px; font-size: 12px;
|
|
|
|
|
+ background: #fafafa; margin: 4px 0; border-radius: 4px; }}
|
|
|
|
|
+ .channel-name {{ width: 100px; color: #666; }}
|
|
|
|
|
+ .channel-metrics {{ display: flex; gap: 15px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 视频链接 */
|
|
|
|
|
+ .video-link {{ color: #667eea; text-decoration: none; font-size: 12px; }}
|
|
|
|
|
+ .video-link:hover {{ text-decoration: underline; }}
|
|
|
|
|
+ .category {{ font-size: 11px; color: #666; background: #f0f0f0; padding: 2px 6px;
|
|
|
|
|
+ border-radius: 3px; margin-left: 6px; }}
|
|
|
|
|
+ .categories-dist {{ margin-left: 8px; }}
|
|
|
|
|
+ .cat-tag {{ font-size: 10px; color: #fff; background: #78909c; padding: 1px 5px;
|
|
|
|
|
+ border-radius: 2px; margin-right: 4px; white-space: nowrap; }}
|
|
|
|
|
+ .rank {{ font-size: 11px; color: #999; font-weight: bold; min-width: 28px; flex-shrink: 0; }}
|
|
|
|
|
+ .controls input[type="number"] {{ padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 图片模态框 */
|
|
|
|
|
+ .img-modal {{ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
|
|
|
+ background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }}
|
|
|
|
|
+ .img-modal img {{ max-width: 90%; max-height: 90%; border-radius: 8px; }}
|
|
|
|
|
+ .img-modal.show {{ display: flex; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 统计信息 */
|
|
|
|
|
+ .stats {{ font-size: 13px; color: #666; margin-bottom: 15px; padding: 10px;
|
|
|
|
|
+ background: #fff3e0; border-radius: 6px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 日期筛选 */
|
|
|
|
|
+ .controls {{ display: flex; gap: 20px; margin-bottom: 20px; padding: 15px; background: white;
|
|
|
|
|
+ border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); align-items: center; }}
|
|
|
|
|
+ .control-group {{ display: flex; align-items: center; gap: 8px; }}
|
|
|
|
|
+ .control-group label {{ font-weight: 500; color: #666; font-size: 13px; }}
|
|
|
|
|
+ .controls select {{ padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 100px; }}
|
|
|
|
|
+ .date-switcher {{ display: flex; align-items: center; gap: 5px; }}
|
|
|
|
|
+ .date-switcher button {{ padding: 5px 10px; border: 1px solid #ddd; background: white;
|
|
|
|
|
+ cursor: pointer; border-radius: 3px; }}
|
|
|
|
|
+ .date-switcher button:hover {{ background: #f0f0f0; }}
|
|
|
|
|
+ .play-btn {{ background: #4CAF50; color: white; border: none; border-radius: 4px;
|
|
|
|
|
+ padding: 6px 12px; font-size: 13px; cursor: pointer; }}
|
|
|
|
|
+ .play-btn:hover {{ background: #45a049; }}
|
|
|
|
|
+ .play-btn.playing {{ background: #f44336; }}
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <div class="container">
|
|
|
|
|
+ <h1>标题相关性分析 v3 - 裂变率版</h1>
|
|
|
|
|
+ <p class="subtitle">点击展开/收起各层级,分析分享标题与视频标题的相似度对裂变率的影响</p>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="controls" style="flex-wrap: wrap;">
|
|
|
|
|
+ <div class="control-group date-switcher">
|
|
|
|
|
+ <label>日期:</label>
|
|
|
|
|
+ <button onclick="switchDate(-1)">◀</button>
|
|
|
|
|
+ <select id="date-select" onchange="onFilterChange()">{date_options_html}</select>
|
|
|
|
|
+ <button onclick="switchDate(1)">▶</button>
|
|
|
|
|
+ <button id="play-btn" class="play-btn" onclick="togglePlay()">▶ 播放</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>渠道:</label>
|
|
|
|
|
+ <select id="channel-select" onchange="onFilterChange()">
|
|
|
|
|
+ <option value="all" selected>全部</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>场景:</label>
|
|
|
|
|
+ <select id="scene-select" onchange="onFilterChange()">
|
|
|
|
|
+ <option value="all" selected>全部</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>人群包:</label>
|
|
|
|
|
+ <select id="crowd-select" onchange="onFilterChange()">
|
|
|
|
|
+ <option value="all" selected>全部</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>创意:</label>
|
|
|
|
|
+ <select id="creative-select" onchange="onFilterChange()">
|
|
|
|
|
+ <option value="all" selected>全部</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>品类:</label>
|
|
|
|
|
+ <select id="category-select" onchange="onFilterChange()">
|
|
|
|
|
+ <option value="all" selected>全部</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>最小点击:</label>
|
|
|
|
|
+ <input type="number" id="min-click-input" value="100" min="0" step="100" style="width:70px" onchange="onFilterChange()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>下级≥:</label>
|
|
|
|
|
+ <input type="number" id="min-children-input" value="2" min="1" max="20" style="width:50px" onchange="onFilterChange()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>下级点击≥:</label>
|
|
|
|
|
+ <input type="number" id="min-child-click-input" value="10" min="0" step="10" style="width:60px" onchange="onFilterChange()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>下级排序:</label>
|
|
|
|
|
+ <select id="child-sort-select" onchange="onFilterChange()">
|
|
|
|
|
+ <option value="click" selected>点击UV</option>
|
|
|
|
|
+ <option value="sim">相似度</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="control-group">
|
|
|
|
|
+ <label>Top:</label>
|
|
|
|
|
+ <input type="number" id="top-input" value="50" min="1" max="500" style="width:60px" onchange="onFilterChange()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="tabs">
|
|
|
|
|
+ <button class="tab active" onclick="switchTab('view1')">分享标题 → 视频</button>
|
|
|
|
|
+ <button class="tab" onclick="switchTab('view2')">视频 → 分享标题</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="view1" class="tab-content active">
|
|
|
|
|
+ <div class="stats">视角1:同一分享标题配不同视频的效果对比</div>
|
|
|
|
|
+ <div id="share-title-list" class="level level-0"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="view2" class="tab-content">
|
|
|
|
|
+ <div class="stats">视角2:同一视频用不同分享标题推广的效果对比</div>
|
|
|
|
|
+ <div id="video-list" class="level level-0"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="imgModal" class="img-modal" onclick="this.classList.remove('show')">
|
|
|
|
|
+ <img id="modalImg" src="" alt="封面预览">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <script>
|
|
|
|
|
+ const allDates = {dates_json};
|
|
|
|
|
+ const allChannels = {channels_json};
|
|
|
|
|
+ const allScenes = {scenes_json};
|
|
|
|
|
+ const allCrowds = {crowds_json};
|
|
|
|
|
+ const allCreatives = {creatives_json};
|
|
|
|
|
+ const allCategories = {categories_json};
|
|
|
|
|
+ const shareDataByDate = {share_data_json};
|
|
|
|
|
+ const videoDataByDate = {video_data_json};
|
|
|
|
|
+
|
|
|
|
|
+ let playInterval = null;
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化下拉框通用函数
|
|
|
|
|
+ function initSelect(selectId, items) {{
|
|
|
|
|
+ const select = document.getElementById(selectId);
|
|
|
|
|
+ items.forEach(item => {{
|
|
|
|
|
+ const opt = document.createElement('option');
|
|
|
|
|
+ opt.value = item;
|
|
|
|
|
+ opt.textContent = item.length > 20 ? item.substring(0, 20) + '...' : item;
|
|
|
|
|
+ opt.title = item;
|
|
|
|
|
+ select.appendChild(opt);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化所有筛选下拉框
|
|
|
|
|
+ function initChannelSelect() {{ initSelect('channel-select', allChannels); }}
|
|
|
|
|
+ function initSceneSelect() {{ initSelect('scene-select', allScenes); }}
|
|
|
|
|
+ function initCrowdSelect() {{ initSelect('crowd-select', allCrowds); }}
|
|
|
|
|
+ function initCreativeSelect() {{ initSelect('creative-select', allCreatives); }}
|
|
|
|
|
+ function initCategorySelect() {{ initSelect('category-select', allCategories); }}
|
|
|
|
|
+
|
|
|
|
|
+ // 获取当前筛选条件
|
|
|
|
|
+ function getFilters() {{
|
|
|
|
|
+ return {{
|
|
|
|
|
+ date: document.getElementById('date-select').value,
|
|
|
|
|
+ channel: document.getElementById('channel-select').value,
|
|
|
|
|
+ scene: document.getElementById('scene-select').value,
|
|
|
|
|
+ crowd: document.getElementById('crowd-select').value,
|
|
|
|
|
+ creative: document.getElementById('creative-select').value,
|
|
|
|
|
+ category: document.getElementById('category-select').value,
|
|
|
|
|
+ minClick: parseInt(document.getElementById('min-click-input').value) || 0,
|
|
|
|
|
+ minChildren: parseInt(document.getElementById('min-children-input').value) || 2,
|
|
|
|
|
+ minChildClick: parseInt(document.getElementById('min-child-click-input').value) || 10,
|
|
|
|
|
+ childSort: document.getElementById('child-sort-select').value,
|
|
|
|
|
+ top: parseInt(document.getElementById('top-input').value) || 50
|
|
|
|
|
+ }};
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 重算裂变率指标
|
|
|
|
|
+ function recalcRates(item, children, clickKey = 'click') {{
|
|
|
|
|
+ item.fission_uv = children.reduce((s, c) => s + (c.fission_uv || 0), 0);
|
|
|
|
|
+ item.head_fission_uv = children.reduce((s, c) => s + (c.head_fission_uv || 0), 0);
|
|
|
|
|
+ item.head_video_fission_uv = children.reduce((s, c) => s + (c.head_video_fission_uv || 0), 0);
|
|
|
|
|
+ item.rec_fission_uv = children.reduce((s, c) => s + (c.rec_fission_uv || 0), 0);
|
|
|
|
|
+ item.enter_rec_uv = children.reduce((s, c) => s + (c.enter_rec_uv || 0), 0);
|
|
|
|
|
+ const click = item.total_click || 0;
|
|
|
|
|
+ const divisor = click + 10;
|
|
|
|
|
+ item.fission_rate = divisor > 0 ? item.fission_uv / divisor : 0;
|
|
|
|
|
+ item.head_fission_rate = divisor > 0 ? item.head_fission_uv / divisor : 0;
|
|
|
|
|
+ item.head_video_fission_rate = divisor > 0 ? item.head_video_fission_uv / divisor : 0;
|
|
|
|
|
+ item.rec_fission_rate = divisor > 0 ? item.rec_fission_uv / divisor : 0;
|
|
|
|
|
+ item.enter_rec_rate = click > 0 ? item.enter_rec_uv / click : 0;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 按渠道过滤数据并重新计算统计(递归)
|
|
|
|
|
+ function filterByChannel(data, channel) {{
|
|
|
|
|
+ if (channel === 'all') return JSON.parse(JSON.stringify(data));
|
|
|
|
|
+
|
|
|
|
|
+ // 深拷贝
|
|
|
|
|
+ const filtered = JSON.parse(JSON.stringify(data));
|
|
|
|
|
+
|
|
|
|
|
+ // 视角1: share_titles -> video_titles -> videos -> channels
|
|
|
|
|
+ if (filtered.share_titles) {{
|
|
|
|
|
+ filtered.share_titles = filtered.share_titles.map(st => {{
|
|
|
|
|
+ st.video_titles = st.video_titles.map(vt => {{
|
|
|
|
|
+ vt.videos = vt.videos.map(v => {{
|
|
|
|
|
+ // 过滤渠道
|
|
|
|
|
+ v.channels = v.channels.filter(ch => ch.channel === channel);
|
|
|
|
|
+ // 重算视频层统计
|
|
|
|
|
+ v.total_click = v.channels.reduce((s, c) => s + c.click, 0);
|
|
|
|
|
+ recalcRates(v, v.channels);
|
|
|
|
|
+ return v;
|
|
|
|
|
+ }}).filter(v => v.channels.length > 0);
|
|
|
|
|
+ // 重算视频标题层统计
|
|
|
|
|
+ vt.total_click = vt.videos.reduce((s, v) => s + v.total_click, 0);
|
|
|
|
|
+ recalcRates(vt, vt.videos);
|
|
|
|
|
+ return vt;
|
|
|
|
|
+ }}).filter(vt => vt.videos.length > 0);
|
|
|
|
|
+ // 重算分享标题层统计
|
|
|
|
|
+ st.total_click = st.video_titles.reduce((s, vt) => s + vt.total_click, 0);
|
|
|
|
|
+ recalcRates(st, st.video_titles);
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }}).filter(st => st.video_titles.length > 0);
|
|
|
|
|
+ // 按点击重新排序
|
|
|
|
|
+ filtered.share_titles.sort((a, b) => b.total_click - a.total_click);
|
|
|
|
|
+ filtered.count = filtered.share_titles.length;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 视角2: videos -> share_titles -> channels
|
|
|
|
|
+ if (filtered.videos) {{
|
|
|
|
|
+ filtered.videos = filtered.videos.map(v => {{
|
|
|
|
|
+ v.share_titles = v.share_titles.map(st => {{
|
|
|
|
|
+ // 过滤渠道
|
|
|
|
|
+ st.channels = st.channels.filter(ch => ch.channel === channel);
|
|
|
|
|
+ // 重算分享标题层统计
|
|
|
|
|
+ st.total_click = st.channels.reduce((s, c) => s + c.click, 0);
|
|
|
|
|
+ recalcRates(st, st.channels);
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }}).filter(st => st.channels.length > 0);
|
|
|
|
|
+ // 重算视频层统计
|
|
|
|
|
+ v.total_click = v.share_titles.reduce((s, st) => s + st.total_click, 0);
|
|
|
|
|
+ recalcRates(v, v.share_titles);
|
|
|
|
|
+ return v;
|
|
|
|
|
+ }}).filter(v => v.share_titles.length > 0);
|
|
|
|
|
+ // 按点击重新排序
|
|
|
|
|
+ filtered.videos.sort((a, b) => b.total_click - a.total_click);
|
|
|
|
|
+ filtered.count = filtered.videos.length;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ return filtered;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 按品类过滤数据(两个视角逻辑不同)
|
|
|
|
|
+ function filterByCategory(shareData, videoData, category) {{
|
|
|
|
|
+ if (category === 'all') {{
|
|
|
|
|
+ return {{ share: shareData, video: videoData }};
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 深拷贝
|
|
|
|
|
+ let filteredShare = JSON.parse(JSON.stringify(shareData));
|
|
|
|
|
+ let filteredVideo = JSON.parse(JSON.stringify(videoData));
|
|
|
|
|
+
|
|
|
|
|
+ // 视角1:筛选包含该品类视频的分享标题(但保留所有内容不过滤)
|
|
|
|
|
+ if (filteredShare.share_titles) {{
|
|
|
|
|
+ filteredShare.share_titles = filteredShare.share_titles.filter(st => {{
|
|
|
|
|
+ // 检查是否包含该品类的视频
|
|
|
|
|
+ return st.video_titles.some(vt =>
|
|
|
|
|
+ vt.videos.some(v => v.category === category)
|
|
|
|
|
+ );
|
|
|
|
|
+ }});
|
|
|
|
|
+ filteredShare.share_titles.sort((a, b) => b.total_click - a.total_click);
|
|
|
|
|
+ filteredShare.count = filteredShare.share_titles.length;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 视角2:直接筛选该品类的视频
|
|
|
|
|
+ if (filteredVideo.videos) {{
|
|
|
|
|
+ filteredVideo.videos = filteredVideo.videos.filter(v => v.category === category);
|
|
|
|
|
+ filteredVideo.videos.sort((a, b) => b.total_click - a.total_click);
|
|
|
|
|
+ filteredVideo.count = filteredVideo.videos.length;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ return {{ share: filteredShare, video: filteredVideo }};
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 获取当前选中日期的数据(带筛选)
|
|
|
|
|
+ function getCurrentData() {{
|
|
|
|
|
+ const filters = getFilters();
|
|
|
|
|
+ let shareData = shareDataByDate[filters.date] || {{ share_titles: [], count: 0 }};
|
|
|
|
|
+ let videoData = videoDataByDate[filters.date] || {{ videos: [], count: 0 }};
|
|
|
|
|
+
|
|
|
|
|
+ // 按渠道过滤(会重新计算统计)
|
|
|
|
|
+ shareData = filterByChannel(shareData, filters.channel);
|
|
|
|
|
+ videoData = filterByChannel(videoData, filters.channel);
|
|
|
|
|
+
|
|
|
|
|
+ // 按品类过滤(两个视角逻辑不同)
|
|
|
|
|
+ const categoryFiltered = filterByCategory(shareData, videoData, filters.category);
|
|
|
|
|
+ shareData = categoryFiltered.share;
|
|
|
|
|
+ videoData = categoryFiltered.video;
|
|
|
|
|
+
|
|
|
|
|
+ // 下级排序函数
|
|
|
|
|
+ const childSortFn = filters.childSort === 'click'
|
|
|
|
|
+ ? (a, b) => b.total_click - a.total_click
|
|
|
|
|
+ : (a, b) => b.sim - a.sim;
|
|
|
|
|
+
|
|
|
|
|
+ // 按最小点击UV过滤 + 下级点击过滤 + 下级排序
|
|
|
|
|
+ if (shareData.share_titles) {{
|
|
|
|
|
+ shareData.share_titles = shareData.share_titles
|
|
|
|
|
+ .filter(st => st.total_click >= filters.minClick)
|
|
|
|
|
+ .map(st => {{
|
|
|
|
|
+ // 下级点击过滤 + 排序
|
|
|
|
|
+ st.video_titles = st.video_titles
|
|
|
|
|
+ .filter(vt => vt.total_click >= filters.minChildClick)
|
|
|
|
|
+ .sort(childSortFn);
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }})
|
|
|
|
|
+ .filter(st => st.video_titles.length >= filters.minChildren); // 下级数量筛选
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (videoData.videos) {{
|
|
|
|
|
+ videoData.videos = videoData.videos
|
|
|
|
|
+ .filter(v => v.total_click >= filters.minClick)
|
|
|
|
|
+ .map(v => {{
|
|
|
|
|
+ // 下级点击过滤 + 排序
|
|
|
|
|
+ v.share_titles = v.share_titles
|
|
|
|
|
+ .filter(st => st.total_click >= filters.minChildClick)
|
|
|
|
|
+ .sort(childSortFn);
|
|
|
|
|
+ return v;
|
|
|
|
|
+ }})
|
|
|
|
|
+ .filter(v => v.share_titles.length >= filters.minChildren); // 下级数量筛选
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 记录真实数量(过滤后、截取前)
|
|
|
|
|
+ const realShareCount = shareData.share_titles ? shareData.share_titles.length : 0;
|
|
|
|
|
+ const realVideoCount = videoData.videos ? videoData.videos.length : 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 按 top 截取
|
|
|
|
|
+ if (shareData.share_titles) {{
|
|
|
|
|
+ shareData.share_titles = shareData.share_titles.slice(0, filters.top);
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (videoData.videos) {{
|
|
|
|
|
+ videoData.videos = videoData.videos.slice(0, filters.top);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ return {{
|
|
|
|
|
+ share: {{ ...shareData, realCount: realShareCount, showCount: shareData.share_titles ? shareData.share_titles.length : 0 }},
|
|
|
|
|
+ video: {{ ...videoData, realCount: realVideoCount, showCount: videoData.videos ? videoData.videos.length : 0 }}
|
|
|
|
|
+ }};
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 日期切换
|
|
|
|
|
+ function switchDate(delta) {{
|
|
|
|
|
+ const select = document.getElementById('date-select');
|
|
|
|
|
+ const newIndex = select.selectedIndex + delta;
|
|
|
|
|
+ if (newIndex >= 0 && newIndex < select.options.length) {{
|
|
|
|
|
+ select.selectedIndex = newIndex;
|
|
|
|
|
+ onDateChange();
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function onDateChange() {{
|
|
|
|
|
+ renderShareTitleView();
|
|
|
|
|
+ renderVideoView();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 筛选条件变更(日期/渠道/数量)
|
|
|
|
|
+ function onFilterChange() {{
|
|
|
|
|
+ renderShareTitleView();
|
|
|
|
|
+ renderVideoView();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 播放功能
|
|
|
|
|
+ function togglePlay() {{
|
|
|
|
|
+ const btn = document.getElementById('play-btn');
|
|
|
|
|
+ if (playInterval) {{
|
|
|
|
|
+ clearInterval(playInterval);
|
|
|
|
|
+ playInterval = null;
|
|
|
|
|
+ btn.classList.remove('playing');
|
|
|
|
|
+ btn.textContent = '▶ 播放';
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ btn.classList.add('playing');
|
|
|
|
|
+ btn.textContent = '■ 停止';
|
|
|
|
|
+ playInterval = setInterval(() => {{
|
|
|
|
|
+ const select = document.getElementById('date-select');
|
|
|
|
|
+ if (select.selectedIndex < select.options.length - 1) {{
|
|
|
|
|
+ select.selectedIndex++;
|
|
|
|
|
+ onDateChange();
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ togglePlay(); // 停止
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}, 2000);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function switchTab(tabId) {{
|
|
|
|
|
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
|
|
|
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
|
+ document.querySelector(`[onclick="switchTab('${{tabId}}')"]`).classList.add('active');
|
|
|
|
|
+ document.getElementById(tabId).classList.add('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function showImgModal(url) {{
|
|
|
|
|
+ if (!url) return;
|
|
|
|
|
+ document.getElementById('modalImg').src = url;
|
|
|
|
|
+ document.getElementById('imgModal').classList.add('show');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function toggleItem(el) {{
|
|
|
|
|
+ const header = el;
|
|
|
|
|
+ const children = header.nextElementSibling;
|
|
|
|
|
+ if (children && children.classList.contains('item-children')) {{
|
|
|
|
|
+ const isExpanded = children.classList.contains('show');
|
|
|
|
|
+ children.classList.toggle('show');
|
|
|
|
|
+ header.classList.toggle('expanded');
|
|
|
|
|
+ header.querySelector('.toggle').textContent = isExpanded ? '▶' : '▼';
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function simClass(sim) {{
|
|
|
|
|
+ return sim >= 0.8 ? 'high' : (sim >= 0.5 ? 'mid' : 'low');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 回流率渐变色(0% -> 白色, 30%+ -> 深绿)
|
|
|
|
|
+ function rateGradient(rate) {{
|
|
|
|
|
+ const maxRate = 0.30; // 30% 为最大值
|
|
|
|
|
+ const ratio = Math.min(rate / maxRate, 1);
|
|
|
|
|
+ // 从白色 (255,255,255) 渐变到绿色 (200,230,201) 再到深绿 (46,125,50)
|
|
|
|
|
+ let r, g, b;
|
|
|
|
|
+ if (ratio < 0.5) {{
|
|
|
|
|
+ // 白色到浅绿
|
|
|
|
|
+ const t = ratio * 2;
|
|
|
|
|
+ r = Math.round(255 - t * 55);
|
|
|
|
|
+ g = Math.round(255 - t * 25);
|
|
|
|
|
+ b = Math.round(255 - t * 54);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 浅绿到深绿
|
|
|
|
|
+ const t = (ratio - 0.5) * 2;
|
|
|
|
|
+ r = Math.round(200 - t * 154);
|
|
|
|
|
+ g = Math.round(230 - t * 105);
|
|
|
|
|
+ b = Math.round(201 - t * 151);
|
|
|
|
|
+ }}
|
|
|
|
|
+ const textColor = ratio > 0.6 ? 'white' : '#333';
|
|
|
|
|
+ return `background:rgb(${{r}},${{g}},${{b}});color:${{textColor}}`;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function rateClass(rate, allRates) {{
|
|
|
|
|
+ if (!allRates || allRates.length === 0) return '';
|
|
|
|
|
+ const sorted = [...allRates].sort((a, b) => a - b);
|
|
|
|
|
+ const q33 = sorted[Math.floor(sorted.length * 0.33)];
|
|
|
|
|
+ const q66 = sorted[Math.floor(sorted.length * 0.66)];
|
|
|
|
|
+ return rate >= q66 ? 'good' : (rate <= q33 ? 'bad' : '');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染裂变率指标(4位小数)
|
|
|
|
|
+ function renderRates(item) {{
|
|
|
|
|
+ return `
|
|
|
|
|
+ <span class="rate" style="${{rateGradient(item.fission_rate)}}">裂变 ${{(item.fission_rate || 0).toFixed(4)}}</span>
|
|
|
|
|
+ <span class="rate" style="${{rateGradient(item.head_fission_rate)}}">头部 ${{(item.head_fission_rate || 0).toFixed(4)}}</span>
|
|
|
|
|
+ <span class="rate" style="${{rateGradient(item.head_video_fission_rate)}}">原视频 ${{(item.head_video_fission_rate || 0).toFixed(4)}}</span>
|
|
|
|
|
+ <span class="rate" style="${{rateGradient(item.rec_fission_rate)}}">推荐 ${{(item.rec_fission_rate || 0).toFixed(4)}}</span>
|
|
|
|
|
+ <span class="rate" style="${{rateGradient(item.enter_rec_rate)}}">进推 ${{(item.enter_rec_rate || 0).toFixed(4)}}</span>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染渠道明细
|
|
|
|
|
+ function renderChannels(channels) {{
|
|
|
|
|
+ return channels.map(ch => `
|
|
|
|
|
+ <div class="channel-row">
|
|
|
|
|
+ <span class="channel-name">${{ch.channel}}</span>
|
|
|
|
|
+ <div class="channel-metrics">
|
|
|
|
|
+ <span>点击 ${{ch.click.toLocaleString()}}</span>
|
|
|
|
|
+ ${{renderRates(ch)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 视角1:分享标题 → 视频 ==========
|
|
|
|
|
+ function renderShareTitleView() {{
|
|
|
|
|
+ const container = document.getElementById('share-title-list');
|
|
|
|
|
+ const data = getCurrentData().share;
|
|
|
|
|
+ if (!data.share_titles || data.share_titles.length === 0) {{
|
|
|
|
|
+ container.innerHTML = '<p style="color:#999;">该日期数据不足</p>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = `<p style="margin-bottom:10px;">共 ${{data.realCount}} 个可对比分享标题,当前显示 ${{data.showCount}} 个</p>` +
|
|
|
|
|
+ data.share_titles.map((st, idx) => `
|
|
|
|
|
+ <div class="item">
|
|
|
|
|
+ <div class="item-header" onclick="toggleItem(this)">
|
|
|
|
|
+ <span class="toggle">▶</span>
|
|
|
|
|
+ <span class="rank">#${{idx + 1}}</span>
|
|
|
|
|
+ ${{st.cover ? `<img class="cover-thumb" src="${{st.cover}}" loading="lazy" onclick="event.stopPropagation();showImgModal('${{st.cover}}')" onerror="this.style.display='none'">` : ''}}
|
|
|
|
|
+ <span class="item-title" title="${{st.share_title}}">${{st.share_title}}</span>
|
|
|
|
|
+ <div class="item-meta">
|
|
|
|
|
+ <span class="count">·${{st.video_titles.length}}</span>
|
|
|
|
|
+ <span>点击 ${{st.total_click.toLocaleString()}}</span>
|
|
|
|
|
+ ${{renderRates(st)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="item-children">
|
|
|
|
|
+ <div class="level">
|
|
|
|
|
+ ${{st.video_titles.map(vt => `
|
|
|
|
|
+ <div class="item">
|
|
|
|
|
+ <div class="item-header" onclick="toggleItem(this)">
|
|
|
|
|
+ <span class="toggle">▶</span>
|
|
|
|
|
+ <span class="item-title">
|
|
|
|
|
+ ${{vt.video_title}}
|
|
|
|
|
+ ${{vt.categories && vt.categories.length > 0 ? `<span class="categories-dist">${{vt.categories.map(c => `<span class="cat-tag">${{c.name}}:${{c.count}}</span>`).join('')}}</span>` : ''}}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div class="item-meta">
|
|
|
|
|
+ <span class="count">·${{vt.videos.length}}</span>
|
|
|
|
|
+ <span class="sim ${{simClass(vt.sim)}}">相似度 ${{vt.sim.toFixed(2)}}</span>
|
|
|
|
|
+ <span>点击 ${{vt.total_click.toLocaleString()}}</span>
|
|
|
|
|
+ ${{renderRates(vt)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="item-children">
|
|
|
|
|
+ <div class="level">
|
|
|
|
|
+ ${{vt.videos.map(v => `
|
|
|
|
|
+ <div class="item">
|
|
|
|
|
+ <div class="item-header" onclick="toggleItem(this)">
|
|
|
|
|
+ <span class="toggle">▶</span>
|
|
|
|
|
+ <span class="item-title">
|
|
|
|
|
+ <a class="video-link" href="https://admin.piaoquantv.com/cms/post-detail/${{v.videoid}}/detail" target="_blank" onclick="event.stopPropagation()">[${{v.videoid}}]</a>
|
|
|
|
|
+ ${{v.category ? `<span class="category">${{v.category}}</span>` : ''}}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div class="item-meta">
|
|
|
|
|
+ <span class="count">·${{v.channels.length}}</span>
|
|
|
|
|
+ <span>点击 ${{v.total_click.toLocaleString()}}</span>
|
|
|
|
|
+ ${{renderRates(v)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="item-children">
|
|
|
|
|
+ <div class="level">
|
|
|
|
|
+ ${{renderChannels(v.channels)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('')}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('')}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 视角2:视频 → 分享标题 ==========
|
|
|
|
|
+ function renderVideoView() {{
|
|
|
|
|
+ const container = document.getElementById('video-list');
|
|
|
|
|
+ const data = getCurrentData().video;
|
|
|
|
|
+ if (!data.videos || data.videos.length === 0) {{
|
|
|
|
|
+ container.innerHTML = '<p style="color:#999;">该日期数据不足</p>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = `<p style="margin-bottom:10px;">共 ${{data.realCount}} 个可对比视频,当前显示 ${{data.showCount}} 个</p>` +
|
|
|
|
|
+ data.videos.map((v, idx) => `
|
|
|
|
|
+ <div class="item">
|
|
|
|
|
+ <div class="item-header" onclick="toggleItem(this)">
|
|
|
|
|
+ <span class="toggle">▶</span>
|
|
|
|
|
+ <span class="rank">#${{idx + 1}}</span>
|
|
|
|
|
+ <span class="item-title">
|
|
|
|
|
+ <a class="video-link" href="https://admin.piaoquantv.com/cms/post-detail/${{v.videoid}}/detail" target="_blank" onclick="event.stopPropagation()">[${{v.videoid}}]</a>
|
|
|
|
|
+ ${{v.category ? `<span class="category">${{v.category}}</span>` : ''}}
|
|
|
|
|
+ ${{v.video_title}}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div class="item-meta">
|
|
|
|
|
+ <span class="count">·${{v.share_titles.length}}</span>
|
|
|
|
|
+ <span>点击 ${{v.total_click.toLocaleString()}}</span>
|
|
|
|
|
+ ${{renderRates(v)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="item-children">
|
|
|
|
|
+ <div class="level">
|
|
|
|
|
+ ${{v.share_titles.map(st => `
|
|
|
|
|
+ <div class="item">
|
|
|
|
|
+ <div class="item-header" onclick="toggleItem(this)">
|
|
|
|
|
+ <span class="toggle">▶</span>
|
|
|
|
|
+ ${{st.cover ? `<img class="cover-thumb" src="${{st.cover}}" loading="lazy" onclick="event.stopPropagation();showImgModal('${{st.cover}}')" onerror="this.style.display='none'">` : ''}}
|
|
|
|
|
+ <span class="item-title" title="${{st.share_title}}">${{st.share_title}}</span>
|
|
|
|
|
+ <div class="item-meta">
|
|
|
|
|
+ <span class="count">·${{st.channels.length}}</span>
|
|
|
|
|
+ <span class="sim ${{simClass(st.sim)}}">相似度 ${{st.sim.toFixed(2)}}</span>
|
|
|
|
|
+ <span>点击 ${{st.total_click.toLocaleString()}}</span>
|
|
|
|
|
+ ${{renderRates(st)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="item-children">
|
|
|
|
|
+ <div class="level">
|
|
|
|
|
+ ${{renderChannels(st.channels)}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('')}}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化
|
|
|
|
|
+ initChannelSelect();
|
|
|
|
|
+ initSceneSelect();
|
|
|
|
|
+ initCrowdSelect();
|
|
|
|
|
+ initCreativeSelect();
|
|
|
|
|
+ initCategorySelect();
|
|
|
|
|
+ renderShareTitleView();
|
|
|
|
|
+ renderVideoView();
|
|
|
|
|
+ </script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+html_file = output_dir / "标题相关性分析.html"
|
|
|
|
|
+with open(html_file, 'w', encoding='utf-8') as f:
|
|
|
|
|
+ f.write(html_content)
|
|
|
|
|
+
|
|
|
|
|
+print(f"\nHTML 报告已生成: {html_file}")
|