A Hybrid Playwright and Google Analytics 4 API Pipeline for Weekly Analytics Reporting

Weekly reporting often begins as a small manual task:

  1. Open several analytics reports.
  2. Change each report to the same weekly date range.
  3. Wait for charts to load.
  4. download each report as PDF.
  5. Rename the files.
  6. export supporting CSV data.
  7. place everything in the correct folders.
  8. verify that no report is missing or incomplete.

None of these steps is particularly difficult. The problem is repetition, inconsistency, and silent failure. This article describes the architecture and engineering decisions behind a macOS automation workflow that generates several Looker Studio PDFs and GA4 CSV files from one shared reporting period.

The original problem

Looker Studio supports scheduled PDF delivery, but long reports can sometimes arrive with partially rendered pages or empty report components.

This creates a dangerous type of failure: the export technically succeeds, but the document is not necessarily complete.

Manual downloads were more reliable, but required repeating the same sequence across several reports every week:

  • open a report;
  • select the reporting period;
  • wait for all components to load;
  • open the export menu;
  • download the PDF;
  • rename it;
  • move it to the weekly folder;
  • repeat for the next report.

There were also two CSV exports based on GA4 data. These required the same reporting period as the PDFs but did not need browser automation.

The final goal was therefore:

Select one weekly period, generate all required PDFs and CSVs, place them in a deterministic directory structure, and verify the results.

Final workflow

The completed system supports independent and unified commands:

npm run batch
npm run csv
npm run all
npm run retry-pdf

Their responsibilities are deliberately separated:

Command Responsibility
npm run batch Export all configured Looker Studio PDFs
npm run csv Generate GA4 CSV files
npm run all Select one date range and run both components
npm run retry-pdf Re-export only selected PDFs
npm run login Open the dedicated Chrome profile for authentication
npm run handover Generate a privacy-safe AI project handover

The unified command produces a structure similar to this:

Weekly Delivery Folder/
└── analytics reports/
    ├── Site A/
    │   ├── 1_analytics-report_(date-range).pdf
    │   ├── 2_search-report_(date-range).pdf
    │   └── 3_page-table_(date-range).csv
    ├── Site B/
    │   ├── 1_analytics-report_(date-range).pdf
    │   ├── 2_search-report_(date-range).pdf
    │   └── 3_page-table_(date-range).csv
    └── Site C/
        └── 1_analytics-report_(date-range).pdf

The system currently creates:

  • five PDF reports;
  • two CSV files;
  • seven verified output files in total.

Why a hybrid architecture was the right choice

The project uses two different automation methods.

flowchart TD
A["Select one weekly period"] –> B["PDF component"]
A –> C["CSV component"]
B –> D["Chrome + Playwright"]
C –> E["GA4 Data API"]
D –> F["Verify PDFs"]
E –> G["Verify CSVs"]
F –> H["Weekly output folder"]
G –> H

PDF export depends on the Looker Studio user interface, so browser automation is used.

CSV generation does not need the interface. The data is available through the GA4 Data API, so a direct API integration is more reliable and efficient.

This produced a useful design rule:

Use browser automation only for operations that genuinely require the browser. Use an API whenever the underlying data can be retrieved directly.

Trying to force everything through Playwright would make the CSV component slower and more fragile. Trying to produce the Looker Studio PDFs entirely through the GA4 API would require recreating the report layouts, charts, formatting, and pagination.

The hybrid approach preserves the original PDF presentation while keeping data extraction programmatic.

Challenge 1: Google rejected the automated sign-in

The first Playwright prototype opened an automated Chromium session and navigated to the Google sign-in page.

Google rejected the login with a message similar to:

This browser or app may not be secure.

This was not a selector problem. The login flow was detecting the automated browser environment.

Entering credentials through the terminal was not an acceptable alternative. It would also have created unnecessary security risks.

First attempted solution: a persistent browser profile

The next version used a dedicated persistent Chrome profile:

const context = await chromium.launchPersistentContext(profilePath, {
  channel: "chrome",
  headless: false,
});

