Den Browser nutzen

browser.run startet ein Headless-Chromium, führt deine Funktion auf einer Seite aus und gibt zurück, was diese Funktion zurückgibt. Du liest Seiten, folgst Links, füllst Formulare aus und meldest dich an.

So funktioniert browser.run

browser.run(fn, data) startet einen neuen Browser, öffnet eine Seite, ruft fn({ page, data }) auf, schließt den Browser und gibt zurück, was fn zurückgegeben hat.

  • fn wird als Text an den Browser geschickt. Die Funktion sieht keine Variablen, Hilfsfunktionen oder Pakete aus deinem Node. Gib alles, was sie braucht, in data mit.
  • data und der Rückgabewert reisen als JSON. Gib einfache Objekte, Arrays, Strings, Zahlen und Booleans zurück. Ein Date kommt als String an.
  • page ist eine Puppeteer-Page (Puppeteer 24). page.browser() ist der Browser, zu dem sie gehört.
  • Jeder Aufruf startet leer: keine Cookies, kein localStorage, kein Login aus einem früheren Aufruf. Du trägst sie selbst weiter, wie unten gezeigt.
  • Die Seite ist 800×600 Pixel groß. Ändere das mit page.setViewport. Der User-Agent nennt HeadlessChrome. Setze vor goto mit page.setUserAgent einen anderen.
  • Ein Aufruf darf 10 Minuten laufen. Der ganze Node stoppt ebenfalls nach 10 Minuten. goto, waitForSelector und locator geben nach 30 Sekunden auf, außer du übergibst timeout.
  • Jeder Aufruf kostet 1 Credit plus 1 Credit pro angefangene 5 Sekunden. Ein fehlgeschlagener Aufruf kostet gleich viel. Die Zeit des Nodes selbst kommt dazu.

Eine Seite lesen

async ({ browser }) => {
  return await browser.run(
    async ({ page, data }) => {
      await page.goto(data.url, { waitUntil: "domcontentloaded" })
      return {
        url: page.url(),
        title: await page.title(),
        heading: await page.$eval("h1", (h1) => h1.textContent.trim()),
      }
    },
    { url: "https://quotes.toscrape.com" },
  )
}

page.$eval(selector, callback) führt callback in der Website auf dem ersten Treffer aus und gibt das Ergebnis zurück. Auch dieser Callback sieht data nicht. Übergib einen Wert als zusätzliches Argument: page.$eval("h1", (el, max) => el.textContent.slice(0, max), data.max).

Eine Liste auslesen

async ({ browser }) => {
  return await browser.run(
    async ({ page, data }) => {
      await page.goto(data.url)
      return await page.$$eval(".quote", (quotes) =>
        quotes.map((quote) => ({
          text: quote.querySelector(".text").textContent,
          author: quote.querySelector(".author").textContent,
          authorUrl: quote.querySelector("a[href^='/author/']").href,
          tags: [...quote.querySelectorAll(".tag")].map(
            (tag) => tag.textContent,
          ),
        })),
      )
    },
    { url: "https://quotes.toscrape.com" },
  )
}

page.$$eval holt alle Treffer auf einmal. Baue die Objekte im Callback und gib sie zurück. href an einem Link ist bereits die vollständige URL.

Eine Tabelle auslesen

async ({ browser }) => {
  return await browser.run(
    async ({ page, data }) => {
      await page.goto(data.url)
      return await page.$eval(data.table, (table) => {
        const headers = [...table.querySelectorAll("thead th")].map((th) =>
          th.textContent.trim(),
        )
        return [...table.querySelectorAll("tbody tr")].map((tr) => {
          const cells = [...tr.querySelectorAll("td")].map((td) =>
            td.textContent.trim(),
          )
          return Object.fromEntries(
            headers.map((header, i) => [header, cells[i]]),
          )
        })
      })
    },
    { url: "https://the-internet.herokuapp.com/tables", table: "#table1" },
  )
}

