Instantly share code, notes, and snippets.
Last active
August 25, 2026 20:50
-
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.
Footer Change
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 | |
| """ | |
| sync_site_footer.py | |
| Copies the canonical footer (markup + footer-glass background variant) from | |
| the home page into every other .html file under a site root, then: | |
| 1. Dry run -- report what would change (always runs first) | |
| 2. Apply -- write the canonical footer into every changed file | |
| (a temporary "<file>.footer_sync.bak" is made | |
| before each overwrite, in case you need to look | |
| at what changed) | |
| 3. Delete ALL .bak -- every .footer_sync.bak file this run created is | |
| deleted unconditionally, pass or fail. There is | |
| NO rollback copy left after this step -- if a | |
| file fails validation you will see it in the | |
| report, but you will need to fix it by hand or | |
| from your own separate backups/version control. | |
| 4. Delete reports -- any footer_sync_report*.json files under --root | |
| (and in the current directory) are deleted | |
| 5. Final validate -- a clean, no-write validation pass over every file | |
| 6. Restart Apache -- `systemctl restart apache2` (falls back to | |
| `service apache2 restart`), only if step 2 | |
| actually changed at least one file | |
| USAGE | |
| python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html | |
| Dry run only. Nothing is written, nothing is deleted, Apache is | |
| NOT restarted. Use this first to see what would change. | |
| python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html --apply | |
| Runs the full pipeline above (steps 1-6). | |
| python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html --validate-only | |
| Just step 5, on its own, any time, with no writes. | |
| python3 sync_site_footer.py --root /var/www/html --apply --no-restart | |
| Full pipeline but skip the Apache restart. | |
| python3 sync_site_footer.py --root /var/www/html --cleanup-backups | |
| Just steps 3+4 on their own (e.g. to clean up leftovers from an | |
| earlier run) -- no HTML is touched, no restart happens. | |
| A summary is printed to the console at each step. This version does not | |
| keep a permanent JSON report file (it deletes its own report as the last | |
| part of cleanup) -- read the console output, or add --keep-report to skip | |
| that deletion for this run. | |
| """ | |
| import argparse | |
| import datetime | |
| import hashlib | |
| import json | |
| import re | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| FOOTER_RE = re.compile(r"<footer\b[^>]*>.*?</footer>", re.IGNORECASE | re.DOTALL) | |
| FOOTER_OPEN_TAG_RE = re.compile(r"<footer\b[^>]*>", re.IGNORECASE) | |
| CLASS_ATTR_RE = re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE) | |
| BAK_SUFFIX = ".footer_sync.bak" | |
| OLD_BACKUP_DIR_NAME = ".footer_sync_backups" # from an earlier version of this script | |
| REPORT_GLOB = "footer_sync_report*.json" | |
| KNOWN_FONT_BUG_PATTERNS = [ | |
| re.compile(r"font-family\s*:\s*sans-serif\s*;", re.IGNORECASE), | |
| re.compile(r"font-family\s*:\s*['\"]?Helvetica", re.IGNORECASE), | |
| re.compile(r"font-family\s*:\s*Arial\b", re.IGNORECASE), | |
| ] | |
| def sha1(text: str) -> str: | |
| return hashlib.sha1(text.encode("utf-8")).hexdigest() | |
| def ts(): | |
| return datetime.datetime.now().isoformat(timespec="seconds") | |
| def build_canonical_footer(reference_html: str) -> str: | |
| match = FOOTER_RE.search(reference_html) | |
| if not match: | |
| raise ValueError("No <footer>...</footer> block found in reference file.") | |
| footer_html = match.group(0) | |
| open_tag = FOOTER_OPEN_TAG_RE.search(footer_html).group(0) | |
| class_match = CLASS_ATTR_RE.search(open_tag) | |
| classes = class_match.group(1).split() if class_match else [] | |
| for needed in ("footer", "footer-glass"): | |
| if needed not in classes: | |
| classes.append(needed) | |
| new_open_tag = CLASS_ATTR_RE.sub(f'class="{" ".join(classes)}"', open_tag, count=1) \ | |
| if class_match else open_tag[:-1] + f' class="{" ".join(classes)}">' | |
| return footer_html.replace(open_tag, new_open_tag, 1) | |
| def find_html_files(root: Path, reference: Path): | |
| for path in sorted(root.rglob("*.html")): | |
| if path.resolve() == reference.resolve(): | |
| continue | |
| if OLD_BACKUP_DIR_NAME in path.parts: | |
| continue | |
| if path.name.endswith(BAK_SUFFIX): | |
| continue | |
| yield path | |
| def scan_font_bug_warnings(html: str): | |
| hits = [] | |
| for pattern in KNOWN_FONT_BUG_PATTERNS: | |
| for m in pattern.finditer(html): | |
| hits.append(html[max(0, m.start() - 30): m.end() + 10].strip()) | |
| return hits | |
| def validate_html(path: Path, canonical_footer: str): | |
| checks = {} | |
| try: | |
| html = path.read_text(encoding="utf-8") | |
| except Exception as e: | |
| return "error", {"read": f"failed: {e}"} | |
| footer_matches = FOOTER_RE.findall(html) | |
| checks["exactly_one_footer_tag"] = (len(footer_matches) == 1) | |
| for tag in ("html", "body", "footer"): | |
| opens = len(re.findall(rf"<{tag}\b", html, re.IGNORECASE)) | |
| closes = len(re.findall(rf"</{tag}\s*>", html, re.IGNORECASE)) | |
| checks[f"{tag}_tags_balanced"] = (opens == closes) | |
| if footer_matches: | |
| footer_html = footer_matches[0] | |
| classes_match = CLASS_ATTR_RE.search(FOOTER_OPEN_TAG_RE.search(footer_html).group(0)) | |
| classes = classes_match.group(1).split() if classes_match else [] | |
| checks["has_footer_class"] = "footer" in classes | |
| checks["has_footer_glass_class"] = "footer-glass" in classes | |
| checks["footer_matches_canonical"] = (sha1(footer_html) == sha1(canonical_footer)) | |
| else: | |
| checks["has_footer_class"] = False | |
| checks["has_footer_glass_class"] = False | |
| checks["footer_matches_canonical"] = False | |
| checks["font_override_warnings"] = scan_font_bug_warnings(html) | |
| status = "pass" if all(v is True for v in checks.values() if isinstance(v, bool)) else "fail" | |
| return status, checks | |
| def analyze_file(path: Path, canonical_footer: str): | |
| result = {"file": str(path), "status": None, "notes": []} | |
| try: | |
| html = path.read_text(encoding="utf-8") | |
| except Exception as e: | |
| result["status"] = "error" | |
| result["notes"].append(f"read error: {e}") | |
| return result, None | |
| match = FOOTER_RE.search(html) | |
| if not match: | |
| result["status"] = "skipped_no_footer" | |
| result["notes"].append("no <footer>...</footer> block found") | |
| return result, None | |
| current_footer = match.group(0) | |
| result["status"] = "already_correct" if sha1(current_footer) == sha1(canonical_footer) else "would_change" | |
| font_warnings = scan_font_bug_warnings(html) | |
| if font_warnings: | |
| result["notes"].append(f"{len(font_warnings)} font-family override(s) outside footer (not auto-fixed)") | |
| new_html = html[:match.start()] + canonical_footer + html[match.end():] | |
| return result, new_html | |
| def step1_dry_run(root, reference, canonical_footer): | |
| print("\n--- STEP 1: DRY RUN ---") | |
| files = [] | |
| for path in find_html_files(root, reference): | |
| result, _ = analyze_file(path, canonical_footer) | |
| files.append(result) | |
| counts = {} | |
| for f in files: | |
| counts[f["status"]] = counts.get(f["status"], 0) + 1 | |
| for status, count in sorted(counts.items()): | |
| print(f" {status:20s} {count}") | |
| for f in files: | |
| if f["status"] in ("error", "skipped_no_footer"): | |
| print(f" [{f['status']}] {f['file']}") | |
| return files | |
| def step2_apply(root, reference, canonical_footer): | |
| print("\n--- STEP 2: APPLY ---") | |
| results = [] | |
| for path in find_html_files(root, reference): | |
| result, new_html = analyze_file(path, canonical_footer) | |
| if result["status"] != "would_change": | |
| results.append(result) | |
| continue | |
| bak_path = path.with_name(path.name + BAK_SUFFIX) | |
| try: | |
| bak_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") | |
| path.write_text(new_html, encoding="utf-8") | |
| result["status"] = "applied" | |
| result["bak_path"] = str(bak_path) | |
| except Exception as e: | |
| result["status"] = "error" | |
| result["notes"].append(f"write error: {e}") | |
| results.append(result) | |
| counts = {} | |
| for f in results: | |
| counts[f["status"]] = counts.get(f["status"], 0) + 1 | |
| for status, count in sorted(counts.items()): | |
| print(f" {status:20s} {count}") | |
| return results | |
| def step3_delete_all_bak(root): | |
| print("\n--- STEP 3: DELETE ALL .bak FILES ---") | |
| # Match this tool's own suffix AND any generic *.bak, in case older/manual | |
| # backups are lying around under a different naming scheme. | |
| candidates = set(root.rglob(f"*{BAK_SUFFIX}")) | set(root.rglob("*.bak")) | |
| print(f" found: {len(candidates)} candidate .bak file(s) under {root}") | |
| removed, errors = [], [] | |
| for p in sorted(candidates): | |
| try: | |
| p.unlink() | |
| removed.append(str(p)) | |
| except Exception as e: | |
| errors.append(f"{p}: {e}") | |
| for d in root.rglob(OLD_BACKUP_DIR_NAME): | |
| if d.is_dir(): | |
| import shutil | |
| try: | |
| shutil.rmtree(d) | |
| removed.append(str(d)) | |
| except Exception as e: | |
| errors.append(f"{d}: {e}") | |
| print(f" removed: {len(removed)}") | |
| for r in removed: | |
| print(f" - {r}") | |
| if errors: | |
| print(f" FAILED TO REMOVE: {len(errors)} (likely a permissions problem -- re-run with sudo)") | |
| for e in errors: | |
| print(f" ERROR: {e}") | |
| return removed, errors | |
| def step4_delete_reports(root, keep_report: bool): | |
| print("\n--- STEP 4: DELETE REPORT .json FILES ---") | |
| if keep_report: | |
| print(" --keep-report set, skipping") | |
| return [] | |
| removed = [] | |
| search_dirs = {root, Path.cwd()} | |
| for d in search_dirs: | |
| for p in d.glob(REPORT_GLOB): | |
| try: | |
| p.unlink() | |
| removed.append(str(p)) | |
| except Exception as e: | |
| print(f" ERROR deleting {p}: {e}") | |
| print(f" removed: {len(removed)}") | |
| for r in removed: | |
| print(f" - {r}") | |
| return removed | |
| def step5_final_validate(root, reference, canonical_footer): | |
| print("\n--- STEP 5: FINAL VALIDATE ---") | |
| files = [] | |
| for path in find_html_files(root, reference): | |
| status, checks = validate_html(path, canonical_footer) | |
| files.append({"file": str(path), "status": status, "checks": checks}) | |
| passed = sum(1 for f in files if f["status"] == "pass") | |
| failed = sum(1 for f in files if f["status"] == "fail") | |
| errors = sum(1 for f in files if f["status"] == "error") | |
| print(f" pass: {passed}") | |
| print(f" fail: {failed}") | |
| print(f" error: {errors}") | |
| for f in files: | |
| if f["status"] != "pass": | |
| print(f" [{f['status']}] {f['file']}") | |
| for k, v in f["checks"].items(): | |
| if v is False or (isinstance(v, list) and v): | |
| print(f" {k}: {v}") | |
| return files | |
| def step6_restart_apache(): | |
| print("\n--- STEP 6: RESTART APACHE ---") | |
| import os | |
| already_root = (os.geteuid() == 0) | |
| attempts = [["systemctl", "restart", "apache2"], ["service", "apache2", "restart"]] | |
| if not already_root: | |
| attempts = [["sudo", "-n"] + a for a in attempts] # -n: fail fast instead of hanging on a password prompt | |
| for cmd in attempts: | |
| try: | |
| proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60) | |
| if proc.returncode == 0: | |
| print(f" OK: `{' '.join(cmd)}` succeeded") | |
| return True | |
| else: | |
| err = proc.stderr.strip() or proc.stdout.strip() | |
| print(f" `{' '.join(cmd)}` failed (exit {proc.returncode}): {err}") | |
| except FileNotFoundError: | |
| print(f" `{cmd[0]}` not found, trying next method") | |
| except Exception as e: | |
| print(f" `{' '.join(cmd)}` error: {e}") | |
| if not already_root: | |
| print(" FAILED: not running as root, and passwordless sudo isn't set up, so Apache " | |
| "could not be restarted automatically.") | |
| print(" Run the WHOLE command with sudo instead, e.g.:") | |
| print(" sudo python3 sync_site_footer.py --root /var/www/html " | |
| "--reference /var/www/html/index.html --apply") | |
| else: | |
| print(" FAILED: could not restart Apache with systemctl or service even as root. " | |
| "Check `systemctl status apache2` manually.") | |
| return False | |
| def run_cleanup_backups_only(root, keep_report): | |
| removed_bak, errors = step3_delete_all_bak(root) | |
| removed_reports = step4_delete_reports(root, keep_report) | |
| return removed_bak, errors, removed_reports | |
| def main(): | |
| parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| parser.add_argument("--root", required=True, help="Site root, e.g. /var/www/html") | |
| parser.add_argument("--reference", help="Path to the home page HTML file (canonical footer source)") | |
| parser.add_argument("--apply", action="store_true", | |
| help="Run the full pipeline: dry-run, apply, delete all .bak, delete reports, " | |
| "final validate, restart Apache. Without this flag: dry-run only.") | |
| parser.add_argument("--validate-only", action="store_true", help="Only run the final validate step") | |
| parser.add_argument("--cleanup-backups", action="store_true", | |
| help="Only delete .bak files / old backup dirs / report .json files, then exit") | |
| parser.add_argument("--no-restart", action="store_true", help="Skip the Apache restart step") | |
| parser.add_argument("--keep-report", action="store_true", help="Don't delete report .json files this run") | |
| args = parser.parse_args() | |
| root = Path(args.root) | |
| if not root.is_dir(): | |
| sys.exit(f"Error: root {root} is not a directory") | |
| import os | |
| running_as_root = (os.geteuid() == 0) | |
| print(f"Running as: {'root' if running_as_root else f'uid {os.geteuid()} (not root)'}") | |
| if args.apply and not running_as_root: | |
| print(f"WARNING: {root} is likely owned by root/www-data, and restarting Apache always " | |
| f"needs root. If writes or the restart fail below, re-run the whole command with " | |
| f"sudo:\n sudo python3 {Path(__file__).name} --root {args.root} " | |
| f"--reference {args.reference or '<reference>'} --apply\n") | |
| if args.cleanup_backups: | |
| run_cleanup_backups_only(root, args.keep_report) | |
| return | |
| if not args.reference: | |
| sys.exit("Error: --reference is required unless using --cleanup-backups") | |
| reference = Path(args.reference) | |
| if not reference.is_file(): | |
| sys.exit(f"Error: reference file {reference} not found") | |
| reference_html = reference.read_text(encoding="utf-8") | |
| canonical_footer = build_canonical_footer(reference_html) | |
| if args.validate_only: | |
| step5_final_validate(root, reference, canonical_footer) | |
| return | |
| dry_run_files = step1_dry_run(root, reference, canonical_footer) | |
| if not args.apply: | |
| print("\nDry run only -- nothing written, nothing deleted, Apache not touched.") | |
| print("Re-run with --apply to run the full pipeline.") | |
| return | |
| would_change = sum(1 for f in dry_run_files if f["status"] == "would_change") | |
| if would_change == 0: | |
| print("\nNothing to apply -- all files already match the canonical footer. " | |
| "Skipping apply/cleanup/restart.") | |
| return | |
| apply_results = step2_apply(root, reference, canonical_footer) | |
| step3_delete_all_bak(root) | |
| step4_delete_reports(root, args.keep_report) | |
| final_validate = step5_final_validate(root, reference, canonical_footer) | |
| applied_count = sum(1 for f in apply_results if f["status"] == "applied") | |
| failed_count = sum(1 for f in final_validate if f["status"] != "pass") | |
| if failed_count: | |
| print(f"\nWARNING: {failed_count} file(s) failed final validation, and their .bak backups " | |
| f"were already deleted in step 3 (no rollback copy exists). Review the STEP 5 output above.") | |
| if args.no_restart: | |
| print("\n--no-restart set -- skipping Apache restart.") | |
| elif applied_count > 0: | |
| step6_restart_apache() | |
| else: | |
| print("\nNo files were actually applied -- skipping Apache restart.") | |
| print("\nDone.") | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html
sudo python3 sync_site_footer.py --root /var/www/html --cleanup-backups
sudo python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html --apply
sudo python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html --apply --no-restart
python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html --validate-only
sudo python3 sync_site_footer.py --root /var/www/html --reference /var/www/html/index.html --apply --keep-report