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,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()
|