From 5bf2aae28beb40e36dfbda0b3e832e8f4ec9c4ca Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Sat, 28 Dec 2024 10:38:09 +0100 Subject: [PATCH] feat: add liberapay support --- README.md | 8 ++++ package.json | 1 + pnpm-lock.yaml | 45 ++++++++++++++++++++ src/configs/env.ts | 3 ++ src/processing/image.ts | 3 ++ src/providers/index.ts | 5 +++ src/providers/liberapay.ts | 86 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 11 ++++- 8 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/providers/liberapay.ts diff --git a/README.md b/README.md index 52685b6..0746a6f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Supports: - [**OpenCollective**](https://opencollective.com/) - [**Afdian**](https://afdian.com/) - [**Polar**](https://polar.sh/) +- [**Liberapay**](https://liberapay.com/) ## Usage @@ -50,6 +51,10 @@ SPONSORKIT_AFDIAN_TOKEN= SPONSORKIT_POLAR_TOKEN= ; The name of the organization to fetch sponsorships from. SPONSORKIT_POLAR_ORGANIZATION= + +; Liberapay provider. +; The name of the profile. +SPONSORKIT_LIBERAPAY_LOGIN= ``` > Only one provider is required to be configured. @@ -87,6 +92,9 @@ export default defineConfig({ polar: { // ... }, + liberapay: { + // ... + }, // Rendering configs width: 800, diff --git a/package.json b/package.json index 0453ed6..61446e0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@antfu/utils": "^0.7.10", + "@fast-csv/parse": "^5.0.2", "consola": "^3.2.3", "d3-hierarchy": "^3.1.2", "dotenv": "^16.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4691a04..f5e2b11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@antfu/utils': specifier: ^0.7.10 version: 0.7.10 + '@fast-csv/parse': + specifier: ^5.0.2 + version: 5.0.2 consola: specifier: ^3.2.3 version: 3.2.3 @@ -711,6 +714,9 @@ packages: resolution: {integrity: sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fast-csv/parse@5.0.2': + resolution: {integrity: sha512-gMu1Btmm99TP+wc0tZnlH30E/F1Gw1Tah3oMDBHNPe9W8S68ixVHjt89Wg5lh7d9RuQMtwN+sGl5kxR891+fzw==} + '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} @@ -1975,9 +1981,27 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3154,6 +3178,15 @@ snapshots: dependencies: levn: 0.4.1 + '@fast-csv/parse@5.0.2': + dependencies: + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.3.0': {} @@ -4506,8 +4539,20 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.escaperegexp@4.1.2: {} + + lodash.groupby@4.6.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} + lodash.uniq@4.5.0: {} + lodash@4.17.21: {} loupe@3.1.1: diff --git a/src/configs/env.ts b/src/configs/env.ts index 81653a8..ae8484c 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -37,6 +37,9 @@ export function loadEnv(): Partial { token: process.env.SPONSORKIT_POLAR_TOKEN || process.env.POLAR_TOKEN, organization: process.env.SPONSORKIT_POLAR_ORGANIZATION || process.env.POLAR_ORGANIZATION, }, + liberapay: { + login: process.env.SPONSORKIT_LIBERAPAY_LOGIN || process.env.LIBERAPAY_LOGIN, + }, outputDir: process.env.SPONSORKIT_DIR, } diff --git a/src/processing/image.ts b/src/processing/image.ts index ee99129..3ff6d07 100644 --- a/src/processing/image.ts +++ b/src/processing/image.ts @@ -38,6 +38,9 @@ export async function resolveAvatars( } const pngBuffer = await fetchImage(ship.sponsor.avatarUrl).catch((e) => { + // Liberapay avatar URLs can return 404 Not Found + if (ship.provider == 'liberapay' && e.toString().includes('404 Not Found') && fallbackAvatar) + return fallbackAvatar t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`) t.error(e) if (fallbackAvatar) diff --git a/src/providers/index.ts b/src/providers/index.ts index 0404787..07cc929 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +1,6 @@ import { AfdianProvider } from './afdian' import { GitHubProvider } from './github' +import { LiberapayProvider } from "./liberapay" import { OpenCollectiveProvider } from './opencollective' import { PatreonProvider } from './patreon' import { PolarProvider } from './polar' @@ -13,6 +14,7 @@ export const ProvidersMap = { opencollective: OpenCollectiveProvider, afdian: AfdianProvider, polar: PolarProvider, + liberapay: LiberapayProvider, } export function guessProviders(config: SponsorkitConfig) { @@ -32,6 +34,9 @@ export function guessProviders(config: SponsorkitConfig) { if (config.polar && config.polar.token) items.push('polar') + if (config.liberapay && config.liberapay.login) + items.push('liberapay') + // fallback if (!items.length) items.push('github') diff --git a/src/providers/liberapay.ts b/src/providers/liberapay.ts new file mode 100644 index 0000000..77a4987 --- /dev/null +++ b/src/providers/liberapay.ts @@ -0,0 +1,86 @@ + +import { $fetch } from 'ofetch' +import { parseString } from '@fast-csv/parse' +import type { Provider, Sponsorship } from '../types' + +export const LiberapayProvider: Provider = { + name: 'liberapay', + fetchSponsors(config) { + return fetchLiberapaySponsors(config.liberapay?.login) + }, +} + +interface LiberapayRow { + pledge_date: string + patron_id: string + patron_username: string + patron_public_name: string + donation_currency: string + weekly_amount: string + patron_avatar_url: string +} + +interface ExchangeRate { + code: string + alphaCode: string + numericCode: string + name: string + rate: number + date: string + inverseRate: number +} + +interface ExchangeRates { + [key: string]: ExchangeRate +} + +export async function fetchLiberapaySponsors(login?: string): Promise { + if (!login) + throw new Error('Liberapay login is required') + + // Fetch and parse public CSV data + const csvUrl = `https://liberapay.com/${login}/patrons/public.csv` + const csvResponse = await $fetch(csvUrl) + const rows: LiberapayRow[] = [] + await new Promise((resolve) => { + parseString(csvResponse, { + headers: true, + ignoreEmpty: true, + trim: true, + }) + .on('data', (row) => rows.push(row)) + .on('end', resolve) + }) + + // Only fetch exchange rates if we have patrons with non-USD currencies + const exchangeRates = rows.some(r => r.donation_currency !== 'USD') + ? await $fetch('https://www.floatrates.com/daily/usd.json') + : {} + + return rows.map(row => ({ + sponsor: { + type: 'User', + login: row.patron_username, + name: row.patron_public_name || row.patron_username, + avatarUrl: row.patron_avatar_url, + linkUrl: `https://liberapay.com/${row.patron_username}`, + }, + monthlyDollars: getMonthlyDollarAmount(parseFloat(row.weekly_amount), row.donation_currency, exchangeRates), + privacyLevel: 'PUBLIC', + createdAt: new Date(row.pledge_date).toISOString(), + provider: 'liberapay', + })) +} + +function getMonthlyDollarAmount(weeklyAmount: number, currency: string, exchangeRates: ExchangeRates): number { + const weeksPerMonth = 4.345 + const monthlyAmount = weeklyAmount * weeksPerMonth + + if (currency === 'USD') + return monthlyAmount + + // Optionally exchange to USD + const currencyLower = currency.toLowerCase() + const inverseRate = exchangeRates[currencyLower]?.inverseRate ?? 1 + return monthlyAmount * inverseRate +} diff --git a/src/types.ts b/src/types.ts index 3e40f71..c9d2c63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,7 +75,7 @@ export const outputFormats = ['svg', 'png', 'webp', 'json'] as const export type OutputFormat = typeof outputFormats[number] -export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' +export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' export type GitHubAccountType = 'user' | 'organization' @@ -200,6 +200,15 @@ export interface ProvidersConfig { */ organization?: string } + + liberapay?: { + /** + * The name of the Liberapay profile. + * + * Will read from `SPONSORKIT_LIBERAPAY_LOGIN` environment variable if not set. + */ + login?: string; +}; } export interface SponsorkitRenderOptions {