Die Kopfzellen werden zu Schlüsseln. Jede Zeile kommt als ein Objekt zurück, zum Beispiel { "Last Name": "Smith", "Email": "jsmith@gmail.com" }.

Seiten folgen

async ({ browser }) => {
  return await browser.run(
    async ({ page, data }) => {
      const books = []
      let url = data.url
      let pages = 0
      while (url && pages < data.maxPages) {
        await page.goto(url)
        const rows = await page.$$eval("article.product_pod", (articles) =>
          articles.map((article) => ({
            title: article.querySelector("h3 a").title,
            url: article.querySelector("h3 a").href,
            price: Number(
              article
                .querySelector(".price_color")
                .textContent.replace(/[^0-9.]/g, ""),
            ),
            rating: article.querySelector(".star-rating").classList[1],
            inStock: article
              .querySelector(".availability")
              .textContent.includes("In stock"),
          })),
        )
        books.push(...rows)
        pages++
        url = await page.$eval("li.next a", (a) => a.href).catch(() => null)
      }
      return { pages, count: books.length, books }
    },
    { url: "https://books.toscrape.com", maxPages: 3 },
  )
}

Die Schleife liest eine Seite und folgt dann dem Weiter-Link. Sie endet, wenn es keinen Weiter-Link gibt oder maxPages erreicht ist. .catch(() => null) macht aus einem fehlenden Weiter-Link das Ende der Schleife. Lies viele Seiten in einem Aufruf. Das ist günstiger als ein Aufruf pro Seite.

Auf Inhalte warten, die später laden

async ({ browser }) => {
  return await browser.run(
    async ({ page, data }) => {
      await page.goto(data.url)
      await page.waitForSelector(".quote", { timeout: 20000 })
      return await page.$$eval(".quote .text", (texts) =>
        texts.map((text) => text.textContent),
      )
    },
    { url: "https://quotes.toscrape.com/js-delayed/" },
  )
}

goto kehrt zurück, wenn die Seite geladen ist, nicht wenn ihre Skripte sie gefüllt haben. Warte auf das Element, das du lesen willst. Diese Seite füllt sich nach etwa 10 Sekunden, darum ist der Timeout 20 Sekunden. Ohne das Warten wäre die Liste leer.

Den Login in Credentials ablegen

  1. Öffne Settings und danach Credentials.
  2. Wähle New credential und nenne es site-username.
  3. Öffne sein Menü, wähle Edit und füge den Benutzernamen ein.
  4. Mach dasselbe für site-password.

Der Node liest beide aus orbi.project.credentials und gibt sie dem Browser in data mit. Das Passwort steht nie im Node. Fehlt ein Credential, stoppt der Node mit Add the credential site-username under Credentials.

Die Login-Beispiele nutzen öffentliche Übungsseiten, du kannst sie also unverändert ausführen. the-internet.herokuapp.com akzeptiert tomsmith mit SuperSecretPassword!. quotes.toscrape.com akzeptiert jeden Benutzernamen und jedes Passwort.

Mit einem Formular anmelden

async ({ browser, orbi }) => {
  const credential = (name) => {
    const value = orbi.project.credentials.find((c) => c.name === name)?.value
    if (!value) throw new Error(`Add the credential ${name} under Credentials`)
    return value
  }

  const result = await browser.run(
    async ({ page, data }) => {
      try {
        await page.goto(data.loginUrl)
        await page.locator("#username").fill(data.username)
        await page.locator("#password").fill(data.password)
        await Promise.all([
          page.waitForNavigation(),
          page.locator("button[type='submit']").click(),
        ])
        const message = await page.$eval("#flash", (el) =>
          el.textContent.replace("×", "").trim(),
        )
        if (!page.url().endsWith("/secure")) return { error: message }
        return { loggedIn: true, message, url: page.url() }
      } catch (error) {
        return { error: error.message, url: page.url() }
      }
    },
    {
      loginUrl: "https://the-internet.herokuapp.com/login",
      username: credential("site-username"),
      password: credential("site-password"),
    },
  )

  if (!result) throw new Error("The browser run returned nothing")
  if (result.error) throw new Error(`Login failed: ${result.error}`)
  return result
}
  • locator(...).fill wartet auf das Feld, leert es und tippt den Wert.
  • Starte waitForNavigation und den Klick zusammen in Promise.all. Klickst du zuerst und wartest danach, kann die Navigation schon vorbei sein.
  • Prüfe, wo du gelandet bist. Nutze die URL oder ein Element, das es nur im angemeldeten Zustand gibt. Ein falsches Passwort stoppt den Node mit Login failed: Your password is invalid!
  • Versteckte Felder, etwa ein CSRF-Token, gehen mit, weil du das echte Formular der Website absendest.
  • Ein Zwei-Faktor-Code, ein CAPTCHA oder eine "Warst du das?"-Prüfung stoppt den Login. Nutze für solche Websites ihre API oder ein verbundenes Konto.

