Selenium - FullPage ScreenShot

採用 Selenium + ChromeDriver 實作網頁快照功能,雖然 FirefoxDriver 好像有一個可以截完整頁面的方法可以用,但平常沒用 FireFox 也不想安裝,直接使用 Chrome 的 CDP Command 來處理截圖

大概就是因為之前的排程有時候會出問題,查了一下原因發現有很多雷,因此最後開始 Survey ScreenShot via Selenium 的技術來重新開發這個功能,說實在的這個功能其實不太困難。但是也有一些值得紀錄的地方,查詢了一下大概可以看到有幾種比較常見的解決方案,在這邊我大概嘗試了幾種,並記錄於下

解決方案

WebBrowser

這個解決方案是原版程式,也就是有時候會出問題的那個版本,說實在的這個解決方案其實我沒有考慮深入研究,依據MSDN - WebBrowser 控制項概觀說明,它會採用IE,基本上看到這一句我已經不想用了,其他的問題就不再贅述,總而言之不考慮這個方案

Selenium 3 + Noksa.WebDriver.ScreenshotsExtensions

這個是我一開始嘗試找到的解決方案,最終的成果雖然可以用,但實際上他截圖的概念是模擬使用者捲動 ScrollBar,然後將每一段的畫面拼接起來,最終合併成一個完整的網頁快照,這個方法有很多的弊端,例如畫面捲動的時候,網頁上浮動的元素也會跟著動,最終的快照截圖上面就都是那些浮動元素;此外也因為捲動的關係,快照一個網頁的時間會很久,在快照任務繁重的背景之下,此方案無疑是GG了

nuget:Noksa.WebDriver.ScreenshotsExtensions

html2canvas

這個是透過前端套件將畫面產生圖檔的方式,實際原理就是讓 Selenium 瀏覽網頁後,將 script 注入到網頁上並執行一段呼叫該套件的 javascript,最終將結果存放於全域變數 window 下面,然後再經由 selenium 取得圖片,如此就可以透過後端儲存截圖。而這個套件的缺點也非常明顯,他是基於 Virtual DOM 所產生出來的圖而不是瀏覽器畫面截圖,所以會跟實際上的不一樣,針對快照的需求來說,這屬於不可以接受的解決方案

github:html2canvas

Selenium 4 + Chrome DevTools Protocol

在搜尋快照的時候,發現了 Chrome DevTool 實際上也可以做截圖。經由 Selenium WebDriver 去呼叫 CDP Command 就可以執行截圖的動作,速度不但快且也與實際畫面相符。最終是採用這個方案

實作細節

整個流程大致上會是

  1. 瀏覽目標網頁
  2. 等候網頁載入完成
  3. 進行截圖、添加浮水印、保存圖片

操控 Selenium 瀏覽網頁

HeadLess Mode
截圖的應用程式執行的時候,希望是採用 HeadLess 模式運作,也因為後續的截圖,CDP 指令是將瀏覽器的可見範圍進行截圖,所以在 HeadLess 模式下偵測網頁高度,並且重新調整可視範圍的寬高就很重要。

1
2
3
4
5
// headless 模式
ChromeOptions options = new ChromeOptions();
options.AddArgument("headless");
options.AddArgument($"--window-size={width}x{height}");
var driver = new ChromeDriver(options);

要使用 HeadLess 模式,可以透過設置 ChromeOptions並經由建構式注入給 ChromeDriver即可

瀏覽網頁

1
2
// 瀏覽網頁
driver.Navigate().GoToUrl("https://www.google.com.tw");

等候網頁讀取完成

等候網頁

透過 javaScriptdocument.readyState ==='complete'來判斷是否讀取完成,在 Selenium 底下需要執行這一段程式碼,直到回傳的結果為 True,表示網頁已經讀取完畢,可以準備截圖了。

WebDriver 有實作介面 IJavaScriptExecutor,該介面提供ExecuteScript允許執行javaScript,再透過WebDriverWait所提供的方法來實作,如下範例

1
2
3
4
5
6
// ChromeDriver : ChromiumDriver : WebDriver : IJavaScriptExecutor
IJavaScriptExecutor js = driver;
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(30));
wait.IgnoreExceptionTypes(typeof(InvalidOperationException));
// wait default 500ms
wait.Until(wd => (bool)js.ExecuteScript("return document.readyState === 'complete'"));

在完成讀取頁面後,一樣是透過javaScript回傳網頁高度,準備等等設置 Selenium 的寬高來截圖

取得網頁高度

1
2
3
4
5
var docHeight = driver.ExecuteScript("return Math.max(window.innerHeight,document.body.scrollHeight,document.documentElement.scrollHeight)").ToString();
int.TryParse(docHeight, out height);

// 重新指定網頁寬高
driver.Manage().Window.Size = new Size(width, height);

透過 CDP Command 將瀏覽器可視範圍進行截圖

使用 CDP 指令截圖
利用Page.captureScreenshot這個指令做截圖,回傳的結果是圖片的 base64 編碼字串,文件可以參考這邊,在這邊需要給予設定的參數,但是實際上,若照著文件上的 clip這個 ViewPort 物件設置參數會出錯,但直接給予 widthheight則是可行的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var screenshot = driver.ExecuteCdpCommand("Page.captureScreenshot", new Dictionary<string, object>()
{
// Image compression format (defaults to png).Allowed Values: jpeg, png, webp
{ "format", "jpeg" },
// Compression quality from range [0..100] (jpeg only).
{ "quality", 70 },
// Capture the screenshot beyond the viewport. Defaults to false
{ "captureBeyondViewport", true },
// Capture the screenshot from the surface, rather than the view. Defaults to true.
{ "fromSurface", true },
{ "width", width },
{ "height", height },
});
var base64Str = ((Dictionary<string, object>)screenshot)["data"].ToString();

將圖片 base64 轉為 Image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var img = Base64StringToImage(base64Str);

private static Bitmap Base64StringToImage(string base64String)
{
byte[] buffer = Convert.FromBase64String(base64String);

MemoryStream stream = null;
Bitmap bitmap;
var data = (byte[])buffer.Clone();
try
{
stream = new MemoryStream(data);
stream.Position = 0;
bitmap = new Bitmap(Image.FromStream(stream));
}
finally
{
if (stream != null)
{
stream.Close();
stream.Dispose();
}
}

return bitmap;
}

幫圖片添加浮水印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static void ApplyWaterMark(Image bmp)
{
var font = new Font("arial", 16, FontStyle.Bold);
int x = 0;
int y = 0;

using (Graphics graphics = Graphics.FromImage(bmp))
{
var printStr = new StringBuilder();
printStr.AppendLine($"TIME: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
printStr.AppendLine("Order: This is Fake Order Number");
printStr.AppendLine("Product: I am Fake Product Code");

SizeF measureStr = graphics.MeasureString(printStr.ToString(), font);
graphics.FillRectangle(Brushes.Black, new Rectangle(x, y, (int)measureStr.Width, (int)measureStr.Height));
graphics.DrawString(printStr.ToString(), font, Brushes.White, new PointF(x, y));
}
}

保存圖片

1
img.Save($"D:\\Temp\\Demo.jpg", ImageFormat.Jpeg);

結論

測試的結果速度很不錯,但這只是一個簡單的概念驗證,實務上很有可能會有很多奇奇怪怪的情況需要處理,使用上要特別注意一下,詳細程式碼放在 Github,有需要請自取