@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/README.md +438 -398
- package/dist/browser.js +32 -10
- 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 +75 -85
- package/dist/index.js +36 -38
- package/dist/keep-alive.js +51 -0
- package/dist/login.js +139 -0
- package/dist/relay.js +11 -7
- package/dist/rpc.js +159 -0
- package/dist/sessions.js +23 -11
- package/dist/site-check.js +88 -0
- package/dist/site-store.js +210 -0
- package/package.json +2 -1
- package/public/dist/assets/index-CmU4YRqF.js +11 -0
- package/public/dist/assets/index-olIqry-_.css +1 -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/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,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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getDataDir } from './data-dir.js';
|
|
4
|
+
const MAX_EVENTS = 1000;
|
|
5
|
+
/** « 1 cookie enregistré », « 18 cookies enregistrés ». */
|
|
6
|
+
export function cookieCountLabel(count, participle) {
|
|
7
|
+
return count > 1 ? `${count} cookies ${participle}s` : `${count} cookie ${participle}`;
|
|
8
|
+
}
|
|
9
|
+
const SITE_ID_RE = /^site_[a-z0-9]{6}$/;
|
|
10
|
+
export function isSiteId(value) {
|
|
11
|
+
return SITE_ID_RE.test(value);
|
|
12
|
+
}
|
|
13
|
+
/** L'hôte appartient au domaine, ou à l'un de ses sous-domaines. */
|
|
14
|
+
export function hostMatches(host, domain) {
|
|
15
|
+
return host === domain || host.endsWith('.' + domain);
|
|
16
|
+
}
|
|
17
|
+
/** Le cookie s'applique à l'un des domaines, ou l'un des domaines l'englobe. */
|
|
18
|
+
export function cookieInScope(cookieDomain, scope) {
|
|
19
|
+
const d = cookieDomain.replace(/^\./, '').toLowerCase();
|
|
20
|
+
return scope.some(s => s === d || s.endsWith('.' + d) || d.endsWith('.' + s));
|
|
21
|
+
}
|
|
22
|
+
/** Cookie réduit aux champs acceptés par `setCookie`, sans date pour un cookie de session. */
|
|
23
|
+
export function toCookieData(cookie) {
|
|
24
|
+
const data = {
|
|
25
|
+
name: cookie.name,
|
|
26
|
+
value: cookie.value,
|
|
27
|
+
domain: cookie.domain,
|
|
28
|
+
path: cookie.path ?? '/',
|
|
29
|
+
secure: cookie.secure ?? false,
|
|
30
|
+
httpOnly: cookie.httpOnly ?? false,
|
|
31
|
+
};
|
|
32
|
+
if (cookie.sameSite)
|
|
33
|
+
data.sameSite = cookie.sameSite;
|
|
34
|
+
if (typeof cookie.expires === 'number' && cookie.expires > 0)
|
|
35
|
+
data.expires = cookie.expires;
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
function isExpired(cookie, nowSec) {
|
|
39
|
+
return typeof cookie.expires === 'number' && cookie.expires > 0 && cookie.expires < nowSec;
|
|
40
|
+
}
|
|
41
|
+
function cookieKey(c) {
|
|
42
|
+
return `${c.domain}\t${c.path ?? '/'}\t${c.name}`;
|
|
43
|
+
}
|
|
44
|
+
function writeJson(file, value) {
|
|
45
|
+
const tmp = `${file}.tmp`;
|
|
46
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf-8');
|
|
47
|
+
fs.renameSync(tmp, file);
|
|
48
|
+
}
|
|
49
|
+
function readJson(file, empty) {
|
|
50
|
+
if (!fs.existsSync(file))
|
|
51
|
+
return empty;
|
|
52
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
53
|
+
}
|
|
54
|
+
function generateSiteId() {
|
|
55
|
+
return 'site_' + Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sites d'un dossier de données : `sites.json` (réglages et état), `events.json`
|
|
59
|
+
* (historique borné) et `cookies/<id>.json` (cookies de chaque site). Le process
|
|
60
|
+
* principal en est le seul lecteur et le seul écrivain ; tout est gardé en mémoire
|
|
61
|
+
* et réécrit à chaque modification.
|
|
62
|
+
*/
|
|
63
|
+
export class SiteStore {
|
|
64
|
+
dir;
|
|
65
|
+
sitesFile;
|
|
66
|
+
eventsFile;
|
|
67
|
+
cookiesDir;
|
|
68
|
+
sites;
|
|
69
|
+
events;
|
|
70
|
+
jars = new Map();
|
|
71
|
+
jarUpdatedAt = new Map();
|
|
72
|
+
constructor(dir) {
|
|
73
|
+
this.dir = dir;
|
|
74
|
+
this.sitesFile = path.join(dir, 'sites.json');
|
|
75
|
+
this.eventsFile = path.join(dir, 'events.json');
|
|
76
|
+
this.cookiesDir = path.join(dir, 'cookies');
|
|
77
|
+
fs.mkdirSync(this.cookiesDir, { recursive: true });
|
|
78
|
+
this.sites = readJson(this.sitesFile, { sites: [] }).sites;
|
|
79
|
+
this.events = readJson(this.eventsFile, []);
|
|
80
|
+
for (const site of this.sites) {
|
|
81
|
+
const file = this.jarFile(site.id);
|
|
82
|
+
this.jars.set(site.id, readJson(file, []));
|
|
83
|
+
if (fs.existsSync(file))
|
|
84
|
+
this.jarUpdatedAt.set(site.id, fs.statSync(file).mtimeMs);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
jarFile(id) {
|
|
88
|
+
return path.join(this.cookiesDir, `${id}.json`);
|
|
89
|
+
}
|
|
90
|
+
saveSites() {
|
|
91
|
+
writeJson(this.sitesFile, { sites: this.sites });
|
|
92
|
+
}
|
|
93
|
+
list() {
|
|
94
|
+
return this.sites;
|
|
95
|
+
}
|
|
96
|
+
get(id) {
|
|
97
|
+
const site = this.sites.find(s => s.id === id);
|
|
98
|
+
if (!site)
|
|
99
|
+
throw new Error(`Site "${id}" introuvable.`);
|
|
100
|
+
return site;
|
|
101
|
+
}
|
|
102
|
+
/** Premier site dont l'un des domaines couvre l'hôte de l'URL. */
|
|
103
|
+
siteForUrl(url) {
|
|
104
|
+
let host;
|
|
105
|
+
try {
|
|
106
|
+
host = new URL(url).hostname.toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return this.sites.find(s => s.domains.some(d => hostMatches(host, d))) ?? null;
|
|
112
|
+
}
|
|
113
|
+
create(config) {
|
|
114
|
+
let id = generateSiteId();
|
|
115
|
+
while (this.sites.some(s => s.id === id))
|
|
116
|
+
id = generateSiteId();
|
|
117
|
+
const site = {
|
|
118
|
+
...config,
|
|
119
|
+
id,
|
|
120
|
+
state: { status: 'unknown', sessionOpenedAt: null, lostAt: null, lastCheckAt: null, lastCheckError: null },
|
|
121
|
+
};
|
|
122
|
+
this.sites.push(site);
|
|
123
|
+
this.jars.set(id, []);
|
|
124
|
+
this.saveSites();
|
|
125
|
+
return site;
|
|
126
|
+
}
|
|
127
|
+
update(id, config) {
|
|
128
|
+
const site = this.get(id);
|
|
129
|
+
Object.assign(site, config);
|
|
130
|
+
this.saveSites();
|
|
131
|
+
return site;
|
|
132
|
+
}
|
|
133
|
+
remove(id) {
|
|
134
|
+
this.get(id);
|
|
135
|
+
this.sites = this.sites.filter(s => s.id !== id);
|
|
136
|
+
this.jars.delete(id);
|
|
137
|
+
this.jarUpdatedAt.delete(id);
|
|
138
|
+
fs.rmSync(this.jarFile(id), { force: true });
|
|
139
|
+
this.events = this.events.filter(e => e.siteId !== id);
|
|
140
|
+
this.saveSites();
|
|
141
|
+
writeJson(this.eventsFile, this.events);
|
|
142
|
+
}
|
|
143
|
+
setState(id, patch) {
|
|
144
|
+
const site = this.get(id);
|
|
145
|
+
site.state = { ...site.state, ...patch };
|
|
146
|
+
this.saveSites();
|
|
147
|
+
return site;
|
|
148
|
+
}
|
|
149
|
+
addEvent(siteId, kind, detail) {
|
|
150
|
+
this.events.push({ siteId, at: Date.now(), kind, detail });
|
|
151
|
+
if (this.events.length > MAX_EVENTS)
|
|
152
|
+
this.events.splice(0, this.events.length - MAX_EVENTS);
|
|
153
|
+
writeJson(this.eventsFile, this.events);
|
|
154
|
+
}
|
|
155
|
+
/** Événements du plus récent au plus ancien, éventuellement limités à un site. */
|
|
156
|
+
listEvents(opts = {}) {
|
|
157
|
+
const filtered = opts.siteId ? this.events.filter(e => e.siteId === opts.siteId) : this.events;
|
|
158
|
+
return filtered.slice().reverse().slice(0, opts.limit ?? 200);
|
|
159
|
+
}
|
|
160
|
+
/** Cookies encore valides du site. */
|
|
161
|
+
getJar(id) {
|
|
162
|
+
this.get(id);
|
|
163
|
+
const now = Date.now() / 1000;
|
|
164
|
+
return (this.jars.get(id) ?? []).filter(c => !isExpired(c, now));
|
|
165
|
+
}
|
|
166
|
+
jarUpdated(id) {
|
|
167
|
+
return this.jarUpdatedAt.get(id) ?? null;
|
|
168
|
+
}
|
|
169
|
+
/** Cookies encore valides de tous les sites. */
|
|
170
|
+
allCookies() {
|
|
171
|
+
return this.sites.flatMap(s => this.getJar(s.id));
|
|
172
|
+
}
|
|
173
|
+
/** Domaines dont le site garde les cookies : les siens, plus ceux déjà présents dans ses cookies. */
|
|
174
|
+
cookieScope(id) {
|
|
175
|
+
const site = this.get(id);
|
|
176
|
+
const jarDomains = (this.jars.get(id) ?? []).map(c => c.domain.replace(/^\./, '').toLowerCase());
|
|
177
|
+
return Array.from(new Set([...site.domains, ...jarDomains]));
|
|
178
|
+
}
|
|
179
|
+
/** Remplace les cookies du site. Retourne false quand ils sont identiques à ceux déjà enregistrés. */
|
|
180
|
+
writeJar(id, cookies) {
|
|
181
|
+
this.get(id);
|
|
182
|
+
const next = cookies.map(toCookieData).sort((a, b) => cookieKey(a).localeCompare(cookieKey(b)));
|
|
183
|
+
const current = this.jars.get(id) ?? [];
|
|
184
|
+
if (JSON.stringify(current) === JSON.stringify(next))
|
|
185
|
+
return false;
|
|
186
|
+
this.jars.set(id, next);
|
|
187
|
+
writeJson(this.jarFile(id), next);
|
|
188
|
+
this.jarUpdatedAt.set(id, Date.now());
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
let store = null;
|
|
193
|
+
/** Store du dossier de données courant, ou null si aucun dossier n'est configuré. */
|
|
194
|
+
export function getStore() {
|
|
195
|
+
const dir = getDataDir();
|
|
196
|
+
if (!dir) {
|
|
197
|
+
store = null;
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (!store || store.dir !== dir)
|
|
201
|
+
store = new SiteStore(dir);
|
|
202
|
+
return store;
|
|
203
|
+
}
|
|
204
|
+
/** Store du dossier de données courant ; échoue si aucun dossier n'est configuré. */
|
|
205
|
+
export function requireStore() {
|
|
206
|
+
const s = getStore();
|
|
207
|
+
if (!s)
|
|
208
|
+
throw new Error('SCRAPER_DATA_DIR (ou COOKIES_DIR) n\'est pas configuré : les sites sont désactivés.');
|
|
209
|
+
return s;
|
|
210
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/simple-scraper",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.27",
|
|
4
4
|
"description": "MCP server for web scraping and JavaScript execution using Puppeteer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@types/react-dom": "^19.2.3",
|
|
53
53
|
"@vitejs/plugin-react": "^6.0.1",
|
|
54
54
|
"concurrently": "^9.2.1",
|
|
55
|
+
"lucide-react": "^0.468.0",
|
|
55
56
|
"react": "^19.2.4",
|
|
56
57
|
"react-dom": "^19.2.4",
|
|
57
58
|
"tsx": "^4.21.0",
|