diff --git a/lib/config.ts b/lib/config.ts index 86637e3dc..9227906df 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -252,6 +252,9 @@ export type Config = { notion: { key?: string; }; + patreon: { + sessionId?: string; + }; pianyuan: { cookie?: string; }; @@ -670,6 +673,9 @@ const calculateValue = () => { notion: { key: envs.NOTION_TOKEN, }, + patreon: { + sessionId: envs.PATREON_SESSION_ID, + }, pianyuan: { cookie: envs.PIANYUAN_COOKIE, }, diff --git a/lib/routes/patreon/feed.ts b/lib/routes/patreon/feed.ts new file mode 100644 index 000000000..1cf45c833 --- /dev/null +++ b/lib/routes/patreon/feed.ts @@ -0,0 +1,126 @@ +import { Route } from '@/types'; +import { CreatorData, MediaRelation, PostData } from './types'; + +import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; +import * as cheerio from 'cheerio'; +import { parseDate } from '@/utils/parse-date'; +import path from 'node:path'; +import { getCurrentPath } from '@/utils/helpers'; +import { art } from '@/utils/render'; +import { config } from '@/config'; + +const __dirname = getCurrentPath(import.meta.url); + +export const route: Route = { + path: '/:creator', + categories: ['new-media'], + example: '/patreon/straightupsisters', + parameters: { creator: 'Patreon creator id, can be found in the url' }, + features: { + requireConfig: [ + { + name: 'PATREON_SESSION_ID', + optional: true, + description: 'The value of the session_id cookie after logging in to Patreon, required to access paid posts', + }, + ], + }, + radar: [ + { + source: ['patreon.com/:creator'], + }, + ], + name: 'Home', + maintainers: ['TonyRL'], + handler, +}; + +async function handler(ctx) { + const { creator } = ctx.req.param(); + + const baseUrl = 'https://www.patreon.com'; + const link = `${baseUrl}/${creator}`; + + const creatorData = (await cache.tryGet(`patreon:creator:${creator}`, async () => { + const response = await ofetch(link); + + const $ = cheerio.load(response); + const nextData = JSON.parse($('#__NEXT_DATA__').text()); + const bootstrapEnvelope = nextData.props.pageProps.bootstrapEnvelope; + + return { + meta: bootstrapEnvelope.meta, + id: bootstrapEnvelope.pageBootstrap.campaign.data.id, + attributes: bootstrapEnvelope.pageBootstrap.campaign.data.attributes, + }; + })) as CreatorData; + + if (!creatorData.id) { + throw new Error('Creator not found'); + } + + let headers = {}; + if (config.patreon?.sessionId) { + headers = { + Cookie: `session_id=${config.patreon.sessionId}`, + }; + } + + const posts = await ofetch('https://www.patreon.com/api/posts', { + headers, + query: { + include: + 'campaign,access_rules,access_rules.tier.null,attachments_media,audio,audio_preview.null,drop,images,media,native_video_insights,poll.choices,poll.current_user_responses.user,poll.current_user_responses.choice,poll.current_user_responses.poll,user,user_defined_tags,ti_checks,video.null,content_unlock_options.product_variant.null', + 'fields[campaign]': 'currency,show_audio_post_download_links,avatar_photo_url,avatar_photo_image_urls,earnings_visibility,is_nsfw,is_monthly,name,url', + 'fields[post]': + 'change_visibility_at,comment_count,commenter_count,content,created_at,current_user_can_comment,current_user_can_delete,current_user_can_report,current_user_can_view,current_user_comment_disallowed_reason,current_user_has_liked,embed,image,insights_last_updated_at,is_paid,like_count,meta_image_url,min_cents_pledged_to_view,monetization_ineligibility_reason,post_file,post_metadata,published_at,patreon_url,post_type,pledge_url,preview_asset_type,thumbnail,thumbnail_url,teaser_text,title,upgrade_url,url,was_posted_by_campaign_owner,has_ti_violation,moderation_status,post_level_suspension_removal_date,pls_one_liners_by_category,video,video_preview,view_count,content_unlock_options,is_new_to_current_user,watch_state', + 'fields[post_tag]': 'tag_type,value', + 'fields[user]': 'image_url,full_name,url', + 'fields[access_rule]': 'access_rule_type,amount_cents', + 'fields[media]': 'id,image_urls,display,download_url,metadata,file_name', + 'fields[native_video_insights]': 'average_view_duration,average_view_pct,has_preview,id,last_updated_at,num_views,preview_views,video_duration', + 'fields[content-unlock-option]': 'content_unlock_type', + 'fields[product-variant]': 'price_cents,currency_code,checkout_url,is_hidden,published_at_datetime,content_type,orders_count,access_metadata', + 'filter[campaign_id]': creatorData.id, + 'filter[contains_exclusive_posts]': true, + 'filter[is_draft]': false, + sort: '-published_at', + 'json-api-use-default-includes': false, + 'json-api-version': '1.0', + }, + }); + + const items = posts.data.map(({ attributes, relationships }) => { + for (const [key, value] of Object.entries(relationships)) { + if (value.data) { + relationships[key] = Array.isArray(value.data) ? value.data.map((item) => posts.included.find((i) => i.id === item.id)) : posts.included.find((i) => i.id === value.data.id); + } + } + if (attributes.video_preview) { + relationships.video_preview = posts.included.find((i) => Number.parseInt(i.id) === attributes.video_preview.media_id) as unknown as MediaRelation; + } + + return { + title: attributes.title, + description: art(path.join(__dirname, 'templates/description.art'), { + attributes, + relationships, + included: posts.included, + }), + link: attributes.url, + pubDate: parseDate(attributes.published_at), + image: attributes.thumbnail?.url ?? attributes.image.url, + category: relationships.user_defined_tags?.map((tag) => tag.attributes.value), + }; + }); + + return { + title: creatorData.meta.title, + description: creatorData.meta.desc, + link, + image: creatorData.attributes.avatar_photo_url, + item: items, + allowEmpty: true, + }; +} diff --git a/lib/routes/patreon/namespace.ts b/lib/routes/patreon/namespace.ts new file mode 100644 index 000000000..d6f8fece4 --- /dev/null +++ b/lib/routes/patreon/namespace.ts @@ -0,0 +1,7 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Patreon', + url: 'www.patreon.com', + lang: 'en', +}; diff --git a/lib/routes/patreon/templates/description.art b/lib/routes/patreon/templates/description.art new file mode 100644 index 000000000..8e6948123 --- /dev/null +++ b/lib/routes/patreon/templates/description.art @@ -0,0 +1,39 @@ +{{ if attributes.post_type === 'image_file' }} + {{ each attributes.post_metadata.image_order mediaIdStr }} + {{ set img = included.find((i) => i.id === mediaIdStr) }} + {{ if img }} + {{ img.attributes.file_name }}
+ {{ /if }} + {{ /each }} + +{{ else if attributes.post_type === 'video_external_file' }} + {{ if attributes.video_preview }} +
+ {{ /if }} + +{{ else if attributes.post_type === 'audio_file' || attributes.post_type === 'podcast' }} +
+ {{ set url = relationships.audio.attributes.download_url || relationships.audio_preview.attributes.download_url }} +
+ +{{ else if attributes.post_type === 'video_embed' || attributes.post_type === 'link' }} +
+ +{{ else }} +Post type: "{{ attributes.post_type }}" is not supported.
+ +{{ /if }} + +{{ if attributes.content || attributes.teaser_text }} + {{@ attributes.content || attributes.teaser_text }} +{{ /if }} + +{{ if relationships.attachments_media }} + {{ each relationships.attachments_media media }} + {{ media.attributes.file_name }}
+ {{ /each }} +{{ /if }} diff --git a/lib/routes/patreon/types.ts b/lib/routes/patreon/types.ts new file mode 100644 index 000000000..bf87cc623 --- /dev/null +++ b/lib/routes/patreon/types.ts @@ -0,0 +1,387 @@ +export interface CreatorData { + meta: { + desc: string; + height: null; + imageUrl: string; + isPrivate: boolean; + key: string; + openGraph: OpenGraph; + title: string; + url: string; + videoHeight: null; + videoUrl: null; + videoWidth: null; + viewport: string; + }; + id: string; + attributes: IncludedAttributes; +} + +interface OpenGraph { + desc: null; + imageUrl: null; + title: null; +} + +export interface PostData { + data: Datum[]; + included: IncludedItem[]; + links: Links; + meta: Meta; +} + +interface Datum { + attributes: Attributes; + id: string; + relationships: Relationships; + type: string; +} + +interface Attributes { + change_visibility_at: null; + comment_count: number; + commenter_count: number; + created_at: string; + current_user_can_comment: boolean; + current_user_can_delete: boolean; + current_user_can_report: boolean; + current_user_can_view: boolean; + current_user_comment_disallowed_reason: string; + has_ti_violation: boolean; + image: Image; + is_new_to_current_user: boolean; + is_paid: boolean; + like_count: number; + meta_image_url: string; + min_cents_pledged_to_view: number | null; + moderation_status: string; + patreon_url: string; + pledge_url: string; + pls_one_liners_by_category: any[]; // Items are `false`, so an empty array + post_level_suspension_removal_date: null; + post_metadata: PostMetadata; + post_type: string; + preview_asset_type: string | null; + published_at: string; + teaser_text: string | null; + title: string; + upgrade_url: string; + url: string; + video_preview: VideoPreview | null; + was_posted_by_campaign_owner: boolean; + thumbnail?: Thumbnail; + content?: string; + embed?: null; + post_file?: PostFile; +} + +interface Image { + height: number; + url: string; + width: number; + large_url?: string; + thumb_square_large_url?: string; + thumb_square_url?: string; + thumb_url?: string; +} + +interface PostMetadata { + image_order?: string[]; + platform?: object; +} + +interface VideoPreview { + closed_captions_enabled: boolean; + default_thumbnail: DefaultThumbnail; + duration: number; + expires_at: string; + full_content_duration: number; + full_duration: number; + height: number; + media_id: number; + progress: Progress; + state: string; + url: string; + video_issues: object; + width: number; +} + +interface DefaultThumbnail { + url: string; +} + +interface Progress { + is_watched: boolean; + watch_state: string; +} + +interface PostFile { + height: number; + image_colors: ImageColors; + media_id: number; + state: string; + url: string; + width: number; +} + +interface ImageColors { + average_colors_of_corners: AverageColorsOfCorners; + dominant_color: string; + palette: string[]; + text_color: string; +} + +interface AverageColorsOfCorners { + bottom_left: string; + bottom_right: string; + top_left: string; + top_right: string; +} + +interface Thumbnail { + url: string; + large?: string; + large_2?: string; + square?: string; + gif_url?: string; + height?: number; + width?: number; +} + +interface Relationships { + access_rules: AccessRules; + audio: MediaRelation; + audio_preview: MediaRelation; + campaign: CampaignRelation; + content_unlock_options: ContentUnlockOptions; + drop: Drop; + images: Images; + media: Media; + poll: Poll; + user: UserRelation; + user_defined_tags: UserDefinedTags; + video: MediaRelation; + attachments_media?: AttachmentsMedia; + // Custom relationships + video_preview?: MediaRelation; +} + +interface AccessRules { + data: AccessRuleData[]; +} + +interface AccessRuleData { + id: string; + type: string; +} + +export interface MediaRelation { + data: MediaData | null; + links?: RelatedLink; +} + +interface MediaData { + id: string; + type: string; +} + +interface CampaignRelation { + data: CampaignData; + links: RelatedLink; +} + +interface CampaignData { + id: string; + type: string; +} + +interface RelatedLink { + related: string; +} + +interface ContentUnlockOptions { + data: any[]; // Empty array +} + +interface Drop { + data: null; +} + +interface Images { + data: MediaData[]; +} + +interface Media { + data: MediaData[]; +} + +interface Poll { + data: null; +} + +interface UserRelation { + data: UserData; + links: RelatedLink; +} + +interface UserData { + id: string; + type: string; +} + +interface UserDefinedTags { + data: UserDefinedTagData[]; + attributes: IncludedAttributes; +} + +interface UserDefinedTagData { + id: string; + type: string; +} + +interface AttachmentsMedia { + data: AttachmentMediaData[]; +} + +interface AttachmentMediaData { + id: string; + type: string; +} + +interface IncludedItem { + attributes: IncludedAttributes; + id: string; + type: string; + relationships?: IncludedRelationships; +} + +interface IncludedAttributes { + // Depending on the `type`, attributes vary + // For example, if `type` is 'user', attributes include: + full_name?: string; + image_url?: string | null; + avatar_photo_image_urls?: Image; + avatar_photo_url?: string; + currency?: string; + earnings_visibility?: string; + is_monthly?: boolean; + is_nsfw?: boolean; + name?: string; + show_audio_post_download_links?: boolean; + url?: string; + // Additional properties for other types + // For 'post_tag' type + tag_type?: string; + value?: string; + // For 'display' + display?: Display; + // For 'file' + file_name?: string | null; + image_urls?: Image | null; + metadata?: Metadata; + download_url?: string; + // For 'tier' + access_rule_type?: string; + amount_cents?: number | null; + post_count?: number; + amount?: number; + created_at?: string; + declined_patron_count?: number; + description?: string; + discord_role_ids?: null; + edited_at?: string; + is_free_tier?: boolean; + patron_amount_cents?: number; + patron_currency?: string; + published?: boolean; + published_at?: string; + remaining?: null; + requires_shipping?: boolean; + title?: string; + unpublished_at?: null; +} + +interface Display { + default_thumbnail?: DefaultThumbnail & { position?: number }; + url?: string; + closed_captions_enabled?: boolean; + expires_at?: string; + height?: number; + video_issues?: VideoIssues; + width?: number; + duration?: number; + full_content_duration?: number; + media_id?: number; + progress?: Progress; + state?: string; + image_colors?: ImageColors; +} + +interface VideoIssues { + processing_warning?: { + video_bitrate?: string; + video_codec?: string; + video_resolution?: string; + }; +} + +interface Image { + thumbnail: string; + url: string; + default?: string; + default_blurred?: string; + default_blurred_small?: string; + default_small?: string; + original?: string; + thumbnail_large?: string; + thumbnail_small?: string; +} + +interface Metadata { + audio_preview_duration?: number; + audio_preview_start_time?: number; + video_preview_end_ms?: number | null; + video_preview_start_ms?: number | null; + duration?: number; + start_position?: number; + duration_s?: number; + orientation?: string; + variant?: string; + dimensions?: Dimensions; +} + +interface Dimensions { + h: number; + w: number; +} + +interface IncludedRelationships { + tier?: TierRelation; +} + +interface TierRelation { + data: TierData | null; + links?: RelatedLink; +} + +interface TierData { + id: string; + type: string; +} + +interface Links { + next: string; +} + +interface Meta { + pagination: Pagination; +} + +interface Pagination { + cursors: Cursors; + total: number; +} + +interface Cursors { + next: string; +}