Migrating Legacy WordPress Sites (PHP 7.1) to PHP 8.4 on an IPv6-Only VPS

Migrating one old WordPress site can be unpredictable. Migrating four of them simultaneously—from an obsolete operating system, an old PHP runtime, aging themes, and years of accumulated plugins—requires a process designed around containment rather than optimism.

The goal of this migration was not simply to copy files onto a new server. It was to establish a clean, verifiable baseline:

  • current WordPress core;
  • PHP 8.4;
  • Nginx and MariaDB on a modern Debian system;
  • Cloudflare-proxied public HTTPS;
  • the smallest practical active-plugin set;
  • preserved historical data and extensions;
  • reversible changes at every important stage.

This article reconstructs the procedure, including the failures that influenced the final design.

All domains, addresses, database names, credentials, and private paths have been anonymized.

1. The starting point

The source environment had several characteristics that made an in-place upgrade risky:

  • Ubuntu 16.04;
  • PHP 7.1;
  • an old Nginx release;
  • multiple WordPress installations;
  • legacy plugin versions from several different eras;
  • commercial plugins and themes that could not be updated through WordPress.org;
  • caching drop-ins and must-use plugins;
  • configuration code dependent on $_SERVER['HTTP_HOST'];
  • unknown PHP 8.4 compatibility across thousands of PHP files.

The destination was deliberately small:

  • one shared virtual CPU;
  • approximately 1 GB RAM;
  • approximately 10 GB storage;
  • IPv6-only public connectivity;
  • Debian 13;
  • PHP 8.4-FPM;
  • Nginx;
  • MariaDB;
  • Cloudflare in front of the origin.

A machine this small can serve modest WordPress sites, but it leaves little room for careless duplication, uncontrolled backups, or activating many resource-heavy plugins at once.

2. The central migration principle

The safest decision was to separate data from executable code.

The following items were treated as data that needed to survive:

  • database contents;
  • media uploads;
  • selected configuration values;
  • active theme files;
  • original plugin activation state;
  • legacy commercial packages for reference or recovery.

The following were treated as replaceable executable code:

  • WordPress core;
  • WordPress.org plugins;
  • caching drop-ins;
  • old plugin binaries;
  • obsolete bundled libraries.

This distinction made it possible to preserve the sites without immediately trusting every historical PHP file.

3. Build a complete inventory before changing anything

The initial inventory recorded:

  • WordPress roots;
  • database names and table prefixes;
  • canonical home and siteurl values;
  • active theme and child-theme relationships;
  • active plugins;
  • must-use plugins;
  • drop-ins such as advanced-cache.php;
  • PHP-FPM sockets;
  • enabled Nginx configurations;
  • service state;
  • available storage.

A simplified inventory structure looked like this:

SITES=(
    /var/www/site-a
    /var/www/site-b
    /var/www/site-c
    /var/www/site-d
)

for site in "${SITES[@]}"; do
    echo "=== $site ==="

    wp --allow-root --path="$site" \
        option get home \
        --skip-plugins \
        --skip-themes

    wp --allow-root --path="$site" \
        theme list \
        --status=active

    wp --allow-root --path="$site" \
        plugin list \
        --status=active
done

The original active-plugin state was stored separately before any database changes. This later made it possible to distinguish:

  • originally active plugins;
  • originally inactive plugins;
  • newly installed clean replacements;
  • plugins intentionally left isolated.

4. Disable and isolate legacy executable extensions

The old plugins were not deleted. They were moved outside the web roots into a root-only preservation directory.

Conceptually:

/root/migration-state/
├── active-plugins-before-php84.tsv
└── legacy-extensions/
    ├── site-a/
    │   ├── plugins/
    │   ├── mu-plugins/
    │   └── drop-ins/
    ├── site-b/
    ├── site-c/
    └── site-d/

The database activation lists were cleared before the sites were bootstrapped under PHP 8.4.

This avoided several classes of failure:

  • fatal errors from PHP-incompatible plugins;
  • automatic plugin database migrations;
  • old caching code running against the new Nginx configuration;
  • must-use plugins loading before ordinary plugins could be disabled;
  • compromised or unverifiable historical files becoming public again.

Preserving the code outside the web root retained forensic and recovery value without allowing it to execute.

5. Replace WordPress core with a verified clean copy

