リンクを辿る
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 側にあり、単体試験が付いている。
礼儀はループの形で守る
Section titled “礼儀はループの形で守る”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);3 つ目は後から足した
Section titled “3 つ目は後から足した”無いと、間隔は段の中では効き、段の境目で消える。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 は browser 1 台に口 1 つ
Section titled “BrowserHive は browser 1 台に口 1 つ”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 は止められない。
設定は変数から読む
Section titled “設定は変数から読む”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_urlu/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 の形を変数の名前を挙げて断る。