I wanted a daily visual record of my Google Calendar: not a list of events exported through an API, but an image of the calendar as it looked on a given day. Taking and uploading screenshots manually was repetitive, so I built a small workflow on Ubuntu that captures a rolling seven-day view and stores each image in Google Drive.
The implementation combines Python and Playwright for browser automation, rclone for uploading to Drive, and a systemd user timer for scheduling. The basic pipeline worked, but authentication turned out to be the most instructive part of the setup.
The source code and reproducible configuration examples are available in the GitHub repository.
Architecture
systemd user timer | v Python / Playwright | v Headless Google Chrome (dedicated authenticated profile) | v Google Calendar website (rolling seven-day view) | v Local dated PNG file | v rclone + Google Drive API | v Google Drive archive folder There is an important distinction between the two Google integrations:
- Calendar: The script opens the Google Calendar website and captures the rendered interface. It does not call the Google Calendar API.
- Drive: rclone uses Google Drive API authorization to upload the resulting PNG.
This approach preserves the familiar calendar layout. It also means the capture depends on the Google Calendar interface and the browser's saved display settings.
Environment and dependencies
I developed and tested the original workflow on Ubuntu 22.04, using Playwright 1.48.0 and an installed Google Chrome. The public repository contains a path-generalized adaptation of that implementation; it has not been independently tested with a live Google account.
The project uses a Python virtual environment:
python3 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/playwright install --with-deps chromium Although the setup installs Playwright's Chromium dependencies, the capture script explicitly launches the installed Google Chrome with channel="chrome".
Reusing a dedicated Chrome session
My first attempt launched Playwright's bundled Chromium and tried to sign in to Google directly. Google rejected the login with a browser-security warning.
I switched to opening ordinary Google Chrome manually for the initial sign-in. That produced a persistent browser profile, but the subsequent headless run still redirected to the Google sign-in page. In this environment, using the same Linux password-storage mode for both launches resolved the session-reuse problem.
I launched a dedicated Chrome profile with:
mkdir -p chrome_profile chmod 700 chrome_profile google-chrome \ --password-store=basic \ --user-data-dir="$(pwd)/chrome_profile" \ --no-first-run \ --no-default-browser-check \ https://calendar.google.com/ I signed in manually, selected Google Calendar's custom 7 days view, and then closed that Chrome window completely. The automation uses the same profile and browser argument:
from pathlib import Path from playwright.sync_api import sync_playwright profile_dir = Path(__file__).resolve().parent / "chrome_profile" with sync_playwright() as p: context = p.chromium.launch_persistent_context( user_data_dir=str(profile_dir), channel="chrome", headless=True, args=["--password-store=basic"], viewport={"width": 1920, "height": 1200}, locale="ja-JP", timezone_id="Asia/Tokyo", ) # Open Calendar, navigate to today, and capture the view. context.close() The full implementation includes navigation, screenshot creation, error checks, and Drive upload. Its 7 days view is configured once in the browser; the script does not set the view on every run. It navigates to Today before capturing the page.
Security trade-off: --password-store=basic uses a less-protected storage method than a desktop keyring. I restricted it to a dedicated automation profile, limited file permissions, and excluded the profile from Git. It is a description of what worked in my environment, not a general recommendation to disable credential protection. The profile must never be opened simultaneously by interactive Chrome and Playwright.
See the Playwright persistent-context documentation and Chromium's Linux password-storage documentation for the underlying behavior.
Capturing and naming the screenshots
The script navigates to the current date, waits for the calendar to render, and captures the viewport. I used a date-based filename:
from datetime import datetime from pathlib import Path base_dir = Path(__file__).resolve().parent output_dir = base_dir / "screenshots" output_dir.mkdir(parents=True, exist_ok=True) filename = f"GoogleCalendar_{datetime.now():%Y-%m-%d}.png" output_file = output_dir / filename # After Calendar has loaded and the Today button has been clicked: page.screenshot(path=str(output_file), full_page=False) For example, a run on September 11 produces GoogleCalendar_2026-09-11.png. Running the same workflow again on that date replaces that day's file. Since the screenshot is produced from the current webpage, it is a snapshot at capture time, not a way to reconstruct an earlier calendar state.
The example screenshot in the repository uses a separate demonstration calendar with fictional events; it does not show private schedule details.
Uploading to Google Drive with rclone
I configured an rclone Google Drive remote named gdrive, authenticated in the browser, and created the destination folder:
rclone config rclone lsd gdrive: rclone mkdir gdrive:GoogleCalendarScreenshots The script uploads the PNG using copyto:
import shutil import subprocess rclone = shutil.which("rclone") if not rclone: raise RuntimeError("rclone was not found in PATH") subprocess.run( [ rclone, "copyto", str(output_file), f"gdrive:GoogleCalendarScreenshots/{filename}", ], check=True, ) check=True makes the Python process fail if the rclone command returns a nonzero exit status, so systemd does not report a successful run when the upload command fails.
OAuth and quota issues I encountered
The rclone configuration took more work than I expected:
- I initially entered an account name in OAuth fields that actually required a Client ID and Client Secret. Those fields are not where the Google account email belongs; the account is selected during browser authorization.
- Using rclone's shared OAuth client resulted in a 403 quota/rate-limit error in my setup. I created my own Google Cloud project, enabled the Google Drive API, and configured an OAuth Desktop app client.
- Authorization was then denied because the OAuth app was in Testing and the intended Google account had not been added as a test user. Adding it resolved that access-denied error.
For unattended operation, the OAuth publishing status matters. Google's documentation states that refresh tokens issued to External / Testing apps generally expire after seven days when scopes such as Drive are requested. Moving an eligible application to In production removes that particular testing limitation, but does not guarantee that a refresh token can never expire or be revoked.
I do not include OAuth tokens, client secrets, or rclone configuration files in the public repository. The rclone Google Drive documentation covers personal OAuth client setup, and Google's OAuth documentation explains refresh-token expiration.
Scheduling with systemd
Once the manual run worked, I created a systemd user service to execute capture.py and a timer to trigger it daily. The public repository includes service and timer templates.
A timer can be configured as follows (the time is only an example and can be changed):
[Unit] Description=Capture Google Calendar daily [Timer] OnCalendar=*-*-* 17:15:00 Persistent=true AccuracySec=1min [Install] WantedBy=timers.target I also enabled user lingering so the user service manager could remain active after logout:
sudo loginctl enable-linger "$USER" systemctl --user enable --now gcal-screenshot.timer systemctl --user list-timers gcal-screenshot.timer The computer still needs to be running. Persistent=true can trigger a catch-up run when the timer becomes active again after a missed schedule, but it does not generate separate historical screenshots for every missed day. Also, AccuracySec=1min allows systemd some flexibility around the scheduled time.
Validation
I tested the actual deployment both from the command line and by manually starting the systemd service. The September 11, 2026 validation recorded:
Calendar title: Google Calendar - 7 days, starting Friday, September 11, 2026 Service exit: status=0/SUCCESS Local PNG: GoogleCalendar_2026-09-11.png Drive upload: confirmed in GoogleCalendarScreenshots/ I also confirmed that the local file existed, its Drive copy was updated after a rerun, and the timer and Linger=yes settings were in place. The schedule was subsequently adjusted without changing the capture-and-upload workflow.
Those tests demonstrate a working end-to-end pipeline. They are not a benchmark, proof of uninterrupted daily execution, or a separate live-account validation of the generalized public code.
Limitations and lessons learned
- The UI is a dependency. A change to the Google Calendar website may require updating a selector or wait condition. The script also relies on the saved rolling seven-day view.
- Authentication needs maintenance. Browser sessions and OAuth credentials can expire or be revoked. A timer alone does not guarantee that future runs will succeed.
- A successful upload is not a historical backup of events. Images preserve the rendered view at a specific moment, not a structured or fully recoverable calendar database.
- Privacy matters. Screenshots may expose appointment names, times, and other details. I used fictional events for the public example and excluded authentication data and real screenshots from Git.
- A missed run is not automatically recoverable in its original form. A later catch-up run captures the calendar as it appears at that later moment.
The most useful lesson was to separate the system into independently testable steps: authenticated browser capture, local image creation, Drive transfer, and scheduled execution. That made it possible to identify browser-session failures independently of Google Drive OAuth and API quota failures.
The implementation, setup guide, and sanitized validation record are available on GitHub.
AI disclosure: This article documents my own technical setup and results. An LLM was used to help draft, organize, and edit the English text. I remain responsible for its technical accuracy.

![Path Planning and Autonomous Navigation — Ch.11: Towing AMR Navigation [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvsxdf311sqylulf87m4.png)