文件内容
scripts/reader.mjs
#!/usr/bin/env node
/**
* Readwise Reader API (v3) - Saved Articles & Documents
* API Docs: https://readwise.io/reader_api
*/
const BASE_URL = 'https://readwise.io/api/v3';
function usage() {
console.error(`Usage: reader.mjs <command> [options]
Commands:
list [options] List documents
get <document_id> Get document details
save <url> [--location X] Save URL to Reader
search "query" Search documents
List options:
--location <loc> new, later, shortlist, archive, feed (default: all)
--category <cat> article, email, rss, highlight, note, pdf, epub, tweet, video
--limit N Number of results (default: 20)
Save options:
--location <loc> new, later, shortlist, archive (default: new)
--tags <tags> Comma-separated tags
General:
--help Show this help
Environment:
READWISE_TOKEN API token from https://readwise.io/access_token
`);
process.exit(2);
}
function getToken() {
const token = (process.env.READWISE_TOKEN ?? '').trim();
if (!token) {
console.error('Error: READWISE_TOKEN environment variable not set');
console.error('Get your token from: https://readwise.io/access_token');
process.exit(1);
}
return token;
}
async function request(endpoint, options = {}) {
const token = getToken();
const method = options.method || 'GET';
const url = new URL(`${BASE_URL}${endpoint}`);
if (method === 'GET' && options.params) {
Object.entries(options.params).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
});
}
const fetchOptions = {
method,
headers: {
'Authorization': `Token ${token}`,
'Accept': 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {})
}
};
if (options.body) {
fetchOptions.body = JSON.stringify(options.body);
}
const resp = await fetch(url, fetchOptions);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Reader API error (${resp.status}): ${text}`);
}
return resp.json();
}
async function listDocuments(location, category, limit = 20) {
const params = { page_size: limit };
if (location) params.location = location;
if (category) params.category = category;
const data = await request('/list/', { params });
const documents = (data.results || []).map(d => ({
id: d.id,
url: d.url,
title: d.title,
author: d.author,
source: d.source,
category: d.category,
location: d.location,
tags: d.tags,
site_name: d.site_name,
word_count: d.word_count,
created_at: d.created_at,
updated_at: d.updated_at,
reading_progress: d.reading_progress,
first_opened_at: d.first_opened_at,
last_opened_at: d.last_opened_at,
saved_at: d.saved_at,
summary: d.summary?.substring(0, 200),
image_url: d.image_url
}));
console.log(JSON.stringify({ count: data.count, documents }, null, 2));
}
async function getDocument(docId) {
const data = await request(`/list/`, { params: { id: docId } });
const doc = data.results?.[0];
if (!doc) {
console.log(JSON.stringify({ error: 'Document not found' }));
return;
}
console.log(JSON.stringify({
id: doc.id,
url: doc.url,
title: doc.title,
author: doc.author,
source: doc.source,
category: doc.category,
location: doc.location,
tags: doc.tags,
site_name: doc.site_name,
word_count: doc.word_count,
reading_progress: doc.reading_progress,
summary: doc.summary,
content: doc.content?.substring(0, 5000),
notes: doc.notes,
highlights: doc.highlights
}, null, 2));
}
async function saveUrl(url, location = 'new', tags = null) {
const body = { url };
if (location) body.location = location;
if (tags) body.tags = tags.split(',').map(t => t.trim());
const data = await request('/save/', { method: 'POST', body });
console.log(JSON.stringify({
success: true,
id: data.id,
url: data.url,
title: data.title
}, null, 2));
}
async function searchDocuments(query) {
// Reader doesn't have direct search, so fetch and filter
const data = await request('/list/', { params: { page_size: 100 } });
const results = (data.results || []).filter(d =>
d.title?.toLowerCase().includes(query.toLowerCase()) ||
d.author?.toLowerCase().includes(query.toLowerCase()) ||
d.summary?.toLowerCase().includes(query.toLowerCase())
);
console.log(JSON.stringify({
query,
count: results.length,
documents: results.slice(0, 20).map(d => ({
id: d.id,
title: d.title,
url: d.url,
author: d.author,
category: d.category
}))
}, null, 2));
}
// CLI parsing
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') usage();
const command = args[0];
let limit = 20;
let location = null;
let category = null;
let docId = null;
let url = null;
let query = null;
let tags = null;
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === '--limit' || a === '-n') {
limit = parseInt(args[++i] || '20', 10);
} else if (a === '--location') {
location = args[++i];
} else if (a === '--category') {
category = args[++i];
} else if (a === '--tags') {
tags = args[++i];
} else if (!a.startsWith('-')) {
if (command === 'get') docId = a;
else if (command === 'save') url = a;
else if (command === 'search') query = a;
}
}
try {
switch (command) {
case 'list':
await listDocuments(location, category, limit);
break;
case 'get':
if (!docId) {
console.error('Error: get requires a document_id');
usage();
}
await getDocument(docId);
break;
case 'save':
if (!url) {
console.error('Error: save requires a URL');
usage();
}
await saveUrl(url, location, tags);
break;
case 'search':
if (!query) {
console.error('Error: search requires a query');
usage();
}
await searchDocuments(query);
break;
default:
console.error(`Unknown command: ${command}`);
usage();
}
} catch (e) {
console.error('Error:', e.message);
process.exit(1);
}