blinkit-scraper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ """Scrape Blinkit category listings and product pages (price, MRP, stock per dark store) with Python."""
2
+
3
+ from .client import CITIES, BlinkitScraper, ScrapeError
4
+ from .parser import (
5
+ LISTING_FIELDS,
6
+ PRODUCT_FIELDS,
7
+ extract_state,
8
+ parse_card,
9
+ parse_cards,
10
+ parse_category,
11
+ parse_count,
12
+ parse_listing_api,
13
+ parse_product,
14
+ product_url,
15
+ )
16
+
17
+ __all__ = [
18
+ "CITIES",
19
+ "LISTING_FIELDS",
20
+ "PRODUCT_FIELDS",
21
+ "BlinkitScraper",
22
+ "ScrapeError",
23
+ "extract_state",
24
+ "parse_card",
25
+ "parse_cards",
26
+ "parse_category",
27
+ "parse_count",
28
+ "parse_listing_api",
29
+ "parse_product",
30
+ "product_url",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
blinkit_scraper/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ """Command line entry point: ``blinkit-scraper category URL`` / ``blinkit-scraper product URL ...``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from .client import CITIES, TOKEN_ENV, BlinkitScraper, ScrapeError
13
+ from .parser import LISTING_FIELDS, PRODUCT_FIELDS
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="blinkit-scraper",
19
+ description="Scrape Blinkit categories and product pages (price, MRP, stock per dark store) into JSON or CSV.",
20
+ )
21
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ sub = parser.add_subparsers(dest="command", required=True)
23
+
24
+ cat = sub.add_parser("category", help="Products of a category page (30 on page 1, 15 per further page)")
25
+ cat.add_argument("url", help="A Blinkit category URL, e.g. https://blinkit.com/cn/chips-crisps/cid/1237/940")
26
+ cat.add_argument("--pages", type=int, default=1, help="Pages to read (default 1)")
27
+
28
+ prod = sub.add_parser("product", help="Product pages: price, stock, FSSAI licence, shelf life, attributes")
29
+ prod.add_argument("urls", nargs="*", help="Blinkit product URLs or product ids")
30
+ prod.add_argument("-i", "--input", help="Text file with one URL per line, or a CSV with a 'url' column")
31
+ prod.add_argument("--limit", type=int, default=0, help="Stop after this many products (0 = all)")
32
+
33
+ for p in (cat, prod):
34
+ p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
35
+ p.add_argument("--city", choices=sorted(CITIES), help="Delivery location preset (default: Gurugram)")
36
+ p.add_argument("--lat", type=float, help="Delivery latitude (with --lon)")
37
+ p.add_argument("--lon", type=float, help="Delivery longitude (with --lat)")
38
+ p.add_argument("-f", "--format", choices=("json", "csv"), default="json")
39
+ p.add_argument("-o", "--output", help="Write to this file instead of stdout")
40
+ return parser
41
+
42
+
43
+ def _read_urls(args: argparse.Namespace) -> List[str]:
44
+ urls = list(args.urls)
45
+ if args.input:
46
+ with open(args.input, encoding="utf-8", newline="") as handle:
47
+ if args.input.endswith(".csv"):
48
+ urls.extend(row["url"] for row in csv.DictReader(handle) if row.get("url"))
49
+ else:
50
+ urls.extend(line.strip() for line in handle if line.strip() and not line.startswith("#"))
51
+ return urls[: args.limit] if args.limit else urls
52
+
53
+
54
+ def _write(rows: List[dict], fields: List[str], fmt: str, stream) -> None:
55
+ if fmt == "csv":
56
+ writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
57
+ writer.writeheader()
58
+ writer.writerows(rows)
59
+ else:
60
+ json.dump(rows, stream, indent=2, ensure_ascii=False)
61
+ stream.write("\n")
62
+
63
+
64
+ def main(argv: Optional[List[str]] = None) -> int:
65
+ args = build_parser().parse_args(argv)
66
+ if (args.lat is None) != (args.lon is None):
67
+ print("Pass both --lat and --lon.", file=sys.stderr)
68
+ return 2
69
+ try:
70
+ scraper = BlinkitScraper(token=args.token, lat=args.lat, lon=args.lon, city=args.city)
71
+ except ValueError as exc:
72
+ print(exc, file=sys.stderr)
73
+ return 2
74
+
75
+ failures = 0
76
+ if args.command == "category":
77
+ fields = LISTING_FIELDS
78
+ try:
79
+ info = scraper.get_category(args.url, max_pages=max(1, args.pages), with_info=True)
80
+ except (ScrapeError, ValueError) as exc:
81
+ print(f"fail {args.url}: {exc}", file=sys.stderr)
82
+ return 1
83
+ rows = info["products"]
84
+ print(f"{len(rows)} of {info['total']} products, store {info['merchant_id']} ({info['locality']}), "
85
+ f"{scraper.credits_used} credits", file=sys.stderr)
86
+ else:
87
+ fields = PRODUCT_FIELDS
88
+ urls = _read_urls(args)
89
+ if not urls:
90
+ print("No URLs given. Pass product URLs or --input FILE.", file=sys.stderr)
91
+ return 2
92
+ rows = []
93
+ for url in urls:
94
+ try:
95
+ product = scraper.get_product(url)
96
+ except (ScrapeError, ValueError) as exc:
97
+ failures += 1
98
+ print(f"fail {url}: {exc}", file=sys.stderr)
99
+ continue
100
+ if not product:
101
+ failures += 1
102
+ print(f"fail {url}: no product data", file=sys.stderr)
103
+ continue
104
+ rows.append(product)
105
+ print(f"ok {url}", file=sys.stderr)
106
+
107
+ if args.output:
108
+ with open(args.output, "w", encoding="utf-8", newline="" if args.format == "csv" else None) as handle:
109
+ _write(rows, fields, args.format, handle)
110
+ print(f"Saved {len(rows)} rows to {args.output}", file=sys.stderr)
111
+ else:
112
+ _write(rows, fields, args.format, sys.stdout)
113
+ return 1 if failures and not rows else 0
114
+
115
+
116
+ if __name__ == "__main__":
117
+ raise SystemExit(main())
@@ -0,0 +1,183 @@
1
+ """Fetch Blinkit pages through the Scrape.do API.
2
+
3
+ Blinkit sits behind Cloudflare and answers scripts with HTTP 403. Through
4
+ Scrape.do every request uses residential proxies in India (``super=true`` and
5
+ ``geoCode=in``); the listing API only answers on those. Each request costs
6
+ 10 Scrape.do credits.
7
+
8
+ Prices, assortment and stock belong to one dark store, chosen from a delivery
9
+ location. Pages take it from the ``gr_1_lat`` / ``gr_1_lon`` cookies, the
10
+ listing API from ``lat`` / ``lon`` headers. Without a location Blinkit uses
11
+ Gurugram.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import time
19
+ from typing import Any, Callable, Dict, Optional
20
+
21
+ import requests
22
+
23
+ from .parser import BLINKIT, extract_state, category_ids, parse_category, parse_listing_api, parse_product, product_id_from_url
24
+
25
+ API_URL = "https://api.scrape.do/"
26
+ TOKEN_ENV = "SCRAPEDO_TOKEN"
27
+ DEFAULT_LAT, DEFAULT_LON = 28.413333, 77.072833 # Gurugram, Blinkit's own default
28
+
29
+ CITIES = {
30
+ "gurugram": (28.413333, 77.072833),
31
+ "delhi": (28.6315, 77.2167),
32
+ "mumbai": (19.0760, 72.8777),
33
+ "bengaluru": (12.9716, 77.5946),
34
+ "hyderabad": (17.3850, 78.4867),
35
+ "pune": (18.5204, 73.8567),
36
+ "kolkata": (22.5726, 88.3639),
37
+ "chennai": (13.0827, 80.2707),
38
+ }
39
+
40
+
41
+ class ScrapeError(RuntimeError):
42
+ """A request that did not return usable data. ``from_target`` is True when the status came from Blinkit."""
43
+
44
+ def __init__(self, message: str, status_code: Optional[int] = None, from_target: bool = False) -> None:
45
+ super().__init__(message)
46
+ self.status_code = status_code
47
+ self.from_target = from_target
48
+
49
+
50
+ def _is_json(text: str) -> bool:
51
+ try:
52
+ return isinstance(json.loads(text), dict)
53
+ except ValueError:
54
+ return False
55
+
56
+
57
+ class BlinkitScraper:
58
+ """Blinkit category listings and product pages, for one delivery location.
59
+
60
+ >>> scraper = BlinkitScraper(token="...", city="mumbai")
61
+ >>> rows = scraper.get_category("https://blinkit.com/cn/chips-crisps/cid/1237/940", max_pages=3)
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ token: Optional[str] = None,
67
+ lat: Optional[float] = None,
68
+ lon: Optional[float] = None,
69
+ city: Optional[str] = None,
70
+ timeout: float = 90,
71
+ retries: int = 2,
72
+ session: Optional[requests.Session] = None,
73
+ ) -> None:
74
+ self.token = (token or os.getenv(TOKEN_ENV) or "").strip()
75
+ if not self.token:
76
+ raise ValueError(f"A Scrape.do API token is required (pass token= or set {TOKEN_ENV}).")
77
+ if city:
78
+ key = city.strip().lower()
79
+ if key not in CITIES:
80
+ raise ValueError(f"Unknown city {city!r}. Known: {', '.join(sorted(CITIES))}. Or pass lat and lon.")
81
+ lat, lon = CITIES[key]
82
+ self.lat = float(lat) if lat is not None else DEFAULT_LAT
83
+ self.lon = float(lon) if lon is not None else DEFAULT_LON
84
+ self.timeout = timeout
85
+ self.retries = max(0, retries)
86
+ self.session = session or requests.Session()
87
+ self.credits_used = 0
88
+
89
+ # ------------------------------------------------------------ transport
90
+
91
+ def request(
92
+ self,
93
+ url: str,
94
+ method: str = "GET",
95
+ headers: Optional[Dict[str, str]] = None,
96
+ cookies: Optional[str] = None,
97
+ valid: Optional[Callable[[str], bool]] = None,
98
+ ) -> str:
99
+ params: Dict[str, str] = {"token": self.token, "url": url, "super": "true", "geoCode": "in"}
100
+ send = None
101
+ if headers:
102
+ params["extraHeaders"] = "true"
103
+ send = {f"sd-{k}": v for k, v in headers.items()}
104
+ if cookies:
105
+ params["setCookies"] = cookies
106
+ last: Optional[ScrapeError] = None
107
+ for attempt in range(self.retries + 1):
108
+ if attempt:
109
+ time.sleep(1.5 * attempt)
110
+ try:
111
+ response = self.session.request(
112
+ method, API_URL, params=params, headers=send, data="{}" if method == "POST" else None, timeout=self.timeout
113
+ )
114
+ except requests.RequestException as exc:
115
+ last = ScrapeError(f"Request failed: {str(exc).replace(self.token, '***')}")
116
+ continue
117
+ from_target = "scrape.do-initial-status-code" in {k.lower() for k in response.headers}
118
+ try:
119
+ self.credits_used += int(response.headers.get("scrape.do-request-cost") or 0)
120
+ except ValueError:
121
+ pass
122
+ if response.status_code == 401 and not from_target:
123
+ raise ScrapeError("Scrape.do rejected the token (HTTP 401).", 401, False)
124
+ if response.status_code == 200 and response.text and (valid is None or valid(response.text)):
125
+ return response.text
126
+ last = ScrapeError(
127
+ f"HTTP {response.status_code} for {url}" if response.status_code != 200 else f"Page without Blinkit data: {url}",
128
+ response.status_code, from_target,
129
+ )
130
+ if response.status_code not in (200, 429, 500, 502, 503, 504):
131
+ break
132
+ raise last or ScrapeError(f"Request failed: {url}")
133
+
134
+ def _cookies(self) -> str:
135
+ return f"gr_1_lat={self.lat}; gr_1_lon={self.lon}"
136
+
137
+ def fetch_page(self, url: str) -> str:
138
+ """A Blinkit page for this location (category or product)."""
139
+ return self.request(url, cookies=self._cookies(), valid=lambda body: bool(extract_state(body)))
140
+
141
+ def fetch_listing_api(self, next_url: str) -> Dict[str, Any]:
142
+ """One page of /v1/layout/listing_widgets (15 products) for this location."""
143
+ headers = {
144
+ "lat": str(self.lat), "lon": str(self.lon), "app_client": "consumer_web",
145
+ "Content-Type": "application/json", "Accept": "application/json",
146
+ }
147
+ url = next_url if next_url.startswith("http") else BLINKIT + next_url
148
+ return json.loads(self.request(url, method="POST", headers=headers, valid=_is_json))
149
+
150
+ # ------------------------------------------------------------- scraping
151
+
152
+ def get_category(self, url: str, max_pages: int = 1, with_info: bool = False) -> Any:
153
+ """Products of a category page. Page 1 is the HTML (30 products); every further page is one
154
+ listing-API call (15 products). Out-of-stock products are kept with ``in_stock=False``."""
155
+ if not category_ids(url):
156
+ raise ValueError("Expected a Blinkit category URL like https://blinkit.com/cn/chips-crisps/cid/1237/940")
157
+ first = parse_category(self.fetch_page(url))
158
+ rows: Dict[int, Dict[str, Any]] = {row["product_id"]: row for row in first["products"]}
159
+ next_url, pages = first["next_url"], 1
160
+ while next_url and pages < max(1, max_pages):
161
+ batch = parse_listing_api(self.fetch_listing_api(next_url))
162
+ pages += 1
163
+ for row in batch["products"]:
164
+ rows.setdefault(row["product_id"], row)
165
+ if not batch["products"]:
166
+ break
167
+ next_url = batch["next_url"]
168
+ products = list(rows.values())
169
+ if not with_info:
170
+ return products
171
+ return {
172
+ "products": products, "total": first["total"], "pages_read": pages, "has_more": bool(next_url),
173
+ "locality": first["locality"], "lat": self.lat, "lon": self.lon,
174
+ "merchant_id": next((r["merchant_id"] for r in products if r.get("merchant_id")), None),
175
+ }
176
+
177
+ def get_product(self, url_or_id: Any) -> Optional[Dict[str, Any]]:
178
+ """Product page: price, MRP, stock and the attribute sheet (FSSAI licence, shelf life...)."""
179
+ text = str(url_or_id).strip()
180
+ product_id = int(text) if text.isdigit() else product_id_from_url(text)
181
+ if not product_id:
182
+ raise ValueError("Expected a Blinkit product URL (.../prn/<name>/prid/<id>) or a product id.")
183
+ return parse_product(self.fetch_page(f"{BLINKIT}/prn/product/prid/{product_id}"))
@@ -0,0 +1,254 @@
1
+ """Parse Blinkit pages.
2
+
3
+ Every Blinkit page is rendered on the server with its data in one inline script,
4
+ ``window.grofers.PRELOADED_STATE = {...}``. Category pages keep the first 30
5
+ product cards in ``ui.plpContainer.feedData.snippets`` plus a ``next_url`` for
6
+ the listing API; product pages keep the catalog sheet (price, MRP, stock,
7
+ attributes such as FSSAI licence and shelf life) in
8
+ ``ui.pdp.bffPdp.bffData.tracking.le_meta.custom_data.seo``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from typing import Any, Dict, List, Optional
16
+ from urllib.parse import parse_qs, urlparse
17
+
18
+ BLINKIT = "https://blinkit.com"
19
+ CARD_TYPE = "product_card_snippet_type_2"
20
+ _STATE_RE = re.compile(r"window\.grofers\.PRELOADED_STATE\s*=\s*")
21
+ _DECODER = json.JSONDecoder()
22
+
23
+ LISTING_FIELDS = [
24
+ "product_id", "name", "brand", "pack_size", "price_inr", "mrp_inr", "discount_pct",
25
+ "in_stock", "inventory", "rating", "rating_count", "variants", "merchant_id", "image", "url",
26
+ ]
27
+ PRODUCT_FIELDS = [
28
+ "product_id", "name", "brand", "pack_size", "price_inr", "mrp_inr", "discount_pct", "in_stock",
29
+ "inventory", "shelf_life", "fssai_license", "country_of_origin", "category_ids", "merchant_id", "url",
30
+ ]
31
+
32
+ # ------------------------------------------------------------------ helpers
33
+
34
+
35
+ def extract_state(html: str) -> Dict[str, Any]:
36
+ """The PRELOADED_STATE object Blinkit renders into every page, or {}."""
37
+ match = _STATE_RE.search(html or "")
38
+ if not match:
39
+ return {}
40
+ try:
41
+ data, _ = _DECODER.raw_decode(html, match.end())
42
+ except json.JSONDecodeError:
43
+ return {}
44
+ return data if isinstance(data, dict) else {}
45
+
46
+
47
+ def _text(node: Any) -> Optional[str]:
48
+ if isinstance(node, dict):
49
+ value = node.get("text")
50
+ return value.strip() if isinstance(value, str) else None
51
+ return node.strip() if isinstance(node, str) else None
52
+
53
+
54
+ def _num(value: Any) -> Optional[float]:
55
+ if isinstance(value, bool) or value is None:
56
+ return None
57
+ if isinstance(value, (int, float)):
58
+ return value
59
+ match = re.search(r"\d[\d,]*(?:\.\d+)?", str(value))
60
+ if not match:
61
+ return None
62
+ number = float(match.group(0).replace(",", ""))
63
+ return int(number) if number.is_integer() else number
64
+
65
+
66
+ def _int(value: Any) -> Optional[int]:
67
+ try:
68
+ return int(value)
69
+ except (TypeError, ValueError):
70
+ return None
71
+
72
+
73
+ def _pct(price: Any, mrp: Any) -> int:
74
+ if isinstance(price, (int, float)) and isinstance(mrp, (int, float)) and mrp > 0 and price <= mrp:
75
+ return int((mrp - price) * 100 // mrp) # Blinkit rounds discounts down
76
+ return 0
77
+
78
+
79
+ _MULTIPLIERS = {"k": 1_000, "lac": 100_000, "lakh": 100_000, "l": 100_000, "cr": 10_000_000, "crore": 10_000_000}
80
+
81
+
82
+ def parse_count(text: Any) -> Optional[int]:
83
+ """'7 lac' -> 700000, '1.2k' -> 1200, '(980)' -> 980."""
84
+ if isinstance(text, (int, float)) and not isinstance(text, bool):
85
+ return int(text)
86
+ match = re.search(r"(\d[\d,]*(?:\.\d+)?)\s*([a-zA-Z]*)", str(text or ""))
87
+ if not match:
88
+ return None
89
+ number = float(match.group(1).replace(",", ""))
90
+ return int(round(number * _MULTIPLIERS.get(match.group(2).lower(), 1)))
91
+
92
+
93
+ def slugify(name: Any) -> str:
94
+ slug = re.sub(r"[^a-z0-9]+", "-", str(name or "").lower()).strip("-")
95
+ return slug or "product"
96
+
97
+
98
+ def product_url(product_id: Any, name: Any = None) -> str:
99
+ return f"{BLINKIT}/prn/{slugify(name)}/prid/{product_id}" if product_id else ""
100
+
101
+
102
+ def product_id_from_url(url: str) -> Optional[int]:
103
+ match = re.search(r"/prid/(\d+)", url or "")
104
+ return int(match.group(1)) if match else None
105
+
106
+
107
+ def category_ids(url: str) -> Optional[tuple]:
108
+ """(l0, l1) from a /cn/<slug>/cid/<l0>/<l1> URL."""
109
+ match = re.search(r"/cid/(\d+)(?:/(\d+))?", url or "")
110
+ if not match:
111
+ return None
112
+ return int(match.group(1)), (int(match.group(2)) if match.group(2) else None)
113
+
114
+
115
+ # ------------------------------------------------------------- product cards
116
+
117
+
118
+ def parse_card(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
119
+ """One listing card -> flat row. Returns None for non-product snippets."""
120
+ if not isinstance(data, dict):
121
+ return None
122
+ cart = ((data.get("atc_action") or {}).get("add_to_cart") or {}).get("cart_item") or {}
123
+ product_id = _int(data.get("product_id") or (data.get("identity") or {}).get("id") or cart.get("product_id"))
124
+ if not product_id:
125
+ return None
126
+ name = _text(data.get("display_name")) or _text(data.get("name")) or cart.get("product_name")
127
+ price = cart.get("price") if isinstance(cart.get("price"), (int, float)) else _num(_text(data.get("normal_price")))
128
+ mrp = cart.get("mrp") if isinstance(cart.get("mrp"), (int, float)) else _num(_text(data.get("mrp")))
129
+ if mrp is None:
130
+ mrp = price
131
+ inventory = data.get("inventory", cart.get("inventory"))
132
+ inventory = inventory if isinstance(inventory, int) else 0
133
+ sold_out = bool(data.get("is_sold_out")) or data.get("product_state") == "out_of_stock" or inventory <= 0
134
+ bar = (data.get("rating") or {}).get("bar") or {}
135
+ rating = bar.get("value")
136
+ return {
137
+ "product_id": product_id,
138
+ "name": name,
139
+ "brand": _text(data.get("brand_name")) or cart.get("brand"),
140
+ "pack_size": _text(data.get("variant")) or cart.get("unit"),
141
+ "price_inr": price,
142
+ "mrp_inr": mrp,
143
+ "discount_pct": _pct(price, mrp),
144
+ "in_stock": not sold_out,
145
+ "inventory": max(inventory, 0),
146
+ "rating": round(rating, 2) if isinstance(rating, (int, float)) else None,
147
+ "rating_count": parse_count(_text(bar.get("title"))) if bar else None,
148
+ "variants": len(data.get("variant_list") or []) or 1,
149
+ "merchant_id": _int(data.get("merchant_id") or cart.get("merchant_id")),
150
+ "image": (data.get("image") or {}).get("url") or cart.get("image_url"),
151
+ "url": product_url(product_id, name),
152
+ }
153
+
154
+
155
+ def parse_cards(snippets: List[Any]) -> List[Dict[str, Any]]:
156
+ rows = []
157
+ for snippet in snippets or []:
158
+ if isinstance(snippet, dict) and snippet.get("widget_type") == CARD_TYPE:
159
+ row = parse_card(snippet.get("data"))
160
+ if row:
161
+ rows.append(row)
162
+ return rows
163
+
164
+
165
+ def _total(next_url: Optional[str]) -> Optional[int]:
166
+ if not next_url:
167
+ return None
168
+ values = parse_qs(urlparse(next_url).query).get("total_pagination_items")
169
+ return _int(values[0]) if values else None
170
+
171
+
172
+ def parse_category(html: str) -> Dict[str, Any]:
173
+ """Category page -> {'products', 'next_url', 'total', 'locality', 'lat', 'lon'}."""
174
+ state = extract_state(html)
175
+ feed = (((state.get("ui") or {}).get("plpContainer") or {}).get("feedData")) or {}
176
+ next_url = (feed.get("pagination") or {}).get("next_url")
177
+ coords = (((state.get("data") or {}).get("location") or {}).get("coords")) or {}
178
+ return {
179
+ "products": parse_cards(feed.get("snippets") or []),
180
+ "next_url": next_url,
181
+ "total": _total(next_url),
182
+ "locality": coords.get("locality"),
183
+ "lat": coords.get("lat"),
184
+ "lon": coords.get("lon"),
185
+ }
186
+
187
+
188
+ def parse_listing_api(payload: Dict[str, Any]) -> Dict[str, Any]:
189
+ """Response of /v1/layout/listing_widgets -> {'products', 'next_url'}."""
190
+ response = (payload or {}).get("response") or {}
191
+ next_url = (response.get("pagination") or {}).get("next_url")
192
+ return {"products": parse_cards(response.get("snippets") or []), "next_url": next_url, "total": _total(next_url)}
193
+
194
+
195
+ # -------------------------------------------------------------- product page
196
+
197
+ _ATTRIBUTE_KEYS = {
198
+ "shelf_life": ("Shelf Life",),
199
+ "fssai_license": ("FSSAI License", "FSSAI License Number"),
200
+ "country_of_origin": ("Country of Origin",),
201
+ "unit": ("Unit", "Net Weight"),
202
+ "ingredients": ("Ingredients",),
203
+ "manufacturer": ("Manufacturer Details", "Manufacturer's Name and Address", "Manufacturer Name"),
204
+ "return_policy": ("Return Policy",),
205
+ }
206
+
207
+
208
+ def parse_product(html: str) -> Optional[Dict[str, Any]]:
209
+ """Product page -> summary dict with price, stock and the attribute sheet."""
210
+ state = extract_state(html)
211
+ bff = ((((state.get("ui") or {}).get("pdp") or {}).get("bffPdp") or {}).get("bffData")) or {}
212
+ seo = ((((bff.get("tracking") or {}).get("le_meta") or {}).get("custom_data")) or {}).get("seo") or {}
213
+ product_id = _int(seo.get("id"))
214
+ if not product_id:
215
+ return None
216
+ attributes: Dict[str, str] = {}
217
+ for item in seo.get("attributes") or []:
218
+ name, value = item.get("name"), item.get("value")
219
+ if name and value not in (None, "") and name not in attributes:
220
+ attributes[name] = str(value).strip()
221
+ picked = {key: next((attributes[n] for n in names if n in attributes), None) for key, names in _ATTRIBUTE_KEYS.items()}
222
+ merchant_id = None
223
+ for snippet in bff.get("snippets") or []:
224
+ if snippet.get("widget_type") == "product_atc_strip":
225
+ blob = json.dumps(snippet.get("data") or {})
226
+ found = re.search(r'"merchant_id":\s*(\d+)', blob)
227
+ merchant_id = int(found.group(1)) if found else None
228
+ break
229
+ price, mrp = seo.get("price"), seo.get("mrp")
230
+ inventory = seo.get("inventory") if isinstance(seo.get("inventory"), int) else 0
231
+ available = bool(price) and inventory > 0
232
+ cats = seo.get("categories") or {}
233
+ return {
234
+ "product_id": product_id,
235
+ "name": seo.get("product_name"),
236
+ "brand": seo.get("brand"),
237
+ "pack_size": picked["unit"],
238
+ "price_inr": price if price else None,
239
+ "mrp_inr": mrp if mrp else None,
240
+ "discount_pct": _pct(price, mrp) if price else 0,
241
+ "in_stock": available,
242
+ "inventory": max(inventory, 0),
243
+ "shelf_life": picked["shelf_life"],
244
+ "fssai_license": picked["fssai_license"],
245
+ "country_of_origin": picked["country_of_origin"],
246
+ "ingredients": picked["ingredients"],
247
+ "manufacturer": picked["manufacturer"],
248
+ "return_policy": picked["return_policy"],
249
+ "category_ids": "/".join(str((cats.get(k) or {}).get("ID")) for k in ("L0Cat", "L1Cat", "L2Cat") if (cats.get(k) or {}).get("ID")),
250
+ "merchant_id": merchant_id,
251
+ "images": [u for u in seo.get("images") or [] if isinstance(u, str)],
252
+ "attributes": attributes,
253
+ "url": product_url(product_id, seo.get("product_name")),
254
+ }
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: blinkit-scraper
3
+ Version: 0.1.0
4
+ Summary: Scrape Blinkit categories and product pages (price, MRP, stock per dark store, FSSAI licence, shelf life) with Python.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/blinkit-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/blinkit-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/blinkit-scraper/issues
10
+ Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=blinkit-scraper
11
+ Keywords: blinkit,web scraping,quick commerce,grocery,scraper,cloudflare,india
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.25
21
+ Dynamic: license-file
22
+
23
+ # blinkit-scraper
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/blinkit-scraper)](https://pypi.org/project/blinkit-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/blinkit-scraper)](https://pypi.org/project/blinkit-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
28
+
29
+ A Python scraper for [Blinkit](https://blinkit.com), India's 10-minute grocery app. Two jobs:
30
+
31
+ - **Category listings:** any category page (`/cn/chips-crisps/cid/1237/940`) → name, brand, pack size, price, MRP, discount, **units in stock**, rating, rating count, variants and URL, **for the dark store that serves your location**. Real pagination: 30 products on the first page, 15 per page after that, out-of-stock products kept.
32
+ - **Product pages:** price, MRP and stock plus the catalog sheet: **FSSAI licence, shelf life, country of origin, ingredients, manufacturer, return policy** and every other attribute Blinkit lists.
33
+
34
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
35
+
36
+ ```bash
37
+ pip install blinkit-scraper
38
+ ```
39
+
40
+ ## Why a plain request fails
41
+
42
+ Blinkit sits behind Cloudflare. A script gets an error page, not products:
43
+
44
+ | Request | Result |
45
+ | --- | --- |
46
+ | `requests`, default or Chrome user agent | HTTP 403, "blinkit \| Error Page" |
47
+ | Through Scrape.do (`super=true&geoCode=in`) | 200 in ~3 s, full page with the data, 5 of 5 tries |
48
+
49
+ Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=blinkit-scraper) API with residential proxies in India. Blinkit requests cost 10 credits each; the free plan includes 1,000 credits a month.
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ from blinkit_scraper import BlinkitScraper
55
+
56
+ scraper = BlinkitScraper(token="YOUR_SCRAPEDO_TOKEN", city="mumbai") # or lat=..., lon=...
57
+
58
+ # 3 pages = 30 + 15 + 15 = 60 products, 3 requests
59
+ rows = scraper.get_category("https://blinkit.com/cn/chips-crisps/cid/1237/940", max_pages=3)
60
+ for row in rows[:3]:
61
+ print(row["name"], row["pack_size"], row["price_inr"], row["mrp_inr"], row["inventory"], row["in_stock"])
62
+
63
+ product = scraper.get_product(rows[0]["url"])
64
+ print(product["fssai_license"], product["shelf_life"], product["country_of_origin"])
65
+ ```
66
+
67
+ ### Command line
68
+
69
+ ```bash
70
+ export SCRAPEDO_TOKEN=your_token
71
+
72
+ # 3 pages of a category for a Mumbai address, CSV
73
+ blinkit-scraper category "https://blinkit.com/cn/chips-crisps/cid/1237/940" --pages 3 --city mumbai -f csv -o blinkit-chips.csv
74
+
75
+ # product sheets for the first 10 products of that file
76
+ blinkit-scraper product -i blinkit-chips.csv --limit 10 --city mumbai -f csv -o blinkit-products.csv
77
+
78
+ # any address: pass its coordinates
79
+ blinkit-scraper category "https://blinkit.com/cn/chips-crisps/cid/1237/940" --lat 12.9352 --lon 77.6245
80
+ ```
81
+
82
+ City presets: `bengaluru`, `chennai`, `delhi`, `gurugram` (Blinkit's default), `hyderabad`, `kolkata`, `mumbai`, `pune`.
83
+
84
+ ### Output
85
+
86
+ Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/category_page.html)):
87
+
88
+ ```json
89
+ {"product_id": 1001, "name": "Example Salted Chips", "brand": "Northwind Snacks", "pack_size": "60 g",
90
+ "price_inr": 43, "mrp_inr": 70, "discount_pct": 38, "in_stock": true, "inventory": 5,
91
+ "rating": 4.31, "rating_count": 120000, "variants": 2, "merchant_id": 90001,
92
+ "image": "https://cdn.example.com/p/1001.png", "url": "https://blinkit.com/prn/example-salted-chips/prid/1001"}
93
+ ```
94
+
95
+ Product columns: `product_id, name, brand, pack_size, price_inr, mrp_inr, discount_pct, in_stock, inventory, shelf_life, fssai_license, country_of_origin, category_ids, merchant_id, url` (plus `ingredients`, `manufacturer`, `return_policy`, `images` and the full `attributes` dict in Python/JSON).
96
+
97
+ ## How it works
98
+
99
+ **The data is one JSON object.** Blinkit renders every page on the server and puts the data in `window.grofers.PRELOADED_STATE = {...}`. Category pages keep the product cards in `ui.plpContainer.feedData.snippets`, product pages keep the catalog sheet in `ui.pdp.bffPdp.bffData.tracking.le_meta.custom_data.seo`. The scraper reads the object with `json.JSONDecoder.raw_decode`. No CSS selectors.
100
+
101
+ **Each card carries the stock count.** `inventory` is the number of units the dark store has (Blinkit stops counting at 50). `rating_count` arrives as text in Indian units ("7 lac" = 700,000) and is converted.
102
+
103
+ **Location picks the store.** Prices, assortment and stock belong to one dark store, chosen from the delivery address. Without one, Blinkit uses Gurugram. In our test the chips category listed 673 products in Gurugram (store 31719) and 524 in Mumbai (store 41649); a banana chips pack had 5 units in one and 1 in the other. Pages read the location from the `gr_1_lat` / `gr_1_lon` cookies, the listing API from `lat` / `lon` headers; the scraper sends both.
104
+
105
+ **Pagination is a POST API.** The page holds 30 products and a `next_url` for `/v1/layout/listing_widgets`. That endpoint returns 15 products per call (a larger `limit` is ignored) and the next `next_url`. It only answers through residential proxies, so every request uses `super=true&geoCode=in`. Reading a whole category of 673 products takes 44 requests.
106
+
107
+ ## Limits
108
+
109
+ - **Credits.** 10 Scrape.do credits per request: page 1 of a category, each further page of 15, each product page.
110
+ - **Stock cap.** `inventory` tops out at 50.
111
+ - **Search.** `/s/` search pages are disallowed in Blinkit's robots.txt and not supported; use category pages.
112
+ - **Errors.** Non-200 responses raise `ScrapeError` with the status code; `from_target` tells a Blinkit status from a Scrape.do one (a wrong token is a Scrape.do 401). A 200 without Blinkit's data is retried. Server errors, 429s and empty pages are retried twice; client errors are not.
113
+
114
+ ## Responsible use
115
+
116
+ The scraper reads category (`/cn/`) and product (`/prn/`) pages, which Blinkit's robots.txt allows, plus the listing API those pages call. It never touches search, cart, checkout or account paths. Product data is not personal data. Read Blinkit's terms for your use case and keep request rates modest. This is not legal advice.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ git clone https://github.com/bugrahansss/blinkit-scraper
122
+ cd blinkit-scraper
123
+ pip install -e .
124
+ python -m unittest discover -s tests -v
125
+ ```
126
+
127
+ The tests run offline against fictional fixture pages.
128
+
129
+ ## License
130
+
131
+ MIT
132
+
133
+ ---
134
+
135
+ Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=blinkit-scraper), November 2026.
@@ -0,0 +1,10 @@
1
+ blinkit_scraper/__init__.py,sha256=cX44DXSnsrpZGZjuQqs4juid1pjkwT4Zojt4RS6UPec,675
2
+ blinkit_scraper/cli.py,sha256=wmXV3GkyZ60ke9PabxAGlGEG37KUt70EdK6ustoD9mE,4994
3
+ blinkit_scraper/client.py,sha256=9GzvdsH7PFsHmv53GwvPjFwvkq6ZanNOdhLsHpbQmSY,7979
4
+ blinkit_scraper/parser.py,sha256=aVOOnSQOy6v-FSJ-wHEk5LP5XXRU4G_DlRfz1BMQ_QU,10415
5
+ blinkit_scraper-0.1.0.dist-info/licenses/LICENSE,sha256=ROg_cpaduSqtDgeddNL4Csjnu7TkWQsINx0OuPmUTwM,1071
6
+ blinkit_scraper-0.1.0.dist-info/METADATA,sha256=CDD2XEXIh6q_DXVt62ybHoTcwarW9bh_CeC2wni5sSA,7433
7
+ blinkit_scraper-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ blinkit_scraper-0.1.0.dist-info/entry_points.txt,sha256=7b9J5JROGnFxy73MEhVmRmhVw7S7jWUK8ojVJXpRWYQ,61
9
+ blinkit_scraper-0.1.0.dist-info/top_level.txt,sha256=fFILs2Ua7ZU_a5l_X-U2FozB9kZXKsrQvnL3R9XIagg,16
10
+ blinkit_scraper-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blinkit-scraper = blinkit_scraper.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğrahan Saka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ blinkit_scraper