mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
feat(tiktok): URL target resolver
This commit is contained in:
parent
ef3f9f2e25
commit
b2d387f994
5 changed files with 130 additions and 0 deletions
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue