Claude Skill

geo

GEO-first SEO analysis tool. Optimizes websites for AI-powered search engines (ChatGPT, Claude, Perplexity, Gemini, Google AI Overviews) while maintaining traditional SEO foundations. Performs full GEO audits, citability scoring, AI crawler analysis, llms.txt generation, brand me

LLM Mart · 0 points · 16 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download thesmokedev-geo-skills-skills_geo-1d09807.zip · 42 KB
Part of thesmokedev/geo-skills — 34 skills

Install

skills CLI npx skills add https://github.com/TheSmokeDev/geo-skills/tree/main/skills/geo
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install thesmokedev-geo-skills@llmmart
Git git clone https://github.com/TheSmokeDev/geo-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole thesmokedev/geo-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

GEO-SEO Analysis Tool — Claude Code Skill (February 2026)

Philosophy: GEO-first, SEO-supported. AI search is eating traditional search. This tool optimizes for where traffic is going, not where it was.


Quick Reference

Command What It Does
/geo audit <url> Full GEO + SEO audit with parallel subagents
/geo page <url> Deep single-page GEO analysis
/geo citability <url> Score content for AI citation readiness
/geo crawlers <url> Check AI crawler access (robots.txt analysis)
/geo llmstxt <url> Analyze or generate llms.txt file
/geo brands <url> Scan brand mentions across AI-cited platforms
/geo platforms <url> Platform-specific optimization (ChatGPT, Perplexity, Google AIO)
/geo schema <url> Detect, validate, and generate structured data
/geo technical <url> Traditional technical SEO audit
/geo content <url> Content quality and E-E-A-T assessment
/geo report <url> Generate client-ready GEO deliverable
/geo report-pdf <url> Generate professional PDF report with charts and scores
/geo quick <url> 60-second GEO visibility snapshot
/geo prospect <cmd> CRM-lite: manage prospects through the sales pipeline
/geo proposal <domain> Auto-generate client proposal from audit data
/geo compare <domain> Monthly delta report: show score improvements to client

Market Context (Why GEO Matters)

Metric Value Source
GEO services market (2025) $850M-$886M Yahoo Finance / Superlines
Projected GEO market (2031) $7.3B (34% CAGR) Industry analysts
AI-referred sessions growth +527% (Jan-May 2025) SparkToro
AI traffic conversion vs organic 4.4x higher Industry data
Google AI Overviews reach 1.5B users/month, 200+ countries Google
ChatGPT weekly active users 900M+ OpenAI
Perplexity monthly queries 500M+ Perplexity
Gartner: search traffic drop by 2028 -50% Gartner
Marketers investing in GEO Only 23% Industry surveys
Brand mentions vs backlinks for AI Branded web mentions r=0.664, YouTube mentions r=0.737; raw backlink counts far weaker (directional only — precise figure has no locatable primary source) Ahrefs 75K brands (Jul 2026)

Orchestration Logic

Full Audit (/geo audit <url>)

Phase 1: Discovery (Sequential)

  1. Fetch homepage HTML (curl or WebFetch)
  2. Detect business type (SaaS, Local, E-commerce, Publisher, Agency, Other)
  3. Extract key pages from sitemap.xml or internal links (up to 50 pages)

Phase 2: Parallel Analysis (Delegate to Subagents) Launch these 5 subagents simultaneously:

Subagent File Responsibility
geo-ai-visibility agents/geo-ai-visibility.md GEO audit, citability, AI crawlers, llms.txt, brand mentions
geo-platform-analysis agents/geo-platform-analysis.md Platform-specific optimization (ChatGPT, Perplexity, Google AIO)
geo-technical agents/geo-technical.md Technical SEO, Core Web Vitals, crawlability, indexability
geo-content agents/geo-content.md Content quality, E-E-A-T, readability, AI content detection
geo-schema agents/geo-schema.md Schema markup detection, validation, generation

Phase 3: Synthesis (Sequential)

  1. Collect all subagent reports
  2. Calculate composite GEO Score (0-100)
  3. Generate prioritized action plan
  4. Output client-ready report

Scoring Methodology

Category Weight Measured By
AI Citability & Visibility 25% Passage scoring, answer block quality, AI crawler access
Brand Authority Signals 20% Mentions on Reddit, YouTube, Wikipedia, LinkedIn; entity presence
Content Quality & E-E-A-T 20% Expertise signals, original data, author credentials
Technical Foundations 15% SSR, Core Web Vitals, crawlability, mobile, security
Structured Data 10% Schema completeness, JSON-LD validation, rich result eligibility
Platform Optimization 10% Platform-specific readiness (Google AIO, ChatGPT, Perplexity)

Business Type Detection

Analyze homepage for patterns:

Type Signals
SaaS Pricing page, "Sign up", "Free trial", "/app", "/dashboard", API docs
Local Service Phone number, address, "Near me", Google Maps embed, service area
E-commerce Product pages, cart, "Add to cart", price elements, product schema
Publisher Blog, articles, bylines, publication dates, article schema
Agency Portfolio, case studies, "Our services", client logos, testimonials
Other Default — apply general GEO best practices

Adjust recommendations based on detected type. Local businesses need LocalBusiness schema and Google Business Profile optimization. SaaS needs SoftwareApplication schema and comparison page strategy. E-commerce needs Product schema and review aggregation.


Sub-Skills (10 Specialized Components)

# Skill Directory Purpose
1 geo-audit skills/geo-audit/ Full audit orchestration and scoring
2 geo-citability skills/geo-citability/ Passage-level AI citation readiness
3 geo-crawlers skills/geo-crawlers/ AI crawler access and robots.txt
4 geo-llmstxt skills/geo-llmstxt/ llms.txt facts-hedge analysis and generation (anti-hallucination hygiene, not a visibility lever)
5 geo-brand-mentions skills/geo-brand-mentions/ Brand presence on AI-cited platforms
6 geo-platform-optimizer skills/geo-platform-optimizer/ Platform-specific AI search optimization
7 geo-schema skills/geo-schema/ Structured data for rich results and entity clarity
8 geo-technical skills/geo-technical/ Technical SEO foundations
9 geo-content skills/geo-content/ Content quality and E-E-A-T
10 geo-report skills/geo-report/ Client-ready deliverable generation
11 geo-prospect skills/geo-prospect/ CRM-lite prospect and client pipeline management
12 geo-proposal skills/geo-proposal/ Auto-generate client proposals from audit data
13 geo-compare skills/geo-compare/ Monthly delta tracking and progress reports

Subagents (5 Parallel Workers)

Agent File Skills Used
geo-ai-visibility agents/geo-ai-visibility.md geo-citability, geo-crawlers, geo-llmstxt, geo-brand-mentions
geo-platform-analysis agents/geo-platform-analysis.md geo-platform-optimizer
geo-technical agents/geo-technical.md geo-technical
geo-content agents/geo-content.md geo-content
geo-schema agents/geo-schema.md geo-schema

Output Files

All commands generate structured output:

Command Output File
/geo audit GEO-AUDIT-REPORT.md
/geo page GEO-PAGE-ANALYSIS.md
/geo citability GEO-CITABILITY-SCORE.md
/geo crawlers GEO-CRAWLER-ACCESS.md
/geo llmstxt llms.txt (ready to deploy)
/geo brands GEO-BRAND-MENTIONS.md
/geo platforms GEO-PLATFORM-OPTIMIZATION.md
/geo schema GEO-SCHEMA-REPORT.md + generated JSON-LD
/geo technical GEO-TECHNICAL-AUDIT.md
/geo content GEO-CONTENT-ANALYSIS.md
/geo report GEO-CLIENT-REPORT.md (presentation-ready)
/geo report-pdf GEO-REPORT.pdf (professional PDF with charts)
/geo quick Inline summary (no file)
/geo prospect Updates ~/.geo-prospects/prospects.json
/geo proposal ~/.geo-prospects/proposals/<domain>-proposal-<date>.md
/geo compare ~/.geo-prospects/reports/<domain>-monthly-<YYYY-MM>.md

PDF Report Generation

The /geo report-pdf <url> command generates a professional, branded PDF report:

How It Works

  1. Run the full audit or individual analyses first
  2. Collect all scores and findings into a JSON structure
  3. Execute the PDF generator: python3 ~/.claude/skills/geo/scripts/generate_pdf_report.py data.json GEO-REPORT.pdf

What the PDF Includes

  • Cover page with GEO score gauge visualization
  • Score breakdown with color-coded bar charts
  • AI Platform Readiness dashboard with horizontal bar chart
  • Crawler Access status table with color-coded Allow/Block
  • Key Findings categorized by severity (Critical/High/Medium/Low)
  • Prioritized Action Plan (Quick Wins, Medium-Term, Strategic)
  • Methodology & Glossary appendix

Workflow

  1. First run /geo audit <url> to collect all data
  2. Then run /geo report-pdf <url> to generate the PDF
  3. The tool will compile audit data into JSON, then generate the PDF
  4. Output: GEO-REPORT.pdf in the current directory

Quality Gates

  • Crawl limit: Max 50 pages per audit (focus on quality over quantity)
  • Timeout: 30 seconds per page fetch
  • Rate limiting: 1-second delay between requests, max 5 concurrent
  • Robots.txt: Always respect, always check
  • Duplicate detection: Skip pages with >80% content similarity

Quick Start Examples

# Full GEO audit of a website
/geo audit https://example.com

# Check if AI bots can see your site
/geo crawlers https://example.com

# Score a specific page for AI citability
/geo citability https://example.com/blog/best-article

# Generate an llms.txt file for your site
/geo llmstxt https://example.com

# Get a 60-second visibility snapshot
/geo quick https://example.com

