5 Commits
Author SHA1 Message Date
Worph 6df8e382b1 release (first build on gitea-watch / IPCR)
Publish image / publish (push) Successful in 2m21s
2026-10-13 11:02:57 +00:00
Worph a1f55c9fd3 gitea workflow: push to IPCR's direct host (Cloudflare 100 MB cap) [skip ci] 2026-10-13 10:02:27 +00:00
Worph 48eeccc8fb gitea workflow: publish to IPCR (metadec.eth) instead of ghcr
Publish image / publish (push) Successful in 1m53s
2026-10-12 18:30:42 +00:00
Manielle ebc7e1da65 give every record a url-locator CID so the gateway can persist it
Publish image / publish (push) Failing after 13s
A record with no CID cannot be persisted by the gateway ("search-persist: record
carries no CID; not persisted"), yet the gateway still stamps the query as
covered for an hour (META_GATEWAY_SEARCH_COVERAGE_TTL_SECS) and, inside that
window, skips this feeder and answers from what it persisted. So the first query
returned papers and every identical one for the next hour returned none.
Measured on the live box: 40 hits, then 0 hits in 1.5 s with no fan-out.

Each record now carries exactly one `cids/` member: a 0x1006 url-locator over the
canonical, versioned PDF URL (https://arxiv.org/pdf/<id>.pdf). This is the fleet
convention (Gutenberg, internetarchive, jamendo): it is a pure function of the
URL, so it costs no I/O, and it doubles as the byte path that meta-share
redeems. The versioned id means v2 of a paper is a different CID. Built from the
canonical URL rather than the feed's link, which arXiv emits as http:// without
.pdf.

Verified live after deploying: 40 hits each carrying a CID; the same query again
inside its coverage window returns 40 (was 0); meta-share serves the locator
with 206 Partial Content and the bytes are a real PDF (%PDF-1.7, 1,182,901 B).
2026-10-11 17:02:17 +02:00
Manielle 92b2f8aa0d browse the newest papers when a query has no keywords
A query with only filters (a consumer's `domain:science contentKind:paper` row)
reached the plugin with no free text, and the plugin substituted `all:*`. arXiv
answers that with HTTP 500, so every such query returned nothing, however many
papers existed.

An empty query (or the SDK's `*` placeholder) now browses the major subject
archives (`cat:<archive>.*`, OR-ed) newest first (`sortBy=submittedDate`).
Keyword queries are unchanged: same `all:` field, same URL, relevance order.

The URL is built by a pure function, so both shapes are tested without the
network. Every form was verified against the live API: `all:*` -> 500, the
browse query -> 200 with 60 entries out of ~2.6M papers.

No `licence` is written, on purpose: arXiv declares none per paper, its default
licence does not permit redistribution, and the registry says absence means
"unknown", never "unrestricted".
2026-10-11 16:34:00 +02:00
2 changed files with 237 additions and 9 deletions
+85
View File
@@ -0,0 +1,85 @@
# Gitea twin of .github/workflows/publish.yml. Gitea runs only .gitea/workflows/ once this
# folder exists, and GitHub never reads it.
#
# Publishes to IPCR on watch.nsl.sh instead of ghcr.io:
# push ipcr-push-85-17-246-67.nip.io/metadec.eth/<repo>:<tag> docker login as metadec.eth with the
# IPCR_PUSH_TOKEN secret (the metadec.eth namespace token from the IPCR dashboard)
# pull ipcr-watch.nsl.sh/ipns/metadec.eth/metadec.eth/<repo>:<tag> anonymous
# The push goes to ipcr-push-85-17-246-67.nip.io, the box's own address, not ipcr-push-watch.nsl.sh: Cloudflare fronts
# *.watch.nsl.sh and refuses any request over 100 MB (413), which a large image layer is.
# That host's certificate is signed by the box's private Mesh Router CA, given to buildkit from
# the IPCR_CA_PEM repo variable. docker login would make the job's Docker daemon check the
# certificate without that CA, so the login is written straight into ~/.docker/config.json.
# The runner's job image (node:22-bookworm) has no docker CLI, so the static one is
# installed first; buildx installs itself. No `type=gha` cache: the runner has none.
name: Publish image
# Build and push the feeder image to the IPCR (ipcr-push-watch.nsl.sh):
# * every push to main -> the moving `main` tag
# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1)
# The build context is the repo root (the workspace) so the vendored
# `meta-feeder-sdk` path dependency resolves; the Dockerfile lives under
# feeder-plugin/paper-feeder/.
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ipcr-push-85-17-246-67.nip.io
IMAGE_NAME: metadec.eth/${{ github.event.repository.name }}
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI
run: curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-28.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
- name: Trust the Mesh Router CA
run: printf '%s\n' "$IPCR_CA_PEM" > /tmp/ipcr-ca.pem
env:
IPCR_CA_PEM: ${{ vars.IPCR_CA_PEM }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
buildkitd-config-inline: |
[registry."${{ env.REGISTRY }}"]
ca = ["/tmp/ipcr-ca.pem"]
- name: Log in to IPCR
env:
IPCR_PUSH_TOKEN: ${{ secrets.IPCR_PUSH_TOKEN }}
run: |
mkdir -p ~/.docker
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$(printf 'metadec.eth:%s' "$IPCR_PUSH_TOKEN" | base64 -w0)" > ~/.docker/config.json
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./feeder-plugin/arxiv-feeder/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+152 -9
View File
@@ -1,6 +1,8 @@
//! arXiv bridge via the public [arXiv API](https://info.arxiv.org/help/api/index.html).
//!
//! - Search: `GET /api/query?search_query=all:<q>&max_results=N` → Atom feed.
//! - Browse (a query with no keywords): the newest papers across the major
//! subject archives, `sortBy=submittedDate` — see [`BROWSE_QUERY`].
//! - Fetch one: `GET /api/query?id_list=<record_id>` → Atom feed (single entry).
//! - Canonical file format for midhash: the entry's PDF link
//! (`<link title="pdf" href="…">`). arXiv always provides one; fall back
@@ -48,6 +50,40 @@ const HTTP_TIMEOUT_SECS: u64 = 30;
/// Polite identification per the arXiv API guidelines.
const USER_AGENT: &str = concat!("meta-share/", env!("CARGO_PKG_VERSION"), " (gateway:arxiv)");
/// What a query with **no keywords** browses: the newest papers across the
/// major subject archives.
///
/// ⚠ This exists because the obvious default does not work. A board row such as
/// meta-read's `domain:science contentKind:paper` carries only filters, so the
/// feeder receives no free text; it used to substitute `all:*`, and arXiv's API
/// answers that with **HTTP 500** (verified against the live API, as is every
/// form below). The plugin then returned nothing for every such query, so a
/// consumer's "papers" row stayed empty no matter how many papers existed.
///
/// `cat:<archive>.*` wildcards are accepted and OR-able; sorting by submission
/// date makes the result "what is new" instead of an arbitrary slice. Written
/// pre-encoded (`+` = space) because it is a constant, not user input.
const BROWSE_QUERY: &str = "cat:cs.*+OR+cat:math.*+OR+cat:physics.*+OR+cat:stat.*+OR+cat:q-bio.*+OR+cat:astro-ph.*+OR+cat:cond-mat.*+OR+cat:econ.*+OR+cat:eess.*+OR+cat:q-fin.*";
/// The Atom-search URL for `query`.
///
/// Keywords search the `all:` field exactly as before. An empty query, or the
/// SDK's `*` placeholder for one, browses [`BROWSE_QUERY`] newest first.
fn search_url(base: &str, query: &str, max_results: usize) -> String {
let base = base.trim_end_matches('/');
let q = query.trim();
if q.is_empty() || q == "*" {
format!(
"{base}/api/query?search_query={BROWSE_QUERY}&sortBy=submittedDate&sortOrder=descending&start=0&max_results={max_results}"
)
} else {
format!(
"{base}/api/query?search_query=all:{}&start=0&max_results={max_results}",
common::urlencode(q)
)
}
}
/// arXiv gateway plugin. Same structural shape as gutenberg: cheap to
/// construct; `configure()` opens the per-plugin redb cache. The cache
/// stores `(record_id → midhash)` so we don't re-download a multi-MB
@@ -86,16 +122,12 @@ impl ArxivPlugin {
common::require_cache(self.cache.as_ref(), "arxiv")
}
/// Search the arXiv Atom API. `query` is passed verbatim as the
/// `all:` field, so callers get the full-text behaviour arXiv users
/// expect (free-text + boolean syntax both work upstream).
/// Search the arXiv Atom API. A non-empty `query` is passed verbatim as the
/// `all:` field, so callers get the full-text behaviour arXiv users expect
/// (free-text + boolean syntax both work upstream); an empty one browses
/// the newest papers (see [`search_url`]).
async fn search_atom(&self, query: &str, max_results: usize) -> Result<Vec<u8>, GatewayError> {
let url = format!(
"{}/api/query?search_query=all:{}&start=0&max_results={}",
self.base_url.trim_end_matches('/'),
common::urlencode(query),
max_results
);
let url = search_url(&self.base_url, query, max_results);
common::fetch_bytes(&self.http, &url)
.await
.map(|b| b.to_vec())
@@ -519,6 +551,29 @@ fn into_discovery_record(entry: ArxivEntry) -> DiscoveryRecord {
fields.insert("fileName".to_string(), format!("arxiv-{record_id}.pdf"));
fields.insert(upstream_id_field("arxiv"), record_id.clone());
fields.insert("format".to_string(), "pdf".to_string());
// ⚠ A record with no CID is invisible to everything downstream of the
// gateway. The gateway persists only search hits that carry at least one
// CID ("search-persist: record carries no CID; not persisted"), but it still
// stamps the query as covered for an hour (META_GATEWAY_SEARCH_COVERAGE_TTL_
// SECS) and, inside that window, skips this feeder and answers from what it
// persisted. So the first query returned papers, and every identical one for
// the next hour returned none. A client also needs a CID to open a paper at
// all. Measured on the live box: 40 hits, then zero hits in 1.5 s.
//
// The fleet convention (Gutenberg, internetarchive, jamendo): a `0x1006`
// url-locator over the file's URL. It is a pure function of the URL, so it
// costs no I/O, and it doubles as the byte path - meta-share redeems the
// locator on demand. Built from the canonical, *versioned* PDF URL rather
// than the feed's own link, which arXiv emits as `http://` without `.pdf`:
// v2 of a paper is a different file and must be a different CID.
//
// ⚠ Exactly ONE `cids/` member: the store key is the lexicographic `.min()`
// of them, so a second one would make it arbitrary.
if let Some(cid) =
meta_feeder_sdk::hash::compute_url_cid(&format!("https://arxiv.org/pdf/{record_id}.pdf"))
{
fields.insert(format!("cids/{cid}"), "true".to_string());
}
if !entry.authors.is_empty() {
fields.insert("author".to_string(), entry.authors.join(", "));
}
@@ -848,4 +903,92 @@ mod tests {
.expect_err("expected NotFound (Err, not Ok(vec![]))");
assert!(matches!(err, GatewayError::NotFound), "got {err:?}");
}
// -- a record the gateway can persist ----------------------------------------
#[test]
fn a_record_carries_exactly_one_url_locator_cid_over_the_versioned_pdf() {
let entries = parse_arxiv_feed(SAMPLE_FEED.as_bytes()).expect("parse");
for e in entries {
let rec = into_discovery_record(e);
let cids: Vec<&String> = rec.fields.keys().filter(|k| k.starts_with("cids/")).collect();
assert_eq!(cids.len(), 1, "exactly one cids/ member: {cids:?}");
let cid = cids[0].trim_start_matches("cids/");
// The locator is the URL itself, recoverable with no I/O - which is
// what lets meta-share redeem it as the byte path.
assert_eq!(
meta_feeder_sdk::hash::decode_url_cid(cid).as_deref(),
Some(format!("https://arxiv.org/pdf/{}.pdf", rec.record_id).as_str())
);
}
}
#[test]
fn two_versions_of_a_paper_get_two_cids() {
let mut feed = SAMPLE_FEED.replace("2106.07447v1", "2106.07447v2");
let v2 = into_discovery_record(parse_arxiv_feed(feed.as_bytes()).unwrap().remove(0));
feed = SAMPLE_FEED.to_string();
let v1 = into_discovery_record(parse_arxiv_feed(feed.as_bytes()).unwrap().remove(0));
let key = |r: &DiscoveryRecord| r.fields.keys().find(|k| k.starts_with("cids/")).cloned();
assert_ne!(key(&v1), key(&v2), "v2 is a different file, so a different CID");
}
// -- keyword-less queries browse instead of asking arXiv for `all:*` -------
#[test]
fn keywords_search_the_all_field_unchanged() {
let url = search_url("https://export.arxiv.org/", "transformer attention", 20);
assert_eq!(
url,
"https://export.arxiv.org/api/query?search_query=all:transformer+attention&start=0&max_results=20"
);
assert!(!url.contains("sortBy"), "a keyword search keeps arXiv's relevance order");
}
#[test]
fn an_empty_query_browses_newest_first() {
for q in ["", " ", "*", " * "] {
let url = search_url("https://export.arxiv.org", q, 30);
assert!(url.contains("cat:cs.*"), "{q:?} -> {url}");
assert!(url.contains("sortBy=submittedDate&sortOrder=descending"), "{q:?} -> {url}");
assert!(url.ends_with("max_results=30"), "{q:?} -> {url}");
assert!(!url.contains("all:"), "the all:* form returns HTTP 500: {q:?} -> {url}");
}
}
#[tokio::test]
async fn a_keywordless_query_returns_papers_rather_than_nothing() {
use wiremock::matchers::query_param_contains;
let server = MockServer::start().await;
// The form that used to be sent. arXiv answers it with a 500; if the
// plugin ever goes back to it this mock is hit and the test fails.
Mock::given(method("GET"))
.and(path("/api/query"))
.and(query_param("search_query", "all:*"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/query"))
.and(query_param("sortBy", "submittedDate"))
.and(query_param_contains("search_query", "cat:cs.*"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(SAMPLE_FEED)
.insert_header("content-type", "application/atom+xml"),
)
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let records = plugin
.handle_query(&GatewayQuery::from_free_text(""), 10)
.await
.expect("a keyword-less query must succeed");
assert_eq!(records.len(), 2);
assert_eq!(
records[0].fields.get("contentKind").map(String::as_str),
Some("paper")
);
}
}