@unavatar/core 3.19.4 → 3.19.6

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
@@ -68,11 +68,13 @@ It's proudly powered by [microlink.io](https://microlink.io/), the headless brow
68
68
 
69
69
  The service is exposed in **unavatar.io** via provider endpoints:
70
70
 
71
- - an **email**: [unavatar.io/gravatar/hello@microlink.io](https://unavatar.io/gravatar/hello@microlink.io)
71
+ - an **email (auto-detect)**: [unavatar.io/hello@microlink.io](https://unavatar.io/hello@microlink.io) — tries Gravatar, then GitHub
72
+ - an **email** via Gravatar: [unavatar.io/gravatar/hello@microlink.io](https://unavatar.io/gravatar/hello@microlink.io)
73
+ - an **email** via GitHub: [unavatar.io/github/sindresorhus@gmail.com](https://unavatar.io/github/sindresorhus@gmail.com)
72
74
  - an **username**: [unavatar.io/github/kikobeats](https://unavatar.io/github/kikobeats)
73
75
  - a **domain**: [unavatar.io/google/reddit.com](https://unavatar.io/google/reddit.com)
74
76
 
75
- Use the `/:provider/:key` format for all lookups. You can read more about available providers in [providers](https://unavatar.io/docs#providers).
77
+ Use `/:provider/:key` for provider-specific lookups, or pass an email as the only path segment for automatic resolution. You can read more in [Email avatars](https://unavatar.io/email) and [providers](https://unavatar.io/docs#providers).
76
78
 
77
79
  ## Authentication
78
80
 
@@ -361,12 +363,13 @@ Available inputs:
361
363
 
362
364
  ### GitHub
363
365
 
364
- Get any GitHub user or organization's profile picture by their username.
366
+ Get any GitHub user or organization's profile picture by username, or resolve an avatar from a public email via GitHub search when the address matches a profile or commit history.
365
367
 
366
368
  Available inputs:
367
369
 
368
370
  - User, e.g., [unavatar.io/github/mdo](https://unavatar.io/github/mdo)
369
371
  - Organization, e.g., [unavatar.io/github/vercel](https://unavatar.io/github/vercel)
372
+ - Email address, e.g., [unavatar.io/github/sindresorhus@gmail.com](https://unavatar.io/github/sindresorhus@gmail.com)
370
373
 
371
374
  ### GitLab
372
375
 
@@ -495,7 +498,7 @@ Get any PlayStation Network user's profile picture by their PSN username.
495
498
 
496
499
  Available inputs:
497
500
 
498
- - Username, e.g., [unavatar.io/psnprofiles/P3](https://unavatar.io/psnprofiles/P3)
501
+ - Username, e.g., [unavatar.io/psnprofiles/Duff85](https://unavatar.io/psnprofiles/Duff85)
499
502
 
500
503
  ### Reddit
501
504
 
@@ -644,7 +647,7 @@ Get any Xbox player's profile picture by their gamertag.
644
647
 
645
648
  Available inputs:
646
649
 
647
- - Gamertag, e.g., [unavatar.io/xboxgamertag/P3](https://unavatar.io/xboxgamertag/P3)
650
+ - Gamertag, e.g., [unavatar.io/xboxgamertag/GD-BerserkerTTD](https://unavatar.io/xboxgamertag/GD-BerserkerTTD)
648
651
 
649
652
  ### YouTube
650
653
 
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.6",
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