T-CREATOR

Node.js で社内 RPA:Playwright でブラウザ自動化&失敗回復の流儀

Node.js で社内 RPA:Playwright でブラウザ自動化&失敗回復の流儀

社内業務の効率化において、ブラウザ操作を自動化する RPA(Robotic Process Automation)は強力な手段です。特に Node.js と Playwright を組み合わせることで、開発者にとって馴染みのある JavaScript/TypeScript の知識を活かしながら、堅牢で保守性の高い自動化システムを構築できます。本記事では、Playwright を使った社内 RPA の実装方法と、実運用で重要となる失敗回復のノウハウを詳しく解説します。

背景

社内 RPA の必要性

企業では日々、様々なブラウザベースの定型業務が発生します。経費精算システムへのデータ入力、勤怠管理システムへの打刻、各種レポートのダウンロードなど、人手で行うには時間がかかり、ミスも発生しやすい作業が数多く存在しています。

これらの作業を自動化することで、従業員は本来注力すべき創造的な業務に集中できるようになります。また、夜間や休日にバッチ処理として実行することで、営業時間中の業務効率も向上するでしょう。

Playwright が選ばれる理由

ブラウザ自動化のツールには Selenium や Puppeteer など様々な選択肢がありますが、Playwright は以下の特徴から社内 RPA に適しています。

クロスブラウザ対応 Chromium、Firefox、WebKit(Safari)のすべてに対応しており、社内システムがどのブラウザで動作していても同じコードで自動化できます。

高速で安定した動作 自動待機機能が充実しており、要素が表示されるまで自動的に待機してくれるため、明示的な待機コードを書く必要が少なくなります。

開発者フレンドリー TypeScript の型定義が充実しており、IDE の補完機能を活用しながら快適に開発できます。また、デバッグツールも優れています。

以下の図は、Playwright を使った RPA システムの基本的な構成を示しています。

mermaidflowchart TB
  scheduler["スケジューラー<br/>(cron/タスク)"] -->|定期実行| script["RPA スクリプト<br/>(Node.js)"]
  script -->|起動| playwright["Playwright"]
  playwright -->|制御| browser["ブラウザ<br/>(Chromium/Firefox/WebKit)"]
  browser -->|アクセス| system["社内システム<br/>(Web アプリ)"]
  system -->|レスポンス| browser
  browser -->|結果取得| playwright
  playwright -->|データ返却| script
  script -->|記録| log["ログ/DB"]
  script -->|通知| notify["通知システム<br/>(メール/Slack)"]

この構成により、定期的に自動実行され、結果がログに記録され、エラー時には通知が送られる堅牢なシステムが実現できます。

課題

自動化における典型的な課題

ブラウザ自動化を実運用する際には、いくつかの課題に直面します。

ネットワークの不安定性 社内ネットワークや外部サービスの一時的な遅延により、ページの読み込みに時間がかかることがあります。タイムアウトエラーが発生すると、処理全体が失敗してしまいます。

要素の動的変化 JavaScript フレームワークを使った Web アプリケーションでは、DOM 要素が動的に生成されたり、遅延読み込みされたりします。要素が見つからないエラーが頻繁に発生する原因となるでしょう。

認証セッションの管理 ログインが必要なシステムでは、セッションの有効期限切れやログアウトにより、処理の途中で認証エラーが発生することがあります。

部分的な失敗への対応 複数のステップからなる処理において、途中のステップで失敗した場合、どこから再開すべきか判断が難しくなります。

以下の図は、自動化処理における典型的な失敗パターンを示しています。

mermaidstateDiagram-v2
  [*] --> Login: 処理開始
  Login --> DataInput: ログイン成功
  Login --> LoginFail: タイムアウト/認証失敗
  DataInput --> Confirmation: 入力完了
  DataInput --> ElementNotFound: 要素が見つからない
  Confirmation --> Complete: 確認完了
  Confirmation --> NetworkError: ネットワークエラー
  LoginFail --> [*]: 処理中断
  ElementNotFound --> [*]: 処理中断
  NetworkError --> [*]: 処理中断
  Complete --> [*]: 正常終了

これらの失敗パターンに対して、適切なリトライ戦略とエラーハンドリングを実装する必要があります。

エラーハンドリングの複雑さ

Playwright のエラーは様々な種類があり、それぞれに適切な対処が必要です。

TimeoutError 要素の出現待機やページ遷移でタイムアウトした場合に発生します。リトライで解決できることが多いエラーです。

Error: Execution context was destroyed ページ遷移中に要素へアクセスしようとすると発生します。ページの読み込み完了を待つ必要があります。