Instead of incrementally updating an unknown core tree, one verified current WordPress package was downloaded and reused for all four installations.

After installation, each site was checked against official checksums:

wp --allow-root --path="/var/www/site-a" \
    core verify-checksums

The databases were then checked and upgraded:

wp --allow-root --path="/var/www/site-a" \
    db check \
    --skip-plugins \
    --skip-themes

wp --allow-root --path="/var/www/site-a" \
    core update-db \
    --skip-plugins \
    --skip-themes

In this migration, the databases successfully moved from substantially older WordPress database versions to the database version expected by WordPress 7.0.3.

The important point was sequencing:

  1. disable executable extensions;
  2. install clean core;
  3. verify core checksums;
  4. check the database;
  5. run the core database upgrade;
  6. test the active theme without plugins.

6. Remove request-dependent canonical URL configuration

Several historical wp-config.php files generated home and siteurl from:

$_SERVER['HTTP_HOST']

That produced WP-CLI warnings because command-line requests do not have a normal HTTP host:

PHP Warning: Undefined array key "HTTP_HOST"

It was also undesirable from a security and operational perspective. Canonical URLs should not depend blindly on an incoming Host header.

The obsolete assignments were removed, while canonical values remained in the WordPress database.

After correction:

  • PHP syntax checks passed;
  • WP-CLI stopped requiring an HTTP request context;
  • every site returned its intended canonical HTTPS URL;
  • no database password or secret was printed.

7. Test themes independently from plugins

Before exposing the sites publicly, every active theme was scanned with PHP 8.4:

find "/var/www/site-a/wp-content/themes/active-theme" \
    -type f \
    -name '*.php' \
    -print0 |
while IFS= read -r -d '' file; do
    php -l "$file" >/dev/null
done

The active themes passed syntax validation and WordPress bootstrap tests.

This did not prove that every visual feature worked, but it established that:

  • PHP could parse every theme file;
  • WordPress could bootstrap the theme;
  • the homepage could render without active plugins.

That clean theme-only baseline was essential. Without it, a later failure could have come from core, the theme, a plugin, Nginx, PHP-FPM, or the database.

8. Use local-only Nginx staging before public cutover

The migrated sites were first exposed only on:

127.0.0.1:8080

A simplified staging virtual host looked like this:

server {
    listen 127.0.0.1:8080;
    server_name site-a.example;

    root /var/www/site-a;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        try_files $uri =404;

        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME
            $document_root$fastcgi_script_name;

        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }
}

This allowed the full Nginx-to-PHP-FPM-to-WordPress path to be tested without publishing the migration.

Example local request:

curl \
  --resolve site-a.example:8080:127.0.0.1 \
  -I \
  http://site-a.example:8080/

Each site was tested for:

  • homepage HTTP 200;
  • login-page HTTP 200;
  • correct content type;
  • absence of new Nginx errors.

A listener-check failure

The first staging attempt passed nginx -t but reported that Nginx was not listening on 127.0.0.1:8080.

The script rolled back the new files.

The corrected attempt explicitly waited for the listener after reloading Nginx:

nginx -t
systemctl reload nginx

for attempt in {1..20}; do
    if ss -ltn | grep -q '127.0.0.1:8080'; then
        break
    fi
    sleep 0.25
done

The corrected listener appeared and all four local sites returned HTTP 200.

The lesson was simple: a valid configuration file does not prove that the intended listener is active.

9. Treat plugin recovery as a staged compatibility exercise

A syntax scan of the preserved plugins found many PHP 8.4 failures.

Examples included:

  • old Jetpack libraries using syntax removed from modern PHP;
  • outdated UpdraftPlus vendor libraries;
  • an old Bridge Core release;
  • a dependency using match as an identifier after match became a PHP keyword;
  • obsolete nested ternary expressions.

A successful lint scan also did not guarantee runtime compatibility, but a failed lint scan was enough to reject a package.

The legacy files were never patched in place. Clean packages were downloaded instead.

For WordPress.org plugins:

wp --allow-root --path="/var/www/site-a" \
    plugin install classic-editor

wp --allow-root --path="/var/www/site-a" \
    plugin verify-checksums classic-editor

The clean packages were:

  1. installed inactive;
  2. verified against WordPress.org checksums;
  3. scanned with PHP 8.4;
  4. tested without activation;
  5. activated individually only when necessary.

