
링크 스크랩 기능을 개발하다가 링크의 메타 데이터를 간단히 표시해주면 좋을 것 같아서 웹페이지의 메타데이터를 수집해보기로 하였다.
간단히 해당 페이지의 제목, 설명, 이미지, canonical url만 가져와보려고 한다.
canonical URL(표준 URL)이란?
- 표준 URL은 중복되거나 유사한 여러 페이지 중 가장 대표적이라고 간주되는 페이지의 URL
- 쿼리 파라미터 등을 제외한 URL
- 예: 두 URL은 표준 URL이 같다
fetch를 사용해 해당 url에 요청을 보내면, html을 응답으로 받을 수 있다.
html에서 원하는 태그를 검색하기 위해서 파싱을 진행했다. 파싱은 가볍고 빠른 node-html-parser 라이브러리를 사용했다.
(참고로 클라이언트에서는 따로 라이브러리 설치 없이 내장된 DOMParser로 파싱이 가능하다)
import parse from "node-html-parser";
// 페이지의 메타데이터 가져오기
const res = await fetch(url);
const html = await res.text();
// 파싱하기
const rootElement = parse(html);
각 타이틀, 설명, 이미지, 표준 URL 정보에 해당하는 태그를 가져와 값을 추출한다.
// 메타데이터 추출
const title =
rootElement
.querySelector("meta[property='og:title']")
?.getAttribute("content") ||
rootElement.querySelector("title")?.text.trim();
const description =
rootElement
.querySelector("meta[property='og:description']")
?.getAttribute("content") ||
rootElement
.querySelector("meta[name='description']")
?.getAttribute("content") ||
rootElement
.querySelector("meta[name='twitter:description']")
?.getAttribute("content");
const image =
rootElement
.querySelector("meta[property='og:image']")
?.getAttribute("content") ||
rootElement
.querySelector("meta[name='twitter:image']")
?.getAttribute("content");
const canonicalUrl =
rootElement.querySelector("link[rel='canonical']")?.getAttribute("href") ||
rootElement
.querySelector("meta[property='og:url']")
?.getAttribute("content");
여러 URL을 넣어보며 기능을 테스트하던 중 네이버 블로그의 경우 메타 데이터를 가져오지 못하는 문제를 발견했다.
해당 페이지의 elements를 확인했을 때 분명 유효한 값을 가진 meta 태그가 존재했지만, 해당 태그들이 iframe 아래에 있다는 게 문제였다.

네이버 블로그는 본문을 "mainFrame"이라는 id를 가진 iframe으로 표시하고 있었다.
iframe이 src가 로드되기 전에 응답이 반환되면서 메타 태그를 찾을 수 없었던 것이다.
다음과 같이 응답으로 받은 html을 확인해보면 iframe 내부에 아무것도 들어있지 않았다.

그럼 페이지에 포함된 모든 iframe은 따로 로드를 하여 메타 태그를 찾아야하는가?
하지만 그렇게 하면 시간도 많이 소요될 수 있을 뿐더러, iframe이 광고나 글의 주 내용과 관련도가 적은 내용이 들어있다면 부적합한 메타 데이터가 추출될 수 있을 것 같았다.
따라서 네이버 블로그 경로인 경우에만 메인 iframe을 찾아서 src 경로로 한 번 더 fetch하기로했다.
if (/(^|\.)blog\.naver\.com$/i.test(new URL(url).hostname)) {
const rootElement = await fetchHtml(url);
const frameElement = rootElement.querySelector("iframe#mainFrame");
const frameSrc = frameElement?.getAttribute("src");
if (!frameSrc) {
return findMetaDataFromElement(rootElement);
}
const finalUrl = new URL(frameSrc, url);
const targetElement = await fetchHtml(finalUrl.toString());
return findMetaDataFromElement(targetElement);
}
메타 데이터로 얻은 이미지를 렌더링할 때 깨지는 문제가 발생했다.

확인해보니 해당 이미지에 대한 요청이 403 Forbidden 상태로 거부되어있었다.

