本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

本記事では、特定の運営者を指し示さないよう、対象サイトのドメイン・パス・識別子は伏せ字(museum.example などのプレースホルダ)にしています。技術的な構成の記録が目的で、特定サイトからの収集手順を示すものではありません。

調べたことと結論

ある公開コレクション(美術館)について、画像とメタデータを API 経由で取得できるかを調べました。調査した範囲での結論は次の通りです。

  • メタデータは公式の JSON API で取得できる(キー不要で、利用も公認されている)
  • 画像URLは、その API のレスポンスには含まれていない
  • 画像は別ドメインの CDN(DAM 製品の配信)にあり、URLは作品詳細ページの HTML 内にのみ現れる
  • 詳細ページは Cloudflare Bot Fight Mode の対象で、素の curl やヘッドレスブラウザでは 403 が返る
  • robots.txt は詳細ページの自動収集を対象外(Disallow)としている

「公開されているか」と「機械可読に取得できるか」と「取得してよいか」が、それぞれ別の問いとして分かれていた点が興味深かったので、調査の過程を記録として残します。なお robots.txt の方針を尊重し、全件の収集・データセット化は行っていません。

メタデータは公式 API で取得できる

運営者は公式のコレクションAPIを公開しています。ドキュメントによれば、キー不要・レート制限なし(現時点)とされています。

https://museum.example/api/v1/collection?search_api_fulltext=<検索語>
  • search_api_fulltext に検索語を渡して絞り込みます(検索語を与えない場合は既定の一覧が返りました)
  • 1ページ10件で、page= でページ送りします

返ってくる JSON の1レコードは次のような構造です(28フィールド。値はプレースホルダに置換)。

{
  "id": "<object-id>",
  "title": "<作品タイトル>",
  "primaryMaker": "<作者名>",
  "makers": [{ "name": "<作者名>", "years": "<生没年・出身>", "nationality": ["<国籍>"] }],
  "medium": ["<技法・素材>"],
  "datingYearFrom": "<制作年>", "datingYearTo": "<制作年>",
  "dimensions": "<寸法>",
  "objectNumber": "<整理番号>",
  "publicDomain": null,
  "type": ["<分類>"],
  "url": "https://museum.example/collection/<slug>"
}

メタデータとしては十分にリッチですが、画像URLのフィールドは含まれていません。あるのは作品ページへの urlpublicDomain フラグです。

画像の所在

url の作品詳細ページを開いて <img> を確認すると、画像はすべて別ドメインから配信されていました。

https://<alias>.cdn.picturepark.com/v/<8文字トークン>/

DAM(Digital Asset Management)製品である Picturepark Content Platform(現在は Fotoware 傘下で "Fotoware Alto" へ改名が進んでいるようです)の配信URLです。<alias> は契約者ごとのサブドメインです。調査した限り、このトークンは API のレスポンスには含まれず、詳細ページの HTML にのみ埋め込まれていました。

したがって画像URLを得る経路は次のようになります。

API で id と url を取得
  → url の詳細ページHTMLを取得
    → HTML から <alias>.cdn.picturepark.com/v/<token>/ を抽出

画像URLの取得経路:コレクションAPI(robots対象外)→ 作品詳細ページHTML(Cloudflare Bot Fight・robots Disallow)→ DAM の CDN(curl可・CORS *)

Cloudflare Bot Fight Mode の挙動

その詳細ページの取得には一手間ありました。

$ curl -s -o /dev/null -w "%{http_code}\n" https://museum.example/
403

User-Agent を Chrome に合わせても、AcceptReferer を加えても 403 でした。ドメイン全体で素の curl に対して 403 を返します(Cloudflare の "Sorry, you have been blocked" 応答)。

Cloudflare のブロック画面「Sorry, you have been blocked」(ドメイン名は伏せ字に加工)

ヘッダではなく TLS/ブラウザのフィンガープリントで判定しているようで、ヘッドレス Chrome(Playwright headless: true)でも 403 でした。特徴的だったのは、cf_clearance クッキーが発行されない点です。クッキー型のチャレンジ通過ではなく、リクエストごとにフィンガープリントを評価しているとみられ、「一度通過して得たクッキーを curl で使い回す」方法は今回は成立しませんでした。