The intention was:

  1. open normal Chrome with a dedicated profile;
  2. let the user sign in manually;
  3. save the Google session;
  4. reuse the profile for future runs.

This was an improvement, but introduced two macOS-specific problems.

Profile locking

Chrome does not allow multiple active processes to control the same profile directory.

If the profile was still open, Playwright failed with an error similar to:

Opening in existing browser session.
The profile is already in use by another Chromium process.

Session persistence and the macOS Keychain

Closing the profile and reopening it under a differently launched process did not always preserve the authenticated session as expected.

Chrome cookies and encryption behavior on macOS can interact with the Keychain and browser launch context. A profile directory existing on disk does not automatically guarantee that every process opening it can access the session in the same way.

The working authentication design: attach to normal Chrome

The reliable solution was to reverse the process:

  1. launch normal Google Chrome with a dedicated profile;
  2. enable a local Chrome DevTools Protocol port;
  3. let the user authenticate normally;
  4. leave that Chrome session open;
  5. attach Playwright to the existing browser through CDP.

Conceptually, Chrome is started like this:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --user-data-dir="/path/to/dedicated-profile" \
  --remote-debugging-port=9222 \
  "https://example-report-url"

Playwright then connects to that browser:

const browser = await chromium.connectOverCDP("http://127.0.0.1:9222");

This solves several problems at once:

  • Google sees a normal Chrome sign-in flow;
  • the user enters credentials directly into Google;
  • Playwright never receives the password;
  • the authenticated Chrome process remains the same process;
  • the profile is not reopened by a competing browser instance;
  • browser automation can still inspect and control the report.

Protecting the user’s main Chrome profile

Attaching to an existing browser creates an important safety risk.

A machine may already have a primary Chrome profile containing:

  • saved passwords;
  • extensions;
  • personal sessions;
  • bookmarks;
  • browser preferences;
  • unrelated open tabs.

The automation must never attach to that browser accidentally.

A fail-closed safety check was therefore added before Playwright connects.

The check verifies that:

  1. the debugging port is available;
  2. the browser process uses the expected dedicated profile path;
  3. the profile path belongs to the automation project;
  4. the process is not using the user’s main Chrome profile.

If the check cannot confirm the exact dedicated profile, the script stops.

The principle is simple:

Uncertainty must result in refusal, not an unsafe guess.

The dedicated profile directory is also excluded from version control because it contains an authenticated browser session.

Challenge 2: the date picker selected the wrong day

The report contained a fixed date-range control with two calendars:

  • a start-date calendar;
  • an end-date calendar.

The first automation searched globally for a day number and clicked a matching element. This was unreliable because the same day number may appear in both calendars.

For example, selecting the end date could accidentally click the same numbered day in the start calendar.

The visible result might become:

Requested: Friday to Thursday
Actual:    Thursday to Thursday

The click succeeded technically, but the wrong DOM element received it.

Inspecting the rendered calendar DOM

The key improvement came from inspecting the actual date-picker HTML.

The dialog contained stable structural classes:

<div class="start-date-picker calendar-wrapper">
  ...
</div>

<div class="end-date-picker calendar-wrapper">
  ...
</div>

Each calendar day was represented by a button with a full accessible date:

<button
  type="button"
  class="mat-calendar-body-cell"
  aria-label="Jul 24, 2026">
  <span>24</span>
</button>

This made it possible to identify dates by meaning instead of by visual position.

The corrected selection logic scopes each date to its calendar:

const startCalendar = page.locator(".start-date-picker");
const endCalendar = page.locator(".end-date-picker");

await startCalendar
  .locator('button[aria-label="Jul 24, 2026"]')
  .click();

await endCalendar
  .locator('button[aria-label="Jul 30, 2026"]')
  .click();

The general lesson is:

A number such as 24 is ambiguous. A full accessible label such as Jul 24, 2026, inside the correct calendar container, is specific.

Supporting dates outside the visible month

A weekly period can cross a month boundary.

