링크의 메타데이터 수집하기

김소연·2025년 8월 27일
post-thumbnail

링크 스크랩 기능을 개발하다가 링크의 메타 데이터를 간단히 표시해주면 좋을 것 같아서 웹페이지의 메타데이터를 수집해보기로 하였다.

간단히 해당 페이지의 제목, 설명, 이미지, canonical url만 가져와보려고 한다.

canonical 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"
/>

referrerPolicy란?

브라우저가 요청을 보낼 때 함께 실어 보내는 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를 사용하기로 했다.

oEmbed란?

소비자(Consumer)가 콘텐츠의 URL 주소만 가지고 간단한 요청을 보내면, Youtube, Facebook 등의 서비스(Provider)에선 해당 콘텐츠를 표시하는 데 필요한 정보(제목, 썸네일, 실제 삽입용 HTML 코드 등)를 표준화된 JSON 형식으로 돌려주는 방식이다.

oEmbed는 다른 사이트의 URL을 내장된 표현을 가능하게 하는 Format 입니다.
유저가 Resource 에 해당하는 링크를 입력할 때, 웹사이트들이 Resource를 직접 파싱하지 않고, 내장된 컨텐츠(사진과 비디오같은)를 보여줄 수 있게 하는 간단한 API 입니다.

Youtube oEmbed 사용하기

요청 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

0개의 댓글