200 が返ったのはヘッド付き(実ウィンドウ)の Chrome でした。

import { chromium } from 'playwright';

const ctx = await chromium.launchPersistentContext('/tmp/profile', {
  channel: 'chrome',     // 実Chrome
  headless: false,       // headless: true では今回は弾かれた
});
const page = ctx.pages()[0] ?? await ctx.newPage();
await page.goto('https://museum.example/collection', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);   // 初回チャレンジ通過待ち

一度オリジンを開いた後は、そのページ内から fetch() すると Chrome のネットワークスタックを経由するため Cloudflare を通過しました。これを使うとフルページ描画なしに HTML を取得できます。

const tokens = await page.evaluate(async (url) => {
  const html = await (await fetch(url, { headers: { Accept: 'text/html' } })).text();
  return [...new Set([...html.matchAll(/picturepark\.com\/v\/([A-Za-z0-9]+)/g)].map(m => m[1]))];
}, detailUrl);

DAM トークンの観察

ここからは /v/<token>/ を実測して分かったことです(トークン値・ファイル名は伏せ字)。

署名も期限もない固定トークン

$ curl -sI https://<alias>.cdn.picturepark.com/v/<token>/
HTTP/2 200
content-type: image/jpeg
content-disposition: inline; filename=OBJ-00000.tif.jpg
etag: "<sha1>"
cache-control: no-cache
access-control-allow-origin: *
via: 1.1 varnish, 1.1 varnish
x-served-by: cache-fra-xxxxxxxx-FRA, cache-nrt-xxxxxxxx-NRT
x-cache: HIT, HIT
age: 591
  • 署名クエリや有効期限を持たない、パスだけのURLでした。レンダリングごとに発行される一時署名URLではなく、(資産 × レンディション)に対して固定で割り当てられたトークンのように見えます。
  • トークンを1文字でも変えると 404 になりました。英数字8文字(base62)の空間は約 2×10^14 で、現時点では推測・列挙は困難なようです。
<token>        -> 200
<token末尾1字違い> -> 404
ZZZZZZZZ       -> 404
  • cache-control: no-cache が返る一方で、Fastly エッジでは HIT していました(age が増えていきます)。ブラウザには毎回再検証させつつ、エッジはキャッシュを返す構成のようです。via: 1.1 varnish は Varnish ベースのキャッシュを示すもので Fastly 専用ではありませんが、x-served-by: cache-*x-cache が併存していることから Fastly とみられます。x-served-by はフランクフルト(FRA)と東京(NRT)の二段になっていました。
  • マスターは .tif、配信は .tif.jpg の派生で、ファイル名に整理番号が含まれていました。

同一画像の3レンディション(thumb 5KB / preview 132KB / zoom 5.78MB)と、トークン改ざんで404になること、Fastly経由のレスポンスヘッダ。値は伏せ字に加工

レンディションはサイズごとに別トークン

?width=2000?size=Original を付けても、同じバイト数が返りました。imgix(?w=2000 のようなクエリ)や Cloudinary(/w_2000/ のようなパス)のようにURLで変形を指定するパラメトリックな方式ではなく、サイズごとに別トークンを持つ設計のようです。

詳細ページの DOM を見ると、各ビューが3つのトークンを持っていました。

レンディション取得元サイズ(同一画像で実測)
thumbサムネ列の img@src約 5 KB
previewdiv[data-preview-url]約 132 KB
zoomdiv[data-zoom-url]約 5.78 MB

URLでサイズを変えるのではなく、欲しいサイズのトークンを HTML から拾う形です。失効はトークン側ではなく Fastly の purge で行う運用とみられます。

その作品自身の画像に絞る

詳細ページには関連作品のサムネも多く含まれます(1ページで100トークンを超えました)。作品自身のメディアは div#carousel-node-<id> の中にあり、この <id> は API の id フィールドと一致していました。

