scrapeMM 0.4.2__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.
Files changed (37) hide show
  1. {scrapemm-0.4.2/scrapeMM.egg-info → scrapemm-0.4.4}/PKG-INFO +1 -1
  2. {scrapemm-0.4.2 → scrapemm-0.4.4}/pyproject.toml +1 -1
  3. {scrapemm-0.4.2 → scrapemm-0.4.4/scrapeMM.egg-info}/PKG-INFO +1 -1
  4. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/__init__.py +1 -1
  5. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/common/__init__.py +9 -0
  6. scrapemm-0.4.4/scrapemm/common/exceptions.py +21 -0
  7. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/fb.py +42 -4
  8. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/tiktok.py +6 -3
  9. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/x.py +24 -10
  10. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/ytdlp.py +3 -3
  11. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/retrieval.py +21 -2
  12. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/secrets.py +3 -2
  13. {scrapemm-0.4.2 → scrapemm-0.4.4}/scripts/example.py +1 -1
  14. scrapemm-0.4.2/scrapemm/common/exceptions.py +0 -8
  15. {scrapemm-0.4.2 → scrapemm-0.4.4}/.gitignore +0 -0
  16. {scrapemm-0.4.2 → scrapemm-0.4.4}/LICENSE +0 -0
  17. {scrapemm-0.4.2 → scrapemm-0.4.4}/README.md +0 -0
  18. {scrapemm-0.4.2 → scrapemm-0.4.4}/requirements.txt +0 -0
  19. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapeMM.egg-info/SOURCES.txt +0 -0
  20. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
  21. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapeMM.egg-info/requires.txt +0 -0
  22. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapeMM.egg-info/top_level.txt +0 -0
  23. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/common/scraping_response.py +0 -0
  24. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/__init__.py +0 -0
  25. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/base.py +0 -0
  26. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/bluesky.py +0 -0
  27. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/decodo.py +0 -0
  28. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/firecrawl/__init__.py +0 -0
  29. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
  30. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
  31. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/instagram.py +0 -0
  32. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/telegram.py +0 -0
  33. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/integrations/youtube.py +0 -0
  34. {scrapemm-0.4.2 → scrapemm-0.4.4}/scrapemm/util.py +0 -0
  35. {scrapemm-0.4.2 → scrapemm-0.4.4}/setup.cfg +0 -0
  36. {scrapemm-0.4.2 → scrapemm-0.4.4}/testing/test_retrieval.py +0 -0
  37. {scrapemm-0.4.2 → scrapemm-0.4.4}/testing/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Flexible multimodal scraper for social media and the open web.
5
5
  Author-email: Mark Rothermel <mark.rothermel@tu-darmstadt.de>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapeMM"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  description = "Flexible multimodal scraper for social media and the open web."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Flexible multimodal scraper for social media and the open web.
5
5
  Author-email: Mark Rothermel <mark.rothermel@tu-darmstadt.de>
6
6
  License-Expression: Apache-2.0
@@ -2,7 +2,7 @@ import logging
2
2
  import sys
3
3
 
4
4
  import scrapemm.common
5
- from .common import APP_NAME
5
+ from .common import APP_NAME, set_wait_on_rate_limit, RateLimitError, ContentNotFoundError
6
6
  from .integrations import Telegram, X
7
7
  from .retrieval import retrieve
8
8
  from .secrets import configure_secrets
@@ -5,6 +5,7 @@ import yaml
5
5
  from platformdirs import user_config_dir
6
6
 
7
7
  from .scraping_response import ScrapingResponse
8
+ from .exceptions import RateLimitError, ContentNotFoundError
8
9
 
9
10
  APP_NAME = "scrapeMM"
10
11
 
@@ -13,6 +14,7 @@ CONFIG_DIR = Path(user_config_dir(APP_NAME))
13
14
  os.makedirs(CONFIG_DIR, exist_ok=True)
14
15
  CONFIG_PATH = CONFIG_DIR / "config.yaml"
15
16
 
17
+ WAIT_ON_RATE_LIMIT = False
16
18
 
17
19
  def load_config() -> dict:
18
20
  if os.path.exists(CONFIG_PATH):
@@ -31,5 +33,12 @@ def get_config_var(name: str, default=None) -> str:
31
33
  return _config.get(name, default)
32
34
 
33
35
 
36
+ def set_wait_on_rate_limit(wait: bool):
37
+ """Set whether to wait on rate limits (particularly relevant for X API).
38
+ Will result in a RateLimitError otherwise."""
39
+ global WAIT_ON_RATE_LIMIT
40
+ WAIT_ON_RATE_LIMIT = wait
41
+
42
+
34
43
  # Load config
35
44
  _config = load_config()
@@ -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.util import get_domain
12
+ from scrapemm.secrets import get_secret
10
13
 
11
14
  logger = logging.getLogger("scrapeMM")
12
15
 
@@ -16,17 +19,38 @@ 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
 
25
39
  async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
26
40
  """Retrieves content from a Facebook post URL."""
41
+ url = self._normalize_url(url)
42
+
27
43
  # Determine if this is a video or photo URL, act accordingly
28
44
  if self._is_video_url(url):
29
- return await self._get_video(url, **kwargs)
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
30
54
  elif self._is_photo_url(url):
31
55
  return await self._get_photo(url, **kwargs)
32
56
 
@@ -40,7 +64,10 @@ class Facebook(RetrievalIntegration):
40
64
  if self.api_available:
41
65
  raise NotImplementedError("Facebook video retrieval through API not yet supported.")
42
66
  else:
43
- return await get_content_with_ytdlp(url, platform="Facebook", **kwargs)
67
+ return await get_content_with_ytdlp(url,
68
+ platform="Facebook",
69
+ cookie_file=self.cookie_file.as_posix(),
70
+ **kwargs)
44
71
 
45
72
  async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
46
73
  """Retrieves content from a Facebook photo URL."""
@@ -50,6 +77,17 @@ class Facebook(RetrievalIntegration):
50
77
  """Retrieves content from a Facebook user profile URL."""
51
78
  raise NotImplementedError("No available method to retrieve Facebook profiles.")
52
79
 
80
+ def _normalize_url(self, url: str) -> str:
81
+ """If the URL is a login Facebook URL, i.e., of the form https://www.facebook.com/login/?next=...
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
89
+ return url
90
+
53
91
  def _is_video_url(self, url: str) -> bool:
54
92
  """Checks if the URL is a Facebook video URL."""
55
93
  # video URLS are in the format: https://www.facebook.com/watch?v=VIDEO_ID or fb.watch/...
@@ -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 "quota" in str(e).lower():
72
- raise RateLimitError(f"TikTok rate limit likely reached: {e}")
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
 
@@ -2,15 +2,17 @@ import asyncio
2
2
  import logging
3
3
  import re
4
4
  from typing import Optional
5
- from urllib.parse import urlparse
5
+ from urllib.parse import urlparse, unquote, urljoin, parse_qs
6
6
 
7
7
  import aiohttp
8
8
  from ezmm import MultimodalSequence, download_video, download_image
9
- from tweepy import Tweet, User
9
+ from tweepy import Tweet, User, TooManyRequests
10
10
  from tweepy.asynchronous import AsyncClient
11
11
 
12
- from scrapemm.secrets import get_secret
12
+ import scrapemm.common
13
+ from scrapemm.common.exceptions import RateLimitError
13
14
  from scrapemm.integrations.base import RetrievalIntegration
15
+ from scrapemm.secrets import get_secret
14
16
 
15
17
  logger = logging.getLogger("scrapeMM")
16
18
 
@@ -40,7 +42,7 @@ class X(RetrievalIntegration):
40
42
  async def _connect(self):
41
43
  bearer_token = get_secret("x_bearer_token")
42
44
  if bearer_token:
43
- self.client = AsyncClient(bearer_token=bearer_token, wait_on_rate_limit=True)
45
+ self.client = AsyncClient(bearer_token=bearer_token, wait_on_rate_limit=scrapemm.common.WAIT_ON_RATE_LIMIT)
44
46
  self.connected = True
45
47
  logger.info("✅ Successfully connected to X.")
46
48
  else:
@@ -50,13 +52,25 @@ class X(RetrievalIntegration):
50
52
  async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
51
53
  session = kwargs.get("session")
52
54
  max_video_size = kwargs.get("max_video_size")
55
+ url = self._normalize(url)
53
56
  tweet_id = extract_tweet_id_from_url(url)
54
- if tweet_id:
55
- return await self._get_tweet(tweet_id, session, max_video_size)
56
- else:
57
- username = extract_username_from_url(url)
58
- if username:
59
- return await self._get_user(username, session)
57
+ try:
58
+ if tweet_id:
59
+ return await self._get_tweet(tweet_id, session, max_video_size)
60
+ else:
61
+ username = extract_username_from_url(url)
62
+ if username:
63
+ return await self._get_user(username, session)
64
+ except TooManyRequests:
65
+ raise RateLimitError("X API rate limit reached.")
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
60
74
 
61
75
  async def _get_tweet(self, tweet_id: int, session: aiohttp.ClientSession, max_video_size: int = None) -> Optional[MultimodalSequence]:
62
76
  """Returns a MultimodalSequence containing the tweet's text and media
@@ -2,7 +2,6 @@ import asyncio
2
2
  import logging
3
3
  import sys
4
4
  import tempfile
5
- import traceback
6
5
  from datetime import datetime
7
6
  from typing import Any, Optional
8
7
 
@@ -10,7 +9,7 @@ import aiohttp
10
9
  from ezmm import MultimodalSequence, download_image, Video, Image
11
10
  from yt_dlp import YoutubeDL
12
11
 
13
- from scrapemm.common.exceptions import IPBannedError, ContentNotFoundError
12
+ from scrapemm.common.exceptions import ContentNotFoundError
14
13
 
15
14
  logger = logging.getLogger("scrapeMM")
16
15
 
@@ -79,7 +78,8 @@ async def download_video_with_ytdlp(
79
78
  if "The following content is not available on this app" in str(e):
80
79
  logger.warning(f"You should update yt-dlp to re-enable YouTube downloads.")
81
80
  raise e
82
- elif "Video unavailable" in str(e):
81
+ elif ("Video unavailable" in str(e)
82
+ or "HTTP Error 404: Not Found" in str(e)):
83
83
  raise ContentNotFoundError(f"Video is unavailable.")
84
84
  elif "Cannot parse data; please report this issue" in str(e):
85
85
  raise RuntimeError(f"yt-dlp is unable to parse the received video metadata.")
@@ -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 == "youtube_cookie":
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://www.awesomescreenshot.com/image/43774028?key=0de2147873b73ed468d07aeb93512c51"
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)
@@ -1,8 +0,0 @@
1
- class RateLimitError(Exception):
2
- pass
3
-
4
- class IPBannedError(Exception):
5
- pass
6
-
7
- class ContentNotFoundError(Exception):
8
- pass
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes