My invoices
Open your billing page signed in with a profile and download the invoices of the last 3 months through the browser; the files API copies them to your computer.
When to use it: You want to download your own documents from a site where you have an account.
| Uses | sessions with profile: {id}, Playwright over CDP, the downloads folder, files.list and files.read |
|---|---|
| Needs | a plan with profiles, and a profile signed in to the site (from "Save a login once"); no model |
| Site | your billing page (BILLING_URL) with dates written YYYY-MM-DD. The runner uses its stand-in billing page |
| Output | output/invoices/*.pdf and output/result.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.
/** * My invoices: open your billing page signed in with a profile and download the invoices of the last 3 months. * * BILLING_URL=https://… PROFILE_ID=… npx tsx node/index.ts (BOXLINE_API_KEY; MONTHS, default 3) * * PROFILE_ID is a profile that is signed in to the site (make one with the "save-a-login" example). The browser * downloads each invoice into the session's downloads/ folder; the files API copies them to output/invoices/. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline, type Session } from "@boxline/sdk";import { chromium } from "playwright-core";const out = process.env.OUTPUT_DIR ?? "output";const billing = process.env.BILLING_URL;const profileId = process.env.PROFILE_ID;const months = Number(process.env.MONTHS ?? 3);if (!billing || !profileId) throw new Error("Set BILLING_URL (your billing page) and PROFILE_ID (a profile signed in to that site)");const bx = new Boxline();// The first day of the month `months - 1` months ago: "the last 3 months" is this month and the two before.const now = new Date();const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (months - 1), 1)).toISOString().slice(0, 10);/** The files in downloads/ (none before the first download: the folder does not exist yet). */const downloads = (session: Session) => session.files.list("downloads").then((l) => l.entries, () => []);/** Waits for a new, finished file in downloads/ (one that was not there before the click). */async function newDownload(session: Session, before: Set<string>, timeoutMs = 30_000) { const deadline = Date.now() + timeoutMs; let last = ""; while (Date.now() < deadline) { const entries = await downloads(session); const fresh = entries.find((e) => e.type === "file" && !before.has(e.name) && !e.name.endsWith(".crdownload")); if (fresh && `${fresh.name}:${fresh.size}` === last) return fresh; // the same size twice: it has finished writing last = fresh ? `${fresh.name}:${fresh.size}` : ""; await new Promise((r) => setTimeout(r, 500)); } throw new Error("the download did not arrive");}const session = await bx.sessions.create({ shell: false, timeout: 600, profile: { id: profileId }, userMetadata: { example: "my-invoices" } });console.log(`Session: ${session.id}`);const browser = await chromium.connectOverCDP(session.connectUrl!);try { const page = browser.contexts()[0]!.pages()[0]!; await page.goto(billing); if (/log.?in|sign.?in/i.test(page.url())) throw new Error(`the profile is not signed in (the site sent the browser to ${page.url()}): run save-a-login again`); // The billing table: each row's cells and its PDF link; the date is the first cell that reads as YYYY-MM-DD. const rows = await page.$$eval("tr", (trs) => trs.map((tr) => ({ cells: [...tr.querySelectorAll("td")].map((td) => td.textContent!.trim()), pdf: (tr.querySelector('a[href$=".pdf"], a[href*=".pdf?"]') as HTMLAnchorElement | null)?.href ?? null })), ); const recent = rows .map((r) => ({ ...r, date: r.cells.find((c) => /^\d{4}-\d{2}-\d{2}$/.test(c)) ?? "" })) .filter((r) => r.pdf && r.date >= since); console.log(`${recent.length} invoices since ${since} on ${page.url()}`); mkdirSync(join(out, "invoices"), { recursive: true }); const invoices: { id: string; date: string; amount: string | null; file: string; bytes: number }[] = []; for (const row of recent) { const before = new Set((await downloads(session)).map((e) => e.name)); await page.click(`a[href="${new URL(row.pdf!).pathname}"]`); const file = await newDownload(session, before); const data = await session.files.read(`downloads/${file.name}`); writeFileSync(join(out, "invoices", file.name), data); const amount = row.cells.find((c) => /[€$£]\s?\d|\d\s?(EUR|USD|GBP)/.test(c)) ?? null; invoices.push({ id: row.cells[0]!, date: row.date, amount, file: `invoices/${file.name}`, bytes: data.length }); console.log(` ${row.date} ${amount ?? ""} → output/invoices/${file.name} (${data.length} bytes)`); } writeFileSync(join(out, "result.json"), JSON.stringify({ billingUrl: billing, months, since, invoices }, null, 2));} finally { await session.stop(); await browser.close().catch(() => undefined);}"""My invoices: open your billing page signed in with a profile and download the invoices of the last 3 months. BILLING_URL=https://… PROFILE_ID=… python python/main.py (BOXLINE_API_KEY; MONTHS, default 3)PROFILE_ID is a profile that is signed in to the site (make one with the "save-a-login" example). The browserdownloads each invoice into the session's downloads/ folder; the files API copies them to output/invoices/."""import jsonimport osimport reimport timefrom datetime import datefrom pathlib import Pathfrom urllib.parse import urlparsefrom playwright.sync_api import sync_playwrightfrom boxline import Boxline, NotFoundErrorout = Path(os.environ.get("OUTPUT_DIR", "output"))billing = os.environ.get("BILLING_URL")profile_id = os.environ.get("PROFILE_ID")months = int(os.environ.get("MONTHS", "3"))if not billing or not profile_id: raise SystemExit("Set BILLING_URL (your billing page) and PROFILE_ID (a profile signed in to that site)")bx = Boxline()# The first day of the month `months - 1` months ago: "the last 3 months" is this month and the two before.today = date.today()y, m = divmod(today.year * 12 + today.month - 1 - (months - 1), 12)since = date(y, m + 1, 1).isoformat()def downloads(session) -> list: """The files in downloads/ (none before the first download: the folder does not exist yet).""" try: return session.files.list("downloads") except NotFoundError: return []def new_download(session, before: set, timeout: float = 30) -> dict: """Waits for a new, finished file in downloads/ (one that was not there before the click).""" deadline, last = time.monotonic() + timeout, "" while time.monotonic() < deadline: fresh = next((e for e in downloads(session) if e["type"] == "file" and e["name"] not in before and not e["name"].endswith(".crdownload")), None) if fresh and f"{fresh['name']}:{fresh['size']}" == last: return fresh # the same size twice: it has finished writing last = f"{fresh['name']}:{fresh['size']}" if fresh else "" time.sleep(0.5) raise SystemExit("the download did not arrive")with bx.sessions.create(timeout=600, profile=profile_id, user_metadata={"example": "my-invoices"}) as session, sync_playwright() as p: print(f"Session: {session.id}", flush=True) page = p.chromium.connect_over_cdp(session.connect_url).contexts[0].pages[0] page.goto(billing) if re.search(r"log.?in|sign.?in", page.url, re.I): raise SystemExit(f"the profile is not signed in (the site sent the browser to {page.url}): run save-a-login again") # The billing table: each row's cells and its PDF link; the date is the first cell that reads as YYYY-MM-DD. rows = page.eval_on_selector_all( "tr", """trs => trs.map((tr) => ({ cells: [...tr.querySelectorAll("td")].map((td) => td.textContent.trim()), pdf: tr.querySelector('a[href$=".pdf"], a[href*=".pdf?"]')?.href ?? null, }))""", ) recent = [] for row in rows: row_date = next((c for c in row["cells"] if re.fullmatch(r"\d{4}-\d{2}-\d{2}", c)), "") if row["pdf"] and row_date >= since: recent.append({**row, "date": row_date}) print(f"{len(recent)} invoices since {since} on {page.url}") (out / "invoices").mkdir(parents=True, exist_ok=True) invoices = [] for row in recent: before = {e["name"] for e in downloads(session)} page.click(f'a[href="{urlparse(row["pdf"]).path}"]') file = new_download(session, before) data = session.files.read(f"downloads/{file['name']}") (out / "invoices" / file["name"]).write_bytes(data) amount = next((c for c in row["cells"] if re.search(r"[€$£]\s?\d|\d\s?(EUR|USD|GBP)", c)), None) invoices.append({"id": row["cells"][0], "date": row["date"], "amount": amount, "file": f"invoices/{file['name']}", "bytes": len(data)}) print(f" {row['date']} {amount or ''} -> output/invoices/{file['name']} ({len(data)} bytes)") (out / "result.json").write_text(json.dumps({"billingUrl": billing, "months": months, "since": since, "invoices": invoices}, indent=2, ensure_ascii=False))Inputs
Environment variables the example reads:
BILLING_URL: your billing page (required).PROFILE_ID: a profile signed in to that site (required).MONTHS: this month and the months before (default3).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/my-invoices. 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 playwright-core tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdk playwrightpython 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, exactly the three invoices of the last 3 months were downloaded as their PDFs, and the older one was left alone.
Source
github.com/Boxline-dev/examples/my-invoices 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.