Prof. Dr.-Ing. Oliver Radfelder
Informatik / Wirtschaftsinformatik
Hochschule Bremerhaven
Kurz und Knapp: Puppeteer

Loggen Sie sich auf hopper ein. Gehen Sie in Ihr Homeverzeichnis, erzeugen Sie ein Verzeichnis mypuppeteer, wechseln Sie hinein und installieren Sie das puppeteer-Modul.

mkdir mypuppeteer
cd mypuppeteer
npm init -y
npm i puppeteer
Öffnen Sie den Editor vim mit dem Dateinamen first.js und geben Sie den folgenden Code ein:

const puppeteer = require('puppeteer')

async function main (){
  
  const browser = await puppeteer.launch({
    args: [ ],
    headless: "new",
  })
  const page = await browser.newPage()
  await page.goto('https://informatik.hs-bremerhaven.de/docker-oradfelder-web/pd.html',{waitUntil:'networkidle0'})
  await page.screenshot({ path: 'puppeteer.png', fullPage: true });
  await browser.close()
}

main()

Starten Sie dann das Skript mit:

node first.js

Dann sollte ein Screenshot im aktuellen Pfad als png existieren.

Für Varianten in virtuellen Maschinen mit Ubuntu scheint es besser zu sein, den je aktuellen Browser zu installieren und dann mit puppeteer-core zu arbeiten.

Alternative unter Ubuntu auf arm-Architekturen wie MacOS-M1: Unter Ubuntu für arm muss der notwendige Chromium-Browser gesondert installiert werden und statt puppeteer das Paket puppeteer-core genutzt werden. Zudem muss der Pfad zu dem externen Browser in der node-Datei mit angegeben werden. Der Rest bleibt so.

sudo apt install chromium-browser
mkdir mypuppeteer
cd mypuppeteer
npm init -y
npm i puppeteer-core

Mittlerweile ist chromium unter Ubuntu ebenfalls ein snap - das heisst, es könnte sein, dass es unten /snap/bin/chromium sein muss.

const puppeteer = require('puppeteer-core')
async function main (){
  
  const browser = await puppeteer.launch({
    executablePath: '/usr/bin/chromium', // '/snap/bin/chromium' // '/usr/bin/chromium-browser'
    args: [ ],
    // args: ['--no-sandbox'], // im docker - siehe unten
    headless: "new",
  })
  const page = await browser.newPage()
  await page.goto('https://informatik.hs-bremerhaven.de/docker-oradfelder-web/pd.html',{waitUntil:'networkidle0'})
  await page.screenshot({ path: 'puppeteer.png', fullPage: true });
  await browser.close()
}
main()

Sie können auf das Erscheinen eines Elementes warten und Javascript-Code in den Ausführungskontext des Browsers injizieren:

  await page.waitForSelector('#header')
  await page.evaluate(()=>{
    document.querySelector('#header').innerHTML='neu'
  })

Mit setViewport können Sie die Größe des Browserfensters verändern:

  await page.setViewport ({width: 1920,height: 1080});

Mit dem Dollar-Operator verkürzt man die evaluate/querySelector-Schachtelung. Mit der Funktion type können Sie direkt in ein selektiertes Feld tippen - mit keyboard.press drücken Sie einzelne Tasten.

  const input = await page.$("input[name='thename']")
  await input.focus()
  await input.type('MOIN ',{delay:100})

  //vierfach-Klick zum Markieren
  await input.click({ clickCount: 4 })
  //waitForTimeout is deprecated since 
  //await page.waitForTimeout(1000)
  await new Promise(e => setTimeout(e, 1000));
  
  await page.type('input[name=thename]', 'moin', {delay: 100})
  await page.keyboard.press('Enter')

Üblicherweise wird man auch im headless-Modus die Ausgaben auf der Konsole im Browser abfangen wollen. Wieder ist der Kontext unterschiedlich und es muss das jeweilige Ereignis von der einen Konsole abgefangen und weitergeleitet werden:

  page.on('console', msg => {
    console.log("console:"+msg.text())
  });

Einige Sehbeeinträchtigungen lassen sich simulieren: ('blurredVision','achromtopsia','deuteranopia','protanopia', 'tritanopia')

    await page.emulateVisionDeficiency('blurredVision');
    await page.screenshot({ path: 'blurredVision.png' });

Bisweilen ist es notwendig, bestimmten Code beim Laden des Dokumentes, jedoch vor dem Ausführen von Script-Tags auszuführen:

    await page.evaluateOnNewDocument(() => {
      Object.defineProperty(navigator, "languages", {
          get: function() {
              return ["en-US", "en"];
          }
      })
    });

Mit exposeFunction lässt sich eine Funktion nachträglich in den Javascript-Kontext des Browser injizieren:

    let outer='outerValue'
    function outerFunction(){
      console.log("called outerFunction from Browser Context")
    }


    await page.exposeFunction('myFunction', (arg) => {
      console.log(`Called with: ${arg} outer:${outer}`);
      outerFunction();
    });
    await page.evaluate(()=>{ myFunction('myArg');});

Mit Selektoren lässt sich fokussieren, anklicken, etc

    await page.focus('input[name=submitter]')
    await new Promise(e => setTimeout(e, 1000));
    await page.$eval('input[name=submitter]',(e)=>e.click())
    // oder
    const submitter = await page.$('input[name=submitter]')
    await submitter.click()

Nicht selten soll dann doch ein Screencast erzeugt werden. Dann muss der Prozess lokal gestartet werden und der headless-Schalter auf false gesetzt werden. Durch den Schalter --enable-automation wird die Anzeige, dass man sich im Automatisierungsmodus befindet, unterdrückt. Davon wird aus Sicherheitsgründen abgeraten, aber wie immer: wenn man weiß, was man tut ...

  const browser = await puppeteer.launch({
    args: ['--start-fullscreen' ],
    ignoreDefaultArgs: ['--enable-automation'],
    headless: false,
    defaultViewport: null,
  })

Für den Fall, dass wir beispielsweise für WebRTC Kamera und Mikrophon benötigen, lässt sich das wie folgt erreichen:

  const browser = await puppeteer.launch({
    args: [
    '--start-fullscreen', // '--start-maximized',
    '--use-fake-ui-for-media-stream',
    '--use-fake-device-for-media-stream',
    '--use-file-for-fake-audio-capture=/tmp/sound.wav',
    '--use-file-for-fake-video-capture=/tmp/video.y4m',
    '--lang=de',
    ],
    executablePath:'/usr/bin/chromium',
    ignoreDefaultArgs: ["--enable-automation","--mute-audio"],
    headless: false,
    defaultViewport: null,
  })

Einzelne Ereignisse lassen sich abfangen:

  page.on('load', () => console.log('page loaded'));
  page.on('frameattached',() => console.log('attached'));
  page.on('framenavigated', () => console.info('frame navigated'));
  page.on('dialog', async dialog => {
    console.info(`dialog: ${dialog.message()}`);
    await dialog.dismiss();
  });
  page.on('request', request => console.info(`request: ${request.url()}`));
  page.on('requestfinished', request => console.info(`request finished: ${request.url()}`));
  page.on('response', response => console.info(`response: ${response.url()}`));
  page.on('workercreated', worker => console.info(`worker created: ${worker.url()}`));
  page.on('workerdestroyed', worker => console.info(`worker destroyed: ${worker.url()}`));
  page.once('close', () => console.info('page closed'));

Datei-Download ist eher kompliziert, weil es kein Event gibt, das sich abfangen ließe, wenn der Download fertig ist. Die Möglichkeiten gehen von eine spezifische, lange Zeit warten über Dateinamen im Download-Verzeichnis überwachen bis hin zu eigene Listener an Requests hängen. Das ist keine ideale Situation.

const client = await page.target().createCDPSession();
await client.send('Page.setDownloadBehavior', {
  behavior: 'allow', downloadPath: './downloads/'
});
await page.click('#downloadlink')
await new Promise(e => setTimeout(e, 1000));

Der Upload - typischerweise per Ajax - funktioniert hingegen deutlich angenehmer, wenn sich die Seite entsprechend verhält: Wenn der Upload fertig ist, sollte irgendetwas an der Seite sich ändern.

  await page.waitForSelector('input[type=file]');
  const inputUploadHandle = await page.$('input[type=file]');
  await inputUploadHandle.uploadFile('puppeteer.png');
  await page.waitForSelector('#uploader');
  await page.evaluate(() => document.getElementById('uploader').click());
  await page.waitForSelector('#uploadedlink');