For example:

  • the start date may be in the current month;
  • the end date may be in the previous or next month displayed by the second calendar.

The selector therefore needs month navigation.

Each calendar exposes:

  • its visible month and year;
  • a previous-month button;
  • a next-month button.

The automation:

  1. reads the visible month;
  2. converts it into a year-and-month index;
  3. compares it with the target date;
  4. clicks previous or next;
  5. repeats until the correct month is visible;
  6. clicks the exact date using aria-label.

A simplified version looks like this:

while (visibleMonthIndex !== targetMonthIndex) {
  if (targetMonthIndex < visibleMonthIndex) {
    await calendar
      .locator('button[aria-label="Previous month"]')
      .click();
  } else {
    await calendar
      .locator('button[aria-label="Next month"]')
      .click();
  }

  visibleMonthIndex = await readVisibleMonth(calendar);
}

Each start and end calendar is navigated independently.

Verifying the selected range

A successful click is not sufficient evidence that the report accepted the requested period.

After both dates are selected and the dialog is applied, the script reads the visible date-range control again.

It compares:

  • expected start date;
  • expected end date;
  • visible start date;
  • visible end date.

If they do not match exactly, the run stops before downloading the PDF.

This converts a silent UI mistake into an explicit error.

Challenge 3: the “already selected” branch skipped the export

An early optimization checked whether the requested period was already visible.

If it was, the script logged:

The requested date range is already selected.

However, the original branch then finished early. It skipped the PDF export entirely.

This is a common control-flow mistake: a condition that should skip one operation accidentally skips the rest of the workflow.

The correct behavior is:

if (currentRange === requestedRange) {
  console.log("The requested date range is already selected.");
} else {
  await changeDateRange();
}

await waitForCharts();
await downloadPdf();

Only the date-change step should be skipped. The download must still run.

Automating the correct export menu

The Looker Studio header contains a main Share button and a separate menu triangle.

Clicking the Share label itself does not necessarily open the menu containing the download action.

The useful selector was the adjacent split-button menu control:

const menuButton = page.locator(
  'button.split-button-menu-button[aria-label="More options"]'
);

await menuButton.click();

The download menu item could then be selected structurally:

await page.locator("button.share-dl-button").click();

This opens the PDF download dialog.

Avoiding unnecessary interaction in the PDF dialog

The dialog contains several optional checkboxes, such as:

  • ignore the custom background;
  • add a report link;
  • protect the PDF with a password.

The workflow did not require any of them.

An early version tried to locate and validate every option before continuing. That made the script fail when one label was rendered differently, even though none of the options needed to be changed.

The better implementation leaves the optional controls untouched and clicks only the required button:

const downloadButton = page.locator(
  'button[data-test-id="download-button"]'
);

await downloadButton.click();

This illustrates another important rule:

Do not automate controls that are irrelevant to the intended outcome.

Every extra selector is another potential failure point.

Waiting for report rendering

Immediately downloading after changing the date can capture a report while charts are still loading.

The workflow therefore includes a rendering delay before opening the export menu:

await page.waitForTimeout(15_000);

A fixed delay is not theoretically ideal, but it is practical for a report containing many independent components.

A more advanced version could combine:

  • a minimum delay;
  • loading-indicator detection;
  • network-idle observation;
  • chart-container readiness checks;
  • repeated stability checks.

However, Looker Studio reports can generate background requests even after visible rendering is complete. Waiting for perfect network silence can therefore become less reliable than a carefully chosen rendering window.

The automation is designed for one weekly run, so a moderate delay is an acceptable trade-off.

Minimizing unnecessary data refreshes

Large analytics reports can encounter temporary data-source or quota errors.

The workflow reduces unnecessary report reads by:

  • changing the date only when necessary;
  • avoiding repeated page reloads;
  • processing reports sequentially;
  • waiting for one report to stabilize before opening the next;
  • allowing selective PDF retries;
  • keeping CSV extraction outside the browser.

