"""从 Cookie 抓取抖音账号资料(昵称 / 头像 / UID)及作品列表。""" from __future__ import annotations import asyncio import json import logging from datetime import datetime from typing import Any, Optional import requests from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from models.models import Account, AccountProfileDetail, AccountVideo from rpa_engine.douyin_im.auth import DouyinAuth from rpa_engine.douyin_im.dy_util import ( DEFAULT_USER_AGENT, generate_a_bogus, generate_msToken, generate_webid, splice_url, ) from rpa_engine.douyin_im.protocol import _pick_avatar_url from rpa_engine.douyin_im.session import DouyinImSession from rpa_engine.device_profiles import resolve_user_agent logger = logging.getLogger("account_profile") def _pick_str(data: dict, *keys: str) -> str: for key in keys: value = data.get(key) if value is not None and str(value).strip(): return str(value).strip() return "" def _pick_int(data: dict, *keys: str) -> Optional[int]: for key in keys: value = data.get(key) if value is None or value == "": continue try: return int(value) except (TypeError, ValueError): continue return None def _iter_profile_nodes(data: Any): if not isinstance(data, dict): return yield data for key in ("user", "user_info", "data", "creator"): node = data.get(key) if isinstance(node, dict): yield node def _extract_profile_from_payload(data: Any) -> dict[str, str]: for node in _iter_profile_nodes(data): uid = _pick_str(node, "uid", "user_uid", "user_id", "creator_user_id", "id") nickname = _pick_str( node, "nickname", "nick_name", "unique_id", "display_name", "name", ) avatar = _pick_avatar_url(node) if uid or nickname or avatar: return { "uid": uid, "nickname": nickname, "avatar_url": avatar, } return {} def _extract_detail_from_payload(data: Any) -> dict[str, Any]: """从抖音资料接口响应中提取详细统计。""" detail: dict[str, Any] = {} for node in _iter_profile_nodes(data): if not detail.get("uid"): uid = _pick_str(node, "uid", "user_uid", "user_id", "creator_user_id", "id") if uid: detail["uid"] = uid if not detail.get("nickname"): nickname = _pick_str( node, "nickname", "nick_name", "display_name", "name" ) if nickname: detail["nickname"] = nickname if not detail.get("avatar_url"): avatar = _pick_avatar_url(node) if avatar: detail["avatar_url"] = avatar if not detail.get("unique_id"): unique_id = _pick_str(node, "unique_id", "short_id", "douyin_id") if unique_id: detail["unique_id"] = unique_id if not detail.get("sec_user_id"): sec_user_id = _pick_str(node, "sec_uid", "sec_user_id") if sec_user_id: detail["sec_user_id"] = sec_user_id if not detail.get("signature"): signature = _pick_str(node, "signature", "desc", "bio") if signature: detail["signature"] = signature for field, keys in ( ("video_count", ("aweme_count", "post_count", "video_count", "works_count")), ("follower_count", ("follower_count", "fans_count")), ("following_count", ("following_count", "follow_count")), ("total_favorited", ("total_favorited", "total_favorited_count", "like_count")), ("favoriting_count", ("favoriting_count", "favorite_count")), ): if detail.get(field) is None: val = _pick_int(node, *keys) if val is not None: detail[field] = val return detail def _pick_url_list(data: Any) -> str: if isinstance(data, str) and data.startswith("http"): return data if isinstance(data, dict): urls = data.get("url_list") or data.get("urlList") or [] if isinstance(urls, list): for item in urls: if isinstance(item, str) and item.startswith("http"): return item if isinstance(data, list): for item in data: if isinstance(item, str) and item.startswith("http"): return item return "" def _pick_video_play_url(video: dict[str, Any]) -> str: play_addr = video.get("play_addr") or video.get("download_addr") or {} urls = play_addr.get("url_list") or [] if not isinstance(urls, list): urls = [] for item in urls: if not isinstance(item, str) or not item.startswith("http"): continue lower = item.lower() if lower.endswith(".mp3") or "/ies-music/" in lower: continue if "douyinvod.com" in lower or "/video/" in lower or "mime_type=video" in lower: return item.replace("playwm", "play") for item in urls: if isinstance(item, str) and item.startswith("http") and not item.lower().endswith(".mp3"): return item.replace("playwm", "play") bit_rate = video.get("bit_rate") if isinstance(bit_rate, list): for entry in bit_rate: if not isinstance(entry, dict): continue play = entry.get("play_addr") or {} url = _pick_url_list(play) if url and not url.lower().endswith(".mp3"): return url.replace("playwm", "play") return "" def _is_published_aweme(item: dict[str, Any]) -> bool: """仅保留已公开发布的作品(排除私密/审核中/已删除)。""" if not isinstance(item, dict): return False if item.get("is_private"): return False status = item.get("status") if isinstance(status, dict): if status.get("is_delete"): return False if status.get("in_reviewing"): return False if status.get("is_prohibited"): return False private_status = status.get("private_status") if private_status not in (None, 0, "0"): return False part_see = status.get("part_see") if part_see not in (None, 0, "0", False): return False review = status.get("review_result") if isinstance(review, dict): review_status = review.get("review_status") if review_status not in (None, 0, "0"): return False rate = item.get("rate") if rate in (10, 11, 12): return False return True def _parse_aweme_item(item: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(item, dict): return None aweme_id = _pick_str(item, "aweme_id", "awemeId", "item_id") if not aweme_id: return None title = _pick_str(item, "desc", "title", "content") video = item.get("video") if isinstance(item.get("video"), dict) else {} cover_url = _pick_url_list(video.get("cover") or video.get("origin_cover") or item.get("cover")) video_url = _pick_video_play_url(video) if not cover_url and isinstance(item.get("images"), list) and item["images"]: first_image = item["images"][0] if isinstance(first_image, dict): cover_url = _pick_url_list(first_image.get("url_list") or first_image) share_url = _pick_str(item, "share_url", "share_link") if not share_url: share_url = f"https://www.douyin.com/video/{aweme_id}" create_time = item.get("create_time") or item.get("createTime") create_dt: datetime | None = None if create_time: try: create_dt = datetime.utcfromtimestamp(int(create_time)) except (TypeError, ValueError): create_dt = None statistics = item.get("statistics") if isinstance(item.get("statistics"), dict) else {} if not title: title = f"作品 {aweme_id}" aweme_type = item.get("aweme_type") if video_url: media_type = "video" elif aweme_type == 68 or item.get("images"): media_type = "image" elif aweme_type in (0, 51, 55, 61): media_type = "video" else: media_type = "other" return { "aweme_id": aweme_id, "title": title, "cover_url": cover_url, "video_url": video_url, "share_url": share_url, "create_time": create_dt, "media_type": media_type, "digg_count": _pick_int(statistics, "digg_count", "like_count"), "comment_count": _pick_int(statistics, "comment_count"), "play_count": _pick_int(statistics, "play_count", "view_count"), } def _parse_creator_item(item: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(item, dict): return None aweme_id = _pick_str(item, "item_id", "item_id_plain", "aweme_id") if not aweme_id: return None title = _pick_str(item, "title", "desc") or f"作品 {aweme_id}" cover_url = _pick_str(item, "cover_image_url", "cover_url") share_url = _pick_str(item, "item_link", "share_url") or f"https://www.douyin.com/video/{aweme_id}" create_time = item.get("create_time") create_dt: datetime | None = None if create_time: try: create_dt = datetime.utcfromtimestamp(int(create_time)) except (TypeError, ValueError): create_dt = None return { "aweme_id": aweme_id, "title": title, "cover_url": cover_url or None, "video_url": None, "share_url": share_url, "create_time": create_dt, "media_type": "image" if _pick_int(item, "media_type") == 2 else "other", "digg_count": _pick_int(item, "digg_count", "like_count"), "comment_count": _pick_int(item, "comment_count"), "play_count": _pick_int(item, "play_count", "view_count"), } def _extract_creator_item_list(data: Any) -> list[dict[str, Any]]: if not isinstance(data, dict): return [] for key in ("item_info_list", "aweme_list", "item_list"): node = data.get(key) if isinstance(node, list) and node: return [x for x in node if isinstance(x, dict)] return [] def _extract_aweme_list(data: Any) -> list[dict[str, Any]]: if not isinstance(data, dict): return [] for key in ("aweme_list", "awemeList", "item_list", "items", "data"): node = data.get(key) if isinstance(node, list) and node: return [x for x in node if isinstance(x, dict)] if isinstance(node, dict): nested = node.get("aweme_list") or node.get("item_list") if isinstance(nested, list): return [x for x in nested if isinstance(x, dict)] return [] def _douyin_get_json( auth: DouyinAuth, ua: str, url: str, base_params: dict[str, str], referer: str = "https://www.douyin.com/", ) -> dict[str, Any] | None: headers = { "User-Agent": ua, "Referer": referer, "Accept": "application/json, text/plain, */*", } proxies = _requests_proxies() try: params = dict(base_params) query = splice_url(params) params["a_bogus"] = generate_a_bogus(query, user_agent=ua) resp = requests.get( url, params=params, headers=headers, cookies=auth.cookie, verify=False, timeout=15, proxies=proxies, ) data = resp.json() return data if isinstance(data, dict) else None except Exception as exc: logger.debug(f"douyin get failed for {url}: {exc}") return None def _fetch_aweme_post_page( auth: DouyinAuth, ua: str, sec_user_id: str, max_count: int, strategy_type: str, ) -> list[dict[str, Any]]: base_params = { "device_platform": "webapp", "aid": "6383", "channel": "channel_pc_web", "sec_user_id": sec_user_id, "max_cursor": "0", "count": str(min(max_count, 35)), "publish_video_strategy_type": strategy_type, "verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "", "fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "", "webid": generate_webid(auth, "https://www.douyin.com/"), "msToken": auth.msToken or generate_msToken(), "pc_client_type": "1", } videos: list[dict[str, Any]] = [] max_cursor = 0 while len(videos) < max_count: params = dict(base_params) params["max_cursor"] = str(max_cursor) params["count"] = str(min(35, max_count - len(videos))) data = _douyin_get_json( auth, ua, "https://www.douyin.com/aweme/v1/web/aweme/post/", params, referer=f"https://www.douyin.com/user/{sec_user_id}", ) if not data: break status_code = data.get("status_code") if status_code not in (None, 0): break batch = _extract_aweme_list(data) if not batch: break for item in batch: if not _is_published_aweme(item): continue parsed = _parse_aweme_item(item) if parsed and parsed["aweme_id"] not in {v["aweme_id"] for v in videos}: videos.append(parsed) if len(videos) >= max_count: break has_more = bool(data.get("has_more") or data.get("hasMore")) next_cursor = data.get("max_cursor") or data.get("maxCursor") try: next_cursor = int(next_cursor or 0) except (TypeError, ValueError): next_cursor = 0 if not has_more or next_cursor == max_cursor: break max_cursor = next_cursor return videos def fetch_douyin_user_videos_sync( cookie_data: str, sec_user_id: str, user_agent: Optional[str] = None, max_count: int = 50, ) -> dict[str, Any]: """抓取用户已公开发布的作品列表(与抖音主页「作品」一致)。""" result: dict[str, Any] = {"videos": [], "fetched": False, "message": ""} sec_user_id = (sec_user_id or "").strip() try: auth, ua = _build_auth(cookie_data, user_agent) except Exception as exc: result["message"] = f"Cookie 无效: {exc}" return result if not sec_user_id: result["message"] = "缺少 sec_user_id,无法拉取已发布作品" return result videos = _fetch_aweme_post_page(auth, ua, sec_user_id, max_count, "2") result["videos"] = videos result["fetched"] = bool(videos) if not result["fetched"]: result["message"] = "未获取到已发布作品,请确认 Cookie 有效且主页有公开作品" return result def _fetch_own_videos_sync( auth: DouyinAuth, ua: str, max_count: int, ) -> dict[str, Any]: """创作者中心作品列表(适用于当前登录账号)。""" result: dict[str, Any] = {"videos": [], "fetched": False} endpoints = [ ( "https://creator.douyin.com/aweme/v1/creator/item/list/", { "status": "1", "count": str(min(max_count, 35)), "max_cursor": "0", "aid": "6383", "device_platform": "webapp", }, "https://creator.douyin.com/creator-micro/content/manage", "creator_item", ), ( "https://creator.douyin.com/aweme/v1/creator/aweme/list/", { "status": "1", "count": str(min(max_count, 35)), "max_cursor": "0", "aid": "6383", "device_platform": "webapp", }, "https://creator.douyin.com/creator-micro/content/manage", "aweme", ), ( "https://creator.douyin.com/web/api/media/aweme/post/", { "status": "1", "count": str(min(max_count, 35)), "max_cursor": "0", }, "https://creator.douyin.com/creator-micro/content/manage", "aweme", ), ] videos: list[dict[str, Any]] = [] for url, base_params, referer, parser in endpoints: data = _douyin_get_json(auth, ua, url, base_params, referer=referer) if not data: continue if parser == "creator_item": batch = _extract_creator_item_list(data) parse_fn = _parse_creator_item else: batch = _extract_aweme_list(data) parse_fn = _parse_aweme_item for item in batch: parsed = parse_fn(item) if parsed and parsed["aweme_id"] not in {v["aweme_id"] for v in videos}: videos.append(parsed) if len(videos) >= max_count: break if videos: break result["videos"] = videos result["fetched"] = bool(videos) return result def _requests_proxies() -> dict | None: try: from rpa_engine.runtime_config import requests_proxies return requests_proxies() except Exception: return None def _build_auth(cookie_data: str, user_agent: Optional[str] = None) -> tuple[DouyinAuth, str]: storage = json.loads(cookie_data) session = DouyinImSession.from_storage_state(storage or {}) ua = resolve_user_agent(user_agent or session.user_agent or DEFAULT_USER_AGENT) auth = DouyinAuth() auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str) auth.user_agent = ua auth.web_id = session.web_id or session.device_id or None return auth, ua def fetch_douyin_profile_sync( cookie_data: str, user_agent: Optional[str] = None, ) -> dict[str, str]: detail = fetch_douyin_profile_detail_sync(cookie_data, user_agent) return { "uid": detail.get("uid") or "", "nickname": detail.get("nickname") or "", "avatar_url": detail.get("avatar_url") or "", } def fetch_douyin_profile_detail_sync( cookie_data: str, user_agent: Optional[str] = None, ) -> dict[str, Any]: """抓取抖音账号详细资料(昵称/头像/UID/作品数/粉丝等)。""" result: dict[str, Any] = { "uid": "", "nickname": "", "avatar_url": "", "unique_id": "", "signature": "", "sec_user_id": "", "video_count": None, "follower_count": None, "following_count": None, "total_favorited": None, "favoriting_count": None, "fetched": False, "message": "", } try: auth, ua = _build_auth(cookie_data, user_agent) except Exception as exc: logger.warning(f"build auth for profile failed: {exc}") result["message"] = "Cookie 无效,无法解析登录凭证" return result uid = auth.get_uid() if uid: result["uid"] = str(uid) headers = { "User-Agent": ua, "Referer": "https://www.douyin.com/", "Accept": "application/json, text/plain, */*", } endpoints: list[tuple[str, dict[str, str]]] = [ ( "https://www.douyin.com/aweme/v1/web/query/user/", { "device_platform": "webapp", "aid": "6383", "channel": "channel_pc_web", "publish_video_strategy_type": "2", "verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "", "fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "", "webid": generate_webid(auth, "https://www.douyin.com/"), "msToken": auth.msToken or generate_msToken(), }, ), ( "https://creator.douyin.com/aweme/v1/creator/user/info/", { "device_platform": "webapp", "aid": "6383", }, ), ( "https://www.douyin.com/aweme/v1/web/user/profile/self/", { "device_platform": "webapp", "aid": "6383", "channel": "channel_pc_web", }, ), ] proxies = _requests_proxies() valid_profile_response = False for url, base_params in endpoints: try: params = dict(base_params) query = splice_url(params) params["a_bogus"] = generate_a_bogus(query, user_agent=ua) resp = requests.get( url, params=params, headers=headers, cookies=auth.cookie, verify=False, timeout=12, proxies=proxies, ) data = resp.json() if not isinstance(data, dict): continue status_code = data.get("status_code") if status_code is not None: try: if int(status_code) != 0: continue except (TypeError, ValueError): continue basic = _extract_profile_from_payload(data) stats = _extract_detail_from_payload(data) payload_has_profile = bool( data.get("user_uid") or basic.get("uid") or basic.get("nickname") or basic.get("avatar_url") or stats.get("uid") or stats.get("sec_user_id") or stats.get("unique_id") ) if not payload_has_profile: # 401/风控响应也可能是 JSON;没有任何用户资料节点时 # 只能视为传输/鉴权未知,不能确认 sec_user_id 缺失。 continue valid_profile_response = True if data.get("user_uid") and not result["uid"]: result["uid"] = str(data["user_uid"]) if basic.get("uid") and not result["uid"]: result["uid"] = basic["uid"] if basic.get("nickname") and not result["nickname"]: result["nickname"] = basic["nickname"] if basic.get("avatar_url") and not result["avatar_url"]: result["avatar_url"] = basic["avatar_url"] for key, value in stats.items(): if value is not None and value != "" and result.get(key) in (None, "", 0): result[key] = value if result.get("video_count") is not None: result["fetched"] = True break if result["uid"] and result["nickname"] and result["avatar_url"]: result["fetched"] = True except Exception as exc: logger.debug(f"profile detail fetch failed for {url}: {exc}") if not result["fetched"] and valid_profile_response: result["fetched"] = True if not result["fetched"]: result["message"] = result["message"] or "未能从抖音获取账号资料,请确认 Cookie 有效" result["profile_response_valid"] = valid_profile_response return result async def fetch_douyin_profile( cookie_data: str, user_agent: Optional[str] = None, ) -> dict[str, str]: return await asyncio.to_thread(fetch_douyin_profile_sync, cookie_data, user_agent) async def fetch_douyin_profile_detail( cookie_data: str, user_agent: Optional[str] = None, ) -> dict[str, Any]: return await asyncio.to_thread(fetch_douyin_profile_detail_sync, cookie_data, user_agent) def _resolve_sec_user_id_by_uid_sync( cookie_data: str, user_agent: Optional[str], uid: str, ) -> tuple[str, bool]: """Return ``(sec_user_id, request_completed)`` for the UID fallback.""" try: auth, ua = _build_auth(cookie_data, user_agent) extra = _douyin_get_json( auth, ua, "https://www.douyin.com/aweme/v1/web/user/profile/other/", { "device_platform": "webapp", "aid": "6383", "channel": "channel_pc_web", "user_id": str(uid), "sec_user_id": "", "publish_video_strategy_type": "2", "verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "", "fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "", "webid": generate_webid(auth, "https://www.douyin.com/"), "msToken": auth.msToken or generate_msToken(), }, ) except Exception as exc: logger.debug(f"resolve sec_user_id failed: {exc}") return "", False if extra is None: return "", False status_code = extra.get("status_code") if status_code is not None: try: if int(status_code) != 0: return "", False except (TypeError, ValueError): return "", False extracted = _extract_detail_from_payload(extra) basic = _extract_profile_from_payload(extra) sec_user_id = (extracted.get("sec_user_id") or "").strip() if not ( sec_user_id or extracted.get("uid") or basic.get("uid") or basic.get("nickname") or basic.get("avatar_url") ): return "", False return sec_user_id, True async def fetch_douyin_profile_detail_with_sec_user_id( cookie_data: str, user_agent: Optional[str] = None, ) -> dict[str, Any]: """Fetch profile data and run the existing UID fallback for sec_user_id. ``sec_user_id_status`` distinguishes a confirmed missing field from a transient request failure so hosting is never stopped merely because the profile endpoint was temporarily unavailable. """ detail = await fetch_douyin_profile_detail(cookie_data, user_agent) sec_user_id = str(detail.get("sec_user_id") or "").strip() if sec_user_id: detail["sec_user_id"] = sec_user_id detail["sec_user_id_status"] = "found" return detail if not detail.get("fetched"): detail["sec_user_id_status"] = "unknown" return detail uid = str(detail.get("uid") or "").strip() if uid: sec_user_id, request_completed = await asyncio.to_thread( _resolve_sec_user_id_by_uid_sync, cookie_data, user_agent, uid, ) if sec_user_id: detail["sec_user_id"] = sec_user_id detail["sec_user_id_status"] = "found" else: detail["sec_user_id_status"] = ( "missing" if request_completed else "unknown" ) return detail detail["sec_user_id_status"] = "missing" return detail async def fetch_douyin_user_videos( cookie_data: str, sec_user_id: str, user_agent: Optional[str] = None, max_count: int = 50, ) -> dict[str, Any]: return await asyncio.to_thread( fetch_douyin_user_videos_sync, cookie_data, sec_user_id, user_agent, max_count, ) def _douyin_profile_url(sec_user_id: Optional[str]) -> Optional[str]: sec = (sec_user_id or "").strip() if not sec: return None return f"https://www.douyin.com/user/{sec}" def _profile_detail_to_dict( profile: AccountProfileDetail | None, account: Account, videos: list[AccountVideo] | None = None, ) -> dict[str, Any]: video_rows = videos or [] cached_count = len(video_rows) def _row_media_type(row: AccountVideo) -> str: if row.media_type: return row.media_type return "video" if row.video_url else "image" video_work_count = sum(1 for v in video_rows if _row_media_type(v) == "video") image_work_count = sum(1 for v in video_rows if _row_media_type(v) == "image") playable_count = video_work_count if profile: api_count = profile.video_count if cached_count: display_count = cached_count else: display_count = api_count return { "account_id": account.id, "uid": profile.uid or account.douyin_uid, "nickname": profile.nickname or account.username, "avatar_url": profile.avatar_url or account.avatar_url, "unique_id": profile.unique_id, "signature": profile.signature, "sec_user_id": profile.sec_user_id, "profile_url": _douyin_profile_url(profile.sec_user_id), "video_count": display_count, "video_count_douyin": api_count, "cached_work_count": cached_count, "playable_video_count": playable_count, "video_work_count": video_work_count, "image_work_count": image_work_count, "follower_count": profile.follower_count, "following_count": profile.following_count, "total_favorited": profile.total_favorited, "favoriting_count": profile.favoriting_count, "fetched": bool(profile.synced_at), "message": profile.sync_message, "synced_at": profile.synced_at.isoformat() if profile.synced_at else None, "profile_aweme_count": api_count, "videos": [ { "id": v.id, "aweme_id": v.aweme_id, "title": v.title or "", "cover_url": v.cover_url, "video_url": v.video_url, "share_url": v.share_url, "create_time": v.create_time.isoformat() if v.create_time else None, "digg_count": v.digg_count, "comment_count": v.comment_count, "play_count": v.play_count, "media_type": v.media_type, } for v in video_rows ], } return { "account_id": account.id, "uid": account.douyin_uid, "nickname": account.username, "avatar_url": account.avatar_url, "unique_id": None, "signature": None, "sec_user_id": None, "profile_url": None, "video_count": None, "video_count_douyin": None, "cached_work_count": 0, "playable_video_count": 0, "video_work_count": 0, "image_work_count": 0, "follower_count": None, "following_count": None, "total_favorited": None, "favoriting_count": None, "fetched": False, "message": "暂无本地资料,请点击刷新从抖音同步", "synced_at": None, "videos": [], } async def load_account_profile_from_db( db: AsyncSession, account: Account, ) -> dict[str, Any]: profile = ( await db.execute( select(AccountProfileDetail).where(AccountProfileDetail.account_id == account.id) ) ).scalar_one_or_none() videos: list[AccountVideo] = [] if profile: videos = list( ( await db.execute( select(AccountVideo) .where(AccountVideo.account_id == account.id) .order_by(AccountVideo.sort_order.asc(), AccountVideo.id.asc()) ) ).scalars().all() ) return _profile_detail_to_dict(profile, account, videos) async def sync_account_profile_to_db( db: AsyncSession, account: Account, cookie_data: str, max_videos: int = 50, ) -> dict[str, Any]: """从抖音拉取资料与作品并写入本地数据库。""" detail = await fetch_douyin_profile_detail_with_sec_user_id( cookie_data, account.user_agent, ) sec_user_id = (detail.get("sec_user_id") or "").strip() if detail.get("sec_user_id_status") == "unknown": message = ( detail.get("message") or "暂时无法核验 sec_user_id,已保留原有账号资料,请稍后重试" ) profile = ( await db.execute( select(AccountProfileDetail).where( AccountProfileDetail.account_id == account.id ) ) ).scalar_one_or_none() if profile: profile.sync_message = message await db.commit() cached = await load_account_profile_from_db(db, account) cached["message"] = message return cached if detail.get("fetched"): try: await apply_douyin_profile(db, account, cookie_data) except Exception as exc: logger.warning(f"apply douyin profile failed: {exc}") video_result = await fetch_douyin_user_videos( cookie_data, sec_user_id, account.user_agent, max_count=max_videos, ) now = datetime.utcnow() profile = ( await db.execute( select(AccountProfileDetail).where(AccountProfileDetail.account_id == account.id) ) ).scalar_one_or_none() if not profile: profile = AccountProfileDetail(account_id=account.id) db.add(profile) profile.uid = detail.get("uid") or account.douyin_uid profile.nickname = detail.get("nickname") or account.username profile.avatar_url = detail.get("avatar_url") or account.avatar_url profile.unique_id = detail.get("unique_id") or None profile.signature = detail.get("signature") or None sec_user_id_status = detail.get("sec_user_id_status") if sec_user_id: profile.sec_user_id = sec_user_id elif sec_user_id_status == "missing": # Only an authoritative successful response may clear the identity. # Network/parse failures keep the last known value so a profile refresh # cannot accidentally force a running account offline. profile.sec_user_id = None profile.follower_count = detail.get("follower_count") profile.following_count = detail.get("following_count") profile.total_favorited = detail.get("total_favorited") profile.favoriting_count = detail.get("favoriting_count") profile.synced_at = now messages: list[str] = [] if detail.get("message"): messages.append(str(detail["message"])) fetched_videos = video_result.get("videos") or [] await db.execute(delete(AccountVideo).where(AccountVideo.account_id == account.id)) for idx, item in enumerate(fetched_videos): db.add( AccountVideo( account_id=account.id, aweme_id=item["aweme_id"], title=item.get("title"), cover_url=item.get("cover_url"), video_url=item.get("video_url"), share_url=item.get("share_url"), create_time=item.get("create_time"), digg_count=item.get("digg_count"), comment_count=item.get("comment_count"), play_count=item.get("play_count"), media_type=item.get("media_type"), sort_order=idx, synced_at=now, ) ) profile.video_count = len(fetched_videos) if fetched_videos: profile.sync_message = ";".join(messages) if messages else None else: empty_msg = str(video_result.get("message") or "主页暂无已公开发布作品") if messages: messages.append(empty_msg) else: messages = [empty_msg] profile.sync_message = ";".join(messages) await db.commit() await db.refresh(account) return await load_account_profile_from_db(db, account) async def _pick_unique_username( db: AsyncSession, account: Account, nickname: str, uid: str, ) -> str: candidates: list[str] = [] if nickname and uid: candidates.extend([nickname, f"{nickname}_{uid}"]) elif nickname: candidates.append(nickname) elif uid: candidates.append(f"用户{uid}") candidates.append(f"{(nickname or '账号')}_{account.id}") for candidate in candidates: name = candidate[:100] stmt = select(Account.id).where(Account.username == name, Account.id != account.id) conflict = (await db.execute(stmt)).scalar_one_or_none() if not conflict: return name return f"账号_{account.id}" async def apply_douyin_profile( db: AsyncSession, account: Account, cookie_data: str, ) -> dict[str, str]: """抓取并写入账号资料,返回抓取结果。""" profile = await fetch_douyin_profile(cookie_data, account.user_agent) uid = (profile.get("uid") or "").strip() nickname = (profile.get("nickname") or "").strip() avatar = (profile.get("avatar_url") or "").strip() if uid: account.douyin_uid = uid if avatar: account.avatar_url = avatar if nickname or uid: account.username = await _pick_unique_username(db, account, nickname, uid) return profile