n8n-nodes-script-runner 1.7.0 → 1.8.0

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.
@@ -6,71 +6,145 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LinkedInFetcher = void 0;
7
7
  const n8n_workflow_1 = require("n8n-workflow");
8
8
  const axios_1 = __importDefault(require("axios"));
9
- function randomDelay(min = 45, max = 90) {
10
- return Math.floor(Math.random() * (max - min + 1)) + min;
9
+ function randomDelayMs(minSeconds = 45, maxSeconds = 90) {
10
+ return Math.floor(Math.random() * (maxSeconds - minSeconds + 1) + minSeconds) * 1000;
11
11
  }
12
- // Fetch function with cursor loop + total limit + error tracking + optional start cursor
13
- async function fetchLinkedIn(accountId, url, totalLimit, apiKey, minDelay, maxDelay, cursor = null, items = []) {
14
- const limit = 100;
15
- let last_page_coursor = cursor;
16
- // STOP if we already collected enough
17
- if (items.length >= totalLimit) {
18
- return { items: items.slice(0, totalLimit), lastFailedCursor: null, error: null };
12
+ async function fetchLinkedIn(accountId, url, totalLimit, apiKey, minDelaySec, maxDelaySec, startCursor = null, collected = [], seenIds = new Set(), consecutiveEmptyPages = 0, pageCount = 0) {
13
+ const MAX_PAGES = 25;
14
+ const MAX_CONSECUTIVE_EMPTY = 1;
15
+ const PAGE_SIZE = 100;
16
+ if (collected.length >= totalLimit || pageCount >= MAX_PAGES) {
17
+ this.logger?.info?.(`Limit reached: ${collected.length} items | ${pageCount} pages`);
18
+ return {
19
+ items: collected,
20
+ nextCursor: null,
21
+ lastFailedCursor: null,
22
+ estimatedTotal: null,
23
+ error: null,
24
+ totalFetched: collected.length,
25
+ };
19
26
  }
20
27
  try {
28
+ const queryParams = new URLSearchParams({
29
+ limit: PAGE_SIZE.toString(),
30
+ account_id: accountId,
31
+ });
21
32
  const config = {
22
33
  method: 'POST',
23
- url: `https://api15.unipile.com:14554/api/v1/linkedin/search?limit=${limit}&account_id=${accountId}${cursor ? `&cursor=${cursor}` : ''}`,
34
+ url: `https://api15.unipile.com:14554/api/v1/linkedin/search?${queryParams.toString()}`,
24
35
  headers: {
25
36
  'X-API-KEY': apiKey,
26
- 'accept': 'application/json',
27
- 'Content-Type': 'application/json'
37
+ 'Accept': 'application/json',
38
+ 'Content-Type': 'application/json',
28
39
  },
29
- data: { url }
40
+ data: startCursor ? { cursor: startCursor } : { url },
41
+ timeout: 60000, // 60 seconds
30
42
  };
43
+ this.logger.info(`Fetching page ${pageCount + 1} | cursor: ${startCursor ?? 'initial'} | ` +
44
+ `collected: ${collected.length}/${totalLimit}`);
31
45
  const response = await (0, axios_1.default)(config);
32
46
  const data = response.data;
33
- // Add items (don't exceed totalLimit)
34
- if (data.items?.length) {
35
- items.push(...data.items);
47
+ // Early return if page_count is zero
48
+ if (data.paging?.page_count === 0) {
49
+ this.logger.info(`page_count is 0, no results available`);
50
+ return {
51
+ items: collected,
52
+ nextCursor: null,
53
+ lastFailedCursor: null,
54
+ estimatedTotal: 0,
55
+ error: null,
56
+ totalFetched: collected.length,
57
+ };
36
58
  }
37
- if (!data.cursor) {
38
- return { items: items.slice(0, totalLimit), lastFailedCursor: last_page_coursor, error: null };
59
+ const beforeLength = collected.length;
60
+ // Deduplicate and collect
61
+ if (Array.isArray(data.items)) {
62
+ for (const item of data.items) {
63
+ if (item?.id && typeof item.id === 'string' && !seenIds.has(item.id)) {
64
+ seenIds.add(item.id);
65
+ collected.push(item);
66
+ if (collected.length >= totalLimit)
67
+ break;
68
+ }
69
+ }
70
+ }
71
+ const addedThisPage = collected.length - beforeLength;
72
+ const nextCursor = data.cursor ?? null;
73
+ const estimatedTotal = data.paging?.total_count ?? null;
74
+ if (!nextCursor) {
75
+ this.logger.info(`No more cursor. Total collected: ${collected.length}`);
76
+ return {
77
+ items: collected,
78
+ nextCursor: null,
79
+ lastFailedCursor: null,
80
+ estimatedTotal,
81
+ error: null,
82
+ totalFetched: collected.length,
83
+ };
84
+ }
85
+ if (addedThisPage === 0) {
86
+ consecutiveEmptyPages++;
87
+ if (consecutiveEmptyPages >= MAX_CONSECUTIVE_EMPTY) {
88
+ this.logger.warn(`Stopping: ${consecutiveEmptyPages} consecutive empty pages`);
89
+ return {
90
+ items: collected,
91
+ nextCursor: null,
92
+ lastFailedCursor: startCursor,
93
+ estimatedTotal,
94
+ error: { message: 'Too many consecutive empty pages from API' },
95
+ totalFetched: collected.length,
96
+ };
97
+ }
39
98
  }
40
- // Continue ONLY if cursor exists and we still need more items
41
- if (data.cursor && items.length < totalLimit) {
42
- const delay = randomDelay(minDelay, maxDelay);
43
- console.log(`Fetched ${items.length} items, waiting ${delay}s before next request...`);
44
- last_page_coursor = data.cursor;
45
- console.log('Next cursor:', data.cursor);
46
- await new Promise(res => setTimeout(res, delay * 1000));
47
- return fetchLinkedIn(accountId, url, totalLimit, apiKey, minDelay, maxDelay, data.cursor, items);
99
+ else {
100
+ consecutiveEmptyPages = 0;
101
+ }
102
+ // Continue only if needed
103
+ if (collected.length < totalLimit) {
104
+ const delayMs = randomDelayMs(minDelaySec, maxDelaySec);
105
+ this.logger.info(`Added ${addedThisPage} new items total ${collected.length}/${totalLimit}. ` +
106
+ `Waiting ~${Math.round(delayMs / 1000)} seconds...`);
107
+ await new Promise(resolve => setTimeout(resolve, delayMs));
108
+ return fetchLinkedIn.call(this, accountId, url, totalLimit, apiKey, minDelaySec, maxDelaySec, nextCursor, collected, seenIds, consecutiveEmptyPages, pageCount + 1);
48
109
  }
49
110
  }
50
- catch (error) {
51
- console.warn('Error fetching page, returning collected data so far.');
52
- console.warn('Failed cursor/page:', cursor);
111
+ catch (err) {
112
+ const error = err;
113
+ const status = error?.response?.status ?? null;
114
+ const message = error.message || 'Unknown fetch error';
115
+ this.logger.warn(`Page fetch failed (cursor: ${startCursor ?? 'initial'}): ${message} (status: ${status})`);
53
116
  return {
54
- items: items.slice(0, totalLimit),
55
- lastFailedCursor: cursor,
117
+ items: collected,
118
+ nextCursor: null,
119
+ lastFailedCursor: startCursor,
120
+ estimatedTotal: null,
56
121
  error: {
57
- code: error.code || null,
58
- status: error.response?.status || null,
59
- message: error.message || 'Unknown error'
60
- }
122
+ code: error?.code ?? null,
123
+ status,
124
+ message,
125
+ },
126
+ totalFetched: collected.length,
61
127
  };
62
128
  }
63
- return { items: items.slice(0, totalLimit), lastFailedCursor: last_page_coursor, error: null };
129
+ // Fallback (should rarely reach here)
130
+ return {
131
+ items: collected,
132
+ nextCursor: null,
133
+ lastFailedCursor: startCursor,
134
+ estimatedTotal: null,
135
+ error: null,
136
+ totalFetched: collected.length,
137
+ };
64
138
  }
65
139
  class LinkedInFetcher {
66
140
  constructor() {
67
141
  this.description = {
68
- displayName: 'LinkedIn Fetcher',
142
+ displayName: 'LinkedIn Fetcher (Unipile)',
69
143
  name: 'linkedInFetcher',
70
144
  icon: 'file:linkedin.svg',
71
145
  group: ['transform'],
72
146
  version: 1,
73
- description: 'Fetch LinkedIn data with pagination and error handling',
147
+ description: 'Fetch people / company results from LinkedIn via Unipile API (max 2500 default)',
74
148
  defaults: {
75
149
  name: 'LinkedIn Fetcher',
76
150
  },
@@ -82,60 +156,55 @@ class LinkedInFetcher {
82
156
  name: 'accountId',
83
157
  type: 'string',
84
158
  default: '',
85
- placeholder: 'Enter account ID',
86
- description: 'The LinkedIn account ID to use for the API',
159
+ placeholder: 'e.g. x4SmGz5ASq6pD66Gn0rPxA',
87
160
  required: true,
88
161
  },
89
162
  {
90
- displayName: 'URL',
163
+ displayName: 'LinkedIn URL',
91
164
  name: 'url',
92
165
  type: 'string',
93
166
  default: '',
94
- placeholder: 'Enter LinkedIn URL',
95
- description: 'The LinkedIn URL to fetch data from',
167
+ placeholder: 'https://www.linkedin.com/sales/search/people?...',
168
+ description: 'Full LinkedIn search, profile, or company page URL',
96
169
  required: true,
97
170
  },
98
171
  {
99
- displayName: 'Total Items to Scrape',
172
+ displayName: 'Max Items to Fetch',
100
173
  name: 'total',
101
174
  type: 'number',
102
- default: 100,
103
- description: 'Maximum number of items to fetch',
175
+ default: 2500,
176
+ typeOptions: { minValue: 1, maxValue: 10000 },
177
+ description: 'Maximum number of records to collect (after deduplication)',
104
178
  required: true,
105
179
  },
106
180
  {
107
181
  displayName: 'API Key',
108
182
  name: 'apiKey',
109
183
  type: 'string',
110
- typeOptions: {
111
- password: true,
112
- },
184
+ typeOptions: { password: true },
113
185
  default: '',
114
- placeholder: 'Enter API key',
115
- description: 'The Unipile API key for authentication',
116
186
  required: true,
117
187
  },
118
188
  {
119
- displayName: 'Start Cursor',
189
+ displayName: 'Resume From Cursor',
120
190
  name: 'startCursor',
121
191
  type: 'string',
122
192
  default: '',
123
- placeholder: 'Optional start cursor',
124
- description: 'Optional cursor to resume from a previous fetch',
193
+ placeholder: '(optional) Paste cursor from previous run',
125
194
  },
126
195
  {
127
- displayName: 'Min Delay (seconds)',
196
+ displayName: 'Min Delay Between Requests (seconds)',
128
197
  name: 'minDelay',
129
198
  type: 'number',
130
199
  default: 45,
131
- description: 'Minimum delay between requests in seconds',
200
+ typeOptions: { minValue: 10 },
132
201
  },
133
202
  {
134
- displayName: 'Max Delay (seconds)',
203
+ displayName: 'Max Delay Between Requests (seconds)',
135
204
  name: 'maxDelay',
136
205
  type: 'number',
137
206
  default: 90,
138
- description: 'Maximum delay between requests in seconds',
207
+ typeOptions: { minValue: 30 },
139
208
  },
140
209
  ],
141
210
  };
@@ -146,42 +215,42 @@ class LinkedInFetcher {
146
215
  for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
147
216
  try {
148
217
  const accountId = this.getNodeParameter('accountId', itemIndex);
149
- const url = this.getNodeParameter('url', itemIndex);
218
+ const url = this.getNodeParameter('url', itemIndex).trim();
150
219
  const total = this.getNodeParameter('total', itemIndex);
151
220
  const apiKey = this.getNodeParameter('apiKey', itemIndex);
152
- const startCursor = this.getNodeParameter('startCursor', itemIndex, '');
221
+ let startCursor = this.getNodeParameter('startCursor', itemIndex, '').trim();
153
222
  const minDelay = this.getNodeParameter('minDelay', itemIndex);
154
223
  const maxDelay = this.getNodeParameter('maxDelay', itemIndex);
155
- // Fetch data - treat empty string cursor as null
156
- const { items: allItems, lastFailedCursor, error } = await fetchLinkedIn(accountId, url, total, apiKey, minDelay, maxDelay, startCursor && startCursor.trim() !== '' ? startCursor : null);
157
- // Remove duplicates by `id`
158
- const uniqueItems = [
159
- ...new Map(allItems.map(i => [i.id, i])).values()
160
- ];
161
- // Final output includes last failed cursor and error info
224
+ if (!url) {
225
+ throw new Error('LinkedIn URL is required');
226
+ }
227
+ if (startCursor.trim() === '' || startCursor.trim() === 'null')
228
+ startCursor = null;
229
+ const result = await fetchLinkedIn.call(this, accountId, url, total, apiKey, minDelay, maxDelay, startCursor);
162
230
  returnData.push({
163
231
  json: {
164
- result: uniqueItems,
165
- lastFailedCursor,
166
- error,
167
- totalFetched: uniqueItems.length,
232
+ result: result.items,
233
+ nextCursor: result.nextCursor,
234
+ lastFailedCursor: result.lastFailedCursor,
235
+ estimatedTotal: result.estimatedTotal,
236
+ error: result.error,
237
+ totalFetched: result.totalFetched,
238
+ requestedLimit: total,
239
+ pagesUsed: Math.ceil(result.totalFetched / 100),
168
240
  },
169
- pairedItem: itemIndex,
241
+ pairedItem: { item: itemIndex },
170
242
  });
171
243
  }
172
- catch (error) {
244
+ catch (err) {
245
+ const error = err;
173
246
  if (this.continueOnFail()) {
174
247
  returnData.push({
175
- json: {
176
- error: error.message,
177
- },
178
- pairedItem: itemIndex,
248
+ json: { error: error.message || 'Execution failed' },
249
+ pairedItem: { item: itemIndex },
179
250
  });
180
251
  continue;
181
252
  }
182
- throw new n8n_workflow_1.NodeOperationError(this.getNode(), error, {
183
- itemIndex,
184
- });
253
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), error, { itemIndex });
185
254
  }
186
255
  }
187
256
  return [returnData];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-script-runner",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Custom n8n nodes for script execution (jsdom, cheerio) and HTTP requests (axios, fetch)",
5
5
  "main": "index.js",
6
6
  "scripts": {