Sequential execution is slower than full parallelism, but it reduces browser contention and simultaneous analytics queries.

For a weekly job, reliability is more valuable than reducing a few minutes of runtime.

Capturing the browser download

Playwright provides a download event that can be awaited before clicking the final button:

const downloadPromise = page.waitForEvent("download");

await downloadButton.click();

const download = await downloadPromise;
await download.saveAs(destinationPath);

The script does not depend on the source filename generated by Looker Studio.

Instead, every file receives a deterministic destination name based on:

  • report type;
  • site identifier;
  • reporting period;
  • output category.

This ensures that generated filenames are consistent even if the report’s internal title changes.

PDF verification

A completed browser download is not automatically a valid PDF.

The workflow performs several checks:

  1. the destination file exists;
  2. the file size is greater than a minimum threshold;
  3. the file begins with the PDF signature %PDF-.

A simplified Python verification function is:

from pathlib import Path

def verify_pdf(path: Path) -> None:
    if not path.is_file():
        raise RuntimeError(f"PDF was not created: {path}")

    if path.stat().st_size < 1024:
        raise RuntimeError(f"PDF is unexpectedly small: {path}")

    with path.open("rb") as handle:
        signature = handle.read(5)

    if signature != b"%PDF-":
        raise RuntimeError(f"Invalid PDF signature: {path}")

These checks detect:

  • missing files;
  • empty downloads;
  • HTML error pages saved with a .pdf extension;
  • truncated or obviously invalid output.

They do not prove that every chart is visually complete. Visual completeness is a harder problem and remains an area for future improvement.

Possible future checks include:

  • rendering PDF pages to images;
  • detecting pages with unusually large white regions;
  • comparing page count against a known baseline;
  • extracting text from expected report sections;
  • verifying that key headings exist;
  • comparing file size with historical exports.

Scaling from one report to a batch

Once a single report could be exported reliably, the next step was configuration-driven batch processing.

The public example configuration follows a structure like this:

{
  "output_root": "/path/to/weekly/reports",
  "reports": [
    {
      "id": "site-a-analytics",
      "group": "Site A",
      "url": "https://example-report-url",
      "filename_template": "1_analytics-report_({start} - {end}).pdf"
    },
    {
      "id": "site-a-search",
      "group": "Site A",
      "url": "https://example-report-url",
      "filename_template": "2_search-report_({start} - {end}).pdf"
    }
  ]
}

The real local configuration contains private report URLs and is excluded from Git.

The batch runner processes reports sequentially:

  1. resolve the date range;
  2. compute the output folder;
  3. open the report;
  4. verify the date range;
  5. change it if necessary;
  6. wait for rendering;
  7. download the PDF;
  8. verify the file;
  9. continue to the next report;
  10. print a final summary.

A configuration-driven design makes it possible to add or remove reports without duplicating the browser automation logic.

Weekly date conventions

The reporting period is Friday through Thursday.

For example:

Reporting period:
Friday, Week N → Thursday, Week N+1

Delivery folder:
Friday immediately after the reporting period

The first implementation named the weekly folder after the start date. That was incorrect for the operational workflow.

The folder must be named after the delivery Friday, which is one day after the reporting period ends.

The corrected logic is:

delivery_date = end_date + timedelta(days=1)

The date is then formatted using Italian month names because that is the existing archive convention.

This is a small detail, but deterministic folder naming is essential when automation must integrate with an established reporting process.

Filename separators on macOS

The desired visual date format used slashes:

31/07/2026 - 06/08/2026

However, / is a path separator and cannot be used as a literal filename character on POSIX filesystems.

The underlying filename therefore uses colons:

31:07:2026 - 06:08:2026

Finder may display colons as slashes, depending on how macOS represents the filename.

The exporter uses the filesystem-safe form internally while preserving the expected appearance in Finder.

Generating CSVs through the GA4 Data API

The CSV component does not use Playwright.

It authenticates with Google Analytics and calls the GA4 Data API directly.