After every activation:

  • WordPress bootstrap was tested;
  • the homepage was requested;
  • the login page was requested;
  • active state was verified.

10. Keep high-risk plugins inactive until justified

Several plugins were intentionally deferred:

  • Jetpack;
  • backup plugins;
  • database optimization plugins;
  • page builders;
  • importers;
  • caching plugins;
  • multilingual plugins;
  • commercial plugins without current packages.

This reduced both debugging complexity and runtime cost.

A minimal site that works is a more useful recovery baseline than a feature-complete site whose failures cannot be attributed.

11. Update commercial theme packages separately

One site depended on a commercial multipurpose theme and companion plugins.

The newly downloaded licensed package contained:

  • Bridge theme 30.8.9.1;
  • Bridge Core 3.3.4.9;
  • WPBakery;
  • Slider Revolution;
  • LayerSlider;
  • several optional Qode extensions.

The outer archive and nested archives were inspected before installation.

The required theme and core plugin were extracted into staging and scanned:

Bridge theme: 388 PHP files; 0 syntax failures
Bridge Core: 736 PHP files; 0 syntax failures

The upgrade sequence was:

  1. create a database checkpoint;
  2. preserve the previous theme files;
  3. install the new theme;
  4. test the theme with its core plugin inactive;
  5. activate Bridge Core;
  6. test again;
  7. activate Elementor;
  8. flush rewrite rules;
  9. flush Elementor-generated CSS;
  10. verify final versions and page responses.

A PHP 8.4 deprecation notice appeared in Elementor concerning an implicitly nullable parameter. It was non-fatal, but it was recorded rather than ignored.

WPML remained absent and inactive during this stage.

12. Publish through Cloudflare with strict origin TLS

The server had public IPv6 but no usable public IPv4. Cloudflare proxied AAAA records were therefore used to reach the origin.

Before cutover:

  • the origin returned the sites correctly on the local listener;
  • public Cloudflare requests returned origin-connection failures;
  • Nginx listened publicly only on port 80;
  • no public HTTPS listener existed.

Cloudflare Origin CA certificates were created separately for the two domain families. Each certificate covered:

*.example-domain.tld
example-domain.tld

Certificate and key pairs were:

  • stored outside the web roots;
  • owned by root;
  • given restrictive permissions;
  • checked for matching public keys;
  • inspected for correct subject alternative names;
  • never printed into logs or documentation.

Cloudflare SSL/TLS mode was set to:

Full (strict)

A simplified public TLS virtual host looked like this:

server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name site-a.example;

    ssl_certificate
        /etc/ssl/cloudflare/example-origin.pem;

    ssl_certificate_key
        /etc/ssl/cloudflare/example-origin.key;

    root /var/www/site-a;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include fastcgi_params;

        fastcgi_param SCRIPT_FILENAME
            $document_root$fastcgi_script_name;

        fastcgi_param HTTPS on;

        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }
}

Duplicate try_files

The first public configuration failed:

"try_files" directive is duplicate

The shared PHP snippet already contained a try_files directive, while the new location added another.

The correction was to choose one coherent PHP-routing definition instead of combining two partially overlapping snippets.

Port 443 not detected

A later configuration passed nginx -t but the validation script checked port 443 too quickly and rolled back.

The final cutover:

  • validated configuration syntax;
  • reloaded Nginx;
  • waited for the listener;
  • confirmed both IPv4 and IPv6 port 443 sockets;
  • tested every hostname directly through Nginx;
  • tested the same hostnames through Cloudflare.

Final public results:

  • subdomains returned HTTP 200;
  • apex domains returned intentional HTTP 301 redirects;
  • Nginx, PHP-FPM, and MariaDB remained active;
  • the local port 8080 fallback remained available.

13. Replace Jetpack-only logo functionality with native WordPress support

Some sites had relied on Jetpack’s Site Logo feature.

Activating all of Jetpack merely to render a logo would have added unnecessary code and requests. The themes were therefore adapted to native WordPress custom-logo support.

Theme setup:

add_theme_support(
    'custom-logo',
    array(
        'height'      => 200,
        'width'       => 600,
        'flex-height' => true,
        'flex-width'  => true,
    )
);

Header rendering:

if (
    function_exists( 'the_custom_logo' )
    && has_custom_logo()
) {
    the_custom_logo();
}

