Browsere og høns har det til fælles, at de kan være ganske aktive selv om de får hovedet kappet af. En headless browser er en browser uden brugerflade – eller om man vil – en bil uden karosseri. Motoren kører glimrende, men der er ikke meget at se på. Med den hovedløse browser kan man i princippet alt hvad man kan via brugerfladen, det foregår blot via programmering. Det kan være nyttigt i automatisering af tests af webapplikationer m.v. Og det kan også – som jeg vil koncentrere mig om i dette indlæg – være en nyttig ting ved scraping af dynamiske websider.
Hvad er bedre til en konkret eksemplificering af problemstillingen end Statens Arkiver? Denne hæderkronede institution. Følgende url leder til en ganske tilfældig af de millionvis af digitaliserede sider fra kirkebøger, folketællinger m.v:
https://www.sa.dk/ao-soegesider/da/billedviser?epid=21618400#389236,74205804
Er jeg nu – af kryptiske grunde – i den situation, at jeg ønske ikke blot at downloade siden, men også få hentet noget meta-data om hvad den indeholder, så vil man måske af gammel refleks ty til HtmlAgilityPack og lave noget i retning af
var url ="https://www.sa.dk/ao-soegesider/da/billedviser?epid=21618400#389236,74205804"; var web = new HtmlWeb(); var doc = web.Load(url); var htmlBody = doc.DocumentNode.SelectSingleNode("//body"); HtmlNodeCollection childNodes = htmlBody.ChildNodes; foreach (var node in childNodes) { if (node.NodeType == HtmlNodeType.Element) { Console.WriteLine(node.OuterHtml); } }
Det giver i dette tilfælde – og i mange andre tilfælde – en noget kedelig oplevelse, fordi den statiske side, som jeg henter. ikke indeholder blot antydningen af de data, som jeg skal bruge. De dannes dynamisk og er først tilgængelige, når javascript på siden er afviklet. Det er i sådan en situation, at man tænker: “Hvor er det godt at have en hovedløs browser”!
Puppeteer Sharp
Puppeteer Sharp er en .Net port til den officielle Node JS Puppeteer API og vil give alt, hvad der er brug for. Den downloader sin egen lokale, skrabede version af Chrome, så jeg ikke ender op med at skidtet ikke længere virker, for jeg har opdateret til en ny version af Chrome browseren på min computer. Der er også andre veje til en hovedløs browser, f.eks. Selenium med ChromeDriver eller FirefoxDriver, men til mange opgaver er Puppeteer hurtigere og kan køre async ud-af-boksen.
Installer Nuget Package PuppeteerSharp:

Eller direkte:
PM> Install-Package PuppeteerSharp -Version 2.0.4
Vi kan nu med enkle midler hente siden efter at javescvript er afvikler, og altså gerne skulle indeholde noget mere data. Det kan komme an på en hurtig prøve:
public static async Task<string> GetContent(string url) { await new BrowserFetcher().DownloadAsync(BrowserFetcher.DefaultRevision); var browser = await Puppeteer.LaunchAsync(new LaunchOptions() { Headless = true }); var page = await browser.NewPageAsync(); await page.GoToAsync(url); return await page.GetContentAsync(); } private static void Main(string[] args) { string url = "https://www.sa.dk/ao-soegesider/da/billedviser?epid=21618400#389236,74205804"; Console.WriteLine($"Længde på statisk html: {new WebClient().DownloadString(url).Length} bytes"); Console.WriteLine($"Længde på dynamisk html: {GetContent(url).GetAwaiter().GetResult().Length} bytes"); } // OUTPUT: // Længde statisk html: 7561 bytes // Længde dynamisk html: 38564 bytes
Dokumentlængden er nu 5 gange så stor, så mon ikke der er bid? Puppeteer har nogle indbyggede funktioner til at trevle dokumentet igennem:
var node = page.WaitForXPathAsync(xPath);
Funktionen tager som parameter en XPath. Synes man den syntaks er træls at arbejde med, så er der hjælp at hente i Developer Tools. Højreklik på den ønskede node i dokumentet og vælg Copy -> Copy Xpath:

Når vi har fået den ønskede node, kan vi trække de elementer ud af den, som vi er interesseret i:
node.EvaluateFunctionAsync(string script);
hvor script f.eks. kan være “e => e.innerText”, hvis det er innerText der skal bruges. Eller “e => e.getAttribute(‘href’)” hvis det er værdien af en attribut der ønskes.
Det kan pakkes sammen i en nifty og nem FindNodeAsync-funktion:
public static async Task<string> FindAsync(Page p, string xPath, string script, int timeOut = 1000) { try { var _node = p.WaitForXPathAsync(xPath, new WaitForSelectorOptions { Timeout = timeOut }); return await _node.EvaluateFunctionAsync<string>(script); } catch (Exception e) { // Cheer up - der er andet i livet end dokument nodes return ""; } }
Det hele kan nu sættes sammen så metadata for en række arkivalie-urls hentes nemt, hurtigt og asykront:
public static Stopwatch stopwatch = new Stopwatch(); public class AOInfo { public string headcontent = ""; public string content = ""; public string creator = ""; public string opslag = ""; public string resultstr = ""; } public static async Task<AOInfo> Extract(Browser browser, string url) { var page = await browser.NewPageAsync(); await page.GoToAsync(url); var info = new AOInfo(); info.opslag = await FindAsync(page, "//*[contains(@class, 'fancytree-active')]", "e => e.innerText"); info.creator = await FindAsync(page, "//*[contains(@id, 'creator')]", "e => e.innerText"); info.headcontent = await FindAsync(page, "//*[contains(@id, 'title')]", "e => e.innerText"); info.content = await FindAsync(page, "//*[contains(@class, 'fancytree-title')]", "e => e.innerText"); info.resultstr = info.creator == "Danmarks Statistik" ? $"{info.headcontent}: {info.content}, opslag {info.opslag}" : $"{info.headcontent} ({info.creator}): {info.content}, opslag {info.opslag}"; Console.WriteLine(info.resultstr); await page.CloseAsync(); return info; } public static async Task<string> FindAsync(Page p, string xPath, string script, int timeOut = 1000) { try { var _node = p.WaitForXPathAsync(xPath, new WaitForSelectorOptions { Timeout = timeOut }); return await _node.EvaluateFunctionAsync<string>(script); } catch (Exception e) { // Cheer up return ""; } } public static async Task Go(string[] urls) { Console.WriteLine("Starter browseren op..."); stopwatch.Start(); int totalUrls = urls.Length; var tasks = new Task[totalUrls]; var browser = await LaunchBrowser(new LaunchOptions() {Headless = true}); Console.WriteLine($"Browser klar efter {stopwatch.ElapsedMilliseconds} ms."); Console.WriteLine(); for (var i = 0; i < totalUrls; i++) { tasks[i]= Extract(browser, urls[i]); } await Task.WhenAll(tasks); await browser.CloseAsync(); Console.WriteLine(); Console.WriteLine($"Færdig efter {stopwatch.ElapsedMilliseconds} ms."); } private static void Main(string[] args) { var urls = new string[] { "https://www.sa.dk/ao-soegesider/da/billedviser?epid=21618433#389529,74223144", "https://www.sa.dk/ao-soegesider/da/billedviser?bsid=119630#119630,17430260", "https://www.sa.dk/ao-soegesider/da/billedviser?bsid=8406#8406,207690", "https://www.sa.dk/ao-soegesider/da/billedviser?epid=17072242#142200,34752377" }; Go(urls).GetAwaiter().GetResult(); }
Det giver de ønskede data:

Download som PDF eller billedfil
Der er andre muligheder med Puppeteer. Skulle livet en dag blive så afsporet, at den eneste tilbageværende interesse er en frisk daglig pdf-kopi af Digitaliseringsstyrelsens hjememside – intet menneskeligt bør være os fremmed – så er der også hjælp at hente med Puppeteer.
public static Stopwatch s = new Stopwatch(); public static async Task CreatePdf(Browser browser, string url) { Console.WriteLine($"Starter download af {url}"); var pdfFilename = $"{new Uri(url).Host}.pdf"; var page = await browser.NewPageAsync(); await page.GoToAsync(url); Console.WriteLine($"Hentet {url} efter {s.ElapsedMilliseconds} ms."); Console.WriteLine($"Starter Konvertering af PDF for {url}"); await page.PdfAsync(pdfFilename); await page.CloseAsync(); Console.WriteLine($"Dannet PDF: {pdfFilename} efter {s.ElapsedMilliseconds} ms."); } public static async Task SaveAsPdf(string[] urls) { Console.WriteLine("Starter browseren op..."); s.Start(); int totalUrls = urls.Length; var tasks = new Task[totalUrls]; await new BrowserFetcher().DownloadAsync(BrowserFetcher.DefaultRevision); var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, DefaultViewport = null }); Console.WriteLine($"Browser klar efter {s.ElapsedMilliseconds} ms."); for (var i = 0; i < totalUrls; i++) { tasks[i]= CreatePdf(browser, urls[i]); } await Task.WhenAll(tasks); await browser.CloseAsync(); Console.WriteLine($"Færdig efter {s.ElapsedMilliseconds} ms."); } private static void Main(string[] args) { var urls = new string[] {"https://google.com", "http://microsoft.com", "http://ft.dk", "http://digst.dk", "http://eb.dk"}; Task.Run(() => SaveAsPdf(urls)); }
Vi får nu siderne hentet ned i fuld størrelse – og altså ikke bare et screendump af, hvad der er synligt i browserviduet.

Et lille hack er indsat for at sikre, at pdf-kopien ikke er hakket op på 3-4 forskellige sider:
var height = await page.EvaluateExpressionAsync<int>("document.body.scrollHeight"); var width = await page.EvaluateExpressionAsync<int>("document.body.scrollWidth"); await page.PdfAsync(pdfFilename, new PdfOptions() { PrintBackground = true, Height = height + "px", Width = width + "px", PageRanges = "1" });
Alt er tilsyneladende som det skal være – men ikke helt. GDPR-helvedet bryder løs:

Sådan en popup kan ødelægge enhver glæde ved den daglige kopi. Der er flere løsninger på problemet, men her kan vi bruge Puppeteers SetCookieAsync funktion. Et kig i Developer Tools viser, at der skal sættes en cookie med det mundrette navn GoBasic_CookieAcceptanceState_digst.dk

Den kan vi så sætte på følgende måde inden siden kaldes:
await page.SetCookieAsync(new CookieParam() {Name = "GoBasic_CookieAcceptanceState_digst.dk", Value = "Accepted", Domain = "digst.dk"});
For feinschmeckere er der også mulighed for at dowloade siden som billedfil. Udskift PDfAsync:
options = new ScreenshotOptions() {Quality = 50, FullPage = true}; await page.ScreenshotAsync(imageFilename,options);
Der er et væld af andre muligheder i Puppeteer. Læs mere på bl.a. https://github.com/hardkoded/puppeteer-sharp