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.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/dist/index.js +426 -66
  3. 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 z2 } from "zod";
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) ? `![${alt}](${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 result = await options.call(plugin.path, params);
5617
- const body = JSON.stringify(result);
5618
- if (body.length <= MAX_OUTPUT_CHARS) return text(body);
5619
- return text(
5620
- body.slice(0, MAX_OUTPUT_CHARS) + `
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
- [Cut at ${MAX_OUTPUT_CHARS} of ${body.length} characters - ask for fewer results (max_results) or a narrower filter to get the whole answer.]`
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.3.5";
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 text2 = await response.text();
6060
+ const text3 = await response.text();
5711
6061
  return {
5712
6062
  status: response.status,
5713
6063
  ok: response.ok,
5714
- text: text2,
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 = z2.enum(["css", "xPath", "className", "tagName"]).describe("How `selector` is interpreted. Defaults to 'css'.");
5723
- var pressableKeySchema = z2.enum([
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 = z2.discriminatedUnion("action", [
5740
- z2.object({
5741
- action: z2.literal("wait_for"),
5742
- selector: z2.string().describe("Selector to wait for (until present)."),
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: z2.number().int().positive().optional()
6094
+ timeout_ms: z3.number().int().positive().optional()
5745
6095
  }).describe("Wait until an element matching `selector` exists."),
5746
- z2.object({
5747
- action: z2.literal("wait_for_text"),
5748
- value: z2.string().describe("Text to wait for anywhere on the page."),
5749
- timeout_ms: z2.number().int().positive().optional()
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
- z2.object({
5752
- action: z2.literal("wait"),
5753
- value: z2.number().int().nonnegative().describe("Fixed pause in milliseconds.")
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
- z2.object({
5756
- action: z2.literal("click"),
5757
- selector: z2.string().describe("Selector of the element to click."),
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: z2.number().int().positive().optional()
6109
+ timeout_ms: z3.number().int().positive().optional()
5760
6110
  }).describe("Click the element matching `selector`."),
5761
- z2.object({
5762
- action: z2.literal("type"),
5763
- selector: z2.string().describe("Selector of the field to type into."),
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: z2.string().describe("Text to type (entered human-like)."),
5766
- clear: z2.boolean().optional().describe("Clear the field before typing."),
5767
- timeout_ms: z2.number().int().positive().optional()
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
- z2.object({
5770
- action: z2.literal("select"),
5771
- selector: z2.string().describe("Selector of the <select> element."),
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: z2.string().describe("The option value to select."),
5774
- timeout_ms: z2.number().int().positive().optional()
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
- z2.object({
5777
- action: z2.literal("press_key"),
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
- z2.object({
5781
- action: z2.literal("scroll"),
5782
- value: z2.union([z2.literal("bottom"), z2.number().int()]).describe("'bottom' to scroll to the end, or a pixel offset.")
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: z2.string().url().describe("The absolute URL to fetch (http/https)."),
5792
- proxy_country: z2.string().length(2).optional().describe(
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: z2.enum(["css", "js"]).optional().describe(
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: z2.string().optional().describe(
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: z2.number().int().positive().optional().describe("Cap in seconds for the render-wait method."),
5802
- sleep_seconds: z2.number().positive().optional().describe("Extra seconds to wait after load before capturing the HTML."),
5803
- steps: z2.array(stepSchema).optional().describe(
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: z2.string().url().describe("The absolute URL whose elements you want to list."),
5877
- proxy_country: z2.string().length(2).optional().describe("Optional ISO country code to route through, e.g. 'US'.")
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: z2.string().url().describe("The absolute URL to fetch and parse."),
5920
- proxy_country: z2.string().length(2).optional().describe("Optional ISO country code to route through, e.g. 'US'."),
5921
- rules_hint: z2.string().optional().describe(
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: z2.string().min(1).describe("The search query."),
5951
- proxy_country: z2.string().length(2).optional().describe("Optional ISO country code to search from, e.g. 'US'."),
5952
- pages_to_check: z2.number().int().positive().max(10).optional().describe("How many result pages to collect (default 1).")
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.5",
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"