feat(openreview): add author command for ID-explicit publication lookup (#1365)

* feat(openreview): add author command for ID-explicit publication lookup

Closes the missing leaf in the openreview adapter. Among the public-strategy
academic adapters, dblp and arxiv both already ship an `author` command for
ID-explicit publication lookup; openreview only had `search` (full-text),
`paper` (detail by note id), `reviews` (thread by forum id) and `venue`
(listing by invitation / venue text). There was no way to ask "give me every
submission this author put on OpenReview, newest first."

`openreview author <profile>`:
  - takes a canonical profile id (`~First_LastN`); validated by
    `requireProfileId` so a dblp PID or a bare name fails before any
    network call,
  - hits `/notes?content.authorids=~<id>&limit=<n>&sort=cdate:desc`,
  - returns rank-ordered rows with the same shape as `openreview search`
    (id / title / authors / venue / pdate / url),
  - throws `EmptyResultError` when the profile has no public submissions
    instead of returning an empty list,
  - inherits the typed-error envelope from `openreviewFetch` so network
    failure, non-200, malformed JSON, and in-band error envelopes all
    surface as `CommandExecutionError`.

Tests: 6 new `it` blocks plus 1 updated registration test in
`clis/openreview/openreview.test.js`.

  - `requireProfileId` (1 block, 9 assertions): accepts canonical
    `~First_LastN`, `~Bo_Liu17`, and a multi-segment middle-name id;
    rejects empty, whitespace, missing tilde, missing trailing number,
    embedded space, and a dblp-style PID.
  - 5 author runtime cases covering pre-network ArgumentError, empty
    result, non-200, fetch network error, and the happy path with a
    request-shape assertion (`content.authorids` filter + `cdate:desc`
    sort).
  - Registration test extended to expect five commands and lock the new
    `columns` contract.

Manifest auto-regenerated to register the new command.

Live-verified end to end against `~Yoshua_Bengio1`: the most recent ICLR
2026 workshop submissions return with the expected fields. A malformed
profile is rejected before any HTTP call. A nonexistent profile yields
`EMPTY_RESULT`.

* fix(openreview): accept real profile id slugs

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
Benjamin Liu
2026-05-11 02:53:46 +09:00
committed by GitHub
parent 2034e90337
commit 674f0e1105
6 changed files with 200 additions and 4 deletions
+37
View File
@@ -17181,6 +17181,43 @@
"modulePath": "openfda/food-recall.js",
"sourceFile": "openfda/food-recall.js"
},
{
"site": "openreview",
"name": "author",
"description": "List OpenReview submissions by an author profile id (newest first)",
"access": "read",
"domain": "openreview.net",
"strategy": "public",
"browser": false,
"args": [
{
"name": "profile",
"type": "str",
"required": true,
"positional": true,
"help": "OpenReview profile id (e.g. \"~Yoshua_Bengio1\"). Find it on the author profile URL on openreview.net."
},
{
"name": "limit",
"type": "int",
"default": 50,
"required": false,
"help": "Max submissions (1-1000)"
}
],
"columns": [
"rank",
"id",
"title",
"authors",
"venue",
"pdate",
"url"
],
"type": "js",
"modulePath": "openreview/author.js",
"sourceFile": "openreview/author.js"
},
{
"site": "openreview",
"name": "paper",
+58
View File
@@ -0,0 +1,58 @@
/**
* OpenReview submissions by author profile id (newest first).
*
* Pairs with `openreview paper <id>` and `openreview reviews <id>` for the
* full read-side workflow: list every submission an author put on
* OpenReview, then drill into a specific paper or its review thread.
*
* Uses the public v2 endpoint `/notes?content.authorids=~<profile-id>`,
* which returns the same note shape as `paper`, sorted by `cdate:desc`.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import {
noteToRow,
openreviewFetch,
requireBoundedInt,
requireProfileId,
} from './utils.js';
cli({
site: 'openreview',
name: 'author',
access: 'read',
description: 'List OpenReview submissions by an author profile id (newest first)',
domain: 'openreview.net',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'profile', positional: true, required: true, help: 'OpenReview profile id (e.g. "~Yoshua_Bengio1"). Find it on the author profile URL on openreview.net.' },
{ name: 'limit', type: 'int', default: 50, help: 'Max submissions (1-1000)' },
],
columns: ['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url'],
func: async (args) => {
const profile = requireProfileId(args.profile);
const limit = requireBoundedInt(args.limit, 50, 1000);
const path = `/notes?content.authorids=${encodeURIComponent(profile)}&limit=${limit}&sort=cdate:desc`;
const json = await openreviewFetch(path, `openreview author ${profile}`);
const notes = Array.isArray(json?.notes) ? json.notes : [];
if (!notes.length) {
throw new EmptyResultError(
'openreview author',
`No OpenReview submissions found for profile "${profile}". Confirm the id format (~First_LastN) and that the profile has public submissions.`,
);
}
return notes.slice(0, limit).map((note, i) => {
const row = noteToRow(note);
return {
rank: i + 1,
id: row.id,
title: row.title,
authors: row.authors,
venue: row.venue,
pdate: row.pdate,
url: row.url,
};
});
},
});
+83 -1
View File
@@ -8,11 +8,13 @@ import {
requireBoundedInt,
requireForumId,
requireNonNegativeInt,
requireProfileId,
} from './utils.js';
import './search.js';
import './venue.js';
import './paper.js';
import './reviews.js';
import './author.js';
const SAMPLE_NOTE = {
id: 'abc123XYZ_',
@@ -37,21 +39,24 @@ afterEach(() => {
});
describe('openreview adapter', () => {
it('registers all four commands with the expected columns', () => {
it('registers all five commands with the expected columns', () => {
const search = getRegistry().get('openreview/search');
const venue = getRegistry().get('openreview/venue');
const paper = getRegistry().get('openreview/paper');
const reviews = getRegistry().get('openreview/reviews');
const author = getRegistry().get('openreview/author');
expect(search).toBeDefined();
expect(venue).toBeDefined();
expect(paper).toBeDefined();
expect(reviews).toBeDefined();
expect(author).toBeDefined();
expect(search.columns).toEqual(['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url']);
expect(venue.columns).toEqual(['rank', 'id', 'title', 'authors', 'keywords', 'primary_area', 'pdate', 'pdf', 'url']);
expect(paper.columns).toEqual(['id', 'title', 'authors', 'keywords', 'venue', 'venueid', 'primary_area', 'abstract', 'pdate', 'pdf', 'url']);
expect(reviews.columns).toEqual(['type', 'author', 'rating', 'confidence', 'text']);
expect(author.columns).toEqual(['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url']);
});
it('noteToRow extracts every wrapped v2 field, joins lists, and builds absolute URLs', () => {
@@ -109,6 +114,30 @@ describe('openreview adapter', () => {
expect(() => requireForumId('short')).toThrow('not a valid forum id');
});
it('requireProfileId accepts canonical profile ids and rejects malformed input', () => {
expect(requireProfileId('~Yoshua_Bengio1')).toBe('~Yoshua_Bengio1');
expect(requireProfileId('~Bo_Liu17')).toBe('~Bo_Liu17');
expect(requireProfileId('~Geoffrey_Everest_Hinton1')).toBe('~Geoffrey_Everest_Hinton1');
expect(requireProfileId('~Anne-Christin_Hauschild1')).toBe('~Anne-Christin_Hauschild1');
expect(requireProfileId('~S.Aruna_Deepthi1')).toBe('~S.Aruna_Deepthi1');
expect(requireProfileId('~Andrzej_Czyżewski1')).toBe('~Andrzej_Czyżewski1');
expect(requireProfileId('~August_Bøgh_Rønberg1')).toBe('~August_Bøgh_Rønberg1');
expect(requireProfileId('~Wagner_Meira_Jr.1')).toBe('~Wagner_Meira_Jr.1');
expect(() => requireProfileId('')).toThrow('required');
expect(() => requireProfileId(' ')).toThrow('required');
// Missing leading tilde.
expect(() => requireProfileId('Bo_Liu17')).toThrow('not a valid profile id');
// Missing trailing disambiguator number.
expect(() => requireProfileId('~Bo_Liu')).toThrow('not a valid profile id');
// Spaces / non-letter characters break the underscore-joined name.
expect(() => requireProfileId('~Bo Liu1')).toThrow('not a valid profile id');
// dblp-style PID must not silently fall through.
expect(() => requireProfileId('56/953')).toThrow('not a valid profile id');
expect(() => requireProfileId('~Bo_Liu1?evil=1')).toThrow('not a valid profile id');
expect(() => requireProfileId('~Bo/Liu1')).toThrow('not a valid profile id');
expect(() => requireProfileId('~123')).toThrow('not a valid profile id');
});
it('formatDate handles ms-since-epoch and rejects invalid input', () => {
expect(formatDate(1727524853394)).toBe('2024-09-28');
expect(formatDate(0)).toBe('');
@@ -342,4 +371,57 @@ describe('openreview adapter', () => {
expect(rows[1].text.length).toBe(500);
expect(rows[1].text.endsWith('...')).toBe(true);
});
it('author rejects invalid profile ids before calling the network', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const author = getRegistry().get('openreview/author');
await expect(author.func({ profile: 'Bo_Liu17', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
await expect(author.func({ profile: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('author throws EmptyResult when the profile has no submissions', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ notes: [] }), { status: 200 })));
const author = getRegistry().get('openreview/author');
await expect(author.func({ profile: '~No_Submissions1', limit: 5 })).rejects.toMatchObject({ code: 'EMPTY_RESULT' });
});
it('author wraps non-200 responses as CommandExecutionError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
const author = getRegistry().get('openreview/author');
await expect(author.func({ profile: '~Bo_Liu17', limit: 5 })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
});
it('author wraps fetch network errors as CommandExecutionError', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNRESET')));
const author = getRegistry().get('openreview/author');
await expect(author.func({ profile: '~Bo_Liu17', limit: 5 })).rejects.toMatchObject({
code: 'COMMAND_EXEC',
message: expect.stringContaining('Network failure'),
});
});
it('author hits /notes?content.authorids and returns rank-ordered rows', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ notes: [SAMPLE_NOTE, SAMPLE_NOTE] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const author = getRegistry().get('openreview/author');
const rows = await author.func({ profile: '~Bo_Liu17', limit: 50 });
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({
rank: 1,
id: 'abc123XYZ_',
title: 'Test Paper Title with spaces',
authors: 'Alice Smith, Bob Jones',
venue: 'ICLR 2024 oral',
pdate: '2024-09-28',
url: 'https://openreview.net/forum?id=abc123XYZ_',
});
expect(rows[1].rank).toBe(2);
// Confirm the request shape: canonical authorids filter + cdate sort.
const url = fetchMock.mock.calls[0][0];
expect(url).toContain('content.authorids=');
expect(url).toContain(encodeURIComponent('~Bo_Liu17'));
expect(url).toContain('sort=cdate:desc');
});
});
+14
View File
@@ -55,6 +55,20 @@ export function requireForumId(value, label = 'id') {
return id;
}
/** OpenReview profile ids are `~...N` slugs and may include dots, hyphens, and Unicode letters. */
const PROFILE_ID_PATTERN = /^~(?=.*\p{L})[\p{L}\p{M}0-9._-]+\d+$/u;
export function requireProfileId(value, label = 'profile') {
const id = String(value ?? '').trim();
if (!id) {
throw new ArgumentError(`openreview ${label} is required`);
}
if (!PROFILE_ID_PATTERN.test(id)) {
throw new ArgumentError(`openreview ${label} "${value}" is not a valid profile id (expected "~First_Last1" or similar; find it on the author's openreview.net profile URL)`);
}
return id;
}
/** Wrap fetch + json with typed errors so failures never look like empty results. */
export async function openreviewFetch(path, label) {
const url = `${OPENREVIEW_API}${path}`;
+7 -2
View File
@@ -2,7 +2,7 @@
**Mode**: 🌐 Public · **Domain**: `openreview.net`
OpenReview is the open peer-review platform used by ICLR, COLM, NeurIPS workshops, TMLR, and many other ML venues. The v2 API exposes everyone-readable submissions, reviews, and decisions without authentication, so all four commands run without a browser.
OpenReview is the open peer-review platform used by ICLR, COLM, NeurIPS workshops, TMLR, and many other ML venues. The v2 API exposes everyone-readable submissions, reviews, and decisions without authentication, so all five commands run without a browser.
## Commands
@@ -10,6 +10,7 @@ OpenReview is the open peer-review platform used by ICLR, COLM, NeurIPS workshop
|---------|-------------|
| `opencli openreview search <query>` | Full-text search across all OpenReview papers |
| `opencli openreview venue <venue>` | List papers at a venue (e.g. `"ICLR 2024 oral"` or full invitation id) |
| `opencli openreview author <profile>` | List submissions by an author profile id (e.g. `"~Yoshua_Bengio1"`), newest first |
| `opencli openreview paper <id>` | Show full metadata (incl. abstract) for a single paper |
| `opencli openreview reviews <forum>` | Show paper + threaded reviews/decisions/comments |
@@ -25,6 +26,9 @@ opencli openreview venue "ICLR 2024 oral" --limit 20
# Browse a venue by full invitation id (use this when display names overlap)
opencli openreview venue "ICLR.cc/2025/Conference/-/Submission" --limit 50 --offset 0
# Every submission by an author profile id (find it on the author's openreview.net profile URL)
opencli openreview author "~Yoshua_Bengio1" --limit 20
# Single-paper detail (full abstract)
opencli openreview paper KS8mIvetg2
@@ -41,10 +45,11 @@ opencli openreview search "LLM" -f json
|---------|---------|
| `search` | `rank, id, title, authors, venue, pdate, url` |
| `venue` | `rank, id, title, authors, keywords, primary_area, pdate, pdf, url` |
| `author` | `rank, id, title, authors, venue, pdate, url` |
| `paper` | `id, title, authors, keywords, venue, venueid, primary_area, abstract, pdate, pdf, url` |
| `reviews` | `type, author, rating, confidence, text` |
The `id` returned by `search`/`venue` round-trips into `paper`/`reviews` — it is the OpenReview note id (also the `forum` id for top-level submissions). `pdf` is normalized to an absolute `https://openreview.net/pdf/...` URL.
The `id` returned by `search`/`venue`/`author` round-trips into `paper`/`reviews` — it is the OpenReview note id (also the `forum` id for top-level submissions). `pdf` is normalized to an absolute `https://openreview.net/pdf/...` URL.
## `reviews` Output
+1 -1
View File
@@ -99,7 +99,7 @@ Run `opencli list` for the live registry.
| **[arxiv](./browser/arxiv.md)** | `search` `paper` | 🌐 Public |
| **[dblp](./browser/dblp.md)** | `search` `author` `paper` `venue` | 🌐 Public |
| **[pubmed](./browser/pubmed.md)** | `search` `article` `author` `citations` `related` | 🌐 Public |
| **[openreview](./browser/openreview.md)** | `search` `venue` `paper` `reviews` | 🌐 Public |
| **[openreview](./browser/openreview.md)** | `search` `venue` `author` `paper` `reviews` | 🌐 Public |
| **[paperreview](./browser/paperreview.md)** | `submit` `review` `feedback` | 🌐 Public |
| **[barchart](./browser/barchart.md)** | `quote` `options` `greeks` `flow` | 🌐 Public |
| **[binance](./browser/binance.md)** | `price` `prices` `ticker` `pairs` `trades` `depth` `asks` `klines` `top` `gainers` `losers` | 🌐 Public |