初始化红餐观察库项目

This commit is contained in:
freedakgmail
2026-07-19 18:11:08 +08:00
commit c663539548
22 changed files with 1261 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Local Hongcan article library and Qwen-assisted editorial analysis."""
from __future__ import annotations
import json
import os
import sqlite3
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, abort, jsonify, render_template, request
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = Path(os.getenv("HONGCAN_DB", BASE_DIR / "data" / "hongcan.db"))
load_dotenv(BASE_DIR / ".env")
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "hongcan-local-only")
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def ensure_schema() -> None:
with db() as conn:
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
def clamp_int(value: str | None, default: int, low: int, high: int) -> int:
try:
return max(low, min(high, int(value or default)))
except ValueError:
return default
TREND_CN = {"emerging": "新兴", "accelerating": "加速", "stable": "稳定", "declining": "下行"}
@app.template_filter("date_cn")
def date_cn(value: str | None) -> str:
if not value:
return "时间未知"
try:
return datetime.fromisoformat(value).strftime("%Y.%m.%d %H:%M")
except ValueError:
return value
@app.get("/")
def index():
query = (request.args.get("q") or "").strip()
page = clamp_int(request.args.get("page"), 1, 1, 10_000)
per_page = 18
where = "WHERE crawl_status='success'"
params: list[object] = []
if query:
where += " AND (title LIKE ? OR author LIKE ? OR content LIKE ?)"
needle = f"%{query}%"
params.extend([needle, needle, needle])
with db() as conn:
total = conn.execute(f"SELECT COUNT(*) FROM articles {where}", params).fetchone()[0]
rows = conn.execute(
f"""
SELECT a.id,a.title,a.author,a.published_at,a.summary,a.canonical_url,
substr(a.content,1,180) AS excerpt,
json_extract(x.result_json,'$.summary') AS ai_summary,
CASE WHEN x.id IS NULL THEN 0 ELSE 1 END AS analyzed
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id=a.id AND x.analysis_type='editorial'
{where.replace('crawl_status', 'a.crawl_status')}
ORDER BY a.published_at DESC,a.id DESC LIMIT ? OFFSET ?
""",
[*params, per_page, (page - 1) * per_page],
).fetchall()
stats = conn.execute(
"""
SELECT COUNT(*) total,
SUM(crawl_status='success') success,
MAX(published_at) latest,
(SELECT COUNT(DISTINCT article_id) FROM ai_analyses) analyzed
FROM articles
"""
).fetchone()
signal_rows = conn.execute(
"SELECT * FROM industry_signals ORDER BY signal_date DESC,confidence DESC LIMIT 8"
).fetchall()
signals = []
for signal in signal_rows:
item = dict(signal)
item["tags"] = json.loads(item["tags_json"])
item["implications"] = json.loads(item["implications_json"])
item["article_ids"] = json.loads(item["article_ids_json"])
item["trend"] = TREND_CN.get(item["trend"], item["trend"])
signals.append(item)
return render_template(
"index.html", articles=rows, query=query, page=page, total=total,
pages=max(1, (total + per_page - 1) // per_page), stats=stats, signals=signals,
)
@app.get("/signals/<int:signal_id>")
def signal_detail(signal_id: int):
with db() as conn:
signal = conn.execute("SELECT * FROM industry_signals WHERE id=?", (signal_id,)).fetchone()
if not signal:
abort(404)
signal = dict(signal)
signal["trend"] = TREND_CN.get(signal["trend"], signal["trend"])
article_ids = json.loads(signal["article_ids_json"])
articles = []
if article_ids:
marks = ",".join("?" for _ in article_ids)
articles = conn.execute(
f"SELECT id,title,published_at FROM articles WHERE id IN ({marks}) ORDER BY published_at DESC",
article_ids,
).fetchall()
return render_template(
"signal.html", signal=signal, articles=articles,
implications=json.loads(signal["implications_json"]), tags=json.loads(signal["tags_json"]),
)
@app.get("/article/<int:article_id>")
def article(article_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
if not row:
abort(404)
analysis = conn.execute(
"SELECT * FROM ai_analyses WHERE article_id=? AND analysis_type='editorial' ORDER BY updated_at DESC LIMIT 1",
(article_id,),
).fetchone()
parsed = json.loads(analysis["result_json"]) if analysis else None
return render_template("article.html", article=row, analysis=parsed, analysis_row=analysis)
def qwen_analyze(title: str, content: str) -> dict:
api_key = os.getenv("DASHSCOPE_API_KEY", "").strip()
if not api_key:
raise RuntimeError("尚未配置 DASHSCOPE_API_KEY,请在 .env 中设置")
model = os.getenv("QWEN_MODEL", "qwen-plus")
prompt = f"""你是一名资深餐饮行业研究编辑。分析下面这篇文章,只返回合法 JSON,不要 Markdown 代码块。
JSON 字段必须为:summary120字内摘要)、key_points3-6条字符串)、industry_signal(行业信号)、brands(品牌数组)、numbers(关键数据数组)、content_angles3条可延展选题)、risk_notes(事实或表述风险数组)、tags5-10个标签)、sentimentpositive/neutral/negative)。
文章标题:{title}
正文:{content[:24000]}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你输出严谨、简洁、可供编辑部直接使用的结构化行业分析。"},
{"role": "user", "content": prompt},
],
"temperature": 0.25,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=90) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"千问接口返回 {exc.code}{detail}") from exc
text = body["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
return json.loads(text)
@app.post("/api/articles/<int:article_id>/analyze")
def analyze(article_id: int):
force = bool((request.get_json(silent=True) or {}).get("force"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
with db() as conn:
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "文章不存在"}), 404
cached = conn.execute(
"SELECT * FROM ai_analyses WHERE article_id=? AND model=? AND analysis_type='editorial'",
(article_id, model),
).fetchone()
if cached and cached["content_hash"] == row["content_hash"] and not force:
return jsonify({"ok": True, "cached": True, "analysis": json.loads(cached["result_json"])})
try:
result = qwen_analyze(row["title"], row["content"])
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 502
timestamp = now_iso()
conn.execute(
"""
INSERT INTO ai_analyses(article_id,model,analysis_type,result_json,content_hash,created_at,updated_at)
VALUES (?,?, 'editorial', ?,?,?,?)
ON CONFLICT(article_id,model,analysis_type) DO UPDATE SET
result_json=excluded.result_json,content_hash=excluded.content_hash,updated_at=excluded.updated_at
""",
(article_id, model, json.dumps(result, ensure_ascii=False), row["content_hash"], timestamp, timestamp),
)
conn.commit()
return jsonify({"ok": True, "cached": False, "analysis": result})
if __name__ == "__main__":
ensure_schema()
app.run(host="127.0.0.1", port=int(os.getenv("PORT", "8766")), debug=False)