scrapeMM 0.7.3__tar.gz → 0.7.4__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.
Files changed (51) hide show
  1. {scrapemm-0.7.3/scrapeMM.egg-info → scrapemm-0.7.4}/PKG-INFO +3 -3
  2. {scrapemm-0.7.3 → scrapemm-0.7.4}/pyproject.toml +3 -3
  3. {scrapemm-0.7.3 → scrapemm-0.7.4/scrapeMM.egg-info}/PKG-INFO +3 -3
  4. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/requires.txt +2 -2
  5. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/common/exceptions.py +4 -0
  6. scrapemm-0.7.4/scrapemm/download/requests.py +172 -0
  7. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/__init__.py +15 -5
  8. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/decodo/decodo.py +1 -1
  9. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/tiktok.py +1 -1
  10. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/retrieval.py +87 -72
  11. {scrapemm-0.7.3 → scrapemm-0.7.4}/scripts/example.py +2 -2
  12. {scrapemm-0.7.3 → scrapemm-0.7.4}/testing/test_retrieval.py +9 -6
  13. {scrapemm-0.7.3 → scrapemm-0.7.4}/testing/test_utils.py +0 -15
  14. scrapemm-0.7.3/scrapemm/download/requests.py +0 -74
  15. {scrapemm-0.7.3 → scrapemm-0.7.4}/.gitignore +0 -0
  16. {scrapemm-0.7.3 → scrapemm-0.7.4}/LICENSE +0 -0
  17. {scrapemm-0.7.3 → scrapemm-0.7.4}/README.md +0 -0
  18. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/SOURCES.txt +0 -0
  19. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
  20. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/top_level.txt +0 -0
  21. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/__init__.py +0 -0
  22. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/common/__init__.py +0 -0
  23. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/common/scraping_response.py +0 -0
  24. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/__init__.py +0 -0
  25. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/common.py +0 -0
  26. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/images.py +0 -0
  27. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/media.py +0 -0
  28. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/util.py +0 -0
  29. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/videos.py +0 -0
  30. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/archive_org.py +0 -0
  31. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/archive_today.py +0 -0
  32. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/awesomescreenshot.py +0 -0
  33. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/base.py +0 -0
  34. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/bluesky.py +0 -0
  35. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/decodo/__init__.py +0 -0
  36. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/decodo/html_postprocessor.py +0 -0
  37. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/fb.py +0 -0
  38. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/firecrawl/__init__.py +0 -0
  39. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
  40. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
  41. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/ghostarchive.py +0 -0
  42. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/headed_browser.py +0 -0
  43. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/instagram.py +0 -0
  44. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/perma_cc.py +0 -0
  45. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/telegram.py +0 -0
  46. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/x.py +0 -0
  47. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/youtube.py +0 -0
  48. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/ytdlp.py +0 -0
  49. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/secrets.py +0 -0
  50. {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/util.py +0 -0
  51. {scrapemm-0.7.3 → scrapemm-0.7.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.7.3
3
+ Version: 0.7.4
4
4
  Summary: Multimodal scraper for social media, internet archives, and the open web.
5
5
  Author-email: Mark Rothermel <mark.rothermel@tu-darmstadt.de>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: aiohttp~=3.14.1
13
13
  Requires-Dist: async_lru
14
- Requires-Dist: atproto>=0.0.69
14
+ Requires-Dist: atproto~=0.0.69
15
15
  Requires-Dist: beautifulsoup4
16
16
  Requires-Dist: backports.zstd
17
17
  Requires-Dist: brotli
@@ -24,7 +24,7 @@ Requires-Dist: pip-system-certs
24
24
  Requires-Dist: platformdirs~=4.10.0
25
25
  Requires-Dist: playwright
26
26
  Requires-Dist: playwright-stealth
27
- Requires-Dist: seleniumbase~=4.51.1
27
+ Requires-Dist: seleniumbase~=4.44.20
28
28
  Requires-Dist: prompt_toolkit
29
29
  Requires-Dist: PyYAML~=6.0.1
30
30
  Requires-Dist: requests~=2.34.2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapeMM"
3
- version = "0.7.3"
3
+ version = "0.7.4"
4
4
  description = "Multimodal scraper for social media, internet archives, and the open web."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -9,7 +9,7 @@ authors = [{name = "Mark Rothermel", email = "mark.rothermel@tu-darmstadt.de"}]
9
9
  dependencies = [
10
10
  "aiohttp~=3.14.1",
11
11
  "async_lru",
12
- "atproto>=0.0.69",
12
+ "atproto~=0.0.69",
13
13
  "beautifulsoup4",
14
14
  "backports.zstd", # For better content parsing with Firecrawl
15
15
  "brotli", # Required to read brotli-encoded media
@@ -22,7 +22,7 @@ dependencies = [
22
22
  "platformdirs~=4.10.0",
23
23
  "playwright",
24
24
  "playwright-stealth",
25
- "seleniumbase~=4.51.1",
25
+ "seleniumbase~=4.44.20", # Package conflict with atproto on the websockets package. Upgrade only when atproto supports websockets>=16.1
26
26
  "prompt_toolkit", # For handling user terminal input
27
27
  "PyYAML~=6.0.1",
28
28
  "requests~=2.34.2",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.7.3
3
+ Version: 0.7.4
4
4
  Summary: Multimodal scraper for social media, internet archives, and the open web.
5
5
  Author-email: Mark Rothermel <mark.rothermel@tu-darmstadt.de>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: aiohttp~=3.14.1
13
13
  Requires-Dist: async_lru
14
- Requires-Dist: atproto>=0.0.69
14
+ Requires-Dist: atproto~=0.0.69
15
15
  Requires-Dist: beautifulsoup4
16
16
  Requires-Dist: backports.zstd
17
17
  Requires-Dist: brotli
@@ -24,7 +24,7 @@ Requires-Dist: pip-system-certs
24
24
  Requires-Dist: platformdirs~=4.10.0
25
25
  Requires-Dist: playwright
26
26
  Requires-Dist: playwright-stealth
27
- Requires-Dist: seleniumbase~=4.51.1
27
+ Requires-Dist: seleniumbase~=4.44.20
28
28
  Requires-Dist: prompt_toolkit
29
29
  Requires-Dist: PyYAML~=6.0.1
30
30
  Requires-Dist: requests~=2.34.2
@@ -1,6 +1,6 @@
1
1
  aiohttp~=3.14.1
2
2
  async_lru
3
- atproto>=0.0.69
3
+ atproto~=0.0.69
4
4
  beautifulsoup4
5
5
  backports.zstd
6
6
  brotli
@@ -13,7 +13,7 @@ pip-system-certs
13
13
  platformdirs~=4.10.0
14
14
  playwright
15
15
  playwright-stealth
16
- seleniumbase~=4.51.1
16
+ seleniumbase~=4.44.20
17
17
  prompt_toolkit
18
18
  PyYAML~=6.0.1
19
19
  requests~=2.34.2
@@ -23,3 +23,7 @@ class UnsupportedDomainError(Exception):
23
23
 
24
24
  class TargetUnavailableError(Exception):
25
25
  """The target website is not available for scraping."""
26
+
27
+
28
+ class DiskFull(Exception):
29
+ """No space left on disk. The application should be aborted immediately before continuing."""
@@ -0,0 +1,172 @@
1
+ import logging
2
+ from typing import Optional, Union, TYPE_CHECKING
3
+
4
+ import aiohttp
5
+
6
+ if TYPE_CHECKING:
7
+ from playwright.async_api import APIRequestContext
8
+
9
+ from scrapemm.download.common import ssl_context, RELAXED_SSL_DOMAINS
10
+ from scrapemm.download.util import stream
11
+
12
+ logger = logging.getLogger("scrapeMM")
13
+
14
+ # Cloudflare (and similar) bot gates reject aiohttp's TLS fingerprint with HTTP 403
15
+ # while accepting real browser JA3/JA4 profiles. Tried in order until one succeeds.
16
+ _CURL_CFFI_IMPERSONATIONS = ("chrome124", "safari17_2_ios", "chrome110")
17
+
18
+
19
+ async def _request_via_curl_cffi(
20
+ url: str,
21
+ headers: Optional[dict] = None,
22
+ ) -> Optional[tuple[int, dict, bytes]]:
23
+ """GET ``url`` with browser TLS impersonation. Returns (status, headers, body) or None."""
24
+ try:
25
+ from curl_cffi.requests import AsyncSession
26
+ except ImportError:
27
+ logger.debug("curl_cffi not available; cannot bypass bot-gated 403 for %s", url)
28
+ return None
29
+
30
+ async with AsyncSession() as session:
31
+ for impersonate in _CURL_CFFI_IMPERSONATIONS:
32
+ try:
33
+ response = await session.get(
34
+ url,
35
+ impersonate=impersonate,
36
+ headers=headers or {},
37
+ allow_redirects=True,
38
+ )
39
+ status = response.status_code
40
+ hdrs = dict(response.headers)
41
+ body = response.content
42
+ if status == 200:
43
+ logger.debug(
44
+ "Retrieved %s via curl_cffi impersonate=%s (%s bytes)",
45
+ url, impersonate, len(body),
46
+ )
47
+ return status, hdrs, body
48
+ if status != 403:
49
+ # Real client/server error — further fingerprints won't help.
50
+ return status, hdrs, body
51
+ logger.debug(
52
+ "curl_cffi impersonate=%s still got 403 for %s; trying next profile",
53
+ impersonate, url,
54
+ )
55
+ except Exception:
56
+ logger.debug(
57
+ "curl_cffi impersonate=%s failed for %s",
58
+ impersonate, url, exc_info=True,
59
+ )
60
+ return None
61
+
62
+
63
+ def _merge_request_headers(
64
+ session: Union[aiohttp.ClientSession, "APIRequestContext"],
65
+ kwargs: dict,
66
+ ) -> dict:
67
+ """Combine session-default headers with per-request overrides for curl_cffi."""
68
+ merged: dict = {}
69
+ if isinstance(session, aiohttp.ClientSession):
70
+ merged.update({str(k): str(v) for k, v in session.headers.items()})
71
+ extra = kwargs.get("headers") or {}
72
+ merged.update({str(k): str(v) for k, v in extra.items()})
73
+ return merged
74
+
75
+
76
+ async def fetch_headers(url, session: Union[aiohttp.ClientSession, "APIRequestContext"], **kwargs) -> dict:
77
+ """Fetch only HTTP headers for a URL."""
78
+ from scrapemm.util import get_domain
79
+ ssl = None if get_domain(str(url)) in RELAXED_SSL_DOMAINS else ssl_context
80
+
81
+ async def _headers_via_curl_cffi() -> dict:
82
+ result = await _request_via_curl_cffi(str(url), _merge_request_headers(session, kwargs))
83
+ if result and result[0] == 200:
84
+ return result[1]
85
+ raise RuntimeError(f"Forbidden (403) fetching headers for {url}")
86
+
87
+ if hasattr(session, "head"): # aiohttp.ClientSession or Playwright APIRequestContext
88
+ try:
89
+ # Playwright APIRequestContext.head exists and returns APIResponse
90
+ if not isinstance(session, aiohttp.ClientSession):
91
+ # Playwright
92
+ response = await session.head(str(url), headers=kwargs.get("headers"), timeout=kwargs.get("timeout"))
93
+ if getattr(response, "status", None) == 403:
94
+ return await _headers_via_curl_cffi()
95
+ return response.headers
96
+ else:
97
+ # aiohttp
98
+ async with session.head(url, ssl=ssl, **kwargs) as response:
99
+ response.raise_for_status()
100
+ return dict(response.headers)
101
+ except Exception:
102
+ logger.debug(f"HEAD failed for {url}, falling back to GET", exc_info=True)
103
+ # Fallback to GET
104
+ try:
105
+ if not isinstance(session, aiohttp.ClientSession):
106
+ response = await session.get(str(url), headers=kwargs.get("headers"), timeout=kwargs.get("timeout"))
107
+ if getattr(response, "status", None) == 403:
108
+ return await _headers_via_curl_cffi()
109
+ return response.headers
110
+ else:
111
+ async with session.get(url, ssl=ssl, **kwargs) as response:
112
+ response.raise_for_status()
113
+ return dict(response.headers)
114
+ except aiohttp.ClientResponseError as e:
115
+ if e.status == 403:
116
+ return await _headers_via_curl_cffi()
117
+ raise
118
+ raise ValueError(f"Unsupported session type: {type(session)}")
119
+
120
+
121
+ async def request_static(url: str,
122
+ session: Union[aiohttp.ClientSession, "APIRequestContext"],
123
+ get_text: bool = True,
124
+ **kwargs) -> Optional[str | bytes]:
125
+ """Downloads the static page from the given URL using aiohttp or Playwright.
126
+
127
+ On HTTP 403 (common Cloudflare bot gate), retries with curl_cffi browser
128
+ TLS impersonation so hosts that accept browsers but reject aiohttp still work.
129
+ """
130
+ if not url:
131
+ return None
132
+
133
+ url = str(url)
134
+ from scrapemm.util import get_domain
135
+ ssl = None if get_domain(url) in RELAXED_SSL_DOMAINS else ssl_context
136
+
137
+ async def _from_curl_cffi() -> Optional[str | bytes]:
138
+ result = await _request_via_curl_cffi(url, _merge_request_headers(session, kwargs))
139
+ if not result or result[0] != 200:
140
+ return None
141
+ _, _, content = result
142
+ if get_text:
143
+ return content.decode("utf-8", errors="replace")
144
+ return content
145
+
146
+ try:
147
+ if not isinstance(session, aiohttp.ClientSession):
148
+ # Playwright APIRequestContext
149
+ response = await session.get(url, **kwargs)
150
+ if response.ok:
151
+ return await response.text() if get_text else await response.body()
152
+ if response.status == 403:
153
+ return await _from_curl_cffi()
154
+ return None
155
+ else:
156
+ # aiohttp
157
+ async with session.get(url, timeout=10, allow_redirects=True,
158
+ raise_for_status=True, ssl=ssl, **kwargs) as response:
159
+ if get_text:
160
+ return await response.text()
161
+ else:
162
+ return await stream(response)
163
+ except aiohttp.ClientResponseError as e:
164
+ if e.status == 403:
165
+ content = await _from_curl_cffi()
166
+ if content is not None:
167
+ return content
168
+ logger.debug(f"Error requesting {url}", exc_info=True)
169
+ return None
170
+ except Exception:
171
+ logger.debug(f"Error requesting {url}", exc_info=True)
172
+ return None
@@ -41,10 +41,20 @@ DOMAIN_TO_INTEGRATION = {
41
41
  for domain in integration.domains
42
42
  }
43
43
 
44
+ NAME_TO_INTEGRATION = {integration.name: integration for integration in RETRIEVAL_INTEGRATIONS}
44
45
 
45
- async def retrieve_via_integration(url: str, **kwargs) -> Optional[MultimodalSequence]:
46
+ INTEGRATION_NAMES = [integration.name for integration in RETRIEVAL_INTEGRATIONS]
47
+
48
+
49
+ async def retrieve_via_integration(url: str, integration_name: str, **kwargs) -> Optional[MultimodalSequence]:
50
+ integration = NAME_TO_INTEGRATION[integration_name]
51
+ if integration.connected or integration.connected is None:
52
+ return await integration.get(url, **kwargs)
53
+
54
+
55
+ def get_integrations_for_url(url: str) -> list[str]:
56
+ """Returns the list of integration names that support the given domain."""
46
57
  domain = get_domain(url)
47
- if domain in DOMAIN_TO_INTEGRATION:
48
- integration = DOMAIN_TO_INTEGRATION[domain]
49
- if integration.connected or integration.connected is None:
50
- return await integration.get(url, **kwargs)
58
+ return [integration.name
59
+ for integration in RETRIEVAL_INTEGRATIONS
60
+ if domain in integration.domains]
@@ -224,7 +224,7 @@ class Decodo:
224
224
  except (RateLimitError, asyncio.TimeoutError):
225
225
  raise
226
226
  except Exception as e:
227
- logger.error(f"Unexpected error while scraping with Decodo: {e}")
227
+ logger.debug(f"Error while scraping with Decodo: {e}")
228
228
  raise
229
229
 
230
230
  await backoff(attempt) # Wait before retrying
@@ -70,7 +70,7 @@ class TikTok(RetrievalIntegration):
70
70
  return await self._get_user_profile(url, session)
71
71
  except Exception as e:
72
72
  if "Your IP address is blocked from accessing this post" in str(e):
73
- raise IPBannedError(f"TikTok prevents your IP address from accessing this post: {str(e)}")
73
+ raise IPBannedError(f"TikTok prevents your IP address from accessing the post {url}")
74
74
  elif "This post may not be comfortable for some audiences" in str(e):
75
75
  raise ContentBlockedError("Video is blocked by TikTok for being 'uncomfortable for some audiences'.")
76
76
  else:
@@ -9,19 +9,21 @@ from ezmm import MultimodalSequence
9
9
  from playwright.async_api import TimeoutError as PlaywrightTimeoutError
10
10
 
11
11
  from scrapemm.common import ScrapingResponse
12
- from scrapemm.common.exceptions import RetrievalFailed, IPBannedError, UnsupportedDomainError
13
- from scrapemm.download import download_medium, download_image, download_video
12
+ from scrapemm.common.exceptions import RetrievalFailed, IPBannedError, UnsupportedDomainError, DiskFull
13
+ from scrapemm.download import download_image, download_video
14
14
  from scrapemm.download.common import HEADERS
15
15
  from scrapemm.download.util import looks_like_image_file_url, looks_like_video_file_url, looks_like_hls_url
16
- from scrapemm.integrations import retrieve_via_integration, fire, decodo
16
+ from scrapemm.integrations import retrieve_via_integration, fire, decodo, get_integrations_for_url, INTEGRATION_NAMES
17
17
  from scrapemm.util import run_with_semaphore, get_domain, normalize_video
18
18
 
19
19
  logger = logging.getLogger("scrapeMM")
20
20
  METHODS = ["integrations", "firecrawl", "decodo"]
21
+ ALL_METHODS = METHODS + INTEGRATION_NAMES
21
22
 
22
23
  UNSUPPORTED_DOMAINS = []
23
24
 
24
25
  BEST_METHODS = {
26
+ # Social media platforms:
25
27
  "instagram.com": ["integrations", "decodo"],
26
28
  "facebook.com": ["integrations"],
27
29
  "fb.watch": ["integrations"],
@@ -34,24 +36,24 @@ BEST_METHODS = {
34
36
  "bsky.app": ["integrations"],
35
37
  "truthsocial.com": ["firecrawl"],
36
38
  "reddit.com": ["integrations"],
37
- "awesomescreenshot.com": ["integrations"],
38
39
  "youtube.com": ["integrations"],
39
40
  "youtu.be": ["integrations"],
41
+ # Archiving services:
42
+ "archive.today": ["integrations"],
43
+ "archive.is": ["integrations"],
44
+ "archive.ph": ["integrations"],
45
+ "archive.vn": ["integrations"],
46
+ "archive.li": ["integrations"],
47
+ "archive.fo": ["integrations"],
48
+ "archive.md": ["integrations"],
40
49
  "perma.cc": ["integrations"],
41
- "mvau.lt": ["integrations"],
42
50
  "archive.org": ["integrations"],
51
+ "mvau.lt": ["integrations"],
52
+ "awesomescreenshot.com": ["integrations"],
53
+ # Miscellaneous:
54
+ "washingtonpost.com": ["decodo"],
43
55
  "verafiles.org": ["decodo", "firecrawl"],
44
56
  "youturn.in": ["decodo"],
45
- # Archive.today:
46
- "archive.today": ["integrations", "decodo"],
47
- "archive.is": ["integrations", "decodo"],
48
- "archive.ph": ["integrations", "decodo"],
49
- "archive.vn": ["integrations", "decodo"],
50
- "archive.li": ["integrations", "decodo"],
51
- "archive.fo": ["integrations", "decodo"],
52
- "archive.md": ["integrations", "decodo"],
53
- # More special cases
54
- "washingtonpost.com": ["decodo"],
55
57
  }
56
58
 
57
59
 
@@ -59,8 +61,8 @@ async def retrieve(
59
61
  urls: str | Collection[str],
60
62
  show_progress: bool = True,
61
63
  actions: list[dict] | None = None,
62
- methods: Literal["auto"] | list[str] | list[Literal["auto"] | list[str]] | None = "auto",
63
- format: str = "multimodal_sequence",
64
+ methods: Literal["auto"] | list[str] | list[Literal["auto"] | list[str]] = "auto",
65
+ format: Literal["multimodal_sequence", "html"] = "multimodal_sequence",
64
66
  include_media: bool = True,
65
67
  max_video_size: int | None = None,
66
68
  prioritize: Literal["completeness", "speed"] = "completeness"
@@ -80,12 +82,12 @@ async def retrieve(
80
82
  You can specify any subset in any order, e.g., ["decodo", "firecrawl"] or ["integrations"]. If provided
81
83
  a list of strings, that order of methods will be applied to all submitted URLs. In contrast, if provided
82
84
  a list of lists, each list will be applied to the corresponding URL in the batch. If provided "auto",
83
- will determine the best method based on the URL's domain. If None, will use the default order.
85
+ will determine the best method based on the URL's domain. If "auto", will use the default order.
84
86
  :param format: The format of the output. Available formats:
85
87
  - "multimodal_sequence" (MultimodalSequence containing parsed and downloaded media from the page)
86
88
  - "html" (string containing the raw HTML code of the page, not compatible with 'integrations' method)
87
89
  :param include_media: If True (default), download and embed images/videos. If False, return text only
88
- (media elements are omitted).
90
+ (media elements are omitted). This parameter is ignored for HTML format.
89
91
  :param max_video_size: Maximum size of videos to download, in bytes. If None, no limit is applied.
90
92
  :param prioritize: Prioritization strategy for retrieval. Available options:
91
93
  - "completeness": Higher timeout limits and more retries.
@@ -110,7 +112,7 @@ async def retrieve(
110
112
  elif isinstance(methods, list):
111
113
  assert len(methods) >= 1, "'methods' cannot be an empty list."
112
114
 
113
- # Build per-URL method dict according to the provided 'methods'
115
+ # Unfold methods list to build per-URL method dict according to the provided 'methods'
114
116
  if isinstance(methods[0], str) and methods[0] != "auto":
115
117
  # methods: list[str] → apply same order to all URLs
116
118
  url_to_methods = {url: methods[:] for url in urls_to_retrieve}
@@ -141,9 +143,9 @@ async def retrieve(
141
143
  async def _retrieve_single(
142
144
  url: str,
143
145
  session: aiohttp.ClientSession,
144
- methods: Literal["auto"] | list[str] | None = "auto",
146
+ methods: Literal["auto"] | list[str] = "auto",
145
147
  actions: list[dict] | None = None,
146
- format: str = "multimodal_sequence",
148
+ format: Literal["multimodal_sequence", "html"] = "multimodal_sequence",
147
149
  include_media: bool = True,
148
150
  max_video_size: int | None = None,
149
151
  prioritize: Literal["completeness", "speed"] = "completeness"
@@ -156,22 +158,26 @@ async def _retrieve_single(
156
158
  errors=dict(scrapemm=UnsupportedDomainError("Unsupported domain.")),
157
159
  retrieval_time=time.time() - start_time)
158
160
 
159
- if methods is None:
160
- methods = METHODS.copy()
161
- elif methods == "auto":
162
- methods = get_optimal_methods(url)
161
+ # Ensure URL is a string
162
+ url = str(url)
163
163
 
164
- try:
165
- # Ensure URL is a string
166
- url = str(url)
164
+ # Resolve the best methods to try in order
165
+ methods: list[str] = resolve_best_methods(url, methods)
166
+
167
+ if len(methods) == 0:
168
+ raise AssertionError("No retrieval methods were resolved for the given URL.")
167
169
 
170
+ try:
168
171
  # Validate methods
169
172
  for method in methods:
170
- assert method in METHODS, f"Unknown method '{method}'. Allowed: {METHODS}"
173
+ assert method in ALL_METHODS, f"Unknown method '{method}'. Allowed: {ALL_METHODS}"
171
174
 
172
- # Ensure compatibility with methods (local list only — never mutate shared globals)
173
- if format == "html":
174
- methods = [m for m in methods if m != "integrations"]
175
+ # Ensure compatibility with methods (local list only — never mutate shared globals)
176
+ if format == "html" and method not in ["decodo", "firecrawl"]:
177
+ raise AssertionError(
178
+ "'html' format is only compatible with 'firecrawl' and 'decodo' method. "
179
+ "Please choose a different format or use a different method."
180
+ )
175
181
 
176
182
  # Shortcut to download as medium
177
183
  if format != "html" and include_media:
@@ -184,44 +190,39 @@ async def _retrieve_single(
184
190
  return ScrapingResponse(url=url, content=MultimodalSequence(medium), method="Direct download",
185
191
  retrieval_time=time.time() - start_time)
186
192
 
187
- # Find available integrations TODO: Propagate name of actual integration to ScrapingResponse
188
- method_map = {
189
- "integrations": lambda: retrieve_via_integration(
190
- url, session=session, max_video_size=max_video_size, include_media=include_media
191
- ),
192
- "firecrawl": lambda: fire.scrape(
193
- url, session=session, format=format, actions=actions, include_media=include_media
194
- ),
195
- "decodo": lambda: decodo.scrape(
196
- url, session,
197
- format=format,
198
- timeout=15 if prioritize == "speed" else 60,
199
- max_retries=1 if prioritize == "speed" else 5,
200
- include_media=include_media),
201
- }
202
-
203
- result = None
204
- errors = {}
193
+ # Map method names to their corresponding retrieval functions
194
+ method_retrieval_tasks = []
195
+ for method in methods:
196
+ if method == "firecrawl":
197
+ method_retrieval_tasks.append(fire.scrape(url, session=session, format=format, actions=actions,
198
+ include_media=include_media))
199
+ elif method == "decodo":
200
+ method_retrieval_tasks.append(decodo.scrape(url, session, format=format,
201
+ timeout=15 if prioritize == "speed" else 60,
202
+ max_retries=1 if prioritize == "speed" else 5,
203
+ include_media=include_media))
204
+ else:
205
+ method_retrieval_tasks.append(retrieve_via_integration(url, integration_name=method, session=session,
206
+ max_video_size=max_video_size,
207
+ include_media=include_media))
205
208
 
206
209
  except Exception as e:
207
210
  logger.error(f"Error while preparing retrieval for '{url}'.\n" + format_exc())
208
- return ScrapingResponse(url=url, content=None, errors=dict(all=e), retrieval_time=time.time() - start_time)
211
+ return ScrapingResponse(url=url, content=None, errors=dict(scrapemm=e), retrieval_time=time.time() - start_time)
209
212
 
210
213
  # Try each method in the specified order until one succeeds
211
- for method_name in methods:
212
- if method_name not in method_map:
213
- logger.warning(f"Unknown retrieval method '{method_name}'. Skipping...")
214
- continue
215
-
214
+ result = None
215
+ errors = {}
216
+ logger.debug(f"Trying methods in order: {', '.join(methods)}")
217
+ for method_name, retrieval_task in zip(methods, method_retrieval_tasks):
216
218
  logger.debug(f"Trying method: {method_name}")
217
219
 
218
220
  try:
219
- result = await method_map[method_name]()
221
+ result = await retrieval_task
220
222
 
221
223
  except NotImplementedError as e:
222
224
  logger.info("Reached a method that is not implemented.", exc_info=True)
223
- errors[method_name] = e
224
- result = None
225
+ errors[method_name] = f"{type(e).__name__}: {e}"
225
226
 
226
227
  except sqlite3.OperationalError as e:
227
228
  if str(e) == "attempt to write a readonly database":
@@ -229,29 +230,29 @@ async def _retrieve_single(
229
230
  raise
230
231
  else:
231
232
  logger.warning(f"Error while retrieving with method '{method_name}': {e}")
232
- errors[method_name] = e
233
- result = None
233
+ errors[method_name] = f"{type(e).__name__}: {e}"
234
234
 
235
235
  except (TimeoutError, PlaywrightTimeoutError) as e:
236
236
  logger.warning(f"Timeout while retrieving with method '{method_name}': {e}")
237
- errors[method_name] = e
238
- result = None
237
+ errors[method_name] = f"{type(e).__name__}: {e}"
239
238
 
240
239
  except IPBannedError as e:
241
- logger.error(e)
242
- errors[method_name] = e
243
- result = None
240
+ logger.info(e)
241
+ errors[method_name] = f"{type(e).__name__}: {e}"
242
+
243
+ except OSError as e:
244
+ if "Disk is full" in str(e):
245
+ logger.critical("❌ Disk is full! Please free up space and try again. Aborting.")
246
+ raise DiskFull()
244
247
 
245
248
  except Exception as e:
246
249
  logger.warning(f"Error while retrieving with method '{method_name}': {e}")
247
250
  errors[method_name] = f"{type(e).__name__}: {e}"
248
- result = None
249
251
 
250
252
  if result is not None:
251
253
  logger.debug(f"Successfully retrieved with method: {method_name}")
252
254
  if isinstance(result, MultimodalSequence):
253
- if include_media:
254
- postprocess_media(result)
255
+ postprocess_media(result)
255
256
  return ScrapingResponse(url=url, content=result, method=method_name,
256
257
  retrieval_time=time.time() - start_time)
257
258
 
@@ -263,11 +264,25 @@ async def _retrieve_single(
263
264
 
264
265
  # All methods failed
265
266
  logger.warning(f"All retrieval methods failed for URL: {url}")
266
- if not errors:
267
- errors = dict(scrapemm=RetrievalFailed(f"No retrieval method produced content for '{url}'."))
268
267
  return ScrapingResponse(url=url, content=None, errors=errors, retrieval_time=time.time() - start_time)
269
268
 
270
269
 
270
+ def resolve_best_methods(url: str, allowed_methods: Literal["auto"] | list[str]) -> list[str]:
271
+ """Returns the best retrieval methods for the given URL."""
272
+ # Initialize methods list
273
+ methods = get_optimal_methods(url) if allowed_methods == "auto" else allowed_methods
274
+
275
+ # Resolve 'integrations' method to specific, applicable integrations, maintaining order
276
+ methods_resolved = []
277
+ for method in methods:
278
+ if method == "integrations":
279
+ methods_resolved.extend(get_integrations_for_url(url))
280
+ else:
281
+ methods_resolved.append(method)
282
+
283
+ return methods_resolved
284
+
285
+
271
286
  def postprocess_media(result: MultimodalSequence):
272
287
  """Ensure all media are located in the default ezmm directory (no temp files)
273
288
  and transcode all videos into a format suitable for browser playback."""
@@ -2,8 +2,8 @@ from scrapemm import retrieve
2
2
  import asyncio
3
3
 
4
4
  if __name__ == "__main__":
5
- url = "https://srilanka.newschecker.co/en/fact-check/videos-of-2022-aragalaya-revived-shared-as-recent"
6
- result = asyncio.run(retrieve(url))
5
+ url = "https://www.vishvasnews.com/viral/fact-check-upsc-has-not-reduced-the-maximum-age-limit-for-ias-and-ips-exams/"
6
+ result = asyncio.run(retrieve(url, format="html", methods=["firecrawl"]))
7
7
  print(f"Retrieval time: {result.retrieval_time:.2f} seconds")
8
8
  if result.errors:
9
9
  print(result.errors)
@@ -7,16 +7,16 @@ from scrapemm import retrieve
7
7
 
8
8
  @pytest.mark.asyncio
9
9
  @pytest.mark.parametrize("url", [
10
+ "https://factcheckarabic.afp.com/doc.afp.com.9DD6J8",
10
11
  "https://www.vishvasnews.com/viral/fact-check-upsc-has-not-reduced-the-maximum-age-limit-for-ias-and-ips-exams/",
11
12
  "https://health.medicaldialogues.in/fact-check/brain-health-fact-check/fact-check-is-sprite-the-best-remedy-for-headaches-in-the-world-140368",
12
13
  "https://assamese.factcrescendo.com/viral-claim-that-the-video-shows-the-incident-from-uttar-pradesh-and-the-youth-on-the-bike-and-the-youth-being-beaten-and-taken-away-by-the-police-are-the-same-youth-named-abdul-is-false/",
13
- # "https://factuel.afp.com/doc.afp.com.43ZN7NP",
14
+ "https://factuel.afp.com/doc.afp.com.43ZN7NP",
14
15
  "https://leadstories.com/365cb414b83e29d26fecae374d55c743a3eac4c7.png",
15
16
  "https://leadstories.com/assets_c/2025/08/193f14f06dd6f15b89bf8050e553ad7fb1be6530-thumb-900xauto-3165872.png"
16
17
  ])
17
- @pytest.mark.parametrize("method", ["firecrawl", "decodo"])
18
- async def test_generic_retrieval(url, method):
19
- result = await retrieve(url, methods=[method])
18
+ async def test_generic_retrieval(url):
19
+ result = await retrieve(url)
20
20
  assert isinstance(result, ScrapingResponse)
21
21
  print(result)
22
22
  assert result
@@ -27,6 +27,7 @@ async def test_generic_retrieval(url, method):
27
27
 
28
28
  @pytest.mark.asyncio
29
29
  @pytest.mark.parametrize("url", [
30
+ "https://www.vishvasnews.com/viral/fact-check-upsc-has-not-reduced-the-maximum-age-limit-for-ias-and-ips-exams/",
30
31
  "https://www.zeit.de/politik/deutschland/2025-07/spionage-iran-festnahme-anschlag-juden-berlin-daenemark",
31
32
  "https://factnameh.com/fa/fact-checks/2025-04-16-araghchi-witkoff-fake-photo",
32
33
  "https://www.thip.media/health-news-fact-check/fact-check-can-a-kalava-on-the-wrist-prevent-paralysis/74724/",
@@ -35,6 +36,7 @@ async def test_generic_retrieval(url, method):
35
36
  async def test_html_retrieval(url, method):
36
37
  result = await retrieve(url, format="html", methods=[method])
37
38
  assert isinstance(result, ScrapingResponse)
39
+ print(result)
38
40
  content = result.content
39
41
  print(content)
40
42
  assert content
@@ -66,13 +68,13 @@ async def test_max_video_size(url, max_video_size, download_expected):
66
68
  ([
67
69
  "https://www.youtube.com/shorts/cE0zgN6pYOc"
68
70
  ], [
69
- "integrations"
71
+ "YouTube"
70
72
  ]),
71
73
  ([
72
74
  "https://www.facebook.com/reel/1089214926521000",
73
75
  "https://www.zeit.de/politik/deutschland/2025-07/spionage-iran-festnahme-anschlag-juden-berlin-daenemark",
74
76
  ], [
75
- ["integrations"],
77
+ ["Facebook"],
76
78
  ["firecrawl"]
77
79
  ]),
78
80
  ([
@@ -99,6 +101,7 @@ async def test_max_video_size(url, max_video_size, download_expected):
99
101
  async def test_methods(urls: list[str], methods: list[str] | list[list[str]] | None):
100
102
  results = await retrieve(urls, methods=methods)
101
103
  assert results
104
+ print(results)
102
105
  if methods and methods != "auto":
103
106
  if isinstance(methods[0], str):
104
107
  methods = [methods] * len(urls)
@@ -44,18 +44,3 @@ def test_extract_skips_emoji_background_image():
44
44
  )
45
45
  soup = BeautifulSoup(html, "html.parser")
46
46
  assert _extract_media_elements(soup) == []
47
-
48
-
49
- def test_extract_skips_background_on_content_wrapper():
50
- """Archive.today wraps the whole snapshot in a div with a decorative background-image.
51
- That wrapper must not be treated as media, or decompose() would wipe the article image."""
52
- html = (
53
- '<div class="html1" style="background-image:url(\'/Edqcv/bg.png\')">'
54
- '<img src="/Edqcv/article.webp"/>'
55
- '</div>'
56
- )
57
- soup = BeautifulSoup(html, "html.parser")
58
- elements = _extract_media_elements(soup)
59
- assert len(elements) == 1
60
- assert elements[0].name == "img"
61
- assert elements[0].get("src") == "/Edqcv/article.webp"
@@ -1,74 +0,0 @@
1
- import logging
2
- from typing import Optional, Union, TYPE_CHECKING
3
-
4
- import aiohttp
5
-
6
- if TYPE_CHECKING:
7
- from playwright.async_api import APIRequestContext
8
-
9
- from scrapemm.download.common import ssl_context, RELAXED_SSL_DOMAINS
10
- from scrapemm.download.util import stream
11
-
12
- logger = logging.getLogger("scrapeMM")
13
-
14
-
15
- async def fetch_headers(url, session: Union[aiohttp.ClientSession, "APIRequestContext"], **kwargs) -> dict:
16
- """Fetch only HTTP headers for a URL."""
17
- from scrapemm.util import get_domain
18
- ssl = None if get_domain(str(url)) in RELAXED_SSL_DOMAINS else ssl_context
19
-
20
- if hasattr(session, "head"): # aiohttp.ClientSession or Playwright APIRequestContext
21
- try:
22
- # Playwright APIRequestContext.head exists and returns APIResponse
23
- if not isinstance(session, aiohttp.ClientSession):
24
- # Playwright
25
- response = await session.head(str(url), headers=kwargs.get("headers"), timeout=kwargs.get("timeout"))
26
- return response.headers
27
- else:
28
- # aiohttp
29
- async with session.head(url, ssl=ssl, **kwargs) as response:
30
- response.raise_for_status()
31
- return dict(response.headers)
32
- except Exception:
33
- logger.debug(f"HEAD failed for {url}, falling back to GET", exc_info=True)
34
- # Fallback to GET
35
- if not isinstance(session, aiohttp.ClientSession):
36
- response = await session.get(str(url), headers=kwargs.get("headers"), timeout=kwargs.get("timeout"))
37
- return response.headers
38
- else:
39
- async with session.get(url, ssl=ssl, **kwargs) as response:
40
- response.raise_for_status()
41
- return dict(response.headers)
42
- raise ValueError(f"Unsupported session type: {type(session)}")
43
-
44
-
45
- async def request_static(url: str,
46
- session: Union[aiohttp.ClientSession, "APIRequestContext"],
47
- get_text: bool = True,
48
- **kwargs) -> Optional[str | bytes]:
49
- """Downloads the static page from the given URL using aiohttp or Playwright."""
50
- if not url:
51
- return None
52
-
53
- url = str(url)
54
- from scrapemm.util import get_domain
55
- ssl = None if get_domain(url) in RELAXED_SSL_DOMAINS else ssl_context
56
-
57
- try:
58
- if not isinstance(session, aiohttp.ClientSession):
59
- # Playwright APIRequestContext
60
- response = await session.get(url, **kwargs)
61
- if response.ok:
62
- return await response.text() if get_text else await response.body()
63
- return None
64
- else:
65
- # aiohttp
66
- async with session.get(url, timeout=10, allow_redirects=True,
67
- raise_for_status=True, ssl=ssl, **kwargs) as response:
68
- if get_text:
69
- return await response.text()
70
- else:
71
- return await stream(response)
72
- except Exception:
73
- logger.debug(f"Error requesting {url}", exc_info=True)
74
- return None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes