Spaces:
Running
Running
| """ | |
| OpenAlex enrichment — journal metadata + peer percentile calculation. | |
| Free API, no key required. Uses polite pool with mailto header. | |
| Changes from v1.0: | |
| - Shared module-level httpx.AsyncClient (init_client / close_client for lifespan) | |
| - compute_percentile accepts an optional concept_cache dict to deduplicate | |
| identical concept peer-set fetches across journals in one request | |
| - _norm_issn removed — uses utils.norm_issn | |
| """ | |
| import httpx | |
| import asyncio | |
| from typing import Optional | |
| from utils import norm_issn | |
| UA = "ResearchNavigator/1.0 (mailto:library@ku.ac.ae)" | |
| BASE = "https://api.openalex.org" | |
| _http_client: Optional[httpx.AsyncClient] = None | |
| def init_client() -> None: | |
| global _http_client | |
| _http_client = httpx.AsyncClient( | |
| timeout=httpx.Timeout(10.0, connect=5.0), | |
| headers={"User-Agent": UA}, | |
| ) | |
| async def close_client() -> None: | |
| global _http_client | |
| if _http_client and not _http_client.is_closed: | |
| await _http_client.aclose() | |
| _http_client = None | |
| def _get_client() -> httpx.AsyncClient: | |
| if _http_client is None or _http_client.is_closed: | |
| init_client() | |
| return _http_client | |
| async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]: | |
| client = _get_client() | |
| try: | |
| if issn: | |
| clean = norm_issn(issn) or issn.strip() | |
| r = await client.get(f"{BASE}/sources/issn:{clean}") | |
| else: | |
| r = await client.get( | |
| f"{BASE}/sources", params={"search": name, "per_page": 1} | |
| ) | |
| if not r.is_success: | |
| return None | |
| d = r.json() | |
| s = d if issn else (d.get("results") or [None])[0] | |
| if not s: | |
| return None | |
| return { | |
| "openalex_id": s.get("id"), | |
| "display_name": s.get("display_name"), | |
| "issn_l": s.get("issn_l"), | |
| "issns": s.get("issn", []), | |
| "host_organization": s.get("host_organization_name"), | |
| "is_oa": s.get("is_oa"), | |
| "is_in_doaj": s.get("is_in_doaj"), | |
| "works_count": s.get("works_count"), | |
| "cited_by_count": s.get("cited_by_count"), | |
| "h_index": (s.get("summary_stats") or {}).get("h_index"), | |
| "two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"), | |
| "homepage_url": s.get("homepage_url"), | |
| "top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"), | |
| "top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"), | |
| } | |
| except Exception as e: | |
| print(f"[OpenAlex] enrich error for {name}: {e}") | |
| return None | |
| async def compute_percentile( | |
| openalex: dict, | |
| concept_cache: Optional[dict] = None, | |
| ) -> Optional[dict]: | |
| try: | |
| if not openalex or not openalex.get("top_concept_id"): | |
| return None | |
| val = openalex.get("two_yr_mean_citedness") | |
| if val is None: | |
| return None | |
| concept_id = str(openalex["top_concept_id"]).split("/")[-1] | |
| if concept_cache is not None and concept_id in concept_cache: | |
| peers = concept_cache[concept_id] | |
| else: | |
| client = _get_client() | |
| r = await client.get( | |
| f"{BASE}/sources", | |
| params={ | |
| "filter": f"concepts.id:{concept_id},type:journal", | |
| "per_page": 200, | |
| "sort": "summary_stats.2yr_mean_citedness:desc", | |
| }, | |
| ) | |
| if not r.is_success: | |
| return None | |
| peers = [ | |
| s.get("summary_stats", {}).get("2yr_mean_citedness") | |
| for s in r.json().get("results", []) | |
| ] | |
| peers = [v for v in peers if isinstance(v, (int, float))] | |
| if concept_cache is not None: | |
| concept_cache[concept_id] = peers | |
| if len(peers) < 10: | |
| return None | |
| below = sum(1 for v in peers if v < val) | |
| pct = round(below / len(peers) * 100) | |
| q = "Q1" if pct >= 75 else "Q2" if pct >= 50 else "Q3" if pct >= 25 else "Q4" | |
| return { | |
| "percentile": pct, | |
| "quartile": q, | |
| "peer_count": len(peers), | |
| "concept": openalex.get("top_concept"), | |
| } | |
| except Exception as e: | |
| print(f"[OpenAlex] percentile error: {e}") | |
| return None | |
| async def enrich_all(journals: list) -> list: | |
| """Enrich a list of journal dicts in parallel, deduplicating concept peer-sets.""" | |
| concept_cache: dict = {} | |
| async def _enrich_one(j: dict) -> dict: | |
| oa = await enrich_journal(j.get("name", ""), j.get("issn")) | |
| pct = await compute_percentile(oa, concept_cache) if oa else None | |
| j["openalex"] = oa | |
| j["oa_percentile"] = pct | |
| if not j.get("issn") and oa: | |
| j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0] | |
| return j | |
| return list(await asyncio.gather(*[_enrich_one(j) for j in journals])) | |