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>
16 KiB
title, type, date
| title | type | date |
|---|---|---|
| feat: Add TikTok as 7th source via Apify | feat | 2026-03-03 |
feat: Add TikTok Signal via Apify
Overview
Add TikTok as the 7th research source alongside Reddit, X, YouTube, HN, Polymarket, and Web. Use the Apify platform (clockworks/tiktok-scraper actor) to search TikTok by keyword, extract engagement metrics (views, likes, comments), and optionally pull video captions for synthesis enrichment — mirroring the YouTube pattern.
Why this matters: TikTok is where trends break first for many topics (products, music, culture, tech tips, news reactions). A viral TikTok with 2M views is a stronger signal than a tweet with 500 likes. The skill currently misses this entirely.
Why Apify: BYO API key, $5/month free credits (no CC required), pay-per-result pricing, Python SDK (apify-client), and the same actor platform supports Facebook and Instagram scrapers — so this investment pays forward.
Proposed Solution
Architecture: Shared Apify Client + Per-Source Modules
scripts/lib/
apify_client_wrapper.py ← NEW: shared Apify client init + helpers (reused by FB/IG later)
tiktok.py ← NEW: TikTok search, captions, relevance
# future:
# facebook.py ← uses same apify_client_wrapper.py
# instagram.py ← uses same apify_client_wrapper.py
This design means adding Facebook or Instagram later is just a new facebook.py module — the Apify client setup, token validation, and error handling are already done.
Data Flow
User topic + date range
↓
[apify_client_wrapper.py] init client with APIFY_API_TOKEN
↓
[tiktok.py] search_tiktok()
├─ Call clockworks/tiktok-scraper actor (sync API, ≤5min)
├─ Input: searchQueries=[core_topic], resultsPerPage=N (depth-aware)
├─ Parse: id, text, playCount, diggCount, commentCount, createTimeISO, authorMeta, webVideoUrl, hashtags
├─ Sort by playCount (views) descending
├─ Compute relevance via token-overlap (reuse youtube_yt._compute_relevance pattern)
└─ Return items
↓
[tiktok.py] fetch_captions() (optional enrichment for top N)
├─ Re-call actor with shouldDownloadSubtitles=true for top videos
├─ OR use video text/description as lightweight "caption" alternative
└─ Truncate to 500 words, attach as caption_snippet
↓
[normalize.py] normalize_tiktok_items() → List[TikTokItem]
↓
[score.py] score_tiktok_items()
├─ compute_tiktok_engagement_raw(): 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
├─ Weighted: 0.45*relevance + 0.25*recency + 0.30*engagement
└─ Same formula as YouTube (views-dominant)
↓
[dedupe.py] dedupe_tiktok() + cross_source_link()
↓
[render.py] render TikTok section
↓
[SKILL.md] stats line: 🎵 TikTok: N videos │ N views │ N with captions
Technical Approach
Phase 1: Apify Client Wrapper (scripts/lib/apify_client_wrapper.py)
Shared module for all Apify-backed sources. Keeps TikTok, Facebook, Instagram from duplicating client setup.
"""Shared Apify client utilities for last30days sources."""
from apify_client import ApifyClient
from typing import Optional, Dict, Any, List
def get_apify_client(token: str) -> ApifyClient:
"""Initialize Apify client with token."""
return ApifyClient(token=token)
def run_actor_sync(
client: ApifyClient,
actor_id: str,
run_input: Dict[str, Any],
timeout_secs: int = 300,
max_items: int = None,
) -> List[Dict[str, Any]]:
"""Run an Apify actor synchronously and return dataset items.
Args:
client: Initialized ApifyClient
actor_id: 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
"""
run = client.actor(actor_id).call(
run_input=run_input,
timeout_secs=timeout_secs,
)
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
if max_items:
items = items[:max_items]
return items
Key design decisions:
- Single
APIFY_API_TOKENenv var for all Apify sources (TikTok, future FB, IG) run_actor_sync()wraps the call+wait+fetch pattern used by every Apify actormax_itemsparam provides cost control (important with $5 free credits)
Phase 2: TikTok Search Module (scripts/lib/tiktok.py)
"""TikTok search via Apify clockworks/tiktok-scraper."""
ACTOR_ID = "clockworks/tiktok-scraper"
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},
}
def search_tiktok(topic, from_date, to_date, depth="default", token=None):
"""Search TikTok via Apify.
Returns:
Dict with 'items' list and optional 'error'.
"""
# 1. Init client via apify_client_wrapper
# 2. Build input: searchQueries=[_extract_core_subject(topic)], resultsPerPage=N
# 3. Call run_actor_sync(client, ACTOR_ID, input, timeout=120)
# 4. Parse items: extract id, text, playCount, diggCount, commentCount,
# shareCount, createTimeISO, authorMeta.name, webVideoUrl, hashtags
# 5. Filter by date range (from_date to to_date)
# 6. Sort by playCount descending
# 7. Compute relevance via _compute_relevance(topic, item_text)
# 8. Return structured items
def fetch_captions(video_items, token, depth="default"):
"""Fetch captions/subtitles for top N TikTok videos.
Strategy: Re-run actor with shouldDownloadSubtitles=true for
specific video URLs, OR fall back to video text/description
as a lightweight alternative.
Returns:
Dict mapping video_id → caption_text (truncated to 500 words)
"""
def search_and_enrich(topic, from_date, to_date, depth="default", token=None):
"""Search + caption enrichment orchestrator (mirrors youtube_yt.search_and_transcribe)."""
def parse_tiktok_response(response):
"""Extract items list from search_and_enrich response."""
Apify actor input for keyword search:
{
"searchQueries": ["claude code tips"],
"resultsPerPage": 20,
"shouldDownloadSubtitles": false,
"shouldDownloadVideos": false,
"shouldDownloadCovers": false
}
Apify actor output fields we use:
| Apify Field | Our Field | Notes |
|---|---|---|
id |
id |
TikTok video ID |
text |
caption |
Video caption/description |
playCount |
engagement.views |
Primary engagement signal |
diggCount |
engagement.likes |
Secondary signal |
commentCount |
engagement.num_comments |
Tertiary signal |
shareCount |
(stored but not scored) | Available for future use |
createTimeISO |
date |
Parse to YYYY-MM-DD |
authorMeta.name |
author_name |
Creator handle |
authorMeta.fans |
(stored but not scored) | Follower count |
webVideoUrl |
url |
Direct TikTok link |
hashtags[].name |
hashtags |
For relevance boosting |
videoMeta.duration |
duration |
For filtering very short clips |
Relevance scoring: Reuse the token-overlap algorithm from youtube_yt._compute_relevance(). Additionally boost relevance when topic tokens appear in hashtags (TikTok-specific signal).
Caption enrichment strategy:
- Primary: Use the
textfield (video description/caption) — always available, free - Enhanced: For top N videos, re-run actor with
shouldDownloadSubtitles: trueto get spoken-word captions - Fallback: If subtitles unavailable, use
textfield alone (most TikTok videos have descriptive captions)
This is cheaper than YouTube transcripts (no second yt-dlp call needed for the basic case).
Phase 3: Schema + Normalization
scripts/lib/schema.py — add TikTokItem dataclass:
@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
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)
scripts/lib/schema.py — add to Engagement dataclass:
shares: Optional[int] = None— TikTok shares (also useful for future Facebook)
scripts/lib/schema.py — add to Report dataclass:
tiktok: List[TikTokItem] = field(default_factory=list)tiktok_error: Optional[str] = None
scripts/lib/normalize.py — add normalize_tiktok_items():
- Parse
createTimeISO→ YYYY-MM-DD - Create Engagement(views=playCount, likes=diggCount, num_comments=commentCount)
- Create TikTokItem objects
- Hard date filter (like Reddit/X, not soft like YouTube)
Phase 4: Scoring
scripts/lib/score.py — add TikTok scoring:
def compute_tiktok_engagement_raw(engagement):
"""TikTok engagement: views-dominant like YouTube.
0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
"""
views = getattr(engagement, 'views', 0) or 0
likes = getattr(engagement, 'likes', 0) or 0
comments = getattr(engagement, 'num_comments', 0) or 0
return 0.50 * log1p(views) + 0.30 * log1p(likes) + 0.20 * log1p(comments)
def score_tiktok_items(items):
"""Score TikTok items. Same weights as YouTube:
0.45*relevance + 0.25*recency + 0.30*engagement"""
Phase 5: Deduplication + Cross-Source Linking
scripts/lib/dedupe.py:
def dedupe_tiktok(items, threshold=0.7):
"""Dedupe TikTok items via Jaccard similarity on text + author_name."""
return dedupe_items(items, threshold)
- Text extraction for similarity:
text + author_name(mirrors YouTube'stitle + channel_name) - Add
tiktoktocross_source_link()— compare TikTok items with all other sources - Cross-ref prefix:
"TK"(e.g.,TK3for TikTok item 3)
Phase 6: Rendering
scripts/lib/render.py — add TikTok section:
### TikTok Videos
**TK1** (score:87) @creator_name (2026-02-28) [2.1M views, 45K likes]
Caption: "This Claude Code trick saved me hours... #claudecode #ai"
https://www.tiktok.com/@creator/video/1234567890
Spoken: "So I found this insane trick with Claude Code where you can..."
*TikTok: This Claude Code trick saved me hours*
Stats line for SKILL.md:
├─ 🎵 TikTok: {N} videos │ {N} views │ {N} with captions
Phase 7: Environment + Config
scripts/lib/env.py — add Apify support:
def is_apify_available(config: Dict[str, Any]) -> bool:
"""Check if Apify token is configured for TikTok/social scraping."""
return bool(config.get('APIFY_API_TOKEN'))
- New env var:
APIFY_API_TOKEN - Add to
get_config()key list - Add to
get_available_sources()/get_missing_keys()logic - Single token covers TikTok + future Facebook + Instagram
User setup:
# Add to ~/.config/last30days/.env
APIFY_API_TOKEN=apify_api_xxxxxxxxxxxxx
Or get free token: Sign up at https://console.apify.com → Settings → Integrations → Personal API Token.
Phase 8: Orchestrator Integration
scripts/last30days.py changes:
- Add
"tiktok"toVALID_SEARCH_SOURCESset (line 47) - Add
tiktok_futurevar + timeout toTIMEOUT_PROFILES:"tiktok_future": 120 # Apify actors can be slow on first run - Add
do_tiktokbool +run_tiktokparameter torun_research() - Submit
_search_tiktok()to ThreadPoolExecutor (now max 7+1 workers) - Collect TikTok results with timeout
- Add tiktok to return tuple + progress display
- Wire tiktok into normalize → score → dedupe → cross-link → render pipeline in main
Phase 9: SKILL.md Updates
- Add TikTok to stats box template
- Add TikTok citation rule:
@creator on TikTok - Add TikTok to source weight guidance (rank between YouTube and HN)
- Document
APIFY_API_TOKENin setup section
Phase 10: Dependency
pip install apify-client
apify-clientis the only new dependency- Requires Python 3.10+ (already required by the project)
- No new binary dependencies (unlike yt-dlp for YouTube)
Files to Create / Modify
New Files
| File | Purpose |
|---|---|
scripts/lib/apify_client_wrapper.py |
Shared Apify client init + run_actor_sync() helper |
scripts/lib/tiktok.py |
TikTok search, caption extraction, relevance scoring |
tests/test_tiktok.py |
Unit tests for TikTok module |
fixtures/tiktok_search.json |
Mock Apify response for testing |
Modified Files
| File | Changes |
|---|---|
scripts/lib/schema.py |
Add TikTokItem dataclass, shares to Engagement, tiktok/tiktok_error to Report |
scripts/lib/normalize.py |
Add normalize_tiktok_items() |
scripts/lib/score.py |
Add compute_tiktok_engagement_raw(), score_tiktok_items() |
scripts/lib/dedupe.py |
Add dedupe_tiktok(), add tiktok to cross_source_link() |
scripts/lib/render.py |
Add TikTok rendering section, stats line |
scripts/lib/env.py |
Add APIFY_API_TOKEN handling, is_apify_available() |
scripts/last30days.py |
Add tiktok to orchestrator pipeline, VALID_SEARCH_SOURCES, TIMEOUT_PROFILES |
SKILL.md |
Add TikTok stats line, citation rules, source weights |
README.md |
Add TikTok to source list, Apify setup instructions |
Future: Facebook + Instagram via Apify
The apify_client_wrapper.py module is designed to be reused. Adding Facebook would look like:
# scripts/lib/facebook.py
from . import apify_client_wrapper
ACTOR_ID = "apify/facebook-posts-scraper" # or "scraper_one/facebook-posts-search"
def search_facebook(topic, from_date, to_date, depth="default", token=None):
client = apify_client_wrapper.get_apify_client(token)
run_input = {
"searchType": "posts",
"searchTerms": [topic],
"maxPosts": DEPTH_CONFIG[depth]["max_posts"],
}
items = apify_client_wrapper.run_actor_sync(client, ACTOR_ID, run_input)
# Parse: text, likes, comments, shares, time, user.name, url
...
Facebook fields available: text, likes, comments, shares, time/timestamp, user.name, url, reactions_count
Instagram would follow the same pattern with apify/instagram-scraper or similar.
Same APIFY_API_TOKEN — no additional keys needed.
Cost Analysis
Per research run (default depth, 20 results):
- Clockworks TikTok scraper: ~$0.10 per 20 results ($5/1000)
- Free tier: ~50 research runs per month on $5 free credits
- With captions (re-run for top 5): ~$0.15 total per run → ~33 runs/month free
Comparison: YouTube costs $0 (yt-dlp is free). TikTok costs ~$0.10-0.15/run. This is acceptable given the signal value and tracks with the BYO key model.
Acceptance Criteria
APIFY_API_TOKENin.envenables TikTok source automatically- TikTok appears in parallel search alongside other 6 sources
- Results include: video URL, caption, author, views, likes, comments, date
- Caption enrichment works for top N videos (configurable by depth)
- Relevance scoring filters off-topic viral videos
- Cross-source linking detects when TikTok + Reddit/YouTube discuss same topic
- Stats box shows:
🎵 TikTok: N videos │ N views │ N with captions --search=tiktokflag works for TikTok-only research- Graceful degradation: if no APIFY_API_TOKEN, TikTok silently skipped
- Mock mode works with
fixtures/tiktok_search.json - Tests pass for search, normalize, score, dedupe, render
apify_client_wrapper.pyis generic enough for Facebook/Instagram reuse