Building a Robust Video URL Canonicalization Pipeline in PHP 8.4

php dev.to

Two different crawlers on my aggregator once ingested the same YouTube clip four times in a single afternoon. One came in as youtube.com/watch?v=dQw4w9WgXcQ&feature=share, another as youtu.be/dQw4w9WgXcQ?t=42, a third as m.youtube.com/watch?v=dQw4w9WgXcQ&utm_source=trending, and the last as a youtube-nocookie.com embed URL. To my database they were four distinct rows, four distinct thumbnails, four distinct FTS5 documents, and four distinct chances to show a user the exact same video twice on one trending page. On TopVideoHub, where I aggregate trending video across Japan, Korea, Taiwan, Hong Kong, Thailand, Vietnam and Singapore, that duplication multiplies fast: the same viral clip surfaces through a dozen regional feeds, each decorating the URL with its own tracking junk. This is the story of the canonicalization pipeline I built to collapse all of those into one stable identity — and the surprisingly large number of edge cases that live between a raw URL string and a clean primary key.

Why a URL is a terrible primary key

The naive approach is to store the URL as-is and slap a UNIQUE index on it. This fails immediately because URLs carry three categories of noise that have nothing to do with which video is being referenced:

  • Tracking and session parameters: utm_*, feature, si, pp, gclid, fbclid, and platform-specific referral tokens.
  • Positional and playback state: t=42, start=120, list=, autoplay flags, and quality hints. These describe how you're watching, not what you're watching.
  • Host and scheme variance: youtu.be vs www.youtube.com vs m.youtube.com, http vs https, trailing slashes, percent-encoding differences, and uppercase hosts.

The insight that makes canonicalization tractable is that almost every video platform has a stable, opaque video ID buried somewhere in the URL. YouTube has an 11-character ID, Vimeo has a numeric ID, Bilibili has a BV ID, Dailymotion has a short alphanumeric. If I can extract (platform, video_id), I have a canonical identity that survives every cosmetic mutation of the URL. The canonical URL is then something I reconstruct from that pair, not something I try to clean up in place.

So the pipeline has two jobs, in this order:

  1. Identify: parse the raw URL down to (platform, video_id).
  2. Reconstruct: build one deterministic canonical URL from that pair.

Everything else — dedup, storage, the FTS5 index — hangs off step 1.

Extracting a stable identity per platform

I model each platform as an extractor: a host matcher plus a set of rules for finding the ID. In PHP 8.4 I lean on readonly classes and first-class callable syntax to keep these declarative and testable.

<?php
declare(strict_types=1);

final readonly class VideoIdentity
{
    public function __construct(
        public string $platform,
        public string $videoId,
    ) {}

    public function key(): string
    {
        return $this->platform . ':' . $this->videoId;
    }
}

final class Canonicalizer
{
    /** @var array<string, callable(array): ?VideoIdentity> */
    private array $extractors;

    public function __construct()
    {
        $this->extractors = [
            'youtube'     => $this->extractYouTube(...),
            'vimeo'       => $this->extractVimeo(...),
            'bilibili'    => $this->extractBilibili(...),
            'dailymotion' => $this->extractDailymotion(...),
        ];
    }

    public function identify(string $rawUrl): ?VideoIdentity
    {
        $parts = parse_url($this->preNormalize($rawUrl));
        if ($parts === false || !isset($parts['host'])) {
            return null;
        }

        $parts['host'] = strtolower($parts['host']);
        $parts['host'] = preg_replace('/^(www|m|mobile)\./', '', $parts['host']);

        foreach ($this->extractors as $extract) {
            if ($identity = $extract($parts)) {
                return $identity;
            }
        }
        return null;
    }

    private function preNormalize(string $url): string
    {
        $url = trim($url);
        // Force a scheme so parse_url treats the host correctly.
        if (!preg_match('#^https?://#i', $url)) {
            $url = 'https://' . ltrim($url, '/');
        }
        return $url;
    }
}
Enter fullscreen mode Exit fullscreen mode

