bmad-plus 0.12.1 → 0.13.0

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 (176) hide show
  1. package/CHANGELOG.md +637 -580
  2. package/README.md +123 -120
  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 +106 -91
  14. package/readme-international/README.de.md +625 -594
  15. package/readme-international/README.es.md +642 -611
  16. package/readme-international/README.fr.md +640 -609
  17. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +2 -0
  18. package/src/bmad-plus/agents/agent-shadow/SKILL.md +18 -0
  19. package/src/bmad-plus/data/role-triggers.yaml +52 -0
  20. package/src/bmad-plus/module.yaml +333 -283
  21. package/src/bmad-plus/packs/pack-animated/animated-website-agent.md +325 -325
  22. package/src/bmad-plus/packs/pack-animated/templates/animated-website-workflow.md +55 -55
  23. package/src/bmad-plus/packs/pack-backup/backup-agent.md +71 -71
  24. package/src/bmad-plus/packs/pack-backup/templates/backup-workflow.md +51 -51
  25. package/src/bmad-plus/packs/pack-dev-studio/README.md +162 -162
  26. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/analyst-agent.md +73 -73
  27. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/document-project.md +61 -61
  28. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/domain-research.md +95 -95
  29. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/market-research.md +95 -95
  30. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/prfaq.md +134 -134
  31. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/product-brief.md +80 -80
  32. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/tech-writer-agent.md +73 -73
  33. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/technical-research.md +95 -95
  34. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/architect-agent.md +73 -73
  35. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-architecture.md +73 -73
  36. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-epics-stories.md +92 -92
  37. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/generate-project-context.md +80 -80
  38. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/implementation-readiness.md +90 -90
  39. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01-init.md +153 -153
  40. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01b-continue.md +173 -173
  41. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-02-context.md +224 -224
  42. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-03-starter.md +329 -329
  43. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-04-decisions.md +318 -318
  44. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-05-patterns.md +359 -359
  45. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-06-structure.md +379 -379
  46. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-07-validation.md +361 -361
  47. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-08-complete.md +81 -81
  48. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/checkpoint-preview.md +67 -67
  49. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-01-gather-context.md +85 -85
  50. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-02-review.md +35 -35
  51. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +49 -49
  52. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-04-present.md +131 -131
  53. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +89 -89
  54. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/correct-course.md +300 -300
  55. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +428 -428
  56. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-agent.md +73 -73
  57. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +80 -80
  58. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +484 -484
  59. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/investigate.md +193 -193
  60. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/qa-e2e-tests.md +175 -175
  61. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/quick-dev.md +110 -110
  62. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/retrospective.md +1511 -1511
  63. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-planning.md +298 -298
  64. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-status.md +296 -296
  65. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-prd.md +29 -29
  66. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-ux-design.md +74 -74
  67. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/edit-prd.md +29 -29
  68. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/pm-agent.md +73 -73
  69. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/prd.md +89 -89
  70. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/ux-designer-agent.md +73 -73
  71. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/validate-prd.md +29 -29
  72. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/advanced-elicitation.md +141 -141
  73. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/adversarial-review.md +37 -37
  74. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/bmad-help.md +75 -75
  75. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/brainstorming.md +6 -6
  76. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/customize.md +110 -110
  77. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/distillator.md +176 -176
  78. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/edge-case-hunter.md +67 -67
  79. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-prose.md +86 -86
  80. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-structure.md +179 -179
  81. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/index-docs.md +66 -66
  82. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/party-mode.md +127 -127
  83. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/shard-doc.md +105 -105
  84. package/src/bmad-plus/packs/pack-dev-studio/dev-studio-orchestrator.md +120 -120
  85. package/src/bmad-plus/packs/pack-dev-studio/shared/architecture-decision-template.md +12 -12
  86. package/src/bmad-plus/packs/pack-dev-studio/shared/bwml-spec.md +328 -328
  87. package/src/bmad-plus/packs/pack-dev-studio/shared/module-help.csv +32 -32
  88. package/src/bmad-plus/packs/pack-dev-studio/upstream-sync.yaml +81 -81
  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 +303 -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 +409 -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/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +1 -1
  137. package/tools/bmad-plus-npx.js +4 -2
  138. package/tools/build/adapters.config.js +60 -51
  139. package/tools/build/check-counts.js +626 -0
  140. package/tools/build/check-install-contract.js +298 -0
  141. package/tools/build/generate-adapters.js +252 -56
  142. package/tools/build/generate.js +187 -10
  143. package/tools/build/generated-adapters/.codex/AGENTS.md +20 -7
  144. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +20 -7
  145. package/tools/build/generated-adapters/.opencode/AGENTS.md +20 -7
  146. package/tools/build/generated-adapters/AGENTS.md +20 -7
  147. package/tools/build/generated-adapters/CLAUDE.md +20 -7
  148. package/tools/build/generated-adapters/CONVENTIONS.md +20 -7
  149. package/tools/build/generated-adapters/GEMINI.md +20 -7
  150. package/tools/build/module.template.yaml +82 -0
  151. package/tools/cli/bmad-plus-cli.js +16 -1
  152. package/tools/cli/commands/autoconfig.js +470 -470
  153. package/tools/cli/commands/doctor.js +205 -233
  154. package/tools/cli/commands/install.js +543 -501
  155. package/tools/cli/commands/memory-journal-cmd.js +311 -311
  156. package/tools/cli/commands/memory.js +195 -195
  157. package/tools/cli/commands/scan.js +348 -348
  158. package/tools/cli/commands/uninstall.js +209 -101
  159. package/tools/cli/commands/update-check.js +31 -0
  160. package/tools/cli/commands/update-policy.js +39 -0
  161. package/tools/cli/commands/update.js +123 -134
  162. package/tools/cli/i18n.js +905 -845
  163. package/tools/cli/lib/README-memory-journal.md +125 -125
  164. package/tools/cli/lib/ide-config.js +10 -259
  165. package/tools/cli/lib/install-manifest.js +17 -0
  166. package/tools/cli/lib/installed-adapters.js +89 -0
  167. package/tools/cli/lib/npm-runner.js +177 -0
  168. package/tools/cli/lib/pack-copy.js +62 -66
  169. package/tools/cli/lib/packs.js +437 -3
  170. package/tools/cli/lib/python-provision.js +508 -508
  171. package/tools/cli/lib/stack-detect.js +102 -102
  172. package/tools/cli/lib/update-check.js +153 -0
  173. package/tools/cli/lib/update-dispatch.js +182 -0
  174. package/tools/cli/lib/update-policy.js +90 -0
  175. package/tools/cli/lib/update-transaction.js +334 -0
  176. 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,303 @@
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 urllib.parse import urljoin, urlparse
22
+
23
+ try:
24
+ import requests
25
+ except ImportError:
26
+ print("Error: requests library required. Install: pip install requests", file=sys.stderr)
27
+ sys.exit(1)
28
+
29
+
30
+ # ── User-Agent Presets ──────────────────────────────────────────────
31
+
32
+ USER_AGENTS = {
33
+ "default": (
34
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
35
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 BMADSEOEngine/2.0"
36
+ ),
37
+ "googlebot": (
38
+ "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
39
+ ),
40
+ "gptbot": (
41
+ "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; "
42
+ "+https://openai.com/gptbot)"
43
+ ),
44
+ "claudebot": (
45
+ "Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://www.anthropic.com/claudebot)"
46
+ ),
47
+ "mobile": (
48
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
49
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
50
+ ),
51
+ }
52
+
53
+ DEFAULT_HEADERS = {
54
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
55
+ "Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
56
+ "Accept-Encoding": "gzip, deflate, br",
57
+ "Connection": "keep-alive",
58
+ "Cache-Control": "no-cache",
59
+ }
60
+
61
+
62
+ # ── Security: SSRF Prevention ──────────────────────────────────────
63
+
64
+ def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
65
+ """Return True if an IP falls in any range that must never be reached."""
66
+ return bool(
67
+ ip.is_private
68
+ or ip.is_loopback
69
+ or ip.is_reserved
70
+ or ip.is_link_local
71
+ or ip.is_multicast
72
+ or ip.is_unspecified
73
+ )
74
+
75
+
76
+ def is_safe_url(url: str) -> bool:
77
+ """Block requests to private, loopback, and reserved IP addresses.
78
+
79
+ Fails CLOSED: a missing host, a non-HTTP(S) scheme, a DNS resolution
80
+ error, or an unparseable/blocked address all cause the URL to be
81
+ rejected. Every resolved address (IPv4 and IPv6) must be public.
82
+ """
83
+ parsed = urlparse(url)
84
+ hostname = parsed.hostname
85
+
86
+ if not hostname:
87
+ return False
88
+
89
+ if parsed.scheme not in ("http", "https"):
90
+ return False
91
+
92
+ try:
93
+ # Resolve ALL IP addresses (IPv4 and IPv6) via getaddrinfo
94
+ addrinfo = socket.getaddrinfo(hostname, None)
95
+ except socket.gaierror:
96
+ return False # Fail closed: unresolvable host is treated as unsafe
97
+
98
+ if not addrinfo:
99
+ return False # Fail closed: no addresses resolved
100
+
101
+ for entry in addrinfo:
102
+ ip_str = entry[4][0] # sockaddr[0] contains the IP string
103
+ try:
104
+ ip = ipaddress.ip_address(ip_str)
105
+ except ValueError:
106
+ return False # Fail closed: unparseable address
107
+ # IPv4-mapped IPv6 (::ffff:a.b.c.d) must be checked as its IPv4 form
108
+ mapped = getattr(ip, "ipv4_mapped", None)
109
+ if mapped is not None and _ip_is_blocked(mapped):
110
+ return False
111
+ if _ip_is_blocked(ip):
112
+ return False
113
+
114
+ return True
115
+
116
+
117
+ # ── Core Fetcher ───────────────────────────────────────────────────
118
+
119
+ def fetch_page(
120
+ url: str,
121
+ timeout: int = 30,
122
+ follow_redirects: bool = True,
123
+ max_redirects: int = 5,
124
+ user_agent: str = "default",
125
+ ) -> dict:
126
+ """
127
+ Fetch a web page with security checks and detailed response tracking.
128
+
129
+ Returns dict with: url, status_code, content, headers, redirect_chain,
130
+ content_length, response_time_ms, error
131
+ """
132
+ result = {
133
+ "url": url,
134
+ "final_url": None,
135
+ "status_code": None,
136
+ "content": None,
137
+ "headers": {},
138
+ "redirect_chain": [],
139
+ "content_length": 0,
140
+ "response_time_ms": 0,
141
+ "error": None,
142
+ }
143
+
144
+ # Normalize URL
145
+ parsed = urlparse(url)
146
+ if not parsed.scheme:
147
+ url = f"https://{url}"
148
+ parsed = urlparse(url)
149
+
150
+ if parsed.scheme not in ("http", "https"):
151
+ result["error"] = f"Invalid URL scheme: {parsed.scheme}"
152
+ return result
153
+
154
+ # SSRF check
155
+ if not is_safe_url(url):
156
+ resolved = "unknown"
157
+ try:
158
+ # Use getaddrinfo for consistent multi-address resolution
159
+ addrinfo = socket.getaddrinfo(parsed.hostname, None)
160
+ resolved = ", ".join(set(entry[4][0] for entry in addrinfo))
161
+ except Exception:
162
+ pass
163
+ result["error"] = f"Blocked: URL resolves to private/internal IP ({resolved})"
164
+ return result
165
+
166
+ try:
167
+ session = requests.Session()
168
+
169
+ headers = dict(DEFAULT_HEADERS)
170
+ ua_string = USER_AGENTS.get(user_agent, user_agent)
171
+ headers["User-Agent"] = ua_string
172
+
173
+ import time
174
+ start = time.monotonic()
175
+
176
+ # Follow redirects manually so is_safe_url() runs on EVERY hop.
177
+ # Letting requests follow redirects internally would allow a
178
+ # public URL to redirect (302) to an internal/metadata endpoint
179
+ # (redirect-based SSRF), bypassing the initial check.
180
+ current_url = url
181
+ redirect_chain = []
182
+ hops = 0
183
+ response = None
184
+
185
+ while True:
186
+ response = session.get(
187
+ current_url,
188
+ headers=headers,
189
+ timeout=timeout,
190
+ allow_redirects=False,
191
+ )
192
+
193
+ if not follow_redirects or not response.is_redirect:
194
+ break
195
+
196
+ location = response.headers.get("Location")
197
+ if not location:
198
+ break
199
+
200
+ next_url = urljoin(current_url, location)
201
+ next_parsed = urlparse(next_url)
202
+
203
+ if next_parsed.scheme not in ("http", "https"):
204
+ result["error"] = (
205
+ f"Blocked redirect to non-HTTP(S) scheme: {next_parsed.scheme}"
206
+ )
207
+ return result
208
+
209
+ # Re-validate the redirect target (blocks redirect-based SSRF)
210
+ if not is_safe_url(next_url):
211
+ result["error"] = (
212
+ f"Blocked: redirect to private/internal URL ({next_url})"
213
+ )
214
+ return result
215
+
216
+ hops += 1
217
+ if hops > max_redirects:
218
+ result["error"] = f"Too many redirects (max {max_redirects})"
219
+ return result
220
+
221
+ redirect_chain.append(
222
+ {"url": current_url, "status": response.status_code}
223
+ )
224
+ current_url = next_url
225
+
226
+ elapsed_ms = round((time.monotonic() - start) * 1000)
227
+
228
+ result["final_url"] = current_url
229
+ result["status_code"] = response.status_code
230
+ result["content"] = response.text
231
+ result["headers"] = dict(response.headers)
232
+ result["content_length"] = len(response.content)
233
+ result["response_time_ms"] = elapsed_ms
234
+ result["redirect_chain"] = redirect_chain
235
+
236
+ except requests.exceptions.Timeout:
237
+ result["error"] = f"Request timed out after {timeout}s"
238
+ except requests.exceptions.TooManyRedirects:
239
+ result["error"] = f"Too many redirects (max {max_redirects})"
240
+ except requests.exceptions.SSLError as e:
241
+ result["error"] = f"SSL error: {e}"
242
+ except requests.exceptions.ConnectionError as e:
243
+ result["error"] = f"Connection error: {e}"
244
+ except requests.exceptions.RequestException as e:
245
+ result["error"] = f"Request failed: {e}"
246
+
247
+ return result
248
+
249
+
250
+ # ── CLI ────────────────────────────────────────────────────────────
251
+
252
+ def main():
253
+ parser = argparse.ArgumentParser(
254
+ description="SEO Fetch — Secure HTTP fetcher for SEO analysis (BMAD+ SEO Engine)"
255
+ )
256
+ parser.add_argument("url", help="URL to fetch")
257
+ parser.add_argument("--output", "-o", help="Save HTML to file")
258
+ parser.add_argument("--timeout", "-t", type=int, default=30, help="Timeout in seconds")
259
+ parser.add_argument("--no-redirects", action="store_true", help="Don't follow redirects")
260
+ parser.add_argument(
261
+ "--ua", choices=list(USER_AGENTS.keys()), default="default",
262
+ help="User-Agent preset (default, googlebot, gptbot, claudebot, mobile)"
263
+ )
264
+ parser.add_argument("--json", "-j", action="store_true", help="Output full result as JSON")
265
+
266
+ args = parser.parse_args()
267
+
268
+ result = fetch_page(
269
+ args.url,
270
+ timeout=args.timeout,
271
+ follow_redirects=not args.no_redirects,
272
+ user_agent=args.ua,
273
+ )
274
+
275
+ if result["error"]:
276
+ print(f"Error: {result['error']}", file=sys.stderr)
277
+ sys.exit(1)
278
+
279
+ if args.json:
280
+ # Output metadata as JSON (without full HTML content for readability)
281
+ output = {k: v for k, v in result.items() if k != "content"}
282
+ output["content_preview"] = result["content"][:500] if result["content"] else None
283
+ print(json.dumps(output, indent=2))
284
+ elif args.output:
285
+ with open(args.output, "w", encoding="utf-8") as f:
286
+ f.write(result["content"])
287
+ print(f"Saved to {args.output}")
288
+ else:
289
+ print(result["content"])
290
+
291
+ # Metadata to stderr
292
+ print("\n--- Fetch Summary ---", file=sys.stderr)
293
+ print(f"Final URL: {result['final_url']}", file=sys.stderr)
294
+ print(f"Status: {result['status_code']}", file=sys.stderr)
295
+ print(f"Size: {result['content_length']:,} bytes", file=sys.stderr)
296
+ print(f"Time: {result['response_time_ms']}ms", file=sys.stderr)
297
+ if result["redirect_chain"]:
298
+ chain = " → ".join(r["url"] for r in result["redirect_chain"])
299
+ print(f"Redirects: {chain}", file=sys.stderr)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()