@imenam/simple-scraper 1.0.23 → 1.0.27

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.js CHANGED
@@ -9,10 +9,12 @@ import { fileURLToPath } from 'url';
9
9
  import { z } from 'zod';
10
10
  import { setupLogging } from './logger.js';
11
11
  import { getBrowser, closeBrowser, getDefaultTimeout } from './browser.js';
12
- import { loadAllCookies } from './cookies.js';
12
+ import { scheduleJarSync, startPeriodicJarSync } from './cookie-sync.js';
13
+ import { getDataDir } from './data-dir.js';
14
+ import { startKeepAlive } from './keep-alive.js';
13
15
  import { GuiLauncher, reconnectGuiToolDefinition } from '@imenam/mcp-gui-interface';
14
16
  import { createSession, getSession, closeSession, closeAllSessions, listSessions, getSessionLogs, clearSessionLogs, resolveViewport } from './sessions.js';
15
- import { registerSessionRpc } from './session-rpc.js';
17
+ import { registerRpc } from './rpc.js';
16
18
  import { deliverFile, RELAY_SHAPE_DESCRIPTIONS } from './relay.js';
17
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
20
  const rootDir = path.resolve(__dirname, '..');
@@ -62,22 +64,6 @@ function toViewportOverride(v) {
62
64
  return undefined;
63
65
  return { width: v.width, height: v.height, deviceScaleFactor: v.device_scale_factor };
64
66
  }
65
- // ─── One-shot helpers ─────────────────────────────────────────────────────────
66
- async function setCookies(page, cookies, pageUrl) {
67
- const prepared = cookies.map(c => {
68
- if (!c.domain) {
69
- return { ...c, url: pageUrl };
70
- }
71
- return c;
72
- });
73
- await page.setCookie(...prepared);
74
- }
75
- async function applyCookies(page, pageUrl) {
76
- const cookies = await loadAllCookies();
77
- if (cookies.length) {
78
- await setCookies(page, cookies, pageUrl);
79
- }
80
- }
81
67
  // ─── Page-level helpers (shared between one-shot and session tools) ───────────
