bmad-plus 0.20.0 → 0.22.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.
- package/CHANGELOG.md +41 -0
- package/README.md +14 -14
- package/SECURITY.md +62 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +1 -1
- package/readme-international/README.de.md +14 -14
- package/readme-international/README.es.md +14 -14
- package/readme-international/README.fr.md +14 -14
- package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +26 -5
- package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
- package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
- package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
- package/src/bmad-plus/packs/pack-shield/README.md +12 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
- package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
- package/tools/build/generate-adapters.js +7 -0
- package/tools/build/generate.js +14 -0
- package/tools/cli/bmad-plus-cli.js +2 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/review.js +141 -7
- package/tools/cli/lib/ai-register.js +393 -0
- package/tools/cli/lib/assurance.js +822 -0
- package/tools/cli/lib/control-refs.js +132 -0
- package/tools/cli/lib/installation-health.js +17 -0
- package/tools/cli/lib/packs.js +60 -2
- package/tools/cli/lib/page-origins.js +582 -0
- package/tools/cli/lib/review-rules.js +124 -26
- package/tools/cli/lib/review.js +493 -10
- package/tools/cli/lib/uat.js +22 -5
- package/tools/cli/review-rules/index.yaml +9 -0
|
@@ -29,7 +29,9 @@ arbitrary branch. Read the intended behavior and applicable project constraints.
|
|
|
29
29
|
beside the scope: it holds, once each, the review rules that apply to the
|
|
30
30
|
selected paths — built-in rules by language and file kind, plus the project's
|
|
31
31
|
own from `_bmad/review-rules.yaml`, which can add, replace or disable rules.
|
|
32
|
-
`bmad-plus review rules <path>` shows which rules a file gets.
|
|
32
|
+
`bmad-plus review rules <path>` shows which rules a file gets. Each unit
|
|
33
|
+
lists its rule groups (`code`, `data`, `delivery` or the project's own):
|
|
34
|
+
parallel reviewers may split a unit by group. Inventory
|
|
33
35
|
related callers, tests and requirements. Preserve existing edits and record
|
|
34
36
|
unavailable context. A supplied diff may omit the surrounding behavior needed
|
|
35
37
|
to evaluate it.
|
|
@@ -73,8 +75,20 @@ arbitrary branch. Read the intended behavior and applicable project constraints.
|
|
|
73
75
|
when a selected file is unaccounted for or a finding is not anchored, `findings`
|
|
74
76
|
or `clean` otherwise. `clean` covers the reviewed scope only. A finding that
|
|
75
77
|
applies a checklist rule names it in `rule`; the CLI refuses a rule that does
|
|
76
|
-
not apply to that path.
|
|
77
|
-
|
|
78
|
+
not apply to that path. A finding that breaks a compliance control lists it in
|
|
79
|
+
`controls`, chosen among the controls `scope.json` records for that file; the
|
|
80
|
+
CLI refuses any other and carries them into the check result. The anchored record replaces credential-like values
|
|
81
|
+
quoted in a finding with `[REDACTED]` and counts them. Record the session in
|
|
82
|
+
`coverage.json` under `run`: `stop` (`completed`, `budget`, `time-limit`,
|
|
83
|
+
`failure-streak` or `interrupted`, with a `detail` unless completed), the
|
|
84
|
+
`passes` actually run, every unit attempt in order (`unit`, the rule `group`
|
|
85
|
+
when reviewers split by group, `completed` or `failed` with a reason), and
|
|
86
|
+
tokens or duration only when the host reports them — never estimate them.
|
|
87
|
+
After three failed attempts in a row on the same unit or group, stop
|
|
88
|
+
retrying it: mark its files failed. A stopped review is never clean. In CI,
|
|
89
|
+
`bmad-plus review gate <id> --emit-check <file.json>` also writes the verdict
|
|
90
|
+
as a check result (`bmad-plus/review-check/1`, with a GitHub check-runs
|
|
91
|
+
payload); the exit code stays the verdict.
|
|
78
92
|
9. On a second review of the same work, compare it with the earlier one:
|
|
79
93
|
`bmad-plus review compare <id> --since <earlier-id>` writes `compare.json`
|
|
80
94
|
with each finding new, persisting, refuted, resolved or not reviewed. An
|
|
@@ -95,7 +109,14 @@ or an untested requirement.
|
|
|
95
109
|
|
|
96
110
|
## Continue
|
|
97
111
|
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
With a sealed scope, run `bmad-plus review continue <id>` first. When the code,
|
|
113
|
+
its head ref or the review rules moved, it refuses: seal a new scope and compare
|
|
114
|
+
it with this one (step 9) instead of continuing. Otherwise `continue.json` lists
|
|
115
|
+
the units, files and rule groups still owed, the files abandoned after three
|
|
116
|
+
failures (they stay failed), the findings to requote and the passes left; add to
|
|
117
|
+
the same `coverage.json` and `findings.json` and record the new `run.stop`.
|
|
118
|
+
|
|
119
|
+
Without a sealed scope, compare the current diff and input hashes with the
|
|
120
|
+
reviewed snapshot. Preserve prior findings and check their resolution against actual changes. Re-review
|
|
100
121
|
affected behavior and invalidate conclusions that relied on changed inputs;
|
|
101
122
|
do not rerun unchanged checks merely to refresh the report date.
|
|
@@ -100,7 +100,9 @@ Run Scout and Judge **simultaneously** on each discovered page:
|
|
|
100
100
|
**Optional**: Run `scripts/seo_apis.py --all <url>` for live PageSpeed + CrUX data.
|
|
101
101
|
|
|
102
102
|
Use `scripts/seo_parse.py <file> --url <url> --json` on fetched HTML.
|
|
103
|
-
Use `scripts/seo_screenshot.py <url> --viewport mobile` for visual audit.
|
|
103
|
+
Use `scripts/seo_screenshot.py <url> --viewport mobile` for visual audit. The browser only
|
|
104
|
+
reaches the audited host: third-party resources are blocked and listed in `blocked_requests`,
|
|
105
|
+
so judge a missing CDN image or font as an effect of the capture, not of the site.
|
|
104
106
|
|
|
105
107
|
### Phase 3 — AI Readiness & GEO
|
|
106
108
|
**Agent**: Judge (GEO Analyst role)
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
|
|
74
74
|
## API Tools
|
|
75
75
|
```bash
|
|
76
|
-
# PageSpeed Insights API
|
|
77
|
-
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=URL
|
|
76
|
+
# PageSpeed Insights API: the key goes in a header, never in the URL (or use scripts/seo_apis.py)
|
|
77
|
+
curl -H "x-goog-api-key: $GOOGLE_API_KEY" "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=URL"
|
|
78
78
|
|
|
79
79
|
# Lighthouse CLI
|
|
80
80
|
npx lighthouse URL --output json --output-path report.json
|
|
@@ -10,6 +10,9 @@ Connects to:
|
|
|
10
10
|
Requires: GOOGLE_API_KEY environment variable (free, no OAuth).
|
|
11
11
|
Get one at: https://console.cloud.google.com/apis/credentials
|
|
12
12
|
|
|
13
|
+
The key travels in the x-goog-api-key header only: never in a request URL, where proxies
|
|
14
|
+
and server logs keep it, and never in a result, a log line or an error message.
|
|
15
|
+
|
|
13
16
|
Author: Laurent Rochetta
|
|
14
17
|
License: MIT
|
|
15
18
|
"""
|
|
@@ -17,6 +20,7 @@ License: MIT
|
|
|
17
20
|
import argparse
|
|
18
21
|
import json
|
|
19
22
|
import os
|
|
23
|
+
import re
|
|
20
24
|
import sys
|
|
21
25
|
from typing import Optional
|
|
22
26
|
|
|
@@ -27,12 +31,56 @@ except ImportError:
|
|
|
27
31
|
sys.exit(1)
|
|
28
32
|
|
|
29
33
|
|
|
30
|
-
API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
|
34
|
+
API_KEY = os.environ.get("GOOGLE_API_KEY", "").strip()
|
|
35
|
+
KEY_HEADER = "x-goog-api-key"
|
|
36
|
+
# Printable ASCII without spaces: anything else cannot be a header value, and the error
|
|
37
|
+
# requests would raise for it quotes the value.
|
|
38
|
+
KEY_SHAPE = re.compile(r"[\x21-\x7e]+")
|
|
31
39
|
|
|
32
40
|
PSI_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
|
|
33
41
|
CRUX_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
|
|
34
42
|
RICH_RESULTS_ENDPOINT = "https://searchconsole.googleapis.com/v1/urlTestingTools/mobileFriendlyTest:run"
|
|
35
43
|
|
|
44
|
+
SESSION = requests.Session()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _key_problem() -> Optional[str]:
|
|
48
|
+
"""Why no request can be sent, without ever quoting the key; None when it can."""
|
|
49
|
+
if not API_KEY:
|
|
50
|
+
return "GOOGLE_API_KEY not set. Get one at https://console.cloud.google.com/apis/credentials"
|
|
51
|
+
if not KEY_SHAPE.fullmatch(API_KEY):
|
|
52
|
+
return "GOOGLE_API_KEY is malformed: an API key is printable ASCII without spaces"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _redact(text: str) -> str:
|
|
57
|
+
"""Remove the key from text headed for a result, a log or the terminal."""
|
|
58
|
+
return text.replace(API_KEY, "[GOOGLE_API_KEY]") if API_KEY else text
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _failure(api: str, error: Exception) -> dict:
|
|
62
|
+
return {"error": _redact(f"{api} request failed: {error}")}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _send(method: str, endpoint: str, timeout: int, **body):
|
|
66
|
+
"""
|
|
67
|
+
Send one request to a Google API with the key in its header.
|
|
68
|
+
|
|
69
|
+
Redirects are refused, not followed: requests would carry this header to whatever host
|
|
70
|
+
a redirect names (it strips only Authorization), and no Google API answers with one.
|
|
71
|
+
"""
|
|
72
|
+
response = SESSION.request(
|
|
73
|
+
method,
|
|
74
|
+
endpoint,
|
|
75
|
+
headers={KEY_HEADER: API_KEY},
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
allow_redirects=False,
|
|
78
|
+
**body,
|
|
79
|
+
)
|
|
80
|
+
if 300 <= response.status_code < 400:
|
|
81
|
+
raise requests.HTTPError(f"unexpected redirect ({response.status_code}), not followed", response=response)
|
|
82
|
+
return response
|
|
83
|
+
|
|
36
84
|
|
|
37
85
|
# ── PageSpeed Insights ─────────────────────────────────────────────
|
|
38
86
|
|
|
@@ -48,25 +96,25 @@ def run_pagespeed(url: str, strategy: str = "mobile", categories: Optional[list]
|
|
|
48
96
|
Returns:
|
|
49
97
|
Structured result with scores, audits, and opportunities
|
|
50
98
|
"""
|
|
51
|
-
|
|
52
|
-
|
|
99
|
+
problem = _key_problem()
|
|
100
|
+
if problem:
|
|
101
|
+
return {"error": problem}
|
|
53
102
|
|
|
54
103
|
if categories is None:
|
|
55
104
|
categories = ["PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO"]
|
|
56
105
|
|
|
57
106
|
params = {
|
|
58
107
|
"url": url,
|
|
59
|
-
"key": API_KEY,
|
|
60
108
|
"strategy": strategy,
|
|
61
109
|
"category": categories,
|
|
62
110
|
}
|
|
63
111
|
|
|
64
112
|
try:
|
|
65
|
-
response =
|
|
113
|
+
response = _send("GET", PSI_ENDPOINT, 120, params=params)
|
|
66
114
|
response.raise_for_status()
|
|
67
115
|
data = response.json()
|
|
68
|
-
except requests.RequestException as e:
|
|
69
|
-
return
|
|
116
|
+
except (requests.RequestException, ValueError) as e:
|
|
117
|
+
return _failure("PSI API", e)
|
|
70
118
|
|
|
71
119
|
# Extract scores
|
|
72
120
|
result = {
|
|
@@ -161,8 +209,9 @@ def run_crux(url: str, form_factor: str = "PHONE") -> dict:
|
|
|
161
209
|
Returns:
|
|
162
210
|
Field CWV data at 75th percentile
|
|
163
211
|
"""
|
|
164
|
-
|
|
165
|
-
|
|
212
|
+
problem = _key_problem()
|
|
213
|
+
if problem:
|
|
214
|
+
return {"error": problem}
|
|
166
215
|
|
|
167
216
|
# Try URL-level first, fall back to origin
|
|
168
217
|
from urllib.parse import urlparse
|
|
@@ -175,28 +224,20 @@ def run_crux(url: str, form_factor: str = "PHONE") -> dict:
|
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
try:
|
|
178
|
-
response =
|
|
179
|
-
f"{CRUX_ENDPOINT}?key={API_KEY}",
|
|
180
|
-
json=payload,
|
|
181
|
-
timeout=30,
|
|
182
|
-
)
|
|
227
|
+
response = _send("POST", CRUX_ENDPOINT, 30, json=payload)
|
|
183
228
|
|
|
184
229
|
if response.status_code == 404:
|
|
185
230
|
# No URL-level data, try origin
|
|
186
231
|
payload = {"origin": origin, "formFactor": form_factor}
|
|
187
|
-
response =
|
|
188
|
-
f"{CRUX_ENDPOINT}?key={API_KEY}",
|
|
189
|
-
json=payload,
|
|
190
|
-
timeout=30,
|
|
191
|
-
)
|
|
232
|
+
response = _send("POST", CRUX_ENDPOINT, 30, json=payload)
|
|
192
233
|
|
|
193
234
|
if response.status_code == 404:
|
|
194
235
|
return {"error": f"No CrUX data available for {url} (not enough traffic)"}
|
|
195
236
|
|
|
196
237
|
response.raise_for_status()
|
|
197
238
|
data = response.json()
|
|
198
|
-
except requests.RequestException as e:
|
|
199
|
-
return
|
|
239
|
+
except (requests.RequestException, ValueError) as e:
|
|
240
|
+
return _failure("CrUX API", e)
|
|
200
241
|
|
|
201
242
|
result = {
|
|
202
243
|
"url": url,
|
|
@@ -264,21 +305,18 @@ def run_rich_results_test(url: str) -> dict:
|
|
|
264
305
|
Returns:
|
|
265
306
|
Mobile-friendly status and detected structured data
|
|
266
307
|
"""
|
|
267
|
-
|
|
268
|
-
|
|
308
|
+
problem = _key_problem()
|
|
309
|
+
if problem:
|
|
310
|
+
return {"error": problem}
|
|
269
311
|
|
|
270
312
|
payload = {"url": url}
|
|
271
313
|
|
|
272
314
|
try:
|
|
273
|
-
response =
|
|
274
|
-
f"{RICH_RESULTS_ENDPOINT}?key={API_KEY}",
|
|
275
|
-
json=payload,
|
|
276
|
-
timeout=60,
|
|
277
|
-
)
|
|
315
|
+
response = _send("POST", RICH_RESULTS_ENDPOINT, 60, json=payload)
|
|
278
316
|
response.raise_for_status()
|
|
279
317
|
data = response.json()
|
|
280
|
-
except requests.RequestException as e:
|
|
281
|
-
return
|
|
318
|
+
except (requests.RequestException, ValueError) as e:
|
|
319
|
+
return _failure("URL Testing API", e)
|
|
282
320
|
|
|
283
321
|
result = {
|
|
284
322
|
"url": url,
|
|
@@ -383,6 +421,10 @@ def main():
|
|
|
383
421
|
print(" Enable: PageSpeed Insights API + Chrome UX Report API", file=sys.stderr)
|
|
384
422
|
print(" Set: export GOOGLE_API_KEY=your_key", file=sys.stderr)
|
|
385
423
|
sys.exit(1)
|
|
424
|
+
problem = _key_problem()
|
|
425
|
+
if problem:
|
|
426
|
+
print(f"⚠️ {problem}", file=sys.stderr)
|
|
427
|
+
sys.exit(1)
|
|
386
428
|
|
|
387
429
|
if args.all:
|
|
388
430
|
result = run_all(args.url)
|
|
@@ -32,13 +32,14 @@ except ImportError:
|
|
|
32
32
|
)
|
|
33
33
|
sys.exit(1)
|
|
34
34
|
|
|
35
|
-
# Reuse the hardened SSRF guard from seo_fetch
|
|
35
|
+
# Reuse the hardened SSRF guard and pinned transport from seo_fetch
|
|
36
|
+
# (same package/directory).
|
|
36
37
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
38
|
try:
|
|
38
|
-
from seo_fetch import
|
|
39
|
+
from seo_fetch import ALLOWED_SCHEMES, UnsafeURLError, create_session
|
|
39
40
|
except ImportError:
|
|
40
41
|
print(
|
|
41
|
-
"Error: seo_fetch.
|
|
42
|
+
"Error: seo_fetch.create_session is required (SSRF protection). "
|
|
42
43
|
"Ensure seo_fetch.py is present alongside seo_crawl.py.",
|
|
43
44
|
file=sys.stderr,
|
|
44
45
|
)
|
|
@@ -65,6 +66,8 @@ USER_AGENT = (
|
|
|
65
66
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 BMADSEOEngine/2.0"
|
|
66
67
|
)
|
|
67
68
|
|
|
69
|
+
MAX_REDIRECTS = 5
|
|
70
|
+
|
|
68
71
|
|
|
69
72
|
class SEOCrawler:
|
|
70
73
|
"""Recursive mini-crawler for SEO site structure analysis."""
|
|
@@ -82,6 +85,7 @@ class SEOCrawler:
|
|
|
82
85
|
self.sitemap_urls: list = []
|
|
83
86
|
self.robots_txt: Optional[str] = None
|
|
84
87
|
self.errors: list = []
|
|
88
|
+
self.session = create_session()
|
|
85
89
|
|
|
86
90
|
def normalize_url(self, url: str) -> str:
|
|
87
91
|
"""Normalize URL for deduplication."""
|
|
@@ -96,42 +100,50 @@ class SEOCrawler:
|
|
|
96
100
|
def _safe_get(self, url: str, error_prefix: str = "Blocked"):
|
|
97
101
|
"""GET a URL, following redirects manually with per-hop SSRF revalidation.
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
Every request goes through the pinned session from seo_fetch, which
|
|
104
|
+
validates the target and connects to the address it validated (no DNS
|
|
105
|
+
rebinding between check and connect). Redirects are followed with
|
|
106
|
+
allow_redirects=False so each hop is validated the same way: a public URL
|
|
107
|
+
that 302s to an internal/metadata endpoint (redirect-based SSRF) is
|
|
108
|
+
refused. Returns the final requests.Response, or None (with an entry
|
|
109
|
+
appended to self.errors) when a hop is unsafe or too many redirects
|
|
110
|
+
occur. Used by fetch(), fetch_robots_txt() and parse_sitemap() so every
|
|
111
|
+
network path shares the same guard.
|
|
105
112
|
"""
|
|
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
113
|
current_url = url
|
|
112
114
|
hops = 0
|
|
113
115
|
while True:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
try:
|
|
117
|
+
response = self.session.get(
|
|
118
|
+
current_url,
|
|
119
|
+
headers={"User-Agent": USER_AGENT},
|
|
120
|
+
timeout=self.timeout,
|
|
121
|
+
allow_redirects=False,
|
|
122
|
+
)
|
|
123
|
+
except UnsafeURLError as e:
|
|
124
|
+
prefix = error_prefix if current_url == url else f"{error_prefix} redirect"
|
|
125
|
+
self.errors.append(
|
|
126
|
+
{"url": current_url, "error": f"{prefix}: {e} (SSRF protection)"}
|
|
127
|
+
)
|
|
128
|
+
return None
|
|
120
129
|
if not response.is_redirect:
|
|
121
130
|
return response
|
|
122
131
|
location = response.headers.get("Location")
|
|
123
132
|
if not location:
|
|
124
133
|
return response
|
|
134
|
+
response.close()
|
|
125
135
|
next_url = urljoin(current_url, location)
|
|
126
|
-
if urlparse(next_url).scheme not in
|
|
136
|
+
if urlparse(next_url).scheme not in ALLOWED_SCHEMES:
|
|
127
137
|
self.errors.append(
|
|
128
138
|
{"url": next_url,
|
|
129
|
-
"error": f"{error_prefix} redirect: non-HTTP(S)
|
|
139
|
+
"error": f"{error_prefix} redirect: non-HTTP(S) URL (SSRF protection)"}
|
|
130
140
|
)
|
|
131
141
|
return None
|
|
132
142
|
hops += 1
|
|
133
|
-
if hops >
|
|
134
|
-
self.errors.append(
|
|
143
|
+
if hops > MAX_REDIRECTS:
|
|
144
|
+
self.errors.append(
|
|
145
|
+
{"url": current_url, "error": f"Too many redirects (max {MAX_REDIRECTS})"}
|
|
146
|
+
)
|
|
135
147
|
return None
|
|
136
148
|
current_url = next_url
|
|
137
149
|
|