scrapeunblocker-haystack 0.2.2__tar.gz → 0.4.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.
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/CHANGELOG.md +26 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/PKG-INFO +40 -11
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/README.md +39 -10
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/src/scrapeunblocker_haystack/fetcher.py +53 -27
- scrapeunblocker_haystack-0.4.0/src/scrapeunblocker_haystack/version.py +1 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/tests/test_components.py +158 -12
- scrapeunblocker_haystack-0.2.2/src/scrapeunblocker_haystack/version.py +0 -1
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/pyproject.toml +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/src/scrapeunblocker_haystack/__init__.py +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/src/scrapeunblocker_haystack/search.py +0 -0
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- `ScrapeUnblockerFetcher` can return pages as clean Markdown for LLMs and RAG.
|
|
6
|
+
With `markdown=True` each Document's content is the page as Markdown
|
|
7
|
+
(headings, lists, GitHub-flavored tables, absolute links, images with alt
|
|
8
|
+
text, fenced code; no scripts, styles, navigation, footers or forms), ready
|
|
9
|
+
for a prompt or a splitter without an HTML converter. `main_content_only`
|
|
10
|
+
(default `True`) keeps only the main content; `False` converts the whole
|
|
11
|
+
page. `meta["markdown"]` is `True` for these Documents (`False` otherwise)
|
|
12
|
+
and `meta["main_content_only"]` records the setting. Billed like an HTML
|
|
13
|
+
fetch. `markdown` cannot be combined with `parsed_data` or `list_elements`
|
|
14
|
+
(the API refuses it), so that raises `ValueError`. Defaults are unchanged.
|
|
15
|
+
- README: the pipeline example fetches Markdown and passes it straight to the
|
|
16
|
+
prompt. The previous example connected the fetcher's Documents to
|
|
17
|
+
`HTMLToDocument`, which only accepts sources, so the pipeline did not build.
|
|
18
|
+
|
|
19
|
+
## 0.3.0
|
|
20
|
+
|
|
21
|
+
- With `parsed_data=True`, a page that loads but holds no structured data is
|
|
22
|
+
now a Document instead of being skipped. The API answers it as HTTP 200 with
|
|
23
|
+
`data_extracted: false` and the rendered HTML (billed like a plain fetch),
|
|
24
|
+
replacing the 422 `no_data_extracted` handled in 0.2.2. The Document's JSON
|
|
25
|
+
carries that HTML, and `meta["data_extracted"]` is `False` (`True` for a
|
|
26
|
+
successful parse). `NoDataExtractedError` is no longer raised; it stays
|
|
27
|
+
importable so existing `except` clauses keep working.
|
|
28
|
+
|
|
3
29
|
## 0.2.2
|
|
4
30
|
|
|
5
31
|
- With `parsed_data=True`, a page that loads but holds no structured data
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scrapeunblocker-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -73,6 +73,8 @@ print(result["documents"][0].content[:200])
|
|
|
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 -
|
|
@@ -122,10 +154,10 @@ which is logged and skipped by default, or re-raised when
|
|
|
122
154
|
|
|
123
155
|
#### Pages with nothing to parse
|
|
124
156
|
|
|
125
|
-
With `parsed_data=True`, a page that loads but holds no structured data
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
157
|
+
With `parsed_data=True`, a page that loads but holds no structured data still
|
|
158
|
+
yields a Document. Its JSON carries `"data_extracted": false` and the rendered
|
|
159
|
+
page in `html`, and `meta["data_extracted"]` is `False`, so you can route it to
|
|
160
|
+
an HTML converter. The call is billed like a plain HTML fetch.
|
|
129
161
|
|
|
130
162
|
#### Pages that do not exist
|
|
131
163
|
|
|
@@ -174,12 +206,11 @@ for doc in result["documents"]:
|
|
|
174
206
|
|
|
175
207
|
## In a pipeline
|
|
176
208
|
|
|
177
|
-
|
|
209
|
+
Fetch pages as Markdown and answer a question from them:
|
|
178
210
|
|
|
179
211
|
```python
|
|
180
212
|
from haystack import Pipeline
|
|
181
213
|
from haystack.components.builders import ChatPromptBuilder
|
|
182
|
-
from haystack.components.converters import HTMLToDocument
|
|
183
214
|
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
184
215
|
from haystack.dataclasses import ChatMessage
|
|
185
216
|
|
|
@@ -194,13 +225,11 @@ prompt = [
|
|
|
194
225
|
]
|
|
195
226
|
|
|
196
227
|
pipe = Pipeline()
|
|
197
|
-
pipe.add_component("fetcher", ScrapeUnblockerFetcher())
|
|
198
|
-
pipe.add_component("converter", HTMLToDocument())
|
|
228
|
+
pipe.add_component("fetcher", ScrapeUnblockerFetcher(markdown=True))
|
|
199
229
|
pipe.add_component("prompt_builder", ChatPromptBuilder(template=prompt, required_variables="*"))
|
|
200
230
|
pipe.add_component("llm", OpenAIChatGenerator())
|
|
201
231
|
|
|
202
|
-
pipe.connect("fetcher.documents", "
|
|
203
|
-
pipe.connect("converter.documents", "prompt_builder.documents")
|
|
232
|
+
pipe.connect("fetcher.documents", "prompt_builder.documents")
|
|
204
233
|
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
205
234
|
|
|
206
235
|
result = pipe.run(
|
|
@@ -48,6 +48,8 @@ print(result["documents"][0].content[:200])
|
|
|
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 -
|
|
@@ -97,10 +129,10 @@ which is logged and skipped by default, or re-raised when
|
|
|
97
129
|
|
|
98
130
|
#### Pages with nothing to parse
|
|
99
131
|
|
|
100
|
-
With `parsed_data=True`, a page that loads but holds no structured data
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
132
|
+
With `parsed_data=True`, a page that loads but holds no structured data still
|
|
133
|
+
yields a Document. Its JSON carries `"data_extracted": false` and the rendered
|
|
134
|
+
page in `html`, and `meta["data_extracted"]` is `False`, so you can route it to
|
|
135
|
+
an HTML converter. The call is billed like a plain HTML fetch.
|
|
104
136
|
|
|
105
137
|
#### Pages that do not exist
|
|
106
138
|
|
|
@@ -149,12 +181,11 @@ for doc in result["documents"]:
|
|
|
149
181
|
|
|
150
182
|
## In a pipeline
|
|
151
183
|
|
|
152
|
-
|
|
184
|
+
Fetch pages as Markdown and answer a question from them:
|
|
153
185
|
|
|
154
186
|
```python
|
|
155
187
|
from haystack import Pipeline
|
|
156
188
|
from haystack.components.builders import ChatPromptBuilder
|
|
157
|
-
from haystack.components.converters import HTMLToDocument
|
|
158
189
|
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
159
190
|
from haystack.dataclasses import ChatMessage
|
|
160
191
|
|
|
@@ -169,13 +200,11 @@ prompt = [
|
|
|
169
200
|
]
|
|
170
201
|
|
|
171
202
|
pipe = Pipeline()
|
|
172
|
-
pipe.add_component("fetcher", ScrapeUnblockerFetcher())
|
|
173
|
-
pipe.add_component("converter", HTMLToDocument())
|
|
203
|
+
pipe.add_component("fetcher", ScrapeUnblockerFetcher(markdown=True))
|
|
174
204
|
pipe.add_component("prompt_builder", ChatPromptBuilder(template=prompt, required_variables="*"))
|
|
175
205
|
pipe.add_component("llm", OpenAIChatGenerator())
|
|
176
206
|
|
|
177
|
-
pipe.connect("fetcher.documents", "
|
|
178
|
-
pipe.connect("converter.documents", "prompt_builder.documents")
|
|
207
|
+
pipe.connect("fetcher.documents", "prompt_builder.documents")
|
|
179
208
|
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
180
209
|
|
|
181
210
|
result = pipe.run(
|
|
@@ -36,20 +36,16 @@ class TargetNotFoundError(Exception):
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class NoDataExtractedError(Exception):
|
|
39
|
-
"""
|
|
39
|
+
"""No longer raised; kept so existing ``except`` clauses still import.
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
A ``parsed_data`` page with no structured data comes back as a billed
|
|
42
|
+
HTTP 200 carrying ``data_extracted: false`` and the rendered HTML, so the
|
|
43
|
+
fetcher returns it as a Document (``meta["data_extracted"]`` is False).
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
46
|
def __init__(self, url: str) -> None:
|
|
47
47
|
self.url = url
|
|
48
|
-
super().__init__(
|
|
49
|
-
f"No structured data could be extracted from {url}: the page loaded, but "
|
|
50
|
-
"nothing on it matched a structured shape. The call was not billed; fetch "
|
|
51
|
-
"it without parsed_data to get the HTML."
|
|
52
|
-
)
|
|
48
|
+
super().__init__(f"No structured data could be extracted from {url}.")
|
|
53
49
|
|
|
54
50
|
|
|
55
51
|
def _target_gone_status(response: requests.Response) -> Optional[int]:
|
|
@@ -107,6 +103,18 @@ class ScrapeUnblockerFetcher:
|
|
|
107
103
|
print(result["documents"][0].content[:200])
|
|
108
104
|
```
|
|
109
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
|
+
|
|
110
118
|
### Browser steps
|
|
111
119
|
|
|
112
120
|
Drive the page in a real browser before the HTML is captured - click, type,
|
|
@@ -139,6 +147,8 @@ class ScrapeUnblockerFetcher:
|
|
|
139
147
|
base_url: str = DEFAULT_BASE_URL,
|
|
140
148
|
timeout: int = DEFAULT_TIMEOUT,
|
|
141
149
|
raise_on_failure: bool = False,
|
|
150
|
+
markdown: bool = False,
|
|
151
|
+
main_content_only: bool = True,
|
|
142
152
|
) -> None:
|
|
143
153
|
"""
|
|
144
154
|
:param api_key: ScrapeUnblocker API key. Read from `SCRAPEUNBLOCKER_API_KEY` by default.
|
|
@@ -155,9 +165,22 @@ class ScrapeUnblockerFetcher:
|
|
|
155
165
|
:param base_url: API base URL. Override to target a different environment.
|
|
156
166
|
:param timeout: HTTP timeout in seconds.
|
|
157
167
|
:param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
|
|
158
|
-
(including when a browser step fails
|
|
159
|
-
|
|
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`.
|
|
160
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
|
+
)
|
|
161
184
|
self.api_key = api_key
|
|
162
185
|
self.parsed_data = parsed_data
|
|
163
186
|
self.proxy_country = proxy_country
|
|
@@ -167,6 +190,8 @@ class ScrapeUnblockerFetcher:
|
|
|
167
190
|
self.base_url = base_url.rstrip("/")
|
|
168
191
|
self.timeout = timeout
|
|
169
192
|
self.raise_on_failure = raise_on_failure
|
|
193
|
+
self.markdown = markdown
|
|
194
|
+
self.main_content_only = main_content_only
|
|
170
195
|
|
|
171
196
|
def to_dict(self) -> Dict[str, Any]:
|
|
172
197
|
"""Serialize this component to a dictionary."""
|
|
@@ -181,6 +206,8 @@ class ScrapeUnblockerFetcher:
|
|
|
181
206
|
base_url=self.base_url,
|
|
182
207
|
timeout=self.timeout,
|
|
183
208
|
raise_on_failure=self.raise_on_failure,
|
|
209
|
+
markdown=self.markdown,
|
|
210
|
+
main_content_only=self.main_content_only,
|
|
184
211
|
)
|
|
185
212
|
|
|
186
213
|
@classmethod
|
|
@@ -201,6 +228,9 @@ class ScrapeUnblockerFetcher:
|
|
|
201
228
|
params["steps"] = json.dumps(self.steps)
|
|
202
229
|
if self.list_elements:
|
|
203
230
|
params["list_elements"] = True
|
|
231
|
+
if self.markdown:
|
|
232
|
+
params["markdown"] = True
|
|
233
|
+
params["main_content_only"] = self.main_content_only
|
|
204
234
|
|
|
205
235
|
response = requests.post(
|
|
206
236
|
f"{self.base_url}/getPageSource",
|
|
@@ -213,11 +243,8 @@ class ScrapeUnblockerFetcher:
|
|
|
213
243
|
if gone is not None:
|
|
214
244
|
raise TargetNotFoundError(url, gone, response.text)
|
|
215
245
|
|
|
216
|
-
# A failed browser step returns 422 with a structured JSON error
|
|
217
|
-
# a parsed_data call that found nothing to extract.
|
|
246
|
+
# A failed browser step returns 422 with a structured JSON error.
|
|
218
247
|
if response.status_code == 422:
|
|
219
|
-
if self._is_no_data_extracted(response):
|
|
220
|
-
raise NoDataExtractedError(url)
|
|
221
248
|
error = self._parse_step_error(response)
|
|
222
249
|
if error is not None:
|
|
223
250
|
raise error
|
|
@@ -225,14 +252,6 @@ class ScrapeUnblockerFetcher:
|
|
|
225
252
|
response.raise_for_status()
|
|
226
253
|
return response
|
|
227
254
|
|
|
228
|
-
@staticmethod
|
|
229
|
-
def _is_no_data_extracted(response: requests.Response) -> bool:
|
|
230
|
-
try:
|
|
231
|
-
payload = response.json()
|
|
232
|
-
except ValueError:
|
|
233
|
-
return False
|
|
234
|
-
return isinstance(payload, dict) and payload.get("error") == "no_data_extracted"
|
|
235
|
-
|
|
236
255
|
@staticmethod
|
|
237
256
|
def _parse_step_error(response: requests.Response) -> Optional[StepExecutionError]:
|
|
238
257
|
try:
|
|
@@ -255,8 +274,10 @@ class ScrapeUnblockerFetcher:
|
|
|
255
274
|
|
|
256
275
|
A URL whose page does not exist (the site answers 404/410) yields no
|
|
257
276
|
Document; with `raise_on_failure` it raises `TargetNotFoundError`. With
|
|
258
|
-
`parsed_data`, a page holding no structured data yields
|
|
259
|
-
|
|
277
|
+
`parsed_data`, a page holding no structured data still yields a
|
|
278
|
+
Document: its JSON carries `data_extracted: false` and the rendered
|
|
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.
|
|
260
281
|
|
|
261
282
|
:param urls: URLs to fetch.
|
|
262
283
|
:returns: A dictionary with a `documents` key holding the fetched pages.
|
|
@@ -268,10 +289,10 @@ class ScrapeUnblockerFetcher:
|
|
|
268
289
|
for url in urls:
|
|
269
290
|
try:
|
|
270
291
|
response = self._fetch(url)
|
|
271
|
-
except
|
|
292
|
+
except TargetNotFoundError as exc:
|
|
272
293
|
if self.raise_on_failure:
|
|
273
294
|
raise
|
|
274
|
-
# A page that does not exist
|
|
295
|
+
# A page that does not exist has no content to index.
|
|
275
296
|
logger.warning("ScrapeUnblocker skipped {url}: {error}", url=url, error=str(exc))
|
|
276
297
|
continue
|
|
277
298
|
except Exception as exc:
|
|
@@ -288,7 +309,10 @@ class ScrapeUnblockerFetcher:
|
|
|
288
309
|
"content_type": response.headers.get("Content-Type", ""),
|
|
289
310
|
"parsed_data": self.parsed_data,
|
|
290
311
|
"list_elements": self.list_elements,
|
|
312
|
+
"markdown": self.markdown,
|
|
291
313
|
}
|
|
314
|
+
if self.markdown:
|
|
315
|
+
meta["main_content_only"] = self.main_content_only
|
|
292
316
|
|
|
293
317
|
if json_mode:
|
|
294
318
|
try:
|
|
@@ -296,6 +320,8 @@ class ScrapeUnblockerFetcher:
|
|
|
296
320
|
content = json.dumps(payload, ensure_ascii=False)
|
|
297
321
|
if self.list_elements and isinstance(payload, dict):
|
|
298
322
|
meta["count"] = payload.get("count")
|
|
323
|
+
elif self.parsed_data and isinstance(payload, dict):
|
|
324
|
+
meta["data_extracted"] = payload.get("data_extracted") is not False
|
|
299
325
|
except ValueError:
|
|
300
326
|
content = response.text
|
|
301
327
|
else:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -302,31 +302,37 @@ class TestTargetNotFound:
|
|
|
302
302
|
|
|
303
303
|
class TestNoDataExtracted:
|
|
304
304
|
BODY = {
|
|
305
|
-
"
|
|
305
|
+
"data": {"page_type": "unknown", "data": {}},
|
|
306
|
+
"data_extracted": False,
|
|
306
307
|
"detail": "The page was rendered, but no structured data could be extracted from it.",
|
|
308
|
+
"html": "<html><title>Example Domain</title></html>",
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
310
|
-
def
|
|
312
|
+
def test_empty_parse_is_a_document_with_the_html(self, mock_post):
|
|
311
313
|
mock_post.side_effect = [
|
|
312
|
-
_response(
|
|
313
|
-
_response(json_data={"data": {"page_type": "product"}}),
|
|
314
|
+
_response(json_data=self.BODY),
|
|
315
|
+
_response(json_data={"data": {"page_type": "product", "data": {"title": "x"}}}),
|
|
314
316
|
]
|
|
315
317
|
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
316
318
|
|
|
317
|
-
|
|
319
|
+
empty, parsed = fetcher.run(urls=["https://example.com/empty", "https://example.com"])[
|
|
320
|
+
"documents"
|
|
321
|
+
]
|
|
318
322
|
|
|
319
|
-
assert
|
|
323
|
+
assert empty.meta["data_extracted"] is False
|
|
324
|
+
assert json.loads(empty.content)["html"] == self.BODY["html"]
|
|
325
|
+
assert parsed.meta["data_extracted"] is True
|
|
320
326
|
|
|
321
327
|
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
322
|
-
def
|
|
323
|
-
mock_post.return_value = _response(
|
|
328
|
+
def test_empty_parse_does_not_raise(self, mock_post):
|
|
329
|
+
mock_post.return_value = _response(json_data=self.BODY)
|
|
324
330
|
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True, raise_on_failure=True)
|
|
325
331
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
assert
|
|
332
|
+
assert len(fetcher.run(urls=["https://example.com/empty"])["documents"]) == 1
|
|
333
|
+
|
|
334
|
+
def test_error_class_still_importable(self):
|
|
335
|
+
assert issubclass(NoDataExtractedError, Exception)
|
|
330
336
|
|
|
331
337
|
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
332
338
|
def test_dead_url_with_parsed_body_is_skipped(self, mock_post):
|
|
@@ -339,3 +345,143 @@ class TestNoDataExtracted:
|
|
|
339
345
|
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
340
346
|
|
|
341
347
|
assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class TestMarkdown:
|
|
351
|
+
MARKDOWN = "# A Light in the Attic\n\n£51.77\n\n| UPC | a897fe39b1053632 |\n"
|
|
352
|
+
|
|
353
|
+
def _markdown(self, text: str = MARKDOWN, status_code: int = 200, origin=None) -> MagicMock:
|
|
354
|
+
response = _response(text=text, status_code=status_code)
|
|
355
|
+
response.headers = {"Content-Type": "text/markdown; charset=utf-8"}
|
|
356
|
+
if origin is not None:
|
|
357
|
+
response.headers["X-Origin-Status"] = origin
|
|
358
|
+
return response
|
|
359
|
+
|
|
360
|
+
def test_off_by_default(self):
|
|
361
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
362
|
+
assert fetcher.markdown is False
|
|
363
|
+
assert fetcher.main_content_only is True
|
|
364
|
+
|
|
365
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
366
|
+
def test_markdown_query(self, mock_post):
|
|
367
|
+
mock_post.return_value = self._markdown()
|
|
368
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True)
|
|
369
|
+
|
|
370
|
+
fetcher.run(urls=["https://example.com"])
|
|
371
|
+
|
|
372
|
+
_, kwargs = mock_post.call_args
|
|
373
|
+
assert kwargs["params"] == {
|
|
374
|
+
"url": "https://example.com",
|
|
375
|
+
"markdown": True,
|
|
376
|
+
"main_content_only": True,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
380
|
+
def test_whole_page(self, mock_post):
|
|
381
|
+
mock_post.return_value = self._markdown()
|
|
382
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, main_content_only=False)
|
|
383
|
+
|
|
384
|
+
fetcher.run(urls=["https://example.com"])
|
|
385
|
+
|
|
386
|
+
_, kwargs = mock_post.call_args
|
|
387
|
+
assert kwargs["params"]["main_content_only"] is False
|
|
388
|
+
|
|
389
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
390
|
+
def test_main_content_only_not_sent_without_markdown(self, mock_post):
|
|
391
|
+
mock_post.return_value = _response()
|
|
392
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, main_content_only=False)
|
|
393
|
+
|
|
394
|
+
fetcher.run(urls=["https://example.com"])
|
|
395
|
+
|
|
396
|
+
_, kwargs = mock_post.call_args
|
|
397
|
+
assert kwargs["params"] == {"url": "https://example.com"}
|
|
398
|
+
|
|
399
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
400
|
+
def test_markdown_document(self, mock_post):
|
|
401
|
+
mock_post.return_value = self._markdown()
|
|
402
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, proxy_country="gb")
|
|
403
|
+
|
|
404
|
+
doc = fetcher.run(urls=["https://example.com/book"])["documents"][0]
|
|
405
|
+
|
|
406
|
+
assert doc.content == self.MARKDOWN
|
|
407
|
+
assert doc.meta["url"] == "https://example.com/book"
|
|
408
|
+
assert doc.meta["markdown"] is True
|
|
409
|
+
assert doc.meta["main_content_only"] is True
|
|
410
|
+
assert doc.meta["content_type"].startswith("text/markdown")
|
|
411
|
+
_, kwargs = mock_post.call_args
|
|
412
|
+
assert kwargs["params"]["proxy_country"] == "gb"
|
|
413
|
+
|
|
414
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
415
|
+
def test_markdown_that_looks_like_json_stays_text(self, mock_post):
|
|
416
|
+
mock_post.return_value = self._markdown(text='{"a": 1}')
|
|
417
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True)
|
|
418
|
+
|
|
419
|
+
assert fetcher.run(urls=["https://example.com"])["documents"][0].content == '{"a": 1}'
|
|
420
|
+
|
|
421
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
422
|
+
def test_html_document_meta_says_not_markdown(self, mock_post):
|
|
423
|
+
mock_post.return_value = _response()
|
|
424
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
425
|
+
|
|
426
|
+
doc = fetcher.run(urls=["https://example.com"])["documents"][0]
|
|
427
|
+
|
|
428
|
+
assert doc.meta["markdown"] is False
|
|
429
|
+
assert "main_content_only" not in doc.meta
|
|
430
|
+
|
|
431
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
432
|
+
def test_markdown_with_steps(self, mock_post):
|
|
433
|
+
mock_post.return_value = self._markdown()
|
|
434
|
+
steps = [{"action": "click", "selector": "#more"}]
|
|
435
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, steps=steps)
|
|
436
|
+
|
|
437
|
+
fetcher.run(urls=["https://example.com"])
|
|
438
|
+
|
|
439
|
+
_, kwargs = mock_post.call_args
|
|
440
|
+
assert json.loads(kwargs["params"]["steps"]) == steps
|
|
441
|
+
assert kwargs["params"]["markdown"] is True
|
|
442
|
+
|
|
443
|
+
@pytest.mark.parametrize("other", [{"parsed_data": True}, {"list_elements": True}])
|
|
444
|
+
def test_rejects_json_formats(self, other):
|
|
445
|
+
with pytest.raises(ValueError, match="cannot be combined"):
|
|
446
|
+
ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, **other)
|
|
447
|
+
|
|
448
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
449
|
+
def test_missing_page_is_skipped(self, mock_post):
|
|
450
|
+
mock_post.side_effect = [
|
|
451
|
+
self._markdown(text="# 404 Not Found", status_code=404, origin="404"),
|
|
452
|
+
self._markdown(),
|
|
453
|
+
]
|
|
454
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True)
|
|
455
|
+
|
|
456
|
+
result = fetcher.run(urls=["https://example.com/gone", "https://example.com"])
|
|
457
|
+
|
|
458
|
+
assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
|
|
459
|
+
|
|
460
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
461
|
+
def test_missing_page_raises_with_markdown_body(self, mock_post):
|
|
462
|
+
mock_post.return_value = self._markdown(text="# 404 Not Found", status_code=404, origin="404")
|
|
463
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, markdown=True, raise_on_failure=True)
|
|
464
|
+
|
|
465
|
+
with pytest.raises(TargetNotFoundError) as exc:
|
|
466
|
+
fetcher.run(urls=["https://example.com/gone"])
|
|
467
|
+
assert exc.value.origin_status == 404
|
|
468
|
+
assert exc.value.body == "# 404 Not Found"
|
|
469
|
+
|
|
470
|
+
def test_to_dict_and_back(self):
|
|
471
|
+
fetcher = ScrapeUnblockerFetcher(api_key=ENV_KEY, markdown=True, main_content_only=False)
|
|
472
|
+
data = fetcher.to_dict()
|
|
473
|
+
assert data["init_parameters"]["markdown"] is True
|
|
474
|
+
assert data["init_parameters"]["main_content_only"] is False
|
|
475
|
+
|
|
476
|
+
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
477
|
+
assert restored.markdown is True
|
|
478
|
+
assert restored.main_content_only is False
|
|
479
|
+
|
|
480
|
+
def test_from_dict_of_older_version(self):
|
|
481
|
+
data = ScrapeUnblockerFetcher(api_key=ENV_KEY).to_dict()
|
|
482
|
+
del data["init_parameters"]["markdown"]
|
|
483
|
+
del data["init_parameters"]["main_content_only"]
|
|
484
|
+
|
|
485
|
+
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
486
|
+
assert restored.markdown is False
|
|
487
|
+
assert restored.main_content_only is True
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.2"
|
{scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.4.0}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|