microlink.io 0.0.5 → 0.2.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/bin/help.js ADDED
@@ -0,0 +1,334 @@
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
+ ['--api-key', 'Microlink API key (defaults to MICROLINK_API_KEY env)'],
16
+ ['--endpoint', 'Microlink API endpoint'],
17
+ ['--header, -H', "Extra request header as 'Name: value' (repeatable)"],
18
+ [
19
+ '--http.header.<name>',
20
+ 'HTTP request header (e.g. --http.header.authorization)'
21
+ ],
22
+ ['--trace', 'Print request & response payload (API key masked)'],
23
+ ['--trace-full', 'Same as --trace, including the full API key'],
24
+ ['--help', 'Show this help']
25
+ ]
26
+
27
+ const CLI_NO_TRACE = CLI.filter(
28
+ ([name]) => name !== '--trace' && name !== '--trace-full'
29
+ )
30
+
31
+ const BROWSER = [
32
+ ['--adblock', 'Block ads and trackers'],
33
+ ['--animations', 'Enable CSS animations'],
34
+ ['--cacheKey', 'Custom cache key'],
35
+ ['--click', 'CSS selector(s) to click before capture'],
36
+ ['--colorScheme', 'Color scheme: no-preference, light, dark'],
37
+ ['--device', "Emulate a device (e.g. 'iPhone 11')"],
38
+ ['--filename', 'Suggested download filename'],
39
+ ['--filter', 'Pick response fields'],
40
+ ['--force', 'Bypass the cache'],
41
+ ['--javascript', 'Enable or disable JavaScript'],
42
+ ['--mediaType', 'Emulate media: screen, print'],
43
+ ['--modules', 'Inject ES module URLs'],
44
+ ['--prerender', 'Prerender: auto, true, false'],
45
+ ['--proxy', 'Proxy URL or country'],
46
+ ['--retry', 'Retry count'],
47
+ ['--scripts', 'Inject script URLs'],
48
+ ['--scroll', 'CSS selector to scroll into view'],
49
+ ['--staleTtl', 'Stale-while-revalidate TTL'],
50
+ ['--styles', 'Inject stylesheet URLs'],
51
+ ['--timeout', 'Request timeout'],
52
+ ['--ttl', 'Cache TTL'],
53
+ ['--viewport', 'Viewport as JSON (width, height, ...)'],
54
+ ['--waitForSelector', 'Wait until a CSS selector matches'],
55
+ ['--waitForTimeout', 'Wait for a duration before continuing'],
56
+ ['--waitUntil', 'auto, load, domcontentloaded, networkidle0, networkidle2']
57
+ ]
58
+
59
+ const CONTENT = [
60
+ ['--selector', 'CSS selector to scope the extraction'],
61
+ ['--selectorAll', 'CSS selector(s) matching many nodes'],
62
+ ['--type', 'Cast the extracted value (url, image, ...)']
63
+ ]
64
+
65
+ const COLLECTION = [
66
+ ['--selector', 'CSS selector (single node)'],
67
+ ['--selectorAll', 'CSS selector(s) matching many nodes'],
68
+ ['--attr', 'Attribute to read (href, src, ...)'],
69
+ ['--type', 'Cast each value (url, image, email, ...)']
70
+ ]
71
+
72
+ const ALIAS = { run: 'function' }
73
+
74
+ const content = (name, desc) => ({
75
+ usage: `${name} <url> [options]`,
76
+ desc,
77
+ flags: CONTENT,
78
+ browser: true,
79
+ examples: [[`${name} https://example.com`, desc]]
80
+ })
81
+
82
+ const collection = (name, desc) => ({
83
+ usage: `${name} <url> [options]`,
84
+ desc,
85
+ flags: COLLECTION,
86
+ browser: true,
87
+ examples: [[`${name} https://example.com`, desc]]
88
+ })
89
+
90
+ const PRODUCTS = {
91
+ metadata: {
92
+ usage: ['metadata <url> [options]', '<url> [options]'],
93
+ desc: 'Unified metadata (title, description, image, ...); default',
94
+ flags: [
95
+ ['--palette', 'Also extract dominant colors from images'],
96
+ ['--meta', 'Include metadata fields, or false to skip']
97
+ ],
98
+ browser: true,
99
+ examples: [
100
+ ['https://example.com', 'unified metadata (default)'],
101
+ [
102
+ 'https://example.com --trace',
103
+ 'print request & response, API key masked'
104
+ ]
105
+ ]
106
+ },
107
+ logo: {
108
+ usage: 'logo <url> [options]',
109
+ desc: 'Brand logo of the site (--square prefers the square variant)',
110
+ flags: [
111
+ ['--square', 'Prefer the square logo variant'],
112
+ ['--palette', 'Also extract dominant colors']
113
+ ],
114
+ browser: true,
115
+ examples: [['logo https://github.com --square', 'square brand logo']]
116
+ },
117
+ markdown: content('markdown', 'Page content as Markdown'),
118
+ html: content('html', 'Page content as HTML'),
119
+ text: content('text', 'Page content as plain text'),
120
+ video: {
121
+ usage: 'video <url> [options]',
122
+ desc: 'Primary video of the page (returns the asset object)',
123
+ flags: [['--meta', 'Include metadata fields, or false to skip']],
124
+ browser: true,
125
+ examples: [['video https://example.com', 'primary video asset']]
126
+ },
127
+ audio: {
128
+ usage: 'audio <url> [options]',
129
+ desc: 'Primary audio of the page (returns the asset object)',
130
+ flags: [['--meta', 'Include metadata fields, or false to skip']],
131
+ browser: true,
132
+ examples: [['audio https://example.com', 'primary audio asset']]
133
+ },
134
+ emails: collection('emails', 'Every email address present on the page'),
135
+ links: collection('links', 'Every absolute link URL on the page'),
136
+ images: collection('images', 'Every absolute image URL on the page'),
137
+ videos: collection('videos', 'Every absolute video URL on the page'),
138
+ audios: collection('audios', 'Every absolute audio URL on the page'),
139
+ extract: {
140
+ usage: 'extract <url> --data <json> [options]',
141
+ desc: 'Custom MQL data rules',
142
+ flags: [['--data', 'JSON data rules (e.g. --data \'{"field":{...}}\')']],
143
+ browser: true,
144
+ examples: [
145
+ [
146
+ 'extract https://microlink.io --data \'{"image":{"selector":"meta[property=og:image]","attr":"content","type":"image"}}\'',
147
+ 'extract og:image via MQL'
148
+ ]
149
+ ]
150
+ },
151
+ screenshot: {
152
+ usage: 'screenshot <url> [options]',
153
+ desc: 'Take a screenshot (returns the asset object)',
154
+ flags: [
155
+ ['--fullPage', 'Capture the full scrollable page'],
156
+ ['--type', 'Image format: png, jpeg'],
157
+ ['--element', 'CSS selector of the element to capture'],
158
+ ['--omitBackground', 'Transparent background (png)'],
159
+ ['--optimizeForSpeed', 'Faster encode, larger file'],
160
+ ['--overlay', 'Browser chrome overlay as JSON'],
161
+ ['--codeScheme', 'Syntax theme for code pages'],
162
+ ['--animated', 'Animated screenshot (GIF/MP4)'],
163
+ ['--palette', 'Also extract dominant colors'],
164
+ ['--quality', 'JPEG quality (0–100)']
165
+ ],
166
+ browser: true,
167
+ examples: [
168
+ ['screenshot https://example.com --fullPage', 'full-page screenshot']
169
+ ]
170
+ },
171
+ pdf: {
172
+ usage: 'pdf <url> [options]',
173
+ desc: 'Generate a PDF (returns the asset object)',
174
+ flags: [
175
+ ['--format', 'Page format: Letter, Legal, A4, ...'],
176
+ ['--margin', "Margin (e.g. '0.5cm' or JSON)"],
177
+ ['--scale', 'Scale (0.1–2)'],
178
+ ['--landscape', 'Landscape orientation'],
179
+ ['--pageRanges', "Pages to print (e.g. '1-3')"],
180
+ ['--width', 'Page width'],
181
+ ['--height', 'Page height'],
182
+ ['--printBackground', 'Print background graphics']
183
+ ],
184
+ browser: true,
185
+ examples: [['pdf https://example.com --format A4', 'A4 PDF']]
186
+ },
187
+ embed: {
188
+ usage: 'embed <url> [options]',
189
+ desc: 'oEmbed-style embeddable iframe ({ html, scripts })',
190
+ flags: [
191
+ ['--maxWidth', 'Maximum iframe width'],
192
+ ['--maxHeight', 'Maximum iframe height']
193
+ ],
194
+ browser: true,
195
+ examples: [['embed https://example.com', 'embeddable iframe']]
196
+ },
197
+ technologies: {
198
+ usage: 'technologies <url> [options]',
199
+ desc: 'Detect the tech stack behind the site',
200
+ flags: [],
201
+ browser: true,
202
+ examples: [['technologies https://example.com', 'detect the tech stack']]
203
+ },
204
+ lighthouse: {
205
+ usage: 'lighthouse <url> [options]',
206
+ desc: 'Run a Lighthouse report',
207
+ flags: [
208
+ ['--onlyCategories', 'Limit to these categories'],
209
+ ['--onlyAudits', 'Limit to these audits'],
210
+ ['--skipAudits', 'Skip these audits'],
211
+ ['--output', 'Report format: json, html, csv']
212
+ ],
213
+ browser: true,
214
+ examples: [['lighthouse https://example.com', 'run a Lighthouse report']]
215
+ },
216
+ search: {
217
+ usage: 'search <query> [options]',
218
+ desc: 'Google as structured data (query instead of url)',
219
+ flags: [
220
+ [
221
+ '--type',
222
+ 'news, images, videos, places, maps, shopping, scholar, patents, autocomplete'
223
+ ],
224
+ ['--limit', 'Maximum number of results'],
225
+ ['--location', 'Country or locale (e.g. es)'],
226
+ ['--period', 'Recency: hour, day, week, month, year'],
227
+ ['--timeout', 'Request timeout']
228
+ ],
229
+ cli: CLI_NO_TRACE,
230
+ examples: [
231
+ [
232
+ 'search "best coffee" --limit 10 --location es',
233
+ 'Google results in Spain, limit 10'
234
+ ],
235
+ [
236
+ 'search "open source llm" --type news --period week',
237
+ 'news results from the past week'
238
+ ]
239
+ ]
240
+ },
241
+ function: {
242
+ usage: 'function <url> --file <path> [options]',
243
+ desc: 'Run code remotely with browser access',
244
+ flags: [['--file', 'Path to the code file']],
245
+ browser: true,
246
+ cli: CLI_NO_TRACE,
247
+ note: 'Extra flags are injected as variables in the function scope.',
248
+ examples: [
249
+ [
250
+ 'function https://example.com --file ./fn.js',
251
+ 'run ./fn.js with browser access'
252
+ ]
253
+ ]
254
+ }
255
+ }
256
+
257
+ const productList = Object.entries(PRODUCTS)
258
+ .map(([name, product]) => col(name, product.desc))
259
+ .join('\n')
260
+
261
+ const global = `Usage
262
+ ${cmd('<url> [options]')}
263
+ ${cmd('<product> <url|query> [options]')}
264
+
265
+ Products
266
+ ${productList}
267
+
268
+ Options
269
+ ${rows(CLI)}
270
+
271
+ Examples
272
+ ${cmd('https://example.com', 'unified metadata (default)')}
273
+ ${cmd(
274
+ 'https://example.com --trace',
275
+ 'print request & response, API key masked'
276
+ )}
277
+ ${cmd(
278
+ 'https://example.com --trace-full',
279
+ 'same as --trace, including the full API key'
280
+ )}
281
+ ${cmd('markdown https://example.com', 'page content as Markdown')}
282
+ ${cmd('screenshot https://example.com --fullPage', 'full-page screenshot')}
283
+ ${cmd('logo https://github.com --square', 'square brand logo')}
284
+ ${cmd('links https://example.com', 'every absolute link on the page')}
285
+ ${cmd(
286
+ 'search "best coffee" --limit 10 --location es',
287
+ 'Google results in Spain, limit 10'
288
+ )}
289
+ ${cmd(
290
+ 'search "open source llm" --type news --period week',
291
+ 'news results from the past week'
292
+ )}
293
+ ${cmd(
294
+ 'extract https://microlink.io --data \'{"image":{"selector":"meta[property=og:image]","attr":"content","type":"image"}}\'',
295
+ 'extract og:image via MQL'
296
+ )}
297
+ ${cmd(
298
+ 'function https://example.com --file ./fn.js',
299
+ 'run ./fn.js with browser access'
300
+ )}
301
+ `
302
+
303
+ const render = (name, product) => {
304
+ const usage = []
305
+ .concat(product.usage)
306
+ .map(line => cmd(line))
307
+ .join('\n')
308
+ const cli = product.cli ?? CLI
309
+ const options = [...product.flags, ...cli]
310
+ const parts = [
311
+ 'Usage',
312
+ usage,
313
+ '',
314
+ gray(product.desc),
315
+ '',
316
+ 'Options',
317
+ rows(options)
318
+ ]
319
+ if (product.browser) parts.push('', 'Browser', rows(BROWSER))
320
+ if (product.note) parts.push('', gray(product.note))
321
+ if (product.examples) {
322
+ parts.push(
323
+ '',
324
+ 'Examples',
325
+ ...product.examples.map(([rest, comment]) => cmd(rest, comment))
326
+ )
327
+ }
328
+ return parts.join('\n') + '\n'
329
+ }
330
+
331
+ module.exports = command => {
332
+ const name = ALIAS[command] ?? command
333
+ return PRODUCTS[name] ? render(name, PRODUCTS[name]) : global
334
+ }
package/bin/index.js CHANGED
@@ -3,16 +3,223 @@
3
3
 
