From 7f06533d8b03fc62ac204ef00b2941848518ea1a Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Fri, 10 Jul 2026 01:45:33 +0200 Subject: [PATCH] feat(seo-data): OAuth consent + property discovery + pinned deps --- lib/seo-data/connect.py | 57 +++++++++++++++++++++++++++++++++++ lib/seo-data/requirements.txt | 3 ++ lib/seo-data/seo-data.test.sh | 10 ++++++ 3 files changed, 70 insertions(+) create mode 100644 lib/seo-data/connect.py create mode 100644 lib/seo-data/requirements.txt diff --git a/lib/seo-data/connect.py b/lib/seo-data/connect.py new file mode 100644 index 0000000..5ebdcde --- /dev/null +++ b/lib/seo-data/connect.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""One-time OAuth consent + GSC property discovery + persist. Third-party imports +are lazy so `persist` is testable stdlib-only.""" +import argparse, os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import tokenstore + +SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"] + +def run_consent(client_id, client_secret): + from google_auth_oauthlib.flow import InstalledAppFlow # lazy + cfg = {"installed": {"client_id": client_id, "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"]}} + flow = InstalledAppFlow.from_client_config(cfg, scopes=SCOPES) + creds = flow.run_local_server(port=0) # opens browser, one-time consent + if not creds.refresh_token: + raise SystemExit("No refresh token returned. Revoke prior grant and retry.") + return creds.refresh_token + +def discover_properties(refresh_token, client_id, client_secret): + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import AuthorizedSession, Request + creds = Credentials(None, refresh_token=refresh_token, client_id=client_id, + client_secret=client_secret, + token_uri="https://oauth2.googleapis.com/token", scopes=SCOPES) + creds.refresh(Request()) + r = AuthorizedSession(creds).get( + "https://searchconsole.googleapis.com/webmasters/v3/sites", timeout=30) + r.raise_for_status() + return [e["siteUrl"] for e in r.json().get("siteEntry", [])] + +def persist(store_path, label, refresh_token, scopes, properties): + tokenstore.save_account(store_path, label, refresh_token, scopes, properties) + +def _cli(): + p = argparse.ArgumentParser() + p.add_argument("--label", required=True) + p.add_argument("--store", default=os.path.expanduser("~/.claude/seo-data/tokens.json")) + args = p.parse_args() + cid = os.environ.get("GOOGLE_OAUTH_CLIENT_ID") + csec = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET") + if not (cid and csec): + raise SystemExit("Set GOOGLE_OAUTH_CLIENT_ID/SECRET in ~/.claude/.env first.") + existing = {a["label"] for a in tokenstore.list_accounts(args.store)} + if args.label in existing: + ans = input("Label '%s' exists. Overwrite? [y/N] " % args.label).strip().lower() + if ans != "y": + raise SystemExit("Aborted.") + rt = run_consent(cid, csec) + props = discover_properties(rt, cid, csec) + persist(args.store, args.label, rt, SCOPES, props) + print("Connected '%s'. Properties: %s" % (args.label, ", ".join(props) or "(none)")) + +if __name__ == "__main__": + _cli() diff --git a/lib/seo-data/requirements.txt b/lib/seo-data/requirements.txt new file mode 100644 index 0000000..bf7a5ef --- /dev/null +++ b/lib/seo-data/requirements.txt @@ -0,0 +1,3 @@ +google-auth==2.40.0 +google-auth-oauthlib==1.2.2 +requests==2.32.4 diff --git a/lib/seo-data/seo-data.test.sh b/lib/seo-data/seo-data.test.sh index d8589c6..5a06271 100644 --- a/lib/seo-data/seo-data.test.sh +++ b/lib/seo-data/seo-data.test.sh @@ -96,6 +96,16 @@ BU="$(SEO_DATA_ENV_FILE=$NOENV bash "$FETCH" crux)"; BURC=$? has "bad usage emits json" "$BU" '"status"' [ "$BURC" = "2" ] && ok "bad usage exit 2" || no "bad usage exit 2" "got $BURC" +echo "── connect (persist, offline) ──" +TMP3="$(mktemp -d)"; S3="$TMP3/tokens.json" +python3 -c "import sys; sys.path.insert(0,'$SD'); import connect; \ +connect.persist('$S3','client-x','RT_X',['https://www.googleapis.com/auth/webmasters.readonly'],['sc-domain:x.com'])" +L3="$(python3 "$SD/tokenstore.py" list --file "$S3")" +has "connect.persist wrote label" "$L3" '"client-x"' +has "connect.persist wrote prop" "$L3" 'sc-domain:x.com' +hasnt "connect.persist redacts" "$L3" 'RT_X' +rm -rf "$TMP3" + echo "" echo "seo-data engine: $PASS pass, $FAIL fail" [ "$FAIL" -eq 0 ]