Files
douyin-pc/src/follow_user.py
T

143 lines
5.7 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.
#!/usr/bin/env python3
"""直接传入数字 UID,通过接口关注;已关注则跳过,不打开目标主页。"""
import argparse
import json
import subprocess
import sys
from urllib.parse import urlencode
from cdp_explicit import add_cdp_args, configure, evaluate_script
from get_current_user import get_user_from_browser
PROFILE = "/aweme/v1/web/user/profile/other/"
FOLLOW = "/aweme/v1/web/commit/follow/user/"
PARAMS = {"device_platform": "webapp", "aid": 6383, "channel": "channel_pc_web"}
def validate_uid(uid: str) -> str:
if (
not isinstance(uid, str)
or not uid.isascii()
or not uid.isdecimal()
or uid.startswith("0")
):
raise ValueError("UID 必须为不带前导零的正整数数字字符串,不是主页链接或抖音号")
return uid
def request(path: str, params: dict, body: dict | None = None) -> dict:
url = path + "?" + urlencode({**PARAMS, **params})
expression = """new Promise((resolve, reject) => {
if (location.origin !== 'https://www.douyin.com')
return reject(new Error('请先选择已登录的抖音标签页'));
const xhr = new XMLHttpRequest();
xhr.open(METHOD, URL);
xhr.withCredentials = true;
xhr.timeout = 20000;
if (BODY !== null)
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.onload = () => resolve(JSON.stringify({status: xhr.status, body: xhr.responseText}));
xhr.onerror = () => reject(new Error('网络错误;如已发送关注,请先查询状态,勿自动重试'));
xhr.ontimeout = () => reject(new Error('请求超时;如已发送关注,请先查询状态,勿自动重试'));
xhr.send(BODY);
})""".replace("METHOD", json.dumps("POST" if body is not None else "GET"))
expression = expression.replace("URL", json.dumps(url)).replace(
"BODY", json.dumps(urlencode(body) if body is not None else None)
)
result = evaluate_script(expression)
try:
response = json.loads(result.stdout.strip().splitlines()[-1])
payload = json.loads(response["body"])
if not isinstance(payload, dict):
raise ValueError("响应不是对象")
except (ValueError, KeyError, IndexError, TypeError) as exc:
raise RuntimeError("无法解析接口响应,停止操作") from exc
if response["status"] != 200 or payload.get("status_code") != 0:
raise RuntimeError(
f"接口失败:HTTP {response['status']},status_code={payload.get('status_code')};"
"请检查登录态或风控提示,不会自动登录、重试或处理验证码"
)
return payload
def get_profile(uid: str) -> dict:
user = request(PROFILE, {"user_id": validate_uid(uid)}).get("user")
if not isinstance(user, dict) or user.get("uid") != uid:
raise RuntimeError("返回用户 UID 与目标不匹配,停止操作")
return user
def follow_user(uid: str, check_only: bool = False) -> dict:
uid = validate_uid(uid)
# 复用登录态检查;失败即退出,绝不尝试登录。
try:
current = get_user_from_browser()
except RuntimeError as exc:
raise RuntimeError("无法确认登录状态,请手动检查或登录后再运行") from exc
if not current.get("uid") or str(current["uid"]) == "0":
raise RuntimeError("登录状态已失效,请手动登录后再运行")
if uid == str(current["uid"]):
raise ValueError("不能关注自己")
user = get_profile(uid)
status = user.get("follow_status")
if type(status) is not int or status not in (0, 1, 2):
raise RuntimeError(f"未知关注状态 {status!r},停止而不发送请求")
action = "checked" if check_only else "already_following"
if not check_only and status == 0:
result = request(FOLLOW, {}, {"user_id": user["uid"], "type": 1})
if type(result.get("follow_status")) is not int or result[
"follow_status"
] not in (1, 2):
raise RuntimeError("关注响应未确认成功;请使用 --check 查询,勿自动重试")
verified = get_profile(uid)
if verified["uid"] != user["uid"] or verified.get("follow_status") not in (
1,
2,
):
raise RuntimeError(
"关注请求已发出,但回查未确认;请使用 --check 查询,勿自动重试"
)
status = verified["follow_status"]
action = "followed"
return {
"action": action,
"uid": user["uid"],
"sec_uid": user.get("sec_uid"),
"nickname": user.get("nickname"),
"follow_status": status,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("uid", help="目标用户数字 UID(通知 users[].uid),不是抖音号")
parser.add_argument("--check", action="store_true", help="只查询,不关注")
add_cdp_args(parser)
args = parser.parse_args()
configure(args)
try:
uid = validate_uid(args.uid)
print(json.dumps(follow_user(uid, args.check), ensure_ascii=False, indent=2))
except (
OSError,
ValueError,
RuntimeError,
KeyError,
TypeError,
subprocess.SubprocessError,
) as exc:
# 不回显浏览器 stderr:它可能包含带签名的请求 URL。
message = (
"浏览器连接或执行失败,请检查 CDP 与登录态"
if isinstance(exc, subprocess.SubprocessError)
else str(exc)
)
print(f"失败:{message}。不会自动登录或重试关注。", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())