microlink.io 0.0.4 → 0.1.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/bin/help.txt CHANGED
@@ -1,8 +1,9 @@
1
1
  Usage
2
+ $ microlink <url> [options]
2
3
  $ microlink <product> <url|query> [options]
3
4
 
4
5
  Products
5
- metadata Unified metadata (title, description, image, ...)
6
+ metadata Unified metadata (title, description, image, ...); default
6
7
  logo Brand logo of the site (--square prefers the square variant)
7
8
  markdown Page content as Markdown
8
9
  html Page content as HTML
@@ -31,12 +32,17 @@ Options
31
32
  --header, -H Extra request header as 'Name: value' (repeatable)
32
33
  --data JSON data rules for the extract command
33
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
34
37
  --help Show this help
35
38
 
36
39
  Any other flag is passed as an option to the product, e.g. --fullPage,
37
40
  --device 'iPhone 11', --waitUntil networkidle0, --selector article.
38
41
 
39
42
  Examples
43
+ $ microlink https://example.com
44
+ $ microlink https://example.com --trace
45
+ $ microlink https://example.com --trace-full
40
46
  $ microlink markdown https://example.com
41
47
  $ microlink screenshot https://example.com --fullPage
42
48
  $ microlink logo https://github.com --square
package/bin/index.js CHANGED
@@ -1,13 +1,219 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
+ const { styleText } = require('node:util')
4
5
  const { readFileSync } = require('fs')
5
6
  const path = require('path')
6
- const jsome = require('jsome')
7
7
  const mri = require('mri')
8
8
 
9
9
  const create = require('../src')
10
10
 
