← back to Ga Allsites
gsc: portfolio-wide owner-grant fan-out (289 domains) — resumable two-phase driver + --all-fleet/--fanout modes
0f32a525b8f674016232367472a3ff7c608dba0c · 2026-08-17 11:10:48 -0700 · Steve Abrams
Files touched
Diff
commit 0f32a525b8f674016232367472a3ff7c608dba0c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 17 11:10:48 2026 -0700
gsc: portfolio-wide owner-grant fan-out (289 domains) — resumable two-phase driver + --all-fleet/--fanout modes
---
fanout_portfolio.py | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 155 insertions(+)
diff --git a/fanout_portfolio.py b/fanout_portfolio.py
new file mode 100644
index 0000000..b58266c
--- /dev/null
+++ b/fanout_portfolio.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""
+Portfolio-wide GSC owner-grant fan-out (Steve-authorized 2026-08-17, entire portfolio).
+
+Grants the ga-allsites SA verified-owner + sc-domain Search Console access across
+EVERY GoDaddy + Cloudflare domain we control DNS for. Two-phase for speed + quota
+safety, resumable via a JSONL log (rerun to pick up where a throttle left off).
+
+ Phase 1 mint DNS-TXT token + write apex TXT (Cloudflare or GoDaddy) [no sleeps]
+ --- one propagation wait
+ Phase 2 verify SA as owner + add sc-domain:<domain> [429-backoff]
+
+Reversible: delete the google-site-verification TXT + remove the SA user in GSC.
+
+Usage: python3 fanout_portfolio.py # resume/run the full union
+ python3 fanout_portfolio.py --phase2 # only re-run verify+add (TXT already written)
+"""
+import json
+import pathlib
+import sys
+import threading
+import time
+import urllib.error
+import urllib.parse
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+import grant_microsites_gsc as G
+
+_LOG_LOCK = threading.Lock()
+WORKERS = 10 # parallel verify+add; 429-backoff absorbs quota bumps
+
+HERE = pathlib.Path(__file__).parent
+PORT = HERE / "cache" / "_portfolio_domains.json"
+LOG = HERE / "cache" / "_fanout_results.jsonl"
+PROP_WAIT = 45 # one-shot DNS propagation wait between phases
+
+
+def load_done():
+ """domains already fully GRANTED in a prior run (resume)."""
+ done = {}
+ if LOG.exists():
+ for line in LOG.read_text().splitlines():
+ if not line.strip():
+ continue
+ r = json.loads(line)
+ done[r["domain"]] = r["result"]
+ return done
+
+
+def logline(domain, result, token=""):
+ with _LOG_LOCK:
+ with LOG.open("a") as f:
+ f.write(json.dumps({"domain": domain, "result": result, "token": token}) + "\n")
+
+
+def verify_and_add(domain, gtoken):
+ """Phase-2 verify owner + add sc-domain, with 429 backoff. Returns result str."""
+ for attempt in range(4):
+ try:
+ G.gget("https://www.googleapis.com/siteVerification/v1/webResource"
+ "?verificationMethod=DNS_TXT", gtoken,
+ {"site": {"type": "INET_DOMAIN", "identifier": domain}})
+ break
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ time.sleep(20 * (attempt + 1))
+ continue
+ return f"VERIFY {e.code}: {e.read().decode()[:120]}"
+ else:
+ return "VERIFY throttled"
+ site = urllib.parse.quote(f"sc-domain:{domain}", safe="")
+ for attempt in range(4):
+ try:
+ G.gget(f"https://searchconsole.googleapis.com/webmasters/v3/sites/{site}",
+ gtoken, method="PUT")
+ return "GRANTED"
+ except urllib.error.HTTPError as e:
+ if e.code == 409:
+ return "GRANTED" # already present
+ if e.code == 429:
+ time.sleep(20 * (attempt + 1))
+ continue
+ return f"OWNER-OK sites.add {e.code}"
+ return "OWNER-OK sites.add throttled"
+
+
+def main():
+ phase2_only = "--phase2" in sys.argv
+ domains = json.loads(PORT.read_text())["union"]
+ creds = G.google_creds()
+ gtoken = creds.token
+ cftok = G.cf_token()
+ gd = G.godaddy_creds()
+ done = load_done()
+
+ todo = [d for d in domains if done.get(d) != "GRANTED"]
+ print(f"portfolio: {len(domains)} domains | already GRANTED: "
+ f"{sum(1 for v in done.values() if v=='GRANTED')} | to process: {len(todo)}", flush=True)
+
+ tokens = {}
+ if not phase2_only:
+ print("=== PHASE 1: mint token + write TXT ===", flush=True)
+ for i, d in enumerate(todo, 1):
+ try:
+ tok = G.gget("https://www.googleapis.com/siteVerification/v1/token", gtoken,
+ {"verificationMethod": "DNS_TXT",
+ "site": {"type": "INET_DOMAIN", "identifier": d}})["token"]
+ state = G.write_txt(d, tok, cftok, gd)
+ tokens[d] = tok
+ if state == "no-host":
+ logline(d, "SKIP no-host")
+ tokens.pop(d, None)
+ if i % 25 == 0:
+ print(f" phase1 {i}/{len(todo)} ...", flush=True)
+ except urllib.error.HTTPError as e:
+ logline(d, f"PHASE1 {e.code}: {e.read().decode()[:100]}")
+ except Exception as e: # noqa: BLE001
+ logline(d, f"PHASE1 ERR: {e}")
+ print(f"phase1 done: {len(tokens)} TXT written/present. waiting {PROP_WAIT}s ...", flush=True)
+ time.sleep(PROP_WAIT)
+ else:
+ tokens = {d: True for d in todo} # assume TXT already there
+
+ print(f"=== PHASE 2: verify owner + add sc-domain ({WORKERS} parallel) ===", flush=True)
+ p2 = [d for d in todo if d in tokens]
+ granted = 0
+ done_n = 0
+
+ def _work(d):
+ res = verify_and_add(d, gtoken)
+ logline(d, res, tokens.get(d, "") if isinstance(tokens.get(d), str) else "")
+ return d, res
+
+ with ThreadPoolExecutor(max_workers=WORKERS) as ex:
+ futs = {ex.submit(_work, d): d for d in p2}
+ for fut in as_completed(futs):
+ d, res = fut.result()
+ done_n += 1
+ if res == "GRANTED":
+ granted += 1
+ if done_n % 25 == 0:
+ print(f" phase2 {done_n}/{len(p2)} ... (+{granted} granted)", flush=True)
+
+ # summary
+ final = load_done()
+ from collections import Counter
+ c = Counter(v.split(":")[0].split(" ")[0] for v in final.values())
+ print("=== SUMMARY ===", flush=True)
+ for k, n in c.most_common():
+ print(f" {k:20} {n}", flush=True)
+ print(f"total logged: {len(final)}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
← 9561e03 auto-data-snapshot: 2026-08-17T10:56:53 (1 data files) — cac
·
back to Ga Allsites
·
auto-data-snapshot: 2026-08-17T11:27:07 (1 data files) — cac a3f1524 →