コンテンツにスキップ

リンクを辿る

capture-ledger の POST /api/crawls が起点を受け取り(URL の配列か、capture_targets の有効な行を起点にする fromTargets)、 1 段ずつ flow に投げる。 flow は f/waggle/crawl_level で、1 回の実行が 1 段。

plan_level ホストで束ね、robots.txt を 1 ホスト 1 回引く
↓
compile_scripts TypeScript のスクリプトを型検査して JavaScript にする(ts-compile-service)
↓
for-each ホストごとに並列(parallelism = host_parallelism)
crawl_host 1 ホスト内は逐次。完了 → 間隔 → 次
↓
report_level capture-ledger に報告し、次の段があるかを受け取る
↓
index_level 台帳に載ったぶんを索引に載せるよう capture-ledger に頼む

compile_scripts は、この flow で唯一、スタックの外のサービスを呼ぶ段 —— ts-compile-service で、この repo の compose の ts-compile として動く。1 本でも型が通らなければサービスは 422 を返し、段が投げる。 flow は fail_crawl へ落ち、クロールは診断の件数と最初の 5 件を reason に載せて failed で終わる。 reason は台帳の上限の 2,000 字に切る。超えると台帳が報告を断り、クロールは running のまま残って、 後のクロールを全部塞ぐ。型が通らないスクリプトはページに届かない。

繰り返すのは capture-ledger。 この flow は 1 段で終わる。Windmill の while ループに 繰り返しを持たせようとしたが、stop_after_if を付けた最小の flow が 643 回まで回り続けた。相手のサーバに負荷をかけない仕組みを、暴走しうるループの 上には載せない。上限の判定は capture-ledger 側にあり、単体試験が付いている。

Windmill CE の per-key concurrency limit は使えない —— 無言で素通しになる理由は Windmill CE に。代わりに効くのは for-loop の parallelism なので、 礼儀をループの構造として表す:

  • ホスト間 の同時数 = for-loop の parallelism
  • ホスト内 は crawl_host が逐次に回し、完了の後に間隔を空ける
  • 段の境目 は capture-ledger が「そのホストを最後に触り終えた時刻」を渡し、残りを待たせる

間隔は完了の後。投入の前ではない

Section titled “間隔は完了の後。投入の前ではない”

細部に見えて、そうではないところ。

取り込みにかかる時間は前もって分からない —— 2 秒で終わるページも 2 分かかるページも ある。だから投入から測った間隔は相手が感じる間隔と無関係で、前の取り込みが終わった 直後に次が届きうる。投入側の間隔は相手のサーバに届かない。

間隔が意味を持つのは、「前のページが終わってから、次を投げるまで」に置いたときだけ:

// **段をまたぐぶんの待ち。** 前の段でこのホストを触っていれば、その完了からの経過を
// 差し引いた残りをここで待つ。これが無いと段の境目だけ間隔が空かない (実測 521ms)。
if (initialDelayMs > 0) {
log(`前の段からの間隔を空ける: ${String(initialDelayMs)}ms`);
await sleep(initialDelayMs);
}
for (const [index, url] of urls.entries()) {
// **間隔は完了の後。** 1 件目の前は上で済ませてある。
if (index > 0 && perHostDelayMs > 0) await sleep(perHostDelayMs);

無いと、間隔は段の中では効き、段の境目で消える。3000ms 設定に対して、実測で 521ms まで詰まった。

残りを計算するのは plan_level。robots.txt は両方向に効く —— 相手がより長い値を 言っているなら、相手が勝つ:

// **長いほうを採る。** 相手が言っている値をこちらの都合で縮めない。
const crawlDelaySec = robots?.getCrawlDelay(USER_AGENT);
const delayMs =
typeof crawlDelaySec === "number" && Number.isFinite(crawlDelaySec)
? Math.max(per_host_delay_ms, Math.round(crawlDelaySec * 1000))
: per_host_delay_ms;
// 前の段でこのホストを触り終えてからの経過を差し引く。
const lastFinished = list
.map((c) => c.lastFinishedAt)
.find((t) => t !== null && t !== undefined);
const sinceMs =
lastFinished === undefined || lastFinished === null
? Number.POSITIVE_INFINITY
: Date.now() - new Date(lastFinished).getTime();
const initialDelayMs = Number.isFinite(sinceMs) ? Math.max(0, delayMs - sinceMs) : 0;

Math.min ではなく Math.max。相手が言っている値をこちらの都合で縮めない。 test/plan-level.test.ts に両側の試験があるのは、「robots があれば常に robots を採る」 実装が片側の試験を通ってしまうから。

BrowserHive は queue も pool も持たない。POST /captures は 1 往復で、走行中の server は 429 Busy で断る。だから空いている server を選ぶのは crawl_host の仕事 —— u/admin/browserhive_endpoints の一覧を順に試し、busy なら次へ、届かない口はその 呼び出しの間は飛ばし、全部 busy なら 0.5〜1.5 秒待ってもう一周する。1 つも届かない ときだけ ServerUnavailable を投げ、段ごと失敗させる。

帰結が 2 つ:

  • host_parallelism は口の数で頭打ち。 plan_level が parallelism = max(1, min(host_parallelism, 口の数)) を返し、for-loop はそれを使う。 browser より多くのホストを並列にしても、busy を引いて待つホストが増えるだけ。
  • 再試行はこちらに移った。 BrowserHive は server 側で再試行しなくなった —— あちらで 再試行すると、ここで測っている間隔を素通りする。一過性の失敗(connection / timeout / internal)はもう一度だけ試す。そのホストへの他のアクセスと同じ間隔を 空けてから。書き込み先の失敗(artifact_sink)は試し直さない —— 壊れているのは 保管庫で、ページを撮り直しても同じ保管庫に書くだけ。 方針を守れなかった失敗(policy。BrowserHive v18.0.0 から)も試し直さない —— deny に当たる WebSocket を開いた頁は、撮り直しても同じ WebSocket を開き、handshake は止められない。

crawl_host と report_level と index_level は接続先を引数では受けない。 Windmill は schema の既定値を UI からの実行にしか埋めないので、webhook で 起こすと何も届かない —— BrowserHive の宛先が undefined で 「Channel target must be a string」になった。

引数で届くのは、そのクロールについて capture-ledger が決めて必ず送るもの —— URL と 間隔、capture_formats / signing、そして scripts(ページの中で走らせる TypeScript)。 台帳が持つのは書いたままの TS で、sha256 はそのバイト列に打たれている。compile_scripts が JS にして hash を打ち直し、BrowserHive は JS の hash を照合する。2 つの hash はその 1 段で継がれる。 report_level はその JS の hash を、サービスが名乗った compiler の版と受け皿の型の tag と一緒に capture-ledger へ運ぶ(段の報告の compiled)。台帳はそれをクロールの TS の hash の隣に書き、 後の段が違う hash を報告すれば 409 で断る。スクリプトが空のクロール(scripts: [])も 1 段として通る。 サービスは 200 で scripts 無しを返し、ページでは何も走らず、報告には typescript と hostTypes が付く。 その場で書いた JS(scripts の inline の要素)も同じ道を通る。capture-ledger がクロールに inline-<name> で固定し、flow は台帳のスクリプトと同じく変換して差し込む。

scripts を必須にしてあるのは capture_formats と同じ理由。BrowserHive v11.0.0 は 走らせるものの顔ぶれを持たないので、送らなければページの中では何も走らず、それでも 取り込みは成功してアーカイブも出る。渡し忘れは「一度もスクロールしなかったクロール」に なり、した側と見分けが付かない。この flow は運ぶだけで、何を・どの順で・どちらの口 (読み込み後の behavior か、遷移前の preload か)で走らせるかは capture-ledger が 決める。

ターミナルウィンドウ
pnpm run windmill:capture-ledger-token # waggle_token / waggle_api_url /
# browserhive_endpoints / browserhive_tls_ca /
# ts_compile_url

u/admin/ts_compile_url は compile_scripts がスクリプトを送る宛先(既定は http://ts-compile.capture-scheduler:8080、この repo の compose の ts-compile。 TS_COMPILE_URL で変えられる)。pnpm run doctor が worker からその /healthz に届くかを見る。

u/admin/browserhive_tls_ca は browserhive の口が https:// のときの CA 証明書 (PEM)で、空文字は「平文」。空でも変数そのものは必ず作る —— 変数を作らない形に すると getVariable が落ち、script 側で「読めなかった」を「TLS は要らない」と扱った 瞬間、読み取りの失敗が黙って平文に落ちる経路になる。「システムの root で TLS」は 用意していない。browserhive の TLS は私設 CA を前提にしたもので、公開の証明書が要る というのは server が公開インターネット上に在るという意味になるが、そうではない。 開発のスタックは平文。

BrowserHive から写しているものは他に無い。crawl_host は BrowserHive の HTTP API (v12 からの POST /captures、本文は JSON)を fetch で話すので、同期し続ける契約の ファイルが無い —— v0.22 までは BrowserHive の gRPC の .proto の写しを Windmill の resource に 置き、腐りを捕まえる番人を付けていた。だから口は http(s):// の URL で、crawl_host は 古い host:port の形を変数の名前を挙げて断る。