feat(tiktok): return profile metadata even when video listing is gated

A profile's account data (name, followers, bio, verification) lives in the
page's rehydration blob and loads over plain HTTP without a signed request,
so emit it first and always. The video listing needs a signed item_list XHR
that TikTok withholds from anonymous sessions, so it stays best-effort and
degrades to an ErrorItem. A blocked profile now yields its metadata instead
of only an ErrorItem.
This commit is contained in:
CREDO23 2026-07-09 17:48:19 +02:00
parent ba375ea3ee
commit 6652efd035
7 changed files with 140 additions and 2 deletions

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from .author import parse_author
from .author import parse_author, parse_profile
from .hydration import extract_rehydration_data
from .item_list import items_from_response
from .scopes import user_info, video_item_struct
@ -12,6 +12,7 @@ __all__ = [
"extract_rehydration_data",
"items_from_response",
"parse_author",
"parse_profile",
"parse_video",
"user_info",
"video_item_struct",

View file

@ -29,3 +29,10 @@ def build_author_meta(author: dict[str, Any], stats: dict[str, Any]) -> dict[str
def parse_author(user_info: dict[str, Any]) -> dict[str, Any]:
"""Map a ``webapp.user-detail`` ``userInfo`` (``{user, stats}``) to authorMeta."""
return build_author_meta(user_info.get("user") or {}, user_info.get("stats") or {})
def parse_profile(user_info: dict[str, Any]) -> dict[str, Any]:
"""Map a ``userInfo`` to a standalone :class:`TikTokProfileItem` output dict."""
from ..schemas.items import TikTokProfileItem
return TikTokProfileItem(**parse_author(user_info)).to_output()

View file

@ -0,0 +1,37 @@
"""Profile flow: reliable blob metadata first, then the (gated) video listing.
A profile's account data (name, followers, bio, verification) lives in the page's
rehydration blob and loads over plain HTTP without a signed request, so we emit it
first and always. The video listing needs a signed ``item_list`` XHR that TikTok
withholds from anonymous sessions, so it is best-effort: it streams videos when it
loads and degrades to an ErrorItem (via :func:`iter_listing`) when withheld. The
metadata item therefore survives even when the videos are blocked.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from ..extraction import extract_rehydration_data, parse_profile, user_info
from ..extraction.timestamps import now_iso
from ..targets.types import TikTokTarget
from . import FetchFn, FetchListingFn
from .listing import iter_listing
async def iter_profile(
target: TikTokTarget,
*,
cap: int,
fetch: FetchFn,
fetch_listing: FetchListingFn,
) -> AsyncIterator[dict[str, Any]]:
html = await fetch(target.url)
info = user_info(extract_rehydration_data(html) or {}) if html else None
if info:
item = parse_profile(info)
item["scrapedAt"] = now_iso()
yield item
async for out in iter_listing(target, cap=cap, fetch_listing=fetch_listing):
yield out

View file

@ -14,6 +14,7 @@ from urllib.parse import quote
from .flows import FetchFn, FetchListingFn
from .flows.listing import iter_listing
from .flows.profile import iter_profile
from .flows.video import iter_video
from .schemas import TikTokScrapeInput
from .session import fetch_html, fetch_item_list
@ -57,6 +58,8 @@ def _dispatch(
) -> AsyncIterator[dict[str, Any]]:
if target.kind == "video":
return iter_video(target, fetch=fetch)
if target.kind == "profile":
return iter_profile(target, cap=cap, fetch=fetch, fetch_listing=fetch_listing)
return iter_listing(target, cap=cap, fetch_listing=fetch_listing)

View file

@ -8,6 +8,7 @@ from .items import (
CommentItem,
ErrorItem,
MusicMeta,
TikTokProfileItem,
TikTokVideoItem,
VideoMeta,
)
@ -18,6 +19,7 @@ __all__ = [
"ErrorItem",
"MusicMeta",
"StartUrl",
"TikTokProfileItem",
"TikTokScrapeInput",
"TikTokVideoItem",
"VideoMeta",

View file

@ -30,6 +30,21 @@ class AuthorMeta(BaseModel):
video: int | None = None
class TikTokProfileItem(AuthorMeta):
"""A profile's public metadata, read from the page's rehydration blob.
Emitted even when the video listing is withheld from anonymous access, so a
blocked profile still yields its account data (name, followers, bio,
verification) instead of only an ErrorItem. Distinguishable from a video item
by the absence of ``webVideoUrl``/``text``.
"""
scrapedAt: str | None = None
def to_output(self) -> dict[str, Any]:
return self.model_dump(exclude_none=False)
class MusicMeta(BaseModel):
model_config = ConfigDict(extra="allow")