A basic request includes:

  • a GA4 property ID;
  • one or more dimensions;
  • one or more metrics;
  • a start date;
  • an end date;
  • sorting and row-limit settings.

A simplified request looks like this:

request = RunReportRequest(
    property=f"properties/{property_id}",
    dimensions=[
        Dimension(name="pagePath"),
    ],
    metrics=[
        Metric(name="eventCount"),
    ],
    date_ranges=[
        DateRange(
            start_date=start_date.isoformat(),
            end_date=end_date.isoformat(),
        )
    ],
)

The response is converted into CSV rows with standard Python tools.

Different CSV requirements for different sites

The two CSV files intentionally use different schemas.

Site A

The first CSV contains only the selected period:

Page path,Event count
/page-one/,1234
/page-two/,987

Site B

The second CSV includes a comparison with the previous weekly period:

Page path,Event count,% Δ
/page-one/,1234,0.12
/page-two/,987,-0.08

The percentage change is calculated as:

\[
\text{change} = \frac{\text{current} – \text{previous}}{\text{previous}}
\]

If the previous value is zero or unavailable, the comparison field is left blank rather than producing an infinite or misleading result.

A simplified implementation is:

def percentage_change(current, previous):
    if previous in (None, 0):
        return ""

    return (current - previous) / previous

The comparison period is the previous Friday-through-Thursday week.

The exporter queries both periods, joins rows by page path, and computes the change only for the configuration that requires it.

Reusing OAuth credentials safely

The first GA4 run opens a browser-based OAuth flow.

After successful authentication, the credentials are cached in a private local token file.

Future runs can reuse that token until it expires or is revoked.

Files such as these must never be committed:

ga4.local.json
.ga4-token.json
client-secret.json

The token file should also use restrictive permissions:

chmod 600 .ga4-token.json

The public repository contains only example configuration files with placeholder property IDs and paths.

Sharing one date range between PDF and CSV components

Originally, the PDF and CSV tools prompted for their own reporting periods.

That preserved modularity but was inconvenient when both were executed together.

A unified Python coordinator was added to:

  1. prompt for the weekly period once;
  2. convert the selected dates to an exact machine-readable format;
  3. call the PDF batch exporter with those dates;
  4. call the CSV exporter with the same dates;
  5. verify the combined result.

The coordinator does not duplicate either component’s export logic.

Conceptually:

start_date, end_date = select_week()

run_pdf_export(start_date, end_date)
run_csv_export(start_date, end_date)

verify_all_outputs(start_date, end_date)

This creates a single source of truth for the reporting period.

Keeping independent commands

Although a unified command is convenient, independent commands remain valuable.

The following operations are still supported:

npm run batch
npm run csv
npm run all

This makes it possible to:

  • rerun only the PDFs;
  • regenerate only the CSVs;
  • test one component in isolation;
  • diagnose failures more easily;
  • avoid unnecessary GA4 API requests;
  • avoid unnecessary browser operations.

The coordinator composes the components instead of replacing them.

Selective PDF retry

PDFs are more vulnerable to visual rendering problems than CSV files.

If one PDF appears incomplete, rerunning the entire weekly workflow would:

  • repeat valid downloads;
  • generate unnecessary analytics reads;
  • consume additional time;
  • recreate CSVs that were already correct.

A selective retry tool was therefore added.

It supports identifiers such as:

Identifier Result
site-a-1 Retry the first PDF for Site A
site-a-2 Retry the second PDF for Site A
site-b-1 Retry the first PDF for Site B
site-b-2 Retry the second PDF for Site B
site-c-1 Retry Site C’s PDF
site-a Retry both Site A PDFs
site-b Retry both Site B PDFs
all Retry every configured PDF

Example:

npm run retry-pdf -- site-a-1 --period latest

An exact date range can also be supplied:

npm run retry-pdf -- site-a-1 --dates 2026-07-31 2026-08-06

Before replacing an existing PDF, the retry tool preserves the old file with a timestamp:

report.previous-20260809-153000.pdf

