unpopular brand generation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
571
app/services/brand_store.py
Normal 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
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
850
data/cache/brand_store/anil.json
vendored
Normal 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
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
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
393
data/cache/brand_store/udhaiyam.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
data/cache/retail_presence.db
vendored
BIN
data/cache/retail_presence.db
vendored
Binary file not shown.
150
scripts/backfill_brand_stores.py
Normal file
150
scripts/backfill_brand_stores.py
Normal 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
274
tests/test_brand_store.py
Normal 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"
|
||||
Reference in New Issue
Block a user