@unavatar/core 3.19.3 → 3.19.5

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.3",
5
+ "version": "3.19.5",
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
@@ -43,6 +43,7 @@ module.exports = ({ constants: userConstants, redis, onFetchHTML } = {}) => {
43
43
  NOT_FOUND,
44
44
  got,
45
45
  isReservedIp,
46
+ githubSearchCache: cache.githubSearchCache,
46
47
  itunesSearchCache: cache.itunesSearchCache
47
48
  }
48
49
  const { providers, providersBy } = require('./providers')(providerCtx)
@@ -1,10 +1,185 @@
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 normalizeValue = value =>
13
+ typeof value === 'string' ? value.trim().toLowerCase() : ''
14
+ const getSearchItems = body => body?.items ?? []
15
+ const getUniqueLogins = candidates =>
16
+ [...new Set(candidates.map(({ login }) => login).filter(Boolean))]
17
+
18
+ const fetchJsonBody = async ({ got, url, options }) => {
19
+ const { body } = await got(url, {
20
+ responseType: 'json',
21
+ ...options
22
+ })
23
+
24
+ return body
25
+ }
26
+
27
+ const isResolvableAccount = (user, accountType) =>
28
+ user?.type === accountType &&
29
+ typeof user.login === 'string' &&
30
+ typeof user.avatar_url === 'string'
31
+
32
+ const isResolvablePerson = user => isResolvableAccount(user, 'User')
33
+ const isResolvableOrganization = user =>
34
+ isResolvableAccount(user, 'Organization')
35
+
36
+ const pickLinkedUser = item => {
37
+ if (isResolvablePerson(item.author)) return item.author
38
+ if (isResolvablePerson(item.committer)) return item.committer
39
+ }
40
+
41
+ const getUsernameAvatarUrl = ({ constants, input }) =>
42
+ `https://github.com/${input}.png?${stringify({
43
+ size: constants.AVATAR_SIZE
44
+ })}`
45
+
46
+ const createSearchUsersByEmail = ({ got }) =>
47
+ async email => {
48
+ const body = await fetchJsonBody({
49
+ got,
50
+ url: `${GITHUB_API_URL}/search/users?q=${encodeURIComponent(
51
+ email
52
+ )}&per_page=${SEARCH_USERS_PER_PAGE}`
53
+ })
54
+ return getSearchItems(body)
55
+ }
56
+
57
+ const createGetUser = ({ githubSearchCache, got }) =>
58
+ memoize(
59
+ login =>
60
+ fetchJsonBody({
61
+ got,
62
+ url: `${GITHUB_API_URL}/users/${encodeURIComponent(login)}`
63
+ }),
64
+ githubSearchCache,
65
+ { key: login => `user:${normalizeValue(login)}` }
66
+ )
67
+
68
+ const createSearchCommitsByEmail = ({ got }) =>
69
+ async email => {
70
+ const body = await fetchJsonBody({
71
+ got,
72
+ url: `${GITHUB_API_URL}/search/commits?q=${encodeURIComponent(
73
+ `author-email:${email}`
74
+ )}&per_page=${SEARCH_COMMITS_PER_PAGE}`,
75
+ options: {
76
+ headers: { accept: COMMIT_SEARCH_ACCEPT_HEADER }
77
+ }
78
+ })
79
+
80
+ return getSearchItems(body)
10
81
  }
82
+
83
+ const findExactPublicProfileMatches = async ({
84
+ email,
85
+ getUser,
86
+ searchUsersByEmail
87
+ }) => {
88
+ // `searchUsersByEmail` returns lightweight candidate accounts (search items),
89
+ // so we use each login to fetch full profiles.
90
+ const candidates = await searchUsersByEmail(email)
91
+
92
+ const candidateLogins = getUniqueLogins(candidates)
93
+
94
+ // Candidate items don't include the public email field, so we hydrate each
95
+ // login in parallel with /users/:login to validate exact matches.
96
+ const profiles = await Promise.all(
97
+ candidateLogins.map(login => getUser(login))
98
+ )
99
+
100
+ let userAvatarUrl
101
+ let organizationAvatarUrl
102
+
103
+ for (const profile of profiles) {
104
+ if (normalizeValue(profile?.email) !== email) continue
105
+
106
+ // Keep first exact match per account type. Caller prioritizes user avatar
107
+ // and only falls back to organization when no user signal is found.
108
+ if (!userAvatarUrl && isResolvablePerson(profile)) {
109
+ userAvatarUrl = profile.avatar_url
110
+ }
111
+
112
+ if (!organizationAvatarUrl && isResolvableOrganization(profile)) {
113
+ organizationAvatarUrl = profile.avatar_url
114
+ }
115
+
116
+ if (userAvatarUrl && organizationAvatarUrl) break
117
+ }
118
+
119
+ return { userAvatarUrl, organizationAvatarUrl }
120
+ }
121
+
122
+ const findCommitConsensusMatch = async ({ email, searchCommitsByEmail }) => {
123
+ const commits = await searchCommitsByEmail(email)
124
+ if (commits.length === 0) return
125
+
126
+ const counts = new Map()
127
+
128
+ for (const item of commits) {
129
+ // Commit search can reference org/bot identities; pickLinkedUser enforces
130
+ // that only real user identities are considered in this fallback.
131
+ const linkedUser = pickLinkedUser(item)
132
+ if (!linkedUser) continue
133
+
134
+ const entry = counts.get(linkedUser.login) ?? {
135
+ avatarUrl: linkedUser.avatar_url,
136
+ count: 0
137
+ }
138
+
139
+ entry.count += 1
140
+ counts.set(linkedUser.login, entry)
141
+ }
142
+ if (counts.size === 0) return
143
+
144
+ let winner
145
+ for (const entry of counts.values()) {
146
+ if (!winner || entry.count > winner.count) winner = entry
147
+ }
148
+
149
+ // Return the dominant user across matched commits.
150
+ return winner?.avatarUrl
151
+ }
152
+
153
+ module.exports = ({ constants, githubSearchCache, got }) => {
154
+ const searchUsersByEmail = createSearchUsersByEmail({ got })
155
+ const getUser = createGetUser({ githubSearchCache, got })
156
+ const searchCommitsByEmail = createSearchCommitsByEmail({ got })
157
+
158
+ return async function github (input) {
159
+ if (!isEmail(input)) return getUsernameAvatarUrl({ constants, input })
160
+ const email = normalizeValue(input)
161
+
162
+ // Strategy: exact public user email -> user commit consensus ->
163
+ // exact organization email.
164
+ const { userAvatarUrl, organizationAvatarUrl } =
165
+ await findExactPublicProfileMatches({
166
+ email,
167
+ getUser,
168
+ searchUsersByEmail
169
+ })
170
+
171
+ if (userAvatarUrl) return userAvatarUrl
172
+
173
+ const userCommitMatch = await findCommitConsensusMatch({
174
+ email,
175
+ searchCommitsByEmail
176
+ })
177
+ if (userCommitMatch) return userCommitMatch
178
+
179
+ return organizationAvatarUrl
180
+ }
181
+ }
182
+
183
+ module.exports.getUsernameAvatarUrl = getUsernameAvatarUrl
184
+ module.exports.findExactPublicProfileMatches = findExactPublicProfileMatches
185
+ 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',
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
  })