Recipes / Google Workspace & Gmail

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.

Operations

Updated September 2026

← All recipes
STEP01TriggerNEW PDF ADDED TO AWATCHED DRIVE FOLDERSTEP02ParseDOCUMENT PARSED ANDFIELDS EXTRACTEDSTEP03WriteROW WRITTEN TO THE SHEETSTEP04FlagFILES THAT FAIL TO PARSEARE FLAGGED FOR A MANUALLOOKOPERATIONSSTEP 01TriggerNEW PDF ADDED TO AWATCHED DRIVE FOLDERSTEP 02ParseDOCUMENT PARSED ANDFIELDS EXTRACTEDSTEP 03WriteROW WRITTEN TO THESHEETSTEP 04FlagFILES THAT FAIL TOPARSE ARE FLAGGEDFOR A MANUAL LOOK
DwgHow it runs
SheetRecipe
RevSeptember 2026

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 steps
  1. Step 01Trigger

    New PDF added to a watched Drive folder

  2. Step 02ParseAI step

    Document parsed and fields extracted

  3. Step 03Write

    Row written to the Sheet

  4. 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?

Google DriveDocument AIGoogle Sheets

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 steps
  1. Create the script

    Open script.google.com in the account that owns the folder, create a project and paste the code below into Code.gs.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. Turn on failure emails

    In Triggers, set Failure notification settings on both triggers to notify you immediately.

The script

DrivePdfs.gs
// 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 need

Continues · 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.

Book a Consultation

We typically respond within 24 hours