scrapeMM 0.4.3__tar.gz → 0.4.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.4.3/scrapeMM.egg-info → scrapemm-0.4.4}/PKG-INFO +1 -1
- {scrapemm-0.4.3 → scrapemm-0.4.4}/pyproject.toml +1 -1
- {scrapemm-0.4.3 → scrapemm-0.4.4/scrapeMM.egg-info}/PKG-INFO +1 -1
- scrapemm-0.4.4/scrapemm/common/exceptions.py +21 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/fb.py +36 -7
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/tiktok.py +6 -3
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/x.py +11 -4
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/retrieval.py +21 -2
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/secrets.py +3 -2
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scripts/example.py +1 -1
- scrapemm-0.4.3/scrapemm/common/exceptions.py +0 -8
- {scrapemm-0.4.3 → scrapemm-0.4.4}/.gitignore +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/LICENSE +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/README.md +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/requirements.txt +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapeMM.egg-info/SOURCES.txt +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapeMM.egg-info/requires.txt +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/__init__.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/common/__init__.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/common/scraping_response.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/__init__.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/base.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/bluesky.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/decodo.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/firecrawl/__init__.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/instagram.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/telegram.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/youtube.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/integrations/ytdlp.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/scrapemm/util.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/setup.cfg +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/testing/test_retrieval.py +0 -0
- {scrapemm-0.4.3 → scrapemm-0.4.4}/testing/test_utils.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class RateLimitError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IPBannedError(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContentNotFoundError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContentBlockedError(Exception):
|
|
14
|
+
"""The content was found but is blocked by content moderation, prohibiting
|
|
15
|
+
automated access (manual access might work, though)."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnsupportedDomainError(Exception):
|
|
20
|
+
"""The domain is not supported by the scraper."""
|
|
21
|
+
pass
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import re
|
|
3
|
-
from urllib.parse import urlparse,
|
|
3
|
+
from urllib.parse import urlparse, parse_qs
|
|
4
4
|
|
|
5
5
|
from ezmm import MultimodalSequence
|
|
6
6
|
|
|
7
|
+
from scrapemm import RateLimitError
|
|
8
|
+
from scrapemm.common import CONFIG_DIR
|
|
9
|
+
from scrapemm.common.exceptions import ContentBlockedError
|
|
7
10
|
from scrapemm.integrations.base import RetrievalIntegration
|
|
8
11
|
from scrapemm.integrations.ytdlp import get_content_with_ytdlp
|
|
9
|
-
from scrapemm.
|
|
12
|
+
from scrapemm.secrets import get_secret
|
|
10
13
|
|
|
11
14
|
logger = logging.getLogger("scrapeMM")
|
|
12
15
|
|
|
@@ -16,9 +19,20 @@ VIDEO_URL_REGEX = r"facebook\.com/\d+/videos/\d+/?"
|
|
|
16
19
|
class Facebook(RetrievalIntegration):
|
|
17
20
|
name = "Facebook"
|
|
18
21
|
domains = ["facebook.com", "fb.watch"]
|
|
22
|
+
cookie_file = CONFIG_DIR / "facebook_cookie.txt"
|
|
19
23
|
|
|
20
24
|
async def _connect(self):
|
|
21
25
|
self.api_available = False # TODO
|
|
26
|
+
|
|
27
|
+
cookie = get_secret("facebook_cookie")
|
|
28
|
+
if cookie:
|
|
29
|
+
# Save the cookie in a .txt file next to the secrets file
|
|
30
|
+
with open(self.cookie_file, "w") as f:
|
|
31
|
+
f.write(cookie)
|
|
32
|
+
logger.info(f"✅ Using cookie to connect to Facebook.")
|
|
33
|
+
else:
|
|
34
|
+
logger.warning(f"⚠️ Missing Facebook cookie. Won't be able to download videos that require login.")
|
|
35
|
+
|
|
22
36
|
logger.info(f"✅ Facebook integration ready (yt-dlp only mode).")
|
|
23
37
|
self.connected = True
|
|
24
38
|
|
|
@@ -28,7 +42,15 @@ class Facebook(RetrievalIntegration):
|
|
|
28
42
|
|
|
29
43
|
# Determine if this is a video or photo URL, act accordingly
|
|
30
44
|
if self._is_video_url(url):
|
|
31
|
-
|
|
45
|
+
try:
|
|
46
|
+
return await self._get_video(url, **kwargs)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
if "No video formats found" in str(e):
|
|
49
|
+
raise ContentBlockedError(f"Video is blocked by Facebook.")
|
|
50
|
+
elif "This video is only available for registered users" in str(e):
|
|
51
|
+
raise RateLimitError(f"Facebook is rate-limiting your IP address. Set a 'facebook_cookie' in ScrapeMM.")
|
|
52
|
+
else:
|
|
53
|
+
raise e
|
|
32
54
|
elif self._is_photo_url(url):
|
|
33
55
|
return await self._get_photo(url, **kwargs)
|
|
34
56
|
|
|
@@ -42,7 +64,10 @@ class Facebook(RetrievalIntegration):
|
|
|
42
64
|
if self.api_available:
|
|
43
65
|
raise NotImplementedError("Facebook video retrieval through API not yet supported.")
|
|
44
66
|
else:
|
|
45
|
-
return await get_content_with_ytdlp(url,
|
|
67
|
+
return await get_content_with_ytdlp(url,
|
|
68
|
+
platform="Facebook",
|
|
69
|
+
cookie_file=self.cookie_file.as_posix(),
|
|
70
|
+
**kwargs)
|
|
46
71
|
|
|
47
72
|
async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
|
|
48
73
|
"""Retrieves content from a Facebook photo URL."""
|
|
@@ -54,9 +79,13 @@ class Facebook(RetrievalIntegration):
|
|
|
54
79
|
|
|
55
80
|
def _normalize_url(self, url: str) -> str:
|
|
56
81
|
"""If the URL is a login Facebook URL, i.e., of the form https://www.facebook.com/login/?next=...
|
|
57
|
-
extracts the actual post's URL."""
|
|
58
|
-
if url.startswith("https://www.facebook.com/login/?next="):
|
|
59
|
-
|
|
82
|
+
or https://www.facebook.com/plugins/post.php?href=..., extracts the actual post's URL."""
|
|
83
|
+
if url.startswith("https://www.facebook.com/login/?next="): # Login redirect URLs
|
|
84
|
+
query = urlparse(url).query
|
|
85
|
+
return parse_qs(query).get("next", [])[0] or url
|
|
86
|
+
elif url.startswith("https://www.facebook.com/plugins/post.php?href="): # Post embedding links
|
|
87
|
+
query = urlparse(url).query
|
|
88
|
+
return parse_qs(query).get("href", [])[0] or url
|
|
60
89
|
return url
|
|
61
90
|
|
|
62
91
|
def _is_video_url(self, url: str) -> bool:
|
|
@@ -9,7 +9,7 @@ from ezmm import MultimodalSequence, download_image
|
|
|
9
9
|
from ezmm.common.items import Video, Image
|
|
10
10
|
from tiktok_research_api import TikTokResearchAPI, QueryVideoRequest, QueryUserInfoRequest, Criteria, Query
|
|
11
11
|
|
|
12
|
-
from scrapemm.common.exceptions import RateLimitError
|
|
12
|
+
from scrapemm.common.exceptions import RateLimitError, ContentBlockedError
|
|
13
13
|
from scrapemm.integrations.base import RetrievalIntegration
|
|
14
14
|
from scrapemm.integrations.ytdlp import download_video_with_ytdlp
|
|
15
15
|
from scrapemm.secrets import get_secret
|
|
@@ -68,8 +68,11 @@ class TikTok(RetrievalIntegration):
|
|
|
68
68
|
else:
|
|
69
69
|
return await self._get_user_profile(url, session)
|
|
70
70
|
except Exception as e:
|
|
71
|
-
if "
|
|
72
|
-
raise
|
|
71
|
+
if "Your IP address is blocked from accessing this post":
|
|
72
|
+
raise ContentBlockedError("Video is blocked by TikTok.")
|
|
73
|
+
elif "This post may not be comfortable for some audiences" in str(e):
|
|
74
|
+
raise ContentBlockedError("Video is blocked by TikTok for being 'uncomfortable for some audiences'. "
|
|
75
|
+
"Set a 'tiktok_cookie' in ScrapeMM to download this video.")
|
|
73
76
|
else:
|
|
74
77
|
raise e
|
|
75
78
|
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
import re
|
|
4
|
-
import traceback
|
|
5
4
|
from typing import Optional
|
|
6
|
-
from urllib.parse import urlparse
|
|
5
|
+
from urllib.parse import urlparse, unquote, urljoin, parse_qs
|
|
7
6
|
|
|
8
7
|
import aiohttp
|
|
9
8
|
from ezmm import MultimodalSequence, download_video, download_image
|
|
10
|
-
from tweepy import Tweet, User,
|
|
9
|
+
from tweepy import Tweet, User, TooManyRequests
|
|
11
10
|
from tweepy.asynchronous import AsyncClient
|
|
12
11
|
|
|
13
12
|
import scrapemm.common
|
|
14
13
|
from scrapemm.common.exceptions import RateLimitError
|
|
15
|
-
from scrapemm.secrets import get_secret
|
|
16
14
|
from scrapemm.integrations.base import RetrievalIntegration
|
|
15
|
+
from scrapemm.secrets import get_secret
|
|
17
16
|
|
|
18
17
|
logger = logging.getLogger("scrapeMM")
|
|
19
18
|
|
|
@@ -53,6 +52,7 @@ class X(RetrievalIntegration):
|
|
|
53
52
|
async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
|
|
54
53
|
session = kwargs.get("session")
|
|
55
54
|
max_video_size = kwargs.get("max_video_size")
|
|
55
|
+
url = self._normalize(url)
|
|
56
56
|
tweet_id = extract_tweet_id_from_url(url)
|
|
57
57
|
try:
|
|
58
58
|
if tweet_id:
|
|
@@ -64,6 +64,13 @@ class X(RetrievalIntegration):
|
|
|
64
64
|
except TooManyRequests:
|
|
65
65
|
raise RateLimitError("X API rate limit reached.")
|
|
66
66
|
|
|
67
|
+
def _normalize(self, url: str) -> str:
|
|
68
|
+
"""Turns URLs of the form https://publish.twitter.com/?query=...
|
|
69
|
+
into the bare Twitter URL."""
|
|
70
|
+
if url.startswith("https://publish.twitter.com/?query="):
|
|
71
|
+
query = urlparse(url).query
|
|
72
|
+
return parse_qs(query).get("query", [])[0] or url
|
|
73
|
+
return url
|
|
67
74
|
|
|
68
75
|
async def _get_tweet(self, tweet_id: int, session: aiohttp.ClientSession, max_video_size: int = None) -> Optional[MultimodalSequence]:
|
|
69
76
|
"""Returns a MultimodalSequence containing the tweet's text and media
|
|
@@ -7,13 +7,28 @@ import aiohttp
|
|
|
7
7
|
from ezmm import MultimodalSequence, download_item
|
|
8
8
|
|
|
9
9
|
from scrapemm.common import ScrapingResponse
|
|
10
|
-
from scrapemm.common.exceptions import IPBannedError
|
|
10
|
+
from scrapemm.common.exceptions import IPBannedError, UnsupportedDomainError
|
|
11
11
|
from scrapemm.integrations import retrieve_via_integration, fire, decodo
|
|
12
|
-
from scrapemm.util import run_with_semaphore
|
|
12
|
+
from scrapemm.util import run_with_semaphore, get_domain
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger("scrapeMM")
|
|
15
15
|
METHODS = ["integrations", "firecrawl", "decodo"]
|
|
16
16
|
|
|
17
|
+
UNSUPPORTED_DOMAINS = [
|
|
18
|
+
"archive.today",
|
|
19
|
+
"archive.is",
|
|
20
|
+
"archive.ph",
|
|
21
|
+
"archive.vn",
|
|
22
|
+
"archive.li",
|
|
23
|
+
"archive.fo",
|
|
24
|
+
"archive.md",
|
|
25
|
+
"perma.cc",
|
|
26
|
+
"ghostarchive.org",
|
|
27
|
+
"archive.org",
|
|
28
|
+
"mvau.lt",
|
|
29
|
+
"archive.st",
|
|
30
|
+
]
|
|
31
|
+
|
|
17
32
|
|
|
18
33
|
async def retrieve(
|
|
19
34
|
urls: str | Collection[str],
|
|
@@ -103,6 +118,10 @@ async def _retrieve_single(
|
|
|
103
118
|
) -> ScrapingResponse:
|
|
104
119
|
logger.debug(f"Retrieving {url}")
|
|
105
120
|
|
|
121
|
+
if get_domain(url) in UNSUPPORTED_DOMAINS:
|
|
122
|
+
return ScrapingResponse(url=url, content=None,
|
|
123
|
+
errors=dict(scrapemm=UnsupportedDomainError("Unsupported domain.")))
|
|
124
|
+
|
|
106
125
|
if methods is None:
|
|
107
126
|
methods = METHODS.copy()
|
|
108
127
|
|
|
@@ -25,7 +25,8 @@ SECRETS = {
|
|
|
25
25
|
"tiktok_client_secret": "TikTok client secret",
|
|
26
26
|
"decodo_username": "Decodo Web Scraping API username",
|
|
27
27
|
"decodo_password": "Decodo Web Scraping API password",
|
|
28
|
-
"youtube_cookie": "YouTube cookie string"
|
|
28
|
+
"youtube_cookie": "YouTube cookie string",
|
|
29
|
+
"facebook_cookie": "Facebook cookie string",
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
SALT = b'\xa4\x93\xf1\x88\x13\x88'
|
|
@@ -142,7 +143,7 @@ def override_secret(key_name: str):
|
|
|
142
143
|
"""Prompts the user to enter a new value for the given secret key. Does nothing
|
|
143
144
|
when nothing entered."""
|
|
144
145
|
description = SECRETS[key_name]
|
|
145
|
-
if key_name
|
|
146
|
+
if key_name in ["youtube_cookie", "facebook_cookie"]:
|
|
146
147
|
user_input = get_multiline_user_input(f"Please enter the {description}. Hit Ctrl-D or Ctrl-Z to save it.")
|
|
147
148
|
else:
|
|
148
149
|
user_input = getpass(f"Please enter the {description} (leave empty to skip): ", stream=sys.stdout)
|
|
@@ -2,7 +2,7 @@ from scrapemm import retrieve
|
|
|
2
2
|
import asyncio
|
|
3
3
|
|
|
4
4
|
if __name__ == "__main__":
|
|
5
|
-
url = "https://
|
|
5
|
+
url = "https://www.facebook.com/login/?next=https%3A%2F%2Fwww.facebook.com%2Fphoto%3Ffbid%3D860758296160977%26set%3Da.513585680878242"
|
|
6
6
|
result = asyncio.run(retrieve(url))
|
|
7
7
|
if result.errors:
|
|
8
8
|
print(result.errors)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|