Error: Target closed ブラウザやタブが予期せず閉じられた場合に発生します。ブラウザの再起動が必要になります。

これらのエラーを適切に分類し、それぞれに応じたリカバリー処理を実装することが、堅牢な RPA システムの鍵となります。

解決策

Playwright の基本セットアップ

まず、Playwright を使った RPA の基本的なセットアップから始めましょう。

パッケージのインストール

Yarn を使って Playwright をインストールします。

bashyarn add -D playwright
yarn playwright install

playwright install コマンドにより、必要なブラウザバイナリが自動的にダウンロードされます。

TypeScript プロジェクトの設定

型安全性を確保するため、TypeScript を使用します。

typescript// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

strict モードを有効にすることで、型エラーを早期に発見できます。

基本的なブラウザ起動コード

Playwright でブラウザを起動し、ページを操作する基本的なコードを作成します。

typescript// src/browser/launcher.ts
import {
  chromium,
  Browser,
  BrowserContext,
  Page,
} from 'playwright';

/**
 * ブラウザインスタンスの起動設定
 */
export interface LaunchOptions {
  headless?: boolean; // ヘッドレスモード(デフォルト: true)
  slowMo?: number; // 操作の遅延(ミリ秒、デバッグ用)
}

インターフェースを定義することで、起動オプションの型安全性が確保されます。

typescript// src/browser/launcher.ts(続き)
/**
 * ブラウザを起動し、新しいコンテキストとページを作成
 */
export async function launchBrowser(
  options: LaunchOptions = {}
): Promise<{
  browser: Browser;
  context: BrowserContext;
  page: Page;
}> {
  // ブラウザの起動
  const browser = await chromium.launch({
    headless: options.headless ?? true,
    slowMo: options.slowMo ?? 0,
  });

  // ブラウザコンテキストの作成(セッションやCookieを隔離)
  const context = await browser.newContext({
    viewport: { width: 1920, height: 1080 },
    userAgent:
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
  });

  // 新しいページを開く
  const page = await context.newPage();

  return { browser, context, page };
}

コンテキストを使用することで、複数の自動化処理を並行実行する際にセッションが混在しないようにできます。

堅牢な待機戦略の実装

Playwright は自動待機機能を持っていますが、より堅牢な処理のために明示的な待機戦略を実装します。

要素の出現待機

要素が DOM に追加され、表示されるまで待機する関数を作成します。

typescript// src/utils/wait.ts
import { Page, Locator } from 'playwright';

/**
 * 要素が表示されるまで待機(リトライ付き)
 */
export async function waitForElement(
  page: Page,
  selector: string,
  options: { timeout?: number; retries?: number } = {}
): Promise<Locator> {
  const timeout = options.timeout ?? 30000; // デフォルト30秒
  const retries = options.retries ?? 3; // デフォルト3回リトライ

  let lastError: Error | null = null;

  // リトライループ
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      // 要素の出現を待機
      const element = page.locator(selector);
      await element.waitFor({ state: 'visible', timeout });
      return element;
    } catch (error) {
      lastError = error as Error;
      console.warn(
        `要素待機失敗 (試行 ${attempt}/${retries}): ${selector}`,
        error
      );

      // 最後の試行でなければ少し待機
      if (attempt < retries) {
        await page.waitForTimeout(2000);
      }
    }
  }

  // すべてのリトライが失敗
  throw new Error(
    `要素が見つかりませんでした: ${selector}`,
    { cause: lastError }
  );
}

リトライ機構を組み込むことで、一時的なネットワーク遅延にも対応できます。

ページ遷移の待機

ページ遷移が完了するまで待機する関数を実装します。

typescript// src/utils/wait.ts(続き)
/**
 * ページ遷移完了まで待機
 */
export async function waitForNavigation(
  page: Page,
  action: () => Promise<void>,
  options: {
    timeout?: number;
    waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
  } = {}
): Promise<void> {
  const timeout = options.timeout ?? 30000;
  const waitUntil = options.waitUntil ?? 'networkidle';

  try {
    // ページ遷移とアクションを並行して実行
    await Promise.all([
      page.waitForLoadState(waitUntil, { timeout }),
      action(),
    ]);
  } catch (error) {
    throw new Error('ページ遷移に失敗しました', {
      cause: error,
    });
  }
}

Promise.all を使うことで、アクション実行とページ遷移の待機を同時に行えます。

リトライ機構の実装

失敗時に自動的にリトライする汎用的な機構を実装します。

