This workflow corresponds to n8n.io template #7379 — we link there as the canonical source.
This workflow follows the Chat Trigger → Google Drive recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"nodes": [
{
"id": "0fe60fa7-bb31-4fbe-a0c2-e3f34fa82382",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"position": [
-432,
-576
],
"parameters": {
"width": 816,
"height": 336,
"content": "## Need more advanced automation solutions? Contact us for custom enterprise workflows!\n\n# Growth-AI.fr\n\n## https://www.linkedin.com/in/allanvaccarizi/\n## https://www.linkedin.com/in/hugo-marinier-%F0%9F%A7%B2-6537b633/"
},
"typeVersion": 1
},
{
"id": "28a285eb-ea10-497c-9492-fdb968e86830",
"name": "Sticky Note16",
"type": "n8n-nodes-base.stickyNote",
"position": [
416,
-608
],
"parameters": {
"width": 1024,
"height": 400,
"content": ""
},
"typeVersion": 1
},
{
"id": "62181c94-d08e-40f5-b47e-dfdad5b8d0d2",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-432,
-160
],
"parameters": {
"width": 480,
"height": 896,
"content": "## Website mapping\n\n### How it works\n\n1. The workflow is triggered when a chat message is received, typically containing a website URL to crawl.\n2. Firecrawl maps the website and retrieves all available URLs from it.\n3. A conditional check validates whether the Firecrawl response was successful; if not, the workflow stops with an error.\n4. On success, a Google Drive template is copied to serve as the destination spreadsheet.\n5. A code node sorts and structures the crawled URLs into a tabular format.\n6. The processed URL data is written into a Google Sheet via data mapping.\n\n### Setup steps\n\n- - [ ] Configure the **Firecrawl** node with a valid Firecrawl API key and set the target website URL (or map it from the chat message input).\n- - [ ] Set up **Google Drive** credentials and specify the template file ID to copy for each run.\n- - [ ] Set up **Google Sheets** credentials and configure the destination sheet/range in the **Data mapping** node.\n- - [ ] Review the **Sorting URL into table** code node to ensure the URL structuring logic matches your expected output format.\n- - [ ] Optionally configure the **Stop and Error** node with a meaningful error message for failed Firecrawl responses.\n\n### Customization\n\nYou can adapt the **Sorting URL into table** code node to filter, deduplicate, or categorize URLs before they are written to the sheet. The chat trigger input can also be used to dynamically pass different website URLs per run."
},
"typeVersion": 1
},
{
"id": "934445d5-9c50-412b-8b72-ea365e82f21d",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
128,
-160
],
"parameters": {
"color": 7,
"width": 448,
"height": 320,
"content": "## Trigger and website crawl\n\nReceives a chat message to initiate the workflow, then uses Firecrawl to map a website and retrieve all its URLs."
},
"typeVersion": 1
},
{
"id": "2127c702-c800-4059-a450-612bce8d2339",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
624,
-160
],
"parameters": {
"color": 7,
"width": 432,
"height": 480,
"content": "## Validate response or stop\n\nChecks whether the Firecrawl response was successful. If the check fails, the workflow halts immediately with an error message."
},
"typeVersion": 1
},
{
"id": "ca2ff766-42c0-4a19-9aec-bf9503c9dba9",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1088,
-160
],
"parameters": {
"color": 7,
"width": 576,
"height": 320,
"content": "## Structure and save URL data\n\nCopies a Google Drive template, sorts the crawled URLs into a structured table via custom code, then writes the final mapped data into a Google Sheet."
},
"typeVersion": 1
},
{
"id": "841c6c91-6ec8-469f-a25c-0fa783292a17",
"name": "If Firecrawl Succeeded",
"type": "n8n-nodes-base.if",
"position": [
672,
0
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "d1e1025f-704e-4392-bf2b-5be624a9c3a2",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.success }}",
"rightValue": "true"
}
]
}
},
"typeVersion": 2.2
},
{
"id": "1653cf0b-b40a-40c9-9e14-76850f11adf6",
"name": "Copy Report Template in Drive",
"type": "n8n-nodes-base.googleDrive",
"position": [
1136,
0
],
"parameters": {
"name": "={{ $('When Chat Message Received').item.json.chatInput }} - n8n - Arborescence",
"fileId": {
"__rl": true,
"mode": "id",
"value": "1YEk44f6DlFqJIx9LGKyMxJS8Y0WQD2BXNJQB4wD-_G0"
},
"options": {},
"operation": "copy"
},
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
},
"executeOnce": true,
"typeVersion": 3
},
{
"id": "7358b789-e740-466c-9edc-e235eb3221d4",
"name": "Append URL Data to Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
1504,
0
],
"parameters": {
"columns": {
"value": {},
"schema": [
{
"id": "Niv 0",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Niv 0",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Niv 1",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Niv 1",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Niv 2",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Niv 2",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Niv 3",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Niv 3",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Niv 4",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Niv 4",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Niv 5",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Niv 5",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "error",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "error",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "message",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "message",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "autoMapInputData",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "name",
"value": "FR"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "={{ $('Copy Report Template in Drive').item.json.id }}"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.5
},
{
"id": "811ccac9-9dbb-4558-9a72-821765611f9b",
"name": "Organize URLs into Table Format",
"type": "n8n-nodes-base.code",
"position": [
1328,
0
],
"parameters": {
"jsCode": "/**\n * Fonction pour traiter les URLs collect\u00e9es par Firecrawl et g\u00e9n\u00e9rer une arborescence de site\n * en traitant s\u00e9par\u00e9ment les diff\u00e9rents domaines et sous-domaines\n * \n * @param {Object} inputData - Les donn\u00e9es brutes de l'appel Firecrawl\n * @returns {Array} - Tableau d'objets avec les colonnes pour Google Sheets\n */\nfunction createSiteHierarchy(inputData) {\n // V\u00e9rifier que les donn\u00e9es d'entr\u00e9e sont valides\n if (!inputData || !inputData.success || !Array.isArray(inputData.links) || inputData.links.length === 0) {\n throw new Error(\"Donn\u00e9es d'entr\u00e9e invalides ou vides\");\n }\n\n // Normaliser toutes les URLs (convertir http en https)\n const urls = inputData.links.map(url => {\n if (url.startsWith('http://')) {\n return 'https://' + url.substring(7);\n }\n return url;\n });\n\n // Extraire les diff\u00e9rents domaines/sous-domaines pr\u00e9sents dans les URLs\n const domainPattern = /^https?:\\/\\/([^\\/]+)/;\n const domains = {};\n \n // Regrouper les URLs par domaine/sous-domaine\n for (const url of urls) {\n const match = url.match(domainPattern);\n if (!match) continue;\n \n const fullDomain = match[1]; // ex: www.zest.fr, wiki.zest.fr\n \n // Extraire le sous-domaine et le domaine de base\n const domainParts = fullDomain.split('.');\n const isSubdomain = domainParts.length > 2;\n \n // D\u00e9terminer le domaine principal\n let mainDomain;\n if (isSubdomain) {\n // Pour les sous-domaines comme wiki.zest.fr\n mainDomain = domainParts.slice(domainParts.length - 2).join('.');\n } else {\n // Pour les domaines principaux comme zest.fr\n mainDomain = fullDomain;\n }\n \n // Enregistrer cette URL dans son groupe de domaine\n if (!domains[fullDomain]) {\n domains[fullDomain] = {\n mainDomain: mainDomain,\n fullDomain: fullDomain,\n baseUrl: `https://${fullDomain}`,\n urls: []\n };\n }\n \n domains[fullDomain].urls.push(url);\n }\n \n // Traiter chaque domaine/sous-domaine s\u00e9par\u00e9ment\n const results = [];\n \n // Fonction pour formater le texte d'affichage d'une URL\n function formatDisplayText(segment) {\n if (!segment) return \"HOME PAGE\";\n // D\u00e9codage des caract\u00e8res URL (comme %20, %C3%A9, etc.)\n try {\n const decoded = decodeURIComponent(segment);\n return decoded.toUpperCase().replace(/-/g, ' ');\n } catch (e) {\n // En cas d'erreur de d\u00e9codage, utiliser le segment tel quel\n return segment.toUpperCase().replace(/-/g, ' ');\n }\n }\n \n // Fonction pour extraire le chemin relatif d'une URL\n function getPathFromUrl(url, baseUrl) {\n // Supprimer le domaine\n let path = url.replace(baseUrl, '');\n \n // Supprimer les slashes au d\u00e9but et \u00e0 la fin\n if (path.startsWith('/')) path = path.substring(1);\n if (path.endsWith('/')) path = path.substring(0, path.length - 1);\n \n return path;\n }\n \n // Fonction pour cr\u00e9er l'arborescence d'un domaine sp\u00e9cifique\n function processUrlsForDomain(domainInfo) {\n // Cr\u00e9er une structure arborescente pour ce domaine\n const tree = {};\n \n // Ajouter la page d'accueil (niveau 0)\n tree[domainInfo.baseUrl] = {\n url: domainInfo.baseUrl,\n level: 0,\n segments: [],\n displayText: domainInfo.fullDomain.toUpperCase(),\n children: {}\n };\n \n // Trier les URLs par longueur de chemin (du plus court au plus long)\n domainInfo.urls.sort((a, b) => {\n const pathA = getPathFromUrl(a, domainInfo.baseUrl);\n const pathB = getPathFromUrl(b, domainInfo.baseUrl);\n \n const segmentsA = pathA ? pathA.split('/') : [];\n const segmentsB = pathB ? pathB.split('/') : [];\n \n // D'abord comparer le nombre de segments\n if (segmentsA.length !== segmentsB.length) {\n return segmentsA.length - segmentsB.length;\n }\n \n // Si m\u00eame nombre de segments, comparer alphab\u00e9tiquement\n return pathA.localeCompare(pathB);\n });\n \n // Construire l'arborescence\n for (const url of domainInfo.urls) {\n // Ignorer l'URL racine d\u00e9j\u00e0 ajout\u00e9e\n if (url === domainInfo.baseUrl || url === domainInfo.baseUrl + '/') continue;\n \n const path = getPathFromUrl(url, domainInfo.baseUrl);\n const segments = path ? path.split('/') : [];\n \n // D\u00e9terminer le niveau (limit\u00e9 \u00e0 5)\n const level = Math.min(segments.length, 5);\n \n if (level === 0) continue; // Ignorer les duplications de l'URL racine\n \n // Construire le chemin complet segment par segment\n let currentNode = tree[domainInfo.baseUrl];\n let parentPath = domainInfo.baseUrl;\n \n for (let i = 0; i < level; i++) {\n const segment = segments[i];\n const currentPath = parentPath + '/' + segment;\n \n // Cr\u00e9er le n\u0153ud s'il n'existe pas\n if (!currentNode.children[segment]) {\n currentNode.children[segment] = {\n url: currentPath,\n level: i + 1,\n segments: segments.slice(0, i + 1),\n displayText: formatDisplayText(segment),\n children: {}\n };\n }\n \n // Avancer au n\u0153ud enfant\n currentNode = currentNode.children[segment];\n parentPath = currentPath;\n }\n }\n \n // Convertir l'arborescence en lignes\n const domainRows = [];\n \n // Fonction r\u00e9cursive pour parcourir l'arborescence\n function traverseTree(node) {\n // Cr\u00e9er une nouvelle ligne\n const row = {\n \"Niv 0\": \"\",\n \"Niv 1\": \"\",\n \"Niv 2\": \"\",\n \"Niv 3\": \"\",\n \"Niv 4\": \"\",\n \"Niv 5\": \"\",\n \"URL\": node.url // Ajout de la colonne URL avec l'URL en texte brut\n };\n \n // D\u00e9finir la valeur au niveau appropri\u00e9\n if (node.level <= 5) {\n row[`Niv ${node.level}`] = `=HYPERLINK(\"${node.url}\";\"${node.displayText}\")`;\n }\n \n // Ajouter la ligne au r\u00e9sultat\n domainRows.push(row);\n \n // Traiter les enfants dans l'ordre alphab\u00e9tique\n const children = Object.values(node.children);\n children.sort((a, b) => a.displayText.localeCompare(b.displayText));\n \n for (const child of children) {\n traverseTree(child);\n }\n }\n \n // Commencer le parcours avec le n\u0153ud racine\n traverseTree(tree[domainInfo.baseUrl]);\n \n return domainRows;\n }\n \n // Trier les domaines: d'abord le domaine principal (sans sous-domaine), puis les sous-domaines\n const sortedDomains = Object.values(domains).sort((a, b) => {\n // Si un domaine est exactement le domaine principal, il vient en premier\n const aParts = a.fullDomain.split('.');\n const bParts = b.fullDomain.split('.');\n \n // Cas sp\u00e9cial pour www: le traiter comme domaine principal\n const aIsWWW = aParts.length > 2 && aParts[0] === 'www';\n const bIsWWW = bParts.length > 2 && bParts[0] === 'www';\n \n if (aIsWWW && !bIsWWW) return -1;\n if (!aIsWWW && bIsWWW) return 1;\n \n // Ensuite comparer par nombre de parties\n if (aParts.length !== bParts.length) {\n return aParts.length - bParts.length;\n }\n \n // Enfin, comparer alphab\u00e9tiquement\n return a.fullDomain.localeCompare(b.fullDomain);\n });\n \n // Traiter chaque domaine et ajouter les r\u00e9sultats\n for (const domainInfo of sortedDomains) {\n const domainRows = processUrlsForDomain(domainInfo);\n results.push(...domainRows);\n }\n \n return results;\n}\n\n/**\n * Fonction principale pour traiter l'entr\u00e9e de n8n\n */\nfunction processInput() {\n try {\n // R\u00e9cup\u00e9rer les donn\u00e9es de la node \"Map a website and get urls\" en utilisant la m\u00e9thode $()\n // Cette m\u00e9thode a \u00e9t\u00e9 confirm\u00e9e fonctionnelle par nos tests\n const firecrawlData = $('Crawl Website and Get URLs').item.json;\n \n // V\u00e9rifier la structure des donn\u00e9es\n if (!firecrawlData || !firecrawlData.success || !Array.isArray(firecrawlData.links)) {\n throw new Error(\"Donn\u00e9es d'entr\u00e9e non valides ou structure incorrecte\");\n }\n \n // Traiter les URLs pour cr\u00e9er l'arborescence\n const siteHierarchy = createSiteHierarchy(firecrawlData);\n \n // Cr\u00e9er un nouvel item pour chaque ligne de l'arborescence\n // C'est le format attendu par Google Sheets dans n8n\n return siteHierarchy.map(row => {\n return {\n json: row\n };\n });\n \n } catch (error) {\n console.error(\"Erreur lors du traitement:\", error.message);\n // Retourner un message d'erreur format\u00e9 pour n8n\n return [{\n json: {\n error: true,\n message: error.message,\n details: error.stack\n }\n }];\n }\n}\n\n// Ex\u00e9cuter le traitement\nreturn processInput();"
},
"typeVersion": 2
},
{
"id": "40ae0f18-c854-4eb9-9114-8e10dac4ac25",
"name": "Crawl Website and Get URLs",
"type": "@mendable/n8n-nodes-firecrawl.firecrawl",
"position": [
432,
0
],
"parameters": {
"url": "={{ $json.chatInput }}",
"operation": "map",
"sitemapOnly": true,
"ignoreSitemap": false,
"requestOptions": {}
},
"credentials": {
"firecrawlApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "ef0c586c-cedb-461d-80ad-7e07afa1e77f",
"name": "Stop on Firecrawl Failure",
"type": "n8n-nodes-base.stopAndError",
"position": [
912,
160
],
"parameters": {
"errorMessage": "Can't map the site"
},
"typeVersion": 1
},
{
"id": "de29569c-eba9-4d78-baff-df60e99239f4",
"name": "When Chat Message Received",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"position": [
176,
0
],
"parameters": {
"options": {}
},
"typeVersion": 1.4
},
{
"id": "d0f9fa91-649c-4590-a732-a0e45b2e86f7",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
112,
432
],
"parameters": {
"color": 4,
"width": 1152,
"height": 128,
"content": "# Google Sheets Template\n## https://docs.google.com/spreadsheets/d/1YEk44f6DlFqJIx9LGKyMxJS8Y0WQD2BXNJQB4wD-_G0\n"
},
"typeVersion": 1
}
],
"connections": {
"If Firecrawl Succeeded": {
"main": [
[
{
"node": "Copy Report Template in Drive",
"type": "main",
"index": 0
}
],
[
{
"node": "Stop on Firecrawl Failure",
"type": "main",
"index": 0
}
]
]
},
"Crawl Website and Get URLs": {
"main": [
[
{
"node": "If Firecrawl Succeeded",
"type": "main",
"index": 0
}
]
]
},
"When Chat Message Received": {
"main": [
[
{
"node": "Crawl Website and Get URLs",
"type": "main",
"index": 0
}
]
]
},
"Copy Report Template in Drive": {
"main": [
[
{
"node": "Organize URLs into Table Format",
"type": "main",
"index": 0
}
]
]
},
"Organize URLs into Table Format": {
"main": [
[
{
"node": "Append URL Data to Sheets",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
firecrawlApigoogleDriveOAuth2ApigoogleSheetsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
📺 Full walkthrough video: https://youtu.be/yjeKYfZP0kU
Source: https://n8n.io/workflows/7379/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
📺 Full walkthrough video: https://youtu.be/x3PDYon4qKk
This workflow builds a free lead generation system that scrapes emails from Google Maps listings and exports them directly into Google Sheets. It’s built in n8n using HTTP requests and JavaScript—no p
template-demo-chatgpt-image-1-with-drive-and-sheet copy. Uses manualTrigger, httpRequest, googleDrive, splitOut. Event-driven trigger; 16 nodes.
Receive a chat input as an image prompt. Call OpenAI's API to generate an image. Split the returned images and process them one by one. Upload each generated image to Google Drive. Save image links an
This workflow is designed for marketers, researchers, and business owners who need to quickly find and export company data from Google Maps into a structured table format.