In einem Aufruf anmelden und auslesen

async ({ browser, orbi }) => {
  const credential = (name) => {
    const value = orbi.project.credentials.find((c) => c.name === name)?.value
    if (!value) throw new Error(`Add the credential ${name} under Credentials`)
    return value
  }

  const result = await browser.run(
    async ({ page, data }) => {
      let logins = 0

      const openLoggedIn = async (url) => {
        await page.goto(url)
        if (await page.$(data.loggedInMarker)) return
        if (logins >= data.maxLogins)
          throw new Error("The session keeps ending")
        await page.goto(data.origin + "/login")
        await page.locator("#username").fill(data.username)
        await page.locator("#password").fill(data.password)
        await Promise.all([
          page.waitForNavigation(),
          page.locator("input[type='submit']").click(),
        ])
        logins++
        await page.goto(url)
        if (!(await page.$(data.loggedInMarker)))
          throw new Error("Login failed")
      }

      try {
        const quotes = []
        let url = data.origin + "/"
        while (url) {
          await openLoggedIn(url)
          const rows = await page.$$eval(".quote", (els) =>
            els.map((el) => ({
              text: el.querySelector(".text").textContent,
              author: el.querySelector(".author").textContent,
              goodreads: el.querySelector("a[href*='goodreads']")?.href ?? null,
            })),
          )
          quotes.push(...rows)
          url = await page.$eval("li.next a", (a) => a.href).catch(() => null)
        }
        return { logins, count: quotes.length, quotes }
      } catch (error) {
        return { error: error.message, url: page.url(), logins }
      }
    },
    {
      origin: "https://quotes.toscrape.com",
      loggedInMarker: "a[href='/logout']",
      maxLogins: 3,
      username: credential("site-username"),
      password: credential("site-password"),
    },
  )

  if (!result) throw new Error("The browser run returned nothing")
  if (result.error) throw new Error(result.error)
  return result
}

Die Session lebt so lange wie der Browser. Melde dich an und lies alles im selben Aufruf. openLoggedIn öffnet eine Seite und meldet sich nur an, wenn die Anmelde-Markierung fehlt: beim ersten Mal und erneut, wenn die Website die Session mittendrin beendet. maxLogins stoppt den Lauf, wenn der Login nie hält. Das Ergebnis hat 100 Zitate, jedes mit einem Link, den nur angemeldete Besucher sehen, und logins: 1.

Die Session halten, bis die Daten ausgelesen sind

