您在等一双运动鞋重新到货,或者等一件已经关注了好几周的商品降价。手动刷页面很快就让人疲惫,而且很容易错过补货的那一天。于是您写了一个小小的价格跟踪器,让它替您检查页面,并在有变化时通知您。单纯的 HttpClient 请求往往不够用,因为在许多商店里,价格是由脚本在页面加载完成后填入的。

本文将构建一个价格和库存监控器,它使用 DotNetBrowser(一个基于 Chromium 的 .NET WebView)来读取这类页面。监控器是一个 .NET Worker Service,它在无头浏览器(即没有窗口的浏览器)中加载每个商品页面,并读取价格和库存状态。它会保存历史记录,并在有变化时向 Discord 频道发送提醒。它可以在控制台中运行,也可以作为 Windows 服务运行,还可以作为 Linux 上的 systemd 服务运行。

我们使用的是 DotNetBrowser 4.3.5 和 .NET 10,并在 Windows 11 和 Ubuntu 24.04 上运行了监控器。

准备工作 

要跟着操作,您需要:

  • .NET 10 SDK。
  • 一个 DotNetBrowser 许可证密钥。评估密钥可免费使用 30 天。
  • 一个可以创建 webhook 的 Discord 服务器(如果您希望在 Discord 中收到提醒)。没有它,监控器会把提醒写入日志。

为什么单纯的 HTTP 请求不够用 

许多商店的商品页面是在浏览器中构建的。服务器只发送一个带有空占位符和一段脚本的 HTML 页面。脚本随后从商店的 API 请求商品数据(通常是 JSON),再填入名称、价格以及“加入购物车”按钮的状态。

HttpClient 只能拿到那个 HTML 页面。下面是示例附带的演示商店的商品区块,也就是服务器发送过来的原始内容:

<h1 id="name"></h1>
<p id="price" class="price"></p>
<p id="stock" class="stock"></p>
<button id="add-to-cart" disabled>Add to cart</button>

演示商店的商品页面:脚本运行前为空,运行后显示价格

脚本运行前后的页面。

要看到价格,就得有东西来运行这段脚本,而这正是浏览器引擎的工作。DotNetBrowser 运行的是 Chromium,因此页面的加载方式与在 Chrome 中完全相同,包括脚本和后台请求。

不过,请先检查页面源代码。如果价格已经在服务器发送的 HTML 中,例如在一个 <script type="application/ld+json"> 块里,那么 HttpClient 是更轻量的工具,您并不需要浏览器。

DOM 还是 JSON:读取价格的两种方式 

页面一旦在浏览器中运行,您就可以从两个地方读取价格:渲染后的 DOM,或者页面从商店 API 加载的 JSON 响应:

DOMJSON 响应
读取的内容页面上的文本,例如 $150.00数据本身,例如 "price": 150.00
如何找到它CSS 选择器,来自页面的 HTML请求 URL,来自开发者工具的“网络”标签页
何时失效页面布局改变时API 改变时
数据何时就绪加载完成后的某个时刻:您必须等待它响应到达时
额外数据只有页面显示的内容往往比页面显示的更多,例如每个尺码的库存

在通过浏览器捕获 JSON 之前,先用单纯的 HttpClient 请求试一试商店的 API。如果直接请求 API 就能拿到数据,就直接调用它,跳过浏览器。有些 API 只响应由页面构造的请求,比如带会话 Cookie 或带脚本计算出的参数的请求。如果您捕获页面加载的响应,就不必自己重新生成这些 Cookie 和参数。

我们的示例以两种方式读取价格:一个商品从 DOM 读取,另一个从 JSON 读取,方便您比较。

示例:带演示商店的 Worker Service 

完整示例位于 GitHub 仓库的 csharp/price-monitor 文件夹中。克隆它以便跟着操作:

git clone https://github.com/TeamDev-IP/DotNetBrowser-Examples.git

我们使用以下命令创建了它:

dotnet new worker -o PriceMonitor
cd PriceMonitor
dotnet add package DotNetBrowser.CrossPlatform
dotnet add package Microsoft.Extensions.Hosting.Systemd
dotnet add package Microsoft.Extensions.Hosting.WindowsServices

DotNetBrowser.CrossPlatform 包含类库以及面向 Windows、Linux 和 macOS 的 Chromium 二进制文件。不需要单独下载浏览器,没有驱动进程,也没有 Node.js 运行时。两个 Hosting 包让同一个构建既能作为 Windows 服务运行,也能作为 systemd 服务运行。

我们不希望示例依赖某个真实的商店,所以它用应用内部的文件在 https://shop.example/ 提供一个演示运动鞋商店。价格和库存每 90 秒变化一次,因此几分钟内您就能收到提醒。

要监视的商品位于 appsettings.json 的 Monitor:Products 数组中。每个商品都说明了它的数据来自哪里:

{
  "Name": "Stride Runner \"Volt\"",
  "Url": "https://shop.example/products/stride-runner-volt",
  "Source": "Dom",
  "PriceSelector": "#price",
  "CartButtonSelector": "#add-to-cart"
},
{
  "Name": "Court Classic Low",
  "Url": "https://shop.example/products/court-classic-low",
  "Source": "Json",
  "ApiUrl": "https://shop.example/api/products/court-classic-low"
}

在没有窗口的情况下启动 Chromium 

在 DotNetBrowser 中,Engine 是一个正在运行的 Chromium 实例,拥有自己的进程。一个 Engine 承载多个 Browser,就像 Chrome 承载多个标签页一样。示例的 BrowserEngineHost 在监控器启动时创建一个 Engine,并在监控器停止时处置它:

EngineOptions.Builder builder = new EngineOptions.Builder
{
    // No window: render into memory.
    RenderingMode = RenderingMode.OffScreen,
    // Keep everything Chromium writes in the data folder. The
    // crash dumps default to the home folder, which a service
    // account may not have.
    ChromiumDirectory = Path.Combine(options.DataDirectory, "Chromium"),
    UserDataDirectory = Path.Combine(options.DataDirectory, "UserData"),
    CrashDumpDirectory = Path.Combine(options.DataDirectory, "CrashDumps")
};

IEngine created = await EngineFactory.CreateAsync(builder.Build());

默认情况下,数据文件夹是可执行文件旁边的 Data。每次检查时,监控器都会创建一个 Browser,读取一个页面,然后处置该 Browser,这样一个页面的脚本就不会在下一次检查期间仍在运行。

从 DOM 读取价格 

示例的 ProductReader.ReadFromDomAsync() 使用 LoadAsync() 加载页面,这是一个小的包装方法,它调用 browser.Navigation.LoadUrl(),并在加载失败时抛出异常。LoadUrl() 在 HTML 加载完成时返回,此时脚本还没有渲染出价格,因此 ReadFromDomAsync() 会轮询价格元素,直到它有文本为止:

await LoadAsync(browser, product.Url, timeout);

// The load is finished when the HTML is, but the price comes
// later, from a script. Poll until the element has text.
DateTime deadline = DateTime.UtcNow + timeout;
while (true)
{
    IDocument document = browser.MainFrame.Document;
    IElement price =
        document.GetElementByCssSelector(product.PriceSelector);
    string text = price?.InnerText.Trim();
    if (!string.IsNullOrEmpty(text))
    {
        IElement button = document.GetElementByCssSelector(
            product.CartButtonSelector)
            ?? throw new InvalidOperationException(
                $"No element matches {product.CartButtonSelector}.");
        bool inStock = !button.Attributes.ContainsKey("disabled");
        return new ProductSnapshot
        {
            // ...
            Price = ParsePrice(text),
            InStock = inStock
        };
    }
    if (DateTime.UtcNow > deadline)
    {
        throw new TimeoutException(
            $"No text in {product.PriceSelector} after {timeout}.");
    }
    await Task.Delay(PollInterval, cancellationToken);
}

库存状态来自“加入购物车”(Add to cart)按钮:商品售罄时,商店会禁用它。这是一种常见的模式,而且比查找“已售罄”字样更可靠,因为那些字样会随页面语言而变化。

有关查找和读取元素的其他方式,请参阅 DOM 指南。

捕获页面加载的 JSON 

ProductReader.ReadFromJsonAsync() 不从页面本身读取任何内容。它的辅助方法 CaptureResponseAsync() 监听 Browser 所属 Profile(配置文件)上的两个网络事件:

  • ResponseBytesReceived 分块传递响应的正文,数据一到就传。
  • RequestCompleted 表示一个请求已经完成,并带有其状态和 HTTP 响应代码。

这些事件会针对 Profile 中的每个请求触发。示例的所有 Browser 共享 Engine 的默认 Profile,而且页面上来自其他站点的脚本也会发出请求。因此 CaptureResponseAsync() 用 IsApiRequest() 检查来过滤它们:请求必须发往 API 地址,并且来自加载该页面的 Browser。

站点还可能安装 service worker,这是一种替站点自行发出部分请求的脚本。这些请求可能不属于任何 Browser(它们的 Browser 为 null),因此 IsApiRequest() 也接受没有 Browser 的 API 请求。请求一旦通过检查,CaptureResponseAsync() 就会收集正文的各个分块:

void OnBytes(object sender, ResponseBytesReceivedEventArgs e)
{
    if (!IsApiRequest(e.UrlRequest))
    {
        return;
    }
    lock (body)
    {
        // ...
        body.Write(e.Data, 0, e.Data.Length);
    }
}

void OnCompleted(object sender, RequestCompletedEventArgs e)
{
    if (!IsApiRequest(e.UrlRequest))
    {
        return;
    }
    if (e.Status == RequestStatus.Success && e.ResponseCode == 200)
    {
        lock (body)
        {
            result.TrySetResult(Encoding.UTF8.GetString(body.ToArray()));
        }
    }
    // ...
}

network.ResponseBytesReceived += OnBytes;
network.RequestCompleted += OnCompleted;

CaptureResponseAsync() 在加载页面之前就完成订阅,因此不会错过请求。到达的分块已经解压缩,所以拼接后的正文就是普通的 JSON。有关更多网络事件,请参阅网络指南。

检测变化并保存历史记录 

MonitorWorker 是运行检查的类,它将每次读数与上次保存的读数进行比较。如果价格或库存发生了变化,它会发送提醒,然后才保存新读数;这样一来,如果提醒发送失败,下一次检查会再次发现这个变化并重试提醒。

PriceHistory 将每条保存的读数追加到数据文件夹中的 history.jsonl,每行一个 JSON 对象。该文件保存着所有商品的价格历史,而且监控器启动时会把它读回来,所以重启后不会再次报告同一个变化。对于少量商品,一个文件就足够了;商品很多、历史很长时,可以把 PriceHistory 改为写入 SQLite。

向 Discord 发送提醒 

Discord webhook 是一个 URL,它接受发往某个频道的消息。您不需要机器人,也不需要库:一条提醒就是一次带 JSON 正文的 HTTPS POST。要创建 webhook,请在 Discord 中打开服务器设置 > 整合 > Webhook,点击新 Webhook(New Webhook),选择频道,然后复制 webhook URL。

DiscordNotifier 将每条提醒作为一张卡片(Discord 称之为 embed)发送,并附上商品链接。当您可以采取行动时,卡片是绿色的:商品重新到货,或者降价了且有货。否则是红色的。

Slack、Telegram 的 Bot API 和 Microsoft Teams(通过 Workflows webhook)接受类似的 POST,只是 JSON 正文不同。

任何拥有 webhook URL 的人都可以向您的频道发消息,所以请不要把它放进代码或 appsettings.json。监控器从 Discord:WebhookUrl 设置中读取它。

安排检查的时间 

MonitorWorker 是一个 BackgroundService,即 .NET 中长时间运行循环的基类。它逐个检查商品,页面之间暂停一段时间(appsettings.json 中的 PageDelaySeconds),然后等待下一轮(IntervalSeconds)。

页面可能加载失败、超时,或者返回时没有数据。worker 对每个页面最多尝试三次(Attempts),每次的暂停时间都更长一些。如果三次都失败,它会跳过该商品,直到下一轮。如果 Chromium 自身退出,BrowserEngineHost 会在下一个页面之前创建一个新的 Engine。

监控器停止时(控制台中的 Ctrl+C、Stop-Service 或 systemctl stop),宿主会取消 worker 的停止令牌。worker 随后关闭当前检查所用的 Browser,这同时会中止正在进行的页面加载。

在控制台中运行监控器 

将您的许可证密钥放入 DotNetBrowser-Examples 仓库根文件夹中的 dotnetbrowser.license 文件,构建时会将其复制到可执行文件旁边。在 Windows 上,请使用一个不是以管理员身份运行的终端:默认情况下,以管理员权限启动时 Chromium 不会启动。如果您需要管理员权限,请参阅故障排除指南。

在控制台中运行时,请把 webhook URL 保存在用户机密(user secrets)中。在 csharp/price-monitor/PriceMonitor 文件夹中运行:

dotnet user-secrets set "Discord:WebhookUrl" "<your webhook URL>"
dotnet run

第一轮会记录每个商品的当前价格。之后,监控器每 60 秒检查一次商店,并在有变化时向 Discord 发送提醒:

监控器的日志和 Discord 提醒(加速播放)。

要监视真实的商品,请将 Monitor:MockShop:Enabled 设为 false,并把 appsettings.json 中的商品替换为您的页面。README 说明了如何填写选择器、查找 API 地址以及匹配 JSON 字段名。

作为 Windows 服务运行监控器 

监控器是一个普通的 Worker Service,因此它作为 Windows 服务安装的方式与我们在 Windows 服务中运行 Chromium一文中的服务相同。那篇文章详细解释了帐户、文件夹和权限。本示例的 README 包含相同的步骤,外加一步:由管理员将 webhook URL 保存到数据文件夹中的 secrets.json。只有管理员、SYSTEM 和服务帐户能读取该文件夹。

