sage/wwwroot/hotspot/analyze.dspy
yumoqing 64cc51cb6e feat(hotspot): comprehensive revamp — 6 tables, 3 fetch modes, 5-dim analysis
Database (3→6 tables):
- hotspot_source: support api/browser/crawler types with type-specific fields
- hotspot_schedule: cron/interval daemon config per source
- hotspot_fetch_log: per-run audit trail (status/duration/items/errors)
- hotspot_item: add heat_velocity, engagement/comment/share counts, sentiment
- hotspot_analysis: 5-dimension scoring (time/heat/content/propagation/audience)
- hotspot_alert: alert rules (heat threshold/velocity/sentiment triggers)

Fetch engine (3 modes):
- API: GET/POST + headers + Bearer auth + JSON extraction
- Browser: HTML title/link extraction + og:title fallback
- Crawler: recursive with depth/domain allowlist/link following

Analysis engine:
- Exponential decay model (7-day half-life)
- Auto-classify: emerging→rising→hot→cooling→expired
- 5-dimension scoring with detail JSON per dimension

Dashboard: 6 tabs + 7 stat cards + workflow guide
Permission paths: 42 paths registered (6 tables × 7 CRUD ops)
2026-08-01 11:14:58 +08:00

206 lines
7.1 KiB
Plaintext

# 五维 + 状态自动分析
# emerging → rising → hot → cooling → expired
import json
from datetime import datetime, timedelta
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
def now_str():
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
async def main(request):
db = DBPools()
now = datetime.now()
results = {'analyzed': 0, 'status_changes': 0, 'dimensions': {}}
async with db.sqlorContext('sage') as sor:
items = await sor.sqlExe(
"select * from hotspot_item order by first_seen desc limit 500",
{}
)
for item in items:
try:
first_seen = datetime.strptime(str(item.get('first_seen', '')), '%Y-%m-%d %H:%M:%S')
except:
first_seen = now
hours_alive = max(0.1, (now - first_seen).total_seconds() / 3600)
heat = float(item.get('heat_score', 0))
velocity = float(item.get('heat_velocity', 0))
engagement = int(item.get('engagement_count', 0))
comments = int(item.get('comment_count', 0))
shares = int(item.get('share_count', 0))
current_status = item.get('status', 'emerging')
item_id = item['id']
# === 五维分析 ===
dimensions = {}
# 1. 时效维度 (0-100) — 越新越高
if hours_alive < 1:
dim_time = 95
elif hours_alive < 6:
dim_time = 85
elif hours_alive < 24:
dim_time = 70
elif hours_alive < 72:
dim_time = 50
elif hours_alive < 168:
dim_time = 30
else:
dim_time = 10
dimensions['time'] = dim_time
# 2. 热度维度 (0-100)
if heat > 10000:
dim_heat = 95
elif heat > 5000:
dim_heat = 85
elif heat > 1000:
dim_heat = 70
elif heat > 500:
dim_heat = 55
elif heat > 100:
dim_heat = 35
else:
dim_heat = 15
dimensions['heat'] = dim_heat
# 3. 内容维度 (0-100) — 基于标题长度+摘要丰富度
title_len = len(item.get('title', ''))
summary_len = len(item.get('summary', ''))
has_tags = bool(item.get('tags'))
has_category = bool(item.get('category'))
dim_content = min(100,
(20 if title_len > 15 else 10) +
(30 if summary_len > 100 else 15) +
(25 if has_tags else 0) +
(25 if has_category else 0)
)
dimensions['content'] = dim_content
# 4. 传播维度 (0-100) — 互动量
total_engagement = engagement + comments * 2 + shares * 3
if total_engagement > 10000:
dim_propagation = 95
elif total_engagement > 5000:
dim_propagation = 80
elif total_engagement > 1000:
dim_propagation = 60
elif total_engagement > 100:
dim_propagation = 35
else:
dim_propagation = 10
dimensions['propagation'] = dim_propagation
# 5. 受众维度 (0-100) — 基于互动率
if heat > 0:
engagement_rate = total_engagement / heat
else:
engagement_rate = 0
if engagement_rate > 0.5:
dim_audience = 90
elif engagement_rate > 0.2:
dim_audience = 70
elif engagement_rate > 0.05:
dim_audience = 45
elif total_engagement > 0:
dim_audience = 25
else:
dim_audience = 5
dimensions['audience'] = dim_audience
# === 状态分类 (基于热度 + 时间) ===
# 热度衰减: heat * e^(-hours/168) ~ 7天半衰期
import math
decay = math.exp(-hours_alive / 168)
adjusted_heat = heat * decay
# 热度加速度 (简化:基于当前热度/time)
new_velocity = round(heat / max(hours_alive, 0.1), 2)
if hours_alive > 336: # 超过14天
new_status = 'expired'
elif hours_alive > 168: # 7-14天
new_status = 'cooling'
elif adjusted_heat > 5000:
new_status = 'hot'
elif adjusted_heat > 500:
new_status = 'rising' if new_velocity > 50 else 'emerging'
elif adjusted_heat > 100:
new_status = 'rising' if new_velocity > 100 else 'emerging'
else:
new_status = 'emerging'
# 保存
async with db.sqlorContext('sage') as sor:
# 更新条目
await sor.U('hotspot_item', {
'id': item_id,
'heat_score': round(adjusted_heat, 2),
'heat_velocity': new_velocity,
'status': new_status,
'last_updated': now_str(),
})
results['analyzed'] += 1
if new_status != current_status:
results['status_changes'] += 1
# 保存五维分析
for dim, score in dimensions.items():
dim_names = {
'time': '时效维度',
'heat': '热度指标',
'content': '内容属性',
'propagation': '传播路径',
'audience': '受众画像',
}
analysis_data = json.dumps({
'dimension': dim,
'dimension_cn': dim_names.get(dim, dim),
'score': score,
'detail': {
'hours_alive': round(hours_alive, 1),
'adjusted_heat': round(adjusted_heat, 2),
'heat_velocity': new_velocity,
'total_engagement': total_engagement,
'decay_factor': round(decay, 4),
}
}, ensure_ascii=False)
# Upsert: delete old analysis for this item+dimension, insert new
old = await sor.sqlExe(
"select id from hotspot_analysis where item_id=${iid}$ and dimension=${dim}$",
{'iid': item_id, 'dim': dim}
)
if old:
await sor.U('hotspot_analysis', {
'id': old[0]['id'],
'score': score,
'analysis_data': analysis_data,
'analyzed_at': now_str(),
})
else:
await sor.C('hotspot_analysis', {
'id': getID(),
'item_id': item_id,
'dimension': dim,
'score': score,
'analysis_data': analysis_data,
'analyzed_at': now_str(),
})
results['dimensions'] = {
'time': dim_names['time'],
'heat': dim_names['heat'],
'content': dim_names['content'],
'propagation': dim_names['propagation'],
'audience': dim_names['audience'],
}
return results