Phase1: 效果数据真实化

- 替换scheduler.py中假随机数metrics_sync为知乎API自动获取
- 新增 POST /api/metrics/zhihu-fetch 知乎公开数据API端点
- metrics.html新增「数据录入」tab:手动录入表单+已有数据列表+知乎自动获取
- gitignore清理已跟踪的生成文件(outlines/research/images/cache)
This commit is contained in:
Yuzhiran Dev
2026-05-20 19:04:35 +08:00
parent eb800c5acc
commit 09da2f9dc4
176 changed files with 331 additions and 4756 deletions
+87 -1
View File
@@ -4,6 +4,9 @@ from sqlalchemy import func, desc
from typing import List, Optional
from datetime import datetime, timedelta, date
import logging
import re
import requests as http_requests
from pydantic import BaseModel
from ..database import get_db
from ..models import ContentMetrics, Topic, ContentCalendar
@@ -302,4 +305,87 @@ def recommend_topics_from_metrics(
return recommendations[:limit]
except Exception as e:
logging.getLogger(__name__).exception(f"推荐选题失败: {e}")
return []
return []
_ZHIHU_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
def _extract_zhihu_post_id(url: str) -> str:
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
if m:
return m.group(1)
m = re.search(r'zhihu\.com/question/\d+/answer/(\d+)', url)
if m:
return m.group(1)
raise ValueError("无法从URL中提取知乎文章/回答ID")
class ZhihuFetchRequest(BaseModel):
topic_id: str
zhihu_url: str
@router.post("/zhihu-fetch")
def fetch_zhihu_metrics(
data: ZhihuFetchRequest,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""从知乎专栏/回答公开API自动获取阅读/点赞/评论数据"""
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
post_id = _extract_zhihu_post_id(data.zhihu_url)
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15)
if resp.status_code == 404:
api_url = f"https://www.zhihu.com/api/v4/answers/{post_id}"
resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"知乎API返回 {resp.status_code}")
raw = resp.json()
platform = "zhihu"
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == data.topic_id,
ContentMetrics.platform == platform
).first()
metric_data = {
"views": raw.get("voteup_count", raw.get("views_count", 0)),
"likes": raw.get("voteup_count", 0),
"favorites": raw.get("favorite_count", 0),
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0) or raw.get("shared_count", 0),
"publish_url": data.zhihu_url,
"data_snapshot": raw,
}
if existing:
for k, v in metric_data.items():
setattr(existing, k, v)
existing.last_fetched = datetime.now()
db.commit()
db.refresh(existing)
return {"ok": True, "source": "updated", "data": ContentMetricsResponse.model_validate(existing)}
else:
metric = ContentMetrics(
topic_id=data.topic_id,
platform=platform,
**metric_data,
last_fetched=datetime.now()
)
db.add(metric)
db.commit()
db.refresh(metric)
return {"ok": True, "source": "created", "data": ContentMetricsResponse.model_validate(metric)}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"获取知乎数据失败: {str(e)}")
+39 -46
View File
@@ -165,12 +165,12 @@ class TaskScheduler:
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
def _run_metrics_sync(self):
"""定时从各平台公开API获取发布文章的效果数据(当前仅支持知乎)"""
try:
logger.info("[Scheduled] Starting metrics sync...")
logger.info("[Scheduled] Starting metrics sync (zhihu auto-fetch)...")
from ..database import SessionLocal
from ..models import Topic, ContentMetrics, PublishRecord
import random, math
from datetime import date
from ..models import Topic, ContentMetrics
import re, requests as http_requests
db = SessionLocal()
try:
@@ -182,58 +182,51 @@ class TaskScheduler:
db.close()
return
random.seed(42)
multipliers = {
"zhihu": {"v": 1.0, "l": 1.2, "f": 0.6, "c": 1.5, "s": 0.3},
"wechat": {"v": 1.8, "l": 0.6, "f": 0.4, "c": 0.3, "s": 2.0},
"xiaohongshu": {"v": 2.5, "l": 1.5, "f": 1.8, "c": 1.0, "s": 1.5},
}
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
count = 0
for topic in topics:
platforms = set()
records = db.query(PublishRecord).filter(
PublishRecord.topic_id == topic.id,
PublishRecord.action == "publish",
PublishRecord.status == "success"
).all()
for rec in records:
platforms.add(rec.platform)
if not platforms:
platforms = {"zhihu", "wechat", "xiaohongshu"}
days = max(1, (date.today() - (topic.published_at or date.today())).days)
quality = (topic.compliance_score or 70) / 100.0
for plat in platforms:
if plat not in multipliers:
platform_urls = topic.platform_urls or {}
zhihu_url = platform_urls.get("zhihu", "")
if not zhihu_url:
continue
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', zhihu_url)
if not m:
continue
post_id = m.group(1)
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
if resp.status_code != 200:
continue
m = multipliers[plat]
base = random.randint(30, 200)
growth = 1 + math.log(days + 1, 2) * 0.5
views = int(base * m["v"] * growth)
likes = int(views * quality * 0.08 * m["l"])
favs = int(likes * 0.5 * m["f"])
comm = int(views * quality * 0.02 * m["c"])
shar = int(views * quality * 0.03 * m["s"])
raw = resp.json()
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == plat
ContentMetrics.platform == "zhihu"
).first()
metric_data = {
"views": raw.get("voteup_count", raw.get("views_count", 0)),
"likes": raw.get("voteup_count", 0),
"favorites": raw.get("favorite_count", 0),
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
"shares": raw.get("share_count", 0),
"last_fetched": datetime.now(),
"publish_url": zhihu_url,
"data_snapshot": raw,
}
if existing:
existing.views = views
existing.likes = likes
existing.favorites = favs
existing.comments = comm
existing.shares = shar
existing.last_fetched = datetime.now()
for k, v in metric_data.items():
setattr(existing, k, v)
else:
db.add(ContentMetrics(
topic_id=topic.id, platform=plat,
views=views, likes=likes, favorites=favs,
comments=comm, shares=shar, last_fetched=datetime.now()
))
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
count += 1
db.commit()
except Exception:
continue
if count:
db.commit()
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
else:
logger.info("[Scheduled] Metrics sync: no zhihu articles to sync")
db.close()
logger.info("[Scheduled] Metrics sync completed: %d entries for %d topics", count, len(topics))
except Exception as e:
logger.exception("[Scheduled] Metrics sync failed: %s", e)
+205 -8
View File
@@ -46,12 +46,17 @@
</uni-nav>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据分析</h2>
<div v-if="loadingDashboard" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<div class="card page-fade">
<div class="filter-bar" style="margin-bottom:20px;">
<el-button size="default" :type="activeTab === 'dashboard' ? 'primary' : ''" @click="activeTab = 'dashboard'">数据概览</el-button>
<el-button size="default" :type="activeTab === 'entry' ? 'primary' : ''" @click="activeTab = 'entry'; switchToEntry()">数据录入</el-button>
</div>
<div v-if="activeTab === 'dashboard'">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据分析</h2>
<div v-if="loadingDashboard" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<div class="card page-fade">
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 选题状态分布</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items:center;">
<div style="flex:1;min-width:200px;">
@@ -169,6 +174,104 @@
</div>
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconInfo /></el-icon><div class="empty-text">暂无选题推荐</div></div>
</div>
</div>
<div v-if="activeTab === 'entry'" class="page-fade">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconEdit /></el-icon> 数据录入</h2>
<div class="card" style="margin-bottom:20px;">
<h3 style="font-size:16px;margin-bottom:16px;">手动录入</h3>
<el-form :model="entryForm" label-width="100px" size="small">
<el-form-item label="选题">
<el-select v-model="entryForm.topic_id" filterable placeholder="选择选题" style="width:100%;">
<el-option v-for="t in allTopics" :key="t.id" :label="t.id + ' - ' + t.title" :value="t.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="平台">
<el-select v-model="entryForm.platform" placeholder="选择平台" style="width:200px;">
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
</el-form-item>
<el-form-item label="发布链接">
<el-input v-model="entryForm.publish_url" placeholder="https://..."></el-input>
</el-form-item>
<el-row :gutter="12">
<el-col :span="6"><el-form-item label="阅读"><el-input-number v-model="entryForm.views" :min="0" style="width:100%;"></el-input-number></el-form-item></el-col>
<el-col :span="6"><el-form-item label="点赞"><el-input-number v-model="entryForm.likes" :min="0" style="width:100%;"></el-input-number></el-form-item></el-col>
<el-col :span="6"><el-form-item label="收藏"><el-input-number v-model="entryForm.favorites" :min="0" style="width:100%;"></el-input-number></el-form-item></el-col>
<el-col :span="6"><el-form-item label="评论"><el-input-number v-model="entryForm.comments" :min="0" style="width:100%;"></el-input-number></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6"><el-form-item label="分享"><el-input-number v-model="entryForm.shares" :min="0" style="width:100%;"></el-input-number></el-form-item></el-col>
</el-row>
<el-form-item>
<el-button type="primary" @click="submitEntry" :loading="submitting">保存</el-button>
<el-button @click="resetEntryForm">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="card" style="margin-bottom:20px;">
<h3 style="font-size:16px;margin-bottom:16px;">从知乎自动获取</h3>
<el-form :model="zhihuForm" label-width="100px" size="small" @submit.prevent="fetchZhihu">
<el-form-item label="选题">
<el-select v-model="zhihuForm.topic_id" filterable placeholder="选择选题" style="width:100%;">
<el-option v-for="t in allTopics" :key="t.id" :label="t.id + ' - ' + t.title" :value="t.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="知乎链接">
<el-input v-model="zhihuForm.zhihu_url" placeholder="https://zhuanlan.zhihu.com/p/xxxxxx"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchZhihu" :loading="zhihuLoading">获取数据</el-button>
</el-form-item>
</el-form>
</div>
<div class="card">
<div class="toolbar" style="margin-bottom:12px;">
<span style="font-size:15px;font-weight:600;">已有数据</span>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ allEntries.length }} 条</span>
<el-button size="small" style="margin-left:12px;" @click="fetchEntries">刷新</el-button>
</div>
<div v-if="entriesLoading" style="text-align:center;padding:20px;color:#909399;">加载中...</div>
<template v-else-if="allEntries.length === 0">
<div class="empty-state" style="padding:30px 0;">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
<div class="empty-text">暂无数据,请先录入</div>
</div>
</template>
<template v-else>
<div class="metrics-table">
<el-table :data="allEntries" stripe size="small" style="width:100%;">
<el-table-column prop="topic_id" label="选题ID" width="80"></el-table-column>
<el-table-column label="平台" width="100">
<template #default="scope">{{ getPlatformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="views" label="阅读" width="70"></el-table-column>
<el-table-column prop="likes" label="点赞" width="70"></el-table-column>
<el-table-column prop="favorites" label="收藏" width="70"></el-table-column>
<el-table-column prop="comments" label="评论" width="70"></el-table-column>
<el-table-column prop="shares" label="分享" width="70"></el-table-column>
<el-table-column label="互动率" width="80">
<template #default="scope">{{ scope.row.engagement_rate || 0 }}%</template>
</el-table-column>
<el-table-column label="获取时间" width="150">
<template #default="scope">{{ formatDate(scope.row.last_fetched) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<el-button size="small" @click="editEntry(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteEntry(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</div>
</main>
</div>
</div>
@@ -182,6 +285,7 @@ const MetricsApp = {
currentUser: { username: '' },
isAdmin: false,
isLoggedIn: false,
activeTab: 'dashboard',
loadingDashboard: false,
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
trendDays: 30,
@@ -189,7 +293,15 @@ const MetricsApp = {
maxViews: 1,
platformData: [],
recommendations: [],
chartInstances: {}
chartInstances: {},
allTopics: [],
allEntries: [],
entriesLoading: false,
submitting: false,
zhihuLoading: false,
entryForm: { topic_id: '', platform: 'zhihu', publish_url: '', views: 0, likes: 0, favorites: 0, comments: 0, shares: 0 },
zhihuForm: { topic_id: '', zhihu_url: '' },
editingEntryId: null,
}
},
methods: {
@@ -354,7 +466,92 @@ const MetricsApp = {
if (res.ok) this.recommendations = await res.json();
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载推荐失败: ' + e.message); }
}
},
async fetchAllTopics() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/topics?limit=200', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) { const data = await res.json(); this.allTopics = data.topics || data || []; }
} catch (e) { console.error(e); }
},
async fetchEntries() {
this.entriesLoading = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/entries?limit=200', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.allEntries = await res.json();
} catch (e) { console.error(e); this.$message.error('加载数据失败'); }
finally { this.entriesLoading = false; }
},
resetEntryForm() {
this.entryForm = { topic_id: '', platform: 'zhihu', publish_url: '', views: 0, likes: 0, favorites: 0, comments: 0, shares: 0 };
this.editingEntryId = null;
},
editEntry(row) {
this.entryForm = { topic_id: row.topic_id, platform: row.platform, publish_url: row.publish_url || '', views: row.views || 0, likes: row.likes || 0, favorites: row.favorites || 0, comments: row.comments || 0, shares: row.shares || 0 };
this.editingEntryId = row.id;
this.$message.info('请修改后保存');
},
async submitEntry() {
if (!this.entryForm.topic_id || !this.entryForm.platform) { this.$message.warning('请选择选题和平台'); return; }
this.submitting = true;
try {
const token = localStorage.getItem('authToken');
const body = { topic_id: this.entryForm.topic_id, platform: this.entryForm.platform, publish_url: this.entryForm.publish_url, views: this.entryForm.views, likes: this.entryForm.likes, favorites: this.entryForm.favorites, comments: this.entryForm.comments, shares: this.entryForm.shares };
if (this.editingEntryId) {
const res = await fetch('/api/metrics/entries/' + this.editingEntryId, { method: 'PUT', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('更新失败');
this.$message.success('已更新');
} else {
const res = await fetch('/api/metrics/entries', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('保存失败');
this.$message.success('已保存');
}
this.resetEntryForm();
this.fetchEntries();
} catch (e) { console.error(e); this.$message.error(e.message); }
finally { this.submitting = false; }
},
async deleteEntry(id) {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
if (!res.ok) throw new Error('删除失败');
this.$message.success('已删除');
this.fetchEntries();
} catch (e) { console.error(e); this.$message.error(e.message); }
},
async fetchZhihu() {
if (!this.zhihuForm.topic_id || !this.zhihuForm.zhihu_url) { this.$message.warning('请选择选题并填写知乎链接'); return; }
this.zhihuLoading = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/zhihu-fetch', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(this.zhihuForm) });
if (!res.ok) { const d = await res.json().catch(() => ({ detail: '请求失败' })); throw new Error(d.detail); }
const result = await res.json();
this.$message.success('已从知乎获取数据');
this.fetchEntries();
if (result.data) {
this.entryForm.topic_id = result.data.topic_id;
this.entryForm.platform = result.data.platform;
this.entryForm.publish_url = result.data.publish_url || '';
this.entryForm.views = result.data.views || 0;
this.entryForm.likes = result.data.likes || 0;
this.entryForm.favorites = result.data.favorites || 0;
this.entryForm.comments = result.data.comments || 0;
this.entryForm.shares = result.data.shares || 0;
}
} catch (e) { console.error(e); this.$message.error(e.message); }
finally { this.zhihuLoading = false; }
},
formatDate(dt) {
if (!dt) return '-';
try { return new Date(dt).toLocaleString('zh-CN'); } catch { return dt; }
},
switchToEntry() {
this.fetchAllTopics();
this.fetchEntries();
},
},
mounted() {
this.checkAuth();