crxpull 1.0.1
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 +228 -0
- package/bin/crxpull.js +228 -0
- package/index.js +75 -0
- package/package.json +48 -0
- package/src/download.js +96 -0
- package/src/extract.js +61 -0
- package/src/info.js +41 -0
- package/src/utils.js +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# crxpull
|
|
2
|
+
|
|
3
|
+
Download Chrome extensions as `.zip` + extracted source folder — by ID or Chrome Web Store URL.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm
|
|
7
|
+
# saves a .zip and extracts source files, ready to inspect
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
> **Disclaimer:** crxpull uses an unofficial Google endpoint to fetch CRX files. Intended for legitimate personal use — security research, extension auditing, offline backup, and developer tooling. Ensure your usage complies with the [Chrome Web Store Terms of Service](https://chromewebstore.google.com/tos). Do not redistribute extensions without the original author's permission.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<!-- screenshot -->
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g crxpull
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add -g crxpull
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or run without installing:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx crxpull <id-or-url>
|
|
32
|
+
bunx crxpull <id-or-url>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
crxpull <id-or-url> [options]
|
|
41
|
+
crxpull -f extensions.txt [options]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Options
|
|
45
|
+
|
|
46
|
+
| Flag | Description | Default |
|
|
47
|
+
|------|-------------|---------|
|
|
48
|
+
| `-o, --output <dir>` | Output directory | Current directory |
|
|
49
|
+
| `-n, --name <filename>` | Custom filename (no extension) | Extension ID |
|
|
50
|
+
| `--crx` | Also keep the raw `.crx` file | `false` |
|
|
51
|
+
| `--info` | Show metadata from the Chrome Web Store | — |
|
|
52
|
+
| `-f, --file <path>` | Read IDs/URLs from a text file (one per line) | — |
|
|
53
|
+
| `-c, --concurrency <n>` | Parallel downloads in batch mode | `3` |
|
|
54
|
+
| `--json` | Output results as JSON | `false` |
|
|
55
|
+
| `-v, --version` | Show version | — |
|
|
56
|
+
| `-h, --help` | Show help | — |
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Examples
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Download by extension ID
|
|
64
|
+
crxpull mihcahmgecmbnbcchbopgniflfhgnkff
|
|
65
|
+
|
|
66
|
+
# Download from a Chrome Web Store URL
|
|
67
|
+
crxpull https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm
|
|
68
|
+
|
|
69
|
+
# Save to a custom directory with a custom filename
|
|
70
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm -o ./extensions -n ublock
|
|
71
|
+
|
|
72
|
+
# Also keep the raw .crx file
|
|
73
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm --crx
|
|
74
|
+
|
|
75
|
+
# Show extension metadata without downloading
|
|
76
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm --info
|
|
77
|
+
|
|
78
|
+
# Batch download from a file
|
|
79
|
+
crxpull -f my-extensions.txt -o ./extensions -c 5
|
|
80
|
+
|
|
81
|
+
# JSON output — great for scripting
|
|
82
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Batch file format
|
|
86
|
+
|
|
87
|
+
Lines starting with `#` are treated as comments and ignored.
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
# my-extensions.txt
|
|
91
|
+
cjpalhdlnbpafiamejdnhcphjbkeiagm
|
|
92
|
+
mihcahmgecmbnbcchbopgniflfhgnkff
|
|
93
|
+
https://chromewebstore.google.com/detail/dark-reader/eimadpbcbfnmbkopoojfekhnkhdbieeh
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Programmatic API
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { download, downloadBatch, fetchExtensionInfo, extractId } from 'crxpull'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `download(input, options?)`
|
|
105
|
+
|
|
106
|
+
Downloads a single extension. Returns `{ id, zipPath, folderPath, crxPath? }`.
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const result = await download('cjpalhdlnbpafiamejdnhcphjbkeiagm', {
|
|
110
|
+
outputDir: './extensions',
|
|
111
|
+
name: 'ublock',
|
|
112
|
+
crx: false, // set true to also keep the raw .crx
|
|
113
|
+
onProgress: (bytes, total) => {
|
|
114
|
+
process.stdout.write(`\r${Math.round(bytes / total * 100)}%`)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
console.log(result.zipPath) // ./extensions/ublock.zip
|
|
119
|
+
console.log(result.folderPath) // ./extensions/ublock/
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `downloadBatch(inputs, options?)`
|
|
123
|
+
|
|
124
|
+
Downloads multiple extensions in parallel. Always resolves — failed items include an `error` field.
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
const results = await downloadBatch([
|
|
128
|
+
'cjpalhdlnbpafiamejdnhcphjbkeiagm',
|
|
129
|
+
'mihcahmgecmbnbcchbopgniflfhgnkff',
|
|
130
|
+
], {
|
|
131
|
+
outputDir: './extensions',
|
|
132
|
+
concurrency: 5,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
for (const r of results) {
|
|
136
|
+
if (r.error) console.error(`${r.id} failed: ${r.error}`)
|
|
137
|
+
else console.log(`${r.id} → ${r.zipPath}`)
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `fetchExtensionInfo(id)`
|
|
142
|
+
|
|
143
|
+
Fetches extension metadata from the Chrome Web Store.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const info = await fetchExtensionInfo('cjpalhdlnbpafiamejdnhcphjbkeiagm')
|
|
147
|
+
|
|
148
|
+
console.log(info.name) // "uBlock Origin"
|
|
149
|
+
console.log(info.version) // "1.59.0"
|
|
150
|
+
console.log(info.users) // "10,000,000+"
|
|
151
|
+
console.log(info.rating) // 4.8
|
|
152
|
+
console.log(info.storeUrl) // https://chromewebstore.google.com/...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `extractId(input)`
|
|
156
|
+
|
|
157
|
+
Parses a 32-character extension ID from a raw ID string or any Chrome Web Store URL.
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
extractId('cjpalhdlnbpafiamejdnhcphjbkeiagm')
|
|
161
|
+
// => 'cjpalhdlnbpafiamejdnhcphjbkeiagm'
|
|
162
|
+
|
|
163
|
+
extractId('https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm')
|
|
164
|
+
// => 'cjpalhdlnbpafiamejdnhcphjbkeiagm'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## How it works
|
|
170
|
+
|
|
171
|
+
crxpull fetches the extension from Google's CRX update service endpoint. The raw `.crx` file is downloaded to a temp path, then crxpull strips the CRX binary header (CRX2 or CRX3 format) to expose the embedded ZIP. It then:
|
|
172
|
+
|
|
173
|
+
1. Saves a clean **`.zip`** file — portable, shareable, openable anywhere
|
|
174
|
+
2. Extracts a **folder** with all source files ready to edit — `manifest.json`, scripts, assets, locales
|
|
175
|
+
|
|
176
|
+
The temporary `.crx` is deleted automatically. Use `--crx` if you want to keep it.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Publishing
|
|
181
|
+
|
|
182
|
+
A PowerShell publish script is included for Windows. It handles token management, version bumping, and retries.
|
|
183
|
+
|
|
184
|
+
```powershell
|
|
185
|
+
# First time — save your npm token
|
|
186
|
+
.\publish.ps1 -Token npm_xxxx -Save
|
|
187
|
+
|
|
188
|
+
# Bump patch and publish
|
|
189
|
+
.\publish.ps1
|
|
190
|
+
|
|
191
|
+
# Bump minor version
|
|
192
|
+
.\publish.ps1 -Minor
|
|
193
|
+
|
|
194
|
+
# Dry run — preview without publishing
|
|
195
|
+
.\publish.ps1 -DryRun
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Token resolution order: `-Token` param → `NPM_TOKEN` env var → `.npmtoken` file (gitignored).
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Disclaimer
|
|
203
|
+
|
|
204
|
+
crxpull relies on an unofficial, undocumented Google endpoint. There is no guarantee it will remain available or stable. Intended for legitimate personal use only — security research, auditing, developer workflows, and offline backup. Always respect the rights of extension authors and the [Chrome Web Store Terms of Service](https://chromewebstore.google.com/tos).
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Requirements
|
|
209
|
+
|
|
210
|
+
- Node.js 18 or later
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
Issues and pull requests are welcome. Feel free to fork, improve, and share.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Credits
|
|
221
|
+
|
|
222
|
+
Built in collaboration with **[Claude](https://claude.ai)** by Anthropic.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
package/bin/crxpull.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { readFileSync, existsSync } from 'fs'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { download, downloadBatch, fetchExtensionInfo, extractId } from '../index.js'
|
|
6
|
+
|
|
7
|
+
const log = {
|
|
8
|
+
info: (msg) => console.log(chalk.blue('INFO'), chalk.white(msg)),
|
|
9
|
+
success: (msg) => console.log(chalk.green('SUCCESS'), chalk.white(msg)),
|
|
10
|
+
error: (msg) => console.log(chalk.red('ERROR'), chalk.white(msg)),
|
|
11
|
+
warning: (msg) => console.log(chalk.yellow('WARNING'), chalk.white(msg)),
|
|
12
|
+
detail: (label, val) => console.log(chalk.gray(' ') + chalk.gray(label.padEnd(14)) + chalk.white(val)),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderProgress(downloaded, total) {
|
|
16
|
+
const pct = Math.floor((downloaded / total) * 100)
|
|
17
|
+
const filled = Math.floor(pct / 5)
|
|
18
|
+
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled)
|
|
19
|
+
const kb = (downloaded / 1024).toFixed(1)
|
|
20
|
+
const totalKb = (total / 1024).toFixed(1)
|
|
21
|
+
process.stdout.write(`\r${chalk.blue('DOWN')} [${chalk.cyan(bar)}] ${pct}% (${kb} / ${totalKb} KB)`)
|
|
22
|
+
if (downloaded >= total) process.stdout.write('\n')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = argv.slice(2)
|
|
27
|
+
const opts = {
|
|
28
|
+
inputs: [],
|
|
29
|
+
outputDir: process.cwd(),
|
|
30
|
+
name: null,
|
|
31
|
+
crx: false,
|
|
32
|
+
info: false,
|
|
33
|
+
file: null,
|
|
34
|
+
concurrency: 3,
|
|
35
|
+
help: false,
|
|
36
|
+
version: false,
|
|
37
|
+
json: false,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const a = args[i]
|
|
42
|
+
if (a === '-h' || a === '--help') opts.help = true
|
|
43
|
+
else if (a === '-v' || a === '--version') opts.version = true
|
|
44
|
+
else if (a === '--json') opts.json = true
|
|
45
|
+
else if (a === '--crx') opts.crx = true
|
|
46
|
+
else if (a === '--info') opts.info = true
|
|
47
|
+
else if ((a === '-o' || a === '--output') && args[i+1]) opts.outputDir = path.resolve(args[++i])
|
|
48
|
+
else if ((a === '-n' || a === '--name') && args[i+1]) opts.name = args[++i]
|
|
49
|
+
else if ((a === '-f' || a === '--file') && args[i+1]) opts.file = args[++i]
|
|
50
|
+
else if ((a === '-c' || a === '--concurrency') && args[i+1]) opts.concurrency = parseInt(args[++i])
|
|
51
|
+
else if (!a.startsWith('-')) opts.inputs.push(a)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return opts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printHelp() {
|
|
58
|
+
console.log(`
|
|
59
|
+
${chalk.bold.white('crxpull')} ${chalk.gray('\u2014')} ${chalk.white('Download Chrome extensions as .crx files from your terminal')}
|
|
60
|
+
|
|
61
|
+
${chalk.bold('USAGE')}
|
|
62
|
+
crxpull <id-or-url> [options]
|
|
63
|
+
crxpull -f extensions.txt [options]
|
|
64
|
+
|
|
65
|
+
${chalk.bold('OPTIONS')}
|
|
66
|
+
${chalk.cyan('-o, --output <dir>')} Output directory ${chalk.gray('(default: current directory)')}
|
|
67
|
+
${chalk.cyan('-n, --name <name>')} Custom filename without extension
|
|
68
|
+
${chalk.cyan(' --crx')} Also keep the raw .crx file
|
|
69
|
+
${chalk.cyan(' --info')} Show extension metadata from Chrome Web Store
|
|
70
|
+
${chalk.cyan('-f, --file <path>')} Read extension IDs/URLs from a file ${chalk.gray('(one per line)')}
|
|
71
|
+
${chalk.cyan('-c, --concurrency <n>')} Parallel downloads when using --file ${chalk.gray('(default: 3)')}
|
|
72
|
+
${chalk.cyan(' --json')} Output results as JSON
|
|
73
|
+
${chalk.cyan('-v, --version')} Show version
|
|
74
|
+
${chalk.cyan('-h, --help')} Show this help message
|
|
75
|
+
|
|
76
|
+
${chalk.bold('EXAMPLES')}
|
|
77
|
+
${chalk.gray('# Download by ID')}
|
|
78
|
+
crxpull mihcahmgecmbnbcchbopgniflfhgnkff
|
|
79
|
+
|
|
80
|
+
${chalk.gray('# Download from Chrome Web Store URL')}
|
|
81
|
+
crxpull https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm
|
|
82
|
+
|
|
83
|
+
${chalk.gray('# Save to custom directory with custom name')}
|
|
84
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm -o ./extensions -n ublock
|
|
85
|
+
|
|
86
|
+
${chalk.gray('# Keep the raw .crx file alongside zip + folder')}
|
|
87
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm --crx
|
|
88
|
+
|
|
89
|
+
${chalk.gray('# Show extension info without downloading')}
|
|
90
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm --info
|
|
91
|
+
|
|
92
|
+
${chalk.gray('# Batch download from a text file')}
|
|
93
|
+
crxpull -f my-extensions.txt -o ./extensions -c 5
|
|
94
|
+
|
|
95
|
+
${chalk.gray('# Output as JSON (great for scripting)')}
|
|
96
|
+
crxpull cjpalhdlnbpafiamejdnhcphjbkeiagm --json
|
|
97
|
+
`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function handleInfo(input, json) {
|
|
101
|
+
const id = extractId(input)
|
|
102
|
+
log.info(`Fetching info for ${id}`)
|
|
103
|
+
const info = await fetchExtensionInfo(id)
|
|
104
|
+
|
|
105
|
+
if (json) {
|
|
106
|
+
console.log(JSON.stringify(info, null, 2))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log()
|
|
111
|
+
console.log(' ' + chalk.bold.white(info.name))
|
|
112
|
+
if (info.version) log.detail('Version', info.version)
|
|
113
|
+
if (info.users) log.detail('Users', info.users)
|
|
114
|
+
if (info.rating) log.detail('Rating', `${info.rating} (${info.ratingCount} reviews)`)
|
|
115
|
+
if (info.description) log.detail('Description', info.description.slice(0, 120) + (info.description.length > 120 ? '...' : ''))
|
|
116
|
+
log.detail('Store URL', info.storeUrl)
|
|
117
|
+
console.log()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleSingle(input, opts) {
|
|
121
|
+
const id = extractId(input)
|
|
122
|
+
log.info(`Downloading ${id}`)
|
|
123
|
+
|
|
124
|
+
const result = await download(input, {
|
|
125
|
+
outputDir: opts.outputDir,
|
|
126
|
+
name: opts.name,
|
|
127
|
+
crx: opts.crx,
|
|
128
|
+
onProgress: opts.json ? null : renderProgress,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (opts.json) {
|
|
132
|
+
console.log(JSON.stringify(result, null, 2))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
log.success(`ZIP saved to ${result.zipPath}`)
|
|
137
|
+
log.success(`Extracted to ${result.folderPath}`)
|
|
138
|
+
if (result.crxPath) log.detail('CRX kept', result.crxPath)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function handleBatch(inputs, opts) {
|
|
142
|
+
log.info(`Downloading ${inputs.length} extensions with concurrency ${opts.concurrency}`)
|
|
143
|
+
|
|
144
|
+
const results = await downloadBatch(inputs, {
|
|
145
|
+
outputDir: opts.outputDir,
|
|
146
|
+
crx: opts.crx,
|
|
147
|
+
concurrency: opts.concurrency,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (opts.json) {
|
|
151
|
+
console.log(JSON.stringify(results, null, 2))
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ok = results.filter(r => !r.error)
|
|
156
|
+
const failed = results.filter(r => r.error)
|
|
157
|
+
|
|
158
|
+
for (const r of ok) {
|
|
159
|
+
log.success(r.id)
|
|
160
|
+
log.detail('ZIP', r.zipPath)
|
|
161
|
+
log.detail('Folder', r.folderPath)
|
|
162
|
+
if (r.crxPath) log.detail('CRX kept', r.crxPath)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const r of failed) {
|
|
166
|
+
log.error(`${r.id} — ${r.error}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log()
|
|
170
|
+
log.info(`${ok.length} succeeded, ${failed.length} failed`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function main() {
|
|
174
|
+
const opts = parseArgs(process.argv)
|
|
175
|
+
|
|
176
|
+
if (opts.version) {
|
|
177
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
178
|
+
console.log(pkg.version)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (opts.help) {
|
|
183
|
+
printHelp()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (opts.file) {
|
|
188
|
+
if (!existsSync(opts.file)) {
|
|
189
|
+
log.error(`File not found: ${opts.file}`)
|
|
190
|
+
process.exit(1)
|
|
191
|
+
}
|
|
192
|
+
const lines = readFileSync(opts.file, 'utf8')
|
|
193
|
+
.split('\n')
|
|
194
|
+
.map(l => l.trim())
|
|
195
|
+
.filter(l => l && !l.startsWith('#'))
|
|
196
|
+
|
|
197
|
+
if (lines.length === 0) {
|
|
198
|
+
log.warning('File is empty or has no valid entries')
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await handleBatch(lines, opts)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (opts.inputs.length === 0) {
|
|
207
|
+
printHelp()
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (opts.info) {
|
|
212
|
+
for (const input of opts.inputs) {
|
|
213
|
+
await handleInfo(input, opts.json)
|
|
214
|
+
}
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (opts.inputs.length === 1) {
|
|
219
|
+
await handleSingle(opts.inputs[0], opts)
|
|
220
|
+
} else {
|
|
221
|
+
await handleBatch(opts.inputs, opts)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
main().catch(err => {
|
|
226
|
+
log.error(err.message)
|
|
227
|
+
process.exit(1)
|
|
228
|
+
})
|
package/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { extractId } from './src/utils.js'
|
|
3
|
+
import { downloadCrx } from './src/download.js'
|
|
4
|
+
import { extractCrx } from './src/extract.js'
|
|
5
|
+
import { fetchExtensionInfo } from './src/info.js'
|
|
6
|
+
|
|
7
|
+
export { extractId, fetchExtensionInfo }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Download a Chrome extension, save as .zip and extract to folder.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} input - Extension ID or Chrome Web Store URL
|
|
13
|
+
* @param {object} [options]
|
|
14
|
+
* @param {string} [options.outputDir='.'] - Directory to save output
|
|
15
|
+
* @param {string} [options.name] - Custom filename (without extension)
|
|
16
|
+
* @param {boolean} [options.crx=false] - Also keep the raw .crx file
|
|
17
|
+
* @param {function} [options.onProgress] - Progress callback (bytesDownloaded, totalBytes)
|
|
18
|
+
* @returns {Promise<{id, zipPath, folderPath, crxPath?}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function download(input, options = {}) {
|
|
21
|
+
const {
|
|
22
|
+
outputDir = process.cwd(),
|
|
23
|
+
name = null,
|
|
24
|
+
crx = false,
|
|
25
|
+
onProgress = null
|
|
26
|
+
} = options
|
|
27
|
+
|
|
28
|
+
const id = extractId(input)
|
|
29
|
+
const filename = (name || id) + '.crx'
|
|
30
|
+
const crxPath = path.resolve(outputDir, filename)
|
|
31
|
+
|
|
32
|
+
await downloadCrx(id, crxPath, { onProgress })
|
|
33
|
+
|
|
34
|
+
const { zipPath, folderPath } = await extractCrx(crxPath, outputDir)
|
|
35
|
+
|
|
36
|
+
const result = { id, zipPath, folderPath }
|
|
37
|
+
|
|
38
|
+
if (crx) {
|
|
39
|
+
// Re-download raw .crx and save it alongside the zip/folder
|
|
40
|
+
await downloadCrx(id, crxPath, {})
|
|
41
|
+
result.crxPath = crxPath
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Download multiple Chrome extensions in parallel.
|
|
49
|
+
*
|
|
50
|
+
* @param {string[]} inputs - Array of extension IDs or Chrome Web Store URLs
|
|
51
|
+
* @param {object} [options] - Same options as download(), applied to all
|
|
52
|
+
* @param {number} [options.concurrency=3] - Max simultaneous downloads
|
|
53
|
+
* @returns {Promise<Array<{id, zipPath, folderPath, crxPath?, error?}>>}
|
|
54
|
+
*/
|
|
55
|
+
export async function downloadBatch(inputs, options = {}) {
|
|
56
|
+
const { concurrency = 3, ...rest } = options
|
|
57
|
+
const results = []
|
|
58
|
+
const queue = [...inputs]
|
|
59
|
+
|
|
60
|
+
async function worker() {
|
|
61
|
+
while (queue.length > 0) {
|
|
62
|
+
const input = queue.shift()
|
|
63
|
+
try {
|
|
64
|
+
const result = await download(input, rest)
|
|
65
|
+
results.push(result)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const id = (() => { try { return extractId(input) } catch { return input } })()
|
|
68
|
+
results.push({ id, zipPath: null, folderPath: null, error: err.message })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, worker))
|
|
74
|
+
return results
|
|
75
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crxpull",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Download Chrome extensions as .crx files from the terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"crxpull": "./bin/crxpull.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"chrome",
|
|
15
|
+
"extension",
|
|
16
|
+
"crx",
|
|
17
|
+
"download",
|
|
18
|
+
"cli",
|
|
19
|
+
"chromium",
|
|
20
|
+
"webstore",
|
|
21
|
+
"browser-extension",
|
|
22
|
+
"devtools",
|
|
23
|
+
"chrome-extension-downloader"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"adm-zip": "^0.5.16",
|
|
30
|
+
"chalk": "^5.3.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.js",
|
|
34
|
+
"bin/",
|
|
35
|
+
"src/"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/yourusername/crxpull"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node test/run.js",
|
|
44
|
+
"test:unit": "node test/run.js --unit",
|
|
45
|
+
"test:network": "node test/run.js --network",
|
|
46
|
+
"preflight": "node test/run.js"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/download.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync } from 'fs'
|
|
2
|
+
import { get as httpsGet } from 'https'
|
|
3
|
+
import { Transform } from 'stream'
|
|
4
|
+
import { pipeline } from 'stream/promises'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
|
|
7
|
+
// The `x` query parameter must be URL-encoded — its value is itself a
|
|
8
|
+
// key=value string. Passing `&x=id=${id}&uc` is wrong: `&uc` gets treated as
|
|
9
|
+
// a separate top-level query param and the server ignores it, returning 204.
|
|
10
|
+
// Correct form: `&x=${encodeURIComponent(`id=${id}&uc`)}`
|
|
11
|
+
//
|
|
12
|
+
// `acceptformat=crx2,crx3` is also required — omitting it causes 204 for any
|
|
13
|
+
// extension that is CRX3-only (all modern extensions).
|
|
14
|
+
//
|
|
15
|
+
// Reference: https://stackoverflow.com/a/7184792
|
|
16
|
+
// https://github.com/Rob--W/crxviewer/blob/master/src/cws_pattern.js
|
|
17
|
+
const CRX_URL = (id) =>
|
|
18
|
+
`https://clients2.google.com/service/update2/crx` +
|
|
19
|
+
`?response=redirect` +
|
|
20
|
+
`&prodversion=138.0.7204.51` +
|
|
21
|
+
`&acceptformat=crx2,crx3` +
|
|
22
|
+
`&x=${encodeURIComponent(`id=${id}&uc`)}`
|
|
23
|
+
|
|
24
|
+
const HEADERS = {
|
|
25
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
|
26
|
+
'Accept': '*/*',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Node's native fetch has known issues with certain redirect chains
|
|
30
|
+
// (body can be null on the final response). Using https.get directly gives
|
|
31
|
+
// full stream control and reliable manual redirect following.
|
|
32
|
+
function httpsGetFollow(url, maxRedirects = 10) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let redirects = 0
|
|
35
|
+
|
|
36
|
+
function request(currentUrl) {
|
|
37
|
+
httpsGet(currentUrl, { headers: HEADERS }, (res) => {
|
|
38
|
+
const { statusCode, headers } = res
|
|
39
|
+
|
|
40
|
+
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
|
41
|
+
if (++redirects > maxRedirects) {
|
|
42
|
+
res.resume()
|
|
43
|
+
return reject(new Error('Too many redirects'))
|
|
44
|
+
}
|
|
45
|
+
res.resume() // drain and discard redirect body
|
|
46
|
+
return request(headers.location)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (statusCode !== 200) {
|
|
50
|
+
res.resume()
|
|
51
|
+
return reject(new Error(`HTTP ${statusCode} — ${res.statusMessage ?? 'Error'}`))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolve(res)
|
|
55
|
+
}).on('error', reject)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
request(url)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchWithRetry(url, retries = 3, delay = 1000) {
|
|
63
|
+
let lastError
|
|
64
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
return await httpsGetFollow(url)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
lastError = err
|
|
69
|
+
if (attempt < retries) await new Promise(r => setTimeout(r, delay * attempt))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw lastError
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function downloadCrx(id, outputPath, { onProgress } = {}) {
|
|
76
|
+
mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
77
|
+
|
|
78
|
+
const res = await fetchWithRetry(CRX_URL(id))
|
|
79
|
+
const total = parseInt(res.headers['content-length'] || '0', 10)
|
|
80
|
+
let downloaded = 0
|
|
81
|
+
|
|
82
|
+
if (onProgress && total > 0) {
|
|
83
|
+
const tracker = new Transform({
|
|
84
|
+
transform(chunk, _enc, cb) {
|
|
85
|
+
downloaded += chunk.length
|
|
86
|
+
onProgress(downloaded, total)
|
|
87
|
+
cb(null, chunk)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
await pipeline(res, tracker, createWriteStream(outputPath))
|
|
91
|
+
} else {
|
|
92
|
+
await pipeline(res, createWriteStream(outputPath))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return outputPath
|
|
96
|
+
}
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync, unlinkSync } from 'fs'
|
|
2
|
+
import { join, basename } from 'path'
|
|
3
|
+
|
|
4
|
+
function findZipStart(buf) {
|
|
5
|
+
if (buf[0] === 0x43 && buf[1] === 0x72 && buf[2] === 0x32 && buf[3] === 0x34) {
|
|
6
|
+
const version = buf.readUInt32LE(4)
|
|
7
|
+
if (version === 3) {
|
|
8
|
+
const headerSize = buf.readUInt32LE(8)
|
|
9
|
+
return 12 + headerSize
|
|
10
|
+
}
|
|
11
|
+
if (version === 2) {
|
|
12
|
+
const pubKeyLen = buf.readUInt32LE(8)
|
|
13
|
+
const sigLen = buf.readUInt32LE(12)
|
|
14
|
+
return 16 + pubKeyLen + sigLen
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (let i = 0; i < buf.length - 4; i++) {
|
|
18
|
+
if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x03 && buf[i + 3] === 0x04) {
|
|
19
|
+
return i
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
throw new Error('Could not find ZIP data in CRX file')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function crxToZip(crxPath, outputDir) {
|
|
26
|
+
const AdmZip = (await import('adm-zip')).default
|
|
27
|
+
const buf = readFileSync(crxPath)
|
|
28
|
+
const zipStart = findZipStart(buf)
|
|
29
|
+
const zipData = buf.slice(zipStart)
|
|
30
|
+
|
|
31
|
+
const name = basename(crxPath, '.crx')
|
|
32
|
+
const zipPath = join(outputDir, `${name}.zip`)
|
|
33
|
+
|
|
34
|
+
const zip = new AdmZip(zipData)
|
|
35
|
+
zip.writeZip(zipPath)
|
|
36
|
+
|
|
37
|
+
return zipPath
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function crxToFolder(crxPath, outputDir) {
|
|
41
|
+
const AdmZip = (await import('adm-zip')).default
|
|
42
|
+
const buf = readFileSync(crxPath)
|
|
43
|
+
const zipStart = findZipStart(buf)
|
|
44
|
+
const zipData = buf.slice(zipStart)
|
|
45
|
+
|
|
46
|
+
const name = basename(crxPath, '.crx')
|
|
47
|
+
const folderPath = join(outputDir, name)
|
|
48
|
+
mkdirSync(folderPath, { recursive: true })
|
|
49
|
+
|
|
50
|
+
const zip = new AdmZip(zipData)
|
|
51
|
+
zip.extractAllTo(folderPath, true)
|
|
52
|
+
|
|
53
|
+
return folderPath
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function extractCrx(crxPath, outputDir) {
|
|
57
|
+
const zipPath = await crxToZip(crxPath, outputDir)
|
|
58
|
+
const folderPath = await crxToFolder(crxPath, outputDir)
|
|
59
|
+
unlinkSync(crxPath)
|
|
60
|
+
return { zipPath, folderPath }
|
|
61
|
+
}
|
package/src/info.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { buildStoreUrl } from './utils.js'
|
|
2
|
+
|
|
3
|
+
export async function fetchExtensionInfo(id) {
|
|
4
|
+
const url = buildStoreUrl(id)
|
|
5
|
+
const res = await fetch(url, {
|
|
6
|
+
headers: {
|
|
7
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36'
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
if (!res.ok) throw new Error(`Could not fetch extension page (HTTP ${res.status})`)
|
|
12
|
+
|
|
13
|
+
const html = await res.text()
|
|
14
|
+
|
|
15
|
+
const meta = (property) => {
|
|
16
|
+
const match = html.match(new RegExp(`<meta[^>]+(?:name|property)="${property}"[^>]+content="([^"]*)"`, 'i'))
|
|
17
|
+
|| html.match(new RegExp(`<meta[^>]+content="([^"]*)"[^>]+(?:name|property)="${property}"`, 'i'))
|
|
18
|
+
return match ? match[1].replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n)) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const name = meta('og:title') || meta('twitter:title')
|
|
22
|
+
const description = meta('og:description') || meta('description')
|
|
23
|
+
const image = meta('og:image') || meta('twitter:image')
|
|
24
|
+
|
|
25
|
+
const ratingMatch = html.match(/"ratingValue"\s*:\s*"?([\d.]+)"?/)
|
|
26
|
+
const ratingCountMatch = html.match(/"ratingCount"\s*:\s*"?(\d+)"?/)
|
|
27
|
+
const usersMatch = html.match(/([\d,]+)\s+users/)
|
|
28
|
+
const versionMatch = html.match(/Version[\s\S]{0,50}?<[^>]*>([\d.]+)</)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
name: name || 'Unknown',
|
|
33
|
+
description: description || null,
|
|
34
|
+
image: image || null,
|
|
35
|
+
rating: ratingMatch ? parseFloat(ratingMatch[1]) : null,
|
|
36
|
+
ratingCount: ratingCountMatch ? parseInt(ratingCountMatch[1]) : null,
|
|
37
|
+
users: usersMatch ? usersMatch[1] : null,
|
|
38
|
+
version: versionMatch ? versionMatch[1] : null,
|
|
39
|
+
storeUrl: url
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const EXT_ID_REGEX = /^[a-z]{32}$/
|
|
2
|
+
|
|
3
|
+
export function extractId(input) {
|
|
4
|
+
const trimmed = input.trim()
|
|
5
|
+
|
|
6
|
+
const urlMatch = trimmed.match(/\/([a-z]{32})(?:[/?#]|$)/)
|
|
7
|
+
if (urlMatch) return urlMatch[1]
|
|
8
|
+
|
|
9
|
+
if (EXT_ID_REGEX.test(trimmed)) return trimmed
|
|
10
|
+
|
|
11
|
+
throw new Error(`Invalid extension ID or URL: "${trimmed}"`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isValidId(id) {
|
|
15
|
+
return EXT_ID_REGEX.test(id)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildStoreUrl(id) {
|
|
19
|
+
return `https://chromewebstore.google.com/detail/${id}`
|
|
20
|
+
}
|