scraper
BlogDocs
April 23, 2026ยท12 min read

Reverse-Engineering Google Finance

19 undocumented RPC methods. One HTTP request. No API key. How the Google Finance frontend fetches data, and how to call it directly.

reverse-engineeringfinancescraping

Last week I started working on a Google Finance scraper and learned a few things about how the site loads its data that I thought were worth sharing.

Not a surprise, Google Finance doesn't have a documented API. The usual approach is parsing HTML with Cheerio or spinning up a headless browser, but it's slow and fragile. I started there, but when I opened DevTools I realized the frontend doesn't use REST or GraphQL at all. It makes POST requests to a single endpoint using some internal RPC protocol.

So I started poking at it.

The thing nobody talks about

If you view source on any Google Finance page, there's an object called AF_dataServiceRequests sitting right in the HTML.

AF_dataServiceRequests = {
'ds:0': {id:'YtbmEe', request:[[1],6,0]},
'ds:1': {id:'HqGpWd', request:[[[null,["GOOGL","NASDAQ"]]]]},
'ds:2': {id:'xh8wxf', request:[[[null,["GOOGL","NASDAQ"]]],1]},
'ds:3': {id:'o6pODe', request:[[null,["GOOGL","NASDAQ"]]]},
'ds:4': {id:'nBEQBc', request:[5,6,[[null,["GOOGL","NASDAQ"]]]]},
'ds:5': {id:'nBEQBc', request:[2,6]},
'ds:6': {id:'mKsvE', request:["GOOGL:NASDAQ"]},
'ds:7': {id:'uwlMvd', request:[[[null,["GOOGL","NASDAQ"]]]]},
'ds:8': {id:'SICF5d', request:[[null,["GOOGL","NASDAQ"]],18]},
'ds:9': {id:'lvVhof', request:[18]},
'ds:10': {id:'AiCwsd', request:[[[null,["GOOGL","NASDAQ"]]],1]},
'ds:11': {id:'AiCwsd', request:[[[null,["GOOGL","NASDAQ"]]],3]},
'ds:12': {id:'SICF5d', request:[[null,["GOOGL","NASDAQ"]]]},
'ds:13': {id:'Pr8h2e', request:[[[null,["GOOGL","NASDAQ"]]]]},
'ds:14': {id:'yYvDpf', request:[[null,["GOOGL","NASDAQ"]]]},
'ds:15': {id:'QKZUzd', request:[1]},
'ds:16': {id:'Xhdx2e', request:[null,1]},
'ds:17': {id:'xh8wxf', request:[[[null,["GOOGL","NASDAQ"]]]]}
};
This is a complete list of every RPC call the page makes. Method IDs, payloads, everything. Google basically documents its own internal API in the page source. I spent a day reading minified JS before I found this and felt dumb.

I pulled the same object from the crypto page, home page, and market pages. Different pages use different methods. Between all of them: 19 methods.

What 19 methods get you

RPC IDPurposeRequest
xh8wxfQuote[[tuple], 1] or [[tuple]]
HqGpWdCompany info[[tuple]]
uwlMvdClassification[[tuple]]
Pr8h2eFinancials / Estimates[[tuple]] or [[tuple], 1]
AiCwsdChart[[tuple], mode]
nBEQBcNews[type, limit, [tuple]]
o6pODeAnalyst articles[tuple]
SICF5dRelated stocks[tuple, 18] or [tuple]
mKsvEStock context["GOOGL:NASDAQ"]
Xhdx2eMarket indices[null, 1]
YtbmEeMarket movers[[categories], count, offset]
lvVhofTrending stocks[18]
JFUMjdEarnings calendar[]
XqaYgCategory stocks[category, offset]
QKZUzdTop headline[1]
mysBRbFeatured stocks[]
qt5Q2dTop stocks by metric[6, 1]
sy8gqeCategory news[category, offset]
yYvDpfUnknown (empty)[tuple]
All of these go through one URL. You can batch multiple methods in a single POST. No API key. The only headers that matter are a proper User-Agent and a CONSENT=YES+ cookie (in case the request originates from the EU).

The request/response encoding is a little unusual but not complicated once you see it:

function buildBody(requests) {
const arr = requests.map((r, i) => [
r.id,
JSON.stringify(r.req),
null,
String(i + 1),
]);
return `f.req=${encodeURIComponent(JSON.stringify([arr]))}`;
}
function parseResponse(raw) {
const stripped = raw.replace(/^\)\]\}'\n\n?/, "");
const results = [];
const lines = stripped.split("\n");
let i = 0;
while (i < lines.length) {
if (/^[0-9a-fA-F]+$/.test(lines[i].trim()) && i + 1 < lines.length) {
try {
for (const entry of JSON.parse(lines[i + 1])) {
if (entry[0] === "wrb.fr") {
results.push({
id: entry[1],
data: JSON.parse(entry[2]),
});
}
}
} catch {}
i += 2;
} else {
i++;
}
}
return results;
}

