mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-16 23:01:06 +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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""User-search orchestration over a fake fetch (no network).
|
||||
|
||||
Drives ``search_tiktok_users``: queries -> captured ``user_info`` -> profile items.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.proprietary.platforms.tiktok import search_tiktok_users
|
||||
|
||||
|
||||
def _user(uid: str, unique_id: str, followers: int = 10) -> dict[str, Any]:
|
||||
return {
|
||||
"uid": uid,
|
||||
"unique_id": unique_id,
|
||||
"nickname": unique_id.upper(),
|
||||
"signature": "bio",
|
||||
"follower_count": followers,
|
||||
"total_favorited": 999,
|
||||
"sec_uid": f"sec-{uid}",
|
||||
"enterprise_verify_reason": "official" if uid == "1" else "",
|
||||
"avatar_thumb": {"url_list": [f"https://cdn/{uid}.webp"]},
|
||||
}
|
||||
|
||||
|
||||
async def test_user_search_parses_dedupes_and_caps():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return [_user("1", "nasa"), _user("1", "nasa"), _user("2", "nasa2")]
|
||||
|
||||
items = await search_tiktok_users(
|
||||
["nasa"], per_query=2, fetch_users=fake_fetch
|
||||
)
|
||||
|
||||
assert [i["id"] for i in items] == ["1", "2"]
|
||||
first = items[0]
|
||||
assert first["name"] == "nasa"
|
||||
assert first["nickName"] == "NASA"
|
||||
assert first["profileUrl"] == "https://www.tiktok.com/@nasa"
|
||||
assert first["verified"] is True
|
||||
assert first["fans"] == 10
|
||||
assert first["avatar"] == "https://cdn/1.webp"
|
||||
assert first["secUid"] == "sec-1"
|
||||
assert first["scrapedAt"] is not None
|
||||
assert items[1]["verified"] is False
|
||||
|
||||
|
||||
async def test_user_search_empty_query_emits_error_item():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
items = await search_tiktok_users(
|
||||
["ghost"], per_query=5, fetch_users=fake_fetch
|
||||
)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["errorCode"] == "no_users"
|
||||
assert items[0]["input"] == "ghost"
|
||||
|
||||
|
||||
async def test_user_search_honors_limit_across_queries():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return [_user("1", "a"), _user("2", "b")]
|
||||
|
||||
items = await search_tiktok_users(
|
||||
["q1", "q2"], per_query=5, limit=3, fetch_users=fake_fetch
|
||||
)
|
||||
|
||||
# 2 from q1 + 1 from q2, then the cross-query limit stops it.
|
||||
assert len(items) == 3
|
||||
Loading…
Add table
Add a link
Reference in a new issue