scrapeMM 0.3.3__tar.gz → 0.3.5__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.3 → scrapemm-0.3.5}/.gitignore +4 -1
- {scrapemm-0.3.3/scrapeMM.egg-info → scrapemm-0.3.5}/PKG-INFO +3 -3
- {scrapemm-0.3.3 → scrapemm-0.3.5}/README.md +2 -2
- {scrapemm-0.3.3 → scrapemm-0.3.5}/pyproject.toml +1 -1
- {scrapemm-0.3.3 → scrapemm-0.3.5}/requirements.txt +1 -1
- {scrapemm-0.3.3 → scrapemm-0.3.5/scrapeMM.egg-info}/PKG-INFO +3 -3
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/__init__.py +2 -2
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/base.py +3 -4
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/bluesky.py +2 -1
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/fb.py +33 -44
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/instagram.py +27 -29
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/telegram.py +1 -1
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/tiktok.py +9 -17
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/integrations/x.py +2 -1
- scrapemm-0.3.5/scrapemm/integrations/youtube.py +43 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/retrieval.py +8 -4
- scrapemm-0.3.5/scrapemm/scraping/decodo.py +216 -0
- scrapemm-0.3.5/scrapemm/scraping/ytdlp.py +129 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/secrets.py +36 -21
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/util.py +6 -0
- scrapemm-0.3.5/scripts/example.py +7 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/testing/test_retrieval.py +58 -15
- scrapemm-0.3.3/scrapemm/integrations/youtube.py +0 -34
- scrapemm-0.3.3/scrapemm/scraping/decodo.py +0 -192
- scrapemm-0.3.3/scrapemm/scraping/ytdlp.py +0 -203
- scrapemm-0.3.3/scripts/example.py +0 -7
- {scrapemm-0.3.3 → scrapemm-0.3.5}/LICENSE +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapeMM.egg-info/SOURCES.txt +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapeMM.egg-info/requires.txt +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/__init__.py +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/common.py +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/scraping/__init__.py +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/scraping/firecrawl.py +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/scraping/no_bot_domains.txt +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/scrapemm/scraping/util.py +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/setup.cfg +0 -0
- {scrapemm-0.3.3 → scrapemm-0.3.5}/testing/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
-
-
|
|
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
|
|
@@ -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:
|
|
@@ -1,79 +1,68 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
from urllib.parse import urlparse
|
|
4
|
+
|
|
3
5
|
import aiohttp
|
|
4
|
-
from ezmm import MultimodalSequence
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
async def _get(self, url: str,
|
|
26
|
+
async def _get(self, url: str, **kwargs) -> 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
|
|
31
|
+
|
|
32
|
+
# Determine if this is a video or photo URL, act accordingly
|
|
36
33
|
if self._is_video_url(url):
|
|
37
|
-
return await self._get_video(url,
|
|
34
|
+
return await self._get_video(url, **kwargs)
|
|
38
35
|
elif self._is_photo_url(url):
|
|
39
|
-
return await self._get_photo(url,
|
|
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."""
|
|
36
|
+
return await self._get_photo(url, **kwargs)
|
|
45
37
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
# The URL is not indicative, so try all methods
|
|
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))
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
async def _get_video(self, url: str, **kwargs) -> 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, platform="Facebook", **kwargs)
|
|
49
|
+
|
|
50
|
+
async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
54
51
|
"""Retrieves content from a Facebook photo URL."""
|
|
55
52
|
logger.error("❌ No available method to retrieve Facebook photo.")
|
|
56
53
|
return None
|
|
57
54
|
|
|
58
|
-
async def _get_user_profile(self, url: str,
|
|
55
|
+
async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
59
56
|
"""Retrieves content from a Facebook user profile URL."""
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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,45 +16,42 @@ class Instagram(RetrievalIntegration):
|
|
|
15
16
|
domains = ["instagram.com", "www.instagram.com"]
|
|
16
17
|
|
|
17
18
|
async def _connect(self):
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
async def _get(self, url: str,
|
|
23
|
+
async def _get(self, url: str, **kwargs) -> 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
|
-
return await self._get_video(url,
|
|
31
|
+
return await self._get_video(url, **kwargs)
|
|
38
32
|
elif self._is_photo_url(url):
|
|
39
|
-
|
|
33
|
+
# /p/ URLs can also be reels, so try both
|
|
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)
|
|
40
39
|
else:
|
|
41
|
-
return await self._get_user_profile(url,
|
|
42
|
-
|
|
43
|
-
async def _get_video(self, url: str,
|
|
40
|
+
return await self._get_user_profile(url, **kwargs)
|
|
41
|
+
|
|
42
|
+
async def _get_video(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
44
43
|
"""Retrieves content from an Instagram video URL."""
|
|
45
|
-
if self.
|
|
46
|
-
|
|
44
|
+
if self.api_available:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
else:
|
|
47
|
+
return await get_content_with_ytdlp(url, platform="Instagram", **kwargs)
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
50
|
+
"""Retrieves content from an Instagram photo URL (can also be a reel)."""
|
|
51
|
+
logger.warning("❌ Native Instagram photo download not yet supported. Use Decodo for that.")
|
|
49
52
|
return None
|
|
50
|
-
|
|
51
|
-
async def
|
|
52
|
-
"""Retrieves content from an Instagram photo URL."""
|
|
53
|
-
logger.error("❌ No available method to retrieve Instagram photo.")
|
|
54
|
-
return None
|
|
55
|
-
|
|
56
|
-
async def _get_user_profile(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
|
|
53
|
+
|
|
54
|
+
async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
57
55
|
"""Retrieves content from an Instagram user profile URL."""
|
|
58
56
|
username = self._extract_username(url)
|
|
59
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
|
|
|
@@ -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")
|
|
@@ -45,6 +44,7 @@ class TikTok(RetrievalIntegration):
|
|
|
45
44
|
|
|
46
45
|
if client_key and client_secret:
|
|
47
46
|
try:
|
|
47
|
+
# TODO: Replace with async version
|
|
48
48
|
self.api = TikTokResearchAPI(
|
|
49
49
|
client_key=client_key,
|
|
50
50
|
client_secret=client_secret,
|
|
@@ -57,18 +57,13 @@ class TikTok(RetrievalIntegration):
|
|
|
57
57
|
except Exception as e:
|
|
58
58
|
logger.info(f"⚠️ TikTok Research API connection failed: {e}. Using fallback mode.")
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
mode = "API" if self.api_available else "yt-dlp only"
|
|
61
|
+
logger.info(f"✅ TikTok integration ready ({mode} mode).")
|
|
62
|
+
self.connected = True
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
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.")
|
|
64
|
+
async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
65
|
+
session = kwargs.get('session')
|
|
70
66
|
|
|
71
|
-
async def _get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence | None:
|
|
72
67
|
# Determine if this is a video or profile URL
|
|
73
68
|
if self._is_video_url(url):
|
|
74
69
|
return await self._get_video(url, session)
|
|
@@ -80,19 +75,16 @@ class TikTok(RetrievalIntegration):
|
|
|
80
75
|
|
|
81
76
|
# Try API mode first if available
|
|
82
77
|
if self.api_available:
|
|
83
|
-
result = await self._get_video_with_api(url
|
|
78
|
+
result = await self._get_video_with_api(url)
|
|
84
79
|
if result:
|
|
85
80
|
return result
|
|
86
81
|
logger.warning("API method failed, falling back to yt-dlp...")
|
|
87
82
|
|
|
88
83
|
# Fallback to yt-dlp mode
|
|
89
|
-
|
|
84
|
+
else:
|
|
90
85
|
return await self._get_video_with_ytdlp(url, session)
|
|
91
86
|
|
|
92
|
-
|
|
93
|
-
return None
|
|
94
|
-
|
|
95
|
-
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:
|
|
96
88
|
"""Retrieves video using TikTok Research API."""
|
|
97
89
|
video_id = self._extract_video_id(url)
|
|
98
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,6 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from typing import Optional, Collection
|
|
3
2
|
from traceback import format_exc
|
|
3
|
+
from typing import Optional, Collection
|
|
4
4
|
|
|
5
5
|
import aiohttp
|
|
6
6
|
from ezmm import MultimodalSequence, download_item
|
|
@@ -20,6 +20,7 @@ async def retrieve(
|
|
|
20
20
|
actions: list[dict] = None,
|
|
21
21
|
methods: list[str] = None,
|
|
22
22
|
format: str = "multimodal_sequence",
|
|
23
|
+
max_video_size: int = None
|
|
23
24
|
) -> Optional[MultimodalSequence | str] | list[Optional[MultimodalSequence | str]]:
|
|
24
25
|
"""Main function of this repository. Downloads the contents present at the given URL(s).
|
|
25
26
|
For each URL, returns a MultimodalSequence containing text, images, and videos.
|
|
@@ -41,6 +42,7 @@ async def retrieve(
|
|
|
41
42
|
:param format: The format of the output. Available formats:
|
|
42
43
|
- "multimodal_sequence" (MultimodalSequence containing parsed and downloaded media from the page)
|
|
43
44
|
- "html" (string containing the raw HTML code of the page, not compatible with 'integrations' method)
|
|
45
|
+
:param max_video_size: Maximum size of videos to download, in MB. If None, no limit is applied.
|
|
44
46
|
"""
|
|
45
47
|
if methods is None:
|
|
46
48
|
methods = METHODS
|
|
@@ -66,7 +68,8 @@ async def retrieve(
|
|
|
66
68
|
urls_unique = set(urls_to_retrieve)
|
|
67
69
|
|
|
68
70
|
# Retrieve URLs concurrently
|
|
69
|
-
tasks = [_retrieve_single(url, remove_urls, session, methods, actions, format) for url in
|
|
71
|
+
tasks = [_retrieve_single(url, remove_urls, session, methods, actions, format, max_video_size) for url in
|
|
72
|
+
urls_unique]
|
|
70
73
|
results = await run_with_semaphore(tasks, limit=20, show_progress=show_progress and len(urls_to_retrieve) > 1,
|
|
71
74
|
progress_description="Retrieving URLs...")
|
|
72
75
|
|
|
@@ -85,6 +88,7 @@ async def _retrieve_single(
|
|
|
85
88
|
methods: list[str],
|
|
86
89
|
actions: list[dict] = None,
|
|
87
90
|
format: str = "multimodal_sequence",
|
|
91
|
+
max_video_size: int = None,
|
|
88
92
|
) -> Optional[MultimodalSequence | str]:
|
|
89
93
|
try:
|
|
90
94
|
# Ensure URL is a string
|
|
@@ -101,9 +105,9 @@ async def _retrieve_single(
|
|
|
101
105
|
|
|
102
106
|
# Define available retrieval methods
|
|
103
107
|
method_map = {
|
|
104
|
-
"integrations": lambda: retrieve_via_integration(url, session),
|
|
108
|
+
"integrations": lambda: retrieve_via_integration(url, session=session, max_video_size=max_video_size),
|
|
105
109
|
"firecrawl": lambda: fire.scrape(url, remove_urls=remove_urls,
|
|
106
|
-
|
|
110
|
+
session=session, format=format, actions=actions),
|
|
107
111
|
"decodo": lambda: decodo.scrape(url, remove_urls, session, format=format),
|
|
108
112
|
}
|
|
109
113
|
|