scrapeMM 0.3.4__tar.gz → 0.3.6__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 (36) hide show
  1. {scrapemm-0.3.4/scrapeMM.egg-info → scrapemm-0.3.6}/PKG-INFO +1 -1
  2. {scrapemm-0.3.4 → scrapemm-0.3.6}/pyproject.toml +1 -1
  3. {scrapemm-0.3.4 → scrapemm-0.3.6}/requirements.txt +1 -1
  4. {scrapemm-0.3.4 → scrapemm-0.3.6/scrapeMM.egg-info}/PKG-INFO +1 -1
  5. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/__init__.py +2 -2
  6. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/base.py +3 -4
  7. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/bluesky.py +2 -1
  8. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/fb.py +13 -15
  9. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/instagram.py +13 -9
  10. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/telegram.py +1 -1
  11. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/tiktok.py +6 -3
  12. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/x.py +2 -1
  13. scrapemm-0.3.6/scrapemm/integrations/youtube.py +43 -0
  14. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/retrieval.py +29 -5
  15. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/ytdlp.py +35 -20
  16. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/secrets.py +34 -19
  17. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/util.py +6 -0
  18. {scrapemm-0.3.4 → scrapemm-0.3.6}/scripts/example.py +1 -1
  19. {scrapemm-0.3.4 → scrapemm-0.3.6}/testing/test_retrieval.py +17 -0
  20. scrapemm-0.3.4/scrapemm/integrations/youtube.py +0 -28
  21. {scrapemm-0.3.4 → scrapemm-0.3.6}/.gitignore +0 -0
  22. {scrapemm-0.3.4 → scrapemm-0.3.6}/LICENSE +0 -0
  23. {scrapemm-0.3.4 → scrapemm-0.3.6}/README.md +0 -0
  24. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/SOURCES.txt +0 -0
  25. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/dependency_links.txt +0 -0
  26. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/requires.txt +0 -0
  27. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/top_level.txt +0 -0
  28. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/__init__.py +0 -0
  29. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/common.py +0 -0
  30. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/__init__.py +0 -0
  31. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/decodo.py +0 -0
  32. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/firecrawl.py +0 -0
  33. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/no_bot_domains.txt +0 -0
  34. {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/util.py +0 -0
  35. {scrapemm-0.3.4 → scrapemm-0.3.6}/setup.cfg +0 -0
  36. {scrapemm-0.3.4 → scrapemm-0.3.6}/testing/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.3.4
3
+ Version: 0.3.6
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapeMM"
3
- version = "0.3.4"
3
+ version = "0.3.6"
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"
@@ -2,7 +2,7 @@ pytest~=8.4.1
2
2
  aiohttp~=3.12.13
3
3
  numpy~=1.26.4
4
4
  requests~=2.32.4
5
- ezMM~=0.3.4
5
+ ezMM~=0.3.9
6
6
  Telethon~=1.40.0
7
7
  PyYAML~=6.0.1
8
8
  tweepy~=4.16.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.3.4
3
+ Version: 0.3.6
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
@@ -18,9 +18,9 @@ DOMAIN_TO_INTEGRATION = {domain: integration
18
18
  for domain in integration.domains}
19
19
 
20
20
 
21
- async def retrieve_via_integration(url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
21
+ async def retrieve_via_integration(url: str, **kwargs) -> Optional[MultimodalSequence]:
22
22
  domain = get_domain(url)
23
23
  if domain in DOMAIN_TO_INTEGRATION:
24
24
  integration = DOMAIN_TO_INTEGRATION[domain]
25
25
  if integration.connected or integration.connected is None:
26
- return await integration.get(url, session)
26
+ return await integration.get(url, **kwargs)
@@ -1,7 +1,6 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from typing import Optional
3
3
 
4
- import aiohttp
5
4
  from ezmm import MultimodalSequence
6
5
 
7
6
  from scrapemm.common import logger
@@ -22,7 +21,7 @@ class RetrievalIntegration(ABC):
22
21
  Must set self.connet = True if connection was successful, else False."""
23
22
  raise NotImplementedError
24
23
 
25
- async def get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
24
+ async def get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
26
25
  """Ensures connectivity before invoking the service for retrieval."""
27
26
  assert get_domain(url) in self.domains
28
27
  if self.connected is None:
@@ -31,9 +30,9 @@ class RetrievalIntegration(ABC):
31
30
  logger.warning(f"❌ Connection to {self.name} service could not be established.")
32
31
  if self.connected:
33
32
  logger.debug(f"Calling {self.name} service for {url}")
34
- return await self._get(url, session)
33
+ return await self._get(url, **kwargs)
35
34
 
36
35
  @abstractmethod
37
- async def _get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
36
+ async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
38
37
  """Retrieves the contents present at the given URL."""
39
38
  raise NotImplementedError
@@ -28,11 +28,12 @@ class Bluesky(RetrievalIntegration):
28
28
  self.client = Client()
29
29
  self._authenticate()
30
30
 
31
- async def _get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
31
+ async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
32
32
  if get_domain(url) not in self.domains:
33
33
  logger.error(f"❌ Invalid domain for Bluesky: {get_domain(url)}")
34
34
  return None
35
35
 
36
+ session = kwargs.get("session")
36
37
  if "post" in url:
37
38
  result = await self._retrieve_post(url, session)
38
39
  else:
@@ -23,7 +23,7 @@ class Facebook(RetrievalIntegration):
23
23
  logger.info(f"✅ Facebook integration ready (yt-dlp only mode).")
24
24
  self.connected = True
25
25
 
26
- async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
26
+ async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
27
27
  """Retrieves content from a Facebook post URL."""
28
28
  if get_domain(url) not in self.domains:
29
29
  logger.error(f"❌ Invalid domain for Facebook: {get_domain(url)}")
@@ -31,31 +31,29 @@ class Facebook(RetrievalIntegration):
31
31
 
32
32
  # Determine if this is a video or photo URL, act accordingly
33
33
  if self._is_video_url(url):
34
- return await self._get_video(url, session)
34
+ return await self._get_video(url, **kwargs)
35
35
  elif self._is_photo_url(url):
36
- return await self._get_photo(url, session)
36
+ return await self._get_photo(url, **kwargs)
37
37
 
38
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))
39
+ return (await self._get_video(url, **kwargs) or
40
+ await self._get_photo(url, **kwargs) or
41
+ await self._get_user_profile(url, **kwargs))
42
42
 
43
- async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
43
+ async def _get_video(self, url: str, **kwargs) -> MultimodalSequence | None:
44
44
  """Retrieves content from a Facebook video URL."""
45
45
  if self.api_available:
46
- raise NotImplementedError
46
+ raise NotImplementedError("❌ Facebook video retrieval through API not yet supported.")
47
47
  else:
48
- return await get_content_with_ytdlp(url, session, platform="Facebook")
48
+ return await get_content_with_ytdlp(url, platform="Facebook", **kwargs)
49
49
 
50
- async def _get_photo(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
50
+ async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
51
51
  """Retrieves content from a Facebook photo URL."""
52
- logger.error("❌ No available method to retrieve Facebook photo.")
53
- return None
52
+ raise NotImplementedError("❌ No available method to retrieve Facebook photos.")
54
53
 
55
- async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
54
+ async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
56
55
  """Retrieves content from a Facebook user profile URL."""
57
- logger.error("❌ No available method to retrieve Facebook profiles.")
58
- return None
56
+ raise NotImplementedError("❌ No available method to retrieve Facebook profiles.")
59
57
 
60
58
  def _is_video_url(self, url: str) -> bool:
61
59
  """Checks if the URL is a Facebook video URL."""
@@ -20,7 +20,7 @@ class Instagram(RetrievalIntegration):
20
20
  logger.info(f"✅ Instagram integration ready (yt-dlp only mode).")
21
21
  self.connected = True
22
22
 
23
- async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
23
+ async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
24
24
  """Retrieves content from an Instagram post URL."""
25
25
  if get_domain(url) not in self.domains:
26
26
  logger.error(f"❌ Invalid domain for Instagram: {get_domain(url)}")
@@ -28,26 +28,30 @@ class Instagram(RetrievalIntegration):
28
28
 
29
29
  # Determine if this is a video or profile URL
30
30
  if self._is_video_url(url):
31
- return await self._get_video(url, session)
31
+ return await self._get_video(url, **kwargs)
32
32
  elif self._is_photo_url(url):
33
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)
34
+ content = await self._get_video(url, **kwargs)
35
+ if content and content.has_videos():
36
+ return content
37
+ else:
38
+ return await self._get_photo(url, **kwargs)
35
39
  else:
36
- return await self._get_user_profile(url, session)
40
+ return await self._get_user_profile(url, **kwargs)
37
41
 
38
- async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
42
+ async def _get_video(self, url: str, **kwargs) -> MultimodalSequence | None:
39
43
  """Retrieves content from an Instagram video URL."""
40
44
  if self.api_available:
41
45
  raise NotImplementedError
42
46
  else:
43
- return await get_content_with_ytdlp(url, session, platform="Instagram")
47
+ return await get_content_with_ytdlp(url, platform="Instagram", **kwargs)
44
48
 
45
- async def _get_photo(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
49
+ async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
46
50
  """Retrieves content from an Instagram photo URL (can also be a reel)."""
47
- logger.error("❌ No available method to retrieve Instagram photo.")
51
+ logger.warning("❌ Native Instagram photo download not yet supported. Use Decodo for that.")
48
52
  return None
49
53
 
50
- async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
54
+ async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
51
55
  """Retrieves content from an Instagram user profile URL."""
52
56
  username = self._extract_username(url)
53
57
  if username:
@@ -42,7 +42,7 @@ class Telegram(RetrievalIntegration):
42
42
  self.connected = False
43
43
  logger.warning("❌ Telegram integration not configured: Missing API keys.")
44
44
 
45
- async def _get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
45
+ async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
46
46
  """Retrieves content from a Telegram post URL."""
47
47
  assert get_domain(url) in self.domains
48
48
 
@@ -44,6 +44,7 @@ class TikTok(RetrievalIntegration):
44
44
 
45
45
  if client_key and client_secret:
46
46
  try:
47
+ # TODO: Replace with async version
47
48
  self.api = TikTokResearchAPI(
48
49
  client_key=client_key,
49
50
  client_secret=client_secret,
@@ -60,7 +61,9 @@ class TikTok(RetrievalIntegration):
60
61
  logger.info(f"✅ TikTok integration ready ({mode} mode).")
61
62
  self.connected = True
62
63
 
63
- async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
64
+ async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
65
+ session = kwargs.get('session')
66
+
64
67
  # Determine if this is a video or profile URL
65
68
  if self._is_video_url(url):
66
69
  return await self._get_video(url, session)
@@ -72,7 +75,7 @@ class TikTok(RetrievalIntegration):
72
75
 
73
76
  # Try API mode first if available
74
77
  if self.api_available:
75
- result = await self._get_video_with_api(url, session)
78
+ result = await self._get_video_with_api(url)
76
79
  if result:
77
80
  return result
78
81
  logger.warning("API method failed, falling back to yt-dlp...")
@@ -81,7 +84,7 @@ class TikTok(RetrievalIntegration):
81
84
  else:
82
85
  return await self._get_video_with_ytdlp(url, session)
83
86
 
84
- async def _get_video_with_api(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
87
+ async def _get_video_with_api(self, url: str) -> MultimodalSequence | None:
85
88
  """Retrieves video using TikTok Research API."""
86
89
  video_id = self._extract_video_id(url)
87
90
  if not video_id:
@@ -47,7 +47,8 @@ class X(RetrievalIntegration):
47
47
  self.connected = False
48
48
  logger.warning("❌ X (Twitter) integration not configured: Missing bearer token.")
49
49
 
50
- async def _get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
50
+ async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
51
+ session = kwargs.get("session")
51
52
  tweet_id = extract_tweet_id_from_url(url)
52
53
  if tweet_id:
53
54
  return await self._get_tweet(tweet_id, session)
@@ -0,0 +1,43 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ import aiohttp
5
+ from ezmm import MultimodalSequence
6
+
7
+ from scrapemm.scraping.ytdlp import get_content_with_ytdlp
8
+ from .base import RetrievalIntegration
9
+ from scrapemm.secrets import get_secret
10
+ from ..common import CONFIG_DIR
11
+
12
+ logger = logging.getLogger("scrapeMM")
13
+
14
+
15
+ class YouTube(RetrievalIntegration):
16
+ """YouTube integration for downloading videos and shorts using yt-dlp.
17
+ YouTube is rate-limited to 333 videos per hour."""
18
+
19
+ name = "YouTube"
20
+ domains = [
21
+ "youtube.com",
22
+ "youtu.be",
23
+ ]
24
+ cookie_file = CONFIG_DIR / "youtube_cookie.txt"
25
+
26
+ async def _connect(self):
27
+ self.connected = True # Connect always by default
28
+
29
+ cookie = get_secret("youtube_cookie")
30
+ if cookie:
31
+ # Save the cookie in a .txt file next to the secrets file
32
+ with open(self.cookie_file, "w") as f:
33
+ f.write(cookie)
34
+ logger.info(f"✅ Using cookie to connect to YouTube.")
35
+ else:
36
+ logger.warning(f"⚠️ Missing YouTube cookie. Won't be able to download videos, only thumbnails and metadata.")
37
+
38
+ async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
39
+ """Downloads YouTube video or short using yt-dlp."""
40
+ return await get_content_with_ytdlp(url,
41
+ platform="YouTube",
42
+ cookie_file=self.cookie_file.as_posix(),
43
+ **kwargs)
@@ -1,6 +1,7 @@
1
1
  import logging
2
- from typing import Optional, Collection
2
+ import sqlite3
3
3
  from traceback import format_exc
4
+ from typing import Optional, Collection
4
5
 
5
6
  import aiohttp
6
7
  from ezmm import MultimodalSequence, download_item
@@ -20,6 +21,8 @@ async def retrieve(
20
21
  actions: list[dict] = None,
21
22
  methods: list[str] = None,
22
23
  format: str = "multimodal_sequence",
24
+ max_video_size: int = None,
25
+ do_raise: bool = False,
23
26
  ) -> Optional[MultimodalSequence | str] | list[Optional[MultimodalSequence | str]]:
24
27
  """Main function of this repository. Downloads the contents present at the given URL(s).
25
28
  For each URL, returns a MultimodalSequence containing text, images, and videos.
@@ -41,6 +44,8 @@ async def retrieve(
41
44
  :param format: The format of the output. Available formats:
42
45
  - "multimodal_sequence" (MultimodalSequence containing parsed and downloaded media from the page)
43
46
  - "html" (string containing the raw HTML code of the page, not compatible with 'integrations' method)
47
+ :param max_video_size: Maximum size of videos to download, in MB. If None, no limit is applied.
48
+ :param do_raise: Whether to raise exceptions occurring during retrieval.
44
49
  """
45
50
  if methods is None:
46
51
  methods = METHODS
@@ -66,7 +71,9 @@ async def retrieve(
66
71
  urls_unique = set(urls_to_retrieve)
67
72
 
68
73
  # Retrieve URLs concurrently
69
- tasks = [_retrieve_single(url, remove_urls, session, methods, actions, format) for url in urls_unique]
74
+ tasks = [_retrieve_single(url, remove_urls, session, methods, actions,
75
+ format, max_video_size, do_raise) for url in
76
+ urls_unique]
70
77
  results = await run_with_semaphore(tasks, limit=20, show_progress=show_progress and len(urls_to_retrieve) > 1,
71
78
  progress_description="Retrieving URLs...")
72
79
 
@@ -85,6 +92,8 @@ async def _retrieve_single(
85
92
  methods: list[str],
86
93
  actions: list[dict] = None,
87
94
  format: str = "multimodal_sequence",
95
+ max_video_size: int = None,
96
+ do_raise: bool = False
88
97
  ) -> Optional[MultimodalSequence | str]:
89
98
  try:
90
99
  # Ensure URL is a string
@@ -101,9 +110,9 @@ async def _retrieve_single(
101
110
 
102
111
  # Define available retrieval methods
103
112
  method_map = {
104
- "integrations": lambda: retrieve_via_integration(url, session),
113
+ "integrations": lambda: retrieve_via_integration(url, session=session, max_video_size=max_video_size),
105
114
  "firecrawl": lambda: fire.scrape(url, remove_urls=remove_urls,
106
- session=session, format=format, actions=actions),
115
+ session=session, format=format, actions=actions),
107
116
  "decodo": lambda: decodo.scrape(url, remove_urls, session, format=format),
108
117
  }
109
118
 
@@ -114,11 +123,26 @@ async def _retrieve_single(
114
123
  continue
115
124
 
116
125
  logger.debug(f"Trying method: {method_name}")
126
+
117
127
  try:
118
128
  result = await method_map[method_name]()
129
+
130
+ except sqlite3.OperationalError as e:
131
+ if str(e) == "attempt to write a readonly database":
132
+ logger.error("ezMM database is read-only! Please check the database.")
133
+ else:
134
+ logger.warning(f"Error while retrieving with method '{method_name}': {e}")
135
+ if do_raise:
136
+ raise
137
+ else:
138
+ result = None
139
+
119
140
  except Exception as e:
120
141
  logger.warning(f"Error while retrieving with method '{method_name}': {e}")
121
- result = None
142
+ if do_raise:
143
+ raise
144
+ else:
145
+ result = None
122
146
 
123
147
  if result is not None:
124
148
  logger.debug(f"Successfully retrieved with method: {method_name}")
@@ -1,22 +1,31 @@
1
+ import asyncio
1
2
  import logging
3
+ import sys
2
4
  import tempfile
3
5
  import traceback
4
6
  from datetime import datetime
5
7
  from typing import Any, Optional
6
8
 
7
- import asyncio
8
9
  import aiohttp
9
10
  from ezmm import MultimodalSequence, download_image, Video, Image
10
11
  from yt_dlp import YoutubeDL
11
12
 
12
13
  logger = logging.getLogger("scrapeMM")
13
14
 
15
+ # Add yt-dlp-specific logger to print warnings to console
16
+ logger_yt_dlp = logging.getLogger("yt_dlp")
17
+ logger_yt_dlp.setLevel(logging.WARNING)
18
+ logger_yt_dlp.addHandler(logging.StreamHandler(sys.stdout))
19
+
14
20
 
15
21
  async def _download_with_ytdlp(
16
22
  url: str,
17
- session: aiohttp.ClientSession
23
+ session: aiohttp.ClientSession,
24
+ max_video_size: int = None,
25
+ cookie_file: str = None
18
26
  ) -> tuple[Optional[Video], Optional[Image], Optional[dict[str, Any]]]:
19
- """Downloads a video, its thumbnail, and the metadata using yt-dlp."""
27
+ """Downloads a video, its thumbnail, and the metadata using yt-dlp.
28
+ @param max_video_size: Maximum video size in bytes. If the video is larger, the download will be aborted."""
20
29
  try:
21
30
  with tempfile.NamedTemporaryFile() as temp_file:
22
31
  temp_path = temp_file.name
@@ -24,30 +33,34 @@ async def _download_with_ytdlp(
24
33
  ydl_opts: dict[str, Any] = dict(
25
34
  outtmpl=f'{temp_path}.%(ext)s', # Output filename format
26
35
  format='best[ext=mp4]/best', # Download the best video/audio quality
27
- quiet=False, # Show progress in terminal
36
+ max_filesize=max_video_size,
37
+ quiet=True, # Silence logs in console
38
+ logger=logger_yt_dlp, # Reroute logs to dedicated logger
28
39
  noplaylist=True, # Disable playlist downloading
29
40
  retries=3,
30
- cookies_from_browser='firefox',
31
- cookies='cookies.txt', # Use cookies from Firefox to bypass sign-in requirements
41
+ cookiefile=cookie_file, # Use cookie to bypass sign-in requirements
32
42
  )
33
43
 
34
44
  if "youtube" in url or "youtu.be" in url:
35
45
  # YouTube delivers video and audio separately when downloaded above 720p.
36
46
  # This would require FFmpeg to merge them. Restrict to 720p to avoid that.
37
- ydl_opts['format'] = 'best[height<=720][acodec!=none]'
47
+ ydl_opts['format'] = 'best[height<=720]'
38
48
  ydl_opts['extractor_args'] = dict(youtube=dict(player_client=["default"]))
39
49
 
40
50
  with YoutubeDL(ydl_opts) as ydl:
41
51
  metadata = ydl.extract_info(url, download=True)
42
52
 
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
53
+ video = None
54
+ if ext := metadata.get("ext"):
55
+ try:
56
+ video = Video(file_path=temp_path + f".{ext}", source_url=url)
57
+ video.relocate(move_not_copy=True)
58
+ if video.size > max_video_size:
59
+ video = None # Discard video
60
+ except FileNotFoundError:
61
+ pass
62
+ except Exception as e:
63
+ logger.warning(f"Could not load downloaded video: {e}\n{traceback.format_exc()}")
51
64
 
52
65
  thumbnail = None
53
66
  if thumbnail_url := metadata.get('thumbnail'):
@@ -56,7 +69,7 @@ async def _download_with_ytdlp(
56
69
  return video, thumbnail, metadata
57
70
 
58
71
  except Exception as e:
59
- logger.warning(f"Could not download video with yt-dlp: {e}\n{traceback.format_exc()}")
72
+ logger.warning(f"Could not download video with yt-dlp: {e}")
60
73
  return None, None, None
61
74
 
62
75
 
@@ -102,13 +115,15 @@ Views: {fmt_count(view_count)} - Likes: {fmt_count(like_count)} - Comments: {fmt
102
115
  return MultimodalSequence(items)
103
116
 
104
117
 
105
- async def get_content_with_ytdlp(url: str, session: aiohttp.ClientSession, platform: str) -> MultimodalSequence | None:
118
+ async def get_content_with_ytdlp(
119
+ url: str, session: aiohttp.ClientSession,
120
+ platform: str,
121
+ **kwargs
122
+ ) -> MultimodalSequence | None:
106
123
  """Retrieves video, thumbnail, and metadata using the powerful yt-dlp package."""
107
124
  # Run the download in a separate thread to avoid blocking the event loop
108
- coroutine = await asyncio.to_thread(_download_with_ytdlp, url, session)
125
+ coroutine = await asyncio.to_thread(_download_with_ytdlp, url, session, **kwargs)
109
126
  video, thumbnail, metadata = await coroutine
110
- if not video:
111
- logger.warning(f"Video download failed for {url}")
112
127
  if metadata:
113
128
  return await compose_data_to_sequence(metadata, video, thumbnail, platform)
114
129
  return None
@@ -10,6 +10,7 @@ from cryptography.hazmat.primitives import hashes
10
10
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
11
11
 
12
12
  from scrapemm.common import get_config_var, update_config, CONFIG_PATH, CONFIG_DIR, logger
13
+ from scrapemm.util import get_multiline_user_input
13
14
 
14
15
  SECRETS = {
15
16
  "x_bearer_token": "Bearer token of X (Twitter)",
@@ -22,6 +23,7 @@ SECRETS = {
22
23
  "tiktok_client_secret": "TikTok client secret",
23
24
  "decodo_username": "Decodo Web Scraping API username",
24
25
  "decodo_password": "Decodo Web Scraping API password",
26
+ "youtube_cookie": "YouTube cookie string"
25
27
  }
26
28
 
27
29
  SALT = b'\xa4\x93\xf1\x88\x13\x88'
@@ -125,40 +127,53 @@ def enter_password(pwd: str):
125
127
  _get_password(pwd=pwd)
126
128
 
127
129
 
128
- def configure_secrets(all_keys: bool = False):
129
- """Gets the secrets from the user by running a CLI dialogue.
130
- Saves them in an encrypted file. Deletes the existing secrets file if existing."""
131
- logging.debug("Configuring new secrets...")
132
-
130
+ def _set_new_password():
133
131
  # Delete existing secrets
134
- if SECRETS_PATH.exists() and all_keys:
132
+ if SECRETS_PATH.exists():
135
133
  SECRETS_PATH.unlink()
136
134
 
137
- # Set up a new password
138
- _get_password("🔐 Enter a password to encrypt your secrets (you'll need it later to decrypt them): ")
135
+ # Set up a new password
136
+ _get_password("🔐 Enter a password to encrypt your secrets (you'll need it later to decrypt them): ")
137
+
138
+
139
+ def override_secret(key_name: str):
140
+ """Prompts the user to enter a new value for the given secret key. Does nothing
141
+ when nothing entered."""
142
+ description = SECRETS[key_name]
143
+ if key_name == "youtube_cookie":
144
+ user_input = get_multiline_user_input(f"Please enter the {description}. Hit Ctrl-D or Ctrl-Z to save it.")
145
+ else:
146
+ user_input = getpass(f"Please enter the {description} (leave empty to skip): ", stream=sys.stdout)
147
+ if user_input:
148
+ set_secret(key_name, user_input)
149
+
139
150
 
140
- prompted = False
141
- for key_name, description in SECRETS.items():
151
+ def configure_secrets(all_keys: bool = False):
152
+ """Gets the secrets from the user by running a CLI dialogue.
153
+ Saves them in an encrypted file. Only overrides an existing secret on non-empty input."""
154
+ logger.info("Starting secrets configuration...")
155
+
156
+ for key_name in SECRETS.keys():
142
157
  key_value = get_secret(key_name)
143
158
  if all_keys or not key_value:
144
159
  # Get and save the missing API key
145
- user_input = getpass(f"Please enter the {description} (leave empty to skip): ", stream=sys.stdout)
146
- prompted = True
147
- if user_input:
148
- set_secret(key_name, user_input)
160
+ override_secret(key_name)
149
161
 
150
162
  update_config(api_keys_configured=True)
151
163
 
152
- if prompted:
153
- print("API keys configured successfully! If you want to change them, go to "
154
- f"{CONFIG_PATH.as_posix()} and set 'api_keys_configured' to 'false' or "
155
- f"run scrapemm.api_keys.configure_api_keys().")
164
+ logger.info("API keys configured successfully! If you want to change them, go to "
165
+ f"{CONFIG_PATH.as_posix()} and set 'api_keys_configured' to 'false' or "
166
+ f"run scrapemm.api_keys.configure_api_keys().")
156
167
 
157
168
 
169
+ # Ensure secrets file exists
170
+ if not SECRETS_PATH.exists():
171
+ _set_new_password()
172
+
173
+ # Ensure secrets are configured
158
174
  if not get_config_var("api_keys_configured"):
159
175
  configure_secrets()
160
176
 
161
-
162
177
  # Print secret summary
163
178
  logger.info("ScrapeMM secrets configuration:")
164
179
  for key_name, description in SECRETS.items():
@@ -71,3 +71,9 @@ async def run_with_semaphore(tasks: Iterable[Awaitable],
71
71
  def read_urls_from_file(file_path):
72
72
  with open(file_path, 'r') as f:
73
73
  return f.read().splitlines()
74
+
75
+
76
+ def get_multiline_user_input(prompt: str) -> str:
77
+ print(prompt)
78
+ lines = sys.stdin.readlines()
79
+ return "".join(lines)
@@ -2,6 +2,6 @@ from scrapemm import retrieve
2
2
  import asyncio
3
3
 
4
4
  if __name__ == "__main__":
5
- url = "https://www.youtube.com/shorts/cE0zgN6pYOc"
5
+ url = "https://www.instagram.com/reel/ClqTRryA6np/?utm_source=ig_embed&ig_rid=1ea336bf-737d-4526-8e12-233bf49f0488"
6
6
  result = asyncio.run(retrieve(url))
7
7
  print(result)
@@ -133,3 +133,20 @@ async def test_youtube(url):
133
133
  print(result)
134
134
  assert result
135
135
  assert result.has_videos()
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ @pytest.mark.parametrize("url, max_video_size, download_expected", [
140
+ # ("https://www.facebook.com/reel/1089214926521000", None, True),
141
+ # ("https://www.facebook.com/reel/1089214926521000", 128_000_000, True),
142
+ ("https://www.facebook.com/reel/1089214926521000", 1_000_000, False),
143
+ # ("https://www.youtube.com/shorts/cE0zgN6pYOc", None, True),
144
+ # ("https://www.youtube.com/shorts/cE0zgN6pYOc", 4_000_000, True),
145
+ # ("https://www.youtube.com/shorts/cE0zgN6pYOc", 3_000_000, False),
146
+ ])
147
+ async def test_max_video_size(url, max_video_size, download_expected):
148
+ result = await retrieve(url, max_video_size=max_video_size)
149
+ assert result.has_videos() == download_expected
150
+ if max_video_size and result.has_videos():
151
+ video = result.videos[0]
152
+ assert video.size <= max_video_size
@@ -1,28 +0,0 @@
1
- import logging
2
- from typing import Optional
3
-
4
- import aiohttp
5
- from ezmm import MultimodalSequence
6
-
7
- from scrapemm.scraping.ytdlp import get_content_with_ytdlp
8
- from .base import RetrievalIntegration
9
-
10
- logger = logging.getLogger("scrapeMM")
11
-
12
-
13
- class YouTube(RetrievalIntegration):
14
- """YouTube integration for downloading videos and shorts using yt-dlp."""
15
-
16
- name = "YouTube"
17
- domains = [
18
- "youtube.com",
19
- "youtu.be",
20
- ]
21
-
22
- async def _connect(self):
23
- self.connected = True
24
-
25
- async def _get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
26
- """Downloads YouTube video or short using yt-dlp."""
27
- logger.debug(f"📺 Downloading YouTube content: {url}")
28
- return await get_content_with_ytdlp(url, session, "YouTube")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes