blinkit-scraper 0.1.0__tar.gz

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,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,3 @@
1
+ include README.md LICENSE
2
+ recursive-include tests *.py *.html *.json
3
+ recursive-include examples *.py
@@ -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,113 @@
1
+ # blinkit-scraper
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/blinkit-scraper)](https://pypi.org/project/blinkit-scraper/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/blinkit-scraper)](https://pypi.org/project/blinkit-scraper/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ A Python scraper for [Blinkit](https://blinkit.com), India's 10-minute grocery app. Two jobs:
8
+
9
+ - **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.
10
+ - **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.
11
+
12
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
13
+
14
+ ```bash
15
+ pip install blinkit-scraper
16
+ ```
17
+
18
+ ## Why a plain request fails
19
+
20
+ Blinkit sits behind Cloudflare. A script gets an error page, not products:
21
+
22
+ | Request | Result |
23
+ | --- | --- |
24
+ | `requests`, default or Chrome user agent | HTTP 403, "blinkit \| Error Page" |
25
+ | Through Scrape.do (`super=true&geoCode=in`) | 200 in ~3 s, full page with the data, 5 of 5 tries |
26
+
27
+ 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.
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ from blinkit_scraper import BlinkitScraper
33
+
34
+ scraper = BlinkitScraper(token="YOUR_SCRAPEDO_TOKEN", city="mumbai") # or lat=..., lon=...
35
+
36
+ # 3 pages = 30 + 15 + 15 = 60 products, 3 requests
37
+ rows = scraper.get_category("https://blinkit.com/cn/chips-crisps/cid/1237/940", max_pages=3)
38
+ for row in rows[:3]:
39
+ print(row["name"], row["pack_size"], row["price_inr"], row["mrp_inr"], row["inventory"], row["in_stock"])
40
+
41
+ product = scraper.get_product(rows[0]["url"])
42
+ print(product["fssai_license"], product["shelf_life"], product["country_of_origin"])
43
+ ```
44
+
45
+ ### Command line
46
+
47
+ ```bash
48
+ export SCRAPEDO_TOKEN=your_token
49
+
50
+ # 3 pages of a category for a Mumbai address, CSV
51
+ blinkit-scraper category "https://blinkit.com/cn/chips-crisps/cid/1237/940" --pages 3 --city mumbai -f csv -o blinkit-chips.csv
52
+
53
+ # product sheets for the first 10 products of that file
54
+ blinkit-scraper product -i blinkit-chips.csv --limit 10 --city mumbai -f csv -o blinkit-products.csv
55
+
56
+ # any address: pass its coordinates
57
+ blinkit-scraper category "https://blinkit.com/cn/chips-crisps/cid/1237/940" --lat 12.9352 --lon 77.6245
58
+ ```
59
+
60
+ City presets: `bengaluru`, `chennai`, `delhi`, `gurugram` (Blinkit's default), `hyderabad`, `kolkata`, `mumbai`, `pune`.
61
+
62
+ ### Output
63
+
64
+ Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/category_page.html)):
65
+
66
+ ```json
67
+ {"product_id": 1001, "name": "Example Salted Chips", "brand": "Northwind Snacks", "pack_size": "60 g",
68
+ "price_inr": 43, "mrp_inr": 70, "discount_pct": 38, "in_stock": true, "inventory": 5,
69
+ "rating": 4.31, "rating_count": 120000, "variants": 2, "merchant_id": 90001,
70
+ "image": "https://cdn.example.com/p/1001.png", "url": "https://blinkit.com/prn/example-salted-chips/prid/1001"}
71
+ ```
72
+
73
+ 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).
74
+
75
+ ## How it works
76
+
77
+ **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.
78
+
79
+ **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.
80
+
81
+ **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.
82
+
83
+ **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.
84
+
85
+ ## Limits
86
+
87
+ - **Credits.** 10 Scrape.do credits per request: page 1 of a category, each further page of 15, each product page.
88
+ - **Stock cap.** `inventory` tops out at 50.
89
+ - **Search.** `/s/` search pages are disallowed in Blinkit's robots.txt and not supported; use category pages.
90
+ - **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.
91
+
92
+ ## Responsible use
93
+
94
+ 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.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ git clone https://github.com/bugrahansss/blinkit-scraper
100
+ cd blinkit-scraper
101
+ pip install -e .
102
+ python -m unittest discover -s tests -v
103
+ ```
104
+
105
+ The tests run offline against fictional fixture pages.
106
+
107
+ ## License
108
+
109
+ MIT
110
+
111
+ ---
112
+
113
+ Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=blinkit-scraper), November 2026.
@@ -0,0 +1,17 @@
1
+ """Same category, two cities: which products and how much stock each dark store has."""
2
+ from blinkit_scraper import BlinkitScraper
3
+
4
+ URL = "https://blinkit.com/cn/chips-crisps/cid/1237/940"
5
+
6
+ stores = {}
7
+ for city in ("gurugram", "mumbai"):
8
+ scraper = BlinkitScraper(city=city) # token from SCRAPEDO_TOKEN
9
+ info = scraper.get_category(URL, max_pages=2, with_info=True)
10
+ stores[city] = {row["product_id"]: row for row in info["products"]}
11
+ print(f"{city}: store {info['merchant_id']}, {info['total']} products listed, {scraper.credits_used} credits")
12
+
13
+ both = stores["gurugram"].keys() & stores["mumbai"].keys()
14
+ print(f"{len(both)} products on page 1-2 in both cities")
15
+ for pid in list(both)[:10]:
16
+ g, m = stores["gurugram"][pid], stores["mumbai"][pid]
17
+ print(f"{g['name'][:40]:40} {g['price_inr']:>5} {m['price_inr']:>5} stock {g['inventory']:>3} / {m['inventory']:>3}")
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blinkit-scraper"
7
+ version = "0.1.0"
8
+ description = "Scrape Blinkit categories and product pages (price, MRP, stock per dark store, FSSAI licence, shelf life) with Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Buğrahan Saka" }]
14
+ keywords = ["blinkit", "web scraping", "quick commerce", "grocery", "scraper", "cloudflare", "india"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
21
+ ]
22
+ dependencies = ["requests>=2.25"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/bugrahansss/blinkit-scraper"
26
+ Source = "https://github.com/bugrahansss/blinkit-scraper"
27
+ Issues = "https://github.com/bugrahansss/blinkit-scraper/issues"
28
+ "Scrape.do" = "https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=blinkit-scraper"
29
+
30
+ [project.scripts]
31
+ blinkit-scraper = "blinkit_scraper.cli:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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())