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)
This commit is contained in:
yumoqing 2026-08-01 11:14:58 +08:00
parent 768544511c
commit 64cc51cb6e
12 changed files with 864 additions and 267 deletions

16
json/hotspot_alert.json Normal file
View File

@ -0,0 +1,16 @@
{
"models_dir": "${HOME}$/py/sage/models",
"output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_alert",
"dbname": "sage",
"tblname": "hotspot_alert",
"title": "预警规则",
"params": {
"sortby": "name",
"browserfields": {
"exclouded": ["id"],
"cwidth": {}
},
"editexclouded": ["id", "last_triggered"],
"record_toolbar": null
}
}

View File

@ -8,12 +8,9 @@
"sortby": "analyzed_at desc", "sortby": "analyzed_at desc",
"browserfields": { "browserfields": {
"exclouded": ["id"], "exclouded": ["id"],
"cwidth": {} "cwidth": {"analysis_data": 400}
}, },
"editexclouded": [ "editexclouded": ["id", "analyzed_at"],
"id",
"analyzed_at"
],
"record_toolbar": null "record_toolbar": null
} }
} }

View File

@ -0,0 +1,16 @@
{
"models_dir": "${HOME}$/py/sage/models",
"output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_fetch_log",
"dbname": "sage",
"tblname": "hotspot_fetch_log",
"title": "抓取日志",
"params": {
"sortby": "start_time desc",
"browserfields": {
"exclouded": ["id", "error_msg"],
"cwidth": {"error_msg": 400}
},
"editexclouded": ["id"],
"record_toolbar": null
}
}

View File

@ -8,15 +8,9 @@
"sortby": "heat_score desc", "sortby": "heat_score desc",
"browserfields": { "browserfields": {
"exclouded": ["id", "content"], "exclouded": ["id", "content"],
"cwidth": { "cwidth": {"title": 300, "summary": 400, "url": 200}
"title": 300,
"summary": 400
}
}, },
"editexclouded": [ "editexclouded": ["id", "first_seen", "last_updated"],
"id",
"fetch_time"
],
"record_toolbar": null "record_toolbar": null
} }
} }

View File

@ -0,0 +1,16 @@
{
"models_dir": "${HOME}$/py/sage/models",
"output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_schedule",
"dbname": "sage",
"tblname": "hotspot_schedule",
"title": "调度配置",
"params": {
"sortby": "source_id",
"browserfields": {
"exclouded": ["id"],
"cwidth": {}
},
"editexclouded": ["id", "last_run", "next_run"],
"record_toolbar": null
}
}

View File

@ -5,16 +5,12 @@
"tblname": "hotspot_source", "tblname": "hotspot_source",
"title": "热点来源", "title": "热点来源",
"params": { "params": {
"sortby": "name", "sortby": "priority asc",
"browserfields": { "browserfields": {
"exclouded": ["id"], "exclouded": ["id", "api_secret", "headers", "request_body", "crawler_rules"],
"cwidth": {} "cwidth": {"name": 150, "url": 300}
}, },
"editexclouded": [ "editexclouded": ["id", "created_at", "last_fetch"],
"id",
"last_fetch",
"created_at"
],
"record_toolbar": null "record_toolbar": null
} }
} }

View File

