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:
parent
768544511c
commit
64cc51cb6e
16
json/hotspot_alert.json
Normal file
16
json/hotspot_alert.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,9 @@
|
||||
"sortby": "analyzed_at desc",
|
||||
"browserfields": {
|
||||
"exclouded": ["id"],
|
||||
"cwidth": {}
|
||||
"cwidth": {"analysis_data": 400}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id",
|
||||
"analyzed_at"
|
||||
],
|
||||
"editexclouded": ["id", "analyzed_at"],
|
||||
"record_toolbar": null
|
||||
}
|
||||
}
|
||||
|
||||
16
json/hotspot_fetch_log.json
Normal file
16
json/hotspot_fetch_log.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -8,15 +8,9 @@
|
||||
"sortby": "heat_score desc",
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "content"],
|
||||
"cwidth": {
|
||||
"title": 300,
|
||||
"summary": 400
|
||||
}
|
||||
"cwidth": {"title": 300, "summary": 400, "url": 200}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id",
|
||||
"fetch_time"
|
||||
],
|
||||
"editexclouded": ["id", "first_seen", "last_updated"],
|
||||
"record_toolbar": null
|
||||
}
|
||||
}
|
||||
|
||||
16
json/hotspot_schedule.json
Normal file
16
json/hotspot_schedule.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -5,16 +5,12 @@
|
||||
"tblname": "hotspot_source",
|
||||
"title": "热点来源",
|
||||
"params": {
|
||||
"sortby": "name",
|
||||
"sortby": "priority asc",
|
||||
"browserfields": {
|
||||
"exclouded": ["id"],
|
||||
"cwidth": {}
|
||||
"exclouded": ["id", "api_secret", "headers", "request_body", "crawler_rules"],
|
||||
"cwidth": {"name": 150, "url": 300}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id",
|
||||
"last_fetch",
|
||||
"created_at"
|
||||
],
|
||||
"editexclouded": ["id", "created_at", "last_fetch"],
|
||||
"record_toolbar": null
|
||||
}
|
||||
}
|
||||
|
||||
18
load_path.py
18
load_path.py
@ -1508,6 +1508,24 @@ paths="""/ any
|
||||
/hotspot_analysis/add_hotspot_analysis.dspy logined
|
||||
/hotspot_analysis/update_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
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -6578,28 +6578,87 @@ default charset=utf8
|
||||
comment '模型供应商'
|
||||
;
|
||||
|
||||
-- ./hotspot_source
|
||||
-- ============================================
|
||||
-- 热点雷达系统 - 6 tables
|
||||
-- ============================================
|
||||
|
||||
-- ./hotspot_source (来源配置: API / Browser / Crawler)
|
||||
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 '请求方法',
|
||||
`type` VARCHAR(20) comment '类型: rss/api/browser/crawler',
|
||||
`url` VARCHAR(2000) comment '目标URL',
|
||||
-- API 类型专用
|
||||
`api_key` VARCHAR(500) comment 'API Key',
|
||||
`api_secret` VARCHAR(500) comment 'API Secret(加密)',
|
||||
`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 '是否启用',
|
||||
`fetch_interval_min` INT DEFAULT 60 comment '抓取间隔(分钟)',
|
||||
`priority` INT DEFAULT 0 comment '优先级(越大越高)',
|
||||
`last_fetch` VARCHAR(50) comment '上次抓取时间',
|
||||
`created_at` VARCHAR(50) comment '创建时间'
|
||||
,primary key(id)
|
||||
)
|
||||
engine=innodb
|
||||
default charset=utf8
|
||||
comment '热点来源'
|
||||
engine=innodb 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;
|
||||
CREATE TABLE hotspot_item
|
||||
(
|
||||
@ -6609,39 +6668,60 @@ CREATE TABLE hotspot_item
|
||||
`url` VARCHAR(2000) comment '原文链接',
|
||||
`summary` 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 '发布时间',
|
||||
`fetch_time` VARCHAR(50) comment '抓取时间',
|
||||
`status` VARCHAR(20) DEFAULT 'rising' comment '状态: rising/hot/cooling/expired',
|
||||
`first_seen` VARCHAR(50) comment '首次发现',
|
||||
`last_updated` VARCHAR(50) comment '最后更新',
|
||||
`status` VARCHAR(20) DEFAULT 'emerging' comment '状态: emerging/rising/hot/cooling/expired',
|
||||
`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)
|
||||
)
|
||||
engine=innodb
|
||||
default charset=utf8
|
||||
comment '热点条目'
|
||||
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);
|
||||
CREATE INDEX hotspot_item_score_idx ON hotspot_item(heat_score);
|
||||
|
||||
-- ./hotspot_analysis
|
||||
-- ./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 '维度得分',
|
||||
`dimension` VARCHAR(50) comment '维度: time/heat/content/propagation/audience',
|
||||
`score` DOUBLE(10,2) DEFAULT 0 comment '维度得分(0-100)',
|
||||
`analysis_data` TEXT comment '分析数据JSON',
|
||||
`analyzed_at` VARCHAR(50) comment '分析时间'
|
||||
,primary key(id)
|
||||
)
|
||||
engine=innodb
|
||||
default charset=utf8
|
||||
comment '热点分析'
|
||||
engine=innodb default charset=utf8 comment '五维分析'
|
||||
;
|
||||
|
||||
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 '预警规则'
|
||||
;
|
||||
|
||||
|
||||
|
||||
@ -1,55 +1,205 @@
|
||||
# 自动分析热点状态:根据时间和热度自动分类
|
||||
# rising -> hot -> cooling -> expired
|
||||
# 五维 + 状态自动分析
|
||||
# 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()
|
||||
analyzed = 0
|
||||
results = {'analyzed': 0, 'status_changes': 0, 'dimensions': {}}
|
||||
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
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:
|
||||
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:
|
||||
fetch_time = now
|
||||
first_seen = now
|
||||
|
||||
hours_ago = (now - fetch_time).total_seconds() / 3600
|
||||
heat = int(item.get('heat_score', 0))
|
||||
current_status = item.get('status', 'rising')
|
||||
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']
|
||||
|
||||
# 热度衰减计算
|
||||
decay_factor = max(0, 1 - hours_ago / 168) # 7天衰减到0
|
||||
adjusted_heat = heat * decay_factor
|
||||
# === 五维分析 ===
|
||||
dimensions = {}
|
||||
|
||||
# 状态分类
|
||||
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'
|
||||
# 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:
|
||||
new_status = 'cooling' if hours_ago > 24 else 'rising'
|
||||
dim_time = 10
|
||||
dimensions['time'] = dim_time
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
return {
|
||||
'analyzed': analyzed,
|
||||
'time': now.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
}
|
||||
# 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
|
||||
|
||||
@ -1,165 +1,419 @@
|
||||
# 从所有启用的来源抓取热点数据
|
||||
import json
|
||||
# 抓取引擎 — 支持 API / Browser / Crawler 三种模式
|
||||
import json, time, hashlib
|
||||
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"""
|
||||
def now_str():
|
||||
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
async def fetch_rss(session, url):
|
||||
"""RSS/Atom 解析"""
|
||||
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()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
if resp.status != 200:
|
||||
return items, resp.status, 0
|
||||
text = await resp.text()
|
||||
size = len(text.encode())
|
||||
|
||||
root = ET.fromstring(text)
|
||||
ns = {'atom': 'http://www.w3.org/2005/Atom'}
|
||||
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,
|
||||
'title': item.findtext('title', ''),
|
||||
'url': item.findtext('link', ''),
|
||||
'summary': (item.findtext('description', '') or '')[:500],
|
||||
'publish_time': item.findtext('pubDate', ''),
|
||||
})
|
||||
|
||||
# Atom
|
||||
# Atom fallback
|
||||
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 ''
|
||||
for entry in root.findall(f'.//{{{ns_atom}}}entry'):
|
||||
link_el = entry.find(f'{{{ns_atom}}}link')
|
||||
items.append({
|
||||
'title': title,
|
||||
'url': link,
|
||||
'summary': summary[:500],
|
||||
'publish_time': updated,
|
||||
'title': entry.findtext(f'{{{ns_atom}}}title', ''),
|
||||
'url': link_el.get('href', '') if link_el is not None else '',
|
||||
'summary': (entry.findtext(f'{{{ns_atom}}}summary', '') or '')[:500],
|
||||
'publish_time': entry.findtext(f'{{{ns_atom}}}updated', '') or '',
|
||||
})
|
||||
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 端点获取热点数据"""
|
||||
return items, resp.status, size
|
||||
except Exception as e:
|
||||
return [], 0, 0
|
||||
|
||||
async def fetch_api(session, src):
|
||||
"""API 模式:支持 GET/POST + Headers + Auth"""
|
||||
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 = []
|
||||
try:
|
||||
hdrs = {}
|
||||
if headers:
|
||||
try:
|
||||
hdrs = json.loads(headers)
|
||||
except:
|
||||
pass
|
||||
if method == 'POST':
|
||||
async with session.post(url, headers=headers, data=body or '', 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:
|
||||
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:
|
||||
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
|
||||
# Extract items from JSON response
|
||||
if isinstance(data, list):
|
||||
entries = data
|
||||
elif isinstance(data, dict):
|
||||
# 常见热点 API 格式
|
||||
entries = data.get('data', data.get('items', data.get('list', [])))
|
||||
entries = data.get('data', data.get('items', data.get('list', data.get('result', []))))
|
||||
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,
|
||||
})
|
||||
for entry in entries[:100]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
items.append({
|
||||
'title': str(entry.get('title', entry.get('name', ''))),
|
||||
'url': str(entry.get('url', entry.get('link', ''))),
|
||||
'summary': str(entry.get('summary', entry.get('description', entry.get('desc', ''))))[:500],
|
||||
'heat_score': float(entry.get('heat', entry.get('hot', entry.get('score', 0))) or 0),
|
||||
'category': str(entry.get('category', entry.get('type', ''))),
|
||||
'tags': str(entry.get('tags', '')),
|
||||
'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:
|
||||
print(f'API fetch error for {url}: {e}')
|
||||
return items
|
||||
return [], 0, 0
|
||||
|
||||
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):
|
||||
db = DBPools()
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
new_items = 0
|
||||
total_sources = 0
|
||||
now = now_str()
|
||||
results = {'total': 0, 'new_items': 0, 'errors': 0, 'details': []}
|
||||
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
sources = await sor.R('hotspot_source', {})
|
||||
|
||||
for src in sources:
|
||||
if src.get('enabled') != '1':
|
||||
continue
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for src in sources:
|
||||
if src.get('enabled') != '1':
|
||||
continue
|
||||
|
||||
total_sources += 1
|
||||
src_type = src.get('type', 'rss')
|
||||
src_url = src.get('url', '')
|
||||
results['total'] += 1
|
||||
src_id = src['id']
|
||||
src_type = src.get('type', 'rss')
|
||||
t0 = time.time()
|
||||
|
||||
if not src_url:
|
||||
continue
|
||||
# 执行抓取
|
||||
try:
|
||||
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
|
||||
|
||||
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
|
||||
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,
|
||||
# 存入数据库
|
||||
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 = item.get('url', '')
|
||||
if url:
|
||||
existing = await sor.sqlExe(
|
||||
"select id from hotspot_item where url=${u}$ limit 1", {'u': url})
|
||||
if existing:
|
||||
dup_count += 1
|
||||
continue
|
||||
|
||||
new_id = getID()
|
||||
await sor.C('hotspot_item', {
|
||||
'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,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
return results
|
||||
|
||||
@ -3,20 +3,6 @@
|
||||
"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": {
|
||||
@ -24,12 +10,12 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"id": "stat_total",
|
||||
"id": "stat_emerging",
|
||||
"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;"}}
|
||||
{"widgettype": "Text", "options": {"text": "潜在热点", "style": "color:#666;font-size:12px;"}},
|
||||
{"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",
|
||||
"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;"}}
|
||||
{"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",
|
||||
"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;"}}
|
||||
{"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",
|
||||
"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;"}}
|
||||
{"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",
|
||||
"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;"}}
|
||||
{"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": {
|
||||
"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;"}}
|
||||
{"widgettype": "Text", "options": {"text": "活跃来源", "style": "color:#666;font-size:12px;"}},
|
||||
{"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": {
|
||||
"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;"
|
||||
"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: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": {
|
||||
"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;"
|
||||
"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:6px;"
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -119,31 +125,46 @@
|
||||
"widgettype": "Tab",
|
||||
"options": {
|
||||
"tabs": [
|
||||
{
|
||||
"title": "热点条目",
|
||||
"content": {
|
||||
"widgettype": "urlwidget",
|
||||
"options": {
|
||||
"url": "{{ entire_url('hotspot_item') }}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "来源管理",
|
||||
"content": {
|
||||
"widgettype": "urlwidget",
|
||||
"options": {
|
||||
"url": "{{ entire_url('hotspot_source') }}"
|
||||
}
|
||||
"options": {"url": "{{ entire_url('hotspot_source') }}"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "分析记录",
|
||||
"title": "调度配置",
|
||||
"content": {
|
||||
"widgettype": "urlwidget",
|
||||
"options": {
|
||||
"url": "{{ entire_url('hotspot_analysis') }}"
|
||||
}
|
||||
"options": {"url": "{{ entire_url('hotspot_schedule') }}"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"options": {
|
||||
"text": "提示:在「来源管理」中添加 RSS/API 来源后,点击「立即抓取」获取热点数据。",
|
||||
"style": "font-size:12px;color:#bbb;padding:16px;"
|
||||
"text": "工作流: ① 添加来源(type=api/browser/crawler) → ② 配置调度(cron/interval) → ③ 查看抓取日志(成功率/错误) → ④ 浏览热点(emerging/rising/hot/cooling/expired) → ⑤ 五维分析 → ⑥ 设置预警",
|
||||
"style": "font-size:12px;color:#bbb;padding:16px;border-top:1px solid #eee;margin-top:8px;"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,24 +1,63 @@
|
||||
from appPublic.uniqueID import getID
|
||||
# 热点统计:全维度
|
||||
from datetime import datetime, timedelta
|
||||
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', {})
|
||||
now = datetime.now()
|
||||
day_ago = (now - timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
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'])
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
items = await sor.R('hotspot_item', {'order': 'heat_score desc'})
|
||||
sources = await sor.R('hotspot_source', {})
|
||||
logs = await sor.sqlExe(
|
||||
"select * from hotspot_fetch_log where start_time >= ${t}$",
|
||||
{'t': day_ago}
|
||||
)
|
||||
|
||||
status_count = {'emerging': 0, 'rising': 0, 'hot': 0, 'cooling': 0, 'expired': 0}
|
||||
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 {
|
||||
'total': total,
|
||||
'rising': rising,
|
||||
'hot': hot,
|
||||
'cooling': cooling,
|
||||
'expired': expired,
|
||||
'sources': sources_count
|
||||
'total': len(items),
|
||||
'emerging': status_count['emerging'],
|
||||
'rising': status_count['rising'],
|
||||
'hot': status_count['hot'],
|
||||
'cooling': status_count['cooling'],
|
||||
'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),
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user