One migrated database still stored a Jetpack-era logo structure rather than the integer attachment ID expected by native WordPress.

The preserved structure referenced an attachment ID. After confirming that the media attachment existed, the native theme modification was set to that integer.

The final rendered markup was similar to:

<a
  href="https://site.example/"
  class="custom-logo-link"
  rel="home"
>
  <img
    width="75"
    height="75"
    src="https://site.example/wp-content/uploads/logo.png"
    class="custom-logo"
    alt="Site name"
  />
</a>

Jetpack remained inactive.

14. Debug WordPress dashboard installation permissions precisely

Later, Classic Editor failed to install from the dashboard:

Installation failed: Could not create directory.
wp-content/upgrade/classic-editor.1.7.0

The diagnostic compared the PHP-FPM identity with every relevant directory:

stat -c \
  'mode=%a owner=%U group=%G path=%n' \
  /var/www/site-main/wp-content/upgrade

runuser -u www-data -- \
  test -w /var/www/site-main/wp-content/upgrade

Everything relevant was owned by www-data:www-data except:

wp-content/upgrade
mode=755 owner=root group=root
www-data write access: NO

The correction was intentionally narrow:

chown www-data:www-data \
  /var/www/site-main/wp-content/upgrade

chmod 0755 \
  /var/www/site-main/wp-content/upgrade

A write test as www-data passed afterward.

Classic Editor 1.7.0 could then be:

  • installed;
  • deleted;
  • reinstalled;
  • activated

through the WordPress dashboard.

This demonstrated why chmod 777 is almost never the correct response to a WordPress permission error. The problem was ownership of one specific temporary directory.

15. Automatically summarize homepage articles

The Penscratch theme displayed complete articles on the homepage unless the author manually inserted:

<!--more-->

The desired behavior was:

  • automatically show a short homepage teaser;
  • preserve manual More tags as author-controlled overrides;
  • preserve old articles containing manual markers;
  • keep single articles complete;
  • retain lists, code blocks, quotes, and other formatting;
  • avoid rewriting database content.

First attempt: wp_trim_words()

The initial automatic implementation used:

$summary = wp_trim_words(
    $source,
    80,
    '…'
);

This produced the correct length, but WordPress intentionally removes HTML from wp_trim_words() output.

Lists, code, and other structures became plain text.

Second attempt: cut at a complete block

The next version located the first block ending after approximately 80 words and inserted a temporary More marker there.

This preserved:

  • paragraphs;
  • lists;
  • code blocks;
  • blockquotes;
  • tables;
  • figures.

However, changing the target from 80 to 60 appeared to do nothing. Both word positions were inside the same block, so both selected the same closing tag.

Final approach: exact visible-word position

The final implementation counts visible text while ignoring:

  • HTML tags;
  • HTML comments;
  • shortcode markup.

The tokenizer is:

preg_match_all(
    '/<!--.*?-->|<[^>]*>|\[[^\]]*\]|[^<\[]+/s',
    (string) $raw_content,
    $content_tokens,
    PREG_OFFSET_CAPTURE
);

Words are counted only inside visible-text tokens:

preg_match_all(
    '/\S+/u',
    $token,
    $word_matches,
    PREG_OFFSET_CAPTURE
);

At the 60th word, the byte position is recorded. The current post is then cloned:

$virtual_post = get_post();
$virtual_post = clone $virtual_post;

A temporary ellipsis and More marker are inserted into the clone:

$virtual_post->post_content =
    substr(
        $raw_content,
        0,
        $cut_position
    )
    . '&hellip;'
    . "\n<!--more-->\n"
    . substr(
        $raw_content,
        $cut_position
    );

The clone is rendered through WordPress:

$rendered_content = get_the_content(
    __(
        'Continue reading <span class="meta-nav">&rarr;</span>',
        'penscratch'
    ),
    false,
    $virtual_post
);

$rendered_content = apply_filters(
    'the_content',
    $rendered_content
);

The saved post is never changed.

Manual More tags still win

Before automatic processing, the raw content is checked:

$has_more_tag = (bool) preg_match(
    '/<!--more(?:.*?)?-->/is',
    (string) $raw_content
);

The template decision becomes:

Search page
    -> normal WordPress excerpt

Homepage with manual More tag
    -> native WordPress content teaser
    -> author-selected position

