Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,9 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

# PyCharm
.idea/

# Large files
domains/world.openfoodfacts.org/another_implementation/products.json
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")


Comment on lines +38 to +47

Copilot AI Dec 19, 2025

Copy link

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.

Suggested change
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")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
# Initialize as a valid empty JSON array
json.dump([], f, ensure_ascii=False)
def append_product(p):
# Ensure the output file exists and is initialized as a JSON array
ensure_output_file()
# Read the existing array, append the new product, and rewrite the file
with open(OUTPUT_FILE, "r+", encoding="utf-8") as f:
try:
data = json.load(f)
except json.JSONDecodeError:
data = []
if not isinstance(data, list):
data = []
data.append(p)
f.seek(0)
json.dump(data, f, ensure_ascii=False)
f.truncate()

Copilot uses AI. Check for mistakes.
# -----------------------------
# 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()

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function returns None for non-200 status codes without logging the error or status code, making it difficult to diagnose API issues. Consider logging the status code and response body to help with debugging.

Suggested change
return r.json()
return r.json()
print(f"[ERROR] Non-200 response for page {page}: status={r.status_code}, body={r.text}")

Copilot uses AI. Check for mistakes.
return None

except (httpx.ReadTimeout, httpx.ConnectTimeout):
print(f"[TIMEOUT] page {page}, attempt {attempt+1}/{retries}")
Comment on lines +68 to +69

Copilot AI Dec 19, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
await asyncio.sleep(1 + attempt)

print(f"[FAILED] page {page}")
return None
Comment on lines +66 to +73

Copilot AI Dec 19, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.


# -----------------------------
# PRODUCT CLEANER
# -----------------------------
def extract_product(p):
return {
"barcode": p.get("code"),
"name": p.get("product_name"),
"image_links": [p.get("image_url")],
Comment on lines +80 to +83

Copilot AI Dec 19, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
"updated_at": datetime.utcfromtimestamp(p["last_updated_t"]).isoformat()

Copilot AI Dec 19, 2025

Copy link

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 uses AI. Check for mistakes.
}


# -----------------------------
# FETCH MANY PAGES IN PARALLEL
# -----------------------------
async def fetch_cycle(last_updated_t):
async with httpx.AsyncClient(timeout=60.0) as client:
sem = asyncio.Semaphore(CONCURRENCY)

async def wrapped(page):
async with sem:
return await fetch_page(client, page, last_updated_t)

tasks = [asyncio.create_task(wrapped(page)) for page in range(1, MAX_PAGES_PER_CYCLE + 1)]
return await asyncio.gather(*tasks)


Comment on lines +99 to +102

Copilot AI Dec 19, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
# -----------------------------
# MAIN LOOP
# -----------------------------
async def main():
state = load_state()
ensure_output_file()

print(f"Starting with last_updated_t={state['last_updated_t']}")

while True:
batch = await fetch_cycle(state["last_updated_t"])

all_products = []
max_ts = state["last_updated_t"]
empty_pages = 0

for idx, data in enumerate(batch, start=1):
if not data or "products" not in data:
empty_pages += 1
continue

products = data["products"]

Copilot AI Dec 19, 2025

Copy link

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 uses AI. Check for mistakes.

if len(products) == 0:
empty_pages += 1
continue

print(f"[PAGE {idx}] +{len(products)} products")

for p in products:
if "last_updated_t" not in p:
continue
Comment on lines +133 to +134

Copilot AI Dec 19, 2025

Copy link

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 uses AI. Check for mistakes.

ts = p["last_updated_t"]
if ts > max_ts:
max_ts = ts

all_products.append(extract_product(p))

Comment on lines +140 to +141

Copilot AI Dec 19, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
# no new data -> stop
if empty_pages == MAX_PAGES_PER_CYCLE:
print("No more new data. Stopping.")
break

# write downloaded products
for pr in all_products:
append_product(pr)
Comment on lines +148 to +149

Copilot AI Dec 19, 2025

Copy link

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 uses AI. Check for mistakes.

print(f"Downloaded {len(all_products)} new products in this cycle.")
print(f"Updated last_updated_t → {max_ts}")

state["last_updated_t"] = max_ts
save_state(state)

print("Parsing finished. Don't forget to close array with ]")

Copilot AI Dec 19, 2025

Copy link

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.

Copilot uses AI. Check for mistakes.


if __name__ == "__main__":
asyncio.run(main())
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"last_updated_t": 1764492189
}