feat(tiktok): add TikTok as 7th signal source via Apify

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>
This commit is contained in:
Matt Van Horn
2026-03-03 05:48:04 -08:00
parent 5e5d586f7d
commit 1db0b6054a
14 changed files with 1619 additions and 36 deletions
+82
View File
@@ -0,0 +1,82 @@
"""Shared Apify client utilities for last30days sources.
Provides a common wrapper around the apify-client SDK so that
TikTok, Facebook, Instagram (future) all share the same client
initialization, error handling, and cost-control patterns.
One APIFY_API_TOKEN covers all Apify-backed sources.
"""
import sys
from typing import Any, Dict, List, Optional
try:
from apify_client import ApifyClient
except ImportError:
ApifyClient = None
def is_apify_available() -> bool:
"""Check if the apify-client library is installed."""
return ApifyClient is not None
def get_apify_client(token: str) -> "ApifyClient":
"""Initialize Apify client with token.
Args:
token: Apify API token (from https://console.apify.com)
Returns:
Initialized ApifyClient instance
Raises:
ImportError: If apify-client is not installed
"""
if ApifyClient is None:
raise ImportError(
"apify-client is not installed. Run: pip install apify-client"
)
return ApifyClient(token=token)
def run_actor_sync(
client: "ApifyClient",
actor_id: str,
run_input: Dict[str, Any],
timeout_secs: int = 300,
max_items: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Run an Apify actor synchronously and return dataset items.
Args:
client: Initialized ApifyClient
actor_id: Actor identifier, e.g. "clockworks/tiktok-scraper"
run_input: Actor-specific input dict
timeout_secs: Max wait time (default 5 min)
max_items: Cap on returned items (cost control)
Returns:
List of result dicts from the actor's default dataset
"""
_log(f"Running actor {actor_id} (timeout={timeout_secs}s)")
run = client.actor(actor_id).call(
run_input=run_input,
timeout_secs=timeout_secs,
)
dataset_id = run["defaultDatasetId"]
items = list(client.dataset(dataset_id).iterate_items())
if max_items and len(items) > max_items:
items = items[:max_items]
_log(f"Actor {actor_id} returned {len(items)} items")
return items
def _log(msg: str):
"""Log to stderr."""
sys.stderr.write(f"[Apify] {msg}\n")
sys.stderr.flush()
+13 -1
View File
@@ -45,7 +45,7 @@ def jaccard_similarity(set1: Set[str], set2: Set[str]) -> float:
return intersection / union if union > 0 else 0.0
AnyItem = Union[schema.RedditItem, schema.XItem, schema.YouTubeItem,
AnyItem = Union[schema.RedditItem, schema.XItem, schema.YouTubeItem, schema.TikTokItem,
schema.HackerNewsItem, schema.PolymarketItem, schema.WebSearchItem]
@@ -57,6 +57,8 @@ def get_item_text(item: AnyItem) -> str:
return item.title
elif isinstance(item, schema.YouTubeItem):
return f"{item.title} {item.channel_name}"
elif isinstance(item, schema.TikTokItem):
return f"{item.text} {item.author_name}"
elif isinstance(item, schema.PolymarketItem):
return f"{item.title} {item.question}"
elif isinstance(item, schema.WebSearchItem):
@@ -74,6 +76,8 @@ def _get_cross_source_text(item: AnyItem) -> str:
"""
if isinstance(item, schema.XItem):
return item.text[:100]
if isinstance(item, schema.TikTokItem):
return item.text[:100]
if isinstance(item, schema.HackerNewsItem):
title = item.title
if title.startswith("Show HN:"):
@@ -194,6 +198,14 @@ def dedupe_youtube(
return dedupe_items(items, threshold)
def dedupe_tiktok(
items: List[schema.TikTokItem],
threshold: float = 0.7,
) -> List[schema.TikTokItem]:
"""Dedupe TikTok items."""
return dedupe_items(items, threshold)
def dedupe_hackernews(
items: List[schema.HackerNewsItem],
threshold: float = 0.7,
+10
View File
@@ -203,6 +203,7 @@ def get_config() -> Dict[str, Any]:
('OPENAI_MODEL_PIN', None),
('XAI_MODEL_POLICY', 'latest'),
('XAI_MODEL_PIN', None),
('APIFY_API_TOKEN', None),
('AUTH_TOKEN', None),
('CT0', None),
]
@@ -406,6 +407,15 @@ def is_polymarket_available() -> bool:
return True
def is_apify_available(config: Dict[str, Any]) -> bool:
"""Check if Apify token is configured for TikTok/social scraping.
Returns True if APIFY_API_TOKEN is set. One token covers
TikTok, Facebook, Instagram (all Apify-backed sources).
"""
return bool(config.get('APIFY_API_TOKEN'))
def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed X source status for UI decisions.
+48 -1
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, TypeVar, Union
from . import dates, schema
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.HackerNewsItem, schema.PolymarketItem)
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.HackerNewsItem, schema.PolymarketItem)
def filter_by_date_range(
@@ -200,6 +200,53 @@ def normalize_youtube_items(
return normalized
def normalize_tiktok_items(
items: List[Dict[str, Any]],
from_date: str,
to_date: str,
) -> List[schema.TikTokItem]:
"""Normalize raw TikTok items to schema.
Args:
items: Raw TikTok items from Apify
from_date: Start of date range
to_date: End of date range
Returns:
List of TikTokItem objects
"""
normalized = []
for i, item in enumerate(items):
# Parse engagement
eng_raw = item.get("engagement") or {}
engagement = schema.Engagement(
views=eng_raw.get("views"),
likes=eng_raw.get("likes"),
num_comments=eng_raw.get("comments"),
shares=eng_raw.get("shares"),
)
# TikTok dates are reliable (exact timestamps from Apify)
date_str = item.get("date")
normalized.append(schema.TikTokItem(
id=f"TK{i+1}",
text=item.get("text", ""),
url=item.get("url", ""),
author_name=item.get("author_name", ""),
date=date_str,
date_confidence="high",
engagement=engagement,
caption_snippet=item.get("caption_snippet", ""),
hashtags=item.get("hashtags", []),
relevance=item.get("relevance", 0.7),
why_relevant=item.get("why_relevant", ""),
))
return normalized
def normalize_hackernews_items(
items: List[Dict[str, Any]],
from_date: str,
+75 -2
View File
@@ -24,6 +24,8 @@ def _xref_tag(item) -> str:
source_names.add('X')
elif ref_id.startswith('YT'):
source_names.add('YouTube')
elif ref_id.startswith('TK'):
source_names.add('TikTok')
elif ref_id.startswith('HN'):
source_names.add('HN')
elif ref_id.startswith('PM'):
@@ -57,8 +59,10 @@ def _assess_data_freshness(report: schema.Report) -> dict:
hn_recent = sum(1 for h in report.hackernews if h.date and h.date >= report.range_from)
pm_recent = sum(1 for p in report.polymarket if p.date and p.date >= report.range_from)
total_recent = reddit_recent + x_recent + web_recent + hn_recent + pm_recent
total_items = len(report.reddit) + len(report.x) + len(report.web) + len(report.hackernews) + len(report.polymarket)
tiktok_recent = sum(1 for t in report.tiktok if t.date and t.date >= report.range_from)
total_recent = reddit_recent + x_recent + web_recent + hn_recent + pm_recent + tiktok_recent
total_items = len(report.reddit) + len(report.x) + len(report.web) + len(report.hackernews) + len(report.polymarket) + len(report.tiktok)
return {
"reddit_recent": reddit_recent,
@@ -244,6 +248,42 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
lines.append(f" *{item.why_relevant}*")
lines.append("")
# TikTok items
if report.tiktok_error:
lines.append("### TikTok Videos")
lines.append("")
lines.append(f"**ERROR:** {report.tiktok_error}")
lines.append("")
elif report.tiktok:
lines.append("### TikTok Videos")
lines.append("")
for item in report.tiktok[:limit]:
eng_str = ""
if item.engagement:
eng = item.engagement
parts = []
if eng.views is not None:
parts.append(f"{eng.views:,} views")
if eng.likes is not None:
parts.append(f"{eng.likes:,} likes")
if parts:
eng_str = f" [{', '.join(parts)}]"
date_str = f" ({item.date})" if item.date else ""
lines.append(f"**{item.id}** (score:{item.score}) @{item.author_name}{date_str}{eng_str}{_xref_tag(item)}")
lines.append(f" {item.text[:200]}")
lines.append(f" {item.url}")
if item.caption_snippet and item.caption_snippet != item.text[:len(item.caption_snippet)]:
snippet = item.caption_snippet[:200]
if len(item.caption_snippet) > 200:
snippet += "..."
lines.append(f" Caption: {snippet}")
if item.hashtags:
lines.append(f" Tags: {' '.join('#' + h for h in item.hashtags[:8])}")
lines.append(f" *{item.why_relevant}*")
lines.append("")
# Hacker News items
if report.hackernews_error:
lines.append("### Hacker News Stories")
@@ -407,6 +447,14 @@ def render_source_status(report: schema.Report, source_info: dict = None) -> str
lines.append(f" ✅ YouTube: {len(report.youtube)} videos ({with_transcripts} with transcripts)")
# Hide when zero results (no skip reason line needed)
# TikTok
if report.tiktok_error:
lines.append(f" ❌ TikTok: error — {report.tiktok_error}")
elif report.tiktok:
with_captions = sum(1 for v in report.tiktok if getattr(v, 'caption_snippet', None))
lines.append(f" ✅ TikTok: {len(report.tiktok)} videos ({with_captions} with captions)")
# Hide when zero results
# Hacker News
if report.hackernews_error:
lines.append(f" ❌ HN: error - {report.hackernews_error}")
@@ -458,6 +506,8 @@ def render_context_snippet(report: schema.Report) -> str:
all_items.append((item.score, "Reddit", item.title, item.url))
for item in report.x[:5]:
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
for item in report.tiktok[:5]:
all_items.append((item.score, "TikTok", item.text[:50] + "...", item.url))
for item in report.hackernews[:5]:
all_items.append((item.score, "HN", item.title[:50] + "...", item.hn_url))
for item in report.polymarket[:5]:
@@ -551,6 +601,29 @@ def render_full_report(report: schema.Report) -> str:
lines.append(f"> {item.text}")
lines.append("")
# TikTok section
if report.tiktok:
lines.append("## TikTok Videos")
lines.append("")
for item in report.tiktok:
lines.append(f"### {item.id}: @{item.author_name}")
lines.append("")
lines.append(f"- **URL:** {item.url}")
lines.append(f"- **Date:** {item.date or 'Unknown'}")
lines.append(f"- **Score:** {item.score}/100")
lines.append(f"- **Relevance:** {item.why_relevant}")
if item.engagement:
eng = item.engagement
lines.append(f"- **Engagement:** {eng.views or '?'} views, {eng.likes or '?'} likes, {eng.num_comments or '?'} comments")
if item.hashtags:
lines.append(f"- **Hashtags:** {' '.join('#' + h for h in item.hashtags[:10])}")
lines.append("")
lines.append(f"> {item.text[:300]}")
lines.append("")
# HN section
if report.hackernews:
lines.append("## Hacker News Stories")
+75
View File
@@ -22,6 +22,9 @@ class Engagement:
# YouTube fields
views: Optional[int] = None
# TikTok / Facebook fields
shares: Optional[int] = None
# Polymarket fields
volume: Optional[float] = None
liquidity: Optional[float] = None
@@ -44,6 +47,8 @@ class Engagement:
d['quotes'] = self.quotes
if self.views is not None:
d['views'] = self.views
if self.shares is not None:
d['shares'] = self.shares
if self.volume is not None:
d['volume'] = self.volume
if self.liquidity is not None:
@@ -231,6 +236,45 @@ class YouTubeItem:
return d
@dataclass
class TikTokItem:
"""Normalized TikTok item."""
id: str # video_id
text: str # caption/description
url: str # webVideoUrl
author_name: str # authorMeta.name
date: Optional[str] = None
date_confidence: str = "high" # Apify provides exact timestamps
engagement: Optional[Engagement] = None # views, likes, num_comments, shares
caption_snippet: str = "" # spoken-word caption (if available), else text
hashtags: List[str] = field(default_factory=list)
relevance: float = 0.7
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
cross_refs: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
d = {
'id': self.id,
'text': self.text,
'url': self.url,
'author_name': self.author_name,
'date': self.date,
'date_confidence': self.date_confidence,
'engagement': self.engagement.to_dict() if self.engagement else None,
'caption_snippet': self.caption_snippet,
'hashtags': self.hashtags,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
if self.cross_refs:
d['cross_refs'] = self.cross_refs
return d
@dataclass
class HackerNewsItem:
"""Normalized Hacker News item."""
@@ -329,6 +373,7 @@ class Report:
x: List[XItem] = field(default_factory=list)
web: List[WebSearchItem] = field(default_factory=list)
youtube: List[YouTubeItem] = field(default_factory=list)
tiktok: List[TikTokItem] = field(default_factory=list)
hackernews: List[HackerNewsItem] = field(default_factory=list)
polymarket: List[PolymarketItem] = field(default_factory=list)
best_practices: List[str] = field(default_factory=list)
@@ -339,6 +384,7 @@ class Report:
x_error: Optional[str] = None
web_error: Optional[str] = None
youtube_error: Optional[str] = None
tiktok_error: Optional[str] = None
hackernews_error: Optional[str] = None
polymarket_error: Optional[str] = None
# Handle resolution
@@ -362,6 +408,7 @@ class Report:
'x': [x.to_dict() for x in self.x],
'web': [w.to_dict() for w in self.web],
'youtube': [y.to_dict() for y in self.youtube],
'tiktok': [t.to_dict() for t in self.tiktok],
'hackernews': [h.to_dict() for h in self.hackernews],
'polymarket': [p.to_dict() for p in self.polymarket],
'best_practices': self.best_practices,
@@ -378,6 +425,8 @@ class Report:
d['web_error'] = self.web_error
if self.youtube_error:
d['youtube_error'] = self.youtube_error
if self.tiktok_error:
d['tiktok_error'] = self.tiktok_error
if self.hackernews_error:
d['hackernews_error'] = self.hackernews_error
if self.polymarket_error:
@@ -485,6 +534,30 @@ class Report:
cross_refs=y.get('cross_refs', []),
))
# Reconstruct TikTok items
tiktok_items = []
for t in data.get('tiktok', []):
eng = None
if t.get('engagement'):
eng = Engagement(**t['engagement'])
subs = SubScores(**t.get('subs', {})) if t.get('subs') else SubScores()
tiktok_items.append(TikTokItem(
id=t['id'],
text=t.get('text', ''),
url=t['url'],
author_name=t.get('author_name', ''),
date=t.get('date'),
date_confidence=t.get('date_confidence', 'high'),
engagement=eng,
caption_snippet=t.get('caption_snippet', ''),
hashtags=t.get('hashtags', []),
relevance=t.get('relevance', 0.7),
why_relevant=t.get('why_relevant', ''),
subs=subs,
score=t.get('score', 0),
cross_refs=t.get('cross_refs', []),
))
# Reconstruct HackerNews items
hn_items = []
for h in data.get('hackernews', []):
@@ -549,6 +622,7 @@ class Report:
x=x_items,
web=web_items,
youtube=youtube_items,
tiktok=tiktok_items,
hackernews=hn_items,
polymarket=pm_items,
best_practices=data.get('best_practices', []),
@@ -558,6 +632,7 @@ class Report:
x_error=data.get('x_error'),
web_error=data.get('web_error'),
youtube_error=data.get('youtube_error'),
tiktok_error=data.get('tiktok_error'),
hackernews_error=data.get('hackernews_error'),
polymarket_error=data.get('polymarket_error'),
resolved_x_handle=data.get('resolved_x_handle'),
+66 -5
View File
@@ -280,6 +280,65 @@ def score_youtube_items(items: List[schema.YouTubeItem]) -> List[schema.YouTubeI
return items
def compute_tiktok_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
"""Compute raw engagement score for TikTok item.
Formula: 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
Views dominate on TikTok — they're the primary discovery signal.
"""
if engagement is None:
return None
if engagement.views is None and engagement.likes is None:
return None
views = log1p_safe(engagement.views)
likes = log1p_safe(engagement.likes)
comments = log1p_safe(engagement.num_comments)
return 0.50 * views + 0.30 * likes + 0.20 * comments
def score_tiktok_items(items: List[schema.TikTokItem]) -> List[schema.TikTokItem]:
"""Compute scores for TikTok items.
Uses same weight structure as YouTube (relevance + recency + engagement).
"""
if not items:
return items
eng_raw = [compute_tiktok_engagement_raw(item.engagement) for item in items]
eng_normalized = normalize_to_100(eng_raw)
for i, item in enumerate(items):
rel_score = int(item.relevance * 100)
rec_score = dates.recency_score(item.date)
if eng_normalized[i] is not None:
eng_score = int(eng_normalized[i])
else:
eng_score = DEFAULT_ENGAGEMENT
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=eng_score,
)
overall = (
WEIGHT_RELEVANCE * rel_score +
WEIGHT_RECENCY * rec_score +
WEIGHT_ENGAGEMENT * eng_score
)
if eng_raw[i] is None:
overall -= UNKNOWN_ENGAGEMENT_PENALTY
item.score = max(0, min(100, int(overall)))
return items
def compute_hackernews_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
"""Compute raw engagement score for Hacker News item.
@@ -453,7 +512,7 @@ def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebS
return items
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.HackerNewsItem, schema.PolymarketItem]]) -> List:
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.HackerNewsItem, schema.PolymarketItem]]) -> List:
"""Sort items by score (descending), then date, then source priority.
Args:
@@ -470,19 +529,21 @@ def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSear
date = item.date or "0000-00-00"
date_key = -int(date.replace("-", ""))
# Tertiary: source priority (Reddit > X > YouTube > HN > Polymarket > WebSearch)
# Tertiary: source priority (Reddit > X > YouTube > TikTok > HN > Polymarket > WebSearch)
if isinstance(item, schema.RedditItem):
source_priority = 0
elif isinstance(item, schema.XItem):
source_priority = 1
elif isinstance(item, schema.YouTubeItem):
source_priority = 2
elif isinstance(item, schema.HackerNewsItem):
elif isinstance(item, schema.TikTokItem):
source_priority = 3
elif isinstance(item, schema.PolymarketItem):
elif isinstance(item, schema.HackerNewsItem):
source_priority = 4
else: # WebSearchItem
elif isinstance(item, schema.PolymarketItem):
source_priority = 5
else: # WebSearchItem
source_priority = 6
# Quaternary: title/text for stability
text = getattr(item, "title", "") or getattr(item, "text", "")
+385
View File
@@ -0,0 +1,385 @@
"""TikTok search via Apify clockworks/tiktok-scraper for /last30days.
Uses the Apify platform to search TikTok by keyword, extract engagement
metrics (views, likes, comments), and optionally pull video captions.
Requires APIFY_API_TOKEN in config. Free tier: $5/month credits.
"""
import re
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
from . import apify_client_wrapper
ACTOR_ID = "clockworks/tiktok-scraper"
# Depth configurations: how many results to fetch / captions to extract
DEPTH_CONFIG = {
"quick": {"results_per_page": 10, "max_captions": 3},
"default": {"results_per_page": 20, "max_captions": 5},
"deep": {"results_per_page": 40, "max_captions": 8},
}
# Max words to keep from each caption
CAPTION_MAX_WORDS = 500
# Stopwords for relevance computation (shared with youtube_yt.py pattern)
STOPWORDS = frozenset({
'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can',
'its', 'be', 'or', 'not', 'no', 'so', 'if', 'but', 'about',
'all', 'just', 'get', 'has', 'have', 'was', 'will',
})
# Synonym groups for relevance scoring
SYNONYMS = {
'hip': {'rap', 'hiphop'},
'hop': {'rap', 'hiphop'},
'rap': {'hip', 'hop', 'hiphop'},
'hiphop': {'rap', 'hip', 'hop'},
'js': {'javascript'},
'javascript': {'js'},
'ts': {'typescript'},
'typescript': {'ts'},
'ai': {'artificial', 'intelligence'},
'ml': {'machine', 'learning'},
'react': {'reactjs'},
'reactjs': {'react'},
}
def _tokenize(text: str) -> Set[str]:
"""Lowercase, strip punctuation, remove stopwords, drop single-char tokens."""
words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
tokens = {w for w in words if w not in STOPWORDS and len(w) > 1}
expanded = set(tokens)
for t in tokens:
if t in SYNONYMS:
expanded.update(SYNONYMS[t])
return expanded
def _compute_relevance(query: str, text: str, hashtags: List[str] = None) -> float:
"""Compute relevance as ratio of query tokens found in text + hashtags.
Uses ratio overlap (intersection / query_length). Hashtags provide
a TikTok-specific relevance boost. Floors at 0.1.
"""
q_tokens = _tokenize(query)
# Combine text and hashtags for matching
combined = text
if hashtags:
combined = f"{text} {' '.join(hashtags)}"
t_tokens = _tokenize(combined)
# Split concatenated hashtags (e.g., "claudecode" → "claude", "code")
if hashtags:
for tag in hashtags:
tag_lower = tag.lower()
for qt in q_tokens:
if qt in tag_lower and qt != tag_lower:
t_tokens.add(qt)
if not q_tokens:
return 0.5 # Neutral fallback
overlap = len(q_tokens & t_tokens)
ratio = overlap / len(q_tokens)
return max(0.1, min(1.0, ratio))
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for TikTok search.
Strips meta/research words to keep only the core product/concept name.
"""
text = topic.lower().strip()
# Strip multi-word prefixes
prefixes = [
'what are the best', 'what is the best', 'what are the latest',
'what are people saying about', 'what do people think about',
'how do i use', 'how to use', 'how to',
'what are', 'what is', 'tips for', 'best practices for',
]
for p in prefixes:
if text.startswith(p + ' '):
text = text[len(p):].strip()
# Strip individual noise words
noise = {
'best', 'top', 'good', 'great', 'awesome', 'killer',
'latest', 'new', 'news', 'update', 'updates',
'trending', 'hottest', 'popular', 'viral',
'practices', 'features',
'recommendations', 'advice',
'prompt', 'prompts', 'prompting',
'methods', 'strategies', 'approaches',
}
words = text.split()
filtered = [w for w in words if w not in noise]
result = ' '.join(filtered) if filtered else text
return result.rstrip('?!.')
def _log(msg: str):
"""Log to stderr."""
sys.stderr.write(f"[TikTok] {msg}\n")
sys.stderr.flush()
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from Apify TikTok item to YYYY-MM-DD.
Handles both createTimeISO (ISO string) and createTime (unix timestamp).
"""
iso = item.get("createTimeISO")
if iso:
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
ts = item.get("createTime")
if ts:
try:
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
pass
return None
def search_tiktok(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
token: str = None,
) -> Dict[str, Any]:
"""Search TikTok via Apify.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
token: Apify API token
Returns:
Dict with 'items' list and optional 'error'.
"""
if not token:
return {"items": [], "error": "No APIFY_API_TOKEN configured"}
if not apify_client_wrapper.is_apify_available():
return {"items": [], "error": "apify-client not installed (pip install apify-client)"}
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
core_topic = _extract_core_subject(topic)
_log(f"Searching TikTok for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
try:
client = apify_client_wrapper.get_apify_client(token)
run_input = {
"searchQueries": [core_topic],
"resultsPerPage": config["results_per_page"],
"shouldDownloadSubtitles": False,
"shouldDownloadVideos": False,
"shouldDownloadCovers": False,
}
raw_items = apify_client_wrapper.run_actor_sync(
client, ACTOR_ID, run_input,
timeout_secs=120,
max_items=config["results_per_page"],
)
except Exception as e:
_log(f"Apify error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
# Parse items
items = []
for raw in raw_items:
video_id = str(raw.get("id", ""))
text = raw.get("text", "")
play_count = raw.get("playCount") or 0
digg_count = raw.get("diggCount") or 0
comment_count = raw.get("commentCount") or 0
share_count = raw.get("shareCount") or 0
author_meta = raw.get("authorMeta") or {}
author_name = author_meta.get("name", "")
web_url = raw.get("webVideoUrl", "")
hashtags_raw = raw.get("hashtags") or []
hashtag_names = [h.get("name", "") for h in hashtags_raw if isinstance(h, dict)]
duration = (raw.get("videoMeta") or {}).get("duration")
date_str = _parse_date(raw)
# Compute relevance with hashtag boost
relevance = _compute_relevance(core_topic, text, hashtag_names)
items.append({
"video_id": video_id,
"text": text,
"url": web_url or f"https://www.tiktok.com/@{author_name}/video/{video_id}",
"author_name": author_name,
"date": date_str,
"engagement": {
"views": play_count,
"likes": digg_count,
"comments": comment_count,
"shares": share_count,
},
"hashtags": hashtag_names,
"duration": duration,
"relevance": relevance,
"why_relevant": f"TikTok: {text[:60]}" if text else f"TikTok: {core_topic}",
"caption_snippet": "", # populated by fetch_captions
})
# Hard date filter
in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
out_of_range = len(items) - len(in_range)
if in_range:
items = in_range
if out_of_range:
_log(f"Filtered {out_of_range} videos outside date range")
else:
_log(f"No videos within date range, keeping all {len(items)}")
# Sort by views descending
items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
_log(f"Found {len(items)} TikTok videos")
return {"items": items}
def fetch_captions(
video_items: List[Dict[str, Any]],
token: str,
depth: str = "default",
) -> Dict[str, str]:
"""Fetch captions for top N TikTok videos.
Strategy:
1. Primary: Use the 'text' field (video description) — always free
2. For top N, re-run actor with shouldDownloadSubtitles for spoken-word
Args:
video_items: Items from search_tiktok()
token: Apify API token
depth: Depth level for caption limit
Returns:
Dict mapping video_id → caption text (truncated to 500 words)
"""
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
max_captions = config["max_captions"]
if not video_items or not token:
return {}
top_items = video_items[:max_captions]
_log(f"Enriching captions for {len(top_items)} videos")
captions = {}
# First pass: use text field as caption (always available, free)
for item in top_items:
vid = item["video_id"]
text = item.get("text", "")
if text:
words = text.split()
if len(words) > CAPTION_MAX_WORDS:
text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
captions[vid] = text
# Second pass: try to get spoken-word subtitles for top videos
try:
urls = [item["url"] for item in top_items if item.get("url")]
if urls:
client = apify_client_wrapper.get_apify_client(token)
run_input = {
"postURLs": urls,
"shouldDownloadSubtitles": True,
"shouldDownloadVideos": False,
"shouldDownloadCovers": False,
}
subtitle_items = apify_client_wrapper.run_actor_sync(
client, ACTOR_ID, run_input,
timeout_secs=60,
max_items=max_captions,
)
for raw in subtitle_items:
vid = str(raw.get("id", ""))
# Check for subtitle text in the response
subtitle_text = raw.get("subtitleText") or raw.get("subtitles") or ""
if isinstance(subtitle_text, list):
subtitle_text = " ".join(str(s) for s in subtitle_text)
if subtitle_text and vid:
words = subtitle_text.split()
if len(words) > CAPTION_MAX_WORDS:
subtitle_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
captions[vid] = subtitle_text # Override text with spoken-word
except Exception as e:
_log(f"Subtitle enrichment failed (using text captions): {e}")
got = sum(1 for v in captions.values() if v)
_log(f"Got captions for {got}/{len(top_items)} videos")
return captions
def search_and_enrich(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
token: str = None,
) -> Dict[str, Any]:
"""Full TikTok search: find videos, then fetch captions for top results.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
token: Apify API token
Returns:
Dict with 'items' list. Each item has a 'caption_snippet' field.
"""
# Step 1: Search
search_result = search_tiktok(topic, from_date, to_date, depth, token)
items = search_result.get("items", [])
if not items:
return search_result
# Step 2: Fetch captions for top N
captions = fetch_captions(items, token, depth)
# Step 3: Attach captions to items
for item in items:
vid = item["video_id"]
caption = captions.get(vid)
if caption:
item["caption_snippet"] = caption
return {"items": items, "error": search_result.get("error")}
def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse TikTok search response to normalized format.
Returns:
List of item dicts ready for normalization.
"""
return response.get("items", [])
+20 -1
View File
@@ -71,6 +71,12 @@ YOUTUBE_MESSAGES = [
"Fetching transcripts...",
]
TIKTOK_MESSAGES = [
"Searching TikTok for trending videos...",
"Finding what's viral on TikTok...",
"Scanning TikTok for relevant content...",
]
HN_MESSAGES = [
"Searching Hacker News...",
"Scanning HN front page stories...",
@@ -271,6 +277,15 @@ class ProgressDisplay:
if self.spinner:
self.spinner.stop(f"{Colors.RED}YouTube{Colors.RESET} Found {count} videos")
def start_tiktok(self):
msg = random.choice(TIKTOK_MESSAGES)
self.spinner = Spinner(f"{Colors.PURPLE}TikTok{Colors.RESET} {msg}", Colors.PURPLE, quiet=True)
self.spinner.start()
def end_tiktok(self, count: int):
if self.spinner:
self.spinner.stop(f"{Colors.PURPLE}TikTok{Colors.RESET} Found {count} videos")
def start_hackernews(self):
msg = random.choice(HN_MESSAGES)
self.spinner = Spinner(f"{Colors.YELLOW}HN{Colors.RESET} {msg}", Colors.YELLOW, quiet=True)
@@ -298,7 +313,7 @@ class ProgressDisplay:
if self.spinner:
self.spinner.stop()
def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0, hn_count: int = 0, pm_count: int = 0):
def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0, hn_count: int = 0, pm_count: int = 0, tiktok_count: int = 0):
elapsed = time.time() - self.start_time
if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
@@ -307,6 +322,8 @@ class ProgressDisplay:
sys.stderr.write(f"{Colors.CYAN}X:{Colors.RESET} {x_count} posts")
if youtube_count:
sys.stderr.write(f" {Colors.RED}YouTube:{Colors.RESET} {youtube_count} videos")
if tiktok_count:
sys.stderr.write(f" {Colors.PURPLE}TikTok:{Colors.RESET} {tiktok_count} videos")
if hn_count:
sys.stderr.write(f" {Colors.YELLOW}HN:{Colors.RESET} {hn_count} stories")
if pm_count:
@@ -316,6 +333,8 @@ class ProgressDisplay:
parts = [f"Reddit: {reddit_count} threads", f"X: {x_count} posts"]
if youtube_count:
parts.append(f"YouTube: {youtube_count} videos")
if tiktok_count:
parts.append(f"TikTok: {tiktok_count} videos")
if hn_count:
parts.append(f"HN: {hn_count} stories")
if pm_count: