diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000..9cf22fdb
Binary files /dev/null and b/.DS_Store differ
diff --git a/hotspot/__init__.py b/hotspot/__init__.py
deleted file mode 100644
index 8a0dfa42..00000000
--- a/hotspot/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""
-热点雷达 (Hotspot Radar) — Sage 框架模块
-
-用法:
- from hotspot.engine import run_fetch
- from hotspot.analysis import run_analysis
- from hotspot.db import get_stats
-"""
-from .engine import run_fetch
-from .analysis import run_analysis
-from .db import get_stats
-from .parsers import BUILTIN as builtin_parsers
-
-__all__ = ['run_fetch', 'run_analysis', 'get_stats', 'builtin_parsers']
diff --git a/hotspot/analysis.py b/hotspot/analysis.py
deleted file mode 100644
index aecae172..00000000
--- a/hotspot/analysis.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""
-五维分析引擎 + 自动状态分类
-
-维度: time(时效) / heat(热度) / content(内容) / propagation(传播) / audience(受众)
-状态: emerging → rising → hot → cooling → expired
-"""
-import json, math
-from datetime import datetime, timedelta
-from appPublic.uniqueID import getID
-from sqlor.dbpools import DBPools
-
-def now_str():
- return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
-DIM_NAMES = {
- 'time': '时效维度',
- 'heat': '热度指标',
- 'content': '内容属性',
- 'propagation': '传播路径',
- 'audience': '受众画像',
-}
-
-async def analyze_item(item):
- """分析单条热点,返回 (new_status, dimensions_dict, detail)"""
- now = datetime.now()
- try:
- first_seen = datetime.strptime(str(item.get('first_seen', '')), '%Y-%m-%d %H:%M:%S')
- except Exception:
- first_seen = now
-
- hours_alive = max(0.1, (now - first_seen).total_seconds() / 3600)
- heat = float(item.get('heat_score', 0))
- engagement = int(item.get('engagement_count', 0))
- comments = int(item.get('comment_count', 0))
- shares = int(item.get('share_count', 0))
-
- # ---- 五维评分 (0-100) ----
- dims = {}
-
- # 时效: 越新越高
- dims['time'] = 95 if hours_alive < 1 else 85 if hours_alive < 6 else \
- 70 if hours_alive < 24 else 50 if hours_alive < 72 else \
- 30 if hours_alive < 168 else 10
-
- # 热度: 绝对值
- dims['heat'] = 95 if heat > 1e4 else 85 if heat > 5e3 else \
- 70 if heat > 1e3 else 55 if heat > 500 else \
- 35 if heat > 100 else 15
-
- # 内容: 标题长度 + 摘要丰富度 + 标签
- title_len = len(item.get('title', ''))
- summary_len = len(item.get('summary', ''))
- dims['content'] = min(100, (20 if title_len > 15 else 10) +
- (30 if summary_len > 100 else 15) +
- (25 if item.get('tags') else 0) +
- (25 if item.get('category') else 0))
-
- # 传播: 互动总量
- total_eng = engagement + comments * 2 + shares * 3
- dims['propagation'] = 95 if total_eng > 1e4 else 80 if total_eng > 5e3 else \
- 60 if total_eng > 1e3 else 35 if total_eng > 100 else 10
-
- # 受众: 互动率
- rate = total_eng / heat if heat > 0 else 0
- dims['audience'] = 90 if rate > 0.5 else 70 if rate > 0.2 else \
- 45 if rate > 0.05 else 25 if total_eng > 0 else 5
-
- # ---- 状态分类 ----
- decay = math.exp(-hours_alive / 168) # 7天半衰期
- adjusted_heat = heat * decay
- velocity = round(heat / hours_alive, 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 velocity > 50 else 'emerging'
- elif adjusted_heat > 100:
- new_status = 'rising' if velocity > 100 else 'emerging'
- else:
- new_status = 'emerging'
-
- detail = {
- 'hours_alive': round(hours_alive, 1),
- 'adjusted_heat': round(adjusted_heat, 2),
- 'heat_velocity': velocity,
- 'total_engagement': total_eng,
- 'decay_factor': round(decay, 4),
- }
-
- return new_status, dims, detail
-
-
-async def save_analysis(item_id, dims, detail, now=None):
- """保存/更新五维分析记录"""
- if now is None:
- now = now_str()
- db = DBPools()
-
- async with db.sqlorContext('sage') as sor:
- for dim, score in dims.items():
- analysis_data = json.dumps({
- 'dimension': dim,
- 'dimension_cn': DIM_NAMES.get(dim, dim),
- 'score': score,
- 'detail': detail,
- }, ensure_ascii=False)
-
- 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,
- })
- else:
- await sor.C('hotspot_analysis', {
- 'id': getID(), 'item_id': item_id,
- 'dimension': dim, 'score': score,
- 'analysis_data': analysis_data, 'analyzed_at': now,
- })
-
-
-async def run_analysis(limit=500):
- """批量分析最近的热点 (默认500条)"""
- db = DBPools()
- now = now_str()
- results = {'analyzed': 0, 'status_changes': 0}
-
- async with db.sqlorContext('sage') as sor:
- items = await sor.sqlExe(
- "select * from hotspot_item order by first_seen desc limit ${n}$",
- {'n': limit})
-
- for item in items:
- current_status = item.get('status', 'emerging')
- new_status, dims, detail = await analyze_item(item)
-
- await save_analysis(item['id'], dims, detail, now)
-
- if new_status != current_status:
- results['status_changes'] += 1
-
- async with db.sqlorContext('sage') as sor:
- await sor.U('hotspot_item', {
- 'id': item['id'],
- 'heat_score': round(detail['adjusted_heat'], 2),
- 'heat_velocity': detail['heat_velocity'],
- 'status': new_status,
- 'last_updated': now,
- })
-
- results['analyzed'] += 1
-
- return results
diff --git a/hotspot/db.py b/hotspot/db.py
deleted file mode 100644
index 24fd6355..00000000
--- a/hotspot/db.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-数据库操作: 保存热点条目、写抓取日志、统计查询
-"""
-from datetime import datetime
-from appPublic.uniqueID import getID
-from sqlor.dbpools import DBPools
-
-def now_str():
- return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
-async def get_enabled_sources():
- """获取所有启用的来源"""
- db = DBPools()
- async with db.sqlorContext('sage') as sor:
- sources = await sor.R('hotspot_source', {})
- return [s for s in sources if s.get('enabled') == '1']
-
-async def save_items(items, source_id, fetch_time=None):
- """批量保存热点条目 (去重)"""
- if fetch_time is None:
- fetch_time = now_str()
- new_count = 0
- dup_count = 0
- db = DBPools()
-
- async with db.sqlorContext('sage') as sor:
- 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
-
- await sor.C('hotspot_item', {
- 'id': getID(), 'source_id': source_id,
- 'title': item.get('title', '')[:500],
- 'url': (url or '')[:2000],
- 'summary': item.get('summary', '')[:1000],
- 'heat_score': item.get('heat_score', 100),
- 'heat_velocity': 0,
- 'publish_time': fetch_time,
- 'first_seen': fetch_time,
- 'last_updated': fetch_time,
- '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
-
- return new_count, dup_count
-
-async def update_source_last_fetch(source_id):
- db = DBPools()
- async with db.sqlorContext('sage') as sor:
- await sor.U('hotspot_source', {'id': source_id, 'last_fetch': now_str()})
-
-async def write_fetch_log(source_id, start_time, elapsed_ms,
- status, items_total, items_new, items_duplicate,
- error_msg='', response_code=0, response_size_bytes=0):
- db = DBPools()
- async with db.sqlorContext('sage') as sor:
- await sor.C('hotspot_fetch_log', {
- 'id': getID(),
- 'source_id': source_id,
- 'start_time': start_time,
- 'end_time': now_str(),
- 'duration_ms': elapsed_ms,
- 'status': status,
- 'items_total': items_total,
- 'items_new': items_new,
- 'items_duplicate': items_duplicate,
- 'error_msg': error_msg[:1000] if error_msg else '',
- 'response_code': response_code,
- 'response_size_bytes': response_size_bytes,
- })
-
-async def get_stats():
- """全维度统计"""
- from datetime import timedelta
- now = datetime.now()
- day_ago = (now - timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S')
-
- db = DBPools()
- 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
-
- return {
- 'total': len(items),
- **status_count,
- 'sources': sum(1 for s in sources if s.get('enabled') == '1'),
- 'failures': sum(1 for log in logs if log.get('status') == 'failed'),
- }
diff --git a/hotspot/engine.py b/hotspot/engine.py
deleted file mode 100644
index bdd7d1c0..00000000
--- a/hotspot/engine.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""
-抓取引擎 — 调度所有来源,fetch + extract + save
-"""
-import json, time
-import aiohttp
-from datetime import datetime
-
-from .parsers import (BUILTIN, extract_json_path, extract_css,
- extract_regex, extract_rss, fetch_hn)
-from .db import (save_items, update_source_last_fetch,
- write_fetch_log, get_enabled_sources)
-
-def now_str():
- return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
-async def fetch_and_extract(session, src):
- """根据 source 的 parser_config 抓取并提取条目"""
- url = src.get('url', '')
- headers = {}
- if src.get('headers'):
- try:
- headers = json.loads(src.get('headers', '{}'))
- except Exception:
- pass
-
- parser_config = {}
- if src.get('parser_config'):
- try:
- parser_config = json.loads(src.get('parser_config', '{}'))
- except Exception:
- pass
-
- parser_type = parser_config.get('parser', 'json_path')
- builtin_name = parser_config.get('builtin_name', '')
-
- # === 内置解析器 ===
- if parser_type == 'builtin' and builtin_name in BUILTIN:
- bp = BUILTIN[builtin_name]
- fetch_url = bp['url'] or url
- fetch_headers = {**headers, **bp.get('headers', {})}
-
- if builtin_name == 'hackernews':
- items = await fetch_hn(session)
- return items, 200, 0
-
- if builtin_name == 'rsshub':
- try:
- async with session.get(url, headers=fetch_headers,
- timeout=aiohttp.ClientTimeout(total=30)) as resp:
- text = await resp.text()
- items = extract_rss(text)
- return items, resp.status, len(text.encode())
- except Exception:
- return [], 0, 0
-
- # 标准内置: GET → JSON → parser_fn
- try:
- async with session.get(fetch_url, headers=fetch_headers,
- timeout=aiohttp.ClientTimeout(total=30)) as resp:
- if resp.status != 200:
- return [], resp.status, 0
- text = await resp.text()
- size = len(text.encode())
- try:
- data = json.loads(text)
- except json.JSONDecodeError:
- return [], resp.status, size
- items = bp['parser'](data)
- return items, resp.status, size
- except Exception:
- return [], 0, 0
-
- # === 通用提取器 ===
- try:
- async with session.get(url, headers=headers,
- timeout=aiohttp.ClientTimeout(total=30)) as resp:
- if resp.status != 200:
- return [], resp.status, 0
- text = await resp.text()
- size = len(text.encode())
-
- if parser_type == 'json_path':
- try:
- data = json.loads(text)
- except json.JSONDecodeError:
- return [], 0, size
- items = extract_json_path(data, parser_config)
- return items, resp.status, size
-
- elif parser_type == 'css':
- items = extract_css(text, parser_config)
- return items, resp.status, size
-
- elif parser_type == 'regex':
- items = extract_regex(text, parser_config)
- return items, resp.status, size
-
- elif parser_type == 'rss':
- items = extract_rss(text)
- return items, resp.status, size
-
- return [], resp.status, size
-
- except Exception:
- return [], 0, 0
-
-
-async def run_fetch():
- """遍历所有启用的来源,执行抓取"""
- sources = await get_enabled_sources()
- now = now_str()
- results = {'total': len(sources), 'new_items': 0, 'errors': 0, 'details': []}
-
- async with aiohttp.ClientSession() as session:
- for src in sources:
- t0 = time.time()
- try:
- items, code, size = await fetch_and_extract(session, src)
- except Exception as e:
- await write_fetch_log(src['id'], now, int((time.time() - t0) * 1000),
- 'failed', 0, 0, 0, str(e)[:1000])
- results['errors'] += 1
- continue
-
- elapsed = int((time.time() - t0) * 1000)
- status = 'success' if code in (0, 200) else ('partial' if items else 'failed')
-
- new_count, dup_count = await save_items(items, src['id'], now)
- await update_source_last_fetch(src['id'])
- await write_fetch_log(src['id'], now, elapsed, status,
- len(items), new_count, dup_count,
- response_code=code, response_size_bytes=size)
-
- results['new_items'] += new_count
- results['details'].append({
- 'source': src.get('name', src['id']),
- 'items': len(items), 'new': new_count, 'dup': dup_count,
- 'elapsed_ms': elapsed, 'code': code,
- })
-
- return results
diff --git a/hotspot/parsers.py b/hotspot/parsers.py
deleted file mode 100644
index c746a549..00000000
--- a/hotspot/parsers.py
+++ /dev/null
@@ -1,297 +0,0 @@
-"""
-内置平台解析器 — 每个返回 (fetch_url, headers_override, extract_fn)
-
-添加新平台: 在模块底部调用 _reg() 注册即可。
-"""
-import json
-import re
-import xml.etree.ElementTree as ET
-import aiohttp
-
-BUILTIN = {}
-
-def _reg(name, url, parser_fn, headers=None):
- BUILTIN[name] = {'url': url, 'parser': parser_fn, 'headers': headers or {}}
-
-# ---- 国内平台 ----
-
-_reg('weibo_hot',
- 'https://weibo.com/ajax/side/hotSearch',
- lambda data: [
- {'title': item.get('word', ''),
- 'url': f'https://s.weibo.com/weibo?q={item.get("word","")}',
- 'heat_score': item.get('raw_hot', item.get('num', 0))}
- for item in data.get('data', {}).get('realtime', [])[:50]
- if isinstance(item, dict) and item.get('word')
- ],
- {'User-Agent': 'Mozilla/5.0', 'X-Requested-With': 'XMLHttpRequest'})
-
-_reg('zhihu_hot',
- 'https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50',
- lambda data: [
- {'title': item.get('target', {}).get('title', ''),
- 'url': f'https://www.zhihu.com/question/{item.get("target",{}).get("id","")}',
- 'heat_score': int(str(item.get('detail_text', '0'))
- .replace('万','0000').replace('亿','00000000')
- .replace('热度','').strip() or 0),
- 'summary': (item.get('target', {}).get('excerpt', '') or '')[:500]}
- for item in data.get('data', []) if isinstance(item, dict)
- ],
- {'User-Agent': 'Mozilla/5.0'})
-
-_reg('baidu_hot',
- 'https://top.baidu.com/board?tab=realtime',
- lambda data: [
- {'title': item.get('word', ''),
- 'url': item.get('url', ''),
- 'heat_score': int(item.get('hotScore', 0)),
- 'summary': item.get('desc', '')}
- for item in (data.get('data', {}).get('cards', [{}])[0].get('content', []))
- if isinstance(item, dict)
- ],
- {'User-Agent': 'Mozilla/5.0'})
-
-_reg('toutiao_hot',
- 'https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc',
- lambda data: [
- {'title': item.get('Title', ''),
- 'url': item.get('Url', ''),
- 'heat_score': int(float(item.get('HotValue', 0)))}
- for item in (data.get('data', []) if isinstance(data.get('data'), list) else [])[:50]
- if isinstance(item, dict) and item.get('Title')
- ],
- {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.toutiao.com/'})
-
-_reg('bilibili_hot',
- 'https://api.bilibili.com/x/web-interface/popular?ps=50',
- lambda data: [
- {'title': item.get('title', ''),
- 'url': f'https://www.bilibili.com/video/{item.get("bvid","")}',
- 'heat_score': item.get('stat', {}).get('view', 0),
- 'summary': item.get('desc', '')[:500],
- 'engagement_count': item.get('stat', {}).get('like', 0),
- 'comment_count': item.get('stat', {}).get('reply', 0),
- 'share_count': item.get('stat', {}).get('share', 0),
- 'category': item.get('tname', '')}
- for item in data.get('data', {}).get('list', [])
- if isinstance(item, dict) and item.get('title')
- ],
- {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.bilibili.com/'})
-
-_reg('douyin_hot',
- 'https://dy.feigua.cn/api/hot/board',
- lambda data: [
- {'title': item.get('title', item.get('word', '')),
- 'url': item.get('url', ''),
- 'heat_score': int(item.get('hot_value', item.get('heat', 0)))}
- for item in (data.get('data', []) if isinstance(data.get('data'), list) else [])[:50]
- if isinstance(item, dict)
- ],
- {'User-Agent': 'Mozilla/5.0'})
-
-_reg('36kr_hot',
- 'https://www.36kr.com/api/search/list/info-flow/hot?pageSize=30',
- lambda data: [
- {'title': item.get('title', ''),
- 'url': f'https://www.36kr.com/p/{item.get("id","")}',
- 'heat_score': item.get('total_count', 0),
- 'summary': (item.get('summary', '') or '')[:500]}
- for item in data.get('data', {}).get('items', [])
- if isinstance(item, dict) and item.get('title')
- ],
- {'User-Agent': 'Mozilla/5.0'})
-
-# ---- 海外平台 ----
-
-_reg('github_trending',
- 'https://api.github.com/search/repositories?q=stars:>1&sort=stars&order=desc&per_page=20',
- lambda data: [
- {'title': item.get('full_name', ''),
- 'url': item.get('html_url', ''),
- 'heat_score': item.get('stargazers_count', 0),
- 'summary': (item.get('description', '') or '')[:500],
- 'category': item.get('language', '')}
- for item in data.get('items', []) if isinstance(item, dict)
- ],
- {'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'HotspotRadar/1.0'})
-
-_reg('hackernews',
- 'https://hacker-news.firebaseio.com/v0/topstories.json',
- None, # 特殊: 两步抓取, 见 engine.py
- {'User-Agent': 'Mozilla/5.0'})
-
-_reg('v2ex_hot',
- 'https://www.v2ex.com/api/topics/hot.json',
- lambda data: [
- {'title': item.get('title', ''),
- 'url': item.get('url', ''),
- 'heat_score': item.get('replies', 0) * 10,
- 'summary': (item.get('content', '') or '')[:500],
- 'comment_count': item.get('replies', 0),
- 'category': item.get('node', {}).get('title', '')
- if isinstance(item.get('node'), dict) else ''}
- for item in data if isinstance(item, dict) and item.get('title')
- ],
- {'User-Agent': 'Mozilla/5.0'})
-
-_reg('rsshub', None, None, {}) # url 来自 source 配置
-
-# ---- 通用提取器 ----
-
-def extract_json_path(data, config):
- """简易 JSONPath: $.data.list[*] → {title, url, heat_score, ...}"""
- path = config.get('item_path', '$[*]')
- fm = config.get('field_map', {})
-
- if path.startswith('$.'):
- path = path[2:]
- elif path.startswith('$'):
- path = path[1:]
-
- parts = []
- for p in path.split('.'):
- p = p.strip()
- if not p: continue
- if p.endswith('[*]'):
- parts.append(p[:-3]); parts.append('*')
- else:
- parts.append(p)
-
- items = data
- for part in parts:
- if part == '*':
- continue
- if isinstance(items, dict):
- items = items.get(part)
- elif isinstance(items, list):
- items = [i.get(part) for i in items if isinstance(i, dict)]
- items = [x for sub in items for x in (sub if isinstance(sub, list) else [sub])]
- else:
- return []
- if not isinstance(items, list):
- items = [items] if items else []
-
- result = []
- for item in items:
- if not isinstance(item, dict):
- continue
- entry = {
- 'title': _field(item, fm.get('title', 'title')),
- 'url': _field(item, fm.get('url', 'url')),
- 'heat_score': float(_field(item, fm.get('heat_score', '0')) or 0),
- 'summary': str(_field(item, fm.get('summary', '')) or '')[:500],
- 'category': str(_field(item, fm.get('category', '')) or ''),
- 'tags': str(_field(item, fm.get('tags', '')) or ''),
- 'engagement_count': int(_field(item, fm.get('engagement_count', '0')) or 0),
- 'comment_count': int(_field(item, fm.get('comment_count', '0')) or 0),
- 'share_count': int(_field(item, fm.get('share_count', '0')) or 0),
- }
- if entry['title']:
- result.append(entry)
- return result
-
-def _field(obj, path):
- if not path or not isinstance(obj, dict):
- return obj if not isinstance(obj, dict) else ''
- val = obj
- for p in path.split('.'):
- if isinstance(val, dict):
- val = val.get(p, '')
- else:
- return ''
- return val
-
-def extract_css(html, config):
- """CSS 选择器提取 (轻量正则版)"""
- results = []
- for m in re.finditer(
- r']+href=["\']([^"\']+)["\'][^>]*>([^<]*)',
- html, re.IGNORECASE | re.DOTALL
- ):
- url, title = m.group(1), m.group(2).strip()
- if len(title) >= 3:
- results.append({
- 'title': title[:200], 'url': url[:2000],
- 'heat_score': 60, 'summary': '',
- 'category': '', 'tags': '',
- 'engagement_count': 0, 'comment_count': 0, 'share_count': 0,
- })
- return results[:100]
-
-def extract_regex(text, config):
- pattern = config.get('item_regex', '')
- fm = config.get('field_map', {})
- if not pattern: return []
- results = []
- for m in re.finditer(pattern, text, re.DOTALL):
- gd = m.groupdict()
- entry = {
- 'title': gd.get(fm.get('title', 'title'), '')[:200],
- 'url': gd.get(fm.get('url', 'url'), ''),
- 'heat_score': float(gd.get(fm.get('heat_score', '0'), 0) or 0),
- 'summary': str(gd.get(fm.get('summary', ''), ''))[:500],
- 'category': '', 'tags': '',
- 'engagement_count': 0, 'comment_count': 0, 'share_count': 0,
- }
- if entry['title']:
- results.append(entry)
- return results
-
-def extract_rss(xml_text):
- items = []
- try:
- root = ET.fromstring(xml_text)
- ns_a = 'http://www.w3.org/2005/Atom'
- for item in root.findall('.//item'):
- items.append({
- 'title': item.findtext('title', '')[:200],
- 'url': item.findtext('link', ''),
- 'summary': (item.findtext('description', '') or '')[:500],
- 'heat_score': 80, 'category': item.findtext('category', ''),
- 'tags': '', 'engagement_count': 0, 'comment_count': 0, 'share_count': 0,
- })
- if not items:
- for e in root.findall(f'.//{{{ns_a}}}entry'):
- link = e.find(f'{{{ns_a}}}link')
- items.append({
- 'title': e.findtext(f'{{{ns_a}}}title', '')[:200],
- 'url': link.get('href', '') if link is not None else '',
- 'summary': (e.findtext(f'{{{ns_a}}}summary', '') or '')[:500],
- 'heat_score': 70, 'category': '',
- 'tags': '', 'engagement_count': 0, 'comment_count': 0, 'share_count': 0,
- })
- except ET.ParseError:
- pass
- return items
-
-async def fetch_hn(session):
- """HackerNews 两步抓取"""
- items = []
- try:
- async with session.get(
- 'https://hacker-news.firebaseio.com/v0/topstories.json',
- timeout=aiohttp.ClientTimeout(total=15)
- ) as resp:
- ids = await resp.json()
- for sid in ids[:30]:
- try:
- async with session.get(
- f'https://hacker-news.firebaseio.com/v0/item/{sid}.json',
- timeout=aiohttp.ClientTimeout(total=10)
- ) as r2:
- s = await r2.json()
- if s and s.get('title'):
- items.append({
- 'title': s.get('title', '')[:200],
- 'url': s.get('url', f'https://news.ycombinator.com/item?id={sid}'),
- 'heat_score': s.get('score', 0),
- 'engagement_count': s.get('score', 0),
- 'comment_count': s.get('descendants', 0),
- 'summary': '', 'category': s.get('type', ''),
- 'tags': '', 'share_count': 0,
- })
- except Exception:
- continue
- except Exception:
- pass
- return items
diff --git a/init/data.xlsx b/init/data.xlsx
new file mode 100644
index 00000000..82098619
Binary files /dev/null and b/init/data.xlsx differ
diff --git a/json/hotspot_alert.json b/json/hotspot_alert.json
deleted file mode 100644
index 13575a03..00000000
--- a/json/hotspot_alert.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "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
- }
-}
diff --git a/json/hotspot_analysis.json b/json/hotspot_analysis.json
deleted file mode 100644
index 901ed07d..00000000
--- a/json/hotspot_analysis.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "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": {"analysis_data": 400}
- },
- "editexclouded": ["id", "analyzed_at"],
- "record_toolbar": null
- }
-}
diff --git a/json/hotspot_fetch_log.json b/json/hotspot_fetch_log.json
deleted file mode 100644
index 8a92fb58..00000000
--- a/json/hotspot_fetch_log.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "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
- }
-}
diff --git a/json/hotspot_item.json b/json/hotspot_item.json
deleted file mode 100644
index 5a584ba4..00000000
--- a/json/hotspot_item.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "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, "url": 200}
- },
- "editexclouded": ["id", "first_seen", "last_updated"],
- "record_toolbar": null
- }
-}
diff --git a/json/hotspot_schedule.json b/json/hotspot_schedule.json
deleted file mode 100644
index 30722963..00000000
--- a/json/hotspot_schedule.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "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
- }
-}
diff --git a/json/hotspot_source.json b/json/hotspot_source.json
deleted file mode 100644
index ab994b36..00000000
--- a/json/hotspot_source.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "models_dir": "${HOME}$/py/sage/models",
- "output_dir": "${HOME}$/py/sage/wwwroot/_a/hotspot_source",
- "dbname": "sage",
- "tblname": "hotspot_source",
- "title": "热点来源",
- "params": {
- "sortby": "priority asc",
- "browserfields": {
- "exclouded": ["id", "api_secret", "headers", "request_body", "crawler_rules", "parser_config"],
- "cwidth": {"name": 150, "url": 300}
- },
- "editexclouded": ["id", "created_at", "last_fetch"],
- "record_toolbar": null
- }
-}
diff --git a/skills/kboss-promote/SKILL.md b/skills/kboss-promote/SKILL.md
new file mode 100644
index 00000000..26857e0a
--- /dev/null
+++ b/skills/kboss-promote/SKILL.md
@@ -0,0 +1,13 @@
+---
+name: kboss产品推荐
+description: 根据用户输入需求,从kboss平台获取产品数据,交给大模型选择推荐产品
+---
+# kboss产品推荐
+使用kboss提供的获取全量产品清单,获取产品列表,根据客户输入的需求用大模型匹配5个推荐产品
+
+## 获取kboss全量产品清单
+使用以下命令
+```
+script/get_kboss_products.sh
+```
+
diff --git a/skills/kboss-promote/scripts/get_kboss_products.sh b/skills/kboss-promote/scripts/get_kboss_products.sh
new file mode 100644
index 00000000..1329677c
--- /dev/null
+++ b/skills/kboss-promote/scripts/get_kboss_products.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+
diff --git a/skills/media-convert/SKILL.md b/skills/media-convert/SKILL.md
new file mode 100644
index 00000000..40834c67
--- /dev/null
+++ b/skills/media-convert/SKILL.md
@@ -0,0 +1,35 @@
+---
+name: media-convert
+description: 媒体转换,支持视频分离出音频,音频分离人声和伴奏,音视频合并等操作
+---
+# media-convert
+媒体转换,收集多种媒体之间的转换,如视频分离出音频,音频分离人声和伴奏,音视频合并
+
+## 视频分离
+能将输入的视频文件转换为无音频的视频和无音频的视频
+### 输入
+输入是一个json
+{
+ "video_file":视频文件路径
+}
+执行以下操作
+```
+scripts/videosplitor.sh
+```
+
+## 音频文件分离人声和伴奏
+本功能使用了Meta (Facebook) 开源的 Demucs,它是目前公认的音质保留最好的工具之一。
+安装:
+```Bash
+pip install demucs
+```
+上述安装需要在有GPU的宿主机上,Demucs第一次运行时需要下载模型,需要有外网环境:https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/955717e8-8726e21a.th
+
+输入是一个json
+{
+ "audio_file": 音频文件路径
+}
+分离命令:
+```Bash
+scripts/audiosplitor.sh
+```
diff --git a/skills/media-convert/ktvmake.sh b/skills/media-convert/ktvmake.sh
new file mode 100644
index 00000000..70b7acfd
--- /dev/null
+++ b/skills/media-convert/ktvmake.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+cd /tmp
+cdir=$(pwd)
+adir=${cdir}/${id}/separated/htdemucs/output_audio
+id=$$
+ffmpeg -i $1 -c:v copy -an output_video${id}.mp4 -c:a pcm_s16le -f wav output_audio${id}.wav
+mkdir ${id}
+cd ${id}
+demucs --two-stems=vocals ../output_audioi${id}.wav
+cd ${cdir}
+ffmpeg -i output_video${id}.mp4 -i ${adir}/no_vocals.wav -i ${adir}/vocals.wav \
+ -map 0:v:0 \
+ -map 1:a:0 \
+ -map 2:a:0 \
+ -c:v copy \
+ -c:a aac -b:a 192k \
+ -metadata:s:a:0 title="伴奏" \
+ -metadata:s:a:1 title="原唱" \
+ output_ktv.mkv -y
+rm -rf ${id} output_video${id}.mp4 output_audio${id}.wav
+echo ${cdir}/output_ktv.mkv
diff --git a/skills/media-convert/scripts/audiosplitor.sh b/skills/media-convert/scripts/audiosplitor.sh
new file mode 100644
index 00000000..90c775c1
--- /dev/null
+++ b/skills/media-convert/scripts/audiosplitor.sh
@@ -0,0 +1,4 @@
+
+
+demucs --two-stems=vocals $audio_file
+
diff --git a/skills/media-convert/scripts/videosplitor.sh b/skills/media-convert/scripts/videosplitor.sh
new file mode 100644
index 00000000..d411c51d
--- /dev/null
+++ b/skills/media-convert/scripts/videosplitor.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+cdir=$(pwd)
+read -r INPUT
+
+error() {
+ echo "{\"error\":\"$1\"}"
+ exit 0
+}
+
+video_file=$(echo "$INPUT" | jq -r '.video_file // empty')
+[[ -z "$video_file" ]] && error "missing video_file"
+[[ ! -f "$video_file" ]] && error "$video_file not exists"
+
+ffmpeg -i $video_file -c:v copy -an output_video$$.mp4 -c:a pcm_s16le -f wav output_audio$$.wav
+
+echo "{\"video_file\": \"$cdir/output_video$$.mp4\", \"audio_file\": \"$cdir/output_audio$$.wav\"}"
diff --git a/skills/media-convert/videosplitor.sh b/skills/media-convert/videosplitor.sh
new file mode 100644
index 00000000..e69de29b
diff --git a/wwwroot/hotspot/analyze.dspy b/wwwroot/hotspot/analyze.dspy
deleted file mode 100644
index 918270b5..00000000
--- a/wwwroot/hotspot/analyze.dspy
+++ /dev/null
@@ -1,2 +0,0 @@
-from hotspot.analysis import run_analysis
-return await run_analysis()
diff --git a/wwwroot/hotspot/fetch_now.dspy b/wwwroot/hotspot/fetch_now.dspy
deleted file mode 100644
index 939d9f64..00000000
--- a/wwwroot/hotspot/fetch_now.dspy
+++ /dev/null
@@ -1,2 +0,0 @@
-from hotspot.engine import run_fetch
-return await run_fetch()
diff --git a/wwwroot/hotspot/index.ui b/wwwroot/hotspot/index.ui
deleted file mode 100644
index d4d0c83f..00000000
--- a/wwwroot/hotspot/index.ui
+++ /dev/null
@@ -1,182 +0,0 @@
-{% set roles = get_user_roles(get_user()) %}
-{
- "widgettype": "VBox",
- "options": {
- "children": [
- {
- "widgettype": "HBox",
- "options": {
- "children": [
- {
- "widgettype": "VBox",
- "options": {
- "id": "stat_emerging",
- "children": [
- {"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:14px;background:#f6ffed;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #b7eb8f;"
- }
- },
- {
- "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:32px;font-weight:bold;color:#1890ff;"}}
- ],
- "style": "padding:14px;background:#e6f7ff;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #91d5ff;"
- }
- },
- {
- "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:32px;font-weight:bold;color:#ff4d4f;"}}
- ],
- "style": "padding:14px;background:#fff1f0;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #ffa39e;"
- }
- },
- {
- "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:32px;font-weight:bold;color:#faad14;"}}
- ],
- "style": "padding:14px;background:#fffbe6;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #ffe58f;"
- }
- },
- {
- "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:32px;font-weight:bold;color:#bfbfbf;"}}
- ],
- "style": "padding:14px;background:#fafafa;border-radius:8px;margin:6px;min-width:90px;text-align:center;border:1px solid #d9d9d9;"
- }
- },
- {
- "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:32px;font-weight:bold;color:#722ed1;"}}
- ],
- "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;"
- }
- }
- ]
- }
- },
- {
- "widgettype": "HBox",
- "options": {
- "children": [
- {
- "widgettype": "Button",
- "options": {
- "label": "立即抓取全部来源",
- "actiontype": "script",
- "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;"
- }
- },
- {
- "widgettype": "Button",
- "options": {
- "label": "刷新统计",
- "actiontype": "script",
- "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;"
- }
- }
- ]
- }
- },
- {
- "widgettype": "Tab",
- "options": {
- "tabs": [
- {
- "title": "来源管理",
- "content": {
- "widgettype": "urlwidget",
- "options": {"url": "{{ entire_url('hotspot_source') }}"}
- }
- },
- {
- "title": "调度配置",
- "content": {
- "widgettype": "urlwidget",
- "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') }}"}
- }
- }
- ]
- }
- },
- {
- "widgettype": "Text",
- "options": {
- "text": "工作流: ① 添加来源 → parser_config选parser=builtin,builtin_name=weibo/zhihu/baidu/bilibili/... 或自定义json_path/css/regex → ② 调度配置 → ③ 抓取 → ④ 分析 → ⑤ 浏览 | 内置解析器: weibo_hot zhihu_hot baidu_hot toutiao_hot bilibili_hot douyin_hot 36kr_hot github_trending hackernews v2ex_hot rsshub",
- "style": "font-size:12px;color:#bbb;padding:16px;border-top:1px solid #eee;margin-top:8px;"
- }
- }
- ]
- }
-}
diff --git a/wwwroot/hotspot/stats.dspy b/wwwroot/hotspot/stats.dspy
deleted file mode 100644
index cd064e5e..00000000
--- a/wwwroot/hotspot/stats.dspy
+++ /dev/null
@@ -1,2 +0,0 @@
-from hotspot.db import get_stats
-return await get_stats()
diff --git a/wwwroot/imgs/ocai1.svg b/wwwroot/imgs/ocai1.svg
new file mode 100644
index 00000000..a08000df
--- /dev/null
+++ b/wwwroot/imgs/ocai1.svg
@@ -0,0 +1,168 @@
+