This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
LedgerLens turns a folder of receipt photos into an accounting CSV, then finds the bills you already paid.
Point it at a folder of .jpg/.png receipts. It runs OCR on your own machine, extracts the vendor, date, invoice number, line items and total from each one, writes one clean row per receipt to data/ledger.csv, and flags the things a human skimming a spreadsheet misses: a bill entered twice, a price that jumped, a total far outside what that shop normally charges, or items that do not add up.
It is for the freelancer or small shop owner who keeps a shoebox of receipt photos and no useful record of them. The information is all there, nobody has time to type it in, and the cloud services that would do it charge per page and upload every receipt to a server you do not control.
That last part is the whole point. A shop's receipts carry client names, GST numbers, addresses and staff details. LedgerLens never uploads an image anywhere. Tesseract runs as WebAssembly inside Node, so there is no Python, no native toolchain, no API key, and no per-image bill. After a first run that downloads about 11 MB of English language data, it works with the network switched off.
Demo
$ npm run pipeline
78.0 conf 0.5s receipt-01.png
82.0 conf 0.2s receipt-02.png
78.0 conf 0.4s receipt-03-duplicate.png
76.0 conf 0.3s receipt-04-stationery.png
4 image(s) -> data/raw
ANNAMAIYA PROVISION STORES 2026-10-12 1451.34 ok via total
ANNAMAIYA PROVISION STORES 2026-10-14 575.52 ok via total
ANNAMAIYA PROVISION STORES 2026-10-12 1451.34 ok via total
SARASWATHI STATIONERY 2026-10-15 649.64 ok via total
4 receipt(s), 4 with a resolved total -> data/ledger.csv
receipts : 4
findings : 1
[HIGH] duplicate_invoice
Invoice 2291 from ANNAMAIYA PROVISION STORES appears 2 times at an
identical 1451.34. Likely a double scan or a double payment.
Those are the synthetic samples the repo ships. On real photographs it reads the paper and hands back one row per bill.
Code
Emanuelmsjv
/
ledgerlens
Offline receipt OCR to accounting CSV with anomaly detection. Tesseract via WebAssembly in pure Node - no native toolchain, no cloud, no API keys.
ledgerlens
Photograph a receipt. Get an accounting CSV. Find the bill you already paid.
Runs entirely on your own machine. Tesseract OCR executes as WebAssembly inside Node, so there is no Python, no native toolchain, no API key, and no image is ever uploaded anywhere. After the first run — which downloads about 11 MB of English language data — it works with the network off.
Built for the Hacktoberfest Open-Source AI Challenge, Week 2: Side Hustle.
Why it exists
A small business or freelancer keeps a folder of receipt photos and no useful record of them. The information is all there; nobody has time to type it in Cloud OCR services do the reading and bill per page, which for a shop doing hundreds of small purchases is a real recurring cost — and they upload every receipt to a server the owner does not control.
Financial…
How I Built It
Plain Node.js, one dependency (tesseract.js), no build step. The pipeline is four small scripts:
| script | what it does |
|---|---|
ocr.mjs |
images to text, with rotation recovery |
parse.mjs |
text to one CSV row per receipt, plus a self-check |
analyze.mjs |
flags duplicates, price jumps, outliers, bad arithmetic |
make-fixtures.mjs |
redacts real OCR output into safe test fixtures |
The design rule everything else follows from: if a field cannot be read confidently, it returns null, never a guess. In a ledger, a blank cell is a nuisance; a confidently wrong figure is a false expense that gets filed and never questioned again.
The bug that rule comes from
My first parser took the last line containing "Total". On one real bill, three lines contained the word. It returned 459.00 where the bill said 4600.00 — with no warning, no low confidence, nothing.
I did not trust my memory of how bad that was, so I rebuilt the original logic and ran it against every real bill I had ground truth for. It was worse than I expected:
| bill | true total | original logic | shipped |
|---|---|---|---|
| railway ticket | 180 | (nothing) | 180.00 |
| bakery | 593 | 102.00 (wrong) | 593.00 |
| bakery (other photo) | 593 | 102.00 (wrong) | 593.00 |
| grocery | 88 | 88.00 | 88.00 |
| credit sale | 4600 | 459.00 (wrong) | 4600.00 |
Original: 1 right, 3 wrong, 1 abstained. Shipped: 5 right, 0 wrong.
The fix was not a better regex. It was label priority: prefer a NetAmount or balance line, never take a tender or "savings" figure, and when two candidates disagree, abstain. Measured, not claimed.
What the benchmark says
Two suites, reported separately and never averaged together:
| synthetic | real | |
|---|---|---|
| cases | 7 | 8 photos, 4 distinct bills |
| ground truth | fixed when written | read off the paper by eye |
| totals correct | 7 / 7 | 7 / 8 |
| wrong | 0 | 0 |
| wrong-rate of produced totals | 0% | 0% |
14 of 15 documents, and no wrong number anywhere. The single miss is the system working: on one photograph it could not read the total cleanly, so it reported nothing. Coverage is the secondary metric; a wrong number is the only real failure.
The same rule caught a GSTIN that OCR had turned from 33ZZQPX1234F1Z2 into 332ZQPX1234F122 — a shape that is no longer a valid GSTIN. The parser returned null and left it for a human, instead of filing a mangled tax number on a real invoice.
The suite is covered by 68 checks across three files (30/30, 12/12, 26/26), plus a 15-check privacy lint wired into CI.
And the part I got wrong about privacy
Halfway through, I checked whether the real receipts had leaked into the public repo. The photos had not — samples/real/ was gitignored from day one. But the redacted fixtures derived from them were incomplete: real shop names, a street address, a pincode and bill numbers were sitting in fixtures, a benchmark file, and even the CI workflow. Worse, the test suite depended on a gitignored file, so the privacy guard in CI had never actually run — it crashed before reaching the check.
I fixed it properly: rewrote the redaction rules, added a fixed-point lint that fails the build if a fixture is not idempotently redacted, made the tests hermetic so the guard runs on a clean clone, and purged the leaked content from git history. The public repo now contains only fictional vendors. The gate reports 15/15.
I am including this because a tool whose whole pitch is "your data stays yours" has to hold itself to that, and mine did not at first.
Why Does Open Innovation Matter?
For this project, open isn't a preference. It is the reason the thing works at all.
A closed receipt-OCR API bills per image. For a shop doing hundreds of small purchases a month, that is a recurring cost that eats the margin it is supposed to protect — and every receipt, each one a page of someone's financial life, gets uploaded to a server they do not own. Running the model locally removes both the metered cost and the data exposure in one move. That is the entire product.
It also means I can trust it. The extraction rules and the five detectors are plain code I can read, test and fix. When the "Total" bug turned up, I could rebuild the old logic and measure it against real bills the same afternoon — no black-box API to wait on, no behaviour changing under me. Everything runs offline, on CPU, with no key and no account. That combination — no metered cost, no third-party exposure, and rules you can audit — is what open innovation made possible here. I do not think the closed version of LedgerLens is worth buying, and I do not think that is a coincidence.
My Agent Session
I built this end to end with an AI coding agent, including the measurement above and the privacy remediation. The session below is the slice where I stopped trusting a claim, rebuilt the original parser, and found the bug was worse than I had said.
Benchmark done and committed. Stopping here as you asked.
Benchmark results
| synthetic | real | |
|---|---|---|
| cases | 7 | 8 photos / 4 distinct bills |
| ground truth | fixed when written | read off paper by eye |
| correct | 7 / 7 | 7 / 8 |
| abstained | 0 | 1 |
| wrong | 0 | 0 |
| coverage | 100% | 88% |
14 of 15 documents, zero wrong numbers.
The headline isn't accuracy - it's wrong-rate of produced totals. A confidently wrong figure gets filed and never questioned; a blank one is just annoying. The one abstention is a photo OCR couldn't read, where the tool said nothing rather than guessing.
The most interesting result
The one synthetic "failure" is the system working:
GSTIN 33ZZQPX1234F1Z2 (rendered)
GSTIN 332ZQPX1234F122 (what OCR read)
OCR turned Z into 2 twice, breaking the shape - so the parser rejected it and returned null. A sloppier parser files 332ZQPX1234F122 as a tax registration number. So: GSTIN coverage 3/4, and every GSTIN this tool emits is well-formed.
Two judgement calls
I implemented GSTIN check-digit validation, then deleted it. It would recover that case - but the only real GSTIN I had was itself OCR'd from an image, so validating against it is circular. Four algorithm variants disagreed with it, which pointed at the bad input, not bad code. An unvalidated validator silently nulls good GSTINs - the exact failure this project fights. Not shipped.
I caught myself leaking. The redaction script named the towns, depots and branches it was stripping - publishing exactly what it was there to hide. And the synthetic cases were seeded with your real GSTIN, depot name and receipt number. Both fixed; a leak check that only inspects fixtures isn't a leak check.
State
Emanuelmsjv/ledgerlens - 4 commits, pushed, 69 tests green, ~48 h left.
Next when you're back: the DEV post. Everything it needs is measured and honest.
lets plan first whats the status , what are the next steps what should be done today to push it to complete
Let me gather the actual state before planning.
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\nWrite-Output \"=== repo state ===\"\ngit log --oneline -4\n\"ahead/behind: {0}/{1}\" -f (git rev-list --count origin/main..HEAD),(git rev-list --count HEAD..origin/main)\n\"tracked files: {0}\" -f (git ls-files | Measure-Object).Count\nWrite-Output \"\"\nWrite-Output \"=== does README reference the benchmark? ===\"\nif (Select-String -Path README.md -Pattern \"benchmark\" -Quiet) { \" yes\" } else { \" NO - README does not link docs/benchmark.md\" }\nWrite-Output \"\"\nWrite-Output \"=== tests green right now? ===\"\nnpm test 2>&1 | Select-String \"checks passed\" | ForEach-Object { \" $($_.ToString().Trim())\" }","timeout":900000}
=== repo state ===
17437c9 Add a benchmark, and generic redaction that does not leak
ebac7be Add real-receipt fixtures, privacy gates, README and CI
1316069 Handle real phone photos: rotation, wrapped items, competing totals
0832f23 Offline receipt OCR to accounting CSV with anomaly detection
ahead/behind: 0/0
tracked files: 33
=== does README reference the benchmark? ===
yes
=== tests green right now? ===
30/30 checks passed
12/12 checks passed
27/27 checks passed
$ {"command":"$ErrorActionPreference='Continue'\nWrite-Output \"=== is the session curator still in temp (from Week 1)? ===\"\nGet-ChildItem \"[redacted:home-path]\\AppData\\Local\\Temp\\opencode\" -Filter \"*.mjs\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"curate|template|finalfix|mkpost\" } | ForEach-Object { \" {0}\" -f $_.Name }\nWrite-Output \"\"\nWrite-Output \"=== do we have a raw export of THIS session for the embed? ===\"\n$raw = \"[redacted:home-path]\\AppData\\Local\\Temp\\opencode\\session-raw.json\"\nif (Test-Path $raw) { \" session-raw.json present ({0:N1} MB)\" -f ((Get-Item $raw).Length/1MB) } else { \" missing\" }\nWrite-Output \"\"\nWrite-Output \"=== current opencode session ===\"\n$env:NO_COLOR=\"1\"\n& opencode session list 2>&1 | Select-Object -First 3","timeout":300000}
=== is the session curator still in temp (from Week 1)? ===
curate.mjs
finalfix.mjs
mkpost.mjs
template.mjs
=== do we have a raw export of THIS session for the embed? ===
session-raw.json present (5.1 MB)
=== current opencode session ===
Session ID Title Updated
-----------------------------------------------------------------------------------------
ses_ee8b69fa9ffe75OFq0YEUWLzhP Hacktoberfest 2026 Touch Grass Ideas 6:34 PM
Everything's in place, including the session curator from Week 1. Let me check the deadline math precisely - this determines the whole plan:
$ {"command":"$ErrorActionPreference='Continue'\n$now = Get-Date\n$due = [datetime]::new(2026,10,18,23,59,0)\n$d = $due - $now\n\" now : $($now.ToString('ddd dd MMM, HH:mm'))\"\n\" due : $($due.ToString('ddd dd MMM, HH:mm')) (Oct 18, 23:59 PDT)\"\n\" remaining : {0} days {1:N1} hours\" -f [Math]::Floor($d.TotalDays), ($d.TotalDays-[Math]::Floor($d.TotalDays))*24\nWrite-Output \"\"\nWrite-Output \"=== Week 1 vs Week 2: what carried over ===\"\nWrite-Output \" reusable: node tooling, test suite, CI, benchmark harness, session curator + scrubber\"\nWrite-Output \" NOT reusable: the Week 1 post body (different project), session 751 (that transcript)\"\nWrite-Output \"\"\nWrite-Output \"=== what's missing for a Week 2 entry ===\"\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\nforeach ($f in @(\"docs/dev-post-week2.md\",\"docs/submission-body.md\",\"scripts/submit-template.mjs\")) {\n \" {0,-34} {1}\" -f $f, $(if(Test-Path $f){\"exists\"}else{\"MISSING\"})\n}","timeout":300000}
now : Fri 16 Oct, 18:35
due : Sun 18 Oct, 23:59 (Oct 18, 23:59 PDT)
remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours remaining : 2 days 0.2 hours
=== Week 1 vs Week 2: what carried over ===
reusable: node tooling, test suite, CI, benchmark harness, session curator + scrubber
NOT reusable: the Week 1 post body (different project), session 751 (that transcript)
=== what's missing for a Week 2 entry ===
docs/dev-post-week2.md MISSING
docs/submission-body.md MISSING
scripts/submit-template.mjs MISSING
Status
The build is done. Only the submission is left.
Emanuelmsjv/ledgerlens |
4 commits, pushed, 33 files |
| Tests | 69/69 green |
| Benchmark | synthetic 7/7, real 7/8, 0 wrong |
| Repo privacy | clean - leak-scanned every staged file |
| Time left | 2 days, 0.2 hours (Sun 18 Oct, 23:59 PDT) |
What's already built and needs nothing
The pipeline (ocr -> parse -> analyze), rotation recovery, the five anomaly detectors, 69 tests, CI, the benchmark, and the redaction pipeline. All measured, all honest, all pushed.
What's missing - everything submission-shaped
docs/dev-post-week2.md MISSING the write-up
session for the embed MISSING a new DEV agent session
PUBLISHED-BODY.md MISSING the paste-ready body
The constraint that shapes the plan
A real bug cost us Week 1. I ran --body "probe" against your published post and overwrote it. You were right to stop me.
So the rule for this week: I never write to your DEV account again. I produce files; you paste. That costs10 minutes and removes the only way this fails badly.
What I'd do today, in order
1. Commit the post draft (~90 min)
Write it against the Week 2 template - all 7 headings. The material is unusually strong because it's mostly measurements:
| claim | source |
|---|---|
| 14/15 documents, 0 wrong | docs/benchmark.json |
| the 102.00 savings-as-total bug | real incident, real receipt |
| GSTIN rejected rather than mangled |
GSTIN 332ZQPX1234F122 -> null
|
| 69 tests, negative ones included | npm test |
| OCR boundary is privacy, not cost | "financial receipts never leave the machine" |
2. Agent session embed (~40 min, optional)
The Week 1 curator still exists in temp. It scrubs, ASCII-folds (both bugs we hit), and has a security gate I proved fires. Needs: export this session -> curate -> submit -> get ID.
3. You paste and publish (~15 min, you)
4. Optional TabPFN ($200 category)
I'd skip this. It needs a cloud API key and license acceptance, and it would consume the margin for a "nice to have" on a 2-day clock. Your real differentiator is offline - a cloud API undercuts it. Claim GitHub Copilot ($100, you already use it) and Overall instead.
One thing I want your call on
The benchmark says 3/4 GSTINs and I decided not to ship check-digit validation because I couldn't validate it. That's an honest gap - but it's also the most obvious thing a judge will probe.
Do you want me to spend ~1 hour trying to source a trustworthy real GSTIN to validate the check digit against, or leave the gap documented and move to the post?
{
"questions": [
{
"header": "GSTIN check digit",
"options": [
{
"description": "Leave GSTIN at 3/4 and document it. The parser already rejects malformed codes, which is the behaviour that matters. Gets you to a finished submission sooner.",
"label": "Skip it, document the gap (Recommended)"
},
{
"description": "I search for published, verifiable GSTIN examples and validate the mod-36 check digit against several before shipping it. Roughly an hour, and it either works or I delete it again.",
"label": "Spend an hour sourcing ground truth"
}
],
"item": {
"header": "Prize categories",
"options": [
{
"description": "Overall, plus Best Use of GitHub Copilot ($100) - you already use it and CI is committed. Skips cloud-API categories that undercut the offline premise.",
"label": "Overall + Copilot (Recommended)"
},
{
"description": "Add TabPFN ($200) for anomaly detection over the ledger CSV, which is exactly their stated use. Needs a cloud API key and license acceptance.",
"label": "Add TabPFN ($200)"
},
{
"description": "Also claim Best Use of Entire with a fresh scrubbed session embed from this week's work.",
"label": "Also claim Entire"
}
],
"question": "Which prize categories should the post claim?"
}
}
]
}
{
"questions": [
{
"header": "GSTIN check digit",
"question": "The benchmark shows 3/4 GSTINs because I couldn't validate a check digit. Should I spend time closing that gap, or document it and move on?",
"options": [
{
"description": "Leave GSTIN at 3/4 and document it in the post. The parser already rejects malformed codes rather than filing them, which is the behaviour that matters. Reaches a finished submission sooner.",
"label": "Skip it, document the gap (Recommended)"
},
{
"description": "I look for published, verifiable GSTIN examples and validate the mod-36 check digit against several before shipping it. Roughly an hour. If it doesn't agree with ground truth, I delete it again.",
"label": "Spend an hour sourcing ground truth"
}
]
},
{
"header": "Prize categories",
"question": "Which prize categories should the post claim?",
"options": [
{
"description": "Overall, plus Best Use of GitHub Copilot ($100) - you already use it and the CI workflow is committed. Skips cloud-API categories that undercut the offline premise.",
"label": "Overall + Copilot (Recommended)"
},
{
"description": "Also claim TabPFN ($200) for anomaly detection over the ledger CSV, which is exactly their stated use. Needs a cloud API key and license acceptance before Friday.",
"label": "Add TabPFN ($200)"
},
{
"description": "Also claim Best Use of Entire, backed by a fresh scrubbed session embed from this week's work.",
"label": "Also claim Entire"
}
]
}
]
}
User has answered your questions: "The benchmark shows 3/4 GSTINs because I couldn't validate a check digit. Should I spend time closing that gap, or document it and move on?"="Spend an hour sourcing ground truth", "Which prize categories should the post claim?"="claim the overall plus entire". You can now continue with the user's answers in mind.
{
"topic": "dev-challenges"
}
{"Ok":"# DEV Challenges: How to Participate\n\n**Last verified:** 2026-09-30 against `dev.to/challenges`, DEV's **Official\nChallenges and Hackathon Rules** (`dev.to/page/official-hackathon-rules`,\nupdated February 25, 2026), individual challenge pages (Frontend Challenge:\nComfort Food Edition, Weekend Challenge: Generosity Edition, Sanity Challenge,\nand the Hacktoberfest 2026 Weekend and Week 1 pages), `dev.to/mlh-hackathon`\n(the Monthly MLH x DEV Writing Challenge), `dev.to/help`, and `dev.to/++`.\n\nThis is the general guide to **entering DEV Challenges**: what they are, who can\nenter, how submissions work, how judging and prizes work, and the rules that\nrecur across challenges. It deliberately leaves out any single challenge's\nprompt, sponsor, prizes, or deadline.\n\n> ! **Which challenges are open is live data.** Use DevRelay's\n> `get_challenges` to list them and `get_challenge_details` to read one\n> challenge's full rules, prompts, rubric, and FAQ (its `full_details`). Or read\n> `https://dev.to/challenges`. **Each challenge page overrides the general\n> patterns below**, and the Official Rules override the challenge page if they\n> conflict.\n\nRelated topics: `/knowledge/dev-guidelines` (writing on DEV, AI disclosure,\nplagiarism), `/knowledge/dev` (the platform and API),\n`/knowledge/hacktoberfest` (the Hacktoberfest 2026 DEV Challenge series).\n\n---\n\n## What DEV Challenges are\n\n*\"DEV Challenges are mini Hackathons that provide a fun opportunity for you to\nbuild up experience using new tools or to publicly show off your best skills to\nthe community, potential employers and more.\"* The challenges page is headed\n*\"Join a DEV Online Hackathon or Writing Challenge.\"* Everything happens online,\nand **you enter by publishing a DEV post**.\n\n### Common formats\n\nThe challenge archive shows several recurring kinds:\n\n| Format | Shape |\n| --- | --- |\n| **Sponsored build challenge** | Build with a partner's product, sometimes with several prompts or \"paths\". The two live on 2026-09-30 (Sanity, Kaggle) both listed cash prizes. Other examples: Notion MCP, GitHub Copilot CLI |\n| **Weekend Challenge** | *\"A short-form challenge that fits into your weekend!\"* The prompt drops at launch and the window runs about three days |\n| **Frontend Challenge** | *\"Flex your CSS and JavaScript Skills!\"* Prompts like CSS Art and a landing page, one winner per prompt |\n| **Writing Challenge** | Write about an event, course, or experience, such as Google I/O or the AI Agents Intensive Course |\n| **Community campaigns** | Themed challenges such as WeCoded (gender equity), the April Fools Challenge, and game jams |\n| **Event series** | Linked rounds, such as Hacktoberfest 2026's Weekend Challenge plus four weekly Open-Source AI rounds |\n\nThe **Monthly MLH x DEV Writing Challenge** is a separate standing program,\ncovered [below](#the-monthly-mlh--dev-writing-challenge).\n\n### Challenge status\n\nThe challenges page groups challenges as **Active** (*\"Join these challenges\nwhile they're open\"*), **Launching Soon**, and **Past**. Each challenge page\nshows a status (Upcoming, Live, or Ended), a **Sign Up** button to be notified\nwhen an upcoming challenge launches, **Key Dates** (contest start, submissions\ndue, winners announced), **Badge Rewards**, a link to the **Launch Post** for\nquestions, the **prompts** with a **Submission Template** and **Judging\nCriteria** for each, **How To Participate**, a **FAQ**, and the challenge's\nrules.\n\n---\n\n## Who can enter\n\nFrom the Official Rules, where DEV's contests are sponsored by **Major League\nHacking PBC Inc.**:\n\n- *\"a skill-based contest open to individuals\"* who are **at least 18** (or the\n age of majority where they live) and *\"capable of forming a binding contract,\"*\n who are **DEV Members** (registered users *\"whose account is active and in good\n standing\"*), and who have email and internet access throughout the entry\n period.\n- **Excl
...[truncated 13254 chars]
Plan - Week 2 "Side Hustle", 2 days left
Status
| Repo |
Emanuelmsjv/ledgerlens, 4 commits, pushed, 33 files |
| Tests | 69/69 green |
| Benchmark | synthetic 7/7 . real 7/8 . 0 wrong totals |
| Privacy | clean - every staged file leak-scanned |
| Missing | the write-up, the session embed, the paste-ready body |
The build is done. Nothing below touches the pipeline.
Your decisions, locked in
- GSTIN check digit: spend the hour sourcing ground truth
- Categories: Overall + Entire
Entire means the agent-session embed is not optional this week - it's the category you claimed. It moves from nice-to-have to required.
Ground rule for this week
I never write to your DEV account. Week 1 I clobbered your post with --body "probe". I produce files; you paste. That costs 10 minutes and removes the only failure mode that can actually hurt you.
Today's plan
1. GSTIN check digit -1 hour, self-killing
Find published GSTINs I can verify independently (GSTN lookup, vendor invoice samples), implement mod-36, and test against them.
Abort condition: if no trustworthy ground truth emerges, delete the implementation and document the gap - same as last time. The benchmark already shows the parser rejects malformed codes, so the risk of shipping is "nulls a valid GSTIN," which is worse than the gap.
Gate before it ships: benchmark must still show 0 wrong, and GSTIN must go 3/4 -> 4/4.
2. Agent session - 40 min, required for Entire
The Week 1 curator is still in temp and already solved the hard parts:
- scrubs email, MLH IDs, home path
- ASCII-folds, because both DevRelay and the DEV editor mangle UTF-8
- has a security gate I proved fires by planting a fake OpenAI key
Export this session -> curate -> submit -> get ID. I'll verify the stored copy server-side, not the local one - that's how I caught the mangling last week.
Risk: this session contains ledgerlens work plus a lot of Week 1 context. If curation looks messy, I'll cut a tight slice rather than submit5 MB of transcript.
3. Write the post - 90 min
All 7 template headings. The material is unusually strong because it's measurements, not claims:
| Section | Evidence |
|---|---|
| What I Built | offline receipt OCR -> accounting CSV + anomaly detection |
| Demo | real terminal output from npm run pipeline
|
| Code |
{% embed %} the repo |
| How I Built It | Tesseract WASM in pure Node, no Python/native/API |
| Why Open Innovation | the 102.00 bug - savings line returned as the receipt total, with no error raised. That story is the argument |
| My Agent Session | the embed |
| Prize Categories | Overall + Entire |
Strongest beat: OCR read a GSTIN's Z as 2, and the parser rejected it and returned null rather than filing a mangled tax ID.
4. You paste - 15 min
PUBLISHED-BODY.md -> https://dev.to/new -> tags devchallenge, hf26challenge.
I'll verify read-only afterwards: tags present, embed rendered as a card, not raw Liquid.
What I'm deliberately not doing
- TabPFN - cloud API key undercuts the offline premise, and 2 days is thin
- Deploying - a locally-run tool that needs no hosting is the better story
- Publishing anything real from your receipts - not without asking you first
Sequence and timing
| Block | Time | Gate |
|---|---|---|
| GSTIN ground truth | 1 h | 4/4 or delete |
| Session embed | 40 m | stored copy verified |
| Write post | 90 m | all 7 headings present |
| You paste | 15 m | - |
Leaves ~20 h buffer against a Sunday deadline.
One thing I need before starting: step 2 puts a transcript on a public profile. It covers ledgerlens, but it also sweeps in earlier context from this week. Do you want me to curate tightly to ledgerlens work only, or include the broader session the way Week 1's embed did?
so only the weel 2 work and start
Starting. Tight ledgerlens-only slice for the embed.
{
"todos": [
{
"content": "Export session, find the ledgerlens-only message range",
"priority": "high",
"status": "in_progress"
},
{
"content": "GSTIN: source verifiable ground truth, validate mod-36 check digit",
"priority": "high",
"status": "pending"
},
{
"content": "GSTIN gate: benchmark must go 3/4 -> 4/4 with 0 wrong, else delete",
"priority": "high",
"status": "pending"
},
{
"content": "Curate ledgerlens slice, scrub, security-scan",
"priority": "high",
"status": "pending"
},
{
"content": "Submit session to DEV, verify the STORED copy server-side",
"priority": "high",
"status": "pending"
},
{
"content": "Write the Week 2 post on the template (7 headings)",
"priority": "high",
"status": "pending"
},
{
"content": "Generate PUBLISHED-BODY.md paste file",
"priority": "high",
"status": "pending"
},
{
"content": "Verify all post numbers against live data before handing over",
"priority": "high",
"status": "pending"
}
]
}
[
{
"content": "Export session, find the ledgerlens-only message range",
"status": "in_progress",
"priority": "high"
},
{
"content": "GSTIN: source verifiable ground truth, validate mod-36 check digit",
"status": "pending",
"priority": "high"
},
{
"content": "GSTIN gate: benchmark must go 3/4 -> 4/4 with 0 wrong, else delete",
"status": "pending",
"priority": "high"
},
{
"content": "Curate ledgerlens slice, scrub, security-scan",
"status": "pending",
"priority": "high"
},
{
"content": "Submit session to DEV, verify the STORED copy server-side",
"status": "pending",
"priority": "high"
},
{
"content": "Write the Week 2 post on the template (7 headings)",
"status": "pending",
"priority": "high"
},
{
"content": "Generate PUBLISHED-BODY.md paste file",
"status": "pending",
"priority": "high"
},
{
"content": "Verify all post numbers against live data before handing over",
"status": "pending",
"priority": "high"
}
]
$ {"command":"$ErrorActionPreference='Continue'\n$env:NO_COLOR=\"1\"\n$id = \"ses_ee8b69fa9ffe75OFq0YEUWLzhP\"\n$t = \"[redacted:home-path]\\AppData\\Local\\Temp\\opencode\"\n$raw = & opencode export $id 2>$null | Out-String\n[IO.File]::WriteAllText(\"$t\\w2-raw.json\", $raw, [Text.Encoding]::UTF8)\n\" export: {0:N2} MB\" -f ((Get-Item \"$t\\w2-raw.json\").Length/1MB)\nnode -e \"\nconst fs=require('fs');\nconst raw=JSON.parse(fs.readFileSync('C:/Users/[redacted:user]/AppData/Local/Temp/opencode/w2-raw.json','utf8').replace(/^\\uFEFF/,''));\nconsole.log(' messages:', raw.messages.length);\nconst first = raw.messages.findIndex(m=>/ledgerlens/i.test(JSON.stringify(m.parts).slice(0,4000)));\nconsole.log(' first ledgerlens mention at message:', first);\n\"","timeout":900000}
export: 9.42 MB
messages: 914
first ledgerlens mention at message: 718
914 messages now (was 577). Ledgerlens starts at 718 - so ~196 messages. Let me check that range's quality and size:
$ {"command":"$ErrorActionPreference='Continue'\nnode -e \"\nconst fs=require('fs');\nconst raw=JSON.parse(fs.readFileSync('C:/Users/[redacted:user]/AppData/Local/Temp/opencode/w2-raw.json','utf8').replace(/^\\uFEFF/,''));\nconst slice=raw.messages.slice(718);\nlet text=0,tools=0,chars=0;\nfor(const m of slice){for(const p of m.parts??[]){chars+=JSON.stringify(p).length;if(p.type==='text')text++;if(p.type==='tool')tools++;}}\nconsole.log(' slice size :', slice.length,'messages');\nconsole.log(' text parts :', text, ' tool parts:', tools);\nconsole.log(' raw chars :', (chars/1024/1024).toFixed(2),'MB');\nconsole.log('');\nconsole.log(' first 3 user messages in the slice:');\nslice.filter(m=>m.info.role==='user').slice(0,3).forEach(m=>{\n const t=(m.parts??[]).filter(p=>p.type==='text').map(p=>p.text).join(' ');\n console.log(' -', t.slice(0,110).replace(/\\s+/g,' '));\n});\n\"","timeout":600000}
slice size : 196 messages
text parts : 107 tool parts: 184
raw chars : 3.13 MB
first 3 user messages in the slice:
- done i pasted them in the real directory inside samples
- samples/real*.log is this how to commit the gitignore
- then just build and commit it i ll be back and the important things never make the receipts as public witout m
196 messages / 3.13 MB - too big to embed. I need a tight arc. Also, important: I need to verify a claim before I write it into the post. Let me locate the key moment:
$ {"command":"$ErrorActionPreference='Continue'\nnode -e \"\nconst fs=require('fs');\nconst raw=JSON.parse(fs.readFileSync('C:/Users/[redacted:user]/AppData/Local/Temp/opencode/w2-raw.json','utf8').replace(/^\\uFEFF/,''));\nconst marks=[\n ['first real OCR run',/8 image\\(s\\)/],\n ['first real parse',/[redacted:real-term]|[redacted:real-term]/],\n ['80 vs 180 bug',/total=80\\b|80\\.00.*via rs/],\n ['mrp trap probe',/mrp trap|102/],\n ['date NaN bug',/order\\[1\\]|NaN/],\n ['vendor rounds',/PARTICULARS|Inclusive of All Taxes/],\n ['benchmark added',/benchmark\\.mjs/],\n ['gstin rejected',/332ZQPX/],\n];\nfor(const [name,re] of marks){\n const hits=[]; raw.messages.forEach((m,i)=>{ if(i<718)return; const s=JSON.stringify(m.parts).slice(0,6000); if(re.test(s))hits.push(i); });\n console.log(' '+(hits.length? hits[0]+'-'+hits[hits.length-1] : 'none').padEnd(10)+' '+name+' ('+hits.length+' hits)');\n}\n\"","timeout":600000}
792-792 first real OCR run (1 hits)
782-900 first real parse (43 hits)
798-837 80 vs 180 bug (8 hits)
752-910 mrp trap probe (16 hits)
740-806 date NaN bug (3 hits)
783-838 vendor rounds (7 hits)
864-900 benchmark added (19 hits)
898-907 gstin rejected (4 hits)
Now let me verify a claim before it goes in the post - I asserted my first parser would return 102.00, but I never actually ran it on real data. Let me make that a measurement instead of a claim:
{"content":"// Reproduces the FIRST version of findTotal, exactly as it was written, and runs\n// it against the real OCR text. This turns \"I believe the original code would\n// have returned the savings\" into a measurement.\n//\n// The original scanned upward for /total/i and took the last number on the line.\n// That is the whole bug: on a real bill, \"Total MRP : 695.00 Savings : 102.00\"\n// sits below the real total, so it returns 102.00 - the discount - and raises no\n// error anywhere.\n//\n// Nothing here ships. It exists so the number in the write-up is not a guess.\n\nimport { readFileSync, readdirSync } from \"node:fs\";\nimport { resolve, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { findTotal } from \"./parse.mjs\";\n\nconst ROOT = resolve(dirname(fileURLToPath(import.meta.url)), \"..\");\nconst DIR = join(ROOT, \"fixtures\");\n\n/** Verbatim logic of the original findTotal, plus the original MONEY_RE. */\nfunction originalFindTotal(lines) {\n for (const re of [/\\btotal\\b/i, /\\bgrand total\\b/i, /\\bamount payable\\b/i]) {\n for (let i = lines.length - 1; i >= 0; i--) {\n if (!re.test(lines[i])) continue;\n const amounts = lines[i].match(/\\d+\\.\\d{2}/g);\n if (amounts && amounts.length) return Number(amounts[amounts.length - 1]);\n }\n }\n return null;\n}\n\nconst GROUND_TRUTH = {\n \"pic_1(receipt dimension 1).txt\": 180,\n \"pic_2(receipt dimension 1).txt\": 593,\n \"pic_2(receipt dimension 3).txt\": 593,\n \"pic_3(receipt dimension 1).txt\": 88,\n \"pic_4(receipt dimension 1).txt\": 4600,\n};\n\nconsole.log(\"Original findTotal vs the shipped one, on real receipt OCR\\n\");\nconsole.log(\" file truth original shipped\");\n\nlet origWrong = 0, origRight = 0, origNull = 0, shipWrong = 0, shipRight = 0, shipNull = 0;\nconst rows = [];\n\nfor (const [file, truth] of Object.entries(GROUND_TRUTH)) {\n const p = join(DIR, file);\n if (!readdirSync(DIR).includes(file)) continue;\n const lines = readFileSync(p, \"utf8\")\n .split(/\\r?\\n/)\n .map((l) => l.replace(/[^\\S\\n]+/g, \" \").trim())\n .filter(Boolean);\n\n const orig = originalFindTotal(lines);\n const ship = findTotal(lines).total;\n\n const oStat = orig === null ? \"abstain\" : orig === truth ? \"right\" : \"WRONG\";\n const sStat = ship === null ? \"abstain\" : ship === truth ? \"right\" : \"WRONG\";\n if (oStat === \"WRONG\") origWrong++; else if (oStat === \"right\") origRight++; else origNull++;\n if (sStat === \"WRONG\") shipWrong++; else if (sStat === \"right\") shipRight++; else shipNull++;\n\n const fmt = (n) => (n == null ? \" ---- \" : n.toFixed(2).padStart(7));\n console.log(` ${file.slice(0, 36).padEnd(36)} ${String(truth).padStart(5)} ${fmt(orig)} ${oStat === \"WRONG\" ? \"!\" : \" \"} ${fmt(ship)} ${sStat === \"WRONG\" ? \"!\" : \" \"}`);\n rows.push({ file, truth, original: orig, originalStatus: oStat, shipped: ship, shippedStatus: sStat });\n}\n\nconsole.log(\"\");\nconsole.log(` original : ${origRight} right, ${origWrong} WRONG, ${origNull} abstained`);\nconsole.log(` shipped : ${shipRight} right, ${shipWrong} WRONG, ${shipNull} abstained`);\nconsole.log(\"\");\nconsole.log(\" A 'WRONG' here is a plausible, confident number that is not the amount owed.\");\n\n// Machine-readable, so the write-up can quote it rather than restate it.\nwriteFileSafe(join(ROOT, \"docs/total-selection-compare.json\"), JSON.stringify({\n note: \"Original findTotal logic versus the shipped one, measured on real receipt OCR. Ground truth read off the paper.\",\n rows,\n original: { right: origRight, wrong: origWrong, abstained: origNull },\n shipped: { right: shipRight, wrong: shipWrong, abstained: shipNull },\n}, null, 2) + \"\\n\");\nconsole.log(\"\\n wrote docs/total-selection-compare.json\");\n\nfunction writeFileSafe(path, content) {\n try {\n const { writeFileSync } = require(\"node:fs\");\n writeF
...[truncated 267 chars]
Wrote file successfully.
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\nnode scripts/compare-totals.mjs 2>&1","timeout":600000}
Original findTotal vs the shipped one, on real receipt OCR
file truth original shipped
pic_1(receipt dimension 1).txt 180 ---- 180.00
pic_2(receipt dimension 1).txt 593 102.00 ! 593.00
pic_2(receipt dimension 3).txt 593 102.00 ! 593.00
pic_3(receipt dimension 1).txt 88 88.00 88.00
pic_4(receipt dimension 1).txt 4600 459.00 ! 4600.00
original : 1 right, 3 WRONG, 1 abstained
shipped : 5 right, 0 WRONG, 0 abstained
A 'WRONG' here is a plausible, confident number that is not the amount owed.
wrote docs/total-selection-compare.json
Measured, not claimed. And it's worse than I predicted:
| file | truth | original | shipped |
|---|---|---|---|
| railway ticket | 180 | ---- |
180.00 |
| bakery | 593 | 102.00 x | 593.00 |
| bakery (other photo) | 593 | 102.00 x | 593.00 |
| grocery | 88 | 88.00 | 88.00 |
| credit sale | 4600 | 459.00 x | 4600.00 |
Original: 1 right, 3 WRONG, 1 abstained. Shipped: 5 right, 0 wrong.
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\ngit add -A\ngit -c user.name=\"[redacted:user]\" -c user.email=\"[redacted:email]\" commit -q -m \"Measure the original total-selection bug instead of asserting it\n\nThe write-up claimed the first version of findTotal would have returned the\nsavings figure as the receipt total. I had reasoned that from reading my own\ncode; I had never run it on real input. scripts/compare-totals.mjs now runs the\noriginal logic verbatim against the committed real-receipt OCR and reports what\nit actually returns.\n\nIt is worse than predicted. On five real documents the original gets one right,\nthree wrong and one abstention:\n\n railway ticket truth 180 original (abstain) shipped 180.00\n bakery truth 593 original 102.00 WRONG shipped 593.00\n bakery (photo 2) truth 593 original 102.00 WRONG shipped 593.00\n grocery truth 88 original 88.00 shipped 88.00\n credit sale truth 4600 original 459.00 WRONG shipped 4600.00\n\nThe 459.00 is worse than the savings case: \\\"Total MRP : 695.00 Savings :\n102.00\\\" is not the only competing total. \\\"Total Points : 459.00\\\" sits below\nit and the original took that instead.\n\nThree confidently wrong numbers out of five, with no error raised anywhere.\nThat is now a measured result in docs/total-selection-compare.json rather than\na claim in a comment.\"\ngit log --oneline -1\ngit push -q origin main 2>&1\n\" pushed\"","timeout":600000}
warning: in the working copy of 'data/anomalies.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/total-selection-compare.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'scripts/compare-totals.mjs', LF will be replaced by CRLF the next time Git touches it
102.00\ is not the only competing total. \Total : The module '102.00' could not be loaded. For more information, run
'Import-Module 102.00'.
At line:22 char:1
+ 102.00\" is not the only competing total. \"Total Points : 459.00\" s ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (102.00\ is not ...g total. \Total:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CouldNotAutoLoadModule
5a9cde2 Measure the original total-selection bug instead of asserting it
pushed
PowerShell mangled the commit message mid-way (the \" broke it). Let me check and fix with a file:
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\nWrite-Output \"=== current message ===\"\ngit log -1 --format=%B | Select-Object -First 12","timeout":300000}
=== current message ===
Measure the original total-selection bug instead of asserting it
The write-up claimed the first version of findTotal would have returned the
savings figure as the receipt total. I had reasoned that from reading my own
code; I had never run it on real input. scripts/compare-totals.mjs now runs the
original logic verbatim against the committed real-receipt OCR and reports what
it actually returns.
It is worse than predicted. On five real documents the original gets one right,
three wrong and one abstention:
railway ticket truth 180 original (abstain) shipped 180.00
{
"content": "Measure the original total-selection bug instead of asserting it\n\nThe write-up claimed the first version of findTotal would have returned the\nsavings figure as the receipt total. I had reasoned that from reading my own\ncode; I had never run it on real input. scripts/compare-totals.mjs now runs the\noriginal logic verbatim against the committed real-receipt OCR and reports what\nit actually returns.\n\nIt is worse than predicted. On five real documents the original gets one right,\nthree wrong and one abstention:\n\n railway ticket truth 180 original (abstain) shipped 180.00\n bakery truth 593 original 102.00 WRONG shipped 593.00\n bakery (photo 2) truth 593 original 102.00 WRONG shipped 593.00\n grocery truth 88 original 88.00 shipped 88.00\n credit sale truth 4600 original 459.00 WRONG shipped 4600.00\n\nThe 459.00 is worse than the savings case. A single \"Total MRP\" line is not the\nonly competing total on a real bill: \"Total Points : 459.00\" also contains the\nword Total, sits lower on the page, and the original took that instead. Fixing\nonly the savings case would still have produced a wrong number here.\n\nThree confidently wrong figures out of five, with no error raised anywhere.\nThat is now a measured result in docs/total-selection-compare.json rather than\na claim in a comment, and it is the single strongest argument the project has for\nwhy totals are chosen by label priority instead of by regex.",
"filePath": "[redacted:home-path]\\AppData\\Local\\Temp\\opencode\\ll5.txt"
}
Wrote file successfully.
and lets run some couple of debugging sessions and security test
Fixing the message, then on to debugging and security testing.
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\ngit -c user.name=\"[redacted:user]\" -c user.email=\"[redacted:email]\" commit -q --amend -F \"[redacted:home-path]\\AppData\\Local\\Temp\\opencode\\ll5.txt\"\ngit log -1 --format=\" %h %s\"\nWrite-Output \"\"\nWrite-Output \"=== message body now intact? ===\"\ngit log -1 --format=%B | Select-String -Pattern \"Total Points\" -SimpleMatch\ngit push -q --force-with-lease origin main 2>&1\nWrite-Output \" pushed\"","timeout":600000}
b6fb063 Measure the original total-selection bug instead of asserting it
=== message body now intact? ===
only competing total on a real bill: "Total Points : 459.00" also contains the
pushed
Now the important part. Let me start with the highest-stakes check - whether real receipt data ever leaked into git history, not just the working tree:
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\nWrite-Output \"=== 1. baseline tests ===\"\nnpm test 2>&1 | Select-String -Pattern \"passed|failed|Error\" | Select-Object -Last 6\nWrite-Output \"\"\nWrite-Output \"=== 2. is samples/real tracked anywhere in history? ===\"\n$hits = git log --all --pretty=format: --name-only --diff-filter=A | Sort-Object -Unique | Select-String -Pattern \"samples/real|redaction-terms\"\nif ($hits) { $hits } else { Write-Output \" CLEAN - never added in any commit on any branch\" }\nWrite-Output \"\"\nWrite-Output \"=== 3. every path ever committed, filtered to risky names ===\"\ngit log --all --pretty=format: --name-only | Sort-Object -Unique | Where-Object { $_ -and $_ -notmatch \"^$\" } | Select-String -Pattern \"receipt|\\.jpg|\\.png|\\.jpeg|real\"","timeout":900000}
=== 1. baseline tests ===
30/30 checks passed
12/12 checks passed
27/27 checks passed
=== 2. is samples/real tracked anywhere in history? ===
CLEAN - never added in any commit on any branch
=== 3. every path ever committed, filtered to risky names ===
fixtures/pic_1(receipt dimension 1).txt
fixtures/pic_1(receipt dimension 2).txt
fixtures/pic_2(receipt dimension 1).txt
fixtures/pic_2(receipt dimension 2).txt
fixtures/pic_2(receipt dimension 3).txt
fixtures/pic_3(receipt dimension 1).txt
fixtures/pic_3(receipt dimension 2).txt
fixtures/pic_4(receipt dimension 1).txt
samples/receipt-01.png
samples/receipt-02.png
samples/receipt-03-duplicate.png
samples/receipt-04-stationery.png
samples/receipt-0*.png are the synthetic ones (safe). Now the real test - can anything personal be recovered from the committed fixtures? Let me audit them adversarially:
{
"filePath": "[redacted:home-path]\\hacktoberfest\\ledgerlens\\scripts\\make-fixtures.mjs"
}
<path>[redacted:home-path]\hacktoberfest\ledgerlens\scripts\make-fixtures.mjs</path>
<type>file</type>
<content>
1: // Build redacted OCR-text fixtures from real receipt photos.
2: //
3: // The photos themselves are never committed: they carry shop phone numbers, a
4: // GSTIN, and a biller's name with an employee id. This script reads the OCR
5: // output, strips identifying data, and writes a fixture that still exercises
6: // the exact parsing bugs the real photos exposed - mangled labels, competing
7: // totals, wrapped item lines, dropped date separators, background debris.
8: //
9: // Run it only when the real samples change. The committed fixtures are what the
10: // test suite reads.
11:
12: import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
13: import { resolve, dirname, join } from "node:path";
14: import { fileURLToPath } from "node:url";
15:
16: const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17: const SRC = process.argv[2] || "data/raw";
18: const OUT = join(ROOT, "fixtures");
19:
20: const SUBSTITUTIONS = [
21: // GSTIN: keep the shape, drop the identity.
22: [/\b\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9]{3}\b/g, "33AAAAA0000A0Z0"],
23: // Indian mobile numbers, 10 digits with an optional prefix.
24: [/\b[6-9]\d{9}\b/g, "9000000000"],
25: [/\b\d{10,13}\b/g, "900000000000"],
26: // Ticket and bill numbers with a recognisable prefix.
27: [/\b(\d{2,4}[A-Z0-9]{2}\d{3,4})\b/g, (m) => m.replace(/\d/g, "0")],
28: ];
29:
30: function redact(text) {
31: let out = text;
32: for (const [re, to] of SUBSTITUTIONS) out = out.replace(re, to);
33: // Phone labels regardless of whether the number pattern matched.
34: out = out.replace(/\b(Ph|Phone|Tel|Mobile|Gpay)\b\s*:?\s*\S+/gi, "$1 : [redacted]");
35: // Staff and customer names. A person's name beside a branch and an employee
36: // id identifies someone, and none of this was consented to publication.
37: out = out.replace(/\b(Cus|Customer|Biller|Cashier|Received\s*by|Served\s*by)\b\s*:?\s*[A-Za-z][\w.'-]*/gi,
38: (m) => m.split(":")[0].replace(/\s*:?\s*$/, "") + " : [redacted]");
39: // Employee ids printed beside the name.
40: out = out.replace(/\(\s*ID\s*:\s*\d+\s*\)/gi, "(ID : [redacted])");
41:
42: // Place names are found by shape, not by a hardcoded list. Naming the
43: // specific towns and branches in this file would publish exactly what it is
44: // there to hide: that a particular station, depot or road is on the receipt.
45: //
46: // "<TOWN>-REGION" hyphenated region
47: // "<TOWN> DEPC" a depot, with OCR dropping the final T
48: // "ROAD.,<TOWN>" a branch town following the street
49: // "NAGAR,<TOWN>" a neighbourhood, then the town
50: // "City : <town>" a lower-case city field
51: // Explicit alternatives rather than DEP\w*: with the /i flag that pattern
52: // matches "Department" as DEP + "artment" and destroys the vendor's name.
53: out = out.replace(/\b[A-Z][A-Z-]{2,}[- ]?(REGION|DEPOT|DEPT|DEPO|DEPC|STATION|JUNCTION|NODE)\b/gi, "[place]");
54: out = out.replace(/\b(ROAD|STREET|NAGAR|BY\s*PASS|COLONY)\b(\.?)\s*,?\s*([A-Za-z]{4,})/g, (m, street, dot) => `${street}${dot}, [place]`);
55: out = out.replace(/\b(City|District|Place|Station|Depot|Region)\b\s*:\s*[A-Za-z][\w-]*/gi, (m) => m.split(":")[0].trim() + ": [place]");
56: // A journey shown as "<code> - <NAME> <code> - <NAME>".
57: out = out.replace(/\b\d{1,3}-[A-Z]{3,}\b/g, "[stop]");
58: // Anything in the local blocklist, which is gitignored so it never ships.
59: const local = join(ROOT, "redaction-terms.txt");
60: if (existsSync(local)) {
61: for (const term of readFileSync(local, "utf8").split(/\r?\n/).map((t) => t.trim()).filter(Boolean)) {
62: out = out.replace(new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), "[place]");
63: }
64: }
65: return out;
66: }
67:
68: function main() {
69: const dir
...[truncated 1611 chars]
Now the adversarial part - let me scan the committed fixtures for anything that still identifies a real person or business:
$ {"command":"$ErrorActionPreference='Continue'\ncd [redacted:home-path]\\hacktoberfest\\ledgerlens\nnode -e \"\nconst fs=require('fs'),p=require('path');\nconst dir='fixtures';\nconst pats=[\n ['phone-like', /\\b[6-9]\\d{9}\\b/g],\n ['GSTIN-shape', /\\b\\d{2}[A-Z]{5}\\d{4}[A-Z][A-Z0-9]{3}\\b/g],\n ['email', /[\\w.-]+@[\\w.-]+\\.\\w+/g],\n ['pincode', /\\b6\\d{5}\\b/g],\n ['long-number', /\\b\\d{10,}\\b/g],\n ['plate/vehicle', /\\b[A-Z]{2}\\s?\\d{1,2}\\s?[A-Z]{1,3}\\s?\\d{3,4}\\b/g],\n ['name-label', /\\b(Cus|Customer|Biller|Cashier|Served|Received)\\b[^\\n]{0,40}/gi],\n ['place-label', /\\b(City|District|Place|Station|Depot|Region)\\b\\s*:[^\\n]{0,30}/gi],\n ['journey-code', /\\b\\d{1,3}-[A-Z]{3,}\\b/g],\n ['ID-field', /\\(\\s*ID[^\\n]{0,20}/gi],\n];\nconst found={};\nfor(const f of fs.readdirSync(dir).filter(x=>x.endsWith('.txt'))){\n const t=fs.readFileSync(p.join(dir,f),'utf8');\n for(const [name,re] of pats){\n const m=t.match(re);\n if(m){ (found[name]??=[]).push(f+': '+[...new Set(m)].slice(0,4).join(' | ')); }\n }\n}\nfor(const [k,v] of Object.entries(found)){\n console.log(' ['+k+']');\n v.forEach(x=>console.log(' '+x));\n}\nif(!Object.keys(found).length) console.log(' nothing matched');\n\"","timeout":600000}
[long-number]
pic_1(receipt dimension 1).txt: 900000000000
pic_1(receipt dimension 2).txt: 900000000000
[name-label]
pic_2(receipt dimension 1).txt: Cashier : [redacted]
pic_2(receipt dimension 2).txt: Cashier : [redacted]
pic_2(receipt dimension 3).txt: Cashier : [redacted]
pic_3(receipt dimension 1).txt: Cashier {BILL2 Counter BILL2
pic_4(receipt dimension 1).txt: Biller : [redacted] | Cus : [redacted] (ID : [redacted])
[GSTIN-shape]
pic_4(receipt dimension 1).txt: 33AAAAA0000A0Z0
[pincode]
pic_4(receipt dimension 1).txt: 636808
[place-label]
pic_4(receipt dimension 1).txt: City: [place]
[ID-field]
pic_4(receipt dimension 1).txt: (ID : [redacted])
Prize Categories
I'm entering:
- Overall — a genuinely useful, fully offline side-hustle tool with measured correctness.
- Entire — the agent session above shows how it was built and where it went wrong.
Top comments (0)