Alternativ kann ebenfalls der FileChooser genutzt werden:

  const elements = await Promise.all([
    page.waitForFileChooser(),
    page.click('#ajaxfile'),
  ]);
  await elements[0].accept(['puppeteer.png']);
  let link = await page.$('#uploader')  
  link.click()
  await page.waitForSelector('#uploadedlink');

Einige weitere hilfreiche Funktionen:

  // warte bis der Ausdruck wahr wird
  await page.waitForFunction('counter>3');

  // injiziere Script-Tag vom Server
  await page.addScriptTag({url:"pd-script.js"})

  // ... oder lokal
  await page.addScriptTag({path:"pd-localscript.js"})
  // globale Variablen müssen dort über window. ...
  // gesetzt werden

  // click und warte auf Navigation
  const [response] = await Promise.all([
    page.waitForNavigation(),
    page.click("a.mylink"),
  ]);
  // bzw:
  const elements = await Promise.all([
    page.waitForNavigation(),
    page.click("a.mylink"),
  ]);
  console.log(elements[0])

  // den Inhalt der Seite abholen
  let content=await page.content()

  // cookies lesen
  let cookies=await page.cookies()

  // devices simulieren ('iPhone 6','iPad','iPad landscape','iPhone SE'
  const iPhone = puppeteer.devices['iPhone 6'];
  await page.emulate(iPhone);

  //
  await page.authenticate({username:'Daddel', password:'Du'})

  // throtteling
  const slow3G = puppeteer.networkConditions['Slow 3G'];
  await page.emulateNetworkConditions(slow3G);
  await page.emulateCPUThrottling(2);

  // dark mode
  await page.emulateMediaFeatures([
    { name: 'prefers-color-scheme', value: 'dark' },
  ]);
  await page.emulateMediaFeatures([
    { name: 'prefers-reduced-motion', value: 'reduce' },
  ]);
  await page.emulateMediaFeatures([
    { name: 'color-gamut', value: 'srgb' }
  ]);

  // media-type
  await page.emulateMediaType('print'); // 'screen'

  // Auswahl 
  await page.select('select#liste', 'eins',  'drei')

  // Requests abfangen
  page.setRequestInterception(true);

  // xpath
  await page.waitForXPath("//img")


  // Download an image  
  var response = await page.goto("https://informatik.hs-bremerhaven.de/oradfelder/baum.png");
  fs.writeFile("baum.png", await response.buffer(),
      function (err) { if (err) {return console.log(err);}
  });


Und hier ein kleiner Roundtrip:

const puppeteer = require('puppeteer-core')

