feat: add hotspot radar module (热点雷达系统)

- Add hotspot_source/hotspot_item/hotspot_analysis table definitions
- Add MySQL DDL for all three tables with indexes
- Create hotspot dashboard UI (index.ui) with stats, tabs for CRUD
- Add DSPY endpoints: stats, fetch_now (RSS/API), analyze (auto-classify)
- Register paths in load_path.py with logined access
- Add '热点雷达' menu entry
This commit is contained in:
yumoqing 2026-08-01 10:39:52 +08:00
parent a229e4084b
commit 71ecb8fea1
10 changed files with 561 additions and 1 deletions

View File

@ -0,0 +1,19 @@
{
"models_dir": "${HOME}$/py/sage/models",
"output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_analysis",
"dbname": "sage",
"tblname": "hotspot_analysis",
"title": "热点分析",
"params": {
"sortby": "analyzed_at desc",
"browserfields": {
"exclouded": ["id"],
"cwidth": {}
},
"editexclouded": [
"id",
"analyzed_at"
],
"record_toolbar": null
}
}

22
json/hotspot_item.json Normal file
View File

@ -0,0 +1,22 @@
{
"models_dir": "${HOME}$/py/sage/models",
"output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_item",
"dbname": "sage",
"tblname": "hotspot_item",
"title": "热点条目",
"params": {
"sortby": "heat_score desc",
"browserfields": {
"exclouded": ["id", "content"],
"cwidth": {
"title": 300,
"summary": 400
}
},
"editexclouded": [
"id",
"fetch_time"
],
"record_toolbar": null
}
}

20
json/hotspot_source.json Normal file
View File

@ -0,0 +1,20 @@
{
"models_dir": "${HOME}$/py/sage/models",
"output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_source",
"dbname": "sage",
"tblname": "hotspot_source",
"title": "热点来源",
"params": {
"sortby": "name",
"browserfields": {
"exclouded": ["id"],
"cwidth": {}
},
"editexclouded": [
"id",
"last_fetch",
"created_at"
],
"record_toolbar": null
}
}

View File

@ -1079,6 +1079,29 @@ paths="""/ any
/unipay/imgs/payment_log.svg any
/unipay/usermenu.ui logined
/unipay/refund.ui customer.*
/hotspot logined
/hotspot/index.ui logined
/hotspot/stats.dspy logined
/hotspot/fetch_now.dspy logined
/hotspot/analyze.dspy logined
/hotspot_source logined
/hotspot_source/index.ui logined
/hotspot_source/get_hotspot_source.dspy logined
/hotspot_source/add_hotspot_source.dspy logined
/hotspot_source/update_hotspot_source.dspy logined
/hotspot_source/delete_hotspot_source.dspy logined
/hotspot_item logined
/hotspot_item/index.ui logined
/hotspot_item/get_hotspot_item.dspy logined
/hotspot_item/add_hotspot_item.dspy logined
/hotspot_item/update_hotspot_item.dspy logined
/hotspot_item/delete_hotspot_item.dspy logined
/hotspot_analysis logined
/hotspot_analysis/index.ui logined
/hotspot_analysis/get_hotspot_analysis.dspy logined
/hotspot_analysis/add_hotspot_analysis.dspy logined
/hotspot_analysis/update_hotspot_analysis.dspy logined
/hotspot_analysis/delete_hotspot_analysis.dspy logined
"""

View File

@ -6578,4 +6578,70 @@ default charset=utf8
comment '模型供应商'
;
-- ./hotspot_source
drop table if exists hotspot_source;
CREATE TABLE hotspot_source
(
`id` VARCHAR(32) comment 'id',
`name` VARCHAR(200) comment '来源名称',
`type` VARCHAR(20) comment '类型: rss/api/web',
`url` VARCHAR(2000) comment '来源URL',
`fetch_method` VARCHAR(20) DEFAULT 'GET' comment '请求方法',
`headers` TEXT comment '自定义请求头 JSON',
`enabled` VARCHAR(1) DEFAULT '1' comment '是否启用',
`fetch_interval_min` INT DEFAULT 60 comment '抓取间隔(分钟)',
`last_fetch` VARCHAR(50) comment '上次抓取时间',
`created_at` VARCHAR(50) comment '创建时间'
,primary key(id)
)
engine=innodb
default charset=utf8
comment '热点来源'
;
-- ./hotspot_item
drop table if exists hotspot_item;
CREATE TABLE hotspot_item
(
`id` VARCHAR(32) comment 'id',
`source_id` VARCHAR(32) comment '来源ID',
`title` VARCHAR(500) comment '标题',
`url` VARCHAR(2000) comment '原文链接',
`summary` TEXT comment '摘要',
`content` TEXT comment '正文',
`heat_score` INT DEFAULT 0 comment '热度分数',
`publish_time` VARCHAR(50) comment '发布时间',
`fetch_time` VARCHAR(50) comment '抓取时间',
`status` VARCHAR(20) DEFAULT 'rising' comment '状态: rising/hot/cooling/expired',
`category` VARCHAR(100) comment '分类',
`tags` VARCHAR(500) comment '标签逗号分隔'
,primary key(id)
)
engine=innodb
default charset=utf8
comment '热点条目'
;
CREATE INDEX hotspot_item_source_idx ON hotspot_item(source_id);
CREATE INDEX hotspot_item_status_idx ON hotspot_item(status);
-- ./hotspot_analysis
drop table if exists hotspot_analysis;
CREATE TABLE hotspot_analysis
(
`id` VARCHAR(32) comment 'id',
`item_id` VARCHAR(32) comment '关联热点ID',
`dimension` VARCHAR(50) comment '分析维度',
`score` DOUBLE(10,2) DEFAULT 0 comment '维度得分',
`analysis_data` TEXT comment '分析数据JSON',
`analyzed_at` VARCHAR(50) comment '分析时间'
,primary key(id)
)
engine=innodb
default charset=utf8
comment '热点分析'
;
CREATE INDEX hotspot_analysis_item_idx ON hotspot_analysis(item_id);

