Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/core/bin/help.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
Usage
$ microlink <url> [options]
$ microlink <product> <url|query> [options]

Products
metadata Unified metadata (title, description, image, ...)
metadata Unified metadata (title, description, image, ...); default
logo Brand logo of the site (--square prefers the square variant)
markdown Page content as Markdown
html Page content as HTML
Expand Down Expand Up @@ -31,12 +32,17 @@ Options
--header, -H Extra request header as 'Name: value' (repeatable)
--data JSON data rules for the extract command
--file Path to the code file for the function command
--trace Print request & response payload (API key masked)
--trace-full Same as --trace, including the full API key
--help Show this help

Any other flag is passed as an option to the product, e.g. --fullPage,
--device 'iPhone 11', --waitUntil networkidle0, --selector article.

Examples
$ microlink https://example.com
$ microlink https://example.com --trace
$ microlink https://example.com --trace-full
$ microlink markdown https://example.com
$ microlink screenshot https://example.com --fullPage
$ microlink logo https://github.com --square
Expand Down
235 changes: 222 additions & 13 deletions packages/core/bin/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,195 @@
#!/usr/bin/env node
'use strict'

const { styleText } = require('node:util')
const { readFileSync } = require('fs')
const path = require('path')
const jsome = require('jsome')
const mri = require('mri')

const create = require('../src')

const SHOW_CURSOR = '\u001b[?25h'
const HIDE_CURSOR = '\u001b[?25l'
const CLEAR_LINE = '\r\u001b[K'
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']

const gray = str => styleText('gray', str)
const white = str => styleText('white', str)
const label = (text, color) =>
styleText(['inverse', 'bold', color], ` ${text.toUpperCase()} `)
const keyValue = (key, value) => key + ' ' + gray(value)

const prettyMs = ms => {
if (!Number.isFinite(ms)) return 'unknown'
const sign = ms < 0 ? '-' : ''
let n = Math.abs(ms)
if (n < 1000) return `${sign}${Math.round(n)}ms`
n /= 1000
if (n < 60) return `${sign}${n.toFixed(1).replace(/\.0$/, '')}s`
const hours = Math.floor(n / 3600)
n %= 3600
const mins = Math.floor(n / 60)
const secs = (n % 60).toFixed(1).replace(/\.0$/, '')
if (hours) return `${sign}${hours}h ${mins}m ${secs}s`
return secs === '0' ? `${sign}${mins}m` : `${sign}${mins}m ${secs}s`
}

const prettyBytes = n => {
if (!Number.isFinite(n) || n < 1000) return `${Math.round(n || 0)} B`
if (n < 1e6) {
const val = n / 1000
return `${
val >= 100 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')
} kB`
}
return `${(n / 1e6).toFixed(1).replace(/\.0$/, '')} MB`
}

const toPlainHeaders = headers => {
if (!headers) return {}
if (typeof headers.entries === 'function') {
return Object.fromEntries(headers.entries())
}
return headers
}

const humanizeApiKey = apiKey => `${String(apiKey).slice(0, 5)}…`

const quote = str =>
gray('"') + white(JSON.stringify(str).slice(1, -1)) + gray('"')

const printPretty = (value, indent = 0) => {
if (value === null) return white('null')
if (typeof value === 'string') return quote(value)
if (typeof value !== 'object') return white(String(value))

const isArray = Array.isArray(value)
const keys = isArray ? value : Object.keys(value)
if (keys.length === 0) return gray(isArray ? '[]' : '{}')

const pad = ' '.repeat(indent)
const inner = ' '.repeat(indent + 1)
const open = gray(isArray ? '[' : '{')
const close = gray(isArray ? ']' : '}')
const lines = keys.map(key => {
if (isArray) return inner + printPretty(key, indent + 1)
const name = /^[A-Za-z_$][\w$]*$/.test(key) ? white(key) : quote(key)
return inner + name + gray(':') + ' ' + printPretty(value[key], indent + 1)
})
return open + '\n' + lines.join(gray(',') + '\n') + '\n' + pad + close
}

const printJson = payload => {
console.log(
process.stdout.hasColors?.()
? printPretty(payload)
: JSON.stringify(payload, null, 2)
)
}

const tracePayload = ({
requestUrl,
requestOptions = {},
response,
full = false
}) => {
const rest = { ...requestOptions }
delete rest.responseType
const headers = { ...rest.headers }
if (!full && headers['x-api-key']) {
headers['x-api-key'] = humanizeApiKey(headers['x-api-key'])
}
return {
request: { url: requestUrl, ...rest, headers },
response: {
...response,
headers: toPlainHeaders(response?.headers)
}
}
}

const shouldSpin = () =>
!process.env.NO_COLOR &&
process.env.FORCE_COLOR !== '0' &&
Boolean(process.stdout?.hasColors?.())

const spinner = () => {
const now = Date.now()
let i = 0
let timer
const draw = () => {
process.stderr.write(
`${CLEAR_LINE}${FRAMES[i++ % FRAMES.length]} ${prettyMs(
Date.now() - now
)}`
)
}
return {
start () {
process.stderr.write(HIDE_CURSOR)
draw()
process.on('SIGINT', () => {
process.stderr.write(CLEAR_LINE + SHOW_CURSOR)
process.exit(130)
})
timer = setInterval(draw, 50)
},
stop () {
clearInterval(timer)
process.stderr.write(CLEAR_LINE + SHOW_CURSOR)
}
}
}

const printFooter = ({ duration, response }) => {
const headers = toPlainHeaders(response?.headers)
const time = prettyMs(duration)
const size = Number(headers['content-length']) || 0
const serverTiming = headers['server-timing']
const id = headers['x-request-id']
const edgeCacheStatus = headers['cf-cache-status']
const unifiedCacheStatus = headers['x-cache-status']
const cacheStatus =
unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT'
? edgeCacheStatus
: unifiedCacheStatus
const timestamp = Number(headers['x-timestamp'])
const ttl = Number(headers['x-cache-ttl'])
const expires = timestamp + ttl - Date.now()
const expiredAt =
cacheStatus === 'HIT' && Number.isFinite(expires)
? `(${prettyMs(expires)})`
: ''
const fetchMode = headers['x-fetch-mode']
const fetchTime = fetchMode && `(${headers['x-fetch-time']})`
const uri = response?.url

if (process.stdout.isTTY) console.error()
console.error(
label('success', 'white'),
gray(`${prettyBytes(size)} in ${time}`)
)
console.error()

if (serverTiming) {
console.error(' ', keyValue(white('timing'), serverTiming))
}
if (cacheStatus) {
console.error(
' ',
keyValue(white('cache'), `${cacheStatus} ${gray(expiredAt)}`.trim())
)
}
if (fetchMode) {
console.error(
' ',
keyValue(white('mode'), `${fetchMode} ${gray(fetchTime)}`.trim())
)
}
if (uri) console.error(' ', keyValue(white('uri'), uri))
if (id) console.error(' ', keyValue(white('id'), id))
}

const showHelp = () => {
console.log(readFileSync(path.join(__dirname, 'help.txt'), 'utf8'))
process.exit(0)
Expand All @@ -27,29 +209,47 @@ const parseHeaders = input => {

const argv = mri(process.argv.slice(2), {
alias: { H: 'header' },
boolean: ['trace', 'trace-full'],
string: ['header', 'api-key', 'data', 'file']
})

const {
let {
_: [command, target],
header,
help,
data,
file,
'api-key': apiKeyFlag,
apiKey: apiKeyCamel,
trace,
'trace-full': traceFull,
...flags
} = argv

const isTrace = trace || traceFull

if (help || !command) showHelp()

const apiKey = apiKeyFlag || apiKeyCamel || process.env.MICROLINK_API_KEY
const client = create(apiKey ? { apiKey } : {})

if (typeof client[command] !== 'function') {
console.error(
`Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
)
if (!target && URL.canParse(command)) {
target = command
command = 'metadata'
} else {
console.error(
`Unknown command \`${command}\`. Run \`microlink --help\` to see the available commands.`
)
process.exit(1)
}
}

if (
isTrace &&
(command === 'search' || command === 'function' || command === 'run')
) {
console.error(`\`--trace\` is not supported for \`${command}\`.`)
process.exit(1)
}

Expand All @@ -68,14 +268,23 @@ const invoke = () => {
return client[command](target, options)
}

Promise.resolve()
.then(invoke)
.then(result => {
if (typeof result === 'string') console.log(result)
else jsome(result)
const spin = !isTrace && shouldSpin() ? spinner() : null

;(async () => {
spin?.start()
const started = Date.now()
try {
const result = await invoke()
const duration = Date.now() - started
spin?.stop()
if (isTrace) printJson(tracePayload({ ...client.last, full: traceFull }))
else if (typeof result === 'string') console.log(result)
else printJson({ status: 'success', data: result })
if (!isTrace) printFooter({ duration, response: client.last.response })
Comment thread
Kikobeats marked this conversation as resolved.
process.exit(0)
})
.catch(error => {
} catch (error) {
spin?.stop()
console.error(error.message)
process.exit(1)
})
}
})()
1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
"@microlink/function": "workspace:*",
"@microlink/google": "workspace:*",
"@microlink/mql": "workspace:*",
"jsome": "~2.5.0",
"mri": "~1.2.0"
},
"devDependencies": {
Expand Down
Loading