diff --git a/README.md b/README.md index d8218d0f..d4b76740 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Run `opencli list` for the live registry. | **devto** | `top` `tag` `user` | Public | | **dictionary** | `search` `synonyms` `examples` | Public | | **arxiv** | `search` `paper` | Public | +| **paperreview** | `submit` `review` `feedback` | Public | | **wikipedia** | `search` `summary` `random` `trending` | Public | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public | | **jd** | `item` | Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index 7ad8b462..c9dc03c6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -148,6 +148,7 @@ npm install -g @jackwener/opencli@latest | **devto** | `top` `tag` `user` | 公开 | | **dictionary** | `search` `synonyms` `examples` | 公开 | | **arxiv** | `search` `paper` | 公开 | +| **paperreview** | `submit` `review` `feedback` | 公开 | | **wikipedia** | `search` `summary` `random` `trending` | 公开 | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API | | **jd** | `item` | 浏览器 | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e73ca4d2..c813e071 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -99,6 +99,7 @@ export default defineConfig({ { text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' }, { text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' }, { text: 'arXiv', link: '/adapters/browser/arxiv' }, + { text: 'paperreview.ai', link: '/adapters/browser/paperreview' }, { text: 'Barchart', link: '/adapters/browser/barchart' }, { text: 'Hugging Face', link: '/adapters/browser/hf' }, { text: 'Sina Finance', link: '/adapters/browser/sinafinance' }, diff --git a/docs/adapters/browser/paperreview.md b/docs/adapters/browser/paperreview.md new file mode 100644 index 00000000..c632f331 --- /dev/null +++ b/docs/adapters/browser/paperreview.md @@ -0,0 +1,43 @@ +# paperreview.ai + +**Mode**: 🌐 Public · **Domain**: `paperreview.ai` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli paperreview submit` | Submit a PDF to paperreview.ai for review | +| `opencli paperreview review` | Fetch a review by token | +| `opencli paperreview feedback` | Send feedback on a completed review | + +## Usage Examples + +```bash +# Validate a local PDF without uploading it +opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --dry-run true + +# Request an upload slot but stop before the actual upload +opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --prepare-only true + +# Submit a paper for review +opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL -f json + +# Check the review status or fetch the final review +opencli paperreview review tok_123 -f json + +# Submit feedback on the review quality +opencli paperreview feedback tok_123 --helpfulness 4 --critical-error no --actionable-suggestions yes +``` + +## Prerequisites + +- No browser required — uses public paperreview.ai endpoints +- The input file must be a local `.pdf` +- paperreview.ai currently rejects files larger than `10MB` +- `submit` requires `--email`; `--venue` is optional + +## Notes + +- `submit` returns both the review token and the review URL when submission succeeds +- `review` returns `processing` until the paperreview.ai result is ready +- `feedback` expects `yes` / `no` values for `--critical-error` and `--actionable-suggestions` diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 5fbe2b6e..92812442 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -57,6 +57,7 @@ Run `opencli list` for the live registry. | **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public | | **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public | | **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public | +| **[paperreview](/adapters/browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public | | **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public | | **[hf](/adapters/browser/hf)** | `top` | 🌐 Public | | **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public | diff --git a/src/clis/paperreview/commands.test.ts b/src/clis/paperreview/commands.test.ts new file mode 100644 index 00000000..fe4bcce3 --- /dev/null +++ b/src/clis/paperreview/commands.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockReadPdfFile, + mockRequestJson, + mockUploadPresignedPdf, + mockValidateHelpfulness, + mockParseYesNo, +} = vi.hoisted(() => ({ + mockReadPdfFile: vi.fn(), + mockRequestJson: vi.fn(), + mockUploadPresignedPdf: vi.fn(), + mockValidateHelpfulness: vi.fn(), + mockParseYesNo: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + readPdfFile: mockReadPdfFile, + requestJson: mockRequestJson, + uploadPresignedPdf: mockUploadPresignedPdf, + validateHelpfulness: mockValidateHelpfulness, + parseYesNo: mockParseYesNo, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './submit.js'; +import './review.js'; +import './feedback.js'; + +describe('paperreview submit command', () => { + beforeEach(() => { + mockReadPdfFile.mockReset(); + mockRequestJson.mockReset(); + mockUploadPresignedPdf.mockReset(); + mockValidateHelpfulness.mockReset(); + mockParseYesNo.mockReset(); + }); + + it('supports dry run without any remote request', async () => { + const cmd = getRegistry().get('paperreview/submit'); + expect(cmd?.func).toBeTypeOf('function'); + + mockReadPdfFile.mockResolvedValue({ + buffer: Buffer.from('%PDF'), + fileName: 'paper.pdf', + resolvedPath: '/tmp/paper.pdf', + sizeBytes: 4096, + }); + + const result = await cmd!.func!(null as any, { + pdf: './paper.pdf', + email: 'wang2629651228@gmail.com', + venue: 'RAL', + 'dry-run': true, + 'prepare-only': false, + }); + + expect(mockRequestJson).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'dry-run', + file: 'paper.pdf', + email: 'wang2629651228@gmail.com', + venue: 'RAL', + }); + }); + + it('treats explicit false flags as false and performs the real submission path', async () => { + const cmd = getRegistry().get('paperreview/submit'); + expect(cmd?.func).toBeTypeOf('function'); + + mockReadPdfFile.mockResolvedValue({ + buffer: Buffer.from('%PDF'), + fileName: 'paper.pdf', + resolvedPath: '/tmp/paper.pdf', + sizeBytes: 4096, + }); + mockRequestJson + .mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + payload: { + success: true, + presigned_url: 'https://upload.example.com', + presigned_fields: { key: 'uploads/paper.pdf' }, + s3_key: 'uploads/paper.pdf', + }, + }) + .mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + payload: { + success: true, + token: 'tok_false', + message: 'Submission accepted', + }, + }); + + const result = await cmd!.func!(null as any, { + pdf: './paper.pdf', + email: 'wang2629651228@gmail.com', + venue: 'RAL', + 'dry-run': false, + 'prepare-only': false, + }); + + expect(mockUploadPresignedPdf).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + status: 'submitted', + token: 'tok_false', + review_url: 'https://paperreview.ai/review?token=tok_false', + }); + }); + + it('supports prepare-only without uploading the PDF', async () => { + const cmd = getRegistry().get('paperreview/submit'); + expect(cmd?.func).toBeTypeOf('function'); + + mockReadPdfFile.mockResolvedValue({ + buffer: Buffer.from('%PDF'), + fileName: 'paper.pdf', + resolvedPath: '/tmp/paper.pdf', + sizeBytes: 4096, + }); + mockRequestJson.mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + payload: { + success: true, + presigned_url: 'https://upload.example.com', + presigned_fields: { key: 'uploads/paper.pdf' }, + s3_key: 'uploads/paper.pdf', + }, + }); + + const result = await cmd!.func!(null as any, { + pdf: './paper.pdf', + email: 'wang2629651228@gmail.com', + venue: 'RAL', + 'dry-run': false, + 'prepare-only': true, + }); + + expect(mockUploadPresignedPdf).not.toHaveBeenCalled(); + expect(mockRequestJson).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + status: 'prepared', + s3_key: 'uploads/paper.pdf', + }); + }); + + it('requests an upload URL, uploads the PDF, and confirms the submission', async () => { + const cmd = getRegistry().get('paperreview/submit'); + expect(cmd?.func).toBeTypeOf('function'); + + mockReadPdfFile.mockResolvedValue({ + buffer: Buffer.from('%PDF'), + fileName: 'paper.pdf', + resolvedPath: '/tmp/paper.pdf', + sizeBytes: 4096, + }); + mockRequestJson + .mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + payload: { + success: true, + presigned_url: 'https://upload.example.com', + presigned_fields: { key: 'uploads/paper.pdf' }, + s3_key: 'uploads/paper.pdf', + }, + }) + .mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + payload: { + success: true, + token: 'tok_123', + message: 'Submission accepted', + }, + }); + + const result = await cmd!.func!(null as any, { + pdf: './paper.pdf', + email: 'wang2629651228@gmail.com', + venue: 'RAL', + 'dry-run': false, + 'prepare-only': false, + }); + + expect(mockRequestJson).toHaveBeenNthCalledWith(1, '/api/get-upload-url', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + filename: 'paper.pdf', + venue: 'RAL', + }), + })); + expect(mockUploadPresignedPdf).toHaveBeenCalledWith( + 'https://upload.example.com', + expect.objectContaining({ fileName: 'paper.pdf' }), + expect.objectContaining({ s3_key: 'uploads/paper.pdf' }), + ); + expect(mockRequestJson).toHaveBeenNthCalledWith(2, '/api/confirm-upload', expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + })); + expect(result).toMatchObject({ + status: 'submitted', + token: 'tok_123', + review_url: 'https://paperreview.ai/review?token=tok_123', + }); + }); +}); + +describe('paperreview review command', () => { + beforeEach(() => { + mockRequestJson.mockReset(); + }); + + it('returns processing status when the review is not ready yet', async () => { + const cmd = getRegistry().get('paperreview/review'); + expect(cmd?.func).toBeTypeOf('function'); + + mockRequestJson.mockResolvedValue({ + response: { status: 202 } as Response, + payload: { detail: 'Review is still processing.' }, + }); + + const result = await cmd!.func!(null as any, { token: 'tok_123' }); + + expect(result).toMatchObject({ + status: 'processing', + token: 'tok_123', + review_url: 'https://paperreview.ai/review?token=tok_123', + message: 'Review is still processing.', + }); + }); +}); + +describe('paperreview feedback command', () => { + beforeEach(() => { + mockRequestJson.mockReset(); + mockValidateHelpfulness.mockReset(); + mockParseYesNo.mockReset(); + }); + + it('normalizes feedback inputs and posts them to the API', async () => { + const cmd = getRegistry().get('paperreview/feedback'); + expect(cmd?.func).toBeTypeOf('function'); + + mockValidateHelpfulness.mockReturnValue(4); + mockParseYesNo.mockReturnValueOnce(true).mockReturnValueOnce(false); + mockRequestJson.mockResolvedValue({ + response: { ok: true, status: 200 } as Response, + payload: { message: 'Thanks for the feedback.' }, + }); + + const result = await cmd!.func!(null as any, { + token: 'tok_123', + helpfulness: 4, + 'critical-error': 'yes', + 'actionable-suggestions': 'no', + 'additional-comments': 'Helpful summary, but the contribution section needs more detail.', + }); + + expect(mockRequestJson).toHaveBeenCalledWith('/api/feedback/tok_123', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + helpfulness: 4, + has_critical_error: true, + has_actionable_suggestions: false, + additional_comments: 'Helpful summary, but the contribution section needs more detail.', + }), + }); + expect(result).toMatchObject({ + status: 'submitted', + token: 'tok_123', + helpfulness: 4, + critical_error: true, + actionable_suggestions: false, + message: 'Thanks for the feedback.', + }); + }); +}); diff --git a/src/clis/paperreview/feedback.ts b/src/clis/paperreview/feedback.ts new file mode 100644 index 00000000..0f69cd35 --- /dev/null +++ b/src/clis/paperreview/feedback.ts @@ -0,0 +1,64 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { + PAPERREVIEW_DOMAIN, + ensureSuccess, + parseYesNo, + requestJson, + summarizeFeedback, + validateHelpfulness, +} from './utils.js'; + +cli({ + site: 'paperreview', + name: 'feedback', + description: 'Submit feedback for a paperreview.ai review token', + domain: PAPERREVIEW_DOMAIN, + strategy: Strategy.PUBLIC, + browser: false, + timeoutSeconds: 30, + args: [ + { name: 'token', positional: true, required: true, help: 'Review token returned by paperreview.ai' }, + { name: 'helpfulness', required: true, type: 'int', help: 'Helpfulness score from 1 to 5' }, + { name: 'critical-error', required: true, choices: ['yes', 'no'], help: 'Whether the review contains a critical error' }, + { name: 'actionable-suggestions', required: true, choices: ['yes', 'no'], help: 'Whether the review contains actionable suggestions' }, + { name: 'additional-comments', help: 'Optional free-text feedback' }, + ], + columns: ['status', 'token', 'helpfulness', 'critical_error', 'actionable_suggestions', 'message'], + func: async (_page, kwargs) => { + const token = String(kwargs.token ?? '').trim(); + if (!token) { + throw new CliError('ARGUMENT', 'A review token is required.'); + } + + const helpfulness = validateHelpfulness(kwargs.helpfulness); + const criticalError = parseYesNo(kwargs['critical-error'], 'critical-error'); + const actionableSuggestions = parseYesNo(kwargs['actionable-suggestions'], 'actionable-suggestions'); + const comments = String(kwargs['additional-comments'] ?? '').trim(); + + const payload: Record = { + helpfulness, + has_critical_error: criticalError, + has_actionable_suggestions: actionableSuggestions, + }; + if (comments) { + payload.additional_comments = comments; + } + + const { response, payload: responsePayload } = await requestJson(`/api/feedback/${encodeURIComponent(token)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + ensureSuccess(response, responsePayload, 'Failed to submit feedback.', 'Check the token and try again'); + + return summarizeFeedback({ + token, + helpfulness, + criticalError, + actionableSuggestions, + comments, + payload: responsePayload, + }); + }, +}); diff --git a/src/clis/paperreview/review.ts b/src/clis/paperreview/review.ts new file mode 100644 index 00000000..64dc4e26 --- /dev/null +++ b/src/clis/paperreview/review.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { + PAPERREVIEW_DOMAIN, + buildReviewUrl, + ensureSuccess, + requestJson, + summarizeReview, +} from './utils.js'; + +cli({ + site: 'paperreview', + name: 'review', + description: 'Fetch a paperreview.ai review by token', + domain: PAPERREVIEW_DOMAIN, + strategy: Strategy.PUBLIC, + browser: false, + timeoutSeconds: 30, + args: [ + { name: 'token', positional: true, required: true, help: 'Review token returned by paperreview.ai' }, + ], + columns: ['status', 'title', 'venue', 'numerical_score', 'has_feedback', 'review_url'], + func: async (_page, kwargs) => { + const token = String(kwargs.token ?? '').trim(); + if (!token) { + throw new CliError('ARGUMENT', 'A review token is required.'); + } + + const { response, payload } = await requestJson(`/api/review/${encodeURIComponent(token)}`); + + if (response.status === 202) { + return { + status: 'processing', + token, + review_url: buildReviewUrl(token), + title: '', + venue: '', + numerical_score: '', + has_feedback: '', + message: typeof payload === 'object' && payload ? payload.detail ?? 'Review is still processing.' : 'Review is still processing.', + }; + } + + ensureSuccess(response, payload, 'Failed to fetch the review.', 'Check the token and try again'); + return summarizeReview(token, payload); + }, +}); diff --git a/src/clis/paperreview/submit.ts b/src/clis/paperreview/submit.ts new file mode 100644 index 00000000..b6cfbeac --- /dev/null +++ b/src/clis/paperreview/submit.ts @@ -0,0 +1,119 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { + PAPERREVIEW_DOMAIN, + ensureApiSuccess, + ensureSuccess, + normalizeVenue, + readPdfFile, + requestJson, + summarizeSubmission, + uploadPresignedPdf, +} from './utils.js'; + +cli({ + site: 'paperreview', + name: 'submit', + description: 'Submit a PDF to paperreview.ai for review', + domain: PAPERREVIEW_DOMAIN, + strategy: Strategy.PUBLIC, + browser: false, + timeoutSeconds: 120, + args: [ + { name: 'pdf', positional: true, required: true, help: 'Path to the paper PDF' }, + { name: 'email', required: true, help: 'Email address for the submission' }, + { name: 'venue', help: 'Optional target venue such as ICLR or NeurIPS' }, + { name: 'dry-run', type: 'bool', default: false, help: 'Validate the input and stop before remote submission' }, + { name: 'prepare-only', type: 'bool', default: false, help: 'Request an upload slot but stop before uploading the PDF' }, + ], + columns: ['status', 'file', 'email', 'venue', 'token', 'review_url', 'message'], + footerExtra: (kwargs) => { + if (kwargs['dry-run'] === true) return 'dry run only'; + if (kwargs['prepare-only'] === true) return 'prepared only'; + return undefined; + }, + func: async (_page, kwargs) => { + const pdfFile = await readPdfFile(kwargs.pdf); + const email = String(kwargs.email ?? '').trim(); + const venue = normalizeVenue(kwargs.venue); + const dryRun = kwargs['dry-run'] === true; + const prepareOnly = kwargs['prepare-only'] === true; + + if (!email) { + throw new CliError('ARGUMENT', 'An email address is required.', 'Pass --email
'); + } + + if (dryRun) { + return summarizeSubmission({ + pdfFile, + email, + venue, + message: 'Input validation passed. No remote request was sent.', + dryRun: true, + }); + } + + const { response: uploadUrlResponse, payload: uploadUrlPayload } = await requestJson('/api/get-upload-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: pdfFile.fileName, + venue, + }), + }); + ensureSuccess( + uploadUrlResponse, + uploadUrlPayload, + 'Failed to request an upload URL.', + 'Try again in a moment', + ); + ensureApiSuccess( + uploadUrlPayload, + 'paperreview.ai did not return a usable upload URL.', + 'Try again in a moment', + ); + + if (prepareOnly) { + return summarizeSubmission({ + pdfFile, + email, + venue, + message: 'Upload slot prepared. The PDF was not uploaded and no submission was confirmed.', + s3Key: uploadUrlPayload.s3_key, + status: 'prepared', + }); + } + + await uploadPresignedPdf(uploadUrlPayload.presigned_url, pdfFile, uploadUrlPayload); + + const confirmForm = new FormData(); + confirmForm.append('s3_key', uploadUrlPayload.s3_key); + confirmForm.append('venue', venue); + confirmForm.append('email', email); + + const { response: confirmResponse, payload: confirmPayload } = await requestJson('/api/confirm-upload', { + method: 'POST', + body: confirmForm, + }); + ensureSuccess( + confirmResponse, + confirmPayload, + 'Failed to confirm the upload with paperreview.ai.', + 'Try again in a moment', + ); + ensureApiSuccess( + confirmPayload, + 'paperreview.ai did not confirm the submission.', + 'Try again in a moment', + ); + + return summarizeSubmission({ + pdfFile, + email, + venue, + token: confirmPayload.token, + message: confirmPayload.message, + s3Key: uploadUrlPayload.s3_key, + }); + }, +}); diff --git a/src/clis/paperreview/utils.test.ts b/src/clis/paperreview/utils.test.ts new file mode 100644 index 00000000..51801be7 --- /dev/null +++ b/src/clis/paperreview/utils.test.ts @@ -0,0 +1,68 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CliError } from '../../errors.js'; +import { + MAX_PDF_BYTES, + buildReviewUrl, + parseYesNo, + readPdfFile, + requestJson, + validateHelpfulness, +} from './utils.js'; + +describe('paperreview utils', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('builds review URLs from the token', () => { + expect(buildReviewUrl('tok 123')).toBe('https://paperreview.ai/review?token=tok%20123'); + }); + + it('parses yes/no flags', () => { + expect(parseYesNo('yes', 'critical-error')).toBe(true); + expect(parseYesNo('NO', 'critical-error')).toBe(false); + }); + + it('rejects invalid yes/no flags with CliError', () => { + expect(() => parseYesNo('maybe', 'critical-error')).toThrow(CliError); + expect(() => parseYesNo('maybe', 'critical-error')).toThrow('"critical-error" must be either "yes" or "no".'); + }); + + it('validates helpfulness scores', () => { + expect(validateHelpfulness(5)).toBe(5); + expect(() => validateHelpfulness(0)).toThrow(CliError); + }); + + it('reads a valid PDF file and returns metadata', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperreview-')); + const pdfPath = path.join(tempDir, 'sample.pdf'); + const pdfBytes = Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(256, 1)]); + await fs.writeFile(pdfPath, pdfBytes); + + const result = await readPdfFile(pdfPath); + + expect(result.fileName).toBe('sample.pdf'); + expect(result.resolvedPath).toBe(pdfPath); + expect(result.sizeBytes).toBe(pdfBytes.length); + expect(result.buffer.equals(pdfBytes)).toBe(true); + }); + + it('rejects PDFs larger than the paperreview.ai size limit', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperreview-')); + const pdfPath = path.join(tempDir, 'large.pdf'); + await fs.writeFile(pdfPath, Buffer.alloc(MAX_PDF_BYTES + 1, 1)); + + await expect(readPdfFile(pdfPath)).rejects.toThrow(CliError); + await expect(readPdfFile(pdfPath)).rejects.toThrow('The PDF is larger than paperreview.ai\'s 10MB limit.'); + }); + + it('normalizes fetch failures into CliError', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up'))); + + await expect(requestJson('/api/review/token')).rejects.toThrow(CliError); + await expect(requestJson('/api/review/token')).rejects.toThrow('Unable to reach paperreview.ai'); + }); +}); diff --git a/src/clis/paperreview/utils.ts b/src/clis/paperreview/utils.ts new file mode 100644 index 00000000..39d332df --- /dev/null +++ b/src/clis/paperreview/utils.ts @@ -0,0 +1,276 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { CliError, getErrorMessage } from '../../errors.js'; + +export const PAPERREVIEW_DOMAIN = 'paperreview.ai'; +export const PAPERREVIEW_BASE_URL = `https://${PAPERREVIEW_DOMAIN}`; +export const MAX_PDF_BYTES = 10 * 1024 * 1024; + +export interface PaperreviewPdfFile { + buffer: Buffer; + fileName: string; + resolvedPath: string; + sizeBytes: number; +} + +export interface PaperreviewRequestResult { + response: Response; + payload: any; +} + +function asText(value: unknown): string { + return value == null ? '' : String(value); +} + +function trimOrEmpty(value: unknown): string { + return asText(value).trim(); +} + +function toErrorMessage(payload: unknown, fallback: string): string { + if (payload && typeof payload === 'object') { + const detail = trimOrEmpty((payload as Record).detail); + const message = trimOrEmpty((payload as Record).message); + const error = trimOrEmpty((payload as Record).error); + if (detail) return detail; + if (message) return message; + if (error) return error; + } + const text = trimOrEmpty(payload); + return text || fallback; +} + +export function buildReviewUrl(token: string): string { + return `${PAPERREVIEW_BASE_URL}/review?token=${encodeURIComponent(token)}`; +} + +export function parseYesNo(value: unknown, name: string): boolean { + const normalized = trimOrEmpty(value).toLowerCase(); + if (normalized === 'yes') return true; + if (normalized === 'no') return false; + throw new CliError('ARGUMENT', `"${name}" must be either "yes" or "no".`); +} + +export function normalizeVenue(value: unknown): string { + return trimOrEmpty(value); +} + +export function validateHelpfulness(value: unknown): number { + const numeric = Number(value); + if (!Number.isInteger(numeric) || numeric < 1 || numeric > 5) { + throw new CliError('ARGUMENT', '"helpfulness" must be an integer from 1 to 5.'); + } + return numeric; +} + +export async function readPdfFile(inputPath: unknown): Promise { + const rawPath = trimOrEmpty(inputPath); + if (!rawPath) { + throw new CliError('ARGUMENT', 'A PDF path is required.', 'Provide a local PDF file path'); + } + + const resolvedPath = path.resolve(rawPath); + const fileName = path.basename(resolvedPath); + + if (!fileName.toLowerCase().endsWith('.pdf')) { + throw new CliError('ARGUMENT', 'The input file must end with .pdf.', 'Provide a PDF file path'); + } + + let fileStat; + try { + fileStat = await fs.stat(resolvedPath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + throw new CliError('FILE_NOT_FOUND', `File not found: ${resolvedPath}`, 'Provide a valid PDF file path'); + } + throw new CliError('FILE_READ_ERROR', `Unable to inspect file: ${resolvedPath}`, 'Check file permissions and try again'); + } + + if (!fileStat.isFile()) { + throw new CliError('FILE_NOT_FOUND', `Not a file: ${resolvedPath}`, 'Provide a valid PDF file path'); + } + + if (fileStat.size < 100) { + throw new CliError( + 'ARGUMENT', + 'The PDF is too small. paperreview.ai requires at least 100 bytes.', + 'Provide the final paper PDF', + ); + } + + if (fileStat.size > MAX_PDF_BYTES) { + throw new CliError( + 'FILE_TOO_LARGE', + 'The PDF is larger than paperreview.ai\'s 10MB limit.', + 'Compress the PDF or submit a smaller file', + ); + } + + let buffer: Buffer; + try { + buffer = await fs.readFile(resolvedPath); + } catch { + throw new CliError('FILE_READ_ERROR', `Unable to read file: ${resolvedPath}`, 'Check file permissions and try again'); + } + + return { + buffer, + fileName, + resolvedPath, + sizeBytes: buffer.byteLength, + }; +} + +export async function requestJson(pathname: string, init: RequestInit = {}): Promise { + let response: Response; + try { + response = await fetch(`${PAPERREVIEW_BASE_URL}${pathname}`, init); + } catch (error: unknown) { + throw new CliError( + 'FETCH_ERROR', + `Unable to reach paperreview.ai: ${getErrorMessage(error)}`, + 'Check your network connection and try again', + ); + } + + const rawText = await response.text(); + + let payload: any = rawText; + if (rawText) { + try { + payload = JSON.parse(rawText); + } catch { + payload = rawText; + } + } + + return { response, payload }; +} + +export function ensureSuccess(response: Response, payload: unknown, fallback: string, hint?: string): void { + if (!response.ok) { + const code = response.status === 404 ? 'NOT_FOUND' : 'API_ERROR'; + throw new CliError(code, toErrorMessage(payload, fallback), hint); + } +} + +export function ensureApiSuccess(payload: unknown, fallback: string, hint?: string): void { + if (!payload || typeof payload !== 'object' || (payload as Record).success !== true) { + throw new CliError('API_ERROR', toErrorMessage(payload, fallback), hint); + } +} + +export function createUploadForm( + urlData: { presigned_fields?: Record }, + pdfFile: PaperreviewPdfFile, +): FormData { + const form = new FormData(); + for (const [key, value] of Object.entries(urlData.presigned_fields ?? {})) { + form.append(key, value); + } + form.append( + 'file', + new Blob([new Uint8Array(pdfFile.buffer)], { type: 'application/pdf' }), + pdfFile.fileName, + ); + return form; +} + +export async function uploadPresignedPdf( + presignedUrl: string, + pdfFile: PaperreviewPdfFile, + urlData: { presigned_fields?: Record }, +): Promise { + let response: Response; + try { + response = await fetch(presignedUrl, { + method: 'POST', + body: createUploadForm(urlData, pdfFile), + }); + } catch (error: unknown) { + throw new CliError( + 'UPLOAD_ERROR', + `S3 upload failed: ${getErrorMessage(error)}`, + 'Try again in a moment', + ); + } + + if (!response.ok) { + const body = await response.text(); + throw new CliError( + 'UPLOAD_ERROR', + body || `S3 upload failed with status ${response.status}.`, + 'Try again in a moment', + ); + } +} + +export function summarizeSubmission(options: { + pdfFile: PaperreviewPdfFile; + email: string; + venue: string; + token?: string; + message?: string; + s3Key?: string; + dryRun?: boolean; + status?: string; +}): Record { + const { pdfFile, email, venue, token, message, s3Key, dryRun = false, status } = options; + return { + status: status ?? (dryRun ? 'dry-run' : 'submitted'), + file: pdfFile.fileName, + file_path: pdfFile.resolvedPath, + size_bytes: pdfFile.sizeBytes, + email, + venue, + token: token ?? '', + review_url: token ? buildReviewUrl(token) : '', + message: message ?? '', + s3_key: s3Key ?? '', + }; +} + +export function summarizeReview(token: string, payload: any, status = 'ready'): Record { + const sections = payload?.sections ?? {}; + const availableSections = Object.keys(sections); + + return { + status, + token, + review_url: buildReviewUrl(token), + title: trimOrEmpty(payload?.title), + venue: trimOrEmpty(payload?.venue), + submission_date: trimOrEmpty(payload?.submission_date), + numerical_score: payload?.numerical_score ?? '', + has_feedback: payload?.has_feedback ?? '', + available_sections: availableSections.join(', '), + section_count: availableSections.length, + summary: trimOrEmpty(sections.summary), + strengths: trimOrEmpty(sections.strengths), + weaknesses: trimOrEmpty(sections.weaknesses), + detailed_comments: trimOrEmpty(sections.detailed_comments), + questions: trimOrEmpty(sections.questions), + assessment: trimOrEmpty(sections.assessment), + content: trimOrEmpty(payload?.content), + sections, + }; +} + +export function summarizeFeedback(options: { + token: string; + helpfulness: number; + criticalError: boolean; + actionableSuggestions: boolean; + comments: string; + payload: any; +}): Record { + const { token, helpfulness, criticalError, actionableSuggestions, comments, payload } = options; + return { + status: 'submitted', + token, + helpfulness, + critical_error: criticalError, + actionable_suggestions: actionableSuggestions, + additional_comments: comments, + message: trimOrEmpty(payload?.message) || 'Feedback submitted.', + }; +} diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts new file mode 100644 index 00000000..97c9b881 --- /dev/null +++ b/src/commanderAdapter.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import type { CliCommand } from './registry.js'; + +const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({ + mockExecuteCommand: vi.fn(), + mockRenderOutput: vi.fn(), +})); + +vi.mock('./execution.js', () => ({ + executeCommand: mockExecuteCommand, +})); + +vi.mock('./output.js', () => ({ + render: mockRenderOutput, +})); + +import { registerCommandToProgram } from './commanderAdapter.js'; + +describe('commanderAdapter bool normalization', () => { + const cmd: CliCommand = { + site: 'paperreview', + name: 'submit', + description: 'Submit a PDF', + browser: false, + args: [ + { name: 'pdf', positional: true, required: true, help: 'Path to the paper PDF' }, + { name: 'dry-run', type: 'bool', default: false, help: 'Validate only' }, + { name: 'prepare-only', type: 'bool', default: false, help: 'Prepare only' }, + ], + func: vi.fn(), + }; + + beforeEach(() => { + mockExecuteCommand.mockReset(); + mockExecuteCommand.mockResolvedValue([]); + mockRenderOutput.mockReset(); + delete process.env.OPENCLI_VERBOSE; + process.exitCode = undefined; + }); + + it('normalizes explicit false string values to false', async () => { + const program = new Command(); + const siteCmd = program.command('paperreview'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'false']); + + expect(mockExecuteCommand).toHaveBeenCalledWith( + cmd, + expect.objectContaining({ + pdf: './paper.pdf', + 'dry-run': false, + 'prepare-only': false, + }), + false, + ); + }); + + it('normalizes valueless bool flags to true', async () => { + const program = new Command(); + const siteCmd = program.command('paperreview'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--prepare-only']); + + expect(mockExecuteCommand).toHaveBeenCalledWith( + cmd, + expect.objectContaining({ + pdf: './paper.pdf', + 'dry-run': false, + 'prepare-only': true, + }), + false, + ); + }); + + it('rejects invalid bool strings before execution', async () => { + const program = new Command(); + const siteCmd = program.command('paperreview'); + registerCommandToProgram(siteCmd, cmd); + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'maybe']); + + expect(mockExecuteCommand).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('"dry-run" must be either "true" or "false".')); + + stderr.mockRestore(); + }); +}); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 3a3be77b..293ce3bc 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -18,6 +18,18 @@ import { render as renderOutput } from './output.js'; import { executeCommand } from './execution.js'; import { CliError, ERROR_ICONS, getErrorMessage } from './errors.js'; +export function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown { + if (argType !== 'bool') return value; + if (typeof value === 'boolean') return value; + if (value == null || value === '') return false; + + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + + throw new CliError('ARGUMENT', `"${name}" must be either "true" or "false".`); +} + /** * Register a single CliCommand as a Commander subcommand. */ @@ -52,21 +64,21 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const optionsRecord = typeof actionOpts === 'object' && actionOpts !== null ? actionOpts as Record : {}; const startTime = Date.now(); - // ── Collect kwargs ────────────────────────────────────────────────── - const kwargs: Record = {}; - for (let i = 0; i < positionalArgs.length; i++) { - const v = actionArgs[i]; - if (v !== undefined) kwargs[positionalArgs[i].name] = v; - } - for (const arg of cmd.args) { - if (arg.positional) continue; - const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase()); - const v = optionsRecord[arg.name] ?? optionsRecord[camelName]; - if (v !== undefined) kwargs[arg.name] = v; - } - // ── Execute + render ──────────────────────────────────────────────── try { + // ── Collect kwargs ──────────────────────────────────────────────── + const kwargs: Record = {}; + for (let i = 0; i < positionalArgs.length; i++) { + const v = actionArgs[i]; + if (v !== undefined) kwargs[positionalArgs[i].name] = v; + } + for (const arg of cmd.args) { + if (arg.positional) continue; + const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase()); + const v = optionsRecord[arg.name] ?? optionsRecord[camelName]; + if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name); + } + const verbose = optionsRecord.verbose === true; const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; diff --git a/tests/e2e/public-commands.test.ts b/tests/e2e/public-commands.test.ts index 4835dca8..704325bb 100644 --- a/tests/e2e/public-commands.test.ts +++ b/tests/e2e/public-commands.test.ts @@ -4,6 +4,9 @@ */ import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { parseJsonOutput, runCli } from './helpers.js'; function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean { @@ -128,6 +131,35 @@ describe('public commands E2E', () => { expect(data[0]).toHaveProperty('id'); }, 30_000); + it('paperreview submit dry-run validates a local PDF without remote upload', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencli-paperreview-')); + const pdfPath = path.join(tempDir, 'sample.pdf'); + await fs.writeFile(pdfPath, Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(256, 1)])); + + const { stdout, code } = await runCli([ + 'paperreview', + 'submit', + pdfPath, + '--email', + 'wang2629651228@gmail.com', + '--venue', + 'RAL', + '--dry-run', + 'true', + '-f', + 'json', + ]); + + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(data).toMatchObject({ + status: 'dry-run', + file: 'sample.pdf', + email: 'wang2629651228@gmail.com', + venue: 'RAL', + }); + }, 30_000); + // ── hackernews ── it('hackernews top returns structured data', async () => { const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '3', '-f', 'json']); diff --git a/vitest.config.ts b/vitest.config.ts index 92c6318d..7f505400 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ 'src/clis/weread/**/*.test.ts', 'src/clis/36kr/**/*.test.ts', 'src/clis/producthunt/**/*.test.ts', + 'src/clis/paperreview/**/*.test.ts', ], sequence: { groupOrder: 1 }, },