diff --git a/.env.example b/.env.example index 7c3277a..cde8f99 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ STATIC_PROXY="" GOOGLE_SEARCH_SITE="" TAGS="" COMMENTS="" +REACTIONS="" LINKS="" NAVS="" -RSS_BEAUTIFY="" \ No newline at end of file +RSS_BEAUTIFY="" diff --git a/README.md b/README.md index 23073ea..9d1544f 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,9 @@ TAGS=tag1,tag2,tag3 ## Show comments COMMENTS=true +## Show reactions +REACTIONS=true + ## List of links in the Links page, Separate using commas and semicolons LINKS=Title1,URL1;Title2,URL3;Title3,URL3; diff --git a/README.zh-cn.md b/README.zh-cn.md index 4fbc3e5..33fe473 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -129,6 +129,9 @@ TAGS=标签A,标签B,标签C ## 展示评论 COMMENTS=true +## 展示 Reactions +REACTIONS=true + ## 链接页面中的超链接, 使用英文逗号和分号分割 LINKS=Title1,URL1;Title2,URL3;Title3,URL3; diff --git a/src/assets/item.css b/src/assets/item.css index b84d0f0..3721e19 100644 --- a/src/assets/item.css +++ b/src/assets/item.css @@ -234,6 +234,12 @@ margin-right: 2px; } + .tg-emoji { + width: 1.15em; + height: 1.15em; + vertical-align: -0.15em; + } + .sticker { box-shadow: none; border: none; @@ -261,6 +267,50 @@ } } +.reaction-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; +} + +.reaction-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px 3px 6px; + font-size: 12px; + color: var(--secondary-color); + background: var(--code-background-color); + border: 1px solid var(--border-color); + border-radius: 999px; +} + +.reaction-pill-paid { + background: rgba(255, 196, 0, 0.12); + border-color: rgba(255, 196, 0, 0.35); + color: #9a6a00; +} + +.reaction-emoji { + display: inline-flex; + align-items: center; + font-size: 14px; + line-height: 1; +} + +.reaction-emoji img { + width: 1em; + height: 1em; + display: block; +} + +.reaction-count { + font-weight: 500; + font-variant-numeric: tabular-nums; + opacity: 0.8; +} + .tag-box { flex-wrap: wrap; } diff --git a/src/assets/style.css b/src/assets/style.css index 5df7c21..83bc7af 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -327,6 +327,17 @@ audio::-webkit-media-controls-panel { margin-left: 3px; } +.reaction-box { + border-left: 2px solid var(--border-color); + padding: 6px 0px 24px 30px; + margin-left: 3px; +} + +.text-box + .reaction-box { + margin-top: -12px; + padding-top: 0px; +} + .text-box p:first-child { margin-top: 0px; } diff --git a/src/components/item.astro b/src/components/item.astro index 15035d4..42f494f 100644 --- a/src/components/item.astro +++ b/src/components/item.astro @@ -14,6 +14,7 @@ const { post, isItem } = Astro.props const channel = getEnv(import.meta.env, Astro, 'CHANNEL') const COMMENTS = getEnv(import.meta.env, Astro, 'COMMENTS') +const REACTIONS = getEnv(import.meta.env, Astro, 'REACTIONS') const datetime = dayjs(post.datetime).tz(timezone) const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('HH:mm · ll · ddd') : datetime.fromNow() @@ -29,6 +30,28 @@ const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('H {post.content.length > 0 &&
} + { + REACTIONS && post.reactions?.length > 0 && ( +
+
+ {post.reactions.map((reaction) => ( + + + { + reaction.isPaid + ? '\u2B50' + : reaction.emojiImage + ? {reaction.emoji + : reaction.emoji + } + + {reaction.count} + + ))} +
+
+ ) + } { post.tags.length > 0 && (
diff --git a/src/lib/telegram/index.js b/src/lib/telegram/index.js index 5327736..ffa98a3 100644 --- a/src/lib/telegram/index.js +++ b/src/lib/telegram/index.js @@ -13,6 +13,42 @@ const cache = new LRUCache({ }, }) +// Normalize emoji variants (e.g., heart variants) +function normalizeEmoji(emoji) { + const emojiMap = { + '\u2764': '\u2764\uFE0F', + '\u263A': '\u263A\uFE0F', + '\u2639': '\u2639\uFE0F', + '\u2665': '\u2764\uFE0F', + } + return emojiMap[emoji] || emoji +} + +function getCustomEmojiImage(emojiId, staticProxy = '') { + if (!emojiId) + return null + const imageUrl = `https://t.me/i/emoji/${emojiId}.webp` + return `${staticProxy}${imageUrl}` +} + +async function hydrateTgEmoji($, content, { staticProxy } = {}) { + const emojiNodes = $(content).find('tg-emoji')?.toArray() ?? [] + if (!emojiNodes.length) + return + + await Promise.all(emojiNodes.map((emojiEl) => { + const emojiId = $(emojiEl).attr('emoji-id') + if (!emojiId) + return + + const imageUrl = getCustomEmojiImage(emojiId, staticProxy) + if (imageUrl) { + const imageMarkup = `` + $(emojiEl).replaceWith(imageMarkup) + } + })) +} + function getVideoStickers($, item, { staticProxy, index }) { return $(item).find('.js-videosticker_video')?.map((_index, video) => { const url = $(video)?.attr('src') @@ -101,7 +137,8 @@ function getReply($, item, { channel }) { return $.html(reply) } -function modifyHTMLContent($, content, { index } = {}) { +async function modifyHTMLContent($, content, { index, staticProxy } = {}) { + await hydrateTgEmoji($, content, { staticProxy }) $(content).find('.emoji')?.removeAttr('style') $(content).find('a')?.each((_index, a) => { $(a)?.attr('title', $(a)?.text())?.removeAttr('onclick') @@ -137,11 +174,59 @@ function modifyHTMLContent($, content, { index } = {}) { return content } -function getPost($, item, { channel, staticProxy, index = 0 }) { +function getReactions($, item, staticProxy) { + const reactions = [] + const reactionNodes = $(item).find('.tgme_widget_message_reactions .tgme_reaction').toArray() + + for (const reaction of reactionNodes) { + const isPaid = $(reaction).hasClass('tgme_reaction_paid') + let emoji = '' + let emojiId + let emojiImage + + const standardEmoji = $(reaction).find('.emoji b') + if (standardEmoji.length) { + emoji = normalizeEmoji(standardEmoji.text().trim()) + } + + const tgEmoji = $(reaction).find('tg-emoji') + if (tgEmoji.length && !emoji) { + emojiId = tgEmoji.attr('emoji-id') + if (emojiId) { + const imageUrl = getCustomEmojiImage(emojiId, staticProxy) + if (imageUrl) { + emojiImage = imageUrl + } + } + } + + if (isPaid && !emoji && !emojiImage) { + emoji = '\u2B50' + } + + const clone = $(reaction).clone() + clone.find('.emoji, tg-emoji, i').remove() + const count = clone.text().trim() + + if (count) { + reactions.push({ + emoji, + emojiId, + emojiImage, + count, + isPaid, + }) + } + } + + return reactions +} + +async function getPost($, item, { channel, staticProxy, index = 0, reactionsEnabled } = {}) { item = item ? $(item).find('.tgme_widget_message') : $('.tgme_widget_message') const content = $(item).find('.js-message_reply_text')?.length > 0 - ? modifyHTMLContent($, $(item).find('.tgme_widget_message_text.js-message_text'), { index }) - : modifyHTMLContent($, $(item).find('.tgme_widget_message_text'), { index }) + ? await modifyHTMLContent($, $(item).find('.tgme_widget_message_text.js-message_text'), { index, staticProxy }) + : await modifyHTMLContent($, $(item).find('.tgme_widget_message_text'), { index, staticProxy }) const title = content?.text()?.match(/^.*?(?=[。\n]|http\S)/g)?.[0] ?? content?.text() ?? '' const id = $(item).attr('data-post')?.replace(new RegExp(`${channel}/`, 'i'), '') @@ -179,6 +264,7 @@ function getPost($, item, { channel, staticProxy, index = 0 }) { } return `${p1}${staticProxy}${p2}` }), + reactions: reactionsEnabled ? getReactions($, item, staticProxy) : [], } } @@ -197,6 +283,7 @@ export async function getChannelInfo(Astro, { before = '', after = '', q = '', t const host = getEnv(import.meta.env, Astro, 'TELEGRAM_HOST') ?? 't.me' const channel = getEnv(import.meta.env, Astro, 'CHANNEL') const staticProxy = getEnv(import.meta.env, Astro, 'STATIC_PROXY') ?? '/static/' + const reactionsEnabled = getEnv(import.meta.env, Astro, 'REACTIONS') const url = id ? `https://${host}/${channel}/${id}?embed=1&mode=tme` : `https://${host}/s/${channel}` const headers = Object.fromEntries(Astro.request.headers) @@ -221,19 +308,21 @@ export async function getChannelInfo(Astro, { before = '', after = '', q = '', t const $ = cheerio.load(html, {}, false) if (id) { - const post = getPost($, null, { channel, staticProxy }) + const post = await getPost($, null, { channel, staticProxy, reactionsEnabled }) cache.set(cacheKey, post) return post } - const posts = $('.tgme_channel_history .tgme_widget_message_wrap')?.map((index, item) => { - return getPost($, item, { channel, staticProxy, index }) - })?.get()?.reverse().filter(post => ['text'].includes(post.type) && post.id && post.content) + const posts = (await Promise.all( + $('.tgme_channel_history .tgme_widget_message_wrap')?.map((index, item) => { + return getPost($, item, { channel, staticProxy, index, reactionsEnabled }) + })?.get() ?? [], + ))?.reverse().filter(post => ['text'].includes(post.type) && post.id && post.content) const channelInfo = { posts, title: $('.tgme_channel_info_header_title')?.text(), description: $('.tgme_channel_info_description')?.text(), - descriptionHTML: modifyHTMLContent($, $('.tgme_channel_info_description'))?.html(), + descriptionHTML: (await modifyHTMLContent($, $('.tgme_channel_info_description'), { staticProxy }))?.html(), avatar: $('.tgme_page_photo_image img')?.attr('src'), } diff --git a/src/pages/static/[...url].js b/src/pages/static/[...url].js index 039e1f2..ea34f23 100644 --- a/src/pages/static/[...url].js +++ b/src/pages/static/[...url].js @@ -10,7 +10,9 @@ const targetWhitelist = [ export async function GET({ request, params, url }) { try { - const target = new URL(params.url + url.search) + const rawTarget = params.url + url.search + const normalizedTarget = rawTarget.startsWith('//') ? `https:${rawTarget}` : rawTarget + const target = new URL(normalizedTarget) if (!targetWhitelist.some(domain => target.hostname.endsWith(domain))) { return Response.redirect(target.toString(), 302) }