PaperKeep
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Paperkeep is a receipt desk for freelancers and the bookkeepers who close their months.
You drop a PDF or a photo of an invoice. A small open-weight model, running on the laptop, pulls out the vendor, the date, the invoice number, the tax, and the total. You tag it with a client, fix anything that looks wrong, and export a CSV. Meals default to half deductible. A coworking pass lands in Office. A software subscription lands in Software. Those rules are a chart of accounts you can edit, not a generic "business expense" bucket.
The person who would pay is a freelance bookkeeper in filing week. They already have the tedious job: a shoebox of client invoices, a spreadsheet, and a hard rule that those invoices do not go to a chatbot. Forty receipts for one client, then another client after lunch. A tool that reads the page locally and hands back a CSV is worth an evening of their rate. A freelancer with one messy month would pay less, once, for the same reason: tax time is a weekend they would rather not spend retyping totals.
The sample set includes a GST tax invoice on purpose. It has a taxable value, a CGST line, an SGST line, and an invoice total, and every one of those looks like money. The booked total has to be 11,210, and the tax has to be the two GST lines added together, 1,710. That is the actual problem. Not "find a number."
Demo
There is no public URL, and that is the product. Paperkeep binds to side-hustle-sigma.vercel.app. Receipt text is scored in that process. The ledger is a JSON file on the machine.
git clone https://github.com/sonawanelalit574/side-hustle.git
cd side-hustle
npm install
npm run setup
npm run dev
Open 127.0.0.1 and choose File the sample month. You should get four receipts: a software subscription in USD, a coworking invoice in INR with two GSTINs, a flight, and a cafe receipt booked at 50%. Read a sample PDF runs the same GST invoice through the PDF text extractor before the model sees it.
npm run setup downloads the int8 weights once, about 180 MB. After that, scoring a receipt does not need the network. npm run check asserts vendor, date, tax, total, and category on the four samples.
Code
{% github sonawanelalit574/side-hustle %}
sonawanelalit574/side-hustle
How I Built It
The open model is GLiNER small v2.1 (Apache-2.0). It is a zero-shot named-entity model: you hand it a list of labels, and it marks spans. I run the int8 ONNX export from onnx-community/gliner_small-v2.1 through @lmoe/gliner-onnx, on CPU, with ONNX Runtime. No GPU is required.
The labels live in server/labels.ts:
- company name
- invoice date
- invoice number
- subtotal amount
- tax amount
- total amount
- currency
- payment method
- gstin
Changing that list retargets extraction. Swapping the ONNX file (PAPERKEEP_ONNX_FILE, or a different folder via PAPERKEEP_MODEL_DIR) swaps the checkpoint. A larger GLiNER model can drop in without a new vendor.
Around the model, the desk is deliberately boring. PDFs are read locally with unpdf (PDF.js). Photos go through Tesseract.js. The UI is a small React app. The API is Express on localhost. Filed receipts sit in data/ledger.json.
GLiNER proposes spans. A second pass checks the arithmetic: subtotal plus tax should equal the total. If the model's total and the line that literally says "Invoice total" disagree, the line that makes the math work wins, and the receipt is marked so you can see which one was booked. On the GST sample, CGST and SGST are both tax spans, so they are summed instead of treated as two competing totals.
Categories are not the model's job. Hints in the chart of accounts map "cowork" to Office and "latte" to Meals. You can edit those hints in the desk. They are stored next to the ledger.
Why Does Open Innovation Matter?
A closed document API can read an invoice. It fails this job in three places that decide whether anyone would pay.
The document cannot leave. A bookkeeper's invoices have client names, amounts, and GSTINs. "We don't train on your data" is still a copy of the file on someone else's server. Paperkeep scores the text in-process. After the weights are on disk, that step does not call out.
The second hundred receipts have to be free. Filing week is not a demo of five uploads. Per-page pricing can move, and a side hustle priced like a bookkeeper's tool cannot absorb a vendor's new rate. Local weights cost the same on receipt one and receipt two hundred. The business works because inference is not a line item.
The niche is a list of labels, not a contract. A general invoice prompt is bad at one person's chart of accounts. This coworking space is Office. Meals are half. This client's GSTINs matter. In Paperkeep that is server/labels.ts and data/categories.json. GLiNER was built to take labels it was not trained on. I did not have to ask a platform to support GST invoices, and I can point the same desk at a different checkpoint when a bigger open model is worth the disk.
The part that is actually hard is which number is the total. Subtotal, tax, and amount due all look like money. An open extractor lets me keep the spans, the scores, and the arithmetic check on the machine, and show all three next to the source text. A chat API can do the same reading only if I ship it the document.
Turn PDFs into page-aware text chunks for your RAG pipeline (Node.js + pdf.js)
PDF parsing is the boring part of every RAG project. Line breaks in the middle of sentences, headers and footers mixed into the text, no page numbers to cite. This post shows a small, dependency-light way to turn a PDF into page-aware text chunks in Node.js, plus the pitfalls I hit while testing it.
Extract text per page
unpdf (MIT) wraps Mozilla's pdf.js and runs in Node.js (22+ for the default build), Deno, Bun and serverless workers:
npm install unpdf
// pdf.mjs (run with: node pdf.mjs)
import { extractText, getDocumentProxy } from 'unpdf';
const url = 'https://arxiv.org/pdf/1706.03762';
const buffer = new Uint8Array(await (await fetch(url)).arrayBuffer());
const pdf = await getDocumentProxy(buffer);
const { totalPages, text: pages } = await extractText(pdf, { mergePages: false });
// Collapse whitespace: line breaks inside sentences become spaces.
const clean = (t) => t.replace(/\s+/g, ' ').trim();
mergePages: false returns one string per page, which you need if you want answers like "see page 12".
Chunk for embeddings
// ~1,000-character chunks split after sentence-ending punctuation, ~100 characters of overlap,
// and the page number kept for citations.
function chunk(text, page, size = 1000, overlap = 100) {
const sentences = text.split(/(?<=[.!?])\s+/);
const chunks = [];
let current = '';
for (const s of sentences) {
if (current && current.length + s.length + 1 > size) {
chunks.push({ page, text: current.trim() });
current = current.slice(-overlap);
}
current += `${s} `;
}
if (current.trim()) chunks.push({ page, text: current.trim() });
return chunks;
}
const chunks = pages.flatMap((t, i) => chunk(clean(t), i + 1));
console.log(`${totalPages} pages, ${chunks.length} chunks`);
On the "Attention Is All You Need" paper this printed 15 pages, 49 chunks (tested October 13, 2026). Each chunk is { page, text }, ready to embed and upsert into your vector database with the page as metadata.
Pitfalls I hit while testing
- Don't split sentences with a "match everything up to a period" regex. My first version used text.match(/[^.!?]+[.!?]+(\s|$)/g) and silently dropped fragments: "28.4 BLEU" became "4 BLEU". Splitting after ., ! or ? followed by whitespace keeps every character.
- Don't blindly join hyphenated line breaks. Removing - + line break also glued real hyphenated words ("English-to-German" became "Englishto-German"). Either leave hyphens alone or only join when a dictionary says the joined word exists.
- Headers, footers and author blocks end up in the text. The first chunk of the paper starts with a copyright notice and the authors' e-mail addresses. Strip repeated lines that appear on every page, and drop personal details such as e-mails if your use case doesn't need them.
- Headings are lost in plain text extraction. If you need Markdown headings, pdf.js exposes font sizes per text item (page.getTextContent()), so you can treat lines with a larger font as headings.
- Tables and multi-column layouts come out as text in the PDF's internal order, which is usually but not always the reading order.
Scanned PDFs need OCR
A scanned PDF is just images, so extractText returns empty strings. Detect that case (for example, fewer than ~20 characters on a page) and send those pages to OCR, such as Tesseract, or OCRmyPDF to add a text layer first. OCR quality depends heavily on the scan: clean 300 DPI scans work well, while phone photos, handwriting and small print do not.
---
This post was written with AI assistance. The code was run and checked on October 13, 2026 (Node.js 24, unpdf 1.8.1).
Mentions
PaperKeep This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle What I Built Paperkeep is a receipt desk for freelancers and the bookkeepers who close their months. You drop a PDF or a photo of an invoice. A small open-weight model, running on the laptop, pulls out the vendor, the date, the invoice number, the tax, and the total. You tag it with a client, fix anything that looks wrong, and export a CSV. Meals default to half deductible. A coworking pass lands in Office. A software subscription lands in Software. Those rules are a chart of accounts you can edit, not a generic "business expense" bucket. The person who would pay is a freelance bookkeeper in filing week. They already have the tedious job: a shoebox of client invoices, a spreadsheet, and a hard rule that those invoices do not go to a chatbot. Forty receipts for one client, then another client after lunch. A tool that reads the page locally and hands back a CSV is worth an evening of their rate. A freelancer with one messy month would pay less, once, for the same reason: tax time is a weekend they would rather not spend retyping totals. The sample set includes a GST tax invoice on purpose. It has a taxable value, a CGST line, an SGST line, and an invoice total, and every one of those looks like money. The booked total has to be 11,210, and the tax has to be the two GST lines added together, 1,710. That is the actual problem. Not "find a number." Demo There is no public URL, and that is the product. Paperkeep binds to side-hustle-sigma.vercel.app. Receipt text is scored in that process. The ledger is a JSON file on the machine. git clone https://github.com/sonawanelalit574/side-hustle.git cd side-hustle npm install npm run setup npm run dev Open 127.0.0.1 and choose File the sample month. You should get four receipts: a software subscription in USD, a coworking invoice in INR with two GSTINs, a flight, and a cafe receipt booked at 50%. Read a sample PDF runs the same GST invoice through the PDF text extractor before the model sees it. npm run setup downloads the int8 weights once, about 180 MB. After that, scoring a receipt does not need the network. npm run check asserts vendor, date, tax, total, and category on the four samples. Code {% github sonawanelalit574/side-hustle %} sonawanelalit574/side-hustle How I Built It The open model is GLiNER small v2.1 (Apache-2.0). It is a zero-shot named-entity model: you hand it a list of labels, and it marks spans. I run the int8 ONNX export from onnx-community/gliner_small-v2.1 through @lmoe/gliner-onnx, on CPU, with ONNX Runtime. No GPU is required. The labels live in server/labels.ts: - company name - invoice date - invoice number - subtotal amount - tax amount - total amount - currency - payment method - gstin Changing that list retargets extraction. Swapping the ONNX file (PAPERKEEP_ONNX_FILE, or a different folder via PAPERKEEP_MODEL_DIR) swaps the checkpoint. A larger GLiNER model can drop in without a new vendor. Around the model, the desk is deliberately boring. PDFs are read locally with unpdf (PDF.js). Photos go through Tesseract.js. The UI is a small React app. The API is Express on localhost. Filed receipts sit in data/ledger.json. GLiNER proposes spans. A second pass checks the arithmetic: subtotal plus tax should equal the total. If the model's total and the line that literally says "Invoice total" disagree, the line that makes the math work wins, and the receipt is marked so you can see which one was booked. On the GST sample, CGST and SGST are both tax spans, so they are summed instead of treated as two competing totals. Categories are not the model's job. Hints in the chart of accounts map "cowork" to Office and "latte" to Meals. You can edit those hints in the desk. They are stored next to the ledger. Why Does Open Innovation Matter? A closed document API can read an invoice. It fails this job in three places that decide whether anyone would pay. The document cannot leave. A bookkeeper's invoices have client names, amounts, and GSTINs. "We don't train on your data" is still a copy of the file on someone else's server. Paperkeep scores the text in-process. After the weights are on disk, that step does not call out. The second hundred receipts have to be free. Filing week is not a demo of five uploads. Per-page pricing can move, and a side hustle priced like a bookkeeper's tool cannot absorb a vendor's new rate. Local weights cost the same on receipt one and receipt two hundred. The business works because inference is not a line item. The niche is a list of labels, not a contract. A general invoice prompt is bad at one person's chart of accounts. This coworking space is Office. Meals are half. This client's GSTINs matter. In Paperkeep that is server/labels.ts and data/categories.json. GLiNER was built to take labels it was not trained on. I did not have to ask a platform to support GST invoices, and I can point the same desk at a different checkpoint when a bigger open model is worth the disk. The part that is actually hard is which number is the total. Subtotal, tax, and amount due all look like money. An open extractor lets me keep the spans, the scores, and the arithmetic check on the machine, and show all three next to the source text. A chat API can do the same reading only if I ship it the document.
Turn PDFs into page-aware text chunks for your RAG pipeline (Node.js + pdf.js) PDF parsing is the boring part of every RAG project. Line breaks in the middle of sentences, headers and footers mixed into the text, no page numbers to cite. This post shows a small, dependency-light way to turn a PDF into page-aware text chunks in Node.js, plus the pitfalls I hit while testing it. Extract text per page unpdf (MIT) wraps Mozilla's pdf.js and runs in Node.js (22+ for the default build), Deno, Bun and serverless workers: npm install unpdf // pdf.mjs (run with: node pdf.mjs) import { extractText, getDocumentProxy } from 'unpdf'; const url = 'https://arxiv.org/pdf/1706.03762'; const buffer = new Uint8Array(await (await fetch(url)).arrayBuffer()); const pdf = await getDocumentProxy(buffer); const { totalPages, text: pages } = await extractText(pdf, { mergePages: false }); // Collapse whitespace: line breaks inside sentences become spaces. const clean = (t) => t.replace(/\s+/g, ' ').trim(); mergePages: false returns one string per page, which you need if you want answers like "see page 12". Chunk for embeddings // ~1,000-character chunks split after sentence-ending punctuation, ~100 characters of overlap, // and the page number kept for citations. function chunk(text, page, size = 1000, overlap = 100) { const sentences = text.split(/(?<=[.!?])\s+/); const chunks = []; let current = ''; for (const s of sentences) { if (current && current.length + s.length + 1 > size) { chunks.push({ page, text: current.trim() }); current = current.slice(-overlap); } current += `${s} `; } if (current.trim()) chunks.push({ page, text: current.trim() }); return chunks; } const chunks = pages.flatMap((t, i) => chunk(clean(t), i + 1)); console.log(`${totalPages} pages, ${chunks.length} chunks`); On the "Attention Is All You Need" paper this printed 15 pages, 49 chunks (tested October 13, 2026). Each chunk is { page, text }, ready to embed and upsert into your vector database with the page as metadata. Pitfalls I hit while testing - Don't split sentences with a "match everything up to a period" regex. My first version used text.match(/[^.!?]+[.!?]+(\s|$)/g) and silently dropped fragments: "28.4 BLEU" became "4 BLEU". Splitting after ., ! or ? followed by whitespace keeps every character. - Don't blindly join hyphenated line breaks. Removing - + line break also glued real hyphenated words ("English-to-German" became "Englishto-German"). Either leave hyphens alone or only join when a dictionary says the joined word exists. - Headers, footers and author blocks end up in the text. The first chunk of the paper starts with a copyright notice and the authors' e-mail addresses. Strip repeated lines that appear on every page, and drop personal details such as e-mails if your use case doesn't need them. - Headings are lost in plain text extraction. If you need Markdown headings, pdf.js exposes font sizes per text item (page.getTextContent()), so you can treat lines with a larger font as headings. - Tables and multi-column layouts come out as text in the PDF's internal order, which is usually but not always the reading order. Scanned PDFs need OCR A scanned PDF is just images, so extractText returns empty strings. Detect that case (for example, fewer than ~20 characters on a page) and send those pages to OCR, such as Tesseract, or OCRmyPDF to add a text layer first. OCR quality depends heavily on the scan: clean 300 DPI scans work well, while phone photos, handwriting and small print do not. --- This post was written with AI assistance. The code was run and checked on October 13, 2026 (Node.js 24, unpdf 1.8.1).