@imenam/simple-scraper 1.0.14 → 1.0.16
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 +16 -2
- package/dist/browser.js +24 -0
- package/dist/index.js +68 -8
- package/dist/sessions.js +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,11 +37,15 @@ simple-scraper
|
|
|
37
37
|
|----------|----------|---------|-------------|
|
|
38
38
|
| `PUPPETEER_HEADLESS` | No | `true` | Run Chromium in headless mode. Set to `false` to display the browser window. |
|
|
39
39
|
| `PUPPETEER_TIMEOUT` | No | `30000` | Default timeout in milliseconds for page navigation and waits. |
|
|
40
|
+
| `PUPPETEER_VIEWPORT_WIDTH` | No | `1280` | Viewport width in CSS pixels, applied to every page. Affects responsive layouts and lazy-loading, not just screenshots. |
|
|
41
|
+
| `PUPPETEER_VIEWPORT_HEIGHT` | No | `800` | Viewport height in CSS pixels, applied to every page. |
|
|
42
|
+
| `PUPPETEER_DEVICE_SCALE_FACTOR` | No | `1` | Physical pixels per CSS pixel. Set to `2` for retina-density screenshots (same framing, twice the detail, ~4× the file size). |
|
|
40
43
|
| `COOKIES_DIR` | No | - | Absolute path to a folder containing Netscape-format `.txt` cookie files. All files are loaded and merged automatically before each request. |
|
|
41
44
|
| `MCP_LOG_DIR` | No | `.mcp-gui/logs` | Absolute path to the directory where log files are written. |
|
|
42
45
|
| `PROXY_URL` | No | - | Base URL of the [MCP HTTP Gateway](https://www.npmjs.com/package/@imenam/mcp-http-gateway). Required to enable the GUI. |
|
|
43
46
|
| `PROXY_APP_PATH` | No | `/simple-scraper-mcp` | URL path under which the GUI is registered on the proxy. |
|
|
44
47
|
| `PROXY_APP_NAME` | No | `Simple Scraper MCP` | Display name shown in the proxy's app list. |
|
|
48
|
+
| `APP_GROUP` | No | - | Groups the app under a collapsible section (folded by default) in the proxy dashboard. Apps sharing the same value appear together; if unset, the app lands in the default "ungrouped" section. |
|
|
45
49
|
| `SCRAPER_MAX_SESSIONS` | No | `5` | Maximum number of concurrent interactive sessions. |
|
|
46
50
|
| `SCRAPER_SESSION_TTL_MS` | No | `600000` | Inactivity TTL for sessions in milliseconds (default: 10 minutes). Sessions unused beyond this duration are closed automatically. |
|
|
47
51
|
|
|
@@ -53,6 +57,9 @@ Copy `.env.example` to `.env` and configure the variables:
|
|
|
53
57
|
# Puppeteer options (optional)
|
|
54
58
|
PUPPETEER_HEADLESS=true
|
|
55
59
|
PUPPETEER_TIMEOUT=30000
|
|
60
|
+
PUPPETEER_VIEWPORT_WIDTH=1280
|
|
61
|
+
PUPPETEER_VIEWPORT_HEIGHT=800
|
|
62
|
+
PUPPETEER_DEVICE_SCALE_FACTOR=1
|
|
56
63
|
|
|
57
64
|
# Optional: path to a folder containing Netscape-format cookie files (.txt)
|
|
58
65
|
# All files in this folder will be loaded automatically before each request.
|
|
@@ -62,6 +69,8 @@ PUPPETEER_TIMEOUT=30000
|
|
|
62
69
|
# PROXY_URL=http://localhost:3000
|
|
63
70
|
# PROXY_APP_PATH=/simple-scraper-mcp
|
|
64
71
|
# PROXY_APP_NAME=Simple Scraper MCP
|
|
72
|
+
# Optional: groups this app under a collapsible section of the proxy dashboard
|
|
73
|
+
# APP_GROUP=Scraping
|
|
65
74
|
```
|
|
66
75
|
|
|
67
76
|
## Usage with Claude Desktop
|
|
@@ -229,6 +238,7 @@ Open a persistent browser session. Returns a `session_id` to use with all `sessi
|
|
|
229
238
|
| `url` | string | ✅ | URL to navigate to |
|
|
230
239
|
| `wait_for` | string | | CSS selector to wait for before the session is considered ready |
|
|
231
240
|
| `timeout` | number | | Timeout in ms (default: 30000) |
|
|
241
|
+
| `viewport` | object | | Viewport override for this session, applied before navigation: `{ width, height, device_scale_factor }`. Omitted fields fall back to the server defaults. |
|
|
232
242
|
|
|
233
243
|
### `close_session`
|
|
234
244
|
|
|
@@ -308,6 +318,8 @@ Return the current full rendered HTML of the session page.
|
|
|
308
318
|
|
|
309
319
|
Capture a screenshot of a page. Use `session_id` to capture an active session in its current state, or `url` for a one-shot capture.
|
|
310
320
|
|
|
321
|
+
The capture area is resolved by precedence: `selector` → `clip` → `full_page` → the visible viewport.
|
|
322
|
+
|
|
311
323
|
| Parameter | Type | Required | Description |
|
|
312
324
|
|-----------|------|----------|-------------|
|
|
313
325
|
| `session_id` | string | | Session ID. If provided, `url` is ignored and the current page state is captured. |
|
|
@@ -315,10 +327,12 @@ Capture a screenshot of a page. Use `session_id` to capture an active session in
|
|
|
315
327
|
| `wait_for` | string | | CSS selector to wait for (one-shot mode only) |
|
|
316
328
|
| `timeout` | number | | Timeout in ms (default: 30000) |
|
|
317
329
|
| `selector` | string | | CSS selector of a specific element to capture |
|
|
318
|
-
| `
|
|
330
|
+
| `clip` | object | | Arbitrary pixel region `{ x, y, width, height }` in CSS pixels, measured from the top-left of the page. May extend below the visible viewport. Ignored when `selector` is set; cannot be combined with `full_page`. |
|
|
331
|
+
| `viewport` | object | | Viewport override for this capture: `{ width, height, device_scale_factor }`. For a session, the previous viewport is restored afterwards. |
|
|
332
|
+
| `full_page` | boolean | | Capture the full scrollable page height (default: false, ignored when `selector` or `clip` is provided) |
|
|
319
333
|
| `format` | `png` \| `jpeg` | | Image format (default: `png`) |
|
|
320
334
|
| `output` | `inline` \| `file` \| `both` | ✅ | `inline` embeds the image in the response, `file` saves to disk and returns the path, `both` does both |
|
|
321
|
-
| `path` | string | | Absolute
|
|
335
|
+
| `path` | string | | **Absolute** path for the saved file — relative paths are rejected, since they would resolve against the server's working directory, which the client sets unpredictably. Missing parent directories are created. Defaults to `<server_root>/screenshots/screenshot-<timestamp>.<format>` |
|
|
322
336
|
|
|
323
337
|
---
|
|
324
338
|
|
package/dist/browser.js
CHANGED
|
@@ -4,6 +4,29 @@ export function getDefaultTimeout() {
|
|
|
4
4
|
const val = process.env.PUPPETEER_TIMEOUT;
|
|
5
5
|
return val ? parseInt(val, 10) : 30_000;
|
|
6
6
|
}
|
|
7
|
+
function envNumber(name, fallback) {
|
|
8
|
+
const raw = process.env[name];
|
|
9
|
+
if (!raw)
|
|
10
|
+
return fallback;
|
|
11
|
+
const parsed = Number(raw);
|
|
12
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
13
|
+
console.error(`[Browser] Ignoring invalid ${name}="${raw}", using ${fallback}`);
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Default viewport applied to every page. Puppeteer's own default is 800x600,
|
|
20
|
+
* which is narrow enough to trigger mobile/tablet layouts on many sites — that
|
|
21
|
+
* affects scraping (lazy-load, responsive hiding), not just screenshots.
|
|
22
|
+
*/
|
|
23
|
+
export function getDefaultViewport() {
|
|
24
|
+
return {
|
|
25
|
+
width: envNumber('PUPPETEER_VIEWPORT_WIDTH', 1280),
|
|
26
|
+
height: envNumber('PUPPETEER_VIEWPORT_HEIGHT', 800),
|
|
27
|
+
deviceScaleFactor: envNumber('PUPPETEER_DEVICE_SCALE_FACTOR', 1),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
7
30
|
export async function getBrowser() {
|
|
8
31
|
if (browser && browser.connected) {
|
|
9
32
|
return browser;
|
|
@@ -11,6 +34,7 @@ export async function getBrowser() {
|
|
|
11
34
|
const headless = process.env.PUPPETEER_HEADLESS !== 'false';
|
|
12
35
|
browser = await puppeteer.launch({
|
|
13
36
|
headless,
|
|
37
|
+
defaultViewport: getDefaultViewport(),
|
|
14
38
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
15
39
|
});
|
|
16
40
|
console.error('[Browser] Launched Puppeteer instance');
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { setupLogging } from './logger.js';
|
|
|
10
10
|
import { getBrowser, closeBrowser, getDefaultTimeout } from './browser.js';
|
|
11
11
|
import { loadAllCookies } from './cookies.js';
|
|
12
12
|
import { GuiLauncher, reconnectGuiToolDefinition } from '@imenam/mcp-gui-interface';
|
|
13
|
-
import { createSession, getSession, closeSession, closeAllSessions, listSessions, getSessionLogs, clearSessionLogs } from './sessions.js';
|
|
13
|
+
import { createSession, getSession, closeSession, closeAllSessions, listSessions, getSessionLogs, clearSessionLogs, resolveViewport } from './sessions.js';
|
|
14
14
|
import { registerSessionRpc } from './session-rpc.js';
|
|
15
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const rootDir = path.resolve(__dirname, '..');
|
|
@@ -33,6 +33,24 @@ const server = new McpServer({
|
|
|
33
33
|
name: 'simple-scraper-mcp',
|
|
34
34
|
version: '1.0.0',
|
|
35
35
|
});
|
|
36
|
+
// Shared viewport override. Any omitted field falls back to the env-configured
|
|
37
|
+
// default (PUPPETEER_VIEWPORT_WIDTH / _HEIGHT / _DEVICE_SCALE_FACTOR).
|
|
38
|
+
const viewportSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
width: z.number().int().positive().optional().describe('Viewport width in CSS pixels'),
|
|
41
|
+
height: z.number().int().positive().optional().describe('Viewport height in CSS pixels'),
|
|
42
|
+
device_scale_factor: z
|
|
43
|
+
.number()
|
|
44
|
+
.positive()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe('Physical pixels per CSS pixel, e.g. 2 for retina-density output (default: 1)'),
|
|
47
|
+
})
|
|
48
|
+
.optional();
|
|
49
|
+
function toViewportOverride(v) {
|
|
50
|
+
if (!v)
|
|
51
|
+
return undefined;
|
|
52
|
+
return { width: v.width, height: v.height, deviceScaleFactor: v.device_scale_factor };
|
|
53
|
+
}
|
|
36
54
|
// ─── One-shot helpers ─────────────────────────────────────────────────────────
|
|
37
55
|
async function setCookies(page, cookies, pageUrl) {
|
|
38
56
|
const prepared = cookies.map(c => {
|
|
@@ -329,9 +347,14 @@ server.tool('open_session', 'Open a persistent browser session on a URL. The pag
|
|
|
329
347
|
url: z.string().url().describe('URL to navigate to when opening the session'),
|
|
330
348
|
wait_for: z.string().optional().describe('CSS selector to wait for before the session is considered ready'),
|
|
331
349
|
timeout: z.number().optional().describe('Timeout in milliseconds (default: 30000)'),
|
|
332
|
-
|
|
350
|
+
viewport: viewportSchema.describe('Viewport override for this session, applied before navigation. Omitted fields fall back to the server default.'),
|
|
351
|
+
}, async ({ url, wait_for, timeout, viewport }) => {
|
|
333
352
|
try {
|
|
334
|
-
const sessionId = await createSession(url, {
|
|
353
|
+
const sessionId = await createSession(url, {
|
|
354
|
+
waitFor: wait_for,
|
|
355
|
+
timeout,
|
|
356
|
+
viewport: toViewportOverride(viewport),
|
|
357
|
+
});
|
|
335
358
|
return { content: [{ type: 'text', text: JSON.stringify({ session_id: sessionId }) }] };
|
|
336
359
|
}
|
|
337
360
|
catch (error) {
|
|
@@ -550,25 +573,50 @@ server.tool('session_scroll', 'Scroll the active session page. Use selector to s
|
|
|
550
573
|
}
|
|
551
574
|
});
|
|
552
575
|
// ─── screenshot ───────────────────────────────────────────────────────────────
|
|
553
|
-
server.tool('screenshot', 'Take a screenshot of a page. Provide either session_id (captures the page in its current interactive state) or url (one-shot: navigates, captures, closes).
|
|
576
|
+
server.tool('screenshot', 'Take a screenshot of a page. Provide either session_id (captures the page in its current interactive state) or url (one-shot: navigates, captures, closes). Capture area, by precedence: selector (a specific element), clip (an arbitrary pixel region), full_page (the whole scrollable height), otherwise the visible viewport. output controls the return format: "inline" returns a base64 image directly in the response, "file" writes the image to disk and returns the path, "both" does both.', {
|
|
554
577
|
session_id: z.string().optional().describe('Session ID of an active browser session. If provided, url is ignored and the current page state is captured.'),
|
|
555
578
|
url: z.string().url().optional().describe('URL to navigate to for a one-shot screenshot (required if session_id is not provided)'),
|
|
556
579
|
wait_for: z.string().optional().describe('CSS selector to wait for before capturing (one-shot mode only)'),
|
|
557
580
|
timeout: z.number().optional().describe('Timeout in milliseconds (default: 30000)'),
|
|
558
581
|
selector: z.string().optional().describe('CSS selector of a specific element to capture instead of the full page'),
|
|
559
|
-
|
|
582
|
+
clip: z
|
|
583
|
+
.object({
|
|
584
|
+
x: z.number().describe('Left offset in CSS pixels, from the top-left of the page'),
|
|
585
|
+
y: z.number().describe('Top offset in CSS pixels, from the top-left of the page'),
|
|
586
|
+
width: z.number().positive().describe('Region width in CSS pixels'),
|
|
587
|
+
height: z.number().positive().describe('Region height in CSS pixels'),
|
|
588
|
+
})
|
|
589
|
+
.optional()
|
|
590
|
+
.describe('Arbitrary pixel region to capture. Ignored when selector is provided, and cannot be combined with full_page.'),
|
|
591
|
+
viewport: viewportSchema.describe('Viewport override for this capture. For a session, the previous viewport is restored afterwards.'),
|
|
592
|
+
full_page: z.boolean().optional().describe('Capture the full scrollable page height (default: false, ignored when selector or clip is provided)'),
|
|
560
593
|
format: z.enum(['png', 'jpeg']).optional().describe('Image format (default: png)'),
|
|
561
594
|
output: z.enum(['inline', 'file', 'both']).describe('Return format: "inline" embeds the image in the response, "file" saves to disk and returns the path, "both" does both'),
|
|
562
|
-
path: z
|
|
563
|
-
|
|
595
|
+
path: z
|
|
596
|
+
.string()
|
|
597
|
+
.refine(p => path.isAbsolute(p), {
|
|
598
|
+
message: 'path must be absolute — a relative path would resolve against the MCP server\'s working directory, which is set by the client and not predictable.',
|
|
599
|
+
})
|
|
600
|
+
.optional()
|
|
601
|
+
.describe('Absolute file path for the saved image (used when output is "file" or "both"). Must be absolute; missing parent directories are created. Defaults to <server_root>/screenshots/screenshot-<timestamp>.<format>'),
|
|
602
|
+
}, async ({ session_id, url, wait_for, timeout, selector, clip, viewport, full_page, format, output, path: filePath }) => {
|
|
564
603
|
const effectiveTimeout = timeout ?? getDefaultTimeout();
|
|
565
604
|
const fmt = format ?? 'png';
|
|
566
605
|
const mimeType = fmt === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
606
|
+
if (clip && full_page) {
|
|
607
|
+
return { content: [{ type: 'text', text: 'Error: clip and full_page cannot be combined — clip already defines the region to capture.' }], isError: true };
|
|
608
|
+
}
|
|
567
609
|
let page = null;
|
|
568
610
|
let ownedPage = false;
|
|
611
|
+
// Only set for session pages, whose viewport must survive this call unchanged.
|
|
612
|
+
let previousViewport;
|
|
569
613
|
try {
|
|
570
614
|
if (session_id) {
|
|
571
615
|
page = getSession(session_id);
|
|
616
|
+
if (viewport) {
|
|
617
|
+
previousViewport = page.viewport();
|
|
618
|
+
await page.setViewport(resolveViewport(toViewportOverride(viewport)));
|
|
619
|
+
}
|
|
572
620
|
}
|
|
573
621
|
else {
|
|
574
622
|
if (!url) {
|
|
@@ -577,6 +625,9 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
|
|
|
577
625
|
const browser = await getBrowser();
|
|
578
626
|
page = await browser.newPage();
|
|
579
627
|
ownedPage = true;
|
|
628
|
+
if (viewport) {
|
|
629
|
+
await page.setViewport(resolveViewport(toViewportOverride(viewport)));
|
|
630
|
+
}
|
|
580
631
|
await applyCookies(page, url);
|
|
581
632
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: effectiveTimeout });
|
|
582
633
|
if (wait_for) {
|
|
@@ -592,11 +643,17 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
|
|
|
592
643
|
}
|
|
593
644
|
imageBuffer = Buffer.from(await element.screenshot({ type: fmt }));
|
|
594
645
|
}
|
|
646
|
+
else if (clip) {
|
|
647
|
+
// captureBeyondViewport lets the region extend past the visible area
|
|
648
|
+
// without having to scroll the page first.
|
|
649
|
+
imageBuffer = Buffer.from(await page.screenshot({ type: fmt, clip, captureBeyondViewport: true }));
|
|
650
|
+
}
|
|
595
651
|
else {
|
|
596
652
|
imageBuffer = Buffer.from(await page.screenshot({ type: fmt, fullPage: full_page ?? false }));
|
|
597
653
|
}
|
|
598
654
|
const base64 = imageBuffer.toString('base64');
|
|
599
|
-
// Resolve file path
|
|
655
|
+
// Resolve file path. filePath is guaranteed absolute by the schema; resolve()
|
|
656
|
+
// is kept only to normalise separators and any ".." segments.
|
|
600
657
|
const resolvedPath = (() => {
|
|
601
658
|
if (filePath)
|
|
602
659
|
return path.resolve(filePath);
|
|
@@ -624,6 +681,9 @@ server.tool('screenshot', 'Take a screenshot of a page. Provide either session_i
|
|
|
624
681
|
if (ownedPage && page) {
|
|
625
682
|
await page.close();
|
|
626
683
|
}
|
|
684
|
+
else if (page && previousViewport) {
|
|
685
|
+
await page.setViewport(previousViewport).catch(() => undefined);
|
|
686
|
+
}
|
|
627
687
|
}
|
|
628
688
|
});
|
|
629
689
|
function launchGUI() {
|
package/dist/sessions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getBrowser } from './browser.js';
|
|
1
|
+
import { getBrowser, getDefaultViewport } from './browser.js';
|
|
2
2
|
import { loadAllCookies } from './cookies.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);
|
|
@@ -13,6 +13,17 @@ async function applyCookies(page, pageUrl) {
|
|
|
13
13
|
const prepared = cookies.map(c => (c.domain ? c : { ...c, url: pageUrl }));
|
|
14
14
|
await page.setCookie(...prepared);
|
|
15
15
|
}
|
|
16
|
+
/** Merge a partial override onto the env-configured default viewport. */
|
|
17
|
+
export function resolveViewport(override) {
|
|
18
|
+
const base = getDefaultViewport();
|
|
19
|
+
if (!override)
|
|
20
|
+
return base;
|
|
21
|
+
return {
|
|
22
|
+
width: override.width ?? base.width,
|
|
23
|
+
height: override.height ?? base.height,
|
|
24
|
+
deviceScaleFactor: override.deviceScaleFactor ?? base.deviceScaleFactor,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
16
27
|
export async function createSession(url, opts = {}) {
|
|
17
28
|
if (sessions.size >= MAX_SESSIONS) {
|
|
18
29
|
throw new Error(`Session limit reached (max ${MAX_SESSIONS}). Close an existing session before opening a new one.`);
|
|
@@ -25,6 +36,9 @@ export async function createSession(url, opts = {}) {
|
|
|
25
36
|
entry.consoleLogs.push({ type: msg.type(), text: msg.text(), timestamp: Date.now() });
|
|
26
37
|
});
|
|
27
38
|
try {
|
|
39
|
+
if (opts.viewport) {
|
|
40
|
+
await page.setViewport(resolveViewport(opts.viewport));
|
|
41
|
+
}
|
|
28
42
|
await applyCookies(page, url);
|
|
29
43
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: opts.timeout ?? 30_000 });
|
|
30
44
|
if (opts.waitFor) {
|