From 50cb5deafccf7f09e0fda21e691deaaddc6d56e3 Mon Sep 17 00:00:00 2001 From: newt Date: Fri, 3 Jan 2025 21:04:55 +0900 Subject: [PATCH 01/14] feat: add fetch-ogp route --- src/app/api/fetch-ogp.ts | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/app/api/fetch-ogp.ts diff --git a/src/app/api/fetch-ogp.ts b/src/app/api/fetch-ogp.ts new file mode 100644 index 0000000..dd6c619 --- /dev/null +++ b/src/app/api/fetch-ogp.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; + +type ResponseProps = + | { + status: "success"; + data: { + title: string; + description: string; + image: string; + }; + } + | { + status: "error"; + message: string; + }; + +export async function GET( + request: NextRequest +): Promise> { + const { searchParams } = request.nextUrl; + const url = searchParams.get("url"); + + if (!url) { + return NextResponse.json( + { status: "error", message: "Missing 'url' query parameter" }, + { status: 400 } + ); + } + + try { + const res = await fetch(url, { method: "GET" }); + const html = await res.text(); + + // DOMParserを使用してHTMLを解析 + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + const title = + doc.querySelector("meta[property='og:title']")?.getAttribute("content") || + doc.querySelector("title")?.textContent || + "No Title"; + const description = + doc + .querySelector("meta[property='og:description']") + ?.getAttribute("content") || "No Description"; + const image = + doc.querySelector("meta[property='og:image']")?.getAttribute("content") || + ""; + + return NextResponse.json({ + status: "success", + data: { title, description, image }, + }); + } catch (error) { + console.error("Error fetching OGP data:", error); + return NextResponse.json( + { status: "error", message: "Error fetching OGP data" }, + { status: 500 } + ); + } +} From c8a4a87148f6b86b6ee9072bcc1a0f27d8604b73 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 00:05:59 +0900 Subject: [PATCH 02/14] feat: add LinkCard component --- src/app/api/fetch-ogp.ts | 8 ++++---- src/components/LinkCard.tsx | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 src/components/LinkCard.tsx diff --git a/src/app/api/fetch-ogp.ts b/src/app/api/fetch-ogp.ts index dd6c619..516c654 100644 --- a/src/app/api/fetch-ogp.ts +++ b/src/app/api/fetch-ogp.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -type ResponseProps = +export type FetchOGPResponse = | { status: "success"; data: { @@ -16,13 +16,13 @@ type ResponseProps = export async function GET( request: NextRequest -): Promise> { +): Promise> { const { searchParams } = request.nextUrl; const url = searchParams.get("url"); if (!url) { return NextResponse.json( - { status: "error", message: "Missing 'url' query parameter" }, + { status: "error", message: "Missing 'url' query parameter" } as const, { status: 400 } ); } @@ -54,7 +54,7 @@ export async function GET( } catch (error) { console.error("Error fetching OGP data:", error); return NextResponse.json( - { status: "error", message: "Error fetching OGP data" }, + { status: "error", message: "Error fetching OGP data" } as const, { status: 500 } ); } diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx new file mode 100644 index 0000000..9e2aa51 --- /dev/null +++ b/src/components/LinkCard.tsx @@ -0,0 +1,37 @@ +import { FetchOGPResponse } from "#/app/api/fetch-ogp"; +import { Card, Image, Text } from "@mantine/core"; + +type LinkCardProps = { + href: string; +}; + +const LinkCard: React.FC = async ({ href }) => { + const escapedHref = new URLSearchParams({ url: href }).toString(); + const response: FetchOGPResponse = await ( + await fetch(`/api/fetch-ogp?url=${escapedHref}`) + ).json(); + + return ( + + + No way! + + + + {response.status === "success" ? response.data.title : "No Title"} + + + + {response.status === "success" + ? response.data.description + : "No Description"} + + + ); +}; + +export default LinkCard; From 8d99b5b51a7dca880ae52df1637d1d3098e85555 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 01:28:39 +0900 Subject: [PATCH 03/14] feat: add fetch-ogp route --- .env.example | 1 + src/app/_components/AppShell.tsx | 9 ++- src/app/api/fetch-ogp.ts | 61 --------------- src/app/api/fetch-ogp/route.ts | 37 +++++++++ src/components/LinkCard.module.css | 23 ++++++ src/components/LinkCard.tsx | 118 +++++++++++++++++++++++------ 6 files changed, 162 insertions(+), 87 deletions(-) delete mode 100644 src/app/api/fetch-ogp.ts create mode 100644 src/app/api/fetch-ogp/route.ts create mode 100644 src/components/LinkCard.module.css diff --git a/.env.example b/.env.example index ac44306..e61788e 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ POSTGRES_PASSWORD=postgres POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +URL=http://localhost:3000 \ No newline at end of file diff --git a/src/app/_components/AppShell.tsx b/src/app/_components/AppShell.tsx index 2dd3845..7b377f3 100644 --- a/src/app/_components/AppShell.tsx +++ b/src/app/_components/AppShell.tsx @@ -1,7 +1,8 @@ "use client"; -import React from "react"; +import React, { Suspense } from "react"; +import LinkCard from "#/components/LinkCard"; import { Burger, Group, AppShell as MantineAppShell } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import Sidebar from "./Sidebar"; @@ -35,7 +36,11 @@ const AppShell: React.FC = ({ children }) => { {children} Aside - Footer + + Loading...}> + + + ); }; diff --git a/src/app/api/fetch-ogp.ts b/src/app/api/fetch-ogp.ts deleted file mode 100644 index 516c654..0000000 --- a/src/app/api/fetch-ogp.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export type FetchOGPResponse = - | { - status: "success"; - data: { - title: string; - description: string; - image: string; - }; - } - | { - status: "error"; - message: string; - }; - -export async function GET( - request: NextRequest -): Promise> { - const { searchParams } = request.nextUrl; - const url = searchParams.get("url"); - - if (!url) { - return NextResponse.json( - { status: "error", message: "Missing 'url' query parameter" } as const, - { status: 400 } - ); - } - - try { - const res = await fetch(url, { method: "GET" }); - const html = await res.text(); - - // DOMParserを使用してHTMLを解析 - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - - const title = - doc.querySelector("meta[property='og:title']")?.getAttribute("content") || - doc.querySelector("title")?.textContent || - "No Title"; - const description = - doc - .querySelector("meta[property='og:description']") - ?.getAttribute("content") || "No Description"; - const image = - doc.querySelector("meta[property='og:image']")?.getAttribute("content") || - ""; - - return NextResponse.json({ - status: "success", - data: { title, description, image }, - }); - } catch (error) { - console.error("Error fetching OGP data:", error); - return NextResponse.json( - { status: "error", message: "Error fetching OGP data" } as const, - { status: 500 } - ); - } -} diff --git a/src/app/api/fetch-ogp/route.ts b/src/app/api/fetch-ogp/route.ts new file mode 100644 index 0000000..00eb6f2 --- /dev/null +++ b/src/app/api/fetch-ogp/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + console.log(url); + + if (!url) { + return NextResponse.json( + { + status: "error", + message: "URL is required", + }, + { status: 400 } + ); + } + + try { + const response = await fetch(url); + const html = await response.text(); + console.log(html); + + return NextResponse.json({ + status: "success", + data: { html }, + }); + } catch (error) { + console.log(error); + return NextResponse.json( + { + status: "error", + message: "Failed to fetch HTML", + }, + { status: 500 } + ); + } +} diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css new file mode 100644 index 0000000..e99a034 --- /dev/null +++ b/src/components/LinkCard.module.css @@ -0,0 +1,23 @@ +.card { + display: flex; + flex-direction: row; + gap: 20px; + justify-content: flex-start; + width: 100%; + height: 150px; + overflow: hidden; + border: 1px solid #ccc; + border-radius: 5px; +} + +.card-image-section { + width: 200px; + max-width: 50%; + height: 150px; +} + +.card-image { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index 9e2aa51..0effb16 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -1,35 +1,105 @@ -import { FetchOGPResponse } from "#/app/api/fetch-ogp"; +"use client"; + import { Card, Image, Text } from "@mantine/core"; +import { useEffect, useState } from "react"; + +import styles from "./LinkCard.module.css"; -type LinkCardProps = { - href: string; +type OGPData = { + title: string; + description: string; + image: string; + url: string; }; -const LinkCard: React.FC = async ({ href }) => { - const escapedHref = new URLSearchParams({ url: href }).toString(); - const response: FetchOGPResponse = await ( - await fetch(`/api/fetch-ogp?url=${escapedHref}`) - ).json(); +type ApiResponse = + | { status: "success"; data: { html: string } } + | { status: "error"; message: string }; + +const LinkCard = ({ href }: { href: string }) => { + const [ogpData, setOgpData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchOgpData = async () => { + try { + const response = await fetch( + `/api/fetch-ogp?url=${encodeURIComponent(href)}` + ); + const data: ApiResponse = await response.json(); + + if (data.status === "success") { + // クライアントサイドでDOMParserを使用してOGPデータを解析 + const parser = new DOMParser(); + const doc = parser.parseFromString(data.data.html, "text/html"); + const ogTitle = + (doc.querySelector('meta[property="og:title"]') as HTMLMetaElement) + ?.content || "No title"; + const ogDescription = + ( + doc.querySelector( + 'meta[property="og:description"]' + ) as HTMLMetaElement + )?.content || "No description"; + const ogImage = + (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement) + ?.content || ""; + console.log(doc.querySelector('meta[property="og:image"]')); + setOgpData({ + title: ogTitle, + description: ogDescription, + image: ogImage, + url: href, + }); + } else { + setError(data.message); + } + } catch (error) { + setError("Failed to fetch OGP data"); + } finally { + setLoading(false); + } + }; + + fetchOgpData(); + }, [href]); + + if (loading) return

Loading...

; + + if (error) return

{error}

; + + if (!ogpData) return

Failed to load OGP data.

; return ( - - - No way! + + + {ogpData.image && ( + {ogpData.title} + )} + + + {ogpData.title} + - - {response.status === "success" ? response.data.title : "No Title"} - - - - {response.status === "success" - ? response.data.description - : "No Description"} - + + {ogpData.description} + + ); }; From 6cbe0778d841a68bda317b6aaf2909baa6dc80f4 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 02:14:51 +0900 Subject: [PATCH 04/14] feat: adapt twitter --- src/app/_components/AppShell.tsx | 9 ++------- src/app/api/fetch-ogp/route.ts | 8 +++++++- src/app/lab/page.tsx | 11 +++++++++++ src/components/LinkCard.module.css | 22 ++++++++++++++++++++-- src/components/LinkCard.tsx | 4 ++-- 5 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 src/app/lab/page.tsx diff --git a/src/app/_components/AppShell.tsx b/src/app/_components/AppShell.tsx index 7b377f3..2dd3845 100644 --- a/src/app/_components/AppShell.tsx +++ b/src/app/_components/AppShell.tsx @@ -1,8 +1,7 @@ "use client"; -import React, { Suspense } from "react"; +import React from "react"; -import LinkCard from "#/components/LinkCard"; import { Burger, Group, AppShell as MantineAppShell } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import Sidebar from "./Sidebar"; @@ -36,11 +35,7 @@ const AppShell: React.FC = ({ children }) => { {children} Aside - - Loading...}> - - - + Footer ); }; diff --git a/src/app/api/fetch-ogp/route.ts b/src/app/api/fetch-ogp/route.ts index 00eb6f2..b02f825 100644 --- a/src/app/api/fetch-ogp/route.ts +++ b/src/app/api/fetch-ogp/route.ts @@ -16,7 +16,13 @@ export async function GET(request: Request) { } try { - const response = await fetch(url); + const userAgent = + "digichat-ogp-fetcher-curl-bot; contact: github.com/SIT-DigiCre/digichat"; + const response = await fetch(url, { + headers: { + "User-Agent": userAgent, + }, + }); const html = await response.text(); console.log(html); diff --git a/src/app/lab/page.tsx b/src/app/lab/page.tsx new file mode 100644 index 0000000..dc7ba2a --- /dev/null +++ b/src/app/lab/page.tsx @@ -0,0 +1,11 @@ +import LinkCard from "#/components/LinkCard"; + +const LabPage = () => { + return ( +
+ +
+ ); +}; + +export default LabPage; diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css index e99a034..99816de 100644 --- a/src/components/LinkCard.module.css +++ b/src/components/LinkCard.module.css @@ -1,19 +1,26 @@ .card { display: flex; flex-direction: row; - gap: 20px; justify-content: flex-start; width: 100%; height: 150px; overflow: hidden; border: 1px solid #ccc; border-radius: 5px; + + @media (width < 768px) { + height: 90px; + } } .card-image-section { width: 200px; - max-width: 50%; height: 150px; + + @media (width < 768px) { + width: 120px; + height: 90px; + } } .card-image { @@ -21,3 +28,14 @@ height: 100%; object-fit: cover; } + +.card-content-section { + display: flex; + flex-direction: column; + width: calc(100% - 200px); + padding: 10px; + + @media (width < 768px) { + width: calc(100% - 120px); + } +} diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index 0effb16..e9d382e 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -91,8 +91,8 @@ const LinkCard = ({ href }: { href: string }) => { /> )}
- - + + {ogpData.title} From 38a190b8eea21b0350634bb6d7bab65300b9ab11 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 02:16:27 +0900 Subject: [PATCH 05/14] docs: add ref --- src/app/api/fetch-ogp/route.ts | 3 +++ src/app/lab/page.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/fetch-ogp/route.ts b/src/app/api/fetch-ogp/route.ts index b02f825..694bb2c 100644 --- a/src/app/api/fetch-ogp/route.ts +++ b/src/app/api/fetch-ogp/route.ts @@ -16,6 +16,9 @@ export async function GET(request: Request) { } try { + // ref: https://github.com/traPtitech/traQ/blob/a8035bd3fac0fa4ae531d3b11c1ccbddc76822d2/service/ogp/parser/domain.go#L15 + // X(Twitter)のOGPを取得するのにuserAgentの中にbotという文字列が入っている必要がある + // Spotifyの新しいOGPを取得するのにuserAgentの中にcurl-botという文字列が入っている必要がある const userAgent = "digichat-ogp-fetcher-curl-bot; contact: github.com/SIT-DigiCre/digichat"; const response = await fetch(url, { diff --git a/src/app/lab/page.tsx b/src/app/lab/page.tsx index dc7ba2a..6d85317 100644 --- a/src/app/lab/page.tsx +++ b/src/app/lab/page.tsx @@ -3,7 +3,7 @@ import LinkCard from "#/components/LinkCard"; const LabPage = () => { return (
- +
); }; From f6a1d50619601c3cfa612a5e3b27698c584a781c Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 02:26:11 +0900 Subject: [PATCH 06/14] feat: add Skelton animation --- src/app/api/fetch-ogp/route.ts | 2 -- src/app/lab/page.tsx | 1 + src/components/LinkCard.module.css | 1 + src/components/LinkCard.tsx | 47 +++++++++++++++--------------- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/app/api/fetch-ogp/route.ts b/src/app/api/fetch-ogp/route.ts index 694bb2c..5f4bd67 100644 --- a/src/app/api/fetch-ogp/route.ts +++ b/src/app/api/fetch-ogp/route.ts @@ -3,7 +3,6 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const url = searchParams.get("url"); - console.log(url); if (!url) { return NextResponse.json( @@ -27,7 +26,6 @@ export async function GET(request: Request) { }, }); const html = await response.text(); - console.log(html); return NextResponse.json({ status: "success", diff --git a/src/app/lab/page.tsx b/src/app/lab/page.tsx index 6d85317..c5962fa 100644 --- a/src/app/lab/page.tsx +++ b/src/app/lab/page.tsx @@ -3,6 +3,7 @@ import LinkCard from "#/components/LinkCard"; const LabPage = () => { return (
+

コンポーネントのプレビュー用

); diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css index 99816de..d8170ce 100644 --- a/src/components/LinkCard.module.css +++ b/src/components/LinkCard.module.css @@ -27,6 +27,7 @@ width: 100%; height: 100%; object-fit: cover; + background-color: #f0f0f0; } .card-content-section { diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index e9d382e..4d55335 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { Card, Image, Text } from "@mantine/core"; +import { Card, Image, Skeleton, Text } from "@mantine/core"; import { useEffect, useState } from "react"; import styles from "./LinkCard.module.css"; @@ -18,8 +18,6 @@ type ApiResponse = const LinkCard = ({ href }: { href: string }) => { const [ogpData, setOgpData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { const fetchOgpData = async () => { @@ -53,52 +51,53 @@ const LinkCard = ({ href }: { href: string }) => { url: href, }); } else { - setError(data.message); + console.log(data.message); } } catch (error) { - setError("Failed to fetch OGP data"); - } finally { - setLoading(false); + console.error(error); } }; fetchOgpData(); }, [href]); - if (loading) return

Loading...

; - - if (error) return

{error}

; - - if (!ogpData) return

Failed to load OGP data.

; - return ( - {ogpData.image && ( + {ogpData ? ( {ogpData.title} + ) : ( + )} - - {ogpData.title} - - - - {ogpData.description} - + {ogpData ? ( + + {ogpData.title} + + ) : ( + + )} + {ogpData ? ( + + {ogpData.description} + + ) : ( + + )} ); From 1101391f7f654ec3d137981d8dc6484638436755 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 02:51:12 +0900 Subject: [PATCH 07/14] add: preview component --- src/app/lab/page.tsx | 10 ++++++++-- src/components/LinkCard.module.css | 25 ++++++++++++++++++++++++- src/components/LinkCard.tsx | 12 ++++-------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/app/lab/page.tsx b/src/app/lab/page.tsx index c5962fa..7916770 100644 --- a/src/app/lab/page.tsx +++ b/src/app/lab/page.tsx @@ -1,10 +1,16 @@ import LinkCard from "#/components/LinkCard"; +import { Stack, Title } from "@mantine/core"; const LabPage = () => { return (
-

コンポーネントのプレビュー用

- + コンポーネントのプレビュー用 + + + + + +
); }; diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css index d8170ce..a258e62 100644 --- a/src/components/LinkCard.module.css +++ b/src/components/LinkCard.module.css @@ -33,10 +33,33 @@ .card-content-section { display: flex; flex-direction: column; + gap: 0.5rem; width: calc(100% - 200px); - padding: 10px; + padding: 1rem; @media (width < 768px) { + gap: 0.25rem; width: calc(100% - 120px); + padding: 0.5rem; + } +} + +.card-title { + overflow: hidden; + font-size: 1rem; + font-weight: 600; + text-wrap: nowrap; +} + +.card-description { + display: -webkit-box; + overflow: hidden; + font-size: 0.75rem; + color: var(--mantine-color-dimmed); + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + + @media (width < 768px) { + -webkit-line-clamp: 2; } } diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index 4d55335..6a31b8c 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -17,7 +17,7 @@ type ApiResponse = | { status: "error"; message: string }; const LinkCard = ({ href }: { href: string }) => { - const [ogpData, setOgpData] = useState(null); + const [ogpData, setOGPData] = useState(null); useEffect(() => { const fetchOgpData = async () => { @@ -28,7 +28,6 @@ const LinkCard = ({ href }: { href: string }) => { const data: ApiResponse = await response.json(); if (data.status === "success") { - // クライアントサイドでDOMParserを使用してOGPデータを解析 const parser = new DOMParser(); const doc = parser.parseFromString(data.data.html, "text/html"); const ogTitle = @@ -43,8 +42,7 @@ const LinkCard = ({ href }: { href: string }) => { const ogImage = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement) ?.content || ""; - console.log(doc.querySelector('meta[property="og:image"]')); - setOgpData({ + setOGPData({ title: ogTitle, description: ogDescription, image: ogImage, @@ -85,14 +83,12 @@ const LinkCard = ({ href }: { href: string }) => {
{ogpData ? ( - - {ogpData.title} - + {ogpData.title} ) : ( )} {ogpData ? ( - + {ogpData.description} ) : ( From 3d0a3ccf773082aabc3f83afb153c9ce8286546c Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 10:35:25 +0900 Subject: [PATCH 08/14] feat: add refetch button when error occured --- src/app/api/fetch-ogp/route.ts | 35 +++++++-- src/app/lab/page.tsx | 2 +- src/app/libs/fetch-ogp.ts | 51 +++++++++++++ src/components/LinkCard.module.css | 3 + src/components/LinkCard.tsx | 114 ++++++++++++----------------- 5 files changed, 127 insertions(+), 78 deletions(-) create mode 100644 src/app/libs/fetch-ogp.ts diff --git a/src/app/api/fetch-ogp/route.ts b/src/app/api/fetch-ogp/route.ts index 5f4bd67..d8a928b 100644 --- a/src/app/api/fetch-ogp/route.ts +++ b/src/app/api/fetch-ogp/route.ts @@ -14,6 +14,12 @@ export async function GET(request: Request) { ); } + const controller = new AbortController(); + const signal = controller.signal; + const timeoutId = setTimeout(() => { + controller.abort(); + }, 5000); + try { // ref: https://github.com/traPtitech/traQ/blob/a8035bd3fac0fa4ae531d3b11c1ccbddc76822d2/service/ogp/parser/domain.go#L15 // X(Twitter)のOGPを取得するのにuserAgentの中にbotという文字列が入っている必要がある @@ -24,21 +30,34 @@ export async function GET(request: Request) { headers: { "User-Agent": userAgent, }, + signal, }); const html = await response.text(); return NextResponse.json({ status: "success", - data: { html }, + html, }); } catch (error) { console.log(error); - return NextResponse.json( - { - status: "error", - message: "Failed to fetch HTML", - }, - { status: 500 } - ); + if (signal.aborted) { + return NextResponse.json( + { + status: "error", + message: "リクエストがタイムアウトしました", + }, + { status: 504 } + ); + } else { + return NextResponse.json( + { + status: "error", + message: "ページ情報を取得できませんでした", + }, + { status: 500 } + ); + } + } finally { + clearTimeout(timeoutId); } } diff --git a/src/app/lab/page.tsx b/src/app/lab/page.tsx index 7916770..7714b60 100644 --- a/src/app/lab/page.tsx +++ b/src/app/lab/page.tsx @@ -9,7 +9,7 @@ const LabPage = () => { - + ); diff --git a/src/app/libs/fetch-ogp.ts b/src/app/libs/fetch-ogp.ts new file mode 100644 index 0000000..5bac1c0 --- /dev/null +++ b/src/app/libs/fetch-ogp.ts @@ -0,0 +1,51 @@ +type APIResponse = + | { status: "success"; html: string } + | { status: "error"; message: string }; + +export const fetchOGPData = async (href: string) => { + try { + const response = await fetch( + `/api/fetch-ogp?url=${encodeURIComponent(href)}` + ); + const data: APIResponse = await response.json(); + + if (data.status === "success") { + const parser = new DOMParser(); + const doc = parser.parseFromString(data.html, "text/html"); + const ogTitle = + (doc.querySelector('meta[property="og:title"]') as HTMLMetaElement) + ?.content || "No title"; + const ogDescription = + ( + doc.querySelector( + 'meta[property="og:description"]' + ) as HTMLMetaElement + )?.content || "No description"; + const ogImage = + (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement) + ?.content || ""; + return { + title: ogTitle, + description: ogDescription, + image: ogImage, + url: href, + }; + } else { + return { + title: href, + description: data.message, + image: null, + url: href, + }; + } + } catch (error) { + console.error(error); + return { + title: href, + description: + "OGPの取得に失敗しました。コンソールログを確認してください。", + image: null, + url: href, + }; + } +}; diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css index a258e62..476312d 100644 --- a/src/components/LinkCard.module.css +++ b/src/components/LinkCard.module.css @@ -14,6 +14,9 @@ } .card-image-section { + display: flex; + align-items: center; + justify-content: center; width: 200px; height: 150px; diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index 6a31b8c..dcf4ac1 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -1,100 +1,76 @@ "use client"; -import { Card, Image, Skeleton, Text } from "@mantine/core"; +import { Button, Card, Image, Skeleton, Text } from "@mantine/core"; import { useEffect, useState } from "react"; +import { fetchOGPData } from "#/app/libs/fetch-ogp"; import styles from "./LinkCard.module.css"; type OGPData = { title: string; description: string; - image: string; + image: string | null; url: string; }; -type ApiResponse = - | { status: "success"; data: { html: string } } - | { status: "error"; message: string }; - const LinkCard = ({ href }: { href: string }) => { - const [ogpData, setOGPData] = useState(null); + const [OGPData, setOGPData] = useState(null); useEffect(() => { - const fetchOgpData = async () => { - try { - const response = await fetch( - `/api/fetch-ogp?url=${encodeURIComponent(href)}` - ); - const data: ApiResponse = await response.json(); - - if (data.status === "success") { - const parser = new DOMParser(); - const doc = parser.parseFromString(data.data.html, "text/html"); - const ogTitle = - (doc.querySelector('meta[property="og:title"]') as HTMLMetaElement) - ?.content || "No title"; - const ogDescription = - ( - doc.querySelector( - 'meta[property="og:description"]' - ) as HTMLMetaElement - )?.content || "No description"; - const ogImage = - (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement) - ?.content || ""; - setOGPData({ - title: ogTitle, - description: ogDescription, - image: ogImage, - url: href, - }); - } else { - console.log(data.message); - } - } catch (error) { - console.error(error); - } - }; - - fetchOgpData(); + fetchOGPData(href).then((data) => { + setOGPData(data); + }); }, [href]); return ( - - - {ogpData ? ( - {ogpData?.title} - ) : ( - - )} - + - {ogpData ? ( - {ogpData.title} + {OGPData ? ( + + {OGPData.title} + ) : ( )} - {ogpData ? ( + {OGPData ? ( - {ogpData.description} + {OGPData.description} ) : ( )} + + {OGPData ? ( + OGPData.image ? ( + {OGPData?.title} + ) : ( + + ) + ) : ( + + )} + ); }; From 872c30d75601150429525285075c5e9366faf016 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 18:51:21 +0900 Subject: [PATCH 09/14] fix: move the libs dir to under the src dir --- src/components/LinkCard.tsx | 2 +- src/{app => }/libs/fetch-ogp.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{app => }/libs/fetch-ogp.ts (100%) diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index dcf4ac1..2dda1a0 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -3,7 +3,7 @@ import { Button, Card, Image, Skeleton, Text } from "@mantine/core"; import { useEffect, useState } from "react"; -import { fetchOGPData } from "#/app/libs/fetch-ogp"; +import { fetchOGPData } from "#/libs/fetch-ogp"; import styles from "./LinkCard.module.css"; type OGPData = { diff --git a/src/app/libs/fetch-ogp.ts b/src/libs/fetch-ogp.ts similarity index 100% rename from src/app/libs/fetch-ogp.ts rename to src/libs/fetch-ogp.ts From fa820650d581d34ccc1bad92ff2cc9f2a6bd7a25 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 18:57:41 +0900 Subject: [PATCH 10/14] docs: add commentout --- src/libs/fetch-ogp.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/fetch-ogp.ts b/src/libs/fetch-ogp.ts index 5bac1c0..17dca4e 100644 --- a/src/libs/fetch-ogp.ts +++ b/src/libs/fetch-ogp.ts @@ -2,6 +2,7 @@ type APIResponse = | { status: "success"; html: string } | { status: "error"; message: string }; +// DOMParserはクライアント側でしか利用できないため、HTML取得後の処理はクライアント側で行う export const fetchOGPData = async (href: string) => { try { const response = await fetch( @@ -14,7 +15,7 @@ export const fetchOGPData = async (href: string) => { const doc = parser.parseFromString(data.html, "text/html"); const ogTitle = (doc.querySelector('meta[property="og:title"]') as HTMLMetaElement) - ?.content || "No title"; + .content || "No title"; const ogDescription = ( doc.querySelector( @@ -31,6 +32,7 @@ export const fetchOGPData = async (href: string) => { url: href, }; } else { + // サーバーサイドで取得に際し何らかのエラーが発生した場合、またはタイムアウトした場合 return { title: href, description: data.message, @@ -39,6 +41,7 @@ export const fetchOGPData = async (href: string) => { }; } } catch (error) { + // サーバーサイドへのリクエストに失敗した場合 console.error(error); return { title: href, From 8331f30aeb11e0d56a619dd24398298d34501fae Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 19:08:56 +0900 Subject: [PATCH 11/14] improve: show more useful content on link card --- src/libs/fetch-ogp.ts | 44 ++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/libs/fetch-ogp.ts b/src/libs/fetch-ogp.ts index 17dca4e..c4aabde 100644 --- a/src/libs/fetch-ogp.ts +++ b/src/libs/fetch-ogp.ts @@ -13,22 +13,36 @@ export const fetchOGPData = async (href: string) => { if (data.status === "success") { const parser = new DOMParser(); const doc = parser.parseFromString(data.html, "text/html"); - const ogTitle = - (doc.querySelector('meta[property="og:title"]') as HTMLMetaElement) - .content || "No title"; - const ogDescription = - ( - doc.querySelector( - 'meta[property="og:description"]' - ) as HTMLMetaElement - )?.content || "No description"; - const ogImage = - (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement) - ?.content || ""; + + // タイトル: og:title > title > URL + const ogTitleElement = doc.querySelector( + 'meta[property="og:title"]' + ) as HTMLMetaElement | null; + const title = ogTitleElement?.content || doc.title || href; + + // 説明文: og:description > description > body.textContent + const ogDescriptionElement = doc.querySelector( + 'meta[property="og:description"]' + ) as HTMLMetaElement | null; + const descriptionElement = doc.querySelector( + 'meta[name="description"]' + ) as HTMLMetaElement | null; + const textContent = doc.body.textContent; + const description = + ogDescriptionElement?.content || + descriptionElement?.content || + textContent?.slice(0, 100) || + "No Content"; + + const ogImageElement = doc.querySelector( + 'meta[property="og:image"]' + ) as HTMLMetaElement | null; + const image = ogImageElement?.content || null; + return { - title: ogTitle, - description: ogDescription, - image: ogImage, + title, + description, + image, url: href, }; } else { From 32522f1508666924ba9a01529bed65d5b060bf51 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 19:13:30 +0900 Subject: [PATCH 12/14] remove: refetch button from link card --- src/components/LinkCard.module.css | 10 ++++++++++ src/components/LinkCard.tsx | 26 ++++++++++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css index 476312d..23495f3 100644 --- a/src/components/LinkCard.module.css +++ b/src/components/LinkCard.module.css @@ -11,6 +11,16 @@ @media (width < 768px) { height: 90px; } + + &[data-no-image="true"] { + .card-image-section { + display: none; + } + + .card-content-section { + width: 100%; + } + } } .card-image-section { diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index 2dda1a0..2d584f8 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Image, Skeleton, Text } from "@mantine/core"; +import { Card, Image, Skeleton, Text } from "@mantine/core"; import { useEffect, useState } from "react"; import { fetchOGPData } from "#/libs/fetch-ogp"; @@ -23,7 +23,12 @@ const LinkCard = ({ href }: { href: string }) => { }, [href]); return ( - + {OGPData ? ( { {OGPData ? ( - OGPData.image ? ( + OGPData.image && ( {OGPData?.title} - ) : ( - ) ) : ( From 842bf64bb5c2d9737a3d01f773e942e38232879f Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 19:14:19 +0900 Subject: [PATCH 13/14] improve: expand clickable area --- src/components/LinkCard.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx index 2d584f8..5517d8d 100644 --- a/src/components/LinkCard.tsx +++ b/src/components/LinkCard.tsx @@ -28,18 +28,14 @@ const LinkCard = ({ href }: { href: string }) => { padding={0} className={styles.card} data-noimage={OGPData?.image ? "false" : "true"} + component="a" + href={OGPData?.url} + target="_blank" + rel="noopener noreferrer" > {OGPData ? ( - - {OGPData.title} - + {OGPData.title} ) : ( )} From 392e9a246141319a1166fff30868f840c0715e81 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 4 Jan 2025 19:17:15 +0900 Subject: [PATCH 14/14] feat: check url is valid --- src/app/api/fetch-ogp/route.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/api/fetch-ogp/route.ts b/src/app/api/fetch-ogp/route.ts index d8a928b..5f77349 100644 --- a/src/app/api/fetch-ogp/route.ts +++ b/src/app/api/fetch-ogp/route.ts @@ -8,7 +8,19 @@ export async function GET(request: Request) { return NextResponse.json( { status: "error", - message: "URL is required", + message: "URLパラメータは必須です", + }, + { status: 400 } + ); + } + + try { + new URL(url); + } catch (e) { + return NextResponse.json( + { + status: "error", + message: "URLが不正です", }, { status: 400 } );