# -*- 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 库的 '
# 内联模式:从缓存或下载
if CACHE_FILE.is_file():
js = CACHE_FILE.read_text(encoding="utf-8")
logger.info("使用缓存的 ECharts (%s KB)", len(js) // 1024)
return f""
# 下载
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""
except Exception as e:
logger.warning("下载 ECharts 失败: %s,降级到 CDN", e)
return f''
# ── 图表 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 + ' · ' + info.asin + '';
}}
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('
');
}};
}};"""
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 = ['
']
for i, theme in enumerate(theme_labels):
blocks.append(
f'
'
)
blocks.append("
")
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 + '
' + 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 _theme_matches_review(
keywords: List[str],
review: Any,
extractions: Dict[int, Any],
*,
is_neg: bool,
) -> bool:
"""主题命中:优先结构化 category+aspect,回退原文 keyword。"""
from report_utils import match_theme_extraction
kws = [kw.lower().strip() for kw in keywords if kw and kw.strip()]
if not kws:
return False
ext = extractions.get(review.source_row)
if ext and match_theme_extraction(kws, ext, is_neg=is_neg):
return True
text = (review.title + " " + review.content).lower()
return _match_theme_in_text(kws, text)
def calc_theme_freq(
themes: List[Dict[str, Any]],
reviews: List[Any],
is_neg: bool = True,
extractions: Dict[int, Any] | None = None,
) -> Dict[str, int]:
"""用主题 keywords 统计频次(结构化 category+aspect 优先,原文回退)。"""
ext_map = extractions or {}
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
if _theme_matches_review(keywords, r, ext_map, is_neg=is_neg):
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,
extractions: Dict[int, Any] | None = None,
) -> Dict[str, Dict[str, int]]:
"""按 ASIN 分别统计各主题频次(结构化优先)。"""
from collections import defaultdict
ext_map = extractions or {}
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
if _theme_matches_review(keywords, r, ext_map, is_neg=is_neg):
result[r.asin][name] += 1
return {asin: dict(counts) for asin, counts in result.items()}