エクスポネンシャルバックオフ付きリトライ

typescript// src/utils/retry.ts
/**
 * リトライ設定
 */
export interface RetryOptions {
  maxRetries?: number; // 最大リトライ回数
  initialDelay?: number; // 初回遅延(ミリ秒)
  maxDelay?: number; // 最大遅延(ミリ秒)
  backoffMultiplier?: number; // バックオフ倍率
}

設定を構造化することで、柔軟なリトライ戦略を実現します。

typescript// src/utils/retry.ts(続き)
/**
 * エクスポネンシャルバックオフ付きリトライ実行
 */
export async function retryWithBackoff<T>(
  operation: () => Promise<T>,
  options: RetryOptions = {}
): Promise<T> {
  const maxRetries = options.maxRetries ?? 3;
  const initialDelay = options.initialDelay ?? 1000;
  const maxDelay = options.maxDelay ?? 30000;
  const backoffMultiplier = options.backoffMultiplier ?? 2;

  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      // 操作を実行
      return await operation();
    } catch (error) {
      lastError = error as Error;

      // 最後の試行なら例外をスロー
      if (attempt === maxRetries) {
        break;
      }

      // 遅延時間を計算(エクスポネンシャルバックオフ)
      const delay = Math.min(
        initialDelay *
          Math.pow(backoffMultiplier, attempt - 1),
        maxDelay
      );

      console.warn(
        `操作失敗 (試行 ${attempt}/${maxRetries})。${delay}ms 後にリトライします`,
        error
      );
      await new Promise((resolve) =>
        setTimeout(resolve, delay)
      );
    }
  }

  throw new Error(
    `${maxRetries}回のリトライ後も操作が失敗しました`,
    { cause: lastError }
  );
}

エクスポネンシャルバックオフにより、サーバーへの負荷を抑えつつ、一時的な障害からの回復を試みます。

エラー分類とハンドリング

Playwright で発生する様々なエラーを分類し、適切に処理します。

エラー種別の定義

typescript// src/errors/types.ts
/**
 * RPA エラーの種別
 */
export enum RpaErrorType {
  TIMEOUT = 'TIMEOUT', // タイムアウトエラー
  ELEMENT_NOT_FOUND = 'ELEMENT_NOT_FOUND', // 要素が見つからない
  NAVIGATION = 'NAVIGATION', // ページ遷移エラー
  AUTHENTICATION = 'AUTHENTICATION', // 認証エラー
  NETWORK = 'NETWORK', // ネットワークエラー
  BROWSER_CLOSED = 'BROWSER_CLOSED', // ブラウザ終了
  UNKNOWN = 'UNKNOWN', // その他のエラー
}

エラー種別を定義することで、適切なリカバリー処理を選択できます。

カスタムエラークラス

typescript// src/errors/RpaError.ts
/**
 * RPA 処理のカスタムエラー
 */
export class RpaError extends Error {
  constructor(
    public readonly type: RpaErrorType,
    message: string,
    public readonly originalError?: Error,
    public readonly retryable: boolean = false
  ) {
    super(message);
    this.name = 'RpaError';
  }
}

retryable フラグにより、リトライ可能なエラーかどうかを判断できます。

エラー分類関数

typescript// src/errors/classifier.ts
import { RpaError, RpaErrorType } from './RpaError';

/**
 * Playwright のエラーを分類
 */
export function classifyError(error: Error): RpaError {
  const message = error.message.toLowerCase();

  // TimeoutError の判定
  if (
    error.name === 'TimeoutError' ||
    message.includes('timeout')
  ) {
    return new RpaError(
      RpaErrorType.TIMEOUT,
      'タイムアウトが発生しました',
      error,
      true // リトライ可能
    );
  }

  // 要素が見つからないエラー
  if (
    message.includes('element') &&
    message.includes('not found')
  ) {
    return new RpaError(
      RpaErrorType.ELEMENT_NOT_FOUND,
      '要素が見つかりませんでした',
      error,
      true // リトライ可能
    );
  }

  // ネットワークエラー
  if (
    message.includes('net::') ||
    message.includes('network')
  ) {
    return new RpaError(
      RpaErrorType.NETWORK,
      'ネットワークエラーが発生しました',
      error,
      true // リトライ可能
    );
  }

  // ブラウザ終了エラー
  if (
    message.includes('target closed') ||
    message.includes('browser closed')
  ) {
    return new RpaError(
      RpaErrorType.BROWSER_CLOSED,
      'ブラウザが閉じられました',
      error,
      false // リトライ不可(ブラウザ再起動が必要)
    );
  }

  // その他のエラー
  return new RpaError(
    RpaErrorType.UNKNOWN,
    '不明なエラーが発生しました',
    error,
    false
  );
}

