Skip to content

JavaScript website chat (LangChain)

A site crawler that lets you ask AI questions about its content.

Create it locally

$scrapely create my-actor --template js-langchain

Main file

.JS
src/main.js
import { rm } from 'node:fs/promises';

import { CheerioCrawler } from '@crawlee/cheerio';
import { createStuffDocumentsChain } from '@langchain/classic/chains/combine_documents';
import { createRetrievalChain } from '@langchain/classic/chains/retrieval';
import { HNSWLib } from '@langchain/community/vectorstores/hnswlib';
import { Document } from '@langchain/core/documents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { Actor, log } from 'scrapely';

// This is ESM project, and as such, it requires you to specify extensions in your relative imports.
// Read more about this here: https://nodejs.org/docs/latest-v18.x/api/esm.html#mandatory-file-extensions
import { cacheVectorIndex, retrieveVectorIndex } from './vector_index_cache.js';

const BLOCK_ELEMENTS =
    'a, address, article, aside, blockquote, br, button, dd, div, dt, h1, h2, h3, h4, h5, h6, hr, li, main, p, pre, section, td, th, tr';

await Actor.init();

// LLM and embedding calls go through the Scrapely LLM gateway (OpenAI-compatible API, OpenRouter model IDs).
// The run token is used as the API key and token usage is billed to your Scrapely account,
// so no provider API key is needed. If running locally, call `scrapely login` in your terminal first.
const { SCRAPELY_TOKEN } = process.env;
const LLM_BASE_URL = process.env.SCRAPELY_LLM_BASE_URL || 'https://api.scrape.ly/v2/llm/api/v1';

// You can configure the input for the Actor in the Scrapely Console when running on the Scrapely platform or editing
// storage/key_value_stores/default/INPUT.json when running locally.
const {
    startUrls = [{ url: 'https://crawlee.dev/js/docs/introduction' }],
    maxCrawlPages = 3,
    forceRecrawl = false, // Enforce a re-crawl of website content and re-creation of the vector index.
    query = 'What is Crawlee and how do I start using it?',
} = (await Actor.getInput()) || {};

// Local directory where the vector index will be stored.
const VECTOR_INDEX_PATH = './vector_index';

const prompt = ChatPromptTemplate.fromTemplate(
    `Answer the user's question: {input} based on the following context {context}`,
);

if (!SCRAPELY_TOKEN) {
    throw new Error(
        'Please configure the SCRAPELY_TOKEN environment variable! Call `scrapely login` in your terminal to authenticate.',
    );
}

const gatewayOptions = { apiKey: SCRAPELY_TOKEN, configuration: { baseURL: LLM_BASE_URL } };
const embeddings = new OpenAIEmbeddings({ ...gatewayOptions, model: 'openai/text-embedding-3-small' });

// The crawl configuration is also used as the cache key of the vector index.
const crawlConfig = { startUrls, maxCrawlPages };

/**
 * Crawls the start URLs and their sub-pages with CheerioCrawler and returns the page texts as LangChain documents.
 */
async function crawlDocuments() {
    const docs = [];
    // Only sub-pages of the start URLs are crawled, e.g. for `https://example.com/blog`
    // the crawler visits `https://example.com/blog/article-1` but skips `https://example.com/docs`.
    const prefixes = new Set(startUrls.map(({ url }) => url));

    const crawler = new CheerioCrawler({
        maxRequestsPerCrawl: maxCrawlPages,
        async requestHandler({ request, $, enqueueLinks }) {
            // Follow redirects of the start URLs (e.g. `https://example.com` -> `https://www.example.com/`).
            if (request.userData.isStartUrl && request.loadedUrl) prefixes.add(request.loadedUrl);

            // Enqueue the sub-pages before the navigation elements get stripped from the page below.
            await enqueueLinks({
                transformRequestFunction: (req) =>
                    [...prefixes].some((prefix) => req.url.startsWith(prefix)) ? req : false,
            });

            const title = $('title').text().trim();
            $('script, style, noscript, svg, iframe, nav, header, footer').remove();
            // Separate text of block elements and links with spaces so that words don't get glued together.
            $(BLOCK_ELEMENTS).after(' ');
            const text = $('body').text().replace(/\s+/g, ' ').trim();
            log.info(`Crawled ${request.loadedUrl} (${text.length} characters)`);
            if (text) {
                docs.push(new Document({ pageContent: text, metadata: { source: request.loadedUrl, title } }));
            }
        },
    });

    await crawler.run(startUrls.map(({ url }) => ({ url, userData: { isStartUrl: true } })));
    return docs;
}

