amz_review_analyse/voc_业务_2/echarts_builder.py
OnesvmWhoops cb6692c0c0 新增 voc_业务_2 报告流水线,并完善聚类与词频模块。
包含 Persona 锚定聚类、LLM 报告生成、方法论文档及 .gitignore 更新,便于在 Gitee 独立部署。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 17:12:39 +08:00

727 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
ECharts 图表构建器:生成内联 ECharts JS 代码和静态库嵌入。
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("voc.echarts")
# ECharts CDN(用于下载内联)
ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"
CACHE_FILE = Path(__file__).resolve().parent / ".echarts_cache.js"
def get_echarts_script(inline: bool = True) -> str:
"""返回 ECharts 库的 <script> 标签。inline=True 则内联,否则用 CDN。"""
if not inline:
return f'<script src="{ECHARTS_CDN}"></script>'
# 内联模式:从缓存或下载
if CACHE_FILE.is_file():
js = CACHE_FILE.read_text(encoding="utf-8")
logger.info("使用缓存的 ECharts (%s KB)", len(js) // 1024)
return f"<script>\n{js}\n</script>"
# 下载
logger.info("下载 ECharts 库...")
import urllib.request
try:
with urllib.request.urlopen(ECHARTS_CDN, timeout=30) as resp:
js = resp.read().decode("utf-8")
CACHE_FILE.write_text(js, encoding="utf-8")
logger.info("ECharts 已缓存 (%s KB)", len(js) // 1024)
return f"<script>\n{js}\n</script>"
except Exception as e:
logger.warning("下载 ECharts 失败: %s,降级到 CDN", e)
return f'<script src="{ECHARTS_CDN}"></script>'
# ── 图表 JS 生成 ──
ASIN_COLORS_BAR = ["#ef4444", "#f97316", "#eab308", "#8b5cf6", "#3b82f6"]
ASIN_COLORS_POS = ["#22c55e", "#86efac", "#4ade80", "#2dd4bf", "#60a5fa"]
def build_star_dist_chart(asins: List[str], star_data: Dict[str, Dict[int, int]]) -> str:
"""评分分布堆叠柱状图。"""
datasets = []
colors = ["#22c55e", "#86efac", "#d1d5db", "#fbbf24", "#ef4444"]
for star in [5, 4, 3, 2, 1]:
datasets.append({
"name": f"{star}★",
"type": "bar",
"stack": "total",
"data": [star_data.get(a, {}).get(star, 0) for a in asins],
"itemStyle": {"color": colors[5 - star]},
})
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"bottom": 0, "textStyle": {"fontSize": 11}},
"grid": {"left": 50, "right": 20, "top": 20, "bottom": 40},
"xAxis": {"type": "category", "data": asins, "axisLabel": {"fontSize": 10}},
"yAxis": {"type": "value"},
"series": datasets,
}
return f"""new Chart(echarts.init(document.getElementById('starDistChart')), {{
type: 'bar',
data: {json.dumps(asins)},
datasets: {json.dumps(datasets, ensure_ascii=False)},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ position: 'bottom', labels: {{ font: {{ size: 11 }} }} }} }},
scales: {{ x: {{ stacked: true }}, y: {{ stacked: true, beginAtZero: true }} }}
}}
}});
// ECharts 版本:
(function() {{
var dom = document.getElementById('starDistChart');
var chart = echarts.init(dom);
chart.setOption({json.dumps(option, ensure_ascii=False)});
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def build_horizontal_bar_chart(
element_id: str,
labels: List[str],
data: List[int],
colors: List[str] | None = None,
height: int = 360,
*,
asin_category_axis: Optional[str] = None,
) -> str:
"""水平柱状图。asin_category_axis='y' 时 Y 轴短码 tooltip 显示 ASIN 链接。"""
if colors is None:
colors = ["#ef4444"] * len(labels)
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"grid": {"left": 150, "right": 40, "top": 10, "bottom": 20},
"xAxis": {"type": "value"},
"yAxis": {"type": "category", "data": labels, "inverse": True,
"axisLabel": {"fontSize": 11}},
"series": [{
"type": "bar",
"data": [{"value": v, "itemStyle": {"color": c}} for v, c in zip(data, colors)],
"label": {"show": True, "position": "right", "fontSize": 10},
}],
}
return _echarts_init(element_id, option, height=height, asin_category_axis=asin_category_axis)
def build_asin_mini_chart(
element_id: str,
labels: List[str],
data: List[int],
color: str = "#ef4444",
) -> str:
"""单个 ASIN 的迷你柱状图。"""
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"grid": {"left": 100, "right": 30, "top": 5, "bottom": 15},
"xAxis": {"type": "value", "axisLabel": {"fontSize": 9}},
"yAxis": {"type": "category", "data": labels, "inverse": True,
"axisLabel": {"fontSize": 9}},
"series": [{
"type": "bar",
"data": [{"value": v, "itemStyle": {"color": color}} for v in data],
}],
}
return f"""(function() {{
var dom = document.getElementById('{element_id}');
var chart = echarts.init(dom);
chart.setOption({json.dumps(option, ensure_ascii=False)});
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def build_grouped_bar_chart(
element_id: str,
labels: List[str],
datasets: List[Dict[str, Any]],
) -> str:
"""分组柱状图(多 ASIN 对比)。"""
series = [
{
"name": ds["label"],
"type": "bar",
"data": ds["data"],
"itemStyle": {"color": ds.get("color", "#3b82f6")},
}
for ds in datasets
]
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"bottom": 0, "textStyle": {"fontSize": 10}},
"grid": {"left": 50, "right": 20, "top": 20, "bottom": 40},
"xAxis": {"type": "category", "data": labels, "axisLabel": {"fontSize": 10, "rotate": 20}},
"yAxis": {"type": "value"},
"series": series,
}
return f"""(function() {{
var dom = document.getElementById('{element_id}');
var chart = echarts.init(dom);
chart.setOption({json.dumps(option, ensure_ascii=False)});
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
# ── 布局与图表类型阈值 ──
THEME_COLORS_NEG = ["#ef4444", "#f97316", "#eab308", "#dc2626", "#b91c1c", "#fb923c"]
THEME_COLORS_POS = ["#22c55e", "#86efac", "#4ade80", "#2dd4bf", "#60a5fa", "#16a34a"]
def get_chart_layout(stats: Any, cfg: Optional[dict] = None) -> Dict[str, Any]:
"""根据 ASIN 数量计算图表布局参数。"""
cfg = cfg or {}
n = len(getattr(stats, "asins", []) or [])
large_th = int(cfg.get("large_asin_threshold", 12))
heatmap_th = int(cfg.get("heatmap_asin_threshold", 30))
large = n > large_th
heatmap = n > heatmap_th
return {
"asin_count": n,
"large_market": large,
"use_heatmap": heatmap,
"star_chart_height": max(320, n * 22) if large else 300,
"star_summary_height": 280,
"show_star_summary": large and n > 15,
"neg_theme_height": 480 if heatmap else (420 if large else 360),
"pos_theme_height": 480 if heatmap else (420 if large else 360),
}
# ── ASIN 短码 tooltip(悬浮显示代称 + 可点击 ASIN)──
def build_asin_tooltip_map(asin_short_codes: Dict[str, str]) -> Dict[str, Dict[str, str]]:
"""ASIN 短码 A → {asin, url}。"""
from data_loader import build_amazon_url
return {
short: {"asin": asin, "url": build_amazon_url(asin)}
for asin, short in asin_short_codes.items()
}
def build_asin_tooltip_bootstrap(asin_short_codes: Dict[str, str]) -> str:
"""注入全局 VOC_ASIN_MAP 与 axis tooltip formatter。"""
map_json = json.dumps(build_asin_tooltip_map(asin_short_codes), ensure_ascii=False)
return f"""// ASIN 短码 tooltip(悬浮:A · B0XXX 可点击跳转亚马逊)
window.VOC_ASIN_MAP = {map_json};
window.vocAsinTooltipHeader = function(code) {{
if (!code || String(code).indexOf('其余') === 0) return code;
var info = (window.VOC_ASIN_MAP || {{}})[code];
if (info && info.asin) {{
return code + ' · <a href="' + info.url + '" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:underline">' + info.asin + '</a>';
}}
return code;
}};
window.vocApplyAsinAxisTooltip = function(opt, axisKey) {{
axisKey = axisKey || 'x';
var axis = axisKey === 'y' ? opt.yAxis : opt.xAxis;
if (!axis || axis.type !== 'category') return;
opt.tooltip = opt.tooltip || {{}};
opt.tooltip.trigger = 'axis';
opt.tooltip.enterable = true;
opt.tooltip.confine = true;
opt.tooltip.axisPointer = opt.tooltip.axisPointer || {{type: 'shadow'}};
opt.tooltip.formatter = function(params) {{
var items = Array.isArray(params) ? params : [params];
if (!items.length) return '';
var code = items[0].axisValue != null ? items[0].axisValue : (items[0].name || '');
var lines = [window.vocAsinTooltipHeader(code)];
items.forEach(function(p) {{
if (p.seriesName == null || p.value == null) return;
var v = p.value;
if (Array.isArray(v)) v = v[v.length - 1];
if (v && typeof v === 'object' && v.value != null) v = v.value;
lines.push((p.marker || '') + p.seriesName + ': ' + v);
}});
return lines.join('<br/>');
}};
}};"""
def _echarts_init(
element_id: str,
option: dict,
height: Optional[int] = None,
*,
asin_category_axis: Optional[str] = None,
) -> str:
h_js = f"dom.style.height='{height}px';" if height else ""
apply_js = ""
if asin_category_axis:
apply_js = f"if (window.vocApplyAsinAxisTooltip) window.vocApplyAsinAxisTooltip(opt, '{asin_category_axis}');"
return f"""(function() {{
var dom = document.getElementById('{element_id}');
if (!dom) return;
{h_js}
var opt = {json.dumps(option, ensure_ascii=False)};
{apply_js}
var chart = echarts.init(dom);
chart.setOption(opt);
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def _aggregate_star_dist(asin_list: List[str], star_data: Dict[str, Dict[int, int]]) -> Dict[int, int]:
agg: Dict[int, int] = {s: 0 for s in (5, 4, 3, 2, 1)}
for asin in asin_list:
sd = star_data.get(asin, {})
for s in agg:
agg[s] += sd.get(s, 0)
return agg
def build_overall_charts(
neg_freq: dict,
pos_freq: dict,
neg_themes: list,
) -> str:
"""生成主题频次画像区的全市场图表(negChartOverall / posChartOverall)。"""
priority_color = {"P0": "#ef4444", "P1": "#f97316", "P2": "#eab308"}
theme_priority = {t["name"]: t.get("priority", "P2") for t in neg_themes}
neg_sorted = sorted(neg_freq.items(), key=lambda x: x[1], reverse=True)
pos_sorted = sorted(pos_freq.items(), key=lambda x: x[1], reverse=True)
neg_labels = [n for n, _ in neg_sorted]
neg_data = [c for _, c in neg_sorted]
pos_labels = [n for n, _ in pos_sorted]
pos_data = [c for _, c in pos_sorted]
neg_colors = [priority_color.get(theme_priority.get(n, "P2"), "#94a3b8") for n in neg_labels]
parts = []
parts.append("// 整体主题频次 - 差评")
parts.append(build_horizontal_bar_chart("negChartOverall", neg_labels, neg_data, neg_colors))
parts.append("// 整体主题频次 - 好评")
parts.append(build_horizontal_bar_chart("posChartOverall", pos_labels, pos_data, ["#22c55e"] * len(pos_labels)))
return "\n\n".join(parts)
def build_theme_mini_charts_html(
prefix: str,
theme_labels: List[str],
asin_count: int,
*,
large_threshold: int = 30,
) -> str:
"""大品类:每主题 Top N ASIN 小 multiples 的 HTML 容器。"""
if not theme_labels or asin_count <= large_threshold:
return ""
blocks = ['<div class="theme-mini-grid">']
for i, theme in enumerate(theme_labels):
blocks.append(
f'<div class="theme-mini-box"><h4>{theme}</h4>'
f'<div id="{prefix}Mini{i}" class="theme-mini-chart"></div></div>'
)
blocks.append("</div>")
return "".join(blocks)
def build_all_charts(
stats: Any,
neg_themes: List[Dict[str, Any]],
pos_themes: List[Dict[str, Any]],
neg_freq: Dict[str, int],
pos_freq: Dict[str, int],
per_asin_neg: Dict[str, Dict[str, int]],
per_asin_pos: Dict[str, Dict[str, int]],
asin_labels: Optional[Dict[str, str]] = None,
asin_short_codes: Optional[Dict[str, str]] = None,
layout: Optional[Dict[str, Any]] = None,
cfg: Optional[dict] = None,
) -> str:
"""生成所有图表的 JS 代码。"""
asin_labels = asin_labels or {}
asin_short_codes = asin_short_codes or asin_labels
layout = layout or get_chart_layout(stats, cfg)
parts: List[str] = [build_asin_tooltip_bootstrap(asin_short_codes)]
asins = [a.asin for a in stats.asins]
if layout["large_market"]:
from report_utils import sorted_asin_stats
asins = [a.asin for a in sorted_asin_stats(stats)]
else:
asins = [a.asin for a in sorted(stats.asins, key=lambda a: (-a.avg_rating, -a.total))]
axis_labels = [asin_short_codes.get(a, a) for a in asins]
star_data = {a.asin: a.star_dist for a in stats.asins}
parts.append("// 1. 评分分布堆叠图")
parts.append(_build_star_dist_echarts(
axis_labels, star_data, asins,
horizontal=layout["large_market"],
))
if layout.get("show_star_summary"):
from report_utils import sorted_asin_stats
ranked = [a.asin for a in sorted_asin_stats(stats)]
top_n = int((cfg or {}).get("star_summary_top_n", 15))
top_asins = ranked[:top_n]
rest_asins = ranked[top_n:]
summary_labels = [asin_short_codes.get(a, a) for a in top_asins]
summary_data = {a: star_data[a] for a in top_asins}
summary_asins = list(top_asins)
if rest_asins:
summary_labels.append(f"其余{len(rest_asins)}款")
summary_data["__other__"] = _aggregate_star_dist(rest_asins, star_data)
summary_asins.append("__other__")
parts.append("// 1b. 评分分布摘要")
parts.append(_build_star_dist_echarts(
summary_labels, summary_data, summary_asins,
element_id="starDistSummaryChart", horizontal=False,
))
neg_sorted = sorted(neg_freq.items(), key=lambda x: x[1], reverse=True)
pos_sorted = sorted(pos_freq.items(), key=lambda x: x[1], reverse=True)
neg_labels = [n for n, _ in neg_sorted]
neg_data = [c for _, c in neg_sorted]
pos_labels = [n for n, _ in pos_sorted]
pos_data = [c for _, c in pos_sorted]
priority_color = {"P0": "#ef4444", "P1": "#f97316", "P2": "#eab308"}
theme_priority = {t["name"]: t.get("priority", "P2") for t in neg_themes}
neg_colors = [priority_color.get(theme_priority.get(n, "P2"), "#94a3b8") for n in neg_labels]
parts.append("// 2. 差评主题")
parts.append(build_horizontal_bar_chart("negChart", neg_labels, neg_data, neg_colors))
parts.append("// 3. 好评主题")
parts.append(build_horizontal_bar_chart("posChart", pos_labels, pos_data, ["#22c55e"] * len(pos_labels)))
neg_top6 = neg_labels[:6]
pos_top6 = pos_labels[:6]
mini_top = int((cfg or {}).get("theme_mini_top_n", 8))
parts.append("// 4. 各 ASIN 差评主题对比")
parts.append(_build_per_asin_theme_chart(
"negChartPerAsin", neg_top6, per_asin_neg, asins,
axis_labels=axis_labels, asin_full_labels=asin_labels,
is_neg=True, use_heatmap=layout["use_heatmap"],
))
if layout["use_heatmap"]:
parts.append(_build_theme_mini_charts_js(
"neg", neg_top6, per_asin_neg, asins, asin_short_codes, asin_labels,
is_neg=True, top_n=mini_top,
))
parts.append("// 5. 各 ASIN 好评主题对比")
parts.append(_build_per_asin_theme_chart(
"posChartPerAsin", pos_top6, per_asin_pos, asins,
axis_labels=axis_labels, asin_full_labels=asin_labels,
is_neg=False, use_heatmap=layout["use_heatmap"],
))
if layout["use_heatmap"]:
parts.append(_build_theme_mini_charts_js(
"pos", pos_top6, per_asin_pos, asins, asin_short_codes, asin_labels,
is_neg=False, top_n=mini_top,
))
return "\n\n".join(parts)
def _build_per_asin_theme_chart(
element_id: str,
theme_labels: List[str],
per_asin_data: Dict[str, Dict[str, int]],
asins: List[str],
*,
axis_labels: Optional[List[str]] = None,
asin_full_labels: Optional[Dict[str, str]] = None,
is_neg: bool = True,
use_heatmap: bool = False,
asin_labels: Optional[Dict[str, str]] = None,
) -> str:
"""各 ASIN 主题对比:分组柱(X=ASIN,series=主题)或热力图。"""
if not theme_labels or not asins:
return f"// skip {element_id}: no data"
if asin_labels and not axis_labels:
axis_labels = [asin_labels.get(a, a) for a in asins]
axis_labels = axis_labels or asins
asin_full_labels = asin_full_labels or asin_labels or {}
if use_heatmap:
return _build_theme_heatmap(
element_id, theme_labels, per_asin_data, asins,
axis_labels, asin_full_labels, is_neg=is_neg,
)
colors = THEME_COLORS_NEG if is_neg else THEME_COLORS_POS
series = []
for i, theme in enumerate(theme_labels):
series.append({
"name": theme,
"type": "bar",
"data": [per_asin_data.get(asin, {}).get(theme, 0) for asin in asins],
"itemStyle": {"color": colors[i % len(colors)]},
})
option: Dict[str, Any] = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"top": 0, "type": "scroll", "textStyle": {"fontSize": 10}},
"grid": {"left": 48, "right": 16, "top": 48, "bottom": 72},
"xAxis": {
"type": "category",
"data": axis_labels,
"axisLabel": {"fontSize": 9, "rotate": 45, "interval": 0},
},
"yAxis": {"type": "value"},
"series": series,
}
if len(asins) > 12:
end_pct = min(100, round(12 / len(asins) * 100))
option["dataZoom"] = [
{"type": "inside", "start": 0, "end": end_pct},
{"type": "slider", "start": 0, "end": end_pct, "bottom": 8, "height": 18},
]
option["grid"]["bottom"] = 96
return _echarts_init(element_id, option, asin_category_axis="x")
def _build_theme_heatmap(
element_id: str,
theme_labels: List[str],
per_asin_data: Dict[str, Dict[str, int]],
asins: List[str],
axis_labels: List[str],
asin_full_labels: Dict[str, str],
*,
is_neg: bool = True,
) -> str:
data = []
max_val = 1
for yi, theme in enumerate(theme_labels):
for xi, asin in enumerate(asins):
v = per_asin_data.get(asin, {}).get(theme, 0)
max_val = max(max_val, v)
data.append([xi, yi, v])
colors = ["#fef2f2", "#fca5a5", "#ef4444", "#b91c1c"] if is_neg else ["#f0fdf4", "#86efac", "#22c55e", "#15803d"]
end_pct = min(100, max(15, round(20 / max(len(asins), 1) * 100)))
option = {
"grid": {"left": 88, "right": 56, "top": 24, "bottom": 72},
"xAxis": {
"type": "category",
"data": axis_labels,
"splitArea": {"show": True},
"axisLabel": {"fontSize": 9, "rotate": 45, "interval": 0},
},
"yAxis": {
"type": "category",
"data": theme_labels,
"splitArea": {"show": True},
"axisLabel": {"fontSize": 10},
},
"visualMap": {
"min": 0,
"max": max_val,
"calculable": True,
"orient": "vertical",
"right": 8,
"top": "center",
"inRange": {"color": colors},
"text": ["高", "低"],
},
"dataZoom": [
{"type": "inside", "xAxisIndex": 0, "start": 0, "end": end_pct},
{"type": "slider", "xAxisIndex": 0, "bottom": 8, "height": 18, "start": 0, "end": end_pct},
],
"series": [{
"name": "命中数",
"type": "heatmap",
"data": data,
"label": {"show": False},
"emphasis": {"itemStyle": {"shadowBlur": 6, "shadowColor": "rgba(0,0,0,0.2)"}},
}],
}
full_map = {axis_labels[i]: asin_full_labels.get(asins[i], asins[i]) for i in range(len(asins))}
js_option = json.dumps(option, ensure_ascii=False)
return f"""(function() {{
var dom = document.getElementById('{element_id}');
if (!dom) return;
var opt = {js_option};
var fullMap = {json.dumps(full_map, ensure_ascii=False)};
opt.tooltip = {{
position: 'top',
enterable: true,
confine: true,
formatter: function(p) {{
if (!p.data) return '';
var code = (opt.xAxis.data[p.data[0]] || '');
var theme = (opt.yAxis.data[p.data[1]] || '');
var head = window.vocAsinTooltipHeader ? window.vocAsinTooltipHeader(code) : (fullMap[code] || code);
return head + '<br/>' + theme + ': ' + p.data[2];
}}
}};
var chart = echarts.init(dom);
chart.setOption(opt);
window.addEventListener('resize', function() {{ chart.resize(); }});
}})();"""
def _build_theme_mini_charts_js(
prefix: str,
theme_labels: List[str],
per_asin_data: Dict[str, Dict[str, int]],
asins: List[str],
asin_short: Dict[str, str],
asin_full: Dict[str, str],
*,
is_neg: bool = True,
top_n: int = 8,
) -> str:
_ = asin_full
parts = []
color = "#ef4444" if is_neg else "#22c55e"
for i, theme in enumerate(theme_labels):
ranked = sorted(
((asin, per_asin_data.get(asin, {}).get(theme, 0)) for asin in asins),
key=lambda x: (-x[1], x[0]),
)
top = [(a, c) for a, c in ranked if c > 0][:top_n]
if not top:
top = ranked[: min(top_n, len(ranked))]
labels = [asin_short.get(a, a) for a, _ in top]
values = [c for _, c in top]
parts.append(build_horizontal_bar_chart(
f"{prefix}Mini{i}", labels, values, [color] * len(labels),
height=200, asin_category_axis="y",
))
return "\n\n".join(parts)
def _build_star_dist_echarts(
chart_labels: List[str],
star_data: Dict[str, Dict[int, int]],
asins: List[str],
*,
element_id: str = "starDistChart",
horizontal: bool = False,
) -> str:
"""评分分布堆叠图;大品类用横向堆叠。"""
colors_5 = ["#22c55e", "#86efac", "#d1d5db", "#fbbf24", "#ef4444"]
series = []
for star in [5, 4, 3, 2, 1]:
series.append({
"name": f"{star}★",
"type": "bar",
"stack": "total",
"data": [star_data.get(a, {}).get(star, 0) for a in asins],
"itemStyle": {"color": colors_5[5 - star]},
})
if horizontal:
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"top": 0, "textStyle": {"fontSize": 11}},
"grid": {"left": 72, "right": 24, "top": 36, "bottom": 24},
"xAxis": {"type": "value"},
"yAxis": {
"type": "category",
"data": chart_labels,
"inverse": True,
"axisLabel": {"fontSize": 10, "width": 64, "overflow": "truncate"},
},
"series": series,
}
else:
option = {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"legend": {"bottom": 0, "textStyle": {"fontSize": 11}},
"grid": {"left": 50, "right": 20, "top": 20, "bottom": 40},
"xAxis": {
"type": "category",
"data": chart_labels,
"axisLabel": {"fontSize": 10, "rotate": 30 if len(chart_labels) > 8 else 0},
},
"yAxis": {"type": "value"},
"series": series,
}
axis_key = "y" if horizontal else "x"
return _echarts_init(element_id, option, asin_category_axis=axis_key)
# ── 图表数据计算辅助 ──
_STRONG_HINTS = (
"waste", "charge", "broken", "stopped", "doesn't", "does not", "not worth",
"terrible", "horrible", "useless", "defect", "return", "refund", "pull",
"nick", "burn", "bleed", "cut", "irritat", "bump", "overheat", "loud",
"durable", "quality", "shave", "trim", "waterproof", "battery",
)
def _is_strong_keyword(kw: str) -> bool:
if len(kw) >= 12:
return True
return any(h in kw for h in _STRONG_HINTS)
def _match_theme_in_text(keywords: List[str], text: str) -> bool:
from report_utils import match_text
return match_text(keywords, text)
def calc_keyword_group_freq(
reviews: List[Any],
keywords: List[str],
is_neg: bool = True,
) -> int:
"""词组频次:组内任一词命中即计 1,同一评论只计 1 次;按星级过滤。"""
kws = [kw.lower().strip() for kw in keywords if kw and kw.strip()]
if not kws:
return 0
count = 0
for r in reviews:
if is_neg and r.rating > 2:
continue
if not is_neg and r.rating < 4:
continue
text = (r.title + " " + r.content).lower()
if _match_theme_in_text(kws, text):
count += 1
return count
def calc_theme_freq(
themes: List[Dict[str, Any]],
reviews: List[Any],
is_neg: bool = True,
) -> Dict[str, int]:
"""用主题 keywords(英文聚类短语 + LLM 关键词)统计频次。"""
freq: Dict[str, int] = {}
for theme in themes:
keywords = [kw.lower().strip() for kw in theme.get("keywords", []) if kw.strip()]
count = 0
for r in reviews:
if is_neg and r.rating > 2:
continue
if not is_neg and r.rating < 4:
continue
text = (r.title + " " + r.content).lower()
if _match_theme_in_text(keywords, text):
count += 1
freq[theme["name"]] = count
return freq
def calc_per_asin_theme_freq(
themes: List[Dict[str, Any]],
reviews: List[Any],
is_neg: bool = True,
) -> Dict[str, Dict[str, int]]:
"""按 ASIN 分别统计各主题频次。"""
from collections import defaultdict
result: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
for theme in themes:
keywords = [kw.lower().strip() for kw in theme.get("keywords", []) if kw.strip()]
name = theme["name"]
for r in reviews:
if is_neg and r.rating > 2:
continue
if not is_neg and r.rating < 4:
continue
text = (r.title + " " + r.content).lower()
if _match_theme_in_text(keywords, text):
result[r.asin][name] += 1
return {asin: dict(counts) for asin, counts in result.items()}