ez-reads 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # ez-reads
2
+
3
+ Turn any research paper into a beautiful, interactive static website.
4
+
5
+ Paste an ArXiv URL or DOI, and ez-reads will fetch the paper, distill it into structured sections using an LLM, and generate a polished React site — complete with an AI chat assistant that can answer questions about the paper.
6
+
7
+ ## Features
8
+
9
+ - **Automatic distillation** — Extracts key contributions, methodology, results, stats, significance, limitations, glossary, and more
10
+ - **Beautiful sites** — React + Tailwind sites with scroll animations, color themes, and responsive design
11
+ - **AI chat assistant** — Each generated site includes a chat widget powered by Groq so visitors can ask questions about the paper
12
+ - **Paper library** — An auto-generated index page lists all your papers with search
13
+ - **ArXiv + DOI support** — Works with ArXiv URLs and DOIs from any publisher
14
+ - **Figure extraction** — Automatically downloads and displays paper figures
15
+
16
+ ## Requirements
17
+
18
+ - **Node.js 18+** (uses native `fetch()`)
19
+ - **Groq API key** — free at [console.groq.com/keys](https://console.groq.com/keys)
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -g ez-reads
25
+ ```
26
+
27
+ Or run it directly without installing:
28
+
29
+ ```bash
30
+ npx ez-reads
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ ez-reads
37
+ ```
38
+
39
+ On first run, you'll be prompted for your Groq API key. The key powers both:
40
+ 1. **Paper distillation** — extracting structured data from the paper via LLM
41
+ 2. **Chat assistant** — the interactive Q&A widget on each generated site
42
+
43
+ **Model used:** `qwen/qwen3-32b` (available on Groq's free tier)
44
+
45
+ ### Skip the prompt
46
+
47
+ Set your key in the environment to skip the interactive prompt:
48
+
49
+ ```bash
50
+ export GROQ_API_KEY=gsk_your_key_here
51
+ ez-reads
52
+ ```
53
+
54
+ Or create a `.env` file in the directory where you save the key:
55
+
56
+ ```
57
+ GROQ_API_KEY=gsk_your_key_here
58
+ ```
59
+
60
+ ## How it works
61
+
62
+ ```
63
+ 📄 paper → 🔬 distill → ✨ generate → 🌐 site
64
+ ```
65
+
66
+ 1. **Fetch** — Scrapes the paper content from ArXiv or resolves a DOI
67
+ 2. **Distill** — Sends the paper through 5-6 LLM calls to extract structured JSON (title, abstract, methodology, results, stats, glossary, etc.)
68
+ 3. **Generate** — Builds a React + Tailwind static site and serves it locally
69
+
70
+ Output goes to `./ez-reads-output/` in your current directory.
71
+
72
+ ## Commands
73
+
74
+ | Command | Description |
75
+ |---|---|
76
+ | `ez-reads` or `paper-site` | Start the interactive CLI |
77
+ | `npm run rebuild` | Rebuild all existing sites (after template changes) |
78
+
79
+ ## Generated site structure
80
+
81
+ ```
82
+ ez-reads-output/
83
+ ├── index.html # Library page (lists all papers)
84
+ └── <paper-slug>/
85
+ ├── index.html # Paper site
86
+ ├── assets/ # JS/CSS bundles
87
+ ├── figures/ # Downloaded figures
88
+ ├── meta.json # Metadata for library index
89
+ └── data.json # Full distilled data (for rebuilds)
90
+ ```
91
+
92
+ ## Chat assistant
93
+
94
+ Every generated site includes a floating chat button in the bottom-right corner. Visitors can ask questions about the paper and get answers powered by the same Groq API key used during generation.
95
+
96
+ **Note:** Your Groq API key is embedded in the generated site's JavaScript bundle to power the chat assistant. This is fine for local use or private hosting. If you plan to host generated sites publicly, be aware that the key is visible in the page source.
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import 'dotenv/config';
4
+ import { run } from '../src/cli.mjs';
5
+
6
+ run();
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ import 'dotenv/config';
4
+ import path from 'path';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import { rebuildAll } from '../src/generator.mjs';
8
+
9
+ const OUTPUT_DIR = path.resolve(process.cwd(), 'ez-reads-output');
10
+
11
+ console.log(chalk.blueBright('\n Rebuilding all paper sites with updated template...\n'));
12
+
13
+ const spinner = ora('Rebuilding...').start();
14
+
15
+ try {
16
+ const results = await rebuildAll(OUTPUT_DIR);
17
+ spinner.succeed(`Rebuilt ${results.length} site${results.length === 1 ? '' : 's'}`);
18
+ for (const r of results) {
19
+ console.log(` ${chalk.green('✓')} ${r.slug}`);
20
+ }
21
+ console.log('');
22
+ } catch (err) {
23
+ spinner.fail(`Rebuild failed: ${err.message}`);
24
+ process.exit(1);
25
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "ez-reads",
3
+ "version": "1.0.0",
4
+ "description": "Turn any research paper into a beautiful static website",
5
+ "type": "module",
6
+ "bin": {
7
+ "ez-reads": "bin/paper-site.mjs",
8
+ "paper-site": "bin/paper-site.mjs"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/paper-site.mjs",
12
+ "rebuild": "node bin/rebuild-all.mjs"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "template/",
18
+ "README.md"
19
+ ],
20
+ "keywords": [
21
+ "arxiv",
22
+ "research",
23
+ "paper",
24
+ "static-site",
25
+ "groq",
26
+ "ai",
27
+ "llm"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/hsidiyer/ez-reads.git"
36
+ },
37
+ "homepage": "https://github.com/hsidiyer/ez-reads#readme",
38
+ "dependencies": {
39
+ "chalk": "^5.3.0",
40
+ "cheerio": "^1.0.0-rc.12",
41
+ "dotenv": "^17.2.4",
42
+ "fs-extra": "^11.2.0",
43
+ "groq-sdk": "^0.37.0",
44
+ "inquirer": "^9.2.12",
45
+ "ora": "^8.0.1"
46
+ }
47
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,243 @@
1
+ import inquirer from 'inquirer';
2
+ import ora from 'ora';
3
+ import chalk from 'chalk';
4
+ import path from 'path';
5
+ import http from 'http';
6
+ import fs from 'fs-extra';
7
+ import { fileURLToPath } from 'url';
8
+ import { fetchPaper } from './fetcher.mjs';
9
+ import { distillPaper } from './distiller.mjs';
10
+ import { generateSite } from './generator.mjs';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const OUTPUT_DIR = path.resolve(process.cwd(), 'ez-reads-output');
14
+
15
+ const gd = chalk.blueBright;
16
+ const dm = chalk.dim;
17
+ const wh = chalk.white;
18
+ const cn = chalk.cyan;
19
+
20
+ const BANNER = `
21
+ ${dm(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}
22
+
23
+ ${gd.bold(' ███████ █████ ███████ ██ ██')}
24
+ ${gd.bold(' ██ ██ ██ ██ ██ ██ ')}
25
+ ${gd.bold(' █████ ███████ ███████ ████ ')}
26
+ ${gd.bold(' ██ ██ ██ ██ ██ ')}
27
+ ${gd.bold(' ███████ ██ ██ ███████ ██ ')}
28
+
29
+ ${gd.bold(' ██████ ███████ █████ ██████ ███████')}
30
+ ${gd.bold(' ██ ██ ██ ██ ██ ██ ██ ██ ')}
31
+ ${gd.bold(' ██████ █████ ███████ ██ ██ ███████')}
32
+ ${gd.bold(' ██ ██ ██ ██ ██ ██ ██ ██')}
33
+ ${gd.bold(' ██ ██ ███████ ██ ██ ██████ ███████')}
34
+
35
+ ${wh.bold(' Make beautiful sites from research papers.')}
36
+
37
+ ${dm(' 📄')} ${cn('paper')} ${dm('→')} ${dm('🔬')} ${cn('distill')} ${dm('→')} ${dm('✨')} ${cn('generate')} ${dm('→')} ${dm('🌐')} ${cn('site')}
38
+
39
+ ${dm(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}
40
+ `;
41
+
42
+ export async function run() {
43
+ console.log(BANNER);
44
+
45
+ // Resolve API key — use env/dotenv if available, otherwise prompt
46
+ if (!process.env.GROQ_API_KEY) {
47
+ console.log(
48
+ chalk.yellow(' ⚡ A Groq API key is required to distill papers and power the chat assistant.\n') +
49
+ chalk.dim(' Get one free at: ') + chalk.cyan('https://console.groq.com/keys') + '\n' +
50
+ chalk.dim(' Model: ') + chalk.white('qwen/qwen3-32b') + chalk.dim(' (free tier)\n') +
51
+ chalk.dim(' Tip: set ') + chalk.white('GROQ_API_KEY=gsk_...') + chalk.dim(' in a .env file to skip this prompt.\n')
52
+ );
53
+
54
+ const { apiKey } = await inquirer.prompt([
55
+ {
56
+ type: 'password',
57
+ name: 'apiKey',
58
+ message: 'Enter your Groq API key:',
59
+ mask: '*',
60
+ validate: (val) => {
61
+ if (!val.trim()) return 'API key is required.';
62
+ if (!val.trim().startsWith('gsk_')) return 'Groq API keys start with gsk_. Get one at https://console.groq.com/keys';
63
+ return true;
64
+ },
65
+ },
66
+ ]);
67
+
68
+ process.env.GROQ_API_KEY = apiKey.trim();
69
+ }
70
+
71
+ console.log(
72
+ chalk.dim(' Model: ') + chalk.cyan('qwen/qwen3-32b') + chalk.dim(' via Groq') +
73
+ chalk.dim(' • API key: ') + chalk.green('✓ loaded') +
74
+ chalk.dim(' • Powers: distillation + chat assistant\n')
75
+ );
76
+
77
+ // Start the server once — it serves the entire output/ directory
78
+ const { port } = await startStaticServer(OUTPUT_DIR);
79
+ const libraryUrl = `http://localhost:${port}/`;
80
+
81
+ console.log(` ${chalk.bold('Library:')} ${chalk.cyan(libraryUrl)}\n`);
82
+
83
+ // Process papers in a loop
84
+ let paperCount = 0;
85
+
86
+ while (true) {
87
+ const result = await processPaper(port);
88
+
89
+ if (result) {
90
+ paperCount++;
91
+ const paperUrl = `http://localhost:${port}/${result.slug}/`;
92
+
93
+ console.log(
94
+ `\n ${chalk.green('Site is live!')}\n` +
95
+ `\n ${chalk.bold('This paper:')} ${chalk.cyan(paperUrl)}` +
96
+ `\n ${chalk.bold('Library:')} ${chalk.cyan(libraryUrl)}` +
97
+ `\n ${chalk.dim(`${paperCount} paper${paperCount === 1 ? '' : 's'} generated this session.`)}\n`
98
+ );
99
+ }
100
+
101
+ // Ask if the user wants to add another paper
102
+ const { another } = await inquirer.prompt([
103
+ {
104
+ type: 'confirm',
105
+ name: 'another',
106
+ message: 'Add another paper?',
107
+ default: true,
108
+ },
109
+ ]);
110
+
111
+ if (!another) break;
112
+
113
+ console.log(''); // spacing
114
+ }
115
+
116
+ console.log(
117
+ `\n ${chalk.dim('Server still running at')} ${chalk.cyan(libraryUrl)}` +
118
+ `\n ${chalk.dim('Press Ctrl+C to stop.')}\n`
119
+ );
120
+ }
121
+
122
+ // ---------- process a single paper ----------
123
+
124
+ async function processPaper(port) {
125
+ // Prompt for paper link
126
+ const { input } = await inquirer.prompt([
127
+ {
128
+ type: 'input',
129
+ name: 'input',
130
+ message: 'Enter an ArXiv URL or DOI:',
131
+ validate: (val) => {
132
+ val = val.trim();
133
+ if (!val) return 'Please enter a URL or DOI.';
134
+ if (/arxiv\.org\/(abs|html|pdf)\//.test(val)) return true;
135
+ if (/^10\.\d{4,}\//.test(val) || /doi\.org\/10\.\d{4,}\//.test(val)) return true;
136
+ return 'Please enter a valid ArXiv URL or DOI (e.g., https://arxiv.org/abs/2401.00001 or 10.1234/example)';
137
+ },
138
+ },
139
+ ]);
140
+
141
+ // Fetch
142
+ const fetchSpinner = ora('Fetching paper content...').start();
143
+ let paperData;
144
+ try {
145
+ paperData = await fetchPaper(input.trim());
146
+ fetchSpinner.succeed(
147
+ `Fetched: ${chalk.bold(paperData.title || 'paper')} (${paperData.sections.length} sections)`
148
+ );
149
+ } catch (err) {
150
+ fetchSpinner.fail(`Failed to fetch paper: ${err.message}`);
151
+ return null;
152
+ }
153
+
154
+ // Distill
155
+ const distillSpinner = ora('Distilling paper...').start();
156
+ let distilled;
157
+ try {
158
+ distilled = await distillPaper(paperData, (chunkName, current, total) => {
159
+ distillSpinner.text = `Distilling: ${chunkName} (${current}/${total})...`;
160
+ });
161
+ distilled.url = paperData.url;
162
+ distilled.publishedDate = paperData.publishedDate || null;
163
+ distillSpinner.succeed('Paper distilled into structured data (5 chunks)');
164
+ } catch (err) {
165
+ distillSpinner.fail(`Failed to distill paper: ${err.message}`);
166
+ return null;
167
+ }
168
+
169
+ // Build
170
+ const genSpinner = ora('Building website...').start();
171
+ try {
172
+ const result = await generateSite(distilled, OUTPUT_DIR);
173
+ genSpinner.succeed(`Built: ${chalk.cyan(result.slug)}`);
174
+ return result;
175
+ } catch (err) {
176
+ genSpinner.fail(`Failed to build site: ${err.message}`);
177
+ return null;
178
+ }
179
+ }
180
+
181
+ // ---------- static file server ----------
182
+
183
+ const MIME_TYPES = {
184
+ '.html': 'text/html; charset=utf-8',
185
+ '.js': 'text/javascript; charset=utf-8',
186
+ '.css': 'text/css; charset=utf-8',
187
+ '.json': 'application/json; charset=utf-8',
188
+ '.png': 'image/png',
189
+ '.jpg': 'image/jpeg',
190
+ '.jpeg': 'image/jpeg',
191
+ '.gif': 'image/gif',
192
+ '.svg': 'image/svg+xml',
193
+ '.ico': 'image/x-icon',
194
+ '.woff': 'font/woff',
195
+ '.woff2':'font/woff2',
196
+ '.ttf': 'font/ttf',
197
+ '.webp': 'image/webp',
198
+ };
199
+
200
+ function startStaticServer(rootDir, port = 3000) {
201
+ return new Promise((resolve, reject) => {
202
+ // Ensure the output directory exists before serving
203
+ fs.ensureDirSync(rootDir);
204
+
205
+ const server = http.createServer(async (req, res) => {
206
+ const urlPath = decodeURIComponent(req.url.split('?')[0]);
207
+ let filePath = path.join(rootDir, urlPath);
208
+
209
+ // If it's a directory, serve index.html
210
+ try {
211
+ const stat = await fs.stat(filePath);
212
+ if (stat.isDirectory()) {
213
+ filePath = path.join(filePath, 'index.html');
214
+ }
215
+ } catch {
216
+ // file doesn't exist — will 404 below
217
+ }
218
+
219
+ try {
220
+ const data = await fs.readFile(filePath);
221
+ const ext = path.extname(filePath).toLowerCase();
222
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
223
+ res.writeHead(200, { 'Content-Type': contentType });
224
+ res.end(data);
225
+ } catch {
226
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
227
+ res.end('Not found');
228
+ }
229
+ });
230
+
231
+ server.listen(port, () => {
232
+ resolve({ server, port });
233
+ });
234
+
235
+ server.on('error', (err) => {
236
+ if (err.code === 'EADDRINUSE') {
237
+ startStaticServer(rootDir, port + 1).then(resolve, reject);
238
+ } else {
239
+ reject(err);
240
+ }
241
+ });
242
+ });
243
+ }