@unavatar/core 3.19.4 → 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.4",
5
+ "version": "3.19.5",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
@@ -9,9 +9,11 @@ const SEARCH_USERS_PER_PAGE = 10
9
9
  const SEARCH_COMMITS_PER_PAGE = 20
10
10
  const COMMIT_SEARCH_ACCEPT_HEADER = 'application/vnd.github+json'
11
11
 
12
- const normalizeCacheKey = value => value.trim().toLowerCase()
13
- const createLookupCacheKey = prefix => value =>
14
- `${prefix}:${normalizeCacheKey(value)}`
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))]
15
17
 
16
18
  const fetchJsonBody = async ({ got, url, options }) => {
17
19
  const { body } = await got(url, {
@@ -22,26 +24,35 @@ const fetchJsonBody = async ({ got, url, options }) => {
22
24
  return body
23
25
  }
24
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
+
25
41
  const getUsernameAvatarUrl = ({ constants, input }) =>
26
42
  `https://github.com/${input}.png?${stringify({
27
43
  size: constants.AVATAR_SIZE
28
44
  })}`
29
45
 
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
- )
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
+ }
45
56
 
46
57
  const createGetUser = ({ githubSearchCache, got }) =>
47
58
  memoize(
@@ -51,52 +62,74 @@ const createGetUser = ({ githubSearchCache, got }) =>
51
62
  url: `${GITHUB_API_URL}/users/${encodeURIComponent(login)}`
52
63
  }),
53
64
  githubSearchCache,
54
- { key: createLookupCacheKey('user') }
65
+ { key: login => `user:${normalizeValue(login)}` }
55
66
  )
56
67
 
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
- })
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
+ })
69
79
 
70
- return body?.items ?? []
71
- },
72
- githubSearchCache,
73
- { key: createLookupCacheKey('search-commits') }
74
- )
80
+ return getSearchItems(body)
81
+ }
75
82
 
76
- const findExactPublicProfileMatch = async ({
83
+ const findExactPublicProfileMatches = async ({
77
84
  email,
78
85
  getUser,
79
86
  searchUsersByEmail
80
87
  }) => {
88
+ // `searchUsersByEmail` returns lightweight candidate accounts (search items),
89
+ // so we use each login to fetch full profiles.
81
90
  const candidates = await searchUsersByEmail(email)
82
- const normalizedEmail = email.toLowerCase()
83
91
 
84
- for (const candidate of candidates) {
85
- const user = await getUser(candidate.login)
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
86
105
 
87
- if (user?.email?.toLowerCase() === normalizedEmail) {
88
- return user.avatar_url
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
89
110
  }
111
+
112
+ if (!organizationAvatarUrl && isResolvableOrganization(profile)) {
113
+ organizationAvatarUrl = profile.avatar_url
114
+ }
115
+
116
+ if (userAvatarUrl && organizationAvatarUrl) break
90
117
  }
118
+
119
+ return { userAvatarUrl, organizationAvatarUrl }
91
120
  }
92
121
 
93
122
  const findCommitConsensusMatch = async ({ email, searchCommitsByEmail }) => {
94
123
  const commits = await searchCommitsByEmail(email)
124
+ if (commits.length === 0) return
125
+
95
126
  const counts = new Map()
96
127
 
97
128
  for (const item of commits) {
98
- const linkedUser = item.author ?? item.committer
99
- if (!linkedUser?.login || !linkedUser?.avatar_url) continue
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
100
133
 
101
134
  const entry = counts.get(linkedUser.login) ?? {
102
135
  avatarUrl: linkedUser.avatar_url,
@@ -106,41 +139,47 @@ const findCommitConsensusMatch = async ({ email, searchCommitsByEmail }) => {
106
139
  entry.count += 1
107
140
  counts.set(linkedUser.login, entry)
108
141
  }
142
+ if (counts.size === 0) return
109
143
 
110
144
  let winner
111
145
  for (const entry of counts.values()) {
112
146
  if (!winner || entry.count > winner.count) winner = entry
113
147
  }
114
148
 
149
+ // Return the dominant user across matched commits.
115
150
  return winner?.avatarUrl
116
151
  }
117
152
 
118
153
  module.exports = ({ constants, githubSearchCache, got }) => {
119
- const searchUsersByEmail = createSearchUsersByEmail({
120
- githubSearchCache,
121
- got
122
- })
154
+ const searchUsersByEmail = createSearchUsersByEmail({ got })
123
155
  const getUser = createGetUser({ githubSearchCache, got })
124
- const searchCommitsByEmail = createSearchCommitsByEmail({
125
- githubSearchCache,
126
- got
127
- })
156
+ const searchCommitsByEmail = createSearchCommitsByEmail({ got })
128
157
 
129
158
  return async function github (input) {
130
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
+ })
131
170
 
132
- const exactMatch = await findExactPublicProfileMatch({
133
- email: input,
134
- getUser,
135
- searchUsersByEmail
136
- })
171
+ if (userAvatarUrl) return userAvatarUrl
137
172
 
138
- if (exactMatch) return exactMatch
173
+ const userCommitMatch = await findCommitConsensusMatch({
174
+ email,
175
+ searchCommitsByEmail
176
+ })
177
+ if (userCommitMatch) return userCommitMatch
139
178
 
140
- return findCommitConsensusMatch({ email: input, searchCommitsByEmail })
179
+ return organizationAvatarUrl
141
180
  }
142
181
  }
143
182
 
144
183
  module.exports.getUsernameAvatarUrl = getUsernameAvatarUrl
145
- module.exports.findExactPublicProfileMatch = findExactPublicProfileMatch
184
+ module.exports.findExactPublicProfileMatches = findExactPublicProfileMatches
146
185
  module.exports.findCommitConsensusMatch = findCommitConsensusMatch