The goal
Scott's agency (Realtorships) serves dozens of real-estate clients wanting fresh realtor.com listings daily. The goal was a zero-ops daily pipeline that scrapes realtor.com per client's ZIPs, writes to a per-client Google Sheet, and serves each client a branded, password-less dashboard with filters, sort and CSV export.
The solution
A two-layer system: (1) Apps Script Code.gs runs daily on a trigger, iterates the parent Users sheet, fires 8 reverse-engineered realtor.com GraphQL queries, paginates 100+ results, and writes to each client's own spreadsheet. (2) Standalone Apps Script web app under webapp/ serves one URL template ?ss=<id> with a branded Realtorships dashboard — sidebar filters, sortable sticky-header table, pagination, CSV export, ACL via parent-sheet hyperlink whitelist, with caching at both layers.
Highlights
- Zero-ops daily pipeline — no server, no cron host
- 8 realtor.com GraphQL queries reverse-engineered from the browser
- Per-client bookmarkable URL without auth, whitelisted by parent sheet
- Stable deployment URL across versions — clients' bookmarks never break
The challenge
realtor.com has no public API — only browser-only GraphQL with rotating queries and real rate limits.
Captured 8 GraphQL calls + a detail drill-down from the browser, normalized them into Apps Script UrlFetchApp requests with exact headers/doc_ids/operations, paginated with nextCursor. probe_inline_query.py helps re-capture when realtor.com rotates queries.
Dozens of clients each need their own editable live sheet — building a DB would be overkill.
One parent Users sheet with per-client spreadsheet hyperlinks in column C. Scraper iterates Users, writes to each client's sheet separately. Clients share/edit their sheet natively.
Clients wanted bookmarkable URLs without login, but the link must not expose another client's data.
doGet extracts ?ss=<id>, looks it up in the Users sheet, and serves only if the ID appears as a column-C hyperlink. Random IDs get 'Dashboard not found'. Onboarding = add row; offboarding = remove or redeploy.
Naive Apps Script rendering gave 8–10s loads on large sheets.
Server returns already-normalized data; client-side JS runs filter/sort/pagination locally. 60s client + 5min server cache. Embedded base64 logo avoids an extra round-trip.
Clients bookmark the dashboard URL — redeploying the web app would break their links.
Used Apps Script's Manage deployments → New version flow — URL stays stable across deploys and all clients see the new version immediately.
What was delivered
- Code.gs scraping service with parent-sheet iteration
- Web app package (webapp/Code.gs, Index.html, Styles.html, Scripts.html, LogoData.html, appsscript.json)
- webapp/README.md with 6-step deployment guide and troubleshooting
- Captured GraphQL request/response pairs (graphql{1..8}_fetch.{js,json})
- Python probes for refreshing GraphQL schemas
- Branded Realtorships dashboard (navy + orange) with embedded base64 logo
- AVAILABLE_FIELDS_FOR_REALTOR.md field reference
Results
8 + detail
GraphQL queries
100+/batch
Batch size
7
Dashboard filters
Daily
Schedule
What it taught me
- For no-auth client portals, ?ss=<whitelisted-id> against a parent sheet beats building real auth
- Client-side filter/sort/pagination on Apps Script-served data keeps the UI snappy without a backend
- Capturing the browser's real GraphQL calls is the sustainable way to integrate undocumented APIs
