コンテンツにスキップ

URL ソース

capture-ledger の入力は Postgres のテーブル 1 つだけです。API は INSERT しません — capture_targets への投入は呼び出し側の責務で、手動 INSERT でも、外部パイプラインでも、 同梱の seed でもかまいません。 開発用には、行を足す小さな CLI(pnpm run targets)も同梱しています(URL を追加する)。

fromTargets で始めたクロールが読むのは、これだけです。

SELECT url FROM capture_targets WHERE enabled AND org_id = $1 ORDER BY id ASC [LIMIT $2]

ORDER BY id ASC なので登録順に起点になり、fromTargets.limit は先頭 n 件を取ります。 つまり動作確認では常に同じ URL が対象になります。 org_id で絞るのがテナントの境目です —— クロールは組織を 1 つしか持たないので、 別の組織の行を起点にすると帰属が言えなくなります。

.addColumn("url", "text", (col) => col.notNull().check(sql`url <> '' AND url = btrim(url)`))
.addColumn("url_hash", sql`bytea`, (col) =>
col.generatedAlwaysAs(sql`digest(url, 'sha256')`).stored(),
)
.addColumn("labels", sql`text[]`, (col) => col.notNull().defaultTo(sql`'{}'::text[]`))
.addColumn("enabled", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`))
.addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`))
カラム 補足
id BIGSERIAL 主キー。投入順で、ローダの ORDER BY がそれを保つ。
url CHECK (url <> '' AND url = btrim(url)) と CHECK (url ~* '^https?://') —— 空文字・前後の空白・http(s) でない値をデータベースが拒否するので、呼ぶ側で検査する必要がない。
url_hash 生成列 digest(url, 'sha256') (pgcrypto) を stored 保存。ユニークインデックスの土台で、直接読むことはない。
labels TEXT[]。もう誰も読みません —— 下記を参照。
enabled ホットパスは WHERE enabled で、部分インデックス capture_targets_enabled_id_idx が覆う。無効行はコストにならない。
org_id どの組織の対象か。既定値は無い —— INSERT では必ず書く(書かなければ NOT NULL で落ちる)。fromTargets はこれで絞る。
created_at / updated_at now() 既定。自動更新トリガは今のところ無い。

capture_targets_org_url_hash_key は (org_id, url_hash) のユニーク索引です。 1 つの組織の中では、同じ URL を二重に登録できません。 別々の組織なら、同じ URL をそれぞれの対象にできます。

かつては自由形式で成果物のファイル名に入り、外部キーを持たせる場所として自然でした。 「自由形式」は文字どおりで、ファイル名の構造とぶつかる文字(_ . / 空白)は BrowserHive が逃がすため、非 ASCII も含めてそのまま往復し、制限は「成果物の名前全体が 255 UTF-8 バイトに収まること」だけでした。同梱のサンプルは今も証券コードと社名を 並べています。

{ url: "https://www.ana.co.jp/group/", labels: ["9202", "ANAHoldings"] }

開発中は CLI を使います。DB に直接書く道具で、API は通らず、認可もありません。 どの組織の対象かは、必ず訊かれます。

ターミナルウィンドウ
pnpm run targets add https://example.com/ --org acme
pnpm run targets add https://example.com/ https://example.org/docs --org acme --label demo
pnpm run targets add - --org acme < my-urls.txt # 1 行 1 URL。空行と # の行は読み飛ばす
pnpm run targets list --org acme # --json で機械向け
pnpm run targets disable 6 # 履歴を残したまま外す(enable 6 で戻す)
pnpm run targets rm 6

URL は、クロールが起点を読むのと同じ関数で読み、同じ形(フラグメントを落とした形)で保存します。 ここで足せた URL は、撮るときにも読めます。 読めない URL が 1 本でもあれば、何も足さずに名指しします。 無効にしてあった URL を足すと、有効に戻ります。 札は --label a --label b のように 1 つずつ渡します。 --label a b と書くと、b は URL として読まれ、読めないので何も足さずに止まります。

SQL で足すときは、組織を自分で書きます。

INSERT INTO capture_targets (url, org_id, labels) VALUES
('https://example.com/', 'acme', ARRAY['example']),
('https://example.org/', 'acme', ARRAY['example', 'org'])
ON CONFLICT (org_id, url_hash) DO NOTHING;

org_id は必ず書きます。既定値が無いので、書かなければ NOT NULL で断られます。 クロールは、呼んだ人の組織の行だけを起点にします。

履歴を残したまま対象から外したいときは、行を削除せずに無効にします (pnpm run targets disable <id>、または enabled = false)。

マイグレーションは src/db/migrations/<NNN>-<説明>.ts に置き、up(db) / down(db) を export します。ランナーは Kysely の Migrator + FileMigrationProvider の薄いラッパで、適用済み ID を kysely_migration テーブルで管理します (同時実行は kysely_migration_lock が守ります)。最新の状態で pnpm run db:migrate を再実行しても何も起きません。

ターミナルウィンドウ
pnpm run db:migrate # 適用
pnpm run db:migrate:down # 直前の 1 つを巻き戻す

追加するときは:

  1. 次の連番を取る (例 002-add-priority.ts)。
  2. スキーマビルダで up / down を実装する。ビルダで書けないもの (拡張、生成列、 他カラムを参照する CHECK) は sql`…`.execute(db) を使う。
  3. ローカルで往復させる: pnpm run db:migrate && pnpm run db:migrate:down && pnpm run db:migrate。 CI も同じ往復を実行します。
  4. マイグレーションと、それに依存するコードは同じ PR でコミットする。

seed も src/db/seeds/ に同じ形で置きますが、記録は kysely_seed と別なので、 マイグレーションとは独立に適用・巻き戻しできます。