async ({ browser, orbi, input }) => {
  const credential = (name) => {
    const value = orbi.project.credentials.find((c) => c.name === name)?.value
    if (!value) throw new Error(`Add the credential ${name} under Credentials`)
    return value
  }

  const step = async ({ page, data }) => {
    let logins = 0

    const openLoggedIn = async (url) => {
      await page.goto(url)
      if (await page.$(data.loggedInMarker)) return
      if (logins >= data.maxLogins) throw new Error("The session keeps ending")
      await page.goto(data.origin + "/login")
      await page.locator("#username").fill(data.username)
      await page.locator("#password").fill(data.password)
      await Promise.all([
        page.waitForNavigation(),
        page.locator("input[type='submit']").click(),
      ])
      logins++
      await page.goto(url)
      if (!(await page.$(data.loggedInMarker))) throw new Error("Login failed")
    }

    try {
      if (data.cookies.length) await page.browser().setCookie(...data.cookies)
      const quotes = []
      let url = data.url
      for (let i = 0; url && i < data.pagesPerRun; i++) {
        await openLoggedIn(url)
        const rows = await page.$$eval(".quote", (els) =>
          els.map((el) => ({
            text: el.querySelector(".text").textContent,
            author: el.querySelector(".author").textContent,
          })),
        )
        quotes.push(...rows)
        url = await page.$eval("li.next a", (a) => a.href).catch(() => null)
      }
      return {
        quotes,
        next: url,
        logins,
        cookies: await page.browser().cookies(),
      }
    } catch (error) {
      return { error: error.message, url: page.url() }
    }
  }

  const origin = "https://quotes.toscrape.com"
  let cookies = input?.cookies ?? []
  let url = origin + "/"
  let runs = 0
  let logins = 0
  const quotes = []

  while (url) {
    const result = await browser.run(step, {
      origin,
      url,
      cookies,
      pagesPerRun: 3,
      loggedInMarker: "a[href='/logout']",
      maxLogins: 2,
      username: credential("site-username"),
      password: credential("site-password"),
    })
    if (!result) throw new Error("The browser run returned nothing")
    if (result.error) throw new Error(`${result.error} (${result.url})`)
    quotes.push(...result.quotes)
    cookies = result.cookies
    url = result.next
    logins += result.logins
    runs++
  }

  return { runs, logins, count: quotes.length, quotes, cookies }
}

Nutze das, wenn die Arbeit über mehrere Aufrufe oder Nodes verteilt ist. Jeder Aufruf endet mit page.browser().cookies(). Der nächste Aufruf setzt sie mit page.browser().setCookie(...) vor seinem ersten goto zurück. Die Website sieht jedes Mal dieselbe Session.

  • Das Beispiel liest 10 Seiten in 4 Aufrufen und meldet sich einmal an: { runs: 4, logins: 1, count: 100 }.
  • Wenn die Cookies abgelaufen sind, meldet sich openLoggedIn erneut an und die Schleife läuft weiter.
  • Der Node gibt cookies zurück. Ein folgender Node liest input.cookies und macht ohne Login weiter. Nutze das für Aufgaben, die länger dauern als die 10 Minuten, die ein Node laufen darf.
  • Die Cookies sind der Login. Wer den Workflow-Lauf lesen kann, kann sie nutzen. Lass cookies im Rückgabewert weg, wenn kein späterer Node sie braucht.
  • Jeder Aufruf kostet mindestens 1 Credit und braucht einen Moment zum Starten. Teile nur, wenn du musst.

Apps, die den Login in localStorage halten

async ({ browser }) => {
  const origin = "https://quotes.toscrape.com"

  const storage = await browser.run(
    async ({ page, data }) => {
      await page.goto(data.origin)
      await page.evaluate(() => localStorage.setItem("token", "demo-token"))
      return await page.evaluate(() => ({ ...localStorage }))
    },
    { origin },
  )

  return await browser.run(
    async ({ page, data }) => {
      await page.evaluateOnNewDocument(
        (origin, storage) => {
          if (location.origin !== origin) return
          for (const [key, value] of Object.entries(storage))
            localStorage.setItem(key, value)
        },
        data.origin,
        data.storage,
      )
      await page.goto(data.origin)
      return { token: await page.evaluate(() => localStorage.getItem("token")) }
    },
    { origin, storage },
  )
}

Manche Web-Apps halten ihr Token in localStorage statt in einem Cookie. Speichere { ...localStorage } am Ende eines Aufrufs. Im nächsten Aufruf schreibt page.evaluateOnNewDocument es zurück, bevor die Skripte der App laufen. Die Origin-Prüfung hält die Werte auf dieser einen Website. Das Beispiel schreibt einen Demo-Wert, damit du siehst, dass er zurückkommt.

