feat(scraper): moments 实体(浏览器fetch getFriendsterInfo,秒→ms,hashNext翻页,接入full_sync+4h调度+图片media)

- scrape_via_page:_normalize_moments(createTime秒×1000,缺id丢弃)+
  _iter_moments_pages 翻页内核(fetch回调注入,sleep可注入,hashNext假/
  信封异常/max_pages 上限即止)+ scrape_moments(逐员工遍历wechat_account,
  form小驼峰,首页code≠10000告警cookie失效,换页重试惯例)+CLI注册moments
- media:download_moments_images(image_urls JSON数组展开去重,MomentsPost无
  回链列→用Media.original_url做已下载标记,分片IN防SQLite变量上限,批间commit)
  接入scrape_media第4源
- full_sync_all.sh:9c步moments(media前,同轮图片即可本地化)+汇总加moments计数
- scheduler:4h messages job 末尾追加 moments --max-pages 3 浅扫(全量翻页
  由每日full承担);测试断言7条命令序列
- tests/test_scrape_moments.py:归一化(秒→ms/空安全/坏createTime)+翻页内核
  (两页累积停止/hashNext假单页/max_pages防死循环/丢id行/坏信封)+图片本地化
  (一帖两图/幂等跳过/非http跳过+500容错);全套440测试通过

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 14:06:45 +08:00
parent b7669199f8
commit 90c28f4c7b
6 changed files with 549 additions and 15 deletions
+157 -1
View File
@@ -16,6 +16,7 @@
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity voice-calls
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity voice-clips
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity wa-messages
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity moments
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity media
Cookie 从 .env 的 YK_COOKIE 读取。
@@ -27,6 +28,7 @@ import asyncio
import json
import logging
import os
from typing import Any
from urllib.parse import urlparse
logger = logging.getLogger("scrape_via_page")
@@ -44,6 +46,7 @@ INTERCEPT_PATHS = {
"wa_friend": "/qqweb/WhatsAppUser/businessFriendList", # WA friends + groups
"wa_user": "/qqweb/WhatsAppUser/businessSalesList", # WA accounts
"msg_chats": "/wechatweb/msg/chats", # chat messages
"friendster": "/wechatweb/wechatFriendster/getFriendsterInfo", # 员工朋友圈
}
@@ -1377,6 +1380,153 @@ async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
return total_fetched, total_upserted
# ---------------------------------------------------------------------------
# 员工朋友圈 Moments (browser fetch, per employee)
# ---------------------------------------------------------------------------
def _normalize_moments(record: dict[str, Any]) -> dict[str, Any]:
"""云客朋友圈行(getFriendsterInfo data.data[]) → moments_post 记录。
真机抓包(2026-08-25):行的 ``createTime`` 是**秒**字符串(与消息接口的
ms 不同),统一 ×1000 成 ms epoch;缺 id 行返回 id=None 由调用方丢弃。
"""
ct = record.get("createTime")
try:
ct_ms = int(ct) * 1000 if ct is not None else None
except (TypeError, ValueError):
ct_ms = None
return {
"id": record.get("id"),
"wechat_id": record.get("wechatId"),
"sns_id": record.get("snsId"),
"author_name": record.get("friendsterName"),
"author_wxid": record.get("friendsterWXId"),
"author_avatar": record.get("friendsterWXIdImg"),
"content": record.get("content"),
"type_raw": str(record.get("type")) if record.get("type") is not None else None,
"create_time": ct_ms,
"praise_json": record.get("praise") or [],
"comment_json": record.get("comment") or [],
"image_urls": record.get("imgUrlsList") or [],
"raw_json": record,
}
async def _iter_moments_pages(
fetch,
wechat_id: str,
page_size: int = 100,
max_pages: int = 50,
sleep=None,
):
"""getFriendsterInfo 翻页内核(fetch 回调注入,测试不启浏览器)。
``fetch(body) -> 响应 dict``;body 由本内核按真机抓包构造(form 小驼峰:
``startTime``/``endTime`` 空串、``pageNum`` 从 1、``pageSize``、
``wechatId``)。逐页 ``data.data[]`` → :func:`_normalize_moments`(丢
id=None 行)累积;``data.hashNext`` 假 / 信封异常 / 达 ``max_pages`` 页
上限即止(4h 调度浅扫 max-pages 3 靠此上限)。页间 sleep 1s。
"""
if sleep is None:
sleep = asyncio.sleep
records: list[dict] = []
page_num = 1
while page_num <= max_pages:
body = {
"startTime": "",
"endTime": "",
"pageNum": page_num,
"pageSize": page_size,
"wechatId": wechat_id,
}
resp = await fetch(body)
data = resp.get("data") if isinstance(resp, dict) else None
rows = data.get("data") if isinstance(data, dict) else None
if not isinstance(rows, list):
break
for row in rows:
if not isinstance(row, dict):
continue
rec = _normalize_moments(row)
if rec.get("id") is not None:
records.append(rec)
if not data.get("hashNext"):
break
page_num += 1
await sleep(1)
return records
async def scrape_moments(cookie_str: str, max_pages: int = 50, page_size: int = 100):
"""员工朋友圈:浏览器直发 getFriendsterInfo(form-encoded),逐员工翻页。
结构镜像 scrape_wa_groups;差异在**逐员工**遍历(端点按 wechatId 过滤,
微信渠道独有实体,wechat_account 表全量)。翻页开关是 ``data.hashNext``
(不是短页判断);批量 upsert 走 :func:`upsert_moments_posts`(收整列表)。
返回总计 dict。
"""
from sqlalchemy import text as sa_text
from app.db.base import async_engine, async_session_maker
from app.storage.repos import upsert_moments_posts
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT wx_id FROM wechat_account WHERE wx_id IS NOT NULL AND wx_id != ''"
))
employees = [row[0] for row in result.fetchall()]
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
for i, wx_id in enumerate(employees):
records: list[dict] = []
async def _fetch(body: dict):
resp = await _browser_fetch(
page, INTERCEPT_PATHS["friendster"], form_body=body
)
# cookie 失效告警惯例(同 _fetch_pages 首页 code 校验):
# 健康 list 端点 code=10000
if body["pageNum"] == 1 and isinstance(resp, dict):
code = resp.get("code")
if code is not None and str(code) not in ("10000",):
logger.warning(
f"⚠️ {INTERCEPT_PATHS['friendster']} code={code} "
f"({resp.get('message')}) — cookie 可能已过期/失效,"
"请从浏览器重新复制 YK_COOKIE 到 .env!"
)
return resp
try:
records = await _iter_moments_pages(
_fetch, wx_id, page_size=page_size, max_pages=max_pages
)
except Exception as e:
logger.warning(f" [{i+1}/{len(employees)}] {wx_id[:16]}.. failed: {e}")
# 换页重试惯例(同 scrape_voice_clips):该员工放弃,继续下一个
try:
await page.close()
except Exception:
pass
page = await _open_fetch_page(scraper)
continue
total_fetched += len(records)
if records:
async with async_session_maker() as session:
await upsert_moments_posts(session, records)
await session.commit()
total_upserted += len(records)
logger.info(f"DONE moments {wx_id} +{len(records)}")
await asyncio.sleep(2 + (i % 3))
logger.info(f"DONE: moments fetched={total_fetched}, upserted={total_upserted}")
return {"fetched": total_fetched, "upserted": total_upserted}
# ---------------------------------------------------------------------------
# Voice call records (navigation + interception, both WeChat + WA pages)
# ---------------------------------------------------------------------------
@@ -1841,6 +1991,7 @@ async def scrape_media(cookie_str: str, batch_size: int = 50):
* call recordings (phone_call.record_file OSS URLs)
* voice clip audio (voice_clip.oss_file_name OSS URLs)
* message images/videos (message.file_url / file_th_url / file_hd_url)
* moments images (moments_post.image_urls OSS URLs, 一帖多图)
These are plain OSS CDN downloads (not CRM API calls), so httpx direct
fetching is safe. The YunkeClient is only used as the httpx transport.
@@ -1850,6 +2001,7 @@ async def scrape_media(cookie_str: str, batch_size: int = 50):
from app.scraper.media import (
download_call_recordings,
download_message_images,
download_moments_images,
download_voice_clip_audio,
)
from app.session.yunke_client import YunkeClient
@@ -1872,6 +2024,7 @@ async def scrape_media(cookie_str: str, batch_size: int = 50):
totals["call_recordings"] = await download_call_recordings(client, store, session, batch_size=batch_size)
totals["voice_clip_audio"] = await download_voice_clip_audio(client, store, session, batch_size=batch_size)
totals["message_images"] = await download_message_images(client, store, session, batch_size=batch_size)
totals["moments_images"] = await download_moments_images(client, store, session, batch_size=batch_size)
finally:
await client.aclose()
@@ -1899,13 +2052,14 @@ async def main():
"voice-clips", # 长语音 (browser fetch call/list per employee)
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
"wa-group-messages", # WA群消息 (chatRecords with friendUid=@g.us)
"moments", # 员工朋友圈 (browser fetch getFriendsterInfo)
"media", # OSS媒体下载 (录音/长语音音频/消息图片)
],
help="抓取实体",
)
parser.add_argument("--max-employees", type=int, default=100, help="最多遍历多少员工 (friends/groups/voice-clips)")
parser.add_argument("--max-conversations", type=int, default=100, help="消息模式:最多遍历多少对话 (messages/wa-messages/group-messages)")
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls)")
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
args = parser.parse_args()
@@ -1945,6 +2099,8 @@ async def main():
await scrape_wa_messages(cookie, args.max_conversations, args.full_history)
elif args.entity == "wa-group-messages":
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history)
elif args.entity == "moments":
await scrape_moments(cookie, args.max_pages)
elif args.entity == "media":
await scrape_media(cookie, args.batch_size)