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.
- ontometer/__init__.py +42 -0
- ontometer/assessment.py +104 -0
- ontometer/cli/__init__.py +1 -0
- ontometer/cli/_entry.py +19 -0
- ontometer/cli/main.py +172 -0
- ontometer/corpus/README.md +260 -0
- ontometer/corpus/__init__.py +29 -0
- ontometer/corpus/fetchers/__init__.py +15 -0
- ontometer/corpus/fetchers/_base.py +718 -0
- ontometer/corpus/fetchers/lov.py +245 -0
- ontometer/corpus/fetchers/obo.py +162 -0
- ontometer/corpus/fetchers/okg.py +310 -0
- ontometer/corpus/fetchers/ontohub.py +179 -0
- ontometer/corpus/fetchers/ontoportal.py +350 -0
- ontometer/corpus/metadata.py +129 -0
- ontometer/corpus/network/README.md +207 -0
- ontometer/corpus/network/__init__.py +1 -0
- ontometer/corpus/network/construction.py +386 -0
- ontometer/corpus/paths.py +204 -0
- ontometer/corpus/pipeline/README.md +350 -0
- ontometer/corpus/pipeline/__init__.py +30 -0
- ontometer/corpus/pipeline/db.py +594 -0
- ontometer/corpus/pipeline/dedup.py +427 -0
- ontometer/corpus/pipeline/parse.py +2329 -0
- ontometer/evaluation.py +77 -0
- ontometer/metrics/__init__.py +15 -0
- ontometer/metrics/hierarchy.py +146 -0
- ontometer/metrics/oquare.py +237 -0
- ontometer/metrics/seeds.py +81 -0
- ontometer/model/__init__.py +10 -0
- ontometer/model/load.py +121 -0
- ontometer/model/quality_model.yaml +87 -0
- ontometer/model/scales.yaml +45 -0
- ontometer/onto/__init__.py +14 -0
- ontometer/onto/load.py +125 -0
- ontometer/onto/namespaces.py +93 -0
- ontometer/onto/obo.py +149 -0
- ontometer/onto/view.py +259 -0
- ontometer/report/__init__.py +5 -0
- ontometer/report/render.py +179 -0
- ontometer/review/__init__.py +14 -0
- ontometer/review/findings.py +49 -0
- ontometer/review/llm.py +187 -0
- ontometer/review/prompt.py +87 -0
- ontometer/review/run.py +103 -0
- ontometer/settings.py +62 -0
- ontometer-0.1.0.dist-info/METADATA +263 -0
- ontometer-0.1.0.dist-info/RECORD +52 -0
- ontometer-0.1.0.dist-info/WHEEL +4 -0
- ontometer-0.1.0.dist-info/entry_points.txt +2 -0
- ontometer-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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, ""
|