ADR-0010 — Wix API as the content/media plane (matched by id to the capture)¶
Status: Accepted (direction + spike-validated 2026-07-12; build sliced + deferred) Date: 2026-07-12 Deciders: Cathal Dempsey Related: ADR-0009 §4 (two-plane ingestion — this confirms its conditional "Wix API is the content/media source of truth where available" into a committed decision), ADR-0005 (R2 image pipeline — the migration target), ADR-0008 (per-site worker fleet — the same account-sharding model).
Context¶
ADR-0009 §4 split ingestion into two planes: the design plane (Playwright capture = the layout/style authority) and the content plane (Wix API = the content/media source of truth "where available"), with the DOM scrape as the section-segmentation backbone. "Where available" was doing load-bearing work — the Wix API was greenfield (no code, no credentials in the repo) and its portfolio viability turned on an unanswered question: do FCR control the sites' Wix accounts?
Two session pains forced the question. The hero-VIDEO migration (Slice 5b) needed
to reconstruct video.wixstatic.com/video/<id>/<quality>/mp4/file.mp4 URLs from
stored basenames, and the carousel frames (Slice 5a) took three capture traps to
recover 8 lazy-mounted slides. The cleaner source is the site's own media library.
Decision¶
Adopt the Wix REST API as the content/media source-of-truth, matched to the DOM scrape's segmentation BY MEDIA ID. The scrape stays the layout/segmentation backbone (which media id sits in which hero/section — ADR-0009 §4); the API supplies the clean asset for each id (real URLs, original filename, dimensions, colours, alt text). The capture design plane is unchanged — the API never sees the rendered layout.
Two facts settled it (2026-07-12 spike, WCP metaSiteId dd5a7db4-…):
- Account model — FCR-owned. The ~1800 portfolio sites live in FCR-owned Wix account(s), so a single API key (or a few, sharded like the worker fleet, ADR-0008) covers the portfolio — no per-site OAuth. This is what makes the API a portfolio plane, not a per-site nicety.
- The API returns exactly what the scrape was fighting for.
GET /site-media/v1/files?mediaTypes=VIDEOreturned WCP's hero video with real MP4 URLs at every resolution (480p/360p/144p + HLS) and its original filename (VID-20251205-WA0007.mp4) — the 480p URL byte-identical to the one Slice 5b hand-reconstructed, now provenance-complete and reconstruction-free.mediaTypes=IMAGEreturned 92 images; all 7 captured carousel frame ids matched the API pool (7/7), with filenames + dimensions.
Auth + call shape (to bake into the client)¶
- API key created in the API Keys Manager (
manage.wix.com/account/api-keys), scoped Manage Site Media (+ Business Info + Blog for the content half), site access restricted per key. StoredWIX_API_KEY/WIX_ACCOUNT_IDinapps/cms/.dev.vars(read via the existing.dev.varsparser convention). - Site-level calls:
Authorization: <key>+wix-site-id: <siteId>ONLY. Addingwix-account-idto a site-level call → HTTP 400 (see known-patterns "A Wix site-level API call takeswix-site-idonly"). - Endpoint:
https://www.wixapis.com/site-media/v1/files(GET, cursor-paged,mediaTypesfilter). Query Sites (account-level, for portfolio enumeration) needs the account-level "Get Sites List" scope — the WCP spike key lacked it (403); not needed while the site id is known.
Alternatives considered¶
- Capture-scrape only (status quo). Works, but brittle for video (URL reconstruction) and lossy for provenance (no clean filenames; scrape-download every asset). Retained as the segmentation backbone; superseded as the asset source where the API covers it.
- Per-site OAuth. Would be forced if the sites were clients' independent Wix accounts — impractical at portfolio scale. The FCR-owned account model retires it.
Consequences¶
Positive¶
- Retires the
video.wixstatic.comURL reconstruction (Slice 5b) and the scrape-and-download of images — look up by the id we already scrape. - Clean provenance in-data (ADR-0005 §6 spirit): original filenames from the source, not derived/normalised guesses.
- Unlocks the business-info + blog content plane on the same key.
Negative / risks¶
- Segmentation still needs the scrape. The API gives the media POOL, not which asset is on which page/section — the by-id match depends on the scrape surfacing the id. Sites where the scrape can't surface an id get no API enrichment.
- Getting an asset ≠ rendering it. The clean video is now available, but
showing it (WCP home's carousel renders slides>video, so the video shows as its
still frame) still needs render work — carousel video-slide support, or a
variant:videohero. A separate concern from acquisition, tracked under the ADR-0009 hero dimension. - Greenfield build. No client code yet; the spike was a throwaway probe.
Build slices¶
| # | Slice | Status |
|---|---|---|
| A | Wix media client (apps/cms/scripts/wix-api.mjs): auth + listFiles + searchFiles + by-id lookup + cursor paging. |
Done 2026-07-12 pt5 — committed client; .dev.vars creds, site-id resolution, cursor paging (paging.limit/paging.cursor), by-id index, pure parseMediaRef/pickVideoUrl/originalNameOf; 22-check network-free --selftest. Site id is derived, not hand-entered (see §"Site id" below). Surfaced the root-folder-scoping correction below. |
| B | Video migration via the API (retires the Slice 5b reconstruction): scrape id → API MP4 url → R2. | Done 2026-07-12 pt5 — video branch in transform-seed-images.mjs; WCP hero video → r2:waterfordcountypainters/vid-20251205-wa0007-73c39f8f.mp4 (video/mp4, 3.96 MB, x-amz-meta-original-name: VID-20251205-WA0007.mp4), seed + live D1 (surgical, never a reseed), idempotent. asset-migration-gaps 1 → 0. The R2 key stem is the API's real filename, so provenance is sourced, not derived. |
| C | Image provenance enrichment: replace derived basenames with API filenames/dimensions/alt. | Planned. Scoping fact found 2026-07-13: a LOGO carries a name, not a media id — so it is in the pool but invisible to the by-id join. WCP's searchFiles pool holds Waterford County Painters_logo (8b4be4_4651bc7c…~mv2.png, 1267×898), yet the scraper saved it locally as /assets/logos/Waterford_20County_20Painters_logo.png — the URL-encoded display name instead of the media id every other image keeps. That is what makes the deriver report "no wixstatic source" and what made the wiki claim "logos are local-only" (now corrected). Slice C must therefore carry a name-based fallback join for the logo class, and should treat it as a weaker key than the id join (known-patterns "A reconciliation is only as honest as its JOIN KEY"). Payoff: the R2 object's x-amz-meta-original-name currently records the mangled Waterford_20County_20Painters_logo.png; the API knows the real name. |
| D | Business Info + Blog content plane (same key, +Business Info/Blog` scopes). |
Planned |
| — | Render gaps (carousel video-slide, variant:video heroes) — tracked separately (ADR-0009 hero). |
Carousel video-slide DONE 2026-07-12 pt5 (ADR-0009 Slice 5b) — the video renders. |
| E | Asset reconciliation (apps/cms/scripts/asset-reconcile.mjs) — joins Wix pool → R2 → seed → deployed HTML; buckets pulled-but-unrendered / referenced-but-missing / empty-image-slots / rendered-placeholders / at-source-never-pulled. Reporting-only, gates nothing. |
Done 2026-07-12 pt5 — see known-issues "WCP logo-strip". |
| F | VOD-channel video acquisition — enumerate _api/vod/public/lists/<channelId> per gallery channel → per-video access token (_api/v1/access-tokens) → download tokenized HLS → remux to mp4 (ffmpeg) → R2. The THIRD Wix media surface (see amendment below). Feeds the gallery-video bucket whose render/producer already shipped (ADR-0009 Slice 5, 2026-07-15). |
BLOCKED AT SOURCE 2026-07-16 pt2 — not Planned, and not startable. The VOD API 403s for the live site's own player (see the fourth amendment below). Prep facts banked: channel ids are network-only (not in markup) — 9ba179c068494c0c8d7bcbc585393c8c, 4cd95f58005b4a48a57a1e3f133e20ab; ffmpeg 8.1.2 on the box. Reopen gated on the Wix Video app's account status. |
| G | Wix Data collections content plane — the structured-data surface (POST /wix-data/v2/items/query), NOT media and NOT the media-id join. See the fifth amendment below. |
DROPPED 2026-07-17 — surface is empty for FCR by construction. Cathal: the portfolio sites were not built on the Wix Data / code editor, so there are no collections to read. The prevalence question the fifth amendment gated on is answered nil from build knowledge — no scope-add or probe needed. Recorded (not deleted) so the "new CMS API!" reflex doesn't re-chase it. Reopen only if FCR onboards code-editor sites. |
Amendment (2026-07-12 pt5) — listFiles is ROOT-FOLDER-scoped; the POOL needs searchFiles¶
The spike above (and the Slice A client as first written) used GET
/site-media/v1/files. That endpoint lists a single folder — the root
(media-root) by default — not the library. A real FCR site files its media
into folders (WCP has Commercial Painting, Residital, Repair, logos,
Misc, …), so the pool it returns is a fraction of the truth:
| enumeration | WCP files |
|---|---|
listFiles (root only) |
94 |
POST /files/search (all folders) |
392 |
The spike's headline numbers were right by luck — WCP's hero video and all 7
carousel frames happen to sit in the root folder, so the by-id match still hit
7/7. The failure mode is silent and dangerous in the other direction: any
"is this asset present at source?" question answered from listFiles will
confidently report a present asset as missing (it first told us 91 of 94
library files were "never pulled", which was nonsense).
Contract: the media POOL is searchFiles (POST /site-media/v1/files/search,
body {mediaTypes:[…], paging:{limit,cursor}}, cursor at
pagingMetadata.cursors.next, terminate on hasNext:false). listFiles stays
available for a deliberate single-folder listing. Both the video lookup and the
reconciliation now use searchFiles. See known-patterns "A Wix media listFiles
returns ONE FOLDER".
SUPERSEDED by the second amendment below (2026-07-13 pt3). "The POOL is
searchFiles" was measured on WCP alone and is wrong on garvanbay in the opposite direction. UsemediaPool(the union). Do not re-derive this from one site.
Second amendment (2026-07-13 pt3) — NEITHER endpoint is the pool; the pool is the UNION¶
The amendment above corrected listFiles → searchFiles on the strength of one
site (WCP: listFiles 94 root-only vs searchFiles 392 — a 4× under-report). The
moment a second site was brought onto the API, that contract broke the other way:
| enumeration | WCP | garvanbay |
|---|---|---|
searchFiles (all folders) |
393 | 0 |
listFiles (root folder) |
94 | 41 |
| folders | 7 | 0 (flat library) |
mediaPool (union, deduped by id) |
393 | 41 |
garvanbay returns ZERO from searchFiles — it is evidently absent from Wix's
media search index (an older site; rootFolder is ignored by the endpoint, so
that is not the cause). It is not an auth failure: the call 200s, the key has
access, and the root listing returns 41 files.
This is the most dangerous failure a reconciliation can have. A truncated pool
reports PRESENT assets as MISSING (the first amendment's lesson). An empty pool
reports zero asset debt — the flattering answer, and the one nobody
double-checks. Reconciling garvanbay from searchFiles would have certified it
clean while telling us nothing.
Contract (supersedes the above): the pool is
mediaPool({site, mediaTypes}) — the union of searchFiles ∪ listFiles(root)
∪ listFiles(each folder from GET /site-media/v1/folders), deduped by media id.
It returns {files, sources, warnings} and warns loudly when the two
enumerations disagree (garvanbay: "searchFiles returned 0 but the folder walk found
41 — this site is NOT in Wix's media search index"). asset-reconcile.mjs consumes
it and records wixPoolSources + wixPoolWarnings in the report, so an
under-reported pool can never be silently read as "no debt".
The generalisable lesson — and it is the third time this shape has bitten: a contract derived from ONE reference site is a hypothesis, not a contract. WCP alone "proved" search ⊃ list; garvanbay alone would have "proved" list ⊃ search. Only the union is true of both, and we only found that out by adding a second site. See known-patterns "A media-pool enumeration derived from ONE site is a hypothesis".
Third amendment (2026-07-15) — there is a THIRD Wix media surface: VOD channels, not in the Media Manager pool¶
The first two amendments corrected how to enumerate the Media Manager pool
(listFiles → searchFiles → mediaPool union). A third surface exists that
neither enumeration touches. FCR gallery pages file their per-category videos in
Wix VOD "channel videos" widgets — a different Wix product. Measured on WCP: all
13 gallery videos are absent from mediaPool (mediaTypes=VIDEO, 46 files) by every
id form (file id, media.video.id, posters[].id). The live player enumerates them
from GET /_api/vod/public/v3-to-v2/public/lists/<channelId>?media_type=secure_video
(items carry source_url: null), gets a JWT from GET /_api/v1/access-tokens, and
streams tokenized HLS (repackager.wixmp.com/.../master.m3u8?token=<JWT> +
.ts). The captured mp4-shaped URL 400/403s on direct fetch (token required,
short-lived).
So the content plane has three Wix media surfaces, and the by-id mediaPool join
(this ADR) covers only the first two:
- Media Manager images —
mediaPool,mediaTypes=IMAGE(Slice C). - Media Manager videos —
mediaPool,mediaTypes=VIDEO(Slice B; the hero video). - VOD channels — a separate API (
_api/vod/public/...) + tokenized HLS delivery (Slice F, Planned).
The generalisable lesson, and it is the fourth of this shape: the pool-truncation
amendments condition you to answer "asset not found" with another pool fix. This one
is not a pool fix — the asset is in a different product. The tell is the live network
trace, not a re-derivation of mediaPool: the _api/<product>/... path names the
product, and source_url: null + a ?token= HLS request means streamed-not-downloaded.
See known-patterns "A Wix gallery's VIDEOS are VOD-channel HLS, not Media Manager".
Fourth amendment (2026-07-16) — the third surface is DOWN at source; and "not in the pool" was measured with a join that misses its own control¶
The third amendment established that VOD channels are a separate Wix product the
mediaPool join cannot reach, and scoped Slice F to acquire them through the
VOD API. Slice F's Step-0 probe killed its own premise before any code was written.
The VOD API 403s for the live site's own player. Every
_api/vod/.../lists/<channelId> and .../play/<id> call on live WCP returns
403 "Internal server error of auth middleware"; zero HLS/repackager/.ts
requests fire; zero <video> elements mount. Reproduced 5 ways (headless and
headed, default and real Chrome UA, consent declined and accepted, all 4 category
tabs) — so it is not headless detection, not a consent gate, and not a mis-aimed
probe. The widget still paints a poster + "Play Video", so the page looks
video-bearing; the failure is console-only.
This contradicts the third amendment's own trace, which describes lists/
returning items (source_url: null) on 2026-07-15. Either the app broke in the
intervening day or that trace's context differed. Not resolvable from our side —
the reopen is an account-side question (the Wix Video app's status on the WCP
account), not a pipeline task.
Scope is wider than the gallery: roof-painting (ADR-0009's fcr.about ×11
bucket) fires the same call and gets the same 403. ~24 of WCP's 27 missing videos
are behind this one wall; only the hero (Media Manager, migrated by Slice B) is
unaffected. So the ranked render backlog in ADR-0009 Slice 5 is not a queue of
independent work — it is one blocked question with three render homes behind it.
A CDN control worth keeping. Same URL form, no API, no token:
GET video.wixstatic.com/video/<id>/480p/mp4/file.mp4 returns 206 video/mp4
for the hero and 403 text/plain for the gallery videos. That is the cleanest
statement of the split — Media Manager assets are directly fetchable; VOD assets
are not, by design.
And a correction to how "not in the pool" was established. The claim is
re-confirmed (0/13 under a much wider net than the original three id forms:
every string at any depth in every pool record, the bare 32-hex tail, plus a raw
substring sweep). But the original test was weaker than it read: the hero — the
one video known to be in the pool — also fails the capture-id join (it is
8b4be4_a0c791484f4b492ca396ec695d175f1a in the pool, and reached R2 via its
poster id, not its video id). A join that misses its positive control proves
nothing on its own; the conclusion survives because the widened sweep and the CDN
control agree with it, not because of the id join. The downstream casualty is
known-issues' "19 are library cruft", which was 46 − 27 over disjoint sets —
corrected there. See known-patterns "A set-difference is not a number until you have
measured the INTERSECTION".
What Slice F still needs if the app comes back: the channel ids are not in
the rendered markup — they exist only in the player's network requests, so the
producer is a capture-plane network-interception probe (buildable; the capture
is already a Playwright crawl). Today's values: 9ba179c068494c0c8d7bcbc585393c8c,
4cd95f58005b4a48a57a1e3f133e20ab. ffmpeg 8.1.2 is available. Re-run the playback
measurement first — if the source still 403s, none of it is worth building.
Fifth amendment (2026-07-17) — a FOURTH content surface: Wix Data collections (structured content, NOT media, NOT the media-id join)¶
A Wix "CMS" API was pointed at the portfolio in the hope it would unblock the missing
videos. It does not, and the why is the whole point of recording it: the URL path
.../business-solutions/cms/ names the product ("CMS" is Wix's rebrand of the Content
Manager), but the resource list is data collections — Data Items, Collection
Management, Operations, External Databases (https://www.wixapis.com/wix-data/v2/...).
There is no media-asset, video-file, VOD-channel, or Media-Manager endpoint anywhere
in it. The 24-of-27 video wall is the third media surface (VOD, fourth amendment) and this
API has no door onto it. The tell was reading the API's own resource enumeration, not its
confusingly-named path — the same discipline as the third amendment (read the live trace,
not the product name) arriving from the opposite direction.
What it is is the site's structured-data content plane: the rows behind dynamic
pages, repeaters, member-submitted content, job boards, and Wix app collections (Stores
Products, etc.). That is a genuinely new surface, distinct from Slice D's Business Info +
Blog (those are the business record and the blog app; this is arbitrary user/app
collections). It also speaks to project_goals' "CSR text capture as JSON" — collection
content is JSON at the source, no DOM reconstruction.
But this surface breaks the ADR's central join, and that is the load-bearing caveat.
Every media slice here matches the API to the capture by media id — an asset sits in a
hero/section and carries an id the scrape surfaces. Collection data has no such anchor. It
backs a dynamic route or a repeater, so the join is route → dataCollectionId → items
and then repeater DOM → item, and nothing in the current capture surfaces which route
is dynamic or which collection backs it. So folding this in is not "add another
mediaTypes filter"; it is an unspecced second join model. Do not spec it on zero data.
Two locks stand before any of that is worth thinking about, and both are off our side:
- The key cannot see it. Querying items needs the
Read Data Items(SCOPE.DC-DATA.READ) scope; discovering which collections exist needs the Collection Management read scope. The existing key is Manage Site Media + Business Info + Blog — neither Data scope is on it. Adding them is an account-side action atmanage.wix.com/account/api-keys, exactly like the VOD reopen is an account-side question. Until then this surface is not measurable at all, only speculated about. - The surface may be empty portfolio-wide. The Wix Data APIs require the site's code
editor to be enabled (stated in the API's own introduction). FCR's portfolio is largely
brochureware built in the Wix editor with no code editor — those sites have zero data
collections. So the first question is not "how do we join it" but "does any FCR site
use collections at all?" — a prevalence probe (List Data Collections over a sample once
the scope lands), the same shape as
measure-two-band-wrappers: measure before scoping.
Consequently nothing is Planned. Slice G is gated, not queued: scope-add → prevalence probe → (only if non-trivial) spec the route→collection join. Promoting it to a build on the strength of "a new API appeared" would be the one-site-hypothesis trap (second amendment) in a new costume — here with a sample size of zero.
Closed same day — the prevalence question is answered nil from build knowledge. Cathal:
the FCR portfolio was not built on the Wix Data / code editor, so the collections surface
is empty by construction — no scope-add and no probe can find rows that were never authored.
Slice G is DROPPED, not gated. This amendment stays as a why-not record: the next
session that meets the "CMS API" and reads "data collections" should stop here, not re-open a
scope request. It reopens only if FCR later onboards code-editor-backed sites.
Site id — a fact of the site, not a config chore¶
wix-site-id is required on every call, and 1800 hand-entered ids is not a
plane. It doesn't have to be: every rendered Wix page carries
metaSiteId=<uuid>, so the id is recoverable from the crawl we already do.
Resolution precedence in the client: WIX_SITE_ID env → sites/<site>.jsonc#wix_site_id
(the durable committed fact) → derived from builds/<site>.ie/pages/*.html
(the bootstrap). It is committed to the fleet descriptor because builds/ is
gitignored — a fresh checkout has no crawl to re-derive from (garvanbay, whose
crawl inputs live only on EC2, is exactly this case and currently has no id).
References¶
- Spike: throwaway
scratchpad/wix-probe.cjs(2026-07-12 pt4); WCP metaSiteIddd5a7db4-38c2-4360-82f0-d4b1e397d3b7. - Wix docs: API Keys (
https://manage.wix.com/account/api-keys), Media ManagerlistFiles(https://dev.wix.com/docs/rest/assets/media/media-manager/files/list-files). - ADR-0009 §4 (two-plane ingestion), ADR-0005 §6 (provenance in-data), ADR-0008
(account sharding). Memory:
project_goals(need CSR text capture as JSON).