エラーメッセージのパターンマッチングにより、エラー種別を自動判定します。

セッション管理と認証の永続化

ログイン処理を毎回実行するのは時間がかかるため、セッション情報を保存して再利用します。

ブラウザコンテキストの保存

typescript// src/auth/session.ts
import { BrowserContext } from 'playwright';
import * as fs from 'fs/promises';
import * as path from 'path';

/**
 * セッション保存先のディレクトリ
 */
const SESSION_DIR = path.join(process.cwd(), '.sessions');

/**
 * ブラウザコンテキスト(Cookie、ストレージ)を保存
 */
export async function saveSession(
  context: BrowserContext,
  sessionName: string
): Promise<void> {
  // セッションディレクトリを作成
  await fs.mkdir(SESSION_DIR, { recursive: true });

  const sessionPath = path.join(
    SESSION_DIR,
    `${sessionName}.json`
  );

  // コンテキストの状態を保存
  await context.storageState({ path: sessionPath });

  console.log(`セッションを保存しました: ${sessionPath}`);
}

storageState メソッドにより、Cookie やローカルストレージの内容が JSON ファイルに保存されます。

セッションの復元

typescript// src/auth/session.ts(続き)
/**
 * 保存されたセッションが存在するか確認
 */
export async function hasSession(
  sessionName: string
): Promise<boolean> {
  const sessionPath = path.join(
    SESSION_DIR,
    `${sessionName}.json`
  );
  try {
    await fs.access(sessionPath);
    return true;
  } catch {
    return false;
  }
}

/**
 * セッションファイルのパスを取得
 */
export function getSessionPath(
  sessionName: string
): string {
  return path.join(SESSION_DIR, `${sessionName}.json`);
}

セッションファイルの存在確認により、ログインをスキップできるか判断します。

セッションを使ったブラウザ起動

typescript// src/browser/launcher.ts(続き)
import {
  getSessionPath,
  hasSession,
} from '../auth/session';

/**
 * セッション付きでブラウザを起動
 */
export async function launchWithSession(
  sessionName: string,
  options: LaunchOptions = {}
): Promise<{
  browser: Browser;
  context: BrowserContext;
  page: Page;
}> {
  const browser = await chromium.launch({
    headless: options.headless ?? true,
    slowMo: options.slowMo ?? 0,
  });

  // セッションが存在する場合は復元
  const contextOptions: any = {
    viewport: { width: 1920, height: 1080 },
    userAgent:
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
  };

  if (await hasSession(sessionName)) {
    contextOptions.storageState =
      getSessionPath(sessionName);
    console.log(`セッションを復元しました: ${sessionName}`);
  }

  const context = await browser.newContext(contextOptions);
  const page = await context.newPage();

  return { browser, context, page };
}

セッションの復元により、2 回目以降の実行ではログイン処理をスキップできます。

ロギングとエラー通知

実運用では、処理の進行状況やエラー情報を記録し、必要に応じて通知する仕組みが重要です。

構造化ログの実装

typescript// src/logging/logger.ts
import * as fs from 'fs/promises';
import * as path from 'path';

/**
 * ログレベル
 */
export enum LogLevel {
  DEBUG = 'DEBUG',
  INFO = 'INFO',
  WARN = 'WARN',
  ERROR = 'ERROR',
}

ログレベルを定義することで、重要度に応じたフィルタリングが可能になります。

typescript// src/logging/logger.ts(続き)
/**
 * ログエントリの型
 */
interface LogEntry {
  timestamp: string;
  level: LogLevel;
  message: string;
  data?: any;
}

/**
 * ロガークラス
 */
export class Logger {
  private logDir: string;
  private logFile: string;

  constructor(logDir: string = './logs') {
    this.logDir = logDir;
    const now = new Date();
    const dateStr = now.toISOString().split('T')[0];
    this.logFile = path.join(logDir, `rpa-${dateStr}.log`);
  }

  /**
   * ログディレクトリを初期化
   */
  async initialize(): Promise<void> {
    await fs.mkdir(this.logDir, { recursive: true });
  }

  /**
   * ログを記録
   */
  private async writeLog(
    level: LogLevel,
    message: string,
    data?: any
  ): Promise<void> {
    const entry: LogEntry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      data,
    };

    const logLine = JSON.stringify(entry) + '\n';

    // ファイルに追記
    await fs.appendFile(this.logFile, logLine);

