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