feat(tiktok): URL target resolver

This commit is contained in:
CREDO23 2026-07-08 15:40:07 +02:00
parent ef3f9f2e25
commit b2d387f994
5 changed files with 130 additions and 0 deletions

View file

@ -0,0 +1,8 @@
"""TikTok URL classification into scrape targets."""
from __future__ import annotations
from .resolver import resolve_target
from .types import TargetKind, TikTokTarget
__all__ = ["TargetKind", "TikTokTarget", "resolve_target"]

View file

@ -0,0 +1,50 @@
"""Classify a TikTok URL into a :class:`TikTokTarget`, or ``None``."""
from __future__ import annotations
from urllib.parse import parse_qs, unquote, urlparse
from .types import SearchSection, TikTokTarget
_TIKTOK_HOSTS = frozenset({"tiktok.com", "www.tiktok.com", "m.tiktok.com"})
_SEARCH_SECTIONS: frozenset[SearchSection] = frozenset({"video", "user"})
def _is_tiktok_host(hostname: str | None) -> bool:
return bool(hostname) and hostname.lower() in _TIKTOK_HOSTS
def resolve_target(url: str) -> TikTokTarget | None:
parsed = urlparse(url)
if not _is_tiktok_host(parsed.hostname):
return None
segments = [s for s in (parsed.path or "").split("/") if s]
if not segments:
return None
# Profile / video live under /@username[...].
if segments[0].startswith("@"):
username = segments[0][1:]
if not username:
return None
if len(segments) >= 3 and segments[1] == "video" and segments[2]:
return TikTokTarget("video", segments[2], url, username=username)
return TikTokTarget("profile", username, url)
if segments[0] == "tag" and len(segments) >= 2 and segments[1]:
return TikTokTarget("hashtag", unquote(segments[1]), url)
if segments[0] == "search":
query = parse_qs(parsed.query).get("q", [None])[0]
if not query:
return None
section = segments[1] if len(segments) >= 2 else None
return TikTokTarget(
"search",
unquote(query),
url,
section=section if section in _SEARCH_SECTIONS else None,
)
return None

View file

@ -0,0 +1,25 @@
"""Scrape-target value object produced by URL classification."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
TargetKind = Literal["video", "profile", "hashtag", "search"]
SearchSection = Literal["video", "user"]
@dataclass(frozen=True, slots=True)
class TikTokTarget:
"""One classified scrape target.
``value`` holds the kind-specific identifier: video id, username, hashtag
name, or search query. ``username`` is set for videos (needed to build the
canonical post URL). ``section`` narrows a search to videos or users.
"""
kind: TargetKind
value: str
url: str
username: str | None = None
section: SearchSection | None = None

View file

@ -0,0 +1,47 @@
"""URL classification for the TikTok scraper (pure, no network)."""
from __future__ import annotations
from app.proprietary.platforms.tiktok.targets import resolve_target
def test_resolve_video_carries_username_and_id():
target = resolve_target("https://www.tiktok.com/@scout2015/video/6718335390845095173")
assert target is not None
assert target.kind == "video"
assert target.value == "6718335390845095173"
assert target.username == "scout2015"
def test_resolve_profile():
target = resolve_target("https://www.tiktok.com/@scout2015")
assert target is not None
assert target.kind == "profile"
assert target.value == "scout2015"
def test_resolve_hashtag():
target = resolve_target("https://www.tiktok.com/tag/funny")
assert target is not None
assert target.kind == "hashtag"
assert target.value == "funny"
def test_resolve_search_top_video_and_user_sections():
top = resolve_target("https://www.tiktok.com/search?q=cats")
assert top is not None
assert top.kind == "search"
assert top.value == "cats"
assert top.section is None
videos = resolve_target("https://www.tiktok.com/search/video?q=cats")
assert videos is not None and videos.section == "video"
users = resolve_target("https://www.tiktok.com/search/user?q=cats")
assert users is not None and users.section == "user"
def test_resolve_rejects_non_tiktok_and_unknown_paths():
assert resolve_target("https://example.com/@scout2015") is None
assert resolve_target("https://www.tiktok.com/") is None
assert resolve_target("https://www.tiktok.com/foundation") is None