11
+ const SHOW_CURSOR = '\u001b[?25h'
12
+ const HIDE_CURSOR = '\u001b[?25l'
13
+ const CLEAR_LINE = '\r\u001b[K'
14
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
15
+
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
+ const label = (text, color) =>
21
+ styleText(['inverse', 'bold', color], ` ${text.toUpperCase()} `)
22
+ const keyValue = (key, value) => key + ' ' + gray(value)
23
+
24
+ const prettyMs = ms => {
25
+ if (!Number.isFinite(ms)) return 'unknown'
26
+ const sign = ms < 0 ? '-' : ''
27
+ let n = Math.abs(ms)
28
+ if (n < 1000) return `${sign}${Math.round(n)}ms`
29
+ n /= 1000
30
+ if (n < 60) return `${sign}${n.toFixed(1).replace(/\.0$/, '')}s`
31
+ const hours = Math.floor(n / 3600)
32
+ n %= 3600
33
+ const mins = Math.floor(n / 60)
34
+ const secs = (n % 60).toFixed(1).replace(/\.0$/, '')
35
+ if (hours) return `${sign}${hours}h ${mins}m ${secs}s`
36
+ return secs === '0' ? `${sign}${mins}m` : `${sign}${mins}m ${secs}s`
37
+ }
38
+
39
+ const prettyBytes = n => {
40
+ if (!Number.isFinite(n) || n < 1000) return `${Math.round(n || 0)} B`
41
+ if (n < 1e6) {
42
+ const val = n / 1000
43
+ return `${
44
+ val >= 100 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')
45
+ } kB`
46
+ }
47
+ return `${(n / 1e6).toFixed(1).replace(/\.0$/, '')} MB`
48
+ }
49
+
50
+ const toPlainHeaders = headers => {
51
+ if (!headers) return {}
52
+ if (typeof headers.entries === 'function') {
53
+ return Object.fromEntries(headers.entries())
54
+ }
55
+ return headers
56
+ }
57
+
58
+ const humanizeApiKey = apiKey => `${String(apiKey).slice(0, 5)}…`
59
+
60
+ const quote = str =>
61
+ gray('"') + white(JSON.stringify(str).slice(1, -1)) + gray('"')
62
+
63
+ const printPretty = (value, indent = 0) => {
64
+ if (value === null) return white('null')
65
+ if (typeof value === 'string') return quote(value)
66
+ if (typeof value !== 'object') return white(String(value))
67
+
68
+ const isArray = Array.isArray(value)
69
+ const keys = isArray ? value : Object.keys(value)
70
+ if (keys.length === 0) return gray(isArray ? '[]' : '{}')
71
+
72
+ const pad = ' '.repeat(indent)
73
+ const inner = ' '.repeat(indent + 1)
74
+ const open = gray(isArray ? '[' : '{')
75
+ const close = gray(isArray ? ']' : '}')
76
+ const lines = keys.map(key => {
77
+ if (isArray) return inner + printPretty(key, indent + 1)
78
+ const name = /^[A-Za-z_$][\w$]*$/.test(key) ? white(key) : quote(key)
79
+ return inner + name + gray(':') + ' ' + printPretty(value[key], indent + 1)
80
+ })
81
+ return open + '\n' + lines.join(gray(',') + '\n') + '\n' + pad + close
82
+ }
83
+
84
+ const printJson = payload => {
85
+ console.log(
86
+ process.stdout.hasColors?.()
87
+ ? printPretty(payload)
88
+ : JSON.stringify(payload, null, 2)
89
+ )
90
+ }
91
+
92
+ const tracePayload = ({
93
+ requestUrl,
94
+ requestOptions = {},
95
+ response,
96
+ full = false
97
+ }) => {
98
+ const rest = { ...requestOptions }
99
+ delete rest.responseType
100
+ const headers = { ...rest.headers }
101
+ if (!full && headers['x-api-key']) {
102
+ headers['x-api-key'] = humanizeApiKey(headers['x-api-key'])
103
+ }
104
+ return {
105
+ request: { url: requestUrl, ...rest, headers },
106
+ response: {
107
+ ...response,
108
+ headers: toPlainHeaders(response?.headers)
109
+ }
110
+ }
111
+ }
112
+
113
+ const shouldSpin = () =>
114
+ !process.env.NO_COLOR &&
115
+ process.env.FORCE_COLOR !== '0' &&
116
+ Boolean(process.stdout?.hasColors?.())
117
+
118
+ const spinner = () => {
119
+ const now = Date.now()
120
+ let i = 0
121
+ let timer
122
+ const draw = () => {
123
+ process.stderr.write(
124
+ `${CLEAR_LINE}${FRAMES[i++ % FRAMES.length]} ${prettyMs(
125
+ Date.now() - now
126
+ )}`
127
+ )
128
+ }
129
+ return {
130
+ start () {
131
+ process.stderr.write(HIDE_CURSOR)
132
+ draw()
133
+ process.on('SIGINT', () => {
134
+ process.stderr.write(CLEAR_LINE + SHOW_CURSOR)
135
+ process.exit(130)
136
+ })
137
+ timer = setInterval(draw, 50)
138
+ },
139
+ stop () {
140
+ clearInterval(timer)
141
+ process.stderr.write(CLEAR_LINE + SHOW_CURSOR)
142
+ }
143
+ }
144
+ }
145
+
146
+ const printFooter = ({ duration, response }) => {
147
+ const headers = toPlainHeaders(response?.headers)
148
+ const time = prettyMs(duration)
149
+ const size = Number(headers['content-length']) || 0
150
+ const serverTiming = headers['server-timing']
151
+ const id = headers['x-request-id']
152
+ const edgeCacheStatus = headers['cf-cache-status']
153
+ const unifiedCacheStatus = headers['x-cache-status']
154
+ const cacheStatus =
155
+ unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT'
156
+ ? edgeCacheStatus
157
+ : unifiedCacheStatus
158
+ const timestamp = Number(headers['x-timestamp'])
159
+ const ttl = Number(headers['x-cache-ttl'])
160
+ const expires = timestamp + ttl - Date.now()
161
+ const expiredAt =
162
+ cacheStatus === 'HIT' && Number.isFinite(expires)
163
+ ? `(${prettyMs(expires)})`
164
+ : ''
165
+ const fetchMode = headers['x-fetch-mode']
166
+ const fetchTime = fetchMode && `(${headers['x-fetch-time']})`
167
+ const uri = response?.url
168
+
169
+ if (process.stdout.isTTY) console.error()
170
+ console.error(
171
+ label('success', 'green'),
172
+ gray(`${prettyBytes(size)} in ${time}`)
173
+ )
174
+ console.error()
175
+
176
+ if (serverTiming) {
177
+ console.error(' ', keyValue(green('timing'), serverTiming))
178
+ }
179
+ if (cacheStatus) {
180
+ console.error(
181
+ ' ',
182
+ keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`.trim())
183
+ )
184
+ }
185
+ if (fetchMode) {
186
+ console.error(
187
+ ' ',
188
+ keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`.trim())
189
+ )
190
+ }
191
+ if (uri) console.error(' ', keyValue(green('uri'), uri))
192
+ if (id) console.error(' ', keyValue(green('id'), id))
193
+ }
194
+
195
+ const printFail = error => {
196
+ if (process.stdout.isTTY) console.error()
197
+ console.error(
198
+ label(error.status || 'fail', 'red'),
199
+ gray(String(error.message).replace(`${error.code}, `, ''))
200
+ )
201
+ console.error()
202
+ const id = error.headers?.['x-request-id']
203
+ if (id) console.error(' ', keyValue(red('id'), id))
204
+ if (error.url) console.error(' ', keyValue(red('uri'), error.url))
205
+ if (error.code) {
206
+ console.error(
207
+ ' ',
208
+ keyValue(
209
+ red('code'),
210
+ `${error.code}${error.statusCode ? ` (${error.statusCode})` : ''}`
211
+ )
212
+ )
213
+ }
214
+ if (error.more) console.error(' ', keyValue(red('more'), error.more))
215
+ }
216
+
11
217
  const showHelp = () => {
12
218
  console.log(readFileSync(path.join(__dirname, 'help.txt'), 'utf8'))
13
219
  process.exit(0)
@@ -27,10 +233,11 @@ const parseHeaders = input => {
27
233
 
28
234
  const argv = mri(process.argv.slice(2), {
29
235
  alias: { H: 'header' },
236
+ boolean: ['trace', 'trace-full'],
30
237
  string: ['header', 'api-key', 'data', 'file']
31
238
  })
32
239
 
33
- const {
240
+ let {
34
241
  _: [command, target],
35
242
  header,
36
243
  help,
@@ -38,18 +245,35 @@ const {
38
245
  file,
39
246
  'api-key': apiKeyFlag,
40
247
  apiKey: apiKeyCamel,
248
+ trace,
249
+ 'trace-full': traceFull,
41
250
  ...flags
42
251
  } = argv
43
252
 
253
+ const isTrace = trace || traceFull
254
+
44
255
  if (help || !command) showHelp()
45
256
 
46
257
  const apiKey = apiKeyFlag || apiKeyCamel || process.env.MICROLINK_API_KEY
47
258
  const client = create(apiKey ? { apiKey } : {})
48
259
 
49
260
  if (typeof client[command] !== 'function') {
50
- console.error(
51
- `Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
52
- )
261
+ if (!target && URL.canParse(command)) {
262
+ target = command
263
+ command = 'metadata'
264
+ } else {
265
+ console.error(
266
+ `Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
267
+ )
268
+ process.exit(1)
269
+ }
270
+ }
271
+
272
+ if (
273
+ isTrace &&
274
+ (command === 'search' || command === 'function' || command === 'run')
275
+ ) {
276
+ console.error(`\`--trace\` is not supported for \`${command}\`.`)
53
277
  process.exit(1)
54
278
  }
55
279
 
@@ -68,14 +292,23 @@ const invoke = () => {
68
292
  return client[command](target, options)
69
293
  }
70
294
 
71
- Promise.resolve()
72
- .then(invoke)
73
- .then(result => {
74
- if (typeof result === 'string') console.log(result)
75
- else jsome(result)
295
+ const spin = !isTrace && shouldSpin() ? spinner() : null
296
+
297
+ ;(async () => {
298
+ spin?.start()
299
+ const started = Date.now()
300
+ try {
301
+ const result = await invoke()
302
+ const duration = Date.now() - started
303
+ spin?.stop()
304
+ if (isTrace) printJson(tracePayload({ ...client.last, full: traceFull }))
305
+ else if (typeof result === 'string') console.log(result)
306
+ else printJson({ status: 'success', data: result })
307
+ if (!isTrace) printFooter({ duration, response: client.last.response })
76
308
  process.exit(0)
77
- })
78
- .catch(error => {
79
- console.error(error.message)
309
+ } catch (error) {
310
+ spin?.stop()
311
+ printFail(error)
80
312
  process.exit(1)
81
- })
313
+ }
314
+ })()
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.4",
5
+ "version": "0.1.1",
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": "13e0818cc80bca85a19f16837095bd7d6b31f76a"
79
+ "gitHead": "a12fcd2a7bc918e741a71b50fd2cd93876f4ca8f"
81
80
  }
package/src/index.js CHANGED
@@ -17,7 +17,8 @@ const SCREENSHOT_KEYS = [
17
17
  'optimizeForSpeed',
18
18
  'codeScheme',
19
19
  'animated',
20
- 'palette'
20
+ 'palette',
21
+ 'quality'
21
22
  ]
22
23
 
23
24
  const PDF_KEYS = [
@@ -40,6 +41,17 @@ const LIGHTHOUSE_KEYS = ['onlyCategories', 'onlyAudits', 'skipAudits', 'output']
40
41
  const isEmpty = obj => Object.keys(obj).length === 0
41
42
 
42
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
+
43
55
  /**
44
56
  * Split the single options bag into the three destinations mql has:
45
57
  * `got.headers` (HTTP layer, 3rd arg), `sub` (capability nested keys)
@@ -60,7 +72,7 @@ const create = (ctx = {}) => {
60
72
 
61
73
  const content = field => (url, options) => {
62
74
  const { top, sub, got } = route(options, CONTENT_KEYS)
63
- return mql(
75
+ return request(
64
76
  url,
65
77
  { ...top, meta: false, data: { [field]: { attr: field, ...sub } } },
66
78
  got
@@ -69,7 +81,7 @@ const create = (ctx = {}) => {
69
81
 
70
82
  const collection = (field, rule) => (url, options) => {
71
83
  const { top, sub, got } = route(options, COLLECTION_KEYS)
72
- return mql(
84
+ return request(
73
85
  url,
74
86
  { ...top, meta: false, data: { [field]: { ...rule, ...sub } } },
75
87
  got
@@ -78,7 +90,7 @@ const create = (ctx = {}) => {
78
90
 
79
91
  const capability = (field, nested) => (url, options) => {
80
92
  const { top, sub, got } = route(options, nested)
81
- return mql(
93
+ return request(
82
94
  url,
83
95
  { ...top, meta: false, [field]: isEmpty(sub) ? true : sub },
84
96
  got
@@ -88,7 +100,7 @@ const create = (ctx = {}) => {
88
100
  /* Primary media detection (`data.video` / `data.audio`). */
89
101
  const media = field => (url, options) => {
90
102
  const { top, got } = route(options)
91
- return mql(url, { ...top, meta: false, [field]: true }, got).then(
103
+ return request(url, { ...top, meta: false, [field]: true }, got).then(
92
104
  ({ data }) => data[field]
93
105
  )
94
106
  }
@@ -100,14 +112,14 @@ const create = (ctx = {}) => {
100
112
  return fn(code, top, got)(url)
101
113
  }
102
114
 
103
- return {
115
+ const client = {
104
116
  metadata: (url, options) => {
105
117
  const { top, got } = route(options)
106
- return mql(url, top, got).then(({ data }) => data)
118
+ return request(url, top, got).then(({ data }) => data)
107
119
  },
108
120
  logo: (url, options) => {
109
121
  const { top, sub, got } = route(options, LOGO_KEYS)
110
- return mql(
122
+ return request(
111
123
  url,
112
124
  { ...top, meta: isEmpty(sub) ? true : { logo: sub } },
113
125
  got
@@ -141,7 +153,7 @@ const create = (ctx = {}) => {
141
153
  }),
142
154
  extract: (url, rules, options) => {
143
155
  const { top, got } = route(options)
144
- return mql(url, { ...top, meta: false, data: rules }, got).then(
156
+ return request(url, { ...top, meta: false, data: rules }, got).then(
145
157
  ({ data }) => data
146
158
  )
147
159
  },
@@ -149,7 +161,7 @@ const create = (ctx = {}) => {
149
161
  pdf: capability('pdf', PDF_KEYS),
150
162
  embed: (url, options) => {
151
163
  const { top, sub, got } = route(options, EMBED_KEYS)
152
- return mql(
164
+ return request(
153
165
  url,
154
166
  { ...top, meta: false, iframe: isEmpty(sub) ? true : sub },
155
167
  got
@@ -157,7 +169,7 @@ const create = (ctx = {}) => {
157
169
  },
158
170
  technologies: (url, options) => {
159
171
  const { top, got } = route(options)
160
- return mql(
172
+ return request(
161
173
  url,
162
174
  {
163
175
  ...top,
@@ -169,7 +181,7 @@ const create = (ctx = {}) => {
169
181
  },
170
182
  lighthouse: (url, options) => {
171
183
  const { top, sub, got } = route(options, LIGHTHOUSE_KEYS)
172
- return mql(
184
+ return request(
173
185
  url,
174
186
  {
175
187
  ...top,
@@ -189,6 +201,9 @@ const create = (ctx = {}) => {
189
201
  function: run,
190
202
  run
191
203
  }
204
+
205
+ Object.defineProperty(client, 'last', { value: last })
206
+ return client
192
207
  }
193
208
 
194
209
  module.exports = create