diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a5214e..12b2c18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,4 +17,4 @@ jobs: - name: Run type check run: deno check --remote ./**/*.ts - name: Run test - run: deno test + run: deno test --allow-read --allow-write diff --git a/.github/workflows/udd.yml b/.github/workflows/udd.yml index a427b04..b2ea0bc 100644 --- a/.github/workflows/udd.yml +++ b/.github/workflows/udd.yml @@ -17,7 +17,7 @@ jobs: run: > deno run --allow-net --allow-read --allow-write=deps/ --allow-run=deno https://deno.land/x/udd@0.7.2/main.ts deps/*.ts - --test="deno test" + --test="deno test --allow-read --allow-write" - name: Create Pull Request uses: peter-evans/create-pull-request@v3 with: diff --git a/browser/websocket/makeChanges.ts b/browser/websocket/makeChanges.ts index 65850f8..fa07cd2 100644 --- a/browser/websocket/makeChanges.ts +++ b/browser/websocket/makeChanges.ts @@ -10,7 +10,7 @@ import { import type { Change } from "../../deps/socket.ts"; import type { HeadData } from "./pull.ts"; import { toTitleLc } from "../../title.ts"; -import { parseYoutube } from "../../parseYoutube.ts"; +import { parseYoutube } from "../../parser/youtube.ts"; export interface Init { userId: string; @@ -95,7 +95,7 @@ const findLinksAndImage = (text: string): [string[], string | null] => { return; case "absolute": { const props = parseYoutube(node.href); - if (!props) return; + if (!props || props.pathType === "list") return; image ??= `https://i.ytimg.com/vi/${props.videoId}/mqdefault.jpg`; return; } diff --git a/deps/testing.ts b/deps/testing.ts index ceb9e2b..6641496 100644 --- a/deps/testing.ts +++ b/deps/testing.ts @@ -1 +1,2 @@ export * from "https://deno.land/std@0.170.0/testing/asserts.ts"; +export * from "https://deno.land/std@0.170.0/testing/snapshot.ts"; diff --git a/mod.ts b/mod.ts index 466b9e7..a7496cc 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,4 @@ export * from "./rest/mod.ts"; export * from "./browser/mod.ts"; export * from "./title.ts"; +export * from "./parseAbsoluteLink.ts"; diff --git a/parseAbsoluteLink.ts b/parseAbsoluteLink.ts new file mode 100644 index 0000000..576bf5b --- /dev/null +++ b/parseAbsoluteLink.ts @@ -0,0 +1,121 @@ +import type { LinkNode } from "./deps/scrapbox.ts"; +import { parseYoutube } from "./parser/youtube.ts"; +import { parseVimeo } from "./parser/vimeo.ts"; +import { parseSpotify } from "./parser/spotify.ts"; +import { parseAnchorFM } from "./parser/anchor-fm.ts"; + +export type { LinkNode }; + +export interface AbsoluteLinkNode { + type: "absoluteLink"; + content: string; + href: string; + raw: string; +} + +/** Youtube埋め込み */ +export interface YoutubeNode { + type: "youtube"; + videoId: string; + pathType: "com" | "dotbe" | "short"; + params: URLSearchParams; + href: string; + raw: string; +} + +/** Youtube List埋め込み */ +export interface YoutubeListNode { + type: "youtube"; + listId: string; + pathType: "list"; + params: URLSearchParams; + href: string; + raw: string; +} + +/** Vimeo埋め込み */ +export interface VimeoNode { + type: "vimeo"; + videoId: string; + href: string; + raw: string; +} + +/** Spotify埋め込み */ +export interface SpotifyNode { + type: "spotify"; + videoId: string; + pathType: "track" | "artist" | "playlist" | "album" | "episode" | "show"; + href: string; + raw: string; +} + +/** Anchor FM埋め込み */ +export interface AnchorFMNode { + type: "anchor-fm"; + videoId: string; + href: string; + raw: string; +} + +/** 動画埋め込み */ +export interface VideoNode { + type: "video"; + href: VideoURL; + raw: string; +} + +/** 音声埋め込み */ +export interface AudioNode { + type: "audio"; + content: string; + href: AudioURL; + raw: string; +} + +/** scrapbox-parserで解析した外部リンク記法を、埋め込み形式ごとに細かく解析する + * + * @param link scrapbox-parserで解析した外部リンク記法のobject + * @return 解析した記法のobject + */ +export const parseAbsoluteLink = ( + link: LinkNode & { pathType: "absolute" }, +): + | AbsoluteLinkNode + | VideoNode + | AudioNode + | YoutubeNode + | YoutubeListNode + | VimeoNode + | SpotifyNode + | AnchorFMNode => { + const { type: _, pathType: __, content, href, ...baseLink } = link; + if (content === "") { + const youtube = parseYoutube(href); + if (youtube) return { type: "youtube", href, ...youtube, ...baseLink }; + + const vimeoId = parseVimeo(href); + if (vimeoId) return { type: "vimeo", videoId: vimeoId, href, ...baseLink }; + + const spotify = parseSpotify(href); + if (spotify) return { type: "spotify", href, ...spotify, ...baseLink }; + + const anchorFMId = parseAnchorFM(href); + if (anchorFMId) { + return { type: "anchor-fm", videoId: anchorFMId, href, ...baseLink }; + } + + if (isVideoURL(href)) return { type: "video", href, ...baseLink }; + } + if (isAudioURL(href)) return { type: "audio", content, href, ...baseLink }; + + return { type: "absoluteLink", content, href, ...baseLink }; +}; + +type AudioURL = `${string}.${"mp3" | "ogg" | "wav" | "acc"}`; +const isAudioURL = (url: string): url is AudioURL => + /\.(?:mp3|ogg|wav|aac)$/.test(url); + +type VideoURL = `${string}.${"mp4" | "webm"}`; +const isVideoURL = (url: string): url is VideoURL => + /\.(?:mp4|webm)$/.test(url); diff --git a/parser/__snapshots__/anchor-fm.test.ts.snap b/parser/__snapshots__/anchor-fm.test.ts.snap new file mode 100644 index 0000000..d916438 --- /dev/null +++ b/parser/__snapshots__/anchor-fm.test.ts.snap @@ -0,0 +1,11 @@ +export const snapshot = {}; + +snapshot[`spotify links > is 1`] = `"1-FM-e1gh6a7/a-a7m2veg"`; + +snapshot[`spotify links > is not 1`] = `undefined`; + +snapshot[`spotify links > is not 2`] = `undefined`; + +snapshot[`spotify links > is not 3`] = `undefined`; + +snapshot[`spotify links > is not 4`] = `undefined`; diff --git a/parser/__snapshots__/spotify.test.ts.snap b/parser/__snapshots__/spotify.test.ts.snap new file mode 100644 index 0000000..ad2724b --- /dev/null +++ b/parser/__snapshots__/spotify.test.ts.snap @@ -0,0 +1,37 @@ +export const snapshot = {}; + +snapshot[`spotify links > is 1`] = ` +{ + pathType: "track", + videoId: "0rlYL6IQIwLZwYIguyy3l0", +} +`; + +snapshot[`spotify links > is 2`] = ` +{ + pathType: "album", + videoId: "1bgUOjg3V0a7tvEfF1N6Kk", +} +`; + +snapshot[`spotify links > is 3`] = ` +{ + pathType: "episode", + videoId: "0JtPGoprZK2WlYMjhFF2xD", +} +`; + +snapshot[`spotify links > is 4`] = ` +{ + pathType: "playlist", + videoId: "2uOyQytSjDq9GF5z1RJj5w", +} +`; + +snapshot[`spotify links > is not 1`] = `undefined`; + +snapshot[`spotify links > is not 2`] = `undefined`; + +snapshot[`spotify links > is not 3`] = `undefined`; + +snapshot[`spotify links > is not 4`] = `undefined`; diff --git a/parser/__snapshots__/vimeo.test.ts.snap b/parser/__snapshots__/vimeo.test.ts.snap new file mode 100644 index 0000000..5778eff --- /dev/null +++ b/parser/__snapshots__/vimeo.test.ts.snap @@ -0,0 +1,11 @@ +export const snapshot = {}; + +snapshot[`vimeo links > is 1`] = `"121284607"`; + +snapshot[`vimeo links > is not 1`] = `undefined`; + +snapshot[`vimeo links > is not 2`] = `undefined`; + +snapshot[`vimeo links > is not 3`] = `undefined`; + +snapshot[`vimeo links > is not 4`] = `undefined`; diff --git a/parser/__snapshots__/youtube.test.ts.snap b/parser/__snapshots__/youtube.test.ts.snap new file mode 100644 index 0000000..1f47445 --- /dev/null +++ b/parser/__snapshots__/youtube.test.ts.snap @@ -0,0 +1,130 @@ +export const snapshot = {}; + +snapshot[`youtube links > is 1`] = ` +{ + params: URLSearchParams { + [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), + [Symbol(list)]: [ + [ + "v", + "LSvaOcaUQ3Y", + ], + ], + [Symbol("url object")]: URL { + hash: "", + host: "www.youtube.com", + hostname: "www.youtube.com", + href: "https://www.youtube.com/watch?v=LSvaOcaUQ3Y", + origin: "https://www.youtube.com", + password: "", + pathname: "/watch", + port: "", + protocol: "https:", + search: "?v=LSvaOcaUQ3Y", + username: "", + }, + }, + pathType: "com", + videoId: "LSvaOcaUQ3Y", +} +`; + +snapshot[`youtube links > is 2`] = ` +{ + listId: "PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + params: URLSearchParams { + [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), + [Symbol(list)]: [ + [ + "list", + "PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + ], + ], + [Symbol("url object")]: null, + }, + pathType: "list", +} +`; + +snapshot[`youtube links > is 3`] = ` +{ + params: URLSearchParams { + [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), + [Symbol(list)]: [ + [ + "v", + "57rdbK4vmKE", + ], + [ + "list", + "PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + ], + ], + [Symbol("url object")]: URL { + hash: "", + host: "www.youtube.com", + hostname: "www.youtube.com", + href: "https://www.youtube.com/watch?v=57rdbK4vmKE&list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + origin: "https://www.youtube.com", + password: "", + pathname: "/watch", + port: "", + protocol: "https:", + search: "?v=57rdbK4vmKE&list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + username: "", + }, + }, + pathType: "com", + videoId: "57rdbK4vmKE", +} +`; + +snapshot[`youtube links > is 4`] = ` +{ + params: URLSearchParams { + [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), + [Symbol(list)]: [ + [ + "v", + "nj1cre2e6t0", + ], + ], + [Symbol("url object")]: URL { + hash: "", + host: "music.youtube.com", + hostname: "music.youtube.com", + href: "https://music.youtube.com/watch?v=nj1cre2e6t0", + origin: "https://music.youtube.com", + password: "", + pathname: "/watch", + port: "", + protocol: "https:", + search: "?v=nj1cre2e6t0", + username: "", + }, + }, + pathType: "com", + videoId: "nj1cre2e6t0", +} +`; + +snapshot[`youtube links > is 5`] = ` +{ + params: URLSearchParams { + [Symbol("[[webidl.brand]]")]: Symbol("[[webidl.brand]]"), + [Symbol(list)]: [ + ], + [Symbol("url object")]: null, + }, + pathType: "dotbe", + videoId: "nj1cre2e6t0", +} +`; + +snapshot[`youtube links > is not 1`] = `undefined`; + +snapshot[`youtube links > is not 2`] = `undefined`; + +snapshot[`youtube links > is not 3`] = `undefined`; + +snapshot[`youtube links > is not 4`] = `undefined`; diff --git a/parser/anchor-fm.test.ts b/parser/anchor-fm.test.ts new file mode 100644 index 0000000..fdbe9af --- /dev/null +++ b/parser/anchor-fm.test.ts @@ -0,0 +1,40 @@ +import { parseAnchorFM } from "./anchor-fm.ts"; +import { assertSnapshot } from "../deps/testing.ts"; + +Deno.test("spotify links", async (t) => { + await t.step("is", async (t) => { + await assertSnapshot( + t, + parseAnchorFM( + "https://anchor.fm/notainc/episodes/1-FM-e1gh6a7/a-a7m2veg", + ), + ); + }); + + await t.step("is not", async (t) => { + await assertSnapshot( + t, + parseAnchorFM( + "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", + ), + ); + await assertSnapshot( + t, + parseAnchorFM( + "ほげほげ", + ), + ); + await assertSnapshot( + t, + parseAnchorFM( + "https://yourtube.com/watch?v=rafere", + ), + ); + await assertSnapshot( + t, + parseAnchorFM( + "https://example.com", + ), + ); + }); +}); diff --git a/parser/anchor-fm.ts b/parser/anchor-fm.ts new file mode 100644 index 0000000..17ab2bf --- /dev/null +++ b/parser/anchor-fm.ts @@ -0,0 +1,15 @@ +const AnchorFMRegExp = + /https?:\/\/anchor\.fm\/[a-zA-Z\d_-]+\/episodes\/([a-zA-Z\d_-]+(?:\/[a-zA-Z\d_-]+)?)(?:\?[^\s]{0,100}|)/; + +/** anchorFMのURLからIDを取得する + * + * @param url + * @return ID anchorFMのURLでなければ`undefined`を返す + */ +export const parseAnchorFM = (url: string): string | undefined => { + const matches = url.match(AnchorFMRegExp); + if (!matches) return undefined; + + const [, videoId] = matches; + return videoId; +}; diff --git a/parser/spotify.test.ts b/parser/spotify.test.ts new file mode 100644 index 0000000..cc4c780 --- /dev/null +++ b/parser/spotify.test.ts @@ -0,0 +1,54 @@ +import { parseSpotify } from "./spotify.ts"; +import { assertSnapshot } from "../deps/testing.ts"; + +Deno.test("spotify links", async (t) => { + await t.step("is", async (t) => { + await assertSnapshot( + t, + parseSpotify("https://open.spotify.com/track/0rlYL6IQIwLZwYIguyy3l0"), + ); + await assertSnapshot( + t, + parseSpotify("https://open.spotify.com/album/1bgUOjg3V0a7tvEfF1N6Kk"), + ); + await assertSnapshot( + t, + parseSpotify( + "https://open.spotify.com/episode/0JtPGoprZK2WlYMjhFF2xD?si=1YLMdgNpSHOuWkaEmCAQ0g", + ), + ); + await assertSnapshot( + t, + parseSpotify( + "https://open.spotify.com/playlist/2uOyQytSjDq9GF5z1RJj5w?si=e73cac2a2a294f7a", + ), + ); + }); + + await t.step("is not", async (t) => { + await assertSnapshot( + t, + parseSpotify( + "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", + ), + ); + await assertSnapshot( + t, + parseSpotify( + "ほげほげ", + ), + ); + await assertSnapshot( + t, + parseSpotify( + "https://yourtube.com/watch?v=rafere", + ), + ); + await assertSnapshot( + t, + parseSpotify( + "https://example.com", + ), + ); + }); +}); diff --git a/parser/spotify.ts b/parser/spotify.ts new file mode 100644 index 0000000..3e8009d --- /dev/null +++ b/parser/spotify.ts @@ -0,0 +1,22 @@ +const spotifyRegExp = + /https?:\/\/open\.spotify\.com\/(track|artist|playlist|album|episode|show)\/([a-zA-Z\d_-]+)(?:\?[^\s]{0,100}|)/; +export interface SpotifyProps { + videoId: string; + pathType: "track" | "artist" | "playlist" | "album" | "episode" | "show"; +} + +/** SpotifyのURLを解析してaudio IDなどを取り出す + * + * @param url SpotifyのURL + * @return 解析結果 SpotifyのURLでなかったときは`undefined`を返す + */ +export const parseSpotify = (url: string): SpotifyProps | undefined => { + const matches = url.match(spotifyRegExp); + if (!matches) return undefined; + + const [, pathType, videoId] = matches; + return { + videoId, + pathType, + } as SpotifyProps; +}; diff --git a/parser/vimeo.test.ts b/parser/vimeo.test.ts new file mode 100644 index 0000000..80bf1bc --- /dev/null +++ b/parser/vimeo.test.ts @@ -0,0 +1,38 @@ +import { parseVimeo } from "./vimeo.ts"; +import { assertSnapshot } from "../deps/testing.ts"; + +Deno.test("vimeo links", async (t) => { + await t.step("is", async (t) => { + await assertSnapshot( + t, + parseVimeo("https://vimeo.com/121284607"), + ); + }); + + await t.step("is not", async (t) => { + await assertSnapshot( + t, + parseVimeo( + "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", + ), + ); + await assertSnapshot( + t, + parseVimeo( + "ほげほげ", + ), + ); + await assertSnapshot( + t, + parseVimeo( + "https://yourtube.com/watch?v=rafere", + ), + ); + await assertSnapshot( + t, + parseVimeo( + "https://example.com", + ), + ); + }); +}); diff --git a/parser/vimeo.ts b/parser/vimeo.ts new file mode 100644 index 0000000..aaa19e4 --- /dev/null +++ b/parser/vimeo.ts @@ -0,0 +1,12 @@ +const vimeoRegExp = /https?:\/\/vimeo\.com\/([0-9]+)/i; + +/** vimeoのURLからvideo IDを取得する + * + * @param url + * @return video ID vimeoのURLでなければ`undefined`を返す + */ +export const parseVimeo = (url: string): string | undefined => { + const matches = url.match(vimeoRegExp); + if (!matches) return undefined; + return matches[1]; +}; diff --git a/parser/youtube.test.ts b/parser/youtube.test.ts new file mode 100644 index 0000000..10c96a8 --- /dev/null +++ b/parser/youtube.test.ts @@ -0,0 +1,62 @@ +import { parseYoutube } from "./youtube.ts"; +import { assertSnapshot } from "../deps/testing.ts"; + +Deno.test("youtube links", async (t) => { + await t.step("is", async (t) => { + await assertSnapshot( + t, + parseYoutube("https://www.youtube.com/watch?v=LSvaOcaUQ3Y"), + ); + await assertSnapshot( + t, + parseYoutube( + "https://www.youtube.com/playlist?list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + ), + ); + await assertSnapshot( + t, + parseYoutube( + "https://www.youtube.com/watch?v=57rdbK4vmKE&list=PLmoRDY8IgE2Okxy4WWdP95RHXOTGzJfQs", + ), + ); + await assertSnapshot( + t, + parseYoutube( + "https://music.youtube.com/watch?v=nj1cre2e6t0", + ), + ); + await assertSnapshot( + t, + parseYoutube( + "https://youtu.be/nj1cre2e6t0", + ), + ); + }); + + await t.step("is not", async (t) => { + await assertSnapshot( + t, + parseYoutube( + "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01", + ), + ); + await assertSnapshot( + t, + parseYoutube( + "ほげほげ", + ), + ); + await assertSnapshot( + t, + parseYoutube( + "https://yourtube.com/watch?v=rafere", + ), + ); + await assertSnapshot( + t, + parseYoutube( + "https://example.com", + ), + ); + }); +}); diff --git a/parseYoutube.ts b/parser/youtube.ts similarity index 52% rename from parseYoutube.ts rename to parser/youtube.ts index 64d5cfa..f17cb96 100644 --- a/parseYoutube.ts +++ b/parser/youtube.ts @@ -1,18 +1,22 @@ // ported from https://github.com/takker99/ScrapBubble/blob/0.4.0/Page.tsx#L662 -export interface YoutubeProps { - params: URLSearchParams; - videoId: string; -} - -const youtubeRegExp = - /https?:\/\/(?:www\.|)youtube\.com\/watch\?((?:[^\s]+&|)v=([a-zA-Z\d_-]+)(?:&[^\s]+|))/; +const youtubeRegExp = /https?:\/\/(?:www\.|music\.|)youtube\.com\/watch/; const youtubeDotBeRegExp = /https?:\/\/youtu\.be\/([a-zA-Z\d_-]+)(?:\?([^\s]{0,100})|)/; const youtubeShortRegExp = /https?:\/\/(?:www\.|)youtube\.com\/shorts\/([a-zA-Z\d_-]+)(?:\?([^\s]+)|)/; const youtubeListRegExp = - /https?:\/\/(?:www\.|)youtube\.com\/playlist\?((?:[^\s]+&|)list=([a-zA-Z\d_-]+)(?:&[^\s]+|))/; + /https?:\/\/(?:www\.|music\.|)youtube\.com\/playlist\?((?:[^\s]+&|)list=([a-zA-Z\d_-]+)(?:&[^\s]+|))/; + +export type YoutubeProps = { + params: URLSearchParams; + videoId: string; + pathType: "com" | "dotbe" | "short"; +} | { + params: URLSearchParams; + listId: string; + pathType: "list"; +}; /** YoutubeのURLを解析してVideo IDなどを取り出す * @@ -20,55 +24,50 @@ const youtubeListRegExp = * @return 解析結果 YoutubeのURLでなかったときは`undefined`を返す */ export const parseYoutube = (url: string): YoutubeProps | undefined => { - { - const matches = url.match(youtubeRegExp); - if (matches) { - const [, params, videoId] = matches; - const _params = new URLSearchParams(params); - _params.delete("v"); - _params.append("autoplay", "0"); + if (youtubeRegExp.test(url)) { + const params = new URL(url).searchParams; + const videoId = params.get("v"); + if (videoId) { return { + pathType: "com", videoId, - params: _params, + params, }; } } + { const matches = url.match(youtubeDotBeRegExp); if (matches) { - const [, videoId] = matches; + const [, videoId, params] = matches; return { videoId, - params: new URLSearchParams("autoplay=0"), + params: new URLSearchParams(params), + pathType: "dotbe", }; } } + { const matches = url.match(youtubeShortRegExp); if (matches) { - const [, videoId] = matches; + const [, videoId, params] = matches; return { videoId, - params: new URLSearchParams("autoplay=0"), + params: new URLSearchParams(params), + pathType: "short", }; } } + { const matches = url.match(youtubeListRegExp); if (matches) { const [, params, listId] = matches; - const _params = new URLSearchParams(params); - const videoId = _params.get("v"); - if (!videoId) return; - _params.delete("v"); - _params.append("autoplay", "0"); - _params.append("list", listId); - return { - videoId, - params: _params, - }; + return { listId, params: new URLSearchParams(params), pathType: "list" }; } } + return undefined; };