figranium 0.9.6 → 0.10.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.
package/dist/index.html CHANGED
@@ -16,7 +16,7 @@
16
16
  <link
17
17
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
18
18
  rel="stylesheet" />
19
- <script type="module" crossorigin src="/assets/index-Bkr74C53.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BwaOqbmy.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index--OZi5-p_.css">
21
21
  </head>
22
22
 
package/headful.js CHANGED
@@ -1,30 +1,19 @@
1
- const { chromium } = require('playwright');
1
+ const { chromium } = require('./stealth-chromium');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { getProxySelection } = require('./proxy-rotation');
5
5
  const { selectUserAgent } = require('./user-agent-settings');
6
6
  const { validateUrl } = require('./url-utils');
7
- const { parseBooleanFlag, cookieMatches } = require('./common-utils');
7
+ const { parseBooleanFlag } = require('./common-utils');
8
8
  const { Mutex } = require('./src/server/utils');
9
9
 
10
+ const HEADFUL_PROFILE_DIR = path.join(__dirname, 'data', 'browser-profile-headful');
11
+
10
12
  const headfulMutex = new Mutex();
11
13
 
12
14
  const EventEmitter = require('events');
13
15
  const headfulEventEmitter = new EventEmitter();
14
16
 
15
- const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
16
- const STORAGE_STATE_FILE = (() => {
17
- try {
18
- if (fs.existsSync(STORAGE_STATE_PATH)) {
19
- const stat = fs.statSync(STORAGE_STATE_PATH);
20
- if (stat.isDirectory()) {
21
- return path.join(STORAGE_STATE_PATH, 'storage_state.json');
22
- }
23
- }
24
- } catch { }
25
- return STORAGE_STATE_PATH;
26
- })();
27
-
28
17
  let activeSession = null;
29
18
 
