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:
CREDO23 2026-07-09 18:00:40 +02:00
parent 6652efd035
commit 192b6dc31a
24 changed files with 502 additions and 18 deletions

View file

@ -36,6 +36,7 @@ _PLATFORM_RATE_KEYS: dict[BillingUnit, str] = {
BillingUnit.YOUTUBE_VIDEO: "YOUTUBE_MICROS_PER_VIDEO",
BillingUnit.YOUTUBE_COMMENT: "YOUTUBE_MICROS_PER_COMMENT",
BillingUnit.TIKTOK_VIDEO: "TIKTOK_MICROS_PER_VIDEO",
BillingUnit.TIKTOK_USER: "TIKTOK_MICROS_PER_USER",
}
@ -53,6 +54,7 @@ _UNIT_NOUNS: dict[BillingUnit, str] = {
BillingUnit.YOUTUBE_VIDEO: "video",
BillingUnit.YOUTUBE_COMMENT: "comment",
BillingUnit.TIKTOK_VIDEO: "video",
BillingUnit.TIKTOK_USER: "profile",
}

View file

@ -26,6 +26,7 @@ class BillingUnit(StrEnum):
YOUTUBE_VIDEO = "youtube_video"
YOUTUBE_COMMENT = "youtube_comment"
TIKTOK_VIDEO = "tiktok_video"
TIKTOK_USER = "tiktok_user"
class BillableInput(Protocol):

View file

@ -3,3 +3,4 @@
from __future__ import annotations
from app.capabilities.tiktok.scrape import definition as _scrape # noqa: F401
from app.capabilities.tiktok.user_search import definition as _user_search # noqa: F401

View file

@ -0,0 +1,3 @@
"""``tiktok.user_search``: find public TikTok accounts by keyword."""
from __future__ import annotations

View file

@ -0,0 +1,26 @@
"""``tiktok.user_search`` capability registration (billed per account; see config
``TIKTOK_MICROS_PER_USER``)."""
from __future__ import annotations
from app.capabilities.core import BillingUnit, Capability, register_capability
from app.capabilities.tiktok.user_search.executor import build_user_search_executor
from app.capabilities.tiktok.user_search.schemas import (
UserSearchInput,
UserSearchOutput,
)
TIKTOK_USER_SEARCH = Capability(
name="tiktok.user_search",
description=(
"Find public TikTok accounts by keyword. Returns profile metadata "
"(name, followers, bio, verification) per matching account."
),
input_schema=UserSearchInput,
output_schema=UserSearchOutput,
executor=build_user_search_executor(),
billing_unit=BillingUnit.TIKTOK_USER,
docs_url="/docs/connectors/native/tiktok",
)
register_capability(TIKTOK_USER_SEARCH)

View file

@ -0,0 +1,39 @@
"""``tiktok.user_search`` executor: queries -> scraper -> TikTok profile items."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from app.capabilities.core import Executor
from app.capabilities.core.progress import emit_progress
from app.capabilities.tiktok.user_search.schemas import (
UserSearchInput,
UserSearchOutput,
)
from app.proprietary.platforms.tiktok import search_tiktok_users
SearchFn = Callable[..., Awaitable[list[dict]]]
def build_user_search_executor(search_fn: SearchFn | None = None) -> Executor:
"""Bind the executor to a search fn (defaults to the proprietary actor)."""
search_fn = search_fn or search_tiktok_users
async def execute(payload: UserSearchInput) -> UserSearchOutput:
emit_progress(
"starting",
"Searching TikTok accounts",
total=payload.max_items,
unit="item",
)
items = await search_fn(
payload.queries,
per_query=payload.results_per_query,
limit=payload.max_items,
)
emit_progress(
"done", f"Found {len(items)} account(s)", current=len(items), unit="item"
)
return UserSearchOutput(items=items)
return execute

View file

@ -0,0 +1,56 @@
"""``tiktok.user_search`` I/O contracts.
Account discovery over ``TikTok``'s Users tab. Where video/general search is
login-walled for anonymous sessions, ``/api/search/user`` returns public account
records, so this verb exposes the one reliably-unblocked search path. Each result
is a :class:`TikTokProfileItem` (the same shape the profile verb emits).
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from app.capabilities.tiktok.scrape.schemas import (
MAX_TIKTOK_ITEMS,
MAX_TIKTOK_SOURCES,
)
from app.proprietary.platforms.tiktok import TikTokProfileItem
class UserSearchInput(BaseModel):
queries: list[str] = Field(
min_length=1,
max_length=MAX_TIKTOK_SOURCES,
description="Keywords to search for TikTok accounts (e.g. names, brands).",
)
results_per_query: int = Field(
default=10,
ge=1,
le=MAX_TIKTOK_ITEMS,
description="Max accounts to return per query.",
)
max_items: int = Field(
default=10,
ge=1,
le=MAX_TIKTOK_ITEMS,
description="Max total accounts to return across all queries.",
)
@property
def estimated_units(self) -> int:
"""Worst-case billable accounts for the pre-flight gate: ``max_items`` is a
hard cross-query ceiling (le=100), so no call can exceed it."""
return self.max_items
class UserSearchOutput(BaseModel):
items: list[TikTokProfileItem] = Field(
default_factory=list,
description="One item per account found, in emission order.",
)
@property
def billable_units(self) -> int:
"""One returned account = one billable unit; ErrorItems (``errorCode`` set,
for empty/withheld queries) are surfaced but never charged."""
return sum(1 for item in self.items if not getattr(item, "errorCode", None))