@unavatar/core 3.19.2 → 3.19.4

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/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.19.2",
5
+ "version": "3.19.4",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
@@ -139,7 +139,7 @@
139
139
  "ms": "~2.1.3",
140
140
  "p-any": "~3.0.0",
141
141
  "p-timeout": "~4.1.0",
142
- "puppeteer": "~24.40.0",
142
+ "puppeteer": "~24.41.0",
143
143
  "re2": "~1.24.0",
144
144
  "srcset": "~4.0.0",
145
145
  "stable-regex": "~1.0.1",
package/src/index.js CHANGED
@@ -29,18 +29,21 @@ module.exports = ({ constants: userConstants, redis, onFetchHTML } = {}) => {
29
29
  })
30
30
  const createBrowser = require('./util/browserless')(constants)
31
31
  const getHTML = require('./util/html-get')({ createBrowser, got })
32
- const { createHtmlProvider, getOgImage } = require('./util/html-provider')({
33
- ...constants,
34
- getHTML,
35
- onFetchHTML
36
- })
32
+ const { createHtmlProvider, getOgImage, NOT_FOUND } =
33
+ require('./util/html-provider')({
34
+ ...constants,
35
+ getHTML,
36
+ onFetchHTML
37
+ })
37
38
 
38
39
  const providerCtx = {
39
40
  constants,
40
41
  createHtmlProvider,
41
42
  getOgImage,
43
+ NOT_FOUND,
42
44
  got,
43
45
  isReservedIp,
46
+ githubSearchCache: cache.githubSearchCache,
44
47
  itunesSearchCache: cache.itunesSearchCache
45
48
  }
46
49
  const { providers, providersBy } = require('./providers')(providerCtx)
@@ -1,10 +1,146 @@
1
1
  'use strict'
2
2
 
3
+ const memoize = require('@keyvhq/memoize')
3
4
  const { stringify } = require('querystring')
5
+ const isEmail = require('is-email-like')
4
6
 
