Back to blog

Batch PDF to Markdown in Python for recurring reports

A rerunnable Python job that converts new and changed PDF reports in a folder to Markdown, skips files it already converted and exits nonzero when a report fails.

Supplier statements, monthly operations reports and research digests tend to arrive the same way: as PDFs, on a schedule, in a folder. Converting that folder to Markdown once takes a ten-line loop, like the Python example on our PDF to Markdown page. Running that loop every hour is another matter. It converts every old report again, paying for the same pages each time, and the first file that fails stops the rest.

This guide sets up process_pdf_reports.py, a job built to be run repeatedly against the same folder. Each run converts the PDFs that are new or changed since their last successful conversion, skips the rest and keeps going when one report fails. It calls the hosted Docling API from Parsebridge, and its only dependency is requests.

What the job does and doesn’t do

The job reads PDFs from an input folder and writes one Markdown file per report to an output folder. Something else has to put the PDFs there: a mail rule that saves attachments, a desktop sync client, an SFTP upload or an export job you already run. The script doesn’t connect to a mailbox and doesn’t watch the folder. It processes what it finds when you or a scheduler start it, then exits.

The output is each report as Markdown. The job doesn’t extract figures, check totals or write summaries; you can build those on the Markdown. It never moves or deletes your PDFs. Removing a PDF from the input folder leaves its Markdown in place, so the output folder keeps the latest successful conversion of every filename it has seen. It isn’t a version history: a corrected PDF saved under the same name replaces the earlier Markdown.

How reruns skip reports they’ve already converted

The script looks at the files directly inside the input folder whose names end in .pdf, in any letter case. It reads each one and hashes its bytes with SHA-256. A state file in the output folder, .reports-state.json, stores the hash of the version each filename last converted successfully. If the hash matches and the Markdown is still there, the file is skipped and nothing is sent to the API. If a report is new, or a corrected version was saved under the same name, the bytes go to Parsebridge and the Markdown is replaced.

The script uploads exactly the bytes it hashed, so the recorded hash always matches what was converted. Markdown goes to a temporary file that is then renamed into place, so a crash never leaves half a report at the output path. The new hash is saved only after the Markdown is written. Delete a Markdown file and the next run converts its PDF again.

Each output file is named after the SHA-256 hash of the full input filename, such as 9c1f…e2.md. That keeps names collision-free: report.pdf and report.PDF get separate files. The log line for each conversion shows the mapping, and you can compute it yourself:

python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest() + ".md")' "report.pdf"

Tracking is per filename, not per content. The same PDF saved under two names is converted, and billed, twice. A renamed report counts as a new one: it gets a new Markdown file, and the old file stays. The state file also stores the input folder’s path, so use one output folder per input folder. If you move the input folder, the job stops instead of starting over.

Failures, retries and billing

When a report fails, the script prints Failed <filename> with the reason, leaves the file pending and continues. Any earlier Markdown for that file stays as it was. After the last file, it exits with status 1 if anything failed, so cron, CI or whatever starts it can tell a partial run from a clean one. Errors show the HTTP status, such as Parsebridge HTTP 402 when your page allowance is used up, but never the API key or response bodies.

Each conversion is billed at one page of your allowance per PDF page, so the script sends each request once and never retries it automatically. A failed report waits for the next run. Skipping by hash means unchanged reports cost nothing, but it can’t promise that each version is billed exactly once. If a conversion succeeds on the server but the response is lost, or the Markdown can’t be saved, the report is still pending and the next run bills it again. PDFs over the API’s 50 MB limit fail without being sent.

Run one instance at a time. Two overlapping runs can convert the same new report twice; the hourly setup below uses a lock to prevent that.

Getting reports into the folder safely

The job can’t tell a finished PDF from one still being written. If the producer supports it, have it write to a temporary name like report.pdf.part and rename the file when it’s complete. The job ignores anything not ending in .pdf, and a rename within one filesystem is atomic. Otherwise, schedule the job away from delivery times. A half-written file usually fails as an invalid PDF and stays pending. If it does convert, its hash changes once the file is complete, and the next run converts it again at the cost of those pages.

Download and run it

You can open or download process_pdf_reports.py and requirements.txt in your browser, or fetch them with the commands below.

You need Python 3.10 or newer. On macOS, python3 can be the system’s Python 3.9. If python3 --version shows that, install a newer Python and create the virtual environment with it, for example python3.12 -m venv .venv.

  1. Create an API key in the dashboard.
  2. Download the script and install requests in a virtual environment:
mkdir -p ~/report-job && cd ~/report-job
curl -fsSLO https://parsebridge.com/downloads/workflows/batch-pdf-to-markdown-python/process_pdf_reports.py
curl -fsSLO https://parsebridge.com/downloads/workflows/batch-pdf-to-markdown-python/requirements.txt
python3 --version   # needs 3.10 or newer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
  1. Load the key without writing it to a file or your shell history, and check it. A 401 means the key is wrong.
read -rs PARSEBRIDGE_API_KEY && export PARSEBRIDGE_API_KEY
curl -fsS https://api.parsebridge.com/v1/status -H "Authorization: Bearer $PARSEBRIDGE_API_KEY"

The script reads the key only from PARSEBRIDGE_API_KEY. Keep it out of the script, source control and logs.

  1. Put some reports in the input folder and run the job:
mkdir -p incoming-reports
cp /path/to/reports/*.pdf incoming-reports/
.venv/bin/python process_pdf_reports.py --input ./incoming-reports --output ./report-markdown
echo "exit status: $?"

The first run converts every PDF and prints Converted <filename> -> <hash>.md for each. Run the same command again with nothing changed and every file prints Skipped <filename>, with no API calls and no pages used. Replace one PDF with a corrected copy under the same name and the next run converts only that file. If one file is broken, the others still convert and the exit status is 1. Once you replace it, the next run picks it up.

The Markdown keeps headings, and tables become Markdown tables. Pages without usable embedded text get OCR. Check complex tables, such as ones with merged cells, before you rely on them. For how Docling compares with other parsers on the same PDFs, see our tested roundup of PDF to Markdown converters.

Run it every hour

Cron doesn’t inherit variables exported in your shell, so store the key in a file only you can read and start the job from a wrapper. These commands use the ~/report-job directory from above on a Linux machine, in the same shell where you ran read -rs:

cd ~/report-job
(umask 077; printf 'PARSEBRIDGE_API_KEY=%s\n' "$PARSEBRIDGE_API_KEY" > parsebridge.env)
cat > run.sh <<'EOF'
#!/bin/sh
set -eu
cd "$HOME/report-job"
set -a; . ./parsebridge.env; set +a
exec .venv/bin/python process_pdf_reports.py --input incoming-reports --output report-markdown
EOF
chmod 700 run.sh

Then add this line with crontab -e to run the job at the top of every hour:

0 * * * * flock -n "$HOME/report-job/run.lock" "$HOME/report-job/run.sh" >> "$HOME/report-job/run.log" 2>&1

flock -n comes with util-linux on most Linux distributions. It skips a run if the previous one still holds the lock, so a slow batch of scanned reports never overlaps with the next run. Cron won’t alert you about a nonzero exit, so check run.log for Failed and Stopped lines, or start the wrapper from a scheduler that alerts on failures.

The job needs a Parsebridge API key, and skipped reports use no pages. New accounts get 50 free pages once, with no credit card, enough to try the job on a few real reports. Plans start at $17/month for 5,000 pages. The API docs cover the upload format and error codes. If your reports already move through n8n, the n8n integration runs the same conversion as a workflow node.