#!/usr/bin/env python3 """verify-blog-titles - assert generated blog posts carry the FULL database title. Catches the deblog truncation bug: the 55-char Page Title bleeding into the on-page heading, og:title and the JSON-LD headline. The database title has four destinations and exactly one of them may differ - the Page Title - and it differs by REWRITE, never by slicing at a word boundary. Checks, per published post in the DB dump: heading (h1/h2 in .post) == database title, verbatim og:title == database title, verbatim JSON-LD "headline" == database title, verbatim, no HTML entities Page Title "> 1 else "posts.json" BLOG = sys.argv[2] if len(sys.argv) > 2 else "blog" CATS = sys.argv[3] if len(sys.argv) > 3 else None PAGE_TITLE_MAX = 55 def canonical(title): """Database title -> plain text. DB titles arrive HTML-escaped ('&'), so unescape first, then apply the house convention: straight quotes, no non-breaking spaces. """ title = html.unescape(title) return (title.replace("’", "'").replace("‘", "'") .replace("“", '"').replace("”", '"') .replace("\xa0", " ").strip()) def load_posts(path): data = json.load(open(path, encoding="utf-8")) posts = data["posts"] if isinstance(data, dict) else data return [p for p in posts if p.get("status", "publish") == "publish"] def find_file(files, post, claimed=None): """Match a DB post to its generated file. Returns (name, warn). Convention: dates are stripped from filenames; the date lives in the JSON-LD datePublished. A -YYMMDD suffix on the file is the old convention and only triggers a warning. Duplicate stripped slugs disambiguate with a -YYYY suffix on the older copy; the title decides which candidate is the post's file. """ plain = post["slug"] + ".aspx" if plain in files: if re.search(r"-\d{6}$", post["slug"]): return plain, "dated filename (convention is date-stripped; the date lives in JSON-LD datePublished)" return plain, None dated = re.compile("^" + re.escape(post["slug"]) + r"-\d{6}\.aspx$") for name in files: if dated.match(name): return name, "dated filename (convention is date-stripped; the date lives in JSON-LD datePublished)" stripped = re.sub(r"-aNaNaN$", "", post["slug"]) stripped = re.sub(r"-\d{6}$", "", stripped) stripped = stripped.rstrip("-") candidates = [stripped + ".aspx"] if "date" in post and post["date"]: candidates.append(stripped + "-" + post["date"][:4] + ".aspx") full = canonical(post["title"]) claimed = claimed or set() for cand in candidates: if cand not in files or cand in claimed: continue og = re.search(r'og:title"\s+content="(.*?)"\s*/?>', files[cand]) if not og or html.unescape(og.group(1)).strip() == full: return cand, None return None, None def load_categories(path): data = json.load(open(path, encoding="utf-8")) valid = set() for category in data["categories"]: valid.add(category["slug"]) valid.update(category.get("aliases", [])) return valid def check_categories(src, valid): m = re.search(r'', src) if not og: out.append(("og:title", "missing")) elif html.unescape(og.group(1)).strip() != full: out.append(("og:title", html.unescape(og.group(1)).strip())) ld = re.search(r'"headline":\s*"((?:[^"\\]|\\.)*)"', src) if not ld: out.append(("json-ld", "missing")) else: value = json.loads('"%s"' % ld.group(1)).strip() if value != full: out.append(("json-ld", value)) elif "&" in value or "" in value or """ in value: #