Nim 2 纯标准库实现的 Edge 浏览器自动化框架
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、代理、输入模拟等一应俱全
navigator.webdriver、伪造 plugins/languages/hardwareConcurrency/platform/deviceMemory、篡改 WebGL 渲染器、伪造 chrome 对象、覆写 Permissions API、隐藏 toString 痕迹DOM.getBoxModel 获取坐标后通过 Input.dispatchMouseEvent 发送鼠标按下/释放事件,而非简单的 JS .click()| 依赖 | 版本 | 安装方式 |
|---|---|---|
| Nim | 2.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
# 在 nimedge 目录下
nimble install
# 在你的项目中使用
import nimedge
在项目根目录创建 nim.cfg 文件,避免每次编译都指定路径:
# nim.cfg 内容
path = "nimedge/src"
# 之后直接编译即可
nim c main.nim
库已自动添加 --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
# 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() 等 |
findEdgePath() 在 Windows/macOS/Linux 常见路径中搜索 Edge 可执行文件config.port == 0,随机选择 9223-9999 范围内的端口--remote-debugging-port、--no-sandbox、--disable-blink-features=AutomationControlled 等参数startProcess("sh", ...) 启动,输出重定向到 /dev/null 防止管道阻塞127.0.0.1:port,最多等待 15 秒/json 端点,解析 JSON 获取 webSocketDebuggerUrlnewSocket(buffered=false) 创建非缓冲 TCP socket,发送 HTTP Upgrade 请求Page.enable、DOM.enable、Runtime.enable、Network.enable、Security.enablePage.addScriptToEvaluateOnNewDocument 注入 stealth JS,同时也在当前页面执行一次DOM.getDocument 获取 rootNodeId,用于后续元素查找库在启动时自动启用以下 CDP 域:
| CDP 域 | 用途 |
|---|---|
Page | 页面导航、截图、PDF 导出、脚本注入 |
DOM | DOM 树查询、元素查找(querySelector)、获取 BoxModel |
Runtime | JavaScript 执行、远程对象操作 |
Network | Cookie 管理、User-Agent 覆写、自定义请求头 |
Security | SSL 证书错误忽略 |
Input | 键盘按键、鼠标事件分发 |
Emulation | 设备模拟、视口覆写 |
库从零实现了 RFC 6455 WebSocket 协议,关键设计:
newSocket(buffered=false) 避免 Nim 的 net 模块缓冲导致的读取超时问题WebSocketError 异常Sec-WebSocket-Accept 验证(避免实现 SHA-1),仅检查 HTTP 101 状态码# 方式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(url = "about:blank", config = defaultConfig()) — 第一个参数是初始页面 URL,第二个是配置对象。两个参数都有默认值,可以省略。
| 方法 | 签名 | 说明 |
|---|---|---|
navigate | navigate(url: string, waitLoad = true) | 导航到 URL,默认等待 readyState == "complete" |
waitForLoad | waitForLoad(timeout = 30000) | 阻塞等待页面加载完成,超时抛出异常 |
reload | reload(waitLoad = true) | 刷新当前页面 |
goBack | goBack() | 浏览器后退 |
goForward | goForward() | 浏览器前进 |
getUrl | getUrl(): string | 获取当前页面 URL |
getTitle | getTitle(): string | 获取页面标题 |
refreshDocument | refreshDocument() | 刷新 DOM 根节点 ID(导航或 DOM 修改后调用) |
当通过 b.eval() 修改了 DOM 结构后,需要调用 b.refreshDocument() 更新根节点 ID,否则后续的 find() / findAll() 可能因 nodeId 过期而失败。navigate() 和 reload() 内部已自动调用。
# 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": 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 接受任意 JS 表达式,以下情况视为"条件满足":
true(布尔值)# 截图(默认 PNG 格式)
b.screenshot("/tmp/full_page.png")
# 截图(JPEG 格式,体积更小)
b.screenshot("/tmp/full_page.jpg", format = "jpeg")
# 导出 PDF(包含背景色)
b.printToPdf("/tmp/page.pdf")
# 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 = 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"})
Element 是一个值类型,代表页面中的一个 DOM 元素。通过 find()、findAll() 或 waitForSelector() 获取。
type
Element* = object
browser*: Browser # 指向所属 Browser 的引用
nodeId*: int # CDP DOM 节点 ID
selector*: string # 用于查找的 CSS 选择器
| 方法 | 签名 | 说明 | 返回值 |
|---|---|---|---|
click | click() | 点击元素(真实鼠标事件) | void |
fill | fill(text: string) | 设置输入框值并触发 input+change 事件 | void |
text | text(): string | 获取元素 textContent | string |
html | html(): string | 获取元素 outerHTML | string |
attr | attr(name: string): string | 获取属性值,不存在返回空字符串 | string |
focus | focus() | 聚焦元素 | void |
press | press(key: string) | 先聚焦再按键 | void |
scrollIntoView | scrollIntoView() | 滚动到元素可见 | void |
selectOption | selectOption(value: string) | 选择 | void |
isChecked | isChecked(): bool | 检查复选框是否选中 | bool |
check | check() | 勾选复选框(已选中则跳过) | void |
uncheck | uncheck() | 取消勾选(未选中则跳过) | void |
click() 方法分为三步,模拟真实用户点击行为:
this.scrollIntoView({block:'center'}) 确保元素在视口中央DOM.getBoxModel 获取元素的 content box,计算中心点 (x, y)Input.dispatchMouseEvent(mousePressed),再发送 mouseReleased直接调用 element.click() 的 JS 方法不包含真实鼠标坐标,很多反爬虫系统会检测 isTrusted 属性。使用 Input.dispatchMouseEvent 分发的事件 isTrusted 为 true,更不容易被检测。
fill() 方法在元素上执行以下 JS:
this.focus() — 聚焦元素this.value = 'text' — 设置值(自动转义引号和反斜杠)this.dispatchEvent(new Event('input', {bubbles:true})) — 触发 input 事件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 命令确保隐身脚本在任何页面脚本之前执行。
| # | 检测项 | 原始值 | 隐身后 | 实现方式 |
|---|---|---|---|---|
| 1 | navigator.webdriver | true | undefined | Object.defineProperty 覆写 getter |
| 2 | navigator.plugins | 空数组 | 5 个伪造插件 | 返回包含 PDF Viewer 等的数组 |
| 3 | navigator.languages | ["en-US"] | ["en-US","en"] | 覆写 getter |
| 4 | window.chrome | 不存在 | 伪造对象 | 创建 chrome.runtime 和 chrome.app |
| 5 | Permissions API | 异常行为 | 正常返回 | 覆写 permissions.query |
| 6 | WebGL vendor | SwiftShader | "Intel Inc." | 拦截 getParameter(37445) |
| 7 | WebGL renderer | SwiftShader | "Intel Iris OpenGL Engine" | 拦截 getParameter(37446) |
| 8 | Function.toString | 暴露覆写 | 返回 native code | 覆写 toString 隐藏痕迹 |
| 9 | navigator.hardwareConcurrency | 不确定 | 8 | 覆写 getter |
| 10 | navigator.platform | Linux/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-sandbox | Linux 下禁用沙箱(root 用户必需) |
--disable-dev-shm-usage | 避免 /dev/shm 空间不足导致崩溃 |
自定义 --user-agent | 使用包含 Edg/ 标识的真实 UA |
自定义 --lang | 设置浏览器语言 |
// 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 的限制,不影响其他隐身措施。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
headless | bool | true | 是否使用无头模式 |
port | int | 0 | 调试端口(0 = 随机分配 9223-9999) |
userDataDir | string | 空(自动) | 用户数据目录(留空则自动创建临时目录) |
windowWidth | int | 1920 | 窗口宽度(像素) |
windowHeight | int | 1080 | 窗口高度(像素) |
userAgent | string | Edge UA | 自定义 User-Agent 字符串 |
disableImages | bool | false | 禁用图片加载(大幅提升速度) |
disableGpu | bool | false | 禁用 GPU 加速(无头模式自动启用) |
proxyServer | string | 空 | 代理服务器地址(如 socks5://127.0.0.1:1080) |
extraArgs | seq[string] | @[] | 额外的 Edge 命令行参数 |
stealthMode | bool | true | 是否启用隐身模式 |
timeout | int | 30000 | CDP 命令超时时间(毫秒) |
lang | string | "en-US,en" | 浏览器语言(影响 Accept-Language 头) |
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 可执行文件:
| 平台 | 搜索路径 |
|---|---|
| Windows | C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exeC:\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 |
如果以上路径都找不到,会通过 findExe 在 PATH 中搜索。找不到时抛出 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() # 确保无论如何都关闭浏览器
对于高级用户,nimedge 暴露了 CDP 和 WebSocket 两个底层模块,可以直接发送任意 CDP 命令。
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()
# 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()
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()
| 方法 | 签名 | 说明 |
|---|---|---|
connect | connect(host, port, path, timeout): WebSocket | 建立 WebSocket 连接 |
sendText | sendText(data: string) | 发送文本帧 |
recvText | recvText(timeout): string | 接收文本帧(自动处理 ping/pong) |
close | close() | 发送 close 帧并关闭连接 |
isConnected | isConnected(): bool | 检查连接状态 |
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()
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()
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()
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 启动时,登录状态会保持
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()
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()
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()
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()
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()
let b = newBrowser()
try:
b.navigate("https://example.com")
# 业务逻辑
finally:
b.close()
# 错误:硬编码等待
b.navigate(url)
sleep(3000)
# 正确:等待元素出现
b.navigate(url)
b.waitForSelector("#content", timeout = 10000)
var cfg = defaultConfig()
cfg.disableImages = true
let b = newBrowser(cfg)
b.typeText("search keyword", humanLike = true)
discard b.eval("document.body.innerHTML += '...'")
b.refreshDocument() # 必须!否则 find 找不到新元素
let el = b.find("#new")
var cfg = defaultConfig()
cfg.timeout = 60000 # 慢速网站增加超时
# 或针对单个操作设置
b.waitForSelector("#slow-element", timeout = 60000)
# 弹窗可能出现的场景
if b.isPresent(".cookie-banner .accept"):
b.find(".cookie-banner .accept").click()
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 相关隐身 |
cfg.headless = false 可以看到浏览器窗口cfg.stealthMode = false 排除隐身脚本干扰b.evalStr("document.title") 检查页面状态b.screenshot("/tmp/debug.png") 查看当前页面b.getUrl() 确认是否跳转到了预期页面| 特性 | nimedge | Puppeteer | Selenium | Playwright |
|---|---|---|---|---|
| 语言 | Nim 2 | JavaScript/TypeScript | 多语言 | 多语言 |
| 第三方依赖 | 零 | puppeteer 包 | selenium + webdriver | playwright 包 |
| 通信协议 | CDP (WebSocket) | CDP (WebSocket) | WebDriver (HTTP) | CDP (WebSocket) |
| 反爬虫隐身 | 内置 10 项 | 需 stealth 插件 | 需额外配置 | 部分内置 |
| 二进制体积 | 极小(~2MB) | 大(~50MB+) | 大 | 大 |
| 编译速度 | 极快(~2s) | N/A(解释执行) | N/A | N/A |
| 运行性能 | 原生速度 | V8 JIT | JVM/V8 | V8 JIT |
| 内存占用 | 极低 | 高 | 高 | 高 |
| 学习曲线 | 简单 | 中等 | 中等 | 中等 |
| 多标签页 | 暂不支持 | 支持 | 支持 | 支持 |
| 多浏览器 | 仅 Edge | Chrome/Firefox | 所有 | 所有 |
零依赖、极小体积、原生性能、内置隐身。特别适合对部署体积和运行性能有要求的场景,如嵌入式设备、CI/CD 管道、服务器端爬虫。
nimedge 的设计理念是零依赖。WebSocket 协议、CDP 通信、JSON 处理全部使用 Nim 标准库从零实现。这意味着:无需安装任何外部依赖,编译即可运行;完全可控的代码,便于调试和定制;更小的二进制体积;展示了 Nim 标准库的强大能力。
支持 Windows、macOS 和 Linux。库会自动查找 Edge 的安装路径。在 Linux 服务器上,库会自动添加 --no-sandbox 和 --disable-dev-shm-usage 参数。
无头模式(headless = true,默认)不显示浏览器窗口,适合服务器环境。有头模式(headless = false)显示浏览器窗口,适合调试和需要可视化交互的场景。有头模式需要桌面环境支持。无头模式下 GPU 加速自动禁用。
可以通过 cfg.timeout 设置全局超时,也可以在 waitForSelector 和 waitForFunction 中指定 timeout 参数。如果超时会抛出 NimEdgeError 异常。也可以使用 navigate(url, waitLoad=false) 跳过等待。
使用 b.isPresent() 检查弹窗是否存在,然后点击关闭按钮。对于 JavaScript alert/confirm 对话框,CDP 会自动处理。
nimedge 的隐身模式可以应对大多数常见检测(navigator.webdriver、plugins、WebGL 等),但不能保证通过所有检测。高级检测可能需要额外的定制。建议配合合理的请求间隔、随机延迟、代理轮换等策略使用。
通过 eval() 修改 DOM 后,CDP 的节点 ID 可能过期。调用 b.refreshDocument() 刷新根节点即可解决。navigate() 和 reload() 内部已自动调用。
当前版本专注于单标签页操作。多标签页需要通过底层 CDP 的 Target.createTarget 和 Target.attachToTarget 实现,可以通过 b.cdp.send() 手动调用。
# nimble 文件
# myproject.nimble
requires "nimedge"
# 或直接引用源码
# nim c --path:../nimedge/src myproject.nim
# 或创建 nim.cfg
# path = "nimedge/src"
欢迎提交 Issue 和 Pull Request。项目结构清晰,每个模块职责明确,便于扩展。
# === 生命周期 ===
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
# === 交互 ===
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
# === 基础 ===
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 未找到