416 lines
17 KiB
Python
416 lines
17 KiB
Python
"""Explicit async CDP session. No global page, no daemon, no UI business actions."""
|
||
|
||
import asyncio
|
||
import json
|
||
import uuid
|
||
from contextlib import suppress
|
||
from urllib.parse import urlsplit
|
||
|
||
from account_store import decode
|
||
from douyin_im import EXPRESSION
|
||
from follow_user import validate_uid
|
||
from get_current_user import BROWSER_SCRIPT, business_user, parse_user_response
|
||
from subscribe_notifications import (
|
||
INSTALL,
|
||
WAIT,
|
||
detail_request_script,
|
||
history_request_script,
|
||
works_request_script,
|
||
)
|
||
|
||
|
||
class SessionError(RuntimeError):
|
||
pass
|
||
|
||
|
||
FOLLOW_JS = r"""async p => {
|
||
let sent=false;
|
||
if(location.origin!=='https://www.douyin.com') return {status:'failed',code:'WRONG_ORIGIN'};
|
||
try {
|
||
const get=async path=>{const r=await fetch(path,{credentials:'include',signal:AbortSignal.timeout(15000)});
|
||
const v=await r.json(); if(!r.ok||v.status_code!==0) throw Error('READ_FAILED'); return v;};
|
||
const prefix='?device_platform=webapp&aid=6383&channel=channel_pc_web';
|
||
const self=await get('/aweme/v1/web/user/profile/self/'+prefix);
|
||
if(String(self.user?.uid)!==p.expected) throw Error('IDENTITY_MISMATCH');
|
||
if(p.target===p.expected) throw Error('SELF_TARGET');
|
||
const path='/aweme/v1/web/user/profile/other/'+prefix+'&user_id='+p.target;
|
||
const profile=(await get(path)).user;
|
||
if(String(profile?.uid)!==p.target) throw Error('TARGET_MISMATCH');
|
||
if(![0,1,2].includes(profile.follow_status)) throw Error('UNKNOWN_FOLLOW_STATE');
|
||
if(profile.follow_status!==0 || p.check) return {status:'succeeded',follow_status:profile.follow_status,action:p.check?'checked':'already_following'};
|
||
sent=true;
|
||
const r=await fetch('/aweme/v1/web/commit/follow/user/'+prefix,{method:'POST',credentials:'include',
|
||
headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},
|
||
body:new URLSearchParams({user_id:p.target,type:'1'}),signal:AbortSignal.timeout(20000)});
|
||
const result=await r.json();
|
||
if(r.ok && typeof result.status_code==='number' && result.status_code!==0)
|
||
return {status:'failed',code:result.status_code};
|
||
if(!r.ok || result.status_code!==0 || ![1,2].includes(result.follow_status)) return {status:'unknown',code:'UNCONFIRMED'};
|
||
const verify=(await get(path)).user;
|
||
return {status:String(verify?.uid)===p.target && [1,2].includes(verify.follow_status)?'succeeded':'unknown',action:'followed'};
|
||
} catch(e) {
|
||
if(sent) {
|
||
try {
|
||
const get=async path=>{const r=await fetch(path,{credentials:'include',signal:AbortSignal.timeout(10000)});return await r.json();};
|
||
const q='?device_platform=webapp&aid=6383&channel=channel_pc_web';
|
||
const me=await get('/aweme/v1/web/user/profile/self/'+q);
|
||
if(me.status_code===0 && String(me.user?.uid)===p.expected) {
|
||
const other=await get('/aweme/v1/web/user/profile/other/'+q+'&user_id='+p.target);
|
||
if(other.status_code===0 && String(other.user?.uid)===p.target && [1,2].includes(other.user.follow_status))
|
||
return {status:'succeeded',action:'verified_after_timeout'};
|
||
}
|
||
} catch(_) {}
|
||
}
|
||
return {status:sent?'unknown':'failed',code:['READ_FAILED','IDENTITY_MISMATCH','SELF_TARGET','TARGET_MISMATCH','UNKNOWN_FOLLOW_STATE'].includes(e.message)?e.message:'REQUEST_FAILED'};
|
||
}
|
||
}"""
|
||
|
||
|
||
def validate_endpoint(endpoint):
|
||
try:
|
||
url = urlsplit(endpoint)
|
||
valid = (
|
||
url.scheme == "http"
|
||
and url.hostname in ("127.0.0.1", "localhost", "::1")
|
||
and 0 < url.port < 65536
|
||
)
|
||
except (ValueError, TypeError):
|
||
raise ValueError("CDP 地址或端口无效") from None
|
||
if (
|
||
not valid
|
||
or url.username
|
||
or url.password
|
||
or url.query
|
||
or url.fragment
|
||
or url.path not in ("", "/")
|
||
):
|
||
raise ValueError("CDP 必须显式指定本机 http://127.0.0.1:端口,不允许远程地址")
|
||
return endpoint
|
||
|
||
|
||
class Session:
|
||
def __init__(self, playwright, endpoint, expected_uid, page_url=None):
|
||
self.playwright = playwright
|
||
self.endpoint = validate_endpoint(endpoint)
|
||
self.uid = validate_uid(expected_uid) if expected_uid is not None else None
|
||
self.page_url = page_url
|
||
self.browser = None
|
||
self.page = None
|
||
self.key = "__douyin_accounts_" + uuid.uuid4().hex
|
||
|
||
async def connect(self, discover=False):
|
||
if not self.browser or not self.browser.is_connected():
|
||
try:
|
||
self.browser = await self.playwright.chromium.connect_over_cdp(
|
||
self.endpoint, timeout=15000
|
||
)
|
||
except Exception:
|
||
raise SessionError("浏览器未连接,请启动账号浏览器") from None
|
||
if len(self.browser.contexts) != 1:
|
||
raise SessionError("浏览器上下文不唯一,拒绝自动选择")
|
||
pages = []
|
||
for page in self.browser.contexts[0].pages:
|
||
url = urlsplit(page.url)
|
||
if (
|
||
url.scheme == "https"
|
||
and url.netloc == "www.douyin.com"
|
||
and (
|
||
not url.path.startswith("/user/")
|
||
or url.path.rstrip("/") == "/user/self"
|
||
)
|
||
and (self.page_url is None or page.url == self.page_url)
|
||
):
|
||
pages.append(page)
|
||
if len(pages) != 1:
|
||
raise SessionError(
|
||
"请只保留一个抖音首页/自身页标签;不会自动选择其他账号或他人主页"
|
||
)
|
||
self.page = pages[0]
|
||
if discover and self.uid is None:
|
||
return await self.profile()
|
||
return await self.identity()
|
||
|
||
async def evaluate(self, expression, arg=None, *, main_world=False):
|
||
if not self.page or self.page.is_closed():
|
||
raise SessionError("账号页面已关闭")
|
||
try:
|
||
async with asyncio.timeout(55):
|
||
return await self.page.evaluate(
|
||
expression, arg, isolated_context=not main_world
|
||
)
|
||
except Exception:
|
||
raise SessionError(
|
||
"页面调用失败:请检查登录、网络、页面刷新或 SDK 变化"
|
||
) from None
|
||
|
||
async def profile(self):
|
||
try:
|
||
user = business_user(
|
||
parse_user_response(
|
||
await self.evaluate(BROWSER_SCRIPT, main_world=True)
|
||
)
|
||
)
|
||
except (RuntimeError, ValueError, TypeError, AttributeError):
|
||
raise SessionError(
|
||
"请在打开的浏览器中手动登录,登录后会自动读取账号信息"
|
||
) from None
|
||
return {
|
||
**user,
|
||
"nickname": user.get("nickname") or "",
|
||
"avatar": user.get("avatar_url") or "",
|
||
}
|
||
|
||
def require_bound(self):
|
||
if self.uid is None:
|
||
raise SessionError("账号尚未完成登录绑定,禁止执行业务操作")
|
||
|
||
async def identity(self):
|
||
self.require_bound()
|
||
user = await self.profile()
|
||
if user.get("uid") != self.uid:
|
||
raise SessionError(
|
||
"身份未通过核验:请手动登录期望账号,不会自动登录或继续写入"
|
||
)
|
||
return user
|
||
|
||
async def json(self, expression, *, main_world=False):
|
||
value = await self.evaluate(expression, main_world=main_world)
|
||
return decode(value) if isinstance(value, str) else value
|
||
|
||
async def install(self):
|
||
await self.identity()
|
||
await self.uninstall()
|
||
result = await self.json(
|
||
INSTALL.replace("KEY", json.dumps(self.key)).replace(
|
||
"UID", json.dumps(self.uid)
|
||
),
|
||
main_world=True,
|
||
)
|
||
if not result.get("connected"):
|
||
raise SessionError("通知连接未就绪,整组已暂停")
|
||
|
||
async def uninstall(self):
|
||
if self.page and not self.page.is_closed():
|
||
with suppress(SessionError):
|
||
await self.evaluate(
|
||
"key => {window[key]?.dispose(); delete window[key];}",
|
||
self.key,
|
||
main_world=True,
|
||
)
|
||
|
||
async def wait(self):
|
||
return await self.json(
|
||
WAIT.replace("KEY", json.dumps(self.key)), main_world=True
|
||
)
|
||
|
||
async def details(self, ids):
|
||
await self.identity()
|
||
response = await self.json(detail_request_script(ids), main_world=True)
|
||
if response.get("status") != 200:
|
||
raise SessionError("通知详情 HTTP 失败,ID 已保存")
|
||
# Parse in Python, preserving 64-bit numeric IDs even when no *_str field is present.
|
||
try:
|
||
value = json.loads(response["body"])
|
||
except (KeyError, TypeError, ValueError):
|
||
raise SessionError("通知详情响应不是有效 JSON,ID 已保存") from None
|
||
if not isinstance(value, dict) or value.get("status_code") != 0:
|
||
code = value.get("status_code") if isinstance(value, dict) else None
|
||
code = code if type(code) is int else "未知"
|
||
raise SessionError(f"通知详情接口失败(业务码 {code}),ID 已保存")
|
||
if "notice_list_v2" not in value:
|
||
raise SessionError("通知详情响应缺少列表字段,ID 已保存")
|
||
# A successful null/empty/partial response means unavailable IDs, not a lost login/socket.
|
||
notices = value["notice_list_v2"] if value["notice_list_v2"] is not None else []
|
||
if not isinstance(notices, list):
|
||
raise SessionError("通知详情列表格式异常,ID 已保存")
|
||
if any(
|
||
not isinstance(n, dict) or str(n.get("user_id")) != self.uid
|
||
for n in notices
|
||
):
|
||
raise SessionError("通知详情身份不符")
|
||
if not {n.get("nid_str") or str(n.get("nid")) for n in notices}.issubset(ids):
|
||
raise SessionError("通知详情返回了未请求的 ID,已停止处理")
|
||
return notices
|
||
|
||
async def history_notices(self, stop_ids=None):
|
||
notices = {}
|
||
stop_ids = set(stop_ids or ())
|
||
min_time = max_time = 0
|
||
seen_cursors = set()
|
||
for _ in range(1000):
|
||
response = await self.json(
|
||
history_request_script(min_time, max_time), main_world=True
|
||
)
|
||
try:
|
||
status = int(response["status"])
|
||
payload = json.loads(response["body"])
|
||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
raise SessionError("历史通知响应格式无效") from exc
|
||
if status != 200 or payload.get("status_code") != 0:
|
||
raise SessionError(
|
||
f"历史通知请求失败:HTTP {status},业务码 {payload.get('status_code')}"
|
||
)
|
||
rows = payload.get("notice_list_v2")
|
||
if rows is None:
|
||
rows = []
|
||
if not isinstance(rows, list) or any(
|
||
not isinstance(row, dict) for row in rows
|
||
):
|
||
raise SessionError("历史通知列表格式无效")
|
||
reached_cache = False
|
||
for row in rows:
|
||
if str(row.get("user_id")) != self.uid:
|
||
raise SessionError("历史通知所属身份不符")
|
||
nid = validate_uid(row.get("nid_str") or row.get("nid"))
|
||
reached_cache = reached_cache or nid in stop_ids
|
||
notices.setdefault(nid, row)
|
||
if reached_cache or not payload.get("has_more"):
|
||
return list(notices.values())
|
||
cursor = (payload.get("min_time"), payload.get("max_time"))
|
||
if cursor in seen_cursors or cursor == (min_time, max_time):
|
||
raise SessionError("历史通知分页游标未推进")
|
||
if any(type(value) not in (int, float) for value in cursor):
|
||
raise SessionError("历史通知分页游标格式无效")
|
||
seen_cursors.add(cursor)
|
||
min_time, max_time = cursor
|
||
raise SessionError("历史通知超过 50000 条,已停止以避免无限分页")
|
||
|
||
async def works(self, all_pages=False, stop_ids=None):
|
||
profile = await self.identity()
|
||
stop_ids = set(stop_ids or ())
|
||
sec_uid = profile.get("sec_uid") or profile.get("secUid")
|
||
if not isinstance(sec_uid, str) or not sec_uid:
|
||
raise SessionError("当前账号缺少作品列表身份参数")
|
||
works = {}
|
||
cursor = 0
|
||
seen_cursors = set()
|
||
pages = 1000 if all_pages or stop_ids else 1
|
||
for _ in range(pages):
|
||
response = await self.json(
|
||
works_request_script(sec_uid, cursor), main_world=True
|
||
)
|
||
try:
|
||
status = int(response["status"])
|
||
payload = json.loads(response["body"])
|
||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
raise SessionError("作品列表响应格式无效") from exc
|
||
if status != 200 or payload.get("status_code") != 0:
|
||
raise SessionError(
|
||
f"作品列表请求失败:HTTP {status},业务码 {payload.get('status_code')}"
|
||
)
|
||
rows = payload.get("aweme_list")
|
||
if rows is None:
|
||
rows = []
|
||
if not isinstance(rows, list) or any(
|
||
not isinstance(row, dict) for row in rows
|
||
):
|
||
raise SessionError("作品列表格式无效")
|
||
reached_cache = False
|
||
for row in rows:
|
||
author = row.get("author") or {}
|
||
if str(author.get("uid")) != self.uid:
|
||
raise SessionError("作品列表所属身份不符")
|
||
ident = validate_uid(row.get("aweme_id") or row.get("awemeId"))
|
||
reached_cache = reached_cache or ident in stop_ids
|
||
cover = ((row.get("video") or {}).get("cover") or {}).get(
|
||
"url_list"
|
||
) or []
|
||
if not cover and row.get("images"):
|
||
cover = (row["images"][0] or {}).get("url_list") or []
|
||
works.setdefault(
|
||
ident,
|
||
{
|
||
"aweme_id": ident,
|
||
"desc": row.get("desc") or "",
|
||
"create_time": row.get("create_time") or "",
|
||
"statistics": row.get("statistics") or {},
|
||
"cover": cover[0] if cover else "",
|
||
"business": row,
|
||
},
|
||
)
|
||
if (
|
||
reached_cache
|
||
or (not all_pages and not stop_ids)
|
||
or not payload.get("has_more")
|
||
):
|
||
return list(works.values())
|
||
next_cursor = payload.get("max_cursor")
|
||
if type(next_cursor) not in (int, float) or next_cursor < 0:
|
||
raise SessionError("作品列表分页游标格式无效")
|
||
if next_cursor in seen_cursors or next_cursor == cursor:
|
||
raise SessionError("作品列表分页游标未推进")
|
||
seen_cursors.add(next_cursor)
|
||
cursor = next_cursor
|
||
raise SessionError("作品列表超过 18000 条,已停止以避免无限分页")
|
||
|
||
async def follow(self, target, check=False):
|
||
self.require_bound()
|
||
validate_uid(target)
|
||
return await self.evaluate(
|
||
FOLLOW_JS,
|
||
{"target": target, "expected": self.uid, "check": check},
|
||
main_world=True,
|
||
)
|
||
|
||
async def im(
|
||
self,
|
||
action,
|
||
target,
|
||
text="",
|
||
confirm=False,
|
||
limit=20,
|
||
cursor="9223372036854775807",
|
||
):
|
||
self.require_bound()
|
||
validate_uid(target)
|
||
if (
|
||
action not in ("send", "history")
|
||
or type(limit) is not int
|
||
or not 1 <= limit <= 100
|
||
):
|
||
raise ValueError("私信操作或历史条数无效")
|
||
if (
|
||
not isinstance(cursor, str)
|
||
or not cursor.isascii()
|
||
or not cursor.isdecimal()
|
||
or len(cursor) > 19
|
||
or (len(cursor) == 19 and cursor > "9223372036854775807")
|
||
):
|
||
raise ValueError("历史游标无效")
|
||
if action == "send" and (
|
||
not isinstance(text, str) or not text.strip() or len(text) > 1000
|
||
):
|
||
raise ValueError("消息必须为 1..1000 字")
|
||
params = {
|
||
"action": action,
|
||
"uid": target,
|
||
"text": text,
|
||
"confirm": confirm,
|
||
"limit": limit,
|
||
"cursor": cursor,
|
||
}
|
||
expression = EXPRESSION.replace(
|
||
"__PARAMS__", json.dumps(params, ensure_ascii=True)
|
||
)
|
||
expression = expression.replace(
|
||
"if (String(profile.user.uid) === p.uid)",
|
||
f"if (String(profile.user.uid) !== {json.dumps(self.uid)}) fail('LOGIN_REQUIRED');\n if (String(profile.user.uid) === p.uid)",
|
||
)
|
||
return await self.json(expression, main_world=True)
|
||
|
||
async def disconnect(self):
|
||
await self.uninstall()
|
||
# CDP Browser.close disconnects the driver; it does not terminate an externally owned browser.
|
||
if self.browser:
|
||
with suppress(Exception):
|
||
await self.browser.close()
|
||
self.browser = None
|
||
self.page = None
|
||
|
||
async def close_browser(self):
|
||
if self.browser and self.browser.is_connected():
|
||
client = await self.browser.new_browser_cdp_session()
|
||
with suppress(Exception):
|
||
await client.send("Browser.close")
|
||
await self.disconnect()
|