A GitHub Action that fetches publications from OpenAlex based on your members' ORCIDs and writes them to your publications directory as markdown. Each generated publication file contains the title, authors, ORCIDs, year, DOI, venue, keywords, a reconstructed abstract, the PDF link, and — when an open-access PDF is available — a screenshot of the page containing the abstract.
- ORCID-driven: scans existing member markdown for ORCIDs and pulls works per author from OpenAlex.
- Institution filter: only fetches publications affiliated with your institution via its ROR ID.
- Rich output: frontmatter includes
title/authors/ORCIDs/year/DOI/venue/keywords/abstract_screenshot; the body is the reconstructed abstract. - Abstract-page screenshot: when an open-access PDF is available, it is downloaded, the page containing the abstract is located, and a high-resolution PNG is rendered (shortest side ≥ 1000 px).
- Smart deduplication: OpenAlex ID + normalized DOI + Jaccard title similarity + author overlap; a CJK-safe tokenizer.
- Backfill: existing publication files missing
openalex_idand/orauthors_orcidare looked up on OpenAlex and completed in place, with the body preserved verbatim. - Local-first: the same code runs in GitHub Actions and locally via
.env.development.
- uses: markuxt/sync-publications@v1
with:
ror_id: 'https://ror.org/03y4dt428'
contact_email: 'contact@example.com'
members_dir: 'src/members'
publications_dir: 'src/publications'See the full workflow example below.
pnpm install
cp .env.example .env.development
# Edit .env.development — fill in ROR_ID and CONTACT_EMAIL
pnpm dev # run via tsx (no build needed)
# or:
./scripts/test-local.sh # recommended — equivalent to pnpm dev
./scripts/test-local.sh --build # run the compiled dist/index.jsYou can also override any environment variable on the command line:
ROR_ID=https://ror.org/other pnpm devThe institution's ROR ID. Look yours up at https://ror.org.
The contact email required by OpenAlex's polite-pool policy.
An OpenAlex API key for the premium pool (higher rate limits). The action
runs fine without it on the free polite pool (contact_email alone). When
provided, it is appended as api_key= to every OpenAlex request. Pass it via a
repository secret — see the full workflow example:
openalex_api_key: ${{ secrets.OPENALEX_API_KEY }}Locally, set OPENALEX_API_KEY in .env.development. An empty/undefined key is treated
as "no key" (polite pool).
The directory scanned for member markdown files (relative to the repo root
or absolute). Set this if your members live somewhere other than
src/members (e.g. src/people).
The directory where generated publication markdown and screenshots are
written (relative to the repo root or absolute). Defaults to
src/publications and is independent of members_dir, so members and
publications can live under different roots.
The number of publication files written by this run.
A newline-separated list of new file paths (emitted via a multi-line heredoc format so it is never truncated).
The number of existing publication files that had missing openalex_id and/or
authors_orcid filled in during this run.
A newline-separated list of backfilled file paths.
Member markdown files live at <members_dir>/**/*.md (default
src/members). They must contain an orcid field:
---
name: John Doe
orcid: 0000-0001-2345-6789
---Members with _hidden: true are skipped. ORCIDs are validated via the ISO 7064
11-2 check digit; an invalid ORCID is skipped with a warning, so a typo in one
file won't poison the whole sync.
Generated publication files are written to a flat layout (one .md per
publication, no subdirectory):
<publications_dir>/<year>/<title-slug>.md
<publications_dir>/<year>/<title-slug>.png (only when an OA PDF was processed)
The filename is a slugified form of the title (lowercase / whitespace and
punctuation → - / CJK preserved / capped at 80 chars), falling back to the
OpenAlex ID when the title is empty, and appending a -<shortId> suffix on
collisions within the same year directory. See src/utils/slugify.ts.
Each <title-slug>.md looks like:
---
_hidden: false
title: Publication Title
authors:
- Doe, John
authors_orcid:
- 0000-0001-2345-6789
- null
year: 2024
doi: https://doi.org/10.1000/example
openalex_id: W123456789
venue: Conference Name 2024
abstract_screenshot: publication-title.png # bare filename — lives next to the .md; markuxt resolves it like a member photo
keywords:
- control systems
- robotics
---
The reconstructed abstract text, rebuilt from OpenAlex's inverted index…- Rendered via
pdftoppm(poppler) at 200 DPI. On A4 paper the shortest side is roughly 1654 px — well above the 1000 px minimum. - GitHub Actions runners ship with
pdftoppm. - macOS locally requires a one-time install:
brew install poppler. - If
pdftoppmis unavailable, the run is not interrupted — the markdown is still written, withabstract_screenshotleft empty (the OA PDF URL and abstract page are resolved internally and not recorded either way).
Three layered checks (a hit on any one ⇒ skipped):
- OpenAlex ID (with or without a leading
W). - Normalized DOI (
https://doi.org//https://dx.doi.org//doi:are all folded into a lowercase bare DOI). - Similarity heuristic: year difference ≤ 1, title Jaccard ≥ 0.85, author overlap ≥ 0.5.
CJK / Hangul / Kana titles are tokenized per character, so non-Latin publications aren't tokenized into the empty set (a previous implementation falsely flagged these as duplicates).
Author deduplication: OpenAlex's authorships are expanded by
(author × institution), so the same person can appear multiple times. The
parser deduplicates on two keys at parse time — ORCID (preferred) and name
(fallback) — keeping the authors and authors_orcid arrays parallel and
preserving the first-seen spelling.
Within the same pending batch, older versions of the same publication are
marked _hidden: true; only the newest version stays visible.
When an existing publication file is missing its openalex_id and/or
authors_orcid frontmatter (common for files written by hand or by an older
version of the action), the run looks the work up on OpenAlex and completes
the fields in place — every other frontmatter field and the body are left
untouched.
Lookup priority: an existing openalex_id, then the doi, then a title +
year search. The search fallback is guarded by title Jaccard similarity
(≥ 0.85) and author overlap (≥ 0.5), so an uncertain match never writes a
wrong ID. authors_orcid is built parallel to the existing authors list,
matching each author by name (unknown authors get null).
This runs on every sync and is idempotent — complete files are skipped, so
there is no ongoing cost after the first run. The count and list of backfilled
files are exposed via backfilled_publications_count /
backfilled_publications_files.
A second pass (screenshots_backfilled_count / screenshots_backfilled_files)
renders an abstract-page screenshot for any existing publication that has an
OpenAlex ID but no abstract_screenshot yet — it re-queries OpenAlex for the
work's OA PDF URL, downloads it, locates the abstract page, and writes the PNG
next to the .md plus the abstract_screenshot (bare filename) field.
Idempotent, and it uses a browser-like User-Agent and tries every OA location,
but publisher anti-bot (Cloudflare/Akamai — e.g. Wiley, MDPI, Elsevier)
still blocks most closed-access hosts; only hosts that actually
serve the PDF (IEEE, arXiv, many repositories) will succeed.
sync-publications/
├── src/
│ ├── index.ts # main entry — reads env vars, orchestrates the sync
│ ├── types.ts # shared TypeScript types
│ ├── utils/
│ │ ├── abstract.ts # rebuilds the abstract from the inverted index
│ │ ├── deduplication.ts # tokenization, Jaccard, author overlap
│ │ ├── doi.ts # DOI normalization
│ │ ├── env.ts # loads .env / .env.<NODE_ENV>
│ │ ├── github.ts # writes GITHUB_OUTPUT (heredoc-aware)
│ │ ├── glob.ts # markdown file discovery
│ │ ├── http.ts # fetch + timeout + backoff retry
│ │ ├── openalex.ts # OpenAlex API client
│ │ ├── pdf.ts # PDF download / text extraction / screenshot
│ │ ├── formatters.ts # author-name / ORCID formatting
│ │ └── yaml.ts # YAML frontmatter parse + serialize
│ ├── scanners/
│ │ ├── members.ts # scans members for ORCIDs
│ │ └── publications.ts # scans existing publications for dedup
│ └── workers/
│ ├── parser.ts # OpenAlex work → PendingPublication
│ └── deduplicator.ts # filters + dedupes the pending list
├── tests/ # vitest suite (140 tests)
├── action.yml # GitHub Action metadata
├── dist/ # compiled artifact loaded by the node24 runtime
└── package.json
- Node.js ≥ 24.0.0
- pnpm (recommended) or npm
- (optional, for local screenshots)
poppler:brew install poppler
pnpm install # install dependencies
pnpm dev # run via tsx (no build needed)
pnpm build # bundle to a single self-contained dist/index.js (tsup, all deps inlined)
pnpm start # run the compiled dist/index.js
pnpm test # run the vitest suite
pnpm test:watch # interactive watch mode
pnpm test:coverage # vitest + v8 coverage
./scripts/test-local.sh # equivalent to pnpm dev, auto-loads .env.development
./scripts/test-local.sh --build # same, but runs dist/index.js.env.example— committed template listing all variables..env.development— gitignored; auto-loaded bypnpm devand./scripts/test-local.sh..env— also supported (lower priority than.env.development).
Existing process.env values always win, so ROR_ID=... pnpm dev on the
command line overrides the value in .env.development.
dist/ is intentionally committed. pnpm build uses
tsup to bundle src/index.ts together with all
dependencies (dotenv / glob / unpdf / yaml) into a single
self-contained dist/index.js. GitHub Actions' node24 runtime loads it
directly (see action.yml) and does not need node_modules at runtime.
Because of this, any PR that changes src/ must rebuild dist/ before merge.
pnpm build
git add dist/
git commit -m 'build: rebuild dist'name: Sync Publications
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # every Sunday at midnight UTC
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Sync publications from OpenAlex
id: sync
uses: markuxt/sync-publications@v1
with:
ror_id: 'https://ror.org/03y4dt428'
contact_email: 'research-lab@example.com'
members_dir: 'src/members'
publications_dir: 'src/publications'
- name: Commit new publications
env:
COUNT: ${{ steps.sync.outputs.new_publications_count }}
FILES: ${{ steps.sync.outputs.new_publications_files }}
run: |
if [ -n "$FILES" ]; then
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add src/publications/
git commit -m "chore: sync $COUNT publication(s) from OpenAlex"
git push
fiApache-2.0
For issues, please report them at GitHub Issues.