0
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?

Playwright ブラウザ自動化ツールで note に自動投稿する

Posted at

note には API がありません。なのでプログラムからの自動投稿はできないのかな?と思ったけどそんなことはありません。非公開のAPIがあります。

でも非公開ってことは使われたくないんだろうなぁ、と思い別の方法をとります。WEBアプリのテストなどに使われるマイクロソフト製の Playwright というツールです。Chrome や Selenium を制御してテストなどに使うのですが、もちろん記事の投稿なんかもできます。

こちらが Playwright で投稿した記事です。

そしてこちらが Playwright の js コードです。

'use strict';

const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright');

// 簡易な現在日時文字列生成
function nowStr() {
  const d = new Date(); const z = n => String(n).padStart(2,'0');
  return `${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}`;
}

// 入力(環境変数で上書き可)
const STATE_PATH = process.env.STATE_PATH || '/home/node/data/auth/note.json';
const START_URL  = process.env.START_URL  || 'https://editor.note.com/new';
const TITLE_TEXT = process.env.TITLE      || 'サンプルタイトル';
const BODY_TEXT  = process.env.BODY       || 'サンプル本文';
const THUMB_IMG  = process.env.THUMB_IMG  || ''; // サムネ画像のパス(/home/node/data/assets/...)
const TAGS_RAW   = process.env.TAGS       || ''; // 例: "AI, ニュース"(カンマ or 改行区切り)
const IS_PUBLIC  = (process.env.NOTE_PUBLIC ?? 'true').toLowerCase() === 'true';

const TIMEOUT    = parseInt(process.env.TIMEOUT_MS || '180000', 10);
const FINAL_SS   = `/home/node/data/screenshots/note-post-simple-${nowStr()}.png`;

