scrapeless-mcp-server 0.4.9 → 0.6.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 +31 -25
- package/build/server.js +1 -1
- package/build/tools/crawl/api.js +26 -0
- package/build/tools/crawl/crawlCancel.js +19 -0
- package/build/tools/crawl/crawlResult.js +72 -0
- package/build/tools/crawl/crawlStart.js +138 -0
- package/build/tools/index.js +4 -0
- package/build/tools/llm_chat_scraper/api.js +27 -0
- package/build/tools/llm_chat_scraper/llmChatScraper.js +634 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Built on the open MCP standard, Scrapeless MCP Server seamlessly connects models
|
|
|
9
9
|
- **Google services integration** (Search, Trends)
|
|
10
10
|
- **Browser automation** for page-level navigation and interaction
|
|
11
11
|
- **Scrape** dynamic, JS-heavy sites—export as HTML, Markdown, or screenshots
|
|
12
|
+
- **Crawl** entire websites by following links and capture each page in multiple formats
|
|
12
13
|
|
|
13
14
|
Whether you're building an AI research assistant, a coding copilot, or autonomous web agents, this server provides the dynamic context and real-world data your workflows need—**without getting blocked**.
|
|
14
15
|
|
|
@@ -100,13 +101,12 @@ Scrapeless MCP Server supports both **Stdio** and **Streamable HTTP** transport
|
|
|
100
101
|
|
|
101
102
|
Customize browser session behavior with optional parameters. These can be set via environment variables (for Stdio) or HTTP headers (for Streamable HTTP):
|
|
102
103
|
|
|
103
|
-
| Stdio (Env Var) | Streamable HTTP (HTTP Header) | Description
|
|
104
|
-
| ----------------------- | ----------------------------- |
|
|
105
|
-
| BROWSER_PROFILE_ID | x-browser-profile-id | Specifies a reusable browser profile ID for session continuity.
|
|
106
|
-
| BROWSER_PROFILE_PERSIST | x-browser-profile-persist | Enables persistent storage for cookies, local storage, etc.
|
|
104
|
+
| Stdio (Env Var) | Streamable HTTP (HTTP Header) | Description |
|
|
105
|
+
| ----------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
106
|
+
| BROWSER_PROFILE_ID | x-browser-profile-id | Specifies a reusable browser profile ID for session continuity. |
|
|
107
|
+
| BROWSER_PROFILE_PERSIST | x-browser-profile-persist | Enables persistent storage for cookies, local storage, etc. |
|
|
107
108
|
| BROWSER_SESSION_TTL | x-browser-session-ttl | Defines the **maximum session timeout** in seconds. The session will automatically expire after this duration of inactivity. |
|
|
108
109
|
|
|
109
|
-
|
|
110
110
|
## Integration with Claude Desktop
|
|
111
111
|
|
|
112
112
|
1. Open **Claude Desktop**
|
|
@@ -129,26 +129,32 @@ Customize browser session behavior with optional parameters. These can be set vi
|
|
|
129
129
|
|
|
130
130
|
## Supported MCP Tools
|
|
131
131
|
|
|
132
|
-
| Name | Description
|
|
133
|
-
| ------------------ |
|
|
134
|
-
| google_search | Universal information search engine.
|
|
135
|
-
| google_trends | Get trending search data from Google Trends.
|
|
136
|
-
|
|
|
137
|
-
|
|
|
138
|
-
|
|
|
139
|
-
|
|
|
140
|
-
|
|
|
141
|
-
|
|
|
142
|
-
|
|
|
143
|
-
|
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
|
|
|
151
|
-
|
|
|
132
|
+
| Name | Description |
|
|
133
|
+
| ------------------ | -------------------------------------------------------------- |
|
|
134
|
+
| google_search | Universal information search engine. |
|
|
135
|
+
| google_trends | Get trending search data from Google Trends. |
|
|
136
|
+
| browser_create | Create or reuse a cloud browser session using Scrapeless. |
|
|
137
|
+
| browser_close | Closes the current session by disconnecting the cloud browser. |
|
|
138
|
+
| browser_goto | Navigate browser to a specified URL. |
|
|
139
|
+
| browser_go_back | Go back one step in browser history. |
|
|
140
|
+
| browser_go_forward | Go forward one step in browser history. |
|
|
141
|
+
| browser_click | Click a specific element on the page. |
|
|
142
|
+
| browser_type | Type text into a specified input field. |
|
|
143
|
+
| browser_press_key | Simulate a key press. |
|
|
144
|
+
| browser_wait_for | Wait for a specific page element to appear. |
|
|
145
|
+
| browser_wait | Pause execution for a fixed duration. |
|
|
146
|
+
| browser_screenshot | Capture a screenshot of the current page. |
|
|
147
|
+
| browser_get_html | Get the full HTML of the current page. |
|
|
148
|
+
| browser_get_text | Get all visible text from the current page. |
|
|
149
|
+
| browser_scroll | Scroll to the bottom of the page. |
|
|
150
|
+
| browser_scroll_to | Scroll a specific element into view. |
|
|
151
|
+
| scrape_html | Scrape a URL and return its full HTML content. |
|
|
152
|
+
| scrape_markdown | Scrape a URL and return its content as Markdown. |
|
|
153
|
+
| scrape_screenshot | Capture a high-quality screenshot of any webpage. |
|
|
154
|
+
| crawl_start | Start an asynchronous crawl job from a base URL and return its job id. |
|
|
155
|
+
| crawl_cancel | Cancel an in-progress crawl job by its id. |
|
|
156
|
+
| crawl_result | Poll a crawl job by its id until it completes and return the crawled data. |
|
|
157
|
+
| llm_chat_scraper | Create an LLM Chat Scraper task for ChatGPT, Gemini, Perplexity, Copilot, Google AI Mode, Google AI Overview, Grok, or Alexa. |
|
|
152
158
|
|
|
153
159
|
## Security Best Practices
|
|
154
160
|
|
package/build/server.js
CHANGED
|
@@ -27,7 +27,7 @@ export const initMcpTools = (server, headers, apiKey) => {
|
|
|
27
27
|
};
|
|
28
28
|
// tools registration
|
|
29
29
|
Object.values(toolsList).forEach((tool) => {
|
|
30
|
-
server.tool(tool.name, tool.description, tool.inputSchema, (params) => tool.handle(params, getScrapelessClient()));
|
|
30
|
+
server.tool(tool.name, tool.description, tool.inputSchema, (params) => tool.handle(params, getScrapelessClient(), headers));
|
|
31
31
|
});
|
|
32
32
|
const context = ContextManager.getInstance().getContext(apiKey ?? API_KEY);
|
|
33
33
|
Object.values(browserTools).forEach((tool) => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { API_KEY, BASE_URL, API_KEY_NAME } from "../../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build an axios instance targeting the Scrapeless v2 crawler API.
|
|
5
|
+
*
|
|
6
|
+
* The `x-api-token` is resolved from the per-request client when available
|
|
7
|
+
* (so multi-tenant HTTP mode keeps using the caller's key) and falls back to
|
|
8
|
+
* the `SCRAPELESS_KEY` environment variable.
|
|
9
|
+
*/
|
|
10
|
+
export function getCrawlApi(client, headers) {
|
|
11
|
+
// Priority: explicit request header (HTTP multi-tenant) -> key carried by the
|
|
12
|
+
// per-request client -> SCRAPELESS_KEY env (stdio / fallback).
|
|
13
|
+
const apiKey = headers?.[API_KEY_NAME] ||
|
|
14
|
+
client?.scrapingCrawl?.crawl?.apiKey ||
|
|
15
|
+
API_KEY ||
|
|
16
|
+
"";
|
|
17
|
+
return axios.create({
|
|
18
|
+
baseURL: BASE_URL,
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
[API_KEY_NAME]: apiKey,
|
|
22
|
+
},
|
|
23
|
+
timeout: 30000,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export const CRAWL_ENDPOINT = "/api/v2/crawler/crawl";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineTool, wrapMcpResponse } from "../utils.js";
|
|
2
|
+
import { getCrawlApi, CRAWL_ENDPOINT } from "./api.js";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
export const crawlCancel = defineTool({
|
|
5
|
+
name: "crawl_cancel",
|
|
6
|
+
description: `Cancel an in-progress crawl job by its id (the id returned by crawl_start). Returns the cancelled status.`,
|
|
7
|
+
inputSchema: {
|
|
8
|
+
id: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe("The crawl job id returned when the job was created."),
|
|
11
|
+
},
|
|
12
|
+
handle: async (params, client, headers) => {
|
|
13
|
+
return wrapMcpResponse(async () => {
|
|
14
|
+
const api = getCrawlApi(client, headers);
|
|
15
|
+
const { data } = await api.delete(`${CRAWL_ENDPOINT}/${params.id}`);
|
|
16
|
+
return data;
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { defineTool } from "../utils.js";
|
|
2
|
+
import { getCrawlApi, CRAWL_ENDPOINT } from "./api.js";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
const TERMINAL_STATUSES = ["completed", "failed", "cancelled"];
|
|
6
|
+
export const crawlResult = defineTool({
|
|
7
|
+
name: "crawl_result",
|
|
8
|
+
description: `Fetch the result of a crawl job by its id (the id returned by crawl_start).
|
|
9
|
+
Polls the job status until it reaches a terminal state (completed / failed / cancelled) or the timeout is reached.
|
|
10
|
+
If the timeout is reached before the job finishes, the latest status is returned along with the job id so you can retry later.`,
|
|
11
|
+
inputSchema: {
|
|
12
|
+
id: z
|
|
13
|
+
.string()
|
|
14
|
+
.describe("The crawl job id returned when the job was created."),
|
|
15
|
+
timeout: z
|
|
16
|
+
.number()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Maximum time to wait in seconds before returning the latest result. Defaults to 300 (5 minutes)."),
|
|
19
|
+
pollInterval: z
|
|
20
|
+
.number()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Interval between status checks in seconds. Defaults to 5 seconds."),
|
|
23
|
+
},
|
|
24
|
+
handle: async (params, client, headers) => {
|
|
25
|
+
const { id } = params;
|
|
26
|
+
const timeoutMs = (params.timeout ?? 300) * 1000;
|
|
27
|
+
const pollIntervalMs = (params.pollInterval ?? 5) * 1000;
|
|
28
|
+
const api = getCrawlApi(client, headers);
|
|
29
|
+
const deadline = Date.now() + timeoutMs;
|
|
30
|
+
try {
|
|
31
|
+
let data;
|
|
32
|
+
while (true) {
|
|
33
|
+
const response = await api.get(`${CRAWL_ENDPOINT}/${id}`);
|
|
34
|
+
data = response.data;
|
|
35
|
+
if (data && TERMINAL_STATUSES.includes(data.status)) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: `Response:\n\n${JSON.stringify(data)}`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() + pollIntervalMs >= deadline) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: `The crawl job did not finish within the timeout. ` +
|
|
51
|
+
`Latest status is "${data?.status}". You can call crawl_result again ` +
|
|
52
|
+
`with this job id to keep waiting, or crawl_cancel to cancel it.\n\n` +
|
|
53
|
+
`Job ID: ${id}\n\nLatest response:\n\n${JSON.stringify(data)}`,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
await sleep(pollIntervalMs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: `Failed to fetch crawl result for job ${id}. Error: ${error.message}`,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { defineTool, wrapMcpResponse } from "../utils.js";
|
|
2
|
+
import { getCrawlApi, CRAWL_ENDPOINT } from "./api.js";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
const formatEnum = z.enum([
|
|
5
|
+
"markdown",
|
|
6
|
+
"html",
|
|
7
|
+
"rawHtml",
|
|
8
|
+
"links",
|
|
9
|
+
"screenshot",
|
|
10
|
+
"screenshot@fullPage",
|
|
11
|
+
"json",
|
|
12
|
+
]);
|
|
13
|
+
const scrapeOptionsSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
formats: z
|
|
16
|
+
.array(formatEnum)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Formats to include in the output. Defaults to ['markdown']."),
|
|
19
|
+
onlyMainContent: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Only return the main content of the page excluding headers, navs, footers, etc."),
|
|
23
|
+
includeTags: z
|
|
24
|
+
.array(z.string())
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Tags to include in the output."),
|
|
27
|
+
excludeTags: z
|
|
28
|
+
.array(z.string())
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Tags to exclude from the output."),
|
|
31
|
+
headers: z
|
|
32
|
+
.record(z.any())
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Headers to send with the request. Can be used to send cookies, user-agent, etc."),
|
|
35
|
+
waitFor: z
|
|
36
|
+
.number()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Delay in milliseconds before fetching the content, allowing the page sufficient time to load."),
|
|
39
|
+
timeout: z
|
|
40
|
+
.number()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Timeout in milliseconds for the request."),
|
|
43
|
+
})
|
|
44
|
+
.describe("Options that control how each page is scraped.");
|
|
45
|
+
const browserOptionsSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
sessionName: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("A name for your session to facilitate searching and viewing in the historical session list."),
|
|
51
|
+
sessionTTL: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Session duration in seconds. Defaults to 180s, customizable between 60s and 900s."),
|
|
55
|
+
sessionRecording: z
|
|
56
|
+
.string()
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Whether to enable session recording. Defaults to false."),
|
|
59
|
+
proxyCountry: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Target country/region for the proxy as a country code (e.g. US, GB, ANY)."),
|
|
63
|
+
proxyURL: z
|
|
64
|
+
.string()
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("Custom proxy URL, e.g. http://user:pass@ip:port. If set, all other proxy_* parameters are ignored."),
|
|
67
|
+
fingerprint: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Custom browser fingerprint configuration."),
|
|
71
|
+
})
|
|
72
|
+
.describe("Options that control the underlying scraping browser session.");
|
|
73
|
+
export const crawlStart = defineTool({
|
|
74
|
+
name: "crawl_start",
|
|
75
|
+
description: `Start an asynchronous crawl job. Crawls a website starting from a base URL, following links according to the provided options, and captures page content in various formats (markdown, html, links, screenshot, etc.).
|
|
76
|
+
Returns a job id that can be used with crawl_result to fetch results and crawl_cancel to cancel the job.
|
|
77
|
+
Only 'url' is required; all other parameters are optional.`,
|
|
78
|
+
inputSchema: {
|
|
79
|
+
url: z.string().url().describe("The base URL to start crawling from."),
|
|
80
|
+
limit: z
|
|
81
|
+
.number()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Maximum number of pages to crawl. Default limit is 10000."),
|
|
84
|
+
excludePaths: z
|
|
85
|
+
.array(z.string())
|
|
86
|
+
.optional()
|
|
87
|
+
.describe("URL pathname regex patterns that exclude matching URLs from the crawl."),
|
|
88
|
+
includePaths: z
|
|
89
|
+
.array(z.string())
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("URL pathname regex patterns that include matching URLs in the crawl."),
|
|
92
|
+
maxDepth: z
|
|
93
|
+
.number()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Maximum depth to crawl relative to the base URL (max number of slashes in the pathname)."),
|
|
96
|
+
maxDiscoveryDepth: z
|
|
97
|
+
.number()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe("Maximum depth to crawl based on discovery order."),
|
|
100
|
+
ignoreSitemap: z
|
|
101
|
+
.boolean()
|
|
102
|
+
.optional()
|
|
103
|
+
.describe("Ignore the website sitemap when crawling."),
|
|
104
|
+
ignoreQueryParameters: z
|
|
105
|
+
.boolean()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Do not re-scrape the same path with different (or none) query parameters."),
|
|
108
|
+
deduplicateSimilarURLs: z
|
|
109
|
+
.boolean()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Controls whether similar URLs should be deduplicated."),
|
|
112
|
+
regexOnFullURL: z
|
|
113
|
+
.boolean()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("Controls whether the include/exclude regex should be applied to the full URL."),
|
|
116
|
+
allowBackwardLinks: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Allow the crawler to follow links that are not part of the URL hierarchy you specify."),
|
|
120
|
+
allowExternalLinks: z
|
|
121
|
+
.boolean()
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Allow the crawler to follow links to external websites."),
|
|
124
|
+
delay: z
|
|
125
|
+
.number()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("Delay in seconds between scrapes. This helps respect website rate limits."),
|
|
128
|
+
scrapeOptions: scrapeOptionsSchema.optional(),
|
|
129
|
+
browserOptions: browserOptionsSchema.optional(),
|
|
130
|
+
},
|
|
131
|
+
handle: async (params, client, headers) => {
|
|
132
|
+
return wrapMcpResponse(async () => {
|
|
133
|
+
const api = getCrawlApi(client, headers);
|
|
134
|
+
const { data } = await api.post(CRAWL_ENDPOINT, params);
|
|
135
|
+
return data;
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
});
|
package/build/tools/index.js
CHANGED
|
@@ -3,3 +3,7 @@ export * from "./deepserp/googleTrends.js";
|
|
|
3
3
|
export * from "./universal/scrapeHtml.js";
|
|
4
4
|
export * from "./universal/scrapeMarkdown.js";
|
|
5
5
|
export * from "./universal/scrapeScreenshot.js";
|
|
6
|
+
export * from "./crawl/crawlStart.js";
|
|
7
|
+
export * from "./crawl/crawlCancel.js";
|
|
8
|
+
export * from "./crawl/crawlResult.js";
|
|
9
|
+
export * from "./llm_chat_scraper/llmChatScraper.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { API_KEY, BASE_URL, API_KEY_NAME } from "../../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build an axios instance targeting the Scrapeless v2 scraper task API.
|
|
5
|
+
*
|
|
6
|
+
* The `x-api-token` is resolved from the per-request client when available
|
|
7
|
+
* (so multi-tenant HTTP mode keeps using the caller's key) and falls back to
|
|
8
|
+
* the `SCRAPELESS_KEY` environment variable.
|
|
9
|
+
*/
|
|
10
|
+
export function getLlmChatScraperApi(client, headers) {
|
|
11
|
+
// Priority: explicit request header (HTTP multi-tenant) -> key carried by the
|
|
12
|
+
// per-request client -> SCRAPELESS_KEY env (stdio / fallback).
|
|
13
|
+
const apiKey = headers?.[API_KEY_NAME] ||
|
|
14
|
+
client?.scraping?.apiKey ||
|
|
15
|
+
API_KEY ||
|
|
16
|
+
"";
|
|
17
|
+
return axios.create({
|
|
18
|
+
baseURL: BASE_URL,
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
[API_KEY_NAME]: apiKey,
|
|
22
|
+
},
|
|
23
|
+
timeout: 30000,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export const LLM_CHAT_SCRAPER_REQUEST_ENDPOINT = "/api/v2/scraper/request";
|
|
27
|
+
export const LLM_CHAT_SCRAPER_RESULT_ENDPOINT = "/api/v2/scraper/result";
|
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { BASE_URL } from "../../config.js";
|
|
4
|
+
import { defineTool } from "../utils.js";
|
|
5
|
+
import { getLlmChatScraperApi, LLM_CHAT_SCRAPER_REQUEST_ENDPOINT, LLM_CHAT_SCRAPER_RESULT_ENDPOINT, } from "./api.js";
|
|
6
|
+
const POLL_INTERVAL_MS = 5000;
|
|
7
|
+
const DEFAULT_TIMEOUT_SECONDS = 180;
|
|
8
|
+
const MIN_TIMEOUT_SECONDS = 60;
|
|
9
|
+
const MAX_TIMEOUT_SECONDS = 600;
|
|
10
|
+
const ACTOR_OPTIONS = [
|
|
11
|
+
"scraper.chatgpt",
|
|
12
|
+
"scraper.gemini",
|
|
13
|
+
"scraper.perplexity",
|
|
14
|
+
"scraper.copilot",
|
|
15
|
+
"scraper.aimode",
|
|
16
|
+
"scraper.overview",
|
|
17
|
+
"scraper.grok",
|
|
18
|
+
"scraper.alexa",
|
|
19
|
+
];
|
|
20
|
+
const MODE_OPTIONS = [
|
|
21
|
+
"search",
|
|
22
|
+
"smart",
|
|
23
|
+
"chat",
|
|
24
|
+
"reasoning",
|
|
25
|
+
"study",
|
|
26
|
+
"MODEL_MODE_FAST",
|
|
27
|
+
"MODEL_MODE_EXPERT",
|
|
28
|
+
"MODEL_MODE_AUTO",
|
|
29
|
+
];
|
|
30
|
+
const ACTOR_CONFIG = {
|
|
31
|
+
"scraper.chatgpt": {
|
|
32
|
+
displayName: "ChatGPT",
|
|
33
|
+
supportsWebSearch: true,
|
|
34
|
+
supportsShopping: true,
|
|
35
|
+
},
|
|
36
|
+
"scraper.gemini": {
|
|
37
|
+
displayName: "Gemini",
|
|
38
|
+
},
|
|
39
|
+
"scraper.perplexity": {
|
|
40
|
+
displayName: "Perplexity",
|
|
41
|
+
supportsWebSearch: true,
|
|
42
|
+
},
|
|
43
|
+
"scraper.copilot": {
|
|
44
|
+
displayName: "Microsoft Copilot",
|
|
45
|
+
supportsMode: true,
|
|
46
|
+
requiresMode: true,
|
|
47
|
+
},
|
|
48
|
+
"scraper.aimode": {
|
|
49
|
+
displayName: "Google AI Mode",
|
|
50
|
+
supportsShopping: true,
|
|
51
|
+
supportsGoogleLocation: true,
|
|
52
|
+
},
|
|
53
|
+
"scraper.overview": {
|
|
54
|
+
displayName: "Google AI Overview",
|
|
55
|
+
supportsShopping: true,
|
|
56
|
+
supportsGoogleLocation: true,
|
|
57
|
+
},
|
|
58
|
+
"scraper.grok": {
|
|
59
|
+
displayName: "Grok",
|
|
60
|
+
supportsMode: true,
|
|
61
|
+
requiresMode: true,
|
|
62
|
+
},
|
|
63
|
+
"scraper.alexa": {
|
|
64
|
+
displayName: "Alexa",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const RESULT_FIELDS_BY_ACTOR = {
|
|
68
|
+
"scraper.chatgpt": [
|
|
69
|
+
"prompt",
|
|
70
|
+
"result_text",
|
|
71
|
+
"model",
|
|
72
|
+
"web_search",
|
|
73
|
+
"links",
|
|
74
|
+
"search_result",
|
|
75
|
+
"content_references",
|
|
76
|
+
"products",
|
|
77
|
+
"ads",
|
|
78
|
+
"map",
|
|
79
|
+
"search_model_queries",
|
|
80
|
+
],
|
|
81
|
+
"scraper.gemini": [
|
|
82
|
+
"result_text",
|
|
83
|
+
"prompt",
|
|
84
|
+
"citations",
|
|
85
|
+
"related_queries",
|
|
86
|
+
],
|
|
87
|
+
"scraper.perplexity": [
|
|
88
|
+
"prompt",
|
|
89
|
+
"result_text",
|
|
90
|
+
"related_prompt",
|
|
91
|
+
"web_results",
|
|
92
|
+
"media_items",
|
|
93
|
+
],
|
|
94
|
+
"scraper.copilot": [
|
|
95
|
+
"result_text",
|
|
96
|
+
"prompt",
|
|
97
|
+
"mode",
|
|
98
|
+
"links",
|
|
99
|
+
"citations",
|
|
100
|
+
],
|
|
101
|
+
"scraper.aimode": [
|
|
102
|
+
"result_text",
|
|
103
|
+
"result_md",
|
|
104
|
+
"result_html",
|
|
105
|
+
"raw_url",
|
|
106
|
+
"citations",
|
|
107
|
+
"search_result",
|
|
108
|
+
"products",
|
|
109
|
+
],
|
|
110
|
+
"scraper.overview": [
|
|
111
|
+
"content",
|
|
112
|
+
"rawtext",
|
|
113
|
+
"metadata",
|
|
114
|
+
"is_overview_shopping",
|
|
115
|
+
"products",
|
|
116
|
+
"source",
|
|
117
|
+
"web_source",
|
|
118
|
+
"ads",
|
|
119
|
+
],
|
|
120
|
+
"scraper.grok": [
|
|
121
|
+
"conversation",
|
|
122
|
+
"create_time",
|
|
123
|
+
"follow_up_suggestions",
|
|
124
|
+
"full_response",
|
|
125
|
+
"tool_usages",
|
|
126
|
+
"user_model",
|
|
127
|
+
"user_query",
|
|
128
|
+
"web_search_results",
|
|
129
|
+
"footnotes",
|
|
130
|
+
"x_search_results",
|
|
131
|
+
],
|
|
132
|
+
"scraper.alexa": [
|
|
133
|
+
"user_text",
|
|
134
|
+
"md_text",
|
|
135
|
+
"raw_text",
|
|
136
|
+
"completed",
|
|
137
|
+
"answer_fragment_uri",
|
|
138
|
+
"answer_revision",
|
|
139
|
+
"dialog_request_id",
|
|
140
|
+
"endpoint_id",
|
|
141
|
+
"fragment_count",
|
|
142
|
+
"conversation",
|
|
143
|
+
"directives",
|
|
144
|
+
"references",
|
|
145
|
+
"sources",
|
|
146
|
+
"suggestions",
|
|
147
|
+
"products",
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
const llmChatScraperSchema = z.object({
|
|
151
|
+
prompt: z
|
|
152
|
+
.string()
|
|
153
|
+
.min(1)
|
|
154
|
+
.describe("Question or prompt to send to the selected LLM Chat Scraper actor."),
|
|
155
|
+
actor: z
|
|
156
|
+
.enum(ACTOR_OPTIONS)
|
|
157
|
+
.describe("Scrapeless actor to run. Choose one explicitly: scraper.chatgpt, scraper.gemini, scraper.perplexity, scraper.copilot, scraper.aimode, scraper.overview, scraper.grok, or scraper.alexa."),
|
|
158
|
+
country: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.default("US")
|
|
162
|
+
.describe("Country or region code used by the actor. Defaults to US."),
|
|
163
|
+
web_search: z
|
|
164
|
+
.boolean()
|
|
165
|
+
.optional()
|
|
166
|
+
.describe("Enable web search for scraper.chatgpt or scraper.perplexity when explicitly provided."),
|
|
167
|
+
shopping: z
|
|
168
|
+
.boolean()
|
|
169
|
+
.optional()
|
|
170
|
+
.describe("Fetch shopping/product data for scraper.chatgpt, scraper.aimode, or scraper.overview when explicitly provided."),
|
|
171
|
+
mode: z
|
|
172
|
+
.enum(MODE_OPTIONS)
|
|
173
|
+
.optional()
|
|
174
|
+
.describe("Required for scraper.copilot or scraper.grok. Copilot: search, smart, chat, reasoning, study. Grok: MODEL_MODE_FAST, MODEL_MODE_EXPERT, MODEL_MODE_AUTO."),
|
|
175
|
+
location: z
|
|
176
|
+
.string()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe("Google canonical location for Google AI Mode or Google AI Overview. Mutually exclusive with uule."),
|
|
179
|
+
uule: z
|
|
180
|
+
.string()
|
|
181
|
+
.optional()
|
|
182
|
+
.describe("Pre-encoded Google UULE for Google AI Mode or Google AI Overview. Mutually exclusive with location."),
|
|
183
|
+
webhook: z
|
|
184
|
+
.object({
|
|
185
|
+
url: z
|
|
186
|
+
.string()
|
|
187
|
+
.url()
|
|
188
|
+
.describe("Webhook URL Scrapeless calls when the task completes."),
|
|
189
|
+
})
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("Optional webhook object passed to the Scrapeless task request."),
|
|
192
|
+
timeout_seconds: z
|
|
193
|
+
.number()
|
|
194
|
+
.int()
|
|
195
|
+
.min(MIN_TIMEOUT_SECONDS)
|
|
196
|
+
.max(MAX_TIMEOUT_SECONDS)
|
|
197
|
+
.optional()
|
|
198
|
+
.default(DEFAULT_TIMEOUT_SECONDS)
|
|
199
|
+
.describe("Maximum polling time in seconds. Defaults to 180. Minimum is 60 and maximum is 600."),
|
|
200
|
+
});
|
|
201
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
202
|
+
function textResponse(data) {
|
|
203
|
+
return {
|
|
204
|
+
content: [
|
|
205
|
+
{
|
|
206
|
+
type: "text",
|
|
207
|
+
text: `Response:\n\n${JSON.stringify(data)}`,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function normalizeCountry(country) {
|
|
213
|
+
const normalized = (country ?? "US").trim().toUpperCase();
|
|
214
|
+
return normalized || "US";
|
|
215
|
+
}
|
|
216
|
+
function validateActorParams(params) {
|
|
217
|
+
const config = ACTOR_CONFIG[params.actor];
|
|
218
|
+
if (params.location && params.uule) {
|
|
219
|
+
return {
|
|
220
|
+
code: "invalid_input",
|
|
221
|
+
message: "location and uule are mutually exclusive.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (params.web_search !== undefined && !config.supportsWebSearch) {
|
|
225
|
+
return {
|
|
226
|
+
code: "invalid_input",
|
|
227
|
+
message: `web_search is not a supported option for ${params.actor}.`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (params.shopping !== undefined && !config.supportsShopping) {
|
|
231
|
+
return {
|
|
232
|
+
code: "invalid_input",
|
|
233
|
+
message: `shopping is not a supported top-level option for ${params.actor}.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if ((params.location || params.uule) && !config.supportsGoogleLocation) {
|
|
237
|
+
return {
|
|
238
|
+
code: "invalid_input",
|
|
239
|
+
message: `location and uule are only supported for scraper.aimode and scraper.overview.`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (params.mode !== undefined && !config.supportsMode) {
|
|
243
|
+
return {
|
|
244
|
+
code: "invalid_input",
|
|
245
|
+
message: `mode is only supported for scraper.copilot and scraper.grok.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (params.actor === "scraper.copilot" && params.mode !== undefined) {
|
|
249
|
+
const allowedModes = ["search", "smart", "chat", "reasoning", "study"];
|
|
250
|
+
if (!allowedModes.includes(params.mode)) {
|
|
251
|
+
return {
|
|
252
|
+
code: "invalid_input",
|
|
253
|
+
message: `mode for scraper.copilot must be one of: ${allowedModes.join(", ")}.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (params.actor === "scraper.grok" && params.mode !== undefined) {
|
|
258
|
+
const allowedModes = [
|
|
259
|
+
"MODEL_MODE_FAST",
|
|
260
|
+
"MODEL_MODE_EXPERT",
|
|
261
|
+
"MODEL_MODE_AUTO",
|
|
262
|
+
];
|
|
263
|
+
if (!allowedModes.includes(params.mode)) {
|
|
264
|
+
return {
|
|
265
|
+
code: "invalid_input",
|
|
266
|
+
message: `mode for scraper.grok must be one of: ${allowedModes.join(", ")}.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (config.requiresMode &&
|
|
271
|
+
params.mode === undefined) {
|
|
272
|
+
return {
|
|
273
|
+
code: "invalid_input",
|
|
274
|
+
message: `mode is required for ${params.actor}.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
function buildActorInput(params, country) {
|
|
280
|
+
const actorInput = {
|
|
281
|
+
prompt: params.prompt,
|
|
282
|
+
country,
|
|
283
|
+
};
|
|
284
|
+
if (params.web_search !== undefined) {
|
|
285
|
+
actorInput.web_search = params.web_search;
|
|
286
|
+
}
|
|
287
|
+
if (params.shopping !== undefined) {
|
|
288
|
+
actorInput.shopping = params.shopping;
|
|
289
|
+
}
|
|
290
|
+
if (params.mode !== undefined) {
|
|
291
|
+
actorInput.mode = params.mode;
|
|
292
|
+
}
|
|
293
|
+
if (params.location)
|
|
294
|
+
actorInput.location = params.location;
|
|
295
|
+
if (params.uule)
|
|
296
|
+
actorInput.uule = params.uule;
|
|
297
|
+
return actorInput;
|
|
298
|
+
}
|
|
299
|
+
function isRecord(value) {
|
|
300
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
301
|
+
}
|
|
302
|
+
function getStringField(source, field) {
|
|
303
|
+
if (!isRecord(source))
|
|
304
|
+
return undefined;
|
|
305
|
+
const value = source[field];
|
|
306
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
307
|
+
}
|
|
308
|
+
function extractTaskId(createResponse) {
|
|
309
|
+
return getStringField(createResponse, "task_id");
|
|
310
|
+
}
|
|
311
|
+
function extractRequestId(createResponse) {
|
|
312
|
+
return getStringField(createResponse, "request_id");
|
|
313
|
+
}
|
|
314
|
+
function extractTaskStatus(resultResponse) {
|
|
315
|
+
return getStringField(resultResponse, "status")?.toLowerCase();
|
|
316
|
+
}
|
|
317
|
+
function extractTaskMessage(resultResponse) {
|
|
318
|
+
return getStringField(resultResponse, "message");
|
|
319
|
+
}
|
|
320
|
+
function extractTaskResult(resultResponse) {
|
|
321
|
+
return isRecord(resultResponse) ? resultResponse.task_result : undefined;
|
|
322
|
+
}
|
|
323
|
+
function pickActorResult(actor, result) {
|
|
324
|
+
if (!isRecord(result))
|
|
325
|
+
return result;
|
|
326
|
+
return RESULT_FIELDS_BY_ACTOR[actor].reduce((actorResult, field) => {
|
|
327
|
+
if (field in result)
|
|
328
|
+
actorResult[field] = result[field];
|
|
329
|
+
return actorResult;
|
|
330
|
+
}, {});
|
|
331
|
+
}
|
|
332
|
+
function formatPollHttpError(statusCode, responseData) {
|
|
333
|
+
return {
|
|
334
|
+
status_code: statusCode,
|
|
335
|
+
code: "api_error",
|
|
336
|
+
message: getStringField(responseData, "message") ||
|
|
337
|
+
getStringField(responseData, "error") ||
|
|
338
|
+
`LLM chat scraper result request failed with HTTP ${statusCode}.`,
|
|
339
|
+
response: responseData,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function formatTaskResultError(code, message, response) {
|
|
343
|
+
return {
|
|
344
|
+
code,
|
|
345
|
+
message,
|
|
346
|
+
response,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function pollTask(api, taskId, timeoutSeconds) {
|
|
350
|
+
const startedAtMs = Date.now();
|
|
351
|
+
const startedAt = new Date(startedAtMs).toISOString();
|
|
352
|
+
const deadline = startedAtMs + timeoutSeconds * 1000;
|
|
353
|
+
let latestResult;
|
|
354
|
+
let latestStatus = "processing";
|
|
355
|
+
let latestHttpStatus;
|
|
356
|
+
while (true) {
|
|
357
|
+
const response = await api.get(`${LLM_CHAT_SCRAPER_RESULT_ENDPOINT}/${encodeURIComponent(taskId)}`, {
|
|
358
|
+
validateStatus: () => true,
|
|
359
|
+
});
|
|
360
|
+
latestResult = response.data;
|
|
361
|
+
latestHttpStatus = response.status;
|
|
362
|
+
if (response.status === 429) {
|
|
363
|
+
latestStatus = "rate_limited_retrying";
|
|
364
|
+
}
|
|
365
|
+
else if (response.status < 200 || response.status >= 300) {
|
|
366
|
+
const finishedAtMs = Date.now();
|
|
367
|
+
return {
|
|
368
|
+
taskId,
|
|
369
|
+
status: `http_${response.status}`,
|
|
370
|
+
terminal: true,
|
|
371
|
+
ok: false,
|
|
372
|
+
timedOut: false,
|
|
373
|
+
startedAt,
|
|
374
|
+
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
375
|
+
elapsedMs: finishedAtMs - startedAtMs,
|
|
376
|
+
latestResult,
|
|
377
|
+
httpStatus: response.status,
|
|
378
|
+
error: formatPollHttpError(response.status, response.data),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const taskStatus = extractTaskStatus(response.data);
|
|
383
|
+
if (taskStatus === "success") {
|
|
384
|
+
const taskResult = extractTaskResult(response.data);
|
|
385
|
+
const finishedAtMs = Date.now();
|
|
386
|
+
if (!isRecord(taskResult)) {
|
|
387
|
+
return {
|
|
388
|
+
taskId,
|
|
389
|
+
status: "invalid_response",
|
|
390
|
+
terminal: true,
|
|
391
|
+
ok: false,
|
|
392
|
+
timedOut: false,
|
|
393
|
+
startedAt,
|
|
394
|
+
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
395
|
+
elapsedMs: finishedAtMs - startedAtMs,
|
|
396
|
+
latestResult,
|
|
397
|
+
httpStatus: response.status,
|
|
398
|
+
error: formatTaskResultError("invalid_response", "Scrapeless result response status is success but task_result is missing or is not an object.", response.data),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
taskId,
|
|
403
|
+
status: taskStatus,
|
|
404
|
+
terminal: true,
|
|
405
|
+
ok: true,
|
|
406
|
+
timedOut: false,
|
|
407
|
+
startedAt,
|
|
408
|
+
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
409
|
+
elapsedMs: finishedAtMs - startedAtMs,
|
|
410
|
+
latestResult,
|
|
411
|
+
taskResult,
|
|
412
|
+
httpStatus: response.status,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (taskStatus === "failed") {
|
|
416
|
+
const finishedAtMs = Date.now();
|
|
417
|
+
return {
|
|
418
|
+
taskId,
|
|
419
|
+
status: taskStatus,
|
|
420
|
+
terminal: true,
|
|
421
|
+
ok: false,
|
|
422
|
+
timedOut: false,
|
|
423
|
+
startedAt,
|
|
424
|
+
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
425
|
+
elapsedMs: finishedAtMs - startedAtMs,
|
|
426
|
+
latestResult,
|
|
427
|
+
httpStatus: response.status,
|
|
428
|
+
error: formatTaskResultError("task_failed", extractTaskMessage(response.data) || "LLM chat scraper task failed.", response.data),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (taskStatus === "pending" || taskStatus === "running") {
|
|
432
|
+
latestStatus = taskStatus;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const finishedAtMs = Date.now();
|
|
436
|
+
return {
|
|
437
|
+
taskId,
|
|
438
|
+
status: "invalid_response",
|
|
439
|
+
terminal: true,
|
|
440
|
+
ok: false,
|
|
441
|
+
timedOut: false,
|
|
442
|
+
startedAt,
|
|
443
|
+
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
444
|
+
elapsedMs: finishedAtMs - startedAtMs,
|
|
445
|
+
latestResult,
|
|
446
|
+
httpStatus: response.status,
|
|
447
|
+
error: formatTaskResultError("invalid_response", "Scrapeless result response did not include a valid status: pending, running, success, or failed.", response.data),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const remainingMs = deadline - Date.now();
|
|
452
|
+
if (remainingMs <= 0) {
|
|
453
|
+
const finishedAtMs = Date.now();
|
|
454
|
+
return {
|
|
455
|
+
taskId,
|
|
456
|
+
status: latestStatus,
|
|
457
|
+
terminal: false,
|
|
458
|
+
ok: false,
|
|
459
|
+
timedOut: true,
|
|
460
|
+
startedAt,
|
|
461
|
+
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
462
|
+
elapsedMs: finishedAtMs - startedAtMs,
|
|
463
|
+
latestResult,
|
|
464
|
+
httpStatus: latestHttpStatus,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
await sleep(Math.min(POLL_INTERVAL_MS, remainingMs));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function timeoutHelp(taskId) {
|
|
471
|
+
return {
|
|
472
|
+
message: "The LLM chat scraper task did not finish before the timeout. Fetch the result manually with the Scrapeless API.",
|
|
473
|
+
manual_result_request: {
|
|
474
|
+
method: "GET",
|
|
475
|
+
url: `${BASE_URL}${LLM_CHAT_SCRAPER_RESULT_ENDPOINT}/${taskId}`,
|
|
476
|
+
headers: {
|
|
477
|
+
"x-api-token": "YOUR_SCRAPELESS_KEY",
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function formatApiError(error) {
|
|
483
|
+
if (axios.isAxiosError(error)) {
|
|
484
|
+
const statusCode = error.response?.status;
|
|
485
|
+
const responseData = error.response?.data;
|
|
486
|
+
return {
|
|
487
|
+
status_code: statusCode,
|
|
488
|
+
code: statusCode === 429 ? "rate_limited" : "api_error",
|
|
489
|
+
message: getStringField(responseData, "message") ||
|
|
490
|
+
getStringField(responseData, "error") ||
|
|
491
|
+
error.message,
|
|
492
|
+
response: responseData,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
code: "unknown_error",
|
|
497
|
+
message: error.message,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
export const llmChatScraper = defineTool({
|
|
501
|
+
name: "llm_chat_scraper",
|
|
502
|
+
description: `Create an LLM Chat Scraper task for an explicit Scrapeless actor, then poll every 5 seconds until the answer is ready or the timeout is reached.
|
|
503
|
+
Supports ChatGPT, Gemini, Perplexity, Copilot, Google AI Mode, Google AI Overview, Grok, and Alexa.
|
|
504
|
+
Defaults to a 3 minute timeout. The timeout can be set from 60 to 600 seconds.
|
|
505
|
+
On timeout, returns the task_id and instructions for manually fetching the result.`,
|
|
506
|
+
inputSchema: llmChatScraperSchema.shape,
|
|
507
|
+
handle: async (rawParams, client, headers) => {
|
|
508
|
+
const params = llmChatScraperSchema.parse(rawParams);
|
|
509
|
+
const country = normalizeCountry(params.country);
|
|
510
|
+
const actorConfig = ACTOR_CONFIG[params.actor];
|
|
511
|
+
const startedAtMs = Date.now();
|
|
512
|
+
const startedAt = new Date(startedAtMs).toISOString();
|
|
513
|
+
const validationError = validateActorParams(params);
|
|
514
|
+
if (validationError) {
|
|
515
|
+
return textResponse({
|
|
516
|
+
status: "failed",
|
|
517
|
+
actor: params.actor,
|
|
518
|
+
actor_display_name: actorConfig.displayName,
|
|
519
|
+
error: validationError,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const actorInput = buildActorInput(params, country);
|
|
523
|
+
const taskRequest = {
|
|
524
|
+
actor: params.actor,
|
|
525
|
+
input: actorInput,
|
|
526
|
+
};
|
|
527
|
+
if (params.webhook) {
|
|
528
|
+
taskRequest.webhook = params.webhook;
|
|
529
|
+
}
|
|
530
|
+
let taskId;
|
|
531
|
+
let requestId;
|
|
532
|
+
let createResponseBody;
|
|
533
|
+
try {
|
|
534
|
+
const api = getLlmChatScraperApi(client, headers);
|
|
535
|
+
const createResponse = await api.post(LLM_CHAT_SCRAPER_REQUEST_ENDPOINT, taskRequest);
|
|
536
|
+
createResponseBody = createResponse.data;
|
|
537
|
+
taskId = extractTaskId(createResponseBody);
|
|
538
|
+
requestId = extractRequestId(createResponseBody);
|
|
539
|
+
if (!taskId) {
|
|
540
|
+
return textResponse({
|
|
541
|
+
status: "failed",
|
|
542
|
+
actor: params.actor,
|
|
543
|
+
actor_display_name: actorConfig.displayName,
|
|
544
|
+
request_id: requestId,
|
|
545
|
+
error: {
|
|
546
|
+
code: "missing_task_id",
|
|
547
|
+
message: "Scrapeless did not return a task id from /api/v2/scraper/request.",
|
|
548
|
+
},
|
|
549
|
+
create_response: createResponseBody,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const pollResult = await pollTask(api, taskId, params.timeout_seconds);
|
|
553
|
+
const finishedAtMs = Date.now();
|
|
554
|
+
if (pollResult.timedOut) {
|
|
555
|
+
return textResponse({
|
|
556
|
+
status: "timeout",
|
|
557
|
+
task_id: taskId,
|
|
558
|
+
request_id: requestId,
|
|
559
|
+
actor: params.actor,
|
|
560
|
+
actor_display_name: actorConfig.displayName,
|
|
561
|
+
timeout_seconds: params.timeout_seconds,
|
|
562
|
+
poll_interval_seconds: POLL_INTERVAL_MS / 1000,
|
|
563
|
+
execution_time: {
|
|
564
|
+
started_at: startedAt,
|
|
565
|
+
finished_at: new Date(finishedAtMs).toISOString(),
|
|
566
|
+
elapsed_ms: finishedAtMs - startedAtMs,
|
|
567
|
+
},
|
|
568
|
+
latest_status: pollResult.status,
|
|
569
|
+
latest_http_status: pollResult.httpStatus,
|
|
570
|
+
latest_result: pollResult.latestResult,
|
|
571
|
+
help: timeoutHelp(taskId),
|
|
572
|
+
create_response: createResponseBody,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (!pollResult.ok) {
|
|
576
|
+
return textResponse({
|
|
577
|
+
status: "failed",
|
|
578
|
+
task_id: taskId,
|
|
579
|
+
request_id: requestId,
|
|
580
|
+
actor: params.actor,
|
|
581
|
+
actor_display_name: actorConfig.displayName,
|
|
582
|
+
timeout_seconds: params.timeout_seconds,
|
|
583
|
+
poll_interval_seconds: POLL_INTERVAL_MS / 1000,
|
|
584
|
+
execution_time: {
|
|
585
|
+
started_at: startedAt,
|
|
586
|
+
finished_at: new Date(finishedAtMs).toISOString(),
|
|
587
|
+
elapsed_ms: finishedAtMs - startedAtMs,
|
|
588
|
+
},
|
|
589
|
+
task_status: pollResult.status,
|
|
590
|
+
result_http_status: pollResult.httpStatus,
|
|
591
|
+
error: pollResult.error,
|
|
592
|
+
latest_result: pollResult.latestResult,
|
|
593
|
+
create_response: createResponseBody,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return textResponse({
|
|
597
|
+
status: "completed",
|
|
598
|
+
task_id: taskId,
|
|
599
|
+
request_id: requestId,
|
|
600
|
+
actor: params.actor,
|
|
601
|
+
actor_display_name: actorConfig.displayName,
|
|
602
|
+
timeout_seconds: params.timeout_seconds,
|
|
603
|
+
poll_interval_seconds: POLL_INTERVAL_MS / 1000,
|
|
604
|
+
execution_time: {
|
|
605
|
+
started_at: startedAt,
|
|
606
|
+
finished_at: new Date(finishedAtMs).toISOString(),
|
|
607
|
+
elapsed_ms: finishedAtMs - startedAtMs,
|
|
608
|
+
},
|
|
609
|
+
task_status: pollResult.status,
|
|
610
|
+
result_http_status: pollResult.httpStatus,
|
|
611
|
+
result: pickActorResult(params.actor, pollResult.taskResult),
|
|
612
|
+
create_response: createResponseBody,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
const finishedAtMs = Date.now();
|
|
617
|
+
return textResponse({
|
|
618
|
+
status: "failed",
|
|
619
|
+
task_id: taskId,
|
|
620
|
+
request_id: requestId,
|
|
621
|
+
actor: params.actor,
|
|
622
|
+
actor_display_name: actorConfig.displayName,
|
|
623
|
+
execution_time: {
|
|
624
|
+
started_at: startedAt,
|
|
625
|
+
finished_at: new Date(finishedAtMs).toISOString(),
|
|
626
|
+
elapsed_ms: finishedAtMs - startedAtMs,
|
|
627
|
+
},
|
|
628
|
+
error: formatApiError(error),
|
|
629
|
+
help: taskId ? timeoutHelp(taskId) : undefined,
|
|
630
|
+
create_response: createResponseBody,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeless-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"cursor",
|
|
24
24
|
"claude",
|
|
25
25
|
"ai",
|
|
26
|
-
"model-context-protocol"
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"crawl",
|
|
28
|
+
"llm-chat-scraper"
|
|
27
29
|
],
|
|
28
30
|
"author": "",
|
|
29
31
|
"license": "ISC",
|