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.
- {scrapemm-0.7.3/scrapeMM.egg-info → scrapemm-0.7.4}/PKG-INFO +3 -3
- {scrapemm-0.7.3 → scrapemm-0.7.4}/pyproject.toml +3 -3
- {scrapemm-0.7.3 → scrapemm-0.7.4/scrapeMM.egg-info}/PKG-INFO +3 -3
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/requires.txt +2 -2
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/common/exceptions.py +4 -0
- scrapemm-0.7.4/scrapemm/download/requests.py +172 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/__init__.py +15 -5
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/decodo/decodo.py +1 -1
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/tiktok.py +1 -1
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/retrieval.py +87 -72
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scripts/example.py +2 -2
- {scrapemm-0.7.3 → scrapemm-0.7.4}/testing/test_retrieval.py +9 -6
- {scrapemm-0.7.3 → scrapemm-0.7.4}/testing/test_utils.py +0 -15
- scrapemm-0.7.3/scrapemm/download/requests.py +0 -74
- {scrapemm-0.7.3 → scrapemm-0.7.4}/.gitignore +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/LICENSE +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/README.md +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/SOURCES.txt +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/__init__.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/common/__init__.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/common/scraping_response.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/__init__.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/common.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/images.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/media.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/util.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/download/videos.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/archive_org.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/archive_today.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/awesomescreenshot.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/base.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/bluesky.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/decodo/__init__.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/decodo/html_postprocessor.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/fb.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/firecrawl/__init__.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/ghostarchive.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/headed_browser.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/instagram.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/perma_cc.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/telegram.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/x.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/youtube.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/integrations/ytdlp.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/secrets.py +0 -0
- {scrapemm-0.7.3 → scrapemm-0.7.4}/scrapemm/util.py +0 -0
- {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
|
+
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
|
|
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.
|
|
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
|
+
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
|
|
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.
|
|
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
|
+
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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
|
|
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
|
|
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]]
|
|
63
|
-
format:
|
|
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
|
|
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
|
-
#
|
|
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]
|
|
146
|
+
methods: Literal["auto"] | list[str] = "auto",
|
|
145
147
|
actions: list[dict] | None = None,
|
|
146
|
-
format:
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
173
|
+
assert method in ALL_METHODS, f"Unknown method '{method}'. Allowed: {ALL_METHODS}"
|
|
171
174
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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.
|
|
242
|
-
errors[method_name] = e
|
|
243
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
"
|
|
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
|
-
["
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|