scrapeunblocker-mcp 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -1
- package/dist/index.js +184 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,10 +11,51 @@ You bring your **own** API key. Nothing is shared or proxied through us.
|
|
|
11
11
|
|
|
12
12
|
| Tool | What it does |
|
|
13
13
|
|------|--------------|
|
|
14
|
-
| `fetch_html` | Fetch the fully rendered HTML of a URL. |
|
|
14
|
+
| `fetch_html` | Fetch the fully rendered HTML of a URL (optionally after running interactive browser `steps`). |
|
|
15
|
+
| `list_elements` | List a page's notable elements with a ready-to-use selector for each (selector discovery). |
|
|
15
16
|
| `fetch_parsed` | Fetch a page and return AI-parsed structured JSON. |
|
|
16
17
|
| `google_search` | Run a Google search and return organic results as JSON. |
|
|
17
18
|
|
|
19
|
+
### Browser steps (interact, then capture)
|
|
20
|
+
|
|
21
|
+
Some pages only reveal what you need after you interact with them - accept a
|
|
22
|
+
cookie banner, click a tab, type into a search box and submit, or scroll to
|
|
23
|
+
trigger lazy loading. Pass an optional `steps` array to `fetch_html` and those
|
|
24
|
+
actions run in a **real browser**, in order, after the page loads; the resulting
|
|
25
|
+
HTML is then returned.
|
|
26
|
+
|
|
27
|
+
Available actions:
|
|
28
|
+
|
|
29
|
+
| Action | Fields |
|
|
30
|
+
|--------|--------|
|
|
31
|
+
| `wait_for` | `selector`, `selector_type?` (`css`/`xPath`/`className`/`tagName`), `timeout_ms?` |
|
|
32
|
+
| `wait_for_text` | `value`, `timeout_ms?` |
|
|
33
|
+
| `wait` | `value` (ms) |
|
|
34
|
+
| `click` | `selector`, `selector_type?`, `timeout_ms?` |
|
|
35
|
+
| `type` | `selector`, `selector_type?`, `value`, `clear?`, `timeout_ms?` (typed human-like) |
|
|
36
|
+
| `select` | `selector`, `selector_type?`, `value`, `timeout_ms?` |
|
|
37
|
+
| `press_key` | `value` (`Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Space`, `Arrow*`, `Home`, `End`, `PageUp`, `PageDown`) |
|
|
38
|
+
| `scroll` | `value` (`"bottom"` or a pixel offset) |
|
|
39
|
+
|
|
40
|
+
Steps are **not idempotent** - they run once per call. If a step fails,
|
|
41
|
+
`fetch_html` returns which step failed, why, and the page HTML at that moment so
|
|
42
|
+
you can fix the selector and retry.
|
|
43
|
+
|
|
44
|
+
### List elements (discover selectors first)
|
|
45
|
+
|
|
46
|
+
`list_elements` loads a page and returns a JSON list of its notable elements
|
|
47
|
+
(links, inputs, buttons, selects, ...), each with a ready-to-use `selector` plus
|
|
48
|
+
`tag`, `text` and useful attributes (`name`, `id`, `type`, `placeholder`,
|
|
49
|
+
`aria_label`, `href`, ...):
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{ "url": "https://example.com", "count": 42, "elements": [ { "tag": "input", "selector": "#search", "type": "text", "placeholder": "Search", "aria_label": "Search" } ] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The natural workflow is **discover, then act**: call `list_elements` to find the
|
|
56
|
+
selectors you need, then pass matching `steps` to `fetch_html` to click/type/
|
|
57
|
+
select and capture the resulting HTML.
|
|
58
|
+
|
|
18
59
|
## Get an API key
|
|
19
60
|
|
|
20
61
|
Sign up and grab your key at **[app.scrapeunblocker.com](https://app.scrapeunblocker.com?utm_source=mcp&utm_medium=integration&utm_campaign=mcp-server)**. The server
|
|
@@ -24,6 +65,19 @@ reads it from the `SCRAPEUNBLOCKER_KEY` environment variable.
|
|
|
24
65
|
|
|
25
66
|
### Claude Code
|
|
26
67
|
|
|
68
|
+
The easiest route is the official **plugin**, which installs this server for you,
|
|
69
|
+
prompts for your API key (stored in your OS keychain rather than an environment
|
|
70
|
+
variable), and adds a `/scrape-url` command plus reference skills:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
/plugin marketplace add ScrapeUnblocker/claude-code-plugin
|
|
74
|
+
/plugin install scrapeunblocker@scrapeunblocker
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
See [ScrapeUnblocker/claude-code-plugin](https://github.com/ScrapeUnblocker/claude-code-plugin).
|
|
78
|
+
|
|
79
|
+
To add the bare server instead:
|
|
80
|
+
|
|
27
81
|
```bash
|
|
28
82
|
claude mcp add scrapeunblocker \
|
|
29
83
|
--env SCRAPEUNBLOCKER_KEY=your_api_key_here \
|
|
@@ -63,6 +117,7 @@ SCRAPEUNBLOCKER_KEY=your_api_key_here npx -y scrapeunblocker-mcp
|
|
|
63
117
|
|
|
64
118
|
- "Fetch the HTML of https://www.example-shop.com/product/123 and list the price."
|
|
65
119
|
- "This page keeps blocking me: <url>. Use fetch_html to get it."
|
|
120
|
+
- "List the elements on <url>, then use fetch_html steps to type 'laptop' into the search box, press Enter, wait for the results, and give me the HTML."
|
|
66
121
|
- "Search Google for 'best running shoes 2026' and give me the top 5 links."
|
|
67
122
|
|
|
68
123
|
## Development
|
package/dist/index.js
CHANGED
|
@@ -5,11 +5,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { ScrapeUnblockerClient } from "scrapeunblocker";
|
|
8
|
-
var VERSION = "0.
|
|
8
|
+
var VERSION = "0.2.0";
|
|
9
|
+
var DEFAULT_BASE_URL = "https://api.scrapeunblocker.com";
|
|
10
|
+
var API_KEY_HEADER = "x-scrapeunblocker-key";
|
|
9
11
|
function resolveApiKey() {
|
|
10
12
|
return process.env.SCRAPEUNBLOCKER_KEY ?? process.env.SCRAPEUNBLOCKER_API_KEY ?? void 0;
|
|
11
13
|
}
|
|
12
14
|
var MISSING_KEY_MESSAGE = "No ScrapeUnblocker API key found. Set the SCRAPEUNBLOCKER_KEY environment variable to your own key (get one at https://app.scrapeunblocker.com) and restart the MCP server.";
|
|
15
|
+
function resolveBaseUrl() {
|
|
16
|
+
return (process.env.SCRAPEUNBLOCKER_BASE_URL || DEFAULT_BASE_URL).replace(
|
|
17
|
+
/\/+$/,
|
|
18
|
+
""
|
|
19
|
+
);
|
|
20
|
+
}
|
|
13
21
|
function client() {
|
|
14
22
|
const apiKey = resolveApiKey();
|
|
15
23
|
if (!apiKey) throw new Error(MISSING_KEY_MESSAGE);
|
|
@@ -20,15 +28,98 @@ function errorText(err) {
|
|
|
20
28
|
if (err instanceof Error) return err.message;
|
|
21
29
|
return String(err);
|
|
22
30
|
}
|
|
31
|
+
async function getPageSourceRaw(params) {
|
|
32
|
+
const apiKey = resolveApiKey();
|
|
33
|
+
if (!apiKey) throw new Error(MISSING_KEY_MESSAGE);
|
|
34
|
+
const query = new URLSearchParams();
|
|
35
|
+
for (const [key, value] of Object.entries(params)) {
|
|
36
|
+
if (value === void 0 || value === null) continue;
|
|
37
|
+
query.set(key, String(value));
|
|
38
|
+
}
|
|
39
|
+
const url = `${resolveBaseUrl()}/getPageSource?${query.toString()}`;
|
|
40
|
+
const response = await fetch(url, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
[API_KEY_HEADER]: apiKey,
|
|
44
|
+
"User-Agent": `scrapeunblocker-mcp/${VERSION}`,
|
|
45
|
+
Accept: "*/*"
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
return { status: response.status, ok: response.ok, text };
|
|
50
|
+
}
|
|
23
51
|
var server = new McpServer({
|
|
24
52
|
name: "scrapeunblocker",
|
|
25
53
|
version: VERSION
|
|
26
54
|
});
|
|
55
|
+
var selectorTypeSchema = z.enum(["css", "xPath", "className", "tagName"]).describe("How `selector` is interpreted. Defaults to 'css'.");
|
|
56
|
+
var pressableKeySchema = z.enum([
|
|
57
|
+
"Enter",
|
|
58
|
+
"Tab",
|
|
59
|
+
"Escape",
|
|
60
|
+
"Backspace",
|
|
61
|
+
"Delete",
|
|
62
|
+
"Space",
|
|
63
|
+
"ArrowUp",
|
|
64
|
+
"ArrowDown",
|
|
65
|
+
"ArrowLeft",
|
|
66
|
+
"ArrowRight",
|
|
67
|
+
"Home",
|
|
68
|
+
"End",
|
|
69
|
+
"PageUp",
|
|
70
|
+
"PageDown"
|
|
71
|
+
]);
|
|
72
|
+
var stepSchema = z.discriminatedUnion("action", [
|
|
73
|
+
z.object({
|
|
74
|
+
action: z.literal("wait_for"),
|
|
75
|
+
selector: z.string().describe("Selector to wait for (until present)."),
|
|
76
|
+
selector_type: selectorTypeSchema.optional(),
|
|
77
|
+
timeout_ms: z.number().int().positive().optional()
|
|
78
|
+
}).describe("Wait until an element matching `selector` exists."),
|
|
79
|
+
z.object({
|
|
80
|
+
action: z.literal("wait_for_text"),
|
|
81
|
+
value: z.string().describe("Text to wait for anywhere on the page."),
|
|
82
|
+
timeout_ms: z.number().int().positive().optional()
|
|
83
|
+
}).describe("Wait until the given text appears on the page."),
|
|
84
|
+
z.object({
|
|
85
|
+
action: z.literal("wait"),
|
|
86
|
+
value: z.number().int().nonnegative().describe("Fixed pause in milliseconds.")
|
|
87
|
+
}).describe("Pause for a fixed number of milliseconds."),
|
|
88
|
+
z.object({
|
|
89
|
+
action: z.literal("click"),
|
|
90
|
+
selector: z.string().describe("Selector of the element to click."),
|
|
91
|
+
selector_type: selectorTypeSchema.optional(),
|
|
92
|
+
timeout_ms: z.number().int().positive().optional()
|
|
93
|
+
}).describe("Click the element matching `selector`."),
|
|
94
|
+
z.object({
|
|
95
|
+
action: z.literal("type"),
|
|
96
|
+
selector: z.string().describe("Selector of the field to type into."),
|
|
97
|
+
selector_type: selectorTypeSchema.optional(),
|
|
98
|
+
value: z.string().describe("Text to type (entered human-like)."),
|
|
99
|
+
clear: z.boolean().optional().describe("Clear the field before typing."),
|
|
100
|
+
timeout_ms: z.number().int().positive().optional()
|
|
101
|
+
}).describe("Type text into an input, character by character."),
|
|
102
|
+
z.object({
|
|
103
|
+
action: z.literal("select"),
|
|
104
|
+
selector: z.string().describe("Selector of the <select> element."),
|
|
105
|
+
selector_type: selectorTypeSchema.optional(),
|
|
106
|
+
value: z.string().describe("The option value to select."),
|
|
107
|
+
timeout_ms: z.number().int().positive().optional()
|
|
108
|
+
}).describe("Choose an option in a <select> dropdown by value."),
|
|
109
|
+
z.object({
|
|
110
|
+
action: z.literal("press_key"),
|
|
111
|
+
value: pressableKeySchema.describe("The key to press.")
|
|
112
|
+
}).describe("Press a single keyboard key."),
|
|
113
|
+
z.object({
|
|
114
|
+
action: z.literal("scroll"),
|
|
115
|
+
value: z.union([z.literal("bottom"), z.number().int()]).describe("'bottom' to scroll to the end, or a pixel offset.")
|
|
116
|
+
}).describe("Scroll the page to the bottom or by a pixel amount.")
|
|
117
|
+
]);
|
|
27
118
|
server.registerTool(
|
|
28
119
|
"fetch_html",
|
|
29
120
|
{
|
|
30
121
|
title: "Fetch page HTML",
|
|
31
|
-
description: "Fetch the fully rendered HTML of any web page through ScrapeUnblocker, bypassing anti-bot protection (Cloudflare, DataDome, PerimeterX, Akamai, Shape). Use this when a normal fetch is blocked (403/429, captcha, 'access denied') or when the page needs a real browser to render. Returns the raw HTML as text.",
|
|
122
|
+
description: "Fetch the fully rendered HTML of any web page through ScrapeUnblocker, bypassing anti-bot protection (Cloudflare, DataDome, PerimeterX, Akamai, Shape). Use this when a normal fetch is blocked (403/429, captcha, 'access denied') or when the page needs a real browser to render. Returns the raw HTML as text.\n\nFor pages that need interaction (accept a cookie banner, click a tab, type into a search box, scroll to trigger lazy loading) pass `steps`: an ordered list of browser actions run in a real browser AFTER the page loads, then the resulting HTML is returned. Workflow: first call the `list_elements` tool to discover the real selectors on the page, then build `steps` against them. Steps are NOT idempotent - they run once per call. If a step fails, this tool returns which step failed, why, and the page HTML at that moment so you can fix the selector and retry.",
|
|
32
123
|
inputSchema: {
|
|
33
124
|
url: z.string().url().describe("The absolute URL to fetch (http/https)."),
|
|
34
125
|
proxy_country: z.string().length(2).optional().describe(
|
|
@@ -41,19 +132,104 @@ server.registerTool(
|
|
|
41
132
|
`The CSS selector or JS expression paired with wait_method (e.g. '#price' or 'document.readyState==="complete"').`
|
|
42
133
|
),
|
|
43
134
|
method_timeout_seconds: z.number().int().positive().optional().describe("Cap in seconds for the render-wait method."),
|
|
44
|
-
sleep_seconds: z.number().positive().optional().describe("Extra seconds to wait after load before capturing the HTML.")
|
|
135
|
+
sleep_seconds: z.number().positive().optional().describe("Extra seconds to wait after load before capturing the HTML."),
|
|
136
|
+
steps: z.array(stepSchema).optional().describe(
|
|
137
|
+
"Optional ordered browser actions to run in a real browser after the page loads, before the HTML is captured. Use `list_elements` first to find selectors. Runs once (not idempotent)."
|
|
138
|
+
)
|
|
45
139
|
}
|
|
46
140
|
},
|
|
47
141
|
async (args) => {
|
|
48
142
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
143
|
+
if (!args.steps || args.steps.length === 0) {
|
|
144
|
+
const html = await client().getPageSource(args.url, {
|
|
145
|
+
proxyCountry: args.proxy_country,
|
|
146
|
+
method: args.wait_method,
|
|
147
|
+
value: args.wait_value,
|
|
148
|
+
methodTimeout: args.method_timeout_seconds,
|
|
149
|
+
timeSleep: args.sleep_seconds
|
|
150
|
+
});
|
|
151
|
+
return { content: [{ type: "text", text: html }] };
|
|
152
|
+
}
|
|
153
|
+
const res = await getPageSourceRaw({
|
|
154
|
+
url: args.url,
|
|
155
|
+
proxy_country: args.proxy_country,
|
|
51
156
|
method: args.wait_method,
|
|
52
157
|
value: args.wait_value,
|
|
53
|
-
|
|
54
|
-
|
|
158
|
+
method_timeout: args.method_timeout_seconds,
|
|
159
|
+
time_sleep: args.sleep_seconds,
|
|
160
|
+
steps: JSON.stringify(args.steps)
|
|
161
|
+
});
|
|
162
|
+
if (res.ok) {
|
|
163
|
+
return { content: [{ type: "text", text: res.text }] };
|
|
164
|
+
}
|
|
165
|
+
if (res.status === 422) {
|
|
166
|
+
let detail = {};
|
|
167
|
+
try {
|
|
168
|
+
detail = JSON.parse(res.text);
|
|
169
|
+
} catch {
|
|
170
|
+
}
|
|
171
|
+
if (detail.error === "step_failed") {
|
|
172
|
+
const idx = typeof detail.step_index === "number" ? detail.step_index : void 0;
|
|
173
|
+
const summary = `Browser step failed` + (idx !== void 0 ? ` at index ${idx}` : "") + (detail.action ? ` (${detail.action})` : "") + (detail.selector ? ` on selector ${JSON.stringify(detail.selector)}` : "") + `: ${detail.reason ?? "unknown reason"}.
|
|
174
|
+
|
|
175
|
+
Fix the selector or step (use list_elements to inspect the page) and retry. The page HTML at the point of failure follows:
|
|
176
|
+
|
|
177
|
+
` + (detail.html ?? "");
|
|
178
|
+
return { content: [{ type: "text", text: summary }], isError: true };
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: "text", text: res.text }],
|
|
182
|
+
isError: true
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: `ScrapeUnblocker returned HTTP ${res.status}: ${res.text.slice(0, 500)}`
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
isError: true
|
|
193
|
+
};
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { content: [{ type: "text", text: errorText(err) }], isError: true };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
server.registerTool(
|
|
200
|
+
"list_elements",
|
|
201
|
+
{
|
|
202
|
+
title: "List page elements (selector discovery)",
|
|
203
|
+
description: "Load a page through ScrapeUnblocker and return a JSON list of its notable elements (links, inputs, buttons, selects, etc.) with a ready-to-use `selector` for each, plus tag, text and useful attributes (name, id, type, placeholder, aria_label, href, ...). Read-only and does not interact with the page. This is the discovery half of interactive scraping: call `list_elements` to find the selectors you need, then pass matching `steps` to `fetch_html` to click/type/select and capture the resulting HTML.",
|
|
204
|
+
inputSchema: {
|
|
205
|
+
url: z.string().url().describe("The absolute URL whose elements you want to list."),
|
|
206
|
+
proxy_country: z.string().length(2).optional().describe("Optional ISO country code to route through, e.g. 'US'.")
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
async (args) => {
|
|
210
|
+
try {
|
|
211
|
+
const res = await getPageSourceRaw({
|
|
212
|
+
url: args.url,
|
|
213
|
+
list_elements: true,
|
|
214
|
+
proxy_country: args.proxy_country
|
|
55
215
|
});
|
|
56
|
-
|
|
216
|
+
if (!res.ok) {
|
|
217
|
+
return {
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: "text",
|
|
221
|
+
text: `ScrapeUnblocker returned HTTP ${res.status}: ${res.text.slice(0, 500)}`
|
|
222
|
+
}
|
|
223
|
+
],
|
|
224
|
+
isError: true
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
let out = res.text;
|
|
228
|
+
try {
|
|
229
|
+
out = JSON.stringify(JSON.parse(res.text), null, 2);
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
return { content: [{ type: "text", text: out }] };
|
|
57
233
|
} catch (err) {
|
|
58
234
|
return { content: [{ type: "text", text: errorText(err) }], isError: true };
|
|
59
235
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeunblocker-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"mcpName": "com.scrapeunblocker/scrapeunblocker-mcp",
|
|
5
5
|
"description": "Model Context Protocol (MCP) server for ScrapeUnblocker - lets Claude fetch any web page's HTML (or AI-parsed JSON) through the ScrapeUnblocker anti-bot API, using your own API key.",
|
|
6
6
|
"keywords": [
|