브라우저는 strict-origin-when-cross-origin을 Referrer Policy의 기본값으로 설정하여 요청을 보낸다. 이 때문에 요청 헤더에 referer가 포함되고, 네이버 서버에서 외부 도메인에서 온 요청을 거부한 것이다.
이미지 엘리먼트의 속성에 referrerPolicy="no-referrer" 추가하여 referer 헤더를 보내지 않는다.
<img
className="shrink-0 w-full h-full object-cover"
src={link.image}
referrerPolicy="no-referrer"
/>

브라우저가 요청을 보낼 때 함께 실어 보내는 Referer 헤더(요청을 보낸 “출처/페이지 URL”)를 어느 범위까지 보낼지 제어하는 설정
| 값 | 교차 출처 요청 시 Referer 내용 | 특징/비고 |
|---|---|---|
no-referrer | 보내지 않음 | 개인정보/보안 최우선. 네이버처럼 외부 referrer 차단을 우회할 때 유용. |
same-origin | 같은 출처로만 전체 URL 전송, 교차 출처엔 안 보냄 | 내부 링크/리소스만 referrer 공유. |
origin | 스킴+호스트(+포트)만 | 경로/쿼리 비공개. 최소한의 출처만 전달. |
origin-when-cross-origin | 같은 출처: 전체 URL / 교차 출처: origin만 | 적당히 정보 제공, 경로는 숨김. |
strict-origin | 보안(HTTPS→HTTPS)일 때만 origin, 다운그레이드(HTTPS→HTTP)는 안 보냄 | 다운그레이드에 엄격. |
strict-origin-when-cross-origin | 같은 출처: 전체 URL / 교차 출처: origin / 다운그레이드: 안 보냄 | 현재 표준적인 기본값. |
no-referrer-when-downgrade | 같은/동등 보안이면 전체 URL, 다운그레이드면 안 보냄 | 예전 기본값, 지금은 잘 안 씀. |
unsafe-url | 항상 전체 URL(경로·쿼리 포함) | 가장 많은 정보 노출. 민감 URL에 비추천. |
유튜브 영상의 경우 배포 환경(vercel)에서는 로컬 환경과 다른 html을 반환하였다.
실제 페이지에는 다음과 같은 메타 태그들이 포함되어 있고 로컬 환경에서도 같은 html을 받을 수 있었으나

하지만 배포 환경에서 확인해보니 특정 동영상에 대한 메타데이터가 아닌 Youtube 자체의 메타데이터가 들어있었다.

정확한 원인은 찾을 수 없었다...
fetch 옵션으로 User-Agent, Accept-Language, Referer 등 헤더를 명시해보았지만 해결되지 않았다.
배포 환경에서만 되지 않는 것을 보아 vercel 서버와 관련이 있지 않을까싶다.
찾아보니 직접 스크래핑하지 않아도 유튜브 메타데이터를 가져올 수 있는 방법이 있었다. YouTube Data API을 사용하거나 Youtube oEmbed를 사용하면 특정 URL에 대한 메타데이터를 JSON 형식으로 받아올 수 있다.
YouTube Data API의 경우 굉장히 세세한 정보까지 가져올 수 있지만 API 키를 발급받아 사용해야 하고 요청 할당량이 제한되어 있었다. oEmbed의 경우 description은 제공하지 않았지만, 가장 중요한 title과 image를 빠르게 가져올 수 있다는 점이 프로젝트에 적합한 것 같아서 oEmbed를 사용하기로 했다.
소비자(Consumer)가 콘텐츠의 URL 주소만 가지고 간단한 요청을 보내면, Youtube, Facebook 등의 서비스(Provider)에선 해당 콘텐츠를 표시하는 데 필요한 정보(제목, 썸네일, 실제 삽입용 HTML 코드 등)를 표준화된 JSON 형식으로 돌려주는 방식이다.
oEmbed는 다른 사이트의 URL을 내장된 표현을 가능하게 하는 Format 입니다.
유저가 Resource 에 해당하는 링크를 입력할 때, 웹사이트들이 Resource를 직접 파싱하지 않고, 내장된 컨텐츠(사진과 비디오같은)를 보여줄 수 있게 하는 간단한 API 입니다.
요청 url: http://www.youtube.com/oembed?url=[링크]&format=json

