scrapeMM 0.3.3__tar.gz → 0.3.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 (38) hide show
  1. {scrapemm-0.3.3 → scrapemm-0.3.4}/.gitignore +4 -1
  2. {scrapemm-0.3.3/scrapeMM.egg-info → scrapemm-0.3.4}/PKG-INFO +3 -3
  3. {scrapemm-0.3.3 → scrapemm-0.3.4}/README.md +2 -2
  4. {scrapemm-0.3.3 → scrapemm-0.3.4}/pyproject.toml +1 -1
  5. {scrapemm-0.3.3 → scrapemm-0.3.4/scrapeMM.egg-info}/PKG-INFO +3 -3
  6. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/fb.py +28 -39
  7. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/instagram.py +16 -22
  8. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/tiktok.py +4 -15
  9. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/youtube.py +5 -11
  10. scrapemm-0.3.4/scrapemm/scraping/decodo.py +216 -0
  11. scrapemm-0.3.4/scrapemm/scraping/ytdlp.py +114 -0
  12. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/secrets.py +2 -2
  13. scrapemm-0.3.4/scripts/example.py +7 -0
  14. {scrapemm-0.3.3 → scrapemm-0.3.4}/testing/test_retrieval.py +41 -15
  15. scrapemm-0.3.3/scrapemm/scraping/decodo.py +0 -192
  16. scrapemm-0.3.3/scrapemm/scraping/ytdlp.py +0 -203
  17. scrapemm-0.3.3/scripts/example.py +0 -7
  18. {scrapemm-0.3.3 → scrapemm-0.3.4}/LICENSE +0 -0
  19. {scrapemm-0.3.3 → scrapemm-0.3.4}/requirements.txt +0 -0
  20. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapeMM.egg-info/SOURCES.txt +0 -0
  21. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
  22. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapeMM.egg-info/requires.txt +0 -0
  23. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapeMM.egg-info/top_level.txt +0 -0
  24. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/__init__.py +0 -0
  25. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/common.py +0 -0
  26. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/__init__.py +0 -0
  27. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/base.py +0 -0
  28. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/bluesky.py +0 -0
  29. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/telegram.py +0 -0
  30. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/integrations/x.py +0 -0
  31. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/retrieval.py +0 -0
  32. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/scraping/__init__.py +0 -0
  33. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/scraping/firecrawl.py +0 -0
  34. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/scraping/no_bot_domains.txt +0 -0
  35. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/scraping/util.py +0 -0
  36. {scrapemm-0.3.3 → scrapemm-0.3.4}/scrapemm/util.py +0 -0
  37. {scrapemm-0.3.3 → scrapemm-0.3.4}/setup.cfg +0 -0
  38. {scrapemm-0.3.3 → scrapemm-0.3.4}/testing/test_utils.py +0 -0
@@ -4,4 +4,7 @@
4
4
  /dist/
5
5
  /config.yaml
6
6
  __pycache__/
7
- .DS_Store
7
+ .DS_Store
8
+ build/
9
+ .pytest_cache/
10
+ .claude/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: LLM-friendly scraper for media and text from social media and the open web.
5
5
  Author-email: Mark Rothermel <mark.rothermel@tu-darmstadt.de>
6
6
  License-Expression: Apache-2.0
@@ -54,8 +54,8 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl) a
54
54
  - ✅ Telegram
55
55
  - ✅ Bluesky
56
56
  - ✅ TikTok
57
- - ⚠️ Facebook (working only sometimes and only with yt-dlp and Decodo)
57
+ - YouTube (working sometimes)
58
+ - ⚠️ Facebook (done for videos but not for images yet)
58
59
  - ⚠️ Instagram (done for videos but not for images yet)
59
- - ⚠️ YouTube (working sometimes)
60
60
  - ⏳ Threads
61
61
  - ⏳ Reddit
@@ -30,8 +30,8 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl) a
30
30
  - ✅ Telegram
31
31
  - ✅ Bluesky
32
32
  - ✅ TikTok
33
- - ⚠️ Facebook (working only sometimes and only with yt-dlp and Decodo)
33
+ - YouTube (working sometimes)
34
+ - ⚠️ Facebook (done for videos but not for images yet)
34
35
  - ⚠️ Instagram (done for videos but not for images yet)
35
- - ⚠️ YouTube (working sometimes)
36
36
  - ⏳ Threads
37
37
  - ⏳ Reddit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapeMM"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "LLM-friendly scraper for media and text from social media and the open web."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.8"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: LLM-friendly scraper for media and text from social media and the open web.
5
5
  Author-email: Mark Rothermel <mark.rothermel@tu-darmstadt.de>
6
6
  License-Expression: Apache-2.0
@@ -54,8 +54,8 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl) a
54
54
  - ✅ Telegram
55
55
  - ✅ Bluesky
56
56
  - ✅ TikTok
57
- - ⚠️ Facebook (working only sometimes and only with yt-dlp and Decodo)
57
+ - YouTube (working sometimes)
58
+ - ⚠️ Facebook (done for videos but not for images yet)
58
59
  - ⚠️ Instagram (done for videos but not for images yet)
59
- - ⚠️ YouTube (working sometimes)
60
60
  - ⏳ Threads
61
61
  - ⏳ Reddit
@@ -1,55 +1,52 @@
1
1
  import logging
2
+ import re
2
3
  from urllib.parse import urlparse
4
+
3
5
  import aiohttp
4
- from ezmm import MultimodalSequence, download_image
6
+ from ezmm import MultimodalSequence
5
7
 
6
- from scrapemm.scraping.ytdlp import check_ytdlp_available, get_video_with_ytdlp
7
8
  from scrapemm.integrations.base import RetrievalIntegration
9
+ from scrapemm.scraping.ytdlp import get_content_with_ytdlp
8
10
  from scrapemm.util import get_domain
9
11
 
10
12
  logger = logging.getLogger("scrapeMM")
11
13
 
14
+ VIDEO_URL_REGEX = r"facebook\.com/\d+/videos/\d+/?"
15
+
12
16
 
13
17
  class Facebook(RetrievalIntegration):
14
18
  name = "Facebook"
15
19
  domains = ["facebook.com", "www.facebook.com"]
16
20
 
17
21
  async def _connect(self):
