""" Competitive Monitor - Track SEO Competitive Changes Over Time ============================================================= Purpose: Monitor traffic trends, DR changes, keyword movement, content velocity, and generate competitive alerts. Python: 3.10+ Usage: python competitive_monitor.py --target https://example.com --period 30 --json python competitive_monitor.py --target https://example.com --competitor https://comp1.com --period 60 --json python competitive_monitor.py --target https://example.com --scope traffic --period 90 --json """ import argparse import asyncio import json import logging import sys from dataclasses import dataclass, field, asdict from datetime import datetime, timedelta from typing import Any from urllib.parse import urlparse from base_client import BaseAsyncClient, config logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class TrafficTrend: """Traffic trend for a domain over a time period.""" domain: str = "" data_points: list[dict[str, Any]] = field(default_factory=list) direction: str = "stable" # up / down / stable growth_rate: float = 0.0 current_traffic: int = 0 period_start_traffic: int = 0 @dataclass class DrTrend: """Domain Rating trend for a domain.""" domain: str = "" data_points: list[dict[str, Any]] = field(default_factory=list) direction: str = "stable" current_dr: float = 0.0 period_start_dr: float = 0.0 change: float = 0.0 @dataclass class KeywordMovement: """Keyword gains and losses for a domain.""" domain: str = "" new_keywords: int = 0 lost_keywords: int = 0 net_change: int = 0 improved_positions: int = 0 declined_positions: int = 0 @dataclass class ContentVelocity: """Content publication rate metrics.""" domain: str = "" new_pages_per_month: float = 0.0 total_pages_start: int = 0 total_pages_end: int = 0 net_new_pages: int = 0 avg_word_count: int = 0 @dataclass class CompetitiveAlert: """Alert for significant competitive movement.""" domain: str = "" alert_type: str = "" # traffic_surge, dr_jump, keyword_surge, content_burst message: str = "" severity: str = "info" # info / warning / critical metric_value: float = 0.0 threshold: float = 0.0 @dataclass class MarketShare: """Market share estimation for a domain within its competitive set.""" domain: str = "" traffic_share_pct: float = 0.0 keyword_share_pct: float = 0.0 overall_share_pct: float = 0.0 @dataclass class CompetitiveMonitorResult: """Full monitoring result.""" target: str = "" period_days: int = 30 scope: str = "all" traffic_trends: list[TrafficTrend] = field(default_factory=list) dr_trends: list[DrTrend] = field(default_factory=list) keyword_movements: list[KeywordMovement] = field(default_factory=list) content_velocities: list[ContentVelocity] = field(default_factory=list) alerts: list[CompetitiveAlert] = field(default_factory=list) market_shares: list[MarketShare] = field(default_factory=list) timestamp: str = "" errors: list[str] = field(default_factory=list) # --------------------------------------------------------------------------- # Monitor # --------------------------------------------------------------------------- class CompetitiveMonitor(BaseAsyncClient): """Monitor competitive landscape changes using Ahrefs MCP tools.""" # Alert thresholds TRAFFIC_CHANGE_THRESHOLD = 0.20 # 20% change triggers alert DR_CHANGE_THRESHOLD = 3.0 # 3-point DR change KEYWORD_SURGE_THRESHOLD = 0.15 # 15% keyword growth CONTENT_BURST_THRESHOLD = 2.0 # 2x normal content velocity def __init__(self): super().__init__(max_concurrent=5, requests_per_second=2.0) @staticmethod def _extract_domain(url: str) -> str: """Extract bare domain from URL or return as-is if already bare.""" if "://" in url: parsed = urlparse(url) return parsed.netloc.lower().replace("www.", "") return url.lower().replace("www.", "") async def _call_ahrefs(self, tool: str, params: dict[str, Any]) -> dict: """Simulate Ahrefs MCP call. In production, routed via MCP bridge.""" self.logger.info(f"Ahrefs MCP call: {tool} | params={params}") return {"tool": tool, "params": params, "data": {}} async def _discover_competitors( self, target: str, limit: int = 10 ) -> list[str]: """Discover competitors if none specified.""" domain = self._extract_domain(target) resp = await self._call_ahrefs( "site-explorer-organic-competitors", {"target": domain, "limit": limit}, ) competitors_raw = resp.get("data", {}).get("competitors", []) return [ c.get("domain", "") for c in competitors_raw if c.get("domain") and c.get("domain") != domain ][:limit] async def get_traffic_trends( self, domains: list[str], period: int ) -> list[TrafficTrend]: """Get traffic trend data for multiple domains.""" trends: list[TrafficTrend] = [] date_from = (datetime.now() - timedelta(days=period)).strftime("%Y-%m-%d") for domain in domains: domain = self._extract_domain(domain) trend = TrafficTrend(domain=domain) resp = await self._call_ahrefs( "site-explorer-metrics-history", {"target": domain, "date_from": date_from}, ) data_points = resp.get("data", {}).get("data_points", []) trend.data_points = data_points if len(data_points) >= 2: first_traffic = int(data_points[0].get("organic_traffic", 0)) last_traffic = int(data_points[-1].get("organic_traffic", 0)) trend.period_start_traffic = first_traffic trend.current_traffic = last_traffic if first_traffic > 0: growth = ((last_traffic - first_traffic) / first_traffic) * 100 trend.growth_rate = round(growth, 2) if growth > 5: trend.direction = "up" elif growth < -5: trend.direction = "down" else: trend.direction = "stable" else: trend.direction = "new" if last_traffic > 0 else "stable" trends.append(trend) self.logger.info( f"Traffic trend for {domain}: {trend.direction} " f"({trend.growth_rate:+.1f}%)" ) return trends async def get_dr_trends( self, domains: list[str], period: int ) -> list[DrTrend]: """Get Domain Rating trend data for multiple domains.""" trends: list[DrTrend] = [] date_from = (datetime.now() - timedelta(days=period)).strftime("%Y-%m-%d") for domain in domains: domain = self._extract_domain(domain) trend = DrTrend(domain=domain) resp = await self._call_ahrefs( "site-explorer-domain-rating-history", {"target": domain, "date_from": date_from}, ) data_points = resp.get("data", {}).get("data_points", []) trend.data_points = data_points if len(data_points) >= 2: first_dr = float(data_points[0].get("domain_rating", 0)) last_dr = float(data_points[-1].get("domain_rating", 0)) trend.period_start_dr = first_dr trend.current_dr = last_dr trend.change = round(last_dr - first_dr, 1) if trend.change > 1: trend.direction = "up" elif trend.change < -1: trend.direction = "down" else: trend.direction = "stable" trends.append(trend) self.logger.info( f"DR trend for {domain}: {trend.direction} " f"(change={trend.change:+.1f})" ) return trends async def track_keyword_movement( self, domains: list[str], period: int ) -> list[KeywordMovement]: """Track new/lost keywords for each domain over the period.""" movements: list[KeywordMovement] = [] for domain in domains: domain = self._extract_domain(domain) movement = KeywordMovement(domain=domain) # Current keyword count current_resp = await self._call_ahrefs( "site-explorer-metrics", {"target": domain}, ) current_kw = int( current_resp.get("data", {}).get("organic_keywords", 0) ) # Historical keyword count date_from = (datetime.now() - timedelta(days=period)).strftime("%Y-%m-%d") hist_resp = await self._call_ahrefs( "site-explorer-metrics-history", {"target": domain, "date_from": date_from}, ) data_points = hist_resp.get("data", {}).get("data_points", []) if data_points: start_kw = int(data_points[0].get("organic_keywords", 0)) else: start_kw = current_kw net_change = current_kw - start_kw movement.net_change = net_change # Estimate new vs lost (simplified: positive net = new > lost) if net_change > 0: movement.new_keywords = net_change movement.lost_keywords = 0 movement.improved_positions = int(net_change * 0.6) movement.declined_positions = int(net_change * 0.1) elif net_change < 0: movement.new_keywords = 0 movement.lost_keywords = abs(net_change) movement.improved_positions = 0 movement.declined_positions = abs(net_change) else: movement.new_keywords = 0 movement.lost_keywords = 0 movements.append(movement) self.logger.info( f"Keyword movement for {domain}: net={movement.net_change:+d}" ) return movements async def compare_content_velocity( self, domains: list[str], period: int ) -> list[ContentVelocity]: """Compare content publication velocity across domains.""" velocities: list[ContentVelocity] = [] date_from = (datetime.now() - timedelta(days=period)).strftime("%Y-%m-%d") for domain in domains: domain = self._extract_domain(domain) velocity = ContentVelocity(domain=domain) resp = await self._call_ahrefs( "site-explorer-pages-history", {"target": domain, "date_from": date_from}, ) data_points = resp.get("data", {}).get("data_points", []) if len(data_points) >= 2: start_pages = int(data_points[0].get("pages", 0)) end_pages = int(data_points[-1].get("pages", 0)) velocity.total_pages_start = start_pages velocity.total_pages_end = end_pages velocity.net_new_pages = end_pages - start_pages months = max(period / 30.0, 1.0) velocity.new_pages_per_month = round( velocity.net_new_pages / months, 1 ) else: # Fallback: get current pages count metrics_resp = await self._call_ahrefs( "site-explorer-metrics", {"target": domain} ) velocity.total_pages_end = int( metrics_resp.get("data", {}).get("pages", 0) ) velocities.append(velocity) self.logger.info( f"Content velocity for {domain}: " f"{velocity.new_pages_per_month:.1f} pages/month" ) return velocities def generate_alerts( self, traffic_trends: list[TrafficTrend], dr_trends: list[DrTrend], keyword_movements: list[KeywordMovement], content_velocities: list[ContentVelocity], target_domain: str, ) -> list[CompetitiveAlert]: """Generate alerts for significant competitive movements.""" alerts: list[CompetitiveAlert] = [] target_domain = self._extract_domain(target_domain) for trend in traffic_trends: if trend.domain == target_domain: continue abs_growth = abs(trend.growth_rate) / 100.0 if abs_growth >= self.TRAFFIC_CHANGE_THRESHOLD: severity = "critical" if abs_growth >= 0.50 else "warning" direction = "surge" if trend.growth_rate > 0 else "decline" alerts.append(CompetitiveAlert( domain=trend.domain, alert_type=f"traffic_{direction}", message=( f"{trend.domain} traffic {direction}: " f"{trend.growth_rate:+.1f}% " f"({trend.period_start_traffic:,} -> {trend.current_traffic:,})" ), severity=severity, metric_value=trend.growth_rate, threshold=self.TRAFFIC_CHANGE_THRESHOLD * 100, )) for trend in dr_trends: if trend.domain == target_domain: continue if abs(trend.change) >= self.DR_CHANGE_THRESHOLD: severity = "warning" if abs(trend.change) < 5 else "critical" direction = "jump" if trend.change > 0 else "drop" alerts.append(CompetitiveAlert( domain=trend.domain, alert_type=f"dr_{direction}", message=( f"{trend.domain} DR {direction}: " f"{trend.change:+.1f} points " f"({trend.period_start_dr:.1f} -> {trend.current_dr:.1f})" ), severity=severity, metric_value=trend.change, threshold=self.DR_CHANGE_THRESHOLD, )) for movement in keyword_movements: if movement.domain == target_domain: continue # Check for keyword surge relative to total keywords if movement.new_keywords > 0: total_est = max(movement.new_keywords + movement.lost_keywords, 1) surge_ratio = movement.new_keywords / total_est if surge_ratio >= self.KEYWORD_SURGE_THRESHOLD: alerts.append(CompetitiveAlert( domain=movement.domain, alert_type="keyword_surge", message=( f"{movement.domain} gained {movement.new_keywords} " f"new keywords (net: {movement.net_change:+d})" ), severity="warning", metric_value=float(movement.new_keywords), threshold=self.KEYWORD_SURGE_THRESHOLD * 100, )) # Check for content burst if content_velocities: avg_velocity = ( sum(v.new_pages_per_month for v in content_velocities) / len(content_velocities) ) for vel in content_velocities: if vel.domain == target_domain: continue if avg_velocity > 0 and vel.new_pages_per_month > avg_velocity * self.CONTENT_BURST_THRESHOLD: alerts.append(CompetitiveAlert( domain=vel.domain, alert_type="content_burst", message=( f"{vel.domain} publishing {vel.new_pages_per_month:.0f} " f"pages/month ({self.CONTENT_BURST_THRESHOLD:.0f}x above average)" ), severity="info", metric_value=vel.new_pages_per_month, threshold=avg_velocity * self.CONTENT_BURST_THRESHOLD, )) # Sort alerts by severity severity_order = {"critical": 0, "warning": 1, "info": 2} alerts.sort(key=lambda a: severity_order.get(a.severity, 3)) self.logger.info(f"Generated {len(alerts)} competitive alerts") return alerts def estimate_market_share( self, traffic_trends: list[TrafficTrend], keyword_movements: list[KeywordMovement], ) -> list[MarketShare]: """Estimate market share based on organic traffic within competitive set.""" shares: list[MarketShare] = [] total_traffic = sum(t.current_traffic for t in traffic_trends) or 1 total_kw_est = sum( max(m.new_keywords + abs(m.net_change), 1) for m in keyword_movements ) or 1 kw_by_domain: dict[str, int] = {} for m in keyword_movements: kw_by_domain[m.domain] = max(m.new_keywords + abs(m.net_change), 1) for trend in traffic_trends: share = MarketShare(domain=trend.domain) share.traffic_share_pct = round( (trend.current_traffic / total_traffic) * 100, 2 ) kw_count = kw_by_domain.get(trend.domain, 1) share.keyword_share_pct = round( (kw_count / total_kw_est) * 100, 2 ) share.overall_share_pct = round( share.traffic_share_pct * 0.7 + share.keyword_share_pct * 0.3, 2 ) shares.append(share) shares.sort(key=lambda s: s.overall_share_pct, reverse=True) return shares async def monitor( self, target: str, competitors: list[str] | None = None, period: int = 30, scope: str = "all", ) -> CompetitiveMonitorResult: """Orchestrate full competitive monitoring pipeline.""" timestamp = datetime.now().isoformat() target_domain = self._extract_domain(target) result = CompetitiveMonitorResult( target=target_domain, period_days=period, scope=scope, timestamp=timestamp, ) try: # Discover competitors if not provided if competitors: comp_domains = [self._extract_domain(c) for c in competitors] else: self.logger.info("Auto-discovering competitors...") comp_domains = await self._discover_competitors(target, limit=10) all_domains = [target_domain] + comp_domains self.logger.info( f"Monitoring {len(all_domains)} domains over {period} days" ) # Traffic trends if scope in ("all", "traffic"): self.logger.info("Fetching traffic trends...") result.traffic_trends = await self.get_traffic_trends( all_domains, period ) # DR trends if scope in ("all", "traffic"): self.logger.info("Fetching DR trends...") result.dr_trends = await self.get_dr_trends(all_domains, period) # Keyword movements if scope in ("all", "keywords"): self.logger.info("Tracking keyword movements...") result.keyword_movements = await self.track_keyword_movement( all_domains, period ) # Content velocity if scope in ("all", "content"): self.logger.info("Comparing content velocity...") result.content_velocities = await self.compare_content_velocity( all_domains, period ) # Generate alerts self.logger.info("Generating competitive alerts...") result.alerts = self.generate_alerts( traffic_trends=result.traffic_trends, dr_trends=result.dr_trends, keyword_movements=result.keyword_movements, content_velocities=result.content_velocities, target_domain=target_domain, ) # Market share estimation if result.traffic_trends and result.keyword_movements: self.logger.info("Estimating market shares...") result.market_shares = self.estimate_market_share( result.traffic_trends, result.keyword_movements, ) self.logger.info( f"Monitoring complete: {len(result.alerts)} alerts generated" ) except Exception as e: msg = f"Monitoring pipeline error: {e}" self.logger.error(msg) result.errors.append(msg) return result # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- def _format_text_report(result: CompetitiveMonitorResult) -> str: """Format monitoring result as human-readable text report.""" lines: list[str] = [] lines.append("=" * 70) lines.append(" COMPETITIVE MONITORING REPORT") lines.append(f" Target: {result.target}") lines.append(f" Period: {result.period_days} days | Scope: {result.scope}") lines.append(f" Generated: {result.timestamp}") lines.append("=" * 70) # Alerts section if result.alerts: lines.append("") lines.append("--- ALERTS ---") for alert in result.alerts: icon = {"critical": "[!]", "warning": "[*]", "info": "[-]"}.get( alert.severity, "[-]" ) lines.append(f" {icon} [{alert.severity.upper()}] {alert.message}") # Traffic trends if result.traffic_trends: lines.append("") lines.append("--- TRAFFIC TRENDS ---") lines.append(f" {'Domain':<30} {'Direction':>10} {'Growth':>10} {'Current':>12}") lines.append(" " + "-" * 65) for t in result.traffic_trends: marker = "*" if t.domain == result.target else " " lines.append( f" {marker}{t.domain:<29} {t.direction:>10} " f"{t.growth_rate:>+9.1f}% {t.current_traffic:>11,}" ) # DR trends if result.dr_trends: lines.append("") lines.append("--- DOMAIN RATING TRENDS ---") lines.append(f" {'Domain':<30} {'Direction':>10} {'Change':>10} {'Current':>10}") lines.append(" " + "-" * 63) for d in result.dr_trends: marker = "*" if d.domain == result.target else " " lines.append( f" {marker}{d.domain:<29} {d.direction:>10} " f"{d.change:>+9.1f} {d.current_dr:>9.1f}" ) # Keyword movements if result.keyword_movements: lines.append("") lines.append("--- KEYWORD MOVEMENTS ---") lines.append( f" {'Domain':<30} {'New':>8} {'Lost':>8} {'Net':>10}" ) lines.append(" " + "-" * 59) for k in result.keyword_movements: marker = "*" if k.domain == result.target else " " lines.append( f" {marker}{k.domain:<29} {k.new_keywords:>8,} " f"{k.lost_keywords:>8,} {k.net_change:>+9,}" ) # Content velocity if result.content_velocities: lines.append("") lines.append("--- CONTENT VELOCITY ---") lines.append( f" {'Domain':<30} {'Pages/Mo':>10} {'Net New':>10} {'Total':>10}" ) lines.append(" " + "-" * 63) for v in result.content_velocities: marker = "*" if v.domain == result.target else " " lines.append( f" {marker}{v.domain:<29} {v.new_pages_per_month:>9.1f} " f"{v.net_new_pages:>9,} {v.total_pages_end:>9,}" ) # Market share if result.market_shares: lines.append("") lines.append("--- ESTIMATED MARKET SHARE ---") lines.append( f" {'Domain':<30} {'Traffic%':>10} {'Keywords%':>10} {'Overall%':>10}" ) lines.append(" " + "-" * 63) for s in result.market_shares: marker = "*" if s.domain == result.target else " " lines.append( f" {marker}{s.domain:<29} {s.traffic_share_pct:>9.1f}% " f"{s.keyword_share_pct:>9.1f}% {s.overall_share_pct:>9.1f}%" ) if result.errors: lines.append("") lines.append("--- ERRORS ---") for err in result.errors: lines.append(f" - {err}") lines.append("") lines.append(" * = target domain") lines.append("=" * 70) return "\n".join(lines) def _serialize_result(result: CompetitiveMonitorResult) -> dict: """Convert result to JSON-serializable dict.""" output = { "target": result.target, "period_days": result.period_days, "scope": result.scope, "traffic_trends": [asdict(t) for t in result.traffic_trends], "dr_trends": [asdict(d) for d in result.dr_trends], "keyword_movements": [asdict(k) for k in result.keyword_movements], "content_velocities": [asdict(v) for v in result.content_velocities], "alerts": [asdict(a) for a in result.alerts], "market_shares": [asdict(s) for s in result.market_shares], "timestamp": result.timestamp, } if result.errors: output["errors"] = result.errors return output # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Competitive Monitor - Track SEO competitive changes over time", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: python competitive_monitor.py --target https://example.com --period 30 --json python competitive_monitor.py --target https://example.com --competitor https://comp1.com --period 60 --json python competitive_monitor.py --target https://example.com --scope traffic --period 90 --json """, ) parser.add_argument( "--target", required=True, help="Target website URL or domain to monitor", ) parser.add_argument( "--competitor", action="append", dest="competitors", default=[], help="Competitor URL/domain (repeatable; omit for auto-discovery)", ) parser.add_argument( "--period", type=int, default=30, help="Monitoring period in days (default: 30)", ) parser.add_argument( "--scope", choices=["all", "traffic", "keywords", "content"], default="all", help="Monitoring scope (default: all)", ) parser.add_argument( "--json", action="store_true", default=False, help="Output in JSON format", ) parser.add_argument( "--output", type=str, default=None, help="Save output to file path", ) return parser.parse_args(argv) async def async_main(args: argparse.Namespace) -> None: monitor = CompetitiveMonitor() result = await monitor.monitor( target=args.target, competitors=args.competitors or None, period=args.period, scope=args.scope, ) if args.json: output_str = json.dumps(_serialize_result(result), indent=2, ensure_ascii=False) else: output_str = _format_text_report(result) if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(output_str) logger.info(f"Report saved to {args.output}") else: print(output_str) monitor.print_stats() def main() -> None: args = parse_args() asyncio.run(async_main(args)) if __name__ == "__main__": main()