Homepage without manual More tag
    -> automatic 60-word formatted teaser
    -> ellipsis
    -> Continue reading link

Single article
    -> complete content

16. Why complete-file staging was safer than repeated patching

Two automatic patch attempts failed safely.

One generated invalid PHP containing an unexpected backslash:

PHP Parse error:
syntax error, unexpected token "\"

The rollback restored the previous valid file.

A second patch relied on matching an explanatory comment exactly and stopped with:

ERROR: Expected old explanatory comment was not found.

It failed before deployment, so the live theme was unchanged.

The final strategy stopped trying to rewrite a small section of an evolving file. Instead, it:

  1. backed up the complete live file;
  2. wrote a complete candidate outside the active theme;
  3. ran PHP lint against the candidate;
  4. deployed only if lint succeeded;
  5. linted the deployed file;
  6. bootstrapped WordPress;
  7. tested the homepage, pagination, and a single post;
  8. retained rollback until every test passed.

Conceptually:

cp content.php content.php.backup

write_complete_candidate

php -l content.php.candidate

cp content.php.candidate content.php

php -l content.php
wp eval 'echo "WordPress bootstrap: OK\n";'

test_homepage
test_pagination
test_single_article

This pattern is useful beyond WordPress. When a configuration or source file has already passed through several iterations, replacing a verified complete candidate can be safer than increasingly fragile regular-expression surgery.

17. Final validation

At the final stopping point:

  • all four sites were publicly available through Cloudflare;
  • Nginx configuration passed validation;
  • ports 80 and 443 were listening;
  • PHP 8.4-FPM was active;
  • MariaDB was active;
  • WordPress core verified successfully;
  • database upgrades completed;
  • active themes bootstrapped;
  • selected plugins were installed from clean packages;
  • high-risk plugins remained inactive;
  • the commercial theme and required companion plugin were current;
  • WPML remained deferred;
  • Jetpack remained inactive;
  • native logos rendered correctly;
  • Classic Editor installed and activated correctly;
  • automatic 60-word homepage teasers worked;
  • manual More tags remained authoritative;
  • homepage, paginated homepage, login, and single-post tests returned HTTP 200;
  • no post content was rewritten by the teaser system.

18. Lessons from the migration

Make the clean baseline boring

A clean WordPress core, one active theme, and no plugins may look incomplete, but it gives every later failure a smaller cause set.

Preserve old code without executing it

Deleting legacy code immediately destroys recovery information. Leaving it in the web root risks execution. Root-only isolation provides a useful middle ground.

Syntax checks are necessary but insufficient

A PHP file can lint successfully and still fail because of:

  • removed runtime behavior;
  • deprecated APIs;
  • incompatible database assumptions;
  • JavaScript errors;
  • plugin interactions.

Linting is the first gate, not the last.

Activate one component at a time

Batch activation makes failures ambiguous. Sequential activation makes rollback obvious.

Never confuse configuration validity with runtime state

nginx -t proves syntax, not listener availability, routing correctness, certificate reachability, or application health.

Avoid broad permission changes

When one directory is unwritable, inspect that directory and its parent chain. Do not recursively make the entire WordPress tree web-writable.

Treat CDN and origin as separate systems

A successful direct-origin test and a successful Cloudflare test prove different things. Both are necessary.

Prefer native functionality over large dependency bundles

A few lines of native custom-logo support replaced the need to activate Jetpack solely for logo rendering.

Stage generated source before deployment

If code is produced programmatically, write it outside the active application, lint it, and only then deploy it.

Record failed attempts

The failed listener checks, duplicate Nginx directive, cache warning, invalid WP-CLI field, PHP parse error, and fragile comment match all contributed to a safer final procedure.

19. Remaining risks and future improvements

The system is operational, but several maintenance concerns remain.

Direct parent-theme modifications

The customized templates can be overwritten by a theme update.

A future iteration should move customizations into:

  • a child theme; or
  • a narrowly scoped custom plugin plus minimal template overrides.

Exact token counting

The teaser algorithm counts whitespace-separated visible tokens. Edge cases include:

  • Chinese or Japanese text without spaces;
  • unusual HTML entities;
  • deeply nested Gutenberg structures;
  • dynamic blocks;
  • complex shortcodes;
  • very large code blocks.

These are known limitations rather than confirmed failures.

Plugin write permissions

