@unavatar/core 3.12.0 → 3.13.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/README.md CHANGED
@@ -12,6 +12,7 @@
12
12
  - [Apple Music](#apple-music)
13
13
  - [Bluesky](#bluesky)
14
14
  - [DeviantArt](#deviantart)
15
+ - [Discord](#discord)
15
16
  - [Dribbble](#dribbble)
16
17
  - [DuckDuckGo](#duckduckgo)
17
18
  - [GitHub](#github)
@@ -57,6 +58,7 @@
57
58
  - [Apple Music](#apple-music)
58
59
  - [Bluesky](#bluesky)
59
60
  - [DeviantArt](#deviantart)
61
+ - [Discord](#discord)
60
62
  - [Dribbble](#dribbble)
61
63
  - [DuckDuckGo](#duckduckgo)
62
64
  - [GitHub](#github)
@@ -238,6 +240,14 @@ Available inputs:
238
240
 
239
241
  - slug, e.g., [unavatar.io/deviantart/spyed](https://unavatar.io/deviantart/spyed)
240
242
 
243
+ ### Discord
244
+
245
+ Get any Discord server icon by invite code.
246
+
247
+ Available inputs:
248
+
249
+ - Invite code, e.g., [unavatar.io/discord/eret](https://unavatar.io/discord/eret)
250
+
241
251
  ### Dribbble
242
252
 
243
253
  Get any Dribbble designer's profile picture by their username.
package/bin/index.js CHANGED
@@ -28,7 +28,9 @@ module.exports = ({ baseUrl }) => {
28
28
  const customHeaders = parseHeaders(flags.header)
29
29
 
30
30
  if (!input) {
31
- console.error('Usage: unavatar <input> | unavatar <provider>/<key> | unavatar ping')
31
+ console.error(
32
+ 'Usage: unavatar <input> | unavatar <provider>/<key> | unavatar ping'
33
+ )
32
34
  console.error(
33
35
  'Examples: unavatar reddit.com | unavatar hello@microlink.io | unavatar x/kikobeats | unavatar ping'
34
36
  )
@@ -39,7 +41,9 @@ module.exports = ({ baseUrl }) => {
39
41
  const normalizeInput = value => {
40
42
  try {
41
43
  const url = new URL(value)
42
- if (url.protocol === 'http:' || url.protocol === 'https:') return url.hostname
44
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
45
+ return url.hostname
46
+ }
43
47
  } catch (_) {}
44
48
  return value
45
49
  }
@@ -57,7 +61,9 @@ module.exports = ({ baseUrl }) => {
57
61
  const key = keyParts.join('/')
58
62
 
59
63
  if (!provider || !key) {
60
- console.error('Invalid input format. Expected: <input>, <provider>/<key>, or "ping"')
64
+ console.error(
65
+ 'Invalid input format. Expected: <input>, <provider>/<key>, or "ping"'
66
+ )
61
67
  console.error(
62
68
  'Examples: unavatar reddit.com | unavatar hello@microlink.io | unavatar x/kikobeats | unavatar ping'
63
69
  )
@@ -78,15 +84,22 @@ module.exports = ({ baseUrl }) => {
78
84
  let durationInfo = null
79
85
  let apiHeaders = null
80
86
  let apiStatusCode = null
87
+ let apiHttpVersion = null
81
88
 
82
89
  const logMeta = () => {
83
90
  if (apiHeaders) {
84
91
  const headerEntries = Object.entries(apiHeaders)
85
92
  if (headerEntries.length > 0) {
86
- const maxHeaderLength = Math.max(...headerEntries.map(([key]) => key.toLowerCase().length))
93
+ const maxHeaderLength = Math.max(
94
+ ...headerEntries.map(([key]) => key.toLowerCase().length)
95
+ )
87
96
 
88
97
  console.error()
89
- console.error(`HTTP/1.1 ${apiStatusCode} ${STATUS_CODES[apiStatusCode]}`)
98
+ console.error(
99
+ `HTTP/${apiHttpVersion || '1.1'} ${apiStatusCode} ${
100
+ STATUS_CODES[apiStatusCode]
101
+ }`
102
+ )
90
103
  headerEntries
91
104
  .sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
92
105
  .forEach(([key, value]) => {
@@ -106,13 +119,16 @@ module.exports = ({ baseUrl }) => {
106
119
  if (flags.apiKey) requestHeaders['x-api-key'] = flags.apiKey
107
120
 
108
121
  got(apiUrl.toString(), { headers: requestHeaders, responseType: 'json' })
109
- .then(({ body, headers, statusCode, timings }) => {
122
+ .then(({ body, headers, statusCode, timings, httpVersion }) => {
110
123
  apiHeaders = headers
111
124
  apiStatusCode = statusCode
125
+ apiHttpVersion = httpVersion
112
126
 
113
127
  const duration = Date.now() - startTime
114
128
  const timeToFirstByte =
115
- timings?.response && timings?.start ? Math.round(timings.response - timings.start) : null
129
+ timings?.response && timings?.start
130
+ ? Math.round(timings.response - timings.start)
131
+ : null
116
132
 
117
133
  durationInfo = timeToFirstByte
118
134
  ? `Duration: ${duration}ms (TTFB: ${timeToFirstByte}ms)`
@@ -123,7 +139,7 @@ module.exports = ({ baseUrl }) => {
123
139
  console.error(`${apiUrl.toString()}\n`)
124
140
  console.error(body)
125
141
  logMeta()
126
- return null
142
+ return
127
143
  }
128
144
 
129
145
  if (!body || !body.url) {
@@ -152,6 +168,7 @@ output: ${imageUrl}
152
168
  if (response) {
153
169
  apiHeaders = response.headers
154
170
  apiStatusCode = response.statusCode
171
+ apiHttpVersion = response.httpVersion
155
172
  }
156
173
 
157
174
  if (!durationInfo) {
@@ -166,7 +183,9 @@ output: ${imageUrl}
166
183
  if (typeof body === 'object') {
167
184
  console.error(JSON.stringify(body, null, 2))
168
185
  } else {
169
- const rawBody = Buffer.isBuffer(body) ? body.toString('utf8') : String(body)
186
+ const rawBody = Buffer.isBuffer(body)
187
+ ? body.toString('utf8')
188
+ : String(body)
170
189
  try {
171
190
  console.error(JSON.stringify(JSON.parse(rawBody), null, 2))
172
191
  } catch (_) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@unavatar/core",
3
3
  "description": "Get unified user avatar from social networks, including Instagram, SoundCloud, Telegram, Twitter, YouTube & more.",
4
4
  "homepage": "https://unavatar.io",
5
- "version": "3.12.0",
5
+ "version": "3.13.0",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
@@ -39,7 +39,7 @@ module.exports = ({ createHtmlProvider, itunesSearchCache, got }) => {
39
39
  const searchEntityId = memoize(
40
40
  async ({ query, type }) => {
41
41
  const entityConfig = APPLE_MUSIC_ENTITY_TYPES[type]
42
- if (!entityConfig) return null
42
+ if (!entityConfig) return
43
43
  const { entity, idKey } = entityConfig
44
44
 
45
45
  const url = `https://itunes.apple.com/search?term=${encodeURIComponent(
@@ -63,7 +63,7 @@ module.exports = ({ createHtmlProvider, itunesSearchCache, got }) => {
63
63
  if (!hasExplicitType) {
64
64
  for (const searchType of APPLE_MUSIC_SEARCH_TYPES) {
65
65
  const entityId = await searchEntityId({ query: id, type: searchType })
66
- if (entityId) return `${APPLE_MUSIC_STOREFRONT}/${searchType}/${entityId}`
66
+ if (entityId) { return `${APPLE_MUSIC_STOREFRONT}/${searchType}/${entityId}` }
67
67
  }
68
68
 
69
69
  return `${APPLE_MUSIC_STOREFRONT}/search?term=${encodeURIComponent(id)}`
@@ -76,7 +76,9 @@ module.exports = ({ createHtmlProvider, itunesSearchCache, got }) => {
76
76
  if (isNumericId(id)) return `${APPLE_MUSIC_STOREFRONT}/${type}/${id}`
77
77
 
78
78
  const entityId = await searchEntityId({ query: id, type })
79
- return `${APPLE_MUSIC_STOREFRONT}/${type}/${entityId || encodeURIComponent(id)}`
79
+ return `${APPLE_MUSIC_STOREFRONT}/${type}/${
80
+ entityId || encodeURIComponent(id)
81
+ }`
80
82
  }
81
83
 
82
84
  return createHtmlProvider({
@@ -0,0 +1,96 @@
1
+ 'use strict'
2
+
3
+ const DISCORD_INVITE_HOST_RE =
4
+ /^(?:www\.)?(?:discord\.gg|discord(?:app)?\.com)$/i
5
+
6
+ const DISCORD_GUILD_ID_RE =
7
+ /^https:\/\/cdn\.discordapp\.com\/splashes\/(\d+)\/[^/?#]+\.[^/?#]+(?:\?[^#]+)?(?:#.*)?$/
8
+
9
+ const DISCORD_INVITE_URL_BASE = 'https://discord.com/invite/'
10
+
11
+ const getInviteCode = input => {
12
+ if (typeof input !== 'string') return
13
+
14
+ const value = input.trim()
15
+ if (value === '') return
16
+
17
+ const hasProtocol = value.includes('://')
18
+ const hasPath = value.includes('/')
19
+
20
+ if (!hasProtocol && !hasPath) return value
21
+
22
+ const normalizedInput = hasProtocol ? value : `https://${value}`
23
+
24
+ let url
25
+ try {
26
+ url = new URL(normalizedInput)
27
+ } catch {
28
+ return
29
+ }
30
+
31
+ if (!DISCORD_INVITE_HOST_RE.test(url.hostname)) return
32
+
33
+ const [firstSegment, secondSegment] = url.pathname.split('/').filter(Boolean)
34
+ if (!firstSegment) return
35
+
36
+ if (url.hostname.includes('discord.gg')) return firstSegment
37
+
38
+ if (firstSegment === 'invite' || firstSegment === 'invites') {
39
+ return secondSegment
40
+ }
41
+ }
42
+
43
+ const buildInviteUrl = inviteCode => `${DISCORD_INVITE_URL_BASE}${inviteCode}`
44
+
45
+ const getInviteUrl = input => {
46
+ const inviteCode = getInviteCode(input)
47
+ return inviteCode ? buildInviteUrl(inviteCode) : undefined
48
+ }
49
+
50
+ const getInviteApiUrl = inviteCode =>
51
+ `https://discord.com/api/v9/invites/${encodeURIComponent(
52
+ inviteCode
53
+ )}?with_counts=true&with_expiration=true`
54
+
55
+ const getGuildIdFromOgImage = ogImage =>
56
+ ogImage?.match(DISCORD_GUILD_ID_RE)?.[1]
57
+
58
+ const getAvatarUrl = ({ guildId, ogImage, iconHash }) => {
59
+ const resolvedGuildId = guildId || getGuildIdFromOgImage(ogImage)
60
+ if (!resolvedGuildId || !iconHash) return
61
+ return `https://cdn.discordapp.com/icons/${resolvedGuildId}/${iconHash}.webp`
62
+ }
63
+
64
+ module.exports = ({ createHtmlProvider, getOgImage, got }) => {
65
+ const fromInvite = createHtmlProvider({
66
+ name: 'discord',
67
+ url: buildInviteUrl,
68
+ getter: getOgImage
69
+ })
70
+
71
+ return async function discord (input, context) {
72
+ const inviteCode = getInviteCode(input)
73
+ if (!inviteCode) return
74
+
75
+ const ogImage = await fromInvite(inviteCode, context)
76
+ if (!ogImage) return
77
+
78
+ const metadataResponse = await got(getInviteApiUrl(inviteCode), {
79
+ responseType: 'json',
80
+ throwHttpErrors: false
81
+ })
82
+
83
+ if (metadataResponse.statusCode >= 400) return
84
+
85
+ const guild = metadataResponse.body?.guild
86
+ const iconHash = guild?.icon
87
+ const guildId = guild?.id
88
+
89
+ return getAvatarUrl({ guildId, ogImage, iconHash })
90
+ }
91
+ }
92
+
93
+ module.exports.getAvatarUrl = getAvatarUrl
94
+ module.exports.getGuildIdFromOgImage = getGuildIdFromOgImage
95
+ module.exports.getInviteCode = getInviteCode
96
+ module.exports.getInviteUrl = getInviteUrl
@@ -6,6 +6,7 @@ const providersBy = {
6
6
  'apple-music',
7
7
  'bluesky',
8
8
  'deviantart',
9
+ 'discord',
9
10
  'dribbble',
10
11
  'github',
11
12
  'gitlab',
@@ -40,6 +41,7 @@ module.exports = ctx => {
40
41
  'apple-music': require('./apple-music')(ctx),
41
42
  bluesky: require('./bluesky')(ctx),
42
43
  deviantart: require('./deviantart')(ctx),
44
+ discord: require('./discord')(ctx),
43
45
  dribbble: require('./dribbble')(ctx),
44
46
  duckduckgo: require('./duckduckgo')(ctx),
45
47
  github: require('./github')(ctx),
@@ -18,15 +18,15 @@ const isValidServer = server => {
18
18
  }
19
19
 
20
20
  const parseMastodonInput = input => {
21
- if (typeof input !== 'string') return null
21
+ if (typeof input !== 'string') return
22
22
 
23
23
  const cleaned = input.startsWith('@') ? input.slice(1) : input
24
24
  const parts = cleaned.split('@')
25
- if (parts.length !== 2) return null
25
+ if (parts.length !== 2) return
26
26
 
27
27
  const [username, server] = parts
28
- if (!username || !server) return null
29
- if (!isValidServer(server)) return null
28
+ if (!username || !server) return
29
+ if (!isValidServer(server)) return
30
30
 
31
31
  return {
32
32
  username,
@@ -37,11 +37,11 @@ const parseMastodonInput = input => {
37
37
  module.exports = ({ got, isReservedIp }) => {
38
38
  const mastodon = async function (input) {
39
39
  const parsed = parseMastodonInput(input)
40
- if (!parsed) return undefined
40
+ if (!parsed) return
41
41
 
42
42
  const { username, server } = parsed
43
43
 
44
- if (await isReservedIp(server)) return undefined
44
+ if (await isReservedIp(server)) return
45
45
 
46
46
  const { body } = await got(
47
47
  `https://${server}/api/v1/accounts/lookup?acct=${encodeURIComponent(
@@ -12,17 +12,14 @@ const toHighResolution = url => {
12
12
  return url
13
13
  }
14
14
 
15
- const getProfileImage = $ =>
16
- toHighResolution(
17
- $jsonld('mainEntity.image.contentUrl')($) ||
18
- $('meta[property="og:image"]').attr('content')
19
- )
15
+ const getProfileImage = ($, getOgImage) =>
16
+ toHighResolution($jsonld('mainEntity.image.contentUrl')($) || getOgImage($))
20
17
 
21
- module.exports = ({ createHtmlProvider }) =>
18
+ module.exports = ({ createHtmlProvider, getOgImage }) =>
22
19
  createHtmlProvider({
23
20
  name: 'x',
24
21
  url: input => `https://x.com/${input}`,
25
- getter: getProfileImage
22
+ getter: $ => getProfileImage($, getOgImage)
26
23
  })
27
24
 
28
25
  module.exports.getProfileImage = getProfileImage
@@ -0,0 +1,3 @@
1
+ module.exports = $ =>
2
+ $('meta[property="og:image"]').attr('content') ||
3
+ $('meta[name="og:image"]').attr('content')
@@ -5,6 +5,7 @@ const debug = require('debug-logfmt')('html-provider')
5
5
  const isAntibot = require('is-antibot')
6
6
 
7
7
  const randomCrawlerAgent = require('./crawler-agent')
8
+ const getOgImage = require('./get-og-image')
8
9
  const httpStatus = require('./http-status')
9
10
  const ExtendableError = require('./error')
10
11
 
@@ -27,10 +28,6 @@ const createProviderError = ({ provider, statusCode, cause, code }) =>
27
28
  message: 'Empty value returned by the provider.'
28
29
  })
29
30
 
30
- const getOgImage = $ =>
31
- $('meta[property="og:image"]').attr('content') ||
32
- $('meta[name="og:image"]').attr('content')
33
-
34
31
  module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
35
32
  /**
36
33
  * @param {object} opts
@@ -151,7 +148,7 @@ module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
151
148
  }
152
149
 
153
150
  const result = await attempt()
154
- if (result === NOT_FOUND) return undefined
151
+ if (result === NOT_FOUND) return
155
152
  return result
156
153
  }
157
154