30
19
  const teardownActiveSession = async () => {
@@ -32,11 +21,6 @@ const teardownActiveSession = async () => {
32
21
  try {
33
22
  if (activeSession.interval) clearInterval(activeSession.interval);
34
23
  } catch { }
35
- try {
36
- if (activeSession.context && !activeSession.stateless) {
37
- await activeSession.context.storageState({ path: STORAGE_STATE_FILE });
38
- }
39
- } catch { }
40
24
  try {
41
25
  if (activeSession.browser) {
42
26
  await activeSession.browser.close();
@@ -62,7 +46,7 @@ async function runHeadful(data, options = {}) {
62
46
 
63
47
  const inspectModeEnabled = true;
64
48
 
65
- activeSession = { status: 'starting', startedAt: Date.now(), stateless: statelessExecution, inspectModeEnabled };
49
+ activeSession = { status: 'starting', startedAt: Date.now(), inspectModeEnabled };
66
50
 
67
51
  const selectedUA = await selectUserAgent(false);
68
52
 
@@ -95,80 +79,47 @@ async function runHeadful(data, options = {}) {
95
79
  }
96
80
 
97
81
  if (!browser) {
98
- const launchOptions = {
99
- headless: false,
100
- args: [
101
- '--no-sandbox',
102
- '--disable-setuid-sandbox',
103
- '--disable-dev-shm-usage',
104
- '--disable-gpu',
105
- '--window-size=1920,1080',
106
- '--window-position=0,0',
107
- '--start-maximized'
108
- ]
109
- };
110
82
  const selection = getProxySelection(rotateProxies);
111
- if (selection.proxy) {
112
- launchOptions.proxy = selection.proxy;
83
+ const hasProxy = !!selection.proxy;
84
+
85
+ const args = [
86
+ '--no-sandbox',
87
+ '--disable-setuid-sandbox',
88
+ '--disable-dev-shm-usage',
89
+ '--disable-gpu',
90
+ '--window-size=1920,1080',
91
+ '--window-position=0,0',
92
+ '--start-maximized',
93
+ '--dns-prefetch-disable',
94
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
95
+ ];
96
+ if (!hasProxy) {
97
+ args.push(
98
+ '--enable-features=DnsOverHttps',
99
+ '--dns-over-https-mode=secure',
100
+ '--dns-over-https-templates=https://cloudflare-dns.com/dns-query'
101
+ );
113
102
  }
114
- browser = await chromium.launch(launchOptions);
115
103
 
116
104
  const contextOptions = {
117
105
  viewport: null,
118
106
  userAgent: selectedUA,
119
107
  locale: 'en-US',
120
- timezoneId: 'America/New_York'
108
+ timezoneId: 'America/New_York',
109
+ permissions: ['clipboard-read', 'clipboard-write'],
110
+ ...(selection.proxy ? { proxy: selection.proxy } : {})
121
111
  };
122
112
 
123
- if (!statelessExecution && fs.existsSync(STORAGE_STATE_FILE)) {
124
- contextOptions.storageState = STORAGE_STATE_FILE;
113
+ if (statelessExecution) {
114
+ browser = await chromium.launch({ headless: false, args, ...(selection.proxy ? { proxy: selection.proxy } : {}) });
115
+ context = await browser.newContext(contextOptions);
116
+ } else {
117
+ await fs.promises.mkdir(HEADFUL_PROFILE_DIR, { recursive: true });
118
+ context = await chromium.launchPersistentContext(HEADFUL_PROFILE_DIR, { headless: false, args, ...contextOptions });
119
+ browser = context.browser();
125
120
  }
126
-
127
- contextOptions.permissions = ['clipboard-read', 'clipboard-write'];
128
- context = await browser.newContext(contextOptions);
129
- }
130
-
131
- let preloadedCookies = [];
132
- if (fs.existsSync(STORAGE_STATE_FILE)) {
133
- try {
134
- const state = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
135
- preloadedCookies = state.cookies || [];
136
- } catch (e) { }
137
121
  }
138
122
 
139
- await context.route('**/*', async (route) => {
140
- const request = route.request();
141
- const requestUrl = request.url();
142
- const resourceType = request.resourceType();
143
-
144
- const isDataRequest = ['document', 'script', 'xhr', 'fetch'].includes(resourceType);
145
- if (isDataRequest && preloadedCookies.length > 0) {
146
- // ⚡ Bolt: Parse URL once to avoid redundant parsing inside cookieMatches filter loop
147
- const urlObj = new URL(requestUrl);
148
- const filteredCookies = preloadedCookies.filter(cookie => cookieMatches(cookie, urlObj));
149
- if (filteredCookies.length > 0) {
150
- const fileCookieMap = new Map();
151
- filteredCookies.forEach(c => fileCookieMap.set(c.name, c.value));
152
-
153
- const existingCookieHeader = request.headers()['cookie'] || '';
154
- const existingCookies = existingCookieHeader.split(';').filter(Boolean).map(s => s.trim());
155
-
156
- existingCookies.forEach(s => {
157
- const [name, ...valParts] = s.split('=');
158
- const val = valParts.join('=');
159
- if (!fileCookieMap.has(name)) {
160
- fileCookieMap.set(name, val);
161
- }
162
- });
163
-
164
- const cookieHeader = Array.from(fileCookieMap.entries()).map(([n, v]) => `${n}=${v}`).join('; ');
165
- const headers = { ...request.headers(), 'cookie': cookieHeader };
166
- return route.continue({ headers });
167
- }
168
- }
169
- route.continue();
170
- });
171
-
172
123
  const inspectInitFn = () => {
173
124
  Object.defineProperty(window, 'open', { writable: true, configurable: true, value: () => null });
174
125
  const handleLinkClick = (event) => {
@@ -461,7 +412,9 @@ async function runHeadful(data, options = {}) {
461
412
  });
462
413
 
463
414
  if (!page) {
464
- page = await context.newPage();
415
+ // Persistent context auto-creates a blank page; reuse it or open a new one
416
+ const existingPages = context.pages();
417
+ page = existingPages.length > 0 ? existingPages[0] : await context.newPage();
465
418
  try {
466
419
  const cdp = await context.newCDPSession(page);
467
420
  const { windowId } = await cdp.send('Browser.getWindowForTarget');
@@ -491,25 +444,13 @@ async function runHeadful(data, options = {}) {
491
444
  await page.goto(url).catch(() => { });
492
445
  }
493
446
 
494
- const saveState = async () => {
495
- if (statelessExecution) return;
496
- try {
497
- await context.storageState({ path: STORAGE_STATE_FILE });
498
- } catch (e) { }
499
- };
500
-
501
- const interval = setInterval(saveState, 10000);
502
- activeSession = { browser, context, page, interval, status: 'running', startedAt: activeSession.startedAt, stateless: statelessExecution, inspectModeEnabled: activeSession.inspectModeEnabled };
447
+ activeSession = { browser, context, page, status: 'running', startedAt: activeSession.startedAt, inspectModeEnabled: activeSession.inspectModeEnabled };
503
448
 
504
- page.on('close', async () => {
505
- clearInterval(interval);
506
- await saveState();
507
- });
449
+ page.on('close', async () => { });
508
450
 
509
451
  const responseData = {
510
452
  message: 'Headful session started.',
511
- userAgentUsed: selectedUA,
512
- path: statelessExecution ? null : STORAGE_STATE_FILE
453
+ userAgentUsed: selectedUA
513
454
  };
514
455
 
515
456
  if (res) {
@@ -517,8 +458,6 @@ async function runHeadful(data, options = {}) {
517
458
  }
518
459
 
519
460
  await new Promise((resolve) => browser.on('disconnected', resolve));
520
- clearInterval(interval);
521
- await saveState();
522
461
  activeSession = null;
523
462
  return responseData;
524
463
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figranium",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "main": "index.js",
5
5
  "bin": {
6
6
  "figranium": "bin/cli.js"
package/scrape.js CHANGED
@@ -1,4 +1,4 @@
1
- const { chromium } = require('playwright');
1
+ const { chromium } = require('./stealth-chromium');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { spawn } = require('child_process');
@@ -6,21 +6,10 @@ const { getProxySelection } = require('./proxy-rotation');
6
6
  const { selectUserAgent } = require('./user-agent-settings');
7
7
  const { formatHTML } = require('./html-utils');
8
8
  const { validateUrl } = require('./url-utils');
9
- const { parseBooleanFlag, sanitizeRunId, toCsvString, cookieMatches } = require('./common-utils');
9
+ const { parseBooleanFlag, sanitizeRunId, toCsvString } = require('./common-utils');
10
10
  const { installMouseHelper } = require('./src/agent/dom-utils');
11
11
 
12
- const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
13
- const STORAGE_STATE_FILE = (() => {
14
- try {
15
- if (fs.existsSync(STORAGE_STATE_PATH)) {
16
- const stat = fs.statSync(STORAGE_STATE_PATH);
17
- if (stat.isDirectory()) {
18
- return path.join(STORAGE_STATE_PATH, 'storage_state.json');
19
- }
20
- }
21
- } catch { }
22
- return STORAGE_STATE_PATH;
23
- })();
12
+ const PROFILE_DIR = path.join(__dirname, 'data', 'browser-profile-scrape');
24
13
 
25
14
  async function runScrape(data) {
26
15
  const url = data.url;
@@ -58,32 +47,38 @@ async function runScrape(data) {
58
47
  let context;
59
48
  let page;
60
49
  try {
61
- const launchOptions = {
62
- headless: true,
63
- args: [
64
- '--no-sandbox',
65
- '--disable-setuid-sandbox',
66
- '--disable-dev-shm-usage',
67
- '--disable-blink-features=AutomationControlled',
68
- '--hide-scrollbars',
69
- '--mute-audio'
70
- ]
71
- };
72
50
  const selection = getProxySelection(rotateProxies);
73
- if (selection.proxy) {
74
- launchOptions.proxy = selection.proxy;
51
+ const hasProxy = !!selection.proxy;
52
+
53
+ const args = [
54
+ '--no-sandbox',
55
+ '--disable-setuid-sandbox',
56
+ '--disable-dev-shm-usage',
57
+ '--disable-blink-features=AutomationControlled',
58
+ '--hide-scrollbars',
59
+ '--mute-audio',
60
+ '--dns-prefetch-disable',
61
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
62
+ ];
63
+ if (!hasProxy) {
64
+ args.push(
65
+ '--enable-features=DnsOverHttps',
66
+ '--dns-over-https-mode=secure',
67
+ '--dns-over-https-templates=https://cloudflare-dns.com/dns-query'
68
+ );
75
69
  }
76
70
 
77
- browser = await chromium.launch(launchOptions);
78
-
79
71
  const recordingsDir = path.join(__dirname, 'data', 'recordings');
80
72
  await fs.promises.mkdir(recordingsDir, { recursive: true });
73
+ await fs.promises.mkdir(PROFILE_DIR, { recursive: true });
81
74
 
82
75
  const viewport = rotateViewport
83
76
  ? { width: 1280 + Math.floor(Math.random() * 640), height: 720 + Math.floor(Math.random() * 360) }
84
77
  : { width: 1366, height: 768 };
85
78
 
86
79
  const contextOptions = {
80
+ headless: true,
81
+ args,
87
82
  userAgent: selectedUA,
88
83
  extraHTTPHeaders: customHeaders,
89
84
  viewport,
@@ -94,61 +89,24 @@ async function runScrape(data) {
94
89
  permissions: ['geolocation']
95
90
  };
96
91
 
97
- const shouldUseStorageState = !statelessExecution && await fs.promises.access(STORAGE_STATE_FILE).then(() => true).catch(() => false);
98
- if (shouldUseStorageState) {
99
- contextOptions.storageState = STORAGE_STATE_FILE;
92
+ if (selection.proxy) {
93
+ contextOptions.proxy = selection.proxy;
100
94
  }
101
95
 
102
96
  if (!disableRecording) {
103
97
  contextOptions.recordVideo = { dir: recordingsDir, size: viewport };
104
98
  }
105
99
 
106
- context = await browser.newContext(contextOptions);
107
-
108
- let preloadedCookies = [];
109
- if (!statelessExecution && fs.existsSync(STORAGE_STATE_FILE)) {
110
- try {
111
- const state = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
112
- preloadedCookies = state.cookies || [];
113
- } catch (e) { }
100
+ if (statelessExecution) {
101
+ const launchOpts = { headless: true, args, ...(selection.proxy ? { proxy: selection.proxy } : {}) };
102
+ browser = await chromium.launch(launchOpts);
103
+ context = await browser.newContext(contextOptions);
104
+ } else {
105
+ await fs.promises.mkdir(PROFILE_DIR, { recursive: true });
106
+ context = await chromium.launchPersistentContext(PROFILE_DIR, { headless: true, args, ...contextOptions });
107
+ browser = context.browser();
114
108
  }
115
109
 
116
- await context.route('**/*', async (route) => {
117
- const request = route.request();
118
- const requestUrl = request.url();
119
- const resourceType = request.resourceType();
120
-
121
- const isDataRequest = ['document', 'script', 'xhr', 'fetch'].includes(resourceType);
122
- if (isDataRequest && preloadedCookies.length > 0) {
123
- // ⚡ Bolt: Parse URL once to avoid redundant parsing inside cookieMatches filter loop
124
- const urlObj = new URL(requestUrl);
125
- const filteredCookies = preloadedCookies.filter(cookie => cookieMatches(cookie, urlObj));
126
- if (filteredCookies.length > 0) {
127
- const fileCookieMap = new Map();
128
- filteredCookies.forEach(c => fileCookieMap.set(c.name, c.value));
129
-
130
- const existingCookieHeader = request.headers()['cookie'] || '';
131
- const existingCookies = existingCookieHeader.split(';').filter(Boolean).map(s => s.trim());
132
-
133
- existingCookies.forEach(s => {
134
- const [name, ...valParts] = s.split('=');
135
- const val = valParts.join('=');
136
- if (!fileCookieMap.has(name)) {
137
- fileCookieMap.set(name, val);
138
- }
139
- });
140
-
141
- const cookieHeader = Array.from(fileCookieMap.entries()).map(([n, v]) => `${n}=${v}`).join('; ');
142
- const headers = { ...request.headers(), 'cookie': cookieHeader };
143
- return route.continue({ headers });
144
- }
145
- }
146
- route.continue();
147
- });
148
-
149
- await context.addInitScript(() => {
150
- Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
151
- });
152
110
  await context.addInitScript(installMouseHelper);
153
111
 
154
112
  if (includeShadowDom) {
@@ -162,7 +120,9 @@ async function runScrape(data) {
162
120
  });
163
121
  }
164
122
 
165
- page = await context.newPage();
123
+ // Persistent context auto-creates a blank page; reuse it or open a new one
124
+ const existingPages = context.pages();
125
+ page = existingPages.length > 0 ? existingPages[0] : await context.newPage();
166
126
 
167
127
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
168
128
 
@@ -360,10 +320,6 @@ async function runScrape(data) {
360
320
  screenshot_url: `/captures/${screenshotName}`
361
321
  };
362
322
 
363
- if (!statelessExecution) {
364
- try { await context.storageState({ path: STORAGE_STATE_FILE }); } catch { }
365
- }
366
-
367
323
  const video = page.video();
368
324
  await context.close();
369
325
  if (video) {
@@ -389,12 +345,9 @@ async function runScrape(data) {
389
345
  }
390
346
  }
391
347
 
392
- await browser.close();
348
+ if (browser) await browser.close();
393
349
  return resultData;
394
350
  } catch (error) {
395
- if (context && !statelessExecution) {
396
- try { await context.storageState({ path: STORAGE_STATE_FILE }); } catch { }
397
- }
398
351
  if (context) await context.close();
399
352
  if (browser) await browser.close();
400
353
  throw error;
package/server.js CHANGED
@@ -5,6 +5,18 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const crypto = require('crypto');
7
7
 
8
+ // Catch unhandled promise rejections from playwright-extra stealth plugin.
9
+ // When pages close before the plugin finishes async CDP initialization,
10
+ // benign rejections bubble up and would otherwise crash the process.
11
+ process.on('unhandledRejection', (reason) => {
12
+ const msg = reason && reason.message ? reason.message : String(reason);
13
+ if (/Target page, context or browser has been closed/i.test(msg)) {
14
+ console.warn('[STEALTH] Suppressed benign rejection:', msg);
15
+ return;
16
+ }
17
+ console.error('Unhandled rejection:', reason);
18
+ });
19
+
8
20
  // Constants
9
21
  const {
10
22
  DEFAULT_PORT,
@@ -61,6 +73,7 @@ const viewRoutes = require('./src/server/routes/views');
61
73
  const scheduleRoutes = require('./src/server/routes/schedules');
62
74
  const credentialRoutes = require('./src/server/routes/credentials');
63
75
  const { pushOutput } = require('./src/server/outputProviders');
76
+ const { migrateStorageState } = require('./src/server/migrate-storage');
64
77
 
65
78
  const app = express();
66
79
  app.disable('x-powered-by');
@@ -472,6 +485,9 @@ findAvailablePort(port, 20)
472
485
  const displayPort = typeof address === 'object' && address ? address.port : availablePort;
473
486
  console.log(`Server running at http://localhost:${displayPort}`);
474
487
 
488
+ // One-time migration of storage_state.json cookies into persistent browser profiles
489
+ migrateStorageState().catch(err => console.error('[MIGRATION] Failed:', err.message));
490
+
475
491
  // Start the cron scheduler
476
492
  const { startScheduler } = require('./src/server/scheduler');
477
493
  startScheduler().catch(err => console.error('[SCHEDULER] Failed to start:', err.message));
@@ -4,9 +4,9 @@ const path = require('path');
4
4
  const USER_AGENT_FILE = path.join(__dirname, 'data', 'user_agent.json');
5
5
  const DEFAULT_SELECTION = 'system';
6
6
  const userAgents = [
7
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
8
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
9
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
7
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
8
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
9
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
10
10
  ];
11
11
 
12
12
  let cached = { mtimeMs: 0, config: { selection: DEFAULT_SELECTION } };