Web画面を自動操作する投稿ツールで、本文は公開できているのにタグだけ入らない、という事故がありました。
先に、今回の根拠になった運用ログを公開向けに匿名化して引用します。
「公開に進む」クリック後に固定4秒待ちのみで公開設定画面を前提にしていた
本文が長く「保存中」が続く記事では、公開設定画面へ未到達のままタグ入力欄を探した
hashtag input not found - skipを出して、黙ってタグ0件のまま公開した
同じ経路で別記事はタグ付与できていたため、タイミング依存の再現性なきバグだった
要するに、原因は「タグ入力欄のセレクタ」ではなく、「その画面にまだ到達していないのに探しに行ったこと」でした。
起きたこと
自動投稿の流れは、おおまかに次のようなものでした。
- エディタ画面にタイトルと本文を入力する
- 「公開に進む」ボタンを押す
- 公開設定画面でタグを入力する
- 公開ボタンを押す
- 公開後にAPIで状態を検証する
問題のある実装では、2 のあとに固定時間だけ待っていました。
await page.getByRole("button", { name: "公開に進む" }).click();
await page.waitForTimeout(4000);
const tagInput = page.locator('input[placeholder*="タグ"]');
if (await tagInput.count()) {
await tagInput.fill("Playwright");
} else {
console.warn("hashtag input not found - skip");
}
短い本文では、4秒後には公開設定画面に移動していたため、このコードでも動いているように見えました。
しかし本文が長いと、クリック後もしばらく「保存中」のままです。4秒後の時点では、まだエディタ画面に残っていることがあります。その状態でタグ入力欄を探しても、当然見つかりません。
ここでさらに悪かったのは、タグ入力欄が見つからない場合にエラーにせず、警告だけ出して処理を続けたことです。結果として、投稿自体は成功し、タグだけが欠落しました。
最小再現
実サービスを使わなくても、画面遷移が遅いページを用意すると同じ構造を再現できます。
<!-- index.html -->
<button id="next">公開に進む</button>
<div id="status"></div>
<script>
document.querySelector("#next").addEventListener("click", () => {
document.querySelector("#status").textContent = "保存中...";
setTimeout(() => {
document.body.innerHTML = `
<label>
タグ
<input aria-label="タグ" />
</label>
<button>公開</button>
`;
}, 7000);
});
</script>
これに対して、固定4秒待ちでタグ欄を探すテストを書きます。
import { test } from "@playwright/test";
test("fixed timeout can miss the tag input", async ({ page }) => {
await page.goto("http://localhost:3000");
await page.getByRole("button", { name: "公開に進む" }).click();
await page.waitForTimeout(4000);
const tagInput = page.getByLabel("タグ");
if (await tagInput.count()) {
await tagInput.fill("Playwright");
} else {
console.warn("tag input not found - skip");
}
});
このテストは「失敗」せずに終わります。だから危険です。
本当はタグ入力が必須なのに、コード上は「見つからなければスキップ」という任意処理になっています。これだと、期待した状態になっていないことを自動化コード自身が見逃します。
修正方針
修正は大きく3つです。
- 固定秒数ではなく、URLや要素の状態を待つ
- 必須要素が見つからない場合はハードエラーにする
- 投稿前後に、期待した状態を検証する
たとえば Playwright なら、画面遷移を明示的に待ちます。
await page.getByRole("button", { name: "公開に進む" }).click();
await page.waitForURL(/\/publish\//, { timeout: 30_000 });
const tagInput = page.getByLabel("タグ");
await tagInput.waitFor({ state: "visible", timeout: 20_000 });
await tagInput.fill("Playwright");
await page.keyboard.press("Enter");
ただし、URLだけに依存するのも十分ではありません。
SPAではURLが変わっても、必要なフォームがまだ描画されていないことがあります。そこで、URLの到達と入力欄の表示を分けて待ちます。
async function waitForPublishSettings(page) {
await page.waitForURL(/\/publish\//, { timeout: 30_000 });
const tagInput = page.getByLabel("タグ");
await tagInput.waitFor({ state: "visible", timeout: 20_000 });
return { tagInput };
}
「見つからなければスキップ」をやめる
今回のような公開フローでは、タグが必須ならスキップしてはいけません。
async function addTags(page, tags) {
const { tagInput } = await waitForPublishSettings(page);
for (const tag of tags) {
await tagInput.fill(tag);
await page.keyboard.press("Enter");
}
}
もしタグ入力欄が出なければ、waitFor がタイムアウトして処理は止まります。
この時点では、まだ公開ボタンを押していません。失敗しても未公開で止まるので、後から修復するより安全です。
投稿前に画面上の状態を確認する
タグを入力したつもりでも、UI側でチップ化されていない可能性があります。
そのため、公開ボタンを押す前に「画面上のタグ数」を確認します。
async function assertTagChips(page, expectedCount) {
const chips = page.locator("[data-testid='tag-chip']");
await chips.first().waitFor({ state: "visible", timeout: 10_000 });
const actualCount = await chips.count();
if (actualCount !== expectedCount) {
throw new Error(`tag count mismatch: expected=${expectedCount}, actual=${actualCount}`);
}
}
実際のサービスでは data-testid がないことも多いので、その場合はロール、ラベル、テキスト、近傍要素などから、できるだけ安定した locator を作ります。
重要なのは、タグ入力操作を「やったつもり」で終わらせず、UI上の結果まで見ることです。
投稿後はAPIで検証する
画面操作の成功ログだけでは、公開後の状態は保証できません。
公開後に取得APIがあるなら、別経路で検証します。
async function verifyPublishedArticle({ itemId, expectedTags, token }) {
const res = await fetch(`https://example.com/api/items/${itemId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
throw new Error(`verify failed: ${res.status}`);
}
const item = await res.json();
const actualTags = item.tags.map((tag) => tag.name).sort();
const expected = [...expectedTags].sort();
if (JSON.stringify(actualTags) !== JSON.stringify(expected)) {
throw new Error(
`tag mismatch: expected=${expected.join(",")}, actual=${actualTags.join(",")}`,
);
}
}
APIトークンは環境変数から読む前提です。ブラウザに露出するフロントエンドコードへ直接埋め込まないようにします。
const token = process.env.PUBLISH_API_TOKEN;
if (!token) {
throw new Error("PUBLISH_API_TOKEN is required");
}
修正後の全体像
最終的には、次のような流れにしました。
async function publishArticle(page, article) {
await page.goto("https://example.com/editor");
await page.getByLabel("タイトル").fill(article.title);
await page.getByLabel("本文").fill(article.body);
await page.getByRole("button", { name: "公開に進む" }).click();
const { tagInput } = await waitForPublishSettings(page);
for (const tag of article.tags) {
await tagInput.fill(tag);
await page.keyboard.press("Enter");
}
await assertTagChips(page, article.tags.length);
await page.getByRole("button", { name: "公開" }).click();
await page.waitForURL(/\/items\//, { timeout: 30_000 });
}
ポイントは、待機を「時間」ではなく「状態」に寄せたことです。
- 公開設定画面のURLに到達したか
- タグ入力欄が表示されたか
- タグチップが期待数だけ作られたか
- 公開後APIでタグが反映されているか
ここまで見ると、同じようなタイミング依存の失敗をかなり見つけやすくなります。
学び
固定秒数待ちは、ローカルでは動いているように見えます。
でも、本文量、ネットワーク、保存処理、サーバー側の混雑、ブラウザの描画タイミングが少し変わるだけで壊れます。しかも今回のように「見つからなければスキップ」という実装と組み合わさると、壊れたことに気づきにくくなります。
自動化コードでは、次の2つを分けて考えるのが大事でした。
- 待つべきものは何か
- 見つからなかったときに続行してよいものか
タグ、価格、公開範囲、送信先のように、公開結果に影響する項目は「任意に見えるUI部品」でも、業務上は必須です。
必須なら、スキップではなく停止。
このルールを入れるだけで、Playwrightの自動操作はかなり堅くなります。