feat(tiktok): add tiktok.comments verb for public comment scraping

Comments load over a signed /api/comment/list XHR that TikTok serves to
anonymous sessions once the comments panel is opened (unlike profile-video and
general-search feeds), so this is a reliable verb. Given video URLs it returns
CommentItems (text, author, likes, reply counts; replies carry repliesToId),
deduped per video, capped, and degraded to an ErrorItem for empty/withheld
videos or a bad_url ErrorItem for non-video inputs.

Generalizes the browser capture over a pluggable interaction step so the
comments flow (open panel, scroll the panel to paginate) reuses the same
warm+capture scaffolding as listing/user-search. Billed per comment on a new
TIKTOK_COMMENT meter (TIKTOK_MICROS_PER_COMMENT, matching the per-comment
market), surfaced on the chat subagent alongside tiktok.scrape/user_search.
This commit is contained in:
CREDO23 2026-07-09 18:35:26 +02:00
parent 192b6dc31a
commit 7723b5b8b6
24 changed files with 626 additions and 26 deletions

View file

@ -37,6 +37,7 @@ _PLATFORM_RATE_KEYS: dict[BillingUnit, str] = {
BillingUnit.YOUTUBE_COMMENT: "YOUTUBE_MICROS_PER_COMMENT",
BillingUnit.TIKTOK_VIDEO: "TIKTOK_MICROS_PER_VIDEO",
BillingUnit.TIKTOK_USER: "TIKTOK_MICROS_PER_USER",
BillingUnit.TIKTOK_COMMENT: "TIKTOK_MICROS_PER_COMMENT",
}
@ -55,6 +56,7 @@ _UNIT_NOUNS: dict[BillingUnit, str] = {
BillingUnit.YOUTUBE_COMMENT: "comment",
BillingUnit.TIKTOK_VIDEO: "video",
BillingUnit.TIKTOK_USER: "profile",
BillingUnit.TIKTOK_COMMENT: "comment",
}

View file

@ -27,6 +27,7 @@ class BillingUnit(StrEnum):
YOUTUBE_COMMENT = "youtube_comment"
TIKTOK_VIDEO = "tiktok_video"
TIKTOK_USER = "tiktok_user"
TIKTOK_COMMENT = "tiktok_comment"
class BillableInput(Protocol):

View file

@ -2,5 +2,6 @@
from __future__ import annotations
from app.capabilities.tiktok.comments import definition as _comments # noqa: F401
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.comments``: scrape the public comment thread of TikTok videos."""
from __future__ import annotations

View file

@ -0,0 +1,23 @@
"""``tiktok.comments`` capability registration (billed per comment; see config
``TIKTOK_MICROS_PER_COMMENT``)."""
from __future__ import annotations
from app.capabilities.core import BillingUnit, Capability, register_capability
from app.capabilities.tiktok.comments.executor import build_comments_executor
from app.capabilities.tiktok.comments.schemas import CommentsInput, CommentsOutput
TIKTOK_COMMENTS = Capability(
name="tiktok.comments",
description=(
"Scrape the public comments of TikTok videos. Provide video URLs; "
"returns comment text, author, likes, and reply counts."
),
input_schema=CommentsInput,
output_schema=CommentsOutput,
executor=build_comments_executor(),
billing_unit=BillingUnit.TIKTOK_COMMENT,
docs_url="/docs/connectors/native/tiktok",
)
register_capability(TIKTOK_COMMENTS)

View file

@ -0,0 +1,36 @@
"""``tiktok.comments`` executor: video URLs -> scraper -> TikTok comment 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.comments.schemas import CommentsInput, CommentsOutput
from app.proprietary.platforms.tiktok import scrape_tiktok_comments
CommentsFn = Callable[..., Awaitable[list[dict]]]
def build_comments_executor(comments_fn: CommentsFn | None = None) -> Executor:
"""Bind the executor to a comments fn (defaults to the proprietary actor)."""
comments_fn = comments_fn or scrape_tiktok_comments
async def execute(payload: CommentsInput) -> CommentsOutput:
emit_progress(
"starting",
"Scraping TikTok comments",
total=payload.max_items,
unit="item",
)
items = await comments_fn(
payload.video_urls,
per_video=payload.comments_per_video,
limit=payload.max_items,
)
emit_progress(
"done", f"Scraped {len(items)} comment(s)", current=len(items), unit="item"
)
return CommentsOutput(items=items)
return execute

View file

@ -0,0 +1,56 @@
"""``tiktok.comments`` I/O contracts.
URL-only: given TikTok video URLs, return each video's public comment thread.
Unlike profile-video/general-search feeds, ``/api/comment/list`` is served to
anonymous sessions once the comments panel opens, so this verb is reliable. Each
result is a :class:`CommentItem` (top-level comments; replies carry ``repliesToId``).
"""
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 CommentItem
class CommentsInput(BaseModel):
video_urls: list[str] = Field(
min_length=1,
max_length=MAX_TIKTOK_SOURCES,
description="TikTok video URLs (/@<user>/video/<id>) to pull comments from.",
)
comments_per_video: int = Field(
default=20,
ge=1,
le=MAX_TIKTOK_ITEMS,
description="Max comments to return per video.",
)
max_items: int = Field(
default=20,
ge=1,
le=MAX_TIKTOK_ITEMS,
description="Max total comments to return across all videos.",
)
@property
def estimated_units(self) -> int:
"""Worst-case billable comments for the pre-flight gate: ``max_items`` is a
hard cross-video ceiling (le=100), so no call can exceed it."""
return self.max_items
class CommentsOutput(BaseModel):
items: list[CommentItem] = Field(
default_factory=list,
description="One item per comment returned, in emission order.",
)
@property
def billable_units(self) -> int:
"""One returned comment = one billable unit; ErrorItems (``errorCode`` set,
for bad URLs or empty/withheld videos) are surfaced but never charged."""
return sum(1 for item in self.items if not getattr(item, "errorCode", None))