scrapeMM 0.1.3__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.
Files changed (37) hide show
  1. {scrapemm-0.1.3 → scrapemm-0.2.2}/.gitignore +2 -1
  2. {scrapemm-0.1.3/scrapeMM.egg-info → scrapemm-0.2.2}/PKG-INFO +14 -5
  3. {scrapemm-0.1.3 → scrapemm-0.2.2}/README.md +9 -3
  4. {scrapemm-0.1.3 → scrapemm-0.2.2}/pyproject.toml +8 -5
  5. {scrapemm-0.1.3 → scrapemm-0.2.2}/requirements.txt +5 -2
  6. {scrapemm-0.1.3 → scrapemm-0.2.2/scrapeMM.egg-info}/PKG-INFO +14 -5
  7. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/SOURCES.txt +6 -1
  8. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/requires.txt +4 -1
  9. scrapemm-0.2.2/scrapemm/__init__.py +4 -0
  10. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/common.py +1 -0
  11. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/__init__.py +10 -1
  12. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/base.py +3 -1
  13. scrapemm-0.2.2/scrapemm/integrations/bluesky.py +237 -0
  14. scrapemm-0.2.2/scrapemm/integrations/fb.py +101 -0
  15. scrapemm-0.2.2/scrapemm/integrations/instagram.py +87 -0
  16. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/telegram.py +4 -4
  17. scrapemm-0.2.2/scrapemm/integrations/tiktok.py +465 -0
  18. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/x.py +29 -24
  19. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/retrieval.py +9 -6
  20. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/firecrawl.py +8 -2
  21. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/util.py +10 -4
  22. scrapemm-0.2.2/scrapemm/scraping/ytdlp.py +190 -0
  23. scrapemm-0.2.2/scrapemm/secrets.py +154 -0
  24. scrapemm-0.2.2/scripts/example.py +8 -0
  25. scrapemm-0.2.2/testing/test_retrieval.py +58 -0
  26. scrapemm-0.1.3/scrapemm/__init__.py +0 -4
  27. scrapemm-0.1.3/scrapemm/api_keys.py +0 -44
  28. scrapemm-0.1.3/scripts/example.py +0 -9
  29. scrapemm-0.1.3/testing/test_retrieval.py +0 -37
  30. {scrapemm-0.1.3 → scrapemm-0.2.2}/LICENSE +0 -0
  31. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/dependency_links.txt +0 -0
  32. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/top_level.txt +0 -0
  33. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/__init__.py +0 -0
  34. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/no_bot_domains.txt +0 -0
  35. {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/util.py +0 -0
  36. {scrapemm-0.1.3 → scrapemm-0.2.2}/setup.cfg +0 -0
  37. {scrapemm-0.1.3 → scrapemm-0.2.2}/testing/test_utils.py +0 -0
@@ -2,4 +2,5 @@
2
2
  /.idea/
3
3
  /scrapeMM.egg-info/
4
4
  /dist/
5
- /config.yaml
5
+ /config.yaml
6
+ __pycache__/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.1.3
3
+ Version: 0.2.2
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
@@ -13,9 +13,12 @@ Requires-Dist: ezmm
13
13
  Requires-Dist: telethon
14
14
  Requires-Dist: tweepy
15
15
  Requires-Dist: markdownify
16
- Requires-Dist: keyring
17
16
  Requires-Dist: platformdirs
18
17
  Requires-Dist: PyYAML
18
+ Requires-Dist: atproto
19
+ Requires-Dist: TikTokResearchApi
20
+ Requires-Dist: yt-dlp
21
+ Requires-Dist: cryptography
19
22
  Dynamic: license-file
20
23
 
21
24
  # scrapeMM: Multimodal Web Retrieval
@@ -26,11 +29,15 @@ This library aims to help developers and researchers to easily access multimodal
26
29
  ## Usage
27
30
  ```python
28
31
  from scrapemm import retrieve
32
+ import asyncio
29
33
 
30
34
  url = "https://example.com"
31
- result = retrieve(url)
35
+ loop = asyncio.get_event_loop()
36
+ result = loop.run_until_complete(retrieve(url))
32
37
  result.render()
33
38
  ```
39
+ `scrapeMM` will ask you for the **API keys** needed for the social media integrations. You may skip them if you don't need them.
40
+ You will also be prompted to choose a **password** that is used to secure the secrets in an encrypted file.
34
41
 
35
42
  ## How it works
36
43
  ```
@@ -44,7 +51,9 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl).
44
51
  ## Supported Proprietary APIs
45
52
  - ✅ X/Twitter
46
53
  - ✅ Telegram
54
+ - ✅ Bluesky
55
+ - ✅ TikTok
56
+ - ⏳ Threads
57
+ - ⏳ Reddit
47
58
  - ⏳ Facebook
48
59
  - ⏳ Instagram
49
- - ⏳ Threads
50
- - ⏳ TikTok
@@ -6,11 +6,15 @@ This library aims to help developers and researchers to easily access multimodal
6
6
  ## Usage
7
7
  ```python
8
8
  from scrapemm import retrieve
9
+ import asyncio
9
10
 
10
11
  url = "https://example.com"
11
- result = retrieve(url)
12
+ loop = asyncio.get_event_loop()
13
+ result = loop.run_until_complete(retrieve(url))
12
14
  result.render()
13
15
  ```
16
+ `scrapeMM` will ask you for the **API keys** needed for the social media integrations. You may skip them if you don't need them.
17
+ You will also be prompted to choose a **password** that is used to secure the secrets in an encrypted file.
14
18
 
15
19
  ## How it works
16
20
  ```
@@ -24,7 +28,9 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl).
24
28
  ## Supported Proprietary APIs
