const puppeteer = require('puppeteer'); const { createCanvas, loadImage } = require('canvas') const mustache = require('mustache') var fs = require('fs'); const fsExtra = require('fs-extra') let MAX_URLS = 50; let TEMPLATE = fs.readFileSync('template.html', 'utf8'); // using googlebot user agent might get rid of some cookie alerts //const agent = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z‡ Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; const Good3G = { 'offline': false, 'downloadThroughput': 1.5 * 1024 * 1024 / 8, 'uploadThroughput': 750 * 1024 / 8, 'latency': 40 }; const phone = puppeteer.devices['Nexus 5X']; function injectJs() { window.shifts = []; function didSizeChange(src) { if (src.previousRect.width !== src.currentRect.width) return true; if (src.previousRect.height !== src.currentRect.height) return true; return false; } function getLastResources(startTime, endTime, regex) { let results = []; let entries = performance.getEntriesByType('resource') for(let i = 0; i < entries.length; i++) { let e = entries[i]; if(regex && !e.name.match(regex)) continue; if(e.responseEnd < endTime && e.responseEnd > startTime) results.push(e); } return results; } let po = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { //console.log(entry); let val = entry.value; //let lastRes = getResources(entry.startTime - 150, entry.startTime); let lastFonts = getLastResources(entry.startTime-150, entry.startTime, /(woff)|(ttf)/); let lastAds = getLastResources(entry.startTime-400, entry.startTime, /\/ads\?/); let lastImgs = getLastResources(entry.startTime-150, entry.startTime, /\.(jpg)|(png)|(gif)|(svg)|(jpeg)|(webp)/); // if we have a text node which changed size, and a font loaded, then attribute the layout jump to the font // not completely true, could also be due to CSS if(lastFonts.length > 0 ) { console.log("Probing for fonts:"); console.log(lastFonts); console.log(entry); let last = lastFonts[lastFonts.length-1]; let diff = entry.startTime - last.responseEnd; //is there a source which is just an icon or text node? for(var i = 0; i < entry.sources.length; i++) { let src = entry.sources[i]; if(!didSizeChange(src)) continue; if(src.node.nodeType === Node.TEXT_NODE || src.node.tagName === "P" || src.node.tagName === "I" || src.node.tagName === "A" ) { window.shifts.push({url: document.location.href, cause: "FONT", ressource: last.name, impact: val, timegap: diff}); console.log(last.name + " was loaded " + diff + "ms before a layout shift with impact " + val); } } } // if an image was loaded, and the size of an image node with same src changed, attribute shift to the image if(lastImgs.length > 0 ) { console.log("Probing for images:"); console.log(lastImgs); console.log(entry); //is there a source which is just an image node? for(var i = 0; i < entry.sources.length; i++) { let src = entry.sources[i]; if(!didSizeChange(src)) continue; console.log("Probing for tagname:"); if(src.node.tagName === "IMG" || src.node.tagName === "PICTURE"|| src.node.tagName === "DIV") { // find the relevant image let last = null; for(var j = 0; j < lastImgs.length; j++) { let filename = new URL(lastImgs[j].name).pathname.split('/').pop(); console.log("Probing for filename: " + filename); if(src.node.outerHTML.indexOf(filename) >= 0) { last = lastImgs[j]; break; } } if(!last) continue; let diff = entry.startTime - last.responseEnd; window.shifts.push({url: document.location.href, cause: "IMAGE", ressource: last.name, impact: val, timegap: diff}); console.log(last.name + " was loaded " + diff + "ms before a layout shift with impact " + val); } } } // if an ad was loaded, and the size of a div containing an ad changed, attribute shift to the image if(lastAds.length > 0 ) { console.log("Probing for ads:"); console.log(lastAds); console.log(entry); let last = lastAds[lastAds.length-1]; let diff = entry.startTime - last.responseEnd; //is there a source which might contain an ad? for(var i = 0; i < entry.sources.length; i++) { let src = entry.sources[i]; if(!didSizeChange(src)) continue; console.log("Probing tagname"); if(src.node.tagName === "DIV" || src.node.tagName === "SPAN" || src.node.tagName === "IFRAME"|| src.node.tagName === "SECTION") { console.log("Probing content"); if(src.node.outerHTML.indexOf("google_ads_iframe") >= 0) { window.shifts.push({url: document.location.href, cause: "ADS", ressource: last.name, impact: val, timegap: diff}); console.log(last.name + " was loaded " + diff + "ms before a layout shift with impact " + val); } } } } } }); po.observe({type: 'layout-shift', buffered: true}); } async function doBatch(urls, max) { // reset output file and images dir fsExtra.emptyDirSync("output"); fs.mkdirSync("output/images"); //json results object to write towards mustache at the end let results = []; const browser = await puppeteer.launch({ args: ['--no-sandbox'], //headless: false, //executablePath: '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', timeout: 10000 }); const page = await browser.newPage(); //await page.evaluateOnNewDocument(injectJs); //phone.userAgent = agent; await page.emulate(phone); const client = await page.target().createCDPSession(); await client.send('Network.enable'); await client.send('ServiceWorker.enable'); await client.send('Network.emulateNetworkConditions', Good3G); //await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); for(var k = 0; k < Math.min(max, urls.length); k++) { const url = urls[k]; console.log("Processing: " + url); try { // inject a function with the code from https://web.dev/cls/#measure-cls-in-javascript await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000}); //page.on('console', consoleObj => console.log(consoleObj.text())); await page.waitFor(2000); // let's give it a bit more time, to be sure everything's loaded console.log("Injecting JS..."); await Promise.race([ page.evaluate(injectJs), page.waitFor(5000) ]); page.waitFor(2000); console.log("Gathering data..."); let url_results = await Promise.race([ page.evaluate(function() {return window.shifts}), page.waitFor(5000) ]); if(!url_results) { console.log("Couldn't retrieve results."); continue; } results = results.concat(url_results); } catch (error) { console.log(error); //process.exit(0); } } // write out result html results.sort((a, b) => (a.impact > b.impact) ? -1 : 1) var rendered = mustache.render(TEMPLATE, {items: results}); fs.writeFileSync('output/index.html', rendered) } let urls = fs.readFileSync('input.csv').toString().split("\n"); doBatch(urls, 200).then(res => {console.log("Done!");process.exit(0);});