Financial report to data
Find a company's latest quarterly report on its investor page, download the PDF in the shell, turn it into text with pdftotext, and have a model read the key figures into JSON.
When to use it: You want figures out of PDF reports published on a web page.
| Uses | the actions API (goto, evaluate), exportCookies, exec (curl, pdftotext), files.readText, the extract action with a schema |
|---|---|
| Needs | a plan with shell sessions and a model on the API |
| Site | your company's investor relations page (INVESTOR_URL, required). The runner uses its stand-in: an annual report and two quarterly reports, the latest listed last |
| Output | output/report.txt and output/figures.json |
| Languages | Node and Python |
The code
The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.
/** * Financial report to data: find a company's latest quarterly report on its investor page, download the PDF in the * session's shell, turn it into text with pdftotext, and have a model pull out the key figures as JSON. * * INVESTOR_URL=https://… npx tsx node/index.ts (BOXLINE_API_KEY; a plan with shell sessions) * * Writes output/report.txt and output/figures.json. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const investorUrl = process.env.INVESTOR_URL;if (!investorUrl) throw new Error("Set INVESTOR_URL to the company's investor relations page (the one that lists its reports)");const bx = new Boxline();interface Figures { company: string; period: string; currency: string; revenueMillions: number; netIncomeMillions: number; dilutedEps: number | null; cashMillions: number | null;}const session = await bx.sessions.create({ shell: true, timeout: 600, userMetadata: { example: "financial-report-to-data" } });console.log(`Session: ${session.id}`);try { // 1. The browser: the investor page's PDF links; the latest quarter is the highest "Qn YYYY" (in any order). await session.goto(investorUrl); const links = await session.evaluate<{ text: string; href: string }[]>( `[...document.querySelectorAll("a[href]")].map((a) => ({ text: a.textContent.trim(), href: a.href }))`, ); const quarterOf = (l: { text: string; href: string }) => { const s = `${l.text} ${l.href}`; const m = /\bQ([1-4])\W{0,3}((?:19|20)\d\d)\b/i.exec(s) ?? /\b((?:19|20)\d\d)\W{0,3}Q([1-4])\b/i.exec(s); if (!m) return 0; return /^Q/i.test(m[0]) ? Number(m[2]) * 10 + Number(m[1]) : Number(m[1]) * 10 + Number(m[2]); }; const reports = links.filter((l) => /\.pdf(\?|$)/i.test(l.href) && quarterOf(l) > 0).sort((a, b) => quarterOf(b) - quarterOf(a)); if (!reports.length) throw new Error(`no quarterly report PDF linked from ${investorUrl}`); const latest = reports[0]!; console.log(`Latest quarterly report: ${latest.text} (${latest.href})`); // 2. The shell: download it with the browser's cookies, then pdftotext. await session.exportCookies("cookies.txt"); const r = await session.exec( 'mkdir -p downloads output && curl -sSfL --max-time 120 -b cookies.txt -o downloads/report.pdf "$REPORT_URL" && pdftotext -layout downloads/report.pdf output/report.txt', { env: { REPORT_URL: latest.href }, timeoutMs: 300_000 }, ); if (r.exitCode !== 0) throw new Error(`the download or pdftotext failed: ${r.stderr.trim()}`); const text = await session.files.readText("output/report.txt"); // 3. A model reads the text (shown in the session's browser) into a fixed shape. await session.evaluate(`document.title = "report.txt"; document.body.innerHTML = "<pre></pre>"; document.querySelector("pre").textContent = ${JSON.stringify(text.slice(0, 40_000))}; true`); const { data, model, usage } = await session.extract<Figures>("The key figures of this quarterly report.", { schema: { type: "object", properties: { company: { type: "string" }, period: { type: "string", description: 'the quarter, written like "Q3 2026"' }, currency: { type: "string", description: "ISO 4217 code, e.g. USD" }, revenueMillions: { type: "number", description: "revenue for the quarter, in millions" }, netIncomeMillions: { type: "number", description: "net income (profit) for the quarter, in millions" }, dilutedEps: { type: ["number", "null"], description: "diluted earnings per share" }, cashMillions: { type: ["number", "null"], description: "cash and cash equivalents at the end of the quarter, in millions" }, }, required: ["company", "period", "currency", "revenueMillions", "netIncomeMillions", "dilutedEps", "cashMillions"], }, }); console.log(`${data.company}, ${data.period}: revenue ${data.revenueMillions} m ${data.currency}, net income ${data.netIncomeMillions} m, diluted EPS ${data.dilutedEps}, cash ${data.cashMillions} m`); mkdirSync(out, { recursive: true }); writeFileSync(join(out, "report.txt"), text); writeFileSync(join(out, "figures.json"), JSON.stringify(data, null, 2)); writeFileSync(join(out, "result.json"), JSON.stringify({ investorUrl, report: latest, figures: data, model, usage: { modelUsd: usage.costUsd } }, null, 2));} finally { await session.stop();}"""Financial report to data: find a company's latest quarterly report on its investor page, download the PDF in thesession's shell, turn it into text with pdftotext, and have a model pull out the key figures as JSON. INVESTOR_URL=https://… python python/main.py (BOXLINE_API_KEY; a plan with shell sessions)Writes output/report.txt and output/figures.json."""import jsonimport osimport refrom pathlib import Pathfrom boxline import Boxlineout = Path(os.environ.get("OUTPUT_DIR", "output"))investor_url = os.environ.get("INVESTOR_URL")if not investor_url: raise SystemExit("Set INVESTOR_URL to the company's investor relations page (the one that lists its reports)")bx = Boxline()def quarter_of(link: dict) -> int: """20263 for "Q3 2026" (or "2026 Q3") in the link's text or address, else 0.""" s = f"{link['text']} {link['href']}" m = re.search(r"\bQ([1-4])\W{0,3}((?:19|20)\d\d)\b", s, re.I) if m: return int(m.group(2)) * 10 + int(m.group(1)) m = re.search(r"\b((?:19|20)\d\d)\W{0,3}Q([1-4])\b", s, re.I) return int(m.group(1)) * 10 + int(m.group(2)) if m else 0with bx.sessions.create(shell=True, timeout=600, user_metadata={"example": "financial-report-to-data"}) as session: print(f"Session: {session.id}", flush=True) # 1. The browser: the investor page's PDF links; the latest quarter is the highest "Qn YYYY" (in any order). session.goto(investor_url) links = session.evaluate('[...document.querySelectorAll("a[href]")].map((a) => ({ text: a.textContent.trim(), href: a.href }))') reports = sorted((l for l in links if re.search(r"\.pdf(\?|$)", l["href"], re.I) and quarter_of(l)), key=quarter_of, reverse=True) if not reports: raise SystemExit(f"no quarterly report PDF linked from {investor_url}") latest = reports[0] print(f"Latest quarterly report: {latest['text']} ({latest['href']})") # 2. The shell: download it with the browser's cookies, then pdftotext. session.export_cookies("cookies.txt") r = session.exec( 'mkdir -p downloads output && curl -sSfL --max-time 120 -b cookies.txt -o downloads/report.pdf "$REPORT_URL" ' "&& pdftotext -layout downloads/report.pdf output/report.txt", env={"REPORT_URL": latest["href"]}, timeout_ms=300_000, ) if r["exitCode"] != 0: raise SystemExit(f"the download or pdftotext failed: {r['stderr'].strip()}") text = session.files.read_text("output/report.txt") # 3. A model reads the text (shown in the session's browser) into a fixed shape. session.evaluate( 'document.title = "report.txt"; document.body.innerHTML = "<pre></pre>"; ' f'document.querySelector("pre").textContent = {json.dumps(text[:40_000])}; true' ) r = session.extract( "The key figures of this quarterly report.", schema={ "type": "object", "properties": { "company": {"type": "string"}, "period": {"type": "string", "description": 'the quarter, written like "Q3 2026"'}, "currency": {"type": "string", "description": "ISO 4217 code, e.g. USD"}, "revenueMillions": {"type": "number", "description": "revenue for the quarter, in millions"}, "netIncomeMillions": {"type": "number", "description": "net income (profit) for the quarter, in millions"}, "dilutedEps": {"type": ["number", "null"], "description": "diluted earnings per share"}, "cashMillions": {"type": ["number", "null"], "description": "cash and cash equivalents at the end of the quarter, in millions"}, }, "required": ["company", "period", "currency", "revenueMillions", "netIncomeMillions", "dilutedEps", "cashMillions"], }, ) f = r["data"] print(f"{f['company']}, {f['period']}: revenue {f['revenueMillions']} m {f['currency']}, net income {f['netIncomeMillions']} m, " f"diluted EPS {f['dilutedEps']}, cash {f['cashMillions']} m") out.mkdir(parents=True, exist_ok=True) (out / "report.txt").write_text(text) (out / "figures.json").write_text(json.dumps(f, indent=2)) result = {"investorUrl": investor_url, "report": latest, "figures": f, "model": r["model"], "usage": {"modelUsd": r["usage"]["costUsd"]}} (out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))Inputs
Environment variables the example reads:
INVESTOR_URL: the page that lists the reports (required).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/financial-report-to-data. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.
What its check proves
check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.
On the stand-in, the Q3 2026 report was picked, and revenue, net income, EPS and cash are exactly the printed figures.
Source
github.com/Boxline-dev/examples/financial-report-to-data on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.