View File

@ -0,0 +1,55 @@
# 自动分析热点状态:根据时间和热度自动分类
# rising -> hot -> cooling -> expired
from datetime import datetime, timedelta
from sqlor.dbpools import DBPools
async def main(request):
db = DBPools()
now = datetime.now()
analyzed = 0
async with db.sqlorContext('sage') as sor:
items = await sor.sqlExe(
"select * from hotspot_item order by fetch_time desc",
{}
)
for item in items:
try:
fetch_time = datetime.strptime(str(item.get('fetch_time', '')), '%Y-%m-%d %H:%M:%S')
except:
fetch_time = now
hours_ago = (now - fetch_time).total_seconds() / 3600
heat = int(item.get('heat_score', 0))
current_status = item.get('status', 'rising')
# 热度衰减计算
decay_factor = max(0, 1 - hours_ago / 168) # 7天衰减到0
adjusted_heat = heat * decay_factor
# 状态分类
if hours_ago > 168: # 超过7天
new_status = 'expired'
elif hours_ago > 72: # 超过3天
new_status = 'cooling'
elif adjusted_heat > 500:
new_status = 'hot'
elif adjusted_heat > 100:
new_status = 'rising'
else:
new_status = 'cooling' if hours_ago > 24 else 'rising'
if new_status != current_status:
async with db.sqlorContext('sage') as sor:
await sor.U('hotspot_item', {
'id': item['id'],
'status': new_status,
'heat_score': int(adjusted_heat),
})
analyzed += 1
return {
'analyzed': analyzed,
'time': now.strftime('%Y-%m-%d %H:%M:%S'),
}

View File

@ -0,0 +1,165 @@
# 从所有启用的来源抓取热点数据
import json
import xml.etree.ElementTree as ET
from datetime import datetime
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
async def fetch_rss(url):
"""解析 RSS/Atom feed"""
import aiohttp
items = []
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
return items
text = await resp.text()
root = ET.fromstring(text)
ns = {'atom': 'http://www.w3.org/2005/Atom'}
# RSS 2.0
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({
'title': title,
'url': link,
'summary': desc,
'publish_time': pub_date,
})
# Atom
if not items:
for entry in root.findall('.//atom:entry', ns):
title = entry.findtext('atom:title', '', ns)
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({
'title': title,
'url': link,
'summary': summary[:500],
'publish_time': updated,
})
except Exception as e:
print(f'RSS fetch error for {url}: {e}')
return items
async def fetch_api(url, method='GET', headers=None, json_path=None):
"""调用 API 端点获取热点数据"""
import aiohttp
items = []
try:
hdrs = {}
if headers:
try:
hdrs = json.loads(headers)
except:
pass
async with aiohttp.ClientSession() as session:
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):
entries = data
elif isinstance(data, dict):
# 常见热点 API 格式
entries = data.get('data', data.get('items', data.get('list', [])))
else:
entries = []
for entry in entries[:50]: # limit
if isinstance(entry, dict):
title = entry.get('title', entry.get('name', ''))
url_val = entry.get('url', entry.get('link', ''))
summary = entry.get('summary', entry.get('description', entry.get('desc', '')))
heat = entry.get('heat', entry.get('hot', entry.get('score', 0)))
items.append({
'title': str(title),
'url': str(url_val),
'summary': str(summary)[:500],
'heat_score': int(heat) if heat else 0,
})
except Exception as e:
print(f'API fetch error for {url}: {e}')
return items
async def main(request):
db = DBPools()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
new_items = 0
total_sources = 0
async with db.sqlorContext('sage') as sor:
sources = await sor.R('hotspot_source', {})
for src in sources:
if src.get('enabled') != '1':
continue
total_sources += 1
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:
# 去重:检查 URL 是否已存在
existing = await sor.sqlExe(
"select id from hotspot_item where url=${url}$",
{'url': item.get('url', '')}
)
if existing:
continue
new_id = getID()
status = 'rising' # 新抓取的默认 rising
await sor.C('hotspot_item', {
'id': new_id,
'source_id': src['id'],
'title': item.get('title', ''),
'url': item.get('url', ''),
'summary': item.get('summary', '')[:500],
'heat_score': item.get('heat_score', 100),
'publish_time': item.get('publish_time', now),
'fetch_time': now,
'status': status,
})
new_items += 1
return {
'total': total_sources,
'new_items': new_items,
'time': now,
}

