Remove CLS collection and support a full news AI reset
Development CI / check-and-build (push) Successful in 1m49s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Failing after 7s

This commit is contained in:
eezd committed 2026-11-04 16:47:29 +08:00
1 parent dd50cb7df8
commit fcd1cfc344
15 files changed
+340 -146

No files matched your search

+2 -2
View File
@@ -11,8 +11,8 @@ LOG_CHANNEL = f"{KEY_PREFIX}pub:logs"
NEWS_CHANNEL = f"{KEY_PREFIX}pub:news"
CONTROL_CHANNEL = f"{KEY_PREFIX}ctl:crawler"
STATUS_KEY = f"{KEY_PREFIX}crawler:status"
# 列表接口支持按时间点往前翻的来源;cls_depth 列表只能翻约 3 页,无法回补历史
HISTORY_SOURCES = ("cls_telegraph", "wscn_live", "wscn_news")
# Sources whose list endpoints support historical pagination.
HISTORY_SOURCES = ("wscn_live", "wscn_news")
def start_urls_key(source_code: str) -> str:
@@ -0,0 +1,117 @@
"""Fixed-scope operator cleanup; callers must stop all application writers."""
import argparse
import asyncio
import json
from sqlalchemy import text
from alphapulse.core.db import engine
SOURCES = ('cls_depth', 'cls_telegraph')
AI_TABLES = (
'news_research_references', 'news_research_versions', 'news_research_memberships',
'news_research_attempts', 'news_research_packages', 'news_research_events',
'news_ai_reprocess_targets', 'news_ai_reprocess_items', 'news_ai_reprocess_jobs',
'news_ai_event_links',
'news_ai_event_versions', 'news_ai_event_members', 'news_ai_events',
'news_ai_package_corrections', 'news_package_evidence', 'news_ai_summary_versions',
'news_ai_grouping_versions', 'news_ai_summary_articles', 'news_ai_summaries',
'news_ai_article_analyses', 'risk_snapshots', 'risk_analyses', 'risk_events', 'news_signals',
)
# These retired models were never included in the migration chain.
OPTIONAL_AI_TABLES = ('news_ai_event_evidence', 'news_ai_event_corrections')
SOURCE_TABLES = ('news_items', 'raw_responses', 'crawl_logs', 'crawl_runs', 'sources')
async def retained_news(connection):
return (await connection.execute(text('''
SELECT count(*), md5(coalesce(string_agg(id::text || ':' || content_hash, ',' ORDER BY id), ''))
FROM news_items WHERE NOT (source_code = ANY(:sources))
'''), {'sources': list(SOURCES)})).one()._asdict()
async def protected_records(connection):
tables = list(await connection.scalars(text("""
SELECT tablename FROM pg_tables WHERE schemaname='public' AND
(tablename IN ('ai_providers','skill_nodes','skill_versions',
'news_research_vocabulary_versions','chat_sessions','chat_messages','context_versions')
OR starts_with(tablename, 'trading_')) ORDER BY tablename
""")))
quote = connection.dialect.identifier_preparer.quote
result = {}
for table in tables:
result[table] = (await connection.execute(text(
"SELECT count(*), md5(coalesce(string_agg(digest,'' ORDER BY digest),'')) FROM "
'(SELECT md5(row_to_json(record)::text) AS digest FROM ' + quote(table) + ' record) records'
))).one()._asdict()
return result
async def cleanup(connection, execute=False):
params = {'sources': list(SOURCES)}
optional = tuple([table for table in OPTIONAL_AI_TABLES
if await connection.scalar(text('SELECT to_regclass(:name)'), {'name': table})])
ai_tables = (*optional, *AI_TABLES)
if execute:
await connection.execute(text("SET LOCAL lock_timeout = '10s'"))
tables = (*ai_tables, 'news_revisions', *SOURCE_TABLES, 'raw_bodies',
'news_research_settings', 'risk_settings')
await connection.execute(text('LOCK TABLE ' + ', '.join(tables) + ' IN SHARE ROW EXCLUSIVE MODE'))
counts = {table: await connection.scalar(text('SELECT count(*) FROM ' + table)) for table in ai_tables}
counts.update({table: await connection.scalar(text('SELECT count(*) FROM ' + table +
' WHERE ' + ('code' if table == 'sources' else 'source_code') + ' = ANY(:sources)'), params)
for table in SOURCE_TABLES})
counts['news_revisions'] = await connection.scalar(text('''SELECT count(*) FROM news_revisions
WHERE news_id IN (SELECT id FROM news_items WHERE source_code = ANY(:sources))'''), params)
before = await retained_news(connection)
protected = await protected_records(connection)
if execute:
await connection.execute(text('''CREATE TEMP TABLE IF NOT EXISTS removed_cls_bodies ON COMMIT DROP AS
SELECT DISTINCT body_sha256 FROM raw_responses WHERE source_code = ANY(:sources)'''), params)
for table in ('news_research_settings', 'risk_settings'):
await connection.execute(text('UPDATE ' + table + ' SET enabled = false'))
await connection.execute(text('UPDATE news_research_settings SET control_revision = control_revision + 1'))
for table in ai_tables:
await connection.execute(text('DELETE FROM ' + table))
await connection.execute(text('''DELETE FROM news_revisions WHERE news_id IN
(SELECT id FROM news_items WHERE source_code = ANY(:sources))'''), params)
for table in SOURCE_TABLES:
await connection.execute(text('DELETE FROM ' + table + ' WHERE ' +
('code' if table == 'sources' else 'source_code') + ' = ANY(:sources)'), params)
result = await connection.execute(text('''DELETE FROM raw_bodies body USING removed_cls_bodies removed
WHERE body.sha256 = removed.body_sha256 AND NOT EXISTS
(SELECT 1 FROM raw_responses response WHERE response.body_sha256 = body.sha256)'''))
counts['unreferenced_raw_bodies'] = result.rowcount
if protected != await protected_records(connection):
raise RuntimeError('保留业务数据校验失败,清理已回滚')
if before != await retained_news(connection):
raise RuntimeError('其他来源原文校验失败,清理已回滚')
return {'executed': execute, 'protected_records': protected, 'counts': counts, 'retained_news': before, 'absent_legacy_tables': sorted(set(OPTIONAL_AI_TABLES) - set(optional))}
async def main(execute=False):
try:
async with engine.begin() as connection:
if not execute:
await connection.execute(text('SET TRANSACTION READ ONLY'))
result = await cleanup(connection, execute)
if execute:
from alphapulse.core.redis import redis
async for key in redis.scan_iter(match='ap:risk:*', count=100):
await redis.delete(key)
for source in SOURCES:
async for key in redis.scan_iter(match=f'ap:{source}:*', count=100):
await redis.delete(key)
await redis.hdel('ap:crawler:status', *SOURCES)
await redis.delete('ap:rl:www.cls.cn')
await redis.aclose()
print(json.dumps(result, ensure_ascii=False))
finally:
await engine.dispose()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--execute', action='store_true')
asyncio.run(main(parser.parse_args().execute))
@@ -1,113 +0,0 @@
import hashlib
import html
import json
import re
from datetime import datetime
from urllib.parse import urlencode
from scrapy import Request
from scrapy.http import Response
from alphapulse.crawler.spiders.base import NewsSpider, ts
def signed_url(path: str, params: dict[str, str]) -> str:
# 财联社网页前端的公开签名算法:md5(sha1(按键排序的查询串))
query = {"app": "CailianpressWeb", "os": "web", "sv": "8.4.6", **params}
query["sign"] = hashlib.md5(hashlib.sha1(urlencode(sorted(query.items())).encode()).hexdigest().encode()).hexdigest()
return f"https://www.cls.cn{path}?{urlencode(query)}"
class ClsTelegraphSpider(NewsSpider):
"""财联社·电报:列表即全文;翻页需 refresh_type=1"""
name = "cls_telegraph"
request_headers = {"Referer": "https://www.cls.cn/telegraph"}
def first_requests(self) -> list[Request]:
return [self.request(signed_url("/v1/roll/get_roll_list", {"category": "", "last_time": "", "rn": "20"}), self.parse_list, page=1)]
def list_request(self, last_time: int, page: int, **meta) -> Request:
params = {"category": "", "last_time": str(last_time), "refresh_type": "1", "rn": "20"}
return self.request(signed_url("/v1/roll/get_roll_list", params), self.parse_list, page=page, **meta)
def history_request(self, before: datetime) -> Request:
return self.list_request(int(before.timestamp()), 1, history=True)
async def parse_list(self, response: Response):
body = response.json()
if body["errno"] != 0:
raise ValueError(f"接口返回错误:{body}")
rows = [x for x in body["data"]["roll_data"] if not x["is_ad"]]
# 先查已入库集合,再产出条目:否则管道可能先落库,导致误判为没有新条目而停止翻页
ids = [str(x["id"]) for x in rows]
known = await self.known_items(ids)
for x in rows:
yield self.item(
response,
external_id=str(x["id"]),
url=f"https://www.cls.cn/detail/{x['id']}",
title=x["title"],
summary=x["brief"],
content_html=f"<p>{html.escape(x['content'])}</p>",
published_at=ts(x["ctime"]),
source_updated_at=ts(x["modified_time"]) if x.get("modified_time") else None,
author=x.get("author"),
tags=[s["subject_name"] for s in x.get("subjects") or []],
extra={"level": x.get("level"), "stock_list": x.get("stock_list")},
)
page = response.meta["page"]
last_time = min(x["ctime"] for x in rows) if rows else None
oldest = ts(last_time) if rows else None
if response.meta.get("history"):
if self.history_step(oldest):
yield self.list_request(last_time, page + 1, history=True)
elif rows and self.should_continue(ids, known, oldest, page):
yield self.list_request(last_time, page + 1)
class ClsDepthSpider(NewsSpider):
"""财联社·深度(头条):列表只有摘要,正文从详情页 __NEXT_DATA__ 解析"""
name = "cls_depth"
request_headers = {"Referer": "https://www.cls.cn/depth?id=1000"}
def first_requests(self) -> list[Request]:
# 首页不带 last_time 时 rn 无效,返回约 30 条且不按时间排序
return [self.request(signed_url("/v3/depth/list/1000", {}), self.parse_list, page=1)]
async def parse_list(self, response: Response):
body = response.json()
if body["errno"] != 0:
raise ValueError(f"接口返回错误:{body}")
rows = [x for x in body["data"] if not x["is_ad"]]
ids = [str(x["id"]) for x in rows]
known = await self.known_items(ids)
for x in rows:
if known.get(str(x["id"])) != (x["title"], x["brief"]):
# 详情页不一定有 brief,摘要以列表为准(也用于下次比较是否变化)
yield self.request(f"https://www.cls.cn/detail/{x['id']}", self.parse_detail, summary=x["brief"])
page = response.meta["page"]
if rows and self.should_continue(ids, known, ts(min(x["ctime"] for x in rows)), page):
params = {"last_time": str(min(x["ctime"] for x in rows)), "rn": "20"}
yield self.request(signed_url("/v3/depth/list/1000", params), self.parse_list, page=page + 1)
async def parse_detail(self, response: Response):
match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', response.text, re.S)
if match is None:
raise ValueError("详情页缺少 __NEXT_DATA__,页面结构可能已变化")
a = json.loads(match.group(1))["props"]["pageProps"]["articleDetail"]
yield self.item(
response,
external_id=str(a["id"]),
url=f"https://www.cls.cn/detail/{a['id']}",
title=a["title"],
summary=response.meta["summary"],
content_html=a["content"],
published_at=ts(a["ctime"]),
author=(a.get("author") or {}).get("name"),
tags=[s["name"] for s in a.get("subject") or []],
# 付费内容只保存公开部分
is_paywalled=not a["isFree"],
extra={"level": a.get("level")},
)