const URL = require('url'); const config = require('@/config').value; const { TwitterApi } = require('twitter-api-v2'); const { fallback, queryToBoolean, queryToInteger } = require('@/utils/readable-social'); const { parseDate } = require('@/utils/parse-date'); const getQueryParams = (url) => URL.parse(url, true).query; const getOriginalImg = (url) => { // https://greasyfork.org/zh-CN/scripts/2312-resize-image-on-open-image-in-new-tab/code#n150 let m = 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`; } else 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'; } else { return url; } }; const replaceBreak = (text) => text.replace(/

|
/g, ' '); const formatText = (item) => { let text = item.full_text; const urls = item.entities.urls || []; urls.forEach((url) => { text = text.replaceAll(url.url, url.expanded_url); }); const media = item.extended_entities?.media || []; media.forEach((m) => { text = text.replaceAll(m.url, ''); }); return text.trim().replace(/\n/g, '
'); }; 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.params.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), 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), }; params = mergedParams; const { readable, authorNameBold, showAuthorInTitle, showAuthorInDesc, showQuotedAuthorAvatarInDesc, showAuthorAvatarInDesc, showEmojiForRetweetAndReply, showSymbolForRetweetAndReply, showRetweetTextInTitle, addLinkForPics, showTimestampInDescription, showQuotedInTitle, widthOfPics, heightOfPics, sizeOfAuthorAvatar, sizeOfQuotedAuthorAvatar, } = params; const formatVideo = (media, extraAttrs = '') => { let content = ''; const video = media.video_info.variants.reduce((video, item) => { if ((item.bitrate || 0) > (video.bitrate || -Infinity)) { video = item; } return video; }, {}); if (video.url) { const gifAutoPlayAttr = media.type === 'animated_gif' ? `autoplay loop muted webkit-playsinline playsinline` : ''; if (!readable) { content += '
'; } content += ``; } return content; }; const formatMedia = (item) => { let img = ''; item.extended_entities && item.extended_entities.media.forEach((item) => { // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/extended-entities-object let content = ''; let style = ''; let originalImg; switch (item.type) { case 'animated_gif': case 'video': content = formatVideo(item); break; case 'photo': default: originalImg = getOriginalImg(item.media_url_https); if (!readable) { content += `
`; } if (addLinkForPics) { content += ``; } content += `= 0) { content += ` width="${widthOfPics}"`; style += `width: ${widthOfPics}px;`; } if (heightOfPics > 0) { content += `height="${heightOfPics}" `; style += `height: ${heightOfPics}px;`; } content += ` style="${style}" ` + `${readable ? 'hspace="4" vspace="8"' : ''} src="${originalImg}">`; if (addLinkForPics) { content += ``; } break; } img += content; }); if (readable && img) { img = `
` + img; } return img; }; const generatePicsPrefix = (item) => { // When author avatar is shown, generate invisible for inner images at the beginning of HTML // to please some RSS readers let picsPrefix = ''; item.extended_entities && item.extended_entities.media.forEach((item) => { let content; let originalImg; switch (item.type) { case 'video': content = formatVideo(item, `width="0" height="0"`); break; case 'photo': default: originalImg = getOriginalImg(item.media_url_https); content = ``; break; } picsPrefix += content; }); return picsPrefix; }; return data.map((item) => { const originalItem = item; item = item.retweeted_status || item; item.full_text = 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) { quoteData.full_text = quoteData.full_text || quoteData.text; const author = quoteData.user; quote += '
'; if (readable) { quote += `
`; quote += `
`; } else { quote += `

`; } if (readable) { quote += ``; } if (showQuotedAuthorAvatarInDesc) { quote += ``; } if (authorNameBold) { quote += ``; } quote += author.name; if (authorNameBold) { quote += ``; } if (readable) { quote += ``; } quote += `: `; quote += formatText(quoteData); if (!readable) { quote += '
'; } quote += formatMedia(quoteData); picsPrefix += generatePicsPrefix(quoteData); quoteInTitle += showEmojiForRetweetAndReply ? ' đŸ’Ŧ ' : showSymbolForRetweetAndReply ? ' RT ' : ''; quoteInTitle += `${author.name}: ${formatText(quoteData)}`; if (readable) { quote += `
Link: https://twitter.com/${author.screen_name}/status/${quoteData.id_str}`; } if (showTimestampInDescription) { quote += '
' + parseDate(quoteData.created_at); quote += ``; if (readable) { quote += `
`; } } if (readable) { quote += `
`; } quote += '
'; } } // 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; } // Make description let description = ''; if (showAuthorInDesc && showAuthorAvatarInDesc) { description += picsPrefix; } if (isRetweet) { if (showAuthorInDesc) { if (readable) { description += ''; description += ``; } if (authorNameBold) { description += ``; } description += originalItem.user.name; if (authorNameBold) { description += ``; } if (readable) { description += ''; } description += ' '; } description += showEmojiForRetweetAndReply ? '🔁' : showSymbolForRetweetAndReply ? 'RT' : ''; if (!showAuthorInDesc) { description += ' '; if (readable) { description += ``; } if (authorNameBold) { description += ``; } description += item.user.name; if (authorNameBold) { description += ``; } if (readable) { description += ''; } } if (readable) { description += ''; } description += '
'; } if (showAuthorInDesc) { if (readable) { description += ``; } if (showAuthorAvatarInDesc) { description += ``; } if (authorNameBold) { description += ``; } description += item.user.name; if (authorNameBold) { description += ``; } if (readable) { description += ``; } description += `: `; } if (item.in_reply_to_screen_name) { description += showEmojiForRetweetAndReply ? 'â†Šī¸ ' : showSymbolForRetweetAndReply ? 'Re ' : ''; } description += item.full_text; description += img; description += quote; if (readable) { description += `

`; } if (showTimestampInDescription) { description += `${parseDate(item.created_at)}`; } const authorName = originalItem.user.name; return { title, author: authorName, description, pubDate: parseDate(item.created_at), link: `https://twitter.com/${item.user.screen_name}/status/${item.id_str}`, _extra: (isRetweet && { links: [ { type: 'repost', }, ], }) || (item.is_quote_status && { links: [ { url: `https://twitter.com/${item.quoted_status?.user?.screen_name}/status/${item.quoted_status?.id_str}`, type: 'quote', }, ], }) || (item.in_reply_to_screen_name && item.in_reply_to_status_id_str && { links: [ { url: `https://twitter.com/${item.in_reply_to_screen_name}/status/${item.in_reply_to_status_id_str}`, type: 'reply', }, ], }), }; }); }; let getAppClient = () => null; if (config.twitter.consumer_key && config.twitter.consumer_secret) { const consumer_keys = config.twitter.consumer_key.split(','); const consumer_secrets = config.twitter.consumer_secret.split(','); const T = {}; let count = 0; let index = -1; consumer_keys.forEach((consumer_key, i) => { const consumer_secret = consumer_secrets[i]; if (consumer_key && consumer_secret) { T[i] = new TwitterApi({ appKey: consumer_key, appSecret: consumer_secret, }).readOnly; count = i + 1; } }); getAppClient = () => { index++; return T[index % count].appLogin(); }; } const parseRouteParams = (routeParams) => { let count, exclude_replies, include_rts; let force_web_api = false; if (routeParams === 'exclude_rts_replies' || routeParams === 'exclude_replies_rts') { exclude_replies = true; include_rts = false; } else if (routeParams === 'exclude_replies') { exclude_replies = true; include_rts = true; } else if (routeParams === 'exclude_rts') { exclude_replies = false; include_rts = false; } else { const parsed = new URLSearchParams(routeParams); count = fallback(undefined, queryToInteger(parsed.get('count')), undefined); exclude_replies = fallback(undefined, queryToBoolean(parsed.get('excludeReplies')), false); include_rts = fallback(undefined, queryToBoolean(parsed.get('includeRts')), true); force_web_api = fallback(undefined, queryToBoolean(parsed.get('forceWebApi')), false); } return { count, exclude_replies, include_rts, force_web_api }; }; module.exports = { ProcessFeed, getAppClient, parseRouteParams, };