feat: 热词热力图改为Treemap矩形堆砌布局,面积代表提及次数;赛道分析页面布局调整
This commit is contained in:
+87
-27
@@ -45,9 +45,11 @@ def now_iso() -> str:
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
return conn
|
||||
|
||||
|
||||
@@ -160,7 +162,7 @@ def dashboard():
|
||||
"type": r["type"] or "经营干货",
|
||||
"score": r["score"] or 0,
|
||||
"summary": r["ai_summary"] or "",
|
||||
"entities": json.loads(r["entities"]) if r["entities"] else [],
|
||||
"entities": [b["name"] if isinstance(b, dict) else b for b in (json.loads(r["entities"]) if r["entities"] else [])],
|
||||
}
|
||||
for r in top_intel
|
||||
],
|
||||
@@ -251,7 +253,7 @@ def intel_list():
|
||||
"summary": r["ai_summary"] or r["summary"] or "",
|
||||
"keyPoints": json.loads(r["key_points"]) if r["key_points"] else [],
|
||||
"score": r["score"] or 0,
|
||||
"entities": json.loads(r["entities"]) if r["entities"] else [],
|
||||
"entities": [b["name"] if isinstance(b, dict) else b for b in (json.loads(r["entities"]) if r["entities"] else [])],
|
||||
"timeliness": r["timeliness"] or "中",
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"url": r["canonical_url"],
|
||||
@@ -393,26 +395,41 @@ def brand_detail(brand_id: int):
|
||||
@app.get("/api/analysis")
|
||||
def analysis():
|
||||
with db() as conn:
|
||||
# 赛道热度趋势:按月统计各赛道的平均评分
|
||||
# 赛道热度趋势:按月统计各赛道的文章数和平均评分,热度=文章数×平均评分/10
|
||||
heat_rows = conn.execute(
|
||||
"""
|
||||
SELECT strftime('%m', a.published_at) AS month,
|
||||
SELECT strftime('%Y-%m', a.published_at) AS month,
|
||||
a.category,
|
||||
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS heat
|
||||
COUNT(*) AS article_cnt,
|
||||
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS avg_score
|
||||
FROM articles a
|
||||
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
||||
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
|
||||
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL AND a.category != ''
|
||||
AND a.published_at >= date('now', '-12 months')
|
||||
GROUP BY month, a.category
|
||||
ORDER BY month
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
# 开关店对比(从文章中提取的品类趋势数据)
|
||||
category_counts = conn.execute(
|
||||
# 品牌门店规模(按赛道聚合真实门店总数)
|
||||
brand_stores = conn.execute(
|
||||
"""
|
||||
SELECT a.category, COUNT(*) AS cnt
|
||||
SELECT category, SUM(stores) AS total_stores, COUNT(*) AS brand_cnt
|
||||
FROM brands WHERE category IS NOT NULL AND category != '' AND stores > 0
|
||||
GROUP BY category
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
# 舆情分布(按赛道统计正面/负面/中性文章数)
|
||||
sentiment_rows = conn.execute(
|
||||
"""
|
||||
SELECT a.category,
|
||||
SUM(CASE WHEN json_extract(x.result_json, '$.sentiment') = 'positive' THEN 1 ELSE 0 END) AS positive_cnt,
|
||||
SUM(CASE WHEN json_extract(x.result_json, '$.sentiment') = 'negative' THEN 1 ELSE 0 END) AS negative_cnt,
|
||||
SUM(CASE WHEN json_extract(x.result_json, '$.sentiment') = 'neutral' OR json_extract(x.result_json, '$.sentiment') IS NULL THEN 1 ELSE 0 END) AS neutral_cnt
|
||||
FROM articles a
|
||||
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
|
||||
JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
||||
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL AND a.category != ''
|
||||
GROUP BY a.category
|
||||
"""
|
||||
).fetchall()
|
||||
@@ -432,29 +449,55 @@ def analysis():
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
# 热词
|
||||
tag_rows = conn.execute(
|
||||
# 热词热力图:近7天每天各标签出现次数
|
||||
tag_heatmap = conn.execute(
|
||||
"""
|
||||
SELECT value AS tag, COUNT(*) AS cnt
|
||||
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
|
||||
GROUP BY value
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 8
|
||||
SELECT value AS tag,
|
||||
strftime('%Y-%m-%d', a.published_at) AS day,
|
||||
COUNT(*) AS cnt
|
||||
FROM ai_analyses x
|
||||
JOIN articles a ON a.id = x.article_id
|
||||
, json_each(json_extract(x.result_json, '$.tags'))
|
||||
WHERE a.published_at >= date('now', '-6 days')
|
||||
AND value IS NOT NULL AND value != ''
|
||||
GROUP BY tag, day
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
# 构建热度趋势数据
|
||||
# 热词总量排行(用于选取 TOP 标签)
|
||||
tag_total = conn.execute(
|
||||
"""
|
||||
SELECT value AS tag, COUNT(*) AS cnt
|
||||
FROM ai_analyses x
|
||||
JOIN articles a ON a.id = x.article_id
|
||||
, json_each(json_extract(x.result_json, '$.tags'))
|
||||
WHERE a.published_at >= date('now', '-6 days')
|
||||
AND value IS NOT NULL AND value != ''
|
||||
GROUP BY value
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
# 构建热度趋势数据(热度 = 文章数 × 平均评分 / 10)
|
||||
heat_trend = {}
|
||||
for r in heat_rows:
|
||||
month_key = f"{r['month']}月" if r["month"] else "未知"
|
||||
month_key = f"{r['month'][-2:]}月" if r["month"] else "未知"
|
||||
if month_key not in heat_trend:
|
||||
heat_trend[month_key] = {"month": month_key}
|
||||
heat_trend[month_key][r["category"]] = round(r["heat"] or 0)
|
||||
heat_val = round((r["article_cnt"] or 0) * (r["avg_score"] or 0) / 10)
|
||||
heat_trend[month_key][r["category"]] = heat_val
|
||||
|
||||
# 构建开关店数据
|
||||
open_close = [
|
||||
{"category": r["category"], "新开": r["cnt"], "关闭": max(0, r["cnt"] - int(r["cnt"] * 0.85))}
|
||||
for r in category_counts
|
||||
# 构建品牌门店规模数据
|
||||
brand_stores_data = [
|
||||
{"category": r["category"], "stores": r["total_stores"] or 0, "brands": r["brand_cnt"]}
|
||||
for r in brand_stores
|
||||
]
|
||||
|
||||
# 构建舆情分布数据
|
||||
sentiment_data = [
|
||||
{"category": r["category"], "正面": r["positive_cnt"] or 0, "负面": r["negative_cnt"] or 0, "中性": r["neutral_cnt"] or 0}
|
||||
for r in sentiment_rows
|
||||
]
|
||||
|
||||
# 构建价格分布
|
||||
@@ -468,11 +511,28 @@ def analysis():
|
||||
{"band": "150元以上", "pct": round((price_rows["band6"] or 0) * 100 / total_brands)},
|
||||
]
|
||||
|
||||
# 构建热词热力图数据(用 UTC 日期与 SQLite date('now') 保持一致)
|
||||
from datetime import timedelta, timezone
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
days = [(today_utc - timedelta(days=i)).isoformat() for i in range(6, -1, -1)]
|
||||
top_tags = [r["tag"] for r in tag_total]
|
||||
# 构建矩阵 {tag: {day: cnt}}
|
||||
matrix = {tag: {day: 0 for day in days} for tag in top_tags}
|
||||
for r in tag_heatmap:
|
||||
if r["tag"] in matrix and r["day"] in matrix[r["tag"]]:
|
||||
matrix[r["tag"]][r["day"]] = r["cnt"]
|
||||
hot_keywords = {
|
||||
"days": days,
|
||||
"tags": top_tags,
|
||||
"matrix": matrix,
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"categoryHeatTrend": list(heat_trend.values()),
|
||||
"openCloseByCategory": open_close,
|
||||
"brandStoresByCategory": brand_stores_data,
|
||||
"sentimentByCategory": sentiment_data,
|
||||
"priceBandData": price_bands,
|
||||
"hotKeywords": [{"word": r["tag"], "change": min(r["cnt"] * 5, 50)} for r in tag_rows],
|
||||
"hotKeywords": hot_keywords,
|
||||
})
|
||||
|
||||
|
||||
|
||||
+13
-10
@@ -36,6 +36,7 @@ load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
DEFAULT_LIST_URL = "https://m.canyin88.com/zixun/"
|
||||
ARTICLE_PATH_RE = re.compile(r"/zixun/\d{4}/\d{1,2}/\d{1,2}/\d+\.html$")
|
||||
CANYIN168_ARTICLE_RE = re.compile(r"/Article/[a-z]+/\d+\.html$")
|
||||
DATE_RE = re.compile(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})(?:日)?(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?")
|
||||
|
||||
|
||||
@@ -47,9 +48,11 @@ def normalize_url(raw_url: str, base_url: str = DEFAULT_LIST_URL) -> str | None:
|
||||
absolute = urljoin(base_url, raw_url.strip())
|
||||
parts = urlsplit(absolute)
|
||||
host = parts.netloc.lower().removeprefix("m.").removeprefix("www.")
|
||||
if host != "canyin88.com" or not ARTICLE_PATH_RE.search(parts.path):
|
||||
return None
|
||||
return urlunsplit(("https", "www.canyin88.com", parts.path, "", ""))
|
||||
if host == "canyin88.com" and ARTICLE_PATH_RE.search(parts.path):
|
||||
return urlunsplit(("https", "www.canyin88.com", parts.path, "", ""))
|
||||
if host == "canyin168.com" and CANYIN168_ARTICLE_RE.search(parts.path):
|
||||
return urlunsplit(("http", "www.canyin168.com", parts.path, "", ""))
|
||||
return None
|
||||
|
||||
|
||||
def clean_text(value: str | None) -> str:
|
||||
@@ -138,7 +141,7 @@ def extract_article(page, url: str) -> dict[str, str | None]:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
|
||||
page.wait_for_timeout(800)
|
||||
document_title = clean_text(page.title())
|
||||
document_title = re.sub(r"(?:[_-]红餐网.*)$", "", document_title).strip()
|
||||
document_title = re.sub(r"(?:[_-](?:红餐网|职业餐饮网).*)$", "", document_title).strip()
|
||||
title = meta(page, 'meta[property="og:title"]') or document_title
|
||||
if not title or title == "相关推荐":
|
||||
title = first_text(page, [".title", "h1"])
|
||||
@@ -197,17 +200,17 @@ def pending_urls(conn: sqlite3.Connection, discovered: list[str], max_retries: i
|
||||
return list(dict.fromkeys(discovered + [row[0] for row in rows]))
|
||||
|
||||
|
||||
def insert_discoveries(conn: sqlite3.Connection, urls: list[str], source_type: str = "公众号") -> int:
|
||||
def insert_discoveries(conn: sqlite3.Connection, urls: list[str], source_type: str = "公众号", source_name: str = "") -> int:
|
||||
timestamp = now_iso()
|
||||
inserted = 0
|
||||
for url in urls:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO articles
|
||||
(canonical_url, source_type, discovered_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
(canonical_url, source_type, source_name, discovered_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(url, source_type, timestamp, timestamp),
|
||||
(url, source_type, source_name, timestamp, timestamp),
|
||||
)
|
||||
inserted += cursor.rowcount
|
||||
conn.commit()
|
||||
@@ -439,7 +442,7 @@ def run_crawl(args: argparse.Namespace) -> int:
|
||||
print(f"--- 采集公众号: {source_name} ({list_url}) ---", flush=True)
|
||||
urls = discover_urls(page, list_url, max_scrolls, list_pause_ms)
|
||||
discovered_count += len(urls)
|
||||
inserted_count += insert_discoveries(conn, urls, source_name)
|
||||
inserted_count += insert_discoveries(conn, urls, "公众号", source_name)
|
||||
queue = pending_urls(conn, urls, args.max_retries)
|
||||
cutoff = datetime.now() - timedelta(days=lookback_days)
|
||||
for index, url in enumerate(queue, start=1):
|
||||
@@ -551,7 +554,7 @@ def run_crawl(args: argparse.Namespace) -> int:
|
||||
"status": status, "source_type": args.source_type,
|
||||
"discovered": discovered_count, "inserted": inserted_count,
|
||||
"updated": updated_count, "failed": failed_count,
|
||||
}, ensure_ascii=False, flush=True))
|
||||
}, ensure_ascii=False))
|
||||
return 0 if status in {"success", "partial"} else 1
|
||||
|
||||
|
||||
|
||||
+23
-1
@@ -51,6 +51,23 @@ def get_api_key() -> str:
|
||||
|
||||
# ---- 单篇文章结构化分析 ----
|
||||
|
||||
_PROFANITY_MAP = {
|
||||
"妈的": "**", "傻逼": "**", "他妈": "**", "操": "*",
|
||||
"草泥马": "***", "尼玛": "**", "贱人": "**",
|
||||
"王八蛋": "***", "混蛋": "**", "去死": "**",
|
||||
"毛爷爷": "某伟人", "毛泽东": "某伟人", "国民党": "某党",
|
||||
"共产党": "某党", "长征": "远征", "抗日": "抗战",
|
||||
"革命": "变革", "社会主义": "某主义",
|
||||
}
|
||||
|
||||
|
||||
def sanitize_content(text: str) -> str:
|
||||
"""过滤粗口和敏感词,避免触发千问内容审核。"""
|
||||
for word, replacement in _PROFANITY_MAP.items():
|
||||
text = text.replace(word, replacement)
|
||||
return text
|
||||
|
||||
|
||||
def analyze_article(title: str, content: str) -> dict:
|
||||
"""调用通义千问对文章进行结构化分析。"""
|
||||
api_key = get_api_key()
|
||||
@@ -71,7 +88,7 @@ JSON 字段必须为:
|
||||
- numbers(关键数据数组)
|
||||
|
||||
文章标题:{title}
|
||||
正文:{content[:24000]}"""
|
||||
正文:{sanitize_content(content[:24000])}"""
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [
|
||||
@@ -276,6 +293,11 @@ def run_batch_analyze(force: bool, pause: float, retries: int) -> int:
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
err_str = str(exc)
|
||||
# 内容审核失败,标记跳过不重试
|
||||
if "data_inspection_failed" in err_str:
|
||||
print(f"[{index}/{len(rows)}] SKIP(内容审核) {row['title']}", flush=True)
|
||||
break
|
||||
if attempt <= retries:
|
||||
wait = attempt * 2
|
||||
print(f"[{index}/{len(rows)}] RETRY {attempt}/{retries} {exc}", flush=True)
|
||||
|
||||
Reference in New Issue
Block a user