5
- module.exports = ({ constants }) =>
6
- function github (input) {
7
- return `https://github.com/${input}.png?${stringify({
8
- size: constants.AVATAR_SIZE
9
- })}`
7
+ const GITHUB_API_URL = 'https://api.github.com'
8
+ const SEARCH_USERS_PER_PAGE = 10
9
+ const SEARCH_COMMITS_PER_PAGE = 20
10
+ const COMMIT_SEARCH_ACCEPT_HEADER = 'application/vnd.github+json'
11
+
12
+ const normalizeCacheKey = value => value.trim().toLowerCase()
13
+ const createLookupCacheKey = prefix => value =>
14
+ `${prefix}:${normalizeCacheKey(value)}`
15
+
16
+ const fetchJsonBody = async ({ got, url, options }) => {
17
+ const { body } = await got(url, {
18
+ responseType: 'json',
19
+ ...options
20
+ })
21
+
22
+ return body
23
+ }
24
+
25
+ const getUsernameAvatarUrl = ({ constants, input }) =>
26
+ `https://github.com/${input}.png?${stringify({
27
+ size: constants.AVATAR_SIZE
28
+ })}`
29
+
30
+ const createSearchUsersByEmail = ({ githubSearchCache, got }) =>
31
+ memoize(
32
+ async email => {
33
+ const body = await fetchJsonBody({
34
+ got,
35
+ url: `${GITHUB_API_URL}/search/users?q=${encodeURIComponent(
36
+ email
37
+ )}&per_page=${SEARCH_USERS_PER_PAGE}`
38
+ })
39
+
40
+ return body?.items ?? []
41
+ },
42
+ githubSearchCache,
43
+ { key: createLookupCacheKey('search-users') }
44
+ )
45
+
46
+ const createGetUser = ({ githubSearchCache, got }) =>
47
+ memoize(
48
+ login =>
49
+ fetchJsonBody({
50
+ got,
51
+ url: `${GITHUB_API_URL}/users/${encodeURIComponent(login)}`
52
+ }),
53
+ githubSearchCache,
54
+ { key: createLookupCacheKey('user') }
55
+ )
56
+
57
+ const createSearchCommitsByEmail = ({ githubSearchCache, got }) =>
58
+ memoize(
59
+ async email => {
60
+ const body = await fetchJsonBody({
61
+ got,
62
+ url: `${GITHUB_API_URL}/search/commits?q=${encodeURIComponent(
63
+ `author-email:${email}`
64
+ )}&per_page=${SEARCH_COMMITS_PER_PAGE}`,
65
+ options: {
66
+ headers: { accept: COMMIT_SEARCH_ACCEPT_HEADER }
67
+ }
68
+ })
69
+
70
+ return body?.items ?? []
71
+ },
72
+ githubSearchCache,
73
+ { key: createLookupCacheKey('search-commits') }
74
+ )
75
+
76
+ const findExactPublicProfileMatch = async ({
77
+ email,
78
+ getUser,
79
+ searchUsersByEmail
80
+ }) => {
81
+ const candidates = await searchUsersByEmail(email)
82
+ const normalizedEmail = email.toLowerCase()
83
+
84
+ for (const candidate of candidates) {
85
+ const user = await getUser(candidate.login)
86
+
87
+ if (user?.email?.toLowerCase() === normalizedEmail) {
88
+ return user.avatar_url
89
+ }
10
90
  }
91
+ }
92
+
93
+ const findCommitConsensusMatch = async ({ email, searchCommitsByEmail }) => {
94
+ const commits = await searchCommitsByEmail(email)
95
+ const counts = new Map()
96
+
97
+ for (const item of commits) {
98
+ const linkedUser = item.author ?? item.committer
99
+ if (!linkedUser?.login || !linkedUser?.avatar_url) continue
100
+
101
+ const entry = counts.get(linkedUser.login) ?? {
102
+ avatarUrl: linkedUser.avatar_url,
103
+ count: 0
104
+ }
105
+
106
+ entry.count += 1
107
+ counts.set(linkedUser.login, entry)
108
+ }
109
+
110
+ let winner
111
+ for (const entry of counts.values()) {
112
+ if (!winner || entry.count > winner.count) winner = entry
113
+ }
114
+
115
+ return winner?.avatarUrl
116
+ }
117
+
118
+ module.exports = ({ constants, githubSearchCache, got }) => {
119
+ const searchUsersByEmail = createSearchUsersByEmail({
120
+ githubSearchCache,
121
+ got
122
+ })
123
+ const getUser = createGetUser({ githubSearchCache, got })
124
+ const searchCommitsByEmail = createSearchCommitsByEmail({
125
+ githubSearchCache,
126
+ got
127
+ })
128
+
129
+ return async function github (input) {
130
+ if (!isEmail(input)) return getUsernameAvatarUrl({ constants, input })
131
+
132
+ const exactMatch = await findExactPublicProfileMatch({
133
+ email: input,
134
+ getUser,
135
+ searchUsersByEmail
136
+ })
137
+
138
+ if (exactMatch) return exactMatch
139
+
140
+ return findCommitConsensusMatch({ email: input, searchCommitsByEmail })
141
+ }
142
+ }
143
+
144
+ module.exports.getUsernameAvatarUrl = getUsernameAvatarUrl
145
+ module.exports.findExactPublicProfileMatch = findExactPublicProfileMatch
146
+ module.exports.findCommitConsensusMatch = findCommitConsensusMatch
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const providersBy = {
4
- email: ['gravatar'],
4
+ email: ['gravatar', 'github'],
5
5
  username: [
6
6
  'apple-music',
7
7
  'behance',
@@ -1,8 +1,15 @@
1
1
  'use strict'
2
2
 
3
- module.exports = ({ createHtmlProvider, getOgImage }) =>
3
+ const getAvatarUrl = ({ $, getOgImage, NOT_FOUND }) => {
4
+ const ogImage = getOgImage($)
5
+ return ogImage === undefined ? NOT_FOUND : ogImage
6
+ }
7
+
8
+ module.exports = ({ createHtmlProvider, getOgImage, NOT_FOUND }) =>
4
9
  createHtmlProvider({
5
10
  name: 'psnprofiles',
6
11
  url: input => `https://psnprofiles.com/${input}`,
7
- getter: getOgImage
12
+ getter: $ => getAvatarUrl({ $, getOgImage, NOT_FOUND })
8
13
  })
14
+
15
+ module.exports.getAvatarUrl = getAvatarUrl
package/src/util/cache.js CHANGED
@@ -4,6 +4,10 @@ const ms = require('ms')
4
4
 
5
5
  module.exports = ({ createMultiCache, createRedisCache }) => ({
6
6
  dnsCache: createMultiCache(createRedisCache({ namespace: 'dns', ttl: ms('1d') })),
7
+ githubSearchCache: createRedisCache({
8
+ namespace: 'github-search',
9
+ ttl: ms('1d')
10
+ }),
7
11
  pingCache: createMultiCache(createRedisCache({ namespace: 'ping', ttl: ms('1d') })),
8
12
  itunesSearchCache: createRedisCache({ namespace: 'itunes-search', ttl: ms('7d') })
9
13
  })
@@ -28,22 +28,26 @@ const createProviderError = ({ provider, statusCode, cause, code }) =>
28
28
  message: 'Empty value returned by the provider.'
29
29
  })
30
30
 
31
+ const createErrorCause = ({ html, headers, statusCode }) => ({
32
+ html,
33
+ headers,
34
+ statusCode
35
+ })
36
+
31
37
  module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
32
38
  /**
33
39
  * @param {object} opts
34
40
  * @param {string} opts.name - Provider identifier used in logs and metrics.
35
41
  * @param {(input: string) => string | Promise<string>} opts.url - Builds the URL to fetch for a given input.
36
- * @param {($: cheerio.CheerioAPI) => string | undefined} opts.getter
42
+ * @param {($: cheerio.CheerioAPI) => string | symbol | undefined} opts.getter
37
43
  * Extracts the avatar URL from the fetched HTML.
38
- * - `string` — avatar URL found (success).
44
+ * - `string` — avatar URL found (success).
45
+ * - `NOT_FOUND` — provider-level miss.
39
46
  * - `undefined` — avatar not found (normal failure, no retry).
40
- * @param {(context: { $: cheerio.CheerioAPI, statusCode: number }) => boolean} [opts.isBlocked]
41
- * Optional provider-specific blocked-page detector, checked after the
42
- * default `is-antibot` check when getter returns empty/undefined.
43
47
  * @param {() => object} [opts.htmlOpts] - Returns extra options merged into the fetch call.
44
48
  */
45
- const createHtmlProvider = ({ name, url, getter, isBlocked, htmlOpts }) => {
46
- const provider = async function (input, context) {
49
+ const createHtmlProvider = ({ name, url, getter, htmlOpts }) => {
50
+ async function provider (input, context) {
47
51
  const providerUrl = await url(input)
48
52
 
49
53
  const attempt = async gotOpts => {
@@ -56,7 +60,8 @@ module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
56
60
  },
57
61
  timeout: PROXY_TIMEOUT
58
62
  }
59
- const fetchOpts = gotOpts ? { ...defaultOpts, ...gotOpts } : defaultOpts
63
+ const fetchOpts = { ...defaultOpts, ...gotOpts }
64
+ const userAgent = fetchOpts.headers['user-agent']
60
65
  const tier = fetchOpts.tier ?? 'origin'
61
66
 
62
67
  const log = debug.duration({ provider: name, input, providerUrl, tier })
@@ -73,6 +78,11 @@ module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
73
78
  : undefined
74
79
  attempt.lastHeaders = responseHeaders
75
80
  attempt.lastStatusCode = statusCode
81
+ const errorCause = createErrorCause({
82
+ html: attempt.lastHtml,
83
+ headers: responseHeaders,
84
+ statusCode
85
+ })
76
86
 
77
87
  if (isStatusCodeMissing(statusCode)) {
78
88
  const code = EMPTY_PROVIDER_VALUE_CODE.MISSING_STATUS_CODE
@@ -80,67 +90,87 @@ module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
80
90
  throw createProviderError({
81
91
  provider: name,
82
92
  statusCode,
83
- cause: {
84
- html: attempt.lastHtml,
85
- headers: responseHeaders,
86
- statusCode
87
- },
93
+ cause: errorCause,
88
94
  code
89
95
  })
90
96
  }
91
97
 
98
+ const result = getter($)
92
99
  if (statusCode === httpStatus.NOT_FOUND) {
93
100
  log.error({ statusCode, status: 'not_found' })
94
101
  return NOT_FOUND
95
102
  }
96
103
 
97
- const result = getter($)
98
- if (typeof result !== 'string' || result === '') {
99
- const error = createProviderError({
104
+ function createEmptyGetterResultError () {
105
+ return createProviderError({
100
106
  provider: name,
101
107
  statusCode,
102
108
  code: EMPTY_PROVIDER_VALUE_CODE.EMPTY_GETTER_RESULT,
103
- cause: {
104
- html: attempt.lastHtml,
105
- headers: responseHeaders,
106
- statusCode
107
- }
109
+ cause: errorCause
108
110
  })
111
+ }
109
112
 
113
+ function getBlockedStatus () {
110
114
  const isRateLimited = statusCode === httpStatus.TOO_MANY_REQUESTS
111
- const providerBlocked = isBlocked?.({ $, statusCode })
115
+ if (isRateLimited) return { isBlocked: true, antibotProvider: null }
112
116
 
113
117
  const { detected: antibotDetected, provider: antibotProvider } =
114
- isRateLimited || providerBlocked
115
- ? { detected: false, provider: null }
116
- : isAntibot({
117
- url: providerUrl,
118
- statusCode,
119
- headers: responseHeaders,
120
- body: attempt.lastHtml
121
- })
122
-
123
- if (isRateLimited || providerBlocked || antibotDetected) {
124
- error.blocked = true
118
+ isAntibot({
119
+ url: providerUrl,
120
+ statusCode,
121
+ headers: responseHeaders,
122
+ body: attempt.lastHtml
123
+ })
124
+
125
+ return {
126
+ isBlocked: antibotDetected,
127
+ antibotProvider
125
128
  }
129
+ }
130
+
131
+ if (typeof result === 'string' && result !== '') {
132
+ const normalizedResult = normalizeUrl(providerUrl, result)
133
+ log({
134
+ statusCode,
135
+ status: 'success',
136
+ result: normalizedResult
137
+ })
138
+ return normalizedResult
139
+ }
140
+
141
+ // Some providers encode not-found via getter output. Check antibot
142
+ // first so challenge pages are retried as blocked, not treated as 404.
143
+ const { isBlocked: shouldMarkBlocked, antibotProvider } =
144
+ getBlockedStatus()
145
+
146
+ if (shouldMarkBlocked) {
147
+ const error = createEmptyGetterResultError()
148
+ error.blocked = true
126
149
 
127
150
  log.error({
128
151
  statusCode,
129
- status: error.blocked ? 'blocked' : undefined,
152
+ status: 'blocked',
130
153
  antibot: antibotProvider ?? undefined,
131
- userAgent: fetchOpts.headers['user-agent']
154
+ userAgent
132
155
  })
133
156
 
134
157
  throw error
135
158
  }
136
159
 
137
- const normalizedResult = normalizeUrl(providerUrl, result)
138
- log({
160
+ if (result === NOT_FOUND) {
161
+ log.error({ statusCode, status: 'not_found' })
162
+ return NOT_FOUND
163
+ }
164
+
165
+ const error = createEmptyGetterResultError()
166
+
167
+ log.error({
139
168
  statusCode,
140
- status: 'success',
141
- result: normalizedResult
169
+ antibot: antibotProvider ?? undefined,
170
+ userAgent
142
171
  })
143
- return normalizedResult
172
+
173
+ throw error
144
174
  }
145
175
 
146
176
  if (typeof onFetchHTML === 'function') {
@@ -148,8 +178,7 @@ module.exports = ({ PROXY_TIMEOUT, getHTML, onFetchHTML }) => {
148
178
  }
149
179
 
150
180
  const result = await attempt()
151
- if (result === NOT_FOUND) return
152
- return result
181
+ return result === NOT_FOUND ? undefined : result
153
182
  }
154
183
 
155
184
  provider.getUrl = url