|
@@ -0,0 +1,262 @@
|
|
|
+import { Tweet } from './twitter.ts';
|
|
|
+
|
|
|
+class SafeString {
|
|
|
+ constructor(readonly raw: string) {}
|
|
|
+
|
|
|
+ get length(): number {
|
|
|
+ return this.raw.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ toString(): string {
|
|
|
+ return this.raw;
|
|
|
+ }
|
|
|
+
|
|
|
+ toJSON(): string {
|
|
|
+ return this.raw;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type StringLike = string | SafeString;
|
|
|
+
|
|
|
+function escapeHTML(unsafe: string): StringLike {
|
|
|
+ return new SafeString(
|
|
|
+ unsafe
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">")
|
|
|
+ .replace(/"/g, """)
|
|
|
+ .replace(/'/g, "'")
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function joinChildren(children: StringLike[]): SafeString {
|
|
|
+ return new SafeString(children
|
|
|
+ .map(child => typeof child === 'string' ? escapeHTML(child) : child)
|
|
|
+ .join('')
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function tag(tag: string, attributes: Record<string, string> = {}, children: StringLike | StringLike[] = []): SafeString {
|
|
|
+ const attrs = Object.entries(attributes).map(([ key, value ]) => {
|
|
|
+ return ` ${key}="${escapeHTML(value)}"`;
|
|
|
+ }).join('');
|
|
|
+ if (children.length === 0) {
|
|
|
+ return new SafeString(`<${tag}${attrs} />`);
|
|
|
+ } else {
|
|
|
+ const childrenArray = Array.isArray(children) ? children : [children];
|
|
|
+ const normalizedChildren = joinChildren(childrenArray);
|
|
|
+ return new SafeString(`<${tag}${attrs}>${normalizedChildren}</${tag}>`);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildTwitterUrl(url: string): string {
|
|
|
+ return new URL(url, 'https://twitter.com').toString();
|
|
|
+}
|
|
|
+
|
|
|
+function buildProxyUrl(url: string): string {
|
|
|
+ const search = new URLSearchParams({ target: url }).toString();
|
|
|
+ return `/__proxy?${search}`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatPlainText(text: string): SafeString {
|
|
|
+ return new SafeString(escapeHTML(text).toString().replace(/\n/g, "<br />"));
|
|
|
+}
|
|
|
+
|
|
|
+class TextFormatter {
|
|
|
+ private splices: { text: StringLike, indices: [number, number] }[];
|
|
|
+ private media: { type: 'video' | 'img', url: string, link?: string }[];
|
|
|
+ private characters: string[];
|
|
|
+
|
|
|
+ constructor(readonly tweet: Tweet, readonly useProxy: boolean) {
|
|
|
+ this.characters = [...tweet.full_text];
|
|
|
+ this.splices = [];
|
|
|
+ this.media = [];
|
|
|
+ for (const { indices, text } of tweet.entities.hashtags) {
|
|
|
+ const url = buildTwitterUrl(`/hashtag/${text}`);
|
|
|
+ this.splices.push({
|
|
|
+ indices,
|
|
|
+ text: tag('a', { href: url }, `#${text}`),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ for (const link of tweet.entities.urls) {
|
|
|
+ const url = new URL(link.expanded_url).toString();
|
|
|
+ this.splices.push({
|
|
|
+ indices: link.indices,
|
|
|
+ text: tag('a', { href: url }, link.display_url),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ for (const { indices, name, screen_name } of tweet.entities.user_mentions) {
|
|
|
+ const url = buildTwitterUrl(`/${screen_name}`);
|
|
|
+ this.splices.push({
|
|
|
+ indices: indices,
|
|
|
+ text: tag('a', { href: url, title: name }, `@${screen_name}`),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const media = tweet.extended_entities?.media ?? [];
|
|
|
+ for (const item of media) {
|
|
|
+ if (item.type === 'photo') {
|
|
|
+ const url = new URL(item.media_url_https).toString();
|
|
|
+ this.media.push({ type: 'img', url });
|
|
|
+ } else if (item.video_info !== undefined) {
|
|
|
+ let max = -1;
|
|
|
+ let maxUrl: string | undefined = undefined;
|
|
|
+ for (const variant of item.video_info.variants) {
|
|
|
+ if (variant.bitrate === undefined) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (variant.bitrate > max) {
|
|
|
+ max = variant.bitrate;
|
|
|
+ maxUrl = variant.url;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (maxUrl !== undefined) {
|
|
|
+ const url = new URL(maxUrl).toString();
|
|
|
+ this.media.push({ type: 'video', url });
|
|
|
+ } else {
|
|
|
+ const url = new URL(item.media_url_https).toString();
|
|
|
+ this.media.push({ type: 'img', url, link: item.expanded_url });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getRange(start: number, end?: number): string {
|
|
|
+ const max = this.tweet.display_text_range[1];
|
|
|
+ return this.characters.slice(start, end ?? max).join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ headerHTML(): SafeString {
|
|
|
+ const date = new Date(this.tweet.created_at);
|
|
|
+ const dateOptions = {
|
|
|
+ weekday: 'short',
|
|
|
+ year: 'numeric',
|
|
|
+ month: '2-digit',
|
|
|
+ day: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ timeZoneName: 'short',
|
|
|
+ } as const;
|
|
|
+ const imageUrl = new URL(this.tweet.user.profile_image_url_https).toString();
|
|
|
+ const imageSrc = this.useProxy ? buildProxyUrl(imageUrl) : imageUrl;
|
|
|
+ const profileUrl = buildTwitterUrl(`/${this.tweet.user.screen_name}`);
|
|
|
+ const tweetUrl = buildTwitterUrl(`/${this.tweet.user.screen_name}/status/${this.tweet.id_str}`);
|
|
|
+ const html = [
|
|
|
+ tag('img', { loading: 'lazy', src: imageSrc, height: '24px', width: '24px' }),
|
|
|
+ ' ',
|
|
|
+ tag('strong', {}, this.tweet.user.name),
|
|
|
+ ' ',
|
|
|
+ tag('a', { href: profileUrl }, `@${this.tweet.user.screen_name}`),
|
|
|
+ tag('br'),
|
|
|
+ 'Posted ',
|
|
|
+ tag('a', { href: tweetUrl }, date.toLocaleString(this.tweet.lang, dateOptions)),
|
|
|
+ ];
|
|
|
+ return joinChildren(html);
|
|
|
+ }
|
|
|
+
|
|
|
+ bodyHTML(): SafeString {
|
|
|
+ const max = this.tweet.display_text_range[1];
|
|
|
+ const splices = this.splices
|
|
|
+ .filter(({ indices }) => indices[0] < max && indices[1] <= max)
|
|
|
+ .sort((a, b) => a.indices[0] - b.indices[0]);
|
|
|
+
|
|
|
+ let index = 0;
|
|
|
+ const html: StringLike[] = [];
|
|
|
+ for (const { text, indices } of splices) {
|
|
|
+ const start = index;
|
|
|
+ const end = indices[0];
|
|
|
+
|
|
|
+ html.push(formatPlainText(this.getRange(start, end)));
|
|
|
+ html.push(text);
|
|
|
+
|
|
|
+ index = indices[1];
|
|
|
+ }
|
|
|
+ html.push(formatPlainText(this.getRange(index)));
|
|
|
+ for (const { type, url, link } of this.media) {
|
|
|
+ html.push(tag('br'));
|
|
|
+ html.push(tag('br'));
|
|
|
+ const src = this.useProxy ? buildProxyUrl(url) : url;
|
|
|
+ if (type === 'img') {
|
|
|
+ html.push(tag('a', { href: link ?? url }, [
|
|
|
+ tag('img', { loading: 'lazy', src }),
|
|
|
+ ]));
|
|
|
+ } else if (type === 'video') {
|
|
|
+ html.push(tag('video', { controls: '', src }));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return joinChildren(html);
|
|
|
+ }
|
|
|
+
|
|
|
+ toHTML(): SafeString {
|
|
|
+ return joinChildren([ this.headerHTML(), tag('br'), this.bodyHTML() ]);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const STYLES = `
|
|
|
+body > div {
|
|
|
+ margin: 10px;
|
|
|
+ padding: 10px;
|
|
|
+ border: solid 1px gray;
|
|
|
+ border-radius: 10px;
|
|
|
+}
|
|
|
+blockquote {
|
|
|
+ padding: 10px;
|
|
|
+ border: solid 1px lightgray;
|
|
|
+ border-radius: 10px;
|
|
|
+}
|
|
|
+div {
|
|
|
+ max-width: 600px;
|
|
|
+}
|
|
|
+img {
|
|
|
+ max-width: 100%;
|
|
|
+}
|
|
|
+`;
|
|
|
+
|
|
|
+export function timelineAsHTML(tweets: Tweet[]): string {
|
|
|
+ const body = tweets.map(tweet => {
|
|
|
+ const displayTweet = tweet.retweeted_status ?? tweet;
|
|
|
+ const children: StringLike[] = [];
|
|
|
+ children.push(new TextFormatter(displayTweet, true).toHTML());
|
|
|
+
|
|
|
+ const quoteTweet = displayTweet.quoted_status;
|
|
|
+ if (quoteTweet !== undefined) {
|
|
|
+ children.push(tag('blockquote', {}, new TextFormatter(quoteTweet, true).toHTML()));
|
|
|
+ }
|
|
|
+ return tag('div', {}, children);
|
|
|
+ }).join('\n');
|
|
|
+ return `
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <style>${STYLES}</style>
|
|
|
+ </head>
|
|
|
+ <body>${body}</body>
|
|
|
+ </html>
|
|
|
+ `;
|
|
|
+}
|
|
|
+
|
|
|
+export function timelineAsJSON(username: string, tweets: Tweet[]): string {
|
|
|
+ const items = tweets.map(tweet => {
|
|
|
+ const displayTweet = tweet.retweeted_status ?? tweet;
|
|
|
+ const children: StringLike[] = [];
|
|
|
+ children.push(new TextFormatter(displayTweet, false).bodyHTML());
|
|
|
+
|
|
|
+ const quoteTweet = displayTweet.quoted_status;
|
|
|
+ if (quoteTweet !== undefined) {
|
|
|
+ children.push(tag('blockquote', {}, [
|
|
|
+ new TextFormatter(quoteTweet, false).toHTML(),
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+ const html = joinChildren(children);
|
|
|
+ return {
|
|
|
+ id: tweet.id_str,
|
|
|
+ url: buildTwitterUrl(`/${tweet.user.screen_name}/status/${tweet.id_str}`),
|
|
|
+ content_html: html,
|
|
|
+ date_published: new Date(tweet.created_at).toISOString(),
|
|
|
+ };
|
|
|
+ });
|
|
|
+ return JSON.stringify({
|
|
|
+ version: '1.1',
|
|
|
+ title: `Twitter @${username}`,
|
|
|
+ home_page_url: buildTwitterUrl(`/${username}`),
|
|
|
+ items,
|
|
|
+ });
|
|
|
+}
|