@topy-ai/maggie 0.7.37 → 0.7.40
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/README-zh-TW.md +65 -7
- package/README.md +80 -11
- package/bin/maggie.js +10 -2
- package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
- package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
- package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
- package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
- package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
- package/bundled-contracts/maggiedash/README.md +1 -1
- package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
- package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
- package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +16 -2
- package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
- package/bundled-contracts/maggiedash/execution-board.json +529 -28
- package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
- package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
- package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
- package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
- package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
- package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
- package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
- package/bundled-references/maggiedash-booking/PRD.md +228 -0
- package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
- package/bundled-references/maggiedash-booking/README.md +271 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
- package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
- package/bundled-references/maggiedash-booking/TASKS.md +137 -0
- package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +16 -0
- package/bundled-skills/maggie-booking/SKILL.md +103 -22
- package/bundled-skills/maggie-design/SKILL.md +35 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +12 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
- package/bundled-tools/clis/maggie_booking.py +231 -31
- package/bundled-tools/clis/maggie_contracts.py +282 -0
- package/bundled-tools/clis/maggie_dash.py +28 -6
- package/bundled-tools/clis/maggie_design.py +29 -12
- package/bundled-tools/clis/maggie_service_booking.py +50 -1
- package/bundled-tools/clis/site_audit.py +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small, deterministic validators for the cross-skill design/SEO contracts.
|
|
3
|
+
|
|
4
|
+
The commands consume sanitized evidence produced by a browser/build adapter. They
|
|
5
|
+
never fetch a third-party site and never write host source files implicitly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import html as html_lib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def now() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_json(path: Path) -> dict:
|
|
26
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(value, dict):
|
|
28
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_json(path: Path | None, value: dict) -> None:
|
|
33
|
+
if path:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def result(schema: str, errors: list[str], **details: object) -> dict:
|
|
39
|
+
return {"schemaVersion": schema, "passed": not errors, "errors": errors, **details}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit_and_exit(value: dict, output: Path | None = None) -> int:
|
|
43
|
+
write_json(output, value)
|
|
44
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
45
|
+
return 0 if value.get("passed") else 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_confirm(args: argparse.Namespace) -> None:
|
|
49
|
+
if not args.confirm:
|
|
50
|
+
raise ValueError("CONFIRMATION_REQUIRED: rerun with --confirm")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def slug(value: str) -> str:
|
|
54
|
+
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "contract"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_plan(args: argparse.Namespace, mode: str, defaults: dict) -> int:
|
|
58
|
+
require_confirm(args)
|
|
59
|
+
project = Path(args.project).resolve()
|
|
60
|
+
seed = f"{project}:{mode}:{getattr(args, 'route', '')}:{now()}".encode()
|
|
61
|
+
ident = f"{mode}-{hashlib.sha256(seed).hexdigest()[:12]}"
|
|
62
|
+
output = project / ".maggie" / "design" / mode / ident / "plan.json"
|
|
63
|
+
plan = {"schemaVersion": f"maggie-{mode}-surface.v1", "id": ident, "mode": mode, "createdAt": now(), **defaults}
|
|
64
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
output.write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
66
|
+
print(json.dumps({"plan": str(output), "id": ident, "mode": mode}, indent=2))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_progressive(args: argparse.Namespace) -> int:
|
|
71
|
+
evidence = read_json(Path(args.evidence))
|
|
72
|
+
errors: list[str] = []
|
|
73
|
+
if evidence.get("schemaVersion") != "maggie-progressive-enhancement.v1":
|
|
74
|
+
errors.append("evidence schemaVersion must be maggie-progressive-enhancement.v1")
|
|
75
|
+
region = evidence.get("region") if isinstance(evidence.get("region"), dict) else {}
|
|
76
|
+
if not region.get("id") or not region.get("selector"):
|
|
77
|
+
errors.append("region id and selector are required")
|
|
78
|
+
if region.get("swapScope") != "smallest-changed-region":
|
|
79
|
+
errors.append("swapScope must be smallest-changed-region")
|
|
80
|
+
initial = evidence.get("initial") if isinstance(evidence.get("initial"), dict) else {}
|
|
81
|
+
initial_requests = set(initial.get("thirdPartyRequests") or [])
|
|
82
|
+
initial_ids = initial.get("identities") if isinstance(initial.get("identities"), dict) else {}
|
|
83
|
+
states = evidence.get("states")
|
|
84
|
+
if not isinstance(states, list) or not states:
|
|
85
|
+
errors.append("states must be a non-empty array")
|
|
86
|
+
states = []
|
|
87
|
+
for state in states:
|
|
88
|
+
if not isinstance(state, dict):
|
|
89
|
+
errors.append("each state must be an object")
|
|
90
|
+
continue
|
|
91
|
+
new_requests = sorted(set(state.get("thirdPartyRequests") or []) - initial_requests)
|
|
92
|
+
if new_requests:
|
|
93
|
+
errors.append(f"state {state.get('name', 'unknown')} introduced third-party requests: {', '.join(new_requests)}")
|
|
94
|
+
identities = state.get("identities") if isinstance(state.get("identities"), dict) else {}
|
|
95
|
+
for identity, token in initial_ids.items():
|
|
96
|
+
if identities.get(identity) != token:
|
|
97
|
+
errors.append(f"state {state.get('name', 'unknown')} changed identity {identity}")
|
|
98
|
+
if state.get("outsideRegionStable") is not True:
|
|
99
|
+
errors.append(f"state {state.get('name', 'unknown')} lacks outsideRegionStable=true")
|
|
100
|
+
return emit_and_exit(result("maggie-progressive-enhancement.v1", errors, checkedStates=len(states), region=region), Path(args.output) if args.output else None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def css_escape(token: str) -> str:
|
|
104
|
+
return "".join((f"\\{char}" if char in r":[]()./%#" else char) for char in token)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_css(args: argparse.Namespace) -> int:
|
|
108
|
+
errors: list[str] = []
|
|
109
|
+
missing: list[str] = []
|
|
110
|
+
classes: list[str] = []
|
|
111
|
+
if args.evidence:
|
|
112
|
+
evidence = read_json(Path(args.evidence))
|
|
113
|
+
if evidence.get("schemaVersion") != "maggie-css-utility-evidence.v1":
|
|
114
|
+
errors.append("evidence schemaVersion must be maggie-css-utility-evidence.v1")
|
|
115
|
+
for element in evidence.get("elements") or []:
|
|
116
|
+
if not isinstance(element, dict):
|
|
117
|
+
errors.append("each CSS evidence element must be an object")
|
|
118
|
+
continue
|
|
119
|
+
for declaration in element.get("declarations") or []:
|
|
120
|
+
winner = declaration.get("winningSource") if isinstance(declaration.get("winningSource"), dict) else {}
|
|
121
|
+
if winner.get("layer") == "unlayered" or winner.get("projectRule") is True:
|
|
122
|
+
errors.append(f"utility declaration {declaration.get('property', 'unknown')} is won by unlayered project CSS")
|
|
123
|
+
else:
|
|
124
|
+
source_html = Path(args.html).read_text(encoding="utf-8")
|
|
125
|
+
source_css = Path(args.css).read_text(encoding="utf-8")
|
|
126
|
+
for raw in re.findall(r'class=["\']([^"\']+)', source_html, re.I):
|
|
127
|
+
classes.extend(item for item in html_lib.unescape(raw).split() if ":" in item or "[" in item)
|
|
128
|
+
classes.extend(args.required_class or [])
|
|
129
|
+
for token in dict.fromkeys(classes):
|
|
130
|
+
if token not in source_css and css_escape(token) not in source_css:
|
|
131
|
+
missing.append(token)
|
|
132
|
+
if missing:
|
|
133
|
+
errors.append("compiled CSS is missing required utility classes: " + ", ".join(missing))
|
|
134
|
+
return emit_and_exit(result("maggie-css-utility-evidence.v1", errors, missingClasses=missing, checkedClasses=list(dict.fromkeys(classes))), Path(args.output) if args.output else None)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_dashboard(args: argparse.Namespace) -> int:
|
|
138
|
+
plan = read_json(Path(args.plan))
|
|
139
|
+
evidence = read_json(Path(args.evidence))
|
|
140
|
+
errors: list[str] = []
|
|
141
|
+
required = {"desktop", "tablet", "mobile", "collapsed-rail", "mobile-drawer", "modal"}
|
|
142
|
+
observed = set(evidence.get("screenshots") or []) | set(evidence.get("states") or [])
|
|
143
|
+
if plan.get("schemaVersion") != "maggie-dashboard-surface.v1":
|
|
144
|
+
errors.append("dashboard plan schemaVersion is invalid")
|
|
145
|
+
if not required <= observed:
|
|
146
|
+
errors.append("dashboard evidence is missing: " + ", ".join(sorted(required - observed)))
|
|
147
|
+
if evidence.get("route") != plan.get("route"):
|
|
148
|
+
errors.append("dashboard evidence route does not match plan")
|
|
149
|
+
return emit_and_exit(result("maggie-dashboard-surface.v1", errors, observed=sorted(observed)), Path(args.output) if args.output else None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def validate_sample(args: argparse.Namespace) -> int:
|
|
153
|
+
plan = read_json(Path(args.plan))
|
|
154
|
+
evidence = read_json(Path(args.evidence))
|
|
155
|
+
errors: list[str] = []
|
|
156
|
+
if plan.get("schemaVersion") != "maggie-sample-surface.v1":
|
|
157
|
+
errors.append("sample plan schemaVersion is invalid")
|
|
158
|
+
if evidence.get("noindex") is not True:
|
|
159
|
+
errors.append("sample route must be noindex")
|
|
160
|
+
if evidence.get("sitemapIncluded") is not False:
|
|
161
|
+
errors.append("sample route must be excluded from the sitemap")
|
|
162
|
+
if not str(evidence.get("banner") or "").strip():
|
|
163
|
+
errors.append("sample route needs an explicit layout-sample banner")
|
|
164
|
+
if not isinstance(evidence.get("placeholders"), list) or not evidence.get("placeholders"):
|
|
165
|
+
errors.append("sample evidence needs placeholder lines")
|
|
166
|
+
if not isinstance(evidence.get("finishChecklist"), list) or not evidence.get("finishChecklist"):
|
|
167
|
+
errors.append("sample evidence needs a finish checklist")
|
|
168
|
+
return emit_and_exit(result("maggie-sample-surface.v1", errors, route=plan.get("route")), Path(args.output) if args.output else None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def validate_privacy(args: argparse.Namespace) -> int:
|
|
172
|
+
evidence = read_json(Path(args.evidence))
|
|
173
|
+
errors: list[str] = []
|
|
174
|
+
if evidence.get("schemaVersion") != "maggie-privacy-origin-evidence.v1":
|
|
175
|
+
errors.append("evidence schemaVersion must be maggie-privacy-origin-evidence.v1")
|
|
176
|
+
observed: set[str] = set()
|
|
177
|
+
for page in evidence.get("pages") or []:
|
|
178
|
+
observed.update(str(origin) for origin in (page.get("origins") or []))
|
|
179
|
+
declared = set(str(origin) for origin in evidence.get("policyOrigins") or [])
|
|
180
|
+
if observed - declared:
|
|
181
|
+
errors.append("unlisted third-party origins: " + ", ".join(sorted(observed - declared)))
|
|
182
|
+
if declared - observed and evidence.get("requireObservedPolicyOrigins", True):
|
|
183
|
+
errors.append("privacy policy origins not observed in rendered pages: " + ", ".join(sorted(declared - observed)))
|
|
184
|
+
return emit_and_exit(result("maggie-privacy-origin-evidence.v1", errors, observedOrigins=sorted(observed), policyOrigins=sorted(declared)), Path(args.output) if args.output else None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_structure(args: argparse.Namespace) -> int:
|
|
188
|
+
model = read_json(Path(args.model))
|
|
189
|
+
evidence = read_json(Path(args.evidence))
|
|
190
|
+
errors: list[str] = []
|
|
191
|
+
if model.get("schemaVersion") != "maggie-site-structure.v1":
|
|
192
|
+
errors.append("model schemaVersion must be maggie-site-structure.v1")
|
|
193
|
+
entries = {str(item.get("route")): item for item in model.get("pages") or [] if isinstance(item, dict) and item.get("route")}
|
|
194
|
+
seen = set()
|
|
195
|
+
for item in evidence.get("pages") or []:
|
|
196
|
+
if not isinstance(item, dict) or not item.get("route"):
|
|
197
|
+
errors.append("each rendered page evidence row needs a route")
|
|
198
|
+
continue
|
|
199
|
+
route = str(item["route"])
|
|
200
|
+
seen.add(route)
|
|
201
|
+
expected = entries.get(route)
|
|
202
|
+
if not expected:
|
|
203
|
+
errors.append(f"rendered route is absent from site structure model: {route}")
|
|
204
|
+
continue
|
|
205
|
+
for field in ("template", "source"):
|
|
206
|
+
if expected.get(field) != item.get(field):
|
|
207
|
+
errors.append(f"{route}: {field} does not match the model")
|
|
208
|
+
if set(expected.get("bands") or []) != set(item.get("bands") or []):
|
|
209
|
+
errors.append(f"{route}: band identities do not match the model")
|
|
210
|
+
for route in sorted(set(entries) - seen):
|
|
211
|
+
errors.append(f"model route has no rendered evidence: {route}")
|
|
212
|
+
return emit_and_exit(result("maggie-site-structure.v1", errors, checkedRoutes=sorted(seen)), Path(args.output) if args.output else None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_capabilities(args: argparse.Namespace) -> int:
|
|
216
|
+
manifest = read_json(Path(args.manifest))
|
|
217
|
+
errors: list[str] = []
|
|
218
|
+
if manifest.get("schemaVersion") != "maggie-host-capabilities.v1":
|
|
219
|
+
errors.append("manifest schemaVersion must be maggie-host-capabilities.v1")
|
|
220
|
+
endpoints = manifest.get("endpoints")
|
|
221
|
+
if not isinstance(endpoints, list) or not endpoints:
|
|
222
|
+
errors.append("endpoints must be a non-empty array")
|
|
223
|
+
endpoints = []
|
|
224
|
+
seen: set[str] = set()
|
|
225
|
+
for endpoint in endpoints:
|
|
226
|
+
if not isinstance(endpoint, dict):
|
|
227
|
+
errors.append("each capability endpoint must be an object")
|
|
228
|
+
continue
|
|
229
|
+
ident = str(endpoint.get("id") or "")
|
|
230
|
+
if not ident or ident in seen:
|
|
231
|
+
errors.append("capability endpoint ids must be non-empty and unique")
|
|
232
|
+
seen.add(ident)
|
|
233
|
+
if not isinstance(endpoint.get("enabled"), bool):
|
|
234
|
+
errors.append(f"{ident or 'unknown'}: enabled must be boolean")
|
|
235
|
+
if not str(endpoint.get("path") or "").startswith("/"):
|
|
236
|
+
errors.append(f"{ident or 'unknown'}: path must start with /")
|
|
237
|
+
return emit_and_exit(result("maggie-host-capabilities.v1", errors, enabled=sum(1 for item in endpoints if isinstance(item, dict) and item.get("enabled") is True), endpointCount=len(endpoints)), Path(args.output) if args.output else None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main() -> int:
|
|
241
|
+
parser = argparse.ArgumentParser(prog="maggie contracts")
|
|
242
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
243
|
+
progressive = sub.add_parser("progressive-check")
|
|
244
|
+
progressive.add_argument("--evidence", required=True); progressive.add_argument("--output")
|
|
245
|
+
progressive.set_defaults(func=validate_progressive)
|
|
246
|
+
css = sub.add_parser("css-check")
|
|
247
|
+
css.add_argument("--evidence"); css.add_argument("--html"); css.add_argument("--css"); css.add_argument("--required-class", action="append", default=[]); css.add_argument("--output")
|
|
248
|
+
css.set_defaults(func=validate_css)
|
|
249
|
+
cascade = sub.add_parser("css-cascade-check")
|
|
250
|
+
cascade.add_argument("--evidence", required=True); cascade.add_argument("--output")
|
|
251
|
+
cascade.set_defaults(func=validate_css)
|
|
252
|
+
dash_init = sub.add_parser("dash-init")
|
|
253
|
+
dash_init.add_argument("--project", default="."); dash_init.add_argument("--route", required=True); dash_init.add_argument("--confirm", action="store_true")
|
|
254
|
+
dash_init.set_defaults(func=lambda args: init_plan(args, "dashboard", {"route": args.route, "operatorJourneys": ["first-person task completion"], "shell": {"rail": True, "workArea": True, "tabs": True, "modal": True}, "requiredEvidence": ["desktop", "tablet", "mobile", "collapsed-rail", "mobile-drawer", "modal"], "publicWrite": False}))
|
|
255
|
+
dash_validate = sub.add_parser("dash-validate")
|
|
256
|
+
dash_validate.add_argument("--plan", required=True); dash_validate.add_argument("--evidence", required=True); dash_validate.add_argument("--output")
|
|
257
|
+
dash_validate.set_defaults(func=validate_dashboard)
|
|
258
|
+
sample_init = sub.add_parser("sample-init")
|
|
259
|
+
sample_init.add_argument("--project", default="."); sample_init.add_argument("--route", required=True); sample_init.add_argument("--purpose", required=True); sample_init.add_argument("--confirm", action="store_true")
|
|
260
|
+
sample_init.set_defaults(func=lambda args: init_plan(args, "sample", {"route": args.route, "purpose": args.purpose, "noindex": True, "sitemapIncluded": False, "bannerRequired": True, "placeholderPolicy": "unwritten lines must remain explicit placeholders", "finishChecklist": ["replace placeholder copy", "replace media", "run accessibility and SEO checks"], "publicWrite": False}))
|
|
261
|
+
sample_validate = sub.add_parser("sample-validate")
|
|
262
|
+
sample_validate.add_argument("--plan", required=True); sample_validate.add_argument("--evidence", required=True); sample_validate.add_argument("--output")
|
|
263
|
+
sample_validate.set_defaults(func=validate_sample)
|
|
264
|
+
privacy = sub.add_parser("privacy-check")
|
|
265
|
+
privacy.add_argument("--evidence", required=True); privacy.add_argument("--output")
|
|
266
|
+
privacy.set_defaults(func=validate_privacy)
|
|
267
|
+
structure = sub.add_parser("structure-validate")
|
|
268
|
+
structure.add_argument("--model", required=True); structure.add_argument("--evidence", required=True); structure.add_argument("--output")
|
|
269
|
+
structure.set_defaults(func=validate_structure)
|
|
270
|
+
capabilities = sub.add_parser("capabilities-validate")
|
|
271
|
+
capabilities.add_argument("--manifest", required=True); capabilities.add_argument("--output")
|
|
272
|
+
capabilities.set_defaults(func=validate_capabilities)
|
|
273
|
+
args = parser.parse_args()
|
|
274
|
+
try:
|
|
275
|
+
return args.func(args)
|
|
276
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
277
|
+
print(f"BLOCKED: maggie contract validation: {error}", file=sys.stderr)
|
|
278
|
+
return 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
raise SystemExit(main())
|
|
@@ -192,9 +192,10 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
192
192
|
"""Add the stable Maggie paths to an existing conventional Astro middleware.
|
|
193
193
|
|
|
194
194
|
The installer must preserve host-owned auth/redirect logic. This narrow
|
|
195
|
-
merge
|
|
196
|
-
|
|
197
|
-
reported to the caller instead of being rewritten
|
|
195
|
+
merge handles the common `onRequest = defineMiddleware((context, next) =>
|
|
196
|
+
{ ... })` shape and the equivalent destructured context form. Unknown
|
|
197
|
+
middleware shapes are reported to the caller instead of being rewritten
|
|
198
|
+
heuristically.
|
|
198
199
|
"""
|
|
199
200
|
try:
|
|
200
201
|
content = path.read_text(encoding="utf-8")
|
|
@@ -209,8 +210,6 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
209
210
|
r"export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\(\s*context\s*,\s*next\s*\)\s*=>\s*\{",
|
|
210
211
|
content,
|
|
211
212
|
)
|
|
212
|
-
if not match:
|
|
213
|
-
return {"status": "manual", "reason": "middleware is not the supported defineMiddleware((context, next) => {}) shape"}
|
|
214
213
|
block = r'''
|
|
215
214
|
// maggie-auto-rewrite-v1: generated by `maggie booking install`; keep host auth below.
|
|
216
215
|
const maggieRewrites: Array<[RegExp, (path: string) => string]> = [
|
|
@@ -228,7 +227,24 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
|
|
|
228
227
|
}
|
|
229
228
|
}
|
|
230
229
|
'''
|
|
231
|
-
|
|
230
|
+
if match:
|
|
231
|
+
merged = content[:match.end()] + block + content[match.end():]
|
|
232
|
+
else:
|
|
233
|
+
# Astro examples commonly destructure the request context in the
|
|
234
|
+
# parameter list: `(({ url, request }, next) => { ... })`. Normalize
|
|
235
|
+
# only this explicit shape so the generated rewrite block can use the
|
|
236
|
+
# full context while preserving the host's existing variable names.
|
|
237
|
+
destructured = re.search(
|
|
238
|
+
r"(?P<prefix>export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\()\s*\{(?P<properties>[^{}]+)\}\s*,\s*(?P<next>[A-Za-z_$][\w$]*)\s*\)\s*=>\s*\{",
|
|
239
|
+
content,
|
|
240
|
+
)
|
|
241
|
+
if not destructured:
|
|
242
|
+
return {"status": "manual", "reason": "middleware is not a supported defineMiddleware((context, next) => {}) shape"}
|
|
243
|
+
properties = destructured.group("properties").strip()
|
|
244
|
+
next_name = destructured.group("next")
|
|
245
|
+
replacement = f"{destructured.group('prefix')}context, {next_name}) => {{\n const {{ {properties} }} = context;"
|
|
246
|
+
normalized = content[:destructured.start()] + replacement + content[destructured.end():]
|
|
247
|
+
merged = normalized[:destructured.start() + len(replacement)] + block + normalized[destructured.start() + len(replacement):]
|
|
232
248
|
try:
|
|
233
249
|
path.write_text(merged, encoding="utf-8")
|
|
234
250
|
except OSError as error:
|
|
@@ -262,6 +278,12 @@ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Pa
|
|
|
262
278
|
for path in sorted(source_item.rglob("*")):
|
|
263
279
|
if path.is_file():
|
|
264
280
|
child = path.relative_to(source_item)
|
|
281
|
+
# A deployment config is host-owned. The default Node config is
|
|
282
|
+
# useful for a blank Astro project, but adding a second config to
|
|
283
|
+
# a host that already has astro.config.ts/cloudflare/etc. can make
|
|
284
|
+
# Astro ambiguous or change its deployment target.
|
|
285
|
+
if framework == "astro" and child.name == "astro.config.mjs" and any(root.glob("astro.config.*")):
|
|
286
|
+
continue
|
|
265
287
|
pairs.append((path, root / target_relative / child, source_relative / child))
|
|
266
288
|
return pairs
|
|
267
289
|
|
|
@@ -532,9 +532,31 @@ def design_job_path(project: Path, job_id: str) -> Path:
|
|
|
532
532
|
return project / ".maggie" / "design-jobs" / f"{job_id}.json"
|
|
533
533
|
|
|
534
534
|
|
|
535
|
-
def
|
|
535
|
+
def design_job_candidates(project: Path, job_id: str) -> list[Path]:
|
|
536
|
+
"""Return every supported location for a resumable design job.
|
|
537
|
+
|
|
538
|
+
In-place jobs historically live under ``design-jobs`` while first-party
|
|
539
|
+
author jobs are briefs under ``.maggie/design``. Both are Maggie design
|
|
540
|
+
jobs and the progress commands must treat them identically.
|
|
541
|
+
"""
|
|
542
|
+
root = project.resolve() / ".maggie"
|
|
543
|
+
return [
|
|
544
|
+
root / "design-jobs" / f"{job_id}.json",
|
|
545
|
+
root / "design" / f"{job_id}.json",
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def find_design_job_path(project: Path, job_id: str) -> Path:
|
|
550
|
+
for candidate in design_job_candidates(project, job_id):
|
|
551
|
+
if candidate.is_file():
|
|
552
|
+
return candidate
|
|
553
|
+
expected = design_job_path(project.resolve(), job_id)
|
|
554
|
+
raise ValueError(f"design job not found: {expected}")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def save_design_job(project: Path, job: dict[str, object], path: Path | None = None) -> None:
|
|
536
558
|
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
537
|
-
path = design_job_path(project, str(job["id"]))
|
|
559
|
+
path = path or design_job_path(project, str(job["id"]))
|
|
538
560
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
539
561
|
path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
540
562
|
|
|
@@ -599,17 +621,13 @@ def run_design_job(urls: list[str], project: Path, clone_run: str, force: bool =
|
|
|
599
621
|
|
|
600
622
|
|
|
601
623
|
def design_status(project: Path, job_id: str) -> int:
|
|
602
|
-
path =
|
|
603
|
-
if not path.exists():
|
|
604
|
-
raise ValueError(f"design job not found: {path}")
|
|
624
|
+
path = find_design_job_path(project, job_id)
|
|
605
625
|
print(path.read_text(encoding="utf-8"), end="")
|
|
606
626
|
return 0
|
|
607
627
|
|
|
608
628
|
|
|
609
629
|
def design_resume(project: Path, job_id: str) -> int:
|
|
610
|
-
path =
|
|
611
|
-
if not path.exists():
|
|
612
|
-
raise ValueError(f"design job not found: {path}")
|
|
630
|
+
path = find_design_job_path(project, job_id)
|
|
613
631
|
job = json.loads(path.read_text(encoding="utf-8"))
|
|
614
632
|
return run_design_job(job["urls"], project, job["clone_run"], force=True)[0]
|
|
615
633
|
|
|
@@ -620,9 +638,7 @@ def _step_records(names: list[str]) -> list[dict[str, object]]:
|
|
|
620
638
|
|
|
621
639
|
def design_step(project: Path, job_id: str, step: str, evidence: str) -> int:
|
|
622
640
|
"""Record one piece of implementation evidence and make progress durable."""
|
|
623
|
-
path =
|
|
624
|
-
if not path.exists():
|
|
625
|
-
raise ValueError(f"design job not found: {path}")
|
|
641
|
+
path = find_design_job_path(project, job_id)
|
|
626
642
|
job = json.loads(path.read_text(encoding="utf-8"))
|
|
627
643
|
steps = job.get("steps", [])
|
|
628
644
|
target = next((item for item in steps if item.get("name") == step), None)
|
|
@@ -774,8 +790,9 @@ def author_job(project: Path, route: str, purpose: str, audience: str, brief_fil
|
|
|
774
790
|
brief = {"schemaVersion": "maggie-page-brief.v1", "route": route, "purpose": purpose.strip() or source_brief["purpose"], "audience": audience.strip() or source_brief["audience"], "contentBrief": source_brief.get("contentBrief", ""), "shell": "homepage-canonical", "status": "draft", "sourceEvidence": None, "approval": {"status": "pending", "actor": None}, "createdAt": datetime.now(timezone.utc).isoformat()}
|
|
775
791
|
digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
|
|
776
792
|
output = project / ".maggie" / "design" / "briefs" / f"{relative.replace('/', '-')}.json"; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(brief, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
793
|
+
required_steps = ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]
|
|
777
794
|
plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
|
|
778
|
-
plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps":
|
|
795
|
+
plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps": required_steps, "required_steps": required_steps, "steps": _step_records(required_steps), "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}]}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
779
796
|
print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
|
|
780
797
|
|
|
781
798
|
|
|
@@ -222,7 +222,24 @@ def make_record(provider, source, pid, name, description, variants, booking, lin
|
|
|
222
222
|
if not booking:
|
|
223
223
|
for href,_ in links:
|
|
224
224
|
if any(x in href.lower() for x in ("book","appointment","checkout")): booking=urljoin(source,href); break
|
|
225
|
-
|
|
225
|
+
record = {"id":f"{provider}:{pid}","provider":provider,"providerServiceId":pid,"slug":slug(name),"title":name,"description":description.strip(),"sourceUrl":source,"category":{"level1":"Uncategorised","level2":None},"variants":variants,"bookingUrl":booking,"paymentUrl":None,"status":"active","supplyState":"live","displayState":"published","firstSeenAt":NOW(),"lastSeenAt":NOW()}
|
|
226
|
+
# A treatment is one catalogue entry. Duration/price choices belong to
|
|
227
|
+
# variants so the same treatment is not presented as four products.
|
|
228
|
+
durations = [int(item["durationMinutes"]) for item in variants if isinstance(item, dict) and isinstance(item.get("durationMinutes"), (int, float)) and item["durationMinutes"] > 0]
|
|
229
|
+
priced = [item.get("price", {}) for item in variants if isinstance(item, dict) and isinstance(item.get("price"), dict) and isinstance(item.get("price", {}).get("amountMinor"), int)]
|
|
230
|
+
currencies = {item.get("currency") for item in priced if item.get("currency")}
|
|
231
|
+
from_price = None
|
|
232
|
+
if priced and len(currencies) == 1:
|
|
233
|
+
lowest = min(priced, key=lambda item: item["amountMinor"])
|
|
234
|
+
from_price = {"amountMinor": lowest["amountMinor"], "currency": lowest["currency"]}
|
|
235
|
+
record["treatmentGroup"] = {
|
|
236
|
+
"id": record["id"],
|
|
237
|
+
"title": name,
|
|
238
|
+
"variantCount": len(variants),
|
|
239
|
+
"durationRange": {"minMinutes": min(durations), "maxMinutes": max(durations)} if durations else None,
|
|
240
|
+
"fromPrice": from_price,
|
|
241
|
+
}
|
|
242
|
+
return record
|
|
226
243
|
|
|
227
244
|
def root(args): return Path(args.project).resolve()
|
|
228
245
|
def path(project): return project/".maggie"/"booking"/"services.json"
|
|
@@ -1263,6 +1280,36 @@ def cmd_resolver_audit(args):
|
|
|
1263
1280
|
print(json.dumps({"status": report["status"], "report": str(output), "counts": counts, "errors": errors}, indent=2)); return 0 if not errors else 1
|
|
1264
1281
|
|
|
1265
1282
|
|
|
1283
|
+
def cmd_treatment_group_audit(args):
|
|
1284
|
+
"""Require one treatment record with explicit duration/price variants."""
|
|
1285
|
+
data = load(root(args))
|
|
1286
|
+
errors = []
|
|
1287
|
+
groups = {}
|
|
1288
|
+
for service in data.get("services", []):
|
|
1289
|
+
group = service.get("treatmentGroup") if isinstance(service.get("treatmentGroup"), dict) else {}
|
|
1290
|
+
group_id = str(group.get("id") or "")
|
|
1291
|
+
if not group_id:
|
|
1292
|
+
errors.append(f"{service.get('id', 'unknown')}: missing treatmentGroup.id")
|
|
1293
|
+
continue
|
|
1294
|
+
if group_id in groups:
|
|
1295
|
+
errors.append(f"duplicate treatment group: {group_id}")
|
|
1296
|
+
groups[group_id] = service.get("id")
|
|
1297
|
+
variants = service.get("variants") if isinstance(service.get("variants"), list) else []
|
|
1298
|
+
if not variants:
|
|
1299
|
+
errors.append(f"{service.get('id', 'unknown')}: treatment group has no variants")
|
|
1300
|
+
if group.get("variantCount") != len(variants):
|
|
1301
|
+
errors.append(f"{service.get('id', 'unknown')}: treatmentGroup.variantCount is stale")
|
|
1302
|
+
durations = [item.get("durationMinutes") for item in variants if isinstance(item, dict) and isinstance(item.get("durationMinutes"), (int, float))]
|
|
1303
|
+
duration_range = group.get("durationRange")
|
|
1304
|
+
if durations and (not isinstance(duration_range, dict) or duration_range.get("minMinutes") != min(durations) or duration_range.get("maxMinutes") != max(durations)):
|
|
1305
|
+
errors.append(f"{service.get('id', 'unknown')}: treatmentGroup.durationRange is stale")
|
|
1306
|
+
report = {"schemaVersion": "maggie-treatment-groups.v1", "passed": not errors, "errors": errors, "treatments": len(groups), "variants": sum(len(item.get("variants", [])) for item in data.get("services", []))}
|
|
1307
|
+
if args.output:
|
|
1308
|
+
output = (root(args) / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output)
|
|
1309
|
+
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
1310
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
1311
|
+
return 0 if report["passed"] else 1
|
|
1312
|
+
|
|
1266
1313
|
def main():
|
|
1267
1314
|
p=argparse.ArgumentParser(); sub=p.add_subparsers(dest="command",required=True)
|
|
1268
1315
|
for name in ("import","sync","run"):
|
|
@@ -1279,6 +1326,7 @@ def main():
|
|
|
1279
1326
|
q=sub.add_parser("category-hash"); q.add_argument("--project",default="."); q.add_argument("--copy-data",required=True); q.add_argument("--apply",action="store_true",help="backfill deterministic provenance hashes in this generated artifact")
|
|
1280
1327
|
q=sub.add_parser("fact-audit"); q.add_argument("--project",default="."); q.add_argument("--backfill-source",action="store_true",help="copy the catalogue sourceUrl into records that have no sourceUrl"); q.add_argument("--overrides",help="JSON of human-approved, evidenced descriptions for provider gaps"); q.add_argument("--apply-overrides",action="store_true",help="apply only approved fact overrides to the draft catalogue")
|
|
1281
1328
|
q=sub.add_parser("catalogue-check"); q.add_argument("source"); q.add_argument("--project",default="."); q.add_argument("--provider",default="fresha",choices=["fresha"]); q.add_argument("--treatment",action="append",required=True,help="provider treatment name; repeat for multiple checks")
|
|
1329
|
+
q=sub.add_parser("treatment-group-audit", help="ensure each treatment is one entry with duration/price variants"); q.add_argument("--project",default="."); q.add_argument("--output")
|
|
1282
1330
|
q=sub.add_parser("retirement-audit"); q.add_argument("--project",default="."); q.add_argument("--catalogue",default=".maggie/booking/services.json"); q.add_argument("--evidence",required=True,help="sanitized runtime retirement evidence JSON"); q.add_argument("--output",default="docs/service-retirement-audit.json")
|
|
1283
1331
|
q=sub.add_parser("capability-audit", help="validate provider variant declarations and fixture evidence"); q.add_argument("--project",default="."); q.add_argument("--provider"); q.add_argument("--catalogue",default=".maggie/booking/services.json"); q.add_argument("--capabilities-file",default=".maggie/booking/provider-capabilities.json"); q.add_argument("--fixture",help="sanitized provider fixture JSON"); q.add_argument("--output")
|
|
1284
1332
|
q=sub.add_parser("category-context"); q.add_argument("--project",default="."); q.add_argument("--output")
|
|
@@ -1306,6 +1354,7 @@ def main():
|
|
|
1306
1354
|
if a.command=="category-hash": return cmd_category_hash(a)
|
|
1307
1355
|
if a.command=="fact-audit": return cmd_fact_audit(a)
|
|
1308
1356
|
if a.command=="catalogue-check": return cmd_catalogue_check(a)
|
|
1357
|
+
if a.command=="treatment-group-audit": return cmd_treatment_group_audit(a)
|
|
1309
1358
|
if a.command=="retirement-audit": return cmd_retirement_audit(a)
|
|
1310
1359
|
if a.command=="capability-audit": return cmd_capability_audit(a)
|
|
1311
1360
|
if a.command=="category-context": return cmd_category_context(a)
|
|
@@ -69,9 +69,20 @@ class PageParser(HTMLParser):
|
|
|
69
69
|
self.paragraphs = []
|
|
70
70
|
self.links = []
|
|
71
71
|
self.fragment_ids = set()
|
|
72
|
+
self.primary_navigation_links = []
|
|
73
|
+
self._header_depth = 0
|
|
74
|
+
self._nav_depth = 0
|
|
75
|
+
self._primary_nav_depth = 0
|
|
72
76
|
|
|
73
77
|
def handle_starttag(self, tag, attrs):
|
|
74
78
|
data = dict(attrs)
|
|
79
|
+
if tag == "header":
|
|
80
|
+
self._header_depth += 1
|
|
81
|
+
if tag == "nav":
|
|
82
|
+
self._nav_depth += 1
|
|
83
|
+
label = f"{data.get('aria-label', '')} {data.get('id', '')} {data.get('class', '')}".lower()
|
|
84
|
+
if self._header_depth or any(token in label for token in ("primary", "main", "navigation")):
|
|
85
|
+
self._primary_nav_depth += 1
|
|
75
86
|
if data.get("id"):
|
|
76
87
|
self.fragment_ids.add(data["id"])
|
|
77
88
|
if tag == "a" and data.get("name"):
|
|
@@ -106,6 +117,8 @@ class PageParser(HTMLParser):
|
|
|
106
117
|
self._jsonld = []
|
|
107
118
|
elif tag == "a" and data.get("href"):
|
|
108
119
|
self.anchors += 1
|
|
120
|
+
if self._primary_nav_depth:
|
|
121
|
+
self.primary_navigation_links.append(data.get("href", ""))
|
|
109
122
|
elif tag == "img":
|
|
110
123
|
self.images.append({"src": data.get("src", ""), "alt": data.get("alt")})
|
|
111
124
|
|
|
@@ -132,6 +145,12 @@ class PageParser(HTMLParser):
|
|
|
132
145
|
except json.JSONDecodeError:
|
|
133
146
|
self.jsonld_values.append(None)
|
|
134
147
|
self._jsonld = None
|
|
148
|
+
if tag == "nav":
|
|
149
|
+
self._nav_depth = max(0, self._nav_depth - 1)
|
|
150
|
+
if self._primary_nav_depth:
|
|
151
|
+
self._primary_nav_depth -= 1
|
|
152
|
+
elif tag == "header":
|
|
153
|
+
self._header_depth = max(0, self._header_depth - 1)
|
|
135
154
|
|
|
136
155
|
def handle_data(self, data):
|
|
137
156
|
if not self.hidden_depth and data.strip():
|
|
@@ -193,6 +212,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
193
212
|
"lang": page.lang, "hreflang": page.hreflang, "robots": robots,
|
|
194
213
|
"jsonld": page.jsonld_values, "images": page.images,
|
|
195
214
|
"fragmentIds": sorted(page.fragment_ids),
|
|
215
|
+
"primaryNavigationLinks": page.primary_navigation_links,
|
|
196
216
|
"structureHash": hashlib.sha256(json.dumps(stable_structure(page.structure), sort_keys=True).encode()).hexdigest(),
|
|
197
217
|
"textHash": hashlib.sha256(" ".join(page.visible_text).encode()).hexdigest(),
|
|
198
218
|
},
|
|
@@ -215,9 +235,37 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
215
235
|
"robots": not robots or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens),
|
|
216
236
|
"robots_directives": robots,
|
|
217
237
|
"robots_conflict": len({token for token in robots_tokens if token in {"index", "noindex", "follow", "nofollow", "none"}} & {"index", "noindex"}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow", "none"}} & {"follow", "nofollow"}) > 1,
|
|
238
|
+
"primary_navigation_links": page.primary_navigation_links,
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
|
|
242
|
+
def primary_navigation_check(base: str, page: PageParser, fetcher=fetch) -> dict:
|
|
243
|
+
"""Ensure links presented as primary navigation are indexable destinations."""
|
|
244
|
+
origin = urlparse(base).netloc
|
|
245
|
+
routes = []
|
|
246
|
+
for href in page.primary_navigation_links:
|
|
247
|
+
target = urljoin(base + "/", href)
|
|
248
|
+
parsed = urlparse(target)
|
|
249
|
+
if parsed.scheme not in {"http", "https"} or parsed.netloc != origin:
|
|
250
|
+
continue
|
|
251
|
+
target = urlunparse((parsed.scheme, parsed.netloc, parsed.path or "/", "", "", ""))
|
|
252
|
+
if target not in routes:
|
|
253
|
+
routes.append(target)
|
|
254
|
+
violations = []
|
|
255
|
+
for target in routes:
|
|
256
|
+
try:
|
|
257
|
+
status, content_type, body = fetcher(target)
|
|
258
|
+
destination = PageParser(); destination.feed(body)
|
|
259
|
+
directives = [item.lower() for item in destination.robots_directives]
|
|
260
|
+
tokens = [token.strip().rsplit(":", 1)[-1] for directive in directives for token in directive.split(",")]
|
|
261
|
+
blocked = sorted(set(tokens) & {"noindex", "nofollow", "none"})
|
|
262
|
+
if blocked:
|
|
263
|
+
violations.append({"url": target, "status": status, "directives": directives, "blocked": blocked})
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
violations.append({"url": target, "error": type(exc).__name__})
|
|
266
|
+
return {"ok": not violations, "links": routes, "violations": violations}
|
|
267
|
+
|
|
268
|
+
|
|
221
269
|
def summarize_crawl(pages: list[dict]) -> dict:
|
|
222
270
|
"""Count captured evidence, preserving regional/script locale distinctions."""
|
|
223
271
|
groups = {}
|
|
@@ -470,6 +518,7 @@ def main() -> int:
|
|
|
470
518
|
checks["jsonld"] = {"ok": page.jsonld > 0 and all(item is not None for item in page.jsonld_values), "count": page.jsonld}
|
|
471
519
|
checks["entity_jsonld"] = {"ok": any(isinstance(item, dict) and item.get("@type") and (item.get("url") or item.get("@id")) for item in page.jsonld_values), "count": page.jsonld}
|
|
472
520
|
checks["crawlable_links"] = {"ok": page.anchors > 0, "count": page.anchors}
|
|
521
|
+
checks["primary_navigation_indexability"] = primary_navigation_check(base, page)
|
|
473
522
|
robots_tokens = [token.strip() for directive in page.robots_directives for token in directive.split(",")]
|
|
474
523
|
checks["robots_directive"] = {"ok": not page.robots_directives or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens), "directives": page.robots_directives}
|
|
475
524
|
checks["robots_conflict"] = {"ok": not (len({token for token in robots_tokens if token in {"index", "noindex"}}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow"}}) > 1), "directives": page.robots_directives}
|