proxmint-haystack 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.
- proxmint_haystack-0.1.0/.github/workflows/ci.yml +17 -0
- proxmint_haystack-0.1.0/.github/workflows/publish.yml +24 -0
- proxmint_haystack-0.1.0/.gitignore +7 -0
- proxmint_haystack-0.1.0/LICENSE +21 -0
- proxmint_haystack-0.1.0/PKG-INFO +136 -0
- proxmint_haystack-0.1.0/README.md +114 -0
- proxmint_haystack-0.1.0/pyproject.toml +37 -0
- proxmint_haystack-0.1.0/src/haystack_integrations/components/fetchers/proxmint/__init__.py +5 -0
- proxmint_haystack-0.1.0/src/haystack_integrations/components/fetchers/proxmint/_gateway.py +239 -0
- proxmint_haystack-0.1.0/src/haystack_integrations/components/fetchers/proxmint/fetcher.py +144 -0
- proxmint_haystack-0.1.0/tests/test_fetcher.py +88 -0
- proxmint_haystack-0.1.0/tests/test_gateway.py +101 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: CI
|
|
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: ["3.10", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v6
|
|
17
|
+
- run: uv run --python ${{ matrix.python }} --group test pytest -q
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Publishes to PyPI when a version tag (v0.1.0, ...) is pushed. PyPI trusted publishing:
|
|
2
|
+
# no token is stored anywhere. One-time setup on pypi.org, under "Publishing" in the
|
|
3
|
+
# account: add a pending publisher with project proxmint-haystack, owner proxmint,
|
|
4
|
+
# repository proxmint-haystack, workflow publish.yml, environment pypi.
|
|
5
|
+
|
|
6
|
+
name: Publish
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: pypi
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
contents: read
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: astral-sh/setup-uv@v6
|
|
22
|
+
- run: uv run --group test pytest -q
|
|
23
|
+
- run: uv build
|
|
24
|
+
- run: uv publish
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Proxmint
|
|
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,136 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proxmint-haystack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Haystack component that fetches web pages through Proxmint residential, datacenter and mobile proxies, with country, city and ASN targeting and sticky sessions.
|
|
5
|
+
Project-URL: Homepage, https://proxmint.com
|
|
6
|
+
Project-URL: Documentation, https://proxmint.com/docs/haystack
|
|
7
|
+
Project-URL: Repository, https://github.com/proxmint/proxmint-haystack
|
|
8
|
+
Project-URL: Issues, https://github.com/proxmint/proxmint-haystack/issues
|
|
9
|
+
Author-email: Proxmint <support@proxmint.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,fetcher,haystack,proxmint,proxy,residential-proxy,web-scraping
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: haystack-ai>=2.10
|
|
20
|
+
Requires-Dist: httpx<1,>=0.27
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# proxmint-haystack
|
|
24
|
+
|
|
25
|
+
[Haystack](https://haystack.deepset.ai) integration for [Proxmint](https://proxmint.com) proxies: fetch web pages
|
|
26
|
+
from residential, datacenter or 4G/5G mobile IPs and get them back as `Document`s. Pick the exit country, state,
|
|
27
|
+
city, ZIP or ASN, and keep one IP for up to two hours when a site needs it.
|
|
28
|
+
|
|
29
|
+
- **`ProxmintFetcher`**: a component that takes a list of URLs and returns `documents` (readable page text, or the
|
|
30
|
+
raw HTML) and `failed_urls`. Use it as the first step of an indexing pipeline, or wrap it with `ComponentTool` to
|
|
31
|
+
give an agent a "read this page from Germany" tool.
|
|
32
|
+
|
|
33
|
+
Requests go straight from your machine to the Proxmint gateway (`gw.proxmint.com`). Nothing else sits in
|
|
34
|
+
between, and bandwidth comes out of your own Proxmint account.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install proxmint-haystack
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Credentials
|
|
43
|
+
|
|
44
|
+
Create an API key under **Settings** in the [Proxmint dashboard](https://proxmint.com/dashboard) and export it:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export PROXMINT_API_KEY="pmk_..."
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The key reads the proxy login for the product you choose (`residential` by default). You can use the proxy login
|
|
51
|
+
instead, with `PROXMINT_USERNAME` and `PROXMINT_PASSWORD`.
|
|
52
|
+
|
|
53
|
+
## Fetch pages
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
57
|
+
|
|
58
|
+
fetcher = ProxmintFetcher(country="gb")
|
|
59
|
+
result = fetcher.run(urls=["https://example.com", "https://example.org"])
|
|
60
|
+
|
|
61
|
+
print(result["documents"][0].content)
|
|
62
|
+
print(result["documents"][0].meta) # source, requested_url, status, title, content_type, country
|
|
63
|
+
print(result["failed_urls"])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Use it in a pipeline
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from haystack import Pipeline
|
|
70
|
+
from haystack.components.preprocessors import DocumentSplitter
|
|
71
|
+
from haystack.components.writers import DocumentWriter
|
|
72
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
73
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
74
|
+
|
|
75
|
+
store = InMemoryDocumentStore()
|
|
76
|
+
pipe = Pipeline()
|
|
77
|
+
pipe.add_component("fetcher", ProxmintFetcher(country="us", city="New York", session_minutes=30))
|
|
78
|
+
pipe.add_component("splitter", DocumentSplitter(split_by="sentence", split_length=5))
|
|
79
|
+
pipe.add_component("writer", DocumentWriter(store))
|
|
80
|
+
pipe.connect("fetcher.documents", "splitter.documents")
|
|
81
|
+
pipe.connect("splitter.documents", "writer.documents")
|
|
82
|
+
|
|
83
|
+
pipe.run({"fetcher": {"urls": ["https://example.com/pricing", "https://example.org/pricing"]}})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Use it as an agent tool
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from haystack.tools import ComponentTool
|
|
90
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
91
|
+
|
|
92
|
+
tool = ComponentTool(
|
|
93
|
+
component=ProxmintFetcher(),
|
|
94
|
+
name="fetch_pages",
|
|
95
|
+
description="Fetch web pages through a proxy in a chosen country and return their text.",
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Pass the tool to an `Agent` or a chat generator as usual. The model can set `urls` and an optional `country` per call.
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
| Option | Default | What it does |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `api_key` | `PROXMINT_API_KEY` | A Haystack `Secret` |
|
|
106
|
+
| `product` | `"residential"` | `residential`, `premium-residential`, `datacenter` or `mobile` |
|
|
107
|
+
| `country` | any | Exit country (two-letter code). `run(urls, country=...)` overrides it for one run |
|
|
108
|
+
| `state`, `city`, `zip_code`, `asn` | none | Finer targeting; needs `country` |
|
|
109
|
+
| `session_minutes` | none | Keep one exit IP across runs for 1 to 120 minutes. Default: a new IP for every URL |
|
|
110
|
+
| `output` | `"text"` | `"text"` for readable text, `"html"` for the raw page |
|
|
111
|
+
| `timeout` | `60` | Seconds per request |
|
|
112
|
+
| `raise_on_failure` | `False` | Raise on a failed URL instead of listing it in `failed_urls` |
|
|
113
|
+
|
|
114
|
+
A gateway refusal (no bandwidth left, no IP matching the targeting, a blocked site) names what to change.
|
|
115
|
+
|
|
116
|
+
## Good to know
|
|
117
|
+
|
|
118
|
+
- Web traffic works on destination ports 80, 443, 8080, 8443, 8888 and 8090 ([port access](https://proxmint.com/docs/ports)).
|
|
119
|
+
- A short list of sites, mostly banking and government, is refused by the network ([blocked websites](https://proxmint.com/docs/blocked-websites)).
|
|
120
|
+
- City, state, ZIP and ASN targeting meter at 2x on residential, datacenter and mobile ([targeting](https://proxmint.com/docs/targeting)).
|
|
121
|
+
- Each product allows up to 2,000 concurrent connections.
|
|
122
|
+
|
|
123
|
+
Full guide: [proxmint.com/docs/haystack](https://proxmint.com/docs/haystack). Pricing: [proxmint.com/pricing](https://proxmint.com/pricing).
|
|
124
|
+
Bandwidth is billed per GB and never expires.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
uv run --group test pytest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The tests run offline against a stand-in for the gateway.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# proxmint-haystack
|
|
2
|
+
|
|
3
|
+
[Haystack](https://haystack.deepset.ai) integration for [Proxmint](https://proxmint.com) proxies: fetch web pages
|
|
4
|
+
from residential, datacenter or 4G/5G mobile IPs and get them back as `Document`s. Pick the exit country, state,
|
|
5
|
+
city, ZIP or ASN, and keep one IP for up to two hours when a site needs it.
|
|
6
|
+
|
|
7
|
+
- **`ProxmintFetcher`**: a component that takes a list of URLs and returns `documents` (readable page text, or the
|
|
8
|
+
raw HTML) and `failed_urls`. Use it as the first step of an indexing pipeline, or wrap it with `ComponentTool` to
|
|
9
|
+
give an agent a "read this page from Germany" tool.
|
|
10
|
+
|
|
11
|
+
Requests go straight from your machine to the Proxmint gateway (`gw.proxmint.com`). Nothing else sits in
|
|
12
|
+
between, and bandwidth comes out of your own Proxmint account.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install proxmint-haystack
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Credentials
|
|
21
|
+
|
|
22
|
+
Create an API key under **Settings** in the [Proxmint dashboard](https://proxmint.com/dashboard) and export it:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export PROXMINT_API_KEY="pmk_..."
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The key reads the proxy login for the product you choose (`residential` by default). You can use the proxy login
|
|
29
|
+
instead, with `PROXMINT_USERNAME` and `PROXMINT_PASSWORD`.
|
|
30
|
+
|
|
31
|
+
## Fetch pages
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
35
|
+
|
|
36
|
+
fetcher = ProxmintFetcher(country="gb")
|
|
37
|
+
result = fetcher.run(urls=["https://example.com", "https://example.org"])
|
|
38
|
+
|
|
39
|
+
print(result["documents"][0].content)
|
|
40
|
+
print(result["documents"][0].meta) # source, requested_url, status, title, content_type, country
|
|
41
|
+
print(result["failed_urls"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use it in a pipeline
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from haystack import Pipeline
|
|
48
|
+
from haystack.components.preprocessors import DocumentSplitter
|
|
49
|
+
from haystack.components.writers import DocumentWriter
|
|
50
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
51
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
52
|
+
|
|
53
|
+
store = InMemoryDocumentStore()
|
|
54
|
+
pipe = Pipeline()
|
|
55
|
+
pipe.add_component("fetcher", ProxmintFetcher(country="us", city="New York", session_minutes=30))
|
|
56
|
+
pipe.add_component("splitter", DocumentSplitter(split_by="sentence", split_length=5))
|
|
57
|
+
pipe.add_component("writer", DocumentWriter(store))
|
|
58
|
+
pipe.connect("fetcher.documents", "splitter.documents")
|
|
59
|
+
pipe.connect("splitter.documents", "writer.documents")
|
|
60
|
+
|
|
61
|
+
pipe.run({"fetcher": {"urls": ["https://example.com/pricing", "https://example.org/pricing"]}})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Use it as an agent tool
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from haystack.tools import ComponentTool
|
|
68
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
69
|
+
|
|
70
|
+
tool = ComponentTool(
|
|
71
|
+
component=ProxmintFetcher(),
|
|
72
|
+
name="fetch_pages",
|
|
73
|
+
description="Fetch web pages through a proxy in a chosen country and return their text.",
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pass the tool to an `Agent` or a chat generator as usual. The model can set `urls` and an optional `country` per call.
|
|
78
|
+
|
|
79
|
+
## Options
|
|
80
|
+
|
|
81
|
+
| Option | Default | What it does |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `api_key` | `PROXMINT_API_KEY` | A Haystack `Secret` |
|
|
84
|
+
| `product` | `"residential"` | `residential`, `premium-residential`, `datacenter` or `mobile` |
|
|
85
|
+
| `country` | any | Exit country (two-letter code). `run(urls, country=...)` overrides it for one run |
|
|
86
|
+
| `state`, `city`, `zip_code`, `asn` | none | Finer targeting; needs `country` |
|
|
87
|
+
| `session_minutes` | none | Keep one exit IP across runs for 1 to 120 minutes. Default: a new IP for every URL |
|
|
88
|
+
| `output` | `"text"` | `"text"` for readable text, `"html"` for the raw page |
|
|
89
|
+
| `timeout` | `60` | Seconds per request |
|
|
90
|
+
| `raise_on_failure` | `False` | Raise on a failed URL instead of listing it in `failed_urls` |
|
|
91
|
+
|
|
92
|
+
A gateway refusal (no bandwidth left, no IP matching the targeting, a blocked site) names what to change.
|
|
93
|
+
|
|
94
|
+
## Good to know
|
|
95
|
+
|
|
96
|
+
- Web traffic works on destination ports 80, 443, 8080, 8443, 8888 and 8090 ([port access](https://proxmint.com/docs/ports)).
|
|
97
|
+
- A short list of sites, mostly banking and government, is refused by the network ([blocked websites](https://proxmint.com/docs/blocked-websites)).
|
|
98
|
+
- City, state, ZIP and ASN targeting meter at 2x on residential, datacenter and mobile ([targeting](https://proxmint.com/docs/targeting)).
|
|
99
|
+
- Each product allows up to 2,000 concurrent connections.
|
|
100
|
+
|
|
101
|
+
Full guide: [proxmint.com/docs/haystack](https://proxmint.com/docs/haystack). Pricing: [proxmint.com/pricing](https://proxmint.com/pricing).
|
|
102
|
+
Bandwidth is billed per GB and never expires.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv run --group test pytest
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The tests run offline against a stand-in for the gateway.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "proxmint-haystack"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Haystack component that fetches web pages through Proxmint residential, datacenter and mobile proxies, with country, city and ASN targeting and sticky sessions."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
authors = [{ name = "Proxmint", email = "support@proxmint.com" }]
|
|
10
|
+
keywords = ["haystack", "proxy", "residential-proxy", "web-scraping", "fetcher", "agents", "proxmint"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Internet :: Proxy Servers",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["haystack-ai>=2.10", "httpx>=0.27,<1"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://proxmint.com"
|
|
22
|
+
Documentation = "https://proxmint.com/docs/haystack"
|
|
23
|
+
Repository = "https://github.com/proxmint/proxmint-haystack"
|
|
24
|
+
Issues = "https://github.com/proxmint/proxmint-haystack/issues"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
test = ["pytest>=8"]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling>=1.27"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/haystack_integrations"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
from haystack_integrations.components.fetchers.proxmint._gateway import GatewayError, proxy_username
|
|
3
|
+
from haystack_integrations.components.fetchers.proxmint.fetcher import ProxmintFetcher
|
|
4
|
+
|
|
5
|
+
__all__ = ["GatewayError", "ProxmintFetcher", "proxy_username"]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Proxmint credentials, gateway usernames and one fetch through the proxy.
|
|
2
|
+
|
|
3
|
+
Targeting rides in the proxy username: the login, then ``__``, then ``key.value``
|
|
4
|
+
parameters joined by ``;``, for example ``login__cr.us;city.newyork;sessid.ab12;sessttl.30``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from html.parser import HTMLParser
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
ACCOUNT_URL = "https://proxmint.com/api/v1/account"
|
|
19
|
+
PRODUCTS = ("residential", "premium-residential", "datacenter", "mobile")
|
|
20
|
+
USER_AGENT = (
|
|
21
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
22
|
+
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# What the gateway puts in the status line when it refuses a request itself.
|
|
26
|
+
GATEWAY_ERRORS = {
|
|
27
|
+
"SITE_PERMANENTLY_BLOCKED": "The site is on the network's blocked list: https://proxmint.com/docs/blocked-websites",
|
|
28
|
+
"PORT_BLOCKED": "The destination port is closed. Web traffic works on 80, 443, 8080, 8443, 8888 and 8090.",
|
|
29
|
+
"HOST_BLOCKED": "You blocked this host yourself under Blocked hosts on your product page.",
|
|
30
|
+
"NO_USER": "The gateway doesn't recognise the login. Copy the credentials again from your dashboard.",
|
|
31
|
+
"TRAFFIC_EXHAUSTED": "The product's bandwidth is used up. Add a pack at https://proxmint.com/pricing",
|
|
32
|
+
"THREADS_EXHAUSTED": "Too many connections at once. Lower your concurrency.",
|
|
33
|
+
"USER_BLOCKED": "The proxy user is blocked, usually after many wrong passwords. Contact support@proxmint.com",
|
|
34
|
+
"NO_HOST_CONNECTION": "The exit IP couldn't reach the site. Retry: a new request gets a new IP.",
|
|
35
|
+
"NO_RAY": "No IPs match your targeting. Drop the city, ZIP or ASN, or check the location spelling.",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_FOLD = str.maketrans(
|
|
39
|
+
{"ł": "l", "ø": "o", "æ": "ae", "œ": "oe", "ß": "ss", "đ": "d", "ð": "d", "þ": "th", "ı": "i", "ħ": "h"}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GatewayError(Exception):
|
|
44
|
+
"""The Proxmint gateway refused the request (bad login, no bandwidth, blocked site...)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Credentials:
|
|
49
|
+
username: str
|
|
50
|
+
password: str
|
|
51
|
+
host: str = "gw.proxmint.com"
|
|
52
|
+
port: int = 823
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_credentials(
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
product: str = "residential",
|
|
58
|
+
*,
|
|
59
|
+
username: str | None = None,
|
|
60
|
+
password: str | None = None,
|
|
61
|
+
client: httpx.Client | None = None,
|
|
62
|
+
) -> Credentials:
|
|
63
|
+
"""Proxy login for one product.
|
|
64
|
+
|
|
65
|
+
A username and password (arguments, else ``PROXMINT_USERNAME`` / ``PROXMINT_PASSWORD``)
|
|
66
|
+
are used as they are. Otherwise the API key (argument, else ``PROXMINT_API_KEY``) reads
|
|
67
|
+
them from the account, which also picks the right product.
|
|
68
|
+
"""
|
|
69
|
+
if product not in PRODUCTS:
|
|
70
|
+
raise ValueError(f"product must be one of {', '.join(PRODUCTS)}")
|
|
71
|
+
if not api_key:
|
|
72
|
+
username = username or os.getenv("PROXMINT_USERNAME")
|
|
73
|
+
password = password or os.getenv("PROXMINT_PASSWORD")
|
|
74
|
+
if username and password:
|
|
75
|
+
return Credentials(username, password)
|
|
76
|
+
api_key = os.getenv("PROXMINT_API_KEY")
|
|
77
|
+
if not api_key:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"Set PROXMINT_API_KEY (create one under Settings at https://proxmint.com/dashboard) "
|
|
80
|
+
"or PROXMINT_USERNAME and PROXMINT_PASSWORD."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
get = client.get if client else httpx.get
|
|
84
|
+
r = get(ACCOUNT_URL, headers={"Authorization": f"Bearer {api_key}"}, timeout=20)
|
|
85
|
+
if r.status_code == 401:
|
|
86
|
+
raise ValueError("Proxmint rejected the API key. Create a new one under Settings in the dashboard.")
|
|
87
|
+
r.raise_for_status()
|
|
88
|
+
for p in r.json()["products"]:
|
|
89
|
+
if p["product"] != product:
|
|
90
|
+
continue
|
|
91
|
+
if p["status"] != "active":
|
|
92
|
+
raise ValueError(f"Your {product} product is {p['status']}. Top it up at https://proxmint.com/dashboard")
|
|
93
|
+
px = p["proxy"]
|
|
94
|
+
return Credentials(px["username"], px["password"], px["host"], int(px["httpPort"]))
|
|
95
|
+
raise ValueError(f"This account has no {product} product. Buy a pack at https://proxmint.com/pricing")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _geo(value: str) -> str:
|
|
99
|
+
"""A place name in the gateway's form: "São Paulo" -> saopaulo, "Łódź" -> lodz."""
|
|
100
|
+
s = unicodedata.normalize("NFKD", value.lower())
|
|
101
|
+
s = "".join(c for c in s if not unicodedata.combining(c)).translate(_FOLD)
|
|
102
|
+
return re.sub(r"[^a-z0-9]", "", s)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def proxy_username(
|
|
106
|
+
login: str,
|
|
107
|
+
*,
|
|
108
|
+
country: str | None = None,
|
|
109
|
+
state: str | None = None,
|
|
110
|
+
city: str | None = None,
|
|
111
|
+
zip_code: str | None = None,
|
|
112
|
+
asn: str | int | None = None,
|
|
113
|
+
session_id: str | None = None,
|
|
114
|
+
session_minutes: int | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""The login with targeting and sticky-session parameters appended."""
|
|
117
|
+
if (state or city or zip_code or asn) and not country:
|
|
118
|
+
raise ValueError("state, city, zip_code and asn need a country as well.")
|
|
119
|
+
params = []
|
|
120
|
+
if country:
|
|
121
|
+
params.append("cr." + re.sub(r"[^a-z]", "", country.lower())[:2])
|
|
122
|
+
if city:
|
|
123
|
+
params.append("city." + _geo(city))
|
|
124
|
+
if state:
|
|
125
|
+
params.append("state." + _geo(state))
|
|
126
|
+
if zip_code:
|
|
127
|
+
params.append("zip." + str(zip_code).strip())
|
|
128
|
+
if asn:
|
|
129
|
+
params.append("asn." + re.sub(r"\D", "", str(asn)))
|
|
130
|
+
if session_id:
|
|
131
|
+
params.append("sessid." + _geo(session_id))
|
|
132
|
+
if session_minutes:
|
|
133
|
+
params.append(f"sessttl.{max(1, min(120, round(session_minutes)))}")
|
|
134
|
+
return f"{login}__{';'.join(params)}" if params else login
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def new_session_id() -> str:
|
|
138
|
+
return secrets.token_hex(6)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def client_for(creds: Credentials, username: str, *, timeout: float = 60.0, headers: dict | None = None) -> httpx.Client:
|
|
142
|
+
"""An HTTP client whose every request leaves through the gateway as ``username``."""
|
|
143
|
+
proxy = httpx.Proxy(f"http://{creds.host}:{creds.port}", auth=(username, creds.password))
|
|
144
|
+
return httpx.Client(
|
|
145
|
+
proxy=proxy,
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
follow_redirects=True,
|
|
148
|
+
max_redirects=5,
|
|
149
|
+
headers={"User-Agent": USER_AGENT, **(headers or {})},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Page:
|
|
155
|
+
url: str
|
|
156
|
+
status: int
|
|
157
|
+
content_type: str
|
|
158
|
+
html: str
|
|
159
|
+
title: str | None
|
|
160
|
+
|
|
161
|
+
def text(self) -> str:
|
|
162
|
+
return html_to_text(self.html) if "html" in self.content_type else self.html
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fetch(client: httpx.Client, url: str) -> Page:
|
|
166
|
+
"""GET one page. Gateway refusals raise GatewayError; the site's own answers return as a Page."""
|
|
167
|
+
if not re.match(r"https?://", url):
|
|
168
|
+
raise ValueError(f"Not an http(s) URL: {url}")
|
|
169
|
+
try:
|
|
170
|
+
r = client.get(url)
|
|
171
|
+
except httpx.ProxyError as e: # HTTPS: the refusal comes back on the CONNECT
|
|
172
|
+
raise _gateway_error(str(e)) from e
|
|
173
|
+
reason = r.reason_phrase.strip().upper()
|
|
174
|
+
if r.status_code in (400, 403, 407, 500, 502, 503) and reason in GATEWAY_ERRORS: # plain HTTP
|
|
175
|
+
raise _gateway_error(f"{r.status_code} {reason}")
|
|
176
|
+
ctype = r.headers.get("content-type", "")
|
|
177
|
+
body = r.text
|
|
178
|
+
title = _Text.parse(body).title if "html" in ctype else None
|
|
179
|
+
return Page(str(r.url), r.status_code, ctype, body, title)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _gateway_error(status_line: str) -> GatewayError:
|
|
183
|
+
reason = status_line.split()[-1].upper() if status_line.split() else ""
|
|
184
|
+
hint = GATEWAY_ERRORS.get(reason, "See https://proxmint.com/docs/errors")
|
|
185
|
+
return GatewayError(f"Proxmint gateway: {status_line}. {hint}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _Text(HTMLParser):
|
|
189
|
+
SKIP = {"script", "style", "noscript", "template", "svg", "head"}
|
|
190
|
+
BLOCK = {
|
|
191
|
+
"address", "article", "aside", "blockquote", "br", "dd", "div", "dl", "dt", "figcaption", "footer",
|
|
192
|
+
"form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre",
|
|
193
|
+
"section", "table", "td", "th", "tr", "ul",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def __init__(self) -> None:
|
|
197
|
+
super().__init__(convert_charrefs=True)
|
|
198
|
+
self.parts: list[str] = []
|
|
199
|
+
self.title: str | None = None
|
|
200
|
+
self._skip = 0
|
|
201
|
+
self._in_title = False
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def parse(cls, html: str) -> _Text:
|
|
205
|
+
p = cls()
|
|
206
|
+
p.feed(html)
|
|
207
|
+
p.close()
|
|
208
|
+
return p
|
|
209
|
+
|
|
210
|
+
def handle_starttag(self, tag, attrs):
|
|
211
|
+
if tag == "title":
|
|
212
|
+
self._in_title = self.title is None # the page's own, not an <svg><title>
|
|
213
|
+
if tag == "body":
|
|
214
|
+
self._skip = 0 # </head> is optional in HTML
|
|
215
|
+
elif tag in self.SKIP:
|
|
216
|
+
self._skip += 1
|
|
217
|
+
elif tag in self.BLOCK:
|
|
218
|
+
self.parts.append("\n")
|
|
219
|
+
|
|
220
|
+
def handle_endtag(self, tag):
|
|
221
|
+
if tag == "title":
|
|
222
|
+
self._in_title = False
|
|
223
|
+
if tag in self.SKIP:
|
|
224
|
+
self._skip = max(0, self._skip - 1)
|
|
225
|
+
elif tag in self.BLOCK:
|
|
226
|
+
self.parts.append("\n")
|
|
227
|
+
|
|
228
|
+
def handle_data(self, data):
|
|
229
|
+
if self._in_title:
|
|
230
|
+
self.title = ((self.title or "") + data).strip()
|
|
231
|
+
elif not self._skip:
|
|
232
|
+
self.parts.append(data)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def html_to_text(html: str) -> str:
|
|
236
|
+
"""Readable text of a page: no scripts or styles, one line per block, blank lines squeezed."""
|
|
237
|
+
raw = "".join(_Text.parse(html).parts)
|
|
238
|
+
lines = (re.sub(r"[ \t\r\f\v]+", " ", line).strip() for line in raw.split("\n"))
|
|
239
|
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""ProxmintFetcher: fetch web pages through Proxmint proxies and return Documents."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from haystack import Document, component, default_from_dict, default_to_dict
|
|
11
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
12
|
+
|
|
13
|
+
from haystack_integrations.components.fetchers.proxmint._gateway import (
|
|
14
|
+
Credentials,
|
|
15
|
+
GatewayError,
|
|
16
|
+
client_for,
|
|
17
|
+
fetch,
|
|
18
|
+
load_credentials,
|
|
19
|
+
new_session_id,
|
|
20
|
+
proxy_username,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
Product = Literal["residential", "premium-residential", "datacenter", "mobile"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@component
|
|
29
|
+
class ProxmintFetcher:
|
|
30
|
+
"""Fetch web pages through Proxmint residential, datacenter or mobile proxies.
|
|
31
|
+
|
|
32
|
+
Every URL leaves from a new exit IP, in the country, city or ASN you ask for. Set
|
|
33
|
+
``session_minutes`` to keep one IP across runs. The output is plain Documents, so
|
|
34
|
+
it plugs into a splitter, a converter or an embedder.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
38
|
+
|
|
39
|
+
fetcher = ProxmintFetcher(country="gb") # reads PROXMINT_API_KEY
|
|
40
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
41
|
+
print(result["documents"][0].content)
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: Secret = Secret.from_env_var("PROXMINT_API_KEY", strict=False), # noqa: B008
|
|
48
|
+
*,
|
|
49
|
+
product: Product = "residential",
|
|
50
|
+
country: str | None = None,
|
|
51
|
+
state: str | None = None,
|
|
52
|
+
city: str | None = None,
|
|
53
|
+
zip_code: str | None = None,
|
|
54
|
+
asn: str | None = None,
|
|
55
|
+
session_minutes: int | None = None,
|
|
56
|
+
output: Literal["text", "html"] = "text",
|
|
57
|
+
timeout: float = 60.0,
|
|
58
|
+
raise_on_failure: bool = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
:param api_key: Proxmint API key (`pmk_...`, created under Settings in the dashboard). Without one,
|
|
62
|
+
`PROXMINT_USERNAME` and `PROXMINT_PASSWORD` are read instead.
|
|
63
|
+
:param product: `residential`, `premium-residential`, `datacenter` or `mobile`.
|
|
64
|
+
:param country: ISO 3166 alpha-2 code, for example `us`. State, city, ZIP and ASN need a country.
|
|
65
|
+
:param session_minutes: Keep one exit IP for this many minutes (1-120), across runs. Default: a new IP per URL.
|
|
66
|
+
:param output: `text` for readable text, `html` for the raw markup.
|
|
67
|
+
:param raise_on_failure: Raise on a failed URL instead of skipping it (it is listed in `failed_urls`).
|
|
68
|
+
"""
|
|
69
|
+
self.api_key = api_key
|
|
70
|
+
self.product = product
|
|
71
|
+
self.targeting = {"country": country, "state": state, "city": city, "zip_code": zip_code, "asn": asn}
|
|
72
|
+
self.session_minutes = session_minutes
|
|
73
|
+
self.output = output
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.raise_on_failure = raise_on_failure
|
|
76
|
+
self._creds: Credentials | None = None
|
|
77
|
+
self._session_id = new_session_id() if session_minutes else None
|
|
78
|
+
|
|
79
|
+
def warm_up(self) -> None:
|
|
80
|
+
"""Read the proxy login once, so a pipeline run makes no extra API call."""
|
|
81
|
+
if self._creds is None:
|
|
82
|
+
self._creds = load_credentials(self.api_key.resolve_value(), self.product)
|
|
83
|
+
|
|
84
|
+
@component.output_types(documents=list[Document], failed_urls=list[str])
|
|
85
|
+
def run(self, urls: list[str], country: str | None = None) -> dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
:param urls: Pages to fetch (`http` or `https`).
|
|
88
|
+
:param country: Overrides the `country` set at init for this run (the state, city, ZIP and ASN set at init are dropped).
|
|
89
|
+
:returns: `documents` (content = page text or HTML, meta = source, status, title...) and `failed_urls`.
|
|
90
|
+
"""
|
|
91
|
+
self.warm_up()
|
|
92
|
+
assert self._creds is not None
|
|
93
|
+
targeting = self.targeting
|
|
94
|
+
if country and country.lower() != (targeting["country"] or "").lower():
|
|
95
|
+
targeting = {"country": country, "state": None, "city": None, "zip_code": None, "asn": None}
|
|
96
|
+
login = proxy_username(
|
|
97
|
+
self._creds.username,
|
|
98
|
+
**targeting,
|
|
99
|
+
session_id=self._session_id,
|
|
100
|
+
session_minutes=self.session_minutes,
|
|
101
|
+
)
|
|
102
|
+
documents: list[Document] = []
|
|
103
|
+
failed: list[str] = []
|
|
104
|
+
with client_for(self._creds, login, timeout=self.timeout) as client:
|
|
105
|
+
for url in urls:
|
|
106
|
+
try:
|
|
107
|
+
page = fetch(client, url)
|
|
108
|
+
except (GatewayError, httpx.HTTPError, ValueError) as e:
|
|
109
|
+
if self.raise_on_failure:
|
|
110
|
+
raise
|
|
111
|
+
logger.warning("Skipped %s: %s", url, e)
|
|
112
|
+
failed.append(url)
|
|
113
|
+
continue
|
|
114
|
+
documents.append(
|
|
115
|
+
Document(
|
|
116
|
+
content=page.html if self.output == "html" else page.text(),
|
|
117
|
+
meta={
|
|
118
|
+
"source": page.url,
|
|
119
|
+
"requested_url": url,
|
|
120
|
+
"status": page.status,
|
|
121
|
+
"title": page.title,
|
|
122
|
+
"content_type": page.content_type,
|
|
123
|
+
"country": targeting["country"],
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return {"documents": documents, "failed_urls": failed}
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, Any]:
|
|
130
|
+
return default_to_dict(
|
|
131
|
+
self,
|
|
132
|
+
api_key=self.api_key.to_dict(),
|
|
133
|
+
product=self.product,
|
|
134
|
+
session_minutes=self.session_minutes,
|
|
135
|
+
output=self.output,
|
|
136
|
+
timeout=self.timeout,
|
|
137
|
+
raise_on_failure=self.raise_on_failure,
|
|
138
|
+
**self.targeting,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, data: dict[str, Any]) -> ProxmintFetcher:
|
|
143
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
144
|
+
return default_from_dict(cls, data)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
from haystack import Pipeline
|
|
4
|
+
from haystack.utils import Secret
|
|
5
|
+
|
|
6
|
+
from haystack_integrations.components.fetchers.proxmint import ProxmintFetcher
|
|
7
|
+
from haystack_integrations.components.fetchers.proxmint._gateway import Credentials, GatewayError
|
|
8
|
+
|
|
9
|
+
CREDS = Credentials("login", "secret")
|
|
10
|
+
MODULE = "haystack_integrations.components.fetchers.proxmint.fetcher"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def gateway(monkeypatch):
|
|
15
|
+
"""Stands in for the gateway: records the proxy username and answers with a small page."""
|
|
16
|
+
seen: list[str] = []
|
|
17
|
+
|
|
18
|
+
def client_for(creds, username, *, timeout, headers=None):
|
|
19
|
+
seen.append(username)
|
|
20
|
+
|
|
21
|
+
def handler(req):
|
|
22
|
+
if "fail" in req.url.host:
|
|
23
|
+
return httpx.Response(407, extensions={"reason_phrase": b"TRAFFIC_EXHAUSTED"})
|
|
24
|
+
return httpx.Response(
|
|
25
|
+
200,
|
|
26
|
+
headers={"content-type": "text/html"},
|
|
27
|
+
text=f"<title>Page {req.url.host}</title><p>Hello from {req.url.host}</p>",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr(f"{MODULE}.client_for", client_for)
|
|
33
|
+
monkeypatch.setattr(f"{MODULE}.load_credentials", lambda *a, **k: CREDS)
|
|
34
|
+
return seen
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_documents_and_failed_urls(gateway):
|
|
38
|
+
out = ProxmintFetcher(country="gb").run(["https://a.example/", "http://fail.example/", "https://b.example/"])
|
|
39
|
+
assert [d.content for d in out["documents"]] == ["Hello from a.example", "Hello from b.example"]
|
|
40
|
+
assert out["documents"][0].meta["title"] == "Page a.example"
|
|
41
|
+
assert out["documents"][0].meta["country"] == "gb"
|
|
42
|
+
assert out["failed_urls"] == ["http://fail.example/"]
|
|
43
|
+
assert gateway == ["login__cr.gb"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_runtime_country_and_sticky_session(gateway):
|
|
47
|
+
fetcher = ProxmintFetcher(country="us", city="New York", session_minutes=10)
|
|
48
|
+
fetcher.run(["https://a.example/"])
|
|
49
|
+
fetcher.run(["https://a.example/"], country="de")
|
|
50
|
+
sid = gateway[0].split("sessid.")[1].split(";")[0]
|
|
51
|
+
assert gateway[0] == f"login__cr.us;city.newyork;sessid.{sid};sessttl.10"
|
|
52
|
+
assert gateway[1].startswith("login__cr.de;sessid.") and "city" not in gateway[1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_html_output(gateway):
|
|
56
|
+
out = ProxmintFetcher(output="html").run(["https://a.example/"])
|
|
57
|
+
assert out["documents"][0].content.startswith("<title>Page a.example</title>")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_raise_on_failure(gateway):
|
|
61
|
+
with pytest.raises(GatewayError, match="bandwidth is used up"):
|
|
62
|
+
ProxmintFetcher(raise_on_failure=True).run(["http://fail.example/"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_serialization_round_trip(monkeypatch):
|
|
66
|
+
monkeypatch.setenv("PROXMINT_API_KEY", "pmk_test")
|
|
67
|
+
fetcher = ProxmintFetcher(product="mobile", country="br", city="São Paulo", session_minutes=5, output="html")
|
|
68
|
+
data = fetcher.to_dict()
|
|
69
|
+
assert data["type"].endswith("ProxmintFetcher")
|
|
70
|
+
assert data["init_parameters"]["api_key"] == {"env_vars": ["PROXMINT_API_KEY"], "strict": False, "type": "env_var"}
|
|
71
|
+
again = ProxmintFetcher.from_dict(data)
|
|
72
|
+
assert (again.product, again.targeting["city"], again.session_minutes, again.output) == ("mobile", "São Paulo", 5, "html")
|
|
73
|
+
assert isinstance(again.api_key, Secret)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_in_a_pipeline(gateway):
|
|
77
|
+
pipe = Pipeline()
|
|
78
|
+
pipe.add_component("fetcher", ProxmintFetcher())
|
|
79
|
+
out = pipe.run({"fetcher": {"urls": ["https://a.example/"]}})
|
|
80
|
+
assert out["fetcher"]["documents"][0].content == "Hello from a.example"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_as_agent_tool(gateway):
|
|
84
|
+
from haystack.tools import ComponentTool
|
|
85
|
+
|
|
86
|
+
tool = ComponentTool(component=ProxmintFetcher(), name="fetch_pages", description="Fetch pages")
|
|
87
|
+
assert set(tool.parameters["properties"]) == {"urls", "country"} and tool.parameters["required"] == ["urls"]
|
|
88
|
+
assert tool.function(urls=["https://a.example/"])["documents"][0].content == "Hello from a.example"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from haystack_integrations.components.fetchers.proxmint._gateway import GatewayError, fetch, html_to_text, load_credentials, proxy_username
|
|
5
|
+
|
|
6
|
+
ACCOUNT = {
|
|
7
|
+
"balance": {"cents": 0, "usd": "0.00"},
|
|
8
|
+
"products": [
|
|
9
|
+
{
|
|
10
|
+
"product": "residential",
|
|
11
|
+
"status": "active",
|
|
12
|
+
"proxy": {"host": "gw.proxmint.com", "httpPort": 823, "socks5Port": 824, "username": "u1", "password": "p1"},
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"product": "mobile",
|
|
16
|
+
"status": "paused",
|
|
17
|
+
"proxy": {"host": "gw.proxmint.com", "httpPort": 823, "socks5Port": 824, "username": "u2", "password": "p2"},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def account_client(status=200, body=ACCOUNT):
|
|
24
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
25
|
+
assert req.headers["authorization"] == "Bearer pmk_test"
|
|
26
|
+
return httpx.Response(status, json=body)
|
|
27
|
+
|
|
28
|
+
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_username_targeting():
|
|
32
|
+
assert proxy_username("u") == "u"
|
|
33
|
+
assert proxy_username("u", country="US") == "u__cr.us"
|
|
34
|
+
assert (
|
|
35
|
+
proxy_username("u", country="br", city="São Paulo", session_id="ab12", session_minutes=30)
|
|
36
|
+
== "u__cr.br;city.saopaulo;sessid.ab12;sessttl.30"
|
|
37
|
+
)
|
|
38
|
+
assert proxy_username("u", country="pl", city="Łódź") == "u__cr.pl;city.lodz"
|
|
39
|
+
assert proxy_username("u", country="us", state="New York", zip_code=" 10001 ", asn="AS7922") == (
|
|
40
|
+
"u__cr.us;state.newyork;zip.10001;asn.7922"
|
|
41
|
+
)
|
|
42
|
+
assert proxy_username("u", session_id="x", session_minutes=500).endswith("sessttl.120")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_fine_geo_needs_a_country():
|
|
46
|
+
with pytest.raises(ValueError, match="need a country"):
|
|
47
|
+
proxy_username("u", city="paris")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_credentials_from_env(monkeypatch):
|
|
51
|
+
monkeypatch.delenv("PROXMINT_API_KEY", raising=False)
|
|
52
|
+
monkeypatch.setenv("PROXMINT_USERNAME", "envuser")
|
|
53
|
+
monkeypatch.setenv("PROXMINT_PASSWORD", "envpass")
|
|
54
|
+
c = load_credentials()
|
|
55
|
+
assert (c.username, c.password, c.host, c.port) == ("envuser", "envpass", "gw.proxmint.com", 823)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_credentials_from_api_key(monkeypatch):
|
|
59
|
+
monkeypatch.delenv("PROXMINT_USERNAME", raising=False)
|
|
60
|
+
c = load_credentials("pmk_test", client=account_client())
|
|
61
|
+
assert (c.username, c.password) == ("u1", "p1")
|
|
62
|
+
with pytest.raises(ValueError, match="mobile product is paused"):
|
|
63
|
+
load_credentials("pmk_test", "mobile", client=account_client())
|
|
64
|
+
with pytest.raises(ValueError, match="no datacenter product"):
|
|
65
|
+
load_credentials("pmk_test", "datacenter", client=account_client())
|
|
66
|
+
with pytest.raises(ValueError, match="rejected the API key"):
|
|
67
|
+
load_credentials("pmk_test", client=account_client(401, {"error": "invalid"}))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_no_credentials(monkeypatch):
|
|
71
|
+
for k in ("PROXMINT_API_KEY", "PROXMINT_USERNAME", "PROXMINT_PASSWORD"):
|
|
72
|
+
monkeypatch.delenv(k, raising=False)
|
|
73
|
+
with pytest.raises(ValueError, match="PROXMINT_API_KEY"):
|
|
74
|
+
load_credentials()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def page_client(status=200, reason=b"OK", html="<html><head><title>Hi</title></head><body><p>x</p></body></html>"):
|
|
78
|
+
def handler(req):
|
|
79
|
+
return httpx.Response(
|
|
80
|
+
status, headers={"content-type": "text/html; charset=utf-8"}, text=html, extensions={"reason_phrase": reason}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_fetch_page_and_gateway_refusal():
|
|
87
|
+
page = fetch(page_client(), "https://example.com/")
|
|
88
|
+
assert (page.status, page.title, page.text()) == (200, "Hi", "x")
|
|
89
|
+
# The site's own 403 is content, not a gateway error.
|
|
90
|
+
assert fetch(page_client(403, b"Forbidden"), "http://example.com/").status == 403
|
|
91
|
+
with pytest.raises(GatewayError, match="No IPs match your targeting"):
|
|
92
|
+
fetch(page_client(503, b"NO_RAY"), "http://example.com/")
|
|
93
|
+
with pytest.raises(ValueError):
|
|
94
|
+
fetch(page_client(), "ftp://example.com/")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_html_to_text():
|
|
98
|
+
html = """<html><head><title>T</title><style>p{}</style></head>
|
|
99
|
+
<body><script>var a=1</script><h1>Title</h1><p>One two</p><p>Three&four<br>five</p>
|
|
100
|
+
<svg><title>icon</title></svg><ul><li>a</li><li>b</li></ul></body></html>"""
|
|
101
|
+
assert html_to_text(html) == "Title\n\nOne two\n\nThree&four\nfive\n\na\n\nb"
|