|
@@ -3,8 +3,9 @@
|
|
|
流程:
|
|
流程:
|
|
|
1. 取 accounts 表中所有有 gh_id 的账号(crawl_deep_done=0 的优先)
|
|
1. 取 accounts 表中所有有 gh_id 的账号(crawl_deep_done=0 的优先)
|
|
|
2. 每个账号:
|
|
2. 每个账号:
|
|
|
- - 0: 首次深翻 → 翻页 → send_time < 一年前停止 → 标记 deep_done=1
|
|
|
|
|
- - 1: 增量更新 → 只取第一页
|
|
|
|
|
|
|
+ - crawl_deep_done=0: 深翻 → 逐页拉取一年内文章(每页持久化游标,崩溃可断点续翻)
|
|
|
|
|
+ → 遇到一年前文章或翻完停止 → 标记 deep_done=1
|
|
|
|
|
+ - crawl_deep_done=1: 增量更新 → 只取第一页
|
|
|
3. 文章 INSERT IGNORE (wx_sn 去重)
|
|
3. 文章 INSERT IGNORE (wx_sn 去重)
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
@@ -13,6 +14,7 @@ import logging
|
|
|
import time
|
|
import time
|
|
|
|
|
|
|
|
from src.infra.database.mysql.manager import MysqlManager
|
|
from src.infra.database.mysql.manager import MysqlManager
|
|
|
|
|
+from src.infra.shared.async_tasks import run_tasks_with_async_worker_group
|
|
|
|
|
|
|
|
from ._const import ContentSupplyConst
|
|
from ._const import ContentSupplyConst
|
|
|
from ._mapper import AccountMapper
|
|
from ._mapper import AccountMapper
|
|
@@ -37,60 +39,85 @@ class AccountArticleFetch(ContentSupplyConst):
|
|
|
if not accounts:
|
|
if not accounts:
|
|
|
logger.info("无待抓取文章的账号")
|
|
logger.info("无待抓取文章的账号")
|
|
|
return {"accounts": 0, "articles_written": 0, "errors": 0}
|
|
return {"accounts": 0, "articles_written": 0, "errors": 0}
|
|
|
|
|
+
|
|
|
total_articles = 0
|
|
total_articles = 0
|
|
|
- errors = 0
|
|
|
|
|
|
|
+ lock = asyncio.Lock()
|
|
|
|
|
+ first_done = False
|
|
|
|
|
+
|
|
|
|
|
+ async def _process_one(acct: dict) -> None:
|
|
|
|
|
+ nonlocal total_articles, first_done
|
|
|
|
|
|
|
|
- for i, acct in enumerate(accounts):
|
|
|
|
|
gh_id = acct["gh_id"]
|
|
gh_id = acct["gh_id"]
|
|
|
- deep_done = acct.get("crawl_deep_done", 0)
|
|
|
|
|
- cursor = acct.get("crawl_cursor") or None
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ async with lock:
|
|
|
|
|
+ need_sleep = first_done
|
|
|
|
|
+ first_done = True
|
|
|
|
|
|
|
|
- if i > 0:
|
|
|
|
|
- await asyncio.sleep(self.ARTICLE_PAGE_INTERVAL_SEC)
|
|
|
|
|
|
|
+ if need_sleep:
|
|
|
|
|
+ await asyncio.sleep(self.ARTICLE_PAGE_INTERVAL_SEC)
|
|
|
|
|
+
|
|
|
|
|
+ deep_done = acct.get("crawl_deep_done", 0)
|
|
|
|
|
+ cursor = acct.get("crawl_cursor") or None
|
|
|
|
|
|
|
|
- try:
|
|
|
|
|
if deep_done:
|
|
if deep_done:
|
|
|
- n = await self._incremental_crawl(gh_id, cursor)
|
|
|
|
|
|
|
+ n = await self._incremental_crawl(gh_id)
|
|
|
else:
|
|
else:
|
|
|
- n = await self._deep_crawl(gh_id)
|
|
|
|
|
- await self.mapper.update_crawl_status(gh_id, cursor="", deep_done=1)
|
|
|
|
|
|
|
+ # _deep_crawl 内部逐页持久化游标,完成后自动标记 deep_done=1
|
|
|
|
|
+ n = await self._deep_crawl(gh_id, start_cursor=cursor)
|
|
|
|
|
+
|
|
|
|
|
+ async with lock:
|
|
|
|
|
+ total_articles += n
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "gh_id=%s 抓取完成: 本轮写入 %d 条, 累计 %d 条",
|
|
|
|
|
+ gh_id,
|
|
|
|
|
+ n,
|
|
|
|
|
+ total_articles,
|
|
|
|
|
+ )
|
|
|
except Exception:
|
|
except Exception:
|
|
|
logger.exception("账号文章抓取异常: gh_id=%s", gh_id)
|
|
logger.exception("账号文章抓取异常: gh_id=%s", gh_id)
|
|
|
- errors += 1
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- total_articles += n
|
|
|
|
|
- prev_count = await self.mapper.count_articles_by_gh_id(gh_id)
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "gh_id=%s 抓取完成: 本批写入 %d 条, 累计 %d 条",
|
|
|
|
|
- gh_id,
|
|
|
|
|
- n,
|
|
|
|
|
- prev_count,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ raise
|
|
|
|
|
+
|
|
|
|
|
+ result = await run_tasks_with_async_worker_group(
|
|
|
|
|
+ accounts,
|
|
|
|
|
+ _process_one,
|
|
|
|
|
+ description="账号文章抓取",
|
|
|
|
|
+ unit="account",
|
|
|
|
|
+ max_concurrency=1,
|
|
|
|
|
+ fail_fast=False,
|
|
|
|
|
+ show_progress=False,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
+ errors_count = len(result["errors"])
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"文章抓取完成: 账号 %d 个, 写入 %d 条, 异常 %d",
|
|
"文章抓取完成: 账号 %d 个, 写入 %d 条, 异常 %d",
|
|
|
len(accounts),
|
|
len(accounts),
|
|
|
total_articles,
|
|
total_articles,
|
|
|
- errors,
|
|
|
|
|
|
|
+ errors_count,
|
|
|
)
|
|
)
|
|
|
return {
|
|
return {
|
|
|
"accounts": len(accounts),
|
|
"accounts": len(accounts),
|
|
|
"articles_written": total_articles,
|
|
"articles_written": total_articles,
|
|
|
- "errors": errors,
|
|
|
|
|
|
|
+ "errors": errors_count,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
# 抓取模式
|
|
# 抓取模式
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
- async def _deep_crawl(self, gh_id: str) -> int:
|
|
|
|
|
- """首次深翻:逐页拉取,直到遇到一年前的文章"""
|
|
|
|
|
|
|
+ async def _deep_crawl(self, gh_id: str, *, start_cursor: str | None = None) -> int:
|
|
|
|
|
+ """首次深翻:逐页拉取直到遇到一年前的文章,每页持久化游标防崩溃丢进度
|
|
|
|
|
+
|
|
|
|
|
+ 断点续翻:start_cursor 非空时从上次中断位置继续,而非从头开始。
|
|
|
|
|
+ send_time=0 的文章保留(时间戳未知,不丢弃)。
|
|
|
|
|
+ """
|
|
|
total = 0
|
|
total = 0
|
|
|
- cursor = None
|
|
|
|
|
|
|
+ cursor = start_cursor
|
|
|
cutoff = int(time.time()) - self.ONE_YEAR_SECONDS
|
|
cutoff = int(time.time()) - self.ONE_YEAR_SECONDS
|
|
|
|
|
+ pages = 0
|
|
|
|
|
|
|
|
- while True:
|
|
|
|
|
|
|
+ while pages < self.MAX_DEEP_CRAWL_PAGES:
|
|
|
|
|
+ pages += 1
|
|
|
(
|
|
(
|
|
|
articles,
|
|
articles,
|
|
|
next_cursor,
|
|
next_cursor,
|
|
@@ -99,14 +126,27 @@ class AccountArticleFetch(ContentSupplyConst):
|
|
|
) = await fetch_article_page(gh_id, cursor)
|
|
) = await fetch_article_page(gh_id, cursor)
|
|
|
|
|
|
|
|
if articles:
|
|
if articles:
|
|
|
- # 筛选一年内的文章,过滤一年前的
|
|
|
|
|
- fresh = [a for a in articles if a["send_time"] >= cutoff]
|
|
|
|
|
|
|
+ # 筛选一年内的文章;send_time=0(未知时间戳)保留不丢弃
|
|
|
|
|
+ fresh = [
|
|
|
|
|
+ a
|
|
|
|
|
+ for a in articles
|
|
|
|
|
+ if a["send_time"] >= cutoff or a["send_time"] == 0
|
|
|
|
|
+ ]
|
|
|
old_count = len(articles) - len(fresh)
|
|
old_count = len(articles) - len(fresh)
|
|
|
|
|
+ zero_ts = [a["wx_sn"] for a in articles if a["send_time"] == 0]
|
|
|
|
|
+ if zero_ts:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "gh_id=%s 第 %d 页有 %d 条文章 send_time=0 已保留: %s",
|
|
|
|
|
+ gh_id,
|
|
|
|
|
+ pages,
|
|
|
|
|
+ len(zero_ts),
|
|
|
|
|
+ zero_ts,
|
|
|
|
|
+ )
|
|
|
if fresh:
|
|
if fresh:
|
|
|
n = await self.mapper.insert_articles_batch(fresh)
|
|
n = await self.mapper.insert_articles_batch(fresh)
|
|
|
total += n
|
|
total += n
|
|
|
|
|
|
|
|
- # 出现一年前的文章 → 停止翻页
|
|
|
|
|
|
|
+ # 出现一年前的文章 → 停止翻页,标记完成
|
|
|
if old_count > 0 or (oldest_send_time and oldest_send_time < cutoff):
|
|
if old_count > 0 or (oldest_send_time and oldest_send_time < cutoff):
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"gh_id=%s 深翻到达一年前边界: 本页 %d 条中 %d 条超一年, 停止",
|
|
"gh_id=%s 深翻到达一年前边界: 本页 %d 条中 %d 条超一年, 停止",
|
|
@@ -114,17 +154,31 @@ class AccountArticleFetch(ContentSupplyConst):
|
|
|
len(articles),
|
|
len(articles),
|
|
|
old_count,
|
|
old_count,
|
|
|
)
|
|
)
|
|
|
- break
|
|
|
|
|
|
|
+ await self.mapper.update_crawl_status(gh_id, cursor="", deep_done=1)
|
|
|
|
|
+ return total
|
|
|
|
|
|
|
|
if not has_more or not next_cursor:
|
|
if not has_more or not next_cursor:
|
|
|
|
|
+ await self.mapper.update_crawl_status(gh_id, cursor="", deep_done=1)
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
cursor = next_cursor
|
|
cursor = next_cursor
|
|
|
|
|
+ # 逐页持久化游标,崩溃后可从断点继续
|
|
|
|
|
+ await self.mapper.update_crawl_status(
|
|
|
|
|
+ gh_id, cursor=next_cursor or "", deep_done=0
|
|
|
|
|
+ )
|
|
|
await asyncio.sleep(self.ARTICLE_PAGE_INTERVAL_SEC)
|
|
await asyncio.sleep(self.ARTICLE_PAGE_INTERVAL_SEC)
|
|
|
|
|
|
|
|
|
|
+ else:
|
|
|
|
|
+ # while 正常结束(非 break)→ 达到最大翻页数
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "gh_id=%s 深翻达到最大翻页数 %d,下次调度从当前游标继续",
|
|
|
|
|
+ gh_id,
|
|
|
|
|
+ self.MAX_DEEP_CRAWL_PAGES,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
return total
|
|
return total
|
|
|
|
|
|
|
|
- async def _incremental_crawl(self, gh_id: str, _cursor: str | None = None) -> int:
|
|
|
|
|
|
|
+ async def _incremental_crawl(self, gh_id: str) -> int:
|
|
|
"""增量更新:只取第一页即停"""
|
|
"""增量更新:只取第一页即停"""
|
|
|
articles, _, _, _ = await fetch_article_page(gh_id, None)
|
|
articles, _, _, _ = await fetch_article_page(gh_id, None)
|
|
|
if not articles:
|
|
if not articles:
|