Extract data from Drive PDFs into a Sheet
PDFs land in a Drive folder and someone opens each one to copy the numbers into a spreadsheet by hand.
PDFs added to a watched Google Drive folder can be parsed automatically, their fields extracted and written as a row in a Google Sheet, with any file that fails to parse flagged for a manual look. Evenops pairs Document AI with Apps Script so different document layouts are handled the same way and only the exceptions need a person.
How does it run?
4 stepsStep 01Trigger
New PDF added to a watched Drive folder
Step 02ParseAI step
Document parsed and fields extracted
Step 03Write
Row written to the Sheet
Step 04Flag
Files that fail to parse are flagged for a manual look
What do you get?
3 outcomes- ✓PDF data reaches the sheet without anyone retyping it
- ✓Different document layouts are handled the same way
- ✓Only the exceptions need a human
Which tools does it use?
Time saved
Replaces manual copying of PDF data into a sheet.What does the script do?
A time-driven Apps Script that watches a Drive folder, adds one row to a register sheet for each new PDF (when it was added, file name, link, status, and the fields you ask for), and tries to read those fields from the PDF's text. It asks Google to turn each PDF into a temporary Google Doc (which is how the text is read), takes that text, applies the patterns you write, and moves the temporary Doc to the bin. Anything it cannot read is still listed, with a status saying why, and a daily email counts the files that need a person. This works on clean, text-based documents with a consistent layout. It is not a general document parser.
What do you need first?
- A Drive folder where the PDFs land and its ID (the long string at the end of the folder's URL), and a Google Sheet for the register and its ID.
- An example or two of the documents, so you can write the patterns for the fields you want.
- A shared mailbox for the daily summary.
- About 40 minutes, most of it testing the patterns. No paid add-on.
How do you set it up?
8 stepsCreate the script
Open script.google.com in the account that owns the folder, create a project and paste the code below into Code.gs.
Switch on the Drive API service
In the editor, next to Services in the left menu, choose Add a service, pick Drive API, keep version v3 and add it. The script uses it once, to convert a PDF to a temporary Google Doc.
Fill in CONFIG
Set WATCH_FOLDER_ID, SPREADSHEET_ID and ALERT_TO. In FIELDS, write one entry per value you want: a name for the column and a regular expression with one bracketed group around the part to keep. The two examples read a document number and a total.
Run it once by hand
Put two or three sample PDFs in the folder, pick registerPdfs and press Run, and authorise Drive, Docs, Sheets and email access. Open the register: each file has a row, with Extracted, Check by hand or another status.
Tune the patterns
Where a field is missing, adjust its pattern and test again. Delete the rows you want re-read, so the script treats the files as new, and run it again.
Install the triggers
Pick installTriggers and press Run once. It schedules registerPdfs every 15 minutes and dailySummary at about 8 AM in the script's time zone, replacing earlier copies.
Agree the status column
Tell the team the rule: when they have fixed a row by hand, they change Status to Done. The daily email counts rows still marked Check by hand, No text found, Too large to read or Could not read.
Turn on failure emails
In Triggers, set Failure notification settings on both triggers to notify you immediately.
The script
// Registers new PDFs from a Drive folder in a Sheet and tries to read fields out of their text.
// Paste into Code.gs in a new project at script.google.com, edit CONFIG, switch on the Drive API
// advanced service (Services > Add a service > Drive API, v3), then run installTriggers() once.
const CONFIG = {
WATCH_FOLDER_ID: 'PASTE_THE_DRIVE_FOLDER_ID_HERE',
SPREADSHEET_ID: 'PASTE_THE_SPREADSHEET_ID_HERE',
SHEET_NAME: 'PDF register',
WINDOW_DAYS: 14, // only files changed in this many days are looked at
MAX_FILES_PER_RUN: 8, // reading a PDF takes seconds, so keep this small
MAX_READ_ATTEMPTS: 3, // a file that fails to convert is retried this many runs before it is marked Could not read
MAX_READ_BYTES: 2000000, // Google's help page says the file should be 2 MB or smaller
// Stop well before the 6 minute limit. On a gmail.com account the whole day's
// trigger time is 90 minutes, so lower this (for example 60 * 1000) there.
TIME_BUDGET_MS: 4 * 60 * 1000,
// Each field is a regular expression with ONE bracketed group around the value to keep.
FIELDS: [
{ name: 'Document number', pattern: '(?:invoice|order)\\s*(?:no\\.?|number|#)\\s*[:\\-]?\\s*([A-Z0-9\\-]*[0-9][A-Z0-9\\-]*)' },
{ name: 'Total', pattern: '\\btotal\\s*(?:due|amount)?\\s*[:\\-]?\\s*\\$?\\s*([0-9][0-9,]*\\.[0-9]{2})' },
],
ALERT_TO: 'ops@example.com',
ALERT_EVERY_MS: 6 * 60 * 60 * 1000,
};
const FIXED_HEADERS = ['Added', 'File', 'Link', 'Status', 'Notes'];
const NEEDS_A_PERSON = ['Check by hand', 'No text found', 'Too large to read', 'Could not read'];
function registerPdfs() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30 * 1000)) return;
const started = Date.now();
const problems = [];
const props = PropertiesService.getScriptProperties();
let attempts = {};
try { attempts = JSON.parse(props.getProperty('readAttempts') || '{}'); } catch (e) { attempts = {}; }
try {
const sheet = getSheet_();
const idCol = FIXED_HEADERS.length + CONFIG.FIELDS.length + 1; // 1-based position of 'File ID'
const lastRow = sheet.getLastRow();
// Read the register once and index it in memory.
const seen = {};
if (lastRow > 1) {
sheet.getRange(2, idCol, lastRow - 1, 1).getValues().forEach(function (r) { seen[r[0]] = true; });
}
const since = Utilities.formatDate(new Date(Date.now() - CONFIG.WINDOW_DAYS * 24 * 60 * 60 * 1000), 'UTC', 'yyyy-MM-dd');
const folder = DriveApp.getFolderById(CONFIG.WATCH_FOLDER_ID);
// The Drive service takes v2 style search terms, hence modifiedDate.
const files = folder.searchFiles('mimeType = "application/pdf" and modifiedDate > "' + since + '"');
const rows = [];
while (files.hasNext() && rows.length < CONFIG.MAX_FILES_PER_RUN) {
if (Date.now() - started > CONFIG.TIME_BUDGET_MS) break; // the next run picks up the rest
const file = files.next();
const id = 'f:' + file.getId(); // the prefix keeps Sheets from reading it as a number
if (seen[id]) continue;
const row = describe_(file, id, attempts, problems);
if (row) { rows.push(row); seen[id] = true; }
}
// One write for the whole run. A file is registered even when it could not be read,
// so it is never dropped: its Status says what a person has to do.
if (rows.length) {
sheet.getRange(lastRow + 1, 1, rows.length, idCol).setValues(rows);
}
} catch (err) {
problems.push('Run failed: ' + err.message);
} finally {
try { props.setProperty('readAttempts', JSON.stringify(attempts)); } catch (e) { /* only a retry counter */ }
lock.releaseLock();
}
alertOnce_(problems);
}
// Returns the register row, or null when the file should be tried again on the next run.
function describe_(file, id, attempts, problems) {
const base = [new Date(), safe_(file.getName()), file.getUrl()];
const blank = CONFIG.FIELDS.map(function () { return ''; });
if (file.getSize() > CONFIG.MAX_READ_BYTES) {
return base.concat(['Too large to read', 'Larger than ' + Math.round(CONFIG.MAX_READ_BYTES / 1000000) + ' MB'], blank, [id]);
}
try {
const text = readText_(file);
if (!text.trim()) return base.concat(['No text found', 'Probably a scan Google could not read'], blank, [id]);
const found = CONFIG.FIELDS.map(function (f) {
const m = new RegExp(f.pattern, 'i').exec(text);
return m ? safe_(m[1]) : '';
});
const missing = CONFIG.FIELDS.filter(function (f, i) { return found[i] === ''; }).map(function (f) { return f.name; });
delete attempts[id];
return base.concat(missing.length ? ['Check by hand', 'Not found: ' + missing.join(', ')] : ['Extracted', ''], found, [id]);
} catch (err) {
// The Drive service not being switched on is a set-up fault, not a problem with this file.
if (err.name === 'ReferenceError') throw new Error('Switch on the Drive API service (Services, Add a service, Drive API, v3)');
// Quota and service errors are usually temporary, so leave the file for the next run and
// only give up on it after MAX_READ_ATTEMPTS tries. Either way a person is told.
attempts[id] = (attempts[id] || 0) + 1;
problems.push(file.getName() + ' (try ' + attempts[id] + ' of ' + CONFIG.MAX_READ_ATTEMPTS + '): ' + err.message);
if (attempts[id] < CONFIG.MAX_READ_ATTEMPTS) return null;
delete attempts[id];
return base.concat(['Could not read', safe_(err.message)], blank, [id]);
}
}
// Google turns the PDF into a temporary Google Doc (it reads the text), we take the text, then bin the Doc.
function readText_(file) {
const temp = Drive.Files.create(
{ name: 'tmp-pdf-read-' + file.getId(), mimeType: MimeType.GOOGLE_DOCS },
file.getBlob(),
{ fields: 'id' }
);
try {
return DocumentApp.openById(temp.id).getBody().getText();
} finally {
DriveApp.getFileById(temp.id).setTrashed(true);
}
}
function getSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.insertSheet(CONFIG.SHEET_NAME);
if (sheet.getLastRow() === 0) {
const headers = FIXED_HEADERS.concat(CONFIG.FIELDS.map(function (f) { return f.name; }), ['File ID']);
sheet.appendRow(headers);
}
return sheet;
}
// Stops text taken from a PDF or a file name, like "=IMPORTXML(...)", running as a formula.
function safe_(value) {
const s = String(value);
return /^[=+@\t\r-]/.test(s) ? "'" + s : s;
}
// Keeps the held-back problems in a script property. Oldest entries are dropped first if the text
// would get long (a property value is limited to about 9 KB), and a failure to store never fails the run.
function saveQueue_(props, queue) {
try {
while (queue.length > 1 && JSON.stringify(queue).length > 6000) queue.shift();
props.setProperty('pendingProblems', JSON.stringify(queue));
} catch (e) { /* the problems are lost only if the store itself is down */ }
}
// Emails the problems to ALERT_TO, at most once every ALERT_EVERY_MS. Problems found while an email is
// being held back are kept in a script property and go out with the next one, so each is reported.
function alertOnce_(problems, force) {
const props = PropertiesService.getScriptProperties();
let queue = [];
try { queue = JSON.parse(props.getProperty('pendingProblems') || '[]'); } catch (e) { queue = []; }
(problems || []).forEach(function (p) {
const text = String(p).slice(0, 160);
if (queue.indexOf(text) === -1) queue.push(text);
});
queue = queue.slice(-30);
if (!queue.length) return;
const last = Number(props.getProperty('lastAlertAt') || 0);
if (!force && Date.now() - last < CONFIG.ALERT_EVERY_MS) {
saveQueue_(props, queue);
return;
}
try {
MailApp.sendEmail(CONFIG.ALERT_TO, 'PDF register needs attention (' + queue.length + ')', queue.join('\n'));
props.setProperty('lastAlertAt', String(Date.now()));
props.deleteProperty('pendingProblems');
} catch (err) {
saveQueue_(props, queue); // keep them for the next try
}
}
// Daily email: how many PDFs still need a person. Silent when there are none.
function dailySummary() {
try {
const sheet = getSheet_();
const lastRow = sheet.getLastRow();
if (lastRow < 2) return;
let waiting = 0;
sheet.getRange(2, 4, lastRow - 1, 1).getValues().forEach(function (r) {
if (NEEDS_A_PERSON.indexOf(r[0]) !== -1) waiting++;
});
if (!waiting) return;
MailApp.sendEmail(
CONFIG.ALERT_TO,
'PDFs waiting for a person: ' + waiting,
'Open the register: ' + sheet.getParent().getUrl() +
'\nFix the values by hand, then change Status to Done.'
);
} catch (err) {
alertOnce_(['Daily summary failed: ' + err.message]);
}
}
// Run once by hand. Safe to run again: it replaces the old triggers.
function installTriggers() {
ScriptApp.getProjectTriggers()
.filter(function (t) { return ['registerPdfs', 'dailySummary'].indexOf(t.getHandlerFunction()) !== -1; })
.forEach(function (t) { ScriptApp.deleteTrigger(t); });
ScriptApp.newTrigger('registerPdfs').timeBased().everyMinutes(15).create();
ScriptApp.newTrigger('dailySummary').timeBased().everyDays(1).atHour(8).create();
}
Every Apps Script method in this script was checked against Google's Apps Script reference (developers.google.com/apps-script/reference) on 11/10/2026. Limits come from Google's quota table, checked the same day. The Drive v3 conversion call was checked against Google's Drive API upload guide and the Apps Script advanced Drive service page, and the size and quality limits against Google Drive Help (Convert PDF and photo files to text), the same day.
What should you know before relying on it?
10 caveats- Quotas
- Google's quota table lists a 6 minute limit per execution, 20 triggers per user per script, and a total trigger runtime of 90 minutes a day on a gmail.com account or 6 hours a day on Google Workspace. This script has a trigger that runs every 15 minutes (96 runs a day) and a daily summary (one more). That is 97 runs a day in all, which on a gmail.com account works out to an average of about 56 seconds per run and on Workspace about 223. A run that finds nothing new is short, but a run that uses its full 4 minute budget costs 240 seconds, so about 22 of those use up a gmail.com day. Open Executions in the Apps Script editor to see how long your runs really take. Reading a PDF is the slow step, and the time depends on the file, so measure it. MAX_FILES_PER_RUN keeps one run short, and the time budget stops it early. Check Google's current table for any limit on documents created before you run thousands of files through it.
- How well it reads
- Google's help page on converting PDFs to text says the file should be 2 MB or smaller, text should be at least 10 pixels high, pages should be the right way up, and formatting such as bold or line breaks may not carry over. Bigger files are registered with the status Too large to read. A scan with poor quality can come back as No text found or with wrong characters, so treat every extracted value as a draft until a person has checked it.
- Patterns are brittle
- A pattern reads the first match in the text. A different supplier layout, a changed label, or a figure that appears twice can give the wrong value without any error. That is why a field that does not match leaves Check by hand, and why the number you act on should still be looked at. Different layouts for every supplier are where this approach stops being reliable.
- Nothing is dropped
- A file that is too big or has no text still gets a row, with a status and a note, so it cannot disappear. The daily email counts these until someone changes their status.
- Errors that clear by themselves
- A conversion that fails for a temporary reason, such as a service error or a quota, is not held against the file. It is left alone, reported to ALERT_TO, and tried again at the next run, and only after MAX_READ_ATTEMPTS (3) failed runs is it registered as Could not read. If the Drive API service has not been switched on, the script says so in the alert instead of blaming the file. If the daily summary itself fails, that is reported too.
- Idempotency
- Each file is recorded by its Drive ID (stored as text with an f: prefix). The register's ID column is read once per run and any file already there is skipped. All rows for a run are written in one call.
- Temporary documents
- Each conversion creates a Google Doc in the root of your Drive, which the script moves to the bin straight after reading it. If a run is killed in the middle, a file named tmp-pdf-read followed by the PDF's ID may be left behind: search for it and delete it.
- Window
- Only PDFs changed in the last WINDOW_DAYS (14) are looked at, which keeps each run short as the folder grows. If the script is off for longer than that, widen the window before restarting.
- Formula injection
- File names and text read from PDFs are controlled by outsiders. Any that start with an equals, plus, minus or at sign are prefixed with an apostrophe before they are written, so they cannot run as formulas.
- Ownership and access
- The script reads the folder as the account that installed it and the temporary Docs are created in that account's Drive. If that person leaves, the script stops. Install it from a dedicated shared Workspace user the business owns, and give that user access to the folder.
When should you call us?
This is a sound way to log documents and pick up simple, regular values. Call us when many layouts have to be read reliably, when scans or handwriting are involved, when tables and line items matter, when values must be validated against another system before they are trusted, or when someone has to be accountable for it working every day. That is the document-reading pipeline we build for clients, with a proper document parser and exception handling.
Tell us what you needContinues · Want this wired into your stack?
Want this wired into your stack?
Tell us your tools and the task. We'll scope it and build it as production code you own.
We typically respond within 24 hours