This gives the operator a recoverable history instead of immediately overwriting the previous result.

Dry-run support

Batch and retry operations support dry runs.

A dry run resolves:

  • the reporting period;
  • report identifiers;
  • destination folders;
  • filenames;
  • configuration values;
  • expected output count.

It does not open reports or download files.

Example:

npm run retry-pdf:dry-run -- site-a-1 --period latest

Dry runs are especially useful for validating date and path logic without consuming browser time or analytics queries.

Layered verification

The unified workflow verifies results at several levels.

Component-level verification

Each PDF export checks:

  • download event received;
  • destination file created;
  • file size;
  • PDF signature.

Each CSV export checks:

  • destination file created;
  • expected header;
  • data row count.

Coordinator-level verification

After both components finish, the unified runner checks all expected outputs again.

The summary includes:

  • selected period;
  • destination folder;
  • expected number of files;
  • component failures;
  • verification failures.

The run is considered successful only if all expected files pass verification.

This duplication is intentional. A component may report success but a later copy, rename, or output-path mistake could still affect the final file.

Configuration and privacy

The project separates reusable code from machine-specific and private configuration.

Safe to publish

README.md
package.json
package-lock.json
demo-date-control.mjs
setup-login.mjs
tools/
reports.example.json
ga4.example.json
.looker-report-url.example
.gitignore

Must remain private

.browser-profile/
.looker-report-url
reports.local.json
ga4.local.json
.ga4-token.json
client-secret.json
downloads/
generated PDFs
generated CSVs
screenshots
patch backups

The .gitignore file is treated as a security boundary, not merely as a convenience.

Before publishing, the repository should also be checked with:

git status --short
git ls-files
git diff --cached

Only intentional source and example configuration files should appear.

Privacy-safe AI handover

The project includes a small Python tool similar to files-to-prompt.

It generates a Markdown handover document containing:

  • a filtered directory tree;
  • selected source files;
  • configuration examples;
  • execution notes;
  • file boundaries;
  • relevant project context.

It excludes:

  • node_modules;
  • browser profiles;
  • OAuth tokens;
  • client secrets;
  • private report URLs;
  • local configuration;
  • downloads;
  • generated PDFs and CSVs;
  • screenshots;
  • backup files;
  • temporary artifacts.

This makes it possible to share the technical project with another AI assistant without manually copying every file or exposing authenticated sessions and private configuration.

A useful handover generator should be deny-by-default for known sensitive files.

Important development iterations

The project reached its stable design through several failures and corrections.

Iteration 1: automated Google login

Problem: Google rejected the automated browser.

Result: authentication moved to normal Chrome.

Iteration 2: persistent profile reuse

Problem: the profile was locked or the session did not reopen consistently.

Result: Playwright attached to the already-running Chrome process through CDP.

Iteration 3: global calendar selectors

Problem: duplicate day numbers caused the wrong date to be selected.

Result: selectors were scoped to start and end calendar containers and used full aria-label values.

Iteration 4: dates outside the visible month

Problem: the target date was not always displayed.

Result: independent month navigation was added to each calendar.

Iteration 5: optional checkbox validation

Problem: the exporter failed while searching for options it did not need.

Result: the dialog interaction was reduced to the required Download button.

Iteration 6: already-selected early exit

Problem: the program skipped the export when no date change was necessary.

Result: only the calendar operation is skipped; the rest of the workflow continues.

Iteration 7: single-report prototype

Problem: only one report was supported.

Result: a configuration-driven batch exporter was introduced.

Iteration 8: generic project download directory

Problem: completed reports were not placed in the operational archive.

Result: deterministic weekly folders and filenames were added.

Iteration 9: separate date prompts

Problem: PDFs and CSVs could accidentally use different periods.

Result: a unified coordinator selects the date once and passes it to both components.

Iteration 10: unnecessary full reruns

Problem: one incomplete PDF required recreating everything.

Result: selective PDF retry was added.

Iteration 11: incorrect weekly folder date

Problem: the output folder used the reporting start date.

