Purpose
Return ranked savings-account, money-market, and CD rates from Bankrate.com as structured JSON — including bank name, account name, APY, minimum opening deposit, minimum balance to earn APY, monthly fee, compounding frequency, FDIC/NCUA insurance status, Bankrate score / star rating, editorial "Why this bank?" copy, "Best for…" tag, promotional bonus, last-updated timestamp, "Open Account" affiliate URL, canonical Bankrate account URL, and bank logo URL. Read-only — never clicks Open Account / Apply / Sign In / any conversion CTA; affiliate hrefs are captured but not followed.
When to Use
- "What are the best high-yield savings rates today?" / "Find me a 1-year CD over 4% APY."
- Daily / weekly monitoring of HYS / CD / MMA top-rate movement for a tracker.
- Comparing a specific bank's account (e.g.,
Ally Bank savings,Marcus by Goldman Sachs CD) to peer rates. - Sourcing the Bankrate-editorial picks for a finance newsletter / personal-finance agent.
- Anywhere you'd otherwise scrape a generic "rates comparison" site — Bankrate is the canonical editorial source and is scrape-friendly (no CAPTCHA, no anti-bot wall, gzip-cached at the edge).
Workflow
Bankrate ships two rate surfaces on the same domain. Pick by query depth:
| Surface | Where | When |
|---|---|---|
| Editorial "best of" articles — 8-15 ranked institutions, full data in static HTML | /banking/{cds,savings,money-market}/best-*-rates/ | Default. Covers 80%+ of natural queries ("best HYS", "best 1-yr CD", "best no-penalty CD"). Fetchable, no auth, ~700KB each, parse <article id="institution-details-{id}"> blocks. |
| WRT (Wealth Rate Table) widget — dynamic rate-comparison UI with full filter surface (deposit amount, ZIP, institution type, min APY, etc.) | /banking/savings/rates/, /banking/money-market/rates/, /banking/cds/cd-rates/ (and /landing/savings/rates) | Use when a query needs filter dimensions the editorial page doesn't expose (custom min-APY, ZIP-localized credit unions, compounding-frequency filter, ATM-access toggle). Requires an interactive browserless_agent session — the inventory is client-hydrated via a private GraphQL endpoint. |
1. Map the query to the right URL
Bankrate's recommended_method is url-param for the editorial path — every account-type / term combination has a canonical, fetchable "best of" URL. Map the user's intent to one:
| Intent | URL |
|---|---|
| High-yield savings (default) | https://www.bankrate.com/banking/savings/best-high-yield-interests-savings-accounts/ (>1MB — a browserless_agent goto handles it fine; a real browser has no fetch-size cap. The landing/savings/rates variant ~430KB has the WRT widget only) |
| Money-market rates | https://www.bankrate.com/banking/money-market/rates/ |
| 6-month CD | https://www.bankrate.com/banking/cds/best-6-month-cd-rates/ |
| 1-year CD | https://www.bankrate.com/banking/cds/best-1-year-cd-rates/ |
| 5-year CD | https://www.bankrate.com/banking/cds/best-5-year-cd-rates/ |
| No-penalty CD | https://www.bankrate.com/banking/cds/best-no-penalty-cds/ (301 → …best-no-penalty-cd-rates/) |
| Jumbo CD | https://www.bankrate.com/banking/cds/best-jumbo-cd-rates/ |
| Specific bank profile + product line | https://www.bankrate.com/banking/reviews/{bank-slug}/ (linked from each rate card's "Read review" anchor) |
/banking/savings/rates/ 301-redirects to /banking/savings/best-high-yield-interests-savings-accounts/. goto follows the redirect natively, so no extra flag is needed for a /rates/ shortcut.
2. Fetch the page (browserless_agent goto + in-page parse)
Run a single browserless_agent call with a commands array — goto the page, then either grab the markup or fold the parsing into an in-page evaluate:
{
"commands": [
{
"method": "goto",
"params": {
"url": "https://www.bankrate.com/banking/cds/best-1-year-cd-rates/",
"waitUntil": "load",
"timeout": 45000
}
},
{ "method": "html", "params": { "selector": "body" } }
]
}The html result comes back under .value; parse the <article id="institution-details-*"> blocks from it (step 3). Prefer folding the regex/DOM extraction into an evaluate ({ "method": "evaluate", "params": { "content": "(()=>{ /* parse cards, return JSON.stringify */ })()" } }) so you ship a compact projection instead of ~700KB of raw HTML.
No proxy is needed. Bankrate has minimal anti-bot — pages are gzip-cached at Fastly + Varnish and return 200 in 200-400ms. (If the rare 429 surfaces, add proxy: { proxy: "residential" } to the call — the residential-proxy path resolves it.)
No 1 MB response cap on this path — a real browser renders any page size, so the flagship pages that used to blow a fetch ceiling load fine here:
/banking/savings/best-high-yield-interests-savings-accounts/≈ 1.05 MB./banking/savings/best-online-savings-accounts/≈ 1.0 MB./landing/cd-rates-{d,f,g}/.
The 1-year-CD, 5-year-CD, 6-month-CD, no-penalty-CD, jumbo-CD, and money-market-rates pages are all comfortably small and load cleanly today (May 2026). When the parse target is large, project it down inside the evaluate rather than returning the whole document (the text return is capped ~200k chars).
3. Parse the rate cards
The static-HTML rate cards live under <div class="wealth-dynamic-rate-block"> as a sequence of <article id="institution-details-{numeric_id}"> blocks. Each article carries one ranked institution with all required fields. Use straight regex / HTML parser — there are no JS-rendered fields inside an article.
Extraction targets per <article id="institution-details-{id}">:
| Field | Selector / regex (within the article block) |
|---|---|
| Institution numeric id | id="institution-details-(\d+)" (use for pinned-style dedup / matching across pages) |
| Bank logo URL | <img\s+src="([^"]+)"\s+alt="([^"]*)_logo" |
| Bank name | <h3 class="heading-4[^"]*">([^<]+)</h3> |
| Bankrate review URL | <a class="Button Button--secondary" href="([^"]+)"[^>]*>Read review (e.g. /banking/reviews/morgan-stanley-private/) |
| Bankrate score / rating | <span class="sr-only">Rating: (\d+(?:\.\d+)?) stars out of 5</span> and the visible score: <span class="heading-4 text-base">(\d+(?:\.\d+)?)</span>\s*<span[^>]*>Bankrate (?:CD|savings|MMA|checking) score</span> |
| APY (decimal — primary) | <h4 class="text-base mb-2">Annual percentage yield</h4>\s*<div>([^<]+)</div> |
| Minimum opening deposit | <h4 class="text-base mb-2">\s*Min\. deposit to open</h4>\s*<div>([^<]+)</div> |
| Term (CDs only) | <h4 class="text-base mb-2">\s*Term</h4>\s*<div>([^<]+)</div> |
| Editorial "Why X?" copy | <h4 class="text-base mb-4">Why ([^<?]+)\?</h4>\s*<p[^>]*>([^<]+)</p> |
| All-terms accordion (CD only) | The next <div class="table-container wealth-product-rate-list"> after the Why X? block contains a <table> with one row per term: <tr>\s*<td>([^<]+)</td>\s*<td>\s*([\d.]+%\s*APY)</td>\s*<td>\s*([^<]+)</td> → term, apy, min_deposit |
Each article block is ~10-12 KB; a typical "best of" page yields 8-13 cards. Iterate articles in document order — that order is the editorial ranking (slot #1 first).
4. Pull page-level editorial metadata
These live in <script type="application/ld+json"> blocks and page <meta> tags:
- Last-rate-updated timestamp: the
Articleld+json block's"dateModified":"..."(e.g."2026-05-13T14:54:00.894Z"). Treat this as the authoritative "as of" date for every rate on the page. - Page canonical URL:
<link rel="canonical" href="...">— use asbankrate_account_urlwhen the per-card URL collapses to the page anchor (#institution-details-{id}). - Editorial headline / "Up to X.XX%" summary: the
Article.headlinefield ("Best 1-Year CD Rates for May 2026 - Up to 4.10% \| Bankrate"). - Author + reviewer:
Article.mainEntity.reviewedBy({"@type":"Person","name":"Greg McBride, CFA","jobTitle":"…"}). - Breadcrumb / category: the
BreadcrumbListld+json (Banking → CDs → Best 1-Year CD Rates).
5. Interactive WRT-widget path — when the static path won't work
Drive the WRT widget with an interactive browserless_agent session when:
- The user asked for a filter dimension not covered by the editorial page (custom min-APY, ZIP-localized credit unions, compounding-frequency, ATM-access, mobile-app-rating). Those live exclusively in the WRT widget.
- The query needs the personalized "Bankrate Boost" overlay (ZIP + email + deposit + accountsHeld → JWT-gated wider inventory). The skill should NOT submit a real email — Boost is read-only-incompatible. Skip Boost entirely and read the unauth WRT default-state inventory.
Keep the whole flow (nav → hydrate → filter → read) inside ONE browserless_agent call's commands array so cookies/session state persist across the steps — batching saves round-trips and avoids dropping the session config (the session persists across calls, keyed by proxy/profile). Session shape:
{
"commands": [
{
"method": "goto",
"params": {
"url": "https://www.bankrate.com/banking/cds/cd-rates/",
"waitUntil": "load",
"timeout": 45000
}
},
{ "method": "waitForTimeout", "params": { "time": 3000 } },
{ "method": "snapshot" }
]
}waitForTimeout 3000 covers the ~1-2s WRT hydration; snapshot returns the a11y tree so the WRT rate-cards and filter controls become addressable. (Confirm a control is present via snapshot if a selector below misses.)
The WRT widget controls (drive via click / type commands appended to the same call, targeting the selectors/labels below):
| Control | Notes |
|---|---|
Deposit amount text input | Default 50,000. Filters cards to those whose Min. balance for APY ≤ deposit. |
Zip code text input | Default IP-derived (observed 97818 on a us-west-2 session). This drives surface of local credit unions / community banks — set explicitly for any geo-scoped query. Validated against https://wealth-zip-service.bankrate.com/us/{zip} (200 = valid). |
Product type collapsible — Savings, MMAs, Checking, CDs checkboxes | Multi-select. For CDs, an additional term range appears: 3 mo, 6 mo, 9 mo, 1 yr, 18 mo, 2 yr, 3 yr, 4 yr, 5 yr, 7 yr, 10 yr, plus No-penalty, Bump-up, Step-up, Jumbo. |
Filters button (gear icon, mobile-style modal) | Opens the wider filter panel: min APY, monthly fee ($0 toggle), FDIC/NCUA toggle, compounding (Daily/Monthly/Quarterly), institution type (Online / National / Credit union / Community / Brick-and-mortar), ATM access (MMA/checking), mobile-app rating. |
Update results button | Re-issues the BGQL request with the new filter state. |
| Sort dropdown (above results) | Highest APY (default), Lowest min deposit, Lowest fees, Bankrate score, Featured. |
Each hydrated WRT card carries the same data shape as a static institution-details-{id} article. In the same call, append a { "method": "html", "params": { "selector": "body" } } (or fold the extraction into an evaluate that returns a compact JSON.stringify) and apply the same regex set, OR read per-card slices from the a11y snapshot.
6. Capture the "Open Account" affiliate href — never follow it
Every rate card has a primary CTA — usually <a class="Button Button--primary" href="...">Open account</a> — that points at Bankrate's /hlink_redirects/ or partner-redirect path. Capture the href value as open_account_url and flag it with "is_affiliate": true. Bankrate's robots.txt explicitly Disallows /hlink_redirects/, /affiliates/, /partners/, and /credit-card-offers/transfer-page/ — do not navigate to these URLs, do not follow redirects through them, do not goto them in browserless_agent. The downstream is a partner application funnel and constitutes a mutation surface.
7. Session teardown
No session-release step — there's nothing to release. The WRT flow (goto → hydrate → filter → read) stays inside ONE call's commands array to save round-trips and avoid dropping the session config; the session itself persists across calls (keyed by proxy/profile), so a later call with the same config reconnects to it while dropping or changing it lands you in a different, blank session.
Site-Specific Gotchas
- The
browserless_agentgoto path has NO 1 MB response cap. A real browser renders any page size, so Bankrate's flagship HYS rate-table (/banking/savings/best-high-yield-interests-savings-accounts/≈ 1.05 MB) and online-savings overview (/banking/savings/best-online-savings-accounts/≈ 1.0 MB) — which used to blow the old fetch ceiling — load fine. The CD "best of" pages (1yr ≈ 738 KB, 5yr ≈ 712 KB, no-penalty ≈ 581 KB) and the money-market rates page (≈ 702 KB) are smaller still. The only limit to respect is the ~200k-char text return, so project/summarize inside an in-pageevaluatefor the largest pages instead of returning the whole document. /banking/savings/rates/is a 301, not the rate table itself. It redirects to/banking/savings/best-high-yield-interests-savings-accounts/.gotofollows the redirect natively; no flag needed (or hard-code the canonical target).Vary: X-Geo-PostalCodeon rate-table responses. Bankrate localizes some rate data by source-IP postal code. Two consequences: (a) Fastly's cache key is partitioned by ZIP, so cached responses are ZIP-specific (you may see different rate ordering across sessions originating in different US regions); (b) to force a specific ZIP, you must use the WRT widget — there is no?zip=query param on the editorial article URLs. (Verified: appending?zip=10001to/banking/cds/best-1-year-cd-rates/is silently ignored — same body served.)- Two parallel data surfaces — pick the right one. The editorial "best of" article has 8-13 ranked institutions in static HTML (
<article id="institution-details-*">). The WRT widget on the same page renders a separate, larger, filter-driven inventory client-side via Bankrate's private BGQL endpoint. They overlap heavily but are NOT identical; if the user asks for a specific filter combination (min APY, institution type, compounding), the WRT inventory is the source of truth — not the editorial 8-13. - The WRT widget hydrates via a private GraphQL backend, NOT a public REST API. The widget POSTs to a
wealth-rt.bankrate.com-domain BGQL endpoint with these variables:accountTypeCategory,cdProducts,cdTermRange,checkingProducts,depositAmount,enableWrmSorting,ignoreBudget,includeCd,includeChecking,includeSavingsMma,listingType,pid: "br3",savingsMmaProducts,zipCode,tclass: "BR_TRAFFIC",editorialTag: "RATE_TABLE",allowScrapedRates: true,boost_token. Do NOT try to call BGQL directly — the endpoint URL is constructed dynamically from a minified JS chunk, andboost_tokenis plumbed through anti-bot middleware. Use the interactivebrowserless_agentsession and let WRT issue the request. - "Bankrate Boost" is a lead-gen funnel, not an unlock. A modal asks for ZIP + email + deposit amount + accountsHeld and returns a
boost_tokenJWT (POST https://wealth-rt.bankrate.com/api/boost) that widens the GraphQL inventory. Never submit an email — Boost is a writeable conversion. Skip the modal and read the unauth default-state WRT inventory (call passesboost_token: ""empty string and still returns rates). Closing the modal via the X button in the upper right is safe. zipCode: "90210"is an internal sentinel — passing it triggers Bankrate'sdeclinePagebranch in the Boost flow. Don't use 90210 as a default; pick10001(NYC) or97818(the IP-derived default observed from us-west-2) if no user ZIP is supplied.- WRT widget defaults are us-west-2-flavored. On a fresh session the widget pre-fills
Deposit amount: 50,000andZip code: 97818(rural Oregon — likely the egress IP's postal). To localize, fill the ZIP field and clickUpdate resultsbefore reading cards. /banking/cds/best-no-penalty-cds/is a 301 →/banking/cds/best-no-penalty-cd-rates/. The destination page has rate data in a DIFFERENT HTML structure than the other "best of" pages — it uses<table class="Table table-content">rate-list tables inside the body copy, NOT<article id="institution-details-*">blocks. Parse via the rate-list selector (see step 3, last row) instead of the institution-details regex when this URL is the target.<article id="institution-details-*">ids are stable across pages and time. The numeric id is Bankrate's internaladvertiserId/ institution PK.5390is E*TRADE;5068is Ally Bank;1966is First Internet Bank of Indiana;1774is UFB Direct. Use the id to dedup the same bank's CD vs. savings product across pages. Bank logos athttps://www.brimg.net/system/img/inst/{advertiserId}.pngand partner logos athttps://www.brimg.net/advertiser/logos/{advertiserId}.png?width=240&format=autofollow the same id space.- Star ratings are rendered as SVG fills, not text. The numeric score appears in two places:
<span class="sr-only">Rating: 4.5 stars out of 5</span>(screen-reader text) and<span class="heading-4 text-base">4.5</span><span>Bankrate {CD\|MMA\|savings\|checking} score</span>(visible numeric next to "Bankrate X score"). Prefer the visible numeric — it's emitted as plain text and immune to a11y-string drift. The 5 individual star glyphs use inline<svg>withstyle="width: 100%"for filled andstyle="width: 50%"for half-filled, but parsing those is unnecessary. /hlink_redirects/,/affiliates/,/partners/,/credit-card-offers/transfer-page/are robots.txt-Disallow and they are partner-redirect funnels. Capture the href asopen_account_urlwithis_affiliate: true. Do NOT follow, do NOTgotoinbrowserless_agent, and do NOT click in an interactive session — the next hop is the partner bank's account-opening application, which is a mutation surface.- Bankrate has NO meaningful anti-bot. No CAPTCHA, no Akamai-style 403, no rate-limit observed (10+ sequential
browserless_agentgotos in <5s succeeded). A plainbrowserless_agentcall with no proxy arg works fine. If you ever see a 4xx from Bankrate, it's almost certainly a redirect that wasn't followed or a real 404. Vary: Accept-Encodingis present — Fastly serves gzip. The browser decompresses transparently, but if you ever script a rawcurl, sendAccept-Encoding: gzipto match production behavior (some Fastly nodes serve identity at 5-10x the byte cost).- Money-market and checking accounts include an ATM-access bullet that the editorial articles render as a separate
<li>in the Pros list (search<h4>Pros</h4>→ adjacent<ul>). CDs never have ATM access. Don't try to extractatm_accessfrom a CD card — it's not there. - Promotional bonus copy appears inline in the "Why X?" paragraph, not as a structured field. Heuristic: search the editorial-copy
<p>for\$\d+\s*(?:bonus|cash bonus|new account bonus|welcome offer)and\d+(?:,\d{3})*\s*(?:points|miles)to extract. - "Best for…" tags appear as a small chip above the bank name (e.g.,
BEST FOR NO MINIMUM DEPOSIT). Selector: a<div>or<span>with class containingeyebrowortagimmediately preceding the<h3 class="heading-4">. Not every card has one — make the field optional in your output schema. - Established date / total bank assets / mobile-app rating / customer-support channels are NOT on the rate-card page itself. They live on the institution's
Bankrate reviewsub-page (/banking/reviews/{bank-slug}/). The rate card has aRead reviewanchor that points there. If the user requested these fields, follow the review URL with a secondbrowserless_agentgoto per institution — review pages parse the same way. - The
/landing/savings/ratesURL is a WRT-only shell (no static institution-details). It's smaller (~432 KB) and contains only the WRT widget chrome. Useful as a "where is the WRT widget" probe but useless for static parsing. The/landing/cd-rates-{d,f,g}/variants are the marketing landers — all >1 MB. - No public REST/GraphQL API exists for Bankrate rate data. Probes against
/api/v{1,2}/...,api.bankrate.com/{graphql,deposit-products,savings-accounts},/api/next/savings/...all returned 404. The only programmatic path is the editorial-article HTML (this skill) or the WRT GraphQL middleware (which is auth-gated and not designed for third-party access — don't try to use it directly). - If the interactive WRT-widget session can't run, degrade to the static goto path. The editorial
browserless_agentgoto + in-page parse always works (no auth, no anti-bot); the only thing the interactive path adds is the WRT custom-filter surface. If the interactive session is unavailable for any reason, gracefully degrade and return what the editorial static HTML provides, flagging in the response that custom-filter dimensions are unavailable.
Expected Output
Three distinct outcome shapes.
Outcome A — editorial best-of fetch succeeded (source: "editorial-fetch")
{
"success": true,
"source": "editorial-fetch",
"page_url": "https://www.bankrate.com/banking/cds/best-1-year-cd-rates/",
"page_title": "Best 1-Year CD Rates for May 2026 - Up to 4.10% | Bankrate",
"account_type": "CD",
"term_filter": "1 yr",
"as_of": "2026-05-13T14:54:00.894Z",
"reviewed_by": {
"name": "Greg McBride, CFA",
"jobTitle": "Former Chief Financial Analyst"
},
"filters_applied": {
"account_type": "CD",
"term": "1 yr",
"sort": "editorial-ranking"
},
"filters_unavailable_on_this_surface": [
"custom_min_apy",
"compounding_frequency",
"institution_type",
"atm_access",
"mobile_app_rating",
"zip_localized_credit_unions"
],
"results": [
{
"rank": 1,
"institution_id": "5390",
"bank_name": "E*TRADE",
"account_name": "E*TRADE 1-Year CD",
"account_type": "CD",
"term_months": 12,
"apy": 0.041,
"min_opening_deposit": 0,
"min_balance_for_apy": 0,
"monthly_fee": 0,
"monthly_fee_waiver_conditions": null,
"compounding_frequency": "Daily",
"fdic_insured": true,
"ncua_insured": false,
"insurance_limit_usd": 250000,
"early_withdrawal_penalty": "3 months interest",
"bankrate_score": 4.5,
"bankrate_score_label": "Bankrate CD score",
"stars_out_of_5": 4.5,
"best_for_tag": null,
"editorial_copy": "E*TRADE from Morgan Stanley offers CDs in terms from six months to five years, all of which earn competitive rates. These CDs have no minimum deposit requirement, making them accessible to most savers.",
"promo_bonus": null,
"all_term_rates": [
{ "term": "6 months", "apy": 0.0405, "min_deposit_text": "No minimum" },
{ "term": "1 year", "apy": 0.041, "min_deposit_text": "No minimum" },
{ "term": "2 years", "apy": 0.0375, "min_deposit_text": "No minimum" },
{ "term": "3 years", "apy": 0.0375, "min_deposit_text": "No minimum" },
{ "term": "5 years", "apy": 0.0385, "min_deposit_text": "No minimum" }
],
"bank_logo_url": "https://www.bankrate.com/2022/03/17155858/Morgan-Stanley-logo.jpg?auto=webp&fit=&width=200&format=pjpg",
"bankrate_review_url": "https://www.bankrate.com/banking/reviews/morgan-stanley-private/",
"bankrate_account_url": "https://www.bankrate.com/banking/cds/best-1-year-cd-rates/#institution-details-5390",
"open_account_url": "https://www.bankrate.com/hlink_redirects/?...",
"is_affiliate": true
}
]
}Outcome B — WRT widget (browser-session) read succeeded (source: "wrt-browser")
Same per-card shape as Outcome A; the envelope changes:
{
"success": true,
"source": "wrt-browser",
"page_url": "https://www.bankrate.com/banking/cds/cd-rates/",
"account_type": "CD",
"filters_applied": {
"account_type": "CD",
"cd_term": "1 yr",
"deposit_amount": 10000,
"zip_code": "10001",
"min_apy": 0.04,
"monthly_fee_zero_only": true,
"fdic_or_ncua": true,
"institution_type": ["Online bank", "Credit union"],
"compounding": "Daily",
"sort": "Highest APY"
},
"filters_unavailable_on_this_surface": [],
"result_count": 23,
"results": [/* same per-card shape as Outcome A */]
}Outcome C — both surfaces unavailable (success: false)
{
"success": false,
"reason": "surface_unavailable",
"page_url": "https://www.bankrate.com/banking/savings/best-high-yield-interests-savings-accounts/",
"details": "The editorial goto returned an unexpected non-200 and the interactive WRT-widget session could not run. Suggest a retry, or use the /banking/cds/best-1-year-cd-rates/ surface for CD queries."
}Other reason values: page_not_found (404 on a stale "best of" URL), unknown_account_type_intent (user query didn't map to a known canonical URL), affiliate_url_only_capture_requested_but_no_cards_found (page structure changed and the institution-details regex returned zero matches).