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 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
+ }
@@ -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
+ }