161
wwwroot/hotspot/index.ui Normal file
View File

@ -0,0 +1,161 @@
{% set roles = get_user_roles(get_user()) %}
{
"widgettype": "VBox",
"options": {
"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",
"options": {
"children": [
{
"widgettype": "VBox",
"options": {
"id": "stat_total",
"children": [
{"widgettype": "Text", "options": {"text": "热点总数", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#1890ff;"}}
],
"style": "padding:16px;background:#f0f5ff;border-radius:8px;margin:8px;min-width:100px;text-align:center;"
}
},
{
"widgettype": "VBox",
"options": {
"id": "stat_rising",
"children": [
{"widgettype": "Text", "options": {"text": "上升中", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#52c41a;"}}
],
"style": "padding:16px;background:#f6ffed;border-radius:8px;margin:8px;min-width:100px;text-align:center;"
}
},
{
"widgettype": "VBox",
"options": {
"id": "stat_hot",
"children": [
{"widgettype": "Text", "options": {"text": "正热点", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#ff4d4f;"}}
],
"style": "padding:16px;background:#fff2f0;border-radius:8px;margin:8px;min-width:100px;text-align:center;"
}
},
{
"widgettype": "VBox",
"options": {
"id": "stat_cooling",
"children": [
{"widgettype": "Text", "options": {"text": "降温中", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#faad14;"}}
],
"style": "padding:16px;background:#fffbe6;border-radius:8px;margin:8px;min-width:100px;text-align:center;"
}
},
{
"widgettype": "VBox",
"options": {
"id": "stat_expired",
"children": [
{"widgettype": "Text", "options": {"text": "已过期", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#999;"}}
],
"style": "padding:16px;background:#f5f5f5;border-radius:8px;margin:8px;min-width:100px;text-align:center;"
}
},
{
"widgettype": "VBox",
"options": {
"id": "stat_sources",
"children": [
{"widgettype": "Text", "options": {"text": "数据来源", "style": "color:#666;font-size:12px;"}},
{"widgettype": "Text", "options": {"text": "0", "style": "font-size:28px;font-weight:bold;color:#722ed1;"}}
],
"style": "padding:16px;background:#f9f0ff;border-radius:8px;margin:8px;min-width:100px;text-align:center;"
}
}
]
}
},
{
"widgettype": "HBox",
"options": {
"children": [
{
"widgettype": "Button",
"options": {
"label": "立即抓取全部来源",
"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))",
"style": "margin:8px;"
}
},
{
"widgettype": "Button",
"options": {
"label": "刷新统计",
"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))",
"style": "margin:8px;"
}
}
]
}
},
{
"widgettype": "Tab",
"options": {
"tabs": [
{
"title": "热点条目",
"content": {
"widgettype": "urlwidget",
"options": {
"url": "{{ entire_url('hotspot_item') }}"
}
}
},
{
"title": "来源管理",
"content": {
"widgettype": "urlwidget",
"options": {
"url": "{{ entire_url('hotspot_source') }}"
}
}
},
{
"title": "分析记录",
"content": {
"widgettype": "urlwidget",
"options": {
"url": "{{ entire_url('hotspot_analysis') }}"
}
}
}
]
}
},
{
"widgettype": "Text",
"options": {
"text": "提示:在「来源管理」中添加 RSS/API 来源后,点击「立即抓取」获取热点数据。",
"style": "font-size:12px;color:#bbb;padding:16px;"
}
}
]
}
}

View File

@ -0,0 +1,24 @@
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
async def main(request):
db = DBPools()
async with db.sqlorContext('sage') as sor:
items = await sor.R('hotspot_item', {'order': 'fetch_time desc'})
sources = await sor.R('hotspot_source', {})
total = len(items)
rising = sum(1 for i in items if i.get('status') == 'rising')
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')
sources_count = len([s for s in sources if s.get('enabled') == '1'])
return {
'total': total,
'rising': rising,
'hot': hot,
'cooling': cooling,
'expired': expired,
'sources': sources_count
}

View File

@ -172,10 +172,15 @@
}
{% endif %}
,{
"name": "hotspot",
"label": "热点雷达",
"url": "{{entire_url('hotspot')}}"
},
{
"name": "skillmgr",
"label": "技能管理",
"submenu": "{{entire_url('/skillagent/menu.ui')}}"
}
}
{% if 'reseller.sale' in roles %}
,{
"name": "reseller",