4
4
  const { readFileSync } = require('fs')
5
5
  const path = require('path')
6
- const jsome = require('jsome')
7
6
  const mri = require('mri')
7
+ const helpText = require('./help')
8
+ const { gray, white, green, red, styleText } = require('./style')
8
9
 
9
10
  const create = require('../src')
10
11
 
11
- const showHelp = () => {
12
- console.log(readFileSync(path.join(__dirname, 'help.txt'), 'utf8'))
12
+ const SHOW_CURSOR = '\u001b[?25h'
13
+ const HIDE_CURSOR = '\u001b[?25l'
14
+ const CLEAR_LINE = '\r\u001b[K'
15
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
16
+
17
+ const label = (text, color) =>
18
+ styleText(['inverse', 'bold', color], ` ${text.toUpperCase()} `)
19
+ const keyValue = (key, value) => key + ' ' + gray(value)
20
+
21
+ const prettyMs = ms => {
22
+ if (!Number.isFinite(ms)) return 'unknown'
23
+ const sign = ms < 0 ? '-' : ''
24
+ let n = Math.abs(ms)
25
+ if (n < 1000) return `${sign}${Math.round(n)}ms`
26
+ n /= 1000
27
+ if (n < 60) return `${sign}${n.toFixed(1).replace(/\.0$/, '')}s`
28
+ const hours = Math.floor(n / 3600)
29
+ n %= 3600
30
+ const mins = Math.floor(n / 60)
31
+ const secs = (n % 60).toFixed(1).replace(/\.0$/, '')
32
+ if (hours) return `${sign}${hours}h ${mins}m ${secs}s`
33
+ return secs === '0' ? `${sign}${mins}m` : `${sign}${mins}m ${secs}s`
34
+ }
35
+
36
+ const prettyBytes = n => {
37
+ if (!Number.isFinite(n) || n < 1000) return `${Math.round(n || 0)} B`
38
+ if (n < 1e6) {
39
+ const val = n / 1000
40
+ return `${
41
+ val >= 100 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')
42
+ } kB`
43
+ }
44
+ return `${(n / 1e6).toFixed(1).replace(/\.0$/, '')} MB`
45
+ }
46
+
47
+ const toPlainHeaders = headers => {
48
+ if (!headers) return {}
49
+ if (typeof headers.entries === 'function') {
50
+ return Object.fromEntries(headers.entries())
51
+ }
52
+ return headers
53
+ }
54
+
55
+ const humanizeApiKey = apiKey => `${String(apiKey).slice(0, 5)}…`
56
+
57
+ const quote = str =>
58
+ gray('"') + white(JSON.stringify(str).slice(1, -1)) + gray('"')
59
+
60
+ const printPretty = (value, indent = 0) => {
61
+ if (value === null) return white('null')
62
+ if (typeof value === 'string') return quote(value)
63
+ if (typeof value !== 'object') return white(String(value))
64
+
65
+ const isArray = Array.isArray(value)
66
+ const keys = isArray ? value : Object.keys(value)
67
+ if (keys.length === 0) return gray(isArray ? '[]' : '{}')
68
+
69
+ const pad = ' '.repeat(indent)
70
+ const inner = ' '.repeat(indent + 1)
71
+ const open = gray(isArray ? '[' : '{')
72
+ const close = gray(isArray ? ']' : '}')
73
+ const lines = keys.map(key => {
74
+ if (isArray) return inner + printPretty(key, indent + 1)
75
+ const name = /^[A-Za-z_$][\w$]*$/.test(key) ? white(key) : quote(key)
76
+ return inner + name + gray(':') + ' ' + printPretty(value[key], indent + 1)
77
+ })
78
+ return open + '\n' + lines.join(gray(',') + '\n') + '\n' + pad + close
79
+ }
80
+
81
+ const printJson = payload => {
82
+ console.log(
83
+ process.stdout.hasColors?.()
84
+ ? printPretty(payload)
85
+ : JSON.stringify(payload, null, 2)
86
+ )
87
+ }
88
+
89
+ const tracePayload = ({
90
+ requestUrl,
91
+ requestOptions = {},
92
+ response,
93
+ full = false
94
+ }) => {
95
+ const rest = { ...requestOptions }
96
+ delete rest.responseType
97
+ const headers = { ...rest.headers }
98
+ if (!full) {
99
+ for (const key of ['x-api-key', 'authorization', 'cookie']) {
100
+ if (headers[key]) headers[key] = humanizeApiKey(headers[key])
101
+ }
102
+ }
103
+ return {
104
+ request: { url: requestUrl, ...rest, headers },
105
+ response: {
106
+ ...response,
107
+ headers: toPlainHeaders(response?.headers)
108
+ }
109
+ }
110
+ }
111
+
112
+ const shouldSpin = () =>
113
+ !process.env.NO_COLOR &&
114
+ process.env.FORCE_COLOR !== '0' &&
115
+ Boolean(process.stdout?.hasColors?.())
116
+
117
+ const spinner = () => {
118
+ const now = Date.now()
119
+ let i = 0
120
+ let timer
121
+ const draw = () => {
122
+ process.stderr.write(
123
+ `${CLEAR_LINE}${FRAMES[i++ % FRAMES.length]} ${prettyMs(
124
+ Date.now() - now
125
+ )}`
126
+ )
127
+ }
128
+ return {
129
+ start () {
130
+ process.stderr.write(HIDE_CURSOR)
131
+ draw()
132
+ process.on('SIGINT', () => {
133
+ process.stderr.write(CLEAR_LINE + SHOW_CURSOR)
134
+ process.exit(130)
135
+ })
136
+ timer = setInterval(draw, 50)
137
+ },
138
+ stop () {
139
+ clearInterval(timer)
140
+ process.stderr.write(CLEAR_LINE + SHOW_CURSOR)
141
+ }
142
+ }
143
+ }
144
+
145
+ const printFooter = ({ duration, response }) => {
146
+ const headers = toPlainHeaders(response?.headers)
147
+ const time = prettyMs(duration)
148
+ const size = Number(headers['content-length']) || 0
149
+ const serverTiming = headers['server-timing']
150
+ const id = headers['x-request-id']
151
+ const edgeCacheStatus = headers['cf-cache-status']
152
+ const unifiedCacheStatus = headers['x-cache-status']
153
+ const cacheStatus =
154
+ unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT'
155
+ ? edgeCacheStatus
156
+ : unifiedCacheStatus
157
+ const timestamp = Number(headers['x-timestamp'])
158
+ const ttl = Number(headers['x-cache-ttl'])
159
+ const expires = timestamp + ttl - Date.now()
160
+ const expiredAt =
161
+ cacheStatus === 'HIT' && Number.isFinite(expires)
162
+ ? `(${prettyMs(expires)})`
163
+ : ''
164
+ const fetchMode = headers['x-fetch-mode']
165
+ const fetchTime = fetchMode && `(${headers['x-fetch-time']})`
166
+ const uri = response?.url
167
+
168
+ if (process.stdout.isTTY) console.error()
169
+ console.error(
170
+ label('success', 'green'),
171
+ gray(`${prettyBytes(size)} in ${time}`)
172
+ )
173
+ console.error()
174
+
175
+ if (serverTiming) {
176
+ console.error(' ', keyValue(green('timing'), serverTiming))
177
+ }
178
+ if (cacheStatus) {
179
+ console.error(
180
+ ' ',
181
+ keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`.trim())
182
+ )
183
+ }
184
+ if (fetchMode) {
185
+ console.error(
186
+ ' ',
187
+ keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`.trim())
188
+ )
189
+ }
190
+ if (uri) console.error(' ', keyValue(green('uri'), uri))
191
+ if (id) console.error(' ', keyValue(green('id'), id))
192
+ }
193
+
194
+ const printFail = error => {
195
+ if (process.stdout.isTTY) console.error()
196
+ console.error(
197
+ label(error.status || 'fail', 'red'),
198
+ gray(String(error.message).replace(`${error.code}, `, ''))
199
+ )
200
+ console.error()
201
+ const id = error.headers?.['x-request-id']
202
+ if (id) console.error(' ', keyValue(red('id'), id))
203
+ if (error.url) console.error(' ', keyValue(red('uri'), error.url))
204
+ if (error.code) {
205
+ console.error(
206
+ ' ',
207
+ keyValue(
208
+ red('code'),
209
+ `${error.code}${error.statusCode ? ` (${error.statusCode})` : ''}`
210
+ )
211
+ )
212
+ }
213
+ if (error.more) console.error(' ', keyValue(red('more'), error.more))
214
+ }
215
+
216
+ const showHelp = command => {
217
+ console.log(helpText(command).trimEnd())
13
218
  process.exit(0)
14
219
  }
15
220
 
221
+ const HTTP_HEADER = 'http.header.'
222
+
16
223
  const parseHeaders = input => {
17
224
  const headers = {}
18
225
  for (const item of [].concat(input ?? [])) {
@@ -25,12 +232,27 @@ const parseHeaders = input => {
25
232
  return headers
26
233
  }
27
234
 
235
+ const takeHttpHeaders = flags => {
236
+ const headers = {}
237
+ for (const key of Object.keys(flags)) {
238
+ if (!key.startsWith(HTTP_HEADER)) continue
239
+ let value = flags[key]
240
+ delete flags[key]
241
+ if (Array.isArray(value)) value = value.at(-1)
242
+ if (typeof value !== 'string' && typeof value !== 'number') continue
243
+ const name = key.slice(HTTP_HEADER.length).toLowerCase()
244
+ if (name) headers[name] = String(value)
245
+ }
246
+ return headers
247
+ }
248
+
28
249
  const argv = mri(process.argv.slice(2), {
29
250
  alias: { H: 'header' },
30
- string: ['header', 'api-key', 'data', 'file']
251
+ boolean: ['trace', 'trace-full', 'help'],
252
+ string: ['header', 'api-key', 'data', 'file', 'endpoint']
31
253
  })
32
254
 
33
- const {
255
+ let {
34
256
  _: [command, target],
35
257
  header,
36
258
  help,
@@ -38,23 +260,49 @@ const {
38
260
  file,
39
261
  'api-key': apiKeyFlag,
40
262
  apiKey: apiKeyCamel,
263
+ endpoint: endpointFlag,
264
+ trace,
265
+ 'trace-full': traceFull,
41
266
  ...flags
42
267
  } = argv
43
268
 
44
- if (help || !command) showHelp()
269
+ const isTrace = trace || traceFull
270
+
271
+ if (!command) showHelp()
45
272
 
46
273
  const apiKey = apiKeyFlag || apiKeyCamel || process.env.MICROLINK_API_KEY
47
- const client = create(apiKey ? { apiKey } : {})
274
+ const endpoint = endpointFlag
275
+ const client = create({
276
+ ...(apiKey && { apiKey }),
277
+ ...(endpoint && { endpoint })
278
+ })
48
279
 
49
280
  if (typeof client[command] !== 'function') {
50
- console.error(
51
- `Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
52
- )
281
+ if (!target && URL.canParse(command)) {
282
+ target = command
283
+ command = 'metadata'
284
+ } else if (help) {
285
+ showHelp()
286
+ } else {
287
+ console.error(
288
+ `Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
289
+ )
290
+ process.exit(1)
291
+ }
292
+ }
293
+
294
+ if (help || !target) showHelp(command)
295
+
296
+ if (
297
+ isTrace &&
298
+ (command === 'search' || command === 'function' || command === 'run')
299
+ ) {
300
+ console.error(`\`--trace\` is not supported for \`${command}\`.`)
53
301
  process.exit(1)
54
302
  }
55
303
 
56
304
  const options = { ...flags }
57
- const headers = parseHeaders(header)
305
+ const headers = { ...takeHttpHeaders(options), ...parseHeaders(header) }
58
306
  if (Object.keys(headers).length > 0) options.headers = headers
59
307
 
60
308
  const invoke = () => {
@@ -68,14 +316,23 @@ const invoke = () => {
68
316
  return client[command](target, options)
69
317
  }
70
318
 
71
- Promise.resolve()
72
- .then(invoke)
73
- .then(result => {
74
- if (typeof result === 'string') console.log(result)
75
- else jsome(result)
319
+ const spin = !isTrace && shouldSpin() ? spinner() : null
320
+
321
+ ;(async () => {
322
+ spin?.start()
323
+ const started = Date.now()
324
+ try {
325
+ const result = await invoke()
326
+ const duration = Date.now() - started
327
+ spin?.stop()
328
+ if (isTrace) printJson(tracePayload({ ...client.last, full: traceFull }))
329
+ else if (typeof result === 'string') console.log(result)
330
+ else printJson({ status: 'success', data: result })
331
+ if (!isTrace) printFooter({ duration, response: client.last.response })
76
332
  process.exit(0)
77
- })
78
- .catch(error => {
79
- console.error(error.message)
333
+ } catch (error) {
334
+ spin?.stop()
335
+ printFail(error)
80
336
  process.exit(1)
81
- })
337
+ }
338
+ })()
package/bin/style.js ADDED
@@ -0,0 +1,10 @@
1
+ 'use strict'
2
+
3
+ const { styleText } = require('node:util')
4
+
5
+ const gray = str => styleText('gray', str)
6
+ const white = str => styleText('white', str)
7
+ const green = str => styleText('green', str)
8
+ const red = str => styleText('red', str)
9
+
10
+ module.exports = { gray, white, green, red, 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.0.5",
5
+ "version": "0.2.0",
6
6
  "types": "./src/index.d.ts",
7
7
  "main": "./src/index.js",
8
8
  "exports": {
@@ -46,7 +46,6 @@
46
46
  "@microlink/function": "0.3.0",
47
47
  "@microlink/google": "1.0.6",
48
48
  "@microlink/mql": "0.18.0",
49
- "jsome": "~2.5.0",
50
49
  "mri": "~1.2.0"
51
50
  },
52
51
  "devDependencies": {
@@ -77,5 +76,5 @@
77
76
  },
78
77
  "directory": "test"
79
78
  },
80
- "gitHead": "f51f9edb297ebe9d8ac6cdb3208547420cbea6ad"
79
+ "gitHead": "3766602b11599a8284e4aa31815d409e0ffa0788"
81
80
  }
package/src/index.js CHANGED
@@ -41,6 +41,17 @@ const LIGHTHOUSE_KEYS = ['onlyCategories', 'onlyAudits', 'skipAudits', 'output']
41
41
  const isEmpty = obj => Object.keys(obj).length === 0
42
42
 
43
43
  const create = (ctx = {}) => {
44
+ const last = {}
45
+ const request = (...args) => {
46
+ const [requestUrl, requestOptions] = mql.getApiUrl(...args)
47
+ last.requestUrl = requestUrl
48
+ last.requestOptions = requestOptions
49
+ return mql(...args).then(result => {
50
+ last.response = result.response
51
+ return result
52
+ })
53
+ }
54
+
44
55
  /**
45
56
  * Split the single options bag into the three destinations mql has:
46
57
  * `got.headers` (HTTP layer, 3rd arg), `sub` (capability nested keys)
@@ -61,7 +72,7 @@ const create = (ctx = {}) => {
61
72
 
62
73
  const content = field => (url, options) => {
63
74
  const { top, sub, got } = route(options, CONTENT_KEYS)
64
- return mql(
75
+ return request(
65
76
  url,
66
77
  { ...top, meta: false, data: { [field]: { attr: field, ...sub } } },
67
78
  got
@@ -70,7 +81,7 @@ const create = (ctx = {}) => {
70
81
 
71
82
  const collection = (field, rule) => (url, options) => {
72
83
  const { top, sub, got } = route(options, COLLECTION_KEYS)
73
- return mql(
84
+ return request(
74
85
  url,
75
86
  { ...top, meta: false, data: { [field]: { ...rule, ...sub } } },
76
87
  got
@@ -79,7 +90,7 @@ const create = (ctx = {}) => {
79
90
 
80
91
  const capability = (field, nested) => (url, options) => {
81
92
  const { top, sub, got } = route(options, nested)
82
- return mql(
93
+ return request(
83
94
  url,
84
95
  { ...top, meta: false, [field]: isEmpty(sub) ? true : sub },
85
96
  got
@@ -89,7 +100,7 @@ const create = (ctx = {}) => {
89
100
  /* Primary media detection (`data.video` / `data.audio`). */
90
101
  const media = field => (url, options) => {
91
102
  const { top, got } = route(options)
92
- return mql(url, { ...top, meta: false, [field]: true }, got).then(
103
+ return request(url, { ...top, meta: false, [field]: true }, got).then(
93
104
  ({ data }) => data[field]
94
105
  )
95
106
  }
@@ -101,14 +112,14 @@ const create = (ctx = {}) => {
101
112
  return fn(code, top, got)(url)
102
113
  }
103
114
 
104
- return {
115
+ const client = {
105
116
  metadata: (url, options) => {
106
117
  const { top, got } = route(options)
107
- return mql(url, top, got).then(({ data }) => data)
118
+ return request(url, top, got).then(({ data }) => data)
108
119
  },
109
120
  logo: (url, options) => {
110
121
  const { top, sub, got } = route(options, LOGO_KEYS)
111
- return mql(
122
+ return request(
112
123
  url,
113
124
  { ...top, meta: isEmpty(sub) ? true : { logo: sub } },
114
125
  got
@@ -142,7 +153,7 @@ const create = (ctx = {}) => {
142
153
  }),
143
154
  extract: (url, rules, options) => {
144
155
  const { top, got } = route(options)
145
- return mql(url, { ...top, meta: false, data: rules }, got).then(
156
+ return request(url, { ...top, meta: false, data: rules }, got).then(
146
157
  ({ data }) => data
147
158
  )
148
159
  },
@@ -150,7 +161,7 @@ const create = (ctx = {}) => {
150
161
  pdf: capability('pdf', PDF_KEYS),
151
162
  embed: (url, options) => {
152
163
  const { top, sub, got } = route(options, EMBED_KEYS)
153
- return mql(
164
+ return request(
154
165
  url,
155
166
  { ...top, meta: false, iframe: isEmpty(sub) ? true : sub },
156
167
  got
@@ -158,7 +169,7 @@ const create = (ctx = {}) => {
158
169
  },
159
170
  technologies: (url, options) => {
160
171
  const { top, got } = route(options)
161
- return mql(
172
+ return request(
162
173
  url,
163
174
  {
164
175
  ...top,
@@ -170,7 +181,7 @@ const create = (ctx = {}) => {
170
181
  },
171
182
  lighthouse: (url, options) => {
172
183
  const { top, sub, got } = route(options, LIGHTHOUSE_KEYS)
173
- return mql(
184
+ return request(
174
185
  url,
175
186
  {
176
187
  ...top,
@@ -190,6 +201,9 @@ const create = (ctx = {}) => {
190
201
  function: run,
191
202
  run
192
203
  }
204
+
205
+ Object.defineProperty(client, 'last', { value: last })
206
+ return client
193
207
  }
194
208
 
195
209
  module.exports = create
package/bin/help.txt DELETED
@@ -1,47 +0,0 @@
1
- Usage
2
- $ microlink <product> <url|query> [options]
3
-
4
- Products
5
- metadata Unified metadata (title, description, image, ...)
6
- logo Brand logo of the site (--square prefers the square variant)
7
- markdown Page content as Markdown
8
- html Page content as HTML
9
- text Page content as plain text
10
- video Primary video of the page (returns the asset object)
11
- audio Primary audio of the page (returns the asset object)
12
- emails Every email address present on the page
13
- links Every absolute link URL on the page
14
- images Every absolute image URL on the page
15
- videos Every absolute video URL on the page
16
- audios Every absolute audio URL on the page
17
- extract Custom MQL data rules (--data '{"field":{...}}')
18
- screenshot Take a screenshot (returns the asset object)
19
- pdf Generate a PDF (returns the asset object)
20
- embed oEmbed-style embeddable iframe ({ html, scripts })
21
- technologies Detect the tech stack behind the site
22
- lighthouse Run a Lighthouse report
23
- search Google as structured data (query instead of url); --type
24
- routes to news, images, videos, places, maps, shopping,
25
- scholar, patents or autocomplete
26
- function Run code remotely with browser access (--file ./fn.js);
27
- extra flags are injected as variables in the function scope
28
-
29
- Options
30
- --api-key Microlink API key (defaults to MICROLINK_API_KEY env)
31
- --header, -H Extra request header as 'Name: value' (repeatable)
32
- --data JSON data rules for the extract command
33
- --file Path to the code file for the function command
34
- --help Show this help
35
-
36
- Any other flag is passed as an option to the product, e.g. --fullPage,
37
- --device 'iPhone 11', --waitUntil networkidle0, --selector article.
38
-
39
- Examples
40
- $ microlink markdown https://example.com
41
- $ microlink screenshot https://example.com --fullPage
42
- $ microlink logo https://github.com --square
43
- $ microlink links https://example.com
44
- $ microlink search "best coffee" --limit 10 --location es
45
- $ microlink search "open source llm" --type news --period week
46
- $ microlink extract https://microlink.io --data '{"image":{"selector":"meta[property=og:image]","attr":"content","type":"image"}}'
47
- $ microlink function https://example.com --file ./fn.js