feat(scraper): WA群抓取(type=1真机验证,223群)+群成员派生(12840成员/750群)
- wa-groups 实体:businessFriendList type=1 真机验证返回群,行结构是好友字段名 (friendUid=@g.us 群ID/nickName=群名),专用 _normalize_wa_group_row - backfill_group_members.py:无独立成员API,从群消息派生(talker=成员,sender_name=昵称) - full_sync_all.sh:+4步(5b WA群/语音后录音重链接/8b 群消息+成员/消息上限99999) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""从群消息派生 group_member(发言者=成员)。
|
||||||
|
|
||||||
|
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
|
||||||
|
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其
|
||||||
|
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。
|
||||||
|
|
||||||
|
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为,
|
||||||
|
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \
|
||||||
|
.venv/Scripts/python.exe -m scripts.backfill_group_members
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
from app.db.base import async_engine, async_session_maker
|
||||||
|
from app.db.models import GroupMember
|
||||||
|
from app.storage.repos import _upsert_one
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger("backfill_group_members")
|
||||||
|
|
||||||
|
# max(timestamp) 裸列:SQLite 保证 sender_name 取自 timestamp 最大的那行
|
||||||
|
SQL = sa_text("""
|
||||||
|
SELECT room_id, talker_id, sender_name, max(timestamp) AS last_ts, count(*) AS msg_count
|
||||||
|
FROM message
|
||||||
|
WHERE channel = 'wechat' AND room_id IS NOT NULL AND room_id != ''
|
||||||
|
AND talker_id IS NOT NULL AND talker_id != ''
|
||||||
|
GROUP BY room_id, talker_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
rows = (await conn.execute(SQL)).fetchall()
|
||||||
|
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
for room_id, talker_id, sender_name, last_ts, msg_count in rows:
|
||||||
|
await _upsert_one(session, GroupMember, {
|
||||||
|
"channel": "wechat",
|
||||||
|
"room_id": room_id,
|
||||||
|
"member_id": talker_id,
|
||||||
|
"nickname": sender_name,
|
||||||
|
# raw_json 附加派生统计,抽屉可见
|
||||||
|
"raw_json": {
|
||||||
|
"derived_from": "group_messages",
|
||||||
|
"msg_count": msg_count,
|
||||||
|
"last_msg_ts": last_ts,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await session.commit()
|
||||||
|
log.info(f"DONE: group_member upserted={len(rows)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -18,14 +18,21 @@ echo "$(date) --- 4/10 WA customers ---"
|
|||||||
$PY scripts/scrape_via_page.py --entity wa-friends
|
$PY scripts/scrape_via_page.py --entity wa-friends
|
||||||
echo "$(date) --- 5/10 WeChat groups ---"
|
echo "$(date) --- 5/10 WeChat groups ---"
|
||||||
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
|
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
|
||||||
|
echo "$(date) --- 5b/10 WA groups ---"
|
||||||
|
$PY scripts/scrape_via_page.py --entity wa-groups
|
||||||
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
|
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
|
||||||
$PY scripts/scrape_via_page.py --entity voice-calls
|
$PY scripts/scrape_via_page.py --entity voice-calls
|
||||||
|
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
|
||||||
|
$PY -m app.sync.cli sync --entity call_recordings
|
||||||
echo "$(date) --- 7/10 Voice clips ---"
|
echo "$(date) --- 7/10 Voice clips ---"
|
||||||
$PY scripts/scrape_via_page.py --entity voice-clips
|
$PY scripts/scrape_via_page.py --entity voice-clips
|
||||||
echo "$(date) --- 8/10 WeChat messages ---"
|
echo "$(date) --- 8/10 WeChat messages ---"
|
||||||
$PY scripts/scrape_via_page.py --entity messages --max-conversations 1000
|
$PY scripts/scrape_via_page.py --entity messages --max-conversations 99999
|
||||||
|
echo "$(date) --- 8b/10 WeChat group messages + members ---"
|
||||||
|
$PY scripts/scrape_via_page.py --entity group-messages --max-conversations 99999
|
||||||
|
$PY -m scripts.backfill_group_members
|
||||||
echo "$(date) --- 9/10 WA messages ---"
|
echo "$(date) --- 9/10 WA messages ---"
|
||||||
$PY scripts/scrape_via_page.py --entity wa-messages --max-conversations 500
|
$PY scripts/scrape_via_page.py --entity wa-messages --max-conversations 99999
|
||||||
echo "$(date) --- 10/10 Media download (audio+images) ---"
|
echo "$(date) --- 10/10 Media download (audio+images) ---"
|
||||||
$PY scripts/scrape_via_page.py --entity media
|
$PY scripts/scrape_via_page.py --entity media
|
||||||
|
|
||||||
|
|||||||
@@ -946,6 +946,84 @@ async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
|
|||||||
return total_fetched, total_upserted
|
return total_fetched, total_upserted
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_wa_group_row(row: dict) -> dict | None:
|
||||||
|
"""businessFriendList type=1 行 → ChatGroup 形状(真机确认 2026-08-20)。
|
||||||
|
|
||||||
|
type=1 返回的是好友字段名结构:friendUid=群 id(@g.us 后缀)、nickName=
|
||||||
|
群名、uid=员工 WA 号、salesNickName=员工 WA 昵称——与微信群的
|
||||||
|
friendWxId/friendWxNickname 完全不同,故不能复用 _normalize_chat_group。
|
||||||
|
非 @g.us 行(异常混入)返回 None。
|
||||||
|
"""
|
||||||
|
friend_uid = str(row.get("friendUid") or "")
|
||||||
|
if not friend_uid.endswith("@g.us"):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"room_id": friend_uid,
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"room_name": row.get("nickName"),
|
||||||
|
"owner_account_id": row.get("uid"),
|
||||||
|
"owner_account_nickname": row.get("salesNickName"),
|
||||||
|
"owner_user_id": row.get("userId"),
|
||||||
|
"owner_user_phone": row.get("salePhone"),
|
||||||
|
"message_count": row.get("messageGroupCount"),
|
||||||
|
"raw_json": row,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
|
||||||
|
"""Scrape WhatsApp GROUPS via browser fetch POST businessFriendList type=1.
|
||||||
|
|
||||||
|
Same endpoint as WA friends with ``type:"1"`` — live-verified 2026-08-20:
|
||||||
|
rows carry friendUid ending ``@g.us`` (group id), nickName = group name.
|
||||||
|
Envelope: data.overviews; upsert to chat_group (channel='whatsapp').
|
||||||
|
"""
|
||||||
|
from app.db.base import async_engine
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
from app.storage.repos import upsert_chat_group
|
||||||
|
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
row = (await conn.execute(sa_text(
|
||||||
|
"SELECT dept_id FROM department WHERE parent_id IS NULL LIMIT 1"
|
||||||
|
))).first()
|
||||||
|
department_id = row[0] if row else ""
|
||||||
|
if not department_id:
|
||||||
|
logger.warning("wa-groups: no root department in DB — abort")
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
page_size = 50
|
||||||
|
total_fetched = 0
|
||||||
|
total_upserted = 0
|
||||||
|
async with PageScraper(cookie_str) as scraper:
|
||||||
|
page = await _open_fetch_page(scraper)
|
||||||
|
page_num = 1
|
||||||
|
while page_num <= max_pages:
|
||||||
|
body = await _browser_fetch(
|
||||||
|
page,
|
||||||
|
INTERCEPT_PATHS["wa_friend"],
|
||||||
|
json_body={
|
||||||
|
"searchInfo": "",
|
||||||
|
"friendSearchInfo": "",
|
||||||
|
"page": page_num,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"departmentId": department_id,
|
||||||
|
"type": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = _rows_overviews(body)
|
||||||
|
records = [rec for r in rows if (rec := _normalize_wa_group_row(r)) is not None]
|
||||||
|
total_fetched += len(records)
|
||||||
|
await _upsert_all(upsert_chat_group, records)
|
||||||
|
total_upserted += len(records)
|
||||||
|
logger.info(f" wa-groups page {page_num}: {len(rows)} rows, {len(records)} groups")
|
||||||
|
if len(rows) < page_size:
|
||||||
|
break
|
||||||
|
page_num += 1
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
|
logger.info(f"DONE: wa-groups fetched={total_fetched}, upserted={total_upserted}")
|
||||||
|
return total_fetched, total_upserted
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Voice call records (navigation + interception, both WeChat + WA pages)
|
# Voice call records (navigation + interception, both WeChat + WA pages)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1299,6 +1377,7 @@ async def main():
|
|||||||
"accounts", # 微信员工账号 (wechatlist page interception)
|
"accounts", # 微信员工账号 (wechatlist page interception)
|
||||||
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
||||||
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
||||||
|
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
|
||||||
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
||||||
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
||||||
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
||||||
@@ -1337,6 +1416,8 @@ async def main():
|
|||||||
await scrape_wa_accounts(cookie, args.max_pages)
|
await scrape_wa_accounts(cookie, args.max_pages)
|
||||||
elif args.entity == "wa-friends":
|
elif args.entity == "wa-friends":
|
||||||
await scrape_wa_friends(cookie, args.max_pages)
|
await scrape_wa_friends(cookie, args.max_pages)
|
||||||
|
elif args.entity == "wa-groups":
|
||||||
|
await scrape_wa_groups(cookie, args.max_pages)
|
||||||
elif args.entity == "voice-calls":
|
elif args.entity == "voice-calls":
|
||||||
await scrape_voice_calls(cookie, args.max_pages)
|
await scrape_voice_calls(cookie, args.max_pages)
|
||||||
elif args.entity == "voice-clips":
|
elif args.entity == "voice-clips":
|
||||||
|
|||||||
Reference in New Issue
Block a user