KI löst heutzutage ja allerhand Probleme für uns. Also dachte ich mir, es könne ja nicht so schwer sein, einer LLM Fragen zu einer Webseite zu stellen und eine passende Antwort zu erhalten. Meine konkrete Fragestellung lautete: „Welche Workshops werden auf den JavaScript Days in Berlin angeboten und welche Speaker halten die Workshops?“ Die Aufgabe lässt sich auf verschiedene Arten lösen.
Auf den ersten Blick scheint eine der populären, gehosteten GUIs mit einem extrem leistungsfähigen LLM im Hintergrund die Lösung für das Problem zu sein. Beispiele hierfür sind ChatGPT, Gemini oder Claude. Ein Vorteil dieser Plattformen ist, dass die Modelle standardmäßig auf das Internet zugreifen können und somit selbstständig recherchieren können. Doch das ernüchternde Ergebnis liegt schnell vor. Weder ChatGPT noch Claude oder Gemini beantworten die Frage korrekt. Vielmehr ist das Ergebnis entweder nur ein Bruchteil (bei ChatGPT) oder schlichtweg falsch (bei Gemini), wobei Gemini ungefragt ein fotorealistisches Bild der Konferenzatmosphäre generiert.
Möglicherweise können die Modelle die Informationen der Webseite nicht richtig abrufen. Dieses Problem ließe sich jedoch lösen, indem man dem Modell die entsprechende Seite zur Verfügung stellt. Doch wenn der Code der Seite einfach in das Eingabefeld des Prompts kopiert wird, quittieren die Web-UIs der Modelle das mit einer Fehlermeldung, da die Eingabe zu lang ist. Also bleibt nur, den HTML-Code in Form einer Datei hochzuladen. Dann werden die Ergebnisse schon deutlich besser.
Von Nachteil ist dabei nur, dass wir den Quellcode der Seite jedes Mal lokal zwischenspeichern und dem Modell zur Verfügung stellen müssten. Mit ein wenig Code lässt sich dieser Prozess jedoch deutlich vereinfachen.
Die lokale Lösung
Die Basis unserer eleganteren Lösung bildet Node.js. Als Schnittstelle soll die Kommandozeile des Systems dienen. Die Applikation benötigt zwei Informationen: die Webseite, für die wir uns interessieren, und unseren Prompt, also die Fragestellung. Die Applikation besteht aus drei Teilen: einer CLI-Schnittstelle für die Ein- und Ausgabe, einer Funktion zum Herunterladen des Webseiteninhalts und einer weiteren Funktion zum Senden der Informationen an ein LLM.
Eine solche Applikation kann auf verschiedene Weise aufgebaut werden. Die einfachste Methode besteht darin, die drei Kernelemente jeweils in einer Funktion zu kapseln und die Funktionen nacheinander aufzurufen. Eine etwas elegantere Variante ist der Einsatz von LangChain als LLM-Framework. LangChain wurde genau für diese Art von Applikation entwickelt. Die einzelnen Teile bilden dabei eine Kette, an deren Ende die Antwort des LLMs steht.
Als Erstes wird die Applikation mit npm init -y initialisiert und in der erzeugten package.json-Datei der type auf module umgestellt, um das ECMAScript-Modulsystem zu aktivieren. Anschließend können die erforderlichen LangChain-Module initialisiert werden:
npm install @langchain/core @langchain/ollama
Die Eingabe
Für die Interaktion auf der Kommandozeile stellt Node.js das Readline-Modul zur Verfügung. Die createInterface-Funktion erzeugt eine neue Schnittstelle für zwei Streams. Normalerweise sind das die Standardeingabe und die Standardausgabe. Über dieses Schnittstellenobjekt können wir dann mit der question-Methode Eingaben auf der Kommandozeile abfragen. Node.js bietet für dieses Modul mittlerweile auch eine Promise-basierte Variante, die in Kombination mit async/await zu gut lesbarem und kompaktem Code führt. Die beiden Eingaben kapseln wir in ein RunnableLambda, eine Abstraktion von LangChain für Funktionen, die es ermöglicht, diese Funktionen in eine LangChain-Kette zu integrieren.
Der Rückgabewert der Funktion ist ein Objekt, das den eingegebenen URL und die Frage zur Webseite, also den Prompt, enthält. Dieses Objekt stellt LangChain dem nächsten Element in der Kette zur Verfügung. Testen können wir diese Funktion, indem wir die invoke-Methode des Objekts aufrufen. In Listing 1 ist das mit dem Code des ersten Kettenglieds zu sehen.
Listing 1: Eingaben über die Kommandozeile
import readline from 'node:readline/promises';
import { RunnableLambda } from '@langchain/core/runnables';
export const cliInput = new RunnableLambda({
async func() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const url = await rl.question('Gib die URL ein: ');
const question = await rl.question('Welche Frage möchtest du stellen? ');
rl.close();
return { url, question };
},
});
// test:
// const result = await cliInput.invoke();
// console.log(result);
Download der Webseite
Für den Download einer einfachen Webseite kann die fetch-Funktion von Node.js verwendet werden. Alternativ gibt es Pakete wie crawler, die über den einfachen Download von HTML-Dokumenten hinaus noch zusätzliche Komfortfeatures bieten, beispielsweise die Verarbeitung mehrerer Seiten oder die Unterstützung von Rate Limits, Timeouts und Retries. Für unser Beispiel, die Programmseite der JavaScript Days, hilft uns jedoch weder die fetch-Funktion noch das crawler-Paket, da die Webseite die wichtigsten Inhaltselemente asynchron mit JavaScript nachlädt. Das Problem der meisten Web-Crawler ist, dass sie kein JavaScript unterstützen und nur die initiale HTML-Struktur zur Verfügung stellen. Diese hilft jedoch nicht bei der Beantwortung unserer Frage. Hier müssen wir also etwas tiefer in die Trickkiste greifen.
Eine mögliche Lösung heißt Puppeteer. Im Kern ist dies ein Headless-Browser, also ein Browser ohne grafische Oberfläche. Er ist in der Lage, JavaScript auszuführen, sodass auch dynamische Inhalte konsumiert werden können.
Puppeteer wird mit dem Kommando npm install puppeteer installiert. Da mit dem Paket auch ein vollwertiger Chrome-Browser heruntergeladen wird, kann der Installationsprozess je nach System und Internetanbindung etwas länger dauern.
Die Verarbeitung von Webseiten mit Puppeteer erfolgt in mehreren Schritten: Zunächst starten wir den Browser im Headless-Modus mit der launch-Methode von Puppeteer. Anschließend erzeugen wir mit der newPage-Methode ein Objekt, das eine Seite repräsentiert, und wechseln mit der goto-Methode zum gewünschten URL. Im letzten Schritt erhalten wir den HTML-Code der Seite mit der evaluate-Methode des Page-Objekts.
Der Workflow mit Puppeteer wird für die Ausführung in der LangChain-Kette in ein RunnableLambda gekapselt. Der Rückgabewert enthält den Inhalt der Seite sowie die ursprünglich übergebene Frage, die hier nur durchgereicht wird. Den Code können wir wiederum mit einem Aufruf der invoke-Methode des RunnableLambda-Objekts testen (Listing 2).
Listing 2: Download einer dynamischen Webseite mit Puppeteer
import { RunnableLambda } from '@langchain/core/runnables';
import puppeteer from 'puppeteer';
export const fetchPage = new RunnableLambda({
async func({ url, question }) {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
const html = await page.evaluate(() => document.body.innerText);
await browser.close();
return { content: html, question };
},
});
// test:
// const result = await fetchPage.invoke({
// url: 'https://de.wikipedia.org/wiki/Kuh',
// question: 'Nenne mir eine interessante Tatsache über Kühe.',
// });
// console.log(result);
Die einzelnen Elemente zusammenfügen
Im letzten Schritt fehlt nun noch die Interaktion mit dem Modell sowie das Zusammenfügen der einzelnen Teile zu einer vollwertigen Kette. Diese besteht aus mehreren Elementen:
-
Konsoleneingabe: Am Anfang der Interaktion steht die Konsoleneingabe, die wir in der cliInput-Funktion gekapselt haben. Als erstes Element in der Kette benötigt diese Funktion keine Argumente und liefert als Ausgaben den URL und die ursprüngliche Frage des Users.
-
Download der Webseite: Der URL aus der Eingabe ist der wichtigste Input für den Download mit Puppeteer. Die entsprechende Logik haben wir in der fetchPage-Funktion gekapselt und in eine eigene Datei ausgelagert. Die Userfrage wird an das nächste Element der Kette weitergereicht.
-
Prompt-Template: Mit dem Prompt-Template können wir die Informationen, die wir an das Modell übergeben, strukturieren. Das verbessert die Ergebnisse deutlich. Neben dem verwendeten Modell ist dieses Template eine der wichtigsten Stellschrauben. Experimentieren Sie mit der Formulierung und der Struktur, um die Ergebnisse zu beeinflussen.
-
Modellinteraktion: Das Herzstück der Applikation ist die Kommunikation mit dem Modell. Die Wahl des passenden Modells ist entscheidend für die Qualität des Ergebnisses. Im Beispiel in Listing 3 kommt mit Llama 3.2:1b ein vergleichsweise kleines Modell zum Einsatz. Der Vorteil ist, dass dieses Modell auf einem einigermaßen gut ausgestatteten Rechner problemlos ausgeführt werden kann. Das Ergebnis ist jedoch in der Regel nicht optimal. Wenn wir ein größeres Modell wie das Granite4-Small-Modell mit seinen 32 Milliarden Parametern nutzen, werden die Ergebnisse deutlich besser. Eine weitere Optimierung besteht in der Verwendung eines kommerziellen Modells, wie beispielsweise GPT-5 von OpenAI. Um dieses zu nutzen, ersetzen Sie das Paket @langchain/ollama durch @langchain/openai, wählen das entsprechende Modell aus und hinterlegen Ihren API-Key. Dabei ist jedoch zu beachten, dass durch die Kommunikation Kosten auf Basis der Input- und Output-Tokens entstehen. Zu den Inputtokens zählt in diesem Fall der gesamte Quellcode, den unser Skript im Zuge der Kette heruntergeladen hat. Zwar sprechen wir hier zunächst über verschwindend geringe Centbeträge. Wird das Skript jedoch sehr häufig ausgeführt, können durchaus nennenswerte Kosten entstehen.
-
Parsen der Antwort: Der StringOutputParser von LangChain stellt sicher, dass die Antwort des Modells ohne zusätzliche Metainformationen zur Verfügung steht, sodass sie direkt ausgegeben werden kann.
Listing 3 zeigt den Quellcode der Applikation.
Listing 3: Integration der einzelnen Bestandteile der LangChain-Kette
import { RunnableSequence, RunnableLambda } from '@langchain/core/runnables';
import { PromptTemplate } from '@langchain/core/prompts';
import { Ollama } from '@langchain/ollama';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { fetchPage } from './fetchPage.js';
import { cliInput } from './cliInput.js';
const prompt = new PromptTemplate({
template: `
You are a helpful assistant.
Here is the content of a web page:
---
{content}
---
Answer this question based on the page content:
{question}
`,
inputVariables: ['content', 'question'],
});
const model = new Ollama({
model: 'llama3.2:1b',
});
const parser = new StringOutputParser();
const chain = RunnableSequence.from([
cliInput,
fetchPage,
prompt,
model,
parser,
]);
const answer = await chain.invoke();
console.log(answer);
Die Applikation können wir nun mit Node.js auf der Kommandozeile ausführen und erhalten dann die Antworten auf unsere Frage zu einer Webseite.
Bleib informiert
Brancheninsights im Newsletter erhalten:
Und was lernen wir daraus?
In diesem Beispiel stellen wir eine konkrete Frage zu einer Webseite und servieren dem LLM die Antwort praktisch auf einem Silbertablett. Doch es bereitet gerade kleineren Modellen erhebliche Schwierigkeiten, die gewünschte Information zu extrahieren und zu präsentieren.
Allerdings gibt es in diesem Prozess eine Reihe von Stellschrauben, an denen wir drehen können. Zunächst wäre da das Prompt-Template. Je nachdem, wie es formuliert ist, kann es das Ergebnis erheblich beeinflussen. Auch die verwendete Sprache kann Bedeutung haben. Die meisten Modelle arbeiten beispielsweise mit Englisch deutlich besser als mit Deutsch. Zudem können wir, wie schon erwähnt, das Modell gegen ein leistungsfähigeres ersetzen und damit die Ergebnisqualität nochmals verbessern.
Aktuell müssen wir bei sehr spezifischen Problemstellungen also noch immer kreativ sein, um eine gute Lösung zu finden. Die technischen Möglichkeiten entwickeln sich in diesem Bereich jedoch rasant weiter.
🔍 Frequently Asked Questions (FAQ)
1. Was ist JavaScript Web-Crawling in diesem Artikel?
JavaScript Web-Crawling beschreibt das automatische Laden und Auslesen von Webseiten mit Node.js. Im Artikel liegt der Fokus auf dynamischen Webseiten, die Inhalte per JavaScript nachladen, um diese Inhalte anschließend an Large Language Models (LLMs) weiterzugeben.
2. Welches Problem löst diese JavaScript-Web-Crawling-Lösung?
LLMs können Webseiteninhalte oft nicht zuverlässig selbst abrufen oder verstehen. Durch das lokale Herunterladen und Vorverarbeiten der Inhalte kann das Modell die relevanten Informationen direkt erhalten, was präzisere und vollständigere Antworten ermöglicht.
3. Welche Technologien werden in diesem Beispiel verwendet?
Verwendet werden Node.js als Laufzeitumgebung, Puppeteer als Headless-Browser für die Ausführung von JavaScript auf Webseiten und LangChain, um die Interaktion zwischen Eingaben, Webseiteninhalt und LLM zu strukturieren.
4. Was kann Puppeteer, was einfache Web-Crawler nicht können?
Puppeteer führt JavaScript in einem Headless-Browser aus. Dadurch können dynamische Inhalte, die erst clientseitig geladen werden, abgerufen werden – etwas, das klassische Web-Crawler, die nur statisches HTML herunterladen, nicht leisten können.
5. Wozu wird LangChain in dieser Anwendung eingesetzt?
LangChain verbindet mehrere Schritte – CLI-Eingabe, Seiten-Download, Prompt-Erstellung, Modellinteraktion und Ausgabe – zu einer strukturierten Kette. Das macht die Anwendung modular, gut lesbar und leicht erweiterbar.
6. Wer kann von diesem JavaScript-Web-Crawling profitieren?
Die Methode ist besonders nützlich für JavaScript-Entwickler, Data Engineers und alle, die Webseiteninhalte mit KI analysieren oder abfragen möchten. Besonders relevant ist sie bei dynamischen Webseiten und automatisierten LLM-Prozessen.
7. Wozu kann diese Web-Crawling-Lösung eingesetzt werden?
Die Lösung dient dazu, konkrete Fragen zu Webseiten zu beantworten, strukturierte Informationen zu extrahieren, Forschungsaufgaben zu automatisieren oder Webseiteninhalte für KI-Analysen mit LLMs vorzubereiten.





