@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.
- package/README-zh-TW.md +48 -2
- package/README.md +66 -54
- package/bin/maggie.js +18 -7
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-booking/SKILL.md +47 -3
- package/bundled-skills/maggie-deployment/SKILL.md +21 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +17 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +17 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +36 -6
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +26 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +15 -4
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
- package/bundled-tools/clis/maggie_design.py +9 -2
- package/bundled-tools/clis/maggie_design_lint.py +75 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +136 -4
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +52 -6
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- 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())
|