Public Access
Drives luckysupermarkets.com in stealth CDP mode: opens the store locator, types the zip code, clicks the target store, then triggers a category page navigation. A fetch + XHR interceptor (installed via JS) captures the first Authorization header sent to a Swiftly host. The captured JWT is validated (iss + exp), then written into the env file. Runs on the host (not docker) since seleniumbase needs a real Chrome. Defaults to .env.test, headless, zip 94806, store 757. Flags: --debug visible Chrome window --restart-backend rerun docker compose to pick up the new token --env-file PATH override target env file --zip / --store override location Selectors are intentionally JS-based and tolerant of UI changes (querySelectorAll fallthrough by attribute heuristics + textContent substring match) so first-attempt failures degrade to clear errors rather than silent breakage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
10 KiB
Python
Executable File
288 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Capture a fresh SWIFTLY_BEARER_TOKEN from luckysupermarkets.com.
|
|
|
|
Uses seleniumbase in stealth CDP mode to drive the live site, intercept
|
|
window.fetch + XMLHttpRequest, and capture the Authorization header on
|
|
the first prod.swiftlyapi.net request after a store is selected. The
|
|
captured JWT is validated (iss + exp), then written to the env file.
|
|
|
|
Runs on the HOST (not inside docker) since seleniumbase needs a real
|
|
Chrome/Chromium binary. Requires:
|
|
pip install seleniumbase
|
|
seleniumbase install chromedriver # one-time
|
|
|
|
Usage:
|
|
python scripts/refresh_swiftly_token.py
|
|
python scripts/refresh_swiftly_token.py --debug
|
|
python scripts/refresh_swiftly_token.py --restart-backend
|
|
python scripts/refresh_swiftly_token.py --env-file .env --zip 94806
|
|
|
|
Exits 0 on success, non-zero with diagnostics otherwise.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from seleniumbase import SB
|
|
except ImportError:
|
|
print(
|
|
"[refresh_swiftly_token] seleniumbase not installed.\n"
|
|
" pip install seleniumbase\n"
|
|
" seleniumbase install chromedriver",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
|
|
|
|
SWIFTLY_HOST = "swiftlyapi"
|
|
EXPECTED_ISS = "https://securetoken.google.com/swiftly-lu-prod"
|
|
DEFAULT_ENV_FILE = Path(".env.test")
|
|
DEFAULT_ZIP = "94806"
|
|
DEFAULT_STORE_NUMBER = "757"
|
|
ENV_LINE_RE = re.compile(r"^SWIFTLY_BEARER_TOKEN=.*$", re.MULTILINE)
|
|
|
|
# JS that wraps window.fetch and XMLHttpRequest to capture the first
|
|
# Bearer header sent to a Swiftly API host. Re-applied after every
|
|
# navigation since SPAs may not always reset window state.
|
|
FETCH_HOOK_JS = r"""
|
|
(function(){
|
|
if (window.__swiftlyHookInstalled) return;
|
|
window.__swiftlyHookInstalled = true;
|
|
window.__capturedSwiftlyAuth = null;
|
|
|
|
const HOST = 'swiftlyapi';
|
|
const origFetch = window.fetch;
|
|
window.fetch = function() {
|
|
try {
|
|
const req = arguments[0];
|
|
const init = arguments[1] || {};
|
|
const url = typeof req === 'string' ? req : (req && req.url) || '';
|
|
let auth = null;
|
|
if (init.headers) {
|
|
const h = init.headers;
|
|
if (h instanceof Headers) auth = h.get('Authorization') || h.get('authorization');
|
|
else if (h && typeof h === 'object') auth = h['Authorization'] || h['authorization'];
|
|
}
|
|
if (!auth && req && req.headers && req.headers.get) {
|
|
auth = req.headers.get('Authorization') || req.headers.get('authorization');
|
|
}
|
|
if (auth && auth.indexOf('Bearer ') === 0 && url.indexOf(HOST) !== -1) {
|
|
window.__capturedSwiftlyAuth = auth.slice(7);
|
|
}
|
|
} catch (e) {}
|
|
return origFetch.apply(this, arguments);
|
|
};
|
|
|
|
const origOpen = XMLHttpRequest.prototype.open;
|
|
XMLHttpRequest.prototype.open = function(m, u) {
|
|
this.__url = u;
|
|
return origOpen.apply(this, arguments);
|
|
};
|
|
const origSet = XMLHttpRequest.prototype.setRequestHeader;
|
|
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
|
|
try {
|
|
if (/^authorization$/i.test(name) && value && value.indexOf('Bearer ') === 0
|
|
&& this.__url && this.__url.indexOf(HOST) !== -1) {
|
|
window.__capturedSwiftlyAuth = value.slice(7);
|
|
}
|
|
} catch (e) {}
|
|
return origSet.apply(this, arguments);
|
|
};
|
|
})();
|
|
"""
|
|
|
|
|
|
def install_hook(sb) -> None:
|
|
sb.cdp.evaluate(FETCH_HOOK_JS)
|
|
|
|
|
|
def get_captured(sb) -> str | None:
|
|
val = sb.cdp.evaluate("window.__capturedSwiftlyAuth")
|
|
if isinstance(val, str) and val:
|
|
return val
|
|
return None
|
|
|
|
|
|
def decode_jwt_payload(token: str) -> dict:
|
|
parts = token.split(".")
|
|
if len(parts) != 3:
|
|
raise ValueError("invalid JWT shape (expected 3 segments)")
|
|
pad = "=" * (-len(parts[1]) % 4)
|
|
return json.loads(base64.urlsafe_b64decode(parts[1] + pad))
|
|
|
|
|
|
def validate_token(token: str) -> dict:
|
|
payload = decode_jwt_payload(token)
|
|
iss = payload.get("iss")
|
|
if iss != EXPECTED_ISS:
|
|
raise ValueError(f"unexpected JWT iss: {iss!r} (expected {EXPECTED_ISS!r})")
|
|
exp = int(payload.get("exp", 0))
|
|
if exp <= time.time():
|
|
raise ValueError(f"captured token is already expired (exp={exp})")
|
|
return payload
|
|
|
|
|
|
def select_store(sb, zip_code: str, store_number: str) -> None:
|
|
"""Open the locator drawer, type a zip, click the matching store."""
|
|
print(f"[refresh] opening store locator at zip {zip_code}, target store #{store_number}")
|
|
sb.cdp.get("https://luckysupermarkets.com/categories?showStoreLocator=true")
|
|
install_hook(sb)
|
|
time.sleep(2)
|
|
|
|
# Type zip via JS so we don't depend on exact CSS selectors.
|
|
typed = sb.cdp.evaluate(f"""
|
|
(function() {{
|
|
const inputs = Array.from(document.querySelectorAll('input'));
|
|
const c = inputs.find(i =>
|
|
/zip|search|locat|store/i.test(i.placeholder || '') ||
|
|
/zip|search|locat|store/i.test(i.name || '') ||
|
|
/zip|search|locat|store/i.test(i.id || '') ||
|
|
i.type === 'search'
|
|
);
|
|
if (!c) return null;
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
|
setter.call(c, '{zip_code}');
|
|
c.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
c.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
c.focus();
|
|
return 'typed in: ' + (c.placeholder || c.name || c.id || c.tagName);
|
|
}})();
|
|
""")
|
|
if not typed:
|
|
raise RuntimeError("could not find zip-code input on store-locator page")
|
|
print(f"[refresh] {typed}")
|
|
time.sleep(3)
|
|
|
|
# Click any element whose text contains the store number.
|
|
deadline = time.time() + 10
|
|
clicked = None
|
|
while time.time() < deadline:
|
|
clicked = sb.cdp.evaluate(f"""
|
|
(function() {{
|
|
const target = '{store_number}';
|
|
const candidates = Array.from(document.querySelectorAll(
|
|
'button, a, [role=button], [data-store-id], [data-store], li, div'
|
|
));
|
|
const match = candidates.find(el => {{
|
|
const txt = (el.textContent || '').trim();
|
|
return txt.length > 0 && txt.length < 400 && txt.includes(target);
|
|
}});
|
|
if (!match) return null;
|
|
match.click();
|
|
return 'clicked: ' + match.tagName + ' -> ' + (match.textContent || '').slice(0, 100);
|
|
}})();
|
|
""")
|
|
if clicked:
|
|
break
|
|
time.sleep(0.5)
|
|
if not clicked:
|
|
raise RuntimeError(f"could not click an element containing store #{store_number}")
|
|
print(f"[refresh] {clicked}")
|
|
time.sleep(3)
|
|
|
|
|
|
def trigger_api_call(sb) -> None:
|
|
print("[refresh] navigating to a category page to trigger /search/api/v1 call")
|
|
sb.cdp.get("https://luckysupermarkets.com/categories/Product%2Fmeat_seafood")
|
|
install_hook(sb)
|
|
|
|
|
|
def wait_for_token(sb, timeout_s: int) -> str:
|
|
deadline = time.time() + timeout_s
|
|
while time.time() < deadline:
|
|
token = get_captured(sb)
|
|
if token:
|
|
return token
|
|
time.sleep(0.5)
|
|
raise TimeoutError(
|
|
f"no Swiftly Bearer token captured within {timeout_s}s — "
|
|
"the site may have blocked the request, or the UI flow may have changed"
|
|
)
|
|
|
|
|
|
def write_token_to_env(env_path: Path, token: str) -> None:
|
|
if not env_path.exists():
|
|
raise FileNotFoundError(f"env file not found: {env_path}")
|
|
content = env_path.read_text()
|
|
new_line = f"SWIFTLY_BEARER_TOKEN={token}"
|
|
if ENV_LINE_RE.search(content):
|
|
new_content = ENV_LINE_RE.sub(new_line, content)
|
|
else:
|
|
suffix = "" if content.endswith("\n") else "\n"
|
|
new_content = content + suffix + new_line + "\n"
|
|
tmp = env_path.with_suffix(env_path.suffix + ".tmp")
|
|
tmp.write_text(new_content)
|
|
tmp.replace(env_path)
|
|
|
|
|
|
def restart_backend(env_file: Path) -> None:
|
|
cmd = [
|
|
"docker", "compose", "--env-file", str(env_file),
|
|
"up", "-d", "--force-recreate", "backend",
|
|
]
|
|
print(f"[refresh] $ {' '.join(cmd)}")
|
|
subprocess.run(cmd, check=True)
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE,
|
|
help=f"env file to update (default: {DEFAULT_ENV_FILE})")
|
|
p.add_argument("--zip", default=DEFAULT_ZIP,
|
|
help=f"zip code for store search (default: {DEFAULT_ZIP})")
|
|
p.add_argument("--store", default=DEFAULT_STORE_NUMBER,
|
|
help=f"target store number to click (default: {DEFAULT_STORE_NUMBER})")
|
|
p.add_argument("--debug", action="store_true",
|
|
help="run with a visible Chrome window")
|
|
p.add_argument("--restart-backend", action="store_true",
|
|
help="restart the docker backend after writing the token")
|
|
p.add_argument("--timeout", type=int, default=30,
|
|
help="seconds to wait for token capture (default: 30)")
|
|
args = p.parse_args()
|
|
|
|
headless = not args.debug
|
|
print(
|
|
f"[refresh] env={args.env_file} headless={headless} "
|
|
f"zip={args.zip} store={args.store} timeout={args.timeout}s"
|
|
)
|
|
|
|
with SB(uc=True, headless=headless, test=True) as sb:
|
|
sb.activate_cdp_mode("https://luckysupermarkets.com")
|
|
install_hook(sb)
|
|
select_store(sb, args.zip, args.store)
|
|
trigger_api_call(sb)
|
|
token = wait_for_token(sb, args.timeout)
|
|
|
|
print(f"[refresh] captured Bearer JWT (length={len(token)})")
|
|
payload = validate_token(token)
|
|
expires_in_s = int(payload["exp"]) - int(time.time())
|
|
print(
|
|
f"[refresh] valid: iss={payload.get('iss')} "
|
|
f"exp_in={expires_in_s}s ({expires_in_s/60:.1f} min)"
|
|
)
|
|
|
|
write_token_to_env(args.env_file, token)
|
|
print(f"[refresh] wrote SWIFTLY_BEARER_TOKEN to {args.env_file}")
|
|
|
|
if args.restart_backend:
|
|
restart_backend(args.env_file)
|
|
print("[refresh] backend restarted")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except (RuntimeError, TimeoutError, ValueError, FileNotFoundError) as e:
|
|
print(f"[refresh] FAILED: {e}", file=sys.stderr)
|
|
sys.exit(1)
|