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