2525import re
2626import statistics
2727import sys
28+ import urllib .parse
2829from pathlib import Path
2930
3031import pandas as pd
@@ -138,6 +139,78 @@ def fetch_record(inchikey):
138139 "odor_threshold_source" : thr_src }
139140
140141
142+ _ANNO = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/annotations/heading/JSON"
143+
144+
145+ def annotation_records (heading ):
146+ """Yield (cid, [strings], source) from PubChem's annotations API — inline data across ALL
147+ pages, public-domain sources only. The whole 'Odor' set is ~3 requests, not ~3k crawls."""
148+ page = 1
149+ while True :
150+ d = _get (f"{ _ANNO } ?heading_type=Compound&heading={ urllib .parse .quote (heading )} &page={ page } " )
151+ ann = (d or {}).get ("Annotations" , {})
152+ for rec in ann .get ("Annotation" , []):
153+ src = (rec .get ("SourceName" ) or "" ).strip ()
154+ if not _pd_source (src ):
155+ continue
156+ cids = rec .get ("LinkedRecords" , {}).get ("CID" , [])
157+ if not cids :
158+ continue
159+ strs = []
160+ for dd in rec .get ("Data" , []):
161+ for s in dd .get ("Value" , {}).get ("StringWithMarkup" , []) or []:
162+ t = " " .join ((s .get ("String" ) or "" ).split ()).strip ()
163+ if t :
164+ strs .append (t )
165+ if strs :
166+ yield cids [0 ], strs , src
167+ if page >= ann .get ("TotalPages" , 1 ):
168+ break
169+ page += 1
170+
171+
172+ def cids_to_inchikeys (cids , chunk = 100 ):
173+ """{cid: InChIKey} via batched PubChem property calls (~1 request per 100 CIDs)."""
174+ out = {}
175+ for i in range (0 , len (cids ), chunk ):
176+ ch = cids [i :i + chunk ]
177+ d = _get (f"{ _BASE } /pug/compound/cid/{ ',' .join (map (str , ch ))} /property/InChIKey/JSON" )
178+ for p in (d or {}).get ("PropertyTable" , {}).get ("Properties" , []):
179+ if p .get ("InChIKey" ):
180+ out [p ["CID" ]] = p ["InChIKey" ]
181+ return out
182+
183+
184+ def build_from_pubchem_annotations ():
185+ """Rows for the WHOLE public-domain PubChem odor set via the annotations API (inline data).
186+ ~3 requests for odor + ~1 for threshold + a batched CID->InChIKey resolve — not a 3k crawl."""
187+ odor_by_cid = {}
188+ for cid , strs , src in annotation_records ("Odor" ):
189+ o , s = odor_by_cid .get (cid , ([], set ()))
190+ odor_by_cid [cid ] = (o + strs , s | {src })
191+ thr_by_cid = {}
192+ for cid , strs , src in annotation_records ("Odor Threshold" ):
193+ thr_by_cid .setdefault (cid , []).extend ((src , t ) for t in strs )
194+ all_cids = sorted (set (odor_by_cid ) | set (thr_by_cid ))
195+ print (f"annotations: { len (odor_by_cid )} odor + { len (thr_by_cid )} threshold CIDs "
196+ f"(public-domain); resolving { len (all_cids )} InChIKeys..." , flush = True )
197+ cid2ik = cids_to_inchikeys (all_cids )
198+ rows = []
199+ for cid in all_cids :
200+ ik = cid2ik .get (cid )
201+ if not ik :
202+ continue
203+ ostrs , osrcs = odor_by_cid .get (cid , ([], set ()))
204+ thr_pairs = thr_by_cid .get (cid , [])
205+ rows .append ({"inchikey" : ik ,
206+ "odor" : "\n " .join (sorted (set (ostrs ), key = len )) or None ,
207+ "odor_source" : "; " .join (sorted (osrcs )) or None ,
208+ "odor_threshold_ppm" : _parse_threshold_ppm (thr_pairs ),
209+ "odor_threshold_note" : "\n " .join (dict .fromkeys (t for _ , t in thr_pairs )) or None ,
210+ "odor_threshold_source" : "; " .join (sorted ({s for s , _ in thr_pairs })) or None })
211+ return rows
212+
213+
141214COLS = ["inchikey" , "odor" , "odor_source" , "odor_threshold_ppm" ,
142215 "odor_threshold_note" , "odor_threshold_source" ]
143216
@@ -146,9 +219,23 @@ def fetch_record(inchikey):
146219 ap .add_argument ("--molecules" , help = "CSV/parquet with 'smiles' or 'inchikey' "
147220 "(default: flavor_volatiles.csv)" )
148221 ap .add_argument ("--out" , default = "odor_notes.parquet" , help = "output (merged if it exists)" )
222+ ap .add_argument ("--pubchem-all" , action = "store_true" ,
223+ help = "pull the ENTIRE public-domain PubChem odor set via the annotations API" )
149224 ap .add_argument ("smiles" , nargs = "*" , help = "SMILES to test-print (no build)" )
150225 a = ap .parse_args ()
151226
227+ if a .pubchem_all : # whole public-domain odor set, via the annotations API (cheap)
228+ rows = build_from_pubchem_annotations ()
229+ new = pd .DataFrame (rows , columns = COLS )
230+ if Path (a .out ).exists ():
231+ old = pd .read_parquet (a .out )
232+ new = pd .concat ([old , new ], ignore_index = True ).drop_duplicates ("inchikey" , keep = "last" )
233+ new = new .reindex (columns = COLS )
234+ new .to_parquet (a .out )
235+ print (f"{ a .out } : { len (new )} molecules ({ int (new ['odor' ].notna ().sum ())} odor, "
236+ f"{ int (new ['odor_threshold_ppm' ].notna ().sum ())} threshold); public-domain" )
237+ sys .exit (0 )
238+
152239 if a .smiles : # test mode
153240 for smi in a .smiles :
154241 mol = Chem .MolFromSmiles (smi )
0 commit comments