Sort a downloads folder without opening every file
classifier.dev labels text against categories you choose and returns a
calibrated confidence. No key. Here it reads a filename and a first page and
says where the file goes, or that it cannot tell.
Three rules make it safe on a real folder: dry run by default, every move
reversible, anything under 0.8 stays put.
Before you run it
- The filename and up to 2,000 characters of each file go over HTTPS to
classifier.dev. Point it at a downloads or scans folder, never at one holding
keys, password exports or a source tree.
- The
identity document label means passport and licence scans get a first
page sent. If that is not acceptable, drop the label.
- PDF text needs
pdftotext (poppler). Without it a PDF is judged on its
filename alone, usually lands under 0.8 and stays: correct, but you will sort
nothing. Install poppler first.
- This is a script you run. It installs no scheduled job, no crontab and no
login item; when it exits, nothing is left running.
The watcher
Save as sortfiles.py. Python 3, no dependencies.
#!/usr/bin/env python3
# sortfiles.py DIR [--apply] [--undo] dry run unless --apply
import json, os, shutil, subprocess, sys, time, urllib.request
DIR = os.path.expanduser(sys.argv[1]); LOG = os.path.join(DIR, ".sorted.log")
F = {"invoice asking for payment": "Invoices", "receipt for something already paid": "Receipts",
"contract or agreement": "Contracts", "identity document": "ID",
"screenshot": "Screenshots", "software installer": "Installers"}
def peek(p):
t = ""
if p.lower().endswith(".pdf") and shutil.which("pdftotext"):
t = subprocess.run(["pdftotext", "-l", "1", p, "-"], capture_output=True, text=True, timeout=20).stdout
elif os.path.getsize(p) < 5_000_000:
t = open(p, "rb").read(2000).decode("utf-8", "replace")
if t and sum(c.isprintable() or c.isspace() for c in t) / len(t) < 0.8:
t = "" # binary bytes classify as noise: filename only
return f"{os.path.basename(p)}\n\n{t[:2000]}"
if "--undo" in sys.argv:
for _, src, dst in reversed([l.split("\t") for l in open(LOG).read().splitlines()]):
os.replace(dst, src); print("back", dst)
os.remove(LOG); sys.exit()
files = [os.path.join(DIR, n) for n in sorted(os.listdir(DIR))
if not n.startswith(".") and os.path.isfile(os.path.join(DIR, n))]
body = json.dumps({"labels": list(F) + ["none of these"], "inputs": [peek(p) for p in files],
"instructions": "Judge the document from the filename and the first page of text."}).encode()
req = urllib.request.Request("https://classifier.dev/v1/classify", data=body,
headers={"content-type": "application/json", "user-agent": "sorter/1"})
for p, r in zip(files, json.load(urllib.request.urlopen(req, timeout=60))["results"]):
conf, dest = r["confidence"] or 0.0, F.get(r["label"])
act = "move " if "--apply" in sys.argv else "would"
if conf < 0.8 or not dest:
act, dest = "stay ", r["label"]
print(f"{act} {conf:.2f} {dest:32.32} {os.path.basename(p)}")
if act != "stay " and "--apply" in sys.argv:
t = os.path.join(DIR, dest, os.path.basename(p)); os.makedirs(os.path.dirname(t), exist_ok=True)
os.replace(p, t); open(LOG, "a").write(f"{int(time.time())}\t{p}\t{t}\n")
Run it
python3 sortfiles.py ~/Downloads # dry run, moves nothing
python3 sortfiles.py ~/Downloads --apply # moves, appends to .sorted.log
python3 sortfiles.py ~/Downloads --undo # puts the last run back
Real output, seven-file folder:
would 1.00 Installers Docker.dmg
stay 0.83 none of these IMG_4417.HEIC
would 1.00 Invoices INV-2291.txt
would 1.00 Contracts MSA_signed.txt
stay 0.75 none of these Northwind_proposal_v3.txt
would 1.00 Screenshots Screenshot 2026-09-12 at 14.03.11.png
would 1.00 Receipts receipt-7731.txt
Read the dry run before you pass --apply, stay rows first: they are what
your labels do not cover yet.
Why 0.8
Confidence is calibrated: measured, answers at or above 0.9 were right 82 to 92%
of the time, answers under 0.5 right 29 to 64%. A move is cheap to reverse, so
the line here is 0.8 rather than 0.9 — but only because the log exists. Without
the log, use 0.9.
Northwind_proposal_v3 at 0.75 says in its own text that it is not an invoice
and is unsigned, so it sits between two labels. Leaving it is right; a
proposal or quote label would fix it properly.
IMG_4417.HEIC has no extractable text, so only the filename was classified.
It scored none of these, which has no folder, so it stays. Scores in this
band move a few points between runs: another reason not to act on them.
Pitfalls
- Always include
none of these. Every call returns one of your labels, so
a bank statement against six document types is filed as one of them, and
confidently, unless there is somewhere else to put it.
- Binary bytes make noise. The script blanks an extract under 80% printable
and falls back to the filename; a raw
.dmg header would otherwise classify
as confident nonsense.
- Null confidence. A provider may return no score, and smart replacement
has no comparable score. The script treats either null as 0.0.
- Limits per IP: 3,000 classifications a minute, 20,000 a day. 1,000 files is
one call; a 429 carries
Retry-After.
1---2name: downloads-and-inbox-sorter3description: File a downloads or scanned-mail folder by classifying each file from its name plus the first 2,000 characters of text into invoice, receipt, contract, identity document, screenshot, installer or none of these, and moving only the confident ones. Dry run first, an undo log for every move, and anything under 0.8 stays put. Use on "sort my downloads", "file these PDFs", "organise the invoices and receipts".4license: MIT5---67# Sort a downloads folder without opening every file89`classifier.dev` labels text against categories you choose and returns a10calibrated confidence. No key. Here it reads a filename and a first page and11says where the file goes, or that it cannot tell.1213Three rules make it safe on a real folder: **dry run by default**, **every move14reversible**, **anything under 0.8 stays put**.1516## Before you run it1718- The filename and up to 2,000 characters of each file go over HTTPS to19 classifier.dev. Point it at a downloads or scans folder, never at one holding20 keys, password exports or a source tree.21- The `identity document` label means passport and licence scans get a first22 page sent. If that is not acceptable, drop the label.23- PDF text needs `pdftotext` (poppler). Without it a PDF is judged on its24 filename alone, usually lands under 0.8 and stays: correct, but you will sort25 nothing. Install poppler first.26- This is a script you run. It installs no scheduled job, no crontab and no27 login item; when it exits, nothing is left running.2829## The watcher3031Save as `sortfiles.py`. Python 3, no dependencies.3233```python34#!/usr/bin/env python335# sortfiles.py DIR [--apply] [--undo] dry run unless --apply36import json, os, shutil, subprocess, sys, time, urllib.request37DIR = os.path.expanduser(sys.argv[1]); LOG = os.path.join(DIR, ".sorted.log")38F = {"invoice asking for payment": "Invoices", "receipt for something already paid": "Receipts",39 "contract or agreement": "Contracts", "identity document": "ID",40 "screenshot": "Screenshots", "software installer": "Installers"}4142def peek(p):43 t = ""44 if p.lower().endswith(".pdf") and shutil.which("pdftotext"):45 t = subprocess.run(["pdftotext", "-l", "1", p, "-"], capture_output=True, text=True, timeout=20).stdout46 elif os.path.getsize(p) < 5_000_000:47 t = open(p, "rb").read(2000).decode("utf-8", "replace")48 if t and sum(c.isprintable() or c.isspace() for c in t) / len(t) < 0.8:49 t = "" # binary bytes classify as noise: filename only50 return f"{os.path.basename(p)}\n\n{t[:2000]}"5152if "--undo" in sys.argv:53 for _, src, dst in reversed([l.split("\t") for l in open(LOG).read().splitlines()]):54 os.replace(dst, src); print("back", dst)55 os.remove(LOG); sys.exit()5657files = [os.path.join(DIR, n) for n in sorted(os.listdir(DIR))58 if not n.startswith(".") and os.path.isfile(os.path.join(DIR, n))]59body = json.dumps({"labels": list(F) + ["none of these"], "inputs": [peek(p) for p in files],60 "instructions": "Judge the document from the filename and the first page of text."}).encode()61req = urllib.request.Request("https://classifier.dev/v1/classify", data=body,62 headers={"content-type": "application/json", "user-agent": "sorter/1"})63for p, r in zip(files, json.load(urllib.request.urlopen(req, timeout=60))["results"]):64 conf, dest = r["confidence"] or 0.0, F.get(r["label"])65 act = "move " if "--apply" in sys.argv else "would"66 if conf < 0.8 or not dest:67 act, dest = "stay ", r["label"]68 print(f"{act} {conf:.2f} {dest:32.32} {os.path.basename(p)}")69 if act != "stay " and "--apply" in sys.argv:70 t = os.path.join(DIR, dest, os.path.basename(p)); os.makedirs(os.path.dirname(t), exist_ok=True)71 os.replace(p, t); open(LOG, "a").write(f"{int(time.time())}\t{p}\t{t}\n")72```7374## Run it7576```77python3 sortfiles.py ~/Downloads # dry run, moves nothing78python3 sortfiles.py ~/Downloads --apply # moves, appends to .sorted.log79python3 sortfiles.py ~/Downloads --undo # puts the last run back80```8182Real output, seven-file folder:8384```85would 1.00 Installers Docker.dmg86stay 0.83 none of these IMG_4417.HEIC87would 1.00 Invoices INV-2291.txt88would 1.00 Contracts MSA_signed.txt89stay 0.75 none of these Northwind_proposal_v3.txt90would 1.00 Screenshots Screenshot 2026-09-12 at 14.03.11.png91would 1.00 Receipts receipt-7731.txt92```9394Read the dry run before you pass `--apply`, `stay` rows first: they are what95your labels do not cover yet.9697## Why 0.89899Confidence is calibrated: measured, answers at or above 0.9 were right 82 to 92%100of the time, answers under 0.5 right 29 to 64%. A move is cheap to reverse, so101the line here is 0.8 rather than 0.9 — but only because the log exists. Without102the log, use 0.9.103104- `Northwind_proposal_v3` at 0.75 says in its own text that it is not an invoice105 and is unsigned, so it sits between two labels. Leaving it is right; a106 `proposal or quote` label would fix it properly.107- `IMG_4417.HEIC` has no extractable text, so only the filename was classified.108 It scored `none of these`, which has no folder, so it stays. Scores in this109 band move a few points between runs: another reason not to act on them.110111## Pitfalls112113- **Always include `none of these`.** Every call returns one of your labels, so114 a bank statement against six document types is filed as one of them, and115 confidently, unless there is somewhere else to put it.116- **Binary bytes make noise.** The script blanks an extract under 80% printable117 and falls back to the filename; a raw `.dmg` header would otherwise classify118 as confident nonsense.119- **Null confidence.** A provider may return no score, and smart replacement120 has no comparable score. The script treats either null as 0.0.121- Limits per IP: 3,000 classifications a minute, 20,000 a day. 1,000 files is122 one call; a 429 carries `Retry-After`.