scrapeMM 0.6.2__tar.gz → 0.6.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 (52) hide show
  1. {scrapemm-0.6.2/scrapeMM.egg-info → scrapemm-0.6.4}/PKG-INFO +39 -11
  2. {scrapemm-0.6.2 → scrapemm-0.6.4}/README.md +13 -0
  3. scrapemm-0.6.4/pyproject.toml +62 -0
  4. {scrapemm-0.6.2 → scrapemm-0.6.4/scrapeMM.egg-info}/PKG-INFO +39 -11
  5. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapeMM.egg-info/SOURCES.txt +0 -1
  6. scrapemm-0.6.4/scrapeMM.egg-info/requires.txt +28 -0
  7. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/__init__.py +1 -1
  8. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/decodo/decodo.py +14 -24
  9. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/fb.py +47 -14
  10. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/instagram.py +0 -1
  11. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/perma_cc.py +175 -14
  12. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/telegram.py +3 -3
  13. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/x.py +1 -1
  14. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/retrieval.py +11 -2
  15. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/secrets.py +6 -8
  16. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/util.py +28 -6
  17. scrapemm-0.6.4/scripts/example.py +11 -0
  18. {scrapemm-0.6.2 → scrapemm-0.6.4}/testing/test_retrieval.py +0 -1
  19. scrapemm-0.6.2/pyproject.toml +0 -44
  20. scrapemm-0.6.2/requirements.txt +0 -25
  21. scrapemm-0.6.2/scrapeMM.egg-info/requires.txt +0 -12
  22. scrapemm-0.6.2/scripts/example.py +0 -10
  23. {scrapemm-0.6.2 → scrapemm-0.6.4}/.gitignore +0 -0
  24. {scrapemm-0.6.2 → scrapemm-0.6.4}/LICENSE +0 -0
  25. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
  26. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapeMM.egg-info/top_level.txt +0 -0
  27. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/common/__init__.py +0 -0
  28. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/common/exceptions.py +0 -0
  29. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/common/scraping_response.py +0 -0
  30. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/__init__.py +0 -0
  31. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/common.py +0 -0
  32. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/images.py +0 -0
  33. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/media.py +0 -0
  34. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/requests.py +0 -0
  35. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/util.py +0 -0
  36. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/videos.py +0 -0
  37. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/__init__.py +0 -0
  38. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/archive_org.py +0 -0
  39. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/archive_today.py +0 -0
  40. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/base.py +0 -0
  41. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/bluesky.py +0 -0
  42. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/decodo/__init__.py +0 -0
  43. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/decodo/html_postprocessor.py +0 -0
  44. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/firecrawl/__init__.py +0 -0
  45. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
  46. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
  47. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/mvault.py +0 -0
  48. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/tiktok.py +0 -0
  49. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/youtube.py +0 -0
  50. {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/ytdlp.py +0 -0
  51. {scrapemm-0.6.2 → scrapemm-0.6.4}/setup.cfg +0 -0
  52. {scrapemm-0.6.2 → scrapemm-0.6.4}/testing/test_utils.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.6.2
4
- Summary: Flexible multimodal scraper for social media and the open web.
3
+ Version: 0.6.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
7
7
  Project-URL: Homepage, https://github.com/multimodal-ai-lab/scrapeMM
@@ -9,18 +9,33 @@ Project-URL: Issues, https://github.com/multimodal-ai-lab/scrapeMM
9
9
  Requires-Python: >=3.10
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
- Requires-Dist: ezmm
13
- Requires-Dist: telethon
14
- Requires-Dist: tweepy
15
- Requires-Dist: markdownify
16
- Requires-Dist: platformdirs
17
- Requires-Dist: PyYAML
12
+ Requires-Dist: aiohttp~=3.14.1
13
+ Requires-Dist: async_lru
18
14
  Requires-Dist: atproto
19
- Requires-Dist: TikTokResearchApi
20
- Requires-Dist: yt-dlp
15
+ Requires-Dist: beautifulsoup4
16
+ Requires-Dist: backports.zstd
17
+ Requires-Dist: brotli
21
18
  Requires-Dist: cryptography
22
- Requires-Dist: firecrawl-py
19
+ Requires-Dist: curl_cffi~=0.15.0
20
+ Requires-Dist: ezmm~=0.5.1
21
+ Requires-Dist: firecrawl-py~=4.32.0
22
+ Requires-Dist: markdownify~=1.2.3
23
+ Requires-Dist: oauthlib
24
+ Requires-Dist: pip-system-certs
25
+ Requires-Dist: platformdirs~=4.10.0
23
26
  Requires-Dist: playwright
27
+ Requires-Dist: playwright-stealth
28
+ Requires-Dist: prompt_toolkit
29
+ Requires-Dist: PyYAML~=6.0.1
30
+ Requires-Dist: requests~=2.34.2
31
+ Requires-Dist: telethon~=1.44.0
32
+ Requires-Dist: TikTokResearchApi~=1.0.4
33
+ Requires-Dist: tqdm~=4.68.4
34
+ Requires-Dist: tweepy~=4.17.0
35
+ Requires-Dist: yt-dlp[curl-cffi,default]
36
+ Provides-Extra: test
37
+ Requires-Dist: pytest; extra == "test"
38
+ Requires-Dist: pytest-asyncio; extra == "test"
24
39
  Dynamic: license-file
25
40
 
26
41
  # scrapeMM: Multimodal Web Retrieval
@@ -33,6 +48,19 @@ This library aims to help developers and researchers to easily access multimodal
33
48
  In Conda, you can install it with `conda install -c conda-forge ffmpeg`.
34
49
  * **If you want to scrape Perma.cc archive records or Facebook photos**, you'll need to install playwright with `pip install playwright` and running `playwright install`.
35
50
 
51
+ ## Configure
52
+ To set the API secrets, run
53
+ ```python
54
+ from scrapemm import configure_secrets
55
+ configure_secrets()
56
+ ```
57
+
58
+ To set the Firecrawl URL, run
59
+ ```python
60
+ from scrapemm import update_config
61
+ update_config(firecrawl_url="your_url")
62
+ ```
63
+
36
64
  ## Usage
37
65
  ```python
38
66
  from scrapemm import retrieve
@@ -8,6 +8,19 @@ This library aims to help developers and researchers to easily access multimodal
8
8
  In Conda, you can install it with `conda install -c conda-forge ffmpeg`.
9
9
  * **If you want to scrape Perma.cc archive records or Facebook photos**, you'll need to install playwright with `pip install playwright` and running `playwright install`.
10
10
 
11
+ ## Configure
12
+ To set the API secrets, run
13
+ ```python
14
+ from scrapemm import configure_secrets
15
+ configure_secrets()
16
+ ```
17
+
18
+ To set the Firecrawl URL, run
19
+ ```python
20
+ from scrapemm import update_config
21
+ update_config(firecrawl_url="your_url")
22
+ ```
23
+
11
24
  ## Usage
12
25
  ```python
13
26
  from scrapemm import retrieve
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "scrapeMM"
3
+ version = "0.6.4"
4
+ description = "Multimodal scraper for social media, internet archives, and the open web."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "Apache-2.0"
8
+ authors = [{name = "Mark Rothermel", email = "mark.rothermel@tu-darmstadt.de"}]
9
+ dependencies = [
10
+ "aiohttp~=3.14.1",
11
+ "async_lru",
12
+ "atproto",
13
+ "beautifulsoup4",
14
+ "backports.zstd", # For better content parsing with Firecrawl
15
+ "brotli", # Required to read brotli-encoded media
16
+ "cryptography",
17
+ "curl_cffi~=0.15.0", # Provides browser binaries for impersonation
18
+ "ezmm~=0.5.1",
19
+ "firecrawl-py~=4.32.0",
20
+ "markdownify~=1.2.3",
21
+ "oauthlib",
22
+ "pip-system-certs", # For better SSL certificate handling
23
+ "platformdirs~=4.10.0",
24
+ "playwright",
25
+ "playwright-stealth",
26
+ "prompt_toolkit", # For handling user terminal input
27
+ "PyYAML~=6.0.1",
28
+ "requests~=2.34.2",
29
+ "telethon~=1.44.0",
30
+ "TikTokResearchApi~=1.0.4",
31
+ "tqdm~=4.68.4",
32
+ "tweepy~=4.17.0",
33
+ "yt-dlp[default,curl-cffi]",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ test = [
38
+ "pytest",
39
+ "pytest-asyncio",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
44
+ Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
45
+
46
+ [build-system]
47
+ requires = ["setuptools"]
48
+ build-backend = "setuptools.build_meta"
49
+
50
+ [tool.setuptools.packages.find]
51
+ namespaces = false # Disable implicit namespaces
52
+
53
+ [tool.setuptools.package-data]
54
+ "*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = [
58
+ "testing/",
59
+ ]
60
+ asyncio_mode = "auto"
61
+ asyncio_default_test_loop_scope = "session"
62
+ asyncio_default_fixture_loop_scope = "session"
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.6.2
4
- Summary: Flexible multimodal scraper for social media and the open web.
3
+ Version: 0.6.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
7
7
  Project-URL: Homepage, https://github.com/multimodal-ai-lab/scrapeMM
@@ -9,18 +9,33 @@ Project-URL: Issues, https://github.com/multimodal-ai-lab/scrapeMM
9
9
  Requires-Python: >=3.10
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
- Requires-Dist: ezmm
13
- Requires-Dist: telethon
14
- Requires-Dist: tweepy
15
- Requires-Dist: markdownify
16
- Requires-Dist: platformdirs
17
- Requires-Dist: PyYAML
12
+ Requires-Dist: aiohttp~=3.14.1
13
+ Requires-Dist: async_lru
18
14
  Requires-Dist: atproto
19
- Requires-Dist: TikTokResearchApi
20
- Requires-Dist: yt-dlp
15
+ Requires-Dist: beautifulsoup4
16
+ Requires-Dist: backports.zstd
17
+ Requires-Dist: brotli
21
18
  Requires-Dist: cryptography
22
- Requires-Dist: firecrawl-py
19
+ Requires-Dist: curl_cffi~=0.15.0
20
+ Requires-Dist: ezmm~=0.5.1
21
+ Requires-Dist: firecrawl-py~=4.32.0
22
+ Requires-Dist: markdownify~=1.2.3
23
+ Requires-Dist: oauthlib
24
+ Requires-Dist: pip-system-certs
25
+ Requires-Dist: platformdirs~=4.10.0
23
26
  Requires-Dist: playwright
27
+ Requires-Dist: playwright-stealth
28
+ Requires-Dist: prompt_toolkit
29
+ Requires-Dist: PyYAML~=6.0.1
30
+ Requires-Dist: requests~=2.34.2
31
+ Requires-Dist: telethon~=1.44.0
32
+ Requires-Dist: TikTokResearchApi~=1.0.4
33
+ Requires-Dist: tqdm~=4.68.4
34
+ Requires-Dist: tweepy~=4.17.0
35
+ Requires-Dist: yt-dlp[curl-cffi,default]
36
+ Provides-Extra: test
37
+ Requires-Dist: pytest; extra == "test"
38
+ Requires-Dist: pytest-asyncio; extra == "test"
24
39
  Dynamic: license-file
25
40
 
26
41
  # scrapeMM: Multimodal Web Retrieval
@@ -33,6 +48,19 @@ This library aims to help developers and researchers to easily access multimodal
33
48
  In Conda, you can install it with `conda install -c conda-forge ffmpeg`.
34
49
  * **If you want to scrape Perma.cc archive records or Facebook photos**, you'll need to install playwright with `pip install playwright` and running `playwright install`.
35
50
 
51
+ ## Configure
52
+ To set the API secrets, run
53
+ ```python
54
+ from scrapemm import configure_secrets
55
+ configure_secrets()
56
+ ```
57
+
58
+ To set the Firecrawl URL, run
59
+ ```python
60
+ from scrapemm import update_config
61
+ update_config(firecrawl_url="your_url")
62
+ ```
63
+
36
64
  ## Usage
37
65
  ```python
38
66
  from scrapemm import retrieve
@@ -2,7 +2,6 @@
2
2
  LICENSE
3
3
  README.md
4
4
  pyproject.toml
5
- requirements.txt
6
5
  scrapeMM.egg-info/PKG-INFO
7
6
  scrapeMM.egg-info/SOURCES.txt
8
7
  scrapeMM.egg-info/dependency_links.txt
@@ -0,0 +1,28 @@
1
+ aiohttp~=3.14.1
2
+ async_lru
3
+ atproto
4
+ beautifulsoup4
5
+ backports.zstd
6
+ brotli
7
+ cryptography
8
+ curl_cffi~=0.15.0
9
+ ezmm~=0.5.1
10
+ firecrawl-py~=4.32.0
11
+ markdownify~=1.2.3
12
+ oauthlib
13
+ pip-system-certs
14
+ platformdirs~=4.10.0
15
+ playwright
16
+ playwright-stealth
17
+ prompt_toolkit
18
+ PyYAML~=6.0.1
19
+ requests~=2.34.2
20
+ telethon~=1.44.0
21
+ TikTokResearchApi~=1.0.4
22
+ tqdm~=4.68.4
23
+ tweepy~=4.17.0
24
+ yt-dlp[curl-cffi,default]
25
+
26
+ [test]
27
+ pytest
28
+ pytest-asyncio
@@ -1,4 +1,4 @@
1
- from .common import APP_NAME, set_wait_on_rate_limit, RateLimitError, ContentNotFoundError, logger
1
+ from .common import APP_NAME, set_wait_on_rate_limit, RateLimitError, ContentNotFoundError, logger, update_config
2
2
  from .integrations import Telegram, X
3
3
  from .retrieval import retrieve
4
4
  from .secrets import configure_secrets
@@ -26,23 +26,21 @@ class Decodo:
26
26
  DECODO_API_URL = "https://scraper-api.decodo.com/v2/scrape"
27
27
 
28
28
  def __init__(self):
29
- self.username = None
30
- self.password = None
29
+ self.basic_auth_token = None
31
30
  self.n_scrapes = 0
32
31
 
33
- def _load_credentials(self):
32
+ def _load_token(self):
34
33
  """Loads Decodo credentials from the secrets manager."""
35
- self.username = get_secret("decodo_username")
36
- self.password = get_secret("decodo_password")
34
+ self.basic_auth_token = get_secret("decodo_token")
37
35
 
38
- if self.username and self.password:
39
- logger.info("✅ Decodo credentials loaded successfully.")
36
+ if self.basic_auth_token:
37
+ logger.info("✅ Decodo token set.")
40
38
  else:
41
- logger.warning("⚠️ Decodo credentials not found. Please configure them in secrets.")
39
+ logger.warning("⚠️ Decodo auth token not found. Please configure it in secrets.")
42
40
 
43
- def _has_credentials(self) -> bool:
41
+ def _has_token(self) -> bool:
44
42
  """Checks if Decodo credentials are available."""
45
- return bool(self.username and self.password)
43
+ return bool(self.basic_auth_token)
46
44
 
47
45
  async def scrape(
48
46
  self, url: str,
@@ -66,10 +64,10 @@ class Decodo:
66
64
  Returns:
67
65
  MultimodalSequence containing the scraped content, or None if scraping failed
68
66
  """
69
- if not self._has_credentials():
70
- self._load_credentials()
67
+ if not self._has_token():
68
+ self._load_token()
71
69
 
72
- if not self._has_credentials():
70
+ if not self._has_token():
73
71
  logger.warning("Cannot scrape with Decodo: credentials not configured.")
74
72
  return None
75
73
 
@@ -124,6 +122,7 @@ class Decodo:
124
122
  """
125
123
  headers = {
126
124
  'Content-Type': 'application/json',
125
+ 'Authorization': f'Basic {self.basic_auth_token}',
127
126
  }
128
127
 
129
128
  # Build request payload
@@ -141,9 +140,6 @@ class Decodo:
141
140
  if use_premium_proxy:
142
141
  payload["proxy_pool"] = "premium"
143
142
 
144
- # Create basic auth
145
- auth = aiohttp.BasicAuth(self.username, self.password)
146
-
147
143
  # Retry loop with exponential backoff
148
144
  for attempt in range(max_retries + 1):
149
145
  try:
@@ -151,12 +147,11 @@ class Decodo:
151
147
  self.DECODO_API_URL,
152
148
  json=payload,
153
149
  headers=headers,
154
- auth=auth,
155
150
  timeout=aiohttp.ClientTimeout(total=timeout)
156
151
  ) as response:
157
152
  # Validate response health
158
153
  if response.status != 200:
159
- logger.debug("Communication with Decodo API failed.")
154
+ logger.debug(f"Communication with Decodo API failed. Status code: {response.status}")
160
155
 
161
156
  if response.status == 429: # Rate limit
162
157
  if attempt >= max_retries:
@@ -233,12 +228,7 @@ class Decodo:
233
228
  except aiohttp.ClientError:
234
229
  logger.error(f"Network error while scraping with Decodo.")
235
230
  raise
236
- except asyncio.TimeoutError:
237
- if attempt >= max_retries:
238
- raise
239
- else:
240
- logger.debug(f"Timeout while scraping with Decodo. Retrying...")
241
- except RateLimitError:
231
+ except (RateLimitError, asyncio.TimeoutError):
242
232
  raise
243
233
  except Exception as e:
244
234
  logger.error(f"Unexpected error while scraping with Decodo: {e}")
@@ -28,17 +28,42 @@ LIKE_COMMENT_SHARE_SVG_REGEX = (
28
28
  )
29
29
  FB_PHOTO_HREF_REGEX = r'href="(https://www\.facebook\.com/photo/[^"]*)"'
30
30
 
31
- JS_GET_MEDIA_VC_IMAGE = """
32
- () => {
33
- const img = document.querySelector('img[data-visualcompletion="media-vc-image"]');
34
- return img ? img.getAttribute('src') : null;
35
- }
36
- """.strip()
37
-
38
- JS_GET_OG_IMAGE = """
31
+ JS_GET_PHOTO_IMAGE = """
39
32
  () => {
33
+ // Strategy 1: data-visualcompletion attribute (legacy)
34
+ let img = document.querySelector('img[data-visualcompletion="media-vc-image"]');
35
+ if (img) return img.getAttribute('src');
36
+
37
+ // Strategy 2: Large image inside the photo theater/spotlight viewer
38
+ const viewerSelectors = [
39
+ '[role="dialog"] img[src*="scontent"]',
40
+ '[data-pagelet="MediaViewerPhoto"] img',
41
+ '[role="main"] img[src*="scontent"]',
42
+ 'img[alt][src*="fbcdn"]',
43
+ 'img[alt][src*="scontent"]',
44
+ ];
45
+ for (const sel of viewerSelectors) {
46
+ const candidates = document.querySelectorAll(sel);
47
+ for (const c of candidates) {
48
+ const src = c.getAttribute('src') || '';
49
+ const w = c.naturalWidth || c.width || 0;
50
+ if (src && w > 200) return src;
51
+ }
52
+ }
53
+
54
+ // Strategy 3: Largest image on the page with a CDN src
55
+ const allImgs = Array.from(document.querySelectorAll('img[src*="scontent"], img[src*="fbcdn"]'));
56
+ if (allImgs.length > 0) {
57
+ allImgs.sort((a, b) => (b.naturalWidth || 0) - (a.naturalWidth || 0));
58
+ const best = allImgs[0];
59
+ if (best && (best.naturalWidth || 0) > 100) return best.getAttribute('src');
60
+ }
61
+
62
+ // Strategy 4: og:image meta tag
40
63
  const og = document.querySelector('meta[property="og:image"]');
41
- return og ? og.getAttribute('content') : null;
64
+ if (og) return og.getAttribute('content');
65
+
66
+ return null;
42
67
  }
43
68
  """.strip()
44
69
 
@@ -118,7 +143,7 @@ class Facebook(RetrievalIntegration):
118
143
  url,
119
144
  platform="Facebook",
120
145
  # cookiefile=self.cookie_file.as_posix(),
121
- impersonate=ImpersonateTarget("chrome", "133"),
146
+ impersonate=ImpersonateTarget("chrome", "146"),
122
147
  **kwargs,
123
148
  )
124
149
 
@@ -148,13 +173,21 @@ class Facebook(RetrievalIntegration):
148
173
  try:
149
174
  await page.goto(url, timeout=30000)
150
175
  await page.wait_for_load_state("domcontentloaded")
176
+ # Wait for network to settle so images are rendered
177
+ try:
178
+ await page.wait_for_load_state("networkidle", timeout=10000)
179
+ except PlaywrightTimeoutError:
180
+ pass
181
+ # Give React/JS a moment to render the photo
182
+ await page.wait_for_timeout(2000)
151
183
  except PlaywrightTimeoutError:
152
- raise RuntimeError("Timed out loading Facebook photo page.")
184
+ raise TimeoutError("Timed out loading Facebook photo page.")
185
+
186
+ image_url = await page.evaluate(JS_GET_PHOTO_IMAGE)
153
187
 
154
- image_url = await page.evaluate(JS_GET_MEDIA_VC_IMAGE)
188
+ except TimeoutError:
189
+ raise
155
190
 
156
- if not image_url:
157
- image_url = await page.evaluate(JS_GET_OG_IMAGE)
158
191
  finally:
159
192
  html = await page.content()
160
193
  await browser.close()
@@ -7,7 +7,6 @@ from yt_dlp import DownloadError
7
7
  from scrapemm.common.exceptions import RateLimitError
8
8
  from scrapemm.integrations.base import RetrievalIntegration
9
9
  from scrapemm.integrations.ytdlp import get_content_with_ytdlp
10
- from scrapemm.util import get_domain
11
10
 
12
11
  logger = logging.getLogger("scrapeMM")
13
12
 
@@ -1,22 +1,96 @@
1
1
  import logging
2
+ import re
2
3
  from typing import Optional
4
+ from urllib.parse import urlparse, parse_qs
3
5
 
4
6
  import aiohttp
5
7
  from ezmm import MultimodalSequence
6
8
  from playwright.async_api import Error, TimeoutError, async_playwright
9
+ from playwright_stealth import Stealth
7
10
 
8
11
  from scrapemm.download.common import HEADERS
9
12
  from scrapemm.integrations.base import RetrievalIntegration
13
+ from scrapemm.integrations.decodo import decodo
14
+ from scrapemm.secrets import get_secret
10
15
  from scrapemm.util import to_multimodal_sequence
11
16
 
12
17
  logger = logging.getLogger("scrapeMM")
13
18
 
14
-
15
19
  # Limits for inlining media as data URIs to avoid excessive memory usage
16
20
  MAX_IMAGE_BYTES = 15 * 1024 * 1024 # 15 MB
17
21
  MAX_VIDEO_BYTES = 25 * 1024 * 1024 # 25 MB
18
22
  INLINE_CONCURRENCY = 6
19
23
 
24
+ PERMA_CC_API_BASE = "https://api.perma.cc/v1/public/archives"
25
+
26
+
27
+ def _extract_guid(url: str) -> Optional[str]:
28
+ """Extract the Perma.cc GUID (e.g. 'N8MR-NS96') from a perma.cc URL."""
29
+ match = re.search(r"perma\.cc/([A-Za-z0-9]+-[A-Za-z0-9]+)", url)
30
+ return match.group(1) if match else None
31
+
32
+
33
+ def _is_screenshot_request(url: str) -> bool:
34
+ """Check if the URL explicitly requests the screenshot view (?type=image)."""
35
+ parsed = urlparse(url)
36
+ params = parse_qs(parsed.query)
37
+ return params.get("type", [None])[0] == "image"
38
+
39
+
40
+ async def _get_archive_metadata(guid: str, session: aiohttp.ClientSession) -> Optional[dict]:
41
+ """Fetch archive metadata from Perma.cc's public API."""
42
+ api_url = f"{PERMA_CC_API_BASE}/{guid}/"
43
+ try:
44
+ async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
45
+ if resp.status == 200:
46
+ return await resp.json()
47
+ else:
48
+ logger.debug(f"Perma.cc API returned status {resp.status} for GUID {guid}")
49
+ except Exception as e:
50
+ logger.debug(f"Failed to fetch Perma.cc API metadata: {e}")
51
+ return None
52
+
53
+
54
+ def _extract_iframe_src(html: str) -> Optional[str]:
55
+ """Extract the rejouer.perma.cc iframe src URL from the Perma.cc wrapper HTML."""
56
+ # Match iframe with class "archive-iframe" or src containing rejouer.perma.cc
57
+ patterns = [
58
+ re.compile(r'<iframe[^>]+class=["\'][^"\']*archive-iframe[^"\']*["\'][^>]+src=["\']([^"\']+)["\']', re.IGNORECASE),
59
+ re.compile(r'<iframe[^>]+src=["\']([^"\']*rejouer\.perma\.cc[^"\']+)["\']', re.IGNORECASE),
60
+ ]
61
+ for pattern in patterns:
62
+ match = pattern.search(html)
63
+ if match:
64
+ url = match.group(1)
65
+ # Unescape HTML entities
66
+ url = url.replace("&amp;", "&")
67
+ return url
68
+ return None
69
+
70
+
71
+ def _extract_screenshot_img_url(html: str, guid: str) -> Optional[str]:
72
+ """Extract the actual screenshot image URL from the Perma.cc screenshot page HTML.
73
+
74
+ The page embeds the screenshot in an <img> tag whose src points to
75
+ something like: https://rejouer.perma.cc/replay-web-page/w/.../mp_/file:///GUID/cap.png
76
+ """
77
+ # Look for img src containing the GUID or cap.png
78
+ patterns = [
79
+ # Match src with the GUID in the path
80
+ re.compile(r'<img[^>]+src=["\']([^"\']*' + re.escape(guid) + r'[^"\']*cap\.png[^"\']*)["\']', re.IGNORECASE),
81
+ # Match any src on rejouer.perma.cc
82
+ re.compile(r'<img[^>]+src=["\']([^"\']*rejouer\.perma\.cc[^"\']*)["\']', re.IGNORECASE),
83
+ # Match any src containing cap.png
84
+ re.compile(r'<img[^>]+src=["\']([^"\']*cap\.png[^"\']*)["\']', re.IGNORECASE),
85
+ # Fallback: any img src that looks like a full URL
86
+ re.compile(r'<img[^>]+src=["\'](https?://[^"\']+)["\']', re.IGNORECASE),
87
+ ]
88
+ for pattern in patterns:
89
+ match = pattern.search(html)
90
+ if match:
91
+ return match.group(1)
92
+ return None
93
+
20
94
 
21
95
  class PermaCC(RetrievalIntegration):
22
96
  name = "Perma.cc"
@@ -27,14 +101,95 @@ class PermaCC(RetrievalIntegration):
27
101
  self.connected = True
28
102
 
29
103
  async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
30
- html = await get_record_html(url)
31
- if html:
32
- async with aiohttp.ClientSession(headers=HEADERS) as session:
33
- return await to_multimodal_sequence(
34
- html, remove_urls=False, session=session, url=url
35
- )
36
- else:
37
- raise RuntimeError("Failed to retrieve Perma.cc record HTML.")
104
+ guid = _extract_guid(url)
105
+ if not guid:
106
+ raise RuntimeError(f"Could not extract Perma.cc GUID from URL: {url}")
107
+
108
+ async with aiohttp.ClientSession(headers=HEADERS) as session:
109
+ # Fetch archive metadata to get the original URL
110
+ metadata = await _get_archive_metadata(guid, session)
111
+ original_url = None
112
+ if metadata:
113
+ # Top-level "url" field
114
+ original_url = metadata.get("url")
115
+ # Fallback: extract from captures array (role=primary)
116
+ if not original_url:
117
+ for capture in metadata.get("captures", []):
118
+ if capture.get("role") == "primary" and capture.get("url"):
119
+ original_url = capture["url"]
120
+ break
121
+ logger.debug(f"Perma.cc metadata keys: {list(metadata.keys())}, original_url={original_url}")
122
+
123
+ # Strategy 1: Scrape the ORIGINAL URL via Decodo (media URLs will be normal)
124
+ if original_url:
125
+ logger.debug(f"Perma.cc archive of: {original_url}")
126
+ result = await _scrape_original_via_decodo(original_url, session)
127
+ if result and _has_media(result):
128
+ return result
129
+
130
+ # Strategy 2: Scrape the Perma.cc page itself via Decodo and download media
131
+ html = await _get_html_via_decodo(url, session)
132
+ if html:
133
+ result = await to_multimodal_sequence(html, remove_urls=False, session=session, url=url)
134
+ if result and _has_media(result):
135
+ return result
136
+
137
+ # Strategy 3: Use Playwright with media inlining (handles session-bound URLs)
138
+ html = await get_record_html(url)
139
+ if html:
140
+ return await to_multimodal_sequence(html, remove_urls=False, session=session, url=url)
141
+
142
+ # Strategy 4: Return Decodo text-only result if we got one earlier
143
+ if original_url:
144
+ result = await _scrape_original_via_decodo(original_url, session)
145
+ if result:
146
+ return result
147
+
148
+ raise RuntimeError("Failed to retrieve Perma.cc record.")
149
+
150
+
151
+ def _has_media(seq: MultimodalSequence) -> bool:
152
+ """Check if a MultimodalSequence contains any images or videos."""
153
+ return bool(seq.images or seq.videos)
154
+
155
+
156
+ async def _scrape_original_via_decodo(
157
+ original_url: str, session: aiohttp.ClientSession
158
+ ) -> Optional[MultimodalSequence]:
159
+ """Scrape the original archived URL via Decodo. Media URLs on the original
160
+ site are normal HTTP URLs that can be downloaded directly."""
161
+ try:
162
+ result = await decodo.scrape(
163
+ original_url,
164
+ remove_urls=False,
165
+ session=session,
166
+ format="multimodal_sequence",
167
+ enable_js=True,
168
+ timeout=30,
169
+ )
170
+ if isinstance(result, MultimodalSequence):
171
+ return result
172
+ except Exception as e:
173
+ logger.debug(f"Decodo scrape of original URL failed: {e}")
174
+ return None
175
+
176
+
177
+ async def _get_html_via_decodo(url: str, session: aiohttp.ClientSession) -> Optional[str]:
178
+ """Fetch Perma.cc page HTML using Decodo proxy service to bypass Cloudflare."""
179
+ try:
180
+ result = await decodo.scrape(
181
+ url,
182
+ remove_urls=False,
183
+ session=session,
184
+ format="html",
185
+ enable_js=True,
186
+ timeout=30,
187
+ )
188
+ if isinstance(result, str) and result.strip():
189
+ return result
190
+ except Exception as e:
191
+ logger.debug(f"Decodo scrape failed for Perma.cc: {e}")
192
+ return None
38
193
 
39
194
 
40
195
  async def _inline_media_in_frame(frame, image_limit: int = MAX_IMAGE_BYTES, video_limit: int = MAX_VIDEO_BYTES,
@@ -269,11 +424,17 @@ async def _inline_media_in_frame(frame, image_limit: int = MAX_IMAGE_BYTES, vide
269
424
 
270
425
 
271
426
  async def get_record_html(url: str) -> str | None:
272
- """Retrieves the HTML of the record saved by Perma.cc. Loads the contents dynamically
273
- and returns any contained media as data URIs (base64-encoded)."""
274
- async with async_playwright() as p:
275
- browser = await p.chromium.launch(headless=True)
276
- context = await browser.new_context(accept_downloads=False)
427
+ """Fallback: Retrieves the HTML of the record saved by Perma.cc using Playwright.
428
+ Loads the contents dynamically and returns any contained media as data URIs (base64-encoded)."""
429
+ async with Stealth().use_async(async_playwright()) as p:
430
+ browser = await p.chromium.launch(
431
+ headless=False,
432
+ args=["--window-position=-10000,-10000"],
433
+ )
434
+ context = await browser.new_context(
435
+ accept_downloads=False,
436
+ user_agent=HEADERS["User-Agent"],
437
+ )
277
438
  page = await context.new_page()
278
439
 
279
440
  try:
@@ -29,14 +29,14 @@ class Telegram(RetrievalIntegration):
29
29
  bot_token = get_secret("telegram_bot_token")
30
30
 
31
31
  if api_id and api_hash and bot_token:
32
- self.client = TelegramClient(self.session_path, api_id, api_hash)
33
32
  try:
34
- await self.client.start(bot_token=bot_token) # Returns a coroutine b/c event loop exists already
33
+ self.client = TelegramClient(self.session_path, api_id, api_hash)
35
34
  except sqlite3.OperationalError: # Database is locked from an interrupted previous session
36
35
  # Remove the database file and try again
37
36
  journal_path = Path(self.session_path + ".session-journal")
38
37
  journal_path.unlink(missing_ok=True)
39
- await self.client.start(bot_token=bot_token) # Returns a coroutine b/c event loop exists already
38
+ self.client = TelegramClient(self.session_path, api_id, api_hash)
39
+ await self.client.start(bot_token=bot_token) # Returns a coroutine b/c event loop exists already
40
40
  self.connected = True
41
41
  logger.info("✅ Successfully connected to Telegram.")
42
42
  else:
@@ -162,7 +162,7 @@ Likes: {metrics['like_count']} - Retweets: {metrics['retweet_count']} - Replies:
162
162
  properties_text += "\n- Protected"
163
163
  if user.withheld:
164
164
  properties_text += "\n- Withheld"
165
- if user.parody:
165
+ if hasattr(user, "parody") and user.parody:
166
166
  properties_text += "\n- Marked as parody"
167
167
 
168
168
  text = f"""**Profile on X**
@@ -41,6 +41,8 @@ BEST_METHODS = {
41
41
  "perma.cc": ["integrations"],
42
42
  "mvau.lt": ["integrations"],
43
43
  "archive.org": ["integrations"],
44
+ "verafiles.org": ["decodo", "firecrawl"],
45
+ "youturn.in": ["decodo"],
44
46
  # Archive.today:
45
47
  "archive.today": ["integrations", "decodo"],
46
48
  "archive.is": ["integrations", "decodo"],
@@ -49,6 +51,8 @@ BEST_METHODS = {
49
51
  "archive.li": ["integrations", "decodo"],
50
52
  "archive.fo": ["integrations", "decodo"],
51
53
  "archive.md": ["integrations", "decodo"],
54
+ # More special cases
55
+ "washingtonpost.com": ["decodo"],
52
56
  }
53
57
 
54
58
 
@@ -185,7 +189,7 @@ async def _retrieve_single(
185
189
  session=session, format=format, actions=actions),
186
190
  "decodo": lambda: decodo.scrape(url, remove_urls, session,
187
191
  format=format,
188
- timeout=10 if prioritize == "speed" else 30,
192
+ timeout=15 if prioritize == "speed" else 60,
189
193
  max_retries=1 if prioritize == "speed" else 5),
190
194
  }
191
195
 
@@ -220,6 +224,11 @@ async def _retrieve_single(
220
224
  errors[method_name] = e
221
225
  result = None
222
226
 
227
+ except TimeoutError as e:
228
+ logger.warning(f"Timeout while retrieving with method '{method_name}': {e}")
229
+ errors[method_name] = e
230
+ result = None
231
+
223
232
  except IPBannedError as e:
224
233
  logger.error(e)
225
234
  errors[method_name] = e
@@ -227,7 +236,7 @@ async def _retrieve_single(
227
236
 
228
237
  except Exception as e:
229
238
  logger.warning(f"Error while retrieving with method '{method_name}': {e}")
230
- errors[method_name] = e
239
+ errors[method_name] = f"{type(e).__name__}: {e}"
231
240
  result = None
232
241
 
233
242
  if result is not None:
@@ -9,8 +9,8 @@ from cryptography.hazmat.backends import default_backend
9
9
  from cryptography.hazmat.primitives import hashes
10
10
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
11
11
 
12
- from scrapemm.common import get_config_var, update_config, CONFIG_PATH, CONFIG_DIR
13
- from scrapemm.util import get_multiline_user_input
12
+ from scrapemm.common import get_config_var, update_config, CONFIG_DIR
13
+ from scrapemm.util import get_user_input
14
14
 
15
15
  logger = logging.getLogger("scrapeMM")
16
16
 
@@ -23,8 +23,7 @@ SECRETS = {
23
23
  "bluesky_password": "Bluesky password",
24
24
  "tiktok_client_key": "TikTok client key",
25
25
  "tiktok_client_secret": "TikTok client secret",
26
- "decodo_username": "Decodo Web Scraping API username",
27
- "decodo_password": "Decodo Web Scraping API password",
26
+ "decodo_token": "Decodo Web Scraping API basic authentication token",
28
27
  "youtube_cookie": "YouTube cookie string",
29
28
  "facebook_cookie": "Facebook cookie string",
30
29
  }
@@ -144,9 +143,9 @@ def override_secret(key_name: str):
144
143
  when nothing entered."""
145
144
  description = SECRETS[key_name]
146
145
  if key_name in ["youtube_cookie", "facebook_cookie"]:
147
- user_input = get_multiline_user_input(f"Please enter the {description}. Hit Ctrl-D or Ctrl-Z to save it.")
146
+ user_input = get_user_input(f"Please enter the {description} (Alt+Enter to submit):", multiline=True)
148
147
  else:
149
- user_input = getpass(f"Please enter the {description} (leave empty to skip): ", stream=sys.stdout)
148
+ user_input = get_user_input(f"Please enter the {description} (leave empty to skip):")
150
149
  if user_input:
151
150
  set_secret(key_name, user_input)
152
151
 
@@ -171,8 +170,7 @@ def configure_secrets(all_keys: bool = False):
171
170
 
172
171
  update_config(api_keys_configured=True)
173
172
 
174
- logger.info("API keys configured successfully! If you want to change them, go to "
175
- f"{CONFIG_PATH.as_posix()} and set 'api_keys_configured' to 'false' or "
173
+ logger.info("API keys configured successfully! If you want to change them, "
176
174
  f"run scrapemm.secrets.configure_secrets().")
177
175
 
178
176
 
@@ -38,7 +38,7 @@ def get_domain(url: str, keep_subdomain: bool = False) -> Optional[str]:
38
38
  async def run_with_semaphore(tasks: Iterable[Awaitable],
39
39
  limit: int,
40
40
  show_progress: bool = True,
41
- progress_description: str | None = None) -> list:
41
+ progress_description: str | None = None) -> tuple:
42
42
  """
43
43
  Runs asynchronous tasks with a concurrency limit.
44
44
 
@@ -76,10 +76,32 @@ def read_urls_from_file(file_path):
76
76
  return f.read().splitlines()
77
77
 
78
78
 
79
- def get_multiline_user_input(prompt: str) -> str:
80
- print(prompt)
81
- lines = sys.stdin.readlines()
82
- return "".join(lines)
79
+ def get_user_input(prompt: str, multiline: bool = False) -> str:
80
+ """Prompts the user for input.
81
+ - Single-line: uses standard input(); Enter submits, empty input skips.
82
+ - Multiline: uses prompt_toolkit; Enter adds newline, Alt+Enter submits.
83
+ """
84
+ if multiline:
85
+ from prompt_toolkit import prompt as pt_prompt
86
+ from prompt_toolkit.key_binding import KeyBindings
87
+
88
+ print(prompt)
89
+
90
+ bindings = KeyBindings()
91
+
92
+ @bindings.add('escape', 'enter')
93
+ def _submit(event):
94
+ event.current_buffer.validate_and_handle()
95
+
96
+ result = pt_prompt(
97
+ ">>> ",
98
+ multiline=True,
99
+ key_bindings=bindings,
100
+ prompt_continuation="... ",
101
+ )
102
+ return result
103
+ else:
104
+ return input(f"{prompt} ")
83
105
 
84
106
 
85
107
  MAX_MEDIA_PER_PAGE = 32
@@ -127,7 +149,7 @@ async def resolve_media_hyperlinks(
127
149
 
128
150
  # Try to download media for each URL
129
151
  tasks = [download_medium(u, session=session, headers={"Referer": url}) for u in hrefs_urls.values()]
130
- media: list[Item | None] = await run_with_semaphore(tasks, limit=100, show_progress=False)
152
+ media: tuple[Item | None] = await run_with_semaphore(tasks, limit=100, show_progress=False)
131
153
 
132
154
  href_media = dict(zip(hrefs_urls.keys(), media))
133
155
 
@@ -0,0 +1,11 @@
1
+ from scrapemm import retrieve
2
+ import asyncio
3
+
4
+ if __name__ == "__main__":
5
+ url = "https://perma.cc/ZD7Z-B3U7?type=image"
6
+ result = asyncio.run(retrieve(url))
7
+ print(f"Retrieval time: {result.retrieval_time:.2f} seconds")
8
+ if result.errors:
9
+ print(result.errors)
10
+ else:
11
+ print(result.content)
@@ -9,7 +9,6 @@ from scrapemm import retrieve
9
9
  @pytest.mark.parametrize("url", [
10
10
  "https://www.vishvasnews.com/viral/fact-check-upsc-has-not-reduced-the-maximum-age-limit-for-ias-and-ips-exams/",
11
11
  "https://health.medicaldialogues.in/fact-check/brain-health-fact-check/fact-check-is-sprite-the-best-remedy-for-headaches-in-the-world-140368",
12
- "https://www.washingtonpost.com/politics/2024/05/15/bidens-false-claim-that-inflation-was-9-percent-when-he-took-office/",
13
12
  "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/",
14
13
  # "https://factuel.afp.com/doc.afp.com.43ZN7NP",
15
14
  "https://leadstories.com/365cb414b83e29d26fecae374d55c743a3eac4c7.png",
@@ -1,44 +0,0 @@
1
- [project]
2
- name = "scrapeMM"
3
- version = "0.6.2"
4
- description = "Flexible multimodal scraper for social media and the open web."
5
- readme = "README.md"
6
- requires-python = ">=3.10"
7
- license = "Apache-2.0"
8
- authors = [{name = "Mark Rothermel", email = "mark.rothermel@tu-darmstadt.de"}]
9
- dependencies = [
10
- "ezmm",
11
- "telethon",
12
- "tweepy",
13
- "markdownify",
14
- "platformdirs",
15
- "PyYAML",
16
- "atproto",
17
- "TikTokResearchApi",
18
- "yt-dlp",
19
- "cryptography",
20
- "firecrawl-py",
21
- "playwright"
22
- ]
23
-
24
- [project.urls]
25
- Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
26
- Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
27
-
28
- [build-system]
29
- requires = ["setuptools"]
30
- build-backend = "setuptools.build_meta"
31
-
32
- [tool.setuptools.packages.find]
33
- namespaces = false # Disable implicit namespaces
34
-
35
- [tool.setuptools.package-data]
36
- "*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
37
-
38
- [tool.pytest.ini_options]
39
- testpaths = [
40
- "testing/",
41
- ]
42
- asyncio_mode = "auto"
43
- asyncio_default_test_loop_scope = "session"
44
- asyncio_default_fixture_loop_scope = "session"
@@ -1,25 +0,0 @@
1
- pytest~=8.4.1
2
- aiohttp~=3.13.5
3
- requests~=2.32.4
4
- ezMM~=0.5.1
5
- Telethon~=1.40.0
6
- PyYAML~=6.0.1
7
- tweepy~=4.16.0
8
- markdownify~=1.1.0
9
- tqdm~=4.66.3
10
- pytest-asyncio
11
- async_lru
12
- oauthlib
13
- platformdirs~=4.3.8
14
- atproto
15
- TikTokResearchApi~=1.0.4
16
- yt-dlp[default,curl-cffi]
17
- curl_cffi~=0.14.0b5 # yt-dlp doesn't support 0.15+ yet
18
- cryptography
19
- firecrawl-py~=4.10.4
20
- brotli # Required to read brotli-encoded media
21
- playwright
22
- playwright-stealth
23
- beautifulsoup4
24
- pip-system-certs # For better SSL certificate handling
25
- backports.zstd # For better content parsing with Firecrawl
@@ -1,12 +0,0 @@
1
- ezmm
2
- telethon
3
- tweepy
4
- markdownify
5
- platformdirs
6
- PyYAML
7
- atproto
8
- TikTokResearchApi
9
- yt-dlp
10
- cryptography
11
- firecrawl-py
12
- playwright
@@ -1,10 +0,0 @@
1
- from scrapemm import retrieve
2
- import asyncio
3
-
4
- if __name__ == "__main__":
5
- url = "https://www.snopes.com/fact-check/biden-bounty-maduro/"
6
- result = asyncio.run(retrieve(url, methods=["firecrawl"]))
7
- if result.errors:
8
- print(result.errors)
9
- else:
10
- print(result.content)
File without changes
File without changes
File without changes
File without changes