scrapeunblocker-haystack 0.3.0__tar.gz → 0.4.1__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.
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/CHANGELOG.md +23 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/PKG-INFO +39 -9
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/README.md +38 -8
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/src/scrapeunblocker_haystack/fetcher.py +40 -1
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/src/scrapeunblocker_haystack/search.py +7 -2
- scrapeunblocker_haystack-0.4.1/src/scrapeunblocker_haystack/version.py +1 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/tests/test_components.py +147 -1
- scrapeunblocker_haystack-0.3.0/src/scrapeunblocker_haystack/version.py +0 -1
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/pyproject.toml +0 -0
- {scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/src/scrapeunblocker_haystack/__init__.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
- `ScrapeUnblockerWebSearch` adds `displayed_url`, `domain`, `source` (the
|
|
6
|
+
site's name) and `date` to each Document's metadata. The API now returns
|
|
7
|
+
every organic result with its real URL, also those Google shows without a
|
|
8
|
+
snippet; such a Document's content is the result's title.
|
|
9
|
+
|
|
10
|
+
## 0.4.0
|
|
11
|
+
|
|
12
|
+
- `ScrapeUnblockerFetcher` can return pages as clean Markdown for LLMs and RAG.
|
|
13
|
+
With `markdown=True` each Document's content is the page as Markdown
|
|
14
|
+
(headings, lists, GitHub-flavored tables, absolute links, images with alt
|
|
15
|
+
text, fenced code; no scripts, styles, navigation, footers or forms), ready
|
|
16
|
+
for a prompt or a splitter without an HTML converter. `main_content_only`
|
|
17
|
+
(default `True`) keeps only the main content; `False` converts the whole
|
|
18
|
+
page. `meta["markdown"]` is `True` for these Documents (`False` otherwise)
|
|
19
|
+
and `meta["main_content_only"]` records the setting. Billed like an HTML
|
|
20
|
+
fetch. `markdown` cannot be combined with `parsed_data` or `list_elements`
|
|
21
|
+
(the API refuses it), so that raises `ValueError`. Defaults are unchanged.
|
|
22
|
+
- README: the pipeline example fetches Markdown and passes it straight to the
|
|
23
|
+
prompt. The previous example connected the fetcher's Documents to
|
|
24
|
+
`HTMLToDocument`, which only accepts sources, so the pipeline did not build.
|
|
25
|
+
|
|
3
26
|
## 0.3.0
|
|
4
27
|
|
|
5
28
|
- With `parsed_data=True`, a page that loads but holds no structured data is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scrapeunblocker-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Haystack integration for ScrapeUnblocker - scrape pages behind anti-bot protections
|
|
5
5
|
Project-URL: Homepage, https://www.scrapeunblocker.com
|
|
6
6
|
Project-URL: Documentation, https://docs.scrapeunblocker.com
|
|
@@ -69,10 +69,12 @@ print(result["documents"][0].content[:200])
|
|
|
69
69
|
| --- | --- | --- |
|
|
70
70
|
| `api_key` | `SCRAPEUNBLOCKER_API_KEY` env var | ScrapeUnblocker API key |
|
|
71
71
|
| `parsed_data` | `False` | Return AI-parsed structured JSON instead of raw HTML |
|
|
72
|
-
| `proxy_country` | `None` | Two-letter country code
|
|
72
|
+
| `proxy_country` | `None` | Two-letter country code of the country the request is made from |
|
|
73
73
|
| `time_sleep` | `None` | Seconds to wait after load before capturing |
|
|
74
74
|
| `steps` | `None` | Browser actions to run before the HTML is captured (see below) |
|
|
75
75
|
| `list_elements` | `False` | Return the parsed elements JSON instead of HTML (see below) |
|
|
76
|
+
| `markdown` | `False` | Return the page as clean Markdown instead of HTML (see below) |
|
|
77
|
+
| `main_content_only` | `True` | With `markdown`, keep only the main content (drop navigation, header, footer, sidebars) |
|
|
76
78
|
| `base_url` | `https://api.scrapeunblocker.com` | API base URL |
|
|
77
79
|
| `timeout` | `180` | HTTP timeout in seconds |
|
|
78
80
|
| `raise_on_failure` | `False` | Raise instead of skipping a URL that fails |
|
|
@@ -80,6 +82,36 @@ print(result["documents"][0].content[:200])
|
|
|
80
82
|
By default a URL that cannot be fetched is logged and skipped, so one bad URL
|
|
81
83
|
does not discard the rest of the batch.
|
|
82
84
|
|
|
85
|
+
#### Markdown for LLMs and RAG
|
|
86
|
+
|
|
87
|
+
Set `markdown=True` to get each page as clean Markdown instead of HTML:
|
|
88
|
+
headings, lists, tables (GitHub-flavored), absolute links, images with their
|
|
89
|
+
alt text and fenced code blocks, without scripts, styles, navigation, footers,
|
|
90
|
+
forms or hidden elements. The Markdown goes straight into a prompt or a
|
|
91
|
+
splitter, with no HTML converter, and is usually a small fraction of the
|
|
92
|
+
HTML's size. It is billed like an HTML fetch.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from scrapeunblocker_haystack import ScrapeUnblockerFetcher
|
|
96
|
+
|
|
97
|
+
fetcher = ScrapeUnblockerFetcher(markdown=True)
|
|
98
|
+
result = fetcher.run(
|
|
99
|
+
urls=["https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
doc = result["documents"][0]
|
|
103
|
+
print("# A Light in the Attic" in doc.content) # True
|
|
104
|
+
print(doc.meta["content_type"]) # text/markdown; charset=utf-8
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
By default only the page's main content is kept: navigation, the site header,
|
|
108
|
+
footer and sidebars are dropped. Pass `main_content_only=False` to convert the
|
|
109
|
+
whole page. `markdown` works with `proxy_country`, `time_sleep` and `steps`,
|
|
110
|
+
but not with `parsed_data` or `list_elements`, which return their own JSON
|
|
111
|
+
formats; combining them raises `ValueError`. A page that does not exist
|
|
112
|
+
(404/410) is skipped or raises `TargetNotFoundError`, as below, with the
|
|
113
|
+
site's own page as Markdown in `body`.
|
|
114
|
+
|
|
83
115
|
#### Browser steps
|
|
84
116
|
|
|
85
117
|
Pass `steps` to drive the page in a real browser before its HTML is captured -
|
|
@@ -153,7 +185,8 @@ print(doc.content) # {"url": ..., "count": ..., "elements": [...]
|
|
|
153
185
|
### ScrapeUnblockerWebSearch
|
|
154
186
|
|
|
155
187
|
Searches Google and returns the organic results as `Document` objects, with the
|
|
156
|
-
snippet as content and `title` / `link` / `position`
|
|
188
|
+
snippet as content and `title` / `link` / `position` / `displayed_url` / `domain` /
|
|
189
|
+
`source` (the site's name) / `date` in the metadata.
|
|
157
190
|
|
|
158
191
|
```python
|
|
159
192
|
from scrapeunblocker_haystack import ScrapeUnblockerWebSearch
|
|
@@ -174,12 +207,11 @@ for doc in result["documents"]:
|
|
|
174
207
|
|
|
175
208
|
## In a pipeline
|
|
176
209
|
|
|
177
|
-
|
|
210
|
+
Fetch pages as Markdown and answer a question from them:
|
|
178
211
|
|
|
179
212
|
```python
|
|
180
213
|
from haystack import Pipeline
|
|
181
214
|
from haystack.components.builders import ChatPromptBuilder
|
|
182
|
-
from haystack.components.converters import HTMLToDocument
|
|
183
215
|
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
184
216
|
from haystack.dataclasses import ChatMessage
|
|
185
217
|
|
|
@@ -194,13 +226,11 @@ prompt = [
|
|
|
194
226
|
]
|
|
195
227
|
|
|
196
228
|
pipe = Pipeline()
|
|
197
|
-
pipe.add_component("fetcher", ScrapeUnblockerFetcher())
|
|
198
|
-
pipe.add_component("converter", HTMLToDocument())
|
|
229
|
+
pipe.add_component("fetcher", ScrapeUnblockerFetcher(markdown=True))
|
|
199
230
|
pipe.add_component("prompt_builder", ChatPromptBuilder(template=prompt, required_variables="*"))
|
|
200
231
|
pipe.add_component("llm", OpenAIChatGenerator())
|
|
201
232
|
|
|
202
|
-
pipe.connect("fetcher.documents", "
|
|
203
|
-
pipe.connect("converter.documents", "prompt_builder.documents")
|
|
233
|
+
pipe.connect("fetcher.documents", "prompt_builder.documents")
|
|
204
234
|
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
205
235
|
|
|
206
236
|
result = pipe.run(
|
|
@@ -44,10 +44,12 @@ print(result["documents"][0].content[:200])
|
|
|
44
44
|
| --- | --- | --- |
|
|
45
45
|
| `api_key` | `SCRAPEUNBLOCKER_API_KEY` env var | ScrapeUnblocker API key |
|
|
46
46
|
| `parsed_data` | `False` | Return AI-parsed structured JSON instead of raw HTML |
|
|
47
|
-
| `proxy_country` | `None` | Two-letter country code
|
|
47
|
+
| `proxy_country` | `None` | Two-letter country code of the country the request is made from |
|
|
48
48
|
| `time_sleep` | `None` | Seconds to wait after load before capturing |
|
|
49
49
|
| `steps` | `None` | Browser actions to run before the HTML is captured (see below) |
|
|
50
50
|
| `list_elements` | `False` | Return the parsed elements JSON instead of HTML (see below) |
|
|
51
|
+
| `markdown` | `False` | Return the page as clean Markdown instead of HTML (see below) |
|
|
52
|
+
| `main_content_only` | `True` | With `markdown`, keep only the main content (drop navigation, header, footer, sidebars) |
|
|
51
53
|
| `base_url` | `https://api.scrapeunblocker.com` | API base URL |
|
|
52
54
|
| `timeout` | `180` | HTTP timeout in seconds |
|
|
53
55
|
| `raise_on_failure` | `False` | Raise instead of skipping a URL that fails |
|
|
@@ -55,6 +57,36 @@ print(result["documents"][0].content[:200])
|
|
|
55
57
|
By default a URL that cannot be fetched is logged and skipped, so one bad URL
|
|
56
58
|
does not discard the rest of the batch.
|
|
57
59
|
|
|
60
|
+
#### Markdown for LLMs and RAG
|
|
61
|
+
|
|
62
|
+
Set `markdown=True` to get each page as clean Markdown instead of HTML:
|
|
63
|
+
headings, lists, tables (GitHub-flavored), absolute links, images with their
|
|
64
|
+
alt text and fenced code blocks, without scripts, styles, navigation, footers,
|
|
65
|
+
forms or hidden elements. The Markdown goes straight into a prompt or a
|
|
66
|
+
splitter, with no HTML converter, and is usually a small fraction of the
|
|
67
|
+
HTML's size. It is billed like an HTML fetch.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from scrapeunblocker_haystack import ScrapeUnblockerFetcher
|
|
71
|
+
|
|
72
|
+
fetcher = ScrapeUnblockerFetcher(markdown=True)
|
|
73
|
+
result = fetcher.run(
|
|
74
|
+
urls=["https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
doc = result["documents"][0]
|
|
78
|
+
print("# A Light in the Attic" in doc.content) # True
|
|
79
|
+
print(doc.meta["content_type"]) # text/markdown; charset=utf-8
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
By default only the page's main content is kept: navigation, the site header,
|
|
83
|
+
footer and sidebars are dropped. Pass `main_content_only=False` to convert the
|
|
84
|
+
whole page. `markdown` works with `proxy_country`, `time_sleep` and `steps`,
|
|
85
|
+
but not with `parsed_data` or `list_elements`, which return their own JSON
|
|
86
|
+
formats; combining them raises `ValueError`. A page that does not exist
|
|
87
|
+
(404/410) is skipped or raises `TargetNotFoundError`, as below, with the
|
|
88
|
+
site's own page as Markdown in `body`.
|
|
89
|
+
|
|
58
90
|
#### Browser steps
|
|
59
91
|
|
|
60
92
|
Pass `steps` to drive the page in a real browser before its HTML is captured -
|
|
@@ -128,7 +160,8 @@ print(doc.content) # {"url": ..., "count": ..., "elements": [...]
|
|
|
128
160
|
### ScrapeUnblockerWebSearch
|
|
129
161
|
|
|
130
162
|
Searches Google and returns the organic results as `Document` objects, with the
|
|
131
|
-
snippet as content and `title` / `link` / `position`
|
|
163
|
+
snippet as content and `title` / `link` / `position` / `displayed_url` / `domain` /
|
|
164
|
+
`source` (the site's name) / `date` in the metadata.
|
|
132
165
|
|
|
133
166
|
```python
|
|
134
167
|
from scrapeunblocker_haystack import ScrapeUnblockerWebSearch
|
|
@@ -149,12 +182,11 @@ for doc in result["documents"]:
|
|
|
149
182
|
|
|
150
183
|
## In a pipeline
|
|
151
184
|
|
|
152
|
-
|
|
185
|
+
Fetch pages as Markdown and answer a question from them:
|
|
153
186
|
|
|
154
187
|
```python
|
|
155
188
|
from haystack import Pipeline
|
|
156
189
|
from haystack.components.builders import ChatPromptBuilder
|
|
157
|
-
from haystack.components.converters import HTMLToDocument
|
|
158
190
|
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
159
191
|
from haystack.dataclasses import ChatMessage
|
|
160
192
|
|
|
@@ -169,13 +201,11 @@ prompt = [
|
|
|
169
201
|
]
|
|
170
202
|
|
|
171
203
|
pipe = Pipeline()
|
|
172
|
-
pipe.add_component("fetcher", ScrapeUnblockerFetcher())
|
|
173
|
-
pipe.add_component("converter", HTMLToDocument())
|
|
204
|
+
pipe.add_component("fetcher", ScrapeUnblockerFetcher(markdown=True))
|
|
174
205
|
pipe.add_component("prompt_builder", ChatPromptBuilder(template=prompt, required_variables="*"))
|
|
175
206
|
pipe.add_component("llm", OpenAIChatGenerator())
|
|
176
207
|
|
|
177
|
-
pipe.connect("fetcher.documents", "
|
|
178
|
-
pipe.connect("converter.documents", "prompt_builder.documents")
|
|
208
|
+
pipe.connect("fetcher.documents", "prompt_builder.documents")
|
|
179
209
|
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
180
210
|
|
|
181
211
|
result = pipe.run(
|
|
@@ -103,6 +103,18 @@ class ScrapeUnblockerFetcher:
|
|
|
103
103
|
print(result["documents"][0].content[:200])
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
### Markdown
|
|
107
|
+
|
|
108
|
+
Set `markdown=True` to get each page as clean Markdown, ready for an LLM or a
|
|
109
|
+
RAG pipeline, instead of HTML. By default only the main content is kept;
|
|
110
|
+
`main_content_only=False` converts the whole page:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
fetcher = ScrapeUnblockerFetcher(markdown=True)
|
|
114
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
115
|
+
print(result["documents"][0].content[:200])
|
|
116
|
+
```
|
|
117
|
+
|
|
106
118
|
### Browser steps
|
|
107
119
|
|
|
108
120
|
Drive the page in a real browser before the HTML is captured - click, type,
|
|
@@ -135,6 +147,8 @@ class ScrapeUnblockerFetcher:
|
|
|
135
147
|
base_url: str = DEFAULT_BASE_URL,
|
|
136
148
|
timeout: int = DEFAULT_TIMEOUT,
|
|
137
149
|
raise_on_failure: bool = False,
|
|
150
|
+
markdown: bool = False,
|
|
151
|
+
main_content_only: bool = True,
|
|
138
152
|
) -> None:
|
|
139
153
|
"""
|
|
140
154
|
:param api_key: ScrapeUnblocker API key. Read from `SCRAPEUNBLOCKER_API_KEY` by default.
|
|
@@ -152,7 +166,21 @@ class ScrapeUnblockerFetcher:
|
|
|
152
166
|
:param timeout: HTTP timeout in seconds.
|
|
153
167
|
:param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
|
|
154
168
|
(including when a browser step fails or the page does not exist).
|
|
169
|
+
:param markdown: Return each page as clean Markdown instead of HTML: headings,
|
|
170
|
+
lists, tables, absolute links, images with alt text and code blocks, without
|
|
171
|
+
scripts, styles, navigation, footers or forms. Billed like an HTML fetch.
|
|
172
|
+
Cannot be combined with `parsed_data` or `list_elements`.
|
|
173
|
+
:param main_content_only: With `markdown`, keep only the page's main content and
|
|
174
|
+
drop navigation, the site header, footer and sidebars. `False` converts the
|
|
175
|
+
whole page. Ignored without `markdown`.
|
|
176
|
+
:raises ValueError: If `markdown` is combined with `parsed_data` or `list_elements`.
|
|
155
177
|
"""
|
|
178
|
+
if markdown and (parsed_data or list_elements):
|
|
179
|
+
# The API refuses these combinations (HTTP 422): each returns its own format.
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"markdown=True cannot be combined with parsed_data or list_elements: "
|
|
182
|
+
"each returns its own format."
|
|
183
|
+
)
|
|
156
184
|
self.api_key = api_key
|
|
157
185
|
self.parsed_data = parsed_data
|
|
158
186
|
self.proxy_country = proxy_country
|
|
@@ -162,6 +190,8 @@ class ScrapeUnblockerFetcher:
|
|
|
162
190
|
self.base_url = base_url.rstrip("/")
|
|
163
191
|
self.timeout = timeout
|
|
164
192
|
self.raise_on_failure = raise_on_failure
|
|
193
|
+
self.markdown = markdown
|
|
194
|
+
self.main_content_only = main_content_only
|
|
165
195
|
|
|
166
196
|
def to_dict(self) -> Dict[str, Any]:
|
|
167
197
|
"""Serialize this component to a dictionary."""
|
|
@@ -176,6 +206,8 @@ class ScrapeUnblockerFetcher:
|
|
|
176
206
|
base_url=self.base_url,
|
|
177
207
|
timeout=self.timeout,
|
|
178
208
|
raise_on_failure=self.raise_on_failure,
|
|
209
|
+
markdown=self.markdown,
|
|
210
|
+
main_content_only=self.main_content_only,
|
|
179
211
|
)
|
|
180
212
|
|
|
181
213
|
@classmethod
|
|
@@ -196,6 +228,9 @@ class ScrapeUnblockerFetcher:
|
|
|
196
228
|
params["steps"] = json.dumps(self.steps)
|
|
197
229
|
if self.list_elements:
|
|
198
230
|
params["list_elements"] = True
|
|
231
|
+
if self.markdown:
|
|
232
|
+
params["markdown"] = True
|
|
233
|
+
params["main_content_only"] = self.main_content_only
|
|
199
234
|
|
|
200
235
|
response = requests.post(
|
|
201
236
|
f"{self.base_url}/getPageSource",
|
|
@@ -241,7 +276,8 @@ class ScrapeUnblockerFetcher:
|
|
|
241
276
|
Document; with `raise_on_failure` it raises `TargetNotFoundError`. With
|
|
242
277
|
`parsed_data`, a page holding no structured data still yields a
|
|
243
278
|
Document: its JSON carries `data_extracted: false` and the rendered
|
|
244
|
-
`html`, and `meta["data_extracted"]` is False.
|
|
279
|
+
`html`, and `meta["data_extracted"]` is False. With `markdown`, the
|
|
280
|
+
Document's content is the page as Markdown and `meta["markdown"]` is True.
|
|
245
281
|
|
|
246
282
|
:param urls: URLs to fetch.
|
|
247
283
|
:returns: A dictionary with a `documents` key holding the fetched pages.
|
|
@@ -273,7 +309,10 @@ class ScrapeUnblockerFetcher:
|
|
|
273
309
|
"content_type": response.headers.get("Content-Type", ""),
|
|
274
310
|
"parsed_data": self.parsed_data,
|
|
275
311
|
"list_elements": self.list_elements,
|
|
312
|
+
"markdown": self.markdown,
|
|
276
313
|
}
|
|
314
|
+
if self.markdown:
|
|
315
|
+
meta["main_content_only"] = self.main_content_only
|
|
277
316
|
|
|
278
317
|
if json_mode:
|
|
279
318
|
try:
|
|
@@ -17,8 +17,9 @@ class ScrapeUnblockerWebSearch:
|
|
|
17
17
|
"""
|
|
18
18
|
Searches Google through the ScrapeUnblocker API and returns the organic results.
|
|
19
19
|
|
|
20
|
-
Each result becomes a Document whose content is the snippet
|
|
21
|
-
|
|
20
|
+
Each result becomes a Document whose content is the snippet (the title when
|
|
21
|
+
Google shows none), with the title, link, position, displayed URL, domain,
|
|
22
|
+
site name and date in the metadata.
|
|
22
23
|
|
|
23
24
|
### Usage example
|
|
24
25
|
|
|
@@ -107,6 +108,10 @@ class ScrapeUnblockerWebSearch:
|
|
|
107
108
|
"title": result.get("title"),
|
|
108
109
|
"link": result.get("url"),
|
|
109
110
|
"position": result.get("position"),
|
|
111
|
+
"displayed_url": result.get("displayedUrl"),
|
|
112
|
+
"domain": result.get("domain"),
|
|
113
|
+
"source": result.get("source"),
|
|
114
|
+
"date": result.get("date"),
|
|
110
115
|
"query": query,
|
|
111
116
|
},
|
|
112
117
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.1"
|
|
@@ -211,7 +211,9 @@ class TestWebSearch:
|
|
|
211
211
|
mock_post.return_value = _response(
|
|
212
212
|
json_data={
|
|
213
213
|
"organic": [
|
|
214
|
-
{"title": "First", "url": "https://a.com", "description": "snippet a", "position": "1"
|
|
214
|
+
{"title": "First", "url": "https://a.com", "description": "snippet a", "position": "1",
|
|
215
|
+
"displayedUrl": "https://a.com › docs", "domain": "a.com", "source": "A",
|
|
216
|
+
"date": "Oct 2, 2026"},
|
|
215
217
|
{"title": "Second", "url": "https://b.com", "description": "snippet b", "position": "2"},
|
|
216
218
|
]
|
|
217
219
|
}
|
|
@@ -225,6 +227,10 @@ class TestWebSearch:
|
|
|
225
227
|
assert result["documents"][0].meta["title"] == "First"
|
|
226
228
|
assert result["documents"][0].meta["link"] == "https://a.com"
|
|
227
229
|
assert result["documents"][0].meta["query"] == "test query"
|
|
230
|
+
assert result["documents"][0].meta["domain"] == "a.com"
|
|
231
|
+
assert result["documents"][0].meta["source"] == "A"
|
|
232
|
+
assert result["documents"][0].meta["date"] == "Oct 2, 2026"
|
|
233
|
+
assert result["documents"][1].meta["date"] is None
|
|
228
234
|
|
|
229
235
|
_, kwargs = mock_post.call_args
|
|
230
236
|
assert kwargs["params"]["keyword"] == "test query"
|
|
@@ -345,3 +351,143 @@ class TestNoDataExtracted:
|
|
|
345
351
|
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
346
352
|
|
|
347
353
|
assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class TestMarkdown:
|
|
357
|
+
MARKDOWN = "# A Light in the Attic\n\n£51.77\n\n| UPC | a897fe39b1053632 |\n"
|
|
358
|
+
|
|
359
|
+
def _markdown(self, text: str = MARKDOWN, status_code: int = 200, origin=None) -> MagicMock:
|
|
360
|
+
response = _response(text=text, status_code=status_code)
|
|
361
|
+
response.headers = {"Content-Type": "text/markdown; charset=utf-8"}
|
|
362
|
+
if origin is not None:
|
|
363
|
+
response.headers["X-Origin-Status"] = origin
|
|
364
|
+
return response
|
|
365
|
+
|
|
366
|
+
def test_off_by_default(self):
|
|
367
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
368
|
+
assert fetcher.markdown is False
|
|
369
|
+
assert fetcher.main_content_only is True
|
|
370
|
+
|
|
371
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
372
|
+
def test_markdown_query(self, mock_post):
|
|
373
|
+
mock_post.return_value = self._markdown()
|
|
374
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True)
|
|
375
|
+
|
|
376
|
+
fetcher.run(urls=["https://example.com"])
|
|
377
|
+
|
|
378
|
+
_, kwargs = mock_post.call_args
|
|
379
|
+
assert kwargs["params"] == {
|
|
380
|
+
"url": "https://example.com",
|
|
381
|
+
"markdown": True,
|
|
382
|
+
"main_content_only": True,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
386
|
+
def test_whole_page(self, mock_post):
|
|
387
|
+
mock_post.return_value = self._markdown()
|
|
388
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, main_content_only=False)
|
|
389
|
+
|
|
390
|
+
fetcher.run(urls=["https://example.com"])
|
|
391
|
+
|
|
392
|
+
_, kwargs = mock_post.call_args
|
|
393
|
+
assert kwargs["params"]["main_content_only"] is False
|
|
394
|
+
|
|
395
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
396
|
+
def test_main_content_only_not_sent_without_markdown(self, mock_post):
|
|
397
|
+
mock_post.return_value = _response()
|
|
398
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, main_content_only=False)
|
|
399
|
+
|
|
400
|
+
fetcher.run(urls=["https://example.com"])
|
|
401
|
+
|
|
402
|
+
_, kwargs = mock_post.call_args
|
|
403
|
+
assert kwargs["params"] == {"url": "https://example.com"}
|
|
404
|
+
|
|
405
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
406
|
+
def test_markdown_document(self, mock_post):
|
|
407
|
+
mock_post.return_value = self._markdown()
|
|
408
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, proxy_country="gb")
|
|
409
|
+
|
|
410
|
+
doc = fetcher.run(urls=["https://example.com/book"])["documents"][0]
|
|
411
|
+
|
|
412
|
+
assert doc.content == self.MARKDOWN
|
|
413
|
+
assert doc.meta["url"] == "https://example.com/book"
|
|
414
|
+
assert doc.meta["markdown"] is True
|
|
415
|
+
assert doc.meta["main_content_only"] is True
|
|
416
|
+
assert doc.meta["content_type"].startswith("text/markdown")
|
|
417
|
+
_, kwargs = mock_post.call_args
|
|
418
|
+
assert kwargs["params"]["proxy_country"] == "gb"
|
|
419
|
+
|
|
420
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
421
|
+
def test_markdown_that_looks_like_json_stays_text(self, mock_post):
|
|
422
|
+
mock_post.return_value = self._markdown(text='{"a": 1}')
|
|
423
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True)
|
|
424
|
+
|
|
425
|
+
assert fetcher.run(urls=["https://example.com"])["documents"][0].content == '{"a": 1}'
|
|
426
|
+
|
|
427
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
428
|
+
def test_html_document_meta_says_not_markdown(self, mock_post):
|
|
429
|
+
mock_post.return_value = _response()
|
|
430
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
431
|
+
|
|
432
|
+
doc = fetcher.run(urls=["https://example.com"])["documents"][0]
|
|
433
|
+
|
|
434
|
+
assert doc.meta["markdown"] is False
|
|
435
|
+
assert "main_content_only" not in doc.meta
|
|
436
|
+
|
|
437
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
438
|
+
def test_markdown_with_steps(self, mock_post):
|
|
439
|
+
mock_post.return_value = self._markdown()
|
|
440
|
+
steps = [{"action": "click", "selector": "#more"}]
|
|
441
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, steps=steps)
|
|
442
|
+
|
|
443
|
+
fetcher.run(urls=["https://example.com"])
|
|
444
|
+
|
|
445
|
+
_, kwargs = mock_post.call_args
|
|
446
|
+
assert json.loads(kwargs["params"]["steps"]) == steps
|
|
447
|
+
assert kwargs["params"]["markdown"] is True
|
|
448
|
+
|
|
449
|
+
@pytest.mark.parametrize("other", [{"parsed_data": True}, {"list_elements": True}])
|
|
450
|
+
def test_rejects_json_formats(self, other):
|
|
451
|
+
with pytest.raises(ValueError, match="cannot be combined"):
|
|
452
|
+
ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, **other)
|
|
453
|
+
|
|
454
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
455
|
+
def test_missing_page_is_skipped(self, mock_post):
|
|
456
|
+
mock_post.side_effect = [
|
|
457
|
+
self._markdown(text="# 404 Not Found", status_code=404, origin="404"),
|
|
458
|
+
self._markdown(),
|
|
459
|
+
]
|
|
460
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True)
|
|
461
|
+
|
|
462
|
+
result = fetcher.run(urls=["https://example.com/gone", "https://example.com"])
|
|
463
|
+
|
|
464
|
+
assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
|
|
465
|
+
|
|
466
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
467
|
+
def test_missing_page_raises_with_markdown_body(self, mock_post):
|
|
468
|
+
mock_post.return_value = self._markdown(text="# 404 Not Found", status_code=404, origin="404")
|
|
469
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, raise_on_failure=True)
|
|
470
|
+
|
|
471
|
+
with pytest.raises(TargetNotFoundError) as exc:
|
|
472
|
+
fetcher.run(urls=["https://example.com/gone"])
|
|
473
|
+
assert exc.value.origin_status == 404
|
|
474
|
+
assert exc.value.body == "# 404 Not Found"
|
|
475
|
+
|
|
476
|
+
def test_to_dict_and_back(self):
|
|
477
|
+
fetcher = ScrapeUnblockerFetcher(api_key=ENV_KEY, markdown=True, main_content_only=False)
|
|
478
|
+
data = fetcher.to_dict()
|
|
479
|
+
assert data["init_parameters"]["markdown"] is True
|
|
480
|
+
assert data["init_parameters"]["main_content_only"] is False
|
|
481
|
+
|
|
482
|
+
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
483
|
+
assert restored.markdown is True
|
|
484
|
+
assert restored.main_content_only is False
|
|
485
|
+
|
|
486
|
+
def test_from_dict_of_older_version(self):
|
|
487
|
+
data = ScrapeUnblockerFetcher(api_key=ENV_KEY).to_dict()
|
|
488
|
+
del data["init_parameters"]["markdown"]
|
|
489
|
+
del data["init_parameters"]["main_content_only"]
|
|
490
|
+
|
|
491
|
+
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
492
|
+
assert restored.markdown is False
|
|
493
|
+
assert restored.main_content_only is True
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
{scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.3.0 → scrapeunblocker_haystack-0.4.1}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|