n8n-nodes-crawlbrulee 0.3.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  put crawlbrulee in your n8n workflows. this community node package turns any url into clean markdown, html, links, images, metadata or a screenshot, maps a site's urls, and starts a workflow when a background scrape finishes. it also works as an [AI Agent](https://docs.n8n.io/advanced-ai/) tool, so an agent in n8n can scrape a page on its own.
9
9
 
10
10
  - **everything runs in the EU.** the fetch, the render, the cache and your result never leave EU servers. the proxy exit is the one hop you choose: pick an EU exit and nothing leaves at all. gdpr-aligned, with a data processing agreement.
11
- - **output made for models.** markdown with the page chrome stripped and the links kept, ready for the prompt. full-page screenshots can come back as tiles sized for an image model.
11
+ - **output made for models.** markdown with ads, popups and cookie banners removed and the links kept, ready for the prompt. full-page screenshots can come back as tiles sized for an image model.
12
12
  - **the hard parts, handled.** headless Chrome when a page needs it, rotating proxies with country selection, automatic retries, ad and cookie-banner removal, caching, background jobs and signed webhooks.
13
13
  - **start free.** 750 credits, no credit card.
14
14
 
@@ -60,6 +60,41 @@ every field has a hint in n8n. three things the hints don't tell you:
60
60
  - a **Screenshot** renders the page in a real browser and is billed at the screenshot rate, the priciest one — leave it on None when you don't need the image.
61
61
  - **Scrape URL (Async)** takes the same fields as **Scrape URL**, minus **Download Screenshot** (a submit gives you a job id, not an image), plus **Webhook URL** and **Webhook Metadata**.
62
62
 
