microlink.io 0.1.1 → 0.5.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/LICENSE.md +0 -0
- package/bin/config.js +61 -0
- package/bin/help.js +366 -0
- package/bin/index.js +159 -73
- package/bin/login.js +148 -0
- package/bin/select.js +98 -0
- package/bin/style.js +21 -0
- package/package.json +12 -11
- package/bin/help.txt +0 -53
package/LICENSE.md
CHANGED
|
File without changes
|
package/bin/config.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { homedir } = require('os')
|
|
4
|
+
const { join } = require('path')
|
|
5
|
+
const {
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
chmodSync
|
|
11
|
+
} = require('fs')
|
|
12
|
+
|
|
13
|
+
const configDir = () =>
|
|
14
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'microlink')
|
|
15
|
+
|
|
16
|
+
const configPath = () => join(configDir(), 'config.json')
|
|
17
|
+
|
|
18
|
+
const configPathDisplay = () => {
|
|
19
|
+
const file = configPath()
|
|
20
|
+
const home = homedir()
|
|
21
|
+
return file.startsWith(home + '/') ? `~${file.slice(home.length)}` : file
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readConfig = () => {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(configPath(), 'utf8'))
|
|
27
|
+
} catch {
|
|
28
|
+
return {}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const writeConfig = data => {
|
|
33
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 })
|
|
34
|
+
writeFileSync(configPath(), JSON.stringify(data) + '\n', { mode: 0o600 })
|
|
35
|
+
chmodSync(configPath(), 0o600)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clearConfig = () => {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(configPath())
|
|
41
|
+
return true
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') return false
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const readApiKey = () => {
|
|
49
|
+
const { apiKey } = readConfig()
|
|
50
|
+
return typeof apiKey === 'string' && apiKey ? apiKey : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
configDir,
|
|
55
|
+
configPath,
|
|
56
|
+
configPathDisplay,
|
|
57
|
+
readConfig,
|
|
58
|
+
writeConfig,
|
|
59
|
+
clearConfig,
|
|
60
|
+
readApiKey
|
|
61
|
+
}
|
package/bin/help.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { gray, white } = require('./style')
|
|
4
|
+
|
|
5
|
+
const col = (name, desc) => ` ${white(name.padEnd(21))} ${gray(desc)}`
|
|
6
|
+
|
|
7
|
+
const cmd = (rest, comment) =>
|
|
8
|
+
`${comment ? ` ${gray(`# ${comment}`)}\n` : ''} ${white(
|
|
9
|
+
'microlink'
|
|
10
|
+
)} ${gray(rest)}`
|
|
11
|
+
|
|
12
|
+
const rows = items => items.map(([name, desc]) => col(name, desc)).join('\n')
|
|
13
|
+
|
|
14
|
+
const CLI = [
|
|
15
|
+
[
|
|
16
|
+
'--api-key',
|
|
17
|
+
'Microlink API key (flag, MICROLINK_API_KEY, or `microlink login`)'
|
|
18
|
+
],
|
|
19
|
+
['--endpoint', 'Microlink API endpoint'],
|
|
20
|
+
['--header, -H', "Extra request header as 'Name: value' (repeatable)"],
|
|
21
|
+
[
|
|
22
|
+
'--http.header.<name>',
|
|
23
|
+
'HTTP request header (e.g. --http.header.authorization)'
|
|
24
|
+
],
|
|
25
|
+
['--trace', 'Print request & response payload (API key masked)'],
|
|
26
|
+
['--trace-full', 'Same as --trace, including the full API key'],
|
|
27
|
+
['--help', 'Show this help']
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const CLI_NO_TRACE = CLI.filter(
|
|
31
|
+
([name]) => name !== '--trace' && name !== '--trace-full'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const BROWSER = [
|
|
35
|
+
['--adblock', 'Block ads and trackers'],
|
|
36
|
+
['--animations', 'Enable CSS animations'],
|
|
37
|
+
['--cacheKey', 'Custom cache key'],
|
|
38
|
+
['--click', 'CSS selector(s) to click before capture'],
|
|
39
|
+
['--colorScheme', 'Color scheme: no-preference, light, dark'],
|
|
40
|
+
['--device', "Emulate a device (e.g. 'iPhone 11')"],
|
|
41
|
+
['--filename', 'Suggested download filename'],
|
|
42
|
+
['--filter', 'Pick response fields'],
|
|
43
|
+
['--force', 'Bypass the cache'],
|
|
44
|
+
['--javascript', 'Enable or disable JavaScript'],
|
|
45
|
+
['--mediaType', 'Emulate media: screen, print'],
|
|
46
|
+
['--modules', 'Inject ES module URLs'],
|
|
47
|
+
['--prerender', 'Prerender: auto, true, false'],
|
|
48
|
+
['--proxy', 'Proxy URL or country'],
|
|
49
|
+
['--retry', 'Retry count'],
|
|
50
|
+
['--scripts', 'Inject script URLs'],
|
|
51
|
+
['--scroll', 'CSS selector to scroll into view'],
|
|
52
|
+
['--staleTtl', 'Stale-while-revalidate TTL'],
|
|
53
|
+
['--styles', 'Inject stylesheet URLs'],
|
|
54
|
+
['--timeout', 'Request timeout'],
|
|
55
|
+
['--ttl', 'Cache TTL'],
|
|
56
|
+
['--viewport', 'Viewport as JSON (width, height, ...)'],
|
|
57
|
+
['--waitForSelector', 'Wait until a CSS selector matches'],
|
|
58
|
+
['--waitForTimeout', 'Wait for a duration before continuing'],
|
|
59
|
+
['--waitUntil', 'auto, load, domcontentloaded, networkidle0, networkidle2']
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const CONTENT = [
|
|
63
|
+
['--selector', 'CSS selector to scope the extraction'],
|
|
64
|
+
['--selectorAll', 'CSS selector(s) matching many nodes'],
|
|
65
|
+
['--type', 'Cast the extracted value (url, image, ...)']
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const COLLECTION = [
|
|
69
|
+
['--selector', 'CSS selector (single node)'],
|
|
70
|
+
['--selectorAll', 'CSS selector(s) matching many nodes'],
|
|
71
|
+
['--attr', 'Attribute to read (href, src, ...)'],
|
|
72
|
+
['--type', 'Cast each value (url, image, email, ...)']
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const ALIAS = { run: 'function' }
|
|
76
|
+
|
|
77
|
+
const COMMANDS = {
|
|
78
|
+
login: {
|
|
79
|
+
usage: 'login',
|
|
80
|
+
desc: 'Save an API key from your Microlink account',
|
|
81
|
+
flags: [],
|
|
82
|
+
cli: [],
|
|
83
|
+
examples: [['login', 'open the dashboard and save an API key']]
|
|
84
|
+
},
|
|
85
|
+
logout: {
|
|
86
|
+
usage: 'logout',
|
|
87
|
+
desc: 'Remove the saved API key from this machine',
|
|
88
|
+
flags: [],
|
|
89
|
+
cli: [],
|
|
90
|
+
examples: [['logout', 'forget the saved API key']]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const content = (name, desc) => ({
|
|
95
|
+
usage: `${name} <url> [options]`,
|
|
96
|
+
desc,
|
|
97
|
+
flags: CONTENT,
|
|
98
|
+
browser: true,
|
|
99
|
+
examples: [[`${name} https://example.com`, desc]]
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const collection = (name, desc) => ({
|
|
103
|
+
usage: `${name} <url> [options]`,
|
|
104
|
+
desc,
|
|
105
|
+
flags: COLLECTION,
|
|
106
|
+
browser: true,
|
|
107
|
+
examples: [[`${name} https://example.com`, desc]]
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const PRODUCTS = {
|
|
111
|
+
metadata: {
|
|
112
|
+
usage: ['metadata <url> [options]', '<url> [options]'],
|
|
113
|
+
desc: 'Unified metadata (title, description, image, ...); default',
|
|
114
|
+
flags: [
|
|
115
|
+
['--palette', 'Also extract dominant colors from images'],
|
|
116
|
+
['--meta', 'Include metadata fields, or false to skip']
|
|
117
|
+
],
|
|
118
|
+
browser: true,
|
|
119
|
+
examples: [
|
|
120
|
+
['https://example.com', 'unified metadata (default)'],
|
|
121
|
+
[
|
|
122
|
+
'https://example.com --trace',
|
|
123
|
+
'print request & response, API key masked'
|
|
124
|
+
]
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
logo: {
|
|
128
|
+
usage: 'logo <url> [options]',
|
|
129
|
+
desc: 'Brand logo of the site (--square prefers the square variant)',
|
|
130
|
+
flags: [
|
|
131
|
+
['--square', 'Prefer the square logo variant'],
|
|
132
|
+
['--palette', 'Also extract dominant colors']
|
|
133
|
+
],
|
|
134
|
+
browser: true,
|
|
135
|
+
examples: [['logo https://github.com --square', 'square brand logo']]
|
|
136
|
+
},
|
|
137
|
+
markdown: content('markdown', 'Page content as Markdown'),
|
|
138
|
+
html: content('html', 'Page content as HTML'),
|
|
139
|
+
text: content('text', 'Page content as plain text'),
|
|
140
|
+
video: {
|
|
141
|
+
usage: 'video <url> [options]',
|
|
142
|
+
desc: 'Primary video of the page (returns the asset object)',
|
|
143
|
+
flags: [['--meta', 'Include metadata fields, or false to skip']],
|
|
144
|
+
browser: true,
|
|
145
|
+
examples: [['video https://example.com', 'primary video asset']]
|
|
146
|
+
},
|
|
147
|
+
audio: {
|
|
148
|
+
usage: 'audio <url> [options]',
|
|
149
|
+
desc: 'Primary audio of the page (returns the asset object)',
|
|
150
|
+
flags: [['--meta', 'Include metadata fields, or false to skip']],
|
|
151
|
+
browser: true,
|
|
152
|
+
examples: [['audio https://example.com', 'primary audio asset']]
|
|
153
|
+
},
|
|
154
|
+
emails: collection('emails', 'Every email address present on the page'),
|
|
155
|
+
links: collection('links', 'Every absolute link URL on the page'),
|
|
156
|
+
images: collection('images', 'Every absolute image URL on the page'),
|
|
157
|
+
videos: collection('videos', 'Every absolute video URL on the page'),
|
|
158
|
+
audios: collection('audios', 'Every absolute audio URL on the page'),
|
|
159
|
+
extract: {
|
|
160
|
+
usage: 'extract <url> --data <json> [options]',
|
|
161
|
+
desc: 'Custom MQL data rules',
|
|
162
|
+
flags: [['--data', 'JSON data rules (e.g. --data \'{"field":{...}}\')']],
|
|
163
|
+
browser: true,
|
|
164
|
+
examples: [
|
|
165
|
+
[
|
|
166
|
+
'extract https://microlink.io --data \'{"image":{"selector":"meta[property=og:image]","attr":"content","type":"image"}}\'',
|
|
167
|
+
'extract og:image via MQL'
|
|
168
|
+
]
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
screenshot: {
|
|
172
|
+
usage: 'screenshot <url> [options]',
|
|
173
|
+
desc: 'Take a screenshot (returns the asset object)',
|
|
174
|
+
flags: [
|
|
175
|
+
['--fullPage', 'Capture the full scrollable page'],
|
|
176
|
+
['--type', 'Image format: png, jpeg'],
|
|
177
|
+
['--element', 'CSS selector of the element to capture'],
|
|
178
|
+
['--omitBackground', 'Transparent background (png)'],
|
|
179
|
+
['--optimizeForSpeed', 'Faster encode, larger file'],
|
|
180
|
+
['--overlay', 'Browser chrome overlay as JSON'],
|
|
181
|
+
['--codeScheme', 'Syntax theme for code pages'],
|
|
182
|
+
['--animated', 'Animated screenshot (GIF/MP4)'],
|
|
183
|
+
['--palette', 'Also extract dominant colors'],
|
|
184
|
+
['--quality', 'JPEG quality (0–100)']
|
|
185
|
+
],
|
|
186
|
+
browser: true,
|
|
187
|
+
examples: [
|
|
188
|
+
['screenshot https://example.com --fullPage', 'full-page screenshot']
|
|
189
|
+
]
|
|
190
|
+
},
|
|
191
|
+
pdf: {
|
|
192
|
+
usage: 'pdf <url> [options]',
|
|
193
|
+
desc: 'Generate a PDF (returns the asset object)',
|
|
194
|
+
flags: [
|
|
195
|
+
['--format', 'Page format: Letter, Legal, A4, ...'],
|
|
196
|
+
['--margin', "Margin (e.g. '0.5cm' or JSON)"],
|
|
197
|
+
['--scale', 'Scale (0.1–2)'],
|
|
198
|
+
['--landscape', 'Landscape orientation'],
|
|
199
|
+
['--pageRanges', "Pages to print (e.g. '1-3')"],
|
|
200
|
+
['--width', 'Page width'],
|
|
201
|
+
['--height', 'Page height'],
|
|
202
|
+
['--printBackground', 'Print background graphics']
|
|
203
|
+
],
|
|
204
|
+
browser: true,
|
|
205
|
+
examples: [['pdf https://example.com --format A4', 'A4 PDF']]
|
|
206
|
+
},
|
|
207
|
+
embed: {
|
|
208
|
+
usage: 'embed <url> [options]',
|
|
209
|
+
desc: 'oEmbed-style embeddable iframe ({ html, scripts })',
|
|
210
|
+
flags: [
|
|
211
|
+
['--maxWidth', 'Maximum iframe width'],
|
|
212
|
+
['--maxHeight', 'Maximum iframe height']
|
|
213
|
+
],
|
|
214
|
+
browser: true,
|
|
215
|
+
examples: [['embed https://example.com', 'embeddable iframe']]
|
|
216
|
+
},
|
|
217
|
+
technologies: {
|
|
218
|
+
usage: 'technologies <url> [options]',
|
|
219
|
+
desc: 'Detect the tech stack behind the site',
|
|
220
|
+
flags: [],
|
|
221
|
+
browser: true,
|
|
222
|
+
examples: [['technologies https://example.com', 'detect the tech stack']]
|
|
223
|
+
},
|
|
224
|
+
lighthouse: {
|
|
225
|
+
usage: 'lighthouse <url> [options]',
|
|
226
|
+
desc: 'Run a Lighthouse report',
|
|
227
|
+
flags: [
|
|
228
|
+
['--onlyCategories', 'Limit to these categories'],
|
|
229
|
+
['--onlyAudits', 'Limit to these audits'],
|
|
230
|
+
['--skipAudits', 'Skip these audits'],
|
|
231
|
+
['--output', 'Report format: json, html, csv']
|
|
232
|
+
],
|
|
233
|
+
browser: true,
|
|
234
|
+
examples: [['lighthouse https://example.com', 'run a Lighthouse report']]
|
|
235
|
+
},
|
|
236
|
+
search: {
|
|
237
|
+
usage: 'search <query> [options]',
|
|
238
|
+
desc: 'Google as structured data (query instead of url)',
|
|
239
|
+
flags: [
|
|
240
|
+
[
|
|
241
|
+
'--type',
|
|
242
|
+
'news, images, videos, places, maps, shopping, scholar, patents, autocomplete'
|
|
243
|
+
],
|
|
244
|
+
['--limit', 'Maximum number of results'],
|
|
245
|
+
['--page', 'Results page (1 default)'],
|
|
246
|
+
['--html', 'Fetch HTML for the results page and each result'],
|
|
247
|
+
['--markdown', 'Fetch Markdown for the results page and each result'],
|
|
248
|
+
['--location', 'Country or locale (e.g. es)'],
|
|
249
|
+
['--period', 'Recency: hour, day, week, month, year'],
|
|
250
|
+
['--timeout', 'Request timeout']
|
|
251
|
+
],
|
|
252
|
+
cli: CLI_NO_TRACE,
|
|
253
|
+
examples: [
|
|
254
|
+
[
|
|
255
|
+
'search "best coffee" --limit 10 --location es',
|
|
256
|
+
'Google results in Spain, limit 10'
|
|
257
|
+
],
|
|
258
|
+
[
|
|
259
|
+
'search "open source llm" --type news --period week',
|
|
260
|
+
'news results from the past week'
|
|
261
|
+
],
|
|
262
|
+
[
|
|
263
|
+
'search "the matrix" --markdown',
|
|
264
|
+
'include Markdown for the SERP and each result'
|
|
265
|
+
],
|
|
266
|
+
['search "the matrix" --page 2', 'second page of results']
|
|
267
|
+
]
|
|
268
|
+
},
|
|
269
|
+
function: {
|
|
270
|
+
usage: 'function <url> --file <path> [options]',
|
|
271
|
+
desc: 'Run code remotely with browser access',
|
|
272
|
+
flags: [['--file', 'Path to the code file']],
|
|
273
|
+
browser: true,
|
|
274
|
+
cli: CLI_NO_TRACE,
|
|
275
|
+
note: 'Extra flags are injected as variables in the function scope.',
|
|
276
|
+
examples: [
|
|
277
|
+
[
|
|
278
|
+
'function https://example.com --file ./fn.js',
|
|
279
|
+
'run ./fn.js with browser access'
|
|
280
|
+
]
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const productList = Object.entries(PRODUCTS)
|
|
286
|
+
.map(([name, product]) => col(name, product.desc))
|
|
287
|
+
.join('\n')
|
|
288
|
+
|
|
289
|
+
const commandList = Object.entries(COMMANDS)
|
|
290
|
+
.map(([name, command]) => col(name, command.desc))
|
|
291
|
+
.join('\n')
|
|
292
|
+
|
|
293
|
+
const global = `Usage
|
|
294
|
+
${cmd('<url> [options]')}
|
|
295
|
+
${cmd('<product> <url|query> [options]')}
|
|
296
|
+
${cmd('login')}
|
|
297
|
+
${cmd('logout')}
|
|
298
|
+
|
|
299
|
+
Commands
|
|
300
|
+
${commandList}
|
|
301
|
+
|
|
302
|
+
Products
|
|
303
|
+
${productList}
|
|
304
|
+
|
|
305
|
+
Options
|
|
306
|
+
${rows(CLI)}
|
|
307
|
+
|
|
308
|
+
Examples
|
|
309
|
+
${cmd('login', 'save an API key from your account')}
|
|
310
|
+
${cmd('https://example.com', 'unified metadata (default)')}
|
|
311
|
+
${cmd(
|
|
312
|
+
'https://example.com --trace',
|
|
313
|
+
'print request & response, API key masked'
|
|
314
|
+
)}
|
|
315
|
+
${cmd(
|
|
316
|
+
'https://example.com --trace-full',
|
|
317
|
+
'same as --trace, including the full API key'
|
|
318
|
+
)}
|
|
319
|
+
${cmd('markdown https://example.com', 'page content as Markdown')}
|
|
320
|
+
${cmd('screenshot https://example.com --fullPage', 'full-page screenshot')}
|
|
321
|
+
${cmd('logo https://github.com --square', 'square brand logo')}
|
|
322
|
+
${cmd('links https://example.com', 'every absolute link on the page')}
|
|
323
|
+
${cmd(
|
|
324
|
+
'search "best coffee" --limit 10 --location es',
|
|
325
|
+
'Google results in Spain, limit 10'
|
|
326
|
+
)}
|
|
327
|
+
${cmd(
|
|
328
|
+
'search "open source llm" --type news --period week',
|
|
329
|
+
'news results from the past week'
|
|
330
|
+
)}
|
|
331
|
+
${cmd(
|
|
332
|
+
'extract https://microlink.io --data \'{"image":{"selector":"meta[property=og:image]","attr":"content","type":"image"}}\'',
|
|
333
|
+
'extract og:image via MQL'
|
|
334
|
+
)}
|
|
335
|
+
${cmd(
|
|
336
|
+
'function https://example.com --file ./fn.js',
|
|
337
|
+
'run ./fn.js with browser access'
|
|
338
|
+
)}
|
|
339
|
+
`
|
|
340
|
+
|
|
341
|
+
const render = (name, product) => {
|
|
342
|
+
const usage = []
|
|
343
|
+
.concat(product.usage)
|
|
344
|
+
.map(line => cmd(line))
|
|
345
|
+
.join('\n')
|
|
346
|
+
const cli = product.cli ?? CLI
|
|
347
|
+
const options = [...product.flags, ...cli]
|
|
348
|
+
const parts = ['Usage', usage, '', gray(product.desc)]
|
|
349
|
+
if (options.length > 0) parts.push('', 'Options', rows(options))
|
|
350
|
+
if (product.browser) parts.push('', 'Browser', rows(BROWSER))
|
|
351
|
+
if (product.note) parts.push('', gray(product.note))
|
|
352
|
+
if (product.examples) {
|
|
353
|
+
parts.push(
|
|
354
|
+
'',
|
|
355
|
+
'Examples',
|
|
356
|
+
...product.examples.map(([rest, comment]) => cmd(rest, comment))
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
return parts.join('\n') + '\n'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = command => {
|
|
363
|
+
const name = ALIAS[command] ?? command
|
|
364
|
+
if (COMMANDS[name]) return render(name, COMMANDS[name])
|
|
365
|
+
return PRODUCTS[name] ? render(name, PRODUCTS[name]) : global
|
|
366
|
+
}
|
package/bin/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
|
-
const { styleText } = require('node:util')
|
|
5
4
|
const { readFileSync } = require('fs')
|
|
6
5
|
const path = require('path')
|
|
7
6
|
const mri = require('mri')
|
|
7
|
+
const helpText = require('./help')
|
|
8
|
+
const { readApiKey, clearConfig } = require('./config')
|
|
9
|
+
const login = require('./login')
|
|
10
|
+
const { gray, white, green, red, orange, link, styleText } = require('./style')
|
|
8
11
|
|
|
9
12
|
const create = require('../src')
|
|
10
13
|
|
|
@@ -13,12 +16,8 @@ const HIDE_CURSOR = '\u001b[?25l'
|
|
|
13
16
|
const CLEAR_LINE = '\r\u001b[K'
|
|
14
17
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
15
18
|
|
|
16
|
-
const gray = str => styleText('gray', str)
|
|
17
|
-
const white = str => styleText('white', str)
|
|
18
|
-
const green = str => styleText('green', str)
|
|
19
|
-
const red = str => styleText('red', str)
|
|
20
19
|
const label = (text, color) =>
|
|
21
|
-
styleText(['inverse', 'bold', color
|
|
20
|
+
styleText(['inverse', 'bold'], color(` ${text.toUpperCase()} `))
|
|
22
21
|
const keyValue = (key, value) => key + ' ' + gray(value)
|
|
23
22
|
|
|
24
23
|
const prettyMs = ms => {
|
|
@@ -66,7 +65,9 @@ const printPretty = (value, indent = 0) => {
|
|
|
66
65
|
if (typeof value !== 'object') return white(String(value))
|
|
67
66
|
|
|
68
67
|
const isArray = Array.isArray(value)
|
|
69
|
-
const keys = isArray
|
|
68
|
+
const keys = isArray
|
|
69
|
+
? value.filter(item => typeof item !== 'function')
|
|
70
|
+
: Object.keys(value).filter(key => typeof value[key] !== 'function')
|
|
70
71
|
if (keys.length === 0) return gray(isArray ? '[]' : '{}')
|
|
71
72
|
|
|
72
73
|
const pad = ' '.repeat(indent)
|
|
@@ -98,8 +99,10 @@ const tracePayload = ({
|
|
|
98
99
|
const rest = { ...requestOptions }
|
|
99
100
|
delete rest.responseType
|
|
100
101
|
const headers = { ...rest.headers }
|
|
101
|
-
if (!full
|
|
102
|
-
|
|
102
|
+
if (!full) {
|
|
103
|
+
for (const key of ['x-api-key', 'authorization', 'cookie']) {
|
|
104
|
+
if (headers[key]) headers[key] = humanizeApiKey(headers[key])
|
|
105
|
+
}
|
|
103
106
|
}
|
|
104
107
|
return {
|
|
105
108
|
request: { url: requestUrl, ...rest, headers },
|
|
@@ -168,7 +171,7 @@ const printFooter = ({ duration, response }) => {
|
|
|
168
171
|
|
|
169
172
|
if (process.stdout.isTTY) console.error()
|
|
170
173
|
console.error(
|
|
171
|
-
label('success',
|
|
174
|
+
label('success', green),
|
|
172
175
|
gray(`${prettyBytes(size)} in ${time}`)
|
|
173
176
|
)
|
|
174
177
|
console.error()
|
|
@@ -188,37 +191,62 @@ const printFooter = ({ duration, response }) => {
|
|
|
188
191
|
keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`.trim())
|
|
189
192
|
)
|
|
190
193
|
}
|
|
191
|
-
if (uri) console.error(' ', keyValue(green('uri'), uri))
|
|
194
|
+
if (uri) console.error(' ', keyValue(green('uri'), link(uri)))
|
|
192
195
|
if (id) console.error(' ', keyValue(green('id'), id))
|
|
193
196
|
}
|
|
194
197
|
|
|
198
|
+
const isClientError = statusCode => statusCode >= 400 && statusCode < 500
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The API reports the actionable reason per field under `data`, keeping
|
|
202
|
+
* `message` as a generic pointer to it.
|
|
203
|
+
*/
|
|
204
|
+
const reasons = error => {
|
|
205
|
+
const values = Object.values(error.data ?? {}).filter(
|
|
206
|
+
value => typeof value === 'string'
|
|
207
|
+
)
|
|
208
|
+
return values.length > 0
|
|
209
|
+
? values
|
|
210
|
+
: [String(error.message).replace(`${error.code}, `, '')]
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
const printFail = error => {
|
|
214
|
+
const color = isClientError(error.statusCode) ? orange : red
|
|
215
|
+
const status = error.status || 'fail'
|
|
216
|
+
const [reason, ...rest] = reasons(error)
|
|
217
|
+
const indent = ' '.repeat(status.length + 2)
|
|
196
218
|
if (process.stdout.isTTY) console.error()
|
|
197
|
-
console.error(
|
|
198
|
-
|
|
199
|
-
gray(String(error.message).replace(`${error.code}, `, ''))
|
|
200
|
-
)
|
|
219
|
+
console.error(label(status, color), gray(reason))
|
|
220
|
+
for (const extra of rest) console.error(indent, gray(extra))
|
|
201
221
|
console.error()
|
|
202
222
|
const id = error.headers?.['x-request-id']
|
|
203
|
-
if (id) console.error(' ', keyValue(
|
|
204
|
-
if (error.url) console.error(' ', keyValue(
|
|
223
|
+
if (id) console.error(' ', keyValue(color('id'), id))
|
|
224
|
+
if (error.url) console.error(' ', keyValue(color('uri'), link(error.url)))
|
|
205
225
|
if (error.code) {
|
|
206
226
|
console.error(
|
|
207
227
|
' ',
|
|
208
228
|
keyValue(
|
|
209
|
-
|
|
229
|
+
color('code'),
|
|
210
230
|
`${error.code}${error.statusCode ? ` (${error.statusCode})` : ''}`
|
|
211
231
|
)
|
|
212
232
|
)
|
|
213
233
|
}
|
|
214
|
-
if (error.more) console.error(' ', keyValue(
|
|
234
|
+
if (error.more) console.error(' ', keyValue(color('more'), link(error.more)))
|
|
235
|
+
if (error.statusCode === 429) {
|
|
236
|
+
console.error(
|
|
237
|
+
' ',
|
|
238
|
+
keyValue(color('hint'), 'run `microlink login` to use an API key')
|
|
239
|
+
)
|
|
240
|
+
}
|
|
215
241
|
}
|
|
216
242
|
|
|
217
|
-
const showHelp =
|
|
218
|
-
console.log(
|
|
243
|
+
const showHelp = command => {
|
|
244
|
+
console.log(helpText(command).trimEnd())
|
|
219
245
|
process.exit(0)
|
|
220
246
|
}
|
|
221
247
|
|
|
248
|
+
const HTTP_HEADER = 'http.header.'
|
|
249
|
+
|
|
222
250
|
const parseHeaders = input => {
|
|
223
251
|
const headers = {}
|
|
224
252
|
for (const item of [].concat(input ?? [])) {
|
|
@@ -231,10 +259,24 @@ const parseHeaders = input => {
|
|
|
231
259
|
return headers
|
|
232
260
|
}
|
|
233
261
|
|
|
262
|
+
const takeHttpHeaders = flags => {
|
|
263
|
+
const headers = {}
|
|
264
|
+
for (const key of Object.keys(flags)) {
|
|
265
|
+
if (!key.startsWith(HTTP_HEADER)) continue
|
|
266
|
+
let value = flags[key]
|
|
267
|
+
delete flags[key]
|
|
268
|
+
if (Array.isArray(value)) value = value.at(-1)
|
|
269
|
+
if (typeof value !== 'string' && typeof value !== 'number') continue
|
|
270
|
+
const name = key.slice(HTTP_HEADER.length).toLowerCase()
|
|
271
|
+
if (name) headers[name] = String(value)
|
|
272
|
+
}
|
|
273
|
+
return headers
|
|
274
|
+
}
|
|
275
|
+
|
|
234
276
|
const argv = mri(process.argv.slice(2), {
|
|
235
277
|
alias: { H: 'header' },
|
|
236
|
-
boolean: ['trace', 'trace-full'],
|
|
237
|
-
string: ['header', 'api-key', 'data', 'file']
|
|
278
|
+
boolean: ['trace', 'trace-full', 'help', 'html', 'markdown'],
|
|
279
|
+
string: ['header', 'api-key', 'data', 'file', 'endpoint']
|
|
238
280
|
})
|
|
239
281
|
|
|
240
282
|
let {
|
|
@@ -245,70 +287,114 @@ let {
|
|
|
245
287
|
file,
|
|
246
288
|
'api-key': apiKeyFlag,
|
|
247
289
|
apiKey: apiKeyCamel,
|
|
290
|
+
endpoint: endpointFlag,
|
|
248
291
|
trace,
|
|
249
292
|
'trace-full': traceFull,
|
|
293
|
+
html: htmlFlag,
|
|
294
|
+
markdown: markdownFlag,
|
|
250
295
|
...flags
|
|
251
296
|
} = argv
|
|
252
297
|
|
|
253
298
|
const isTrace = trace || traceFull
|
|
254
299
|
|
|
255
|
-
if (
|
|
300
|
+
if (!command) showHelp()
|
|
301
|
+
|
|
302
|
+
if (command === 'login' || command === 'logout') {
|
|
303
|
+
if (help) showHelp(command)
|
|
304
|
+
if (command === 'logout') {
|
|
305
|
+
console.error(clearConfig() ? 'Logged out.' : 'Already logged out.')
|
|
306
|
+
process.exit(0)
|
|
307
|
+
}
|
|
308
|
+
login().then(
|
|
309
|
+
() => process.exit(0),
|
|
310
|
+
error => {
|
|
311
|
+
console.error(error.message)
|
|
312
|
+
process.exit(error.code === 'ABORT' ? 130 : 1)
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
} else {
|
|
316
|
+
const apiKey =
|
|
317
|
+
apiKeyFlag || apiKeyCamel || process.env.MICROLINK_API_KEY || readApiKey()
|
|
318
|
+
const endpoint = endpointFlag
|
|
319
|
+
const client = create({
|
|
320
|
+
...(apiKey && { apiKey }),
|
|
321
|
+
...(endpoint && { endpoint })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
if (typeof client[command] !== 'function') {
|
|
325
|
+
if (!target && URL.canParse(command)) {
|
|
326
|
+
target = command
|
|
327
|
+
command = 'metadata'
|
|
328
|
+
} else if (help) {
|
|
329
|
+
showHelp()
|
|
330
|
+
} else {
|
|
331
|
+
console.error(
|
|
332
|
+
`Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
|
|
333
|
+
)
|
|
334
|
+
process.exit(1)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
256
337
|
|
|
257
|
-
|
|
258
|
-
const client = create(apiKey ? { apiKey } : {})
|
|
338
|
+
if (help || !target) showHelp(command)
|
|
259
339
|
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.error(
|
|
266
|
-
`Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
|
|
267
|
-
)
|
|
340
|
+
if (
|
|
341
|
+
isTrace &&
|
|
342
|
+
(command === 'search' || command === 'function' || command === 'run')
|
|
343
|
+
) {
|
|
344
|
+
console.error(`\`--trace\` is not supported for \`${command}\`.`)
|
|
268
345
|
process.exit(1)
|
|
269
346
|
}
|
|
270
|
-
}
|
|
271
347
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
(
|
|
275
|
-
) {
|
|
276
|
-
console.error(`\`--trace\` is not supported for \`${command}\`.`)
|
|
277
|
-
process.exit(1)
|
|
278
|
-
}
|
|
348
|
+
const options = { ...flags }
|
|
349
|
+
const headers = { ...takeHttpHeaders(options), ...parseHeaders(header) }
|
|
350
|
+
if (Object.keys(headers).length > 0) options.headers = headers
|
|
279
351
|
|
|
280
|
-
|
|
281
|
-
const headers = parseHeaders(header)
|
|
282
|
-
if (Object.keys(headers).length > 0) options.headers = headers
|
|
283
|
-
|
|
284
|
-
const invoke = () => {
|
|
352
|
+
let rules
|
|
285
353
|
if (command === 'extract') {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
354
|
+
try {
|
|
355
|
+
rules = JSON.parse(data)
|
|
356
|
+
} catch {
|
|
357
|
+
printFail({ message: 'Invalid --data JSON' })
|
|
358
|
+
process.exit(1)
|
|
359
|
+
}
|
|
291
360
|
}
|
|
292
|
-
return client[command](target, options)
|
|
293
|
-
}
|
|
294
361
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
printFail(error)
|
|
312
|
-
process.exit(1)
|
|
362
|
+
const invoke = () => {
|
|
363
|
+
if (command === 'extract') {
|
|
364
|
+
return client.extract(target, rules, options)
|
|
365
|
+
}
|
|
366
|
+
if (command === 'function' || command === 'run') {
|
|
367
|
+
const code = readFileSync(path.resolve(file), 'utf8')
|
|
368
|
+
return client.function(target, code, options)
|
|
369
|
+
}
|
|
370
|
+
if (command === 'search') {
|
|
371
|
+
return client.search(target, {
|
|
372
|
+
...options,
|
|
373
|
+
...(htmlFlag && { html: true }),
|
|
374
|
+
...(markdownFlag && { markdown: true })
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
return client[command](target, options)
|
|
313
378
|
}
|
|
314
|
-
|
|
379
|
+
|
|
380
|
+
const spin = !isTrace && shouldSpin() ? spinner() : null
|
|
381
|
+
|
|
382
|
+
;(async () => {
|
|
383
|
+
spin?.start()
|
|
384
|
+
const started = Date.now()
|
|
385
|
+
try {
|
|
386
|
+
const result = await invoke()
|
|
387
|
+
const duration = Date.now() - started
|
|
388
|
+
spin?.stop()
|
|
389
|
+
if (isTrace) printJson(tracePayload({ ...client.last, full: traceFull }))
|
|
390
|
+
else if (typeof result === 'string') console.log(result)
|
|
391
|
+
else printJson({ status: 'success', data: result })
|
|
392
|
+
if (!isTrace) printFooter({ duration, response: client.last.response })
|
|
393
|
+
process.exit(0)
|
|
394
|
+
} catch (error) {
|
|
395
|
+
spin?.stop()
|
|
396
|
+
printFail(error)
|
|
397
|
+
process.exit(1)
|
|
398
|
+
}
|
|
399
|
+
})()
|
|
400
|
+
}
|
package/bin/login.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { randomBytes } = require('crypto')
|
|
4
|
+
const { spawn } = require('child_process')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const { writeConfig, readApiKey, configPathDisplay } = require('./config')
|
|
7
|
+
const select = require('./select')
|
|
8
|
+
const { gray } = require('./style')
|
|
9
|
+
|
|
10
|
+
const TIMEOUT_MS = 5 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
const dashboardUrl = () =>
|
|
13
|
+
process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io'
|
|
14
|
+
|
|
15
|
+
const openUrl = url => {
|
|
16
|
+
const { platform } = process
|
|
17
|
+
const child =
|
|
18
|
+
platform === 'win32'
|
|
19
|
+
? spawn('cmd', ['/c', 'start', '""', `"${url}"`], {
|
|
20
|
+
detached: true,
|
|
21
|
+
stdio: 'ignore',
|
|
22
|
+
windowsVerbatimArguments: true
|
|
23
|
+
})
|
|
24
|
+
: spawn(platform === 'darwin' ? 'open' : 'xdg-open', [url], {
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: 'ignore'
|
|
27
|
+
})
|
|
28
|
+
child.on('error', () => {})
|
|
29
|
+
child.unref()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const listen = state =>
|
|
33
|
+
new Promise((resolve, reject) => {
|
|
34
|
+
let settle
|
|
35
|
+
const token = new Promise((resolve, reject) => {
|
|
36
|
+
settle = { resolve, reject }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const cors = res => {
|
|
40
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
41
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
|
42
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type')
|
|
43
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const server = http.createServer((req, res) => {
|
|
47
|
+
cors(res)
|
|
48
|
+
if (req.method === 'OPTIONS') {
|
|
49
|
+
res.writeHead(204)
|
|
50
|
+
res.end()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (req.method !== 'POST') {
|
|
54
|
+
res.writeHead(405)
|
|
55
|
+
res.end()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const chunks = []
|
|
59
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
60
|
+
req.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const body = JSON.parse(Buffer.concat(chunks).toString())
|
|
63
|
+
if (body.state !== state || typeof body.token !== 'string') {
|
|
64
|
+
res.writeHead(400)
|
|
65
|
+
res.end()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
res.writeHead(204)
|
|
69
|
+
res.end()
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
settle.resolve(body.token)
|
|
72
|
+
} catch {
|
|
73
|
+
res.writeHead(400)
|
|
74
|
+
res.end()
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const close = () => {
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
server.close()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
close()
|
|
86
|
+
settle.reject(new Error('Timed out waiting for dashboard authorization'))
|
|
87
|
+
}, TIMEOUT_MS)
|
|
88
|
+
|
|
89
|
+
server.listen(0, '127.0.0.1', () => {
|
|
90
|
+
resolve({ port: server.address().port, token, close })
|
|
91
|
+
})
|
|
92
|
+
server.on('error', reject)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const fetchKeys = async token => {
|
|
96
|
+
const res = await fetch(new URL('/api/v1/connect/keys', dashboardUrl()), {
|
|
97
|
+
headers: { authorization: `Bearer ${token}` }
|
|
98
|
+
})
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
throw new Error(`Could not load API keys (${res.status})`)
|
|
101
|
+
}
|
|
102
|
+
const body = await res.json()
|
|
103
|
+
return Array.isArray(body) ? body : body.keys
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const asChoice = key => ({
|
|
107
|
+
name: key.label,
|
|
108
|
+
hint: key.maskedKey,
|
|
109
|
+
value: key.apiKey
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const login = async () => {
|
|
113
|
+
const state = randomBytes(16).toString('hex')
|
|
114
|
+
const { port, token: tokenP, close } = await listen(state)
|
|
115
|
+
const url = new URL('/connect', dashboardUrl())
|
|
116
|
+
url.searchParams.set('port', String(port))
|
|
117
|
+
url.searchParams.set('state', state)
|
|
118
|
+
process.stderr.write(`Opening ${url}\n`)
|
|
119
|
+
openUrl(url.toString())
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const keys = await fetchKeys(await tokenP)
|
|
123
|
+
if (!keys?.length) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`No API keys on this account. Create a plan at ${dashboardUrl()}/plans`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const choices = keys.map(asChoice)
|
|
130
|
+
const picked =
|
|
131
|
+
choices.length === 1
|
|
132
|
+
? choices[0]
|
|
133
|
+
: await select({
|
|
134
|
+
message: 'Which API key?',
|
|
135
|
+
choices,
|
|
136
|
+
current: readApiKey()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
writeConfig({ apiKey: picked.value })
|
|
140
|
+
process.stderr.write(
|
|
141
|
+
`\n${gray('Saved')} ${picked.name} ${gray(`to ${configPathDisplay()}`)}\n`
|
|
142
|
+
)
|
|
143
|
+
} finally {
|
|
144
|
+
close()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = login
|
package/bin/select.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const readline = require('readline')
|
|
4
|
+
const { gray, white, styleText } = require('./style')
|
|
5
|
+
|
|
6
|
+
const HIDE = '\u001b[?25l'
|
|
7
|
+
const SHOW = '\u001b[?25h'
|
|
8
|
+
const UP_N = n => `\u001b[${n}A`
|
|
9
|
+
|
|
10
|
+
const label = (choice, current) => {
|
|
11
|
+
const mark = current && choice.value === current ? gray(' (current)') : ''
|
|
12
|
+
return `${choice.name} ${gray(`(${choice.hint})`)}${mark}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const numbered = ({ message, choices, current }) =>
|
|
16
|
+
new Promise((resolve, reject) => {
|
|
17
|
+
for (const [i, choice] of choices.entries()) {
|
|
18
|
+
process.stderr.write(` ${i + 1}) ${label(choice, current)}\n`)
|
|
19
|
+
}
|
|
20
|
+
const rl = readline.createInterface({
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: process.stderr
|
|
23
|
+
})
|
|
24
|
+
rl.question(`${message} `, answer => {
|
|
25
|
+
rl.close()
|
|
26
|
+
const choice = choices[Number.parseInt(answer, 10) - 1]
|
|
27
|
+
if (!choice) reject(new Error('Invalid selection'))
|
|
28
|
+
else resolve(choice)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const arrows = ({ message, choices, current }) => {
|
|
33
|
+
let index = Math.max(
|
|
34
|
+
0,
|
|
35
|
+
choices.findIndex(choice => choice.value === current)
|
|
36
|
+
)
|
|
37
|
+
const lines = choices.length + 1
|
|
38
|
+
|
|
39
|
+
const draw = first => {
|
|
40
|
+
if (!first) process.stderr.write(UP_N(lines))
|
|
41
|
+
process.stderr.write(`${styleText('cyan', '?')} ${white(message)}\n`)
|
|
42
|
+
for (const [i, choice] of choices.entries()) {
|
|
43
|
+
const active = i === index
|
|
44
|
+
const prefix = active ? styleText('cyan', '❯') : ' '
|
|
45
|
+
const text = active
|
|
46
|
+
? styleText('cyan', label(choice, current))
|
|
47
|
+
: label(choice, current)
|
|
48
|
+
process.stderr.write(`\u001b[2K${prefix} ${text}\n`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
readline.emitKeypressEvents(process.stdin)
|
|
54
|
+
process.stdin.setRawMode(true)
|
|
55
|
+
process.stderr.write(HIDE)
|
|
56
|
+
draw(true)
|
|
57
|
+
|
|
58
|
+
const cleanup = () => {
|
|
59
|
+
process.stdin.setRawMode(false)
|
|
60
|
+
process.stdin.off('keypress', onKey)
|
|
61
|
+
process.stderr.write(SHOW)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const onKey = (_str, key) => {
|
|
65
|
+
if (key.name === 'up') {
|
|
66
|
+
index = (index - 1 + choices.length) % choices.length
|
|
67
|
+
draw()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (key.name === 'down') {
|
|
71
|
+
index = (index + 1) % choices.length
|
|
72
|
+
draw()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (key.name === 'return') {
|
|
76
|
+
cleanup()
|
|
77
|
+
resolve(choices[index])
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (key.ctrl && key.name === 'c') {
|
|
81
|
+
cleanup()
|
|
82
|
+
reject(Object.assign(new Error('Aborted'), { code: 'ABORT' }))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
process.stdin.on('keypress', onKey)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const select = ({ message, choices, current }) => {
|
|
91
|
+
if (choices.length === 1) return Promise.resolve(choices[0])
|
|
92
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
93
|
+
return numbered({ message, choices, current })
|
|
94
|
+
}
|
|
95
|
+
return arrows({ message, choices, current })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = select
|
package/bin/style.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { styleText } = require('node:util')
|
|
4
|
+
const { default: terminalLink } = require('terminal-link')
|
|
5
|
+
|
|
6
|
+
const gray = str => styleText('gray', str)
|
|
7
|
+
const white = str => styleText('white', str)
|
|
8
|
+
const green = str => styleText('green', str)
|
|
9
|
+
const red = str => styleText('red', str)
|
|
10
|
+
|
|
11
|
+
const ORANGE_256 = '\u001b[38;5;208m'
|
|
12
|
+
const DEFAULT_FOREGROUND = '\u001b[39m'
|
|
13
|
+
|
|
14
|
+
const orange = str =>
|
|
15
|
+
process.stdout.hasColors?.()
|
|
16
|
+
? `${ORANGE_256}${str}${DEFAULT_FOREGROUND}`
|
|
17
|
+
: String(str)
|
|
18
|
+
|
|
19
|
+
const link = url => terminalLink.stderr(url, url, { fallback: false })
|
|
20
|
+
|
|
21
|
+
module.exports = { gray, white, green, red, orange, link, styleText }
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "microlink.io",
|
|
3
3
|
"description": "The Microlink API organized into products, each returning a direct result",
|
|
4
4
|
"homepage": "https://github.com/microlinkhq/microlink",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"exports": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"microlink": "bin/index.js"
|
|
17
|
+
"microlink": "bin/index.js",
|
|
18
|
+
"microlink.io": "bin/index.js"
|
|
18
19
|
},
|
|
19
20
|
"author": {
|
|
20
21
|
"email": "hello@microlink.io",
|
|
@@ -43,10 +44,11 @@
|
|
|
43
44
|
"seo"
|
|
44
45
|
],
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"@microlink/
|
|
49
|
-
"
|
|
47
|
+
"mri": "~1.2.0",
|
|
48
|
+
"terminal-link": "~5.0.0",
|
|
49
|
+
"@microlink/google": "1.2.0",
|
|
50
|
+
"@microlink/function": "0.3.2",
|
|
51
|
+
"@microlink/mql": "0.18.1"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"ava": "latest",
|
|
@@ -61,9 +63,6 @@
|
|
|
61
63
|
"bin",
|
|
62
64
|
"src"
|
|
63
65
|
],
|
|
64
|
-
"scripts": {
|
|
65
|
-
"test": "ava && tsd"
|
|
66
|
-
},
|
|
67
66
|
"preferGlobal": true,
|
|
68
67
|
"license": "MIT",
|
|
69
68
|
"ava": {
|
|
@@ -76,5 +75,7 @@
|
|
|
76
75
|
},
|
|
77
76
|
"directory": "test"
|
|
78
77
|
},
|
|
79
|
-
"
|
|
80
|
-
|
|
78
|
+
"scripts": {
|
|
79
|
+
"test": "ava && tsd"
|
|
80
|
+
}
|
|
81
|
+
}
|
package/bin/help.txt
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
Usage
|
|
2
|
-
$ microlink <url> [options]
|
|
3
|
-
$ microlink <product> <url|query> [options]
|
|
4
|
-
|
|
5
|
-
Products
|
|
6
|
-
metadata Unified metadata (title, description, image, ...); default
|
|
7
|
-
logo Brand logo of the site (--square prefers the square variant)
|
|
8
|
-
markdown Page content as Markdown
|
|
9
|
-
html Page content as HTML
|
|
10
|
-
text Page content as plain text
|
|
11
|
-
video Primary video of the page (returns the asset object)
|
|
12
|
-
audio Primary audio of the page (returns the asset object)
|
|
13
|
-
emails Every email address present on the page
|
|
14
|
-
links Every absolute link URL on the page
|
|
15
|
-
images Every absolute image URL on the page
|
|
16
|
-
videos Every absolute video URL on the page
|
|
17
|
-
audios Every absolute audio URL on the page
|
|
18
|
-
extract Custom MQL data rules (--data '{"field":{...}}')
|
|
19
|
-
screenshot Take a screenshot (returns the asset object)
|
|
20
|
-
pdf Generate a PDF (returns the asset object)
|
|
21
|
-
embed oEmbed-style embeddable iframe ({ html, scripts })
|
|
22
|
-
technologies Detect the tech stack behind the site
|
|
23
|
-
lighthouse Run a Lighthouse report
|
|
24
|
-
search Google as structured data (query instead of url); --type
|
|
25
|
-
routes to news, images, videos, places, maps, shopping,
|
|
26
|
-
scholar, patents or autocomplete
|
|
27
|
-
function Run code remotely with browser access (--file ./fn.js);
|
|
28
|
-
extra flags are injected as variables in the function scope
|
|
29
|
-
|
|
30
|
-
Options
|
|
31
|
-
--api-key Microlink API key (defaults to MICROLINK_API_KEY env)
|
|
32
|
-
--header, -H Extra request header as 'Name: value' (repeatable)
|
|
33
|
-
--data JSON data rules for the extract command
|
|
34
|
-
--file Path to the code file for the function command
|
|
35
|
-
--trace Print request & response payload (API key masked)
|
|
36
|
-
--trace-full Same as --trace, including the full API key
|
|
37
|
-
--help Show this help
|
|
38
|
-
|
|
39
|
-
Any other flag is passed as an option to the product, e.g. --fullPage,
|
|
40
|
-
--device 'iPhone 11', --waitUntil networkidle0, --selector article.
|
|
41
|
-
|
|
42
|
-
Examples
|
|
43
|
-
$ microlink https://example.com
|
|
44
|
-
$ microlink https://example.com --trace
|
|
45
|
-
$ microlink https://example.com --trace-full
|
|
46
|
-
$ microlink markdown https://example.com
|
|
47
|
-
$ microlink screenshot https://example.com --fullPage
|
|
48
|
-
$ microlink logo https://github.com --square
|
|
49
|
-
$ microlink links https://example.com
|
|
50
|
-
$ microlink search "best coffee" --limit 10 --location es
|
|
51
|
-
$ microlink search "open source llm" --type news --period week
|
|
52
|
-
$ microlink extract https://microlink.io --data '{"image":{"selector":"meta[property=og:image]","attr":"content","type":"image"}}'
|
|
53
|
-
$ microlink function https://example.com --file ./fn.js
|