// This variable will contain a vector index that we will use to retrieve the most relevant documents for a given query.
let vectorStore;

// First, we check if the vector index is already cached. If not, we crawl the website to get the documents.
// By setting up forceRecrawl=true you can enforce a re-crawl of the website content and re-creation of the vector index.
log.info('Fetching cached vector index from key-value store...');
const reinitializeIndex = forceRecrawl || !(await retrieveVectorIndex(crawlConfig));
if (reinitializeIndex) {
    log.info('Vector index was not found.');
    log.info('Crawling the website to gather the data...');
    const docs = await crawlDocuments();
    if (!docs.length) throw new Error('No content was crawled from the start URLs.');

    // Initialize the vector index from the crawled documents.
    log.info(`Feeding vector index with ${docs.length} crawled pages...`);
    vectorStore = await HNSWLib.fromDocuments(docs, embeddings);

    // Save the vector index to the key-value store so that we can skip this phase in the next run.
    log.info('Saving vector index to the disk...');
    await vectorStore.save(VECTOR_INDEX_PATH);
    await cacheVectorIndex(crawlConfig, VECTOR_INDEX_PATH);
}

// Load the vector index from the disk if not already initialized above.
if (!vectorStore) {
    log.info('Initializing the vector store...');
    vectorStore = await HNSWLib.load(VECTOR_INDEX_PATH, embeddings);
}

// Next, create the retrieval chain and enter a query:
const llm = new ChatOpenAI({ ...gatewayOptions, model: 'openai/gpt-4o-mini' });
const combineDocsChain = await createStuffDocumentsChain({
    llm,
    prompt,
});

const chain = await createRetrievalChain({
    combineDocsChain,
    retriever: vectorStore.asRetriever(),
    returnSourceDocuments: true,
});

log.info('Asking model a question...');
const res = await chain.invoke({ input: query });

log.info(`Question: ${query}`);
log.info(`Model response: ${res.answer}`);

// Remove the vector index directory as we have it cached in the key-value store for the next time.
await rm(VECTOR_INDEX_PATH, { recursive: true });

await Actor.setValue('OUTPUT', res);
await Actor.exit();

JavaScript website chat (LangChain)

Crawls a website with Crawlee and lets you ask AI questions about its content.

Included features

  • Scrapely SDK — Actor lifecycle, input, storage and proxies
  • LangChain — LLM framework with a vector store
  • Scrapely LLM gateway — call OpenRouter models with the run token; usage is billed to your Scrapely account, no provider API key needed
  • Input schema — define and validate the Actor's input (.actor/input_schema.json)

Getting started locally

npm install
npm run start      # run the Actor

LLM calls go to SCRAPELY_LLM_BASE_URL (set automatically on the platform) with the run's SCRAPELY_TOKEN, so the agent works inside a Scrapely run without any provider API key.

Deploying to Scrapely

Create an Actor from this template in the Scrapely Console (Develop new → template → Git). Scrapely creates a repository in your account, pushes this code, and builds the Actor. Every push to your repository triggers a new build.

G2
4.7/5
Excellent
Trustpilot

The internet is full of data.
Start owning yours.

Scrape.ly gives you the infrastructure, the tooling, and the team to extract, process, and operationalize web data at any scale. Setup takes minutes. The advantage lasts forever.

No credit card. No setup calls.