Add three-stage cross-domain news research and reading briefings
Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections. Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized. Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
This commit is contained in:
1 parent
ae35d3d608
commit
80d266dba7
44 files changed
+2682
-574
No files matched your search
@@ -35,6 +35,7 @@ class Worker:
|
||||
next_run_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
# 下一次触发的类型:schedule / startup / restart
|
||||
trigger: str = "schedule"
|
||||
adapter: str = "builtin"
|
||||
restarting: bool = False
|
||||
restart_delay: int = RESTART_DELAY_MIN
|
||||
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
@@ -53,11 +54,13 @@ class Manager:
|
||||
def __init__(self) -> None:
|
||||
self.workers: dict[str, Worker] = {}
|
||||
self.stopping = asyncio.Event()
|
||||
self.dynamic_tasks = set()
|
||||
self.supervisor_group = None
|
||||
|
||||
async def run(self) -> None:
|
||||
async with SessionLocal() as session:
|
||||
codes = (await session.scalars(select(Source.code).where(Source.collector == "news").order_by(Source.code))).all()
|
||||
self.workers = {code: Worker(code) for code in codes}
|
||||
sources = (await session.scalars(select(Source).where(Source.collector == "news").order_by(Source.code))).all()
|
||||
self.workers = {source.code: Worker(source.code, adapter=source.adapter) for source in sources}
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
@@ -66,12 +69,15 @@ class Manager:
|
||||
# 各循环自行重试可恢复的错误;仍有异常逃出时 TaskGroup 让整个进程退出,由 docker 重启,
|
||||
# 避免出现"进程活着但调度或守护任务已经死掉"的状态
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
self.supervisor_group = tg
|
||||
for w in self.workers.values():
|
||||
tg.create_task(self.supervise(w))
|
||||
loops = [tg.create_task(c) for c in (self.schedule_loop(), self.control_loop(), self.status_loop())]
|
||||
await self.stopping.wait()
|
||||
logger.info("收到退出信号,停止全部子进程")
|
||||
await asyncio.gather(*(self.stop_process(w) for w in self.workers.values()))
|
||||
for task in self.dynamic_tasks:
|
||||
task.cancel()
|
||||
for task in loops:
|
||||
task.cancel()
|
||||
await redis.delete(STATUS_KEY)
|
||||
@@ -79,7 +85,8 @@ class Manager:
|
||||
async def supervise(self, w: Worker) -> None:
|
||||
while not self.stopping.is_set():
|
||||
await self.cleanup_before_spawn(w)
|
||||
w.proc = await asyncio.create_subprocess_exec("scrapy", "crawl", w.code)
|
||||
arguments = ["scrapy", "crawl", "custom_rss", "-a", "source_code="+w.code] if w.adapter == "rss" else ["scrapy", "crawl", w.code]
|
||||
w.proc = await asyncio.create_subprocess_exec(*arguments)
|
||||
w.started_at = datetime.now(UTC)
|
||||
# 新进程的第一轮立即执行,并做全量回溯扫描
|
||||
if w.trigger == "schedule":
|
||||
@@ -145,11 +152,16 @@ class Manager:
|
||||
for s in sources:
|
||||
w = self.workers.get(s.code)
|
||||
if w is None:
|
||||
# 启动后才新增的来源,需要重启管理进程才会接管
|
||||
if s.code not in unknown:
|
||||
unknown.add(s.code)
|
||||
logger.warning("来源 %s 不在本进程的守护列表中,重启管理进程后生效", s.code)
|
||||
continue
|
||||
if s.adapter != 'rss':
|
||||
if s.code not in unknown:
|
||||
unknown.add(s.code)
|
||||
logger.warning("来源 %s 不在本进程的守护列表中,重启管理进程后生效", s.code)
|
||||
continue
|
||||
w = Worker(s.code, adapter='rss')
|
||||
self.workers[s.code] = w
|
||||
task = self.supervisor_group.create_task(self.supervise(w))
|
||||
self.dynamic_tasks.add(task)
|
||||
task.add_done_callback(self.dynamic_tasks.discard)
|
||||
if not s.enabled or w.proc is None or w.restarting:
|
||||
continue
|
||||
if s.paused_until and s.paused_until > now:
|
||||
|
||||
@@ -170,3 +170,74 @@ class BlsDownloadHandler:
|
||||
str(response.url), status=response.status_code, headers=response.headers.multi_items(),
|
||||
body=body, request=request,
|
||||
)
|
||||
|
||||
|
||||
class CustomRssSpider(RssNewsSpider):
|
||||
"""Load administrator-managed subscriptions without registering new spider classes."""
|
||||
name = 'custom_rss'
|
||||
|
||||
def __init__(self, source_code, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not re.fullmatch(r'rss_[0-9a-f]{24}', source_code):
|
||||
raise ValueError('订阅来源标识无效')
|
||||
self.name = source_code
|
||||
|
||||
async def start(self):
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import Source
|
||||
from alphapulse.news_risk.research import public_address
|
||||
async with SessionLocal() as session:
|
||||
source = await session.get(Source, self.name)
|
||||
if source is None or source.adapter != 'rss': raise ValueError('订阅不存在')
|
||||
await public_address(source.list_url)
|
||||
self.feeds = (source.list_url,)
|
||||
async for request in super().start(): yield request
|
||||
|
||||
custom_settings = {'DOWNLOAD_HANDLERS': {'http': 'alphapulse.crawler.spiders.rss.PublicFeedDownloadHandler',
|
||||
'https': 'alphapulse.crawler.spiders.rss.PublicFeedDownloadHandler'}}
|
||||
|
||||
def _parse_entry(self, response, entry):
|
||||
if entry.tag == ATOM+'entry':
|
||||
# Prefer the article link over Atom's optional self/edit links.
|
||||
links = entry.findall(ATOM+'link')
|
||||
article_link = next((link for link in links if link.get('rel', 'alternate') == 'alternate'), None)
|
||||
if article_link is not None:
|
||||
entry.remove(article_link)
|
||||
entry.insert(0, article_link)
|
||||
item = super()._parse_entry(response, entry)
|
||||
full_content = False
|
||||
if entry.tag == ATOM+'entry':
|
||||
content = entry.find(ATOM+'content')
|
||||
full_content = content is not None
|
||||
if content is None:
|
||||
content = entry.find(ATOM+'summary')
|
||||
if content is not None:
|
||||
if content.get('type') == 'xhtml':
|
||||
body = ''.join(ElementTree.tostring(child, encoding='unicode') for child in content)
|
||||
elif content.get('type') == 'html': body = content.text or ''
|
||||
else: body = escape(content.text or '')
|
||||
else: body = item['content_html']
|
||||
item['tags'] = [node.get('term') or node.text.strip() for node in entry.findall(ATOM+'category') if node.get('term') or (node.text and node.text.strip())]
|
||||
else:
|
||||
encoded = entry.findtext('{http://purl.org/rss/1.0/modules/content/}encoded')
|
||||
full_content = bool(encoded)
|
||||
body = encoded or item['content_html']
|
||||
item.update(content_html=body, content_text=html_to_text(body), summary=html_to_text(body))
|
||||
item['extra']['language'] = 'und'
|
||||
item['extra']['content_scope'] = 'feed_content' if full_content else 'feed_summary'
|
||||
return item
|
||||
|
||||
|
||||
class PublicFeedDownloadHandler:
|
||||
lazy = True
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler): return cls()
|
||||
|
||||
async def close(self): pass
|
||||
|
||||
async def download_request(self, request):
|
||||
from alphapulse.news_risk.research import fetch_public_response
|
||||
response = await fetch_public_response(request.url)
|
||||
return Response(str(response.url), status=response.status_code, body=response.content, request=request,
|
||||
headers=response.headers.multi_items())
|
||||
Reference in new issue
Block a user