- Category
- browser extension
- Tech Stack
- TypeScript, WXT, React, Manifest V3, Vitest
A Manifest V3 browser extension that turns any Beatport list into a producer-facing market brief — BPM shape, key concentration, label and artist dominance, exclusives, freshness — without a partner API, a backend, or a Beatport account.
The engineering problem was extracting structured catalog data from a third-party Next.js SPA, normalizing it across competing sources, and presenting a filterable analysis surface that stays private to the browser. The architecture follows isolated-world constraints, session-scoped storage as a snapshot bus, and a tested domain model for electronic-music metadata.
Challenges
1. Reading a Third-Party Catalog Without an API
Beatport does not expose a public partner API for this use case, and calling api.beatport.com from an extension would be both fragile and ethically the wrong product. The list the user already opened is the data: charts, genre Top 100s, search results, label pages, releases.
The site is a Next.js SPA. Track payloads arrive as same-origin /catalog/ JSON, as dehydrated React Query state inside NEXT_DATA, and — as a last resort — as table rows in the DOM. None of these are a contract.
Manifest V3 isolates content scripts from the page’s window.fetch. A page-world script had to be injected at document_start to hook fetch and XMLHttpRequest, filter for catalog responses, and forward payloads across the isolation boundary via CustomEvent. The same hook patches history.pushState / replaceState so SPA navigations are visible to the content script.
The extraction layer had to survive: - catalog JSON that is not a documented API
- Next.js hydration rewriting the URL during first paint
- lazy-loaded rows and infinite scroll
- layout changes that would break a DOM-only scraper
The first panel open reloads the Beatport tab once. That is intentional: the interceptor can only see requests that happen after it is installed. After that, capture is snapshot-based — Refresh is explicit.
2. Unifying Three Sources Into One Snapshot
A single page can emit a catalog payload, a Next.js blob, and a partially rendered table at different times. Treating them independently would produce flickering, incomplete, or contradictory stats.
Each source is parsed into the same Track model. A richness score prefers records that carry Camelot, BPM, ISRC, genre, label, and preview URL. Snapshots are then merged by track id: the larger list wins order, overlapping ids keep the richer record, and a zero-overlap shrink is rejected as a false replacement (a common SPA glitch when a new route’s empty table flashes before data arrives).
Capture is armed with a settle window. A MutationObserver keeps the window open while the list is still filling. After two quiet seconds the observer disconnects. If nothing usable appears, a delayed failure message drives a help card instead of an empty dashboard. SPA path changes disarm the pipeline — the next capture is a user Refresh, not a background crawl.
The product is reconnaissance for producers: what this list is made of. That is a different question from crate digging or harmonic mixing, and the UI is built around that distinction.
Stats are computed in a pure module: BPM median and IQR, exclusive and hype share, tracks published in the last 7 / 30 days, mix-type and length-band histograms, and concentration for the top keys, labels, and artists. Musical key is resolved in both Beatport scale names and Camelot, including enharmonics (F♯ min / Gb min), then mapped onto the wheel for sorting and harmonic-neighbor filters.
Charts are not decoration. Clicking a BPM column, a Camelot cell, a label, Exclusive, or “last 7 days” is the filter. The table, CSV, and printable report all follow the same filtered set. On a label, artist, or genre page, the redundant facet is hidden so the brief does not narrate the obvious.
4. A Side Panel That Belongs to One Tab, on Three Browsers
Chromium’s sidePanel API and Firefox’s sidebarAction do not share a model. A global panel that stays open on every tab would leak yesterday’s chart onto Gmail. A popup would be too small for histograms, a table, and a preview player.
The background service worker therefore: enables the Chromium panel only on Beatport tabs and disables it elsewhere
opens Firefox’s sidebar on Beatport and closes it when the user leaves,
restoring it if they return
keeps a live snapshot in session storage so closing the browser cannot leave
a stale chart as “today”
- shows a badge with the captured track count
Panel-open is counted over a named runtime port, so auto-reload and sidebar restore do not fight the user. A cooldown and a skip flag prevent reload loops when switching away from Beatport mid-refresh.
Architecture & Technical Decisions
1. WXT — Typed MV3 Entrypoints Across Chrome, Edge, and Firefox
WXT was chosen over hand-rolled Manifest V3 scaffolding
so entrypoints, per-browser manifests, and the React panel stay one codebase.
The manifest is a function of the target browser: Chromium gets storage, sidePanel, and tabs; Firefox gets storage plus gecko id and data-collection declarations. Host permissions are limited to ://.beatport.com/*. A build hook strips Chromium’s default global side_panel path so the panel cannot open as a session-wide overlay.
This paid off as: one TypeScript graph for background, content, page-world hook, side panel,
and print report
unlisted beatport-fetch.js shipped as a
web-accessible resource, injected into the page world
- zip targets named for store submission without a separate build graph
The trade-off is framework lock-in. The alternative — three hand-maintained manifests — would have duplicated the exact branching WXT already encodes.
2. Isolated Worlds + Session Storage as the Snapshot Bus
Following the Evil Martians MV3 pattern (content script, messaging, storage, service worker), the live dataset is not passed as a giant runtime message that dies with the worker. The content script publishes TRACKS_EXTRACTED / EXTRACTION_FAILED; the background writes a snapshot into browser.storage.session (falling back to local where session is missing) and sets session access so the panel can read it.
The React panel does not own extraction. It subscribes to storage changes, recomputes stats, and asks the background to reload the tab when the user hits Refresh. That keeps the service worker thin and the UI replaceable.
The pipeline is: - page-world interceptor → catalog payload events
- content script → normalize, merge, settle, publish
- service worker → persist snapshot, badge, panel availability, reload
- side panel / print report → read the same snapshot
Session storage is the right lifetime for market reconnaissance: a chart is “now,” not a library.
3. A Pure Analysis Core, Tested Without a Browser
Parsing, Camelot conversion, filters, CSV, and report title logic live under src/lib with no Chrome APIs. Vitest covers the parts that would silently lie: Next.js dehydrated queries, snapshot merge collisions, BPM histograms, key enharmonics, mix-type classification, freshness windows, and CSV escaping.
That split is the reason the print report can reuse computeTrackStats instead of a second implementation. The panel is a view. The domain model is the product.
4. React Only Where There Is Application State
React 19 runs in the side panel and the print report — filter state, sort, preview queue, notation toggle. Content scripts and the background stay vanilla TypeScript. Hydrating a UI framework into every Beatport tab would be the wrong cost for a host page the extension does not own.
Charts are CSS histograms and chip lists, not a charting library. Bucket height is a percentage of the max count; selection state is aria-pressed. The preview player plays Beatport’s own sample URLs, seeks to the catalog clip window, and falls back to a blob URL if the element cannot stream the sample directly.
Export is two artifacts from the same snapshot: a UTF-8 BOM CSV of the visible rows, and an HTML report opened with ?print=1 so the user can save a one-page brief of the full list.
5. Privacy as a Constraint, Not a Setting
No account, no backend, no telemetry. Track data never leaves the browser. Previews are Beatport’s samples, played in-panel. Preferences (key notation) are local; the live snapshot is session-scoped.
If extraction fails — wrong page, or Beatport redesigned a table — the panel shows a short help card and a contact path instead of pretending the list is empty.
Trade-offs
Why Not a Beatport Partner API or a Backend
A backend would make the extension a crawler: credentials, rate limits, ToS, and a server that holds other people’s catalog. The product is “analyze the list you already opened,” which is a stricter and more honest scope.
Staying client-side allowed: Trade-off: - the extension cannot analyze a chart the user did not open
- Beatport markup or catalog shape changes require a parser update
The multi-source extractor is the mitigation: catalog intercept first, Next.js data second, DOM last.
Why Not DOM-Only Scraping
A table parser is simpler and would have shipped faster. It also loses mix names, Camelot letters, preview offsets, exclusive / hype flags, and ISRCs the moment Beatport restyles a cell — and it cannot see rows that have not been painted.
Intercepting /catalog/ JSON keeps the domain model aligned with what Beatport already fetched. The DOM parser remains a fallback so a visual list still produces some brief when JSON is missing.
The reload is documented in-product as expected behavior, and it happens once per capture session.
Why Not a Charting Library
Recharts, D3, or Chart.js would have drawn prettier axes. They would also have added dependency weight to a side panel that must feel instant, and they fight the “the bar is the filter” interaction.
Custom CSS columns keep the histogram in the same button/chip language as Exclusive and Hype. Empty buckets stay visible on the Camelot wheel so the shape of the page is readable, not just the peaks.
Trade-off: - less chart chrome (tooltips, animations, responsive redraw)
- full control over selection, density, and accessibility
Why Not Live Updating
Infinite-scroll pages keep mutating. A live subscription would churn stats, reset filters, and interrupt previews. The product treats a list as a snapshot you inspect, then Refresh when the page has changed.
Conclusion
Beatport Analyst is a small surface with a hard constraint: extract a trustworthy market picture from a site you do not control, inside Manifest V3 isolation, without sending data anywhere.
The work sits at the intersection of extension architecture, defensive parsing, and domain modeling — Camelot and BPM as first-class types, not display strings. WXT keeps Chrome, Edge, and Firefox on one graph. Session storage keeps the snapshot honest. A tested src/lib keeps the brief, the filters, the CSV, and the print report in agreement.
The result is a producer tool that reads the list in front of you and stops there.