    // コンソールにも出力
    console.log(
      `[${entry.timestamp}] ${level}: ${message}`
    );
    if (data) {
      console.log(JSON.stringify(data, null, 2));
    }
  }

  async debug(message: string, data?: any): Promise<void> {
    await this.writeLog(LogLevel.DEBUG, message, data);
  }

  async info(message: string, data?: any): Promise<void> {
    await this.writeLog(LogLevel.INFO, message, data);
  }

  async warn(message: string, data?: any): Promise<void> {
    await this.writeLog(LogLevel.WARN, message, data);
  }

  async error(message: string, data?: any): Promise<void> {
    await this.writeLog(LogLevel.ERROR, message, data);
  }
}

JSON 形式でログを記録することで、後から検索や分析がしやすくなります。

Slack 通知の実装

typescript// src/notification/slack.ts
/**
 * Slack Webhook URL(環境変数から取得)
 */
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;

/**
 * Slack に通知を送信
 */
export async function notifySlack(
  message: string,
  severity: 'info' | 'warning' | 'error'
): Promise<void> {
  if (!SLACK_WEBHOOK_URL) {
    console.warn('SLACK_WEBHOOK_URL が設定されていません');
    return;
  }

  const color = {
    info: '#36a64f',
    warning: '#ff9900',
    error: '#ff0000',
  }[severity];

  const payload = {
    attachments: [
      {
        color,
        title: `RPA 通知 [${severity.toUpperCase()}]`,
        text: message,
        footer: 'Playwright RPA',
        ts: Math.floor(Date.now() / 1000),
      },
    ],
  };

  try {
    const response = await fetch(SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      console.error(
        'Slack 通知送信に失敗しました',
        response.statusText
      );
    }
  } catch (error) {
    console.error(
      'Slack 通知でエラーが発生しました',
      error
    );
  }
}

環境変数から Webhook URL を取得することで、セキュリティを保ちつつ柔軟な設定が可能です。

具体例

社内経費精算システムの自動入力

実際の業務シナリオとして、経費精算システムへの自動入力を実装してみましょう。

タスクの全体フロー

以下の図は、経費精算 RPA の処理フローを示しています。

mermaidflowchart TD
  start["処理開始"] --> checkSession{"セッション<br/>存在?"}
  checkSession -->|あり| restoreSession["セッション復元"]
  checkSession -->|なし| login["ログイン処理"]
  restoreSession --> verifyAuth{"認証<br/>確認"}
  login --> verifyAuth
  verifyAuth -->|OK| readData["CSV データ読込"]
  verifyAuth -->|NG| login
  readData --> loopStart{"残データ<br/>あり?"}
  loopStart -->|なし| saveSession["セッション保存"]
  loopStart -->|あり| inputForm["フォーム入力"]
  inputForm --> submit["送信"]
  submit --> success{"成功?"}
  success -->|はい| logSuccess["成功ログ記録"]
  success -->|いいえ| retry{"リトライ<br/>可能?"}
  retry -->|はい| inputForm
  retry -->|いいえ| logError["エラーログ記録"]
  logSuccess --> loopStart
  logError --> loopStart
  saveSession --> notify["Slack 通知"]
  notify --> task_end["処理終了"]

この図により、処理の流れとエラーハンドリングの戦略が一目で理解できます。

メイン処理の実装

typescript// src/tasks/expenseReporting.ts
import { Page } from 'playwright';
import { launchWithSession } from '../browser/launcher';
import { saveSession } from '../auth/session';
import { Logger } from '../logging/logger';
import { notifySlack } from '../notification/slack';
import { retryWithBackoff } from '../utils/retry';
import { classifyError } from '../errors/classifier';
import * as fs from 'fs/promises';
import { parse } from 'csv-parse/sync';

/**
 * 経費データの型定義
 */
interface ExpenseData {
  date: string; // 日付
  category: string; // カテゴリ
  amount: number; // 金額
  description: string; // 説明
}

型定義により、CSV データの構造が明確になり、タイプセーフな処理が可能です。

typescript// src/tasks/expenseReporting.ts(続き)
/**
 * CSV ファイルから経費データを読み込み
 */
async function loadExpenseData(
  csvPath: string
): Promise<ExpenseData[]> {
  const content = await fs.readFile(csvPath, 'utf-8');
  const records = parse(content, {
    columns: true,
    skip_empty_lines: true,
  });

  return records.map((record: any) => ({
    date: record['日付'],
    category: record['カテゴリ'],
    amount: parseInt(record['金額'], 10),
    description: record['説明'],
  }));
}

CSV パースライブラリを使うことで、簡潔にデータを読み込めます。

ログイン処理

typescript// src/tasks/expenseReporting.ts(続き)
/**
 * 経費精算システムにログイン
 */
async function login(
  page: Page,
  logger: Logger
): Promise<void> {
  await logger.info('ログイン処理を開始します');

  // ログインページへ移動
  await page.goto('https://expense.example.com/login');

  // 環境変数からユーザー名とパスワードを取得
  const username = process.env.EXPENSE_USERNAME;
  const password = process.env.EXPENSE_PASSWORD;

  if (!username || !password) {
    throw new Error(
      'EXPENSE_USERNAME または EXPENSE_PASSWORD が設定されていません'
    );
  }

  // ログインフォームに入力
  await page.fill('input[name="username"]', username);
  await page.fill('input[name="password"]', password);

  // ログインボタンをクリック
  await page.click('button[type="submit"]');

  // ログイン後のページ遷移を待機
  await page.waitForURL('**/dashboard', { timeout: 10000 });

  await logger.info('ログインに成功しました');
}

環境変数を使うことで、認証情報をコードに埋め込まずに管理できます。

認証状態の確認

typescript// src/tasks/expenseReporting.ts(続き)
/**
 * 現在の認証状態を確認
 */
async function verifyAuthentication(
  page: Page
): Promise<boolean> {
  try {
    // ダッシュボードページにアクセス
    await page.goto(
      'https://expense.example.com/dashboard',
      {
        waitUntil: 'networkidle',
        timeout: 10000,
      }
    );

    // ログインページにリダイレクトされていないか確認
    const currentUrl = page.url();
    return !currentUrl.includes('/login');
  } catch {
    return false;
  }
}

URL チェックにより、セッション有効性を簡単に判定できます。

フォーム入力処理

typescript// src/tasks/expenseReporting.ts(続き)
/**
 * 経費データを入力フォームに送信
 */
async function submitExpense(
  page: Page,
  data: ExpenseData,
  logger: Logger
): Promise<void> {
  await logger.info(
    `経費を入力します: ${data.description}`
  );

  // 新規作成ページへ移動
  await page.goto(
    'https://expense.example.com/expenses/new'
  );

  // 日付を入力
  await page.fill('input[name="date"]', data.date);

  // カテゴリを選択
  await page.selectOption(
    'select[name="category"]',
    data.category
  );

  // 金額を入力
  await page.fill(
    'input[name="amount"]',
    data.amount.toString()
  );

  // 説明を入力
  await page.fill(
    'textarea[name="description"]',
    data.description
  );

  // 送信ボタンをクリック
  await page.click('button[type="submit"]');

  // 完了メッセージが表示されるまで待機
  await page.waitForSelector('.success-message', {
    timeout: 10000,
  });

  await logger.info('経費の入力が完了しました');
}

各入力項目を明示的に待機しながら入力することで、確実な動作を実現します。

メイン実行関数

typescript// src/tasks/expenseReporting.ts(続き)
/**
 * 経費精算 RPA のメイン処理
 */
export async function runExpenseReporting(
  csvPath: string
): Promise<void> {
  const logger = new Logger();
  await logger.initialize();

  let browser, context, page;

  try {
    await logger.info('経費精算 RPA を開始します');

    // セッション付きでブラウザを起動
    ({ browser, context, page } = await launchWithSession(
      'expense-system'
    ));

    // 認証状態を確認
    const isAuthenticated = await verifyAuthentication(
      page
    );

    if (!isAuthenticated) {
      // ログインが必要な場合
      await retryWithBackoff(() => login(page, logger), {
        maxRetries: 3,
        initialDelay: 2000,
      });
    }

    // CSV データを読み込み
    const expenses = await loadExpenseData(csvPath);
    await logger.info(
      `${expenses.length}件の経費データを読み込みました`
    );

    // 各経費データを処理
    let successCount = 0;
    let errorCount = 0;

    for (const expense of expenses) {
      try {
        // リトライ付きで送信
        await retryWithBackoff(
          () => submitExpense(page, expense, logger),
          {
            maxRetries: 2,
            initialDelay: 3000,
          }
        );
        successCount++;
      } catch (error) {
        errorCount++;
        const rpaError = classifyError(error as Error);
        await logger.error('経費入力に失敗しました', {
          expense,
          error: rpaError,
        });
      }
    }

    // セッションを保存
    await saveSession(context, 'expense-system');

    // 結果を通知
    const summary = `経費精算 RPA が完了しました\n成功: ${successCount}件\n失敗: ${errorCount}件`;
    await logger.info(summary);
    await notifySlack(
      summary,
      errorCount > 0 ? 'warning' : 'info'
    );
  } catch (error) {
    const rpaError = classifyError(error as Error);
    await logger.error(
      'RPA 処理でエラーが発生しました',
      rpaError
    );
    await notifySlack(
      `RPA 処理が失敗しました: ${rpaError.message}`,
      'error'
    );
    throw error;
  } finally {
    // ブラウザをクリーンアップ
    if (browser) {
      await browser.close();
    }
  }
}

finally ブロックでブラウザを確実に閉じることで、リソースリークを防ぎます。

エントリーポイントの作成

コマンドラインから実行できるエントリーポイントを作成します。

typescript// src/index.ts
import { runExpenseReporting } from './tasks/expenseReporting';

/**
 * コマンドライン引数をパース
 */
function parseArgs(): string {
  const args = process.argv.slice(2);

  if (args.length === 0) {
    console.error('使用方法: yarn start <csv-file-path>');
    process.exit(1);
  }

  return args[0];
}

/**
 * メイン関数
 */
async function main() {
  const csvPath = parseArgs();

  try {
    await runExpenseReporting(csvPath);
    console.log('RPA 処理が正常に完了しました');
    process.exit(0);
  } catch (error) {
    console.error('RPA 処理が失敗しました', error);
    process.exit(1);
  }
}

// 実行
main();

終了コードを適切に設定することで、スケジューラーや CI/CD システムとの連携が容易になります。

環境変数の設定

認証情報や設定値を環境変数で管理します。

bash# .env
EXPENSE_USERNAME=your_username
EXPENSE_PASSWORD=your_password
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL

.env ファイルは .gitignore に追加し、リポジトリにコミットしないようにしましょう。

typescript// src/config/env.ts
import * as dotenv from 'dotenv';

// .env ファイルを読み込み
dotenv.config();

/**
 * 環境変数を検証
 */
export function validateEnv(): void {
  const required = ['EXPENSE_USERNAME', 'EXPENSE_PASSWORD'];

  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(
        `環境変数 ${key} が設定されていません`
      );
    }
  }
}

起動時に環境変数を検証することで、設定ミスを早期に発見できます。

実行とスケジューリング

手動実行

bash# 依存パッケージのインストール
yarn install

# TypeScript のビルド
yarn build

# CSV ファイルを指定して実行
node dist/index.js ./data/expenses.csv

ビルドステップを挟むことで、型チェックが実行され、実行前にエラーを検出できます。

cron でのスケジューリング

Linux/macOS の cron で定期実行を設定します。

bash# crontab を編集
crontab -e

# 毎日午前9時に実行
0 9 * * * cd /path/to/rpa-project && /usr/bin/yarn start ./data/expenses.csv >> ./logs/cron.log 2>&1

標準出力とエラー出力をログファイルにリダイレクトすることで、実行履歴を確認できます。

Docker での実行

再現性の高い環境で実行するため、Docker コンテナを利用できます。

dockerfile# Dockerfile
FROM node:18-slim

# Playwright の依存パッケージをインストール
RUN apt-get update && apt-get install -y \
    libnss3 \
    libatk-bridge2.0-0 \
    libdrm2 \
    libxkbcommon0 \
    libgbm1 \
    libasound2 \
    && rm -rf /var/lib/apt/lists/*

# アプリケーションディレクトリを作成
WORKDIR /app

# パッケージファイルをコピー
COPY package.json yarn.lock ./

# 依存関係をインストール
RUN yarn install --frozen-lockfile

# Playwright ブラウザをインストール
RUN yarn playwright install chromium

# ソースコードをコピー
COPY . .

# TypeScript をビルド
RUN yarn build

# 実行コマンド
CMD ["node", "dist/index.js", "/data/expenses.csv"]

Docker を使うことで、ホスト環境に依存しない一貫した実行環境を構築できます。

bash# Docker イメージをビルド
docker build -t expense-rpa .

# コンテナを実行
docker run -v $(pwd)/data:/data -v $(pwd)/logs:/app/logs --env-file .env expense-rpa

ボリュームマウントにより、データファイルとログをホスト側で管理できます。

デバッグとトラブルシューティング

ヘッドフルモードでの実行

開発時には、ブラウザの動作を目視確認できるヘッドフルモードが便利です。

typescript// デバッグモードで起動
const { browser, context, page } = await launchWithSession(
  'expense-system',
  {
    headless: false, // ブラウザを表示
    slowMo: 500, // 操作を500ms遅延(動作を追いやすくする)
  }
);

slowMo オプションにより、各操作の間隔を空けて、何が起きているか確認できます。

スクリーンショットの取得

エラー発生時に自動でスクリーンショットを保存する仕組みを追加します。

typescript// src/utils/screenshot.ts
import { Page } from 'playwright';
import * as path from 'path';
import * as fs from 'fs/promises';

/**
 * スクリーンショットを保存
 */