const carousel = doc.querySelector('[id^="carousel-node-"]');   // 自身のメディアのみ
const cdn = t => `https://<alias>.cdn.picturepark.com/v/${t}/`;
const views = [...carousel.querySelectorAll('[data-zoom-url]')].map(el => ({
  zoom:    cdn(el.getAttribute('data-zoom-url').match(/\/v\/([A-Za-z0-9]+)/)[1]),
  preview: cdn(el.getAttribute('data-preview-url').match(/\/v\/([A-Za-z0-9]+)/)[1]),
}));

これで「ID → そのビューごとのURL」が得られます。

全件取得の可否

コレクション検索UIの表示では総数が10万点を超えるとされ、そのうちオンラインで閲覧できるのは8万点規模と表示されていました。全件取得を考えると、技術面とポリシー面の双方に制約がありました。

技術面:全件を列挙する手段

  • 検索語を与えない場合は既定の10件が返り、全件を一度に返すクエリは見つかりませんでした(語によっては0件のこともありました)
  • ファセット絞り込みのパラメータ(type= / f[0]= 等)は API では効かないようでした
  • /sitemap.xml/jsonapi/api/rest/session/token はいずれも 404 で、Drupal ではあるものの標準のデータ出口は公開されている範囲では無効化されているようです
  • ページ送り自体は深く効きました(ある語で60ページ・600件を超えても上限には当たりませんでした)が、与えた語にマッチする物しか辿れません

調査した限り、全件をカバーするには「語の辞書で総当たりして id で重複排除する」ような方法しか見当たらず、網羅性を保証する手段は見つかりませんでした。

ポリシー面:robots.txt の記述

User-agent: *
Content-Signal: search=yes, ai-train=no      # AI学習目的の収集は対象外(EU DSM指令4条の権利留保として記載)
Crawl-delay: 10
Disallow: /collection                        # 詳細ページ・検索が Disallow 対象
Disallow: /site-search

User-agent: ClaudeBot   Disallow: /
User-agent: GPTBot      Disallow: /
User-agent: CCBot       Disallow: /
# Amazonbot, Google-Extended, meta-externalagent などAI関連クローラも同様

画像URLの抽出に必要な詳細ページのパスは、robots.txt で Disallow 対象に含まれています。加えて Crawl-delay: 10 が指定されています。

一方で、メタデータの API エンドポイントは Disallow には含まれておらず、運営者自身も自由な利用を案内しています。整理すると次のようになります。

目的可否
メタデータ(個別・少量)API で取得可(公認)
メタデータ全件語の総当たりで近似可・網羅は保証できず
画像URL一覧(全件)詳細ページ取得が必要で、robots.txt で Disallow 対象

調査からの所感

技術的には詳細ページから画像URLを取り出せることは確認できましたが、robots.txt が詳細ページを Disallow 対象としているため、規約の趣旨を尊重して全件の自動収集は採用していません。API が正規ルートで、本記事の詳細ページ側の手法は構成を説明するためのものです。全件のデータが必要であれば、収集ではなく運営者へ直接データ提供を相談する方法が考えられます。

調査を通じて確認できたのは次の点です。

  • 画像を公開していることと、画像URLを機械可読に提供していることは別だった。メタデータ API は公開されている一方、画像URLは API には載らず、HTML 内の DAM トークンとして持っていた
  • Cloudflare Bot Fight Mode は、クッキーではなくフィンガープリントで判定する場合がある。cf_clearance を取得して使い回す方法が効かず、実ブラウザが必要になる場面があった
  • オープンデータを掲げる組織でも robots.txt の確認は必要だった。近年は Content-Signal(収集可否シグナル)や AI 関連クローラの個別 Disallow が増えており、「公開」と「収集してよい」が区別されてきている

「公開されているか」「APIで取得できるか」「取得してよいか」は、それぞれ別の問いでした。

本記事の調査は技術的検証を目的としたもので、対象サイトの robots.txt の方針を尊重し、全件取得・データセット化は行っていません。対象の識別子は伏せ字にしています。