Last active
May 30, 2026 20:25
-
-
Save ducks/cc216cff323f572830487cad7acd87be to your computer and use it in GitHub Desktop.
sqsp-preflight: audit a Squarespace 7.0 site against the known limitations of Squarespace's 7.0 -> 7.1 update tool.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| sqsp-preflight: audit a Squarespace 7.0 site against the known limitations | |
| of Squarespace's 7.0 -> 7.1 update tool. | |
| What we check (all sourced from Squarespace's own docs): | |
| - Page count vs the 100-page eligibility cap | |
| - Product count per store page vs the 200-per-store cap | |
| - Album pages (will disappear after the update) | |
| - Gallery pages with videos (gallery pages convert to layout pages; | |
| videos on them do NOT carry over) | |
| - Sidebar navigation (not supported in 7.1) | |
| - Share buttons (not supported) | |
| - Custom Adobe Typekit fonts (not supported) | |
| - Template identifier (operator should cross-check against the | |
| discontinued-template list manually) | |
| What we do NOT check: | |
| - Developer mode (only the admin knows) | |
| - Member-only content (we crawl unauthenticated) | |
| - Whether a specific 7.0 layout will look acceptable in 7.1 (no tool | |
| can predict layout fidelity) | |
| Usage: | |
| python3 preflight.py https://www.portlandbonsai.org > report.md | |
| No third-party deps. Stdlib only. | |
| """ | |
| import argparse | |
| import json | |
| import re | |
| import sys | |
| import urllib.error | |
| import urllib.request | |
| from urllib.parse import urlparse | |
| UA = "Mozilla/5.0 (X11; Linux x86_64) sqsp-preflight/1.0" | |
| TIMEOUT = 25 | |
| def fetch(url, *, as_json=False): | |
| req = urllib.request.Request(url, headers={"User-Agent": UA}) | |
| with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: | |
| body = resp.read().decode("utf-8", errors="replace") | |
| return json.loads(body) if as_json else body | |
| def discover_pages(base): | |
| """Return a list of page paths from the sitemap. | |
| Squarespace ships a sitemap.xml on every site that lists every public | |
| URL. Parse it with a light regex; the structure is predictable enough | |
| that we don't need an XML parser. | |
| """ | |
| try: | |
| xml = fetch(f"{base}/sitemap.xml") | |
| except Exception as e: | |
| print(f" warn: could not fetch sitemap.xml: {e}", file=sys.stderr) | |
| return [] | |
| urls = re.findall(r"<loc>([^<]+)</loc>", xml) | |
| paths = [] | |
| for u in urls: | |
| parsed = urlparse(u) | |
| if parsed.netloc and parsed.netloc not in base: | |
| continue | |
| path = parsed.path or "/" | |
| paths.append(path) | |
| # Dedup, preserve order. | |
| seen = set() | |
| out = [] | |
| for p in paths: | |
| if p not in seen: | |
| seen.add(p) | |
| out.append(p) | |
| return out | |
| def analyze_page(base, path): | |
| """Fetch ?format=json for one page and return findings. | |
| Findings is a list of (severity, message) tuples. Severity is one of | |
| 'block', 'warn', 'info'. Caller groups them in the final report. | |
| """ | |
| findings = [] | |
| url = f"{base}{path}?format=json" | |
| try: | |
| data = fetch(url, as_json=True) | |
| except urllib.error.HTTPError as e: | |
| if e.code == 404: | |
| return findings, None | |
| findings.append(("warn", f"HTTP {e.code} fetching {path}")) | |
| return findings, None | |
| except Exception as e: | |
| findings.append(("warn", f"could not fetch {path}: {e}")) | |
| return findings, None | |
| coll = data.get("collection") or {} | |
| type_name = coll.get("typeName", "unknown") | |
| title = coll.get("title") or path | |
| url_id = coll.get("urlId") or "" | |
| # Album page detection. typeName 'album' will not carry. | |
| if type_name == "album": | |
| findings.append(( | |
| "block", | |
| "album page — will be DELETED by the update tool", | |
| )) | |
| # Gallery page detection. 'gallery' converts; video blocks inside | |
| # don't carry over. | |
| if type_name == "gallery": | |
| findings.append(( | |
| "warn", | |
| "gallery page — converts to a layout page; any embedded " | |
| "videos will be lost", | |
| )) | |
| # mainContent may contain video blocks regardless of page type. | |
| main = data.get("mainContent", "") or "" | |
| if isinstance(main, str): | |
| video_blocks = len(re.findall( | |
| r"data-block-type=\"(?:32|video|video-block)\"", main | |
| )) | |
| if type_name != "gallery" and video_blocks > 0: | |
| findings.append(( | |
| "info", | |
| f"{video_blocks} video block(s) detected — manually verify " | |
| "they survive the update", | |
| )) | |
| # Share buttons. | |
| if re.search(r"squarespace-social-buttons|sqs-share-buttons", main): | |
| findings.append(( | |
| "warn", | |
| "share buttons detected — not supported in 7.1", | |
| )) | |
| # Store eligibility: products per store page. | |
| if type_name == "products": | |
| item_count = coll.get("itemCount") | |
| if isinstance(item_count, int): | |
| if item_count > 200: | |
| findings.append(( | |
| "block", | |
| f"{item_count} products — exceeds 200-per-store cap", | |
| )) | |
| else: | |
| findings.append(( | |
| "info", | |
| f"{item_count} products (under 200-per-store cap)", | |
| )) | |
| # Distinguish a container page from an item-or-view inside it. Only | |
| # containers count toward the 100-page eligibility cap. | |
| # | |
| # The JSON response populates `item` when the URL points at a single | |
| # entry within a collection (a blog post, product, event item, | |
| # portfolio project), so those are clearly items. | |
| # | |
| # But the sitemap also includes filter views like | |
| # `/<blog>/tag/<tag>` and `/<blog>/category/<cat>` which have no | |
| # `item` but aren't separate containers either — they're just | |
| # tag-filtered renderings of the same blog. Treat anything whose | |
| # path is deeper than the collection's own slug as not-a-container. | |
| item = data.get("item") | |
| is_item = isinstance(item, dict) and bool(item) | |
| # A container page sits at exactly the collection's own slug | |
| # (e.g. /scotts-branch-tips). Anything below that slug is either an | |
| # individual item or a filter view; neither counts toward the cap. | |
| path_slug = path.lstrip("/") | |
| is_container = (not is_item) and (path_slug == url_id) if url_id else (not is_item) | |
| return findings, { | |
| "path": path, | |
| "title": title, | |
| "type": type_name, | |
| "is_item": is_item, | |
| "is_container": is_container, | |
| } | |
| def analyze_site_settings(base): | |
| """Fetch site-wide settings via the homepage JSON.""" | |
| findings = [] | |
| try: | |
| data = fetch(f"{base}/?format=json", as_json=True) | |
| except Exception as e: | |
| findings.append(("warn", f"could not fetch site settings: {e}")) | |
| return findings, {} | |
| ws = data.get("website") or {} | |
| site_settings = data.get("websiteSettings") or {} | |
| template = data.get("template") or {} | |
| # Custom Adobe Typekit usage. | |
| typekit = ws.get("typekitId") or "" | |
| if typekit: | |
| findings.append(( | |
| "warn", | |
| f"Adobe Typekit ID set ({typekit!r}) — custom Adobe fonts are " | |
| "not supported in 7.1", | |
| )) | |
| info = { | |
| "site_title": ws.get("siteTitle"), | |
| "tagline": ws.get("siteTagLine"), | |
| "site_id": ws.get("id"), | |
| "primary_domain": ws.get("primaryDomain"), | |
| "website_type": ws.get("websiteType"), | |
| "template": template, | |
| "type": site_settings.get("type"), | |
| } | |
| return findings, info | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Audit a Squarespace 7.0 site against the 7.1 update tool's " | |
| "known limitations." | |
| ) | |
| parser.add_argument("url", help="Site URL, e.g. https://example.com") | |
| parser.add_argument( | |
| "--limit", type=int, default=0, | |
| help="Stop after analyzing N pages (0 = all). Useful for spot checks " | |
| "on large sites." | |
| ) | |
| parser.add_argument( | |
| "--start", type=int, default=0, | |
| help="Skip the first N pages (0-indexed). Combine with --limit to " | |
| "resume from where a previous run stopped." | |
| ) | |
| parser.add_argument( | |
| "--progress-every", type=int, default=25, | |
| help="Print a progress line to stderr every N pages (default 25, " | |
| "0 disables)." | |
| ) | |
| args = parser.parse_args() | |
| base = args.url.rstrip("/") | |
| # Header | |
| print(f"# Squarespace 7.0 → 7.1 preflight: {base}") | |
| print() | |
| print("Generated by sqsp-preflight. Each finding maps to a documented") | |
| print("limitation of Squarespace's automatic update tool.") | |
| print() | |
| # Site-level findings | |
| site_findings, site_info = analyze_site_settings(base) | |
| print("## Site settings") | |
| print() | |
| print(f"- **Site title:** {site_info.get('site_title')}") | |
| print(f"- **Primary domain:** {site_info.get('primary_domain')}") | |
| print(f"- **Site ID:** `{site_info.get('site_id')}`") | |
| tpl = site_info.get("template") or {} | |
| print(f"- **Template:** `{tpl}`") | |
| print(f"- **Business type:** {site_info.get('type')}") | |
| print() | |
| # Page discovery | |
| paths = discover_pages(base) | |
| print("## Page inventory") | |
| print() | |
| print(f"- **Discovered URLs (sitemap):** {len(paths)}") | |
| print(" Note: the 100-page cap counts *containers* (the blog page,") | |
| print(" store page, gallery page, etc.), not the items inside them.") | |
| print(" Individual blog posts, products, and event items don't count.") | |
| print(" We compute the eligibility number after per-page analysis.") | |
| print() | |
| # Per-page analysis | |
| print("## Findings by page") | |
| print() | |
| # Apply --start and --limit to scope what we crawl this run. | |
| crawl = paths[args.start:] | |
| if args.limit > 0: | |
| crawl = crawl[:args.limit] | |
| if args.start or args.limit: | |
| end = args.start + len(crawl) | |
| print(f"*Analyzing pages {args.start}–{end - 1} of {len(paths)}*") | |
| print() | |
| pages_analyzed = [] | |
| for i, path in enumerate(crawl): | |
| # Progress to stderr — keeps stdout clean for the markdown report. | |
| if args.progress_every and (i + 1) % args.progress_every == 0: | |
| print( | |
| f" ...{i + 1}/{len(crawl)} pages analyzed", | |
| file=sys.stderr, | |
| ) | |
| findings, page_info = analyze_page(base, path) | |
| if page_info is None: | |
| continue | |
| pages_analyzed.append(page_info) | |
| if not findings: | |
| continue | |
| print(f"### `{path}` *(type: {page_info['type']})*") | |
| print() | |
| for severity, msg in findings: | |
| icon = {"block": "❌", "warn": "⚠️ ", "info": "ℹ️ "}[severity] | |
| print(f"- {icon} {msg}") | |
| print() | |
| if args.progress_every: | |
| print( | |
| f" done: {len(crawl)} pages analyzed", | |
| file=sys.stderr, | |
| ) | |
| # Site-level warnings at the end so they're not buried | |
| if site_findings: | |
| print("## Site-wide warnings") | |
| print() | |
| for severity, msg in site_findings: | |
| icon = {"block": "❌", "warn": "⚠️ ", "info": "ℹ️ "}[severity] | |
| print(f"- {icon} {msg}") | |
| print() | |
| # Eligibility-counted total. Per Squarespace's docs, the 100-page | |
| # cap counts container pages only: layout pages, blog pages, gallery | |
| # pages, store pages, calendar/events pages, album pages, etc. The | |
| # individual items inside them (blog posts, products, event items, | |
| # portfolio projects) are excluded. | |
| from collections import Counter | |
| containers = [p for p in pages_analyzed if p["is_container"]] | |
| items = [p for p in pages_analyzed if p["is_item"]] | |
| views = [p for p in pages_analyzed | |
| if not p["is_container"] and not p["is_item"]] | |
| print("## Eligibility count") | |
| print() | |
| print(f"- **Container pages (count toward 100-page cap):** {len(containers)}") | |
| print(f"- **Individual items (excluded from cap):** {len(items)}") | |
| print(f"- **Filter/tag views (excluded from cap):** {len(views)}") | |
| if len(containers) > 100: | |
| delta = len(containers) - 100 | |
| print(f"- ❌ **OVER the 100-page eligibility cap by {delta}.** The") | |
| print(" update tool may refuse to run without admin assistance.") | |
| else: | |
| room = 100 - len(containers) | |
| print(f"- ✓ Under the 100-page cap ({room} pages of headroom).") | |
| print() | |
| print("## Page type summary (containers only)") | |
| print() | |
| container_types = Counter(p["type"] for p in containers) | |
| for t, n in container_types.most_common(): | |
| print(f"- `{t}`: {n}") | |
| print() | |
| if items: | |
| print("## Page type summary (items, not counted)") | |
| print() | |
| item_types = Counter(p["type"] for p in items) | |
| for t, n in item_types.most_common(): | |
| print(f"- `{t}`: {n}") | |
| print() | |
| # Manual-check checklist | |
| print("## Manual checks not automatable") | |
| print() | |
| print("These can only be verified inside the Squarespace admin or by") | |
| print("the current webmaster.") | |
| print() | |
| print("- [ ] **Developer mode is disabled.** Settings → Advanced →") | |
| print(" Developer Mode.") | |
| print("- [ ] **Template is not discontinued.** Cross-reference the") | |
| print(" template name above against Squarespace's current") | |
| print(" supported-template list.") | |
| print("- [ ] **Sidebar navigation usage.** If any page uses a sidebar") | |
| print(" nav layout, that navigation won't carry to 7.1.") | |
| print("- [ ] **Members-only content.** This audit can't see logged-in") | |
| print(" pages. Walk the Members area manually before update.") | |
| print("- [ ] **Third-party integrations.** Sentry Login, embedded") | |
| print(" forms, code injections — confirm each still works in the") | |
| print(" 7.1 preview before publishing the update.") | |
| print() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment