初始化红餐观察库项目
This commit is contained in:
@@ -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 字段必须为: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)
|
||||
Reference in New Issue
Block a user