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.
Files changed (160) hide show
  1. package/CHANGELOG.md +612 -554
  2. package/README.md +557 -577
  3. package/osint-agent-package/agents/osint-investigator.md +12 -0
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/SKILL.md +491 -482
  5. package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/dossier-template.md +126 -126
  6. package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/lawful-basis-record.md +48 -48
  7. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/content-extraction.md +100 -100
  8. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/gdpr-osint.md +48 -48
  9. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/platforms.md +130 -130
  10. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/psychoprofile.md +69 -69
  11. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/tools.md +281 -281
  12. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/mcp-client.py +136 -136
  13. package/package.json +104 -91
  14. package/readme-international/README.de.md +596 -589
  15. package/readme-international/README.es.md +613 -606
  16. package/readme-international/README.fr.md +611 -604
  17. package/src/bmad-plus/agents/agent-shadow/SKILL.md +18 -0
  18. package/src/bmad-plus/data/role-triggers.yaml +52 -0
  19. package/src/bmad-plus/module.yaml +283 -283
  20. package/src/bmad-plus/packs/pack-animated/animated-website-agent.md +325 -325
  21. package/src/bmad-plus/packs/pack-animated/templates/animated-website-workflow.md +55 -55
  22. package/src/bmad-plus/packs/pack-backup/backup-agent.md +71 -71
  23. package/src/bmad-plus/packs/pack-backup/templates/backup-workflow.md +51 -51
  24. package/src/bmad-plus/packs/pack-dev-studio/README.md +162 -162
  25. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/analyst-agent.md +73 -73
  26. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/document-project.md +61 -61
  27. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/domain-research.md +95 -95
  28. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/market-research.md +95 -95
  29. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/prfaq.md +134 -134
  30. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/product-brief.md +80 -80
  31. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/tech-writer-agent.md +73 -73
  32. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/technical-research.md +95 -95
  33. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/architect-agent.md +73 -73
  34. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-architecture.md +73 -73
  35. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-epics-stories.md +92 -92
  36. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/generate-project-context.md +80 -80
  37. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/implementation-readiness.md +90 -90
  38. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01-init.md +153 -153
  39. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01b-continue.md +173 -173
  40. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-02-context.md +224 -224
  41. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-03-starter.md +329 -329
  42. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-04-decisions.md +318 -318
  43. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-05-patterns.md +359 -359
  44. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-06-structure.md +379 -379
  45. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-07-validation.md +361 -361
  46. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-08-complete.md +81 -81
  47. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/checkpoint-preview.md +67 -67
  48. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-01-gather-context.md +85 -85
  49. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-02-review.md +35 -35
  50. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +49 -49
  51. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-04-present.md +131 -131
  52. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +89 -89
  53. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/correct-course.md +300 -300
  54. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +428 -428
  55. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-agent.md +73 -73
  56. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +80 -80
  57. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +484 -484
  58. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/investigate.md +193 -193
  59. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/qa-e2e-tests.md +175 -175
  60. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/quick-dev.md +110 -110
  61. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/retrospective.md +1511 -1511
  62. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-planning.md +298 -298
  63. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-status.md +296 -296
  64. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-prd.md +29 -29
  65. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-ux-design.md +74 -74
  66. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/edit-prd.md +29 -29
  67. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/pm-agent.md +73 -73
  68. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/prd.md +89 -89
  69. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/ux-designer-agent.md +73 -73
  70. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/validate-prd.md +29 -29
  71. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/advanced-elicitation.md +141 -141
  72. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/adversarial-review.md +37 -37
  73. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/bmad-help.md +75 -75
  74. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/brainstorming.md +6 -6
  75. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/customize.md +110 -110
  76. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/distillator.md +176 -176
  77. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/edge-case-hunter.md +67 -67
  78. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-prose.md +86 -86
  79. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-structure.md +179 -179
  80. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/index-docs.md +66 -66
  81. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/party-mode.md +127 -127
  82. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/shard-doc.md +105 -105
  83. package/src/bmad-plus/packs/pack-dev-studio/dev-studio-orchestrator.md +120 -120
  84. package/src/bmad-plus/packs/pack-dev-studio/shared/architecture-decision-template.md +12 -12
  85. package/src/bmad-plus/packs/pack-dev-studio/shared/bwml-spec.md +328 -328
  86. package/src/bmad-plus/packs/pack-dev-studio/shared/module-help.csv +32 -32
  87. package/src/bmad-plus/packs/pack-dev-studio/upstream-sync.yaml +81 -81
  88. package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +14 -0
  89. package/src/bmad-plus/packs/pack-seo/SKILL.md +171 -171
  90. package/src/bmad-plus/packs/pack-seo/checklist.md +140 -140
  91. package/src/bmad-plus/packs/pack-seo/pagespeed-playbook.md +320 -320
  92. package/src/bmad-plus/packs/pack-seo/ref/audit-schema.json +187 -187
  93. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +87 -87
  94. package/src/bmad-plus/packs/pack-seo/ref/eeat-criteria.md +123 -123
  95. package/src/bmad-plus/packs/pack-seo/ref/geo-signals.md +167 -167
  96. package/src/bmad-plus/packs/pack-seo/ref/hreflang-rules.md +153 -153
  97. package/src/bmad-plus/packs/pack-seo/ref/quality-gates.md +133 -133
  98. package/src/bmad-plus/packs/pack-seo/ref/schema-catalog.md +91 -91
  99. package/src/bmad-plus/packs/pack-seo/ref/schema-templates.json +356 -356
  100. package/src/bmad-plus/packs/pack-seo/requirements.txt +17 -0
  101. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +456 -0
  102. package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +359 -0
  103. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +304 -0
  104. package/src/bmad-plus/packs/pack-seo/scripts/seo_parse.py +255 -0
  105. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +410 -0
  106. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +202 -0
  107. package/src/bmad-plus/packs/pack-seo/seo-chief.md +294 -294
  108. package/src/bmad-plus/packs/pack-seo/seo-judge.md +241 -241
  109. package/src/bmad-plus/packs/pack-seo/seo-scout.md +171 -171
  110. package/src/bmad-plus/packs/pack-seo/templates/seo-audit-workflow.md +241 -241
  111. package/src/bmad-plus/packs/pack-shield/README.md +6 -6
  112. package/src/bmad-plus/packs/pack-shield/SKILL.md +2 -2
  113. package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/csrd-agent.md +11 -11
  114. package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/section508-agent.md +11 -11
  115. package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/wcag-agent.md +11 -11
  116. package/src/bmad-plus/packs/pack-shield/categories/ai-governance/eu-ai-act-agent.md +11 -11
  117. package/src/bmad-plus/packs/pack-shield/categories/ai-governance/iso42001-agent.md +11 -11
  118. package/src/bmad-plus/packs/pack-shield/categories/ai-governance/nist-ai-rmf-agent.md +11 -11
  119. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/cis-controls-agent.md +11 -11
  120. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/ism-agent.md +11 -11
  121. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/iso27001-agent.md +11 -11
  122. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nis2-agent.md +11 -11
  123. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nist-800-53-agent.md +11 -11
  124. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nist-csf-agent.md +11 -11
  125. package/src/bmad-plus/packs/pack-shield/categories/defense-export/cmmc-agent.md +11 -11
  126. package/src/bmad-plus/packs/pack-shield/categories/defense-export/ear-agent.md +11 -11
  127. package/src/bmad-plus/packs/pack-shield/categories/defense-export/itar-agent.md +11 -11
  128. package/src/bmad-plus/packs/pack-shield/categories/defense-export/tsa-agent.md +11 -11
  129. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/dora-agent.md +11 -11
  130. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/fedramp-agent.md +11 -11
  131. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/hipaa-agent.md +11 -11
  132. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/pci-dss-agent.md +11 -11
  133. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/soc2-agent.md +11 -11
  134. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/swift-csp-agent.md +11 -11
  135. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +1 -1
  136. package/tools/build/check-counts.js +628 -0
  137. package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
  138. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
  139. package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
  140. package/tools/build/generated-adapters/AGENTS.md +1 -1
  141. package/tools/build/generated-adapters/CLAUDE.md +1 -1
  142. package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
  143. package/tools/build/generated-adapters/GEMINI.md +1 -1
  144. package/tools/cli/commands/autoconfig.js +470 -472
  145. package/tools/cli/commands/doctor.js +233 -233
  146. package/tools/cli/commands/install.js +598 -495
  147. package/tools/cli/commands/memory-journal-cmd.js +311 -311
  148. package/tools/cli/commands/memory.js +195 -195
  149. package/tools/cli/commands/scan.js +348 -364
  150. package/tools/cli/commands/uninstall.js +101 -101
  151. package/tools/cli/commands/update.js +134 -134
  152. package/tools/cli/i18n.js +845 -845
  153. package/tools/cli/lib/README-memory-journal.md +125 -125
  154. package/tools/cli/lib/ide-config.js +267 -259
  155. package/tools/cli/lib/memory-init.js +204 -113
  156. package/tools/cli/lib/packs.js +209 -209
  157. package/tools/cli/lib/path-hash.js +39 -0
  158. package/tools/cli/lib/python-provision.js +508 -508
  159. package/tools/cli/lib/stack-detect.js +102 -102
  160. package/tools/cli/lib/validate.js +50 -50
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SEO Crawl — Recursive mini-crawler for site structure discovery.
4
+
5
+ Features:
6
+ - Sitemap.xml parsing for initial page list
7
+ - Recursive link-following with configurable depth
8
+ - Internal link graph construction
9
+ - Orphan page detection
10
+ - robots.txt respect
11
+
12
+ Author: Laurent Rochetta
13
+ License: MIT
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ # XXE protection: require defusedxml. We deliberately FAIL CLOSED rather
23
+ # than fall back to the stdlib parser, which is vulnerable to entity-
24
+ # expansion / external-entity (XXE) attacks on attacker-controlled XML.
25
+ try:
26
+ from defusedxml import ElementTree as ET
27
+ except ImportError:
28
+ print(
29
+ "Error: defusedxml library required for safe XML parsing. "
30
+ "Install: pip install defusedxml",
31
+ file=sys.stderr,
32
+ )
33
+ sys.exit(1)
34
+
35
+ # Reuse the hardened SSRF guard from seo_fetch (same package/directory).
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+ try:
38
+ from seo_fetch import is_safe_url
39
+ except ImportError:
40
+ print(
41
+ "Error: seo_fetch.is_safe_url is required (SSRF protection). "
42
+ "Ensure seo_fetch.py is present alongside seo_crawl.py.",
43
+ file=sys.stderr,
44
+ )
45
+ sys.exit(1)
46
+
47
+ try:
48
+ from bs4 import BeautifulSoup
49
+ HAS_BS4 = True
50
+ except ImportError:
51
+ HAS_BS4 = False
52
+
53
+ from collections import defaultdict
54
+ from typing import Optional, Set
55
+ from urllib.parse import urljoin, urlparse
56
+
57
+ try:
58
+ import requests
59
+ except ImportError:
60
+ print("Error: requests library required. Install: pip install requests", file=sys.stderr)
61
+ sys.exit(1)
62
+
63
+ USER_AGENT = (
64
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
65
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 BMADSEOEngine/2.0"
66
+ )
67
+
68
+
69
+ class SEOCrawler:
70
+ """Recursive mini-crawler for SEO site structure analysis."""
71
+
72
+ def __init__(self, base_url: str, max_depth: int = 2, max_pages: int = 25, timeout: int = 15):
73
+ self.base_url = base_url.rstrip("/")
74
+ self.base_domain = urlparse(self.base_url).netloc
75
+ self.max_depth = max_depth
76
+ self.max_pages = max_pages
77
+ self.timeout = timeout
78
+
79
+ self.visited: Set[str] = set()
80
+ self.pages: list = []
81
+ self.link_graph: dict = defaultdict(set) # page -> set of linked pages
82
+ self.sitemap_urls: list = []
83
+ self.robots_txt: Optional[str] = None
84
+ self.errors: list = []
85
+
86
+ def normalize_url(self, url: str) -> str:
87
+ """Normalize URL for deduplication."""
88
+ parsed = urlparse(url)
89
+ path = parsed.path.rstrip("/") or "/"
90
+ return f"{parsed.scheme}://{parsed.netloc}{path}"
91
+
92
+ def is_internal(self, url: str) -> bool:
93
+ """Check if URL belongs to the same domain."""
94
+ return urlparse(url).netloc == self.base_domain
95
+
96
+ def _safe_get(self, url: str, error_prefix: str = "Blocked"):
97
+ """GET a URL, following redirects manually with per-hop SSRF revalidation.
98
+
99
+ Redirects are followed with allow_redirects=False so is_safe_url() runs on
100
+ EVERY hop: a public URL that 302s to an internal/metadata endpoint
101
+ (redirect-based SSRF) is refused. Returns the final requests.Response, or
102
+ None (with an entry appended to self.errors) when a hop is unsafe or too
103
+ many redirects occur. Used by fetch(), fetch_robots_txt() and
104
+ parse_sitemap() so every network path shares the same guard.
105
+ """
106
+ if not is_safe_url(url):
107
+ self.errors.append(
108
+ {"url": url, "error": f"{error_prefix}: private/internal URL (SSRF protection)"}
109
+ )
110
+ return None
111
+ current_url = url
112
+ hops = 0
113
+ while True:
114
+ response = requests.get(
115
+ current_url,
116
+ headers={"User-Agent": USER_AGENT},
117
+ timeout=self.timeout,
118
+ allow_redirects=False,
119
+ )
120
+ if not response.is_redirect:
121
+ return response
122
+ location = response.headers.get("Location")
123
+ if not location:
124
+ return response
125
+ next_url = urljoin(current_url, location)
126
+ if urlparse(next_url).scheme not in ("http", "https") or not is_safe_url(next_url):
127
+ self.errors.append(
128
+ {"url": next_url,
129
+ "error": f"{error_prefix} redirect: non-HTTP(S) or private/internal URL (SSRF protection)"}
130
+ )
131
+ return None
132
+ hops += 1
133
+ if hops > 5:
134
+ self.errors.append({"url": current_url, "error": "Too many redirects (max 5)"})
135
+ return None
136
+ current_url = next_url
137
+
138
+ def fetch(self, url: str) -> Optional[str]:
139
+ """Fetch a page (HTML), following redirects with per-hop SSRF revalidation."""
140
+ try:
141
+ response = self._safe_get(url)
142
+ if response is None:
143
+ return None
144
+ if response.status_code == 200 and "text/html" in response.headers.get("content-type", ""):
145
+ return response.text
146
+ self.pages.append({
147
+ "url": url,
148
+ "status": response.status_code,
149
+ "content_type": response.headers.get("content-type", ""),
150
+ "title": None,
151
+ "word_count": 0,
152
+ "depth": -1,
153
+ })
154
+ except requests.RequestException as e:
155
+ self.errors.append({"url": url, "error": str(e)})
156
+ return None
157
+
158
+ def fetch_robots_txt(self):
159
+ """Fetch and store robots.txt (redirects revalidated per hop)."""
160
+ robots_url = f"{self.base_url}/robots.txt"
161
+ try:
162
+ response = self._safe_get(robots_url)
163
+ if response is not None and response.status_code == 200:
164
+ self.robots_txt = response.text
165
+ except requests.RequestException:
166
+ pass
167
+
168
+ def parse_sitemap(self):
169
+ """Discover pages from sitemap.xml (redirects revalidated per hop)."""
170
+ sitemap_url = f"{self.base_url}/sitemap.xml"
171
+
172
+ # Check robots.txt for sitemap reference
173
+ if self.robots_txt:
174
+ for line in self.robots_txt.splitlines():
175
+ if line.strip().lower().startswith("sitemap:"):
176
+ sitemap_url = line.split(":", 1)[1].strip()
177
+ break
178
+
179
+ # The sitemap URL can come from an attacker-controlled robots.txt
180
+ # ("Sitemap:" line) — _safe_get validates it AND every redirect hop.
181
+ try:
182
+ response = self._safe_get(sitemap_url, error_prefix="Blocked sitemap")
183
+ if response is not None and response.status_code == 200 and "xml" in response.headers.get("content-type", ""):
184
+ root = ET.fromstring(response.content)
185
+ ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
186
+
187
+ for url_el in root.findall(".//sm:url/sm:loc", ns):
188
+ if url_el.text and self.is_internal(url_el.text):
189
+ self.sitemap_urls.append(url_el.text)
190
+
191
+ # Handle sitemap index
192
+ for sitemap_el in root.findall(".//sm:sitemap/sm:loc", ns):
193
+ self.sitemap_urls.append(f"[sitemap-index]: {sitemap_el.text}")
194
+
195
+ except (requests.RequestException, ET.ParseError):
196
+ pass
197
+
198
+ def extract_links(self, html: str, page_url: str) -> list:
199
+ """Extract internal links from HTML using BeautifulSoup (fallback to regex)."""
200
+ links = []
201
+
202
+ if HAS_BS4:
203
+ soup = BeautifulSoup(html, "html.parser")
204
+ for a_tag in soup.find_all("a", href=True):
205
+ href = a_tag["href"]
206
+ if href.startswith("#") or href.startswith("javascript:") or href.startswith("mailto:"):
207
+ continue
208
+ full_url = urljoin(page_url, href)
209
+ if self.is_internal(full_url):
210
+ normalized = self.normalize_url(full_url)
211
+ links.append(normalized)
212
+ self.link_graph[page_url].add(normalized)
213
+ else:
214
+ # Fallback: regex (less robust, no BS4 dependency)
215
+ for match in re.finditer(r'href=["\']([^"\']+)["\']', html):
216
+ href = match.group(1)
217
+ if href.startswith("#") or href.startswith("javascript:") or href.startswith("mailto:"):
218
+ continue
219
+ full_url = urljoin(page_url, href)
220
+ if self.is_internal(full_url):
221
+ normalized = self.normalize_url(full_url)
222
+ links.append(normalized)
223
+ self.link_graph[page_url].add(normalized)
224
+
225
+ return links
226
+
227
+ def extract_title(self, html: str) -> Optional[str]:
228
+ """Extract title from HTML."""
229
+ match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
230
+ return match.group(1).strip() if match else None
231
+
232
+ def count_words(self, html: str) -> int:
233
+ """Count visible words in HTML."""
234
+ text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
235
+ text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
236
+ text = re.sub(r"<[^>]+>", " ", text)
237
+ words = re.findall(r"\b\w+\b", text)
238
+ return len(words)
239
+
240
+ def crawl(self):
241
+ """Execute the recursive crawl."""
242
+ self.fetch_robots_txt()
243
+ self.parse_sitemap()
244
+
245
+ # Start with base URL
246
+ queue = [(self.base_url, 0)] # (url, depth)
247
+
248
+ while queue and len(self.visited) < self.max_pages:
249
+ url, depth = queue.pop(0)
250
+ normalized = self.normalize_url(url)
251
+
252
+ if normalized in self.visited:
253
+ continue
254
+ if depth > self.max_depth:
255
+ continue
256
+
257
+ self.visited.add(normalized)
258
+ html = self.fetch(normalized)
259
+
260
+ if html:
261
+ title = self.extract_title(html)
262
+ word_count = self.count_words(html)
263
+
264
+ self.pages.append({
265
+ "url": normalized,
266
+ "status": 200,
267
+ "title": title,
268
+ "word_count": word_count,
269
+ "depth": depth,
270
+ })
271
+
272
+ # Discover links for next level
273
+ if depth < self.max_depth:
274
+ links = self.extract_links(html, normalized)
275
+ for link in links:
276
+ if link not in self.visited:
277
+ queue.append((link, depth + 1))
278
+
279
+ def get_results(self) -> dict:
280
+ """Return crawl results as dictionary."""
281
+ # Detect orphan pages (in sitemap but not linked from any crawled page)
282
+ all_linked = set()
283
+ for targets in self.link_graph.values():
284
+ all_linked.update(targets)
285
+
286
+ orphans = [url for url in self.sitemap_urls
287
+ if isinstance(url, str) and not url.startswith("[") and
288
+ self.normalize_url(url) not in all_linked]
289
+
290
+ return {
291
+ "base_url": self.base_url,
292
+ "pages_crawled": len(self.pages),
293
+ "max_depth": self.max_depth,
294
+ "sitemap_urls_found": len([u for u in self.sitemap_urls if not str(u).startswith("[")]),
295
+ "has_robots_txt": self.robots_txt is not None,
296
+ "has_sitemap": len(self.sitemap_urls) > 0,
297
+ "pages": self.pages,
298
+ "orphan_pages": orphans[:10],
299
+ "link_graph_summary": {
300
+ "total_internal_links": sum(len(v) for v in self.link_graph.values()),
301
+ "avg_links_per_page": round(
302
+ sum(len(v) for v in self.link_graph.values()) / max(len(self.link_graph), 1), 1
303
+ ),
304
+ },
305
+ "errors": self.errors,
306
+ }
307
+
308
+
309
+ # ── CLI ────────────────────────────────────────────────────────────
310
+
311
+ def main():
312
+ parser = argparse.ArgumentParser(
313
+ description="SEO Crawl — Recursive mini-crawler (BMAD+ SEO Engine)"
314
+ )
315
+ parser.add_argument("url", help="Base URL to crawl")
316
+ parser.add_argument("--depth", "-d", type=int, default=2, help="Max crawl depth (default: 2)")
317
+ parser.add_argument("--max", "-m", type=int, default=25, help="Max pages (default: 25)")
318
+ parser.add_argument("--timeout", "-t", type=int, default=15, help="Per-page timeout (default: 15s)")
319
+ parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
320
+
321
+ args = parser.parse_args()
322
+
323
+ crawler = SEOCrawler(
324
+ base_url=args.url,
325
+ max_depth=args.depth,
326
+ max_pages=args.max,
327
+ timeout=args.timeout,
328
+ )
329
+
330
+ print(f"Crawling {args.url} (depth={args.depth}, max={args.max})...", file=sys.stderr)
331
+ crawler.crawl()
332
+ results = crawler.get_results()
333
+
334
+ if args.json:
335
+ # Convert sets to lists for JSON serialization
336
+ print(json.dumps(results, indent=2, ensure_ascii=False, default=list))
337
+ else:
338
+ print(f"\n{'='*60}")
339
+ print(f"Crawl Summary: {results['base_url']}")
340
+ print(f"{'='*60}")
341
+ print(f"Pages crawled: {results['pages_crawled']}")
342
+ print(f"Sitemap URLs: {results['sitemap_urls_found']}")
343
+ print(f"robots.txt: {'✅' if results['has_robots_txt'] else '❌'}")
344
+ print(f"Internal links: {results['link_graph_summary']['total_internal_links']}")
345
+ print(f"Avg links/page: {results['link_graph_summary']['avg_links_per_page']}")
346
+ print(f"Orphan pages: {len(results['orphan_pages'])}")
347
+ print(f"Errors: {len(results['errors'])}")
348
+
349
+ print(f"\n{'─'*60}")
350
+ print("Pages:")
351
+ for page in results["pages"]:
352
+ status = "✅" if page["status"] == 200 else f"⚠️ {page['status']}"
353
+ title = (page["title"] or "No title")[:50]
354
+ print(f" {status} [{page['depth']}] {title} ({page['word_count']} words)")
355
+ print(f" {page['url']}")
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SEO Fetch — Secure HTTP page fetcher for SEO analysis.
4
+
5
+ Features:
6
+ - SSRF protection (blocks private/loopback/reserved IPs)
7
+ - Multi-UA support (standard, Googlebot, GPTBot, ClaudeBot)
8
+ - Redirect chain tracking
9
+ - Cookie handling
10
+ - Configurable timeout
11
+
12
+ Author: Laurent Rochetta
13
+ License: MIT
14
+ """
15
+
16
+ import argparse
17
+ import ipaddress
18
+ import json
19
+ import socket
20
+ import sys
21
+ from typing import Optional
22
+ from urllib.parse import urljoin, urlparse
23
+
24
+ try:
25
+ import requests
26
+ except ImportError:
27
+ print("Error: requests library required. Install: pip install requests", file=sys.stderr)
28
+ sys.exit(1)
29
+
30
+
31
+ # ── User-Agent Presets ──────────────────────────────────────────────
32
+
33
+ USER_AGENTS = {
34
+ "default": (
35
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
36
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 BMADSEOEngine/2.0"
37
+ ),
38
+ "googlebot": (
39
+ "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
40
+ ),
41
+ "gptbot": (
42
+ "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; "
43
+ "+https://openai.com/gptbot)"
44
+ ),
45
+ "claudebot": (
46
+ "Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://www.anthropic.com/claudebot)"
47
+ ),
48
+ "mobile": (
49
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
50
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
51
+ ),
52
+ }
53
+
54
+ DEFAULT_HEADERS = {
55
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
56
+ "Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
57
+ "Accept-Encoding": "gzip, deflate, br",
58
+ "Connection": "keep-alive",
59
+ "Cache-Control": "no-cache",
60
+ }
61
+
62
+
63
+ # ── Security: SSRF Prevention ──────────────────────────────────────
64
+
65
+ def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
66
+ """Return True if an IP falls in any range that must never be reached."""
67
+ return bool(
68
+ ip.is_private
69
+ or ip.is_loopback
70
+ or ip.is_reserved
71
+ or ip.is_link_local
72
+ or ip.is_multicast
73
+ or ip.is_unspecified
74
+ )
75
+
76
+
77
+ def is_safe_url(url: str) -> bool:
78
+ """Block requests to private, loopback, and reserved IP addresses.
79
+
80
+ Fails CLOSED: a missing host, a non-HTTP(S) scheme, a DNS resolution
81
+ error, or an unparseable/blocked address all cause the URL to be
82
+ rejected. Every resolved address (IPv4 and IPv6) must be public.
83
+ """
84
+ parsed = urlparse(url)
85
+ hostname = parsed.hostname
86
+
87
+ if not hostname:
88
+ return False
89
+
90
+ if parsed.scheme not in ("http", "https"):
91
+ return False
92
+
93
+ try:
94
+ # Resolve ALL IP addresses (IPv4 and IPv6) via getaddrinfo
95
+ addrinfo = socket.getaddrinfo(hostname, None)
96
+ except socket.gaierror:
97
+ return False # Fail closed: unresolvable host is treated as unsafe
98
+
99
+ if not addrinfo:
100
+ return False # Fail closed: no addresses resolved
101
+
102
+ for entry in addrinfo:
103
+ ip_str = entry[4][0] # sockaddr[0] contains the IP string
104
+ try:
105
+ ip = ipaddress.ip_address(ip_str)
106
+ except ValueError:
107
+ return False # Fail closed: unparseable address
108
+ # IPv4-mapped IPv6 (::ffff:a.b.c.d) must be checked as its IPv4 form
109
+ mapped = getattr(ip, "ipv4_mapped", None)
110
+ if mapped is not None and _ip_is_blocked(mapped):
111
+ return False
112
+ if _ip_is_blocked(ip):
113
+ return False
114
+
115
+ return True
116
+
117
+
118
+ # ── Core Fetcher ───────────────────────────────────────────────────
119
+
120
+ def fetch_page(
121
+ url: str,
122
+ timeout: int = 30,
123
+ follow_redirects: bool = True,
124
+ max_redirects: int = 5,
125
+ user_agent: str = "default",
126
+ ) -> dict:
127
+ """
128
+ Fetch a web page with security checks and detailed response tracking.
129
+
130
+ Returns dict with: url, status_code, content, headers, redirect_chain,
131
+ content_length, response_time_ms, error
132
+ """
133
+ result = {
134
+ "url": url,
135
+ "final_url": None,
136
+ "status_code": None,
137
+ "content": None,
138
+ "headers": {},
139
+ "redirect_chain": [],
140
+ "content_length": 0,
141
+ "response_time_ms": 0,
142
+ "error": None,
143
+ }
144
+
145
+ # Normalize URL
146
+ parsed = urlparse(url)
147
+ if not parsed.scheme:
148
+ url = f"https://{url}"
149
+ parsed = urlparse(url)
150
+
151
+ if parsed.scheme not in ("http", "https"):
152
+ result["error"] = f"Invalid URL scheme: {parsed.scheme}"
153
+ return result
154
+
155
+ # SSRF check
156
+ if not is_safe_url(url):
157
+ resolved = "unknown"
158
+ try:
159
+ # Use getaddrinfo for consistent multi-address resolution
160
+ addrinfo = socket.getaddrinfo(parsed.hostname, None)
161
+ resolved = ", ".join(set(entry[4][0] for entry in addrinfo))
162
+ except Exception:
163
+ pass
164
+ result["error"] = f"Blocked: URL resolves to private/internal IP ({resolved})"
165
+ return result
166
+
167
+ try:
168
+ session = requests.Session()
169
+
170
+ headers = dict(DEFAULT_HEADERS)
171
+ ua_string = USER_AGENTS.get(user_agent, user_agent)
172
+ headers["User-Agent"] = ua_string
173
+
174
+ import time
175
+ start = time.monotonic()
176
+
177
+ # Follow redirects manually so is_safe_url() runs on EVERY hop.
178
+ # Letting requests follow redirects internally would allow a
179
+ # public URL to redirect (302) to an internal/metadata endpoint
180
+ # (redirect-based SSRF), bypassing the initial check.
181
+ current_url = url
182
+ redirect_chain = []
183
+ hops = 0
184
+ response = None
185
+
186
+ while True:
187
+ response = session.get(
188
+ current_url,
189
+ headers=headers,
190
+ timeout=timeout,
191
+ allow_redirects=False,
192
+ )
193
+
194
+ if not follow_redirects or not response.is_redirect:
195
+ break
196
+
197
+ location = response.headers.get("Location")
198
+ if not location:
199
+ break
200
+
201
+ next_url = urljoin(current_url, location)
202
+ next_parsed = urlparse(next_url)
203
+
204
+ if next_parsed.scheme not in ("http", "https"):
205
+ result["error"] = (
206
+ f"Blocked redirect to non-HTTP(S) scheme: {next_parsed.scheme}"
207
+ )
208
+ return result
209
+
210
+ # Re-validate the redirect target (blocks redirect-based SSRF)
211
+ if not is_safe_url(next_url):
212
+ result["error"] = (
213
+ f"Blocked: redirect to private/internal URL ({next_url})"
214
+ )
215
+ return result
216
+
217
+ hops += 1
218
+ if hops > max_redirects:
219
+ result["error"] = f"Too many redirects (max {max_redirects})"
220
+ return result
221
+
222
+ redirect_chain.append(
223
+ {"url": current_url, "status": response.status_code}
224
+ )
225
+ current_url = next_url
226
+
227
+ elapsed_ms = round((time.monotonic() - start) * 1000)
228
+
229
+ result["final_url"] = current_url
230
+ result["status_code"] = response.status_code
231
+ result["content"] = response.text
232
+ result["headers"] = dict(response.headers)
233
+ result["content_length"] = len(response.content)
234
+ result["response_time_ms"] = elapsed_ms
235
+ result["redirect_chain"] = redirect_chain
236
+
237
+ except requests.exceptions.Timeout:
238
+ result["error"] = f"Request timed out after {timeout}s"
239
+ except requests.exceptions.TooManyRedirects:
240
+ result["error"] = f"Too many redirects (max {max_redirects})"
241
+ except requests.exceptions.SSLError as e:
242
+ result["error"] = f"SSL error: {e}"
243
+ except requests.exceptions.ConnectionError as e:
244
+ result["error"] = f"Connection error: {e}"
245
+ except requests.exceptions.RequestException as e:
246
+ result["error"] = f"Request failed: {e}"
247
+
248
+ return result
249
+
250
+
251
+ # ── CLI ────────────────────────────────────────────────────────────
252
+
253
+ def main():
254
+ parser = argparse.ArgumentParser(
255
+ description="SEO Fetch — Secure HTTP fetcher for SEO analysis (BMAD+ SEO Engine)"
256
+ )
257
+ parser.add_argument("url", help="URL to fetch")
258
+ parser.add_argument("--output", "-o", help="Save HTML to file")
259
+ parser.add_argument("--timeout", "-t", type=int, default=30, help="Timeout in seconds")
260
+ parser.add_argument("--no-redirects", action="store_true", help="Don't follow redirects")
261
+ parser.add_argument(
262
+ "--ua", choices=list(USER_AGENTS.keys()), default="default",
263
+ help="User-Agent preset (default, googlebot, gptbot, claudebot, mobile)"
264
+ )
265
+ parser.add_argument("--json", "-j", action="store_true", help="Output full result as JSON")
266
+
267
+ args = parser.parse_args()
268
+
269
+ result = fetch_page(
270
+ args.url,
271
+ timeout=args.timeout,
272
+ follow_redirects=not args.no_redirects,
273
+ user_agent=args.ua,
274
+ )
275
+
276
+ if result["error"]:
277
+ print(f"Error: {result['error']}", file=sys.stderr)
278
+ sys.exit(1)
279
+
280
+ if args.json:
281
+ # Output metadata as JSON (without full HTML content for readability)
282
+ output = {k: v for k, v in result.items() if k != "content"}
283
+ output["content_preview"] = result["content"][:500] if result["content"] else None
284
+ print(json.dumps(output, indent=2))
285
+ elif args.output:
286
+ with open(args.output, "w", encoding="utf-8") as f:
287
+ f.write(result["content"])
288
+ print(f"Saved to {args.output}")
289
+ else:
290
+ print(result["content"])
291
+
292
+ # Metadata to stderr
293
+ print(f"\n--- Fetch Summary ---", file=sys.stderr)
294
+ print(f"Final URL: {result['final_url']}", file=sys.stderr)
295
+ print(f"Status: {result['status_code']}", file=sys.stderr)
296
+ print(f"Size: {result['content_length']:,} bytes", file=sys.stderr)
297
+ print(f"Time: {result['response_time_ms']}ms", file=sys.stderr)
298
+ if result["redirect_chain"]:
299
+ chain = " → ".join(r["url"] for r in result["redirect_chain"])
300
+ print(f"Redirects: {chain}", file=sys.stderr)
301
+
302
+
303
+ if __name__ == "__main__":
304
+ main()