在 Linux 上作为 systemd 服务运行监控器 

Linux 上的 DotNetBrowser 需要 X 服务器(管理显示的程序),即使它在没有窗口的情况下渲染也是如此。服务器通常没有显示,因此无头 Linux 指南使用 Xvfb(一个虚拟 X 服务器),并通过 xvfb-run 启动它。

对于 systemd 服务,请把 Xvfb 作为单独的服务运行,而不是在监控器的服务内部通过 xvfb-run 运行。使用 xvfb-run 时,systemctl stop 会同时停止 Xvfb 和监控器。Engine 没有显示就无法关闭,于是监控器会一直挂起,直到 systemd 默认的 90 秒停止超时。然后 systemd 杀掉它,而 Chromium 进程则继续运行。

将虚拟显示的单元文件保存为 /etc/systemd/system/price-monitor-xvfb.service:

[Unit]
Description=Virtual display for the price monitor

[Service]
User=price-monitor
PrivateTmp=yes
ExecStart=/usr/bin/Xvfb :99 -screen 0 1280x800x24 -nolisten tcp -nolisten local
Restart=always

然后将监控器的单元文件保存为 /etc/systemd/system/price-monitor.service。它在显示之后启动并使用该显示:

[Unit]
Description=Price monitor
Requires=price-monitor-xvfb.service
After=price-monitor-xvfb.service network-online.target
Wants=network-online.target
JoinsNamespaceOf=price-monitor-xvfb.service

[Service]
Type=notify
User=price-monitor
PrivateTmp=yes
Environment=DISPLAY=:99
Environment=Monitor__DataDirectory=/var/lib/price-monitor
EnvironmentFile=/etc/price-monitor.env
ExecStart=/usr/bin/dotnet /opt/price-monitor/PriceMonitor.dll
StateDirectory=price-monitor
StateDirectoryMode=0700
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

有三个细节最为重要:

  • User=price-monitor。 不要以 root 运行监控器。启用沙箱时 Chromium 不会以 root 启动,而沙箱正是保护服务器免受监控器所加载页面侵害的机制。
  • PrivateTmp、JoinsNamespaceOf 和 -nolisten local。 Xvfb 没有密码,因此任何能访问其套接字的人都能使用该显示。PrivateTmp 和 JoinsNamespaceOf 把它的套接字文件放在一个只有这两个服务共享的私有 /tmp 中。-nolisten local 关闭它的第二个套接字,即抽象套接字;它不是文件,所以 PrivateTmp 覆盖不到它。这样服务器上的其他用户就无法连接。
  • EnvironmentFile。 webhook URL 位于 /etc/price-monitor.env 中,内容是一行 Discord__WebhookUrl=<URL>。只有 root 能读取该文件,systemd 在启动监控器之前读取它。监控器随后从自己的环境中移除该变量,这样运行页面的 Chromium 进程就不会继承它。

README 列出了要安装的软件包、从发布到移除服务的每条命令,以及 Engine 在 Ubuntu 上无法启动时该怎么办。

限制与礼节 

价格监控器是一种网页抓取(web scraping)。它会一遍又一遍地访问相同的页面,所以请让这些访问保持礼貌:

  • 先查看条款。 阅读站点的使用条款及其 robots.txt。如果商店提供官方 API、商品数据源(feed)或“到货通知”邮件,请改用它们。
  • 放慢轮询。 对于价格来说,每隔几分钟检查一次就足够了,而页面之间的暂停可以防止监控器一次性加载整个目录。appsettings.json 中的 60 秒间隔是为演示商店设置的。
  • 做好遇到机器人防护的准备。 许多商店,尤其是运动鞋商店,使用机器人管理服务,这些服务可能会拦截任何自动化浏览器,包括 DotNetBrowser。本示例不会尝试绕过这种防护。

监控器只读取公开的价格和库存。它不登录、不加入购物车,也不结账。

总结 

最终结果是一个用于价格监控的后台作业:它从 DOM 或页面加载的 JSON 中读取价格和库存,在它们发生变化时通过 Discord 提醒您,并保存历史记录。它会重试失败的页面,能干净地停止,并且无论在控制台中还是作为 Windows 和 Linux 上的服务,运行方式都相同。

完整源代码可在示例仓库中获取。要用您自己的页面试一试,请获取免费评估许可证:

Spinner

发送中。。。

抱歉,发送中断

请再次尝试,如果问题仍然存在,请联系我们 info@teamdev.com.

阅读并同意条款以继续。

您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。