(async () => {
  if (!fs.existsSync(STATE_PATH)) {
    console.error('[post-simple] storageState not found:', STATE_PATH);
    process.exit(1);
  }

  const browser = await chromium.launch({ headless: true, args: ['--lang=ja-JP'] });
  const context = await browser.newContext({ storageState: STATE_PATH, locale: 'ja-JP' });
  const page = await context.newPage();

  // Playwright 全体の既定タイムアウトを拡大
  page.setDefaultTimeout(TIMEOUT);

  try {
    await page.goto(START_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
    await page.waitForSelector('textarea[placeholder*="タイトル"]', { timeout: TIMEOUT });

    // === サムネ画像の設定(ヘッダー側) ================================
    if (THUMB_IMG) {
      // 1) 最上段の「画像を追加」を特定(本文側の誤クリック回避)
      const candidates = page.locator('button[aria-label="画像を追加"]');
      await candidates.first().waitFor({ state: 'visible', timeout: TIMEOUT });
      let target = candidates.first();
      const cnt = await candidates.count();
      if (cnt > 1) {
        let minY = Infinity, idx = 0;
        for (let i = 0; i < cnt; i++) {
          const box = await candidates.nth(i).boundingBox();
          if (box && box.y < minY) { minY = box.y; idx = i; }
        }
        target = candidates.nth(idx);
      }
      await target.scrollIntoViewIfNeeded();
      await target.click({ force: true });

      // 2) 「画像をアップロード」→ filechooser(フォールバックあり)
      const uploadBtn = page.locator('button:has-text("画像をアップロード")').first();
      await uploadBtn.waitFor({ state: 'visible', timeout: TIMEOUT });

      let chooser = null;
      try {
        [chooser] = await Promise.all([
          page.waitForEvent('filechooser', { timeout: 5000 }),
          uploadBtn.click({ force: true }),
        ]);
      } catch (_) { /* 何もせずフォールバックへ */ }

      if (chooser) {
        await chooser.setFiles(THUMB_IMG);
      } else {
        // 一部実装では filechooser が発火しないため input[type=file] を直接操作
        await uploadBtn.click({ force: true }).catch(()=>{});
        const fileInput = page.locator('input[type="file"]').first();
        await fileInput.waitFor({ state: 'attached', timeout: TIMEOUT });
        await fileInput.setInputFiles(THUMB_IMG);
      }

      // 3) トリミングダイアログ内「保存」を押す(ダイアログにスコープ)
      const dialog       = page.locator('div[role="dialog"]');
      await dialog.waitFor({ state: 'visible', timeout: TIMEOUT });

      // 保存ボタン & cropper を取得
      const saveThumbBtn = dialog.locator('button:has-text("保存")').first();
      const cropper      = dialog.locator('[data-testid="cropper"]').first();

      // Locator → elementHandle に変換してから waitForFunction に渡す
      const cropperEl = await cropper.elementHandle();
      const saveEl    = await saveThumbBtn.elementHandle();

      await Promise.race([
        page.waitForFunction(
          el => getComputedStyle(el).pointerEvents === 'none',
          cropperEl,
          { timeout: TIMEOUT }
        ),
        page.waitForFunction(
          el => !el.disabled,
          saveEl,
          { timeout: TIMEOUT }
        ),
      ]);

      await saveThumbBtn.click();            // force なしで確実にヒット

      // ダイアログが閉じるのを待つ
      await dialog.waitFor({ state: 'hidden', timeout: TIMEOUT }).catch(()=>{});
      await page.waitForLoadState('networkidle', { timeout: TIMEOUT }).catch(()=>{});

      // 4) 反映確認(CSP回避のため selector ベース)
      //   A) 「画像を変更」ボタンが出る  B) 既存の「画像を追加」ボタンが消える
      const changedBtn = page.locator('button[aria-label="画像を変更"]');
      const addBtn     = page.locator('button[aria-label="画像を追加"]');

      let applied = false;
      try { await changedBtn.waitFor({ state: 'visible', timeout: 5000 }); applied = true; } catch {}
      if (!applied) {
        try { await addBtn.waitFor({ state: 'hidden', timeout: 5000 }); applied = true; } catch {}
      }
      if (!applied) console.warn('[post-simple] サムネ反映確認が曖昧(続行します)');
    }
    // === サムネ画像の設定 ここまで =====================================

    // タイトル設定
    await page.fill('textarea[placeholder*="タイトル"]', TITLE_TEXT);

    // === 本文設定(ロール or contenteditable で待機)====================
    const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
    await bodyBox.waitFor({ state: 'visible' });   // 差し替え完了を待つ
    await bodyBox.click();
    await page.keyboard.type(BODY_TEXT);

    // URLが含まれる場合は最後に改行(Enter)を追加 → note の自動展開トリガー
    if (/https?:\/\//.test(BODY_TEXT)) {
      await page.keyboard.press('Enter');
      await page.waitForTimeout(1000);
    }

    // ---- 公開 or 下書き保存 -------------------------------------------
    if (!IS_PUBLIC) {
      // ↓ 「下書き保存」ボタンを押して終了
      const saveBtn = page.locator('button:has-text("下書き保存"), [aria-label*="下書き保存"]').first();
      await saveBtn.waitFor({ state:'visible', timeout: TIMEOUT });
      if (await saveBtn.isEnabled()) {
        await saveBtn.click();
        await page.locator('text=保存しました').waitFor({ timeout: 4000 }).catch(()=>{});
        await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(()=>{});
      }

      // スクショだけ撮って正常終了
      fs.mkdirSync(path.dirname(FINAL_SS), { recursive: true });
      await page.screenshot({ path: FINAL_SS, fullPage: true });
      console.log('[post-simple] 下書き保存完了:', page.url());
      console.log('[post-simple] スクリーンショット:', FINAL_SS);
      return;                       // ここで処理終了(公開しない)
    }

    // === 「公開に進む」をクリック ===
    const proceedBtn = page.locator('button:has-text("公開に進む")').first();
    await proceedBtn.waitFor({ state: 'visible', timeout: TIMEOUT });
    for (let i = 0; i < 20; i++) { if (await proceedBtn.isEnabled()) break; await page.waitForTimeout(100); }
    await proceedBtn.click({ force: true });

    // 公開ページ(/publish)へ遷移 or 「投稿する」ボタン表示を待つ
    await Promise.race([
      page.waitForURL(/\/publish/i, { timeout: TIMEOUT }).catch(()=>{}),
      page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible', timeout: TIMEOUT }).catch(()=>{}),
    ]);

    // === タグ入力(公開ページ)===
    const tags = TAGS_RAW.split(/[\n,]/).map(s => s.trim()).filter(Boolean);
    if (tags.length) {
      let tagInput = page.locator('input[placeholder*="ハッシュタグ"]'); // 例: placeholder="ハッシュタグを追加する"
      if (!(await tagInput.count())) tagInput = page.locator('input[role="combobox"]').first();
      await tagInput.waitFor({ state: 'visible', timeout: TIMEOUT });
      for (const t of tags) {
        await tagInput.click();
        await tagInput.fill(t);
        await page.keyboard.press('Enter');       // 候補選択 or 新規追加
        await page.waitForTimeout(120);
      }
    }

    // === 投稿する ===
    const publishBtn = page.locator('button:has-text("投稿する")').first();
    await publishBtn.waitFor({ state: 'visible', timeout: TIMEOUT });
    for (let i = 0; i < 20; i++) { if (await publishBtn.isEnabled()) break; await page.waitForTimeout(100); }
    await publishBtn.click({ force: true });

    // 投稿完了待ち:/publish 以外へ遷移 or 完了トースト表示
    await Promise.race([
      page.waitForURL(url => !/\/publish/i.test(url), { timeout: 20000 }).catch(()=>{}),
      page.locator('text=投稿しました').first().waitFor({ timeout: 8000 }).catch(()=>{}),
      page.waitForTimeout(5000),
    ]);

    // スクリーンショット
    fs.mkdirSync(path.dirname(FINAL_SS), { recursive: true });
    await page.screenshot({ path: FINAL_SS, fullPage: true });

    console.log('[post-simple] 記事作成完了:', page.url());
    console.log('[post-simple] スクリーンショット:', FINAL_SS);

  } catch (e) {
    console.error('[post-simple] エラー発生:', e.message);
    process.exit(1);
  } finally {
    await context.close();
    await browser.close();
  }
})();

とても長いですねぇ。

それもそのばずで note への投稿にはタイトルと本文以外にもサムネ画像のアップロードでダイアログが開いて位置調整などもします。投稿ボタンの後にタグを設定したりもします。それぞれのボタンをポチポチするぶんだけコードが長くなります。

コードを書いたのは全部AIなので書く苦労はないけどレビューする苦労は多めでした。

ChatGPT のエージェントモードも裏で Playwright がお仕事してるのかしら、とか思いながら今日もきょうとてAI関連のニュースを読みます。ほんとコード書かなくなったなぁ。

0
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
0
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?