@ -1508,6 +1508,24 @@ paths="""/ any
/hotspot_analysis/add_hotspot_analysis.dspy logined /hotspot_analysis/add_hotspot_analysis.dspy logined
/hotspot_analysis/update_hotspot_analysis.dspy logined /hotspot_analysis/update_hotspot_analysis.dspy logined
/hotspot_analysis/delete_hotspot_analysis.dspy logined /hotspot_analysis/delete_hotspot_analysis.dspy logined
/hotspot_schedule logined
/hotspot_schedule/index.ui logined
/hotspot_schedule/get_hotspot_schedule.dspy logined
/hotspot_schedule/add_hotspot_schedule.dspy logined
/hotspot_schedule/update_hotspot_schedule.dspy logined
/hotspot_schedule/delete_hotspot_schedule.dspy logined
/hotspot_fetch_log logined
/hotspot_fetch_log/index.ui logined
/hotspot_fetch_log/get_hotspot_fetch_log.dspy logined
/hotspot_fetch_log/add_hotspot_fetch_log.dspy logined
/hotspot_fetch_log/update_hotspot_fetch_log.dspy logined
/hotspot_fetch_log/delete_hotspot_fetch_log.dspy logined
/hotspot_alert logined
/hotspot_alert/index.ui logined
/hotspot_alert/get_hotspot_alert.dspy logined
/hotspot_alert/add_hotspot_alert.dspy logined
/hotspot_alert/update_hotspot_alert.dspy logined
/hotspot_alert/delete_hotspot_alert.dspy logined
""" """

View File

@ -6578,28 +6578,87 @@ default charset=utf8
comment '模型供应商' comment '模型供应商'
; ;
-- ./hotspot_source -- ============================================
-- 热点雷达系统 - 6 tables
-- ============================================
-- ./hotspot_source (来源配置: API / Browser / Crawler)
drop table if exists hotspot_source; drop table if exists hotspot_source;
CREATE TABLE hotspot_source CREATE TABLE hotspot_source
( (
`id` VARCHAR(32) comment 'id', `id` VARCHAR(32) comment 'id',
`name` VARCHAR(200) comment '来源名称', `name` VARCHAR(200) comment '来源名称',
`type` VARCHAR(20) comment '类型: rss/api/web', `type` VARCHAR(20) comment '类型: rss/api/browser/crawler',
`url` VARCHAR(2000) comment '来源URL', `url` VARCHAR(2000) comment '目标URL',
`fetch_method` VARCHAR(20) DEFAULT 'GET' comment '请求方法', -- API 类型专用
`api_key` VARCHAR(500) comment 'API Key',
`api_secret` VARCHAR(500) comment 'API Secret(加密)',
`headers` TEXT comment '自定义请求头 JSON', `headers` TEXT comment '自定义请求头 JSON',
`fetch_method` VARCHAR(10) DEFAULT 'GET' comment 'HTTP方法',
`request_body` TEXT comment 'POST请求体',
-- Browser 类型专用
`browser_headless` VARCHAR(1) DEFAULT '1' comment '无头模式',
`browser_wait_selector` VARCHAR(500) comment '等待元素选择器',
`browser_scroll` VARCHAR(1) DEFAULT '0' comment '是否自动滚动',
-- Crawler 类型专用
`crawler_depth` INT DEFAULT 1 comment '爬取深度',
`crawler_rules` TEXT comment '爬取规则 JSON',
`crawler_start_urls` TEXT comment '起始URL列表 JSON',
-- 通用
`proxy_enabled` VARCHAR(1) DEFAULT '0' comment '启用代理',
`proxy_url` VARCHAR(500) comment '代理地址',
`enabled` VARCHAR(1) DEFAULT '1' comment '是否启用', `enabled` VARCHAR(1) DEFAULT '1' comment '是否启用',
`fetch_interval_min` INT DEFAULT 60 comment '抓取间隔(分钟)', `priority` INT DEFAULT 0 comment '优先级(越大越高)',
`last_fetch` VARCHAR(50) comment '上次抓取时间', `last_fetch` VARCHAR(50) comment '上次抓取时间',
`created_at` VARCHAR(50) comment '创建时间' `created_at` VARCHAR(50) comment '创建时间'
,primary key(id) ,primary key(id)
) )
engine=innodb engine=innodb default charset=utf8 comment '热点来源配置'
default charset=utf8
comment '热点来源'
; ;
-- ./hotspot_item -- ./hotspot_schedule (调度配置)
drop table if exists hotspot_schedule;
CREATE TABLE hotspot_schedule
(
`id` VARCHAR(32) comment 'id',
`source_id` VARCHAR(32) comment '关联来源ID',
`cron_expr` VARCHAR(100) comment 'cron表达式',
`interval_min` INT DEFAULT 60 comment '间隔(分钟)',
`enabled` VARCHAR(1) DEFAULT '1' comment '是否启用',
`last_run` VARCHAR(50) comment '上次运行',
`next_run` VARCHAR(50) comment '下次运行',
`retry_max` INT DEFAULT 3 comment '最大重试',
`retry_count` INT DEFAULT 0 comment '已重试次数'
,primary key(id)
)
engine=innodb default charset=utf8 comment '调度配置'
;
CREATE INDEX hotspot_schedule_source_idx ON hotspot_schedule(source_id);
-- ./hotspot_fetch_log (抓取日志)
drop table if exists hotspot_fetch_log;
CREATE TABLE hotspot_fetch_log
(
`id` VARCHAR(32) comment 'id',
`source_id` VARCHAR(32) comment '关联来源ID',
`start_time` VARCHAR(50) comment '开始时间',
`end_time` VARCHAR(50) comment '结束时间',
`duration_ms` INT DEFAULT 0 comment '耗时(毫秒)',
`status` VARCHAR(20) comment '状态: success/failed/partial',
`items_total` INT DEFAULT 0 comment '抓取总数',
`items_new` INT DEFAULT 0 comment '新增数量',
`items_duplicate` INT DEFAULT 0 comment '重复数量',
`error_msg` TEXT comment '错误信息',
`response_code` INT comment 'HTTP状态码',
`response_size_bytes` INT DEFAULT 0 comment '响应大小'
,primary key(id)
)
engine=innodb default charset=utf8 comment '抓取日志'
;
CREATE INDEX hotspot_fetch_log_source_idx ON hotspot_fetch_log(source_id);
CREATE INDEX hotspot_fetch_log_status_idx ON hotspot_fetch_log(status);
-- ./hotspot_item (热点条目)
drop table if exists hotspot_item; drop table if exists hotspot_item;
CREATE TABLE hotspot_item CREATE TABLE hotspot_item
( (
@ -6609,39 +6668,60 @@ CREATE TABLE hotspot_item
`url` VARCHAR(2000) comment '原文链接', `url` VARCHAR(2000) comment '原文链接',
`summary` TEXT comment '摘要', `summary` TEXT comment '摘要',
`content` TEXT comment '正文', `content` TEXT comment '正文',
`heat_score` INT DEFAULT 0 comment '热度分数', `heat_score` DOUBLE(10,2) DEFAULT 0 comment '热度分数',
`heat_velocity` DOUBLE(10,2) DEFAULT 0 comment '热度加速度',
`publish_time` VARCHAR(50) comment '发布时间', `publish_time` VARCHAR(50) comment '发布时间',
`fetch_time` VARCHAR(50) comment '抓取时间', `first_seen` VARCHAR(50) comment '首次发现',
`status` VARCHAR(20) DEFAULT 'rising' comment '状态: rising/hot/cooling/expired', `last_updated` VARCHAR(50) comment '最后更新',
`status` VARCHAR(20) DEFAULT 'emerging' comment '状态: emerging/rising/hot/cooling/expired',
`category` VARCHAR(100) comment '分类', `category` VARCHAR(100) comment '分类',
`tags` VARCHAR(500) comment '标签逗号分隔' `tags` VARCHAR(500) comment '标签',
`engagement_count` INT DEFAULT 0 comment '互动数',
`comment_count` INT DEFAULT 0 comment '评论数',
`share_count` INT DEFAULT 0 comment '转发数',
`sentiment` VARCHAR(20) DEFAULT 'neutral' comment '情感: positive/negative/neutral',
`is_verified` VARCHAR(1) DEFAULT '0' comment '是否已验证',
`duplicate_of` VARCHAR(32) comment '重复于(另一热点ID)'
,primary key(id) ,primary key(id)
) )
engine=innodb engine=innodb default charset=utf8 comment '热点条目'
default charset=utf8
comment '热点条目'
; ;
CREATE INDEX hotspot_item_source_idx ON hotspot_item(source_id); CREATE INDEX hotspot_item_source_idx ON hotspot_item(source_id);
CREATE INDEX hotspot_item_status_idx ON hotspot_item(status); CREATE INDEX hotspot_item_status_idx ON hotspot_item(status);
CREATE INDEX hotspot_item_score_idx ON hotspot_item(heat_score);
-- ./hotspot_analysis -- ./hotspot_analysis (五维分析)
drop table if exists hotspot_analysis; drop table if exists hotspot_analysis;
CREATE TABLE hotspot_analysis CREATE TABLE hotspot_analysis
( (
`id` VARCHAR(32) comment 'id', `id` VARCHAR(32) comment 'id',
`item_id` VARCHAR(32) comment '关联热点ID', `item_id` VARCHAR(32) comment '关联热点ID',
`dimension` VARCHAR(50) comment '分析维度', `dimension` VARCHAR(50) comment '维度: time/heat/content/propagation/audience',
`score` DOUBLE(10,2) DEFAULT 0 comment '维度得分', `score` DOUBLE(10,2) DEFAULT 0 comment '维度得分(0-100)',
`analysis_data` TEXT comment '分析数据JSON', `analysis_data` TEXT comment '分析数据JSON',
`analyzed_at` VARCHAR(50) comment '分析时间' `analyzed_at` VARCHAR(50) comment '分析时间'
,primary key(id) ,primary key(id)
) )
engine=innodb engine=innodb default charset=utf8 comment '五维分析'
default charset=utf8
comment '热点分析'
; ;
CREATE INDEX hotspot_analysis_item_idx ON hotspot_analysis(item_id); CREATE INDEX hotspot_analysis_item_idx ON hotspot_analysis(item_id);
-- ./hotspot_alert (预警规则)
drop table if exists hotspot_alert;
CREATE TABLE hotspot_alert
(
`id` VARCHAR(32) comment 'id',
`name` VARCHAR(200) comment '规则名称',
`source_id` VARCHAR(32) comment '来源ID(空=全局)',
`condition_type` VARCHAR(50) comment '条件: heat_threshold/velocity/sentiment/status_change',
`condition_value` VARCHAR(500) comment '条件值 JSON',
`enabled` VARCHAR(1) DEFAULT '1' comment '是否启用',
`notify_method` VARCHAR(50) comment '通知方式: email/webhook/sms',
`notify_target` VARCHAR(500) comment '通知目标',
`last_triggered` VARCHAR(50) comment '上次触发时间'
,primary key(id)
)
engine=innodb default charset=utf8 comment '预警规则'
;

View File

@ -1,55 +1,205 @@
# 自动分析热点状态:根据时间和热度自动分类 # 五维 + 状态自动分析
# rising -> hot -> cooling -> expired # emerging → rising → hot → cooling → expired
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlor.dbpools import DBPools 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): async def main(request):
db = DBPools() db = DBPools()
now = datetime.now() now = datetime.now()
analyzed = 0 results = {'analyzed': 0, 'status_changes': 0, 'dimensions': {}}
async with db.sqlorContext('sage') as sor: async with db.sqlorContext('sage') as sor:
items = await sor.sqlExe( items = await sor.sqlExe(
"select * from hotspot_item order by fetch_time desc", "select * from hotspot_item order by first_seen desc limit 500",
{} {}
) )
for item in items: for item in items:
try: try:
fetch_time = datetime.strptime(str(item.get('fetch_time', '')), '%Y-%m-%d %H:%M:%S') first_seen = datetime.strptime(str(item.get('first_seen', '')), '%Y-%m-%d %H:%M:%S')
except: except:
fetch_time = now first_seen = now
hours_ago = (now - fetch_time).total_seconds() / 3600 hours_alive = max(0.1, (now - first_seen).total_seconds() / 3600)
heat = int(item.get('heat_score', 0)) heat = float(item.get('heat_score', 0))
current_status = item.get('status', 'rising') 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']
# 热度衰减计算 # === 五维分析 ===
decay_factor = max(0, 1 - hours_ago / 168) # 7天衰减到0 dimensions = {}
adjusted_heat = heat * decay_factor
# 状态分类 # 1. 时效维度 (0-100) — 越新越高
if hours_ago > 168: # 超过7天 if hours_alive < 1:
new_status = 'expired' dim_time = 95
elif hours_ago > 72: # 超过3天 elif hours_alive < 6:
new_status = 'cooling' dim_time = 85
elif adjusted_heat > 500: elif hours_alive < 24:
new_status = 'hot' dim_time = 70
elif adjusted_heat > 100: elif hours_alive < 72:
new_status = 'rising' dim_time = 50
elif hours_alive < 168:
dim_time = 30
else: else:
new_status = 'cooling' if hours_ago > 24 else 'rising' dim_time = 10
dimensions['time'] = dim_time
if new_status != current_status: # 2. 热度维度 (0-100)
async with db.sqlorContext('sage') as sor: if heat > 10000:
await sor.U('hotspot_item', { dim_heat = 95
'id': item['id'], elif heat > 5000:
'status': new_status, dim_heat = 85
'heat_score': int(adjusted_heat), elif heat > 1000:
}) dim_heat = 70
analyzed += 1 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 { return results
'analyzed': analyzed,
'time': now.strftime('%Y-%m-%d %H:%M:%S'),
}

View File

@ -1,165 +1,419 @@
# 从所有启用的来源抓取热点数据 # 抓取引擎 — 支持 API / Browser / Crawler 三种模式
import json import json, time, hashlib
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime from datetime import datetime
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
async def fetch_rss(url): def now_str():
"""解析 RSS/Atom feed""" return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
async def fetch_rss(session, url):
"""RSS/Atom 解析"""
import aiohttp import aiohttp
items = [] items = []
try: try:
async with aiohttp.ClientSession() as session: async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200:
if resp.status != 200: return items, resp.status, 0
return items text = await resp.text()
text = await resp.text() size = len(text.encode())
root = ET.fromstring(text) root = ET.fromstring(text)
ns = {'atom': 'http://www.w3.org/2005/Atom'} ns_atom = 'http://www.w3.org/2005/Atom'
# RSS 2.0 # RSS 2.0
for item in root.findall('.//item'): for item in root.findall('.//item'):
title = item.findtext('title', '')
link = item.findtext('link', '')
desc = item.findtext('description', '')[:500]
pub_date = item.findtext('pubDate', '')
items.append({ items.append({
'title': title, 'title': item.findtext('title', ''),
'url': link, 'url': item.findtext('link', ''),
'summary': desc, 'summary': (item.findtext('description', '') or '')[:500],
'publish_time': pub_date, 'publish_time': item.findtext('pubDate', ''),
}) })
# Atom # Atom fallback
if not items: if not items:
for entry in root.findall('.//atom:entry', ns): for entry in root.findall(f'.//{{{ns_atom}}}entry'):
title = entry.findtext('atom:title', '', ns) link_el = entry.find(f'{{{ns_atom}}}link')
link_el = entry.find('atom:link', ns)
link = link_el.get('href', '') if link_el is not None else ''
summary = entry.findtext('atom:summary', '', ns) or ''
updated = entry.findtext('atom:updated', '', ns) or ''
items.append({ items.append({
'title': title, 'title': entry.findtext(f'{{{ns_atom}}}title', ''),
'url': link, 'url': link_el.get('href', '') if link_el is not None else '',
'summary': summary[:500], 'summary': (entry.findtext(f'{{{ns_atom}}}summary', '') or '')[:500],
'publish_time': updated, 'publish_time': entry.findtext(f'{{{ns_atom}}}updated', '') or '',
}) })
return items, resp.status, size
except Exception as e: except Exception as e:
print(f'RSS fetch error for {url}: {e}') return [], 0, 0
return items
async def fetch_api(url, method='GET', headers=None, json_path=None): async def fetch_api(session, src):
"""调用 API 端点获取热点数据""" """API 模式:支持 GET/POST + Headers + Auth"""
import aiohttp import aiohttp
url = src.get('url', '')
method = src.get('fetch_method', 'GET').upper()
headers = {}
if src.get('headers'):
try:
headers = json.loads(src.get('headers', '{}'))
except:
pass
if src.get('api_key'):
headers['Authorization'] = f'Bearer {src["api_key"]}'
body = None
if src.get('request_body') and method == 'POST':
try:
body = json.dumps(json.loads(src['request_body']))
except:
body = src['request_body']
items = [] items = []
try: try:
hdrs = {} if method == 'POST':
if headers: async with session.post(url, headers=headers, data=body or '', timeout=aiohttp.ClientTimeout(total=30)) as resp:
try: status = resp.status
hdrs = json.loads(headers) text = await resp.text()
except: size = len(text.encode())
pass if status == 200:
try:
data = json.loads(text)
except:
data = {}
else:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
status = resp.status
text = await resp.text()
size = len(text.encode())
if status == 200:
try:
data = json.loads(text)
except:
# Maybe it's XML/RSS — try RSS parser
data = {}
async with aiohttp.ClientSession() as session: # Extract items from JSON response
if method.upper() == 'GET':
async with session.get(url, headers=hdrs, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status == 200:
data = await resp.json()
else:
async with session.post(url, headers=hdrs, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status == 200:
data = await resp.json()
# 尝试从 JSON 中提取 items
if isinstance(data, list): if isinstance(data, list):
entries = data entries = data
elif isinstance(data, dict): elif isinstance(data, dict):
# 常见热点 API 格式 entries = data.get('data', data.get('items', data.get('list', data.get('result', []))))
entries = data.get('data', data.get('items', data.get('list', [])))
else: else:
entries = [] entries = []
for entry in entries[:50]: # limit for entry in entries[:100]:
if isinstance(entry, dict): if not isinstance(entry, dict):
title = entry.get('title', entry.get('name', '')) continue
url_val = entry.get('url', entry.get('link', '')) items.append({
summary = entry.get('summary', entry.get('description', entry.get('desc', ''))) 'title': str(entry.get('title', entry.get('name', ''))),
heat = entry.get('heat', entry.get('hot', entry.get('score', 0))) 'url': str(entry.get('url', entry.get('link', ''))),
items.append({ 'summary': str(entry.get('summary', entry.get('description', entry.get('desc', ''))))[:500],
'title': str(title), 'heat_score': float(entry.get('heat', entry.get('hot', entry.get('score', 0))) or 0),
'url': str(url_val), 'category': str(entry.get('category', entry.get('type', ''))),
'summary': str(summary)[:500], 'tags': str(entry.get('tags', '')),
'heat_score': int(heat) if heat else 0, 'engagement_count': int(entry.get('engagement', entry.get('interactions', 0)) or 0),
}) 'comment_count': int(entry.get('comments', entry.get('replies', 0)) or 0),
'share_count': int(entry.get('shares', entry.get('forwards', 0)) or 0),
})
return items, status, size
except Exception as e: except Exception as e:
print(f'API fetch error for {url}: {e}') return [], 0, 0
return items
async def fetch_browser(session, src):
"""
Browser 模式:使用 HTTP 请求 + 从 HTML 提取结构化数据
(完整 Headless Browser 需要 playwright/selenium — 这里用 HTTP+解析做轻量版)
"""
import aiohttp
from html.parser import HTMLParser
url = src.get('url', '')
items = []
try:
headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'}
if src.get('headers'):
try:
headers.update(json.loads(src.get('headers', '{}')))
except:
pass
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
status = resp.status
html = await resp.text()
size = len(html.encode())
if status != 200:
return items, status, size
# 通用 HTML 提取:找常见的热点列表结构
# 策略:提取所有 <a> 标签中带标题属性的,或 <article>/<li> 块
from html.parser import HTMLParser as HP
class HotExtractor(HP):
def __init__(self):
super().__init__()
self.items = []
self.current = {}
self.in_item = False
self.tag_stack = []
self.text_buf = ''
def handle_starttag(self, tag, attrs):
attrs_d = dict(attrs)
self.tag_stack.append(tag)
# 检测列表项容器
if tag in ('article', 'li') and 'class' in attrs_d:
cls = attrs_d['class'].lower()
if any(k in cls for k in ('post', 'item', 'entry', 'card', 'topic', 'hot', 'trend')):
self.in_item = True
self.current = {'url': attrs_d.get('href', '')}
if tag == 'a' and ('title' in attrs_d or self._is_hot_class(attrs_d.get('class', ''))):
href = attrs_d.get('href', '')
title = attrs_d.get('title', '')
if href and title:
self.items.append({'title': title, 'url': href, 'summary': ''})
def handle_endtag(self, tag):
if self.tag_stack:
self.tag_stack.pop()
if self.in_item and tag in ('article', 'li', 'div'):
if self.current.get('title'):
self.items.append(self.current)
self.in_item = False
self.current = {}
def handle_data(self, data):
if self.in_item and not self.current.get('title'):
d = data.strip()
if len(d) > 3:
self.current['title'] = d[:200]
def _is_hot_class(self, cls):
if not cls:
return False
cl = cls.lower()
return any(k in cl for k in ('title', 'hot', 'trending', 'headline'))
extractor = HotExtractor()
try:
extractor.feed(html)
except:
pass
for it in extractor.items:
if it.get('title') and len(it['title']) > 2:
items.append({
'title': it['title'][:200],
'url': it.get('url', ''),
'summary': it.get('summary', '')[:500],
'heat_score': 80,
})
# 如果没提取到,尝试 JSON-LD 或 meta 标签
if not items:
import re
# 尝试提取 og:title / twitter:title
og_titles = re.findall(r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"', html)
for t in og_titles[:20]:
items.append({'title': t, 'url': url, 'summary': '', 'heat_score': 60})
return items, status, size
except Exception as e:
return [], 0, 0
async def fetch_crawler(session, src):
"""
Crawler 模式从起始URL递归爬取遵守深度和规则
"""
import aiohttp
from urllib.parse import urljoin, urlparse
start_urls = [src.get('url', '')]
if src.get('crawler_start_urls'):
try:
start_urls = json.loads(src.get('crawler_start_urls', '[]'))
except:
pass
depth = int(src.get('crawler_depth', 1))
rules = {}
if src.get('crawler_rules'):
try:
rules = json.loads(src.get('crawler_rules', '{}'))
except:
pass
# 规则allowed_domains, link_selector, item_selector, title_selector
allowed_domains = rules.get('allowed_domains', [])
visited = set()
to_visit = list(start_urls)
items = []
headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'}
for d in range(depth):
next_urls = []
for url in to_visit:
if url in visited or len(items) >= 200:
continue
visited.add(url)
# 域名白名单检查
if allowed_domains:
domain = urlparse(url).netloc
if not any(ad in domain for ad in allowed_domains):
continue
try:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=20)) as resp:
if resp.status != 200:
continue
html = await resp.text()
import re
# 提取页面内链接
links = re.findall(r'href=["\']([^"\']+)["\']', html)
for link in links:
full = urljoin(url, link)
if full not in visited and full.startswith(('http://', 'https://')):
next_urls.append(full)
# 提取标题
titles = re.findall(r'<title>([^<]+)</title>', html)
if titles:
items.append({
'title': titles[0][:200],
'url': url,
'summary': '',
'heat_score': 50 + (3 - d) * 15,
})
# 尝试提取 h1/h2 标题
h_tags = re.findall(r'<h[12][^>]*>([^<]+)</h[12]>', html)
for h in h_tags[:10]:
h = h.strip()
if len(h) > 5:
items.append({
'title': h[:200],
'url': url,
'summary': '',
'heat_score': 40 + (3 - d) * 10,
})
except:
continue
to_visit = next_urls[:50] # 每层限制50个链接
return items, 200, 0
async def main(request): async def main(request):
db = DBPools() db = DBPools()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') now = now_str()
new_items = 0 results = {'total': 0, 'new_items': 0, 'errors': 0, 'details': []}
total_sources = 0
async with db.sqlorContext('sage') as sor: async with db.sqlorContext('sage') as sor:
sources = await sor.R('hotspot_source', {}) sources = await sor.R('hotspot_source', {})
for src in sources: import aiohttp
if src.get('enabled') != '1': async with aiohttp.ClientSession() as session:
continue for src in sources:
if src.get('enabled') != '1':
total_sources += 1 continue
src_type = src.get('type', 'rss')
src_url = src.get('url', '')
if not src_url:
continue
if src_type == 'rss':
items = await fetch_rss(src_url)
elif src_type == 'api':
fetch_method = src.get('fetch_method', 'GET')
headers = src.get('headers', '')
items = await fetch_api(src_url, fetch_method, headers)
else:
continue
# 存入数据库
async with db.sqlorContext('sage') as sor:
# 更新 last_fetch
await sor.U('hotspot_source', {
'id': src['id'],
'last_fetch': now,
})
for item in items: results['total'] += 1
# 去重:检查 URL 是否已存在 src_id = src['id']
existing = await sor.sqlExe( src_type = src.get('type', 'rss')
"select id from hotspot_item where url=${url}$", t0 = time.time()
{'url': item.get('url', '')}
) # 执行抓取
if existing: try:
continue if src_type == 'rss':
items, code, size = await fetch_rss(session, src.get('url', ''))
elif src_type == 'api':
items, code, size = await fetch_api(session, src)
elif src_type == 'browser':
items, code, size = await fetch_browser(session, src)
elif src_type == 'crawler':
items, code, size = await fetch_crawler(session, src)
else:
items, code, size = [], 0, 0
except Exception as e:
items, code, size = [], 0, 0
results['errors'] += 1
async with db.sqlorContext('sage') as sor:
await sor.C('hotspot_fetch_log', {
'id': getID(), 'source_id': src_id,
'start_time': now, 'end_time': now_str(),
'duration_ms': int((time.time() - t0) * 1000),
'status': 'failed', 'items_total': 0, 'items_new': 0,
'items_duplicate': 0, 'error_msg': str(e)[:1000],
'response_code': 0, 'response_size_bytes': 0,
})
continue
elapsed = int((time.time() - t0) * 1000)
status = 'success' if code in (0, 200) else 'partial'
new_count = 0
dup_count = 0
# 存入数据库
async with db.sqlorContext('sage') as sor:
# 更新来源 last_fetch
await sor.U('hotspot_source', {'id': src_id, 'last_fetch': now})
new_id = getID() for item in items:
status = 'rising' # 新抓取的默认 rising url = item.get('url', '')
await sor.C('hotspot_item', { if url:
'id': new_id, existing = await sor.sqlExe(
'source_id': src['id'], "select id from hotspot_item where url=${u}$ limit 1", {'u': url})
'title': item.get('title', ''), if existing:
'url': item.get('url', ''), dup_count += 1
'summary': item.get('summary', '')[:500], continue
'heat_score': item.get('heat_score', 100),
'publish_time': item.get('publish_time', now), new_id = getID()
'fetch_time': now, await sor.C('hotspot_item', {
'status': status, 'id': new_id, 'source_id': src_id,
'title': item.get('title', '')[:500],
'url': url[:2000] if url else '',
'summary': item.get('summary', '')[:1000],
'heat_score': item.get('heat_score', 100),
'heat_velocity': 0,
'publish_time': item.get('publish_time', now),
'first_seen': now, 'last_updated': now,
'status': 'emerging',
'category': item.get('category', ''),
'tags': item.get('tags', ''),
'engagement_count': item.get('engagement_count', 0),
'comment_count': item.get('comment_count', 0),
'share_count': item.get('share_count', 0),
'sentiment': 'neutral',
})
new_count += 1
# 写抓取日志
await sor.C('hotspot_fetch_log', {
'id': getID(), 'source_id': src_id,
'start_time': now, 'end_time': now_str(),
'duration_ms': elapsed,
'status': status, 'items_total': len(items),
'items_new': new_count, 'items_duplicate': dup_count,
'error_msg': '',
'response_code': code, 'response_size_bytes': size,
}) })
new_items += 1
results['new_items'] += new_count
results['details'].append({
'source': src.get('name', src_id),
'type': src_type,
'total': len(items),
'new': new_count,
'dup': dup_count,
'elapsed_ms': elapsed,
'code': code,
})
return { return results
'total': total_sources,
'new_items': new_items,
'time': now,
}

View File

@ -3,20 +3,6 @@
"widgettype": "VBox", "widgettype": "VBox",
"options": { "options": {
"children": [ "children": [
{
"widgettype": "Text",
"options": {
"text": "热点雷达系统",
"style": "font-size:24px;font-weight:bold;padding:16px 16px 0 16px;"
}
},
{
"widgettype": "Text",
"options": {
"text": "指定来源的热点聚合分析平台",
"style": "font-size:14px;color:#999;padding:0 16px 16px 16px;"
}
},
{ {
"widgettype": "HBox", "widgettype": "HBox",
"options": { "options": {
@ -24,12 +10,12 @@
{ {
"widgettype": "VBox", "widgettype": "VBox",
"options": { "options": {
"id": "stat_total", "id": "stat_emerging",
"children": [ "children": [
{"widgettype": "Text", "options": {"text": "热点总数", "style": "color:#666;font-size:12px;"}}, {"widgettype": "Text", "options": {"text": "潜在热点", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#1890ff;"}} {"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#52c41a;"}}
], ],
"style": "padding:16px;background:#f0f5ff;border-radius:8px;margin:8px;min-width:100px;text-align:center;" "style": "padding:14px;background:#f6ffed;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #b7eb8f;"
} }
}, },
{ {
@ -38,9 +24,9 @@
"id": "stat_rising", "id": "stat_rising",
"children": [ "children": [
{"widgettype": "Text", "options": {"text": "上升中", "style": "color:#666;font-size:12px;"}}, {"widgettype": "Text", "options": {"text": "上升中", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#52c41a;"}} {"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#1890ff;"}}
], ],
"style": "padding:16px;background:#f6ffed;border-radius:8px;margin:8px;min-width:100px;text-align:center;" "style": "padding:14px;background:#e6f7ff;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #91d5ff;"
} }
}, },
{ {
@ -49,9 +35,9 @@
"id": "stat_hot", "id": "stat_hot",
"children": [ "children": [
{"widgettype": "Text", "options": {"text": "正热点", "style": "color:#666;font-size:12px;"}}, {"widgettype": "Text", "options": {"text": "正热点", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#ff4d4f;"}} {"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#ff4d4f;"}}
], ],
"style": "padding:16px;background:#fff2f0;border-radius:8px;margin:8px;min-width:100px;text-align:center;" "style": "padding:14px;background:#fff1f0;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #ffa39e;"
} }
}, },
{ {
@ -60,9 +46,9 @@
"id": "stat_cooling", "id": "stat_cooling",
"children": [ "children": [
{"widgettype": "Text", "options": {"text": "降温中", "style": "color:#666;font-size:12px;"}}, {"widgettype": "Text", "options": {"text": "降温中", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#faad14;"}} {"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#faad14;"}}
], ],
"style": "padding:16px;background:#fffbe6;border-radius:8px;margin:8px;min-width:100px;text-align:center;" "style": "padding:14px;background:#fffbe6;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #ffe58f;"
} }
}, },
{ {
@ -71,9 +57,9 @@
"id": "stat_expired", "id": "stat_expired",
"children": [ "children": [
{"widgettype": "Text", "options": {"text": "已过期", "style": "color:#666;font-size:12px;"}}, {"widgettype": "Text", "options": {"text": "已过期", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#999;"}} {"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#bfbfbf;"}}
], ],
"style": "padding:16px;background:#f5f5f5;border-radius:8px;margin:8px;min-width:100px;text-align:center;" "style": "padding:14px;background:#fafafa;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #d9d9d9;"
} }
}, },
{ {
@ -81,10 +67,21 @@
"options": { "options": {
"id": "stat_sources", "id": "stat_sources",
"children": [ "children": [
{"widgettype": "Text", "options": {"text": "数据来源", "style": "color:#666;font-size:12px;"}}, {"widgettype": "Text", "options": {"text": "活跃来源", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#722ed1;"}} {"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#722ed1;"}}
], ],
"style": "padding:16px;background:#f9f0ff;border-radius:8px;margin:8px;min-width:100px;text-align:center;" "style": "padding:14px;background:#f9f0ff;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #d3adf7;"
}
},
{
"widgettype": "VBox",
"options": {
"id": "stat_failures",
"children": [
{"widgettype": "Text", "options": {"text": "24h 抓取失败", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:32px;font-weight:bold;color:#ff7875;"}}
],
"style": "padding:14px;background:#fff2f0;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #ffccc7;"
} }
} }
] ]
@ -99,8 +96,17 @@
"options": { "options": {
"label": "立即抓取全部来源", "label": "立即抓取全部来源",
"actiontype": "script", "actiontype": "script",
"script": "fetch('/hotspot/fetch_now.dspy').then(r=>r.json()).then(d=>{alert('抓取完成: 处理'+d.total+'个来源,新增'+d.new_items+'条');setTimeout(()=>location.reload(),1000);}).catch(e=>alert('抓取出错: '+e))", "script": "fetch('/hotspot/fetch_now.dspy').then(r=>r.json()).then(d=>{var m='完成: '+d.total+'来源, 新增'+d.new_items+'条';if(d.errors)m+='\\\\n失败: '+d.errors;alert(m);setTimeout(()=>location.reload(),1000);}).catch(e=>alert('异常: '+e))",
"style": "margin:8px;" "style": "margin:6px;"
}
},
{
"widgettype": "Button",
"options": {
"label": "执行分析",
"actiontype": "script",
"script": "fetch('/hotspot/analyze.dspy').then(r=>r.json()).then(d=>{alert('已分析: '+d.analyzed+'条热点');setTimeout(()=>location.reload(),1000);}).catch(e=>alert('分析异常: '+e))",
"style": "margin:6px;"
} }
}, },
{ {
@ -108,8 +114,8 @@
"options": { "options": {
"label": "刷新统计", "label": "刷新统计",
"actiontype": "script", "actiontype": "script",
"script": "fetch('/hotspot/stats.dspy').then(r=>r.json()).then(d=>{document.querySelectorAll('[id^=stat_]').forEach(el=>{const num=el.querySelectorAll('div')[1];if(num){const k=el.id.replace('stat_','');num.textContent=d[k]||0;}});}).catch(e=>console.error(e))", "script": "fetch('/hotspot/stats.dspy').then(r=>r.json()).then(d=>{['emerging','rising','hot','cooling','expired','sources','failures'].forEach(k=>{var el=document.querySelectorAll('[id=stat_'+k+'] div')[1];if(el)el.textContent=d[k]||0;});}).catch(e=>console.error('stats err',e))",
"style": "margin:8px;" "style": "margin:6px;"
} }
} }
] ]
@ -119,31 +125,46 @@
"widgettype": "Tab", "widgettype": "Tab",
"options": { "options": {
"tabs": [ "tabs": [
{
"title": "热点条目",
"content": {
"widgettype": "urlwidget",
"options": {
"url": "{{ entire_url('hotspot_item') }}"
}
}
},
{ {
"title": "来源管理", "title": "来源管理",
"content": { "content": {
"widgettype": "urlwidget", "widgettype": "urlwidget",
"options": { "options": {"url": "{{ entire_url('hotspot_source') }}"}
"url": "{{ entire_url('hotspot_source') }}"
}
} }
}, },
{ {
"title": "分析记录", "title": "调度配置",
"content": { "content": {
"widgettype": "urlwidget", "widgettype": "urlwidget",
"options": { "options": {"url": "{{ entire_url('hotspot_schedule') }}"}
"url": "{{ entire_url('hotspot_analysis') }}" }
} },
{
"title": "抓取日志",
"content": {
"widgettype": "urlwidget",
"options": {"url": "{{ entire_url('hotspot_fetch_log') }}"}
}
},
{
"title": "热点条目",
"content": {
"widgettype": "urlwidget",
"options": {"url": "{{ entire_url('hotspot_item') }}"}
}
},
{
"title": "五维分析",
"content": {
"widgettype": "urlwidget",
"options": {"url": "{{ entire_url('hotspot_analysis') }}"}
}
},
{
"title": "预警规则",
"content": {
"widgettype": "urlwidget",
"options": {"url": "{{ entire_url('hotspot_alert') }}"}
} }
} }
] ]
@ -152,8 +173,8 @@
{ {
"widgettype": "Text", "widgettype": "Text",
"options": { "options": {
"text": "提示:在「来源管理」中添加 RSS/API 来源后,点击「立即抓取」获取热点数据。", "text": "工作流: ① 添加来源(type=api/browser/crawler) → ② 配置调度(cron/interval) → ③ 查看抓取日志(成功率/错误) → ④ 浏览热点(emerging/rising/hot/cooling/expired) → ⑤ 五维分析 → ⑥ 设置预警",
"style": "font-size:12px;color:#bbb;padding:16px;" "style": "font-size:12px;color:#bbb;padding:16px;border-top:1px solid #eee;margin-top:8px;"
} }
} }
] ]

View File

@ -1,24 +1,63 @@
from appPublic.uniqueID import getID # 热点统计:全维度
from datetime import datetime, timedelta
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
async def main(request): async def main(request):
db = DBPools() db = DBPools()
now = datetime.now()
day_ago = (now - timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S')
async with db.sqlorContext('sage') as sor: async with db.sqlorContext('sage') as sor:
items = await sor.R('hotspot_item', {'order': 'fetch_time desc'}) items = await sor.R('hotspot_item', {'order': 'heat_score desc'})
sources = await sor.R('hotspot_source', {}) sources = await sor.R('hotspot_source', {})
logs = await sor.sqlExe(
total = len(items) "select * from hotspot_fetch_log where start_time >= ${t}$",
rising = sum(1 for i in items if i.get('status') == 'rising') {'t': day_ago}
hot = sum(1 for i in items if i.get('status') == 'hot') )
cooling = sum(1 for i in items if i.get('status') == 'cooling')
expired = sum(1 for i in items if i.get('status') == 'expired') status_count = {'emerging': 0, 'rising': 0, 'hot': 0, 'cooling': 0, 'expired': 0}
sources_count = len([s for s in sources if s.get('enabled') == '1']) for item in items:
s = item.get('status', 'emerging')
status_count[s] = status_count.get(s, 0) + 1
sources_active = sum(1 for s in sources if s.get('enabled') == '1')
failures = sum(1 for log in logs if log.get('status') == 'failed')
# Top sources by item count
src_count = {}
for item in items:
sid = item.get('source_id', '')
src_count[sid] = src_count.get(sid, 0) + 1
top_src = sorted(src_count.items(), key=lambda x: x[1], reverse=True)[:5]
src_names = {s['id']: s.get('name', s['id']) for s in sources}
# Avg heat by category
cat_heat = {}
for item in items:
cat = item.get('category', '未分类') or '未分类'
h = float(item.get('heat_score', 0))
if cat not in cat_heat:
cat_heat[cat] = {'sum': 0, 'cnt': 0}
cat_heat[cat]['sum'] += h
cat_heat[cat]['cnt'] += 1
top_categories = sorted(
[{'name': k, 'avg': round(v['sum']/v['cnt'], 1), 'cnt': v['cnt']}
for k, v in cat_heat.items()],
key=lambda x: x['cnt'], reverse=True
)[:10]
return { return {
'total': total, 'total': len(items),
'rising': rising, 'emerging': status_count['emerging'],
'hot': hot, 'rising': status_count['rising'],
'cooling': cooling, 'hot': status_count['hot'],
'expired': expired, 'cooling': status_count['cooling'],
'sources': sources_count 'expired': status_count['expired'],
'sources': sources_active,
'failures': failures,
'top_sources': [{'name': src_names.get(k, k), 'count': v} for k, v in top_src],
'top_categories': top_categories,
'log_count_24h': len(logs),
} }