scrapeMM 0.4.0__tar.gz → 0.4.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.
Files changed (38) hide show
  1. {scrapemm-0.4.0/scrapeMM.egg-info → scrapemm-0.4.2}/PKG-INFO +14 -5
  2. {scrapemm-0.4.0 → scrapemm-0.4.2}/README.md +13 -4
  3. {scrapemm-0.4.0 → scrapemm-0.4.2}/pyproject.toml +7 -1
  4. {scrapemm-0.4.0 → scrapemm-0.4.2/scrapeMM.egg-info}/PKG-INFO +14 -5
  5. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapeMM.egg-info/SOURCES.txt +1 -0
  6. scrapemm-0.4.2/scrapemm/common/exceptions.py +8 -0
  7. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/common/scraping_response.py +1 -1
  8. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/base.py +2 -2
  9. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/bluesky.py +13 -3
  10. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/fb.py +9 -10
  11. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/instagram.py +7 -10
  12. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/telegram.py +46 -43
  13. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/tiktok.py +42 -200
  14. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/x.py +7 -2
  15. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/ytdlp.py +30 -15
  16. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/retrieval.py +40 -16
  17. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/util.py +1 -1
  18. scrapemm-0.4.2/scripts/example.py +10 -0
  19. scrapemm-0.4.2/testing/test_retrieval.py +100 -0
  20. scrapemm-0.4.0/scripts/example.py +0 -7
  21. scrapemm-0.4.0/testing/test_retrieval.py +0 -163
  22. {scrapemm-0.4.0 → scrapemm-0.4.2}/.gitignore +0 -0
  23. {scrapemm-0.4.0 → scrapemm-0.4.2}/LICENSE +0 -0
  24. {scrapemm-0.4.0 → scrapemm-0.4.2}/requirements.txt +0 -0
  25. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapeMM.egg-info/dependency_links.txt +0 -0
  26. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapeMM.egg-info/requires.txt +0 -0
  27. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapeMM.egg-info/top_level.txt +0 -0
  28. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/__init__.py +0 -0
  29. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/common/__init__.py +0 -0
  30. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/__init__.py +0 -0
  31. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/decodo.py +0 -0
  32. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/firecrawl/__init__.py +0 -0
  33. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
  34. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
  35. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/integrations/youtube.py +0 -0
  36. {scrapemm-0.4.0 → scrapemm-0.4.2}/scrapemm/secrets.py +0 -0
  37. {scrapemm-0.4.0 → scrapemm-0.4.2}/setup.cfg +0 -0
  38. {scrapemm-0.4.0 → scrapemm-0.4.2}/testing/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -50,12 +50,21 @@ The `MultimodalSequence` is a sequence of Markdown-formatted text and media prov
