#!/usr/bin/env python3 """page-signature.py: reads the structural signature of one server-rendered page, the unit a parity review compares (gt-verify references/parity-review.md). The signature holds what a reader, a crawler or an agent gets from the HTML before any script runs: the head (title, description, robots, Open Graph and Twitter tags, canonical, hreflang alternates, JSON-LD), the article's headings with their ids, the in-page ids a deep link can target, the article's links, images, iframes, videos, tables, code blocks (a hash of each block's text, its line count, its line classes and its styled spans), tabs, callouts and buttons, the visible article text (length, hash and lines), the sidebar and table-of-contents links, every link on the page, and the error markers Next.js leaves in a failed render (the error root, a digest, a not-found or redirect fallback, a client-side bailout). The article is the first
. The sidebar is aside#nd-sidebar and the table of contents #nd-toc, as Fumadocs renders them; on another site those two lists stay empty and the rest still reads. Hrefs are compared as paths: a localhost or 127.0.0.1 origin on any port is stripped, and so is every --strip-origin. Usage: python3 page-signature.py [--strip-origin https://example.com]... [--full] python3 page-signature.py --help It prints the signature as JSON, without the text lines unless --full is given. compare-signatures.py in this folder imports signature() from this file to compare two fetches of the same routes. Requires: Python 3.9 or later, standard library only. Last real run: 2026-10-08, the docs parity review of gt-cloud #5217 (New Onboarding and Dashboard session), 475 page fetches per side, as sig.py. Copied into this skill on 2026-10-10. """ import argparse import hashlib import html import json import re import sys from html.parser import HTMLParser from urllib.parse import parse_qs, unquote, urlparse VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'} BLOCK = {'p', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr', 'pre', 'div', 'section', 'figure', 'figcaption', 'table', 'ul', 'ol', 'blockquote', 'header', 'footer', 'details', 'summary', 'dt', 'dd', 'br', 'td', 'th', 'button'} LOCAL_ORIGIN = re.compile(r'^https?://(?:localhost|127\.0\.0\.1)(?::\d+)?') # origins stripped from every href and src, set by set_strip_origins() STRIP_ORIGINS = [] # Next.js error markers, read on the raw document before parsing ERROR_MARKERS = [ (r'id="__next_error__"', 'next_error_root'), (r'Application error: a (?:client|server)-side exception', 'application_error'), (r'This page could not be found', 'not_found_text'), (r'NEXT_HTTP_ERROR_FALLBACK;404', 'rsc_not_found'), (r'NEXT_REDIRECT', 'rsc_redirect'), (r'\\"digest\\":\\"[0-9]+\\"|"digest":"[0-9]+"', 'rsc_digest'), (r'data-dgst=', 'data_dgst'), (r'Internal Server Error', 'ise_text'), (r'Something went wrong', 'something_went_wrong'), (r'Unhandled Runtime Error', 'unhandled_runtime'), (r'nextjs__container_errors', 'dev_overlay_errors'), (r'