export async function saveScreenshot(
  page: Page,
  name: string
): Promise<string> {
  const screenshotDir = path.join(
    process.cwd(),
    'screenshots'
  );
  await fs.mkdir(screenshotDir, { recursive: true });

  const timestamp = new Date()
    .toISOString()
    .replace(/[:.]/g, '-');
  const filename = `${name}-${timestamp}.png`;
  const filepath = path.join(screenshotDir, filename);

  await page.screenshot({ path: filepath, fullPage: true });

  return filepath;
}

フルページスクリーンショットにより、スクロールが必要な長いページも全体を確認できます。

typescript// エラーハンドリングでスクリーンショットを保存
try {
  await submitExpense(page, expense, logger);
} catch (error) {
  // エラー時のスクリーンショットを保存
  const screenshotPath = await saveScreenshot(
    page,
    'error-expense-submit'
  );
  await logger.error('経費入力でエラーが発生しました', {
    error,
    screenshot: screenshotPath,
  });
  throw error;
}

スクリーンショットのパスをログに記録することで、後から視覚的に状況を確認できます。

トレースの記録

Playwright のトレース機能を使うと、操作の詳細な記録を取得できます。

typescript// src/browser/launcher.ts(トレース対応版)
export async function launchWithTrace(
  sessionName: string,
  options: LaunchOptions = {}
): Promise<{
  browser: Browser;
  context: BrowserContext;
  page: Page;
}> {
  const browser = await chromium.launch({
    headless: options.headless ?? true,
  });

  const context = await browser.newContext({
    viewport: { width: 1920, height: 1080 },
  });

  // トレースを開始
  await context.tracing.start({
    screenshots: true,
    snapshots: true,
    sources: true,
  });

  const page = await context.newPage();

  return { browser, context, page };
}

トレースには、スクリーンショット、DOM スナップショット、ネットワークログが含まれます。

typescript// トレースを保存
await context.tracing.stop({ path: './traces/trace.zip' });

保存したトレースは、npx playwright show-trace trace.zip コマンドで可視化できます。

まとめ

Node.js と Playwright を使った社内 RPA の実装について、基本的なセットアップから実運用に必要な失敗回復の仕組みまで解説しました。

重要なポイント

#ポイント詳細
1エラー分類エラーを種別ごとに分類し、リトライ可能性を判断する
2リトライ戦略エクスポネンシャルバックオフで適切な間隔でリトライする
3セッション管理Cookie やストレージを保存し、ログイン処理を省略する
4ログと通知構造化ログで処理を記録し、エラー時に通知を送る
5段階的な実装小さな機能から始めて、徐々に堅牢性を高める

実運用での注意点

定期的なメンテナンス Web サイトの UI が変更されると、セレクターが無効になる可能性があります。定期的にスクリプトの動作確認を行い、必要に応じて修正しましょう。

セキュリティ対策 認証情報は環境変数や秘密管理サービス(AWS Secrets Manager、HashiCorp Vault など)で管理し、コードに直接埋め込まないようにしてください。

パフォーマンス最適化 大量のデータを処理する場合は、並列実行やバッチ処理を検討しましょう。Playwright は複数のブラウザコンテキストを同時に扱えます。

モニタリング Datadog や Prometheus などの監視ツールと連携し、実行状況をリアルタイムで把握できる仕組みを構築すると、より安定した運用が可能になります。

今後の拡張案

AI との連携 画像認識や自然言語処理を組み合わせることで、より柔軟な自動化が実現できます。例えば、CAPTCHA の自動解決や、フォームの自動入力内容の推測などが可能です。

マルチステップワークフロー 複数のシステムにまたがる業務フローを一連の処理として自動化することで、さらに大きな効率化が期待できます。

クラウド実行環境 AWS Lambda や Google Cloud Functions などのサーバーレス環境で実行することで、インフラ管理の負担を軽減できます。

Playwright を使った RPA は、開発者にとって理解しやすく、拡張性の高いソリューションです。本記事で紹介した失敗回復の手法を活用して、堅牢な自動化システムを構築してください。

関連リンク