Coverage for src/local_deep_research/journal_quality/data_sources/openalex.py: 89%

83 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""OpenAlex sources data source. 

2 

3Downloads the **bulk snapshot** of ~280K journals + conferences from 

4``openalex.s3.amazonaws.com`` and writes a compact gzipped JSON file 

5used by the in-memory tier-2 lookups. 

6 

7This is the public OpenAlex S3 snapshot — CC0 licensed, no auth, no 

8rate limits, ~350 MB compressed across ~120 partition files. We stream 

9each part, extract the few fields we need, and discard the rest. Total 

10wall-clock is ~30–60 s on a normal connection. 

11 

12Why bulk snapshot instead of the REST API 

13----------------------------------------- 

14 

15The previous implementation paginated ``/api/sources`` with 

16``per_page=200`` and a 100 ms polite-rate-limiting sleep between 

17requests. For ~280K records that meant ~1,400 HTTP requests + ~140 s 

18of pure sleep, plus actual transfer time — wall-clock 5–10 minutes, 

19and that's the time the user spends staring at the dashboard 

20"Download Data" button. The S3 dump is the recommended bulk path per 

21OpenAlex docs and finishes in well under a minute. 

22""" 

23 

24from __future__ import annotations 

25 

26import gzip 

27import json 

28import time 

29from pathlib import Path 

30 

31from loguru import logger 

32 

33from ...utilities.citation_normalizer import normalize_issn 

34from .base import DataSource, atomic_replace 

35 

36from ._openalex_common import ( 

37 OPENALEX_S3_BASE, 

38 iter_partitions, 

39 validate_manifest_entries, 

40) 

41 

42_OPENALEX_SOURCES_MANIFEST = ( 

43 f"{OPENALEX_S3_BASE}/data/jsonl/sources/manifest.json" 

44) 

45 

46 

47class SchemaDriftError(RuntimeError): 

48 """OpenAlex renamed / removed a required field in the snapshot. 

49 

50 A row-count floor catches the case where the whole fetch collapses, 

51 but not the case where every row loads but a key field (``h_index``, 

52 ``cited_by_count``) is silently None. We refuse to overwrite the 

53 existing snapshot in that case — better to keep the old data than 

54 rebuild an all-None DB that would quietly reclassify every journal 

55 into the "unknown" quality tier. 

