@topy-ai/maggie 0.7.51 → 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 -45
- 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 +30 -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 +52 -5
- 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 +11 -2
- package/bundled-tools/clis/maggie_design_lint.py +142 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +154 -8
- 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
|
@@ -12,6 +12,13 @@ broken change or quietly damaged real data:
|
|
|
12
12
|
(iframes, tabs, parallel viewports) refresh at once, and a local dev server
|
|
13
13
|
that talks to a production API turns QA clicks into production writes.
|
|
14
14
|
|
|
15
|
+
Two more failures hid stale code: a dev server killed and replaced by another
|
|
16
|
+
session's server on the same port, and a watcher that kept serving the old
|
|
17
|
+
bundle after edits. ``--expect-symbol NAME`` fetches the served entry bundle
|
|
18
|
+
from ``--base-url`` and fails when it lacks a name from the edit just made;
|
|
19
|
+
the report also names the process holding the base URL's port and when it
|
|
20
|
+
started, so a server restarted by someone else is obvious.
|
|
21
|
+
|
|
15
22
|
The command inspects running dev-server processes (Linux ``/proc``; other
|
|
16
23
|
systems fall back to ``ps``), the app's environment files and auth code, and
|
|
17
24
|
reports environment variable names and host classes only — never values,
|
|
@@ -28,7 +35,8 @@ import subprocess
|
|
|
28
35
|
import sys
|
|
29
36
|
from datetime import datetime, timezone
|
|
30
37
|
from pathlib import Path
|
|
31
|
-
from urllib.parse import urlsplit
|
|
38
|
+
from urllib.parse import urljoin, urlsplit
|
|
39
|
+
from urllib.request import Request, urlopen
|
|
32
40
|
|
|
33
41
|
SCHEMA = "maggie-qa-app-preflight.v1"
|
|
34
42
|
DEV_SERVER = re.compile(r"(expo(?:/cli)?[^ ]*\s+start|@expo/cli.*\bstart\b|react-native\s+start|metro(?:\.js)?\s|node_modules/\.bin/metro|expo-router)", re.I)
|
|
@@ -78,6 +86,84 @@ def list_processes() -> list[dict]:
|
|
|
78
86
|
return found
|
|
79
87
|
|
|
80
88
|
|
|
89
|
+
def process_started_at(pid: int) -> str | None:
|
|
90
|
+
"""ISO start time of a Linux process from /proc/<pid>/stat and the boot time; None elsewhere."""
|
|
91
|
+
try:
|
|
92
|
+
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
93
|
+
ticks = int(stat.rsplit(")", 1)[1].split()[19])
|
|
94
|
+
boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines() if line.startswith("btime "))
|
|
95
|
+
return datetime.fromtimestamp(boot + ticks / os.sysconf("SC_CLK_TCK"), timezone.utc).isoformat(timespec="seconds")
|
|
96
|
+
except (OSError, ValueError, IndexError, StopIteration):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def port_owner(port: int) -> dict | None:
|
|
101
|
+
"""The process listening on a TCP port (Linux /proc); command line trimmed to the program and script."""
|
|
102
|
+
inodes = set()
|
|
103
|
+
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
104
|
+
try:
|
|
105
|
+
lines = Path(table).read_text(encoding="utf-8").splitlines()[1:]
|
|
106
|
+
except OSError:
|
|
107
|
+
continue
|
|
108
|
+
for line in lines:
|
|
109
|
+
fields = line.split()
|
|
110
|
+
if len(fields) > 9 and fields[3] == "0A" and int(fields[1].rsplit(":", 1)[1], 16) == port:
|
|
111
|
+
inodes.add(fields[9])
|
|
112
|
+
if not inodes:
|
|
113
|
+
return None
|
|
114
|
+
for entry in Path("/proc").iterdir():
|
|
115
|
+
if not entry.name.isdigit():
|
|
116
|
+
continue
|
|
117
|
+
links = set()
|
|
118
|
+
try:
|
|
119
|
+
descriptors = list((entry / "fd").iterdir())
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
for fd in descriptors:
|
|
123
|
+
try:
|
|
124
|
+
links.add(os.readlink(fd))
|
|
125
|
+
except OSError:
|
|
126
|
+
continue
|
|
127
|
+
if any(f"socket:[{inode}]" in links for inode in inodes):
|
|
128
|
+
try:
|
|
129
|
+
cmdline = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
|
|
130
|
+
cwd = os.readlink(entry / "cwd")
|
|
131
|
+
except OSError:
|
|
132
|
+
cmdline, cwd = "", None
|
|
133
|
+
return {"pid": int(entry.name), "command": " ".join(cmdline.split()[:3])[:160], "cwd": cwd, "startedAt": process_started_at(int(entry.name))}
|
|
134
|
+
return {"pid": None, "command": "unknown (owned by another user)", "cwd": None, "startedAt": None}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
SCRIPT_SRC = re.compile(r"<script[^>]+src=[\"']([^\"']+)[\"']", re.I)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _fetch(url: str, limit: int) -> bytes:
|
|
141
|
+
request = Request(url, headers={"User-Agent": "maggie-qa-app-preflight"})
|
|
142
|
+
with urlopen(request, timeout=60) as response: # noqa: S310 - local dev server URL given by the operator
|
|
143
|
+
return response.read(limit)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def bundle_symbols(base_url: str, symbols: list[str], limit: int = 80_000_000) -> dict:
|
|
147
|
+
"""Fetch the page at base_url and its same-origin scripts; report which expected names they contain."""
|
|
148
|
+
origin = urlsplit(base_url)
|
|
149
|
+
html = _fetch(base_url, 5_000_000).decode("utf-8", "replace")
|
|
150
|
+
scripts = []
|
|
151
|
+
for src in SCRIPT_SRC.findall(html):
|
|
152
|
+
url = urljoin(base_url, src)
|
|
153
|
+
if urlsplit(url).netloc == origin.netloc:
|
|
154
|
+
scripts.append(url)
|
|
155
|
+
found: dict[str, list[str]] = {symbol: [] for symbol in symbols}
|
|
156
|
+
fetched = []
|
|
157
|
+
for url in scripts[:12]:
|
|
158
|
+
body = _fetch(url, limit).decode("utf-8", "replace")
|
|
159
|
+
path = urlsplit(url).path
|
|
160
|
+
fetched.append({"path": path, "bytes": len(body)})
|
|
161
|
+
for symbol in symbols:
|
|
162
|
+
if symbol in body:
|
|
163
|
+
found[symbol].append(path)
|
|
164
|
+
return {"scripts": fetched, "found": {symbol: bool(paths) for symbol, paths in found.items()}, "foundIn": found}
|
|
165
|
+
|
|
166
|
+
|
|
81
167
|
def find_app_dirs(project: Path) -> list[Path]:
|
|
82
168
|
"""Expo apps in the project: the root itself, or monorepo apps/* and packages/*."""
|
|
83
169
|
candidates = [project] + sorted(path for pattern in ("apps/*", "packages/*") for path in project.glob(pattern) if path.is_dir())
|
|
@@ -158,7 +244,7 @@ def refresh_token_signals(app_dir: Path, limit: int = 4000) -> dict:
|
|
|
158
244
|
return {"filesScanned": files, "refreshTokenFiles": refresh, "rotationHintFiles": rotation, "examples": examples}
|
|
159
245
|
|
|
160
246
|
|
|
161
|
-
def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None) -> dict:
|
|
247
|
+
def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None, expect_symbols: list[str] | None = None) -> dict:
|
|
162
248
|
project = project.resolve()
|
|
163
249
|
errors: list[str] = []
|
|
164
250
|
warnings: list[str] = []
|
|
@@ -174,7 +260,7 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
|
|
|
174
260
|
for item in (list_processes() if processes is None else processes):
|
|
175
261
|
cwd = item.get("cwd")
|
|
176
262
|
command = item.get("cmdline", "")
|
|
177
|
-
record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command}
|
|
263
|
+
record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command, "startedAt": item.get("startedAt") or (process_started_at(item["pid"]) if isinstance(item.get("pid"), int) else None)}
|
|
178
264
|
if cwd and not Path(cwd).resolve().is_relative_to(project):
|
|
179
265
|
record["scope"] = "other project"
|
|
180
266
|
servers.append(record)
|
|
@@ -189,6 +275,35 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
|
|
|
189
275
|
in_project = [server for server in servers if server.get("scope") != "other project"]
|
|
190
276
|
if not in_project:
|
|
191
277
|
warnings.append("no running Metro/Expo dev server found for this project; start one from the app folder without CI=1")
|
|
278
|
+
owner = None
|
|
279
|
+
if base_url:
|
|
280
|
+
parts = urlsplit(base_url)
|
|
281
|
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
282
|
+
if classify_host(base_url) == "local":
|
|
283
|
+
owner = port_owner(port)
|
|
284
|
+
if owner is None:
|
|
285
|
+
errors.append(f"nothing is listening on port {port}; the dev server was stopped or killed (check for exit 137) — start it from {rel(app_dir)}")
|
|
286
|
+
else:
|
|
287
|
+
owner = {**owner, "port": port, "cwd": rel(owner["cwd"]) if owner.get("cwd") else None}
|
|
288
|
+
known = {server["pid"] for server in in_project}
|
|
289
|
+
if owner["pid"] is not None and owner["pid"] not in known:
|
|
290
|
+
where = owner["cwd"] or "an unknown folder"
|
|
291
|
+
errors.append(f"port {port} is held by pid {owner['pid']} ({owner['command']}, started {owner['startedAt'] or 'unknown'}) from {where}, not by a dev server of {rel(app_dir)}; another session may have replaced it")
|
|
292
|
+
bundle = None
|
|
293
|
+
if expect_symbols:
|
|
294
|
+
if not base_url:
|
|
295
|
+
errors.append("--expect-symbol needs --base-url to fetch the served bundle")
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
bundle = bundle_symbols(base_url, expect_symbols)
|
|
299
|
+
except (OSError, ValueError) as error:
|
|
300
|
+
errors.append(f"could not fetch the served bundle from --base-url: {error}")
|
|
301
|
+
else:
|
|
302
|
+
if not bundle["scripts"]:
|
|
303
|
+
errors.append("the page at --base-url has no same-origin script, so the served bundle could not be checked")
|
|
304
|
+
missing = [symbol for symbol, ok in bundle["found"].items() if not ok]
|
|
305
|
+
if bundle["scripts"] and missing:
|
|
306
|
+
errors.append("the served bundle does not contain " + ", ".join(missing) + ": the dev server is serving old code (stale watcher, wrong folder or another session's server); restart it before QA")
|
|
192
307
|
signals = refresh_token_signals(app_dir)
|
|
193
308
|
rotating_tokens = rotating == "yes" or (rotating == "auto" and signals["refreshTokenFiles"] > 0)
|
|
194
309
|
if rotating_tokens:
|
|
@@ -213,6 +328,8 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
|
|
|
213
328
|
"appDir": rel(app_dir),
|
|
214
329
|
"appCandidates": [rel(path) for path in apps],
|
|
215
330
|
"devServers": servers,
|
|
331
|
+
"portOwner": owner,
|
|
332
|
+
"servedBundle": bundle,
|
|
216
333
|
"refreshTokens": {"rotating": rotating_tokens, "source": rotating, **signals},
|
|
217
334
|
"apiTargets": targets,
|
|
218
335
|
"status": "failed" if errors else ("warning" if warnings else "passed"),
|
|
@@ -229,10 +346,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
346
|
parser.add_argument("--app-dir", type=Path, help="app folder inside a monorepo, e.g. apps/app")
|
|
230
347
|
parser.add_argument("--base-url", help="URL the browser QA will open")
|
|
231
348
|
parser.add_argument("--rotating-refresh-tokens", choices=("auto", "yes", "no"), default="auto")
|
|
349
|
+
parser.add_argument("--expect-symbol", action="append", default=[], metavar="NAME", help="a name from the edit just made (component, string, export); fails when the served bundle lacks it")
|
|
232
350
|
parser.add_argument("--output", type=Path)
|
|
233
351
|
args = parser.parse_args(argv)
|
|
234
352
|
try:
|
|
235
|
-
report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens)
|
|
353
|
+
report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens, expect_symbols=args.expect_symbol)
|
|
236
354
|
except (OSError, ValueError) as error:
|
|
237
355
|
print(f"BLOCKED: maggie qa app-preflight: {error}", file=sys.stderr)
|
|
238
356
|
return 2
|
|
@@ -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())
|