unpopular brand generation

This commit is contained in:
sriram
2026-09-10 17:51:05 +05:30
parent d5a23f6456
commit e1a5962f82
11 changed files with 7629 additions and 3 deletions

View File

@@ -105,7 +105,7 @@ from app.infrastructure.settings import (
BRAND_DISCOVERY_USE_LLM,
BRAND_DISCOVERY_USE_OFF,
)
from app.services import active_brands, ollama_service, retail_presence
from app.services import active_brands, brand_store, ollama_service, retail_presence
from app.services.brand_registry import (
get_fssai_license,
get_known_sub_brands,
@@ -481,6 +481,33 @@ def _from_open_facts(brand: str, *, refresh: bool = False) -> List[Dict[str, Any
return out
def _from_brand_store(brand: str) -> List[Dict[str, Any]]:
"""The brand's own shop catalogue - the strongest source there is.
Open Food Facts is crowd-sourced and, for a regional South Indian brand,
usually empty: Udhaiyam 0 hits, Gopuram 0, Tenali Double Horse 0. Their
storefronts publish the whole catalogue as structured JSON - Double Horse
99 rows, Gopuram 122 - with real names, prices, images and sometimes a
valid EAN-13.
CACHE ONLY. `scripts/backfill_brand_stores.py` does the fetching, exactly
as the retail check is split, so a discovery preview never blocks on
somebody's storefront being slow or down.
Pack sizes are deliberately left as the store stated them, which is often
nothing: most of these shops put the size in the PRODUCT NAME rather than
in a variant ("Roasted Vermicelli 400g"). `_resolve_sizes` already reads a
title's own size first and is the one place that rule should live -
measured, that lifts Double Horse from 14 sized rows to 94 of 99.
"""
try:
rows = brand_store.fetch_store_catalogue(brand, None, live=False)
except Exception as exc: # noqa: BLE001 - a bad cache must not kill discovery
logger.warning("Brand store catalogue unreadable for %r: %s", brand, exc)
return []
return list(rows or [])
# ---------------------------------------------------------------------------
# Source B - Ollama (breadth and prose)
# ---------------------------------------------------------------------------
@@ -669,6 +696,21 @@ def _score(sources: Sequence[str], evidence: Optional[str]) -> float:
"""
has_off = "off" in sources
has_llm = "llm" in sources
has_store = "store" in sources
# THE BRAND'S OWN SHOP OUTRANKS OPEN FOOD FACTS.
#
# OFF is crowd-sourced and, for these brands, mostly absent or wrong - its
# two "Naga" rows are a British pickle. A manufacturer's own storefront is
# first-party: it is the definitive list of what the company sells, at the
# sizes and prices it sells them, with its own photographs.
#
# Scored above `off` rather than merged into it so the preview can say
# which source a row came from, and so a future disagreement between the
# two is visible rather than silently resolved.
if has_store and (has_off or has_llm):
return 1.0
if has_store:
return 0.95
if has_off and has_llm:
return 1.0
if has_off:
@@ -866,6 +908,7 @@ def discover_brand_products(
deadline_seconds: float = BRAND_DISCOVERY_DEADLINE_SECONDS,
use_openfacts: bool = BRAND_DISCOVERY_USE_OFF,
use_llm: bool = BRAND_DISCOVERY_USE_LLM,
use_store: bool = True,
require_evidence: bool = True,
refresh_corpus: bool = False,
) -> DiscoveryResult:
@@ -898,6 +941,24 @@ def discover_brand_products(
if key:
off_keys.setdefault(key, candidate)
# The brand's own shop. Read from cache only - see `_from_brand_store`.
store_candidates = _from_brand_store(brand) if use_store else []
store_keys = {}
for candidate in store_candidates:
key = _normalise_title(brand, candidate.get("title") or "")
if key:
store_keys.setdefault(key, candidate)
if store_candidates:
logger.info("%s: %d products from the brand's own shop", brand, len(store_candidates))
elif use_store and not off_candidates:
warnings.append(
"Neither Open Food Facts nor a brand storefront has anything for "
"this brand. Every row below rests on the language model alone - "
"review them individually. If this brand has its own online shop, "
"adding it to brand_registry.BRAND_STORE_DOMAINS and running "
"scripts/backfill_brand_stores.py will populate a real catalogue."
)
llm_candidates: List[Dict[str, Any]] = []
if use_llm:
@@ -920,7 +981,10 @@ def discover_brand_products(
# and adopts the size of the one it joins.
merged: List[Dict[str, Any]] = []
keys: List[str] = []
for candidate in off_candidates + llm_candidates:
# STORE FIRST, then Open Food Facts, then the model. The merge fills a
# blank from whatever comes later, so order IS precedence: the brand's own
# shop outranks a crowd-sourced database, which outranks a guess.
for candidate in store_candidates + off_candidates + llm_candidates:
title = candidate["title"]
key = _normalise_title(brand, title)
target = None
@@ -1056,7 +1120,8 @@ def discover_brand_products(
)
if product is None:
continue
if require_evidence and "off" not in product.sources and not product.evidence:
if (require_evidence and "off" not in product.sources
and "store" not in product.sources and not product.evidence):
dropped += 1
continue
products.append(product)

View File

@@ -261,8 +261,70 @@ BRAND_ALIASES = {
# that spells it without the s. Removing it re-opens the bug.
"haldiram": "haldirams",
"haldiram's": "haldirams",
# --- Regional South Indian brands -------------------------------------
# Not in Open Food Facts (Udhaiyam 0 hits, Gopuram 0, Tenali Double Horse
# 0), so their catalogues come from the brands' own storefronts - see
# BRAND_STORE_DOMAINS below and app/services/brand_store.py.
"udhayam": "udhaiyam",
"udhaiyam dhall": "udhaiyam",
"uthayam": "udhaiyam",
"gopuram products": "gopuram",
#
# TENALI DOUBLE HORSE IS A DIFFERENT COMPANY FROM DOUBLE HORSE.
#
# Double Horse is Manjilas, in Kerala; Tenali Double Horse is an Andhra
# rice and rava brand. They share a name and nothing else.
#
# This entry is LOAD-BEARING and must stay a DIRECT one. `_contains_word`
# matches whole words in either direction, and "double horse" is a whole
# word sequence inside "tenali double horse" - so the moment any
# "double horse" key exists, the fallback loop folds the Andhra brand into
# the Kerala one and both companies' products land in a single table.
# `resolve_parent_brand` checks BRAND_ALIASES directly before it ever
# reaches that loop, which is the only reason this works.
#
# BOTH names need a direct self-entry, because `_contains_word` is applied
# in BOTH directions. Registering only the longer one moved the collision
# rather than fixing it - measured: with only "tenali double horse" present,
# resolve_parent_brand("Double Horse") returned "tenali double horse",
# because "double horse" is a whole word sequence inside the alias key and
# the loop matches `_contains_word(alias, key)` too. Kerala's brand was
# swallowed by Andhra's instead of the other way round.
#
# A direct hit short-circuits before the loop, so each of these entries
# protects the OTHER brand. Remove either one and the two merge.
"tenali double horse": "tenali double horse",
"tenali doublehorse": "tenali double horse",
"double horse": "double horse",
"doublehorse": "double horse",
}
# A brand's own online shop, verified by a person.
#
# NOT resolved by search, on purpose. `retail_presence.resolve_brand_domain`
# answers "Naga" with `cityofnagacebu.gov.ph` - the Philippine city - and a
# wrong domain here does not cost one bad row, it imports a hundred of another
# company's real products under this brand's name. `brand_store` treats a
# domain from this map as already checked and applies its heuristic identity
# guard only to domains that came from search.
#
# Add a brand here only after opening the site and confirming it is theirs.
BRAND_STORE_DOMAINS: dict[str, str] = {
"aachi": "aachifoods.com",
"anil": "shop.theanilgroup.com",
"double horse": "doublehorse.in",
"gopuram": "gopuramproducts.com",
"udhaiyam": "udhaiyamdhall.com",
# Naga and Tenali Double Horse are deliberately ABSENT: their official
# sites have not been confirmed. An absent brand simply has no store
# catalogue, which is the safe outcome; a guessed one is not.
}
def get_brand_store_domain(brand: str) -> Optional[str]:
"""The verified storefront for a brand, or None if we do not have one."""
return BRAND_STORE_DOMAINS.get(resolve_parent_brand(brand).lower().strip())
DEFAULT_ALIASES = BRAND_ALIASES

571
app/services/brand_store.py Normal file
View File

@@ -0,0 +1,571 @@
"""
Read a brand's real catalogue from the brand's own shop.
WHY THIS FILE EXISTS
--------------------
`off_bulk.fetch_brand_corpus()` is the only thing in this codebase that can
ENUMERATE a brand's products. Everything else - `retail_presence`,
`sku_service`, `manufacturer_site` - is a per-product VERIFIER: it needs a
product name as input and answers yes or no. So for a brand Open Food Facts has
never heard of there is nothing to verify, and the catalogue is whatever
`qwen2.5:1.5b` invents.
Measured 2026-09-10, Open Food Facts hits: Udhaiyam 0, Tenali Double Horse 0,
Gopuram 0, Double Horse 14, and Naga 2 - where both "Naga" rows are a UK /
Bangladeshi pickle brand, not the Tamil Nadu one. Regional South Indian brands
are simply not in that database.
They are, however, on their own shop, and modern storefronts publish their
whole catalogue as structured JSON:
doublehorse.in Shopify 91 products in one request
gopuramproducts.com WooCommerce 100+
aachifoods.com Shopify 123 (Open Food Facts has 61)
shop.theanilgroup.com Shopify 52 (Open Food Facts has 8)
and each record carries what the pipeline needs, from the manufacturer itself:
title Soan Papdi Ghee
product_type Snacks vendor Aachifoods
variants[0] title='200g' price=76.00 sku='8904209319340' grams=215
images[0] https://cdn.shopify.com/.../ghee-soan-papdi.webp
That SKU passes `validators.validate_barcode` - a real EAN-13 on the Indian 890
GS1 prefix. Name, category, pack size, price, images and a barcode, none of it
guessed.
THE RISK THIS MODULE CARRIES, AND THE GUARD ON IT
--------------------------------------------------
Every other source here verifies one product at a time, so a mistake costs one
bad row. This one ASSERTS a hundred products at once, so pointing it at the
wrong site imports a hundred bad rows under a real brand's name - and they would
look impeccable, because they are a real catalogue, just somebody else's.
That is not hypothetical. `retail_presence.resolve_brand_domain("Naga")`
returns `cityofnagacebu.gov.ph`, the website of Naga City in the Philippines.
So: the domain comes from a human-curated map (`brand_registry.
BRAND_STORE_DOMAINS`), and the catalogue is additionally checked against the
brand before any of it is accepted - see `_store_identity_ok`. A catalogue that
fails is rejected WHOLE. Half a foreign catalogue is not better than all of it.
STRUCTURED ENDPOINTS ONLY
-------------------------
Shopify's `products.json`, WooCommerce's Store API, and failing those a product
sitemap plus each page's JSON-LD. No HTML scraping and no Playwright: these are
documented, stable, paginated interfaces, and a brand that offers none of them
is better reported as "no store catalogue" than guessed at.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
import requests
from app.services.category_units import COUNT_UNITS, VOLUME_UNITS, WEIGHT_UNITS, parse_unit
from app.services.enrichment.barcode.matching import brand_matches
from app.services.enrichment.barcode.retry import with_retry
from app.services.enrichment.barcode.validators import validate_barcode
logger = logging.getLogger(__name__)
# The same identifying User-Agent off_bulk uses, deliberately NOT the
# browser-spoofing strings elsewhere in this codebase. These are small
# companies' own websites rather than marketplaces, and a crawler reading them
# should say who it is and how to be reached.
USER_AGENT = "BrandCatalogRAG/1.0 (suriya@tenext.in)"
# Same figure off_bulk uses between pages.
PAUSE_SECONDS = 2.0
_BACKEND_DIR = Path(__file__).resolve().parent.parent.parent
CACHE_DIR = _BACKEND_DIR / "data" / "cache" / "brand_store"
TIMEOUT_SECONDS = 25
SHOPIFY_PAGE_SIZE = 250
WOO_PAGE_SIZE = 100
MAX_PAGES = 12 # 3000 Shopify products; no FMCG brand here is close
# Units a pack size may legitimately carry - the same set brand_discovery uses,
# imported from category_units rather than from brand_discovery, because
# brand_discovery imports THIS module and the reverse would close a cycle.
_SIZE_UNITS = WEIGHT_UNITS | VOLUME_UNITS | COUNT_UNITS
# Shopify's placeholder when a product has no real variants.
_NO_VARIANT = {"default title", "default", ""}
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
@with_retry(max_attempts=3, min_wait=2.0, max_wait=10.0)
def _get(url: str, params: Optional[Dict[str, Any]] = None) -> requests.Response:
return requests.get(
url, params=params or {},
headers={"User-Agent": USER_AGENT, "Accept": "application/json, */*"},
timeout=TIMEOUT_SECONDS,
)
def _json_or_none(resp: requests.Response) -> Optional[Any]:
"""Parse a JSON body, tolerating a UTF-8 BOM.
WooCommerce really does serve one: gopuramproducts.com's Store API response
begins with EF BB BF, and `resp.json()` raises
"Unexpected UTF-8 BOM (decode using utf-8-sig)". `brand_sync._read_catalog`
already reads seed files as utf-8-sig for the same reason.
"""
if resp.status_code != 200:
return None
content_type = (resp.headers.get("content-type") or "").lower()
if "json" not in content_type:
return None
try:
return json.loads(resp.content.decode("utf-8-sig"))
except Exception as e: # noqa: BLE001 - a malformed body is "no catalogue"
logger.debug("Unparseable JSON from %s: %s", resp.url, e)
return None
# ---------------------------------------------------------------------------
# Sizes
# ---------------------------------------------------------------------------
def canonical_size(raw: Optional[str]) -> Optional[str]:
"""A pack size in the one form the pipeline hashes consistently.
Mirrors `brand_discovery._canonical_size` exactly - "200 g" and "200g" are
the same pack, but `build_image_id` slugifies them differently and would
make two permanent rows. Returns None for anything that is not a size: a
bare count, "Default Title", a colour.
"""
if not raw or not str(raw).strip():
return None
value, unit = parse_unit(str(raw))
if value is None or not unit or unit not in _SIZE_UNITS:
return None
number = str(int(value)) if float(value).is_integer() else str(value)
return f"{number}{unit}"
# ---------------------------------------------------------------------------
# Identity - the guard that matters
# ---------------------------------------------------------------------------
def _store_identity_ok(brand: str, domain: str, vendors: Sequence[str],
aliases: Optional[Sequence[str]] = None) -> bool:
"""Does this catalogue actually belong to `brand`?
Accepts on either signal, because neither alone is reliable:
- the DOMAIN names the brand (`doublehorse.in` for Double Horse), or
- the catalogue's own `vendor` field names it (Shopify's "Aachifoods").
Some stores leave `vendor` as the shop's theme name or blank, so requiring
it would reject good catalogues; and a brand can trade on a domain that
does not contain its name, so requiring that would too.
What this DOES stop is the case it was written for: a catalogue fetched
from a site belonging to neither the brand's domain nor its name, which is
how `cityofnagacebu.gov.ph` would otherwise have become Naga's product
list.
"""
aliases = list(aliases or [])
stem = (domain or "").split(".")[0].lower()
domain_words = set(re.split(r"[^a-z0-9]+", stem))
brand_words = [w for w in re.split(r"[^a-z0-9]+", (brand or "").lower()) if len(w) > 2]
if brand_words and all(w in domain_words for w in brand_words):
return True
# PREFIX, NOT SUBSTRING. A brand's own domain is the brand followed by a
# qualifier - "gopuramproducts", "udhaiyamdhall", "doublehorse". A domain
# that merely CONTAINS the brand somewhere in the middle is a different
# organisation that happens to share a word:
#
# "gopuramproducts".startswith("gopuram") -> True, correct
# "cityofnagacebu".startswith("naga") -> False, correct
# "cityofnagacebu" contains "naga" -> True, THE BUG
#
# This is the whole reason `_pick_brand_domain` handed back the website of
# Naga City in the Philippines as a Tamil Nadu food brand's catalogue.
if brand_words and stem.startswith("".join(brand_words)):
return True
for vendor in vendors:
if vendor and brand_matches(vendor, brand, aliases):
return True
return False
# ---------------------------------------------------------------------------
# Shopify
# ---------------------------------------------------------------------------
def fetch_shopify(domain: str) -> Optional[List[Dict[str, Any]]]:
"""Raw Shopify products, or None when this is not a Shopify store."""
out: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
try:
resp = _get(f"https://{domain}/products.json",
{"limit": SHOPIFY_PAGE_SIZE, "page": page})
except Exception as e: # noqa: BLE001 - unreachable is not a verdict
logger.debug("Shopify fetch failed for %s p%d: %s", domain, page, e)
return out or None
data = _json_or_none(resp)
if not isinstance(data, dict) or "products" not in data:
return out or None
products = data.get("products") or []
if not products:
break
out.extend(products)
if len(products) < SHOPIFY_PAGE_SIZE:
break
time.sleep(PAUSE_SECONDS)
return out or None
def _shopify_candidates(product: Dict[str, Any]) -> List[Dict[str, Any]]:
title = str(product.get("title") or "").strip()
if not title:
return []
category = str(product.get("product_type") or "").strip() or None
images = [
str(i.get("src")) for i in (product.get("images") or [])
if isinstance(i, dict) and i.get("src")
]
variants = product.get("variants") or []
rows: List[Dict[str, Any]] = []
for variant in variants:
if not isinstance(variant, dict):
continue
raw_size = variant.get("title")
if str(raw_size or "").strip().lower() in _NO_VARIANT:
# No real variant axis. The size, if any, is in the product name,
# and `_resolve_sizes` reads a title's own size first anyway.
raw_size = variant.get("option1")
size = canonical_size(raw_size)
# The SKU is a barcode only when it IS one. Shopify shops put anything
# here - Aachi puts a real EAN-13 ("8904209319340"), Gopuram leaves it
# blank, others use an internal code. `validate_barcode` decides, so a
# shop's internal numbering never reaches the barcode column.
sku = str(variant.get("sku") or "").strip()
barcode = validate_barcode(sku) if sku else None
if not barcode:
raw_barcode = str(variant.get("barcode") or "").strip()
barcode = validate_barcode(raw_barcode) if raw_barcode else None
rows.append({
"title": title,
"category": category,
"size": size,
"barcode": barcode,
"price": _decimal_or_none(variant.get("price")),
"image_url": images[0] if images else None,
"image_urls": images,
"vendor": str(product.get("vendor") or "").strip() or None,
"source": "store",
})
if not rows:
rows.append({
"title": title, "category": category, "size": None, "barcode": None,
"price": None, "image_url": images[0] if images else None,
"image_urls": images,
"vendor": str(product.get("vendor") or "").strip() or None,
"source": "store",
})
return rows
# ---------------------------------------------------------------------------
# WooCommerce Store API
# ---------------------------------------------------------------------------
def fetch_woocommerce(domain: str) -> Optional[List[Dict[str, Any]]]:
"""Raw WooCommerce Store API products, or None when unavailable.
udhaiyamdhall.com is WooCommerce but answers this endpoint with 403, which
is why the sitemap tier below exists.
"""
out: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
try:
resp = _get(f"https://{domain}/wp-json/wc/store/products",
{"per_page": WOO_PAGE_SIZE, "page": page})
except Exception as e: # noqa: BLE001
logger.debug("Woo fetch failed for %s p%d: %s", domain, page, e)
return out or None
data = _json_or_none(resp)
if not isinstance(data, list):
return out or None
if not data:
break
out.extend(data)
if len(data) < WOO_PAGE_SIZE:
break
time.sleep(PAUSE_SECONDS)
return out or None
def _woo_price(prices: Dict[str, Any]) -> Optional[float]:
"""WooCommerce reports MINOR UNITS.
gopuramproducts.com returns `price: "5500"` with `currency_minor_unit: 2`,
which is Rs55.00 and not Rs5,500. Read straight, every price on the site is
a hundred times too large - and `product_validator.validate_price_range`
would then reject the row as an implausible price, so the failure would
surface as "this brand's products are all wrong" rather than as a units bug.
"""
raw = prices.get("price")
if raw is None or str(raw).strip() == "":
return None
try:
minor = int(prices.get("currency_minor_unit", 2))
return float(raw) / (10 ** minor)
except Exception: # noqa: BLE001
return None
def _woo_candidates(product: Dict[str, Any]) -> List[Dict[str, Any]]:
title = str(product.get("name") or "").strip()
if not title:
return []
categories = [
str(c.get("name")).strip() for c in (product.get("categories") or [])
if isinstance(c, dict) and c.get("name")
]
images = [
str(i.get("src")) for i in (product.get("images") or [])
if isinstance(i, dict) and i.get("src")
]
sku = str(product.get("sku") or "").strip()
return [{
"title": title,
"category": categories[0] if categories else None,
"size": canonical_size(title),
"barcode": validate_barcode(sku) if sku else None,
"price": _woo_price(product.get("prices") or {}),
"image_url": images[0] if images else None,
"image_urls": images,
"vendor": None,
"source": "store",
}]
def _decimal_or_none(raw: Any) -> Optional[float]:
if raw is None or str(raw).strip() == "":
return None
try:
return float(raw)
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# Tier 3 - product sitemap, for sites that publish no catalogue API
# ---------------------------------------------------------------------------
# udhaiyamdhall.com is WooCommerce but answers the Store API with 403, and its
# product pages carry only a BreadcrumbList in JSON-LD - no Product node, no
# og: tags. What it does have is `wp-sitemap-posts-product-1.xml` listing 32
# products, each page with an <h1> and a gallery image.
#
# So this tier yields NAMES AND IMAGES, AND NOTHING ELSE. No price, no pack
# size, no barcode - because the site does not state them, and a catalogue
# source that guesses is the exact problem this whole area exists to fix. The
# names are real and the photographs are the brand's own, which solves
# enumeration and solves the image problem; sizes have to come from somewhere
# that actually knows them.
_SITEMAP_CANDIDATES = ("/sitemap.xml", "/wp-sitemap.xml", "/sitemap_index.xml")
MAX_SITEMAP_PAGES = 200
def _sitemap_locs(xml: str) -> List[str]:
return re.findall(r"<loc>\s*(.*?)\s*</loc>", xml or "", re.IGNORECASE | re.DOTALL)
def fetch_sitemap(domain: str) -> Optional[List[Dict[str, Any]]]:
"""Product names and images from a site's product sitemap, or None."""
try:
from bs4 import BeautifulSoup
except ImportError: # pragma: no cover - declared in requirements.txt
logger.debug("Sitemap tier unavailable: beautifulsoup4 not installed")
return None
product_urls: List[str] = []
for path in _SITEMAP_CANDIDATES:
try:
resp = _get(f"https://{domain}{path}")
except Exception: # noqa: BLE001
continue
if resp.status_code != 200 or "xml" not in (resp.headers.get("content-type") or ""):
continue
locs = _sitemap_locs(resp.text)
# A sitemap index points at sub-sitemaps; take only the product one.
for loc in locs:
if "product" not in loc.lower() or "categor" in loc.lower():
continue
if loc.lower().endswith(".xml"):
try:
sub = _get(loc)
except Exception: # noqa: BLE001
continue
if sub.status_code == 200:
product_urls.extend(
u for u in _sitemap_locs(sub.text) if "/product/" in u.lower()
)
elif "/product/" in loc.lower():
product_urls.append(loc)
if product_urls:
break
product_urls = list(dict.fromkeys(product_urls))[:MAX_SITEMAP_PAGES]
if not product_urls:
return None
out: List[Dict[str, Any]] = []
for url in product_urls:
try:
page = _get(url)
except Exception: # noqa: BLE001 - one dead page cannot stop the sweep
continue
if page.status_code != 200:
continue
soup = BeautifulSoup(page.text, "lxml")
heading = soup.find("h1")
title = heading.get_text(strip=True) if heading else ""
if not title:
continue
image = None
node = soup.select_one(".woocommerce-product-gallery img, .wp-post-image, "
"meta[property='og:image']")
if node is not None:
image = node.get("src") or node.get("data-src") or node.get("content")
out.append({
"title": title,
"category": None,
# Left None deliberately - this site states neither, and inventing
# them is the failure mode, not the fallback.
"size": None,
"barcode": None,
"price": None,
"image_url": image,
"image_urls": [image] if image else [],
"vendor": None,
"source": "store",
})
time.sleep(1.0)
return out or None
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def _cache_path(brand: str) -> Path:
slug = re.sub(r"[^a-z0-9]+", "_", (brand or "").lower()).strip("_")
return CACHE_DIR / f"{slug}.json"
def _write_cache(path: Path, payload: Dict[str, Any]) -> None:
"""Atomic write, the same .tmp + os.replace off_bulk uses - a half-written
catalogue read by the next run is worse than no cache."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
os.replace(tmp, path)
except Exception as e: # noqa: BLE001 - caching is best effort
logger.debug("Could not cache brand store catalogue at %s: %s", path, e)
def read_cached(brand: str) -> Optional[List[Dict[str, Any]]]:
"""The cached catalogue for a brand, or None. Never raises."""
path = _cache_path(brand)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
return list(data.get("products") or [])
except Exception as e: # noqa: BLE001
logger.debug("Unreadable brand store cache %s: %s", path, e)
return None
def fetch_store_catalogue(brand: str, domain: Optional[str], *,
live: bool = False, refresh: bool = False,
trusted_domain: bool = False,
aliases: Optional[Sequence[str]] = None,
) -> Optional[List[Dict[str, Any]]]:
"""The brand's own catalogue as candidate dicts, or None if there isn't one.
`live` defaults to FALSE, matching `retail_presence.check_listing`: the
runtime path reads the cache and the backfill script does the fetching, so
a discovery preview never blocks on somebody's storefront being slow.
None means "no catalogue" - not reachable, not a supported platform, or
rejected by the identity guard. An empty list is not returned; a store with
zero products is indistinguishable from no store and is reported the same.
"""
if not refresh:
cached = read_cached(brand)
if cached is not None:
return cached or None
if not live or not domain:
return None
raw = fetch_shopify(domain)
platform = "shopify"
to_candidates = _shopify_candidates
if raw is None:
raw = fetch_woocommerce(domain)
platform = "woocommerce"
to_candidates = _woo_candidates
if raw is None:
# Last tier: names and images off the product sitemap. See fetch_sitemap
# for why it yields nothing else.
raw = fetch_sitemap(domain)
platform = "sitemap"
to_candidates = lambda row: [row] # noqa: E731 - already candidates
if raw is None:
logger.info("No structured catalogue at %s for %s", domain, brand)
return None
candidates: List[Dict[str, Any]] = []
for product in raw:
if isinstance(product, dict):
candidates.extend(to_candidates(product))
vendors = sorted({c.get("vendor") for c in candidates if c.get("vendor")})
# A CURATED DOMAIN IS ALREADY VERIFIED, BY A PERSON.
#
# The heuristic below cannot recognise every legitimate shape a brand's
# domain takes - "theanilgroup.com" is Anil's, and neither starts with
# "anil" nor contains it as a whole token - so applying it to a
# hand-checked mapping would reject good catalogues for looking unusual.
# It runs only on domains that came from automatic resolution, which is
# the path that produced cityofnagacebu.gov.ph.
if not trusted_domain and not _store_identity_ok(brand, domain, vendors, aliases):
# REJECTED WHOLE, on purpose. This is the failure that would otherwise
# put a hundred of somebody else's products under this brand's name,
# each of them a real product and none of them theirs.
logger.warning(
"Rejecting catalogue at %s for brand %r: neither the domain nor "
"its vendors (%s) identify this brand",
domain, brand, ", ".join(vendors) or "none stated",
)
return None
for c in candidates:
c.pop("vendor", None)
logger.info("%s: %d products from %s (%s)", brand, len(candidates), domain, platform)
_write_cache(_cache_path(brand), {
"brand": brand,
"domain": domain,
"platform": platform,
"fetched_at": time.time(),
"fetched_at_human": time.strftime("%Y-%m-%d %H:%M:%S"),
"products": candidates,
})
return candidates or None

2092
data/cache/brand_store/aachi.json vendored Normal file

File diff suppressed because it is too large Load Diff

850
data/cache/brand_store/anil.json vendored Normal file
View File

@@ -0,0 +1,850 @@
{
"brand": "anil",
"domain": "shop.theanilgroup.com",
"platform": "shopify",
"fetched_at": 1789040416.1837516,
"fetched_at_human": "2026-09-10 17:10:16",
"products": [
{
"title": "Anil Kozhukattai Celebration Pack",
"category": null,
"size": null,
"barcode": null,
"price": 90.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Jaggery_powder_landing_page.jpg_4.jpg?v=1784616300",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Jaggery_powder_landing_page.jpg_4.jpg?v=1784616300"
],
"source": "store"
},
{
"title": "Jaggery Powder (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 60.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Jaggery_powder_jpg.jpg?v=1784200654",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Jaggery_powder_jpg.jpg?v=1784200654",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilJaggeryPowder_Back.jpg?v=1784116997",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilJaggeryPowder_500gNutrtion_1.jpg?v=1784116997"
],
"source": "store"
},
{
"title": "Millet Vermicelli Combo",
"category": null,
"size": null,
"barcode": null,
"price": 150.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Web_banners-29-29.jpg?v=1776855278",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Web_banners-29-29.jpg?v=1776855278"
],
"source": "store"
},
{
"title": "Dosa Mix Combo",
"category": null,
"size": null,
"barcode": null,
"price": 200.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Webbanners-29-28.jpg?v=1776673267",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Webbanners-29-28.jpg?v=1776673267"
],
"source": "store"
},
{
"title": "Elbow Pasta (400g)",
"category": null,
"size": "400g",
"barcode": null,
"price": 38.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilelbowpasta_400g_front.jpg?v=1774966033",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilelbowpasta_400g_front.jpg?v=1774966033",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilelbowpasta_400g_back.jpg?v=1774965910",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Elbow_pasta_400g.jpg?v=1777033433"
],
"source": "store"
},
{
"title": "Penne Pasta (400g)",
"category": null,
"size": "400g",
"barcode": null,
"price": 38.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilpennepasta_400g_front.jpg?v=1774882504",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilpennepasta_400g_front.jpg?v=1774882504",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilpennepasta_400g_back.jpg?v=1774882454",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Penne_Pasta_400g.jpg?v=1777033433"
],
"source": "store"
},
{
"title": "Noodles (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 36.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_200g_Front_a60a3633-8d40-4de2-a8ff-9a7b9f170ca7.jpg?v=1774968330",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_200g_Front_a60a3633-8d40-4de2-a8ff-9a7b9f170ca7.jpg?v=1774968330",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_200g_Back_a45f3611-bdaa-4f94-8920-9f8d4a5a8bdd.jpg?v=1774968330",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/NOODLES_200G.jpg?v=1774968330"
],
"source": "store"
},
{
"title": "Noodles (100g)",
"category": null,
"size": "100g",
"barcode": null,
"price": 18.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_100g_Front_5aabcacf-71d9-405a-92fb-be7a23d82b84.jpg?v=1774968330",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_100g_Front_5aabcacf-71d9-405a-92fb-be7a23d82b84.jpg?v=1774968330",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_100g_Back_25410e5e-3ae6-4c51-ab7e-2216ab37eb9e.jpg?v=1774968330",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/NOODLES_100G.jpg?v=1774968330"
],
"source": "store"
},
{
"title": "Samba Rava (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 65.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__200g_front.jpg?v=1774967435",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__200g_front.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__200g_back.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SAMBA_RAVA_200G.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__500g_front.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__500g_back.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SAMBA_RAVA_500G.jpg?v=1774967435"
],
"source": "store"
},
{
"title": "Double Roasted Rava (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 35.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anildoubleroastedrava_500g_front.jpg?v=1774967284",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anildoubleroastedrava_500g_front.jpg?v=1774967284",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anildoubleroastedrava_500g_back.jpg?v=1774967284",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/DOUBLE_ROASTED_RAVA_500G.jpg?v=1774967284"
],
"source": "store"
},
{
"title": "Biryani Vermicelli (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 34.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilBiryanivermicelli_200g_front.jpg?v=1776068408",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilBiryanivermicelli_200g_front.jpg?v=1776068408",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilBiryanivermicelli_200g_back.jpg?v=1776068408",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/BIRIYANI_VERMICELLI_200G_e40f458a-e028-4ef4-a870-0f88c94afb52.jpg?v=1777033367"
],
"source": "store"
},
{
"title": "Instant Idiyappam (150g)",
"category": null,
"size": "150g",
"barcode": null,
"price": 72.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilInstantIdiyappam_150g_front_70e9d4f8-74fd-44cf-a69a-72acc3780f6a.jpg?v=1776838851",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilInstantIdiyappam_150g_front_70e9d4f8-74fd-44cf-a69a-72acc3780f6a.jpg?v=1776838851",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilInstantIdiyappam_150g_back_3c4ab733-f1c3-4cce-b014-4f836e2181c1.jpg?v=1776838851"
],
"source": "store"
},
{
"title": "Masala Vermicelli Combo",
"category": null,
"size": "200g",
"barcode": null,
"price": 80.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Webbanners-25.jpg?v=1775823776",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Webbanners-25.jpg?v=1775823776"
],
"source": "store"
},
{
"title": "Wheat Vermicelli (180g)",
"category": null,
"size": "180g",
"barcode": null,
"price": 32.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Wheat_Vermicelli_180g_front_5023bd30-e3e9-4e5e-859c-87c2fc4782f3.jpg?v=1774966611",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Wheat_Vermicelli_180g_front_5023bd30-e3e9-4e5e-859c-87c2fc4782f3.jpg?v=1774966611",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Wheat_Vermicelli_180g_back_f8c254e0-e557-4882-a5c4-90529b9d4298.jpg?v=1774966611",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHEAT_VERMICELLI_180G.jpg?v=1774966611"
],
"source": "store"
},
{
"title": "Roasted Vermicelli",
"category": null,
"size": "180g",
"barcode": null,
"price": 23.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__180g__front.jpg?v=1774874826",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__180g__front.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__180g__back.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RoastedVermicelli_180g_Card3.png?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__450g__front.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__450g__back.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RoastedVermicelli_450g_Card3.png?v=1774874826"
],
"source": "store"
},
{
"title": "Roasted Vermicelli",
"category": null,
"size": "450g",
"barcode": null,
"price": 56.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__180g__front.jpg?v=1774874826",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__180g__front.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__180g__back.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RoastedVermicelli_180g_Card3.png?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__450g__front.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_roasted_short_vermicelli__450g__back.jpg?v=1774874826",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RoastedVermicelli_450g_Card3.png?v=1774874826"
],
"source": "store"
},
{
"title": "Roasted Payasa Vermicelli",
"category": null,
"size": "100g",
"barcode": null,
"price": 15.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Roasted_payasa_vermicelli__100g_front.jpg?v=1774874946",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Roasted_payasa_vermicelli__100g_front.jpg?v=1774874946",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Roasted_payasa_vermicelli__100g_back.jpg?v=1774874946",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RoastedPayasaVermicelli_100g_Card3_d24ed05a-8eff-4882-9a83-c9c392965c9f.png?v=1774874946",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Roasted_payasa_vermicelli__200g_front.jpg?v=1774874946",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Roasted_payasa_vermicelli__200g_back.jpg?v=1774874946",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RoastedPayasaVermicelli_200g_Card3_401f3afd-6a33-499c-92a8-aa77cd8675c9.png?v=1774874946"
],
"source": "store"
},
{
"title": "Cholam Vermicelli (180g)",
"category": null,
"size": "180g",
"barcode": null,
"price": 32.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_sorghum_Millet_vermicelli_180g_front_126d520a-648d-437a-bde8-a3688cf66c65.jpg?v=1774874431",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_sorghum_Millet_vermicelli_180g_front_126d520a-648d-437a-bde8-a3688cf66c65.jpg?v=1774874431",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_sorghum_Millet_vermicelli_180g_back.jpg?v=1774874431",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/CHOLAM_VERMICELLI_180G_90b4dff1-502e-49f2-b335-f0b365b2d337.jpg?v=1774874431"
],
"source": "store"
},
{
"title": "Hakka Noodles (180g)",
"category": null,
"size": "180g",
"barcode": null,
"price": 30.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Hakka_Noodles_180g_front.jpg?v=1774874597",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Hakka_Noodles_180g_front.jpg?v=1774874597",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Hakka_Noodles_180g_back.jpg?v=1774874597",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/HAKKANOODLES180G_5daed037-bce0-444d-9775-b162b4ae75b0.jpg?v=1774874597"
],
"source": "store"
},
{
"title": "Soya Chunks",
"category": null,
"size": "40g",
"barcode": null,
"price": 8.5,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_soya_chunks_40g_front.jpg?v=1774875034",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_soya_chunks_40g_front.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_soya_chunks_40g_back.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SOYACHUNKS40G.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Soya_Front_200g_Frontt.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Soya_Front_200g_Back.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SOYACHUNKS200G.jpg?v=1774875034"
],
"source": "store"
},
{
"title": "Soya Chunks",
"category": null,
"size": "200g",
"barcode": null,
"price": 40.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_soya_chunks_40g_front.jpg?v=1774875034",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_soya_chunks_40g_front.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_soya_chunks_40g_back.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SOYACHUNKS40G.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Soya_Front_200g_Frontt.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Soya_Front_200g_Back.jpg?v=1774875034",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SOYACHUNKS200G.jpg?v=1774875034"
],
"source": "store"
},
{
"title": "Appalam Baby",
"category": null,
"size": "50g",
"barcode": null,
"price": 21.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_appalam__50g__front.jpg?v=1774875314",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_appalam__50g__front.jpg?v=1774875314",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_appalam__50g__back.jpg?v=1774875314",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/APPALAM_BABY_50G.jpg?v=1774875314"
],
"source": "store"
},
{
"title": "Ragi Vermicelli",
"category": null,
"size": "180g",
"barcode": null,
"price": 28.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__180g_front.jpg?v=1774875411",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__180g_front.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__180g_back.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RAGI_VERMICELLI_180G.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__450g_front.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__450g_back.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RAGI_VERMICELLI_450g.jpg?v=1774875411"
],
"source": "store"
},
{
"title": "Ragi Vermicelli",
"category": null,
"size": "450g",
"barcode": null,
"price": 67.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__180g_front.jpg?v=1774875411",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__180g_front.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__180g_back.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RAGI_VERMICELLI_180G.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__450g_front.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_vermicelli__450g_back.jpg?v=1774875411",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RAGI_VERMICELLI_450g.jpg?v=1774875411"
],
"source": "store"
},
{
"title": "Rice Flour (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 39.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilriceflour_500g_front.jpg?v=1775819439",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilriceflour_500g_front.jpg?v=1775819439",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilriceflour_500g_back.jpg?v=1775819439",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RICE_FLOUR_500G_edc67383-1cca-46bc-80da-efa9354ba9af.jpg?v=1775819507"
],
"source": "store"
},
{
"title": "Varagu Dosa Mix (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 75.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_varagu_dosa_mix_500g__front.jpg?v=1774875674",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_varagu_dosa_mix_500g__front.jpg?v=1774875674",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_varagu_dosa_mix_500g__back.jpg?v=1774875674",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/VARAGU_DOSA_MIX_500G.jpg?v=1774875674"
],
"source": "store"
},
{
"title": "Thinai Vermicelli (180g)",
"category": null,
"size": "180g",
"barcode": null,
"price": 34.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_foxtail_millet_vermicelli_180g_front.jpg?v=1774875833",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_foxtail_millet_vermicelli_180g_front.jpg?v=1774875833",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_foxtail_millet_vermicelli_180g_back.jpg?v=1774875833",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/THINAI_VERMICELLI_180G.jpg?v=1774875833"
],
"source": "store"
},
{
"title": "Thinai Dosa Mix (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 75.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_thinai_dosa_mix__500g_front.jpg?v=1774875890",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_thinai_dosa_mix__500g_front.jpg?v=1774875890",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_thinai_dosa_mix__500g_back.jpg?v=1774875890",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Thinai_dosa_mix_500g.jpg?v=1774875890"
],
"source": "store"
},
{
"title": "ShortCut Vermicelli",
"category": null,
"size": "200g",
"barcode": null,
"price": 17.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilshortcutvermicelli_200g_front.jpg?v=1774875989",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilshortcutvermicelli_200g_front.jpg?v=1774875989",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilshortcutvermicelli_200g_back.jpg?v=1775816933",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Short_Cut_Vermicelli_200g.jpg?v=1777033294",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilshortcutvermicelli_400g_front.jpg?v=1775816933",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilshortcutvermicelli_400g_back.jpg?v=1775816933",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Short_Cut_Vermicelli_400g.jpg?v=1777033294"
],
"source": "store"
},
{
"title": "Rice Vermicelli",
"category": null,
"size": "200g",
"barcode": null,
"price": 35.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rice_Vermicelli_200g_Front_53ab899b-1c0c-4096-98dc-43bb3e75107b.jpg?v=1774876201",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rice_Vermicelli_200g_Front_53ab899b-1c0c-4096-98dc-43bb3e75107b.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rice_Vermicelli_200g_Back_2ce3204d-4723-4379-bfdf-51e7d2b126e1.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RICE_VERMICELLI_200G.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_rice_vermicelli__500g_front_521b2956-a980-4618-b385-e0304f3a5d47.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_rice_vermicelli__500g_back_b17f7095-b964-4d90-bf77-bde361394f94.jpg?v=1774876479",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RICE_VERMICELLI_500G.jpg?v=1774876479"
],
"source": "store"
},
{
"title": "Rice Vermicelli",
"category": null,
"size": "500g",
"barcode": null,
"price": 80.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rice_Vermicelli_200g_Front_53ab899b-1c0c-4096-98dc-43bb3e75107b.jpg?v=1774876201",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rice_Vermicelli_200g_Front_53ab899b-1c0c-4096-98dc-43bb3e75107b.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rice_Vermicelli_200g_Back_2ce3204d-4723-4379-bfdf-51e7d2b126e1.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RICE_VERMICELLI_200G.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_rice_vermicelli__500g_front_521b2956-a980-4618-b385-e0304f3a5d47.jpg?v=1774876201",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_rice_vermicelli__500g_back_b17f7095-b964-4d90-bf77-bde361394f94.jpg?v=1774876479",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RICE_VERMICELLI_500G.jpg?v=1774876479"
],
"source": "store"
},
{
"title": "Ragi Flour (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 50.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_flour__500g_front.jpg?v=1774876547",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_flour__500g_front.jpg?v=1774876547",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_finger_millet_flour__500g_back.jpg?v=1774876547",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RAGI_FLOUR_500G.jpg?v=1774876547"
],
"source": "store"
},
{
"title": "Ragi Dosa Mix (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 62.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_ragi_dosa_mix_500g_front.jpg?v=1774876885",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_ragi_dosa_mix_500g_front.jpg?v=1774876885",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_ragi_dosa_mix_500g_back.jpg?v=1774876885",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/RAGI_DOSA_MIX_500G.jpg?v=1774876885"
],
"source": "store"
},
{
"title": "Murukku Flour (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 56.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_murukku_flour_500g_front.jpg?v=1774876944",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_murukku_flour_500g_front.jpg?v=1774876944",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_murukku_flour_500g_back.jpg?v=1774876944",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/MURUKKU_FLOUR_500G.jpg?v=1774876944"
],
"source": "store"
},
{
"title": "Refined Wheat Flour Maida",
"category": null,
"size": "500g",
"barcode": null,
"price": 42.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_200g_front.jpg?v=1774877080",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_200g_front.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_200g_back.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Maida_200g.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_500g_front.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_500g_back.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Maida_500g.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_1kg_front.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Maida_1kg_back.jpg?v=1774877080",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Maida_1kg.jpg?v=1774877080"
],
"source": "store"
},
{
"title": "Lemon Vermicelli (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 33.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Lemon_Vermicelli__200g_front.jpg?v=1774877176",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Lemon_Vermicelli__200g_front.jpg?v=1774877176",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Lemon_Vermicelli__200g_back.jpg?v=1774877176",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/LEMON_VERMICELLI_200G.jpg?v=1774877176"
],
"source": "store"
},
{
"title": "Kozhukattai Flour (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 46.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_kozhukattai_flour_500g_front.jpg?v=1774881287",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_kozhukattai_flour_500g_front.jpg?v=1774881287",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_kozhukattai_flour_500g_back.jpg?v=1774881287",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/KOZHUKATTAI_FLOUR_500G.jpg?v=1774881287"
],
"source": "store"
},
{
"title": "Kambu Vermicelli (180g)",
"category": null,
"size": "180g",
"barcode": null,
"price": 32.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Pearl_vermicelli__180g_front.jpg?v=1774881485",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Pearl_vermicelli__180g_front.jpg?v=1774881485",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Pearl_vermicelli__180g_back.jpg?v=1774881485",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/KAMBU_VERMICELLI_180G.jpg?v=1774881485"
],
"source": "store"
},
{
"title": "Kambu Flour (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 44.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Pearl_millet_flour_500g_front.jpg?v=1774881576",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Pearl_millet_flour_500g_front.jpg?v=1774881576",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Pearl_millet_flour_500g_back.jpg?v=1774881576",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/KAMBU_FLOUR_500GM.jpg?v=1774881576"
],
"source": "store"
},
{
"title": "Kambu Dosa Mix (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 53.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Kambu_dosa_mix_500g_front.jpg?v=1774881678",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Kambu_dosa_mix_500g_front.jpg?v=1774881678",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Kambu_dosa_mix_500g_back.jpg?v=1774881678",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/KAMBU_DOSA_MIX_500G.jpg?v=1774881678"
],
"source": "store"
},
{
"title": "String Hopper Flour (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 46.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_String_Hopper_Flour__500g_front.jpg?v=1774881892",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_String_Hopper_Flour__500g_front.jpg?v=1774881892",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_String_Hopper_Flour__500g_back.jpg?v=1774881892",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/STRING_HOPPER_FLOUR_500G.jpg?v=1774881892"
],
"source": "store"
},
{
"title": "Cholam Dosa Mix (500g)",
"category": null,
"size": "500g",
"barcode": null,
"price": 62.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Cholam_Dosa_mix_500g_front.jpg?v=1774881992",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Cholam_Dosa_mix_500g_front.jpg?v=1774881992",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Cholam_Dosa_mix_500g_back.jpg?v=1774881992",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/CHOLAM_DOSA_MIX_500G.jpg?v=1774881992"
],
"source": "store"
},
{
"title": "Whole Wheat Chakki Atta",
"category": null,
"size": "500g",
"barcode": null,
"price": 41.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_500g_front.jpg?v=1774882220",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_500g_front.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_500g_back.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHOLE_WHEAT_FLOUR_ATTA_500G.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_1kg_front.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_1kg_back.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHOLE_WHEAT_FLOUR_ATTA_1KG.jpg?v=1774967605"
],
"source": "store"
},
{
"title": "Whole Wheat Chakki Atta",
"category": null,
"size": "1kg",
"barcode": null,
"price": 81.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_500g_front.jpg?v=1774882220",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_500g_front.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_500g_back.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHOLE_WHEAT_FLOUR_ATTA_500G.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_1kg_front.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Chakki_ATTA_1kg_back.jpg?v=1774882220",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHOLE_WHEAT_FLOUR_ATTA_1KG.jpg?v=1774967605"
],
"source": "store"
},
{
"title": "Adai Dosa Mix",
"category": null,
"size": "250g",
"barcode": null,
"price": 90.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Adai_dosa_Flour_250g_front_a67cf4b8-5f1b-4408-9e11-27ab9eda3651.jpg?v=1776855106",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Adai_dosa_Flour_250g_front_a67cf4b8-5f1b-4408-9e11-27ab9eda3651.jpg?v=1776855106",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Adai_dosa_Flour_250g_front.jpg?v=1774882328",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Adai_dosa_Flour_250g_back.jpg?v=1774882328",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/ADAI_DOSA_MIX_250G.jpg?v=1774882328"
],
"source": "store"
},
{
"title": "Penne Pasta (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 21.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilpennepasta_200g_front.jpg?v=1774882504",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilpennepasta_200g_front.jpg?v=1774882504",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilpennepasta_200g_back.jpg?v=1774882504",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Penne_Pasta_200g.jpg?v=1777033433"
],
"source": "store"
},
{
"title": "Elbow Pasta (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 21.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilelbowpasta_200g_front.jpg?v=1774966033",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilelbowpasta_200g_front.jpg?v=1774966033",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anilelbowpasta_200g_back.jpg?v=1774966033",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Elbow_pasta_200g.jpg?v=1777033433"
],
"source": "store"
},
{
"title": "Free Flow Salt",
"category": null,
"size": "1kg",
"barcode": null,
"price": 20.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_free_flow_salt_500g_front.jpg?v=1774966248",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_free_flow_salt_500g_front.jpg?v=1774966248",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_free_flow_salt_500g_back.jpg?v=1774966248",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/FREE_FLOW_SALT_500G.jpg?v=1774966248",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_free_flow_salt_1kg_front.jpg?v=1774966248",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_free_flow_salt_1kg_back.jpg?v=1774966248",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/FREE_FLOW_SALT_1_KG.jpg?v=1774966248"
],
"source": "store"
},
{
"title": "Crystal Salt",
"category": null,
"size": "1kg",
"barcode": null,
"price": 19.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_crystal_salt__1kg_front.jpg?v=1774966328",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_crystal_salt__1kg_front.jpg?v=1774966328",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_crystal_salt__1kg_back.jpg?v=1774966328",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/CRYSTAL_SALT_1_KG.jpg?v=1774966328"
],
"source": "store"
},
{
"title": "Appalam-4",
"category": null,
"size": "100g",
"barcode": null,
"price": 41.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_appalam__100g__front.jpg?v=1774875293",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_appalam__100g__front.jpg?v=1774875293",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_appalam__100g__back.jpg?v=1774875293",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/APPALAM_4_NO_100G.jpg?v=1774875293"
],
"source": "store"
},
{
"title": "Tamarind Vermicelli (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 33.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Tamarind_vermicelli_200g_front.jpg?v=1774966483",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Tamarind_vermicelli_200g_front.jpg?v=1774966483",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Tamarind_vermicelli_200g_back.jpg?v=1774966483",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/TAMARIND_VERMICELLI_200G.jpg?v=1774966483"
],
"source": "store"
},
{
"title": "Varagu Vermicelli (180g)",
"category": null,
"size": "180g",
"barcode": null,
"price": 34.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_kodo_millet_vermicelli_180g_front.jpg?v=1774966614",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_kodo_millet_vermicelli_180g_front.jpg?v=1774966614",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_kodo_millet_vermicelli_180g_back.jpg?v=1774966614",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/VARAGU_VERMICELLI_180G.jpg?v=1774966614"
],
"source": "store"
},
{
"title": "Noodles (50g)",
"category": null,
"size": "50g",
"barcode": null,
"price": 10.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_50g_Front_4edd64c5-7d76-45b0-a01a-437da9ad30ff.jpg?v=1774968330",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_50g_Front_4edd64c5-7d76-45b0-a01a-437da9ad30ff.jpg?v=1774968330",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/AnilNoodles_50g_Back_0bfd8351-7ce2-4b63-b307-53540f172d9b.jpg?v=1775817457",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/NOODLES_50G.jpg?v=1775817457"
],
"source": "store"
},
{
"title": "Rusk",
"category": null,
"size": "60g",
"barcode": null,
"price": 10.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Rusk_Front.jpg?v=1776075676",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Rusk_Front.jpg?v=1776075676",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_Rusk_Back.jpg?v=1776075676",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Rusk.jpg?v=1776075676"
],
"source": "store"
},
{
"title": "Double Roasted Rava (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 24.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anildoubleroastedrava_200g_front.jpg?v=1774967284",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anildoubleroastedrava_200g_front.jpg?v=1774967284",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anildoubleroastedrava_200g_back.jpg?v=1774967284",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/DOUBLE_ROASTED_RAVA_200G.jpg?v=1774967284"
],
"source": "store"
},
{
"title": "Samba Rava (200g)",
"category": null,
"size": "200g",
"barcode": null,
"price": 40.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__200g_front.jpg?v=1774967435",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__200g_front.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_samba_rava__200g_back.jpg?v=1774967435",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/SAMBA_RAVA_200G.jpg?v=1774967435"
],
"source": "store"
},
{
"title": "Whole Wheat Flour(Atta)",
"category": null,
"size": "500g",
"barcode": null,
"price": 40.0,
"image_url": "https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_AttA__500g_front.jpg?v=1774967605",
"image_urls": [
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_AttA__500g_front.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_AttA__500g_back.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHOLE_WHEAT_FLOUR_ATTA_500G.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_AttA__1kg_front.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/Anil_AttA__1kg_back.jpg?v=1774967605",
"https://cdn.shopify.com/s/files/1/0754/2410/4500/files/WHOLE_WHEAT_FLOUR_ATTA_1KG.jpg?v=1774967605"
],
"source": "store"
}
]
}

1627
data/cache/brand_store/double_horse.json vendored Normal file

File diff suppressed because it is too large Load Diff

1542
data/cache/brand_store/gopuram.json vendored Normal file

File diff suppressed because it is too large Load Diff

393
data/cache/brand_store/udhaiyam.json vendored Normal file
View File

@@ -0,0 +1,393 @@
{
"brand": "Udhaiyam",
"domain": "udhaiyamdhall.com",
"platform": "sitemap",
"fetched_at": 1789040933.5187476,
"fetched_at_human": "2026-09-10 17:18:53",
"products": [
{
"title": "Orid Dhall",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Orid-dal.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Orid-dal.png"
],
"source": "store"
},
{
"title": "Moong Dhall",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Moong-dal.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Moong-dal.png"
],
"source": "store"
},
{
"title": "Toor Dhall",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Toor-dal.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Toor-dal.png"
],
"source": "store"
},
{
"title": "Fried Gram",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Fried-dal.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Fried-dal.png"
],
"source": "store"
},
{
"title": "Masala Peanuts",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Gram-Dhall-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Gram-Dhall-1.png"
],
"source": "store"
},
{
"title": "Moong Dhall",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Moong-Dhall-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Moong-Dhall-1.png"
],
"source": "store"
},
{
"title": "Gram Dhall",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Gram-Dhall-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Gram-Dhall-1.png"
],
"source": "store"
},
{
"title": "Pepper Karasev",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Pepper-Karasev-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Pepper-Karasev-1.png"
],
"source": "store"
},
{
"title": "Kara Pattani",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Kara-Pattani-2.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Namkeen-Kara-Pattani-2.png"
],
"source": "store"
},
{
"title": "Atta",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Wheat-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Wheat-1.png"
],
"source": "store"
},
{
"title": "Rawa",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Rawa-2.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Rawa-2.png"
],
"source": "store"
},
{
"title": "Maida",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Maida-2.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Maida-2.png"
],
"source": "store"
},
{
"title": "Asafoetida",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/asafoetida-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/asafoetida-1.png"
],
"source": "store"
},
{
"title": "Elaichi (Cardamom)",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Aliachi-50g-Front-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Aliachi-50g-Front-1.png"
],
"source": "store"
},
{
"title": "Sombu (Fennel seed)",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Sonbu-50g-Front-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Sonbu-50g-Front-1.png"
],
"source": "store"
},
{
"title": "Venthiyam (Fenugreek)",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Venthiyam-50g-Front-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Venthiyam-50g-Front-1.png"
],
"source": "store"
},
{
"title": "Pepper",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Pepper-50g-Front-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Pepper-50g-Front-1.png"
],
"source": "store"
},
{
"title": "Cummin (Jeera)",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Jeera-50g-Front-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Jeera-50g-Front-1.png"
],
"source": "store"
},
{
"title": "Kadugu (Mustard)",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Kadugu-50g-Front-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Udhaiyam-Kadugu-50g-Front-1.png"
],
"source": "store"
},
{
"title": "Appalam",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Appalalm-600x468.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Appalalm-600x468.png"
],
"source": "store"
},
{
"title": "Sugar",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Sugar-1-1-600x550.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Sugar-1-1-600x550.png"
],
"source": "store"
},
{
"title": "Salt",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/iodised-salt-600x589.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/iodised-salt-600x589.png"
],
"source": "store"
},
{
"title": "Parota",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/parotta-600x362.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/parotta-600x362.png"
],
"source": "store"
},
{
"title": "Chappathi",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/chappati-600x323.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/chappati-600x323.png"
],
"source": "store"
},
{
"title": "Idly Batter",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/idli-batter-e1751970085228-600x327.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/idli-batter-e1751970085228-600x327.png"
],
"source": "store"
},
{
"title": "Gram Dhall",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Gram-dal.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/Gram-dal.png"
],
"source": "store"
},
{
"title": "Idli Rice",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/07/Idly-rice-1-600x574.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/07/Idly-rice-1-600x574.png"
],
"source": "store"
},
{
"title": "Basmati Rice",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/07/ponni-rice-4-copy-600x574.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/07/ponni-rice-4-copy-600x574.png"
],
"source": "store"
},
{
"title": "Masala Vada",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2026/06/2.-Masala-Vada-600x1004.jpg",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2026/06/2.-Masala-Vada-600x1004.jpg"
],
"source": "store"
},
{
"title": "Idly Batter \u2013 Copy",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/idli-batter-e1751970085228-768x419-1.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/06/idli-batter-e1751970085228-768x419-1.png"
],
"source": "store"
},
{
"title": "Adai",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2026/06/1.-Adai-Dal-600x892.jpg",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2026/06/1.-Adai-Dal-600x892.jpg"
],
"source": "store"
},
{
"title": "Ponni Rice",
"category": null,
"size": null,
"barcode": null,
"price": null,
"image_url": "https://www.udhaiyamdhall.com/wp-content/uploads/2025/07/ponni-rice-4-600x574.png",
"image_urls": [
"https://www.udhaiyamdhall.com/wp-content/uploads/2025/07/ponni-rice-4-600x574.png"
],
"source": "store"
}
]
}

Binary file not shown.

View File

@@ -0,0 +1,150 @@
"""
Read each brand's own shop catalogue and cache it for discovery to use.
WHY THIS IS A SCRIPT
--------------------
Same split as `backfill_retail_presence.py`, for the same reason: the network
work happens offline and paced, and the runtime path
(`brand_discovery._from_brand_store`) reads only the cache. A discovery preview
must never block on a small company's storefront being slow, and a storefront
must never be hammered because somebody opened a preview.
Unlike the retail check, this is cheap - one or two requests per BRAND, not one
per product - so a full pass over every configured brand costs a handful of
requests and a few seconds.
WHERE THE DOMAINS COME FROM
---------------------------
`brand_registry.BRAND_STORE_DOMAINS`, which is human-curated on purpose. A
wrong domain here does not cost one bad row; it imports a hundred of another
company's real products under this brand's name, and they look perfect because
they ARE perfect - just somebody else's. `resolve_brand_domain` answers "Naga"
with the website of Naga City in the Philippines, which is why search is not
trusted for this.
`--domain` exists for trying a candidate site before adding it to the map. A
domain given that way is NOT trusted: it goes through `brand_store`'s identity
guard, which a curated one skips.
USAGE
python scripts/backfill_brand_stores.py --all # dry run
python scripts/backfill_brand_stores.py --all --apply
python scripts/backfill_brand_stores.py --brand Gopuram --apply --refresh
python scripts/backfill_brand_stores.py --brand Naga --domain nagafoods.in --apply
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from pathlib import Path
from typing import List, Optional
_BACKEND_DIR = Path(__file__).resolve().parent.parent
if str(_BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(_BACKEND_DIR))
from app.services import brand_store # noqa: E402
from app.services.brand_registry import ( # noqa: E402
BRAND_STORE_DOMAINS,
get_brand_store_domain,
)
logger = logging.getLogger("backfill_brand_stores")
def _summarise(brand: str, rows: List[dict]) -> None:
sized = sum(1 for r in rows if r.get("size"))
barcoded = sum(1 for r in rows if r.get("barcode"))
imaged = sum(1 for r in rows if r.get("image_url"))
priced = sum(1 for r in rows if r.get("price") is not None)
logger.info(
" %d products size=%d barcode=%d image=%d price=%d",
len(rows), sized, barcoded, imaged, priced,
)
# Size coverage looks low here for a reason worth stating rather than
# silently accepting: most of these shops put the pack size in the PRODUCT
# NAME, not in a variant. `brand_discovery._resolve_sizes` reads a title's
# own size first, which lifts Double Horse from 14 sized rows to 94 of 99.
if rows and sized < len(rows) // 2:
logger.info(
" (most sizes are in the product name, not a variant - "
"_resolve_sizes recovers those at discovery time)"
)
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--brand", help="Only this brand.")
parser.add_argument("--all", action="store_true",
help="Every brand in BRAND_STORE_DOMAINS.")
parser.add_argument("--domain",
help="Try this domain instead of the curated one. NOT "
"trusted - it must pass the identity guard.")
parser.add_argument("--apply", action="store_true",
help="Actually fetch. Without this nothing hits the network.")
parser.add_argument("--refresh", action="store_true",
help="Re-fetch even if a cached catalogue exists.")
parser.add_argument("--pause", type=float, default=brand_store.PAUSE_SECONDS)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
if not args.brand and not args.all:
parser.error("give --brand NAME or --all")
if args.brand:
brands = [args.brand]
else:
brands = sorted(BRAND_STORE_DOMAINS)
ok = missing = failed = 0
for brand in brands:
curated = get_brand_store_domain(brand)
domain = args.domain or curated
trusted = bool(curated) and not args.domain
if not domain:
logger.warning("%-22s no store domain configured - add it to "
"brand_registry.BRAND_STORE_DOMAINS", brand)
missing += 1
continue
label = domain + ("" if trusted else " (untrusted, guard applies)")
if not args.apply:
logger.info("[dry-run] %-20s would read %s", brand, label)
continue
logger.info("%-22s %s", brand, label)
try:
rows = brand_store.fetch_store_catalogue(
brand, domain, live=True, refresh=args.refresh,
trusted_domain=trusted,
)
except Exception as exc: # noqa: BLE001 - one bad site cannot stop the sweep
logger.warning(" failed: %s", exc)
failed += 1
continue
if not rows:
logger.warning(" no catalogue (not a supported storefront, "
"unreachable, or rejected by the identity guard)")
failed += 1
else:
_summarise(brand, rows)
ok += 1
time.sleep(args.pause)
if args.apply:
logger.info("")
logger.info("catalogues read : %d", ok)
logger.info("no catalogue : %d", failed)
if missing:
logger.info("no domain set : %d", missing)
return 0
if __name__ == "__main__":
raise SystemExit(main())

274
tests/test_brand_store.py Normal file
View File

@@ -0,0 +1,274 @@
"""A brand's own shop as a catalogue source.
WHY THIS SOURCE EXISTS
----------------------
`off_bulk.fetch_brand_corpus` is the only thing in the codebase that can
ENUMERATE a brand's products; everything else verifies one product at a time
and needs a name as input. For regional South Indian brands Open Food Facts is
empty - measured 2026-09-10: Udhaiyam 0 hits, Gopuram 0, Tenali Double Horse 0,
and both of Naga's 2 hits are a British pickle - so their catalogues were
entirely `qwen2.5:1.5b` invention.
Their storefronts publish the real thing as structured JSON. Measured live:
Aachi 161 products (159 with a VALID EAN-13), Double Horse 99, Gopuram 122,
Anil 57 - against 83 for the same brands in Open Food Facts.
WHAT THESE TESTS GUARD
----------------------
This source is different in kind from every other one here: it ASSERTS a
hundred products at once instead of verifying one. So the identity guard is the
thing that matters most - pointing it at the wrong site imports a hundred of
somebody else's real products under this brand's name, and they look flawless
because they are flawless, just not theirs.
The fixtures are trimmed copies of real responses.
"""
from __future__ import annotations
import json
import pytest
from app.services import brand_store as bs
from app.services.brand_registry import get_brand_store_domain, resolve_parent_brand
# A real aachifoods.com /products.json record, trimmed.
SHOPIFY_PRODUCT = {
"title": "Soan Papdi Ghee",
"product_type": "Snacks",
"vendor": "Aachifoods",
"images": [{"src": "https://cdn.shopify.com/s/files/1/x/ghee-soan-papdi.webp"}],
"variants": [
{"title": "200g", "price": "76.00", "sku": "8904209319340", "grams": 215},
],
}
# A real gopuramproducts.com Store API record, trimmed. Note the price.
WOO_PRODUCT = {
"name": "GOPURAM PREMIUM SAMBRAIN POWDER",
"sku": "",
"categories": [{"name": "Pooja Products"}],
"images": [{"src": "https://gopuramproducts.com/wp-content/uploads/x.jpg"}],
"prices": {"price": "5500", "currency_minor_unit": 2},
}
# ---------------------------------------------------------------------------
# Identity - the guard that matters most
# ---------------------------------------------------------------------------
def test_a_city_council_is_not_a_food_brand():
"""THE CASE THIS GUARD EXISTS FOR.
`retail_presence.resolve_brand_domain("Naga")` really returns
`cityofnagacebu.gov.ph` - the website of Naga City in the Philippines -
because `_pick_brand_domain` accepted a domain whose stem merely CONTAINS a
brand token. Here that would not cost one bad row; it would file a
municipal website's contents as a Tamil Nadu brand's product list.
"""
assert bs._store_identity_ok("Naga", "cityofnagacebu.gov.ph", []) is False
def test_a_brand_domain_is_the_brand_plus_a_qualifier():
"""Prefix, not substring - that is the whole distinction.
"gopuramproducts".startswith("gopuram") but "cityofnagacebu" does not
start with "naga", though it contains it."""
for brand, domain in [("Gopuram", "gopuramproducts.com"),
("Udhaiyam", "udhaiyamdhall.com"),
("Double Horse", "doublehorse.in"),
("Naga", "nagafoods.in")]:
assert bs._store_identity_ok(brand, domain, []) is True, domain
def test_the_vendor_field_can_vouch_for_a_domain_that_does_not():
"""Shopify states the seller. A brand trading on a domain that does not
contain its name is still identifiable."""
assert bs._store_identity_ok("Aachi", "someshop.com", ["Aachifoods"]) is True
assert bs._store_identity_ok("Aachi", "someshop.com", ["Britannia"]) is False
def test_a_rejected_catalogue_is_rejected_whole(monkeypatch):
"""Half a foreign catalogue is not better than all of it."""
monkeypatch.setattr(bs, "fetch_shopify",
lambda d: [dict(SHOPIFY_PRODUCT, vendor="Someone Else")])
monkeypatch.setattr(bs, "read_cached", lambda b: None)
monkeypatch.setattr(bs, "_write_cache", lambda p, d: None)
assert bs.fetch_store_catalogue("Naga", "cityofnagacebu.gov.ph", live=True) is None
def test_a_curated_domain_skips_the_heuristic(monkeypatch):
"""A person already checked it. The heuristic cannot recognise every shape
a real brand domain takes - "theanilgroup.com" is Anil's and neither starts
with nor contains "anil" as a whole token - so applying it to a hand-verified
mapping would reject good catalogues for looking unusual."""
assert bs._store_identity_ok("Anil", "shop.theanilgroup.com", []) is False
monkeypatch.setattr(bs, "fetch_shopify", lambda d: [SHOPIFY_PRODUCT])
monkeypatch.setattr(bs, "read_cached", lambda b: None)
monkeypatch.setattr(bs, "_write_cache", lambda p, d: None)
rows = bs.fetch_store_catalogue("Anil", "shop.theanilgroup.com",
live=True, trusted_domain=True)
assert rows and len(rows) == 1
# ---------------------------------------------------------------------------
# Field mapping
# ---------------------------------------------------------------------------
def test_a_shopify_variant_carries_the_pack_size_and_a_real_barcode():
rows = bs._shopify_candidates(SHOPIFY_PRODUCT)
assert len(rows) == 1
row = rows[0]
assert row["title"] == "Soan Papdi Ghee"
assert row["category"] == "Snacks"
assert row["size"] == "200g"
assert row["price"] == 76.00
assert row["image_url"].endswith("ghee-soan-papdi.webp")
# A genuine EAN-13 on the Indian 890 GS1 prefix.
assert row["barcode"] == "8904209319340"
def test_a_shop_internal_sku_is_not_treated_as_a_barcode():
"""Shops put anything in `sku`. `validate_barcode` decides, so an internal
numbering scheme never reaches the barcode column - Gopuram leaves it blank
and others use their own codes."""
for junk in ("", "GOP-001", "12345"):
product = json.loads(json.dumps(SHOPIFY_PRODUCT))
product["variants"][0]["sku"] = junk
assert bs._shopify_candidates(product)[0]["barcode"] is None, junk
def test_default_title_is_not_a_pack_size():
"""Shopify's placeholder for a product with no variant axis."""
product = json.loads(json.dumps(SHOPIFY_PRODUCT))
product["variants"][0]["title"] = "Default Title"
product["variants"][0].pop("sku")
assert bs._shopify_candidates(product)[0]["size"] is None
def test_woocommerce_prices_are_minor_units():
"""gopuramproducts.com returns price "5500" with currency_minor_unit 2,
which is Rs55.00 and not Rs5,500. Read straight, every price on the site is
a hundred times too large - and `validate_price_range` would then reject
every row, so the bug would present as "this brand is all wrong" rather
than as a units error."""
row = bs._woo_candidates(WOO_PRODUCT)[0]
assert row["price"] == 55.00
assert row["category"] == "Pooja Products"
assert row["title"] == "GOPURAM PREMIUM SAMBRAIN POWDER"
def test_a_utf8_bom_does_not_break_parsing():
"""WooCommerce really serves one; `resp.json()` raises on it."""
class Resp:
status_code = 200
headers = {"content-type": "application/json; charset=UTF-8"}
content = b"\xef\xbb\xbf" + json.dumps([WOO_PRODUCT]).encode()
url = "https://example.com"
parsed = bs._json_or_none(Resp())
assert isinstance(parsed, list) and parsed[0]["name"].startswith("GOPURAM")
# ---------------------------------------------------------------------------
# The sitemap tier states only what the site states
# ---------------------------------------------------------------------------
def test_the_sitemap_tier_never_invents_a_size_or_price():
"""udhaiyamdhall.com is a brochure site: an <h1> and a photograph, no
price, no pack size, no variants. Those stay None. A catalogue source that
guesses them is the failure this whole area exists to fix."""
import inspect
source = inspect.getsource(bs.fetch_sitemap)
assert '"size": None' in source
assert '"price": None' in source
assert '"barcode": None' in source
# ---------------------------------------------------------------------------
# The registry
# ---------------------------------------------------------------------------
def test_double_horse_and_tenali_double_horse_stay_apart():
"""TWO COMPANIES, ONE NAME. Double Horse is Manjilas of Kerala; Tenali
Double Horse is an Andhra rice brand.
`_contains_word` matches whole words in BOTH directions, and "double horse"
is a whole word sequence inside "tenali double horse". Registering only one
of them moved the collision instead of fixing it - measured: with only the
longer key present, resolve_parent_brand("Double Horse") returned
"tenali double horse". Each direct entry protects the OTHER brand.
"""
assert resolve_parent_brand("Tenali Double Horse") == "tenali double horse"
assert resolve_parent_brand("Double Horse") == "double horse"
assert resolve_parent_brand("Tenali Double Horse") != resolve_parent_brand("Double Horse")
def test_spelling_variants_reach_the_same_brand():
assert resolve_parent_brand("Udhayam") == "udhaiyam"
assert resolve_parent_brand("DoubleHorse") == "double horse"
def test_a_brand_with_no_verified_store_has_none():
"""Absent is the safe answer. A guessed domain is not - Naga's search
result is a Philippine city council."""
assert get_brand_store_domain("Naga") is None
assert get_brand_store_domain("Tenali Double Horse") is None
assert get_brand_store_domain("Gopuram") == "gopuramproducts.com"
def test_existing_brands_are_unaffected():
for brand, parent in [("Cadbury Dairy Milk", "cadbury"),
("Haldiram", "haldirams"),
("Sunfeast Yippee", "sunfeast")]:
assert resolve_parent_brand(brand) == parent
# ---------------------------------------------------------------------------
# Discovery integration
# ---------------------------------------------------------------------------
def test_a_store_row_outranks_open_food_facts():
"""A manufacturer's own catalogue is first-party; Open Food Facts is
crowd-sourced and, for these brands, absent or wrong."""
from app.services import brand_discovery as bd
assert bd._score(["store"], None) > bd._score(["off"], "openfacts")
assert bd._score(["store", "off"], "openfacts") == 1.0
def test_a_store_row_survives_the_evidence_gate():
"""WITHOUT THIS THE WHOLE SOURCE IS INVISIBLE. `require_evidence` drops any
row with no `off` source and no evidence, and a store row has neither - it
would score 0.25 and never reach the preview."""
from app.services import brand_discovery as bd
import inspect
gate = inspect.getsource(bd.discover_brand_products)
assert '"store" not in product.sources' in gate
def test_discovery_reads_the_store_cache_and_never_fetches(monkeypatch):
"""Same split as the retail check: the backfill script fetches, discovery
reads. A preview must not block on somebody's storefront."""
from app.services import brand_discovery as bd
def explode(*_a, **_kw):
raise AssertionError("discovery must not fetch a storefront")
monkeypatch.setattr(bs, "fetch_shopify", explode)
monkeypatch.setattr(bs, "fetch_woocommerce", explode)
monkeypatch.setattr(bs, "fetch_sitemap", explode)
monkeypatch.setattr(bs, "read_cached", lambda b: [dict(SHOPIFY_PRODUCT, source="store")])
rows = bd._from_brand_store("Aachi")
assert rows and rows[0]["source"] == "store"