sage/wwwroot/hotspot/fetch_now.dspy
yumoqing 64cc51cb6e 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)
2026-08-01 11:14:58 +08:00

420 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 抓取引擎 — 支持 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
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 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'
# RSS 2.0
for item in root.findall('.//item'):
items.append({
'title': item.findtext('title', ''),
'url': item.findtext('link', ''),
'summary': (item.findtext('description', '') or '')[:500],
'publish_time': item.findtext('pubDate', ''),
})
# Atom fallback
if not items:
for entry in root.findall(f'.//{{{ns_atom}}}entry'):
link_el = entry.find(f'{{{ns_atom}}}link')
items.append({
'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 '',
})
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:
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 = {}
# Extract items from JSON response
if isinstance(data, list):
entries = data
elif isinstance(data, dict):
entries = data.get('data', data.get('items', data.get('list', data.get('result', []))))
else:
entries = []
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:
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 = now_str()
results = {'total': 0, 'new_items': 0, 'errors': 0, 'details': []}
async with db.sqlorContext('sage') as sor:
sources = await sor.R('hotspot_source', {})
import aiohttp
async with aiohttp.ClientSession() as session:
for src in sources:
if src.get('enabled') != '1':
continue
results['total'] += 1
src_id = src['id']
src_type = src.get('type', 'rss')
t0 = time.time()
# 执行抓取
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
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})
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,
})
return results