Instantly share code, notes, and snippets.
Created
August 11, 2026 00:38
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save AnythingLinux/4ae4bd92e55ea7c4ceaef82c413294b6 to your computer and use it in GitHub Desktop.
Finalize footer sync
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 | |
| """ | |
| finalize_footer_sync.py — Run this AFTER sync_footer.py has applied the | |
| homepage footer to your site. It re-validates every live HTML page on | |
| disk, rolls back any page that's broken to its .bak backup (created by | |
| sync_footer.py), and — only once the whole site is confirmed healthy — | |
| deletes all leftover .bak files and restarts Apache. | |
| This is a defense-in-depth safety net, not a replacement for | |
| sync_footer.py's own pre-write validation. sync_footer.py already | |
| guarantees it never writes a broken page; this script exists to catch | |
| anything unexpected after the fact (disk write errors, encoding issues, | |
| a page hand-edited after the sync, etc.) before you clean up backups and | |
| bounce the web server. | |
| WORKFLOW | |
| 1. Recursively validate every *.html file under site_dir: | |
| - HTML must parse (no unclosed/mismatched tags) | |
| - exactly one <footer> element | |
| - no duplicate id="" attributes | |
| - any inline JSON-LD blocks still parse as valid JSON | |
| 2. Any page that FAILS validation is looked up for a matching backup | |
| (<file>.bak.<timestamp>, most recent if several exist). If a valid | |
| backup is found, the live file is restored from it and re-validated. | |
| If no backup exists, or the backup is itself broken, the page is | |
| left untouched and marked UNRESOLVED. | |
| 3. If any page is UNRESOLVED: STOP. No backups are deleted, Apache is | |
| NOT restarted. Fix the page manually (or re-run sync_footer.py) and | |
| run this script again. | |
| 4. If every page is healthy (either already, or after rollback): | |
| - delete every *.bak.* file under site_dir | |
| - restart Apache | |
| - print a summary | |
| USAGE | |
| python3 finalize_footer_sync.py /var/www/html --dry-run # report only | |
| python3 finalize_footer_sync.py /var/www/html # apply | |
| # Override the restart command if it isn't Apache/systemd on this box: | |
| python3 finalize_footer_sync.py /var/www/html --restart-cmd "service apache2 restart" | |
| NOTE: restarting Apache normally requires root. Run this with sudo, e.g. | |
| sudo python3 finalize_footer_sync.py /var/www/html | |
| EXIT CODES | |
| 0 success (site healthy; backups deleted and Apache restarted, or | |
| --dry-run reported a healthy/fixable site without acting) | |
| 1 one or more pages are unresolved — nothing was deleted or restarted | |
| 2 usage / setup error | |
| """ | |
| import argparse | |
| import glob | |
| import json | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| from html.parser import HTMLParser | |
| from pathlib import Path | |
| VOID_ELEMENTS = { | |
| "area", "base", "br", "col", "embed", "hr", "img", "input", | |
| "link", "meta", "param", "source", "track", "wbr", | |
| } | |
| # -------------------------------------------------------------------------- | |
| # Validation (same rules as sync_footer.py's dry run, kept self-contained | |
| # so this script has no import dependency on sync_footer.py being present) | |
| # -------------------------------------------------------------------------- | |
| class _TagBalanceChecker(HTMLParser): | |
| def __init__(self): | |
| super().__init__(convert_charrefs=True) | |
| self.stack = [] | |
| self.errors = [] | |
| def handle_starttag(self, tag, attrs): | |
| if tag in VOID_ELEMENTS: | |
| return | |
| self.stack.append(tag) | |
| def handle_startendtag(self, tag, attrs): | |
| pass | |
| def handle_endtag(self, tag): | |
| if tag in VOID_ELEMENTS: | |
| return | |
| if not self.stack: | |
| self.errors.append(f"extra closing tag </{tag}> with nothing open") | |
| return | |
| if self.stack[-1] == tag: | |
| self.stack.pop() | |
| return | |
| if tag in self.stack: | |
| while self.stack and self.stack[-1] != tag: | |
| self.errors.append(f"tag <{self.stack[-1]}> was never closed before </{tag}>") | |
| self.stack.pop() | |
| if self.stack: | |
| self.stack.pop() | |
| else: | |
| self.errors.append(f"</{tag}> found but <{tag}> was never opened") | |
| def check_tag_balance(html: str): | |
| parser = _TagBalanceChecker() | |
| parser.feed(html) | |
| errors = list(parser.errors) | |
| for tag in parser.stack: | |
| errors.append(f"<{tag}> was never closed") | |
| return errors | |
| def check_single_footer(html: str): | |
| count = len(re.findall(r"<footer\b", html, re.IGNORECASE)) | |
| if count == 0: | |
| return ["no <footer> element present"] | |
| if count > 1: | |
| return [f"{count} <footer> elements present (expected 1)"] | |
| return [] | |
| def check_duplicate_ids(html: str): | |
| ids = re.findall(r'\bid\s*=\s*"([^"]+)"', html, re.IGNORECASE) | |
| seen, dupes = set(), set() | |
| for i in ids: | |
| if i in seen: | |
| dupes.add(i) | |
| seen.add(i) | |
| if dupes: | |
| return [f'duplicate id="{d}" found' for d in sorted(dupes)] | |
| return [] | |
| def check_json_ld(html: str): | |
| errors = [] | |
| for i, m in enumerate( | |
| re.finditer( | |
| r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', | |
| html, re.DOTALL | re.IGNORECASE, | |
| ), | |
| start=1, | |
| ): | |
| try: | |
| json.loads(m.group(1)) | |
| except json.JSONDecodeError as e: | |
| errors.append(f"JSON-LD block #{i} is invalid JSON: {e}") | |
| return errors | |
| def validate_html(html: str): | |
| errors = [] | |
| errors += check_tag_balance(html) | |
| errors += check_single_footer(html) | |
| errors += check_duplicate_ids(html) | |
| errors += check_json_ld(html) | |
| return errors | |
| # -------------------------------------------------------------------------- | |
| # Backup lookup / rollback | |
| # -------------------------------------------------------------------------- | |
| def find_latest_backup(html_path: Path): | |
| """Return the most recent <html_path>.bak.<timestamp> file, or None.""" | |
| candidates = sorted(glob.glob(f"{html_path}.bak.*")) | |
| return Path(candidates[-1]) if candidates else None | |
| # -------------------------------------------------------------------------- | |
| # Main flow | |
| # -------------------------------------------------------------------------- | |
| def run(site_dir: Path, dry_run: bool, restart_cmd: str): | |
| if not site_dir.is_dir(): | |
| print(f"ERROR: site_dir not found or not a directory: {site_dir}", file=sys.stderr) | |
| return 2 | |
| html_files = sorted(Path(p) for p in glob.glob(str(site_dir / "**" / "*.html"), recursive=True)) | |
| if not html_files: | |
| print(f"No .html files found under {site_dir} — nothing to do.") | |
| return 2 | |
| print(f"Validating {len(html_files)} page(s) under {site_dir}\n") | |
| print("VALIDATION RESULTS") | |
| print("=" * 60) | |
| unresolved = [] | |
| fixed = [] | |
| healthy = [] | |
| for path in html_files: | |
| html = path.read_text(encoding="utf-8") | |
| errors = validate_html(html) | |
| if not errors: | |
| healthy.append(path) | |
| print(f"[OK] {path}") | |
| continue | |
| # Broken — look for a backup to roll back to. | |
| backup = find_latest_backup(path) | |
| if backup is None: | |
| unresolved.append((path, errors, None)) | |
| print(f"[BROKEN] {path}") | |
| for e in errors: | |
| print(f" - {e}") | |
| print(f" no backup found — cannot auto-fix") | |
| continue | |
| backup_html = backup.read_text(encoding="utf-8") | |
| backup_errors = validate_html(backup_html) | |
| if backup_errors: | |
| unresolved.append((path, errors, backup)) | |
| print(f"[BROKEN] {path}") | |
| for e in errors: | |
| print(f" - {e}") | |
| print(f" backup {backup.name} is ALSO broken — cannot auto-fix") | |
| continue | |
| if dry_run: | |
| fixed.append((path, backup)) | |
| print(f"[WOULD FIX] {path}") | |
| print(f" would roll back to {backup.name}") | |
| else: | |
| path.write_text(backup_html, encoding="utf-8") | |
| # Re-validate the restored file to be certain. | |
| re_errors = validate_html(path.read_text(encoding="utf-8")) | |
| if re_errors: | |
| unresolved.append((path, re_errors, backup)) | |
| print(f"[BROKEN] {path}") | |
| print(f" rollback attempted but page is still broken") | |
| else: | |
| fixed.append((path, backup)) | |
| print(f"[FIXED] {path} (rolled back to {backup.name})") | |
| print("=" * 60) | |
| print(f"\n{len(healthy)} healthy, {len(fixed)} fixed via rollback, {len(unresolved)} unresolved\n") | |
| if unresolved: | |
| print("UNRESOLVED PAGES — nothing was deleted, Apache was NOT restarted:") | |
| for path, errors, backup in unresolved: | |
| print(f" - {path}") | |
| for e in errors: | |
| print(f" - {e}") | |
| print( | |
| "\nFix these pages manually (or re-run sync_footer.py once fixed), " | |
| "then run this script again." | |
| ) | |
| return 1 | |
| if dry_run: | |
| bak_files = glob.glob(str(site_dir / "**" / "*.bak.*"), recursive=True) | |
| print(f"Site is healthy. --dry-run: would delete {len(bak_files)} backup file(s) " | |
| f"and run: {restart_cmd}") | |
| return 0 | |
| # ---- cleanup ---- | |
| bak_files = glob.glob(str(site_dir / "**" / "*.bak.*"), recursive=True) | |
| for f in bak_files: | |
| Path(f).unlink() | |
| print(f"Deleted {len(bak_files)} backup file(s).") | |
| # ---- restart Apache ---- | |
| print(f"Restarting Apache: {restart_cmd}") | |
| try: | |
| result = subprocess.run(restart_cmd.split(), capture_output=True, text=True) | |
| if result.returncode == 0: | |
| print("Apache restarted successfully.") | |
| else: | |
| print(f"WARNING: restart command exited with code {result.returncode}") | |
| if result.stdout.strip(): | |
| print(f"stdout: {result.stdout.strip()}") | |
| if result.stderr.strip(): | |
| print(f"stderr: {result.stderr.strip()}") | |
| print("Backups were already deleted; restart Apache manually.") | |
| return 1 | |
| except FileNotFoundError: | |
| print(f"ERROR: could not run '{restart_cmd}' — command not found.") | |
| print("Backups were already deleted; restart Apache manually, or re-run with --restart-cmd.") | |
| return 1 | |
| return 0 | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Validate pages after a footer sync, roll back anything broken, " | |
| "then clean up backups and restart Apache." | |
| ) | |
| parser.add_argument("site_dir", help="Site root directory, e.g. /var/www/html") | |
| parser.add_argument("--dry-run", action="store_true", help="Report only; never modify or delete files, never restart Apache.") | |
| parser.add_argument( | |
| "--restart-cmd", | |
| default="systemctl restart apache2", | |
| help="Command used to restart the web server (default: 'systemctl restart apache2').", | |
| ) | |
| args = parser.parse_args() | |
| exit_code = run(Path(args.site_dir), args.dry_run, args.restart_cmd) | |
| sys.exit(exit_code) | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
nano finalize_footer_sync.py
chmod +x finalize_footer_sync.py
python3 finalize_footer_sync.py /var/www/html --dry-run # report only
python3 finalize_footer_sync.py /var/www/html # apply