Wenn die Website eine API hat

async ({ orbi, input }) => {
  const credential = (name) => {
    const value = orbi.project.credentials.find((c) => c.name === name)?.value
    if (!value) throw new Error(`Add the credential ${name} under Credentials`)
    return value
  }

  const api = "https://dummyjson.com"
  let token = input?.token ?? null
  let logins = 0

  const login = async () => {
    const response = await fetch(api + "/auth/login", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        username: credential("api-username"),
        password: credential("api-password"),
        expiresInMins: 30,
      }),
    })
    if (!response.ok) throw new Error(`Login failed: ${response.status}`)
    token = (await response.json()).accessToken
    logins++
  }

  const get = async (path) => {
    if (!token) await login()
    let response = await fetch(api + path, {
      headers: { authorization: `Bearer ${token}` },
    })
    if (response.status === 401) {
      await login()
      response = await fetch(api + path, {
        headers: { authorization: `Bearer ${token}` },
      })
    }
    if (!response.ok) throw new Error(`${path} failed: ${response.status}`)
    return await response.json()
  }

  const products = []
  for (let skip = 0; ; skip += 50) {
    const page = await get(
      `/auth/products?limit=50&skip=${skip}&select=title,price`,
    )
    products.push(...page.products)
    if (skip + 50 >= page.total) break
  }

  return { logins, count: products.length, products, token }
}

Eine JSON-API braucht keinen Browser. Melde dich mit fetch an, behalte das Token und schick es bei jeder Anfrage mit. Ein 401 heißt, dass das Token abgelaufen ist: melde dich erneut an und wiederhole diese Anfrage einmal. Der Node gibt token zurück, damit der nächste Node input.token übergeben und den Login überspringen kann. Das Beispiel nutzt die öffentliche Übungs-API dummyjson.com. Speichere api-username als emilys und api-password als emilyspass, um es auszuprobieren.

Herausfinden, warum ein Aufruf fehlschlug

Wenn die Funktion wirft oder ein Aufruf sein Zeitlimit erreicht, gibt browser.run undefined zurück. Es wirft nicht, und die Meldung geht verloren. Der fehlgeschlagene Aufruf kostet trotzdem seine Credits, eine Schleife, die immer wieder scheitert, gibt also immer weiter aus. Fang Fehler in der Funktion ab und gib { error } zurück. Entscheide dann im Node: wirf, um den Workflow zu stoppen, oder gib die Details zurück, während du den Selektor korrigierst.

async ({ browser }) => {
  const result = await browser.run(
    async ({ page, data }) => {
      try {
        await page.goto(data.url)
        await page.waitForSelector(data.selector, { timeout: 5000 })
        return { text: await page.$eval(data.selector, (el) => el.textContent) }
      } catch (error) {
        return {
          error: error.message,
          url: page.url(),
          title: await page.title(),
          screenshot: await page.screenshot({
            encoding: "base64",
            type: "jpeg",
            quality: 50,
          }),
        }
      }
    },
    { url: "https://quotes.toscrape.com", selector: ".price" },
  )

  if (!result) throw new Error("The browser run returned nothing")
  return result
}

Das Ergebnis enthält die Meldung, die URL, den Seitentitel und einen JPEG-Screenshot als Base64. Öffne data:image/jpeg;base64, gefolgt von diesem String in deinem eigenen Browser, um die Seite zu sehen.

  • failed to find element matching selector: das Element ist nicht da. Prüfe den Selektor oder warte zuerst darauf.
  • Waiting for selector ... failed: das Element ist innerhalb des Timeouts nicht erschienen.
  • Navigation timeout of 30000 ms exceeded: die Website ist langsam oder bremst Headless-Browser. Übergib goto einen größeren timeout oder setze einen User-Agent.
  • ... is not defined: die Funktion hat eine Variable aus dem Node genutzt. Gib sie in data mit.