63
+ ### elements
64
+
65
+ **Elements** reads named values from the page by CSS selector. add one row per value: a **Name**, a **CSS Selector**, the **Output** (Text, HTML or an Attribute such as `href`), and **All Matches** to get every match as a list. they come back in `elements`, under the names you gave. elements cost no extra credits. **Extract** starts on Cleaned HTML and Metadata, so clear it to get only the elements.
66
+
67
+ for a value per match, like a title, price and link per product card, put the request in **Elements (JSON)** under **Options**. it is merged with the list, and each name may be used once:
68
+
69
+ ```json
70
+ {
71
+ "books": {
72
+ "selector": "article.product_pod",
73
+ "all": true,
74
+ "fields": {
75
+ "title": { "selector": "h3 a", "output": "attribute", "attribute": "title" },
76
+ "price": ".price_color"
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ with a **heading** row (`h1`, Text), the item's json gets this (the page has 20 books; two are shown here):
83
+
84
+ ```json
85
+ {
86
+ "elements": {
87
+ "heading": "All products",
88
+ "books": [
89
+ { "title": "A Light in the Attic", "price": "£51.77" },
90
+ { "title": "Tipping the Velvet", "price": "£53.74" }
91
+ ]
92
+ }
93
+ }
94
+ ```
95
+
96
+ a name with no match is `null` (`[]` with All Matches). selector rules and limits: see [elements](https://crawlbrulee.com/docs/scrape/elements).
97
+
63
98
  ### screenshots as files
64
99
 
65
100
  turn on **Download Screenshot** — it's on **Scrape URL** and on **Get Scrape Result** — and the node attaches the image as binary data under `screenshot`, plus one key per tile (`screenshot_slice_0`, `screenshot_slice_1`, …) when you set Slice Height. the urls stay in the json too, but they are signed links that expire 24 hours after the scrape (for **Scrape URL (Async)**, 24 hours after the job was submitted), so turn this on when you need the image later in the workflow. a download can fail, since a signed url can expire; the item still comes through with its page content and the reason lands on the json as `screenshot_download_error`.
@@ -57,6 +57,7 @@ function scrapeParams(i) {
57
57
  return {
58
58
  url: this.getNodeParameter('url', i),
59
59
  extract: this.getNodeParameter('extract', i, []),
60
+ elements: this.getNodeParameter('elements', i, {}),
60
61
  screenshotType: this.getNodeParameter('screenshotType', i, 'none'),
61
62
  screenshotOptions: this.getNodeParameter('screenshotOptions', i, {}),
62
63
  options: this.getNodeParameter('options', i, {}),
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EXTRACT_KEYS = void 0;
4
4
  exports.parseCacheMaxAge = parseCacheMaxAge;
5
5
  exports.isSet = isSet;
6
+ exports.buildElements = buildElements;
6
7
  exports.buildScrapeBody = buildScrapeBody;
7
8
  exports.buildAsyncScrapeBody = buildAsyncScrapeBody;
8
9
  const n8n_workflow_1 = require("n8n-workflow");
@@ -67,11 +68,98 @@ function buildScreenshot(node, itemIndex, p) {
67
68
  }
68
69
  return shot;
69
70
  }
71
+ function parseJsonText(node, itemIndex, text, field) {
72
+ try {
73
+ return JSON.parse(text);
74
+ }
75
+ catch (error) {
76
+ const reason = error instanceof Error ? error.message : '';
77
+ const safe = reason !== '' && reason.length <= 120 && !reason.includes('"');
78
+ throw new n8n_workflow_1.NodeOperationError(node, `${field} is not valid JSON`, {
79
+ itemIndex,
80
+ description: safe ? `${reason}.` : 'Check for a missing quote, comma or bracket.',
81
+ });
82
+ }
83
+ }
84
+ function parseElementsJson(node, itemIndex, raw) {
85
+ if (raw === undefined || raw === null)
86
+ return {};
87
+ if (typeof raw === 'string' && raw.trim() === '')
88
+ return {};
89
+ const parsed = typeof raw === 'string' ? parseJsonText(node, itemIndex, raw, 'Elements (JSON)') : raw;
90
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
91
+ throw new n8n_workflow_1.NodeOperationError(node, 'Elements (JSON) must be a JSON object', {
92
+ itemIndex,
93
+ description: 'Each key is a name, each value a CSS selector or an object.',
94
+ });
95
+ }
96
+ for (const [name, value] of Object.entries(parsed)) {
97
+ const ok = typeof value === 'string' || (typeof value === 'object' && value !== null);
98
+ if (!ok || Array.isArray(value)) {
99
+ throw new n8n_workflow_1.NodeOperationError(node, `Element "${name}" in Elements (JSON) must be a CSS selector or an object`, { itemIndex });
100
+ }
101
+ }
102
+ return parsed;
103
+ }
104
+ function elementFromRow(node, itemIndex, row, name) {
105
+ var _a, _b, _c;
106
+ const selector = String((_a = row.selector) !== null && _a !== void 0 ? _a : '').trim();
107
+ if (selector === '') {
108
+ throw new n8n_workflow_1.NodeOperationError(node, `Element "${name}" needs a CSS Selector`, { itemIndex });
109
+ }
110
+ const output = (_b = row.output) !== null && _b !== void 0 ? _b : 'text';
111
+ const all = row.all === true;
112
+ if (output === 'text' && !all)
113
+ return selector;
114
+ const spec = { selector };
115
+ if (output !== 'text')
116
+ spec.output = output;
117
+ if (output === 'attribute') {
118
+ const attribute = String((_c = row.attribute) !== null && _c !== void 0 ? _c : '').trim();
119
+ if (attribute === '') {
120
+ throw new n8n_workflow_1.NodeOperationError(node, `Element "${name}" needs an Attribute`, {
121
+ itemIndex,
122
+ description: 'Set the attribute to read, for example href.',
123
+ });
124
+ }
125
+ spec.attribute = attribute;
126
+ }
127
+ if (all)
128
+ spec.all = true;
129
+ return spec;
130
+ }
131
+ function buildElements(node, itemIndex, p) {
132
+ var _a, _b, _c, _d, _e;
133
+ const elements = {};
134
+ const twice = (name) => new n8n_workflow_1.NodeOperationError(node, `Element name "${name}" is used twice`, {
135
+ itemIndex,
136
+ description: 'Each name in Elements and Elements (JSON) must be different.',
137
+ });
138
+ for (const row of (_b = (_a = p.elements) === null || _a === void 0 ? void 0 : _a.element) !== null && _b !== void 0 ? _b : []) {
139
+ const name = String((_c = row.name) !== null && _c !== void 0 ? _c : '').trim();
140
+ if (name === '' && String((_d = row.selector) !== null && _d !== void 0 ? _d : '').trim() === '')
141
+ continue;
142
+ if (name === '') {
143
+ throw new n8n_workflow_1.NodeOperationError(node, 'Each Element needs a Name', { itemIndex });
144
+ }
145
+ if (Object.prototype.hasOwnProperty.call(elements, name))
146
+ throw twice(name);
147
+ elements[name] = elementFromRow(node, itemIndex, row, name);
148
+ }
149
+ const fromJson = parseElementsJson(node, itemIndex, (_e = p.options) === null || _e === void 0 ? void 0 : _e.elementsJson);
150
+ for (const [name, value] of Object.entries(fromJson)) {
151
+ if (Object.prototype.hasOwnProperty.call(elements, name))
152
+ throw twice(name);
153
+ elements[name] = value;
154
+ }
155
+ return Object.keys(elements).length > 0 ? elements : undefined;
156
+ }
70
157
  function buildScrapeBody(node, itemIndex, p) {
71
158
  var _a, _b, _c, _d, _e;
72
159
  const chosen = new Set((_a = p.extract) !== null && _a !== void 0 ? _a : []);
73
- if (chosen.size === 0 && p.screenshotType === 'none') {
74
- throw new n8n_workflow_1.NodeOperationError(node, 'Pick at least one Extract output or a Screenshot', {
160
+ const elements = buildElements(node, itemIndex, p);
161
+ if (chosen.size === 0 && !elements && p.screenshotType === 'none') {
162
+ throw new n8n_workflow_1.NodeOperationError(node, 'Pick at least one Extract output, an Element or a Screenshot', {
75
163
  itemIndex,
76
164
  description: 'The request would ask for nothing.',
77
165
  });
@@ -79,6 +167,8 @@ function buildScrapeBody(node, itemIndex, p) {
79
167
  const extract = {};
80
168
  for (const key of exports.EXTRACT_KEYS)
81
169
  extract[key] = chosen.has(key);
170
+ if (elements)
171
+ extract.elements = elements;
82
172
  const screenshot = buildScreenshot(node, itemIndex, p);
83
173
  if (screenshot)
84
174
  extract.screenshot = screenshot;
@@ -117,17 +207,8 @@ function parseMetadata(node, itemIndex, raw) {
117
207
  return undefined;
118
208
  if (typeof raw === 'object')
119
209
  return raw;
120
- let parsed;
121
- let ok = true;
122
- try {
123
- parsed = JSON.parse(raw);
124
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
125
- ok = false;
126
- }
127
- catch {
128
- ok = false;
129
- }
130
- if (!ok)
210
+ const parsed = parseJsonText(node, itemIndex, raw, 'Webhook Metadata');
211
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
131
212
  throw new n8n_workflow_1.NodeOperationError(node, 'Webhook Metadata must be a JSON object', { itemIndex });
132
213
  return parsed;
133
214
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scrapeFields = exports.scrapeOperations = void 0;
4
4
  const shared_1 = require("./shared");
5
+ const ELEMENTS_DOCS = 'Selector rules and limits: <a href="https://crawlbrulee.com/docs/scrape/elements">elements docs</a>';
5
6
  const resourceShow = { resource: ['scrape'] };
6
7
  const urlShow = { resource: ['scrape'], operation: ['scrape', 'scrapeAsync'] };
7
8
  const screenshotShow = { ...urlShow, screenshotType: ['full_page', 'viewport'] };
@@ -87,6 +88,72 @@ exports.scrapeFields = [
87
88
  description: 'Which outputs to return',
88
89
  displayOptions: { show: urlShow },
89
90
  },
91
+ {
92
+ displayName: 'Elements',
93
+ name: 'elements',
94
+ type: 'fixedCollection',
95
+ typeOptions: { multipleValues: true },
96
+ placeholder: 'Add Element',
97
+ default: {},
98
+ description: `Values to read from the page by CSS selector. They come back in elements, under the names you give. No extra credits. ${ELEMENTS_DOCS}.`,
99
+ displayOptions: { show: urlShow },
100
+ options: [
101
+ {
102
+ name: 'element',
103
+ displayName: 'Element',
104
+ values: [
105
+ {
106
+ displayName: 'All Matches',
107
+ name: 'all',
108
+ type: 'boolean',
109
+ default: false,
110
+ description: 'Whether to return every match as a list. Off returns the first match, or null when nothing matches.',
111
+ },
112
+ {
113
+ displayName: 'Attribute',
114
+ name: 'attribute',
115
+ type: 'string',
116
+ default: '',
117
+ placeholder: 'e.g. href',
118
+ description: 'The attribute to read',
119
+ displayOptions: { show: { output: ['attribute'] } },
120
+ },
121
+ {
122
+ displayName: 'CSS Selector',
123
+ name: 'selector',
124
+ type: 'string',
125
+ default: '',
126
+ placeholder: 'e.g. .price',
127
+ description: 'Which element on the page to read',
128
+ },
129
+ {
130
+ displayName: 'Name',
131
+ name: 'name',
132
+ type: 'string',
133
+ default: '',
134
+ placeholder: 'e.g. price',
135
+ description: 'The key this value gets in elements',
136
+ },
137
+ {
138
+ displayName: 'Output',
139
+ name: 'output',
140
+ type: 'options',
141
+ options: [
142
+ {
143
+ name: 'Attribute',
144
+ value: 'attribute',
145
+ description: 'One attribute, like href. href and src come back as full URLs.',
146
+ },
147
+ { name: 'HTML', value: 'html', description: 'The HTML of the element itself' },
148
+ { name: 'Text', value: 'text', description: 'The text inside the element' },
149
+ ],
150
+ default: 'text',
151
+ description: 'What to read from each match',
152
+ },
153
+ ],
154
+ },
155
+ ],
156
+ },
90
157
  {
91
158
  displayName: 'Screenshot',
92
159
  name: 'screenshotType',
@@ -219,6 +286,14 @@ exports.scrapeFields = [
219
286
  options: [
220
287
  (0, shared_1.cacheMaxAgeOption)('2 days'),
221
288
  shared_1.countryOption,
289
+ {
290
+ displayName: 'Elements (JSON)',
291
+ name: 'elementsJson',
292
+ type: 'json',
293
+ default: '',
294
+ placeholder: 'e.g. {"books": {"selector": "article.product_pod", "all": true, "fields": {"title": {"selector": "h3 a", "output": "attribute", "attribute": "title"}, "price": ".price_color"}}}',
295
+ description: `Elements as a JSON object, merged with the Elements list. Use it for fields, which read values inside each match, like a title and price per product card. ${ELEMENTS_DOCS}.`,
296
+ },
222
297
  {
223
298
  displayName: 'Exclude Selectors',
224
299
  name: 'excludeSelectors',
@@ -226,7 +301,7 @@ exports.scrapeFields = [
226
301
  typeOptions: { multipleValues: true, multipleValueButtonText: 'Add Selector' },
227
302
  default: [],
228
303
  placeholder: 'e.g. .cookie-banner',
229
- description: 'CSS selectors removed before anything is extracted. Sending any skips the cache.',
304
+ description: 'CSS selectors removed before anything is extracted, Elements included, except Raw HTML. Requests with different selectors do not share cached results.',
230
305
  },
231
306
  shared_1.localeOption,
232
307
  shared_1.proxyOption,
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.USER_AGENT = void 0;
4
4
  exports.crawlbruleeRequest = crawlbruleeRequest;
5
5
  const errors_1 = require("./errors");
6
- exports.USER_AGENT = 'n8n-nodes-crawlbrulee/0.3.1';
6
+ exports.USER_AGENT = 'n8n-nodes-crawlbrulee/1.0.1';
7
7
  function stripTrailingSlash(url) {
8
8
  return url.replace(/\/+$/, '');
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-crawlbrulee",
3
- "version": "0.3.1",
3
+ "version": "1.0.1",
4
4
  "description": "crawlbrulee for n8n — EU-native web scraping for AI agents & developers. scrape any url to markdown, html, links, images, metadata or a screenshot, map a site's urls, and react to finished background scrapes.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://crawlbrulee.com",
@@ -57,7 +57,7 @@
57
57
  ]
58
58
  },
59
59
  "devDependencies": {
60
- "@crawlbrulee/sdk": "^1.2.1",
60
+ "@crawlbrulee/sdk": "^1.3.0",
61
61
  "@n8n/node-cli": "0.48.6",
62
62
  "@types/node": "^22.0.0",
63
63
  "eslint": "9.32.0",