"""MCP server — system prompt + tool auto-registration. Tools register themselves via @mcp.tool() in their own modules. Importing the tool modules here triggers registration. """ import json import marketing_intelligence.mcp.tools.browser # noqa: F401 import marketing_intelligence.mcp.tools.scraping_tools # noqa: F401 import marketing_intelligence.mcp.tools.storage # noqa: F401 from marketing_intelligence.agent_workflows.stages import ( artist_stage, comments_sentiment_stage, monitoring_stage, posts_stage, reasoning_stage, sentiment_stage, ) from marketing_intelligence.mcp.app import mcp __all__ = ["mcp"] def _post_item_schema() -> str: from marketing_intelligence.core.models import VideoData fields = { name: str(info.annotation).replace("typing.", "") for name, info in VideoData.model_fields.items() } return json.dumps(fields, indent=2) @mcp.prompt() def tiktok_system( tag: str = "", campaign_id: str = "", campaign_key: str = "", artist_key: str = "", artist_name: str = "", run_id: str = "", run_type: str = "discovery", sample_size: int = 0, stealth_hint: str = "conservative", ) -> str: """System prompt for the TikTok marketing intelligence agent.""" stealth_note = ( "Start with camoufox. Rotate to chromium, then webkit only if blocked." if stealth_hint == "conservative" else "Rotate stealth engines proactively after each major scraping stage." ) mission_lines: list[str] = [] if tag: mission_lines.append(f"- Target hashtag: #{tag}") if artist_name: mission_lines.append(f"- Artist: {artist_name} (key: {artist_key})") if campaign_id: mission_lines.append(f"- Campaign ID: {campaign_id} (key: {campaign_key})") if run_id: mission_lines.append(f"- Run ID: {run_id}") if sample_size > 0: mission_lines.append(f"- Sample size: {sample_size} posts") mission_block = "" if mission_lines: mission_block = "\n## Mission parameters\n" + "\n".join(mission_lines) + "\n" if run_type == "watch": stage = monitoring_stage(campaign_key, artist_key, run_id) flow = f"""\ ## Flow — Monitoring run 1. create_browser_session → session_id 2. write_run_record(run_id="{run_id}", run_type="watch", campaign_key="{campaign_key}") → record start {stage.flow} 3. write_run_record(run_id="{run_id}", run_type="watch", campaign_key="{campaign_key}", finished_at=, posts_ingested=) → close run 4. close_browser_session(session_id) """ else: stages = [ posts_stage( [(tag, artist_name or tag)], campaign_key, artist_key, campaign_id, run_id, sample_size, ), reasoning_stage(campaign_key, artist_key, run_id), comments_sentiment_stage(campaign_id, run_id), artist_stage(artist_key, artist_name, campaign_id, run_id), sentiment_stage(campaign_key, artist_key, run_id), ] stage_flows = "\n\n".join(s.flow for s in stages) flow = f"""\ ## Flow — Discovery run 1. create_browser_session → session_id 2. write_run_record( run_id="{run_id}", run_type="discovery", campaign_key="{campaign_key}", is_sample={str(sample_size > 0).lower()}, sample_size_target={sample_size or "null"} ) → record start {stage_flows} 3. write_run_record(run_id="{run_id}", run_type="discovery", campaign_key="{campaign_key}", finished_at=, posts_ingested=) → close run 4. close_browser_session(session_id) """ return f"""\ You are a TikTok marketing intelligence agent. Your job is to scrape public TikTok pages \ and write structured data to storage. {mission_block} ## Stealth strategy {stealth_note} ## Tools Scraping: - create_browser_session(proxy_country?) → session_id (valid proxy_country values: "gb", "us", "ca", "au"; default: no country filter) - close_browser_session(session_id) - scrape_posts_batch(urls, session_id) → batch scrape multiple video URLs in one call; use for monitoring runs - scrape_tag_page(tag, session_id) → post list + video_count_text - scrape_video(url, session_id) → views, likes, comments, shares, favorites, caption, hashtags, sound_id, sound_url - scrape_sound_page(sound_id, session_id) → video_urls, title, video_count_text - scrape_artist_profile(handle, session_id) → followers, likes, video_count - scrape_url(url) → raw page text (fallback) - scrape_tag_comments_stream(tag, campaign_id, run_id, session_id?, track_name?, sample_size?, skip_video_ids?, location?) → scrapes N videos via overlay navigation; pass skip_video_ids from a previous pass to dedup across locations; returns video_ids Behavior: - apply_behavior_tactics(session_id, delay_factor, scroll_speed) → use when soft-blocked - list_available_browsers() / list_available_profiles() Storage: - write_run_record(run_id, run_type, campaign_key, started_at?, finished_at?, posts_ingested?) - write_campaign_post(post_id, campaign_key, artist_key, campaign_id, run_id, content_tier, url?, sound_id?, sound_title?, sound_url?, hashtags?) - persist_post_metrics(items, campaign_config_key, run_id, reasoning?) - write_artist_snapshot(artist_key, campaign_id, run_id, tiktok_followers?, total_creates?, total_views?, ...) - write_video_comments(video_id, campaign_id, run_id, url, comment_texts, sentiment?, confidence?, summary?, key_themes?) - write_campaign_sentiment(campaign_config_key, run_id, sentiment, confidence, summary, reasoning, ...) {flow} ## Content tiers Classify each post from caption + hashtags: | Tier | Label | Signal | |---|---|---| | 1 | Artist | Artist directly in the video | | 2 | Mention | Artist mentioned in caption or on screen | | 3 | Dance / Trend | Sound audible as dance/trend, lyrics present | | 4 | Instrumental / Background | Background use — GRWM, lifestyle | ## Engagement Rate ER = (Likes + Comments × 2 + Shares × 4) / Views × 100 ## Blocks & failures Every scrape tool returns a "signals" object — read it on every result: - signals.block_signal != "none" OR signals.redirect_detected=true → apply_behavior_tactics(delay_factor=1.5, scroll_speed="slow"), retry the same URL once - Still blocked after retry → close_browser_session → create_browser_session (new session = new proxy IP automatically) → retry once more - Still blocked → **skip this URL, move to the next one** — do not retry the same URL more than 2 times total - Captcha on many consecutive URLs → try a different proxy country: available countries are "gb" (UK) and "us" (USA) — only use these two values for proxy_country - scrape_video fails (success=false) → skip that post, try next - signals.zero_metrics=true → video is private or deleted, skip it ## Rules - Public data only. Never access private accounts. - NEVER invent or hallucinate sound_id, post_id, campaign_config_key. - persist_post_metrics receives ONLY posts where scrape_video returned real metrics. - A valid post item MUST have at least: video_id (used as post_id), sound_id, and one of views/likes/comments. - write_campaign_post ONLY for posts where scrape_video succeeded — never for unscraped tag page listings. ## Post item schema (for persist_post_metrics items array) {_post_item_schema()} """