# Generate a client-ready report
/geo report https://example.com
Files (geo-skills)
  • schema
    • article-author.json 1.9 KB
      {
        "@context": "https://schema.org",
        "@type": "Article",
        "@id": "https://YOURDOMAIN.com/blog/ARTICLE_SLUG/#article",
        "headline": "ARTICLE_TITLE",
        "description": "ARTICLE_DESCRIPTION",
        "url": "https://YOURDOMAIN.com/blog/ARTICLE_SLUG",
        "datePublished": "YYYY-MM-DD",
        "dateModified": "YYYY-MM-DD",
        "image": {
          "@type": "ImageObject",
          "url": "https://YOURDOMAIN.com/images/ARTICLE_IMAGE.jpg",
          "width": 1200,
          "height": 630
        },
        "author": {
          "@type": "Person",
          "@id": "https://YOURDOMAIN.com/about/AUTHOR_SLUG/#person",
          "name": "AUTHOR_NAME",
          "url": "https://YOURDOMAIN.com/about/AUTHOR_SLUG",
          "image": "https://YOURDOMAIN.com/images/authors/AUTHOR_IMAGE.jpg",
          "jobTitle": "AUTHOR_JOB_TITLE",
          "description": "AUTHOR_BIO_SHORT",
          "knowsAbout": [
            "EXPERTISE_1",
            "EXPERTISE_2",
            "EXPERTISE_3"
          ],
          "sameAs": [
            "https://www.linkedin.com/in/AUTHOR_LINKEDIN",
            "https://twitter.com/AUTHOR_TWITTER",
            "https://github.com/AUTHOR_GITHUB",
            "https://en.wikipedia.org/wiki/AUTHOR_WIKIPEDIA"
          ],
          "alumniOf": {
            "@type": "CollegeOrUniversity",
            "name": "UNIVERSITY_NAME"
          },
          "worksFor": {
            "@type": "Organization",
            "name": "YOUR_ORGANIZATION_NAME",
            "@id": "https://YOURDOMAIN.com/#organization"
          }
        },
        "publisher": {
          "@type": "Organization",
          "@id": "https://YOURDOMAIN.com/#organization",
          "name": "YOUR_ORGANIZATION_NAME",
          "logo": {
            "@type": "ImageObject",
            "url": "https://YOURDOMAIN.com/logo.png"
          }
        },
        "mainEntityOfPage": {
          "@type": "WebPage",
          "@id": "https://YOURDOMAIN.com/blog/ARTICLE_SLUG"
        },
        "wordCount": "XXXX",
        "articleSection": "CATEGORY",
        "keywords": "KEYWORD_1, KEYWORD_2, KEYWORD_3",
        "speakable": {
          "@type": "SpeakableSpecification",
          "cssSelector": [".article-summary", ".key-takeaway", "h2"]
        },
        "isAccessibleForFree": true,
        "inLanguage": "en-US"
      }
      
    • local-business.json 2 KB
      {
        "@context": "https://schema.org",
        "@type": "LocalBusiness",
        "@id": "https://YOURDOMAIN.com/#localbusiness",
        "name": "YOUR_BUSINESS_NAME",
        "url": "https://YOURDOMAIN.com",
        "image": "https://YOURDOMAIN.com/images/storefront.jpg",
        "description": "YOUR_BUSINESS_DESCRIPTION",
        "telephone": "+1-XXX-XXX-XXXX",
        "email": "contact@YOURDOMAIN.com",
        "priceRange": "$$",
        "address": {
          "@type": "PostalAddress",
          "streetAddress": "YOUR_STREET",
          "addressLocality": "YOUR_CITY",
          "addressRegion": "YOUR_STATE",
          "postalCode": "YOUR_ZIP",
          "addressCountry": "US"
        },
        "geo": {
          "@type": "GeoCoordinates",
          "latitude": "XX.XXXXXX",
          "longitude": "-XX.XXXXXX"
        },
        "openingHoursSpecification": [
          {
            "@type": "OpeningHoursSpecification",
            "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
            "opens": "09:00",
            "closes": "17:00"
          }
        ],
        "areaServed": {
          "@type": "GeoCircle",
          "geoMidpoint": {
            "@type": "GeoCoordinates",
            "latitude": "XX.XXXXXX",
            "longitude": "-XX.XXXXXX"
          },
          "geoRadius": "50 mi"
        },
        "sameAs": [
          "https://www.google.com/maps/place/YOUR_GOOGLE_MAPS_URL",
          "https://www.yelp.com/biz/YOUR_YELP_URL",
          "https://www.facebook.com/YOUR_FACEBOOK",
          "https://www.linkedin.com/company/YOUR_LINKEDIN",
          "https://www.youtube.com/@YOUR_YOUTUBE",
          "https://www.bbb.org/YOUR_BBB_URL"
        ],
        "aggregateRating": {
          "@type": "AggregateRating",
          "ratingValue": "4.8",
          "reviewCount": "XX",
          "bestRating": "5"
        },
        "hasOfferCatalog": {
          "@type": "OfferCatalog",
          "name": "Services",
          "itemListElement": [
            {
              "@type": "Offer",
              "itemOffered": {
                "@type": "Service",
                "name": "SERVICE_NAME_1",
                "description": "SERVICE_DESCRIPTION_1"
              }
            },
            {
              "@type": "Offer",
              "itemOffered": {
                "@type": "Service",
                "name": "SERVICE_NAME_2",
                "description": "SERVICE_DESCRIPTION_2"
              }
            }
          ]
        }
      }
      
    • organization.json 1.6 KB
      {
        "@context": "https://schema.org",
        "@type": "Organization",
        "@id": "https://YOURDOMAIN.com/#organization",
        "name": "YOUR_ORGANIZATION_NAME",
        "url": "https://YOURDOMAIN.com",
        "logo": {
          "@type": "ImageObject",
          "url": "https://YOURDOMAIN.com/logo.png",
          "width": 600,
          "height": 60
        },
        "description": "YOUR_ORGANIZATION_DESCRIPTION",
        "foundingDate": "YYYY-MM-DD",
        "founder": {
          "@type": "Person",
          "name": "FOUNDER_NAME",
          "url": "https://YOURDOMAIN.com/about/FOUNDER",
          "sameAs": [
            "https://www.linkedin.com/in/FOUNDER_LINKEDIN",
            "https://twitter.com/FOUNDER_TWITTER"
          ]
        },
        "contactPoint": {
          "@type": "ContactPoint",
          "telephone": "+1-XXX-XXX-XXXX",
          "contactType": "customer service",
          "email": "contact@YOURDOMAIN.com",
          "availableLanguage": ["English"]
        },
        "address": {
          "@type": "PostalAddress",
          "streetAddress": "YOUR_STREET",
          "addressLocality": "YOUR_CITY",
          "addressRegion": "YOUR_STATE",
          "postalCode": "YOUR_ZIP",
          "addressCountry": "US"
        },
        "sameAs": [
          "https://www.linkedin.com/company/YOUR_LINKEDIN",
          "https://www.youtube.com/@YOUR_YOUTUBE",
          "https://www.reddit.com/r/YOUR_SUBREDDIT",
          "https://twitter.com/YOUR_TWITTER",
          "https://www.facebook.com/YOUR_FACEBOOK",
          "https://github.com/YOUR_GITHUB",
          "https://www.crunchbase.com/organization/YOUR_CRUNCHBASE",
          "https://en.wikipedia.org/wiki/YOUR_WIKIPEDIA",
          "https://www.wikidata.org/wiki/YOUR_WIKIDATA_ID"
        ],
        "numberOfEmployees": {
          "@type": "QuantitativeValue",
          "value": "XX"
        },
        "areaServed": "US",
        "knowsAbout": [
          "TOPIC_1",
          "TOPIC_2",
          "TOPIC_3"
        ]
      }
      
    • product-ecommerce.json 2.6 KB
      {
        "@context": "https://schema.org",
        "@type": "Product",
        "@id": "https://YOURDOMAIN.com/products/PRODUCT_SLUG/#product",
        "name": "PRODUCT_NAME",
        "url": "https://YOURDOMAIN.com/products/PRODUCT_SLUG",
        "description": "PRODUCT_DESCRIPTION",
        "image": [
          "https://YOURDOMAIN.com/images/products/PRODUCT_1.jpg",
          "https://YOURDOMAIN.com/images/products/PRODUCT_2.jpg",
          "https://YOURDOMAIN.com/images/products/PRODUCT_3.jpg"
        ],
        "brand": {
          "@type": "Brand",
          "name": "YOUR_BRAND_NAME"
        },
        "sku": "YOUR_SKU",
        "gtin13": "YOUR_GTIN",
        "mpn": "YOUR_MPN",
        "category": "PRODUCT_CATEGORY",
        "material": "PRODUCT_MATERIAL",
        "color": "PRODUCT_COLOR",
        "weight": {
          "@type": "QuantitativeValue",
          "value": "X.X",
          "unitCode": "LBR"
        },
        "offers": {
          "@type": "Offer",
          "url": "https://YOURDOMAIN.com/products/PRODUCT_SLUG",
          "price": "XX.XX",
          "priceCurrency": "USD",
          "priceValidUntil": "YYYY-12-31",
          "availability": "https://schema.org/InStock",
          "itemCondition": "https://schema.org/NewCondition",
          "seller": {
            "@type": "Organization",
            "@id": "https://YOURDOMAIN.com/#organization"
          },
          "shippingDetails": {
            "@type": "OfferShippingDetails",
            "shippingRate": {
              "@type": "MonetaryAmount",
              "value": "0",
              "currency": "USD"
            },
            "deliveryTime": {
              "@type": "ShippingDeliveryTime",
              "handlingTime": {
                "@type": "QuantitativeValue",
                "minValue": "0",
                "maxValue": "1",
                "unitCode": "DAY"
              },
              "transitTime": {
                "@type": "QuantitativeValue",
                "minValue": "3",
                "maxValue": "7",
                "unitCode": "DAY"
              }
            },
            "shippingDestination": {
              "@type": "DefinedRegion",
              "addressCountry": "US"
            }
          },
          "hasMerchantReturnPolicy": {
            "@type": "MerchantReturnPolicy",
            "applicableCountry": "US",
            "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
            "merchantReturnDays": "30",
            "returnMethod": "https://schema.org/ReturnByMail",
            "returnFees": "https://schema.org/FreeReturn"
          }
        },
        "aggregateRating": {
          "@type": "AggregateRating",
          "ratingValue": "4.6",
          "reviewCount": "XXX",
          "bestRating": "5",
          "worstRating": "1"
        },
        "review": [
          {
            "@type": "Review",
            "author": {
              "@type": "Person",
              "name": "REVIEWER_NAME"
            },
            "datePublished": "YYYY-MM-DD",
            "reviewRating": {
              "@type": "Rating",
              "ratingValue": "5",
              "bestRating": "5"
            },
            "reviewBody": "REVIEW_TEXT"
          }
        ]
      }
      
    • software-saas.json 1.8 KB
      {
        "@context": "https://schema.org",
        "@type": "SoftwareApplication",
        "@id": "https://YOURDOMAIN.com/#software",
        "name": "YOUR_SOFTWARE_NAME",
        "url": "https://YOURDOMAIN.com",
        "description": "YOUR_SOFTWARE_DESCRIPTION",
        "applicationCategory": "BusinessApplication",
        "operatingSystem": "Web, Windows, macOS, Linux",
        "offers": {
          "@type": "AggregateOffer",
          "lowPrice": "XX",
          "highPrice": "XXX",
          "priceCurrency": "USD",
          "offerCount": "3",
          "offers": [
            {
              "@type": "Offer",
              "name": "Starter Plan",
              "price": "XX",
              "priceCurrency": "USD",
              "priceValidUntil": "YYYY-12-31",
              "availability": "https://schema.org/InStock"
            },
            {
              "@type": "Offer",
              "name": "Professional Plan",
              "price": "XX",
              "priceCurrency": "USD",
              "priceValidUntil": "YYYY-12-31",
              "availability": "https://schema.org/InStock"
            },
            {
              "@type": "Offer",
              "name": "Enterprise Plan",
              "price": "XXX",
              "priceCurrency": "USD",
              "priceValidUntil": "YYYY-12-31",
              "availability": "https://schema.org/InStock"
            }
          ]
        },
        "aggregateRating": {
          "@type": "AggregateRating",
          "ratingValue": "4.7",
          "reviewCount": "XXX",
          "bestRating": "5",
          "worstRating": "1"
        },
        "featureList": [
          "FEATURE_1",
          "FEATURE_2",
          "FEATURE_3",
          "FEATURE_4"
        ],
        "screenshot": "https://YOURDOMAIN.com/images/screenshot.png",
        "softwareVersion": "X.X",
        "author": {
          "@type": "Organization",
          "@id": "https://YOURDOMAIN.com/#organization"
        },
        "sameAs": [
          "https://www.g2.com/products/YOUR_G2",
          "https://www.capterra.com/p/YOUR_CAPTERRA",
          "https://www.producthunt.com/products/YOUR_PH",
          "https://github.com/YOUR_GITHUB"
        ]
      }
      
    • website-searchaction.json 575 B
      {
        "@context": "https://schema.org",
        "@type": "WebSite",
        "@id": "https://YOURDOMAIN.com/#website",
        "name": "YOUR_SITE_NAME",
        "url": "https://YOURDOMAIN.com",
        "description": "YOUR_SITE_DESCRIPTION",
        "publisher": {
          "@type": "Organization",
          "@id": "https://YOURDOMAIN.com/#organization"
        },
        "potentialAction": {
          "@type": "SearchAction",
          "target": {
            "@type": "EntryPoint",
            "urlTemplate": "https://YOURDOMAIN.com/search?q={search_term_string}"
          },
          "query-input": "required name=search_term_string"
        },
        "inLanguage": "en-US"
      }
      
  • scripts
    • webapp
      • templates
        • base.html 4.3 KB · in bundle
        • dashboard.html 4.9 KB · in bundle
        • prospect.html 5.8 KB · in bundle
        • _notes.html 298 B · in bundle
      • app.py 6.7 KB
        #!/usr/bin/env python3
        """
        GEO-SEO CRM — Web UI (Flask + HTMX)
        Usage:
            pip install flask
            python app.py
            open http://localhost:5050
        """
        
        import json
        import os
        from datetime import datetime
        from pathlib import Path
        
        from flask import Flask, render_template, request, redirect, url_for, send_file, abort, jsonify
        
        app = Flask(__name__)
        
        
        @app.context_processor
        def inject_now():
            return {"now": datetime.now().strftime("%Y-%m-%d %H:%M")}
        
        CRM_PATH = Path.home() / ".geo-prospects" / "prospects.json"
        PROPOSALS_DIR = Path.home() / ".geo-prospects" / "proposals"
        AUDITS_DIR = Path.home() / ".geo-prospects" / "audits"
        
        
        # ── Helpers ────────────────────────────────────────────────────────────
        
        def load_prospects() -> list[dict]:
            if not CRM_PATH.exists():
                return []
            with open(CRM_PATH) as f:
                return json.load(f)
        
        def save_prospects(prospects: list[dict]):
            with open(CRM_PATH, "w") as f:
                json.dump(prospects, f, indent=2, ensure_ascii=False)
        
        def score_tier(score: int) -> str:
            if score >= 80: return "good"
            if score >= 60: return "moderate"
            if score >= 40: return "poor"
            return "critical"
        
        def score_label(score: int) -> str:
            if score >= 80: return "Good"
            if score >= 60: return "Moderate"
            if score >= 40: return "Poor"
            return "Critical"
        
        def format_eur(value) -> str:
            if not value:
                return "—"
            return f"€{int(value):,}".replace(",", ".")
        
        def crm_stats(prospects: list[dict]) -> dict:
            total = len(prospects)
            active = [p for p in prospects if p.get("status") == "active"]
            proposals = [p for p in prospects if p.get("status") == "proposal"]
            mrr = sum(p.get("monthly_value", 0) for p in active)
            pipeline = sum(p.get("monthly_value", 0) for p in proposals)
            avg_score = round(sum(p.get("geo_score", 0) for p in prospects) / total) if total else 0
            return {
                "total": total,
                "active": len(active),
                "mrr": format_eur(mrr),
                "pipeline": format_eur(pipeline),
                "avg_score": avg_score,
                "avg_tier": score_tier(avg_score),
            }
        
        def find_pdf(prospect: dict) -> Path | None:
            """Find the PDF file for a prospect."""
            domain = prospect.get("domain", "")
            for f in sorted(PROPOSALS_DIR.glob(f"{domain}*.pdf"), reverse=True):
                return f
            return None
        
        
        # ── Template filters ────────────────────────────────────────────────────
        
        app.jinja_env.filters["score_tier"] = score_tier
        app.jinja_env.filters["score_label"] = score_label
        app.jinja_env.filters["format_eur"] = format_eur
        
        STATUS_META = {
            "lead":     {"icon": "⬜", "badge": "secondary",  "label": "Lead"},
            "audit":    {"icon": "🔍", "badge": "warning",    "label": "Audit"},
            "proposal": {"icon": "📄", "badge": "info",       "label": "Proposal"},
            "active":   {"icon": "✅", "badge": "success",    "label": "Active"},
            "churned":  {"icon": "❌", "badge": "danger",     "label": "Churned"},
            "lost":     {"icon": "💀", "badge": "dark",       "label": "Lost"},
        }
        
        @app.template_filter("status_meta")
        def status_meta_filter(status: str) -> dict:
            return STATUS_META.get(status, {"icon": "?", "badge": "secondary", "label": status})
        
        
        # ── Routes ─────────────────────────────────────────────────────────────
        
        @app.route("/")
        def dashboard():
            prospects = load_prospects()
            status_filter = request.args.get("status", "")
            sort = request.args.get("sort", "score")
        
            filtered = [p for p in prospects if not status_filter or p.get("status") == status_filter]
        
            if sort == "score":
                filtered.sort(key=lambda x: x.get("geo_score", 0))
            elif sort == "company":
                filtered.sort(key=lambda x: x.get("company", "").lower())
            elif sort == "mrr":
                filtered.sort(key=lambda x: x.get("monthly_value", 0), reverse=True)
        
            stats = crm_stats(prospects)
            statuses = list(STATUS_META.keys())
        
            return render_template(
                "dashboard.html",
                prospects=filtered,
                stats=stats,
                status_filter=status_filter,
                sort=sort,
                statuses=statuses,
                STATUS_META=STATUS_META,
            )
        
        
        @app.route("/prospect/<pid>")
        def prospect_detail(pid):
            prospects = load_prospects()
            p = next((x for x in prospects if x.get("id") == pid), None)
            if not p:
                abort(404)
        
            pdf_path = find_pdf(p)
            has_pdf = pdf_path is not None
        
            return render_template(
                "prospect.html",
                p=p,
                has_pdf=has_pdf,
                STATUS_META=STATUS_META,
                statuses=list(STATUS_META.keys()),
            )
        
        
        @app.route("/prospect/<pid>/note", methods=["POST"])
        def add_note(pid):
            """HTMX endpoint — returns updated notes fragment."""
            prospects = load_prospects()
            p = next((x for x in prospects if x.get("id") == pid), None)
            if not p:
                abort(404)
        
            text = request.form.get("text", "").strip()
            if text:
                if "notes" not in p:
                    p["notes"] = []
                p["notes"].append({
                    "date": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
                    "text": text,
                })
                p["updated_at"] = datetime.now().strftime("%Y-%m-%d")
                save_prospects(prospects)
        
            return render_template("_notes.html", p=p)
        
        
        @app.route("/prospect/<pid>/status", methods=["POST"])
        def update_status(pid):
            """HTMX endpoint — update status, returns badge fragment."""
            prospects = load_prospects()
            p = next((x for x in prospects if x.get("id") == pid), None)
            if not p:
                abort(404)
        
            new_status = request.form.get("status", "").strip()
            if new_status in STATUS_META:
                p["status"] = new_status
                p["updated_at"] = datetime.now().strftime("%Y-%m-%d")
                save_prospects(prospects)
        
            meta = STATUS_META.get(p["status"], {})
            return f'<span class="badge bg-{meta["badge"]} fs-6">{meta["icon"]} {meta["label"]}</span>'
        
        
        @app.route("/prospect/<pid>/pdf")
        def download_pdf(pid):
            prospects = load_prospects()
            p = next((x for x in prospects if x.get("id") == pid), None)
            if not p:
                abort(404)
        
            pdf_path = find_pdf(p)
            if not pdf_path:
                abort(404)
        
            return send_file(
                pdf_path,
                as_attachment=True,
                download_name=pdf_path.name,
                mimetype="application/pdf",
            )
        
        
        # ── Run ─────────────────────────────────────────────────────────────────
        
        if __name__ == "__main__":
            app.run(debug=True, port=5050)
        
    • brand_scanner.py 10.9 KB
      #!/usr/bin/env python3
      """
      Brand Mention Scanner — Checks brand presence across AI-cited platforms.
      
      Brand mentions correlate 3x more strongly with AI visibility than backlinks.
      (Ahrefs December 2025 study of 75,000 brands)
      
      Platform importance for AI citations:
      1. YouTube mentions (~0.737 correlation - STRONGEST)
      2. Reddit mentions (high)
      3. Wikipedia presence (high)
      4. LinkedIn presence (moderate)
      5. Domain Rating/backlinks (~0.266 - weak)
      """
      
      import sys
      import json
      import re
      from urllib.parse import quote_plus
      
      try:
          import requests
          from bs4 import BeautifulSoup
      except ImportError:
          print("ERROR: Required packages not installed. Run: pip install -r requirements.txt")
          sys.exit(1)
      
      DEFAULT_HEADERS = {
          "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
          "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
          "Accept-Language": "en-US,en;q=0.9",
      }
      
      
      def check_youtube_presence(brand_name: str) -> dict:
          """Check brand presence on YouTube."""
          result = {
              "platform": "YouTube",
              "correlation": 0.737,
              "weight": "25%",
              "has_channel": False,
              "mentioned_in_videos": False,
              "search_url": f"https://www.youtube.com/results?search_query={quote_plus(brand_name)}",
              "recommendations": [],
          }
      
          # Note: Actual YouTube API would be used in production
          # This provides the framework for Claude Code to use WebFetch
          result["check_instructions"] = [
              f"Search YouTube for '{brand_name}' and check:",
              "1. Does the brand have an official YouTube channel?",
              "2. Are there videos FROM the brand (tutorials, demos, thought leadership)?",
              "3. Are there videos ABOUT the brand from other creators?",
              "4. What's the view count on brand-related videos?",
              "5. Are there positive reviews or demonstrations?",
          ]
      
          result["recommendations"] = [
              "Create a YouTube channel if none exists",
              "Publish educational/tutorial content related to your niche",
              "Encourage customers to create review/demo videos",
              "Optimize video titles and descriptions with brand name",
              "Add timestamps and chapters to improve AI parseability",
              "Include transcripts (YouTube auto-generates, but review for accuracy)",
          ]
      
          return result
      
      
      def check_reddit_presence(brand_name: str) -> dict:
          """Check brand presence on Reddit."""
          result = {
              "platform": "Reddit",
              "correlation": "High",
              "weight": "25%",
              "has_subreddit": False,
              "mentioned_in_discussions": False,
              "search_url": f"https://www.reddit.com/search/?q={quote_plus(brand_name)}",
              "recommendations": [],
          }
      
          result["check_instructions"] = [
              f"Search Reddit for '{brand_name}' and check:",
              "1. Does the brand have its own subreddit (r/brandname)?",
              "2. Is the brand discussed in relevant industry subreddits?",
              "3. What's the sentiment (positive, negative, neutral)?",
              "4. Are there recommendation threads mentioning the brand?",
              "5. Does the brand have an official Reddit presence?",
              "6. Are mentions recent (within last 6 months)?",
          ]
      
          result["recommendations"] = [
              "Monitor relevant subreddits for brand mentions",
              "Participate authentically in industry discussions (no spam)",
              "Create an official Reddit account for customer support",
              "Share valuable content (not just self-promotion)",
              "Respond to questions about your product/service category",
              "Reddit authenticity matters — don't use marketing speak",
          ]
      
          return result
      
      
      def check_wikipedia_presence(brand_name: str) -> dict:
          """Check brand/entity presence on Wikipedia and Wikidata."""
          result = {
              "platform": "Wikipedia",
              "correlation": "High",
              "weight": "20%",
              "has_wikipedia_page": False,
              "has_wikidata_entry": False,
              "cited_in_articles": False,
              "search_url": f"https://en.wikipedia.org/wiki/Special:Search?search={quote_plus(brand_name)}",
              "wikidata_url": f"https://www.wikidata.org/w/index.php?search={quote_plus(brand_name)}",
              "recommendations": [],
          }
      
          # Check Wikipedia API
          try:
              api_url = f"https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={quote_plus(brand_name)}&format=json"
              response = requests.get(api_url, headers=DEFAULT_HEADERS, timeout=15)
              if response.status_code == 200:
                  data = response.json()
                  search_results = data.get("query", {}).get("search", [])
                  if search_results:
                      # Check if top result is about the brand
                      top_title = search_results[0].get("title", "").lower()
                      if brand_name.lower() in top_title:
                          result["has_wikipedia_page"] = True
                      result["wikipedia_search_results"] = len(search_results)
          except Exception:
              pass
      
          # Check Wikidata
          try:
              wikidata_url = f"https://www.wikidata.org/w/api.php?action=wbsearchentities&search={quote_plus(brand_name)}&language=en&format=json"
              response = requests.get(wikidata_url, headers=DEFAULT_HEADERS, timeout=15)
              if response.status_code == 200:
                  data = response.json()
                  entities = data.get("search", [])
                  if entities:
                      result["has_wikidata_entry"] = True
                      result["wikidata_id"] = entities[0].get("id", "")
                      result["wikidata_description"] = entities[0].get("description", "")
          except Exception:
              pass
      
          result["recommendations"] = [
              "If eligible, create a Wikipedia article (requires notability criteria)",
              "Ensure Wikidata entry exists with complete structured data",
              "Add sameAs links in schema markup pointing to Wikipedia/Wikidata",
              "Get cited in existing Wikipedia articles as a source",
              "Build notability through press coverage and independent reviews",
              "Note: Wikipedia has strict notability guidelines — PR coverage helps establish this",
          ]
      
          return result
      
      
      def check_linkedin_presence(brand_name: str) -> dict:
          """Check brand presence on LinkedIn."""
          result = {
              "platform": "LinkedIn",
              "correlation": "Moderate",
              "weight": "15%",
              "has_company_page": False,
              "employee_thought_leadership": False,
              "search_url": f"https://www.linkedin.com/search/results/companies/?keywords={quote_plus(brand_name)}",
              "recommendations": [],
          }
      
          result["check_instructions"] = [
              f"Search LinkedIn for '{brand_name}' and check:",
              "1. Does the company have a LinkedIn page?",
              "2. How many followers?",
              "3. Is the page active with recent posts?",
              "4. Do employees post thought leadership content?",
              "5. Are there LinkedIn articles about the brand?",
              "6. Is there engagement on posts (likes, comments, shares)?",
          ]
      
          result["recommendations"] = [
              "Create/optimize LinkedIn company page",
              "Post regular thought leadership content",
              "Encourage employees to share company content",
              "Publish long-form LinkedIn articles",
              "Engage with industry discussions and comments",
              "Add company LinkedIn URL to schema sameAs property",
          ]
      
          return result
      
      
      def check_other_platforms(brand_name: str) -> dict:
          """Check brand presence on additional platforms."""
          result = {
              "platform": "Other Platforms",
              "weight": "15%",
              "platforms_checked": {},
              "recommendations": [],
          }
      
          platforms = {
              "Quora": f"https://www.quora.com/search?q={quote_plus(brand_name)}",
              "Stack Overflow": f"https://stackoverflow.com/search?q={quote_plus(brand_name)}",
              "GitHub": f"https://github.com/search?q={quote_plus(brand_name)}",
              "Crunchbase": f"https://www.crunchbase.com/textsearch?q={quote_plus(brand_name)}",
              "Product Hunt": f"https://www.producthunt.com/search?q={quote_plus(brand_name)}",
              "G2": f"https://www.g2.com/search?utf8=&query={quote_plus(brand_name)}",
              "Trustpilot": f"https://www.trustpilot.com/search?query={quote_plus(brand_name)}",
          }
      
          result["platforms_checked"] = {
              name: {
                  "search_url": url,
                  "check_instruction": f"Search for '{brand_name}' on {name}",
              }
              for name, url in platforms.items()
          }
      
          result["recommendations"] = [
              "Maintain profiles on industry-relevant platforms",
              "Respond to questions on Quora and Stack Overflow",
              "Encourage customer reviews on G2 and Trustpilot",
              "Keep Crunchbase profile updated (important for B2B)",
              "Open-source contributions on GitHub boost developer brand authority",
              "Product Hunt launch can generate significant initial buzz",
          ]
      
          return result
      
      
      def generate_brand_report(brand_name: str, domain: str = None) -> dict:
          """Generate a comprehensive brand mention report."""
          report = {
              "brand_name": brand_name,
              "domain": domain,
              "analysis_date": "Generated by GEO-SEO Claude Tool",
              "key_insight": "Brand mentions correlate 3x more strongly with AI visibility than backlinks (Ahrefs Dec 2025, 75K brands)",
              "platforms": {},
              "overall_recommendations": [],
          }
      
          # Check all platforms
          report["platforms"]["youtube"] = check_youtube_presence(brand_name)
          report["platforms"]["reddit"] = check_reddit_presence(brand_name)
          report["platforms"]["wikipedia"] = check_wikipedia_presence(brand_name)
          report["platforms"]["linkedin"] = check_linkedin_presence(brand_name)
          report["platforms"]["other"] = check_other_platforms(brand_name)
      
          # Overall recommendations
          report["overall_recommendations"] = [
              "Priority 1: YouTube — highest correlation (0.737) with AI citations. Create educational content.",
              "Priority 2: Reddit — build authentic presence in industry subreddits. No marketing speak.",
              "Priority 3: Wikipedia — establish notability through press coverage, then create/improve entry.",
              "Priority 4: LinkedIn — thought leadership content from founders and employees.",
              "Priority 5: Review platforms — G2, Trustpilot, Capterra for social proof signals.",
              "Cross-platform: Ensure consistent NAP (Name, Address, Phone) across all platforms.",
              "Schema markup: Add sameAs property linking to ALL platform profiles.",
              "Monitor: Set up brand mention alerts across all platforms.",
          ]
      
          return report
      
      
      if __name__ == "__main__":
          if len(sys.argv) < 2:
              print("Usage: python brand_scanner.py <brand_name> [domain]")
              print("Example: python brand_scanner.py 'Acme Corp' acmecorp.com")
              sys.exit(1)
      
          brand = sys.argv[1]
          domain = sys.argv[2] if len(sys.argv) > 2 else None
      
          result = generate_brand_report(brand, domain)
          print(json.dumps(result, indent=2, default=str))
      
    • citability_scorer.py 10.2 KB
      #!/usr/bin/env python3
      """
      Citability Scorer — Analyzes content blocks for AI citation readiness.
      Scores passages based on how likely AI models are to cite them.
      
      Based on research showing optimal AI-cited passages are:
      - 134-167 words long
      - Self-contained (extractable without context)
      - Fact-rich with specific statistics
      - Structured with clear answer patterns
      """
      
      import sys
      import json
      import re
      from typing import Optional
      
      try:
          import requests
          from bs4 import BeautifulSoup
      except ImportError:
          print("ERROR: Required packages not installed. Run: pip install -r requirements.txt")
          sys.exit(1)
      
      
      def score_passage(text: str, heading: Optional[str] = None) -> dict:
          """Score a single passage for AI citability (0-100)."""
          words = text.split()
          word_count = len(words)
      
          scores = {
              "answer_block_quality": 0,
              "self_containment": 0,
              "structural_readability": 0,
              "statistical_density": 0,
              "uniqueness_signals": 0,
          }
      
          # === 1. Answer Block Quality (30%) ===
          abq_score = 0
      
          # Check for definition patterns ("X is...", "X refers to...", "X means...")
          definition_patterns = [
              r"\b\w+\s+is\s+(?:a|an|the)\s",
              r"\b\w+\s+refers?\s+to\s",
              r"\b\w+\s+means?\s",
              r"\b\w+\s+(?:can be |are )?defined\s+as\s",
              r"\bin\s+(?:simple|other)\s+(?:terms|words)\s*,",
          ]
          for pattern in definition_patterns:
              if re.search(pattern, text, re.IGNORECASE):
                  abq_score += 15
                  break
      
          # Check if answer appears early (first 60 words)
          first_60_words = " ".join(words[:60])
          if any(
              re.search(p, first_60_words, re.IGNORECASE)
              for p in [
                  r"\b(?:is|are|was|were|means?|refers?)\b",
                  r"\d+%",
                  r"\$[\d,]+",
                  r"\d+\s+(?:million|billion|thousand)",
              ]
          ):
              abq_score += 15
      
          # Question-based heading bonus
          if heading and heading.endswith("?"):
              abq_score += 10
      
          # Clear, direct sentence structure
          sentences = re.split(r"[.!?]+", text)
          short_clear_sentences = sum(
              1 for s in sentences if 5 <= len(s.split()) <= 25
          )
          if sentences:
              clarity_ratio = short_clear_sentences / len(sentences)
              abq_score += int(clarity_ratio * 10)
      
          # Has specific, quotable claim
          if re.search(
              r"(?:according to|research shows|studies? (?:show|indicate|suggest|found)|data (?:shows|indicates|suggests))",
              text,
              re.IGNORECASE,
          ):
              abq_score += 10
      
          scores["answer_block_quality"] = min(abq_score, 30)
      
          # === 2. Self-Containment (25%) ===
          sc_score = 0
      
          # Optimal word count (134-167 words)
          if 134 <= word_count <= 167:
              sc_score += 10
          elif 100 <= word_count <= 200:
              sc_score += 7
          elif 80 <= word_count <= 250:
              sc_score += 4
          elif word_count < 30 or word_count > 400:
              sc_score += 0
          else:
              sc_score += 2
      
          # Low pronoun density (fewer pronouns = more self-contained)
          pronoun_count = len(
              re.findall(
                  r"\b(?:it|they|them|their|this|that|these|those|he|she|his|her)\b",
                  text,
                  re.IGNORECASE,
              )
          )
          if word_count > 0:
              pronoun_ratio = pronoun_count / word_count
              if pronoun_ratio < 0.02:
                  sc_score += 8
              elif pronoun_ratio < 0.04:
                  sc_score += 5
              elif pronoun_ratio < 0.06:
                  sc_score += 3
      
          # Contains named entities (proper nouns, brands, specific terms)
          proper_nouns = len(re.findall(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b", text))
          if proper_nouns >= 3:
              sc_score += 7
          elif proper_nouns >= 1:
              sc_score += 4
      
          scores["self_containment"] = min(sc_score, 25)
      
          # === 3. Structural Readability (20%) ===
          sr_score = 0
      
          # Sentence count and length distribution
          if sentences:
              avg_sentence_length = word_count / len(sentences)
              if 10 <= avg_sentence_length <= 20:
                  sr_score += 8
              elif 8 <= avg_sentence_length <= 25:
                  sr_score += 5
              else:
                  sr_score += 2
      
          # Contains list-like structures
          if re.search(r"(?:first|second|third|finally|additionally|moreover|furthermore)", text, re.IGNORECASE):
              sr_score += 4
      
          # Contains numbered items or bullet-like content
          if re.search(r"(?:\d+[\.\)]\s|\b(?:step|tip|point)\s+\d+)", text, re.IGNORECASE):
              sr_score += 4
      
          # Paragraph breaks (indicates structure)
          if "\n" in text:
              sr_score += 4
      
          scores["structural_readability"] = min(sr_score, 20)
      
          # === 4. Statistical Density (15%) ===
          sd_score = 0
      
          # Percentages
          pct_count = len(re.findall(r"\d+(?:\.\d+)?%", text))
          sd_score += min(pct_count * 3, 6)
      
          # Dollar amounts
          dollar_count = len(re.findall(r"\$[\d,]+(?:\.\d+)?(?:\s*(?:million|billion|M|B|K))?", text))
          sd_score += min(dollar_count * 3, 5)
      
          # Other numbers with context
          number_count = len(re.findall(r"\b\d+(?:,\d{3})*(?:\.\d+)?\s+(?:users|customers|pages|sites|companies|businesses|people|percent|times|x\b)", text, re.IGNORECASE))
          sd_score += min(number_count * 2, 4)
      
          # Year references (indicates timeliness)
          year_count = len(re.findall(r"\b20(?:2[3-6]|1\d)\b", text))
          if year_count > 0:
              sd_score += 2
      
          # Named sources
          source_patterns = [
              r"(?:according to|per|from|by)\s+[A-Z]",
              r"(?:Gartner|Forrester|McKinsey|Harvard|Stanford|MIT|Google|Microsoft|OpenAI|Anthropic)",
              r"\([A-Z][a-z]+(?:\s+\d{4})?\)",
          ]
          for pattern in source_patterns:
              if re.search(pattern, text):
                  sd_score += 2
      
          scores["statistical_density"] = min(sd_score, 15)
      
          # === 5. Uniqueness Signals (10%) ===
          us_score = 0
      
          # Original data indicators
          if re.search(
              r"(?:our (?:research|study|data|analysis|survey|findings)|we (?:found|discovered|analyzed|surveyed|measured))",
              text,
              re.IGNORECASE,
          ):
              us_score += 5
      
          # Case study or example indicators
          if re.search(
              r"(?:case study|for example|for instance|in practice|real-world|hands-on)",
              text,
              re.IGNORECASE,
          ):
              us_score += 3
      
          # Specific tool/product mentions (shows practical experience)
          if re.search(r"(?:using|with|via|through)\s+[A-Z][a-z]+", text):
              us_score += 2
      
          scores["uniqueness_signals"] = min(us_score, 10)
      
          # === Calculate total ===
          total = sum(scores.values())
      
          # Determine grade
          if total >= 80:
              grade = "A"
              label = "Highly Citable"
          elif total >= 65:
              grade = "B"
              label = "Good Citability"
          elif total >= 50:
              grade = "C"
              label = "Moderate Citability"
          elif total >= 35:
              grade = "D"
              label = "Low Citability"
          else:
              grade = "F"
              label = "Poor Citability"
      
          return {
              "heading": heading,
              "word_count": word_count,
              "total_score": total,
              "grade": grade,
              "label": label,
              "breakdown": scores,
              "preview": " ".join(words[:30]) + ("..." if word_count > 30 else ""),
          }
      
      
      def analyze_page_citability(url: str) -> dict:
          """Analyze all content blocks on a page for citability."""
          try:
              response = requests.get(
                  url,
                  headers={
                      "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
                  },
                  timeout=30,
              )
              response.raise_for_status()
          except Exception as e:
              return {"error": f"Failed to fetch page: {str(e)}"}
      
          soup = BeautifulSoup(response.text, "lxml")
      
          # Remove non-content elements
          for element in soup.find_all(
              ["script", "style", "nav", "footer", "header", "aside", "form"]
          ):
              element.decompose()
      
          # Extract content blocks
          blocks = []
          current_heading = "Introduction"
          current_paragraphs = []
      
          for element in soup.find_all(["h1", "h2", "h3", "h4", "p", "ul", "ol", "table"]):
              if element.name.startswith("h"):
                  # Save previous section
                  if current_paragraphs:
                      combined = " ".join(current_paragraphs)
                      if len(combined.split()) >= 20:
                          blocks.append(
                              {"heading": current_heading, "content": combined}
                          )
                  current_heading = element.get_text(strip=True)
                  current_paragraphs = []
              else:
                  text = element.get_text(strip=True)
                  if text and len(text.split()) >= 5:
                      current_paragraphs.append(text)
      
          # Last block
          if current_paragraphs:
              combined = " ".join(current_paragraphs)
              if len(combined.split()) >= 20:
                  blocks.append({"heading": current_heading, "content": combined})
      
          # Score each block
          scored_blocks = []
          for block in blocks:
              score = score_passage(block["content"], block["heading"])
              scored_blocks.append(score)
      
          # Calculate page-level metrics
          if scored_blocks:
              avg_score = sum(b["total_score"] for b in scored_blocks) / len(scored_blocks)
              top_blocks = sorted(scored_blocks, key=lambda x: x["total_score"], reverse=True)[:5]
              bottom_blocks = sorted(scored_blocks, key=lambda x: x["total_score"])[:5]
      
              # Optimal passage count (134-167 words)
              optimal_count = sum(
                  1 for b in scored_blocks if 134 <= b["word_count"] <= 167
              )
          else:
              avg_score = 0
              top_blocks = []
              bottom_blocks = []
              optimal_count = 0
      
          # Grade distribution
          grade_dist = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
          for block in scored_blocks:
              grade_dist[block["grade"]] += 1
      
          return {
              "url": url,
              "total_blocks_analyzed": len(scored_blocks),
              "average_citability_score": round(avg_score, 1),
              "optimal_length_passages": optimal_count,
              "grade_distribution": grade_dist,
              "top_5_citable": top_blocks,
              "bottom_5_citable": bottom_blocks,
              "all_blocks": scored_blocks,
          }
      
      
      if __name__ == "__main__":
          if len(sys.argv) < 2:
              print("Usage: python citability_scorer.py <url>")
              print("Returns JSON with citability analysis for all content blocks.")
              sys.exit(1)
      
          url = sys.argv[1]
          result = analyze_page_citability(url)
          print(json.dumps(result, indent=2, default=str))
      
    • crm_dashboard.py 11.7 KB
      #!/usr/bin/env python3
      """
      GEO-SEO CRM Dashboard — CLI
      Visualizza il CRM dei prospect con rich.
      
      Usage:
          python crm_dashboard.py                  # Vista principale
          python crm_dashboard.py --prospect PRO-001   # Dettaglio singolo prospect
          python crm_dashboard.py --refresh           # Aggiorna + mostra
      """
      
      import json
      import sys
      import os
      import argparse
      from pathlib import Path
      from datetime import datetime
      
      try:
          from rich.console import Console
          from rich.table import Table
          from rich.panel import Panel
          from rich.columns import Columns
          from rich.text import Text
          from rich.rule import Rule
          from rich.layout import Layout
          from rich.align import Align
          from rich import box
          from rich.progress import Progress, BarColumn, TextColumn
          from rich.padding import Padding
          from rich.style import Style
          from rich.markup import escape
      except ImportError:
          print("ERROR: rich is required. Run: pip install rich")
          sys.exit(1)
      
      # ── Paths ─────────────────────────────────────────────────────────────
      CRM_PATH = Path.home() / ".geo-prospects" / "prospects.json"
      AUDITS_DIR = Path.home() / ".geo-prospects" / "audits"
      PROPOSALS_DIR = Path.home() / ".geo-prospects" / "proposals"
      
      console = Console()
      
      # ── Color helpers ──────────────────────────────────────────────────────
      STATUS_STYLE = {
          "lead":     ("⬜", "dim white",       "grey50"),
          "audit":    ("🔍", "bold yellow",     "yellow"),
          "proposal": ("📄", "bold cyan",       "cyan"),
          "active":   ("✅", "bold green",      "green"),
          "churned":  ("❌", "dim red",         "red"),
          "lost":     ("💀", "dim red",         "red"),
      }
      
      def score_style(score: int) -> tuple[str, str]:
          """Returns (color, label) based on GEO score."""
          if score >= 80:
              return "bold green",  "GOOD"
          elif score >= 60:
              return "bold blue",   "MODERATE"
          elif score >= 40:
              return "bold yellow",  "POOR"
          else:
              return "bold red",    "CRITICAL"
      
      def score_bar(score: int, width: int = 20) -> Text:
          """Renders a colored progress bar for a score."""
          filled = round((score / 100) * width)
          empty = width - filled
          color, _ = score_style(score)
          bar = Text()
          bar.append("█" * filled, style=color)
          bar.append("░" * empty, style="grey30")
          bar.append(f" {score}/100", style=color)
          return bar
      
      def format_eur(value: int | None) -> str:
          if not value:
              return "—"
          return f"€{value:,.0f}".replace(",", ".")
      
      
      # ── Load CRM ───────────────────────────────────────────────────────────
      def load_prospects() -> list[dict]:
          if not CRM_PATH.exists():
              console.print(f"[red]CRM file not found:[/red] {CRM_PATH}")
              return []
          with open(CRM_PATH) as f:
              return json.load(f)
      
      
      # ── Views ──────────────────────────────────────────────────────────────
      def view_summary(prospects: list[dict]):
          """KPI summary cards at top."""
          total = len(prospects)
          active = sum(1 for p in prospects if p.get("status") == "active")
          pipeline = sum(p.get("monthly_value", 0) for p in prospects if p.get("status") == "proposal")
          mrr = sum(p.get("monthly_value", 0) for p in prospects if p.get("status") == "active")
          avg_score = round(sum(p.get("geo_score", 0) for p in prospects) / total) if total else 0
      
          cards = [
              Panel(
                  Align.center(
                      Text.from_markup(
                          f"[bold white]{total}[/bold white]\n[dim]Total Prospects[/dim]"
                      )
                  ),
                  border_style="bright_blue",
                  padding=(1, 3),
              ),
              Panel(
                  Align.center(
                      Text.from_markup(
                          f"[bold green]{active}[/bold green]\n[dim]Active Clients[/dim]"
                      )
                  ),
                  border_style="green",
                  padding=(1, 3),
              ),
              Panel(
                  Align.center(
                      Text.from_markup(
                          f"[bold cyan]{format_eur(mrr)}[/bold cyan]\n[dim]MRR[/dim]"
                      )
                  ),
                  border_style="cyan",
                  padding=(1, 3),
              ),
              Panel(
                  Align.center(
                      Text.from_markup(
                          f"[bold yellow]{format_eur(pipeline)}[/bold yellow]\n[dim]Pipeline (proposals)[/dim]"
                      )
                  ),
                  border_style="yellow",
                  padding=(1, 3),
              ),
              Panel(
                  Align.center(
                      Text.from_markup(
                          f"[bold]{avg_score}[/bold][dim]/100[/dim]\n[dim]Avg GEO Score[/dim]"
                      )
                  ),
                  border_style="magenta",
                  padding=(1, 3),
              ),
          ]
          console.print(Columns(cards, equal=True, expand=True))
      
      
      def view_prospect_table(prospects: list[dict]):
          """Main prospects table."""
          table = Table(
              title=None,
              box=box.ROUNDED,
              border_style="bright_blue",
              header_style="bold bright_white on grey23",
              show_lines=False,
              expand=True,
              padding=(0, 1),
          )
      
          table.add_column("ID",         style="dim", width=9)
          table.add_column("Company",    style="bold white", min_width=16)
          table.add_column("Domain",     style="cyan", min_width=18)
          table.add_column("Status",     justify="center", min_width=12)
          table.add_column("GEO Score",  justify="left", min_width=26)
          table.add_column("Audit",      justify="center", min_width=12)
          table.add_column("MRR",        justify="right", min_width=10)
          table.add_column("Proposal",   justify="center", min_width=10)
      
          for p in sorted(prospects, key=lambda x: x.get("geo_score", 0)):
              pid     = p.get("id", "—")
              company = p.get("company", "—")
              domain  = p.get("domain", "—")
              status  = p.get("status", "lead")
              score   = p.get("geo_score", 0)
              audit   = p.get("audit_date", "—")
              mrr     = format_eur(p.get("monthly_value"))
              has_proposal = "✓" if p.get("proposal_file") else "—"
      
              icon, status_style, _ = STATUS_STYLE.get(status, ("?", "white", "white"))
              status_text = Text(f"{icon} {status.upper()}", style=status_style)
      
              table.add_row(
                  pid,
                  company,
                  domain,
                  status_text,
                  score_bar(score),
                  audit,
                  mrr,
                  has_proposal,
              )
      
          console.print(table)
      
      
      def view_prospect_detail(prospects: list[dict], prospect_id: str):
          """Detailed view of a single prospect."""
          p = next((x for x in prospects if x.get("id") == prospect_id), None)
          if not p:
              console.print(f"[red]Prospect not found:[/red] {prospect_id}")
              return
      
          score = p.get("geo_score", 0)
          color, label = score_style(score)
      
          # Header
          console.print(Rule(f"[bold]{p['company']}[/bold] — {p['domain']}", style="bright_blue"))
          console.print()
      
          # Score + info side by side
          score_panel = Panel(
              Align.center(
                  Text.from_markup(
                      f"\n[{color}]{score}[/{color}]\n[dim]/100[/dim]\n\n[{color}]{label}[/{color}]\n"
                  )
              ),
              title="GEO Score",
              border_style=color.replace("bold ", ""),
              width=20,
          )
      
          info_lines = [
              f"[dim]ID:[/dim]          {p.get('id', '—')}",
              f"[dim]Status:[/dim]      {p.get('status', '—').upper()}",
              f"[dim]Industry:[/dim]    {p.get('industry', '—')}",
              f"[dim]Country:[/dim]     {p.get('country', '—')}",
              f"[dim]Audit Date:[/dim]  {p.get('audit_date', '—')}",
              f"[dim]MRR:[/dim]         {format_eur(p.get('monthly_value'))}",
              f"[dim]Contract:[/dim]    {p.get('contract_months', '—')} months",
          ]
          if p.get("contact_name"):
              info_lines.append(f"[dim]Contact:[/dim]     {p['contact_name']}")
          if p.get("contact_email"):
              info_lines.append(f"[dim]Email:[/dim]       {p['contact_email']}")
      
          info_panel = Panel(
              "\n".join(info_lines),
              title="Details",
              border_style="bright_blue",
          )
      
          console.print(Columns([score_panel, info_panel], expand=False))
          console.print()
      
          # Files
          files = []
          if p.get("audit_file"):
              audit_path = Path(p["audit_file"].replace("~", str(Path.home())))
              exists = "✓" if audit_path.exists() else "✗"
              files.append(f"  {exists} [cyan]Audit:[/cyan]    {p['audit_file']}")
          if p.get("proposal_file"):
              prop_path = Path(p["proposal_file"].replace("~", str(Path.home())))
              exists = "✓" if prop_path.exists() else "✗"
              files.append(f"  {exists} [yellow]Proposal:[/yellow] {p['proposal_file']}")
          if files:
              console.print(Panel("\n".join(files), title="Files", border_style="dim"))
              console.print()
      
          # Notes
          notes = p.get("notes", [])
          if notes:
              note_text = ""
              for note in notes:
                  date = note.get("date", "")[:10]
                  text = escape(note.get("text", ""))
                  note_text += f"[dim]{date}[/dim]  {text}\n\n"
              console.print(Panel(note_text.rstrip(), title="Notes", border_style="dim"))
      
      
      def view_pipeline(prospects: list[dict]):
          """Show pipeline by status."""
          statuses = ["lead", "audit", "proposal", "active", "churned", "lost"]
          console.print()
          console.print(Rule("[bold]Pipeline by Status[/bold]", style="bright_blue"))
          console.print()
      
          for status in statuses:
              group = [p for p in prospects if p.get("status") == status]
              if not group:
                  continue
              icon, style, _ = STATUS_STYLE.get(status, ("?", "white", "white"))
              total_mrr = sum(p.get("monthly_value", 0) for p in group)
              label = f"{icon} [bold]{status.upper()}[/bold] ({len(group)})  {format_eur(total_mrr)}/mo"
              console.print(f"  {label}", style=style)
              for p in group:
                  score = p.get("geo_score", 0)
                  color, _ = score_style(score)
                  console.print(
                      f"    [dim]·[/dim] {p.get('company', '—'):<25} [{color}]{score:>3}/100[/{color}]  [dim]{p.get('domain', '—')}[/dim]"
                  )
              console.print()
      
      
      # ── Main ───────────────────────────────────────────────────────────────
      def main():
          parser = argparse.ArgumentParser(description="GEO-SEO CRM Dashboard")
          parser.add_argument("--prospect", "-p", help="Show detail for a prospect ID")
          parser.add_argument("--pipeline", action="store_true", help="Show pipeline view")
          args = parser.parse_args()
      
          prospects = load_prospects()
          if not prospects:
              return
      
          now = datetime.now().strftime("%Y-%m-%d %H:%M")
      
          console.print()
          console.print(
              Panel.fit(
                  f"[bold bright_white]GEO-SEO CRM[/bold bright_white]  [dim]—  {now}[/dim]",
                  border_style="bright_blue",
                  padding=(0, 2),
              )
          )
          console.print()
      
          if args.prospect:
              view_prospect_detail(prospects, args.prospect)
          elif args.pipeline:
              view_pipeline(prospects)
          else:
              view_summary(prospects)
              console.print()
              view_prospect_table(prospects)
              console.print()
              view_pipeline(prospects)
      
          console.print(
              f"[dim]CRM: {CRM_PATH}   |   /geo audit <domain> to add prospects[/dim]\n"
          )
      
      
      if __name__ == "__main__":
          main()
      
    • fetch_page.py 16.8 KB
      #!/usr/bin/env python3
      """
      Fetch and parse web pages for GEO analysis.
      Extracts HTML, text content, meta tags, headers, and structured data.
      """
      
      import sys
      import json
      import re
      from urllib.parse import urljoin, urlparse
      from typing import Optional
      
      try:
          import requests
          from bs4 import BeautifulSoup
      except ImportError:
          print("ERROR: Required packages not installed. Run: pip install -r requirements.txt")
          sys.exit(1)
      
      # Common AI crawler user agents for testing
      AI_CRAWLERS = {
          "GPTBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)",
          "ClaudeBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +https://www.anthropic.com/claude-bot)",
          "PerplexityBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
          "GoogleBot": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
          "BingBot": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
      }
      
      DEFAULT_HEADERS = {
          "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
          "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
          "Accept-Language": "en-US,en;q=0.9",
          "Accept-Encoding": "gzip, deflate",
      }
      
      
      def fetch_page(url: str, timeout: int = 30) -> dict:
          """Fetch a page and return structured analysis data."""
          result = {
              "url": url,
              "status_code": None,
              "redirect_chain": [],
              "headers": {},
              "meta_tags": {},
              "title": None,
              "description": None,
              "canonical": None,
              "h1_tags": [],
              "heading_structure": [],
              "word_count": 0,
              "text_content": "",
              "internal_links": [],
              "external_links": [],
              "images": [],
              "structured_data": [],
              "has_ssr_content": True,
              "security_headers": {},
              "errors": [],
          }
      
          try:
              response = requests.get(
                  url,
                  headers=DEFAULT_HEADERS,
                  timeout=timeout,
                  allow_redirects=True,
              )
      
              # Track redirects
              if response.history:
                  result["redirect_chain"] = [
                      {"url": r.url, "status": r.status_code} for r in response.history
                  ]
      
              result["status_code"] = response.status_code
              result["headers"] = dict(response.headers)
      
              # Security headers check
              security_headers = [
                  "Strict-Transport-Security",
                  "Content-Security-Policy",
                  "X-Frame-Options",
                  "X-Content-Type-Options",
                  "Referrer-Policy",
                  "Permissions-Policy",
              ]
              for header in security_headers:
                  result["security_headers"][header] = response.headers.get(header, None)
      
              # Parse HTML
              soup = BeautifulSoup(response.text, "lxml")
      
              # Title
              title_tag = soup.find("title")
              result["title"] = title_tag.get_text(strip=True) if title_tag else None
      
              # Meta tags
              for meta in soup.find_all("meta"):
                  name = meta.get("name", meta.get("property", ""))
                  content = meta.get("content", "")
                  if name and content:
                      result["meta_tags"][name.lower()] = content
                      if name.lower() == "description":
                          result["description"] = content
      
              # Canonical
              canonical = soup.find("link", rel="canonical")
              result["canonical"] = canonical.get("href") if canonical else None
      
              # Headings
              for level in range(1, 7):
                  for heading in soup.find_all(f"h{level}"):
                      text = heading.get_text(strip=True)
                      result["heading_structure"].append({"level": level, "text": text})
                      if level == 1:
                          result["h1_tags"].append(text)
      
              # Structured data (JSON-LD) — extract before decompose() mutates the tree
              for script in soup.find_all("script", type="application/ld+json"):
                  try:
                      data = json.loads(script.string)
                      result["structured_data"].append(data)
                  except (json.JSONDecodeError, TypeError):
                      result["errors"].append("Invalid JSON-LD detected")
      
              # SSR check — extract before decompose() removes relevant elements
              noscript_tags = soup.find_all("noscript")
              js_app_roots = soup.find_all(
                  id=re.compile(r"(app|root|__next|__nuxt)", re.I)
              )
      
              # Text content — decompose non-content elements (destructive)
              for element in soup.find_all(["script", "style", "nav", "footer", "header"]):
                  element.decompose()
              text = soup.get_text(separator=" ", strip=True)
              result["text_content"] = text
              result["word_count"] = len(text.split())
      
              # Links
              parsed_url = urlparse(url)
              base_domain = parsed_url.netloc
              for link in soup.find_all("a", href=True):
                  href = urljoin(url, link["href"])
                  link_text = link.get_text(strip=True)
                  parsed_href = urlparse(href)
                  if parsed_href.netloc == base_domain:
                      result["internal_links"].append({"url": href, "text": link_text})
                  elif parsed_href.scheme in ("http", "https"):
                      result["external_links"].append({"url": href, "text": link_text})
      
              # Images
              for img in soup.find_all("img"):
                  img_data = {
                      "src": img.get("src", ""),
                      "alt": img.get("alt", ""),
                      "width": img.get("width"),
                      "height": img.get("height"),
                      "loading": img.get("loading"),
                  }
                  result["images"].append(img_data)
      
              if js_app_roots:
                  # Check if the app root has meaningful content
                  for root in js_app_roots:
                      inner_text = root.get_text(strip=True)
                      if len(inner_text) < 50:
                          result["has_ssr_content"] = False
                          result["errors"].append(
                              f"Possible client-side only rendering detected: #{root.get('id', 'unknown')} has minimal server-rendered content"
                          )
      
          except requests.exceptions.Timeout:
              result["errors"].append(f"Timeout after {timeout} seconds")
          except requests.exceptions.ConnectionError as e:
              result["errors"].append(f"Connection error: {str(e)}")
          except Exception as e:
              result["errors"].append(f"Unexpected error: {str(e)}")
      
          return result
      
      
      def fetch_robots_txt(url: str, timeout: int = 15) -> dict:
          """Fetch and parse robots.txt for AI crawler directives."""
          parsed = urlparse(url)
          robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
      
          ai_crawlers = [
              "GPTBot",
              "OAI-SearchBot",
              "ChatGPT-User",
              "ClaudeBot",
              "anthropic-ai",
              "PerplexityBot",
              "CCBot",
              "Bytespider",
              "cohere-ai",
              "Google-Extended",
              "GoogleOther",
              "Applebot-Extended",
              "FacebookBot",
              "Amazonbot",
          ]
      
          result = {
              "url": robots_url,
              "exists": False,
              "content": "",
              "ai_crawler_status": {},
              "sitemaps": [],
              "errors": [],
          }
      
          try:
              response = requests.get(robots_url, headers=DEFAULT_HEADERS, timeout=timeout)
      
              if response.status_code == 200:
                  result["exists"] = True
                  result["content"] = response.text
      
                  # Parse for each AI crawler
                  lines = response.text.split("\n")
                  current_agent = None
                  agent_rules = {}
      
                  for line in lines:
                      line = line.strip()
                      if line.lower().startswith("user-agent:"):
                          current_agent = line.split(":", 1)[1].strip()
                          if current_agent not in agent_rules:
                              agent_rules[current_agent] = []
                      elif line.lower().startswith("disallow:") and current_agent:
                          path = line.split(":", 1)[1].strip()
                          agent_rules[current_agent].append(
                              {"directive": "Disallow", "path": path}
                          )
                      elif line.lower().startswith("allow:") and current_agent:
                          path = line.split(":", 1)[1].strip()
                          agent_rules[current_agent].append(
                              {"directive": "Allow", "path": path}
                          )
                      elif line.lower().startswith("sitemap:"):
                          sitemap_url = line.split(":", 1)[1].strip()
                          # Handle case where "Sitemap:" splits off the "http"
                          if not sitemap_url.startswith("http"):
                              sitemap_url = "http" + sitemap_url
                          result["sitemaps"].append(sitemap_url)
      
                  # Determine status for each AI crawler
                  for crawler in ai_crawlers:
                      if crawler in agent_rules:
                          rules = agent_rules[crawler]
                          if any(
                              r["directive"] == "Disallow" and r["path"] == "/"
                              for r in rules
                          ):
                              result["ai_crawler_status"][crawler] = "BLOCKED"
                          elif any(
                              r["directive"] == "Disallow" and r["path"] for r in rules
                          ):
                              result["ai_crawler_status"][crawler] = "PARTIALLY_BLOCKED"
                          else:
                              result["ai_crawler_status"][crawler] = "ALLOWED"
                      elif "*" in agent_rules:
                          wildcard_rules = agent_rules["*"]
                          if any(
                              r["directive"] == "Disallow" and r["path"] == "/"
                              for r in wildcard_rules
                          ):
                              result["ai_crawler_status"][crawler] = "BLOCKED_BY_WILDCARD"
                          else:
                              result["ai_crawler_status"][crawler] = "ALLOWED_BY_DEFAULT"
                      else:
                          result["ai_crawler_status"][crawler] = "NOT_MENTIONED"
      
              elif response.status_code == 404:
                  result["errors"].append("No robots.txt found (404)")
                  for crawler in ai_crawlers:
                      result["ai_crawler_status"][crawler] = "NO_ROBOTS_TXT"
              else:
                  result["errors"].append(
                      f"Unexpected status code: {response.status_code}"
                  )
      
          except Exception as e:
              result["errors"].append(f"Error fetching robots.txt: {str(e)}")
      
          return result
      
      
      def fetch_llms_txt(url: str, timeout: int = 15) -> dict:
          """Check for llms.txt file."""
          parsed = urlparse(url)
          llms_url = f"{parsed.scheme}://{parsed.netloc}/llms.txt"
          llms_full_url = f"{parsed.scheme}://{parsed.netloc}/llms-full.txt"
      
          result = {
              "llms_txt": {"url": llms_url, "exists": False, "content": ""},
              "llms_full_txt": {"url": llms_full_url, "exists": False, "content": ""},
              "errors": [],
          }
      
          for key, check_url in [("llms_txt", llms_url), ("llms_full_txt", llms_full_url)]:
              try:
                  response = requests.get(
                      check_url, headers=DEFAULT_HEADERS, timeout=timeout
                  )
                  if response.status_code == 200:
                      result[key]["exists"] = True
                      result[key]["content"] = response.text
              except Exception as e:
                  result["errors"].append(f"Error checking {check_url}: {str(e)}")
      
          return result
      
      
      def extract_content_blocks(html: str) -> list:
          """Extract content blocks for citability analysis."""
          soup = BeautifulSoup(html, "lxml")
      
          # Remove non-content elements
          for element in soup.find_all(
              ["script", "style", "nav", "footer", "header", "aside"]
          ):
              element.decompose()
      
          blocks = []
          # Extract content sections (between headings)
          current_heading = None
          current_content = []
      
          for element in soup.find_all(
              ["h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "table", "blockquote"]
          ):
              tag = element.name
      
              if tag.startswith("h"):
                  # Save previous block
                  if current_content:
                      text = " ".join(current_content)
                      word_count = len(text.split())
                      blocks.append(
                          {
                              "heading": current_heading,
                              "content": text,
                              "word_count": word_count,
                              "tag_types": list(
                                  set(
                                      [
                                          e.name
                                          for e in element.find_all_previous(
                                              ["p", "ul", "ol", "table"]
                                          )
                                      ]
                                  )
                              ),
                          }
                      )
                  current_heading = element.get_text(strip=True)
                  current_content = []
              else:
                  text = element.get_text(strip=True)
                  if text:
                      current_content.append(text)
      
          # Don't forget the last block
          if current_content:
              text = " ".join(current_content)
              blocks.append(
                  {
                      "heading": current_heading,
                      "content": text,
                      "word_count": len(text.split()),
                  }
              )
      
          return blocks
      
      
      def crawl_sitemap(url: str, max_pages: int = 50, timeout: int = 15) -> list:
          """Crawl sitemap.xml to discover pages."""
          parsed = urlparse(url)
          sitemap_urls = [
              f"{parsed.scheme}://{parsed.netloc}/sitemap.xml",
              f"{parsed.scheme}://{parsed.netloc}/sitemap_index.xml",
              f"{parsed.scheme}://{parsed.netloc}/sitemap/",
          ]
      
          discovered_pages = set()
      
          for sitemap_url in sitemap_urls:
              try:
                  response = requests.get(
                      sitemap_url, headers=DEFAULT_HEADERS, timeout=timeout
                  )
                  if response.status_code == 200:
                      soup = BeautifulSoup(response.text, "lxml")
      
                      # Check for sitemap index
                      for sitemap in soup.find_all("sitemap"):
                          loc = sitemap.find("loc")
                          if loc:
                              # Fetch child sitemap
                              try:
                                  child_resp = requests.get(
                                      loc.text.strip(),
                                      headers=DEFAULT_HEADERS,
                                      timeout=timeout,
                                  )
                                  if child_resp.status_code == 200:
                                      child_soup = BeautifulSoup(child_resp.text, "lxml")
                                      for url_tag in child_soup.find_all("url"):
                                          loc_tag = url_tag.find("loc")
                                          if loc_tag:
                                              discovered_pages.add(loc_tag.text.strip())
                                          if len(discovered_pages) >= max_pages:
                                              break
                              except Exception:
                                  pass
                          if len(discovered_pages) >= max_pages:
                              break
      
                      # Direct URL entries
                      for url_tag in soup.find_all("url"):
                          loc = url_tag.find("loc")
                          if loc:
                              discovered_pages.add(loc.text.strip())
                          if len(discovered_pages) >= max_pages:
                              break
      
                      if discovered_pages:
                          break
      
              except Exception:
                  continue
      
          return list(discovered_pages)[:max_pages]
      
      
      if __name__ == "__main__":
          if len(sys.argv) < 2:
              print("Usage: python fetch_page.py <url> [mode]")
              print("Modes: page (default), robots, llms, sitemap, blocks, full")
              sys.exit(1)
      
          target_url = sys.argv[1]
          mode = sys.argv[2] if len(sys.argv) > 2 else "page"
      
          if mode == "page":
              data = fetch_page(target_url)
          elif mode == "robots":
              data = fetch_robots_txt(target_url)
          elif mode == "llms":
              data = fetch_llms_txt(target_url)
          elif mode == "sitemap":
              pages = crawl_sitemap(target_url)
              data = {"pages": pages, "count": len(pages)}
          elif mode == "blocks":
              response = requests.get(target_url, headers=DEFAULT_HEADERS, timeout=30)
              data = extract_content_blocks(response.text)
          elif mode == "full":
              data = {
                  "page": fetch_page(target_url),
                  "robots": fetch_robots_txt(target_url),
                  "llms": fetch_llms_txt(target_url),
                  "sitemap": crawl_sitemap(target_url),
              }
          else:
              print(f"Unknown mode: {mode}")
              sys.exit(1)
      
          print(json.dumps(data, indent=2, default=str))
      
    • generate_pdf_report.py 34.4 KB
      #!/usr/bin/env python3
      """
      GEO-SEO PDF Report Generator
      Generates professional, client-ready PDF reports from GEO audit data.
      
      Usage:
          python generate_pdf_report.py <json_data_file> [output_file.pdf]
      
      The JSON data file should contain the audit results structured as:
      {
          "url": "https://example.com",
          "brand_name": "Example Co",
          "date": "2026-02-18",
          "geo_score": 62,
          "scores": { ... },
          "findings": { ... },
          ...
      }
      
      Or pipe JSON data from stdin:
          cat audit_data.json | python generate_pdf_report.py - output.pdf
      """
      
      import sys
      import json
      import os
      from datetime import datetime
      
      try:
          from reportlab.lib.pagesizes import letter, A4
          from reportlab.lib.units import inch, mm
          from reportlab.lib.colors import (
              HexColor, black, white, grey, lightgrey, darkgrey,
              Color
          )
          from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
          from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY
          from reportlab.platypus import (
              SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
              PageBreak, HRFlowable, KeepTogether, Image as RLImage
          )
          from reportlab.graphics.shapes import Drawing, Rect, String, Circle, Line, Wedge
          from reportlab.graphics.charts.barcharts import VerticalBarChart
          from reportlab.graphics.charts.piecharts import Pie
          from reportlab.graphics import renderPDF
      except ImportError:
          print("ERROR: Required packages not installed. Run: pip install -r requirements.txt")
          sys.exit(1)
      
      
      # ============================================================
      # COLOR PALETTE
      # ============================================================
      PRIMARY = HexColor("#1a1a2e")       # Dark navy
      SECONDARY = HexColor("#16213e")     # Slightly lighter navy
      ACCENT = HexColor("#0f3460")        # Blue accent
      HIGHLIGHT = HexColor("#e94560")     # Red/coral highlight
      SUCCESS = HexColor("#00b894")       # Green
      WARNING = HexColor("#fdcb6e")       # Yellow/amber
      DANGER = HexColor("#d63031")        # Red
      INFO = HexColor("#0984e3")          # Blue
      LIGHT_BG = HexColor("#f8f9fa")      # Light background
      MEDIUM_BG = HexColor("#e9ecef")     # Medium background
      TEXT_PRIMARY = HexColor("#2d3436")   # Dark text
      TEXT_SECONDARY = HexColor("#636e72") # Grey text
      WHITE = white
      BLACK = black
      
      
      def get_score_color(score):
          """Return color based on score value."""
          if score >= 80:
              return SUCCESS
          elif score >= 60:
              return INFO
          elif score >= 40:
              return WARNING
          else:
              return DANGER
      
      
      def get_score_label(score):
          """Return label based on score value."""
          if score >= 85:
              return "Excellent"
          elif score >= 70:
              return "Good"
          elif score >= 55:
              return "Moderate"
          elif score >= 40:
              return "Below Average"
          else:
              return "Needs Attention"
      
      
      def create_score_gauge(score, width=120, height=120):
          """Create a visual score gauge."""
          d = Drawing(width, height)
      
          # Background circle
          d.add(Circle(width/2, height/2, 50, fillColor=LIGHT_BG, strokeColor=lightgrey, strokeWidth=2))
      
          # Score arc (simplified as colored circle)
          color = get_score_color(score)
          d.add(Circle(width/2, height/2, 45, fillColor=color, strokeColor=None))
      
          # Inner white circle
          d.add(Circle(width/2, height/2, 35, fillColor=WHITE, strokeColor=None))
      
          # Score text
          d.add(String(width/2, height/2 + 5, str(score),
                       fontSize=24, fontName='Helvetica-Bold',
                       fillColor=TEXT_PRIMARY, textAnchor='middle'))
      
          # Label
          d.add(String(width/2, height/2 - 12, "/100",
                       fontSize=10, fontName='Helvetica',
                       fillColor=TEXT_SECONDARY, textAnchor='middle'))
      
          return d
      
      
      def create_bar_chart(data, labels, width=400, height=200):
          """Create a horizontal bar chart for scores."""
          d = Drawing(width, height)
      
          chart = VerticalBarChart()
          chart.x = 60
          chart.y = 30
          chart.height = height - 60
          chart.width = width - 80
          chart.data = [data]
          chart.categoryAxis.categoryNames = labels
          chart.categoryAxis.labels.angle = 0
          chart.categoryAxis.labels.fontSize = 8
          chart.categoryAxis.labels.fontName = 'Helvetica'
          chart.valueAxis.valueMin = 0
          chart.valueAxis.valueMax = 100
          chart.valueAxis.valueStep = 20
          chart.valueAxis.labels.fontSize = 8
      
          # Color each bar based on score
          for i, score in enumerate(data):
              chart.bars[0].fillColor = get_score_color(score)
      
          chart.bars[0].strokeColor = None
          chart.bars[0].strokeWidth = 0
      
          d.add(chart)
          return d
      
      
      def create_platform_chart(platforms, width=450, height=180):
          """Create a chart showing platform readiness scores."""
          d = Drawing(width, height)
      
          bar_height = 22
          bar_max_width = 280
          start_y = height - 30
          label_x = 10
      
          for i, (name, score) in enumerate(platforms.items()):
              y = start_y - (i * (bar_height + 10))
      
              # Platform name
              d.add(String(label_x, y + 5, name,
                           fontSize=9, fontName='Helvetica',
                           fillColor=TEXT_PRIMARY, textAnchor='start'))
      
              # Background bar
              bar_x = 130
              d.add(Rect(bar_x, y, bar_max_width, bar_height,
                          fillColor=LIGHT_BG, strokeColor=None))
      
              # Score bar
              bar_width = (score / 100) * bar_max_width
              color = get_score_color(score)
              d.add(Rect(bar_x, y, bar_width, bar_height,
                          fillColor=color, strokeColor=None))
      
              # Score text
              d.add(String(bar_x + bar_max_width + 10, y + 6, f"{score}/100",
                           fontSize=9, fontName='Helvetica-Bold',
                           fillColor=TEXT_PRIMARY, textAnchor='start'))
      
          return d
      
      
      def build_styles():
          """Create custom paragraph styles."""
          styles = getSampleStyleSheet()
      
          styles.add(ParagraphStyle(
              name='ReportTitle',
              fontName='Helvetica-Bold',
              fontSize=28,
              textColor=PRIMARY,
              spaceAfter=6,
              alignment=TA_LEFT,
          ))
      
          styles.add(ParagraphStyle(
              name='ReportSubtitle',
              fontName='Helvetica',
              fontSize=14,
              textColor=TEXT_SECONDARY,
              spaceAfter=20,
              alignment=TA_LEFT,
          ))
      
          styles.add(ParagraphStyle(
              name='SectionHeader',
              fontName='Helvetica-Bold',
              fontSize=18,
              textColor=PRIMARY,
              spaceBefore=20,
              spaceAfter=10,
              alignment=TA_LEFT,
          ))
      
          styles.add(ParagraphStyle(
              name='SubHeader',
              fontName='Helvetica-Bold',
              fontSize=13,
              textColor=ACCENT,
              spaceBefore=14,
              spaceAfter=6,
              alignment=TA_LEFT,
          ))
      
          styles.add(ParagraphStyle(
              name='BodyText_Custom',
              fontName='Helvetica',
              fontSize=10,
              textColor=TEXT_PRIMARY,
              spaceBefore=4,
              spaceAfter=4,
              leading=14,
              alignment=TA_JUSTIFY,
          ))
      
          styles.add(ParagraphStyle(
              name='SmallText',
              fontName='Helvetica',
              fontSize=8,
              textColor=TEXT_SECONDARY,
              spaceBefore=2,
              spaceAfter=2,
          ))
      
          styles.add(ParagraphStyle(
              name='ScoreLabel',
              fontName='Helvetica-Bold',
              fontSize=36,
              textColor=PRIMARY,
              alignment=TA_CENTER,
          ))
      
          styles.add(ParagraphStyle(
              name='HighlightBox',
              fontName='Helvetica',
              fontSize=10,
              textColor=TEXT_PRIMARY,
              backColor=LIGHT_BG,
              borderPadding=10,
              spaceBefore=8,
              spaceAfter=8,
              leading=14,
          ))
      
          styles.add(ParagraphStyle(
              name='CriticalFinding',
              fontName='Helvetica-Bold',
              fontSize=10,
              textColor=DANGER,
              spaceBefore=4,
              spaceAfter=2,
          ))
      
          styles.add(ParagraphStyle(
              name='Recommendation',
              fontName='Helvetica',
              fontSize=10,
              textColor=TEXT_PRIMARY,
              leftIndent=15,
              spaceBefore=3,
              spaceAfter=3,
              bulletIndent=5,
              leading=14,
          ))
      
          styles.add(ParagraphStyle(
              name='Footer',
              fontName='Helvetica',
              fontSize=8,
              textColor=TEXT_SECONDARY,
              alignment=TA_CENTER,
          ))
      
          return styles
      
      
      def header_footer(canvas, doc):
          """Add header and footer to each page."""
          canvas.saveState()
      
          # Header line
          canvas.setStrokeColor(ACCENT)
          canvas.setLineWidth(2)
          canvas.line(50, letter[1] - 40, letter[0] - 50, letter[1] - 40)
      
          # Header text
          canvas.setFont('Helvetica', 8)
          canvas.setFillColor(TEXT_SECONDARY)
          canvas.drawString(50, letter[1] - 35, "GEO-SEO Analysis Report")
      
          # Footer
          canvas.setStrokeColor(lightgrey)
          canvas.setLineWidth(0.5)
          canvas.line(50, 40, letter[0] - 50, 40)
      
          canvas.setFont('Helvetica', 8)
          canvas.setFillColor(TEXT_SECONDARY)
          canvas.drawString(50, 28, f"Generated {datetime.now().strftime('%B %d, %Y')}")
          canvas.drawRightString(letter[0] - 50, 28, f"Page {doc.page}")
          canvas.drawCentredString(letter[0] / 2, 28, "Confidential")
      
          canvas.restoreState()
      
      
      def make_table_style(header_color=PRIMARY):
          """Create a consistent table style."""
          return TableStyle([
              ('BACKGROUND', (0, 0), (-1, 0), header_color),
              ('TEXTCOLOR', (0, 0), (-1, 0), WHITE),
              ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
              ('FONTSIZE', (0, 0), (-1, 0), 9),
              ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
              ('FONTSIZE', (0, 1), (-1, -1), 9),
              ('TEXTCOLOR', (0, 1), (-1, -1), TEXT_PRIMARY),
              ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
              ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
              ('GRID', (0, 0), (-1, -1), 0.5, lightgrey),
              ('BACKGROUND', (0, 1), (-1, -1), WHITE),
              ('ROWBACKGROUNDS', (0, 1), (-1, -1), [WHITE, LIGHT_BG]),
              ('TOPPADDING', (0, 0), (-1, -1), 6),
              ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
              ('LEFTPADDING', (0, 0), (-1, -1), 8),
              ('RIGHTPADDING', (0, 0), (-1, -1), 8),
          ])
      
      
      def generate_report(data, output_path="GEO-REPORT.pdf"):
          """Generate the full PDF report from audit data."""
      
          doc = SimpleDocTemplate(
              output_path,
              pagesize=letter,
              topMargin=55,
              bottomMargin=55,
              leftMargin=50,
              rightMargin=50,
          )
      
          styles = build_styles()
          elements = []
      
          # Extract data with defaults
          url = data.get("url", "https://example.com")
          brand_name = data.get("brand_name", url.replace("https://", "").replace("http://", "").split("/")[0])
          date = data.get("date", datetime.now().strftime("%Y-%m-%d"))
          geo_score = data.get("geo_score", 0)
      
          scores = data.get("scores", {})
          ai_citability = scores.get("ai_citability", 0)
          brand_authority = scores.get("brand_authority", 0)
          content_eeat = scores.get("content_eeat", 0)
          technical = scores.get("technical", 0)
          schema_score = scores.get("schema", 0)
          platform_optimization = scores.get("platform_optimization", 0)
      
          platforms = data.get("platforms", {
              "Google AI Overviews": 0,
              "ChatGPT": 0,
              "Perplexity": 0,
              "Gemini": 0,
              "Bing Copilot": 0,
          })
      
          crawlers = data.get("crawlers", [])
          findings = data.get("findings", [])
          quick_wins = data.get("quick_wins", [])
          medium_term = data.get("medium_term", [])
          strategic = data.get("strategic", [])
          executive_summary = data.get("executive_summary", "")
          crawler_access = data.get("crawler_access", {})
          schema_findings = data.get("schema_findings", {})
          content_findings = data.get("content_findings", {})
          technical_findings = data.get("technical_findings", {})
          brand_findings = data.get("brand_findings", {})
      
          # ============================================================
          # COVER PAGE
          # ============================================================
          elements.append(Spacer(1, 100))
      
          # Title
          elements.append(Paragraph("GEO Analysis Report", styles['ReportTitle']))
          elements.append(Spacer(1, 8))
      
          # Subtitle
          elements.append(Paragraph(
              f"Generative Engine Optimization Audit for <b>{brand_name}</b>",
              styles['ReportSubtitle']
          ))
      
          elements.append(HRFlowable(width="100%", thickness=2, color=ACCENT, spaceAfter=20))
      
          # Key details table
          details_data = [
              ["Website", url],
              ["Analysis Date", datetime.strptime(date, "%Y-%m-%d").strftime("%B %d, %Y") if "-" in date else date],
              ["GEO Score", f"{geo_score}/100 — {get_score_label(geo_score)}"],
          ]
      
          details_table = Table(details_data, colWidths=[120, 350])
          details_table.setStyle(TableStyle([
              ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
              ('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
              ('FONTSIZE', (0, 0), (-1, -1), 11),
              ('TEXTCOLOR', (0, 0), (0, -1), ACCENT),
              ('TEXTCOLOR', (1, 0), (1, -1), TEXT_PRIMARY),
              ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
              ('TOPPADDING', (0, 0), (-1, -1), 10),
              ('LINEBELOW', (0, 0), (-1, -2), 0.5, lightgrey),
          ]))
          elements.append(details_table)
      
          elements.append(Spacer(1, 30))
      
          # Score gauge
          gauge = create_score_gauge(geo_score, 200, 200)
          elements.append(gauge)
      
          elements.append(Spacer(1, 20))
      
          # Score label
          score_color = get_score_color(geo_score)
          elements.append(Paragraph(
              f'<font color="{score_color.hexval()}">{get_score_label(geo_score)}</font>',
              ParagraphStyle('ScoreLabelColored', parent=styles['SectionHeader'],
                             alignment=TA_CENTER, fontSize=20)
          ))
      
          elements.append(PageBreak())
      
          # ============================================================
          # EXECUTIVE SUMMARY
          # ============================================================
          elements.append(Paragraph("Executive Summary", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          if executive_summary:
              elements.append(Paragraph(executive_summary, styles['BodyText_Custom']))
          else:
              elements.append(Paragraph(
                  f"This report presents the findings of a comprehensive Generative Engine Optimization (GEO) "
                  f"audit conducted on <b>{brand_name}</b> ({url}). The analysis evaluated the website's readiness "
                  f"for AI-powered search engines including Google AI Overviews, ChatGPT, Perplexity, Gemini, "
                  f"and Bing Copilot. The overall GEO Readiness Score is <b>{geo_score}/100</b>, "
                  f"placing the site in the <b>{get_score_label(geo_score)}</b> tier.",
                  styles['BodyText_Custom']
              ))
      
          elements.append(Spacer(1, 16))
      
          # ============================================================
          # SCORE BREAKDOWN
          # ============================================================
          elements.append(Paragraph("GEO Score Breakdown", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          score_data = [
              ["Component", "Score", "Weight", "Weighted"],
              ["AI Citability & Visibility", f"{ai_citability}/100", "25%", f"{round(ai_citability * 0.25, 1)}"],
              ["Brand Authority Signals", f"{brand_authority}/100", "20%", f"{round(brand_authority * 0.20, 1)}"],
              ["Content Quality & E-E-A-T", f"{content_eeat}/100", "20%", f"{round(content_eeat * 0.20, 1)}"],
              ["Technical Foundations", f"{technical}/100", "15%", f"{round(technical * 0.15, 1)}"],
              ["Structured Data", f"{schema_score}/100", "10%", f"{round(schema_score * 0.10, 1)}"],
              ["Platform Optimization", f"{platform_optimization}/100", "10%", f"{round(platform_optimization * 0.10, 1)}"],
              ["OVERALL", f"{geo_score}/100", "100%", f"{geo_score}"],
          ]
      
          score_table = Table(score_data, colWidths=[200, 80, 60, 80])
          style = make_table_style()
      
          # Bold the last row
          style.add('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold')
          style.add('BACKGROUND', (0, -1), (-1, -1), MEDIUM_BG)
      
          # Color-code score cells
          for i in range(1, len(score_data) - 1):
              score_val = int(score_data[i][1].split("/")[0])
              color = get_score_color(score_val)
              style.add('TEXTCOLOR', (1, i), (1, i), color)
      
          score_table.setStyle(style)
          elements.append(score_table)
      
          elements.append(Spacer(1, 16))
      
          # Score bar chart
          chart_scores = [ai_citability, brand_authority, content_eeat, technical, schema_score, platform_optimization]
          chart_labels = ["Citability", "Brand", "Content", "Technical", "Schema", "Platform"]
          elements.append(create_bar_chart(chart_scores, chart_labels))
      
          elements.append(PageBreak())
      
          # ============================================================
          # AI PLATFORM READINESS
          # ============================================================
          elements.append(Paragraph("AI Platform Readiness", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          elements.append(Paragraph(
              "These scores reflect how likely your content is to be cited by each AI search platform. "
              "A score below 50 indicates significant barriers to citation on that platform.",
              styles['BodyText_Custom']
          ))
          elements.append(Spacer(1, 10))
      
          # Platform chart
          if platforms:
              elements.append(create_platform_chart(platforms))
      
          elements.append(Spacer(1, 10))
      
          # Platform table
          platform_table_data = [["AI Platform", "Score", "Status"]]
          for name, score in platforms.items():
              status = get_score_label(score)
              platform_table_data.append([name, f"{score}/100", status])
      
          pt = Table(platform_table_data, colWidths=[180, 80, 150])
          pt_style = make_table_style()
          for i in range(1, len(platform_table_data)):
              score_val = int(platform_table_data[i][1].split("/")[0])
              color = get_score_color(score_val)
              pt_style.add('TEXTCOLOR', (1, i), (1, i), color)
          pt.setStyle(pt_style)
          elements.append(pt)
      
          elements.append(PageBreak())
      
          # ============================================================
          # AI CRAWLER ACCESS
          # ============================================================
          elements.append(Paragraph("AI Crawler Access Status", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          elements.append(Paragraph(
              "Blocking AI crawlers prevents AI platforms from citing your content. "
              "The table below shows which AI crawlers can currently access your site.",
              styles['BodyText_Custom']
          ))
          elements.append(Spacer(1, 8))
      
          if crawler_access:
              # Use Paragraph objects for text wrapping in cells
              cell_style = ParagraphStyle(
                  'CrawlerCell', fontName='Helvetica', fontSize=9,
                  textColor=TEXT_PRIMARY, leading=12,
              )
              header_cell_style = ParagraphStyle(
                  'CrawlerHeaderCell', fontName='Helvetica-Bold', fontSize=9,
                  textColor=WHITE, leading=12,
              )
              status_style_allowed = ParagraphStyle(
                  'StatusAllowed', fontName='Helvetica-Bold', fontSize=9,
                  textColor=SUCCESS, leading=12,
              )
              status_style_blocked = ParagraphStyle(
                  'StatusBlocked', fontName='Helvetica-Bold', fontSize=9,
                  textColor=DANGER, leading=12,
              )
              status_style_restricted = ParagraphStyle(
                  'StatusRestricted', fontName='Helvetica-Bold', fontSize=9,
                  textColor=WARNING, leading=12,
              )
              status_style_default = ParagraphStyle(
                  'StatusDefault', fontName='Helvetica', fontSize=9,
                  textColor=TEXT_PRIMARY, leading=12,
              )
      
              crawler_data = [[
                  Paragraph("Crawler", header_cell_style),
                  Paragraph("Platform", header_cell_style),
                  Paragraph("Status", header_cell_style),
                  Paragraph("Recommendation", header_cell_style),
              ]]
              for crawler_name, info in crawler_access.items():
                  if isinstance(info, dict):
                      status_text = info.get("status", "Unknown")
                      status_upper = status_text.upper()
                      if "ALLOW" in status_upper:
                          s_style = status_style_allowed
                      elif "BLOCK" in status_upper:
                          s_style = status_style_blocked
                      elif "RESTRICT" in status_upper:
                          s_style = status_style_restricted
                      else:
                          s_style = status_style_default
      
                      crawler_data.append([
                          Paragraph(crawler_name, cell_style),
                          Paragraph(info.get("platform", ""), cell_style),
                          Paragraph(status_text, s_style),
                          Paragraph(info.get("recommendation", ""), cell_style),
                      ])
                  else:
                      crawler_data.append([
                          Paragraph(crawler_name, cell_style),
                          Paragraph("", cell_style),
                          Paragraph(str(info), cell_style),
                          Paragraph("", cell_style),
                      ])
      
              # Full page width: letter (612pt) - 50pt margins each side = 512pt
              ct = Table(crawler_data, colWidths=[90, 110, 72, 240])
              ct_style = make_table_style()
              ct_style.add('VALIGN', (0, 0), (-1, -1), 'TOP')
      
              ct.setStyle(ct_style)
              elements.append(ct)
          else:
              elements.append(Paragraph(
                  "<i>Run /geo crawlers to populate this section with AI crawler access data.</i>",
                  styles['BodyText_Custom']
              ))
      
          elements.append(PageBreak())
      
          # ============================================================
          # KEY FINDINGS
          # ============================================================
          elements.append(Paragraph("Key Findings", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          if findings:
              for finding in findings:
                  severity = finding.get("severity", "info").upper()
                  title = finding.get("title", "")
                  description = finding.get("description", "")
      
                  if severity == "CRITICAL":
                      sev_color = DANGER
                  elif severity == "HIGH":
                      sev_color = WARNING
                  elif severity == "MEDIUM":
                      sev_color = INFO
                  else:
                      sev_color = TEXT_SECONDARY
      
                  elements.append(Paragraph(
                      f'<font color="{sev_color.hexval()}">[{severity}]</font> <b>{title}</b>',
                      styles['BodyText_Custom']
                  ))
                  if description:
                      elements.append(Paragraph(description, styles['Recommendation']))
                  elements.append(Spacer(1, 4))
          else:
              elements.append(Paragraph(
                  "<i>Run a full /geo audit to populate findings.</i>",
                  styles['BodyText_Custom']
              ))
      
          elements.append(PageBreak())
      
          # ============================================================
          # PRIORITIZED ACTION PLAN
          # ============================================================
          elements.append(Paragraph("Prioritized Action Plan", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          # Quick Wins
          elements.append(Paragraph("Quick Wins (This Week)", styles['SubHeader']))
          elements.append(Paragraph(
              "High impact, low effort — can be implemented immediately.",
              styles['SmallText']
          ))
      
          if quick_wins:
              for i, action in enumerate(quick_wins, 1):
                  if isinstance(action, dict):
                      text = f"<b>{i}.</b> {action.get('action', '')} — <i>{action.get('impact', '')}</i>"
                  else:
                      text = f"<b>{i}.</b> {action}"
                  elements.append(Paragraph(text, styles['Recommendation']))
          else:
              default_wins = [
                  "Allow all Tier 1 AI crawlers in robots.txt (GPTBot, ClaudeBot, PerplexityBot)",
                  "Add publication and last-updated dates to all content pages",
                  "Add author bylines with credentials to blog posts and articles",
                  "Create an llms.txt file to guide AI systems to your key content",
                  "Add sameAs properties to Organization schema linking to all platform profiles",
              ]
              for i, action in enumerate(default_wins, 1):
                  elements.append(Paragraph(f"<b>{i}.</b> {action}", styles['Recommendation']))
      
          elements.append(Spacer(1, 12))
      
          # Medium-Term
          elements.append(Paragraph("Medium-Term Improvements (This Month)", styles['SubHeader']))
          elements.append(Paragraph(
              "Significant impact, moderate effort — requires content or technical changes.",
              styles['SmallText']
          ))
      
          if medium_term:
              for i, action in enumerate(medium_term, 1):
                  if isinstance(action, dict):
                      text = f"<b>{i}.</b> {action.get('action', '')} — <i>{action.get('impact', '')}</i>"
                  else:
                      text = f"<b>{i}.</b> {action}"
                  elements.append(Paragraph(text, styles['Recommendation']))
          else:
              default_medium = [
                  "Restructure top 10 pages with question-based headings and direct answer blocks",
                  "Implement comprehensive Organization + Article + Person schema markup",
                  "Optimize content blocks for AI citability (134-167 word self-contained passages)",
                  "Ensure server-side rendering for all public content pages",
                  "Implement IndexNow protocol for Bing/Copilot indexing speed",
              ]
              for i, action in enumerate(default_medium, 1):
                  elements.append(Paragraph(f"<b>{i}.</b> {action}", styles['Recommendation']))
      
          elements.append(Spacer(1, 12))
      
          # Strategic
          elements.append(Paragraph("Strategic Initiatives (This Quarter)", styles['SubHeader']))
          elements.append(Paragraph(
              "Long-term competitive advantage — requires ongoing investment.",
              styles['SmallText']
          ))
      
          if strategic:
              for i, action in enumerate(strategic, 1):
                  if isinstance(action, dict):
                      text = f"<b>{i}.</b> {action.get('action', '')} — <i>{action.get('impact', '')}</i>"
                  else:
                      text = f"<b>{i}.</b> {action}"
                  elements.append(Paragraph(text, styles['Recommendation']))
          else:
              default_strategic = [
                  "Build Wikipedia/Wikidata entity presence through press coverage and notability",
                  "Develop active Reddit community engagement strategy in relevant subreddits",
                  "Create YouTube content strategy aligned with AI-searched queries",
                  "Establish original research/data publication program for unique citability",
                  "Build topical authority through comprehensive content clusters",
              ]
              for i, action in enumerate(default_strategic, 1):
                  elements.append(Paragraph(f"<b>{i}.</b> {action}", styles['Recommendation']))
      
          elements.append(PageBreak())
      
          # ============================================================
          # METHODOLOGY & GLOSSARY
          # ============================================================
          elements.append(Paragraph("Appendix: Methodology", styles['SectionHeader']))
          elements.append(HRFlowable(width="100%", thickness=1, color=ACCENT, spaceAfter=12))
      
          elements.append(Paragraph(
              f"This GEO audit was conducted on {date} analyzing {url}. "
              "The analysis evaluated the website across six dimensions: AI Citability & Visibility (25%), "
              "Brand Authority Signals (20%), Content Quality & E-E-A-T (20%), Technical Foundations (15%), "
              "Structured Data (10%), and Platform Optimization (10%).",
              styles['BodyText_Custom']
          ))
      
          elements.append(Spacer(1, 8))
      
          elements.append(Paragraph(
              "<b>Platforms assessed:</b> Google AI Overviews, ChatGPT Web Search, Perplexity AI, "
              "Google Gemini, Bing Copilot",
              styles['BodyText_Custom']
          ))
      
          elements.append(Paragraph(
              "<b>Standards referenced:</b> Google Search Quality Rater Guidelines (Dec 2025), "
              "Schema.org specification, Core Web Vitals (2026 thresholds), "
              "llms.txt emerging standard, RSL 1.0 licensing framework",
              styles['BodyText_Custom']
          ))
      
          elements.append(Spacer(1, 16))
      
          # Glossary
          elements.append(Paragraph("Glossary", styles['SubHeader']))
      
          glossary = [
              ["Term", "Definition"],
              ["GEO", "Generative Engine Optimization — optimizing content for AI search citation"],
              ["AIO", "AI Overviews — Google's AI-generated answer boxes in search results"],
              ["E-E-A-T", "Experience, Expertise, Authoritativeness, Trustworthiness"],
              ["SSR", "Server-Side Rendering — generating HTML on the server for crawler access"],
              ["CWV", "Core Web Vitals — Google's page experience metrics (LCP, INP, CLS)"],
              ["INP", "Interaction to Next Paint — responsiveness metric (replaced FID March 2024)"],
              ["JSON-LD", "JavaScript Object Notation for Linked Data — preferred structured data format"],
              ["sameAs", "Schema.org property linking an entity to its profiles on other platforms"],
              ["llms.txt", "Proposed standard file for guiding AI systems about site content"],
              ["IndexNow", "Protocol for instantly notifying search engines of content changes"],
          ]
      
          gt = Table(glossary, colWidths=[80, 380])
          gt.setStyle(make_table_style())
          elements.append(gt)
      
          elements.append(Spacer(1, 30))
      
          # Footer disclaimer
          elements.append(HRFlowable(width="100%", thickness=0.5, color=lightgrey, spaceAfter=8))
          elements.append(Paragraph(
              "This report was generated by the GEO-SEO Claude Code Analysis Tool. "
              "Scores and recommendations are based on automated analysis and industry benchmarks. "
              "Results should be validated with platform-specific testing.",
              styles['SmallText']
          ))
      
          # ============================================================
          # BUILD PDF
          # ============================================================
          doc.build(elements, onFirstPage=header_footer, onLaterPages=header_footer)
          return output_path
      
      
      if __name__ == "__main__":
          if len(sys.argv) < 2:
              # Generate a sample report for demonstration
              sample_data = {
                  "url": "https://example.com",
                  "brand_name": "Example Company",
                  "date": datetime.now().strftime("%Y-%m-%d"),
                  "geo_score": 58,
                  "scores": {
                      "ai_citability": 45,
                      "brand_authority": 62,
                      "content_eeat": 70,
                      "technical": 55,
                      "schema": 30,
                      "platform_optimization": 48,
                  },
                  "platforms": {
                      "Google AI Overviews": 65,
                      "ChatGPT": 52,
                      "Perplexity": 48,
                      "Gemini": 60,
                      "Bing Copilot": 45,
                  },
                  "executive_summary": (
                      "This report presents the findings of a comprehensive GEO audit "
                      "conducted on Example Company (https://example.com). The site achieved "
                      "an overall GEO Readiness Score of 58/100, placing it in the Moderate tier. "
                      "The strongest area is Content Quality (70/100), while Structured Data (30/100) "
                      "represents the biggest opportunity for improvement. Implementing schema markup, "
                      "allowing AI crawlers, and optimizing content structure could increase the score "
                      "to approximately 78/100 within 90 days."
                  ),
                  "findings": [
                      {"severity": "critical", "title": "No Schema Markup Detected",
                       "description": "The site has no JSON-LD structured data, making it difficult for AI models to understand entity relationships."},
                      {"severity": "high", "title": "JavaScript-Only Rendering",
                       "description": "Key content pages use client-side rendering, making them invisible to AI crawlers that don't execute JavaScript."},
                      {"severity": "high", "title": "Missing llms.txt",
                       "description": "No llms.txt file exists to guide AI systems to the most important content."},
                      {"severity": "medium", "title": "Weak Brand Entity Presence",
                       "description": "Brand is not present on Wikipedia or Wikidata, limiting entity recognition by AI models."},
                      {"severity": "medium", "title": "Content Not Optimized for Citability",
                       "description": "Most content blocks are either too short or too long for optimal AI citation (target: 134-167 words)."},
                  ],
                  "quick_wins": [
                      "Allow all Tier 1 AI crawlers in robots.txt",
                      "Add publication dates to all content pages",
                      "Create llms.txt file with key page references",
                      "Add author bylines with credentials",
                      "Fix meta descriptions on top 10 pages",
                  ],
                  "medium_term": [
                      "Implement Organization schema with sameAs linking",
                      "Add Article + Person schema to all blog posts",
                      "Restructure content with question-based H2 headings",
                      "Optimize content blocks for 134-167 word citability",
                      "Implement server-side rendering for content pages",
                  ],
                  "strategic": [
                      "Build Wikipedia/Wikidata entity presence",
                      "Develop Reddit community engagement strategy",
                      "Create YouTube content aligned with AI search queries",
                      "Establish original research publication program",
                      "Build comprehensive topical authority content clusters",
                  ],
                  "crawler_access": {
                      "GPTBot": {"platform": "ChatGPT", "status": "Allowed", "recommendation": "Keep allowed"},
                      "ClaudeBot": {"platform": "Claude", "status": "Allowed", "recommendation": "Keep allowed"},
                      "PerplexityBot": {"platform": "Perplexity", "status": "Blocked", "recommendation": "Unblock for visibility"},
                      "Google-Extended": {"platform": "Gemini", "status": "Allowed", "recommendation": "Keep allowed"},
                      "Bingbot": {"platform": "Bing Copilot", "status": "Allowed", "recommendation": "Keep allowed"},
                  },
              }
      
              output_file = "GEO-REPORT-sample.pdf"
              result = generate_report(sample_data, output_file)
              print(f"Report generated: {result}")
      
          else:
              # Load data from file or stdin
              input_path = sys.argv[1]
              output_file = sys.argv[2] if len(sys.argv) > 2 else "GEO-REPORT.pdf"
      
              if input_path == "-":
                  data = json.loads(sys.stdin.read())
              else:
                  with open(input_path) as f:
                      data = json.load(f)
      
              result = generate_report(data, output_file)
              print(f"Report generated: {result}")
      
    • llmstxt_generator.py 9.9 KB
      #!/usr/bin/env python3
      """
      llms.txt Generator — Creates and validates llms.txt files for AI crawler guidance.
      
      The llms.txt standard is an emerging specification that helps AI crawlers
      understand your site structure and find your most important content.
      
      Location: /llms.txt (root of domain)
      Extended: /llms-full.txt (detailed version)
      """
      
      import sys
      import json
      import re
      from urllib.parse import urljoin, urlparse
      
      try:
          import requests
          from bs4 import BeautifulSoup
      except ImportError:
          print("ERROR: Required packages not installed. Run: pip install -r requirements.txt")
          sys.exit(1)
      
      DEFAULT_HEADERS = {
          "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
          "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
      }
      
      
      def validate_llmstxt(url: str) -> dict:
          """Check if llms.txt exists and validate its format."""
          parsed = urlparse(url)
          base_url = f"{parsed.scheme}://{parsed.netloc}"
          llms_url = f"{base_url}/llms.txt"
          llms_full_url = f"{base_url}/llms-full.txt"
      
          result = {
              "url": llms_url,
              "exists": False,
              "format_valid": False,
              "has_title": False,
              "has_description": False,
              "has_sections": False,
              "has_links": False,
              "section_count": 0,
              "link_count": 0,
              "content": "",
              "issues": [],
              "suggestions": [],
              "full_version": {
                  "url": llms_full_url,
                  "exists": False,
              },
          }
      
          # Check llms.txt
          try:
              response = requests.get(llms_url, headers=DEFAULT_HEADERS, timeout=15)
              if response.status_code == 200:
                  result["exists"] = True
                  result["content"] = response.text
                  content = response.text
      
                  # Validate format
                  lines = content.strip().split("\n")
      
                  # Check for title (# at start)
                  if lines and lines[0].startswith("# "):
                      result["has_title"] = True
                  else:
                      result["issues"].append("Missing title (should start with '# Site Name')")
      
                  # Check for description (> blockquote)
                  for line in lines:
                      if line.startswith("> "):
                          result["has_description"] = True
                          break
                  if not result["has_description"]:
                      result["issues"].append("Missing description (use '> Brief description')")
      
                  # Check for sections (## headings)
                  sections = [l for l in lines if l.startswith("## ")]
                  result["section_count"] = len(sections)
                  result["has_sections"] = len(sections) > 0
                  if not result["has_sections"]:
                      result["issues"].append("No sections found (use '## Section Name')")
      
                  # Check for links
                  link_pattern = r"- \[.+\]\(.+\)"
                  links = re.findall(link_pattern, content)
                  result["link_count"] = len(links)
                  result["has_links"] = len(links) > 0
                  if not result["has_links"]:
                      result["issues"].append("No page links found (use '- [Page Title](url): Description')")
      
                  # Overall format validity
                  result["format_valid"] = (
                      result["has_title"]
                      and result["has_description"]
                      and result["has_sections"]
                      and result["has_links"]
                  )
      
                  # Suggestions
                  if result["link_count"] < 5:
                      result["suggestions"].append("Consider adding more key pages (aim for 10-20)")
                  if result["section_count"] < 2:
                      result["suggestions"].append("Add more sections to organize content types")
                  if "contact" not in content.lower():
                      result["suggestions"].append("Add a Contact section with email and location")
                  if "key fact" not in content.lower() and "about" not in content.lower():
                      result["suggestions"].append("Add key facts about your business/service")
      
              else:
                  result["issues"].append(f"llms.txt returned status {response.status_code}")
          except Exception as e:
              result["issues"].append(f"Error fetching llms.txt: {str(e)}")
      
          # Check llms-full.txt
          try:
              response = requests.get(llms_full_url, headers=DEFAULT_HEADERS, timeout=15)
              if response.status_code == 200:
                  result["full_version"]["exists"] = True
          except Exception:
              pass
      
          return result
      
      
      def generate_llmstxt(url: str, max_pages: int = 30) -> dict:
          """Generate an llms.txt file by crawling the site."""
          parsed = urlparse(url)
          base_url = f"{parsed.scheme}://{parsed.netloc}"
      
          result = {
              "generated_llmstxt": "",
              "generated_llmstxt_full": "",
              "pages_analyzed": 0,
              "sections": {},
          }
      
          # Fetch homepage
          try:
              response = requests.get(url, headers=DEFAULT_HEADERS, timeout=30)
              soup = BeautifulSoup(response.text, "lxml")
          except Exception as e:
              result["error"] = f"Failed to fetch homepage: {str(e)}"
              return result
      
          # Extract site name and description
          title = soup.find("title")
          site_name = title.get_text(strip=True).split("|")[0].split("-")[0].strip() if title else parsed.netloc
          meta_desc = soup.find("meta", attrs={"name": "description"})
          site_description = meta_desc.get("content", "") if meta_desc else f"Official website of {site_name}"
      
          # Discover and categorize pages
          pages = {
              "Main Pages": [],
              "Products & Services": [],
              "Resources & Blog": [],
              "Company": [],
              "Support": [],
          }
      
          # Crawl internal links
          seen_urls = set()
          for link in soup.find_all("a", href=True):
              href = urljoin(base_url, link["href"])
              link_text = link.get_text(strip=True)
      
              if not link_text or len(link_text) < 2:
                  continue
      
              parsed_href = urlparse(href)
              if parsed_href.netloc != parsed.netloc:
                  continue
              if href in seen_urls:
                  continue
              if any(ext in href for ext in [".pdf", ".jpg", ".png", ".gif", ".css", ".js"]):
                  continue
              if "#" in href and href.split("#")[0] in seen_urls:
                  continue
      
              seen_urls.add(href)
              path = parsed_href.path.lower()
      
              # Categorize
              page_entry = {"url": href, "title": link_text}
      
              if any(kw in path for kw in ["/pricing", "/feature", "/product", "/solution", "/demo"]):
                  pages["Products & Services"].append(page_entry)
              elif any(kw in path for kw in ["/blog", "/article", "/resource", "/guide", "/learn", "/docs", "/documentation"]):
                  pages["Resources & Blog"].append(page_entry)
              elif any(kw in path for kw in ["/about", "/team", "/career", "/contact", "/press", "/partner"]):
                  pages["Company"].append(page_entry)
              elif any(kw in path for kw in ["/help", "/support", "/faq", "/status"]):
                  pages["Support"].append(page_entry)
              elif path in ["/", ""] or any(kw in path for kw in ["/home", "/index"]):
                  if href != base_url and href != base_url + "/":
                      pages["Main Pages"].append(page_entry)
              else:
                  pages["Main Pages"].append(page_entry)
      
              if len(seen_urls) >= max_pages:
                  break
      
          result["pages_analyzed"] = len(seen_urls)
      
          # Generate llms.txt (concise version)
          llms_lines = [
              f"# {site_name}",
              f"> {site_description}",
              "",
          ]
      
          for section, section_pages in pages.items():
              if section_pages:
                  llms_lines.append(f"## {section}")
                  # Limit to top 10 per section for concise version
                  for page in section_pages[:10]:
                      llms_lines.append(f"- [{page['title']}]({page['url']})")
                  llms_lines.append("")
      
          # Add contact section placeholder
          llms_lines.extend([
              "## Contact",
              f"- Website: {base_url}",
              f"- Email: contact@{parsed.netloc}",
              "",
          ])
      
          result["generated_llmstxt"] = "\n".join(llms_lines)
      
          # Generate llms-full.txt (detailed version with descriptions)
          full_lines = [
              f"# {site_name}",
              f"> {site_description}",
              "",
          ]
      
          for section, section_pages in pages.items():
              if section_pages:
                  full_lines.append(f"## {section}")
                  for page in section_pages:
                      # Try to fetch page description
                      try:
                          page_resp = requests.get(page["url"], headers=DEFAULT_HEADERS, timeout=10)
                          page_soup = BeautifulSoup(page_resp.text, "lxml")
                          page_meta = page_soup.find("meta", attrs={"name": "description"})
                          page_desc = page_meta.get("content", "") if page_meta else ""
                          if page_desc:
                              full_lines.append(f"- [{page['title']}]({page['url']}): {page_desc}")
                          else:
                              full_lines.append(f"- [{page['title']}]({page['url']})")
                      except Exception:
                          full_lines.append(f"- [{page['title']}]({page['url']})")
                  full_lines.append("")
      
          full_lines.extend([
              "## Contact",
              f"- Website: {base_url}",
              f"- Email: contact@{parsed.netloc}",
              "",
          ])
      
          result["generated_llmstxt_full"] = "\n".join(full_lines)
          result["sections"] = {k: len(v) for k, v in pages.items()}
      
          return result
      
      
      if __name__ == "__main__":
          if len(sys.argv) < 2:
              print("Usage: python llmstxt_generator.py <url> [mode]")
              print("Modes: validate (default), generate")
              sys.exit(1)
      
          target_url = sys.argv[1]
          mode = sys.argv[2] if len(sys.argv) > 2 else "validate"
      
          if mode == "validate":
              data = validate_llmstxt(target_url)
          elif mode == "generate":
              data = generate_llmstxt(target_url)
          else:
              print(f"Unknown mode: {mode}. Use 'validate' or 'generate'.")
              sys.exit(1)
      
          print(json.dumps(data, indent=2, default=str))
      
  • SKILL.md 10.2 KB
    ---
    name: geo
    description: >
      GEO-first SEO analysis tool. Optimizes websites for AI-powered search engines
      (ChatGPT, Claude, Perplexity, Gemini, Google AI Overviews) while maintaining
      traditional SEO foundations. Performs full GEO audits, citability scoring,
      AI crawler analysis, llms.txt generation, brand mention scanning, platform-specific
      optimization, schema markup, technical SEO, content quality (E-E-A-T), and
      client-ready GEO report generation. Use when user says "geo", "seo", "audit",
      "AI search", "AI visibility", "optimize", "citability", "llms.txt", "schema",
      "brand mentions", "GEO report", or any URL for analysis.
    allowed-tools: Read, Grep, Glob, Bash, WebFetch, Write
    ---
    
    # GEO-SEO Analysis Tool — Claude Code Skill (February 2026)
    
    > **Philosophy:** GEO-first, SEO-supported. AI search is eating traditional search.
    > This tool optimizes for where traffic is going, not where it was.
    
    ---
    
    ## Quick Reference
    
    | Command | What It Does |
    |---------|-------------|
    | `/geo audit <url>` | Full GEO + SEO audit with parallel subagents |
    | `/geo page <url>` | Deep single-page GEO analysis |
    | `/geo citability <url>` | Score content for AI citation readiness |
    | `/geo crawlers <url>` | Check AI crawler access (robots.txt analysis) |
    | `/geo llmstxt <url>` | Analyze or generate llms.txt file |
    | `/geo brands <url>` | Scan brand mentions across AI-cited platforms |
    | `/geo platforms <url>` | Platform-specific optimization (ChatGPT, Perplexity, Google AIO) |
    | `/geo schema <url>` | Detect, validate, and generate structured data |
    | `/geo technical <url>` | Traditional technical SEO audit |
    | `/geo content <url>` | Content quality and E-E-A-T assessment |
    | `/geo report <url>` | Generate client-ready GEO deliverable |
    | `/geo report-pdf <url>` | Generate professional PDF report with charts and scores |
    | `/geo quick <url>` | 60-second GEO visibility snapshot |
    | `/geo prospect <cmd>` | CRM-lite: manage prospects through the sales pipeline |
    | `/geo proposal <domain>` | Auto-generate client proposal from audit data |
    | `/geo compare <domain>` | Monthly delta report: show score improvements to client |
    
    ---
    
    ## Market Context (Why GEO Matters)
    
    | Metric | Value | Source |
    |--------|-------|--------|
    | GEO services market (2025) | $850M-$886M | Yahoo Finance / Superlines |
    | Projected GEO market (2031) | $7.3B (34% CAGR) | Industry analysts |
    | AI-referred sessions growth | +527% (Jan-May 2025) | SparkToro |
    | AI traffic conversion vs organic | 4.4x higher | Industry data |
    | Google AI Overviews reach | 1.5B users/month, 200+ countries | Google |
    | ChatGPT weekly active users | 900M+ | OpenAI |
    | Perplexity monthly queries | 500M+ | Perplexity |
    | Gartner: search traffic drop by 2028 | -50% | Gartner |
    | Marketers investing in GEO | Only 23% | Industry surveys |
    | Brand mentions vs backlinks for AI | Branded web mentions r=0.664, YouTube mentions r=0.737; raw backlink counts far weaker (directional only — precise figure has no locatable primary source) | Ahrefs 75K brands (Jul 2026) |
    
    ---
    
    ## Orchestration Logic
    
    ### Full Audit (`/geo audit <url>`)
    
    **Phase 1: Discovery (Sequential)**
    1. Fetch homepage HTML (curl or WebFetch)
    2. Detect business type (SaaS, Local, E-commerce, Publisher, Agency, Other)
    3. Extract key pages from sitemap.xml or internal links (up to 50 pages)
    
    **Phase 2: Parallel Analysis (Delegate to Subagents)**
    Launch these 5 subagents simultaneously:
    
    | Subagent | File | Responsibility |
    |----------|------|---------------|
    | geo-ai-visibility | `agents/geo-ai-visibility.md` | GEO audit, citability, AI crawlers, llms.txt, brand mentions |
    | geo-platform-analysis | `agents/geo-platform-analysis.md` | Platform-specific optimization (ChatGPT, Perplexity, Google AIO) |
    | geo-technical | `agents/geo-technical.md` | Technical SEO, Core Web Vitals, crawlability, indexability |
    | geo-content | `agents/geo-content.md` | Content quality, E-E-A-T, readability, AI content detection |
    | geo-schema | `agents/geo-schema.md` | Schema markup detection, validation, generation |
    
    **Phase 3: Synthesis (Sequential)**
    1. Collect all subagent reports
    2. Calculate composite GEO Score (0-100)
    3. Generate prioritized action plan
    4. Output client-ready report
    
    ### Scoring Methodology
    
    | Category | Weight | Measured By |
    |----------|--------|-------------|
    | AI Citability & Visibility | 25% | Passage scoring, answer block quality, AI crawler access |
    | Brand Authority Signals | 20% | Mentions on Reddit, YouTube, Wikipedia, LinkedIn; entity presence |
    | Content Quality & E-E-A-T | 20% | Expertise signals, original data, author credentials |
    | Technical Foundations | 15% | SSR, Core Web Vitals, crawlability, mobile, security |
    | Structured Data | 10% | Schema completeness, JSON-LD validation, rich result eligibility |
    | Platform Optimization | 10% | Platform-specific readiness (Google AIO, ChatGPT, Perplexity) |
    
    ---
    
    ## Business Type Detection
    
    Analyze homepage for patterns:
    
    | Type | Signals |
    |------|---------|
    | **SaaS** | Pricing page, "Sign up", "Free trial", "/app", "/dashboard", API docs |
    | **Local Service** | Phone number, address, "Near me", Google Maps embed, service area |
    | **E-commerce** | Product pages, cart, "Add to cart", price elements, product schema |
    | **Publisher** | Blog, articles, bylines, publication dates, article schema |
    | **Agency** | Portfolio, case studies, "Our services", client logos, testimonials |
    | **Other** | Default — apply general GEO best practices |
    
    Adjust recommendations based on detected type. Local businesses need LocalBusiness schema and Google Business Profile optimization. SaaS needs SoftwareApplication schema and comparison page strategy. E-commerce needs Product schema and review aggregation.
    
    ---
    
    ## Sub-Skills (10 Specialized Components)
    
    | # | Skill | Directory | Purpose |
    |---|-------|-----------|---------|
    | 1 | geo-audit | `skills/geo-audit/` | Full audit orchestration and scoring |
    | 2 | geo-citability | `skills/geo-citability/` | Passage-level AI citation readiness |
    | 3 | geo-crawlers | `skills/geo-crawlers/` | AI crawler access and robots.txt |
    | 4 | geo-llmstxt | `skills/geo-llmstxt/` | llms.txt facts-hedge analysis and generation (anti-hallucination hygiene, not a visibility lever) |
    | 5 | geo-brand-mentions | `skills/geo-brand-mentions/` | Brand presence on AI-cited platforms |
    | 6 | geo-platform-optimizer | `skills/geo-platform-optimizer/` | Platform-specific AI search optimization |
    | 7 | geo-schema | `skills/geo-schema/` | Structured data for rich results and entity clarity |
    | 8 | geo-technical | `skills/geo-technical/` | Technical SEO foundations |
    | 9 | geo-content | `skills/geo-content/` | Content quality and E-E-A-T |
    | 10 | geo-report | `skills/geo-report/` | Client-ready deliverable generation |
    | 11 | geo-prospect | `skills/geo-prospect/` | CRM-lite prospect and client pipeline management |
    | 12 | geo-proposal | `skills/geo-proposal/` | Auto-generate client proposals from audit data |
    | 13 | geo-compare | `skills/geo-compare/` | Monthly delta tracking and progress reports |
    
    ---
    
    ## Subagents (5 Parallel Workers)
    
    | Agent | File | Skills Used |
    |-------|------|-------------|
    | geo-ai-visibility | `agents/geo-ai-visibility.md` | geo-citability, geo-crawlers, geo-llmstxt, geo-brand-mentions |
    | geo-platform-analysis | `agents/geo-platform-analysis.md` | geo-platform-optimizer |
    | geo-technical | `agents/geo-technical.md` | geo-technical |
    | geo-content | `agents/geo-content.md` | geo-content |
    | geo-schema | `agents/geo-schema.md` | geo-schema |
    
    ---
    
    ## Output Files
    
    All commands generate structured output:
    
    | Command | Output File |
    |---------|------------|
    | `/geo audit` | `GEO-AUDIT-REPORT.md` |
    | `/geo page` | `GEO-PAGE-ANALYSIS.md` |
    | `/geo citability` | `GEO-CITABILITY-SCORE.md` |
    | `/geo crawlers` | `GEO-CRAWLER-ACCESS.md` |
    | `/geo llmstxt` | `llms.txt` (ready to deploy) |
    | `/geo brands` | `GEO-BRAND-MENTIONS.md` |
    | `/geo platforms` | `GEO-PLATFORM-OPTIMIZATION.md` |
    | `/geo schema` | `GEO-SCHEMA-REPORT.md` + generated JSON-LD |
    | `/geo technical` | `GEO-TECHNICAL-AUDIT.md` |
    | `/geo content` | `GEO-CONTENT-ANALYSIS.md` |
    | `/geo report` | `GEO-CLIENT-REPORT.md` (presentation-ready) |
    | `/geo report-pdf` | `GEO-REPORT.pdf` (professional PDF with charts) |
    | `/geo quick` | Inline summary (no file) |
    | `/geo prospect` | Updates `~/.geo-prospects/prospects.json` |
    | `/geo proposal` | `~/.geo-prospects/proposals/<domain>-proposal-<date>.md` |
    | `/geo compare` | `~/.geo-prospects/reports/<domain>-monthly-<YYYY-MM>.md` |
    
    ---
    
    ## PDF Report Generation
    
    The `/geo report-pdf <url>` command generates a professional, branded PDF report:
    
    ### How It Works
    1. Run the full audit or individual analyses first
    2. Collect all scores and findings into a JSON structure
    3. Execute the PDF generator: `python3 ~/.claude/skills/geo/scripts/generate_pdf_report.py data.json GEO-REPORT.pdf`
    
    ### What the PDF Includes
    - **Cover page** with GEO score gauge visualization
    - **Score breakdown** with color-coded bar charts
    - **AI Platform Readiness** dashboard with horizontal bar chart
    - **Crawler Access** status table with color-coded Allow/Block
    - **Key Findings** categorized by severity (Critical/High/Medium/Low)
    - **Prioritized Action Plan** (Quick Wins, Medium-Term, Strategic)
    - **Methodology & Glossary** appendix
    
    ### Workflow
    1. First run `/geo audit <url>` to collect all data
    2. Then run `/geo report-pdf <url>` to generate the PDF
    3. The tool will compile audit data into JSON, then generate the PDF
    4. Output: `GEO-REPORT.pdf` in the current directory
    
    ---
    
    ## Quality Gates
    
    - **Crawl limit:** Max 50 pages per audit (focus on quality over quantity)
    - **Timeout:** 30 seconds per page fetch
    - **Rate limiting:** 1-second delay between requests, max 5 concurrent
    - **Robots.txt:** Always respect, always check
    - **Duplicate detection:** Skip pages with >80% content similarity
    
    ---
    
    ## Quick Start Examples
    
    ```
    # Full GEO audit of a website
    /geo audit https://example.com
    
    # Check if AI bots can see your site
    /geo crawlers https://example.com
    
    # Score a specific page for AI citability
    /geo citability https://example.com/blog/best-article
    
    # Generate an llms.txt file for your site
    /geo llmstxt https://example.com
    
    # Get a 60-second visibility snapshot
    /geo quick https://example.com
    
    # Generate a client-ready report
    /geo report https://example.com
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related