scrapeunblocker-haystack 0.3.0__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,21 @@
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
+
3
19
  ## 0.3.0
4
20
 
5
21
  - 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.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 -
@@ -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 -
@@ -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(
@@ -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:
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -345,3 +345,143 @@ class TestNoDataExtracted:
345
345
  fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
346
346
 
347
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.3.0"