Add support for embedding TikTok videos
This commit is contained in:
@ -39,6 +39,7 @@ import { WeixinQqHandler } from './websites/weixin-qq-handler'
|
|||||||
import { WikipediaHandler } from './websites/wikipedia-handler'
|
import { WikipediaHandler } from './websites/wikipedia-handler'
|
||||||
import { YoutubeHandler } from './websites/youtube-handler'
|
import { YoutubeHandler } from './websites/youtube-handler'
|
||||||
import { ZhihuHandler } from './websites/zhihu-handler'
|
import { ZhihuHandler } from './websites/zhihu-handler'
|
||||||
|
import { TikTokHandler } from './websites/tiktok-handler'
|
||||||
|
|
||||||
const validateUrlString = (url: string): boolean => {
|
const validateUrlString = (url: string): boolean => {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@ -83,6 +84,7 @@ const contentHandlers: ContentHandler[] = [
|
|||||||
new WeixinQqHandler(),
|
new WeixinQqHandler(),
|
||||||
new ZhihuHandler(),
|
new ZhihuHandler(),
|
||||||
new TwitterHandler(),
|
new TwitterHandler(),
|
||||||
|
new TikTokHandler(),
|
||||||
]
|
]
|
||||||
|
|
||||||
const newsletterHandlers: ContentHandler[] = [
|
const newsletterHandlers: ContentHandler[] = [
|
||||||
|
|||||||
100
packages/content-handler/src/websites/tiktok-handler.ts
Normal file
100
packages/content-handler/src/websites/tiktok-handler.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { ContentHandler, PreHandleResult } from '../content-handler'
|
||||||
|
import axios from 'axios'
|
||||||
|
import _ from 'underscore'
|
||||||
|
|
||||||
|
const getRedirectUrl = async (url: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) => status === 302,
|
||||||
|
})
|
||||||
|
return response.headers.location as string
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response && error.response.headers.location) {
|
||||||
|
return error.response.headers.location
|
||||||
|
}
|
||||||
|
console.error('No redirect or network error occurred:', error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeTitle = (title: string) => {
|
||||||
|
return _.escape(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TikTokHandler extends ContentHandler {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.name = 'TikTok'
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldPreHandle(url: string): boolean {
|
||||||
|
const u = new URL(url)
|
||||||
|
return u.hostname.endsWith('tiktok.com')
|
||||||
|
}
|
||||||
|
|
||||||
|
async preHandle(url: string): Promise<PreHandleResult> {
|
||||||
|
let fetchUrl = url
|
||||||
|
const u = new URL(url)
|
||||||
|
if (
|
||||||
|
u.hostname.startsWith('vm.tiktok.com') ||
|
||||||
|
u.hostname.startsWith('vt.tiktok.com')
|
||||||
|
) {
|
||||||
|
// Fetch the full URL
|
||||||
|
const redirectedUrl = await getRedirectUrl(url)
|
||||||
|
if (!redirectedUrl) {
|
||||||
|
throw new Error('Could not fetch redirect URL for: ' + url)
|
||||||
|
}
|
||||||
|
fetchUrl = redirectedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const oembedUrl =
|
||||||
|
`https://www.tiktok.com/oembed?format=json&url=` +
|
||||||
|
encodeURIComponent(fetchUrl)
|
||||||
|
const oembed = (await axios.get(oembedUrl.toString())).data as {
|
||||||
|
title: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
html: string
|
||||||
|
thumbnail_url: string
|
||||||
|
author_name: string
|
||||||
|
author_url: string
|
||||||
|
}
|
||||||
|
console.log('oembed results: ', oembed)
|
||||||
|
// escape html entities in title
|
||||||
|
const title = oembed.title
|
||||||
|
const escapedTitle = escapeTitle(title)
|
||||||
|
const ratio = oembed.width / oembed.height
|
||||||
|
const thumbnail = oembed.thumbnail_url
|
||||||
|
const height = 350
|
||||||
|
const width = height * ratio
|
||||||
|
const authorName = _.escape(oembed.author_name)
|
||||||
|
// <p><a href="${url}" target="_blank">${escapedTitle}</a></p>
|
||||||
|
const content = `
|
||||||
|
<html>
|
||||||
|
<head><title>TikTok page</title>
|
||||||
|
<meta property="og:image" content="${thumbnail}" />
|
||||||
|
<meta property="og:image:secure_url" content="${thumbnail}" />
|
||||||
|
<meta property="og:title" content="${escapedTitle}" />
|
||||||
|
<meta property="og:description" content="" />
|
||||||
|
<meta property="og:article:author" content="${authorName}" />
|
||||||
|
<meta property="og:site_name" content="TikTok" />
|
||||||
|
<meta property="og:type" content="video" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<article id="_omnivore_tiktok">
|
||||||
|
<div id="_omnivore_tiktok_video">
|
||||||
|
${oembed.html}
|
||||||
|
</div>
|
||||||
|
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
console.log('content, title', title, content)
|
||||||
|
|
||||||
|
return { content, title }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -263,7 +263,7 @@ Readability.prototype = {
|
|||||||
|
|
||||||
// These are the classes that we want to keep.
|
// These are the classes that we want to keep.
|
||||||
CLASSES_TO_PRESERVE: [
|
CLASSES_TO_PRESERVE: [
|
||||||
"page", "twitter-tweet", "tweet-placeholder", "instagram-placeholder", "morning-brew-markets", "prism-code"
|
"page", "twitter-tweet", "tweet-placeholder", "instagram-placeholder", "morning-brew-markets", "prism-code", "tiktok-embed"
|
||||||
],
|
],
|
||||||
|
|
||||||
// Classes of placeholder elements that can be empty but shouldn't be removed
|
// Classes of placeholder elements that can be empty but shouldn't be removed
|
||||||
|
|||||||
7
packages/web/additional.d.ts
vendored
7
packages/web/additional.d.ts
vendored
@ -16,6 +16,7 @@ declare global {
|
|||||||
AndroidWebKitMessenger?: AndroidWebKitMessenger
|
AndroidWebKitMessenger?: AndroidWebKitMessenger
|
||||||
themeKey?: string
|
themeKey?: string
|
||||||
twttr?: EmbedTweetWidget
|
twttr?: EmbedTweetWidget
|
||||||
|
tiktokEmbed?: EmbedTiktokWidget
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,3 +51,9 @@ export interface EmbedTweetWidget {
|
|||||||
}
|
}
|
||||||
[key: string]: string | { createTweet: unknown }
|
[key: string]: string | { createTweet: unknown }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmbedTiktokWidget {
|
||||||
|
lib: {
|
||||||
|
render: (tiktoksOnPage) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -67,9 +67,8 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||||
const [imageSrcs, setImageSrcs] = useState<SlideImage[]>([])
|
const [imageSrcs, setImageSrcs] = useState<SlideImage[]>([])
|
||||||
const [lightboxIndex, setlightBoxIndex] = useState(0)
|
const [lightboxIndex, setlightBoxIndex] = useState(0)
|
||||||
const [linkHoverData, setlinkHoverData] = useState<
|
const [linkHoverData, setlinkHoverData] =
|
||||||
LinkHoverData | undefined
|
useState<LinkHoverData | undefined>()
|
||||||
>()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
@ -247,6 +246,36 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tikTokPlaceholders = Array.from(
|
||||||
|
document.getElementsByClassName('tiktok-embed')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tikTokPlaceholders.length > 0) {
|
||||||
|
;(async () => {
|
||||||
|
const tkScriptUrl = 'https://www.tiktok.com/embed.js'
|
||||||
|
const tkScriptWindowFieldName = 'tiktok'
|
||||||
|
const tkScriptName = tkScriptWindowFieldName
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
if (!loadjs.isDefined(tkScriptName)) {
|
||||||
|
loadjs(tkScriptUrl, tkScriptName)
|
||||||
|
}
|
||||||
|
loadjs.ready(tkScriptName, {
|
||||||
|
success: () => {
|
||||||
|
if (window.tiktokEmbed) {
|
||||||
|
window.tiktokEmbed.lib.render(tikTokPlaceholders)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(true)
|
||||||
|
},
|
||||||
|
error: () => reject(new Error('Could not load TikTok handler')),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get all images with initial sizes, if they are small
|
// Get all images with initial sizes, if they are small
|
||||||
// make sure they get displayed small
|
// make sure they get displayed small
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
const ContentSecurityPolicy = `
|
const ContentSecurityPolicy = `
|
||||||
default-src 'self';
|
default-src 'self';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com;
|
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com wss://www.tiktok.com;
|
||||||
font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com;
|
font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com;
|
||||||
form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com;
|
form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com;
|
||||||
frame-ancestors 'none';
|
frame-ancestors 'none';
|
||||||
frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/ https://www.recaptcha.net;
|
frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/ https://www.recaptcha.net https://www.tiktok.com;
|
||||||
manifest-src 'self';
|
manifest-src 'self';
|
||||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net https://www.gstatic.cn/;
|
script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net https://www.gstatic.cn/ https://*.neutral.ttwstatic.com https://www.tiktok.com/embed.js;
|
||||||
style-src 'self' 'unsafe-inline' https://accounts.google.com https://cdnjs.cloudflare.com;
|
style-src 'self' 'unsafe-inline' https://accounts.google.com https://cdnjs.cloudflare.com https://*.neutral.ttwstatic.com;
|
||||||
img-src 'self' blob: data: https:;
|
img-src 'self' blob: data: https:;
|
||||||
worker-src 'self' blob:;
|
worker-src 'self' blob:;
|
||||||
media-src https://js.intercomcdn.com;
|
media-src https://js.intercomcdn.com;
|
||||||
|
|||||||
1
packages/web/public/static/scripts/tiktok-embed.js
Normal file
1
packages/web/public/static/scripts/tiktok-embed.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user