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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user