-
Notifications
You must be signed in to change notification settings - Fork 4
Feature OpenFoodFacts Parser #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,161 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import httpx | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import datetime | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| STATE_FILE = "state.json" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| OUTPUT_FILE = "products.json" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| API_URL = "https://world.openfoodfacts.org/api/v2/search" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CONCURRENCY = 20 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| PAGE_SIZE = 100 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MAX_PAGES_PER_CYCLE = 200 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ----------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # STATE | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ----------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def load_state(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not os.path.exists(STATE_FILE): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {"last_updated_t": 0} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(STATE_FILE, "r") as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return json.load(f) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def save_state(state): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(STATE_FILE, "w") as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| json.dump(state, f, indent=2) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ----------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # OUTPUT | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ----------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def ensure_output_file(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not os.path.exists(OUTPUT_FILE): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(OUTPUT_FILE, "w") as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f.write("[\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def append_product(p): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(OUTPUT_FILE, "a", encoding="utf-8") as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| json.dump(p, f, ensure_ascii=False) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f.write(",\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ----------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # API REQUEST | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ----------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def fetch_page(client: httpx.AsyncClient, page: int, last_updated_t: int, retries=3): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "page": page, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "page_size": PAGE_SIZE, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "fields": "code,product_name,image_url,last_updated_t", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "sort_by": "last_updated_t", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "sort_order": "asc", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "last_updated_t": f">{last_updated_t}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for attempt in range(retries): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| r = await client.get(API_URL, params=params) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if r.status_code == 200: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return r.json() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return r.json() | |
| return r.json() | |
| print(f"[ERROR] Non-200 response for page {page}: status={r.status_code}, body={r.text}") |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The retry logic only handles timeout exceptions (ReadTimeout, ConnectTimeout) but not other common HTTP errors like ConnectionError, HTTPStatusError, or network failures. Consider catching a broader set of exceptions or using httpx.HTTPError as a base exception.
| except (httpx.ReadTimeout, httpx.ConnectTimeout): | |
| print(f"[TIMEOUT] page {page}, attempt {attempt+1}/{retries}") | |
| except httpx.HTTPError as exc: | |
| print(f"[HTTP ERROR] page {page}, attempt {attempt+1}/{retries}: {exc}") |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function can return None after all retry attempts are exhausted, but this is only logged and not properly handled. The caller checks for 'not data' but this can lead to silent failures. Consider implementing a more robust error handling strategy or raising an exception for persistent failures.
| return None | |
| except (httpx.ReadTimeout, httpx.ConnectTimeout): | |
| print(f"[TIMEOUT] page {page}, attempt {attempt+1}/{retries}") | |
| await asyncio.sleep(1 + attempt) | |
| print(f"[FAILED] page {page}") | |
| return None | |
| print( | |
| f"[HTTP ERROR] page {page}, status {r.status_code}, " | |
| f"attempt {attempt+1}/{retries}" | |
| ) | |
| except (httpx.ReadTimeout, httpx.ConnectTimeout): | |
| print(f"[TIMEOUT] page {page}, attempt {attempt+1}/{retries}") | |
| await asyncio.sleep(1 + attempt) | |
| print(f"[FAILED] page {page} after {retries} attempts") | |
| raise RuntimeError(f"Failed to fetch page {page} after {retries} attempts") |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The image_url is wrapped in a list but could be None if the product has no image. This would result in [None] in the output. Consider filtering out None values or not wrapping a single value in a list, or check if the value exists before wrapping.
| return { | |
| "barcode": p.get("code"), | |
| "name": p.get("product_name"), | |
| "image_links": [p.get("image_url")], | |
| image_url = p.get("image_url") | |
| return { | |
| "barcode": p.get("code"), | |
| "name": p.get("product_name"), | |
| "image_links": [image_url] if image_url else [], |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of datetime.utcfromtimestamp() is deprecated in Python 3.12+. Consider using datetime.fromtimestamp(timestamp, tz=timezone.utc) instead to follow current best practices and avoid deprecation warnings.
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code requests pages 1 to MAX_PAGES_PER_CYCLE (200) in every cycle, regardless of how many pages actually have data. When approaching the end of the dataset, this results in many unnecessary API requests to empty pages. Consider implementing a more adaptive approach that stops requesting additional pages once consecutive empty pages are encountered.
| tasks = [asyncio.create_task(wrapped(page)) for page in range(1, MAX_PAGES_PER_CYCLE + 1)] | |
| return await asyncio.gather(*tasks) | |
| results = [] | |
| page = 1 | |
| while page <= MAX_PAGES_PER_CYCLE: | |
| # Determine the next batch of pages to fetch, up to the concurrency limit. | |
| batch_end = min(page + CONCURRENCY - 1, MAX_PAGES_PER_CYCLE) | |
| batch_pages = list(range(page, batch_end + 1)) | |
| tasks = [asyncio.create_task(wrapped(p)) for p in batch_pages] | |
| batch_results = await asyncio.gather(*tasks) | |
| results.extend(batch_results) | |
| # Stop early if we encounter an empty page (no products) or a failed response. | |
| empty_found = False | |
| for res in batch_results: | |
| if not res: | |
| empty_found = True | |
| break | |
| products = res.get("products") | |
| if not products: | |
| empty_found = True | |
| break | |
| if empty_found: | |
| break | |
| page = batch_end + 1 | |
| return results |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No validation is performed on the response JSON structure before accessing 'products'. If the API returns malformed JSON or the structure changes, this could cause a KeyError. Consider adding validation or using .get() with a default value.
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a product is missing the 'last_updated_t' field, it's silently skipped but not counted. This could lead to confusion about the actual number of products processed versus downloaded. Consider logging when products are skipped.
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If extract_product() raises an exception (e.g., KeyError on p["last_updated_t"]), it will crash the entire script without proper error handling. Consider wrapping the extraction in a try-except block to handle malformed product data gracefully.
| all_products.append(extract_product(p)) | |
| try: | |
| pr = extract_product(p) | |
| except Exception as e: | |
| print(f"Skipping malformed product due to error: {e}") | |
| continue | |
| all_products.append(pr) |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File I/O operations are performed for each product individually within a loop (lines 148-149), which is inefficient. Consider batching the writes or accumulating products in memory and writing them in larger chunks to reduce I/O overhead.
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message instructs to manually close the JSON array, which is error-prone. This is related to the invalid JSON generation issue mentioned earlier. The script should properly close the JSON array automatically.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "last_updated_t": 1764492189 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The output file will have a trailing comma after the last product, which makes it invalid JSON. The file needs proper JSON array formatting with a closing bracket. Consider using a different approach such as writing complete JSON at once, or tracking whether to add a comma before each entry.