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.
- {scrapemm-0.3.4/scrapeMM.egg-info → scrapemm-0.3.6}/PKG-INFO +1 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6}/pyproject.toml +1 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6}/requirements.txt +1 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6/scrapeMM.egg-info}/PKG-INFO +1 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/__init__.py +2 -2
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/base.py +3 -4
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/bluesky.py +2 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/fb.py +13 -15
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/instagram.py +13 -9
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/telegram.py +1 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/tiktok.py +6 -3
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/integrations/x.py +2 -1
- scrapemm-0.3.6/scrapemm/integrations/youtube.py +43 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/retrieval.py +29 -5
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/ytdlp.py +35 -20
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/secrets.py +34 -19
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/util.py +6 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scripts/example.py +1 -1
- {scrapemm-0.3.4 → scrapemm-0.3.6}/testing/test_retrieval.py +17 -0
- scrapemm-0.3.4/scrapemm/integrations/youtube.py +0 -28
- {scrapemm-0.3.4 → scrapemm-0.3.6}/.gitignore +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/LICENSE +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/README.md +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/SOURCES.txt +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/requires.txt +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/__init__.py +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/common.py +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/__init__.py +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/decodo.py +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/firecrawl.py +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/no_bot_domains.txt +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/scrapemm/scraping/util.py +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/setup.cfg +0 -0
- {scrapemm-0.3.4 → scrapemm-0.3.6}/testing/test_utils.py +0 -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,
|
|
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,
|
|
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,
|
|
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,
|
|
33
|
+
return await self._get(url, **kwargs)
|
|
35
34
|
|
|
36
35
|
@abstractmethod
|
|
37
|
-
async def _get(self, url: str,
|
|
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,
|
|
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,
|
|
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,
|
|
34
|
+
return await self._get_video(url, **kwargs)
|
|
35
35
|
elif self._is_photo_url(url):
|
|
36
|
-
return await self._get_photo(url,
|
|
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,
|
|
40
|
-
await self._get_photo(url,
|
|
41
|
-
await self._get_user_profile(url,
|
|
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,
|
|
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,
|
|
48
|
+
return await get_content_with_ytdlp(url, platform="Facebook", **kwargs)
|
|
49
49
|
|
|
50
|
-
async def _get_photo(self, url: str,
|
|
50
|
+
async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
51
51
|
"""Retrieves content from a Facebook photo URL."""
|
|
52
|
-
|
|
53
|
-
return None
|
|
52
|
+
raise NotImplementedError("❌ No available method to retrieve Facebook photos.")
|
|
54
53
|
|
|
55
|
-
async def _get_user_profile(self, url: str,
|
|
54
|
+
async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
56
55
|
"""Retrieves content from a Facebook user profile URL."""
|
|
57
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
40
|
+
return await self._get_user_profile(url, **kwargs)
|
|
37
41
|
|
|
38
|
-
async def _get_video(self, url: str,
|
|
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,
|
|
47
|
+
return await get_content_with_ytdlp(url, platform="Instagram", **kwargs)
|
|
44
48
|
|
|
45
|
-
async def _get_photo(self, url: str,
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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}
|
|
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(
|
|
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
|
|
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()
|
|
132
|
+
if SECRETS_PATH.exists():
|
|
135
133
|
SECRETS_PATH.unlink()
|
|
136
134
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
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
|
|
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
|