| from __future__ import annotations |
|
|
| """ |
| Targeted TGSC enrichment of the PINO aroma registry. |
| |
| Processes a priority subset of single-chemical fragrance materials, fetches their |
| TGSC data sheets, and writes the extracted boiling point, vapor pressure |
| (mmHg -> Pa), and odor description back into the SQLite registry. |
| """ |
|
|
| import argparse |
| import logging |
| import re |
| import sys |
| from pathlib import Path |
| from typing import Any |
|
|
| sys.path.insert(0, str(Path(__file__).parent.parent / "src")) |
|
|
| from camoufox.sync_api import Camoufox |
| from pino.registry import AromaRegistry |
| from pino.scraper import search_tgsc, parse_tgsc_page_camofox |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s %(levelname)s %(name)s: %(message)s", |
| ) |
| logger = logging.getLogger("enrich_registry") |
|
|
|
|
| |
| |
| PRIORITY_CAS = [ |
| "5989-27-5", |
| "78-70-6", |
| "5392-40-5", |
| "106-24-1", |
| "106-25-2", |
| "115-95-7", |
| "80-56-8", |
| "99-85-4", |
| "91-64-5", |
| "121-33-5", |
| "105-95-3", |
| "77-53-2", |
| "87-44-5", |
| "97-53-0", |
| "119-36-8", |
| "118-58-1", |
| "1222-05-5", |
| "21145-77-7", |
| "4602-84-0", |
| "7212-44-4", |
| "104-67-6", |
| "120-57-0", |
| "124-13-0", |
| "112-31-2", |
| "140-11-4", |
| "141-12-8", |
| "24851-98-7", |
| "127-51-5", |
| "14901-07-6", |
| "16409-43-1", |
| "33704-61-9", |
| ] |
|
|
|
|
| def _is_single_chemical(cas: str) -> bool: |
| """Natural oils and absolutes have 8000/9000 CAS prefixes; skip them.""" |
| return not re.match(r"^(8\d{3}|9\d{3})", cas) |
|
|
|
|
| def enrich_priority( |
| registry: AromaRegistry, |
| priority: list[str], |
| limit: int | None = None, |
| ) -> dict[str, Any]: |
| """ |
| Enrich a priority list of CAS entries from TGSC. |
| |
| Returns a summary dict with counts and the list of updated records. |
| """ |
| rows = registry._conn.execute( |
| "SELECT cas, name FROM aroma_chemicals WHERE cas IN (" |
| + ",".join("?" * len(priority)) |
| + ") ORDER BY cas", |
| tuple(priority), |
| ).fetchall() |
|
|
| if limit is not None: |
| rows = rows[:limit] |
|
|
| logger.info("Enriching %d priority materials from TGSC", len(rows)) |
|
|
| enriched: list[dict[str, Any]] = [] |
| skipped = 0 |
| failed = 0 |
|
|
| with Camoufox(headless=True) as browser: |
| for cas, name in rows: |
| if not _is_single_chemical(cas): |
| skipped += 1 |
| continue |
|
|
| try: |
| candidates = search_tgsc(name, browser=browser) |
| except Exception as exc: |
| logger.warning("Search failed for %s (%s): %s", cas, name, exc) |
| failed += 1 |
| continue |
|
|
| if not candidates: |
| logger.warning("No TGSC results for %s (%s)", cas, name) |
| failed += 1 |
| continue |
|
|
| scraped = None |
| for candidate in candidates: |
| try: |
| scraped = parse_tgsc_page_camofox(candidate["url"], browser=browser) |
| except Exception as exc: |
| logger.debug("Scrape failed for %s: %s", candidate["url"], exc) |
| continue |
| if scraped.get("vapor_pressure_pa") is not None: |
| scraped["url"] = candidate["url"] |
| scraped["name"] = candidate["name"] |
| break |
|
|
| if scraped is None or scraped.get("vapor_pressure_pa") is None: |
| failed += 1 |
| continue |
|
|
| registry._conn.execute( |
| """ |
| UPDATE aroma_chemicals |
| SET boiling_point_k = COALESCE(?, boiling_point_k), |
| vapor_pressure_pa = COALESCE(?, vapor_pressure_pa), |
| odor_description = COALESCE(?, odor_description) |
| WHERE cas = ? |
| """, |
| ( |
| scraped.get("boiling_point_k"), |
| scraped.get("vapor_pressure_pa"), |
| scraped.get("odor_description", ""), |
| cas, |
| ), |
| ) |
| registry._conn.commit() |
| enriched.append({"cas": cas, "name": name, **scraped}) |
| logger.info( |
| "Enriched %s (%s): VP=%.4f Pa, BP=%.2f K, odor=%r", |
| cas, |
| name, |
| scraped["vapor_pressure_pa"], |
| scraped["boiling_point_k"] or 0.0, |
| scraped.get("odor_description", "")[:60], |
| ) |
|
|
| return { |
| "enriched": len(enriched), |
| "skipped": skipped, |
| "failed": failed, |
| "records": enriched, |
| } |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description="Targeted TGSC registry enrichment") |
| parser.add_argument( |
| "--registry", |
| default="src/pino/registry.db", |
| help="Path to the SQLite aroma registry", |
| ) |
| parser.add_argument( |
| "--limit", |
| type=int, |
| default=None, |
| help="Process only the first N priority materials", |
| ) |
| parser.add_argument( |
| "--priority", |
| default=None, |
| help="Comma-separated list of CAS numbers to enrich instead of the default priority list", |
| ) |
| parser.add_argument( |
| "--log-level", |
| default="INFO", |
| help="Logging level", |
| ) |
| args = parser.parse_args() |
|
|
| logging.getLogger().setLevel(getattr(logging, args.log_level.upper(), logging.INFO)) |
|
|
| registry = AromaRegistry(args.registry) |
| priority = [c.strip() for c in args.priority.split(",")] if args.priority else PRIORITY_CAS |
|
|
| summary = enrich_priority(registry, priority, limit=args.limit) |
| print(summary) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|