최종적으로는 url을 확인하며 각 사이트(네이버, 유튜브, 나머지)에 맞는 메타데이터 추출 로직을 실행할 수 있도록 metatdata api를 작성했다.
// src/app/api/metadata/route.ts
import { NextResponse } from "next/server";
import { Matcher, naverBlogMatcher, youtubeMatcher } from "./matchers";
import {
defaultFetcher,
Fetcher,
naverBlogFetcher,
youtubeFetcher,
} from "./fetchers";
type MetadataFetcher = {
matcher: Matcher;
fetcher: Fetcher;
};
const fetchers: MetadataFetcher[] = [
{ matcher: naverBlogMatcher, fetcher: naverBlogFetcher },
{ matcher: youtubeMatcher, fetcher: youtubeFetcher },
{ matcher: () => true, fetcher: defaultFetcher },
];
export async function POST(req: Request) {
const { url } = await req.json();
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
try {
// 페이지의 메타데이터 가져오기
const metadata = await getMetadata(url);
if (!metadata.canonicalUrl) {
metadata.canonicalUrl = url;
}
return NextResponse.json({ metadata }, { status: 200 });
} catch (error) {
return NextResponse.json({ error }, { status: 500 });
}
}
async function getMetadata(url: string) {
for (const { matcher, fetcher } of fetchers) {
if (!matcher(url)) {
continue;
}
return await fetcher(url);
}
return await defaultFetcher(url);
}
// src/app/api/metadata/matchers.ts
export type Matcher = (url: string) => boolean;
export const naverBlogMatcher: Matcher = (url: string) =>
/(^|\.)blog\.naver\.com$/i.test(new URL(url).hostname);
export const youtubeMatcher: Matcher = (url: string) => {
const hostname = new URL(url).hostname;
return (
/(^|\.)youtube\.com$/i.test(hostname) || /(^|\.)youtu\.be$/i.test(hostname)
);
};
// src/app/api/metadata/fetchers.ts
import { Metadata } from "@/types/Metadata";
import parse, { HTMLElement } from "node-html-parser";
export type Fetcher = (url: string) => Promise<Metadata>;
async function fetchHtml(url: string): Promise<HTMLElement> {
const res = await fetch(url);
if (!res.ok) {
console.log(await res.json());
throw new Error(`Failed to fetch "${url}"`);
}
const html = await res.text();
return parse(html);
}
function findMetaDataFromElement(element: HTMLElement): Metadata {
const title =
element
.querySelector("meta[property='og:title']")
?.getAttribute("content") || element.querySelector("title")?.text.trim();
const description =
element
.querySelector("meta[property='og:description']")
?.getAttribute("content") ||
element
.querySelector("meta[name='description']")
?.getAttribute("content") ||
element
.querySelector("meta[name='twitter:description']")
?.getAttribute("content");
const image =
element
.querySelector("meta[property='og:image']")
?.getAttribute("content") ||
element
.querySelector("meta[name='twitter:image']")
?.getAttribute("content");
const canonicalUrl =
element.querySelector("link[rel='canonical']")?.getAttribute("href") ||
element.querySelector("meta[property='og:url']")?.getAttribute("content");
const metadata: Metadata = {
title,
description,
image,
canonicalUrl,
};
return metadata;
}
export const defaultFetcher: Fetcher = async (url: string) => {
const rootElement = await fetchHtml(url);
return findMetaDataFromElement(rootElement);
};
export const naverBlogFetcher: Fetcher = async (url: string) => {
const rootElement = await fetchHtml(url);
const frameElement = rootElement.querySelector("iframe#mainFrame");
const frameSrc = frameElement?.getAttribute("src");
if (!frameSrc) {
return findMetaDataFromElement(rootElement);
}
const finalUrl = new URL(frameSrc, url);
const targetElement = await fetchHtml(finalUrl.toString());
return findMetaDataFromElement(targetElement);
};
export const youtubeFetcher: Fetcher = async (url: string) => {
const requestUrl = `https://youtube.com/oembed?url=${url}&format=json`;
const res = await fetch(requestUrl);
if (!res.ok) {
console.log(await res.json());
throw new Error(`Failed to fetch youtube oEmbed`);
}
const json = await res.json();
return {
title: json.title,
image: json.thumbnail_url,
};
};

https://toyou101.tistory.com/17
https://meetup.nhncloud.com/posts/81
https://100lvlmaster.medium.com/fetch-any-youtube-videos-metadata-in-2-lines-e97c961c9004