agents/openai.yaml
interface:
display_name: "Seo"
short_description: "Help with Seo tasks and workflows"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
default_prompt: "Use $seo to handle this SEO task and provide an actionable report with prioritized fixes."
assets/icon.svg
<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="color">
<ellipse cx="29.5854" cy="24.8305" rx="11.1656" ry="11.1657" transform="matrix(0.8006 -0.5992 0.5992 0.8006 -8.979 22.6777)" fill="#FFFFFF" stroke="none"/>
<path fill="#92D3F5" stroke="none" d="M39,19c-1.6269-2.1743-5.3114-4.6131-8-5c-1.841-0.2648-5,0-5.3126,1.4508 c2.432,0.2232,6.261,1.3933,9.4483,6.0391c2.7047,3.9427,2.9098,7.2606,2.5092,9.5215C40,31,41.7867,22.7243,39,19z"/>
<path fill="#D0CFCE" stroke="none" d="M20.717,13.2532c-3.1395,2.3517-5.1766,5.7832-5.7372,9.6663c-0.5584,3.883,0.4286,7.7514,2.7785,10.8927 c4.8528,6.4827,14.0758,7.8101,20.5595,2.9577c6.4832-4.8514,7.8112-14.073,2.9587-20.5591 c-2.3504-3.1402-5.7839-5.1778-9.6668-5.7362C27.7267,9.9161,23.8587,10.9022,20.717,13.2532z M38.1933,18.3883 c2.2813,3.0482,2.696,6.924,1.4434,10.248c-0.6965,1.8514-1.9113,3.5306-3.6095,4.8016c-4.7463,3.5525-11.498,2.58-15.0496-2.166 c-1.7206-2.2992-2.4437-5.1309-2.0341-7.9731c0.41-2.8429,1.8999-5.3554,4.2002-7.0765c2.2984-1.721,5.1298-2.4433,7.9731-2.0341 C33.9592,14.5969,36.4719,16.0889,38.1933,18.3883z"/>
<path fill="#3F3F3F" stroke="none" d="M39,42l10,14c0,0,3,4,5,2s0-5,0-5L43,39L39,42z"/>
</g>
<g id="hair"/>
<g id="skin"/>
<g id="skin-shadow"/>
<g id="line">
<ellipse cx="29.5854" cy="24.8305" rx="14.6372" ry="14.6372" transform="matrix(0.8006 -0.5992 0.5992 0.8006 -8.979 22.6778)" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
<ellipse cx="29.5854" cy="24.8305" rx="11.1656" ry="11.1657" transform="matrix(0.8006 -0.5992 0.5992 0.8006 -8.979 22.6777)" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M38.6805,41.7876l4.1839-3.1316l11.6927,15.622c0.8354,1.1162,0.5758,2.7219-0.5795,3.5867l0,0 c-1.1554,0.8647-2.7691,0.661-3.6045-0.4551L38.6805,41.7876z"/>
</g>
</svg>
assets/templates.json
{
"templates": [
{
"type": "VideoObject",
"description": "Video content pages with thumbnails, duration, and playback URLs. Enables video rich results in Google Search.",
"template": {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "[Video Title]",
"description": "[Video Description]",
"thumbnailUrl": "[Thumbnail Image URL]",
"uploadDate": "[YYYY-MM-DD]",
"duration": "[ISO 8601 Duration, e.g. PT1H30M]",
"contentUrl": "[Direct Video File URL]",
"embedUrl": "[Embed Player URL]",
"publisher": {
"@type": "Organization",
"name": "[Publisher Name]",
"logo": {
"@type": "ImageObject",
"url": "[Logo URL]"
}
}
}
},
{
"type": "BroadcastEvent",
"description": "Live streaming content for LIVE badge in Google Search results. Requires VideoObject and isLiveBroadcast flag.",
"template": {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "[Live Stream Title]",
"description": "[Live Stream Description]",
"thumbnailUrl": "[Thumbnail Image URL]",
"uploadDate": "[YYYY-MM-DD]",
"contentUrl": "[Stream URL]",
"embedUrl": "[Embed Player URL]",
"publication": {
"@type": "BroadcastEvent",
"isLiveBroadcast": true,
"startDate": "[YYYY-MM-DDTHH:MM:SSZ]",
"endDate": "[YYYY-MM-DDTHH:MM:SSZ]"
}
}
},
{
"type": "Clip",
"description": "Key moments or chapters within a video. Enables key moments rich results with timestamp links.",
"template": {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "[Video Title]",
"description": "[Video Description]",
"thumbnailUrl": "[Thumbnail Image URL]",
"uploadDate": "[YYYY-MM-DD]",
"contentUrl": "[Direct Video File URL]",
"hasPart": [
{
"@type": "Clip",
"name": "[Clip Title]",
"startOffset": 0,
"endOffset": 120,
"url": "[Video URL with timestamp, e.g. ?t=0]"
},
{
"@type": "Clip",
"name": "[Next Clip Title]",
"startOffset": 120,
"endOffset": 300,
"url": "[Video URL with timestamp, e.g. ?t=120]"
}
]
}
},
{
"type": "SeekToAction",
"description": "Enable seek functionality in video rich results. Allows users to jump to specific timestamps from search.",
"template": {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "[Video Title]",
"description": "[Video Description]",
"thumbnailUrl": "[Thumbnail Image URL]",
"uploadDate": "[YYYY-MM-DD]",
"contentUrl": "[Direct Video File URL]",
"potentialAction": {
"@type": "SeekToAction",
"target": "[Video URL]?t={seek_to_second_number}",
"startOffset-input": "required name=seek_to_second_number"
}
}
},
{
"type": "SoftwareSourceCode",
"description": "Open source and code repository pages. Describes programming language, platform, and repository location.",
"template": {
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
"name": "[Repository Name]",
"description": "[Repository Description]",
"codeRepository": "[Repository URL, e.g. https://github.com/org/repo]",
"programmingLanguage": "[Language, e.g. Python]",
"runtimePlatform": "[Platform, e.g. Node.js]",
"author": {
"@type": "Person",
"name": "[Author Name]"
},
"license": "[License URL, e.g. https://opensource.org/licenses/MIT]",
"dateCreated": "[YYYY-MM-DD]",
"dateModified": "[YYYY-MM-DD]"
}
},
{
"type": "ProductGroup",
"description": "E-commerce product variants grouped by attributes like size, color. Enables variant-aware rich results with variesBy and hasVariant properties.",
"template": {
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": "[Product Name]",
"description": "[Product group description]",
"productGroupID": "[product-group-id]",
"variesBy": ["https://schema.org/size", "https://schema.org/color"],
"hasVariant": [
{
"@type": "Product",
"name": "[Variant - Red, Large]",
"sku": "[SKU-001]",
"color": "[Red]",
"size": "[Large]",
"offers": {
"@type": "Offer",
"price": "[29.99]",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
]
}
},
{
"type": "ProfilePage",
"description": "Author, creator, or team member profile pages. Enhances E-E-A-T signals with mainEntity Person markup and sameAs links.",
"template": {
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@type": "Person",
"name": "[Author Name]",
"url": "[Profile URL]",
"description": "[Author bio and expertise summary]",
"sameAs": [
"[Twitter URL]",
"[LinkedIn URL]"
]
}
}
},
{
"type": "Certification",
"description": "Product certifications (Energy Star, safety, organic, etc.). Replaced EnergyConsumptionDetails in April 2025.",
"template": {
"@context": "https://schema.org",
"@type": "Product",
"name": "[Product Name]",
"hasCertification": {
"@type": "Certification",
"certificationIdentification": "[Certification Name, e.g. Energy Star]",
"issuedBy": {
"@type": "Organization",
"name": "[Issuing Organization, e.g. EPA]"
}
}
}
},
{
"type": "OfferShippingDetails",
"description": "Shipping and delivery information for e-commerce products. Includes shipping rate, handling time, and transit time.",
"template": {
"@context": "https://schema.org",
"@type": "Product",
"name": "[Product Name]",
"offers": {
"@type": "Offer",
"price": "[Price]",
"priceCurrency": "USD",
"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": 1,
"maxValue": 5,
"unitCode": "DAY"
}
}
}
}
}
}
]
}
references/cwv-thresholds.md
<!-- Updated: 2026-02-07 -->
# Core Web Vitals Thresholds (February 2026)
## Current Metrics
| Metric | Good | Needs Improvement | Poor |
|--------|------|-------------------|------|
| LCP (Largest Contentful Paint) | ≤2.5s | 2.5s–4.0s | >4.0s |
| INP (Interaction to Next Paint) | ≤200ms | 200ms–500ms | >500ms |
| CLS (Cumulative Layout Shift) | ≤0.1 | 0.1–0.25 | >0.25 |
## Key Facts
- INP replaced FID (First Input Delay) on **March 12, 2024**. FID was fully removed from all Chrome tools (CrUX API, PageSpeed Insights, Lighthouse) on **September 9, 2024**. INP is the sole interactivity metric.
- Evaluation uses the **75th percentile** of real user data (field data from CrUX).
- Google assesses at the **page level** and the **origin level**.
- Core Web Vitals are a **tiebreaker** ranking signal — they matter most when content quality is similar between competitors.
- **Thresholds unchanged since original definitions** — ignore claims of "tightened thresholds" from SEO blogs.
- December 2025 core update appeared to weight **mobile CWV more heavily**.
- As of October 2025: **57.1%** desktop sites and **49.7%** mobile sites pass all three CWV.
## LCP Subparts (February 2025 CrUX Addition)
LCP can now be broken into diagnostic subparts:
| Subpart | What It Measures | Target |
|---------|------------------|--------|
| **TTFB** | Time to First Byte (server response) | <800ms |
| **Resource Load Delay** | Time from TTFB to resource request start | Minimize |
| **Resource Load Time** | Time to download the LCP resource | Depends on size |
| **Element Render Delay** | Time from resource loaded to rendered | Minimize |
**Total LCP = TTFB + Resource Load Delay + Resource Load Time + Element Render Delay**
Use this breakdown to identify which phase is causing LCP issues.
## Soft Navigations API (Experimental)
**Chrome 139+ Origin Trial (July 2025)** — First step toward measuring CWV in SPAs.
- Addresses the long-standing SPA measurement blind spot
- Currently experimental, **no ranking impact yet**
- Detects "soft navigations" (URL changes without full page load)
- May affect future SPA CWV measurement
**Detection:** Check for SPA frameworks (React, Vue, Angular, Svelte) and warn about current CWV measurement limitations.
## Measurement Sources
### Field Data (Real Users)
- Chrome User Experience Report (CrUX)
- PageSpeed Insights (uses CrUX data)
- Search Console Core Web Vitals report
### Lab Data (Simulated)
- Lighthouse
- WebPageTest
- Chrome DevTools
> Field data is what Google uses for ranking. Lab data is useful for debugging.
## Common Bottlenecks
### LCP (Largest Contentful Paint)
- Unoptimized hero images (compress, use WebP/AVIF, add preload)
- Render-blocking CSS/JS (defer, async, critical CSS inlining)
- Slow server response (TTFB >200ms — use edge CDN, caching)
- Third-party script blocking (defer analytics, chat widgets)
- Web font loading delay (use font-display: swap + preload)
### INP (Interaction to Next Paint)
- Long JavaScript tasks on main thread (break into smaller tasks <50ms)
- Heavy event handlers (debounce, use requestAnimationFrame)
- Excessive DOM size (>1,500 elements is concerning)
- Third-party scripts hijacking main thread
- Synchronous XHR or localStorage operations
- Layout thrashing (multiple forced reflows)
### CLS (Cumulative Layout Shift)
- Images/iframes without width/height dimensions
- Dynamically injected content above existing content
- Web fonts causing layout shift (use font-display: swap + preload)
- Ads/embeds without reserved space
- Late-loading content pushing down the page
## Optimization Priority
1. **LCP** — Most impactful for perceived performance
2. **CLS** — Most common issue affecting user experience
3. **INP** — Matters most for interactive applications
## Tools
```bash
# PageSpeed Insights API
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=URL&key=API_KEY"
# Lighthouse CLI
npx lighthouse URL --output json --output-path report.json
```
## Performance Tooling Updates (2025)
- **Lighthouse 13.0** (October 2025): Major audit restructuring with reorganized performance categories and updated scoring weights. Lighthouse is a lab tool (simulated conditions) — always cross-reference with CrUX field data for real-world performance.
- **CrUX Vis** replaced the CrUX Dashboard (November 2025). The old Looker Studio dashboard was deprecated. Use [CrUX Vis](https://cruxvis.withgoogle.com) or the CrUX API directly.
- **LCP subparts** added to CrUX (February 2025): Time to First Byte (TTFB), resource load delay, resource load time, and element render delay are now available as sub-components of LCP in CrUX data.
- **Google Search Console 2025 features** (December 2025): AI-powered configuration for automated analysis. Branded vs. non-branded queries filter. Hourly data available in API. Custom chart annotations. Social channels tracking.
> **Mobile-first indexing** is 100% complete as of July 5, 2024. Google now crawls and indexes ALL websites exclusively with the mobile Googlebot user-agent. Ensure your mobile version contains all critical content, structured data, and meta tags.
references/eeat-framework.md
# E-E-A-T Evaluation Framework
## Updated per Google Quality Rater Guidelines — September 11, 2025
## Plus December 2025 Core Update Implications
## Overview
E-E-A-T = **E**xperience, **E**xpertise, **A**uthoritativeness, **T**rustworthiness
Trustworthiness is the most important factor. It is assessed based on the
other three signals plus direct trust indicators.
## CRITICAL: December 2025 Core Update
> **E-E-A-T now applies to ALL competitive queries, not just YMYL.**
The December 2025 core update was described as a "watershed moment" that:
- Extended E-E-A-T evaluation to virtually all competitive queries
- Made author attribution standards tighter across all categories
- Penalized anonymous or generic authorship even for non-YMYL content
- Significantly improved AI content quality detection
**Impact by industry:**
| Industry | Traffic Drops |
|----------|--------------|
| Affiliate sites | 71% average decline |
| Health/YMYL | 67% average decline |
| E-commerce | 52% average decline |
**Key takeaway:** Even entertainment and lifestyle content now requires demonstrated expertise. Generic content no longer ranks.
## YMYL (Your Money or Your Life)
Topics requiring **highest** E-E-A-T standards (but E-E-A-T now matters everywhere):
- Health and safety
- Financial advice and transactions
- Legal information
- News and current events
- **Elections and civic trust** (added Sept 2025)
- **Democratic processes** (added Sept 2025)
- Groups of people (potential for harm)
---
## Experience (Weight: 20%)
First-hand knowledge and personal involvement with the topic.
### Signals to Check
- [ ] Author has demonstrable first-hand experience with the topic
- [ ] Content includes original photos, screenshots, or data
- [ ] Case studies or real-world examples with specific details
- [ ] Personal process documentation or methodology descriptions
- [ ] Before/after results or outcome data
- [ ] Specific anecdotes that couldn't be fabricated
### Scoring
- **Strong**: Multiple first-hand experience signals, original content
- **Moderate**: Some personal experience evident
- **Weak**: Generic information, no personal touch
- **None**: Clearly AI-generated or scraped content
---
## Expertise (Weight: 25%)
Formal qualifications, training, and demonstrated knowledge.
### Signals to Check
- [ ] Author credentials relevant to topic (bio, certifications)
- [ ] Technical accuracy and depth appropriate for audience
- [ ] Claims supported by evidence or sources
- [ ] Specialized vocabulary used correctly
- [ ] Up-to-date with current developments in the field
- [ ] Byline with author name and credentials visible
### Scoring
- **Strong**: Verified credentials, deep technical accuracy
- **Moderate**: Demonstrable knowledge, some credentials
- **Weak**: Surface-level information, no credentials
- **None**: Factual errors, misinformation
---
## Authoritativeness (Weight: 25%)
Recognition by others as a go-to source.
### Signals to Check
- [ ] Site recognized as authority in its niche
- [ ] Author recognized as expert (external citations, speaking, publications)
- [ ] Content cited by other authoritative sources
- [ ] Industry awards, certifications, or accreditations
- [ ] Consistent publication history in the topic area
- [ ] Featured in reputable media outlets
- [ ] Professional affiliations
### Scoring
- **Strong**: Widely recognized authority, cited by others
- **Moderate**: Growing recognition, some external validation
- **Weak**: No external recognition
- **None**: Negative reputation, known for misinformation
---
## Trustworthiness (Weight: 30%)
The most important factor — overall reliability and transparency.
### Signals to Check
- [ ] Clear contact information (physical address, phone, email)
- [ ] Privacy policy and terms of service
- [ ] HTTPS with valid certificate
- [ ] Transparent about who creates content and why
- [ ] Customer reviews and testimonials
- [ ] Corrections and update history visible
- [ ] No deceptive practices (hidden ads, clickbait)
- [ ] Secure payment processing (for e-commerce)
- [ ] Return/refund policy visible
### Scoring
- **Strong**: Full transparency, verified business, positive reputation
- **Moderate**: Good trust signals, minor gaps
- **Weak**: Missing key trust signals
- **None**: Deceptive practices, scam indicators
---
## September 2025 QRG Updates
### AI Content Assessment
Raters now formally evaluate whether content appears AI-generated:
- AI content is **acceptable** if it demonstrates genuine E-E-A-T
- Low-quality AI content (generic, no unique value) is penalized
- The presence of AI-generated content is not inherently penalizing
- What matters: does the content provide unique value regardless of creation method?
### Markers of Low-Quality AI Content
- Generic phrasing without specificity
- Lack of original insight or unique perspective
- No first-hand experience signals
- Factual inaccuracies
- Repetitive structure across multiple pages
- No author attribution or expertise signals
### New Spam Categories
- **Expired domain abuse**: Buying expired domains for their backlinks
- **Site reputation abuse**: Using reputable site to host low-quality content
- **Scaled content abuse**: Mass-producing content without value
### AI Overview Evaluation
Raters assess quality of AI-generated summaries in search results.
### RSL 1.0 (Really Simple Licensing)
New machine-readable content licensing standard (December 2025) for AI training:
- Backed by: Reddit, Yahoo, Medium, Quora, Cloudflare, Akamai, Creative Commons
- Allows publishers to specify AI licensing terms
- Augments robots.txt for AI-specific permissions
---
## Experience Signals Are Critical Differentiators
The December 2025 update elevated the "Experience" dimension as a key differentiator:
- First-person narrative ("I tested this...", "In my experience...")
- Original photos and screenshots (not stock images)
- Specific examples with verifiable details
- Process documentation showing actual work done
**Why:** AI can generate expertise-sounding content but cannot fabricate genuine experience.
---
## Overall Scoring Guide
| Score | Description |
|-------|-------------|
| 90-100 | Exceptional E-E-A-T — authority site, recognized expert, full transparency |
| 70-89 | Strong E-E-A-T — demonstrated expertise, good trust signals |
| 50-69 | Moderate E-E-A-T — some signals, room for improvement |
| 30-49 | Weak E-E-A-T — minimal signals, significant gaps |
| 0-29 | Very low E-E-A-T — no visible signals, potential trust issues |
---
## Improvement Recommendations by Score
### 0-29 (Critical)
1. Add contact information and about page
2. Establish author identity with credentials
3. Implement HTTPS
4. Remove deceptive elements
### 30-49 (Major)
1. Add author bios with credentials
2. Include first-hand experience content
3. Get external citations/mentions
4. Add customer testimonials
### 50-69 (Moderate)
1. Deepen content with original research
2. Build topical authority through content clusters
3. Pursue industry recognition
4. Document processes and methodologies
### 70-89 (Minor)
1. Maintain freshness with regular updates
2. Expand author presence across platforms
3. Pursue speaking/publication opportunities
4. Add video/multimedia demonstrating expertise
### 90-100 (Maintenance)
1. Continue publishing high-quality content
2. Monitor and respond to reputation issues
3. Keep credentials and certifications current
references/quality-gates.md
# Content Quality Gates
## Minimum Word Counts by Page Type
| Page Type | Min Words | Unique Content % | Notes |
|-----------|-----------|-----------------|-------|
| Homepage | 500 | 100% | Must clearly communicate value proposition |
| Service / Feature Page | 800 | 100% | Detailed explanation of offering |
| Location (Primary) | 600 | 60%+ | City headquarters or main service area |
| Location (Secondary) | 500 | 40%+ | Satellite locations |
| Blog Post | 1,500 | 100% | In-depth, valuable content |
| Product Page | 400 | 80%+ | Unique descriptions, specs |
| Category Page | 400 | 100% | Unique intro, not just product listings |
| About Page | 400 | 100% | Company story, team, values |
| Landing Page | 600 | 100% | Focused conversion content |
| FAQ Page | 800 | 100% | Comprehensive Q&A |
---
## Location Page Thresholds
### Warning Level (30+ pages)
- ⚠️ **WARNING** at 30+ location pages
- Enforce 60%+ unique content per page
- Content must include:
- Unique local information (landmarks, neighborhoods)
- Location-specific services or offerings
- Local team or staff information
- Genuine customer testimonials from that area
### Hard Stop (50+ pages)
- 🛑 **HARD STOP** at 50+ location pages
- Require explicit user justification
- Must demonstrate:
- Legitimate business presence in each location
- Unique content strategy for each page
- Local signals (Google Business Profile, local reviews)
### Why This Matters
Google's doorway page algorithm penalizes programmatic location pages with thin/duplicate content. Signs of doorway pages:
- Only city/state name changed between pages
- No unique local information
- No local business signals
- Keyword-stuffed URLs
---
## Safe vs. Risky Programmatic Pages
### Safe at Scale ✅
| Page Type | Why It's Safe |
|-----------|---------------|
| Integration pages | Real setup documentation, unique technical content |
| Template/tool pages | Downloadable assets, unique functionality |
| Glossary pages | 200+ word unique definitions |
| Product pages | Unique specs, images, reviews |
| User profile pages | User-generated unique content |
### Penalty Risk ❌
| Page Type | Why It's Risky |
|-----------|----------------|
| Location pages with only city swapped | Duplicate content, doorway pages |
| "Best [tool] for [industry]" | Often thin, no industry-specific value |
| "[Competitor] alternative" | Requires genuine comparison data |
| AI-generated mass content | No unique value, E-E-A-T failure |
---
## Title Tag Requirements
| Aspect | Requirement |
|--------|-------------|
| Minimum length | 30 characters |
| Maximum length | 60 characters (Google truncates ~60) |
| Primary keyword | Near the beginning |
| Brand name | At end (if included) |
| Uniqueness | Each page must have unique title |
### Good Examples
- "Emergency Plumbing Services in Austin | ABC Plumbing"
- "How to Fix a Leaky Faucet: Step-by-Step Guide"
- "Enterprise SEO Software | Comprehensive Platform"
### Bad Examples
- "Home" (too short, not descriptive)
- "Best Plumbing Services for All Your Plumbing Needs in Austin Texas and Surrounding Areas" (too long)
- "ABC Plumbing - Plumbing - Plumber - Plumbing Services" (keyword stuffing)
---
## Meta Description Requirements
| Aspect | Requirement |
|--------|-------------|
| Minimum length | 120 characters |
| Maximum length | 160 characters (Google truncates ~155-160) |
| Call-to-action | Include compelling CTA |
| Primary keyword | Include naturally |
| Uniqueness | Each page must have unique description |
---
## Image Alt Text Requirements
| Aspect | Requirement |
|--------|-------------|
| Required on | All non-decorative images |
| Length | 10-125 characters |
| Content | Describe the image content, not "image" or filename |
| Keywords | Include naturally where relevant |
| Decorative images | Use `alt=""` or `role="presentation"` |
### Good Examples
- "Professional plumber repairing kitchen sink faucet"
- "Red 2024 Toyota Camry sedan front view"
- "Team meeting in modern office conference room"
### Bad Examples
- "image.jpg" (filename, not description)
- "plumber plumbing plumber services" (keyword stuffing)
- "Click here" (not descriptive)
---
## Internal Linking Guidelines
| Page Type | Internal Links Target |
|-----------|----------------------|
| Blog post (1,500+ words) | 5-10 internal links |
| Service page | 3-5 internal links |
| Category page | Links to all child pages |
| Product page | 2-4 internal links |
### Anchor Text Rules
- Use descriptive anchor text (not "click here")
- Vary anchor text (don't always use exact match keywords)
- Link to relevant, related content
- Ensure no orphan pages (every page linked from at least one other page)
---
## Content Freshness Signals
| Content Type | Update Frequency |
|--------------|------------------|
| News/current events | Within hours/days |
| Blog posts (evergreen) | Review annually |
| Product pages | When specs change |
| Service pages | Review quarterly |
| Company info | When changes occur |
### Required Elements
- Publication date visible (for articles/blogs)
- Last updated date (if significantly revised)
- Changelog for major updates (optional but good)
references/schema-types.md
<!-- Updated: 2026-02-07 -->
# Schema.org Types — Status & Recommendations (February 2026)
**Schema.org Version:** 29.4 (December 8, 2025)
## Format Preference
Always use **JSON-LD** (`<script type="application/ld+json">`).
Google's documentation explicitly recommends JSON-LD over Microdata and RDFa.
**AI Search Note:** Content with proper schema has ~2.5× higher chance of appearing in AI-generated answers (confirmed by Google and Microsoft, March 2025).
---
## Active — Recommend freely
| Type | Use Case | Key Properties |
|------|----------|----------------|
| Organization | Company info | name, url, logo, contactPoint, sameAs |
| LocalBusiness | Physical businesses | name, address, telephone, openingHours, geo, priceRange |
| SoftwareApplication | Desktop/mobile apps | name, operatingSystem, applicationCategory, offers, aggregateRating |
| WebApplication | Browser-based SaaS | name, applicationCategory, offers, browserRequirements, featureList |
| Product | Physical/digital products | name, image, description, sku, brand, offers, review |
| Offer | Pricing | price, priceCurrency, availability, url, validFrom |
| Service | Service businesses | name, provider, areaServed, description, offers |
| Article | Blog posts, news | headline, author, datePublished, dateModified, image, publisher |
| BlogPosting | Blog content | Same as Article + blog-specific context |
| NewsArticle | News content | Same as Article + news-specific context |
| Review | Individual reviews | reviewRating, author, itemReviewed, reviewBody |
| AggregateRating | Rating summaries | ratingValue, reviewCount, bestRating, worstRating |
| BreadcrumbList | Navigation | itemListElement with position, name, item |
| WebSite | Site-level | name, url, potentialAction (SearchAction for sitelinks search) |
| WebPage | Page-level | name, description, datePublished, dateModified |
| Person | Author/team | name, jobTitle, url, sameAs, image, worksFor |
| ContactPage | Contact pages | name, url |
| VideoObject | Video content | name, description, thumbnailUrl, uploadDate, duration, contentUrl |
| ImageObject | Image content | contentUrl, caption, creator, copyrightHolder |
| Event | Events | name, startDate, endDate, location, organizer, offers |
| JobPosting | Job listings | title, description, datePosted, hiringOrganization, jobLocation |
| Course | Educational content | name, description, provider, hasCourseInstance |
| DiscussionForumPosting | Forum threads | headline, author, datePublished, text, url |
| ProductGroup | Variant products | name, productGroupID, variesBy, hasVariant |
| ProfilePage | Author/creator profiles | mainEntity (Person), name, url, description, sameAs |
---
## Restricted — Only for specific site types
| Type | Restriction | Since |
|------|------------|-------|
| FAQPage | Government and healthcare authority sites ONLY | August 2023 |
> Google severely limited FAQ rich results. Only authoritative sources (government, health organizations) now receive FAQ rich results. Do NOT recommend FAQPage schema for commercial sites.
---
## Deprecated — Never recommend
| Type | Status | Since | Notes |
|------|--------|-------|-------|
| HowTo | Rich results fully removed | September 2023 | Google stopped showing how-to rich results |
| SpecialAnnouncement | Deprecated | July 31, 2025 | COVID-era schema, no longer processed |
| CourseInfo | Retired from rich results | June 2025 | Merged into Course |
| EstimatedSalary | Retired from rich results | June 2025 | No longer displayed |
| LearningVideo | Retired from rich results | June 2025 | Use VideoObject instead |
| ClaimReview | Retired from rich results | June 2025 | Fact-check markup no longer generates rich results |
| VehicleListing | Retired from rich results | June 2025 | Vehicle listing structured data discontinued |
| Book Actions | Deprecated then REVERSED | June 2025 | **Still functional as of Feb 2026** — historical note only |
| Practice Problem | Retired from rich results | Late 2025 | Educational practice problems no longer displayed |
| Dataset | Retired from rich results | Late 2025 | Dataset Search feature discontinued |
---
## Recent Additions (2024-2026)
| Type/Feature | Added | Notes |
|-------------|-------|-------|
| Product Certification markup | April 2025 | Energy ratings, safety certifications. Replaced EnergyConsumptionDetails. |
| ProductGroup | 2025 | E-commerce product variants with variesBy, hasVariant properties |
| ProfilePage | 2025 | Author/creator profile pages with mainEntity Person for E-E-A-T |
| DiscussionForumPosting | 2024 | For forum/community content |
| Speakable | Updated 2024 | For voice search optimization |
| LoyaltyProgram | June 2025 | Member pricing, loyalty card structured data |
| Organization-level shipping/return policies | November 2025 | Configure via Search Console without Merchant Center |
| ConferenceEvent | December 2025 | Schema.org v29.4 addition |
| PerformingArtsEvent | December 2025 | Schema.org v29.4 addition |
## E-commerce Requirements (Updated)
| Requirement | Status | Since |
|-------------|--------|-------|
| `returnPolicyCountry` in MerchantReturnPolicy | **Required** | March 2025 |
| Product variant structured data | Expanded | 2025 — includes apparel, cosmetics, electronics |
> **Note:** Content API for Shopping sunsets August 18, 2026. Migrate to Merchant API.
---
## Validation Checklist
For any schema block, verify:
1. ✅ `@context` is `"https://schema.org"` (not http)
2. ✅ `@type` is a valid, non-deprecated type
3. ✅ All required properties are present
4. ✅ Property values match expected data types
5. ✅ No placeholder text (e.g., "[Business Name]")
6. ✅ URLs are absolute, not relative
7. ✅ Dates are in ISO 8601 format
8. ✅ Images have valid URLs
## Testing Tools
- [Google Rich Results Test](https://search.google.com/test/rich-results)
- [Schema.org Validator](https://validator.schema.org/)
scripts/analyze_visual.py
#!/usr/bin/env python3
"""
Analyze visual aspects of a web page using Playwright.
Usage:
python analyze_visual.py https://example.com
"""
import argparse
import ipaddress
import json
import socket
import sys
from urllib.parse import urlparse
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError:
print("Error: playwright required. Install with: pip install playwright && playwright install chromium")
sys.exit(1)
def _is_public_ip(ip: ipaddress._BaseAddress) -> bool:
return not (
ip.is_private
or ip.is_loopback
or ip.is_reserved
or ip.is_link_local
or ip.is_multicast
or ip.is_unspecified
)
def _is_public_host(hostname: str) -> tuple[bool, str]:
if not hostname:
return False, "Missing hostname"
try:
ip = ipaddress.ip_address(hostname)
if not _is_public_ip(ip):
return False, f"Blocked non-public IP: {hostname}"
return True, ""
except ValueError:
pass
try:
addr_info = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
except socket.gaierror as e:
return False, f"DNS resolution failed for {hostname}: {e}"
seen_ips: set[str] = set()
for _, _, _, _, sockaddr in addr_info:
ip_text = sockaddr[0]
if ip_text in seen_ips:
continue
seen_ips.add(ip_text)
try:
ip = ipaddress.ip_address(ip_text)
except ValueError:
return False, f"Invalid resolved IP for {hostname}: {ip_text}"
if not _is_public_ip(ip):
return False, f"Blocked non-public IP for {hostname}: {ip_text}"
return True, ""
def _validate_url(raw_url: str) -> tuple[str | None, str | None]:
url = raw_url.strip()
parsed = urlparse(url)
if not parsed.scheme:
url = f"https://{url}"
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None, f"Invalid URL scheme: {parsed.scheme}"
if not parsed.hostname:
return None, "Invalid URL: missing hostname"
is_public, reason = _is_public_host(parsed.hostname)
if not is_public:
return None, reason
return url, None
def analyze_visual(url: str, timeout: int = 30000) -> dict:
"""
Analyze visual aspects of a web page.
Args:
url: URL to analyze
timeout: Page load timeout in milliseconds
Returns:
Dictionary with visual analysis results
"""
result = {
"url": url,
"above_fold": {
"h1_visible": False,
"cta_visible": False,
"hero_image": None,
},
"mobile": {
"viewport_meta": False,
"horizontal_scroll": False,
"touch_targets_ok": True,
},
"layout": {
"overlapping_elements": [],
"text_overflow": [],
},
"fonts": {
"base_size": None,
"readable": True,
},
"error": None,
}
validated_url, validation_error = _validate_url(url)
if validation_error:
result["error"] = validation_error
return result
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
host_cache: dict[str, tuple[bool, str]] = {}
def attach_route_guard(page):
def route_handler(route):
req_url = route.request.url
parsed_req = urlparse(req_url)
if parsed_req.scheme in ("data", "blob", "about"):
route.continue_()
return
if parsed_req.scheme not in ("http", "https") or not parsed_req.hostname:
route.abort()
return
cached = host_cache.get(parsed_req.hostname)
if cached is None:
cached = _is_public_host(parsed_req.hostname)
host_cache[parsed_req.hostname] = cached
if not cached[0]:
route.abort()
return
route.continue_()
page.route("**/*", route_handler)
# Desktop analysis
desktop = browser.new_context(viewport={"width": 1920, "height": 1080})
page = desktop.new_page()
attach_route_guard(page)
page.goto(validated_url, wait_until="networkidle", timeout=timeout)
parsed_final = urlparse(page.url)
if parsed_final.scheme not in ("http", "https") or not parsed_final.hostname:
result["error"] = f"Blocked final URL: {page.url}"
desktop.close()
browser.close()
return result
is_public, reason = _is_public_host(parsed_final.hostname)
if not is_public:
result["error"] = f"Blocked final URL: {reason}"
desktop.close()
browser.close()
return result
# Check H1 visibility above fold
h1 = page.query_selector("h1")
if h1:
box = h1.bounding_box()
if box and box["y"] < 1080:
result["above_fold"]["h1_visible"] = True
# Check for CTA buttons above fold
cta_selectors = [
"a[href*='signup']",
"a[href*='contact']",
"a[href*='demo']",
"button:has-text('Get Started')",
"button:has-text('Sign Up')",
"button:has-text('Contact')",
".cta",
"[class*='cta']",
]
for selector in cta_selectors:
try:
cta = page.query_selector(selector)
if cta:
box = cta.bounding_box()
if box and box["y"] < 1080:
result["above_fold"]["cta_visible"] = True
break
except Exception:
pass
# Check hero image
hero_selectors = [
".hero img",
"[class*='hero'] img",
"header img",
"main img:first-of-type",
]
for selector in hero_selectors:
try:
hero = page.query_selector(selector)
if hero:
src = hero.get_attribute("src")
if src:
result["above_fold"]["hero_image"] = src
break
except Exception:
pass
desktop.close()
# Mobile analysis
mobile = browser.new_context(viewport={"width": 375, "height": 812})
page = mobile.new_page()
attach_route_guard(page)
page.goto(validated_url, wait_until="networkidle", timeout=timeout)
parsed_mobile_final = urlparse(page.url)
if parsed_mobile_final.scheme not in ("http", "https") or not parsed_mobile_final.hostname:
result["error"] = f"Blocked final URL: {page.url}"
mobile.close()
browser.close()
return result
is_public, reason = _is_public_host(parsed_mobile_final.hostname)
if not is_public:
result["error"] = f"Blocked final URL: {reason}"
mobile.close()
browser.close()
return result
# Check viewport meta
viewport_meta = page.query_selector('meta[name="viewport"]')
result["mobile"]["viewport_meta"] = viewport_meta is not None
# Check for horizontal scroll
scroll_width = page.evaluate("document.documentElement.scrollWidth")
viewport_width = page.evaluate("window.innerWidth")
result["mobile"]["horizontal_scroll"] = scroll_width > viewport_width
# Check font size
base_font_size = page.evaluate(
"""
() => {
const body = document.body;
const style = window.getComputedStyle(body);
return parseFloat(style.fontSize);
}
"""
)
result["fonts"]["base_size"] = base_font_size
result["fonts"]["readable"] = base_font_size >= 16
mobile.close()
browser.close()
except PlaywrightTimeout:
result["error"] = f"Page load timed out after {timeout}ms"
except Exception as e:
result["error"] = str(e)
return result
def main():
parser = argparse.ArgumentParser(description="Analyze visual aspects of a web page")
parser.add_argument("url", help="URL to analyze")
parser.add_argument("--timeout", "-t", type=int, default=30000, help="Timeout in ms")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
result = analyze_visual(args.url, timeout=args.timeout)
if args.json:
print(json.dumps(result, indent=2))
else:
print("Visual Analysis Results")
print("=" * 40)
print("\nAbove the Fold:")
print(f" H1 Visible: {'✓' if result['above_fold']['h1_visible'] else '✗'}")
print(f" CTA Visible: {'✓' if result['above_fold']['cta_visible'] else '✗'}")
print(f" Hero Image: {result['above_fold']['hero_image'] or 'None found'}")
print("\nMobile Responsiveness:")
print(f" Viewport Meta: {'✓' if result['mobile']['viewport_meta'] else '✗'}")
print(f" Horizontal Scroll: {'✗ (problem)' if result['mobile']['horizontal_scroll'] else '✓'}")
print("\nTypography:")
print(f" Base Font Size: {result['fonts']['base_size']}px")
print(f" Readable (≥16px): {'✓' if result['fonts']['readable'] else '✗'}")
if result["error"]:
print(f"\nError: {result['error']}")
if __name__ == "__main__":
main()
scripts/capture_screenshot.py
#!/usr/bin/env python3
"""
Capture screenshots of web pages using Playwright.
Usage:
python capture_screenshot.py https://example.com
python capture_screenshot.py https://example.com --mobile
python capture_screenshot.py https://example.com --output screenshots/
"""
import argparse
import ipaddress
import os
import socket
import sys
from urllib.parse import urlparse
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError:
print("Error: playwright required. Install with: pip install playwright && playwright install chromium")
sys.exit(1)
VIEWPORTS = {
"desktop": {"width": 1920, "height": 1080},
"laptop": {"width": 1366, "height": 768},
"tablet": {"width": 768, "height": 1024},
"mobile": {"width": 375, "height": 812},
}
def _is_public_ip(ip: ipaddress._BaseAddress) -> bool:
return not (
ip.is_private
or ip.is_loopback
or ip.is_reserved
or ip.is_link_local
or ip.is_multicast
or ip.is_unspecified
)
def _is_public_host(hostname: str) -> tuple[bool, str]:
if not hostname:
return False, "Missing hostname"
try:
ip = ipaddress.ip_address(hostname)
if not _is_public_ip(ip):
return False, f"Blocked non-public IP: {hostname}"
return True, ""
except ValueError:
pass
try:
addr_info = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
except socket.gaierror as e:
return False, f"DNS resolution failed for {hostname}: {e}"
seen_ips: set[str] = set()
for _, _, _, _, sockaddr in addr_info:
ip_text = sockaddr[0]
if ip_text in seen_ips:
continue
seen_ips.add(ip_text)
try:
ip = ipaddress.ip_address(ip_text)
except ValueError:
return False, f"Invalid resolved IP for {hostname}: {ip_text}"
if not _is_public_ip(ip):
return False, f"Blocked non-public IP for {hostname}: {ip_text}"
return True, ""
def _validate_url(raw_url: str) -> tuple[str | None, str | None]:
url = raw_url.strip()
parsed = urlparse(url)
if not parsed.scheme:
url = f"https://{url}"
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None, f"Invalid URL scheme: {parsed.scheme}"
if not parsed.hostname:
return None, "Invalid URL: missing hostname"
is_public, reason = _is_public_host(parsed.hostname)
if not is_public:
return None, reason
return url, None
def capture_screenshot(
url: str,
output_path: str,
viewport: str = "desktop",
full_page: bool = False,
timeout: int = 30000,
) -> dict:
"""
Capture a screenshot of a web page.
"""
result = {
"url": url,
"output": output_path,
"viewport": viewport,
"success": False,
"error": None,
}
if viewport not in VIEWPORTS:
result["error"] = f"Invalid viewport: {viewport}. Choose from: {list(VIEWPORTS.keys())}"
return result
validated_url, validation_error = _validate_url(url)
if validation_error:
result["error"] = validation_error
return result
vp = VIEWPORTS[viewport]
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": vp["width"], "height": vp["height"]},
device_scale_factor=2 if viewport == "mobile" else 1,
)
page = context.new_page()
# Block requests to non-http(s) or non-public hosts.
host_cache: dict[str, tuple[bool, str]] = {}
def route_handler(route):
req_url = route.request.url
parsed_req = urlparse(req_url)
if parsed_req.scheme in ("data", "blob", "about"):
route.continue_()
return
if parsed_req.scheme not in ("http", "https") or not parsed_req.hostname:
route.abort()
return
cached = host_cache.get(parsed_req.hostname)
if cached is None:
cached = _is_public_host(parsed_req.hostname)
host_cache[parsed_req.hostname] = cached
if not cached[0]:
route.abort()
return
route.continue_()
page.route("**/*", route_handler)
# Navigate and wait for network idle
page.goto(validated_url, wait_until="networkidle", timeout=timeout)
# Validate final URL after redirects
parsed_final = urlparse(page.url)
if parsed_final.scheme not in ("http", "https") or not parsed_final.hostname:
browser.close()
result["error"] = f"Blocked final URL: {page.url}"
return result
is_public, reason = _is_public_host(parsed_final.hostname)
if not is_public:
browser.close()
result["error"] = f"Blocked final URL: {reason}"
return result
# Wait a bit more for any lazy-loaded content
page.wait_for_timeout(1000)
# Capture screenshot
page.screenshot(path=output_path, full_page=full_page)
result["success"] = True
browser.close()
except PlaywrightTimeout:
result["error"] = f"Page load timed out after {timeout}ms"
except Exception as e:
result["error"] = str(e)
return result
def main():
parser = argparse.ArgumentParser(description="Capture web page screenshots")
parser.add_argument("url", help="URL to capture")
parser.add_argument("--output", "-o", default="screenshots", help="Output directory")
parser.add_argument("--viewport", "-v", default="desktop", choices=VIEWPORTS.keys())
parser.add_argument("--all", "-a", action="store_true", help="Capture all viewports")
parser.add_argument("--full", "-f", action="store_true", help="Capture full page")
parser.add_argument("--timeout", "-t", type=int, default=30000, help="Timeout in ms")
args = parser.parse_args()
# Sanitize output path — prevent directory traversal
output_dir = os.path.realpath(args.output)
cwd = os.getcwd()
home = os.path.expanduser("~")
if not (output_dir.startswith(cwd) or output_dir.startswith(home)):
print("Error: Output path must be within current directory or home directory", file=sys.stderr)
sys.exit(1)
# Create output directory
os.makedirs(args.output, exist_ok=True)
# Generate filename from URL
parsed = urlparse(args.url)
base_name = parsed.netloc.replace(".", "_") if parsed.netloc else "page"
viewports = VIEWPORTS.keys() if args.all else [args.viewport]
for viewport in viewports:
filename = f"{base_name}_{viewport}.png"
output_path = os.path.join(args.output, filename)
print(f"Capturing {viewport} screenshot...")
result = capture_screenshot(
args.url,
output_path,
viewport=viewport,
full_page=args.full,
timeout=args.timeout,
)
if result["success"]:
print(f" ✓ Saved to {output_path}")
else:
print(f" ✗ Failed: {result['error']}")
if __name__ == "__main__":
main()
scripts/fetch_page.py
#!/usr/bin/env python3
"""
Fetch a web page with proper headers and error handling.
Usage:
python fetch_page.py https://example.com
python fetch_page.py https://example.com --output page.html
"""
import argparse
import ipaddress
import socket
import sys
from urllib.parse import urljoin, urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests")
sys.exit(1)
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; ClaudeSEO/1.0; +https://github.com/AgriciDaniel/claude-seo)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
}
def _is_public_ip(ip: ipaddress._BaseAddress) -> bool:
return not (
ip.is_private
or ip.is_loopback
or ip.is_reserved
or ip.is_link_local
or ip.is_multicast
or ip.is_unspecified
)
def _is_public_host(hostname: str) -> tuple[bool, str]:
"""
Validate that hostname resolves only to public IP addresses.
"""
if not hostname:
return False, "Missing hostname"
# Direct IP literal
try:
ip = ipaddress.ip_address(hostname)
if not _is_public_ip(ip):
return False, f"Blocked non-public IP: {hostname}"
return True, ""
except ValueError:
pass
try:
addr_info = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
except socket.gaierror as e:
return False, f"DNS resolution failed for {hostname}: {e}"
seen_ips: set[str] = set()
for _, _, _, _, sockaddr in addr_info:
ip_text = sockaddr[0]
if ip_text in seen_ips:
continue
seen_ips.add(ip_text)
try:
ip = ipaddress.ip_address(ip_text)
except ValueError:
return False, f"Invalid resolved IP for {hostname}: {ip_text}"
if not _is_public_ip(ip):
return False, f"Blocked non-public IP for {hostname}: {ip_text}"
return True, ""
def _validate_url(raw_url: str) -> tuple[str | None, str | None]:
"""
Normalize and validate input URL.
"""
url = raw_url.strip()
parsed = urlparse(url)
if not parsed.scheme:
url = f"https://{url}"
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None, f"Invalid URL scheme: {parsed.scheme}"
if not parsed.hostname:
return None, "Invalid URL: missing hostname"
is_public, reason = _is_public_host(parsed.hostname)
if not is_public:
return None, reason
return url, None
def fetch_page(
url: str,
timeout: int = 30,
follow_redirects: bool = True,
max_redirects: int = 5,
) -> dict:
"""
Fetch a web page and return response details.
"""
result = {
"url": url,
"status_code": None,
"content": None,
"headers": {},
"redirect_chain": [],
"error": None,
}
validated_url, validation_error = _validate_url(url)
if validation_error:
result["error"] = validation_error
return result
try:
session = requests.Session()
session.max_redirects = max_redirects
current_url = validated_url
redirect_chain: list[str] = []
for _ in range(max_redirects + 1):
response = session.get(
current_url,
headers=DEFAULT_HEADERS,
timeout=timeout,
allow_redirects=False,
)
if not follow_redirects or not response.is_redirect:
parsed_final = urlparse(response.url)
if parsed_final.hostname:
is_public, reason = _is_public_host(parsed_final.hostname)
if not is_public:
result["error"] = f"Blocked final URL: {reason}"
return result
result["url"] = response.url
result["status_code"] = response.status_code
result["content"] = response.text
result["headers"] = dict(response.headers)
result["redirect_chain"] = redirect_chain
return result
location = response.headers.get("Location")
if not location:
result["error"] = "Redirect response missing Location header"
return result
next_url = urljoin(current_url, location)
parsed_next = urlparse(next_url)
if parsed_next.scheme not in ("http", "https") or not parsed_next.hostname:
result["error"] = f"Blocked redirect target: {next_url}"
return result
is_public, reason = _is_public_host(parsed_next.hostname)
if not is_public:
result["error"] = f"Blocked redirect target: {reason}"
return result
redirect_chain.append(next_url)
current_url = next_url
result["error"] = f"Too many redirects (max {max_redirects})"
return result
except requests.exceptions.Timeout:
result["error"] = f"Request timed out after {timeout} seconds"
except requests.exceptions.TooManyRedirects:
result["error"] = f"Too many redirects (max {max_redirects})"
except requests.exceptions.SSLError as e:
result["error"] = f"SSL error: {e}"
except requests.exceptions.ConnectionError as e:
result["error"] = f"Connection error: {e}"
except requests.exceptions.RequestException as e:
result["error"] = f"Request failed: {e}"
return result
def main():
parser = argparse.ArgumentParser(description="Fetch a web page for SEO analysis")
parser.add_argument("url", help="URL to fetch")
parser.add_argument("--output", "-o", help="Output file path")
parser.add_argument("--timeout", "-t", type=int, default=30, help="Timeout in seconds")
parser.add_argument("--no-redirects", action="store_true", help="Don't follow redirects")
args = parser.parse_args()
result = fetch_page(
args.url,
timeout=args.timeout,
follow_redirects=not args.no_redirects,
)
if result["error"]:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(result["content"])
print(f"Saved to {args.output}")
else:
print(result["content"])
# Print metadata to stderr
print(f"\nURL: {result['url']}", file=sys.stderr)
print(f"Status: {result['status_code']}", file=sys.stderr)
if result["redirect_chain"]:
print(f"Redirects: {' -> '.join(result['redirect_chain'])}", file=sys.stderr)
if __name__ == "__main__":
main()
scripts/parse_html.py
#!/usr/bin/env python3
"""
Parse HTML and extract SEO-relevant elements.
Usage:
python parse_html.py page.html
python parse_html.py --url https://example.com
"""
import argparse
import json
import os
import re
import sys
from typing import Optional
from urllib.parse import urljoin, urlparse
try:
from bs4 import BeautifulSoup
except ImportError:
print("Error: beautifulsoup4 required. Install with: pip install beautifulsoup4")
sys.exit(1)
def parse_html(html: str, base_url: Optional[str] = None) -> dict:
"""
Parse HTML and extract SEO-relevant elements.
Args:
html: HTML content to parse
base_url: Base URL for resolving relative links
Returns:
Dictionary with extracted SEO data
"""
soup = BeautifulSoup(html, "lxml" if "lxml" in sys.modules else "html.parser")
result = {
"title": None,
"meta_description": None,
"meta_robots": None,
"canonical": None,
"h1": [],
"h2": [],
"h3": [],
"images": [],
"links": {
"internal": [],
"external": [],
},
"schema": [],
"open_graph": {},
"twitter_card": {},
"word_count": 0,
"hreflang": [],
}
# Title
title_tag = soup.find("title")
if title_tag:
result["title"] = title_tag.get_text(strip=True)
# Meta tags
for meta in soup.find_all("meta"):
name = meta.get("name", "").lower()
property_attr = meta.get("property", "").lower()
content = meta.get("content", "")
if name == "description":
result["meta_description"] = content
elif name == "robots":
result["meta_robots"] = content
# Open Graph
if property_attr.startswith("og:"):
result["open_graph"][property_attr] = content
# Twitter Card
if name.startswith("twitter:"):
result["twitter_card"][name] = content
# Canonical
canonical = soup.find("link", rel="canonical")
if canonical:
result["canonical"] = canonical.get("href")
# Hreflang
for link in soup.find_all("link", rel="alternate"):
hreflang = link.get("hreflang")
if hreflang:
result["hreflang"].append({
"lang": hreflang,
"href": link.get("href"),
})
# Headings
for tag in ["h1", "h2", "h3"]:
for heading in soup.find_all(tag):
text = heading.get_text(strip=True)
if text:
result[tag].append(text)
# Images
for img in soup.find_all("img"):
src = img.get("src", "")
if base_url and src:
src = urljoin(base_url, src)
result["images"].append({
"src": src,
"alt": img.get("alt"),
"width": img.get("width"),
"height": img.get("height"),
"loading": img.get("loading"),
})
# Links
if base_url:
base_domain = urlparse(base_url).netloc
for a in soup.find_all("a", href=True):
href = a.get("href", "")
if not href or href.startswith("#") or href.startswith("javascript:"):
continue
full_url = urljoin(base_url, href)
parsed = urlparse(full_url)
link_data = {
"href": full_url,
"text": a.get_text(strip=True)[:100],
"rel": a.get("rel", []),
}
if parsed.netloc == base_domain:
result["links"]["internal"].append(link_data)
else:
result["links"]["external"].append(link_data)
# Schema (JSON-LD)
for script in soup.find_all("script", type="application/ld+json"):
try:
schema_data = json.loads(script.string)
result["schema"].append(schema_data)
except (json.JSONDecodeError, TypeError):
pass
# Word count (visible text only)
for element in soup(["script", "style", "nav", "footer", "header"]):
element.decompose()
text = soup.get_text(separator=" ", strip=True)
words = re.findall(r"\b\w+\b", text)
result["word_count"] = len(words)
return result
def main():
parser = argparse.ArgumentParser(description="Parse HTML for SEO analysis")
parser.add_argument("file", nargs="?", help="HTML file to parse")
parser.add_argument("--url", "-u", help="Base URL for resolving links")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.file:
real_path = os.path.realpath(args.file)
if not os.path.isfile(real_path):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
with open(real_path, "r", encoding="utf-8") as f:
html = f.read()
else:
html = sys.stdin.read()
result = parse_html(html, args.url)
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Title: {result['title']}")
print(f"Meta Description: {result['meta_description']}")
print(f"Canonical: {result['canonical']}")
print(f"H1 Tags: {len(result['h1'])}")
print(f"H2 Tags: {len(result['h2'])}")
print(f"Images: {len(result['images'])}")
print(f"Internal Links: {len(result['links']['internal'])}")
print(f"External Links: {len(result['links']['external'])}")
print(f"Schema Blocks: {len(result['schema'])}")
print(f"Word Count: {result['word_count']}")
if __name__ == "__main__":
main()
SKILL.md
---
name: seo
description: >
Comprehensive SEO analysis for any website or business type. Performs full site
audits, single-page deep analysis, technical SEO checks (crawlability, indexability,
Core Web Vitals with INP), schema markup detection/validation/generation, content
quality assessment (E-E-A-T framework per Dec 2025 update extending to all
competitive queries), image optimization, sitemap analysis, and Generative Engine
Optimization (GEO) for AI Overviews, ChatGPT, and Perplexity citations. Analyzes
AI crawler accessibility (GPTBot, ClaudeBot, PerplexityBot), llms.txt compliance,
brand mention signals, and passage-level citability. Industry detection for SaaS,
e-commerce, local business, publishers, agencies. Triggers on: "SEO", "audit",
"schema", "Core Web Vitals", "sitemap", "E-E-A-T", "AI Overviews", "GEO",
"technical SEO", "content quality", "page speed", "structured data".
---
# SEO — Universal SEO Analysis Skill
Comprehensive SEO analysis across all industries (SaaS, local services,
e-commerce, publishers, agencies). Orchestrates 12 specialized sub-skills.
## Quick Reference
| Command | What it does |
|---------|-------------|
| `$seo audit <url>` | Full website audit with parallel specialized analyses |
| `$seo page <url>` | Deep single-page analysis |
| `$seo sitemap <url or generate>` | Analyze or generate XML sitemaps |
| `$seo schema <url>` | Detect, validate, and generate Schema.org markup |
| `$seo images <url>` | Image optimization analysis |
| `$seo technical <url>` | Technical SEO audit (8 categories) |
| `$seo content <url>` | E-E-A-T and content quality analysis |
| `$seo geo <url>` | AI Overviews / Generative Engine Optimization |
| `$seo plan <business-type>` | Strategic SEO planning |
| `$seo programmatic [url\|plan]` | Programmatic SEO analysis and planning |
| `$seo competitor-pages [url\|generate]` | Competitor comparison page generation |
| `$seo hreflang [url]` | Hreflang/i18n SEO audit and generation |
## Codex Invocation Notes
- Trigger this skill on natural-language requests such as "run an SEO audit", "analyze this page for SEO", or "validate schema markup".
- Treat the `$...` command examples as operation labels, not required CLI syntax.
- Prefer repository-local outputs and summarize assumptions before running broad crawls.
- For full audits, use Codex multi-agent mode: `spawn_agent` specialists in parallel and `wait` for completion before synthesis.
## Orchestration Logic
When the user requests a full SEO audit, execute in this order:
1. Detect business type (SaaS, local, ecommerce, publisher, agency, other)
2. Run core analyses in parallel where possible: `seo-technical`, `seo-content`, `seo-schema`, `seo-sitemap`, and `seo-images`
3. Aggregate findings into a unified SEO Health Score (0-100)
4. Create a prioritized action plan (Critical → High → Medium → Low)
For individual requests, trigger the corresponding specialized skill directly.
### Multi-Agent Role Mapping (Codex)
- Use `explorer` agents for site discovery and evidence gathering.
- Use `worker` agents for each specialist analysis (`seo-technical`, `seo-content`, `seo-schema`, `seo-sitemap`, `seo-images`).
- Use the `default` agent to reconcile conflicts, compute final scoring, and deliver recommendations.
- Keep delegated tasks independent and bounded (single URL/domain scope per agent unless user asks otherwise).
## Industry Detection
Detect business type from homepage signals:
- **SaaS**: pricing page, /features, /integrations, /docs, "free trial", "sign up"
- **Local Service**: phone number, address, service area, "serving [city]", Google Maps embed
- **E-commerce**: /products, /collections, /cart, "add to cart", product schema
- **Publisher**: /blog, /articles, /topics, article schema, author pages, publication dates
- **Agency**: /case-studies, /portfolio, /industries, "our work", client logos
## Quality Gates
Read `references/quality-gates.md` for thin content thresholds per page type.
Hard rules:
- ⚠️ WARNING at 30+ location pages (enforce 60%+ unique content)
- 🛑 HARD STOP at 50+ location pages (require user justification)
- Never recommend HowTo schema (deprecated Sept 2023)
- FAQ schema only for government and healthcare sites
- All Core Web Vitals references use INP, never FID
## Reference Files
Load these on-demand as needed — do NOT load all at startup:
- `references/cwv-thresholds.md` — Current Core Web Vitals thresholds and measurement details
- `references/schema-types.md` — All supported schema types with deprecation status
- `references/eeat-framework.md` — E-E-A-T evaluation criteria (Sept 2025 QRG update)
- `references/quality-gates.md` — Content length minimums, uniqueness thresholds
## Scoring Methodology
### SEO Health Score (0-100)
Weighted aggregate of all categories:
| Category | Weight |
|----------|--------|
| Technical SEO | 25% |
| Content Quality | 25% |
| On-Page SEO | 20% |
| Schema / Structured Data | 10% |
| Performance (CWV) | 10% |
| Images | 5% |
| AI Search Readiness | 5% |
### Priority Levels
- **Critical**: Blocks indexing or causes penalties (immediate fix required)
- **High**: Significantly impacts rankings (fix within 1 week)
- **Medium**: Optimization opportunity (fix within 1 month)
- **Low**: Nice to have (backlog)
## Sub-Skills
This skill orchestrates 12 specialized sub-skills:
1. **seo-audit** — Full website audit with parallel delegation
2. **seo-page** — Deep single-page analysis
3. **seo-technical** — Technical SEO (8 categories)
4. **seo-content** — E-E-A-T and content quality
5. **seo-schema** — Schema markup detection and generation
6. **seo-images** — Image optimization
7. **seo-sitemap** — Sitemap analysis and generation
8. **seo-geo** — AI Overviews / GEO optimization
9. **seo-plan** — Strategic planning with templates
10. **seo-programmatic** — Programmatic SEO analysis and planning
11. **seo-competitor-pages** — Competitor comparison page generation
12. **seo-hreflang** — Hreflang/i18n SEO audit and generation
## Parallel Analysis Areas
During a full audit, parallelize by analysis area:
- `seo-technical` — Crawlability, indexability, security, CWV
- `seo-content` — E-E-A-T, readability, thin content
- `seo-schema` — Detection, validation, generation
- `seo-sitemap` — Structure, coverage, quality gates
- `seo-images` — Image optimization and media hygiene