🤖

Deno で browser-use を自作してみた (勉強用)

2025/03/15に公開

AI 経由でブラウザを操作する browser-use を deno で実装してみました。

https://github.com/browser-use/browser-use

元は python ですが、コア部分を自分で作れるように書き直しました。

注意: 勉強用の作例であって、本番で使えるものではないです。

https://gist.github.com/mizchi/81d17dd85f5cb9fb2373db872f648260

以下の記事を読みながら実装しました

https://zenn.dev/gunjo/articles/8450e69537dbb6

tl;dr

  • アクセシビリティ要素を列挙
  • 各要素にブラウザ上でオーバーレイで操作用インデックスを書き込みつつ、インデックスに対応する xpath を作っておく
  • claude のスクリーンショットと xpath を渡す
  • claude に対応する xpath の操作方法を教える
  • ツールとして対話的に実行

ステップ

  • Tool Runner
  • Puppeteer
  • BrowesrTool

Tool Runner

まず、Tools を使って AI と対話する部分を作ります。

import { anthropic } from "@ai-sdk/anthropic";
import { streamText, Tool, tool, type ToolResultPart } from "ai";
import { z } from "zod";

const _encoder = new TextEncoder();
const write = (text: string) => {
  Deno.stdout.write(_encoder.encode(text));
};
function truncate(text: string, length: number = 100) {
  return text.length > length ? text.slice(0, length) + "..." : text;
}
export async function runTools(
  options: Partial<Parameters<typeof streamText>[0]> = {},
): Promise<void> {
  const { fullStream } = streamText({
    model: anthropic("claude-3-7-sonnet-20250219"),
    ...options,
  });

  for await (const part of fullStream) {
    switch (part.type) {
      case "text-delta": {
        write(part.textDelta);
        break;
      }
      case "tool-call": {
        console.log(
          `\n%c[tool-call:${part.toolName}] ${
            JSON.stringify(
              part.args,
              null,
              2,
            )
          }`,
          "color: gray",
        );
        // write("\n");
        break;
      }
      // @ts-ignore this is returned by the AI SDK
      case "tool-result": {
        const toolPart = part as ToolResultPart;
        console.log(
          `\n%c[tool-result:${toolPart.toolName}] ${
            truncate(JSON.stringify(
              toolPart.result,
              null,
              2,
            ))
          }`,
          "color: gray",
        );
        break;
      }
      case "error": {
        console.error("Error:", part.error);
        break;
      }
      case "finish":
      case "step-start":
      case "step-finish": {
        break;
      }
      default: {
        console.error("Unknown part type:", part);
      }
    }
  }
  write("\n");
}

export const askToUser: Tool = tool({
  description: "Ask a question to the user. Call this for user input",
  parameters: z.object({
    question: z.string().describe("The question to ask the user"),
  }),
  async execute({ question }) {
    console.log(`\n%c[ask] ${question}`, "color: green");
    const ret = prompt(">") ?? "no answer";
    console.log(`\n%c[response] ${ret}`, "color: blue");
    if (!ret) {
      Deno.exit(1);
    }
    return ret;
  },
});

使用例

await runTools({
  prompt: "雑談しよう"
  tools: {
    askToUser,
  },
  maxSteps: 10,
});
$ ANTHROPIC_API_KEY=... deno run -A ai.ts 雑談しよ
こんにちは!雑談をしましょう。何か特定の話題について話したいことはありますか?例えば、最近の出来事や趣味、好きな映画や音楽など、どんなことでも構いません。
[ask] 今日はどんな一日でしたか?または、何か話したいトピックはありますか?
> うんち

[response] うんち

[tool-call:askToUser] {
  "question": "今日はどんな一日でしたか?または、何か話したいトピックはありますか?"
}

[tool-result:askToUser] "うんち"
「うんち」についてのお話ですね。少し意外なトピックですが、実は健康の観点からは大切な話題でもあります。

便の状態は体調を反映することがあり、健康のバロメーターとも言われています。色や形状、頻度などから体の状態を知ることができるんですよ。例えば、理想的な便は「バナナ状でやわらかく、茶色」と言われています。

また、世界には「うんち博物館」があったり、肥料として重要な役割を果たしていたりと、意外な側面もあります。

他に何か話したいことはありますか?もう少し別のトピックについて雑談しましょうか?
[ask] 他に話したいトピックはありますか?

この時点でも便利っちゃ便利です。

スクリーンショットからレイアウト分析

  • a タグのようなアクセシビリティがある要素を列挙
  • 連番で xpath を生成
  • DOM 上に連番のオーバーレイを書き込む
import type { Page } from "npm:puppeteer";

export type AccessibleElement = {
  tagName: string;
  xpath: string;
  text: string;
  attributes: Record<string, string>;
};

export type Highlighter = {
  collect(): AccessibleElement[];
  reset(): void;
};

export async function collectHighlight(
  page: Page,
): Promise<AccessibleElement[]> {
  const injected = await page.evaluate(() => {
    // @ts-ignore xxx
    return globalThis.__hl != null;
  });
  if (!injected) {
    await injectHighlighter(page);
  }

  return await page.evaluate(() => {
    // @ts-ignore xxx
    if (globalThis.__hl == null) {
      console.error("Highlighter not found");
      return [];
    }
    // @ts-ignore xxx
    globalThis.__hl.reset();
    // @ts-ignore xxx
    return globalThis.__hl.collect();
  });
}

async function injectHighlighter(
  page: Page,
) {
  return await page.evaluate(injectHighlightScript);
}

function injectHighlightScript() {
  // @ts-expect-error this is a browser context
  const window = globalThis as Window;
  console.log("Highlighting interactive elements...");

  // Generate a color based on the index
  const colors = [
    "#FF0000",
    "#00FF00",
    "#0000FF",
    "#FFA500",
    "#800080",
    "#008080",
    "#FF69B4",
    "#4B0082",
    "#FF4500",
    "#2E8B57",
    "#DC143C",
    "#4682B4",
  ];
  // Find interactive elements
  const selectors = [
    "a",
    "button",
    "input",
    "select",
    "textarea",
    '[role="button"]',
    '[role="link"]',
    '[role="checkbox"]',
    '[role="radio"]',
    '[role="tab"]',
    '[role="menuitem"]',
    "[onclick]",
    '[tabindex]:not([tabindex="-1"])',
  ];
  // @ts-ignore xxx
  if (globalThis.__hl != null) {
    console.log("[Inject] Highlighter already exists");
    return;
  }
  console.log("[Inject] Creating highlighter");
  // @ts-ignore inject global
  globalThis.__hl = createHighlighter();
  console.log("[Inject] Setup complete");
  return;
  // ----

  function generateXPath(element: HTMLElement): string {
    if (element.nodeType !== Node.ELEMENT_NODE) {
      throw new Error("XPathは要素ノードに対してのみ生成できます");
    }
    // 要素がdocument.bodyの場合は直接パスを返す
    if (element === document.body) {
      return "/html/body";
    }
    // 親要素が存在しない場合
    if (!element.parentNode) {
      return "";
    }

    // 親要素のXPathを取得
    const parentPath = generateXPath(element.parentNode as HTMLElement);

    // 現在の要素の位置を特定
    const tagName = element.tagName.toLowerCase();

    // 同じタグ名の兄弟要素がある場合はインデックスを計算
    if (element.parentNode) {
      const siblings = Array.from(element.parentNode.children).filter(
        (sibling) => sibling.tagName.toLowerCase() === tagName,
      );

      if (siblings.length > 1) {
        const index = siblings.indexOf(element) + 1;
        return `${parentPath}/${tagName}[${index}]`;
      }
    }

    // 兄弟要素がない場合は単純にタグ名を追加
    return `${parentPath}/${tagName}`;
  }

  function getVisibleElements() {
    const elements = document.querySelectorAll(selectors.join(","));
    const visibleElements = Array.from(elements).filter(
      (element): element is HTMLElement => {
        if (!(element instanceof HTMLElement)) return false;
        // return isVisible(element);
        const style = window.getComputedStyle(element);
        return element.offsetWidth > 0 &&
          element.offsetHeight > 0 &&
          style.visibility !== "hidden" &&
          style.display !== "none" &&
          style.opacity !== "0";
      },
    );
    return visibleElements;
  }

  /**
   * Create a highlighter object
   */
  function createHighlighter(): Highlighter {
    let container = initContainer();
    let highlightIndex = 0;
    return {
      reset() {
        container.remove();
        container = initContainer();
        highlightIndex = 0;
      },

      collect() {
        const visibleElements = getVisibleElements();
        for (const element of visibleElements) {
          _overlay(element);
        }
        const elements = visibleElements.map((element) => {
          return {
            xpath: generateXPath(element),
            tagName: element.tagName.toLowerCase(),
            text: element.textContent?.trim(),
            attributes: Array.from(element.attributes).reduce((acc, attr) => {
              acc[attr.name] = attr.value;
              return acc;
            }, {} as Record<string, string>),
          };
        }) as AccessibleElement[];
        return elements;
        // overlay(element);
      },
    };

    /**
     * Overlay a highlight on top of an element
     */
    function _overlay(element: HTMLElement) {
      // Get element position
      const rect = element.getBoundingClientRect();
      if (!rect) return;
      highlightIndex++;

      const colorIndex = highlightIndex % colors.length;
      const baseColor = colors[colorIndex];
      const backgroundColor = baseColor + "1A"; // 10% opacity version of the color

      // Create highlight overlay
      const overlay = document.createElement("div");
      Object.assign(overlay.style, {
        position: "fixed",
        border: `2px solid ${baseColor}`,
        backgroundColor,
        pointerEvents: "none",
        boxSizing: "border-box",
        top: `${rect.top}px`,
        left: `${rect.left}px`,
        width: `${rect.width}px`,
        height: `${rect.height}px`,
        zIndex: "2147483646",
      });

      // Create and position label
      const label = document.createElement("div");
      Object.assign(label.style, {
        position: "fixed",
        background: baseColor,
        color: "white",
        padding: "1px 4px",
        borderRadius: "4px",
        fontSize: `${Math.min(12, Math.max(8, rect.height / 2))}px`,
        zIndex: "2147483647",
      });
      label.textContent = highlightIndex.toString();

      const labelWidth = 20;
      const labelHeight = 16;

      let labelTop = rect.top + 2;
      let labelLeft = rect.left + rect.width - labelWidth - 2;

      if (rect.width < labelWidth + 4 || rect.height < labelHeight + 4) {
        labelTop = rect.top - labelHeight - 2;
        labelLeft = rect.left + rect.width - labelWidth;
      }

      label.style.top = `${labelTop}px`;
      label.style.left = `${labelLeft}px`;
      // Add to container
      container.appendChild(overlay);
      container.appendChild(label);
    }

    /**
     * Ensure a container for the highlights exists
     */
    function initContainer(): HTMLElement {
      console.log("[init] Creating container");
      const HIGHLIGHT_CONTAINER_ID = "dom-analyzer-highlight-container";
      let container = document.getElementById(HIGHLIGHT_CONTAINER_ID);
      // force reset
      if (container) {
        container.remove();
      }
      container = document.createElement("div");
      container.id = HIGHLIGHT_CONTAINER_ID;
      container.style.position = "fixed";
      container.style.pointerEvents = "none";
      container.style.top = "0";
      container.style.left = "0";
      container.style.width = "100%";
      container.style.height = "100%";
      container.style.zIndex = "2147483647";
      document.body.appendChild(container);
      return container;
    }
  }
}

puppeteer.evaluate で評価するためにブラウザコンテキストで評価するので、スコープに工夫が必要です。

これでもだいぶコード減らしたんです

Tools

  • puppeteer でスクリーンショットを取って claude-3.7 に送信
  • 現在のブラウザの状態を取得するツールを定義
  • click を発行するツールを定義
  • 先に定義したブラウザランナーにつなぎこむ
/**
 * Usage
 * deno run -A v0.ts https://google.com
 */
import "npm:core-js/proposals/explicit-resource-management.js";
import puppeteer from "npm:puppeteer@24.4.0";
import { printImageFromBase64 } from "jsr:@mizchi/imgcat";
import { tool } from "npm:ai";
import { collectHighlight } from "./inject.ts";
import { z } from "npm:zod@3.24.2";

function trapCtrlC(fn: () => Promise<void>) {
  const handler = async () => {
    console.log("Signal received. Exiting...");
    try {
      await fn();
      Deno.exit(0);
    } finally {
      console.error("Error during cleanup. Exiting...");
      Deno.exit(1);
    }
  };
  Deno.addSignalListener("SIGINT", handler);
  return () => {
    Deno.removeSignalListener("SIGINT", handler);
  };
}

async function createBrowserTools(
  browser: puppeteer.Browser,
  options: {
    url?: string;
    headless?: boolean;
    imgcat?: boolean;
  },
) {
  // TODO: Add a way to close the browser
  const activePage = await browser.newPage();
  activePage.on("console", (msg) => {
    console.log(`%c[console.${msg.type()}]: ${msg.text()}`, "color: gray");
  });

  async function updateState() {
    const elements = await collectHighlight(activePage);

    const screenshot = await activePage.screenshot({ encoding: "base64" });
    if (options.imgcat) {
      printImageFromBase64(screenshot);
    }

    return {
      screenshot,
      elements,
    };
  }

  if (options.url) {
    await activePage.goto(options.url, { waitUntil: "networkidle0" });
    // await updateState();
  }

  const getBrowserState = tool({
    description: `
    現在のブラウザの状態を取得します。
    スクリーンショットを撮影し、画面上のインタラクティブな要素を取得します。
    スクリーンショットには操作可能なインデックスがオーバーレイで表示されています。
    `.trim(),
    parameters: z.object({}),
    async execute() {
      return await updateState();
    },
    // experimental_toToolResultContent
    experimental_toToolResultContent(result) {
      return [
        {
          type: "image",
          data: result.screenshot,
          mimeType: "image/png",
        },
        {
          type: "text",
          text: JSON.stringify(result.elements, null, 2),
        },
      ];
    },
  });

  const doClick = tool({
    description: `
    指定されたXPathの要素をクリックします。
    操作完了後のブラウザの状態を返します。
    `.trim(),
    parameters: z.object({
      xpath: z.string(),
    }),
    async execute({ xpath }) {
      await activePage.evaluate((xpath) => {
        const element = document.evaluate(
          xpath,
          document,
          null,
          XPathResult.FIRST_ORDERED_NODE_TYPE,
          null,
        ).singleNodeValue as HTMLElement;
        if (element) {
          element.click();
        }
      }, xpath);
      await activePage.waitForNavigation({
        timeout: 10000,
      });
      return await updateState();
    },
    experimental_toToolResultContent(result) {
      return [
        {
          type: "image",
          data: result.screenshot,
          mimeType: "image/png",
        },
        {
          type: "text",
          text: JSON.stringify(result.elements, null, 2),
        },
      ];
    },
  });

  return {
    getBrowserState,
    doClick,
    askToUser,
  };
}

const PROMPT = `
あなたはユーザーのブラウザ操作を代行します。
ステップごとにユーザーに質問をし、その回答に基づいて操作を行います。

`.trim();

// async function run() {
import { parseArgs } from "node:util";
import { askToUser, runTools } from "./ai.ts";

if (import.meta.main) {
  const parsed = parseArgs({
    allowPositionals: true,
    options: {
      prompt: { type: "string", short: "p" },
      headful: { type: "boolean" },
      imgcat: { type: "boolean" },
      output: { type: "string", short: "o" },
    },
  });
  const url = parsed.positionals[0] || "https://google.com";
  // Launch a browser instance
  await using d = new AsyncDisposableStack();
  trapCtrlC(d[Symbol.asyncDispose]);
  const browser = await puppeteer.launch({
    headless: !parsed.values.headful,
    defaultViewport: {
      width: 1280,
      height: 800,
    },
  });
  d.defer(() => browser.close());

  const tools = await createBrowserTools(browser, {
    url,
    headless: !parsed.values.headful,
    imgcat: parsed.values.imgcat,
  });
  await runTools({
    prompt: PROMPT + "\n" + parsed.values.prompt,
    tools,
    maxSteps: 15,
  });
}

おわり

TODO 明らかに解説足りないので後日

Discussion