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

@ -10,6 +10,10 @@ from app.capabilities import (
from app.capabilities.core import BillingUnit
from app.capabilities.core.store import get_capability
from app.capabilities.tiktok.scrape.schemas import ScrapeInput, ScrapeOutput
from app.capabilities.tiktok.user_search.schemas import (
UserSearchInput,
UserSearchOutput,
)
pytestmark = pytest.mark.unit
@ -21,3 +25,12 @@ def test_tiktok_scrape_is_registered_and_billed_per_video():
assert cap.input_schema is ScrapeInput
assert cap.output_schema is ScrapeOutput
assert cap.billing_unit is BillingUnit.TIKTOK_VIDEO
def test_tiktok_user_search_is_registered_and_billed_per_profile():
cap = get_capability("tiktok.user_search")
assert cap.name == "tiktok.user_search"
assert cap.input_schema is UserSearchInput
assert cap.output_schema is UserSearchOutput
assert cap.billing_unit is BillingUnit.TIKTOK_USER

View file

@ -0,0 +1,49 @@
"""``tiktok.user_search`` executor: verb input → search args → typed profile items.
Boundary mocked: the proprietary search actor (injected fake). NOT mocked: the
verb's own payload→args forwarding and the dict→TikTokProfileItem wrapping.
"""
from __future__ import annotations
import pytest
from app.capabilities.tiktok.user_search.executor import build_user_search_executor
from app.capabilities.tiktok.user_search.schemas import (
UserSearchInput,
UserSearchOutput,
)
pytestmark = pytest.mark.unit
class _FakeSearch:
"""Records the queries + kwargs it was called with; returns canned items."""
def __init__(self, items: list[dict]):
self._items = items
self.calls: list[tuple[list[str], int, int | None]] = []
async def __call__(
self, queries: list[str], *, per_query: int, limit: int | None = None
) -> list[dict]:
self.calls.append((queries, per_query, limit))
return self._items
async def test_forwards_queries_and_limits_and_wraps_items():
search = _FakeSearch([{"id": "1", "name": "nasa"}])
execute = build_user_search_executor(search_fn=search)
out = await execute(
UserSearchInput(queries=["nasa"], results_per_query=7, max_items=25)
)
assert isinstance(out, UserSearchOutput)
assert len(out.items) == 1
assert out.items[0].name == "nasa"
(queries, per_query, limit) = search.calls[0]
assert queries == ["nasa"]
assert per_query == 7
assert limit == 25

View file

@ -0,0 +1,49 @@
"""``tiktok.user_search`` input guards and billing: a query is required, bounded,
and ErrorItems are surfaced free."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.capabilities.tiktok.scrape.schemas import MAX_TIKTOK_ITEMS, MAX_TIKTOK_SOURCES
from app.capabilities.tiktok.user_search.schemas import (
UserSearchInput,
UserSearchOutput,
)
pytestmark = pytest.mark.unit
def test_rejects_input_with_no_query():
with pytest.raises(ValidationError):
UserSearchInput(queries=[])
def test_defaults_and_bounds():
payload = UserSearchInput(queries=["nasa"])
assert payload.max_items == 10
assert payload.results_per_query == 10
assert payload.estimated_units == 10
with pytest.raises(ValidationError):
UserSearchInput(queries=["nasa"], max_items=0)
with pytest.raises(ValidationError):
UserSearchInput(queries=["nasa"], max_items=MAX_TIKTOK_ITEMS + 1)
def test_rejects_more_queries_than_the_cap():
too_many = [f"q{i}" for i in range(MAX_TIKTOK_SOURCES + 1)]
with pytest.raises(ValidationError):
UserSearchInput(queries=too_many)
def test_error_items_are_not_billed():
# Real accounts count; ErrorItems (empty/withheld queries) are surfaced free.
out = UserSearchOutput(
items=[
{"id": "1", "name": "nasa"},
{"errorCode": "no_users", "input": "ghost", "error": "empty"},
]
)
assert len(out.items) == 2
assert out.billable_units == 1