Ecommerce Template
Next.js·React 등으로 만든 최신 전자상거래 템플릿
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A four-page site where you describe the clothes you want in your own words and get
back matching items. "I need a black shirt for school under $50" is a complete
request — there is no form to fill in.
The interface is deliberately plain: one typeface, three levels of neutral ink
(black for primary text, one step down for body copy, muted for metadata), and
two brand hues used only where they are saying something.README 전문 보기 ↗Fynd finds the fit. The home page, Find Fit, takes one thing: a link to a clothing product from any shop. Fynd reads that product's page — and the size guide it links to — on its own server, and shows the sizing chart there and then: its sizes and measurements exactly as published, their unit, whether they describe the body or the garment, where they came from, and a short note on what they do and don't tell you. A saved fit profile adds a comparison with a garment the shopper owns, where the chart supports one. See Find Fit: sizing charts from a product link.
The Search page (keyword search across the product source) is still there, reached from the footer; each of its results can open the same sizing chart.
The interface is deliberately plain: one typeface, three levels of neutral ink (black for primary text, one step down for body copy, muted for metadata), and two brand hues used only where they are saying something.
Hierarchy is carried by size, weight, space and surface. Every page opens on a lightly tinted band — the stage — holding the headline and the page's one card (choosing clothing at home, the search on the Search page), and closes on one inverted block that carries the call to action and the footer together. In between, the page is white. The search card is the only elevated layer and the primary button the only filled control, so the thing to do next is never in doubt.
Every colour in the interface is a token in the :root block of
assets/styles.css, and no rule anywhere writes a raw value. The foundation —
pages, cards, type, rules — is neutral; the hues have jobs:
| Token | Job |
|---|---|
--color-primary | Trust and action: the one filled control, the focus ring, the current page, links, the logo mark, and the retailer a product came from |
--color-accent | Discovery and energy: the example searches, the numbered steps — the places that invite a try |
--color-success | A live listing came back from a real product source |
--color-warning | A caveat: sample data, or a request that needs fixing before it can run |
Each hue comes in weights — the solid for filled controls, an -ink dark enough
to set type on white, and where it is needed a -soft tint and a -line edge.
Every ink clears 4.5:1 both on the page and on its own tint.
scripts/test-ui.js holds the interface to all of this by walking the rendered
document rather than a fixed list of selectors: every piece of type must be set
in one of the declared inks and must clear its contrast requirement on the
ground it actually sits on, no rule may carry a raw colour value, no token may
go unused, and the palette must still be wired to the CTA, the logo, the current
page, the examples, the steps and the retailer labels. The fit profile's type
cards, with every detail open, and the search page's fit panel are audited too.
| Page | File | What it does |
|---|---|---|
| Find Fit (home) | index.html | Paste a clothing product's link; its sizing chart appears on the page, with its source, what it measures and a note, and a fit-profile comparison where supported. index.html#guide, the old fit guide's address, goes to the fit profile |
| Search | find-clothes.html | Describe what you want in your own words, and say what kind of clothing it is ("Shopping for"); results are followed by how your fit profile was used. The demo film of four real searches sits under the box |
| Pricing | pricing.html | The three plans, which one you are on, and the way to change it |
| Account | account.html | Sign in with Google or email; your plan, usage and subscription |
| Fit profile | fit-profile.html | The one place for sizing: a card per type of clothing (a reference that fits, its product and measurements laid flat, how it should fit, what goes wrong), measurements, usual sizes by brand. Opened at a type's card from a search (?for=<type>&from=search&q=…), with the way back |
| About | about.html | What the site does and what it takes into account |
Discover, the catalogue browser, was retired when Fynd turned to fit. discover.html
is kept only so old links and bookmarks land on the home page: it redirects there,
loads no script and asks no API anything.
Every product, wherever it appears, is drawn by one function in assets/app.js
and carries the same six things in the same order — image, retailer, name,
price, attributes, action — so a grid always reads as one set of rows.
The site itself is plain HTML, CSS and JavaScript with no build step.
python3 -m http.server 8000
# then open http://localhost:8000
Opening find-clothes.html directly in a browser also works. Without the interpreter
endpoint below, requests are read by a small local parser instead of the AI. The
page still returns results, and says on screen that the AI did not read the
request — a keyword match is never presented as an AI reading.
The fit profile needs an account, so it needs the functions: run it under
npx vercel dev.
pricing.html lists the plans from its own markup, so it reads correctly with
no server at all. Signing in, subscribing and the usage meters need the
functions in api/, and the page says plainly when they are not reachable.
index.html, find-clothes.html, fit-profile.html, about.html, pricing.html, account.html
discover.html retired; redirects old links to the home page
api/interpret.js serverless endpoint; calls OpenAI, holds the API key
api/search.js serverless endpoint; asks the product source for real listings
api/_providers/ product source adapters and the verification gate
api/account.js what plan the caller is on, and what they have used
api/auth.js sign up, sign in, sign out, verify, reset
api/verify-email.js spends a confirmation link, once
api/google-start.js begins a Google sign-in
api/google-callback.js finishes one, and is the only thing that believes Google
api/_google.js OAuth with PKCE, and ID token verification
api/_email.js transactional email: Resend or Postmark
api/_tokens.js single-use, expiring, unguessable links
api/_ratelimit.js what stops guessing and mail-bombing
api/checkout.js opens a Stripe Checkout Session for Pro or Max
api/portal.js opens Stripe's billing portal
api/stripe-webhook.js the ONLY thing that can change a plan
api/_plans.js Free, Pro and Max, and what each entitles you to
api/_stripe.js Stripe's REST API, and webhook signature verification
api/_usage.js the token and search counters
api/_meter.js what /api/interpret and /api/search ask before spending
api/_auth.js password hashing, session cookies, who a request is
api/_users.js user records, and the Stripe customer mapping
api/fit-profile.js reads, saves and deletes the signed-in shopper's fit profile
api/_fit-profile.js where a fit profile is kept: one record per account
api/size-recommendation.js a size suggestion for one product, from the caller's own fit profile (read-only)
api/_fit-feedback.js where fit feedback is kept, and its consent rules
api/_sizing/ product sizing records (records/*.json), their schema, and the size engine
api/_catalog/ a brand's authorized catalogue: feed import, sync, API fetch, the committed catalogue (data/), and its search
assets/fit-profile-schema.js the fit profile's versioned schema; the page and the server share it
assets/fit-profile-ui.js draws the fit profile page
assets/fit-for-you.js the search page's fit panel: which type a search is for, and that type's profile, used on the results
scripts/test-fit-profile.js offline test of the schema, units, per-type answers, readiness, and who may read or change a profile
scripts/test-sizing.js offline test of the sizing records, the size engine and both of its endpoints
scripts/sync-catalog.js syncs a brand's catalogue from its authorized feed (a file or the API)
scripts/test-catalog-feed.js offline test of the catalogue: import, sync, fetch, search, staleness, and each product's sizing
scripts/test-catalog-browser.js Find Fit searching and paging a catalogue, and a product's page, in a real browser
scripts/test-size-eval.js the size engine against hand-worked expectations (scripts/fixtures/size-eval.json)
api/_store.js the key/value store: Vercel KV / Upstash, or memory
api/_providers/openwebninja.js OpenWeb Ninja Real-Time Product Search adapter
api/_providers/etsy.js Etsy Open API v3 adapter, kept as an alternative
api/_interpreters/gemini.js Gemini interpreter, registered for measurement
api/_interpreters/index.js which model reads a request; OpenAI unless told
assets/search.js sends interpreted intent to /api/search
scripts/verify-api.sh checks a deployed interpreter endpoint
scripts/verify-search.sh checks a deployed search endpoint
scripts/verify-billing.sh checks a deployed billing setup
scripts/probe-openwebninja.js prints the provider's live response fields
scripts/diagnose-search.js replays the browser's exact /api/search call; on a 502, prints what threw
scripts/bench-interpreters.js OpenAI against Gemini, on twenty requests
scripts/test-gemini.js offline test of the Gemini interpreter
scripts/test-pipeline.js offline test of the whole server pipeline
scripts/test-stripe.js offline test of payments and subscriptions
scripts/test-auth.js offline test of accounts, sessions and OAuth
scripts/test-e2e.js the whole sign-in flow, shopping, and the fit profile in a real browser
scripts/test-cross-origin.js the Pages copy calling the API across origins, in a real browser
scripts/test-ui.js browser test of the interface: the search, the fit profile, palette and contrast
assets/attachments.js drag-and-drop and file picker for the search box
assets/account.js talks to the account and billing endpoints
assets/billing-ui.js draws the pricing page
assets/account-ui.js draws the account page and the sign-in flow
.env.example template; the real .env is git-ignored
assets/products.js data layer: normalises any source into one schema
assets/catalog.js demo product source, replaceable by a real feed
assets/demo-video.js lazy-loads the Search page's demo film, and decides autoplay
assets/demo/ the demo film: two shapes, two codecs, two posters; the session in footage/
scripts/record-demo.js records the demo from four real searches, end to end
scripts/demo-narration.py speaks the demo's narration lines
scripts/demo-audio.js the demo's score and mix: composed to each video's timeline
scripts/demo-film.js cuts the demo film from the recorded session
assets/interpret.js sends the request to the endpoint; local fallback; reads descriptive requests
api/_providers/query.js the one search phrase every provider is asked
api/_providers/relevance.js the order a descriptive request's results are shown in
scripts/test-concepts.js offline test of descriptive requests, end to end
scripts/bench-concepts.js before/after benchmark of descriptive requests
scripts/test-messy.js offline test of messy, negative and misspelled requests
scripts/bench-messy.js before/after benchmark of 88 messy requests
scripts/bench-messy-queries.js the benchmark's 107 requests: ids, types, intents and grading rules
scripts/bench-messy-live.js runs them through the real route, live; compares checkouts
api/_reading.js the model's reading, held to the shopper's words
scripts/test-reading.js offline test of that: inventions, roles, exact requests unchanged
assets/app.js rendering and page behaviour
scripts/audit-catalog.js re-proves every field a catalogue card shows
scripts/test-catalog-audit.js offline test of that audit, and of the shipped catalogue
assets/styles.css colour tokens, design tokens and all shared components
Everything under api/ is deployed as its own Serverless Function, except
paths carrying an underscore-prefixed segment. That is the whole reason for the
naming: api/_plans.js, api/_providers/ and the rest are helper modules, not
endpoints, and an endpoint is exactly the set of files without a leading
underscore. The distinction is not cosmetic — hosts cap how many functions a
deployment may have, and a helper that gets routed is both a wasted slot and a
URL that answers nothing useful. scripts/test-pipeline.js holds the line:
every routed file must export a handler, and there must not be too many of them.
api/interpret.js turns a written request into structured preferences. It runs
server-side so OPENAI_API_KEY is never sent to a browser. Do not move this
call into the frontend — a key in client JavaScript is a key anyone can take.
The live setup is: GitHub Pages (site) → Vercel (interpreter) → OpenAI. Pages serves static files only and cannot run the function, which is why the two are split. Run these from the repository root.
1. Deploy the function. No vercel.json is needed — Vercel treats api/*.js
as serverless functions and serves the rest as static files, which is exactly
this layout. Deploying the whole repo is fine and gives you a second, fully
working same-origin copy of the site at the Vercel URL.
npm i -g vercel # once
vercel login
vercel --prod # deployed: https://ai-clothes-application.vercel.app
2. Store the key. It lives only in Vercel's encrypted environment. It is never committed, never bundled, and never sent to a browser.
vercel env add OPENAI_API_KEY production # paste the key when prompted
vercel env add ALLOWED_ORIGIN production # value: https://lxmafromnyc.github.io
vercel --prod # redeploy so the vars take effect
ALLOWED_ORIGIN is scheme and host only — no path, no trailing slash. For this
repository's Pages site that is exactly https://lxmafromnyc.github.io.
3. Point the site at the function. Already done — index.html and
find-clothes.html each carry the tag. The tag name and the
window.FINDWEAR_API override keep their original spelling, so an existing
deployment needs no configuration change:
<meta name="findwear-api" content="https://ai-clothes-application.vercel.app/api/interpret">
This is the only place the endpoint is configured. Change it here if the
deployment URL ever changes; window.FINDWEAR_API overrides it at runtime if
you need to point somewhere else without editing the file.
The account calls (/api/account, /api/auth, /api/checkout,
/api/portal) ask the page's own origin first. A page served by the app
itself, on the deployment or under npx vercel dev, talks to the API beside
it, which is the one its session cookie belongs to. The tag's deployment is
asked only when the page's origin has no API, as on GitHub Pages: a static host
answers 404 to the GET, or 405 to a POST. window.FINDWEAR_API, when set,
still overrides both.
4. Verify the whole chain.
./scripts/verify-api.sh https://ai-clothes-application.vercel.app/api/interpret https://lxmafromnyc.github.io
It sends the real request "Find me a black oversized hoodie under $80", then
checks the response came back tagged source: "openai", that a colour, a fit and
the $80 budget were extracted, that CORS allows your Pages origin, that preflight
works and lets through the X-Fynd-CSRF header signed-in requests carry, that
GET and empty queries are refused, and that no key material appears
in the response. It exits non-zero if anything fails and names the fix — a 503
tells you the key is missing, a missing CORS header tells you ALLOWED_ORIGIN
is unset.
Then open the live Ask page, type the same request, and confirm no fallback notice appears. A notice means the browser did not get an AI reading, and its wording says which step failed.
Deploying everything to Vercel removes steps 3 and 4's CORS entirely: the site
and the function share an origin, /api/interpret resolves by default, and
ALLOWED_ORIGIN can stay unset. The Pages site can then just redirect there.
| Variable | Required | Purpose |
|---|---|---|
OPENAI_API_KEY | yes | Your OpenAI key. Without it the endpoint returns 503 and the frontend falls back to local parsing. |
OPENAI_MODEL | no | Model to call. Defaults to gpt-4o-mini; set it to whatever your account has access to. |
AI_PROVIDER | no | Which model reads a request. Unset in production, which is OpenAI. gemini runs the interpreter in api/_interpreters/. See Trying another model. |
GEMINI_API_KEY | only for AI_PROVIDER=gemini | A Google AI Studio key. Production does not need it, and never sees it: it is read inside the function, sent as a header, and redacted out of anything logged. |
GEMINI_MODEL | no | Which model to call. Defaults to gemini-3.6-flash. |
GEMINI_THINKING_LEVEL / GEMINI_THINKING_BUDGET | no | How much thinking to allow: thinkingLevel (minimal, the default, through high) on a 3.x model, thinkingBudget (0, off, by default) on a 2.5 one. |
OPENWEBNINJA_API_KEY | for live search | The product source's key. Without it /api/search returns 503 and the frontend falls back to the sample catalogue, labelled as such. |
OPENWEBNINJA_SUBSCRIPTION | for live search | active while the OpenWeb Ninja plan is paid for. Otherwise OpenWeb Ninja is never called, key or no key, and /api/search returns the same 503. Unset in production today: the plan has lapsed. |
PRODUCT_SOURCE | no | Which adapter in api/_providers/ finds the products. Unset runs openwebninja when its key is set and its subscription is marked active; otherwise no source, and /api/search answers 503. Nothing falls back to another paid source unless it is named here. |
See Accounts and signing in and Plans, payments and subscriptions for the whole setup.
Both keys are server-side secrets. Set them in your host's dashboard or CLI
(npx vercel env add OPENAI_API_KEY, npx vercel env add OPENWEBNINJA_API_KEY). Never put either in a file you commit, never prefix
one so a bundler would publish it, and never move either call into the frontend
— a key in client JavaScript is a key anyone can read.
Photos and documents can be dropped onto the search card or chosen with the Attach files button. Images get a thumbnail; PDFs and other documents are named and labelled with their type rather than rendered as a picture they are not.
Accepted: JPEG, PNG, WEBP, GIF, AVIF, HEIC, BMP, PDF, TXT, CSV, RTF, DOC, DOCX, ODT. Up to 8 files, 10 MB each, 40 MB together. A file whose type the browser does not report is judged by its extension, so a photo straight off a phone is not turned away.
Nothing is uploaded. Files are held in the page until the search is submitted, and even then only a manifest travels — each file's name, type and size. The bytes stay in the browser, because nothing on the server can read a file yet, and sending megabytes to be discarded would spend the shopper's bandwidth and put their photos on a server for no purpose.
/api/search accepts that manifest, shapes it, and reports back
attachments: { received: n, used: 0 }. The interface says the same thing in
words under the chips. An attachment is never presented as something that
influenced the results, because it did not.
When the backend can use them, manifest() in assets/attachments.js is the
one function that changes.
Interface behaviour is covered by node scripts/test-ui.js, which drives the
real page in a browser: dragging, dropping, the picker, multiple files,
refusals, removal and submission.
Both endpoints spend money — one on OpenAI credit, the other on product-search
quota — so who may call them from a browser is decided in one place,
api/_cors.js, rather than twice with a chance of drifting apart.
Four sources of allowed origins, in order of how much configuration each needs:
SITE_ORIGINS in api/_cors.js.
The Pages site is a fact about the project, not a deployment setting, so it
lives in the repository where it cannot go missing. These are public
origins, not secrets — the same hostname is already in the meta tag of every
page — and keeping them here means the site cannot be locked out of its own
API by a mistyped or undelivered environment variable.Host
header. A page served from the same host as the function is same-origin by
definition. No configuration, and it keeps working on production, preview
and custom domains alike.VERCEL_PROJECT_PRODUCTION_URL,
VERCEL_BRANCH_URL, VERCEL_URL). Needs Automatically expose System
Environment Variables left enabled in project settings, which is the
default.ALLOWED_ORIGIN, for anything the first three do not cover. It adds to
the list and cannot remove from it. One origin or several, comma-separated. A trailing slash is tolerated and
stripped, because pasting a URL out of an address bar brings one along and
the resulting mismatch is invisible in a dashboard.ALLOWED_ORIGIN=https://lxmafromnyc.github.io
ALLOWED_ORIGIN=https://lxmafromnyc.github.io,https://findwear.example
There is deliberately no wildcard and no blanket *.vercel.app rule: that
would let anybody's Vercel deployment spend this project's API credit.
A rejected origin gets a 403, preflight included. This matters more than it
looks. Answering a preflight 204 without the CORS headers is what a browser
treats as a rejection, but the log only records the 204 — so a misconfigured
deployment reads as a working one, and the failure appears to be somewhere else
entirely. A 403 puts the reason in the log where it can be found:
OPTIONS /api/search → 403 the Origin is not allowed
OPTIONS /api/search → 204 the Origin is allowed; the POST will follow
A request with no Origin header is not a browser cross-origin request —
curl, a server-to-server call — and is left alone, so the verification
scripts keep working.
An allowed origin may send Content-Type and X-Fynd-CSRF. The first
because every POST is JSON; the second because every signed-in POST carries the
CSRF token. The name is taken from api/_auth.js, which checks it, so
the two cannot drift apart. A header the page sends but the preflight answer
leaves out is a refusal the server never hears about: until the CSRF header was
listed, logout, checkout, the billing portal and resend-verification were all
blocked by the browser on the Pages copy, while the all-on-Vercel deployment —
same-origin, so never preflighted — showed nothing wrong. scripts/test-e2e.js
now logs out from a page on a second origin to keep it that way. That test runs
without a Playwright route, because while one is installed Playwright answers
every preflight itself and allows whatever was asked.
scripts/test-cross-origin.js goes one step further and uses the production
hostnames: the pages at https://lxmafromnyc.github.io/AI-clothes-application/
and the real handlers at https://ai-clothes-application.vercel.app, both served
from the test machine over TLS through a local proxy. The two are different
sites, so the session cookie really is a cross-site SameSite=None; Secure
cookie, which plain http on one host cannot test. It signs up, logs out and
checks out from the Pages origin, and shows that a page on another origin can
neither read the account nor end its session.
Accounts on the Pages copy depend on third-party cookies. The session
cookie belongs to the Vercel host. To a page on lxmafromnyc.github.io that is
a third-party cookie — github.io and vercel.app are both public suffixes, so
the two are different sites, not just different origins — and a browser that
refuses third-party cookies refuses it whatever the CORS headers say:
| Browser, default settings | Signed in on the Pages copy |
|---|---|
| Chrome, Edge | Works. Third-party cookies are allowed by default. |
| Safari 13.1 and later, and the other WebKit browsers on iPhone and iPad | Does not work. Cookies on cross-site requests are blocked outright. |
| Firefox | Email and password work, in a cookie jar kept for the Pages site alone (Total Cookie Protection). Continue with Google is not reliable: its cookie is set while the browser is on the Vercel host itself, which is a different jar. |
| Brave; Chrome in Incognito; any browser set to block third-party cookies | Does not work. |
"Does not work" looks like this: the sign-in request succeeds and the server creates the session, but the browser drops the cookie, so the next request arrives signed out. Searching needs no account and is unaffected.
So accounts and billing belong on the Vercel copy, where the page and the
functions share an origin and the cookie is first-party in every browser. The
fixes for the split hosting are about sites, not headers: serve the pages and
the functions under one registrable domain (findwear.example and
api.findwear.example are the same site, so the cookie is first-party), or
send the Pages copy's account and pricing links to the Vercel copy. A
Partitioned (CHIPS) cookie would cover email sign-in in Chrome with
third-party cookies blocked, but not Google sign-in, and Safari's support for it
has come and gone between releases.
"a black oversized hoodie under $80"
|
v
POST /api/interpret browser sends the request plus the
| vocabulary the catalogue can match
v
OpenAI chat completions key lives here, server-side only
| the model reads the request; it never
| names a product, price, shop or URL
v
{ categories: ["hoodie"], colors: ["Black"],
fits: ["Oversized"], maxPrice: 80, ... }
|
v
POST /api/search the interpreted constraints, nothing else
|
v
OpenWeb Ninja q=black oversized hoodie, max_price=80
Real-Time Product Search key lives here, server-side only
|
v
real products, each carrying its own photo
and its own retailer offer
|
v
the verification gate drops anything without a title, price,
| image, retailer or direct product URL
v
rendered, badged with the retailer, linking to its page
While that runs, the page says what is actually happening, and changes only when something actually happened:
| From | Until | The page says |
|---|---|---|
| the search is sent | the request has been read | Understanding your request, over the request as typed |
| the reading is back and the product search is sent | that search answers | Finding matching products, over what was understood: "Looking for black oversized hoodies under $80", "Looking for cozy sweaters, sweatshirts or cardigans to wear with jeans" |
/api/search finds the products and verifies each one in a single request, so
the page cannot see where one ends and the other begins, and it does not
pretend to with a timed "verifying" step. There is no timer in any of it: a
fast or cached search goes straight to its results, a slow one keeps its line
on screen while a hairline pulses quietly along the bottom edge of the search
box, and results, an empty answer or an error replace both the moment they
arrive. The hairline is the full width of the box at both stages, so it says
the box is working, not how far along it is; it lies inside the box's border
and padding, so the box keeps its size. While the search runs, the page keeps
the box and the stage line in view, and brings the results up when they come.
Nothing claims a count of shops or products. A garment named only as the
setting ("with jeans") is said as the setting. The lines keep their size
between stages, so the page does not move. The screen-reader status announces
each stage (the hairline itself is hidden from assistive technology), focus
stays where the shopper left it, and with reduced motion nothing moves at all.
If the interpreter fails, the frontend reads the request locally instead and shows a notice saying so, naming the reason: no interpreter connected, deployed without a key, unreachable, or an unusable reply.
What the page may show when there are no products depends on why:
/api/search says | State | The page shows |
|---|---|---|
503 / 404 | not-configured | the sample catalogue, with a notice and a Sample badge on every row that is not a real listing |
502, or unreachable | unavailable | "Product search unavailable" — no sample rows |
200, empty products | empty | "No matches found" — no sample rows |
The sample catalogue stands in only when nothing is connected at all. Once a product source is configured, a failed or empty search says so plainly: a deployment that can sell things must never pad the page with demo rows, however clearly they are labelled. No product is ever invented to fill the gap.
A 502 says which failure it was, so it can be read straight off the
browser's network panel: reason is timeout or failed, kind is the
failure's class (invalid-key, bad-request, rate-limited,
credits-exhausted, rate-limited-or-credits, server-error, network,
timeout, bad-response for a 200 that is not JSON, other) and
upstreamStatus is the status the source answered with, or null when it
never answered. The source's own message, URLs and keys stay in the server log.
Only the source can produce a 502. An offer lookup that fails, times out or
answers nonsense costs that one product its link and nothing else: the search
answers 200 with whatever did verify. The one exception is a search in which
NOTHING verified because the lookups ran out of time — the source returned
products, every one needed a seller's price and link, and not one lookup came
back usable before the deadline. That is a 502 with reason: "timeout",
stage: "offers" and the same counts a 200 would carry, and the page says
the prices could not be confirmed in time; it is not charged. A search whose
lookups answered in time and showed nothing usable is still an honest empty
200. A cache or session store that cannot
be reached is a cache miss. A fault of Fynd's own after the source answered —
in the verification gate, the garment filter or the ranking — is a 500 with
reason: "internal" and the stage, never "the product source is
unavailable". A cached entry of another shape (written by another version into
the same store) is a miss, not a product without its link.
The OpenWeb Ninja key, country and language are read in one place
(api/_providers/openwebninja.js): the key is trimmed and unquoted, and the
region is sent as the two-letter codes the API takes (US → us, en-US →
en; a value that is not a code is named in a warning and the default used).
scripts/probe-openwebninja.js runs /api/search's own search —
runSearch() in api/search.js, everything the route does except check and
charge the shopper's allowance — on the body the page would post for the
words given (or a body copied from the browser with --body), under the
route's own deadline. It prints every request that reaches OpenWeb Ninja — and
Serper, when OpenWeb Ninja says its searches are spent and the route falls back
to it: source, path, parameters, header names (and whether the key sent is this
environment's, as a yes or no), status, time, and whether the clock cut it off;
then the answer /api/search would give, a failure classified exactly as the
route classifies it, and which source refused when it fell back.
scripts/test-deadline.js holds that equivalence: for the same body and
environment the probe and the route send identical requests, are cut off at the
same moment, answer every provider failure identically, fall back to Serper
with the same requests and answer, give the same 502 when the fallback is spent
too, and — with no OpenWeb Ninja key — neither asks anything (the route answers
503, the probe refuses to run). So a probe that
works with an environment means /api/search works with it; when one works and
the other fails, the two processes are not reading the same environment.
Shoppers often cannot name what they want: "something like a hoodie but
cleaner", "a shirt that looks like a jacket", "something cozy I can wear with
jeans". Searching those words finds hoodies, shirts and jeans — the wrong thing
each time. So every request is read a second time, deterministically and from
tables (readConcepts in assets/interpret.js), for how it names its garments:
| The request | Read as | Asked of the provider |
|---|---|---|
| something like a hoodie but cleaner | compared with a hoodie; polished, minimal | minimal quarter zip pullover crewneck sweatshirt knit pullover |
| a shirt that looks like a jacket | a shirt crossed with a jacket | overshirt shirt jacket chore jacket |
| loose black pants that look nice | trousers; black; relaxed; polished | black relaxed wide leg trousers pleated trousers |
| something cozy I can wear with jeans | jeans only the setting; cozy | cozy sweater sweatshirt cardigan |
| that short jacket thing people wear over shirts | a jacket, unsure of the name; shirts the setting | cropped jacket overshirt shirt jacket |
| black oversized hoodie under $80 | not descriptive | exactly what it was asked before |
What it holds to:
People also type badly: misspelled, in slang, in fragments, around what they do
not want. So before anything is read, the request is put in one plain form
(normalize): contractions are opened ("aren't" is "are not", so the "not" can be
seen), shorthand and slang are said in shop words ("trackies", "kicks"), plain
misspellings of known words are corrected ("hoddie", "jeens", "sweter") — but a
word that only looks like one ("dressed", "heather", "boat") is left alone — and
"find me" or "I'm looking for" comes off the front. Then what the request rules
out is read before anything else:
| The request | Read as | Asked of the provider |
|---|---|---|
| pants that aren't skinny | trousers; not skinny | straight leg pants relaxed trousers wide leg pants |
| something warm but not a coat | warm; not a coat | warm sweater fleece jacket overshirt |
| something like a hoodie without the hood | a hoodie with no hood is a sweatshirt | crewneck sweatshirt pullover sweatshirt knit pullover |
| a shirt but heavier | a heavyweight shirt | heavyweight shirt |
| jacket that isn't really a jacket | compared with a jacket, not ruling one out | overshirt shirt jacket light jacket |
| i want a shirt thats kinda oversized | the talk taken out | oversized shirt |
| blak hoddie | black hoodie | black hoodie |
A garment ruled out is never the target and never searched, and a listing that
is one is removed after the gate (counted as ruled-out-by-the-request). A
fit, colour, material or logo ruled out is never searched, and listings that
have it rank below everything that does not. "Oversized but fitted" holds to
neither side. "Expensive-looking" is a look, never a price. A request that is
only talk ("something like what my mom wears") is searched as broadly as it
was asked, never as the talk itself.
Anything else after a "not" that a listing could be titled with is ruled out as itself and as what the page's descriptors call it: "not ripped" rules out ripped and distressed, "not see through" rules out see through and sheer as one phrase, "no florals", "aren't chunky". "Not sure", "not into" and "not a fan" rule out nothing. A name the request points at — "jackets like the ones in top gun" — is neither a garment nor a search word. Thinking noises ("umm", "uh", "fr") are dropped wherever they appear.
/api/interpret attaches the reading as concepts, only when there is one, so
the reply for an exact request is unchanged. node scripts/test-messy.js holds
the messy-input behaviour; node scripts/bench-messy.js measures it before and
after on 88 deliberately difficult requests. node scripts/test-concepts.js
holds all of this; node scripts/bench-concepts.js measures it before and after
against a fixed pool of listings (see the script for how).
The tables above are deterministic, and blind outside what they list. The served interpreter is not blind, and is not trustworthy either: asked to read "something nice for dinner" a model will happily answer black dress, heels, women. So the two are used for what each is good at, in three layers, with one model call and one provider search per request, exactly as before:
The page's reader (assets/interpret.js) puts the request in plain form
and reads what it can: the garments and the role each plays — wanted,
compared to, worn with, ruled out — and the shopper's own colours, fits and
exclusions.
The model (api/interpret.js) answers the flat fields as it always did,
plus a reading: what is wanted (want, up to three alternatives), what it
is compared to (comparedTo), worn with (wornWith) and not wanted
(avoid), and its fit, material, style and occasion.
api/_reading.js holds every part of that to the shopper's words before
any of it is used:
avoid needs a
negation in the words; nothing ruled out is ever searched;Then the two readings are reconciled. A request in shop words with no
concepts is an exact search and is answered exactly as before. Where the
tables understood the request, their reading stands and the model adds only
exclusions and settings. Where the tables knew the garment but not what the
comparison or the named reference asks for ("a cardigan but more structured",
"jackets like the ones in top gun"), the model's kinds of that garment are
searched. Where the tables found nothing, the model's checked reading is the
reading. understood.by says which: exact, reader, reader+model or
model, with what was refused and what was kept.
Everything ruled out — garments, and modifiers like skinny, logo or see through — is enforced after the gate as well as kept out of the search.
scripts/bench-messy-queries.js holds the requests, version-controlled: 99
messy ones and 8 plain ones as a control group. Every one has a stable id
(M001…, P001…), a type (misspelling, merged words, abbreviation, fragment,
natural language, several attributes, synonym, contradiction, size, negation,
worn-with, comparison, reference, slang, contraction, word order,
conversational, no-result, plain), its intent in words, and the rules it is
graded by. Three are requests nothing honest can answer ("cashmere sweater
under $5"): for those, zero products is the right answer.
scripts/bench-messy-live.js puts them through the real path. In process (the
default), each checkout runs in a process of its own: the page's own scripts
read the request as the browser does, the page's call to /api/interpret goes
to that checkout's real handler (which answers 503 when no interpreter is
configured, and the page reads the request locally), and the body the page
posts goes to that checkout's real /api/search handler — allowance, provider,
offer lookups, cache, deadline, gate, filter, ranking — for the HTTP status and
JSON the browser would get. Each request is a new anonymous visitor, and the
cache and allowance counters are kept in memory unless --store env is given,
so a run never writes into a configured Redis. With --servers, the page's two
requests go over HTTP to running servers instead (vercel dev, or a
deployment), with whatever environment they run with.
# this checkout against main, on the same requests, interleaved
git worktree add ../fynd-main origin/main
node --env-file=.env.local scripts/bench-messy-live.js --roots before=../fynd-main,after=.
# against running servers
node scripts/bench-messy-live.js --servers after=http://localhost:3005
Each run writes results.json (every request: HTTP status, latencies, the
failure's kind if any, the phrase asked, provider searches by source and offer
lookups, whether the route fell back, the products shown and how each was
graded) and summary.md to bench-results/ (ignored by git). The summary
counts every dataset request as attempted, completed (200), failed, rate-limited
or skipped (left out by --only, --set or --types); says how each metric is
measured — relevance is graded automatically from patterns written before any
run, and no manual relevance judgment is made; and picks representative
successes and failures, with each kind of failure counted by kind of request. Each request is graded by the same rules
whichever checkout answered it: interpretation (target, exclusions, stated
constraints kept, nothing invented), relevant@4 and @8, strong matches and
clearly wrong in the top 8, hard-constraint violations (budget, stated colour
or gender contradicted, anything ruled out shown), products the filter removed
that were plainly what was asked, results missing where some were expected,
provider searches, offer lookups and latency. What "plainly" means is written
beside each request as patterns on the product title; a title that does not say
is counted neither way. --reader local measures the page's local fallback
instead of the model.
Every failure is recorded as whose it is — environment (503: nothing
configured), provider (502, with its kind), fynd (500, with the stage) or
allowance (429) — and none is ever counted as a search. A 502 that is
rate-limited, a server error, a network failure or a timeout is tried again
once after a pause (--retries, --retry-delay-ms); a refused key, a bad
request or spent credits never are. The record names each checkout's
configuration as states (provider configured or not, interpreter configured or
not, which store), never values. The run exits 2 when no search succeeded on a
checkout — the record then measures interpretation only, and its summary says
so in its first line — 1 when the judged checkout crashed, showed something
ruled out or made more than one provider search per request, and 0 otherwise.
A request the route answered from Serper after OpenWeb Ninja refused for want of
searches is counted as a fallback, not as a second search of ours.
.github/workflows/live-validation.yml runs the same checks on GitHub's
runners, which can reach the providers and the deployments when a sandbox
cannot. Its first job holds no key: it posts the page's request to production
and to the commit's Vercel preview (one search each, inside the anonymous Free
allowance) and prints the answer's diagnostics and x-vercel-id. Its second
job runs the probe, the diagnostic and the 107-request benchmark in process,
main at 9507da1 against the commit, with OPENWEBNINJA_API_KEY and
OPENAI_API_KEY from the repository's Actions secrets — and without them says
so and runs nothing. Each record is printed to the job log one line per request
and checked for the keys before it is uploaded. The benchmark is never pointed
at a deployment: a cookieless caller is metered at three searches a day, and
previews sit behind Vercel Authentication.
scripts/diagnose-search.js --server says whether the running server answered
as the diagnostic's own process does with the same environment, and exits 1
when either did not answer 200.
Fynd runs on OpenAI. Whether it should is a question with an answer — how often
each model returns usable structured output, how often the fields are right,
what a search costs and how long a shopper waits — and answering it needs a
second model wired up well enough to be measured, without production moving an
inch. That is what api/_interpreters/ is.
AI_PROVIDER decides which model reads a request:
AI_PROVIDER | what runs |
|---|---|
| unset — production | the OpenAI path in api/interpret.js, unchanged |
openai | the same path, named explicitly |
gemini | api/_interpreters/gemini.js, on gemini-3.6-flash |
| anything else | nothing, so the endpoint answers 503 |
That last row is deliberate, and is the rule PRODUCT_SOURCE already follows: a
typo should be visible as a 503 rather than quietly answered by a model you did
not ask for.
An alternative interpreter returns the same raw object the OpenAI path returns,
and /api/interpret shapes it with its own shapePreferences(). So the JSON
the browser and /api/search receive is identical whichever model produced it —
same eleven fields, same validation, same 400-character cap, same metering on
the tokens the provider itself reported. The only difference in the reply is
source, which says "gemini" instead of "openai". Nothing under assets/
knows any of this, and there is nothing to change there.
To try it on a preview deployment — never on production:
npx vercel env add GEMINI_API_KEY preview
npx vercel env add AI_PROVIDER preview # value: gemini
GEMINI_API_KEY is a server-side secret like every other key here. It is read
only inside the function, sent as the x-goog-api-key header rather than as
a query parameter — so it cannot end up in a URL that something logs — and
redacted out of anything the adapter prints, including an upstream error body
that quotes it back.
Measuring it. scripts/bench-interpreters.js sends the same twenty
clothing requests to both models, with the same system prompt, the same
catalogue vocabulary and the same JSON mode, and reports valid structured
output rate, field accuracy against a fixed rubric, malformed-output rate,
latency, tokens in and out, and cost per 1,000 searches:
OPENAI_API_KEY=... GEMINI_API_KEY=... node scripts/bench-interpreters.js
node scripts/bench-interpreters.js --dry-run # the plan, spending nothing
node scripts/bench-interpreters.js --queries=5 --only=gemini
node scripts/bench-interpreters.js --out=run.json # every reading, for later
node scripts/bench-interpreters.js --compare=run.json # read it back, no calls
node scripts/bench-interpreters.js --diagnose # one call each, first
Run --diagnose before trusting a benchmark. It makes exactly one call per
provider and reports what came back: the HTTP status, the provider's own error
code and message, whether the request reached the provider at all, whether
authentication was accepted, and whether the reply was shaped the way the
adapter expects. A 400 costs one further call, asking again without the one
field most likely to have caused it — response_format for OpenAI,
generationConfig.thinkingConfig for Gemini — so a request-shape mismatch is
identified rather than guessed at. It exits non-zero unless both returned a
usable Fynd intent.
It exists because a whole run can fail for a reason that has nothing to do with
either model. An egress allowlist or a corporate proxy answers a blocked host
with a real HTTP response — a 403 with a text/plain body — which is a
perfectly ordinary upstream error as far as any client is concerned, and a run
of twenty of them looks exactly like a provider outage. So a failure now
carries its status, its headers and the provider's own message all the way into
the report and into --out, the error envelope is parsed from the whole body
before the body is truncated for logging, and a non-2xx that did not arrive
as JSON is named as what it is: something in the path answering on the
provider's behalf. A benchmark over a broken path measures the path.
It also reports, per query, where the two models actually disagree — on the
budget, colour, category, fit, brand, occasion and style — naming the reading
that matches the rubric where the rubric has an opinion, and saying so plainly
where it does not. Two models can post the same accuracy and still be wrong in
different places, which is the thing a rate cannot show you. --compare renders
the whole report from a file --out wrote, so a run made on a machine that has
both keys can be read on one that has neither.
It spends real credit on both accounts, and says how many calls it is about to
make before it makes any. Prices move, so the per-1M unit prices it costs with
are printed and overridable (--gemini-out=2.50). Neither key is ever printed.
Gemini's thinking is turned down to minimal by default: this is a short
extraction with a fixed output shape, and turning it down is what makes the
comparison against gpt-4o-mini a comparison of like work.
GEMINI_THINKING_LEVEL raises it, and thinking tokens are billed as output, so
the benchmark costs them as output.
The Gemini 3 family changed two things this adapter sends, so the request is
built to match the model it is going to. temperature is ignored by Gemini 3
and Google's guidance is to leave it out, so a 3.x request does not send it —
while a 2.5 request still gets temperature: 0. And thinkingBudget is the 2.5
field, deprecated in favour of thinkingLevel; sending both in one request is a
400, so exactly one is ever sent. One consequence is worth knowing before
reading a benchmark: gpt-4o-mini runs at temperature 0 and Gemini 3 runs at
its own default, so Gemini's readings are not as repeatable. Use --repeat for
a steadier number.
gemini-2.5-flash is not a working default any more. Google answers a request
for it with a 404 on an account that was not already using it, which is why
--diagnose asks the API which models the key can actually use whenever a model
404s.
Why not the Interactions API. Google now recommends it for new work and
calls generateContent legacy, but recommends is all it does: generateContent
remains fully supported and gemini-3.6-flash is served on both. The adapter
stays on generateContent because it is one stateless call in and one answer
out, which is exactly what the OpenAI path does — so the benchmark compares two
like things rather than a stateless call against a managed conversation. Moving
to Interactions is a separate decision, and nothing here forecloses it.
Neither model is given a response schema. Gemini has one available and OpenAI,
as called here, does not — using it would make the malformed-output rate measure
the schema rather than the model. Both replies are parsed with a plain
JSON.parse, and a fenced or chatty reply counts as malformed on both sides
rather than being repaired.
Removing it is a require and a marked block in api/interpret.js, plus the
api/_interpreters/ directory. node scripts/test-gemini.js covers the whole
thing offline — the request, the parsing, every way a reply can be malformed, an
API error, a missing key, the key never reaching a log or a response, the shaped
output being the same object /api/search already reads — and holds the two
promises that matter: production with AI_PROVIDER unset still calls OpenAI and
nothing else, and nothing under assets/ learns a second provider exists.
The two halves of a search have separate jobs, and neither does the other's:
/api/interpret understands the request. It turns "black oversized Nike
hoodie under $80" into structured constraints. It never names a product,
a price, a shop or a URL./api/search finds the products. It hands those constraints to a product
source, and everything shown on a card comes back from that source.Nothing in between invents anything. The model is never asked what a product costs or where to buy it, because it does not know — only the source does.
No provider is hard-coded: the registry in api/_providers/product-source.js
decides which adapter runs from PRODUCT_SOURCE. Unset, or naming an adapter
whose credentials are missing, and the endpoint answers 503 and the interface
says plainly that no product source is connected.
Real-Time Product Search searches Google Shopping's cross-retailer index, so one query reaches Amazon, Walmart, Target, Nordstrom, ASOS and the long tail of clothing merchants rather than one shop's catalogue. Set these in the server environment:
| Variable | Required | Purpose |
|---|---|---|
OPENWEBNINJA_API_KEY | yes | Your API key, sent as the x-api-key header. |
OPENWEBNINJA_SUBSCRIPTION | yes | active only while the OpenWeb Ninja plan is paid for. Anything else — unset included — and the adapter counts as not configured and makes no request at all, even with a key present: /api/search answers 503 and the Search page shows its labelled sample items. |
PRODUCT_SOURCE | no | Which adapter to run. Left unset, the OpenWeb Ninja adapter is used when its key is present and OPENWEBNINJA_SUBSCRIPTION is active. Set it only to run a different one. |
OPENWEBNINJA_COUNTRY | no | Marketplace to search, ISO 3166-1 alpha-2. Defaults to us |
OPENWEBNINJA_LANGUAGE | no | Result language, ISO 639-1. Defaults to en |
Set them on the server only. The key is read inside the serverless function, sent as a request header rather than in the query string, and never included in a response — a 502 from the provider is logged server-side and reported to the browser as a generic message, so neither the key nor the upstream body leaks.
vercel env add OPENWEBNINJA_API_KEY production
Setting PRODUCT_SOURCE is not necessary — an unset value runs the OpenWeb
Ninja adapter whenever its key is present, so there is no second variable that
has to agree with the first. An explicit value always wins, and one naming no
adapter selects none, so a typo surfaces as a 503 rather than quietly running
something else.
Environment variables apply to the next build, not the running deployment, so redeploy after adding them.
The adapter builds one search phrase from the interpreter's output, in the order a shopper would type it — gender, colour, fit, style, brand, garment, occasion, then any remaining keywords:
{ colors: ["black"], fits: ["oversized"], brands: ["Nike"],
categories: ["hoodie"], maxPrice: 80 }
-> q=black oversized nike hoodie & max_price=80 & country=us
Only words the shopper's own request produced are used. season is left out
deliberately: it reads as a keyword to the search engine and would narrow
results on a word used descriptively. The stated budget is passed to the source
as max_price, and any offer that still comes back above it is dropped.
Confirmed against a live response: the commerce fields appear at the top
level of a search record, not only nested under offer. Both shapes are read.
| Fynd | OpenWeb Ninja |
|---|---|
title | product_title |
imageUrl | product_photos[0], from that product's own photo list — the first entry that is an absolute https URL, since the pages are served over https and a browser refuses an http image before it requests it. A record whose photos are all unusable is rejected as image-url-not-https, one with no photos as missing-image-url. |
price | price, or the price of the resolved offer |
retailer | store_name — a domain, e.g. nike.com |
productUrl | the resolved offer's offer_page_url — never product_page_url |
brand | product_attributes.Brand, when present |
/search returns Google Shopping's product view. Its product_page_url is
a Google URL — a live response returned a google.com/search?… URL in it. It is
never the retailer's page, so it is never used as one. store_name is a domain,
and a domain is not a URL: no link is ever built from it.
The per-seller links live behind a second endpoint, confirmed in the vendor's OpenAPI manifest:
GET /realtime-product-search/v2/product-offers?product_id=…
"Get all offers available for a product. Each page of offers contains
offers from 10 sellers."
So the adapter:
/product-offers for that product_id and takes the first
offer that has one, preferring the shop the search result namedNothing is inferred at any step.
This costs one extra request per product that needs a link. A page of 12 products is up to 13 requests rather than 1. On the $25/mo Pro plan that is roughly 770 shopper searches a month rather than 10,000. Two knobs bound it:
| Variable | Default | Effect |
|---|---|---|
OPENWEBNINJA_RESOLVE_OFFERS | on | off skips the lookups entirely — cheaper, and almost everything is then dropped for having no retailer link |
OPENWEBNINJA_OFFER_BUDGET_MS | 6000 | total wall-clock budget for the lookups; whatever resolved by then is what shows |
OPENWEBNINJA_OFFER_LOOKUP_TIMEOUT_MS | 2500 | how long one lookup may hold a worker; a straggler's worker moves on to the next candidate, so slow sellers cannot starve the ones behind them. The straggler itself runs on to the request's deadline and its answer is used if it arrives in time — the same lookups start at the same moments either way. Each answer's time to headers and to the whole body is reported in diagnostics.offers.lookupTiming |
Since a lookup is a whole request, it is only spent on a record that could actually reach the page:
toProduct() with a stand-in offer —
rather than a copy of its rules being kept here, which would drift from it.
Each one is counted in diagnostics.offers.skippedUnfit under the gate's own
name for the fault.wanted + LOOKUP_SLACK lookups per search, so poor data
cannot run the request count down the whole candidate list. LOOKUP_SLACK is
8, making the ceiling 20 lookups for a page of 12. Measured over 140 seeded
searches, that costs what the uncapped behaviour cost — a tenth of a request
per search — for one and a half more products and twice as many full pages,
with a worst case of 21 requests where the old one could reach 25. Lower it
to 4 when provider quota rather than a thin page is the binding constraint:
1.9 requests a search cheaper, about one product thinner. The full table is
in the comment on the constant.A record's own stated price is deliberately not used to skip a lookup. It is not a bound on what the sellers charge: the same record priced at $87.97 has been seen with one seller offer at $54 and another at $240. The budget is applied to the offer's own price, after the lookup.
Measure any of this offline, with no key and no network:
node scripts/bench-offer-resolution.js --out=before.json
node scripts/bench-offer-resolution.js --compare=before.json
It runs six scenarios of varying data quality through the adapter and the gate with both endpoints stubbed, and reports requests, lookups, products shown, gate pass rate, modelled latency and wasted lookups.
Lookups also stop early once enough records have a link, and only products that need one are looked up at all.
Two shoppers asking for "black oversized hoodie under $80" ten minutes apart
used to cost the same eighteen OpenWeb Ninja requests each. api/_cache.js is
what stops that. It has two layers, both kept in the same Redis-over-HTTP store
(api/_store.js) that already holds accounts, sessions, usage counters and
rate limits — there is no second database and no new service.
| Layer | Key | TTL | What is stored |
|---|---|---|---|
| Search results | the normalized intent, the provider, the page size and the marketplace | 30 minutes | The records the adapter produced, and its funnel |
| Product offers | the product's own id, the marketplace, and the shop the search record named | 2 hours | The one offer the adapter picked: price, currency, retailer, retailer URL |
| No usable offer | the same | 5 minutes | That this product had nothing showable |
The shop is in the offer key because pickOffer() prefers the offer whose
seller matches the shop the search result named, falling back to the first
usable one. The same product id found by one search that named Nordstrom and
another that named Nike resolves to two different retailer offers — both real,
both correctly priced, and not interchangeable — so they are two entries. The
rest of the intent is deliberately absent: colour and budget do not change
which seller stocks an item, so two searches landing on the same product in the
same shop are still one lookup. It over-keys negatives slightly, since "no
seller had a usable link" does not depend on the preferred shop; one key shape
for both outcomes is worth that, and the error is in the safe direction.
Four rules hold the whole thing up:
product-source.js that a live answer does, at the
moment it is served. A record whose link would fail the rules today is
dropped today, whatever it was when it was stored.The key is a SHA-256 digest of the normalized intent plus everything else that
changes an answer — provider, page size, country, language, whether offer
resolution is on. Case and spacing are normalized away, so "Black" and " black "
are one search; order within a field is not, because the adapter builds its
query phrase in that order and ["black","white"] asks the provider a
different question from ["white","black"]. Every intent field is in the key,
so two budgets, two colours or two fits are never one entry. The prefix carries
a version — fynd:cache:v1: — so bumping one constant invalidates everything
without touching the store.
Ten identical searches arriving at once would all miss and all call the provider, so the first one runs and the other nine wait on it, in process, bounded at 64 keys in flight. A distributed lock would add a second failure mode — a holder that dies, a lease that has to be renewed — to a problem that does not need one; losing coalescing across instances costs at most one duplicate search per instance.
Every reply carries diagnostics.cache: hits, misses, negative hits, what was
stored, and how many provider requests the hit saved. Counts only — no key, no
digest, nothing a search was stored under.
| Variable | Default | Purpose |
|---|---|---|
FYND_CACHE | on | Set to off to disable both layers and the coalescing with it. The provider then behaves exactly as it did before any of this existed. |
FYND_SEARCH_CACHE_TTL_SECONDS | 1800 | How long a verified search answer is reused |
FYND_OFFER_CACHE_TTL_SECONDS | 7200 | How long a resolved offer is reused |
FYND_OFFER_NEGATIVE_TTL_SECONDS | 300 | How long "this product has no usable offer" is remembered |
With no Redis configured the cache falls back to the same per-instance memory driver everything else falls back to, and a store that cannot be reached is simply a miss: the search goes to the provider and the shopper is answered.
Measured on the seeded scenarios in scripts/bench-offer-resolution.js, which
runs each of them cold, warm, and warm-offers-only:
| Pass | Requests / search | Latency | Products shown | Cache hit rate |
|---|---|---|---|---|
Cold (and with FYND_CACHE=off) | 18.5 | 2200 ms | 9.83 | 0% |
| Warm, inside the search TTL | 0 | 1 ms | 9.83 | 100% |
| Search TTL expired, offers still warm | 5.5 | 909 ms | 9.83 | 71.8% |
The products shown are identical in all three, in the same order — the benchmark asserts it rather than trusting it.
The request parameters above are taken from the vendor's own OpenAPI-derived
manifest, shipped in @openwebninja/mcp-server. The response field names come
from the vendor's published documentation and have not been confirmed
against a live call in this repository.
The mapping is therefore written to fail closed. It accepts the documented
spellings and their obvious variants, and anything it does not recognise
produces a record missing its required fields — which the gate rejects and
counts. A wrong field name shows up as an empty result set with a populated
rejected tally. It can never show up as an invented product.
To confirm the live shape, run the probe with a real key:
OPENWEBNINJA_API_KEY=... node scripts/probe-openwebninja.js "black oversized hoodie"
It prints the response envelope's keys, every key on the first product and on that product's offer, the record the adapter maps out of it, and the gate's verdict for the batch — enough to see at a glance which alias to add if a name differs.
The probe calls the provider directly with the words as typed. It does not go
through what a browser's search goes through — the page's reading of the
request, /api/search's intent, its query, the cache, the offer lookups, the
gate and the garment filter — so a probe that works and a search that fails
are not a contradiction. To see the browser's search, run:
node --env-file=.env.local scripts/diagnose-search.js "a baggy hoodie thats light grey and its cozy"
node --env-file=.env.local scripts/diagnose-search.js --server http://localhost:3000 "..."
It builds the body the page sends with the page's own code (as it does when
/api/interpret is not configured), runs it through the real handler, lists
every provider request with its status, and on a 502 prints the exception the
search threw: name, message, cause and stack. With --server it also posts the
same body to a running local server, so a server whose environment differs
from .env.local shows up as the server failing where the script does not. It
prints no key, cookie or header value.
api/_providers/etsy.js still works and is still registered, but production
never calls it: nothing selects Etsy unless PRODUCT_SOURCE=etsy names it
explicitly, and an unset PRODUCT_SOURCE runs OpenWeb Ninja instead. Deleting
one require and one registry line removes it entirely. It searches a single
catalogue, which is why it is no longer the default.
api/_providers/serpapi.js reaches the same Google Shopping index through a
different vendor. It is registered so the two can be measured against each
other on the only question that matters here — how many records survive the
gate with a link to the retailer's own product page — and nothing selects it
unless PRODUCT_SOURCE=serpapi names it. Fynd still runs on OpenWeb Ninja.
It searches engine=google_shopping_light, the cheaper and faster Google
Shopping surface; SERPAPI_ENGINE switches it to the full google_shopping.
Two differences from the OpenWeb Ninja adapter are worth knowing:
SERPAPI_API_KEY only inside the function, never returns it,
and puts every string it might log through redact() first, which replaces
the key with ***.product_link is
Google's comparison page and serpapi_immersive_product_api is an API
endpoint, so
neither is ever shown as a product URL. Each candidate field goes through
looksDirect(), which refuses every Google and SerpApi host, and a record
left without a retailer URL is dropped by the gate rather than linked
somewhere it should not be. When a result carries no direct link, the
google_immersive_product endpoint is asked for that product's stores, by
the page token the result carries — Google retired the Product service, and
a call to it now answers "The Google Product service is no longer offered by
Google." A failure that will repeat, a retired service or any 4xx, stops the
lookups for the whole search and is reported once as
diagnostics.sellers.halted rather than made once per record. One
extra request per product, counted in diagnostics.requests, which is what
the cost per search is computed from. SERPAPI_RESOLVE_SELLERS=off skips
that step.Which live field actually carries a merchant URL is answered against a real response rather than assumed:
SERPAPI_API_KEY=... node scripts/probe-serpapi.js "black oversized hoodie"
It prints the plan and the searches left on it (the account endpoint is free),
every key on the first result with each URL classified — retailer page,
Google's own, SerpApi's own, redirector or listing page — how many of the batch
carry a usable link inline, the record the adapter maps, and the gate's verdict
for the whole batch. Add --sellers to spend one more request on seeing what
the sellers endpoint returns.
Then measure it:
SERPAPI_API_KEY=... node scripts/bench-serpapi.js --queries=5 --max-requests=40
It runs realistic shopper searches through the adapter and the gate and reports
usable products per search, products shown, full-page rate, direct retailer-link
rate, latency, duplicates, retailer diversity, requests per search and cost per
search — the last at the plan's own price per request, read from the account
rather than assumed. A run spends real quota, so it states the worst case
before it starts, refuses to start when that exceeds the searches left, and
stops at --max-requests whatever happens. --out=path.json writes the whole
result for comparing against another provider later.
Serper (api/_providers/serper.js) is the source discovery falls back to when
SerpApi reports its search allowance gone. It answers POST /shopping, and the
open question about it is the same one the SerpApi probe exists for: whether a
result carries the retailer's own product URL anywhere, and under which key.
SERPER_API_KEY=... node scripts/probe-serper.js "Boxy Cotton Tee"
One live request. It prints the envelope's keys, shopping[0] raw and entire,
then every url-valued path anywhere in that result — nested objects and
arrays included, named by the exact path (offers[0].link, not "somewhere in
offers") — each classified by the gate's own rule: a retailer product page,
Google's own, a Google-hosted image, a forwarder and where it forwards to, or a
URL the gate refuses and why. Then the same question across the batch, the
record the adapter maps, and the gate's verdict.
If no result carries a retailer URL, it spends one more request on the web
/search endpoint and classifies the organic links the same way, because
whether ANOTHER Serper result type carries a merchant URL is the question a
/shopping response with none leaves behind. --no-search suppresses that,
--search forces it, --all walks every result rather than the first. Nothing
is written and the key is stripped by value out of every line, so a product URL
is printed whole rather than mangled by a redaction pattern.
Run against a live key for one catalogue row: /shopping returned 40 results,
every one of them carrying a single url-valued field — link — and every one of
those a google.com Shopping URL. No retailer URL anywhere in the response: not
under another key, not nested, not embedded in a forwarder. The shopping
endpoint cannot answer the question discovery asks, and reading it differently
will not change that.
The same query put to the web endpoint returned 9 organic results, all 9 of them retailer URLs that pass the link rule. So discovery escalates:
/shopping is asked first, as before./search is asked
once for the same phrase, built by the same queryFrom()./search, so a row pays for
the linkless surface once rather than once per phrasing. Nothing is
remembered across rows.An organic record carries a title and a link, and nothing else — no price, no photo, no retailer, because the endpoint supplies none of them and a hostname is not a shop's name. That has two consequences, both deliberate:
/api/search, whose gate wants five fields from the
source and would refuse a record with two. searchOrganic is not part of
the provider contract either: name, configured and search are what
/api/search runs on, and they are unchanged.productUrl, imageUrl and imageEvidence, the same three fields as ever.If nothing clears every gate, nothing is written. A fallback that cannot prove a photo writes no row, which is the same rule the rest of discovery runs on.
Offline, with no key and no network — intent, mapping, the gate's rejection
rules, and the JSON /api/search returns:
node scripts/test-pipeline.js
The rest of the suites, all offline except the two that drive a browser:
node scripts/bench-offer-resolution.js # what one search costs the provider
node scripts/test-concepts.js # descriptive requests: what they mean, and what must not change
node scripts/test-messy.js # misspelled, slang, negative and conversational requests
node scripts/test-reading.js # the model's reading held to the shopper's words
node scripts/test-bench-messy-live.js # the benchmark's requests and grading, and stand-in runs through the real handlers
node scripts/bench-messy.js --compare before.json after.json # 88 messy requests, before and after
node scripts/bench-concepts.js --compare before.json after.json # descriptive requests, before and after
node scripts/test-cache.js # the search and offer caches, and what they may not change
node scripts/test-gemini.js # the Gemini interpreter, and what did not change
node scripts/test-serpapi.js # the SerpApi adapter, its links and its costs
node scripts/test-stripe.js # payments and subscriptions
node scripts/test-auth.js # accounts, sessions, tokens, OAuth
node scripts/test-catalog-images.js # the catalogue image extractor's gates
node scripts/test-catalog-prices.js # the catalogue price extractor's gates
node scripts/test-ui.js # the interface, its palette, its contrast, and a search in progress
node scripts/record-demo.js # re-records the Search page's demo video
node scripts/test-demo-audio.js # the demo's sound: loudness, ducking, captions
node scripts/test-demo-film.js # the demo film: length, order, every store frame real
node scripts/test-e2e.js # the whole sign-in flow, in a real browser
Every pull request into main, and every push to main, runs
.github/workflows/ci.yml: the suites named in scripts/ci-tests.js, in two
jobs, the Node suites and the browser suites (in Playwright's Chromium). The
same command runs them anywhere:
npm ci && npx playwright install chromium # once; ffmpeg on the PATH too
npm test # every required suite
node scripts/ci-tests.js node # or one group: node | browser
Every suite is offline and keyless. It stubs its own network,
scripts/ci-offline.js refuses any connection off the machine, and each suite
starts with a clean environment, so a key in your shell never reaches a test;
the workflow references no secret. A suite passes only when it exits 0 and
reports a count with nothing failed and nothing skipped. A browser suite that
skips itself because Chromium would not start is a failure, not a pass. A new
scripts/test-*.js has to be added to a group, or to NOT_RUN with its
reason, or the run stops.
test-catalog-prices is back in the browser group. Its one failing test,
"the live shape: the endpoint the page asks answers instead" (expected 49.9,
read undefined), was a fault in the extractor: a JSON-LD offer counted as
the evidence that confirmed the same page's schema.org record, so a stale
$7.90 written for crawlers stood against the $49.90 the page priced from,
and nothing was written. A JSON-LD offer no longer vouches for markup.
NOT_RUN is empty, so CI's known-failing step has nothing to run.
Against a live deployment, with the request the interpreter produces for "black oversized hoodie under $80":
./scripts/verify-search.sh https://ai-clothes-application.vercel.app/api/search \
https://lxmafromnyc.github.io
It checks that every product the browser receives has a title, an absolute image URL, a real price, a named retailer and a direct retailer product URL, that none links to Google or another aggregator, and that none is over the stated budget.
Or call it by hand:
curl -s -X POST https://ai-clothes-application.vercel.app/api/search \
-H 'Content-Type: application/json' \
--data '{"intent":{"categories":["hoodie"],"colors":["black"],"fits":["oversized"],"maxPrice":80},"limit":6}'
What the reply tells you:
| Response | Meaning |
|---|---|
"source":"openwebninja" with a populated products array | Live. Each entry carries a real title, price, image URL, retailer and product-page URL. |
"source":"openwebninja", products empty, rejected populated | The source answered but nothing passed the gate. rejected names the fault for each dropped record; run the probe above to compare with the live field names. |
503 "No product source is configured." | PRODUCT_SOURCE is unset, or names a provider whose credentials are missing. Environment variables only apply to deployments built after they were added, so a redeploy is usually what is missing. |
502 | The provider was reached but failed. The reason is in the function logs, never in the response. |
api/_providers/<name>.js exporting { name, configured, search }:module.exports = {
name: 'example',
defaultRetailer: 'Example Store',
configured: () => Boolean(process.env.EXAMPLE_API_KEY),
async search(intent, { limit }) {
// intent is the interpreter's output:
// categories, colors, occasions, fits, brands, styles,
// maxPrice, minPrice, season, gender, keywords
// return raw records; field names are mapped for you
}
};
PROVIDERS in product-source.js.PRODUCT_SOURCE=example and the provider's credentials in Vercel.That is the whole integration. The frontend, the schema and the rendering do not change.
Every record must arrive from the source with all five of:
title price imageUrl productUrl retailer
A record missing any one is dropped, never filled in.
brand is optional. A cross-retailer source often carries no separate brand
for a listing, and a hoodie is still a real hoodie without one — so a brandless
record passes, and the card simply shows no brand line. What is never done is
putting something else there: the retailer's name in the brand field would be
a fabricated attribution.
productUrl must address a specific product page on the retailer's own site.
Three kinds of link are rejected:
| Rejected | Reason |
|---|---|
https://www.uniqlo.com/, https://shop.com/search | a homepage or listing is not the product the card claims to show |
https://www.google.com/shopping/product/… | an aggregator's comparison page is not a retailer's product page |
https://…/aclk?u=https://… | a redirector's destination cannot be read off the link |
Out-of-stock records are dropped; a source that says nothing about stock is not assumed to be out of stock. Prices must parse to a positive number. The response reports how many records were rejected and why, so a badly behaved provider is visible rather than silently thinning the results.
Records are shown in the order the source returned them, with the retailer named on the card. Fynd does not attach a match percentage to any result: it did not score the provider's records, and a made-up score would be inventing information about a real product. What the request was read as is said once, above the grid, rather than claimed again on every card.
Searching still needs no account. The Free allowance is metered per browser and the pages say what they always said. An account exists for one reason: a subscription has to belong to somebody, and a Stripe webhook arriving later has to be able to find them.
Two ways in, offered as one question with two answers:
| Continue with Google | A real OAuth 2.0 flow. Fynd never sees a Google password. |
| Continue with Email | Name, email and a password, confirmed by a link sent to that address. |
Both land on the same kind of account. Signing in with Google using an address that already has a password account signs you into that account and attaches Google to it — it does not create a second one, and it does not fork your subscription or your usage away from what you had.
fynd_session holds 32 random bytes and nothing else. It is not a signed claim
about who you are; it is a lookup key for a record the server keeps. That is
what makes four things possible that a stateless token cannot do:
sessionEpoch; raising it orphans all of them. A password reset does this,
so resetting signs out every device without needing a list of them.The store never holds the token: records live under session:<HMAC(token)>
keyed by AUTH_SECRET. A dump of the database is not a set of usable cookies.
The cookie is HttpOnly — no script on the page can read it, so no script can
leak it — Secure everywhere but plain-http localhost, and SameSite=Lax
same-origin or SameSite=None when the site and the functions are on different
hosts, decided per request so neither deployment has a setting to get wrong.
Nothing is kept in localStorage.
Two independent defences, because they fail differently.
The first is already there: api/_cors.js refuses a request whose Origin is
not on the allow-list, and it does that on the request itself rather than only
on the preflight — so a "simple" cross-site POST, the one shape that reaches a
server without a preflight, still gets a 403.
The second is a token. /api/account returns a csrfToken derived from the
session token, and state-changing authenticated requests must echo it in
X-Fynd-CSRF. It works because of what each side can see: the session cookie is
HttpOnly, so a page on another origin cannot read it and cannot compute the
token; and a cross-site form post cannot set a header at all.
/api/checkout refuses
it, with email-unverified, and the account page says why and offers to
send another.Tokens are 32 bytes from crypto.randomBytes, expire (a day for verification,
an hour for a reset), and are stored as token:<purpose>:<HMAC(token)> — so
the raw token exists only in the email and the URL the reader clicks. They are
never logged, never returned in an API response, and never put in an error.
Resending is rate limited on two axes: one every 60 seconds and five an hour, counted per account and per requesting address. One alone leaves the other open — per-account stops many machines flooding one mailbox, per-address stops one machine working through many mailboxes.
Why "unverified" has teeth. The receipt, the renewal notice and the failed-payment warning all go to that address. An account that cannot receive them should not be able to start a subscription, so the check is a concrete refusal rather than a flag nothing acts on.
scrypt from node's own crypto — no dependency — with a per-password salt, the parameters stored alongside the hash so they can be raised later without invalidating anyone, and a constant-time comparison. Plaintext is never stored, never logged and never leaves the request it arrived in.
Minimum ten characters, checked on the server. The page checks too, for a fast message, but the server is the one that decides.
Whether an address has an account.
Sign-up is the one place that cannot hide it — the address either gets an account or it does not — so it says so plainly and points at signing in, rather than pretending to have worked and leaving somebody waiting for an email that was never coming.
In the Google Cloud console, Create credentials → OAuth client ID → Web application.
Under Authorized redirect URIs, add exactly, with no query string:
https://<your-deployment>/api/google-callback
Google matches it character for character. That is also why the callback endpoint has no query string of its own.
Configure the OAuth consent screen. While it is in Testing, only accounts
you list as test users can sign in; publishing it opens it to everyone. The
scopes needed are only openid, email and profile.
Set the two variables and redeploy:
vercel env add GOOGLE_CLIENT_ID production
vercel env add GOOGLE_CLIENT_SECRET production
vercel --prod
With them unset, the account page does not offer the button at all and
/api/google-start declines rather than half-starting a flow.
What the flow actually does. /api/google-start mints a PKCE verifier, a
nonce and a state token, stores all three server-side, and redirects. The
browser carries only the state. /api/google-callback then, in order: spends
the state (single-use, so a replay finds nothing); checks the binding cookie, so
the callback must reach the browser that started; exchanges the code with Google
directly, server to server, with the client secret and the verifier that never
left the server; and verifies the returned ID token — RS256 signature against
Google's published keys, issuer, audience, azp, expiry and nonce — before
reading a single claim from it.
Nothing on that URL says who anybody is, and there is no branch that would read
it if it did. Google's email_verified is honoured rather than assumed: where
Google has not proved the address, Fynd does not treat it as proved, and it
cannot be used to take over an existing account.
Verification is not optional in the sense that matters — without it, nobody can confirm an address, and an unconfirmed account cannot subscribe. Pick one:
Resend — create an API key and verify a sending domain:
vercel env add RESEND_API_KEY production
vercel env add EMAIL_FROM production # e.g. Fynd <[email protected]>
Postmark — create a server and use its server token:
vercel env add POSTMARK_SERVER_TOKEN production
vercel env add EMAIL_FROM production
EMAIL_FROM must be on a domain the provider has verified. Providers refuse
anything else; that is the anti-spoofing rule working, not a setting to fight.
With no provider configured, /api/account reports
accounts.email.configured: false, sign-up returns
verification: { sent: false, reason: "no-provider" }, and the account page
says in those words that no confirmation link can be sent. Nothing claims an
email went out, nothing prints the link to a log — that would publish a
credential meant for one mailbox — and nobody becomes verified by the absence
of a provider.
Accounts, sessions, verification tokens and usage counters all live in the
store. Without KV_REST_API_URL / KV_REST_API_TOKEN (or the Upstash pair) it
falls back to memory, which means signing up and signing back in a minute later
can land on two different function instances and find no account.
That fallback is right for node scripts/… and local development and wrong for
everything else, so it is stated rather than hidden: /api/account reports
storage.durable: false and both the account and pricing pages say so on
screen.
node scripts/test-auth.js # offline: accounts, sessions, tokens, OAuth
node scripts/test-e2e.js # a real browser, driving the real handlers
test-auth.js runs with no key, no Google project and no mailbox. Google is
stubbed at the fetch boundary, but the verification is not: an RSA key pair
is generated in the test, its public half is served as a JWKS, and ID tokens are
signed with the private half — so a token signed by the wrong key, with
alg: none, for another audience, from another issuer, expired, or with a
mismatched nonce is refused by the real production path.
test-e2e.js drives Chromium against the real handlers over real HTTP: it
clicks Account in the navigation, chooses email, signs up, reads the
confirmation link out of the captured message and navigates to it, checks the
session survives a reload and a new tab, logs out, logs back in, runs a whole
Google sign-in through the redirect chain, and checks that a page which rewrites
its own state does not change what the server says.
AUTH_SECRET, generated not chosen.The foundation brand-specific size recommendations will be built on. It records what a shopper knows about how clothes fit them; nothing is recommended from it yet.
It lives on fit-profile.html, reached from the main navigation, the account
page, Compare, and the Search page's fit panel — the one place it is seen and
edited. Every part is optional, and a profile with one answer is a profile:
| Group | What it holds | Set on |
|---|---|---|
| Fit by type of clothing | For each type the shopper has answered — T-shirts, hoodies, sweatshirts, pants, sweatpants, jeans, something else (with what it is, in the shopper's words) — one of that type they own that fits perfectly (brand, size, and a length for jeans and pants sized by the waist), how they like that type to fit, and what usually gets its fit wrong. A reference point, not a measurement. | the fit profile page (and, until it was retired, the fit guide) |
| Earlier answers about a top | What the first guide (version 2) saved: a top that fits, how clothes should sit, and trouble spots. Kept as given, never read as any one type's answers. | shown on the fit profile page, which can remove them |
| Measurements | Height and chest (tops); waist and hip (bottoms). One unit for all of them: in or cm, chosen by the shopper. | the fit profile page |
| Usual sizes by brand | Brand, category (hoodies or sweatshirts), the size usually bought, and how it fits: too small, about right, too large. | the fit profile page |
| Preferred fit | Fitted, regular, relaxed or oversized — per category (hoodies, sweatshirts). | the fit profile page |
The fit profile page shows a card per answered type, built from that type's own brands, sizes and trouble spots, so nothing typed for one type can be chosen for another. A type can be added, edited or removed there; the guide's earlier answers about a top are shown as given, with a way to remove them.
assets/fit-profile-schema.js is the one definition. The page loads it to
check fields as they are typed; api/fit-profile.js runs every save through
its normalise() regardless. A stored profile looks like this:
{
"schemaVersion": 4,
"measurements": { "unit": "in", "height": 70, "chest": 40.5, "waist": null, "hip": null },
"brandSizes": [
{ "brand": "Uniqlo", "category": "hoodies", "size": "M", "fit": "about-right" },
{ "brand": "Champion", "category": "sweatshirts", "size": null, "fit": null }
],
"fitPreferences": { "hoodies": "relaxed" },
"anchor": { "brand": "UNIQLO", "size": "M" },
"fitGoal": "true-to-size",
"troubleZones": ["sleeves-short"],
"garments": {
"hoodies": { "anchor": { "brand": "Nike", "size": "L" }, "fitGoal": "oversized", "troubleZones": ["sleeves-short"], "line": "men" },
"jeans": { "anchor": { "brand": "Levi’s", "size": "32", "length": "30" }, "fitGoal": "true-to-size", "troubleZones": [], "line": null }
},
"createdAt": "2026-10-10T14:00:00.000Z",
"updatedAt": "2026-10-10T14:05:00.000Z"
}
Units are never guessed. A measurement is stored as entered, beside its unit. A value with no unit is refused rather than defaulted. Each range is stated per unit, and a value that only fits the other unit is refused with a message that says so ("102 looks like centimetres…") — it is never read as the other unit. On the page, switching the unit converts every value that was valid in the old one, leaves one that was not exactly as typed, and says which was which.
Plausible ranges, to one decimal place:
| Inches | Centimetres | |
|---|---|---|
| Height | 48–90 (4 ft 0 in – 7 ft 6 in) | 120–230 |
| Chest | 24–67 | 60–170 |
| Waist | 20–67 | 50–170 |
| Hip | 24–71 | 60–180 |
An unknown size stays unknown. A blank size, or "not sure", "unknown",
"?" and the like, is stored as null — never as a default. Letter sizes are
written in capitals (m → M); a brand's own label (38, M Tall) is kept as
typed in usual sizes by brand.
No brand twice. The same brand and category twice is refused, however the
brand is typed (H&M / h & m, Levi’s / Levis). The same brand in two
categories is two entries. Up to 50.
Nothing else is kept. No weight, no photos, no notes. A field the schema does not name is dropped, not stored.
Versioned. upgrade() reads any version it knows and returns the current
shape, filling what an older profile never had with "not said". A new version
adds a step to UPGRADES; a new type of clothing is a row in GARMENTS. A
profile written by a later version than the deployment knows is reported (409)
and never overwritten.
Version 2 added the first guide's answers — anchor, fitGoal and
troubleZones, about "a top", each null until answered.
Version 3 answers per type of clothing. garments is keyed by the type's
id (tshirts, hoodies, sweatshirts, , , ,
, shown as "Something else"); only ids are stored, and labels and
plurals live in the UI. Each entry is , each
until answered, and a type with all three unanswered is not kept.
also carries — what the garment is, in the shopper's words (up
to 40 characters), or — a label kept only beside an answer; any other
type sent a name is refused.
GET /api/fit-profile -> { profile | null, storage }
POST /api/fit-profile { action: "save", profile } -> { profile, storage, saved }
POST /api/fit-profile { action: "guide", answers } -> { profile, storage, saved }
POST /api/fit-profile { action: "delete" } -> { profile: null, storage, deleted }
guide saves one type at a time (the retired fit guide used it; Compare and older pages may still): { garments: { <type>: { anchor, fitGoal, troubleZones } } }, merged into that type's entry. Within the type, a field
left out — a skipped step — keeps its saved value; every other type,
measurements, usual sizes and per-category fits are carried over on the server
and never travel, so the guide cannot lose them. A type sent as null is
removed. A guide page loaded before version 3 sends { anchor, fitGoal, troubleZones } instead; those are still accepted, kept where version 2 kept
them, and no type is touched.
Signed out, every method answers 401. The account is always the one the session
cookie resolves to: no id, email or key in the query, headers or body is read, so
no request can name somebody else's profile. Saving and deleting carry the CSRF
token, like logout and checkout. A refused save answers 400 with one error per
field (measurements.chest, brandSizes.2.brand, garments.jeans.anchor.size,
…) and changes nothing. POST with an action rather than PUT/DELETE keeps it
inside the existing cross-origin rules.
The profile lives in the store the accounts already use, under
fitprofile:<user id> — no new database and no dependency. With Vercel KV or
Upstash configured it is durable; on the memory fallback it lasts as long as the
account does, and the page says so.
Responses carry Cache-Control: no-store, private. The profile appears in no
other response — /api/account is unchanged — and nothing logs a measurement,
brand, size or preference: a failure logs only the store's own message, which
names the command and never its arguments. Neither the fit profile page nor the
search's fit panel writes anything to browser storage, and no answer goes in a
URL: only the type being shopped for and the shopper's own search words do, to
open that type's card and come back. Deleting removes the record — every type's
answers included — straight away.
The fit guide asked four things, one at a time, before shopping. They are now
the questions on each type's card on fit-profile.html, in the guide's own
words, and no shopper has to step through them to shop:
Each card opens with what its answers can support
(FitProfileSchema.readiness): nothing yet; kept, but that type can't be
compared yet (jeans, pants, sweatpants, something else); usable, with what would
help (a reference, its measurements — the chest and two more —, the sizing line,
the fit); or ready to compare with the reference wherever Fynd has a product's
checked measurements. Answering every question is never called complete or
accurate. Every detail is shown in its own box, and every box is sent on save, so
clearing one clears it; a profile saved by the old guide opens with all of it
filled in, and saving it unchanged changes nothing (test-e2e).
The earlier per-category preferred fit (hoodies, sweatshirts) is shown only to a profile that holds one, labelled as earlier, so how a type should fit is asked in one place; the engine still reads a type's own answer first and that only where it has none. Nothing was migrated or dropped: the schema is unchanged (version 5).
Signed out, the page makes the account right there (create one, or sign in), so a shopper who came from a search keeps their place; Google sign-in is offered where set up, and says it ends on the account page. Compare shows the hoodie or sweatshirt reference read-only and links to that type's card to add, edit, replace or remove it.
The Search page has Shopping for — Anything, T-shirts, Hoodies, Sweatshirts,
Jeans, Pants, Sweatpants. A request that names a type ("black hoodie") chooses it;
one that names none gets the chosen type's word added; a type alone is searched
for itself (the home page's cards hand it over as find-clothes.html?shop=<type>,
taken out of the address once run). Two types named at once are no type.
Under the results, one panel (assets/fit-for-you.js) says how the profile was
used — after the results, so their arrival never moves:
fit-profile.html?for=<type>&from=search&q=<the search>. The card
is opened (added, empty, if there is none), and after saving the page offers
Back to your search, which runs it again. Only the type and the shopper's own
search words are ever in an address — never anything from the profile.GET /api/size-recommendation?fit=<type>&items=<listings> matches each
listing to a sizing record the way a favourite is matched (the brand's own link
with the product's code, or a name only one record answers to; never guessed),
and sizes a matched listing from that type's profile only: a comparison with the
reference where there is one (partial stays partial — the closest size, never a
recommendation), else the engine's suggestion, else what is missing. A matched
listing shows its size on its own card; the others get nothing, and the panel
says how many had no checked measurements. Jeans, pants and sweatpants are said
to be uncomparable, and are never sized from a hoodie.node scripts/test-fit-profile.js # offline: schema, units, versions 1→2→3, every type's sizes and trouble spots, one type's
# answers refused for another, the per-type merge, endpoint, cross-account access, logs
node scripts/test-e2e.js # shopping and the fit profile page against the real handlers: choosing clothing, the search's fit
# panel signed out and in, the prompt to the card and back, references added, replaced and removed,
# a guide-era profile shown whole, category isolation, "Not really", a 360px phone, units,
# save failure, deletion, cross-account access, browser storage; and from a second origin
node scripts/test-ui.js # the fit profile's cards and account step, and the search's fit panel, in the palette and legibility audits
fitprofile:<user id>.test-e2e saves, reloads and deletes
one from a second origin), but signing in there depends on third-party cookies,
which Safari, Brave and Incognito refuse — see
Cross-origin access.A size suggestion for one product, from the shopper's fit profile and the brand's own published sizing. It aims for the size closest to how the shopper likes that kind of garment to fit — not a letter-to-letter conversion — and says how sure it is from the quality of the data, not from its own score. It never says a size will fit.
Four of the six hoodie and sweatshirt records are verified. Each was checked against its official sources
and, with the owners' approval, marked
{ "status": "verified", "checkedBy": "claude-code-source-audit", "checkedAt": "2026-10-10" }.
UNIQLO's two (Sweat Pullover Hoodie E475378, Sweatshirt E475377) were marked the
same way and are now pending review (review.status: "pending"): that check was
an automated source audit, and no person has confirmed that the two products
really publish identical Product Size tables, as the Sweatshirt's own note asks.
Their review.needs says what the check is. Every record
keeps its official source URLs and the brand's own words for every
measurement. The engine still refuses to suggest from any record that is not
verified ("not enough data: unverified"), and the code never sets that status.
There is no product-card panel yet; this is the API and its evaluation.
Four T-shirt records are in, unverified. Copied from each brand's own US page on 2026-10-14, one product each, and waiting for the same source check before the engine will use them:
| Record | What the brand publishes |
|---|---|
| UNIQLO SUPIMA® Cotton T-Shirt (E455365) | body chest ranges, and the garment: chest flat, shoulders, sleeve and length from the centre back |
| Nike Sportswear Premium Essentials (IH1121) | body chest ranges only (Nike's general men's tops chart) |
| H&M Regular Fit T-shirt (0685816) | body chest ranges only (H&M's general tops guide; its how-to-measure panel was not read) |
| Zara Basic Medium Weight T-shirt /02 (01887411) | the garment only: chest flat, back width, sleeve from the shoulder seam, front length |
Each record's notes list what needs checking (Zara's "arm circumference" values equal its sleeve lengths as shown, for one).
E471808's Product Size chart is in, pending review. UNIQLO's Sweat Oversized Pullover Hoodie (E471808, UNIQLO product ID 489933) has its own record:
…/products/E471808-000/00/size?sizeDisplayCode=005&measurementUnit=inch),
in inches, eight sizes XXS–3XL. It has four columns, each under its own name:
Length (back), Shoulder width, Chest, Sleeve length (center back). Values are
kept exactly as supplied ("25 1/4").capture.how: "supplied-transcription").verification.status: "unverified" with
review.status: "pending". The schema refuses a record that is both pending
and verified, and the engine refuses to compare it until it is checked.How each column was measured:
It has no body chart of its own. UNIQLO's general body chart is shown after it, labelled general.
How a record's values reached Fynd is part of the record (capture.how):
copied-from-page (every record before this one, and the default),
supplied-transcription, browser-extension or server-read. The chart,
the product page and the notes all say it in those words. None of them is a
check: only verification marks a record verified.
For a T-shirt, the shopper's favourite (from the fit profile) is read strongest first:
Two garment measurements are compared only when measured the same way; a flat
chest and a circumference are related by × 2 (estimated). A sleeve from the
centre back and one from the shoulder seam, or a length from the centre back and
from the top of the shoulder, are not compared, and the reasons say so —
UNIQLO against Zara compares chest and shoulders only. What makes the favourite
fit well counts double; on a favourite that is only close, what wasn't named
counts half, and confidence drops a step. With a favourite and no fit goal, a
brand's fit words ("Loose fit") don't move the size. A favourite T-shirt is never
read for a hoodie, nor a hoodie for a T-shirt. Brand fits are never rules: the
engine's code names no brand, and each record's fit words are that product's
own. Every answer carries anchor: where its evidence came from, how it was used
(garment, chest-range or not-used), and which measurements were and weren't
compared, and why.
Find Fit opens on a button for each brand it can browse
(GET /api/size-recommendation?brands=1), so no link has to be pasted
(assets/brand-browser.js). Choosing UNIQLO opens its products in a popup over
the page: a modal <dialog> (full screen on a phone) with a search box, the
filters the data supports, product cards and "Show more". Nothing is fetched
until the brand is chosen; the search, filters and paging are the server's
(GET /api/size-recommendation?brand=uniqlo&q=…&offset=…, api/_catalog/search.js),
so the page never holds the catalogue. Closing the popup (Escape, its close
button, or a click outside) returns focus to the brand, and reopening it keeps
the search as it was; a list that failed to load is asked for again. A card
opens the product's Fynd page (product.html?brand=uniqlo&item=…), whose Size
Guide asks for that product's chart by brand and product code
(GET /api/size-recommendation?brand=uniqlo&item=E475378-000) and links to the
product on UNIQLO.
What the popup lists is whatever the brand module holds: today the five UNIQLO US products Fynd sourced itself (said so at the top of the popup), and, once an authorized catalogue feed is synced (see UNIQLO's authorized catalogue below), that catalogue — the same popup, paged, with the feed's photos, prices, colours, sizes and stock where its licence covers them.
The server finds the product's address in its own brand module
(api/_sizing/brands) and looks it up through the same pipeline a pasted link
takes (api/_sizing/chart.js):
Every product, the same way. Whichever way a product is chosen, its chart
comes from this one pipeline (chartFor in api/_sizing/chart.js), and its
tables are recognised by one shared reader (assets/sizing-tables.js):
| How the product is chosen | Asks |
|---|---|
| A card in the UNIQLO browser (Find Fit) | ?brand=uniqlo&item=<code> → the product's address → chartFor |
Size Guide on a product's page (product.html) | the same ?brand=&item= lookup |
| A pasted or typed product link (Find Fit) | ?chart=<link> → chartFor |
| "View sizing chart" on a search result | ?chart=<link> → chartFor |
| The browser extension | reads the open page with the same shared reader; with no chart there, #fynd-url= asks chartFor |
No product's values live in this code: a chart belongs to the product whose page or record it was read from.
No address the browser sends is used, and a code that isn't listed is refused
before anything is fetched. The listing and lookup modes are public and
read-only. They share the sizing:chart rate limit and never read a profile.
UNIQLO is the first and only brand so far. The list holds every UNIQLO US product Fynd can source:
What Fynd holds already (always listed, first):
| Product | Code | What Fynd holds for its sizing |
|---|---|---|
| Sweat Pullover Hoodie | E475378-000 | its own body and garment charts, pending review (not yet checked) |
| Sweatshirt | E475377-000 | its own body and garment charts, pending review (not yet checked) |
| SUPIMA Cotton T-Shirt | E455365-000 | its own charts, not yet checked |
| Sweat Oversized Pullover Hoodie | E471808-000 | its own Product Size chart, supplied as a transcription and pending review, with UNIQLO's general US men's tops body chart beside it (below) |
| Extra Fine Merino Crew Neck Sweater | E429066-000 | a listing only; its chart is looked up live |
A catalogue snapshot — retired. Fynd used to be able to read UNIQLO's US
category pages into api/_sizing/brands/uniqlo-catalog.json
(scripts/fetch-uniqlo-catalog.js); that reader and its workflow have been
removed, because Fynd doesn't build its catalogue by reading uniqlo.com
without UNIQLO's permission. uniqlo-snapshot.js now reads a snapshot only
from a test's own fixture (FYND_UNIQLO_CATALOG), never a committed file. In
tests, a snapshot row is dropped if its code doesn't match its own canonical
link, its category or line is unknown, or it wasn't listed on a uniqlo.com
category page; a men's sweatshirt, hoodie or T-shirt listed there may fall
back to UNIQLO's general men's body chart, labelled as general.
The list shows products with a saved chart first, 24 at a time (Show more).
It can be filtered by type and by who it's for, and searched by what the
shopper types — on the server, a page at a time (see
UNIQLO's authorized catalogue). Each card
says what Fynd holds, never that a chart was read. Photos come from UNIQLO's
image host only. All five products listed today have one
(uniqlo-photos.json: found by scripts/find-uniqlo-photos.js at addresses
carrying each product's code).
Where UNIQLO's product listings could come from, and what was found (18 October 2026):
.github/workflows/uniqlo-catalog.yml, runs
39081940293, 39082036637 and 39082168769), www.uniqlo.com accepted the
connection and then reset every request (HTTP/2 INTERNAL_ERROR):
robots.txt included, under curl's own user agent and Fynd's.
image.uniqlo.com answered.Find Fit is built to list UNIQLO's whole US range from an authorized catalogue — an API or product-feed file UNIQLO (or a licensed distributor) grants access to. None has been granted yet, so today Find Fit's UNIQLO popup lists the five products above and says, above the list, that coverage is limited:
Fynd's own list holds only 5 UNIQLO US products — it isn't connected to UNIQLO's full catalogue. For any other UNIQLO product, close this list and paste the product's link in Find Fit: Fynd shows its size chart where it holds one, and says so plainly when it can't.
Nothing here reads UNIQLO's website, and no catalogue — real or test — is committed. Fynd will not claim full-catalogue support until a real feed has been imported and validated.
Where an authorized feed could come from (looked into 20 October 2026, from public listings only — none of it verified from inside a network account):
| Route | What was found | What it would take |
|---|---|---|
| UNIQLO directly | No public product API, feed or data-licensing programme for the US site. | Written permission from UNIQLO USA naming the feed, the fields Fynd may show, refresh and retention terms. |
| Rakuten Advertising | Third-party directories list a "UNIQLO USA" programme on Rakuten. | A Rakuten publisher account, approval by UNIQLO's programme, then the programme's product-feed access (if it offers one) and its terms. |
| FlexOffers | FlexOffers' own listing shows the UNIQLO USA programme as Deactivated (last updated 31 July 2026). | Not available unless reactivated. |
| LinkConnector | A directory says the UNIQLO campaign's links come "from the product feed or link manager". | A LinkConnector publisher account and approval; then confirm the feed's coverage and terms. |
| Third-party scraping APIs (e.g. storefront scrapers) | Exist. | Not used: unauthorized. |
A daily-refreshed UNIQLO feed is mentioned only by secondary sources. Whether any of these feeds covers the full US catalogue, carries online availability, or may be stored and indexed by Fynd can only be read from the network's own feed documentation and programme terms once approved. Until then the questions in "Connecting a real feed" below are unanswered, and nothing is connected.
The pieces (api/_catalog/), each knowing nothing of the UI:
| File | Does |
|---|---|
sources/uniqlo-us.js | Everything UNIQLO-specific: its ID format, its product-page and image hosts, how its category and gender words map to Fynd's, the hosts a feed may be fetched from (FEED_HOSTS, empty), the fields its licence covers (LICENSED, empty), how old a catalogue may be |
product.js | The one product shape, and the checks every source's rows pass: plain text only, https on the brand's hosts only, the product's own ID in its own link |
import.js | Reads a feed: the Fynd catalogue feed (JSON) or product-feed variant rows (JSON array or JSON Lines), row by row |
sync.js | Merges a feed with the last catalogue: adds, updates, removes with a reason, or refuses |
fetch.js | Fetches a feed from an authorized API |
store.js | Loads the committed catalogue and checks every product again |
search.js | The search index, filters and pages |
Feed formats. Either:
{ "format": "fynd-catalog-feed", "version": 1, "source": { "name", "licence", "licensedFields" }, "generatedAt", "complete", "products": [...] }, one entry per product: id, name,
url, category (the source's own path, e.g. Men > Tops > T-Shirts),
gender, status (active / discontinued), and when licensed images,
colours (code, name, image), sizes, price, wasPrice,
availability, sizeAvailability, variants (colour, size,
availability).id, item_group_id (the product's exact ID), title,
description, link, image_link, additional_image_link,
product_type, gender, age_group, color, color_code, size,
price ("39.90 USD"), sale_price, availability. As a JSON array, JSON
Lines, or a tab- or comma-separated file with a header row (quoted fields
may hold the delimiter, quotes and line breaks). They carry no header
saying what they cover, so --complete and --generated-at say what the
file can't.A real feed in another shape needs only a new reader in import.js mapping it
onto the same fields; nothing after the import changes.
What is kept. The ID, name, canonical link (rebuilt as
https://www.uniqlo.com/us/en/products/<ID>/00, and only from a link to that
product's own page), category and line, always. Photos, colours, sizes,
price, stock and a description only when the feed supplies them and the
licence covers them:
the licence is what the operator records (UNIQLO_FEED_LICENSED_FIELDS or
--licensed), narrowed by what the feed itself declares. A catalogue never
holds garment measurements.
Every row is checked, never repaired. A row is rejected, with its reason,
for an ID not in UNIQLO's exact format; a name with markup, control
characters or over 160 characters; a link off www.uniqlo.com, over http, with
credentials, a port or a fragment, or to another product's ID; an unmappable
category or line. A photo off image.uniqlo.com, or of another product, and a
price that isn't a plain amount in dollars, are dropped and the product kept.
A repeated ID keeps its first row. The feed is parsed as data only (no key that
could reach a prototype survives), within caps: 64 MB, 50,000 products,
400,000 rows.
Syncing. node scripts/sync-catalog.js --source uniqlo-us --file <feed> (or
--api) writes api/_catalog/data/uniqlo-us.json and a status file; a person
reads the report and the diff and commits it. A sync:
removed, never served;lastSuccessfulSync only on success;api/_catalog/data/uniqlo-us.status.json on every attempt: when,
whether it succeeded and why not, the last successful sync, the feed's rows
read / rejected / duplicated with the first rejected rows and their reasons,
and — on success — coverage: products by category and line, and how many
carry photos, colours, sizes, a price, stock and a description. A thin feed
shows up there; nothing assumes it is complete.It refuses, leaving the catalogue byte for byte as it was and recording the
failed attempt, for: a feed with no valid products; more than 10% of rows
rejected (a format change); a complete feed removing over half the catalogue
(a truncated download — --allow-mass-removal when it is real); a feed older
than the one already imported; a fixture.
Fetching from an API. Only over https, only to a host listed in
FEED_HOSTS (added in a reviewed change once an agreement names it), with no
redirect followed. The credential (UNIQLO_FEED_TOKEN) is sent only as a
bearer header to that host and is never printed, logged or written. A timeout,
a size cap read as the body streams, and any answer but a 200 with JSON are
plain failures. .github/workflows/catalog-sync.yml runs it from repository
secrets, by hand or — only once the repository variable UNIQLO_FEED_SCHEDULE
is enabled, which it isn't — daily, and uploads the result; it commits
nothing, so what customers see changes only when a person commits a sync.
Serving. The deployed functions read only the committed file, and check every product again as it loads. A file marked as a fixture is refused there. Its age is asked on every request: prices and stock are shown for 48 hours after the last successful sync, the catalogue itself for 30 days. After that Find Fit falls back to the five products and says the catalogue is out of date. The coverage line names the source, the count and the date — never "full".
Searching. GET /api/size-recommendation?brand=uniqlo&q=black+oversized+hoodie
[&category=][&line=][&colour=][&size=][&inStock=1][&offset=][&limit=] answers
one page (24, at most 48) with the total and the filters' choices, from an
index built once. Every word must match the product's name, category, line, a
colour name, its description or its ID — names ranking first, the word being
typed as a prefix —
with plurals and shop words folded (tee → T-shirt, hoody → hoodie). When
nothing matches every word, the closest are shown and the words nothing
matched are named, so "black" is never quietly ignored. The page asks once
typing pauses and draws only the newest answer.
Filters are offered only where the data has them:
| Filter | From | Without a catalogue |
|---|---|---|
| Type | the category the adapter mapped | the five products' types |
| Men / Women / Kids | the source's gender and age words; unisex counts as both | Men / Women |
| Colour | the source's colour names, grouped into families (black, white, gray, navy, blue, green, beige, brown, red, pink, purple, yellow, orange); a name no family matches is found by search only, never guessed | none — Fynd holds no colour names |
| Size | the source's size labels, or Fynd's records' sizes | the four records' sizes |
| In stock only | the source's stock — per colour and size together where it gives stock per variant — only while current (48 hours) | none |
Sizing, by exact identity. A catalogue product is matched to Fynd's sizing by its exact code and canonical link only. One Fynd holds a record for keeps that record's name, sizes, fit and chart, and gains the catalogue's price and colours. Any other gets the same pipeline as a pasted link: its live page, or UNIQLO's general body chart when the catalogue places it in that chart's category and line (a men's hoodie, sweatshirt or T-shirt), labelled as general and never compared; otherwise an honest "unavailable".
Until a feed is approved: Fynd's list, and a pasted link. Find Fit lists only the products Fynd sourced itself (five UNIQLO US products, with the sizing records Fynd holds for them), says so in its notice, and offers one other way in: paste a product's link at the bottom of the box. There is no live UNIQLO search. One existed, through OpenWeb Ninja's Real-Time Product Search; it was removed when that subscription lapsed, rather than left as a box that would only ever answer with an error. The box above Fynd's list filters that list only, on the server, and asks no product source.
A pasted link goes through chartFor (api/_sizing/chart.js) and needs no
product source and no paid service: Fynd's record by the link's exact product
code, else the product's own page read once on request (and, from it, up to two
size-guide pages it links to) where that site permits it (below), else the
brand's official general body chart where Fynd holds one, labelled as general —
else an honest "unavailable". No retailer is permitted today, so a pasted link
is answered from Fynd's records and official charts only: the five UNIQLO
products answer from their records (four) or say no chart is available
(E429066, a listing without one), and any other shop's link says Fynd doesn't
read that retailer's pages, with the link to open it.
Which pages may be read (api/_sizing/page-access.js). A page is fetched
only when both hold, for the page, every redirect and every size guide:
DOMAINS with the clause (or its absence), the URL and the date. A domain
that isn't listed hasn't been reviewed and is treated as not permitted —
nothing is fetched, robots.txt included. The list is empty: a first
review of the 28 retailer domains Fynd links to (its five sizing-record
brands and the demo catalogue's shops) couldn't retrieve a single terms page
or robots.txt from the environment it ran in, so permission isn't
established for any of them, and the common retail clause forbidding
"robots, spiders, scrapers or other automated means" would rule most out.FyndSizing, else *;
longest match wins, Allow on a tie. A 4xx means the site has none; a 5xx,
429, timeout or unreadable file means nothing is fetched.robots.txt is never taken as permission on its own: it can only narrow what a
reviewed domain allows. To permit a domain, record its review in DOMAINS and
add a test to scripts/test-page-access.js.
A broader UNIQLO US list needs an authorized catalogue feed (below). Nothing here falls back to another paid search or reads UNIQLO's site to build a list.
The feed it is built for: UNIQLO USA's Product Catalog on Rakuten
Advertising. UNIQLO USA runs its affiliate programme on Rakuten
Advertising, and Rakuten delivers an advertiser's whole catalogue to
approved publishers as a file — every product, with its page, photo, retail
and sale price, availability and, for clothing (Class 60), its colour, size,
gender and age — deposited daily over SFTP. Fynd reads that file as it is
(api/_catalog/rakuten.js, to Rakuten's Product Catalog Data Feed
Implementation Guidelines for Publishers, December 2023):
HDR|MID|name|time, one product variant per line (38 pipe-delimited
fields; 39 in a delta file), TRL|count. A file whose trailer doesn't
match its lines, or that has no trailer, is refused whole; a file from
another advertiser than RAKUTEN_UNIQLO_MID is refused.murl, and only a UNIQLO US page with the product's code counts.
The tracking link is kept (affiliateUrl) only when it leads to that same
page, and "View on UNIQLO" uses it.Measured on a FIXTURE file at catalogue scale (scripts/test-catalog-rakuten.js:
2,400 products, 24,000 variant lines): the catalogue file is about 6 MB and a
cold start loads, checks and indexes it in about a quarter of a second. A real
UNIQLO file's size isn't known until one is delivered; if it is many times
that, the catalogue should move from a committed file to the KV store.
Connecting it, once Rakuten and UNIQLO USA have approved Fynd's access to UNIQLO's Product Catalog:
RAKUTEN_SFTP_USER,
RAKUTEN_SFTP_PASSWORD; variables RAKUTEN_UNIQLO_MID (UNIQLO USA's
advertiser ID), RAKUTEN_SID (Fynd's publisher channel ID),
RAKUTEN_SFTP_HOSTKEY_SHA256 (the server's host key, from Rakuten — never
taken on first use) and UNIQLO_FEED_LICENSED_FIELDS.MID_SID_mp.txt.gz,
syncs it, measures coverage (scripts/catalog-coverage.js) and uploads the
catalogue and report.UNIQLO_FEED_AUTOPR to enabled (each sync that changes anything opens or
updates a pull request) and, if the terms allow a daily pull,
UNIQLO_FEED_SCHEDULE to enabled. Prices and stock stop showing 48 hours
after the last merged sync.Any other feed (an HTTPS API in the Fynd or Merchant formats): add its host
to FEED_HOSTS, set UNIQLO_FEED_URL / UNIQLO_FEED_TOKEN, and run
node scripts/sync-catalog.js --source uniqlo-us --api --dry-run first.
Testing it: node scripts/test-catalog-rakuten.js (the Rakuten reader,
a 2,400-product sync, a removing full file, a delta, refused files, coverage,
the catalogue API, a product's page and chart, and the popup in Chromium),
node scripts/test-catalog-feed.js (import of the Fynd feed,
JSON/JSON Lines rows and a tab-separated Merchant-format file, duplicates,
URLs, licensing, descriptions, sync and stale removal, the status record, the
API fetch, the command, loading, search, colour/size/stock filters, pages,
staleness, product selection, sizing and verification status) and
node scripts/test-catalog-browser.js (Find Fit and the product page in
Chromium). They use FIXTURE feeds (in scripts/fixtures/catalog-feed/, or
written by the test); every code from E910001 or E960000 and every "FIXTURE"
name is invented. They prove the
importer and the page work; they say nothing about any real feed's coverage.
Clicking a product card opens its own page,
product.html?brand=uniqlo&item=E475378-000 (assets/product-page.js). It is
laid out the way a shop lays out a product, in the order a shopper chooses: a
large photo with thumbnails, brand and name, colour swatches, then Choose your
size — large size buttons (the chosen one filled, ringed and ticked, and named
in the heading) with View size measurements directly under them, saying what
Fynd holds before it is pressed — then View on UNIQLO. Everything else sits
in closed sections: Description & features, Materials & care, Fit & sizing, and
Source details (product ID, record date, where sizes, colours and photos come
from, and what Fynd doesn't hold). On a phone the photo is shorter and the
colours scroll in one row, so the name, colours and sizes are on the first
screen.
Everything on it comes from GET /api/size-recommendation?brand=uniqlo&detail=<code>
(the brand module's detail(code)), and each fact carries its source:
image.uniqlo.com, at addresses
carrying the product's code. A colour is one UNIQLO has photographed
(uniqlo-images.json, found by scripts/find-uniqlo-images.js). It is
named by UNIQLO's two-digit colour code ("Colour 09"), never a guessed name,
and is not claimed to be on sale.View size measurements opens the product's real chart in a dialog, using the
same lookup as everywhere else (?brand=&item=) and the same renderer
(assets/size-chart.js, in its product-page form: render(chart, url, { garment })),
so its tables, units and sources are Find Fit's own. The dialog is a place to
choose a size:
assets/fit-compare.js (shared with Find Fit) adds what a
saved fit profile can say. Only a checked finished-garment chart is compared,
and then FitCompare.focus(container, size) also shows the size being
considered beside the reference: for each of chest, body length, shoulders and
sleeves, yours, this size's and the difference — the engine's own
per-size figures (engine.compare(...).sizes), in the comparison's unit,
following each new choice without asking again. A dimension measured
differently, or not measured, shows this size's value and "Not compared", never
a difference; the engine's caveat says why, once. On a phone each dimension
reads as a heading over its three labelled values. A body or general chart is
never compared; it says why.The chosen colour and size stay in the address (&colour=09&size=M), so they
survive closing the dialog and reloading the page.
Adding a brand: write a module beside api/_sizing/brands/uniqlo.js with
id, name, market, coverage, products() and find(code), and add it to
BRANDS in api/_sizing/brands/index.js. Find Fit needs no change.
A link from any other shop can still be pasted under Have a link to a product
from another shop? (closed by default). The Fynd browser extension's
#fynd-url= uses the same box.
Find Fit (index.html, assets/find-fit.js) can also take a clothing
product's link, and its sizing chart is drawn below it (assets/size-chart.js,
which the Search page's View sizing chart dialog uses too). A link is checked
on the page first (a whole http(s) address with a host name); a pasted link starts
the lookup by itself, a typed one waits for Enter or Find sizing chart — no
request is made per keystroke. The result is headed by the product's brand and
name, with a View product link to its page (the server's canonical address,
without tracking parameters). In view: each chart, what it measures (body or
finished garment), its unit, and one line on where it is from — the product's
own chart, a retailer's size guide, or the brand's general guide. When there is
no chart, a short status and reason. Everything else — the server's notes, the
market, scope and verification status, every source link and the request trace —
is under Source details, closed by default.
No account or fit profile is needed: GET /api/size-recommendation?chart=<link>
reads no profile, and only the link travels. It is rate limited per client
(sizing:chart, 40 per ten minutes).
api/_sizing/chart.js is the one place a chart is worked out:
The address. http or https; no credentials; no port but the default; a
host name, never an address literal, localhost, .local/.internal names or
metadata.google.internal. The connection itself uses a DNS lookup that refuses
any non-public address (private, loopback, link-local and cloud metadata,
carrier-grade NAT, multicast, reserved, and their IPv6 forms), so a name can't
be pointed somewhere private between a check and the request. Tracking
parameters (utm_*, gclid, …) and fragments are dropped. On a brand's own
site Fynd has records for (UNIQLO, Zara, Nike…), the path names the product,
so every query parameter is dropped too: a pasted
…/products/E471808-000/00?colorDisplayCode=03&sizeDisplayCode=005&utm_… is
read as …/products/E471808-000/00. Other shops keep any non-tracking query,
since some name the product there (product.do?pid=…).
Fynd's own record of exactly that product, when it holds the garment's own measurements, is used as it stands, and nothing is read: body chart and finished-garment chart kept apart, every value in the brand's words and units, its sources, and its own verification status. A record matches only:
schema.codeIn): K121 is not K1210,
E475378 is not E4753780. Zara's code follows a "p" and H&M's carries a
three-digit colour after it;A record that holds only the brand's body chart (Nike, Carhartt, H&M today) doesn't stop the search. Where the brand's site permits it, the page is still read for the garment's own measurements; if it has them, they lead and the record's body chart follows in place of the page's own. If not — or where the page isn't read — the record answers, saying the garment's own weren't found or weren't looked for.
The product page, only where page-access.js permits it (its terms
reviewed and allowing it, and its robots.txt allowing the path — see
Which pages may be read; no domain is permitted today), read once:
redirects followed by hand (at most three),
every new address checked again; HTML only; 3 MB after decompression (gzip,
deflate and Brotli are asked for, since large shop pages are slow
uncompressed). One 9-second budget covers the page and any guides it
links to, so a lookup can't outlast the serverless function; the shop must
start answering within 6 seconds of it. Its <table>s are read
deterministically (no model): a table counts only with a size column and at
least one measurement column (sideways tables are turned).
What a table measures — body, finished garment or not stated — and its
unit come only from the words around it ("in" as an ordinary word is not
inches), and only the words after the table before it: a "Body size" heading
over one table never speaks for the "Product size" table that follows (the
server's tablesOf and the extension's collect.js both stop there). In a
tabbed size guide (role="tabpanel"), the extension reads only the panel's own
words and the name of its tab (by aria-labelledby or the tab's
aria-controls): the tab list beside the panels names every tab, so it speaks
for none.
Every chart says where it is from (origin): fynd-record,
product-page, linked-size-guide, official-general, or, from the
extension, browser-page / browser-size-guide. Each also carries its own
verification. When one answer holds charts from different places, each table
says where it is from and whether it was checked, in place of one caveat for
all.
When the garment's own measurements weren't found, the answer says so
(productSize) with the date it looked:
not-retrieved: the page couldn't be read;not-found: the page, and any guide it links to, had none;not-in-browser: the table the extension read wasn't the garment's.The chart drawing says it first, above any body chart: "Product measurements of this hoodie: none found on this product's page…". Nothing is estimated from a body chart.
A garment claim must agree with the table's columns
(assets/sizing-tables.js, kindOf):
Each kind of garment keeps the columns its page publishes: a hoodie's chest, length, shoulders and sleeve; jeans' waist, hip, rise, thigh, leg opening and inseam; a dress's bust, waist and length. Nothing is forced into four columns.
How each column was measured, as the page says it. A garment table read from a page keeps the method words its own headings carry ("Shoulder width (seam to seam)" → "seam to seam"; "center back"; "armpit to armpit"; "HPS"). A table that says it was measured "laid flat" gives that to its width columns (chest, waist, hip, thigh, hem), marked as said of the whole table. These are shown "in the page's words" and never mapped onto the engine's methods, so they can't start a comparison. A body chart has none. A column some sizes leave empty is named under the table ("Not published for every size"), its cells shown as —.
Values are kept exactly as written and nothing is converted; a missing value stays missing, and a chest-only chart stays chest-only. A chart read this way is not verified and never compared: reading is not checking. Only a verified record's finished-garment chart is compared with a fit profile (step 2), by the engine, measurement by measurement and only where both were measured the same way.
From a chart read to a record: scripts/draft-sizing-record.js turns the
tables Fynd actually read into a draft record in the schema's shape. It reads
from:
--url: the same lookup as Find Fit, so only from a machine
the retailer answers);--payload: the #fynd-chart= value);--chart).The draft keeps:
pageSays).method and methodSource are left empty for a person to set from the brand's
own words. Columns the schema has no dimension for yet (a waist, an inseam) are
kept under review.unmapped. The draft is always unverified with
review.status: "pending", and its remaining schema problems are printed as the
reviewer's checklist. Nothing is written into api/_sizing/records/, and a draft
is never loaded: records.js loads only the files it names.
The garment's own measurements come first. Every answer lists its charts
finished-garment first, then not stated, then body — from Fynd's records and
from a page alike (on a page with more than four tables, the garment's are the
ones kept). A chart from Fynd's record also says how each column was measured
(methods, in the engine's words: "flat, armpit to armpit", "from the centre
back", "seam to seam", "around the body"), and the chart drawing shows it under
the table, so a flat width is never read as a circumference.
An unavailable answer names which of these happened, and the heading says whether the page was read at all:
| What happened | reason | Heading |
|---|---|---|
| The shop never started answering | no-answer | We couldn't read the product page |
| It answered, but the page hadn't finished arriving | slow-page | We couldn't read the product page |
| Refused (401, 403, 429 — never worked around) or an error status | refused, http-error | We couldn't read the product page |
| Not HTML, too big, a bad redirect, unreachable, a private address | not-html, too-large, … | We couldn't read the product page |
| The page loaded; it links a size guide that couldn't be read | guide-unreadable | Page read — its size guide couldn't be |
| The page loaded; it is drawn by script and held no table | needs-script | Page read — no sizing chart found |
| The page loaded and held no table and no readable guide | no-chart | Page read — no sizing chart found |
A page that loaded is never reported as slow. When it loaded, what it said about
the product is still shown — its name and brand, and its fit and fabric where its
JSON-LD gives them (material, or an additionalProperty named fit, fabric or
composition) — marked as the page's words, with no measurements. Every answer
also carries trace: for each address asked, the address finally read, each
redirect, the status, content type, bytes, milliseconds, and how far it got
(connect, body, done). The page lists it under What Fynd tried, so a
failure on the live site can be read off the screen.
Each answer carries the product and brand (from the page's JSON-LD), the retailer
(its og:site_name, or host), the market (only when the address or the page's
og:locale names it), whether the chart is the product's own, published on its
page, or from its linked guide, its status, and a note built from those facts
alone. It never says how a product runs. Results are cached by address: six
hours for a chart, thirty minutes for a failure.
The fit profile, separately. A chart is not a recommendation. Only a chart
from a verified record with finished-garment measurements is compared with the
shopper's saved reference (signed in, engine.compare, as on Compare); a chart
read live can't be, because how each measurement was taken isn't known, and a
body chart can't be set beside a garment at all — Find Fit says which.
What has and hasn't been tried against real pages. The tests use invented HTML in the shapes product pages and size guides take, and Fynd's own records; the development environment had no outbound network, so this code has not yet read a single live retailer page. Many shops draw their size guides with script after the page loads, or refuse automated readers; for those the honest outcome is "unavailable". Which shops publish usable HTML tables has to be established against real pages before retailer-specific reading is added.
extension/ is a Manifest V3 Chrome extension. Fynd's toolbar button opens
the Fit panel beside the product page the customer has open (below, under
The Fit panel and Fit Anchors). Installation steps, permissions and tests
are in extension/README.md.
The page reading and the hand-over to Find Fit described here are shared by
the panel and by the earlier popup page (extension/popup.*, no longer on
the toolbar). In short:
activeTab), or on one site they chose to let Fynd read. The
extension asks for no site at install.assets/sizing-tables.js). It shows the chart with
Find Fit's own renderer.#fynd-chart=…, assets/fynd-payload.js). That part of the
address never reaches a server. Find Fit validates the payload (bounded text,
http(s) links only, rows as wide as their columns, a number in every row),
draws it, and clears it from the address.#fynd-url=… asks Find Fit to look the product up
as if its link had been pasted.This can show charts that Fynd's server can't fetch, such as a page that never answers the server, or a chart that is only shown once the customer opens the size guide. It doesn't get around a retailer's protections. Extraction has been tested on invented fixture pages only, not yet on live retailer pages.
The extension's side panel (extension/sidepanel.*) is the shopping tool:
extension/background.js).Where the code lives:
assets/fit-anchor.js (shared with the
extension as extension/vendor/fit-anchor.js).assets/fit-reference-data.js, generated from
api/_sizing/records by scripts/build-fit-references.js.Everything runs in the browser. References are kept in chrome.storage.local,
and the panel makes no network request while the shopper shops. The one path
to Fynd's server is the shopper pressing Open in Find Fit. That opens the
site, and for a product Fynd holds, Find Fit sends its address to
/api/size-recommendation?chart=. The panel says which, under the button. How to use it, what it reads, stores
and sends, the retailer modules, and how it is tested are in
extension/README.md. It sits beside the fit profile
and engine.compare() rather than replacing them: Find Fit and Compare are
unchanged.
A reference without a tape measure. Brand, kind and size label are
enough. estimateReference() takes the first of these that exists, and says
which:
oneProductSpread, because a stand-in isn't the
shopper's garment.minEaseSpread wide).Measuring methods are never pooled. Every estimate keeps the products and sources it rests on.
An anchor keeps four things apart:
method, source and verified flag. An estimate also has low and high.
Sources, by trust:
fynd-record: high when Fynd has checked it, medium when not;retailer-page (the product's own garment chart, read from the page):
high;shopper (entered or corrected): medium;similar-product (the same brand's other products): low;estimate (a body chart or other brands): low.preferred():
How a size is chosen (recommend()):
Read the chart.
Check it. Values that don't grow with size, outlying steps and duplicate sizes are flagged. A problem in the main dimension stops it being used.
Compare. For each size, each dimension's miss is how far it falls outside the preferred range, divided by its tolerance, plus:
repeatPenalty, when the size repeats the shopper's complaint.The misses are combined with weights for the category, the shopper's
priority, and reportedBoost on an axis they said was off.
Say how sure it is. Every reason the confidence isn't higher is shown.
Explain. Exact numbers only for published or measured values; "about", with the range, for estimates.
A body chart, sizes only, or no shared measurement give no size: the result
is body-chart, labels-only or insufficient, saying what is missing.
The constants are hypotheses. Every weight, tolerance and shift is in
HEURISTICS and none has been fitted to real outcomes. Fynd claims no
accuracy figure. Outcomes stay in the shopper's browser; an accuracy number
needs outcomes collected with consent and a documented evaluation, and neither
exists yet.
Tests:
scripts/test-fit-anchor.js: the model and the data.scripts/test-fit-panel-browser.js: the panel, headless, on fixtures.scripts/test-fit-panel-real-chrome.js: the whole workflow in a visible
Chrome, through real clicks on the toolbar button. Its --live mode is the
check against real retailer pages; none has been run yet, because the
environment it was built in could not reach them.compare.html (in the main navigation as Compare) walks a shopper through
four steps, each backed by the systems above rather than new ones:
garments.hoodies or
garments.sweatshirts: favorite and anchor), added, edited and removed on
that type's card on the fit profile page, which this page links to; here it is
read only. Measurements are stored exactly as entered, in their unit, and
converted once, for display. A
hoodie reference never touches the T-shirt one. Nothing needs measuring to look
around, but without measurements nothing is compared.GET /api/size-recommendation lists each record with
garmentDimensions (the finished-garment dimensions it publishes for every
size, and how each is measured), garmentOrigin, and comparable — true only
for a verified record with finished-garment measurements.GET /api/size-recommendation?product=<id>&compare=1[&unit=]
runs engine.compare() on the caller's own profile: every size beside the
reference, each dimension's signed difference in the reference's unit
("2 cm wider", "¾ in shorter"), the closest size, what wasn't compared and why,
how reliable it is, and the product's fabric, stretch and construction in the
brand's words ("unknown" where it says nothing) — context, never a numerical
adjustment.
What production can compare today. Nothing: the only records publishing finished-garment measurements are UNIQLO's two, pending review, and Zara's, unverified, and the engine compares none of them. Once UNIQLO's pair is checked: UNIQLO measures body length and sleeve from the centre back, while a reference is measured from the shoulder. So a home-measured reference against UNIQLO compares chest and shoulders only — partial, never a recommendation. A reference that is a UNIQLO product (found by its link) compares all four. Four Zara hoodies, measured exactly the way a reference is, are recorded and unverified pending an approved source check; until then they compare nothing. Nike and Carhartt publish body charts only and are never compared.
For hoodies and sweatshirts, a reference of brand and size alone keeps the reading
the evaluation set was worked out on in recommend() (that brand's record for the
size); T-shirts read it only as a chest range. Bringing hoodies to the stricter rule
means reviewing the evaluation set's hand-worked cases first.
api/_sizing/records/*.json, one per product, region and sizing line, validated
by api/_sizing/schema.js (sizingVersion: 2). Each record keeps body charts
(measures: "body": who a size is cut for) apart from finished-garment
measurements (measures: "finished-garment": what the piece itself measures).
Every dimension names its method from a fixed list — body-circumference for a
body chest; flat-width-armpit-to-armpit or garment-circumference for a garment
chest; center-back-to-hem, center-back-to-cuff, seam-to-seam and so on — and
the brand's own words for it (methodSource: URL and quote). A flat garment width
is never compared with a body circumference: where the engine needs the garment's
way around it takes twice the flat width, and says so as an estimate. A record
holding a flat width documents that derivation itself
(derivedCircumference: "estimated", factor 2, and why); the doubled value is
never stored. Who a product is sold to (product.audience) is kept apart from
what its chart is called (charts.body.title and designation): Nike's Club
pieces are men's products sized on Nike's "Gender Neutral (Men's Based) Size
Chart". Each record
also keeps the product name the brand displays, the page title, the garment type
(pullover-hoodie, crewneck-sweatshirt), region, units, the date it was read
(retrievedAt), and the exact text each value was copied from. A value the brand
does not publish is absent — never filled in.
| Product | Region · line · units | What the brand publishes |
|---|---|---|
| UNIQLO Sweat Pullover Hoodie (E475378) | US · unisex on men's sizes · in | Body chest ranges XXS–3XL; garment length (back), shoulder (seam to seam), chest (flat, armpit to armpit) and sleeve (centre back); "Regular" fit; fabric |
| UNIQLO Sweatshirt (E475377) | US · men · in | The same two charts, with values identical to the hoodie's as published; "Regular" fit; fabric |
| Nike Club Pullover Fleece Hoodie (FN3859) | US · men · in | Body chest ranges XXS–4XL from Nike's gender-neutral (men's based) chart; "Standard fit"; no garment measurements; fabric blend not stated |
| Nike Club Fleece Crew (FN3886) | US · men · in | The same chart and fit |
| Carhartt Loose Fit Midweight Hoodie (K121) | US · men · in | One body chest value per size, XS–4XL (not ranges); "Loose fit"; fabric by colour; no garment measurements |
| Carhartt Loose Fit Midweight Crewneck (K124) | US · men · in | The same chart and fit |
No brand states stretch; none publishes neck openings or hem widths; there is no
women's chart for any of the six. Each record's sources and notes say exactly
where every value came from and what was left out.
api/_sizing/engine.js. What the profile says becomes a range, never an exact
body: a chest measurement (± ½ in); a size the shopper says fits, in a brand there
is a record for (that size's chart range — an estimate); and, best of all, a garment
known to fit whose garment measurements are published. A letter size in a brand
without a chart is not used, and a letter size is only read at all once the
shopper has said which sizing line it is in (the card's optional Sized as:
Men's, Women's, Unisex or Not sure, stored per type as line).
Three methods, by what the product publishes:
Each size gets a cost: per dimension, a weight times the miss over a tolerance, squared; a trouble spot counts 2½ times in its direction (sleeves or torso shorter, chest tighter). Spots no chart speaks to (necklines, a loose waist) are said, not scored. Weights, tolerances, ease and margins are in one table at the top of the engine, per category (hoodies and sweatshirts share it for now).
The answer is a leading size, an alternative when the next size is close or the
brand's cut moved the aim, plain reasons, the ranking, and a confidence:
high only for a garment-to-garment comparison with the next size clearly
behind; medium for a measured chest against garment measurements or a body
chart; low when the shopper's chest is inferred from a chart, the evidence
disagrees, the fit goal had to be assumed, or two sizes are level. Every answer
lists what it used as verified, unverified, estimated or unknown. When the data
cannot support an answer it returns insufficient with what is missing and what
would help.
Beside the ranking, every answer carries fitBySize: for each size the product
sells, its chest, length, sleeves and shoulders, each on a five-step scale —
too tight, slightly tight, about right, slightly loose, too loose (for lengths:
too short … too long) — or no class and the reason why. fitScale says what the
classes are measured against, and tradeoffs sums up the leading size and the
alternative. A class is given only where the evidence reaches:
Basis (fitScale.basis) | When | What gets a class |
|---|---|---|
garment-vs-reference | the product's garment measurements, and a garment the shopper says fits | every dimension both publish by the same method, against that garment (about right within 1 in around the chest, ½ in in length and sleeve, ⅜ in at the shoulder; slightly within 3, 1½ and 1 in) |
garment-vs-body | the product's garment measurements, and the shopper's chest | the chest only: its ease (garment around minus chest) against the room the fit goal wants (slim 2–5, true to size 5–9, oversized 10–16 in; slightly = up to 2 in outside; no room at all is too tight). The chest is a range, so a size whose ease straddles two classes names both (couldBe), and one that spans more gets no class. Lengths and shoulders get none: the profile has no arm, torso or shoulder measurement |
body-chart | only the brand's body chart | nothing. Fynd reports where the shopper's chest falls in each size's range (chart.yourChest: below, inside, above); how much room a size has, and how long it is, can't be known without the product's finished measurements |
Each dimension also says what its class rests on (measurements): verified
(published values from verified records only, beside the shopper's own
measurement, taken as given), estimated (at least one value was worked out
rather than published — a circumference from a flat width, a chest from another
brand's chart — listed in estimated), or unknown (no class). So UNIQLO's chest
is always estimated, its lengths and shoulders against a garment that fits are
verified, and every dimension of a Nike or Carhartt size is unknown.
Every number in those bands is a hypothesis in one table at the top of the
engine (fitBands, beside ease), to be calibrated against real fit outcomes —
not a rule of tailoring. A circumference worked out from a flat width is listed
as an estimate; so is a chest inferred from another brand's chart. No record
states stretch, so none is applied. The ranking, the leading size and the
confidence are unchanged by this: they are still the scoring above, and a
leading size whose length, sleeves or shoulders can't be assessed says so in
its caveats.
GET /api/size-recommendation -> { products } what has sizing, and whether it is verified
GET /api/size-recommendation?product=<id> -> { recommendation } for the signed-in shopper
GET /api/size-recommendation?fit=<type>&items=<JSON> -> { fit } up to 24 search listings, matched and sized from that type only
GET /api/size-recommendation?chart=<link> -> { chart } a product's sizing chart from its link; no account needed (see Find Fit)
GET /api/fit-profile?part=feedback -> { feedback }
POST /api/fit-profile { action: "feedback-consent", consent: { store, improve } }
POST /api/fit-profile { action: "feedback-add", entry } -> { feedback, entry }
POST /api/fit-profile { action: "feedback-delete", id } | { action: "feedback-delete-all" }
Fit feedback is part of the fit profile endpoint, not one of its own: the
deployment's plan allows twelve functions, and test-pipeline holds it to that.
Both read only the signed-in caller's own data (401 signed out; no id in the
request is read), answer no-store, and log nothing about the profile, the
suggestion or the feedback. The suggestion is read-only; every feedback change
carries the CSRF token.
Fit feedback (fitfeedback:<user id>) records the product and size tried,
too small / right / too large overall, and tight / right / loose (or short /
right / long) for chest, shoulders, waist, length and sleeves — fixed choices only,
no free text. Nothing is kept without the shopper's consent to keep it; turning
that off deletes every entry; it is deleted with the fit profile. A second,
separate consent allows de-identified use to improve product data; nothing uses it
yet, and when something does it may read only counts per product and size, from
at least ten accounts. Feedback does not change suggestions yet.
scripts/fixtures/size-eval.json holds 23 cases — profiles against the six
products — each with the expected leading size (or the two a reasonable fitter
might pick), the allowed alternatives and confidence, and the reasoning from the
published chart. node scripts/test-size-eval.js runs them on the verified
records exactly as the endpoint does, and checks that with each record marked
unverified every case is "insufficient: unverified-data". Today: every case passes
(23 cases and the unverified check). Leading size is one the case
accepts in 18/18. Every accepted size is the leading size or the alternative in
16/18 — reported, not required: the two oversized UNIQLO cases accept L or XL and
are offered XL, then XXL, so L, though accepted, is not offered. (A case's
accepted sizes are listed smallest first, not by preference; the metric used to
read the first one as "the" expected size, which made the gap look like the
engine missing L rather than offering XXL.) Insufficient exactly when expected
23/23; confidence label allowed 18/18.
Its perSize section holds 7 more cases, each class worked out by hand from the
published numbers: several sizes of one product for one shopper; slim, true to
size and oversized for the same shopper and product; a garment known to fit
against every size; a chest only inferred from another brand's chart (where one
size must get no class); and body-chart-only products, Nike's ranges and
Carhartt's single values. 58 classes are checked, and every answer — in these
cases and the 23 above — is held to the same rules: every ranked size assessed,
a reason for every missing class, no class from a body chart, the doubled flat
width marked as an estimate. 31 checks pass in all.
What this shows, and what it does not. These are tests of code correctness: that the engine does what its documented method says with real published data. They are not evidence that the classes match how clothes feel — that needs real fit outcomes, and there are none in this set yet. Stretch against non-stretch, and bottoms (waist, hip, rise, thigh, inseam), can't be tested yet: no record states stretch, there are no bottoms records, and the profile holds no rise, thigh or inseam.
node scripts/test-sizing.js # records, engine rules, both endpoints: auth, CSRF, ownership, consent, logs
node scripts/test-size-eval.js # the evaluation set and its metrics
summarise() exists, but no job
gathers accounts' feedback yet (the store has no scan), and nothing acts on it.Fynd has three plans. Free needs no account and no card; Pro and Max are monthly subscriptions bought through Stripe Checkout.
| Plan | Price | AI tokens | Live product searches |
|---|---|---|---|
| Free | $0 | 20,000 per day | 3 per day |
| Pro | $14.99 / month | 1,000,000 per month | 100 per month |
| Max | $39.99 / month | 5,000,000 per month | 500 per month |
Free counts by UTC day and the paid plans by UTC calendar month, because that is how each is written. The period is part of the counter's key, so upgrading starts the monthly allowance at zero rather than inheriting a day's use — somebody who upgrades is buying the month, not the remainder of an afternoon.
The search box shows what is left before anything is typed: a thin bar of
what remains of the allowance, and beside it the count in words — "3 searches
left today", "97 searches left this month", "No live searches left today".
On a wide screen they share one row at the bottom of the box, which keeps it
compact; on a phone the bar is as wide as the Search button with the words
centred under it. The words are exactly usage.searches.remaining and
usage.searches.period from /api/account, for the account or, signed out,
for the browser, and the bar is remaining / limit from the same answer; the
page knows no plan's limit and subtracts nothing. An allowance of up to ten is
drawn one step a search, so Free's three read as three; a larger one is a
single smooth fill. Whatever is left is the accent blue at every count — the
shrinking fill is the signal — an empty allowance is the bare grey track, and
no count is ever shown in a warning colour. The bar is decoration
(aria-hidden); the words are what assistive technology reads.
The bar is a level, not an activity: it holds still, inset on its own track.
While a search runs it and the words give way to the progress hairline along
the box's edge; when the search is over the page reads /api/account again
and shows the server's new count, and a screen reader hears it once after the
results. If the account cannot be read, neither bar nor number is shown.
Fynd never sees a card. The shopper types their card on Stripe's own pages, and changing a card, switching plan, downloading an invoice and cancelling all happen in Stripe's billing portal. There is no billing screen in this repository, on purpose: a second place to type a card is a second place for one to leak, and a second copy of a subscription's state is a second thing to be wrong.
A plan is raised or lowered by a signed Stripe webhook, and by nothing else.
Not by the checkout endpoint, which only opens a session. Not by the page the shopper lands on afterwards. Not by any request a browser can make.
/api/account is the only endpoint the interface reads a plan from, and
it is GET-only — there is no write path to answer. The plan on a user
record is not a field anything assigns: api/_users.js recomputes it from
the stored subscription on every write, so even a call site that tried to
set one would get back the plan the subscription actually justifies.
That is what makes the success URL harmless. ?checkout=success is worth
one sentence on screen — "confirming your payment" — and the page then
re-reads /api/account until the server changes its mind. Loading that
URL by hand does the same thing and finds the same answer: the plan you
actually have.
Which Stripe statuses entitle anything is one list, in api/_plans.js:
| Status | Plan |
|---|---|
active, trialing | the plan the subscription's price maps to |
past_due, unpaid, incomplete, incomplete_expired, paused, canceled | Free |
past_due being Free is deliberate: a card that stopped working stops the
paid allowance rather than running it on credit. The status is kept and
shown, so the account page says why — "your last payment failed, update
your card" — instead of downgrading in silence. A subscription set to
cancel at period end stays active until the period ends, which is what
the shopper paid for; the plan drops when Stripe says canceled, not when
they press the button.
All of this in test mode first. The deployment reports which mode it is in, and the pricing page says so on screen.
1. Two products, two prices. In the Stripe dashboard, Products → Add product:
| Product | Price | Billing period | Currency |
|---|---|---|---|
| Fynd Pro | 14.99 | Monthly, recurring | USD |
| Fynd Max | 39.99 | Monthly, recurring | USD |
Copy each price's id — it starts price_, not prod_. The price id
is what Fynd needs; the product id is not used.
2. One webhook endpoint. Developers → Webhooks → Add endpoint:
https://your-app.vercel.app/api/stripe-webhook
Subscribe it to exactly these six events:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed
Then copy that endpoint's signing secret (whsec_…). It is on the
endpoint's own page and is not the API key.
3. Turn on the billing portal. Settings → Billing → Customer portal,
and save it once. Until that is done, "Manage subscription" answers 502 —
the endpoint says portal-not-configured when Stripe gives it that
reason.
4. A database. Accounts, subscriptions and usage counters have to
outlive a request. Attach Vercel KV to the project (Storage → Create →
KV) and Vercel sets KV_REST_API_URL and KV_REST_API_TOKEN itself; an
Upstash Redis database works too, through
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN.
Without one, the store falls back to memory. That is right for local
development and for the tests, and wrong for anything else: a
subscription somebody paid for would vanish when the function instance is
recycled. /api/account reports storage.durable: false and both billing
pages say so on screen.
5. The variables.
vercel env add STRIPE_SECRET_KEY production # sk_test_… for now
vercel env add STRIPE_WEBHOOK_SECRET production # whsec_…
vercel env add STRIPE_PRICE_PRO production # price_…
vercel env add STRIPE_PRICE_MAX production # price_…
vercel env add AUTH_SECRET production # 32 random bytes, hex
vercel --prod # redeploy so they take effect
Generate AUTH_SECRET rather than choosing it:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Environment variables apply to the next build, not the running deployment, so the redeploy is not optional.
6. Check it from outside.
./scripts/verify-billing.sh https://ai-clothes-application.vercel.app
It confirms an anonymous visitor is on Free with the right allowance,
that all three plans are priced and purchasable, that the deployment is
in test mode, that a webhook secret and a durable store are configured,
that /api/checkout refuses a caller with no account, and that
/api/stripe-webhook refuses both an unsigned delivery and a wrongly
signed one. It creates nothing at Stripe and charges nothing.
7. Buy something, with a test card. Sign up on /account.html, go to
/pricing.html, press Get Pro, and pay with 4242 4242 4242 4242,
any future expiry, any CVC. You should land back on the pricing page,
see "confirming your payment" for a second or two, and then see Pro.
Stripe's CLI is the fastest way to watch the other half:
stripe listen --forward-to localhost:3000/api/stripe-webhook
stripe trigger customer.subscription.deleted
the shopper presses "Get Pro"
|
v
POST /api/checkout plan: "pro" — a name, and nothing else.
| The price is looked up server-side from
| STRIPE_PRICE_PRO, so a page cannot
| re-price its own checkout.
v
Stripe Checkout the card is typed here, on Stripe's
| domain. Fynd never receives it.
v
the shopper lands on /pricing.html?checkout=success
| ...which grants nothing. It re-reads
| /api/account and waits.
|
meanwhile, server to server:
|
POST /api/stripe-webhook signature verified against the raw bytes
| with STRIPE_WEBHOOK_SECRET
v
the event id is claimed an atomic set-if-absent. A duplicate
| delivery loses the claim and stops.
v
the price id decides the plan the account is on
|
v
/api/account now says Pro, and /api/search and /api/interpret meter
against 100 searches and 1,000,000 tokens a month
Stripe delivers at least once and retries anything it did not hear a 2xx for, so the same event arrives twice often enough to plan for. Three separate mechanisms, because they fail in different ways:
scripts/test-stripe.js
fires exactly that.created can land after the deleted that followed it. Every stored
subscription carries the timestamp of the event it came from, and an
older one is dropped. A late created cannot resurrect a cancelled
plan.Fynd had no accounts before this, and the site's own copy says "no filters, no account". That is still true for searching. An account exists for one reason: a subscription has to belong to somebody a webhook can find later.
Set-Cookie. Neither is airtight and neither needs to be: metering the
free tier is about the API bill.email and password, hashed with scrypt from node's own
crypto, salted per password, compared in constant time. A wrong password
and an address with no account give the same answer in the same time.HttpOnly cookie. No script on the page
can read it, so no script can leak one. Cross-site it is
SameSite=None; Secure — which is what the Pages-to-Vercel deployment
needs — and SameSite=Lax when the site and the functions share an
origin.The Stripe customer id is stored against the user and indexed back to it, because a webhook knows a customer and nothing about Fynd. The customer is created before the checkout starts, so no event can arrive naming a customer nothing can resolve, and a customer is never moved to a different account.
stripe packageThis repository has no build step, no package.json and no
node_modules: the site is files, and node scripts/… runs the tests
with nothing installed. api/_stripe.js calls Stripe's REST API with the
same fetch already used for OpenAI and the product source, and verifies
webhook signatures with node:crypto.
What that costs is that the request encoding and the signature scheme are
written out rather than imported. Both are small and specified, and
scripts/test-stripe.js covers them — signing its webhook payloads with
crypto directly rather than with the module that verifies them, so a
bug that made both sides agree on the wrong scheme would still fail.
node scripts/test-stripe.js
Offline, with no key, no Stripe account and no network. It drives the
real endpoints with Stripe's HTTP API stubbed at the fetch boundary:
signature verification and its refusals, a Pro checkout, a Max checkout, a
cancelled checkout, cancellation, cancel-at-period-end, a failed payment
and its recovery, a plan change made in the portal, duplicate and
concurrent and out-of-order deliveries, the customer mapping, the account
endpoints, and every way a browser might try to grant itself a plan.
node scripts/test-ui.js covers the pricing and account pages in a real
browser, including that landing on ?checkout=success with the server
still saying Free leaves the page saying Free.
| What you see | What it is |
|---|---|
| The pricing page says the paid plans cannot be bought here | STRIPE_SECRET_KEY is not set on the running deployment. Variables apply to the next build — redeploy. |
Checkout answers 503 with no-price-id | STRIPE_PRICE_PRO or STRIPE_PRICE_MAX is missing. A plan with no price is never sold at some other price. |
| The checkout completes but the plan never changes | The webhook is not reaching Fynd, or is being refused. Check Developers → Webhooks in Stripe for the delivery and its response, then the function logs. |
The webhook answers 400 Raw body unavailable | Something parsed the body before the handler saw it, so the exact bytes Stripe signed are gone. api/stripe-webhook.js turns body parsing off with its config export; a host that ignores it needs its own equivalent. |
The webhook answers 400 Signature verification failed | STRIPE_WEBHOOK_SECRET does not match this endpoint. Test and live endpoints have separate secrets, and it is the endpoint's signing secret, not the API key. |
| The webhook answers 503 | STRIPE_WEBHOOK_SECRET is not set at all. |
"Manage subscription" answers 502 portal-not-configured | The customer portal has not been saved once in the Stripe dashboard, per mode. |
| A plan is right and then wrong again later | No durable store: state is in memory and the instance was recycled. verify-billing.sh reports this. |
Sign-in answers 503 no-auth-secret | AUTH_SECRET is missing or shorter than 16 characters. |
| Everything answers 403 | The Origin is not allowed — set ALLOWED_ORIGIN for a frontend on another host. See Cross-origin access. |
| From another origin, a signed-in action fails with "Request header field x-fynd-csrf is not allowed" in the console | The preflight answer is not listing the CSRF header. api/_cors.js sends it to every allowed origin; check nothing in front of the function (a proxy, a vercel.json header rule) is replacing . |
The function logs name which variables the running deployment can see —
states only, never values. See api/_env-report.js.
STRIPE_SECRET_KEY for the live key, and STRIPE_PRICE_PRO /
STRIPE_PRICE_MAX for prices created in live mode. Test-mode price ids
do not exist in live mode.verify-billing.sh will tell
you../scripts/verify-billing.sh against production. It fails on a
live key, which is what you want right up until the moment you do not.Every product, demo or real, is normalised by assets/products.js into one shape:
id, name, brand, price, productUrl, imageUrl,
category, styles, occasions, fits, colors, sizes
Feeds disagree about field names, so style/styles, color/colors,
url/productUrl, image/imageUrl and similar are all accepted; "$49.90"
parses to a number and "S,M,L" to a list. Records missing a name or brand are
dropped rather than throwing, and only http(s) links survive.
Swapping the demo catalogue for real data is one call:
Products.load(DEMO_PRODUCTS); // this repo's demo data
Products.load('/api/products.json'); // a URL returning JSON
Products.load(() => queryDatabase()); // a function or promise
The interface subscribes to the store, so the Search page's matching vocabulary rebuilds from whatever arrives. Unfamiliar colours and garment categories fall back to neutral artwork rather than breaking.
scripts/audit-catalog.js re-proves every field a card shows, for every row,
from the row itself. Run it on its own to see each row's verdict
(node scripts/audit-catalog.js):
| Field | Proved by |
|---|---|
| Photo | catalogRowIdentity in fetch-catalog-images.js: its imageEvidence, or the listing's code in the photo's own URL |
| Price | catalogRowPrice in fetch-catalog-prices.js: a linked row may carry a price only with priceEvidence |
| Name | identity.name: the title in the store's own product record, the product's own URL slug word for word, or the listing tools that verified the row |
| Brand | identity.brand: the store's photo of the product filed under the brand's name, the product's URL slug opening with it, or the verifying tools |
| Link | An http(s) listing whose URL names a product by code or handle |
A brand nothing proves is null. It is never guessed, and the store a listing
is on is not taken to be its maker. The card then names the store's address on
its top line, which is what the card already does for a source that names no
brand. A row whose name nothing ties to its listing keeps no identity note.
The sample- rows were drafted as invented products, with invented brands
like "Northfold" and "Halden" and demo prices. They were later tied to real
listings and photos by fetch-catalog-images.js --discover, which kept the
invented name, brand and price. They have since been corrected from what each
row's own evidence says. scripts/fixtures/catalog-as-drafted.js keeps the
drafted version, only so the discovery tool's tests can run against the rows
they were written around (via FYND_CATALOG). The shipped catalogue is never
written by those tests.
The Search page carries a short film directly under the search (#demo in
find-clothes.html; it was on the home page until fit took that
place, and scripts/record-demo.js records on and writes to the Search page,
named once as DEMO_PAGE): Fynd used for four genuinely different shopping problems, one
after another, with a calm narration, in about 55 seconds. The point it makes:
you know what you want but not where to find it — describe it, and Fynd finds
it. It steps aside as soon as a real search starts, because the results take
its place.
The film is cut from one real recorded session (below). Every request, result, photograph, price, retailer and retailer page in it is from that session; what the film adds is what an editor and a motion designer add — framing, timing, type, and how one moment becomes the next. Nothing in it that looks like Fynd or a store is drawn.
| Request typed | What the film does | Narration / caption | |
|---|---|---|---|
| Opening | a quiet field of the session's own product photographs drifts in depth; the real search field opens out of it and the homepage resolves around it | "Ever know exactly what you want, but not where to find it?" | |
| Everyday | "black oversized hoodie under $80" | the request is typed for real; its words lift out of the page as type (BLACK / OVERSIZED / HOODIE / UNDER $80) and release on Search; the real results rise into place; the chosen card lifts, becomes the subject with its brand, name and price, and its frame opens onto the store's own page | "With Fynd, you just describe it." · "It searches real stores, and brings back what matches." · "Pick one, and you're right at the store." |
| Another category | "lightweight jacket for fall under $150" | the hoodie grid stays, ghosted, under the real search field floating above it; on Search the grid re-forms as jackets | |
| Hard to find | "BAPE shark hoodie under $400" | close on the field; a dark stage opens from the Search button, BAPE; the real results rise out of it; the listing on black; its store | "Even the pieces that are hard to find." |
| A particular style | "sage green linen midi dress under $120" | softer: the request's attributes in quiet type, the results settling, the dress, its store | "Down to the exact color and fabric." |
| Close | the four requests as they were typed, then the four products they found, then the mark, then white | "Describe what you want. Fynd finds it." |
scripts/demo-film.js draws it, frame by frame, with a Skia canvas
(@napi-rs/canvas, Inter from @fontsource/inter) from the session footage
in assets/demo/footage/, and pipes the frames to ffmpeg. The camera is gentle
(pushes of a few per cent; the BAPE close-up is the strongest), moves are eased
on cubic-Bézier curves, and anything that moves is drawn with true motion blur:
several renders inside the frame's shutter, averaged. Mostly cuts and
dissolves; no glitches, whip pans or fake 3D. Holds — a moment kept on screen a
little longer so a word can be read — freeze a real frame; they never invent
one.
What it holds itself to, checked by node scripts/test-demo-film.js:
stores[k].until,
measured: the session logs leaving a few frames after the page has gone);
the recording report says each of those pages was verified on camera, with
no challenge or block page;Two notes on the phone film. The phone's Target page was still loading when it was filmed, so the phone film shows the same Target page from the same session's desktop recording. The Gap and Old Navy pages squeeze their product titles into a one-letter column at that width; the frame starts just right of it. Target's store-location bar (a delivery postcode) is kept out of frame in both films.
The moments the cut is built on — the last key, the frame Search was pressed,
the first live results, the click, each store's window — are measured from the
session and written in FORMATS at the top of the script, with the keystrokes
the sound follows in footage/*.keys.json (each frame where the typed text
grows). A new recording is a new session: copy its videos (video only) and
timelines into assets/demo/footage/ as *.session.mp4 / *.session.json,
measure those moments again, and cut it.
node scripts/demo-film.js # both films, finished, into assets/demo
node scripts/demo-film.js --only=mobile # one
node scripts/demo-film.js --stills=12,36.5 # PNG frames, to look at
node scripts/demo-film.js --master=DIR # the picture only, to review
node scripts/demo-film.js --from-master=DIR # finish from that picture
node scripts/test-demo-film.js # the checks above
The session the film is cut from is recorded by scripts/record-demo.js: each
search, the request is typed at a person's pace, the site's own "Searching…"
state, the real results, and one product opened at its retailer — the
retailer's own page, in the new tab the link opens. A different retailer each
time wherever the results allow.
Product sources have bad minutes — offer lookups aborted at the time budget, a
search that times out — and a page that comes back one product short for that
reason says nothing about the request. So each slot, in SEARCHES at the top of
scripts/record-demo.js, gets its answer in this order:
Every attempt is held to every check below; nothing is lowered along the way, and the minimum stays at four products. If no attempt passes, nothing is written, and the run lists every attempt and why it was not used.
Every run ends with a recording report, printed and written to
assets/demo/demo-report.json: for each search, what was meant, what was typed,
whether it needed a retry or an equivalent wording (and why), and the product
and retailer each recording opened, then the finished lengths.
The words are spoken and shown in a thin strip along the bottom edge, so the video reads the same with the sound off, and the same lines are its captions track. Under the narration, a quiet score written for the video (below); no overlays on the page, no drawn products.
scripts/record-demo.js serves this repository locally, points the page at the
repository's own /api/interpret and /api/search handlers, and runs them with
your .env — the same code and the same product source the live site uses. The
products in the video are the real listings those searches return, with their
real photographs, prices and retailer links.
The script refuses to record anything less. Before a frame is recorded it checks, in the page itself, for every search: that at least four products came back, that they are four different products and not one repeated, that every one is a real listing with a retailer link, and that every photograph actually loaded at product-photo size. A request that names a brand (BAPE, Levi's, Ralph Lauren, Burberry) is only used when at least four of its results are that brand, and only one of those is ever clicked. Each product's retailer page is opened off camera, under the same test the camera applies (below); a page that blocks automated browsers, errors, comes up blank or arrives too slowly is noted, and only products whose page did open are ever clicked. The same photo checks run again on camera.
The searches are made once, off camera, and saved to
assets/demo/demo-search.json, with any request that was passed over and why.
Both recordings — desktop and the phone layout — are made from those saved
answers, so they show the same products, and --replay re-records from them
without searching again. The "Searching…" state on camera is the page's own,
held to about a second and a half. Each search starts from an untouched
allowance in the in-memory usage store (KV settings are dropped from the
process), so the recording never touches production state.
Every click in the video reaches its retailer's page on camera. A retailer page is held to one test wherever it is checked — in the first pass, in a preflight just before each recording, and on camera: it arrives within 6 seconds, is not a block, challenge, error or blank page a moment later, and is still itself after being held for about two seconds. The preflight opens every candidate page again in a tab shaped like the recording's own (a phone's, for the phone layout), and only products that passed both the first pass and the preflight may be clicked, preferring a retailer not yet shown in the video. If a page still does not show when its product is clicked, that click is cut from the video, the shop is not tried again anywhere in the run, and another verified product from the same search is taken — the search itself is kept. If no product in a search reaches its page within three tries, nothing is written.
No retailer can stall a recording. Every step of a retailer check has a hard
limit — navigation, the page appearing, each read of the page, opening and
closing its tab — and so does each whole check (12 s) and each whole run of
checks. A page that runs out of time, or stops responding (a script holding its
main thread, say), is a failed retailer: excluded, and the next one is checked.
The preflight says so line by line: mobile preflight 3/16: checking shop.example, then mobile preflight 3/16: timed out — excluded: shop.example (…) or passed — shop.example. The video shows the retailer's
tab for 1.8 seconds once the page has arrived; the wait for it to arrive is cut,
and the page is still checked at the end of the full 2.1-second hold, after the
video has moved on, so nothing shown can be a page that failed. The check that
every product on camera is real and every photo loaded runs while the results
are looked at and the retailer's page is open, rather than holding the camera
on a still page; a failure still stops the run with nothing written. The recording report lists, per search and per layout, the product and
retailer opened, any product the preflight left out, and any click that was cut,
and ends by saying whether every click reached its retailer. The note under the
video on the
Search page, and the video's label, are rewritten by the script with the requests
and the date of the searches, since prices and stock move on after a
recording. The script prints the finished length and says so if it falls
outside 50–60 seconds.
On a machine with network access and your .env.local or .env
(PRODUCT_SOURCE and its key; OPENAI_API_KEY for the AI interpreter). Both
are read, .env.local first, so a value there wins; --env=FILE reads one file
instead:
npm install # Playwright
npx playwright install chromium # the browser it records with
npm run demo:record # = node scripts/record-demo.js
Then cut the film from the new session (The film, above), watch both films —
with sound, without sound, and without reading the captions — and commit
assets/demo/ and find-clothes.html.
node scripts/record-demo.js --replay # re-record from the saved searches
node scripts/record-demo.js --only=mobile # one shape (or --only=desktop)
node scripts/record-demo.js --stills # a PNG at every beat, no video
It needs ffmpeg with libx264, libvpx-vp9, AAC and Opus, and the ffprobe that
ships beside it: ffmpeg encodes, ffprobe reads durations. FFMPEG_PATH points
at ffmpeg; ffprobe is found next to it (C:\ffmpeg\bin\ffmpeg.exe gives
C:\ffmpeg\bin\ffprobe.exe), or set FFPROBE_PATH if it lives elsewhere.
On Windows (PowerShell):
$env:FFMPEG_PATH = "C:\ffmpeg\bin\ffmpeg.exe"
npm run demo:record
node scripts/test-record-demo.js checks those paths offline. The pointer paths,
typing rhythm and pauses come from a seeded generator, the narration is fixed,
and the saved searches pin the products, so the same inputs give the same video.
How it moves: the pointer travels on gentle curves, slow at both ends, with a small overshoot on long moves, and takes the shape the page asks for — arrow, text cursor over the box, hand over a product. Typing has an uneven rhythm with a beat at each space. Scrolling is a real wheel gesture on desktop and a real touch swipe on the phone layout, where taps show as a small soft dot, the way a phone's own screen recording shows touches. The browser draws no pointer of its own when headless, which is the only reason one is drawn.
The lines in assets/demo/narration/, spoken by Kokoro v1.0 (voice
af_heart), a neural text-to-speech model run offline through sherpa-onnx. Each
line has its own pace, so the lines never share one cadence, and a sentence is
never cut in two (a synthetic voice drops its pitch at the end of anything it is
given); the closing line is two sentences with a breath between them, and its
captions are two cues, each where it is heard. "Fynd" is
spoken as "Find" — the voice has no entry for the brand and would guess — while
the captions keep "Fynd". Each line is finished the way a voice recorded close to
a good microphone is: a high-pass, a little body, its slight glassiness eased, a
de-esser, gentle 2:1 compression, every line set to -19 LUFS, and the few
stressed vowels that stand far above the rest held by a look-ahead limiter.
They are committed, so recording needs no speech model.
scripts/demo-narration.py makes them again if a line changes:
pip install sherpa-onnx soundfile numpy
# kokoro-multi-lang-v1_0.tar.bz2 from
# https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models, unpacked
KOKORO_DIR=/path/to/kokoro-multi-lang-v1_0 python3 scripts/demo-narration.py
The film uses hook, describe, results, pick, rare, exact and
finale. The recorder's own lines stay for its raw session videos, where
"from different retailers" is only said when the products on screen link to
more than one shop; otherwise the shorter line is used.
scripts/demo-audio.js writes a score for each video from that video's own
timeline — when each line is said, and when each search is typed, made,
answered and opened at its retailer — rather than laying a loop under it. Soft
pads in D, close warm voicings with ninths and elevenths, a felt-on-glass note
now and then, and a little air; no drums, no lead melody, no build. It enters
softly under the first line; gains a little texture while a search runs; lifts
when results arrive (the chord opens, three quiet notes); rises by the smallest
step at each retailer; and comes home to D under the last line, ending in
silence so the looping player starts again from silence.
The voice comes first. The music starts to fall just before each line, sits lower under it — its brighter layers lower still, and the whole bed eased around 2.5 kHz, so the words have the range speech is understood in to themselves — and comes back over about a second after. The movement is drawn from where the lines are rather than by a compressor listening to them, so nothing pumps. The voice is set to -16 LUFS (ITU BS.1770, measured on the voice), the music about 10 LU under it between lines, and a true-peak ceiling of -2 dBTP keeps the AAC (160 kb/s stereo) and Opus (128 kb/s stereo) encodes clear of clipping. The last line is laid in 0.4 s before the cut back to Fynd, so it can finish with music still under it. The captions are written from where each line is actually spoken.
For the film, the score follows the film's own timeline instead
(planFilm): almost nothing under the opening question; a lift as each set of
results arrives; a single soft note when a product is chosen; a change of
colour as its store opens; the BAPE reveal the one modest high point; and a
calm resolution home that ends in silence, with no flourish. A little sound
design sits under it, all quiet and all from the edit: a soft tick at every
keystroke the film shows, a low tock on Search, a click on the chosen card, and
a breath of air under the larger transitions — ducked under the voice like the
music.
Every recording writes its timeline beside the video, so the sound can be made again without recording again — the video streams are copied, not re-encoded:
node scripts/demo-audio.js --remix # both videos
node scripts/demo-audio.js --remix --stems=DIR # and the voice and music stems, to listen to
node scripts/test-demo-audio.js # the checks: loudness, ducking, captions
| File | ||
|---|---|---|
fynd-demo.mp4 / .webm | 1280 × 800, with narration and score, stereo | wide screens |
fynd-demo-mobile.mp4 / .webm | 800 × 1440 (400 × 720 at 2×), with narration and score, stereo | 1023px and under |
fynd-demo{,-mobile}-poster.jpg | the real results, shown before playback | |
fynd-demo{,-mobile}.vtt | the narration as captions, timed to where it is spoken | |
fynd-demo{,-mobile}.timeline.json | the film's timeline: when each line is said, each key typed and each search made, answered, chosen and opened; what the score is written to | |
footage/fynd-demo{,-mobile}.session.mp4 / .session.json | the recorded session the film is cut from (video only), and its timeline | |
footage/fynd-demo{,-mobile}.keys.json | the keystrokes in that footage | |
demo-search.json | the real searches both recordings were made from, with every attempt each took | |
demo-report.json | the recording report: attempts, retries, equivalents, products and retailers opened, lengths | |
narration/*.wav, narration/manifest.json | the spoken lines |
The narrow one is the site's real mobile layout, driven in a phone-shaped window rather than cropped from the wide one. The two swap at 1023px, the site's own tablet breakpoint.
Two codecs, because neither one alone reaches every browser: H.264 with AAC is the format nobody has to be asked about, except that a Chromium built without proprietary codecs — which is what most Linux distributions ship, and what this repository's own test browser is — cannot play it at all. VP9 with Opus covers those and is the smaller file, so the page lists the WebM first. The page starts the video muted, as browsers require for autoplay; the player's own controls turn the narration on.
assets/demo-video.js and a short inline script in find-clothes.html divide the work
by when the decision has to be made.
Before the browser acts on the markup, in the inline script: which of the two
recordings this screen gets, at the same 1023px the stylesheet reshapes the frame
at. A poster attribute is fetched the moment it is
parsed, and a <source> swapped after the player has chosen one is ignored, so
neither can wait for a deferred file. The frame is reshaped by the same line, so
the shape and the file cannot disagree and nothing jumps. (media on <source>
would be the declarative way to do this; browsers have never agreed on it, and
the one that ignores it hands a phone the wide file.)
After the page has finished loading, in assets/demo-video.js: whether to
play. The markup says preload="none", so not one byte of video is fetched until
something asks for it; an IntersectionObserver, armed on the load event, is what
asks. Waiting for the load event keeps the video behind the stylesheet, the font
and the scripts the page needs to work at all — measured, DOMContentLoaded and
the load event are unchanged by the video's presence, and the request for it
starts after both.
Autoplay is attempted muted, and refused autoplay is not something to predict:
play() rejects, and the rejection is what puts the play button on screen. Two
readings are taken before even trying — a reader who asked for less motion, and
a connection asking for fewer bytes — and both mean the button instead. The
player's own controls are always present, so the keyboard path and the screen
reader path are the browser's, not a reimplementation. The video loops, and
scrolling away pauses it; a pause the page did not ask for is the reader's, and
after one the page does not touch the video again.
Typeface is Inter, loaded from Google Fonts.
The demo video on the Search page is a recording of a real search on this
site, made by scripts/record-demo.js; see The demo video above.
Products without an imageUrl — every row but the UNIQLO one — render
generated artwork built from CSS gradients and inline SVG. Set imageUrl on a
product and it renders the photo; if that photo fails to load, the artwork
returns.
price carries an amount on a linked row only where one was read off the
retailer's own page and tied to that exact product. L.L.Bean is 84.95, from
the offer on its JSON-LD product record; J.Crew is 98, read off the figure
in div#productPriceSelectColors-CX449NA6434 — the colour that listing had
selected — marked as its sale price under AU763. UNIQLO is null on
purpose and renders none: its code appears in 17 elements of the rendered
page, all images and preload links, and no figure on it can be tied to the
product. The sample rows carry the demo's own prices and link to nothing, so
nothing claims they were read from a retailer.
imageUrl carries a photo only where one was verified against that exact
listing; a row left null keeps its drawn artwork, which is the honest state
rather than a placeholder.
imageEvidence records how a photo was tied to its product, and only where
the URL cannot say so itself. UNIQLO and J.Crew carry their listing's code in
the image URL, so no note is written. L.L.Bean requests a Scene7 asset
(521659_32573_41) whose name says nothing about product 129244; the tie
was made by that listing's JSON-LD product record naming sku 129244, so the
row records it. The defaultImage parameter in that URL also contains the
code, but it names Scene7's stand-in image rather than the one requested and
is explicitly not what vouches for the photo. The note is re-proved rather
than trusted — a recorded sku must match a code in the row's own productUrl
— and assets/products.js drops the field before anything renders.
priceEvidence records how a price was tied to its product, and unlike
imageEvidence it is written for every price with no exception: an image URL
can carry the product's code and speak for itself, while 84.95 carries
nothing. L.L.Bean's names the offer on that listing's JSON-LD product record
for sku 129244. It is re-proved rather than trusted the same way — the
recorded sku must match a code in the row's own — and it never
reaches the interface either. A price read off a selected variant records
both halves, : the
listing's code is what a shipped row is re-proved against, while the variant
names which colour was on screen when the amount was read. A price whose
evidence cannot be written as a note is refused by rather than
written without one.
ALLOWED_ORIGIN |
| no |
| Extra browser origins allowed to call the endpoints, comma-separated. The deployment's own origin is always allowed without configuration, so this is only needed for a frontend hosted elsewhere — GitHub Pages calling functions on Vercel. See Cross-origin access. |
STRIPE_SECRET_KEY | for billing | Your Stripe key. sk_test_… until launch. Without it the paid plans cannot be bought and the pricing page says so. |
STRIPE_WEBHOOK_SECRET | for billing | The signing secret of the webhook endpoint (whsec_…). Without it every delivery is refused, so no plan ever changes. |
STRIPE_PRICE_PRO | for billing | The Stripe Price Pro is sold at — recurring, monthly, $14.99. |
STRIPE_PRICE_MAX | for billing | The Stripe Price Max is sold at — recurring, monthly, $39.99. |
AUTH_SECRET | for accounts | Keys the HMAC that sessions, verification links, reset links and OAuth state are stored under; 16 characters or more. Without it nobody can sign in, so nobody can subscribe. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | for Google sign-in | An OAuth 2.0 Web application client. Without them the account page does not offer the Google button. |
RESEND_API_KEY or POSTMARK_SERVER_TOKEN | for verification | A transactional email provider. Without one, accounts are created unverified and cannot be confirmed — and therefore cannot subscribe. |
EMAIL_FROM | for verification | The sending address, on a domain the provider has verified. |
EMAIL_PROVIDER, EMAIL_REPLY_TO, POSTMARK_MESSAGE_STREAM | no | Pick a provider explicitly; set a reply-to; name a Postmark stream. |
KV_REST_API_URL / KV_REST_API_TOKEN | for billing | Where accounts, subscriptions and usage counters are kept. Vercel KV sets these when a database is attached; UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN are read too. Without either, state is held in memory and lost on restart. |
pantssweatpantsjeansother{ anchor, fitGoal, troubleZones }nullothernamenullVersion 4 adds each type's sizing line, line: men, women, unisex or
not-sure, exactly as chosen beside the size that fits ("Not sure" is an answer,
not men's), or null. Like name, a label kept only beside an answer. A
version 3 profile reads as version 4 with every line unanswered; a fit profile
page from before version 4 that sends a type without line keeps the stored one.
Size suggestions read it, and never guess it. A version 1 or 2 profile reads as version
3 with garments: {} and everything else exactly as stored — version 2's
answers are not moved into any type, because the shopper never said whether
that top was a T-shirt, a hoodie or a sweatshirt. They stay at the top level,
are shown in the words they were asked in, and the new guide never writes them.
Version 5 adds the favourite T-shirt (T-shirts only — a type marked
favorite in GARMENTS): favorite is the answer to "Do you own a T-shirt that
fits you exactly how you like?" — perfect, close or none ("Not really / I'm
not sure"), or null. none is an answer on its own and clears the anchor. The
T-shirt anchor may also carry, each optional and left out when not said:
product ({ name, url } — a link keeps only its address, without query or
fragment, and is never fetched), likes (what makes its fit good: shoulders,
chest, sleeves, length, looseness, feel) and measurements (the garment
laid flat: { unit, chestWidth, shoulderWidth, sleeveLength, bodyLength }, each by
a stated method — chest is a flat width armpit to armpit, never a circumference;
a chest past the flat range is refused with "about half the way around"). A
version 4 profile reads as version 5 with every anchor exactly as stored. A fit
profile page that sends a T-shirt's brand and size without the details keeps
them; a different brand or size is a different piece, and its details go; sending
them as null removes them. Any other type sent a favourite or details is refused.
Each type has its own lists, and one type's values are refused for another:
M is refused for
jeans, 32 for a T-shirt, a length for a hoodie.slim,
true-to-size, oversized), per type, so oversized hoodies and regular
jeans are two answers. The cards' second line is worded for the type ("Slim
or skinny leg" for jeans). fitGoal sits beside fitPreferences and never
rewrites them; FIT_GOALS records which per-category fit each goal is nearest.troubleZones: [] means "none of these", which is different from not
answered.A save never erases what it does not send. A save that leaves out any of
anchor, fitGoal, troubleZones or garments keeps the stored value, so a
fit profile page loaded before a version added them cannot wipe them. Sent as
null, an answer is cleared. Sent, garments is the whole map: the fit
profile page sends every card it shows, and a type it removed is gone.
The size guide it links to, when the page has no finished-garment table: up to two links on the same site whose words or address say "size guide", "size chart", "sizing"… are read the same way. Their charts are labelled the retailer's size guide linked from the product page — possibly a general chart, not this product's own, with the guide's own link. When the page had a body table of its own, only the guide's garment tables join it (a general guide's body chart adds nothing).
Labels only: the sizes in the product's JSON-LD, when no table was found.
An official fallback (api/_sizing/official-charts.js), when the page
couldn't be read or held no chart: a brand's general official chart for a
category, used only for a product listed by its code under that chart. It is
shown as status: 'fallback', scope: 'brand-general',
verification: 'official-general', with the chart's label, its official
sources, and a note saying why the page wasn't used — never as the product's
own chart or as read live. Only body charts may be fallbacks, so a fallback is
never comparable with a garment. An unlisted product, another market or
another shop gets no fallback: it stays unavailable, with the page's link.
Cached like a failure (30 minutes), so a page that answers later is read.
Each chart file (api/_sizing/official-charts/*.json) holds its brand,
market, measurement type, unit, values as published, a label, the categories
and lines it covers, its evidence (the official product pages it was copied
from, each tied to a sizing record, with dates), its provenance, and the
products it is known to apply to, each with how that is known. The loader
refuses a chart whose values disagree with any of its evidence records, that
isn't a body chart, whose label doesn't say "not garment measurements", or
that lists a product outside its categories or off the brand's site.
The one chart so far: UNIQLO's US men's and unisex tops Body Size chart (chest, inches, XXS–3XL). UNIQLO publishes it identically on the Sweat Pullover Hoodie (E475378), Sweatshirt (E475377) and SUPIMA Cotton T-Shirt (E455365) pages Fynd has records for. It is listed for E471808, the Sweat Oversized Pullover Hoodie, whose page doesn't answer Fynd's requests from the deployment. E471808 now has its own Product Size record, which comes first. The record has no body chart, so this general chart is shown beside it, labelled general, never in its place.
A product listed under a chart may record where else its own measurements
were looked for (productSize.tried, shown under Source details). The
loader refuses anything else there: a listed product can hold no garment
values. Fynd's own attempts to read E471808's Product Size tab (19 October
2026) all failed:
www.uniqlo.com and image.uniqlo.com, and its web fetch could not
resolve the host;Its record's values came from the supplied transcription instead (above,
"E471808's Product Size chart is in, pending review"), never from another
product such as the Sweat Pullover Hoodie (E475378, a different, regular-fit
product). For any other product, the way in is the same: open the page in a
normal browser, open Size Guide → Product Size, and read it with the Fynd
extension; then draft a record from what it read
(scripts/draft-sizing-record.js, below). When Fynd's server can't read a
page, the Size Guide says so and points to that route.
summarise() counts consenting accounts' feedback per product
and size, reads nothing below ten accounts, and flags opposite reports; nothing
in the engine reads feedback.Access-Control-Allow-Headers| Signed in on the Pages site, signed out again on the next page | The browser is refusing the Vercel session cookie as third-party — Safari, Brave, Incognito. Use the Vercel copy for accounts. See Cross-origin access. |
productUrl{ via: 'dom-variant-scope', code: 'AU763', variant: '…' }writePricenode scripts/fetch-catalog-images.js fills the imageUrl of every row that
carries a productUrl, by reading the photo off the listing the row already
links to. It tries plain HTTP first; a page that gives up nothing — no
candidates, or a 403 from its bot check — is then opened in a real Chromium
through Playwright, which runs the page's scripts and reports its rendered
metadata, JSON-LD, preload links, gallery images and the photos the page
actually loaded. A retailer that refuses a bare client gets a real browser,
never a guessed CDN URL.
Four gates decide what may be written: the URL came out of that page
(found), its host is the retailer's rather than an aggregator or a stock
library (sound), it can be tied to this product — the listing's code
appears in the image URL, or the JSON-LD record supplying it names a matching
sku, or the page declares itself canonical for the listing (this) — and it
answers 200 as an image both plainly and under the deployed site's Referer
(loadable). A photo of a similar garment on the right retailer's own CDN
fails the third gate and is refused. Anything that fails any gate leaves the
row null and keeps its artwork.
In the browser it answers a cookie wall (accept only — nothing is rejected, configured or submitted) and walks the page down so a gallery that loads on scroll actually loads before it is read.
--discover is how a row with nothing behind it becomes photographable. Most
of the catalogue is sample rows — names invented to give the demo something to
search — and a sample row cannot be photographed, because there is nothing to
photograph. So the listing is found rather than typed in: the product
source the app already uses (the same one that answers /api/search, set by
PRODUCT_SOURCE and its key) is asked for real listings, its link rule
refuses aggregators, search pages, category pages and redirectors, and every
listing that survives is put through the same four gates as any other row.
It is asked more than one way. Packing everything a row knows into one
query sends "white relaxed oversized minimal sporty tee everyday weekend boxy cotton tee", which is not a query anyone would type and which a shopping API
answers with nothing — or with an error, which is what most rows reported as
NO SOURCE. The row's name is the query, so the source is asked for that
first, then its plural, then the name with its category, then the name with
one word dropped ("Midi Skirt", "Pleated Skirt"), and only last for
everything the row knows. Widening like that is safe because the gate
downstream takes no title's word for anything: a candidate found by "Pleated Skirt" still has to establish midi. A form that throws no longer ends the
row — the next one is tried, and a row where every form failed reports
SOURCE FAILED with the error, which an unconfigured source (NO SOURCE)
never was. Searches cost money, so the ladder stops once something is found.
The first that clears them all fills three fields of the row and no others:
productUrl, imageUrl, and the imageEvidence that ties the second to the
first. A sample row's id, name, brand, price, category, style,
occasion, fit, colors and sizes are the demo's own — "Tailored Wool
Coat" by Halden is what that row means, and a row renamed after whichever shop
stocked a match is no longer the row the demo was built around, with a price
and a category now describing a product nobody chose. The evidence is not
optional: a photo whose URL does not carry its listing's code cannot be
re-proved without it, so a write that has neither is refused rather than
shipped as a row --coverage will call unaccounted. (--candidate --as is the
other mode, a deliberate hand-driven swap where the name and brand do move.)
Ahead of those four sits the gate they cannot be: whether the listing is the garment the row means. A photo can be provably this listing's own and still be the wrong answer — asked for Kinfield's "Fleece Sweatpant" the source offered Aerie's "Street Trouser", which cleared every identity gate and was still the wrong trousers. So each candidate's title is read as a garment before its page is ever fetched — type from the head noun (a "ribbed knit skirt" is a skirt, not a knit), family, audience, fibre, and the descriptors that exclude one another — and compared with the row's own reading. A contradiction refuses on the title and costs no request — a mini answers no midi, a girls' skirt answers no adult-sized row, a trouser will not become a sweatpant further down the page.
A word the row states that the title merely does not say is a different
matter, and is settled in two stages. A shopping title is a headline, not a
specification: "Aerie Real Soft Jogger" is a fleece jogger or it is not, and
the title will never say. So the title stage marks fleece pending, the
page is fetched, and the product's own JSON-LD record, description,
attributes and material fields are read for it. The candidate passes only
once every descriptor the sample row names is established, by the title or
by the page — nothing waives that.
What the page may answer with is the whole question, because a page says far
more than it sells: a "you may also like: wool midi skirts" strip would prove
midi about a garment that is not for sale there. Evidence is taken only from
the product's own structured record, the page's own meta description, and
blocks the page itself marks as this product's description, details,
specification or composition — and recommendation strips, carousels,
cross-sells, breadcrumbs, navigation, headers and footers are cut out first. A
detail block that runs into one is read up to it and no further. Evidence can
only ever establish a pending descriptor; it is never read for
contradictions, since a size chart naming every length there is would refuse a
skirt for being available in mini.
The check is on meaning rather than spelling: mid-length establishes midi,
"slim" establishes a tailored row, "merino" a wool one. It stays
one-directional where the words are not guaranteed equivalents — cotton does
not establish poplin, and generic lyocell does not establish Tencel,
which is one manufacturer's. Sleeve, neckline and rise are exempt throughout,
being words a title omits without it meaning anything.
Brand is deliberately not compared — Kinfield, Northfold and Rue Nine were
invented for the demo, and demanding the brand back would refuse every
correct answer there is. Every candidate's verdict is printed with its
reason, pass or refusal, because a gate whose reasoning is invisible cannot
be corrected. A row that
already carries a photo is never a target, which is what keeps a verified
photo verified, and a photo another row already wears is refused, because two
rows in one picture is the catalogue telling a lie about one of them. Nothing
is written without --write, and a re-run re-derives what it wrote.
--coverage answers the question that work is aimed at: how many rows carry a
photo, how many link to a listing, and whether each still accounts for what it
carries. It reads the catalogue and nothing else.
When a retailer cannot be read at all, the row's product has to change
rather than its photo. --candidate <productUrl> --as <row-id> tries a
replacement listing through the same four gates and prints the row it would
become — name, brand, productUrl and imageUrl, every field taken off that
page rather than typed in — and --write then swaps all four together, so a
row can never point at one product and picture another.
It reports VERIFIED / NO IMAGE FOUND / UNREACHABLE per row and writes
only with --write. A row that fails lists every candidate it found with the
gate that stopped it, so a failure says what to fix rather than just that
nothing worked. A row that already carries a photo is left alone unless
--refresh is passed, so a working URL is never churned; --no-browser
keeps it to plain HTTP. Run it from an ordinary connection — where retailer
hosts are refused every row reports UNREACHABLE and nothing is written.
node scripts/test-catalog-images.js covers all four gates and the semantic
one without a network, driving the browser path against a local server that
refuses plain HTTP and builds its gallery in JavaScript, and offering the
semantic gate the wrong garments on purpose — a trouser for a sweatpant, a
dress for a skirt, a jacket for a hoodie, a tote for a sneaker — as well as
the awkward right ones it must not refuse.
node scripts/fetch-catalog-prices.js fills the price of every row that
carries a productUrl, by reading what the listing charges off the same page
the photo came from. Plain HTTP first, then a real Chromium for a page that
only prices itself after its scripts run.
Four gates decide what may be written: the figure appeared on that page
(found), it reads as an amount in a currency the page itself names
(money), its own DOM or its own structured record ties it to this
product (this), and something on the page says it is the amount charged
rather than the one it is discounted from (charged). The third gate is
the one worth stating plainly: a page that declares itself canonical for the
listing has vouched for the page, and a product page renders many figures,
so canonical says nothing about which of them is the price — it is not
provenance here the way it is for an og:image. What counts is a
data-product-id or an itemprop sku on the block the figure sits in, or an
offer on a JSON-LD product record naming a matching sku.
Then one rule over all four: if more than one distinct amount clears every gate, the run fails closed. A struck-through list price, a "4 payments of", a free-shipping threshold and a neighbouring product's price are each refused by name — but two figures that both survive mean the page has not said which one a shopper pays, and that is not a tie to be broken by picking the lowest.
One thing does break a tie, and only one: a group page prices every colour it
contains, so a figure tied to the variant the page has selected — read off
a checked input, an aria-selected swatch or the URL's own variant parameter
— outranks one tied only to the group all of those colours belong to. A cookie
wall is full of checked boxes with code-shaped ids, so the consent widget is
not a place a variant can be chosen: OneTrust's ot-group-id-C0004 is refused
both where the page is read and again at the gate, and the inspection prints
what it ignored rather than letting a cookie category read as a colour. That is
specificity about which product, not a preference about which amount; two
figures at the same rank that disagree still fail closed, and a structured
offer that disagrees with the rendered page does too.
It reports VERIFIED / AMBIGUOUS / NO PRICE FOUND / UNREACHABLE per
row and writes only with --write, always with a priceEvidence note.
--explain prints every figure with the DOM that justified or condemned it,
and --explain --json emits the same as a capture file. --refresh re-reads
a row that already carries a price; --no-browser keeps it to plain HTTP.
Every capture names the layer it was read through. Each row carries
readThrough: "plain HTTP" | "browser" | "nothing", each row's trail.browser
is either the rendered read or the reason there wasn't one, and the capture as
a whole carries complete and an incomplete sentence. A file that never
reached a browser reads exactly like a page that renders no price, and those
are opposite findings — so the capture says which it is rather than leaving it
to be inferred from an absence.
--inspect <productUrl> answers the question a capture cannot: it opens one
page in Chromium and prints every figure on it with the ancestor chain above
it, what the element is marked as, whether it is struck through or off-screen,
which product codes its ancestry carries, whether it sits in a
"you-may-also-like" block, and what each gate says about it — then what the
run would write, which for a page that has not said is nothing. It reads no
catalogue row and changes no file. --inspect <url> --json gives the same
untruncated.
--inspect-data <productUrl> answers the question after that one: when no
element on the page can be tied to the product, does the page carry the price
anywhere else? It opens the page, then reads the scripts it shipped, the
application state left on window, the JSON it fetched while building itself
and performance.getEntriesByType("resource"), and reports every place where
this listing's code and an amount are fields of the same record — with the
source, the record's path, the field, the currency and the product or variant
code that tied it. One rule makes it worth anything: a document that mentions
429066 somewhere and 49.90 somewhere else is not evidence, because a
catalogue response holds fifty products. It also reports what it set aside
(anything under a recommend/related/carousel key), whether the listing's
record names colour codes and whether a second record prices one of them, and
what the normal gates would make of all of it. It writes nothing.
A record that clears those gates becomes an ordinary candidate, via: 'data-product-record' or, through the colour chain, via: 'data-variant-mapping' — judged by the same decide() as every DOM figure,
so a record that disagrees with the rendered page still fails closed.
A payload is never the shop. A row is written from data only where the
shop's own commerce response carries the amount, or where the page draws it
somewhere a shopper can see, tied to this product. A hydration blob is built
from the same source as the schema.org beside it and inherits its staleness,
so one agreeing with the other is one source speaking twice — UNIQLO's page
carries 7.90 in both and charges neither.
Carrying the product's id is what lets a record be considered, not what
makes it true. Payload sources are tiered by what they are: a commerce-api
response the page fetched to price itself, app-state it hydrated from, and
markup — schema.org in a <script type="application/ld+json">, written for
crawlers, which nothing on the page reads and nothing breaks when it goes
stale. Markup may confirm an amount that a source the page prices from also
carries; on its own it answers nothing. UNIQLO's @graph ProductGroup names
E429066-000 and offers 7.90 on a page that charges something else, and
that record is perfectly well-formed — which is exactly why the id being in
it cannot be the test. Two authoritative sources that disagree still fail
closed.
--version says what a build is: a sha256 fingerprint of the file itself, the
commit it came from, and every mode and option it has. A mode missing from
that list is missing from that build, which is a faster answer than reading
the source or the commit log.
Options are read once into a parsed shape, in both spellings (--flag value
and --flag=value) and any case, and an unrecognised option stops the run —
being ignored would silently make it a different command. That is not
hypothetical: --inspect-api=<url> was invisible to a scan for an exact
string, so the flag was not there, so the run fell through to the ordinary
verifier and priced a row off the page's markup. Each diagnostic now
announces itself (API INSPECTION — one endpoint…), and --help lists them.
A written price is the selected variant's. A product with
variant-dependent pricing has no single price — UNIQLO prices colours of one
sweater at 7.90 and 49.90 — so the row records what the listing offers
when it is opened, and the evidence beside it names which variant that was.
The strongest form of that evidence is a chain, written as
via: 'datalayer-variant-price': the shop's commerce response prices one
variant, the page's analytics event names that same variant with the same
amount, the product and legacy ids it belongs to, and the currency, and the
figure on screen is that amount. The note keeps every link —
{ via, productId, l1Id, l2Id, communicationCode, currency } — and a shipped
row is re-proved against the productId, which is the one identifier the
listing URL itself carries.
Every link is required, and each missing one is a refusal with a name: an event that names a different product contradicts the commerce record rather than corroborating it and lends nothing, not even its currency; an event sharing the currency but not the amount says what units this item's prices are in, not that this is one of them; another variant's price is not selected because nothing names its units; and an analytics event cannot corroborate itself into a price, since the amount has to come from the shop's own commerce data. Schema.org may agree with the chain and never constitutes it.
--datalayer <productUrl> dumps the page's dataLayer ecommerce events in
full, lists every variant its data prices — so one cheap colour among dear
ones is visible rather than inferred — and walks the chain link by link: the
variant the event names, the amount the commerce response carries for that
same key, whether that record names a currency, what the event calls the
product, and whether the figure on screen is that amount. It ends with what
the ordinary gates make of it, using the same functions the verifier uses, and
writes nothing.
That chain is how a missing currency can be supplied without assuming one. A
record carrying an amount and no units may borrow a currency from another
record that names the same item and carries the same amount — an analytics
event for variant 05437392 saying 7.9 and USD is the same price said
twice, once with units. A record that names the item and a currency but not
that amount is reported and refused: it says what units this item's prices are
in, not that this is one of them. Markup may supply neither. The currency may
sit one level above the record it applies to (ecommerce.currency covers
ecommerce.items[]), and the report says where it was found and whether it
was carried or inherited.
--hunt <productUrl> --find 49.90,49.9 searches everything the page loaded —
every response it fetched, every script it shipped, every state object on
window — for the amounts named and for the listing's identities, including
the other codes the page's own records tie to them. Each hit is reported with
its exact JSON path, the record around it, the identity fields that record
carries, whether that identity is this listing's, and the nearest currency
field with its path. An amount is hunted in the shapes an API may carry it
in, so 49.90 is also looked for as 4990 in cents — which is how a
displayed price that appears in no payload turns up. Payloads that will not
parse are read as text, with enough context to see what surrounds the number.
It ends by answering four questions directly: does another response carry the
amount tied to this product, is there currency metadata elsewhere in a
payload that has it, does page state or a shipped script hold it, and does it
appear only in another unit. Amounts come from --find — nothing about what
a product costs is built into the tool — and a hunt decides nothing, prices
nothing and writes nothing.
--inspect-api <endpoint> --for <productUrl> [--codes a,b,c] reads one API
response the way the page reads it: fetched from inside the opened page, so
it carries the session, cookies and origin the retailer expects. It is a
command of its own and answers from that one response — it never falls back
to the page, its DOM or its JSON-LD. A missing or non-http URL stops the run
with a non-zero exit rather than quietly becoming a catalogue verification, an
endpoint that answers with a page (a bot wall, a redirect, a 404) is reported
as exactly that and inspected no further, and a response that is itself
schema.org is refused rather than reported as an endpoint's answer — markup
cannot borrow a commerce API's authority by being served through its URL.
It joins the variant records and the price map a commerce endpoint answers
with (result.l2s[*] against result.prices[...]), showing which identity
joined them. It reports
every record carrying one of the listing's codes together with an amount, the
other identities those records name (a legacy id in the URL is often not the
id the API keys by), the colour and display fields, every amount-looking field
with its path, and whether the endpoint carries a selling price for each
identity — counting amounts kept in a map keyed by variant id, which is where
a commerce API usually puts them. --inspect-data additionally audits each
markup offer: an expired priceValidUntil, an out-of-stock availability, an
amount on a variant rather than the group, and whether anything the page
prices from corroborates it.
--inspect also searches the rendered DOM for the listing's own code and
reports every element carrying it, with the figures inside each — because when no
price can be tied to the product, the next question is always whether the
page names that product anywhere. An element carrying E429066-000 whose
subtree holds a figure is the price block; the code appearing nowhere in
the body means no figure on that page can be tied by ancestry at all, and the
inspection says which of those two it found.
node scripts/test-catalog-prices.js covers the gates without a network,
including both live shapes: UNIQLO's single hydrated figure sitting in no
product's block, and J.Crew's ProductGroup AU763 publishing offers: []
behind five rendered figures, four of them marked as the current price.
The catalogue is a small sample set plus three real listings; it is not real inventory.