@topy-ai/maggie 0.7.52 → 0.7.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -54
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +17 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +36 -6
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +9 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +75 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +136 -4
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """Fit-to-screen check for dashboards (``maggie qa fit-check``).
3
+
4
+ Some dashboards must never scroll the page: everything fits the viewport, and
5
+ long content scrolls inside a pane, a drawer or a pager. Checking this by hand
6
+ misses states — a tab that is hidden at load measures zero, so a list inside
7
+ it looks fine until it is shown. This command loads each route at each
8
+ viewport (default a short laptop 1536x687 and a 390x844 phone), measures page
9
+ overflow, then **activates every tab** (DOM click, so a background browser
10
+ window still works) and **opens every drawer trigger** and measures again
11
+ after each change. Hidden panels are only measured after activation.
12
+
13
+ Requests other than GET/HEAD/OPTIONS are failed by default (``--allow-write``
14
+ lets a token refresh through), so opening drawers on a production-backed app
15
+ cannot change data. One tab, one state at a time: safe with rotating refresh
16
+ tokens. Output is ``maggie-qa-fit-check.v1`` JSON; exit 1 when a state
17
+ overflows.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
30
+ from cdp_client import Browser, CdpError, attach, launch # noqa: E402
31
+
32
+ SCHEMA = "maggie-qa-fit-check.v1"
33
+ DEFAULT_VIEWPORTS = ("1536x687", "390x844")
34
+ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
35
+
36
+ # Icon fonts put private-use glyphs into innerText (React Native Web tabs read
37
+ # "\nImage" or "Home"); labels drop them and prefer aria-label.
38
+ HELPERS = r"""
39
+ const __fit = window.__maggieFit = window.__maggieFit || {};
40
+ __fit.label = (el) => {
41
+ const aria = el.getAttribute('aria-label') || '';
42
+ const text = (aria || el.innerText || el.textContent || '').replace(/[-]/g, '').replace(/\s+/g, ' ').trim();
43
+ return text.slice(0, 60);
44
+ };
45
+ __fit.visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
46
+ __fit.measure = (config) => {
47
+ const doc = document.scrollingElement || document.documentElement;
48
+ // Mobile emulation widens the layout viewport to fit wide content, so compare with the requested size.
49
+ const W = config.width || window.innerWidth, H = config.height || window.innerHeight;
50
+ const result = {width: W, height: H, pageOverflowY: Math.max(0, doc.scrollHeight - H), pageOverflowX: Math.max(0, doc.scrollWidth - W)};
51
+ if (result.pageOverflowX > 1) {
52
+ const wide = [...document.body.querySelectorAll('*')].find((el) => { const r = el.getBoundingClientRect(); return r.right > W + 1 && r.width > 0 && __fit.visible(el); });
53
+ if (wide) result.widest = (wide.tagName.toLowerCase() + (wide.id ? '#' + wide.id : '') + (typeof wide.className === 'string' && wide.className ? '.' + wide.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 80);
54
+ }
55
+ if (config.sidebarSelector) {
56
+ const side = document.querySelector(config.sidebarSelector);
57
+ if (side) result.sidebarOverflowY = Math.max(0, side.scrollHeight - side.clientHeight);
58
+ }
59
+ const dialog = [...document.querySelectorAll('[role=dialog], dialog[open], [aria-modal=true]')].find(__fit.visible);
60
+ if (dialog) {
61
+ const r = dialog.getBoundingClientRect();
62
+ const inner = [dialog, ...dialog.querySelectorAll('*')].some((el) => el.scrollHeight > el.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(el).overflowY));
63
+ result.dialog = {label: __fit.label(dialog), fits: r.left >= -1 && r.top >= -1 && r.right <= W + 1 && r.bottom <= H + 1, scrollsInside: inner};
64
+ }
65
+ return result;
66
+ };
67
+ __fit.targets = (selector) => [...document.querySelectorAll(selector)].filter(__fit.visible);
68
+ """
69
+
70
+
71
+ def parse_viewport(value: str) -> tuple[int, int]:
72
+ match = re.fullmatch(r"(\d{3,4})x(\d{3,4})", value.strip())
73
+ if not match:
74
+ raise ValueError(f"viewport must look like 1536x687, got {value!r}")
75
+ return int(match.group(1)), int(match.group(2))
76
+
77
+
78
+ def verdict(states: list[dict], tolerance: int = 1) -> list[str]:
79
+ """Failures for measured states: page overflow on either axis, a drawer outside the viewport."""
80
+ failures = []
81
+ for state in states:
82
+ where = f"{state['route']} @ {state['viewport']} [{state['state']}]"
83
+ m = state.get("measure") or {}
84
+ if state.get("error"):
85
+ failures.append(f"{where}: {state['error']}")
86
+ continue
87
+ if m.get("pageOverflowY", 0) > tolerance:
88
+ failures.append(f"{where}: the page scrolls vertically by {m['pageOverflowY']} px; move long content into a pane, drawer or pager")
89
+ if m.get("pageOverflowX", 0) > tolerance:
90
+ failures.append(f"{where}: the page scrolls sideways by {m['pageOverflowX']} px" + (f" (first wide element: {m['widest']})" if m.get("widest") else ""))
91
+ if m.get("sidebarOverflowY", 0) > tolerance:
92
+ failures.append(f"{where}: the sidebar overflows by {m['sidebarOverflowY']} px")
93
+ dialog = m.get("dialog")
94
+ if dialog and not dialog.get("fits"):
95
+ failures.append(f"{where}: drawer {dialog.get('label') or ''!r} extends past the viewport")
96
+ return failures
97
+
98
+
99
+ def _install_read_only(browser: Browser, allow: list[str], blocked: list[str]) -> None:
100
+ def paused(params: dict) -> None:
101
+ request = params.get("request", {})
102
+ url = request.get("url", "")
103
+ if request.get("method", "GET").upper() in WRITE_METHODS and not any(token in url for token in allow):
104
+ blocked.append(f"{request.get('method')} {url.split('?')[0][:120]}")
105
+ browser.call("Fetch.failRequest", {"requestId": params["requestId"], "errorReason": "BlockedByClient"})
106
+ else:
107
+ browser.call("Fetch.continueRequest", {"requestId": params["requestId"]})
108
+ browser.handlers["Fetch.requestPaused"] = paused
109
+ browser.call("Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]})
110
+
111
+
112
+ def _load(browser: Browser, url: str, settle: float, budget: float) -> None:
113
+ browser.call("Page.navigate", {"url": url})
114
+ browser.wait_for("Page.loadEventFired", budget)
115
+ browser.pump(settle)
116
+ browser.evaluate(HELPERS)
117
+
118
+
119
+ def check(browser: Browser, base_url: str, routes: list[str], viewports: list[tuple[int, int]], options: dict, settle_ms: int = 800, budget_ms: int = 20000, read_only: bool = True, allow_write: list[str] | None = None) -> dict:
120
+ settle, budget = settle_ms / 1000, budget_ms / 1000
121
+ blocked: list[str] = []
122
+ browser.open_tab()
123
+ browser.call("Page.enable")
124
+ if read_only:
125
+ _install_read_only(browser, allow_write or [], blocked)
126
+ states: list[dict] = []
127
+ for width, height in viewports: # sequential: one app instance at a time
128
+ browser.call("Emulation.setDeviceMetricsOverride", {"width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 768})
129
+ viewport = f"{width}x{height}"
130
+ config = json.dumps({"sidebarSelector": options.get("sidebarSelector"), "width": width, "height": height})
131
+ for route in routes:
132
+ url = base_url.rstrip("/") + route
133
+ _load(browser, url, settle, budget)
134
+ states.append({"route": route, "viewport": viewport, "state": "initial", "measure": browser.evaluate(f"__maggieFit.measure({config})")})
135
+ tab_selector = json.dumps(options["tabSelector"])
136
+ tabs = browser.evaluate(f"__maggieFit.targets({tab_selector}).map(__maggieFit.label)") or []
137
+ for index, label in enumerate(tabs):
138
+ clicked = browser.evaluate(f"(() => {{ const el = __maggieFit.targets({tab_selector})[{index}]; if (!el) return false; el.click(); return true; }})()")
139
+ browser.pump(settle)
140
+ entry = {"route": route, "viewport": viewport, "state": f"tab {index + 1}: {label or '(no label)'}"}
141
+ if clicked:
142
+ entry["measure"] = browser.evaluate(f"__maggieFit.measure({config})")
143
+ entry["hash"] = browser.evaluate("location.hash")
144
+ else:
145
+ entry["error"] = "tab disappeared before it could be activated"
146
+ states.append(entry)
147
+ if options.get("drawerSelector"):
148
+ drawer_selector = json.dumps(options["drawerSelector"])
149
+ _load(browser, url, settle, budget)
150
+ triggers = browser.evaluate(f"__maggieFit.targets({drawer_selector}).map(__maggieFit.label)") or []
151
+ for index, label in enumerate(triggers[: options.get("maxDrawers", 12)]):
152
+ if index:
153
+ _load(browser, url, settle, budget) # a fresh page per drawer, so states never stack
154
+ browser.evaluate(f"(() => {{ const el = __maggieFit.targets({drawer_selector})[{index}]; if (el) el.click(); }})()")
155
+ browser.pump(settle)
156
+ measure = browser.evaluate(f"__maggieFit.measure({config})")
157
+ entry = {"route": route, "viewport": viewport, "state": f"drawer {index + 1}: {label or '(no label)'}", "measure": measure}
158
+ if not (measure or {}).get("dialog"):
159
+ entry["warning"] = "no visible role=dialog after the click"
160
+ states.append(entry)
161
+ failures = verdict(states)
162
+ return {
163
+ "schemaVersion": SCHEMA,
164
+ "baseUrl": base_url,
165
+ "routes": routes,
166
+ "viewports": [f"{w}x{h}" for w, h in viewports],
167
+ "states": states,
168
+ "blockedWrites": sorted(set(blocked)),
169
+ "status": "failed" if failures else "passed",
170
+ "failures": failures,
171
+ "checkedAt": datetime.now(timezone.utc).isoformat(),
172
+ }
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ parser = argparse.ArgumentParser(prog="maggie qa fit-check", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
177
+ parser.add_argument("--base-url", required=True)
178
+ parser.add_argument("--route", action="append", required=True, help="path to check; repeat for every dashboard route")
179
+ parser.add_argument("--viewport", action="append", default=[], help="WIDTHxHEIGHT; repeatable (default 1536x687 and 390x844)")
180
+ session = parser.add_mutually_exclusive_group(required=True)
181
+ session.add_argument("--cdp-url", help="attach to a signed-in Chrome started with --remote-debugging-port")
182
+ session.add_argument("--profile-dir", type=Path, help="dedicated QA Chrome profile (never your everyday profile)")
183
+ parser.add_argument("--chrome", default="")
184
+ parser.add_argument("--headed", action="store_true")
185
+ parser.add_argument("--tab-selector", default="[role=tab]")
186
+ parser.add_argument("--drawer-selector", help="elements that open a drawer or dialog, e.g. [data-drawer-trigger]")
187
+ parser.add_argument("--sidebar-selector", help="sidebar that must not overflow either")
188
+ parser.add_argument("--allow-write", action="append", default=[], help="URL substring allowed to POST (token refresh)")
189
+ parser.add_argument("--allow-writes", action="store_true", help="do not block write requests (non-production API only)")
190
+ parser.add_argument("--settle-ms", type=int, default=800)
191
+ parser.add_argument("--output", type=Path)
192
+ args = parser.parse_args(argv)
193
+ browser = None
194
+ try:
195
+ viewports = [parse_viewport(value) for value in (args.viewport or DEFAULT_VIEWPORTS)]
196
+ if args.cdp_url:
197
+ browser = attach(args.cdp_url)
198
+ else:
199
+ from maggie_icon_glyph_check import find_chrome
200
+ browser = launch(args.chrome or find_chrome(), args.profile_dir.resolve(), headless=not args.headed)
201
+ report = check(browser, args.base_url, args.route, viewports, {"tabSelector": args.tab_selector, "drawerSelector": args.drawer_selector, "sidebarSelector": args.sidebar_selector}, args.settle_ms, read_only=not args.allow_writes, allow_write=args.allow_write)
202
+ except (OSError, ValueError, CdpError, RuntimeError) as error:
203
+ print(f"BLOCKED: maggie qa fit-check: {error}", file=sys.stderr)
204
+ return 2
205
+ finally:
206
+ if browser:
207
+ browser.close()
208
+ text = json.dumps(report, indent=2, ensure_ascii=False)
209
+ if args.output:
210
+ args.output.parent.mkdir(parents=True, exist_ok=True)
211
+ args.output.write_text(text + "\n", encoding="utf-8")
212
+ print(text)
213
+ return 1 if report["failures"] else 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ raise SystemExit(main())
@@ -615,6 +615,11 @@ def parser() -> argparse.ArgumentParser:
615
615
 
616
616
 
617
617
  def main() -> int:
618
+ if "fit-check" in sys.argv[1:3]:
619
+ from maggie_qa_fit import main as fit_main
620
+ argv = sys.argv[1:]
621
+ index = argv.index("fit-check")
622
+ return fit_main(argv[:index] + argv[index + 1:])
618
623
  if "app-preflight" in sys.argv[1:3]:
619
624
  from maggie_qa_app_preflight import main as app_preflight_main
620
625
  argv = sys.argv[1:]
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Estimate structured-output JSON schema complexity before a model rejects it.
3
+
4
+ Gemini answers ``400 INVALID_ARGUMENT`` (with no detail) when a response
5
+ schema is too complex. The limit is complexity, not vocabulary size: an enum
6
+ of 5-6 values inside the items of an array with ``maxItems: 20`` fails, while
7
+ 4 values or ``maxItems: 10`` pass. The size of the state space is roughly
8
+ enum values x repeated array items x nesting, so the check:
9
+
10
+ - fails on any ``enum``/``const`` list inside repeated array items: describe
11
+ the allowed values in the prompt or the field description and validate
12
+ server-side instead;
13
+ - fails on enums over 20 values anywhere, and on arrays without ``maxItems``
14
+ around nested objects;
15
+ - reports an estimated complexity score (sum of enum values and properties,
16
+ each multiplied by the ``maxItems`` of every enclosing array) and warns
17
+ above 200, plus nesting deeper than 5.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ ENUM_LIMIT = 20
28
+ SCORE_WARN = 200
29
+ DEPTH_WARN = 5
30
+
31
+
32
+ def check_schema(schema: dict) -> dict:
33
+ errors: list[str] = []
34
+ warnings: list[str] = []
35
+ score = 0
36
+ deepest = 0
37
+
38
+ def walk(node: object, path: str, multiplier: int, in_array: bool, depth: int) -> None:
39
+ nonlocal score, deepest
40
+ if not isinstance(node, dict):
41
+ return
42
+ deepest = max(deepest, depth)
43
+ values = node.get("enum") if isinstance(node.get("enum"), list) else None
44
+ if values is not None:
45
+ score += len(values) * multiplier
46
+ if in_array:
47
+ errors.append(f"{path}: enum of {len(values)} inside repeated array items (x{multiplier}); describe the allowed values in the prompt and validate server-side")
48
+ elif len(values) > ENUM_LIMIT:
49
+ errors.append(f"{path}: enum of {len(values)} values; use a string, list examples in the prompt, validate server-side")
50
+ properties = node.get("properties") if isinstance(node.get("properties"), dict) else {}
51
+ score += len(properties) * multiplier
52
+ for name, child in properties.items():
53
+ walk(child, f"{path}.{name}", multiplier, in_array, depth + 1)
54
+ items = node.get("items")
55
+ if isinstance(items, dict):
56
+ max_items = node.get("maxItems")
57
+ if not isinstance(max_items, int):
58
+ if isinstance(items.get("properties"), dict) or isinstance(items.get("enum"), list):
59
+ errors.append(f"{path}: array of objects/enums without maxItems; set a small maxItems")
60
+ max_items = 10
61
+ walk(items, f"{path}[]", multiplier * max(1, max_items), True, depth + 1)
62
+ for key in ("anyOf", "oneOf", "allOf"):
63
+ for index, child in enumerate(node.get(key) or []):
64
+ walk(child, f"{path}.{key}[{index}]", multiplier, in_array, depth + 1)
65
+
66
+ walk(schema, "$", 1, False, 0)
67
+ if score > SCORE_WARN:
68
+ warnings.append(f"estimated complexity {score} is high; lower maxItems, split the call, or move vocabularies into the prompt")
69
+ if deepest > DEPTH_WARN:
70
+ warnings.append(f"nesting depth {deepest}; flatten the schema")
71
+ return {"schemaVersion": "maggie-schema-complexity.v1", "valid": not errors, "score": score, "depth": deepest, "errors": errors, "warnings": warnings}
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = argparse.ArgumentParser(prog="maggie social schema-check", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
76
+ parser.add_argument("input", type=Path, help="JSON schema file (responseJsonSchema)")
77
+ args = parser.parse_args(argv)
78
+ result = check_schema(json.loads(args.input.read_text(encoding="utf-8")))
79
+ print(json.dumps(result, indent=2, ensure_ascii=False))
80
+ return 0 if result["valid"] else 1
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Route-check guards for SEO release checks (``maggie seo origin-check|fragment-check``).
3
+
4
+ ``origin-check`` proves which site a base URL serves before route checks run
5
+ against it. Check scripts that default to ``http://127.0.0.1:4321`` silently hit
6
+ whatever else runs on that port and report false "foreign URL" and sitemap
7
+ failures. The command fetches the page and ``/robots.txt`` and compares the
8
+ canonical / ``og:url`` / sitemap host with the project's configured site
9
+ (``site`` in ``astro.config.*``, or ``--expect-host``), and prints the origin it
10
+ checked.
11
+
12
+ ``fragment-check`` checks personalised HTML fragments (recently viewed,
13
+ recommendations, "your bookings" partials): each must answer
14
+ ``Cache-Control: private, no-store`` (no ``public``/``s-maxage``) and
15
+ ``X-Robots-Tag: noindex``. Global middleware that sets public caching on every
16
+ ``text/html`` response must keep a route's private header; otherwise a CDN
17
+ caches one person's fragment and serves it to others.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+ from urllib.error import HTTPError, URLError
28
+ from urllib.parse import urljoin, urlsplit
29
+ from urllib.request import Request, urlopen
30
+
31
+ CANONICAL = re.compile(r"<link[^>]+rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)|<link[^>]+href=[\"']([^\"']+)[\"'][^>]*rel=[\"']canonical[\"']", re.I)
32
+ OG_URL = re.compile(r"<meta[^>]+property=[\"']og:url[\"'][^>]*content=[\"']([^\"']+)", re.I)
33
+ SITE_CONFIG = re.compile(r"\bsite\s*:\s*[\"'`](https?://[^\"'`]+)[\"'`]")
34
+
35
+
36
+ def fetch(url: str, headers: dict | None = None, limit: int = 2_000_000) -> tuple[int, dict, str]:
37
+ request = Request(url, headers={"User-Agent": "maggie-seo-route-check", **(headers or {})})
38
+ try:
39
+ with urlopen(request, timeout=20) as response: # noqa: S310 - operator-supplied check URL
40
+ return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read(limit).decode("utf-8", "replace")
41
+ except HTTPError as error:
42
+ return error.code, {k.lower(): v for k, v in error.headers.items()}, ""
43
+
44
+
45
+ def host(url: str) -> str:
46
+ return (urlsplit(url).hostname or "").lower().removeprefix("www.")
47
+
48
+
49
+ def configured_host(project: Path) -> str | None:
50
+ for name in ("astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"):
51
+ path = project / name
52
+ if path.is_file():
53
+ match = SITE_CONFIG.search(path.read_text(encoding="utf-8", errors="replace"))
54
+ if match:
55
+ return host(match.group(1))
56
+ return None
57
+
58
+
59
+ def origin_check(base_url: str, project: Path, expect_host: str | None = None, fetcher=fetch) -> dict:
60
+ parts = urlsplit(base_url)
61
+ if parts.scheme not in {"http", "https"} or not parts.netloc:
62
+ raise ValueError("--base-url must be an absolute http(s) URL; route checks must never assume a default port")
63
+ origin = f"{parts.scheme}://{parts.netloc}"
64
+ expected = (expect_host or configured_host(project) or "").lower().removeprefix("www.") or None
65
+ errors: list[str] = []
66
+ warnings: list[str] = []
67
+ try:
68
+ status, _, html = fetcher(base_url)
69
+ robots_status, _, robots = fetcher(urljoin(origin + "/", "robots.txt"))
70
+ except (URLError, OSError) as error:
71
+ return {"schemaVersion": "maggie-seo-origin-check.v1", "origin": origin, "passed": False, "errors": [f"nothing answered at {origin}: {error}"], "warnings": []}
72
+ seen = {}
73
+ canonical = CANONICAL.search(html)
74
+ if canonical:
75
+ seen["canonical"] = host(canonical.group(1) or canonical.group(2))
76
+ og = OG_URL.search(html)
77
+ if og:
78
+ seen["og:url"] = host(og.group(1))
79
+ sitemap = re.search(r"^\s*sitemap:\s*(\S+)", robots, re.I | re.M) if robots_status == 200 else None
80
+ if sitemap:
81
+ seen["robots sitemap"] = host(sitemap.group(1))
82
+ if status >= 400:
83
+ errors.append(f"{base_url} answered {status}")
84
+ if not expected:
85
+ warnings.append("no expected host: set `site` in astro.config or pass --expect-host, so a different local project on this port is detected")
86
+ elif not seen:
87
+ warnings.append(f"{origin} shows no canonical, og:url or robots sitemap to identify the site")
88
+ else:
89
+ foreign = {source: value for source, value in seen.items() if value and value != expected and not value.endswith("." + expected)}
90
+ if foreign:
91
+ errors.append(f"{origin} serves another site ({', '.join(f'{k}: {v}' for k, v in foreign.items())}), not {expected}; start this project's server or pass the right --base-url")
92
+ return {"schemaVersion": "maggie-seo-origin-check.v1", "origin": origin, "expectedHost": expected, "seenHosts": seen, "passed": not errors, "errors": errors, "warnings": warnings}
93
+
94
+
95
+ def fragment_problems(headers: dict) -> list[str]:
96
+ cache = headers.get("cache-control", "").lower()
97
+ directives = {item.strip().split("=")[0] for item in cache.split(",") if item.strip()}
98
+ problems = []
99
+ if "private" not in directives:
100
+ problems.append("Cache-Control lacks private")
101
+ if "no-store" not in directives:
102
+ problems.append("Cache-Control lacks no-store")
103
+ if directives & {"public", "s-maxage"}:
104
+ problems.append("Cache-Control allows shared caching (" + ", ".join(sorted(directives & {"public", "s-maxage"})) + "); middleware must keep the route's private header")
105
+ if "noindex" not in headers.get("x-robots-tag", "").lower():
106
+ problems.append("X-Robots-Tag lacks noindex")
107
+ if headers.get("cdn-cache-control") or headers.get("surrogate-control"):
108
+ problems.append("a CDN-specific cache header is set; remove it for personalised fragments")
109
+ return problems
110
+
111
+
112
+ def fragment_check(urls: list[str], cookie: str | None = None, fetcher=fetch) -> dict:
113
+ rows = []
114
+ errors = []
115
+ for url in urls:
116
+ try:
117
+ status, headers, _ = fetcher(url, {"Cookie": cookie} if cookie else None)
118
+ except (URLError, OSError) as error:
119
+ errors.append(f"{url}: {error}")
120
+ continue
121
+ problems = fragment_problems(headers)
122
+ rows.append({"url": url, "status": status, "cacheControl": headers.get("cache-control"), "xRobotsTag": headers.get("x-robots-tag"), "problems": problems})
123
+ errors += [f"{url}: {problem}" for problem in problems]
124
+ return {"schemaVersion": "maggie-seo-fragment-check.v1", "fragments": rows, "passed": not errors, "errors": errors}
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ parser = argparse.ArgumentParser(prog="maggie seo", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
129
+ sub = parser.add_subparsers(dest="command", required=True)
130
+ origin = sub.add_parser("origin-check")
131
+ origin.add_argument("--base-url", required=True)
132
+ origin.add_argument("--project", type=Path, default=Path("."))
133
+ origin.add_argument("--expect-host")
134
+ fragment = sub.add_parser("fragment-check")
135
+ fragment.add_argument("--url", action="append", required=True, help="personalised fragment URL; repeat")
136
+ fragment.add_argument("--cookie-env", help="environment variable holding a test session cookie (never pass the value on the command line)")
137
+ args = parser.parse_args(argv)
138
+ try:
139
+ if args.command == "origin-check":
140
+ report = origin_check(args.base_url, args.project.resolve(), args.expect_host)
141
+ else:
142
+ import os
143
+ report = fragment_check(args.url, os.environ.get(args.cookie_env) if args.cookie_env else None)
144
+ except ValueError as error:
145
+ print(f"BLOCKED: maggie seo {args.command}: {error}", file=sys.stderr)
146
+ return 2
147
+ print(json.dumps(report, indent=2, ensure_ascii=False))
148
+ if args.command == "origin-check":
149
+ print(f"checked origin: {report['origin']}", file=sys.stderr)
150
+ return 0 if report["passed"] else 1
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())