What do you get? Not just price quotes. Full income statements, balance sheets, cash flow. Quarterly and annual. Price charts at every interval from 1-minute to monthly. News with thumbnails and related tickers. Analyst estimates. Earnings calendar with conference call timestamps.

And it works across everything Google Finance supports. US stocks, Samsung on KRX, Toyota on TYO, crypto pairs, EUR-USD, the Dow. I tested 9 tickers across different asset types to make sure.

DataStocksETFsIndicesCryptoFX
QuoteYesYesYesYesYes
CompanyYesPartialDesc onlyNoNo
Financials40-57KBEmptyEmptyEmptyEmpty
ChartsYesYesYesYesYes
NewsYesYesYesYesYes
RelatedYesYesYesYesNo
After-hoursYesYesNoNoNo

Stocks get everything. ETFs and indices get most of it. Crypto and currencies get quotes, charts, and news but no financials (obviously).

The only difference between asset types is how you identify the instrument. Stocks use [ticker, exchange]. Crypto uses [base, quote] at a different position in the array:

function tickerTuple(ticker) {
if (ticker.includes("-") && !ticker.includes(":")) {
const [base, quote] = ticker.split("-");
return [null, null, [base, quote]]; // BTC-USD, EUR-USD
}
const [sym, exchange] = ticker.split(":");
return [null, [sym, exchange]]; // GOOGL:NASDAQ, .DJI:INDEXDJX
}

Positional arrays

This is the annoying part. Google doesn't return normal JSON. No field names. Everything is nested arrays where position determines meaning.

IndexFieldStocksCrypto
[0]KG MID/m/07zln7n/g/11bvvxp7st
[1][ticker, exchange]["GOOGL","NASDAQ"]null
[2]NameAlphabet Inc Class ABitcoin (BTC / USD)
[3]Type enum0 (stock)3 (crypto)
[5]Price array[339.32, 7.03, 2.12][77877, -341, -0.44]
[7]Previous close332.2978218.05
[12]TimezoneAmerica/New_Yorknull
[15]Crypto pairN/A["BTC","USD","Bitcoin",...]
[16]After-hours[336.63, -2.69, ...]null
[21]Ticker stringGOOGL:NASDAQBTC-USD

Price lives at [5][0]. Market cap at [7]. CEO name at index [5] of a completely different array. The quote array is 27 elements long for stocks but 25 for crypto, because crypto doesn't have an exchange field.

You figure out the mapping by opening the Google Finance page next to the raw response and matching numbers by hand. Tedious, but it only needs to be done once. I mapped every field I could find and cross-checked across the 9 tickers.

Probably why they use this format. Saves bandwidth and makes it harder for people like me to scrape.

Company info (HqGpWd)

IndexFieldGOOGLAAPLSPY.DJI
[2]DescriptionAlphabet...Apple Inc...The SPDR..The Dow...
[3]HQ[MtnView,CA,US][Cupertino,..]nullnull
[5]CEOSundar PichaiTim Cooknullnull
[6]Employees1908201660002null
[7]Market cap4.09e124.01e126.42e11null
[12]52-week high349.00288.61712.3950512.79
[16]P/E ratio31.4034.56nullnull
[18]Volume272009974133964384110351null
[71]SectorInteractive..Computers..Capital..N/A

Financials (Pr8h2e)

Only exists for stocks. Deeply nested 39-field arrays. I find them by recursive search: length >= 38, [0] > 1 billion (revenue), [2] < 100 (EPS).

IndexField
[0]Revenue
[1]Net income
[2]EPS
[3]Operating margin %
[4]Operating income
[7]EBITDA
[8]Shares outstanding
[9]EPS diluted
[11]Revenue growth YoY
[16]Currency
[17]Fiscal period end [y, m, d]
[18]P/E ratio
[23]Total assets
[24]Total liabilities
[25]Total equity
[28]Operating cash flow
[33]Profit margin
[36]Free cash flow
[38]Capital expenditure

Month 12 = annual. Anything else = quarterly.

The page source has the data too

The page also embeds all the actual data in AF_initDataCallback blocks. 18 per stock page, each a different data type:

AF_initDataCallback({
key: 'ds:2',
hash: '8291',
data: [[[["/m/07zln7n", ["GOOGL", "NASDAQ"],
"Alphabet Inc Class A", 0, "USD",
[339.32, 7.03, 2.12], null, 332.29, ...]]],
sideChannel: {}
});
// Extract all 18 blocks with one regex:
const re = /AF_initDataCallback\(\{key:\s*'(ds:\d+)',
\s*hash:\s*'\d+',\s*data:(.*?),\s*sideChannel:\s*\{/g;

So if you don't want to call the RPC endpoint, you can just fetch the HTML and parse these blocks. The RPC approach is faster though, and lets you pick exactly which data you want.

The script

I put together a Node.js script that calls the endpoint directly. No dependencies. One command, JSON output:

// google-finance.mjs
const BATCHEXECUTE_URL =
"https://www.google.com/finance/_/GoogleFinanceUi/data/batchexecute";
const HEADERS = {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "identity",
Cookie: "CONSENT=YES+",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
};
function tickerTuple(ticker) {
if (ticker.includes("-") && !ticker.includes(":")) {
const [base, quote] = ticker.split("-");
return [null, null, [base, quote]];
}
const [sym, exchange] = ticker.split(":");
return [null, [sym, exchange]];
}
function buildBody(requests) {
const arr = requests.map((r, i) => [
r.id,
JSON.stringify(r.req),
null,
String(i + 1),
]);
return `f.req=${encodeURIComponent(JSON.stringify([arr]))}`;
}
function parseResponse(raw) {
const stripped = raw.replace(/^\)\]\}'\n\n?/, "");
const results = [];
const lines = stripped.split("\n");
let i = 0;
while (i < lines.length) {
if (/^[0-9a-fA-F]+$/.test(lines[i].trim()) && i + 1 < lines.length) {
try {
for (const entry of JSON.parse(lines[i + 1])) {
if (entry[0] === "wrb.fr") {
results.push({
id: entry[1],
data: JSON.parse(entry[2]),
});
}
}
} catch {}
i += 2;
} else {
i++;
}
}
return results;
}
async function scrape(ticker) {
const t = tickerTuple(ticker);
const isCrypto = ticker.includes("-") && !ticker.includes(":");
const requests = [
{ id: "xh8wxf", req: [[t], 1] },
{ id: "HqGpWd", req: [[t]] },
{ id: "Pr8h2e", req: [[t]] },
{ id: "AiCwsd", req: [[t], 3] },
{ id: "nBEQBc", req: [isCrypto ? 6 : 5, 3, [t]] },
];
const rpcids = [...new Set(requests.map((r) => r.id))].join(",");
const url =
`${BATCHEXECUTE_URL}?rpcids=${rpcids}` +
`&source-path=/finance/quote/${ticker}&hl=en&gl=us&rt=c`;
const res = await fetch(url, {
method: "POST",
headers: HEADERS,
body: buildBody(requests),
});
const results = parseResponse(await res.text());
const get = (id) => results.find((r) => r.id === id)?.data;
const q = get("xh8wxf")?.[0]?.[0]?.[0];
if (!q) {
console.error(`No data for ${ticker}`);
return;
}
const quote = {
ticker: isCrypto ? q[21] : q[1]?.[0],
exchange: isCrypto ? "" : q[1]?.[1],
name: q[2],
type: { 0: "stock", 1: "index", 3: "crypto", 5: "etf" }[q[3]] ?? "other",
price: q[5][0],
change: q[5][1],
changePercent: q[5][2],
previousClose: q[7],
currency: q[4],
timezone: q[12],
};
const info = get("HqGpWd")?.[0]?.[0];
const company = info
? {
description: info[2] || null,
ceo: info[5] || null,
employees: info[6] || null,
marketCap: info[7] || null,
open: info[9] || null,
high: info[10] || null,
low: info[11] || null,
fiftyTwoWeekHigh: info[12] || null,
fiftyTwoWeekLow: info[13] || null,
peRatio: info[16] || null,
volume: info[18] || null,
sector: info[71] || null,
}
: null;
const chartRaw = get("AiCwsd")?.[0]?.[0];
let chart = null;
if (chartRaw) {
const points = [];
for (const period of chartRaw[3] || []) {
for (const pt of period?.[1] || []) {
if (!Array.isArray(pt?.[0]) || !Array.isArray(pt?.[1])) continue;
const [y, m, d] = pt[0];
points.push({
date: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
price: pt[1][0],
volume: pt[2] ?? null,
});
}
}
chart = { previousClose: chartRaw[6], points };
}
const newsRaw = get("nBEQBc");
let news = null;
if (Array.isArray(newsRaw?.[0])) {
news = newsRaw[0]
.filter((a) => Array.isArray(a) && a[1])
.map((a) => ({
title: a[1],
source: a[2],
url: a[0],
timestamp: a[4],
}));
}
console.log(JSON.stringify({ ...quote, company, chart, news }, null, 2));
}
const ticker = process.argv[2];
if (!ticker) {
console.error("Usage: node google-finance.mjs GOOGL:NASDAQ");
process.exit(1);
}
scrape(ticker);

Usage:

node google-finance.mjs GOOGL:NASDAQ
node google-finance.mjs BTC-USD
node google-finance.mjs .DJI:INDEXDJX
node google-finance.mjs 005930:KRX
scraperHome