82
68
  async function extractInputs(page, opts) {
83
69
  return page.evaluate(({ selector: sel, showHidden }) => {
@@ -211,7 +197,6 @@ server.tool('scrape_page', 'Navigate to a URL using a headless browser and retur
211
197
  const browser = await getBrowser();
212
198
  const page = await browser.newPage();
213
199
  try {
214
- await applyCookies(page, url);
215
200
  await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
216
201
  if (wait_for) {
217
202
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
@@ -225,6 +210,7 @@ server.tool('scrape_page', 'Navigate to a URL using a headless browser and retur
225
210
  }
226
211
  finally {
227
212
  await page.close();
213
+ scheduleJarSync();
228
214
  }
229
215
  });
230
216
  // ─── reconnect_gui ──────────────────────────────────────────────────────────────
@@ -250,7 +236,6 @@ server.tool('execute_js', 'Navigate to a URL and execute custom JavaScript in th
250
236
  const browser = await getBrowser();
251
237
  const page = await browser.newPage();
252
238
  try {
253
- await applyCookies(page, url);
254
239
  await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
255
240
  if (wait_for) {
256
241
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
@@ -269,6 +254,7 @@ server.tool('execute_js', 'Navigate to a URL and execute custom JavaScript in th
269
254
  }
270
255
  finally {
271
256
  await page.close();
257
+ scheduleJarSync();
272
258
  }
273
259
  });
274
260
  // ─── get_page_inputs ──────────────────────────────────────────────────────────
@@ -286,7 +272,7 @@ server.tool('get_page_inputs', 'Return a structured JSON object of all form inpu
286
272
  }
287
273
  if (session_id) {
288
274
  try {
289
- const page = getSession(session_id);
275
+ const page = getSession(session_id, 'get_page_inputs');
290
276
  const result = await extractInputs(page, { selector, showHidden: show_hidden ?? false });
291
277
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
292
278
  }
@@ -298,7 +284,6 @@ server.tool('get_page_inputs', 'Return a structured JSON object of all form inpu
298
284
  const browser = await getBrowser();
299
285
  const page = await browser.newPage();
300
286
  try {
301
- await applyCookies(page, url);
302
287
  await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
303
288
  if (wait_for) {
304
289
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
@@ -312,6 +297,7 @@ server.tool('get_page_inputs', 'Return a structured JSON object of all form inpu
312
297
  }
313
298
  finally {
314
299
  await page.close();
300
+ scheduleJarSync();
315
301
  }
316
302
  });
317
303
  // ─── get_show_page ────────────────────────────────────────────────────────────
@@ -333,7 +319,6 @@ server.tool('get_show_page', 'Navigate to a URL and automatically parse a show/d
333
319
  const browser = await getBrowser();
334
320
  const page = await browser.newPage();
335
321
  try {
336
- await applyCookies(page, url);
337
322
  await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
338
323
  if (wait_for) {
339
324
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
@@ -351,6 +336,7 @@ server.tool('get_show_page', 'Navigate to a URL and automatically parse a show/d
351
336
  }
352
337
  finally {
353
338
  await page.close();
339
+ scheduleJarSync();
354
340
  }
355
341
  });
356
342
  // ─── open_session ─────────────────────────────────────────────────────────────
@@ -400,7 +386,7 @@ server.tool('session_goto', 'Navigate the browser session to a new URL without c
400
386
  }, async ({ session_id, url, wait_for, timeout }) => {
401
387
  const effectiveTimeout = timeout ?? getDefaultTimeout();
402
388
  try {
403
- const page = getSession(session_id);
389
+ const page = getSession(session_id, 'session_goto');
404
390
  await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
405
391
  if (wait_for) {
406
392
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
@@ -420,7 +406,7 @@ server.tool('session_click', 'Click on an element in the browser session identif
420
406
  }, async ({ session_id, selector, timeout }) => {
421
407
  const effectiveTimeout = timeout ?? getDefaultTimeout();
422
408
  try {
423
- const page = getSession(session_id);
409
+ const page = getSession(session_id, 'session_click');
424
410
  await page.waitForSelector(selector, { timeout: effectiveTimeout });
425
411
  await page.click(selector);
426
412
  return { content: [{ type: 'text', text: `Clicked "${selector}"` }] };
@@ -440,7 +426,7 @@ server.tool('session_type', 'Type text into an input element in the browser sess
440
426
  }, async ({ session_id, selector, text, clear, timeout }) => {
441
427
  const effectiveTimeout = timeout ?? getDefaultTimeout();
442
428
  try {
443
- const page = getSession(session_id);
429
+ const page = getSession(session_id, 'session_type');
444
430
  await page.waitForSelector(selector, { timeout: effectiveTimeout });
445
431
  if (clear) {
446
432
  await page.click(selector, { clickCount: 3 });
@@ -461,7 +447,7 @@ server.tool('session_wait_for', 'Wait for a CSS selector to appear in the active
461
447
  }, async ({ session_id, selector, timeout }) => {
462
448
  const effectiveTimeout = timeout ?? getDefaultTimeout();
463
449
  try {
464
- const page = getSession(session_id);
450
+ const page = getSession(session_id, 'session_wait_for');
465
451
  await page.waitForSelector(selector, { timeout: effectiveTimeout });
466
452
  return { content: [{ type: 'text', text: `Selector "${selector}" found.` }] };
467
453
  }
@@ -479,7 +465,7 @@ server.tool('session_evaluate', 'Execute custom JavaScript in the context of an
479
465
  }, async ({ session_id, script, wait_for, timeout }) => {
480
466
  const effectiveTimeout = timeout ?? getDefaultTimeout();
481
467
  try {
482
- const page = getSession(session_id);
468
+ const page = getSession(session_id, 'session_evaluate');
483
469
  if (wait_for) {
484
470
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
485
471
  }
@@ -501,7 +487,7 @@ server.tool('session_html', 'Return the current full rendered HTML of an active
501
487
  session_id: z.string().describe('Session ID returned by open_session'),
502
488
  }, async ({ session_id }) => {
503
489
  try {
504
- const page = getSession(session_id);
490
+ const page = getSession(session_id, 'session_html');
505
491
  const html = await page.content();
506
492
  return { content: [{ type: 'text', text: html }] };
507
493
  }
@@ -517,7 +503,7 @@ server.tool('session_query_selector', 'Query the active session page with a CSS
517
503
  limit: z.number().optional().describe('Max number of elements to return (default: 10)'),
518
504
  }, async ({ session_id, selector, limit }) => {
519
505
  try {
520
- const page = getSession(session_id);
506
+ const page = getSession(session_id, 'session_query_selector');
521
507
  const results = await page.$$eval(selector, (elements, maxItems) => elements.slice(0, maxItems).map(el => {
522
508
  const text = (el.textContent ?? '').trim().slice(0, 100);
523
509
  return {
@@ -566,7 +552,7 @@ server.tool('session_scroll', 'Scroll the active session page. Use selector to s
566
552
  return { content: [{ type: 'text', text: 'Error: provide at least one of selector, to_bottom, x, or y.' }], isError: true };
567
553
  }
568
554
  try {
569
- const page = getSession(session_id);
555
+ const page = getSession(session_id, 'session_scroll');
570
556
  if (selector) {
571
557
  await page.$eval(selector, el => el.scrollIntoView());
572
558
  return { content: [{ type: 'text', text: `Scrolled "${selector}" into view.` }] };
@@ -606,7 +592,7 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
606
592
  destination_path: z
607
593
  .string()
608
594
  .optional()
609
- .describe(`${RELAY_SHAPE_DESCRIPTIONS.destination_path}. Required when output is "file" or "both"`),
595
+ .describe(`${RELAY_SHAPE_DESCRIPTIONS.destination_path}. Required when output is "file" or "both", except behind the mcp-http-gateway relay, which keeps it on the agent side`),
610
596
  filename: z
611
597
  .string()
612
598
  .optional()
@@ -623,10 +609,17 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
623
609
  // Écrire un fichier suppose de savoir où, et sur quelle machine. Il n'y a pas de
624
610
  // destination par défaut : elle atterrirait sur le disque de ce serveur, qui n'est
625
611
  // pas forcément celui de l'agent — un fichier introuvable plutôt qu'une erreur.
612
+ // Derrière le relais, la destination reste côté agent : seul le nom du fichier arrive ici.
626
613
  const wantsFile = output === 'file' || output === 'both';
627
- if (wantsFile && (!destination_path || !filename)) {
614
+ if (wantsFile && !filename) {
615
+ return {
616
+ content: [{ type: 'text', text: 'Error: filename is required when output is "file" or "both".' }],
617
+ isError: true,
618
+ };
619
+ }
620
+ if (wantsFile && !destination_relay && !destination_path) {
628
621
  return {
629
- content: [{ type: 'text', text: 'Error: destination_path and filename are required when output is "file" or "both".' }],
622
+ content: [{ type: 'text', text: 'Error: destination_path is required when output is "file" or "both".' }],
630
623
  isError: true,
631
624
  };
632
625
  }
@@ -636,7 +629,7 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
636
629
  let previousViewport;
637
630
  try {
638
631
  if (session_id) {
639
- page = getSession(session_id);
632
+ page = getSession(session_id, 'screenshot');
640
633
  if (viewport) {
641
634
  previousViewport = page.viewport();
642
635
  await page.setViewport(resolveViewport(toViewportOverride(viewport)));
@@ -652,7 +645,6 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
652
645
  if (viewport) {
653
646
  await page.setViewport(resolveViewport(toViewportOverride(viewport)));
654
647
  }
655
- await applyCookies(page, url);
656
648
  await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
657
649
  if (wait_for) {
658
650
  await page.waitForSelector(wait_for, { timeout: effectiveTimeout });
@@ -681,7 +673,7 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
681
673
  }
682
674
  if (wantsFile) {
683
675
  resultContent.push(...deliverFile(imageBuffer, mimeType, {
684
- destination_path: destination_path,
676
+ destination_path,
685
677
  filename: filename,
686
678
  destination_overwrite,
687
679
  destination_relay,
@@ -696,6 +688,7 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
696
688
  finally {
697
689
  if (ownedPage && page) {
698
690
  await page.close();
691
+ scheduleJarSync();
699
692
  }
700
693
  else if (page && previousViewport) {
701
694
  await page.setViewport(previousViewport).catch(() => undefined);
@@ -708,15 +701,20 @@ function launchGUI() {
708
701
  return;
709
702
  }
710
703
  launcher.start();
711
- registerSessionRpc(launcher);
704
+ registerRpc(launcher);
712
705
  }
713
706
  async function main() {
707
+ // Un dossier de données mal configuré arrête le serveur avant qu'aucun outil ne serve.
708
+ const dataDir = getDataDir();
709
+ console.error(`[MCP] Dossier des sites : ${dataDir ?? 'non configuré (sites désactivés)'}`);
714
710
  const transport = new StdioServerTransport();
715
711
  await server.connect(transport);
716
712
  console.error('[MCP] Simple Scraper MCP server started');
717
713
  // Après le connect, comme mcp-documentor : au redémarrage du serveur par le
718
714
  // client, le master sortant a alors eu le temps de libérer la route.
719
715
  launchGUI();
716
+ startKeepAlive();
717
+ startPeriodicJarSync();
720
718
  }
721
719
  main().catch(async (err) => {
722
720
  console.error('[MCP] Fatal error:', err);
@@ -0,0 +1,51 @@
1
+ import { checkSite } from './site-check.js';
2
+ import { getStore } from './site-store.js';
3
+ const TICK_MS = 60_000;
4
+ /** Le maintien visite ce site : activé, avec une page de vérification, et pas déjà déconnecté. */
5
+ function isMaintained(site) {
6
+ return site.keepAlive.enabled && !!site.keepAlive.pageUrl && site.state.status !== 'disconnected';
7
+ }
8
+ /** Prochaine visite du maintien pour ce site, ou null s'il n'en fait pas. */
9
+ export function nextVisitAt(site, now = Date.now()) {
10
+ if (!isMaintained(site))
11
+ return null;
12
+ const { lastCheckAt } = site.state;
13
+ return lastCheckAt ? Math.max(now, lastCheckAt + site.keepAlive.intervalMin * 60_000) : now;
14
+ }
15
+ /** Prochaine visite du maintien, tous sites confondus. */
16
+ export function nextKeepAliveAt(now = Date.now()) {
17
+ const store = getStore();
18
+ if (!store)
19
+ return null;
20
+ const times = store.list().map(s => nextVisitAt(s, now)).filter((t) => t !== null);
21
+ return times.length ? Math.min(...times) : null;
22
+ }
23
+ let ticking = false;
24
+ async function tick() {
25
+ if (ticking)
26
+ return;
27
+ ticking = true;
28
+ try {
29
+ const store = getStore();
30
+ if (!store)
31
+ return;
32
+ const now = Date.now();
33
+ for (const site of store.list()) {
34
+ const next = nextVisitAt(site, now);
35
+ if (next === null || next > now)
36
+ continue;
37
+ await checkSite(site.id);
38
+ }
39
+ }
40
+ catch (err) {
41
+ console.error(`[KeepAlive] ${err instanceof Error ? err.message : String(err)}`);
42
+ }
43
+ finally {
44
+ ticking = false;
45
+ }
46
+ }
47
+ /** Démarre le maintien : chaque minute, visite les sites dont le passage est dû. */
48
+ export function startKeepAlive() {
49
+ setInterval(() => void tick(), TICK_MS).unref();
50
+ setTimeout(() => void tick(), 10_000).unref();
51
+ }
package/dist/login.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import puppeteer from 'puppeteer';
5
+ import { replaceSiteCookies } from './cookie-sync.js';
6
+ import { pageShowsSignal } from './site-check.js';
7
+ import { cookieCountLabel, requireStore } from './site-store.js';
8
+ const POLL_MS = 1_000;
9
+ let active = null;
10
+ let last = null;
11
+ /** Onglet le plus récent de la fenêtre de connexion. */
12
+ async function currentPage(browser) {
13
+ const pages = (await browser.pages()).filter(p => !p.isClosed());
14
+ return pages.length ? pages[pages.length - 1] : null;
15
+ }
16
+ /** Ferme la fenêtre de connexion et supprime son profil temporaire. */
17
+ async function teardown(login) {
18
+ clearInterval(login.timer);
19
+ await login.browser.close().catch(() => undefined);
20
+ fs.rmSync(login.profileDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
21
+ }
22
+ function settle(login, patch) {
23
+ login.status = { ...login.status, ...patch };
24
+ last = login.status;
25
+ if (active === login)
26
+ active = null;
27
+ }
28
+ /** Récupère les cookies de la fenêtre de connexion et les enregistre pour le site. */
29
+ async function finish(login) {
30
+ if (login.status.phase !== 'waiting')
31
+ return;
32
+ login.status.phase = 'saving';
33
+ clearInterval(login.timer);
34
+ try {
35
+ const cookies = await login.browser.cookies();
36
+ if (!cookies.length)
37
+ throw new Error('La fenêtre de connexion ne contient aucun cookie.');
38
+ const count = await replaceSiteCookies(login.status.siteId, cookies);
39
+ const store = requireStore();
40
+ const now = Date.now();
41
+ store.setState(login.status.siteId, {
42
+ status: 'connected',
43
+ sessionOpenedAt: now,
44
+ lostAt: null,
45
+ lastCheckAt: now,
46
+ lastCheckError: null,
47
+ });
48
+ store.addEvent(login.status.siteId, 'login', cookieCountLabel(count, 'enregistré'));
49
+ settle(login, { phase: 'done', cookieCount: count });
50
+ }
51
+ catch (err) {
52
+ settle(login, { phase: 'error', error: err instanceof Error ? err.message : String(err) });
53
+ }
54
+ await teardown(login);
55
+ }
56
+ /** Surveille la fenêtre : connexion terminée quand le signe du site apparaît. */
57
+ async function poll(login) {
58
+ if (login.status.phase !== 'waiting')
59
+ return;
60
+ if (!login.browser.connected) {
61
+ settle(login, { phase: 'cancelled' });
62
+ await teardown(login);
63
+ return;
64
+ }
65
+ const page = await currentPage(login.browser).catch(() => null);
66
+ if (!page)
67
+ return;
68
+ login.status.url = page.url();
69
+ const site = requireStore().get(login.status.siteId);
70
+ const signalled = await pageShowsSignal(page, site.signal).catch(() => false);
71
+ if (signalled)
72
+ await finish(login);
73
+ }
74
+ /**
75
+ * Ouvre une fenêtre de navigateur visible, sur un profil vierge, à la page de
76
+ * connexion du site. L'utilisateur s'y connecte lui-même ; ses cookies sont
77
+ * enregistrés pour le site dès que le signe de connexion apparaît.
78
+ */
79
+ export async function startLogin(siteId) {
80
+ if (active)
81
+ throw new Error('Une connexion est déjà en cours.');
82
+ const site = requireStore().get(siteId);
83
+ if (!site.loginUrl)
84
+ throw new Error(`Le site "${site.name}" n'a pas de page de connexion.`);
85
+ const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'simple-scraper-login-'));
86
+ const browser = await puppeteer.launch({ headless: false, defaultViewport: null, userDataDir: profileDir });
87
+ const page = (await browser.pages())[0] ?? (await browser.newPage());
88
+ const login = {
89
+ status: { siteId, phase: 'waiting', url: site.loginUrl, error: null, cookieCount: null, startedAt: Date.now() },
90
+ browser,
91
+ profileDir,
92
+ timer: setInterval(() => void poll(login), POLL_MS),
93
+ };
94
+ active = login;
95
+ last = login.status;
96
+ browser.on('disconnected', () => {
97
+ if (login.status.phase === 'waiting') {
98
+ settle(login, { phase: 'cancelled' });
99
+ void teardown(login);
100
+ }
101
+ });
102
+ page.goto(site.loginUrl).catch(err => {
103
+ if (login.status.phase !== 'waiting')
104
+ return;
105
+ settle(login, { phase: 'error', error: err instanceof Error ? err.message : String(err) });
106
+ void teardown(login);
107
+ });
108
+ await page.bringToFront();
109
+ return login.status;
110
+ }
111
+ /** Connexion en cours, ou la dernière terminée. */
112
+ export function getLoginStatus() {
113
+ return active?.status ?? last;
114
+ }
115
+ /** Enregistre la connexion sans attendre le signe du site. */
116
+ export async function confirmLogin() {
117
+ if (!active)
118
+ throw new Error('Aucune connexion en cours.');
119
+ const login = active;
120
+ await finish(login);
121
+ return login.status;
122
+ }
123
+ export async function cancelLogin() {
124
+ if (!active)
125
+ return;
126
+ const login = active;
127
+ settle(login, { phase: 'cancelled' });
128
+ await teardown(login);
129
+ }
130
+ /** Capture JPEG de l'onglet courant de la fenêtre de connexion. */
131
+ export async function loginScreenshot() {
132
+ if (!active)
133
+ throw new Error('Aucune connexion en cours.');
134
+ const page = await currentPage(active.browser);
135
+ if (!page)
136
+ throw new Error('La fenêtre de connexion n\'a aucun onglet.');
137
+ const buffer = await page.screenshot({ type: 'jpeg', quality: 60 });
138
+ return Buffer.from(buffer).toString('base64');
139
+ }
package/dist/relay.js CHANGED
@@ -50,18 +50,22 @@ export function deliverFile(data, mimeType, target) {
50
50
  ];
51
51
  }
52
52
  // Pas de relais : le MCP tourne à côté de l'agent, son disque est le bon.
53
- if (!path.isAbsolute(target.destination_path)) {
54
- throw new Error(`destination_path must be an absolute directory path: ${target.destination_path}`);
53
+ if (!target.destination_path) {
54
+ throw new Error('destination_path is required when no relay delivers the file.');
55
55
  }
56
- if (fs.existsSync(target.destination_path) &&
57
- !fs.statSync(target.destination_path).isDirectory()) {
58
- throw new Error(`destination_path must be a directory, but a file already exists there: ${target.destination_path}`);
56
+ const destination = target.destination_path;
57
+ if (!path.isAbsolute(destination)) {
58
+ throw new Error(`destination_path must be an absolute directory path: ${destination}`);
59
59
  }
60
- const written = path.join(target.destination_path, name);
60
+ if (fs.existsSync(destination) &&
61
+ !fs.statSync(destination).isDirectory()) {
62
+ throw new Error(`destination_path must be a directory, but a file already exists there: ${destination}`);
63
+ }
64
+ const written = path.join(destination, name);
61
65
  if (!target.destination_overwrite && fs.existsSync(written)) {
62
66
  throw new Error(`Destination already exists: ${written}. Pass destination_overwrite to replace it.`);
63
67
  }
64
- fs.mkdirSync(target.destination_path, { recursive: true });
68
+ fs.mkdirSync(destination, { recursive: true });
65
69
  fs.writeFileSync(written, data);
66
70
  return [{ type: "text", text: `File written to ${written} (${data.length} bytes).` }];
67
71
  }
package/dist/rpc.js ADDED
@@ -0,0 +1,159 @@
1
+ import { z } from 'zod';
2
+ import { clearSiteCookies, replaceSiteCookies } from './cookie-sync.js';
3
+ import { parseNetscapeCookies } from './cookies.js';
4
+ import { getDataDir } from './data-dir.js';
5
+ import { nextKeepAliveAt, nextVisitAt } from './keep-alive.js';
6
+ import { cancelLogin, confirmLogin, getLoginStatus, loginScreenshot, startLogin } from './login.js';
7
+ import { checkSite, isChecking } from './site-check.js';
8
+ import { cookieCountLabel, getStore, requireStore } from './site-store.js';
9
+ import { closeAllSessions, closeSession, getSessionLogs, listSessions, peekSession } from './sessions.js';
10
+ const siteConfigSchema = z.object({
11
+ name: z.string().trim().min(1, 'Le nom est requis.'),
12
+ domains: z
13
+ .array(z.string().trim().toLowerCase().regex(/^[a-z0-9.-]+$/, 'Domaine invalide.'))
14
+ .min(1, 'Au moins un domaine est requis.'),
15
+ loginUrl: z.union([z.literal(''), z.url('Page de connexion invalide.')]),
16
+ signal: z.object({ kind: z.enum(['url', 'selector']), value: z.string().trim() }),
17
+ keepAlive: z.object({
18
+ enabled: z.boolean(),
19
+ intervalMin: z.number().int().min(1).max(24 * 60),
20
+ pageUrl: z.union([z.literal(''), z.url('Page visitée invalide.')]),
21
+ }),
22
+ flagWhenLost: z.boolean(),
23
+ });
24
+ function parseSiteConfig(data) {
25
+ const result = siteConfigSchema.safeParse(data);
26
+ if (!result.success)
27
+ throw new Error(result.error.issues.map(i => i.message).join(' '));
28
+ return result.data;
29
+ }
30
+ /** Site enrichi de ce que l'interface affiche à côté de ses réglages. */
31
+ function siteView(site) {
32
+ const store = requireStore();
33
+ return {
34
+ ...site,
35
+ cookieCount: store.getJar(site.id).length,
36
+ jarUpdatedAt: store.jarUpdated(site.id),
37
+ checking: isChecking(site.id),
38
+ nextVisitAt: nextVisitAt(site),
39
+ };
40
+ }
41
+ function sessionViews() {
42
+ const store = getStore();
43
+ return listSessions().map(s => {
44
+ const site = store?.siteForUrl(s.url) ?? null;
45
+ return { ...s, siteId: site?.id ?? null, siteName: site?.name ?? null };
46
+ });
47
+ }
48
+ /** Lance la vérification sans l'attendre : l'interface suit son avancement par l'état du site. */
49
+ function startCheck(siteId) {
50
+ checkSite(siteId).catch(err => console.error(`[Sites] Vérification de ${siteId} échouée : ${err instanceof Error ? err.message : String(err)}`));
51
+ }
52
+ function status() {
53
+ let dataDir = null;
54
+ let error = null;
55
+ try {
56
+ dataDir = getDataDir();
57
+ }
58
+ catch (err) {
59
+ error = err instanceof Error ? err.message : String(err);
60
+ }
61
+ const sites = dataDir ? requireStore().list() : [];
62
+ return {
63
+ dataDir,
64
+ error,
65
+ lostCount: sites.filter(s => s.state.status === 'disconnected' && s.flagWhenLost).length,
66
+ sessionCount: listSessions().length,
67
+ keepAlive: {
68
+ maintainedCount: sites.filter(s => nextVisitAt(s) !== null).length,
69
+ nextAt: dataDir ? nextKeepAliveAt() : null,
70
+ },
71
+ };
72
+ }
73
+ const handlers = {
74
+ STATUS: () => status(),
75
+ SITES_LIST: () => requireStore().list().map(siteView),
76
+ SITE_GET: ({ siteId }) => siteView(requireStore().get(siteId)),
77
+ SITE_CREATE: ({ config }) => {
78
+ const site = requireStore().create(parseSiteConfig(config));
79
+ if (site.keepAlive.pageUrl)
80
+ startCheck(site.id);
81
+ return siteView(site);
82
+ },
83
+ SITE_UPDATE: ({ siteId, config }) => siteView(requireStore().update(siteId, parseSiteConfig(config))),
84
+ SITE_DELETE: async ({ siteId }) => {
85
+ await clearSiteCookies(siteId);
86
+ requireStore().remove(siteId);
87
+ return null;
88
+ },
89
+ SITE_CHECK: ({ siteId }) => {
90
+ const site = requireStore().get(siteId);
91
+ if (!site.keepAlive.pageUrl)
92
+ throw new Error(`Le site "${site.name}" n'a pas de page de vérification.`);
93
+ startCheck(siteId);
94
+ return null;
95
+ },
96
+ SITE_IMPORT_COOKIES: async ({ siteId, content }) => {
97
+ const cookies = parseNetscapeCookies(String(content ?? ''));
98
+ if (!cookies.length)
99
+ throw new Error('Aucun cookie au format Netscape dans ce fichier.');
100
+ const count = await replaceSiteCookies(siteId, cookies);
101
+ const store = requireStore();
102
+ store.setState(siteId, { status: 'unknown', sessionOpenedAt: Date.now(), lostAt: null, lastCheckError: null });
103
+ store.addEvent(siteId, 'import', cookieCountLabel(count, 'importé'));
104
+ if (store.get(siteId).keepAlive.pageUrl)
105
+ startCheck(siteId);
106
+ return siteView(store.get(siteId));
107
+ },
108
+ SITE_CLEAR_COOKIES: async ({ siteId }) => {
109
+ await clearSiteCookies(siteId);
110
+ const store = requireStore();
111
+ store.setState(siteId, { status: 'disconnected', sessionOpenedAt: null, lostAt: Date.now() });
112
+ store.addEvent(siteId, 'cleared', 'session effacée');
113
+ return siteView(store.get(siteId));
114
+ },
115
+ EVENTS_LIST: ({ siteId, limit }) => requireStore().listEvents({ siteId, limit }),
116
+ LOGIN_START: ({ siteId }) => startLogin(siteId),
117
+ LOGIN_STATUS: () => getLoginStatus(),
118
+ LOGIN_SCREENSHOT: async () => ({ base64: await loginScreenshot() }),
119
+ LOGIN_CONFIRM: () => confirmLogin(),
120
+ LOGIN_CANCEL: async () => {
121
+ await cancelLogin();
122
+ return null;
123
+ },
124
+ SESSION_LIST: () => sessionViews(),
125
+ SESSION_SCREENSHOT: async ({ sessionId }) => {
126
+ const buffer = await peekSession(sessionId).screenshot({ type: 'jpeg', quality: 60 });
127
+ return { base64: Buffer.from(buffer).toString('base64') };
128
+ },
129
+ SESSION_LOGS: ({ sessionId }) => getSessionLogs(sessionId),
130
+ SESSION_CLOSE: async ({ sessionId }) => {
131
+ await closeSession(sessionId);
132
+ return null;
133
+ },
134
+ SESSION_CLOSE_ALL: async () => {
135
+ await closeAllSessions();
136
+ return null;
137
+ },
138
+ };
139
+ /** Répond aux requêtes de la GUI, qui n'a accès ni au navigateur ni aux sites. */
140
+ export function registerRpc(launcher) {
141
+ launcher.getIpcHub().onMessage(async (msg) => {
142
+ const handler = handlers[msg.type];
143
+ if (!handler)
144
+ return;
145
+ const reply = (data, error) => launcher.getIpcHub().send({
146
+ type: `${msg.type}_RESPONSE`,
147
+ correlationId: msg.correlationId,
148
+ data,
149
+ error,
150
+ timestamp: new Date().toISOString(),
151
+ });
152
+ try {
153
+ reply(await handler(msg.data ?? {}));
154
+ }
155
+ catch (err) {
156
+ reply(undefined, err instanceof Error ? err.message : String(err));
157
+ }
158
+ });
159
+ }