@imenam/simple-scraper 1.0.24 → 1.0.28
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 +444 -398
- package/dist/browser.js +40 -11
- package/dist/cli/setup-mcp.js +2 -2
- package/dist/cookie-sync.js +67 -0
- package/dist/cookies.js +28 -40
- package/dist/data-dir.js +24 -0
- package/dist/gui.js +83 -85
- package/dist/index.js +36 -38
- package/dist/keep-alive.js +51 -0
- package/dist/login.js +157 -0
- package/dist/relay.js +11 -7
- package/dist/remote-input.js +67 -0
- package/dist/rpc.js +184 -0
- package/dist/sessions.js +23 -11
- package/dist/signal-picker.js +144 -0
- package/dist/site-check.js +88 -0
- package/dist/site-store.js +210 -0
- package/package.json +2 -1
- package/public/dist/assets/index-BAs4dkz0.css +1 -0
- package/public/dist/assets/index-D44p8v3x.js +11 -0
- package/public/dist/index.html +13 -13
- package/public/dist/assets/index-DAjr-PsH.js +0 -12
- package/public/dist/assets/index-IkSxq6_f.css +0 -1
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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, loginInput, loginScreenshot, startLogin } from './login.js';
|
|
7
|
+
import { parseRemoteAction } from './remote-input.js';
|
|
8
|
+
import { closePicker, openPicker, pickAt, pickerScreenshot, pickerScroll, testSignal } from './signal-picker.js';
|
|
9
|
+
import { checkSite, isChecking } from './site-check.js';
|
|
10
|
+
import { cookieCountLabel, getStore, requireStore } from './site-store.js';
|
|
11
|
+
import { closeAllSessions, closeSession, getSessionLogs, listSessions, peekSession } from './sessions.js';
|
|
12
|
+
const signalSchema = z.object({ kind: z.enum(['url', 'selector']), value: z.string().trim() });
|
|
13
|
+
const siteConfigSchema = z.object({
|
|
14
|
+
name: z.string().trim().min(1, 'Le nom est requis.'),
|
|
15
|
+
domains: z
|
|
16
|
+
.array(z.string().trim().toLowerCase().regex(/^[a-z0-9.-]+$/, 'Domaine invalide.'))
|
|
17
|
+
.min(1, 'Au moins un domaine est requis.'),
|
|
18
|
+
loginUrl: z.union([z.literal(''), z.url('Page de connexion invalide.')]),
|
|
19
|
+
signal: signalSchema,
|
|
20
|
+
keepAlive: z.object({
|
|
21
|
+
enabled: z.boolean(),
|
|
22
|
+
intervalMin: z.number().int().min(1).max(24 * 60),
|
|
23
|
+
pageUrl: z.union([z.literal(''), z.url('Page visitée invalide.')]),
|
|
24
|
+
}),
|
|
25
|
+
flagWhenLost: z.boolean(),
|
|
26
|
+
});
|
|
27
|
+
/** Valide une donnée de la GUI ; l'erreur reprend les messages du schéma. */
|
|
28
|
+
function parse(schema, data) {
|
|
29
|
+
const result = schema.safeParse(data);
|
|
30
|
+
if (!result.success)
|
|
31
|
+
throw new Error(result.error.issues.map(i => i.message).join(' '));
|
|
32
|
+
return result.data;
|
|
33
|
+
}
|
|
34
|
+
const pageUrlSchema = z.url('Page visitée invalide.');
|
|
35
|
+
const ratioSchema = z.number().min(0).max(1);
|
|
36
|
+
function parseSiteConfig(data) {
|
|
37
|
+
return parse(siteConfigSchema, data);
|
|
38
|
+
}
|
|
39
|
+
/** Site enrichi de ce que l'interface affiche à côté de ses réglages. */
|
|
40
|
+
function siteView(site) {
|
|
41
|
+
const store = requireStore();
|
|
42
|
+
return {
|
|
43
|
+
...site,
|
|
44
|
+
cookieCount: store.getJar(site.id).length,
|
|
45
|
+
jarUpdatedAt: store.jarUpdated(site.id),
|
|
46
|
+
checking: isChecking(site.id),
|
|
47
|
+
nextVisitAt: nextVisitAt(site),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function sessionViews() {
|
|
51
|
+
const store = getStore();
|
|
52
|
+
return listSessions().map(s => {
|
|
53
|
+
const site = store?.siteForUrl(s.url) ?? null;
|
|
54
|
+
return { ...s, siteId: site?.id ?? null, siteName: site?.name ?? null };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Lance la vérification sans l'attendre : l'interface suit son avancement par l'état du site. */
|
|
58
|
+
function startCheck(siteId) {
|
|
59
|
+
checkSite(siteId).catch(err => console.error(`[Sites] Vérification de ${siteId} échouée : ${err instanceof Error ? err.message : String(err)}`));
|
|
60
|
+
}
|
|
61
|
+
function status() {
|
|
62
|
+
let dataDir = null;
|
|
63
|
+
let error = null;
|
|
64
|
+
try {
|
|
65
|
+
dataDir = getDataDir();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
error = err instanceof Error ? err.message : String(err);
|
|
69
|
+
}
|
|
70
|
+
const sites = dataDir ? requireStore().list() : [];
|
|
71
|
+
return {
|
|
72
|
+
dataDir,
|
|
73
|
+
error,
|
|
74
|
+
lostCount: sites.filter(s => s.state.status === 'disconnected' && s.flagWhenLost).length,
|
|
75
|
+
sessionCount: listSessions().length,
|
|
76
|
+
keepAlive: {
|
|
77
|
+
maintainedCount: sites.filter(s => nextVisitAt(s) !== null).length,
|
|
78
|
+
nextAt: dataDir ? nextKeepAliveAt() : null,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const handlers = {
|
|
83
|
+
STATUS: () => status(),
|
|
84
|
+
SITES_LIST: () => requireStore().list().map(siteView),
|
|
85
|
+
SITE_GET: ({ siteId }) => siteView(requireStore().get(siteId)),
|
|
86
|
+
SITE_CREATE: ({ config }) => {
|
|
87
|
+
const site = requireStore().create(parseSiteConfig(config));
|
|
88
|
+
if (site.keepAlive.pageUrl)
|
|
89
|
+
startCheck(site.id);
|
|
90
|
+
return siteView(site);
|
|
91
|
+
},
|
|
92
|
+
SITE_UPDATE: ({ siteId, config }) => siteView(requireStore().update(siteId, parseSiteConfig(config))),
|
|
93
|
+
SITE_DELETE: async ({ siteId }) => {
|
|
94
|
+
await clearSiteCookies(siteId);
|
|
95
|
+
requireStore().remove(siteId);
|
|
96
|
+
return null;
|
|
97
|
+
},
|
|
98
|
+
SITE_CHECK: ({ siteId }) => {
|
|
99
|
+
const site = requireStore().get(siteId);
|
|
100
|
+
if (!site.keepAlive.pageUrl)
|
|
101
|
+
throw new Error(`Le site "${site.name}" n'a pas de page de vérification.`);
|
|
102
|
+
startCheck(siteId);
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
105
|
+
SITE_IMPORT_COOKIES: async ({ siteId, content }) => {
|
|
106
|
+
const cookies = parseNetscapeCookies(String(content ?? ''));
|
|
107
|
+
if (!cookies.length)
|
|
108
|
+
throw new Error('Aucun cookie au format Netscape dans ce fichier.');
|
|
109
|
+
const count = await replaceSiteCookies(siteId, cookies);
|
|
110
|
+
const store = requireStore();
|
|
111
|
+
store.setState(siteId, { status: 'unknown', sessionOpenedAt: Date.now(), lostAt: null, lastCheckError: null });
|
|
112
|
+
store.addEvent(siteId, 'import', cookieCountLabel(count, 'importé'));
|
|
113
|
+
if (store.get(siteId).keepAlive.pageUrl)
|
|
114
|
+
startCheck(siteId);
|
|
115
|
+
return siteView(store.get(siteId));
|
|
116
|
+
},
|
|
117
|
+
SITE_CLEAR_COOKIES: async ({ siteId }) => {
|
|
118
|
+
await clearSiteCookies(siteId);
|
|
119
|
+
const store = requireStore();
|
|
120
|
+
store.setState(siteId, { status: 'disconnected', sessionOpenedAt: null, lostAt: Date.now() });
|
|
121
|
+
store.addEvent(siteId, 'cleared', 'session effacée');
|
|
122
|
+
return siteView(store.get(siteId));
|
|
123
|
+
},
|
|
124
|
+
EVENTS_LIST: ({ siteId, limit }) => requireStore().listEvents({ siteId, limit }),
|
|
125
|
+
LOGIN_START: ({ siteId }) => startLogin(siteId),
|
|
126
|
+
LOGIN_STATUS: () => getLoginStatus(),
|
|
127
|
+
LOGIN_SCREENSHOT: async () => ({ base64: await loginScreenshot() }),
|
|
128
|
+
LOGIN_INPUT: async ({ action }) => {
|
|
129
|
+
await loginInput(parseRemoteAction(action));
|
|
130
|
+
return getLoginStatus();
|
|
131
|
+
},
|
|
132
|
+
LOGIN_CONFIRM: () => confirmLogin(),
|
|
133
|
+
LOGIN_CANCEL: async () => {
|
|
134
|
+
await cancelLogin();
|
|
135
|
+
return null;
|
|
136
|
+
},
|
|
137
|
+
PICKER_OPEN: ({ pageUrl }) => openPicker(parse(pageUrlSchema, pageUrl)),
|
|
138
|
+
PICKER_SCREENSHOT: async () => ({ base64: await pickerScreenshot() }),
|
|
139
|
+
PICKER_SCROLL: async ({ dy }) => {
|
|
140
|
+
await pickerScroll(parse(z.number().min(-5000).max(5000), dy));
|
|
141
|
+
return null;
|
|
142
|
+
},
|
|
143
|
+
PICKER_PICK: ({ x, y }) => pickAt(parse(ratioSchema, x), parse(ratioSchema, y)),
|
|
144
|
+
PICKER_CLOSE: async () => {
|
|
145
|
+
await closePicker();
|
|
146
|
+
return null;
|
|
147
|
+
},
|
|
148
|
+
SIGNAL_TEST: ({ pageUrl, signal }) => testSignal(parse(pageUrlSchema, pageUrl), parse(signalSchema, signal)),
|
|
149
|
+
SESSION_LIST: () => sessionViews(),
|
|
150
|
+
SESSION_SCREENSHOT: async ({ sessionId }) => {
|
|
151
|
+
const buffer = await peekSession(sessionId).screenshot({ type: 'jpeg', quality: 60 });
|
|
152
|
+
return { base64: Buffer.from(buffer).toString('base64') };
|
|
153
|
+
},
|
|
154
|
+
SESSION_LOGS: ({ sessionId }) => getSessionLogs(sessionId),
|
|
155
|
+
SESSION_CLOSE: async ({ sessionId }) => {
|
|
156
|
+
await closeSession(sessionId);
|
|
157
|
+
return null;
|
|
158
|
+
},
|
|
159
|
+
SESSION_CLOSE_ALL: async () => {
|
|
160
|
+
await closeAllSessions();
|
|
161
|
+
return null;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
/** Répond aux requêtes de la GUI, qui n'a accès ni au navigateur ni aux sites. */
|
|
165
|
+
export function registerRpc(launcher) {
|
|
166
|
+
launcher.getIpcHub().onMessage(async (msg) => {
|
|
167
|
+
const handler = handlers[msg.type];
|
|
168
|
+
if (!handler)
|
|
169
|
+
return;
|
|
170
|
+
const reply = (data, error) => launcher.getIpcHub().send({
|
|
171
|
+
type: `${msg.type}_RESPONSE`,
|
|
172
|
+
correlationId: msg.correlationId,
|
|
173
|
+
data,
|
|
174
|
+
error,
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
reply(await handler(msg.data ?? {}));
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
reply(undefined, err instanceof Error ? err.message : String(err));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
package/dist/sessions.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { getBrowser, getDefaultViewport } from './browser.js';
|
|
2
|
-
import {
|
|
2
|
+
import { scheduleJarSync } from './cookie-sync.js';
|
|
3
3
|
const MAX_SESSIONS = parseInt(process.env.SCRAPER_MAX_SESSIONS ?? '5', 10);
|
|
4
4
|
const TTL_MS = parseInt(process.env.SCRAPER_SESSION_TTL_MS ?? String(10 * 60 * 1000), 10);
|
|
5
5
|
const sessions = new Map();
|
|
6
6
|
function generateSessionId() {
|
|
7
7
|
return 'sess_' + Math.random().toString(36).slice(2, 8);
|
|
8
8
|
}
|
|
9
|
-
async function applyCookies(page, pageUrl) {
|
|
10
|
-
const cookies = await loadAllCookies();
|
|
11
|
-
if (!cookies.length)
|
|
12
|
-
return;
|
|
13
|
-
const prepared = cookies.map(c => (c.domain ? c : { ...c, url: pageUrl }));
|
|
14
|
-
await page.setCookie(...prepared);
|
|
15
|
-
}
|
|
16
9
|
/** Merge a partial override onto the env-configured default viewport. */
|
|
17
10
|
export function resolveViewport(override) {
|
|
18
11
|
const base = getDefaultViewport();
|
|
@@ -31,7 +24,13 @@ export async function createSession(url, opts = {}) {
|
|
|
31
24
|
const sessionId = generateSessionId();
|
|
32
25
|
const browser = await getBrowser();
|
|
33
26
|
const page = await browser.newPage();
|
|
34
|
-
const entry = {
|
|
27
|
+
const entry = {
|
|
28
|
+
page,
|
|
29
|
+
createdAt: Date.now(),
|
|
30
|
+
lastUsedAt: Date.now(),
|
|
31
|
+
lastAction: 'open_session',
|
|
32
|
+
consoleLogs: [],
|
|
33
|
+
};
|
|
35
34
|
page.on('console', (msg) => {
|
|
36
35
|
entry.consoleLogs.push({ type: msg.type(), text: msg.text(), timestamp: Date.now() });
|
|
37
36
|
});
|
|
@@ -39,7 +38,6 @@ export async function createSession(url, opts = {}) {
|
|
|
39
38
|
if (opts.viewport) {
|
|
40
39
|
await page.setViewport(resolveViewport(opts.viewport));
|
|
41
40
|
}
|
|
42
|
-
await applyCookies(page, url);
|
|
43
41
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: opts.timeout ?? 30_000 });
|
|
44
42
|
if (opts.waitFor) {
|
|
45
43
|
await page.waitForSelector(opts.waitFor, { timeout: opts.timeout ?? 30_000 });
|
|
@@ -53,7 +51,8 @@ export async function createSession(url, opts = {}) {
|
|
|
53
51
|
console.error(`[Session] Created ${sessionId} → ${url}`);
|
|
54
52
|
return sessionId;
|
|
55
53
|
}
|
|
56
|
-
|
|
54
|
+
/** Page of the session; `action` is recorded as the last action performed on it. */
|
|
55
|
+
export function getSession(sessionId, action) {
|
|
57
56
|
const entry = sessions.get(sessionId);
|
|
58
57
|
if (!entry) {
|
|
59
58
|
throw new Error(`Session "${sessionId}" not found or already closed.`);
|
|
@@ -63,6 +62,15 @@ export function getSession(sessionId) {
|
|
|
63
62
|
throw new Error(`Session "${sessionId}" page was closed unexpectedly.`);
|
|
64
63
|
}
|
|
65
64
|
entry.lastUsedAt = Date.now();
|
|
65
|
+
entry.lastAction = action;
|
|
66
|
+
return entry.page;
|
|
67
|
+
}
|
|
68
|
+
/** Page of the session, without marking it as used. */
|
|
69
|
+
export function peekSession(sessionId) {
|
|
70
|
+
const entry = sessions.get(sessionId);
|
|
71
|
+
if (!entry || entry.page.isClosed()) {
|
|
72
|
+
throw new Error(`Session "${sessionId}" not found or already closed.`);
|
|
73
|
+
}
|
|
66
74
|
return entry.page;
|
|
67
75
|
}
|
|
68
76
|
export async function closeSession(sessionId) {
|
|
@@ -71,6 +79,7 @@ export async function closeSession(sessionId) {
|
|
|
71
79
|
return;
|
|
72
80
|
await entry.page.close().catch(() => undefined);
|
|
73
81
|
sessions.delete(sessionId);
|
|
82
|
+
scheduleJarSync();
|
|
74
83
|
console.error(`[Session] Closed ${sessionId}`);
|
|
75
84
|
}
|
|
76
85
|
export async function closeAllSessions() {
|
|
@@ -97,6 +106,9 @@ export function listSessions() {
|
|
|
97
106
|
sessionId,
|
|
98
107
|
createdAt: s.createdAt,
|
|
99
108
|
lastUsedAt: s.lastUsedAt,
|
|
109
|
+
lastAction: s.lastAction,
|
|
110
|
+
url: s.page.isClosed() ? '' : s.page.url(),
|
|
111
|
+
errorCount: s.consoleLogs.filter(l => l.type === 'error').length,
|
|
100
112
|
}));
|
|
101
113
|
}
|
|
102
114
|
// GC: close sessions inactive for more than TTL_MS
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { getBrowser, getDefaultTimeout } from './browser.js';
|
|
2
|
+
import { applyRemoteAction, pointOf } from './remote-input.js';
|
|
3
|
+
import { pageShowsSignal, urlMatchesSignal } from './site-check.js';
|
|
4
|
+
const SIGNAL_WAIT_MS = 3_000;
|
|
5
|
+
let picker = null;
|
|
6
|
+
function requirePicker() {
|
|
7
|
+
if (!picker || picker.page.isClosed())
|
|
8
|
+
throw new Error('Aucune page ouverte pour choisir le signe de connexion.');
|
|
9
|
+
return picker;
|
|
10
|
+
}
|
|
11
|
+
/** Ouvre la page visitée du site dans le navigateur partagé, donc avec les cookies des sites. */
|
|
12
|
+
export async function openPicker(pageUrl) {
|
|
13
|
+
await closePicker();
|
|
14
|
+
const page = await (await getBrowser()).newPage();
|
|
15
|
+
picker = { page, pageUrl };
|
|
16
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
17
|
+
return { url: page.url() };
|
|
18
|
+
}
|
|
19
|
+
export async function closePicker() {
|
|
20
|
+
const current = picker;
|
|
21
|
+
picker = null;
|
|
22
|
+
await current?.page.close().catch(() => undefined);
|
|
23
|
+
}
|
|
24
|
+
export async function pickerScreenshot() {
|
|
25
|
+
const buffer = await requirePicker().page.screenshot({ type: 'jpeg', quality: 70 });
|
|
26
|
+
return Buffer.from(buffer).toString('base64');
|
|
27
|
+
}
|
|
28
|
+
/** Fait défiler la page du sélecteur ; seul le défilement y est relayé. */
|
|
29
|
+
export async function pickerScroll(dy) {
|
|
30
|
+
const action = { type: 'scroll', dy };
|
|
31
|
+
await applyRemoteAction(requirePicker().page, action);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sélecteurs désignant l'élément situé au point donné, du plus précis au plus
|
|
35
|
+
* général : son texte dans son conteneur repérable, son chemin depuis ce
|
|
36
|
+
* conteneur, le conteneur seul, l'élément seul.
|
|
37
|
+
*/
|
|
38
|
+
function candidatesAt(x, y) {
|
|
39
|
+
const target = document.elementFromPoint(x, y);
|
|
40
|
+
if (!target)
|
|
41
|
+
return [];
|
|
42
|
+
const stable = (name) => /^[a-zA-Z_][\w-]*$/.test(name) &&
|
|
43
|
+
!/\d{3,}/.test(name) &&
|
|
44
|
+
!/^(active|open|show|hover|focus|focused|in|collapsed|selected|disabled|is-|has-|ng-)/.test(name);
|
|
45
|
+
const count = (selector) => document.querySelectorAll(selector).length;
|
|
46
|
+
/** Classe stable de l'élément la plus rare dans la page ; à égalité, la plus longue, souvent la plus parlante. */
|
|
47
|
+
const ownClass = (el) => Array.from(el.classList)
|
|
48
|
+
.filter(stable)
|
|
49
|
+
.sort((a, b) => count(`.${CSS.escape(a)}`) - count(`.${CSS.escape(b)}`) || b.length - a.length)[0];
|
|
50
|
+
const selfSel = (el) => {
|
|
51
|
+
if (el.id && stable(el.id))
|
|
52
|
+
return `#${CSS.escape(el.id)}`;
|
|
53
|
+
const cls = ownClass(el);
|
|
54
|
+
return cls ? `.${CSS.escape(cls)}` : null;
|
|
55
|
+
};
|
|
56
|
+
const part = (el) => {
|
|
57
|
+
if (el.id && stable(el.id))
|
|
58
|
+
return `#${CSS.escape(el.id)}`;
|
|
59
|
+
const cls = ownClass(el);
|
|
60
|
+
return el.tagName.toLowerCase() + (cls ? `.${CSS.escape(cls)}` : '');
|
|
61
|
+
};
|
|
62
|
+
// Conteneur : l'ancêtre repérable le plus proche qui soit unique dans la page,
|
|
63
|
+
// sinon le plus proche repérable.
|
|
64
|
+
let anchorSel = null;
|
|
65
|
+
let firstSel = null;
|
|
66
|
+
for (let el = target.parentElement, depth = 0; el && depth < 6; el = el.parentElement, depth++) {
|
|
67
|
+
if (el === document.body || el === document.documentElement)
|
|
68
|
+
break;
|
|
69
|
+
const sel = selfSel(el);
|
|
70
|
+
if (!sel)
|
|
71
|
+
continue;
|
|
72
|
+
firstSel ??= sel;
|
|
73
|
+
if (count(sel) === 1) {
|
|
74
|
+
anchorSel = sel;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
anchorSel ??= firstSel;
|
|
79
|
+
const targetSel = part(target);
|
|
80
|
+
const scoped = anchorSel ? `${anchorSel} ${targetSel}` : targetSel;
|
|
81
|
+
const out = [];
|
|
82
|
+
const text = (target.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
83
|
+
if (text && text.length <= 60 && target.children.length === 0) {
|
|
84
|
+
const quoted = JSON.stringify(text);
|
|
85
|
+
out.push({ selector: `${scoped}::-p-text(${quoted})`, label: `Le texte « ${text} » à cet endroit` });
|
|
86
|
+
}
|
|
87
|
+
if (anchorSel)
|
|
88
|
+
out.push({ selector: scoped, label: 'Cet élément, dans son conteneur' });
|
|
89
|
+
if (anchorSel)
|
|
90
|
+
out.push({ selector: anchorSel, label: 'Son conteneur' });
|
|
91
|
+
out.push({ selector: targetSel, label: 'Cet élément, n\'importe où dans la page' });
|
|
92
|
+
return out.filter((c, i) => out.findIndex(o => o.selector === c.selector) === i);
|
|
93
|
+
}
|
|
94
|
+
/** Ouvre la page dans un contexte sans cookies, comme la verrait une session perdue. */
|
|
95
|
+
async function withAnonymousPage(pageUrl, fn) {
|
|
96
|
+
const context = await (await getBrowser()).createBrowserContext();
|
|
97
|
+
try {
|
|
98
|
+
const page = await context.newPage();
|
|
99
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
100
|
+
return await fn(page);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await context.close().catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function countMatches(page, selector) {
|
|
107
|
+
return (await page.$$(selector).catch(() => [])).length;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Propose les sélecteurs de l'élément cliqué, chacun compté sur la page vue
|
|
111
|
+
* connectée et sur la même page vue sans cookies. Un bon signe de connexion est
|
|
112
|
+
* trouvé dans le premier cas et absent dans le second ; ceux-là viennent en tête.
|
|
113
|
+
*/
|
|
114
|
+
export async function pickAt(x, y) {
|
|
115
|
+
const { page, pageUrl } = requirePicker();
|
|
116
|
+
const point = pointOf(page, x, y);
|
|
117
|
+
const proposals = await page.evaluate(candidatesAt, point.x, point.y);
|
|
118
|
+
if (!proposals.length)
|
|
119
|
+
return [];
|
|
120
|
+
const connected = await Promise.all(proposals.map(p => countMatches(page, p.selector)));
|
|
121
|
+
const anonymous = await withAnonymousPage(pageUrl, anon => Promise.all(proposals.map(p => countMatches(anon, p.selector))));
|
|
122
|
+
const results = proposals.map((p, i) => ({ ...p, connected: connected[i], anonymous: anonymous[i] }));
|
|
123
|
+
const good = (c) => c.connected > 0 && c.anonymous === 0;
|
|
124
|
+
return [...results.filter(good), ...results.filter(c => !good(c))];
|
|
125
|
+
}
|
|
126
|
+
/** Vérifie le signe sur la page vue connectée (attendu : trouvé) et sans cookies (attendu : absent). */
|
|
127
|
+
export async function testSignal(pageUrl, signal) {
|
|
128
|
+
const page = await (await getBrowser()).newPage();
|
|
129
|
+
let connected;
|
|
130
|
+
let connectedUrl;
|
|
131
|
+
try {
|
|
132
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
133
|
+
connectedUrl = page.url();
|
|
134
|
+
connected = await pageShowsSignal(page, signal, SIGNAL_WAIT_MS);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await page.close().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
const anon = await withAnonymousPage(pageUrl, async (p) => ({
|
|
140
|
+
url: p.url(),
|
|
141
|
+
shown: signal.kind === 'url' ? urlMatchesSignal(p.url(), signal.value) : await pageShowsSignal(p, signal),
|
|
142
|
+
}));
|
|
143
|
+
return { connected, anonymous: anon.shown, connectedUrl, anonymousUrl: anon.url };
|
|
144
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getBrowser, getDefaultTimeout } from './browser.js';
|
|
2
|
+
import { syncJarsFromBrowser } from './cookie-sync.js';
|
|
3
|
+
import { requireStore } from './site-store.js';
|
|
4
|
+
const SELECTOR_WAIT_MS = 5_000;
|
|
5
|
+
/** L'adresse, sans paramètres ni ancre, avec ou sans son port, contient la valeur attendue. */
|
|
6
|
+
export function urlMatchesSignal(url, value) {
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = new URL(url);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const expected = value.replace(/^https?:\/\//, '');
|
|
15
|
+
return [`${parsed.host}${parsed.pathname}`, `${parsed.hostname}${parsed.pathname}`].some(bare => bare.includes(expected));
|
|
16
|
+
}
|
|
17
|
+
/** La page montre le signe de connexion du site. */
|
|
18
|
+
export async function pageShowsSignal(page, signal, waitMs = 0) {
|
|
19
|
+
if (signal.kind === 'url')
|
|
20
|
+
return urlMatchesSignal(page.url(), signal.value);
|
|
21
|
+
if (waitMs > 0) {
|
|
22
|
+
return page
|
|
23
|
+
.waitForSelector(signal.value, { timeout: waitMs })
|
|
24
|
+
.then(el => el !== null)
|
|
25
|
+
.catch(() => false);
|
|
26
|
+
}
|
|
27
|
+
return (await page.$(signal.value)) !== null;
|
|
28
|
+
}
|
|
29
|
+
function describePath(url) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(url);
|
|
32
|
+
return parsed.pathname + parsed.search;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const running = new Set();
|
|
39
|
+
export function isChecking(siteId) {
|
|
40
|
+
return running.has(siteId);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Visite la page de vérification du site avec ses cookies et met à jour son
|
|
44
|
+
* état : connecté si le signe de connexion apparaît, déconnecté sinon. Une
|
|
45
|
+
* page injoignable laisse l'état inchangé et enregistre l'erreur.
|
|
46
|
+
*/
|
|
47
|
+
export async function checkSite(siteId) {
|
|
48
|
+
const store = requireStore();
|
|
49
|
+
const site = store.get(siteId);
|
|
50
|
+
if (!site.keepAlive.pageUrl) {
|
|
51
|
+
throw new Error(`Le site "${site.name}" n'a pas de page de vérification.`);
|
|
52
|
+
}
|
|
53
|
+
if (running.has(siteId))
|
|
54
|
+
return site;
|
|
55
|
+
running.add(siteId);
|
|
56
|
+
let page = null;
|
|
57
|
+
try {
|
|
58
|
+
page = await (await getBrowser()).newPage();
|
|
59
|
+
await page.goto(site.keepAlive.pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
60
|
+
const connected = await pageShowsSignal(page, site.signal, SELECTOR_WAIT_MS);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (connected) {
|
|
63
|
+
store.setState(siteId, { status: 'connected', lostAt: null, lastCheckAt: now, lastCheckError: null });
|
|
64
|
+
store.addEvent(siteId, 'check-ok', `visite de ${describePath(site.keepAlive.pageUrl)}`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const wasConnected = site.state.status !== 'disconnected';
|
|
68
|
+
store.setState(siteId, {
|
|
69
|
+
status: 'disconnected',
|
|
70
|
+
lostAt: wasConnected ? now : site.state.lostAt,
|
|
71
|
+
lastCheckAt: now,
|
|
72
|
+
lastCheckError: null,
|
|
73
|
+
});
|
|
74
|
+
store.addEvent(siteId, 'check-lost', `redirigé vers ${describePath(page.url())}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
store.setState(siteId, { lastCheckAt: Date.now(), lastCheckError: message });
|
|
80
|
+
store.addEvent(siteId, 'check-error', message);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await page?.close().catch(() => undefined);
|
|
84
|
+
running.delete(siteId);
|
|
85
|
+
}
|
|
86
|
+
await syncJarsFromBrowser();
|
|
87
|
+
return store.get(siteId);
|
|
88
|
+
}
|