catalog-extract/references/site-recipes.md · md · download
# Site recipes
Worked examples from the production tracker (Citizen Watch Company of America,
snapshot 2026-08-05). Use them as patterns for new brands: the first column of
each table is the question recon has to answer.
## Platform fingerprints -> discovery source
| Platform (recon hint) | Discovery | Notes |
|---|---|---|
| Shopify (`cdn.shopify.com`) | `/products.json?limit=250&page=N` (7 pages for Timex), `/collections.json` for the line vocabulary | prices + variants in the JSON; specs only on the PDP; `render=False` + 8 s wait |
| Salesforce Commerce Cloud (`demandware`) | product sitemap, or the grid endpoint `Search-UpdateGrid?cgid=...&start=0&sz=48` | gendered grid gives gender; PDP JSON-LD gender may be the founder's (TAG), not the product's |
| Magento (`Magento_`, `/static/version`) | sitemap | JSON-LD under an `@graph` wrapper (Hamilton); specs in `ul.product.attribute.details`; store API is a REST XHR |
| Next.js (`__NEXT_DATA__`) | collection listing pages, `?page=N` until `total_count` | everything SSR at `render=False`; numeric attribute ids decoded from the co-located dictionary (Longines); Movado PDP `preFetchedProduct` |
| Algolia InstantSearch | collection landing pages carry the SSR Algolia payload (`window[Symbol.for("InstantSearchInitialResults")]`) | Tudor: 16 landing pages hold every spec + USD price, so PDPs are optional |
| Custom SPA | sitemap for URLs; PDP needs `render=True` | Grand Seiko + Seiko discovery, Tissot PDP (5.9 KB shell at render=False vs 550 KB rendered) |
## Brand recipes (products)
| Brand | Discovery | PDP render | Payload | Gender |
|---|---|---|---|---|
| Tissot | product sitemap `sitemap-custom-sitemap_0-product-en_US.xml`, SKU `T\d+` from path (569 URLs on 2026-10-19) | was True + 5 s wait in 2026-07 (5.9 KB shell raw); on 2026-10-19 recon found the raw fetch complete (727 KB, JSON-LD, 26 spec labels), so `render=False` + 3 s wait works for most pages, but 1 of 7 raw fetches came back as a 13.5 KB shell (caught by the guard, retried) and 1 of 7 passed a JSON-LD sentinel with no spec block. Sentinel must be `Country of manufacture` (spec block, byte 266k), not JSON-LD (byte 18k). Re-run recon per brand before each rebuild: render needs drift. | JSON-LD + `.accordion` rows (heading, label); labels now carry prefixes (`case length (mm)`, `strap colour (main strap)`) | men's / women's landing pages -> `{sku: gender}` cache |
| Longines | collection paths, `?page=N` | False | `__NEXT_DATA__`; attribute ids decoded via dictionary | `meta_description` text ("watch for woman") |
| Movado | sitemap | False | `__NEXT_DATA__` -> `preFetchedProduct` | landing pages |
| TAG Heuer | sitemap | False | JSON-LD; specs in DOM | SFCC grid endpoint (PDP JSON-LD gender is wrong) |
| Hamilton | sitemap | False | JSON-LD `@graph`; `ul.product.attribute.details` | landing pages; ~16 families |
| Tudor | sitemap (210 refs) | False | 16 collection landings, Algolia SSR payload | from collection |
| Timex | Shopify `products.json` (1,402) | False + 4 s wait, sentinel = spec item | record JSON + PDP spec block | landing pages; seed lines win over collab titles |
| Fossil | sitemap (~2/3 non-watch, cached discards) | True, sentinel `div.attribute, div.key-feature-attribute` | `dataLayer` (in `<head>`, survives truncation: hence the sentinel) + Key Features | from `dataLayer` |
| Seiko | sitemap (366 models, matches the Watch Finder); keep `discovermore` URLs | True for discovery, static PDP | JSON-LD | heuristic |
| Grand Seiko | listing needs `render=True`; PDP static | False | JSON-LD | none on site -> model-code + case-diameter heuristic |
Spec-map gotchas seen in production:
- Tissot prints the unit in the label: `power reserve (hours)`. Without that
variant the column sat at 0% while the value went to `extra_attributes`.
- `length (mm)` is the diameter on round cases.
- British spellings (`colour`) and both spellings coexist across models; map both.
- A sitemap listing a SKU twice, and colourways sharing a model page: dedupe on SKU.
## Store recipes
| Brand | Mechanism | Call |
|---|---|---|
| Grand Seiko, Seiko | own JSON API, GET | `/api/v2/stores?...&key=<public key>&country=<id>&unit=99999` -> `stores[]`; `city.0.name`, `state.state_name`, `store_type.0.store_type_name` |
| Hamilton | Magento REST, XHR only | load `/en-us/store-locator/united-states/`, in-browser GET `...?lat=39.8283&lng=-98.5795&dist=9999` with `Accept: application/json` -> `items[]`, filter `country_code == US` |
| TAG Heuer | Algolia POST | read `algoliaConfigs` (`apiId`, `apiKey`, `apiUrl`) from `.storefinder[data-options]` on the locator page; POST `{"params": "facetFilters=[\"country:US\"]&hitsPerPage=1000"}` with `X-Algolia-Application-Id` / `X-Algolia-API-Key` -> `hits[]` |
| Movado | `SearchStores` POST, 200-nearest cap, no pagination | ~27-point US metro grid + dedup; report as a floor |
| Fossil | Where2GetIt widget POST | `hosted.where2getit.com/fossil/rest/locatorsearch`, appkey read from the widget page, `radius 5000, limit 10000` from (39.5, -98.35) -> all 1,528 US doors |
| Tudor | SSR country page | `/en/retailers/unitedstates` lists ~240 dealer detail URLs (state in the path); fetch each detail page `render=False` |
| Longines, Tissot | no reachable API | the only two left on a Web Search Agent (outside this skill) |
| Timex | no locator exists | every candidate path 404s; 0 rows by design, say so |
```bash
# Hamilton via stores.py
python3 $SK/stores.py --brand hamilton --out output/stores/hamilton.csv \
--browser-api 'https://www.hamiltonwatch.com/<store api>?lat=39.8283&lng=-98.5795&dist=9999' \
--page https://www.hamiltonwatch.com/en-us/store-locator/united-states/ \
--headers '{"Accept":"application/json"}' --records items --country-filter US \
--map '{"store_name":"name","store_type":"store_type","address":"address","city":"city","state_region":"region","country_code":"country_code","latitude":"latitude","longitude":"longitude","store_url":"url"}'
# Movado-style grid
python3 $SK/stores.py --brand movado --out output/stores/movado.csv \
--browser-api 'https://www.movado.com/<SearchStores>' --page https://www.movado.com/us/en/store-locator \
--method POST --headers '{"Content-Type":"application/json"}' \
--body '{"latitude":{lat},"longitude":{lng},"radius":500}' --records stores \
--grid $SK/../references/us_metro_grid.csv --dedup-key store_name,postal_code \
--map '{"store_name":"name","address":"address1","city":"city","state_region":"stateCode","postal_code":"postalCode","country_code":"countryCode","phone":"phone","latitude":"latitude","longitude":"longitude"}'
```
## Retailer recipes (carriage layer)
| Retailer | Grid | Render | Reference source |
|---|---|---|---|
| Macy's, Bloomingdale's | `/shop/featured/<brand>-watches/Pageindex/N`, tiles `li.sortablegrid-product` (skip `li.featured-item`: sponsored, repeated on every facet) | False | PDP JSON-LD description `Style #<ref>` |
| Kay, Jared, Zales (Signet) | SFCC grid, Akamai edge | True, warm cookies first, `cookies=` on each grid fetch | PDP |
| JCPenney, Belk, Helzberg, Fred Meyer | brand listing pages | per recon | PDP |
| Amazon, Sam's Club | outside this skill (Web Search Agent) | | |
Matching rule of thumb: exact manufacturer reference first; composite
(name + price + gender) only with a score, and two listings hitting the same
DTC SKU become `composite_ambiguous` and are excluded from gap stats.
## Recon prompt for a human-in-the-loop pass
When Extract alone cannot establish the site shape (bot wall, iframe
locator), a browser-driven recon by a person follows the same rules: US
locale only, watches only, read-only, no CAPTCHA work-arounds, every claim
with its URL and a verbatim example, `UNVERIFIED` otherwise, and never paste
a token value (report where the page reads it from). Save the report to
`output/audit/<brand>_recon.md`.