-
Notifications
You must be signed in to change notification settings - Fork 622
/
twitter.server.ts
326 lines (298 loc) · 13.4 KB
/
twitter.server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import http from 'http'
import https from 'https'
import { cachified, verboseReporter } from '@epic-web/cachified'
import makeMetascraper from 'metascraper'
import mDescription from 'metascraper-description'
import mImage from 'metascraper-image'
import mTitle from 'metascraper-title'
import { cache, lruCache } from './cache.server.ts'
import { formatDate, formatNumber, typedBoolean } from './misc.tsx'
import { getTweet } from './twitter/get-tweet.ts'
import { type Tweet } from './twitter/types/index.ts'
const metascraper = makeMetascraper([mTitle(), mDescription(), mImage()])
type Metadata = {
// metascraper has types, but they just say all these values will be there
// whether you're actually parsing for them or not.
title?: string
description?: string
image?: string
}
async function getMetadata(url: string): Promise<Metadata> {
const html = await fetch(url).then((res) => res.text())
return metascraper({ html, url })
}
function unshorten(
urlString: string,
maxFollows: number = 10,
): Promise<string> {
return new Promise((resolve, reject) => {
try {
const url = new URL(urlString)
if (url.protocol) {
const { request } = url.protocol === 'https:' ? https : http
request(urlString, { method: 'HEAD' }, (response) => {
const {
headers: { location },
} = response
if (location && location !== urlString && maxFollows > 0) {
const fullLocation = location.startsWith('/')
? new URL(location, url).toString()
: location
void unshorten(fullLocation, maxFollows - 1).then(resolve)
} else {
resolve(urlString)
}
}).end()
} else {
reject(`Invalid URL: ${urlString}`)
}
} catch (error: unknown) {
reject(error)
}
})
}
async function getTweetCached(tweetId: string) {
const result = await cachified<Tweet | null>(
{
key: `tweet:${tweetId}`,
cache: lruCache,
ttl: 1000 * 60,
swr: 1000 * 60 * 60 * 24 * 30 * 6,
async getFreshValue({ background }) {
const tweet = await getTweet(tweetId)
if (tweet) return tweet
if (background) {
// throw an error so this fallsback to the cache
throw new Error(`Tweet not found: ${tweetId}`)
}
return null
},
},
verboseReporter(),
).catch((e) => {
// catch the error so things don't crash if there's no cache to fallback to.
console.error('Error getting tweet', tweetId, e)
return null
})
return result
}
const playSvg = `<svg width="75" height="75" viewBox="0 0 75 75" xmlns="http://www.w3.org/2000/svg"><circle cx="37.4883" cy="37.8254" r="37" fill="white" /><path fillRule="evenodd" clipRule="evenodd" d="M35.2643 33.025L41.0017 36.9265C41.6519 37.369 41.6499 38.3118 40.9991 38.7518L35.2616 42.6276C34.5113 43.1349 33.4883 42.6077 33.4883 41.7143V33.9364C33.4883 33.0411 34.5146 32.5151 35.2643 33.025" /></svg>`
function buildMediaList(
mediaDetails: NonNullable<Tweet['mediaDetails']>,
link?: string,
) {
const width = mediaDetails.length > 1 ? '50%' : '100%'
const imgs = mediaDetails
.map((media) => {
const src = media.media_url_https
const imgHTML = `<img src="${src}" width="${width}" loading="lazy" alt="Tweet media" />`
if (media.type === 'animated_gif' || media.type === 'video') {
return `<div class="tweet-media-with-play-button"><div class="tweet-media-play-button">${playSvg}</div>${imgHTML}</div>`
} else {
return imgHTML
}
})
.join('')
const grid = `<div class="tweet-media-container"><div class="tweet-media-grid" data-count="${mediaDetails.length}">${imgs}</div></div>`
if (link) {
return `<a href="${link}" target="_blank" rel="noreferrer noopener">${grid}</a>`
} else {
return grid
}
}
const likesSVG = `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.034 11.596 8.55 11.658 1.518-.062 8.55-5.917 8.55-11.658 0-2.267-1.823-4.255-3.903-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.014-.03-1.425-2.965-3.954-2.965z"></path></g></svg>`
const repliesSVG = `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><g><path d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.044.286.12.403.142.225.384.347.632.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.367-3.43-7.787-7.8-7.788zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.335-.75-.75-.75h-.396c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z"></path></g></svg>`
const retweetSVG = `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><g><path d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z"></path></g></svg>`
const linkSvg = `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><g><path d="M11.96 14.945c-.067 0-.136-.01-.203-.027-1.13-.318-2.097-.986-2.795-1.932-.832-1.125-1.176-2.508-.968-3.893s.942-2.605 2.068-3.438l3.53-2.608c2.322-1.716 5.61-1.224 7.33 1.1.83 1.127 1.175 2.51.967 3.895s-.943 2.605-2.07 3.438l-1.48 1.094c-.333.246-.804.175-1.05-.158-.246-.334-.176-.804.158-1.05l1.48-1.095c.803-.592 1.327-1.463 1.476-2.45.148-.988-.098-1.975-.69-2.778-1.225-1.656-3.572-2.01-5.23-.784l-3.53 2.608c-.802.593-1.326 1.464-1.475 2.45-.15.99.097 1.975.69 2.778.498.675 1.187 1.15 1.992 1.377.4.114.633.528.52.928-.092.33-.394.547-.722.547z"></path><path d="M7.27 22.054c-1.61 0-3.197-.735-4.225-2.125-.832-1.127-1.176-2.51-.968-3.894s.943-2.605 2.07-3.438l1.478-1.094c.334-.245.805-.175 1.05.158s.177.804-.157 1.05l-1.48 1.095c-.803.593-1.326 1.464-1.475 2.45-.148.99.097 1.975.69 2.778 1.225 1.657 3.57 2.01 5.23.785l3.528-2.608c1.658-1.225 2.01-3.57.785-5.23-.498-.674-1.187-1.15-1.992-1.376-.4-.113-.633-.527-.52-.927.112-.4.528-.63.926-.522 1.13.318 2.096.986 2.794 1.932 1.717 2.324 1.224 5.612-1.1 7.33l-3.53 2.608c-.933.693-2.023 1.026-3.105 1.026z"></path></g></svg>`
const arrowSvg = `<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.25 15.25V6.75H8.75"></path>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 7L6.75 17.25"></path>
</svg>
`
async function buildTweetHTML(tweet: Tweet, expandQuotedTweet: boolean) {
const author = tweet.user
const tweetURL = `https://twitter.com/${author.screen_name}/status/${tweet.id_str}`
// _normal is only 48x48 which looks bad on high-res displays
// _bigger is 73x73 which looks better...
const authorImg = author.profile_image_url_https.replace('_normal', '_bigger')
const authorHTML = `
<a class="tweet-author" href="https://twitter.com/${author.screen_name}" target="_blank" rel="noreferrer noopener">
<img src="${authorImg}" loading="lazy" alt="${author.name} avatar" />
<div>
<span class="tweet-author-name">${author.name}</span>
<span class="tweet-author-handle">@${author.screen_name}</span>
</div>
</a>`
const links = (
await Promise.all(
[...tweet.text.matchAll(/https:\/\/t.co\/\w+/g)].map(
async ([shortLink]) => {
if (!shortLink) return
const longLink = await unshorten(shortLink).catch(() => shortLink)
const longUrl = new URL(longLink)
const isTwitterLink = longUrl.host === 'twitter.com'
let replacement = `<a href="${longLink}" target="_blank" rel="noreferrer noopener">${
longUrl.hostname + longUrl.pathname
}</a>`
const isReferenced =
tweet.quoted_tweet?.id_str &&
longLink.includes(tweet.quoted_tweet.id_str)
let metadata: Metadata | null = null
if (isReferenced) {
// we'll handle the referenced tweet later
replacement = ''
}
const isTwitterMediaLink =
isTwitterLink && /\/(video|photo)\//.test(longUrl.pathname)
if (isTwitterMediaLink) {
// we just embed the media link as an href around the media
replacement = ''
}
if (!isTwitterLink) {
// we don't want to get metadata for tweets.
metadata = await getMetadata(longLink).catch(() => null)
}
if (metadata) {
replacement = `<a href="${longLink}" target="_blank" rel="noreferrer noopener">${
metadata.title?.trim() || longUrl.hostname + longUrl.pathname
}</a>`
}
return {
shortLink,
isTwitterLink,
longLink,
longUrl,
replacement,
metadata,
}
},
),
)
).filter(typedBoolean)
let blockquote = tweet.text
for (let index = 0; index < links.length; index++) {
const linkInfo = links[index]
if (!linkInfo) continue
const { shortLink, replacement } = linkInfo
blockquote = blockquote.replaceAll(shortLink, replacement)
}
let expandedQuoteTweetHTML = ''
if (expandQuotedTweet && tweet.quoted_tweet) {
const quotedTweet = await getTweetCached(tweet.quoted_tweet.id_str).catch(
() => {},
)
if (quotedTweet) {
const quotedHTML = await buildTweetHTML(quotedTweet, false).catch(
() => {},
)
if (quotedHTML) {
expandedQuoteTweetHTML = `<div class="tweet-quoted">${quotedHTML}</div>`
}
}
}
// twitterify @mentions
blockquote = blockquote.replace(
/@(\w+)/g,
`<a href="https://twitter.com/$1" target="_blank" rel="noreferrer noopener">$&</a>`,
)
const tweetHTML = `<blockquote>${blockquote.trim()}</blockquote>`
const mediaHTML = tweet.mediaDetails?.length
? buildMediaList(tweet.mediaDetails, tweetURL)
: ''
const lastMetadataLink = links.reverse().find((l) => l.metadata)
let linkMetadataHTML = ''
if (lastMetadataLink && !mediaHTML) {
const { metadata: md, longLink, longUrl } = lastMetadataLink
if (md) {
const title = md.title ?? 'Unknown title'
const titleHtml = `<div class="tweet-ref-metadata-title">${title}</div>`
const imgHtml = md.image
? `<img class="tweet-ref-metadata-image" src="${md.image}" loading="lazy" alt="Referenced media" />`
: ''
const descHtml = md.description
? `<div class="tweet-ref-metadata-description">${md.description}</div>`
: ''
const urlHtml = `<div class="tweet-ref-metadata-domain">${linkSvg}<span>${longUrl.hostname}</span></div>`
linkMetadataHTML = `
<a href="${longLink}" class="tweet-ref-metadata" target="_blank" rel="noreferrer noopener">
${imgHtml}
${titleHtml}
${descHtml}
${urlHtml}
</a>
`.trim()
}
}
const createdAtHTML = `<div class="tweet-time"><a href="${tweetURL}" target="_blank" rel="noreferrer noopener">${formatDate(
tweet.created_at,
'h:mm a',
)} (UTC) · ${formatDate(new Date(tweet.created_at))}</a></div>`
const likeIntent = `https://twitter.com/intent/like?tweet_id=${tweet.id_str}`
const retweetIntent = `https://twitter.com/intent/retweet?tweet_id=${tweet.id_str}`
const replyIntent = tweetURL
const { favorite_count, conversation_count } = tweet
const likeCount = formatNumber(favorite_count)
const replyCount = formatNumber(conversation_count)
const statsHTML = `
<div class="tweet-stats">
<a href="${replyIntent}" class="tweet-reply" target="_blank" rel="noreferrer noopener">${repliesSVG}<span>${replyCount}</span></a>
<a href="${retweetIntent}" class="tweet-retweet" target="_blank" rel="noreferrer noopener">${retweetSVG}</a>
<a href="${likeIntent}" class="tweet-like" target="_blank" rel="noreferrer noopener">${likesSVG}<span>${likeCount}</span></a>
<a href="${tweetURL}" class="tweet-link" target="_blank" rel="noreferrer noopener">${arrowSvg}<span></span></a>
</div>
`
return `
<div class="tweet-embed">
${authorHTML}
${tweetHTML}
${mediaHTML}
${linkMetadataHTML}
${expandedQuoteTweetHTML}
${createdAtHTML}
${statsHTML}
</div>
`.trim()
}
async function getTweetEmbedHTML(urlString: string) {
return cachified(
{
key: `tweet:embed:${urlString}`,
ttl: 1000 * 60 * 60 * 24,
cache,
staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30 * 6,
getFreshValue: () => getTweetEmbedHTMLImpl(urlString),
},
verboseReporter(),
)
}
async function getTweetEmbedHTMLImpl(urlString: string) {
const url = new URL(urlString)
const tweetId = url.pathname.split('/').pop()
if (!tweetId) {
console.error('TWEET ID NOT FOUND', urlString, tweetId)
return ''
}
let tweet: Awaited<ReturnType<typeof getTweet>> = null
const failureHtml = `<callout-danger>𝕏 post data not available: <a href="${urlString}">${urlString}</a></callout-danger>`
try {
tweet = await getTweetCached(tweetId)
if (!tweet) {
return failureHtml
}
const html = await buildTweetHTML(tweet, true)
return html
} catch (error: unknown) {
console.error('Error processing tweet', {
urlString,
tweetId,
error,
tweet,
})
return failureHtml
}
}
function isTwitterUrl(urlString: string) {
const url = new URL(urlString)
return /\.?twitter\.com/.test(url.hostname)
}
export { getTweetEmbedHTML, isTwitterUrl }