Jaspers Hub

jaspers / yfinance

pluginOfficialReviewed★ 0↓ 11

yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.

9.4 KB
1// Yahoo Finance over HTTP. Yahoo has no public API: these are the endpoints its own pages call, and
2// what they need was found by asking (2026-09-16, from Electron's Node).
3//
4// - The agent is a plain `Mozilla/5.0 (compatible; …)`. A browser's full agent from Node gets 429 on
5// everything, since Yahoo checks it against the TLS handshake; Node's default agent, curl's, and
6// python-requests' get 429 from getcrumb.
7// - quote, quoteSummary, and options need a session: the cookie fc.yahoo.com sets (with a 404), and
8// the crumb getcrumb answers with it, as text (asking for JSON gets a 406). Everything else answers
9// without one, and a 401 there asks again with a session.
10// - One session per process, shared; a 401 opens a new one and asks once more. A 429, a 5xx, or a
11// network failure is asked once more after a pause. Four requests at a time. Answers are kept for as
12// long as the caller says, without the crumb in the key, and a failure is not kept.
13
14export const USER_AGENT = 'Mozilla/5.0 (compatible; JaspersTerminal-yfinance/1.0)'
15export const API = 'https://query1.finance.yahoo.com'
16const COOKIE_URL = 'https://fc.yahoo.com/'
17const CRUMB_PATH = '/v1/test/getcrumb'
18const TIMEOUT_MS = 20_000
19const RETRY_MS = 800
20const AT_ONCE = 4
21const KEPT = 300
22
23export const RATE_LIMITED = 'The market data service is limiting requests from this network (429). Try again in a minute.'
24
25/** A refusal worded for the caller. */
26export class YahooError extends Error {}
27
28export type Query = Record<string, string | number | boolean | undefined>
29
30export interface YahooRequest {
31 path: string
32 query?: Query
33 /** Whether the endpoint needs the session's crumb. */
34 crumb?: boolean
35 /** How long the answer is kept, in ms. */
36 ttl: number
37 /** What was asked, for the error: "the AAPL quote". */
38 what: string
39}
40
41export interface Yahoo {
42 /** Yahoo's JSON, or a YahooError in words. */
43 get(request: YahooRequest): Promise<unknown>
44 /** Forgets the session; the next request that needs one opens another. */
45 reset(): void
46}
47
48export interface YahooOptions {
49 fetch?: typeof fetch
50 sleep?: (ms: number) => Promise<void>
51 /** Milliseconds, for what is kept. */
52 now?: () => number
53}
54
55interface Session {
56 cookie: string
57 crumb: string
58}
59
60interface Answer {
61 status: number
62 body: string
63}
64
65export function buildUrl(path: string, query: Query = {}, crumb?: string): string {
66 const params = new URLSearchParams()
67 for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value))
68 if (crumb) params.set('crumb', crumb)
69 const search = params.toString()
70 return `${API}${path}${search ? `?${search}` : ''}`
71}
72
73/** The Cookie header for what Set-Cookie said: each cookie's name and value. */
74export function cookieHeader(setCookies: string[]): string {
75 return setCookies
76 .map((line) => line.split(';')[0]!.trim())
77 .filter((pair) => pair.includes('='))
78 .join('; ')
79}
80
81/** A crumb is a short token: no markup, no spaces. */
82export function isCrumb(text: string): boolean {
83 return /^[^\s<>]{1,64}$/.test(text)
84}
85
86/** What a Yahoo error body says: `{ <anything>: { result: null, error: { code, description } } }`. */
87export function yahooError(body: unknown): string | null {
88 if (typeof body !== 'object' || body === null) return null
89 for (const value of Object.values(body)) {
90 const error = typeof value === 'object' && value !== null ? (value as { error?: unknown }).error : null
91 if (typeof error !== 'object' || error === null) continue
92 const { description, code } = error as { description?: unknown; code?: unknown }
93 if (typeof description === 'string' && description.trim()) return description.trim()
94 if (typeof code === 'string' && code.trim()) return code.trim()
95 }
96 return null
97}
98
99function setCookiesOf(headers: Headers): string[] {
100 if (typeof headers.getSetCookie === 'function') return headers.getSetCookie()
101 const joined = headers.get('set-cookie')
102 return joined ? joined.split(/,(?=\s*[^;,=\s]+=)/) : []
103}
104
105function reason(err: unknown): string {
106 if (!(err instanceof Error)) return String(err)
107 if (err.name === 'TimeoutError') return 'no answer in 20 seconds'
108 return err.cause instanceof Error ? err.cause.message : err.message
109}
110
111function parse(body: string): unknown {
112 try {
113 return JSON.parse(body)
114 } catch {
115 return undefined
116 }
117}
118
119/** Runs tasks at most `max` at a time; a finishing task hands its place to the next in line. */
120function limiter(max: number): <T>(task: () => Promise<T>) => Promise<T> {
121 let active = 0
122 const waiting: (() => void)[] = []
123 return async (task) => {
124 if (active >= max) await new Promise<void>((resolve) => waiting.push(resolve))
125 else active++
126 try {
127 return await task()
128 } finally {
129 const next = waiting.shift()
130 if (next) next()
131 else active--
132 }
133 }
134}
135
136export function createYahoo({ fetch: send = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }: YahooOptions = {}): Yahoo {
137 const slot = limiter(AT_ONCE)
138 const kept = new Map<string, { at: number; ttl: number; value: Promise<unknown> }>()
139 let active: { promise: Promise<Session>; value: Session | null } | null = null
140
141 async function openSession(): Promise<Session> {
142 let cookie: string
143 let answer: Response
144 try {
145 const home = await send(COOKIE_URL, {
146 headers: { 'user-agent': USER_AGENT, accept: '*/*' },
147 redirect: 'manual',
148 signal: AbortSignal.timeout(TIMEOUT_MS),
149 })
150 cookie = cookieHeader(setCookiesOf(home.headers))
151 await home.arrayBuffer().catch(() => undefined)
152 if (!cookie) throw new YahooError('The market data service did not open a session (no cookie).')
153 answer = await send(`${API}${CRUMB_PATH}`, {
154 headers: { 'user-agent': USER_AGENT, accept: '*/*', cookie },
155 signal: AbortSignal.timeout(TIMEOUT_MS),
156 })
157 } catch (err) {
158 if (err instanceof YahooError) throw err
159 throw new YahooError(`The market data service could not be reached for a session: ${reason(err)}`)
160 }
161 const crumb = (await answer.text()).trim()
162 if (answer.status === 429) throw new YahooError(RATE_LIMITED)
163 if (!answer.ok || !isCrumb(crumb)) throw new YahooError(`The market data service did not open a session (${answer.status}).`)
164 return { cookie, crumb }
165 }
166
167 function session(): Promise<Session> {
168 if (!active) {
169 const opening: { promise: Promise<Session>; value: Session | null } = { promise: openSession(), value: null }
170 opening.promise.then(
171 (value) => {
172 opening.value = value
173 },
174 () => {
175 if (active === opening) active = null
176 },
177 )
178 active = opening
179 }
180 return active.promise
181 }
182
183 /** A new session in place of the one Yahoo refused, unless another asker has already replaced it. */
184 function renew(stale: Session | null): Promise<Session> {
185 if (stale && active?.value === stale) active = null
186 return session()
187 }
188
189 async function ask(url: string, cookie: string | null): Promise<Answer> {
190 return slot(async () => {
191 const headers: Record<string, string> = { 'user-agent': USER_AGENT, accept: 'application/json' }
192 if (cookie) headers['cookie'] = cookie
193 const response = await send(url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) })
194 return { status: response.status, body: await response.text() }
195 })
196 }
197
198 async function load(request: YahooRequest): Promise<unknown> {
199 let current = request.crumb ? await session() : null
200 let renewed = false
201 let retried = false
202 for (;;) {
203 let answer: Answer
204 try {
205 answer = await ask(buildUrl(request.path, request.query, current?.crumb), current?.cookie ?? null)
206 } catch (err) {
207 if (!retried) {
208 retried = true
209 await sleep(RETRY_MS)
210 continue
211 }
212 throw new YahooError(`The market data service could not be reached for ${request.what}: ${reason(err)}`)
213 }
214 const { status } = answer
215 if (status === 401 && !renewed) {
216 renewed = true
217 current = await renew(current)
218 continue
219 }
220 if ((status === 429 || status >= 500) && !retried) {
221 retried = true
222 await sleep(RETRY_MS)
223 continue
224 }
225 const body = parse(answer.body)
226 const said = yahooError(body)
227 if (status === 429) throw new YahooError(RATE_LIMITED)
228 if (status === 401) throw new YahooError(`The market data service refused ${request.what}: ${said ?? 'not authorized'}.`)
229 if (status >= 500) throw new YahooError(`The market data service answered ${status} for ${request.what}${said ? `: ${said}` : '.'}`)
230 if (status < 200 || status >= 300) throw new YahooError(said ? `Market data: ${said}` : `The market data service answered ${status} for ${request.what}.`)
231 if (body === undefined) throw new YahooError(`The market data service answered ${request.what} with something that is not JSON.`)
232 if (said) throw new YahooError(`Market data: ${said}`)
233 return body
234 }
235 }
236
237 return {
238 get(request) {
239 const key = buildUrl(request.path, request.query)
240 const hit = kept.get(key)
241 if (hit && now() - hit.at < hit.ttl) return hit.value
242 const value = load(request)
243 kept.delete(key)
244 kept.set(key, { at: now(), ttl: request.ttl, value })
245 value.catch(() => {
246 if (kept.get(key)?.value === value) kept.delete(key)
247 })
248 if (kept.size > KEPT) kept.delete(kept.keys().next().value!)
249 return value
250 },
251 reset() {
252 active = null
253 },
254 }
255}