Repository navigation
Add the String Web Access component #22047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 6 commits
ac592d2
9a35daf
553b93d
6353a60
a16f61b
2cb2abe
7d14aed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # Overview | ||
|
|
||
| The String Web Access API returns any page on the web as clean, LLM-ready Markdown, runs a | ||
| structured web search, and maps a site's URLs. | ||
|
|
||
| # Example Use Cases | ||
|
|
||
| **Competitor price watch into a warehouse** | ||
|
|
||
| - Fetch a product page on a schedule, extract fields with a JSON schema, and append the result to | ||
| Google Sheets, Postgres or BigQuery. No parser to maintain when the page changes shape. | ||
|
|
||
| **Research agent with live sources** | ||
|
|
||
| - Run Search Web on an incoming question, fetch the top results as Markdown, and pass them to | ||
| OpenAI or Claude in the same workflow. The model reads the page text, not raw HTML. | ||
|
|
||
| **Inbound lead enrichment** | ||
|
|
||
| - When a form submission arrives, fetch the company's site, pull the fields you need with a schema, | ||
| and write them back to HubSpot or Salesforce before the sales rep opens the record. | ||
|
|
||
| # Getting Started | ||
|
|
||
| 1. Create a free account at [portal.usestring.ai](https://portal.usestring.ai/sign-up). The first | ||
| 5,000 requests are free. | ||
| 2. Open [Settings](https://portal.usestring.ai/settings) and copy an API key. | ||
| 3. In Pipedream, connect the String Web Access account and paste the key. Pipedream sends it as a | ||
| bearer token, so the key never appears in a step's code. | ||
|
|
||
| # Troubleshooting | ||
|
|
||
| **402 Payment Required** — the key is valid but the account balance can't cover the request. Top up | ||
| in the portal. | ||
|
|
||
| **A fetch comes back empty or incomplete** — set **Render JavaScript** to true for pages that build | ||
| their content in the browser, or set a **Country Code** for sites that serve different content by | ||
| country. Failed requests are not billed. | ||
|
|
||
| **Map Site URLs costs more than expected** — the crawl is quoted before it runs and stops at the | ||
| **Budget (USD)** you set, so lower the budget or the page cap. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import app from "../../string_web_access.app.mjs"; | ||
|
|
||
| export default { | ||
| key: "string_web_access-extract-data", | ||
| name: "Extract Data From URL", | ||
| description: "Fetch a URL and return only the fields described by a JSON schema. [See the documentation](https://portal.usestring.ai/docs/fetch/structured-extraction)", | ||
| version: "0.0.1", | ||
| annotations: { | ||
| destructiveHint: false, | ||
| openWorldHint: true, | ||
| readOnlyHint: true, | ||
| }, | ||
| type: "action", | ||
| ai: "optimized", | ||
| props: { | ||
| app, | ||
| url: { | ||
| propDefinition: [ | ||
| app, | ||
| "url", | ||
| ], | ||
| description: "The URL to extract fields from, e.g. `https://example.com/products/widget`", | ||
| }, | ||
| jsonSchema: { | ||
| type: "object", | ||
| label: "JSON Schema", | ||
| description: "The shape to extract, as a [JSON Schema](https://portal.usestring.ai/docs/fetch/structured-extraction) object. E.g. `{\"type\":\"object\",\"properties\":{\"price\":{\"type\":\"string\"}}}`", | ||
| }, | ||
| executeJS: { | ||
| propDefinition: [ | ||
| app, | ||
| "executeJS", | ||
| ], | ||
| }, | ||
| countryCode: { | ||
| propDefinition: [ | ||
| app, | ||
| "countryCode", | ||
| ], | ||
| }, | ||
| }, | ||
| async run({ $ }) { | ||
| const response = await this.app.fetchUrl({ | ||
| $, | ||
| data: { | ||
| url: this.url, | ||
| format: "json", | ||
| jsonSchema: this.jsonSchema, | ||
| executeJS: this.executeJS, | ||
| countryCode: this.countryCode, | ||
| }, | ||
| }); | ||
|
|
||
| $.export("$summary", `Successfully extracted fields from ${this.url}`); | ||
| return response; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import app from "../../string_web_access.app.mjs"; | ||
|
|
||
| export default { | ||
| key: "string_web_access-fetch-url", | ||
| name: "Fetch URL", | ||
| description: "Fetch a URL and return the page as Markdown, raw content, or a JSON envelope. [See the documentation](https://portal.usestring.ai/docs/api-reference/fetch)", | ||
| version: "0.0.1", | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| annotations: { | ||
| destructiveHint: false, | ||
| openWorldHint: true, | ||
| readOnlyHint: true, | ||
| }, | ||
| type: "action", | ||
| ai: "optimized", | ||
| props: { | ||
| app, | ||
| url: { | ||
| propDefinition: [ | ||
| app, | ||
| "url", | ||
| ], | ||
| }, | ||
| format: { | ||
| propDefinition: [ | ||
| app, | ||
| "format", | ||
| ], | ||
| }, | ||
| mainContentOnly: { | ||
| type: "boolean", | ||
| label: "Main Content Only", | ||
| description: "Drop navigation, headers and footers, e.g. `true`. Only affects Markdown responses.", | ||
| optional: true, | ||
| }, | ||
| executeJS: { | ||
| propDefinition: [ | ||
| app, | ||
| "executeJS", | ||
| ], | ||
| }, | ||
| countryCode: { | ||
| propDefinition: [ | ||
| app, | ||
| "countryCode", | ||
| ], | ||
| }, | ||
| solveCaptcha: { | ||
| propDefinition: [ | ||
| app, | ||
| "solveCaptcha", | ||
| ], | ||
| }, | ||
| headers: { | ||
| propDefinition: [ | ||
| app, | ||
| "headers", | ||
| ], | ||
| }, | ||
| }, | ||
| async run({ $ }) { | ||
| const response = await this.app.fetchUrl({ | ||
| $, | ||
| data: { | ||
| url: this.url, | ||
| format: this.format ?? "markdown", | ||
| mainContentOnly: this.mainContentOnly, | ||
| executeJS: this.executeJS, | ||
| countryCode: this.countryCode, | ||
| solveCaptcha: this.solveCaptcha, | ||
| headers: this.headers, | ||
| }, | ||
| }); | ||
|
|
||
| $.export("$summary", `Successfully fetched ${this.url}`); | ||
| return response; | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import app from "../../string_web_access.app.mjs"; | ||
| import { TERMINAL_STATUSES } from "../../common/constants.mjs"; | ||
|
|
||
| export default { | ||
| key: "string_web_access-get-mapped-site-urls", | ||
| name: "Get Mapped Site URLs", | ||
| description: "Get the status of a crawl started by **Map Site URLs** and, once it has finished, every URL it discovered. Use it when **Map Site URLs** returned a `jobId` before its crawl finished. While the crawl is still running, the job's status and progress are returned instead. If the crawl's approval was interrupted, it approves the job again so the crawl resumes within the budget set in **Map Site URLs**. [See the documentation](https://portal.usestring.ai/docs/api-reference/sitemap)", | ||
| version: "0.0.1", | ||
| annotations: { | ||
| destructiveHint: false, | ||
| openWorldHint: true, | ||
| readOnlyHint: false, | ||
| }, | ||
| type: "action", | ||
| ai: "optimized", | ||
| props: { | ||
| app, | ||
| jobId: { | ||
| propDefinition: [ | ||
| app, | ||
| "jobId", | ||
| ], | ||
| }, | ||
| }, | ||
| async run({ $ }) { | ||
| let state = await this.app.getSitemapStatus({ | ||
| $, | ||
| jobId: this.jobId, | ||
| }); | ||
| if (state.status === "partial_state") { | ||
| state = await this.app.approveSitemap({ | ||
| $, | ||
| jobId: this.jobId, | ||
| }); | ||
| } | ||
| const { status } = state; | ||
|
|
||
| if (status === "failed") { | ||
| throw new Error(`Crawl ${this.jobId} failed: ${state.errorMessage}`); | ||
| } | ||
|
|
||
| if (!TERMINAL_STATUSES.includes(status)) { | ||
| $.export("$summary", `Crawl ${this.jobId} is ${status}; run this action again later to collect its URLs`); | ||
| return state; | ||
| } | ||
|
|
||
| const results = await this.app.getAllSitemapUrls({ | ||
| $, | ||
| jobId: this.jobId, | ||
| }); | ||
|
|
||
| $.export("$summary", `Retrieved ${results.total} URL${results.total === 1 | ||
| ? "" | ||
| : "s"} from crawl ${this.jobId}`); | ||
| return { | ||
| status, | ||
| ...results, | ||
| }; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import app from "../../string_web_access.app.mjs"; | ||
| import { TERMINAL_STATUSES } from "../../common/constants.mjs"; | ||
| import { sleep } from "../../common/utils.mjs"; | ||
|
|
||
| const POLLING_INTERVAL = 5000; | ||
|
|
||
| export default { | ||
| key: "string_web_access-map-site-urls", | ||
| name: "Map Site URLs", | ||
| description: "Crawl a site within a spend cap and return the URLs it discovered. If the crawl is still running when `maxWaitSeconds` elapses, pass the returned `jobId` to **Get Mapped Site URLs** to collect the URLs later. [See the documentation](https://portal.usestring.ai/docs/api-reference/sitemap)", | ||
| version: "0.0.1", | ||
| annotations: { | ||
| destructiveHint: false, | ||
| openWorldHint: true, | ||
| readOnlyHint: false, | ||
| }, | ||
| type: "action", | ||
| ai: "optimized", | ||
| props: { | ||
| app, | ||
| url: { | ||
| propDefinition: [ | ||
| app, | ||
| "url", | ||
| ], | ||
| label: "Start URL", | ||
| description: "Where the crawl starts, e.g. `https://example.com/docs`. It stays on this hostname.", | ||
| }, | ||
| budgetUsd: { | ||
| type: "string", | ||
| label: "Budget (USD)", | ||
| description: "Hard spend cap for this crawl, e.g. `1.00`. The job stops rather than exceeding it, so a workflow can never approve an open-ended bill.", | ||
| }, | ||
| maxPages: { | ||
| type: "integer", | ||
| label: "Max Pages", | ||
| description: "Maximum pages to crawl, 1-10000, e.g. `500`", | ||
| optional: true, | ||
| default: 100, | ||
| }, | ||
| maxDepth: { | ||
| type: "integer", | ||
| label: "Max Depth", | ||
| description: "Maximum link depth from the start URL, 1-100, e.g. `3`", | ||
| optional: true, | ||
| default: 2, | ||
| }, | ||
| pathPrefix: { | ||
| type: "string", | ||
| label: "Path Prefix", | ||
| description: "Only crawl URLs whose path starts with this, e.g. `/docs`", | ||
| optional: true, | ||
| }, | ||
| useSitemap: { | ||
| type: "boolean", | ||
| label: "Seed From sitemap.xml", | ||
| description: "Also seed from the site's root `sitemap.xml`, e.g. `true`. Costs one extra page and finds pages that internal links miss.", | ||
| optional: true, | ||
| }, | ||
| maxWaitSeconds: { | ||
| type: "integer", | ||
| label: "Max Wait (seconds)", | ||
| description: "How long to wait for the crawl, e.g. `300`. If it is still running, the job ID is returned so **Get Mapped Site URLs** can collect the URLs later.", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🌐 Web query:
💡 Result: 🏁 Script executed: set -o pipefail
printf '%s\n' '--- action ---'
nl -ba components/string_web_access/actions/map-site-urls/map-site-urls.mjs
printf '%s\n' '--- related action and app references ---'
rg -n -F --glob 'components/string_web_access/**' -e 'jobId' -e 'maxWaitSeconds' -e 'export("$summary"' -e 'approve' -e 'partial_state' || test "$?" -eq 1
printf '%s\n' '--- diff against requested base ---'
git diff --no-ext-diff --unified=40 ff5f89cd2db1bd47dbfb4429ff53748f15babaad..2cb2abe41475d62691c8dd3cd9fd9d6aa935e340 -- components/string_web_access/actions/map-site-urls/map-site-urls.mjsRepository: PipedreamHQ/pipedream Length of output: 16491 🌐 Web query:
💡 Result: 🌐 Web query:
💡 Result: 🌐 Web query:
💡 Result: Do not use a 300-second default wait in workflows with the default timeout. Pipedream reports a 30-second default workflow timeout and a 300-second maximum. This action approves the crawl before polling, so it can time out before returning 🐛 Suggested fix await this.app.approveSitemap({
$,
jobId: quote.jobId,
});
+ $.export("jobId", quote.jobId);🤖 Prompt for AI Agents |
||
| optional: true, | ||
| default: 300, | ||
| }, | ||
| }, | ||
| async run({ $ }) { | ||
| const quote = await this.app.submitSitemap({ | ||
| $, | ||
| data: { | ||
| url: this.url, | ||
| budgetUsd: Number(this.budgetUsd), | ||
| maxPages: this.maxPages, | ||
| maxDepth: this.maxDepth, | ||
| pathPrefix: this.pathPrefix, | ||
| useSitemap: this.useSitemap, | ||
| }, | ||
| }); | ||
|
|
||
| await this.app.approveSitemap({ | ||
| $, | ||
| jobId: quote.jobId, | ||
| }); | ||
|
|
||
| const deadline = Date.now() + ((this.maxWaitSeconds ?? 300) * 1000); | ||
| let state = { | ||
| status: "running", | ||
| }; | ||
|
|
||
| while (Date.now() < deadline) { | ||
| await sleep(POLLING_INTERVAL); | ||
| state = await this.app.getSitemapStatus({ | ||
| $, | ||
| jobId: quote.jobId, | ||
| }); | ||
|
|
||
| if (state.status === "partial_state") { | ||
| await this.app.approveSitemap({ | ||
| $, | ||
| jobId: quote.jobId, | ||
| }); | ||
| continue; | ||
| } | ||
| if (TERMINAL_STATUSES.includes(state.status)) { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const { status } = state; | ||
|
|
||
| if (status === "failed") { | ||
| throw new Error(`Crawl ${quote.jobId} failed: ${state.errorMessage}`); | ||
| } | ||
|
|
||
| if (!TERMINAL_STATUSES.includes(status)) { | ||
| $.export("$summary", `Crawl ${quote.jobId} is still running; collect its URLs with Get Mapped Site URLs`); | ||
| return { | ||
| ...quote, | ||
| status, | ||
| urls: [], | ||
| }; | ||
| } | ||
|
|
||
| const results = await this.app.getAllSitemapUrls({ | ||
| $, | ||
| jobId: quote.jobId, | ||
| }); | ||
|
|
||
| $.export("$summary", `Discovered ${results.total} URL${results.total === 1 | ||
| ? "" | ||
| : "s"} on ${this.url}`); | ||
| return { | ||
| jobId: quote.jobId, | ||
| status, | ||
| estimatedCostUsd: quote.estimatedCostUsd, | ||
| estimatedPages: quote.estimatedPages, | ||
| ...results, | ||
| }; | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Replace "Failed requests are not billed" with a statement that matches the provider's billing rules.
String's fetch documentation says that most failures are not billed. It also says that a target TLS/SSL failure can be billed when an upstream attempt has already done billable work. Those
502responses includex-billed-request-type. The README states that no failures are billed, which is not correct.📝 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents