Spaces:
Running
Running
Upload 52 files
Browse files- .env.example +6 -0
- DEPLOYMENT-LINKS.md +67 -67
- README-DEPLOY.md +6 -0
- README.md +71 -66
- RELEASE_1.8.3.md +62 -0
- ResearchBee_LibGuides.html +0 -0
- SHA256SUMS.json +73 -62
- app.py +0 -0
- journal_links_curated.json +1 -1
- openalex.py +307 -307
- re3data.py +589 -0
- repositories.py +708 -708
- scimago_index.py +324 -0
- trusted_links.py +283 -283
.env.example
CHANGED
|
@@ -27,3 +27,9 @@ USAGE_PUSH_ENABLED=false
|
|
| 27 |
# Optional model configuration
|
| 28 |
ASJC_LLM_MODEL=gpt-4.1-nano
|
| 29 |
ANTHROPIC_API_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# Optional model configuration
|
| 28 |
ASJC_LLM_MODEL=gpt-4.1-nano
|
| 29 |
ANTHROPIC_API_KEY=
|
| 30 |
+
|
| 31 |
+
# Optional re3data.org supplement for the data-repository finder (v1.8.3)
|
| 32 |
+
# Set to false to hide the "More repositories from re3data.org" list.
|
| 33 |
+
RE3DATA_ENABLED=true
|
| 34 |
+
# Upper bound on the re3data lookup; it runs alongside the model call.
|
| 35 |
+
RE3DATA_TIMEOUT_SECONDS=12
|
DEPLOYMENT-LINKS.md
CHANGED
|
@@ -1,67 +1,67 @@
|
|
| 1 |
-
# ResearchBee hyperlink update — 20 September 2026
|
| 2 |
-
|
| 3 |
-
## Upload the backend first
|
| 4 |
-
|
| 5 |
-
1. Keep a backup of your current Hugging Face Space and LibGuides HTML.
|
| 6 |
-
2. Extract `ResearchBee_HuggingFace_Backend.zip`. Upload **the contents of its `researchbee` folder** to the root of your existing Docker Space. Replace matching files. Do not upload the ZIP itself as the application.
|
| 7 |
-
3. Include the complete `tests/` directory, `trusted_links.py`, `journal_links_curated.json`, `journals.db`, `scimagojr_2025.csv`, requirements files, Dockerfile and all other supplied application files. The Docker build executes the Python tests. There is no new paid service or dependency.
|
| 8 |
-
4. Keep your existing Space secrets and configuration. Credentials belong only in Hugging Face Secrets; do not put them in the frontend. The existing OpenAlex integration and optional `OPENALEX_API_KEY` are reused.
|
| 9 |
-
5. Wait for the build. If the existing pinned packages cannot be resolved or a test fails, do not bypass the build gate. Check the build log before publishing the frontend. The preparation environment could not install these dependencies or run Docker.
|
| 10 |
-
6. Check `/api/health`, then try a journal search and the new “Find author guidelines” action. A publisher may reject automated access; an unavailable-guidance result with the retained homepage is expected in that case.
|
| 11 |
-
|
| 12 |
-
## Replace the LibGuides frontend
|
| 13 |
-
|
| 14 |
-
1. Open `ResearchBee_LibGuides.html` in a text editor and copy its complete contents into the existing LibGuides HTML/source block, replacing the old fragment.
|
| 15 |
-
2. The file remains one self-contained HTML fragment with embedded CSS and JavaScript. Do not add a second copy alongside the old one.
|
| 16 |
-
3. `RB_API_BASE` retains `https://nikeshn-researchbee.hf.space`. Change only that setting if your Space address changes.
|
| 17 |
-
4. Preview desktop and mobile layouts in LibGuides before publishing. Test English and Arabic, journal details, additional results, comparisons, article sharing, repository selection, and reset while an author-guidance request is pending.
|
| 18 |
-
5. Revert the HTML and backend together from your backup if necessary.
|
| 19 |
-
|
| 20 |
-
## What changed
|
| 21 |
-
|
| 22 |
-
- Journal titles and prominent website/author-guidance actions in detailed cards, additional results and comparison views. Existing branding, mascot, institutional text, copyright, language options, feedback destination and workflows were retained from the separately supplied frontend.
|
| 23 |
-
- Canonical database identities and ISSNs gate homepage metadata. OpenAlex responses must match the requested ISSN; a title search must match the complete normalized title. No LLM-supplied URLs are used by the new link controls.
|
| 24 |
-
- New `POST /api/journal-links` accepts a canonical Scopus ID and/or ISSN, never an arbitrary URL. Publisher-page discovery runs only when the user clicks. It follows actual HTML anchors on an identity-matched publisher page and checks the discovered destination.
|
| 25 |
-
- Official publisher domains must match the canonical publisher. HTTPS only, public DNS addresses, pinned connection IP with original TLS verification/Host, manual redirect validation, at most three requests per page, two pages per discovery, 512 KB per page, socket/page/overall discovery limits and four concurrent discovery operations.
|
| 26 |
-
- Checked guidance is cached for 24 hours; unsuccessful guidance and metadata lookups for one hour. Caches are bounded to 512 records per process and reset on Space restart. Concurrent clicks for the same journal share the lookup. Publisher content can change after a check.
|
| 27 |
-
- Evidence labels distinguish curated, checked, metadata-only and unavailable. Provider update/retrieval dates and curated review dates retain their meaning. A lookup attempt is not called a review. “Checked” means the official page/link was retrieved successfully, not that every instruction was reviewed by library staff.
|
| 28 |
-
- Guidance is labelled journal-specific only when the checked page heading and journal path support that classification. Other discovered instructions are explicitly general publisher guidance.
|
| 29 |
-
- Category quartiles are read from the supplied CSV using the matching SCImago source ID plus overlapping canonical ISSNs. The metric year is read from the CSV's `Total Docs. (2025)` header. Each category retains its own quartile; the best journal quartile is not substituted.
|
| 30 |
-
- Category rankings use exact, unambiguous category-code matches from the supplied taxonomy; quartile badges use the journal's SCImago profile. Missing/ambiguous mappings show “Not available”. Scopus source links use Scopus IDs independently. No CiteScore quartiles are inferred or added.
|
| 31 |
-
- Repository names and visit buttons use the existing curated registry. Primary/secondary deposit choices receive URLs from their resolved registry IDs, ignoring model-generated URLs. Existing registry source and review information is retained.
|
| 32 |
-
- Late discovery responses cannot change results after reset or a newer search. Request failures preserve the homepage and allow a retry.
|
| 33 |
-
|
| 34 |
-
## Evidence limitations and maintenance
|
| 35 |
-
|
| 36 |
-
There was no curated author-guidelines catalogue in the upload. `journal_links_curated.json` therefore starts empty; no remembered or guessed publisher paths have been added. Staff can add records keyed by canonical Scopus source ID with `issns`, `url`, `source`, `scope` (`journal-specific` or `general-publisher`), and an actual `reviewed_at` date. The ISSNs and official publisher domains are validated. Do not populate dates without a real review. Extend the publisher-domain allowlist only after verifying publisher ownership.
|
| 37 |
-
|
| 38 |
-
The supplied taxonomy has duplicate names under different codes, and some CSV categories have no code match. Those category names remain visible with their original quartile and profile link, but their category-ranking link says “Not available”. This is deliberate: resolving them requires an authoritative SCImago category crosswalk. An attempt to retrieve the live SCImago rankings page was blocked with HTTP 403 in this environment.
|
| 39 |
-
|
| 40 |
-
Some publishers require JavaScript, cookies or authentication, use another platform/domain, omit a discoverable author link, or block automated requests. The resolver does not guess a path or bypass access controls. It retains the metadata homepage and reports unavailable guidance. A homepage labelled metadata-only has not been tested for reachability.
|
| 41 |
-
|
| 42 |
-
## Validation performed
|
| 43 |
-
|
| 44 |
-
- 17 offline Python link-resolver tests passed using the actual bundled database and CSV plus mocked upstream responses: canonical identity conflicts, metadata identity, source dates, missing evidence, category-specific quartiles, separate identifiers, real relative-anchor extraction, general guidance labels, successful/negative/expired/bounded caches, concurrent duplicate requests, failed requests, unsafe URLs/private DNS/redirects, and curated repository selection.
|
| 45 |
-
- 12 Node VM frontend behavior checks passed against the actual embedded functions: safe URL rendering, clickable titles, evidence labels, distinct category quartiles, reset/session invalidation, successful result updates and failed-request behavior.
|
| 46 |
-
- 7 existing dependency-free frontend/repository regression checks passed.
|
| 47 |
-
- Python compilation and embedded JavaScript syntax checks passed.
|
| 48 |
-
- Full original tests directory retained. Added two FastAPI endpoint integration tests for identity-only input and rate limiting; these are included in the Docker pytest gate but could not be executed here.
|
| 49 |
-
|
| 50 |
-
## Not verified here
|
| 51 |
-
|
| 52 |
-
The environment could not install the pinned Python requirements. Therefore the full FastAPI/pytest suite, API server startup and Docker build were not run. Playwright was present but no browser executable was installed, so no visual/browser or real LibGuides test was completed. Node VM checks are not browser tests. Live OpenAlex, publisher discovery, credential-dependent services and production deployment were not verified. The release has not been deployed.
|
| 53 |
-
|
| 54 |
-
Reproduce checks in a configured development environment:
|
| 55 |
-
|
| 56 |
-
```bash
|
| 57 |
-
python -m pip install -r requirements-dev.txt
|
| 58 |
-
python -m compileall -q .
|
| 59 |
-
python -m pytest -q
|
| 60 |
-
node tests/test_frontend_links.cjs
|
| 61 |
-
```
|
| 62 |
-
|
| 63 |
-
The standalone offline link tests need only the Python standard library:
|
| 64 |
-
|
| 65 |
-
```bash
|
| 66 |
-
python -m unittest discover -s tests -p test_trusted_links.py -v
|
| 67 |
-
```
|
|
|
|
| 1 |
+
# ResearchBee hyperlink update — 20 September 2026
|
| 2 |
+
|
| 3 |
+
## Upload the backend first
|
| 4 |
+
|
| 5 |
+
1. Keep a backup of your current Hugging Face Space and LibGuides HTML.
|
| 6 |
+
2. Extract `ResearchBee_HuggingFace_Backend.zip`. Upload **the contents of its `researchbee` folder** to the root of your existing Docker Space. Replace matching files. Do not upload the ZIP itself as the application.
|
| 7 |
+
3. Include the complete `tests/` directory, `trusted_links.py`, `journal_links_curated.json`, `journals.db`, `scimagojr_2025.csv`, requirements files, Dockerfile and all other supplied application files. The Docker build executes the Python tests. There is no new paid service or dependency.
|
| 8 |
+
4. Keep your existing Space secrets and configuration. Credentials belong only in Hugging Face Secrets; do not put them in the frontend. The existing OpenAlex integration and optional `OPENALEX_API_KEY` are reused.
|
| 9 |
+
5. Wait for the build. If the existing pinned packages cannot be resolved or a test fails, do not bypass the build gate. Check the build log before publishing the frontend. The preparation environment could not install these dependencies or run Docker.
|
| 10 |
+
6. Check `/api/health`, then try a journal search and the new “Find author guidelines” action. A publisher may reject automated access; an unavailable-guidance result with the retained homepage is expected in that case.
|
| 11 |
+
|
| 12 |
+
## Replace the LibGuides frontend
|
| 13 |
+
|
| 14 |
+
1. Open `ResearchBee_LibGuides.html` in a text editor and copy its complete contents into the existing LibGuides HTML/source block, replacing the old fragment.
|
| 15 |
+
2. The file remains one self-contained HTML fragment with embedded CSS and JavaScript. Do not add a second copy alongside the old one.
|
| 16 |
+
3. `RB_API_BASE` retains `https://nikeshn-researchbee.hf.space`. Change only that setting if your Space address changes.
|
| 17 |
+
4. Preview desktop and mobile layouts in LibGuides before publishing. Test English and Arabic, journal details, additional results, comparisons, article sharing, repository selection, and reset while an author-guidance request is pending.
|
| 18 |
+
5. Revert the HTML and backend together from your backup if necessary.
|
| 19 |
+
|
| 20 |
+
## What changed
|
| 21 |
+
|
| 22 |
+
- Journal titles and prominent website/author-guidance actions in detailed cards, additional results and comparison views. Existing branding, mascot, institutional text, copyright, language options, feedback destination and workflows were retained from the separately supplied frontend.
|
| 23 |
+
- Canonical database identities and ISSNs gate homepage metadata. OpenAlex responses must match the requested ISSN; a title search must match the complete normalized title. No LLM-supplied URLs are used by the new link controls.
|
| 24 |
+
- New `POST /api/journal-links` accepts a canonical Scopus ID and/or ISSN, never an arbitrary URL. Publisher-page discovery runs only when the user clicks. It follows actual HTML anchors on an identity-matched publisher page and checks the discovered destination.
|
| 25 |
+
- Official publisher domains must match the canonical publisher. HTTPS only, public DNS addresses, pinned connection IP with original TLS verification/Host, manual redirect validation, at most three requests per page, two pages per discovery, 512 KB per page, socket/page/overall discovery limits and four concurrent discovery operations.
|
| 26 |
+
- Checked guidance is cached for 24 hours; unsuccessful guidance and metadata lookups for one hour. Caches are bounded to 512 records per process and reset on Space restart. Concurrent clicks for the same journal share the lookup. Publisher content can change after a check.
|
| 27 |
+
- Evidence labels distinguish curated, checked, metadata-only and unavailable. Provider update/retrieval dates and curated review dates retain their meaning. A lookup attempt is not called a review. “Checked” means the official page/link was retrieved successfully, not that every instruction was reviewed by library staff.
|
| 28 |
+
- Guidance is labelled journal-specific only when the checked page heading and journal path support that classification. Other discovered instructions are explicitly general publisher guidance.
|
| 29 |
+
- Category quartiles are read from the supplied CSV using the matching SCImago source ID plus overlapping canonical ISSNs. The metric year is read from the CSV's `Total Docs. (2025)` header. Each category retains its own quartile; the best journal quartile is not substituted.
|
| 30 |
+
- Category rankings use exact, unambiguous category-code matches from the supplied taxonomy; quartile badges use the journal's SCImago profile. Missing/ambiguous mappings show “Not available”. Scopus source links use Scopus IDs independently. No CiteScore quartiles are inferred or added.
|
| 31 |
+
- Repository names and visit buttons use the existing curated registry. Primary/secondary deposit choices receive URLs from their resolved registry IDs, ignoring model-generated URLs. Existing registry source and review information is retained.
|
| 32 |
+
- Late discovery responses cannot change results after reset or a newer search. Request failures preserve the homepage and allow a retry.
|
| 33 |
+
|
| 34 |
+
## Evidence limitations and maintenance
|
| 35 |
+
|
| 36 |
+
There was no curated author-guidelines catalogue in the upload. `journal_links_curated.json` therefore starts empty; no remembered or guessed publisher paths have been added. Staff can add records keyed by canonical Scopus source ID with `issns`, `url`, `source`, `scope` (`journal-specific` or `general-publisher`), and an actual `reviewed_at` date. The ISSNs and official publisher domains are validated. Do not populate dates without a real review. Extend the publisher-domain allowlist only after verifying publisher ownership.
|
| 37 |
+
|
| 38 |
+
The supplied taxonomy has duplicate names under different codes, and some CSV categories have no code match. Those category names remain visible with their original quartile and profile link, but their category-ranking link says “Not available”. This is deliberate: resolving them requires an authoritative SCImago category crosswalk. An attempt to retrieve the live SCImago rankings page was blocked with HTTP 403 in this environment.
|
| 39 |
+
|
| 40 |
+
Some publishers require JavaScript, cookies or authentication, use another platform/domain, omit a discoverable author link, or block automated requests. The resolver does not guess a path or bypass access controls. It retains the metadata homepage and reports unavailable guidance. A homepage labelled metadata-only has not been tested for reachability.
|
| 41 |
+
|
| 42 |
+
## Validation performed
|
| 43 |
+
|
| 44 |
+
- 17 offline Python link-resolver tests passed using the actual bundled database and CSV plus mocked upstream responses: canonical identity conflicts, metadata identity, source dates, missing evidence, category-specific quartiles, separate identifiers, real relative-anchor extraction, general guidance labels, successful/negative/expired/bounded caches, concurrent duplicate requests, failed requests, unsafe URLs/private DNS/redirects, and curated repository selection.
|
| 45 |
+
- 12 Node VM frontend behavior checks passed against the actual embedded functions: safe URL rendering, clickable titles, evidence labels, distinct category quartiles, reset/session invalidation, successful result updates and failed-request behavior.
|
| 46 |
+
- 7 existing dependency-free frontend/repository regression checks passed.
|
| 47 |
+
- Python compilation and embedded JavaScript syntax checks passed.
|
| 48 |
+
- Full original tests directory retained. Added two FastAPI endpoint integration tests for identity-only input and rate limiting; these are included in the Docker pytest gate but could not be executed here.
|
| 49 |
+
|
| 50 |
+
## Not verified here
|
| 51 |
+
|
| 52 |
+
The environment could not install the pinned Python requirements. Therefore the full FastAPI/pytest suite, API server startup and Docker build were not run. Playwright was present but no browser executable was installed, so no visual/browser or real LibGuides test was completed. Node VM checks are not browser tests. Live OpenAlex, publisher discovery, credential-dependent services and production deployment were not verified. The release has not been deployed.
|
| 53 |
+
|
| 54 |
+
Reproduce checks in a configured development environment:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
python -m pip install -r requirements-dev.txt
|
| 58 |
+
python -m compileall -q .
|
| 59 |
+
python -m pytest -q
|
| 60 |
+
node tests/test_frontend_links.cjs
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
The standalone offline link tests need only the Python standard library:
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
python -m unittest discover -s tests -p test_trusted_links.py -v
|
| 67 |
+
```
|
README-DEPLOY.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# ResearchBee 1.8.2 — upload instructions
|
| 2 |
|
| 3 |
Prepared 10 September 2026 from your uploaded HF backend and the separately uploaded LibGuides text. Your original uploads were preserved.
|
|
@@ -39,6 +43,8 @@ The API address remains `https://nikeshn-researchbee.hf.space`. No frontend API
|
|
| 39 |
| `CLOUDFLARE_WORKER_URL`, `CLOUDFLARE_WORKER_TOKEN` | Keep existing values if using the external usage dashboard. Credentials stay on the server. |
|
| 40 |
| `USAGE_PUSH_ENABLED` | Preserve existing setting; use `true` for periodic usage pushes. |
|
| 41 |
| `OPENALEX_API_KEY` | Optional supported OpenAlex credential. Configure it if required by your OpenAlex access plan. |
|
|
|
|
|
|
|
| 42 |
| `RESEARCHBEE_MODEL`, `RESEARCHBEE_MODEL_LIGHT` | Defaults remain `gpt-4.1-mini` and `gpt-4.1-nano`. Use models your account supports. |
|
| 43 |
|
| 44 |
No new secret is mandatory for the code repairs themselves. Existing features still depend on their corresponding valid credentials and services.
|
|
|
|
| 1 |
+
# ResearchBee upload instructions (current: 1.8.3 / interface v1.9.3)
|
| 2 |
+
|
| 3 |
+
> **1.8.3 quick path.** Upload the extracted backend files (including the new `re3data.py`, `tests/test_re3data.py`, `tests/test_subject_nearest.py`, `tests/test_frontend_re3data.cjs` and `tests/fixtures/`) to the Space, wait for the build, and confirm `/api/health` shows `version: "1.8.3"` and a `sources.re3data` entry. Then replace the LibGuides fragment with `ResearchBee_LibGuides.html` and confirm the footer shows **Interface v1.9.3**. Post-deploy checks: browse by subject "physics" (journals appear with an amber "nearest verified subject area" notice); run a repository search with discipline "Materials science" (a "More repositories from re3data.org" section appears below the curated cards). No new secret is required. The sections below are the 1.8.2 guide and still apply.
|
| 4 |
+
|
| 5 |
# ResearchBee 1.8.2 — upload instructions
|
| 6 |
|
| 7 |
Prepared 10 September 2026 from your uploaded HF backend and the separately uploaded LibGuides text. Your original uploads were preserved.
|
|
|
|
| 43 |
| `CLOUDFLARE_WORKER_URL`, `CLOUDFLARE_WORKER_TOKEN` | Keep existing values if using the external usage dashboard. Credentials stay on the server. |
|
| 44 |
| `USAGE_PUSH_ENABLED` | Preserve existing setting; use `true` for periodic usage pushes. |
|
| 45 |
| `OPENALEX_API_KEY` | Optional supported OpenAlex credential. Configure it if required by your OpenAlex access plan. |
|
| 46 |
+
| `RE3DATA_ENABLED` | Optional (1.8.3). Default `true`. Set `false` to hide the re3data.org supplementary list. |
|
| 47 |
+
| `RE3DATA_TIMEOUT_SECONDS` | Optional (1.8.3). Default `12`. Upper bound on the re3data lookup. |
|
| 48 |
| `RESEARCHBEE_MODEL`, `RESEARCHBEE_MODEL_LIGHT` | Defaults remain `gpt-4.1-mini` and `gpt-4.1-nano`. Use models your account supports. |
|
| 49 |
|
| 50 |
No new secret is mandatory for the code repairs themselves. Existing features still depend on their corresponding valid credentials and services.
|
README.md
CHANGED
|
@@ -1,66 +1,71 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: ResearchBee
|
| 3 |
-
emoji: 🐝
|
| 4 |
-
colorFrom: indigo
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
-
pinned: false
|
| 9 |
-
license: mit
|
| 10 |
-
short_description: Journal selection, Open Access and research data assistant
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
# ResearchBee
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
##
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
-
|
| 38 |
-
-
|
| 39 |
-
-
|
| 40 |
-
-
|
| 41 |
-
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
```
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ResearchBee
|
| 3 |
+
emoji: 🐝
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Journal selection, Open Access and research data assistant
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# ResearchBee 1.8.3 (interface v1.9.3)
|
| 14 |
+
|
| 15 |
+
**What's new:** nearest-subject matching for journal browse (e.g. "physics" → Physics and Astronomy, disclosed to the user) and a clearly labelled re3data.org supplement in the data-repository finder. See **RELEASE_1.8.3.md** for details, validation and post-deploy checks. Deploy as described in README-DEPLOY.md; the new optional settings are `RE3DATA_ENABLED` and `RE3DATA_TIMEOUT_SECONDS`.
|
| 16 |
+
|
| 17 |
+
## Earlier: hyperlink update
|
| 18 |
+
|
| 19 |
+
See **DEPLOYMENT-LINKS.md** for this update, upload instructions, executed checks and untested limitations. The following notes describe the supplied base release.
|
| 20 |
+
|
| 21 |
+
## Base release 1.8.2
|
| 22 |
+
|
| 23 |
+
Publishing, Open Access and research data guidance for Khalifa University researchers.
|
| 24 |
+
|
| 25 |
+
This release repairs the supplied backend and uses the separately uploaded LibGuides fragment as the frontend baseline. It retains the existing HF endpoint and `journal_discovery_v2` schema.
|
| 26 |
+
|
| 27 |
+
## Included functions
|
| 28 |
+
|
| 29 |
+
- Journal discovery by manuscript or subject, verified identities and subject quartiles, preferred/excluded titles, comparisons and exports.
|
| 30 |
+
- On-demand journal policies and DOI-based article-sharing checks with version, destination, licence, funder and embargo context.
|
| 31 |
+
- Dataset repository selection from a curated registry, with deterministic constraint checks.
|
| 32 |
+
- Source-aware English/Arabic chat, tool shortcuts, related literature and cover-letter drafts.
|
| 33 |
+
- Health reporting, authenticated Khazna refresh and administration/usage monitoring.
|
| 34 |
+
|
| 35 |
+
## Main repairs
|
| 36 |
+
|
| 37 |
+
- Repaired 7,721 missing subject-quartile values in 7,096 existing journal records using the bundled 2025 CSV. Remaining unknowns stay unknown.
|
| 38 |
+
- Retained eligible preferred journals through both shortlist limits; removed inflated fallback match scores.
|
| 39 |
+
- Sharing advice evaluates complete policy options. Permission, embargo timing and destination conditions cannot be borrowed from incompatible options.
|
| 40 |
+
- Repository recommendations enforce declared capabilities and sensitive-data restrictions. Khazna dataset advice is metadata registration, with file-hosting capabilities requiring confirmation.
|
| 41 |
+
- Fixed chat rendering, question chips, history limits, SSE interruptions, autocomplete identity, embargo controls, CSV safety and long-abstract cover letters.
|
| 42 |
+
- Added Arabic knowledge matching, interface translations, focus/keyboard improvements and source-review warnings.
|
| 43 |
+
- Added bounded requests, paid-route quotas/concurrency, safer admin sessions, server-only usage credentials, redacted failure logging and lifecycle repairs.
|
| 44 |
+
- OpenAlex receives broad validated taxonomy terms for manuscript evidence searches, not the unpublished title or abstract. It still receives journal identifiers and public literature queries.
|
| 45 |
+
- Disabled the unsupported journal-level Altmetric score and unrepresentative percentile calculation.
|
| 46 |
+
|
| 47 |
+
## Deploy
|
| 48 |
+
|
| 49 |
+
See **README-DEPLOY.md**. Upload the complete extracted release to the existing Docker Space, including `journals.db`, new helper modules and `tests/`. Then replace the entire LibGuides fragment with `ResearchBee_LibGuides.html`.
|
| 50 |
+
|
| 51 |
+
The Docker build installs pinned dependencies, runs `pip check`, compiles Python and runs the full Python test suite before creating the runtime image. Production runs as a non-root user.
|
| 52 |
+
|
| 53 |
+
## Validation
|
| 54 |
+
|
| 55 |
+
See **VALIDATION_1.8.2.md** for executed checks and limitations. Local verification used Python 3.12 and Node 24; the container targets Python 3.11. The Docker build and real FastAPI HTTP smoke suite could not be executed in the preparation environment. Live credentials, external service availability and rendered LibGuides browser behavior require the post-upload checks.
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
python -m pip install -r requirements-dev.txt
|
| 59 |
+
python -m pytest -q
|
| 60 |
+
node tests/test_frontend_links.cjs
|
| 61 |
+
node tests/test_frontend_re3data.cjs
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Evidence boundaries
|
| 65 |
+
|
| 66 |
+
Journal identities and metrics come from the supplied local database and 2025 CSV. This release does not refresh them from current Scopus/SCImago sources. Fit scores support comparison; they do not estimate acceptance probability. Current APCs, title-level KU eligibility and unstructured policy conditions need authoritative confirmation.
|
| 67 |
+
|
| 68 |
+
Khazna lookup reports a loaded snapshot. Its source harvest date is absent from the supplied snapshot format, so a missing DOI is not proof of current repository absence.
|
| 69 |
+
|
| 70 |
+
Questions: library@ku.ac.ae; Khazna: khazna@ku.ac.ae.
|
| 71 |
+
|
RELEASE_1.8.3.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ResearchBee 1.8.3 — change record (30 October 2026)
|
| 2 |
+
|
| 3 |
+
Backend `1.8.3`, LibGuides interface `v1.9.3`. Earlier features are unchanged unless a change is listed here.
|
| 4 |
+
|
| 5 |
+
## 1. Journal browse by subject: nearest-subject matching
|
| 6 |
+
|
| 7 |
+
**Problem.** A subject that was not a literal SCImago/ASJC name (for example "physics", "maths", "biology", "machine learning") depended entirely on the LLM classifier. When the classifier was unavailable, or answered `not_found` for a broad term, the user saw no journals.
|
| 8 |
+
|
| 9 |
+
**Change.** `/api/browse-subject` now resolves subjects in this order:
|
| 10 |
+
|
| 11 |
+
1. Exact vocabulary term, as before.
|
| 12 |
+
2. Conservative spelling repair, as before.
|
| 13 |
+
3. **New:** deterministic nearest match in `scimago_index.nearest_subject_term()`:
|
| 14 |
+
- a reviewed alias table (about 200 common discipline names, for example maths → Mathematics, machine learning → Artificial Intelligence, petroleum engineering → Energy Engineering and Power Technology, criminology → Sociology and Political Science);
|
| 15 |
+
- whole-word containment, for example physics → area "Physics and Astronomy", or electrical engineering → "Electrical and Electronic Engineering". Parenthetical qualifiers are ignored, and generic words such as "science", "engineering" or "learning" never decide a match on their own.
|
| 16 |
+
4. LLM classifier (closed vocabulary), as before.
|
| 17 |
+
5. **New:** if the classifier fails or returns no mapping, a last-resort partial match on a distinctive word (for example "quantum computing networks" stays unmatched, while "geology rocks" maps to Geology).
|
| 18 |
+
|
| 19 |
+
Every nearest or synonym match is disclosed to the user, for example: *SCImago and Scopus have no exact subject category for "physics". ResearchBee selected the nearest verified subject area "Physics And Astronomy".* Up to four verified alternative categories are offered as one-click chips. The disclosure note is highlighted in amber in the interface.
|
| 20 |
+
|
| 21 |
+
Every alias target is validated against the bundled vocabulary when it is used, and a test fails if any target disappears. Meaningless input ("xyzzy") still returns no journals. Deterministic matches avoid an LLM call, which reduces cost and latency.
|
| 22 |
+
|
| 23 |
+
Also fixed: the spelling-correction notice was shown twice.
|
| 24 |
+
|
| 25 |
+
## 2. Data repository finder: re3data.org supplement
|
| 26 |
+
|
| 27 |
+
The curated KU Library registry stays the only source of recommendations, deposit strategy and Khazna guidance. New module `re3data.py` adds a separate list: **More repositories from re3data.org**, labelled *Not reviewed by KU Library*.
|
| 28 |
+
|
| 29 |
+
- Runs in parallel with the model call, so it adds no latency in normal operation. It is bounded by `RE3DATA_TIMEOUT_SECONDS` (default 12).
|
| 30 |
+
- **Privacy:** only the *Discipline / domain* field is sent to re3data. The dataset title, description and notes are never sent.
|
| 31 |
+
- Uses the re3data beta search API and v1 repository records, with namespace-agnostic XML parsing that refuses DTDs and is capped at 2 MB.
|
| 32 |
+
- Excludes repositories that are already in the curated registry, closed (end date), closed to uploads, or purely institutional. These are usually limited to their own staff.
|
| 33 |
+
- For datasets marked sensitive or personal, only repositories that list restricted, embargoed or closed access are shown, with the standard approval warning.
|
| 34 |
+
- Ranks by subject/keyword/name evidence, then DOI, certification, open upload and versioning. Shows up to five.
|
| 35 |
+
- Shows subjects, PID system, upload and access type, versioning, certificates, country, the repository link, the re3data record link and the record's own last-update date.
|
| 36 |
+
- The list opens automatically when the curated registry produced fewer than two candidates. Otherwise it is collapsed.
|
| 37 |
+
- Failures are isolated. An unreachable re3data service shows a short note and a "Search re3data.org yourself" link, and the curated results are unaffected.
|
| 38 |
+
- Caches: 24 hours for searches, 7 days for records, 10 minutes for failures. All are bounded.
|
| 39 |
+
- `/api/health` reports `sources.re3data`. Set `RE3DATA_ENABLED=false` to switch the list off.
|
| 40 |
+
|
| 41 |
+
Also fixed: the curated cards' "Verify on re3data/FAIRsharing" links now URL-encode repository names.
|
| 42 |
+
|
| 43 |
+
## 3. Frontend
|
| 44 |
+
|
| 45 |
+
- Based on the live LibGuides fragment supplied on 30 October 2026 (KU styling). Its JavaScript was identical to the 1.8.2 package copy.
|
| 46 |
+
- Adds amber styling for nearest/synonym subject notes, the re3data section (desktop and mobile), and Arabic labels for the new strings.
|
| 47 |
+
- The footer reads **Interface v1.9.3**.
|
| 48 |
+
|
| 49 |
+
## Validation
|
| 50 |
+
|
| 51 |
+
- Python 3.11 (Docker target) and 3.13 with pinned requirements: `pip check` clean, `compileall` clean, **78 tests passed**. These are the 37 existing tests plus 27 subject tests and 14 re3data tests.
|
| 52 |
+
- Node: 12 existing and 16 new frontend checks passed. The embedded script passes a syntax check.
|
| 53 |
+
- Real-browser end-to-end test (Chromium/Playwright) of the live fragment against the real backend under uvicorn, with only the LLM and the re3data transport mocked:
|
| 54 |
+
- "physics" returns 15 verified journals with the nearest-area notice and alternative chips, and an alternative chip runs an exact search;
|
| 55 |
+
- the repository flow shows the curated cards, then the re3data section with safe links;
|
| 56 |
+
- no horizontal overflow at 390 px;
|
| 57 |
+
- no JavaScript errors.
|
| 58 |
+
|
| 59 |
+
## Not verified here
|
| 60 |
+
|
| 61 |
+
- The live re3data API was not reachable from the preparation environment (egress blocked). Parsing was built against re3data's published schema and tested with fixtures in that shape. After deployment, run one repository search and check that the re3data section shows real entries, or that `/api/health → sources.re3data.last_error` is empty.
|
| 62 |
+
- The live OpenAI classifier and the Docker build on Hugging Face were not executed. The same pytest gate was run locally on Python 3.11.
|
ResearchBee_LibGuides.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
SHA256SUMS.json
CHANGED
|
@@ -1,62 +1,73 @@
|
|
| 1 |
-
{
|
| 2 |
-
".dockerignore": "acbfa007f9cc614a581b5517709de7fb6df4a1836bc7c0c7bee7b96d020f3b83",
|
| 3 |
-
".env.example": "
|
| 4 |
-
".gitattributes": "4d72e9b97f1cb0fd76258dec00ff2a9ac929fafab43ac1e68da5d4ab82149622",
|
| 5 |
-
"DEPLOYMENT-LINKS.md": "
|
| 6 |
-
"Dockerfile": "a5289268fe287dd5677b37ce60017563f7294729bed078c935cfce3fc010d280",
|
| 7 |
-
"README-DEPLOY.md": "
|
| 8 |
-
"README.md": "
|
| 9 |
-
"RELEASE_1.7.0.md": "334d4cb6215ff7ab540c539d3c3f8d2830641ced95c4f9325fdd86c78b7488ce",
|
| 10 |
-
"RELEASE_1.8.0.md": "4d85c1711c33cd314c799d27e59795b62fe9104656d79cd76bba6af538b9ad1e",
|
| 11 |
-
"RELEASE_1.8.1.md": "500ba91d0737c6198a453de2bd539827b019185c34d68d32d325a2dc5e106fa4",
|
| 12 |
-
"RELEASE_1.8.2.md": "b006bed1643a39a8f79930ffb682c37c64ba00a3752b9a056993791153810f88",
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
-
"
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"tests/
|
| 50 |
-
"tests/
|
| 51 |
-
"tests/
|
| 52 |
-
"tests/
|
| 53 |
-
"tests/
|
| 54 |
-
"tests/
|
| 55 |
-
"tests/
|
| 56 |
-
"tests/
|
| 57 |
-
"tests/
|
| 58 |
-
"
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
".dockerignore": "acbfa007f9cc614a581b5517709de7fb6df4a1836bc7c0c7bee7b96d020f3b83",
|
| 3 |
+
".env.example": "b3ad9769d1306238eec831d7a818a21b15cd7cdb3df8b670d49831fb10beb6b8",
|
| 4 |
+
".gitattributes": "4d72e9b97f1cb0fd76258dec00ff2a9ac929fafab43ac1e68da5d4ab82149622",
|
| 5 |
+
"DEPLOYMENT-LINKS.md": "f58b06a9ebf2558c3699025038c5671d1eb45c3e3ff989c9300ed586800a9347",
|
| 6 |
+
"Dockerfile": "a5289268fe287dd5677b37ce60017563f7294729bed078c935cfce3fc010d280",
|
| 7 |
+
"README-DEPLOY.md": "74103d9cc21b69354e5c978910edd7e22b24cf21c3ef657f82102178b0cd962c",
|
| 8 |
+
"README.md": "4b8cb6a850fdecf36e0d8efb70ec7fe9c916edbfa82db367d9126c9df51166e4",
|
| 9 |
+
"RELEASE_1.7.0.md": "334d4cb6215ff7ab540c539d3c3f8d2830641ced95c4f9325fdd86c78b7488ce",
|
| 10 |
+
"RELEASE_1.8.0.md": "4d85c1711c33cd314c799d27e59795b62fe9104656d79cd76bba6af538b9ad1e",
|
| 11 |
+
"RELEASE_1.8.1.md": "500ba91d0737c6198a453de2bd539827b019185c34d68d32d325a2dc5e106fa4",
|
| 12 |
+
"RELEASE_1.8.2.md": "b006bed1643a39a8f79930ffb682c37c64ba00a3752b9a056993791153810f88",
|
| 13 |
+
"RELEASE_1.8.3.md": "9d0aace78e561326ab1c74e63dc801029fc2c958b656d82f6325242a2476c491",
|
| 14 |
+
"RELEASE_A_NOTES.md": "2c9d4dd7b8e787ada145b2393578c6b3499370fb4be2a89100bd555ed8b96a42",
|
| 15 |
+
"RELEASE_OPF_WEB_1.5.2.md": "b4cc66479031664e35026fdbb57ec0a83468e1d54100ac022100b2849df67b77",
|
| 16 |
+
"ResearchBee_LibGuides.html": "2ad265c90e98d2d30ded0754d8fefb2f0137b3f1f0b8416aaacfc42718764219",
|
| 17 |
+
"VALIDATION_1.8.2.md": "2c5ae04d43ebe3e3867bd5fbb4bdf303cebf03a7dc85dddc0079844b3ec82f41",
|
| 18 |
+
"admin.py": "7db70eb130f8bc4d9e98f566ce86315fd2dfa4cbe90e3415dba037aa13d28421",
|
| 19 |
+
"app.py": "ded10bb7abc502c1142c6a513ff1f1d4950f70d4e47df5351cbb0172ef849a23",
|
| 20 |
+
"asjc_classifier.py": "c689f9d1fcd3ac15b4a0bd711153acbc9cb8780caed220f0c5d6afcde9af6fa6",
|
| 21 |
+
"asjc_eval.jsonl": "cc762d1d81532574a2270b98758324dc7c251b1d22a58604045ca5fa02a1b684",
|
| 22 |
+
"build_centroids.py": "48b3064a7b261195824fdedf84708ebb6e36954b9160d4b1722235d548e93e42",
|
| 23 |
+
"chat_knowledge.py": "d8b69d510ae847bb46f2c054a1458df7fe4a8db237dfb94ae515cfd4dff414ad",
|
| 24 |
+
"doi_utils.py": "7026a3913998b94aa4822f7cbbe9bb779ce52880f6e5a180959b37a19de9f8f9",
|
| 25 |
+
"harvest_openalex.py": "3c3cedd28c0892a10126249d892cd27275131d5295669d10d201ebae9ea4da3d",
|
| 26 |
+
"journal_discovery.py": "b69d282411eb4fecd3bcdb354cc34ac27f705b5b1d71ae600372d75a4672c12d",
|
| 27 |
+
"journal_links_curated.json": "e3566b3a06430868d71e9287dfd6c6c520a3da027aabea01951d407ee131dc2f",
|
| 28 |
+
"journal_lookup.py": "4d7e1638b48cfd1c908b217c3e4b8bf145932a9f34d3cb762092a99e4a817aa0",
|
| 29 |
+
"journals.db": "6553cefa4515cbeea4631fcd5f59a4b8bdb756fe83701788e8a11fb9298b2404",
|
| 30 |
+
"khazna_loader.py": "3e944cbfa116647ff548015b37e083134da9aa2aafbda0e957be6c31ea7ac9c7",
|
| 31 |
+
"knowledge_base.json": "1838c1d1fd5456c16308ebfaba237b8516aa01df5901a83e7290bb73b55b5bc6",
|
| 32 |
+
"ku_knowledge.py": "b54c5acd3c26783e5b44872897810d04ae0257df367b9fde62e8b6bb6e5f9745",
|
| 33 |
+
"oaworks.py": "491ae10ae479001fd1218d3d9c623cb39f4e8c222fc3bdf3420b2a8f03328410",
|
| 34 |
+
"openalex.py": "1f846a13c46d7dd4ed70494a552a1bfd596cf764d7fb7f8b89ac3389a9cf195c",
|
| 35 |
+
"openpolicyfinder.py": "9f55d2e744b6f784422a0731f01c0b727c3cc5b001da009889e8dcf83288f081",
|
| 36 |
+
"policy_evaluation.py": "287df1a54c2d4893cd495da6d95ee8049f480bf0ff75879e52f2b0ede7307dab",
|
| 37 |
+
"prompts.py": "5b40d1be17b41b8a51231378bf70dee4056d5373c1a739b1a964094f93850359",
|
| 38 |
+
"re3data.py": "fee9fb9a36c77b9ab2da9e63baa54f7f74b88a864ac5d39df0d6804f6904c25e",
|
| 39 |
+
"repair_subject_quartiles.py": "91ddce1c0a08fa802923ff1806d5333617f4262eca686ef50ae35b3f35dd5950",
|
| 40 |
+
"repositories.py": "f0f1673a2b73a8afc9be6b20a96bd3396336513550570d8b5048000ed75fa5a4",
|
| 41 |
+
"requirements-dev.txt": "e5bfd68d8ff1bdd7151344031c2e4cb97b187d099f681f4007b6d96ad7dfbdf8",
|
| 42 |
+
"requirements.txt": "179482f7f9e3177d035ef04101719117524476a98271dbafab3e3f18a17ce149",
|
| 43 |
+
"researchbee_journals.py": "623272ee184e37bf08b4fddd887f7aabd10b6c6cb1999416ddbcc0f5c72309c6",
|
| 44 |
+
"runtime_security.py": "8d3466aba8a11fb729f30bf83cd56596b53e935cb88f144539a20aaf9ac7d762",
|
| 45 |
+
"scimago_index.py": "8ec98c45527c7af397b9c3d0d1f4a23319e2bebbcb0c2642727559b9c3909a80",
|
| 46 |
+
"scimagojr_2025.csv": "a536c49b0854e96c66350c92b6f50dcfed26845e7c5545e5ebc65c1f96058fe2",
|
| 47 |
+
"style.css": "10762d70e0c991a6bb989c56bf6d22bb2e53ba902d1c9bc4ce7011d8d9f98699",
|
| 48 |
+
"subject_quartile_repair.json": "13fb100a9aaabacd9182551ccae8b531da8ae878789692402adde245d8300edb",
|
| 49 |
+
"tests/a": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 50 |
+
"tests/conftest.py": "d8a6d008056392e4dbcf83d1970c8d077d94cd8363804727a8ca0c5fe4538edf",
|
| 51 |
+
"tests/fixtures/re3data_r3d100010134.xml": "7d6a2c0c6a655529dec1d69af12ec949369268d75ddaa7723e3ca5449d304683",
|
| 52 |
+
"tests/fixtures/re3data_r3d100012345.xml": "6ad59819be352abc72b34127aa59813ee128196651783d051224f6e7355331cd",
|
| 53 |
+
"tests/fixtures/re3data_r3d100012346.xml": "ef4d110920251213e1da2fa4de7ac34b047164e59923390e9df3a6f075b3f2c8",
|
| 54 |
+
"tests/fixtures/re3data_r3d100012347.xml": "7aea33c7ad7c38d7c6288a913237eaed235d40c0162af7bb5b7cd2f4a6cd3775",
|
| 55 |
+
"tests/fixtures/re3data_r3d100012348.xml": "2220f3b9ee3665707c161cec6b0d07019541a902a6f028d8aeb1ff6ae14de458",
|
| 56 |
+
"tests/fixtures/re3data_search_materials.xml": "9cd339477b3184a48027316baa576fcb367353356311c2c3e8bd3420f7673a83",
|
| 57 |
+
"tests/test_frontend_links.cjs": "33bf2edca82a17ef98df86221beb1ca0fddfaf52289dfc9a7cc161e657c7e551",
|
| 58 |
+
"tests/test_frontend_re3data.cjs": "1f934c661982b6e00a0676ac2e5b36b4aef302d5b0a4d41858ea8a24a68f0e41",
|
| 59 |
+
"tests/test_frontend_release_a.py": "ae8f029cd7a6e3ebc404c6cffdf35b3a9a3188cb65f93ff5f9c0cbcfb344947a",
|
| 60 |
+
"tests/test_journal_policy_guard.py": "27ece48b44981ba75a0335dbcca5ec5a09dfa57f5976b037e2dbd7155ff4990b",
|
| 61 |
+
"tests/test_khazna_reload_auth.py": "c9fe67180ca23b5d0adf0d8f91bd21e256d929a993a61f07a02ae7d111091013",
|
| 62 |
+
"tests/test_oaworks_tri_state.py": "0bd94f8af28d39c01c42a821ad79604699690d8801ffcc39e5189b6add5afee2",
|
| 63 |
+
"tests/test_re3data.py": "62388760aed721d86172bc617cc88ab3e2cb20eb3f2974aff9c2f3b5ff5f76a2",
|
| 64 |
+
"tests/test_repository_schema.py": "658880256ca07349737a52c602544a98153f0c995125970d13cc5fb9c9caf64f",
|
| 65 |
+
"tests/test_subject_nearest.py": "d9d4e9f07ae5399201fb376885171378d5f47842be4b41072c476e86ce80be06",
|
| 66 |
+
"tests/test_subject_quartiles.py": "c93529b2e2501f2e4f378dbd73d37cabb388040d907566404f19b97980789676",
|
| 67 |
+
"tests/test_trusted_links.py": "05e2e6a614ab61bbf44bb9b937ef00b831ec4f5e7c1dd2b2557c536e56feb8ba",
|
| 68 |
+
"tests/test_trusted_links_api.py": "b3d5d1f765fc451f13e514559a1ee53dbd1b7c51ad6a3736a47c75b878d619e0",
|
| 69 |
+
"train_asjc.py": "caff809252a7e3234a4c22f113c4df56e2aafbcfa45398fcfbed3061755d1dae",
|
| 70 |
+
"trusted_links.py": "8cbc9e55a9682af3991ea42cd040df89bdce02347919114bdb7236e97ad97c00",
|
| 71 |
+
"usage.py": "5cbe805a1732ed5a11d2a420cf085b6baef63f5c5eff20de3126f6a208848df3",
|
| 72 |
+
"utils.py": "57e37e6b426159b5233657bfd442671150647c7b5f291a2d1a6ba5938b3a42b7"
|
| 73 |
+
}
|
app.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
journal_links_curated.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{}
|
|
|
|
| 1 |
+
{}
|
openalex.py
CHANGED
|
@@ -1,307 +1,307 @@
|
|
| 1 |
-
"""
|
| 2 |
-
OpenAlex enrichment — journal metadata + peer percentile calculation.
|
| 3 |
-
Optional server-side API key raises the available request budget.
|
| 4 |
-
The obsolete sample-derived percentile is deliberately not reported.
|
| 5 |
-
|
| 6 |
-
Changes from v1.0:
|
| 7 |
-
- Shared module-level httpx.AsyncClient (init_client / close_client for lifespan)
|
| 8 |
-
- compute_percentile accepts an optional concept_cache dict to deduplicate
|
| 9 |
-
identical concept peer-set fetches across journals in one request
|
| 10 |
-
- _norm_issn removed — uses utils.norm_issn
|
| 11 |
-
"""
|
| 12 |
-
import httpx
|
| 13 |
-
import asyncio
|
| 14 |
-
import re
|
| 15 |
-
import os
|
| 16 |
-
import time
|
| 17 |
-
import logging
|
| 18 |
-
from collections import OrderedDict
|
| 19 |
-
from datetime import datetime, timezone
|
| 20 |
-
from typing import Optional
|
| 21 |
-
from utils import norm_issn
|
| 22 |
-
|
| 23 |
-
UA = "ResearchBee/1.8.2 (mailto:library@ku.ac.ae)"
|
| 24 |
-
logger = logging.getLogger(__name__)
|
| 25 |
-
_last_success = None
|
| 26 |
-
_last_error = None
|
| 27 |
-
_source_cache = OrderedDict()
|
| 28 |
-
BASE = "https://api.openalex.org"
|
| 29 |
-
|
| 30 |
-
_http_client: Optional[httpx.AsyncClient] = None
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
async def _record_response(response) -> None:
|
| 34 |
-
global _last_success, _last_error
|
| 35 |
-
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 36 |
-
if response.is_success:
|
| 37 |
-
_last_success = now
|
| 38 |
-
_last_error = None
|
| 39 |
-
else:
|
| 40 |
-
_last_error = {"status": response.status_code, "at": now}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def status() -> dict:
|
| 44 |
-
return {"api_key_configured": bool((os.getenv("OPENALEX_API_KEY") or "").strip()),
|
| 45 |
-
"last_success": _last_success, "last_error": _last_error,
|
| 46 |
-
"availability": "not_checked" if _last_success is None and _last_error is None else ("degraded" if _last_error else "available")}
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
async def _authorize_request(request) -> None:
|
| 50 |
-
"""Enforce the provider boundary before attaching its credential."""
|
| 51 |
-
url = request.url
|
| 52 |
-
if url.scheme != "https" or url.host != "api.openalex.org" or url.port not in (None, 443):
|
| 53 |
-
request.headers.pop("Authorization", None)
|
| 54 |
-
raise ValueError("OpenAlex client refuses non-provider origins")
|
| 55 |
-
key = (os.getenv("OPENALEX_API_KEY") or "").strip()
|
| 56 |
-
if key:
|
| 57 |
-
request.headers["Authorization"] = "Bearer " + key
|
| 58 |
-
else:
|
| 59 |
-
request.headers.pop("Authorization", None)
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _record_failure(exc: Exception) -> None:
|
| 63 |
-
global _last_error
|
| 64 |
-
_last_error = {"type": type(exc).__name__,
|
| 65 |
-
"at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
|
| 66 |
-
status_code = getattr(exc, "status_code", None)
|
| 67 |
-
if status_code is not None:
|
| 68 |
-
_last_error["status"] = status_code
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
class OpenAlexUnavailable(RuntimeError):
|
| 72 |
-
def __init__(self, status_code=None):
|
| 73 |
-
self.status_code = status_code
|
| 74 |
-
super().__init__("OpenAlex is temporarily unavailable")
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def init_client() -> None:
|
| 78 |
-
global _http_client
|
| 79 |
-
_http_client = httpx.AsyncClient(
|
| 80 |
-
timeout=httpx.Timeout(10.0, connect=5.0),
|
| 81 |
-
headers={"User-Agent": UA}, follow_redirects=False,
|
| 82 |
-
event_hooks={"request": [_authorize_request], "response": [_record_response]},
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
async def close_client() -> None:
|
| 87 |
-
global _http_client
|
| 88 |
-
if _http_client and not _http_client.is_closed:
|
| 89 |
-
await _http_client.aclose()
|
| 90 |
-
_http_client = None
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _get_client() -> httpx.AsyncClient:
|
| 94 |
-
if _http_client is None or _http_client.is_closed:
|
| 95 |
-
init_client()
|
| 96 |
-
return _http_client
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]:
|
| 100 |
-
cache_key = (norm_issn(issn), name.strip().casefold())
|
| 101 |
-
cached = _source_cache.get(cache_key)
|
| 102 |
-
if cached and time.monotonic() - cached[0] < 3600:
|
| 103 |
-
_source_cache.move_to_end(cache_key)
|
| 104 |
-
return dict(cached[1])
|
| 105 |
-
client = _get_client()
|
| 106 |
-
try:
|
| 107 |
-
if issn:
|
| 108 |
-
clean = norm_issn(issn) or issn.strip()
|
| 109 |
-
r = await client.get(f"{BASE}/sources/issn:{clean}")
|
| 110 |
-
else:
|
| 111 |
-
r = await client.get(
|
| 112 |
-
f"{BASE}/sources", params={"search": name, "per_page": 1}
|
| 113 |
-
)
|
| 114 |
-
if not r.is_success:
|
| 115 |
-
return None
|
| 116 |
-
d = r.json()
|
| 117 |
-
s = d if issn else (d.get("results") or [None])[0]
|
| 118 |
-
if not s:
|
| 119 |
-
return None
|
| 120 |
-
# An ISSN request must be confirmed by the response, not merely the URL.
|
| 121 |
-
source_issns = {norm_issn(v) for v in [s.get("issn_l"), *(s.get("issn") or [])] if v}
|
| 122 |
-
if issn and norm_issn(issn) not in source_issns:
|
| 123 |
-
return None
|
| 124 |
-
if not issn:
|
| 125 |
-
from researchbee_journals import JournalIndex
|
| 126 |
-
if JournalIndex.normalise_identity_title(name) != JournalIndex.normalise_identity_title(s.get("display_name", "")):
|
| 127 |
-
return None
|
| 128 |
-
result = {
|
| 129 |
-
"source_updated_at": s.get("updated_date"),
|
| 130 |
-
"retrieved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 131 |
-
"openalex_id": s.get("id"),
|
| 132 |
-
"display_name": s.get("display_name"),
|
| 133 |
-
"issn_l": s.get("issn_l"),
|
| 134 |
-
"issns": s.get("issn", []),
|
| 135 |
-
"host_organization": s.get("host_organization_name"),
|
| 136 |
-
"is_oa": s.get("is_oa"),
|
| 137 |
-
"is_in_doaj": s.get("is_in_doaj"),
|
| 138 |
-
"works_count": s.get("works_count"),
|
| 139 |
-
"cited_by_count": s.get("cited_by_count"),
|
| 140 |
-
"h_index": (s.get("summary_stats") or {}).get("h_index"),
|
| 141 |
-
"two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"),
|
| 142 |
-
"homepage_url": s.get("homepage_url"),
|
| 143 |
-
"top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"),
|
| 144 |
-
"top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"),
|
| 145 |
-
}
|
| 146 |
-
_source_cache[cache_key] = (time.monotonic(), result)
|
| 147 |
-
while len(_source_cache) > 512:
|
| 148 |
-
_source_cache.popitem(last=False)
|
| 149 |
-
return dict(result)
|
| 150 |
-
except Exception as e:
|
| 151 |
-
_record_failure(e)
|
| 152 |
-
logger.warning("OpenAlex source lookup failed (%s)", type(e).__name__)
|
| 153 |
-
return None
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
async def compute_percentile(openalex: dict, concept_cache: Optional[dict] = None) -> Optional[dict]:
|
| 157 |
-
"""A truncated high-citation peer sample cannot establish a field quartile."""
|
| 158 |
-
return None
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
async def enrich_all(journals: list) -> list:
|
| 162 |
-
"""Enrich a list of journal dicts in parallel, deduplicating concept peer-sets."""
|
| 163 |
-
concept_cache: dict = {}
|
| 164 |
-
|
| 165 |
-
async def _enrich_one(j: dict) -> dict:
|
| 166 |
-
oa = await enrich_journal(j.get("name", ""), j.get("issn"))
|
| 167 |
-
pct = await compute_percentile(oa, concept_cache) if oa else None
|
| 168 |
-
j["openalex"] = oa
|
| 169 |
-
j["oa_percentile"] = pct
|
| 170 |
-
if not j.get("issn") and oa:
|
| 171 |
-
j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0]
|
| 172 |
-
return j
|
| 173 |
-
|
| 174 |
-
return list(await asyncio.gather(*[_enrich_one(j) for j in journals]))
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
_EVIDENCE_STOP = {
|
| 178 |
-
"this", "that", "with", "from", "into", "using", "used", "study",
|
| 179 |
-
"research", "paper", "article", "results", "method", "methods", "based",
|
| 180 |
-
"analysis", "approach", "between", "among", "their", "these", "those",
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def _evidence_terms(text: str) -> set[str]:
|
| 185 |
-
words = re.findall(r"[A-Za-z][A-Za-z\-]{3,}", (text or "").lower())
|
| 186 |
-
return {w.strip("-") for w in words if w not in _EVIDENCE_STOP}
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def _title_overlap(query: str, titles: list[str]) -> int:
|
| 190 |
-
q = _evidence_terms(query)
|
| 191 |
-
if not q or not titles:
|
| 192 |
-
return 0
|
| 193 |
-
scores = []
|
| 194 |
-
for title in titles:
|
| 195 |
-
t = _evidence_terms(title)
|
| 196 |
-
if not t:
|
| 197 |
-
continue
|
| 198 |
-
overlap = len(q & t)
|
| 199 |
-
# Dice coefficient, converted to 0..100. OpenAlex already orders the
|
| 200 |
-
# search by relevance; this score is only a transparent local signal.
|
| 201 |
-
scores.append((2 * overlap / max(len(q) + len(t), 1)) * 100)
|
| 202 |
-
return round(max(scores, default=0.0))
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
async def similar_works_for_journal(
|
| 206 |
-
name: str,
|
| 207 |
-
issn: str = "",
|
| 208 |
-
query: str = "",
|
| 209 |
-
*,
|
| 210 |
-
per_page: int = 3,
|
| 211 |
-
) -> dict:
|
| 212 |
-
"""Return representative recent works as evidence for journal fit.
|
| 213 |
-
|
| 214 |
-
Failure is explicitly labelled unavailable, separately from no matches. Journal
|
| 215 |
-
identity and ranking still come from the local verified database, so an
|
| 216 |
-
OpenAlex outage never removes or invents a recommendation.
|
| 217 |
-
"""
|
| 218 |
-
source = await enrich_journal(name, issn or None)
|
| 219 |
-
if not source or not source.get("openalex_id"):
|
| 220 |
-
return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
|
| 221 |
-
|
| 222 |
-
source_id = str(source["openalex_id"]).rsplit("/", 1)[-1]
|
| 223 |
-
if not re.fullmatch(r"S\d+", source_id):
|
| 224 |
-
return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
|
| 225 |
-
|
| 226 |
-
current_year = datetime.now(timezone.utc).year
|
| 227 |
-
query_text = " ".join((query or "").split())[:500]
|
| 228 |
-
client = _get_client()
|
| 229 |
-
|
| 230 |
-
async def _fetch(use_search: bool) -> list[dict]:
|
| 231 |
-
params = {
|
| 232 |
-
"filter": (
|
| 233 |
-
f"primary_location.source.id:{source_id},is_retracted:false,"
|
| 234 |
-
f"from_publication_date:{current_year - 5}-01-01"
|
| 235 |
-
),
|
| 236 |
-
"per_page": min(max(per_page, 1), 5),
|
| 237 |
-
"select": "id,title,publication_year,doi,cited_by_count,authorships",
|
| 238 |
-
}
|
| 239 |
-
if use_search and query_text:
|
| 240 |
-
params["search"] = query_text
|
| 241 |
-
params["sort"] = "relevance_score:desc"
|
| 242 |
-
else:
|
| 243 |
-
params["sort"] = "cited_by_count:desc"
|
| 244 |
-
response = await client.get(f"{BASE}/works", params=params, timeout=6.0)
|
| 245 |
-
if not response.is_success:
|
| 246 |
-
raise OpenAlexUnavailable(response.status_code)
|
| 247 |
-
works = []
|
| 248 |
-
for work in response.json().get("results", []):
|
| 249 |
-
title = " ".join(str(work.get("title") or "").split())
|
| 250 |
-
if not title:
|
| 251 |
-
continue
|
| 252 |
-
authors = [
|
| 253 |
-
(a.get("author") or {}).get("display_name", "")
|
| 254 |
-
for a in (work.get("authorships") or [])[:3]
|
| 255 |
-
]
|
| 256 |
-
works.append({
|
| 257 |
-
"title": title,
|
| 258 |
-
"year": work.get("publication_year"),
|
| 259 |
-
"doi": work.get("doi", ""),
|
| 260 |
-
"cited_by": work.get("cited_by_count", 0),
|
| 261 |
-
"authors": [a for a in authors if a],
|
| 262 |
-
"openalex_url": work.get("id", ""),
|
| 263 |
-
})
|
| 264 |
-
return works
|
| 265 |
-
|
| 266 |
-
try:
|
| 267 |
-
works = await _fetch(bool(query_text))
|
| 268 |
-
if not works:
|
| 269 |
-
works = await _fetch(False)
|
| 270 |
-
score = _title_overlap(query_text, [w["title"] for w in works])
|
| 271 |
-
return {
|
| 272 |
-
"source": source,
|
| 273 |
-
"works": works,
|
| 274 |
-
"similarity_score": score,
|
| 275 |
-
"status": "available" if works else "no_recent_matches",
|
| 276 |
-
}
|
| 277 |
-
except Exception as exc:
|
| 278 |
-
_record_failure(exc)
|
| 279 |
-
logger.warning("OpenAlex evidence unavailable (%s)", type(exc).__name__)
|
| 280 |
-
return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
async def evidence_for_journals(
|
| 284 |
-
journals: list[dict],
|
| 285 |
-
query: str,
|
| 286 |
-
*,
|
| 287 |
-
limit: int = 12,
|
| 288 |
-
per_page: int = 3,
|
| 289 |
-
concurrency: int = 5,
|
| 290 |
-
) -> dict[str, dict]:
|
| 291 |
-
"""Fetch representative works for the strongest verified candidates."""
|
| 292 |
-
semaphore = asyncio.Semaphore(max(1, concurrency))
|
| 293 |
-
|
| 294 |
-
async def _one(journal: dict) -> tuple[str, dict]:
|
| 295 |
-
key = str(journal.get("id") or journal.get("scopus_id") or journal.get("issn") or journal.get("name"))
|
| 296 |
-
async with semaphore:
|
| 297 |
-
evidence = await similar_works_for_journal(
|
| 298 |
-
journal.get("name", ""),
|
| 299 |
-
journal.get("issn", ""),
|
| 300 |
-
query,
|
| 301 |
-
per_page=per_page,
|
| 302 |
-
)
|
| 303 |
-
return key, evidence
|
| 304 |
-
|
| 305 |
-
selected = journals[: max(0, limit)]
|
| 306 |
-
pairs = await asyncio.gather(*[_one(j) for j in selected]) if selected else []
|
| 307 |
-
return {key: evidence for key, evidence in pairs}
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAlex enrichment — journal metadata + peer percentile calculation.
|
| 3 |
+
Optional server-side API key raises the available request budget.
|
| 4 |
+
The obsolete sample-derived percentile is deliberately not reported.
|
| 5 |
+
|
| 6 |
+
Changes from v1.0:
|
| 7 |
+
- Shared module-level httpx.AsyncClient (init_client / close_client for lifespan)
|
| 8 |
+
- compute_percentile accepts an optional concept_cache dict to deduplicate
|
| 9 |
+
identical concept peer-set fetches across journals in one request
|
| 10 |
+
- _norm_issn removed — uses utils.norm_issn
|
| 11 |
+
"""
|
| 12 |
+
import httpx
|
| 13 |
+
import asyncio
|
| 14 |
+
import re
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
import logging
|
| 18 |
+
from collections import OrderedDict
|
| 19 |
+
from datetime import datetime, timezone
|
| 20 |
+
from typing import Optional
|
| 21 |
+
from utils import norm_issn
|
| 22 |
+
|
| 23 |
+
UA = "ResearchBee/1.8.2 (mailto:library@ku.ac.ae)"
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
_last_success = None
|
| 26 |
+
_last_error = None
|
| 27 |
+
_source_cache = OrderedDict()
|
| 28 |
+
BASE = "https://api.openalex.org"
|
| 29 |
+
|
| 30 |
+
_http_client: Optional[httpx.AsyncClient] = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def _record_response(response) -> None:
|
| 34 |
+
global _last_success, _last_error
|
| 35 |
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 36 |
+
if response.is_success:
|
| 37 |
+
_last_success = now
|
| 38 |
+
_last_error = None
|
| 39 |
+
else:
|
| 40 |
+
_last_error = {"status": response.status_code, "at": now}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def status() -> dict:
|
| 44 |
+
return {"api_key_configured": bool((os.getenv("OPENALEX_API_KEY") or "").strip()),
|
| 45 |
+
"last_success": _last_success, "last_error": _last_error,
|
| 46 |
+
"availability": "not_checked" if _last_success is None and _last_error is None else ("degraded" if _last_error else "available")}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def _authorize_request(request) -> None:
|
| 50 |
+
"""Enforce the provider boundary before attaching its credential."""
|
| 51 |
+
url = request.url
|
| 52 |
+
if url.scheme != "https" or url.host != "api.openalex.org" or url.port not in (None, 443):
|
| 53 |
+
request.headers.pop("Authorization", None)
|
| 54 |
+
raise ValueError("OpenAlex client refuses non-provider origins")
|
| 55 |
+
key = (os.getenv("OPENALEX_API_KEY") or "").strip()
|
| 56 |
+
if key:
|
| 57 |
+
request.headers["Authorization"] = "Bearer " + key
|
| 58 |
+
else:
|
| 59 |
+
request.headers.pop("Authorization", None)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _record_failure(exc: Exception) -> None:
|
| 63 |
+
global _last_error
|
| 64 |
+
_last_error = {"type": type(exc).__name__,
|
| 65 |
+
"at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
|
| 66 |
+
status_code = getattr(exc, "status_code", None)
|
| 67 |
+
if status_code is not None:
|
| 68 |
+
_last_error["status"] = status_code
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class OpenAlexUnavailable(RuntimeError):
|
| 72 |
+
def __init__(self, status_code=None):
|
| 73 |
+
self.status_code = status_code
|
| 74 |
+
super().__init__("OpenAlex is temporarily unavailable")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def init_client() -> None:
|
| 78 |
+
global _http_client
|
| 79 |
+
_http_client = httpx.AsyncClient(
|
| 80 |
+
timeout=httpx.Timeout(10.0, connect=5.0),
|
| 81 |
+
headers={"User-Agent": UA}, follow_redirects=False,
|
| 82 |
+
event_hooks={"request": [_authorize_request], "response": [_record_response]},
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def close_client() -> None:
|
| 87 |
+
global _http_client
|
| 88 |
+
if _http_client and not _http_client.is_closed:
|
| 89 |
+
await _http_client.aclose()
|
| 90 |
+
_http_client = None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _get_client() -> httpx.AsyncClient:
|
| 94 |
+
if _http_client is None or _http_client.is_closed:
|
| 95 |
+
init_client()
|
| 96 |
+
return _http_client
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]:
|
| 100 |
+
cache_key = (norm_issn(issn), name.strip().casefold())
|
| 101 |
+
cached = _source_cache.get(cache_key)
|
| 102 |
+
if cached and time.monotonic() - cached[0] < 3600:
|
| 103 |
+
_source_cache.move_to_end(cache_key)
|
| 104 |
+
return dict(cached[1])
|
| 105 |
+
client = _get_client()
|
| 106 |
+
try:
|
| 107 |
+
if issn:
|
| 108 |
+
clean = norm_issn(issn) or issn.strip()
|
| 109 |
+
r = await client.get(f"{BASE}/sources/issn:{clean}")
|
| 110 |
+
else:
|
| 111 |
+
r = await client.get(
|
| 112 |
+
f"{BASE}/sources", params={"search": name, "per_page": 1}
|
| 113 |
+
)
|
| 114 |
+
if not r.is_success:
|
| 115 |
+
return None
|
| 116 |
+
d = r.json()
|
| 117 |
+
s = d if issn else (d.get("results") or [None])[0]
|
| 118 |
+
if not s:
|
| 119 |
+
return None
|
| 120 |
+
# An ISSN request must be confirmed by the response, not merely the URL.
|
| 121 |
+
source_issns = {norm_issn(v) for v in [s.get("issn_l"), *(s.get("issn") or [])] if v}
|
| 122 |
+
if issn and norm_issn(issn) not in source_issns:
|
| 123 |
+
return None
|
| 124 |
+
if not issn:
|
| 125 |
+
from researchbee_journals import JournalIndex
|
| 126 |
+
if JournalIndex.normalise_identity_title(name) != JournalIndex.normalise_identity_title(s.get("display_name", "")):
|
| 127 |
+
return None
|
| 128 |
+
result = {
|
| 129 |
+
"source_updated_at": s.get("updated_date"),
|
| 130 |
+
"retrieved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 131 |
+
"openalex_id": s.get("id"),
|
| 132 |
+
"display_name": s.get("display_name"),
|
| 133 |
+
"issn_l": s.get("issn_l"),
|
| 134 |
+
"issns": s.get("issn", []),
|
| 135 |
+
"host_organization": s.get("host_organization_name"),
|
| 136 |
+
"is_oa": s.get("is_oa"),
|
| 137 |
+
"is_in_doaj": s.get("is_in_doaj"),
|
| 138 |
+
"works_count": s.get("works_count"),
|
| 139 |
+
"cited_by_count": s.get("cited_by_count"),
|
| 140 |
+
"h_index": (s.get("summary_stats") or {}).get("h_index"),
|
| 141 |
+
"two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"),
|
| 142 |
+
"homepage_url": s.get("homepage_url"),
|
| 143 |
+
"top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"),
|
| 144 |
+
"top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"),
|
| 145 |
+
}
|
| 146 |
+
_source_cache[cache_key] = (time.monotonic(), result)
|
| 147 |
+
while len(_source_cache) > 512:
|
| 148 |
+
_source_cache.popitem(last=False)
|
| 149 |
+
return dict(result)
|
| 150 |
+
except Exception as e:
|
| 151 |
+
_record_failure(e)
|
| 152 |
+
logger.warning("OpenAlex source lookup failed (%s)", type(e).__name__)
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def compute_percentile(openalex: dict, concept_cache: Optional[dict] = None) -> Optional[dict]:
|
| 157 |
+
"""A truncated high-citation peer sample cannot establish a field quartile."""
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def enrich_all(journals: list) -> list:
|
| 162 |
+
"""Enrich a list of journal dicts in parallel, deduplicating concept peer-sets."""
|
| 163 |
+
concept_cache: dict = {}
|
| 164 |
+
|
| 165 |
+
async def _enrich_one(j: dict) -> dict:
|
| 166 |
+
oa = await enrich_journal(j.get("name", ""), j.get("issn"))
|
| 167 |
+
pct = await compute_percentile(oa, concept_cache) if oa else None
|
| 168 |
+
j["openalex"] = oa
|
| 169 |
+
j["oa_percentile"] = pct
|
| 170 |
+
if not j.get("issn") and oa:
|
| 171 |
+
j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0]
|
| 172 |
+
return j
|
| 173 |
+
|
| 174 |
+
return list(await asyncio.gather(*[_enrich_one(j) for j in journals]))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
_EVIDENCE_STOP = {
|
| 178 |
+
"this", "that", "with", "from", "into", "using", "used", "study",
|
| 179 |
+
"research", "paper", "article", "results", "method", "methods", "based",
|
| 180 |
+
"analysis", "approach", "between", "among", "their", "these", "those",
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _evidence_terms(text: str) -> set[str]:
|
| 185 |
+
words = re.findall(r"[A-Za-z][A-Za-z\-]{3,}", (text or "").lower())
|
| 186 |
+
return {w.strip("-") for w in words if w not in _EVIDENCE_STOP}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _title_overlap(query: str, titles: list[str]) -> int:
|
| 190 |
+
q = _evidence_terms(query)
|
| 191 |
+
if not q or not titles:
|
| 192 |
+
return 0
|
| 193 |
+
scores = []
|
| 194 |
+
for title in titles:
|
| 195 |
+
t = _evidence_terms(title)
|
| 196 |
+
if not t:
|
| 197 |
+
continue
|
| 198 |
+
overlap = len(q & t)
|
| 199 |
+
# Dice coefficient, converted to 0..100. OpenAlex already orders the
|
| 200 |
+
# search by relevance; this score is only a transparent local signal.
|
| 201 |
+
scores.append((2 * overlap / max(len(q) + len(t), 1)) * 100)
|
| 202 |
+
return round(max(scores, default=0.0))
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
async def similar_works_for_journal(
|
| 206 |
+
name: str,
|
| 207 |
+
issn: str = "",
|
| 208 |
+
query: str = "",
|
| 209 |
+
*,
|
| 210 |
+
per_page: int = 3,
|
| 211 |
+
) -> dict:
|
| 212 |
+
"""Return representative recent works as evidence for journal fit.
|
| 213 |
+
|
| 214 |
+
Failure is explicitly labelled unavailable, separately from no matches. Journal
|
| 215 |
+
identity and ranking still come from the local verified database, so an
|
| 216 |
+
OpenAlex outage never removes or invents a recommendation.
|
| 217 |
+
"""
|
| 218 |
+
source = await enrich_journal(name, issn or None)
|
| 219 |
+
if not source or not source.get("openalex_id"):
|
| 220 |
+
return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
|
| 221 |
+
|
| 222 |
+
source_id = str(source["openalex_id"]).rsplit("/", 1)[-1]
|
| 223 |
+
if not re.fullmatch(r"S\d+", source_id):
|
| 224 |
+
return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
|
| 225 |
+
|
| 226 |
+
current_year = datetime.now(timezone.utc).year
|
| 227 |
+
query_text = " ".join((query or "").split())[:500]
|
| 228 |
+
client = _get_client()
|
| 229 |
+
|
| 230 |
+
async def _fetch(use_search: bool) -> list[dict]:
|
| 231 |
+
params = {
|
| 232 |
+
"filter": (
|
| 233 |
+
f"primary_location.source.id:{source_id},is_retracted:false,"
|
| 234 |
+
f"from_publication_date:{current_year - 5}-01-01"
|
| 235 |
+
),
|
| 236 |
+
"per_page": min(max(per_page, 1), 5),
|
| 237 |
+
"select": "id,title,publication_year,doi,cited_by_count,authorships",
|
| 238 |
+
}
|
| 239 |
+
if use_search and query_text:
|
| 240 |
+
params["search"] = query_text
|
| 241 |
+
params["sort"] = "relevance_score:desc"
|
| 242 |
+
else:
|
| 243 |
+
params["sort"] = "cited_by_count:desc"
|
| 244 |
+
response = await client.get(f"{BASE}/works", params=params, timeout=6.0)
|
| 245 |
+
if not response.is_success:
|
| 246 |
+
raise OpenAlexUnavailable(response.status_code)
|
| 247 |
+
works = []
|
| 248 |
+
for work in response.json().get("results", []):
|
| 249 |
+
title = " ".join(str(work.get("title") or "").split())
|
| 250 |
+
if not title:
|
| 251 |
+
continue
|
| 252 |
+
authors = [
|
| 253 |
+
(a.get("author") or {}).get("display_name", "")
|
| 254 |
+
for a in (work.get("authorships") or [])[:3]
|
| 255 |
+
]
|
| 256 |
+
works.append({
|
| 257 |
+
"title": title,
|
| 258 |
+
"year": work.get("publication_year"),
|
| 259 |
+
"doi": work.get("doi", ""),
|
| 260 |
+
"cited_by": work.get("cited_by_count", 0),
|
| 261 |
+
"authors": [a for a in authors if a],
|
| 262 |
+
"openalex_url": work.get("id", ""),
|
| 263 |
+
})
|
| 264 |
+
return works
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
works = await _fetch(bool(query_text))
|
| 268 |
+
if not works:
|
| 269 |
+
works = await _fetch(False)
|
| 270 |
+
score = _title_overlap(query_text, [w["title"] for w in works])
|
| 271 |
+
return {
|
| 272 |
+
"source": source,
|
| 273 |
+
"works": works,
|
| 274 |
+
"similarity_score": score,
|
| 275 |
+
"status": "available" if works else "no_recent_matches",
|
| 276 |
+
}
|
| 277 |
+
except Exception as exc:
|
| 278 |
+
_record_failure(exc)
|
| 279 |
+
logger.warning("OpenAlex evidence unavailable (%s)", type(exc).__name__)
|
| 280 |
+
return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
async def evidence_for_journals(
|
| 284 |
+
journals: list[dict],
|
| 285 |
+
query: str,
|
| 286 |
+
*,
|
| 287 |
+
limit: int = 12,
|
| 288 |
+
per_page: int = 3,
|
| 289 |
+
concurrency: int = 5,
|
| 290 |
+
) -> dict[str, dict]:
|
| 291 |
+
"""Fetch representative works for the strongest verified candidates."""
|
| 292 |
+
semaphore = asyncio.Semaphore(max(1, concurrency))
|
| 293 |
+
|
| 294 |
+
async def _one(journal: dict) -> tuple[str, dict]:
|
| 295 |
+
key = str(journal.get("id") or journal.get("scopus_id") or journal.get("issn") or journal.get("name"))
|
| 296 |
+
async with semaphore:
|
| 297 |
+
evidence = await similar_works_for_journal(
|
| 298 |
+
journal.get("name", ""),
|
| 299 |
+
journal.get("issn", ""),
|
| 300 |
+
query,
|
| 301 |
+
per_page=per_page,
|
| 302 |
+
)
|
| 303 |
+
return key, evidence
|
| 304 |
+
|
| 305 |
+
selected = journals[: max(0, limit)]
|
| 306 |
+
pairs = await asyncio.gather(*[_one(j) for j in selected]) if selected else []
|
| 307 |
+
return {key: evidence for key, evidence in pairs}
|
re3data.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
re3data.py — supplementary repository discovery from the re3data.org registry.
|
| 3 |
+
|
| 4 |
+
WHY THIS EXISTS
|
| 5 |
+
---------------
|
| 6 |
+
The curated KU Library registry (repositories.py) stays the primary source:
|
| 7 |
+
the model selects from it by id and every displayed fact comes from a
|
| 8 |
+
reviewed record. That registry is intentionally small, so a dataset in a
|
| 9 |
+
narrower field may only receive generalist suggestions.
|
| 10 |
+
|
| 11 |
+
This module adds a clearly separated "more options" list from re3data.org,
|
| 12 |
+
the Registry of Research Data Repositories (DataCite). It never changes the
|
| 13 |
+
curated recommendations, the deposit strategy, or Khazna's position.
|
| 14 |
+
|
| 15 |
+
EVIDENCE RULES
|
| 16 |
+
--------------
|
| 17 |
+
* Facts shown for these entries come only from the re3data record, labelled
|
| 18 |
+
as such, with the record's own last-update date. They are not reviewed by
|
| 19 |
+
KU Library and are never merged into curated entries.
|
| 20 |
+
* Repositories already in the curated registry, repositories with an end
|
| 21 |
+
date (closed), repositories whose data upload is closed, and purely
|
| 22 |
+
institutional repositories (usually restricted to their own staff) are
|
| 23 |
+
excluded.
|
| 24 |
+
* Only the researcher-entered *discipline* is sent to re3data, never the
|
| 25 |
+
dataset title, description or notes, which may be unpublished.
|
| 26 |
+
|
| 27 |
+
FAILURE MODEL
|
| 28 |
+
-------------
|
| 29 |
+
Every network, parsing or validation failure returns an "unavailable"
|
| 30 |
+
status and an empty list. Nothing here may raise into the route.
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import asyncio
|
| 35 |
+
import logging
|
| 36 |
+
import os
|
| 37 |
+
import re
|
| 38 |
+
import time
|
| 39 |
+
from collections import OrderedDict
|
| 40 |
+
from datetime import datetime, timezone
|
| 41 |
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
| 42 |
+
from urllib.parse import urlparse
|
| 43 |
+
import xml.etree.ElementTree as ET
|
| 44 |
+
|
| 45 |
+
import httpx
|
| 46 |
+
|
| 47 |
+
logger = logging.getLogger(__name__)
|
| 48 |
+
|
| 49 |
+
API_HOST = "www.re3data.org"
|
| 50 |
+
SEARCH_URL = f"https://{API_HOST}/api/beta/repositories"
|
| 51 |
+
DETAIL_URL = f"https://{API_HOST}/api/v1/repository/{{rid}}"
|
| 52 |
+
RECORD_URL = f"https://{API_HOST}/repository/{{rid}}"
|
| 53 |
+
UA = "ResearchBee/1.8.3 (mailto:library@ku.ac.ae)"
|
| 54 |
+
|
| 55 |
+
ID_RE = re.compile(r"^r3d\d{9}$")
|
| 56 |
+
MAX_RESPONSE_BYTES = 2_000_000
|
| 57 |
+
MAX_DETAIL_FETCHES = 20
|
| 58 |
+
DETAIL_CONCURRENCY = 6
|
| 59 |
+
SEARCH_TTL = 24 * 3600
|
| 60 |
+
FAILURE_TTL = 10 * 60
|
| 61 |
+
DETAIL_TTL = 7 * 24 * 3600
|
| 62 |
+
CACHE_MAX = 256
|
| 63 |
+
DETAIL_CACHE_MAX = 1024
|
| 64 |
+
|
| 65 |
+
_STOPWORDS = {
|
| 66 |
+
"and", "of", "the", "in", "for", "or", "a", "an", "to", "with", "on", "data",
|
| 67 |
+
"dataset", "datasets", "research", "science", "sciences", "studies", "study",
|
| 68 |
+
"general", "applied", "other", "misc", "miscellaneous",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
_http_client: Optional[httpx.AsyncClient] = None
|
| 72 |
+
_search_cache: "OrderedDict[str, Tuple[float, Any]]" = OrderedDict()
|
| 73 |
+
_detail_cache: "OrderedDict[str, Tuple[float, Optional[dict]]]" = OrderedDict()
|
| 74 |
+
_inflight: Dict[str, asyncio.Future] = {}
|
| 75 |
+
_last_success: Optional[str] = None
|
| 76 |
+
_last_error: Optional[dict] = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def enabled() -> bool:
|
| 80 |
+
return (os.getenv("RE3DATA_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"})
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _now_iso() -> str:
|
| 84 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def status() -> dict:
|
| 88 |
+
return {
|
| 89 |
+
"enabled": enabled(),
|
| 90 |
+
"last_success": _last_success,
|
| 91 |
+
"last_error": _last_error,
|
| 92 |
+
"availability": (
|
| 93 |
+
"disabled" if not enabled()
|
| 94 |
+
else "not_checked" if _last_success is None and _last_error is None
|
| 95 |
+
else "degraded" if _last_error else "available"
|
| 96 |
+
),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ── HTTP client lifecycle ─────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
async def _enforce_origin(request: httpx.Request) -> None:
|
| 103 |
+
url = request.url
|
| 104 |
+
if url.scheme != "https" or url.host != API_HOST or url.port not in (None, 443):
|
| 105 |
+
raise ValueError("re3data client refuses non-provider origins")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def init_client() -> None:
|
| 109 |
+
global _http_client
|
| 110 |
+
if _http_client is None:
|
| 111 |
+
_http_client = httpx.AsyncClient(
|
| 112 |
+
timeout=httpx.Timeout(6.0, connect=4.0),
|
| 113 |
+
headers={"User-Agent": UA, "Accept": "application/xml, text/xml"},
|
| 114 |
+
follow_redirects=False,
|
| 115 |
+
event_hooks={"request": [_enforce_origin]},
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def close_client() -> None:
|
| 120 |
+
global _http_client
|
| 121 |
+
if _http_client is not None:
|
| 122 |
+
await _http_client.aclose()
|
| 123 |
+
_http_client = None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _client() -> httpx.AsyncClient:
|
| 127 |
+
if _http_client is None:
|
| 128 |
+
init_client()
|
| 129 |
+
return _http_client # type: ignore[return-value]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ── Bounded caches ────────────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
def _cache_get(cache: OrderedDict, key: str):
|
| 135 |
+
item = cache.get(key)
|
| 136 |
+
if not item:
|
| 137 |
+
return None
|
| 138 |
+
expires, value = item
|
| 139 |
+
if expires < time.monotonic():
|
| 140 |
+
cache.pop(key, None)
|
| 141 |
+
return None
|
| 142 |
+
cache.move_to_end(key)
|
| 143 |
+
return item
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _cache_put(cache: OrderedDict, key: str, value: Any, ttl: float, limit: int) -> None:
|
| 147 |
+
cache[key] = (time.monotonic() + ttl, value)
|
| 148 |
+
cache.move_to_end(key)
|
| 149 |
+
while len(cache) > limit:
|
| 150 |
+
cache.popitem(last=False)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def clear_caches() -> None:
|
| 154 |
+
_search_cache.clear()
|
| 155 |
+
_detail_cache.clear()
|
| 156 |
+
_inflight.clear()
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── XML helpers (namespace-agnostic; the schema namespace has changed) ────
|
| 160 |
+
|
| 161 |
+
def _local(tag: Any) -> str:
|
| 162 |
+
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _safe_parse(content: bytes) -> Optional[ET.Element]:
|
| 166 |
+
if not content or len(content) > MAX_RESPONSE_BYTES:
|
| 167 |
+
return None
|
| 168 |
+
head = content[:4096].upper()
|
| 169 |
+
# re3data never needs a DTD; refusing one blocks entity-expansion tricks.
|
| 170 |
+
if b"<!DOCTYPE" in head or b"<!ENTITY" in content.upper():
|
| 171 |
+
return None
|
| 172 |
+
try:
|
| 173 |
+
return ET.fromstring(content)
|
| 174 |
+
except ET.ParseError:
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _children(node: ET.Element, name: str) -> List[ET.Element]:
|
| 179 |
+
return [child for child in list(node) if _local(child.tag) == name]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _text(node: Optional[ET.Element]) -> str:
|
| 183 |
+
if node is None:
|
| 184 |
+
return ""
|
| 185 |
+
return " ".join("".join(node.itertext()).split())
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _first_text(node: ET.Element, name: str) -> str:
|
| 189 |
+
for child in _children(node, name):
|
| 190 |
+
value = _text(child)
|
| 191 |
+
if value:
|
| 192 |
+
return value
|
| 193 |
+
return ""
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _all_text(node: ET.Element, name: str, limit: int = 20, width: int = 200) -> List[str]:
|
| 197 |
+
out: List[str] = []
|
| 198 |
+
for child in node.iter():
|
| 199 |
+
if _local(child.tag) == name:
|
| 200 |
+
value = _text(child)[:width]
|
| 201 |
+
if value and value not in out:
|
| 202 |
+
out.append(value)
|
| 203 |
+
if len(out) >= limit:
|
| 204 |
+
break
|
| 205 |
+
return out
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _https_url(value: str) -> str:
|
| 209 |
+
value = (value or "").strip()
|
| 210 |
+
try:
|
| 211 |
+
parsed = urlparse(value)
|
| 212 |
+
except ValueError:
|
| 213 |
+
return ""
|
| 214 |
+
if parsed.scheme != "https" or not parsed.hostname or "." not in parsed.hostname:
|
| 215 |
+
return ""
|
| 216 |
+
if parsed.username or parsed.password or (parsed.port not in (None, 443)):
|
| 217 |
+
return ""
|
| 218 |
+
return value[:500]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _host(value: str) -> str:
|
| 222 |
+
try:
|
| 223 |
+
host = (urlparse(value).hostname or "").lower()
|
| 224 |
+
except ValueError:
|
| 225 |
+
return ""
|
| 226 |
+
return host[4:] if host.startswith("www.") else host
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def parse_search(content: bytes) -> List[dict]:
|
| 230 |
+
"""Parse a search/list response into [{"id", "name", "doi"}]."""
|
| 231 |
+
root = _safe_parse(content)
|
| 232 |
+
if root is None:
|
| 233 |
+
return []
|
| 234 |
+
out: List[dict] = []
|
| 235 |
+
seen = set()
|
| 236 |
+
for node in root.iter():
|
| 237 |
+
if _local(node.tag) != "repository":
|
| 238 |
+
continue
|
| 239 |
+
rid = _first_text(node, "id")
|
| 240 |
+
if not ID_RE.match(rid) or rid in seen:
|
| 241 |
+
continue
|
| 242 |
+
seen.add(rid)
|
| 243 |
+
out.append({
|
| 244 |
+
"id": rid,
|
| 245 |
+
"name": _first_text(node, "name")[:300],
|
| 246 |
+
"doi": _first_text(node, "doi")[:200],
|
| 247 |
+
})
|
| 248 |
+
return out
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _yes_no_unknown(value: str) -> str:
|
| 252 |
+
value = (value or "").strip().lower()
|
| 253 |
+
if value == "yes":
|
| 254 |
+
return "Yes"
|
| 255 |
+
if value == "no":
|
| 256 |
+
return "No"
|
| 257 |
+
return "Unknown"
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
_SUBJECT_CODE = re.compile(r"^\s*\d+\s+")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def parse_detail(content: bytes) -> Optional[dict]:
|
| 264 |
+
"""Parse one re3data repository record. Returns None if unusable."""
|
| 265 |
+
root = _safe_parse(content)
|
| 266 |
+
if root is None:
|
| 267 |
+
return None
|
| 268 |
+
repo = root if _local(root.tag) == "repository" else next(
|
| 269 |
+
(node for node in root.iter() if _local(node.tag) == "repository"), None
|
| 270 |
+
)
|
| 271 |
+
if repo is None:
|
| 272 |
+
return None
|
| 273 |
+
rid = _first_text(repo, "re3data.orgIdentifier")
|
| 274 |
+
name = _first_text(repo, "repositoryName")
|
| 275 |
+
if not ID_RE.match(rid) or not name:
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
subjects: List[str] = []
|
| 279 |
+
for value in _all_text(repo, "subject", limit=30):
|
| 280 |
+
clean = _SUBJECT_CODE.sub("", value).strip()
|
| 281 |
+
if clean and clean not in subjects:
|
| 282 |
+
subjects.append(clean)
|
| 283 |
+
|
| 284 |
+
pid_systems = [p for p in _all_text(repo, "pidSystem", limit=8, width=40) if p.lower() != "none"]
|
| 285 |
+
upload_types = [v.lower() for v in _all_text(repo, "dataUploadType", limit=5, width=30)]
|
| 286 |
+
access_types = [v.lower() for v in _all_text(repo, "dataAccessType", limit=5, width=30)]
|
| 287 |
+
description = _first_text(repo, "description")
|
| 288 |
+
if len(description) > 600:
|
| 289 |
+
description = description[:597].rsplit(" ", 1)[0] + "…"
|
| 290 |
+
|
| 291 |
+
return {
|
| 292 |
+
"id": rid,
|
| 293 |
+
"name": name[:300],
|
| 294 |
+
"url": _https_url(_first_text(repo, "repositoryURL")),
|
| 295 |
+
"description": description,
|
| 296 |
+
"types": [t.lower() for t in _all_text(repo, "type", limit=5, width=40)],
|
| 297 |
+
"subjects": subjects[:10],
|
| 298 |
+
"keywords": _all_text(repo, "keyword", limit=15, width=80),
|
| 299 |
+
"content_types": [_SUBJECT_CODE.sub("", c) for c in _all_text(repo, "contentType", limit=10, width=120)],
|
| 300 |
+
"pid_systems": pid_systems,
|
| 301 |
+
"versioning": _yes_no_unknown(_first_text(repo, "versioning")),
|
| 302 |
+
"data_upload": upload_types,
|
| 303 |
+
"data_access": access_types,
|
| 304 |
+
"certificates": _all_text(repo, "certificate", limit=5, width=80),
|
| 305 |
+
"countries": _all_text(repo, "institutionCountry", limit=5, width=10),
|
| 306 |
+
"end_date": _first_text(repo, "endDate")[:30],
|
| 307 |
+
"last_update": _first_text(repo, "lastUpdate")[:30],
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# ── Network operations ────────────────────────────────────────────────────
|
| 312 |
+
|
| 313 |
+
class Re3dataUnavailable(RuntimeError):
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
async def _get(url: str, params: Optional[dict] = None) -> bytes:
|
| 318 |
+
global _last_success, _last_error
|
| 319 |
+
try:
|
| 320 |
+
response = await _client().get(url, params=params)
|
| 321 |
+
except (httpx.HTTPError, ValueError) as exc:
|
| 322 |
+
_last_error = {"type": type(exc).__name__, "at": _now_iso()}
|
| 323 |
+
raise Re3dataUnavailable() from None
|
| 324 |
+
if response.status_code != 200:
|
| 325 |
+
_last_error = {"status": response.status_code, "at": _now_iso()}
|
| 326 |
+
raise Re3dataUnavailable()
|
| 327 |
+
content = response.content
|
| 328 |
+
if len(content) > MAX_RESPONSE_BYTES:
|
| 329 |
+
_last_error = {"type": "ResponseTooLarge", "at": _now_iso()}
|
| 330 |
+
raise Re3dataUnavailable()
|
| 331 |
+
_last_success = _now_iso()
|
| 332 |
+
_last_error = None
|
| 333 |
+
return content
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def search(query: str) -> List[dict]:
|
| 337 |
+
"""Search re3data; cached. Raises Re3dataUnavailable on failure."""
|
| 338 |
+
key = " ".join(query.lower().split())[:120]
|
| 339 |
+
cached = _cache_get(_search_cache, key)
|
| 340 |
+
if cached is not None:
|
| 341 |
+
value = cached[1]
|
| 342 |
+
if isinstance(value, Exception):
|
| 343 |
+
raise Re3dataUnavailable()
|
| 344 |
+
return value
|
| 345 |
+
pending = _inflight.get("s:" + key)
|
| 346 |
+
if pending is not None:
|
| 347 |
+
return await asyncio.shield(pending)
|
| 348 |
+
|
| 349 |
+
loop = asyncio.get_running_loop()
|
| 350 |
+
future: asyncio.Future = loop.create_future()
|
| 351 |
+
_inflight["s:" + key] = future
|
| 352 |
+
try:
|
| 353 |
+
content = await _get(SEARCH_URL, params={"query": key})
|
| 354 |
+
hits = parse_search(content)
|
| 355 |
+
_cache_put(_search_cache, key, hits, SEARCH_TTL, CACHE_MAX)
|
| 356 |
+
future.set_result(hits)
|
| 357 |
+
return hits
|
| 358 |
+
except Exception as exc:
|
| 359 |
+
_cache_put(_search_cache, key, Re3dataUnavailable(), FAILURE_TTL, CACHE_MAX)
|
| 360 |
+
future.set_exception(Re3dataUnavailable())
|
| 361 |
+
future.exception() # mark retrieved; concurrent waiters still see it
|
| 362 |
+
raise Re3dataUnavailable() from None
|
| 363 |
+
finally:
|
| 364 |
+
_inflight.pop("s:" + key, None)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
async def detail(rid: str) -> Optional[dict]:
|
| 368 |
+
"""Fetch one record; cached. Returns None when unavailable."""
|
| 369 |
+
if not ID_RE.match(rid or ""):
|
| 370 |
+
return None
|
| 371 |
+
cached = _cache_get(_detail_cache, rid)
|
| 372 |
+
if cached is not None:
|
| 373 |
+
return cached[1]
|
| 374 |
+
try:
|
| 375 |
+
content = await _get(DETAIL_URL.format(rid=rid))
|
| 376 |
+
except Re3dataUnavailable:
|
| 377 |
+
_cache_put(_detail_cache, rid, None, FAILURE_TTL, DETAIL_CACHE_MAX)
|
| 378 |
+
return None
|
| 379 |
+
record = parse_detail(content)
|
| 380 |
+
_cache_put(_detail_cache, rid, record, DETAIL_TTL if record else FAILURE_TTL, DETAIL_CACHE_MAX)
|
| 381 |
+
return record
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ── Selection ─────────────────────────────────────────────────────────────
|
| 385 |
+
|
| 386 |
+
def _tokens(text: str) -> List[str]:
|
| 387 |
+
out: List[str] = []
|
| 388 |
+
for token in re.findall(r"[a-z0-9]+", (text or "").lower()):
|
| 389 |
+
if len(token) > 2 and token not in _STOPWORDS and token not in out:
|
| 390 |
+
out.append(token)
|
| 391 |
+
return out
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _stem(token: str) -> str:
|
| 395 |
+
for suffix in ("ies", "es", "s"):
|
| 396 |
+
if token.endswith(suffix) and len(token) - len(suffix) >= 4:
|
| 397 |
+
return token[: -len(suffix)] + ("y" if suffix == "ies" else "")
|
| 398 |
+
return token
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _matches(tokens: Iterable[str], text: str) -> List[str]:
|
| 402 |
+
words = {_stem(w) for w in re.findall(r"[a-z0-9]+", (text or "").lower())}
|
| 403 |
+
return [t for t in tokens if _stem(t) in words]
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def build_query(discipline: str) -> str:
|
| 407 |
+
"""Only the user's discipline field is sent; bounded and normalised."""
|
| 408 |
+
text = " ".join(str(discipline or "").split())
|
| 409 |
+
text = re.sub(r"[^\w\s\-&,/()]", " ", text, flags=re.UNICODE)
|
| 410 |
+
return " ".join(text.split())[:100]
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _is_sensitive(constraints: dict) -> bool:
|
| 414 |
+
sensitivity = str(constraints.get("sensitivity") or "").strip().lower()
|
| 415 |
+
personal = str(constraints.get("contains_personal_data") or "").strip().lower()
|
| 416 |
+
return (bool(sensitivity) and sensitivity not in {"open", "none", "non-sensitive", "not sensitive"}) or (
|
| 417 |
+
bool(personal) and personal != "no"
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _excluded_by_registry(record: dict, curated: Iterable[dict]) -> bool:
|
| 422 |
+
name = " ".join(record.get("name", "").lower().split())
|
| 423 |
+
host = _host(record.get("url", ""))
|
| 424 |
+
for entry in curated:
|
| 425 |
+
entry_name = " ".join(str(entry.get("name", "")).lower().split())
|
| 426 |
+
if name and (name == entry_name or (len(entry_name) > 4 and entry_name in name)):
|
| 427 |
+
return True
|
| 428 |
+
entry_host = _host(str(entry.get("url", "")))
|
| 429 |
+
if host and entry_host and (host == entry_host or host.endswith("." + entry_host)):
|
| 430 |
+
return True
|
| 431 |
+
return False
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def score_record(record: dict, tokens: List[str]) -> Tuple[int, List[str]]:
|
| 435 |
+
"""Relevance from subject/keyword/name evidence plus deposit-readiness."""
|
| 436 |
+
subject_text = " ".join(record.get("subjects", []) + record.get("keywords", []) + [record.get("name", "")])
|
| 437 |
+
strong = _matches(tokens, subject_text)
|
| 438 |
+
weak = [t for t in _matches(tokens, record.get("description", "")) if t not in strong]
|
| 439 |
+
if not strong:
|
| 440 |
+
return 0, []
|
| 441 |
+
score = 10 * len(strong) + 3 * len(weak)
|
| 442 |
+
pids = {p.lower() for p in record.get("pid_systems", [])}
|
| 443 |
+
if "doi" in pids:
|
| 444 |
+
score += 4
|
| 445 |
+
if record.get("certificates"):
|
| 446 |
+
score += 3
|
| 447 |
+
if "open" in record.get("data_upload", []):
|
| 448 |
+
score += 2
|
| 449 |
+
if record.get("versioning") == "Yes":
|
| 450 |
+
score += 1
|
| 451 |
+
return score, strong + weak
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _public_entry(record: dict, matched: List[str], sensitive: bool) -> dict:
|
| 455 |
+
pids = record.get("pid_systems", [])
|
| 456 |
+
upload = record.get("data_upload", [])
|
| 457 |
+
access = record.get("data_access", [])
|
| 458 |
+
notes = [
|
| 459 |
+
"Listed in re3data.org; not reviewed by KU Library. Confirm deposit eligibility, costs, "
|
| 460 |
+
"licences and file limits on the repository's own site before uploading."
|
| 461 |
+
]
|
| 462 |
+
if upload and "open" not in upload:
|
| 463 |
+
notes.append("re3data lists data upload as restricted; depositor eligibility must be confirmed.")
|
| 464 |
+
if sensitive:
|
| 465 |
+
notes.append(
|
| 466 |
+
"Your dataset is marked sensitive or personal: KU Library and the responsible ethics/data-protection "
|
| 467 |
+
"team must approve any repository and access conditions before upload."
|
| 468 |
+
)
|
| 469 |
+
last_update = record.get("last_update", "")
|
| 470 |
+
return {
|
| 471 |
+
"id": record["id"],
|
| 472 |
+
"name": record["name"],
|
| 473 |
+
"url": record.get("url", ""),
|
| 474 |
+
"re3data_url": RECORD_URL.format(rid=record["id"]),
|
| 475 |
+
"description": record.get("description", ""),
|
| 476 |
+
"repository_types": record.get("types", []),
|
| 477 |
+
"subjects": record.get("subjects", [])[:6],
|
| 478 |
+
"content_types": record.get("content_types", [])[:6],
|
| 479 |
+
"persistent_identifier": ", ".join(pids) if pids else "Not listed",
|
| 480 |
+
"doi_assignment": "Yes" if any(p.lower() == "doi" for p in pids) else "Not listed",
|
| 481 |
+
"versioning": record.get("versioning", "Unknown"),
|
| 482 |
+
"data_upload": ", ".join(u.capitalize() for u in upload) if upload else "Not listed",
|
| 483 |
+
"data_access": ", ".join(a.capitalize() for a in access) if access else "Not listed",
|
| 484 |
+
"certificates": record.get("certificates", []),
|
| 485 |
+
"countries": record.get("countries", []),
|
| 486 |
+
"last_update": last_update,
|
| 487 |
+
"matched_terms": matched[:6],
|
| 488 |
+
"source": "re3data",
|
| 489 |
+
"source_note": (
|
| 490 |
+
"Details as recorded by re3data.org"
|
| 491 |
+
+ (f" (record last updated {last_update[:10]})" if last_update else "")
|
| 492 |
+
+ "."
|
| 493 |
+
),
|
| 494 |
+
"review_notes": notes,
|
| 495 |
+
"file_deposit_recommended": False,
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
async def find_supplementary(
|
| 500 |
+
discipline: str,
|
| 501 |
+
constraints: Optional[dict] = None,
|
| 502 |
+
curated: Iterable[dict] = (),
|
| 503 |
+
limit: int = 5,
|
| 504 |
+
) -> dict:
|
| 505 |
+
"""Return {"status", "query", "repositories", "message", "retrieved_at"}.
|
| 506 |
+
|
| 507 |
+
Never raises. ``curated`` is the KU registry, used only for de-duplication.
|
| 508 |
+
"""
|
| 509 |
+
constraints = constraints if isinstance(constraints, dict) else {}
|
| 510 |
+
query = build_query(discipline)
|
| 511 |
+
base = {"status": "skipped", "query": query, "repositories": [], "message": "",
|
| 512 |
+
"source": "re3data.org", "source_url": "https://www.re3data.org",
|
| 513 |
+
"retrieved_at": _now_iso()}
|
| 514 |
+
if not enabled():
|
| 515 |
+
return {**base, "status": "disabled",
|
| 516 |
+
"message": "re3data search is switched off for this service."}
|
| 517 |
+
tokens = _tokens(query)
|
| 518 |
+
if not tokens:
|
| 519 |
+
return {**base, "message": "Enter a discipline to search re3data for additional repositories."}
|
| 520 |
+
|
| 521 |
+
curated = list(curated)
|
| 522 |
+
try:
|
| 523 |
+
hits = await search(query)
|
| 524 |
+
if len(hits) < 3 and len(tokens) > 1:
|
| 525 |
+
# A multi-word discipline can be too narrow for the registry's
|
| 526 |
+
# search; retry once with the most distinctive (longest) word.
|
| 527 |
+
extra = await search(max(tokens, key=len))
|
| 528 |
+
seen = {h["id"] for h in hits}
|
| 529 |
+
hits += [h for h in extra if h["id"] not in seen]
|
| 530 |
+
except Re3dataUnavailable:
|
| 531 |
+
return {**base, "status": "unavailable",
|
| 532 |
+
"message": "re3data.org could not be reached. The curated KU Library recommendations are unaffected."}
|
| 533 |
+
except Exception as exc: # defensive: never break the repository finder
|
| 534 |
+
logger.warning("[re3data] search failed (%s)", type(exc).__name__)
|
| 535 |
+
return {**base, "status": "unavailable",
|
| 536 |
+
"message": "re3data.org could not be searched. The curated KU Library recommendations are unaffected."}
|
| 537 |
+
|
| 538 |
+
if not hits:
|
| 539 |
+
return {**base, "status": "no_matches",
|
| 540 |
+
"message": f'No additional re3data repositories were found for "{query}".'}
|
| 541 |
+
|
| 542 |
+
# Fetch the hits most likely to be relevant first: name matches lead.
|
| 543 |
+
ordered = sorted(enumerate(hits), key=lambda item: (-len(_matches(tokens, item[1].get("name", ""))), item[0]))
|
| 544 |
+
candidates = [hit for _i, hit in ordered[:MAX_DETAIL_FETCHES]]
|
| 545 |
+
semaphore = asyncio.Semaphore(DETAIL_CONCURRENCY)
|
| 546 |
+
|
| 547 |
+
async def fetch(rid: str) -> Optional[dict]:
|
| 548 |
+
async with semaphore:
|
| 549 |
+
try:
|
| 550 |
+
return await detail(rid)
|
| 551 |
+
except Exception as exc: # defensive
|
| 552 |
+
logger.warning("[re3data] detail failed (%s)", type(exc).__name__)
|
| 553 |
+
return None
|
| 554 |
+
|
| 555 |
+
records = await asyncio.gather(*(fetch(hit["id"]) for hit in candidates))
|
| 556 |
+
sensitive = _is_sensitive(constraints)
|
| 557 |
+
scored: List[Tuple[int, str, dict, List[str]]] = []
|
| 558 |
+
for record in records:
|
| 559 |
+
if not record:
|
| 560 |
+
continue
|
| 561 |
+
if record.get("end_date"):
|
| 562 |
+
continue # closed repository
|
| 563 |
+
upload = record.get("data_upload", [])
|
| 564 |
+
if upload and all(u == "closed" for u in upload):
|
| 565 |
+
continue # does not accept deposits
|
| 566 |
+
types = set(record.get("types", []))
|
| 567 |
+
if types and types <= {"institutional"}:
|
| 568 |
+
continue # usually limited to that institution's own members
|
| 569 |
+
if _excluded_by_registry(record, curated):
|
| 570 |
+
continue
|
| 571 |
+
if sensitive:
|
| 572 |
+
access = set(record.get("data_access", []))
|
| 573 |
+
if not access & {"restricted", "embargoed", "closed"}:
|
| 574 |
+
continue # no controlled-access route recorded
|
| 575 |
+
score, matched = score_record(record, tokens)
|
| 576 |
+
if score <= 0:
|
| 577 |
+
continue
|
| 578 |
+
scored.append((score, record["name"].lower(), record, matched))
|
| 579 |
+
|
| 580 |
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
| 581 |
+
picked = [_public_entry(record, matched, sensitive) for _s, _n, record, matched in scored[: max(0, limit)]]
|
| 582 |
+
if not picked:
|
| 583 |
+
return {**base, "status": "no_matches",
|
| 584 |
+
"message": f're3data returned results for "{query}", but none passed the subject and deposit checks.'}
|
| 585 |
+
return {**base, "status": "ok", "repositories": picked,
|
| 586 |
+
"message": (
|
| 587 |
+
f'{len(picked)} additional repositor{"y" if len(picked) == 1 else "ies"} from re3data.org matched "{query}". '
|
| 588 |
+
"These are registry listings, not KU Library recommendations."
|
| 589 |
+
)}
|
repositories.py
CHANGED
|
@@ -1,708 +1,708 @@
|
|
| 1 |
-
"""
|
| 2 |
-
repositories.py — curated registry of research data repositories.
|
| 3 |
-
|
| 4 |
-
WHY THIS EXISTS
|
| 5 |
-
---------------
|
| 6 |
-
The previous /api/find-repository asked the LLM to produce repository facts:
|
| 7 |
-
max file size, certification, cost, persistent identifier type. Those are
|
| 8 |
-
exactly the kind of specific, checkable claims a model gets subtly wrong, and
|
| 9 |
-
a wrong "50 GB limit" or "CoreTrustSeal certified" in a library tool is a
|
| 10 |
-
credibility problem.
|
| 11 |
-
|
| 12 |
-
Here the LLM only SELECTS entries by id and writes a fit_reason. Every factual
|
| 13 |
-
field below is served from this file and overwrites whatever the model says.
|
| 14 |
-
|
| 15 |
-
MAINTENANCE
|
| 16 |
-
-----------
|
| 17 |
-
Each entry carries `source_url` and `verified` (YYYY-MM). Re-check quarterly.
|
| 18 |
-
Fields you are unsure of should be left as "" rather than guessed — the UI
|
| 19 |
-
renders empty fields as "Not specified", which is honest.
|
| 20 |
-
|
| 21 |
-
Selection is deliberately KU-flavoured: engineering, energy, materials,
|
| 22 |
-
petroleum, AI/CS and biomedical are weighted because that is KU's output
|
| 23 |
-
profile.
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
from __future__ import annotations
|
| 27 |
-
|
| 28 |
-
from typing import Any
|
| 29 |
-
import json
|
| 30 |
-
import re
|
| 31 |
-
|
| 32 |
-
# ── Registry ───────────────────────────────────────────────────────────────
|
| 33 |
-
# NOTE: max_file_size / certification are left blank where not personally
|
| 34 |
-
# verified. Fill them in from source_url and bump `verified`.
|
| 35 |
-
|
| 36 |
-
REGISTRY: dict[str, dict[str, Any]] = {
|
| 37 |
-
# ---- Institutional ----------------------------------------------------
|
| 38 |
-
"khazna": {
|
| 39 |
-
"id": "khazna",
|
| 40 |
-
"name": "Khazna",
|
| 41 |
-
"type": "Institutional",
|
| 42 |
-
"url": "https://khazna.ku.ac.ae",
|
| 43 |
-
"scope": "Khalifa University research outputs, datasets and theses across all disciplines.",
|
| 44 |
-
"data_types_accepted": "Confirm file-deposit support with KU Library",
|
| 45 |
-
"persistent_identifier": "",
|
| 46 |
-
"versioning": "",
|
| 47 |
-
"embargo_support": "",
|
| 48 |
-
"cost": "Free for KU researchers",
|
| 49 |
-
"curation": "Mediated — library review before publication",
|
| 50 |
-
"source_url": "https://khazna.ku.ac.ae",
|
| 51 |
-
"verified": "2026-07",
|
| 52 |
-
"is_khazna": True,
|
| 53 |
-
"disciplines": ["all"],
|
| 54 |
-
},
|
| 55 |
-
|
| 56 |
-
# ---- Generalist -------------------------------------------------------
|
| 57 |
-
"zenodo": {
|
| 58 |
-
"id": "zenodo", "name": "Zenodo", "type": "Generalist",
|
| 59 |
-
"url": "https://zenodo.org",
|
| 60 |
-
"scope": "Multidisciplinary repository operated by CERN; accepts data, software, publications.",
|
| 61 |
-
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 62 |
-
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
|
| 63 |
-
"curation": "Unmediated",
|
| 64 |
-
"source_url": "https://help.zenodo.org", "verified": "2026-07",
|
| 65 |
-
"disciplines": ["all"],
|
| 66 |
-
},
|
| 67 |
-
"figshare": {
|
| 68 |
-
"id": "figshare", "name": "Figshare", "type": "Generalist",
|
| 69 |
-
"url": "https://figshare.com",
|
| 70 |
-
"scope": "Multidisciplinary repository for figures, datasets, media and preprints.",
|
| 71 |
-
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 72 |
-
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
|
| 73 |
-
"curation": "Unmediated",
|
| 74 |
-
"source_url": "https://help.figshare.com", "verified": "2026-07",
|
| 75 |
-
"disciplines": ["all"],
|
| 76 |
-
},
|
| 77 |
-
"dryad": {
|
| 78 |
-
"id": "dryad", "name": "Dryad", "type": "Generalist",
|
| 79 |
-
"url": "https://datadryad.org",
|
| 80 |
-
"scope": "Curated repository for data underlying published research, strong in life and environmental sciences.",
|
| 81 |
-
"data_types_accepted": "Research data (not software)", "persistent_identifier": "DOI",
|
| 82 |
-
"versioning": "Yes", "embargo_support": "Yes",
|
| 83 |
-
"cost": "Fee-based; free where the institution is a member",
|
| 84 |
-
"curation": "Mediated — curator review",
|
| 85 |
-
"source_url": "https://datadryad.org/stash/faq", "verified": "2026-07",
|
| 86 |
-
"disciplines": ["life sciences", "environmental", "ecology", "biology"],
|
| 87 |
-
},
|
| 88 |
-
"dataverse": {
|
| 89 |
-
"id": "dataverse", "name": "Harvard Dataverse", "type": "Generalist",
|
| 90 |
-
"url": "https://dataverse.harvard.edu",
|
| 91 |
-
"scope": "Multidisciplinary repository with strong social science and statistical tooling.",
|
| 92 |
-
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 93 |
-
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
|
| 94 |
-
"curation": "Unmediated",
|
| 95 |
-
"source_url": "https://support.dataverse.harvard.edu", "verified": "2026-07",
|
| 96 |
-
"disciplines": ["social sciences", "economics", "political science", "all"],
|
| 97 |
-
},
|
| 98 |
-
"osf": {
|
| 99 |
-
"id": "osf", "name": "Open Science Framework (OSF)", "type": "Generalist",
|
| 100 |
-
"url": "https://osf.io",
|
| 101 |
-
"scope": "Project-based platform combining data, preprints, preregistration and materials.",
|
| 102 |
-
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 103 |
-
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
|
| 104 |
-
"curation": "Unmediated",
|
| 105 |
-
"source_url": "https://help.osf.io", "verified": "2026-07",
|
| 106 |
-
"disciplines": ["psychology", "social sciences", "all"],
|
| 107 |
-
},
|
| 108 |
-
"mendeley_data": {
|
| 109 |
-
"id": "mendeley_data", "name": "Mendeley Data", "type": "Generalist",
|
| 110 |
-
"url": "https://data.mendeley.com",
|
| 111 |
-
"scope": "Multidisciplinary data repository operated by Elsevier.",
|
| 112 |
-
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 113 |
-
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
|
| 114 |
-
"curation": "Unmediated",
|
| 115 |
-
"source_url": "https://data.mendeley.com", "verified": "2026-07",
|
| 116 |
-
"disciplines": ["all"],
|
| 117 |
-
},
|
| 118 |
-
|
| 119 |
-
# ---- Engineering / energy / materials — KU core ------------------------
|
| 120 |
-
"materials_cloud": {
|
| 121 |
-
"id": "materials_cloud", "name": "Materials Cloud", "type": "Domain-specific",
|
| 122 |
-
"url": "https://www.materialscloud.org",
|
| 123 |
-
"scope": "Computational materials science data, workflows and simulations.",
|
| 124 |
-
"data_types_accepted": "Simulation data, structures, workflows",
|
| 125 |
-
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 126 |
-
"cost": "Free", "curation": "Mediated",
|
| 127 |
-
"source_url": "https://www.materialscloud.org/home", "verified": "2026-07",
|
| 128 |
-
"disciplines": ["materials", "chemistry", "physics", "computational"],
|
| 129 |
-
},
|
| 130 |
-
"nomad": {
|
| 131 |
-
"id": "nomad", "name": "NOMAD", "type": "Domain-specific",
|
| 132 |
-
"url": "https://nomad-lab.eu",
|
| 133 |
-
"scope": "Repository for computational materials science, especially DFT calculations.",
|
| 134 |
-
"data_types_accepted": "Electronic-structure calculation outputs",
|
| 135 |
-
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 136 |
-
"cost": "Free", "curation": "Unmediated",
|
| 137 |
-
"source_url": "https://nomad-lab.eu", "verified": "2026-07",
|
| 138 |
-
"disciplines": ["materials", "physics", "chemistry", "computational"],
|
| 139 |
-
},
|
| 140 |
-
"oedi": {
|
| 141 |
-
"id": "oedi", "name": "Open Energy Data Initiative (OEDI)", "type": "Domain-specific",
|
| 142 |
-
"url": "https://data.openei.org",
|
| 143 |
-
"scope": "Energy systems, renewables, grid and buildings data (US DOE).",
|
| 144 |
-
"data_types_accepted": "Energy datasets, time series, geospatial",
|
| 145 |
-
"persistent_identifier": "DOI", "versioning": "", "embargo_support": "",
|
| 146 |
-
"cost": "Free", "curation": "Mediated",
|
| 147 |
-
"source_url": "https://data.openei.org", "verified": "2026-07",
|
| 148 |
-
"disciplines": ["energy", "renewables", "engineering", "sustainability"],
|
| 149 |
-
},
|
| 150 |
-
"ieee_dataport": {
|
| 151 |
-
"id": "ieee_dataport", "name": "IEEE DataPort", "type": "Domain-specific",
|
| 152 |
-
"url": "https://ieee-dataport.org",
|
| 153 |
-
"scope": "Datasets across electrical engineering, computing, communications and signal processing.",
|
| 154 |
-
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 155 |
-
"versioning": "Yes", "embargo_support": "Yes",
|
| 156 |
-
"cost": "Free for open datasets; subscription for some features",
|
| 157 |
-
"curation": "Unmediated",
|
| 158 |
-
"source_url": "https://ieee-dataport.org", "verified": "2026-07",
|
| 159 |
-
"disciplines": ["engineering", "electrical", "computing", "communications", "signal processing"],
|
| 160 |
-
},
|
| 161 |
-
|
| 162 |
-
# ---- Earth / environment / geospatial ---------------------------------
|
| 163 |
-
"pangaea": {
|
| 164 |
-
"id": "pangaea", "name": "PANGAEA", "type": "Domain-specific",
|
| 165 |
-
"url": "https://www.pangaea.de",
|
| 166 |
-
"scope": "Earth and environmental science data; georeferenced observations.",
|
| 167 |
-
"data_types_accepted": "Georeferenced environmental data",
|
| 168 |
-
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 169 |
-
"cost": "Free", "curation": "Mediated — editorial review",
|
| 170 |
-
"source_url": "https://www.pangaea.de/submit/", "verified": "2026-07",
|
| 171 |
-
"disciplines": ["earth science", "environmental", "climate", "oceanography", "geoscience"],
|
| 172 |
-
},
|
| 173 |
-
"esa_earth": {
|
| 174 |
-
"id": "esa_earth", "name": "ESA Earth Online", "type": "Domain-specific",
|
| 175 |
-
"url": "https://earth.esa.int",
|
| 176 |
-
"scope": "Earth observation and satellite remote-sensing datasets.",
|
| 177 |
-
"data_types_accepted": "Satellite imagery, EO products",
|
| 178 |
-
"persistent_identifier": "", "versioning": "", "embargo_support": "",
|
| 179 |
-
"cost": "Free", "curation": "Mediated",
|
| 180 |
-
"source_url": "https://earth.esa.int", "verified": "2026-07",
|
| 181 |
-
"disciplines": ["remote sensing", "earth observation", "geospatial", "climate"],
|
| 182 |
-
},
|
| 183 |
-
|
| 184 |
-
# ---- Life sciences / biomedical ---------------------------------------
|
| 185 |
-
"ena": {
|
| 186 |
-
"id": "ena", "name": "European Nucleotide Archive (ENA)", "type": "Domain-specific",
|
| 187 |
-
"url": "https://www.ebi.ac.uk/ena",
|
| 188 |
-
"scope": "Nucleotide sequencing data — raw reads, assemblies, annotations.",
|
| 189 |
-
"data_types_accepted": "Sequence data (FASTQ, BAM, assemblies)",
|
| 190 |
-
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 191 |
-
"cost": "Free", "curation": "Mediated — format validation",
|
| 192 |
-
"source_url": "https://www.ebi.ac.uk/ena/browser/submit", "verified": "2026-07",
|
| 193 |
-
"disciplines": ["genomics", "biology", "life sciences", "sequencing", "microbiology"],
|
| 194 |
-
},
|
| 195 |
-
"geo": {
|
| 196 |
-
"id": "geo", "name": "NCBI GEO", "type": "Domain-specific",
|
| 197 |
-
"url": "https://www.ncbi.nlm.nih.gov/geo",
|
| 198 |
-
"scope": "Functional genomics data — gene expression, microarray, high-throughput sequencing.",
|
| 199 |
-
"data_types_accepted": "Expression and functional genomics data",
|
| 200 |
-
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 201 |
-
"cost": "Free", "curation": "Mediated",
|
| 202 |
-
"source_url": "https://www.ncbi.nlm.nih.gov/geo/info/submission.html", "verified": "2026-07",
|
| 203 |
-
"disciplines": ["genomics", "transcriptomics", "biology", "life sciences"],
|
| 204 |
-
},
|
| 205 |
-
"pride": {
|
| 206 |
-
"id": "pride", "name": "PRIDE", "type": "Domain-specific",
|
| 207 |
-
"url": "https://www.ebi.ac.uk/pride",
|
| 208 |
-
"scope": "Mass-spectrometry proteomics data.",
|
| 209 |
-
"data_types_accepted": "Proteomics MS data",
|
| 210 |
-
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 211 |
-
"cost": "Free", "curation": "Mediated",
|
| 212 |
-
"source_url": "https://www.ebi.ac.uk/pride/markdownpage/submitdatapage", "verified": "2026-07",
|
| 213 |
-
"disciplines": ["proteomics", "biochemistry", "life sciences"],
|
| 214 |
-
},
|
| 215 |
-
"pdb": {
|
| 216 |
-
"id": "pdb", "name": "Protein Data Bank (PDB)", "type": "Domain-specific",
|
| 217 |
-
"url": "https://www.rcsb.org",
|
| 218 |
-
"scope": "Three-dimensional structural data for biological macromolecules.",
|
| 219 |
-
"data_types_accepted": "Macromolecular structures",
|
| 220 |
-
"persistent_identifier": "PDB ID", "versioning": "Yes", "embargo_support": "Yes",
|
| 221 |
-
"cost": "Free", "curation": "Mediated — biocuration",
|
| 222 |
-
"source_url": "https://www.rcsb.org/pages/policy", "verified": "2026-07",
|
| 223 |
-
"disciplines": ["structural biology", "biochemistry", "chemistry", "life sciences"],
|
| 224 |
-
},
|
| 225 |
-
"metabolights": {
|
| 226 |
-
"id": "metabolights", "name": "MetaboLights", "type": "Domain-specific",
|
| 227 |
-
"url": "https://www.ebi.ac.uk/metabolights",
|
| 228 |
-
"scope": "Metabolomics experiments and derived information.",
|
| 229 |
-
"data_types_accepted": "Metabolomics data",
|
| 230 |
-
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 231 |
-
"cost": "Free", "curation": "Mediated",
|
| 232 |
-
"source_url": "https://www.ebi.ac.uk/metabolights", "verified": "2026-07",
|
| 233 |
-
"disciplines": ["metabolomics", "biochemistry", "life sciences"],
|
| 234 |
-
},
|
| 235 |
-
|
| 236 |
-
# ---- Chemistry --------------------------------------------------------
|
| 237 |
-
"chemrxiv_data": {
|
| 238 |
-
"id": "chemrxiv_data", "name": "Cambridge Structural Database (CCDC)",
|
| 239 |
-
"type": "Domain-specific", "url": "https://www.ccdc.cam.ac.uk",
|
| 240 |
-
"scope": "Small-molecule organic and metal-organic crystal structures.",
|
| 241 |
-
"data_types_accepted": "Crystallographic data (CIF)",
|
| 242 |
-
"persistent_identifier": "CCDC number", "versioning": "", "embargo_support": "Yes",
|
| 243 |
-
"cost": "Free deposition", "curation": "Mediated",
|
| 244 |
-
"source_url": "https://www.ccdc.cam.ac.uk/structures/", "verified": "2026-07",
|
| 245 |
-
"disciplines": ["chemistry", "crystallography", "materials"],
|
| 246 |
-
},
|
| 247 |
-
|
| 248 |
-
# ---- Computing / AI ---------------------------------------------------
|
| 249 |
-
"huggingface_datasets": {
|
| 250 |
-
"id": "huggingface_datasets", "name": "Hugging Face Datasets",
|
| 251 |
-
"type": "Domain-specific", "url": "https://huggingface.co/datasets",
|
| 252 |
-
"scope": "Machine learning datasets and models with versioned git-based storage.",
|
| 253 |
-
"data_types_accepted": "ML datasets, models",
|
| 254 |
-
"persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
|
| 255 |
-
"cost": "Free tier available", "curation": "Unmediated",
|
| 256 |
-
"source_url": "https://huggingface.co/docs/hub/datasets", "verified": "2026-07",
|
| 257 |
-
"disciplines": ["machine learning", "ai", "nlp", "computer vision", "computing"],
|
| 258 |
-
},
|
| 259 |
-
"openml": {
|
| 260 |
-
"id": "openml", "name": "OpenML", "type": "Domain-specific",
|
| 261 |
-
"url": "https://www.openml.org",
|
| 262 |
-
"scope": "Machine learning datasets, tasks, flows and benchmark runs.",
|
| 263 |
-
"data_types_accepted": "Tabular ML datasets",
|
| 264 |
-
"persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
|
| 265 |
-
"cost": "Free", "curation": "Unmediated",
|
| 266 |
-
"source_url": "https://docs.openml.org", "verified": "2026-07",
|
| 267 |
-
"disciplines": ["machine learning", "ai", "data science", "computing"],
|
| 268 |
-
},
|
| 269 |
-
|
| 270 |
-
# ---- Social science / humanities --------------------------------------
|
| 271 |
-
"ukds": {
|
| 272 |
-
"id": "ukds", "name": "UK Data Service ReShare", "type": "Domain-specific",
|
| 273 |
-
"url": "https://reshare.ukdataservice.ac.uk",
|
| 274 |
-
"scope": "Social and economic research data, including sensitive data under controlled access.",
|
| 275 |
-
"data_types_accepted": "Social science data",
|
| 276 |
-
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 277 |
-
"cost": "Free", "curation": "Mediated",
|
| 278 |
-
"source_url": "https://reshare.ukdataservice.ac.uk", "verified": "2026-07",
|
| 279 |
-
"disciplines": ["social sciences", "economics", "sociology", "survey"],
|
| 280 |
-
},
|
| 281 |
-
"icpsr": {
|
| 282 |
-
"id": "icpsr", "name": "ICPSR", "type": "Domain-specific",
|
| 283 |
-
"url": "https://www.icpsr.umich.edu",
|
| 284 |
-
"scope": "Social and behavioural science data with strong curation and restricted-access options.",
|
| 285 |
-
"data_types_accepted": "Social science data",
|
| 286 |
-
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 287 |
-
"cost": "Varies by membership", "curation": "Mediated — extensive curation",
|
| 288 |
-
"source_url": "https://www.icpsr.umich.edu/web/pages/deposit/", "verified": "2026-07",
|
| 289 |
-
"disciplines": ["social sciences", "behavioural", "health", "survey"],
|
| 290 |
-
},
|
| 291 |
-
|
| 292 |
-
# ---- Sensitive / controlled access -------------------------------------
|
| 293 |
-
"ega": {
|
| 294 |
-
"id": "ega", "name": "European Genome-phenome Archive (EGA)",
|
| 295 |
-
"type": "Domain-specific", "url": "https://ega-archive.org",
|
| 296 |
-
"scope": "Controlled-access repository for personally identifiable genetic and phenotypic data.",
|
| 297 |
-
"data_types_accepted": "Human genomic and phenotypic data",
|
| 298 |
-
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 299 |
-
"cost": "Free", "curation": "Mediated — DAC-controlled access",
|
| 300 |
-
"source_url": "https://ega-archive.org/submission", "verified": "2026-07",
|
| 301 |
-
"disciplines": ["human genomics", "clinical", "biomedical", "sensitive"],
|
| 302 |
-
},
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
# ── Helpers ────────────────────────────────────────────────────────────────
|
| 307 |
-
# The model may author ONLY analytical fit fields. Every other repository
|
| 308 |
-
# attribute is supplied from REGISTRY, including the id itself after lookup.
|
| 309 |
-
MODEL_ALLOWED = ("fit_reason", "fit_confidence")
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def _clean_text(value: Any, limit: int = 1000) -> str:
|
| 313 |
-
return " ".join(str(value or "").split())[:limit]
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
def _clean_list(value: Any, limit: int = 12, item_limit: int = 500) -> list[str]:
|
| 317 |
-
if not isinstance(value, list):
|
| 318 |
-
return []
|
| 319 |
-
out: list[str] = []
|
| 320 |
-
for item in value[:limit]:
|
| 321 |
-
cleaned = _clean_text(item, item_limit)
|
| 322 |
-
if cleaned:
|
| 323 |
-
out.append(cleaned)
|
| 324 |
-
return out
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
def _yes_no(value: Any) -> str:
|
| 328 |
-
text = _clean_text(value, 100).casefold()
|
| 329 |
-
if text in {"yes", "true"} or text.startswith("yes ") or text.startswith("yes—"):
|
| 330 |
-
return "Yes"
|
| 331 |
-
if text in {"no", "false"}:
|
| 332 |
-
return "No"
|
| 333 |
-
return "Unknown"
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
def repository_capabilities(entry: dict) -> dict:
|
| 337 |
-
"""Expose only capabilities supported by existing curated fields.
|
| 338 |
-
|
| 339 |
-
A generic homepage and a broad access description do not establish approval
|
| 340 |
-
for a particular sensitive dataset, size limit or accepted file format.
|
| 341 |
-
"""
|
| 342 |
-
identifier = _clean_text(entry.get("persistent_identifier"), 100)
|
| 343 |
-
description = (str(entry.get("scope", "")) + " " + str(entry.get("curation", ""))).casefold()
|
| 344 |
-
controlled = any(term in description for term in ("controlled access", "controlled-access", "restricted-access", "restricted access"))
|
| 345 |
-
return {
|
| 346 |
-
"doi": "Yes" if identifier.casefold() == "doi" else "No" if identifier else "Unknown",
|
| 347 |
-
"persistent_id_type": identifier or "Unknown",
|
| 348 |
-
"versioning": _yes_no(entry.get("versioning")),
|
| 349 |
-
"embargo": _yes_no(entry.get("embargo_support")),
|
| 350 |
-
"restricted_access": "Yes" if controlled and not entry.get("is_khazna") else "Unknown",
|
| 351 |
-
"approved_sensitive_data": "Unknown",
|
| 352 |
-
"accepts_external_deposits": "Unknown",
|
| 353 |
-
"accepted_file_formats": "Unknown",
|
| 354 |
-
"maximum_dataset_size": "Unknown",
|
| 355 |
-
"source_url": entry.get("source_url", ""),
|
| 356 |
-
"reviewed": entry.get("verified", ""),
|
| 357 |
-
}
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
def evaluate_repository(entry: dict, constraints: dict | None = None) -> dict:
|
| 361 |
-
"""Check stated requirements against curated facts; unknown is not Yes."""
|
| 362 |
-
constraints = constraints if isinstance(constraints, dict) else {}
|
| 363 |
-
capabilities = repository_capabilities(entry)
|
| 364 |
-
unresolved, conflicts = [], []
|
| 365 |
-
sensitivity = _clean_text(constraints.get("sensitivity"), 100).casefold()
|
| 366 |
-
personal = _clean_text(constraints.get("contains_personal_data"), 100).casefold()
|
| 367 |
-
sensitive = (bool(sensitivity) and sensitivity not in {"open", "none", "non-sensitive", "not sensitive"}) or (bool(personal) and personal != "no")
|
| 368 |
-
for requirement, capability, label in (
|
| 369 |
-
("needs_doi", "doi", "DOI assignment"),
|
| 370 |
-
("needs_versioning", "versioning", "versioning"),
|
| 371 |
-
("embargo_required", "embargo", "an embargo"),
|
| 372 |
-
):
|
| 373 |
-
if _yes_no(constraints.get(requirement)) != "Yes":
|
| 374 |
-
continue
|
| 375 |
-
if capabilities[capability] == "No":
|
| 376 |
-
conflicts.append(f"The curated record does not support the requested {label}.")
|
| 377 |
-
elif capabilities[capability] != "Yes":
|
| 378 |
-
unresolved.append(f"Confirm support for {label} with the repository.")
|
| 379 |
-
if sensitive:
|
| 380 |
-
if capabilities["restricted_access"] != "Yes":
|
| 381 |
-
conflicts.append("Controlled/restricted access suitable for the submitted sensitive or personal data is not established.")
|
| 382 |
-
unresolved.append("KU Library and the responsible ethics/data-protection team must approve the repository and access conditions for this dataset before any file upload.")
|
| 383 |
-
if _clean_text(constraints.get("file_formats")):
|
| 384 |
-
unresolved.append("Confirm acceptance of the submitted file formats: " + _clean_text(constraints["file_formats"], 300))
|
| 385 |
-
if _clean_text(constraints.get("approx_size")):
|
| 386 |
-
unresolved.append("Confirm the size/quota and any associated cost for: " + _clean_text(constraints["approx_size"], 300))
|
| 387 |
-
if _clean_text(constraints.get("funder")):
|
| 388 |
-
unresolved.append("Confirm the funder's repository, licence and retention requirements; the registry does not establish mandate compliance.")
|
| 389 |
-
if _clean_text(constraints.get("licence_intent")):
|
| 390 |
-
unresolved.append("Confirm that the repository supports the selected licence and that you hold the necessary rights.")
|
| 391 |
-
institution = _clean_text(constraints.get("institution"))
|
| 392 |
-
country = _clean_text(constraints.get("country"))
|
| 393 |
-
if institution or country:
|
| 394 |
-
unresolved.append("Confirm depositor eligibility and any institution/country restrictions.")
|
| 395 |
-
if _yes_no(constraints.get("embargo_required")) == "Yes":
|
| 396 |
-
duration = constraints.get("embargo_months")
|
| 397 |
-
if duration is not None:
|
| 398 |
-
unresolved.append(f"Confirm support for the requested {duration}-month embargo.")
|
| 399 |
-
elif constraints.get("embargo_duration"):
|
| 400 |
-
unresolved.append("Confirm support for the requested embargo duration: " + _clean_text(constraints["embargo_duration"], 100))
|
| 401 |
-
if entry.get("id") == "dryad" and "software" in _clean_text(constraints.get("data_types")).casefold():
|
| 402 |
-
conflicts.append("The curated record lists research data, not software, as accepted content.")
|
| 403 |
-
metadata_only = bool(entry.get("is_khazna"))
|
| 404 |
-
if metadata_only:
|
| 405 |
-
unresolved.insert(0, "Ask KU Library about metadata registration; dataset file acceptance, DOI assignment and access controls require confirmation.")
|
| 406 |
-
# Institutional registration advice is not a file-deposit destination.
|
| 407 |
-
conflicts = []
|
| 408 |
-
return {
|
| 409 |
-
"state": "metadata_only" if metadata_only else "does_not_meet" if conflicts else "conditional" if unresolved else "meets",
|
| 410 |
-
"unresolved_constraints": list(dict.fromkeys(unresolved)),
|
| 411 |
-
"constraint_conflicts": list(dict.fromkeys(conflicts)),
|
| 412 |
-
"sensitive_data_review_required": sensitive,
|
| 413 |
-
"file_deposit_recommended": not metadata_only and not conflicts and not unresolved,
|
| 414 |
-
"strategy_eligible": not metadata_only and not conflicts and not sensitive,
|
| 415 |
-
"capabilities": capabilities,
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
def registry_for_prompt(limit: int = 0) -> str:
|
| 420 |
-
"""Compact registry listing for the model to select from."""
|
| 421 |
-
lines = []
|
| 422 |
-
for r in REGISTRY.values():
|
| 423 |
-
lines.append(
|
| 424 |
-
f'- id="{r["id"]}" | {r["name"]} | {r["type"]} | '
|
| 425 |
-
f'disciplines: {", ".join(r["disciplines"])} | {r["scope"]} | '
|
| 426 |
-
f'capabilities: {json.dumps(repository_capabilities(r), ensure_ascii=False)} | '
|
| 427 |
-
f'accepted content: {r.get("data_types_accepted", "Unknown")} | cost: {r.get("cost", "Unknown")}'
|
| 428 |
-
)
|
| 429 |
-
if limit and len(lines) >= limit:
|
| 430 |
-
break
|
| 431 |
-
return "\n".join(lines)
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
def hydrate_selection(selected: list[dict], constraints: dict | None = None) -> list[dict]:
|
| 435 |
-
"""
|
| 436 |
-
Hydrate exact registry ids and discard every model-authored factual field.
|
| 437 |
-
|
| 438 |
-
Only `fit_reason` and `fit_confidence` survive from the model. Unknown ids,
|
| 439 |
-
duplicate ids, non-object entries, and malformed confidence values are
|
| 440 |
-
dropped or normalised rather than displayed as repository facts.
|
| 441 |
-
"""
|
| 442 |
-
out: list[dict] = []
|
| 443 |
-
seen: set[str] = set()
|
| 444 |
-
if not isinstance(selected, list):
|
| 445 |
-
return out
|
| 446 |
-
|
| 447 |
-
for item in selected:
|
| 448 |
-
if not isinstance(item, dict):
|
| 449 |
-
continue
|
| 450 |
-
rid = str(item.get("id", "")).strip().lower()
|
| 451 |
-
entry = REGISTRY.get(rid)
|
| 452 |
-
if not entry or rid in seen:
|
| 453 |
-
continue
|
| 454 |
-
seen.add(rid)
|
| 455 |
-
assessment = evaluate_repository(entry, constraints)
|
| 456 |
-
if constraints is not None and assessment["state"] == "does_not_meet":
|
| 457 |
-
continue
|
| 458 |
-
|
| 459 |
-
fit_reason = _clean_text(item.get("fit_reason"), 1200)
|
| 460 |
-
confidence = _clean_text(item.get("fit_confidence"), 20).title()
|
| 461 |
-
if confidence not in {"High", "Medium", "Low"}:
|
| 462 |
-
confidence = ""
|
| 463 |
-
|
| 464 |
-
merged = dict(entry)
|
| 465 |
-
merged["fit_reason"] = fit_reason
|
| 466 |
-
merged["fit_confidence"] = confidence
|
| 467 |
-
# Backwards-compatible alias for older frontends; it is still an
|
| 468 |
-
# analytical fit score, never a repository-fact confidence score.
|
| 469 |
-
merged["confidence"] = confidence
|
| 470 |
-
merged["verification_status"] = "Curated KU Library registry record"
|
| 471 |
-
merged["verified_note"] = (
|
| 472 |
-
f"Repository details verified {entry['verified']} from {entry['source_url']}"
|
| 473 |
-
)
|
| 474 |
-
merged["capabilities"] = assessment["capabilities"]
|
| 475 |
-
merged["constraint_assessment"] = assessment
|
| 476 |
-
merged["file_deposit_recommended"] = assessment["file_deposit_recommended"]
|
| 477 |
-
merged["metadata_registration_only"] = bool(entry.get("is_khazna"))
|
| 478 |
-
if constraints is not None and assessment["state"] == "conditional":
|
| 479 |
-
merged["fit_reason"] = ("Candidate for review. " + fit_reason + " " + " ".join(assessment["unresolved_constraints"]))[:3000]
|
| 480 |
-
merged["fit_confidence"] = "Low" if assessment["sensitive_data_review_required"] else "Medium"
|
| 481 |
-
merged["confidence"] = merged["fit_confidence"]
|
| 482 |
-
out.append(merged)
|
| 483 |
-
return out
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
def selection_exclusions(selected: list[dict], constraints: dict | None = None) -> list[dict]:
|
| 487 |
-
"""Explain deterministic exclusions without trusting model-authored facts."""
|
| 488 |
-
excluded, seen = [], set()
|
| 489 |
-
for item in selected if isinstance(selected, list) else []:
|
| 490 |
-
if not isinstance(item, dict):
|
| 491 |
-
continue
|
| 492 |
-
rid = _clean_text(item.get("id"), 100).lower()
|
| 493 |
-
entry = REGISTRY.get(rid)
|
| 494 |
-
if not entry or rid in seen:
|
| 495 |
-
continue
|
| 496 |
-
seen.add(rid)
|
| 497 |
-
result = evaluate_repository(entry, constraints)
|
| 498 |
-
if result["state"] == "does_not_meet":
|
| 499 |
-
excluded.append({"id": rid, "name": entry["name"], "reasons": result["constraint_conflicts"],
|
| 500 |
-
"unresolved_constraints": result["unresolved_constraints"]})
|
| 501 |
-
return excluded
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
def hydrate_deposit_strategy(raw: Any, hydrated: list[dict], constraints: dict | None = None) -> dict:
|
| 505 |
-
"""Resolve model-selected ids to server-owned names and bound free text."""
|
| 506 |
-
raw = raw if isinstance(raw, dict) else {}
|
| 507 |
-
by_id = {str(r.get("id", "")): r for r in hydrated if r.get("id")}
|
| 508 |
-
eligible = {rid for rid, entry in by_id.items()
|
| 509 |
-
if not entry.get("is_khazna") and entry.get("constraint_assessment", {}).get("strategy_eligible", True)}
|
| 510 |
-
ranked_ids = [
|
| 511 |
-
str(r.get("id")) for r in hydrated
|
| 512 |
-
if r.get("id") in eligible
|
| 513 |
-
]
|
| 514 |
-
|
| 515 |
-
def selected_id(key: str, fallback_index: int) -> str:
|
| 516 |
-
candidate = str(raw.get(key, "")).strip().lower()
|
| 517 |
-
if candidate in eligible:
|
| 518 |
-
return candidate
|
| 519 |
-
return ranked_ids[fallback_index] if len(ranked_ids) > fallback_index else ""
|
| 520 |
-
|
| 521 |
-
primary_id = selected_id("primary_repository_id", 0)
|
| 522 |
-
secondary_id = selected_id("secondary_repository_id", 1)
|
| 523 |
-
if secondary_id == primary_id:
|
| 524 |
-
secondary_id = ranked_ids[1] if len(ranked_ids) > 1 else ""
|
| 525 |
-
|
| 526 |
-
checks = _clean_list(raw.get("manual_checks_required"), limit=10)
|
| 527 |
-
for entry in hydrated:
|
| 528 |
-
checks.extend(entry.get("constraint_assessment", {}).get("unresolved_constraints", []))
|
| 529 |
-
if not primary_id:
|
| 530 |
-
checks.insert(0, "No file-deposit destination has been established for these requirements; contact KU Library before uploading data.")
|
| 531 |
-
workflow = _clean_list(raw.get("deposit_workflow"), limit=10)
|
| 532 |
-
if constraints is not None:
|
| 533 |
-
# Model prose must not instruct immediate upload while deterministic
|
| 534 |
-
# requirements remain unresolved, especially for sensitive datasets.
|
| 535 |
-
workflow = (["Review the candidate repository's source guidance and all unresolved requirements.",
|
| 536 |
-
"Confirm rights, consent, access conditions and depositor eligibility.",
|
| 537 |
-
"Prepare metadata and supporting documentation; upload files only after all checks are complete."]
|
| 538 |
-
if primary_id else ["Contact KU Library to identify an approved deposit or metadata-registration route.",
|
| 539 |
-
"Do not upload dataset files until a suitable destination and access conditions are confirmed."])
|
| 540 |
-
return {
|
| 541 |
-
"primary_repository_id": primary_id,
|
| 542 |
-
"primary_url": REGISTRY.get(primary_id, {}).get("url"),
|
| 543 |
-
"secondary_url": REGISTRY.get(secondary_id, {}).get("url"),
|
| 544 |
-
"secondary_repository_id": secondary_id,
|
| 545 |
-
"primary_choice": by_id.get(primary_id, {}).get("name", "Not determined"),
|
| 546 |
-
"secondary_choice": by_id.get(secondary_id, {}).get("name", "Not determined"),
|
| 547 |
-
"metadata_standard": _clean_text(
|
| 548 |
-
raw.get("metadata_standard") or "Check the selected repository's current metadata guidance.",
|
| 549 |
-
500,
|
| 550 |
-
),
|
| 551 |
-
"deposit_workflow": workflow,
|
| 552 |
-
"manual_checks_required": list(dict.fromkeys(checks)),
|
| 553 |
-
"selection_status": "candidates_require_review" if checks and primary_id else "no_verified_destination" if not primary_id else "requirements_matched",
|
| 554 |
-
"khazna_metadata_note": (
|
| 555 |
-
"Ask KU Library about registering dataset metadata in Khazna when the files are held in an appropriate external repository."
|
| 556 |
-
),
|
| 557 |
-
}
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
def khazna_entry(constraints: dict | None = None) -> dict:
|
| 561 |
-
e = dict(REGISTRY["khazna"])
|
| 562 |
-
e["fit_reason"] = ("Contact KU Library about registering dataset metadata in Khazna for institutional visibility. "
|
| 563 |
-
"File-deposit eligibility, DOI assignment and access controls must be confirmed separately.")
|
| 564 |
-
e["metadata_registration_only"] = True
|
| 565 |
-
e["file_deposit_recommended"] = False
|
| 566 |
-
e["constraint_assessment"] = evaluate_repository(e, constraints)
|
| 567 |
-
e["capabilities"] = e["constraint_assessment"]["capabilities"]
|
| 568 |
-
e["fit_confidence"] = "High"
|
| 569 |
-
e["confidence"] = "High"
|
| 570 |
-
e["verification_status"] = "Curated KU Library registry record"
|
| 571 |
-
e["verified_note"] = (f"Repository details verified {e['verified']} "
|
| 572 |
-
f"from {e['source_url']}")
|
| 573 |
-
return e
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
# ══════════════════════════════════════════════════════════════════════════
|
| 577 |
-
# SUBJECT REPOSITORIES — for ARTICLES, not datasets
|
| 578 |
-
# ══════════════════════════════════════════════════════════════════════════
|
| 579 |
-
# OA.Works returns permitted locations such as "Non-commercial Subject
|
| 580 |
-
# Repository". That string alone is not actionable: the author still has to
|
| 581 |
-
# know WHICH one. These entries are matched against the journal's SCImago
|
| 582 |
-
# subject areas/categories so the answer is specific.
|
| 583 |
-
#
|
| 584 |
-
# Deliberately distinct from REGISTRY above, which holds DATA repositories.
|
| 585 |
-
# Depositing a manuscript in Dryad or a dataset in arXiv are both wrong.
|
| 586 |
-
|
| 587 |
-
SUBJECT_REPOSITORIES: dict[str, dict] = {
|
| 588 |
-
"arxiv": {
|
| 589 |
-
"id": "arxiv", "name": "arXiv", "url": "https://arxiv.org",
|
| 590 |
-
"scope": "Physics, mathematics, computer science, quantitative biology, "
|
| 591 |
-
"statistics, electrical engineering and economics.",
|
| 592 |
-
"versions": "Preprint and accepted manuscript",
|
| 593 |
-
"moderation": "Moderated; endorsement required for first submission",
|
| 594 |
-
"source_url": "https://arxiv.org/help/submit", "verified": "2026-07",
|
| 595 |
-
"match": ["physics", "astronomy", "mathematics", "computer science",
|
| 596 |
-
"engineering", "electrical", "statistics", "economics",
|
| 597 |
-
"materials science", "energy"],
|
| 598 |
-
},
|
| 599 |
-
"pmc": {
|
| 600 |
-
"id": "pmc", "name": "Europe PMC / PubMed Central",
|
| 601 |
-
"url": "https://europepmc.org",
|
| 602 |
-
"scope": "Biomedical and life sciences literature. Required by many "
|
| 603 |
-
"health funders.",
|
| 604 |
-
"versions": "Accepted manuscript and published version where licensed",
|
| 605 |
-
"moderation": "Mediated; funder-linked deposit routes available",
|
| 606 |
-
"source_url": "https://europepmc.org/Help", "verified": "2026-07",
|
| 607 |
-
"match": ["medicine", "biochemistry", "immunology", "microbiology",
|
| 608 |
-
"neuroscience", "pharmacology", "health", "nursing",
|
| 609 |
-
"veterinary", "dentistry", "biology"],
|
| 610 |
-
},
|
| 611 |
-
"biorxiv": {
|
| 612 |
-
"id": "biorxiv", "name": "bioRxiv", "url": "https://www.biorxiv.org",
|
| 613 |
-
"scope": "Preprints across the biological sciences.",
|
| 614 |
-
"versions": "Preprint (submitted version) only",
|
| 615 |
-
"moderation": "Screened, not peer reviewed",
|
| 616 |
-
"source_url": "https://www.biorxiv.org/about/FAQ", "verified": "2026-07",
|
| 617 |
-
"match": ["biology", "biochemistry", "genetics", "neuroscience",
|
| 618 |
-
"immunology", "microbiology", "agricultural"],
|
| 619 |
-
},
|
| 620 |
-
"chemrxiv": {
|
| 621 |
-
"id": "chemrxiv", "name": "ChemRxiv", "url": "https://chemrxiv.org",
|
| 622 |
-
"scope": "Preprints in chemistry and related areas.",
|
| 623 |
-
"versions": "Preprint (submitted version) only",
|
| 624 |
-
"moderation": "Screened, not peer reviewed",
|
| 625 |
-
"source_url": "https://chemrxiv.org/engage/chemrxiv/public-dashboard",
|
| 626 |
-
"verified": "2026-07",
|
| 627 |
-
"match": ["chemistry", "chemical engineering", "materials science"],
|
| 628 |
-
},
|
| 629 |
-
"engrxiv": {
|
| 630 |
-
"id": "engrxiv", "name": "engrXiv", "url": "https://engrxiv.org",
|
| 631 |
-
"scope": "Preprints across all engineering disciplines.",
|
| 632 |
-
"versions": "Preprint and accepted manuscript",
|
| 633 |
-
"moderation": "Screened, not peer reviewed",
|
| 634 |
-
"source_url": "https://engrxiv.org", "verified": "2026-07",
|
| 635 |
-
"match": ["engineering", "civil", "mechanical", "aerospace",
|
| 636 |
-
"industrial", "environmental engineering"],
|
| 637 |
-
},
|
| 638 |
-
"ssrn": {
|
| 639 |
-
"id": "ssrn", "name": "SSRN", "url": "https://www.ssrn.com",
|
| 640 |
-
"scope": "Social sciences, economics, management, law and humanities.",
|
| 641 |
-
"versions": "Preprint and accepted manuscript",
|
| 642 |
-
"moderation": "Screened",
|
| 643 |
-
"source_url": "https://www.ssrn.com/index.cfm/en/", "verified": "2026-07",
|
| 644 |
-
"match": ["social sciences", "economics", "business", "management",
|
| 645 |
-
"accounting", "finance", "law", "psychology", "decision sciences"],
|
| 646 |
-
},
|
| 647 |
-
"repec": {
|
| 648 |
-
"id": "repec", "name": "RePEc", "url": "http://repec.org",
|
| 649 |
-
"scope": "Working papers and articles in economics and finance.",
|
| 650 |
-
"versions": "Working paper and accepted manuscript",
|
| 651 |
-
"moderation": "Institution-mediated archives",
|
| 652 |
-
"source_url": "http://repec.org/", "verified": "2026-07",
|
| 653 |
-
"match": ["economics", "econometrics", "finance"],
|
| 654 |
-
},
|
| 655 |
-
"eartharxiv": {
|
| 656 |
-
"id": "eartharxiv", "name": "EarthArXiv",
|
| 657 |
-
"url": "https://eartharxiv.org",
|
| 658 |
-
"scope": "Preprints in earth, planetary and environmental sciences.",
|
| 659 |
-
"versions": "Preprint and accepted manuscript",
|
| 660 |
-
"moderation": "Screened, not peer reviewed",
|
| 661 |
-
"source_url": "https://eartharxiv.org", "verified": "2026-07",
|
| 662 |
-
"match": ["earth", "planetary", "environmental science", "geology",
|
| 663 |
-
"atmospheric", "oceanography"],
|
| 664 |
-
},
|
| 665 |
-
"medrxiv": {
|
| 666 |
-
"id": "medrxiv", "name": "medRxiv", "url": "https://www.medrxiv.org",
|
| 667 |
-
"scope": "Preprints in clinical and health sciences.",
|
| 668 |
-
"versions": "Preprint (submitted version) only",
|
| 669 |
-
"moderation": "Screened; clinical claims reviewed",
|
| 670 |
-
"source_url": "https://www.medrxiv.org/about/FAQ", "verified": "2026-07",
|
| 671 |
-
"match": ["medicine", "health", "clinical", "epidemiology", "public health"],
|
| 672 |
-
},
|
| 673 |
-
}
|
| 674 |
-
|
| 675 |
-
# Always offered when a subject repository is permitted but nothing matched.
|
| 676 |
-
SUBJECT_FALLBACK = ["arxiv", "ssrn"]
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
def suggest_subject_repositories(areas: str = "", categories: str = "",
|
| 680 |
-
limit: int = 3) -> list[dict]:
|
| 681 |
-
"""
|
| 682 |
-
Match a journal's SCImago subject areas/categories to subject repositories.
|
| 683 |
-
|
| 684 |
-
Deterministic keyword scoring — no LLM. Returns [] rather than guessing
|
| 685 |
-
when nothing matches well and no fallback is warranted.
|
| 686 |
-
"""
|
| 687 |
-
haystack = f"{areas} {categories}".lower()
|
| 688 |
-
if not haystack.strip():
|
| 689 |
-
return []
|
| 690 |
-
|
| 691 |
-
scored: list[tuple[int, dict]] = []
|
| 692 |
-
for entry in SUBJECT_REPOSITORIES.values():
|
| 693 |
-
score = sum(1 for kw in entry["match"] if kw in haystack)
|
| 694 |
-
if score:
|
| 695 |
-
scored.append((score, entry))
|
| 696 |
-
|
| 697 |
-
scored.sort(key=lambda x: (-x[0], x[1]["name"]))
|
| 698 |
-
picked = [dict(e) for _, e in scored[:limit]]
|
| 699 |
-
|
| 700 |
-
# An unrelated fallback is not a subject match. Return no suggestions if
|
| 701 |
-
# the supplied discipline does not match a curated repository.
|
| 702 |
-
if not picked:
|
| 703 |
-
return []
|
| 704 |
-
|
| 705 |
-
for p in picked:
|
| 706 |
-
p.pop("match", None)
|
| 707 |
-
p["verified_note"] = f"Verified {p['verified']} from {p['source_url']}"
|
| 708 |
-
return picked
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
repositories.py — curated registry of research data repositories.
|
| 3 |
+
|
| 4 |
+
WHY THIS EXISTS
|
| 5 |
+
---------------
|
| 6 |
+
The previous /api/find-repository asked the LLM to produce repository facts:
|
| 7 |
+
max file size, certification, cost, persistent identifier type. Those are
|
| 8 |
+
exactly the kind of specific, checkable claims a model gets subtly wrong, and
|
| 9 |
+
a wrong "50 GB limit" or "CoreTrustSeal certified" in a library tool is a
|
| 10 |
+
credibility problem.
|
| 11 |
+
|
| 12 |
+
Here the LLM only SELECTS entries by id and writes a fit_reason. Every factual
|
| 13 |
+
field below is served from this file and overwrites whatever the model says.
|
| 14 |
+
|
| 15 |
+
MAINTENANCE
|
| 16 |
+
-----------
|
| 17 |
+
Each entry carries `source_url` and `verified` (YYYY-MM). Re-check quarterly.
|
| 18 |
+
Fields you are unsure of should be left as "" rather than guessed — the UI
|
| 19 |
+
renders empty fields as "Not specified", which is honest.
|
| 20 |
+
|
| 21 |
+
Selection is deliberately KU-flavoured: engineering, energy, materials,
|
| 22 |
+
petroleum, AI/CS and biomedical are weighted because that is KU's output
|
| 23 |
+
profile.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from typing import Any
|
| 29 |
+
import json
|
| 30 |
+
import re
|
| 31 |
+
|
| 32 |
+
# ── Registry ───────────────────────────────────────────────────────────────
|
| 33 |
+
# NOTE: max_file_size / certification are left blank where not personally
|
| 34 |
+
# verified. Fill them in from source_url and bump `verified`.
|
| 35 |
+
|
| 36 |
+
REGISTRY: dict[str, dict[str, Any]] = {
|
| 37 |
+
# ---- Institutional ----------------------------------------------------
|
| 38 |
+
"khazna": {
|
| 39 |
+
"id": "khazna",
|
| 40 |
+
"name": "Khazna",
|
| 41 |
+
"type": "Institutional",
|
| 42 |
+
"url": "https://khazna.ku.ac.ae",
|
| 43 |
+
"scope": "Khalifa University research outputs, datasets and theses across all disciplines.",
|
| 44 |
+
"data_types_accepted": "Confirm file-deposit support with KU Library",
|
| 45 |
+
"persistent_identifier": "",
|
| 46 |
+
"versioning": "",
|
| 47 |
+
"embargo_support": "",
|
| 48 |
+
"cost": "Free for KU researchers",
|
| 49 |
+
"curation": "Mediated — library review before publication",
|
| 50 |
+
"source_url": "https://khazna.ku.ac.ae",
|
| 51 |
+
"verified": "2026-07",
|
| 52 |
+
"is_khazna": True,
|
| 53 |
+
"disciplines": ["all"],
|
| 54 |
+
},
|
| 55 |
+
|
| 56 |
+
# ---- Generalist -------------------------------------------------------
|
| 57 |
+
"zenodo": {
|
| 58 |
+
"id": "zenodo", "name": "Zenodo", "type": "Generalist",
|
| 59 |
+
"url": "https://zenodo.org",
|
| 60 |
+
"scope": "Multidisciplinary repository operated by CERN; accepts data, software, publications.",
|
| 61 |
+
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 62 |
+
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
|
| 63 |
+
"curation": "Unmediated",
|
| 64 |
+
"source_url": "https://help.zenodo.org", "verified": "2026-07",
|
| 65 |
+
"disciplines": ["all"],
|
| 66 |
+
},
|
| 67 |
+
"figshare": {
|
| 68 |
+
"id": "figshare", "name": "Figshare", "type": "Generalist",
|
| 69 |
+
"url": "https://figshare.com",
|
| 70 |
+
"scope": "Multidisciplinary repository for figures, datasets, media and preprints.",
|
| 71 |
+
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 72 |
+
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
|
| 73 |
+
"curation": "Unmediated",
|
| 74 |
+
"source_url": "https://help.figshare.com", "verified": "2026-07",
|
| 75 |
+
"disciplines": ["all"],
|
| 76 |
+
},
|
| 77 |
+
"dryad": {
|
| 78 |
+
"id": "dryad", "name": "Dryad", "type": "Generalist",
|
| 79 |
+
"url": "https://datadryad.org",
|
| 80 |
+
"scope": "Curated repository for data underlying published research, strong in life and environmental sciences.",
|
| 81 |
+
"data_types_accepted": "Research data (not software)", "persistent_identifier": "DOI",
|
| 82 |
+
"versioning": "Yes", "embargo_support": "Yes",
|
| 83 |
+
"cost": "Fee-based; free where the institution is a member",
|
| 84 |
+
"curation": "Mediated — curator review",
|
| 85 |
+
"source_url": "https://datadryad.org/stash/faq", "verified": "2026-07",
|
| 86 |
+
"disciplines": ["life sciences", "environmental", "ecology", "biology"],
|
| 87 |
+
},
|
| 88 |
+
"dataverse": {
|
| 89 |
+
"id": "dataverse", "name": "Harvard Dataverse", "type": "Generalist",
|
| 90 |
+
"url": "https://dataverse.harvard.edu",
|
| 91 |
+
"scope": "Multidisciplinary repository with strong social science and statistical tooling.",
|
| 92 |
+
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 93 |
+
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
|
| 94 |
+
"curation": "Unmediated",
|
| 95 |
+
"source_url": "https://support.dataverse.harvard.edu", "verified": "2026-07",
|
| 96 |
+
"disciplines": ["social sciences", "economics", "political science", "all"],
|
| 97 |
+
},
|
| 98 |
+
"osf": {
|
| 99 |
+
"id": "osf", "name": "Open Science Framework (OSF)", "type": "Generalist",
|
| 100 |
+
"url": "https://osf.io",
|
| 101 |
+
"scope": "Project-based platform combining data, preprints, preregistration and materials.",
|
| 102 |
+
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 103 |
+
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
|
| 104 |
+
"curation": "Unmediated",
|
| 105 |
+
"source_url": "https://help.osf.io", "verified": "2026-07",
|
| 106 |
+
"disciplines": ["psychology", "social sciences", "all"],
|
| 107 |
+
},
|
| 108 |
+
"mendeley_data": {
|
| 109 |
+
"id": "mendeley_data", "name": "Mendeley Data", "type": "Generalist",
|
| 110 |
+
"url": "https://data.mendeley.com",
|
| 111 |
+
"scope": "Multidisciplinary data repository operated by Elsevier.",
|
| 112 |
+
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 113 |
+
"versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
|
| 114 |
+
"curation": "Unmediated",
|
| 115 |
+
"source_url": "https://data.mendeley.com", "verified": "2026-07",
|
| 116 |
+
"disciplines": ["all"],
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
# ---- Engineering / energy / materials — KU core ------------------------
|
| 120 |
+
"materials_cloud": {
|
| 121 |
+
"id": "materials_cloud", "name": "Materials Cloud", "type": "Domain-specific",
|
| 122 |
+
"url": "https://www.materialscloud.org",
|
| 123 |
+
"scope": "Computational materials science data, workflows and simulations.",
|
| 124 |
+
"data_types_accepted": "Simulation data, structures, workflows",
|
| 125 |
+
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 126 |
+
"cost": "Free", "curation": "Mediated",
|
| 127 |
+
"source_url": "https://www.materialscloud.org/home", "verified": "2026-07",
|
| 128 |
+
"disciplines": ["materials", "chemistry", "physics", "computational"],
|
| 129 |
+
},
|
| 130 |
+
"nomad": {
|
| 131 |
+
"id": "nomad", "name": "NOMAD", "type": "Domain-specific",
|
| 132 |
+
"url": "https://nomad-lab.eu",
|
| 133 |
+
"scope": "Repository for computational materials science, especially DFT calculations.",
|
| 134 |
+
"data_types_accepted": "Electronic-structure calculation outputs",
|
| 135 |
+
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 136 |
+
"cost": "Free", "curation": "Unmediated",
|
| 137 |
+
"source_url": "https://nomad-lab.eu", "verified": "2026-07",
|
| 138 |
+
"disciplines": ["materials", "physics", "chemistry", "computational"],
|
| 139 |
+
},
|
| 140 |
+
"oedi": {
|
| 141 |
+
"id": "oedi", "name": "Open Energy Data Initiative (OEDI)", "type": "Domain-specific",
|
| 142 |
+
"url": "https://data.openei.org",
|
| 143 |
+
"scope": "Energy systems, renewables, grid and buildings data (US DOE).",
|
| 144 |
+
"data_types_accepted": "Energy datasets, time series, geospatial",
|
| 145 |
+
"persistent_identifier": "DOI", "versioning": "", "embargo_support": "",
|
| 146 |
+
"cost": "Free", "curation": "Mediated",
|
| 147 |
+
"source_url": "https://data.openei.org", "verified": "2026-07",
|
| 148 |
+
"disciplines": ["energy", "renewables", "engineering", "sustainability"],
|
| 149 |
+
},
|
| 150 |
+
"ieee_dataport": {
|
| 151 |
+
"id": "ieee_dataport", "name": "IEEE DataPort", "type": "Domain-specific",
|
| 152 |
+
"url": "https://ieee-dataport.org",
|
| 153 |
+
"scope": "Datasets across electrical engineering, computing, communications and signal processing.",
|
| 154 |
+
"data_types_accepted": "All types", "persistent_identifier": "DOI",
|
| 155 |
+
"versioning": "Yes", "embargo_support": "Yes",
|
| 156 |
+
"cost": "Free for open datasets; subscription for some features",
|
| 157 |
+
"curation": "Unmediated",
|
| 158 |
+
"source_url": "https://ieee-dataport.org", "verified": "2026-07",
|
| 159 |
+
"disciplines": ["engineering", "electrical", "computing", "communications", "signal processing"],
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
# ---- Earth / environment / geospatial ---------------------------------
|
| 163 |
+
"pangaea": {
|
| 164 |
+
"id": "pangaea", "name": "PANGAEA", "type": "Domain-specific",
|
| 165 |
+
"url": "https://www.pangaea.de",
|
| 166 |
+
"scope": "Earth and environmental science data; georeferenced observations.",
|
| 167 |
+
"data_types_accepted": "Georeferenced environmental data",
|
| 168 |
+
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 169 |
+
"cost": "Free", "curation": "Mediated — editorial review",
|
| 170 |
+
"source_url": "https://www.pangaea.de/submit/", "verified": "2026-07",
|
| 171 |
+
"disciplines": ["earth science", "environmental", "climate", "oceanography", "geoscience"],
|
| 172 |
+
},
|
| 173 |
+
"esa_earth": {
|
| 174 |
+
"id": "esa_earth", "name": "ESA Earth Online", "type": "Domain-specific",
|
| 175 |
+
"url": "https://earth.esa.int",
|
| 176 |
+
"scope": "Earth observation and satellite remote-sensing datasets.",
|
| 177 |
+
"data_types_accepted": "Satellite imagery, EO products",
|
| 178 |
+
"persistent_identifier": "", "versioning": "", "embargo_support": "",
|
| 179 |
+
"cost": "Free", "curation": "Mediated",
|
| 180 |
+
"source_url": "https://earth.esa.int", "verified": "2026-07",
|
| 181 |
+
"disciplines": ["remote sensing", "earth observation", "geospatial", "climate"],
|
| 182 |
+
},
|
| 183 |
+
|
| 184 |
+
# ---- Life sciences / biomedical ---------------------------------------
|
| 185 |
+
"ena": {
|
| 186 |
+
"id": "ena", "name": "European Nucleotide Archive (ENA)", "type": "Domain-specific",
|
| 187 |
+
"url": "https://www.ebi.ac.uk/ena",
|
| 188 |
+
"scope": "Nucleotide sequencing data — raw reads, assemblies, annotations.",
|
| 189 |
+
"data_types_accepted": "Sequence data (FASTQ, BAM, assemblies)",
|
| 190 |
+
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 191 |
+
"cost": "Free", "curation": "Mediated — format validation",
|
| 192 |
+
"source_url": "https://www.ebi.ac.uk/ena/browser/submit", "verified": "2026-07",
|
| 193 |
+
"disciplines": ["genomics", "biology", "life sciences", "sequencing", "microbiology"],
|
| 194 |
+
},
|
| 195 |
+
"geo": {
|
| 196 |
+
"id": "geo", "name": "NCBI GEO", "type": "Domain-specific",
|
| 197 |
+
"url": "https://www.ncbi.nlm.nih.gov/geo",
|
| 198 |
+
"scope": "Functional genomics data — gene expression, microarray, high-throughput sequencing.",
|
| 199 |
+
"data_types_accepted": "Expression and functional genomics data",
|
| 200 |
+
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 201 |
+
"cost": "Free", "curation": "Mediated",
|
| 202 |
+
"source_url": "https://www.ncbi.nlm.nih.gov/geo/info/submission.html", "verified": "2026-07",
|
| 203 |
+
"disciplines": ["genomics", "transcriptomics", "biology", "life sciences"],
|
| 204 |
+
},
|
| 205 |
+
"pride": {
|
| 206 |
+
"id": "pride", "name": "PRIDE", "type": "Domain-specific",
|
| 207 |
+
"url": "https://www.ebi.ac.uk/pride",
|
| 208 |
+
"scope": "Mass-spectrometry proteomics data.",
|
| 209 |
+
"data_types_accepted": "Proteomics MS data",
|
| 210 |
+
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 211 |
+
"cost": "Free", "curation": "Mediated",
|
| 212 |
+
"source_url": "https://www.ebi.ac.uk/pride/markdownpage/submitdatapage", "verified": "2026-07",
|
| 213 |
+
"disciplines": ["proteomics", "biochemistry", "life sciences"],
|
| 214 |
+
},
|
| 215 |
+
"pdb": {
|
| 216 |
+
"id": "pdb", "name": "Protein Data Bank (PDB)", "type": "Domain-specific",
|
| 217 |
+
"url": "https://www.rcsb.org",
|
| 218 |
+
"scope": "Three-dimensional structural data for biological macromolecules.",
|
| 219 |
+
"data_types_accepted": "Macromolecular structures",
|
| 220 |
+
"persistent_identifier": "PDB ID", "versioning": "Yes", "embargo_support": "Yes",
|
| 221 |
+
"cost": "Free", "curation": "Mediated — biocuration",
|
| 222 |
+
"source_url": "https://www.rcsb.org/pages/policy", "verified": "2026-07",
|
| 223 |
+
"disciplines": ["structural biology", "biochemistry", "chemistry", "life sciences"],
|
| 224 |
+
},
|
| 225 |
+
"metabolights": {
|
| 226 |
+
"id": "metabolights", "name": "MetaboLights", "type": "Domain-specific",
|
| 227 |
+
"url": "https://www.ebi.ac.uk/metabolights",
|
| 228 |
+
"scope": "Metabolomics experiments and derived information.",
|
| 229 |
+
"data_types_accepted": "Metabolomics data",
|
| 230 |
+
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 231 |
+
"cost": "Free", "curation": "Mediated",
|
| 232 |
+
"source_url": "https://www.ebi.ac.uk/metabolights", "verified": "2026-07",
|
| 233 |
+
"disciplines": ["metabolomics", "biochemistry", "life sciences"],
|
| 234 |
+
},
|
| 235 |
+
|
| 236 |
+
# ---- Chemistry --------------------------------------------------------
|
| 237 |
+
"chemrxiv_data": {
|
| 238 |
+
"id": "chemrxiv_data", "name": "Cambridge Structural Database (CCDC)",
|
| 239 |
+
"type": "Domain-specific", "url": "https://www.ccdc.cam.ac.uk",
|
| 240 |
+
"scope": "Small-molecule organic and metal-organic crystal structures.",
|
| 241 |
+
"data_types_accepted": "Crystallographic data (CIF)",
|
| 242 |
+
"persistent_identifier": "CCDC number", "versioning": "", "embargo_support": "Yes",
|
| 243 |
+
"cost": "Free deposition", "curation": "Mediated",
|
| 244 |
+
"source_url": "https://www.ccdc.cam.ac.uk/structures/", "verified": "2026-07",
|
| 245 |
+
"disciplines": ["chemistry", "crystallography", "materials"],
|
| 246 |
+
},
|
| 247 |
+
|
| 248 |
+
# ---- Computing / AI ---------------------------------------------------
|
| 249 |
+
"huggingface_datasets": {
|
| 250 |
+
"id": "huggingface_datasets", "name": "Hugging Face Datasets",
|
| 251 |
+
"type": "Domain-specific", "url": "https://huggingface.co/datasets",
|
| 252 |
+
"scope": "Machine learning datasets and models with versioned git-based storage.",
|
| 253 |
+
"data_types_accepted": "ML datasets, models",
|
| 254 |
+
"persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
|
| 255 |
+
"cost": "Free tier available", "curation": "Unmediated",
|
| 256 |
+
"source_url": "https://huggingface.co/docs/hub/datasets", "verified": "2026-07",
|
| 257 |
+
"disciplines": ["machine learning", "ai", "nlp", "computer vision", "computing"],
|
| 258 |
+
},
|
| 259 |
+
"openml": {
|
| 260 |
+
"id": "openml", "name": "OpenML", "type": "Domain-specific",
|
| 261 |
+
"url": "https://www.openml.org",
|
| 262 |
+
"scope": "Machine learning datasets, tasks, flows and benchmark runs.",
|
| 263 |
+
"data_types_accepted": "Tabular ML datasets",
|
| 264 |
+
"persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
|
| 265 |
+
"cost": "Free", "curation": "Unmediated",
|
| 266 |
+
"source_url": "https://docs.openml.org", "verified": "2026-07",
|
| 267 |
+
"disciplines": ["machine learning", "ai", "data science", "computing"],
|
| 268 |
+
},
|
| 269 |
+
|
| 270 |
+
# ---- Social science / humanities --------------------------------------
|
| 271 |
+
"ukds": {
|
| 272 |
+
"id": "ukds", "name": "UK Data Service ReShare", "type": "Domain-specific",
|
| 273 |
+
"url": "https://reshare.ukdataservice.ac.uk",
|
| 274 |
+
"scope": "Social and economic research data, including sensitive data under controlled access.",
|
| 275 |
+
"data_types_accepted": "Social science data",
|
| 276 |
+
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 277 |
+
"cost": "Free", "curation": "Mediated",
|
| 278 |
+
"source_url": "https://reshare.ukdataservice.ac.uk", "verified": "2026-07",
|
| 279 |
+
"disciplines": ["social sciences", "economics", "sociology", "survey"],
|
| 280 |
+
},
|
| 281 |
+
"icpsr": {
|
| 282 |
+
"id": "icpsr", "name": "ICPSR", "type": "Domain-specific",
|
| 283 |
+
"url": "https://www.icpsr.umich.edu",
|
| 284 |
+
"scope": "Social and behavioural science data with strong curation and restricted-access options.",
|
| 285 |
+
"data_types_accepted": "Social science data",
|
| 286 |
+
"persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
|
| 287 |
+
"cost": "Varies by membership", "curation": "Mediated — extensive curation",
|
| 288 |
+
"source_url": "https://www.icpsr.umich.edu/web/pages/deposit/", "verified": "2026-07",
|
| 289 |
+
"disciplines": ["social sciences", "behavioural", "health", "survey"],
|
| 290 |
+
},
|
| 291 |
+
|
| 292 |
+
# ---- Sensitive / controlled access -------------------------------------
|
| 293 |
+
"ega": {
|
| 294 |
+
"id": "ega", "name": "European Genome-phenome Archive (EGA)",
|
| 295 |
+
"type": "Domain-specific", "url": "https://ega-archive.org",
|
| 296 |
+
"scope": "Controlled-access repository for personally identifiable genetic and phenotypic data.",
|
| 297 |
+
"data_types_accepted": "Human genomic and phenotypic data",
|
| 298 |
+
"persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
|
| 299 |
+
"cost": "Free", "curation": "Mediated — DAC-controlled access",
|
| 300 |
+
"source_url": "https://ega-archive.org/submission", "verified": "2026-07",
|
| 301 |
+
"disciplines": ["human genomics", "clinical", "biomedical", "sensitive"],
|
| 302 |
+
},
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
| 307 |
+
# The model may author ONLY analytical fit fields. Every other repository
|
| 308 |
+
# attribute is supplied from REGISTRY, including the id itself after lookup.
|
| 309 |
+
MODEL_ALLOWED = ("fit_reason", "fit_confidence")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _clean_text(value: Any, limit: int = 1000) -> str:
|
| 313 |
+
return " ".join(str(value or "").split())[:limit]
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _clean_list(value: Any, limit: int = 12, item_limit: int = 500) -> list[str]:
|
| 317 |
+
if not isinstance(value, list):
|
| 318 |
+
return []
|
| 319 |
+
out: list[str] = []
|
| 320 |
+
for item in value[:limit]:
|
| 321 |
+
cleaned = _clean_text(item, item_limit)
|
| 322 |
+
if cleaned:
|
| 323 |
+
out.append(cleaned)
|
| 324 |
+
return out
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _yes_no(value: Any) -> str:
|
| 328 |
+
text = _clean_text(value, 100).casefold()
|
| 329 |
+
if text in {"yes", "true"} or text.startswith("yes ") or text.startswith("yes—"):
|
| 330 |
+
return "Yes"
|
| 331 |
+
if text in {"no", "false"}:
|
| 332 |
+
return "No"
|
| 333 |
+
return "Unknown"
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def repository_capabilities(entry: dict) -> dict:
|
| 337 |
+
"""Expose only capabilities supported by existing curated fields.
|
| 338 |
+
|
| 339 |
+
A generic homepage and a broad access description do not establish approval
|
| 340 |
+
for a particular sensitive dataset, size limit or accepted file format.
|
| 341 |
+
"""
|
| 342 |
+
identifier = _clean_text(entry.get("persistent_identifier"), 100)
|
| 343 |
+
description = (str(entry.get("scope", "")) + " " + str(entry.get("curation", ""))).casefold()
|
| 344 |
+
controlled = any(term in description for term in ("controlled access", "controlled-access", "restricted-access", "restricted access"))
|
| 345 |
+
return {
|
| 346 |
+
"doi": "Yes" if identifier.casefold() == "doi" else "No" if identifier else "Unknown",
|
| 347 |
+
"persistent_id_type": identifier or "Unknown",
|
| 348 |
+
"versioning": _yes_no(entry.get("versioning")),
|
| 349 |
+
"embargo": _yes_no(entry.get("embargo_support")),
|
| 350 |
+
"restricted_access": "Yes" if controlled and not entry.get("is_khazna") else "Unknown",
|
| 351 |
+
"approved_sensitive_data": "Unknown",
|
| 352 |
+
"accepts_external_deposits": "Unknown",
|
| 353 |
+
"accepted_file_formats": "Unknown",
|
| 354 |
+
"maximum_dataset_size": "Unknown",
|
| 355 |
+
"source_url": entry.get("source_url", ""),
|
| 356 |
+
"reviewed": entry.get("verified", ""),
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def evaluate_repository(entry: dict, constraints: dict | None = None) -> dict:
|
| 361 |
+
"""Check stated requirements against curated facts; unknown is not Yes."""
|
| 362 |
+
constraints = constraints if isinstance(constraints, dict) else {}
|
| 363 |
+
capabilities = repository_capabilities(entry)
|
| 364 |
+
unresolved, conflicts = [], []
|
| 365 |
+
sensitivity = _clean_text(constraints.get("sensitivity"), 100).casefold()
|
| 366 |
+
personal = _clean_text(constraints.get("contains_personal_data"), 100).casefold()
|
| 367 |
+
sensitive = (bool(sensitivity) and sensitivity not in {"open", "none", "non-sensitive", "not sensitive"}) or (bool(personal) and personal != "no")
|
| 368 |
+
for requirement, capability, label in (
|
| 369 |
+
("needs_doi", "doi", "DOI assignment"),
|
| 370 |
+
("needs_versioning", "versioning", "versioning"),
|
| 371 |
+
("embargo_required", "embargo", "an embargo"),
|
| 372 |
+
):
|
| 373 |
+
if _yes_no(constraints.get(requirement)) != "Yes":
|
| 374 |
+
continue
|
| 375 |
+
if capabilities[capability] == "No":
|
| 376 |
+
conflicts.append(f"The curated record does not support the requested {label}.")
|
| 377 |
+
elif capabilities[capability] != "Yes":
|
| 378 |
+
unresolved.append(f"Confirm support for {label} with the repository.")
|
| 379 |
+
if sensitive:
|
| 380 |
+
if capabilities["restricted_access"] != "Yes":
|
| 381 |
+
conflicts.append("Controlled/restricted access suitable for the submitted sensitive or personal data is not established.")
|
| 382 |
+
unresolved.append("KU Library and the responsible ethics/data-protection team must approve the repository and access conditions for this dataset before any file upload.")
|
| 383 |
+
if _clean_text(constraints.get("file_formats")):
|
| 384 |
+
unresolved.append("Confirm acceptance of the submitted file formats: " + _clean_text(constraints["file_formats"], 300))
|
| 385 |
+
if _clean_text(constraints.get("approx_size")):
|
| 386 |
+
unresolved.append("Confirm the size/quota and any associated cost for: " + _clean_text(constraints["approx_size"], 300))
|
| 387 |
+
if _clean_text(constraints.get("funder")):
|
| 388 |
+
unresolved.append("Confirm the funder's repository, licence and retention requirements; the registry does not establish mandate compliance.")
|
| 389 |
+
if _clean_text(constraints.get("licence_intent")):
|
| 390 |
+
unresolved.append("Confirm that the repository supports the selected licence and that you hold the necessary rights.")
|
| 391 |
+
institution = _clean_text(constraints.get("institution"))
|
| 392 |
+
country = _clean_text(constraints.get("country"))
|
| 393 |
+
if institution or country:
|
| 394 |
+
unresolved.append("Confirm depositor eligibility and any institution/country restrictions.")
|
| 395 |
+
if _yes_no(constraints.get("embargo_required")) == "Yes":
|
| 396 |
+
duration = constraints.get("embargo_months")
|
| 397 |
+
if duration is not None:
|
| 398 |
+
unresolved.append(f"Confirm support for the requested {duration}-month embargo.")
|
| 399 |
+
elif constraints.get("embargo_duration"):
|
| 400 |
+
unresolved.append("Confirm support for the requested embargo duration: " + _clean_text(constraints["embargo_duration"], 100))
|
| 401 |
+
if entry.get("id") == "dryad" and "software" in _clean_text(constraints.get("data_types")).casefold():
|
| 402 |
+
conflicts.append("The curated record lists research data, not software, as accepted content.")
|
| 403 |
+
metadata_only = bool(entry.get("is_khazna"))
|
| 404 |
+
if metadata_only:
|
| 405 |
+
unresolved.insert(0, "Ask KU Library about metadata registration; dataset file acceptance, DOI assignment and access controls require confirmation.")
|
| 406 |
+
# Institutional registration advice is not a file-deposit destination.
|
| 407 |
+
conflicts = []
|
| 408 |
+
return {
|
| 409 |
+
"state": "metadata_only" if metadata_only else "does_not_meet" if conflicts else "conditional" if unresolved else "meets",
|
| 410 |
+
"unresolved_constraints": list(dict.fromkeys(unresolved)),
|
| 411 |
+
"constraint_conflicts": list(dict.fromkeys(conflicts)),
|
| 412 |
+
"sensitive_data_review_required": sensitive,
|
| 413 |
+
"file_deposit_recommended": not metadata_only and not conflicts and not unresolved,
|
| 414 |
+
"strategy_eligible": not metadata_only and not conflicts and not sensitive,
|
| 415 |
+
"capabilities": capabilities,
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def registry_for_prompt(limit: int = 0) -> str:
|
| 420 |
+
"""Compact registry listing for the model to select from."""
|
| 421 |
+
lines = []
|
| 422 |
+
for r in REGISTRY.values():
|
| 423 |
+
lines.append(
|
| 424 |
+
f'- id="{r["id"]}" | {r["name"]} | {r["type"]} | '
|
| 425 |
+
f'disciplines: {", ".join(r["disciplines"])} | {r["scope"]} | '
|
| 426 |
+
f'capabilities: {json.dumps(repository_capabilities(r), ensure_ascii=False)} | '
|
| 427 |
+
f'accepted content: {r.get("data_types_accepted", "Unknown")} | cost: {r.get("cost", "Unknown")}'
|
| 428 |
+
)
|
| 429 |
+
if limit and len(lines) >= limit:
|
| 430 |
+
break
|
| 431 |
+
return "\n".join(lines)
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def hydrate_selection(selected: list[dict], constraints: dict | None = None) -> list[dict]:
|
| 435 |
+
"""
|
| 436 |
+
Hydrate exact registry ids and discard every model-authored factual field.
|
| 437 |
+
|
| 438 |
+
Only `fit_reason` and `fit_confidence` survive from the model. Unknown ids,
|
| 439 |
+
duplicate ids, non-object entries, and malformed confidence values are
|
| 440 |
+
dropped or normalised rather than displayed as repository facts.
|
| 441 |
+
"""
|
| 442 |
+
out: list[dict] = []
|
| 443 |
+
seen: set[str] = set()
|
| 444 |
+
if not isinstance(selected, list):
|
| 445 |
+
return out
|
| 446 |
+
|
| 447 |
+
for item in selected:
|
| 448 |
+
if not isinstance(item, dict):
|
| 449 |
+
continue
|
| 450 |
+
rid = str(item.get("id", "")).strip().lower()
|
| 451 |
+
entry = REGISTRY.get(rid)
|
| 452 |
+
if not entry or rid in seen:
|
| 453 |
+
continue
|
| 454 |
+
seen.add(rid)
|
| 455 |
+
assessment = evaluate_repository(entry, constraints)
|
| 456 |
+
if constraints is not None and assessment["state"] == "does_not_meet":
|
| 457 |
+
continue
|
| 458 |
+
|
| 459 |
+
fit_reason = _clean_text(item.get("fit_reason"), 1200)
|
| 460 |
+
confidence = _clean_text(item.get("fit_confidence"), 20).title()
|
| 461 |
+
if confidence not in {"High", "Medium", "Low"}:
|
| 462 |
+
confidence = ""
|
| 463 |
+
|
| 464 |
+
merged = dict(entry)
|
| 465 |
+
merged["fit_reason"] = fit_reason
|
| 466 |
+
merged["fit_confidence"] = confidence
|
| 467 |
+
# Backwards-compatible alias for older frontends; it is still an
|
| 468 |
+
# analytical fit score, never a repository-fact confidence score.
|
| 469 |
+
merged["confidence"] = confidence
|
| 470 |
+
merged["verification_status"] = "Curated KU Library registry record"
|
| 471 |
+
merged["verified_note"] = (
|
| 472 |
+
f"Repository details verified {entry['verified']} from {entry['source_url']}"
|
| 473 |
+
)
|
| 474 |
+
merged["capabilities"] = assessment["capabilities"]
|
| 475 |
+
merged["constraint_assessment"] = assessment
|
| 476 |
+
merged["file_deposit_recommended"] = assessment["file_deposit_recommended"]
|
| 477 |
+
merged["metadata_registration_only"] = bool(entry.get("is_khazna"))
|
| 478 |
+
if constraints is not None and assessment["state"] == "conditional":
|
| 479 |
+
merged["fit_reason"] = ("Candidate for review. " + fit_reason + " " + " ".join(assessment["unresolved_constraints"]))[:3000]
|
| 480 |
+
merged["fit_confidence"] = "Low" if assessment["sensitive_data_review_required"] else "Medium"
|
| 481 |
+
merged["confidence"] = merged["fit_confidence"]
|
| 482 |
+
out.append(merged)
|
| 483 |
+
return out
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def selection_exclusions(selected: list[dict], constraints: dict | None = None) -> list[dict]:
|
| 487 |
+
"""Explain deterministic exclusions without trusting model-authored facts."""
|
| 488 |
+
excluded, seen = [], set()
|
| 489 |
+
for item in selected if isinstance(selected, list) else []:
|
| 490 |
+
if not isinstance(item, dict):
|
| 491 |
+
continue
|
| 492 |
+
rid = _clean_text(item.get("id"), 100).lower()
|
| 493 |
+
entry = REGISTRY.get(rid)
|
| 494 |
+
if not entry or rid in seen:
|
| 495 |
+
continue
|
| 496 |
+
seen.add(rid)
|
| 497 |
+
result = evaluate_repository(entry, constraints)
|
| 498 |
+
if result["state"] == "does_not_meet":
|
| 499 |
+
excluded.append({"id": rid, "name": entry["name"], "reasons": result["constraint_conflicts"],
|
| 500 |
+
"unresolved_constraints": result["unresolved_constraints"]})
|
| 501 |
+
return excluded
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def hydrate_deposit_strategy(raw: Any, hydrated: list[dict], constraints: dict | None = None) -> dict:
|
| 505 |
+
"""Resolve model-selected ids to server-owned names and bound free text."""
|
| 506 |
+
raw = raw if isinstance(raw, dict) else {}
|
| 507 |
+
by_id = {str(r.get("id", "")): r for r in hydrated if r.get("id")}
|
| 508 |
+
eligible = {rid for rid, entry in by_id.items()
|
| 509 |
+
if not entry.get("is_khazna") and entry.get("constraint_assessment", {}).get("strategy_eligible", True)}
|
| 510 |
+
ranked_ids = [
|
| 511 |
+
str(r.get("id")) for r in hydrated
|
| 512 |
+
if r.get("id") in eligible
|
| 513 |
+
]
|
| 514 |
+
|
| 515 |
+
def selected_id(key: str, fallback_index: int) -> str:
|
| 516 |
+
candidate = str(raw.get(key, "")).strip().lower()
|
| 517 |
+
if candidate in eligible:
|
| 518 |
+
return candidate
|
| 519 |
+
return ranked_ids[fallback_index] if len(ranked_ids) > fallback_index else ""
|
| 520 |
+
|
| 521 |
+
primary_id = selected_id("primary_repository_id", 0)
|
| 522 |
+
secondary_id = selected_id("secondary_repository_id", 1)
|
| 523 |
+
if secondary_id == primary_id:
|
| 524 |
+
secondary_id = ranked_ids[1] if len(ranked_ids) > 1 else ""
|
| 525 |
+
|
| 526 |
+
checks = _clean_list(raw.get("manual_checks_required"), limit=10)
|
| 527 |
+
for entry in hydrated:
|
| 528 |
+
checks.extend(entry.get("constraint_assessment", {}).get("unresolved_constraints", []))
|
| 529 |
+
if not primary_id:
|
| 530 |
+
checks.insert(0, "No file-deposit destination has been established for these requirements; contact KU Library before uploading data.")
|
| 531 |
+
workflow = _clean_list(raw.get("deposit_workflow"), limit=10)
|
| 532 |
+
if constraints is not None:
|
| 533 |
+
# Model prose must not instruct immediate upload while deterministic
|
| 534 |
+
# requirements remain unresolved, especially for sensitive datasets.
|
| 535 |
+
workflow = (["Review the candidate repository's source guidance and all unresolved requirements.",
|
| 536 |
+
"Confirm rights, consent, access conditions and depositor eligibility.",
|
| 537 |
+
"Prepare metadata and supporting documentation; upload files only after all checks are complete."]
|
| 538 |
+
if primary_id else ["Contact KU Library to identify an approved deposit or metadata-registration route.",
|
| 539 |
+
"Do not upload dataset files until a suitable destination and access conditions are confirmed."])
|
| 540 |
+
return {
|
| 541 |
+
"primary_repository_id": primary_id,
|
| 542 |
+
"primary_url": REGISTRY.get(primary_id, {}).get("url"),
|
| 543 |
+
"secondary_url": REGISTRY.get(secondary_id, {}).get("url"),
|
| 544 |
+
"secondary_repository_id": secondary_id,
|
| 545 |
+
"primary_choice": by_id.get(primary_id, {}).get("name", "Not determined"),
|
| 546 |
+
"secondary_choice": by_id.get(secondary_id, {}).get("name", "Not determined"),
|
| 547 |
+
"metadata_standard": _clean_text(
|
| 548 |
+
raw.get("metadata_standard") or "Check the selected repository's current metadata guidance.",
|
| 549 |
+
500,
|
| 550 |
+
),
|
| 551 |
+
"deposit_workflow": workflow,
|
| 552 |
+
"manual_checks_required": list(dict.fromkeys(checks)),
|
| 553 |
+
"selection_status": "candidates_require_review" if checks and primary_id else "no_verified_destination" if not primary_id else "requirements_matched",
|
| 554 |
+
"khazna_metadata_note": (
|
| 555 |
+
"Ask KU Library about registering dataset metadata in Khazna when the files are held in an appropriate external repository."
|
| 556 |
+
),
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def khazna_entry(constraints: dict | None = None) -> dict:
|
| 561 |
+
e = dict(REGISTRY["khazna"])
|
| 562 |
+
e["fit_reason"] = ("Contact KU Library about registering dataset metadata in Khazna for institutional visibility. "
|
| 563 |
+
"File-deposit eligibility, DOI assignment and access controls must be confirmed separately.")
|
| 564 |
+
e["metadata_registration_only"] = True
|
| 565 |
+
e["file_deposit_recommended"] = False
|
| 566 |
+
e["constraint_assessment"] = evaluate_repository(e, constraints)
|
| 567 |
+
e["capabilities"] = e["constraint_assessment"]["capabilities"]
|
| 568 |
+
e["fit_confidence"] = "High"
|
| 569 |
+
e["confidence"] = "High"
|
| 570 |
+
e["verification_status"] = "Curated KU Library registry record"
|
| 571 |
+
e["verified_note"] = (f"Repository details verified {e['verified']} "
|
| 572 |
+
f"from {e['source_url']}")
|
| 573 |
+
return e
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
# ══════════════════════════════════════════════════════════════════════════
|
| 577 |
+
# SUBJECT REPOSITORIES — for ARTICLES, not datasets
|
| 578 |
+
# ══════════════════════════════════════════════════════════════════════════
|
| 579 |
+
# OA.Works returns permitted locations such as "Non-commercial Subject
|
| 580 |
+
# Repository". That string alone is not actionable: the author still has to
|
| 581 |
+
# know WHICH one. These entries are matched against the journal's SCImago
|
| 582 |
+
# subject areas/categories so the answer is specific.
|
| 583 |
+
#
|
| 584 |
+
# Deliberately distinct from REGISTRY above, which holds DATA repositories.
|
| 585 |
+
# Depositing a manuscript in Dryad or a dataset in arXiv are both wrong.
|
| 586 |
+
|
| 587 |
+
SUBJECT_REPOSITORIES: dict[str, dict] = {
|
| 588 |
+
"arxiv": {
|
| 589 |
+
"id": "arxiv", "name": "arXiv", "url": "https://arxiv.org",
|
| 590 |
+
"scope": "Physics, mathematics, computer science, quantitative biology, "
|
| 591 |
+
"statistics, electrical engineering and economics.",
|
| 592 |
+
"versions": "Preprint and accepted manuscript",
|
| 593 |
+
"moderation": "Moderated; endorsement required for first submission",
|
| 594 |
+
"source_url": "https://arxiv.org/help/submit", "verified": "2026-07",
|
| 595 |
+
"match": ["physics", "astronomy", "mathematics", "computer science",
|
| 596 |
+
"engineering", "electrical", "statistics", "economics",
|
| 597 |
+
"materials science", "energy"],
|
| 598 |
+
},
|
| 599 |
+
"pmc": {
|
| 600 |
+
"id": "pmc", "name": "Europe PMC / PubMed Central",
|
| 601 |
+
"url": "https://europepmc.org",
|
| 602 |
+
"scope": "Biomedical and life sciences literature. Required by many "
|
| 603 |
+
"health funders.",
|
| 604 |
+
"versions": "Accepted manuscript and published version where licensed",
|
| 605 |
+
"moderation": "Mediated; funder-linked deposit routes available",
|
| 606 |
+
"source_url": "https://europepmc.org/Help", "verified": "2026-07",
|
| 607 |
+
"match": ["medicine", "biochemistry", "immunology", "microbiology",
|
| 608 |
+
"neuroscience", "pharmacology", "health", "nursing",
|
| 609 |
+
"veterinary", "dentistry", "biology"],
|
| 610 |
+
},
|
| 611 |
+
"biorxiv": {
|
| 612 |
+
"id": "biorxiv", "name": "bioRxiv", "url": "https://www.biorxiv.org",
|
| 613 |
+
"scope": "Preprints across the biological sciences.",
|
| 614 |
+
"versions": "Preprint (submitted version) only",
|
| 615 |
+
"moderation": "Screened, not peer reviewed",
|
| 616 |
+
"source_url": "https://www.biorxiv.org/about/FAQ", "verified": "2026-07",
|
| 617 |
+
"match": ["biology", "biochemistry", "genetics", "neuroscience",
|
| 618 |
+
"immunology", "microbiology", "agricultural"],
|
| 619 |
+
},
|
| 620 |
+
"chemrxiv": {
|
| 621 |
+
"id": "chemrxiv", "name": "ChemRxiv", "url": "https://chemrxiv.org",
|
| 622 |
+
"scope": "Preprints in chemistry and related areas.",
|
| 623 |
+
"versions": "Preprint (submitted version) only",
|
| 624 |
+
"moderation": "Screened, not peer reviewed",
|
| 625 |
+
"source_url": "https://chemrxiv.org/engage/chemrxiv/public-dashboard",
|
| 626 |
+
"verified": "2026-07",
|
| 627 |
+
"match": ["chemistry", "chemical engineering", "materials science"],
|
| 628 |
+
},
|
| 629 |
+
"engrxiv": {
|
| 630 |
+
"id": "engrxiv", "name": "engrXiv", "url": "https://engrxiv.org",
|
| 631 |
+
"scope": "Preprints across all engineering disciplines.",
|
| 632 |
+
"versions": "Preprint and accepted manuscript",
|
| 633 |
+
"moderation": "Screened, not peer reviewed",
|
| 634 |
+
"source_url": "https://engrxiv.org", "verified": "2026-07",
|
| 635 |
+
"match": ["engineering", "civil", "mechanical", "aerospace",
|
| 636 |
+
"industrial", "environmental engineering"],
|
| 637 |
+
},
|
| 638 |
+
"ssrn": {
|
| 639 |
+
"id": "ssrn", "name": "SSRN", "url": "https://www.ssrn.com",
|
| 640 |
+
"scope": "Social sciences, economics, management, law and humanities.",
|
| 641 |
+
"versions": "Preprint and accepted manuscript",
|
| 642 |
+
"moderation": "Screened",
|
| 643 |
+
"source_url": "https://www.ssrn.com/index.cfm/en/", "verified": "2026-07",
|
| 644 |
+
"match": ["social sciences", "economics", "business", "management",
|
| 645 |
+
"accounting", "finance", "law", "psychology", "decision sciences"],
|
| 646 |
+
},
|
| 647 |
+
"repec": {
|
| 648 |
+
"id": "repec", "name": "RePEc", "url": "http://repec.org",
|
| 649 |
+
"scope": "Working papers and articles in economics and finance.",
|
| 650 |
+
"versions": "Working paper and accepted manuscript",
|
| 651 |
+
"moderation": "Institution-mediated archives",
|
| 652 |
+
"source_url": "http://repec.org/", "verified": "2026-07",
|
| 653 |
+
"match": ["economics", "econometrics", "finance"],
|
| 654 |
+
},
|
| 655 |
+
"eartharxiv": {
|
| 656 |
+
"id": "eartharxiv", "name": "EarthArXiv",
|
| 657 |
+
"url": "https://eartharxiv.org",
|
| 658 |
+
"scope": "Preprints in earth, planetary and environmental sciences.",
|
| 659 |
+
"versions": "Preprint and accepted manuscript",
|
| 660 |
+
"moderation": "Screened, not peer reviewed",
|
| 661 |
+
"source_url": "https://eartharxiv.org", "verified": "2026-07",
|
| 662 |
+
"match": ["earth", "planetary", "environmental science", "geology",
|
| 663 |
+
"atmospheric", "oceanography"],
|
| 664 |
+
},
|
| 665 |
+
"medrxiv": {
|
| 666 |
+
"id": "medrxiv", "name": "medRxiv", "url": "https://www.medrxiv.org",
|
| 667 |
+
"scope": "Preprints in clinical and health sciences.",
|
| 668 |
+
"versions": "Preprint (submitted version) only",
|
| 669 |
+
"moderation": "Screened; clinical claims reviewed",
|
| 670 |
+
"source_url": "https://www.medrxiv.org/about/FAQ", "verified": "2026-07",
|
| 671 |
+
"match": ["medicine", "health", "clinical", "epidemiology", "public health"],
|
| 672 |
+
},
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
# Always offered when a subject repository is permitted but nothing matched.
|
| 676 |
+
SUBJECT_FALLBACK = ["arxiv", "ssrn"]
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
def suggest_subject_repositories(areas: str = "", categories: str = "",
|
| 680 |
+
limit: int = 3) -> list[dict]:
|
| 681 |
+
"""
|
| 682 |
+
Match a journal's SCImago subject areas/categories to subject repositories.
|
| 683 |
+
|
| 684 |
+
Deterministic keyword scoring — no LLM. Returns [] rather than guessing
|
| 685 |
+
when nothing matches well and no fallback is warranted.
|
| 686 |
+
"""
|
| 687 |
+
haystack = f"{areas} {categories}".lower()
|
| 688 |
+
if not haystack.strip():
|
| 689 |
+
return []
|
| 690 |
+
|
| 691 |
+
scored: list[tuple[int, dict]] = []
|
| 692 |
+
for entry in SUBJECT_REPOSITORIES.values():
|
| 693 |
+
score = sum(1 for kw in entry["match"] if kw in haystack)
|
| 694 |
+
if score:
|
| 695 |
+
scored.append((score, entry))
|
| 696 |
+
|
| 697 |
+
scored.sort(key=lambda x: (-x[0], x[1]["name"]))
|
| 698 |
+
picked = [dict(e) for _, e in scored[:limit]]
|
| 699 |
+
|
| 700 |
+
# An unrelated fallback is not a subject match. Return no suggestions if
|
| 701 |
+
# the supplied discipline does not match a curated repository.
|
| 702 |
+
if not picked:
|
| 703 |
+
return []
|
| 704 |
+
|
| 705 |
+
for p in picked:
|
| 706 |
+
p.pop("match", None)
|
| 707 |
+
p["verified_note"] = f"Verified {p['verified']} from {p['source_url']}"
|
| 708 |
+
return picked
|
scimago_index.py
CHANGED
|
@@ -183,6 +183,330 @@ def closest_subject_term(text: str, cutoff: float = 0.90) -> Optional[Dict[str,
|
|
| 183 |
}
|
| 184 |
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
_subject_token_df_cache: Optional[Dict[str, float]] = None
|
| 187 |
|
| 188 |
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
|
| 186 |
+
# ── Deterministic nearest-subject resolution ──────────────────────────────
|
| 187 |
+
#
|
| 188 |
+
# Researchers type everyday discipline names ("physics", "maths", "machine
|
| 189 |
+
# learning") that are not literal SCImago/ASJC names. Previously such input
|
| 190 |
+
# depended entirely on the LLM classifier; when it was unavailable or answered
|
| 191 |
+
# "not_found", the user saw no journals at all.
|
| 192 |
+
#
|
| 193 |
+
# This resolver is deliberately conservative and auditable:
|
| 194 |
+
# 1. A reviewed alias table maps common discipline names onto real
|
| 195 |
+
# vocabulary terms. Every target is validated against the live
|
| 196 |
+
# vocabulary at call time, so a renamed category is skipped, never
|
| 197 |
+
# invented (a test also fails if any target disappears).
|
| 198 |
+
# 2. Whole-word containment: every significant query word must appear in
|
| 199 |
+
# the vocabulary name ("physics" -> area "physics and astronomy").
|
| 200 |
+
# 3. (Only after the LLM has failed) a partial match on a distinctive word.
|
| 201 |
+
# Character-level similarity is NOT used here; that is the spelling repair in
|
| 202 |
+
# closest_subject_term, which rejects "criminology" -> "immunology".
|
| 203 |
+
|
| 204 |
+
# kind: "area" or "category". match: "synonym" for true equivalents, otherwise
|
| 205 |
+
# "nearest". Alternatives are optional extra verified categories to offer.
|
| 206 |
+
_SUBJECT_ALIASES: Dict[str, Dict[str, Any]] = {}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _alias(keys: List[str], kind: str, name: str, match: str = "nearest",
|
| 210 |
+
alternatives: Tuple[str, ...] = ()) -> None:
|
| 211 |
+
for key in keys:
|
| 212 |
+
_SUBJECT_ALIASES[_normalise_subject_match_text(key)] = {
|
| 213 |
+
"kind": kind, "name": name, "match": match,
|
| 214 |
+
"alternatives": list(alternatives),
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# Mathematics, statistics, physics
|
| 219 |
+
_alias(["math", "maths", "mathematic"], "area", "mathematics", "synonym",
|
| 220 |
+
("applied mathematics", "mathematics (miscellaneous)", "statistics and probability"))
|
| 221 |
+
_alias(["stats"], "category", "statistics and probability", "synonym")
|
| 222 |
+
_alias(["quantum physics", "quantum mechanics", "theoretical physics"], "area", "physics and astronomy",
|
| 223 |
+
alternatives=("atomic and molecular physics, and optics", "mathematical physics",
|
| 224 |
+
"statistical and nonlinear physics", "nuclear and high energy physics"))
|
| 225 |
+
_alias(["photonics", "optics and photonics", "lasers"], "category", "atomic and molecular physics, and optics",
|
| 226 |
+
alternatives=("electronic, optical and magnetic materials", "electrical and electronic engineering"))
|
| 227 |
+
_alias(["semiconductors", "semiconductor", "microelectronics"], "category", "electronic, optical and magnetic materials",
|
| 228 |
+
alternatives=("electrical and electronic engineering", "condensed matter physics"))
|
| 229 |
+
# Computing and AI
|
| 230 |
+
_alias(["ai", "a i", "machine learning", "deep learning", "neural networks", "neural network",
|
| 231 |
+
"natural language processing", "nlp", "reinforcement learning", "generative ai",
|
| 232 |
+
"large language models", "llm", "llms"], "category", "artificial intelligence",
|
| 233 |
+
alternatives=("computer vision and pattern recognition", "computer science applications",
|
| 234 |
+
"information systems"))
|
| 235 |
+
_alias(["computer vision", "image processing", "pattern recognition"], "category",
|
| 236 |
+
"computer vision and pattern recognition",
|
| 237 |
+
alternatives=("artificial intelligence", "signal processing", "software"))
|
| 238 |
+
_alias(["cs", "comp sci", "computing", "informatics", "computer engineering"], "area", "computer science",
|
| 239 |
+
alternatives=("computer science applications", "hardware and architecture", "software"))
|
| 240 |
+
_alias(["data science", "big data", "data analytics", "data mining"], "area", "computer science",
|
| 241 |
+
alternatives=("information systems", "artificial intelligence", "statistics and probability"))
|
| 242 |
+
_alias(["software engineering", "programming"], "category", "software",
|
| 243 |
+
alternatives=("computer science applications", "information systems"))
|
| 244 |
+
_alias(["cybersecurity", "cyber security", "computer security", "information security",
|
| 245 |
+
"network security", "cryptography"], "category", "computer networks and communications",
|
| 246 |
+
alternatives=("information systems", "safety, risk, reliability and quality", "hardware and architecture"))
|
| 247 |
+
_alias(["networking", "internet of things", "iot", "telecommunications", "telecommunication",
|
| 248 |
+
"wireless communications", "wireless communication", "communications engineering"],
|
| 249 |
+
"category", "computer networks and communications",
|
| 250 |
+
alternatives=("electrical and electronic engineering", "signal processing", "hardware and architecture"))
|
| 251 |
+
_alias(["hci", "user experience", "ux"], "category", "human-computer interaction",
|
| 252 |
+
alternatives=("human factors and ergonomics", "computer science applications"))
|
| 253 |
+
_alias(["robotics", "robots", "autonomous systems", "mechatronics"], "category", "control and systems engineering",
|
| 254 |
+
alternatives=("artificial intelligence", "mechanical engineering", "electrical and electronic engineering"))
|
| 255 |
+
# Engineering, energy, materials
|
| 256 |
+
_alias(["electronics", "electronic engineering"], "category", "electrical and electronic engineering", "synonym")
|
| 257 |
+
_alias(["power systems", "power engineering", "smart grid", "smart grids", "energy storage",
|
| 258 |
+
"batteries", "battery"], "category", "energy engineering and power technology",
|
| 259 |
+
alternatives=("electrical and electronic engineering", "renewable energy, sustainability and the environment",
|
| 260 |
+
"electrochemistry"))
|
| 261 |
+
_alias(["solar energy", "wind energy", "clean energy", "green energy"], "category",
|
| 262 |
+
"renewable energy, sustainability and the environment",
|
| 263 |
+
alternatives=("energy engineering and power technology", "energy (miscellaneous)"))
|
| 264 |
+
_alias(["hydrogen", "hydrogen energy", "fuel cells", "fuel cell"], "category", "fuel technology",
|
| 265 |
+
alternatives=("energy engineering and power technology", "renewable energy, sustainability and the environment",
|
| 266 |
+
"electrochemistry"))
|
| 267 |
+
_alias(["petroleum", "petroleum engineering", "oil and gas", "reservoir engineering", "petroleum geoscience"],
|
| 268 |
+
"category", "energy engineering and power technology",
|
| 269 |
+
alternatives=("fuel technology", "geotechnical engineering and engineering geology", "geochemistry and petrology"))
|
| 270 |
+
_alias(["materials engineering", "material science", "materials"], "area", "materials science", "synonym",
|
| 271 |
+
alternatives=("materials chemistry", "mechanics of materials", "electronic, optical and magnetic materials"))
|
| 272 |
+
_alias(["nanotech"], "category", "nanoscience and nanotechnology", "synonym")
|
| 273 |
+
_alias(["biotech"], "category", "biotechnology", "synonym")
|
| 274 |
+
_alias(["aeronautics", "aeronautical engineering", "aviation", "astronautics"], "category", "aerospace engineering")
|
| 275 |
+
_alias(["heat transfer", "fluid mechanics", "fluid dynamics", "cfd"], "category", "fluid flow and transfer processes",
|
| 276 |
+
alternatives=("mechanical engineering", "computational mechanics"))
|
| 277 |
+
_alias(["desalination", "water resources", "hydrology", "water treatment"], "category", "water science and technology",
|
| 278 |
+
alternatives=("filtration and separation", "environmental engineering"))
|
| 279 |
+
_alias(["supply chain", "supply chain management", "logistics"], "category", "management science and operations research",
|
| 280 |
+
alternatives=("transportation", "strategy and management", "industrial and manufacturing engineering"))
|
| 281 |
+
_alias(["transport", "transportation engineering", "traffic engineering"], "category", "transportation",
|
| 282 |
+
alternatives=("civil and structural engineering", "urban studies"))
|
| 283 |
+
_alias(["urban planning", "urban design", "city planning", "regional planning"], "category", "urban studies",
|
| 284 |
+
alternatives=("geography, planning and development", "architecture"))
|
| 285 |
+
_alias(["remote sensing", "gis", "geographic information systems", "geoinformatics"], "category",
|
| 286 |
+
"computers in earth sciences",
|
| 287 |
+
alternatives=("earth-surface processes", "geography, planning and development"))
|
| 288 |
+
# Earth and environment
|
| 289 |
+
_alias(["geoscience", "geosciences", "earth science", "earth sciences"], "area", "earth and planetary sciences", "synonym",
|
| 290 |
+
alternatives=("geology", "geophysics", "geochemistry and petrology"))
|
| 291 |
+
_alias(["climate", "climate change", "climate science"], "category", "global and planetary change",
|
| 292 |
+
alternatives=("atmospheric science", "environmental science (miscellaneous)"))
|
| 293 |
+
_alias(["meteorology"], "category", "atmospheric science")
|
| 294 |
+
_alias(["environment", "environmental studies", "environmental sciences"], "area", "environmental science", "synonym",
|
| 295 |
+
alternatives=("environmental engineering", "pollution", "ecology"))
|
| 296 |
+
_alias(["marine science", "marine sciences", "marine biology"], "category", "aquatic science",
|
| 297 |
+
alternatives=("oceanography", "ecology"))
|
| 298 |
+
_alias(["agriculture", "agricultural sciences"], "area", "agricultural and biological sciences", "synonym",
|
| 299 |
+
alternatives=("agronomy and crop science", "soil science", "food science"))
|
| 300 |
+
# Life and health sciences
|
| 301 |
+
_alias(["life sciences", "life science", "biological sciences", "biosciences", "bioscience"], "area",
|
| 302 |
+
"biochemistry, genetics and molecular biology",
|
| 303 |
+
alternatives=("agricultural and biological sciences (miscellaneous)", "molecular biology", "cell biology", "ecology"))
|
| 304 |
+
_alias(["bioinformatics", "computational biology"], "category", "molecular biology",
|
| 305 |
+
alternatives=("computational theory and mathematics", "computer science applications",
|
| 306 |
+
"statistics and probability", "genetics"))
|
| 307 |
+
_alias(["genomics", "genome"], "category", "genetics",
|
| 308 |
+
alternatives=("molecular biology", "biotechnology"))
|
| 309 |
+
_alias(["biochem"], "category", "biochemistry", "synonym")
|
| 310 |
+
_alias(["chem"], "area", "chemistry", "synonym")
|
| 311 |
+
_alias(["neurobiology"], "area", "neuroscience",
|
| 312 |
+
alternatives=("cellular and molecular neuroscience", "cognitive neuroscience"))
|
| 313 |
+
_alias(["biomedical", "biomedical science", "biomedical sciences", "biomedicine", "medical",
|
| 314 |
+
"medical sciences", "clinical", "clinical medicine", "health sciences", "healthcare", "health care"],
|
| 315 |
+
"area", "medicine",
|
| 316 |
+
alternatives=("molecular medicine", "biomedical engineering", "health policy",
|
| 317 |
+
"public health, environmental and occupational health"))
|
| 318 |
+
_alias(["health", "global health", "population health"], "category",
|
| 319 |
+
"public health, environmental and occupational health",
|
| 320 |
+
alternatives=("health policy", "epidemiology", "medicine (miscellaneous)"))
|
| 321 |
+
_alias(["medical imaging"], "category", "radiology, nuclear medicine and imaging")
|
| 322 |
+
_alias(["dental", "dental medicine", "oral health"], "area", "dentistry", "synonym",
|
| 323 |
+
alternatives=("oral surgery", "orthodontics", "periodontics"))
|
| 324 |
+
_alias(["veterinary medicine", "veterinary science"], "area", "veterinary", "synonym")
|
| 325 |
+
_alias(["covid", "covid 19", "covid19", "infectious disease"], "category", "infectious diseases",
|
| 326 |
+
alternatives=("epidemiology", "virology", "public health, environmental and occupational health"))
|
| 327 |
+
_alias(["cognitive science"], "category", "experimental and cognitive psychology",
|
| 328 |
+
alternatives=("cognitive neuroscience", "artificial intelligence"))
|
| 329 |
+
# Social sciences, business, humanities
|
| 330 |
+
_alias(["politics", "political science", "international politics"], "category",
|
| 331 |
+
"political science and international relations", "synonym",
|
| 332 |
+
alternatives=("sociology and political science", "public administration"))
|
| 333 |
+
_alias(["criminology", "criminal justice", "crime", "crime studies"], "category", "sociology and political science",
|
| 334 |
+
alternatives=("law", "pathology and forensic medicine", "social psychology"))
|
| 335 |
+
_alias(["public policy", "governance"], "category", "public administration",
|
| 336 |
+
alternatives=("political science and international relations", "health policy"))
|
| 337 |
+
_alias(["entrepreneurship"], "category", "business and international management",
|
| 338 |
+
alternatives=("strategy and management", "management of technology and innovation"))
|
| 339 |
+
_alias(["hr", "hrm"], "category", "organizational behavior and human resource management", "synonym")
|
| 340 |
+
_alias(["fintech"], "category", "finance",
|
| 341 |
+
alternatives=("information systems", "economics and econometrics"))
|
| 342 |
+
_alias(["teaching", "pedagogy", "higher education", "engineering education", "medical education"],
|
| 343 |
+
"category", "education",
|
| 344 |
+
alternatives=("e-learning", "developmental and educational psychology"))
|
| 345 |
+
_alias(["library science", "librarianship", "libraries", "library and information science",
|
| 346 |
+
"information science", "information studies"], "category", "library and information sciences", "synonym")
|
| 347 |
+
_alias(["legal studies", "jurisprudence"], "category", "law", "synonym")
|
| 348 |
+
_alias(["media", "media studies", "journalism", "mass communication"], "category", "communication",
|
| 349 |
+
alternatives=("cultural studies", "media technology"))
|
| 350 |
+
_alias(["religion", "theology", "islamic studies"], "category", "religious studies",
|
| 351 |
+
alternatives=("philosophy", "history"))
|
| 352 |
+
_alias(["ethics"], "category", "philosophy",
|
| 353 |
+
alternatives=("issues, ethics and legal aspects", "health policy"))
|
| 354 |
+
_alias(["business", "business studies"], "area", "business, management and accounting", "synonym",
|
| 355 |
+
alternatives=("business and international management", "strategy and management", "marketing"))
|
| 356 |
+
_alias(["economy", "banking"], "area", "economics, econometrics and finance",
|
| 357 |
+
alternatives=("economics and econometrics", "finance"))
|
| 358 |
+
_alias(["animal", "animals", "zoology", "animal science"], "category", "animal science and zoology", "synonym")
|
| 359 |
+
_alias(["ocean", "oceans", "ocean science", "ocean sciences"], "category", "oceanography", "synonym",
|
| 360 |
+
alternatives=("ocean engineering", "aquatic science"))
|
| 361 |
+
_alias(["marine"], "category", "aquatic science", alternatives=("oceanography", "ocean engineering"))
|
| 362 |
+
_alias(["film", "films", "cinema", "film studies", "theatre", "theater", "performing arts"], "category",
|
| 363 |
+
"visual arts and performing arts", alternatives=("communication", "cultural studies"))
|
| 364 |
+
_alias(["nano"], "category", "nanoscience and nanotechnology", "synonym")
|
| 365 |
+
_alias(["solar", "wind", "wind power", "solar power"], "category", "renewable energy, sustainability and the environment",
|
| 366 |
+
alternatives=("energy engineering and power technology",))
|
| 367 |
+
_alias(["english literature", "literary studies", "literature studies"], "category", "literature and literary theory",
|
| 368 |
+
alternatives=("linguistics and language", "cultural studies"))
|
| 369 |
+
|
| 370 |
+
# Words that never decide a nearest match on their own.
|
| 371 |
+
_GENERIC_SUBJECT_TOKENS = frozenset({
|
| 372 |
+
"science", "sciences", "scientific", "study", "studies", "research", "engineering",
|
| 373 |
+
"technology", "technologies", "theory", "applied", "general", "miscellaneous",
|
| 374 |
+
"system", "systems", "learning", "process", "processes", "analysis", "development",
|
| 375 |
+
"international", "policy", "care", "methods", "method", "advanced", "modern",
|
| 376 |
+
"introduction", "principles", "topics", "issues", "aspects", "new",
|
| 377 |
+
# Ambiguous on their own; left to the semantic classifier.
|
| 378 |
+
"planning", "behavior", "behaviour", "family", "design", "film", "films",
|
| 379 |
+
})
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _token_matches(query_token: str, name_token: str) -> bool:
|
| 383 |
+
if query_token == name_token:
|
| 384 |
+
return True
|
| 385 |
+
for a, b in ((query_token, name_token), (name_token, query_token)):
|
| 386 |
+
if a == b + "s" or a == b + "es":
|
| 387 |
+
return True
|
| 388 |
+
if a.endswith("ies") and a[:-3] + "y" == b:
|
| 389 |
+
return True
|
| 390 |
+
return False
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _covered(query_tokens: Set[str], name: str) -> Set[str]:
|
| 394 |
+
# Parenthetical qualifiers ("archeology (arts and humanities)") describe
|
| 395 |
+
# scope; they must not make a category look like a match for its parent.
|
| 396 |
+
name_tokens = _tokens(re.sub(r"\([^)]*\)", " ", name))
|
| 397 |
+
return {q for q in query_tokens if any(_token_matches(q, n) for n in name_tokens)}
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _is_misc(name: str) -> bool:
|
| 401 |
+
return name.endswith("(miscellaneous)")
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def nearest_subject_term(text: str, allow_partial: bool = False) -> Optional[Dict[str, Any]]:
|
| 405 |
+
"""Resolve free text to the nearest real area/category, or None.
|
| 406 |
+
|
| 407 |
+
Returns {"name", "kind" ("area"|"category"), "method", "match"
|
| 408 |
+
("synonym"|"nearest"), "alternatives": [real category names]}.
|
| 409 |
+
``method`` is "alias", "token_match" or "partial_token_match".
|
| 410 |
+
Exact names are not handled here (the caller checks them first).
|
| 411 |
+
"""
|
| 412 |
+
vocab = subject_vocabulary()
|
| 413 |
+
cats, areas = vocab["categories"], vocab["areas"]
|
| 414 |
+
|
| 415 |
+
def real(kind: str, name: str) -> bool:
|
| 416 |
+
return name in (areas if kind == "area" else cats)
|
| 417 |
+
|
| 418 |
+
def result(name: str, kind: str, method: str, match: str, extra: List[str]) -> Dict[str, Any]:
|
| 419 |
+
alternatives: List[str] = []
|
| 420 |
+
for alt in extra:
|
| 421 |
+
if alt in cats and alt != name and alt not in alternatives:
|
| 422 |
+
alternatives.append(alt)
|
| 423 |
+
return {"name": name, "kind": kind, "method": method, "match": match,
|
| 424 |
+
"alternatives": alternatives[:6]}
|
| 425 |
+
|
| 426 |
+
key = _normalise_subject_match_text(text)
|
| 427 |
+
if not key:
|
| 428 |
+
return None
|
| 429 |
+
|
| 430 |
+
def related_suggestions() -> List[str]:
|
| 431 |
+
# Lexical suggestions that share a distinctive word with the input;
|
| 432 |
+
# a shared generic word ("science") is not a relationship.
|
| 433 |
+
words = {t for t in _tokens(key) if t not in _GENERIC_SUBJECT_TOKENS}
|
| 434 |
+
return [c for c in suggest_categories(text, limit=8) if _covered(words, c)]
|
| 435 |
+
|
| 436 |
+
alias = _SUBJECT_ALIASES.get(key)
|
| 437 |
+
if alias and real(alias["kind"], alias["name"]):
|
| 438 |
+
return result(alias["name"], alias["kind"], "alias", alias["match"],
|
| 439 |
+
list(alias["alternatives"]) + related_suggestions())
|
| 440 |
+
|
| 441 |
+
query = _tokens(key)
|
| 442 |
+
distinctive = {t for t in query if t not in _GENERIC_SUBJECT_TOKENS and len(t) > 1}
|
| 443 |
+
if not distinctive:
|
| 444 |
+
return None
|
| 445 |
+
|
| 446 |
+
# Full containment: every significant query word appears in the name.
|
| 447 |
+
cat_hits = [c for c in cats if _covered(query, c) == query]
|
| 448 |
+
area_hits = [a for a in areas if _covered(query, a) == query]
|
| 449 |
+
specific = [c for c in cat_hits if not _is_misc(c)]
|
| 450 |
+
|
| 451 |
+
def best_category(candidates: List[str]) -> str:
|
| 452 |
+
# Most precise name first (fewest extra words), then most journals.
|
| 453 |
+
return sorted(candidates, key=lambda c: (len(_tokens(c)), -cats.get(c, 0), c))[0]
|
| 454 |
+
|
| 455 |
+
def extra_words(name: str) -> int:
|
| 456 |
+
return len(_tokens(re.sub(r"\([^)]*\)", " ", name)) - query)
|
| 457 |
+
|
| 458 |
+
chosen: Optional[Tuple[str, str]] = None
|
| 459 |
+
if len(specific) == 1 and len(area_hits) == 1:
|
| 460 |
+
# One specific category and one area: take the tighter name, so
|
| 461 |
+
# "astronomy" -> "astronomy and astrophysics" but "chemical" ->
|
| 462 |
+
# area "chemical engineering", not "chemical health and safety".
|
| 463 |
+
if extra_words(specific[0]) <= extra_words(area_hits[0]):
|
| 464 |
+
chosen = (specific[0], "category")
|
| 465 |
+
else:
|
| 466 |
+
chosen = (area_hits[0], "area")
|
| 467 |
+
elif len(specific) == 1:
|
| 468 |
+
chosen = (specific[0], "category")
|
| 469 |
+
elif len(area_hits) == 1:
|
| 470 |
+
chosen = (area_hits[0], "area")
|
| 471 |
+
elif specific:
|
| 472 |
+
chosen = (best_category(specific), "category")
|
| 473 |
+
elif cat_hits:
|
| 474 |
+
chosen = (best_category(cat_hits), "category")
|
| 475 |
+
if chosen:
|
| 476 |
+
name, kind = chosen
|
| 477 |
+
extra = specific + [c for c in cat_hits if c not in specific] + related_suggestions()
|
| 478 |
+
return result(name, kind, "token_match", "nearest", extra)
|
| 479 |
+
|
| 480 |
+
if not allow_partial:
|
| 481 |
+
return None
|
| 482 |
+
|
| 483 |
+
# Partial: the best candidate sharing at least one distinctive word,
|
| 484 |
+
# weighted by how much of the query it covers. Used only as a last
|
| 485 |
+
# resort when the semantic classifier is unavailable or gave up.
|
| 486 |
+
weights = _subject_token_weights()
|
| 487 |
+
total = sum(weights.get(t, 1.0) for t in distinctive) or 1.0
|
| 488 |
+
ranked: List[Tuple[float, int, str, str]] = []
|
| 489 |
+
for kind, names in (("area", areas), ("category", cats)):
|
| 490 |
+
for name in names:
|
| 491 |
+
hit = _covered(distinctive, name)
|
| 492 |
+
if not hit:
|
| 493 |
+
continue
|
| 494 |
+
coverage = sum(weights.get(t, 1.0) for t in hit) / total
|
| 495 |
+
extra_words = len(_tokens(name) - set(hit))
|
| 496 |
+
# Prefer areas for a broad single-word match and specific
|
| 497 |
+
# categories otherwise; tie-break on fewer extra words.
|
| 498 |
+
ranked.append((-coverage, extra_words + (0 if kind == "area" else 1), name, kind))
|
| 499 |
+
if not ranked:
|
| 500 |
+
return None
|
| 501 |
+
ranked.sort()
|
| 502 |
+
best_cov = -ranked[0][0]
|
| 503 |
+
if best_cov < 0.5:
|
| 504 |
+
return None
|
| 505 |
+
_cov, _extra, name, kind = ranked[0]
|
| 506 |
+
extra = [n for _c, _e, n, k in ranked[1:12] if k == "category"] + related_suggestions()
|
| 507 |
+
return result(name, kind, "partial_token_match", "nearest", extra)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
_subject_token_df_cache: Optional[Dict[str, float]] = None
|
| 511 |
|
| 512 |
|
trusted_links.py
CHANGED
|
@@ -1,283 +1,283 @@
|
|
| 1 |
-
"""Evidence-backed hyperlinks. No model links, paid search, or client credentials."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
import asyncio
|
| 4 |
-
import csv
|
| 5 |
-
import functools
|
| 6 |
-
import json
|
| 7 |
-
import http.client
|
| 8 |
-
import ipaddress
|
| 9 |
-
import re
|
| 10 |
-
import socket
|
| 11 |
-
import ssl
|
| 12 |
-
import time
|
| 13 |
-
from collections import OrderedDict
|
| 14 |
-
from datetime import datetime, timezone
|
| 15 |
-
from html.parser import HTMLParser
|
| 16 |
-
from pathlib import Path
|
| 17 |
-
from urllib.parse import urlsplit, urljoin
|
| 18 |
-
|
| 19 |
-
ROOT = Path(__file__).parent
|
| 20 |
-
# Publisher identity AND destination must match. Unknown publishers fail closed.
|
| 21 |
-
PUBLISHERS = {
|
| 22 |
-
'elsevier': ('elsevier.com', 'sciencedirect.com'),
|
| 23 |
-
'springer': ('springer.com', 'springernature.com', 'nature.com', 'biomedcentral.com'),
|
| 24 |
-
'nature': ('nature.com', 'springernature.com'),
|
| 25 |
-
'wiley': ('wiley.com',), 'taylor': ('tandfonline.com', 'taylorandfrancis.com'),
|
| 26 |
-
'sage': ('sagepub.com',), 'ieee': ('ieee.org',), 'mdpi': ('mdpi.com',),
|
| 27 |
-
'frontiers': ('frontiersin.org',), 'plos': ('plos.org',),
|
| 28 |
-
'oxford': ('oup.com',), 'cambridge': ('cambridge.org',),
|
| 29 |
-
'american chemical': ('acs.org',), 'royal society of chemistry': ('rsc.org',),
|
| 30 |
-
'iop': ('iop.org',), 'acm': ('acm.org',), 'american physical': ('aps.org',),
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
def safe_url(value):
|
| 34 |
-
try:
|
| 35 |
-
if not isinstance(value, str) or len(value) > 2048 or re.search(r'[\s\\\x00-\x1f]', value): return None
|
| 36 |
-
p = urlsplit(value)
|
| 37 |
-
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.port not in (None,443): return None
|
| 38 |
-
h = p.hostname.lower()
|
| 39 |
-
if '.' not in h or h.endswith(('.local', '.internal', '.localhost')): return None
|
| 40 |
-
try:
|
| 41 |
-
if not ipaddress.ip_address(h).is_global: return None
|
| 42 |
-
except ValueError: pass
|
| 43 |
-
return value
|
| 44 |
-
except ValueError: return None
|
| 45 |
-
|
| 46 |
-
def allowed(url, domains):
|
| 47 |
-
return bool(safe_url(url) and any(urlsplit(url).hostname == d or urlsplit(url).hostname.endswith('.'+d) for d in domains))
|
| 48 |
-
|
| 49 |
-
def stamp(): return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
| 50 |
-
def unavailable(): return {'url': None, 'status':'unavailable', 'source':None, 'reviewed_at':None}
|
| 51 |
-
|
| 52 |
-
def canonical(j):
|
| 53 |
-
from journal_lookup import get_index
|
| 54 |
-
from researchbee_journals import norm_issn
|
| 55 |
-
idx = get_index()
|
| 56 |
-
if not idx: return None
|
| 57 |
-
ident = j.get('identity_verification') or {}
|
| 58 |
-
sid = j.get('scopus_id') or ident.get('scopus_id')
|
| 59 |
-
issn = j.get('issn')
|
| 60 |
-
rec = idx.lookup_exact(scopus_id=str(sid)) if sid else idx.lookup_exact(issn=issn) if issn else idx.lookup_exact(title=j.get('name',''))
|
| 61 |
-
if rec and issn and norm_issn(issn) not in {norm_issn(rec.issn_e),norm_issn(rec.issn_print)}: return None
|
| 62 |
-
if rec and j.get('name'):
|
| 63 |
-
if idx.normalise_identity_title(j['name']) != idx.normalise_identity_title(rec.title): return None
|
| 64 |
-
return rec
|
| 65 |
-
|
| 66 |
-
@functools.lru_cache(maxsize=1)
|
| 67 |
-
def metric_data():
|
| 68 |
-
from journal_lookup import get_index
|
| 69 |
-
from scimago_index import parse_category_quartiles
|
| 70 |
-
idx = get_index()
|
| 71 |
-
names = {}
|
| 72 |
-
if idx:
|
| 73 |
-
for row in idx.con.execute('SELECT code, name FROM asjc'):
|
| 74 |
-
names.setdefault(row['name'].casefold(),set()).add(str(row['code']))
|
| 75 |
-
records = {}
|
| 76 |
-
with (ROOT/'scimagojr_2025.csv').open(encoding='latin-1',newline='') as f:
|
| 77 |
-
reader=csv.DictReader(f)
|
| 78 |
-
years=[re.fullmatch(r'Total Docs\. \((\d{4})\)',h) for h in reader.fieldnames]
|
| 79 |
-
year=next((m.group(1) for m in years if m),None)
|
| 80 |
-
for row in reader:
|
| 81 |
-
fields=[]
|
| 82 |
-
sid=row.get('Sourceid','')
|
| 83 |
-
profile=f'https://www.scimagojr.com/journalsearch.php?q={sid}&tip=sid' if sid.isdigit() else None
|
| 84 |
-
parsed=parse_category_quartiles(row.get('Categories',''))
|
| 85 |
-
labels={}
|
| 86 |
-
for part in row.get('Categories','').split(';'):
|
| 87 |
-
label=re.sub(r'\s*\(Q[1-4]\)\s*$','',part.strip(),flags=re.I)
|
| 88 |
-
if label: labels[' '.join(label.split()).casefold()]=label
|
| 89 |
-
for name,label in labels.items():
|
| 90 |
-
q=parsed.get(name)
|
| 91 |
-
codes=names.get(name.casefold(),set())
|
| 92 |
-
code=next(iter(codes)) if len(codes)==1 else None
|
| 93 |
-
fields.append({'field':label,'quartile':q,'code':code,'source':'SCImago SJR','year':year,
|
| 94 |
-
'category_url':f'https://www.scimagojr.com/journalrank.php?category={code}&year={year}' if code and year else None,
|
| 95 |
-
'profile_url':profile})
|
| 96 |
-
records[sid]={'fields':fields,'year':year,'profile_url':profile,'issns':set(re.findall(r'[0-9X]{8}',row.get('Issn','')))}
|
| 97 |
-
return records
|
| 98 |
-
|
| 99 |
-
@functools.lru_cache(maxsize=1)
|
| 100 |
-
def curated_records():
|
| 101 |
-
try:
|
| 102 |
-
return json.loads((ROOT/'journal_links_curated.json').read_text())
|
| 103 |
-
except (OSError, ValueError): return {}
|
| 104 |
-
|
| 105 |
-
def curated_guidance(rec):
|
| 106 |
-
entry = curated_records().get(str(rec.scopus_id))
|
| 107 |
-
if not isinstance(entry,dict): return None
|
| 108 |
-
norm=lambda v: re.sub('[^0-9X]','',str(v).upper())
|
| 109 |
-
if not ({norm(rec.issn_e),norm(rec.issn_print)}-{''}) & {norm(v) for v in entry.get('issns',[])}: return None
|
| 110 |
-
if not safe_url(entry.get('url')) or not safe_url(entry.get('source')): return None
|
| 111 |
-
domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
|
| 112 |
-
if not allowed(entry['url'],domains) or not allowed(entry['source'],domains): return None
|
| 113 |
-
if entry.get('scope') not in ('journal-specific','general-publisher'): return None
|
| 114 |
-
# Dates are retained exactly, never populated with this software release date.
|
| 115 |
-
return {**entry,'status':'curated'}
|
| 116 |
-
|
| 117 |
-
_cache=OrderedDict()
|
| 118 |
-
_metadata_cache=OrderedDict()
|
| 119 |
-
_locks={}
|
| 120 |
-
_gate=asyncio.Semaphore(4)
|
| 121 |
-
|
| 122 |
-
def cached(key):
|
| 123 |
-
item=_cache.get(key)
|
| 124 |
-
if item and time.monotonic()<item[0]:
|
| 125 |
-
_cache.move_to_end(key); return item[1]
|
| 126 |
-
if item: del _cache[key]
|
| 127 |
-
return None
|
| 128 |
-
|
| 129 |
-
def cache_put(key,value):
|
| 130 |
-
_cache[key]=(time.monotonic()+(86400 if value['status']=='checked' else 3600),value)
|
| 131 |
-
while len(_cache)>512: _cache.popitem(last=False)
|
| 132 |
-
|
| 133 |
-
async def journal_links(j, fetch_metadata=True):
|
| 134 |
-
import openalex
|
| 135 |
-
rec=canonical(j)
|
| 136 |
-
result={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':bool(rec)}
|
| 137 |
-
j['journal_links']=result
|
| 138 |
-
if not rec: return result
|
| 139 |
-
result['canonical_id']=rec.scopus_id
|
| 140 |
-
metric=metric_data().get(str(rec.scimago_id)) or {}
|
| 141 |
-
if not ({re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)} & metric.get('issns',set())): metric={}
|
| 142 |
-
fields=metric.get('fields',[])
|
| 143 |
-
if not metric:
|
| 144 |
-
j['quartile']=None
|
| 145 |
-
if isinstance(j.get('ranking'),dict):
|
| 146 |
-
j['ranking'].update(quartile=None, verification_status='Not available')
|
| 147 |
-
j['quartiles_by_field']=fields
|
| 148 |
-
if isinstance(j.get('ranking'),dict):
|
| 149 |
-
j['ranking'].update(source='SCImago SJR' if metric else 'Not available',year=metric.get('year'),quartiles_by_field=fields)
|
| 150 |
-
vl=j.setdefault('verify_links',{})
|
| 151 |
-
vl['scimago']=metric.get('profile_url')
|
| 152 |
-
vl['scopus']=f'https://www.scopus.com/sourceid/{rec.scopus_id}' if str(rec.scopus_id).isdigit() else None
|
| 153 |
-
oa=j.get('openalex') or {}
|
| 154 |
-
expected={re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)}-{''}
|
| 155 |
-
norm=lambda s:re.sub('[^0-9X]','',str(s).upper())
|
| 156 |
-
matched=lambda v:bool(expected & {norm(s) for s in [v.get('issn_l'),*(v.get('issns') or [])]})
|
| 157 |
-
if not matched(oa) and fetch_metadata:
|
| 158 |
-
item=_metadata_cache.get(rec.scopus_id)
|
| 159 |
-
if item and time.monotonic()<item[0]:
|
| 160 |
-
oa=item[1]
|
| 161 |
-
_metadata_cache.move_to_end(rec.scopus_id)
|
| 162 |
-
else:
|
| 163 |
-
oa=await openalex.enrich_journal(rec.title,rec.issn_e or rec.issn_print) or {}
|
| 164 |
-
_metadata_cache[rec.scopus_id]=(time.monotonic()+3600,oa)
|
| 165 |
-
while len(_metadata_cache)>512: _metadata_cache.popitem(last=False)
|
| 166 |
-
if matched(oa) and safe_url(oa.get('homepage_url')):
|
| 167 |
-
result['homepage']={'url':oa['homepage_url'],'status':'metadata-only','source':oa.get('openalex_id'),'source_updated_at':oa.get('source_updated_at'),'retrieved_at':oa.get('retrieved_at'),'reviewed_at':None}
|
| 168 |
-
result['guidelines']=curated_guidance(rec) or cached(rec.scopus_id) or unavailable()
|
| 169 |
-
return result
|
| 170 |
-
|
| 171 |
-
class Anchors(HTMLParser):
|
| 172 |
-
def __init__(self): super().__init__(); self.links=[]; self.current=None; self.text=[]
|
| 173 |
-
def handle_starttag(self,tag,attrs):
|
| 174 |
-
if tag=='a': self.current=dict(attrs).get('href'); self.text=[]
|
| 175 |
-
def handle_data(self,data):
|
| 176 |
-
if self.current: self.text.append(data)
|
| 177 |
-
def handle_endtag(self,tag):
|
| 178 |
-
if tag=='a' and self.current:
|
| 179 |
-
self.links.append((self.current,' '.join(self.text))); self.current=None
|
| 180 |
-
|
| 181 |
-
class PinnedHTTPS(http.client.HTTPSConnection):
|
| 182 |
-
def __init__(self,host,ip): super().__init__(host,timeout=3,context=ssl.create_default_context()); self.ip=ip
|
| 183 |
-
def connect(self):
|
| 184 |
-
self.sock=self._context.wrap_socket(socket.create_connection((self.ip,443),self.timeout),server_hostname=self.host)
|
| 185 |
-
|
| 186 |
-
def fetch_page(url,domains):
|
| 187 |
-
"""At most 3 requests, public DNS only, pinned IP + original TLS SNI/Host."""
|
| 188 |
-
deadline=time.monotonic()+6
|
| 189 |
-
for redirect in range(3):
|
| 190 |
-
if time.monotonic()>deadline: raise TimeoutError('Page deadline')
|
| 191 |
-
if not allowed(url,domains): raise ValueError('Destination not allowed')
|
| 192 |
-
p=urlsplit(url)
|
| 193 |
-
ips={x[4][0] for x in socket.getaddrinfo(p.hostname,443,type=socket.SOCK_STREAM)}
|
| 194 |
-
if not ips or any(not ipaddress.ip_address(ip).is_global for ip in ips): raise ValueError('Unsafe DNS')
|
| 195 |
-
conn=PinnedHTTPS(p.hostname,sorted(ips)[0])
|
| 196 |
-
try:
|
| 197 |
-
conn.request('GET',(p.path or '/')+('?' + p.query if p.query else ''),headers={'User-Agent':'ResearchBee/1.9 (library@ku.ac.ae)','Accept':'text/html','Accept-Encoding':'identity'})
|
| 198 |
-
response=conn.getresponse()
|
| 199 |
-
if response.status in (301,302,303,307,308):
|
| 200 |
-
if redirect==2: raise ValueError('Redirect limit')
|
| 201 |
-
url=urljoin(url,response.getheader('Location','')); continue
|
| 202 |
-
if response.status!=200 or 'text/html' not in (response.getheader('Content-Type') or ''): raise ValueError('Page unavailable')
|
| 203 |
-
chunks=[]; total=0
|
| 204 |
-
while total<=512000:
|
| 205 |
-
if time.monotonic()>deadline: raise TimeoutError('Page deadline')
|
| 206 |
-
chunk=response.read1(min(16384,512001-total))
|
| 207 |
-
if not chunk: break
|
| 208 |
-
chunks.append(chunk); total+=len(chunk)
|
| 209 |
-
data=b''.join(chunks)
|
| 210 |
-
if len(data)>512000: raise ValueError('Page too large')
|
| 211 |
-
return url,data.decode('utf-8',errors='replace')
|
| 212 |
-
finally: conn.close()
|
| 213 |
-
raise ValueError('Redirect limit')
|
| 214 |
-
|
| 215 |
-
async def discover(j):
|
| 216 |
-
rec=canonical(j)
|
| 217 |
-
if not rec: return {'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
|
| 218 |
-
async with _gate:
|
| 219 |
-
lock=_locks.setdefault(rec.scopus_id,asyncio.Lock())
|
| 220 |
-
try:
|
| 221 |
-
async with lock:
|
| 222 |
-
links=await journal_links(j)
|
| 223 |
-
prior=curated_guidance(rec) or cached(rec.scopus_id)
|
| 224 |
-
if prior: links['guidelines']=prior; return links
|
| 225 |
-
result=unavailable()
|
| 226 |
-
result['attempted_at']=stamp()
|
| 227 |
-
result['message']='Author guidance unavailable; use the known journal homepage if available.'
|
| 228 |
-
domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
|
| 229 |
-
home=links['homepage'].get('url')
|
| 230 |
-
try:
|
| 231 |
-
if not allowed(home,domains): raise ValueError('Official publisher not established')
|
| 232 |
-
async with asyncio.timeout(15):
|
| 233 |
-
page,html=await asyncio.to_thread(fetch_page,home,domains)
|
| 234 |
-
# Require journal identity on the publisher page, not just its domain.
|
| 235 |
-
from researchbee_journals import JournalIndex
|
| 236 |
-
text=JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',html))
|
| 237 |
-
title=JournalIndex.normalise_identity_title(rec.title)
|
| 238 |
-
if title not in text and not any(i and i.replace('-','') in re.sub('[^0-9X]','',html.upper()) for i in (rec.issn_e,rec.issn_print)):
|
| 239 |
-
raise ValueError('Journal page identity missing')
|
| 240 |
-
parser=Anchors(); parser.feed(html)
|
| 241 |
-
candidates=[]
|
| 242 |
-
for href,label in parser.links:
|
| 243 |
-
if re.search(r'author.{0,25}(guid|instruction|information)|guide for authors|submission guidelines',label,re.I):
|
| 244 |
-
target=urljoin(page,href)
|
| 245 |
-
if allowed(target,domains): candidates.append(target)
|
| 246 |
-
if not candidates: raise ValueError('No actual guidance link')
|
| 247 |
-
target,body=await asyncio.to_thread(fetch_page,candidates[0],domains)
|
| 248 |
-
headings = ' '.join(re.findall(r'<(?:title|h1)\b[^>]*>(.*?)</(?:title|h1)>', body, flags=re.I|re.S))
|
| 249 |
-
home_parts=[p for p in urlsplit(page).path.split('/') if p]
|
| 250 |
-
journal_slug=home_parts[-1] if home_parts else ''
|
| 251 |
-
target_parts=urlsplit(target).path.split('/')
|
| 252 |
-
specific=bool(journal_slug and journal_slug in target_parts and title in JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',headings)))
|
| 253 |
-
result={'url':target,'status':'checked','scope':'journal-specific' if specific else 'general-publisher',
|
| 254 |
-
'source':page,'reviewed_at':stamp(),'message':'Journal-specific author instructions' if specific else 'General publisher guidance; journal-specific instructions were not established.'}
|
| 255 |
-
except Exception as exc:
|
| 256 |
-
result['failure_reason']=type(exc).__name__
|
| 257 |
-
cache_put(rec.scopus_id,result); links['guidelines']=result
|
| 258 |
-
return links
|
| 259 |
-
finally:
|
| 260 |
-
# Keep locks only while waiters exist; global gate bounds active tasks.
|
| 261 |
-
if not lock.locked() and not getattr(lock,'_waiters',None): _locks.pop(rec.scopus_id,None)
|
| 262 |
-
|
| 263 |
-
async def enrich_response(result):
|
| 264 |
-
jobs=[]
|
| 265 |
-
def walk(value):
|
| 266 |
-
if isinstance(value,dict):
|
| 267 |
-
if value.get('name') and ('ranking' in value or 'quartiles_by_field' in value or 'identity_verification' in value): jobs.append(value)
|
| 268 |
-
for child in list(value.values()): walk(child)
|
| 269 |
-
elif isinstance(value,list):
|
| 270 |
-
for child in value: walk(child)
|
| 271 |
-
walk(result)
|
| 272 |
-
semaphore=asyncio.Semaphore(5)
|
| 273 |
-
async def one(j):
|
| 274 |
-
async with semaphore:
|
| 275 |
-
try: await journal_links(j)
|
| 276 |
-
except Exception: j['journal_links']={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
|
| 277 |
-
await asyncio.gather(*(one(j) for j in jobs))
|
| 278 |
-
return result
|
| 279 |
-
|
| 280 |
-
def with_links(fn):
|
| 281 |
-
@functools.wraps(fn)
|
| 282 |
-
async def wrapped(*args,**kwargs): return await enrich_response(await fn(*args,**kwargs))
|
| 283 |
-
return wrapped
|
|
|
|
| 1 |
+
"""Evidence-backed hyperlinks. No model links, paid search, or client credentials."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import asyncio
|
| 4 |
+
import csv
|
| 5 |
+
import functools
|
| 6 |
+
import json
|
| 7 |
+
import http.client
|
| 8 |
+
import ipaddress
|
| 9 |
+
import re
|
| 10 |
+
import socket
|
| 11 |
+
import ssl
|
| 12 |
+
import time
|
| 13 |
+
from collections import OrderedDict
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from html.parser import HTMLParser
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from urllib.parse import urlsplit, urljoin
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).parent
|
| 20 |
+
# Publisher identity AND destination must match. Unknown publishers fail closed.
|
| 21 |
+
PUBLISHERS = {
|
| 22 |
+
'elsevier': ('elsevier.com', 'sciencedirect.com'),
|
| 23 |
+
'springer': ('springer.com', 'springernature.com', 'nature.com', 'biomedcentral.com'),
|
| 24 |
+
'nature': ('nature.com', 'springernature.com'),
|
| 25 |
+
'wiley': ('wiley.com',), 'taylor': ('tandfonline.com', 'taylorandfrancis.com'),
|
| 26 |
+
'sage': ('sagepub.com',), 'ieee': ('ieee.org',), 'mdpi': ('mdpi.com',),
|
| 27 |
+
'frontiers': ('frontiersin.org',), 'plos': ('plos.org',),
|
| 28 |
+
'oxford': ('oup.com',), 'cambridge': ('cambridge.org',),
|
| 29 |
+
'american chemical': ('acs.org',), 'royal society of chemistry': ('rsc.org',),
|
| 30 |
+
'iop': ('iop.org',), 'acm': ('acm.org',), 'american physical': ('aps.org',),
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
def safe_url(value):
|
| 34 |
+
try:
|
| 35 |
+
if not isinstance(value, str) or len(value) > 2048 or re.search(r'[\s\\\x00-\x1f]', value): return None
|
| 36 |
+
p = urlsplit(value)
|
| 37 |
+
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.port not in (None,443): return None
|
| 38 |
+
h = p.hostname.lower()
|
| 39 |
+
if '.' not in h or h.endswith(('.local', '.internal', '.localhost')): return None
|
| 40 |
+
try:
|
| 41 |
+
if not ipaddress.ip_address(h).is_global: return None
|
| 42 |
+
except ValueError: pass
|
| 43 |
+
return value
|
| 44 |
+
except ValueError: return None
|
| 45 |
+
|
| 46 |
+
def allowed(url, domains):
|
| 47 |
+
return bool(safe_url(url) and any(urlsplit(url).hostname == d or urlsplit(url).hostname.endswith('.'+d) for d in domains))
|
| 48 |
+
|
| 49 |
+
def stamp(): return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
| 50 |
+
def unavailable(): return {'url': None, 'status':'unavailable', 'source':None, 'reviewed_at':None}
|
| 51 |
+
|
| 52 |
+
def canonical(j):
|
| 53 |
+
from journal_lookup import get_index
|
| 54 |
+
from researchbee_journals import norm_issn
|
| 55 |
+
idx = get_index()
|
| 56 |
+
if not idx: return None
|
| 57 |
+
ident = j.get('identity_verification') or {}
|
| 58 |
+
sid = j.get('scopus_id') or ident.get('scopus_id')
|
| 59 |
+
issn = j.get('issn')
|
| 60 |
+
rec = idx.lookup_exact(scopus_id=str(sid)) if sid else idx.lookup_exact(issn=issn) if issn else idx.lookup_exact(title=j.get('name',''))
|
| 61 |
+
if rec and issn and norm_issn(issn) not in {norm_issn(rec.issn_e),norm_issn(rec.issn_print)}: return None
|
| 62 |
+
if rec and j.get('name'):
|
| 63 |
+
if idx.normalise_identity_title(j['name']) != idx.normalise_identity_title(rec.title): return None
|
| 64 |
+
return rec
|
| 65 |
+
|
| 66 |
+
@functools.lru_cache(maxsize=1)
|
| 67 |
+
def metric_data():
|
| 68 |
+
from journal_lookup import get_index
|
| 69 |
+
from scimago_index import parse_category_quartiles
|
| 70 |
+
idx = get_index()
|
| 71 |
+
names = {}
|
| 72 |
+
if idx:
|
| 73 |
+
for row in idx.con.execute('SELECT code, name FROM asjc'):
|
| 74 |
+
names.setdefault(row['name'].casefold(),set()).add(str(row['code']))
|
| 75 |
+
records = {}
|
| 76 |
+
with (ROOT/'scimagojr_2025.csv').open(encoding='latin-1',newline='') as f:
|
| 77 |
+
reader=csv.DictReader(f)
|
| 78 |
+
years=[re.fullmatch(r'Total Docs\. \((\d{4})\)',h) for h in reader.fieldnames]
|
| 79 |
+
year=next((m.group(1) for m in years if m),None)
|
| 80 |
+
for row in reader:
|
| 81 |
+
fields=[]
|
| 82 |
+
sid=row.get('Sourceid','')
|
| 83 |
+
profile=f'https://www.scimagojr.com/journalsearch.php?q={sid}&tip=sid' if sid.isdigit() else None
|
| 84 |
+
parsed=parse_category_quartiles(row.get('Categories',''))
|
| 85 |
+
labels={}
|
| 86 |
+
for part in row.get('Categories','').split(';'):
|
| 87 |
+
label=re.sub(r'\s*\(Q[1-4]\)\s*$','',part.strip(),flags=re.I)
|
| 88 |
+
if label: labels[' '.join(label.split()).casefold()]=label
|
| 89 |
+
for name,label in labels.items():
|
| 90 |
+
q=parsed.get(name)
|
| 91 |
+
codes=names.get(name.casefold(),set())
|
| 92 |
+
code=next(iter(codes)) if len(codes)==1 else None
|
| 93 |
+
fields.append({'field':label,'quartile':q,'code':code,'source':'SCImago SJR','year':year,
|
| 94 |
+
'category_url':f'https://www.scimagojr.com/journalrank.php?category={code}&year={year}' if code and year else None,
|
| 95 |
+
'profile_url':profile})
|
| 96 |
+
records[sid]={'fields':fields,'year':year,'profile_url':profile,'issns':set(re.findall(r'[0-9X]{8}',row.get('Issn','')))}
|
| 97 |
+
return records
|
| 98 |
+
|
| 99 |
+
@functools.lru_cache(maxsize=1)
|
| 100 |
+
def curated_records():
|
| 101 |
+
try:
|
| 102 |
+
return json.loads((ROOT/'journal_links_curated.json').read_text())
|
| 103 |
+
except (OSError, ValueError): return {}
|
| 104 |
+
|
| 105 |
+
def curated_guidance(rec):
|
| 106 |
+
entry = curated_records().get(str(rec.scopus_id))
|
| 107 |
+
if not isinstance(entry,dict): return None
|
| 108 |
+
norm=lambda v: re.sub('[^0-9X]','',str(v).upper())
|
| 109 |
+
if not ({norm(rec.issn_e),norm(rec.issn_print)}-{''}) & {norm(v) for v in entry.get('issns',[])}: return None
|
| 110 |
+
if not safe_url(entry.get('url')) or not safe_url(entry.get('source')): return None
|
| 111 |
+
domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
|
| 112 |
+
if not allowed(entry['url'],domains) or not allowed(entry['source'],domains): return None
|
| 113 |
+
if entry.get('scope') not in ('journal-specific','general-publisher'): return None
|
| 114 |
+
# Dates are retained exactly, never populated with this software release date.
|
| 115 |
+
return {**entry,'status':'curated'}
|
| 116 |
+
|
| 117 |
+
_cache=OrderedDict()
|
| 118 |
+
_metadata_cache=OrderedDict()
|
| 119 |
+
_locks={}
|
| 120 |
+
_gate=asyncio.Semaphore(4)
|
| 121 |
+
|
| 122 |
+
def cached(key):
|
| 123 |
+
item=_cache.get(key)
|
| 124 |
+
if item and time.monotonic()<item[0]:
|
| 125 |
+
_cache.move_to_end(key); return item[1]
|
| 126 |
+
if item: del _cache[key]
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
def cache_put(key,value):
|
| 130 |
+
_cache[key]=(time.monotonic()+(86400 if value['status']=='checked' else 3600),value)
|
| 131 |
+
while len(_cache)>512: _cache.popitem(last=False)
|
| 132 |
+
|
| 133 |
+
async def journal_links(j, fetch_metadata=True):
|
| 134 |
+
import openalex
|
| 135 |
+
rec=canonical(j)
|
| 136 |
+
result={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':bool(rec)}
|
| 137 |
+
j['journal_links']=result
|
| 138 |
+
if not rec: return result
|
| 139 |
+
result['canonical_id']=rec.scopus_id
|
| 140 |
+
metric=metric_data().get(str(rec.scimago_id)) or {}
|
| 141 |
+
if not ({re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)} & metric.get('issns',set())): metric={}
|
| 142 |
+
fields=metric.get('fields',[])
|
| 143 |
+
if not metric:
|
| 144 |
+
j['quartile']=None
|
| 145 |
+
if isinstance(j.get('ranking'),dict):
|
| 146 |
+
j['ranking'].update(quartile=None, verification_status='Not available')
|
| 147 |
+
j['quartiles_by_field']=fields
|
| 148 |
+
if isinstance(j.get('ranking'),dict):
|
| 149 |
+
j['ranking'].update(source='SCImago SJR' if metric else 'Not available',year=metric.get('year'),quartiles_by_field=fields)
|
| 150 |
+
vl=j.setdefault('verify_links',{})
|
| 151 |
+
vl['scimago']=metric.get('profile_url')
|
| 152 |
+
vl['scopus']=f'https://www.scopus.com/sourceid/{rec.scopus_id}' if str(rec.scopus_id).isdigit() else None
|
| 153 |
+
oa=j.get('openalex') or {}
|
| 154 |
+
expected={re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)}-{''}
|
| 155 |
+
norm=lambda s:re.sub('[^0-9X]','',str(s).upper())
|
| 156 |
+
matched=lambda v:bool(expected & {norm(s) for s in [v.get('issn_l'),*(v.get('issns') or [])]})
|
| 157 |
+
if not matched(oa) and fetch_metadata:
|
| 158 |
+
item=_metadata_cache.get(rec.scopus_id)
|
| 159 |
+
if item and time.monotonic()<item[0]:
|
| 160 |
+
oa=item[1]
|
| 161 |
+
_metadata_cache.move_to_end(rec.scopus_id)
|
| 162 |
+
else:
|
| 163 |
+
oa=await openalex.enrich_journal(rec.title,rec.issn_e or rec.issn_print) or {}
|
| 164 |
+
_metadata_cache[rec.scopus_id]=(time.monotonic()+3600,oa)
|
| 165 |
+
while len(_metadata_cache)>512: _metadata_cache.popitem(last=False)
|
| 166 |
+
if matched(oa) and safe_url(oa.get('homepage_url')):
|
| 167 |
+
result['homepage']={'url':oa['homepage_url'],'status':'metadata-only','source':oa.get('openalex_id'),'source_updated_at':oa.get('source_updated_at'),'retrieved_at':oa.get('retrieved_at'),'reviewed_at':None}
|
| 168 |
+
result['guidelines']=curated_guidance(rec) or cached(rec.scopus_id) or unavailable()
|
| 169 |
+
return result
|
| 170 |
+
|
| 171 |
+
class Anchors(HTMLParser):
|
| 172 |
+
def __init__(self): super().__init__(); self.links=[]; self.current=None; self.text=[]
|
| 173 |
+
def handle_starttag(self,tag,attrs):
|
| 174 |
+
if tag=='a': self.current=dict(attrs).get('href'); self.text=[]
|
| 175 |
+
def handle_data(self,data):
|
| 176 |
+
if self.current: self.text.append(data)
|
| 177 |
+
def handle_endtag(self,tag):
|
| 178 |
+
if tag=='a' and self.current:
|
| 179 |
+
self.links.append((self.current,' '.join(self.text))); self.current=None
|
| 180 |
+
|
| 181 |
+
class PinnedHTTPS(http.client.HTTPSConnection):
|
| 182 |
+
def __init__(self,host,ip): super().__init__(host,timeout=3,context=ssl.create_default_context()); self.ip=ip
|
| 183 |
+
def connect(self):
|
| 184 |
+
self.sock=self._context.wrap_socket(socket.create_connection((self.ip,443),self.timeout),server_hostname=self.host)
|
| 185 |
+
|
| 186 |
+
def fetch_page(url,domains):
|
| 187 |
+
"""At most 3 requests, public DNS only, pinned IP + original TLS SNI/Host."""
|
| 188 |
+
deadline=time.monotonic()+6
|
| 189 |
+
for redirect in range(3):
|
| 190 |
+
if time.monotonic()>deadline: raise TimeoutError('Page deadline')
|
| 191 |
+
if not allowed(url,domains): raise ValueError('Destination not allowed')
|
| 192 |
+
p=urlsplit(url)
|
| 193 |
+
ips={x[4][0] for x in socket.getaddrinfo(p.hostname,443,type=socket.SOCK_STREAM)}
|
| 194 |
+
if not ips or any(not ipaddress.ip_address(ip).is_global for ip in ips): raise ValueError('Unsafe DNS')
|
| 195 |
+
conn=PinnedHTTPS(p.hostname,sorted(ips)[0])
|
| 196 |
+
try:
|
| 197 |
+
conn.request('GET',(p.path or '/')+('?' + p.query if p.query else ''),headers={'User-Agent':'ResearchBee/1.9 (library@ku.ac.ae)','Accept':'text/html','Accept-Encoding':'identity'})
|
| 198 |
+
response=conn.getresponse()
|
| 199 |
+
if response.status in (301,302,303,307,308):
|
| 200 |
+
if redirect==2: raise ValueError('Redirect limit')
|
| 201 |
+
url=urljoin(url,response.getheader('Location','')); continue
|
| 202 |
+
if response.status!=200 or 'text/html' not in (response.getheader('Content-Type') or ''): raise ValueError('Page unavailable')
|
| 203 |
+
chunks=[]; total=0
|
| 204 |
+
while total<=512000:
|
| 205 |
+
if time.monotonic()>deadline: raise TimeoutError('Page deadline')
|
| 206 |
+
chunk=response.read1(min(16384,512001-total))
|
| 207 |
+
if not chunk: break
|
| 208 |
+
chunks.append(chunk); total+=len(chunk)
|
| 209 |
+
data=b''.join(chunks)
|
| 210 |
+
if len(data)>512000: raise ValueError('Page too large')
|
| 211 |
+
return url,data.decode('utf-8',errors='replace')
|
| 212 |
+
finally: conn.close()
|
| 213 |
+
raise ValueError('Redirect limit')
|
| 214 |
+
|
| 215 |
+
async def discover(j):
|
| 216 |
+
rec=canonical(j)
|
| 217 |
+
if not rec: return {'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
|
| 218 |
+
async with _gate:
|
| 219 |
+
lock=_locks.setdefault(rec.scopus_id,asyncio.Lock())
|
| 220 |
+
try:
|
| 221 |
+
async with lock:
|
| 222 |
+
links=await journal_links(j)
|
| 223 |
+
prior=curated_guidance(rec) or cached(rec.scopus_id)
|
| 224 |
+
if prior: links['guidelines']=prior; return links
|
| 225 |
+
result=unavailable()
|
| 226 |
+
result['attempted_at']=stamp()
|
| 227 |
+
result['message']='Author guidance unavailable; use the known journal homepage if available.'
|
| 228 |
+
domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
|
| 229 |
+
home=links['homepage'].get('url')
|
| 230 |
+
try:
|
| 231 |
+
if not allowed(home,domains): raise ValueError('Official publisher not established')
|
| 232 |
+
async with asyncio.timeout(15):
|
| 233 |
+
page,html=await asyncio.to_thread(fetch_page,home,domains)
|
| 234 |
+
# Require journal identity on the publisher page, not just its domain.
|
| 235 |
+
from researchbee_journals import JournalIndex
|
| 236 |
+
text=JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',html))
|
| 237 |
+
title=JournalIndex.normalise_identity_title(rec.title)
|
| 238 |
+
if title not in text and not any(i and i.replace('-','') in re.sub('[^0-9X]','',html.upper()) for i in (rec.issn_e,rec.issn_print)):
|
| 239 |
+
raise ValueError('Journal page identity missing')
|
| 240 |
+
parser=Anchors(); parser.feed(html)
|
| 241 |
+
candidates=[]
|
| 242 |
+
for href,label in parser.links:
|
| 243 |
+
if re.search(r'author.{0,25}(guid|instruction|information)|guide for authors|submission guidelines',label,re.I):
|
| 244 |
+
target=urljoin(page,href)
|
| 245 |
+
if allowed(target,domains): candidates.append(target)
|
| 246 |
+
if not candidates: raise ValueError('No actual guidance link')
|
| 247 |
+
target,body=await asyncio.to_thread(fetch_page,candidates[0],domains)
|
| 248 |
+
headings = ' '.join(re.findall(r'<(?:title|h1)\b[^>]*>(.*?)</(?:title|h1)>', body, flags=re.I|re.S))
|
| 249 |
+
home_parts=[p for p in urlsplit(page).path.split('/') if p]
|
| 250 |
+
journal_slug=home_parts[-1] if home_parts else ''
|
| 251 |
+
target_parts=urlsplit(target).path.split('/')
|
| 252 |
+
specific=bool(journal_slug and journal_slug in target_parts and title in JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',headings)))
|
| 253 |
+
result={'url':target,'status':'checked','scope':'journal-specific' if specific else 'general-publisher',
|
| 254 |
+
'source':page,'reviewed_at':stamp(),'message':'Journal-specific author instructions' if specific else 'General publisher guidance; journal-specific instructions were not established.'}
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
result['failure_reason']=type(exc).__name__
|
| 257 |
+
cache_put(rec.scopus_id,result); links['guidelines']=result
|
| 258 |
+
return links
|
| 259 |
+
finally:
|
| 260 |
+
# Keep locks only while waiters exist; global gate bounds active tasks.
|
| 261 |
+
if not lock.locked() and not getattr(lock,'_waiters',None): _locks.pop(rec.scopus_id,None)
|
| 262 |
+
|
| 263 |
+
async def enrich_response(result):
|
| 264 |
+
jobs=[]
|
| 265 |
+
def walk(value):
|
| 266 |
+
if isinstance(value,dict):
|
| 267 |
+
if value.get('name') and ('ranking' in value or 'quartiles_by_field' in value or 'identity_verification' in value): jobs.append(value)
|
| 268 |
+
for child in list(value.values()): walk(child)
|
| 269 |
+
elif isinstance(value,list):
|
| 270 |
+
for child in value: walk(child)
|
| 271 |
+
walk(result)
|
| 272 |
+
semaphore=asyncio.Semaphore(5)
|
| 273 |
+
async def one(j):
|
| 274 |
+
async with semaphore:
|
| 275 |
+
try: await journal_links(j)
|
| 276 |
+
except Exception: j['journal_links']={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
|
| 277 |
+
await asyncio.gather(*(one(j) for j in jobs))
|
| 278 |
+
return result
|
| 279 |
+
|
| 280 |
+
def with_links(fn):
|
| 281 |
+
@functools.wraps(fn)
|
| 282 |
+
async def wrapped(*args,**kwargs): return await enrich_response(await fn(*args,**kwargs))
|
| 283 |
+
return wrapped
|