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.
@@ -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.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 for the exit IP |
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` in the metadata.
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
- Search the web, fetch the pages behind the results, and answer from them:
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", "converter.sources")
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 for the exit IP |
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` in the metadata.
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
- Search the web, fetch the pages behind the results, and answer from them:
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", "converter.sources")
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, with the title,
21
- link and position in the metadata.
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"