8383}
8484
8585STOPWORDS = {
86- "the" , "a" , "an" , "of" , "and" , "in" , "on" , "for" , "with" , "to" , "from" ,
87- "community" , "communities" , "consortium" , "consortia" , "coculture" ,
88- "co" , "culture" , "microbial" , "synthetic" , "syncom" , "defined" , "system" ,
89- "model" , "based" , "using" , "study" , "novel" , "new" ,
86+ "the" ,
87+ "a" ,
88+ "an" ,
89+ "of" ,
90+ "and" ,
91+ "in" ,
92+ "on" ,
93+ "for" ,
94+ "with" ,
95+ "to" ,
96+ "from" ,
97+ "community" ,
98+ "communities" ,
99+ "consortium" ,
100+ "consortia" ,
101+ "coculture" ,
102+ "co" ,
103+ "culture" ,
104+ "microbial" ,
105+ "synthetic" ,
106+ "syncom" ,
107+ "defined" ,
108+ "system" ,
109+ "model" ,
110+ "based" ,
111+ "using" ,
112+ "study" ,
113+ "novel" ,
114+ "new" ,
90115}
91116
92117
93118def _tokens (text : str ) -> set [str ]:
94119 """Lowercase alphanumeric tokens minus stopwords, length >= 4."""
95- return {
96- t
97- for t in re .findall (r"[a-z0-9]+" , text .lower ())
98- if len (t ) >= 4 and t not in STOPWORDS
99- }
120+ return {t for t in re .findall (r"[a-z0-9]+" , text .lower ()) if len (t ) >= 4 and t not in STOPWORDS }
100121
101122
102123def build_dedup_index (communities_dir : Path ) -> dict :
@@ -190,7 +211,9 @@ def query_epmc(query: str, since: int, limit: int) -> list[dict]:
190211 {
191212 "pmid" : r .get ("pmid" , "" ),
192213 "doi" : r .get ("doi" , "" ),
193- "title" : re .sub (r"<[^>]+>" , "" , html .unescape (r .get ("title" ) or "" )).rstrip ("." ),
214+ "title" : re .sub (r"<[^>]+>" , "" , html .unescape (r .get ("title" ) or "" )).rstrip (
215+ "."
216+ ),
194217 "abstract" : re .sub (r"<[^>]+>" , "" , html .unescape (r .get ("abstractText" ) or "" )),
195218 "year" : r .get ("pubYear" , "" ),
196219 "journal" : (r .get ("journalInfo" , {}) or {}).get ("journal" , {}).get ("title" , "" ),
@@ -205,6 +228,49 @@ def query_epmc(query: str, since: int, limit: int) -> list[dict]:
205228 return hits [:limit ]
206229
207230
231+ def crossref_doi_for_title (title : str , session : requests .Session ) -> str :
232+ """Best-effort DOI for a title via CrossRef, so ref-less hits can dedup by DOI.
233+
234+ Europe PMC AGR-source records often carry no PMID/DOI; without one a hit for an
235+ already-curated paper re-surfaces as NEW. Resolving the DOI lets dedup_status
236+ catch it against cited_dois. Requires a high title-token overlap to avoid
237+ grabbing an unrelated DOI.
238+ """
239+ if not title :
240+ return ""
241+ try :
242+ resp = session .get (
243+ "https://api.crossref.org/works" ,
244+ params = {"query.bibliographic" : title , "rows" : "1" },
245+ timeout = 30 ,
246+ )
247+ resp .raise_for_status ()
248+ items = resp .json ().get ("message" , {}).get ("items" , [])
249+ except (requests .exceptions .RequestException , ValueError ):
250+ return ""
251+ if not items :
252+ return ""
253+ it = items [0 ]
254+ cand_title = (it .get ("title" ) or ["" ])[0 ]
255+ # Require the CrossRef hit's title to substantially match the query title.
256+ q , c = _tokens (title ), _tokens (cand_title )
257+ if q and len (q & c ) / len (q ) >= 0.7 :
258+ return (it .get ("DOI" ) or "" ).strip ()
259+ return ""
260+
261+
262+ def backfill_missing_dois (hits : list [dict ], session : requests .Session ) -> int :
263+ """Fill hit['doi'] from CrossRef for hits lacking both PMID and DOI. Returns count filled."""
264+ filled = 0
265+ for h in hits :
266+ if not (h .get ("pmid" ) or "" ).strip () and not (h .get ("doi" ) or "" ).strip ():
267+ doi = crossref_doi_for_title (h .get ("title" , "" ), session )
268+ if doi :
269+ h ["doi" ] = doi
270+ filled += 1
271+ return filled
272+
273+
208274def slugify (text : str ) -> str :
209275 return re .sub (r"[^a-z0-9]+" , "-" , text .lower ()).strip ("-" )[:40 ] or "scout"
210276
@@ -213,7 +279,11 @@ def emit_stub(hit: dict, out_dir: Path) -> Path:
213279 """Write a minimal review-only draft record (placeholder id, NOT minted)."""
214280 stub_dir = out_dir / "stubs"
215281 stub_dir .mkdir (parents = True , exist_ok = True )
216- ref = f"PMID:{ hit ['pmid' ]} " if hit .get ("pmid" ) else (f"doi:{ hit ['doi' ]} " if hit .get ("doi" ) else "" )
282+ ref = (
283+ f"PMID:{ hit ['pmid' ]} "
284+ if hit .get ("pmid" )
285+ else (f"doi:{ hit ['doi' ]} " if hit .get ("doi" ) else "" )
286+ )
217287 name = hit ["title" ][:120 ]
218288 stub = {
219289 "id" : "CommunityMech:XXXXXX" , # placeholder — mint via manage-identifiers
@@ -235,14 +305,24 @@ def emit_stub(hit: dict, out_dir: Path) -> Path:
235305
236306
237307def main (argv : list [str ] | None = None ) -> int :
238- p = argparse .ArgumentParser (description = __doc__ , formatter_class = argparse .RawDescriptionHelpFormatter )
308+ p = argparse .ArgumentParser (
309+ description = __doc__ , formatter_class = argparse .RawDescriptionHelpFormatter
310+ )
239311 grp = p .add_mutually_exclusive_group (required = True )
240312 grp .add_argument ("--query" , help = "Free-text Europe PMC query." )
241313 grp .add_argument ("--preset" , choices = sorted (PRESETS ), help = "Ready-made query angle." )
242- p .add_argument ("--since" , type = int , default = 2024 , help = "Earliest first-publication year (default 2024)." )
243- p .add_argument ("--limit" , type = int , default = 40 , help = "Max Europe PMC hits to fetch (default 40)." )
244- p .add_argument ("--min-score" , type = int , default = 1 , help = "Drop hits below this community-signal score." )
245- p .add_argument ("--include-cited" , action = "store_true" , help = "Keep hits already cited by a record." )
314+ p .add_argument (
315+ "--since" , type = int , default = 2024 , help = "Earliest first-publication year (default 2024)."
316+ )
317+ p .add_argument (
318+ "--limit" , type = int , default = 40 , help = "Max Europe PMC hits to fetch (default 40)."
319+ )
320+ p .add_argument (
321+ "--min-score" , type = int , default = 1 , help = "Drop hits below this community-signal score."
322+ )
323+ p .add_argument (
324+ "--include-cited" , action = "store_true" , help = "Keep hits already cited by a record."
325+ )
246326 p .add_argument ("--emit-stubs" , action = "store_true" , help = "Write review-only draft stub YAMLs." )
247327 p .add_argument ("--out-dir" , type = Path , default = DEFAULT_OUT_DIR )
248328 args = p .parse_args (argv )
@@ -260,6 +340,12 @@ def main(argv: list[str] | None = None) -> int:
260340 return 1
261341 print (f"[scout] fetched { len (hits )} hits" , file = sys .stderr )
262342
343+ # Ref-less hits (e.g. Europe PMC AGR source) can't dedup by PMID/DOI; try to
344+ # resolve a DOI via CrossRef so already-curated papers don't re-surface as NEW.
345+ n_filled = backfill_missing_dois (hits , requests .Session ())
346+ if n_filled :
347+ print (f"[scout] backfilled DOIs for { n_filled } ref-less hits (CrossRef)" , file = sys .stderr )
348+
263349 index = build_dedup_index (COMMUNITIES_DIR )
264350 print (
265351 f"[scout] dedup index: { len (index ['cited_pmids' ])} PMIDs, "
@@ -306,14 +392,20 @@ def main(argv: list[str] | None = None) -> int:
306392 (f"_{ h ['dedup_detail' ]} _ " if h ["dedup_detail" ] else "" ),
307393 f"Signals: { ', ' .join (h ['signals' ]) or '—' } " ,
308394 "" ,
309- (h ["abstract" ][:600 ] + ("…" if len (h ["abstract" ]) > 600 else "" )) if h ["abstract" ] else "_(no abstract)_" ,
395+ (
396+ (h ["abstract" ][:600 ] + ("…" if len (h ["abstract" ]) > 600 else "" ))
397+ if h ["abstract" ]
398+ else "_(no abstract)_"
399+ ),
310400 "" ,
311401 ]
312402 report_path .write_text ("\n " .join (lines ))
313403
314404 queue = [
315405 {
316- "reference" : f"PMID:{ h ['pmid' ]} " if h ["pmid" ] else (f"doi:{ h ['doi' ]} " if h ["doi" ] else "" ),
406+ "reference" : (
407+ f"PMID:{ h ['pmid' ]} " if h ["pmid" ] else (f"doi:{ h ['doi' ]} " if h ["doi" ] else "" )
408+ ),
317409 "title" : h ["title" ],
318410 "year" : h ["year" ],
319411 "journal" : h ["journal" ],
@@ -334,8 +426,7 @@ def main(argv: list[str] | None = None) -> int:
334426
335427 n_new = sum (1 for h in candidates if h ["dedup" ] == "NEW" )
336428 print (
337- f"[scout] { len (candidates )} candidates ({ n_new } NEW). "
338- f"Report: { report_path } { stub_note } " ,
429+ f"[scout] { len (candidates )} candidates ({ n_new } NEW). " f"Report: { report_path } { stub_note } " ,
339430 file = sys .stderr ,
340431 )
341432 return 0
0 commit comments