M4: ingestion management (incremental, GS1 supplement, dedup/conflict, quality, scheduler)
- OFF incremental fetch via search API + persistent watermark (ingest_state, migration 0004) - GS1 barcode supplement adapter (offline mapping + GS1-style API) filling only gaps with field-level provenance - Non-GTIN dedup with canonical selection + merge_log; field-level conflict resolution (source trust > recency) - Quality scoring (0.4 completeness + 0.3 source trust + 0.2 multi-source + 0.1 freshness) wired into load/merge - Jobs: update_off, dedup, schedule; docs/ingestion-management.md - 19 new tests (pure + DB-integration), ruff clean Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -25,6 +25,16 @@ USER_AGENT = "OpenGoods/0.1 (+https://github.com/baicai2026-baicai/goods) public
|
||||
# Conservative client-side spacing between API calls (seconds).
|
||||
_DEFAULT_MIN_INTERVAL = 4.0
|
||||
_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||
_SEARCH_URL = "https://world.openfoodfacts.org/api/v2/search"
|
||||
|
||||
# Fields requested from the search API so a returned product can be transformed
|
||||
# without an extra per-barcode round trip.
|
||||
_SEARCH_FIELDS = (
|
||||
"code,product_name,product_name_en,product_name_zh,brands,quantity,"
|
||||
"categories,categories_tags,countries,ingredients_text,allergens_tags,"
|
||||
"additives_tags,nutriments,nutriscore_grade,serving_size,"
|
||||
"image_front_url,image_url,last_modified_t"
|
||||
)
|
||||
|
||||
|
||||
class OpenFoodFactsAdapter:
|
||||
@@ -65,6 +75,45 @@ class OpenFoodFactsAdapter:
|
||||
if record is not None:
|
||||
yield record
|
||||
|
||||
def fetch_modified_since(
|
||||
self,
|
||||
since_t: int,
|
||||
*,
|
||||
page_size: int = 100,
|
||||
max_pages: int = 10,
|
||||
) -> Iterator[dict]:
|
||||
"""Yield products modified after ``since_t`` (unix ``last_modified_t``).
|
||||
|
||||
Uses the OFF search API sorted by ``last_modified_t`` (most recent
|
||||
first) and paginates until it reaches products at or before the
|
||||
watermark, an empty/short page, or ``max_pages``. This is the
|
||||
incremental ingestion path: callers persist the highest
|
||||
``last_modified_t`` they processed as the next watermark.
|
||||
"""
|
||||
for page in range(1, max_pages + 1):
|
||||
self._throttle()
|
||||
resp = self._client.get(
|
||||
_SEARCH_URL,
|
||||
params={
|
||||
"fields": _SEARCH_FIELDS,
|
||||
"sort_by": "last_modified_t",
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
products = resp.json().get("products") or []
|
||||
if not products:
|
||||
return
|
||||
reached_old = False
|
||||
for prod in products:
|
||||
if int(prod.get("last_modified_t") or 0) <= since_t:
|
||||
reached_old = True
|
||||
break
|
||||
yield prod
|
||||
if reached_old or len(products) < page_size:
|
||||
return
|
||||
|
||||
|
||||
def read_dump(path: str | Path) -> Iterator[dict]:
|
||||
"""Yield raw product records from an OFF JSONL dump file.
|
||||
|
||||
Reference in New Issue
Block a user