1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スクレイパーを作り直した話 ― 権利者に断られてから、全画像を消すまで

1
Posted at

個人でMotoHubという中古バイクの情報サイトを運営している。全国の販売店や在庫、道の駅、駐輪場、レンタルガレージなどを集めて地図に載せるサイトで、データの多くはスクレイピングで集めている。

先日、そのうちの1社に画像の利用を問い合わせて、断られた。取得済みのものも含めて掲載を止めてほしい、と言われた。

その対応を通して、自分のスクレイパーがいかに雑だったかを思い知ったので、記録として残しておく。前半は「やってしまっていたこと」と「掲載停止の実務」、後半は同じ作業で踏んだ技術的な落とし穴を並べる。


1. 問い合わせて、断られた

きっかけは在庫画像だった。ある事業者のCDNから画像を取得して自サイトで表示していたのだが、これは許諾を取っていない。まずいと思って問い合わせた。

返ってきた答えは明快だった。

恐れ入りますが許諾は致しかねますので取得済の画像も含めた掲載の停止にてお願いいたします。掲載停止の処理が完了されましたら一報いただけますと幸いです。

当然の回答だと思う。むしろ丁寧に対応してもらった。

問題は「取得済の画像も含めた掲載の停止」をどうやって完全にやりきるか、である。ここから半日かけて、表示を止め、実体を消し、消えたことを検証した。


2. 問い合わせる前に、そもそも何がまずかったか

対応の過程で自分のスクレイパーを全部読み直した。3つ、はっきりまずい点があった。

すべてのスパイダーがブラウザを詐称していた。 User-Agent に Chrome の文字列を入れていた。20箇所のUA設定を数えたら、20箇所すべてがそうだった。取得を止められないための、いわば防御的な設定のつもりだったのだと思う。だが相手から見れば、自分が誰なのか名乗らずに来る自動アクセスである。名乗っていれば、相手は判断できる。名乗らなければ判断の機会すら奪う。

いまは全スパイダーが MotoHubBot/1.0 (+https://motohub.jp/) を名乗る。URLを入れておけば、相手は素性を確認できるし、止めたければ robots.txt で止められる。

robots.txt を一度も見ていなかった。 Scrapy の ROBOTSTXT_OBEY は、フレームワークの既定値が False である。プロジェクトテンプレートには True が書かれているが、設定を自前で組み立てていると既定の False がそのまま効く。私はそうなっていた。

ROBOTSTXT_OBEY = True にして、ついでに対象サイトの robots.txt を全部読んだ。そこで見つかったのが3つ目の問題である。

画像CDNのホストが、全クローラーのアクセスを拒否していた。 問題の img.webike-cdn.net の robots.txt は Disallow: / を全 User-Agent に対して返していた。つまり、問い合わせる以前に、明示的に断られていた。読んでいなかっただけだ。

このホストは即座にダウンロード禁止リストに入れた。

MOTOHUB_USER_AGENT = "MotoHubBot/1.0 (+https://motohub.jp/)"
DISALLOWED_IMAGE_HOSTS = {"img.webike-cdn.net"}

def is_image_url_allowed(url):
    if not url:
        return True
    host = (urlparse(str(url)).hostname or "").lower()
    return host not in DISALLOWED_IMAGE_HOSTS

3. 掲載停止の実務

ここからが本題である。「表示を止める」と「実体を消す」は別の作業で、順番を間違えると悲惨なことになる。

落とし穴: DBの値を消すと、逆に相手のサーバへ直リンクが復活する

在庫レコードには2種類の画像情報が入っていた。相手のCDNのURL(image_urls)と、それをダウンロードして自分のストレージに置いたパス(local_image_paths)である。表示ロジックは「ローカルがあればローカル、なければ元URL」という順で見ていた。

最初、素直に local_image_paths を空にしようとした。実体を消すのだから、DBのパスも消すのが筋に思えた。

だがそれをやると、表示ロジックのフォールバックが働いて、元URLすなわち相手のCDNへの直リンクが表示される。閲覧されるたびに相手のサーバにリクエストが飛ぶ、いまより悪い状態である。

正解は逆で、DBの値は一切消さず、モデルのアクセサで隠す

/** 画像を一切表示しないサイトID。
 * ※ local_image_paths を消すと images アクセサが image_urls(先方CDN直リンク)へ
 *   フォールバックし、閲覧のたびに先方サーバへ直リンク要求が飛ぶ"より悪い"状態になる。 */
public const IMAGE_SUPPRESSED_SITE_IDS = [3];

public function imagesAreSuppressed(): bool
{
    return in_array((int) $this->site_id, self::IMAGE_SUPPRESSED_SITE_IDS, true);
}

images() / imageUrls() / localImagePaths() の3つが、抑止時に空配列を返す。Laravel のアクセサはシリアライズにも効くので、ビューもAPIリソースも構造化データも、1行も触らずに全部止まる。DBの値は監査のために残る。

見落とし: 在庫画像だけ見ていて、店舗写真を忘れていた

在庫画像を止めて本番を確認したら、まだ1件だけ相手のCDNへの参照が残っていた。追いかけると、在庫詳細ページの「販売店情報」ブロックに出ている店舗の写真だった。

調べたら、店舗テーブルの image_url に相手のCDNを指す行が 9,679件あった。在庫だけ見ていたら、報告した後に「まだ出ています」と言われるところだった。

店舗側も同じ方針で塞いだ。ただし店舗には在庫のような「サイトID」が無いので、URLのホスト名をキーにした。

public const SUPPRESSED_IMAGE_HOST_KEYWORDS = ['webike-cdn'];

ここでもうひとつ落とし穴があって、店舗詳細ページだけは表示用アクセサを通さず生の image_url を直接出力していた。表示用アクセサだけ塞いでも、そのページは出し続ける。結局 image_url / local_image_path / 表示用アクセサの3口すべてを塞ぐことになった。

こういうものは grep で全経路を洗い出さないと必ず取りこぼす。

実体の削除: 1ファイルずつ消してはいけない

表示を止めたら、次は実体である。対象はこうだった。

対象 件数 サイズ
在庫画像(自サーバ) 13,985 ファイル 568.76 MB
在庫画像(オブジェクトストレージ) 105,669 オブジェクト 計測せず
店舗写真(自サーバ・9,679店舗分) 19,720 ファイル 154.66 MB

最初に書いた削除コマンドは、DBを1行ずつ走査して、記録されたパスを1つずつ existssizedelete する作りだった。ローカルなら問題ないが、オブジェクトストレージ相手だとこれが1ファイルあたり最大3回のAPI呼び出しになる。在庫49,516件 × 複数枚で、数十万から百万回のリクエストになる。時間もコストも見合わない。

書き直して、ディレクトリ単位の削除にした。オブジェクトのキーはローカルの相対パスと完全一致しており、対象は全部 listings/webike/ 配下にある。ならば一覧を1回取って、ディレクトリごと消せばよい。S3互換APIのバッチ削除が効く。

副産物として、DBに記録されていない孤児ファイルも一掃できた。これが効いた。店舗写真は、DBに local_image_path が入っているのは 168件しかないのに、ディスク上には 19,720ファイルあった。1店舗あたり複数枚ダウンロードして先頭の1枚だけをDBに記録する作りだったので、残り全部が記録されていなかったのである。DB駆動で消していたら、19,552ファイルがそのまま残っていた。

「取得済の画像も含めた掲載の停止」と言われたときに、DBに載っているものだけ消して報告するのは、たぶん誠実ではない。

消えたことを検証する

削除コマンドとは別に、読み取り専用の検証モードを作った。DBに記録された全パスについて実在を確認し、対象ディレクトリのオブジェクト数を数え、店舗ディレクトリの残存を数える。結果はすべて0だった。

さらに、旧画像URLを直接叩いて 404 が返ることも確認した。サイト内の全ページを走査して相手のCDNへの参照が0件であることも確認した。

ここまでやって、はじめて「完了しました」と書ける。


4. ジオコーディングの規約も見直した

同じ流れで、住所から座標を求める処理も点検した。ここにも規約違反があった。

Nominatim(OpenStreetMap の検索API)は、利用規約でバルクジオコーディングを禁止している。 数万件を流すのは明確に想定外の使い方である。Google Maps のスクレイピングは利用規約違反であり、Places API のデータは30日を超えて保存できない

結局、日本国内の住所については国土地理院のジオコーディングAPI一本に絞った。精度も日本の住所に対しては十分で、むしろ Nominatim より良い。実測すると、両者の差は都市部で50〜80メートル程度、つまり同じ街区の中に収まる。

面白かったのは、「統一しすぎない」判断をした箇所があったことだ。

レンタルガレージ用のジオコーディングだけ、共通サービスを経由せず自前でAPIを叩いていた。当然これも共通化しようとしたのだが、実装を読んだAIから差し戻された。共通サービスのガードは「結果が県名だけ」の場合しか弾かないのに対し、この自前実装は「番地を含まない、市区の中心を指す結果」も弾いている。共通化すると、番地が解決できない住所で市区の中心座標が「成功」として保存されてしまう。

つまりもっともらしく間違った座標が増える。統一の美しさより、間違ったデータを入れないほうが大事だ。この判断はコメントとして残した。将来の自分が「なんで統一されてないんだ」と思って壊さないように。


5. 同じ作業で踏んだ、技術的な罠

ここからは種類の違う話。半日で踏んだものを並べる。

「ちょうど20件」が並んだら疑う

別の事業者からレンタルガレージを701件取得していた。市区町村別に集計したら、こういう並びになった。

横浜市鶴見区 20 / 横浜市港北区 20 / 川崎市川崎区 20
大田区 20 / 厚木市 20 / 藤沢市 20
川口市 19 / 川崎市幸区 19 / 大和市 17 ...

20を超える市区が1つも無い。一覧ページの1ページあたり表示件数が20件だった。ページ送りを実装していなかったので、20件を超える市区は切り捨てられていたわけである。

ただし、ここには続きがある。相手サイトの一覧ページは総件数を出していたので、それを読み取って突き合わせたところ、実際に不足していたのは3市区・14件だけだった。20件ちょうどの6市区のうち3つは、本当にその市区に20件しかなかった。

「切り捨てだ」と決めつけて全件取得に作り替えようとしたのだが、それをやると絞り込み条件(バイク収納可のカテゴリ)まで外れて、無関係な物件を1,100件も取り込むところだった。症状から原因を推定したら、必ず数字で裏を取る。

結局、一覧ページの総件数と実際の取得数を比較して不足を検知し、不足がある都道府県だけ別の切り口(駅ごとのページ)も辿って合流させる、という形に落ち着いた。不足の無い県には余計なアクセスをしない。

相関サブクエリは空間インデックスを殺す

125,130件のポリゴンから、座標がどの市区町村に含まれるかを引く処理を書いた。SPATIAL INDEX は張ってある。

-- これは動かない(935秒ハングして手動で kill した)
UPDATE pois p SET municipality_code = (
  SELECT code FROM municipality_polygons
  WHERE ST_Contains(geom, POINT(p.longitude, p.latitude)) LIMIT 1
);

ST_Contains(geom, <他テーブルの列を含む式>) は、行ごとに引数が変わるため空間インデックスを使えない。全ポリゴン走査になる。

同じテーブルに対して ROW_NUMBER() OVER (PARTITION BY code ORDER BY ST_Area(geom)) を書いたときは "Out of sort memory" で落ちた。12万件のジオメトリBLOBをソートしようとするからである。

正解は、アプリケーション側から1行ずつリテラルの座標を渡すこと。ダサく見えるが、インデックスが効くので圧倒的に速い。

MySQL の既定照合順序は、半角と全角を区別しない

utf8mb4 の既定照合順序は、半角カタカナと全角カタカナ、大きい「ケ」と小さい「ヶ」、さらにひらがなとカタカナまで同一視する。

SELECT '鳩ヶ谷' = '鳩ケ谷';  -- 1 が返る

住所の突き合わせでは救われることも多いが、「表記が違うレコードを探す」ときには致命的に困る。厳密に比べたいときは COLLATE utf8mb4_bin を付けるか HEX() で比較する。

これに気づかず「全角の『i』が混ざっている道の駅を直そう」としたのだが、調べたら正規化処理が既に吸収していて、表示は正常だった。無駄な修正をせずに済んだ。

PHP はパイプへの出力を約8KBバッファする

長時間かかるコマンドをバックグラウンドに流して tail -f で見ていると、何分経っても最初の1行しか出てこない。処理が止まったように見える。

止まっていない。PHPはパイプに書くとき約8KBためてから流すので、それだけの出力がたまるまで何も見えないだけである。

進捗を見たいなら明示的にフラッシュするか、そもそも標準出力を進捗計にしないほうがいい。私はDBの MAX(updated_at) を見て進み具合を測るようにした。こちらのほうが確実である。

LIKE を使った一括UPDATEで、無関係な行を壊した

ある地域の座標がずれていたので、まとめて直そうとした。

UPDATE shops SET latitude = ..., longitude = ... WHERE address LIKE '%高丘西%';

同じ地名が別の市にもあった。無関係な3店舗の正しい座標を上書きした。

UPDATE の前に、必ず同じ条件で SELECT して件数と中身を見る。当たり前のことなのだが、急いでいるときほどやらない。

コンテナのクライアントを切っても、中のプロセスは死なない

docker compose exec で長時間のコマンドを流していて、SSHが切れた。ジョブは消えたように見えるが、コンテナ内のプロセスは走り続けている。ログの書き込みだけが止まる。

慌てて再実行すると二重に走る。実際に一度やってしまって、ログが混ざった。中で走っているかどうかは /proc を数えれば分かる(軽量なコンテナには ps すら無いことが多い)。

docker compose exec -T app sh -c \
  'for p in /proc/[0-9]*; do c=$(tr "\0" " " < $p/cmdline 2>/dev/null); case "$c" in *コマンド名*) echo "$p";; esac; done'

公共APIは普通に落ちる

この記事を書いている最中に、毎朝のPOI取得が失敗していたことに気づいた。原因は Overpass API が 504 を返していたことだった。3回リトライして駄目なら例外で全体を落とす作りだったので、1地域の一時的な混雑で日次バッチ全体が死んでいた。

公共の共有APIを使う以上、落ちるのは異常ではなく通常運転である。1つの地域の失敗で全体を止めず、失敗した地域を記録して次に進み、最後にまとめて報告する。そう直すことにした。


まとめ

技術的な話より、順序の話をしたい。

取得する前に robots.txt を読む。 読んでいれば、そもそも取ってはいけないものを取らずに済んだ。

名乗る。 UAを詐称しないだけで、相手は自分の意思で判断できるようになる。

聞く。 断られる可能性はあるが、聞かずに使い続けるよりずっといい。断られたら止めればいい。

消せる設計にしておく。 出所ごとにディレクトリを分ける、表示を1箇所で止められるようにする、消したことを検証する手段を持つ。これがあるかないかで、いざというときの所要時間が桁違いになる。

今回は半日で完了報告まで出せた。設計が悪ければ数日かかっていたと思うし、取りこぼしたまま「完了しました」と送っていた可能性もある。それが一番まずい。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?