1db0b6054a
Add TikTok search, scoring, and rendering using the Apify platform (clockworks/tiktok-scraper actor). Users bring their own APIFY_API_TOKEN ($5/month free credits, no CC required). The shared apify_client_wrapper module is designed for reuse by future Facebook/Instagram sources. - New modules: tiktok.py (search + caption extraction), apify_client_wrapper.py - Schema: TikTokItem dataclass, shares field on Engagement, Report.tiktok - Pipeline: normalize → filter → score → sort → dedupe → cross-link → render - Scoring: 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments) - SKILL.md bumped to v2.7 with TikTok stats, citations, and security docs - 26 unit tests covering relevance, normalize, score, dedupe, render, round-trip Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
9.9 KiB
Python
260 lines
9.9 KiB
Python
"""Tests for TikTok module (search, normalize, score, dedupe, render)."""
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
# Add lib to path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
|
|
|
from lib import schema, score, normalize, dedupe, render
|
|
from lib import tiktok
|
|
|
|
|
|
class TestTikTokRelevance(unittest.TestCase):
|
|
"""Test relevance scoring for TikTok items."""
|
|
|
|
def test_exact_match(self):
|
|
rel = tiktok._compute_relevance("claude code", "Claude Code tricks and tips")
|
|
self.assertGreaterEqual(rel, 0.8)
|
|
|
|
def test_partial_match(self):
|
|
rel = tiktok._compute_relevance("claude code tips", "Best AI tools for coding")
|
|
self.assertLess(rel, 0.5)
|
|
|
|
def test_hashtag_boost(self):
|
|
"""Hashtags should boost relevance."""
|
|
rel_no_hash = tiktok._compute_relevance("claude code", "random video about stuff")
|
|
rel_with_hash = tiktok._compute_relevance("claude code", "random video about stuff", ["claudecode", "ai"])
|
|
self.assertGreater(rel_with_hash, rel_no_hash)
|
|
|
|
def test_empty_query(self):
|
|
rel = tiktok._compute_relevance("", "Some video title")
|
|
self.assertEqual(rel, 0.5)
|
|
|
|
def test_floor(self):
|
|
rel = tiktok._compute_relevance("quantum physics", "cat dancing video")
|
|
self.assertGreaterEqual(rel, 0.1)
|
|
|
|
|
|
class TestExtractCoreSubject(unittest.TestCase):
|
|
"""Test core subject extraction for TikTok search."""
|
|
|
|
def test_strips_prefix(self):
|
|
result = tiktok._extract_core_subject("what are the best claude code tips")
|
|
self.assertNotIn("what are the best", result)
|
|
self.assertIn("claude", result)
|
|
|
|
def test_strips_noise(self):
|
|
result = tiktok._extract_core_subject("latest trending updates on React")
|
|
self.assertNotIn("latest", result)
|
|
self.assertNotIn("trending", result)
|
|
self.assertIn("react", result.lower())
|
|
|
|
def test_preserves_core(self):
|
|
result = tiktok._extract_core_subject("Claude Code")
|
|
self.assertEqual(result, "claude code")
|
|
|
|
|
|
class TestParseDate(unittest.TestCase):
|
|
"""Test date parsing from Apify items."""
|
|
|
|
def test_iso_date(self):
|
|
item = {"createTimeISO": "2026-02-28T17:44:35.000Z"}
|
|
self.assertEqual(tiktok._parse_date(item), "2026-02-28")
|
|
|
|
def test_unix_timestamp(self):
|
|
item = {"createTime": 1756403075}
|
|
result = tiktok._parse_date(item)
|
|
self.assertIsNotNone(result)
|
|
self.assertRegex(result, r"\d{4}-\d{2}-\d{2}")
|
|
|
|
def test_no_date(self):
|
|
item = {}
|
|
self.assertIsNone(tiktok._parse_date(item))
|
|
|
|
|
|
class TestNormalizeTikTokItems(unittest.TestCase):
|
|
"""Test TikTok normalization."""
|
|
|
|
def setUp(self):
|
|
self.fixtures_dir = Path(__file__).parent.parent / "fixtures"
|
|
with open(self.fixtures_dir / "tiktok_search.json") as f:
|
|
data = json.load(f)
|
|
self.raw_items = data["items"]
|
|
|
|
def test_normalizes_items(self):
|
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
|
self.assertEqual(len(items), 3)
|
|
self.assertIsInstance(items[0], schema.TikTokItem)
|
|
|
|
def test_ids_are_sequential(self):
|
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
|
self.assertEqual(items[0].id, "TK1")
|
|
self.assertEqual(items[1].id, "TK2")
|
|
self.assertEqual(items[2].id, "TK3")
|
|
|
|
def test_engagement_parsed(self):
|
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
|
eng = items[0].engagement
|
|
self.assertIsNotNone(eng)
|
|
self.assertEqual(eng.views, 2100000)
|
|
self.assertEqual(eng.likes, 45000)
|
|
self.assertEqual(eng.shares, 8400)
|
|
|
|
def test_hashtags_preserved(self):
|
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
|
self.assertEqual(items[0].hashtags, ["claudecode", "ai", "coding"])
|
|
|
|
def test_caption_snippet_preserved(self):
|
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
|
self.assertIn("slash commands", items[0].caption_snippet)
|
|
|
|
|
|
class TestScoreTikTokItems(unittest.TestCase):
|
|
"""Test TikTok scoring."""
|
|
|
|
def test_engagement_scoring(self):
|
|
eng = schema.Engagement(views=1000000, likes=50000, num_comments=2000)
|
|
raw = score.compute_tiktok_engagement_raw(eng)
|
|
self.assertIsNotNone(raw)
|
|
self.assertGreater(raw, 0)
|
|
|
|
def test_none_engagement(self):
|
|
raw = score.compute_tiktok_engagement_raw(None)
|
|
self.assertIsNone(raw)
|
|
|
|
def test_empty_engagement(self):
|
|
eng = schema.Engagement()
|
|
raw = score.compute_tiktok_engagement_raw(eng)
|
|
self.assertIsNone(raw)
|
|
|
|
def test_scoring_pipeline(self):
|
|
items = [
|
|
schema.TikTokItem(
|
|
id="TK1", text="High views video", url="https://tiktok.com/1",
|
|
author_name="creator1", date="2026-03-01",
|
|
engagement=schema.Engagement(views=2000000, likes=50000, num_comments=1000),
|
|
relevance=0.9,
|
|
),
|
|
schema.TikTokItem(
|
|
id="TK2", text="Low views video", url="https://tiktok.com/2",
|
|
author_name="creator2", date="2026-02-20",
|
|
engagement=schema.Engagement(views=1000, likes=50, num_comments=5),
|
|
relevance=0.5,
|
|
),
|
|
]
|
|
scored = score.score_tiktok_items(items)
|
|
self.assertEqual(len(scored), 2)
|
|
self.assertGreater(scored[0].score, 0)
|
|
self.assertGreater(scored[0].score, scored[1].score)
|
|
|
|
|
|
class TestDedupeTikTok(unittest.TestCase):
|
|
"""Test TikTok deduplication."""
|
|
|
|
def test_no_dupes(self):
|
|
items = [
|
|
schema.TikTokItem(id="TK1", text="Totally different video A",
|
|
url="https://tiktok.com/1", author_name="a", score=80),
|
|
schema.TikTokItem(id="TK2", text="Completely unique video B",
|
|
url="https://tiktok.com/2", author_name="b", score=70),
|
|
]
|
|
result = dedupe.dedupe_tiktok(items)
|
|
self.assertEqual(len(result), 2)
|
|
|
|
def test_removes_dupes(self):
|
|
items = [
|
|
schema.TikTokItem(id="TK1", text="Claude Code is amazing for AI coding",
|
|
url="https://tiktok.com/1", author_name="a", score=80),
|
|
schema.TikTokItem(id="TK2", text="Claude Code is amazing for AI coding wow",
|
|
url="https://tiktok.com/2", author_name="a", score=60),
|
|
]
|
|
result = dedupe.dedupe_tiktok(items)
|
|
self.assertEqual(len(result), 1)
|
|
self.assertEqual(result[0].id, "TK1") # Higher score kept
|
|
|
|
|
|
class TestRenderTikTok(unittest.TestCase):
|
|
"""Test TikTok rendering in reports."""
|
|
|
|
def test_renders_tiktok_section(self):
|
|
report = schema.Report(
|
|
topic="test", range_from="2026-02-01", range_to="2026-03-03",
|
|
generated_at="2026-03-03T00:00:00Z", mode="all",
|
|
tiktok=[
|
|
schema.TikTokItem(
|
|
id="TK1", text="Video caption here", url="https://tiktok.com/1",
|
|
author_name="creator", date="2026-03-01", score=85,
|
|
engagement=schema.Engagement(views=1000000, likes=50000),
|
|
hashtags=["ai", "coding"],
|
|
why_relevant="TikTok: Video caption here",
|
|
),
|
|
],
|
|
)
|
|
output = render.render_compact(report)
|
|
self.assertIn("### TikTok Videos", output)
|
|
self.assertIn("TK1", output)
|
|
self.assertIn("@creator", output)
|
|
self.assertIn("1,000,000 views", output)
|
|
|
|
def test_renders_source_status(self):
|
|
report = schema.Report(
|
|
topic="test", range_from="2026-02-01", range_to="2026-03-03",
|
|
generated_at="2026-03-03T00:00:00Z", mode="all",
|
|
tiktok=[
|
|
schema.TikTokItem(
|
|
id="TK1", text="test", url="https://tiktok.com/1",
|
|
author_name="creator", caption_snippet="some caption",
|
|
),
|
|
],
|
|
)
|
|
status = render.render_source_status(report)
|
|
self.assertIn("TikTok", status)
|
|
self.assertIn("1 videos", status)
|
|
|
|
def test_xref_tag_tiktok(self):
|
|
"""Test that TK prefix is recognized in cross-ref tags."""
|
|
item = schema.RedditItem(id="R1", title="test", url="test", subreddit="test",
|
|
cross_refs=["TK1"])
|
|
tag = render._xref_tag(item)
|
|
self.assertIn("TikTok", tag)
|
|
|
|
|
|
class TestSchemaRoundtrip(unittest.TestCase):
|
|
"""Test TikTokItem serialization round-trip via Report."""
|
|
|
|
def test_to_dict_and_back(self):
|
|
original = schema.TikTokItem(
|
|
id="TK1", text="Test caption", url="https://tiktok.com/1",
|
|
author_name="creator", date="2026-03-01",
|
|
date_confidence="high",
|
|
engagement=schema.Engagement(views=100, likes=10, num_comments=5, shares=3),
|
|
caption_snippet="spoken words",
|
|
hashtags=["test", "ai"],
|
|
relevance=0.8, why_relevant="TikTok: Test",
|
|
subs=schema.SubScores(relevance=80, recency=90, engagement=70),
|
|
score=80, cross_refs=["R1"],
|
|
)
|
|
report = schema.Report(
|
|
topic="test", range_from="2026-02-01", range_to="2026-03-03",
|
|
generated_at="2026-03-03T00:00:00Z", mode="all",
|
|
tiktok=[original],
|
|
)
|
|
d = report.to_dict()
|
|
restored = schema.Report.from_dict(d)
|
|
self.assertEqual(len(restored.tiktok), 1)
|
|
tk = restored.tiktok[0]
|
|
self.assertEqual(tk.id, "TK1")
|
|
self.assertEqual(tk.author_name, "creator")
|
|
self.assertEqual(tk.hashtags, ["test", "ai"])
|
|
self.assertEqual(tk.engagement.views, 100)
|
|
self.assertEqual(tk.engagement.shares, 3)
|
|
self.assertEqual(tk.caption_snippet, "spoken words")
|
|
self.assertEqual(tk.cross_refs, ["R1"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|