You need to sign in or sign up before continuing.
content.js 6.98 KB
/**
 * XHS Bridge - Content Script(隔离 world)
 *
 * 接收来自 background.js 的 DOM 操作命令并执行。
 * evaluate / has_element 等需要访问页面 JS 变量的命令由 background.js
 * 通过 chrome.scripting.executeScript(world:"MAIN") 直接处理,不经过这里。
 */

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  handleDomCommand(msg.method, msg.params || {})
    .then((result) => sendResponse({ result: result ?? null }))
    .catch((err) => sendResponse({ error: String(err.message || err) }));
  return true; // 异步响应
});

async function handleDomCommand(method, params) {
  switch (method) {
    case "click_element":
      return cmdClickElement(params);

    case "input_text":
      return cmdInputText(params);

    case "input_content_editable":
      return cmdInputContentEditable(params);

    case "scroll_by":
      window.scrollBy(params.x || 0, params.y || 0);
      return null;

    case "scroll_to":
      window.scrollTo(params.x || 0, params.y || 0);
      return null;

    case "scroll_to_bottom":
      window.scrollTo(0, document.body.scrollHeight);
      return null;

    case "scroll_element_into_view": {
      const el = document.querySelector(params.selector);
      if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
      return null;
    }

    case "scroll_nth_element_into_view": {
      const els = document.querySelectorAll(params.selector);
      if (els[params.index]) els[params.index].scrollIntoView({ behavior: "smooth", block: "center" });
      return null;
    }

    case "dispatch_wheel_event": {
      const target =
        document.querySelector(".note-scroller") ||
        document.querySelector(".interaction-container") ||
        document.documentElement;
      target.dispatchEvent(
        new WheelEvent("wheel", {
          deltaY: params.deltaY || 0,
          deltaMode: 0,
          bubbles: true,
          cancelable: true,
          view: window,
        }),
      );
      return null;
    }

    case "mouse_move": {
      document.dispatchEvent(
        new MouseEvent("mousemove", { clientX: params.x, clientY: params.y, bubbles: true }),
      );
      return null;
    }

    case "mouse_click": {
      const el = document.elementFromPoint(params.x, params.y);
      if (el) {
        el.dispatchEvent(new MouseEvent("mousedown", { clientX: params.x, clientY: params.y, bubbles: true }));
        el.dispatchEvent(new MouseEvent("mouseup", { clientX: params.x, clientY: params.y, bubbles: true }));
        el.dispatchEvent(new MouseEvent("click", { clientX: params.x, clientY: params.y, bubbles: true }));
      }
      return null;
    }

    case "press_key": {
      const keyMap = {
        Enter: { key: "Enter", code: "Enter", keyCode: 13 },
        ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
        Tab: { key: "Tab", code: "Tab", keyCode: 9 },
        Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
      };
      const info = keyMap[params.key] || { key: params.key, code: params.key, keyCode: 0 };
      const active = document.activeElement || document.body;
      active.dispatchEvent(new KeyboardEvent("keydown", { ...info, bubbles: true }));
      active.dispatchEvent(new KeyboardEvent("keyup", { ...info, bubbles: true }));
      return null;
    }

    case "type_text": {
      const active = document.activeElement || document.body;
      const delay = params.delayMs || 50;
      for (const char of params.text) {
        active.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));
        active.dispatchEvent(new KeyboardEvent("keypress", { key: char, bubbles: true }));
        active.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));
        await sleep(delay);
      }
      return null;
    }

    case "remove_element": {
      const el = document.querySelector(params.selector);
      if (el) el.remove();
      return null;
    }

    case "hover_element": {
      const el = document.querySelector(params.selector);
      if (el) {
        const rect = el.getBoundingClientRect();
        const x = rect.left + rect.width / 2;
        const y = rect.top + rect.height / 2;
        el.dispatchEvent(new MouseEvent("mouseover", { clientX: x, clientY: y, bubbles: true }));
        el.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }));
      }
      return null;
    }

    case "select_all_text": {
      const el = document.querySelector(params.selector);
      if (el) {
        el.focus();
        if (el.select) el.select();
        else document.execCommand("selectAll");
      }
      return null;
    }

    case "set_file_input":
      return cmdSetFileInput(params);

    default:
      throw new Error(`content.js: 未知命令 ${method}`);
  }
}

// ───────────────────────── 具体实现 ─────────────────────────

function cmdClickElement({ selector }) {
  const el = document.querySelector(selector);
  if (!el) throw new Error(`元素不存在: ${selector}`);
  el.scrollIntoView({ block: "center" });
  el.click();
  return null;
}

function cmdInputText({ selector, text }) {
  const el = document.querySelector(selector);
  if (!el) throw new Error(`元素不存在: ${selector}`);
  el.focus();
  el.value = text;
  el.dispatchEvent(new Event("input", { bubbles: true }));
  el.dispatchEvent(new Event("change", { bubbles: true }));
  return null;
}

async function cmdInputContentEditable({ selector, text }) {
  const el = document.querySelector(selector);
  if (!el) throw new Error(`元素不存在: ${selector}`);
  el.focus();
  // 全选清空
  document.execCommand("selectAll", false, null);
  document.execCommand("delete", false, null);
  await sleep(80);
  // 逐行插入(换行转为 Enter 键事件)
  const lines = text.split("\n");
  for (let i = 0; i < lines.length; i++) {
    if (lines[i]) document.execCommand("insertText", false, lines[i]);
    if (i < lines.length - 1) {
      el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }));
      el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }));
      await sleep(40);
    }
  }
  return null;
}

async function cmdSetFileInput({ selector, files }) {
  const el = document.querySelector(selector);
  if (!el) throw new Error(`文件输入框不存在: ${selector}`);

  const dt = new DataTransfer();
  for (const f of files) {
    const bytes = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0));
    const blob = new Blob([bytes], { type: f.type });
    dt.items.add(new File([blob], f.name, { type: f.type }));
  }

  Object.defineProperty(el, "files", {
    value: dt.files,
    configurable: true,
    writable: true,
  });

  el.dispatchEvent(new Event("change", { bubbles: true }));
  el.dispatchEvent(new Event("input", { bubbles: true }));
  return null;
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}