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)
206 lines
7.1 KiB
Plaintext
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
|