← back to Ga Allsites
fanout_portfolio.py
155 lines
#!/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, 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()