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.
- {scrapemm-0.1.3 → scrapemm-0.2.2}/.gitignore +2 -1
- {scrapemm-0.1.3/scrapeMM.egg-info → scrapemm-0.2.2}/PKG-INFO +14 -5
- {scrapemm-0.1.3 → scrapemm-0.2.2}/README.md +9 -3
- {scrapemm-0.1.3 → scrapemm-0.2.2}/pyproject.toml +8 -5
- {scrapemm-0.1.3 → scrapemm-0.2.2}/requirements.txt +5 -2
- {scrapemm-0.1.3 → scrapemm-0.2.2/scrapeMM.egg-info}/PKG-INFO +14 -5
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/SOURCES.txt +6 -1
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/requires.txt +4 -1
- scrapemm-0.2.2/scrapemm/__init__.py +4 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/common.py +1 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/__init__.py +10 -1
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/base.py +3 -1
- scrapemm-0.2.2/scrapemm/integrations/bluesky.py +237 -0
- scrapemm-0.2.2/scrapemm/integrations/fb.py +101 -0
- scrapemm-0.2.2/scrapemm/integrations/instagram.py +87 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/telegram.py +4 -4
- scrapemm-0.2.2/scrapemm/integrations/tiktok.py +465 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/integrations/x.py +29 -24
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/retrieval.py +9 -6
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/firecrawl.py +8 -2
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/util.py +10 -4
- scrapemm-0.2.2/scrapemm/scraping/ytdlp.py +190 -0
- scrapemm-0.2.2/scrapemm/secrets.py +154 -0
- scrapemm-0.2.2/scripts/example.py +8 -0
- scrapemm-0.2.2/testing/test_retrieval.py +58 -0
- scrapemm-0.1.3/scrapemm/__init__.py +0 -4
- scrapemm-0.1.3/scrapemm/api_keys.py +0 -44
- scrapemm-0.1.3/scripts/example.py +0 -9
- scrapemm-0.1.3/testing/test_retrieval.py +0 -37
- {scrapemm-0.1.3 → scrapemm-0.2.2}/LICENSE +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/__init__.py +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/scraping/no_bot_domains.txt +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/scrapemm/util.py +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/setup.cfg +0 -0
- {scrapemm-0.1.3 → scrapemm-0.2.2}/testing/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
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
|
|
@@ -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 ""
|