mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-18 23:11:12 +02:00
feat(tiktok): add tiktok.user_search verb for account discovery
Video/general search is login-walled for anonymous sessions, but the Users tab (/api/search/user) returns public account records without a redirect, so this exposes the one reliably-unblocked search path. A keyword yields TikTokProfileItems (name, followers, bio, verification), deduped per query, capped, and degraded to an ErrorItem when a query is empty/withheld. Reuses the browser capture (generalized over XHR markers + extractor) and the shared profile item shape. Billed per account on a new TIKTOK_USER meter (TIKTOK_MICROS_PER_USER), surfaced on the chat subagent alongside tiktok.scrape.
This commit is contained in:
parent
6652efd035
commit
192b6dc31a
24 changed files with 502 additions and 18 deletions
|
|
@ -6,14 +6,16 @@ schema, the collector/generator, the video item shape, and the hard-block error.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from .orchestrator import iter_tiktok, scrape_tiktok
|
||||
from .schemas import TikTokScrapeInput, TikTokVideoItem
|
||||
from .orchestrator import iter_tiktok, scrape_tiktok, search_tiktok_users
|
||||
from .schemas import TikTokProfileItem, TikTokScrapeInput, TikTokVideoItem
|
||||
from .session import TikTokAccessBlockedError
|
||||
|
||||
__all__ = [
|
||||
"TikTokAccessBlockedError",
|
||||
"TikTokProfileItem",
|
||||
"TikTokScrapeInput",
|
||||
"TikTokVideoItem",
|
||||
"iter_tiktok",
|
||||
"scrape_tiktok",
|
||||
"search_tiktok_users",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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
|
||||
from .user_search import parse_search_user, users_from_response
|
||||
from .video import parse_video
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -13,7 +14,9 @@ __all__ = [
|
|||
"items_from_response",
|
||||
"parse_author",
|
||||
"parse_profile",
|
||||
"parse_search_user",
|
||||
"parse_video",
|
||||
"user_info",
|
||||
"users_from_response",
|
||||
"video_item_struct",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
"""Parse the ``/api/search/user`` response into profile items.
|
||||
|
||||
User search returns ``{"user_list": [{"user_info": {...}}, ...]}`` where each
|
||||
``user_info`` uses the mobile-API snake_case shape (``uid``, ``unique_id``,
|
||||
``follower_count``, ``total_favorited``, ``avatar_thumb.url_list``) — distinct
|
||||
from the camelCase ``webapp.user-detail`` blob the profile flow reads, so it gets
|
||||
its own mapping into the shared :class:`TikTokProfileItem` output contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_PROFILE_URL = "https://www.tiktok.com/@{username}"
|
||||
|
||||
|
||||
def users_from_response(body: Any) -> list[dict[str, Any]]:
|
||||
"""Return the ``user_info`` objects carried by one search response, or ``[]``."""
|
||||
if not isinstance(body, dict):
|
||||
return []
|
||||
user_list = body.get("user_list")
|
||||
if not isinstance(user_list, list):
|
||||
return []
|
||||
return [
|
||||
entry["user_info"]
|
||||
for entry in user_list
|
||||
if isinstance(entry, dict) and isinstance(entry.get("user_info"), dict)
|
||||
]
|
||||
|
||||
|
||||
def _avatar(user_info: dict[str, Any]) -> str | None:
|
||||
thumb = user_info.get("avatar_thumb")
|
||||
if isinstance(thumb, dict):
|
||||
urls = thumb.get("url_list")
|
||||
if isinstance(urls, list) and urls:
|
||||
return urls[0]
|
||||
return None
|
||||
|
||||
|
||||
def parse_search_user(user_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map a search ``user_info`` to a :class:`TikTokProfileItem` output dict."""
|
||||
from ..schemas.items import TikTokProfileItem
|
||||
|
||||
username = user_info.get("unique_id")
|
||||
return TikTokProfileItem(
|
||||
id=user_info.get("uid"),
|
||||
name=username,
|
||||
nickName=user_info.get("nickname"),
|
||||
profileUrl=_PROFILE_URL.format(username=username) if username else None,
|
||||
verified=bool(user_info.get("enterprise_verify_reason")),
|
||||
signature=user_info.get("signature"),
|
||||
avatar=_avatar(user_info),
|
||||
fans=user_info.get("follower_count"),
|
||||
heart=user_info.get("total_favorited"),
|
||||
secUid=user_info.get("sec_uid"),
|
||||
).to_output()
|
||||
|
|
@ -10,4 +10,7 @@ FetchFn = Callable[[str], Awaitable[str | None]]
|
|||
FetchListingFn = Callable[[str, int], Awaitable[list[dict]]]
|
||||
"""Load a listing page and return up to ``count`` captured itemStructs."""
|
||||
|
||||
FetchUsersFn = Callable[[str, int], Awaitable[list[dict]]]
|
||||
"""Load a user-search page and return up to ``count`` captured ``user_info`` records."""
|
||||
|
||||
FlowResult = AsyncIterator[dict]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
"""User-search flow: keyword -> public account records.
|
||||
|
||||
Unlike video/general search (login-walled for anonymous sessions), the Users tab
|
||||
hits ``/api/search/user`` and returns account records without a redirect. Each
|
||||
query's results are deduped by uid, capped, and — when a query returns nothing —
|
||||
degraded to one ErrorItem, mirroring the listing flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from ..extraction import parse_search_user
|
||||
from ..extraction.timestamps import now_iso
|
||||
from ..schemas import ErrorItem
|
||||
from . import FetchUsersFn
|
||||
|
||||
_USER_SEARCH_URL = "https://www.tiktok.com/search/user?q={query}"
|
||||
_EMPTY_MESSAGE = (
|
||||
"No accounts returned for this query. It may have no matches, or TikTok "
|
||||
"withheld the results from anonymous access."
|
||||
)
|
||||
|
||||
|
||||
async def iter_user_search(
|
||||
query: str, *, cap: int, fetch_users: FetchUsersFn
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
if cap <= 0:
|
||||
return
|
||||
url = _USER_SEARCH_URL.format(query=quote(query))
|
||||
seen: set[str] = set()
|
||||
emitted = 0
|
||||
for user_info in await fetch_users(url, cap):
|
||||
out = parse_search_user(user_info)
|
||||
uid = out.get("id")
|
||||
if uid is not None:
|
||||
if uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
out["scrapedAt"] = now_iso()
|
||||
yield out
|
||||
emitted += 1
|
||||
if emitted >= cap:
|
||||
return
|
||||
if emitted == 0:
|
||||
yield ErrorItem(
|
||||
url=url,
|
||||
input=query,
|
||||
error=_EMPTY_MESSAGE,
|
||||
errorCode="no_users",
|
||||
scrapedAt=now_iso(),
|
||||
).to_output()
|
||||
|
|
@ -12,12 +12,13 @@ from collections.abc import AsyncIterator
|
|||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from .flows import FetchFn, FetchListingFn
|
||||
from .flows import FetchFn, FetchListingFn, FetchUsersFn
|
||||
from .flows.listing import iter_listing
|
||||
from .flows.profile import iter_profile
|
||||
from .flows.user_search import iter_user_search
|
||||
from .flows.video import iter_video
|
||||
from .schemas import TikTokScrapeInput
|
||||
from .session import fetch_html, fetch_item_list
|
||||
from .session import fetch_html, fetch_item_list, fetch_user_search
|
||||
from .targets import resolve_target
|
||||
from .targets.types import TikTokTarget
|
||||
|
||||
|
|
@ -100,3 +101,25 @@ async def scrape_tiktok(
|
|||
if limit is not None and len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
async def search_tiktok_users(
|
||||
queries: list[str],
|
||||
*,
|
||||
per_query: int,
|
||||
limit: int | None = None,
|
||||
fetch_users: FetchUsersFn = fetch_user_search,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Collect user-search account records across queries, honoring ``limit``."""
|
||||
from app.capabilities.core.progress import emit_progress
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for query in queries:
|
||||
async for item in iter_user_search(
|
||||
query, cap=per_query, fetch_users=fetch_users
|
||||
):
|
||||
results.append(item)
|
||||
emit_progress("searching", current=len(results), total=limit, unit="item")
|
||||
if limit is not None and len(results) >= limit:
|
||||
return results
|
||||
return results
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
from .client import fetch_html
|
||||
from .errors import TikTokAccessBlockedError
|
||||
from .listing import fetch_item_list
|
||||
from .listing import fetch_item_list, fetch_user_search
|
||||
from .proxy import bind_proxy_holder, open_proxy_holder, proxy_session
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -12,6 +12,7 @@ __all__ = [
|
|||
"bind_proxy_holder",
|
||||
"fetch_html",
|
||||
"fetch_item_list",
|
||||
"fetch_user_search",
|
||||
"open_proxy_holder",
|
||||
"proxy_session",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from scrapling.fetchers import StealthyFetcher
|
||||
|
|
@ -29,16 +30,20 @@ from app.proprietary.web_crawler.stealth import (
|
|||
)
|
||||
from app.utils.proxy import get_proxy_url
|
||||
|
||||
from ..extraction import items_from_response
|
||||
from ..extraction import items_from_response, users_from_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ExtractFn = Callable[[Any], list[dict[str, Any]]]
|
||||
|
||||
# XHR paths that carry itemStructs for the three listing kinds.
|
||||
_ITEM_LIST_MARKERS = (
|
||||
"/api/post/item_list",
|
||||
"/api/challenge/item_list",
|
||||
"/api/search/",
|
||||
)
|
||||
# The user-search XHR carries account records (user_list), not itemStructs.
|
||||
_USER_SEARCH_MARKERS = ("/api/search/user",)
|
||||
_HOME_URL = "https://www.tiktok.com/"
|
||||
_MSTOKEN_COOKIE = "msToken"
|
||||
# Bounded scroll: a dead page can't loop forever, and a live one stops early
|
||||
|
|
@ -57,24 +62,31 @@ def _has_mstoken(page: Any) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _build_page_action(collected: list[dict[str, Any]], url: str, target_count: int):
|
||||
"""A sync ``page_action`` that warms the session then captures item_list XHRs.
|
||||
def _build_page_action(
|
||||
collected: list[dict[str, Any]],
|
||||
url: str,
|
||||
target_count: int,
|
||||
markers: tuple[str, ...],
|
||||
extract: ExtractFn,
|
||||
):
|
||||
"""A sync ``page_action`` that warms the session then captures matching XHRs.
|
||||
|
||||
A cold context returns an empty ``item_list`` body, so we first mint the
|
||||
anonymous ``msToken`` (homepage hit), then navigate to the target with the
|
||||
listener already attached so page-one fires into it; scrolling pages the rest.
|
||||
A cold context returns an empty body, so we first mint the anonymous
|
||||
``msToken`` (homepage hit), then navigate to the target with the listener
|
||||
already attached so page-one fires into it; scrolling pages the rest.
|
||||
``markers``/``extract`` select which XHRs to keep and how to unwrap them.
|
||||
"""
|
||||
|
||||
def _on_response(response: Any) -> None:
|
||||
response_url = getattr(response, "url", "")
|
||||
if not any(marker in response_url for marker in _ITEM_LIST_MARKERS):
|
||||
if not any(marker in response_url for marker in markers):
|
||||
return
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
# An empty 200 (TikTok soft-block) or a body evicted before read.
|
||||
return
|
||||
collected.extend(items_from_response(body))
|
||||
collected.extend(extract(body))
|
||||
|
||||
def _warm(page: Any) -> None:
|
||||
if _has_mstoken(page):
|
||||
|
|
@ -110,7 +122,9 @@ def _build_page_action(collected: list[dict[str, Any]], url: str, target_count:
|
|||
return page_action
|
||||
|
||||
|
||||
def _fetch_sync(url: str, target_count: int) -> list[dict[str, Any]]:
|
||||
def _fetch_sync(
|
||||
url: str, target_count: int, markers: tuple[str, ...], extract: ExtractFn
|
||||
) -> list[dict[str, Any]]:
|
||||
collected: list[dict[str, Any]] = []
|
||||
kwargs = build_stealthy_kwargs(get_stealth_config())
|
||||
StealthyFetcher.fetch(
|
||||
|
|
@ -118,7 +132,9 @@ def _fetch_sync(url: str, target_count: int) -> list[dict[str, Any]]:
|
|||
headless=True,
|
||||
network_idle=False,
|
||||
proxy=get_proxy_url(),
|
||||
page_action=_build_page_action(collected, url, target_count),
|
||||
page_action=_build_page_action(
|
||||
collected, url, target_count, markers, extract
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
return collected[:target_count]
|
||||
|
|
@ -126,4 +142,13 @@ def _fetch_sync(url: str, target_count: int) -> list[dict[str, Any]]:
|
|||
|
||||
async def fetch_item_list(page_url: str, target_count: int) -> list[dict[str, Any]]:
|
||||
"""Return up to ``target_count`` itemStructs from a listing page's XHRs."""
|
||||
return await asyncio.to_thread(_fetch_sync, page_url, target_count)
|
||||
return await asyncio.to_thread(
|
||||
_fetch_sync, page_url, target_count, _ITEM_LIST_MARKERS, items_from_response
|
||||
)
|
||||
|
||||
|
||||
async def fetch_user_search(page_url: str, target_count: int) -> list[dict[str, Any]]:
|
||||
"""Return up to ``target_count`` ``user_info`` records from a user-search page."""
|
||||
return await asyncio.to_thread(
|
||||
_fetch_sync, page_url, target_count, _USER_SEARCH_MARKERS, users_from_response
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue