採用 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 就可以執行截圖的動作,速度不但快且也與實際畫面相符。最終是採用這個方案
實作細節
整個流程大致上會是
- 瀏覽目標網頁
- 等候網頁載入完成
- 進行截圖、添加浮水印、保存圖片
操控 Selenium 瀏覽網頁
HeadLess Mode 截圖的應用程式執行的時候,希望是採用 HeadLess 模式運作,也因為後續的截圖,CDP 指令是將瀏覽器的可見範圍進行截圖,所以在 HeadLess 模式下偵測網頁高度,並且重新調整可視範圍的寬高就很重要。
// headless 模式
ChromeOptions options = new ChromeOptions();
options.AddArgument("headless");
options.AddArgument($"--window-size={width}x{height}");
var driver = new ChromeDriver(options);
要使用
HeadLess
模式,可以透過設置ChromeOptions
並經由建構式注入給ChromeDriver
即可
瀏覽網頁
// 瀏覽網頁
driver.Navigate().GoToUrl("https://www.google.com.tw");
等候網頁讀取完成
等候網頁
透過 javaScript
的 document.readyState ==='complete'
來判斷是否讀取完成,在 Selenium 底下需要執行這一段程式碼,直到回傳的結果為 True,表示網頁已經讀取完畢,可以準備截圖了。
WebDriver 有實作介面 IJavaScriptExecutor
,該介面提供ExecuteScript
允許執行javaScript
,再透過WebDriverWait
所提供的方法來實作,如下範例
// 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 的寬高來截圖
取得網頁高度
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 物件設置參數會出錯,但直接給予 width
、height
則是可行的
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
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;
}
幫圖片添加浮水印
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));
}
}
保存圖片
img.Save($"D:\\Temp\\Demo.jpg", ImageFormat.Jpeg);
結論
測試的結果速度很不錯,但這只是一個簡單的概念驗證,實務上很有可能會有很多奇奇怪怪的情況需要處理,使用上要特別注意一下,詳細程式碼放在 Github,有需要請自取