ontometer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. ontometer/__init__.py +42 -0
  2. ontometer/assessment.py +104 -0
  3. ontometer/cli/__init__.py +1 -0
  4. ontometer/cli/_entry.py +19 -0
  5. ontometer/cli/main.py +172 -0
  6. ontometer/corpus/README.md +260 -0
  7. ontometer/corpus/__init__.py +29 -0
  8. ontometer/corpus/fetchers/__init__.py +15 -0
  9. ontometer/corpus/fetchers/_base.py +718 -0
  10. ontometer/corpus/fetchers/lov.py +245 -0
  11. ontometer/corpus/fetchers/obo.py +162 -0
  12. ontometer/corpus/fetchers/okg.py +310 -0
  13. ontometer/corpus/fetchers/ontohub.py +179 -0
  14. ontometer/corpus/fetchers/ontoportal.py +350 -0
  15. ontometer/corpus/metadata.py +129 -0
  16. ontometer/corpus/network/README.md +207 -0
  17. ontometer/corpus/network/__init__.py +1 -0
  18. ontometer/corpus/network/construction.py +386 -0
  19. ontometer/corpus/paths.py +204 -0
  20. ontometer/corpus/pipeline/README.md +350 -0
  21. ontometer/corpus/pipeline/__init__.py +30 -0
  22. ontometer/corpus/pipeline/db.py +594 -0
  23. ontometer/corpus/pipeline/dedup.py +427 -0
  24. ontometer/corpus/pipeline/parse.py +2329 -0
  25. ontometer/evaluation.py +77 -0
  26. ontometer/metrics/__init__.py +15 -0
  27. ontometer/metrics/hierarchy.py +146 -0
  28. ontometer/metrics/oquare.py +237 -0
  29. ontometer/metrics/seeds.py +81 -0
  30. ontometer/model/__init__.py +10 -0
  31. ontometer/model/load.py +121 -0
  32. ontometer/model/quality_model.yaml +87 -0
  33. ontometer/model/scales.yaml +45 -0
  34. ontometer/onto/__init__.py +14 -0
  35. ontometer/onto/load.py +125 -0
  36. ontometer/onto/namespaces.py +93 -0
  37. ontometer/onto/obo.py +149 -0
  38. ontometer/onto/view.py +259 -0
  39. ontometer/report/__init__.py +5 -0
  40. ontometer/report/render.py +179 -0
  41. ontometer/review/__init__.py +14 -0
  42. ontometer/review/findings.py +49 -0
  43. ontometer/review/llm.py +187 -0
  44. ontometer/review/prompt.py +87 -0
  45. ontometer/review/run.py +103 -0
  46. ontometer/settings.py +62 -0
  47. ontometer-0.1.0.dist-info/METADATA +263 -0
  48. ontometer-0.1.0.dist-info/RECORD +52 -0
  49. ontometer-0.1.0.dist-info/WHEEL +4 -0
  50. ontometer-0.1.0.dist-info/entry_points.txt +2 -0
  51. ontometer-0.1.0.dist-info/licenses/LICENSE +201 -0
  52. ontometer-0.1.0.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,718 @@
