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