"""Tests for marketing_intelligence.scraper.extractor.TikTokExtractor. All tests use real-shaped but anonymised HTML fixtures. No browser, no network — pure unit tests. """ from __future__ import annotations import json from typing import Any import pytest from marketing_intelligence.scraper.extractor import TikTokExtractor # ─────────────────────────── helpers / fixtures ──────────────────────────── def _make_state_html(item: dict, extra_scope: dict | None = None) -> str: scope = {"webapp.video-detail": {"itemInfo": {"itemStruct": item}}} if extra_scope: scope |= extra_scope blob = {"__DEFAULT_SCOPE__": scope} return ( f'" ) def _make_tag_html(video_ids: list[str], authors: list[str] | None = None) -> str: authors = authors or [f"user{i}" for i in range(len(video_ids))] links = "".join( f'video' for a, v in zip(authors, video_ids, strict=False) ) return f"
{links}
12.3K videos" # ─────────────────────────── state_blob_from_html ────────────────────────── class TestStateBlobFromHtml: def test_returns_none_when_no_script_tag(self): assert TikTokExtractor.state_blob_from_html("") is None def test_returns_dict_when_script_present(self) -> None: blob: dict[str, Any] = {"__DEFAULT_SCOPE__": {}} html = ( f'" ) result = TikTokExtractor.state_blob_from_html(html) assert result == blob def test_returns_none_on_malformed_json(self): html = '' assert TikTokExtractor.state_blob_from_html(html) is None # ──────────────────────── video_metrics_from_state ───────────────────────── class TestVideoMetricsFromState: def _make_blob( self, stats: dict, challenges: list | None = None, create_time: int = 0 ) -> dict: item = { "stats": stats, "createTime": create_time, "challenges": challenges or [], } return { "__DEFAULT_SCOPE__": { "webapp.video-detail": {"itemInfo": {"itemStruct": item}} } } def test_extracts_all_stats(self): blob = self._make_blob( stats={ "playCount": 5000, "diggCount": 200, "commentCount": 30, "shareCount": 10, "collectCount": 5, }, create_time=1700000000, ) result = TikTokExtractor.video_metrics_from_state(blob) assert result is not None assert result["views"] == 5000 assert result["likes"] == 200 assert result["comments"] == 30 assert result["shares"] == 10 assert result["favorites"] == 5 def test_extracts_hashtags_from_challenges(self): blob = self._make_blob( stats={"playCount": 100}, challenges=[{"title": "foryou"}, {"title": "viral"}, {}], ) result = TikTokExtractor.video_metrics_from_state(blob) assert result is not None assert "foryou" in result["hashtags"] assert "viral" in result["hashtags"] assert len(result["hashtags"]) == 2 # empty title skipped def test_returns_none_when_item_struct_missing(self) -> None: blob: dict[str, Any] = {"__DEFAULT_SCOPE__": {"webapp.video-detail": {}}} assert TikTokExtractor.video_metrics_from_state(blob) is None def test_created_at_is_iso_string(self): blob = self._make_blob(stats={"playCount": 0}, create_time=1700000000) result = TikTokExtractor.video_metrics_from_state(blob) assert result is not None assert result["created_at"] is not None assert "T" in result["created_at"] # ──────────────────────────────── tag_videos ─────────────────────────────── class TestTagVideos: def test_parses_video_urls(self): html = _make_tag_html(["111", "222", "333"]) videos, _ = TikTokExtractor.tag_videos(html) video_ids = [v.video_id for v in videos] assert set(video_ids) == {"111", "222", "333"} def test_deduplicates_videos(self): html = _make_tag_html(["111", "111", "222"]) videos, _ = TikTokExtractor.tag_videos(html) assert len(videos) == 2 def test_extracts_author(self): html = _make_tag_html(["7001"], ["artistname"]) videos, _ = TikTokExtractor.tag_videos(html) assert videos[0].author == "artistname" def test_returns_video_count_text(self): html = _make_tag_html(["111"]) _, count_text = TikTokExtractor.tag_videos(html) assert count_text == "12.3K videos" def test_empty_html_returns_empty_list(self): videos, count = TikTokExtractor.tag_videos("") assert videos == [] assert count is None # ──────────────────────── sound_id_from_url ──────────────────────────────── class TestSoundIdFromUrl: def test_extracts_id_from_music_url(self): url = "https://www.tiktok.com/music/some-track-name-7123456789" assert TikTokExtractor.sound_id_from_url(url) == "7123456789" def test_returns_none_for_non_matching_url(self): assert ( TikTokExtractor.sound_id_from_url("https://www.tiktok.com/tag/foryou") is None ) def test_extracts_last_numeric_segment(self): url = "https://www.tiktok.com/music/feet-dont-fail-me-now-7000000001" assert TikTokExtractor.sound_id_from_url(url) == "7000000001" # ──────────────────────── author_and_video_id ────────────────────────────── class TestAuthorAndVideoId: def test_parses_standard_url(self): url = "https://www.tiktok.com/@joycrookesmusic/video/7649525945887509782" author, vid = TikTokExtractor.author_and_video_id(url) assert author == "joycrookesmusic" assert vid == "7649525945887509782" def test_returns_none_tuple_for_non_video_url(self): author, vid = TikTokExtractor.author_and_video_id( "https://www.tiktok.com/tag/foryou" ) assert author is None assert vid is None # ──────────────────────── video_count ────────────────────────────────────── class TestVideoCount: @pytest.mark.parametrize( "html,expected", [ ("942.9K Videos", "942.9K Videos"), ("1.2M videos", "1.2M videos"), ("500 Videos", "500 Videos"), ("
no count here
", None), ], ) def test_extracts_count_text(self, html: str, expected: str | None) -> None: assert TikTokExtractor.video_count(html) == expected # ──────────────────────── _parse_count ───────────────────────────────────── class TestParseCount: @pytest.mark.parametrize( "text,expected", [ ("1K", 1_000), ("2.5K", 2_500), ("1M", 1_000_000), ("1.5B", 1_500_000_000), ("500", 500), ("1,234", 1234), ], ) def test_parses_count(self, text: str, expected: int) -> None: assert TikTokExtractor._parse_count(text) == expected def test_returns_none_on_invalid(self): assert TikTokExtractor._parse_count("invalid") is None # ──────────────────────── video_metrics fallback ─────────────────────────── class TestVideoMetricsRegexFallback: def test_falls_back_when_no_state_blob(self): html = ( '"createTime":"1700000000"' '"playCount":9999' '"diggCount":123' '"commentCount":45' '"shareCount":6' '"collectCount":2' ) result = TikTokExtractor.video_metrics(html) assert result["views"] == 9999 assert result["likes"] == 123 assert result["comments"] == 45 def test_prefers_state_blob_over_regex(self): item = { "stats": { "playCount": 777, "diggCount": 88, "commentCount": 9, "shareCount": 2, "collectCount": 1, }, "createTime": 1700000000, "challenges": [], } # State blob has 777 views; regex noise has different value html = _make_state_html(item) + '"playCount":9999' result = TikTokExtractor.video_metrics(html) assert result["views"] == 777