feat(route): patreon (#17621)

* feat(route): patreon

* fix: typo

* fix: typo
This commit is contained in:
Tony
2024-11-18 10:21:24 -08:00
committed by GitHub
parent 437e7ca30b
commit d9ca0d09b1
5 changed files with 565 additions and 0 deletions
+6
View File
@@ -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,
},
+126
View File
@@ -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<PostData>('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,
};
}
+7
View File
@@ -0,0 +1,7 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'Patreon',
url: 'www.patreon.com',
lang: 'en',
};
@@ -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 src="{{ img.attributes.image_urls.original }}" alt="{{ img.attributes.file_name }}"><br>
{{ /if }}
{{ /each }}
{{ else if attributes.post_type === 'video_external_file' }}
{{ if attributes.video_preview }}
<video controls preload="metadata" poster="{{ attributes.image.url }}">
<source src="{{ relationships.video_preview.attributes.download_url }}" type="video/mp4">
</video><br>
{{ /if }}
{{ else if attributes.post_type === 'audio_file' || attributes.post_type === 'podcast' }}
<img src="{{ attributes.thumbnail.url }}"><br>
{{ set url = relationships.audio.attributes.download_url || relationships.audio_preview.attributes.download_url }}
<audio controls preload="metadata">
<source src="{{ url }}" type="audio/mpeg">
</audio><br>
{{ else if attributes.post_type === 'video_embed' || attributes.post_type === 'link' }}
<img src="{{ attributes.image.url }}"><br>
{{ else }}
Post type: "{{ attributes.post_type }}" is not supported. <br>
{{ /if }}
{{ if attributes.content || attributes.teaser_text }}
{{@ attributes.content || attributes.teaser_text }}
{{ /if }}
{{ if relationships.attachments_media }}
{{ each relationships.attachments_media media }}
<a href="{{ media.attributes.download_url }}">{{ media.attributes.file_name }}</a><br>
{{ /each }}
{{ /if }}
+387
View File
@@ -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;
}