async function main (){
  const browser = await puppeteer.launch({
    args: [
      //'--start-fullscreen', 
      //'--start-maximized',
      '--no-sandbox',
      '--use-fake-ui-for-media-stream',
      '--use-fake-device-for-media-stream',
      '--use-file-for-fake-audio-capture=/tmp/sound.wav',
      '--use-file-for-fake-video-capture=/tmp/video.y4m',
      '--lang=de',
    ],
    executablePath:"/usr/bin/chromium",
    ignoreDefaultArgs: ["--enable-automation","--mute-audio"],
    headless: false,
    defaultViewport: null,
    //devtools: true,
  })

  const context = browser.defaultBrowserContext();

  // entferne 1. (leeren) Tab für Screencasts
  const page = await browser.newPage()
  const pages = await browser.pages();
  await pages[0].close();
  await page.setRequestInterception(true);
  page.on('request', interceptedRequest => {
    if (interceptedRequest.isInterceptResolutionHandled()) return;
    interceptedRequest.continue();
  });

  // greife console in Browser Kontext ab
  page.on('console', msg => {
      console.log('console:'+msg.text());
  });

  // greife Ereignisse ab
  page.on('load', () => console.log('page loaded'));
  page.on('frameattached',() => console.log('attached'));
  page.on('framenavigated', () => console.info('frame navigated'));
  page.on('dialog', async dialog => {
    console.info(`dialog: ${dialog.message()}`);
    await dialog.dismiss();
  });
  page.on('request', request => console.info(`request: ${request.url()}`));
  page.on('requestfinished', request => console.info(`request finished: ${request.url()}`));
  page.on('response', response => console.info(`response: ${response.url()}`));
  page.on('workercreated', worker => console.info(`worker created: ${worker.url()}`));
  page.on('workerdestroyed', worker => console.info(`worker destroyed: ${worker.url()}`));
  page.once('close', () => console.info('page closed'));


  // hänge Funktion in den Page-Kontext ein
  function outerFunction(){
    console.log("called outerFunction from Browser Context")
  }

  await page.exposeFunction('myFunction', (arg) => {
    console.log(`Called myFunction with: ${arg},  outer:${outer}`);
    outerFunction()
  });
  let outer='outer';


  // Ausführen vor Auswertung einer Navigation
  await page.evaluateOnNewDocument(type => {
    myFunction('moin');
  });

  await page.evaluateOnNewDocument(() => {
    Object.defineProperty(navigator, "languages", {
        get: function() {
            return ["en-US", "en"];
        }
    })
  });


  await page.goto('https://informatik.hs-bremerhaven.de/oradfelder/pd.html')

  // ein Script nachladen und warten bis ein Ausdruck wahr wird
  await page.evaluate(()=>{window.counter=0})
  await page.addScriptTag({url:"pd-script.js"})
  await page.waitForFunction('counter>3');


  // Dom Element verändern
  await page.waitForSelector('#header')
  await page.evaluate(()=>{
    document.querySelector('#header').innerHTML='neuer Header'
  })


  // eine Mehrfachauswahl durchführen
  await page.select('select#liste', 'eins')
  await new Promise(e => setTimeout(e, 1000));
  await page.select('select#liste', 'eins', 'drei')
  await new Promise(e => setTimeout(e, 1000));


  // ein Textfeld ausfüllen
  const input = await page.$("input[name='thename']")
  await input.focus()
  await input.type("MOIN ",{delay:100})
  await input.click({ clickCount: 4 })
  await new Promise(e => setTimeout(e, 1000));
  await page.type('input[name=thename]', 'moin', {delay: 100})


  // warte auf File Handle
  await page.waitForSelector('input[type=file]');
  const inputUploadHandle = await page.$('input[type=file]');

  // File Auswahl simulieren
  let fileToUpload = 'puppeteer.png';
  await inputUploadHandle.uploadFile(fileToUpload);

  // Screenshot
  await page.screenshot({path: "puppeteer.png"})


  // klick auf submit-Input
  await page.waitForSelector('#uploader');
  await page.evaluate(() => document.getElementById('uploader').click());

  // warte auf das Erscheinen des Uploaded Links
  await page.waitForSelector('#uploadedlink');
  let link = await page.evaluateHandle(() =>
     document.querySelector('#uploadedlink')
  );
  // klick auf den Link und warte
  await link.click()
  await new Promise(e => setTimeout(e, 1000));

  // bereite Download vor
  const client = await page.target().createCDPSession();
  await client.send('Page.setDownloadBehavior', {
    behavior: 'allow', downloadPath: './downloads/'
  });
  await page.click('#downloadlink')
  
  // und warte einfach ...
  await new Promise(e => setTimeout(e, 5000));


  // fullPage Screenshot
  await page.screenshot({ path: 'puppeteer.png',fullPage:true });
  await browser.close()
}

main()

Seit einiger Zeit können auch Screencasts mit Puppeteer erzeugt werden.

  ...
  const recorder = await page.screencast({path: 'recording.webm'});
  await new Promise(e => setTimeout(e, 5000));
  await recorder.stop();

Sie *können* puppeteer auch im docker-Container laufen lassen. Allerdings müssen Sie dann bei den args noch "--no-sandbox" mit angeben. Davor sei ausdrücklich gewarnt (https://no-sandbox.io/) und das sollte nur passieren, wenn Sie sich absolut sicher sind, dass der Javascript-Code keine Exploits enthalten kann. Für das Testen eigener Anwendungen mag das in Ordnung sein, aber allgemein sollte man darauf verzichten.

Weiterführendes