🪒

外部サイトのOGPを取得する

5 min read 2

現在開発しているアプリで外部サイトのOGPを取得して以下のようなカードを表示する機能を実装しました。

https://zenn.dev/littleforest

今後また別のアプリで使うかもしれないのでアプリ本体と切り離してCloud Functionsにデプロイしました。

コード

functions/src/index.ts
import * as functions from 'firebase-functions';
import axios from 'axios';
import { JSDOM } from 'jsdom'


export const getOgpFromExternalWebsite = functions.https.onRequest(async (request, response) => {
  const targetUrls = extractUrlParams(request, response);

  if (!targetUrls) return;

  const ogps: any = {};

  // リクエストで渡されたURLごとにOGPを取得
  await Promise.all(
    targetUrls.map(async (targetUrl: string) => {
      const encodedUri = encodeURI(targetUrl);
      const headers = { 'User-Agent': 'bot' };
      
      try {
        const res = await axios.get(encodedUri, { headers: headers });
        const html = res.data;
        const dom = new JSDOM(html);
        const meta = dom.window.document.head.querySelectorAll("meta");
        const ogp = extractOgp([...meta]);

        // URLをキーとして、取得したOGPをまとめて返す
        ogps[targetUrl] = ogp;
      } catch (error) {
        console.error(error)
        sendErrorResponse(response, error)
      }
    }));

  response.status(200).json(ogps);
})


// リクエストからOGPを取得しに行くURLを抽出
function extractUrlParams(request: functions.https.Request, response: functions.Response<any>): string[] {
  const url = request.query.url;
  const urls = request.query.urls;

  if (url && urls) {
    sendErrorResponse(response, "Request query can't have both 'url' and 'urls'");
    return [];
  } else if (url) {
    if (Array.isArray(url)) {
      sendErrorResponse(response, "'url' must be string");
      return [];
    }
    return [<string>url];
  } else if (urls) {
    if (!Array.isArray(urls)) {
      sendErrorResponse(response, "'urls' must be array of string");
      return [];
    }
    return <string[]>urls;
  } else {
    sendErrorResponse(response, "Either 'url' or 'urls' must be included");
    return [];
  }
}


// HTMLのmetaタグからogpを抽出
function extractOgp(metaElements: HTMLMetaElement[]): object {
  const ogp = metaElements
    .filter((element: Element) => element.hasAttribute("property"))
    .reduce((previous: any, current: Element) => {
      const property = current.getAttribute("property")?.trim();
      if (!property) return;
      const content = current.getAttribute("content");
      previous[property] = content;
      return previous;
    }, {});

  return ogp;
}


function sendErrorResponse(response: functions.Response<any>, message: string): void {
  response.status(400).send(message);
}

Cloud Functionsを呼ぶ回数はできるだけ少なくしたいので、事前に呼び出し元で正しい形のURLかチェックしています。

また、たいていのページはカードを表示するたびにOGP情報が書き換わっているというほど更新頻度の高いものでもないと思うので、一度取得したOGPはキャッシュしています。保持する期間はどれくらいがいいんでしょうか。

動作

任意のURLを渡してみると以下のような形でレスポンスが返ってくると思います。

  • Zenn (https://zenn.dev)

    <function-url>?url=https://zenn.dev
    
    {
      "https://zenn.dev": {
        "og:type": "article",
        "og:image": "https://zenn.dev/images/og-large.png",
        "og:site_name": "Zenn",
        "og:url": "https://zenn.dev",
        "og:title": "Zenn|エンジニアのための情報共有コミュニティ",
        "og:description": "Zennはエンジニアが技術・開発についての知見をシェアする場所です。ウェブ上での本の販売や、読者からのサポートにより対価を受け取ることができます。"
      }
    }
    
  • Zenn (https://zenn.dev) & OGP (https://ogp.me)

    <function-url>?urls[0]=https://zenn.dev&urls[1]=https://ogp.me
    
    {
      "https://zenn.dev": {
        "og:type": "article",
        "og:image": "https://zenn.dev/images/og-large.png",
        "og:site_name": "Zenn",
        "og:url": "https://zenn.dev",
        "og:title": "Zenn|エンジニアのための情報共有コミュニティ",
        "og:description": "Zennはエンジニアが技術・開発についての知見をシェアする場所です。ウェブ上での本の販売や、読者からのサポートにより対価を受け取ることができます。"
      },
      "https://ogp.me": {
        "og:title": "Open Graph protocol",
        "og:type": "website",
        "og:url": "https://ogp.me/",
        "og:image": "https://ogp.me/logo.png",
        "og:image:type": "image/png",
        "og:image:width": "300",
        "og:image:height": "300",
        "og:image:alt": "The Open Graph logo",
        "og:description": "The Open Graph protocol enables any web page to become a rich object in a social graph.",
        "fb:app_id": "115190258555800"
      }
    }
    

参考

Discussion

ZennでもCloudFunctionsを使ってますが、responseを返す前に

response.set("Cache-Control", "public, max-age=86400")

などとすることレスポンスをキャッシュしています。
CloudFunctions(Firebase Functions)はデフォルトでエッジキャッシュに対応してるので便利ですね。

なるほど。Cloud Functionsでキャッシュできるのは見落としていました。
GCP使いこなせるようになりたい😭

ログインするとコメントできます