25
29
  - ✅ X/Twitter
26
30
  - ✅ Telegram
31
+ - ✅ Bluesky
32
+ - ✅ TikTok
33
+ - ⏳ Threads
34
+ - ⏳ Reddit
27
35
  - ⏳ Facebook
28
36
  - ⏳ Instagram
29
- - ⏳ Threads
30
- - ⏳ TikTok
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapeMM"
3
- version = "0.1.3"
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"
@@ -11,9 +11,12 @@ dependencies = [
11
11
  "telethon",
12
12
  "tweepy",
13
13
  "markdownify",
14
- "keyring",
15
14
  "platformdirs",
16
- "PyYAML"
15
+ "PyYAML",
16
+ "atproto",
17
+ "TikTokResearchApi",
18
+ "yt-dlp",
19
+ "cryptography",
17
20
  ]
18
21
 
19
22
  [project.urls]
@@ -21,7 +24,7 @@ Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
21
24
  Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
22
25
 
23
26
  [build-system]
24
- requires = ["setuptools>=60", "setuptools-scm>=8.0"]
27
+ requires = ["setuptools"]
25
28
  build-backend = "setuptools.build_meta"
26
29
 
27
30
  [tool.setuptools.packages.find]
@@ -30,4 +33,4 @@ namespaces = false # Disable implicit namespaces
30
33
  # exclude = ["*.egg-info", "tests*", "in*", "scripts*", "temp*", "secrets.yaml", "dist*"]
31
34
 
32
35
  [tool.setuptools.package-data]
33
- "*" = ["*.txt"] # Include all .txt files in the build
36
+ "*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
@@ -9,7 +9,10 @@ tweepy~=4.16.0
9
9
  markdownify~=1.1.0
10
10
  tqdm~=4.66.3
11
11
  pytest-asyncio
12
- keyring~=25.6.0
13
12
  async_lru
14
13
  oauthlib
15
- platformdirs~=4.3.8
14
+ platformdirs~=4.3.8
15
+ atproto
16
+ TikTokResearchApi
17
+ yt-dlp
18
+ cryptography
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapeMM
3
- Version: 0.1.3
3
+ Version: 0.2.2
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
@@ -13,9 +13,12 @@ Requires-Dist: ezmm
13
13
  Requires-Dist: telethon
14
14
  Requires-Dist: tweepy
15
15
  Requires-Dist: markdownify
16
- Requires-Dist: keyring
17
16
  Requires-Dist: platformdirs
18
17
  Requires-Dist: PyYAML
18
+ Requires-Dist: atproto
19
+ Requires-Dist: TikTokResearchApi
20
+ Requires-Dist: yt-dlp
21
+ Requires-Dist: cryptography
19
22
  Dynamic: license-file
20
23
 
21
24
  # scrapeMM: Multimodal Web Retrieval
@@ -26,11 +29,15 @@ This library aims to help developers and researchers to easily access multimodal
26
29
  ## Usage
27
30
  ```python
28
31
  from scrapemm import retrieve
32
+ import asyncio
29
33
 
30
34
  url = "https://example.com"
31
- result = retrieve(url)
35
+ loop = asyncio.get_event_loop()
36
+ result = loop.run_until_complete(retrieve(url))
32
37
  result.render()
33
38
  ```
39
+ `scrapeMM` will ask you for the **API keys** needed for the social media integrations. You may skip them if you don't need them.
40
+ You will also be prompted to choose a **password** that is used to secure the secrets in an encrypted file.
34
41
 
35
42
  ## How it works
36
43
  ```
@@ -44,7 +51,9 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl).
44
51
  ## Supported Proprietary APIs
45
52
  - ✅ X/Twitter
46
53
  - ✅ Telegram
54
+ - ✅ Bluesky
55
+ - ✅ TikTok
56
+ - ⏳ Threads
57
+ - ⏳ Reddit
47
58
  - ⏳ Facebook
48
59
  - ⏳ Instagram
49
- - ⏳ Threads
50
- - ⏳ TikTok
@@ -9,18 +9,23 @@ scrapeMM.egg-info/dependency_links.txt
9
9
  scrapeMM.egg-info/requires.txt
10
10
  scrapeMM.egg-info/top_level.txt
11
11
  scrapemm/__init__.py
12
- scrapemm/api_keys.py
13
12
  scrapemm/common.py
14
13
  scrapemm/retrieval.py
14
+ scrapemm/secrets.py
15
15
  scrapemm/util.py
16
16
  scrapemm/integrations/__init__.py
17
17
  scrapemm/integrations/base.py
18
+ scrapemm/integrations/bluesky.py
19
+ scrapemm/integrations/fb.py
20
+ scrapemm/integrations/instagram.py
18
21
  scrapemm/integrations/telegram.py
22
+ scrapemm/integrations/tiktok.py
19
23
  scrapemm/integrations/x.py
20
24
  scrapemm/scraping/__init__.py
21
25
  scrapemm/scraping/firecrawl.py
22
26
  scrapemm/scraping/no_bot_domains.txt
23
27
  scrapemm/scraping/util.py
28
+ scrapemm/scraping/ytdlp.py
24
29
  scripts/example.py
25
30
  testing/test_retrieval.py
26
31
  testing/test_utils.py
@@ -2,6 +2,9 @@ ezmm
2
2
  telethon
3
3
  tweepy
4
4
  markdownify
5
- keyring
6
5
  platformdirs
7
6
  PyYAML
7
+ atproto
8
+ TikTokResearchApi
9
+ yt-dlp
10
+ cryptography
@@ -0,0 +1,4 @@
1
+ import scrapemm.common
2
+ from .secrets import configure_secrets
3
+ from .retrieval import retrieve
4
+ from .integrations import Telegram, X
@@ -18,6 +18,7 @@ if not logger.hasHandlers():
18
18
  formatter = logging.Formatter('[%(levelname)s]: %(message)s')
19
19
  handler.setFormatter(formatter)
20
20
  logger.addHandler(handler)
21
+ logger.propagate = False
21
22
 
22
23
  # Set up config directory
23
24
  CONFIG_DIR = Path(user_config_dir(APP_NAME))
@@ -1,13 +1,22 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("scrapeMM")
4
+ logger.debug("Starting integrations...")
5
+
1
6
  from typing import Optional
2
7
 
3
8
  import aiohttp
4
9
  from ezmm import MultimodalSequence
5
10
 
6
11
  from scrapemm.util import get_domain
12
+ from .bluesky import Bluesky
13
+ from .fb import Facebook
14
+ from .instagram import Instagram
7
15
  from .telegram import Telegram
16
+ from .tiktok import TikTok
8
17
  from .x import X
9
18
 
10
- RETRIEVAL_INTEGRATIONS = [X(), Telegram()]
19
+ RETRIEVAL_INTEGRATIONS = [X(), Telegram(), Bluesky(), TikTok(), Instagram(), Facebook()]
11
20
  DOMAIN_TO_INTEGRATION = {domain: integration
12
21
  for integration in RETRIEVAL_INTEGRATIONS
13
22
  for domain in integration.domains
@@ -1,3 +1,5 @@
1
+ from typing import Optional
2
+
1
3
  import aiohttp
2
4
  from ezmm import MultimodalSequence
3
5
 
@@ -9,6 +11,6 @@ class RetrievalIntegration:
9
11
  domains: list[str] # The domains supported by this integration
10
12
  connected: bool = False
11
13
 
12
- async def get(self, url: str, session: aiohttp.ClientSession) -> MultimodalSequence:
14
+ async def get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
13
15
  """Retrieves the contents present at the given URL."""
14
16
  raise NotImplementedError
@@ -0,0 +1,237 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ import aiohttp
5
+ from atproto_client.exceptions import RequestErrorBase
6
+ from atproto_client.models.common import XrpcError
7
+ from ezmm import MultimodalSequence, download_image, download_video
8
+
9
+ from scrapemm.secrets import get_secret
10
+ from scrapemm.integrations.base import RetrievalIntegration
11
+ from scrapemm.util import get_domain
12
+
13
+ logger = logging.getLogger("scrapeMM")
14
+
15
+
16
+ class Bluesky(RetrievalIntegration):
17
+ domains = ["bsky.app"]
18
+
19
+ def __init__(self):
20
+ self.username = get_secret("bluesky_username")
21
+ self.password = get_secret("bluesky_password")
22
+
23
+ if not (self.username and self.password):
24
+ logger.warning("❌ Bluesky integration not configured: Missing username or password.")
25
+ self.connected = False
26
+ return
27
+
28
+ from atproto import Client
29
+ self.client = Client()
30
+ self.authenticated = False
31
+ self._authenticate()
32
+
33
+ async def get(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
34
+ if not self.authenticated:
35
+ logger.error("❌ Not authenticated with Bluesky. Cannot retrieve content.")
36
+ return None
37
+
38
+ if get_domain(url) not in self.domains:
39
+ logger.error(f"❌ Invalid domain for Bluesky: {get_domain(url)}")
40
+ return None
41
+
42
+ if "post" in url:
43
+ result = await self._retrieve_post(url, session)
44
+ else:
45
+ result = await self._retrieve_profile(url, session)
46
+
47
+ return result
48
+
49
+ async def _retrieve_post(self, url: str, session: aiohttp.ClientSession = None) -> Optional[MultimodalSequence]:
50
+ """Retrieve a post from the given Bluesky URL."""
51
+ uri = self._construct_uri(url)
52
+ if not uri:
53
+ logger.error(f"❌ Could not construct URI for Bluesky post: {url}")
54
+ return None
55
+
56
+ try:
57
+ thread_response = self.client.get_post_thread(uri=uri, depth=0, parent_height=0)
58
+ thread = thread_response.thread
59
+
60
+ if hasattr(thread, 'py_type'):
61
+ thread_type = getattr(thread, 'py_type')
62
+ if thread_type == 'app.bsky.feed.defs#notFoundPost':
63
+ raise Exception(f"Post not found for url {url}")
64
+ if thread_type == 'app.bsky.feed.defs#blockedPost':
65
+ raise Exception(f"Post is blocked for url {url}")
66
+
67
+ # Extract post data
68
+ post_view = thread.post
69
+ record = post_view.record
70
+
71
+ # Basic post information
72
+ post_text = record.text if hasattr(record, 'text') else ''
73
+ created_at_str = record.created_at[:-1] if hasattr(record, 'created_at') else None
74
+
75
+ # Author information
76
+ author = post_view.author
77
+ author_username = author.handle if hasattr(author, 'handle') else ''
78
+ author_display_name = author.display_name if hasattr(author, 'display_name') else ''
79
+
80
+ # Engagement metrics
81
+ like_count = post_view.like_count if hasattr(post_view, 'like_count') else 0
82
+ comment_count = post_view.reply_count if hasattr(post_view, 'reply_count') else 0
83
+ share_count = post_view.repost_count if hasattr(post_view, 'repost_count') else 0
84
+
85
+ # Extract media (images)
86
+ media = []
87
+ # Check for embedded images in the post
88
+ if hasattr(post_view, 'embed'):
89
+ embed = post_view.embed
90
+
91
+ # For image embeds
92
+ if hasattr(embed, 'py_type') and getattr(embed, 'py_type') == 'app.bsky.embed.images#view':
93
+ for img in embed.images:
94
+ if hasattr(img, 'fullsize'):
95
+ img_url = img.fullsize
96
+ img = await download_image(img_url, session)
97
+ media.append(img)
98
+ # For video embeds
99
+ elif hasattr(embed, 'py_type') and getattr(embed, 'py_type') == 'app.bsky.embed.video#view':
100
+ video = await download_video(embed.playlist, session)
101
+ if video:
102
+ media.append(video)
103
+
104
+ # Extract hashtags and mentions
105
+ hashtags, mentions, external_links = [], [], []
106
+ # Parse facets (rich text features like links, mentions, etc.)
107
+ if hasattr(record, 'facets') and record.facets:
108
+ for facet in record.facets:
109
+ if hasattr(facet, 'features'):
110
+ for feature in facet.features:
111
+ if hasattr(feature, 'py_type'):
112
+ feature_type = getattr(feature, 'py_type')
113
+ if feature_type == 'app.bsky.richtext.facet#tag':
114
+ hashtags.append(feature.tag if hasattr(feature, 'tag') else '')
115
+ elif feature_type == 'app.bsky.richtext.facet#mention':
116
+ mentions.append(feature.did if hasattr(feature, 'did') else '')
117
+ elif feature_type == 'app.bsky.richtext.facet#link':
118
+ external_links.append(feature.uri)
119
+
120
+ # Check if this is a reply
121
+ is_reply, reply_to = False, None
122
+ if hasattr(record, 'reply'):
123
+ is_reply = True
124
+ # Get the parent post's author
125
+ if hasattr(record.reply, 'parent') and hasattr(record.reply.parent, 'uri'):
126
+ parent_uri = record.reply.parent.uri
127
+ post_id = parent_uri.split('/')[-1]
128
+ reply_to_post = self.client.get_posts([parent_uri]).posts[0]
129
+ reply_to_author = reply_to_post.author
130
+ reply_to = f"https://bsky.app/profile/{reply_to_author.handle}/post/{post_id}"
131
+
132
+ text = f"""**Post on Bluesky**
133
+ Author: {author_display_name} (@{author_username})
134
+ Posted on: {created_at_str}
135
+ Likes: {like_count} - Comments: {comment_count} - Shares: {share_count}
136
+ {"Reply to: " + reply_to if is_reply and reply_to else ""}
137
+ {post_text}"""
138
+ return MultimodalSequence([text, *media])
139
+
140
+ except Exception as e:
141
+ err_msg = error_to_string(e)
142
+ logger.error(f"❌ Error retrieving Bluesky post: {err_msg}")
143
+ return None
144
+
145
+ async def _retrieve_profile(self, url: str, session: aiohttp.ClientSession) -> Optional[MultimodalSequence]:
146
+ """Retrieve a profile from the given Bluesky URL."""
147
+ profile = self.client.get_profile(url.split('/')[-1])
148
+
149
+ avatar = await download_image(profile.avatar, session) if profile.avatar else None
150
+ banner = await download_image(profile.banner, session) if profile.banner else None
151
+
152
+ text = f"""**Profile on Bluesky**
153
+ User: {profile.display_name} (@{profile.handle})
154
+ Created on: {profile.created_at}
155
+ Profile image: {avatar.reference if avatar else 'None'}
156
+ Profile banner: {banner.reference if banner else 'None'}
157
+
158
+ URL: {url}
159
+ Description: {profile.description or 'No description provided'}
160
+
161
+ Metrics:
162
+ - Follower count: {profile.followers_count}
163
+ - Following count: {profile.follows_count}
164
+ - Post count: {profile.posts_count}
165
+ """
166
+ return MultimodalSequence(text)
167
+
168
+ def _authenticate(self) -> bool:
169
+ """Authenticate with Bluesky using provided credentials."""
170
+ try:
171
+ self.client.login(self.username, self.password)
172
+ self.authenticated = True
173
+ logger.info(f"✅ Successfully authenticated with Bluesky as {self.username}")
174
+ return True
175
+ except Exception as e:
176
+ logger.error(f"❌ Error authenticating with Bluesky: {str(e)}")
177
+ return False
178
+
179
+ def _construct_uri(self, url: str) -> str:
180
+ # Extract post URI from the URL - Bluesky URLs typically look like:
181
+ # https://bsky.app/profile/username.bsky.social/post/abcdef123
182
+ try:
183
+ # Parse URL to extract components for building the AT URI
184
+ parts = url.split('/')
185
+ if len(parts) < 5 or "bsky.app" not in url:
186
+ raise Exception(f"Invalid Bluesky URL format for {url}.")
187
+
188
+ # Find the profile part of the URL
189
+ profile_idx = -1
190
+ for i, part in enumerate(parts):
191
+ if part == "profile":
192
+ profile_idx = i
193
+ break
194
+
195
+ if profile_idx < 0 or profile_idx + 3 >= len(parts):
196
+ raise Exception(f"Could not extract profile or post ID from {url}.")
197
+
198
+ handle = parts[profile_idx + 1]
199
+ post_id = parts[profile_idx + 3]
200
+
201
+ # Resolve the handle to a DID
202
+ did = self._resolve_handle(handle)
203
+
204
+ # Construct the AT URI
205
+ uri = f"at://{did}/app.bsky.feed.post/{post_id}"
206
+
207
+ return uri
208
+
209
+ except Exception as e:
210
+ err_msg = error_to_string(e)
211
+ logger.error(f"Error retrieving Bluesky post: {err_msg}")
212
+
213
+ def _resolve_handle(self, handle: str) -> str:
214
+ """Resolve a handle to a DID."""
215
+ try:
216
+ response = self.client.resolve_handle(handle)
217
+ return response.did
218
+ except Exception as e:
219
+ err_msg = error_to_string(e)
220
+ logger.error(f"Error resolving handle: {err_msg}")
221
+ return handle # Return the handle itself as fallback
222
+
223
+
224
+ def error_to_string(error: RequestErrorBase | Exception) -> str:
225
+ """Takes an Error object containing a response and prints the contents."""
226
+ if isinstance(error, RequestErrorBase):
227
+ response = error.response
228
+ code = response.status_code
229
+ content = response.content
230
+ if isinstance(content, XrpcError):
231
+ error_type = content.error
232
+ msg = content.message
233
+ return f"Error {code} ({error_type}): {msg}."
234
+ else:
235
+ return f"Error {code}: {content}."
236
+ else:
237
+ return str(error)
@@ -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 ""