The host normalization step — lowercasing and stripping www./m./mobile. — is small but eliminates a whole class of duplicates before any platform logic runs. The individual extractors then each know exactly one thing:

<?php

trait PlatformExtractors
{
    private function extractYouTube(array $p): ?VideoIdentity
    {
        $host = $p['host'];
        if ($host === 'youtu.be') {
            $id = ltrim($p['path'] ?? '', '/');
            return $this->makeYouTube($id);
        }
        if (!in_array($host, ['youtube.com', 'youtube-nocookie.com'], true)) {
            return null;
        }
        parse_str($p['query'] ?? '', $q);
        if (isset($q['v'])) {
            return $this->makeYouTube($q['v']);
        }
        // /embed/ID, /shorts/ID, /live/ID, /v/ID
        if (preg_match('#^/(embed|shorts|live|v)/([^/?&]+)#', $p['path'] ?? '', $m)) {
            return $this->makeYouTube($m[2]);
        }
        return null;
    }

    private function makeYouTube(string $id): ?VideoIdentity
    {
        // YouTube IDs are exactly 11 chars of [A-Za-z0-9_-].
        return preg_match('/^[A-Za-z0-9_-]{11}$/', $id)
            ? new VideoIdentity('youtube', $id)
            : null;
    }

    private function extractVimeo(array $p): ?VideoIdentity
    {
        if ($p['host'] !== 'vimeo.com') {
            return null;
        }
        if (preg_match('#^/(?:video/)?(\d+)#', $p['path'] ?? '', $m)) {
            return new VideoIdentity('vimeo', $m[1]);
        }
        return null;
    }

    private function extractBilibili(array $p): ?VideoIdentity
    {
        if (!str_ends_with($p['host'], 'bilibili.com')) {
            return null;
        }
        if (preg_match('#/video/(BV[A-Za-z0-9]+)#', $p['path'] ?? '', $m)) {
            return new VideoIdentity('bilibili', $m[1]);
        }
        return null;
    }

