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.
@@ -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.2.2
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 yields
126
- no Document: it is skipped with a warning, or `NoDataExtractedError` is raised
127
- when `raise_on_failure=True`. The call is not billed; fetch the URL without
128
- `parsed_data` to get the HTML.
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
- Search the web, fetch the pages behind the results, and answer from them:
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", "converter.sources")
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 yields
101
- no Document: it is skipped with a warning, or `NoDataExtractedError` is raised
102
- when `raise_on_failure=True`. The call is not billed; fetch the URL without
103
- `parsed_data` to get the HTML.
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
- Search the web, fetch the pages behind the results, and answer from them:
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", "converter.sources")
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
- """Raised when ``parsed_data`` found no structured data on the page.
39
+ """No longer raised; kept so existing ``except`` clauses still import.
40
40
 
41
- The API answers this as HTTP 422 ``no_data_extracted``: the page loaded but
42
- held nothing to extract. It is not billed and carries no HTML; fetch the
43
- URL without ``parsed_data`` to get the page itself.
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, the page does not exist, or
159
- `parsed_data` finds nothing to extract).
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; so does
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 no Document
259
- either, or raises `NoDataExtractedError`.
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 (TargetNotFoundError, NoDataExtractedError) as exc:
292
+ except TargetNotFoundError as exc:
272
293
  if self.raise_on_failure:
273
294
  raise
274
- # A page that does not exist, or has nothing to parse, has no content to index.
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
- "error": "no_data_extracted",
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 test_empty_parse_is_skipped(self, mock_post):
312
+ def test_empty_parse_is_a_document_with_the_html(self, mock_post):
311
313
  mock_post.side_effect = [
312
- _response(status_code=422, json_data=self.BODY),
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
- result = fetcher.run(urls=["https://example.com/empty", "https://example.com"])
319
+ empty, parsed = fetcher.run(urls=["https://example.com/empty", "https://example.com"])[
320
+ "documents"
321
+ ]
318
322
 
319
- assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
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 test_empty_parse_raises_when_asked(self, mock_post):
323
- mock_post.return_value = _response(status_code=422, json_data=self.BODY)
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
- with pytest.raises(NoDataExtractedError) as exc:
327
- fetcher.run(urls=["https://example.com/empty"])
328
- assert exc.value.url == "https://example.com/empty"
329
- assert "not billed" in str(exc.value)
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"