langchain-scrapeunblocker 0.1.0__tar.gz

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.
@@ -0,0 +1,24 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes on every GitHub Release through PyPI Trusted Publishing (OIDC):
4
+ # no API token anywhere. The trusted publisher points at this repo, this
5
+ # workflow (publish.yml) and the environment "pypi".
6
+
7
+ on:
8
+ release:
9
+ types: [published]
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment: pypi
15
+ permissions:
16
+ id-token: write
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+ - run: pip install -e ".[test]" && pytest
23
+ - run: pip install build && python -m build
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,20 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[test]"
20
+ - run: pytest -v
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .venv/
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release: `ScrapeUnblockerMarkdown`, `ScrapeUnblockerSearchAndRead`, `ScrapeUnblockerGoogleSearch`, `ScrapeUnblockerParsedData`, `ScrapeUnblockerListPlugins`, `ScrapeUnblockerRunPlugin` and `ScrapeUnblockerToolkit`, sync and async, passing LangChain's standard tool tests.
@@ -0,0 +1,17 @@
1
+ <!-- HQ-SESSION-GUARD v1 (managed from ScrapeUnblocker/headquarters - do not remove) -->
2
+ # HQ SESSION GUARD (Claude - MANDATORY, READ FIRST)
3
+
4
+ This repo is part of the ScrapeUnblocker ecosystem. All development on it is governed by the central
5
+ `headquarters` repo (`ScrapeUnblocker/headquarters`) - its CLAUDE.md carries the global rules
6
+ (branch/push policy, lint gates, credentials handling, worklog, deploy runbooks).
7
+
8
+ - **If the headquarters CLAUDE.md IS loaded in your context** (the session was started from the local
9
+ `headquarters` folder - you can see its "ScrapeUnblocker - Valdymo pultas (Headquarters)" instructions),
10
+ this guard is satisfied: work normally under those rules.
11
+ - **If it is NOT loaded** (Claude was launched directly in this repo or anywhere else): treat this repo as
12
+ **READ-ONLY**. Do NOT edit files, do NOT commit, do NOT push, do NOT create branches or tags, and do NOT
13
+ run deploys from here. Tell the developer: ScrapeUnblocker development sessions must be started from the
14
+ local `headquarters` folder (clone of `ScrapeUnblocker/headquarters`) so the global rules load - then stop.
15
+ - Reading, searching, running read-only commands and explaining code is always allowed.
16
+ - **Exemption:** sanctioned headless server agents (self-heal / scout / parts-monitor / no-code crawler etc.
17
+ running on our servers) follow their own playbooks and are NOT bound by this guard.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScrapeUnblocker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-scrapeunblocker
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for ScrapeUnblocker: read any web page as Markdown or JSON, search Google, run 100+ site scrapers
5
+ Project-URL: Homepage, https://scrapeunblocker.com?utm_source=pypi&utm_medium=integration&utm_campaign=langchain
6
+ Project-URL: Documentation, https://github.com/ScrapeUnblocker/langchain-scrapeunblocker#readme
7
+ Project-URL: Source, https://github.com/ScrapeUnblocker/langchain-scrapeunblocker
8
+ Project-URL: Issues, https://github.com/ScrapeUnblocker/langchain-scrapeunblocker/issues
9
+ Author-email: ScrapeUnblocker <info@scrapeunblocker.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,langchain,llm,markdown,scraping,serp,tools,web-scraping
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: langchain-core<2.0.0,>=0.3.0
22
+ Requires-Dist: scrapeunblocker>=0.18.6
23
+ Provides-Extra: test
24
+ Requires-Dist: langchain-tests==1.1.9; extra == 'test'
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
26
+ Requires-Dist: pytest>=8; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # langchain-scrapeunblocker
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/langchain-scrapeunblocker)](https://pypi.org/project/langchain-scrapeunblocker/)
32
+ [![Test](https://github.com/ScrapeUnblocker/langchain-scrapeunblocker/actions/workflows/test.yml/badge.svg)](https://github.com/ScrapeUnblocker/langchain-scrapeunblocker/actions/workflows/test.yml)
33
+
34
+ LangChain tools for [ScrapeUnblocker](https://scrapeunblocker.com?utm_source=github&utm_medium=integration&utm_campaign=langchain): give your agent live web data. Read any public page in a real browser as clean Markdown or structured JSON, search Google, and run 100+ ready-made site scrapers (Amazon, Walmart, eBay, Google Maps, YouTube, Zillow, Indeed and more), including pages behind Cloudflare, DataDome, PerimeterX and Akamai.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install -U langchain-scrapeunblocker
40
+ export SCRAPEUNBLOCKER_API_KEY="your-api-key"
41
+ ```
42
+
43
+ Get a key at [app.scrapeunblocker.com](https://app.scrapeunblocker.com?utm_source=github&utm_medium=integration&utm_campaign=langchain) - a free account comes with 500 requests to start.
44
+
45
+ ## Use with an agent
46
+
47
+ ```python
48
+ from langchain.agents import create_agent
49
+ from langchain_scrapeunblocker import ScrapeUnblockerToolkit
50
+
51
+ agent = create_agent(
52
+ "anthropic:claude-sonnet-5-5",
53
+ tools=ScrapeUnblockerToolkit().get_tools(),
54
+ )
55
+ result = agent.invoke(
56
+ {"messages": [{"role": "user", "content": "What does the Echo Dot cost on amazon.com today?"}]}
57
+ )
58
+ print(result["messages"][-1].content)
59
+ ```
60
+
61
+ ## Tools
62
+
63
+ | Tool | Name the model sees | What it does | Billed |
64
+ |------|---------------------|--------------|--------|
65
+ | `ScrapeUnblockerMarkdown` | `scrapeunblocker_markdown` | A page as clean Markdown (main content, absolute links) | 1 request |
66
+ | `ScrapeUnblockerSearchAndRead` | `scrapeunblocker_search_and_read` | Google search plus the top 1-5 results read as Markdown, in one call | 1 + 1 per page |
67
+ | `ScrapeUnblockerGoogleSearch` | `scrapeunblocker_google_search` | Organic results, AI Overview, People also ask, related searches | 1 per result page |
68
+ | `ScrapeUnblockerParsedData` | `scrapeunblocker_parsed_data` | Structured JSON from a product, article or listing page | 1 request |
69
+ | `ScrapeUnblockerListPlugins` | `scrapeunblocker_list_plugins` | Search the catalog of 100+ site scrapers | free |
70
+ | `ScrapeUnblockerRunPlugin` | `scrapeunblocker_run_plugin` | Run a site scraper and get its JSON | per plugin |
71
+
72
+ Use them one by one:
73
+
74
+ ```python
75
+ from langchain_scrapeunblocker import ScrapeUnblockerMarkdown, ScrapeUnblockerRunPlugin
76
+
77
+ ScrapeUnblockerMarkdown().invoke({"url": "https://example.com"})
78
+
79
+ ScrapeUnblockerRunPlugin().invoke(
80
+ {"plugin": "youtube_transcript", "params": {"video": "dQw4w9WgXcQ"}}
81
+ )
82
+ ```
83
+
84
+ `ScrapeUnblockerGoogleSearch`, `ScrapeUnblockerParsedData` and `ScrapeUnblockerRunPlugin` return `content_and_artifact`: the model gets a compact text or JSON, and the full API response is on the `ToolMessage`'s `artifact` for your code. Called with a tool call, they return that message:
85
+
86
+ ```python
87
+ from langchain_scrapeunblocker import ScrapeUnblockerGoogleSearch
88
+
89
+ msg = ScrapeUnblockerGoogleSearch().invoke(
90
+ {"name": "scrapeunblocker_google_search", "args": {"query": "langchain"}, "id": "1", "type": "tool_call"}
91
+ )
92
+ msg.content # what the model reads
93
+ msg.artifact # the whole results page: ads, knowledge panel, local, shopping, videos...
94
+ ```
95
+
96
+ Every tool takes an optional `proxy_country` (ISO code such as `"US"` or `"DE"`) to make the request from that country, and works with `ainvoke` too.
97
+
98
+ The plugin catalog comes from the installed [`scrapeunblocker`](https://pypi.org/project/scrapeunblocker/) SDK: `pip install -U scrapeunblocker` picks up new plugins without a new release of this package.
99
+
100
+ ## Configuration
101
+
102
+ | Argument | Default | |
103
+ |----------|---------|---|
104
+ | `api_key` | `SCRAPEUNBLOCKER_API_KEY` (or `SCRAPEUNBLOCKER_KEY`) | Your API key |
105
+ | `timeout` | `180` | Seconds per API call; protected pages can take a while |
106
+ | `max_retries` | `2` | Retries for 429 and 502-504 |
107
+ | `max_chars` | `100000` (Markdown), `60000` (JSON) | Longer results are cut, with a note saying so |
108
+
109
+ `ScrapeUnblockerToolkit(include_plugins=False)` leaves out the two plugin tools.
110
+
111
+ Failed calls come back to the model as text it can act on (for example "the page itself answered 404, do not retry"), not as exceptions. Set `handle_tool_error=False` on a tool to raise instead.
112
+
113
+ ## Links
114
+
115
+ - [ScrapeUnblocker docs](https://docs.scrapeunblocker.com?utm_source=github&utm_medium=integration&utm_campaign=langchain)
116
+ - [Site plugins](https://docs.scrapeunblocker.com/plugins/overview?utm_source=github&utm_medium=integration&utm_campaign=langchain)
117
+ - [Python SDK](https://github.com/ScrapeUnblocker/scrapeunblocker-python)
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ pip install -e ".[test]"
123
+ pytest # unit tests, no network
124
+ SCRAPEUNBLOCKER_API_KEY=... pytest tests/integration_tests # real API calls
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,101 @@
1
+ # langchain-scrapeunblocker
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/langchain-scrapeunblocker)](https://pypi.org/project/langchain-scrapeunblocker/)
4
+ [![Test](https://github.com/ScrapeUnblocker/langchain-scrapeunblocker/actions/workflows/test.yml/badge.svg)](https://github.com/ScrapeUnblocker/langchain-scrapeunblocker/actions/workflows/test.yml)
5
+
6
+ LangChain tools for [ScrapeUnblocker](https://scrapeunblocker.com?utm_source=github&utm_medium=integration&utm_campaign=langchain): give your agent live web data. Read any public page in a real browser as clean Markdown or structured JSON, search Google, and run 100+ ready-made site scrapers (Amazon, Walmart, eBay, Google Maps, YouTube, Zillow, Indeed and more), including pages behind Cloudflare, DataDome, PerimeterX and Akamai.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install -U langchain-scrapeunblocker
12
+ export SCRAPEUNBLOCKER_API_KEY="your-api-key"
13
+ ```
14
+
15
+ Get a key at [app.scrapeunblocker.com](https://app.scrapeunblocker.com?utm_source=github&utm_medium=integration&utm_campaign=langchain) - a free account comes with 500 requests to start.
16
+
17
+ ## Use with an agent
18
+
19
+ ```python
20
+ from langchain.agents import create_agent
21
+ from langchain_scrapeunblocker import ScrapeUnblockerToolkit
22
+
23
+ agent = create_agent(
24
+ "anthropic:claude-sonnet-5-5",
25
+ tools=ScrapeUnblockerToolkit().get_tools(),
26
+ )
27
+ result = agent.invoke(
28
+ {"messages": [{"role": "user", "content": "What does the Echo Dot cost on amazon.com today?"}]}
29
+ )
30
+ print(result["messages"][-1].content)
31
+ ```
32
+
33
+ ## Tools
34
+
35
+ | Tool | Name the model sees | What it does | Billed |
36
+ |------|---------------------|--------------|--------|
37
+ | `ScrapeUnblockerMarkdown` | `scrapeunblocker_markdown` | A page as clean Markdown (main content, absolute links) | 1 request |
38
+ | `ScrapeUnblockerSearchAndRead` | `scrapeunblocker_search_and_read` | Google search plus the top 1-5 results read as Markdown, in one call | 1 + 1 per page |
39
+ | `ScrapeUnblockerGoogleSearch` | `scrapeunblocker_google_search` | Organic results, AI Overview, People also ask, related searches | 1 per result page |
40
+ | `ScrapeUnblockerParsedData` | `scrapeunblocker_parsed_data` | Structured JSON from a product, article or listing page | 1 request |
41
+ | `ScrapeUnblockerListPlugins` | `scrapeunblocker_list_plugins` | Search the catalog of 100+ site scrapers | free |
42
+ | `ScrapeUnblockerRunPlugin` | `scrapeunblocker_run_plugin` | Run a site scraper and get its JSON | per plugin |
43
+
44
+ Use them one by one:
45
+
46
+ ```python
47
+ from langchain_scrapeunblocker import ScrapeUnblockerMarkdown, ScrapeUnblockerRunPlugin
48
+
49
+ ScrapeUnblockerMarkdown().invoke({"url": "https://example.com"})
50
+
51
+ ScrapeUnblockerRunPlugin().invoke(
52
+ {"plugin": "youtube_transcript", "params": {"video": "dQw4w9WgXcQ"}}
53
+ )
54
+ ```
55
+
56
+ `ScrapeUnblockerGoogleSearch`, `ScrapeUnblockerParsedData` and `ScrapeUnblockerRunPlugin` return `content_and_artifact`: the model gets a compact text or JSON, and the full API response is on the `ToolMessage`'s `artifact` for your code. Called with a tool call, they return that message:
57
+
58
+ ```python
59
+ from langchain_scrapeunblocker import ScrapeUnblockerGoogleSearch
60
+
61
+ msg = ScrapeUnblockerGoogleSearch().invoke(
62
+ {"name": "scrapeunblocker_google_search", "args": {"query": "langchain"}, "id": "1", "type": "tool_call"}
63
+ )
64
+ msg.content # what the model reads
65
+ msg.artifact # the whole results page: ads, knowledge panel, local, shopping, videos...
66
+ ```
67
+
68
+ Every tool takes an optional `proxy_country` (ISO code such as `"US"` or `"DE"`) to make the request from that country, and works with `ainvoke` too.
69
+
70
+ The plugin catalog comes from the installed [`scrapeunblocker`](https://pypi.org/project/scrapeunblocker/) SDK: `pip install -U scrapeunblocker` picks up new plugins without a new release of this package.
71
+
72
+ ## Configuration
73
+
74
+ | Argument | Default | |
75
+ |----------|---------|---|
76
+ | `api_key` | `SCRAPEUNBLOCKER_API_KEY` (or `SCRAPEUNBLOCKER_KEY`) | Your API key |
77
+ | `timeout` | `180` | Seconds per API call; protected pages can take a while |
78
+ | `max_retries` | `2` | Retries for 429 and 502-504 |
79
+ | `max_chars` | `100000` (Markdown), `60000` (JSON) | Longer results are cut, with a note saying so |
80
+
81
+ `ScrapeUnblockerToolkit(include_plugins=False)` leaves out the two plugin tools.
82
+
83
+ Failed calls come back to the model as text it can act on (for example "the page itself answered 404, do not retry"), not as exceptions. Set `handle_tool_error=False` on a tool to raise instead.
84
+
85
+ ## Links
86
+
87
+ - [ScrapeUnblocker docs](https://docs.scrapeunblocker.com?utm_source=github&utm_medium=integration&utm_campaign=langchain)
88
+ - [Site plugins](https://docs.scrapeunblocker.com/plugins/overview?utm_source=github&utm_medium=integration&utm_campaign=langchain)
89
+ - [Python SDK](https://github.com/ScrapeUnblocker/scrapeunblocker-python)
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ pip install -e ".[test]"
95
+ pytest # unit tests, no network
96
+ SCRAPEUNBLOCKER_API_KEY=... pytest tests/integration_tests # real API calls
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-scrapeunblocker"
7
+ dynamic = ["version"]
8
+ description = "LangChain tools for ScrapeUnblocker: read any web page as Markdown or JSON, search Google, run 100+ site scrapers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ keywords = ["langchain", "scraping", "web-scraping", "agents", "llm", "serp", "markdown", "tools"]
13
+ authors = [{ name = "ScrapeUnblocker", email = "info@scrapeunblocker.com" }]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ ]
23
+ dependencies = ["langchain-core>=0.3.0,<2.0.0", "scrapeunblocker>=0.18.6"]
24
+
25
+ [project.optional-dependencies]
26
+ test = ["pytest>=8", "pytest-asyncio>=0.23", "langchain-tests==1.1.9"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://scrapeunblocker.com?utm_source=pypi&utm_medium=integration&utm_campaign=langchain"
30
+ Documentation = "https://github.com/ScrapeUnblocker/langchain-scrapeunblocker#readme"
31
+ Source = "https://github.com/ScrapeUnblocker/langchain-scrapeunblocker"
32
+ Issues = "https://github.com/ScrapeUnblocker/langchain-scrapeunblocker/issues"
33
+
34
+ [tool.hatch.version]
35
+ path = "src/langchain_scrapeunblocker/version.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/langchain_scrapeunblocker"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests/unit_tests"]
42
+ asyncio_mode = "auto"
43
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,27 @@
1
+ """LangChain integration for ScrapeUnblocker: live web data for agents.
2
+
3
+ Read any page as Markdown or structured JSON in a real browser, search Google,
4
+ and run 100+ ready-made site scrapers. See https://docs.scrapeunblocker.com
5
+ """
6
+
7
+ from langchain_scrapeunblocker.toolkit import ScrapeUnblockerToolkit
8
+ from langchain_scrapeunblocker.tools import (
9
+ ScrapeUnblockerGoogleSearch,
10
+ ScrapeUnblockerListPlugins,
11
+ ScrapeUnblockerMarkdown,
12
+ ScrapeUnblockerParsedData,
13
+ ScrapeUnblockerRunPlugin,
14
+ ScrapeUnblockerSearchAndRead,
15
+ )
16
+ from langchain_scrapeunblocker.version import __version__
17
+
18
+ __all__ = [
19
+ "ScrapeUnblockerGoogleSearch",
20
+ "ScrapeUnblockerListPlugins",
21
+ "ScrapeUnblockerMarkdown",
22
+ "ScrapeUnblockerParsedData",
23
+ "ScrapeUnblockerRunPlugin",
24
+ "ScrapeUnblockerSearchAndRead",
25
+ "ScrapeUnblockerToolkit",
26
+ "__version__",
27
+ ]
@@ -0,0 +1,139 @@
1
+ """Shared plumbing for the ScrapeUnblocker tools: the API key, the SDK clients
2
+ and turning SDK errors into text an agent can act on."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from typing import Any, Optional
8
+
9
+ from langchain_core.tools import BaseTool, ToolException
10
+ from pydantic import Field, PrivateAttr, SecretStr, model_validator
11
+ from scrapeunblocker import AsyncClient, Client
12
+ from scrapeunblocker.exceptions import (
13
+ AuthenticationError,
14
+ BlockedError,
15
+ PaymentRequiredError,
16
+ RateLimitError,
17
+ ScrapeTimeoutError,
18
+ ScrapeUnblockerError,
19
+ TargetNotFoundError,
20
+ )
21
+
22
+ from langchain_scrapeunblocker.version import __version__
23
+
24
+ DEFAULT_BASE_URL = "https://api.scrapeunblocker.com"
25
+ # Checked in this order; the SDK itself reads only the second.
26
+ API_KEY_ENV_VARS = ("SCRAPEUNBLOCKER_API_KEY", "SCRAPEUNBLOCKER_KEY")
27
+ SIGNUP_URL = (
28
+ "https://app.scrapeunblocker.com"
29
+ "?utm_source=langchain&utm_medium=integration&utm_campaign=langchain"
30
+ )
31
+
32
+
33
+ def _key_from_env() -> Optional[str]:
34
+ for name in API_KEY_ENV_VARS:
35
+ value = os.environ.get(name)
36
+ if value:
37
+ return value
38
+ return None
39
+
40
+
41
+ def cap_text(text: str, max_chars: int, hint: str) -> str:
42
+ """Cut `text` to `max_chars`, saying so, so the agent knows it is partial."""
43
+ if len(text) <= max_chars:
44
+ return text
45
+ return f"{text[:max_chars]}\n\n[Cut at {max_chars} of {len(text)} characters. {hint}]"
46
+
47
+
48
+ def error_text(err: BaseException) -> str:
49
+ """What the agent is told when a call fails, with what to do next."""
50
+ if isinstance(err, TargetNotFoundError):
51
+ return (
52
+ f"The page itself answered {err.origin_status} (not found or gone). "
53
+ "It was billed as one request; do not retry the same URL."
54
+ )
55
+ if isinstance(err, AuthenticationError):
56
+ return (
57
+ "ScrapeUnblocker rejected the API key, or the account has no active plan. "
58
+ f"Check the key or pick a plan at {SIGNUP_URL}"
59
+ )
60
+ if isinstance(err, PaymentRequiredError):
61
+ return f"The ScrapeUnblocker account is out of credits or over its limit: {err}"
62
+ if isinstance(err, BlockedError):
63
+ return f"The site blocked this request even through ScrapeUnblocker: {err}. Not billed."
64
+ if isinstance(err, RateLimitError):
65
+ return "Too many requests at once for this API key. Wait a moment and try again."
66
+ if isinstance(err, ScrapeTimeoutError):
67
+ return "The page took too long to load. Try again, or try a simpler URL."
68
+ return f"ScrapeUnblocker request failed: {err}"
69
+
70
+
71
+ class ScrapeUnblockerBaseTool(BaseTool):
72
+ """Base class: owns the API key and the ScrapeUnblocker SDK clients."""
73
+
74
+ api_key: Optional[SecretStr] = Field(default=None)
75
+ """ScrapeUnblocker API key. Read from ``SCRAPEUNBLOCKER_API_KEY`` (or
76
+ ``SCRAPEUNBLOCKER_KEY``) when not passed."""
77
+
78
+ base_url: str = DEFAULT_BASE_URL
79
+ """API host. Rarely needed."""
80
+
81
+ timeout: float = 180.0
82
+ """Seconds to wait for one API call. Protected pages can take a while."""
83
+
84
+ max_retries: int = 2
85
+ """Retries for transient failures (429, 502-504, network errors)."""
86
+
87
+ handle_tool_error: bool = True
88
+ """Failed calls come back to the agent as text instead of raising."""
89
+
90
+ _client: Optional[Client] = PrivateAttr(default=None)
91
+ _aclient: Optional[AsyncClient] = PrivateAttr(default=None)
92
+
93
+ @model_validator(mode="before")
94
+ @classmethod
95
+ def _api_key_from_env(cls, values: Any) -> Any:
96
+ if isinstance(values, dict) and not values.get("api_key"):
97
+ key = _key_from_env()
98
+ if key:
99
+ values = {**values, "api_key": key}
100
+ return values
101
+
102
+ def _key(self) -> str:
103
+ if self.api_key is None or not self.api_key.get_secret_value():
104
+ raise ToolException(
105
+ "No ScrapeUnblocker API key. Pass api_key=... or set SCRAPEUNBLOCKER_API_KEY. "
106
+ f"Get a key at {SIGNUP_URL}"
107
+ )
108
+ return self.api_key.get_secret_value()
109
+
110
+ @staticmethod
111
+ def _name_this_package(client: Any) -> None:
112
+ # The API counts calls per client library by User-Agent token.
113
+ try:
114
+ headers = client._http.headers
115
+ headers["User-Agent"] = f"{headers.get('User-Agent', '')} langchain-scrapeunblocker/{__version__}".strip()
116
+ except AttributeError:
117
+ pass
118
+
119
+ @property
120
+ def client(self) -> Client:
121
+ if self._client is None:
122
+ self._client = Client(
123
+ self._key(), base_url=self.base_url, timeout=self.timeout, max_retries=self.max_retries
124
+ )
125
+ self._name_this_package(self._client)
126
+ return self._client
127
+
128
+ @property
129
+ def aclient(self) -> AsyncClient:
130
+ if self._aclient is None:
131
+ self._aclient = AsyncClient(
132
+ self._key(), base_url=self.base_url, timeout=self.timeout, max_retries=self.max_retries
133
+ )
134
+ self._name_this_package(self._aclient)
135
+ return self._aclient
136
+
137
+ @staticmethod
138
+ def _fail(err: ScrapeUnblockerError) -> ToolException:
139
+ return ToolException(error_text(err))
@@ -0,0 +1,106 @@
1
+ """The site plugin catalog, read from the installed ``scrapeunblocker`` SDK.
2
+
3
+ The SDK has one generated method per public plugin, with its parameters and a
4
+ docstring. Reading them here keeps this package in step with the SDK: a new
5
+ plugin needs an SDK upgrade, not a release of this package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from functools import lru_cache
12
+ from typing import Any, Dict, List, NamedTuple, Optional
13
+
14
+ from langchain_core.tools import ToolException
15
+ from scrapeunblocker import Client
16
+
17
+ # Plugins the SDK still has methods for but we do not offer.
18
+ _HIDDEN_PREFIXES = ("temu_",)
19
+ # Client methods that are not plugins.
20
+ _NOT_PLUGINS = frozenset(
21
+ {
22
+ "close",
23
+ "plugin",
24
+ "get_page_source",
25
+ "get_markdown",
26
+ "get_parsed",
27
+ "get_page_with_cookies",
28
+ "get_image",
29
+ "serp",
30
+ }
31
+ )
32
+ _SUMMARY_MAX = 110
33
+ _LIST_MAX = 40
34
+
35
+
36
+ class Plugin(NamedTuple):
37
+ name: str
38
+ summary: str
39
+ doc: str
40
+ params: List[str]
41
+ signature: inspect.Signature
42
+
43
+
44
+ def _summary(doc: str) -> str:
45
+ first = " ".join(doc.split("\n\n", 1)[0].split())
46
+ return first if len(first) <= _SUMMARY_MAX else first[: _SUMMARY_MAX - 3].rstrip() + "..."
47
+
48
+
49
+ @lru_cache(maxsize=1)
50
+ def plugins() -> Dict[str, Plugin]:
51
+ found: Dict[str, Plugin] = {}
52
+ for name, member in inspect.getmembers(Client, inspect.isfunction):
53
+ if name.startswith("_") or name in _NOT_PLUGINS or name.startswith(_HIDDEN_PREFIXES):
54
+ continue
55
+ sig = inspect.signature(member)
56
+ params = [p for p in sig.parameters if p != "self" and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD]
57
+ doc = inspect.getdoc(member) or ""
58
+ found[name] = Plugin(name, _summary(doc), doc, params, sig)
59
+ return found
60
+
61
+
62
+ def _line(p: Plugin) -> str:
63
+ return f"- {p.name}({', '.join(p.params)}): {p.summary}"
64
+
65
+
66
+ def describe(query: Optional[str] = None) -> str:
67
+ """The catalog text for scrapeunblocker_list_plugins."""
68
+ catalog = plugins()
69
+ q = (query or "").strip().lower()
70
+ if q in catalog:
71
+ p = catalog[q]
72
+ return f"{p.name}({', '.join(p.params)})\n\n{p.doc}"
73
+ if not q:
74
+ # Names only: with summaries the whole list is too long for one tool result.
75
+ return (
76
+ f"{len(catalog)} plugins. Search with a word (e.g. 'amazon', 'reviews', 'jobs') to see what "
77
+ "they do, or pass an exact name for its parameters.\n\n" + ", ".join(catalog)
78
+ )
79
+ words = q.split()
80
+ matches = [p for p in catalog.values() if all(w in f"{p.name} {p.summary}".lower() for w in words)]
81
+ if not matches:
82
+ return f'No plugin matches "{query}". Call scrapeunblocker_list_plugins with no query to see all of them.'
83
+ shown = matches[:_LIST_MAX]
84
+ more = f" (showing {_LIST_MAX}; narrow the query)" if len(matches) > _LIST_MAX else ""
85
+ return f'{len(matches)} plugins match "{query}"{more}:\n\n' + "\n".join(_line(p) for p in shown)
86
+
87
+
88
+ def check_call(name: str, params: Dict[str, Any]) -> Dict[str, Any]:
89
+ """The keyword arguments for plugin `name`, or a ToolException the agent can fix."""
90
+ p = plugins().get(name)
91
+ if p is None:
92
+ raise ToolException(
93
+ f'There is no plugin "{name}". Find the right name with scrapeunblocker_list_plugins.'
94
+ )
95
+ if not isinstance(params, dict):
96
+ raise ToolException("params must be an object of parameter names to values.")
97
+ unknown = sorted(set(params) - set(p.params))
98
+ if unknown:
99
+ raise ToolException(
100
+ f"{name} has no parameter {', '.join(unknown)}. Its parameters: {', '.join(p.params) or 'none'}."
101
+ )
102
+ try:
103
+ p.signature.bind(None, **params)
104
+ except TypeError as err:
105
+ raise ToolException(f"Wrong parameters for {name}: {err}. Its parameters: {', '.join(p.params)}.") from err
106
+ return params