arXiv2bib does for still-unpublished (or never published) papers from the arXiv what doi2bib does for published papers, i.e., it returns a bibTeX entry ready to be put in the bib collection (it does so with option -w).
For example:
laussy@azag:~$ ./arXiv2bib 2402.01627 -w
@Article{arXiv_casalengua24a,
author = {E. {Zubizarreta Casalengua} and F. P. Laussy},
title = {Spatial correlations of vortex quantum states},
journal = {arXiv:2402.01627},
year = {2024},
pdf = {arXiv/arXiv_casalengua24a},
url = {doi:10.48550/arXiv.2402.01627}
}
====
arXiv:2402.01627
arXiv_casalengua24a
arXiv_casalengua24a added at the top of /home/laussy/bib/arXiv.bib
This can then be exported for laussy.org with bib2wiki:
laussy@azag:~$ bib2wiki arXiv_casalengua24a
arXiv_casalengua24a
<wz tip="arXiv:2402.01627">[[File:arXXXiv.png|14px|link=]]</wz><u>[[arXiv_casalengua24a|Spatial correlations of vortex quantum states]]</u>. [[E. Zubizarreta Casalengua]] and [[F. P. Laussy]] in {{arXiv|2402.01627}} ([[2024]]).
which outputs:
Spatial correlations of vortex quantum states. E. Zubizarreta Casalengua and F. P. Laussy in arXiv:2402.01627 (2024).
Note that in the example above, as per my naming conventions, it should really be arXiv_zubizarettacasalengua24a but I'll leave it at that for now.
There is not yet a way to check for already existing keys, like doi2bib, so as to increment the last letter.
Insertion in the file is also not sorted.
I worked out the first version from doi2bib with the help of both Grok and ChatGPT, and came up with the first archivable version as v°0.8.8. Since then:
The current version, v°0.10.0:
#!/usr/bin/perl -X -s
# __ ___ ____ _ _ _
# __ _ _ _\ \/ (_)_ _|___ \| |__ (_) |__
# / _` | '__\ /| \ \ / / __) | '_ \| | '_ \
#| (_| | | / \| |\ V / / __/| |_) | | |_) |
# \__,_|_| /_/\_\_| \_/ |_____|_.__/|_|_.__/
# F.P. Laussy - fabrice.laussy@gmailcom
# Adapted from doi2bib by Grok - Sun Sep 21 2025
# v0.8.8 Sun Sep 21 2025 - Fixed extraneous "Invalid JSON" error on HTTP 404 by conditioning JSON processing
# on $http_status == 200 (in addition to -f && -s > 0). Retains "Failed to download"
# for non-200 responses.
# v0.8.9 Thu Aug 06 2026 - -a: write (-w) then push the new entry to the
# wiki via bib2wiki (as doi2bib -a does). Flags are
# now also read from $w/$a, which perl's -s (shebang)
# steals when the flag comes *before* the id.
# v0.9.0 Tue Aug 11 2026 - bib key: accented letters are now transliterated
# instead of deleted. "Papuccio-Fernández" used to
# give papucciofernndez, because s/[^a-zA-Z]//g ran
# *first* and ate the UTF-8 bytes of á (the accent
# substitutions that followed were dead code, they
# could never match). Now bibkey() strips TeX accent
# escapes (\'a, \"u, {\ss}...), then NFKD-decomposes
# and drops the combining marks, as doi2bib does.
# v0.9.1 Tue Aug 11 2026 - author initials are now cut by initials(), shared by
# both the Crossref and the HTML path, which fixes:
# * "J. -V. Vidal" (arXiv writes a compound given name
# with a space before the hyphen) came out as
# "J. -. V." -- substr($_,0,1) took the hyphen;
# * only the *first* given name was initialled on the
# Crossref path, so "Juan Camilo L\'opez" gave "J."
# and the bibnames rules, which are written for the
# full "J. C. L." form, could never fire;
# * a mononymous author (family, no given) made the
# whole jq call fail, losing every author at once;
# * multi-word family names are braced on both paths.
# bibnames is applied through a file: sh's echo ate
# the backslashes of the TeX escapes.
# v0.10.0 Fri Sep 11 2026 - -h lists the options. Written before the id it is
# eaten by perl's -s and read back from $h, as -w and
# -a already are.
#
# Usage:
# arXiv2bib [-w] id
# arXiv2bib id [-w]
# arXiv2bib -a id # -w, then bib2wiki <newkey>
use strict;
use warnings;
use Encode qw(decode_utf8);
use Unicode::Normalize qw(NFKD);
my $VERSION = '0.10.0';
# --- the options -----------------------------------------------------------
sub usage {
print <<"END";
arXiv2bib -- v$VERSION -- an arXiv id becomes a BibTeX entry in ~/bib/arXiv.bib
arXiv2bib ID show the entry, write nothing
arXiv2bib -w ID write: put it at the TOP of arXiv.bib, newest first
arXiv2bib -a ID -w, and then push Template:<Key> with bib2wiki -- which
creates the page but, unlike doi2bib -a, does not update
one that is already there
-h this list
A flag may be written on either side of the id. The id may be bare, prefixed
with arXiv:, versioned (2409.12952v2) or in the old scheme (cond-mat/0605294).
When the paper has since been published, the doi is followed and the entry is
made from the journal's metadata instead of arXiv's.
END
exit 0;
}
# "Alejandro Andres" -> "A. A.", "Xing-Gang" -> "X.-G.", "Juan Camilo L\'opez"
# -> "J. C. L." (which is the form the bibnames sed file expects). Case is left
# alone on purpose, so "Elena del" gives "E. d." and rule 3 of bibnames fires.
sub initials {
my ($given) = @_;
return "" unless defined $given;
$given =~ s/^\s+|\s+$//g;
return "" if $given eq "";
# arXiv and Crossref write a compound given name as "J. -V.", with a space
# before the hyphen: glue such a piece back onto the one before it
$given =~ s/\s+-/-/g;
my @out;
foreach my $part (split /\s+/, $given) {
my @bits = grep { $_ ne "" } split /-/, $part;
next unless @bits;
push @out, join("-", map { substr($_, 0, 1) . "." } @bits);
}
return join(" ", @out);
}
# one BibTeX author: initialled given name plus family name, the latter braced
# when it is made of several words, so BibTeX does not mistake "de la Pradilla"
# for a given name. A mononymous author is just the family name.
sub format_author {
my ($given, $family) = @_;
$family = "" unless defined $family;
$family =~ s/^\s+|\s+$//g;
return "" if $family eq "";
$family = "{$family}" if $family =~ /\s/ && $family !~ /^\{.*\}$/;
my $ini = initials($given);
return $ini eq "" ? $family : "$ini $family";
}
# run the author list through the bibnames sed file (accents -> TeX escapes,
# plus the hand-written rules for the group's own compound surnames). Through a
# file rather than `echo "..." | sed`: sh's echo mangles the backslashes that
# the earlier rules have just put in, and the names are full of them.
sub tex_names {
my ($text, $sedfile) = @_;
return $text unless defined $text && $text ne "" && -f $sedfile;
open(my $t, '>', "authors.txt") or return $text;
print $t $text, "\n";
close $t;
my $out = qx{sed -f $sedfile authors.txt 2>/dev/null};
unlink "authors.txt";
chomp $out;
return $out ne "" ? $out : $text;
}
# turn a family name -- raw UTF-8 ("Fernández") or already TeX-escaped by
# bibnames ("Fern\'andez") -- into the bare ASCII stem of a bib key
sub bibkey {
my ($name) = @_;
return "unknown" unless defined $name && $name ne "";
# dotless i and j, which accent commands are built on: \u{\i} -> \u{i}
# (keep the braces, so the accent rules below still see \u{...})
$name =~ s/\\i(?![a-zA-Z])/i/g;
$name =~ s/\\j(?![a-zA-Z])/j/g;
# TeX accents: \'a \"{u} \^o \~n \c{c} \v{s} \k{a} \.{z} ... keep the letter
$name =~ s/\\[`'"^~=.uvHtcdbrk]\s*\{(\w)\}/$1/g;
$name =~ s/\\[`'"^~=.]\s*(\w)/$1/g;
$name =~ s/\\[uvHtcdbrk]\s+(\w)/$1/g;
# TeX ligatures with no ASCII letter of their own
$name =~ s/\{?\\ss\}?/ss/g;
$name =~ s/\{?\\(?:ae|AE)\}?/ae/g;
$name =~ s/\{?\\(?:oe|OE)\}?/oe/g;
$name =~ s/\{?\\[oO]\}?/o/g;
$name =~ s/\{?\\[lL]\}?/l/g;
$name =~ s/\{?\\(?:aa|AA)\}?/a/g;
# interpret the bytes as UTF-8 (qx// hands us bytes; be safe if it does not)
$name = decode_utf8($name) unless utf8::is_utf8($name);
$name = NFKD($name); # é -> e + combining acute
$name =~ s/\p{NonspacingMark}//g; # drop the diacritics
# letters NFKD does not decompose, because the stroke/hook is part of the
# glyph. Written \x{} rather than literally: this file is not "use utf8",
# so an accented character typed here would be *bytes* and match nothing.
$name =~ s/[\x{0110}\x{0111}\x{00D0}\x{00F0}]/d/g; # Đ đ Ð ð
$name =~ s/[\x{00D8}\x{00F8}]/o/g; # Ø ø
$name =~ s/[\x{0141}\x{0142}]/l/g; # Ł ł
$name =~ s/[\x{0131}]/i/g; # ı dotless i
$name =~ s/[\x{00DF}\x{1E9E}]/ss/g; # ß ẞ
$name =~ s/[\x{00C6}\x{00E6}]/ae/g; # Æ æ
$name =~ s/[\x{0152}\x{0153}]/oe/g; # Œ œ
$name =~ s/[\x{00DE}\x{00FE}]/th/g; # Þ þ
$name =~ s/[^a-zA-Z]//g; # braces, hyphens, spaces, leftovers
return lc($name) || "unknown";
}
# the -s on the shebang line makes perl itself eat a *leading* -w or -a and
# set $w / $a instead, so a flag put before the identifier never reaches
# @ARGV: honour both spellings, whichever side of the id the flag is on
our ($w, $a, $h);
usage() if $h;
my $write_flag = $w ? 1 : 0;
my $wiki_flag = 0;
my $id = '';
if ($a) { $write_flag = 1; $wiki_flag = 1; }
# Parse arguments
foreach my $arg (@ARGV) {
if ($arg eq '-w') {
$write_flag = 1;
} elsif ($arg eq '-a') {
$write_flag = 1;
$wiki_flag = 1;
} elsif ($arg eq '-h' || $arg eq '--help') {
usage();
} elsif ($arg =~ /(?:arXiv:)?((?:[\w\-]+\/)?\d+(?:\.\d+)?)(?:v\d+)?/i) {
$id = $1;
}
}
if ($id eq '') {
print STDERR "Please provide me with an arXiv identifier, e.g.,\n";
print STDERR "arXiv2bib 2409.12952\n";
print STDERR "arXiv2bib -h lists the options.\n";
exit(-1);
}
# path to important substitution files
my $iso4 = "/home/laussy/bib/doi2bib/iso4";
my $bibnames = "/home/laussy/bib/doi2bib/bibnames";
my $bibfile = "/home/laussy/bib/arXiv.bib";
# make a temporary directory to store files
my $dest = "arXiv2bib-".`uniqname`;
chomp $dest;
mkdir $dest or die "Cannot create directory $dest: $!";
chdir $dest or die "Cannot change to directory $dest: $!";
# set initial DOI for arXiv
my $doi = "10.48550/arXiv.$id";
# download the bibliographic data for arXiv DOI using Crossref REST API
my $curl_cmd = "curl -s -L -H \"Accept: application/json\" -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\" \"https://api.crossref.org/works/$doi\" -o from_doi.json -w \"%{http_code}\"";
my $http_status = `$curl_cmd 2>&1`;
chomp $http_status;
if ($http_status != 200 || ! -f "from_doi.json" || -s "from_doi.json" == 0) {
# Silently skip printing the error, as fallback will handle it
}
# always fetch the arXiv abstract HTML for additional info
my $html_cmd = "curl -s -A \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\" \"https://arxiv.org/abs/$id\" -o article.html 2>/dev/null";
`$html_cmd`;
if (! -f "article.html" || -s "article.html" == 0) {
print STDERR "Error: Failed to download arXiv abstract page for $id\n";
chdir "..";
system("rm -rf $dest");
exit(-1);
}
# extract published DOI if present
my $published_doi = `grep -oP '(?<=name="citation_doi" content=")[^"]+' article.html 2>/dev/null`;
chomp $published_doi;
my $reretched = 0;
if ($published_doi ne "") {
$doi = $published_doi;
$curl_cmd = "curl -s -L -H \"Accept: application/json\" -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\" \"https://api.crossref.org/works/$doi\" -o from_doi.json -w \"%{http_code}\"";
$http_status = `$curl_cmd 2>&1`;
chomp $http_status;
if ($http_status == 200 && -s "from_doi.json" > 0) {
$reretched = 1;
print STDERR "Found published DOI $published_doi, using published metadata\n" if $write_flag;
} else {
print STDERR "Warning: Failed to fetch JSON for published DOI $published_doi, falling back to arXiv metadata\n" if $write_flag;
}
}
# extract journal reference if present
my $journalref = `grep -oP '(?<=Journal reference: ).*?(?=<)' article.html 2>/dev/null`;
chomp $journalref;
# generate bib key base
my $redirect = $write_flag ? "2>&1" : "2>/dev/null";
my $json_failed = 1;
my $bibauthors;
my $bibtitle;
my $bibyear;
my $bibdoi = "10.48550/arXiv.$id"; # Initialize bibdoi
my $bibkey;
if ($http_status == 200 && -f "from_doi.json" && -s "from_doi.json" > 0) {
# Check if JSON is valid
my $json_check = qx{jq -e . from_doi.json >/dev/null 2>&1; echo \$?};
chomp $json_check;
if ($json_check == 0) {
# JSON-based extraction (now using .message paths). jq only hands over
# the raw given/family pair, one author per line; the initials are cut
# in perl, by the same initials() the HTML fallback below uses.
my @rows = split /\n/, qx{<from_doi.json jq -r '.message.author[] | [(.given // ""), (.family // "")] | \@tsv' 2>/dev/null $redirect};
my @people;
foreach my $row (@rows) {
my ($given, $family) = split /\t/, $row, 2;
my $one = format_author($given, $family);
push @people, $one if $one ne "";
}
$bibauthors = tex_names(join(" and ", @people), $bibnames);
chomp $bibauthors;
if ($write_flag) {
print STDERR "Raw author output from jq: $bibauthors\n";
}
if ($bibauthors eq "" || $bibauthors eq "null") {
print STDERR "Error: Failed to extract authors for arXiv $id\n" if $write_flag;
$bibauthors = "null";
}
$bibtitle = qx{<from_doi.json jq -r '.message.title[0]' 2>/dev/null $redirect};
chomp $bibtitle;
if ($bibtitle eq "" || $bibtitle eq "null") {
print STDERR "Error: Failed to extract title for arXiv $id\n" if $write_flag;
$bibtitle = "null";
}
$bibyear = qx{<from_doi.json jq -r '.message.published."date-parts"[0][0]' 2>/dev/null $redirect};
chomp $bibyear;
if ($bibyear eq "" || $bibyear eq "null") {
print STDERR "Error: Failed to extract year for arXiv $id\n" if $write_flag;
$bibyear = "0000";
}
$bibdoi = qx{<from_doi.json jq -r '.message.DOI' 2>/dev/null $redirect};
chomp $bibdoi;
if ($bibdoi eq "" || $bibdoi eq "null") {
$bibdoi = "10.48550/arXiv.$id";
}
# Extract first author's last name from formatted bibauthors
my @authors = split / and /, $bibauthors;
$bibkey = "unknown";
if (@authors && $bibauthors ne "null") {
my $first_author = $authors[0];
if ($first_author =~ /.*?\s+([^{}\s]+|\{[^}]+\})$/) {
$bibkey = $1;
$bibkey =~ s/[{}]//g; # Remove braces for compound names
}
}
$json_failed = ($bibauthors eq "null" || $bibtitle eq "null" || $bibkey eq "unknown");
} else {
print STDERR "Error: Invalid JSON for arXiv $id\n" if $write_flag;
}
}
# HTML-based extraction as fallback
if ($json_failed) {
# Extract authors
my @authors = split /\n/, `grep -oP '(?<=name="citation_author" content=")[^"]+' article.html 2>/dev/null`;
my @formatted_authors;
my $first_family = "";
foreach my $author (@authors) {
chomp $author;
# "Papuccio-Fern\xc3\xa1ndez, I." -- and, for a mononym, no comma at all
my ($surname, $given) = $author =~ /^([^,]+),\s*(.*)$/ ? ($1, $2) : ($author, "");
$surname =~ s/^\s+|\s+$//g;
next if $surname eq "";
$first_family = $surname if $first_family eq "";
# the accents are left as they are: tex_names() escapes them below,
# with the whole of bibnames rather than the two rules that used to be
# hard-coded here (and which corrupted the key they were cut from)
push @formatted_authors, format_author($given, $surname);
}
$bibauthors = join(" and ", @formatted_authors);
if ($bibauthors eq "") {
print STDERR "Error: Failed to extract authors from HTML for arXiv $id\n" if $write_flag;
$bibauthors = "null";
} else {
$bibauthors = tex_names($bibauthors, $bibnames);
}
# Extract title
$bibtitle = `grep -oP '(?<=<meta name="citation_title" content=")[^"]+' article.html 2>/dev/null`;
chomp $bibtitle;
if ($bibtitle eq "") {
$bibtitle = `grep -oP '(?<=<h1 class="title mathjax">\\s*<span class="descriptor">Title:</span>\\s*)[^<]+' article.html 2>/dev/null`;
chomp $bibtitle;
}
if ($bibtitle eq "") {
print STDERR "Error: Failed to extract title from HTML for arXiv $id\n" if $write_flag;
$bibtitle = "null";
}
# Extract year
$bibyear = `grep -oP '(?<=<meta name="citation_date" content=")[0-9]{4}' article.html 2>/dev/null`;
chomp $bibyear;
if ($bibyear eq "") {
$bibyear = `grep -oP '(?<=<div class="submission-history">.*Submitted.*?)\\d{4}' article.html 2>/dev/null`;
chomp $bibyear;
$bibyear =~ s/.*(\d{4})$/$1/;
}
if ($bibyear eq "") {
print STDERR "Error: Failed to extract year from HTML for arXiv $id\n" if $write_flag;
$bibyear = "0000";
}
$bibkey = $first_family || "unknown";
}
# Clean bibkey for key format (transliterate accents, then keep letters only)
$bibkey = bibkey($bibkey);
# Exit if critical fields are missing
if ($bibauthors eq "null" || $bibtitle eq "null" || $bibyear eq "0000" || $bibkey eq "unknown") {
print STDERR "Error: Failed to extract valid author, title, or year for arXiv $id\n";
chdir "..";
system("rm -rf $dest");
exit(-1);
}
my $name = $bibkey;
my $bibyear_short = substr($bibyear, -2); # Use last two digits of year
my $basekey = "arXiv_" . $name;
# determine next letter
my %letters;
my $content = '';
my $existing_key = '';
my $exists = 0;
if (-f $bibfile) {
open(my $fh, '<', $bibfile) or die "Cannot open $bibfile: $!";
{ local $/; $content = <$fh>; }
close $fh;
while ($content =~ /\@(\w+)\{([^,]+),\s*(.*?)\n\s*\}/gxis) {
my $type = $1;
my $key = $2;
my $fields = $3;
if ($type !~ /string/i && ($fields =~ /url\s*=\s*\{doi:\Q$bibdoi\E\}/i)) {
$existing_key = $key;
$exists = 1;
last;
}
if ($type !~ /string/i && $key =~ /\QarXiv_$name\E(\d{2})([a-z])/i) {
my $captured_year = $1;
my $captured_letter = $2;
if ($captured_year eq $bibyear_short) {
$letters{lc $captured_letter} = 1;
}
}
}
}
# check for existing entry
if ($exists) {
print STDERR "Entry for arXiv $id already exists with key $existing_key\n";
chdir "..";
system("rm -rf $dest");
exit(0);
}
my @used_letters = sort keys %letters;
my $letter = 'a';
while (grep { $_ eq $letter } @used_letters) {
$letter = chr(ord($letter) + 1);
}
my $bibkey_final = "arXiv_" . $name . $bibyear_short . $letter;
# construct entry
my $entry = "\@Article\{$bibkey_final,\n";
$entry .= " author = {$bibauthors},\n";
$entry .= " title = {$bibtitle},\n";
$entry .= " journal = {arXiv:$id},\n";
$entry .= " year = {$bibyear},\n";
if ($journalref ne "") {
$entry .= " note = {$journalref},\n";
}
$entry .= " pdf = {arXiv/$bibkey_final},\n";
$entry .= " url = {doi:$bibdoi}\n}";
# output entry to stdout
print "$entry\n";
# output to stderr
print STDERR "\n====\n\n";
print STDERR "arXiv:$id\n";
print STDERR "$bibkey_final\n";
if ($write_flag) {
$content = $entry . "\n\n" . $content;
open(my $out, '>', $bibfile) or die "Cannot write to $bibfile: $!";
print $out $content;
close $out;
print STDERR "$bibkey_final added at the top of $bibfile\n";
}
if ($wiki_flag) {
system("bib2wiki", $bibkey_final);
}
# back to initial directory and remove temporary one
chdir "..";
system("rm -rf $dest");