Result: the folder now uses the Friday following the reporting end date.

Operational workflow

A typical weekly run is:

npm run login
npm run all

The first command is needed when the dedicated Chrome session is not already open or authenticated.

The second command:

  1. prompts for the reporting period;
  2. exports all configured PDFs;
  3. generates both CSV files;
  4. stores all files in the weekly folder;
  5. verifies every expected output;
  6. prints a final summary.

If one PDF needs to be regenerated:

npm run retry-pdf

The retry command prompts for the specific PDF and period.

Design principles

Several principles made the final workflow more reliable.

Prefer semantic selectors

Use:

  • aria-label;
  • stable component classes;
  • visible button roles;
  • stable test IDs;
  • container-scoped queries.

Avoid:

  • screen coordinates;
  • generated overlay IDs;
  • positional CSS selectors;
  • globally matching day numbers;
  • selectors based only on translated text when structural alternatives exist.

Verify state after every important action

Do not assume a click succeeded.

Read the resulting UI state and compare it with the requested state.

Keep credentials out of automation code

Authentication should use:

  • manual Google sign-in;
  • a dedicated browser profile;
  • OAuth;
  • private local configuration;
  • ignored token files.

Fail closed on browser safety

If the script cannot prove that it is attached to the dedicated automation profile, it should stop.

Keep output deterministic

Dates, filenames, directories, and report identifiers should be generated from configuration and explicit rules.

Preserve recoverability

Before replacing an existing retry result, keep a timestamped copy.

Separate orchestration from components

The unified runner should coordinate existing tools, not duplicate their logic.

Minimize unnecessary reads

Do not reload reports or call analytics APIs more often than required.

Verify deliverables, not just processes

A process exiting successfully is weaker evidence than verifying the files it was expected to create.

Current limitations

The workflow is reliable, but it still has limitations.

Visual PDF completeness is not fully automated

A PDF can have a valid header and reasonable file size while still containing an empty chart.

Future versions could render the PDF to images and perform visual checks.

Looker Studio selectors may change

The workflow relies on the rendered user interface. A major Looker Studio update may require selector maintenance.

The code therefore keeps selectors localized and logs each major UI action.

The dedicated Chrome session must remain available

Because Playwright attaches to a normal Chrome process, that process must be running during PDF export.

OAuth tokens may require renewal

A revoked or expired GA4 token may require another manual authorization.

A fixed rendering delay is approximate

The current delay works well for the reports tested, but unusually slow data sources may require a longer wait or richer readiness detection.

Possible future improvements

Useful next steps include:

  1. render downloaded PDFs to images;
  2. detect blank or nearly blank report regions;
  3. compare page count with historical reports;
  4. verify expected headings through PDF text extraction;
  5. add limited automatic retries with increasing wait times;
  6. save structured JSON run logs;
  7. record per-report execution duration;
  8. retain a manifest of generated files and checksums;
  9. send a completion notification;
  10. schedule the unified command through launchd;
  11. add a non-interactive mode for weekly execution;
  12. produce a compact HTML verification dashboard.

Automatic retries should remain conservative. Repeatedly refreshing a large analytics report can increase data-source load and make temporary quota problems worse.

Conclusion

The most important outcome was not simply automating a set of clicks.

The real improvement was converting a fragile manual routine into a controlled reporting pipeline with:

  • safe authentication;
  • browser-profile isolation;
  • semantic date selection;
  • date verification;
  • API-based CSV generation;
  • deterministic filenames and folders;
  • shared reporting periods;
  • selective retries;
  • privacy-safe configuration;
  • layered output verification.

The architecture is intentionally hybrid:

Playwright handles the presentation layer that must remain in Looker Studio, while the GA4 Data API handles structured data extraction directly.

That division keeps the solution practical, maintainable, and easier to troubleshoot.

A repetitive weekly process that once required opening multiple reports, changing dates, downloading files, renaming them, moving them, and checking them manually can now be executed through one coordinated command—while still preserving independent tools for testing and recovery.