fix(route/patreon): creator ID extraction and render content from JSON (#22188)

* fix(route/patreon): Fix creator ID extraction

* fix(route/patreon): render content from content_json_string

* fix(route/patreon): satisfy oxlint and use trueUA for creator fetches

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(route/patreon): resolve review comments

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
an-lee 2026-06-06 12:54:26 +08:00 committed by GitHub
parent 6c52888af2
commit cae03809c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 108 additions and 5 deletions

View File

@ -8,6 +8,7 @@ import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { renderContentJson } from './render-content-json';
import type { CreatorData, MediaRelation, PostData } from './types';
const renderDescription = ({ attributes, relationships, included }) => {
@ -16,6 +17,7 @@ const renderDescription = ({ attributes, relationships, included }) => {
const previewImage = attributes.image?.url ?? attributes.meta_image_url;
const audioUrl = relationships.audio?.attributes?.download_url || relationships.audio_preview?.attributes?.download_url;
const imageItems = imageOrder.map((mediaIdStr) => included.find((item) => item.id === mediaIdStr)).filter(Boolean);
const textContent = renderContentJson(attributes.content_json_string) || renderContentJson(attributes.teaser_text_json_string);
return renderToString(
<>
@ -68,7 +70,7 @@ const renderDescription = ({ attributes, relationships, included }) => {
</>
)}
{attributes.content || attributes.teaser_text ? raw(attributes.content || attributes.teaser_text) : null}
{textContent ? raw(textContent) : null}
{relationships.attachments_media?.length
? relationships.attachments_media.map((media) => (
@ -121,7 +123,7 @@ async function handler(ctx) {
const ogUrl = $('meta[property="og:url"]').attr('content');
if (ogUrl?.startsWith(`${baseUrl}/cw/`)) {
const ogImage = $('meta[property="og:image"]').attr('content');
const creatorId = decodeURIComponent(ogImage || '').match(/card-teaser-image\/creator\/(\d+)\?/)?.[1];
const creatorId = decodeURIComponent(ogImage || '').match(/card-teaser-image\/creator\/(\d+)/)?.[1];
if (creatorId) {
const creator = await ofetch(`${baseUrl}/api/campaigns/${creatorId}`);
return {
@ -159,7 +161,7 @@ async function handler(ctx) {
'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',
'change_visibility_at,comment_count,commenter_count,content_json_string,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_json_string,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',

View File

@ -0,0 +1,101 @@
import { renderToString } from 'hono/jsx/dom/server';
interface ContentNode {
type: string;
attrs?: Record<string, unknown>;
content?: ContentNode[];
text?: string;
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
}
const TextNode = ({ node }: { node: ContentNode }) => {
let content: JSX.Element | string = node.text ?? '';
for (const mark of node.marks ?? []) {
if (mark.type === 'bold') {
content = <strong>{content}</strong>;
} else if (mark.type === 'link' && mark.attrs?.href) {
content = <a href={String(mark.attrs.href)}>{content}</a>;
}
}
return <>{content}</>;
};
const ContentNode = ({ node }: { node: ContentNode }) => {
switch (node.type) {
case 'doc':
return (
<>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</>
);
case 'paragraph':
return (
<p>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</p>
);
case 'text':
return <TextNode node={node} />;
case 'hardBreak':
return <br />;
case 'heading': {
const level = Math.min(6, Math.max(1, Number(node.attrs?.level) || 3));
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Tag>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</Tag>
);
}
case 'image':
return node.attrs?.src ? <img src={String(node.attrs.src)} alt={String(node.attrs.alt ?? '')} /> : null;
case 'bulletList':
return (
<ul>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</ul>
);
case 'orderedList':
return (
<ol>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</ol>
);
case 'listItem':
return (
<li>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</li>
);
case 'horizontalRule':
return <hr />;
default:
return (
<>
{node.content?.map((child, index) => (
<ContentNode key={index} node={child} />
))}
</>
);
}
};
export const renderContentJson = (jsonString?: string | null): string => {
if (!jsonString) {
return '';
}
const doc = JSON.parse(jsonString) as ContentNode;
return renderToString(<ContentNode node={doc} />);
};

View File

@ -63,14 +63,14 @@ interface Attributes {
post_type: string;
preview_asset_type: string | null;
published_at: string;
teaser_text: string | null;
teaser_text_json_string?: string | null;
title: string;
upgrade_url: string;
url: string;
video_preview: VideoPreview | null;
was_posted_by_campaign_owner: boolean;
thumbnail?: Thumbnail;
content?: string;
content_json_string?: string | null;
embed?: null;
post_file?: PostFile;
}