18
- # Check if yt-dlp is available
19
- self.ytdlp_available = check_ytdlp_available()
20
-
21
- if self.ytdlp_available:
22
- self.connected = True
23
- mode = "yt-dlp only"
24
- logger.info(f"✅ Facebook integration ready ({mode} mode).")
25
- else:
26
- self.connected = False
27
- logger.warning("❌ Facebook integration not available: Neither API credentials nor yt-dlp found.")
22
+ self.api_available = False # TODO
23
+ logger.info(f"✅ Facebook integration ready (yt-dlp only mode).")
24
+ self.connected = True
28
25
 
29
26
  async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
30
27
  """Retrieves content from a Facebook post URL."""
31
28
  if get_domain(url) not in self.domains:
32
29
  logger.error(f"❌ Invalid domain for Facebook: {get_domain(url)}")
33
30
  return None
34
-
35
- # Determine if this is a video or profile URL
31
+
32
+ # Determine if this is a video or photo URL, act accordingly
36
33
  if self._is_video_url(url):
37
34
  return await self._get_video(url, session)
38
35
  elif self._is_photo_url(url):
39
36
  return await self._get_photo(url, session)
40
- else:
41
- return await self._get_user_profile(url, session)
42
-
43
- async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
44
- """Retrieves content from a TikTok video URL."""
45
37
 
46
- # Fallback to yt-dlp mode
47
- if self.ytdlp_available:
48
- return await get_video_with_ytdlp(url, session, platform="Facebook")
38
+ # The URL is not indicative, so try all methods
39
+ return (await self._get_video(url, session) or
40
+ await self._get_photo(url, session) or
41
+ await self._get_user_profile(url, session))
42
+
43
+ async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
44
+ """Retrieves content from a Facebook video URL."""
45
+ if self.api_available:
46
+ raise NotImplementedError
47
+ else:
48
+ return await get_content_with_ytdlp(url, session, platform="Facebook")
49
49
 
50
- logger.error("❌ No available method to retrieve Facebook video.")
51
- return None
52
-
53
50
  async def _get_photo(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
54
51
  """Retrieves content from a Facebook photo URL."""
55
52
  logger.error("❌ No available method to retrieve Facebook photo.")
@@ -57,23 +54,15 @@ class Facebook(RetrievalIntegration):
57
54
 
58
55
  async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
59
56
  """Retrieves content from a Facebook user profile URL."""
60
- username = self._extract_username(url)
61
- if username:
62
- text = f"""**Facebook Profile**
63
- Username: @{username}
64
- URL: {url}
65
-
66
- Note: Profile details require Facebook API access.
67
- Configure API credentials for full profile information."""
68
- return MultimodalSequence([text])
69
-
57
+ logger.error("❌ No available method to retrieve Facebook profiles.")
70
58
  return None
71
59
 
72
60
  def _is_video_url(self, url: str) -> bool:
73
61
  """Checks if the URL is a Facebook video URL."""
74
62
  # video URLS are in the format: https://www.facebook.com/watch?v=VIDEO_ID
75
- return "facebook.com/watch" in url
76
-
63
+ # or Reels: https://www.facebook.com/reel/REEL_ID
64
+ return "facebook.com/watch" in url or "facebook.com/reel" in url or bool(re.search(VIDEO_URL_REGEX, url))
65
+
77
66
  def _extract_video_id(self, url: str) -> str:
78
67
  """Extracts the video ID from a Facebook video URL."""
79
68
  parsed_url = urlparse(url)
@@ -82,11 +71,11 @@ Configure API credentials for full profile information."""
82
71
  if param.startswith('v='):
83
72
  return param.split('=')[1]
84
73
  return ""
85
-
74
+
86
75
  def _is_photo_url(self, url: str) -> bool:
87
76
  """Checks if the URL is a Facebook photo URL."""
88
77
  return "facebook.com/photo" in url or "facebook.com/photos" in url
89
-
78
+
90
79
  def _extract_username(self, url: str) -> str:
91
80
  """Extracts the username from a Facebook profile URL."""
92
81
  # url format: https://www.facebook.com/username<?...>
@@ -1,10 +1,11 @@
1
1
  import logging
2
2
  from urllib.parse import urlparse
3
+
3
4
  import aiohttp
4
- from ezmm import MultimodalSequence, download_image
5
+ from ezmm import MultimodalSequence
5
6
 
6
- from scrapemm.scraping.ytdlp import check_ytdlp_available, get_video_with_ytdlp
7
7
  from scrapemm.integrations.base import RetrievalIntegration
8
+ from scrapemm.scraping.ytdlp import get_content_with_ytdlp
8
9
  from scrapemm.util import get_domain
9
10
 
10
11
  logger = logging.getLogger("scrapeMM")
@@ -15,44 +16,37 @@ class Instagram(RetrievalIntegration):
15
16
  domains = ["instagram.com", "www.instagram.com"]
16
17
 
17
18
  async def _connect(self):
18
- # Check if yt-dlp is available
19
- self.ytdlp_available = check_ytdlp_available()
20
-
21
- if self.ytdlp_available:
22
- self.connected = True
23
- mode = "yt-dlp only"
24
- logger.info(f"✅ Instagram integration ready ({mode} mode).")
25
- else:
26
- self.connected = False
27
- logger.warning("❌ Instagram integration not available: Neither API credentials nor yt-dlp found.")
19
+ self.api_available = False
20
+ logger.info(f"✅ Instagram integration ready (yt-dlp only mode).")
21
+ self.connected = True
28
22
 
29
23
  async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
30
24
  """Retrieves content from an Instagram post URL."""
31
25
  if get_domain(url) not in self.domains:
32
26
  logger.error(f"❌ Invalid domain for Instagram: {get_domain(url)}")
33
27
  return None
34
-
28
+
35
29
  # Determine if this is a video or profile URL
36
30
  if self._is_video_url(url):
37
31
  return await self._get_video(url, session)
38
32
  elif self._is_photo_url(url):
39
- return await self._get_photo(url, session)
33
+ # /p/ URLs can also be reels, so try both
34
+ return await self._get_video(url, session) or await self._get_photo(url, session)
40
35
  else:
41
36
  return await self._get_user_profile(url, session)
42
-
37
+
43
38
  async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
44
39
  """Retrieves content from an Instagram video URL."""
45
- if self.ytdlp_available:
46
- return await get_video_with_ytdlp(url, session, platform="Instagram")
40
+ if self.api_available:
41
+ raise NotImplementedError
42
+ else:
43
+ return await get_content_with_ytdlp(url, session, platform="Instagram")
47
44
 
48
- logger.error("❌ No available method to retrieve Instagram video.")
49
- return None
50
-
51
45
  async def _get_photo(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
52
- """Retrieves content from an Instagram photo URL."""
46
+ """Retrieves content from an Instagram photo URL (can also be a reel)."""
53
47
  logger.error("❌ No available method to retrieve Instagram photo.")
54
48
  return None
55
-
49
+
56
50
  async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
57
51
  """Retrieves content from an Instagram user profile URL."""
58
52
  username = self._extract_username(url)
@@ -15,7 +15,6 @@ from ezmm.common.items import Video, Image
15
15
  from tiktok_research_api import TikTokResearchAPI, QueryVideoRequest, QueryUserInfoRequest, Criteria, Query
16
16
 
17
17
  from scrapemm.integrations.base import RetrievalIntegration
18
- from scrapemm.scraping.ytdlp import check_ytdlp_available
19
18
  from scrapemm.secrets import get_secret
20
19
 
21
20
  logger = logging.getLogger("scrapeMM")
@@ -57,16 +56,9 @@ class TikTok(RetrievalIntegration):
57
56
  except Exception as e:
58
57
  logger.info(f"⚠️ TikTok Research API connection failed: {e}. Using fallback mode.")
59
58
 
60
- # Check if yt-dlp is available
61
- self.ytdlp_available = check_ytdlp_available()
62
-
63
- if self.api_available or self.ytdlp_available:
64
- self.connected = True
65
- mode = "API + yt-dlp" if self.api_available else "yt-dlp only"
66
- logger.info(f"✅ TikTok integration ready ({mode} mode).")
67
- else:
68
- self.connected = False
69
- logger.warning("❌ TikTok integration not available: Neither API credentials nor yt-dlp found.")
59
+ mode = "API" if self.api_available else "yt-dlp only"
60
+ logger.info(f"✅ TikTok integration ready ({mode} mode).")
61
+ self.connected = True
70
62
 
71
63
  async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
72
64
  # Determine if this is a video or profile URL
@@ -86,12 +78,9 @@ class TikTok(RetrievalIntegration):
86
78
  logger.warning("API method failed, falling back to yt-dlp...")
87
79
 
88
80
  # Fallback to yt-dlp mode
89
- if self.ytdlp_available:
81
+ else:
90
82
  return await self._get_video_with_ytdlp(url, session)
91
83
 
92
- logger.error("❌ No available method to retrieve TikTok video.")
93
- return None
94
-
95
84
  async def _get_video_with_api(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
96
85
  """Retrieves video using TikTok Research API."""
97
86
  video_id = self._extract_video_id(url)
@@ -4,7 +4,7 @@ from typing import Optional
4
4
  import aiohttp
5
5
  from ezmm import MultimodalSequence
6
6
 
7
- from scrapemm.scraping.ytdlp import get_video_with_ytdlp, check_ytdlp_available
7
+ from scrapemm.scraping.ytdlp import get_content_with_ytdlp
8
8
  from .base import RetrievalIntegration
9
9
 
10
10
  logger = logging.getLogger("scrapeMM")
@@ -15,20 +15,14 @@ class YouTube(RetrievalIntegration):
15
15
 
16
16
  name = "YouTube"
17
17
  domains = [
18
- "youtube.com",
19
- "www.youtube.com",
20
- "youtu.be",
21
- "m.youtube.com"
18
+ "youtube.com",
19
+ "youtu.be",
22
20
  ]
23
21
 
24
22
  async def _connect(self):
25
- self.connected = check_ytdlp_available()
26
- if not self.connected:
27
- logger.warning("❌ YouTube integration disabled: yt-dlp not available")
28
- else:
29
- logger.info("✅ YouTube integration enabled")
23
+ self.connected = True
30
24
 
31
25
  async def _get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
32
26
  """Downloads YouTube video or short using yt-dlp."""
33
27
  logger.debug(f"📺 Downloading YouTube content: {url}")
34
- return await get_video_with_ytdlp(url, session, "YouTube")
28
+ return await get_content_with_ytdlp(url, session, "YouTube")
@@ -0,0 +1,216 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Optional
4
+
5
+ import aiohttp
6
+ from ezmm import MultimodalSequence
7
+
8
+ from scrapemm.secrets import get_secret
9
+ from scrapemm.scraping.util import to_multimodal_sequence, get_domain_root
10
+
11
+ logger = logging.getLogger("scrapeMM")
12
+
13
+
14
+ class Decodo:
15
+ """Scrapes web content using Decodo's Web Scraping API with proxy support
16
+ and JavaScript rendering capabilities."""
17
+
18
+ DECODO_API_URL = "https://scraper-api.decodo.com/v2/scrape"
19
+
20
+ def __init__(self):
21
+ self.username = None
22
+ self.password = None
23
+ self.n_scrapes = 0
24
+
25
+ def _load_credentials(self):
26
+ """Loads Decodo credentials from the secrets manager."""
27
+ self.username = get_secret("decodo_username")
28
+ self.password = get_secret("decodo_password")
29
+
30
+ if self.username and self.password:
31
+ logger.info("✅ Decodo credentials loaded successfully.")
32
+ else:
33
+ logger.warning("⚠️ Decodo credentials not found. Please configure them in secrets.")
34
+
35
+ def _has_credentials(self) -> bool:
36
+ """Checks if Decodo credentials are available."""
37
+ return bool(self.username and self.password)
38
+
39
+ async def scrape(
40
+ self, url: str,
41
+ remove_urls: bool,
42
+ session: aiohttp.ClientSession,
43
+ format: str,
44
+ enable_js: bool = True,
45
+ timeout: int = 30,
46
+ ) -> Optional[MultimodalSequence | str]:
47
+ """Downloads the contents of the specified webpage using Decodo's API.
48
+
49
+ Args:
50
+ url: The URL to scrape
51
+ remove_urls: Whether to remove URLs from hyperlinks in the result
52
+ session: The aiohttp ClientSession to use
53
+ enable_js: Whether to enable JavaScript rendering (default: True)
54
+ timeout: Request timeout in seconds (default: 30)
55
+
56
+ Returns:
57
+ MultimodalSequence containing the scraped content, or None if scraping failed
58
+ """
59
+ if not self._has_credentials():
60
+ self._load_credentials()
61
+
62
+ if not self._has_credentials():
63
+ logger.warning("Cannot scrape with Decodo: credentials not configured.")
64
+ return None
65
+
66
+ # Try with JS rendering first if enabled
67
+ html = await self._call_decodo(url, session, enable_js, timeout)
68
+
69
+ # If it failed with JS and we got a 400 error, try without JS
70
+ # (400 might mean the plan doesn't support headless rendering)
71
+ if html is None and enable_js:
72
+ logger.debug("Retrying without JavaScript rendering...")
73
+ html = await self._call_decodo(url, session, enable_js=False, timeout=timeout)
74
+
75
+ if html:
76
+ if format == "html":
77
+ return html
78
+ else:
79
+ domain_root = get_domain_root(url)
80
+ return await to_multimodal_sequence(html, remove_urls=remove_urls, session=session, domain_root=domain_root)
81
+ return None
82
+
83
+ async def _call_decodo(
84
+ self, url: str,
85
+ session: aiohttp.ClientSession,
86
+ enable_js: bool = True,
87
+ timeout: int = 30,
88
+ max_retries: int = 5
89
+ ) -> Optional[str]:
90
+ """Calls the Decodo API to scrape the given URL with exponential backoff retry logic.
91
+
92
+ Args:
93
+ url: The URL to scrape
94
+ session: The aiohttp ClientSession to use
95
+ enable_js: Whether to enable JavaScript rendering
96
+ timeout: Request timeout in seconds
97
+ max_retries: Maximum number of retry attempts for rate limits (default: 5)
98
+
99
+ Returns:
100
+ HTML content as a string, or None if scraping failed
101
+ """
102
+ headers = {
103
+ 'Content-Type': 'application/json',
104
+ }
105
+
106
+ # Build request payload
107
+ # Note: For simple URL scraping, we just provide the URL
108
+ # The "target" parameter is only used for specific templates like "google_search"
109
+ payload = {
110
+ "url": url,
111
+ }
112
+
113
+ # Enable JavaScript rendering if requested
114
+ # Note: This requires an Advanced plan subscription
115
+ if enable_js:
116
+ payload["headless"] = "html"
117
+
118
+ # Create basic auth
119
+ auth = aiohttp.BasicAuth(self.username, self.password)
120
+
121
+ # Retry loop with exponential backoff
122
+ for attempt in range(max_retries + 1):
123
+ try:
124
+ async with session.post(
125
+ self.DECODO_API_URL,
126
+ json=payload,
127
+ headers=headers,
128
+ auth=auth,
129
+ timeout=aiohttp.ClientTimeout(total=timeout)
130
+ ) as response:
131
+ # Validate response health
132
+ if response.status != 200:
133
+ logger.debug("Communication with Decodo API failed.")
134
+
135
+ # Handle rate limiting and 613 errors with retry
136
+ if response.status in (429, 613):
137
+ if attempt < max_retries:
138
+ # Exponential backoff: 2^attempt seconds (1s, 2s, 4s, 8s, 16s)
139
+ wait_time = 2 ** attempt
140
+ error_msg = "Rate limit exceeded" if response.status == 429 else "Error 613"
141
+ logger.warning(f"Error {response.status}: {error_msg}. Retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})...")
142
+ await asyncio.sleep(wait_time)
143
+ continue
144
+ else:
145
+ logger.warning(f"Error {response.status}: Maximum retries reached.")
146
+ return None
147
+
148
+ # Log other errors
149
+ match response.status:
150
+ case 400:
151
+ logger.debug("Error 400: Bad request. If you use JavaScript, make sure you have the "
152
+ "Advanced plan subscription.")
153
+ case 401:
154
+ logger.error("Error 401: Unauthorized. Check your Decodo credentials.")
155
+ case 402:
156
+ logger.error("Error 402: Payment required. Check your Decodo subscription.")
157
+ case 403:
158
+ logger.debug("Error 403: Forbidden.")
159
+ case 408:
160
+ logger.warning("Error 408: Timeout! Website did not respond in time.")
161
+ case 500:
162
+ logger.debug("Error 500: Server error.")
163
+ case _:
164
+ logger.debug(f"Error {response.status}: {response.reason}.")
165
+
166
+ return None
167
+
168
+ # Parse response
169
+ json_response = await response.json()
170
+
171
+ # Validate if scrape was successful
172
+ if json_response.get("status") == "failed":
173
+ status_code = json_response.get("status_code")
174
+ message = json_response.get("message")
175
+ logger.info(f"Decodo failed to scrape. Error {status_code}: {message}")
176
+ return None
177
+
178
+ # Extract HTML content from results
179
+ if "results" in json_response and len(json_response["results"]) > 0:
180
+ result = json_response["results"][0]
181
+
182
+ # Check status code from the actual request
183
+ status_code = result.get("status_code")
184
+ if status_code and status_code >= 400:
185
+ logger.warning(f"Target website returned status {status_code} for {url}")
186
+ return None
187
+
188
+ html_content = result.get("content")
189
+ if html_content:
190
+ self.n_scrapes += 1
191
+ logger.debug(f"Successfully scraped {url} with Decodo (scrape #{self.n_scrapes})")
192
+ return html_content
193
+ else:
194
+ logger.warning(f"No content in Decodo response for {url}")
195
+ return None
196
+ else:
197
+ logger.warning(f"No results in Decodo response for {url}")
198
+ logger.debug(f"Response: {json_response}")
199
+ return None
200
+
201
+ except aiohttp.ClientError as e:
202
+ logger.error(f"Network error while scraping {url} with Decodo: {repr(e)}")
203
+ return None
204
+ except asyncio.TimeoutError:
205
+ logger.warning(f"Timeout while scraping {url} with Decodo. Skipping...")
206
+ return None
207
+ except Exception as e:
208
+ logger.error(f"Unexpected error while scraping {url} with Decodo: {repr(e)}")
209
+ return None
210
+
211
+ # Should not reach here, but return None as fallback
212
+ return None
213
+
214
+
215
+ # Create a singleton instance
216
+ decodo = Decodo()
@@ -0,0 +1,114 @@
1
+ import logging
2
+ import tempfile
3
+ import traceback
4
+ from datetime import datetime
5
+ from typing import Any, Optional
6
+
7
+ import asyncio
8
+ import aiohttp
9
+ from ezmm import MultimodalSequence, download_image, Video, Image
10
+ from yt_dlp import YoutubeDL
11
+
12
+ logger = logging.getLogger("scrapeMM")
13
+
14
+
15
+ async def _download_with_ytdlp(
16
+ url: str,
17
+ session: aiohttp.ClientSession
18
+ ) -> tuple[Optional[Video], Optional[Image], Optional[dict[str, Any]]]:
19
+ """Downloads a video, its thumbnail, and the metadata using yt-dlp."""
20
+ try:
21
+ with tempfile.NamedTemporaryFile() as temp_file:
22
+ temp_path = temp_file.name
23
+
24
+ ydl_opts: dict[str, Any] = dict(
25
+ outtmpl=f'{temp_path}.%(ext)s', # Output filename format
26
+ format='best[ext=mp4]/best', # Download the best video/audio quality
27
+ quiet=False, # Show progress in terminal
28
+ noplaylist=True, # Disable playlist downloading
29
+ retries=3,
30
+ cookies_from_browser='firefox',
31
+ cookies='cookies.txt', # Use cookies from Firefox to bypass sign-in requirements
32
+ )
33
+
34
+ if "youtube" in url or "youtu.be" in url:
35
+ # YouTube delivers video and audio separately when downloaded above 720p.
36
+ # This would require FFmpeg to merge them. Restrict to 720p to avoid that.
37
+ ydl_opts['format'] = 'best[height<=720][acodec!=none]'
38
+ ydl_opts['extractor_args'] = dict(youtube=dict(player_client=["default"]))
39
+
40
+ with YoutubeDL(ydl_opts) as ydl:
41
+ metadata = ydl.extract_info(url, download=True)
42
+
43
+ ext = metadata.get("ext")
44
+
45
+ try:
46
+ video = Video(file_path=temp_path + f".{ext}", source_url=url)
47
+ video.relocate(move_not_copy=True)
48
+ except Exception as e:
49
+ logger.warning(f"Could not load downloaded video: {e}\n{traceback.format_exc()}")
50
+ video = None
51
+
52
+ thumbnail = None
53
+ if thumbnail_url := metadata.get('thumbnail'):
54
+ thumbnail = await download_image(thumbnail_url, session)
55
+
56
+ return video, thumbnail, metadata
57
+
58
+ except Exception as e:
59
+ logger.warning(f"Could not download video with yt-dlp: {e}\n{traceback.format_exc()}")
60
+ return None, None, None
61
+
62
+
63
+ def fmt_count(v):
64
+ return f"{v:,}" if isinstance(v, int) else "Unknown"
65
+
66
+
67
+ async def compose_data_to_sequence(metadata: dict, video: Video | None, thumbnail: Image | None,
68
+ platform: str) -> MultimodalSequence:
69
+ """Creates a MultimodalSequence from the yt-dlp metadata."""
70
+ # title = metadata.get('title', '')
71
+ uploader = metadata.get('uploader', 'Unknown')
72
+ upload_date = metadata.get('upload_date', '')
73
+ duration = metadata.get('duration', 0)
74
+ view_count = metadata.get('view_count', 0)
75
+ like_count = metadata.get('like_count', 0)
76
+ comment_count = metadata.get('comment_count', 0)
77
+ description = metadata.get('description', '')
78
+
79
+ # Format upload date
80
+ formatted_date = upload_date
81
+ if upload_date and len(upload_date) == 8:
82
+ try:
83
+ date_obj = datetime.strptime(upload_date, '%Y%m%d')
84
+ formatted_date = date_obj.strftime('%Y-%m-%d')
85
+ except ValueError:
86
+ pass
87
+
88
+ text = f"""**{platform} Video**
89
+ Author: @{uploader}
90
+ Posted: {formatted_date}
91
+ Duration: {duration}s
92
+ Views: {fmt_count(view_count)} - Likes: {fmt_count(like_count)} - Comments: {fmt_count(comment_count)}
93
+
94
+ {description}"""
95
+
96
+ items: list = [text]
97
+ if thumbnail:
98
+ items.append(thumbnail)
99
+ if video:
100
+ items.append(video)
101
+
102
+ return MultimodalSequence(items)
103
+
104
+
105
+ async def get_content_with_ytdlp(url: str, session: aiohttp.ClientSession, platform: str) -> MultimodalSequence | None:
106
+ """Retrieves video, thumbnail, and metadata using the powerful yt-dlp package."""
107
+ # Run the download in a separate thread to avoid blocking the event loop
108
+ coroutine = await asyncio.to_thread(_download_with_ytdlp, url, session)
109
+ video, thumbnail, metadata = await coroutine
110
+ if not video:
111
+ logger.warning(f"Video download failed for {url}")
112
+ if metadata:
113
+ return await compose_data_to_sequence(metadata, video, thumbnail, platform)
114
+ return None
@@ -20,8 +20,8 @@ SECRETS = {
20
20
  "bluesky_password": "Bluesky password",
21
21
  "tiktok_client_key": "TikTok client key",
22
22
  "tiktok_client_secret": "TikTok client secret",
23
- "decodo_username": "Decodo API username",
24
- "decodo_password": "Decodo API password",
23
+ "decodo_username": "Decodo Web Scraping API username",
24
+ "decodo_password": "Decodo Web Scraping API password",
25
25
  }
26
26
 
27
27
  SALT = b'\xa4\x93\xf1\x88\x13\x88'
@@ -0,0 +1,7 @@
1
+ from scrapemm import retrieve
2
+ import asyncio
3
+
4
+ if __name__ == "__main__":
5
+ url = "https://www.youtube.com/shorts/cE0zgN6pYOc"
6
+ result = asyncio.run(retrieve(url))
7
+ print(result)
@@ -12,6 +12,7 @@ from scrapemm import retrieve
12
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/",
13
13
  "https://factuel.afp.com/doc.afp.com.43ZN7NP",
14
14
  "https://leadstories.com/365cb414b83e29d26fecae374d55c743a3eac4c7.png",
15
+ "https://leadstories.com/assets_c/2025/08/193f14f06dd6f15b89bf8050e553ad7fb1be6530-thumb-900xauto-3165872.png"
15
16
  ])
16
17
  @pytest.mark.parametrize("method", ["firecrawl", "decodo"])
17
18
  async def test_generic_retrieval(url, method):
@@ -44,7 +45,7 @@ async def test_html_retrieval(url, method):
44
45
  "https://t.me/tglobaleye/6289", # GIF (treated as video)
45
46
  "https://t.me/tglobaleye/16192", # Images and video
46
47
  ])
47
- async def test_telegram_retrieval(url):
48
+ async def test_telegram(url):
48
49
  result = await retrieve(url)
49
50
  print(result)
50
51
  assert result
@@ -55,7 +56,7 @@ async def test_telegram_retrieval(url):
55
56
  "https://www.tiktok.com/@realdonaldtrump/video/7433870905635409198",
56
57
  "https://www.tiktok.com/@xxxx.xxxx5743/video/7521704371109793046"
57
58
  ])
58
- async def test_tiktok_retrieval(url):
59
+ async def test_tiktok(url):
59
60
  result = await retrieve(url)
60
61
  print(result)
61
62
  assert result
@@ -66,7 +67,7 @@ async def test_tiktok_retrieval(url):
66
67
  "https://x.com/PopBase/status/1938496291908030484",
67
68
  "https://x.com/realDonaldTrump"
68
69
  ])
69
- async def test_x_retrieval(url):
70
+ async def test_x(url):
70
71
  result = await retrieve(url)
71
72
  print(result)
72
73
  assert result
@@ -74,35 +75,60 @@ async def test_x_retrieval(url):
74
75
 
75
76
  @pytest.mark.asyncio
76
77
  @pytest.mark.parametrize("url", [
77
- "https://www.instagram.com/p/CqJDbyOP839", # image (post)
78
- "https://www.instagram.com/reel/DKqPQqpTDW4", # video (Reel)
79
- "https://www.instagram.com/reel/C75nh7Lvo8F", # video (Reel)
80
- "https://www.instagram.com/p/DMuOe6th94D", # video (Reel)
78
+ "https://www.instagram.com/p/CqJDbyOP839",
81
79
  ])
82
- async def test_instagram_retrieval(url):
80
+ async def test_instagram_images(url):
83
81
  result = await retrieve(url)
84
82
  print(result)
85
83
  assert result
84
+ assert result.has_images()
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ @pytest.mark.parametrize("url", [
89
+ "https://www.instagram.com/reel/DKqPQqpTDW4",
90
+ "https://www.instagram.com/reel/C75nh7Lvo8F",
91
+ "https://www.instagram.com/p/DMuOe6th94D", # yes, this is a video
92
+ ])
93
+ async def test_instagram_videos(url):
94
+ result = await retrieve(url)
95
+ print(result)
96
+ assert result
97
+ assert result.has_videos()
86
98
 
87
99
 
88
100
  @pytest.mark.asyncio
89
101
  @pytest.mark.parametrize("url", [
90
- "https://www.facebook.com/reel/1089214926521000", # video
91
- "https://www.facebook.com/reel/3466446073497470", # video, restricted for misinformation
92
- "https://www.facebook.com/groups/1973976962823632/posts/3992825270938781/", # video, restricted for misinformation
93
- "https://www.facebook.com/photo/?fbid=1721085455188778&set=a.107961589834514&_rdc=1&_rdr", # image
102
+ "https://www.facebook.com/reel/1089214926521000",
103
+ "https://www.facebook.com/reel/3466446073497470", # restricted for misinformation
104
+ "https://www.facebook.com/61561558177010/videos/1445957793080961/",
105
+ "https://www.facebook.com/watch/?v=1445957793080961",
106
+ "https://www.facebook.com/groups/1973976962823632/posts/3992825270938781/", # restricted for misinformation, yt-dlp fails here
94
107
  ])
95
- async def test_facebook_retrieval(url):
108
+ async def test_facebook_videos(url):
96
109
  result = await retrieve(url)
97
110
  print(result)
98
111
  assert result
112
+ assert result.has_videos()
113
+
114
+
115
+ @pytest.mark.asyncio
116
+ @pytest.mark.parametrize("url", [
117
+ "https://www.facebook.com/photo/?fbid=1721085455188778&set=a.107961589834514&_rdc=1&_rdr",
118
+ ])
119
+ async def test_facebook_images(url):
120
+ result = await retrieve(url)
121
+ print(result)
122
+ assert result
123
+ assert result.has_images()
99
124
 
100
125
 
101
126
  @pytest.mark.asyncio
102
127
  @pytest.mark.parametrize("url", [
103
- "https://www.youtube.com/watch?v=ykQZPdaXTJc",
128
+ "https://www.youtube.com/watch?v=A4dVOznX6Kk",
129
+ "https://www.youtube.com/shorts/cE0zgN6pYOc",
104
130
  ])
105
- async def test_youtube_retrieval(url):
131
+ async def test_youtube(url):
106
132
  result = await retrieve(url)
107
133
  print(result)
108
134
  assert result
@@ -1,192 +0,0 @@
1
- import asyncio
2
- import logging
3
- from typing import Optional
4
-
5
- import aiohttp
6
- from ezmm import MultimodalSequence
7
-
8
- from scrapemm.secrets import get_secret
9
- from scrapemm.scraping.util import to_multimodal_sequence, get_domain_root
10
-
11
- logger = logging.getLogger("scrapeMM")
12
-
13
-
14
- class Decodo:
15
- """Scrapes web content using Decodo's Web Scraping API with proxy support
16
- and JavaScript rendering capabilities."""
17
-
18
- DECODO_API_URL = "https://scraper-api.decodo.com/v2/scrape"
19
-
20
- def __init__(self):
21
- self.username = None
22
- self.password = None
23
- self.n_scrapes = 0
24
-
25
- def _load_credentials(self):
26
- """Loads Decodo credentials from the secrets manager."""
27
- self.username = get_secret("decodo_username")
28
- self.password = get_secret("decodo_password")
29
-
30
- if self.username and self.password:
31
- logger.info("✅ Decodo credentials loaded successfully.")
32
- else:
33
- logger.warning("⚠️ Decodo credentials not found. Please configure them in secrets.")
34
-
35
- def _has_credentials(self) -> bool:
36
- """Checks if Decodo credentials are available."""
37
- return bool(self.username and self.password)
38
-
39
- async def scrape(self, url: str,
40
- remove_urls: bool,
41
- session: aiohttp.ClientSession,
42
- format: str,
43
- enable_js: bool = True,
44
- timeout: int = 30) -> Optional[MultimodalSequence | str]:
45
- """Downloads the contents of the specified webpage using Decodo's API.
46
-
47
- Args:
48
- url: The URL to scrape
49
- remove_urls: Whether to remove URLs from hyperlinks in the result
50
- session: The aiohttp ClientSession to use
51
- enable_js: Whether to enable JavaScript rendering (default: True)
52
- timeout: Request timeout in seconds (default: 30)
53
-
54
- Returns:
55
- MultimodalSequence containing the scraped content, or None if scraping failed
56
- """
57
- if not self._has_credentials():
58
- self._load_credentials()
59
-
60
- if not self._has_credentials():
61
- logger.warning("Cannot scrape with Decodo: credentials not configured.")
62
- return None
63
-
64
- # Try with JS rendering first if enabled
65
- html = await self._call_decodo(url, session, enable_js, timeout)
66
-
67
- # If it failed with JS and we got a 400 error, try without JS
68
- # (400 might mean the plan doesn't support headless rendering)
69
- if html is None and enable_js:
70
- logger.debug("Retrying without JavaScript rendering...")
71
- html = await self._call_decodo(url, session, enable_js=False, timeout=timeout)
72
-
73
- if html:
74
- if format == "html":
75
- return html
76
- else:
77
- domain_root = get_domain_root(url)
78
- return await to_multimodal_sequence(html, remove_urls=remove_urls, session=session, domain_root=domain_root)
79
- return None
80
-
81
- async def _call_decodo(self, url: str,
82
- session: aiohttp.ClientSession,
83
- enable_js: bool = True,
84
- timeout: int = 30) -> Optional[str]:
85
- """Calls the Decodo API to scrape the given URL.
86
-
87
- Args:
88
- url: The URL to scrape
89
- session: The aiohttp ClientSession to use
90
- enable_js: Whether to enable JavaScript rendering
91
- timeout: Request timeout in seconds
92
-
93
- Returns:
94
- HTML content as a string, or None if scraping failed
95
- """
96
- headers = {
97
- 'Content-Type': 'application/json',
98
- }
99
-
100
- # Build request payload
101
- # Note: For simple URL scraping, we just provide the URL
102
- # The "target" parameter is only used for specific templates like "google_search"
103
- payload = {
104
- "url": url,
105
- }
106
-
107
- # Enable JavaScript rendering if requested
108
- # Note: This requires an Advanced plan subscription
109
- if enable_js:
110
- payload["headless"] = "html"
111
-
112
- # Create basic auth
113
- auth = aiohttp.BasicAuth(self.username, self.password)
114
-
115
- try:
116
- async with session.post(
117
- self.DECODO_API_URL,
118
- json=payload,
119
- headers=headers,
120
- auth=auth,
121
- timeout=aiohttp.ClientTimeout(total=timeout)
122
- ) as response:
123
- # Validate response health
124
- if response.status != 200:
125
- logger.debug("Communication with Decodo API failed.")
126
- match response.status:
127
- case 400:
128
- logger.debug("Error 400: Bad request. If you use JavaScript, make sure you have the "
129
- "Advanced plan subscription.")
130
- case 401:
131
- logger.error("Error 401: Unauthorized. Check your Decodo credentials.")
132
- case 402:
133
- logger.error("Error 402: Payment required. Check your Decodo subscription.")
134
- case 403:
135
- logger.debug("Error 403: Forbidden.")
136
- case 408:
137
- logger.warning("Error 408: Timeout! Website did not respond in time.")
138
- case 429:
139
- logger.warning("Error 429: Rate limit exceeded. Too many requests.")
140
- case 500:
141
- logger.debug("Error 500: Server error.")
142
- case _:
143
- logger.debug(f"Error {response.status}: {response.reason}.")
144
-
145
- return None
146
-
147
- # Parse response
148
- json_response = await response.json()
149
-
150
- # Validate if scrape was successful
151
- if json_response.get("status") == "failed":
152
- status_code = json_response.get("status_code")
153
- message = json_response.get("message")
154
- logger.info(f"Decodo failed to scrape. Error {status_code}: {message}")
155
- return None
156
-
157
- # Extract HTML content from results
158
- if "results" in json_response and len(json_response["results"]) > 0:
159
- result = json_response["results"][0]
160
-
161
- # Check status code from the actual request
162
- status_code = result.get("status_code")
163
- if status_code and status_code >= 400:
164
- logger.warning(f"Target website returned status {status_code} for {url}")
165
- return None
166
-
167
- html_content = result.get("content")
168
- if html_content:
169
- self.n_scrapes += 1
170
- logger.debug(f"Successfully scraped {url} with Decodo (scrape #{self.n_scrapes})")
171
- return html_content
172
- else:
173
- logger.warning(f"No content in Decodo response for {url}")
174
- return None
175
- else:
176
- logger.warning(f"No results in Decodo response for {url}")
177
- logger.debug(f"Response: {json_response}")
178
- return None
179
-
180
- except aiohttp.ClientError as e:
181
- logger.error(f"Network error while scraping {url} with Decodo: {repr(e)}")
182
- return None
183
- except asyncio.TimeoutError:
184
- logger.warning(f"Timeout while scraping {url} with Decodo. Skipping...")
185
- return None
186
- except Exception as e:
187
- logger.error(f"Unexpected error while scraping {url} with Decodo: {repr(e)}")
188
- return None
189
-
190
-
191
- # Create a singleton instance
192
- decodo = Decodo()
@@ -1,203 +0,0 @@
1
- import sys
2
- from typing import Any, cast
3
-
4
- import aiohttp
5
- import asyncio
6
- from datetime import datetime
7
- import json
8
- import logging
9
- import os
10
- import subprocess
11
- import tempfile
12
-
13
- from ezmm import MultimodalSequence, download_image
14
- from ezmm.common.items import Video, Image
15
-
16
- logger = logging.getLogger("scrapeMM")
17
-
18
-
19
- def check_ytdlp_available() -> bool:
20
- """Returns True if yt-dlp is available, else False."""
21
- try:
22
- # Run yt-dlp --version to check if it's installed and working'
23
- subprocess.run([sys.executable, '-m', 'yt_dlp', '--version'],
24
- capture_output=True, check=True, timeout=5)
25
- return True
26
- except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e:
27
- return False
28
-
29
-
30
- async def extract_metadata_with_ytdlp(url: str) -> dict[str, Any] | None:
31
- """Extracts metadata using yt-dlp without downloading the video."""
32
- try:
33
- cmd = [
34
- sys.executable, # Ensure to use the same Python interpreter as the calling process
35
- '-m',
36
- 'yt_dlp',
37
- '--no-download',
38
- '--print-json',
39
- '--no-warnings',
40
- '--quiet',
41
- url
42
- ]
43
-
44
- process = await asyncio.create_subprocess_exec(
45
- *cmd,
46
- stdout=asyncio.subprocess.PIPE,
47
- stderr=asyncio.subprocess.PIPE
48
- )
49
-
50
- stdout, stderr = await process.communicate()
51
-
52
- if process.returncode != 0:
53
- logger.error(f"❌ yt-dlp metadata extraction failed: {stderr.decode()}")
54
- return None
55
-
56
- # Parse JSON output
57
- metadata = json.loads(stdout.decode())
58
- return metadata
59
-
60
- except Exception as e:
61
- logger.error(f"❌ Error extracting metadata with yt-dlp: {e}")
62
- return None
63
-
64
-
65
- async def download_video_with_ytdlp(url: str) -> Video | None:
66
- """Downloads a video using yt-dlp."""
67
- try:
68
- with tempfile.NamedTemporaryFile(suffix='.%(ext)s', delete=False) as temp_file:
69
- temp_path = temp_file.name
70
-
71
- cmd = [
72
- sys.executable, # Ensure to use the same Python interpreter as the calling process
73
- "-m",
74
- "yt_dlp",
75
- "--no-playlist",
76
- "--no-warnings",
77
- "--quiet",
78
- "--retries", "10",
79
- ]
80
-
81
- if "youtube" in url or "youtu.be" in url:
82
- cmd.extend([
83
- "-f", "bv*[vcodec~='^avc1']+ba/bv*+ba/b",
84
- "--merge-output-format", "mp4",
85
- ])
86
- else:
87
- cmd.extend(['--format', 'best[ext=mp4]/best'])
88
-
89
- cmd.extend([
90
- "--output", temp_path,
91
- url
92
- ])
93
-
94
- process = await asyncio.create_subprocess_exec(
95
- *cmd,
96
- stdout=asyncio.subprocess.PIPE,
97
- stderr=asyncio.subprocess.PIPE
98
- )
99
-
100
- _, stderr = await process.communicate()
101
-
102
- if process.returncode != 0:
103
- logger.warning(f"yt-dlp video download failed: {stderr.decode()}")
104
- return None
105
-
106
- # Find the actual downloaded file (yt-dlp changes extension)
107
- downloaded_file = None
108
- for ext in ['.mp4', '.webm', '.mkv']:
109
- potential_file = temp_path.replace('.%(ext)s', ext)
110
- if os.path.exists(potential_file) and os.path.getsize(potential_file) > 0:
111
- downloaded_file = potential_file
112
- break
113
-
114
- if downloaded_file and os.path.exists(downloaded_file):
115
- with open(downloaded_file, 'rb') as f:
116
- video_data = f.read()
117
-
118
- os.unlink(downloaded_file)
119
-
120
- video = Video(binary_data=video_data, source_url=url)
121
- video.relocate(move_not_copy=True)
122
- return cast(Video, video)
123
-
124
- return None
125
-
126
- except Exception as e:
127
- logger.error(f"❌ Error downloading video with yt-dlp: {e}")
128
- return None
129
-
130
-
131
- async def download_thumbnail_with_ytdlp(url: str, session: aiohttp.ClientSession) -> Image | None:
132
- """Downloads thumbnail using yt-dlp metadata."""
133
- try:
134
- metadata = await extract_metadata_with_ytdlp(url)
135
- if not metadata:
136
- return None
137
-
138
- thumbnail_url = metadata.get('thumbnail')
139
- if thumbnail_url:
140
- return await download_image(thumbnail_url, session)
141
-
142
- except Exception as e:
143
- logger.error(f"❌ Error downloading thumbnail: {e}")
144
-
145
- return None
146
-
147
- def fmt_count(v):
148
- return f"{v:,}" if isinstance(v, int) else "Unknown"
149
-
150
- async def create_video_sequence_from_ytdlp(metadata: dict, url: str, video: Video | None, thumbnail: Image | None, platform: str) -> MultimodalSequence:
151
- """Creates MultimodalSequence from yt-dlp metadata."""
152
- # title = metadata.get('title', '')
153
- uploader = metadata.get('uploader', 'Unknown')
154
- upload_date = metadata.get('upload_date', '')
155
- duration = metadata.get('duration', 0)
156
- view_count = metadata.get('view_count', 0)
157
- like_count = metadata.get('like_count', 0)
158
- comment_count = metadata.get('comment_count', 0)
159
- description = metadata.get('description', '')
160
-
161
- # Format upload date
162
- formatted_date = upload_date
163
- if upload_date and len(upload_date) == 8:
164
- try:
165
- date_obj = datetime.strptime(upload_date, '%Y%m%d')
166
- formatted_date = date_obj.strftime('%Y-%m-%d')
167
- except ValueError:
168
- pass
169
-
170
- text = f"""**{platform} Video**
171
- Author: @{uploader}
172
- Posted: {formatted_date}
173
- Duration: {duration}s
174
- Views: {fmt_count(view_count)} - Likes: {fmt_count(like_count)} - Comments: {fmt_count(comment_count)}
175
-
176
- {description}"""
177
-
178
- items: list = [text]
179
- if thumbnail:
180
- items.append(thumbnail)
181
- if video:
182
- items.append(video)
183
-
184
- return MultimodalSequence(items)
185
-
186
- async def get_video_with_ytdlp(url: str, session: aiohttp.ClientSession, platform: str) -> MultimodalSequence | None:
187
- """Retrieves video using only yt-dlp (no API required)."""
188
- try:
189
- # Get metadata and video using yt-dlp
190
- metadata = await extract_metadata_with_ytdlp(url)
191
- if not metadata:
192
- return None
193
-
194
- video = await download_video_with_ytdlp(url)
195
- thumbnail = await download_thumbnail_with_ytdlp(url, session)
196
-
197
- return await create_video_sequence_from_ytdlp(metadata, url, video, thumbnail, platform)
198
-
199
- except Exception as e:
200
- import traceback
201
- traceback.print_exc()
202
- logger.error(f"❌ Error retrieving video with yt-dlp: {e}")
203
- return None
@@ -1,7 +0,0 @@
1
- from scrapemm import retrieve
2
- import asyncio
3
-
4
- if __name__ == "__main__":
5
- url = "https://factuel.afp.com/doc.afp.com.43ZN7NP"
6
- result = asyncio.run(retrieve(url, methods=["decodo"]))
7
- print(result)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes