nimedge

Nim 2 纯标准库实现的 Edge 浏览器自动化框架

零依赖 Nim 2.2+ CDP 协议 反爬虫

简介

nimedge 是一个用 Nim 2 语言编写的 Edge 浏览器自动化库。它不使用任何第三方库,完全基于 Nim 标准库实现,通过 Chrome DevTools Protocol (CDP) 控制 Chromium 内核的 Edge 浏览器。

极简语法

一行代码启动浏览器,链式调用操作元素,无需复杂配置

🔒

反爬虫隐身

内置 navigator.webdriver 隐藏、插件伪造、WebGL 篡改等 10 项隐身功能

📦

零第三方依赖

纯 Nim 标准库实现,WebSocket/CDP/JSON 全部从零构建

🎯

真实鼠标事件

点击使用 Input.dispatchMouseEvent,模拟真实用户行为

🖥️

跨平台

支持 Windows、macOS、Linux,自动查找 Edge 安装路径

🔧

功能完备

导航、元素操作、截图、PDF、Cookie、代理、输入模拟等一应俱全

核心特性

安装

前置条件

依赖版本安装方式
Nim2.2.0+(推荐 2.2.10)choosenim stable 或从 nim-lang.org 下载
Microsoft Edge任意稳定版microsoft.com/edge 下载

方式一:直接引用源码(推荐)

nimedge 源码放入项目中,编译时指定路径:

# 项目结构
myproject/
├── nimedge/          # nimedge 库源码
│   ├── nimedge.nimble
│   └── src/
│       ├── nimedge.nim       # 统一入口
│       └── nimedge/
│           ├── config.nim    # 配置类型
│           ├── stealth.nim   # 反检测 JS
│           ├── websocket.nim # WebSocket 协议
│           ├── cdp.nim       # CDP 协议层
│           └── browser.nim   # 高层 API
├── main.nim          # 你的代码
└── nim.cfg           # 可选:Nim 配置

# 编译命令
nim c --path:nimedge/src main.nim

# 优化编译(发布模式)
nim c -d:release --path:nimedge/src main.nim

方式二:Nimble 安装

# 在 nimedge 目录下
nimble install

# 在你的项目中使用
import nimedge

方式三:nim.cfg 配置

在项目根目录创建 nim.cfg 文件,避免每次编译都指定路径:

# nim.cfg 内容
path = "nimedge/src"

# 之后直接编译即可
nim c main.nim
Linux 服务器提示

库已自动添加 --no-sandbox--disable-dev-shm-usage 参数。如遇依赖缺失,请安装:apt install libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2

快速开始

30 秒上手,4 行代码完成浏览器自动化:

import nimedge

let b = newBrowser()                      # 启动浏览器
b.navigate("https://example.com")          # 访问网页
echo b.find("h1").text()                # 获取元素文本
b.close()                                   # 关闭浏览器

输出:

Example Domain

指定初始 URL 启动

# newBrowser 的第一个参数是初始 URL
let b = newBrowser("https://www.bing.com")
echo b.getTitle()  # "Bing"
b.close()

搜索并截图

import nimedge

let b = newBrowser()

b.navigate("https://www.bing.com")

# 等待搜索框出现并输入文字
let searchBox = b.waitForSelector("#sb_form_q", timeout = 10000)
searchBox.fill("Nim 编程语言")

# 点击搜索按钮
b.find("#search_icon").click()

# 等待结果加载并获取标题
b.waitForSelector("li.b_algo h2 a", timeout = 10000)
let results = b.findAll("li.b_algo h2 a")

for i, r in results:
  if i >= 5: break
  echo $(i+1) & ". " & r.text()

# 截图保存
b.screenshot("/tmp/search_result.png")

b.close()

架构设计

nimedge 采用 5 层分层架构,从底层到高层依次为:

┌──────────────────────────────────────────┐
│            用户代码 (main.nim)              │
├──────────────────────────────────────────┤
│         nimedge.nim (统一入口)               │  ← export 所有模块
├──────────────┬────────────┬───────────────┤
│  browser.nim │ config.nim │  stealth.nim  │  ← 高层 API
│  (Browser/   │ (配置类型/  │ (反检测 JS     │
│   Element)   │  路径查找)  │  隐身脚本)     │
├──────────────┴────────────┴───────────────┤
│               cdp.nim                      │  ← CDP 协议层
│       (JSON-RPC 命令/响应匹配)              │
│       (事件跳过, 超时控制, 会话管理)        │
├──────────────────────────────────────────┤
│            websocket.nim                   │  ← WebSocket 层
│    (RFC 6455 帧收发, 客户端掩码, 握手)      │
│    (ping/pong 自动处理, 关闭帧检测)          │
├──────────────────────────────────────────┤
│       Nim 标准库 (net, json, osproc,       │  ← 底层依赖
│       base64, times, random, strutils,     │
│       httpclient, tables, os...)           │
└──────────────────────────────────────────┘

模块说明

模块行数功能关键类型/过程
config.nim 81 配置管理,Edge 路径查找 BrowserConfig, defaultConfig(), findEdgePath()
stealth.nim 73 反爬虫隐身 JS 脚本 stealthJs
websocket.nim 171 WebSocket 协议实现 WebSocket, connect(), sendText(), recvText(), close()
cdp.nim 92 CDP JSON-RPC 通信 CdpClient, connectCdp(), send(), sendVoid()
browser.nim 516 高层浏览器/元素 API Browser, Element, navigate(), find(), click()

启动工作流程

1
查找 EdgefindEdgePath() 在 Windows/macOS/Linux 常见路径中搜索 Edge 可执行文件
2
分配端口 — 如果 config.port == 0,随机选择 9223-9999 范围内的端口
3
构建启动参数 — 拼接 --remote-debugging-port--no-sandbox--disable-blink-features=AutomationControlled 等参数
4
启动 Edge 进程 — 通过 startProcess("sh", ...) 启动,输出重定向到 /dev/null 防止管道阻塞
5
等待 HTTP 端点 — 用 raw TCP 连接轮询 127.0.0.1:port,最多等待 15 秒
6
获取 WebSocket URL — HTTP GET /json 端点,解析 JSON 获取 webSocketDebuggerUrl
7
建立 WebSocket 连接 — 使用 newSocket(buffered=false) 创建非缓冲 TCP socket,发送 HTTP Upgrade 请求
8
启用 CDP 域 — 依次发送 Page.enableDOM.enableRuntime.enableNetwork.enableSecurity.enable
9
注入隐身脚本 — 通过 Page.addScriptToEvaluateOnNewDocument 注入 stealth JS,同时也在当前页面执行一次
10
获取文档根节点 — 调用 DOM.getDocument 获取 rootNodeId,用于后续元素查找

CDP 域说明

库在启动时自动启用以下 CDP 域:

CDP 域用途
Page页面导航、截图、PDF 导出、脚本注入
DOMDOM 树查询、元素查找(querySelector)、获取 BoxModel
RuntimeJavaScript 执行、远程对象操作
NetworkCookie 管理、User-Agent 覆写、自定义请求头
SecuritySSL 证书错误忽略
Input键盘按键、鼠标事件分发
Emulation设备模拟、视口覆写

WebSocket 协议实现细节

库从零实现了 RFC 6455 WebSocket 协议,关键设计:

浏览器 API

生命周期管理

# 方式1:默认配置启动(无头 + 隐身)
let b = newBrowser()

# 方式2:指定初始 URL
let b = newBrowser("https://example.com")

# 方式3:自定义配置启动
var cfg = defaultConfig()
cfg.headless = false
let b = newBrowser(cfg)

# 方式4:连接已运行的 Edge 实例
let b = connectExisting(9222)

# 关闭浏览器(终止进程 + 关闭 WebSocket)
b.close()
newBrowser 参数说明

newBrowser(url = "about:blank", config = defaultConfig()) — 第一个参数是初始页面 URL,第二个是配置对象。两个参数都有默认值,可以省略。

页面导航

方法签名说明
navigatenavigate(url: string, waitLoad = true)导航到 URL,默认等待 readyState == "complete"
waitForLoadwaitForLoad(timeout = 30000)阻塞等待页面加载完成,超时抛出异常
reloadreload(waitLoad = true)刷新当前页面
goBackgoBack()浏览器后退
goForwardgoForward()浏览器前进
getUrlgetUrl(): string获取当前页面 URL
getTitlegetTitle(): string获取页面标题
refreshDocumentrefreshDocument()刷新 DOM 根节点 ID(导航或 DOM 修改后调用)
重要:refreshDocument

当通过 b.eval() 修改了 DOM 结构后,需要调用 b.refreshDocument() 更新根节点 ID,否则后续的 find() / findAll() 可能因 nodeId 过期而失败。navigate()reload() 内部已自动调用。

JavaScript 执行

# eval: 执行 JS 表达式,返回 JsonNode
let r = b.eval("1 + 2")
echo r.getInt()           # 3

let b2 = b.eval("true")
echo b2.getBool()         # true

let s = b.eval("document.title")
echo s.getStr()           # "Example Domain"

# evalStr: 执行 JS 并返回字符串(自动类型转换)
echo b.evalStr("document.title")  # "Example Domain"
echo b.evalStr("1 + 2")            # "3"

# 复杂 JS:获取所有链接
let linkCount = b.eval("Array.from(document.querySelectorAll('a')).length")
echo "页面有 " & $(linkCount.getInt()) & " 个链接"

# 修改 DOM(需要 refreshDocument)
discard b.eval("document.body.innerHTML = ''")
b.refreshDocument()
let input = b.find("#myinput")
eval 的 awaitPromise

eval() 内部设置了 "awaitPromise": true,可以直接执行返回 Promise 的异步 JS 代码,如 fetch()

元素查找

# find: 查找单个元素(CSS 选择器),找不到则抛出 NimEdgeError
let h1 = b.find("h1")
let btn = b.find("#submit-btn")
let item = b.find(".list-item:first-child")
let nested = b.find("div.container > ul li.active a")

# findAll: 查找所有匹配元素,返回 seq[Element]
let allLinks = b.findAll("a")
for link in allLinks:
  echo link.attr("href")

# waitForSelector: 轮询等待元素出现(每 200ms 检查一次)
let el = b.waitForSelector(".dynamic-content", timeout = 10000)

# isPresent: 检查元素是否存在(不抛异常)
if b.isPresent("#popup"):
  echo "弹窗出现了!"