    private function extractDailymotion(array $p): ?VideoIdentity
    {
        $host = $p['host'];
        if ($host === 'dai.ly') {
            $id = ltrim($p['path'] ?? '', '/');
        } elseif ($host === 'dailymotion.com'
            && preg_match('#^/video/([^/?_]+)#', $p['path'] ?? '', $m)) {
            $id = $m[1];
        } else {
            return null;
        }
        return preg_match('/^[a-zA-Z0-9]+$/', $id)
            ? new VideoIdentity('dailymotion', $id)
            : null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that every extractor validates the ID shape before trusting it. This matters more than it looks. A malformed feed once handed me youtube.com/watch?v=undefined, and without the {11} length check I'd have created a canonical row for a video that doesn't exist. Validation at the extraction boundary is the cheapest place to reject garbage.

Reconstructing the canonical URL

Once I have (platform, video_id), the canonical URL is pure template substitution. I never mutate the input URL — I throw it away and build a fresh one. This guarantees byte-for-byte identical output regardless of how mangled the input was.

<?php

final class CanonicalUrlBuilder
{
    private const TEMPLATES = [
        'youtube'     => 'https://www.youtube.com/watch?v=%s',
        'vimeo'       => 'https://vimeo.com/%s',
        'bilibili'    => 'https://www.bilibili.com/video/%s',
        'dailymotion' => 'https://www.dailymotion.com/video/%s',
    ];

    public function build(VideoIdentity $id): string
    {
        $template = self::TEMPLATES[$id->platform]
            ?? throw new \InvalidArgumentException("Unknown platform: {$id->platform}");
        return sprintf($template, rawurlencode($id->videoId));
    }

    /** Stable 64-bit dedup key, independent of URL form. */
    public function fingerprint(VideoIdentity $id): string
    {
        return substr(hash('sha256', $id->key()), 0, 16);
    }
}
Enter fullscreen mode Exit fullscreen mode

The fingerprint() method is the actual primary dedup key. I hash the platform:video_id pair rather than the URL, so two rows collide only when they genuinely reference the same video. Truncating SHA-256 to 16 hex chars (64 bits) is plenty for a corpus in the low millions — the birthday-collision probability is negligible and it indexes tighter than a full 64-char digest in SQLite.

Wiring it into SQLite with FTS5

My storage layer is SQLite because it deploys as a single file behind LiteSpeed with zero external dependencies, and its FTS5 module gives me full-text search with a CJK-aware tokenizer — essential when the same trending page mixes Japanese, Korean and Traditional Chinese titles. The canonical fingerprint becomes a UNIQUE column, and the FTS index is populated from the canonical row only.

CREATE TABLE videos (
    id           INTEGER PRIMARY KEY,
    fingerprint  TEXT NOT NULL UNIQUE,
    platform     TEXT NOT NULL,
    video_id     TEXT NOT NULL,
    canonical_url TEXT NOT NULL,
    title        TEXT NOT NULL,
    region       TEXT NOT NULL,
    created_at   INTEGER NOT NULL
);

-- CJK tokenizer: trigram handles languages without word boundaries.
CREATE VIRTUAL TABLE videos_fts USING fts5(
    title,
    content='videos',
    content_rowid='id',
    tokenize='trigram'
);
Enter fullscreen mode Exit fullscreen mode

The insert path is an idempotent upsert. Because the fingerprint is deterministic, ingesting the same video from a seventh regional feed is a no-op on the identity row — I only append the new region to a mapping table so I still know the clip is trending in, say, both Vietnam and Thailand.

<?php

final class VideoRepository
{
    public function __construct(
        private \PDO $db,
        private Canonicalizer $canon,
        private CanonicalUrlBuilder $builder,
    ) {}

    public function ingest(string $rawUrl, string $title, string $region, int $now): ?string
    {
        $identity = $this->canon->identify($rawUrl);
        if ($identity === null) {
            return null; // unsupported host or malformed URL — skip, don't store
        }

        $fingerprint   = $this->builder->fingerprint($identity);
        $canonicalUrl  = $this->builder->build($identity);

        $this->db->beginTransaction();
        try {
            $stmt = $this->db->prepare(
                'INSERT INTO videos
                    (fingerprint, platform, video_id, canonical_url, title, region, created_at)
                 VALUES (:fp, :pf, :vid, :url, :title, :region, :now)
                 ON CONFLICT(fingerprint) DO NOTHING'
            );
            $stmt->execute([
                ':fp' => $fingerprint, ':pf' => $identity->platform,
                ':vid' => $identity->videoId, ':url' => $canonicalUrl,
                ':title' => $title, ':region' => $region, ':now' => $now,
            ]);

            if ($this->db->lastInsertId() && $stmt->rowCount() > 0) {
                $rowId = (int) $this->db->lastInsertId();
                $this->db->prepare(
                    'INSERT INTO videos_fts(rowid, title) VALUES (:id, :title)'
                )->execute([':id' => $rowId, ':title' => $title]);
            }

            $this->db->prepare(
                'INSERT OR IGNORE INTO video_regions(fingerprint, region) VALUES (:fp, :region)'
            )->execute([':fp' => $fingerprint, ':region' => $region]);

            $this->db->commit();
            return $fingerprint;
        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The ON CONFLICT ... DO NOTHING clause is what makes the whole pipeline safe to run concurrently from multiple cron workers hitting different regional APIs. Two workers can race on the same viral clip; the loser's insert simply evaporates, and both still register their region. There's no application-level lock, no read-then-write window to lose — the uniqueness constraint on fingerprint is the coordination point, enforced by SQLite itself.

The redirect-resolution problem

Static extraction handles maybe 95% of real traffic, but some feeds hand you shortener URLs — bit.ly, t.co, platform-internal share links — that reveal nothing until you follow them. You cannot canonicalize what you cannot see. So the pipeline has a second, optional stage: resolve redirects, then re-run identification on the final URL.

I keep this stage in a separate worker because network I/O is slow and failure-prone, and I never want a hung shortener to stall the fast path. Here it is in Go, which is what I use for the resolver daemon since its http.Client gives me precise control over redirect depth and timeouts:

package main

import (
    "errors"
    "net/http"
    "time"
)

// resolveFinalURL follows redirects up to a bounded depth and returns
// the final URL without downloading the body.
func resolveFinalURL(shortURL string, maxHops int) (string, error) {
    hops := 0
    client := &http.Client{
        Timeout: 5 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            hops++
            if hops > maxHops {
                return errors.New("too many redirects")
            }
            return nil
        },
    }

    // HEAD avoids pulling the response body; most shorteners honor it.
    req, err := http.NewRequest(http.MethodHead, shortURL, nil)
    if err != nil {
        return "", err
    }
    req.Header.Set("User-Agent", "TopVideoHub-Resolver/1.0")

    resp, err := client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    // resp.Request.URL is the URL after all redirects were followed.
    return resp.Request.URL.String(), nil
}
Enter fullscreen mode Exit fullscreen mode

The resolved URL flows straight back into the same Canonicalizer::identify(). Redirect resolution isn't a special case in the identity logic — it's just a preprocessing step that turns an opaque short link into something the static extractors already understand. This separation keeps the core canonicalizer pure, synchronous, and trivially unit-testable, while the messy network concerns live behind a queue.

Edge cases that will bite you

A few hard-won lessons from running this in production across seven markets:

  • Percent-encoding drift: youtu.be/dQw4w9WgXcQ and youtu.be/dQw4w9WgXcQ%20 are not the same string but reference the same video. Because I validate the extracted ID against a strict character class, the trailing %20 fails the {11} check and I reject rather than duplicate — but you must decode consciously, not accidentally.
  • Case-sensitive IDs, case-insensitive hosts: hosts are case-insensitive per RFC 3986, but video IDs are not. dQw4 and DQW4 are different YouTube videos. Lowercase the host, never the ID. Getting this backwards silently merges distinct videos, which is far worse than a duplicate.
  • Playlists masquerading as videos: youtube.com/playlist?list=... has no v param. My extractor returns null for these, and the repository skips them. Deciding not to store something is a valid canonicalization outcome.
  • Region as metadata, not identity: the single biggest early mistake was folding region into the fingerprint. A clip trending in both Japan and Korea is one video shown in two markets, not two videos. Identity is (platform, video_id); everything geographic is a separate many-to-many relationship.
  • Idempotency under concurrency: rely on the database's uniqueness constraint, not an application SELECT followed by an INSERT. The read-then-write pattern has a race window; ON CONFLICT DO NOTHING does not.

Conclusion

A canonicalization pipeline is really an identity pipeline wearing a URL costume. The moment you stop trying to clean URLs and start extracting stable (platform, video_id) pairs, every downstream problem gets simpler: dedup becomes a hash comparison, the canonical URL becomes a template fill, storage becomes an idempotent upsert, and your FTS5 index stops carrying four copies of the same trending clip. The pipeline I run today is three small, boring, heavily-tested pieces — an identifier, a builder, and an idempotent repository — plus an optional out-of-band redirect resolver for the awkward 5%. Boring is exactly what you want in the layer that decides what counts as the same video. Push the messy network I/O to the edges, keep the core synchronous and pure, and let the database's uniqueness constraint be the single source of truth. Do that, and the same viral clip arriving from twelve regional feeds collapses cleanly into one row — which is the whole point.

Source: dev.to

arrow_back Back to Tutorials