Allowing PHP-FPM to modify plugin code makes dashboard installation possible, but it also increases the consequences of a compromised WordPress administrator account.

For stricter production systems, plugin deployment through a controlled command-line or CI process is preferable.

Small-server resource limits

A 1 GB server requires continued discipline:

  • avoid running multiple backup jobs simultaneously;
  • monitor disk usage;
  • avoid unnecessary page builders and optimization plugins;
  • limit PHP-FPM workers appropriately;
  • keep database and log growth under observation;
  • retain off-server backups.

Conclusion

The most important result was not merely that four WordPress sites came back online. It was that they emerged with a comprehensible operational state.

The final platform had:

  • a verified core;
  • known database versions;
  • a modern PHP runtime;
  • explicit Nginx routing;
  • strict Cloudflare origin TLS;
  • a minimal plugin surface;
  • current licensed theme components;
  • native replacements for unnecessary plugin features;
  • documented rollback points;
  • automatic formatted homepage teasers;
  • a repeatable method for future changes.

Legacy WordPress migrations become dangerous when every historical component is allowed to return at once. They become manageable when code is isolated, state is recorded, changes are staged, and every layer is tested independently.

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[&quot;Select one weekly period&quot;] –&gt; B[&quot;PDF component&quot;]
A –&gt; C[&quot;CSV component&quot;]
B –&gt; D[&quot;Chrome + Playwright&quot;]
C –&gt; E[&quot;GA4 Data API&quot;]
D –&gt; F[&quot;Verify PDFs&quot;]
E –&gt; G[&quot;Verify CSVs&quot;]
F –&gt; H[&quot;Weekly output folder&quot;]
G –&gt; 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.

Sharing Port 8080 Between qBittorrent and V2Ray with Nginx (One Public Port, Two Applications)

Introduction

I recently faced a network configuration problem that initially looked simple:

How can I install V2Ray on a VPS when the only usable general-purpose public port is already occupied by qBittorrent?

The server was an Oracle Cloud Ubuntu VPS running qbittorrent-nox. Its WebUI was publicly accessible on TCP port 8080, while BitTorrent peer traffic used TCP and UDP port 47374.

Other candidate ports, including 80, 443, and 56894, were locally unused. However, external testing showed that they were not reachable through the Oracle Cloud network configuration. I did not want to modify the Oracle Virtual Cloud Network rules again, interrupt qBittorrent whenever I needed V2Ray, or expose unnecessary additional ports.

Continue reading

File Share authentication issue in MacOS (including SMB and AFP) in an unusual circumstance

*updated on September 19, 2022

I tried the MacOS native File Share feature since I need to share data between the two Macs via LAN. Naturally, this function, which by default is based on SMB, also supports AFP.

An accidental irreversible event happened during the attempt to authenticate, and the error form seemed to have the incorrect user name or password. However, I am confident that the login credentials are correct. Even after reinstalling the system from High Sierra to Catalina (this procedure has taken a long time….), the issue has not been fixed, the error is still present. This outcome looks absurd.

The error message in the console is:

smbd transact: gss_accept_sec_context: major_status: 0xd0000, minor_status: 0xa2e9a74a

After looking around, I found this prompt to be quite inspiring: https://discussions.apple.com/thread/8318535

Solution: synchronize both the time and time zone of two Macs. Issue is resolved.

Consideration: Rather than starting over with a fresh installation of the system whenever an unclear issue arises, searching for the relevant log in Console will be more essential.

Installation and optimization of “Audiophonics ES9028Q2M”

*updated on December 24, 2017

Preface


I really shouldn’t say much about the desire to have a wireless PC-Hifi setup. Despite having a large collection of CDs and LPs, I feel that digitizing my music library would be beneficial in some circumstances.

Though Audiophonics’ official website shows compatibility with Volumio (a multi-platform music player), I have spent a great deal of time in the last several days and, honestly, had quite a terrible experience when piecing together Audiophonics and Volumio.1 Even so, I am really pleased with the sound from DietPi, which is more distinct and pure, as well as the efficient start time and UI design, and, ultimately, the stability when compared to Volumio.

As a result, the purpose of this article is to serve as a resource for serious philharmonicgeeks who have a limited budget but a need for “adequate” high fidelity quality.

The General Workflow


The Workflow of Signal Connection

Continue reading