221 lines
8.8 KiB
Python
221 lines
8.8 KiB
Python
#!/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 字段必须为:summary(120字内摘要)、key_points(3-6条字符串)、industry_signal(行业信号)、brands(品牌数组)、numbers(关键数据数组)、content_angles(3条可延展选题)、risk_notes(事实或表述风险数组)、tags(5-10个标签)、sentiment(positive/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)
|