Files

162 lines
4.8 KiB
Python

"""Locator parsing and normalization helpers for research lookup."""
from __future__ import annotations
import hashlib
import json
import re
import urllib.parse
from typing import Any, Dict, Optional
ARXIV_ID_RE = re.compile(r"(?:arxiv:|arxiv\.org/(?:abs|pdf)/)?(?P<id>\d{4}\.\d{4,5}(?:v\d+)?)", re.IGNORECASE)
DOI_RE = re.compile(r"(?:https?://(?:dx\.)?doi\.org/)?(?P<doi>10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.IGNORECASE)
GITHUB_URL_RE = re.compile(
r"^https?://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s#]+)(?:/(?P<rest>.*))?$",
re.IGNORECASE,
)
HTTP_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
URL_RE = re.compile(r"https?://[^\s<>\]\"')]+", re.IGNORECASE)
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-")
return slug[:48] or "source"
def stable_digest(payload: Dict[str, Any]) -> str:
text = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def stable_filename(kind: str, slug: str, digest: str, suffix: str = "json") -> str:
return f"{kind}__{slug}__{digest[:12]}.{suffix}"
def ensure_http_url(value: str) -> str:
text = str(value or "").strip()
if not text:
return ""
if HTTP_URL_RE.match(text):
return text
if text.lower().startswith("doi:"):
return f"https://doi.org/{text[4:].strip()}"
return text
def canonicalize_url(value: str) -> str:
text = ensure_http_url(value)
if not text:
return ""
parsed = urllib.parse.urlsplit(text)
path = parsed.path or "/"
return urllib.parse.urlunsplit(
(
parsed.scheme.lower(),
parsed.netloc.lower(),
path.rstrip("/") or "/",
parsed.query,
"",
)
)
def extract_urls(text: str) -> list[str]:
found: list[str] = []
for match in URL_RE.finditer(str(text or "")):
url = match.group(0).rstrip(".,);]")
if url not in found:
found.append(url)
return found
def parse_arxiv_locator(locator: str) -> Optional[Dict[str, Any]]:
text = str(locator or "").strip()
match = ARXIV_ID_RE.search(text)
if not match:
return None
arxiv_id = match.group("id")
locator_type = "arxiv_url" if "arxiv.org" in text.lower() else "arxiv_id"
return {
"provider_type": "arxiv",
"source_type": "paper",
"locator_type": locator_type,
"raw_locator": text,
"normalized_id": f"arxiv:{arxiv_id.lower()}",
"identifier": arxiv_id,
"arxiv_id": arxiv_id,
"url": f"https://arxiv.org/abs/{arxiv_id}",
}
def parse_doi_locator(locator: str) -> Optional[Dict[str, Any]]:
text = str(locator or "").strip()
match = DOI_RE.search(text)
if not match:
return None
doi = match.group("doi").lower()
locator_type = "doi_url" if "doi.org" in text.lower() else "doi"
return {
"provider_type": "doi",
"source_type": "paper",
"locator_type": locator_type,
"raw_locator": text,
"normalized_id": f"doi:{doi}",
"identifier": doi,
"doi": doi,
"url": f"https://doi.org/{doi}",
}
def parse_github_repo_locator(locator: str) -> Optional[Dict[str, Any]]:
text = canonicalize_url(locator)
match = GITHUB_URL_RE.match(text)
if not match:
return None
owner = match.group("owner")
repo = (match.group("repo") or "").removesuffix(".git")
rest = match.group("rest") or ""
source_file = ""
if rest.startswith("blob/"):
parts = rest.split("/", 3)
if len(parts) == 4:
source_file = parts[3]
return {
"provider_type": "github",
"source_type": "repo",
"locator_type": "github_repo_url",
"raw_locator": str(locator or "").strip(),
"normalized_id": f"github:{owner.lower()}/{repo.lower()}",
"identifier": f"{owner}/{repo}",
"repo_full_name": f"{owner}/{repo}",
"owner": owner,
"repo": repo,
"source_file": source_file,
"url": f"https://github.com/{owner}/{repo}",
}
def parse_generic_url(locator: str) -> Optional[Dict[str, Any]]:
text = canonicalize_url(locator)
if not HTTP_URL_RE.match(text):
return None
parsed = urllib.parse.urlsplit(text)
return {
"provider_type": "url",
"source_type": "web",
"locator_type": "url",
"raw_locator": str(locator or "").strip(),
"normalized_id": f"url:{text}",
"identifier": text,
"host": parsed.netloc.lower(),
"url": text,
}
def detect_locator(locator: str) -> Optional[Dict[str, Any]]:
for parser in (parse_github_repo_locator, parse_arxiv_locator, parse_doi_locator, parse_generic_url):
parsed = parser(locator)
if parsed:
return parsed
return None