# waitForFunction: 等待 JS 条件为真
b.waitForFunction("document.querySelectorAll('.item').length > 20",
                  timeout = 30000)

waitForFunction 的判定逻辑

waitForFunction 接受任意 JS 表达式,以下情况视为"条件满足":

截图与 PDF

# 截图(默认 PNG 格式)
b.screenshot("/tmp/full_page.png")

# 截图(JPEG 格式,体积更小)
b.screenshot("/tmp/full_page.jpg", format = "jpeg")

# 导出 PDF(包含背景色)
b.printToPdf("/tmp/page.pdf")

Cookie 管理

# setCookie: 设置 Cookie(name, value, domain, path 可选)
b.setCookie("session_id", "abc123", "example.com")
b.setCookie("token", "xyz", "example.com", path = "/api")

# getCookies: 获取所有 Cookie(返回 JsonNode 数组)
let cookies = b.getCookies()
for c in cookies:
  echo c["name"].getStr() & " = " & c["value"].getStr()
  echo "  domain: " & c["domain"].getStr()
  echo "  path: " & c["path"].getStr()

# deleteCookie: 删除指定 Cookie
b.deleteCookie("session_id", url = "https://example.com")

输入模拟

# press: 模拟键盘按键(先 keyDown 再 keyUp)
b.press("Enter")
b.press("Escape")
b.press("Tab")
b.press("F5")

# typeText: 在当前聚焦元素中输入文本
b.typeText("Hello World")

# typeText: 模拟人类输入(每个字符间随机延迟 50-150ms)
b.typeText("Hello World", humanLike = true)

# scroll: 滚动到绝对坐标
b.scroll(0, 500)

# scrollBy: 相对滚动
b.scrollBy(0, 300)
humanLike 输入原理

启用 humanLike = true 后,每个字符通过 Input.dispatchKeyEvent 逐个发送,字符间随机休眠 50-150ms。关闭时使用 Input.insertText 一次性插入,速度更快但更容易被检测。

运行时配置覆写

# setUserAgent: 运行时修改 User-Agent
b.setUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)...")

# setViewport: 修改视口大小(通过 Emulation 域)
b.setViewport(1920, 1080)
b.setViewport(375, 812)  # iPhone X 尺寸

# setHeaders: 设置自定义 HTTP 请求头
b.setHeaders({"X-Token": "my_token", "X-Request-Id": "12345"})

元素 API

Element 是一个值类型,代表页面中的一个 DOM 元素。通过 find()findAll()waitForSelector() 获取。

Element 类型定义

type
  Element* = object
    browser*: Browser    # 指向所属 Browser 的引用
    nodeId*: int          # CDP DOM 节点 ID
    selector*: string    # 用于查找的 CSS 选择器

元素操作一览

方法签名说明返回值
clickclick()点击元素(真实鼠标事件)void
fillfill(text: string)设置输入框值并触发 input+change 事件void
texttext(): string获取元素 textContentstring
htmlhtml(): string获取元素 outerHTMLstring
attrattr(name: string): string获取属性值,不存在返回空字符串string
focusfocus()聚焦元素void
presspress(key: string)先聚焦再按键void
scrollIntoViewscrollIntoView()滚动到元素可见void
selectOptionselectOption(value: string)选择 void
isCheckedisChecked(): bool检查复选框是否选中bool
checkcheck()勾选复选框(已选中则跳过)void
uncheckuncheck()取消勾选(未选中则跳过)void

click 的实现原理

click() 方法分为三步,模拟真实用户点击行为:

  1. 滚动到可见 — 调用 this.scrollIntoView({block:'center'}) 确保元素在视口中央
  2. 获取坐标 — 通过 DOM.getBoxModel 获取元素的 content box,计算中心点 (x, y)
  3. 分发鼠标事件 — 先发送 Input.dispatchMouseEvent(mousePressed),再发送 mouseReleased
为什么不用 JS .click()?

直接调用 element.click() 的 JS 方法不包含真实鼠标坐标,很多反爬虫系统会检测 isTrusted 属性。使用 Input.dispatchMouseEvent 分发的事件 isTrustedtrue,更不容易被检测。

fill 的实现原理

fill() 方法在元素上执行以下 JS:

  1. this.focus() — 聚焦元素
  2. this.value = 'text' — 设置值(自动转义引号和反斜杠)
  3. this.dispatchEvent(new Event('input', {bubbles:true})) — 触发 input 事件
  4. this.dispatchEvent(new Event('change', {bubbles:true})) — 触发 change 事件

这确保前端框架(React、Vue 等)能正确捕获到值变化。

使用示例

let b = newBrowser()
b.navigate("https://example.com")

# 获取元素信息
let h1 = b.find("h1")
echo "文本: " & h1.text()
echo "HTML: " & h1.html()

let link = b.find("a")
echo "链接: " & link.attr("href")
echo "class: " & link.attr("class")

# 点击元素
link.click()

# 填写表单
b.find("#username").fill("admin")
b.find("#password").fill("123456")
b.find("#country").selectOption("cn")
b.find("#agree").check()

# 验证复选框状态
echo b.find("#agree").isChecked()  # true

# 提交
b.find("button[type='submit']").click()

b.close()

遍历元素列表

# 获取所有链接的 href 和文本
let links = b.findAll("a")
for i, link in links:
  echo "[" & $i & "] " & link.text() & " -> " & link.attr("href")

# 获取所有图片的 src
let imgs = b.findAll("img")
for img in imgs:
  echo img.attr("src")

反爬虫隐身

nimedge 内置了 10 项反检测措施,在浏览器启动时通过 Page.addScriptToEvaluateOnNewDocument 自动注入。该 CDP 命令确保隐身脚本在任何页面脚本之前执行。

隐身措施详解

#检测项原始值隐身后实现方式
1navigator.webdrivertrueundefinedObject.defineProperty 覆写 getter
2navigator.plugins空数组5 个伪造插件返回包含 PDF Viewer 等的数组
3navigator.languages["en-US"]["en-US","en"]覆写 getter
4window.chrome不存在伪造对象创建 chrome.runtimechrome.app
5Permissions API异常行为正常返回覆写 permissions.query
6WebGL vendorSwiftShader"Intel Inc."拦截 getParameter(37445)
7WebGL rendererSwiftShader"Intel Iris OpenGL Engine"拦截 getParameter(37446)
8Function.toString暴露覆写返回 native code覆写 toString 隐藏痕迹
9navigator.hardwareConcurrency不确定8覆写 getter
10navigator.platformLinux/Mac"Win32"覆写 getter

启动参数隐身

除了 JS 注入,库还在启动 Edge 时添加了以下命令行参数:

参数作用
--disable-blink-features=AutomationControlled禁用 Blink 引擎的自动化控制标记,这是最重要的参数
--disable-features=IsolateOrigins,site-per-process禁用站点隔离,避免某些检测
--no-first-run跳过首次运行向导
--no-default-browser-check跳过默认浏览器检查
--disable-extensions禁用扩展,避免干扰
--disable-popup-blocking禁用弹窗拦截
--no-sandboxLinux 下禁用沙箱(root 用户必需)
--disable-dev-shm-usage避免 /dev/shm 空间不足导致崩溃
自定义 --user-agent使用包含 Edg/ 标识的真实 UA
自定义 --lang设置浏览器语言

隐身 JS 源码逐行解析

// 1. 删除 navigator.webdriver — 最基本的反检测
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });

// 2. 伪造 plugins 数组 — 真实浏览器有 PDF 查看器插件
Object.defineProperty(navigator, 'plugins', {
  get: () => {
    const arr = [
      { name: 'PDF Viewer', ... },
      { name: 'Chrome PDF Viewer', ... },
      { name: 'Chromium PDF Viewer', ... },
      { name: 'Microsoft Edge PDF Viewer', ... },
      { name: 'WebKit built-in PDF', ... },
    ];
    arr.item = (i) => arr[i] || null;       // 模拟 PluginArray 接口
    arr.namedItem = (n) => arr.find(p => p.name === n) || null;
    arr.refresh = () => {};
    return arr;
  }
});

// 3. 伪造 languages — 真实浏览器返回多个语言
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });

// 4. 伪造 window.chrome 对象 — Chrome/Edge 特有
if (!window.chrome) { window.chrome = {}; }
window.chrome.runtime = {};
window.chrome.app = { isInstalled: false };

// 5. 修复 Permissions API — 自动化浏览器的 query() 行为异常
const origQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
  parameters.name === 'notifications' ?
    Promise.resolve({ state: Notification.permission }) :
    origQuery(parameters)
);

// 6. 篡改 WebGL vendor/renderer — 指纹识别常用
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
  if (parameter === 37445) return 'Intel Inc.';            // UNMASKED_VENDOR_WEBGL
  if (parameter === 37446) return 'Intel Iris OpenGL Engine';  // UNMASKED_RENDERER_WEBGL
  return getParameter.call(this, parameter);
};

// 7. 隐藏 toString 痕迹 — 防止检测覆写的函数
const origToString = Function.prototype.toString;
Function.prototype.toString = function() {
  if (this === window.navigator.permissions.query)
    return 'function query() { [native code] }';
  return origToString.call(this);
};

// 8-10. 伪造硬件信息
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });

验证隐身效果

import nimedge, json

let b = newBrowser()
b.navigate("about:blank")

# 1. 验证 webdriver 已隐藏
assert b.eval("navigator.webdriver").kind == JNull

# 2. 验证插件已伪造
assert b.eval("navigator.plugins.length").getInt() == 5

# 3. 验证平台已伪造
assert b.eval("navigator.platform").getStr() == "Win32"

# 4. 验证 hardwareConcurrency
assert b.eval("navigator.hardwareConcurrency").getInt() == 8

# 5. 验证 chrome 对象存在
assert b.eval("typeof window.chrome === 'object'").getBool()

# 6. 验证 languages
let langs = b.eval("navigator.languages")
assert langs.len == 2

b.close()
echo "所有隐身验证通过!"
注意事项

隐身模式默认开启。如需关闭(例如调试时),设置 cfg.stealthMode = false。无头模式下 WebGL 可能不可用,这是 Chromium 的限制,不影响其他隐身措施。

配置详解

BrowserConfig 完整字段

字段类型默认值说明
headlessbooltrue是否使用无头模式
portint0调试端口(0 = 随机分配 9223-9999)
userDataDirstring空(自动)用户数据目录(留空则自动创建临时目录)
windowWidthint1920窗口宽度(像素)
windowHeightint1080窗口高度(像素)
userAgentstringEdge UA自定义 User-Agent 字符串
disableImagesboolfalse禁用图片加载(大幅提升速度)
disableGpuboolfalse禁用 GPU 加速(无头模式自动启用)
proxyServerstring代理服务器地址(如 socks5://127.0.0.1:1080
extraArgsseq[string]@[]额外的 Edge 命令行参数
stealthModebooltrue是否启用隐身模式
timeoutint30000CDP 命令超时时间(毫秒)
langstring"en-US,en"浏览器语言(影响 Accept-Language 头)

默认 User-Agent

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0

配置示例

基础配置

var cfg = defaultConfig()
cfg.headless = false          # 显示浏览器窗口
cfg.windowWidth = 1280
cfg.windowHeight = 720
let b = newBrowser(cfg)

代理配置

var cfg = defaultConfig()
cfg.proxyServer = "socks5://127.0.0.1:1080"
# 或 HTTP 代理
# cfg.proxyServer = "http://proxy.example.com:8080"
let b = newBrowser(cfg)

高性能抓取配置

var cfg = defaultConfig()
cfg.disableImages = true      # 禁用图片,大幅提升速度
cfg.disableGpu = true         # 禁用 GPU
cfg.timeout = 60000            # 增加超时
let b = newBrowser(cfg)

移动端模拟

var cfg = defaultConfig()
cfg.userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) " &
               "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 " &
               "Mobile/15E148 Safari/604.1"
cfg.windowWidth = 375
cfg.windowHeight = 812
let b = newBrowser(cfg)
b.setViewport(375, 812)  # 运行时也可修改

会话保持配置

var cfg = defaultConfig()
cfg.userDataDir = "/tmp/my_profile"  # 持久化用户数据
let b = newBrowser(cfg)
# 登录后关闭,下次使用相同 userDataDir 启动时登录状态保持

自定义额外参数

var cfg = defaultConfig()
cfg.extraArgs = @[
  "--disable-web-security",           # 禁用同源策略(调试用)
  "--ignore-certificate-errors",       # 忽略证书错误
  "--window-position=100,100",         # 窗口位置
]
let b = newBrowser(cfg)

关闭隐身模式

var cfg = defaultConfig()
cfg.stealthMode = false  # 调试时关闭隐身
let b = newBrowser(cfg)

Edge 路径查找

库会自动在以下路径查找 Edge 可执行文件:

平台搜索路径
WindowsC:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
C:\Program Files\Microsoft\Edge\Application\msedge.exe
macOS/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge
Linux/usr/bin/microsoft-edge
/usr/bin/microsoft-edge-stable
/usr/bin/microsoft-edge-dev
/opt/microsoft/msedge/microsoft-edge

如果以上路径都找不到,会通过 findExePATH 中搜索。找不到时抛出 IOError

错误处理

异常类型层次

CatchableError                    # Nim 标准库
├── NimEdgeError                  # browser.nim — 所有 nimedge 高层错误
│   ├── "Element not found: ..."
│   ├── "Timeout waiting for: ..."
│   ├── "Timeout waiting for page load"
│   ├── "Edge did not start within 15s"
│   └── "JS error: ..."
├── CdpError                      # cdp.nim — CDP 协议错误
│   ├── "Timeout (30000ms) waiting for: ..."
│   ├── "CDP error in ..."
│   └── "WebSocket error in ..."
├── WebSocketError                # websocket.nim — WebSocket 协议错误
│   ├── "Connection closed during handshake"
│   ├── "WebSocket handshake failed: ..."
│   ├── "Connection closed or timed out"
│   └── "WebSocket closed by server"
└── IOError                       # config.nim — Edge 未找到
    └── "Edge browser not found"

异常处理示例

import nimedge

try:
  let b = newBrowser()
  b.navigate("https://example.com")

  try:
    let el = b.find(".nonexistent")
  except NimEdgeError:
    echo "元素不存在,跳过"

  try:
    b.waitForSelector("#slow-element", timeout = 5000)
  except NimEdgeError as e:
    echo "等待超时: " & e.msg

  b.close()
except IOError:
  echo "Edge 未安装,请先安装 Microsoft Edge"
except NimEdgeError as e:
  echo "浏览器启动失败: " & e.msg
except CdpError as e:
  echo "CDP 通信错误: " & e.msg
重要

务必在 finally 块中调用 b.close(),否则 Edge 进程可能残留。推荐使用 try/finally 模式。

安全的资源管理模式

import nimedge

try:
  b.navigate("https://example.com")
  # ... 业务逻辑 ...
finally:
  b.close()  # 确保无论如何都关闭浏览器

底层 API

对于高级用户,nimedge 暴露了 CDP 和 WebSocket 两个底层模块,可以直接发送任意 CDP 命令。

CDP 客户端 (cdp.nim)

import nimedge/[cdp, browser]

let b = newBrowser()

# 通过 b.cdp 直接发送任意 CDP 命令
let version = b.cdp.send("Browser.getVersion")
echo "浏览器版本: " & version["product"].getStr()
echo "用户代理: " & version["userAgent"].getStr()

# 获取页面内存信息
let metrics = b.cdp.send("Performance.getMetrics")
for m in metrics["metrics"]:
  echo m["name"].getStr() & ": " & $m["value"].getFloat()

# 清除浏览器缓存
b.cdp.sendVoid("Network.clearBrowserCache")

# 设置地理位置
b.cdp.sendVoid("Emulation.setGeolocationOverride", %*{
  "latitude": 39.9042,
  "longitude": 116.4074,
  "accuracy": 100
})

b.close()

callFnOnNode — 在 DOM 节点上执行 JS 函数

# browser.nim 暴露了 callFnOnNode 方法
let el = b.find("#myelement")

# 在元素上执行任意 JS 函数
let r = b.callFnOnNode(el.nodeId, """function() {
  return {
    width: this.offsetWidth,
    height: this.offsetHeight,
    color: getComputedStyle(this).color
  }
}""")
echo "宽度: " & $(r["value"]["width"].getInt())
echo "颜色: " & r["value"]["color"].getStr()

WebSocket 客户端 (websocket.nim)

import nimedge/websocket

# 直接使用 WebSocket 连接任意 WS 服务
var ws = websocket.connect("echo.websocket.org", 80, "/")
ws.sendText("Hello WebSocket!")
let reply = ws.recvText()
echo reply  # "Hello WebSocket!"
ws.close()

WebSocket API 一览

方法签名说明
connectconnect(host, port, path, timeout): WebSocket建立 WebSocket 连接
sendTextsendText(data: string)发送文本帧
recvTextrecvText(timeout): string接收文本帧(自动处理 ping/pong)
closeclose()发送 close 帧并关闭连接
isConnectedisConnected(): bool检查连接状态

实战示例

1. 网页数据抓取

import nimedge

let b = newBrowser()

b.navigate("https://news.ycombinator.com")
b.waitForSelector(".athing", timeout = 10000)

let titles = b.findAll(".titleline > a")
echo "Hacker News 热门文章:"
for i, t in titles:
  if i >= 10: break
  echo $(i+1) & ". " & t.text()
  echo "   " & t.attr("href")

b.close()

2. 表单自动填写与提交

import nimedge

let b = newBrowser()
b.navigate("https://example.com/form")

# 填写表单
b.find("#username").fill("nim_user")
b.find("#email").fill("user@example.com")
b.find("#country").selectOption("cn")
b.find("#bio").fill("Hello, I am a Nim developer!")
b.find("#agree").check()

# 提交
b.find("button[type='submit']").click()

# 等待结果
b.waitForSelector(".success-message", timeout = 10000)
echo "提交成功!"

b.close()

3. 等待策略组合

import nimedge

let b = newBrowser()

# 等待元素出现
let btn = b.waitForSelector("#load-more", timeout = 15000)

# 等待 JS 条件满足
b.waitForFunction("document.querySelectorAll('.item').length > 20",
                  timeout = 30000)

# 检查元素是否存在
if b.isPresent(".popup-close"):
  b.find(".popup-close").click()

b.close()

4. Cookie 管理与会话保持

import nimedge

# 使用持久化用户数据目录保持会话
var cfg = defaultConfig()
cfg.userDataDir = "/tmp/my_session"

let b = newBrowser(cfg)
b.navigate("https://example.com/login")

# 登录...
b.find("#username").fill("myuser")
b.find("#password").fill("mypass")
b.find("#login-btn").click()

# 等待登录完成
b.waitForSelector(".user-avatar", timeout = 10000)

# 手动设置 Cookie
b.setCookie("custom_token", "xyz789", "example.com")

b.close()

# 下次使用相同 userDataDir 启动时,登录状态会保持

5. 模拟移动设备

import nimedge

var cfg = defaultConfig()
cfg.userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) " &
               "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
cfg.windowWidth = 375
cfg.windowHeight = 812

let b = newBrowser(cfg)
b.setViewport(375, 812)

b.navigate("https://example.com")
b.screenshot("/tmp/mobile_view.png")

b.close()

6. 代理 + 禁用图片高性能抓取

import nimedge

var cfg = defaultConfig()
cfg.proxyServer = "socks5://127.0.0.1:1080"
cfg.disableImages = true
cfg.disableGpu = true
cfg.timeout = 60000

let b = newBrowser(cfg)
b.navigate("https://example.com")
echo b.getTitle()
b.close()

7. 无限滚动抓取

import nimedge

let b = newBrowser()
b.navigate("https://example.com/infinite-scroll")
b.waitForSelector(".item", timeout = 10000)

var lastCount = 0
for page in 1..10:
  # 滚动到底部
  b.scrollBy(0, 3000)
  sleep(2000)  # 等待加载

  let items = b.findAll(".item")
  if items.len == lastCount:
    echo "没有更多内容了"
    break
  lastCount = items.len
  echo "第 " & $page & " 页,共 " & $items.len & " 条"

b.close()

8. 人类模拟输入

import nimedge

let b = newBrowser()
b.navigate("https://example.com/search")

# 聚焦搜索框
b.find("#search-input").focus()

# 逐字符输入,模拟人类打字
b.typeText("nim edge automation", humanLike = true)

# 随机延迟后按回车
sleep(500)
b.press("Enter")

# 等待结果
b.waitForSelector(".search-results", timeout = 10000)

b.close()

9. 使用底层 CDP 获取浏览器信息

import nimedge

let b = newBrowser()

# 获取浏览器版本信息
let ver = b.cdp.send("Browser.getVersion")
echo "产品: " & ver["product"].getStr()
echo "版本: " & ver["revision"].getStr()
echo "UA: " & ver["userAgent"].getStr()
echo "语言: " & ver["userAgent"].getStr()

# 获取性能指标
b.cdp.sendVoid("Performance.enable")
let metrics = b.cdp.send("Performance.getMetrics")
echo "性能指标:"
for m in metrics["metrics"]:
  echo "  " & m["name"].getStr() & ": " & $m["value"].getFloat()

b.close()

最佳实践

1. 始终使用 try/finally 确保资源释放

let b = newBrowser()
try:
  b.navigate("https://example.com")
  # 业务逻辑
finally:
  b.close()

2. 使用 waitForSelector 而非 sleep

# 错误:硬编码等待
b.navigate(url)
sleep(3000)

# 正确:等待元素出现
b.navigate(url)
b.waitForSelector("#content", timeout = 10000)

3. 抓取时禁用图片提升速度

var cfg = defaultConfig()
cfg.disableImages = true
let b = newBrowser(cfg)

4. 使用 humanLike 输入避免检测

b.typeText("search keyword", humanLike = true)

5. 修改 DOM 后调用 refreshDocument

discard b.eval("document.body.innerHTML += '
...
'"
) b.refreshDocument() # 必须!否则 find 找不到新元素 let el = b.find("#new")

6. 合理设置超时时间

var cfg = defaultConfig()
cfg.timeout = 60000  # 慢速网站增加超时

# 或针对单个操作设置
b.waitForSelector("#slow-element", timeout = 60000)

7. 使用 isPresent 处理可选元素

# 弹窗可能出现的场景
if b.isPresent(".cookie-banner .accept"):
  b.find(".cookie-banner .accept").click()

8. 使用代理轮换 IP

var cfg = defaultConfig()
cfg.proxyServer = "socks5://user:pass@proxy.example.com:1080"

故障排除

常见问题与解决方案

问题原因解决方案
Edge browser not found 系统未安装 Edge 从 microsoft.com/edge 下载安装
Edge did not start within 15s Edge 启动超时 检查 Edge 是否正常运行;可能是依赖缺失(Linux)
Timeout waiting for page load 页面加载超时 增加 cfg.timeout 或使用 navigate(url, waitLoad=false)
Element not found: ... CSS 选择器未匹配到元素 检查选择器拼写;使用 waitForSelector 等待动态元素
Timeout waiting for: ... 元素未在超时时间内出现 增加 timeout 参数;检查页面是否需要滚动加载
JS error: ... JavaScript 执行出错 检查 JS 表达式语法;使用浏览器控制台先测试
WebSocket closed by server Edge 进程意外退出 检查内存是否不足;尝试 cfg.disableGpu = true
CDP error in ... CDP 命令返回错误 检查 CDP 命令参数是否正确
find() 在 eval 后找不到元素 DOM 修改后 nodeId 过期 调用 b.refreshDocument() 后再 find
Linux 下 Edge 崩溃 缺少系统依赖 安装 libnss3, libatk1.0-0, libcups2 等
无头模式下 WebGL 不可用 Chromium 限制 使用 headless=false 或忽略 WebGL 相关隐身

调试技巧

工具对比

与其他浏览器自动化工具对比

特性nimedgePuppeteerSeleniumPlaywright
语言Nim 2JavaScript/TypeScript多语言多语言
第三方依赖puppeteer 包selenium + webdriverplaywright 包
通信协议CDP (WebSocket)CDP (WebSocket)WebDriver (HTTP)CDP (WebSocket)
反爬虫隐身内置 10 项需 stealth 插件需额外配置部分内置
二进制体积极小(~2MB)大(~50MB+)
编译速度极快(~2s)N/A(解释执行)N/AN/A
运行性能原生速度V8 JITJVM/V8V8 JIT
内存占用极低
学习曲线简单中等中等中等
多标签页暂不支持支持支持支持
多浏览器仅 EdgeChrome/Firefox所有所有
nimedge 的优势

零依赖、极小体积、原生性能、内置隐身。特别适合对部署体积和运行性能有要求的场景,如嵌入式设备、CI/CD 管道、服务器端爬虫。

常见问题

Q: 为什么不使用第三方库?

nimedge 的设计理念是零依赖。WebSocket 协议、CDP 通信、JSON 处理全部使用 Nim 标准库从零实现。这意味着:无需安装任何外部依赖,编译即可运行;完全可控的代码,便于调试和定制;更小的二进制体积;展示了 Nim 标准库的强大能力。

Q: 支持哪些操作系统?

支持 Windows、macOS 和 Linux。库会自动查找 Edge 的安装路径。在 Linux 服务器上,库会自动添加 --no-sandbox--disable-dev-shm-usage 参数。

Q: 无头模式和有头模式有什么区别?

无头模式(headless = true,默认)不显示浏览器窗口,适合服务器环境。有头模式(headless = false)显示浏览器窗口,适合调试和需要可视化交互的场景。有头模式需要桌面环境支持。无头模式下 GPU 加速自动禁用。

Q: 如何处理页面加载超时?

可以通过 cfg.timeout 设置全局超时,也可以在 waitForSelectorwaitForFunction 中指定 timeout 参数。如果超时会抛出 NimEdgeError 异常。也可以使用 navigate(url, waitLoad=false) 跳过等待。

Q: 如何处理弹窗和对话框?

使用 b.isPresent() 检查弹窗是否存在,然后点击关闭按钮。对于 JavaScript alert/confirm 对话框,CDP 会自动处理。

Q: 隐身模式能通过所有反爬虫检测吗?

nimedge 的隐身模式可以应对大多数常见检测(navigator.webdriver、plugins、WebGL 等),但不能保证通过所有检测。高级检测可能需要额外的定制。建议配合合理的请求间隔、随机延迟、代理轮换等策略使用。

Q: 为什么 find() 在 eval() 后找不到元素?

通过 eval() 修改 DOM 后,CDP 的节点 ID 可能过期。调用 b.refreshDocument() 刷新根节点即可解决。navigate()reload() 内部已自动调用。

Q: 支持多标签页吗?

当前版本专注于单标签页操作。多标签页需要通过底层 CDP 的 Target.createTargetTarget.attachToTarget 实现,可以通过 b.cdp.send() 手动调用。

Q: 如何在项目中集成?

# nimble 文件
# myproject.nimble
requires "nimedge"

# 或直接引用源码
# nim c --path:../nimedge/src myproject.nim

# 或创建 nim.cfg
# path = "nimedge/src"

Q: 如何贡献代码?

欢迎提交 Issue 和 Pull Request。项目结构清晰,每个模块职责明确,便于扩展。

API 速查表

Browser 方法

# === 生命周期 ===
newBrowser(url = "about:blank", cfg = defaultConfig())  # -> Browser
connectExisting(port: int, timeout = 30000)             # -> Browser
b.close()
b.refreshDocument()

# === 导航 ===
b.navigate(url: string, waitLoad = true)
b.waitForLoad(timeout = 30000)
b.reload(waitLoad = true)
b.goBack()
b.goForward()
b.getUrl()                                              # -> string
b.getTitle()                                            # -> string

# === JavaScript ===
b.eval(expr: string)                                    # -> JsonNode
b.evalStr(expr: string)                                 # -> string

# === 元素查找 ===
b.find(selector: string)                                # -> Element (找不到抛异常)
b.findAll(selector: string)                             # -> seq[Element]
b.waitForSelector(selector: string, timeout = 30000)    # -> Element
b.isPresent(selector: string)                            # -> bool
b.waitForFunction(jsExpr: string, timeout = 30000)      # -> void

# === 截图/PDF ===
b.screenshot(path: string, format = "png")
b.printToPdf(path: string)

# === Cookie ===
b.setCookie(name, value, domain: string, path = "/")
b.getCookies()                                          # -> JsonNode
b.deleteCookie(name: string, url = "")

# === 输入 ===
b.press(key: string)
b.typeText(text: string, humanLike = false)
b.scroll(x, y: int)
b.scrollBy(dx, dy: int)

# === 运行时配置 ===
b.setUserAgent(ua: string)
b.setViewport(width, height: int)
b.setHeaders(headers: Table[string, string])

# === 底层访问 ===
b.cdp                                                   # -> CdpClient
b.cdp.send(methodName: string, params: JsonNode)        # -> JsonNode
b.cdp.sendVoid(methodName: string, params: JsonNode)
b.callFnOnNode(nodeId: int, fn: string, args: seq)      # -> JsonNode

Element 方法

# === 交互 ===
el.click()                    # 真实鼠标点击
el.fill(text: string)         # 设置输入框值
el.focus()                    # 聚焦元素
el.press(key: string)          # 聚焦后按键
el.scrollIntoView()           # 滚动到可见
el.selectOption(value: string) # 选择下拉选项
el.check()                    # 勾选复选框
el.uncheck()                  # 取消勾选

# === 信息获取 ===
el.text()                     # -> string (textContent)
el.html()                     # -> string (outerHTML)
el.attr(name: string)         # -> string (属性值)
el.isChecked()                # -> bool

BrowserConfig 字段

# === 基础 ===
cfg.headless: bool            # 默认 true
cfg.port: int                 # 默认 0 (随机)
cfg.userDataDir: string       # 默认 "" (自动)
cfg.windowWidth: int          # 默认 1920
cfg.windowHeight: int         # 默认 1080
cfg.userAgent: string         # 默认 Edge UA
cfg.lang: string              # 默认 "en-US,en"

# === 性能 ===
cfg.disableImages: bool       # 默认 false
cfg.disableGpu: bool          # 默认 false

# === 网络 ===
cfg.proxyServer: string       # 默认 ""
cfg.extraArgs: seq[string]    # 默认 @[]

# === 隐身 ===
cfg.stealthMode: bool         # 默认 true
cfg.timeout: int              # 默认 30000 (ms)

异常类型

NimEdgeError     # 高层错误(元素未找到、超时、JS 错误等)
CdpError          # CDP 协议错误(命令失败、WebSocket 断开)
WebSocketError    # WebSocket 错误(握手失败、连接关闭)
IOError           # Edge 未找到