Skip to content

Commit 4af7b61

Browse files
committed
Add GH sponsors
1 parent 476d50b commit 4af7b61

File tree

10 files changed

+410
-220
lines changed

10 files changed

+410
-220
lines changed

crawl/github-sponsors.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @typedef GithubOrganizationData
3+
* Github organization data.
4+
* @property {{nodes: ReadonlyArray<Readonly<GithubSponsorNode>>}} lifetimeReceivedSponsorshipValues
5+
* Sponsorships.
6+
*
7+
* @typedef GithubSponsor
8+
* GitHub sponsor.
9+
* @property {string} avatarUrl
10+
* Avatar URL.
11+
* @property {string | null | undefined} [bio]
12+
* Bio.
13+
* @property {string | null | undefined} [description]
14+
* Description.
15+
* @property {string} login
16+
* Username.
17+
* @property {string | null} name
18+
* Name.
19+
* @property {string | null} websiteUrl
20+
* URL.
21+
*
22+
* @typedef GithubSponsorNode
23+
* GitHub sponsor node.
24+
* @property {number} amountInCents
25+
* Total price.
26+
* @property {Readonly<GithubSponsor>} sponsor
27+
* Sponsor.
28+
*
29+
* @typedef GithubSponsorsResponse
30+
* GitHub sponsors response.
31+
* @property {{organization: Readonly<GithubOrganizationData>}} data
32+
* Data.
33+
*
34+
* @typedef SponsorRaw
35+
* Sponsor (raw).
36+
* @property {string | undefined} description
37+
* Description.
38+
* @property {string} github
39+
* GitHub username.
40+
* @property {string} image
41+
* Image.
42+
* @property {number} total
43+
* Total amount.
44+
* @property {string | undefined} name
45+
* Name.
46+
* @property {string | undefined} url
47+
* URL.
48+
*/
49+
50+
import fs from 'node:fs/promises'
51+
import process from 'node:process'
52+
import dotenv from 'dotenv'
53+
54+
dotenv.config()
55+
56+
const key = process.env.GH_TOKEN
57+
58+
if (!key) throw new Error('Missing `GH_TOKEN`')
59+
60+
const outUrl = new URL('../data/github-sponsors.json', import.meta.url)
61+
62+
const endpoint = 'https://api.github.com/graphql'
63+
64+
// To do: paginate.
65+
const query = `query($org: String!) {
66+
organization(login: $org) {
67+
lifetimeReceivedSponsorshipValues(first: 100, orderBy: {field: LIFETIME_VALUE, direction: DESC}) {
68+
nodes {
69+
amountInCents
70+
sponsor {
71+
... on Organization { avatarUrl description login name websiteUrl }
72+
... on User { avatarUrl bio login name websiteUrl }
73+
}
74+
}
75+
}
76+
}
77+
}
78+
`
79+
80+
const response = await fetch(endpoint, {
81+
body: JSON.stringify({query, variables: {org: 'unifiedjs'}}),
82+
headers: {Authorization: 'bearer ' + key, 'Content-Type': 'application/json'},
83+
method: 'POST'
84+
})
85+
const body = /** @type {Readonly<GithubSponsorsResponse>} */ (
86+
await response.json()
87+
)
88+
89+
const collective =
90+
body.data.organization.lifetimeReceivedSponsorshipValues.nodes
91+
.map(function (d) {
92+
return clean(d)
93+
})
94+
.sort(sort)
95+
// `10` dollar minimum.
96+
.filter(function (d) {
97+
return d.total >= 10
98+
})
99+
100+
await fs.mkdir(new URL('./', outUrl), {recursive: true})
101+
await fs.writeFile(outUrl, JSON.stringify(collective, undefined, 2) + '\n')
102+
103+
/**
104+
* @param {Readonly<GithubSponsorNode>} d
105+
* Sponsor node.
106+
* @returns {SponsorRaw}
107+
* Sponsor.
108+
*/
109+
function clean(d) {
110+
return {
111+
description: d.sponsor.bio || d.sponsor.description || undefined,
112+
github: d.sponsor.login,
113+
image: d.sponsor.avatarUrl,
114+
total: Math.floor(d.amountInCents / 100),
115+
name: d.sponsor.name || undefined,
116+
url: d.sponsor.websiteUrl || undefined
117+
}
118+
}
119+
120+
/**
121+
* @param {Readonly<SponsorRaw>} a
122+
* Left.
123+
* @param {Readonly<SponsorRaw>} b
124+
* Right.
125+
* @returns {number}
126+
* Sort order.
127+
*/
128+
function sort(a, b) {
129+
return b.total - a.total
130+
}

crawl/opencollective.js

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* @typedef OcAccount
3+
* Open Collective account.
4+
* @property {string | undefined} description
5+
* Description.
6+
* @property {string | undefined} githubHandle
7+
* GitHub username.
8+
* @property {string} id
9+
* ID.
10+
* @property {string} imageUrl
11+
* Image URL.
12+
* @property {string} name
13+
* Name.
14+
* @property {string} slug
15+
* Slug.
16+
* @property {string | undefined} twitterHandle
17+
* Twitter username.
18+
* @property {string | undefined} website
19+
* Website.
20+
*
21+
* @typedef OcCollective
22+
* Open Collective collective.
23+
* @property {{nodes: ReadonlyArray<Readonly<OcMember>>}} members
24+
* Members.
25+
*
26+
* @typedef OcData
27+
* Open Collective data.
28+
* @property {Readonly<OcCollective>} collective
29+
* Collective.
30+
*
31+
* @typedef OcMember
32+
* Open Collective member.
33+
* @property {Readonly<OcAccount>} account
34+
* Account.
35+
* @property {Readonly<{value: number}>} totalDonations
36+
* Total donations.
37+
*
38+
* @typedef OcResponse
39+
* Open Collective response.
40+
* @property {Readonly<OcData>} data
41+
* Data.
42+
*
43+
* @typedef {Omit<SponsorRaw, 'spam'>} Sponsor
44+
* Sponsor.
45+
*
46+
* @typedef SponsorRaw
47+
* Sponsor (raw).
48+
* @property {string | undefined} [description]
49+
* Description.
50+
* @property {string | undefined} [github]
51+
* GitHub username.
52+
* @property {string} image
53+
* Image.
54+
* @property {string} name
55+
* Name.
56+
* @property {string} oc
57+
* Open Collective slug.
58+
* @property {boolean} spam
59+
* Whether it’s spam.
60+
* @property {number} total
61+
* Total donations.
62+
* @property {string | undefined} [twitter]
63+
* Twitter username.
64+
* @property {string | undefined} [url]
65+
* URL.
66+
*/
67+
68+
import fs from 'node:fs/promises'
69+
import process from 'node:process'
70+
import dotenv from 'dotenv'
71+
72+
dotenv.config()
73+
74+
const key = process.env.OC_TOKEN
75+
76+
if (!key) throw new Error('Missing `OC_TOKEN`')
77+
78+
const min = 10
79+
80+
const endpoint = 'https://api.opencollective.com/graphql/v2'
81+
82+
const variables = {slug: 'unified'}
83+
84+
const ghBase = 'https://github.com/'
85+
const twBase = 'https://twitter.com/'
86+
87+
// To do: paginate.
88+
const query = `query($slug: String) {
89+
collective(slug: $slug) {
90+
members(limit: 100, role: BACKER) {
91+
nodes {
92+
account {
93+
description
94+
githubHandle
95+
id
96+
imageUrl
97+
name
98+
slug
99+
twitterHandle
100+
website
101+
}
102+
totalDonations { value }
103+
}
104+
}
105+
}
106+
}
107+
`
108+
109+
const sponsorsTxt = await fs.readFile(
110+
new URL('sponsors.txt', import.meta.url),
111+
'utf8'
112+
)
113+
114+
const collectiveResponse = await fetch(endpoint, {
115+
body: JSON.stringify({query, variables}),
116+
headers: {'Api-Key': key, 'Content-Type': 'application/json'},
117+
method: 'POST'
118+
})
119+
const collectiveBody = /** @type {Readonly<OcResponse>} */ (
120+
await collectiveResponse.json()
121+
)
122+
123+
/** @type {Array<{oc: string, spam: boolean}>} */
124+
const control = []
125+
126+
for (const d of sponsorsTxt.split('\n')) {
127+
const spam = d.charAt(0) === '-'
128+
129+
control.push({oc: spam ? d.slice(1) : d, spam})
130+
}
131+
132+
/** @type {Set<string>} */
133+
const seen = new Set()
134+
/** @type {Array<SponsorRaw>} */
135+
const members = []
136+
137+
for (const d of collectiveBody.data.collective.members.nodes) {
138+
const oc = d.account.slug
139+
const github = d.account.githubHandle || undefined
140+
const twitter = d.account.twitterHandle || undefined
141+
let url = d.account.website || undefined
142+
const info = control.find(function (d) {
143+
return d.oc === oc
144+
})
145+
146+
if (url === ghBase + github || url === twBase + twitter) {
147+
url = undefined
148+
}
149+
150+
if (!info) {
151+
console.error(
152+
'✖ @%s is an unknown sponsor, please define whether it’s spam or not in `sponsors.txt`',
153+
oc
154+
)
155+
}
156+
157+
/** @type {Readonly<SponsorRaw>} */
158+
const person = {
159+
description: d.account.description || undefined,
160+
github,
161+
image: d.account.imageUrl,
162+
name: d.account.name,
163+
oc,
164+
spam: !info || info.spam,
165+
total: d.totalDonations.value,
166+
twitter,
167+
url
168+
}
169+
170+
const ignore = person.spam || seen.has(person.oc) // Ignore dupes in data.
171+
seen.add(person.oc)
172+
173+
if (person.total > min && !ignore) {
174+
members.push(person)
175+
}
176+
}
177+
178+
members.sort(sort)
179+
180+
/** @type {Array<Sponsor>} */
181+
const stripped = []
182+
183+
for (const d of members) {
184+
const {spam, ...rest} = d
185+
stripped.push(rest)
186+
}
187+
188+
await fs.writeFile(
189+
new URL('../data/opencollective.js', import.meta.url),
190+
[
191+
'/**',
192+
' * @import {Sponsor} from "../crawl/opencollective.js"',
193+
' */',
194+
'',
195+
'/** @type {Array<Sponsor>} */',
196+
'export const sponsors = ' + JSON.stringify(stripped, undefined, 2),
197+
''
198+
].join('\n')
199+
)
200+
201+
/**
202+
* @param {Readonly<SponsorRaw>} a
203+
* Left.
204+
* @param {Readonly<SponsorRaw>} b
205+
* Right.
206+
* @returns {number}
207+
* Sort value.
208+
*/
209+
function sort(a, b) {
210+
return b.total - a.total
211+
}

0 commit comments

Comments
 (0)