56 """ 

57 

58 

59class OpenAlexSource(DataSource): 

60 key = "openalex" # gitleaks:allow 

61 name = "OpenAlex" 

62 url = "https://openalex.org" 

63 dataset_url = ( 

64 "https://docs.openalex.org/download-all-data/openalex-snapshot" 

65 ) 

66 license = "CC0 1.0" 

67 license_url = "https://creativecommons.org/publicdomain/zero/1.0/" 

68 description = ( 

69 "~280K journals and conferences with h-index, impact factor, " 

70 "and publisher metadata" 

71 ) 

72 # Deliberately NOT `downloader.REQUIRED_SNAPSHOT_FILENAME`: downloader.py 

73 # imports this package (`from .data_sources import ALL_SOURCES, 

74 # get_source`) before it defines that constant, and this is a class 

75 # attribute (evaluated at class-definition / import time, not inside a 

76 # function body), so even a lazy import wouldn't avoid the cycle. 

77 filename = "openalex_sources.json.gz" 

78 count_label = "OpenAlex sources" 

79 auto_download = False # large; user opts in via dashboard 

80 required = True # bulk-download fatal-on-failure 

81 approx_size_mb = 13.0 # final compact output, NOT the raw snapshot 

82 

83 def fetch(self, data_dir: Path, progress_cb=None) -> int: 

84 from ...security.safe_requests import ( 

85 safe_get_with_retries as safe_get, 

86 ) 

87 

88 # 1. Fetch the manifest. Tells us which partition files exist 

89 # and how many records to expect — so we can give the user 

90 # an accurate progress log instead of just dots. 

91 logger.info( 

92 f"Fetching OpenAlex sources manifest: {_OPENALEX_SOURCES_MANIFEST}" 

93 ) 

94 # consume_body=True: the manifest is small but a serial 

95 # bottleneck for the whole download. A body-read transient 

96 # here aborts everything. 

97 manifest_resp = safe_get( 

98 _OPENALEX_SOURCES_MANIFEST, 

99 timeout=30, 

100 consume_body=True, 

101 require_https=True, 

102 ) 

103 manifest_resp.raise_for_status() 

104 manifest = manifest_resp.json() 

105 

106 # OpenAlex's 2026-06 "standard-format" snapshot moved the data to 

107 # ``data/jsonl/<entity>/`` and renamed the manifest's part list from 

108 # ``entries`` to ``files``. Each file entry still has ``url`` 

109 # (s3://openalex/...) + ``meta.record_count`` / ``meta.content_length``. 

110 entries = manifest.get("files", []) 

111 total_records = sum( 

112 e.get("meta", {}).get("record_count", 0) for e in entries 

113 ) 

114 total_bytes = sum( 

115 e.get("meta", {}).get("content_length", 0) for e in entries 

116 ) 

117 logger.info( 

118 f"OpenAlex sources snapshot: {len(entries)} parts, " 

119 f"{total_records:,} records, " 

120 f"{total_bytes / 1024 / 1024:.0f} MB compressed" 

121 ) 

122 

123 # Validate manifest URLs before fetching anything, so a 

124 # compromised manifest cannot redirect fetches to an arbitrary 

125 # host. Legitimate OpenAlex manifest entries always use the 

126 # s3://openalex/ prefix. 

127 validate_manifest_entries(entries, "OpenAlex sources") 

128 

129 # 2. Stream-process each part. The ``iter_partitions`` helper 

130 # in ``_openalex_common`` owns the tmp-file lifecycle + the 

131 # first-10 malformed-JSON suppression; we just consume 

132 # records and track our own schema-drift counters. 

133 type_map = {"journal": "j", "conference": "c"} 

134 sources: dict = {} 

135 # Raw parse counters feed the ``id``-rename drift check below — 

136 # records without an ``id`` are silently skipped at the point of 

137 # extraction, so we need to know the ratio to distinguish a 

138 # collapsed fetch from a renamed identifier field. 

139 parsed_records = 0 

140 parsed_with_id = 0 

141 start = time.time() 

142 

143 for idx, total_parts, records in iter_partitions( 

144 entries, 

145 data_dir, 

146 file_prefix="openalex_sources", 

147 label="OpenAlex sources", 

148 safe_get=safe_get, 

149 ): 

150 for rec in records: 

151 parsed_records += 1 

152 src_id = (rec.get("id") or "").split("/")[-1] 

153 if not src_id: 

154 continue 

155 parsed_with_id += 1 

156 

157 stats = rec.get("summary_stats") or {} 

158 compact = { 

159 "n": rec.get("display_name", ""), 

160 "t": type_map.get(rec.get("type", ""), rec.get("type", "")), 

161 "h": stats.get("h_index"), 

162 "if": stats.get("2yr_mean_citedness"), 

163 "cb": rec.get("cited_by_count"), 

164 "p": rec.get("host_organization_name") or "", 

165 "i": normalize_issn(rec.get("issn_l")), 

166 } 

167 if rec.get("is_in_doaj"): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 compact["d"] = 1 

169 if rec.get("is_core"): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 compact["s"] = 1 

171 sources[src_id] = compact 

172 

173 if (idx + 1) % 5 == 0 or idx == total_parts - 1: 173 ↛ 181line 173 didn't jump to line 181 because the condition on line 173 was always true

174 elapsed = time.time() - start 

175 logger.info( 

176 f"OpenAlex sources: processed {idx + 1}/{total_parts} " 

177 f"parts ({len(sources):,} records, {elapsed:.0f}s)" 

178 ) 

179 # Report on EVERY partition, not just every 5th — the UI 

180 # needs smoother updates than the human-readable log. 

181 if progress_cb is not None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 try: 

183 progress_cb( 

184 idx + 1, 

185 total_parts, 

186 f"{len(sources):,} records", 

187 ) 

188 except Exception: 

189 logger.debug( 

190 "OpenAlex progress_cb raised; continuing", 

191 exc_info=True, 

192 ) 

193 

194 # ``id``-rename drift runs *before* the row-count floor: a 

195 # renamed identifier makes every record drop out at parse time 

196 # (src_id empty → continue), so ``sources`` is empty and the 

197 # row-count floor would fire first with a generic RuntimeError 

198 # that hides the actual cause. 

199 if parsed_records >= 10_000 and parsed_with_id == 0: 

200 raise SchemaDriftError( 

201 f"OpenAlex snapshot parsed {parsed_records:,} records but " 

202 "none carried an 'id' field — the source identifier may " 

203 "have been renamed (e.g. to 'source_id'). Refusing to " 

204 "overwrite existing data — please check " 

205 "https://docs.openalex.org/download-all-data/" 

206 "snapshot-data-format for schema changes." 

207 ) 

208 

209 # 3. Write the compact snapshot in the same shape the existing 

210 # build pipeline expects (`{"s": {src_id: compact}}`). 

211 # Sanity check: OpenAlex normally has ~280K sources. If the 

212 # fetch silently returned a tiny subset (e.g., every partition 

213 # returned an empty shard), refuse to overwrite existing data. 

214 _MIN_OPENALEX_SOURCES = 10_000 

215 if len(sources) < _MIN_OPENALEX_SOURCES: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true

216 raise RuntimeError( 

217 f"OpenAlex sources: suspiciously few records " 

218 f"({len(sources):,} < {_MIN_OPENALEX_SOURCES:,}); " 

219 "refusing to overwrite existing data" 

220 ) 

221 

222 # Field-level schema drift detection. The row-count floor above 

223 # catches a collapsed fetch, but not a silent upstream rename. 

224 # The journal-only sample avoids false-triggering on snapshots 

225 # that skew to non-journal types (conferences, repositories, 

226 # etc.) which legitimately lack ``h_index``. 

227 _SCHEMA_SAMPLE_SIZE = 100 

228 journal_sample = [r for r in sources.values() if r.get("t") == "j"][ 

229 :_SCHEMA_SAMPLE_SIZE 

230 ] 

231 if len(journal_sample) >= _SCHEMA_SAMPLE_SIZE: 

232 has_hindex = any(r.get("h") is not None for r in journal_sample) 

233 has_cited = any(r.get("cb") is not None for r in journal_sample) 

234 if not has_hindex or not has_cited: 

235 raise SchemaDriftError( 

236 "OpenAlex snapshot appears to have renamed a required " 

237 "field: " 

238 f"h_index present in journal sample={has_hindex}, " 

239 f"cited_by_count present in journal sample={has_cited}. " 

240 "Refusing to overwrite existing data — please check " 

241 "https://docs.openalex.org/download-all-data/" 

242 "snapshot-data-format for schema changes." 

243 ) 

244 else: 

245 # The row-count floor above already refuses a collapsed 

246 # fetch. This branch only fires in unusual cases (truncated 

247 # test snapshot, snapshot with very few journal-typed 

248 # sources). Log at info so operators see the drift check 

249 # was bypassed — debug would be invisible at production 

250 # log levels. 

251 logger.info( 

252 "OpenAlex schema-drift check skipped: " 

253 f"only {len(journal_sample)} journal source(s) in sample " 

254 f"(< {_SCHEMA_SAMPLE_SIZE} required)" 

255 ) 

256 

257 output = data_dir / self.filename 

258 tmp = data_dir / f"{self.filename}.tmp" 

259 with gzip.open(tmp, "wt", encoding="utf-8") as f: 

260 json.dump({"s": sources}, f) 

261 atomic_replace(tmp, output) 

262 

263 elapsed = time.time() - start 

264 logger.info( 

265 f"OpenAlex sources: saved {len(sources):,} sources in {elapsed:.0f}s" 

266 ) 

267 return len(sources)