Files
yunke/yunke-server/tests/test_scrape_moments.py
T
浅念 90c28f4c7b 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>
2026-08-25 14:07:39 +08:00

267 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""scrape_moments 归一化 + 翻页内核 + 图片本地化测试(不启浏览器)。
云客接口事实(2026-08-25 真机抓包,已验证):
``POST /wechatweb/wechatFriendster/getFriendsterInfo``,form 小驼峰字段
``startTime``/``endTime``(空串)/``pageNum``(从1)/``pageSize``/``wechatId``;
信封 ``data.data[]`` + ``data.hashNext``;行的 ``createTime`` 是**秒**
(与消息接口的 ms 不同,归一化 ×1000)。
"""
from __future__ import annotations
import hashlib
import httpx
import respx
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import Media, MomentsPost
from app.scraper.media import download_moments_images
from app.session.yunke_client import YunkeClient
from app.storage.object_store import ObjectStore
from scripts.scrape_via_page import _iter_moments_pages, _normalize_moments
RAW = { # 2026-08-25 真机抓包样本(截取关键字段)
"id": "69e082a3c3524a7fe3ac53b4", "wechatId": "wxid_wo97u1u2x11w22",
"snsId": "-3546042415332912522", "friendsterName": "Yoyo-HESU",
"friendsterWXId": "wxid_wo97u1u2x11w22",
"friendsterWXIdImg": "https://wx.qlogo.cn/mmhead/ver_1/vsHn.../0",
"content": "Welcome to HESU", "type": "1", "createTime": "1776302051", # 秒!
"praise": [{"name": "E**U", "praiseId": "wxid***r822", "praiseImg": "https://wx.qlogo.cn/p/0"}],
"comment": [],
"imgUrlsList": ["https://wx.oss.yunkecn.com/friendstr/8m0atd/16/b1/ea/xx.jpg"],
}
# ---------------------------------------------------------------------------
# _normalize_moments
# ---------------------------------------------------------------------------
def test_normalize_moments_converts_seconds_to_ms():
rec = _normalize_moments(RAW)
assert rec["id"] == "69e082a3c3524a7fe3ac53b4"
assert rec["create_time"] == 1776302051 * 1000 # 秒→ms
assert rec["author_name"] == "Yoyo-HESU"
assert rec["praise_json"][0]["name"] == "E**U"
assert rec["image_urls"] == RAW["imgUrlsList"]
assert rec["raw_json"]["snsId"] == "-3546042415332912522"
def test_normalize_moments_null_safe():
rec = _normalize_moments({})
assert rec["id"] is None # 缺 id 行由调用方丢弃
assert rec["create_time"] is None
assert rec["praise_json"] == []
assert rec["image_urls"] == []
def test_normalize_moments_bad_create_time_is_none():
rec = _normalize_moments({**RAW, "createTime": "not-a-number"})
assert rec["create_time"] is None
assert rec["id"] == RAW["id"] # 其余字段不受影响
# ---------------------------------------------------------------------------
# _iter_moments_pages(翻页内核,fetch 回调注入)
# ---------------------------------------------------------------------------
def _page(rows: list[dict], hash_next: bool) -> dict:
return {"code": "10000", "data": {"hashNext": hash_next, "data": rows}}
async def _noop_sleep(_seconds: float) -> None:
return None
async def test_iter_moments_pages_accumulates_two_pages_and_stops():
"""第一页 hashNext=True → 继续;第二页 hashNext=False → 停(恰好 2 次请求)。"""
bodies: list[dict] = []
async def fetch(body: dict) -> dict:
bodies.append(body)
return _page([RAW] if body["pageNum"] == 1 else [{**RAW, "id": "second"}], body["pageNum"] == 1)
records = await _iter_moments_pages(
fetch, "wxid_wo97u1u2x11w22", page_size=100, max_pages=50, sleep=_noop_sleep
)
assert [r["id"] for r in records] == [RAW["id"], "second"]
assert records[0]["create_time"] == 1776302051 * 1000
# 恰好两次请求:hashNext=False 即止
assert len(bodies) == 2
# form 字段与真机抓包一致(小驼峰,pageNum 从 1 起)
assert bodies[0] == {
"startTime": "", "endTime": "", "pageNum": 1, "pageSize": 100,
"wechatId": "wxid_wo97u1u2x11w22",
}
assert bodies[1]["pageNum"] == 2
async def test_iter_moments_pages_stops_when_hash_next_falsy():
"""单页 hashNext=False → 只请求一次。"""
calls: list[int] = []
async def fetch(body: dict) -> dict:
calls.append(body["pageNum"])
return _page([RAW], hash_next=False)
records = await _iter_moments_pages(fetch, "wx", sleep=_noop_sleep)
assert len(records) == 1
assert calls == [1]
async def test_iter_moments_pages_respects_max_pages_cap():
"""hashNext 永真 → max_pages 页上限防死循环(4h 调度浅扫 max-pages 3 靠它)。"""
calls: list[int] = []
async def fetch(body: dict) -> dict:
calls.append(body["pageNum"])
return _page([{**RAW, "id": f"id-{body['pageNum']}"}], hash_next=True)
records = await _iter_moments_pages(fetch, "wx", max_pages=3, sleep=_noop_sleep)
assert len(calls) == 3
assert [r["id"] for r in records] == ["id-1", "id-2", "id-3"]
async def test_iter_moments_pages_drops_null_id_rows_and_bad_envelope():
"""缺 id 行丢弃;data 非 dict / data.data 非列表 → 停止不抛。"""
async def fetch(body: dict) -> dict:
if body["pageNum"] == 1:
return _page([RAW, {"content": "no id"}], hash_next=True)
return _page([], hash_next=False)
records = await _iter_moments_pages(fetch, "wx", sleep=_noop_sleep)
assert [r["id"] for r in records] == [RAW["id"]]
async def fetch_bad(_body: dict) -> dict:
return {"code": "10000", "data": None}
assert await _iter_moments_pages(fetch_bad, "wx", sleep=_noop_sleep) == []
# ---------------------------------------------------------------------------
# download_moments_images(朋友圈图片本地化)
# ---------------------------------------------------------------------------
BASE = "https://crm2.yunkecn.com"
OSS_A = "https://wx.oss.yunkecn.com/friendstr/8m0atd/16/b1/ea/a.jpg"
OSS_B = "https://wx.oss.yunkecn.com/friendstr/8m0atd/16/b1/ea/b.jpg"
async def _make_engine():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine
def _primed_client() -> YunkeClient:
return YunkeClient(base_url=BASE, userid="a" * 32, company="8m0atd")
@respx.mock
async def test_download_moments_images_localizes_all_urls(tmp_path):
"""一帖两图(image_urls JSON 数组)全部下载入 media 表,media_type='image'。"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
blob_a, blob_b = b"\xff\xd8\xff\xe0jpg-a", b"\xff\xd8\xff\xe0jpg-b"
respx.get(OSS_A).mock(return_value=httpx.Response(200, content=blob_a))
respx.get(OSS_B).mock(return_value=httpx.Response(200, content=blob_b))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(MomentsPost(
id="p1", wechat_id="wx1", image_urls=[OSS_A, OSS_B],
))
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_moments_images(client, store, session)
await session.commit()
assert (attempted, localized) == (2, 2)
for blob, url in ((blob_a, OSS_A), (blob_b, OSS_B)):
sha = hashlib.sha256(blob).hexdigest()
assert (tmp_path / sha[:2] / f"{sha}.jpg").is_file()
async with SessionLocal() as session:
media = (await session.execute(
sa.select(Media).where(Media.media_hash == sha)
)).scalar_one()
assert media.media_type == "image"
assert media.channel == "wechat"
assert media.original_url == url
assert media.mime_type == "image/jpeg"
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_download_moments_images_idempotent_via_original_url(tmp_path):
"""MomentsPost 无 media_hash 回链列 → 已有同 original_url 的 Media 行即跳过
(第二轮 attempted=0,不触网)。"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
respx.get(OSS_A).mock(return_value=httpx.Response(200, content=b"blob"))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(MomentsPost(
id="p1", wechat_id="wx1", image_urls=[OSS_A],
))
await session.commit()
async with SessionLocal() as session:
first = await download_moments_images(client, store, session)
await session.commit()
assert first == (1, 1)
async with SessionLocal() as session:
second = await download_moments_images(client, store, session)
await session.commit()
assert second == (0, 0)
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_download_moments_images_skips_non_http_and_survives_failure(tmp_path):
"""非 http 条目跳过;下载 500 只计数不抛(下轮重试)。"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
respx.get(OSS_A).mock(return_value=httpx.Response(500))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(MomentsPost(
id="p1", wechat_id="wx1", image_urls=[OSS_A, "not-a-url"],
))
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_moments_images(client, store, session)
await session.commit()
assert (attempted, localized) == (1, 0)
async with SessionLocal() as session:
assert (await session.execute(sa.select(Media))).scalars().all() == []
finally:
await client.aclose()
await engine.dispose()