50
50
  Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl) and [Decodo](https://decodo.com/).
51
51
 
52
52
  ## Supported Platforms
53
+ ### Social Media
53
54
  - ✅ X/Twitter
54
55
  - ✅ Telegram
55
56
  - ✅ Bluesky
56
57
  - ✅ TikTok
57
58
  - ✅ YouTube
58
- - (✅️) Instagram (works for most content)
59
- - ⚠️ Facebook (done for videos but not for images yet)
60
- - Threads (TBD)
61
- - Reddit (TBD)
59
+ - (✅️) Instagram: works for most content
60
+ - Facebook: done for videos but not for images yet
61
+ - Threads: TBD
62
+ - Reddit: TBD
63
+
64
+ ### Archiving Services
65
+ - ❌ Perma.cc
66
+ - ❌ Archive.today
67
+ - ❌ Wayback Machine, Internet Archive (web.archive.org)
68
+ - ❌ AwesomeScreenshot.com
69
+ - ⏳ MediaVault (mvau.lt): Works for images but not for videos yet
70
+ - ❌ Ghost Archive (ghostarchive.org)
@@ -26,12 +26,21 @@ The `MultimodalSequence` is a sequence of Markdown-formatted text and media prov
26
26
  Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl) and [Decodo](https://decodo.com/).
27
27
 
28
28
  ## Supported Platforms
29
+ ### Social Media
29
30
  - ✅ X/Twitter
30
31
  - ✅ Telegram
31
32
  - ✅ Bluesky
32
33
  - ✅ TikTok
33
34
  - ✅ YouTube
34
- - (✅️) Instagram (works for most content)
35
- - ⚠️ Facebook (done for videos but not for images yet)
36
- - Threads (TBD)
37
- - Reddit (TBD)
35
+ - (✅️) Instagram: works for most content
36
+ - Facebook: done for videos but not for images yet
37
+ - Threads: TBD
38
+ - Reddit: TBD
39
+
40
+ ### Archiving Services
41
+ - ❌ Perma.cc
42
+ - ❌ Archive.today
43
+ - ❌ Wayback Machine, Internet Archive (web.archive.org)
44
+ - ❌ AwesomeScreenshot.com
45
+ - ⏳ MediaVault (mvau.lt): Works for images but not for videos yet
46
+ - ❌ Ghost Archive (ghostarchive.org)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapeMM"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Flexible multimodal scraper for social media and the open web."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -33,3 +33,9 @@ namespaces = false # Disable implicit namespaces
33
33
 
34
34
  [tool.setuptools.package-data]
35
35
  "*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = [
39
+ "testing/",
40
+ ]
41
+ asyncio_default_fixture_loop_scope = "session"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -50,12 +50,21 @@ The `MultimodalSequence` is a sequence of Markdown-formatted text and media prov
50
50
  Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl) and [Decodo](https://decodo.com/).
51
51
 
52
52
  ## Supported Platforms
53
+ ### Social Media
53
54
  - ✅ X/Twitter
54
55
  - ✅ Telegram
55
56
  - ✅ Bluesky
56
57
  - ✅ TikTok
57
58
  - ✅ YouTube
58
- - (✅️) Instagram (works for most content)
59
- - ⚠️ Facebook (done for videos but not for images yet)
60
- - Threads (TBD)
61
- - Reddit (TBD)
59
+ - (✅️) Instagram: works for most content
60
+ - Facebook: done for videos but not for images yet
61
+ - Threads: TBD
62
+ - Reddit: TBD
63
+
64
+ ### Archiving Services
65
+ - ❌ Perma.cc
66
+ - ❌ Archive.today
67
+ - ❌ Wayback Machine, Internet Archive (web.archive.org)
68
+ - ❌ AwesomeScreenshot.com
69
+ - ⏳ MediaVault (mvau.lt): Works for images but not for videos yet
70
+ - ❌ Ghost Archive (ghostarchive.org)
@@ -13,6 +13,7 @@ scrapemm/retrieval.py
13
13
  scrapemm/secrets.py
14
14
  scrapemm/util.py
15
15
  scrapemm/common/__init__.py
16
+ scrapemm/common/exceptions.py
16
17
  scrapemm/common/scraping_response.py
17
18
  scrapemm/integrations/__init__.py
18
19
  scrapemm/integrations/base.py
@@ -0,0 +1,8 @@
1
+ class RateLimitError(Exception):
2
+ pass
3
+
4
+ class IPBannedError(Exception):
5
+ pass
6
+
7
+ class ContentNotFoundError(Exception):
8
+ pass
@@ -8,7 +8,7 @@ from ezmm import MultimodalSequence
8
8
  class ScrapingResponse:
9
9
  url: str # The input URL that was scraped
10
10
  content: Optional[MultimodalSequence | str] # The retrieved content. None if retrieval failed.
11
- errors: dict[str, Optional[Exception]] = None # The exceptions raised during retrieval for each method
11
+ errors: dict[str, Optional[Exception]] | None = None # The exceptions raised during retrieval for each method
12
12
  method: Optional[str] = None # The successful method used to retrieve the content
13
13
 
14
14
  @property
@@ -15,7 +15,7 @@ class RetrievalIntegration(ABC):
15
15
 
16
16
  name: str
17
17
  domains: list[str] # The domains supported by this integration
18
- connected: bool = None
18
+ connected: bool | None = None
19
19
 
20
20
  @abstractmethod
21
21
  async def _connect(self):
@@ -25,7 +25,7 @@ class RetrievalIntegration(ABC):
25
25
 
26
26
  async def get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
27
27
  """Ensures connectivity before invoking the service for retrieval."""
28
- assert get_domain(url) in self.domains
28
+ assert get_domain(url) in self.domains, f"Invalid domain {get_domain(url)} for integration {self.name}."
29
29
  if self.connected is None:
30
30
  await self._connect()
31
31
  if not self.connected:
@@ -34,14 +34,20 @@ class Bluesky(RetrievalIntegration):
34
34
  return None
35
35
 
36
36
  session = kwargs.get("session")
37
+ max_video_size = kwargs.get("max_video_size")
37
38
  if "post" in url:
38
- result = await self._retrieve_post(url, session)
39
+ result = await self._retrieve_post(url, session, max_video_size)
39
40
  else:
40
41
  result = await self._retrieve_profile(url, session)
41
42
 
42
43
  return result
43
44
 
44
- async def _retrieve_post(self, url: str, session: aiohttp.ClientSession = None) -> Optional[MultimodalSequence]:
45
+ async def _retrieve_post(
46
+ self,
47
+ url: str,
48
+ session: aiohttp.ClientSession | None = None,
49
+ max_video_size: int | None = None
50
+ ) -> Optional[MultimodalSequence]:
45
51
  """Retrieve a post from the given Bluesky URL."""
46
52
  uri = self._construct_uri(url)
47
53
  if not uri:
@@ -94,7 +100,11 @@ class Bluesky(RetrievalIntegration):
94
100
  elif hasattr(embed, 'py_type') and getattr(embed, 'py_type') == 'app.bsky.embed.video#view':
95
101
  video = await download_video(embed.playlist, session)
96
102
  if video:
97
- media.append(video)
103
+ if max_video_size is None or video.size <= max_video_size:
104
+ media.append(video)
105
+ else:
106
+ logger.info(f"Removing video {video.reference} because it exceeds the maximum size "
107
+ f"of {max_video_size / 1024 / 1024:.2f} MB.")
98
108
 
99
109
  # Extract hashtags and mentions
100
110
  hashtags, mentions, external_links = [], [], []
@@ -15,7 +15,7 @@ VIDEO_URL_REGEX = r"facebook\.com/\d+/videos/\d+/?"
15
15
 
16
16
  class Facebook(RetrievalIntegration):
17
17
  name = "Facebook"
18
- domains = ["facebook.com", "www.facebook.com"]
18
+ domains = ["facebook.com", "fb.watch"]
19
19
 
20
20
  async def _connect(self):
21
21
  self.api_available = False # TODO
@@ -24,10 +24,6 @@ class Facebook(RetrievalIntegration):
24
24
 
25
25
  async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
26
26
  """Retrieves content from a Facebook post URL."""
27
- if get_domain(url) not in self.domains:
28
- logger.error(f"❌ Invalid domain for Facebook: {get_domain(url)}")
29
- return None
30
-
31
27
  # Determine if this is a video or photo URL, act accordingly
32
28
  if self._is_video_url(url):
33
29
  return await self._get_video(url, **kwargs)
@@ -42,23 +38,26 @@ class Facebook(RetrievalIntegration):
42
38
  async def _get_video(self, url: str, **kwargs) -> MultimodalSequence | None:
43
39
  """Retrieves content from a Facebook video URL."""
44
40
  if self.api_available:
45
- raise NotImplementedError("Facebook video retrieval through API not yet supported.")
41
+ raise NotImplementedError("Facebook video retrieval through API not yet supported.")
46
42
  else:
47
43
  return await get_content_with_ytdlp(url, platform="Facebook", **kwargs)
48
44
 
49
45
  async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
50
46
  """Retrieves content from a Facebook photo URL."""
51
- raise NotImplementedError("No available method to retrieve Facebook photos.")
47
+ raise NotImplementedError("No available method to retrieve Facebook photos.")
52
48
 
53
49
  async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
54
50
  """Retrieves content from a Facebook user profile URL."""
55
- raise NotImplementedError("No available method to retrieve Facebook profiles.")
51
+ raise NotImplementedError("No available method to retrieve Facebook profiles.")
56
52
 
57
53
  def _is_video_url(self, url: str) -> bool:
58
54
  """Checks if the URL is a Facebook video URL."""
59
- # video URLS are in the format: https://www.facebook.com/watch?v=VIDEO_ID
55
+ # video URLS are in the format: https://www.facebook.com/watch?v=VIDEO_ID or fb.watch/...
60
56
  # or Reels: https://www.facebook.com/reel/REEL_ID
61
- return "facebook.com/watch" in url or "facebook.com/reel" in url or bool(re.search(VIDEO_URL_REGEX, url))
57
+ return ("facebook.com/watch" in url
58
+ or "facebook.com/reel" in url
59
+ or bool(re.search(VIDEO_URL_REGEX, url))
60
+ or "fb.watch" in url)
62
61
 
63
62
  def _extract_video_id(self, url: str) -> str:
64
63
  """Extracts the video ID from a Facebook video URL."""
@@ -4,6 +4,7 @@ from urllib.parse import urlparse
4
4
  from ezmm import MultimodalSequence
5
5
  from yt_dlp import DownloadError
6
6
 
7
+ from scrapemm.common.exceptions import RateLimitError
7
8
  from scrapemm.integrations.base import RetrievalIntegration
8
9
  from scrapemm.integrations.ytdlp import get_content_with_ytdlp
9
10
  from scrapemm.util import get_domain
@@ -22,16 +23,15 @@ class Instagram(RetrievalIntegration):
22
23
 
23
24
  async def _get(self, url: str, **kwargs) -> MultimodalSequence | None:
24
25
  """Retrieves content from an Instagram post URL."""
25
- if get_domain(url) not in self.domains:
26
- logger.error(f"❌ Invalid domain for Instagram: {get_domain(url)}")
27
- return None
28
-
29
26
  # Determine if this is a video or profile URL
30
27
  if self._is_video_url(url):
31
28
  return await self._get_video(url, **kwargs)
32
29
  elif self._is_photo_url(url):
33
30
  # /p/ URLs can also be reels, so try both
34
- content = await self._get_video(url, **kwargs)
31
+ try:
32
+ content = await self._get_video(url, **kwargs)
33
+ except Exception:
34
+ content = None
35
35
  if content and content.has_videos():
36
36
  return content
37
37
  else:
@@ -48,16 +48,13 @@ class Instagram(RetrievalIntegration):
48
48
  return await get_content_with_ytdlp(url, platform="Instagram", **kwargs)
49
49
  except DownloadError as e:
50
50
  if "rate-limit reached" in str(e):
51
- msg = "Instagram rate limit likely reached!"
52
- logger.error(msg)
53
- raise RuntimeError(msg) from e
51
+ raise RateLimitError(f"Instagram rate limit likely reached: {e}")
54
52
  else:
55
53
  raise e
56
54
 
57
55
  async def _get_photo(self, url: str, **kwargs) -> MultimodalSequence | None:
58
56
  """Retrieves content from an Instagram photo URL (can also be a reel)."""
59
- logger.warning("Native Instagram photo download not yet supported. Use Decodo for that.")
60
- return None
57
+ raise NotImplementedError("Native Instagram photo download not yet supported. Use Decodo for that.")
61
58
 
62
59
  async def _get_user_profile(self, url: str, **kwargs) -> MultimodalSequence | None:
63
60
  """Retrieves content from an Instagram user profile URL."""
@@ -31,11 +31,12 @@ class Telegram(RetrievalIntegration):
31
31
  if api_id and api_hash and bot_token:
32
32
  self.client = TelegramClient(self.session_path, api_id, api_hash)
33
33
  try:
34
- self.client.start(bot_token=bot_token)
34
+ await self.client.start(bot_token=bot_token) # Returns a coroutine b/c event loop exists already
35
35
  except sqlite3.OperationalError: # Database is locked from an interrupted previous session
36
36
  # Remove the database file and try again
37
37
  journal_path = Path(self.session_path + ".session-journal")
38
38
  journal_path.unlink(missing_ok=True)
39
+ await self.client.start(bot_token=bot_token) # Returns a coroutine b/c event loop exists already
39
40
  self.connected = True
40
41
  logger.info("✅ Successfully connected to Telegram.")
41
42
  else:
@@ -44,8 +45,6 @@ class Telegram(RetrievalIntegration):
44
45
 
45
46
  async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
46
47
  """Retrieves content from a Telegram post URL."""
47
- assert get_domain(url) in self.domains
48
-
49
48
  # Parse the URL to get channel/group name and post ID
50
49
  parsed = urlparse(url)
51
50
  path_parts = parsed.path.strip("/").split("/")
@@ -56,40 +55,44 @@ class Telegram(RetrievalIntegration):
56
55
  channel_name = path_parts[0]
57
56
  post_id = int(path_parts[1])
58
57
 
59
- try:
60
- # Get the message
61
- channel = await self.client.get_entity(channel_name)
62
- message = await self.client.get_messages(channel, ids=post_id)
63
-
64
- if not message:
65
- return None
66
-
67
- # Handle media
68
- media = await self._get_media_from_message(channel, message)
69
-
70
- author = message.sender
71
- author_type = type(author).__name__
72
- if isinstance(author, Channel):
73
- name = f'"{author.title}"'
74
- if author.username:
75
- name += f" (@{author.username})"
76
- elif isinstance(author, User):
77
- if author.bot:
78
- author_type = "Bot"
79
- name = f"{author.first_name} {author.last_name}" if author.last_name else author.first_name
80
- if author.username:
81
- name += f" (@{author.username})"
82
- if author.phone:
83
- name += f", Phone: {author.phone}"
84
- if author.verified:
85
- name += " (Verified)"
86
- else:
87
- name = "Unknown"
88
-
89
- edit_text = "\nEdit date: " + message.edit_date.strftime("%B %d, %Y at %H:%M") if message.edit_date else ""
90
- reactions_text = "\nReactions: " + message.reactions.stringify() if message.reactions else ""
91
-
92
- text = f"""**Telegram Post**
58
+ # Get the message
59
+ channel = await self.client.get_entity(channel_name)
60
+ if not channel:
61
+ return None
62
+
63
+ assert isinstance(channel, Channel), f"Can only retrieve posts from Telegram channels, but got {type(channel).__name__}."
64
+
65
+ message = await self.client.get_messages(channel, ids=post_id)
66
+ if not message:
67
+ return None
68
+
69
+ # Handle media
70
+ max_video_size = kwargs.get("max_video_size")
71
+ media = await self._get_media_from_message(channel, message, max_video_size=max_video_size)
72
+
73
+ author = message.sender
74
+ author_type = type(author).__name__
75
+ if isinstance(author, Channel):
76
+ name = f'"{author.title}"'
77
+ if author.username:
78
+ name += f" (@{author.username})"
79
+ elif isinstance(author, User):
80
+ if author.bot:
81
+ author_type = "Bot"
82
+ name = f"{author.first_name} {author.last_name}" if author.last_name else author.first_name
83
+ if author.username:
84
+ name += f" (@{author.username})"
85
+ if author.phone:
86
+ name += f", Phone: {author.phone}"
87
+ if author.verified:
88
+ name += " (Verified)"
89
+ else:
90
+ name = "Unknown"
91
+
92
+ edit_text = "\nEdit date: " + message.edit_date.strftime("%B %d, %Y at %H:%M") if message.edit_date else ""
93
+ reactions_text = "\nReactions: " + message.reactions.stringify() if message.reactions else ""
94
+
95
+ text = f"""**Telegram Post**
93
96
  Author: {author_type} {name}
94
97
  Date: {message.date.strftime("%B %d, %Y at %H:%M")}{edit_text}
95
98
  Views: {message.views}
@@ -98,13 +101,9 @@ Forwards: {message.forwards}{reactions_text}
98
101
  {' '.join(m.reference for m in media)}
99
102
  {message.text}"""
100
103
 
101
- return MultimodalSequence(text)
102
-
103
- except Exception as e:
104
- logger.warning(f"Error retrieving Telegram content: {e}")
105
- return None
104
+ return MultimodalSequence(text)
106
105
 
107
- async def _get_media_from_message(self, chat, original_post, max_amp=10) -> list[Item]:
106
+ async def _get_media_from_message(self, chat, original_post, max_amp=10, max_video_size=None) -> list[Item]:
108
107
  """
109
108
  Searches for Telegram posts that are part of the same group of uploads.
110
109
  The search is conducted around the id of the original post with an amplitude
@@ -129,6 +128,10 @@ Forwards: {message.forwards}{reactions_text}
129
128
  item = Image(binary_data=medium_bytes, source_url=post_url)
130
129
  elif hasattr(medium, "video"):
131
130
  item = Video(binary_data=medium_bytes, source_url=post_url)
131
+ if max_video_size is not None and item.size > max_video_size:
132
+ logger.info(f"Removing video {item.reference} because it exceeds the maximum size "
133
+ f"of {max_video_size / 1024 / 1024:.2f} MB.")
134
+ item = None
132
135
  else:
133
136
  raise ValueError(f"Unsupported medium: {medium.__dict__}")
134
137
  media.append(item)