@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 +2 -2
- package/src/index.js +8 -5
- package/src/providers/github.js +141 -5
- package/src/providers/index.js +1 -1
- package/src/providers/psnprofiles.js +9 -2
- package/src/util/cache.js +4 -0
- package/src/util/html-provider.js +71 -42
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.
|
|
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.
|
|
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 } =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
package/src/providers/github.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
package/src/providers/index.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
|
|
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`
|
|
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,
|
|
46
|
-
|
|
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 =
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
115
|
+
if (isRateLimited) return { isBlocked: true, antibotProvider: null }
|
|
112
116
|
|
|
113
117
|
const { detected: antibotDetected, provider: antibotProvider } =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
152
|
+
status: 'blocked',
|
|
130
153
|
antibot: antibotProvider ?? undefined,
|
|
131
|
-
userAgent
|
|
154
|
+
userAgent
|
|
132
155
|
})
|
|
133
156
|
|
|
134
157
|
throw error
|
|
135
158
|
}
|
|
136
159
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
169
|
+
antibot: antibotProvider ?? undefined,
|
|
170
|
+
userAgent
|
|
142
171
|
})
|
|
143
|
-
|
|
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
|
-
|
|
152
|
-
return result
|
|
181
|
+
return result === NOT_FOUND ? undefined : result
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
provider.getUrl = url
|