1
+ """
2
+ OntoMeter fetchers — shared base infrastructure.
3
+
4
+ About:
5
+ Defines the canonical FetchRecord dataclass and all shared HTTP/IO helpers
6
+ used by every per-source fetcher module. Nothing in here is source-specific.
7
+
8
+ Normalized output schema:
9
+ source_id – globally unique identifier (e.g. "lov:http://...")
10
+ canonical_url – URL that was actually fetched
11
+ retrieved_file – absolute path of the saved file ("" on failure)
12
+ retrieval_date – ISO-8601 UTC timestamp of the fetch attempt
13
+ source_registry – human-readable registry name ("LOV", "OBO", ...)
14
+ source_registry_metadata – raw dict from the registry for this entry
15
+
16
+ Last Updated: 2026-07-14
17
+ Progress: Content-addressed immutable object store + full attempt tracking.
18
+ Version History:
19
+ - v1.1: Raw files stored in a content-addressed (SHA-256) immutable object
20
+ store. FetchRecord now records every attempt (status ok/fail) with
21
+ file_hash + error, so fetch-level failure rates are queryable.
22
+ - v1.0: Created as part of per-source fetcher refactor.
23
+ """
24
+
25
+ import hashlib
26
+ import json
27
+ import re
28
+ import sys
29
+ from dataclasses import asdict, dataclass, field
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from urllib.parse import urljoin, urlparse
33
+
34
+ import requests
35
+ import urllib3
36
+ from bs4 import BeautifulSoup
37
+ from requests.adapters import HTTPAdapter
38
+ from urllib3.util.retry import Retry
39
+
40
+ from ontometer.corpus.metadata import project_registry_metadata
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Global HTTP noise suppression
44
+ # ---------------------------------------------------------------------------
45
+
46
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
47
+
48
+ # Reconfigure stdout to UTF-8 on Windows
49
+ if sys.stdout.encoding != "utf-8":
50
+ try:
51
+ sys.stdout.reconfigure(encoding="utf-8")
52
+ except AttributeError:
53
+ pass
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Constants
57
+ # ---------------------------------------------------------------------------
58
+
59
+ TIMEOUT = 30
60
+
61
+ HEADERS = {
62
+ "Accept": ",".join(
63
+ [
64
+ "text/turtle",
65
+ "application/rdf+xml",
66
+ "application/ld+json",
67
+ "text/n3",
68
+ "application/n-triples",
69
+ "application/xml;q=0.9",
70
+ "*/*;q=0.1",
71
+ ]
72
+ ),
73
+ "User-Agent": "OntoMeterOntologyFetcher/1.0",
74
+ }
75
+
76
+ CONTENT_TYPE_EXTENSIONS: dict[str, str] = {
77
+ "text/turtle": ".ttl",
78
+ "application/x-turtle": ".ttl",
79
+ "application/rdf+xml": ".rdf",
80
+ "application/xml": ".xml",
81
+ "application/ld+json": ".jsonld",
82
+ "application/n-triples": ".nt",
83
+ "text/n3": ".n3",
84
+ "application/trig": ".trig",
85
+ }
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # FetchRecord — the normalized output schema
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ @dataclass
93
+ class FetchRecord:
94
+ """
95
+ Canonical record produced by every per-source fetcher for EVERY fetch
96
+ attempt — both successes and failures.
97
+
98
+ Recording failures (status="fail") as well as successes is what lets the
99
+ pipeline compute the fraction of each registry's ontologies actually
100
+ captured. A failed attempt (broken/abandoned ontology, dead URL, HTML page
101
+ instead of RDF) is part of the population, not noise to be discarded.
102
+
103
+ Fields
104
+ ------
105
+ source_id
106
+ Globally unique identifier for this entry, namespaced by registry.
107
+ Examples: "lov:http://xmlns.com/foaf/0.1/", "obo:go", "okg:Q123"
108
+ canonical_url
109
+ The URL that was fetched (or attempted).
110
+ retrieved_file
111
+ Absolute path (as str) of the saved object in the content-addressed
112
+ store. Empty string on failure.
113
+ retrieval_date
114
+ ISO-8601 UTC timestamp of the fetch attempt.
115
+ source_registry
116
+ Human-readable registry name: "LOV", "OBO", "BioPortal", "OKG", "Ontohub".
117
+ source_registry_metadata
118
+ The raw dict from the registry API for this entry (for provenance).
119
+ status
120
+ "ok" if a file was written/reused, "fail" if the download failed.
121
+ file_hash
122
+ SHA-256 (hex) of the raw file bytes — the object-store key. This is the
123
+ RAW-BYTES hash, distinct from the semantic class/property content hash
124
+ computed later during parsing. Empty string on failure.
125
+ error
126
+ Failure reason. Empty string on success.
127
+ source_version
128
+ Registry-declared version label for this entry (e.g. LOV "v0.99"), when
129
+ the source exposes version history. Empty string if unknown/unversioned.
130
+ This is what makes source_id version-unique and lets the pipeline keep
131
+ multiple versions of the same ontology as distinct rows.
132
+ source_version_date
133
+ Publication date of this version as an ISO-8601 string (e.g. LOV's
134
+ `issued`), when the source provides one. Empty string otherwise. Used to
135
+ order versions by recency (version *labels* are not reliably sortable),
136
+ so the pipeline can flag the most-recent version per ontology.
137
+ content_kind
138
+ Cheap heuristic classification of the downloaded content, computed by
139
+ `classify_content_kind()` at fetch time: "ontology_schema" (has
140
+ class/property definitions), "instance_data" (RDF with no schema
141
+ markers — a data/ABox dump), "catalog_metadata" (DCAT/void dataset
142
+ catalog description), "service_description" (SPARQL endpoint
143
+ description — never a real fetch, download_ontology rejects these
144
+ outright), or "" when not computed (registries like OBO/LOV/OntoPortal
145
+ already curate real ontologies, so this is mainly populated for
146
+ crawl-based sources like LOD Cloud where linked files are unvetted).
147
+ """
148
+
149
+ source_id: str
150
+ canonical_url: str
151
+ retrieved_file: str
152
+ retrieval_date: str
153
+ source_registry: str
154
+ source_registry_metadata: dict = field(default_factory=dict)
155
+ status: str = "ok"
156
+ file_hash: str = ""
157
+ error: str = ""
158
+ source_version: str = ""
159
+ source_version_date: str = ""
160
+ content_kind: str = ""
161
+
162
+ def __post_init__(self) -> None:
163
+ """Project registry metadata down to the fields the corpus work reads.
164
+
165
+ Applied here rather than at serialization time so the bulk never reaches
166
+ either artifact: the same record is written to `manifest.jsonl` and, via
167
+ `seed_from_manifest`, into `corpus_registry`. Stored verbatim it was the
168
+ single largest thing in the corpus and carried maintainers' contact
169
+ details; see `ontometer.corpus.metadata`.
170
+ """
171
+ self.source_registry_metadata = project_registry_metadata(self.source_registry_metadata)
172
+
173
+ def to_dict(self) -> dict:
174
+ return asdict(self)
175
+
176
+ @classmethod
177
+ def ok(
178
+ cls,
179
+ *,
180
+ source_id: str,
181
+ canonical_url: str,
182
+ retrieved_file: str,
183
+ retrieval_date: str,
184
+ source_registry: str,
185
+ source_registry_metadata: dict,
186
+ file_hash: str,
187
+ source_version: str = "",
188
+ source_version_date: str = "",
189
+ content_kind: str = "",
190
+ ) -> "FetchRecord":
191
+ """Build a successful-fetch record."""
192
+ return cls(
193
+ source_id=source_id,
194
+ canonical_url=canonical_url,
195
+ retrieved_file=retrieved_file,
196
+ retrieval_date=retrieval_date,
197
+ source_registry=source_registry,
198
+ source_registry_metadata=source_registry_metadata,
199
+ status="ok",
200
+ file_hash=file_hash,
201
+ error="",
202
+ source_version=source_version,
203
+ source_version_date=source_version_date,
204
+ content_kind=content_kind,
205
+ )
206
+
207
+ @classmethod
208
+ def failure(
209
+ cls,
210
+ *,
211
+ source_id: str,
212
+ canonical_url: str,
213
+ retrieval_date: str,
214
+ source_registry: str,
215
+ source_registry_metadata: dict,
216
+ error: str,
217
+ source_version: str = "",
218
+ source_version_date: str = "",
219
+ ) -> "FetchRecord":
220
+ """Build a failed-fetch record (no file on disk)."""
221
+ return cls(
222
+ source_id=source_id,
223
+ canonical_url=canonical_url,
224
+ retrieved_file="",
225
+ retrieval_date=retrieval_date,
226
+ source_registry=source_registry,
227
+ source_registry_metadata=source_registry_metadata,
228
+ status="fail",
229
+ file_hash="",
230
+ error=error,
231
+ source_version=source_version,
232
+ source_version_date=source_version_date,
233
+ )
234
+
235
+
236
+ def now_utc_iso() -> str:
237
+ """Return the current UTC time as an ISO-8601 string."""
238
+ return datetime.now(timezone.utc).isoformat()
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # HTTP session factory
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def make_session() -> requests.Session:
247
+ """Build a requests.Session with retry logic pre-configured."""
248
+ s = requests.Session()
249
+ retry = Retry(
250
+ total=3,
251
+ backoff_factor=1.5,
252
+ status_forcelist=[429, 500, 502, 503, 504],
253
+ allowed_methods=["GET"],
254
+ )
255
+ adapter = HTTPAdapter(max_retries=retry)
256
+ s.mount("http://", adapter)
257
+ s.mount("https://", adapter)
258
+ return s
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Filename helpers
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ def safe_filename(uri: str) -> str:
267
+ """
268
+ Produce a filesystem-safe name from a URI or arbitrary string.
269
+ Strips the scheme, joins netloc + path, then replaces unsafe chars.
270
+ Capped at 180 characters.
271
+ """
272
+ parsed = urlparse(uri)
273
+ if parsed.netloc:
274
+ name = (parsed.netloc + parsed.path).replace("/", "_")
275
+ else:
276
+ name = uri.replace("/", "_")
277
+ name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
278
+ return name[:180].strip("_")
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Content helpers
283
+ # ---------------------------------------------------------------------------
284
+
285
+
286
+ def extension_from_response(response: requests.Response, url: str) -> str:
287
+ """
288
+ Infer a file extension from the HTTP response.
289
+ Priority: Content-Type header → URL path suffix → ".rdf" fallback.
290
+ """
291
+ ct = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
292
+ if ct in CONTENT_TYPE_EXTENSIONS:
293
+ return CONTENT_TYPE_EXTENSIONS[ct]
294
+ suffix = Path(urlparse(url).path).suffix
295
+ if suffix and len(suffix) <= 10:
296
+ return suffix
297
+ return ".rdf"
298
+
299
+
300
+ def looks_like_html(content: bytes) -> bool:
301
+ """Return True if the first 1 000 bytes look like an HTML document."""
302
+ head = content[:1000].lower()
303
+ return b"<html" in head or b"<!doctype html" in head or b"<head" in head
304
+
305
+
306
+ def looks_like_feed(content: bytes) -> bool:
307
+ """
308
+ Return True if the content looks like an RSS or Atom feed rather than RDF.
309
+
310
+ RSS/Atom feeds share the .xml / .rdf MIME type with actual RDF, but are
311
+ fundamentally not ontology data. OKG homepages (WordPress sites) commonly
312
+ return an RSS feed when their URL is requested without specific Accept
313
+ headers, which causes the fetcher to save a feed with a .rdf extension.
314
+
315
+ Detection is based on the first 500 bytes only (fast, before full parse).
316
+ """
317
+ head = content[:500].lower()
318
+ return (
319
+ b"<rss " in head
320
+ or b"<feed " in head # Atom feeds
321
+ or b"<channel>" in head # RSS body marker
322
+ or (b"<rss" in head and b"version=" in head)
323
+ )
324
+
325
+
326
+ def looks_like_sparql_service_description(content: bytes) -> bool:
327
+ """
328
+ Return True if *content* is a SPARQL Service Description document
329
+ (describes a query endpoint's capabilities) rather than ontology data.
330
+
331
+ Confirmed live via LOD Cloud crawling: some dataset pages link an
332
+ auto-generated `sd:Service` description (endpoint URL, supported result
333
+ formats) instead of the actual vocabulary/data dump. These are valid RDF
334
+ but contain zero domain classes/properties, so they must not be counted
335
+ as a fetched ontology.
336
+ """
337
+ sample = content[:5000].decode("utf-8", errors="ignore")
338
+ if "sparql-service-description" not in sample and "sd:Service" not in sample:
339
+ return False
340
+ # A real ontology that merely references the SD vocabulary (rare) would
341
+ # still declare its own classes/properties; a pure endpoint description
342
+ # will not.
343
+ return not _SCHEMA_MARKER_RE.search(sample)
344
+
345
+
346
+ def zip_looks_like_source_repo(content: bytes) -> bool:
347
+ """
348
+ Return True if a ZIP archive's member listing looks like a GitHub
349
+ source-code repository dump (README/LICENSE/.git*) rather than a
350
+ compressed ontology/data file.
351
+
352
+ Confirmed live via LOD Cloud crawling: some dataset "download" links
353
+ actually point at a GitHub repo archive (e.g. a linguistic treebank or an
354
+ ontology's dev repo including CI config, issue templates, etc.), which
355
+ got silently saved and mislabeled with a `.rdf`/`.owl` extension inferred
356
+ from the URL/Content-Type — the archive itself is not ontology content.
357
+ """
358
+ import zipfile
359
+ from io import BytesIO
360
+
361
+ try:
362
+ with zipfile.ZipFile(BytesIO(content)) as zf:
363
+ names = [n.lower() for n in zf.namelist()]
364
+ except Exception:
365
+ return False
366
+
367
+ if not names:
368
+ return False
369
+
370
+ repo_markers = (
371
+ "readme",
372
+ "license",
373
+ ".git/",
374
+ ".github/",
375
+ "contributing",
376
+ "package.json",
377
+ ".gitignore",
378
+ "setup.py",
379
+ "makefile",
380
+ )
381
+ has_repo_marker = any(any(m in n for m in repo_markers) for n in names)
382
+ rdf_like = sum(
383
+ 1 for n in names if n.endswith((".owl", ".rdf", ".ttl", ".nt", ".n3", ".jsonld", ".xml"))
384
+ )
385
+ return has_repo_marker and rdf_like < max(1, len(names) // 10)
386
+
387
+
388
+ _SCHEMA_MARKER_RE = re.compile(
389
+ r"\b(?:owl|rdfs):(?:Class|ObjectProperty|DatatypeProperty|AnnotationProperty)\b"
390
+ r"|<owl:Class\b|<rdfs:Class\b"
391
+ )
392
+ _CATALOG_MARKER_RE = re.compile(r"\bdcat:Dataset\b|dcatap:|dcterms:accrualPeriodicity")
393
+
394
+
395
+ def classify_content_kind(content: bytes) -> str:
396
+ """
397
+ Cheap heuristic classification of downloaded RDF content, based on a
398
+ sample of the first 200KB (fast even for multi-hundred-MB dumps).
399
+
400
+ Returns one of: "empty", "service_description", "ontology_schema",
401
+ "catalog_metadata", "instance_data", "unknown". This does not require a
402
+ full RDF parse — it is a fast first-pass filter so that non-ontology
403
+ content (bare data dumps, DCAT catalogs) fetched incidentally by
404
+ crawl-based sources like LOD Cloud can be distinguished from real
405
+ ontologies without waiting for the parse stage.
406
+ """
407
+ if not content or not content.strip():
408
+ return "empty"
409
+ sample = content[:200_000]
410
+ try:
411
+ text = sample.decode("utf-8", errors="ignore")
412
+ except Exception:
413
+ return "unknown"
414
+ if not text.strip():
415
+ return "empty"
416
+ if _SCHEMA_MARKER_RE.search(text):
417
+ return "ontology_schema"
418
+ if "sparql-service-description" in text or "sd:Service" in text:
419
+ return "service_description"
420
+ if _CATALOG_MARKER_RE.search(text):
421
+ return "catalog_metadata"
422
+ return "instance_data"
423
+
424
+
425
+ def extract_ontology_links_from_html(html_text: str, base_url: str) -> list[str]:
426
+ """
427
+ Parse HTML and return candidate direct links to ontology files,
428
+ ordered by heuristic priority (highest first).
429
+ """
430
+ soup = BeautifulSoup(html_text, "html.parser")
431
+ candidates: list[tuple[str, int]] = []
432
+
433
+ # 1. <link> tags -- require the "type" attribute to actually look like an
434
+ # RDF/ontology media type. A bare rel="alternate"/"meta" is NOT enough on
435
+ # its own: e.g. GitHub repo pages carry
436
+ # <link rel="alternate icon" type="image/png" href=".../favicon.png">
437
+ # for the site favicon, which satisfies "alternate" in rel regardless of
438
+ # content -- confirmed live, this exact favicon got captured as "the
439
+ # ontology" for 239 distinct OKG source_ids (~half of everything OKG had
440
+ # captured), all sharing one identical 958-byte file.
441
+ for link in soup.find_all("link"):
442
+ type_attr = link.get("type", "").lower()
443
+ href = link.get("href")
444
+ if href and any(
445
+ marker in type_attr
446
+ for marker in ("rdf", "turtle", "ontology", "owl", "n-triples", "ld+json")
447
+ ):
448
+ candidates.append((urljoin(base_url, href), 3))
449
+
450
+ # 2. <a> tags scored by extension and link text
451
+ for a in soup.find_all("a"):
452
+ href = a.get("href")
453
+ if not href:
454
+ continue
455
+ full_url = urljoin(base_url, href)
456
+ text = a.get_text().strip().lower()
457
+ path_lower = urlparse(full_url).path.lower()
458
+
459
+ priority = 0
460
+ if path_lower.endswith(".ttl"):
461
+ priority = 5
462
+ elif path_lower.endswith((".owl", ".rdf")):
463
+ priority = 4
464
+ elif path_lower.endswith((".n3", ".nt", ".jsonld")):
465
+ priority = 4
466
+ elif path_lower.endswith(".xml") and not path_lower.endswith(".html"):
467
+ priority = 2
468
+
469
+ if any(kw in text for kw in ("download", "rdf", "turtle", "owl", "ttl")):
470
+ priority = max(priority, 3)
471
+
472
+ if priority > 0:
473
+ candidates.append((full_url, priority))
474
+
475
+ candidates.sort(key=lambda x: x[1], reverse=True)
476
+ seen: set[str] = set()
477
+ result: list[str] = []
478
+ for url, _ in candidates:
479
+ if url not in seen:
480
+ seen.add(url)
481
+ result.append(url)
482
+ return result
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Atomic file write
487
+ # ---------------------------------------------------------------------------
488
+
489
+
490
+ def atomic_write(dest: Path, content: bytes) -> None:
491
+ """Write *content* to *dest* atomically via a .tmp sibling file."""
492
+ tmp = dest.with_suffix(dest.suffix + ".tmp")
493
+ try:
494
+ tmp.write_bytes(content)
495
+ tmp.replace(dest)
496
+ except Exception:
497
+ if tmp.exists():
498
+ tmp.unlink(missing_ok=True)
499
+ raise
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Content-addressed object store
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ def content_hash(content: bytes) -> str:
508
+ """Return the SHA-256 hex digest of raw file bytes (the object-store key)."""
509
+ return hashlib.sha256(content).hexdigest()
510
+
511
+
512
+ def store_object(objects_dir: Path, content: bytes, ext: str) -> tuple[Path, str]:
513
+ """
514
+ Store *content* immutably in a content-addressed object store.
515
+
516
+ Files are keyed by the SHA-256 of their bytes and sharded by the first two
517
+ hex chars to keep directories small:
518
+
519
+ <objects_dir>/<hh>/<full-hash><ext>
520
+
521
+ The store is write-once / immutable: if an object with the same content
522
+ hash already exists it is reused as-is (this is how byte-identical files
523
+ fetched from different registries or URLs are de-duplicated at the storage
524
+ layer). The extension of the first writer wins.
525
+
526
+ Returns
527
+ -------
528
+ (path, hash)
529
+ Absolute path of the stored object and its content hash.
530
+ """
531
+ h = content_hash(content)
532
+ shard = objects_dir / h[:2]
533
+ shard.mkdir(parents=True, exist_ok=True)
534
+
535
+ # Reuse an existing object with this hash regardless of extension.
536
+ for existing in shard.glob(f"{h}.*"):
537
+ if existing.suffix != ".tmp":
538
+ return existing, h
539
+ exact = shard / h # extension-less variant
540
+ if exact.exists():
541
+ return exact, h
542
+
543
+ dest = shard / f"{h}{ext}"
544
+ atomic_write(dest, content)
545
+ return dest, h
546
+
547
+
548
+ # ---------------------------------------------------------------------------
549
+ # Failure log
550
+ # ---------------------------------------------------------------------------
551
+
552
+
553
+ def log_failure(fail_log: Path, source: str, url: str, error: str) -> None:
554
+ """Append a tab-delimited failure line to the failure log."""
555
+ with fail_log.open("a", encoding="utf-8") as f:
556
+ f.write(f"{source}\t{url}\t{error}\n")
557
+
558
+
559
+ # ---------------------------------------------------------------------------
560
+ # Manifest writer (incremental)
561
+ # ---------------------------------------------------------------------------
562
+
563
+
564
+ def append_record(record: FetchRecord, manifest_path: Path) -> None:
565
+ """
566
+ Append a single FetchRecord to manifest.jsonl immediately.
567
+
568
+ Fetchers call this right after producing each record (not once at the end
569
+ of the whole run) so that a crash or kill mid-run loses at most the one
570
+ attempt in flight, not every record collected so far. This replaced a
571
+ batch-at-end model that lost an entire run's provenance (e.g. a full
572
+ BioPortal pass) when the process died before its single end-of-run write.
573
+ """
574
+ with manifest_path.open("a", encoding="utf-8") as f:
575
+ f.write(json.dumps(record.to_dict(), ensure_ascii=False, sort_keys=True) + "\n")
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # Core fetch with SSL fallback
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ def fetch_url(
584
+ session: requests.Session,
585
+ url: str,
586
+ extra_headers: dict | None = None,
587
+ ) -> requests.Response:
588
+ """
589
+ GET *url* with the standard ontology headers plus any extra_headers.
590
+ Retries without SSL verification only if the first attempt raises an
591
+ SSLError (a broken/incomplete cert chain — confirmed for real on
592
+ TechnoPortal). Raises on HTTP errors (response.raise_for_status()).
593
+
594
+ Connection/read timeouts, DNS failures, and other non-SSL errors are NOT
595
+ retried here: they will fail identically the second time, so retrying
596
+ just doubles the wait. Confirmed live: LOD Cloud crawling hits dead hosts
597
+ (e.g. ja.dbpedia.org) where a blind retry-on-any-exception turned one
598
+ 30s connect timeout into ~60s per candidate, across many candidates.
599
+ """
600
+ hdrs = {**HEADERS, **(extra_headers or {})}
601
+
602
+ # (connect_timeout, read_timeout): fail fast on unreachable hosts instead
603
+ # of waiting the full read timeout just to find out the TCP handshake
604
+ # never completed.
605
+ timeout = (10, TIMEOUT)
606
+
607
+ def _get(verify: bool) -> requests.Response:
608
+ return session.get(
609
+ url,
610
+ headers=hdrs,
611
+ timeout=timeout,
612
+ allow_redirects=True,
613
+ verify=verify,
614
+ )
615
+
616
+ try:
617
+ r = _get(verify=True)
618
+ except requests.exceptions.SSLError:
619
+ print(" [ssl retry]")
620
+ r = _get(verify=False)
621
+
622
+ r.raise_for_status()
623
+ return r
624
+
625
+
626
+ # ---------------------------------------------------------------------------
627
+ # High-level download helper
628
+ # ---------------------------------------------------------------------------
629
+
630
+
631
+ def download_ontology(
632
+ session: requests.Session,
633
+ url: str,
634
+ objects_dir: Path,
635
+ source: str,
636
+ fail_log: Path,
637
+ parse_html_for_links: bool = False,
638
+ reject_non_schema: bool = False,
639
+ ) -> tuple[bool, str, str, str, str]:
640
+ """
641
+ Fetch *url* and store the result in the content-addressed object store
642
+ rooted at *objects_dir* (keyed by the SHA-256 of the raw bytes, immutable).
643
+
644
+ Parameters
645
+ ----------
646
+ reject_non_schema
647
+ If True, also reject content classified as "instance_data" or
648
+ "catalog_metadata" by `classify_content_kind()` — i.e. only accept
649
+ content that actually defines classes/properties. Off by default:
650
+ curated registries (OBO, LOV, OntoPortal) already only list real
651
+ ontologies, so this heuristic would be redundant noise there. Turn
652
+ it on for crawl-based sources (LOD Cloud) where linked files are
653
+ unvetted and frequently turn out to be bare data dumps.
654
+
655
+ Returns
656
+ -------
657
+ (success, saved_path, file_hash, error_message, content_kind)
658
+ success – True if a file was written or already existed
659
+ saved_path – absolute path as str; empty string on failure
660
+ file_hash – SHA-256 hex of the stored bytes; empty on failure
661
+ error_message – non-empty string on failure, empty string on success
662
+ content_kind – classify_content_kind() result; "" on failure
663
+ """
664
+ try:
665
+ r = fetch_url(session, url)
666
+ ext = extension_from_response(r, str(r.url))
667
+
668
+ if looks_like_html(r.content):
669
+ if parse_html_for_links:
670
+ print(f" [html page] Parsing links from: {url}")
671
+ candidates = extract_ontology_links_from_html(r.text, str(r.url))
672
+ for cand in candidates:
673
+ print(f" [try candidate] {cand}")
674
+ ok, path, fhash, err, kind = download_ontology(
675
+ session,
676
+ cand,
677
+ objects_dir,
678
+ source,
679
+ fail_log,
680
+ parse_html_for_links=False,
681
+ reject_non_schema=reject_non_schema,
682
+ )
683
+ if ok:
684
+ return ok, path, fhash, err, kind
685
+ raise RuntimeError("received HTML instead of RDF")
686
+
687
+ if looks_like_feed(r.content):
688
+ raise RuntimeError(
689
+ "received RSS/Atom feed instead of RDF "
690
+ "(URL returned a syndication feed, not an ontology)"
691
+ )
692
+
693
+ if not r.content or not r.content.strip():
694
+ raise RuntimeError("received empty content")
695
+
696
+ if looks_like_sparql_service_description(r.content):
697
+ raise RuntimeError("received a SPARQL service/endpoint description, not ontology data")
698
+
699
+ if r.content.startswith(b"PK\x03\x04") and zip_looks_like_source_repo(r.content):
700
+ raise RuntimeError(
701
+ "received a zip archive that looks like a source-code repository "
702
+ "(README/LICENSE/.git*), not an ontology/data dump"
703
+ )
704
+
705
+ content_kind = classify_content_kind(r.content)
706
+ if reject_non_schema and content_kind in ("instance_data", "catalog_metadata"):
707
+ raise RuntimeError(f"content classified as '{content_kind}', not an ontology schema")
708
+
709
+ dest, file_hash = store_object(objects_dir, r.content, ext)
710
+ size_kb = len(r.content) // 1024
711
+ print(f" [ok] {source}: {dest.name} ({size_kb} KB) [{content_kind}]")
712
+ return True, str(dest), file_hash, "", content_kind
713
+
714
+ except Exception as exc:
715
+ msg = str(exc)
716
+ print(f" [err] {source}: {url} -> {msg}")
717
+ log_failure(fail_log, source, url, msg)
718
+ return False, "", "", msg, ""