diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index bfd876763b0..b4b6b015c51 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -3,7 +3,21 @@
"allow": [
"Bash(wc:*)",
"Bash(git:*)",
- "Bash(gh pr:*)"
+ "Bash(gh pr:*)",
+ "Bash(python3 -c \"import yaml; print\\(yaml.__version__\\)\")",
+ "Bash(pip3 show *)",
+ "Bash(pip3 install *)",
+ "Bash(python3 -c \"from ruamel.yaml import YAML; print\\('ok'\\)\")",
+ "Bash(mkdir -p /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest/data /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest/scripts)",
+ "Bash(cp /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/publications_test.yaml /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest/data/publications.yaml)",
+ "Bash(cp /Users/rdm/oss/forrtproject.github.io/scripts/build_publications.py /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest/scripts/)",
+ "Bash(python3 scripts/build_publications.py)",
+ "Bash(mkdir -p /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest2/data /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest2/scripts)",
+ "Bash(cp /Users/rdm/oss/forrtproject.github.io/scripts/build_publications.py /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest2/scripts/)",
+ "Bash(cat)",
+ "Bash(mkdir -p /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest3/data /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest3/scripts)",
+ "Bash(cp /Users/rdm/oss/forrtproject.github.io/scripts/build_publications.py /private/tmp/claude-501/-Users-rdm-oss-forrtproject-github-io/d40a3858-03b0-4abe-8e07-eb52ecb3b04b/scratchpad/pubtest3/scripts/)",
+ "Bash(python3 scripts/build_publications.py --dry-run)"
]
}
}
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 04c704ec6a4..01837eb766b 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -72,6 +72,21 @@ Processes:
Triggers `deploy.yaml` after successful processing via `repository_dispatch`.
+### [update_publications.yml](workflows/update_publications.yml)
+**Hydrates DOI-stub entries in `data/publications.yaml`**
+
+| Trigger | Schedule |
+|---------|----------|
+| Push to `main` touching `data/publications.yaml` | On push |
+| Weekly | Monday 03:30 UTC |
+| Manual dispatch | Manual (`force` option to bypass the DOI cache) |
+
+Runs `scripts/build_publications.py`, which fetches title/authors/journal/year/
+abstract/citation for any entry with a top-level `doi:` key from Crossref/DataCite
+(via doi.org content negotiation). Never commits to `main` directly â opens a PR
+for review when metadata changes, same pattern as the glossary-update PR in
+`data-processing.yml`. See `content/publications/README` for the entry format.
+
## Quality Checks
### [spell-check.yaml](workflows/spell-check.yaml)
diff --git a/.github/workflows/update_publications.yml b/.github/workflows/update_publications.yml
new file mode 100644
index 00000000000..04876c52e4e
--- /dev/null
+++ b/.github/workflows/update_publications.yml
@@ -0,0 +1,90 @@
+name: Update publications
+
+# =====================================================================
+# Hydrates DOI-stub entries in data/publications.yaml (see
+# scripts/build_publications.py) by fetching title/authors/journal/year/
+# abstract/citation from the DOI's registration agency (Crossref/DataCite).
+#
+# Never commits to main directly: if hydration produces a diff, it's pushed
+# to a new branch and opened as a PR for human review before it goes live.
+#
+# Triggers:
+# - push to main touching data/publications.yaml (hydrate a newly added
+# DOI stub right away)
+# - weekly schedule (catch metadata drift: preprint -> published,
+# volume/page assignment, etc.)
+# - workflow_dispatch (manual run, optionally ignoring the DOI cache)
+# =====================================================================
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'data/publications.yaml'
+ schedule:
+ - cron: '30 3 * * 1' # weekly, Monday 03:30 UTC
+ workflow_dispatch:
+ inputs:
+ force:
+ description: 'Ignore the DOI lookup cache and refetch everything'
+ required: false
+ type: boolean
+ default: false
+
+concurrency:
+ group: update-publications
+ cancel-in-progress: false
+
+jobs:
+ hydrate:
+ name: Hydrate publication DOIs
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: pip install ruamel.yaml
+
+ - name: Restore DOI metadata cache
+ uses: actions/cache@v4
+ with:
+ path: scripts/publications_doi_cache.json
+ key: publications-doi-cache-${{ github.run_id }}
+ restore-keys: |
+ publications-doi-cache-
+
+ - name: Run hydration script
+ run: |
+ python3 scripts/build_publications.py ${{ inputs.force && '--force' || '' }}
+
+ - name: Create PR with hydrated metadata
+ env:
+ GITHUB_TOKEN: ${{ secrets.FORRT_PAT }}
+ run: |
+ if [ -z "$(git status --porcelain -- data/publications.yaml)" ]; then
+ echo "âšī¸ No publication metadata changes detected"
+ exit 0
+ fi
+
+ BRANCH_NAME="publications-update-$(date +%Y%m%d-%H%M%S)"
+ git checkout -b "$BRANCH_NAME"
+
+ git add data/publications.yaml
+ git commit -m "Update hydrated publication metadata - $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
+
+ git push -u origin "$BRANCH_NAME"
+
+ gh pr create \
+ --title "đ Publications metadata update - $(date '+%Y-%m-%d')" \
+ --body "Automated DOI metadata hydration generated on $(date -u +'%Y-%m-%d %H:%M:%S UTC') by scripts/build_publications.py. Please review the hydrated title/authors/abstract/citation fields before merging." \
+ --base main \
+ --head "$BRANCH_NAME"
+ echo "â
PR created for publications update"
diff --git a/.gitignore b/.gitignore
index 92078259524..61ce9f9f455 100644
--- a/.gitignore
+++ b/.gitignore
@@ -277,3 +277,6 @@ bibtex_to_apa/node_modules/
# DOI lookup cache for cluster parsing script
scripts/doi_cache.json
+
+# DOI metadata cache for publications hydration script
+scripts/publications_doi_cache.json
diff --git a/content/publications/README b/content/publications/README
index 6ef1564c72a..61c882dae82 100644
--- a/content/publications/README
+++ b/content/publications/README
@@ -2,9 +2,60 @@
This folder contains PDF files and other publication-related assets. The actual publication data is stored in `/data/publications.yaml` (NOT in this folder).
-## Adding a New Publication
+## Adding a New Publication (DOI, recommended)
-Publications are managed in the **`/data/publications.yaml`** file. This file contains all publication metadata that is displayed on the website.
+If the publication has a DOI, you only need to add a minimal stub â a script
+fetches the rest. Add an entry with a top-level `doi:` key:
+
+```yaml
+- doi: "10.1016/j.socec.2025.102502"
+ type: "journal" # journal, preprint, media, policy, affiliated, wip
+ focus_area: "Meta-Research & Scientific Reform"
+```
+
+Commit that stub to `data/publications.yaml` and push (or open a PR). The
+[update_publications.yml](/.github/workflows/update_publications.yml) workflow
+runs `scripts/build_publications.py`, which resolves the DOI via Crossref/DataCite
+and fills in `title`, `authors`, `journal_name`, `year`, `status`, `abstract`,
+`citation`, `links.doi`, and `altmetric_doi`. It opens a PR with the hydrated
+result for review â nothing is pushed straight to `main`.
+
+**On every run, all of the auto-filled fields above are recomputed from the DOI
+and will overwrite hand edits.** To pin a field permanently (a custom abstract
+blurb, an extra `postprint`/`pdf`/`project` link, a manually-chosen `status`),
+add it under `overrides:` instead of editing the auto field directly:
+
+```yaml
+- doi: "10.1016/j.socec.2025.102502"
+ type: "journal"
+ focus_area: "Meta-Research & Scientific Reform"
+ overrides:
+ abstract: "Custom short blurb instead of the paper's full abstract"
+ status: "In Press"
+ links:
+ postprint: "https://osf.io/preprints/..."
+ pdf: "./pdf/local-copy.pdf"
+```
+
+`type` and `focus_area` are never touched by the script â always set those by
+hand. If a DOI fails to resolve (bad DOI, API outage), the script leaves that
+entry's existing fields untouched and logs a warning rather than blanking them.
+
+Citations are built as
+`{authors} ({year}). {title}. {journal_name}, {volume}({issue}), {page}. https://doi.org/{doi}`
+â good for typical journal articles and preprints, but book chapters and other
+unusual venues often need a hand-written `overrides.citation`. Check the PR
+diff before merging.
+
+**Note:** Existing publications that predate this pipeline are still fully
+hand-written (no top-level `doi:` key) and are left alone by the script â
+migrating them to the DOI-stub format is a separate, deliberate task, not
+something that happens automatically.
+
+## Adding a New Publication (manual, no DOI)
+
+For publications without a DOI (op-eds, policy briefs, work-in-progress, etc.),
+write the full entry by hand as before.
### Steps to Add a Publication
diff --git a/content/publications/template.yaml b/content/publications/template.yaml
index 8601386e13a..692b08f8f89 100644
--- a/content/publications/template.yaml
+++ b/content/publications/template.yaml
@@ -1,6 +1,23 @@
# Below is a template for adding a new publication to the publications.yaml file. in data/publications.yaml
# Remove or add the fields as needed following the layout of the existing entries.
+# --- Preferred: DOI stub (auto-hydrated by scripts/build_publications.py) ---
+# Only `doi`, `type`, and `focus_area` are hand-written; the rest is fetched
+# from Crossref/DataCite and refreshed on every run. Pin a field permanently by
+# adding it under `overrides:` instead. See content/publications/README.
+
+- doi: "10.xxxx/xxxxx"
+ type: "journal" # journal, preprint, media, policy, affiliated, wip
+ focus_area: "Meta-Research & Scientific Reform"
+ # overrides:
+ # abstract: "Custom short blurb instead of the paper's full abstract"
+ # status: "In Press"
+ # links:
+ # postprint: "https://osf.io/preprints/..."
+ # pdf: "./pdf/local-copy.pdf"
+
+# --- Fallback: full manual entry (no DOI, e.g. op-eds, policy briefs, WIP) ---
+
- title: ""
type: ""
year: ""
diff --git a/requirements.txt b/requirements.txt
index 2a95cae1bae..cc086b057f5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,3 +8,4 @@ gspread
python-dotenv
matplotlib
pypinyin
+ruamel.yaml
diff --git a/scripts/build_publications.py b/scripts/build_publications.py
new file mode 100644
index 00000000000..2091ebe54dd
--- /dev/null
+++ b/scripts/build_publications.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+"""
+Hydrate DOI-stub entries in data/publications.yaml with metadata fetched from
+the DOI's registration agency (Crossref or DataCite, via doi.org content
+negotiation).
+
+An entry opts into hydration by having a top-level `doi:` key, e.g.:
+
+ - doi: "10.1016/j.socec.2025.102502"
+ type: "journal"
+ focus_area: "Meta-Research & Scientific Reform"
+
+On every run, the script (re)computes title, authors, journal_name, year,
+status, abstract, citation, links.doi, and altmetric_doi for every such entry.
+Any field can be permanently pinned by adding it under `overrides:` (nested
+dicts, e.g. overrides.links.pdf, are deep-merged). `type` and `focus_area` are
+never touched by this script.
+
+Entries without a top-level `doi:` key (today's media/policy/wip entries) are
+left completely untouched.
+
+If a DOI fails to resolve, the entry's existing fields are left as-is (so a
+transient API outage never blanks out already-good data) and a warning is
+printed.
+
+Usage:
+ python3 scripts/build_publications.py [--dry-run] [--force]
+"""
+
+import argparse
+import html
+import json
+import re
+import sys
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+from urllib.parse import quote
+from urllib.request import Request, urlopen
+
+from ruamel.yaml import YAML
+from ruamel.yaml.comments import CommentedMap
+
+DATA_FILE = Path("data/publications.yaml")
+CACHE_FILE = Path("scripts/publications_doi_cache.json")
+USER_AGENT = "FORRT-PublicationsBuilder/1.0 (mailto:info@forrt.org)"
+
+AUTO_FIELDS = ["title", "authors", "journal_name", "year", "status", "abstract", "citation"]
+
+STATUS_MAP = {
+ "journal-article": "Published",
+ "posted-content": "Preprint",
+ "preprint": "Preprint",
+ "proceedings-article": "Published",
+ "book-chapter": "Published",
+ "report": "Report",
+ "monograph": "Published",
+}
+
+TAG_RE = re.compile(r"<[^>]+>")
+ABSTRACT_PREFIX_RE = re.compile(r"^(abstract\.?\s*[:.]?\s*)", re.IGNORECASE)
+WHITESPACE_RE = re.compile(r"\s+")
+
+
+def strip_tags(text):
+ if not text:
+ return ""
+ text = TAG_RE.sub(" ", text)
+ text = html.unescape(text)
+ return WHITESPACE_RE.sub(" ", text).strip()
+
+
+def clean_abstract(text):
+ text = strip_tags(text)
+ if not text:
+ return ""
+ return ABSTRACT_PREFIX_RE.sub("", text).strip()
+
+
+def format_initials(given):
+ """'Flavio' -> 'F.', 'William R.' -> 'W. R.', 'Jean-Michel' -> 'J.-M.'"""
+ given = (given or "").strip()
+ if not given:
+ return ""
+ initials = []
+ for part in re.split(r"\s+", given):
+ sub_initials = [
+ f"{sub[0].upper()}." for sub in part.split("-") if sub.strip(".")
+ ]
+ if sub_initials:
+ initials.append("-".join(sub_initials))
+ return " ".join(initials)
+
+
+def format_authors(csl_authors):
+ names = []
+ for author in csl_authors or []:
+ family = (author.get("family") or "").strip()
+ if not family:
+ literal = (author.get("literal") or "").strip()
+ if literal:
+ names.append(literal)
+ continue
+ initials = format_initials(author.get("given"))
+ names.append(f"{family}, {initials}" if initials else family)
+
+ if not names:
+ return ""
+ if len(names) == 1:
+ return names[0]
+ if len(names) > 9:
+ return ", ".join(names[:8]) + ", ... & " + names[-1]
+ return ", ".join(names[:-1]) + " & " + names[-1]
+
+
+def derive_status(csl_type):
+ if not csl_type:
+ return "Published"
+ return STATUS_MAP.get(csl_type, csl_type.replace("-", " ").title())
+
+
+def derive_year(csl):
+ issued = csl.get("issued") or {}
+ parts = issued.get("date-parts") or []
+ if parts and parts[0]:
+ return str(parts[0][0])
+ return ""
+
+
+def normalize_doi(doi):
+ doi = (doi or "").strip()
+ doi = re.sub(r"^https?://(dx\.)?doi\.org/", "", doi, flags=re.IGNORECASE)
+ return doi
+
+
+def build_citation(authors, year, title, journal_name, volume, issue, page, doi):
+ citation = f"{authors} ({year}). {title}."
+ if journal_name:
+ tail = f" {journal_name}"
+ if volume:
+ tail += f", {volume}"
+ if issue:
+ tail += f"({issue})"
+ if page:
+ tail += f", {page}"
+ citation += tail + "."
+ citation += f" https://doi.org/{doi}"
+ return citation
+
+
+def fetch_json(url, accept=None, timeout=20):
+ req = Request(url)
+ if accept:
+ req.add_header("Accept", accept)
+ req.add_header("User-Agent", USER_AGENT)
+ with urlopen(req, timeout=timeout) as resp:
+ return json.load(resp)
+
+
+def fetch_csl_json(doi):
+ url = f"https://doi.org/{quote(doi, safe='/:@!$&()*+,;=-._~')}"
+ try:
+ return fetch_json(url, accept="application/vnd.citationstyles.csl+json")
+ except HTTPError as e:
+ print(f" WARNING: doi.org returned HTTP {e.code} for {doi}", file=sys.stderr)
+ except (URLError, TimeoutError, json.JSONDecodeError) as e:
+ print(f" WARNING: failed to resolve {doi}: {e}", file=sys.stderr)
+ return None
+
+
+def fetch_datacite_abstract(doi):
+ url = f"https://api.datacite.org/dois/{quote(doi, safe='/:@!$&()*+,;=-._~')}"
+ try:
+ data = fetch_json(url)
+ except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
+ return ""
+ descriptions = (data.get("data") or {}).get("attributes", {}).get("descriptions") or []
+ for d in descriptions:
+ if d.get("descriptionType") == "Abstract":
+ abstract = clean_abstract(d.get("description", ""))
+ if abstract:
+ return abstract
+ return ""
+
+
+def build_metadata(doi):
+ csl = fetch_csl_json(doi)
+ if csl is None:
+ return None
+
+ title = strip_tags(csl.get("title", ""))
+ container_title = csl.get("container-title") or ""
+ if isinstance(container_title, list):
+ container_title = container_title[0] if container_title else ""
+ # Repository-hosted preprints (DataCite) often have no container-title;
+ # fall back to the publisher (e.g. "Center for Open Science").
+ journal_name = strip_tags(container_title) or strip_tags(csl.get("publisher", ""))
+ authors = format_authors(csl.get("author"))
+ year = derive_year(csl)
+ status = derive_status(csl.get("type"))
+ volume = csl.get("volume", "")
+ issue = csl.get("issue", "")
+ page = csl.get("page") or csl.get("article-number") or ""
+
+ abstract = clean_abstract(csl.get("abstract", ""))
+ if not abstract:
+ abstract = fetch_datacite_abstract(doi)
+
+ citation = build_citation(authors, year, title, journal_name, volume, issue, page, doi)
+
+ return {
+ "doi": doi,
+ "title": title,
+ "authors": authors,
+ "journal_name": journal_name,
+ "year": year,
+ "status": status,
+ "abstract": abstract,
+ "citation": citation,
+ }
+
+
+def load_cache():
+ if CACHE_FILE.exists():
+ with CACHE_FILE.open(encoding="utf-8") as f:
+ return json.load(f)
+ return {}
+
+
+def save_cache(cache):
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ with CACHE_FILE.open("w", encoding="utf-8") as f:
+ json.dump(cache, f, indent=2, ensure_ascii=False)
+
+
+def resolve_doi(doi, cache, force=False):
+ if not force and doi in cache:
+ return cache[doi]
+ metadata = build_metadata(doi)
+ if metadata is not None:
+ cache[doi] = metadata
+ return metadata
+
+
+def deep_merge_links(entry, doi, overrides):
+ links = entry.get("links")
+ if links is None:
+ links = CommentedMap()
+ entry["links"] = links
+ links["doi"] = f"https://doi.org/{doi}"
+ for key, value in (overrides.get("links") or {}).items():
+ links[key] = value
+
+
+def hydrate_entry(entry, metadata):
+ overrides = entry.get("overrides") or {}
+ for field in AUTO_FIELDS:
+ if field in overrides:
+ entry[field] = overrides[field]
+ continue
+ new_value = metadata.get(field)
+ if new_value:
+ entry[field] = new_value
+ deep_merge_links(entry, metadata["doi"], overrides)
+ entry["altmetric_doi"] = metadata["doi"]
+
+
+def make_yaml():
+ yaml = YAML()
+ yaml.preserve_quotes = True
+ yaml.width = 100000
+ return yaml
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--dry-run", action="store_true", help="Print result, don't write the file")
+ parser.add_argument("--force", action="store_true", help="Ignore cached DOI lookups")
+ args = parser.parse_args()
+
+ yaml = make_yaml()
+ with DATA_FILE.open(encoding="utf-8") as f:
+ data = yaml.load(f)
+
+ cache = load_cache()
+ resolved, failed = 0, 0
+
+ for entry in data:
+ if not isinstance(entry, dict) or "doi" not in entry:
+ continue
+ doi = normalize_doi(entry["doi"])
+ print(f"Resolving {doi} ...")
+ metadata = resolve_doi(doi, cache, force=args.force)
+ if metadata is None:
+ failed += 1
+ print(f" Could not resolve {doi}; leaving existing fields untouched")
+ continue
+ hydrate_entry(entry, metadata)
+ resolved += 1
+
+ save_cache(cache)
+ print(f"Done: {resolved} resolved, {failed} failed")
+
+ if args.dry_run:
+ yaml.dump(data, sys.stdout)
+ return
+
+ with DATA_FILE.open("w", encoding="utf-8") as f:
+ yaml.dump(data, f)
+
+
+if __name__ == "__main__":
+ main()