This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
View File
+441
View File
@@ -0,0 +1,441 @@
import json
import os
import time
from datetime import datetime
from typing import Optional
SESSIONS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"sessions",
)
def ensure_sessions_dir():
os.makedirs(SESSIONS_DIR, exist_ok=True)
def get_cookie_path(account_id: int) -> str:
ensure_sessions_dir()
return os.path.join(SESSIONS_DIR, f"account_{account_id}.json")
def convert_dycred_to_storage_state(data: dict) -> dict:
import time
from urllib.parse import unquote
import re
# 1. 解析 Cookie
cookies = []
raw_cookie = data.get("cookie") or ""
now = int(time.time())
far_future = now + 60 * 60 * 24 * 180 # 180天过期
for pair in raw_cookie.split(";"):
pair = pair.strip()
if not pair or "=" not in pair:
continue
name, value = pair.split("=", 1)
name = name.strip()
value = value.strip()
if not name or any(c in name for c in "()[]{}'\"\n \t\\"):
continue
# 敏感且通常为 HttpOnly 的登录态保持 HttpOnly
is_httponly = name.lower() in ("sessionid", "sessionid_ss")
cookies.append({
"name": name,
"value": value,
"domain": ".douyin.com",
"path": "/",
"expires": far_future,
"httpOnly": is_httponly,
"secure": True,
"sameSite": "None"
})
# 2. 构建 localStorage 条目
local_storage = []
if data.get("keys"):
local_storage.append({"name": "security-sdk/s_sdk_crypt_sdk", "value": str(data["keys"])})
if data.get("web_protect"):
local_storage.append({"name": "security-sdk/s_sdk_sign_data_key/web_protect", "value": str(data["web_protect"])})
if data.get("sec_uid"):
local_storage.append({"name": "web_runtime_security_uid", "value": str(data["sec_uid"])})
# 补充 tea_cache_tokens
uid = str(data.get("unique_id") or data.get("my_uid") or "")
if uid:
local_storage.append({
"name": "tea_cache_tokens",
"value": json.dumps({"user_unique_id": uid, "web_id": uid}, ensure_ascii=False)
})
origins = []
if local_storage:
origins.append({
"origin": "https://www.douyin.com",
"localStorage": local_storage
})
# 3. 组装 storage_state
storage_state = {
"cookies": cookies,
"origins": origins,
"user_agent": data.get("ua") or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
# 4. 数字 UID 与 WebSocket 处理
if uid:
try:
storage_state["my_uid"] = int(uid)
except ValueError:
pass
ws_url = data.get("frontier_ws_url")
if not ws_url and data.get("ws_urls"):
for url in data["ws_urls"]:
if url:
ws_url = url
break
if ws_url:
storage_state["frontier_ws_url"] = ws_url
def _q(url_str: str, key: str) -> str:
m = re.search(rf"[?&]{re.escape(key)}=([^&\s]+)", url_str)
return unquote(m.group(1)) if m else ""
sdk_cert = _q(ws_url, "sdk_cert")
ts_sign = _q(ws_url, "ts_sign")
if sdk_cert:
storage_state["sdk_cert"] = sdk_cert
if ts_sign:
storage_state["ts_sign"] = ts_sign
return storage_state
def _parse_tea_from_ls(origins: list) -> tuple[str, str]:
"""从 origins.localStorage 提取 (my_uid, web_id)www 站优先,跳过伪造 tea。"""
my_uid = ""
web_id = ""
ordered = sorted(origins or [], key=lambda o: 0 if "www.douyin.com" in (o.get("origin") or "") else 1)
for origin in ordered:
for entry in origin.get("localStorage") or []:
name = entry.get("name") or ""
if "tea_cache" not in name.lower():
continue
try:
parsed = json.loads(entry.get("value") or "{}")
except Exception:
continue
uid = str(parsed.get("user_unique_id") or "").strip()
wid = str(parsed.get("web_id") or "").strip()
if uid and wid and uid == wid and len(uid) > 12:
continue
if uid and uid.isdigit() and not my_uid:
my_uid = uid
if wid and wid.isdigit() and not web_id:
web_id = wid
if my_uid and web_id:
return my_uid, web_id or my_uid
return my_uid, web_id or my_uid
def _ws_device_id(url: str) -> str:
import re
from urllib.parse import unquote
m = re.search(r"[?&]device_id=([^&\s]+)", url or "")
return unquote(m.group(1)) if m else ""
def normalize_storage_state_for_im(data: dict) -> dict:
"""保存/导入前修正 UID、device_id;保留创作者私信 WSaid=2906 + sdk_cert)。"""
if not isinstance(data, dict) or not isinstance(data.get("cookies"), list):
return data
data = dict(data)
origins = data.get("origins") or []
tea_uid, tea_web_id = _parse_tea_from_ls(origins)
ws_url = str(data.get("frontier_ws_url") or "")
is_creator_ws = "aid=2906" in ws_url and "sdk_cert=" in ws_url
if is_creator_ws:
ws_dev = _ws_device_id(ws_url)
if ws_dev and ws_dev.isdigit():
data["my_uid"] = int(ws_dev)
my_uid = ws_dev
else:
my_uid = str(data.get("my_uid") or tea_uid or "")
if my_uid.isdigit():
data["my_uid"] = int(my_uid)
else:
my_uid = ""
elif tea_uid:
data["my_uid"] = int(tea_uid)
my_uid = tea_uid
else:
my_uid = str(data.get("my_uid") or "")
if my_uid.isdigit():
data["my_uid"] = int(my_uid)
else:
my_uid = ""
if ws_url and my_uid and not is_creator_ws:
ws_dev = _ws_device_id(ws_url)
token = ""
try:
from urllib.parse import parse_qs, urlparse
token = parse_qs(urlparse(ws_url).query).get("token", [""])[0]
except Exception:
pass
looks_built = bool(token) and len(token) < 40
if ws_dev and ws_dev.isdigit() and ws_dev != my_uid and looks_built:
data.pop("frontier_ws_url", None)
data.pop("sdk_cert", None)
data.pop("ts_sign", None)
data.pop("frontier_ws_built", None)
for origin in origins:
ls = origin.get("localStorage") or []
fixed = False
for entry in ls:
if entry.get("name") == "web_runtime_security_uid":
fixed = True
val = str(entry.get("value") or "")
if not val.isdigit() and my_uid:
entry["value"] = my_uid
break
if not fixed and my_uid:
ls.append({"name": "web_runtime_security_uid", "value": my_uid})
origin["localStorage"] = ls
data["origins"] = origins
return data
def validate_cookie_json(cookie_data: str) -> dict:
cookie_data = cookie_data.strip()
if cookie_data.startswith("DYCRED1."):
import base64
try:
b64_part = cookie_data.split(".", 1)[1]
decoded = base64.b64decode(b64_part).decode("utf-8")
data = json.loads(decoded)
return normalize_storage_state_for_im(convert_dycred_to_storage_state(data))
except Exception as e:
raise ValueError(f"解析 DYCRED1 凭证密文失败: {e}")
data = json.loads(cookie_data)
if not isinstance(data, dict):
raise ValueError("Cookie 必须是 JSON 对象")
if "cookies" not in data or not isinstance(data.get("cookies"), list):
raise ValueError("Cookie 格式无效,需包含 cookies 字段(Playwright storage_state 格式)")
return normalize_storage_state_for_im(data)
def write_cookie_file(account_id: int, cookie_data: str) -> str:
data = validate_cookie_json(cookie_data)
path = get_cookie_path(account_id)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return path
def read_cookie_file(account_id: int) -> Optional[str]:
path = get_cookie_path(account_id)
if not os.path.exists(path) or os.path.getsize(path) == 0:
return None
with open(path, "r", encoding="utf-8") as f:
return f.read()
def clear_cookie_file(account_id: int):
path = get_cookie_path(account_id)
if os.path.exists(path):
os.remove(path)
DOUYIN_LOGIN_COOKIES = {
"sessionid",
"sessionid_ss",
"sid_tt",
"sid_tt_ss",
"uid_tt",
"uid_tt_ss",
"sid_guard",
"passport_auth_status",
"passport_auth_status_ss",
"login_status",
"odin_tt",
}
IM_TOKEN_COOKIES = ("sessionid", "sessionid_ss")
def _is_login_cookie(name: str) -> bool:
return name.lower() in DOUYIN_LOGIN_COOKIES
def _cookie_not_expired(cookie: dict, now: float) -> bool:
expires = cookie.get("expires")
if expires in (None, -1, 0):
return True
try:
return float(expires) > now
except (TypeError, ValueError):
return True
def analyze_cookie_data(data: Optional[dict]) -> dict:
"""静态分析 Cookie 是否具备登录凭证且未过期(不等同于服务端仍认可)"""
result = {
"has_cookie": False,
"cookie_valid": False,
"has_login_token": False,
"cookie_expired": False,
"cookie_count": 0,
"login_cookie_count": 0,
"expires_at": None,
"reason": "未保存 Cookie",
"has_sessionid": False,
}
if not data or not isinstance(data, dict):
return result
cookies = data.get("cookies", [])
if not isinstance(cookies, list) or not cookies:
result["reason"] = "Cookie 数据为空"
return result
result["has_cookie"] = True
result["cookie_count"] = len(cookies)
now = time.time()
login_cookies = [c for c in cookies if c.get("name") and _is_login_cookie(c["name"])]
result["login_cookie_count"] = len(login_cookies)
result["has_login_token"] = len(login_cookies) > 0
if not login_cookies:
result["reason"] = "缺少登录凭证(sessionid / sid_guard 等)"
return result
valid_login = [c for c in login_cookies if _cookie_not_expired(c, now)]
expired_login = [c for c in login_cookies if not _cookie_not_expired(c, now)]
if valid_login:
expires_values = [
float(c["expires"]) for c in valid_login
if c.get("expires") not in (None, -1, 0)
]
if expires_values:
earliest = min(expires_values)
result["expires_at"] = datetime.utcfromtimestamp(earliest).isoformat()
if not valid_login:
result["cookie_expired"] = True
result["reason"] = "登录 Cookie 已全部过期,需重新扫码"
return result
if expired_login:
result["reason"] = "部分登录 Cookie 已过期,启动后将探测浏览器是否已登录"
else:
has_sessionid = any(
c.get("name") in IM_TOKEN_COOKIES and c.get("value")
for c in cookies
)
if has_sessionid:
result["reason"] = "Cookie 有效,含 sessionid,可尝试 IM 直连"
else:
result["reason"] = "Cookie 有效,但缺少 sessionid,启动时将打开浏览器补全 IM 凭证"
result["cookie_valid"] = True
result["has_sessionid"] = any(
c.get("name") in IM_TOKEN_COOKIES and c.get("value") for c in cookies
)
return result
def analyze_cookie(cookie_data: Optional[str]) -> dict:
if not cookie_data:
return analyze_cookie_data(None)
try:
return analyze_cookie_data(json.loads(cookie_data))
except Exception:
return {
"has_cookie": False,
"cookie_valid": False,
"has_login_token": False,
"cookie_expired": False,
"cookie_count": 0,
"login_cookie_count": 0,
"expires_at": None,
"reason": "Cookie JSON 格式错误",
"has_sessionid": False,
}
def cookie_summary(cookie_data: Optional[str]) -> dict:
analysis = analyze_cookie(cookie_data)
key_names = []
if cookie_data:
try:
cookies = json.loads(cookie_data).get("cookies", [])
key_names = [c.get("name", "") for c in cookies if c.get("name")]
except Exception:
pass
return {
"cookie_count": analysis["cookie_count"],
"key_names": key_names[:20],
"cookie_valid": analysis["cookie_valid"],
"has_login_token": analysis["has_login_token"],
"cookie_expired": analysis["cookie_expired"],
"expires_at": analysis["expires_at"],
"reason": analysis["reason"],
"has_sessionid": analysis.get("has_sessionid", False),
}
def merge_playwright_cookies(storage: dict, live_cookies: list) -> dict:
"""将浏览器实时 Cookie 合并进 storage_state,确保 HttpOnly 的 sessionid 被保存"""
storage = dict(storage or {})
storage.setdefault("cookies", [])
storage.setdefault("origins", storage.get("origins") or [])
def cookie_key(c: dict) -> tuple:
return (c.get("name") or "", c.get("domain") or "", c.get("path") or "/")
merged: dict[tuple, dict] = {}
for item in storage.get("cookies") or []:
if item.get("name"):
merged[cookie_key(item)] = item
priority_names = set(IM_TOKEN_COOKIES) | DOUYIN_LOGIN_COOKIES
for item in live_cookies or []:
name = item.get("name") or ""
if not name:
continue
key = cookie_key(item)
entry = {
"name": name,
"value": item.get("value") or "",
"domain": item.get("domain") or "",
"path": item.get("path") or "/",
"expires": item.get("expires", -1),
"httpOnly": item.get("httpOnly", False),
"secure": item.get("secure", False),
"sameSite": item.get("sameSite", "Lax"),
}
prev = merged.get(key)
if not prev:
merged[key] = entry
continue
prev_val = prev.get("value") or ""
new_val = entry.get("value") or ""
if name in priority_names and len(new_val) > len(prev_val):
merged[key] = entry
elif not prev_val and new_val:
merged[key] = entry
storage["cookies"] = list(merged.values())
return storage
+47
View File
@@ -0,0 +1,47 @@
"""接收消息原始日志写入。"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from models.database import AsyncSessionLocal
from models.models import ReceivedMessageLog
logger = logging.getLogger("received_message_log")
async def record_received_message(
*,
account_id: int,
raw_content: str,
sender_name: Optional[str] = None,
sender_id: Optional[str] = None,
sender_avatar: Optional[str] = None,
conversation_id: Optional[str] = None,
message_type: Optional[int] = None,
server_message_id: Optional[str] = None,
) -> None:
# 原样落库:不做 strip / parse / serialize,空字符串也记录
store_content = raw_content if raw_content is not None else ""
async with AsyncSessionLocal() as db:
try:
db.add(
ReceivedMessageLog(
account_id=account_id,
conversation_id=(conversation_id or "").strip() or None,
sender_id=(sender_id or "").strip() or None,
sender_name=(sender_name or "").strip() or None,
sender_avatar=(sender_avatar or "").strip() or None,
message_type=message_type,
server_message_id=(server_message_id or "").strip() or None,
raw_content=store_content,
created_at=datetime.utcnow(),
)
)
await db.commit()
except Exception as exc:
await db.rollback()
logger.error("Failed to record received message log: %s", exc)
+107
View File
@@ -0,0 +1,107 @@
"""线程安全的系统诊断日志缓冲区。
任何上下文(异步协程、同步函数、WebSocket 回调所在的执行器线程)都可以安全调用
``record(...)`` 写入一条诊断日志,用于排查私信收发失败的原因。
设计要点:
- 内存环形缓冲区是“实时”数据源(API 直接读取,零延迟),任何线程都能写入;
- ``_pending`` 队列由 main.py 的后台任务定期落库到 ``system_logs`` 表做持久化;
- 启动时可用 ``seed(...)`` 把历史记录读回缓冲区。
"""
import logging
import threading
from collections import deque
from datetime import datetime
from typing import Optional
logger = logging.getLogger("douyin_im.system")
_VALID_LEVELS = ("info", "success", "warning", "error")
_lock = threading.Lock()
_buffer: deque = deque(maxlen=3000)
_pending: list = []
_seq = 0
def record(
event: str,
detail: str = "",
level: str = "info",
category: str = "system",
account_id: Optional[int] = None,
) -> dict:
"""写入一条诊断日志,返回该条记录。"""
global _seq
if level not in _VALID_LEVELS:
level = "info"
with _lock:
_seq += 1
entry = {
"id": _seq,
"account_id": account_id,
"level": level,
"category": category,
"event": str(event or ""),
"detail": str(detail or ""),
"created_at": datetime.utcnow().isoformat(),
}
_buffer.appendleft(entry)
_pending.append(entry)
msg = f"[{category}] {event}" + (f" | {detail}" if detail else "")
if level == "error":
logger.error(msg)
elif level == "warning":
logger.warning(msg)
else:
logger.info(msg)
return entry
def get_logs(
account_id: Optional[int] = None,
level: Optional[str] = None,
category: Optional[str] = None,
limit: int = 200,
) -> list:
"""按条件读取最近的诊断日志(最新优先)。"""
with _lock:
items = list(_buffer)
out = []
for e in items:
if account_id is not None and e["account_id"] != account_id:
continue
if level and e["level"] != level:
continue
if category and e["category"] != category:
continue
out.append(e)
if len(out) >= limit:
break
return out
def drain_pending() -> list:
"""取出尚未落库的记录(供后台 flush 任务持久化)。"""
with _lock:
items = _pending[:]
_pending.clear()
return items
def seed(entries: list) -> None:
"""启动时把持久化的历史记录读回缓冲区(不会重复落库)。"""
global _seq
with _lock:
for e in sorted(entries, key=lambda x: x.get("id") or 0):
_buffer.appendleft(e)
if (e.get("id") or 0) > _seq:
_seq = e["id"]
def clear() -> None:
with _lock:
_buffer.clear()
_pending.clear()