scrapeunblocker-mcp 0.3.5 → 0.5.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 +7 -1
- package/dist/index.js +426 -66
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -16,8 +16,11 @@ You bring your **own** API key. Nothing is shared or proxied through us.
|
|
|
16
16
|
| `list_elements` | List a page's notable elements with a ready-to-use selector for each (selector discovery). |
|
|
17
17
|
| `fetch_parsed` | Fetch a page and return AI-parsed structured JSON. |
|
|
18
18
|
| `google_search` | Run a Google search and return organic results as JSON. |
|
|
19
|
+
| `fetch_markdown` | Fetch a page and return it as clean Markdown: the main content with absolute links, without scripts, styles, navigation or footer. Usually 2-25% the size of the HTML, so it is the cheap way for a model to read a page. |
|
|
20
|
+
| `search_and_read` | Search Google and read the top results in one call: Google's AI Overview (when there is one), the first 1-5 result pages as Markdown, and the other results as title, URL and snippet. Billed as one search plus one request per page read. |
|
|
19
21
|
| `list_plugins` | List the ready-made site plugins (100+: Amazon, Walmart, Zillow, Booking.com, Indeed, Google Trends, YouTube, App Store, Ahrefs, ...) with their parameters. Free - it calls nothing. |
|
|
20
22
|
| `run_plugin` | Run one site plugin and return its structured JSON. |
|
|
23
|
+
| `amazon_search`, `amazon_product`, `amazon_reviews`, `walmart_search`, `ebay_search`, `google_shopping`, `google_local`, `maps_place_details`, `google_images`, `youtube_search`, `youtube_transcript`, `zillow_search`, `indeed_jobs`, `trustpilot_reviews`, `booking_hotels` | The most-used site plugins as tools of their own, each with its parameters in the tool schema - one call, no `list_plugins` lookup. Same JSON as `run_plugin`. |
|
|
21
24
|
|
|
22
25
|
### Site plugins (structured JSON for known sites)
|
|
23
26
|
|
|
@@ -25,7 +28,8 @@ For many popular sites there is a plugin that returns the data already
|
|
|
25
28
|
parsed - a Target search, a Zillow listing search, an app's App Store reviews,
|
|
26
29
|
Google Trends for a few keywords. The model calls `list_plugins` (optionally
|
|
27
30
|
with `search: "zillow"` or a `category`) to find the plugin and its
|
|
28
|
-
parameters, then `run_plugin` with `name` and `params
|
|
31
|
+
parameters, then `run_plugin` with `name` and `params` (the most-used plugins, listed in
|
|
32
|
+
the table above, can also be called directly as their own tools):
|
|
29
33
|
|
|
30
34
|
```json
|
|
31
35
|
{ "name": "target_search", "params": { "query": "coffee maker", "max_results": 20 } }
|
|
@@ -138,6 +142,8 @@ SCRAPEUNBLOCKER_KEY=your_api_key_here npx -y scrapeunblocker-mcp
|
|
|
138
142
|
- "This page keeps blocking me: <url>. Use fetch_html to get it."
|
|
139
143
|
- "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."
|
|
140
144
|
- "Search Google for 'best running shoes 2026' and give me the top 5 links."
|
|
145
|
+
- "Read https://en.wikipedia.org/wiki/Web_scraping as Markdown and summarise it in five bullets."
|
|
146
|
+
- "Search the web for the current population of Vilnius and cite the pages you read."
|
|
141
147
|
- "Find coffee makers on Target under $50 and compare them with Walmart's."
|
|
142
148
|
- "Show Google Trends for 'coffee' vs 'tea' in the US over the last year."
|
|
143
149
|
- "What are the newest App Store reviews of WhatsApp saying?"
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
import { z as
|
|
6
|
+
import { z as z3 } from "zod";
|
|
7
7
|
import { APIError, ScrapeUnblockerClient } from "scrapeunblocker";
|
|
8
8
|
|
|
9
9
|
// src/plugin-tools.ts
|
|
@@ -5506,6 +5506,23 @@ var MAX_OUTPUT_CHARS = 1e5;
|
|
|
5506
5506
|
var BY_NAME = new Map(PLUGINS.map((p) => [p.name, p]));
|
|
5507
5507
|
var NAMES = PLUGINS.map((p) => p.name);
|
|
5508
5508
|
var CATEGORIES2 = CATEGORIES;
|
|
5509
|
+
var FEATURED_PLUGINS = [
|
|
5510
|
+
"amazon_search",
|
|
5511
|
+
"amazon_product",
|
|
5512
|
+
"amazon_reviews",
|
|
5513
|
+
"walmart_search",
|
|
5514
|
+
"ebay_search",
|
|
5515
|
+
"google_shopping",
|
|
5516
|
+
"google_local",
|
|
5517
|
+
"maps_place_details",
|
|
5518
|
+
"google_images",
|
|
5519
|
+
"youtube_search",
|
|
5520
|
+
"youtube_transcript",
|
|
5521
|
+
"zillow_search",
|
|
5522
|
+
"indeed_jobs",
|
|
5523
|
+
"trustpilot_reviews",
|
|
5524
|
+
"booking_hotels"
|
|
5525
|
+
];
|
|
5509
5526
|
var text = (t, isError = false) => isError ? { content: [{ type: "text", text: t }], isError: true } : { content: [{ type: "text", text: t }] };
|
|
5510
5527
|
function signature(p) {
|
|
5511
5528
|
return `${p.name}(${p.params.map((q) => q.required ? `${q.name}*` : q.name).join(", ")})`;
|
|
@@ -5564,8 +5581,49 @@ function prepare(p, params) {
|
|
|
5564
5581
|
}
|
|
5565
5582
|
return out;
|
|
5566
5583
|
}
|
|
5584
|
+
function paramType(q) {
|
|
5585
|
+
const scalar = (t) => t === "integer" ? z.number().int() : t === "number" ? z.number() : t === "boolean" ? z.boolean() : z.string();
|
|
5586
|
+
let type;
|
|
5587
|
+
if (q.type === "array") {
|
|
5588
|
+
const item = scalar(q.items);
|
|
5589
|
+
type = z.union([z.array(item), item]);
|
|
5590
|
+
} else if (q.enum && q.enum.length && q.enum.every((v) => typeof v === "string")) {
|
|
5591
|
+
type = z.enum(q.enum);
|
|
5592
|
+
if (q.join) type = z.union([type, z.array(type)]);
|
|
5593
|
+
} else {
|
|
5594
|
+
type = scalar(q.type);
|
|
5595
|
+
if (q.join) type = z.union([type, z.array(type)]);
|
|
5596
|
+
}
|
|
5597
|
+
const extras = [
|
|
5598
|
+
q.enum && !q.enum.every((v) => typeof v === "string") ? `One of: ${q.enum.join(", ")}.` : "",
|
|
5599
|
+
q.default !== void 0 ? `Default: ${String(q.default)}.` : ""
|
|
5600
|
+
].filter(Boolean);
|
|
5601
|
+
const description = [q.description, ...extras].filter(Boolean).join(" ");
|
|
5602
|
+
if (description) type = type.describe(description);
|
|
5603
|
+
return q.required ? type : type.optional();
|
|
5604
|
+
}
|
|
5567
5605
|
function registerPluginTools(server2, options) {
|
|
5568
5606
|
const annotations = { readOnlyHint: true, openWorldHint: true, destructiveHint: false, ...options.annotations };
|
|
5607
|
+
async function run(plugin, raw) {
|
|
5608
|
+
const blocked = options.blocked?.();
|
|
5609
|
+
if (blocked) return blocked;
|
|
5610
|
+
const params = prepare(plugin, raw);
|
|
5611
|
+
if (typeof params === "string") return text(`${params}
|
|
5612
|
+
|
|
5613
|
+
${details(plugin)}`, true);
|
|
5614
|
+
try {
|
|
5615
|
+
const result = await options.call(plugin.path, params);
|
|
5616
|
+
const body = JSON.stringify(result);
|
|
5617
|
+
if (body.length <= MAX_OUTPUT_CHARS) return text(body);
|
|
5618
|
+
return text(
|
|
5619
|
+
body.slice(0, MAX_OUTPUT_CHARS) + `
|
|
5620
|
+
|
|
5621
|
+
[Cut at ${MAX_OUTPUT_CHARS} of ${body.length} characters - ask for fewer results (max_results) or a narrower filter to get the whole answer.]`
|
|
5622
|
+
);
|
|
5623
|
+
} catch (err) {
|
|
5624
|
+
return text(options.errorText(err), true);
|
|
5625
|
+
}
|
|
5626
|
+
}
|
|
5569
5627
|
server2.registerTool(
|
|
5570
5628
|
"list_plugins",
|
|
5571
5629
|
{
|
|
@@ -5604,32 +5662,324 @@ function registerPluginTools(server2, options) {
|
|
|
5604
5662
|
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))])).optional().describe(`The plugin's parameters, e.g. {"query": "coffee maker", "max_results": 20}.`)
|
|
5605
5663
|
}
|
|
5606
5664
|
},
|
|
5665
|
+
async (args) => run(BY_NAME.get(args.name), args.params ?? {})
|
|
5666
|
+
);
|
|
5667
|
+
for (const name of FEATURED_PLUGINS) {
|
|
5668
|
+
const plugin = BY_NAME.get(name);
|
|
5669
|
+
if (!plugin) continue;
|
|
5670
|
+
const shape = Object.fromEntries(plugin.params.map((q) => [q.name, paramType(q)]));
|
|
5671
|
+
server2.registerTool(
|
|
5672
|
+
plugin.name,
|
|
5673
|
+
{
|
|
5674
|
+
title: plugin.title,
|
|
5675
|
+
annotations: { title: plugin.title, ...annotations },
|
|
5676
|
+
description: `${plugin.description} Returns JSON. Billed like an API request; listing plugins bill one unit per result page they fetch, so keep max_results modest.`,
|
|
5677
|
+
inputSchema: shape
|
|
5678
|
+
},
|
|
5679
|
+
async (args) => {
|
|
5680
|
+
const raw = Object.fromEntries(
|
|
5681
|
+
Object.entries(args).filter(([, v]) => v !== void 0)
|
|
5682
|
+
);
|
|
5683
|
+
return run(plugin, raw);
|
|
5684
|
+
}
|
|
5685
|
+
);
|
|
5686
|
+
}
|
|
5687
|
+
}
|
|
5688
|
+
|
|
5689
|
+
// src/reading-tools.ts
|
|
5690
|
+
import { z as z2 } from "zod";
|
|
5691
|
+
|
|
5692
|
+
// src/markdown.ts
|
|
5693
|
+
import { parseHTML } from "linkedom";
|
|
5694
|
+
import TurndownService from "turndown";
|
|
5695
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
5696
|
+
var ALWAYS_DROP = [
|
|
5697
|
+
"script",
|
|
5698
|
+
"style",
|
|
5699
|
+
"noscript",
|
|
5700
|
+
"template",
|
|
5701
|
+
"svg",
|
|
5702
|
+
"canvas",
|
|
5703
|
+
"iframe",
|
|
5704
|
+
"object",
|
|
5705
|
+
"embed",
|
|
5706
|
+
"link",
|
|
5707
|
+
"meta",
|
|
5708
|
+
"head",
|
|
5709
|
+
"button",
|
|
5710
|
+
"input",
|
|
5711
|
+
"select",
|
|
5712
|
+
"textarea",
|
|
5713
|
+
"dialog",
|
|
5714
|
+
"[hidden]",
|
|
5715
|
+
'[aria-hidden="true"]'
|
|
5716
|
+
].join(",");
|
|
5717
|
+
var CHROME = ["nav", "footer", "aside", '[role="navigation"]', '[role="contentinfo"]', '[role="complementary"]'].join(",");
|
|
5718
|
+
var MAIN_MIN_SHARE = 0.3;
|
|
5719
|
+
function textLength(el) {
|
|
5720
|
+
return (el?.textContent ?? "").replace(/\s+/g, " ").trim().length;
|
|
5721
|
+
}
|
|
5722
|
+
function absolute(href, base) {
|
|
5723
|
+
try {
|
|
5724
|
+
const url = new URL(href, base);
|
|
5725
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
5726
|
+
} catch {
|
|
5727
|
+
return null;
|
|
5728
|
+
}
|
|
5729
|
+
}
|
|
5730
|
+
function converter() {
|
|
5731
|
+
const td = new TurndownService({
|
|
5732
|
+
headingStyle: "atx",
|
|
5733
|
+
codeBlockStyle: "fenced",
|
|
5734
|
+
bulletListMarker: "-",
|
|
5735
|
+
emDelimiter: "_"
|
|
5736
|
+
});
|
|
5737
|
+
td.use(gfm);
|
|
5738
|
+
td.addRule("images", {
|
|
5739
|
+
filter: "img",
|
|
5740
|
+
replacement: (_content, node) => {
|
|
5741
|
+
const el = node;
|
|
5742
|
+
const alt = (el.getAttribute("alt") || "").replace(/\s+/g, " ").trim();
|
|
5743
|
+
const src = el.getAttribute("src") || "";
|
|
5744
|
+
return alt && /^https?:/i.test(src) ? `` : "";
|
|
5745
|
+
}
|
|
5746
|
+
});
|
|
5747
|
+
td.addRule("flatCells", {
|
|
5748
|
+
filter: ["th", "td"],
|
|
5749
|
+
replacement: (content, node) => {
|
|
5750
|
+
const cell = node;
|
|
5751
|
+
const first = Array.prototype.indexOf.call(cell.parentNode.childNodes, cell) === 0;
|
|
5752
|
+
const text3 = content.replace(/\s*\n+\s*/g, " ").replace(/\|/g, "\\|").trim();
|
|
5753
|
+
return `${first ? "| " : " "}${text3} |`;
|
|
5754
|
+
}
|
|
5755
|
+
});
|
|
5756
|
+
return td;
|
|
5757
|
+
}
|
|
5758
|
+
function normalizeTables(document) {
|
|
5759
|
+
for (const table of [...document.querySelectorAll("table")].reverse()) {
|
|
5760
|
+
const rows = [...table.querySelectorAll("tr")].filter((tr) => tr.closest("table") === table).map((tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH")).filter((cells) => cells.length > 0);
|
|
5761
|
+
if (rows.length === 0) {
|
|
5762
|
+
table.remove();
|
|
5763
|
+
continue;
|
|
5764
|
+
}
|
|
5765
|
+
if (rows.length === 1 || Math.max(...rows.map((cells) => cells.length)) === 1) {
|
|
5766
|
+
const blocks = document.createElement("div");
|
|
5767
|
+
for (const [cell] of rows) {
|
|
5768
|
+
const block = document.createElement("div");
|
|
5769
|
+
block.innerHTML = cell.innerHTML;
|
|
5770
|
+
blocks.appendChild(block);
|
|
5771
|
+
}
|
|
5772
|
+
table.replaceWith(blocks);
|
|
5773
|
+
continue;
|
|
5774
|
+
}
|
|
5775
|
+
const clean = document.createElement("table");
|
|
5776
|
+
rows.forEach((cells, i) => {
|
|
5777
|
+
const section = document.createElement(i === 0 ? "thead" : "tbody");
|
|
5778
|
+
const tr = document.createElement("tr");
|
|
5779
|
+
for (const cell of cells) {
|
|
5780
|
+
const out = document.createElement(i === 0 ? "th" : "td");
|
|
5781
|
+
out.innerHTML = cell.innerHTML.replace(/\s+/g, " ");
|
|
5782
|
+
tr.appendChild(out);
|
|
5783
|
+
}
|
|
5784
|
+
if (i === 0 || !clean.lastElementChild || clean.lastElementChild.tagName !== "TBODY") {
|
|
5785
|
+
section.appendChild(tr);
|
|
5786
|
+
clean.appendChild(section);
|
|
5787
|
+
} else {
|
|
5788
|
+
clean.lastElementChild.appendChild(tr);
|
|
5789
|
+
}
|
|
5790
|
+
});
|
|
5791
|
+
table.replaceWith(clean);
|
|
5792
|
+
}
|
|
5793
|
+
}
|
|
5794
|
+
function htmlToMarkdown(html, url, options) {
|
|
5795
|
+
if (!/<[a-z!][\s\S]*>/i.test(html)) return { title: "", markdown: html.trim() };
|
|
5796
|
+
const { document } = parseHTML(html);
|
|
5797
|
+
const title = (document.querySelector("title")?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
5798
|
+
for (const el of document.querySelectorAll(ALWAYS_DROP)) el.remove();
|
|
5799
|
+
for (const a of document.querySelectorAll("a[href]")) {
|
|
5800
|
+
const href = absolute(a.getAttribute("href") || "", url);
|
|
5801
|
+
if (href) a.setAttribute("href", href);
|
|
5802
|
+
else a.removeAttribute("href");
|
|
5803
|
+
}
|
|
5804
|
+
for (const img of document.querySelectorAll("img")) {
|
|
5805
|
+
const src = absolute(img.getAttribute("src") || "", url);
|
|
5806
|
+
if (src) img.setAttribute("src", src);
|
|
5807
|
+
else img.removeAttribute("src");
|
|
5808
|
+
}
|
|
5809
|
+
const body = document.body ?? document.documentElement;
|
|
5810
|
+
let root = body;
|
|
5811
|
+
if (options.mainOnly) {
|
|
5812
|
+
const main2 = document.querySelector('main, [role="main"]');
|
|
5813
|
+
if (main2 && textLength(main2) >= MAIN_MIN_SHARE * textLength(body)) {
|
|
5814
|
+
root = main2;
|
|
5815
|
+
} else {
|
|
5816
|
+
for (const el of document.querySelectorAll(CHROME)) el.remove();
|
|
5817
|
+
for (const header of document.querySelectorAll('header, [role="banner"]')) {
|
|
5818
|
+
if (!header.closest("article, main")) header.remove();
|
|
5819
|
+
}
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5822
|
+
normalizeTables(document);
|
|
5823
|
+
let converted;
|
|
5824
|
+
try {
|
|
5825
|
+
converted = converter().turndown(root.innerHTML);
|
|
5826
|
+
} catch {
|
|
5827
|
+
converted = (root.textContent ?? "").replace(/[ \t]+/g, " ").replace(/\s*\n\s*/g, "\n");
|
|
5828
|
+
}
|
|
5829
|
+
const markdown = converted.replace(/\[\s*\]\([^)]*\)/g, "").replace(/^(\s*)([-*+]|\d+\.) {2,}/gm, "$1$2 ").replace(/(\)|\*\*|_) ([.,;:!?])/g, "$1$2").replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
5830
|
+
return { title, markdown };
|
|
5831
|
+
}
|
|
5832
|
+
function capText(text3, max, hint) {
|
|
5833
|
+
if (text3.length <= max) return text3;
|
|
5834
|
+
return `${text3.slice(0, max)}
|
|
5835
|
+
|
|
5836
|
+
[Cut at ${max} of ${text3.length} characters. ${hint}]`;
|
|
5837
|
+
}
|
|
5838
|
+
|
|
5839
|
+
// src/reading-tools.ts
|
|
5840
|
+
var PAGE_MAX_CHARS = 1e5;
|
|
5841
|
+
var SEARCH_PAGE_MAX_CHARS = 15e3;
|
|
5842
|
+
var SEARCH_DEFAULT_RESULTS = 3;
|
|
5843
|
+
var SEARCH_MAX_RESULTS = 5;
|
|
5844
|
+
var SEARCH_PAGE_DEADLINE_MS = 12e4;
|
|
5845
|
+
function text2(body, isError = false) {
|
|
5846
|
+
return isError ? { content: [{ type: "text", text: body }], isError } : { content: [{ type: "text", text: body }] };
|
|
5847
|
+
}
|
|
5848
|
+
function pageMarkdown(url, html, mainOnly, max) {
|
|
5849
|
+
const page = htmlToMarkdown(html, url, { mainOnly });
|
|
5850
|
+
const head = page.title ? `URL: ${url}
|
|
5851
|
+
Title: ${page.title}` : `URL: ${url}`;
|
|
5852
|
+
return capText(`${head}
|
|
5853
|
+
|
|
5854
|
+
${page.markdown}`, max, "Use fetch_html for the whole page.");
|
|
5855
|
+
}
|
|
5856
|
+
function organicResults(serp) {
|
|
5857
|
+
const organic = serp?.organic;
|
|
5858
|
+
if (!Array.isArray(organic)) return [];
|
|
5859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5860
|
+
const out = [];
|
|
5861
|
+
for (const item of organic) {
|
|
5862
|
+
const url = typeof item?.url === "string" ? item.url : "";
|
|
5863
|
+
if (!/^https?:\/\//i.test(url) || seen.has(url)) continue;
|
|
5864
|
+
seen.add(url);
|
|
5865
|
+
out.push({
|
|
5866
|
+
title: typeof item.title === "string" ? item.title : url,
|
|
5867
|
+
url,
|
|
5868
|
+
description: typeof item.description === "string" ? item.description : ""
|
|
5869
|
+
});
|
|
5870
|
+
}
|
|
5871
|
+
return out;
|
|
5872
|
+
}
|
|
5873
|
+
function aiOverview(serp) {
|
|
5874
|
+
const overview = serp?.aiOverview;
|
|
5875
|
+
if (!overview || typeof overview.text !== "string" || !overview.text.trim()) return null;
|
|
5876
|
+
const sources = Array.isArray(overview.sources) ? overview.sources.filter((s) => typeof s === "string") : [];
|
|
5877
|
+
return sources.length ? `${overview.text.trim()}
|
|
5878
|
+
|
|
5879
|
+
Sources: ${sources.join(", ")}` : overview.text.trim();
|
|
5880
|
+
}
|
|
5881
|
+
function withDeadline(work, ms) {
|
|
5882
|
+
let timer;
|
|
5883
|
+
const deadline = new Promise((_, reject) => {
|
|
5884
|
+
timer = setTimeout(() => reject(new Error(`no answer within ${ms / 1e3} s`)), ms);
|
|
5885
|
+
});
|
|
5886
|
+
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
|
|
5887
|
+
}
|
|
5888
|
+
var proxyCountry = z2.string().length(2).optional().describe("Optional ISO country code to route through, e.g. 'US'.");
|
|
5889
|
+
function registerReadingTools(server2, options) {
|
|
5890
|
+
const annotations = { readOnlyHint: true, openWorldHint: true, destructiveHint: false };
|
|
5891
|
+
server2.registerTool(
|
|
5892
|
+
"fetch_markdown",
|
|
5893
|
+
{
|
|
5894
|
+
title: "Fetch page as Markdown",
|
|
5895
|
+
annotations: { title: "Fetch page as Markdown", ...annotations },
|
|
5896
|
+
description: "Fetch a web page in a real browser through the ScrapeUnblocker API (https://docs.scrapeunblocker.com) and return it as clean Markdown: the main content with absolute links, without scripts, styles, navigation or footer. Use it to read, quote or summarise a page; it is usually 2-25% the size of the HTML. Works on JavaScript-rendered pages and on pages that refuse ordinary fetches (403/429, captcha). Use fetch_html instead when you need the markup, selectors or browser steps. Billed as one request.",
|
|
5897
|
+
inputSchema: {
|
|
5898
|
+
url: z2.string().url().describe("The absolute URL to read (http/https)."),
|
|
5899
|
+
proxy_country: proxyCountry,
|
|
5900
|
+
wait_method: z2.enum(["css", "js"]).optional().describe("Optional render-wait: 'css' selector or 'js' expression."),
|
|
5901
|
+
wait_value: z2.string().optional().describe("The selector/expression paired with wait_method."),
|
|
5902
|
+
sleep_seconds: z2.number().positive().optional().describe("Extra seconds to wait after load."),
|
|
5903
|
+
main_content_only: z2.boolean().optional().describe("Drop navigation, page header, footer and sidebars (default true). False keeps the whole page.")
|
|
5904
|
+
}
|
|
5905
|
+
},
|
|
5607
5906
|
async (args) => {
|
|
5608
5907
|
const blocked = options.blocked?.();
|
|
5609
5908
|
if (blocked) return blocked;
|
|
5610
|
-
const plugin = BY_NAME.get(args.name);
|
|
5611
|
-
const params = prepare(plugin, args.params ?? {});
|
|
5612
|
-
if (typeof params === "string") return text(`${params}
|
|
5613
|
-
|
|
5614
|
-
${details(plugin)}`, true);
|
|
5615
5909
|
try {
|
|
5616
|
-
const
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5910
|
+
const html = await options.fetchHtml(args.url, {
|
|
5911
|
+
proxyCountry: args.proxy_country,
|
|
5912
|
+
method: args.wait_method,
|
|
5913
|
+
value: args.wait_value,
|
|
5914
|
+
timeSleep: args.sleep_seconds
|
|
5915
|
+
});
|
|
5916
|
+
return text2(pageMarkdown(args.url, html, args.main_content_only ?? true, PAGE_MAX_CHARS));
|
|
5917
|
+
} catch (err) {
|
|
5918
|
+
return options.fetchError(args.url, err);
|
|
5919
|
+
}
|
|
5920
|
+
}
|
|
5921
|
+
);
|
|
5922
|
+
server2.registerTool(
|
|
5923
|
+
"search_and_read",
|
|
5924
|
+
{
|
|
5925
|
+
title: "Search Google and read the top results",
|
|
5926
|
+
annotations: { title: "Search Google and read the top results", ...annotations },
|
|
5927
|
+
description: "Search Google through the ScrapeUnblocker API (https://docs.scrapeunblocker.com) and read the top results in one call: returns Google's AI Overview when there is one, each of the first `results` pages (default 3, max 5) as Markdown, and the other organic results as title, URL and snippet. Use it to answer a question from the live web. Billed as one request for the search plus one per page read.",
|
|
5928
|
+
inputSchema: {
|
|
5929
|
+
keyword: z2.string().min(1).describe("The search query."),
|
|
5930
|
+
proxy_country: z2.string().length(2).optional().describe("Optional ISO country code to search from and read the pages through, e.g. 'US'."),
|
|
5931
|
+
results: z2.number().int().min(1).max(SEARCH_MAX_RESULTS).optional().describe(`How many top results to read (default ${SEARCH_DEFAULT_RESULTS}, max ${SEARCH_MAX_RESULTS}).`)
|
|
5932
|
+
}
|
|
5933
|
+
},
|
|
5934
|
+
async (args) => {
|
|
5935
|
+
const blocked = options.blocked?.();
|
|
5936
|
+
if (blocked) return blocked;
|
|
5937
|
+
let serp;
|
|
5938
|
+
try {
|
|
5939
|
+
serp = await options.serp(args.keyword, { proxyCountry: args.proxy_country });
|
|
5940
|
+
} catch (err) {
|
|
5941
|
+
return text2(options.errorText(err), true);
|
|
5942
|
+
}
|
|
5943
|
+
const organic = organicResults(serp);
|
|
5944
|
+
const overview = aiOverview(serp);
|
|
5945
|
+
if (!organic.length && !overview) {
|
|
5946
|
+
return text2(`Google returned no organic results for "${args.keyword}". The search was billed as one request.`);
|
|
5947
|
+
}
|
|
5948
|
+
const toRead = organic.slice(0, args.results ?? SEARCH_DEFAULT_RESULTS);
|
|
5949
|
+
const pages = await Promise.allSettled(
|
|
5950
|
+
toRead.map(
|
|
5951
|
+
(r) => withDeadline(options.fetchHtml(r.url, { proxyCountry: args.proxy_country }), SEARCH_PAGE_DEADLINE_MS)
|
|
5952
|
+
)
|
|
5953
|
+
);
|
|
5954
|
+
const parts = [
|
|
5955
|
+
`Google results for "${args.keyword}": ${toRead.length} read below. Billed as one search plus one request per page read.`
|
|
5956
|
+
];
|
|
5957
|
+
if (overview) parts.push(`## Google AI Overview
|
|
5621
5958
|
|
|
5622
|
-
|
|
5959
|
+
${overview}`);
|
|
5960
|
+
toRead.forEach((r, i) => {
|
|
5961
|
+
const page = pages[i];
|
|
5962
|
+
const body = page.status === "fulfilled" ? pageMarkdown(r.url, page.value, true, SEARCH_PAGE_MAX_CHARS) : `URL: ${r.url}
|
|
5963
|
+
|
|
5964
|
+
Could not read this page: ${options.errorText(page.reason)}
|
|
5965
|
+
Snippet: ${r.description}`;
|
|
5966
|
+
parts.push(`## ${i + 1}. ${r.title}
|
|
5967
|
+
|
|
5968
|
+
${body}`);
|
|
5969
|
+
});
|
|
5970
|
+
const rest = organic.slice(toRead.length);
|
|
5971
|
+
if (rest.length) {
|
|
5972
|
+
parts.push(
|
|
5973
|
+
"## Other results\n\n" + rest.map((r) => `- [${r.title}](${r.url}): ${r.description}`).join("\n")
|
|
5623
5974
|
);
|
|
5624
|
-
} catch (err) {
|
|
5625
|
-
return text(options.errorText(err), true);
|
|
5626
5975
|
}
|
|
5976
|
+
return text2(parts.join("\n\n"));
|
|
5627
5977
|
}
|
|
5628
5978
|
);
|
|
5629
5979
|
}
|
|
5630
5980
|
|
|
5631
5981
|
// src/index.ts
|
|
5632
|
-
var VERSION = "0.
|
|
5982
|
+
var VERSION = "0.5.0";
|
|
5633
5983
|
var DEFAULT_BASE_URL = "https://api.scrapeunblocker.com";
|
|
5634
5984
|
var API_KEY_HEADER = "x-scrapeunblocker-key";
|
|
5635
5985
|
function resolveApiKey() {
|
|
@@ -5707,11 +6057,11 @@ async function getPageSourceRaw(params) {
|
|
|
5707
6057
|
Accept: "*/*"
|
|
5708
6058
|
}
|
|
5709
6059
|
});
|
|
5710
|
-
const
|
|
6060
|
+
const text3 = await response.text();
|
|
5711
6061
|
return {
|
|
5712
6062
|
status: response.status,
|
|
5713
6063
|
ok: response.ok,
|
|
5714
|
-
text:
|
|
6064
|
+
text: text3,
|
|
5715
6065
|
originStatus: response.headers.get("x-origin-status")
|
|
5716
6066
|
};
|
|
5717
6067
|
}
|
|
@@ -5719,8 +6069,8 @@ var server = new McpServer({
|
|
|
5719
6069
|
name: "scrapeunblocker",
|
|
5720
6070
|
version: VERSION
|
|
5721
6071
|
});
|
|
5722
|
-
var selectorTypeSchema =
|
|
5723
|
-
var pressableKeySchema =
|
|
6072
|
+
var selectorTypeSchema = z3.enum(["css", "xPath", "className", "tagName"]).describe("How `selector` is interpreted. Defaults to 'css'.");
|
|
6073
|
+
var pressableKeySchema = z3.enum([
|
|
5724
6074
|
"Enter",
|
|
5725
6075
|
"Tab",
|
|
5726
6076
|
"Escape",
|
|
@@ -5736,71 +6086,72 @@ var pressableKeySchema = z2.enum([
|
|
|
5736
6086
|
"PageUp",
|
|
5737
6087
|
"PageDown"
|
|
5738
6088
|
]);
|
|
5739
|
-
var stepSchema =
|
|
5740
|
-
|
|
5741
|
-
action:
|
|
5742
|
-
selector:
|
|
6089
|
+
var stepSchema = z3.discriminatedUnion("action", [
|
|
6090
|
+
z3.object({
|
|
6091
|
+
action: z3.literal("wait_for"),
|
|
6092
|
+
selector: z3.string().describe("Selector to wait for (until present)."),
|
|
5743
6093
|
selector_type: selectorTypeSchema.optional(),
|
|
5744
|
-
timeout_ms:
|
|
6094
|
+
timeout_ms: z3.number().int().positive().optional()
|
|
5745
6095
|
}).describe("Wait until an element matching `selector` exists."),
|
|
5746
|
-
|
|
5747
|
-
action:
|
|
5748
|
-
value:
|
|
5749
|
-
timeout_ms:
|
|
6096
|
+
z3.object({
|
|
6097
|
+
action: z3.literal("wait_for_text"),
|
|
6098
|
+
value: z3.string().describe("Text to wait for anywhere on the page."),
|
|
6099
|
+
timeout_ms: z3.number().int().positive().optional()
|
|
5750
6100
|
}).describe("Wait until the given text appears on the page."),
|
|
5751
|
-
|
|
5752
|
-
action:
|
|
5753
|
-
value:
|
|
6101
|
+
z3.object({
|
|
6102
|
+
action: z3.literal("wait"),
|
|
6103
|
+
value: z3.number().int().nonnegative().describe("Fixed pause in milliseconds.")
|
|
5754
6104
|
}).describe("Pause for a fixed number of milliseconds."),
|
|
5755
|
-
|
|
5756
|
-
action:
|
|
5757
|
-
selector:
|
|
6105
|
+
z3.object({
|
|
6106
|
+
action: z3.literal("click"),
|
|
6107
|
+
selector: z3.string().describe("Selector of the element to click."),
|
|
5758
6108
|
selector_type: selectorTypeSchema.optional(),
|
|
5759
|
-
timeout_ms:
|
|
6109
|
+
timeout_ms: z3.number().int().positive().optional()
|
|
5760
6110
|
}).describe("Click the element matching `selector`."),
|
|
5761
|
-
|
|
5762
|
-
action:
|
|
5763
|
-
selector:
|
|
6111
|
+
z3.object({
|
|
6112
|
+
action: z3.literal("type"),
|
|
6113
|
+
selector: z3.string().describe("Selector of the field to type into."),
|
|
5764
6114
|
selector_type: selectorTypeSchema.optional(),
|
|
5765
|
-
value:
|
|
5766
|
-
clear:
|
|
5767
|
-
timeout_ms:
|
|
6115
|
+
value: z3.string().describe("Text to type (entered human-like)."),
|
|
6116
|
+
clear: z3.boolean().optional().describe("Clear the field before typing."),
|
|
6117
|
+
timeout_ms: z3.number().int().positive().optional()
|
|
5768
6118
|
}).describe("Type text into an input, character by character."),
|
|
5769
|
-
|
|
5770
|
-
action:
|
|
5771
|
-
selector:
|
|
6119
|
+
z3.object({
|
|
6120
|
+
action: z3.literal("select"),
|
|
6121
|
+
selector: z3.string().describe("Selector of the <select> element."),
|
|
5772
6122
|
selector_type: selectorTypeSchema.optional(),
|
|
5773
|
-
value:
|
|
5774
|
-
timeout_ms:
|
|
6123
|
+
value: z3.string().describe("The option value to select."),
|
|
6124
|
+
timeout_ms: z3.number().int().positive().optional()
|
|
5775
6125
|
}).describe("Choose an option in a <select> dropdown by value."),
|
|
5776
|
-
|
|
5777
|
-
action:
|
|
6126
|
+
z3.object({
|
|
6127
|
+
action: z3.literal("press_key"),
|
|
5778
6128
|
value: pressableKeySchema.describe("The key to press.")
|
|
5779
6129
|
}).describe("Press a single keyboard key."),
|
|
5780
|
-
|
|
5781
|
-
action:
|
|
5782
|
-
value:
|
|
6130
|
+
z3.object({
|
|
6131
|
+
action: z3.literal("scroll"),
|
|
6132
|
+
value: z3.union([z3.literal("bottom"), z3.number().int()]).describe("'bottom' to scroll to the end, or a pixel offset.")
|
|
5783
6133
|
}).describe("Scroll the page to the bottom or by a pixel amount.")
|
|
5784
6134
|
]);
|
|
5785
6135
|
server.registerTool(
|
|
5786
6136
|
"fetch_html",
|
|
5787
6137
|
{
|
|
5788
6138
|
title: "Fetch page HTML",
|
|
6139
|
+
annotations: { title: "Fetch page HTML", readOnlyHint: true, openWorldHint: true, destructiveHint: false },
|
|
5789
6140
|
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.",
|
|
5790
6141
|
inputSchema: {
|
|
5791
|
-
url:
|
|
5792
|
-
proxy_country:
|
|
6142
|
+
url: z3.string().url().describe("The absolute URL to fetch (http/https)."),
|
|
6143
|
+
proxy_country: z3.string().length(2).optional().describe(
|
|
5793
6144
|
"Optional ISO 3166-1 alpha-2 country code to route through, e.g. 'US', 'GB', 'DE'."
|
|
5794
6145
|
),
|
|
5795
|
-
wait_method:
|
|
6146
|
+
wait_method: z3.enum(["css", "js"]).optional().describe(
|
|
5796
6147
|
"Optional render-wait strategy: 'css' waits for a selector, 'js' waits for a JS expression to be truthy."
|
|
5797
6148
|
),
|
|
5798
|
-
wait_value:
|
|
6149
|
+
wait_value: z3.string().optional().describe(
|
|
5799
6150
|
`The CSS selector or JS expression paired with wait_method (e.g. '#price' or 'document.readyState==="complete"').`
|
|
5800
6151
|
),
|
|
5801
|
-
method_timeout_seconds:
|
|
5802
|
-
sleep_seconds:
|
|
5803
|
-
steps:
|
|
6152
|
+
method_timeout_seconds: z3.number().int().positive().optional().describe("Cap in seconds for the render-wait method."),
|
|
6153
|
+
sleep_seconds: z3.number().positive().optional().describe("Extra seconds to wait after load before capturing the HTML."),
|
|
6154
|
+
steps: z3.array(stepSchema).optional().describe(
|
|
5804
6155
|
"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)."
|
|
5805
6156
|
)
|
|
5806
6157
|
}
|
|
@@ -5871,10 +6222,11 @@ server.registerTool(
|
|
|
5871
6222
|
"list_elements",
|
|
5872
6223
|
{
|
|
5873
6224
|
title: "List page elements (selector discovery)",
|
|
6225
|
+
annotations: { title: "List page elements (selector discovery)", readOnlyHint: true, openWorldHint: true, destructiveHint: false },
|
|
5874
6226
|
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.",
|
|
5875
6227
|
inputSchema: {
|
|
5876
|
-
url:
|
|
5877
|
-
proxy_country:
|
|
6228
|
+
url: z3.string().url().describe("The absolute URL whose elements you want to list."),
|
|
6229
|
+
proxy_country: z3.string().length(2).optional().describe("Optional ISO country code to route through, e.g. 'US'.")
|
|
5878
6230
|
}
|
|
5879
6231
|
},
|
|
5880
6232
|
async (args) => {
|
|
@@ -5914,11 +6266,12 @@ server.registerTool(
|
|
|
5914
6266
|
"fetch_parsed",
|
|
5915
6267
|
{
|
|
5916
6268
|
title: "Fetch AI-parsed page data",
|
|
6269
|
+
annotations: { title: "Fetch AI-parsed page data", readOnlyHint: true, openWorldHint: true, destructiveHint: false },
|
|
5917
6270
|
description: "Fetch a web page through ScrapeUnblocker and return AI-parsed structured JSON instead of raw HTML (e.g. product details, article content). Best for extracting fields from product, listing or article pages without writing your own HTML parsing. If the page holds no structured data, the result says so and returns the rendered HTML instead (billed like fetch_html).",
|
|
5918
6271
|
inputSchema: {
|
|
5919
|
-
url:
|
|
5920
|
-
proxy_country:
|
|
5921
|
-
rules_hint:
|
|
6272
|
+
url: z3.string().url().describe("The absolute URL to fetch and parse."),
|
|
6273
|
+
proxy_country: z3.string().length(2).optional().describe("Optional ISO country code to route through, e.g. 'US'."),
|
|
6274
|
+
rules_hint: z3.string().optional().describe(
|
|
5922
6275
|
"Optional natural-language hint about what to extract, to guide parsing."
|
|
5923
6276
|
)
|
|
5924
6277
|
}
|
|
@@ -5945,11 +6298,12 @@ server.registerTool(
|
|
|
5945
6298
|
"google_search",
|
|
5946
6299
|
{
|
|
5947
6300
|
title: "Google search results",
|
|
6301
|
+
annotations: { title: "Google search results", readOnlyHint: true, openWorldHint: true, destructiveHint: false },
|
|
5948
6302
|
description: "Run a Google search through ScrapeUnblocker and return the organic results as structured JSON. Use this to discover URLs before fetching them.",
|
|
5949
6303
|
inputSchema: {
|
|
5950
|
-
keyword:
|
|
5951
|
-
proxy_country:
|
|
5952
|
-
pages_to_check:
|
|
6304
|
+
keyword: z3.string().min(1).describe("The search query."),
|
|
6305
|
+
proxy_country: z3.string().length(2).optional().describe("Optional ISO country code to search from, e.g. 'US'."),
|
|
6306
|
+
pages_to_check: z3.number().int().positive().max(10).optional().describe("How many result pages to collect (default 1).")
|
|
5953
6307
|
}
|
|
5954
6308
|
},
|
|
5955
6309
|
async (args) => {
|
|
@@ -5966,6 +6320,12 @@ server.registerTool(
|
|
|
5966
6320
|
}
|
|
5967
6321
|
}
|
|
5968
6322
|
);
|
|
6323
|
+
registerReadingTools(server, {
|
|
6324
|
+
fetchHtml: (url, options) => client().getPageSource(url, options),
|
|
6325
|
+
serp: (keyword, options) => client().serp(keyword, options),
|
|
6326
|
+
fetchError: errorResult,
|
|
6327
|
+
errorText
|
|
6328
|
+
});
|
|
5969
6329
|
registerPluginTools(server, {
|
|
5970
6330
|
call: (path, params) => client().plugin(path, params),
|
|
5971
6331
|
errorText
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeunblocker-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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) and run 100+ site plugins that return structured JSON, through the ScrapeUnblocker anti-bot API, using your own API key.",
|
|
6
6
|
"keywords": [
|
|
@@ -43,11 +43,15 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
46
|
+
"linkedom": "^0.18.13",
|
|
46
47
|
"scrapeunblocker": "^0.10.0",
|
|
48
|
+
"turndown": "^7.2.4",
|
|
49
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
47
50
|
"zod": "^3.23.8"
|
|
48
51
|
},
|
|
49
52
|
"devDependencies": {
|
|
50
53
|
"@types/node": "^20.19.43",
|
|
54
|
+
"@types/turndown": "^5.0.6",
|
|
51
55
|
"tsup": "^8.0.0",
|
|
52
56
|
"tsx": "^4.16.0",
|
|
53
57
|
"typescript": "^5.4.0"
|