Formula Duude Archive Project

October 01, 2026 12:13am

Bringing a 2008-2017 GTR2 fantasy-league blog home: a WordPress-to-HTML converter, dedicated tables, and links to the real race results.

Overview

Formula Duude was a GTR2 fantasy league I ran with friends between 2008 and 2017. Every race weekend came with press releases, press conferences and race notes, all written in character, with drivers and team bosses borrowed from pop culture. The blog lived on WordPress, and with every year that passed, more of its images, videos and links went dead.

This project brings the whole archive home: 435 posts, 7 pages and 63 comments, converted from a WordPress dump into clean HTML, browsable at /formuladuude, and linked to the actual race results in the Racing module.

Technical Stack

Area Technologies
Converter PHP 8 CLI script: DOMDocument, regular expressions, cURL for dead-link detection
Backend PHP 8.3, OOP (FDuudeRepo / FDuudeRender)
Database MySQL 8.0 via PDO, dedicated tables
Admin TinyMCE editor, comment moderation, category management

Architecture & Design

Why not regular articles?

The obvious option was to import every post as a site article. It would have been a mistake: over 400 archive posts would have flooded the sitemap, the admin page list and the /page/ URL space. The archive gets its own tables instead:

  • new_horta_fduude_posts: posts and pages, with the original publication date, author, excerpt, cleaned content, list image and the original WordPress id.
  • new_horta_fduude_categories: seasons (FD01 to FD06), post types (press release, press conference, race notes…) and individual race events as children of their season.
  • new_horta_fduude_comments: read-only comments, imported without e-mail addresses or IPs.

The original blog also had almost 1,500 inconsistent tags. They were left behind: seasons and post types turned out to be the only taxonomy anyone needs to browse the archive.

A one-off converter

A CLI script, run only on my machine, reads the WordPress database dump and media folder and writes two SQL files: a schema file that runs once, and a data file that can be re-run to wipe and refill the archive tables. Along the way it:

  • expands WordPress shortcodes ([caption], [gallery], [slideshow], both [youtube] syntaxes), including galleries from the wordpress.com era whose attachments were orphaned;
  • ports WordPress's wpautop paragraph logic, so posts written without explicit paragraphs still read correctly;
  • cleans the HTML with a DOMDocument allowlist of tags and attributes;
  • remaps image URLs to local copies (only files that are actually referenced get copied) and swaps Wikimedia flag images for the site's own;
  • detects dead external hosts, and writes a report of every missing replay, skin and image, so they can be restored later if they turn up.

Key Features

  • Browse and filter by season (which includes its race events), post type, free text and a date range.
  • Linked results: each season links to its championship in the Racing module, and each race write-up gets "Race N results" buttons pointing to the actual session.
  • Previous / next navigation between posts, in publication order.
  • Lightbox images: content images open full-size, using the original file when a thumbnail was embedded.
  • Admin: edit posts in TinyMCE, manage categories, and enable, disable or delete comments.

Challenges & Solutions

Challenge Solution
Linking posts to races Posts and race sessions share no ids. The converter matches each race write-up to the latest race day of that season within the 5 days before the post, which mirrors how the blog was actually written.
Different track names The blog and the result logs didn't always agree (A1 Ring vs Red Bull Ring, Le Mans vs La Sarthe as a single endurance race covering two rounds). An alias map in the converter reconciles them.
Re-running without losing edits A full re-import wipes the tables, so it's only for the initial load. A separate --relink mode restores found media in place with UPDATE … REPLACE() on marked placeholders, so edits made in the admin survive.
Keeping the editor from stripping markup TinyMCE is configured to allow the archive's own data attributes and classes, so full-size image links and missing-media markers survive an edit.

Security & Best Practices

  1. Bound parameters everywhere: unlike some older modules, the archive's filters return an SQL fragment together with its parameters, so even free-text search is fully parameterized, with LIKE wildcards escaped.
  2. Allowlist HTML cleaning at import time: anything not explicitly allowed is removed from the old content before it reaches the database.
  3. Privacy: commenter e-mails and IPs were never imported.
  4. Local-only tooling: the converter lives in a folder that's both ignored by git and excluded from deployment, so it never reaches the server.

Results & Learnings

The archive is back, readable and linked to the races it talks about. The key learning was that content migrations are data migrations: the converter needed the same care as a database importer, with repeatable runs, reports of what couldn't be converted, and a safe way to patch data after the fact.

It also had a fun side effect. The in-character writing style of the blog became the persona behind the site's Race Reports, so the Formula Duude press corps is writing again, many years later.

The converter works like an archivist restoring a box of old newspapers: it flattens every page, replaces the photos that faded, notes on an index card every one that couldn't be found, and files each story next to the race it was written about.

Code Snippets

1. Filters as SQL plus bound parameters

$search = trim((string)Component::input('search'));
if ($search !== '') {
    $sql .= " AND (p.title LIKE :s1 OR p.excerpt LIKE :s2 OR p.content LIKE :s3 OR p.author LIKE :s4)";
    $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $search) . '%';
    $params += ['s1' => $like, 's2' => $like, 's3' => $like, 's4' => $like];
}
// a season also matches its race-event children
$season = (int)Component::input('filter-season');
if ($season) {
    $sql .= " AND EXISTS (SELECT 1 FROM new_horta_fduude_post_category pc JOIN new_horta_fduude_categories c ON c.id = pc.category_id
                          WHERE pc.post_id = p.id AND (c.id = :season OR c.parent_id = :season2))";
    $params += ['season' => $season, 'season2' => $season];
}
return ['sql' => $sql, 'params' => $params];

2. Using them

public function getResultsCount(array $filters): int
{
    $stmt = $this->pdoRead->prepare("SELECT COUNT(*) FROM new_horta_fduude_posts p
        WHERE p.enabled = 1 AND p.kind = 'post' {$filters['sql']}");
    $stmt->execute($filters['params']);
    return (int)$stmt->fetchColumn();
}

go to the archive