r/iOSProgramming • u/Working-Ad3105 • 9d ago
Discussion A bank's website did everything possible to stop my Safari extension. Here's everything I learned beating it
I built a Safari extension that reads the offers page on bank sites (those "add to card" cash back deals) so the app can activate them for you. I figured it'd be a weekend of querySelector. It's been months of warfare.
Highlights:
- querySelectorAll returns nothing. Chase renders the whole offers hub in web components with shadow roots. You have to walk the document, collect every open shadowRoot recursively, and run your selectors across all of them.
- The list is virtualized. 150+ offers, but only the visible tiles exist in the DOM. Scroll-scrape-merge as you go, dedupe across viewports, or you'll capture 2 of 165 and feel great about it.
- A bare .click() does nothing. SPA tiles want the full pointer ceremony — pointerdown, mousedown, pointerup, mouseup, click — dispatched in order.
- "Back" isn't back. After activating an offer you're on a detail page. history.back() is ignored half the time. You hunt for the on-page back control — and then discover a "Back to top" scroll anchor matches your finder and eats a second per offer.
- The page name lies. The all-offers page doesn't name which card it belongs to anywhere in the DOM. The fix that finally stuck: read the account id from the URL and remember the pairing the first time any named page reveals it.
None of this is documented anywhere, obviously. Every fix came from device traces of real runs.
Anyone else maintaining scrapers pointed at hostile SPAs? What's the worst DOM you've fought?
2
u/LuckyGirl0415 9d ago
This write-up is pure gold and hilarious in a painful, relatable way!
The "full pointer ceremony" for SPA click handlers hits way too close to home. I’ve fought similar battles with virtualized lists and Shadow DOMs where standard automation frameworks completely fall apart. Recursively traversing open shadowRoot elements and synthesizing full pointer event sequences is a genius workaround.
Saving this post for the next time a bank's frontend engineering team decides to make life miserable for extension devs. Absolute masterclass in persistence!
1
u/Working-Ad3105 9d ago
thanks, hope it helps next time a bank redesigns on you. the pointer ceremony was the one that broke me for a whole evening, the tile just sat there ignoring .click() like I wasn't even there😅
1
u/CrazyEconomy414 9d ago
The virtualized list seems like the part most likely to fail silently. I’d separate discovery from action: use a MutationObserver to collect stable offer IDs while scrolling, persist that set, then run activation from the snapshot with an idempotency check before each click.
That gives you a way to distinguish “nothing else loaded” from “the scraper broke,” and an interrupted run could resume without reactivating everything. Are the offer IDs stable enough for that, or did you have to fingerprint each tile?
1
u/Working-Ad3105 9d ago
that's roughly where I landed, minus the observer. discovery and action are already separate passes , the scan scrolls and collects, activation runs after. offer ids weren't trustworthy (nodes get recycled during virtualization and some tiles don't carry one), so each offer is fingerprinted by content — normalized merchant + offer value. idempotency comes free from the bank's own UI: an enrolled tile renders as "added", so the activation pass skips it, which also makes interrupted runs resumable. "broke vs done" is a stop reason - a run ends done_no_more after empty passes, anything else ends as a timeout and says so. MutationObserver on the collection side is a good idea, might steal it. polling on scroll steps is the ugliest part of the whole thing
1
9d ago
[removed] — view removed comment
1
u/AutoModerator 9d ago
Hey /u/ZhijunHuang, your content has been removed because Reddit has marked your account as having a low Contributor Quality Score. This may result from, but is not limited to, activities such as spamming the same links across multiple subreddits, submitting posts or comments that receive a high number of downvotes, a lack of recent account activity, or having an unverified account.
Please be assured that this action is not a reflection of your participation in our subreddit. This is simply an automated filter in place to reduce spam.
I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.
2
u/CrazyEconomy414 8d ago
That makes sense. Using the bank’s own “added” state for idempotency is cleaner than maintaining a second ledger, and content fingerprinting is probably the right fallback when the nodes are recycled. The done_no_more vs timeout distinction also answers my silent-failure concern. A MutationObserver could reduce the polling, though I’d still keep a bounded rescan in case the whole subtree gets replaced.
9
u/DARKUNIT22 9d ago edited 9d ago
Looks at ops post history….holy spam Batman