scrapeMM 0.2.1__tar.gz → 0.2.2__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 (33) hide show
  1. {scrapemm-0.2.1/scrapeMM.egg-info → scrapemm-0.2.2}/PKG-INFO +1 -1
  2. {scrapemm-0.2.1 → scrapemm-0.2.2}/pyproject.toml +2 -2
  3. {scrapemm-0.2.1 → scrapemm-0.2.2/scrapeMM.egg-info}/PKG-INFO +1 -1
  4. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapeMM.egg-info/SOURCES.txt +3 -0
  5. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/integrations/__init__.py +3 -1
  6. scrapemm-0.2.2/scrapemm/integrations/fb.py +101 -0
  7. scrapemm-0.2.2/scrapemm/integrations/instagram.py +87 -0
  8. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/integrations/tiktok.py +9 -13
  9. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/integrations/x.py +14 -13
  10. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/scraping/util.py +5 -2
  11. scrapemm-0.2.2/scrapemm/scraping/ytdlp.py +190 -0
  12. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/secrets.py +7 -2
  13. {scrapemm-0.2.1 → scrapemm-0.2.2}/.gitignore +0 -0
  14. {scrapemm-0.2.1 → scrapemm-0.2.2}/LICENSE +0 -0
  15. {scrapemm-0.2.1 → scrapemm-0.2.2}/README.md +0 -0
  16. {scrapemm-0.2.1 → scrapemm-0.2.2}/requirements.txt +0 -0
  17. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapeMM.egg-info/dependency_links.txt +0 -0
  18. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapeMM.egg-info/requires.txt +0 -0
  19. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapeMM.egg-info/top_level.txt +0 -0
  20. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/__init__.py +0 -0
  21. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/common.py +0 -0
  22. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/integrations/base.py +0 -0
  23. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/integrations/bluesky.py +0 -0
  24. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/integrations/telegram.py +0 -0
  25. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/retrieval.py +0 -0
  26. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/scraping/__init__.py +0 -0
  27. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/scraping/firecrawl.py +0 -0
  28. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/scraping/no_bot_domains.txt +0 -0
  29. {scrapemm-0.2.1 → scrapemm-0.2.2}/scrapemm/util.py +0 -0
  30. {scrapemm-0.2.1 → scrapemm-0.2.2}/scripts/example.py +0 -0
  31. {scrapemm-0.2.1 → scrapemm-0.2.2}/setup.cfg +0 -0
  32. {scrapemm-0.2.1 → scrapemm-0.2.2}/testing/test_retrieval.py +0 -0
  33. {scrapemm-0.2.1 → scrapemm-0.2.2}/testing/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.2.1
3
+ Version: 0.2.2
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.2.1"
3
+ version = "0.2.2"
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"
@@ -24,7 +24,7 @@ Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
24
24
  Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
25
25
 
26
26
  [build-system]
27
- requires = ["setuptools>=60", "setuptools-scm>=8.0"]
27
+ requires = ["setuptools"]
28
28
  build-backend = "setuptools.build_meta"
29
29
 
30
30
  [tool.setuptools.packages.find]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -16,6 +16,8 @@ scrapemm/util.py
16
16
  scrapemm/integrations/__init__.py
17
17
  scrapemm/integrations/base.py
18
18
  scrapemm/integrations/bluesky.py
19
+ scrapemm/integrations/fb.py
20
+ scrapemm/integrations/instagram.py
19
21
  scrapemm/integrations/telegram.py
20
22
  scrapemm/integrations/tiktok.py
21
23
  scrapemm/integrations/x.py
@@ -23,6 +25,7 @@ scrapemm/scraping/__init__.py
23
25
  scrapemm/scraping/firecrawl.py
24
26
  scrapemm/scraping/no_bot_domains.txt
25
27
  scrapemm/scraping/util.py
28
+ scrapemm/scraping/ytdlp.py
26
29
  scripts/example.py
27
30
  testing/test_retrieval.py
28
31
  testing/test_utils.py
@@ -10,11 +10,13 @@ from ezmm import MultimodalSequence
10
10
 
11
11
  from scrapemm.util import get_domain
12
12
  from .bluesky import Bluesky
13
+ from .fb import Facebook
14
+ from .instagram import Instagram
13
15
  from .telegram import Telegram
14
16
  from .tiktok import TikTok
15
17
  from .x import X
16
18
 
17
- RETRIEVAL_INTEGRATIONS = [X(), Telegram(), Bluesky(), TikTok()]
19
+ RETRIEVAL_INTEGRATIONS = [X(), Telegram(), Bluesky(), TikTok(), Instagram(), Facebook()]
18
20
  DOMAIN_TO_INTEGRATION = {domain: integration
19
21
  for integration in RETRIEVAL_INTEGRATIONS
20
22
  for domain in integration.domains
@@ -0,0 +1,101 @@
1
+ import logging
2
+ from urllib.parse import urlparse
3
+ import aiohttp
4
+ from ezmm import MultimodalSequence, download_image
5
+
6
+ from scrapemm.scraping.ytdlp import check_ytdlp_available, get_video_with_ytdlp
7
+ from scrapemm.integrations.base import RetrievalIntegration
8
+ from scrapemm.util import get_domain
9
+
10
+ logger = logging.getLogger("scrapeMM")
11
+
12
+
13
+ class Facebook(RetrievalIntegration):
14
+
15
+ domains = ["facebook.com", "www.facebook.com"]
16
+
17
+ def __init__(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.")
28
+
29
+ async def get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
30
+ """Retrieves content from a Facebook post URL."""
31
+ if not self.connected:
32
+ logger.error("❌ Facebook integration not connected. Cannot retrieve content.")
33
+ return None
34
+
35
+ if get_domain(url) not in self.domains:
36
+ logger.error(f"❌ Invalid domain for Facebook: {get_domain(url)}")
37
+ return None
38
+
39
+ # Determine if this is a video or profile URL
40
+ if self._is_video_url(url):
41
+ return await self._get_video(url, session)
42
+ elif self._is_photo_url(url):
43
+ return await self._get_photo(url, session)
44
+ else:
45
+ return await self._get_user_profile(url, session)
46
+
47
+ async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
48
+ """Retrieves content from a TikTok video URL."""
49
+
50
+ # Fallback to yt-dlp mode
51
+ if self.ytdlp_available:
52
+ return await get_video_with_ytdlp(url, session, platform="Facebook")
53
+
54
+ logger.error("❌ No available method to retrieve Facebook video.")
55
+ return None
56
+
57
+ async def _get_photo(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
58
+ """Retrieves content from a Facebook photo URL."""
59
+ logger.error("❌ No available method to retrieve Facebook photo.")
60
+ return None
61
+
62
+ async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
63
+ """Retrieves content from a Facebook user profile URL."""
64
+ username = self._extract_username(url)
65
+ if username:
66
+ text = f"""**Facebook Profile**
67
+ Username: @{username}
68
+ URL: {url}
69
+
70
+ Note: Profile details require Facebook API access.
71
+ Configure API credentials for full profile information."""
72
+ return MultimodalSequence([text])
73
+
74
+ return None
75
+
76
+ def _is_video_url(self, url: str) -> bool:
77
+ """Checks if the URL is a Facebook video URL."""
78
+ # video URLS are in the format: https://www.facebook.com/watch?v=VIDEO_ID
79
+ return "facebook.com/watch" in url
80
+
81
+ def _extract_video_id(self, url: str) -> str:
82
+ """Extracts the video ID from a Facebook video URL."""
83
+ parsed_url = urlparse(url)
84
+ query_params = parsed_url.query
85
+ for param in query_params.split('&'):
86
+ if param.startswith('v='):
87
+ return param.split('=')[1]
88
+ return ""
89
+
90
+ def _is_photo_url(self, url: str) -> bool:
91
+ """Checks if the URL is a Facebook photo URL."""
92
+ return "facebook.com/photo" in url or "facebook.com/photos" in url
93
+
94
+ def _extract_username(self, url: str) -> str:
95
+ """Extracts the username from a Facebook profile URL."""
96
+ # url format: https://www.facebook.com/username<?...>
97
+ parsed_url = urlparse(url)
98
+ path_parts = parsed_url.path.strip('/').split('/')
99
+ if len(path_parts) > 0:
100
+ return path_parts[0]
101
+ return ""
@@ -0,0 +1,87 @@
1
+ import logging
2
+ from urllib.parse import urlparse
3
+ import aiohttp
4
+ from ezmm import MultimodalSequence, download_image
5
+
6
+ from scrapemm.scraping.ytdlp import check_ytdlp_available, get_video_with_ytdlp
7
+ from scrapemm.integrations.base import RetrievalIntegration
8
+ from scrapemm.util import get_domain
9
+
10
+ logger = logging.getLogger("scrapeMM")
11
+
12
+
13
+ class Instagram(RetrievalIntegration):
14
+ domains = ["instagram.com", "www.instagram.com"]
15
+
16
+ def __init__(self):
17
+ # Check if yt-dlp is available
18
+ self.ytdlp_available = check_ytdlp_available()
19
+
20
+ if self.ytdlp_available:
21
+ self.connected = True
22
+ mode = "yt-dlp only"
23
+ logger.info(f"✅ Instagram integration ready ({mode} mode).")
24
+ else:
25
+ self.connected = False
26
+ logger.warning("❌ Instagram integration not available: Neither API credentials nor yt-dlp found.")
27
+
28
+ async def get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
29
+ """Retrieves content from an Instagram post URL."""
30
+ if not self.connected:
31
+ logger.error("❌ Instagram integration not connected. Cannot retrieve content.")
32
+ return None
33
+
34
+ if get_domain(url) not in self.domains:
35
+ logger.error(f"❌ Invalid domain for Instagram: {get_domain(url)}")
36
+ return None
37
+
38
+ # Determine if this is a video or profile URL
39
+ if self._is_video_url(url):
40
+ return await self._get_video(url, session)
41
+ elif self._is_photo_url(url):
42
+ return await self._get_photo(url, session)
43
+ else:
44
+ return await self._get_user_profile(url, session)
45
+
46
+ async def _get_video(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
47
+ """Retrieves content from an Instagram video URL."""
48
+ if self.ytdlp_available:
49
+ return await get_video_with_ytdlp(url, session, platform="Instagram")
50
+
51
+ logger.error("❌ No available method to retrieve Instagram video.")
52
+ return None
53
+
54
+ async def _get_photo(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
55
+ """Retrieves content from an Instagram photo URL."""
56
+ logger.error("❌ No available method to retrieve Instagram photo.")
57
+ return None
58
+
59
+ async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
60
+ """Retrieves content from an Instagram user profile URL."""
61
+ username = self._extract_username(url)
62
+ if username:
63
+ text = f"""**Instagram Profile**
64
+ Username: @{username}
65
+ URL: {url}
66
+
67
+ Note: Profile details require Instagram API access.
68
+ Configure API credentials for full profile information."""
69
+ return MultimodalSequence([text])
70
+
71
+ return None
72
+
73
+ def _is_video_url(self, url: str) -> bool:
74
+ """Checks if the URL is an Instagram video URL."""
75
+ return "instagram.com/reels" in url
76
+
77
+ def _is_photo_url(self, url: str) -> bool:
78
+ """Checks if the URL is an Instagram photo URL."""
79
+ return "instagram.com/p/" in url
80
+
81
+ def _extract_username(self, url: str) -> str:
82
+ """Extracts the username from an Instagram profile URL."""
83
+ parsed_url = urlparse(url)
84
+ path_parts = parsed_url.path.strip('/').split('/')
85
+ if len(path_parts) > 0:
86
+ return path_parts[0]
87
+ return ""
@@ -2,9 +2,9 @@ import asyncio
2
2
  import json
3
3
  import logging
4
4
  import re
5
+ import sys
5
6
  import tempfile
6
7
  import os
7
- import subprocess
8
8
  from urllib.parse import urlparse
9
9
  from datetime import datetime
10
10
  from typing import Any
@@ -15,6 +15,7 @@ from ezmm.common.items import Video, Image
15
15
 
16
16
  from tiktok_research_api import TikTokResearchAPI, QueryVideoRequest, QueryUserInfoRequest, Criteria, Query
17
17
 
18
+ from scrapemm.scraping.ytdlp import check_ytdlp_available
18
19
  from scrapemm.secrets import get_secret
19
20
  from scrapemm.integrations.base import RetrievalIntegration
20
21
  from scrapemm.util import get_domain
@@ -56,7 +57,7 @@ class TikTok(RetrievalIntegration):
56
57
  logger.warning(f"❌ TikTok Research API connection failed: {e}. Using fallback mode.")
57
58
 
58
59
  # Check if yt-dlp is available
59
- self.ytdlp_available = self._check_ytdlp_available()
60
+ self.ytdlp_available = check_ytdlp_available()
60
61
 
61
62
  if self.api_available or self.ytdlp_available:
62
63
  self.connected = True
@@ -66,15 +67,6 @@ class TikTok(RetrievalIntegration):
66
67
  self.connected = False
67
68
  logger.warning("❌ TikTok integration not available: Neither API credentials nor yt-dlp found.")
68
69
 
69
- def _check_ytdlp_available(self) -> bool:
70
- """Check if yt-dlp is available."""
71
- try:
72
- subprocess.run(['yt-dlp', '--version'],
73
- capture_output=True, check=True, timeout=5)
74
- return True
75
- except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
76
- return False
77
-
78
70
  async def get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
79
71
  """Retrieves content from a TikTok post URL."""
80
72
  if not self.connected:
@@ -217,7 +209,9 @@ Configure API credentials for full profile information."""
217
209
  """Extracts metadata using yt-dlp without downloading the video."""
218
210
  try:
219
211
  cmd = [
220
- 'yt-dlp',
212
+ sys.executable, # Ensure to use the same Python interpreter as the calling process
213
+ '-m',
214
+ 'yt_dlp',
221
215
  '--no-download',
222
216
  '--print-json',
223
217
  '--no-warnings',
@@ -252,7 +246,9 @@ Configure API credentials for full profile information."""
252
246
  temp_path = temp_file.name
253
247
 
254
248
  cmd = [
255
- 'yt-dlp',
249
+ sys.executable, # Ensure to use the same Python interpreter as the calling process
250
+ '-m',
251
+ 'yt_dlp',
256
252
  '--no-playlist',
257
253
  '--no-warnings',
258
254
  '--quiet',
@@ -40,7 +40,7 @@ class X(RetrievalIntegration):
40
40
  def __init__(self):
41
41
  bearer_token = get_secret("x_bearer_token")
42
42
  if bearer_token:
43
- self.client = AsyncClient(bearer_token=bearer_token)
43
+ self.client = AsyncClient(bearer_token=bearer_token, wait_on_rate_limit=True)
44
44
  self.connected = True
45
45
  logger.info("✅ Successfully connected to X.")
46
46
  else:
@@ -80,18 +80,19 @@ class X(RetrievalIntegration):
80
80
 
81
81
  # Download the media
82
82
  media = []
83
- for medium_raw in media_raw:
84
- if medium_raw.type == "photo":
85
- url = medium_raw.url
86
- medium = await download_image(url, session=session)
87
- elif medium_raw.type in ["video", "animated_gif"]:
88
- # Get the variant with the highest bitrate
89
- url = _get_best_quality_video_url(medium_raw.variants)
90
- medium = await download_video(url, session=session)
91
- else:
92
- raise ValueError(f"Unsupported media type: {medium_raw.type}")
93
- if medium:
94
- media.append(medium)
83
+ if media_raw:
84
+ for medium_raw in media_raw:
85
+ if medium_raw.type == "photo":
86
+ url = medium_raw.url
87
+ medium = await download_image(url, session=session)
88
+ elif medium_raw.type in ["video", "animated_gif"]:
89
+ # Get the variant with the highest bitrate
90
+ url = _get_best_quality_video_url(medium_raw.variants)
91
+ medium = await download_video(url, session=session)
92
+ else:
93
+ raise ValueError(f"Unsupported media type: {medium_raw.type}")
94
+ if medium:
95
+ media.append(medium)
95
96
 
96
97
  tweet_str = f"""**Post on X**
97
98
  Author: {author.name}, @{author.username}
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from typing import Optional
2
3
 
3
4
  import aiohttp
@@ -16,6 +17,7 @@ URL_REGEX = r"https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{
16
17
  DATA_URI_REGEX = r"data:([\w/+.-]+/[\w.+-]+);base64,([A-Za-z0-9+/=]+)"
17
18
  MD_HYPERLINK_REGEX = rf'(!?\[([^]^[]*)\]\((.*?)(?: "[^"]*")?\))'
18
19
 
20
+ logger = logging.getLogger("scrapeMM")
19
21
 
20
22
  def find_firecrawl(urls):
21
23
  for url in urls:
@@ -153,5 +155,6 @@ def from_base64(b64_data: str, mime_type: str = "image/jpeg") -> Optional[Item]:
153
155
  return Video(binary_data=binary_data)
154
156
  else:
155
157
  raise ValueError(f"Unsupported media type: {mime_type}")
156
- except ValueError as e:
157
- print(f"Error decoding base64 data: {e}")
158
+ except Exception as e:
159
+ logger.warning(f"Error decoding base64 data!\n"
160
+ f"{type(e).__name__}: {e}")
@@ -0,0 +1,190 @@
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
+ with open('metadata.json', 'w') as f:
59
+ json.dump(metadata, f, indent=2)
60
+ return metadata
61
+
62
+ except Exception as e:
63
+ logger.error(f"❌ Error extracting metadata with yt-dlp: {e}")
64
+ return None
65
+
66
+
67
+ async def download_video_with_ytdlp(url: str) -> Video | None:
68
+ """Downloads a video using yt-dlp."""
69
+ try:
70
+ with tempfile.NamedTemporaryFile(suffix='.%(ext)s', delete=False) as temp_file:
71
+ temp_path = temp_file.name
72
+
73
+ cmd = [
74
+ sys.executable, # Ensure to use the same Python interpreter as the calling process
75
+ '-m',
76
+ 'yt_dlp',
77
+ '--no-playlist',
78
+ '--no-warnings',
79
+ '--quiet',
80
+ '--format', 'best[ext=mp4]/best',
81
+ '--output', temp_path,
82
+ url
83
+ ]
84
+
85
+ process = await asyncio.create_subprocess_exec(
86
+ *cmd,
87
+ stdout=asyncio.subprocess.PIPE,
88
+ stderr=asyncio.subprocess.PIPE
89
+ )
90
+
91
+ _, stderr = await process.communicate()
92
+
93
+ if process.returncode != 0:
94
+ logger.warning(f"yt-dlp video download failed: {stderr.decode()}")
95
+ return None
96
+
97
+ # Find the actual downloaded file (yt-dlp changes extension)
98
+ downloaded_file = None
99
+ for ext in ['.mp4', '.webm', '.mkv']:
100
+ potential_file = temp_path.replace('.%(ext)s', ext)
101
+ if os.path.exists(potential_file) and os.path.getsize(potential_file) > 0:
102
+ downloaded_file = potential_file
103
+ break
104
+
105
+ if downloaded_file and os.path.exists(downloaded_file):
106
+ with open(downloaded_file, 'rb') as f:
107
+ video_data = f.read()
108
+
109
+ os.unlink(downloaded_file)
110
+
111
+ video = Video(binary_data=video_data, source_url=url)
112
+ video.relocate(move_not_copy=True)
113
+ return cast(Video, video)
114
+
115
+ return None
116
+
117
+ except Exception as e:
118
+ logger.error(f"❌ Error downloading video with yt-dlp: {e}")
119
+ return None
120
+
121
+
122
+ async def download_thumbnail_with_ytdlp(url: str, session: aiohttp.ClientSession) -> Image | None:
123
+ """Downloads thumbnail using yt-dlp metadata."""
124
+ try:
125
+ metadata = await extract_metadata_with_ytdlp(url)
126
+ if not metadata:
127
+ return None
128
+
129
+ thumbnail_url = metadata.get('thumbnail')
130
+ if thumbnail_url:
131
+ return await download_image(thumbnail_url, session)
132
+
133
+ except Exception as e:
134
+ logger.error(f"❌ Error downloading thumbnail: {e}")
135
+
136
+ return None
137
+
138
+
139
+ async def create_video_sequence_from_ytdlp(metadata: dict, url: str, video: Video | None, thumbnail: Image | None, platform: str) -> MultimodalSequence:
140
+ """Creates MultimodalSequence from yt-dlp metadata."""
141
+ # title = metadata.get('title', '')
142
+ uploader = metadata.get('uploader', 'Unknown')
143
+ upload_date = metadata.get('upload_date', '')
144
+ duration = metadata.get('duration', 0)
145
+ view_count = metadata.get('view_count', 0)
146
+ like_count = metadata.get('like_count', 0)
147
+ comment_count = metadata.get('comment_count', 0)
148
+ description = metadata.get('description', '')
149
+
150
+ # Format upload date
151
+ formatted_date = upload_date
152
+ if upload_date and len(upload_date) == 8:
153
+ try:
154
+ date_obj = datetime.strptime(upload_date, '%Y%m%d')
155
+ formatted_date = date_obj.strftime('%Y-%m-%d')
156
+ except ValueError:
157
+ pass
158
+
159
+ text = f"""**{platform} Video**
160
+ Author: @{uploader}
161
+ Posted: {formatted_date}
162
+ Duration: {duration}s
163
+ Views: {view_count:,} - Likes: {like_count:,} - Comments: {comment_count:,}
164
+
165
+ {description}"""
166
+
167
+ items: list = [text]
168
+ if thumbnail:
169
+ items.append(thumbnail)
170
+ if video:
171
+ items.append(video)
172
+
173
+ return MultimodalSequence(items)
174
+
175
+ async def get_video_with_ytdlp(url: str, session: aiohttp.ClientSession, platform: str) -> MultimodalSequence | None:
176
+ """Retrieves video using only yt-dlp (no API required)."""
177
+ try:
178
+ # Get metadata and video using yt-dlp
179
+ metadata = await extract_metadata_with_ytdlp(url)
180
+ if not metadata:
181
+ return None
182
+
183
+ video = await download_video_with_ytdlp(url)
184
+ thumbnail = await download_thumbnail_with_ytdlp(url, session)
185
+
186
+ return await create_video_sequence_from_ytdlp(metadata, url, video, thumbnail, platform)
187
+
188
+ except Exception as e:
189
+ logger.error(f"❌ Error retrieving video with yt-dlp: {e}")
190
+ return None
@@ -118,9 +118,14 @@ def set_password(pwd: str):
118
118
 
119
119
  def configure_secrets(all_keys: bool = False):
120
120
  """Gets the secrets from the user by running a CLI dialogue.
121
- Saves them in an encrypted file."""
121
+ Saves them in an encrypted file. Deletes the existing secrets file if existing."""
122
122
  logging.debug("Configuring new secrets...")
123
- # Ensure a password is set
123
+
124
+ # Delete existing secrets
125
+ if SECRETS_PATH.exists():
126
+ SECRETS_PATH.unlink()
127
+
128
+ # Set up a new password
124
129
  _get_password("🔐 Enter a password to encrypt your secrets (you'll need it later to decrypt them): ")
125
130
 
126
131
  prompted = False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes