やりたいこと
ポートフォリオサイト(自己紹介サイト)にリンクを貼ったとき、リンク先サイトから OGP 情報を動的に読み込んで表示させたいと考えました。
OGP(Open Graph Protocol)とは、下記の画像のように、リンク先サイトのタイトル・画像・説明文などの補足情報を、表示(プレビュー)するための仕組みです。X や Facebook などの SNS でおなじみですね。
(蛇足ですが、この「15分で分かる人工知能と計算機科学の歴史」という記事は、私の渾身作ですので、ぜひどうぞ〜)
フロントエンドの仕組みだけで実現できると誤解していたのですが、実はバックエンドが必要になります。セキュリティ上の理由(CORS 制約)で、フロントエンドから直接外部サイトの情報を読み込むことができないからです。
そこで、Python + Lambda + API Gateway という構成でこれを実現することにしました。
技術スタック
- フロントエンド:JavaScript
- バックエンド:Python(Flask, beautifulsoup)
- インフラ:AWS(Lambda, API Gateway)
- ホスティング:GitHub Pages
- その他:Serverless Framework
ディレクトリ構成
下記の6つのファイルを編集していきます。
-
app.py:OGP 情報を取得するための簡単な API サーバー -
index.html:ウェブサイト本体(自己紹介ページ) -
lambda_handler.py:Lambda の実行時に呼び出されるハンドラー関数 -
requirements.txt:インストールするパッケージを記載したテキスト -
serverless.yml:デプロイ設計を記述した設計書 -
style.css:ウェブサイトの見た目を決めるスタイルシート
API サーバー(app.py)の実装
まず、OGP 情報を取得するための API サーバーを、下記のとおりに実装します。
このサーバーは、/api/ogp?url=取得したいサイトのURL というリクエストを受け取ると、指定されたサイトの OGP 情報を調べて JSON で返します。
from flask import Flask, request, jsonify
import requests
from bs4 import BeautifulSoup
from flask_cors import CORS
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False
CORS(app) # CORS設定を有効にする
@app.route('/api/ogp')
def get_ogp():
# クエリパラメータからURLを取得
url = request.args.get('url')
if not url:
return jsonify({"error": "URL is required"}), 400
try:
# ブラウザからのアクセスであることを偽装するためのヘッダー
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Accept-Language': 'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8'
}
# タイムアウトを少し長めに設定し、ヘッダーを付与してリクエスト
response = requests.get(url, headers=headers, timeout=10)
# エラーがあれば例外を発生させる
response.raise_for_status()
# BeautifulSoupでHTMLを解析
soup = BeautifulSoup(response.content, 'html.parser')
# OGP情報を抽出
ogp_data = {
'title': get_meta_property(soup, 'og:title'),
'description': get_meta_property(soup, 'og:description'),
'image': get_meta_property(soup, 'og:image'),
'url': get_meta_property(soup, 'og:url')
}
# 成功したらJSONで返す
return jsonify(ogp_data)
except requests.exceptions.RequestException as e:
return jsonify({"error": f"Failed to fetch URL: {e}"}), 500
except Exception as e:
return jsonify({"error": f"An error occurred: {e}"}), 500
def get_meta_property(soup, prop):
# 指定されたpropertyを持つmetaタグのcontentを返すヘルパー関数
tag = soup.find('meta', property=prop)
return tag['content'] if tag else None
ハンドラー関数の実装(lambda_handler.py)
Lambda はウェブサーバーではないため、Flask アプリをそのまま動かすことはできません。
なので、serverless-wsgi のようなライブラリを使って、API Gateway からのリクエストを Flask が理解できる形に変換する必要があります。
そこで、Lambda の実行時に呼び出されるハンドラー関数を含むファイル(lambda_handler.py)を、次のように作成します。
import serverless_wsgi
from app import app # app.py からFlaskアプリをインポート
def lambda_handler(event, context):
return serverless_wsgi.handle(app, event, context)
インストールパッケージの定義(requirements.txt)
ここまでの実装に必要となったパッケージをインストールするために、requirements.txt に列挙しておきます。
Flask
requests
beautifulsoup4
serverless-wsgi
flask-cors
デプロイ設計書の作成(serverless.yml)
次に、デプロイの設定を記述する serverless.yml を作成します。これが Serverless Framework の設計図になります。
service: ogp-fetcher-service
provider:
name: aws
runtime: python3.13 # 使用するPythonのバージョン
region: ap-northeast-1 # デプロイするAWSリージョン
functions:
api:
handler: wsgi_handler.handler # serverless-wsgiが生成するハンドラーを指定
events:
- httpApi: # API GatewayのHTTP APIを設定
path: /api/ogp
method: get
plugins:
- serverless-wsgi
- serverless-python-requirements
custom:
wsgi:
app: app.app # app.pyファイル内のFlaskインスタンス'app'を指定
packRequirements: false # serverless-python-requirementsにパッケージングを任せる
pythonRequirements:
dockerizePip: true # 環境差異をなくすためにDockerでビルド
デプロイ
デプロイするためには、下記の3つの条件が満たされている必要があります。
- Serverless Framework がインストールされていること
- aws-cli が正常に使えること
- Docker Desktop が起動していること
ここでは、上記3つについては説明しませんので、各自で調べてください。
まず、プロジェクトフォルダで以下のコマンドを実行し、プラグインをインストールします。
$ serverless plugin install --name serverless-wsgi
$ serverless plugin install --name serverless-python-requirements
次に、デプロイコマンドを実行します。
$ serverless deploy
Docker でビルドするので、事前に Docker Desktop を起動しておく必要があることに注意してください。
デプロイが完了すると、ターミナルに API のエンドポイント URL が表示されるので確認しましょう。
curl を使った動作確認
ターミナルで以下のコマンドを実行します。
YOUR_API_ENDPOINT の部分を、上記で確認した URL に置き換えてください。
ここでは、私の著書の OGP を取得してみます。
$ curl "YOUR_API_ENDPOINT?url=https://gihyo.jp/book/2019/978-4-297-10406-1"
下記のように、description, image, title, url の4つの情報が JSON 形式で表示されたら、デプロイに成功しています。
{
"description": "本書は、技術文書の作成に特化したライティング技術を解説した書籍です。...",
"image": "https://gihyo.jp/assets/images/book/2019/978-4-297-10406-1/978-4-297-10406-1.jpg",
"title": "成果を生み出すテクニカルライティング",
"url": "https://gihyo.jp/book/2019/978-4-297-10406-1"
}
なお、ターミナルによっては、\u... のように表示されるかもしれませんが、これは文字化け(バグ)ではありません。JSON が英語以外の文字を安全に扱うための標準的な形式(Unicode エスケープシーケンス)です。
この JSON データをウェブサイトの JavaScript で受け取れば、自動的に元の日本語に変換されて表示されます。
フロントエンドの実装
ここまで来れば、あとはフロントエンドで API を叩いて表示させるだけです。
まず、index.html の末尾に、下記のコードを挿入します。
<script>
const OGP_API_ENDPOINT = 'YOUR_API_ENDPOINT';
function buildOgpCard(data, targetUrl, container) {
if (data && data.title) {
container.innerHTML = `
<a href="${targetUrl}" target="_blank" rel="noopener" class="ogp-link-wrapper">
<div class="ogp-card">
${data.image ? `<img src="${data.image}" alt="OGP Image" class="ogp-image">` : ''}
<div class="ogp-text">
<h3 class="ogp-title">${data.title}</h3>
<p class="ogp-description">${data.description || ''}</p>
<span class="ogp-link">${data.url || targetUrl}</span>
</div>
</div>
</a>
`;
} else {
container.innerHTML = '<div class="ogp-card-error">プレビューを取得できませんでした。</div>';
}
}
async function generateOgpPreview(targetUrl, container) {
if (!targetUrl || !container) return;
const cacheKey = `ogp-cache:${targetUrl}`;
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
buildOgpCard(JSON.parse(cachedData), targetUrl, container);
return;
}
container.innerHTML = '<div class="ogp-card-loading">読み込み中...</div>';
try {
const response = await fetch(`${OGP_API_ENDPOINT}?url=${encodeURIComponent(targetUrl)}`);
if (!response.ok) throw new Error(`Server error: ${response.status}`);
const data = await response.json();
if (data.error) throw new Error(data.error);
sessionStorage.setItem(cacheKey, JSON.stringify(data));
buildOgpCard(data, targetUrl, container);
} catch (error) {
console.error(`Failed to fetch OGP for ${targetUrl}:`, error);
container.innerHTML = '<div class="ogp-card-error">プレビューの読み込みに失敗しました。</div>';
}
}
window.addEventListener('DOMContentLoaded', () => {
const previewElements = document.querySelectorAll('.auto-ogp-preview');
previewElements.forEach(element => {
const url = element.dataset.url;
if (url) {
generateOgpPreview(url, element);
}
});
});
async function fetchAndShowOgp() {
const urlInput = document.getElementById('ogpUrlInput');
const resultContainer = document.getElementById('ogpResultContainer');
const urlToFetch = urlInput.value;
if (!urlToFetch) {
resultContainer.innerHTML = '<p style="color: red;">URLを入力してください。</p>';
return;
}
generateOgpPreview(urlToFetch, resultContainer);
}
</script>
見た目は次のように調整しました。
.ogp-card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
margin-top: 20px;
max-width: 712px;
height: 110px;
display: flex;
background-color: #fff;
text-decoration: none;
}
.ogp-card:hover {
box-shadow: 0 4px 10px rgba(0,0,0,0.12);
transform: translateY(-2px);
transition: all 0.2s ease-out;
}
.ogp-image {
width: 200px;
height: 110px;
object-fit: cover;
flex-shrink: 0;
border-right: 1px solid #eee;
}
.ogp-text {
padding: 10px 15px;
display: flex;
flex-direction: column;
overflow: hidden;
flex-grow: 1;
}
.ogp-title {
font-size: 1em;
font-weight: bold;
margin: 0 0 5px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: inherit;
}
.ogp-description {
font-size: 0.8em;
color: #666;
flex-grow: 1;
margin: 0 0 5px 0;
line-height: 1.4;
height: calc(1.4em * 2);
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
display: -webkit-box;
}
.ogp-link {
font-size: 0.75em;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ogp-link-wrapper {
text-decoration: none;
}
.ogp-link:hover {
text-decoration: underline;
}
.ogp-card-loading,
.ogp-card-error {
display: flex;
align-items: center;
justify-content: center;
height: 110px;
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 8px;
color: #888;
}
最後に、OGP プレビューを挿入したい箇所に、class="auto-ogp-preview" を指定したタグを仕込みます。
<div class="auto-ogp-preview" data-url="https://qiita.com/hajime-f"></div>
すると......
表示されました!!!
問題点
きちんと OGP プレビューができるようになったのですが、実は問題が2つあります。
まず、セキュリティ上の問題。これは 「エンドポイント URL をハードコーディングしているので丸見えになっている」 ことです。
万が一、悪意のある第三者がこのエンドポイントに対して大量のリクエストを送ると、AWS からの請求がひどいことになる......これはまずい。
そこで、API キーを導入して GitHub Actions で云々......と、いろいろ調べながら考えたのですが、「GitHub Pages としてホスティングしている限り、隠蔽は難しい」という結論に至りました。
API キーを安全に使うには、サーバーサイドのプログラムを間に挟んで、そこから API を呼び出す必要があるはずですが、静的ファイルをシンプルにホスティングするだけの GitHub Pages にはこのサーバーサイドの機能がないからです。
最も簡単で確実な解決策は、サーバーレス関数機能が無料で使えるホスティングサービス(Netlify など)に移行することでしょう。
......面倒くさいけど、そのうちやります、たぶん。
そして、もう1つは、「Amazon からは OGP 情報を取得できない」 ことです。
Amazon のサイトには高度なボット対策がされているらしく、単純なヘッダー偽装だけではブロックされてしまうようです。Lambda のようなデータセンターからのアクセスは、特に厳しく監視されているとか......同じアマゾンなのに...。
これについては諦めました。解決策がないわけではないのですが、あまり現実的ではないので。
おわりに
というわけで、フロントエンドから API Gateway を介して Lambda をキックし、OGP 情報を取得することを実現しました。
これ自体が技術力を示す証拠になると思うので、皆さんもこれを使ってイケてるポートフォリオサイトを作ってみてはいかがでしょうか。

