Remove CLS collection and support a full news AI reset
This commit is contained in:
1 parent
dd50cb7df8
commit
fcd1cfc344
15 files changed
+340
-146
No files matched your search
@@ -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")},
|
||||
)
|
||||
Reference in new issue
Block a user