Note: This is mostly a README I've generated by guiding AI to examine the project. My intent is to create enough docs here to be able to redeploy from scratch if I don't come back to this in a while.
I doubt anyone other than myself will actually use this. It's intended to be "good enough to get started" - please ping me in some manner if you actually intend to deploy this, and I will help you get it running.
Good luck. Godspeed.
- Ben, Feb. 2025
Event Flyer Parser is an automation tool that extracts structured event details from images (flyers) and logs them into Google Sheets. It monitors a Gmail inbox for incoming emails with event flyer attachments, uses an AI model to parse each flyer’s content, and outputs key information like event title, date, time, location, etc., as a JSON entry in a spreadsheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The project integrates Google Apps Script (for email handling and Google Sheets interaction) with a serverless GPU service on Runpod (for AI-powered image OCR and text extraction). This allows for end-to-end processing: from receiving an email to having a new row in a Google Sheet with all the event details. By automating flyer data entry, the project saves time and reduces errors in compiling event information.
Components:
- Gmail & Google Apps Script: The solution uses a Google Apps Script to automatically retrieve emails from Gmail. The script (
googleAppsScript.gs
) runs within Google’s environment and has access to Gmail, Google Drive, and Google Sheets APIs. It searches for unread emails in a specified Gmail inbox and processes those containing image attachments (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Google Drive: A Google Drive folder is used to store flyer images from the emails. When an email with image attachments is processed, each image is saved to the designated Drive folder (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The Drive folder ID is stored in the script configuration so the script knows where to save and retrieve images.
- Google Sheets: Two Google Spreadsheets are used:
- Inbox Spreadsheet – holds a RawData sheet (for unprocessed email data) and a Jobs sheet (to track AI processing jobs) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Processed Spreadsheet – holds a Processed sheet where final extracted event details are stored after successful processing (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Runpod API (AI Service): The heavy lifting of image analysis is done by a Python application (
app.py
) deployed on Runpod (a serverless GPU cloud). This application accepts requests via the Runpod API, downloads the images from Google Drive, runs a Transformer-based OCR/analysis model on them, and returns extracted text and event fields. The Runpod service provides an asynchronous endpoint URL for submitting jobs and checking their status. - AI Model & Prompt: The Python app uses a multimodal AI model (loaded via Hugging Face Transformers) capable of processing images with text. The model (by default
openbmb/MiniCPM-o-2_6
) is configured with vision support to perform OCR and interpret the flyer content (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). A custom system prompt (seeprompt.txt
) instructs the model to output event details in a JSON format with specific fields (event-flyer-parser/prompt.txt at main · sayhiben/event-flyer-parser · GitHub). Additionally, few-shot examples (sample images and expected outputs inexamples/
) are provided to guide the model (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub), improving accuracy.
Workflow:
-
Email Intake (Google Apps Script): A time-driven trigger invokes
processEmails()
periodically (e.g., every 5 minutes). This function searches for any unread emails in the inbox (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). For each unread email, it generates a unique UUID for tracking (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), then saves any image attachments to the specified Google Drive folder (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The script collects the email’s date, subject, body text, any URLs found in the body, and the Drive URLs/IDs of saved images. It appends a new row to the RawData sheet with these details and a “processed” flag set to"false"
(indicating this email’s flyer still needs processing) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). After logging the data, the email is marked as read and the email thread is moved to trash to prevent duplicate processing in the future (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). -
Job Creation (Google Apps Script -> Runpod): Another trigger invokes
launchRunPodJobs()
(ideally shortly afterprocessEmails()
runs). This function scans the RawData sheet for any entries withProcessed = false
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Each such row represents a flyer that has not been parsed yet. The script compiles all these pending entries into a submissions list (each submission has the UUID assubmissionId
and an array of image file IDs) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If there are one or more submissions, the script calls the Runpod endpoint via HTTP POST, sending a JSON payload containing the submissions array (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (The structure is:{"input": {"submissions": [ { "submissionId": "...", "imageIds": ["...", "..."] }, ... ]}}
.) The request includes the Runpod API key in the header for authorization (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Upon success, Runpod immediately returns a Job ID that references this batch of submissions for processing (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The Google script logs this job by adding a new row to the Jobs sheet with columns: Timestamp, JobID, Status (PENDING
initially), PollAttempts (0), NextPollMins (initially 5), and the JSON string of submissions (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). After logging the job,launchRunPodJobs()
schedules a follow-up trigger to callpollRunPodJobs()
in a few minutes (using Apps Script’s time-based trigger) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This kicks off the asynchronous polling loop. -
Asynchronous Processing & Polling: The Runpod service, upon receiving the job, queues it and then runs the
app.py
code to process the flyer images. Meanwhile, the Google Apps ScriptpollRunPodJobs()
function will wake up at the scheduled time (e.g., 5 minutes later) to check on all active jobs. WhenpollRunPodJobs()
runs, it performs the following:- It first cancels any earlier pending triggers for itself to avoid overlap (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- It reads all entries in the Jobs sheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) and iterates through each job that is not yet marked COMPLETED or FAILED (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). For each such job, it checks how long it’s been since the last status update (the script updates a timestamp each poll) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If the configured waiting time (
NextPollMins
) hasn’t passed yet, the script skips polling that job this round and notes the earliest time a poll is needed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If a job is due for polling (enough time elapsed), the script calls
checkRunPodJobStatus(jobId)
to query the Runpod status API (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This function constructs the status URL by taking the original endpoint URL and replacing/run
with/status/
, then appending the Job ID (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It sends a GET request with the same API key auth and parses the response (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The Runpod response contains astatus
field (which can be “IN_PROGRESS”/“RUNNING”, “COMPLETED”, or “FAILED”) and, if completed, anoutput
field with the results (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Based on the status result:
- If the job is still running (
status
not yet completed),pollRunPodJobs()
updates the Jobs sheet row: it sets Status to “RUNNING”, increments the PollAttempts count, and uses an exponential backoff algorithm to increase NextPollMins for the next check (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (The backoff logic is: after the first poll, wait 1 minute; thereafter double the interval up to a max of 10 minutes (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).) The script will mark that it still has running jobs and will schedule itself to run again at the earliest NextPollMins needed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If the job is completed (
status
== "COMPLETED"), the script sets the job’s Status to “COMPLETED” in the sheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) and callsfinalizeJobResults(outputData, submissionsStr)
to process the returned data (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (Here,outputData
is thedata.output
from Runpod which should be an array of results, andsubmissionsStr
is the JSON string of the original submissions for cross-reference (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).) - If the job failed (
status
== "FAILED"), the script updates the Jobs sheet Status to “FAILED” (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) and callsmarkSubmissionsAsFailed(submissionsStr)
to mark all related entries in RawData as failed (so they won’t be retried unless manually handled) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If the job is still running (
- After checking all active jobs, if any jobs are still in progress,
pollRunPodJobs()
sets a new trigger to run itself after the shortest wait among them (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If none are pending, it logs that no further polling is needed at this time and stops until the next external trigger or new job.
-
AI Processing (Runpod, behind the scenes): When Runpod executes the
app.py
for a submitted job, it invokes thehandle_inference(event)
function with the payload we sent. The Python code then:- Loads the AI model and tokenizer into GPU memory (if not already loaded from a previous run) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). This can take a few seconds especially on the first run as the model (2.6B parameters) is loaded. If model initialization fails for any reason, it returns an error in the output (which the Apps Script would interpret as a failed job) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub).
- Loads the system prompt from
prompt.txt
and the few-shot examples fromexamples/examples.json
(event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). The few-shot examples are formatted as a short dialogue: each example includes one or more images of sample flyers and the assistant’s JSON answer (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). These are prepended to the model’s input to provide context. - Initializes a Google Drive API service using a service account credential (from the
GOOGLE_SERVICE_ACCOUNT_KEY
env variable) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). This allows the app to download images from the Drive folder. (The service account must have access to the folder – see Setup Instructions below.) - For each submission in the
event["input"]["submissions"]
list, the app downloads all images by their file IDs from Drive (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) using the Drive API. Each image is loaded into memory as a PIL image object. If any image cannot be retrieved, it logs an error but proceeds to the next steps (the error would likely result in a failed parse for that submission) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). - The model is then invoked in a multi-modal chat format. It constructs a message list consisting of: all few-shot example Q&A pairs, followed by a final user message that contains both the new images and the system instruction prompt (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). (Using the model’s API, the user content is provided as a list where images and text prompt are combined:
{"role": "user", "content": [ image1, image2, ..., system_prompt_text ]}
(event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub).) - The model’s
.chat
method is called with these messages to generate a response (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). The output from the model (hopefully) is a JSON string containing the event details as per the prompt format. For example, the model is instructed to produce an object with keys like "date", "time", "title", "description", "city", "state", etc., up to "extracted_text" (the full text from the flyer) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The strict prompt guidelines ensure the model returns exactly one JSON object and no extra commentary (event-flyer-parser/prompt.txt at main · sayhiben/event-flyer-parser · GitHub). - The app collects the model’s answer along with the submissionId in a result list. If the model raises an exception or any error occurs during inference for that submission, the error is caught and logged, and the function will return whatever results have been gathered so far (or an error structure) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub).
- Once all submissions in the batch are processed, the function returns the list of results (each result contains a
submissionId
and ananswer
string). Runpod wraps this output as the job’soutput
. For example, a successful output might look like:[ {"submissionId": "123e4567-e89b-12d3-a456-426614174000", "answer": "{ \"date\": \"2025-08-30\", \"time\": \"6:00 PM\", ... }"} ]
.
-
Result Integration (Google Apps Script): When
finalizeJobResults()
is invoked (after an AI job completes), it takes the returned job data (the list of{submissionId, answer}
) and the original submissions list (as a JSON string) to map results back to the RawData entries (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Its steps:-
Parse the submissions JSON to know which
submissionId
values were in this job (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It builds a lookup from submissionId to the row index in RawData (by scanning the RawData sheet’s "UUID" column) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). -
For each item in the job’s results, it attempts to extract a JSON object from the
answer
text returned by the model (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). TheextractJsonFromText()
helper looks for the first “{” and last “}” in the answer and tries toJSON.parse()
that substring (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This is done in case the model’s response has extra text; it ensures we isolate a valid JSON. If parsing is successful, we get a JavaScript object (extracted
) with keys like date, time, title, etc. If it fails (no JSON or parse error), the code logs that and marks the result as a failure for that submission (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). -
It then updates the corresponding RawData row for each submission:
- If there was a valid extracted JSON, it sets that row’s "Processed" flag to
"true"
(meaning this entry was processed) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If the result was a failure (no JSON could be extracted, or the AI output indicated failure), it calls
markRowFailed
on that row instead, which will set the "Processed" flag to"failed"
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (The script uses"failed"
to denote that an attempt was made but no data was extracted, so it won’t keep retrying this entry unless manually reset.)
- If there was a valid extracted JSON, it sets that row’s "Processed" flag to
-
For each successful extraction,
finalizeJobResults
also appends a new row to the Processed sheet in the Processed Spreadsheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The appended row contains the UUID and all the fields from the extracted JSON object in a predefined column order (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The code expects the JSON to have the following 12 keys (if a key is missing, it will just use an empty string):- UUID (the unique ID linking back to RawData) – for traceability.
- Date – date of the event (e.g.
"2025-08-30"
). - Time – time of the event (e.g.
"6:00 PM"
). - Title – title or name of the event.
- Description – description or subtitle of the event.
- City – city where the event is taking place.
- State – state/region of the event.
- Address – street address or venue name.
- Meeting Location – specific meeting point or location details (if separate from address, e.g., a landmark or corner).
- Links – any relevant URLs or social media links related to the event.
- Sponsors – organizing groups or sponsors of the event.
- Image – (possibly a link or name of the source image; this key is prepared to record the image reference or filename).
- Source – source of the information (could be the email or organization; the prompt might fill this with context, or it might be left blank if not applicable).
- Extracted Text – the full text extracted from the flyer image (useful for verification or if manual review is needed).
These columns correspond to the Processed sheet’s structure (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). After appending the row, the event details are now permanently recorded in the Processed spreadsheet for easy viewing or sharing.
-
Once all submissions in that job are handled,
finalizeJobResults
ends. The pipeline for that batch is complete. Any entries marked failed will remain in RawData with "failed" status; one can review those manually or adjust parameters and clear the flag to retry if desired.
-
Overall, the architecture ensures that the potentially slow AI processing (which involves downloading images and running a large model) does not block the email retrieval or the Google Apps Script execution. The use of the Jobs sheet and polling mechanism allows the script to hand off work to Runpod and come back later for results, working around Apps Script execution time limits. Google Apps Script coordinates the workflow and data storage, while the Runpod-hosted Python app performs the intensive OCR and parsing task.
Follow these steps to install and configure the Event Flyer Parser in your environment:
- Create the “Inbox” Spreadsheet: In your Google Drive, create a new Google Spreadsheet (e.g., named "Event Parser Inbox"). Inside this spreadsheet, add two sheets (tabs) named RawData and Jobs.
- In RawData, set up a header row (row 1) with the following columns:
Date
,UUID
,Subject
,Body
,Links
,Image URLs
,Image IDs
,Processed
. This is the format the script expects when appending data (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (While the script will function without headers, having them helps in understanding the data. The script treats the first row as header and will start adding emails from row 2 onwards.) - In Jobs, set a header row with:
Timestamp
,JobID
,Status
,PollAttempts
,NextPollMins
,Submissions
. The script will create this sheet if it doesn’t exist, but you can create and label it upfront for clarity (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Each job submitted to Runpod will be logged as a new row here.
- In RawData, set up a header row (row 1) with the following columns:
- Create the “Processed” Spreadsheet: Create another Google Spreadsheet (e.g., "Event Parser Processed"). Add a sheet (tab) named Processed. Set up a header row with the columns:
UUID
,Date
,Time
,Title
,Description
,City
,State
,Address
,Meeting Location
,Links
,Sponsors
,Image
,Source
,Extracted Text
. These correspond to the fields the script will output for each event (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). When events are parsed, each will be appended as a new row under these headers. - Create a Drive Folder for Images: In Google Drive, create a folder where flyer images will be stored (e.g., "Event Parser Images"). Note the Folder ID from the URL. (The folder ID is the long string in the URL when you open the folder in Drive. For example, in
https://drive.google.com/drive/u/0/folders/ABC123XYZ456
, the ID isABC123XYZ456
.) - Share the Folder with Service Account: (This step will connect to Runpod setup later, but it’s convenient to mention here.) You will create a Google Cloud service account in step 3. Once you have its email, share the Drive folder you just created with that service account’s email (give Viewer or Editor access). This allows the Python app running on Runpod to download images from your Drive. You can also set the folder’s access to “Anyone with link can view” as an alternative, but sharing with the service account is more secure.
Since the Runpod service needs to download images from your Google Drive, it requires credentials. We will use a Google Cloud service account for this:
- Go to the Google Cloud Console, create a new project (or use an existing project) to house the service account.
- Enable the Google Drive API for this project (APIs & Services > Library > enable Google Drive API).
- Create a new Service Account (IAM & Admin > Service Accounts > Create Service Account). Give it a name like "event-parser-sa".
- Assign roles: For Drive access, you can give it the "Viewer" role on Drive, or more specifically, enable the scope later. You might not need to assign a role in Cloud IAM for accessing a shared folder, but to be safe, you can skip assigning any specific GCP role here since we’ll use the API scope directly.
- After creation, go to the service account’s Keys section and add a new key (JSON). Download the JSON key file.
- Open the JSON file in a text editor. You will need its content for the Runpod container. Specifically, in the next step we will set an environment variable
GOOGLE_SERVICE_ACCOUNT_KEY
with this JSON content (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub).
(Reminder: As mentioned earlier, make sure the Drive folder from step 1 is shared with the service account’s email. The email is listed in the JSON under client_email
.)
Sign up for a Runpod account if you haven’t already. Runpod provides “serverless GPU” endpoints which we will use to run the app.py
for parsing flyers.
- Create a new Endpoint: In Runpod’s dashboard, create a new Serverless Endpoint. You will be asked to choose a base container or provide one. There are two ways to deploy:
- Option A: Use Pre-built Image – The project’s Docker image may be available at
sayhiben/minicpm-o-2.6-events-parser:latest
(as indicated in the Makefile (event-flyer-parser/Makefile at main · sayhiben/event-flyer-parser · GitHub)). If this image is published (check Docker Hub forsayhiben/minicpm-o-2.6-events-parser
), you can use it directly. In Runpod, specify this image name and tag. - Option B: Build from Source – If you prefer or if the image is not available, you can build your own. Use the provided
Dockerfile
to build the image, or let Runpod build from the GitHub repository:- You might upload the repository code to your own GitHub (or use this one if public) and have Runpod connect to it. Ensure the Dockerfile is present.
- Alternatively, build locally using the Makefile (run
make docker
which executes a build command as shown in the Makefile (event-flyer-parser/Makefile at main · sayhiben/event-flyer-parser · GitHub)) and push to a container registry (like Docker Hub or GitHub Packages), then use that image in Runpod.
- Option A: Use Pre-built Image – The project’s Docker image may be available at
- Select GPU and Resources: Choose an appropriate GPU type and memory for the endpoint. The model is ~2.6B parameters and loaded in bfloat16; a GPU with at least ~10GB memory is recommended (for example, NVIDIA T4, RTX A10G, or better). Runpod will also ask for CPU/Memory allocation; the defaults should suffice (the heavy load is on GPU and memory for the model).
- Set Environment Variables: In the endpoint configuration, add an environment variable named
GOOGLE_SERVICE_ACCOUNT_KEY
. Paste the entire JSON content of the service account key file as the value (you can compress it to one line or ensure the JSON formatting is preserved as needed – Runpod should handle the JSON string, but if issues arise, escape quotes or encode it). This environment variable is read inapp.py
to initialize the Drive API client (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). No other env vars are strictly required; the model name and paths are hard-coded in the app (MODEL_NAME, SYSTEM_PROMPT_PATH, etc.). Optionally, if you want to override the model or provide a Hugging Face token (for private models), you would set those here, but by default it’s not needed. - Deploy: Deploy/start the endpoint. Wait for the endpoint to be ready – it may need to download the model weights on first startup. You can monitor the endpoint logs in Runpod; you should see messages once it’s up (or when it processes a request).
- Note the Endpoint URL: Once deployed, Runpod will provide an endpoint URL for invoking the service. It typically looks like:
https://api.runpod.ai/v2/<ENDPOINT_ID>/run
(Runpod might also show the endpoint ID separately. The Apps Script needs the full URL that ends with/run
to submit jobs, which it then transforms to/status/<jobId>
for polling (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).)
Also note/generate a Runpod API Key (available under your account settings or the endpoint settings). This key is required for the Apps Script to authenticate to the Runpod API.
Now configure the Google Apps Script that ties everything together:
-
Create a Script Project: You can create a standalone script by visiting script.google.com and creating a New Project. Alternatively, you can bind the script to the "Inbox" Google Sheet (open the sheet, go to Extensions > Apps Script). A standalone script is fine since we’ll use explicit IDs for Sheets.
-
Copy the Code: Open the
googleAppsScript.gs
file from this repository. Copy its entire content and paste it into the script editor (replace any default function that might be there). The script is written in JavaScript (Apps Script environment). It contains all the functions (processEmails, launchRunPodJobs, etc.) described in the Architecture section. -
Set Script Properties: In the Apps Script project, click on Project Settings (the gear icon), then find the Variables / Properties section (in older editor it’s under File > Project Properties > Script Properties). Add the following keys and their values:
INBOX_SPREADSHEET_ID
– the ID of the Inbox spreadsheet (RawData/Jobs). This is found in the Google Sheets URL. For example, inhttps://docs.google.com/spreadsheets/d/abcd1234EFGH5678/edit#gid=0
, the ID isabcd1234EFGH5678
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).PROCESSED_SPREADSHEET_ID
– the ID of the Processed spreadsheet (Processed sheet for results) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).DRIVE_FOLDER_ID
– the ID of the Drive folder where images are saved (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).RUNPOD_ENDPOINT_URL
– the full Runpod endpoint URL for running jobs (the one ending in/run
that you noted in step 3) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).RUNPOD_API_KEY
– your Runpod API key (you can label it asBearer <token>
or just the token string; the script already prefixes with "Bearer " when sending, so just use the raw token) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
Make sure there are no extra spaces and that the keys match exactly those names (they are case-sensitive in the script). The script will fetch these properties at runtime (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
-
Authorize Services: Since the script uses Gmail, Drive, and Sheets, the first time you run it you’ll be prompted to authorize these scopes. In the script editor, go to Run > Run Function > select
processEmails
(for instance). It will ask for permissions; review and allow access to your Gmail, Google Drive, and spreadsheets. (Alternatively, deploying as a web app or clicking the trigger might also prompt later. It’s good to do an initial test run manually to handle authorization.) -
Set Triggers: Click on the clock icon (Triggers) in the left sidebar of the script editor (or go to Edit > Current Project’s Triggers). Add the following triggers:
- Trigger processEmails to run periodically. For example, set it to Time-driven, “Every 5 minutes” or a schedule that suits how frequently you receive event emails. (Every minute is possible but may be overkill and could hit Gmail rate limits; every 5 minutes is a common choice.)
- Trigger launchRunPodJobs to run periodically. This can also be every 5 minutes, but offset from processEmails. For instance, you could set
processEmails
at :00, :05, :10 minutes of the hour andlaunchRunPodJobs
at :02, :07, :12, etc. If your script environment doesn’t allow fine-grained offsets, running both every 5 minutes is generally okay:processEmails
will usually finish quickly, andlaunchRunPodJobs
will find any new RawData entries almost immediately after. They are idempotent when no new data is present (they simply do nothing if there’s nothing to process).
Note: The
pollRunPodJobs
function should not be manually scheduled. It is invoked bylaunchRunPodJobs
and reschedules itself as needed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). So you only need triggers for the initial two functions above. -
Test the Setup: To verify, send an email to the Gmail account with a sample flyer image attached (or use one of the example images from the repository). Within the next trigger cycle:
- The email should be moved to trash (after being processed).
- A new row should appear in the RawData sheet with the email’s info and Processed = false.
- A job entry should appear in the Jobs sheet when the script submits to Runpod (Status PENDING or RUNNING).
- After a few minutes (depending on model runtime), the RawData entry’s Processed flag should turn "true" or "failed", and for "true", a new row should be added in the Processed sheet with the extracted event details.
- If things don’t appear to work, check the script’s logs and the Runpod logs (see Debugging tips in a later section).
- Apps Script Dependencies: The script uses built-in Google services (GmailApp, DriveApp, SpreadsheetApp). You do not need to enable advanced services or import libraries for these – they are available by default in Apps Script. Just ensure the account running the script has access to the Gmail inbox, the Drive folder, and the spreadsheets (using the same Google account for all is simplest).
- Runpod Python Dependencies: The Docker image is configured to include all necessary Python packages. Key dependencies (as seen in
requirements-cuda.txt
) includetorch
(PyTorch for the model),transformers
(Hugging Face Transformers library),google-api-python-client
(for Drive API), andPillow
(for image handling). The Dockerfile also sets up caching for model downloads. There is no direct need for you to install anything manually in Runpod; deploying the container will handle this. If running the Python app locally for development, you should install the requirements in a Python environment that has GPU access. - Email Filtering: The Gmail search query in
processEmails
is currently'in:inbox is:unread'
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), which means it will grab all unread emails in the inbox. If you want to limit to specific senders or subjects (e.g., if that inbox receives other emails), you can modify the query. For example, you might use a label or a keyword and update the search string accordingly (e.g.,label:event-flyers is:unread
). Make sure to also update how you handle those emails (e.g., if using labels instead of trash to archive processed emails). - Quotas and Limits: Free Gmail accounts have sending/reading quotas via Apps Script (around 100 emails/day for free consumer accounts) and reading a large number of emails can be slow. If you expect high volume, consider using a Google Workspace account which has higher quotas or splitting the work. Similarly, Google Drive API (via the service account) has download quotas – but since images are small and likely only downloaded a few at a time, this should not be an issue under normal use.
- Runpod Costs: Be aware that running a GPU endpoint on Runpod will incur costs based on usage time. The script is designed to submit jobs on demand and the endpoint will only run when jobs are submitted (serverless model). Still, the model load time means each job might keep the GPU busy for a couple of minutes. Monitor your Runpod usage and consider shutting down the endpoint when not in use, or exploring an alternative like a local server if continuous usage is needed.
With the above steps completed, the system should be fully operational. New event flyer emails will be auto-processed and the data will accumulate in your Google Sheet without manual intervention.
This section provides technical details on the API calls between the Apps Script and Runpod, as well as explanations of key functions and data structures.
Job Submission (POST /run): The Google Apps Script sends an HTTP POST request to the Runpod endpoint URL (stored as RUNPOD_ENDPOINT_URL
) to start a job (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The request body is a JSON object with an input
field containing our submissions array (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub):
{
"input": {
"submissions": [
{
"submissionId": "<UUID>",
"imageIds": ["<ImageID1>", "<ImageID2>", ...]
},
... (more submissions if multiple unprocessed entries)
]
}
}
Each submission
corresponds to one row in RawData (one email) and includes the unique UUID and an array of Google Drive file IDs for images from that email (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (Even if there is only one image, it is wrapped in an array.)
The script sets an HTTP header Authorization: Bearer <RUNPOD_API_KEY>
for authentication (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Content type is application/json
. The Runpod API expects this format for serverless endpoints.
Submission Response: On a successful request, the Runpod service responds with a JSON containing a job identifier. The code captures respData.id
or respData.jobId
from the response (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). For example, the response may look like:
{
"id": "abcd-efgh-1234-5678",
"status": "IN_QUEUE"
}
or
{
"jobId": "abcd-efgh-1234-5678"
}
The script logs the HTTP status and response (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If the status is not 200/201, it logs an error and does not create a Jobs entry (meaning the job submission failed) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). On success, the extracted jobId
is stored in the Jobs sheet with status PENDING (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
Job Status (GET /status/{jobId}): To check progress, the script calls the status endpoint. The URL is derived by replacing the .../run
part of the endpoint URL with .../status/
and appending the Job ID (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). For example:
https://api.runpod.ai/v2/<ENDPOINT_ID>/status/abcd-efgh-1234-5678
The request is a GET with the same Authorization: Bearer <API_KEY>
header (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If the job is still running or queued, the response might look like:
or
{ "status": "IN_PROGRESS" }
{ "status": "RUNNING" }
. In some cases, if the job is very quick, the first poll might already get a completed status. - If the job completed successfully, the response will include
status: "COMPLETED"
and anoutput
field. Theoutput
is the return value from ourhandle_inference
function – which in our case is an array of{submissionId, answer}
objects for each submission. For example:{ "status": "COMPLETED", "output": [ {"submissionId": "1234-uuid-5678", "answer": "{ \"date\": \"2025-08-30\", ... }"}, ... ] }
- If the job failed (e.g., an unhandled exception, or the container crashed), the response may have
status: "FAILED"
and possibly anerror
message or no output.
The Apps Script’s checkRunPodJobStatus
function interprets the response as follows (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub):
- It defaults any unknown status to "RUNNING" (so if
data.status
is missing or not"COMPLETED"
/"FAILED"
, it assumes the job is still in progress) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If status is "COMPLETED" and
data.output
exists, it returns an object{ status: 'COMPLETED', data: data.output }
to the caller (thepollRunPodJobs
loop) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If status is "FAILED", it returns
{ status: 'FAILED', data: null }
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - In case of an HTTP error (non-200 response) or exception, it logs an error and returns
null
to indicate the poll attempt didn’t succeed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The polling logic treats anull
as a transient failure and will retry with backoff (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
Output Data Structure: The output data (when status is COMPLETED) is expected to be an array of results. Each result corresponds to one submission from the input and has:
submissionId
– matches the UUID from input.answer
– the raw text output from the AI. We instruct the AI to output a JSON object as text, so this string usually starts with{
and ends with}
. In some cases, the model might include some explanatory text (we try to avoid that with the prompt). Our script will attempt to extract the JSON portion from this string (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
Example of answer
string (as returned by AI) for a flyer might be:
"{ \"date\": \"August 30, 2025\", \"time\": \"6:00 PM\", \"title\": \"Community Rally\", \"description\": \"Gathering for neighborhood safety...\",
\"city\": \"Springfield\", \"state\": \"IL\", \"address\": \"123 Main St\", \"meeting_location\": \"Central Park\", \"links\": \"http://example.com/info\", \"sponsors\": \"ABC Org\", \"image\": \"IMG_2025_0830.jpg\", \"source\": \"Email Newsletter\", \"extracted_text\": \"Join us on August 30... (full text) ...\" }"
(Note: It’s a JSON string inside a string in the output JSON, which is why it has escaped quotes. The Apps Script will parse it as text and then extract the JSON content.)
RawData Sheet: Each row in RawData represents one email (one potential event submission). Columns (as mentioned) are:
- Date – The date/time the email was received (the script uses
message.getDate()
which is the timestamp of the email) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This is inserted as a Date object in the sheet (which Google Sheets will format; you can format the column as desired). - UUID – A unique identifier generated for that email using
Utilities.getUuid()
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This is used to track the submission through the pipeline and match results. - Subject – Email subject line (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Body – The plain text body of the email (
message.getPlainBody()
) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (The script does not include HTML content; if your flyers come with HTML emails, you might adjust to use getBody() and strip HTML as needed. Plain body is usually fine for extracting URLs.) - Links – Any URLs found in the email body. The script uses a regex to find
http://
orhttps://
links and joins them into a comma-separated string (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Image URLs – URLs to the images saved in Drive. After saving attachments, the script gets each file’s URL via
file.getUrl()
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). These are the standard Drive file share URLs (which will only work for authorized users – mainly for reference). - Image IDs – The Google Drive file IDs of the saved images (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). These are what we send to Runpod for downloading the images. If multiple images, they are stored as a comma-separated list in this cell.
- Processed – A flag indicating processing status. Initially the script writes
"false"
(as text) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Later, upon completion, this will be set to"true"
or"failed"
:"true"
means the event was processed and data is in the Processed sheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub)."failed"
means processing was attempted but no result could be extracted (or an error occurred) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The script marks failed inmarkRowFailed
by setting this cell to 'failed' (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
The RawData sheet allows you to see what was extracted from the email and serves as a log of all incoming flyers. If you re-run the script on an email, it would generate a new UUID and new row (the script doesn’t currently prevent duplicate processing except by marking them read and moved to trash).
Jobs Sheet: Each row in Jobs sheet logs a Runpod job submission. Columns:
- Timestamp – When the job was created (the script uses
new Date()
at job submission time) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This gets updated to a new timestamp on each poll attempt to record last check time (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - JobID – The Runpod job ID returned by the API (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This is used by the script to poll for status.
- Status – Can be
PENDING
,RUNNING
,COMPLETED
, orFAILED
.PENDING
is set right when we create the job (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (Runpod might start it immediately, but we mark pending until first poll.)RUNNING
is set during polling if the job isn’t done yet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).COMPLETED
orFAILED
is set when we determine the final state (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- PollAttempts – Number of times we have polled the status for this job (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Starts at 0 when job is created, then increments on each poll where the job is still running. This helps implement backoff and can be used to troubleshoot if a job took many polls.
- NextPollMins – The current waiting interval before next poll (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Initialized to 5 minutes for a new job (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). After first poll, it might reduce to 1 (for quick check) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), and then double each time up to 10 minutes max (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This dynamic scheduling is to balance not overloading the API with too frequent requests vs. getting results promptly.
- Submissions – A JSON string of the submissions that were sent in this job (essentially the same as the payload we POSTed, but stringified) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It includes all the
submissionId
andimageIds
that were processed together. This is used byfinalizeJobResults
andmarkSubmissionsAsFailed
to know which RawData entries correspond to the job results (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Storing this also allows post-mortem debugging: you can see which items were in a job if something went wrong.
The Jobs sheet is mainly for internal tracking and debugging; end users might not need to look at it often, but it’s useful for understanding system behavior.
Processed Sheet: Each row is an extracted event. Columns (as noted in Setup) are:
- UUID, Date, Time, Title, Description, City, State, Address, Meeting Location, Links, Sponsors, Image, Source, Extracted Text.
This sheet is the final output that one would use. For example, you might share this spreadsheet with others or use it as a data source for an event calendar. Because each event’s details are structured in separate columns, it’s easy to filter or search (e.g., filter by City or Date).
The script appends to this sheet in the same order for consistency (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If any of the fields are missing in the flyer, they will just be blank in that cell. You might see blank City/State if the flyer didn’t include location, etc., or blank Links if none were found.
One additional note: The Image column in Processed is meant to hold some identifier of the source image. In the current implementation, the AI model is prompted to include an "image" field in its JSON output (maybe the file name or a reference). Since the model has access to the image, it could output something like the original file name (if it was in the prompt) or some label. However, in our current prompt, we don’t explicitly provide the file name to the model, so it might not populate the "image" field meaningfully (it could even echo a part of text mistakenly thinking it’s an image name). This field can be used or ignored depending on needs. The Source field is similarly optional; you might instruct the model to fill it with the email sender or subject if it adds value, but currently it might be blank or a static value from the prompt context.
Here’s a closer look at key functions in googleAppsScript.gs
and their roles:
-
processEmails()
– Entry point for email ingestion. It usesGmailApp.search()
to find unread threads (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), then iterates through each message in those threads (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). For each unread message:- Generates
uuid = Utilities.getUuid()
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Gets
subject
andbodyText
from the email (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Extracts
links
via regex on the body text (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Fetches attachments with
message.getAttachments({includeInlineImages: false})
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Inline images are skipped to avoid picking up email signature logos, etc. - Saves each attachment that is an image (
Content-Type
starts with"image/"
) to the configured Drive folder (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It usesfolder.createFile(blob)
to save, then gets the file’s URL and ID (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Compiles arrays of image URLs and image IDs, then joins them into comma-separated strings (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Appends a row to RawData with date, uuid, subject, body, links, image URLs, image IDs, "false" (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Marks the message as read and moves the whole thread to trash (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
It logs progress at each step for debugging (e.g., how many links found, how many attachments, etc.) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If no unread emails are found, it simply logs and exits (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This function is safe to run repeatedly; it only processes unread mails and then marks them read, so it won’t double-handle the same message.
- Generates
-
launchRunPodJobs()
– Prepares and sends the API request to Runpod.- Opens the Inbox spreadsheet and RawData sheet, and reads all data into an array (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Iterates from the second row down, looking for entries where the Processed flag (column 8) is "false" (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (It lowercases the value to avoid case issues (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).)
- For each unprocessed entry, it retrieves the UUID and the Image IDs string (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It splits the Image IDs by comma into an array and filters out any empty values (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If there is at least one image ID, it pushes an object
{ submissionId: <UUID>, imageIds: [<ID1>, <ID2>, ...] }
into thesubmissions
array (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - After scanning all rows, if
submissions
is empty, it logs that nothing needs processing and exits (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If there are submissions, it logs how many and sends the POST request via
UrlFetchApp.fetch()
to RUNPOD_ENDPOINT_URL with the prepared JSON payload (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It wraps the submissions in aninput
object as required (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - On successful response, it parses the JSON and extracts the job ID (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It then logs the job creation and appends a new row to the Jobs sheet with the schema discussed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Finally, it uses
createOneTimeTrigger('pollRunPodJobs', 5)
to schedule the first poll 5 minutes later (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (ThecreateOneTimeTrigger
function usesScriptApp.newTrigger().timeBased().after(ms).create()
to schedule an execution in the future (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).) - If any error occurs during the API call (e.g., network issue or non-200 response), it logs the error and does not schedule the poll (so that those submissions remain "false" and can be retried on the next trigger run) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
-
pollRunPodJobs()
– Checks the status of all jobs and updates sheets accordingly.- Removes any existing trigger for itself at the start to avoid duplicate concurrent runs (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (this prevents an accumulation of triggers if one job’s poll scheduling overlaps with another’s).
- Reads the Jobs sheet data into an array and iterates through each job row (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Skips header and any job already marked COMPLETED/FAILED (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- For each active job, calculates minutes since last update (current time minus the Timestamp column) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If this difference is less than NextPollMins, it means it’s not time to poll yet: it notes that a job is still running (
anyStillRunning = true
) and computes how many minutes remain until we should poll (NextPollMins - elapsed) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It keeps track of the smallest such remaining time asearliestNextPoll
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If enough time has passed (diffMins >= nextPollMins), it proceeds to poll:
- Calls
checkRunPodJobStatus(jobId)
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Updates the Timestamp in Jobs sheet to now (to record that we polled now) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If
checkRunPodJobStatus
returnednull
(meaning a transient error), it logs a warning and does the following: increment PollAttempts, calculate a newNextPoll viagetNewNextPollInterval
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), update PollAttempts, NextPollMins in the sheet, set Status to "RUNNING" (assuming the job is likely still running) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), and mark to continue polling (anyStillRunning = true
). It also updatesearliestNextPoll
if the new interval is the smallest. - If a valid
pollResult
is returned:- If
pollResult.status === 'RUNNING'
: The job isn’t finished yet. Similar to above, increment attempts, backoff NextPoll, mark status RUNNING (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), set anyStillRunning, update earliestNextPoll. - If
pollResult.status === 'COMPLETED'
: Log completion, set Status = "COMPLETED" in sheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), and callfinalizeJobResults(pollResult.data, submissionsStr)
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Here,pollResult.data
is the array of results, andsubmissionsStr
is the JSON string from the Jobs sheet (the code saved it earlier when creating the job) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). After this call, the RawData and Processed sheets are updated for that job. - If
pollResult.status === 'FAILED'
: Log failure, set Status = "FAILED" (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), and callmarkSubmissionsAsFailed(submissionsStr)
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) to mark each involved RawData row as failed.
- If
- Calls
- After looping through jobs, if
anyStillRunning
is true, it schedules itself to run again afterearliestNextPoll
minutes (ceiling to at least 1 minute) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If no jobs are left running, it does nothing further (polling stops until a new job is launched). - This function is the core of ensuring that results eventually get processed and nothing is left hanging indefinitely.
-
finalizeJobResults(jobData, submissionsStr)
– Handles the output of a completed job.- Parses
submissionsStr
(which should be something like[{"submissionId":"...","imageIds":["..."]}, ...]
) into a JavaScript arraysubmissions
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Constructs a map
resultMap
from submissionId -> parsed JSON result or failure status. It iterates over each item injobData
(the array of results returned by Runpod) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub):- For each result, grabs
subId
and the rawanswer
text (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Calls
extractJsonFromText(rawAnswer)
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This function will return a JavaScript object if it finds and parses a JSON substring, ornull
if it fails (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If a JSON object was extracted, it stores it in
resultMap[subId]
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If extraction failed, it logs a message and stores
{ status: 'failed', reason: 'No valid JSON extracted' }
as a placeholder inresultMap
for that subId (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (This way we know it was processed but the model didn’t give us what we need.)
- For each result, grabs
- Next, it creates a lookup of submissionId -> row index in RawData (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It does this by reading the RawData sheet again and mapping the UUID column values to their sheet row numbers (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- Then it iterates over each
submission
from the original submissions list (to ensure we cover those even if some had no return in jobData) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub):- If a particular
submissionId
has no entry inresultMap
(which would be unusual unless the AI failed to return one entry, but it’s a safety check), it logs and marks that RawData row as failed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - Otherwise, it gets the
result
fromresultMap
. Ifresult.status === 'failed'
(meaning we put a failure placeholder), it marks the RawData row failed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). - If the result is a proper JSON object with event data, it proceeds to mark that RawData row as processed (
Processed = true
) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) and then appends a new row to the Processed sheet with the data (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It builds therowVals
array for the 14 columns expected, pulling each field from the result object (using empty string for missing fields) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), then callsprocessedSheet.appendRow(rowVals)
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If a particular
- Logs that it completed finalizing results (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Any failed marks will remain in RawData for reference.
- Parses
-
markSubmissionsAsFailed(submissionsStr)
– Marks a batch of submissions as failed without parsing results (used when a job outright fails) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).- Parses the submissions JSON string to get the list of submissions (each with a submissionId) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- For each submission in that list, finds the corresponding row in RawData (using a similar UUID->row map method) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), then calls
markRowFailed
on that row (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). markRowFailed(sheet, rowIndex)
simply sets the "Processed" column of that row to"failed"
(event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub), and logs it. It includes a guard to not modify if rowIndex is invalid or 1 (header) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
-
Helper Functions:
createOneTimeTrigger(functionName, minutesFromNow)
– Schedules a one-time trigger for the given function after the specified number of minutes (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This uses the ScriptApp API and is utilized to schedulepollRunPodJobs
.removeTriggersForFunction(functionName)
– Deletes any existing triggers for the named function (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Used at the start ofpollRunPodJobs
to prevent overlapping triggers if the previous poll hadn’t finished (or to clean up any leftover triggers).getNewNextPollInterval(pollAttempts, prevInterval)
– Implements the backoff logic for polling interval (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). As documented in code comments (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub):- If
pollAttempts == 1
(meaning we’ve polled once and the job was still running), it returns 1 (minute) to poll quickly soon after. - Otherwise it doubles the previous interval.
- It caps the interval at 10 minutes max (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- This results in sequence: initial 5, then 1, then 2, 4, 8, 10, 10, ... minutes for subsequent polls.
- If
extractJsonFromText(text)
– As described, it tries to find a JSON object in a string (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). It returns the parsed object or null. This helps in case the model’s response isn’t perfectly formatted.- These helpers are not directly user-facing but are important for reliability.
For those interested in the AI side:
-
Model Selection: The code uses
MODEL_NAME = "openbmb/MiniCPM-o-2_6"
by default (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). This appears to be a 2.6-billion parameter model from the OpenBMB project. It’s loaded withtrust_remote_code=True
andinit_vision=True
(event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub), which implies it’s a multi-modal model capable of image input. (It might be a variant or fork that supports image prompts, possibly similar to merging a vision encoder with a language model.) -
System Prompt (
prompt.txt
): This text prompt is crucial. It sets the role and instructions for the AI. From the prompt content:- It explains that the AI’s role is to parse public event details from images (event-flyer-parser/prompt.txt at main · sayhiben/event-flyer-parser · GitHub).
- It outlines which fields to extract and how to prioritize information (for instance, if conflicting info is present, trust the flyer over email text, etc. – as hinted by sections like "Flyer First" under Data Priority).
- It explicitly instructs: Return exactly one JSON object with the 12 keys shown, no additional commentary (event-flyer-parser/prompt.txt at main · sayhiben/event-flyer-parser · GitHub). This is why we expect a single JSON output.
- It covers edge cases (e.g., if no event details found, perhaps how to handle that) (event-flyer-parser/prompt.txt at main · sayhiben/event-flyer-parser · GitHub).
- It also likely defines what to do in various scenarios (like multiple dates or recurring events, etc., given the nature of events).
- Finally, it reminds the AI to perform OCR on the images and populate the JSON (event-flyer-parser/prompt.txt at main · sayhiben/event-flyer-parser · GitHub).
-
Few-shot Examples (
examples/examples.json
): These provide the model with concrete examples. The examples file contains entries where each entry has"images": [<image filenames>]
and"answer": "<JSON string>"
. Theload_few_shot_examples
function reads this file and for each example:- Loads the images from the examples folder and converts them to the format needed (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub).
- Adds a conversation turn: user role with content as the images + system prompt (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub), then assistant role with content as the answer (the expected JSON).
- This sequence of messages is then prepended to every query the model gets (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). Essentially, it’s saying: “Here are some examples of what to do,” before asking it to do the actual task on the new images.
-
Model Inference: The
model.chat
function is likely a convenience method provided viatrust_remote_code=True
for this model. It takes the list of message dictionaries (msgs
) and a tokenizer, and returns the assistant’s reply as a string (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). We then package that into our result list with the submissionId (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). -
Performance: Running a large model for OCR might be slower than dedicated OCR engines, but it can allow more direct extraction of structured info. There are alternatives (e.g., using Google Vision API for text, then an LLM for parsing text to JSON) which could be explored in development. The chosen approach here uses the model for both OCR and understanding in one go. Depending on the quality of the model (MiniCPM in this case), results may vary. The prompt tries to enforce structure, but some tweaking might be required if the model output isn’t consistent.
-
Error handling in
app.py
: The Python code is robust in that it catches errors at each stage (model init, prompt loading, Drive download, model inference) and logs them. If the model fails to load or examples fail to load, it returns `{"error": "..."} (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub); if an image fails to download, it logs an error for that submission but continues (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub); if the model errors out for a submission, it catches it and will return whatever results it got so far (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (which the Apps Script would interpret as missing that one submission, likely marking it failed as a result).
- Where emails go: They are not stored permanently in Gmail – the script trashes them after processing (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The canonical storage of the info is the RawData sheet and the image files in Drive.
- Where images go: Stored in the specified Drive folder. They remain there even after processing. Over time, you might clean up old images manually if they are large or not needed, but note that the Processed sheet only has the extracted text; if you ever need to refer back to the actual flyer, the image file will be in Drive (with the ID recorded in RawData). You could enhance the script to delete images after processing if storage is a concern.
- Where parsed data goes: In the Processed spreadsheet. Consider protecting this sheet or using data validation if multiple people are collaborating, to avoid accidental edits to parsed data.
By understanding the above, developers can modify the behavior (e.g., change the JSON fields to extract, use a different ML model or service, adjust polling frequency, etc.) with knowledge of how parts connect.
If you wish to contribute to the project or modify it for your needs, here are some guidelines and tips:
Project Structure & Codebase:
- The repository is structured to separate the Google Apps Script code and the Runpod service code. The key files are:
googleAppsScript.gs
– the Google Apps Script code (JavaScript syntax, runs in Apps Script environment).app.py
– the Python server that runs on Runpod (or any similar platform).prompt.txt
– the system prompt text for the AI model.examples/
– directory containing example images and anexamples.json
file with few-shot example data.requirements.txt
andrequirements-cuda.txt
– Python dependencies (the latter including CUDA-specific ones for GPU).Dockerfile
– to containerize the Python app for deployment.Makefile
– helper commands for building/pushing the Docker image.LICENSE
– MIT license file.README.md
– documentation (to be updated with any changes you make).
When working on the code, ensure changes to one side (Apps Script or Python) remain compatible with the other. For example, if you alter the JSON structure the model outputs, update finalizeJobResults
accordingly.
Setting up a Dev Environment (Python): You can run app.py
locally on a machine with a GPU for testing. Ensure you have Python 3.10+, PyTorch, and the other packages installed. Set the environment variable GOOGLE_SERVICE_ACCOUNT_KEY
in your shell to the service account JSON content (or you can modify the code to load from a file in dev). Since the Runpod serverless expects handle_inference
to be called via their system, you can simulate this by manually calling handle_inference
:
import json
from app import handle_inference
# Load a sample payload
event = {"input": {"submissions": [
{"submissionId": "test-uuid-1234", "imageIds": ["<your test image file ID>"]}
]}}
result = handle_inference(event)
print(json.dumps(result, indent=2))
Make sure to set up credentials and have a test image in the Drive folder accessible. This will let you iterate on the prompt or model and see results immediately, rather than going through the full Gmail->Apps Script loop.
Testing the Apps Script: You can simulate parts of it by creating dummy entries in RawData and calling launchRunPodJobs()
from the script editor (with a valid Runpod endpoint configured). Also, you can use the Logger logs to debug. In the script editor, go to Executions to see logs of each run or use Logger.log
statements (which we have plenty of) to trace values. For example, if a submission isn’t getting picked up, check the RawData flags; the logs will show how many were found (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
Debugging Common Issues:
- If no emails are being processed, ensure the trigger is set and that the Gmail query matches your incoming emails. Check the script’s logs for "Found X unread threads" (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- If emails are processed (RawData entries appear) but nothing happens after, check that
launchRunPodJobs
is running and thatRUNPOD_ENDPOINT_URL
andRUNPOD_API_KEY
are correct. The logs will show if it attempted to submit to Runpod and if it received a job ID (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). If there’s an authorization error, ensure the API key is valid and has rights to invoke the endpoint. - If jobs are created but never marked completed, the poller might not be running. Ensure that the first poll trigger was created (the log "Creating time-based trigger for pollRunPodJobs()" should appear (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub)). Check the executions; sometimes if an error occurs in scheduling, it might not schedule the poll. Also verify that the script’s project triggers (in the dashboard) include any future trigger for pollRunPodJobs. If not, you might need to manually trigger polling or adjust the code if a timing issue occurred.
- If a job is marked failed unexpectedly, check the Runpod logs for the job. Possibly the model crashed or ran OOM. In such a case, you might try using a larger GPU or optimizing the model (e.g., use half precision, smaller model, or ensure not too many submissions/images per job – currently we send all unprocessed in one job which could be heavy if many images; you could modify
launchRunPodJobs
to limit submissions per job). - The Processed sheet being empty while RawData shows "true" could indicate that the JSON extraction failed. In such cases, the RawData would be marked "failed", not "true". "true" means it should have written to Processed. So if you see "true" but nothing in Processed, perhaps the Processed spreadsheet ID or sheet name is wrong. Check that
PROCESSED_SPREADSHEET_ID
is correct and that a sheet named "Processed" exists in that spreadsheet (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). The code opens that spreadsheet by ID and assumes the sheet is present (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
Extending the Project:
- Multiple Accounts or Sources: If you want to process flyers from multiple email accounts, you could set up multiple instances of the script (or have one script account with delegation to other inboxes, though GmailApp doesn’t directly support multi-account). Easiest would be separate Projects per account, or have all mails forwarded to one account.
- Alternate OCR Approach: You could replace the Runpod LLM approach with an API like Google Vision for text extraction and then use a smaller local model (or even rule-based parsing) for pulling structured data from text. This might reduce complexity/cost. The current setup is a demonstration of an LLM doing end-to-end parsing, which is flexible (understands context) but resource-intensive.
- Model Fine-tuning: If the extraction accuracy isn’t great, you could fine-tune a model specifically on event flyers, or use a different pre-trained model (like Donut by Naver for documents, or an instruction-tuned multimodal model). If you switch the model, update
MODEL_NAME
and possibly the prompt/examples to match it. Ensure the output JSON keys remain the same or adjust the Apps Script accordingly. - Error Notifications: For a production use, you might want the script to notify you (e.g., send an email or Slack message) if a job fails or if an email couldn’t be processed. Currently, it just marks failures in the sheet. Adding a notification in
markSubmissionsAsFailed
or when marking row failed could be useful if failure is rare and you want to know when to intervene. - UI or Manual Trigger: You could integrate this with a Google Sheets custom menu to allow manual triggers (e.g., a "Process Now" button that calls these functions). Given it’s automated, that’s optional.
Contributing Guidelines:
- Before making a pull request, test your changes end-to-end with at least one sample email and flyer to ensure nothing breaks.
- Keep code style consistent. The Apps Script code uses a lot of logging and clear variable names for readability; please maintain that for any new code. The Python code is structured to initialize everything each invocation (since it may not run as a persistent server between jobs on Runpod’s serverless). If you modify it to keep the model in memory between calls (e.g., using a global or caching mechanism), ensure that it still works in the serverless context (Runpod might reuse the container for a short time, but it’s not guaranteed for subsequent jobs).
- Document any changes in this README. The documentation is crucial for others (and your future self) to understand the system.
- If you find and fix a bug, describe the root cause and solution clearly in your commit and consider adding comments in code to prevent regressions.
We welcome contributions that improve accuracy, performance, or usability. For example, better prompt tuning, support for more fields, or integration with calendar APIs to automatically create events from the parsed data would all be interesting enhancements.
If you encounter issues, you can use the GitHub issue tracker of the repository to report them. When reporting, include relevant log excerpts (if possible) or a description of the scenario (perhaps anonymize any personal data).
This project spans multiple cloud components. Below is a summary of configuration details and environment variables across Google Apps Script and Runpod, and how to properly set them up:
Google Apps Script (GAS) Project Properties:
Upon deploying the Apps Script, five script properties must be defined so the script knows how to connect to other services:
INBOX_SPREADSHEET_ID
– The unique ID of the Google Sheet that contains RawData and Jobs sheets (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). (This is the "Inbox" spreadsheet you created. Find the ID in the sheet’s URL as described in Setup Step 1.)PROCESSED_SPREADSHEET_ID
– The ID of the Google Sheet that contains the Processed sheet for final event data (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).DRIVE_FOLDER_ID
– The ID of the Google Drive folder where images will be stored and accessed (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).RUNPOD_ENDPOINT_URL
– The full URL of the Runpod serverless endpoint for the AI service (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). This is provided by Runpod when you deploy the endpoint (likely in the formathttps://api.runpod.ai/v2/<endpoint-id>/run
). Double-check it ends with/run
, as the script programmatically creates the status URL by replacing this segment (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).RUNPOD_API_KEY
– Your Runpod API key (string) for authenticating requests (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). You can get this from your Runpod account settings. Store just the token string; the script adds the "Bearer " part when adding it to the header (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
These can be added in the Apps Script editor under Project Settings. They are retrieved in code via scriptProperties.getProperty
at runtime (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub). Storing them as script properties keeps them out of your code (good for security, especially for the API key).
Google Apps Script Scopes/Authorization:
The script uses the following Google services which will prompt for OAuth scopes:
- GmailApp – requires permission to read and modify your Gmail (to search, read, mark read, and trash emails).
- DriveApp – requires permission to create and read files on your Google Drive (to save attachments to the folder).
- SpreadsheetApp – requires permission to read and write to spreadsheets that the script is not bound to (since we use openById for possibly an external spreadsheet) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/googleAppsScript.gs at main · sayhiben/event-flyer-parser · GitHub).
- ScriptApp – (for triggers) requires permission to manage triggers on your behalf.
When you first run the script or set up triggers, Google will prompt you to allow these scopes. You might see warnings if the app isn’t verified since it’s a custom script; you’ll have to acknowledge and allow. In a G Suite domain, an admin might need to whitelist the scopes. The scopes are standard (Gmail, Drive, Sheets).
Runpod Endpoint Environment:
On the Runpod side, the container requires configuration to access Google Drive and load the model:
GOOGLE_SERVICE_ACCOUNT_KEY
– This env var should contain the JSON credentials for the service account, as mentioned (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). The Python code loads it and uses it to build the Drive API client. If this is not set or is malformed, the app will throw an error at startup ofhandle_inference
(logged as "Error initializing Google Drive service") (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub), and it will return an error for any job.- Make sure to include the entire JSON. In Runpod’s UI, you may need to paste it as a single-line string or upload it as a secret. Some users store the JSON as a base64-encoded string or in a file; in this case, the code expects it directly. If you want to modify that, you could mount a volume with the JSON file and change
service_account_info = json.loads(os.environ["GOOGLE_SERVICE_ACCOUNT_KEY"])
to read from a file path. - The JSON includes fields like
project_id
,private_key
,client_email
, etc. Theclient_email
must have access to your Drive folder (shared earlier). The scope used is Drive readonly (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub), which is fine since the script only downloads images.
- Make sure to include the entire JSON. In Runpod’s UI, you may need to paste it as a single-line string or upload it as a secret. Some users store the JSON as a base64-encoded string or in a file; in this case, the code expects it directly. If you want to modify that, you could mount a volume with the JSON file and change
- Model and Data Paths: The container by default expects
prompt.txt
in the working directory and an./examples
folder. The Dockerfile copies these in (event-flyer-parser/Dockerfile at main · sayhiben/event-flyer-parser · GitHub). If you change their locations, updateSYSTEM_PROMPT_PATH
orEXAMPLES_PATH
inapp.py
(event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub). Currently,SYSTEM_PROMPT_PATH = "prompt.txt"
andEXAMPLES_PATH = "./examples"
(set just above the functions inapp.py
). If using a different prompt or extra data files, ensure the Dockerfile includes them and paths match. - Model Download: The first run will download the model weights from Hugging Face. The Dockerfile sets caching environment variables to use
/runpod-volume
for persistent cache between runs (event-flyer-parser/Dockerfile at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/Dockerfile at main · sayhiben/event-flyer-parser · GitHub). This means subsequent jobs start faster (since the model is cached). No Hugging Face API token is needed for the specified model (assuming it’s public). - GPU Requirements: The
requirements-cuda.txt
suggests GPU usage. If you run on CPU (not recommended due to speed), you might switch torequirements.txt
and a smaller model. For Runpod, ensure you selected a GPU. The code usestorch.cuda.empty_cache()
and then moves model to CUDA.cuda()
(event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub) – if no GPU is present, this would error. So the endpoint must have a GPU.
Google Apps Script Properties vs. Hardcoded values:
The design keeps all deploy-specific IDs/URLs in properties for flexibility. For example, if you duplicate this setup in another account, you only need to change the properties, not the code. Similarly, on the Python side, the service account and any model details are via environment or constants at the top for easy modification.
Security and Access:
- The service account JSON is sensitive. In Runpod, treat it as a secret. Do not expose it in logs (the code doesn’t log it, and you shouldn’t print it).
- The Runpod API key is also sensitive; keep it in Script Properties (which are not visible in the code). Google Apps Script project properties are not publicly visible unless someone has edit access to the script project.
- The data in Google Sheets and images in Drive are as secure as your Google account. Since we move emails to trash, the source email isn’t easily accessible (until trash is emptied after 30 days), which might be considered a feature or a drawback. You might choose to archive instead of trash if you want to keep the original emails.
- If multiple people need to use the system, consider using a shared Google account or a Google Workspace service account approach (Gmail API via service accounts is complicated for consumer Gmail, so likely a user-bound script is simplest).
- Ensure the service account’s Drive API access is limited to only what it needs (readonly on that specific folder). By sharing just the one folder with the service account, you prevent it from accessing other files in your Drive.
Environment Variables Summary (for reference):
- Google Apps Script: INBOX_SPREADSHEET_ID, PROCESSED_SPREADSHEET_ID, DRIVE_FOLDER_ID, RUNPOD_ENDPOINT_URL, RUNPOD_API_KEY.
- Runpod/Python: GOOGLE_SERVICE_ACCOUNT_KEY (plus optionally things like MODEL_NAME if you wanted to override it, or HF API token if using a private model; none required for default use).
Each time you update something like the Runpod endpoint (e.g., deploying a new container with changes), you might get a new endpoint URL or ID. Update the script property RUNPOD_ENDPOINT_URL
accordingly. The note in the initial README draft “When you get a new deployed SHA, update the runpod endpoint settings” likely refers to this – if you push a new Docker image and update the endpoint to that image, the endpoint ID/URL stays the same, but if you instead create a new endpoint, you’ll have a new URL that the script needs to know.
Logging and Monitoring:
- The Apps Script logs (accessible in Script Dashboard or Stackdriver logging) will show events of the script. You might integrate Stackdriver alerts if this were a long-running project (e.g., alert if an error message appears frequently).
- Runpod allows you to see logs per job or overall. It might be useful to monitor memory usage there. If memory is an issue, consider reducing
MAX_DIM
in the Python (currently 1280, which resizes images to at most 1280px in largest dimension to limit size) (event-flyer-parser/app.py at main · sayhiben/event-flyer-parser · GitHub).
Cleaning Up:
If you want to disable the system, remove the triggers in Apps Script (so it stops processing new emails) and possibly stop the Runpod endpoint (to avoid accidental charges). Data already in Sheets/Drive will remain until you delete it manually.
License: This project is released under the MIT License (GitHub - sayhiben/event-flyer-parser: Parses events described in images and posts the event details to a Google Sheet), which means you are free to use, modify, and distribute it. Please give credit in your project repo or mention if you build upon it, so others know where it originated. Happy parsing!