初始提交:餐智库 CIBank 餐饮行业知识库

This commit is contained in:
freedakgmail
2026-07-20 19:49:27 +08:00
commit a40b0f14ae
99 changed files with 22553 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# 阿里云 DashScope (通义千问) API Key
DASHSCOPE_API_KEY=replace_with_your_key
# 使用的千问模型
QWEN_MODEL=qwen-plus
# 文本向量模型
EMBEDDING_MODEL=text-embedding-v3
# Flask 配置
FLASK_SECRET_KEY=replace_with_a_random_local_secret
PORT=8765
# 采集频率控制(秒)
CRAWL_ARTICLE_PAUSE=2.0
CRAWL_LIST_PAUSE=1.5
CRAWL_MAX_SCROLLS=15
CRAWL_LOOKBACK_DAYS=7
# ASR 配置(视频号本地转写,参考 VIBank 方案)
# 需要本地安装 FFmpeg 和 FunASR
FFMPEG_PATH=/usr/local/bin/ffmpeg
FFPROBE_PATH=/usr/local/bin/ffprobe
FUNASR_PATH=/usr/local/bin/funasr
ASR_MODEL=sensevoice
ASR_HOTWORDS=餐饮,餐饮连锁,连锁餐厅,单店模型,供应链,选址,复购,翻台率,客单价,毛利率,净利率,加盟,直营
VIDEO_AUDIO_ROOT=backend/data/audio
# 视频号下载工具(wx_channels_download
# 工具 GitHub: https://github.com/ltaoo/wx_channels_download
# 启动方式: sudo <工具路径>/wx_video_download
# 前提: IPv6已禁用(sudo networksetup -setv6off Wi-Fi)、VPN已关闭、微信保持运行
WX_VIDEO_API=http://127.0.0.1:2022
+172
View File
@@ -0,0 +1,172 @@
# 餐智库后端服务
餐饮行业知识库后端,提供采集管道、结构化引擎和 RAG 问答 API。
## 目录结构
```
backend/
├── app.py # Flask API 服务(端口 8788
├── crawler.py # 采集管道:公众号文章抓取 + 视频号 ASR
├── video_downloader.py # 视频号批量下载管理器(调用 wx_channels_download API
├── structurer.py # 结构化引擎:LLM 打标签/摘要/要点/品牌识别
├── daily_pipeline.py # 每日增量流水线:备份/采集/分析/向量/行业信号
├── rag.py # RAG 问答:向量检索 + LLM 生成
├── schema.sql # SQLite 数据库 schema
├── requirements.txt # Python 依赖
├── .env.example # 环境变量模板
├── video_config.json # 视频源配置(账号、finder、下载目录)
├── video_sources.json # 旧格式视频源(兼容)
├── run.sh # 一键启动脚本
└── data/ # SQLite 数据库目录(自动创建)
```
## 快速开始
### 1. 安装依赖
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
playwright install chromium
```
### 2. 配置环境变量
```bash
cp .env.example .env
# 编辑 .env,填入你的 DASHSCOPE_API_KEY
```
### 3. 启动服务
```bash
# 方式一:一键启动前后端
./run.sh
# 方式二:仅启动后端
python app.py
```
### 4. 数据采集与分析流程
```bash
# 步骤1:采集公众号文章
python crawler.py --source-type 公众号
# 步骤2:视频号批量下载(需要先启动 wx_channels_download 工具)
# 2a. 启动下载工具(另开终端)
# sudo networksetup -setv6off Wi-Fi # 禁用 IPv6(关键!)
# sudo <工具路径>/wx_video_download # 以管理员启动
# # 然后重启微信,打开视频号
#
# 2b. 自动检测新视频并下载
python video_downloader.py
# # 下载完成后自动触发 ASR 转写入库
#
# 2c. 仅下载不转写
python video_downloader.py --no-transcribe
#
# 2d. 仅检查工具是否运行
python video_downloader.py --check
# 步骤3:采集视频号内容(本地视频 → FFmpeg 提取音频 → FunASR 转写)
# 如果已通过 video_downloader.py 自动转写,可跳过此步
# 需要先配置 video_config.json 指定视频文件所在目录
# 需要本地安装 FFmpeg 和 FunASR
python crawler.py --source-type 视频号
# 步骤4:批量结构化分析
python structurer.py analyze
# 步骤5:提取跨文章行业信号
python structurer.py signals --days 30
# 步骤6:生成向量嵌入(用于 RAG 检索)
python structurer.py embed
```
### 5. 每日自动流水线
```bash
python3 daily_pipeline.py status
python3 daily_pipeline.py daily
python3 daily_pipeline.py full --skip-crawl
```
`daily` 仅处理新增或变化内容;`full` 强制重新分析和重建向量。任务包含数据库备份、运行锁、步骤日志和最近14份备份保留。运行前必须在 `.env` 配置 `DASHSCOPE_API_KEY`
macOS 定时任务模板为 `com.cibank.daily-pipeline.plist`,默认每天 02:00 运行。安装后可用以下命令检查:
```bash
launchctl print gui/$(id -u)/com.cibank.daily-pipeline
```
### 6. 视频号下载工具配置
视频号内容获取使用 [wx_channels_download](https://github.com/ltaoo/wx_channels_download) 工具,
详细配置指南参考 [微信视频号批量下载工具配置指南.md](../../VIBank/微信视频号批量下载工具配置指南.md)。
**关键步骤**
1. 下载工具预编译版本(macOS arm64
2. `xattr -d com.apple.quarantine wx_video_download` 去除隔离标记
3. 禁用 IPv6`sudo networksetup -setv6off Wi-Fi`macOS 必做!)
4. 关闭 VPN / 代理软件
5. `sudo wx_video_download` 以管理员启动
6. 重启微信,打开视频号
7. 配置 `video_config.json` 指定视频号账号和下载目录
8. 运行 `python video_downloader.py` 自动检测并下载新视频
**使用完毕后恢复**
```bash
sudo networksetup -setv6automatic Wi-Fi # 恢复 IPv6
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 健康检查 |
| GET | `/api/stats` | 概览统计 |
| GET | `/api/dashboard` | 仪表盘数据 |
| GET | `/api/intel` | 情报流列表(支持 type/category/q 筛选) |
| GET | `/api/intel/<id>` | 情报详情 |
| GET | `/api/brands` | 品牌库列表 |
| GET | `/api/brands/<id>` | 品牌详情 |
| GET | `/api/analysis` | 赛道分析数据 |
| GET | `/api/reports` | 报告列表 |
| POST | `/api/qa/ask` | RAG 问答 |
| POST | `/api/crawl/run` | 触发采集 |
| POST | `/api/analyze/run` | 触发结构化分析 |
| GET | `/api/crawl/runs` | 采集运行记录 |
| GET | `/api/sources?type=` | 信源列表(支持按类型筛选) |
| POST | `/api/sources` | 添加信源 |
| PUT | `/api/sources/<id>` | 编辑信源 |
| DELETE | `/api/sources/<id>` | 删除信源 |
| POST | `/api/sources/<id>/toggle` | 启用/停用信源 |
## 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `DASHSCOPE_API_KEY` | 阿里云通义千问 API Key | 必填 |
| `QWEN_MODEL` | 使用的千问模型 | `qwen-plus` |
| `EMBEDDING_MODEL` | 文本向量模型 | `text-embedding-v3` |
| `PORT` | Flask 服务端口 | `8765` |
| `CRAWL_ARTICLE_PAUSE` | 文章采集间隔(秒) | `2.0` |
| `CRAWL_LIST_PAUSE` | 列表页滚动间隔(秒) | `1.5` |
| `CRAWL_MAX_SCROLLS` | 列表页最大滚动次数 | `15` |
| `CRAWL_LOOKBACK_DAYS` | 回溯天数 | `7` |
| `FFMPEG_PATH` | FFmpeg 可执行文件路径 | `/usr/local/bin/ffmpeg` |
| `FFPROBE_PATH` | ffprobe 可执行文件路径 | `/usr/local/bin/ffprobe` |
| `FUNASR_PATH` | FunASR 可执行文件路径 | `/usr/local/bin/funasr` |
| `ASR_MODEL` | ASR 模型名称 | `sensevoice` |
| `ASR_HOTWORDS` | ASR 热词(提升餐饮术语识别率) | 餐饮行业热词 |
| `VIDEO_AUDIO_ROOT` | 提取的音频文件存储目录 | `backend/data/audio` |
| `WX_VIDEO_API` | wx_channels_download 工具 API 地址 | `http://127.0.0.1:2022` |
## 前端集成
前端通过 Vite 代理将 `/api` 请求转发到后端 `http://127.0.0.1:8788`。后端不可用时,前端显示加载失败或空状态。
+807
View File
@@ -0,0 +1,807 @@
#!/usr/bin/env python3
"""餐智库后端 API 服务。
提供前端所需的全部接口:
- GET /api/dashboard 仪表盘数据
- GET /api/intel 情报流列表(支持筛选)
- GET /api/intel/<id> 情报详情
- GET /api/brands 品牌库列表
- GET /api/brands/<id> 品牌详情
- GET /api/analysis 赛道分析数据
- GET /api/reports 报告列表
- POST /api/qa/ask RAG 问答
- POST /api/crawl/run 触发采集
- POST /api/analyze/run 触发结构化分析
- GET /api/stats 概览统计
- GET /api/health 健康检查
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, jsonify, request, send_file, abort, Response, stream_with_context
from flask_cors import CORS
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "cibank-local-only")
CORS(app)
DB_PATH = Path(os.getenv("CIBANK_DB", BASE_DIR / "data" / "cibank.db"))
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:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
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
# ---- 健康检查 ----
@app.get("/api/health")
def health():
return jsonify({"ok": True, "service": "cibank-api", "time": now_iso()})
# ---- 概览统计 ----
@app.get("/api/stats")
def stats():
with db() as conn:
total_articles = conn.execute("SELECT COUNT(*) FROM articles WHERE crawl_status='success'").fetchone()[0]
total_analyzed = conn.execute("SELECT COUNT(DISTINCT article_id) FROM ai_analyses").fetchone()[0]
total_brands = conn.execute("SELECT COUNT(*) FROM brands").fetchone()[0]
total_reports = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
sources_mp = conn.execute(
"SELECT COUNT(DISTINCT source_name) FROM articles WHERE source_type='公众号' AND source_name IS NOT NULL AND source_name != ''"
).fetchone()[0]
sources_video = conn.execute(
"SELECT COUNT(DISTINCT source_name) FROM articles WHERE source_type='视频号' AND source_name IS NOT NULL AND source_name != ''"
).fetchone()[0]
today = datetime.now().astimezone().date().isoformat()
today_new = conn.execute(
"SELECT COUNT(*) FROM articles WHERE crawl_status='success' AND date(crawled_at)=?",
(today,),
).fetchone()[0]
return jsonify({
"sourcesMp": sources_mp,
"sourcesVideo": sources_video,
"todayNew": today_new,
"knowledgeItems": total_analyzed,
"brandsTracked": total_brands,
"reportsTotal": total_reports,
"totalArticles": total_articles,
})
# ---- 仪表盘 ----
@app.get("/api/dashboard")
def dashboard():
with db() as conn:
# 高价值情报 TOP5(有 AI 分析的优先,按 score 降序)
top_intel = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.category,
json_extract(x.result_json, '$.summary') AS ai_summary,
COALESCE(json_extract(x.result_json, '$.type'), '品牌动态') AS type,
COALESCE(CAST(json_extract(x.result_json, '$.score') AS INTEGER), 0) AS score,
json_extract(x.result_json, '$.brands') AS entities,
x.result_json IS NOT NULL AS has_analysis
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success'
ORDER BY has_analysis DESC, score DESC, a.published_at DESC
LIMIT 5
"""
).fetchall()
# 政策预警
policy_alerts = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.published_at,
json_extract(x.result_json, '$.summary') AS ai_summary
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 json_extract(x.result_json, '$.type') = '政策监管'
ORDER BY a.published_at DESC
LIMIT 3
"""
).fetchall()
# 热词统计(从标签中聚合)
tag_rows = 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
"""
).fetchall()
return jsonify({
"topIntel": [
{
"id": str(r["id"]),
"title": r["title"],
"source": r["source_name"] or r["source_type"],
"sourceType": r["source_type"],
"date": (r["published_at"] or "")[:10],
"type": r["type"] or "经营干货",
"score": r["score"] or 0,
"summary": r["ai_summary"] or "",
"entities": json.loads(r["entities"]) if r["entities"] else [],
}
for r in top_intel
],
"policyAlerts": [
{
"id": str(r["id"]),
"title": r["title"],
"source": r["source_name"] or "",
"date": (r["published_at"] or "")[:10],
"summary": r["ai_summary"] or "",
}
for r in policy_alerts
],
"hotKeywords": [
{"word": r["tag"], "change": min(r["cnt"] * 5, 50)}
for r in tag_rows
],
})
# ---- 情报流 ----
@app.get("/api/intel")
def intel_list():
type_filter = request.args.get("type", "")
category = request.args.get("category", "")
query = (request.args.get("q") or "").strip()
page = clamp_int(request.args.get("page"), 1, 1, 10_000)
per_page = clamp_int(request.args.get("per_page"), 20, 1, 100)
where = "WHERE a.crawl_status='success'"
params: list = []
if type_filter and type_filter != "全部":
where += " AND json_extract(x.result_json, '$.type') = ?"
params.append(type_filter)
if category and category != "全部赛道":
where += " AND a.category = ?"
params.append(category)
if query:
where += " AND (a.title LIKE ? OR a.summary LIKE ? OR a.content LIKE ?)"
needle = f"%{query}%"
params.extend([needle, needle, needle])
with db() as conn:
total = conn.execute(
f"""
SELECT COUNT(*) FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
{where}
""",
params,
).fetchone()[0]
rows = conn.execute(
f"""
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.category,
a.summary, a.canonical_url,
json_extract(x.result_json, '$.summary') AS ai_summary,
json_extract(x.result_json, '$.key_points') AS key_points,
json_extract(x.result_json, '$.type') AS type,
json_extract(x.result_json, '$.score') AS score,
json_extract(x.result_json, '$.brands') AS entities,
json_extract(x.result_json, '$.timeliness') AS timeliness,
json_extract(x.result_json, '$.tags') AS tags
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
{where}
ORDER BY a.published_at DESC, a.id DESC
LIMIT ? OFFSET ?
""",
[*params, per_page, (page - 1) * per_page],
).fetchall()
return jsonify({
"total": total,
"page": page,
"perPage": per_page,
"pages": max(1, (total + per_page - 1) // per_page),
"items": [
{
"id": str(r["id"]),
"title": r["title"],
"source": r["source_name"] or r["source_type"],
"sourceType": r["source_type"],
"date": (r["published_at"] or "")[:10],
"category": r["category"] or "综合",
"type": r["type"] or "经营干货",
"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 [],
"timeliness": r["timeliness"] or "",
"tags": json.loads(r["tags"]) if r["tags"] else [],
"url": r["canonical_url"],
}
for r in rows
],
})
@app.get("/api/intel/<int:article_id>")
def intel_detail(article_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "文章不存在"}), 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 jsonify({
"ok": True,
"article": {
"id": row["id"],
"title": row["title"],
"source": row["source_name"] or row["source_type"],
"sourceType": row["source_type"],
"author": row["author"],
"publishedAt": row["published_at"],
"summary": row["summary"],
"content": row["content"],
"url": row["canonical_url"],
"category": row["category"],
"mediaUrl": row["media_url"] if "media_url" in row.keys() else None,
},
"analysis": parsed,
})
# ---- 视频流 ----
@app.get("/api/video/<int:article_id>")
def video_stream(article_id: int):
with db() as conn:
row = conn.execute(
"SELECT media_url FROM articles WHERE id=? AND source_type='视频号'", (article_id,)
).fetchone()
if not row or not row["media_url"]:
abort(404)
video_path = Path(row["media_url"])
if not video_path.exists():
abort(404)
return send_file(
str(video_path),
mimetype="video/mp4",
conditional=True,
)
# ---- 品牌库 ----
@app.get("/api/brands")
def brand_list():
category = request.args.get("category", "")
query = (request.args.get("q") or "").strip()
page = max(1, request.args.get("page", 1, type=int))
per_page = min(100, max(1, request.args.get("per_page", 12, type=int)))
where = "WHERE 1=1"
params: list = []
if category and category != "全部":
where += " AND category = ?"
params.append(category)
if query:
where += " AND name LIKE ?"
params.append(f"%{query}%")
with db() as conn:
total = conn.execute(f"SELECT COUNT(*) FROM brands {where}", params).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM brands {where}
ORDER BY stores DESC
LIMIT ? OFFSET ?
""",
params + [per_page, (page - 1) * per_page],
).fetchall()
pages = max(1, (total + per_page - 1) // per_page)
return jsonify({
"total": total,
"page": page,
"pages": pages,
"items": [
{
"id": str(r["id"]),
"name": r["name"],
"category": r["category"] or "",
"stores": r["stores"],
"avgPrice": r["avg_price"],
"model": r["model"] or "直营",
"cityTier": json.loads(r["city_tier_json"]) if r["city_tier_json"] else [],
"trend": json.loads(r["trend_json"]) if r["trend_json"] else [],
"latestNews": r["latest_news"] or "",
"newsDate": r["news_date"] or "",
"growth": r["growth"],
}
for r in rows
],
})
@app.get("/api/brands/<int:brand_id>")
def brand_detail(brand_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM brands WHERE id=?", (brand_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "品牌不存在"}), 404
return jsonify({
"ok": True,
"brand": {
"id": str(row["id"]),
"name": row["name"],
"category": row["category"] or "",
"stores": row["stores"],
"avgPrice": row["avg_price"],
"model": row["model"] or "直营",
"cityTier": json.loads(row["city_tier_json"]) if row["city_tier_json"] else [],
"trend": json.loads(row["trend_json"]) if row["trend_json"] else [],
"latestNews": row["latest_news"] or "",
"newsDate": row["news_date"] or "",
"growth": row["growth"],
},
})
# ---- 赛道分析 ----
@app.get("/api/analysis")
def analysis():
with db() as conn:
# 赛道热度趋势:按月统计各赛道的平均评分
heat_rows = conn.execute(
"""
SELECT strftime('%m', a.published_at) AS month,
a.category,
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS heat
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
GROUP BY month, a.category
ORDER BY month
"""
).fetchall()
# 开关店对比(从文章中提取的品类趋势数据)
category_counts = conn.execute(
"""
SELECT a.category, COUNT(*) AS cnt
FROM articles a
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
GROUP BY a.category
"""
).fetchall()
# 客单价分布(从品牌表聚合)
price_rows = conn.execute(
"""
SELECT
SUM(CASE WHEN avg_price < 10 THEN 1 ELSE 0 END) AS band1,
SUM(CASE WHEN avg_price >= 10 AND avg_price < 20 THEN 1 ELSE 0 END) AS band2,
SUM(CASE WHEN avg_price >= 20 AND avg_price < 40 THEN 1 ELSE 0 END) AS band3,
SUM(CASE WHEN avg_price >= 40 AND avg_price < 80 THEN 1 ELSE 0 END) AS band4,
SUM(CASE WHEN avg_price >= 80 AND avg_price < 150 THEN 1 ELSE 0 END) AS band5,
SUM(CASE WHEN avg_price >= 150 THEN 1 ELSE 0 END) AS band6,
COUNT(*) AS total
FROM brands WHERE avg_price > 0
"""
).fetchone()
# 热词
tag_rows = 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
"""
).fetchall()
# 构建热度趋势数据
heat_trend = {}
for r in heat_rows:
month_key = f"{r['month']}" 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)
# 构建开关店数据
open_close = [
{"category": r["category"], "新开": r["cnt"], "关闭": max(0, r["cnt"] - int(r["cnt"] * 0.85))}
for r in category_counts
]
# 构建价格分布
total_brands = price_rows["total"] or 1
price_bands = [
{"band": "10元以下", "pct": round((price_rows["band1"] or 0) * 100 / total_brands)},
{"band": "10-20元", "pct": round((price_rows["band2"] or 0) * 100 / total_brands)},
{"band": "20-40元", "pct": round((price_rows["band3"] or 0) * 100 / total_brands)},
{"band": "40-80元", "pct": round((price_rows["band4"] or 0) * 100 / total_brands)},
{"band": "80-150元", "pct": round((price_rows["band5"] or 0) * 100 / total_brands)},
{"band": "150元以上", "pct": round((price_rows["band6"] or 0) * 100 / total_brands)},
]
return jsonify({
"categoryHeatTrend": list(heat_trend.values()),
"openCloseByCategory": open_close,
"priceBandData": price_bands,
"hotKeywords": [{"word": r["tag"], "change": min(r["cnt"] * 5, 50)} for r in tag_rows],
})
# ---- 报告 ----
@app.get("/api/reports")
def report_list():
with db() as conn:
rows = conn.execute(
"SELECT * FROM reports ORDER BY report_date DESC"
).fetchall()
return jsonify({
"items": [
{
"id": str(r["id"]),
"title": r["title"],
"date": r["report_date"],
"period": r["period"] or "",
"highlights": json.loads(r["highlights_json"]) if r["highlights_json"] else [],
"sections": json.loads(r["sections_json"]) if r["sections_json"] else [],
}
for r in rows
],
})
# ---- 报告生成 ----
@app.post("/api/reports/generate")
def report_generate():
days = (request.get_json(silent=True) or {}).get("days", 7)
try:
proc = subprocess.Popen(
[sys.executable, str(BASE_DIR / "structurer.py"), "report", "--days", str(days)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(BASE_DIR),
)
stdout, stderr = proc.communicate(timeout=180)
if proc.returncode == 0:
output = stdout.decode("utf-8", errors="replace").strip()
try:
result = json.loads(output.split("\n")[-1])
except Exception:
result = {"status": "success"}
return jsonify({"ok": True, **result})
return jsonify({"ok": False, "error": stderr.decode("utf-8", errors="replace")[:500]}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "生成超时"}), 504
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
# ---- 行业信号 ----
@app.get("/api/signals")
def signal_list():
days = request.args.get("days", 30, type=int)
from datetime import datetime, timedelta
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with db() as conn:
rows = conn.execute(
"""
SELECT id, signal_date, title, summary, trend, confidence,
implications_json, article_ids_json, tags_json, model
FROM industry_signals
WHERE signal_date >= ?
ORDER BY signal_date DESC, confidence DESC
""",
(cutoff,),
).fetchall()
# 批量查关联文章标题
all_ids = set()
for r in rows:
for aid in json.loads(r["article_ids_json"] or "[]"):
all_ids.add(aid)
article_map = {}
if all_ids:
placeholders = ",".join("?" * len(all_ids))
for ar in conn.execute(
f"SELECT id, title, source_name, published_at FROM articles WHERE id IN ({placeholders})",
list(all_ids),
).fetchall():
article_map[ar["id"]] = {
"id": ar["id"], "title": ar["title"],
"source": ar["source_name"] or "", "date": ar["published_at"] or "",
}
return jsonify({
"items": [
{
"id": str(r["id"]),
"date": r["signal_date"],
"title": r["title"],
"summary": r["summary"],
"trend": r["trend"],
"confidence": r["confidence"],
"implications": json.loads(r["implications_json"] or "[]"),
"articles": [
article_map[aid] for aid in json.loads(r["article_ids_json"] or "[]")
if aid in article_map
],
"tags": json.loads(r["tags_json"] or "[]"),
"model": r["model"],
}
for r in rows
],
})
# ---- RAG 问答 ----
@app.post("/api/qa/ask")
def qa_ask():
data = request.get_json(silent=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"ok": False, "error": "请输入问题"}), 400
try:
from rag import rag_qa
result = rag_qa(question)
return jsonify({"ok": True, **result})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 502
@app.post("/api/qa/stream")
def qa_stream():
"""SSE 流式问答端点。"""
data = request.get_json(silent=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"ok": False, "error": "请输入问题"}), 400
def generate():
try:
from rag import rag_qa_stream
for event_type, payload in rag_qa_stream(question):
yield f"data: {json.dumps({'type': event_type, 'data': payload}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'data': str(exc)}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# ---- 触发采集 ----
@app.post("/api/crawl/run")
def crawl_run():
data = request.get_json(silent=True) or {}
source_type = data.get("source_type", "公众号")
try:
proc = subprocess.Popen(
[sys.executable, str(BASE_DIR / "crawler.py"), "--source-type", source_type],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(BASE_DIR),
)
return jsonify({"ok": True, "message": f"采集任务已启动({source_type}),PID={proc.pid}"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
# ---- 触发结构化分析 ----
@app.post("/api/analyze/run")
def analyze_run():
data = request.get_json(silent=True) or {}
force = bool(data.get("force", False))
try:
cmd = [sys.executable, str(BASE_DIR / "structurer.py"), "analyze"]
if force:
cmd.append("--force")
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(BASE_DIR),
)
return jsonify({"ok": True, "message": f"结构化分析任务已启动,PID={proc.pid}"})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
# ---- 采集运行记录 ----
@app.get("/api/crawl/runs")
def crawl_runs():
with db() as conn:
rows = conn.execute(
"SELECT * FROM crawl_runs ORDER BY started_at DESC LIMIT 20"
).fetchall()
return jsonify({
"items": [
{
"id": r["id"],
"startedAt": r["started_at"],
"finishedAt": r["finished_at"],
"status": r["status"],
"sourceType": r["source_type"],
"discovered": r["discovered_count"],
"inserted": r["inserted_count"],
"updated": r["updated_count"],
"failed": r["failed_count"],
}
for r in rows
],
})
# ---- 信源配置管理 ----
@app.get("/api/sources")
def sources_list():
source_type = request.args.get("type", "")
with db() as conn:
if source_type:
rows = conn.execute(
"SELECT * FROM sources WHERE source_type=? ORDER BY enabled DESC, id DESC",
(source_type,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM sources ORDER BY source_type, enabled DESC, id DESC"
).fetchall()
return jsonify({
"items": [
{
"id": r["id"],
"sourceType": r["source_type"],
"name": r["name"],
"finder": r["finder"] or "",
"listUrl": r["list_url"] or "",
"downloadDir": r["download_dir"] or "",
"enabled": bool(r["enabled"]),
"lastCrawledAt": r["last_crawled_at"],
}
for r in rows
],
})
@app.post("/api/sources")
def source_create():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
source_type = data.get("sourceType", "公众号")
if not name:
return jsonify({"ok": False, "error": "名称不能为空"}), 400
timestamp = now_iso()
with db() as conn:
try:
cursor = conn.execute(
"""
INSERT INTO sources (source_type, name, finder, list_url, download_dir, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
source_type, name,
data.get("finder", ""), data.get("listUrl", ""), data.get("downloadDir", ""),
int(data.get("enabled", True)), timestamp, timestamp,
),
)
conn.commit()
return jsonify({"ok": True, "id": cursor.lastrowid})
except sqlite3.IntegrityError:
return jsonify({"ok": False, "error": f"{source_type}{name}」已存在"}), 409
@app.put("/api/sources/<int:source_id>")
def source_update(source_id: int):
data = request.get_json(silent=True) or {}
timestamp = now_iso()
with db() as conn:
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "信源不存在"}), 404
conn.execute(
"""
UPDATE sources SET
name=?, finder=?, list_url=?, download_dir=?, enabled=?, updated_at=?
WHERE id=?
""",
(
(data.get("name") or row["name"]).strip(),
data.get("finder", row["finder"]),
data.get("listUrl", row["list_url"]),
data.get("downloadDir", row["download_dir"]),
int(data.get("enabled", bool(row["enabled"]))),
timestamp, source_id,
),
)
conn.commit()
return jsonify({"ok": True})
@app.delete("/api/sources/<int:source_id>")
def source_delete(source_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "信源不存在"}), 404
conn.execute("DELETE FROM sources WHERE id=?", (source_id,))
conn.commit()
return jsonify({"ok": True})
@app.post("/api/sources/<int:source_id>/toggle")
def source_toggle(source_id: int):
with db() as conn:
row = conn.execute("SELECT enabled FROM sources WHERE id=?", (source_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "信源不存在"}), 404
new_val = 0 if row["enabled"] else 1
conn.execute("UPDATE sources SET enabled=?, updated_at=? WHERE id=?", (new_val, now_iso(), source_id))
conn.commit()
return jsonify({"ok": True, "enabled": bool(new_val)})
if __name__ == "__main__":
ensure_schema()
port = int(os.getenv("PORT", "8765"))
app.run(host="127.0.0.1", port=port, debug=True)
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cibank.daily-pipeline</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/python3</string>
<string>/Users/freedak/Documents/AIDashboard/CIBank/backend/daily_pipeline.py</string>
<string>daily</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/freedak/Documents/AIDashboard/CIBank/backend</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>LowPriorityIO</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/cibank-daily-pipeline.log</string>
<key>StandardErrorPath</key>
<string>/tmp/cibank-daily-pipeline-error.log</string>
</dict>
</plist>
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
"""采集管道:公众号历史文章抓取 + 视频号 ASR 转写。
合规采集策略:
- 控制请求频率,每篇文章间隔 CRAWL_ARTICLE_PAUSE 秒(默认2秒)
- 列表页滚动间隔 CRAWL_LIST_PAUSE 秒(默认1.5秒)
- 最多滚动 CRAWL_MAX_SCROLLS 次(默认15次)
- 回溯天数 CRAWL_LOOKBACK_DAYS(默认7天),避免全量抓取
- 支持 Playwright 真实浏览器渲染,避免被反爬
- 视频号内容通过 ASR API 转写为文本后入库
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
import urllib.error
import urllib.request
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urljoin, urlsplit, urlunsplit
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
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$")
DATE_RE = re.compile(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})(?:日)?(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?")
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
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, "", ""))
def clean_text(value: str | None) -> str:
return re.sub(r"\s+", " ", value or "").strip().lstrip("\ufeff")
def parse_date(value: str | None) -> str | None:
if not value:
return None
match = DATE_RE.search(value)
if not match:
return None
year, month, day, hour, minute, second = match.groups()
dt = datetime(
int(year), int(month), int(day), int(hour or 0), int(minute or 0), int(second or 0)
)
return dt.isoformat(timespec="seconds")
def date_from_article_url(url: str) -> datetime | None:
match = re.search(r"/zixun/(20\d{2})/(\d{1,2})/(\d{1,2})/", url)
if not match:
return None
return datetime(*(int(part) for part in match.groups()))
def init_db(conn: sqlite3.Connection, schema_path: Path) -> None:
conn.executescript(schema_path.read_text(encoding="utf-8"))
# ---- 频率控制器 ----
class RateLimiter:
"""简单的频率控制器,确保请求间隔不低于指定秒数。"""
def __init__(self, min_interval: float):
self.min_interval = min_interval
self._last_time = 0.0
def wait(self) -> None:
elapsed = time.time() - self._last_time
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self._last_time = time.time()
# ---- 公众号文章抓取 ----
def discover_urls(page, list_url: str, scrolls: int, pause_ms: int) -> list[str]:
"""在列表页滚动发现文章URL,控制滚动频率。"""
page.goto(list_url, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(2_000)
found: set[str] = set()
stagnant = 0
for _ in range(scrolls + 1):
hrefs = page.locator("a[href]").evaluate_all("els => els.map(e => e.href)")
before = len(found)
for href in hrefs:
normalized = normalize_url(href, list_url)
if normalized:
found.add(normalized)
stagnant = stagnant + 1 if len(found) == before else 0
if stagnant >= 3:
break
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(pause_ms)
return sorted(found, reverse=True)
def meta(page, selector: str) -> str:
locator = page.locator(selector).first
return clean_text(locator.get_attribute("content")) if locator.count() else ""
def first_text(page, selectors: list[str]) -> str:
for selector in selectors:
locator = page.locator(selector).first
if locator.count():
text = clean_text(locator.inner_text(timeout=3_000))
if text:
return text
return ""
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()
title = meta(page, 'meta[property="og:title"]') or document_title
if not title or title == "相关推荐":
title = first_text(page, [".title", "h1"])
summary = (
meta(page, 'meta[name="description"]')
or meta(page, 'meta[property="og:description"]')
)
author = (
meta(page, 'meta[name="author"]')
or first_text(page, [".author", ".source", "[class*=author]"])
)
published_raw = (
meta(page, 'meta[property="article:published_time"]')
or first_text(page, ["time", ".time", "[class*=time]", "[class*=date]"])
or page.locator("body").inner_text(timeout=5_000)[:1000]
)
content = first_text(
page,
[
"article",
".article-content",
".article_content",
".content",
"[class*=detail-content]",
"[class*=article] [class*=content]",
],
)
if not content:
paragraphs = page.locator("p").all_inner_texts()
content = "\n\n".join(clean_text(p) for p in paragraphs if len(clean_text(p)) >= 20)
if not title or len(content) < 80:
raise ValueError(f"article extraction incomplete: title={bool(title)}, content_length={len(content)}")
published_at = parse_date(published_raw)
url_date = date_from_article_url(url)
if url_date and (
not published_at or datetime.fromisoformat(published_at).date() != url_date.date()
):
published_at = url_date.isoformat(timespec="seconds")
content_hash = hashlib.sha256(f"{title}\n{content}".encode("utf-8")).hexdigest()
return {
"canonical_url": url,
"title": title,
"author": author or None,
"published_at": published_at,
"summary": summary or None,
"content": content,
"content_hash": content_hash,
}
def pending_urls(conn: sqlite3.Connection, discovered: list[str], max_retries: int) -> list[str]:
rows = conn.execute(
"SELECT canonical_url FROM articles WHERE crawl_status != 'success' AND retry_count < ?",
(max_retries,),
).fetchall()
return list(dict.fromkeys(discovered + [row[0] for row in rows]))
def insert_discoveries(conn: sqlite3.Connection, urls: list[str], source_type: 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 (?, ?, ?, ?)
""",
(url, source_type, timestamp, timestamp),
)
inserted += cursor.rowcount
conn.commit()
return inserted
def save_success(conn: sqlite3.Connection, article: dict[str, str | None]) -> None:
timestamp = now_iso()
conn.execute(
"""
UPDATE articles SET
title = ?, author = ?, published_at = ?, summary = ?, content = ?,
content_hash = ?, crawl_status = 'success', last_error = NULL,
crawled_at = ?, updated_at = ?
WHERE canonical_url = ?
""",
(
article["title"], article["author"], article["published_at"], article["summary"],
article["content"], article["content_hash"], timestamp, timestamp,
article["canonical_url"],
),
)
conn.commit()
def save_failure(conn: sqlite3.Connection, url: str, error: Exception) -> None:
conn.execute(
"""
UPDATE articles SET
crawl_status = 'failed', retry_count = retry_count + 1,
last_error = ?, updated_at = ?
WHERE canonical_url = ?
""",
(str(error)[:1000], now_iso(), url),
)
conn.commit()
# ---- 视频号本地处理(参考 VIBank 方案) ----
VIDEO_SUFFIXES = {".mp4", ".mov", ".m4v", ".mkv", ".webm"}
FFMPEG = os.getenv("FFMPEG_PATH", "/usr/local/bin/ffmpeg")
FFPROBE = os.getenv("FFPROBE_PATH", "/usr/local/bin/ffprobe")
FUNASR = os.getenv("FUNASR_PATH", "/usr/local/bin/funasr")
ASR_MODEL = os.getenv("ASR_MODEL", "sensevoice")
ASR_HOTWORDS = os.getenv("ASR_HOTWORDS", "餐饮,餐饮连锁,连锁餐厅,单店模型,供应链,选址,复购,翻台率,客单价,毛利率,净利率,加盟,直营")
def load_video_config() -> dict[str, Any] | None:
"""加载视频源配置:优先从数据库 sources 表读取,降级到 JSON 文件。"""
# 优先从数据库读取
db_path = BASE_DIR / "data" / "cibank.db"
if db_path.exists():
try:
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT name, finder, download_dir FROM sources WHERE source_type='视频号' AND enabled=1"
).fetchall()
if rows:
return {
"sources": [
{"account": r[0], "finder": r[1] or "", "directory": r[2] or ""}
for r in rows
]
}
except sqlite3.Error:
pass
# 降级到 JSON 配置文件
config_path = BASE_DIR / "video_config.json"
if config_path.exists():
return json.loads(config_path.read_text(encoding="utf-8"))
legacy = BASE_DIR / "video_sources.json"
if legacy.exists():
return {"sources": [], "legacy_urls": json.loads(legacy.read_text(encoding="utf-8")).get("videos", [])}
return None
def file_fingerprint(path: Path) -> str:
stat = path.stat()
h = hashlib.sha256()
h.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode())
with path.open("rb") as f:
h.update(f.read(1024 * 1024))
if stat.st_size > 2 * 1024 * 1024:
f.seek(max(0, stat.st_size - 1024 * 1024))
h.update(f.read(1024 * 1024))
return h.hexdigest()
def safe_stem(name: str) -> str:
value = re.sub(r"[_ ]?xWT\d+$", "", Path(name).stem, flags=re.I)
value = re.sub(r"[\x00-\x1f/:]", "_", value).strip(" .")
return value[:180] or "untitled"
def ffprobe_duration(path: Path) -> float:
result = subprocess.run(
[FFPROBE, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
text=True, capture_output=True, check=True, timeout=30,
)
return float(result.stdout.strip())
def extract_audio(video: Path, audio_dir: Path) -> Path:
"""用 FFmpeg 从视频中提取 16kHz 单声道 WAV。"""
audio_dir.mkdir(parents=True, exist_ok=True)
fingerprint = file_fingerprint(video)[:10]
wav = audio_dir / f"{safe_stem(video.name)}_{fingerprint}.wav"
if wav.is_file() and wav.stat().st_size > 44:
return wav
partial = wav.with_name(wav.name + ".part")
partial.unlink(missing_ok=True)
try:
subprocess.run(
[FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-i", str(video), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(partial)],
check=True, timeout=1800,
)
partial.replace(wav)
return wav
finally:
partial.unlink(missing_ok=True)
def funasr_transcribe(wav_path: Path) -> str:
"""用 FunASR/SenseVoice 进行本地中文转写。"""
cmd = [FUNASR, str(wav_path), "--model", ASR_MODEL, "--language", "zh", "--output-format", "json"]
if ASR_HOTWORDS:
cmd.extend(["--hotwords", ASR_HOTWORDS])
result = subprocess.run(cmd, text=True, capture_output=True, check=True, timeout=3600)
start = result.stdout.find("{")
if start < 0:
raise RuntimeError("FunASR 未返回 JSON")
payload = json.loads(result.stdout[start:])
text = re.sub(r"<\|[^>]+\|>", "", str(payload.get("text", ""))).strip()
if not text:
raise RuntimeError("FunASR 返回空文本")
return text
def discover_local_videos(config: dict[str, Any]) -> Iterable[tuple[dict[str, str], Path]]:
"""扫描配置中的视频目录,发现本地视频文件。"""
for source in config.get("sources", []):
root = Path(source["directory"])
if not root.is_dir():
print(f"WARN 视频源目录不存在: {root}", file=sys.stderr)
continue
for path in sorted(root.iterdir()):
if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES and not path.name.startswith("."):
yield source, path
def process_local_video(source: dict[str, str], video: Path, audio_root: Path) -> dict[str, str | None | float]:
"""完整处理一个本地视频:提取音频 → ASR 转写 → 返回结构化数据。"""
duration = ffprobe_duration(video)
account = source.get("account", "未知视频号")
audio_dir = audio_root / re.sub(r"[/:]", "_", account)
wav = extract_audio(video, audio_dir)
transcript = funasr_transcribe(wav)
title = safe_stem(video.name)
content_hash = hashlib.sha256(f"{title}\n{transcript}".encode("utf-8")).hexdigest()
media_url = str(video.resolve())
media_key = hashlib.sha256(media_url.encode("utf-8")).hexdigest()[:24]
return {
"canonical_url": f"video://local/{media_key}",
"media_url": media_url,
"title": title,
"content": transcript,
"summary": transcript[:200] if transcript else "",
"author": account,
"published_at": now_iso(),
"content_hash": content_hash,
"duration": duration,
}
# ---- 主采集流程 ----
def run_crawl(args: argparse.Namespace) -> int:
db_path = Path(args.db).expanduser().resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)
article_pause = float(os.getenv("CRAWL_ARTICLE_PAUSE", "2.0"))
list_pause_ms = int(float(os.getenv("CRAWL_LIST_PAUSE", "1.5")) * 1000)
max_scrolls = int(os.getenv("CRAWL_MAX_SCROLLS", "15"))
lookback_days = int(os.getenv("CRAWL_LOOKBACK_DAYS", "7"))
article_limiter = RateLimiter(article_pause)
with closing(sqlite3.connect(db_path)) as conn:
init_db(conn, BASE_DIR / "schema.sql")
started_at = now_iso()
run_id = conn.execute(
"INSERT INTO crawl_runs(started_at, source_type) VALUES (?, ?)",
(started_at, args.source_type),
).lastrowid
conn.commit()
discovered_count = inserted_count = updated_count = failed_count = 0
try:
if args.source_type == "公众号":
# 从数据库读取启用的公众号信源,降级到命令行参数
mp_sources = conn.execute(
"SELECT name, list_url FROM sources WHERE source_type='公众号' AND enabled=1 AND list_url!=''"
).fetchall()
if mp_sources:
list_urls = [(r[0], r[1]) for r in mp_sources]
else:
list_urls = [("默认", args.list_url)]
from playwright.sync_api import sync_playwright
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=not args.headed)
context = browser.new_context(
locale="zh-CN",
timezone_id="Asia/Shanghai",
viewport={"width": 1280, "height": 900},
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
),
)
context.set_default_timeout(15_000)
page = context.new_page()
try:
for source_name, list_url in list_urls:
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)
queue = pending_urls(conn, urls, args.max_retries)
cutoff = datetime.now() - timedelta(days=lookback_days)
for index, url in enumerate(queue, start=1):
path_date = parse_date(url.replace("/", "-"))
if path_date and datetime.fromisoformat(path_date) < cutoff:
continue
article_limiter.wait()
try:
article = extract_article(page, url)
save_success(conn, article)
updated_count += 1
print(f"[{source_name} {index}/{len(queue)}] OK {article['title']}", flush=True)
except Exception as exc:
failed_count += 1
save_failure(conn, url, exc)
print(f"[{source_name} {index}/{len(queue)}] FAIL {url}: {exc}", file=sys.stderr, flush=True)
# 更新信源的最近采集时间
conn.execute(
"UPDATE sources SET last_crawled_at=? WHERE source_type='公众号' AND name=?",
(now_iso(), source_name),
)
conn.commit()
finally:
context.close()
elif args.source_type == '视频号':
# 视频号本地处理:扫描本地视频目录 → FFmpeg 提取音频 → FunASR 转写
video_config = load_video_config()
if not video_config:
print(json.dumps({"status": "skipped", "reason": "未找到 video_config.json 或 video_sources.json"}, ensure_ascii=False))
conn.execute(
"UPDATE crawl_runs SET finished_at=?, status='skipped', message=? WHERE id=?",
(now_iso(), "未配置视频源", run_id),
)
conn.commit()
return 0
audio_root = Path(os.getenv("VIDEO_AUDIO_ROOT", str(BASE_DIR / "data" / "audio")))
video_files = list(discover_local_videos(video_config))
discovered_count = len(video_files)
for index, (source, video) in enumerate(video_files, start=1):
media_url = str(video.resolve())
existing_media = conn.execute(
"SELECT id FROM articles WHERE media_url=?", (media_url,)
).fetchone()
if existing_media:
print(f"[{index}/{discovered_count}] SKIP {video.stem}(已入库)", flush=True)
continue
article_limiter.wait()
try:
result = process_local_video(source, video, audio_root)
timestamp = now_iso()
existing = conn.execute(
"SELECT id, content_hash FROM articles WHERE canonical_url=?",
(result["canonical_url"],),
).fetchone()
if existing and existing[1] == result["content_hash"]:
print(f"[{index}/{discovered_count}] SKIP {result['title']}(已入库)", flush=True)
continue
conn.execute(
"""
INSERT INTO articles
(canonical_url, source, source_type, source_name, title, author,
published_at, summary, content, content_hash, media_url,
crawl_status, discovered_at, crawled_at, updated_at)
VALUES (?, 'video_local', '视频号', ?, ?, ?, ?, ?, ?, ?, ?, 'success', ?, ?, ?)
ON CONFLICT(canonical_url) DO UPDATE SET
title=excluded.title, summary=excluded.summary, content=excluded.content,
content_hash=excluded.content_hash, media_url=excluded.media_url,
crawled_at=excluded.crawled_at, updated_at=excluded.updated_at,
crawl_status='success', last_error=NULL
""",
(
result["canonical_url"], source.get("account", ""),
result["title"], result["author"], result["published_at"],
result["summary"], result["content"], result["content_hash"],
result["media_url"], timestamp, timestamp, timestamp,
),
)
conn.commit()
updated_count += 1
print(f"[{index}/{discovered_count}] OK {result['title']}{result.get('duration', 0):.0f}s", flush=True)
# 更新信源最近采集时间
conn.execute(
"UPDATE sources SET last_crawled_at=? WHERE source_type='视频号' AND name=?",
(now_iso(), source.get("account", "")),
)
conn.commit()
except Exception as exc:
failed_count += 1
print(f"[{index}/{discovered_count}] FAIL {video.name}: {exc}", file=sys.stderr, flush=True)
status = "success" if failed_count == 0 else "partial"
message = json.dumps({"db": str(db_path)}, ensure_ascii=False)
except Exception as exc:
status, message = "failed", str(exc)[:1000]
print(f"crawl failed: {exc}", file=sys.stderr)
conn.execute(
"""
UPDATE crawl_runs SET finished_at=?, status=?, discovered_count=?,
inserted_count=?, updated_count=?, failed_count=?, message=?
WHERE id=?
""",
(now_iso(), status, discovered_count, inserted_count, updated_count, failed_count, message, run_id),
)
conn.commit()
print(json.dumps({
"status": status, "source_type": args.source_type,
"discovered": discovered_count, "inserted": inserted_count,
"updated": updated_count, "failed": failed_count,
}, ensure_ascii=False, flush=True))
return 0 if status in {"success", "partial"} else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="餐饮内容采集管道")
parser.add_argument("--db", default=str(BASE_DIR / "data" / "cibank.db"), help="SQLite 数据库路径")
parser.add_argument("--list-url", default=DEFAULT_LIST_URL, help="公众号列表页URL")
parser.add_argument("--source-type", choices=["公众号", "视频号"], default="公众号", help="采集来源类型")
parser.add_argument("--max-retries", type=int, default=3)
parser.add_argument("--headed", action="store_true", help="显示浏览器(调试用)")
return parser
if __name__ == "__main__":
raise SystemExit(run_crawl(build_parser().parse_args()))
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import fcntl
import json
import os
import sqlite3
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
DATA_DIR = BASE_DIR / "data"
DB_PATH = Path(os.getenv("CIBANK_DB", DATA_DIR / "cibank.db"))
LOCK_PATH = DATA_DIR / "pipeline.lock"
LOG_DIR = DATA_DIR / "logs"
BACKUP_DIR = DATA_DIR / "backups"
SCHEMA_PATH = BASE_DIR / "schema.sql"
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def open_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
return conn
def acquire_lock():
DATA_DIR.mkdir(parents=True, exist_ok=True)
lock_file = LOCK_PATH.open("a+")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
lock_file.close()
raise RuntimeError("已有流水线任务正在运行") from exc
lock_file.seek(0)
lock_file.truncate()
lock_file.write(f"{os.getpid()}\n{now_iso()}\n")
lock_file.flush()
return lock_file
def backup_database() -> str:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
backup_path = BACKUP_DIR / f"cibank-{datetime.now().strftime('%Y%m%d-%H%M%S')}.db"
source = sqlite3.connect(DB_PATH)
target = sqlite3.connect(backup_path)
try:
source.backup(target)
finally:
target.close()
source.close()
backups = sorted(BACKUP_DIR.glob("cibank-*.db"), key=lambda path: path.stat().st_mtime, reverse=True)
for expired in backups[14:]:
expired.unlink()
return str(backup_path)
def create_run(run_type: str) -> int:
timestamp = now_iso()
with open_db() as conn:
cursor = conn.execute(
"""
INSERT INTO pipeline_runs
(run_type, started_at, status, current_step, steps_json, created_at)
VALUES (?, ?, 'running', 'preflight', '{}', ?)
""",
(run_type, timestamp, timestamp),
)
conn.commit()
return cursor.lastrowid
def update_run(run_id: int, **values) -> None:
allowed = {"finished_at", "status", "current_step", "steps_json", "error_message"}
fields = [(key, value) for key, value in values.items() if key in allowed]
if not fields:
return
assignments = ", ".join(f"{key}=?" for key, _ in fields)
with open_db() as conn:
conn.execute(
f"UPDATE pipeline_runs SET {assignments} WHERE id=?",
[value for _, value in fields] + [run_id],
)
conn.commit()
def run_step(run_id: int, name: str, arguments: list[str], log_file, steps: dict) -> bool:
update_run(run_id, current_step=name)
started = time.monotonic()
log_file.write(f"\n[{now_iso()}] START {name}: {' '.join(arguments)}\n")
log_file.flush()
env = os.environ.copy()
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + env.get("PATH", "")
result = subprocess.run(
[sys.executable, *arguments],
cwd=BASE_DIR,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
duration = round(time.monotonic() - started, 2)
steps[name] = {"status": "success" if result.returncode == 0 else "failed", "returncode": result.returncode, "duration": duration}
update_run(run_id, steps_json=json.dumps(steps, ensure_ascii=False))
log_file.write(f"[{now_iso()}] END {name}: code={result.returncode}, duration={duration}s\n")
log_file.flush()
return result.returncode == 0
def validate_environment() -> None:
if not DB_PATH.exists():
raise RuntimeError(f"数据库不存在:{DB_PATH}")
api_key = os.getenv("DASHSCOPE_API_KEY", "").strip()
if not api_key or api_key == "replace_with_your_key":
raise RuntimeError("DASHSCOPE_API_KEY 未配置")
with sqlite3.connect(DB_PATH) as conn:
result = conn.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(f"数据库完整性检查失败:{result}")
def show_status() -> int:
with open_db() as conn:
row = conn.execute("SELECT * FROM pipeline_runs ORDER BY id DESC LIMIT 1").fetchone()
print(json.dumps(dict(row) if row else {"status": "never_run"}, ensure_ascii=False, indent=2))
return 0
def run_pipeline(run_type: str, skip_crawl: bool) -> int:
lock_file = acquire_lock()
run_id = None
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"pipeline-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
try:
validate_environment()
run_id = create_run(run_type)
steps = {"backup": {"status": "success", "path": backup_database()}}
update_run(run_id, steps_json=json.dumps(steps, ensure_ascii=False))
commands = []
if not skip_crawl:
commands.extend([
("crawl_mp", ["crawler.py", "--source-type", "公众号"]),
("crawl_video", ["crawler.py", "--source-type", "视频号"]),
])
analyze_args = ["structurer.py", "analyze", "--pause", "0.6", "--retries", "3"]
embed_args = ["structurer.py", "embed"]
if run_type == "full":
analyze_args.append("--force")
embed_args.append("--force")
commands.extend([
("analyze", analyze_args),
("embed", embed_args),
("signals", ["structurer.py", "signals", "--days", "30"]),
])
all_success = True
with log_path.open("a", encoding="utf-8") as log_file:
log_file.write(f"[{now_iso()}] pipeline run_id={run_id}, type={run_type}\n")
for name, arguments in commands:
if not run_step(run_id, name, arguments, log_file, steps):
all_success = False
status = "success" if all_success else "partial"
update_run(run_id, finished_at=now_iso(), status=status, current_step="completed", steps_json=json.dumps(steps, ensure_ascii=False))
print(json.dumps({"run_id": run_id, "status": status, "log": str(log_path), "steps": steps}, ensure_ascii=False, indent=2))
return 0 if all_success else 1
except Exception as exc:
if run_id is not None:
update_run(run_id, finished_at=now_iso(), status="failed", current_step="failed", error_message=str(exc))
print(json.dumps({"status": "failed", "error": str(exc), "log": str(log_path)}, ensure_ascii=False), file=sys.stderr)
return 1
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
lock_file.close()
def main() -> int:
parser = argparse.ArgumentParser(description="CIBank 自动增量处理流水线")
parser.add_argument("command", choices=["daily", "full", "status"])
parser.add_argument("--skip-crawl", action="store_true")
args = parser.parse_args()
if args.command == "status":
return show_status()
return run_pipeline(args.command, args.skip_crawl)
if __name__ == "__main__":
raise SystemExit(main())
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""RAG 问答模块:向量检索 + LLM 生成。
流程:
1. 将用户问题转为向量
2. 在 embeddings 表中做余弦相似度检索,取 TOP-K 相关文章
3. 将检索到的文章内容作为上下文,调用千问生成回答
4. 返回回答 + 引用来源(可追溯)
"""
from __future__ import annotations
import json
import os
import sqlite3
import urllib.error
import urllib.request
from pathlib import Path
import numpy as np
from dotenv import load_dotenv
from structurer import get_api_key, get_embeddings, DASHSCOPE_CHAT_URL
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""计算两个向量的余弦相似度。"""
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(np.dot(a, b) / (norm_a * norm_b))
def vector_search(conn: sqlite3.Connection, query_embedding: list[float], top_k: int = 5) -> list[dict]:
"""向量检索:在 embeddings 表中找到与查询向量最相似的文章。"""
query_vec = np.array(query_embedding, dtype=np.float32)
rows = conn.execute(
"""
SELECT e.article_id, e.embedding, e.chunk_text,
a.title, a.source_name, a.source_type, a.published_at, a.summary,
x.result_json
FROM embeddings e
JOIN articles a ON a.id = e.article_id
LEFT JOIN ai_analyses x ON x.id = (
SELECT x2.id FROM ai_analyses x2
WHERE x2.article_id = e.article_id AND x2.analysis_type = 'editorial'
ORDER BY x2.updated_at DESC, x2.id DESC LIMIT 1
)
WHERE a.crawl_status = 'success'
"""
).fetchall()
scored = []
for row in rows:
emb = json.loads(row["embedding"])
emb_vec = np.array(emb, dtype=np.float32)
score = cosine_similarity(query_vec, emb_vec)
scored.append((score, row))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
seen_articles = set()
for score, row in scored:
if row["article_id"] in seen_articles:
continue
seen_articles.add(row["article_id"])
analysis = json.loads(row["result_json"]) if row["result_json"] else {}
results.append({
"article_id": row["article_id"],
"title": row["title"],
"source": row["source_name"] or row["source_type"],
"date": (row["published_at"] or "")[:10],
"summary": analysis.get("summary") or row["summary"] or "",
"key_points": analysis.get("key_points", []),
"content": row["chunk_text"] or "",
"score": score,
})
if len(results) >= top_k:
break
return results
def generate_answer(question: str, contexts: list[dict]) -> dict:
"""基于检索到的上下文,调用千问生成回答。"""
api_key = get_api_key()
model = os.getenv("QWEN_MODEL", "qwen-plus")
# 构建上下文
context_text = ""
sources = []
for i, ctx in enumerate(contexts, start=1):
context_text += f"\n--- 来源{i} ---\n标题:{ctx['title']}\n摘要:{ctx['summary']}\n要点:{'; '.join(ctx['key_points'][:3])}\n原文片段:{ctx['content']}\n"
sources.append({
"title": ctx["title"],
"source": ctx["source"],
"date": ctx["date"],
})
prompt = f"""你是餐饮行业知识库的问答助手。根据以下知识库中的情报内容回答用户问题。
要求:
1. 回答必须基于提供的来源内容,不要编造信息
2. 如果来源内容不足以回答问题,明确说明"知识库中暂无直接相关信息"
3. 回答要结构化、简洁、有经营参考价值
4. 用中文回答
知识库来源:
{context_text}
用户问题:{question}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你是餐饮行业知识库问答助手,基于库内情报给出有出处的专业回答。"},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
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
answer = body["choices"][0]["message"]["content"].strip()
return {
"answer": answer,
"sources": sources,
"contexts": contexts,
}
def generate_answer_stream(question: str, contexts: list[dict]):
"""流式生成回答,逐 token yield。"""
api_key = get_api_key()
model = os.getenv("QWEN_MODEL", "qwen-plus")
context_text = ""
sources = []
for i, ctx in enumerate(contexts, start=1):
context_text += f"\n--- 来源{i} ---\n标题:{ctx['title']}\n摘要:{ctx['summary']}\n要点:{'; '.join(ctx['key_points'][:3])}\n原文片段:{ctx['content']}\n"
sources.append({
"title": ctx["title"],
"source": ctx["source"],
"date": ctx["date"],
})
prompt = f"""你是餐饮行业知识库的问答助手。根据以下知识库中的情报内容回答用户问题。
要求:
1. 回答必须基于提供的来源内容,不要编造信息
2. 如果来源内容不足以回答问题,明确说明"知识库中暂无直接相关信息"
3. 回答要结构化、简洁、有经营参考价值
4. 用中文回答,使用 Markdown 格式
知识库来源:
{context_text}
用户问题:{question}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你是餐饮行业知识库问答助手,基于库内情报给出有出处的专业回答。使用 Markdown 格式输出。"},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"stream": True,
"stream_options": {"include_usage": False},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=120)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"千问接口返回 {exc.code}{detail}") from exc
buffer = b""
while True:
chunk = resp.read(4096)
if not chunk:
break
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
line = line.strip()
if not line or line == b"data: [DONE]":
continue
if line.startswith(b"data:"):
try:
data = json.loads(line[5:].strip())
delta = data.get("choices", [{}])[0].get("delta", {})
content = delta.get("content")
if content:
yield content
except (json.JSONDecodeError, IndexError, KeyError):
pass
resp.close()
yield json.dumps({"__sources__": sources}, ensure_ascii=False)
def rag_qa(question: str, top_k: int = 5) -> dict:
"""完整的 RAG 问答流程:向量检索 → LLM 生成。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
# 检查是否有向量数据
count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
if count == 0:
# 无向量数据时使用关键词检索作为降级方案
return keyword_fallback(conn, question)
# 1. 将问题转为向量
query_embeddings = get_embeddings([question])
# 2. 向量检索
contexts = vector_search(conn, query_embeddings[0], top_k)
if not contexts:
conn.close()
return {
"answer": "知识库中暂无与您问题相关的内容。请先运行采集和分析流程入库更多情报。",
"sources": [],
"contexts": [],
}
# 3. LLM 生成回答
result = generate_answer(question, contexts)
conn.close()
return result
def rag_qa_stream(question: str, top_k: int = 5):
"""流式 RAG 问答:先检索,再流式生成。yield (type, data) 元组。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
if count == 0:
conn.close()
yield ("error", "知识库中暂无向量数据,请先运行采集和分析流程。")
return
query_embeddings = get_embeddings([question])
contexts = vector_search(conn, query_embeddings[0], top_k)
conn.close()
if not contexts:
yield ("error", "知识库中暂无与您问题相关的内容。")
return
sources = [
{"title": ctx["title"], "source": ctx["source"], "date": ctx["date"]}
for ctx in contexts
]
yield ("sources", sources)
for token in generate_answer_stream(question, contexts):
yield ("token", token)
def keyword_fallback(conn: sqlite3.Connection, question: str) -> dict:
"""无向量数据时的关键词检索降级方案。"""
rows = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.summary,
a.content, x.result_json
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.title LIKE ? OR a.summary LIKE ? OR a.content LIKE ?)
ORDER BY a.published_at DESC
LIMIT 5
""",
(f"%{question}%", f"%{question}%", f"%{question}%"),
).fetchall()
if not rows:
conn.close()
return {
"answer": "知识库中暂无与您问题相关的内容。请先运行采集和分析流程入库更多情报。",
"sources": [],
"contexts": [],
}
contexts = []
for row in rows:
analysis = json.loads(row["result_json"]) if row["result_json"] else {}
contexts.append({
"article_id": row["id"],
"title": row["title"],
"source": row["source_name"] or row["source_type"],
"date": (row["published_at"] or "")[:10],
"summary": analysis.get("summary") or row["summary"] or "",
"key_points": analysis.get("key_points", []),
"content": row["content"][:3000] if row["content"] else "",
"score": 0.5,
})
conn.close()
return generate_answer(question, contexts)
if __name__ == "__main__":
import sys
question = " ".join(sys.argv[1:]) or "现在开茶饮店还有机会吗?"
result = rag_qa(question)
print(json.dumps(result, ensure_ascii=False, indent=2))
+5
View File
@@ -0,0 +1,5 @@
playwright>=1.45,<2
Flask>=3.0,<4
flask-cors>=4.0,<5
python-dotenv>=1.0,<2
numpy>=1.26,<3
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# 餐智库一键启动脚本
# 同时启动后端 API 服务和前端开发服务器
set -e
BACKEND_DIR="$(cd "$(dirname "$0")" && pwd)"
FRONTEND_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== 餐智库启动 ==="
# 检查后端虚拟环境
if [ ! -d "$BACKEND_DIR/venv" ]; then
echo "[后端] 创建虚拟环境..."
python3 -m venv "$BACKEND_DIR/venv"
echo "[后端] 安装依赖..."
"$BACKEND_DIR/venv/bin/pip" install -r "$BACKEND_DIR/requirements.txt" -q
echo "[后端] 安装 Playwright 浏览器..."
"$BACKEND_DIR/venv/bin/playwright" install chromium
fi
# 检查 .env 文件
if [ ! -f "$BACKEND_DIR/.env" ]; then
echo "[后端] 复制 .env.example -> .env"
cp "$BACKEND_DIR/.env.example" "$BACKEND_DIR/.env"
echo "[提示] 请编辑 backend/.env 填入 DASHSCOPE_API_KEY"
fi
# 启动后端
echo "[后端] 启动 API 服务 (端口 8765)..."
"$BACKEND_DIR/venv/bin/python" "$BACKEND_DIR/app.py" &
BACKEND_PID=$!
echo "[后端] PID=$BACKEND_PID"
# 等待后端就绪
sleep 2
# 启动前端
echo "[前端] 启动开发服务器 (端口 3000)..."
cd "$FRONTEND_DIR"
npm run dev &
FRONTEND_PID=$!
echo "[前端] PID=$FRONTEND_PID"
echo ""
echo "=== 启动完成 ==="
echo "前端: http://localhost:3000"
echo "后端: http://localhost:8765/api/health"
echo ""
echo "按 Ctrl+C 停止所有服务"
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
wait
+154
View File
@@ -0,0 +1,154 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- 文章表:公众号文章 + 视频号ASR转写文本
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
canonical_url TEXT NOT NULL UNIQUE,
source TEXT NOT NULL DEFAULT 'hongcan', -- 来源平台
source_name TEXT, -- 公众号名称 / 视频号名称
source_type TEXT NOT NULL DEFAULT '公众号', -- 公众号 | 视频号
title TEXT,
author TEXT,
published_at TEXT,
summary TEXT,
content TEXT,
content_hash TEXT,
category TEXT, -- 赛道:茶饮咖啡/快餐/火锅/正餐/烘焙/供应链/综合
crawl_status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
media_url TEXT, -- 原始视频文件路径(视频号专用)
discovered_at TEXT NOT NULL,
crawled_at TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_crawl_status ON articles(crawl_status, retry_count);
CREATE INDEX IF NOT EXISTS idx_articles_source_type ON articles(source_type);
CREATE INDEX IF NOT EXISTS idx_articles_category ON articles(category);
-- 采集运行记录
CREATE TABLE IF NOT EXISTS crawl_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
source_type TEXT NOT NULL DEFAULT '公众号',
discovered_count INTEGER NOT NULL DEFAULT 0,
inserted_count INTEGER NOT NULL DEFAULT 0,
updated_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
message TEXT
);
-- AI 结构化分析结果
CREATE TABLE IF NOT EXISTS ai_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
model TEXT NOT NULL,
analysis_type TEXT NOT NULL DEFAULT 'editorial',
result_json TEXT NOT NULL, -- 完整结构化JSON
content_hash TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(article_id, model, analysis_type),
FOREIGN KEY(article_id) REFERENCES articles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ai_analyses_article ON ai_analyses(article_id, updated_at);
-- 向量嵌入表(用于RAG检索)
CREATE TABLE IF NOT EXISTS embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL DEFAULT 0,
chunk_text TEXT NOT NULL,
embedding BLOB, -- JSON序列化的float数组
model TEXT NOT NULL,
content_hash TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(article_id) REFERENCES articles(id) ON DELETE CASCADE,
UNIQUE(article_id, chunk_index, model)
);
CREATE INDEX IF NOT EXISTS idx_embeddings_article ON embeddings(article_id);
-- 品牌实体库
CREATE TABLE IF NOT EXISTS brands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
category TEXT,
stores INTEGER DEFAULT 0,
avg_price INTEGER DEFAULT 0,
model TEXT, -- 直营 | 加盟 | 直营+加盟
city_tier_json TEXT DEFAULT '[]', -- [{tier, pct}]
trend_json TEXT DEFAULT '[]', -- 近12个月门店数
latest_news TEXT,
news_date TEXT,
growth REAL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 行业信号(跨文章聚合)
CREATE TABLE IF NOT EXISTS industry_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
signal_date TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
trend TEXT NOT NULL DEFAULT 'emerging',
confidence REAL NOT NULL DEFAULT 0.5,
implications_json TEXT NOT NULL DEFAULT '[]',
article_ids_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
model TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(signal_date, title)
);
CREATE INDEX IF NOT EXISTS idx_industry_signals_date ON industry_signals(signal_date DESC, confidence DESC);
-- 报告表
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
report_date TEXT NOT NULL,
period TEXT,
highlights_json TEXT NOT NULL DEFAULT '[]',
sections_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reports_date ON reports(report_date DESC);
CREATE TABLE IF NOT EXISTS pipeline_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_type TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
current_step TEXT,
steps_json TEXT NOT NULL DEFAULT '{}',
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started ON pipeline_runs(started_at DESC);
-- 信源配置表(公众号 + 视频号)
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL DEFAULT '公众号', -- 公众号 | 视频号
name TEXT NOT NULL, -- 信源名称(公众号名称/视频号账号名)
finder TEXT, -- 视频号 finder ID(视频号专用)
list_url TEXT, -- 列表页URL(公众号专用)
download_dir TEXT, -- 下载目录(视频号专用)
enabled INTEGER NOT NULL DEFAULT 1, -- 是否启用 1/0
last_crawled_at TEXT, -- 最近采集时间
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(source_type, name)
);
+693
View File
@@ -0,0 +1,693 @@
#!/usr/bin/env python3
"""结构化引擎:大模型自动打标签、摘要、提取要点、识别品牌实体。
对每篇采集入库的文章调用通义千问,输出结构化JSON:
- summary: 120字内摘要
- key_points: 3-6条关键要点
- tags: 5-10个标签
- brands: 识别到的品牌实体
- category: 赛道分类
- type: 情报类型(政策监管/品牌动态/品类趋势/经营干货/供应链/消费洞察)
- score: 价值评分 0-100
- timeliness: 时效性(高/中/低)
- sentiment: 情感倾向
- content_angles: 可延展选题
- risk_notes: 事实或表述风险
- numbers: 关键数据
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
DASHSCOPE_CHAT_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
DASHSCOPE_EMBED_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def get_api_key() -> str:
key = os.getenv("DASHSCOPE_API_KEY", "").strip()
if not key:
raise RuntimeError("尚未配置 DASHSCOPE_API_KEY,请在 .env 中设置")
return key
# ---- 单篇文章结构化分析 ----
def analyze_article(title: str, content: str) -> dict:
"""调用通义千问对文章进行结构化分析。"""
api_key = get_api_key()
model = os.getenv("QWEN_MODEL", "qwen-plus")
prompt = f"""你是一名资深餐饮行业研究编辑。分析下面这篇文章,只返回合法 JSON,不要 Markdown 代码块。
JSON 字段必须为:
- summary120字内摘要)
- key_points3-6条字符串,关键要点)
- tags5-10个标签)
- brands(品牌数组,每个元素为对象:{{"name":"品牌名","stores":门店数整数或0,"avg_price":人均消费整数或0,"model":"直营/加盟/直营+加盟"}},仅填文中明确提及的数据,未提及的填0或空字符串)
- category(赛道分类,从以下选择:茶饮咖啡/快餐/火锅/正餐/烘焙/供应链/综合)
- type(情报类型,从以下选择:政策监管/品牌动态/品类趋势/经营干货/供应链/消费洞察)
- score(价值评分0-100整数,越高越有经营参考价值)
- timeliness(时效性:高/中/低)
- sentimentpositive/neutral/negative
- content_angles3条可延展选题)
- risk_notes(事实或表述风险数组)
- numbers(关键数据数组)
文章标题:{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(
DASHSCOPE_CHAT_URL,
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
if "choices" not in body or not body["choices"]:
raise RuntimeError(f"千问返回异常:{json.dumps(body, ensure_ascii=False)[:300]}")
text = body["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
try:
return json.loads(text)
except Exception as e:
raise RuntimeError(f"JSON解析失败: {e}\n{text[:200]}") from e
# ---- 跨文章行业信号提取 ----
def extract_industry_signals(items: list[dict], model: str) -> list[dict]:
"""从多篇文章的结构化分析中提取跨文章行业信号。"""
api_key = get_api_key()
prompt = f"""你是餐饮产业首席分析师。根据以下多篇文章的结构化分析,识别跨文章、可验证、有经营意义的行业信号。
只返回合法 JSON{{"signals":[...]}}。每个 signal 必须包含:
- title(短标题)
- summary100-180字)
- trendemerging/accelerating/stable/declining
- confidence0到1
- article_ids(至少2个证据文章ID
- implications2-4条经营启示)
- tags3-6个标签)
不要把单一品牌新闻简单改写成行业信号;合并重复主题;最多输出8条。
输入:{json.dumps(items, ensure_ascii=False)}"""
body = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你进行基于证据的餐饮行业趋势聚类,避免空泛结论。"},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise RuntimeError(f"千问接口返回 {exc.code}{exc.read().decode('utf-8', errors='replace')[:500]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"千问接口网络超时:{exc}") from exc
text = result["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
return json.loads(text).get("signals", [])
# ---- 文本向量嵌入 ----
def get_embeddings(texts: list[str]) -> list[list[float]]:
"""调用通义千问文本向量模型,批量获取向量。"""
api_key = get_api_key()
model = os.getenv("EMBEDDING_MODEL", "text-embedding-v3")
payload = json.dumps({
"model": model,
"input": texts,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_EMBED_URL,
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) 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
if "output" in body:
return [item["embedding"] for item in body["output"]["embeddings"]]
return [item["embedding"] for item in body["data"]]
# ---- 品牌实体入库 ----
def upsert_brand(conn: sqlite3.Connection, brand_name: str, category: str | None = None,
stores: int = 0, avg_price: int = 0, model: str | None = None,
latest_news: str | None = None, news_date: str | None = None) -> None:
"""将识别到的品牌实体写入品牌表,有新数据时更新。"""
existing = conn.execute("SELECT id, stores, avg_price FROM brands WHERE name=?", (brand_name,)).fetchone()
timestamp = now_iso()
if existing:
# 只在新值更大或非空时更新,避免旧数据被覆盖
new_stores = max(existing["stores"], stores) if stores > 0 else existing["stores"]
new_avg_price = avg_price if avg_price > 0 else existing["avg_price"]
conn.execute(
"""
UPDATE brands SET category=COALESCE(?, category),
stores=?, avg_price=?,
model=COALESCE(NULLIF(?, ''), model),
latest_news=COALESCE(NULLIF(?, ''), latest_news),
news_date=COALESCE(NULLIF(?, ''), news_date),
updated_at=?
WHERE id=?
""",
(category, new_stores, new_avg_price, model or "", latest_news or "", news_date or "", timestamp, existing["id"]),
)
else:
conn.execute(
"""
INSERT INTO brands (name, category, stores, avg_price, model, city_tier_json, trend_json,
latest_news, news_date, growth, created_at, updated_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), '[]', '[]', NULLIF(?, ''), NULLIF(?, ''), 0, ?, ?)
""",
(brand_name, category, stores, avg_price, model or "", latest_news or "", news_date or "", timestamp, timestamp),
)
conn.commit()
# ---- 批量分析主流程 ----
def run_batch_analyze(force: bool, pause: float, retries: int) -> int:
"""批量对所有成功采集的文章执行结构化分析。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
rows = conn.execute(
"""
SELECT a.id, a.title, a.content, a.content_hash, a.published_at, x.content_hash analyzed_hash
FROM articles a
LEFT JOIN ai_analyses x
ON x.article_id = a.id AND x.model = ? AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success'
ORDER BY a.published_at DESC, a.id DESC
""",
(model,),
).fetchall()
pending = [r for r in rows if force or not r["analyzed_hash"] or r["analyzed_hash"] != r["content_hash"]]
print(json.dumps({"total": len(rows), "pending": len(pending), "model": model}, ensure_ascii=False), flush=True)
success = skipped = failed = 0
for index, row in enumerate(rows, start=1):
if not force and row["analyzed_hash"] == row["content_hash"]:
skipped += 1
continue
last_error = None
for attempt in range(1, retries + 2):
try:
result = analyze_article(row["title"], row["content"])
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
""",
(row["id"], model, json.dumps(result, ensure_ascii=False), row["content_hash"], timestamp, timestamp),
)
# 更新文章的category字段
if result.get("category"):
conn.execute("UPDATE articles SET category=? WHERE id=?", (result["category"], row["id"]))
# 品牌实体入库
for brand in result.get("brands", []):
if isinstance(brand, str):
upsert_brand(conn, brand, result.get("category"))
elif isinstance(brand, dict):
upsert_brand(conn, brand.get("name", ""), result.get("category"),
int(brand.get("stores", 0) or 0), int(brand.get("avg_price", 0) or 0),
brand.get("model"), row["title"], (row["published_at"] or "")[:10])
conn.commit()
success += 1
print(f"[{index}/{len(rows)}] OK {row['title']}", flush=True)
last_error = None
break
except Exception as exc:
last_error = exc
if attempt <= retries:
wait = attempt * 2
print(f"[{index}/{len(rows)}] RETRY {attempt}/{retries} {exc}", flush=True)
time.sleep(wait)
if last_error is not None:
failed += 1
print(f"[{index}/{len(rows)}] FAIL {row['title']}: {last_error}", flush=True)
time.sleep(pause)
total_analyzed = conn.execute("SELECT COUNT(DISTINCT article_id) FROM ai_analyses").fetchone()[0]
conn.close()
print(json.dumps({
"success": success, "skipped": skipped, "failed": failed,
"total_analyzed": total_analyzed,
}, ensure_ascii=False), flush=True)
return 0 if failed == 0 else 1
def run_extract_signals(days: int) -> int:
"""提取跨文章行业信号。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
from datetime import datetime, timedelta
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
rows = conn.execute(
"""
SELECT a.id, a.title, a.published_at, x.result_json
FROM articles a JOIN ai_analyses x ON x.article_id = a.id
WHERE a.crawl_status = 'success' AND a.published_at >= ?
ORDER BY a.published_at DESC
""",
(cutoff,),
).fetchall()
items = []
for row in rows:
analysis = json.loads(row["result_json"])
items.append({
"article_id": row["id"], "title": row["title"], "published_at": row["published_at"],
"summary": analysis.get("summary"), "key_points": analysis.get("key_points", []),
"type": analysis.get("type"), "tags": analysis.get("tags", []),
"brands": analysis.get("brands", []), "score": analysis.get("score", 0),
})
if not items:
print(json.dumps({"status": "skipped", "reason": "没有已分析文章"}, ensure_ascii=False))
return 0
# 分批提取信号,避免一次性传入过多内容触发内容审查
signal_date = datetime.now().astimezone().date().isoformat()
timestamp = now_iso()
conn.execute("DELETE FROM industry_signals WHERE signal_date=?", (signal_date,))
conn.commit()
all_signals = []
batch_size = 20
total_batches = (len(items) + batch_size - 1) // batch_size
for i in range(0, len(items), batch_size):
batch_num = i // batch_size + 1
batch = items[i:i + batch_size]
print(f" 信号批次 {batch_num}/{total_batches}{len(batch)}", flush=True)
for attempt in range(2):
try:
batch_signals = extract_industry_signals(batch, model)
all_signals.extend(batch_signals)
# 每批成功后立即写入数据库
for signal in batch_signals:
conn.execute(
"""
INSERT OR IGNORE INTO industry_signals
(signal_date, title, summary, trend, confidence, implications_json, article_ids_json, tags_json, model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_date, signal["title"], signal["summary"], signal.get("trend", "emerging"),
float(signal.get("confidence", 0.5)), json.dumps(signal.get("implications", []), ensure_ascii=False),
json.dumps(signal.get("article_ids", [])), json.dumps(signal.get("tags", []), ensure_ascii=False),
model, timestamp, timestamp,
),
)
conn.commit()
break
except RuntimeError as exc:
print(f" 批次 {batch_num} 失败(尝试{attempt+1}): {exc}", flush=True)
if attempt == 0:
import time; time.sleep(2)
continue
except Exception as exc:
print(f" 批次 {batch_num} 异常(尝试{attempt+1}): {type(exc).__name__}: {exc}", flush=True)
if attempt == 0:
import time; time.sleep(2)
continue
# 合并去重:按标题去重,保留置信度更高的
seen = {}
for sig in all_signals:
title = sig.get("title", "")
conf = float(sig.get("confidence", 0))
if title not in seen or conf > float(seen[title].get("confidence", 0)):
seen[title] = sig
signals = list(seen.values())[:8]
# 用去重后的结果替换当天信号
conn.execute("DELETE FROM industry_signals WHERE signal_date=?", (signal_date,))
for signal in signals:
conn.execute(
"""
INSERT INTO industry_signals
(signal_date, title, summary, trend, confidence, implications_json, article_ids_json, tags_json, model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_date, signal["title"], signal["summary"], signal.get("trend", "emerging"),
float(signal.get("confidence", 0.5)), json.dumps(signal.get("implications", []), ensure_ascii=False),
json.dumps(signal.get("article_ids", [])), json.dumps(signal.get("tags", []), ensure_ascii=False),
model, timestamp, timestamp,
),
)
conn.commit()
conn.close()
print(json.dumps({"status": "success", "input_articles": len(items), "signals": len(signals), "date": signal_date}, ensure_ascii=False))
return 0
# ---- 向量嵌入生成 ----
def ensure_embedding_schema(conn: sqlite3.Connection) -> None:
"""兼容已有数据库,为向量表补齐增量更新所需字段与唯一索引。"""
columns = {row[1] for row in conn.execute("PRAGMA table_info(embeddings)").fetchall()}
if "content_hash" not in columns:
conn.execute("ALTER TABLE embeddings ADD COLUMN content_hash TEXT")
conn.execute(
"""
DELETE FROM embeddings
WHERE id NOT IN (
SELECT MAX(id) FROM embeddings GROUP BY article_id, chunk_index, model
)
"""
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_embeddings_unique_chunk "
"ON embeddings(article_id, chunk_index, model)"
)
conn.commit()
def split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
"""将长文本切分为有重叠的中文检索片段。"""
normalized = "\n".join(line.strip() for line in text.splitlines() if line.strip())
if not normalized:
return []
chunks = []
start = 0
while start < len(normalized):
end = min(len(normalized), start + chunk_size)
if end < len(normalized):
candidates = [normalized.rfind(mark, start + chunk_size // 2, end) for mark in "。!?;\n"]
boundary = max(candidates)
if boundary > start:
end = boundary + 1
chunks.append(normalized[start:end])
if end >= len(normalized):
break
start = max(start + 1, end - overlap)
return chunks
def build_embedding_chunks(row: sqlite3.Row) -> tuple[list[str], str]:
"""构建包含标题、AI摘要和完整原文的向量分块。"""
analysis = json.loads(row["result_json"]) if row["result_json"] else {}
header_parts = [f"标题:{row['title'] or ''}"]
summary = analysis.get("summary") or row["summary"] or ""
if summary:
header_parts.append(f"摘要:{summary}")
key_points = analysis.get("key_points") or []
if key_points:
header_parts.append("要点:" + "".join(str(item) for item in key_points))
tags = analysis.get("tags") or []
if tags:
header_parts.append("标签:" + "".join(str(item) for item in tags))
header = "\n".join(header_parts)
content_chunks = split_text(row["content"] or "") or [""]
chunks = [f"{header}\n正文片段:{content}".strip() for content in content_chunks]
source_hash = hashlib.sha256("\n\n".join(chunks).encode("utf-8")).hexdigest()
return chunks, source_hash
def run_generate_embeddings(force: bool) -> int:
"""为所有成功文章增量生成分块向量,用于RAG检索。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
ensure_embedding_schema(conn)
model = os.getenv("EMBEDDING_MODEL", "text-embedding-v3")
rows = conn.execute(
"""
SELECT a.id, a.title, a.summary, a.content, x.result_json
FROM articles a
LEFT JOIN ai_analyses x ON x.id = (
SELECT x2.id FROM ai_analyses x2
WHERE x2.article_id = a.id AND x2.analysis_type = 'editorial'
ORDER BY x2.updated_at DESC, x2.id DESC LIMIT 1
)
WHERE a.crawl_status = 'success'
ORDER BY a.id
""",
).fetchall()
success = skipped = failed = chunks_written = 0
for row in rows:
chunks, source_hash = build_embedding_chunks(row)
existing = conn.execute(
"SELECT content_hash FROM embeddings WHERE article_id=? AND model=? LIMIT 1",
(row["id"], model),
).fetchone()
if not force and existing and existing["content_hash"] == source_hash:
skipped += 1
continue
try:
vectors = []
for start in range(0, len(chunks), 10):
vectors.extend(get_embeddings(chunks[start:start + 10]))
if len(vectors) != len(chunks):
raise RuntimeError(f"向量数量异常:期望 {len(chunks)},实际 {len(vectors)}")
timestamp = now_iso()
conn.execute("BEGIN")
conn.execute("DELETE FROM embeddings WHERE article_id=? AND model=?", (row["id"], model))
conn.executemany(
"""
INSERT INTO embeddings
(article_id, chunk_index, chunk_text, embedding, model, content_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(row["id"], index, chunk, json.dumps(vector), model, source_hash, timestamp)
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
],
)
conn.commit()
success += 1
chunks_written += len(chunks)
except Exception as exc:
conn.rollback()
failed += 1
print(f"FAIL article {row['id']}: {exc}", flush=True)
conn.close()
print(json.dumps({
"success": success, "skipped": skipped, "failed": failed,
"chunks_written": chunks_written, "model": model,
}, ensure_ascii=False))
return 0 if failed == 0 else 1
def run_generate_report(days: int = 7) -> int:
"""生成周期报告:聚合近期文章分析结果,调用LLM生成结构化报告。"""
db_path = Path(os.getenv("CIBANK_DB", str(BASE_DIR / "data" / "cibank.db")))
conn = sqlite3.connect(db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
from datetime import datetime, timedelta
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
rows = conn.execute(
"""
SELECT a.id, a.title, a.source_name, a.published_at, a.category,
x.result_json
FROM articles a
JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
WHERE a.crawl_status = 'success' AND a.published_at >= ?
ORDER BY a.published_at DESC
LIMIT 50
""",
(cutoff,),
).fetchall()
if not rows:
print(json.dumps({"status": "skip", "reason": "近期无分析数据"}, ensure_ascii=False))
return 0
items = []
for r in rows:
analysis = json.loads(r["result_json"]) if r["result_json"] else {}
items.append({
"title": r["title"],
"source": r["source_name"] or "",
"date": (r["published_at"] or "")[:10],
"category": r["category"] or analysis.get("category", ""),
"type": analysis.get("type", ""),
"summary": analysis.get("summary", ""),
"key_points": analysis.get("key_points", [])[:3],
"score": analysis.get("score", 0),
"brands": [b["name"] if isinstance(b, dict) else b for b in analysis.get("brands", [])][:3],
})
api_key = get_api_key()
period_label = f"{days}"
prompt = f"""你是餐饮行业首席分析师。根据以下{len(items)}篇情报的结构化分析,生成一份{period_label}餐饮行业经营决策简报。
只返回合法 JSON,不要 Markdown 代码块。格式:
{{
"title": "报告标题(含日期范围)",
"highlights": ["核心要点1", "核心要点2", "核心要点3", "核心要点4"],
"sections": [
{{"heading": "板块标题", "body": "详细分析正文(200-400字)"}},
{{"heading": "板块标题", "body": "详细分析正文(200-400字)"}},
{{"heading": "板块标题", "body": "详细分析正文(200-400字)"}},
{{"heading": "经营建议", "body": "基于以上分析的经营建议(200-300字)"}}
]
}}
要求:
1. highlights 为4-6条核心洞察,每条15-30字
2. sections 至少4个板块,覆盖赛道趋势、品牌动态、政策/供应链、经营建议
3. 内容必须基于提供的情报数据,不要编造
4. 语言专业、简洁、有决策参考价值
情报数据:
{json.dumps(items, ensure_ascii=False)}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你是餐饮行业首席分析师,输出严谨的经营决策简报。"},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_CHAT_URL,
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) 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]
print(json.dumps({"status": "error", "error": f"千问接口返回 {exc.code}{detail}"}, ensure_ascii=False))
return 1
text = body["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
result = json.loads(text)
report_date = datetime.now().strftime("%Y-%m-%d")
timestamp = now_iso()
conn.execute(
"""
INSERT INTO reports (title, report_date, period, highlights_json, sections_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
result["title"],
report_date,
period_label,
json.dumps(result["highlights"], ensure_ascii=False),
json.dumps(result["sections"], ensure_ascii=False),
timestamp,
),
)
conn.commit()
conn.close()
print(json.dumps({
"status": "success",
"title": result["title"],
"date": report_date,
"highlights": len(result["highlights"]),
"sections": len(result["sections"]),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="结构化引擎:LLM分析+品牌识别+信号提取+向量嵌入")
sub = parser.add_subparsers(dest="command")
p_analyze = sub.add_parser("analyze", help="批量文章结构化分析")
p_analyze.add_argument("--force", action="store_true")
p_analyze.add_argument("--pause", type=float, default=0.35)
p_analyze.add_argument("--retries", type=int, default=2)
p_signals = sub.add_parser("signals", help="提取跨文章行业信号")
p_signals.add_argument("--days", type=int, default=30)
p_embed = sub.add_parser("embed", help="生成向量嵌入")
p_embed.add_argument("--force", action="store_true")
p_report = sub.add_parser("report", help="生成周期报告")
p_report.add_argument("--days", type=int, default=7)
args = parser.parse_args()
if args.command == "analyze":
raise SystemExit(run_batch_analyze(args.force, args.pause, args.retries))
elif args.command == "signals":
raise SystemExit(run_extract_signals(args.days))
elif args.command == "embed":
raise SystemExit(run_generate_embeddings(args.force))
elif args.command == "report":
raise SystemExit(run_generate_report(args.days))
else:
parser.print_help()
+14
View File
@@ -0,0 +1,14 @@
{
"sources": [
{
"account": "剑哥聊餐饮",
"finder": "sphnu5kSqZT224x",
"directory": "/Volumes/Projects/视频/剑哥聊餐饮"
},
{
"account": "餐饮周伯通(赖老师TimLai",
"finder": "sph0XoCC1rhJXB7",
"directory": "/Volumes/Projects/视频/餐饮周伯通"
}
]
}
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""视频号批量下载管理器。
基于 wx_channels_download 工具的 API 接口,实现:
1. 检查工具是否运行
2. 获取视频号创作者的视频列表
3. 与本地已下载文件比对,发现新视频
4. 触发批量下载
5. 下载完成后自动触发 ASR 转写入库
工具 GitHub: https://github.com/ltaoo/wx_channels_download
API 基地址: http://127.0.0.1:2022
使用前提:
- wx_channels_download 以 sudo 运行
- IPv6 已禁用(sudo networksetup -setv6off Wi-Fi
- VPN 已关闭
- 微信保持运行
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
TOOL_API = os.getenv("WX_VIDEO_API", "http://127.0.0.1:2022")
VIDEO_CONFIG_PATH = BASE_DIR / "video_config.json"
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def api_get(endpoint: str, params: dict[str, str] | None = None, timeout: int = 30) -> dict[str, Any]:
"""调用 wx_channels_download 工具的 API。"""
url = f"{TOOL_API}{endpoint}"
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items())
url += f"?{qs}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
return 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"工具 API 返回 {exc.code}{detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"无法连接工具 API{TOOL_API}):{exc.reason}\n请确认 wx_channels_download 已以 sudo 启动。") from exc
def check_tool_running() -> bool:
"""检查下载工具是否在运行。"""
try:
result = api_get("/api/channels/version", timeout=5)
return bool(result.get("version") or result.get("data"))
except Exception:
return False
def get_video_list(finder: str) -> list[dict[str, Any]]:
"""获取视频号创作者的视频列表。
需要微信客户端已连接到工具(WebSocket)。
"""
result = api_get("/api/channels/contact/feed/list", {"finder": finder}, timeout=60)
# 适配不同版本的返回格式
if isinstance(result, list):
return result
if isinstance(result, dict):
return result.get("data", result.get("list", result.get("feeds", [])))
return []
def list_local_videos(directory: Path) -> set[str]:
"""列出本地已下载的视频文件名(不含扩展名)。"""
if not directory.is_dir():
return set()
suffixes = {".mp4", ".mov", ".m4v", ".mkv", ".webm"}
return {f.stem for f in directory.iterdir() if f.is_file() and f.suffix.lower() in suffixes}
def normalize_title(title: str) -> str:
"""将视频标题规范化,用于与本地文件名比对。"""
# 去除 wx_channels_download 添加的 _xWT 后缀和画质标记
value = re.sub(r"[_ ]?xWT\d+$", "", title, flags=re.I)
value = re.sub(r"[\x00-\x1f/:]", "_", value).strip(" .")
return value[:180] or "untitled"
def find_new_videos(video_list: list[dict[str, Any]], local_dir: Path) -> list[dict[str, Any]]:
"""比对线上视频列表与本地已下载文件,返回未下载的视频。"""
local_names = list_local_videos(local_dir)
new_videos = []
for video in video_list:
title = video.get("title", video.get("desc", ""))
normalized = normalize_title(title)
# 检查本地是否已有该视频(模糊匹配)
if not any(normalized in local_name or local_name in normalized for local_name in local_names):
new_videos.append(video)
return new_videos
def create_batch_download(finder: str, video_ids: list[str]) -> dict[str, Any]:
"""创建批量下载任务。"""
params = {"finder": finder, "ids": ",".join(video_ids)}
return api_get("/api/task/create_batch", params, timeout=120)
def wait_for_downloads(local_dir: Path, expected_count: int, timeout: int = 600) -> int:
"""等待下载完成,返回实际新增的文件数。"""
initial = list_local_videos(local_dir)
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(10)
current = list_local_videos(local_dir)
new_files = current - initial
if len(new_files) >= expected_count:
return len(new_files)
return len(list_local_videos(local_dir) - initial)
def run_download(auto_transcribe: bool = True) -> int:
"""主流程:检查工具 → 获取视频列表 → 发现新视频 → 触发下载。"""
config = json.loads(VIDEO_CONFIG_PATH.read_text(encoding="utf-8")) if VIDEO_CONFIG_PATH.exists() else {"sources": []}
sources = config.get("sources", [])
if not sources:
print(json.dumps({"status": "skipped", "reason": "video_config.json 中未配置视频源"}, ensure_ascii=False))
return 0
# 1. 检查工具是否运行
if not check_tool_running():
print(json.dumps({
"status": "error",
"reason": f"wx_channels_download 工具未运行({TOOL_API}",
"hint": "请执行: sudo <工具路径>/wx_video_download,并确保 IPv6 已禁用、VPN 已关闭"
}, ensure_ascii=False))
return 1
print(json.dumps({"status": "tool_running", "api": TOOL_API}, ensure_ascii=False), flush=True)
total_new = 0
total_downloaded = 0
for source in sources:
account = source.get("account", "未知")
finder = source.get("finder", "")
download_dir = Path(source.get("directory", ""))
if not finder:
print(f"SKIP {account}: 未配置 finder", file=sys.stderr)
continue
# 2. 获取视频列表
try:
video_list = get_video_list(finder)
except Exception as exc:
print(f"FAIL {account}: 获取视频列表失败 - {exc}", file=sys.stderr, flush=True)
continue
print(f"INFO {account}: 线上视频 {len(video_list)}", flush=True)
# 3. 发现新视频
new_videos = find_new_videos(video_list, download_dir)
total_new += len(new_videos)
if not new_videos:
print(f"INFO {account}: 无新视频", flush=True)
continue
print(f"INFO {account}: 发现 {len(new_videos)} 个新视频", flush=True)
# 4. 触发批量下载
video_ids = [v.get("id", v.get("objectId", "")) for v in new_videos if v.get("id") or v.get("objectId")]
if video_ids:
try:
result = create_batch_download(finder, video_ids)
print(f"INFO {account}: 批量下载任务已创建 - {result}", flush=True)
# 5. 等待下载完成
downloaded = wait_for_downloads(download_dir, len(new_videos), timeout=600)
total_downloaded += downloaded
print(f"INFO {account}: 新增下载 {downloaded} 个文件", flush=True)
except Exception as exc:
print(f"FAIL {account}: 下载失败 - {exc}", file=sys.stderr, flush=True)
else:
# 如果无法获取 video_id,提示用户手动下载
print(f"WARN {account}: 无法自动获取视频ID,请通过微信内批量下载或 Web 管理页面手动下载", flush=True)
print(f" Web 管理页面: {TOOL_API}/download", flush=True)
print(f" 下载目录: {download_dir}", flush=True)
print(json.dumps({
"status": "success",
"total_new": total_new,
"total_downloaded": total_downloaded,
}, ensure_ascii=False), flush=True)
# 6. 自动触发 ASR 转写入库
if auto_transcribe and total_downloaded > 0:
print("INFO 自动触发视频号 ASR 转写...", flush=True)
import subprocess
subprocess.run(
[sys.executable, str(BASE_DIR / "crawler.py"), "--source-type", "视频号"],
cwd=str(BASE_DIR),
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="视频号批量下载管理器")
parser.add_argument("--no-transcribe", action="store_true", help="仅下载,不自动触发 ASR 转写")
parser.add_argument("--check", action="store_true", help="仅检查工具状态")
return parser
if __name__ == "__main__":
args = build_parser().parse_args()
if args.check:
running = check_tool_running()
print(json.dumps({"tool_running": running, "api": TOOL_API}, ensure_ascii=False))
raise SystemExit(0 if running else 1)
raise SystemExit(run_download(auto_transcribe=not args.no_transcribe))
+6
View File
@@ -0,0 +1,6 @@
{
"videos": [
{"url": "https://example.com/video/1", "title": "海底捞创始人分享:翻台率不是目标", "source_name": "海底捞官方视频号"},
{"url": "https://example.com/video/2", "title": "西贝贾国龙谈预制菜争议", "source_name": "餐饮O2O视频号"}
]
}