mirror of
https://github.com/misskey-dev/summerflare.git
synced 2025-04-29 02:37:17 +09:00
feat: add oEmbed support
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
This commit is contained in:
parent
0cfacfeacc
commit
8fa8ab8bc7
@ -12,6 +12,7 @@
|
|||||||
"html-entities": "^2.5.2",
|
"html-entities": "^2.5.2",
|
||||||
"jschardet": "^3.1.2",
|
"jschardet": "^3.1.2",
|
||||||
"summaly": "^2.7.0",
|
"summaly": "^2.7.0",
|
||||||
"whatwg-mimetype": "^4.0.0"
|
"whatwg-mimetype": "^4.0.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -16,6 +16,9 @@ dependencies:
|
|||||||
whatwg-mimetype:
|
whatwg-mimetype:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
|
zod:
|
||||||
|
specifier: ^3.23.8
|
||||||
|
version: 3.23.8
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/vitest-pool-workers':
|
'@cloudflare/vitest-pool-workers':
|
||||||
@ -58,7 +61,7 @@ packages:
|
|||||||
miniflare: 3.20240404.0
|
miniflare: 3.20240404.0
|
||||||
vitest: 1.3.0
|
vitest: 1.3.0
|
||||||
wrangler: 3.48.0(@cloudflare/workers-types@4.20240405.0)
|
wrangler: 3.48.0(@cloudflare/workers-types@4.20240405.0)
|
||||||
zod: 3.22.4
|
zod: 3.23.8
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@cloudflare/workers-types'
|
- '@cloudflare/workers-types'
|
||||||
- bufferutil
|
- bufferutil
|
||||||
@ -1692,7 +1695,7 @@ packages:
|
|||||||
workerd: 1.20240404.0
|
workerd: 1.20240404.0
|
||||||
ws: 8.16.0
|
ws: 8.16.0
|
||||||
youch: 3.3.3
|
youch: 3.3.3
|
||||||
zod: 3.22.4
|
zod: 3.23.8
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- supports-color
|
- supports-color
|
||||||
@ -2396,6 +2399,5 @@ packages:
|
|||||||
stacktracey: 2.1.8
|
stacktracey: 2.1.8
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/zod@3.22.4:
|
/zod@3.23.8:
|
||||||
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
|
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||||
dev: true
|
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
export const cf = {
|
export const fetchOptions = {
|
||||||
cacheEverything: true,
|
cf: {
|
||||||
cacheTtlByStatus: {
|
cacheEverything: true,
|
||||||
"200-299": 86400,
|
cacheTtlByStatus: {
|
||||||
"400-599": 60,
|
"200-299": 86400,
|
||||||
|
"400-599": 60,
|
||||||
|
},
|
||||||
|
} satisfies RequestInitCfProperties,
|
||||||
|
headers: {
|
||||||
|
Accept: "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8",
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; Summerflare; +https://github.com/misskey-dev/summerflare)",
|
||||||
},
|
},
|
||||||
} satisfies RequestInitCfProperties
|
}
|
||||||
|
45
src/index.ts
45
src/index.ts
@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { cf } from "./config"
|
import { fetchOptions } from "./config"
|
||||||
import { normalize } from "./encoding"
|
import { normalize } from "./encoding"
|
||||||
import summary from "./summary"
|
import summary from "./summary"
|
||||||
export interface Env {
|
export interface Env {
|
||||||
@ -30,13 +30,7 @@ app.get("/url", async (context) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return context.json({ error: "Invalid URL" }, 400)
|
return context.json({ error: "Invalid URL" }, 400)
|
||||||
}
|
}
|
||||||
const response = (await fetch(url, {
|
const response = (await fetch(url, fetchOptions)) as any as Response
|
||||||
cf,
|
|
||||||
headers: {
|
|
||||||
Accept: "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8",
|
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; Summerflare; +https://github.com/misskey-dev/summerflare)",
|
|
||||||
},
|
|
||||||
})) as any as Response
|
|
||||||
url = new URL(response.url)
|
url = new URL(response.url)
|
||||||
const rewriter = new HTMLRewriter()
|
const rewriter = new HTMLRewriter()
|
||||||
const summarized = summary(url, rewriter)
|
const summarized = summary(url, rewriter)
|
||||||
@ -61,11 +55,11 @@ if (import.meta.vitest) {
|
|||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
description: null,
|
description: null,
|
||||||
player: {
|
player: {
|
||||||
|
allow: [],
|
||||||
url: null,
|
url: null,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
},
|
},
|
||||||
allow: [],
|
|
||||||
sitename: "example.com",
|
sitename: "example.com",
|
||||||
icon: "https://example.com/favicon.ico",
|
icon: "https://example.com/favicon.ico",
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
@ -81,11 +75,11 @@ if (import.meta.vitest) {
|
|||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
description: null,
|
description: null,
|
||||||
player: {
|
player: {
|
||||||
|
allow: [],
|
||||||
url: null,
|
url: null,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
},
|
},
|
||||||
allow: [],
|
|
||||||
sitename: "abehiroshi.la.coocan.jp",
|
sitename: "abehiroshi.la.coocan.jp",
|
||||||
icon: "http://abehiroshi.la.coocan.jp/favicon.ico",
|
icon: "http://abehiroshi.la.coocan.jp/favicon.ico",
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
@ -101,11 +95,11 @@ if (import.meta.vitest) {
|
|||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
description: null,
|
description: null,
|
||||||
player: {
|
player: {
|
||||||
|
allow: [],
|
||||||
url: null,
|
url: null,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
},
|
},
|
||||||
allow: [],
|
|
||||||
sitename: "www.postgresql.jp",
|
sitename: "www.postgresql.jp",
|
||||||
icon: "https://www.postgresql.jp/favicon.ico",
|
icon: "https://www.postgresql.jp/favicon.ico",
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
@ -120,8 +114,12 @@ if (import.meta.vitest) {
|
|||||||
title: "アイドルマスター ミリオンライブ! 第1幕 パンフレット",
|
title: "アイドルマスター ミリオンライブ! 第1幕 パンフレット",
|
||||||
thumbnail: "https://store.shochiku.co.jp/img/goods/S/23080501s.jpg",
|
thumbnail: "https://store.shochiku.co.jp/img/goods/S/23080501s.jpg",
|
||||||
description: "映画グッズ・アニメグッズを取り扱う通販サイト『Froovie/フルービー』です。ハリー・ポッター、ファンタスティック・ビースト、ガンダム、アニメなどのキャラクターグッズを多数揃えております。",
|
description: "映画グッズ・アニメグッズを取り扱う通販サイト『Froovie/フルービー』です。ハリー・ポッター、ファンタスティック・ビースト、ガンダム、アニメなどのキャラクターグッズを多数揃えております。",
|
||||||
player: { url: null, width: null, height: null },
|
player: {
|
||||||
allow: [],
|
allow: [],
|
||||||
|
url: null,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
},
|
||||||
sitename: "SHOCHIKU STORE | 松竹ストア",
|
sitename: "SHOCHIKU STORE | 松竹ストア",
|
||||||
icon: "https://store.shochiku.co.jp/favicon.ico",
|
icon: "https://store.shochiku.co.jp/favicon.ico",
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
@ -129,6 +127,26 @@ if (import.meta.vitest) {
|
|||||||
url: "https://store.shochiku.co.jp/shop/g/g23080501/",
|
url: "https://store.shochiku.co.jp/shop/g/g23080501/",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"the UTF-8 encoded website with oEmbed",
|
||||||
|
"https://open.spotify.com/intl-ja/track/5Odr16TvEN4my22K9nbH7l",
|
||||||
|
{
|
||||||
|
description: "May'n · Song · 2012",
|
||||||
|
icon: "https://open.spotifycdn.com/cdn/images/favicon.0f31d2ea.ico",
|
||||||
|
large: false,
|
||||||
|
player: {
|
||||||
|
allow: ["autoplay", "clipboard-write", "encrypted-media", "fullscreen", "picture-in-picture"],
|
||||||
|
height: 152,
|
||||||
|
url: "https://open.spotify.com/embed/track/5Odr16TvEN4my22K9nbH7l?utm_source=oembed",
|
||||||
|
width: 456,
|
||||||
|
},
|
||||||
|
sensitive: false,
|
||||||
|
sitename: "Spotify",
|
||||||
|
thumbnail: "https://i.scdn.co/image/ab67616d0000b273357d721f236b923d864f1c2e",
|
||||||
|
title: "Brain Diver",
|
||||||
|
url: "https://open.spotify.com/track/5Odr16TvEN4my22K9nbH7l",
|
||||||
|
},
|
||||||
|
],
|
||||||
])("should return summary of %s <%s>", async (_, url, expected) => {
|
])("should return summary of %s <%s>", async (_, url, expected) => {
|
||||||
const request = new Request(`https://fakehost/url?${new URLSearchParams({ url })}`)
|
const request = new Request(`https://fakehost/url?${new URLSearchParams({ url })}`)
|
||||||
const ctx = createExecutionContext()
|
const ctx = createExecutionContext()
|
||||||
@ -136,7 +154,6 @@ if (import.meta.vitest) {
|
|||||||
await waitOnExecutionContext(ctx)
|
await waitOnExecutionContext(ctx)
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
const body = await response.json()
|
const body = await response.json()
|
||||||
console.log(body)
|
|
||||||
expect(body).toStrictEqual(expected)
|
expect(body).toStrictEqual(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -48,7 +48,6 @@ export default function amazon(url: URL, html: HTMLRewriter) {
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
description: title === description ? null : description,
|
description: title === description ? null : description,
|
||||||
player,
|
player,
|
||||||
allow: [],
|
|
||||||
sitename: siteName,
|
sitename: siteName,
|
||||||
icon: favicon,
|
icon: favicon,
|
||||||
sensitive,
|
sensitive,
|
||||||
|
@ -3,32 +3,21 @@ import getCard from "./card"
|
|||||||
import getDescription from "./description"
|
import getDescription from "./description"
|
||||||
import getFavicon from "./favicon"
|
import getFavicon from "./favicon"
|
||||||
import getImage from "./image"
|
import getImage from "./image"
|
||||||
import getPlayerUrlCommon from "./playerUrlCommon"
|
|
||||||
import getPlayerUrlGeneral from "./playerUrlGeneral"
|
|
||||||
import getPlayerUrlHeight from "./playerUrlHeight"
|
|
||||||
import getPlayerUrlWidth from "./playerUrlWidth"
|
|
||||||
import getSiteName from "./siteName"
|
import getSiteName from "./siteName"
|
||||||
import getTitle from "./title"
|
import getTitle from "./title"
|
||||||
import getSensitive from "./sensitive"
|
import getSensitive from "./sensitive"
|
||||||
|
import getPlayer, { Player } from "./player"
|
||||||
|
|
||||||
export default function general(url: URL, html: HTMLRewriter) {
|
export default function general(url: URL, html: HTMLRewriter) {
|
||||||
const card = getCard(url, html)
|
const card = getCard(url, html)
|
||||||
const title = getTitle(url, html)
|
const title = getTitle(url, html)
|
||||||
const image = getImage(url, html)
|
const image = getImage(url, html)
|
||||||
const player = Promise.all([card, getPlayerUrlGeneral(url, html), getPlayerUrlCommon(url, html), getPlayerUrlWidth(url, html), getPlayerUrlHeight(url, html)]).then(([card, general, common, width, height]) => {
|
const player = Promise.all([card, getPlayer(url, html)]).then<Player>(([card, parsedPlayer]) => {
|
||||||
const url = (card !== "summary_large_image" && general) || common
|
return {
|
||||||
if (url !== null && width !== null && height !== null) {
|
url: card !== "summary_large_image" && parsedPlayer.urlGeneral || parsedPlayer.urlCommon,
|
||||||
return {
|
width: parsedPlayer.width,
|
||||||
url,
|
height: parsedPlayer.height,
|
||||||
width,
|
allow: parsedPlayer.allow,
|
||||||
height,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
url: null,
|
|
||||||
width: null,
|
|
||||||
height: null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const description = getDescription(url, html)
|
const description = getDescription(url, html)
|
||||||
@ -48,7 +37,6 @@ export default function general(url: URL, html: HTMLRewriter) {
|
|||||||
thumbnail: image,
|
thumbnail: image,
|
||||||
description: title === description ? null : description,
|
description: title === description ? null : description,
|
||||||
player,
|
player,
|
||||||
allow: [],
|
|
||||||
sitename: siteName,
|
sitename: siteName,
|
||||||
icon: favicon,
|
icon: favicon,
|
||||||
sensitive,
|
sensitive,
|
||||||
|
38
src/summary/general/player.ts
Normal file
38
src/summary/general/player.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import getPlayerOEmbed from "./playerOEmbed"
|
||||||
|
import getPlayerUrlCommon from "./playerUrlCommon"
|
||||||
|
import getPlayerUrlGeneral from "./playerUrlGeneral"
|
||||||
|
import getPlayerUrlHeight from "./playerUrlHeight"
|
||||||
|
import getPlayerUrlWidth from "./playerUrlWidth"
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
url: string | null
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
allow: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedPlayer extends Omit<Player, "url"> {
|
||||||
|
urlCommon: string | null
|
||||||
|
urlGeneral: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function getPlayer(url: URL, html: HTMLRewriter): Promise<ParsedPlayer> {
|
||||||
|
const oEmbed = getPlayerOEmbed(url, html)
|
||||||
|
const urlGeneral = getPlayerUrlGeneral(url, html)
|
||||||
|
const urlCommon = getPlayerUrlCommon(url, html)
|
||||||
|
const width = getPlayerUrlWidth(url, html)
|
||||||
|
const height = getPlayerUrlHeight(url, html)
|
||||||
|
|
||||||
|
return Promise.all([oEmbed, urlGeneral, urlCommon, width, height]).then(([oEmbed, urlGeneral, urlCommon, width, height]) => {
|
||||||
|
if (oEmbed) {
|
||||||
|
return oEmbed
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
urlCommon,
|
||||||
|
urlGeneral,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
allow: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
124
src/summary/general/playerOembed.ts
Normal file
124
src/summary/general/playerOembed.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { decode } from "html-entities"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { fetchOptions } from "../../config"
|
||||||
|
import { assign, PrioritizedReference } from "../common"
|
||||||
|
import type { ParsedPlayer } from "./player"
|
||||||
|
|
||||||
|
const oEmbedBase = z.object({
|
||||||
|
type: z.enum(["photo", "video", "link", "rich"]),
|
||||||
|
version: z.literal("1.0"),
|
||||||
|
title: z.string().optional(),
|
||||||
|
author_name: z.string().optional(),
|
||||||
|
author_url: z.string().optional(),
|
||||||
|
provider_name: z.string().optional(),
|
||||||
|
provider_url: z.string().optional(),
|
||||||
|
cache_age: z.number().optional(),
|
||||||
|
thumbnail_url: z.string().optional(),
|
||||||
|
thumbnail_width: z.number().optional(),
|
||||||
|
thumbnail_height: z.number().optional(),
|
||||||
|
})
|
||||||
|
const oEmbed = z.union([
|
||||||
|
oEmbedBase.extend({
|
||||||
|
type: z.literal("photo"),
|
||||||
|
url: z.string(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
oEmbedBase.extend({
|
||||||
|
type: z.literal("video"),
|
||||||
|
html: z.string(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
oEmbedBase.extend({
|
||||||
|
type: z.literal("link"),
|
||||||
|
}),
|
||||||
|
oEmbedBase.extend({
|
||||||
|
type: z.literal("rich"),
|
||||||
|
html: z.string(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
export default function getPlayerOEmbed(url: URL, html: HTMLRewriter) {
|
||||||
|
const result: PrioritizedReference<ParsedPlayer> = {
|
||||||
|
bits: 1, // 0-1
|
||||||
|
priority: 0,
|
||||||
|
content: {
|
||||||
|
urlCommon: null,
|
||||||
|
urlGeneral: null,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
allow: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html.on('link[type="application/json+oembed"]', {
|
||||||
|
async element(element) {
|
||||||
|
const oEmbedHref = decode(element.getAttribute("href") ?? "")
|
||||||
|
if (!oEmbedHref) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(oEmbedHref)
|
||||||
|
const oEmbedData: unknown = await fetch(oEmbedHref, fetchOptions)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.catch(() => undefined)
|
||||||
|
const { success, data } = oEmbed.safeParse(oEmbedData)
|
||||||
|
console.log(oEmbedData, success, data)
|
||||||
|
if (!success) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const html = new HTMLRewriter()
|
||||||
|
html.on("iframe", {
|
||||||
|
element(element) {
|
||||||
|
const allowValue = element.getAttribute("allow")
|
||||||
|
const allow =
|
||||||
|
(allowValue &&
|
||||||
|
decode(allowValue)
|
||||||
|
?.replace(/^\s*|\s*$/g, "")
|
||||||
|
.split(/\s*;\s*/)
|
||||||
|
.sort()) ||
|
||||||
|
[]
|
||||||
|
const srcValue = element.getAttribute("src")
|
||||||
|
const src = srcValue ? decode(srcValue) : null
|
||||||
|
switch (data.type) {
|
||||||
|
case "video":
|
||||||
|
case "rich": {
|
||||||
|
assign(result, 1, {
|
||||||
|
urlCommon: src,
|
||||||
|
urlGeneral: null,
|
||||||
|
width: data.width,
|
||||||
|
height: data.height,
|
||||||
|
allow,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
switch (data.type) {
|
||||||
|
case "video":
|
||||||
|
case "rich": {
|
||||||
|
const reader = html
|
||||||
|
.transform(
|
||||||
|
new Response(data.html, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=UTF-8",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.body?.getReader()
|
||||||
|
while (reader != null && !(await reader.read()).done);
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return new Promise<ParsedPlayer>((resolve) => {
|
||||||
|
html.onDocument({
|
||||||
|
end() {
|
||||||
|
resolve(result.content)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
@ -16,7 +16,6 @@ export default async function wikipedia(url: URL, html: HTMLRewriter) {
|
|||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
},
|
},
|
||||||
allow: [],
|
|
||||||
sitename: "Wikipedia",
|
sitename: "Wikipedia",
|
||||||
url: url.href,
|
url: url.href,
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user