bmad-plus 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +612 -554
- package/README.md +557 -577
- package/osint-agent-package/agents/osint-investigator.md +12 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/SKILL.md +491 -482
- package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/dossier-template.md +126 -126
- package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/lawful-basis-record.md +48 -48
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/content-extraction.md +100 -100
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/gdpr-osint.md +48 -48
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/platforms.md +130 -130
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/psychoprofile.md +69 -69
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/tools.md +281 -281
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/mcp-client.py +136 -136
- package/package.json +104 -91
- package/readme-international/README.de.md +596 -589
- package/readme-international/README.es.md +613 -606
- package/readme-international/README.fr.md +611 -604
- package/src/bmad-plus/agents/agent-shadow/SKILL.md +18 -0
- package/src/bmad-plus/data/role-triggers.yaml +52 -0
- package/src/bmad-plus/module.yaml +283 -283
- package/src/bmad-plus/packs/pack-animated/animated-website-agent.md +325 -325
- package/src/bmad-plus/packs/pack-animated/templates/animated-website-workflow.md +55 -55
- package/src/bmad-plus/packs/pack-backup/backup-agent.md +71 -71
- package/src/bmad-plus/packs/pack-backup/templates/backup-workflow.md +51 -51
- package/src/bmad-plus/packs/pack-dev-studio/README.md +162 -162
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/analyst-agent.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/document-project.md +61 -61
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/domain-research.md +95 -95
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/market-research.md +95 -95
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/prfaq.md +134 -134
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/product-brief.md +80 -80
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/tech-writer-agent.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/technical-research.md +95 -95
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/architect-agent.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-architecture.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-epics-stories.md +92 -92
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/generate-project-context.md +80 -80
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/implementation-readiness.md +90 -90
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01-init.md +153 -153
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01b-continue.md +173 -173
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-02-context.md +224 -224
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-03-starter.md +329 -329
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-04-decisions.md +318 -318
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-05-patterns.md +359 -359
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-06-structure.md +379 -379
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-07-validation.md +361 -361
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-08-complete.md +81 -81
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/checkpoint-preview.md +67 -67
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-01-gather-context.md +85 -85
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-02-review.md +35 -35
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +49 -49
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-04-present.md +131 -131
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +89 -89
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/correct-course.md +300 -300
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +428 -428
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-agent.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +80 -80
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +484 -484
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/investigate.md +193 -193
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/qa-e2e-tests.md +175 -175
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/quick-dev.md +110 -110
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/retrospective.md +1511 -1511
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-planning.md +298 -298
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-status.md +296 -296
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-prd.md +29 -29
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-ux-design.md +74 -74
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/edit-prd.md +29 -29
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/pm-agent.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/prd.md +89 -89
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/ux-designer-agent.md +73 -73
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/validate-prd.md +29 -29
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/advanced-elicitation.md +141 -141
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/adversarial-review.md +37 -37
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/bmad-help.md +75 -75
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/brainstorming.md +6 -6
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/customize.md +110 -110
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/distillator.md +176 -176
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/edge-case-hunter.md +67 -67
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-prose.md +86 -86
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-structure.md +179 -179
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/index-docs.md +66 -66
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/party-mode.md +127 -127
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/shard-doc.md +105 -105
- package/src/bmad-plus/packs/pack-dev-studio/dev-studio-orchestrator.md +120 -120
- package/src/bmad-plus/packs/pack-dev-studio/shared/architecture-decision-template.md +12 -12
- package/src/bmad-plus/packs/pack-dev-studio/shared/bwml-spec.md +328 -328
- package/src/bmad-plus/packs/pack-dev-studio/shared/module-help.csv +32 -32
- package/src/bmad-plus/packs/pack-dev-studio/upstream-sync.yaml +81 -81
- package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +14 -0
- package/src/bmad-plus/packs/pack-seo/SKILL.md +171 -171
- package/src/bmad-plus/packs/pack-seo/checklist.md +140 -140
- package/src/bmad-plus/packs/pack-seo/pagespeed-playbook.md +320 -320
- package/src/bmad-plus/packs/pack-seo/ref/audit-schema.json +187 -187
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +87 -87
- package/src/bmad-plus/packs/pack-seo/ref/eeat-criteria.md +123 -123
- package/src/bmad-plus/packs/pack-seo/ref/geo-signals.md +167 -167
- package/src/bmad-plus/packs/pack-seo/ref/hreflang-rules.md +153 -153
- package/src/bmad-plus/packs/pack-seo/ref/quality-gates.md +133 -133
- package/src/bmad-plus/packs/pack-seo/ref/schema-catalog.md +91 -91
- package/src/bmad-plus/packs/pack-seo/ref/schema-templates.json +356 -356
- package/src/bmad-plus/packs/pack-seo/requirements.txt +17 -0
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +456 -0
- package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +359 -0
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +304 -0
- package/src/bmad-plus/packs/pack-seo/scripts/seo_parse.py +255 -0
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +410 -0
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +202 -0
- package/src/bmad-plus/packs/pack-seo/seo-chief.md +294 -294
- package/src/bmad-plus/packs/pack-seo/seo-judge.md +241 -241
- package/src/bmad-plus/packs/pack-seo/seo-scout.md +171 -171
- package/src/bmad-plus/packs/pack-seo/templates/seo-audit-workflow.md +241 -241
- package/src/bmad-plus/packs/pack-shield/README.md +6 -6
- package/src/bmad-plus/packs/pack-shield/SKILL.md +2 -2
- package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/csrd-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/section508-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/wcag-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/ai-governance/eu-ai-act-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/ai-governance/iso42001-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/ai-governance/nist-ai-rmf-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/cis-controls-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/ism-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/iso27001-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nis2-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nist-800-53-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nist-csf-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/defense-export/cmmc-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/defense-export/ear-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/defense-export/itar-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/defense-export/tsa-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/dora-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/fedramp-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/hipaa-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/pci-dss-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/soc2-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/swift-csp-agent.md +11 -11
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +1 -1
- package/tools/build/check-counts.js +628 -0
- package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
- package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
- package/tools/build/generated-adapters/AGENTS.md +1 -1
- package/tools/build/generated-adapters/CLAUDE.md +1 -1
- package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
- package/tools/build/generated-adapters/GEMINI.md +1 -1
- package/tools/cli/commands/autoconfig.js +470 -472
- package/tools/cli/commands/doctor.js +233 -233
- package/tools/cli/commands/install.js +598 -495
- package/tools/cli/commands/memory-journal-cmd.js +311 -311
- package/tools/cli/commands/memory.js +195 -195
- package/tools/cli/commands/scan.js +348 -364
- package/tools/cli/commands/uninstall.js +101 -101
- package/tools/cli/commands/update.js +134 -134
- package/tools/cli/i18n.js +845 -845
- package/tools/cli/lib/README-memory-journal.md +125 -125
- package/tools/cli/lib/ide-config.js +267 -259
- package/tools/cli/lib/memory-init.js +204 -113
- package/tools/cli/lib/packs.js +209 -209
- package/tools/cli/lib/path-hash.js +39 -0
- package/tools/cli/lib/python-provision.js +508 -508
- package/tools/cli/lib/stack-detect.js +102 -102
- package/tools/cli/lib/validate.js +50 -50
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SEO Parse — HTML parser for SEO element extraction.
|
|
4
|
+
|
|
5
|
+
Extracts: title, meta tags, canonicals, headings, images, links (internal/external),
|
|
6
|
+
schema (JSON-LD), Open Graph, Twitter Cards, hreflang, word count, text/code ratio.
|
|
7
|
+
|
|
8
|
+
Author: Laurent Rochetta
|
|
9
|
+
License: MIT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
from urllib.parse import urljoin, urlparse
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from bs4 import BeautifulSoup
|
|
22
|
+
except ImportError:
|
|
23
|
+
print("Error: beautifulsoup4 required. Install: pip install beautifulsoup4", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
# Use lxml if available for speed, fallback to html.parser
|
|
27
|
+
try:
|
|
28
|
+
import lxml # noqa: F401
|
|
29
|
+
HTML_PARSER = "lxml"
|
|
30
|
+
except ImportError:
|
|
31
|
+
HTML_PARSER = "html.parser"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_html(html: str, base_url: Optional[str] = None) -> dict:
|
|
35
|
+
"""
|
|
36
|
+
Parse HTML and extract all SEO-relevant elements.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
html: Raw HTML content
|
|
40
|
+
base_url: Base URL for resolving relative links
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Comprehensive dictionary of SEO data
|
|
44
|
+
"""
|
|
45
|
+
soup = BeautifulSoup(html, HTML_PARSER)
|
|
46
|
+
|
|
47
|
+
result = {
|
|
48
|
+
"title": None,
|
|
49
|
+
"title_length": 0,
|
|
50
|
+
"meta_description": None,
|
|
51
|
+
"meta_description_length": 0,
|
|
52
|
+
"meta_robots": None,
|
|
53
|
+
"meta_viewport": None,
|
|
54
|
+
"canonical": None,
|
|
55
|
+
"headings": {"h1": [], "h2": [], "h3": [], "h4": []},
|
|
56
|
+
"images": [],
|
|
57
|
+
"links": {"internal": [], "external": [], "broken_candidates": []},
|
|
58
|
+
"schema_blocks": [],
|
|
59
|
+
"open_graph": {},
|
|
60
|
+
"twitter_card": {},
|
|
61
|
+
"hreflang": [],
|
|
62
|
+
"word_count": 0,
|
|
63
|
+
"html_size_bytes": len(html.encode("utf-8")),
|
|
64
|
+
"text_ratio": 0.0,
|
|
65
|
+
"has_lang_attr": False,
|
|
66
|
+
"lang": None,
|
|
67
|
+
"scripts_count": 0,
|
|
68
|
+
"stylesheets_count": 0,
|
|
69
|
+
"dom_depth_estimate": 0,
|
|
70
|
+
"security_headers_hints": {},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# ── Title ──
|
|
74
|
+
title_tag = soup.find("title")
|
|
75
|
+
if title_tag:
|
|
76
|
+
result["title"] = title_tag.get_text(strip=True)
|
|
77
|
+
result["title_length"] = len(result["title"])
|
|
78
|
+
|
|
79
|
+
# ── Meta Tags ──
|
|
80
|
+
for meta in soup.find_all("meta"):
|
|
81
|
+
name = (meta.get("name") or "").lower()
|
|
82
|
+
property_attr = (meta.get("property") or "").lower()
|
|
83
|
+
content = meta.get("content", "")
|
|
84
|
+
|
|
85
|
+
if name == "description":
|
|
86
|
+
result["meta_description"] = content
|
|
87
|
+
result["meta_description_length"] = len(content)
|
|
88
|
+
elif name == "robots":
|
|
89
|
+
result["meta_robots"] = content
|
|
90
|
+
elif name == "viewport":
|
|
91
|
+
result["meta_viewport"] = content
|
|
92
|
+
|
|
93
|
+
# Open Graph
|
|
94
|
+
if property_attr.startswith("og:"):
|
|
95
|
+
result["open_graph"][property_attr] = content
|
|
96
|
+
|
|
97
|
+
# Twitter Card
|
|
98
|
+
if name.startswith("twitter:"):
|
|
99
|
+
result["twitter_card"][name] = content
|
|
100
|
+
|
|
101
|
+
# ── Language ──
|
|
102
|
+
html_tag = soup.find("html")
|
|
103
|
+
if html_tag and html_tag.get("lang"):
|
|
104
|
+
result["has_lang_attr"] = True
|
|
105
|
+
result["lang"] = html_tag.get("lang")
|
|
106
|
+
|
|
107
|
+
# ── Canonical ──
|
|
108
|
+
canonical = soup.find("link", rel="canonical")
|
|
109
|
+
if canonical:
|
|
110
|
+
result["canonical"] = canonical.get("href")
|
|
111
|
+
|
|
112
|
+
# ── Hreflang ──
|
|
113
|
+
for link in soup.find_all("link", rel="alternate"):
|
|
114
|
+
hreflang = link.get("hreflang")
|
|
115
|
+
if hreflang:
|
|
116
|
+
result["hreflang"].append({
|
|
117
|
+
"lang": hreflang,
|
|
118
|
+
"href": link.get("href"),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
# ── Headings ──
|
|
122
|
+
for level in ["h1", "h2", "h3", "h4"]:
|
|
123
|
+
for tag in soup.find_all(level):
|
|
124
|
+
text = tag.get_text(strip=True)
|
|
125
|
+
if text:
|
|
126
|
+
result["headings"][level].append(text)
|
|
127
|
+
|
|
128
|
+
# ── Images ──
|
|
129
|
+
for img in soup.find_all("img"):
|
|
130
|
+
src = img.get("src", "")
|
|
131
|
+
if base_url and src:
|
|
132
|
+
src = urljoin(base_url, src)
|
|
133
|
+
|
|
134
|
+
has_alt = img.get("alt") is not None
|
|
135
|
+
alt_text = img.get("alt", "")
|
|
136
|
+
has_dimensions = bool(img.get("width") and img.get("height"))
|
|
137
|
+
|
|
138
|
+
result["images"].append({
|
|
139
|
+
"src": src,
|
|
140
|
+
"alt": alt_text,
|
|
141
|
+
"has_alt": has_alt,
|
|
142
|
+
"alt_empty": has_alt and alt_text.strip() == "",
|
|
143
|
+
"width": img.get("width"),
|
|
144
|
+
"height": img.get("height"),
|
|
145
|
+
"has_dimensions": has_dimensions,
|
|
146
|
+
"loading": img.get("loading"),
|
|
147
|
+
"srcset": img.get("srcset") is not None,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
# ── Links ──
|
|
151
|
+
if base_url:
|
|
152
|
+
base_domain = urlparse(base_url).netloc
|
|
153
|
+
|
|
154
|
+
for a in soup.find_all("a", href=True):
|
|
155
|
+
href = a.get("href", "")
|
|
156
|
+
if not href or href.startswith("#") or href.startswith("javascript:"):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
full_url = urljoin(base_url, href)
|
|
160
|
+
parsed = urlparse(full_url)
|
|
161
|
+
|
|
162
|
+
link_data = {
|
|
163
|
+
"href": full_url,
|
|
164
|
+
"text": a.get_text(strip=True)[:100],
|
|
165
|
+
"rel": a.get("rel", []),
|
|
166
|
+
"is_nofollow": "nofollow" in (a.get("rel") or []),
|
|
167
|
+
"target": a.get("target"),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if parsed.netloc == base_domain:
|
|
171
|
+
result["links"]["internal"].append(link_data)
|
|
172
|
+
else:
|
|
173
|
+
result["links"]["external"].append(link_data)
|
|
174
|
+
|
|
175
|
+
# ── Schema (JSON-LD) ──
|
|
176
|
+
for script in soup.find_all("script", type="application/ld+json"):
|
|
177
|
+
try:
|
|
178
|
+
schema_data = json.loads(script.string)
|
|
179
|
+
if isinstance(schema_data, dict):
|
|
180
|
+
result["schema_blocks"].append({
|
|
181
|
+
"type": schema_data.get("@type", "unknown"),
|
|
182
|
+
"data": schema_data,
|
|
183
|
+
})
|
|
184
|
+
elif isinstance(schema_data, list):
|
|
185
|
+
for item in schema_data:
|
|
186
|
+
if isinstance(item, dict):
|
|
187
|
+
result["schema_blocks"].append({
|
|
188
|
+
"type": item.get("@type", "unknown"),
|
|
189
|
+
"data": item,
|
|
190
|
+
})
|
|
191
|
+
except (json.JSONDecodeError, TypeError):
|
|
192
|
+
result["schema_blocks"].append({"type": "PARSE_ERROR", "data": None})
|
|
193
|
+
|
|
194
|
+
# ── Resource Counts ──
|
|
195
|
+
result["scripts_count"] = len(soup.find_all("script"))
|
|
196
|
+
result["stylesheets_count"] = len(soup.find_all("link", rel="stylesheet"))
|
|
197
|
+
|
|
198
|
+
# ── Word Count & Text Ratio ──
|
|
199
|
+
text_soup = BeautifulSoup(html, HTML_PARSER)
|
|
200
|
+
for element in text_soup(["script", "style", "nav", "footer", "header", "noscript"]):
|
|
201
|
+
element.decompose()
|
|
202
|
+
|
|
203
|
+
visible_text = text_soup.get_text(separator=" ", strip=True)
|
|
204
|
+
words = re.findall(r"\b\w+\b", visible_text)
|
|
205
|
+
result["word_count"] = len(words)
|
|
206
|
+
|
|
207
|
+
text_bytes = len(visible_text.encode("utf-8"))
|
|
208
|
+
if result["html_size_bytes"] > 0:
|
|
209
|
+
result["text_ratio"] = round(text_bytes / result["html_size_bytes"], 3)
|
|
210
|
+
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── CLI ────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def main():
|
|
217
|
+
parser = argparse.ArgumentParser(
|
|
218
|
+
description="SEO Parse — HTML parser for SEO analysis (BMAD+ SEO Engine)"
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument("file", nargs="?", help="HTML file to parse")
|
|
221
|
+
parser.add_argument("--url", "-u", help="Base URL for resolving relative links")
|
|
222
|
+
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
|
|
223
|
+
|
|
224
|
+
args = parser.parse_args()
|
|
225
|
+
|
|
226
|
+
if args.file:
|
|
227
|
+
real_path = os.path.realpath(args.file)
|
|
228
|
+
if not os.path.isfile(real_path):
|
|
229
|
+
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
with open(real_path, "r", encoding="utf-8") as f:
|
|
232
|
+
html = f.read()
|
|
233
|
+
else:
|
|
234
|
+
html = sys.stdin.read()
|
|
235
|
+
|
|
236
|
+
result = parse_html(html, args.url)
|
|
237
|
+
|
|
238
|
+
if args.json:
|
|
239
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
240
|
+
else:
|
|
241
|
+
print(f"Title: {result['title']} ({result['title_length']} chars)")
|
|
242
|
+
print(f"Meta Description: {result['meta_description'][:80] + '...' if result['meta_description'] and len(result['meta_description']) > 80 else result['meta_description']}")
|
|
243
|
+
print(f"Canonical: {result['canonical']}")
|
|
244
|
+
print(f"Language: {result['lang']}")
|
|
245
|
+
print(f"H1: {len(result['headings']['h1'])} | H2: {len(result['headings']['h2'])} | H3: {len(result['headings']['h3'])}")
|
|
246
|
+
print(f"Images: {len(result['images'])} (missing alt: {sum(1 for i in result['images'] if not i['has_alt'])})")
|
|
247
|
+
print(f"Internal Links: {len(result['links']['internal'])} | External: {len(result['links']['external'])}")
|
|
248
|
+
print(f"Schema Blocks: {len(result['schema_blocks'])} ({', '.join(s['type'] for s in result['schema_blocks'])})")
|
|
249
|
+
print(f"Word Count: {result['word_count']:,}")
|
|
250
|
+
print(f"Text/HTML Ratio: {result['text_ratio']:.1%}")
|
|
251
|
+
print(f"Scripts: {result['scripts_count']} | Stylesheets: {result['stylesheets_count']}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
main()
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SEO Report — Professional HTML audit report generator.
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- Single-file HTML with inline CSS (no external deps)
|
|
7
|
+
- SVG radar chart for score visualization
|
|
8
|
+
- Color-coded issue cards (Critical/High/Medium/Low)
|
|
9
|
+
- Quick Wins section
|
|
10
|
+
- Print-friendly (@media print)
|
|
11
|
+
- Responsive (mobile-readable)
|
|
12
|
+
|
|
13
|
+
Author: Laurent Rochetta
|
|
14
|
+
License: MIT
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import html
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_radar_svg(scores: dict, size: int = 300) -> str:
|
|
27
|
+
"""Generate an SVG radar chart for the 7 score categories."""
|
|
28
|
+
categories = list(scores.keys())
|
|
29
|
+
values = list(scores.values())
|
|
30
|
+
n = len(categories)
|
|
31
|
+
|
|
32
|
+
if n == 0:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
cx, cy = size // 2, size // 2
|
|
36
|
+
radius = size // 2 - 40
|
|
37
|
+
|
|
38
|
+
# Short labels for display
|
|
39
|
+
short_labels = {
|
|
40
|
+
"technical": "Tech",
|
|
41
|
+
"content_eeat": "E-E-A-T",
|
|
42
|
+
"on_page": "On-Page",
|
|
43
|
+
"schema": "Schema",
|
|
44
|
+
"performance": "Perf",
|
|
45
|
+
"ai_readiness": "AI/GEO",
|
|
46
|
+
"images": "Images",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def point(angle_deg, r):
|
|
50
|
+
angle_rad = math.radians(angle_deg - 90)
|
|
51
|
+
x = cx + r * math.cos(angle_rad)
|
|
52
|
+
y = cy + r * math.sin(angle_rad)
|
|
53
|
+
return x, y
|
|
54
|
+
|
|
55
|
+
svg_parts = [f'<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg" style="max-width:{size}px;margin:auto;display:block;">']
|
|
56
|
+
|
|
57
|
+
# Background circles
|
|
58
|
+
for pct in [25, 50, 75, 100]:
|
|
59
|
+
r = radius * pct / 100
|
|
60
|
+
svg_parts.append(f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#e2e8f0" stroke-width="1" opacity="0.5"/>')
|
|
61
|
+
|
|
62
|
+
# Axis lines + labels
|
|
63
|
+
for i in range(n):
|
|
64
|
+
angle = (360 / n) * i
|
|
65
|
+
x1, y1 = point(angle, 0)
|
|
66
|
+
x2, y2 = point(angle, radius)
|
|
67
|
+
svg_parts.append(f'<line x1="{cx}" y1="{cy}" x2="{x2}" y2="{y2}" stroke="#e2e8f0" stroke-width="1"/>')
|
|
68
|
+
|
|
69
|
+
lx, ly = point(angle, radius + 20)
|
|
70
|
+
label = short_labels.get(categories[i], categories[i][:6])
|
|
71
|
+
svg_parts.append(f'<text x="{lx}" y="{ly}" text-anchor="middle" font-size="11" fill="#64748b" font-family="Inter,sans-serif">{label}</text>')
|
|
72
|
+
|
|
73
|
+
# Data polygon
|
|
74
|
+
data_points = []
|
|
75
|
+
for i in range(n):
|
|
76
|
+
angle = (360 / n) * i
|
|
77
|
+
r = radius * min(values[i], 100) / 100
|
|
78
|
+
x, y = point(angle, r)
|
|
79
|
+
data_points.append(f"{x},{y}")
|
|
80
|
+
|
|
81
|
+
poly = " ".join(data_points)
|
|
82
|
+
svg_parts.append(f'<polygon points="{poly}" fill="rgba(59,130,246,0.2)" stroke="#3b82f6" stroke-width="2"/>')
|
|
83
|
+
|
|
84
|
+
# Data points
|
|
85
|
+
for i in range(n):
|
|
86
|
+
angle = (360 / n) * i
|
|
87
|
+
r = radius * min(values[i], 100) / 100
|
|
88
|
+
x, y = point(angle, r)
|
|
89
|
+
color = "#22c55e" if values[i] >= 80 else "#f59e0b" if values[i] >= 50 else "#ef4444"
|
|
90
|
+
svg_parts.append(f'<circle cx="{x}" cy="{y}" r="4" fill="{color}" stroke="white" stroke-width="2"/>')
|
|
91
|
+
|
|
92
|
+
svg_parts.append('</svg>')
|
|
93
|
+
return "\n".join(svg_parts)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def severity_color(severity: str) -> str:
|
|
97
|
+
"""Get color for severity level."""
|
|
98
|
+
return {
|
|
99
|
+
"critical": "#ef4444",
|
|
100
|
+
"high": "#f97316",
|
|
101
|
+
"medium": "#f59e0b",
|
|
102
|
+
"low": "#22c55e",
|
|
103
|
+
}.get(severity, "#64748b")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def severity_icon(severity: str) -> str:
|
|
107
|
+
return {
|
|
108
|
+
"critical": "🔴",
|
|
109
|
+
"high": "🟠",
|
|
110
|
+
"medium": "🟡",
|
|
111
|
+
"low": "🟢",
|
|
112
|
+
}.get(severity, "⚪")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def score_color(score: int) -> str:
|
|
116
|
+
if score >= 90:
|
|
117
|
+
return "#22c55e"
|
|
118
|
+
elif score >= 70:
|
|
119
|
+
return "#84cc16"
|
|
120
|
+
elif score >= 50:
|
|
121
|
+
return "#f59e0b"
|
|
122
|
+
else:
|
|
123
|
+
return "#ef4444"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def generate_html_report(audit_data: dict) -> str:
|
|
127
|
+
"""Generate a complete HTML report from audit JSON data."""
|
|
128
|
+
|
|
129
|
+
domain = audit_data.get("domain", "Unknown")
|
|
130
|
+
timestamp = audit_data.get("timestamp", datetime.now().isoformat())
|
|
131
|
+
total_score = audit_data.get("score", {}).get("total", 0)
|
|
132
|
+
|
|
133
|
+
# Escaped copies of externally-sourced content for safe HTML interpolation
|
|
134
|
+
domain_esc = html.escape(str(domain))
|
|
135
|
+
timestamp_esc = html.escape(str(timestamp)[:10])
|
|
136
|
+
total_score_esc = html.escape(str(total_score))
|
|
137
|
+
categories = audit_data.get("score", {}).get("categories", {})
|
|
138
|
+
issues = audit_data.get("issues", [])
|
|
139
|
+
pages = audit_data.get("pages", [])
|
|
140
|
+
|
|
141
|
+
# Generate radar chart
|
|
142
|
+
radar_svg = generate_radar_svg(categories) if categories else ""
|
|
143
|
+
|
|
144
|
+
# Sort issues by severity
|
|
145
|
+
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
146
|
+
sorted_issues = sorted(issues, key=lambda x: severity_order.get(x.get("severity", "low"), 4))
|
|
147
|
+
|
|
148
|
+
# Count by severity
|
|
149
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
150
|
+
for issue in issues:
|
|
151
|
+
sev = issue.get("severity", "low")
|
|
152
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
153
|
+
|
|
154
|
+
# Quick wins
|
|
155
|
+
quick_wins = [i for i in issues if i.get("quick_win", False)][:5]
|
|
156
|
+
|
|
157
|
+
# Build issue cards HTML
|
|
158
|
+
issue_cards = ""
|
|
159
|
+
for issue in sorted_issues:
|
|
160
|
+
sev = issue.get("severity", "low")
|
|
161
|
+
fix_html = ""
|
|
162
|
+
if issue.get("fix"):
|
|
163
|
+
fix_html = f'<div class="fix-block"><strong>Fix:</strong><pre><code>{html.escape(str(issue["fix"]))}</code></pre></div>'
|
|
164
|
+
|
|
165
|
+
issue_cards += f'''
|
|
166
|
+
<div class="issue-card" style="border-left: 4px solid {severity_color(sev)}">
|
|
167
|
+
<div class="issue-header">
|
|
168
|
+
<span class="severity-badge" style="background:{severity_color(sev)}">{html.escape(str(sev)).upper()}</span>
|
|
169
|
+
<span class="issue-category">{html.escape(str(issue.get("category", "")))}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<h4>{html.escape(str(issue.get("title", "")))}</h4>
|
|
172
|
+
<p>{html.escape(str(issue.get("description", "")))}</p>
|
|
173
|
+
{fix_html}
|
|
174
|
+
</div>'''
|
|
175
|
+
|
|
176
|
+
# Quick wins HTML
|
|
177
|
+
qw_html = ""
|
|
178
|
+
if quick_wins:
|
|
179
|
+
qw_items = ""
|
|
180
|
+
for qw in quick_wins:
|
|
181
|
+
qw_items += f'<li>{severity_icon(qw.get("severity", ""))} {html.escape(str(qw.get("title", "")))}</li>'
|
|
182
|
+
qw_html = f'<div class="quick-wins"><h3>⚡ Quick Wins</h3><ul>{qw_items}</ul></div>'
|
|
183
|
+
|
|
184
|
+
# Category scores table
|
|
185
|
+
cat_rows = ""
|
|
186
|
+
for cat, score in categories.items():
|
|
187
|
+
cat_name = html.escape(str(cat).replace("_", " ").title())
|
|
188
|
+
score_esc = html.escape(str(score))
|
|
189
|
+
cat_rows += f'''
|
|
190
|
+
<tr>
|
|
191
|
+
<td>{cat_name}</td>
|
|
192
|
+
<td>
|
|
193
|
+
<div class="score-bar-bg">
|
|
194
|
+
<div class="score-bar" style="width:{score_esc}%;background:{score_color(score)}"></div>
|
|
195
|
+
</div>
|
|
196
|
+
</td>
|
|
197
|
+
<td style="color:{score_color(score)};font-weight:700">{score_esc}</td>
|
|
198
|
+
</tr>'''
|
|
199
|
+
|
|
200
|
+
html_out = f'''<!DOCTYPE html>
|
|
201
|
+
<html lang="en">
|
|
202
|
+
<head>
|
|
203
|
+
<meta charset="UTF-8">
|
|
204
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
205
|
+
<title>SEO Audit Report — {domain_esc}</title>
|
|
206
|
+
<style>
|
|
207
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
208
|
+
|
|
209
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
210
|
+
body {{
|
|
211
|
+
font-family: 'Inter', -apple-system, sans-serif;
|
|
212
|
+
background: #f8fafc;
|
|
213
|
+
color: #1e293b;
|
|
214
|
+
line-height: 1.6;
|
|
215
|
+
}}
|
|
216
|
+
.container {{ max-width: 900px; margin: 0 auto; padding: 2rem; }}
|
|
217
|
+
|
|
218
|
+
/* Header */
|
|
219
|
+
.header {{
|
|
220
|
+
background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%);
|
|
221
|
+
color: white;
|
|
222
|
+
padding: 3rem 2rem;
|
|
223
|
+
border-radius: 16px;
|
|
224
|
+
margin-bottom: 2rem;
|
|
225
|
+
text-align: center;
|
|
226
|
+
}}
|
|
227
|
+
.header h1 {{ font-size: 2rem; margin-bottom: 0.5rem; }}
|
|
228
|
+
.header .domain {{ font-size: 1.2rem; opacity: 0.8; }}
|
|
229
|
+
.header .date {{ font-size: 0.85rem; opacity: 0.6; margin-top: 0.5rem; }}
|
|
230
|
+
|
|
231
|
+
/* Score circle */
|
|
232
|
+
.score-hero {{
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
justify-content: center;
|
|
236
|
+
gap: 3rem;
|
|
237
|
+
margin: 2rem 0;
|
|
238
|
+
flex-wrap: wrap;
|
|
239
|
+
}}
|
|
240
|
+
.score-circle {{
|
|
241
|
+
width: 150px;
|
|
242
|
+
height: 150px;
|
|
243
|
+
border-radius: 50%;
|
|
244
|
+
display: flex;
|
|
245
|
+
flex-direction: column;
|
|
246
|
+
align-items: center;
|
|
247
|
+
justify-content: center;
|
|
248
|
+
border: 6px solid {score_color(total_score)};
|
|
249
|
+
background: white;
|
|
250
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
|
251
|
+
}}
|
|
252
|
+
.score-number {{ font-size: 3rem; font-weight: 700; color: {score_color(total_score)}; }}
|
|
253
|
+
.score-label {{ font-size: 0.75rem; text-transform: uppercase; color: #64748b; letter-spacing: 1px; }}
|
|
254
|
+
|
|
255
|
+
/* Summary cards */
|
|
256
|
+
.summary-grid {{
|
|
257
|
+
display: grid;
|
|
258
|
+
grid-template-columns: repeat(4, 1fr);
|
|
259
|
+
gap: 1rem;
|
|
260
|
+
margin-bottom: 2rem;
|
|
261
|
+
}}
|
|
262
|
+
.summary-card {{
|
|
263
|
+
background: white;
|
|
264
|
+
border-radius: 12px;
|
|
265
|
+
padding: 1.2rem;
|
|
266
|
+
text-align: center;
|
|
267
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
|
268
|
+
}}
|
|
269
|
+
.summary-card .count {{ font-size: 2rem; font-weight: 700; }}
|
|
270
|
+
.summary-card .label {{ font-size: 0.8rem; color: #64748b; }}
|
|
271
|
+
|
|
272
|
+
/* Sections */
|
|
273
|
+
.section {{ background: white; border-radius: 12px; padding: 2rem; margin-bottom: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }}
|
|
274
|
+
.section h2 {{ margin-bottom: 1rem; font-size: 1.3rem; }}
|
|
275
|
+
.section h3 {{ margin-bottom: 0.8rem; font-size: 1.1rem; }}
|
|
276
|
+
|
|
277
|
+
/* Score bars */
|
|
278
|
+
table {{ width: 100%; border-collapse: collapse; }}
|
|
279
|
+
td {{ padding: 0.6rem 0; }}
|
|
280
|
+
.score-bar-bg {{ width: 100%; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; margin: 0 1rem; }}
|
|
281
|
+
.score-bar {{ height: 100%; border-radius: 4px; transition: width 0.5s ease; }}
|
|
282
|
+
|
|
283
|
+
/* Issue cards */
|
|
284
|
+
.issue-card {{
|
|
285
|
+
border: 1px solid #e2e8f0;
|
|
286
|
+
border-radius: 8px;
|
|
287
|
+
padding: 1rem;
|
|
288
|
+
margin-bottom: 0.8rem;
|
|
289
|
+
}}
|
|
290
|
+
.issue-header {{ display: flex; gap: 0.5rem; margin-bottom: 0.3rem; align-items: center; }}
|
|
291
|
+
.severity-badge {{ color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }}
|
|
292
|
+
.issue-category {{ font-size: 0.8rem; color: #64748b; }}
|
|
293
|
+
.issue-card h4 {{ margin-bottom: 0.3rem; }}
|
|
294
|
+
.issue-card p {{ color: #475569; font-size: 0.9rem; }}
|
|
295
|
+
.fix-block {{ background: #f1f5f9; border-radius: 6px; padding: 0.8rem; margin-top: 0.5rem; }}
|
|
296
|
+
.fix-block pre {{ overflow-x: auto; font-size: 0.8rem; }}
|
|
297
|
+
|
|
298
|
+
/* Quick wins */
|
|
299
|
+
.quick-wins {{ background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }}
|
|
300
|
+
.quick-wins ul {{ list-style: none; padding: 0; }}
|
|
301
|
+
.quick-wins li {{ padding: 0.3rem 0; }}
|
|
302
|
+
|
|
303
|
+
/* Footer */
|
|
304
|
+
.footer {{ text-align: center; color: #94a3b8; font-size: 0.8rem; padding: 2rem 0; }}
|
|
305
|
+
|
|
306
|
+
/* Print */
|
|
307
|
+
@media print {{
|
|
308
|
+
body {{ background: white; }}
|
|
309
|
+
.container {{ max-width: 100%; padding: 0; }}
|
|
310
|
+
.header {{ break-after: avoid; }}
|
|
311
|
+
.section {{ break-inside: avoid; box-shadow: none; border: 1px solid #e2e8f0; }}
|
|
312
|
+
}}
|
|
313
|
+
|
|
314
|
+
/* Mobile */
|
|
315
|
+
@media (max-width: 640px) {{
|
|
316
|
+
.summary-grid {{ grid-template-columns: repeat(2, 1fr); }}
|
|
317
|
+
.score-hero {{ flex-direction: column; gap: 1.5rem; }}
|
|
318
|
+
}}
|
|
319
|
+
</style>
|
|
320
|
+
</head>
|
|
321
|
+
<body>
|
|
322
|
+
<div class="container">
|
|
323
|
+
<div class="header">
|
|
324
|
+
<h1>SEO Audit Report</h1>
|
|
325
|
+
<div class="domain">{domain_esc}</div>
|
|
326
|
+
<div class="date">{timestamp_esc}</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div class="score-hero">
|
|
330
|
+
<div class="score-circle">
|
|
331
|
+
<div class="score-number">{total_score_esc}</div>
|
|
332
|
+
<div class="score-label">SEO Score</div>
|
|
333
|
+
</div>
|
|
334
|
+
<div>
|
|
335
|
+
{radar_svg}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<div class="summary-grid">
|
|
340
|
+
<div class="summary-card">
|
|
341
|
+
<div class="count" style="color:#ef4444">{counts["critical"]}</div>
|
|
342
|
+
<div class="label">Critical</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="summary-card">
|
|
345
|
+
<div class="count" style="color:#f97316">{counts["high"]}</div>
|
|
346
|
+
<div class="label">High</div>
|
|
347
|
+
</div>
|
|
348
|
+
<div class="summary-card">
|
|
349
|
+
<div class="count" style="color:#f59e0b">{counts["medium"]}</div>
|
|
350
|
+
<div class="label">Medium</div>
|
|
351
|
+
</div>
|
|
352
|
+
<div class="summary-card">
|
|
353
|
+
<div class="count" style="color:#22c55e">{counts["low"]}</div>
|
|
354
|
+
<div class="label">Low</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{qw_html}
|
|
359
|
+
|
|
360
|
+
<div class="section">
|
|
361
|
+
<h2>📊 Category Scores</h2>
|
|
362
|
+
<table>{cat_rows}</table>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
<div class="section">
|
|
366
|
+
<h2>🔍 Issues ({len(issues)})</h2>
|
|
367
|
+
{issue_cards}
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<div class="footer">
|
|
371
|
+
Generated by BMAD+ SEO Engine v2.1 — By Laurent Rochetta
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</body>
|
|
375
|
+
</html>'''
|
|
376
|
+
|
|
377
|
+
return html_out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ── CLI ────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
def main():
|
|
383
|
+
parser = argparse.ArgumentParser(
|
|
384
|
+
description="SEO Report — HTML audit report generator (BMAD+ SEO Engine)"
|
|
385
|
+
)
|
|
386
|
+
parser.add_argument("input", help="Audit JSON file")
|
|
387
|
+
parser.add_argument("--output", "-o", default="seo-report.html", help="Output HTML file")
|
|
388
|
+
|
|
389
|
+
args = parser.parse_args()
|
|
390
|
+
|
|
391
|
+
if not os.path.isfile(args.input):
|
|
392
|
+
print(f"Error: File not found: {args.input}", file=sys.stderr)
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
|
|
395
|
+
with open(args.input, "r", encoding="utf-8") as f:
|
|
396
|
+
audit_data = json.load(f)
|
|
397
|
+
|
|
398
|
+
html = generate_html_report(audit_data)
|
|
399
|
+
|
|
400
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
401
|
+
f.write(html)
|
|
402
|
+
|
|
403
|
+
print(f"✅ Report generated: {args.output}", file=sys.stderr)
|
|
404
|
+
print(f" Domain: {audit_data.get('domain', 'Unknown')}")
|
|
405
|
+
print(f" Score: {audit_data.get('score', {}).get('total', 0)}/100")
|
|
406
|
+
print(f" Issues: {len(audit_data.get('issues', []))}")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
main()
|