512 lines
21 KiB
TypeScript
512 lines
21 KiB
TypeScript
import { parseDate } from '@/utils/parse-date';
|
||
import { fallback, queryToBoolean, queryToInteger } from '@/utils/readable-social';
|
||
|
||
const getQueryParams = (url) => Object.fromEntries(new URL(url).searchParams.entries());
|
||
const getOriginalImg = (url) => {
|
||
// https://greasyfork.org/zh-CN/scripts/2312-resize-image-on-open-image-in-new-tab/code#n150
|
||
let m: RegExpMatchArray | null;
|
||
if ((m = url.match(/^(https?:\/\/\w+\.twimg\.com\/media\/[^/:]+)\.(jpg|jpeg|gif|png|bmp|webp)(:\w+)?$/i))) {
|
||
let format = m[2];
|
||
if (m[2] === 'jpeg') {
|
||
format = 'jpg';
|
||
}
|
||
return `${m[1]}?format=${format}&name=orig`;
|
||
}
|
||
if ((m = url.match(/^(https?:\/\/\w+\.twimg\.com\/[^?]+)(\?.+)$/i))) {
|
||
const pars = getQueryParams(url);
|
||
if (!pars.format || !pars.name) {
|
||
return url;
|
||
}
|
||
if (pars.name === 'orig') {
|
||
return url;
|
||
}
|
||
return m[1] + '?format=' + pars.format + '&name=orig';
|
||
}
|
||
return url;
|
||
};
|
||
const replaceBreak = (text) => text.replaceAll(/<br><br>|<br>/g, ' ');
|
||
const quoteSeparator = "<hr style='border:0;border-top:1px solid #80808030;margin:12px 0;'>";
|
||
|
||
const formatText = (item) => {
|
||
let text = item.full_text;
|
||
const id_str = item.id_str || item.conversation_id_str;
|
||
const urls = item.entities.urls || [];
|
||
for (const url of urls) {
|
||
// trim link pointing to the tweet itself (usually appears when the tweet is truncated)
|
||
text = text.replaceAll(url.url, () => (url.expanded_url?.endsWith(id_str) ? '' : url.expanded_url));
|
||
}
|
||
const media = item.extended_entities?.media || [];
|
||
for (const m of media) {
|
||
text = text.replaceAll(m.url, '');
|
||
}
|
||
return text.trim().replaceAll('\n', '<br>');
|
||
};
|
||
|
||
const ProcessFeed = (ctx, { data = [] }, params = {}) => {
|
||
// undefined and strings like "exclude_rts_replies" is also safely parsed, so no if branch is needed
|
||
const routeParams = new URLSearchParams(ctx.req.param('routeParams'));
|
||
|
||
const mergedParams = {
|
||
readable: fallback(params.readable, queryToBoolean(routeParams.get('readable')), false),
|
||
authorNameBold: fallback(params.authorNameBold, queryToBoolean(routeParams.get('authorNameBold')), false),
|
||
showAuthorInTitle: fallback(params.showAuthorInTitle, queryToBoolean(routeParams.get('showAuthorInTitle')), false),
|
||
showAuthorAsTitleOnly: fallback(params.showAuthorAsTitleOnly, queryToBoolean(routeParams.get('showAuthorAsTitleOnly')), false),
|
||
showAuthorInDesc: fallback(params.showAuthorInDesc, queryToBoolean(routeParams.get('showAuthorInDesc')), false),
|
||
showQuotedAuthorAvatarInDesc: fallback(params.showQuotedAuthorAvatarInDesc, queryToBoolean(routeParams.get('showQuotedAuthorAvatarInDesc')), false),
|
||
showAuthorAvatarInDesc: fallback(params.showAuthorAvatarInDesc, queryToBoolean(routeParams.get('showAuthorAvatarInDesc')), false),
|
||
showEmojiForRetweetAndReply: fallback(params.showEmojiForRetweetAndReply, queryToBoolean(routeParams.get('showEmojiForRetweetAndReply')), false),
|
||
showSymbolForRetweetAndReply: fallback(params.showSymbolForRetweetAndReply, queryToBoolean(routeParams.get('showSymbolForRetweetAndReply')), true),
|
||
showRetweetTextInTitle: fallback(params.showRetweetTextInTitle, queryToBoolean(routeParams.get('showRetweetTextInTitle')), true),
|
||
addLinkForPics: fallback(params.addLinkForPics, queryToBoolean(routeParams.get('addLinkForPics')), false),
|
||
showTimestampInDescription: fallback(params.showTimestampInDescription, queryToBoolean(routeParams.get('showTimestampInDescription')), false),
|
||
showQuotedInTitle: fallback(params.showQuotedInTitle, queryToBoolean(routeParams.get('showQuotedInTitle')), false),
|
||
|
||
widthOfPics: fallback(params.widthOfPics, queryToInteger(routeParams.get('widthOfPics')), -1),
|
||
heightOfPics: fallback(params.heightOfPics, queryToInteger(routeParams.get('heightOfPics')), -1),
|
||
sizeOfAuthorAvatar: fallback(params.sizeOfAuthorAvatar, queryToInteger(routeParams.get('sizeOfAuthorAvatar')), 48),
|
||
sizeOfQuotedAuthorAvatar: fallback(params.sizeOfQuotedAuthorAvatar, queryToInteger(routeParams.get('sizeOfQuotedAuthorAvatar')), 24),
|
||
mediaNumber: fallback(params.mediaNumber, queryToInteger(routeParams.get('mediaNumber')), false),
|
||
showEmojiForSubscriberOnly: fallback(params.showEmojiForSubscriberOnly, queryToBoolean(routeParams.get('showEmojiForSubscriberOnly')), false),
|
||
showSymbolForSubscriberOnly: fallback(params.showSymbolForSubscriberOnly, queryToBoolean(routeParams.get('showSymbolForSubscriberOnly')), true),
|
||
showFullPrefixForSubscriberOnly: fallback(params.showFullPrefixForSubscriberOnly, queryToBoolean(routeParams.get('showFullPrefixForSubscriberOnly')), false),
|
||
};
|
||
|
||
params = mergedParams;
|
||
|
||
const {
|
||
readable,
|
||
authorNameBold,
|
||
showAuthorInTitle,
|
||
showAuthorAsTitleOnly,
|
||
showAuthorInDesc,
|
||
showQuotedAuthorAvatarInDesc,
|
||
showAuthorAvatarInDesc,
|
||
showEmojiForRetweetAndReply,
|
||
showSymbolForRetweetAndReply,
|
||
showRetweetTextInTitle,
|
||
addLinkForPics,
|
||
showTimestampInDescription,
|
||
showQuotedInTitle,
|
||
mediaNumber,
|
||
showEmojiForSubscriberOnly,
|
||
showSymbolForSubscriberOnly,
|
||
showFullPrefixForSubscriberOnly,
|
||
widthOfPics,
|
||
heightOfPics,
|
||
sizeOfAuthorAvatar,
|
||
sizeOfQuotedAuthorAvatar,
|
||
} = params;
|
||
|
||
const formatVideo = (media, extraAttrs = '') => {
|
||
let content = '';
|
||
let bestVideo = null;
|
||
|
||
for (const item of media.video_info.variants) {
|
||
if (!bestVideo || (item.bitrate || 0) > (bestVideo.bitrate || -Infinity)) {
|
||
bestVideo = item;
|
||
}
|
||
}
|
||
|
||
if (bestVideo && bestVideo.url) {
|
||
const gifAutoPlayAttr = media.type === 'animated_gif' ? 'autoplay loop muted webkit-playsinline playsinline' : '';
|
||
if (!readable) {
|
||
content += '<br>';
|
||
}
|
||
content += `<video width="${media.sizes.large.w}" height="${media.sizes.large.h}" src='${bestVideo.url}' ${gifAutoPlayAttr} controls='controls' poster='${getOriginalImg(media.media_url_https)}' ${extraAttrs}></video>`;
|
||
}
|
||
|
||
return content;
|
||
};
|
||
|
||
const formatMedia = (item) => {
|
||
let img = '';
|
||
if (item.extended_entities) {
|
||
const mediaCount = item.extended_entities.media.length;
|
||
let index = 1;
|
||
for (const media of item.extended_entities.media) {
|
||
// https://developer.x.com/en/docs/tweets/data-dictionary/overview/extended-entities-object
|
||
let content = '';
|
||
let style = '';
|
||
let originalImg;
|
||
switch (media.type) {
|
||
case 'animated_gif':
|
||
case 'video':
|
||
content = formatVideo(media);
|
||
break;
|
||
|
||
case 'photo':
|
||
default:
|
||
originalImg = getOriginalImg(media.media_url_https);
|
||
if (!readable) {
|
||
content += '<br>';
|
||
}
|
||
if (addLinkForPics) {
|
||
content += `<a href='${originalImg}' target='_blank' rel='noopener noreferrer'>`;
|
||
}
|
||
content += '<img ';
|
||
if (widthOfPics >= 0) {
|
||
content += ` width="${widthOfPics}"`;
|
||
style += `width: ${widthOfPics}px;`;
|
||
}
|
||
if (heightOfPics > 0) {
|
||
content += `height="${heightOfPics}" `;
|
||
style += `height: ${heightOfPics}px;`;
|
||
}
|
||
if (widthOfPics <= 0 && heightOfPics <= 0) {
|
||
content += `width="${media.sizes.large.w}" height="${media.sizes.large.h}" `;
|
||
}
|
||
content += ` style="${style}" ${readable ? 'hspace="4" vspace="8"' : ''} src="${originalImg}">`;
|
||
if (addLinkForPics) {
|
||
content += '</a>';
|
||
}
|
||
break;
|
||
}
|
||
|
||
img += content;
|
||
|
||
if (mediaNumber) {
|
||
img += `<p style="text-align:center">${index}/${mediaCount}</p>`;
|
||
index++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (readable && img) {
|
||
img = `<br clear='both' /><div style='clear: both'></div>` + img;
|
||
}
|
||
return img;
|
||
};
|
||
|
||
const generatePicsPrefix = (item) => {
|
||
// When author avatar is shown, generate invisible <img> for inner images at the beginning of HTML
|
||
// to please some RSS readers
|
||
let picsPrefix = '';
|
||
if (item.extended_entities) {
|
||
for (const media of item.extended_entities.media) {
|
||
let content;
|
||
let originalImg;
|
||
switch (media.type) {
|
||
case 'video':
|
||
content = formatVideo(media, 'width="0" height="0"');
|
||
break;
|
||
|
||
case 'photo':
|
||
default:
|
||
originalImg = getOriginalImg(media.media_url_https);
|
||
content = `<img width='0' height='0' hidden='true' src='${originalImg}'>`;
|
||
break;
|
||
}
|
||
|
||
picsPrefix += content;
|
||
}
|
||
}
|
||
return picsPrefix;
|
||
};
|
||
|
||
return data.map((item) => {
|
||
// Handle subscriber-only prefix based on user preference
|
||
if (item.full_text?.startsWith('[Subscribers Only]')) {
|
||
const text = item.full_text.replace('[Subscribers Only] ', '');
|
||
const prefix = showFullPrefixForSubscriberOnly ? '🔒 [Subscribers Only] ' : showEmojiForSubscriberOnly ? '🔒 ' : showSymbolForSubscriberOnly ? '[Subscribers Only] ' : '';
|
||
item.full_text = `${prefix}${text}`;
|
||
}
|
||
const originalItem = item;
|
||
item = item.retweeted_status || item;
|
||
item.full_text ||= item.text;
|
||
item.full_text = formatText(item);
|
||
const img = formatMedia(item);
|
||
let picsPrefix = generatePicsPrefix(item);
|
||
let quote = '';
|
||
let quoteInTitle = '';
|
||
|
||
// Make quote in description
|
||
if (item.is_quote_status) {
|
||
const quoteData = item.quoted_status;
|
||
|
||
if (quoteData?.user) {
|
||
quoteData.full_text ||= quoteData.text;
|
||
const author = quoteData.user;
|
||
if (!readable) {
|
||
quote += quoteSeparator;
|
||
}
|
||
quote += '<div class="rsshub-quote">';
|
||
if (readable) {
|
||
quote += `<br clear='both' /><div style='clear: both'></div>`;
|
||
quote += `<blockquote style='background: #80808010;border-top:1px solid #80808030;border-bottom:1px solid #80808030;margin:0;padding:5px 20px;'>`;
|
||
}
|
||
|
||
if (readable) {
|
||
quote += `<a href='https://x.com/${author.screen_name}' target='_blank' rel='noopener noreferrer'>`;
|
||
}
|
||
|
||
if (showQuotedAuthorAvatarInDesc) {
|
||
quote += `<img width='${sizeOfQuotedAuthorAvatar}' height='${sizeOfQuotedAuthorAvatar}' src='${author.profile_image_url_https}' ${readable ? 'hspace="8" vspace="8" align="left"' : ''}>`;
|
||
}
|
||
|
||
if (authorNameBold) {
|
||
quote += '<strong>';
|
||
}
|
||
|
||
quote += author.name;
|
||
|
||
if (authorNameBold) {
|
||
quote += '</strong>';
|
||
}
|
||
|
||
if (readable) {
|
||
quote += '</a>';
|
||
}
|
||
|
||
quote += ': ';
|
||
quote += formatText(quoteData);
|
||
|
||
if (!readable) {
|
||
quote += '<br>';
|
||
}
|
||
quote += formatMedia(quoteData);
|
||
picsPrefix += generatePicsPrefix(quoteData);
|
||
quoteInTitle += showEmojiForRetweetAndReply ? ' 💬 ' : showSymbolForRetweetAndReply ? ' RT ' : '';
|
||
quoteInTitle += `${author.name}: ${formatText(quoteData)}`;
|
||
|
||
if (readable) {
|
||
quote += `<br><small>Link: <a href='https://x.com/${author.screen_name}/status/${quoteData.id_str || quoteData.conversation_id_str}' target='_blank' rel='noopener noreferrer'>https://x.com/${
|
||
author.screen_name
|
||
}/status/${quoteData.id_str || quoteData.conversation_id_str}</a></small>`;
|
||
}
|
||
if (showTimestampInDescription) {
|
||
quote += '<br><small>' + parseDate(quoteData.created_at);
|
||
quote += '</small>';
|
||
if (readable) {
|
||
quote += `<br clear='both' /><div style='clear: both'></div>`;
|
||
}
|
||
}
|
||
|
||
if (readable) {
|
||
quote += '</blockquote>';
|
||
}
|
||
quote += '</div>';
|
||
}
|
||
}
|
||
|
||
// Make title
|
||
let title = '';
|
||
if (showAuthorInTitle) {
|
||
title += originalItem.user?.name + ': ';
|
||
}
|
||
const isRetweet = originalItem !== item;
|
||
const isQuote = item.is_quote_status;
|
||
if (!isRetweet && (!isQuote || showRetweetTextInTitle)) {
|
||
if (item.in_reply_to_screen_name) {
|
||
title += showEmojiForRetweetAndReply ? '↩️ ' : showSymbolForRetweetAndReply ? 'Re ' : '';
|
||
}
|
||
title += replaceBreak(originalItem.full_text);
|
||
}
|
||
if (isRetweet) {
|
||
title += showEmojiForRetweetAndReply ? '🔁 ' : showSymbolForRetweetAndReply ? 'RT ' : '';
|
||
title += item.user.name + ': ';
|
||
if (item.in_reply_to_screen_name) {
|
||
title += showEmojiForRetweetAndReply ? ' ↩️ ' : showSymbolForRetweetAndReply ? ' Re ' : '';
|
||
}
|
||
title += replaceBreak(item.full_text);
|
||
}
|
||
|
||
if (showQuotedInTitle) {
|
||
title += quoteInTitle;
|
||
}
|
||
|
||
if (showAuthorAsTitleOnly) {
|
||
title = originalItem.user?.name;
|
||
}
|
||
|
||
// Make description
|
||
let description = '';
|
||
if (showAuthorInDesc && showAuthorAvatarInDesc) {
|
||
description += picsPrefix;
|
||
}
|
||
if (isRetweet) {
|
||
if (showAuthorInDesc) {
|
||
if (readable) {
|
||
description += '<small>';
|
||
description += `<a href='https://x.com/${originalItem.user?.screen_name}' target='_blank' rel='noopener noreferrer'>`;
|
||
}
|
||
if (authorNameBold) {
|
||
description += '<strong>';
|
||
}
|
||
description += originalItem.user?.name;
|
||
if (authorNameBold) {
|
||
description += '</strong>';
|
||
}
|
||
if (readable) {
|
||
description += '</a>';
|
||
}
|
||
description += ' ';
|
||
}
|
||
description += showEmojiForRetweetAndReply ? '🔁' : showSymbolForRetweetAndReply ? 'RT' : '';
|
||
if (!showAuthorInDesc) {
|
||
description += ' ';
|
||
if (readable) {
|
||
description += `<a href='https://x.com/${item.user?.screen_name}' target='_blank' rel='noopener noreferrer'>`;
|
||
}
|
||
if (authorNameBold) {
|
||
description += '<strong>';
|
||
}
|
||
description += item.user?.name;
|
||
if (authorNameBold) {
|
||
description += '</strong>';
|
||
}
|
||
if (readable) {
|
||
description += '</a>';
|
||
}
|
||
}
|
||
if (readable) {
|
||
description += '</small>';
|
||
}
|
||
description += '<br>';
|
||
}
|
||
if (showAuthorInDesc) {
|
||
if (readable) {
|
||
description += `<a href='https://x.com/${item.user?.screen_name}' target='_blank' rel='noopener noreferrer'>`;
|
||
}
|
||
|
||
if (showAuthorAvatarInDesc) {
|
||
description += `<img width='${sizeOfAuthorAvatar}' height='${sizeOfAuthorAvatar}' src='${item.user.profile_image_url_https}' ${readable ? 'hspace="8" vspace="8" align="left"' : ''}>`;
|
||
}
|
||
if (authorNameBold) {
|
||
description += '<strong>';
|
||
}
|
||
description += item.user?.name;
|
||
if (authorNameBold) {
|
||
description += '</strong>';
|
||
}
|
||
if (readable) {
|
||
description += '</a>';
|
||
}
|
||
description += ': ';
|
||
}
|
||
if (item.in_reply_to_screen_name) {
|
||
description += showEmojiForRetweetAndReply ? '↩️ ' : showSymbolForRetweetAndReply ? 'Re ' : '';
|
||
}
|
||
|
||
description += item.full_text;
|
||
// 从 description 提取 话题作为 category,放在此处是为了避免 匹配到 quote 中的 # 80808030 颜色字符
|
||
const category = description.match(/(\s)?(#[^\s;<]+)/g)?.map((e) => e?.match(/#([^\s<]+)/)?.[1]);
|
||
description += img;
|
||
description += quote;
|
||
if (readable) {
|
||
description += `<br clear='both' /><div style='clear: both'></div>`;
|
||
}
|
||
|
||
if (showTimestampInDescription) {
|
||
if (readable) {
|
||
description += '<hr>';
|
||
}
|
||
description += `<small>${parseDate(item.created_at)}</small>`;
|
||
}
|
||
|
||
const link =
|
||
originalItem.user?.screen_name && (originalItem.id_str || originalItem.conversation_id_str)
|
||
? `https://x.com/${originalItem.user?.screen_name}/status/${originalItem.id_str || originalItem.conversation_id_str}`
|
||
: `https://x.com/${item.user?.screen_name}/status/${item.id_str || item.conversation_id_str}`;
|
||
return {
|
||
title,
|
||
author: [
|
||
{
|
||
name: originalItem.user?.name,
|
||
url: `https://x.com/${originalItem.user?.screen_name}`,
|
||
avatar: originalItem.user?.profile_image_url_https,
|
||
},
|
||
],
|
||
description,
|
||
pubDate: parseDate(item.created_at),
|
||
link,
|
||
guid: link.replace('x.com', 'twitter.com'),
|
||
category,
|
||
_extra:
|
||
(isRetweet && {
|
||
links: [
|
||
{
|
||
url: `https://x.com/${item.user?.screen_name}/status/${item.conversation_id_str}`,
|
||
type: 'repost',
|
||
},
|
||
],
|
||
}) ||
|
||
(item.is_quote_status &&
|
||
item.quoted_status?.user && {
|
||
links: [
|
||
{
|
||
url: `https://x.com/${item.quoted_status?.user?.screen_name}/status/${item.quoted_status?.id_str || item.quoted_status?.conversation_id_str}`,
|
||
type: 'quote',
|
||
},
|
||
],
|
||
}) ||
|
||
(item.in_reply_to_screen_name &&
|
||
item.in_reply_to_status_id_str && {
|
||
links: [
|
||
{
|
||
url: `https://x.com/${item.in_reply_to_screen_name}/status/${item.in_reply_to_status_id_str}`,
|
||
type: 'reply',
|
||
},
|
||
],
|
||
}),
|
||
};
|
||
});
|
||
};
|
||
|
||
const parseRouteParams = (routeParams) => {
|
||
let count, include_replies, include_rts, only_media;
|
||
let force_web_api = false;
|
||
switch (routeParams) {
|
||
case 'exclude_rts_replies':
|
||
case 'exclude_replies_rts':
|
||
include_replies = false;
|
||
include_rts = false;
|
||
|
||
break;
|
||
|
||
case 'include_replies':
|
||
include_replies = true;
|
||
include_rts = true;
|
||
|
||
break;
|
||
|
||
case 'exclude_rts':
|
||
include_replies = false;
|
||
include_rts = false;
|
||
|
||
break;
|
||
|
||
default: {
|
||
const parsed = new URLSearchParams(routeParams);
|
||
count = fallback(undefined, queryToInteger(parsed.get('count')));
|
||
include_replies = fallback(undefined, queryToBoolean(parsed.get('includeReplies')), false);
|
||
include_rts = fallback(undefined, queryToBoolean(parsed.get('includeRts')), true);
|
||
force_web_api = fallback(undefined, queryToBoolean(parsed.get('forceWebApi')), false);
|
||
only_media = fallback(undefined, queryToBoolean(parsed.get('onlyMedia')), false);
|
||
}
|
||
}
|
||
return { count, include_replies, include_rts, force_web_api, only_media };
|
||
};
|
||
|
||
export const excludeRetweet = function (tweets) {
|
||
const excluded = [];
|
||
for (const t of tweets) {
|
||
if (t.retweeted_status) {
|
||
continue;
|
||
}
|
||
excluded.push(t);
|
||
}
|
||
return excluded;
|
||
};
|
||
|
||
export const keepOnlyMedia = function (tweets) {
|
||
const excluded = tweets.filter((t) => t.extended_entities && t.extended_entities.media);
|
||
return excluded;
|
||
};
|
||
|
||
export default {
|
||
ProcessFeed,
|
||
parseRouteParams,
|
||
excludeRetweet,
|
||
keepOnlyMedia,
|
||
};
|