# app.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SaveNest — 'Fetch Everything' Crawler API
=========================================
- Enumerates product URLs via sitemaps (Shopify / Woo + generic sitemap discovery).
- Optional fallback BFS enumerator for stores without sitemaps (lightweight, bounded).
- Scrapes product details with JSON-LD first; CSS fallback; optional Playwright (Firefox) for JS-heavy pages (Daraz etc).
- Stateless on the server: supports paginated crawling (cursor/limit). You can persist the results in Firebase/Firestore from your app.
Endpoints:
- GET /health
- GET / -> intro JSON
- GET /crawl -> enumerate + scrape entire store (paginated)
params:
store : domain OR key (e.g. "alfatah.pk", "daraz.pk")
limit : number of product pages to fetch this call (default 100, max 500)
cursor : resume offset (int; default 0)
mode : "full" (default) or "urls" (only URLs, no scraping)
use_js : 0/1 override for Playwright for this request (default: env USE_PLAYWRIGHT)
- GET /search -> per-query live search (kept from previous build)
Designed for HuggingFace Spaces free tier:
- Restrict limit per call to avoid timeouts; use `cursor` to continue next page.
- Uses a gentle rate limiter per host.
"""
import asyncio, time, re, json, os, gzip, io
from typing import Optional, Dict, Any, List, Tuple, Iterable
from urllib.parse import urljoin, urlparse, quote_plus
from dataclasses import dataclass
from fastapi.middleware.cors import CORSMiddleware
import os
import httpx
from bs4 import BeautifulSoup
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse, HTMLResponse
from rapidfuzz import fuzz
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
logger = logging.getLogger(__name__)
# ---------------------- Runtime config (via env) ----------------------
USE_PLAYWRIGHT_DEFAULT = os.getenv("USE_PLAYWRIGHT", "1") == "1" # enable JS fallback by default now
PER_HOST_GAP = float(os.getenv("PER_HOST_GAP", "2")) # politeness per host (seconds)
REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "30")) # seconds
TTL_SECONDS = int(os.getenv("TTL_SECONDS", "180")) # page TTL cache
MAX_CONCURRENCY = int(os.getenv("MAX_CONCURRENCY", "5")) # parallel HTTP requests
CRAWL_LIMIT_CAP = int(os.getenv("CRAWL_LIMIT_CAP", "500")) # per-call max items
MAX_FALLBACK_BFS_PAGES = int(os.getenv("MAX_FALLBACK_BFS_PAGES", "50")) # BFS page cap if no sitemap
# ---------------------- Store registry (extend as needed) ------------
# Each entry supports:
# base: canonical base url
# platform: "shopify" | "woo" | "custom"
# product_link_patterns: regex patterns to recognize product pages
# product_fallback: CSS selectors for title/price if no JSON-LD
# sitemap_hints: list of potential sitemap endpoints
# bfs_seeds: optional seeds for BFS (when no sitemap is available)
# ---------------------- Store registry (extend as needed) ------------
STORES: List[Dict[str, Any]] = [
# Existing — keep as-is
{"base": "https://alfatah.pk", "platform": "shopify"},
# NEW STORES from API #2
{
"base": "https://qne.com.pk",
"platform": "custom",
"search_url_template": "{base}/search?type=product&q={query}",
"product_link_patterns": [r"/products?/[-a-z0-9]+", r"/p/[-a-z0-9]+"],
"product_fallback": {
"title": "h1.product-title, h1.entry-title, .product-name",
"price": ".price, .amount, span.price, .woocommerce-Price-amount",
"image": ".product-image img, img[src*='product'], .attachment-woocommerce_thumbnail"
}
},
{
"base": "https://springs.com.pk",
"platform": "custom",
"search_url_template": "{base}/search?q={query}",
"product_link_patterns": [r"/products?/[-a-z0-9]+"],
"product_fallback": {
"title": "h1.product_title, h1.entry-title, h1[itemprop='name']",
"price": ".price ins .amount, .price > span, .woocommerce-Price-amount",
"image": ".woocommerce-product-gallery__image img, img.wp-post-image, img.attachment-product"
}
},
{
"base": "https://vmart.pk",
"platform": "custom",
"search_url_template": "{base}/search?q={query}",
"product_link_patterns": [r"/products?/[-a-z0-9]+"],
"product_fallback": {
"title": "h1.product-title, .product-name, h1[itemprop='name']",
"price": ".price, .amount, span.price-current",
"image": "img.product-image, .main-image img, img[src*='product']"
}
},
{
"base": "https://grocerapp.pk",
"platform": "custom",
"search_url_template": "{base}/search?q={query}",
"sitemap_hints": [
"https://grocerapp.pk/sitemap_index.xml",
"https://grocerapp.pk/sitemap_categories.xml",
"https://grocerapp.pk/sitemap_subcategories.xml",
],
"product_link_patterns": [r"/products/[-a-z0-9]+-\d+/?$"],
"product_fallback": {
"title": "h1.product-title, .product-detail h1, h1[itemprop='name']",
"price": ".price-current, .amount, .sale-price",
"image": ".product-image img, img.main-product-image, img[src*='product']"
}
},
]
STORE_DISPLAY_NAMES = {
"alfatah.pk": "Al-Fatah",
"qne.com.pk": "QnE",
"springs.com.pk": "Springs Store",
"vmart.pk": "Vmart",
"grocerapp.pk": "GrocerApp",
}
# ---------------------- Helpers --------------------------------------
def extract_og_image(soup: BeautifulSoup, page_url: str) -> Optional[str]:
# 1)
meta = soup.find("meta", attrs={"property": "og:image"}) or soup.find("meta", attrs={"name": "og:image"})
if meta and meta.get("content"):
return urljoin(page_url, meta["content"].strip())
# 2)
link = soup.find("link", rel=lambda v: v and "image_src" in v)
if link and link.get("href"):
return urljoin(page_url, link["href"].strip())
return None
def d(u: str) -> str:
return urlparse(u).netloc.lower().replace("www.", "")
def abs_url(base: str, href: str) -> str:
return urljoin(base.rstrip("/") + "/", href)
def _norm(s: str) -> str:
return re.sub(r"[^a-z0-9\s]+", " ", (s or "").lower()).strip()
def clean_price(text: str) -> Optional[float]:
if not text:
return None
t = text.replace(",", "")
m = re.search(r"(\d+(?:\.\d{1,2})?)", t)
return float(m.group(1)) if m else None
def guess_store(domain_or_key: str) -> Optional[Dict[str, Any]]:
key = domain_or_key.strip().lower()
for s in STORES:
if key in d(s["base"]) or key in s["base"].lower():
return s
return None
# politeness limiter
_last_hit: Dict[str, float] = {}
async def rate_limit(host: str, min_gap: float = PER_HOST_GAP):
last = _last_hit.get(host, 0.0)
dt = time.time() - last
if dt < min_gap:
await asyncio.sleep(min_gap - dt)
_last_hit[host] = time.time()
BROWSER_HEADERS = {
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8",
"Cache-Control": "no-cache", "Pragma": "no-cache",
}
# in-memory TTL cache
_cache: Dict[str, Tuple[float, Optional[str]]] = {}
def cache_get(url: str) -> Optional[str]:
v = _cache.get(url)
if not v:
return None
ts, body = v
if time.time() - ts > TTL_SECONDS:
_cache.pop(url, None)
return None
return body
def cache_put(url: str, body: Optional[str]):
_cache[url] = (time.time(), body)
async def fetch_text(client: httpx.AsyncClient, url: str, referer: Optional[str] = None) -> Optional[str]:
cached = cache_get(url)
if cached is not None:
return cached
try:
await rate_limit(urlparse(url).netloc)
headers = dict(BROWSER_HEADERS)
if referer:
headers["Referer"] = referer
r = await client.get(url, headers=headers, timeout=REQUEST_TIMEOUT, follow_redirects=True)
if r.status_code != 200:
cache_put(url, None)
return None
# gunzip transparently if needed
content = r.content
if r.headers.get("Content-Encoding", "").lower() == "gzip" or url.endswith(".gz"):
try:
content = gzip.decompress(content)
except Exception:
pass
text = content.decode(r.encoding or "utf-8", errors="ignore")
cache_put(url, text)
return text
except Exception:
cache_put(url, None)
return None
# optional Playwright (Firefox) renderer
async def fetch_js(url: str) -> Optional[str]:
try:
from playwright.async_api import async_playwright
except Exception:
return None
try:
async with async_playwright() as p:
browser = await p.firefox.launch(headless=True)
page = await browser.new_page(user_agent=BROWSER_HEADERS["User-Agent"])
await page.goto(url, wait_until="domcontentloaded", timeout=int((REQUEST_TIMEOUT+10) * 1000))
await page.wait_for_timeout(1200)
html = await page.content()
await browser.close()
return html
except Exception:
return None
# ---------------------- Parsing product pages -------------------------
@dataclass
class Offer:
source: str
title: str
price: Optional[float]
currency: Optional[str]
url: str
image: Optional[str] = None
in_stock: Optional[bool] = None
image_url: Optional[str] = None
SERVICE_BLACKLIST = [
"service", "services", "installation", "repair", "fixing", "cleaning",
"alignment", "movers", "packers", "examination", "inspection", "consultation"
]
def looks_like_service(title: str) -> bool:
t = (title or "").lower()
return any(w in t for w in SERVICE_BLACKLIST)
def parse_ldjson(html: str, page_url: str) -> List[Offer]:
soup = BeautifulSoup(html or "", "html.parser")
out: List[Offer] = []
for tag in soup.find_all("script", type="application/ld+json"):
raw = tag.string or ""
if not raw.strip():
continue
try:
data = json.loads(raw)
except Exception:
continue
blocks = data if isinstance(data, list) else [data]
for b in blocks:
if not isinstance(b, dict):
continue
typ = b.get("@type")
is_product = (isinstance(typ, list) and "Product" in typ) or (typ == "Product")
if not is_product:
continue
name = (b.get("name") or "").strip()
url = b.get("url") or page_url
# ✅ Improved image extraction
img = b.get("image")
if isinstance(img, dict):
img = img.get("url") or img.get("@id")
elif isinstance(img, list) and img:
img = img[0]
image_url = str(img) if isinstance(img, str) else None
# Handle offers
offs = b.get("offers")
arr = offs if isinstance(offs, list) else [offs] if offs else []
if not arr:
# ✅ Fallback: check priceSpecification or aggregateOffer
ps = b.get("priceSpecification")
if isinstance(ps, dict):
price = clean_price(str(ps.get("price") or ""))
cur = ps.get("priceCurrency")
if name and price is not None:
out.append(Offer(
source=d(page_url),
title=name,
price=price,
currency=cur,
url=url,
image_url=image_url
))
agg = b.get("aggregateOffer")
if isinstance(agg, dict):
price = clean_price(str(agg.get("lowPrice") or agg.get("price") or ""))
cur = agg.get("priceCurrency")
if name and price is not None:
out.append(Offer(
source=d(page_url),
title=name,
price=price,
currency=cur,
url=url,
image_url=image_url
))
for o in arr:
if not isinstance(o, dict):
continue
price = o.get("price")
if price is None and isinstance(o.get("priceSpecification"), dict):
price = o["priceSpecification"].get("price")
currency = o.get("priceCurrency") or (o.get("priceSpecification") or {}).get("priceCurrency")
avail = o.get("availability")
in_stock = None
if isinstance(avail, str):
in_stock = "OutOfStock" not in avail and "SoldOut" not in avail
p = clean_price(str(price) if price is not None else "")
if name and p is not None:
out.append(Offer(
source=d(page_url),
title=name,
price=p,
currency=currency,
url=url,
in_stock=in_stock,
image_url=image_url
))
return out
def css_text(soup: BeautifulSoup, sel: Optional[str]) -> Optional[str]:
if not sel:
return None
node = soup.select_one(sel)
return node.get_text(" ", strip=True) if node else None
def css_attr(soup: BeautifulSoup, sel: Optional[str], attr: str) -> Optional[str]:
if not sel:
return None
node = soup.select_one(sel)
return node.get(attr) if node else None
def parse_product_fallback(html: str, page_url: str, fallback: Dict[str, str]) -> Optional[Offer]:
soup = BeautifulSoup(html or "", "html.parser")
title = css_text(soup, fallback.get("title"))
price_text = css_text(soup, fallback.get("price"))
price = clean_price(price_text or "")
# ✅ Improved image fallback
image_url = css_attr(soup, fallback.get("image", "img[src]"), "src")
if not image_url:
image_url = extract_og_image(soup, page_url)
if title and price is not None:
return Offer(
source=d(page_url),
title=title,
price=price,
currency=None,
url=page_url,
image_url=image_url # ✅ Updated field
)
return None
async def parse_product(
client: httpx.AsyncClient,
store: Dict[str, Any],
url: str,
use_js: bool,
fetch_func=fetch_text # ← NEW: custom fetcher, defaults to global one for backward compatibility
) -> List[Offer]:
html = await fetch_func(client, url, referer=store["base"])
if not html and use_js:
html = await fetch_js(url)
if not html:
return []
offers = parse_ldjson(html, url)
if offers:
return offers
# CSS fallback by platform
if store["platform"] == "shopify":
fallback = {
"title": "h1.product__title, h1.product-title, h1.page-title",
"price": ".price-item--regular, .price .price__regular, span.price",
"image": "img[src]"
}
elif store["platform"] == "woo":
fallback = {
"title": "h1.product_title, h1.entry-title",
"price": "p.price, span.price, bdi",
"image": "img[src]"
}
else:
fallback = store.get("product_fallback", {
"title": "h1, h1.product-title",
"price": "span.price, .price",
"image": "img[src]"
})
one = parse_product_fallback(html, url, fallback) if fallback else None
return [one] if one else []
# ---------------------- Sitemap discovery & enumeration ---------------
SITEMAP_HINTS_DEFAULT = [
"/sitemap.xml",
"/sitemap_index.xml",
"/sitemap-index.xml",
]
async def discover_sitemaps(client: httpx.AsyncClient, base: str, store: Dict[str, Any]) -> List[str]:
results: List[str] = []
# robots.txt
robots = await fetch_text(client, abs_url(base, "/robots.txt"))
if robots:
for line in robots.splitlines():
if line.lower().startswith("sitemap:"):
sm = line.split(":", 1)[1].strip()
if sm and sm not in results:
results.append(sm)
# hints from store
for h in store.get("sitemap_hints", []):
results.append(h)
# defaults
for h in SITEMAP_HINTS_DEFAULT:
results.append(abs_url(base, h))
# uniq, keep order
seen = set(); out = []
for u in results:
if u not in seen:
seen.add(u); out.append(u)
return out
def _extract_xml_urls(xml_text: str) -> List[str]:
urls: List[str] = []
soup = BeautifulSoup(xml_text or "", "xml") # needs 'lxml' or bs4's xml parser
for loc in soup.find_all("loc"):
if loc and loc.text:
urls.append(loc.text.strip())
return urls
async def iter_sitemap_tree(client: httpx.AsyncClient, sitemap_url: str, max_nodes: int = 1000) -> List[str]:
"""Return flattened list of URLs from a (possibly nested) sitemap index."""
urls: List[str] = []
seen: set = set()
queue: List[str] = [sitemap_url]
while queue and len(seen) < max_nodes:
u = queue.pop(0)
if u in seen:
continue
seen.add(u)
txt = await fetch_text(client, u)
if not txt:
continue
locs = _extract_xml_urls(txt)
if any(u2.endswith(".xml") or "/sitemap" in u2 for u2 in locs):
# child sitemaps
for c in locs:
if c not in seen and len(seen) + len(queue) < max_nodes:
queue.append(c)
else:
urls.extend(locs)
return urls
def _match_product(url: str, store: Dict[str, Any]) -> bool:
pats = store.get("product_link_patterns")
if pats:
return any(re.search(p, url) for p in pats)
# platform defaults
if store["platform"] == "shopify":
return re.search(r"/products?/[-a-z0-9]+/?$", url) is not None
if store["platform"] == "woo":
return re.search(r"/product/[-a-z0-9]+/?$", url) is not None
return True # best effort
async def enumerate_products_via_sitemaps(client: httpx.AsyncClient, store: Dict[str, Any]) -> List[str]:
base = store["base"]
roots = await discover_sitemaps(client, base, store)
all_urls: List[str] = []
for sm in roots:
items = await iter_sitemap_tree(client, sm, max_nodes=5000)
all_urls.extend(items)
# filter to same domain + product patterns
dd = d(base)
prods = []
seen = set()
for u in all_urls:
if d(u).endswith(dd) and _match_product(u, store):
if u not in seen:
seen.add(u); prods.append(u)
return prods
# ---------------------- Fallback BFS (bounded) ------------------------
async def enumerate_products_via_bfs(client: httpx.AsyncClient, store: Dict[str, Any]) -> List[str]:
base = store["base"].rstrip("/")
seeds = store.get("bfs_seeds") or [base]
dd = d(base)
to_visit = list({abs_url(base, s) for s in seeds})
visited = set()
found: List[str] = []
pages = 0
while to_visit and pages < MAX_FALLBACK_BFS_PAGES and len(found) < 8000:
url = to_visit.pop(0)
if url in visited:
continue
visited.add(url)
pages += 1
html = await fetch_text(client, url, referer=base)
if not html:
continue
soup = BeautifulSoup(html, "html.parser")
# harvest links
for a in soup.find_all("a", href=True):
href = abs_url(base, a["href"])
if d(href).endswith(dd):
if _match_product(href, store):
if href not in found:
found.append(href)
elif href not in visited and len(to_visit) + pages < MAX_FALLBACK_BFS_PAGES * 20:
# enqueue further category/listing pages conservatively
if re.search(r"(category|catalog|search|page=|/c/|/s/)", href, re.I):
to_visit.append(href)
return found
# ---------------------- Query expansion (reused search) ---------------
async def harvest_shopify_suggestions(client: httpx.AsyncClient, stores: List[dict], query: str, limit_per_store: int = 10) -> List[str]:
suggestions: List[str] = []
for s in stores:
if s.get("platform") != "shopify":
continue
base = s["base"].rstrip("/")
sug_url = f"{base}/search/suggest.json?q={quote_plus(query)}&resources[type]=product&resources[limit]={limit_per_store}"
txt = await fetch_text(client, sug_url, referer=base)
if not txt:
continue
try:
data = json.loads(txt)
prods = data.get("resources", {}).get("results", {}).get("products", [])
for p in prods:
title = p.get("title") or p.get("handle") or ""
if title:
suggestions.append(title.strip())
except Exception:
continue
# de-dupe case-insensitive
seen = set(); uniq = []
for s in suggestions:
k = s.lower()
if k not in seen:
seen.add(k); uniq.append(s)
return uniq
# ---------------------- FastAPI app -----------------------------------
app = FastAPI(title="SaveNest Everything Crawler API", version="2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # or restrict to your app’s domain(s)
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/", response_class=HTMLResponse)
async def root():
return """
SaveNest Crawler API
🛒
SaveNest API
v2.0 Live
📖 Docs
🕷️ Grocery Price Intelligence
Crawl. Compare.
Save Smarter.
Real-time product price scraping across Pakistan's top grocery stores.
Sitemap enumeration, BFS fallback, JSON-LD parsing — all in one API.
5
Stores
500
Max / Call
3
Endpoints
PKR
Currency
Endpoints
Returns API operational status.
{
"ok": true
}
Search for a product across all registered stores simultaneously. Returns deduplicated, price-sorted results.
| Parameter | Type | Description |
| qrequired |
string |
Product keyword (e.g., "milk", "bread", "laptop") |
| limitoptional |
integer |
Max results (1–100, default: 40) |
{
"query": "milk",
"count": 4,
"offers": [
{
"source": "Al-Fatah",
"title": "Olpers Full Cream Milk 1L",
"price": 285.0,
"currency": "PKR",
"url": "https://alfatah.pk/products/...",
"image": "https://...",
"in_stock": true
}
]
}
Enumerate ALL product URLs from a store via sitemaps (BFS fallback) and optionally scrape details.
Use cursor for pagination.
| Parameter | Type | Description |
| storerequired |
string |
Domain key, e.g. "alfatah.pk" |
| limitoptional |
integer |
Items per call (1–500, default: 100) |
| cursoroptional |
integer |
Resume offset for pagination (default: 0) |
| modeoptional |
string |
"full" (scrape) or "urls" (enumerate only) |
| use_jsoptional |
0|1 |
Override Playwright JS rendering |
{
"store": "alfatah.pk",
"count": 48,
"items": [ /* array of product objects */ ],
"next_cursor": 50,
"total_urls": 1240,
"enumeration": "sitemap_or_bfs",
"js_fallback": true
}
Live API Tester
Response will appear here...
Response will appear here...
Check if the API is running and operational.
Response will appear here...
"""
@app.get("/health")
async def health():
return {"ok": True}
# SaveNest app.py
@app.get("/documentation")
async def documentation():
from fastapi.responses import RedirectResponse
return RedirectResponse("https://aero-woad.vercel.app/savenest-docs.html")
@app.get("/crawl")
async def crawl(
store: str = Query(..., description="Domain or key, e.g., 'alfatah.pk', 'daraz.pk'"),
limit: int = Query(100, ge=1, le=CRAWL_LIMIT_CAP),
cursor: int = Query(0, ge=0),
mode: str = Query("full", pattern="^(full|urls)$"),
use_js: Optional[int] = Query(None, description="Override USE_PLAYWRIGHT (1 or 0)"),
):
"""
Enumerate ALL product URLs for a store (via sitemaps, else BFS) and optionally scrape details.
Pagination: call repeatedly with `cursor=next_cursor` until next_cursor is None.
"""
s = guess_store(store)
if not s:
raise HTTPException(400, f"Unknown store: {store}")
use_js_effective = USE_PLAYWRIGHT_DEFAULT if use_js is None else (use_js == 1)
async with httpx.AsyncClient(follow_redirects=True, timeout=REQUEST_TIMEOUT) as client:
# 1) enumerate product URLs
urls = await enumerate_products_via_sitemaps(client, s)
if not urls:
urls = await enumerate_products_via_bfs(client, s)
if not urls:
return {"store": store, "count": 0, "items": [], "next_cursor": None, "enumeration": "none"}
# 2) paginate
start = cursor
end = min(len(urls), start + limit)
batch = urls[start:end]
if mode == "urls":
return {
"store": store,
"count": len(batch),
"items": [{"url": u} for u in batch],
"next_cursor": (end if end < len(urls) else None),
"total_urls": len(urls),
"enumeration": "sitemap_or_bfs"
}
# 3) scrape product pages (concurrently, politely)
sem = asyncio.Semaphore(MAX_CONCURRENCY)
async def _one(u: str):
async with sem:
try:
return await parse_product(client, s, u, use_js_effective)
except Exception:
return []
tasks = [_one(u) for u in batch]
parsed = await asyncio.gather(*tasks, return_exceptions=True)
# flatten + clean
offers: List[Offer] = []
for r in parsed:
if isinstance(r, Exception) or not r:
continue
offers.extend(r)
def _ok(o: Offer) -> bool:
return o.title and (o.price is not None) and not looks_like_service(o.title)
items = [{
"source": STORE_DISPLAY_NAMES.get(o.source, o.source.replace(".pk", "").title()),
"title": o.title,
"price": o.price,
"currency": o.currency or "PKR",
"url": o.url,
"image": o.image_url,
"in_stock": o.in_stock,
} for o in offers if _ok(o)]
# de-duplicate by normalized title+source keep cheapest
keyd: Dict[Tuple[str, str], Dict[str, Any]] = {}
for it in items:
k = (it["source"], _norm(it["title"]))
if k not in keyd or (it["price"] or 1e18) < (keyd[k]["price"] or 1e18):
keyd[k] = it
items = list(keyd.values())
items.sort(key=lambda x: (x["price"] if x["price"] is not None else 1e18, x["title"].lower()))
return {
"store": store,
"count": len(items),
"items": items,
"next_cursor": (end if end < len(urls) else None),
"total_urls": len(urls),
"enumeration": "sitemap_or_bfs",
"js_fallback": use_js_effective,
}
# (kept) lightweight live search from previous build, handy for ad-hoc queries
@app.get("/search")
async def search(
q: str = Query(..., description="Product keyword (e.g., 'bread', 'milk', 'laptop')"),
limit: int = Query(40, ge=1, le=100),
):
"""
Search for products across all stores.
✅ FIX #1: Per-request local cache (no global pollution)
✅ FIX #2: Proper search_url_template usage
✅ Comprehensive logging for debugging
"""
request_id = f"{int(time.time() * 1000) % 100000}"
logger.info(f"[{request_id}] ========== NEW SEARCH REQUEST ==========")
logger.info(f"[{request_id}] Query: '{q}' | Limit: {limit}")
q = q.strip()
if not q:
raise HTTPException(status_code=400, detail="Missing query")
# 🔧 FIX #1: Per-request local cache and rate limiter
local_cache: Dict[str, Tuple[float, Optional[str]]] = {}
local_last_hit: Dict[str, float] = {}
logger.info(f"[{request_id}] 🆕 Fresh local cache created (global cache has {len(_cache)} entries but will be ignored for product parsing)")
logger.info(f"[{request_id}] 🆕 Created fresh local cache for this request")
def cache_get(url: str) -> Optional[str]:
v = local_cache.get(url)
if not v:
return None
ts, body = v
if time.time() - ts > TTL_SECONDS:
local_cache.pop(url, None)
logger.debug(f"[{request_id}] ⏰ Cache expired for {url}")
return None
logger.debug(f"[{request_id}] 💾 Cache hit for {url}")
return body
def cache_put(url: str, body: Optional[str]):
local_cache[url] = (time.time(), body)
status = "✅ cached" if body else "❌ cached as None"
logger.debug(f"[{request_id}] {status}: {url}")
async def rate_limit_local(host: str, min_gap: float = PER_HOST_GAP):
last = local_last_hit.get(host, 0.0)
dt = time.time() - last
if dt < min_gap:
sleep_time = min_gap - dt
logger.debug(f"[{request_id}] ⏳ Rate limiting {host}: sleeping {sleep_time:.2f}s")
await asyncio.sleep(sleep_time)
local_last_hit[host] = time.time()
async def fetch_text_local(client: httpx.AsyncClient, url: str, referer: Optional[str] = None) -> Optional[str]:
cached = cache_get(url)
if cached is not None:
return cached
try:
await rate_limit_local(urlparse(url).netloc)
headers = dict(BROWSER_HEADERS)
if referer:
headers["Referer"] = referer
logger.debug(f"[{request_id}] 🌐 Fetching: {url}")
r = await client.get(url, headers=headers, timeout=REQUEST_TIMEOUT, follow_redirects=True)
if r.status_code != 200:
logger.warning(f"[{request_id}] ⚠️ HTTP {r.status_code} for {url}")
cache_put(url, None)
return None
content = r.content
if r.headers.get("Content-Encoding", "").lower() == "gzip" or url.endswith(".gz"):
try:
content = gzip.decompress(content)
except Exception as e:
logger.warning(f"[{request_id}] ⚠️ Gunzip failed for {url}: {e}")
text = content.decode(r.encoding or "utf-8", errors="ignore")
logger.debug(f"[{request_id}] ✅ Fetched {len(text)} bytes from {url}")
cache_put(url, text)
return text
except Exception as e:
logger.error(f"[{request_id}] ❌ Fetch error for {url}: {e}")
cache_put(url, None)
return None
async with httpx.AsyncClient(follow_redirects=True, timeout=REQUEST_TIMEOUT) as client:
async def _links_for_store(s: Dict[str, Any]) -> List[str]:
store_name = d(s["base"])
logger.info(f"[{request_id}] 🏪 Processing store: {store_name} ({s['platform']})")
links: List[str] = []
base = s["base"].rstrip("/")
# ---------- Shopify ----------
if s["platform"] == "shopify":
# 1) Try suggest.json
sug_url = f"{base}/search/suggest.json?q={quote_plus(q)}&resources[type]=product&resources[limit]=10"
logger.info(f"[{request_id}] → Shopify suggest: {sug_url}")
txt = await fetch_text_local(client, sug_url, referer=base)
if txt:
logger.info(f"[{request_id}] ✅ Suggest API response: {len(txt)} bytes")
try:
data = json.loads(txt)
prods = data.get("resources", {}).get("results", {}).get("products", [])
logger.info(f"[{request_id}] 📦 Found {len(prods)} products in suggest JSON")
for p in prods:
if "handle" in p:
product_url = abs_url(base, f"/products/{p['handle']}")
links.append(product_url)
except Exception as e:
logger.error(f"[{request_id}] ❌ JSON parse error: {e}")
else:
logger.warning(f"[{request_id}] ⚠️ No response from suggest API")
# 2) Fallback: ?view=json
if not links:
json_url = f"{base}/search?q={quote_plus(q)}&view=json"
logger.info(f"[{request_id}] → Shopify JSON view fallback: {json_url}")
txt = await fetch_text_local(client, json_url, referer=base)
if txt and txt.strip().startswith("{"):
try:
data = json.loads(txt)
prods = data.get("products", [])
logger.info(f"[{request_id}] 📦 Found {len(prods)} products in JSON view")
for prod in prods:
if "url" in prod:
links.append(abs_url(base, prod["url"]))
except Exception as e:
logger.error(f"[{request_id}] ❌ JSON view parse error: {e}")
# 3) Fallback: HTML scrape
if not links:
search_url = f"{base}/search?q={quote_plus(q)}"
logger.info(f"[{request_id}] → Shopify HTML fallback: {search_url}")
html = await fetch_text_local(client, search_url, referer=base)
if html:
logger.info(f"[{request_id}] ✅ HTML response: {len(html)} bytes")
soup = BeautifulSoup(html, "html.parser")
for a in soup.find_all("a", href=True):
href = abs_url(base, a["href"])
if _match_product(href, s):
links.append(href)
if len(links) >= 10:
break
logger.info(f"[{request_id}] 📦 Scraped {len(links)} product links")
else:
logger.warning(f"[{request_id}] ⚠️ No HTML response")
# ---------- WooCommerce ----------
elif s["platform"] == "woo":
search_url = f"{base}/?s={quote_plus(q)}&post_type=product"
logger.info(f"[{request_id}] → WooCommerce: {search_url}")
html = await fetch_text_local(client, search_url, referer=base)
if html:
logger.info(f"[{request_id}] ✅ HTML response: {len(html)} bytes")
soup = BeautifulSoup(html, "html.parser")
for a in soup.find_all("a", href=True):
href = abs_url(base, a["href"])
if _match_product(href, s):
links.append(href)
if len(links) >= 10:
break
logger.info(f"[{request_id}] 📦 Scraped {len(links)} product links")
else:
logger.warning(f"[{request_id}] ⚠️ No HTML response")
# ---------- Custom ----------
else:
# 🔧 FIX #2: Use search_url_template if defined
search_template = s.get("search_url_template")
if search_template:
search_url = search_template.format(base=base, query=quote_plus(q))
logger.info(f"[{request_id}] → Custom (template): {search_url}")
else:
search_url = f"{base}/search?q={quote_plus(q)}"
logger.info(f"[{request_id}] → Custom (generic): {search_url}")
# Special Daraz handling
if "daraz.pk" in base:
logger.info(f"[{request_id}] 🔧 Daraz: using JS rendering")
if USE_PLAYWRIGHT_DEFAULT:
html = await fetch_js(search_url)
else:
html = await fetch_text_local(client, search_url, referer=base)
else:
html = await fetch_text_local(client, search_url, referer=base)
if html:
logger.info(f"[{request_id}] ✅ HTML response: {len(html)} bytes")
soup = BeautifulSoup(html, "html.parser")
for a in soup.find_all("a", href=True):
href = abs_url(base, a["href"])
if _match_product(href, s):
links.append(href)
if len(links) >= 10:
break
logger.info(f"[{request_id}] 📦 Scraped {len(links)} product links")
else:
logger.warning(f"[{request_id}] ⚠️ No HTML response")
logger.info(f"[{request_id}] ✅ {store_name}: collected {len(links)} links, returning top {min(len(links), 6)}")
return links[:6]
# Gather links from all stores
logger.info(f"[{request_id}] 🚀 Starting parallel search across {len(STORES)} stores...")
link_lists = await asyncio.gather(*[_links_for_store(s) for s in STORES])
# Flatten and deduplicate
product_urls = []
seen = set()
for links in link_lists:
for url in links:
if url not in seen:
seen.add(url)
product_urls.append(url)
logger.info(f"[{request_id}] 📊 Total unique product URLs: {len(product_urls)}")
logger.info(f"[{request_id}] 💾 Local cache entries: {len(local_cache)}")
logger.info(f"[{request_id}] 💾 Global cache entries: {len(_cache)} (should not be used)")
product_urls = product_urls[:limit * 3]
# Parse product pages
sem = asyncio.Semaphore(MAX_CONCURRENCY)
logger.info(f"[{request_id}] 🔧 Parsing {min(len(product_urls), limit)} product pages...")
async def _one(u: str):
async with sem:
try:
store_config = guess_store(d(u)) or {"base": u, "platform": "custom"}
return await parse_product(
client,
store_config,
u,
USE_PLAYWRIGHT_DEFAULT,
fetch_func=fetch_text_local # ← CRITICAL: use per-request cache
)
except Exception as e:
logger.error(f"[{request_id}] ❌ Parse error for {u}: {e}")
return []
parsed = await asyncio.gather(*[_one(u) for u in product_urls[:limit]], return_exceptions=True)
logger.info(f"[{request_id}] ✅ Parsing complete")
# Flatten + clean
offers: List[Offer] = []
for r in parsed:
if isinstance(r, Exception) or not r:
continue
offers.extend(r)
logger.info(f"[{request_id}] 📦 Total offers extracted: {len(offers)}")
# Build items
items = []
for o in offers:
if not o.title or o.price is None:
continue
items.append({
"source": STORE_DISPLAY_NAMES.get(o.source, o.source.replace(".pk", "").title()),
"title": o.title,
"price": o.price,
"currency": o.currency or "PKR",
"url": o.url,
"image": o.image_url,
"in_stock": o.in_stock,
})
logger.info(f"[{request_id}] 🧹 After filtering: {len(items)} valid items")
# De-duplicate by normalized title+source, keep cheapest
keyd: Dict[Tuple[str, str], Dict[str, Any]] = {}
for it in items:
k = (it["source"], _norm(it["title"]))
if k not in keyd or (it["price"] or 1e18) < (keyd[k]["price"] or 1e18):
keyd[k] = it
items = list(keyd.values())
logger.info(f"[{request_id}] 🔄 After deduplication: {len(items)} unique items")
items.sort(key=lambda x: (x["price"] if x["price"] is not None else 1e18, x["title"].lower()))
logger.info(f"[{request_id}] ✅ RETURNING {len(items[:limit])} offers for '{q}'")
logger.info(f"[{request_id}] ========== REQUEST COMPLETE ==========\n")
return {"query": q, "count": len(items[:limit]), "offers": items[:limit]}