@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 +8 -5
- package/package.json +1 -1
- package/src/providers/github.js +99 -60
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/
|
|
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
|
|
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
|
|
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/
|
|
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/
|
|
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.
|
|
5
|
+
"version": "3.19.6",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
package/src/providers/github.js
CHANGED
|
@@ -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
|
|
13
|
-
|
|
14
|
-
|
|
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 = ({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
65
|
+
{ key: login => `user:${normalizeValue(login)}` }
|
|
55
66
|
)
|
|
56
67
|
|
|
57
|
-
const createSearchCommitsByEmail = ({
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
githubSearchCache,
|
|
73
|
-
{ key: createLookupCacheKey('search-commits') }
|
|
74
|
-
)
|
|
80
|
+
return getSearchItems(body)
|
|
81
|
+
}
|
|
75
82
|
|
|
76
|
-
const
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
133
|
-
email: input,
|
|
134
|
-
getUser,
|
|
135
|
-
searchUsersByEmail
|
|
136
|
-
})
|
|
171
|
+
if (userAvatarUrl) return userAvatarUrl
|
|
137
172
|
|
|
138
|
-
|
|
173
|
+
const userCommitMatch = await findCommitConsensusMatch({
|
|
174
|
+
email,
|
|
175
|
+
searchCommitsByEmail
|
|
176
|
+
})
|
|
177
|
+
if (userCommitMatch) return userCommitMatch
|
|
139
178
|
|
|
140
|
-
return
|
|
179
|
+
return organizationAvatarUrl
|
|
141
180
|
}
|
|
142
181
|
}
|
|
143
182
|
|
|
144
183
|
module.exports.getUsernameAvatarUrl = getUsernameAvatarUrl
|
|
145
|
-
module.exports.
|
|
184
|
+
module.exports.findExactPublicProfileMatches = findExactPublicProfileMatches
|
|
146
185
|
module.exports.findCommitConsensusMatch = findCommitConsensusMatch
|