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
Install
npx skills add https://github.com/TheSmokeDev/geo-skills/tree/main/skills/geo
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install thesmokedev-geo-skills@llmmart
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 | |
| 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)
- Fetch homepage HTML (curl or WebFetch)
- Detect business type (SaaS, Local, E-commerce, Publisher, Agency, Other)
- 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)
- Collect all subagent reports
- Calculate composite GEO Score (0-100)
- Generate prioritized action plan
- 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
- Run the full audit or individual analyses first
- Collect all scores and findings into a JSON structure
- 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
- First run
/geo audit <url>to collect all data - Then run
/geo report-pdf <url>to generate the PDF - The tool will compile audit data into JSON, then generate the PDF
- Output:
GEO-REPORT.pdfin 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.
Reviews (0)
No reviews yet.
No comments yet.