unaltraweb 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/Makefile +412 -0
- data/README.md +226 -0
- data/_config.yml +842 -0
- data/_data/i18n/ca.yml +278 -0
- data/_data/i18n/en.yml +278 -0
- data/_data/i18n/es.yml +278 -0
- data/_includes/audio.liquid +30 -0
- data/_includes/bib_search.liquid +5 -0
- data/_includes/blog-list.liquid +66 -0
- data/_includes/book-citation.liquid +33 -0
- data/_includes/citation.liquid +26 -0
- data/_includes/custom_head.liquid +3 -0
- data/_includes/custom_scripts.liquid +5 -0
- data/_includes/cv/list.liquid +5 -0
- data/_includes/cv/list_groups.liquid +51 -0
- data/_includes/cv/map.liquid +29 -0
- data/_includes/cv/nested_list.liquid +16 -0
- data/_includes/cv/time_table.liquid +118 -0
- data/_includes/cv-download-card.liquid +26 -0
- data/_includes/developer-mode-switcher.liquid +24 -0
- data/_includes/disqus.liquid +13 -0
- data/_includes/distill_scripts.liquid +298 -0
- data/_includes/documentation-download-detail.liquid +76 -0
- data/_includes/documentation-downloads.liquid +71 -0
- data/_includes/documentation-profile-attributes.liquid +4 -0
- data/_includes/documentation-section-toc.liquid +9 -0
- data/_includes/documentation-sidebar.liquid +112 -0
- data/_includes/figure.liquid +87 -0
- data/_includes/footer.liquid +66 -0
- data/_includes/giscus.liquid +25 -0
- data/_includes/head.liquid +220 -0
- data/_includes/header.liquid +544 -0
- data/_includes/latest_posts.liquid +61 -0
- data/_includes/localized-date.liquid +5 -0
- data/_includes/manual-bibliography.liquid +2 -0
- data/_includes/manual-featured-readings.liquid +12 -0
- data/_includes/manual-other-readings.liquid +12 -0
- data/_includes/manual-page-toc.liquid +11 -0
- data/_includes/manual-sidebar.liquid +58 -0
- data/_includes/manual-teachers.liquid +74 -0
- data/_includes/metadata.liquid +278 -0
- data/_includes/news.liquid +48 -0
- data/_includes/newsletter.liquid +68 -0
- data/_includes/page-hero.liquid +96 -0
- data/_includes/page-link.liquid +8 -0
- data/_includes/pagination.liquid +22 -0
- data/_includes/post-reading-time.liquid +29 -0
- data/_includes/previous-next.liquid +37 -0
- data/_includes/profile-card.liquid +49 -0
- data/_includes/profile-feature-matrix.liquid +75 -0
- data/_includes/profile-highlights.liquid +122 -0
- data/_includes/project-grid.liquid +26 -0
- data/_includes/project-resource-link.liquid +45 -0
- data/_includes/project-resources.liquid +21 -0
- data/_includes/projects.liquid +38 -0
- data/_includes/projects_horizontal.liquid +37 -0
- data/_includes/publications-metrics-summary.liquid +69 -0
- data/_includes/publications-summary.liquid +127 -0
- data/_includes/reading-biblio-controls.liquid +65 -0
- data/_includes/reading-cover-card.liquid +93 -0
- data/_includes/reading-rating.liquid +11 -0
- data/_includes/recommended-readings.liquid +30 -0
- data/_includes/related_posts.liquid +39 -0
- data/_includes/repository/repo.liquid +48 -0
- data/_includes/repository/repo_trophies.liquid +44 -0
- data/_includes/repository/repo_user.liquid +35 -0
- data/_includes/resume/awards.liquid +19 -0
- data/_includes/resume/basics.liquid +28 -0
- data/_includes/resume/certificates.liquid +35 -0
- data/_includes/resume/education.liquid +54 -0
- data/_includes/resume/interests.liquid +34 -0
- data/_includes/resume/languages.liquid +31 -0
- data/_includes/resume/projects.liquid +32 -0
- data/_includes/resume/publications.liquid +28 -0
- data/_includes/resume/references.liquid +31 -0
- data/_includes/resume/skills.liquid +33 -0
- data/_includes/resume/volunteer.liquid +52 -0
- data/_includes/resume/work.liquid +52 -0
- data/_includes/scripts.liquid +364 -0
- data/_includes/search-data.js.liquid +341 -0
- data/_includes/selected_papers.liquid +3 -0
- data/_includes/t.liquid +31 -0
- data/_includes/team-grid.liquid +172 -0
- data/_includes/video.liquid +97 -0
- data/_layouts/about.liquid +93 -0
- data/_layouts/archive.liquid +45 -0
- data/_layouts/bib.liquid +677 -0
- data/_layouts/book-review.liquid +225 -0
- data/_layouts/book-shelf.liquid +66 -0
- data/_layouts/cv.liquid +127 -0
- data/_layouts/default.liquid +70 -0
- data/_layouts/distill.liquid +132 -0
- data/_layouts/documentation-download-detail.liquid +5 -0
- data/_layouts/documentation-home.liquid +100 -0
- data/_layouts/documentation-page.liquid +45 -0
- data/_layouts/manual-bib.liquid +73 -0
- data/_layouts/manual-chapter.liquid +99 -0
- data/_layouts/manual-featured-bib.liquid +100 -0
- data/_layouts/manual-home.liquid +120 -0
- data/_layouts/none.liquid +1 -0
- data/_layouts/outputs.liquid +29 -0
- data/_layouts/page.liquid +52 -0
- data/_layouts/post.liquid +95 -0
- data/_layouts/profile.liquid +36 -0
- data/_layouts/profiles.liquid +36 -0
- data/_layouts/project.liquid +7 -0
- data/_layouts/repositories.liquid +58 -0
- data/_layouts/theses.liquid +126 -0
- data/_plugins/bibliography_profiles.rb +164 -0
- data/_plugins/callouts.rb +104 -0
- data/_plugins/code_blocks.rb +277 -0
- data/_plugins/computation_figure_images.rb +266 -0
- data/_plugins/computation_sources.rb +42 -0
- data/_plugins/content_search_index.rb +210 -0
- data/_plugins/details.rb +23 -0
- data/_plugins/external-posts.rb +124 -0
- data/_plugins/figure_captions.rb +800 -0
- data/_plugins/file-exists.rb +22 -0
- data/_plugins/google-scholar-citations.rb +86 -0
- data/_plugins/hide-custom-bibtex.rb +18 -0
- data/_plugins/inspirehep-citations.rb +57 -0
- data/_plugins/localized_visual_sources.rb +145 -0
- data/_plugins/manual_release_metadata.rb +47 -0
- data/_plugins/mermaid_mmd_images.rb +233 -0
- data/_plugins/profile-pages.rb +28 -0
- data/_plugins/remove-accents.rb +32 -0
- data/_plugins/reproducible_build_time.rb +55 -0
- data/_plugins/search-data.rb +83 -0
- data/_plugins/theme-cache-bust.rb +56 -0
- data/_plugins/vega_visual_images.rb +290 -0
- data/_plugins/web_capture_images.rb +154 -0
- data/_sass/_base.scss +2133 -0
- data/_sass/_blog.scss +67 -0
- data/_sass/_callouts.scss +100 -0
- data/_sass/_custom.scss +437 -0
- data/_sass/_cv.scss +274 -0
- data/_sass/_developer.scss +97 -0
- data/_sass/_distill.scss +185 -0
- data/_sass/_documentation.scss +2220 -0
- data/_sass/_layout.scss +218 -0
- data/_sass/_manual.scss +2691 -0
- data/_sass/_profile.scss +323 -0
- data/_sass/_projects.scss +163 -0
- data/_sass/_reading.scss +465 -0
- data/_sass/_site-custom.scss +3 -0
- data/_sass/_tabs.scss +48 -0
- data/_sass/_team.scss +230 -0
- data/_sass/_themes.scss +254 -0
- data/_sass/_typograms.scss +132 -0
- data/_sass/_variables.scss +52 -0
- data/_sass/font-awesome/_animated.scss +152 -0
- data/_sass/font-awesome/_bordered-pulled.scss +20 -0
- data/_sass/font-awesome/_core.scss +49 -0
- data/_sass/font-awesome/_fixed-width.scss +7 -0
- data/_sass/font-awesome/_functions.scss +57 -0
- data/_sass/font-awesome/_icons.scss +13 -0
- data/_sass/font-awesome/_list.scss +18 -0
- data/_sass/font-awesome/_mixins.scss +65 -0
- data/_sass/font-awesome/_rotated-flipped.scss +31 -0
- data/_sass/font-awesome/_screen-reader.scss +14 -0
- data/_sass/font-awesome/_shims.scss +1578 -0
- data/_sass/font-awesome/_sizing.scss +16 -0
- data/_sass/font-awesome/_stacked.scss +32 -0
- data/_sass/font-awesome/_variables.scss +5044 -0
- data/_sass/font-awesome/brands.scss +30 -0
- data/_sass/font-awesome/fontawesome.scss +21 -0
- data/_sass/font-awesome/regular.scss +27 -0
- data/_sass/font-awesome/solid.scss +27 -0
- data/_sass/font-awesome/v4-shims.scss +11 -0
- data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
- data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
- data/_sass/tabler-icons/tabler-icons.scss +11463 -0
- data/_scripts/cronitor-analytics-setup.js +9 -0
- data/_scripts/giscus-setup.js +48 -0
- data/_scripts/google-analytics-setup.js +9 -0
- data/_scripts/open-panel-analytics-setup.js +14 -0
- data/_scripts/photoswipe-setup.js +11 -0
- data/assets/audio/epicaly-short-113909.mp3 +0 -0
- data/assets/bibliography/2018-12-22-distill.bib +7 -0
- data/assets/css/academicons.min.css +1 -0
- data/assets/css/bootstrap-toc.min.css +4 -0
- data/assets/css/bootstrap.min.css +7 -0
- data/assets/css/bootstrap.min.css.map +1 -0
- data/assets/css/jekyll-pygments-themes-github.css +209 -0
- data/assets/css/jekyll-pygments-themes-native.css +232 -0
- data/assets/css/jupyter-grade3.css +3758 -0
- data/assets/css/jupyter-monokai.css +3758 -0
- data/assets/css/jupyter.css +383 -0
- data/assets/css/main.scss +36 -0
- data/assets/css/mdb.min.css +48 -0
- data/assets/css/mdb.min.css.map +1 -0
- data/assets/css/scholar-icons.css +386 -0
- data/assets/fonts/academicons.ttf +0 -0
- data/assets/fonts/academicons.woff +0 -0
- data/assets/fonts/scholar-icons.ttf +0 -0
- data/assets/fonts/scholar-icons.woff +0 -0
- data/assets/fonts/tabler-icons-filled.ttf +0 -0
- data/assets/fonts/tabler-icons-filled.woff +0 -0
- data/assets/fonts/tabler-icons-filled.woff2 +0 -0
- data/assets/fonts/tabler-icons-outline.ttf +0 -0
- data/assets/fonts/tabler-icons-outline.woff +0 -0
- data/assets/fonts/tabler-icons-outline.woff2 +0 -0
- data/assets/fonts/tabler-icons.ttf +0 -0
- data/assets/fonts/tabler-icons.woff +0 -0
- data/assets/fonts/tabler-icons.woff2 +0 -0
- data/assets/html/relativity.html +15 -0
- data/assets/img/1.jpg +0 -0
- data/assets/img/10.jpg +0 -0
- data/assets/img/11.jpg +0 -0
- data/assets/img/12.jpg +0 -0
- data/assets/img/2.jpg +0 -0
- data/assets/img/3.jpg +0 -0
- data/assets/img/4.jpg +0 -0
- data/assets/img/5.jpg +0 -0
- data/assets/img/6.jpg +0 -0
- data/assets/img/7.jpg +0 -0
- data/assets/img/8.jpg +0 -0
- data/assets/img/9.jpg +0 -0
- data/assets/img/book_covers/the_godfather.jpg +0 -0
- data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
- data/assets/img/prof_pic.jpg +0 -0
- data/assets/img/prof_pic_color.png +0 -0
- data/assets/img/profile-placeholder.svg +8 -0
- data/assets/img/publication_preview/brownian-motion.gif +0 -0
- data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
- data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
- data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
- data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
- data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
- data/assets/img/publication_preview/wave-mechanics.gif +0 -0
- data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
- data/assets/img/rhino.png +0 -0
- data/assets/img/team/aaron-gutierrez.png +0 -0
- data/assets/img/team/alba-font.png +0 -0
- data/assets/img/team/alba-viana.png +0 -0
- data/assets/img/team/alejandro-gonzalez.png +0 -0
- data/assets/img/team/ana-pastor.png +0 -0
- data/assets/img/team/antoni-domenech.png +0 -0
- data/assets/img/team/benito-zaragozi.png +0 -0
- data/assets/img/team/cinta-sanz.png +0 -0
- data/assets/img/team/daniel-miravet.png +0 -0
- data/assets/img/team/eugenia-altamirano.png +0 -0
- data/assets/img/team/fiammetta-brandajs.png +0 -0
- data/assets/img/team/francesc-gonzalez.png +0 -0
- data/assets/img/team/isabel-paulino.png +0 -0
- data/assets/img/team/joan-borras.png +0 -0
- data/assets/img/team/julie-wilson.png +0 -0
- data/assets/img/team/liliana-sole.png +0 -0
- data/assets/img/team/maria-casado.png +0 -0
- data/assets/img/team/maria-medina.png +0 -0
- data/assets/img/team/marta-nello.png +0 -0
- data/assets/img/team/mogrify +1 -0
- data/assets/img/team/oscar-saladie.png +0 -0
- data/assets/img/team/paolo-russo.png +0 -0
- data/assets/img/team/raquel-santos.png +0 -0
- data/assets/img/team/salvador-anton.png +0 -0
- data/assets/img/team/sunsi-huertas.png +0 -0
- data/assets/img/team/user-placeholder.png +0 -0
- data/assets/img/team/wilbert-den-hoed.png +0 -0
- data/assets/img/team/xavier-delclos.png +0 -0
- data/assets/img/template_error.png +0 -0
- data/assets/js/bibsearch.js +70 -0
- data/assets/js/bootstrap-toc.min.js +5 -0
- data/assets/js/bootstrap.bundle.min.js +7 -0
- data/assets/js/bootstrap.bundle.min.js.map +1 -0
- data/assets/js/callouts.js +115 -0
- data/assets/js/chartjs-setup.js +14 -0
- data/assets/js/common.js +117 -0
- data/assets/js/content-search-match.js +159 -0
- data/assets/js/content-search.js +441 -0
- data/assets/js/copy_code.js +60 -0
- data/assets/js/custom.js +6 -0
- data/assets/js/developer-mode.js +103 -0
- data/assets/js/diff2html-setup.js +20 -0
- data/assets/js/distillpub/overrides.js +27 -0
- data/assets/js/distillpub/template.v2.js +9616 -0
- data/assets/js/distillpub/template.v2.js.map +1 -0
- data/assets/js/distillpub/transforms.v2.js +14557 -0
- data/assets/js/distillpub/transforms.v2.js.map +1 -0
- data/assets/js/documentation.js +435 -0
- data/assets/js/echarts-setup.js +29 -0
- data/assets/js/highlight-search-term.js +110 -0
- data/assets/js/jupyter_new_tab.js +18 -0
- data/assets/js/leaflet-setup.js +22 -0
- data/assets/js/manual.js +308 -0
- data/assets/js/masonry.js +12 -0
- data/assets/js/mathjax-setup.js +46 -0
- data/assets/js/mermaid-setup.js +37 -0
- data/assets/js/newsletter.js +105 -0
- data/assets/js/no_defer.js +24 -0
- data/assets/js/plotly-setup.js +52 -0
- data/assets/js/profile-nav.js +92 -0
- data/assets/js/progress-bar.js +73 -0
- data/assets/js/pseudocode-setup.js +18 -0
- data/assets/js/search/base-styles.min.js +286 -0
- data/assets/js/search/command-score.min.js +7 -0
- data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
- data/assets/js/search/lit/decorators.min.js +7 -0
- data/assets/js/search/lit/directives/class-map.min.js +7 -0
- data/assets/js/search/lit/directives/join.min.js +7 -0
- data/assets/js/search/lit/directives/live.min.js +7 -0
- data/assets/js/search/lit/directives/ref.min.js +7 -0
- data/assets/js/search/lit/directives/repeat.min.js +7 -0
- data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
- data/assets/js/search/lit/index.min.js +7 -0
- data/assets/js/search/lit-element/lit-element.min.js +12 -0
- data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
- data/assets/js/search/lit-html/directives/join.min.js +12 -0
- data/assets/js/search/lit-html/directives/live.min.js +28 -0
- data/assets/js/search/lit-html/directives/ref.min.js +32 -0
- data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
- data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
- data/assets/js/search/lit-html/is-server.min.js +12 -0
- data/assets/js/search/lit-html/lit-html.min.js +12 -0
- data/assets/js/search/ninja-action.min.js +101 -0
- data/assets/js/search/ninja-footer.min.js +62 -0
- data/assets/js/search/ninja-header.min.js +78 -0
- data/assets/js/search/ninja-keys.min.js +39 -0
- data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
- data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
- data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
- data/assets/js/search-setup.js +17 -0
- data/assets/js/shortcut-key.js +11 -0
- data/assets/js/tabs.min.js +5 -0
- data/assets/js/theme.js +314 -0
- data/assets/js/tooltips-setup.js +3 -0
- data/assets/js/typograms.js +1341 -0
- data/assets/js/vanilla-back-to-top.min.js +1 -0
- data/assets/js/vega-setup.js +24 -0
- data/assets/js/venobox-setup.js +5 -0
- data/assets/js/wechat.js +14 -0
- data/assets/js/zoom.js +15 -0
- data/assets/json/resume.json +98 -0
- data/assets/json/table_data.json +128 -0
- data/assets/jupyter/blog.ipynb +48 -0
- data/assets/pdf/example_pdf.pdf +0 -0
- data/assets/plotly/demo.html +72 -0
- data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
- data/assets/webfonts/fa-brands-400.ttf +0 -0
- data/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/assets/webfonts/fa-regular-400.ttf +0 -0
- data/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/assets/webfonts/fa-solid-900.ttf +0 -0
- data/assets/webfonts/fa-solid-900.woff2 +0 -0
- data/assets/webfonts/fa-v4compatibility.ttf +0 -0
- data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
- data/docs/Gemfile +5 -0
- data/docs/_config.yml +83 -0
- data/docs/_documentation/en/01-quick-start.md +115 -0
- data/docs/_documentation/en/02-tools.md +146 -0
- data/docs/_documentation/en/03-usage.md +107 -0
- data/docs/_documentation/en/04-jekyll-basics.md +51 -0
- data/docs/_documentation/en/05-pages-collections.md +68 -0
- data/docs/_documentation/en/06-github-web-editing.md +92 -0
- data/docs/_documentation/en/10-profiles.md +125 -0
- data/docs/_documentation/en/11-unaltreselfie.md +39 -0
- data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
- data/docs/_documentation/en/13-unaltremanual.md +237 -0
- data/docs/_documentation/en/14-unaltredocs.md +92 -0
- data/docs/_documentation/en/15-profile-checklist.md +39 -0
- data/docs/_documentation/en/20-syntax.md +353 -0
- data/docs/_documentation/en/21-themes.md +103 -0
- data/docs/_documentation/en/22-feature-reference.md +56 -0
- data/docs/_documentation/en/23-front-matter.md +51 -0
- data/docs/_documentation/en/24-data-files.md +28 -0
- data/docs/_documentation/en/30-customization.md +448 -0
- data/docs/_documentation/en/31-template.md +55 -0
- data/docs/_documentation/en/32-development.md +108 -0
- data/docs/_documentation/en/33-reusable-workflows.md +136 -0
- data/docs/_documentation/en/40-distribution.md +164 -0
- data/docs/_documentation/en/41-bibliometrics.md +137 -0
- data/docs/_documentation/en/42-docker-image.md +185 -0
- data/docs/_documentation/en/50-faq.md +33 -0
- data/docs/_pages/ca/index.md +35 -0
- data/docs/_pages/en/index.md +35 -0
- data/docs/_pages/es/index.md +35 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
- data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
- data/docs/agents/action-prompts/10-content-update.txt +9 -0
- data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
- data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
- data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
- data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
- data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
- data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
- data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
- data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
- data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
- data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
- data/docs/agents/manual-authoring-components.md +255 -0
- data/docs/agents/mcp-contract.md +162 -0
- data/docs/agents/quarto-computation-adoption.md +102 -0
- data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
- data/lib/unaltraweb/version.rb +8 -0
- data/lib/unaltraweb.rb +31 -0
- data/requirements.txt +5 -0
- data/scripts/biblio/fetch_scimago_csv.sh +126 -0
- data/scripts/biblio/metrics_common.py +154 -0
- data/scripts/biblio/metrics_merge_meta.py +73 -0
- data/scripts/biblio/metrics_update.py +826 -0
- data/scripts/computations/python/Dockerfile +37 -0
- data/scripts/computations/r/Dockerfile +31 -0
- data/scripts/computations/render.py +1267 -0
- data/scripts/cv/render_pdf_preview.sh +36 -0
- data/scripts/deploy/publish_branch.sh +80 -0
- data/scripts/generate_logo_variants.py +47 -0
- data/scripts/manual/Dockerfile +31 -0
- data/scripts/manual/build_pdf.py +1780 -0
- data/scripts/manual/filters/bibliography.lua +164 -0
- data/scripts/manual/filters/code-blocks.lua +102 -0
- data/scripts/manual/filters/figure-captions.lua +35 -0
- data/scripts/manual/publish_release.sh +281 -0
- data/scripts/manual/templates/manual.tex +494 -0
- data/scripts/manual/verify_release_assets.py +650 -0
- data/scripts/test_gem_build.py +128 -0
- data/scripts/test_reproducible_jekyll_build.py +191 -0
- data/scripts/test_wheel_install.py +282 -0
- data/scripts/unaltraweb-docker-mount.sh +36 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
- data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
- data/scripts/unaltraweb-mcp-project-id.sh +34 -0
- data/scripts/validate_distribution.py +563 -0
- data/scripts/validate_workflows.py +1285 -0
- data/scripts/web_captures/Dockerfile +31 -0
- data/scripts/web_captures/capture.mjs +157 -0
- data/scripts/web_captures/package-lock.json +56 -0
- data/scripts/web_captures/package.json +9 -0
- data/scripts/web_captures/render.py +867 -0
- data/src/unaltraweb_mcp/component-contract.json +153 -0
- data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
- data/src/unaltraweb_mcp/docker_mount.py +19 -0
- metadata +861 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import bibtexparser
|
|
20
|
+
from bibtexparser.bparser import BibTexParser
|
|
21
|
+
from bibtexparser.bwriter import BibTexWriter
|
|
22
|
+
from bibtexparser.customization import convert_to_unicode
|
|
23
|
+
except Exception as exc: # pragma: no cover - runtime guard
|
|
24
|
+
print("Missing dependency 'bibtexparser'. Install dependencies first:", file=sys.stderr)
|
|
25
|
+
print(" python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", file=sys.stderr)
|
|
26
|
+
print(f"Details: {exc}", file=sys.stderr)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
from metrics_common import SummaryContext, as_bool, as_float, as_int, compact_categories, compute_summary, fmt_float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
X_FIELDS = [
|
|
33
|
+
"x_openalex_id",
|
|
34
|
+
"x_openalex_cited_by",
|
|
35
|
+
"x_crossref_cited_by",
|
|
36
|
+
"x_gs_id",
|
|
37
|
+
"x_gs_cited_by",
|
|
38
|
+
"x_openalex_fwci",
|
|
39
|
+
"x_openalex_citation_normalized_percentile",
|
|
40
|
+
"x_openalex_topic",
|
|
41
|
+
"x_openalex_subfield",
|
|
42
|
+
"x_openalex_field",
|
|
43
|
+
"x_openalex_domain",
|
|
44
|
+
"x_openalex_funders",
|
|
45
|
+
"x_openalex_source_display_name",
|
|
46
|
+
"x_openalex_source_works_count",
|
|
47
|
+
"x_openalex_source_cited_by_count",
|
|
48
|
+
"x_openalex_source_is_oa",
|
|
49
|
+
"x_openalex_source_is_core",
|
|
50
|
+
"x_openalex_source_is_in_doaj",
|
|
51
|
+
"x_openalex_source_h_index",
|
|
52
|
+
"x_openalex_source_i10_index",
|
|
53
|
+
"x_openalex_source_2yr_mean_citedness",
|
|
54
|
+
"x_openalex_cited_by_2y",
|
|
55
|
+
"x_openalex_cited_by_5y",
|
|
56
|
+
"x_scimago_sjr",
|
|
57
|
+
"x_scimago_quartile",
|
|
58
|
+
"x_scimago_year",
|
|
59
|
+
"x_scimago_categories",
|
|
60
|
+
"x_scimago_h_index",
|
|
61
|
+
"x_scimago_cites_per_doc_2y",
|
|
62
|
+
"x_scimago_total_docs_3y",
|
|
63
|
+
"x_scimago_total_cites_3y",
|
|
64
|
+
"x_scimago_country",
|
|
65
|
+
"x_scimago_type",
|
|
66
|
+
"x_scimago_coverage",
|
|
67
|
+
"x_metrics_updated",
|
|
68
|
+
"note",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_args() -> argparse.Namespace:
|
|
73
|
+
parser = argparse.ArgumentParser(description="Update bibliography with bibliometric metrics.")
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--bib",
|
|
76
|
+
action="append",
|
|
77
|
+
default=[],
|
|
78
|
+
help="BibTeX file path (can be repeated). Defaults to all _bibliography/*.bib",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--overrides", default="_data/metrics-overrides.yml", help="Overrides YAML path")
|
|
81
|
+
parser.add_argument("--citations-data", default="_data/citations.yml", help="Google Scholar citations YAML path")
|
|
82
|
+
parser.add_argument("--scimago-csv", default=".cache/scimago/scimagojr.csv", help="Local Scimago CSV path")
|
|
83
|
+
parser.add_argument("--metrics-out", default="_data/metrics.yml", help="Aggregated metrics YAML output")
|
|
84
|
+
parser.add_argument("--report-json", default="tmp/metrics-report.json", help="Diagnostics JSON output")
|
|
85
|
+
parser.add_argument("--unmatched-tsv", default="tmp/metrics-unmatched.tsv", help="Diagnostics TSV output")
|
|
86
|
+
parser.add_argument("--max-year-gap", type=int, default=2, help="Max gap between publication year and Scimago year")
|
|
87
|
+
parser.add_argument("--offline", action="store_true", help="Do not call OpenAlex/Crossref")
|
|
88
|
+
parser.add_argument("--dry-run", action="store_true", help="Do not rewrite BibTeX files")
|
|
89
|
+
parser.add_argument("--strict-external", action="store_true", help="Exit non-zero if OpenAlex/Crossref requests fail")
|
|
90
|
+
parser.add_argument("--require-scimago", action="store_true", help="Exit non-zero when the Scimago CSV is unavailable")
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--validate-scimago",
|
|
93
|
+
default="",
|
|
94
|
+
help="Only validate a Scimago CSV file and exit (path argument)",
|
|
95
|
+
)
|
|
96
|
+
return parser.parse_args()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_doi(value: str | None) -> str | None:
|
|
100
|
+
if not value:
|
|
101
|
+
return None
|
|
102
|
+
doi = value.strip()
|
|
103
|
+
doi = doi.replace("https://doi.org/", "").replace("http://doi.org/", "")
|
|
104
|
+
doi = doi.replace("doi:", "").strip()
|
|
105
|
+
return doi.lower() if doi else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def normalize_issn_token(value: str) -> str | None:
|
|
109
|
+
token = re.sub(r"[^0-9xX]", "", value or "")
|
|
110
|
+
if len(token) != 8:
|
|
111
|
+
return None
|
|
112
|
+
return f"{token[:4]}-{token[4:].upper()}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def normalize_issn_list(raw_value: str | None) -> list[str]:
|
|
116
|
+
if not raw_value:
|
|
117
|
+
return []
|
|
118
|
+
items = re.split(r"[;,|\s]+", raw_value)
|
|
119
|
+
normalized = []
|
|
120
|
+
seen = set()
|
|
121
|
+
for item in items:
|
|
122
|
+
issn = normalize_issn_token(item)
|
|
123
|
+
if issn and issn not in seen:
|
|
124
|
+
seen.add(issn)
|
|
125
|
+
normalized.append(issn)
|
|
126
|
+
return normalized
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
130
|
+
if not path.exists():
|
|
131
|
+
return {}
|
|
132
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
133
|
+
return data if isinstance(data, dict) else {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find_bib_files(cli_bib_args: list[str]) -> list[Path]:
|
|
137
|
+
if cli_bib_args:
|
|
138
|
+
return [Path(p) for p in cli_bib_args]
|
|
139
|
+
return sorted(Path("_bibliography").glob("*.bib"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def split_bib_front_matter(content: str) -> tuple[str, str]:
|
|
143
|
+
match = re.search(r"@\w+\s*\{", content)
|
|
144
|
+
if not match:
|
|
145
|
+
return content, ""
|
|
146
|
+
return content[: match.start()], content[match.start() :]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_bib_database(path: Path) -> tuple[str, Any]:
|
|
150
|
+
content = path.read_text(encoding="utf-8")
|
|
151
|
+
prefix, bib_body = split_bib_front_matter(content)
|
|
152
|
+
|
|
153
|
+
parser = BibTexParser(common_strings=True)
|
|
154
|
+
parser.ignore_nonstandard_types = False
|
|
155
|
+
parser.homogenize_fields = False
|
|
156
|
+
parser.customization = convert_to_unicode
|
|
157
|
+
db = bibtexparser.loads(bib_body, parser=parser) if bib_body.strip() else bibtexparser.bibdatabase.BibDatabase()
|
|
158
|
+
return prefix, db
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write_bib_database(path: Path, prefix: str, db: Any) -> None:
|
|
162
|
+
writer = BibTexWriter()
|
|
163
|
+
writer.indent = " "
|
|
164
|
+
writer.comma_first = False
|
|
165
|
+
writer.display_order = []
|
|
166
|
+
writer.order_entries_by = ("ID",)
|
|
167
|
+
rendered = bibtexparser.dumps(db, writer)
|
|
168
|
+
path.write_text(f"{prefix}{rendered}", encoding="utf-8")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_float_text(value: str | None) -> float | None:
|
|
172
|
+
if value is None:
|
|
173
|
+
return None
|
|
174
|
+
text = str(value).strip()
|
|
175
|
+
if not text:
|
|
176
|
+
return None
|
|
177
|
+
text = text.replace(" ", "")
|
|
178
|
+
if text.count(",") == 1 and text.count(".") == 0:
|
|
179
|
+
text = text.replace(",", ".")
|
|
180
|
+
elif text.count(",") > 1 and text.count(".") == 0:
|
|
181
|
+
text = text.replace(".", "").replace(",", ".")
|
|
182
|
+
else:
|
|
183
|
+
text = text.replace(",", "")
|
|
184
|
+
try:
|
|
185
|
+
return float(text)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_int_text(value: str | None) -> int | None:
|
|
191
|
+
if value is None:
|
|
192
|
+
return None
|
|
193
|
+
text = str(value).strip().replace(" ", "")
|
|
194
|
+
if not text:
|
|
195
|
+
return None
|
|
196
|
+
if re.fullmatch(r"-?\d+(?:\.\d+)?", text):
|
|
197
|
+
try:
|
|
198
|
+
return int(float(text))
|
|
199
|
+
except ValueError:
|
|
200
|
+
return None
|
|
201
|
+
token = re.sub(r"[^0-9-]", "", text)
|
|
202
|
+
if not token:
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
return int(token)
|
|
206
|
+
except ValueError:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def normalize_column_name(name: str) -> str:
|
|
211
|
+
return re.sub(r"[^a-z0-9]", "", (name or "").lower())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def detect_scimago_columns(fieldnames: list[str]) -> dict[str, str]:
|
|
215
|
+
normalized = {normalize_column_name(name): name for name in fieldnames if name}
|
|
216
|
+
|
|
217
|
+
def pick(*candidates: str) -> str | None:
|
|
218
|
+
for candidate in candidates:
|
|
219
|
+
if candidate in normalized:
|
|
220
|
+
return normalized[candidate]
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
columns = {
|
|
224
|
+
"title": pick("title", "sourcetitle", "journaltitle"),
|
|
225
|
+
"issn": pick("issn", "issns", "issncode", "issnprint"),
|
|
226
|
+
"year": pick("year", "rankyear"),
|
|
227
|
+
"sjr": pick("sjr", "sjrindex"),
|
|
228
|
+
"quartile": pick("sjrbestquartile", "bestquartile", "quartile"),
|
|
229
|
+
"categories": pick("categories", "category", "subjectareaandcategory"),
|
|
230
|
+
"h_index": pick("hindex", "hindexscore"),
|
|
231
|
+
"cites_per_doc_2y": pick("citesdoc2years", "citesdoc2year", "citesdoc2y", "citesperdoc2years", "citesperdoc2year"),
|
|
232
|
+
"total_docs_3y": pick("totaldocs3years", "totaldocs3year"),
|
|
233
|
+
"total_cites_3y": pick("totalcites3years", "totalcites3year"),
|
|
234
|
+
"country": pick("country", "sourcecountry"),
|
|
235
|
+
"type": pick("type", "sourcetype"),
|
|
236
|
+
"coverage": pick("coverage"),
|
|
237
|
+
}
|
|
238
|
+
return columns
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_scimago(path: Path) -> tuple[dict[str, list[dict[str, Any]]], dict[str, Any]]:
|
|
242
|
+
if not path.exists():
|
|
243
|
+
return {}, {"available": False, "message": f"missing: {path}"}
|
|
244
|
+
|
|
245
|
+
sample = path.read_text(encoding="utf-8", errors="ignore")[:4096]
|
|
246
|
+
try:
|
|
247
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=";,\t,")
|
|
248
|
+
delimiter = dialect.delimiter
|
|
249
|
+
except csv.Error:
|
|
250
|
+
delimiter = ";"
|
|
251
|
+
|
|
252
|
+
with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle:
|
|
253
|
+
reader = csv.DictReader(handle, delimiter=delimiter)
|
|
254
|
+
fieldnames = reader.fieldnames or []
|
|
255
|
+
columns = detect_scimago_columns(fieldnames)
|
|
256
|
+
|
|
257
|
+
if not columns["issn"] or not columns["year"] or not columns["sjr"]:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"Scimago CSV missing required columns. Required: ISSN, Year, SJR. "
|
|
260
|
+
f"Detected headers: {fieldnames}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
index: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
264
|
+
for row in reader:
|
|
265
|
+
issns = normalize_issn_list(row.get(columns["issn"], ""))
|
|
266
|
+
if not issns:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
year = parse_int_text(row.get(columns["year"]))
|
|
270
|
+
sjr = parse_float_text(row.get(columns["sjr"]))
|
|
271
|
+
quartile = (row.get(columns["quartile"]) or "").strip().upper() if columns["quartile"] else ""
|
|
272
|
+
categories = (row.get(columns["categories"]) or "").strip() if columns["categories"] else ""
|
|
273
|
+
title = (row.get(columns["title"]) or "").strip() if columns["title"] else ""
|
|
274
|
+
h_index = parse_int_text(row.get(columns["h_index"])) if columns["h_index"] else None
|
|
275
|
+
cites_per_doc_2y = parse_float_text(row.get(columns["cites_per_doc_2y"])) if columns["cites_per_doc_2y"] else None
|
|
276
|
+
total_docs_3y = parse_int_text(row.get(columns["total_docs_3y"])) if columns["total_docs_3y"] else None
|
|
277
|
+
total_cites_3y = parse_int_text(row.get(columns["total_cites_3y"])) if columns["total_cites_3y"] else None
|
|
278
|
+
country = (row.get(columns["country"]) or "").strip() if columns["country"] else ""
|
|
279
|
+
source_type = (row.get(columns["type"]) or "").strip() if columns["type"] else ""
|
|
280
|
+
coverage = (row.get(columns["coverage"]) or "").strip() if columns["coverage"] else ""
|
|
281
|
+
|
|
282
|
+
payload = {
|
|
283
|
+
"title": title,
|
|
284
|
+
"year": year,
|
|
285
|
+
"sjr": sjr,
|
|
286
|
+
"quartile": quartile if quartile in {"Q1", "Q2", "Q3", "Q4"} else "",
|
|
287
|
+
"categories": categories,
|
|
288
|
+
"h_index": h_index,
|
|
289
|
+
"cites_per_doc_2y": cites_per_doc_2y,
|
|
290
|
+
"total_docs_3y": total_docs_3y,
|
|
291
|
+
"total_cites_3y": total_cites_3y,
|
|
292
|
+
"country": country,
|
|
293
|
+
"type": source_type,
|
|
294
|
+
"coverage": coverage,
|
|
295
|
+
}
|
|
296
|
+
for issn in issns:
|
|
297
|
+
index[issn].append(payload)
|
|
298
|
+
|
|
299
|
+
return index, {"available": True, "path": str(path), "records": sum(len(v) for v in index.values())}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def validate_scimago_file(path: Path) -> None:
|
|
303
|
+
try:
|
|
304
|
+
_, meta = load_scimago(path)
|
|
305
|
+
if not meta.get("available"):
|
|
306
|
+
raise ValueError(meta.get("message", "Scimago file unavailable"))
|
|
307
|
+
print(f"Scimago CSV validated: {path}")
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
print(f"Scimago CSV validation failed: {exc}", file=sys.stderr)
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class MetricsClient:
|
|
314
|
+
def __init__(self, offline: bool, email: str = "") -> None:
|
|
315
|
+
self.offline = offline
|
|
316
|
+
ua = "unaltraweb-biblio-metrics/1.0"
|
|
317
|
+
if email:
|
|
318
|
+
ua += f" (mailto:{email})"
|
|
319
|
+
self.session = requests.Session()
|
|
320
|
+
self.session.headers.update({"User-Agent": ua})
|
|
321
|
+
|
|
322
|
+
self.oa_work_cache: dict[str, dict[str, Any] | None] = {}
|
|
323
|
+
self.oa_source_cache: dict[str, dict[str, Any] | None] = {}
|
|
324
|
+
self.crossref_cache: dict[str, dict[str, Any] | None] = {}
|
|
325
|
+
self.api_errors: list[dict[str, str]] = []
|
|
326
|
+
self.last_error_by_service: dict[str, bool] = {}
|
|
327
|
+
|
|
328
|
+
def _record_api_error(self, service: str, url: str, exc: Exception) -> None:
|
|
329
|
+
message = f"{type(exc).__name__}: {exc}"
|
|
330
|
+
self.api_errors.append({"service": service, "url": url, "error": message})
|
|
331
|
+
print(f"{service} request failed: {url} ({message})", file=sys.stderr)
|
|
332
|
+
|
|
333
|
+
def last_service_error(self, service: str) -> bool:
|
|
334
|
+
return self.last_error_by_service.get(service, False)
|
|
335
|
+
|
|
336
|
+
def api_error_summary(self) -> dict[str, int]:
|
|
337
|
+
summary: dict[str, int] = {}
|
|
338
|
+
for item in self.api_errors:
|
|
339
|
+
service = item.get("service", "unknown")
|
|
340
|
+
summary[service] = summary.get(service, 0) + 1
|
|
341
|
+
return summary
|
|
342
|
+
|
|
343
|
+
def _get_json(self, url: str, service: str) -> dict[str, Any] | None:
|
|
344
|
+
if self.offline:
|
|
345
|
+
return None
|
|
346
|
+
self.last_error_by_service[service] = False
|
|
347
|
+
try:
|
|
348
|
+
response = self.session.get(url, timeout=30)
|
|
349
|
+
if response.status_code == 404:
|
|
350
|
+
return None
|
|
351
|
+
response.raise_for_status()
|
|
352
|
+
return response.json()
|
|
353
|
+
except (requests.RequestException, ValueError) as exc:
|
|
354
|
+
self.last_error_by_service[service] = True
|
|
355
|
+
self._record_api_error(service, url, exc)
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
def get_openalex_work(self, doi: str | None = None, openalex_id: str | None = None) -> dict[str, Any] | None:
|
|
359
|
+
if openalex_id:
|
|
360
|
+
key = f"id:{openalex_id}"
|
|
361
|
+
if key not in self.oa_work_cache:
|
|
362
|
+
api_id = openalex_id.split("/")[-1] if openalex_id.startswith("http") else openalex_id
|
|
363
|
+
self.oa_work_cache[key] = self._get_json(f"https://api.openalex.org/works/{api_id}", "openalex")
|
|
364
|
+
return self.oa_work_cache[key]
|
|
365
|
+
|
|
366
|
+
if doi:
|
|
367
|
+
key = f"doi:{doi}"
|
|
368
|
+
if key not in self.oa_work_cache:
|
|
369
|
+
self.oa_work_cache[key] = self._get_json(f"https://api.openalex.org/works/https://doi.org/{doi}", "openalex")
|
|
370
|
+
return self.oa_work_cache[key]
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
def get_openalex_source(self, source_id: str | None) -> dict[str, Any] | None:
|
|
374
|
+
if not source_id:
|
|
375
|
+
return None
|
|
376
|
+
sid = source_id.split("/")[-1]
|
|
377
|
+
if sid not in self.oa_source_cache:
|
|
378
|
+
self.oa_source_cache[sid] = self._get_json(f"https://api.openalex.org/sources/{sid}", "openalex")
|
|
379
|
+
return self.oa_source_cache[sid]
|
|
380
|
+
|
|
381
|
+
def get_crossref_work(self, doi: str | None) -> dict[str, Any] | None:
|
|
382
|
+
if not doi:
|
|
383
|
+
return None
|
|
384
|
+
if doi not in self.crossref_cache:
|
|
385
|
+
payload = self._get_json(f"https://api.crossref.org/works/{doi}", "crossref")
|
|
386
|
+
self.crossref_cache[doi] = payload.get("message") if payload and "message" in payload else payload
|
|
387
|
+
return self.crossref_cache[doi]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def choose_scimago_record(candidates: list[dict[str, Any]], publication_year: int | None, max_year_gap: int) -> tuple[dict[str, Any] | None, str]:
|
|
391
|
+
if not candidates:
|
|
392
|
+
return None, "no-issn-match"
|
|
393
|
+
|
|
394
|
+
if publication_year is None:
|
|
395
|
+
sorted_by_year = sorted(
|
|
396
|
+
[c for c in candidates if c.get("year") is not None], key=lambda item: item["year"], reverse=True
|
|
397
|
+
)
|
|
398
|
+
return (sorted_by_year[0], "matched") if sorted_by_year else (candidates[0], "matched")
|
|
399
|
+
|
|
400
|
+
ranked: list[tuple[Any, dict[str, Any]]] = []
|
|
401
|
+
for candidate in candidates:
|
|
402
|
+
year = candidate.get("year")
|
|
403
|
+
if year is None:
|
|
404
|
+
continue
|
|
405
|
+
gap = abs(publication_year - year)
|
|
406
|
+
bias = 0 if year <= publication_year else 1
|
|
407
|
+
tie = -year if year <= publication_year else year
|
|
408
|
+
ranked.append(((gap, bias, tie), candidate))
|
|
409
|
+
|
|
410
|
+
if not ranked:
|
|
411
|
+
return None, "no-year-in-scimago"
|
|
412
|
+
|
|
413
|
+
ranked.sort(key=lambda item: item[0])
|
|
414
|
+
best = ranked[0][1]
|
|
415
|
+
best_gap = abs(publication_year - best["year"])
|
|
416
|
+
|
|
417
|
+
if best_gap > max_year_gap:
|
|
418
|
+
return None, "stale-rejected"
|
|
419
|
+
return best, "matched"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def parse_counts_by_year(work: dict[str, Any]) -> tuple[int | None, int | None]:
|
|
423
|
+
counts = work.get("counts_by_year") or []
|
|
424
|
+
now = datetime.now(timezone.utc).year
|
|
425
|
+
total_2y = 0
|
|
426
|
+
total_5y = 0
|
|
427
|
+
found_any = False
|
|
428
|
+
|
|
429
|
+
for row in counts:
|
|
430
|
+
year = as_int(row.get("year"))
|
|
431
|
+
cited = as_int(row.get("cited_by_count")) or 0
|
|
432
|
+
if year is None:
|
|
433
|
+
continue
|
|
434
|
+
found_any = True
|
|
435
|
+
if year >= now - 1:
|
|
436
|
+
total_2y += cited
|
|
437
|
+
if year >= now - 4:
|
|
438
|
+
total_5y += cited
|
|
439
|
+
|
|
440
|
+
if not found_any:
|
|
441
|
+
return None, None
|
|
442
|
+
return total_2y, total_5y
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def set_field(entry: dict[str, Any], field: str, value: Any) -> None:
|
|
446
|
+
if value is None:
|
|
447
|
+
return
|
|
448
|
+
if isinstance(value, bool):
|
|
449
|
+
entry[field] = "true" if value else "false"
|
|
450
|
+
elif isinstance(value, float):
|
|
451
|
+
entry[field] = fmt_float(value, digits=4)
|
|
452
|
+
else:
|
|
453
|
+
entry[field] = str(value)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def build_metrics_note(entry: dict[str, Any], original_note: str) -> str | None:
|
|
457
|
+
blocks: list[str] = []
|
|
458
|
+
|
|
459
|
+
oa = as_int(entry.get("x_openalex_cited_by"))
|
|
460
|
+
cr = as_int(entry.get("x_crossref_cited_by"))
|
|
461
|
+
gs = as_int(entry.get("x_gs_cited_by"))
|
|
462
|
+
cites = []
|
|
463
|
+
if oa is not None and oa > 0:
|
|
464
|
+
cites.append(f"OA: {oa}")
|
|
465
|
+
if cr is not None and cr > 0:
|
|
466
|
+
cites.append(f"CR: {cr}")
|
|
467
|
+
if gs is not None and gs > 0:
|
|
468
|
+
cites.append(f"GS: {gs}")
|
|
469
|
+
if cites:
|
|
470
|
+
blocks.append("Cites: " + " / ".join(cites))
|
|
471
|
+
|
|
472
|
+
fwci = as_float(entry.get("x_openalex_fwci"))
|
|
473
|
+
if fwci is not None:
|
|
474
|
+
blocks.append(f"FWCI: {fmt_float(fwci, 2)}")
|
|
475
|
+
|
|
476
|
+
cnp = as_float(entry.get("x_openalex_citation_normalized_percentile"))
|
|
477
|
+
if cnp is not None:
|
|
478
|
+
blocks.append(f"CNP: {fmt_float(cnp, 2)}")
|
|
479
|
+
|
|
480
|
+
core = as_bool(entry.get("x_openalex_source_is_core"))
|
|
481
|
+
if core is not None:
|
|
482
|
+
blocks.append(f"Core {'yes' if core else 'no'}")
|
|
483
|
+
|
|
484
|
+
doaj = as_bool(entry.get("x_openalex_source_is_in_doaj"))
|
|
485
|
+
if doaj is not None:
|
|
486
|
+
blocks.append(f"DOAJ {'yes' if doaj else 'no'}")
|
|
487
|
+
|
|
488
|
+
q = str(entry.get("x_scimago_quartile", "")).strip().upper()
|
|
489
|
+
sjr = as_float(entry.get("x_scimago_sjr"))
|
|
490
|
+
sc_year = as_int(entry.get("x_scimago_year"))
|
|
491
|
+
categories = compact_categories(entry.get("x_scimago_categories"))
|
|
492
|
+
if q or sjr is not None or sc_year is not None or categories:
|
|
493
|
+
sc_parts = []
|
|
494
|
+
if q:
|
|
495
|
+
sc_parts.append(q)
|
|
496
|
+
if sjr is not None:
|
|
497
|
+
sc_parts.append(f"SJR: {fmt_float(sjr, 3)}")
|
|
498
|
+
if sc_year is not None:
|
|
499
|
+
sc_parts.append(str(sc_year))
|
|
500
|
+
if categories:
|
|
501
|
+
sc_parts.append(categories)
|
|
502
|
+
blocks.append("Scimago: " + ", ".join(sc_parts))
|
|
503
|
+
|
|
504
|
+
if not blocks and not original_note:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
note = "Metrics: " + " | ".join(blocks) if blocks else ""
|
|
508
|
+
if original_note and not original_note.startswith("Metrics:"):
|
|
509
|
+
note = f"{note} | {original_note}" if note else original_note
|
|
510
|
+
return note
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def main() -> None:
|
|
514
|
+
args = parse_args()
|
|
515
|
+
|
|
516
|
+
if args.validate_scimago:
|
|
517
|
+
validate_scimago_file(Path(args.validate_scimago))
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
bib_files = find_bib_files(args.bib)
|
|
521
|
+
if not bib_files:
|
|
522
|
+
print("No BibTeX files found.", file=sys.stderr)
|
|
523
|
+
sys.exit(1)
|
|
524
|
+
|
|
525
|
+
overrides_data = load_yaml(Path(args.overrides))
|
|
526
|
+
overrides_entries = overrides_data.get("entries", overrides_data)
|
|
527
|
+
overrides_defaults = overrides_data.get("defaults", {}) if isinstance(overrides_data, dict) else {}
|
|
528
|
+
|
|
529
|
+
max_year_gap = int(overrides_defaults.get("max_year_gap", args.max_year_gap))
|
|
530
|
+
contact_email = str(overrides_defaults.get("mailto", "")).strip()
|
|
531
|
+
|
|
532
|
+
citations_data = load_yaml(Path(args.citations_data))
|
|
533
|
+
scholar_map = citations_data.get("papers", {}) if isinstance(citations_data, dict) else {}
|
|
534
|
+
|
|
535
|
+
scimago_index, scimago_meta = load_scimago(Path(args.scimago_csv))
|
|
536
|
+
if not scimago_meta.get("available"):
|
|
537
|
+
message = scimago_meta.get("message", f"missing: {args.scimago_csv}")
|
|
538
|
+
print(f"Scimago data unavailable: {message}", file=sys.stderr)
|
|
539
|
+
if args.require_scimago:
|
|
540
|
+
print("Use fetch_scimago/metrics-scimago-fetch or pass --scimago-csv/--input with a local file.", file=sys.stderr)
|
|
541
|
+
sys.exit(1)
|
|
542
|
+
|
|
543
|
+
client = MetricsClient(offline=args.offline, email=contact_email)
|
|
544
|
+
|
|
545
|
+
updated_on = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
546
|
+
stale_rejected_count = 0
|
|
547
|
+
report_entries: list[dict[str, Any]] = []
|
|
548
|
+
unmatched_rows: list[dict[str, Any]] = []
|
|
549
|
+
|
|
550
|
+
for bib_path in bib_files:
|
|
551
|
+
if not bib_path.exists():
|
|
552
|
+
print(f"Skipping missing BibTeX file: {bib_path}", file=sys.stderr)
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
prefix, db = parse_bib_database(bib_path)
|
|
556
|
+
|
|
557
|
+
for entry in db.entries:
|
|
558
|
+
key = entry.get("ID", "")
|
|
559
|
+
entry_type = entry.get("ENTRYTYPE", "")
|
|
560
|
+
override = overrides_entries.get(key, {}) if isinstance(overrides_entries, dict) else {}
|
|
561
|
+
|
|
562
|
+
doi = normalize_doi(entry.get("doi"))
|
|
563
|
+
year = as_int(entry.get("year"))
|
|
564
|
+
original_note = str(entry.get("note", "")).strip()
|
|
565
|
+
|
|
566
|
+
status = {
|
|
567
|
+
"openalex": "skipped",
|
|
568
|
+
"crossref": "skipped",
|
|
569
|
+
"scimago": "skipped",
|
|
570
|
+
"google_scholar": "skipped",
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
# Google Scholar compatibility bridge
|
|
574
|
+
gs_id = override.get("x_gs_id") or override.get("gs_id") or entry.get("x_gs_id") or entry.get("google_scholar_id")
|
|
575
|
+
gs_cited = (
|
|
576
|
+
override.get("x_gs_cited_by")
|
|
577
|
+
or override.get("gs_cited_by")
|
|
578
|
+
or entry.get("x_gs_cited_by")
|
|
579
|
+
or entry.get("google_scholar_citations")
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if gs_id:
|
|
583
|
+
set_field(entry, "x_gs_id", gs_id)
|
|
584
|
+
status["google_scholar"] = "matched"
|
|
585
|
+
if gs_cited is None and gs_id:
|
|
586
|
+
scholar_key = str(gs_id).replace("cites:", "")
|
|
587
|
+
scholar_hit = None
|
|
588
|
+
for k, v in scholar_map.items():
|
|
589
|
+
if scholar_key and scholar_key in str(k):
|
|
590
|
+
scholar_hit = v
|
|
591
|
+
break
|
|
592
|
+
if isinstance(scholar_hit, dict):
|
|
593
|
+
gs_cited = scholar_hit.get("citations")
|
|
594
|
+
if gs_cited is not None:
|
|
595
|
+
set_field(entry, "x_gs_cited_by", as_int(gs_cited))
|
|
596
|
+
status["google_scholar"] = "matched"
|
|
597
|
+
|
|
598
|
+
# OpenAlex
|
|
599
|
+
oa_override_id = override.get("x_openalex_id") or override.get("openalex_id")
|
|
600
|
+
work = client.get_openalex_work(doi=doi, openalex_id=oa_override_id)
|
|
601
|
+
if work:
|
|
602
|
+
status["openalex"] = "matched"
|
|
603
|
+
set_field(entry, "x_openalex_id", work.get("id"))
|
|
604
|
+
set_field(entry, "x_openalex_cited_by", as_int(work.get("cited_by_count")))
|
|
605
|
+
set_field(entry, "x_openalex_fwci", as_float(work.get("fwci")))
|
|
606
|
+
|
|
607
|
+
cnp_raw = (work.get("citation_normalized_percentile") or {}).get("value")
|
|
608
|
+
cnp = as_float(cnp_raw)
|
|
609
|
+
if cnp is not None:
|
|
610
|
+
set_field(entry, "x_openalex_citation_normalized_percentile", cnp * 100)
|
|
611
|
+
|
|
612
|
+
primary_topic = work.get("primary_topic") or {}
|
|
613
|
+
if isinstance(primary_topic, dict):
|
|
614
|
+
subfield = primary_topic.get("subfield")
|
|
615
|
+
field = primary_topic.get("field")
|
|
616
|
+
domain = primary_topic.get("domain")
|
|
617
|
+
subfield_name = subfield.get("display_name") if isinstance(subfield, dict) else ""
|
|
618
|
+
field_name = field.get("display_name") if isinstance(field, dict) else ""
|
|
619
|
+
domain_name = domain.get("display_name") if isinstance(domain, dict) else ""
|
|
620
|
+
set_field(entry, "x_openalex_topic", str(primary_topic.get("display_name") or "").strip())
|
|
621
|
+
set_field(entry, "x_openalex_subfield", str(subfield_name or "").strip())
|
|
622
|
+
set_field(entry, "x_openalex_field", str(field_name or "").strip())
|
|
623
|
+
set_field(entry, "x_openalex_domain", str(domain_name or "").strip())
|
|
624
|
+
else:
|
|
625
|
+
set_field(entry, "x_openalex_topic", "")
|
|
626
|
+
set_field(entry, "x_openalex_subfield", "")
|
|
627
|
+
set_field(entry, "x_openalex_field", "")
|
|
628
|
+
set_field(entry, "x_openalex_domain", "")
|
|
629
|
+
|
|
630
|
+
funders: list[str] = []
|
|
631
|
+
seen_funders: set[str] = set()
|
|
632
|
+
for grant in (work.get("grants") or []):
|
|
633
|
+
if not isinstance(grant, dict):
|
|
634
|
+
continue
|
|
635
|
+
funder_name = str(grant.get("funder_display_name") or "").strip()
|
|
636
|
+
if not funder_name:
|
|
637
|
+
funder = grant.get("funder")
|
|
638
|
+
if isinstance(funder, dict):
|
|
639
|
+
funder_name = str(funder.get("display_name") or "").strip()
|
|
640
|
+
if not funder_name:
|
|
641
|
+
continue
|
|
642
|
+
canonical = funder_name.lower()
|
|
643
|
+
if canonical in seen_funders:
|
|
644
|
+
continue
|
|
645
|
+
seen_funders.add(canonical)
|
|
646
|
+
funders.append(funder_name)
|
|
647
|
+
set_field(entry, "x_openalex_funders", "; ".join(funders))
|
|
648
|
+
|
|
649
|
+
cited_2y, cited_5y = parse_counts_by_year(work)
|
|
650
|
+
set_field(entry, "x_openalex_cited_by_2y", cited_2y)
|
|
651
|
+
set_field(entry, "x_openalex_cited_by_5y", cited_5y)
|
|
652
|
+
|
|
653
|
+
source = (work.get("primary_location") or {}).get("source") or {}
|
|
654
|
+
source_id = source.get("id")
|
|
655
|
+
if source_id:
|
|
656
|
+
set_field(entry, "x_openalex_source_id", source_id)
|
|
657
|
+
source_full = client.get_openalex_source(source_id)
|
|
658
|
+
source_payload = source_full or source
|
|
659
|
+
set_field(entry, "x_openalex_source_display_name", str(source_payload.get("display_name") or "").strip())
|
|
660
|
+
set_field(entry, "x_openalex_source_works_count", as_int(source_payload.get("works_count")))
|
|
661
|
+
set_field(entry, "x_openalex_source_cited_by_count", as_int(source_payload.get("cited_by_count")))
|
|
662
|
+
set_field(entry, "x_openalex_source_is_oa", as_bool(source_payload.get("is_oa")))
|
|
663
|
+
set_field(entry, "x_openalex_source_is_core", as_bool(source_payload.get("is_core")))
|
|
664
|
+
set_field(entry, "x_openalex_source_is_in_doaj", as_bool(source_payload.get("is_in_doaj")))
|
|
665
|
+
summary_stats = source_payload.get("summary_stats") or {}
|
|
666
|
+
set_field(entry, "x_openalex_source_h_index", as_int(summary_stats.get("h_index")))
|
|
667
|
+
set_field(entry, "x_openalex_source_i10_index", as_int(summary_stats.get("i10_index")))
|
|
668
|
+
set_field(entry, "x_openalex_source_2yr_mean_citedness", as_float(summary_stats.get("2yr_mean_citedness")))
|
|
669
|
+
else:
|
|
670
|
+
if doi or oa_override_id:
|
|
671
|
+
status["openalex"] = "api-error" if client.last_service_error("openalex") else "not-found"
|
|
672
|
+
else:
|
|
673
|
+
status["openalex"] = "no-doi"
|
|
674
|
+
|
|
675
|
+
# Crossref
|
|
676
|
+
cr_override = override.get("x_crossref_cited_by") or override.get("crossref_cited_by")
|
|
677
|
+
if cr_override is not None:
|
|
678
|
+
set_field(entry, "x_crossref_cited_by", as_int(cr_override))
|
|
679
|
+
status["crossref"] = "matched"
|
|
680
|
+
else:
|
|
681
|
+
crossref_work = client.get_crossref_work(doi)
|
|
682
|
+
if crossref_work:
|
|
683
|
+
set_field(entry, "x_crossref_cited_by", as_int(crossref_work.get("is-referenced-by-count")))
|
|
684
|
+
status["crossref"] = "matched"
|
|
685
|
+
else:
|
|
686
|
+
if doi:
|
|
687
|
+
status["crossref"] = "api-error" if client.last_service_error("crossref") else "not-found"
|
|
688
|
+
else:
|
|
689
|
+
status["crossref"] = "no-doi"
|
|
690
|
+
|
|
691
|
+
# Scimago (articles only)
|
|
692
|
+
if str(entry_type).lower() == "article":
|
|
693
|
+
issns = []
|
|
694
|
+
for field_name in ("issn", "eissn"):
|
|
695
|
+
issns.extend(normalize_issn_list(entry.get(field_name)))
|
|
696
|
+
unique_issns = sorted(set(issns))
|
|
697
|
+
|
|
698
|
+
candidates: list[dict[str, Any]] = []
|
|
699
|
+
for issn in unique_issns:
|
|
700
|
+
candidates.extend(scimago_index.get(issn, []))
|
|
701
|
+
|
|
702
|
+
chosen, sc_status = choose_scimago_record(candidates, year, max_year_gap)
|
|
703
|
+
status["scimago"] = sc_status if scimago_meta.get("available") else "scimago-missing"
|
|
704
|
+
|
|
705
|
+
if sc_status == "stale-rejected":
|
|
706
|
+
stale_rejected_count += 1
|
|
707
|
+
|
|
708
|
+
if chosen:
|
|
709
|
+
set_field(entry, "x_scimago_sjr", chosen.get("sjr"))
|
|
710
|
+
set_field(entry, "x_scimago_quartile", chosen.get("quartile"))
|
|
711
|
+
set_field(entry, "x_scimago_year", chosen.get("year"))
|
|
712
|
+
set_field(entry, "x_scimago_categories", chosen.get("categories"))
|
|
713
|
+
set_field(entry, "x_scimago_h_index", chosen.get("h_index"))
|
|
714
|
+
set_field(entry, "x_scimago_cites_per_doc_2y", chosen.get("cites_per_doc_2y"))
|
|
715
|
+
set_field(entry, "x_scimago_total_docs_3y", chosen.get("total_docs_3y"))
|
|
716
|
+
set_field(entry, "x_scimago_total_cites_3y", chosen.get("total_cites_3y"))
|
|
717
|
+
set_field(entry, "x_scimago_country", chosen.get("country"))
|
|
718
|
+
set_field(entry, "x_scimago_type", chosen.get("type"))
|
|
719
|
+
set_field(entry, "x_scimago_coverage", chosen.get("coverage"))
|
|
720
|
+
else:
|
|
721
|
+
status["scimago"] = "not-article"
|
|
722
|
+
|
|
723
|
+
set_field(entry, "x_metrics_updated", updated_on)
|
|
724
|
+
|
|
725
|
+
note = build_metrics_note(entry, original_note)
|
|
726
|
+
if note:
|
|
727
|
+
entry["note"] = note
|
|
728
|
+
|
|
729
|
+
snapshot = {
|
|
730
|
+
"key": key,
|
|
731
|
+
"type": entry_type,
|
|
732
|
+
"title": entry.get("title", ""),
|
|
733
|
+
"doi": doi,
|
|
734
|
+
"status": status,
|
|
735
|
+
}
|
|
736
|
+
for field in X_FIELDS:
|
|
737
|
+
if field in entry:
|
|
738
|
+
snapshot[field] = entry[field]
|
|
739
|
+
report_entries.append(snapshot)
|
|
740
|
+
|
|
741
|
+
if any(value not in {"matched", "not-article", "skipped"} for value in status.values()):
|
|
742
|
+
unmatched_rows.append(
|
|
743
|
+
{
|
|
744
|
+
"key": key,
|
|
745
|
+
"type": entry_type,
|
|
746
|
+
"title": entry.get("title", ""),
|
|
747
|
+
"doi": doi or "",
|
|
748
|
+
"openalex_status": status["openalex"],
|
|
749
|
+
"crossref_status": status["crossref"],
|
|
750
|
+
"scimago_status": status["scimago"],
|
|
751
|
+
"google_scholar_status": status["google_scholar"],
|
|
752
|
+
}
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if not args.dry_run:
|
|
756
|
+
write_bib_database(bib_path, prefix, db)
|
|
757
|
+
|
|
758
|
+
summary = compute_summary(report_entries, SummaryContext(max_year_gap=max_year_gap, stale_rejected=stale_rejected_count))
|
|
759
|
+
|
|
760
|
+
metrics_payload = {
|
|
761
|
+
"metrics": {
|
|
762
|
+
"updated_on": updated_on,
|
|
763
|
+
"sources": {
|
|
764
|
+
"openalex": "api" if not args.offline else "offline",
|
|
765
|
+
"crossref": "api" if not args.offline else "offline",
|
|
766
|
+
"google_scholar": "bibtex + _data/citations.yml",
|
|
767
|
+
"scimago": str(scimago_meta.get("path", args.scimago_csv)) if scimago_meta.get("available") else "missing",
|
|
768
|
+
},
|
|
769
|
+
"api_errors": client.api_error_summary(),
|
|
770
|
+
"summary": summary,
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
metrics_out = Path(args.metrics_out)
|
|
775
|
+
metrics_out.parent.mkdir(parents=True, exist_ok=True)
|
|
776
|
+
metrics_out.write_text(yaml.safe_dump(metrics_payload, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
777
|
+
|
|
778
|
+
report_out = Path(args.report_json)
|
|
779
|
+
report_out.parent.mkdir(parents=True, exist_ok=True)
|
|
780
|
+
report_out.write_text(
|
|
781
|
+
json.dumps(
|
|
782
|
+
{
|
|
783
|
+
"generated_on": updated_on,
|
|
784
|
+
"scimago": scimago_meta,
|
|
785
|
+
"api_errors": client.api_errors,
|
|
786
|
+
"api_error_summary": client.api_error_summary(),
|
|
787
|
+
"max_year_gap": max_year_gap,
|
|
788
|
+
"entries": report_entries,
|
|
789
|
+
"summary": summary,
|
|
790
|
+
},
|
|
791
|
+
indent=2,
|
|
792
|
+
ensure_ascii=False,
|
|
793
|
+
),
|
|
794
|
+
encoding="utf-8",
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
unmatched_out = Path(args.unmatched_tsv)
|
|
798
|
+
unmatched_out.parent.mkdir(parents=True, exist_ok=True)
|
|
799
|
+
with unmatched_out.open("w", encoding="utf-8", newline="") as handle:
|
|
800
|
+
fieldnames = [
|
|
801
|
+
"key",
|
|
802
|
+
"type",
|
|
803
|
+
"title",
|
|
804
|
+
"doi",
|
|
805
|
+
"openalex_status",
|
|
806
|
+
"crossref_status",
|
|
807
|
+
"scimago_status",
|
|
808
|
+
"google_scholar_status",
|
|
809
|
+
]
|
|
810
|
+
writer = csv.DictWriter(handle, fieldnames=fieldnames, delimiter="\t")
|
|
811
|
+
writer.writeheader()
|
|
812
|
+
writer.writerows(unmatched_rows)
|
|
813
|
+
|
|
814
|
+
print(f"Metrics updated on {updated_on}")
|
|
815
|
+
print(f"- Metrics summary: {metrics_out}")
|
|
816
|
+
print(f"- Diagnostics JSON: {report_out}")
|
|
817
|
+
print(f"- Unmatched TSV: {unmatched_out}")
|
|
818
|
+
if client.api_errors:
|
|
819
|
+
print(f"External API request errors: {client.api_error_summary()}", file=sys.stderr)
|
|
820
|
+
if args.strict_external:
|
|
821
|
+
print("Failing because --strict-external is enabled.", file=sys.stderr)
|
|
822
|
+
sys.exit(2)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
if __name__ == "__main__":
|
|
826
|
+
main()
|