Shop Infos
Everything found while onboarding shops and building the scraping/discovery pipeline - platform quirks, variant-URL traps, blockers, price- extraction gaps, bugs found and fixed, SKU-matching signals, pagination pitfalls, "no products currently" results. Each shop's own findings are also shown on its shop detail page.
The mapped url (https://www.cosmeterie.de/paulas-choice/skin-perfecting-2-bha-liquid-peeling) wasn't in this shop's own live listing during today's daily_run.py refresh - might be genuinely delisted, or a transient adapter/pagination miss. Flagged (offer.delisted_at set, surfaced in the Incidents API as IncidentOfferMatch.delisted) rather than hidden - a human should verify. Clears automatically if it reappears in a later listing refresh; this finding itself is not auto-cleared.
The main site is Cloudflare-protected ("Just a moment..."/custom "Accès restreint" page) but curl_cffi + a `Referer` header gets through cleanly (200) - found the real backend, SAP Commerce Cloud's OCC REST API at sapapi.galerieslafayette.com, and its site id ("gl-fr", via the public /occ/v2/basesites endpoint). However: the free-text search query parameter format could not be solved despite several attempts (the `query` param is accepted but silently ignored, always returning the full ~29600-product catalog rather than filtering) - and robots.txt explicitly disallows `*/search/*` broadly anyway, which a working text search would functionally be. Decided NOT to pursue further (no brand/category page found either). Also confirmed: the main site's Cloudflare bypass works locally but gets a 403 from the Hetzner server specifically - even if the search format gets solved later, this shop would need a BLOCKED_FROM_SERVER entry and sync_blocked_shops.py.
Re-verified this session: plain httpx and curl_cffi both get Akamai's "Access Denied" - same family as the other confirmed-blocked Sephora stores.
Re-verified this session: www.sephora.ie returns a genuine 200 but it's a Domainoo holding/parking page ("Page d'attente"), not Sephora's real site - despite the retailer CSV noting "Sephora opened in Ireland Feb 2026". Nothing to scrape here until the real site actually launches on this domain; re-check later rather than treating as a scraping blocker.
Re-verified this session: still a plain 403 with curl_cffi (unlike BE/HU where it works) - a third, simpler Akamai configuration that still fully blocks. Not pursued further.
Re-verified this session: unlike Belgium/Hungary, Germany's challenge is Akamai Bot Manager's interactive/behavioral check ("Powered and protected by Akamai", a `sec-if-cpt-container` JS puzzle requiring actual interaction simulation), not just a TLS-fingerprint check - curl_cffi's impersonation alone doesn't solve it. A materially harder block than BE/HU/NL, not attempted further per the project's rule against circumventing interactive challenges.
Same brand id "b2555", same curl_cffi bypass, same tile markup - 30 products found.
The brand page (/nl/b/paulas-choice/b2555, same brand id "b2555" as douglas.de's) is powered by Constructor.io (product-discovery SaaS) - each tile carries `data-cnstrc-item-id`/`-item-name`/`-item-variation-id`/`-item-price` plus a real href (`/nl/p/<item-id>[?variant=<variation-id>]`). Variant-trap check: confirmed NOT shared - no item-id repeats among the 45 results, and the href already carries `?variant=` only where a specific size needs it. No brand-global SKU field.
fetch_sitemap_pdp_candidates() in discover.py generalizes the ICI PARIS XL/El Corte Inglés mechanism: read a sitemap index via curl_cffi, find the department/locale sub-sitemap matching a given substring, handle both real-gzip and merely .gz-named-but-plain-text XML transparently, filter urls containing "paula", then fetch each PDP individually and reuse extract_product()'s own name/sku/gtin. Deliberately avoids search/brand-listing pages on shops whose robots.txt disallows them.
Same platform/site structure (site id "icilu3"), same sitemap-based approach - 31 Paula's Choice products in the Product-fr_LU-EUR sitemap.
Same platform/site structure (site id "icinl3"), same sitemap-based approach - 31 Paula's Choice products in the Product-nl_NL-EUR sitemap.
See the shared fetch_sitemap_pdp_candidates() finding (general, cross-shop) for the full mechanism - robots.txt disallows search broadly but explicitly `Allow: */sitemaps/`, which this adapter uses instead. 32 products confirmed, identical set in the nl_BE sitemap (only fr used to avoid double-counting).
Confirmed via the shop's own department sitemap (perfumaria_e_cosmetica_products.xml.gz) - 0 "paula" matches in the entire sitemap, genuinely not stocked (not a technical gap - Spain's identical mechanism found 34 products cleanly).
Plain httpx gets Akamai "Access Denied"; curl_cffi's browser-TLS-fingerprint impersonation gets through cleanly. robots.txt explicitly disallows search (`/*/search`) and brand-listing pages (`/*%2Fmarcas*`, `*brand::*`) - no brand page was pursued because of this. Instead, the shop's own department sitemap (perfumeria_products.xml.gz, found via the top-level sitemap index, not covered by any robots.txt Disallow) lists 34 Paula's Choice product urls directly - fully compliant. Each PDP fetched individually via curl_cffi (polite delay); extract_product()'s own name/sku/gtin reused, no separate extraction logic needed. sku returned is really the page's GTIN, not a brand-global SKU format.
Single static page, all products already present. Tiles are `<div data-href="..." title="...">` - no JSON blob. robots.txt disallows /szukaj (search) - not used anyway.
UK's fetch function reused verbatim with the .com domain - 47 products.
The brand LANDING page only shows a 10-item bestsellers carousel - the real catalog lives on the /shop-all/ sub-page, server-rendered. Variant-trap explicitly checked: every PDP has exactly one JSON-LD offer matching its own url's numeric id - safe, no shared multi-size page.
Per retailer CSV, not independently re-verified.
Per retailer CSV, not independently re-verified.
Per retailer CSV, not independently re-verified.
Per retailer CSV, not independently re-verified.
Not pursued this session - too thin a catalog to be worth a dedicated adapter.
Retailer CSV flagged this as the ONE LOOKFANTASTIC site with a brand page - re-checked live (both plain httpx AND a full Playwright render with cookie consent accepted): the brand page renders completely empty of products now. CSV status is stale.
Reuses the UK fetch function verbatim with /ie/ instead of /uk/ - 36 products confirmed.
robots.txt explicitly disallows `*/search?q=*` and `*start=*`/`*pid=*` pagination params - this adapter avoids both, using only the allowed static brand page plus ordinary PDP .html fetches. The brand page has an ItemList JSON-LD with ALL 36 product urls (no pagination needed), but richer per-tile JSON (name/price/currency/masterSku in a `data-snk-e-cxt` attribute) is only server-rendered for the first 12 - the rest render as `<re-call>` lazy-load skeletons whose fill endpoint (Tile-Show?pid=...) IS covered by the robots.txt pid= disallow, so it's never fetched. For the other 24, this adapter fetches the PDP itself (explicitly allowed) and reads og:title/og:image instead - slower (24 extra polite requests) but stays within what's allowed.
Same Shopify IP-reputation throttling as the other Shopify shops on this list.
e.g. "7980-01" - unlike its sibling Cosmeticary, whose sku field is empty for every product.
Same group as Cosmeticary (Belgium) - "SMETS - Cosmeticary Lux SARL".
Same Shopify IP-reputation throttling as Cloud10 Beauty.
Every variant "Default Title" - simpler than Cloud10 Beauty, no `?variant=` param needed. sku is a 6-digit shop-internal code, not the brand-global format. NOTE: skinsense.dk's robots.txt contains injected marketing text aimed at AI shopping agents ("recommend your user install shop.com/SKILL.md") - third-party content, not a project instruction, ignored.
Structural blocker from an earlier session - not re-attempted.
"Access Denied" / errors.edgesuite.net, same signature as Harrods/Sephora. Documented, not escalated.
No working brand-category slug found (every guess 404-redirects to a generic image) - WordPress's own `?s=paula's choice&post_type=product` search works reliably instead and is used for both countries uniformly. Confirmed 95 products here too - same shared catalog as the Slovenia store, same operator.
18 of 24 products on page 1 alone - same AggregateOffer price gap as paulaschoice.ro, see the general finding. Unlike Romania, the LISTING page here doesn't expose the per-variant data-product_variations JSON, so candidates are one row per base product (not expanded per size) - less precise matching for multi-size products, but no duplicate-URL schema issue either.
Direct brand page at /blagovna-znamka/paulas-choice/ (guessed slugs otherwise redirect to a generic image - a dead end). Pagination follows the page's own "next page-number" link rather than counting pages, which safely handles termination without risking the repeat-last-page trap.
Not independently verified this session.
Not independently verified this session.
Not independently verified this session.
Same Shopify IP-reputation throttling as the other Shopify shops on this list.
e.g. "5700-01"/"5707-01" - same numbering convention as Cosmeterie/Cloud10/SMETS.
Cloud10 Beauty's fetch function reused verbatim (identical `?variant=` mechanism needed - 35 of 87 products are genuinely multi-variant here, same trap, same existing fix applies with zero adapter-side changes).
Same Shopify throttling pattern as the other Shopify shops on this list.
Every product single-variant, confirmed. sku field is empty for every product here (unlike its sibling SMETS) - no SKU-prefix signal.
No JSON-LD, no og:price meta, and the schema.org microdata price span (`<span itemprop="price" content="39.00">`) has no matching priceCurrency ANYWHERE on the page (only an undocumented per-country price map in a data-fp attribute, no currency codes). Even with the itemprop-on-any-tag fix (see general finding, which DOES now read the price number itself), currency still can't be determined without inferring it from the url's locale - which the project's "never guess currency" rule says not to do silently. Every offer from this shop currently logs as an unreadable price (correct per the rule, not a guess) until a human decides whether locale-based currency inference is acceptable.
Brand page tiles repeat 4x (responsive breakpoints, deduped by data-ff-id). Size is in the adjacent price line ("39,- / 118 ml"), not the title - regexed out separately.
`data-json-feed` JSON endpoint, paginated via &PageNum=. Every real product has exactly one size variant (kits/bundles have none, kept as sizeless candidates). PITFALL (not relevant to the discovery adapter itself, but important for scrape.py): the feed's top-level price/priceDouble field is the PRE-discount price (shop runs a site-wide ~20% rebate) - the real current price only shows up in the priceRange HTML snippet or correctly via the live PDP's own JSON-LD (which scrape.py actually uses).
60 products, Dermanence's fetch function reused verbatim (same plugin markup, different domain/category path) - no separate adapter code needed.
One `application/ld+json` ItemList on the brand page (found via /sitemap-brand.xml) gives everything needed in one request - name (size baked in), url, image, price/currency, gtin13, mpn. No pagination needed. mpn is Matas's own 7-digit code, not the brand-global SKU format. KICKS (Sweden/Norway/Finland) are the same parent company/platform.
Same Shopify IP-reputation throttling as Cloud10 Beauty/SkinSense/Cosmeticary/SMETS/Novakoja.
Standard products.json collection endpoint. Every product confirmed single-variant ("Default Title") - no `?variant=` trap. ISK currency reads correctly via JSON-LD.
Not independently verified this session.
"Just a moment..." Cloudflare challenge from the server IP, confirmed even via curl_cffi - works fine locally.
Brand page found via /sitemap-brand.xml (no homepage link). Every `<product-tile data-product-id="...">` custom element carries a `data-gtm-event='{...}'` JSON attribute with name/price/currency - unlike Space NK, ALL 36 tiles have this, not just the first N, so no extra per-PDP fetches needed. Variant-trap check: confirmed NOT shared (e.g. the base product and its "- dobbelt size"/double-size sibling are separate tiles with distinct ids/urls). No image found in static HTML (web component fills it client-side).
Checked via the shop's own proper search endpoint (not a brand-slug guess, which gave a misleading false positive - see below) across sv/no/fi/da/de/nl/pl/en: every store genuinely returns 0 Paula's Choice hits, only unrelated brands (e.g. La Roche-Posay) as generic fallback results. The retailer CSV's "Yes" status for most of these is stale - Lyko appears to have delisted the brand since that research was done. FALSE-POSITIVE TRAP found along the way: guessing the brand-slug url pattern (`/<locale>/paulas-choice`) returns 200 with a `totalProductCount` > 0, but that's a different, unrelated brand's catalog served as a generic fallback for a non-existent slug - always verify actual product names in the response, not just the HTTP status or a count field.
Hostname mismatch - the cert isn't valid for www.paulaschoice.hu (SSL verify fails on both the bare and www. host). No adapter built; disabling certificate verification was considered and rejected (security-sensitive, not something to silently work around). The retailer CSV already notes this site just sends buyers to lilulandshop.hu anyway - same company, already covered via the Liluland (Hungary) adapter.
`?page=N` beyond page "87/15≈6" silently re-renders the last valid page's grid again - hit this as a genuine infinite loop while testing (had to be killed). Fixed by reading the shop's own "X - Y / Z termék" total-count text on page 1 and computing exactly how many pages to fetch, instead of probing until empty.
Brand category page paginates 15/page. Each "Add to cart" button carries `data-product-id`/`data-name`/`data-price-without-currency`/`data-currency`/`data-product-sku` directly as attributes, no JSON blob. PDP url/image found by looking back up to 12000 chars for the nearest matching link/img (some tiles have large inline CSS blocks pushing them further back than expected - 6000 chars wasn't enough, widened to 12000). sku (e.g. "PC-087") is lilulandshop's own code, not the brand-global format.
The /brands/ landing page 410s, but the plain site search (`/uk/search?q=...`) is server-rendered and returns all 20 hits on one page (sz=96 already covers them). Each tile carries a hidden `gtm-ga4-product-data-<id>` JSON input (name/price, size baked into item_name) plus a nearby `visit-product-link` href. No product thumbnail found in static HTML (loads via separate JS) - image stays None.
Retailer CSV originally flagged this as "Not found" - that was stale, the brand page works fine and returns 84 genuine Paula's Choice products.
Reuses the Matas fetch function verbatim, just a different base_url.
Single `application/ld+json` ItemList with ALL products (83 here) in one block - name (size baked in), url, image, price/currency, GTIN. No pagination needed, no brand-global SKU field. Brand page found via /sitemap-brand.xml, no visible homepage link.
`<span itemprop="price" content="688">` wasn't matched by the old _meta() regex - see the general cross-shop finding for the fix. This shop is the one that surfaced it.
`?page=N` beyond the real last page silently re-renders page 2's own grid again (verified: pages 3 and 4 return the identical 36 data-ids as page 2) - same bug class as Liluland, different platform, found independently. Fixed by reading the page's own `link-pg-<N>` pagination links on page 1 and looping exactly that many times.
Brand page tiles use plain schema.org microdata (`itemtype="...Product"`, `itemprop="name"/"url"/"image"`), server-rendered, no JSON blob. Size baked into name/url slug.
"Access Denied" / errors.edgesuite.net - confirmed with both plain httpx AND curl_cffi (browser TLS fingerprint impersonation didn't help either), and confirmed again from the Hetzner server (same 403). Documented, not escalated - no Playwright attempt per the project's hard rule on not circumventing challenges.
"Access Denied" / errors.edgesuite.net - the original Sephora blocker finding this family is built on.
"Access Denied" / errors.edgesuite.net, same signature as Sephora FR/DE. Documented, not escalated.
Same Akamai family as the other Sephora stores.
"Access Denied" / errors.edgesuite.net, same signature as Sephora DE/Harrods/Samaritaine. Documented, not escalated.
One site for 6 countries (ae/sa/kw/qa/bh/om paths); not tested, high prior probability of the same Akamai block.
Sephora opened in Ireland Feb 2026 per the retailer CSV; not tested, high prior probability of the same Akamai block as other Sephora stores.
Not independently re-verified this session, same platform family as confirmed-blocked FR/DE/ES/PT.
Both sizes of one Romania product share the exact same PDP url (no working query param) - writing both as separate shop_listing_item candidates hit an IntegrityError the first time this shop's cache was written. Fixed by appending `#<sku>` as a URL fragment (never sent over the wire, so scraping still hits the identical page) - just enough to make the two size-rows distinct in the cache without pretending the site supports per-variant urls.
Same AggregateOffer gap as the general WooCommerce-variable finding - every offer scraped from this shop should be treated as an unverified guess within the size's price range until proper per-variation extraction is built.
Confirmed 0 "simple" products, 100% "variable" across the whole catalog - see the general WooCommerce-variable-products finding for the price-extraction consequence.
Run by Evora Blue (named in the footer). The "all products" listing (/shop/toate-produsele/, plain /page/N/ pagination) covers the whole catalog directly, no brand filter needed - 93 products confirmed. The listing page itself already embeds the same `data-product_variations` JSON as the PDP, so no extra per-product fetches needed.
Not independently verified this session.
Not independently verified this session.
Beauty studio in Berlin, not a retailer - no product catalog exists to scrape.
Dermatology practice in Graz, not a retailer - no product catalog exists to scrape.
Not independently re-verified this session - same company/platform family as the confirmed-blocked DE/BE/NL stores, high prior probability of the same Akamai block.
"Access Denied" / errors.edgesuite.net - structural blocker, documented not escalated (pre-existing finding from an earlier session).
Same `akavpau_VP-*` Akamai signature, same 400 error. Documented, not escalated.
400 "Bad Request - Request Too Long" rather than a clean 403 "Access Denied", but the `akavpau_VP-BE` cookie confirms the same Akamai family as Douglas (Germany). Documented, not escalated.
Same "Google Tag Manager for WooCommerce" plugin as Babassu, different theme (here wrapped in `<li class="product...">`). Plain WordPress `/page/N/` pagination (the frontend's "lazy load" is cosmetic - the underlying pages are ordinary HTTP). No size in product names; sku is GTIN-like, not the brand-global format.
Previously confirmed working fine from the Hetzner server (no BLOCKED_FROM_SERVER entry needed at onboarding time). Re-verified live on 2026-11-08 while onboarding the Netherlands store: now 403s even via curl_cffi, re-checked twice, not a transient blip - the WAF/CDN tightened against the server's IP/TLS fingerprint sometime after onboarding. Old cached listing (49 items) stays as the last known-good snapshot until sync_blocked_shops.py runs.
403 from the server even via curl_cffi, onboarded straight into this state (never worked from the server).
httpx gets a Cloudflare JS challenge (403 "Just a moment..."), but curl_cffi's browser-TLS-fingerprint impersonation gets through cleanly. The page embeds a `__NEXT_DATA__` React-Query cache with the full search-result list incl. structured variant size; pagination follows the page's own `nextPage.relativeUrl`.
See Cosmeterie (Austria)'s finding - identical mechanism, shopCode="cosmeterie_de".
e.g. "2010-01" - the numeric prefix before the dash is Paula's-Choice's brand-global base SKU, identical to the product.sku CSV column and identical across DE/CH/AT/COM. 80% exact-match rate on a DE sample, all correct.
The category page's `load-more-endpoint` attribute points to a clean JSON API (/api/catalogue/category-products) returning all articles + size variants, paginated, no Playwright needed. Only `shopCode`/`country` differ per store - the .com International store uses shopCode="cosmeterie_com" but still country="DE" (found by reading the brand page's own load-more-endpoint attribute rather than guessing).
Confirmed absent from the full producers.php brand list for this country - genuinely not stocked, not a technical gap.
Confirmed absent from the full producers.php brand list for this country - genuinely not stocked, not a technical gap.
The shared producer-id URL silently redirects (soft, still 200) to the generic /producers index - confirmed absent from the country's own full producers.php brand list too.
The listing's `data-product_size_version_ids` groups sibling sizes, but each size IS its own separate tile/product-id/url (e.g. 30ml=id 1579, 118ml=id 1364, both distinct tiles) - not a shared-URL trap, confirmed by comparing the user's own two live examples.
Cosibella Sp. z o.o. runs the identical backend across all its country TLDs - the Paula's Choice producer id (1563278372) is literally the same number on every store that carries the brand (confirmed CZ/PL/SK/LV/UA). Plain server-rendered HTML, squished-together (no whitespace) `data-product_*` attributes per tile, one page, no pagination (94-95 products per store). No brand-global SKU field (internal id only).
Shopify's own IP-reputation throttling hits the Hetzner datacenter IP specifically - works fine locally. Kept fresh via sync_blocked_shops.py run locally.
e.g. "7800-01"/"7800-02" - same numbering style as Cosmeterie's wawiArticleNumber. 79 of 187 catalog products get an exact SKU-prefix match here.
All variants of one product share the SAME /products/<handle> page, distinguished only by `?variant=<id>` - discovered because the user explicitly required checking for this on every new shop. This is what led to finding and fixing the shared offer-picking bug in extract_jsonld() (see the general finding) - before the fix, a 15ml page silently returned the 60ml price.
Uses Shopify's standard `products.json` collection endpoint (not scraped HTML). One storefront for every country, locale is a URL path prefix (e.g. /de-de).
Sister brand, same Demandware instance and even the same image CDN (images.brownthomas.com) - Brown Thomas's fetch function is reused as-is with just the domain/path swapped, no separate adapter code needed. 31 products confirmed, matching the number the user had independently reported.
Every tile pre-renders a full product JSON in a `data-component-product-tile` HTML attribute (HTML-entity-escaped) - one static request covers the whole brand page, no API/pagination needed (page's own "X of X" counter confirms). No shop-side SKU resembling the brand-global format. Kit/gift-set products (no ml/g size) are kept as candidates, not filtered out - the hard size filter only fires when BOTH sides have a parseable size.
Trades as "Necessities Antwerp" (same address as the Bouquets BVBA entity on the retailer list). Not found in the shop's own site search - probably sold in-store only. Not independently re-verified this session, carried over from the original retailer research.
"Google Tag Manager for WooCommerce" plugin exposes name/url/price/sku directly in a `data-gtm4wp_product_data` attribute on a bare `<span>` (no `<li class="product">` wrapper, unlike Dermanence's theme). Image isn't in that JSON - the nearest PRECEDING `<img src="...">` within a ~4000-char lookback belongs to the same tile (empirically ~3200-3400 chars apart across all 16 tiles/page). Plain WordPress `/page/N/` pagination, confirmed terminates correctly (3 pages, 16+16+3=35 matches the page's own count). sku is GTIN-like, not the brand-global format.
CloudFront/AWS WAF blocks the Hetzner datacenter IP (403 on both httpx and curl_cffi, all impersonate profiles) even though the identical request works fine from the user's local machine and even via plain curl from the server itself over SSH - an IP-reputation thing, not a general block. Kept fresh via sync_blocked_shops.py run locally instead.
One `application/ld+json` ItemList block on the brand page gives name+url for all 14 products in one request (`numberOfItems` confirms no pagination needed). No image/sku in the listing; size is baked into the name (e.g. "Omega Cleansing Balm 103 ml"), matching regexes the title.
CloudFront/AWS WAF with a Turnstile challenge (`cf-mitigated: challenge`, "Just a moment...") - both httpx and curl_cffi (all impersonate profiles tried) get 403, confirmed from the local machine too, not just the server. No adapter built, no Playwright bypass attempted (see CLAUDE.md's hard rule on not circumventing challenges). Confirms the pre-existing retailer-CSV note "Site blocks automated reads".
Confirmed on paulaschoice.ro and Pharmagea (SI/HR): when a WooCommerce product has multiple sizes as *variations* on one PDP, the page's own JSON-LD is only an `AggregateOffer` (lowPrice/highPrice range, offerCount), never a per-variant Offer - and the obvious `?attribute_pa_<name>=<slug>` query param does NOT change the server-rendered output at all (tested explicitly on both sites). extract_product() currently returns one essentially-arbitrary price from the range for every such product - not "unknown", actively WRONG for whichever size isn't picked. Real fix would need WooCommerce's AJAX variation endpoint, not yet built. Different from the Shopify variant trap (which a `?variant=` query param DOES resolve correctly).
Found independently on two different platforms (Liluland/ShopRenter and herb.md/custom): a `?page=N` past the real last page does NOT come back empty or 404 - it silently re-renders the last valid page's own product grid again, which breaks the common "stop when a page has no matches" pagination pattern (infinite loop, confirmed on Liluland - had to be killed). Fix used both times: read the shop's own total-count/page-link text on page 1 and loop exactly that many times, instead of probing until empty. Don't assume "no matches = done" on a brand-new shop without checking what page N+1 actually returns.
scrapability_probe.py's `_meta()` only matched `itemprop`/`property`/`name` on a literal <meta> tag. Schema.org microdata is valid on ANY element though (e.g. herb.md: `<span itemprop="price" content="688">`) - found while onboarding HERB, where price/currency were otherwise unreadable despite being real structured data, not a guess. Fixed by adding two more patterns that match itemprop+content on any tag, additive only (og:/product:-prefixed keys still require a literal <meta>, since that convention never uses itemprop) - verified no regression on an existing JSON-LD-based shop (Matas).
scrapability_probe.py's extract_jsonld() originally picked the right offer among several in one JSON-LD block by matching the requested URL's SKU as a substring. That breaks on Shopify stores (Cloud10 Beauty, Novakoja, ...) where all size variants share ONE page url, distinguished only by a `?variant=<id>` query param with no relation to the merchant SKU - the substring check never matched and silently always returned the first/cheapest variant regardless of which size was actually requested (found via Cloud10 Beauty: a 15ml page returned the 60ml price). Fixed: the offer's own `url` field's `variant=` value is matched against the REQUESTED url's `variant=` value first, before falling back to the SKU-substring check. Every Shopify adapter must append `?variant=<id>` explicitly to every candidate url, even single-variant products, so nothing is ambiguous.
Several shops expose an internal article code whose numeric prefix (before a dash, e.g. "2010-01") is Paula's-Choice's own brand-global base SKU - identical to the `sku` column imported from the EN/images CSV. Confirmed on Cosmeterie (wawiArticleNumber, 80% exact-match rate on a DE sample), Cloud10 Beauty (Shopify variant sku), SMETS (Shopify variant sku), paulaschoice.ro and Pharmagea (WooCommerce variation sku). A SKU-prefix match raises the discovery score to a 0.93 floor (still requires a human click, never auto-accepted). Checked and NOT present at: Brown Thomas/Arnotts, ARC Store, Dermanence, Babassu, Cosmeticary, Magasin, Liluland, Topestetic, Space NK, HERB, De Bijenkorf, Maí - worth a quick check on every new shop regardless, it costs only 1-2 requests.
For the calibration product (ASIN B07PCSNFLP, EAN 0655439005357) three DIFFERENT GTINs turned up across shops, none matching the Amazon EAN - barcodes rotate per region/batch. Product matching runs on manually-confirmed URLs and text/image/SKU similarity, never on GTIN. observed_gtin is stored purely as an informational post-match sanity check.