截取网页截图看似是一项简单的任务,但当你真正动手去做的时候,就会发现事情远没有那么容易。我在尝试截取一篇很长的 Reddit 帖子时就深有体会。一开始我以为只要调用 browser.TakeImage() 就万事大吉,结果却陷入了浏览器视口、动态内容加载、内存占用等一系列问题。

在这篇文章中,我将分享从一开始的“想当然”到最后找到高效方案的全过程。我们将以 r/dotnet 子版块为案例,探索常见的陷阱以及如何避免这些问题。看完这篇文章后,你将掌握如何稳定、完整地截取包含动态内容与无限滚动的网页截图的方法。

基础截图方式 

我们先从最基础的方法开始:不进行任何特殊处理,直接截取截图。在 DotNetBrowser 中,这非常简单:

var image = browser.TakeImage();
var bitmap = ToBitmap(image);
bitmap.Save("screenshot.png", ImageFormat.Png);

由于 DotNetBrowser 返回的是原始位图,我们需要一个工具方法将其转换为 System.Drawing.Bitmap ,以便进行标准的 .NET 操作:

public static Bitmap ToBitmap(DotNetBrowser.Ui.Bitmap bitmap)
{
    var width = (int)bitmap.Size.Width;
    var height = (int)bitmap.Size.Height;

    var data = bitmap.Pixels.ToArray();
    var bmp = new Bitmap(width, height, PixelFormat.Format32bppRgb);

    var bmpData = 
         bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height),
                      ImageLockMode.WriteOnly, bmp.PixelFormat);

    Marshal.Copy(data, 0, bmpData.Scan0, data.Length);
    bmp.UnlockBits(bmpData);
    return bmp;
}

运行这段代码后,我们得到了第一张截图:

使用 DotNetBrowser 制作的简单屏幕截图

通过简单的 TakeImage() 调用所获得的截图。

但问题显而易见:我们只截取了页面的一小部分。

这是因为 TakeImage() 方法仅截取浏览器视口内可见的内容,而默认视口大小并不足以显示整个页面。

截取整页截图 

我们需要先获取网页的尺寸,并将浏览器调整为相应大小,然后重新尝试截图。可以通过 JavaScript 获取页面的尺寸,方法是取 documentdocument.documentElement 中较大的那个值:

var widthScript = @"Math.max(
       document.body.scrollWidth,
       document.documentElement.scrollWidth)";

var heightScript = @"Math.max(
       document.body.scrollHeight,
       document.documentElement.scrollHeight)";

然后调整浏览器大小以匹配这些尺寸:

var frame = browser.MainFrame;
var width = frame.ExecuteJavaScript<double>(widthScript).Result;
var height = frame.ExecuteJavaScript<double>(heightScript).Result;

browser.Size = new Size((uint)width, (uint)height);
var image = browser.TakeImage();

以下是使用这种改进方法得到的结果:

截图区域变大了,但内容尚未加载完成。 查看完整图片

紧接着出现了另一个问题:页面并未完全加载。这是因为我们在调整浏览器大小后立即进行了截图,没有给浏览器足够的时间来加载和渲染所有内容。从页面底部的加载指示器可以看出这一点。

让我们填加一个暂停等待时间:

browser.Size = new Size((uint)width, (uint)height);
// 通过反复试验得出的任意数字。
Thread.Sleep(2000);
var image = browser.TakeImage();

再试一次:

包含所有内容的完整截图

包含所有内容的完整截图。 查看完整图片

终于,我们得到了一张像样的截图!但位图对象变得相当大,而且 Chromium 需要大量资源来渲染大视口,这在实际应用中并不实用。我们可以做得更好。

截取分段截图 

使用之前的方法,浏览器会一次性渲染整个页面,并将结果位图传递给 .NET 进程内存。在处理长页面时,这会迅速耗尽系统资源,尤其是 RAM。

与其尝试一次性截取整个页面,不如我们将其分解为更小、更易管理的部分:

  1. 对页面的各个小段进行多次截图。
  2. 在每次截取之间滚动屏幕。
  3. 将各个部分拼接成最终图像。

以下是实现这种方法的代码:

// Reddit 页面是无限滚动的,因此我们需要设置一个固定的截图次数。
var numberOfShots = 15;
var viewportHeight = 1000;
browser.Size = new Size((uint)Width, (uint)viewportHeight);

var capturedHeight = 0;
for (var count = 0; count < numberOfShots; count++)
{
    capturedHeight += viewportHeight;
    frame.ExecuteJavaScript($"window.scrollTo(0, {capturedHeight})").Wait();
    // 为了等待内容加载而设定的任意暂停时间。
    Thread.Sleep(500);
    var image = browser.TakeImage();
    var bitmap = ToBitmap(image);
    bitmap.Save($"screenshot-{count:D3}.png", ImageFormat.Png);
}

在实际应用中,我们会在另一台服务器上的异步任务中拼接这些片段。但为了简单起见,我们选择直接使用这个辅助函数:

public static Bitmap MergeBitmapsVertically(List<Bitmap> bitmaps)
    var files = Directory.GetFiles("/path/to/directory", "*.png")
                         .OrderBy(Path.GetFileName)
                         .ToArray();
    var images = files.Select(f => Image.FromFile(f)).ToArray();
    int width = images.Max(img => img.Width);
    int totalHeight = images.Sum(img => img.Height);

    using var merged = new Bitmap(width, totalHeight);
    using (var g = Graphics.FromImage(final))
    {
        int y = 0;
        foreach (var img in images)
        {
            g.DrawImage(img, 0, y);
            y += img.Height;
        }
    }
    merged.Save("merged.png");
}

让我们看看结果如何:

合并的带有固定元素的屏幕截图片段

合并后的截图片段包含重复元素。 查看完整图片

又一个新问题出现了:页眉和导航侧边栏在每张分段截图里重复出现,这显然不是截图应有的样子!

处理固定元素 

当我们滚动页面时,像页眉和侧边栏这样的固定元素会保持在原位不动。但在进行分段截图时,我们希望这些固定元素只出现在第一段截图中。

让我们通过 CSS 的 position 属性来找出这些元素,并将它们隐藏起来:

var removeFixedElements = 
    @"(() => {
          document.querySelectorAll('*').forEach(el => {
              const pos = getComputedStyle(el).position;
              if (pos === 'fixed' || pos === 'sticky') {
                  el.style.display = 'none';
              }
          });
      })()";
    
for (var count = 0; count < numberOfShots; count++)
{
    // 仅从第二张截图开始移除固定元素,
    // 这样可以保留第一张截图中的页眉和导航栏。
    if (count == 1) 
    {
        frame.ExecuteJavaScript(removeFixedElements).Wait();
    }
    // 继续进行截图操作。
}

另一个常见的固定元素是 Cookie 同意弹窗。由于每个网站的实现方式都不一样,我们需要逐个页面单独处理。

以 Reddit 为例,我们可以使用以下代码识别并隐藏 Cookie 弹窗:

var removeCookieScreen =
    @"(() => {
      const dialog = document.querySelector('reddit-cookie-banner');
      if (dialog) {
          dialog.style.display = 'none';
      }
    })()";
frame.ExecuteJavaScript(removeCookieScreen).Wait();

最终,我们以节省内存的方式截取了一张合适的网页截图。

合并了固定元素的屏幕截图片段

一张干净、完整的网页截图。 查看完整图片

总结 

最初只是一个简单的 browser.TakeImage() 调用,最后演变成一个需要应对无数细节的复杂解决方案。我们解决了以下挑战:

  • 一次性截取整页内容;
  • 将长页面分段截取并拼接;
  • 处理固定元素和 Cookie 弹窗;
  • 优化内存使用和实用性。

需要说明的是,本文介绍的方法并非放之四海而皆准。每个网页都有其独特之处,而优秀的截图工具之所以强大,正是因为它能处理各种边缘场景。尽管如此,本文所展示的方法已经涵盖了使用 DotNetBrowser 进行网页截图所需掌握的所有核心知识。

Spinner

发送中。。。

抱歉,发送中断

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

阅读并同意条款以继续。

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