mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-09-01 14:57:14 +08:00
feat(route): patreon (#17621)
* feat(route): patreon * fix: typo * fix: typo
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user