scrapeMM 0.1.2__tar.gz → 0.2.1__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.2.1/.gitignore +6 -0
- {scrapemm-0.1.2/scrapeMM.egg-info → scrapemm-0.2.1}/PKG-INFO +17 -6
- {scrapemm-0.1.2 → scrapemm-0.2.1}/README.md +9 -3
- scrapemm-0.2.1/pyproject.toml +36 -0
- scrapemm-0.2.1/requirements.txt +18 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1/scrapeMM.egg-info}/PKG-INFO +17 -6
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapeMM.egg-info/SOURCES.txt +10 -2
- scrapemm-0.2.1/scrapeMM.egg-info/requires.txt +10 -0
- scrapemm-0.2.1/scrapemm/__init__.py +4 -0
- scrapemm-0.2.1/scrapemm/common.py +47 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/integrations/__init__.py +8 -1
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/integrations/base.py +3 -1
- scrapemm-0.2.1/scrapemm/integrations/bluesky.py +237 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/integrations/telegram.py +5 -5
- scrapemm-0.2.1/scrapemm/integrations/tiktok.py +469 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/integrations/x.py +16 -12
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/retrieval.py +10 -7
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/scraping/firecrawl.py +36 -10
- scrapemm-0.2.1/scrapemm/scraping/no_bot_domains.txt +15 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/scraping/util.py +8 -10
- scrapemm-0.2.1/scrapemm/secrets.py +149 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/util.py +7 -3
- scrapemm-0.2.1/scripts/example.py +8 -0
- scrapemm-0.2.1/testing/test_retrieval.py +58 -0
- scrapemm-0.2.1/testing/test_utils.py +19 -0
- scrapemm-0.1.2/pyproject.toml +0 -28
- scrapemm-0.1.2/scrapeMM.egg-info/requires.txt +0 -5
- scrapemm-0.1.2/scrapemm/__init__.py +0 -4
- scrapemm-0.1.2/scrapemm/api_keys.py +0 -43
- scrapemm-0.1.2/scrapemm/common.py +0 -42
- {scrapemm-0.1.2 → scrapemm-0.2.1}/LICENSE +0 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/scrapemm/scraping/__init__.py +0 -0
- {scrapemm-0.1.2 → scrapemm-0.2.1}/setup.cfg +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
7
7
|
Project-URL: Homepage, https://github.com/multimodal-ai-lab/scrapeMM
|
|
8
|
-
Project-URL:
|
|
8
|
+
Project-URL: Issues, https://github.com/multimodal-ai-lab/scrapeMM
|
|
9
9
|
Requires-Python: >=3.8
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
@@ -13,7 +13,12 @@ Requires-Dist: ezmm
|
|
|
13
13
|
Requires-Dist: telethon
|
|
14
14
|
Requires-Dist: tweepy
|
|
15
15
|
Requires-Dist: markdownify
|
|
16
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: platformdirs
|
|
17
|
+
Requires-Dist: PyYAML
|
|
18
|
+
Requires-Dist: atproto
|
|
19
|
+
Requires-Dist: TikTokResearchApi
|
|
20
|
+
Requires-Dist: yt-dlp
|
|
21
|
+
Requires-Dist: cryptography
|
|
17
22
|
Dynamic: license-file
|
|
18
23
|
|
|
19
24
|
# scrapeMM: Multimodal Web Retrieval
|
|
@@ -24,11 +29,15 @@ This library aims to help developers and researchers to easily access multimodal
|
|
|
24
29
|
## Usage
|
|
25
30
|
```python
|
|
26
31
|
from scrapemm import retrieve
|
|
32
|
+
import asyncio
|
|
27
33
|
|
|
28
34
|
url = "https://example.com"
|
|
29
|
-
|
|
35
|
+
loop = asyncio.get_event_loop()
|
|
36
|
+
result = loop.run_until_complete(retrieve(url))
|
|
30
37
|
result.render()
|
|
31
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.
|
|
32
41
|
|
|
33
42
|
## How it works
|
|
34
43
|
```
|
|
@@ -42,7 +51,9 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl).
|
|
|
42
51
|
## Supported Proprietary APIs
|
|
43
52
|
- ✅ X/Twitter
|
|
44
53
|
- ✅ Telegram
|
|
54
|
+
- ✅ Bluesky
|
|
55
|
+
- ✅ TikTok
|
|
56
|
+
- ⏳ Threads
|
|
57
|
+
- ⏳ Reddit
|
|
45
58
|
- ⏳ Facebook
|
|
46
59
|
- ⏳ Instagram
|
|
47
|
-
- ⏳ Threads
|
|
48
|
-
- ⏳ 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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scrapeMM"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "LLM-friendly scraper for media and text from social media and the open web."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.8"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{name = "Mark Rothermel", email = "mark.rothermel@tu-darmstadt.de"}]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"ezmm",
|
|
11
|
+
"telethon",
|
|
12
|
+
"tweepy",
|
|
13
|
+
"markdownify",
|
|
14
|
+
"platformdirs",
|
|
15
|
+
"PyYAML",
|
|
16
|
+
"atproto",
|
|
17
|
+
"TikTokResearchApi",
|
|
18
|
+
"yt-dlp",
|
|
19
|
+
"cryptography",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
|
|
24
|
+
Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["setuptools>=60", "setuptools-scm>=8.0"]
|
|
28
|
+
build-backend = "setuptools.build_meta"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
namespaces = false # Disable implicit namespaces
|
|
32
|
+
# include = ["scrapemm/*"]
|
|
33
|
+
# exclude = ["*.egg-info", "tests*", "in*", "scripts*", "temp*", "secrets.yaml", "dist*"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
"*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pytest~=8.4.1
|
|
2
|
+
aiohttp~=3.12.13
|
|
3
|
+
numpy~=1.26.4
|
|
4
|
+
requests~=2.32.4
|
|
5
|
+
ezMM~=0.3.1
|
|
6
|
+
Telethon~=1.40.0
|
|
7
|
+
PyYAML~=6.0.1
|
|
8
|
+
tweepy~=4.16.0
|
|
9
|
+
markdownify~=1.1.0
|
|
10
|
+
tqdm~=4.66.3
|
|
11
|
+
pytest-asyncio
|
|
12
|
+
async_lru
|
|
13
|
+
oauthlib
|
|
14
|
+
platformdirs~=4.3.8
|
|
15
|
+
atproto
|
|
16
|
+
TikTokResearchApi
|
|
17
|
+
yt-dlp
|
|
18
|
+
cryptography
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
7
7
|
Project-URL: Homepage, https://github.com/multimodal-ai-lab/scrapeMM
|
|
8
|
-
Project-URL:
|
|
8
|
+
Project-URL: Issues, https://github.com/multimodal-ai-lab/scrapeMM
|
|
9
9
|
Requires-Python: >=3.8
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
@@ -13,7 +13,12 @@ Requires-Dist: ezmm
|
|
|
13
13
|
Requires-Dist: telethon
|
|
14
14
|
Requires-Dist: tweepy
|
|
15
15
|
Requires-Dist: markdownify
|
|
16
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: platformdirs
|
|
17
|
+
Requires-Dist: PyYAML
|
|
18
|
+
Requires-Dist: atproto
|
|
19
|
+
Requires-Dist: TikTokResearchApi
|
|
20
|
+
Requires-Dist: yt-dlp
|
|
21
|
+
Requires-Dist: cryptography
|
|
17
22
|
Dynamic: license-file
|
|
18
23
|
|
|
19
24
|
# scrapeMM: Multimodal Web Retrieval
|
|
@@ -24,11 +29,15 @@ This library aims to help developers and researchers to easily access multimodal
|
|
|
24
29
|
## Usage
|
|
25
30
|
```python
|
|
26
31
|
from scrapemm import retrieve
|
|
32
|
+
import asyncio
|
|
27
33
|
|
|
28
34
|
url = "https://example.com"
|
|
29
|
-
|
|
35
|
+
loop = asyncio.get_event_loop()
|
|
36
|
+
result = loop.run_until_complete(retrieve(url))
|
|
30
37
|
result.render()
|
|
31
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.
|
|
32
41
|
|
|
33
42
|
## How it works
|
|
34
43
|
```
|
|
@@ -42,7 +51,9 @@ Web scraping is done with [Firecrawl](https://github.com/mendableai/firecrawl).
|
|
|
42
51
|
## Supported Proprietary APIs
|
|
43
52
|
- ✅ X/Twitter
|
|
44
53
|
- ✅ Telegram
|
|
54
|
+
- ✅ Bluesky
|
|
55
|
+
- ✅ TikTok
|
|
56
|
+
- ⏳ Threads
|
|
57
|
+
- ⏳ Reddit
|
|
45
58
|
- ⏳ Facebook
|
|
46
59
|
- ⏳ Instagram
|
|
47
|
-
- ⏳ Threads
|
|
48
|
-
- ⏳ TikTok
|
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
.gitignore
|
|
1
2
|
LICENSE
|
|
2
3
|
README.md
|
|
3
4
|
pyproject.toml
|
|
5
|
+
requirements.txt
|
|
4
6
|
scrapeMM.egg-info/PKG-INFO
|
|
5
7
|
scrapeMM.egg-info/SOURCES.txt
|
|
6
8
|
scrapeMM.egg-info/dependency_links.txt
|
|
7
9
|
scrapeMM.egg-info/requires.txt
|
|
8
10
|
scrapeMM.egg-info/top_level.txt
|
|
9
11
|
scrapemm/__init__.py
|
|
10
|
-
scrapemm/api_keys.py
|
|
11
12
|
scrapemm/common.py
|
|
12
13
|
scrapemm/retrieval.py
|
|
14
|
+
scrapemm/secrets.py
|
|
13
15
|
scrapemm/util.py
|
|
14
16
|
scrapemm/integrations/__init__.py
|
|
15
17
|
scrapemm/integrations/base.py
|
|
18
|
+
scrapemm/integrations/bluesky.py
|
|
16
19
|
scrapemm/integrations/telegram.py
|
|
20
|
+
scrapemm/integrations/tiktok.py
|
|
17
21
|
scrapemm/integrations/x.py
|
|
18
22
|
scrapemm/scraping/__init__.py
|
|
19
23
|
scrapemm/scraping/firecrawl.py
|
|
20
|
-
scrapemm/scraping/
|
|
24
|
+
scrapemm/scraping/no_bot_domains.txt
|
|
25
|
+
scrapemm/scraping/util.py
|
|
26
|
+
scripts/example.py
|
|
27
|
+
testing/test_retrieval.py
|
|
28
|
+
testing/test_utils.py
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from platformdirs import user_config_dir
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
APP_NAME = "scrapeMM"
|
|
10
|
+
|
|
11
|
+
# Set up logger
|
|
12
|
+
logger = logging.getLogger(APP_NAME)
|
|
13
|
+
logger.setLevel(logging.DEBUG)
|
|
14
|
+
|
|
15
|
+
# Only add handler if none exists (avoid duplicate logs on rerun)
|
|
16
|
+
if not logger.hasHandlers():
|
|
17
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
18
|
+
formatter = logging.Formatter('[%(levelname)s]: %(message)s')
|
|
19
|
+
handler.setFormatter(formatter)
|
|
20
|
+
logger.addHandler(handler)
|
|
21
|
+
logger.propagate = False
|
|
22
|
+
|
|
23
|
+
# Set up config directory
|
|
24
|
+
CONFIG_DIR = Path(user_config_dir(APP_NAME))
|
|
25
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
26
|
+
CONFIG_PATH = CONFIG_DIR / "config.yaml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config() -> dict:
|
|
30
|
+
if os.path.exists(CONFIG_PATH):
|
|
31
|
+
with open(CONFIG_PATH, "r") as f:
|
|
32
|
+
return yaml.safe_load(f) or {}
|
|
33
|
+
else:
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def update_config(**kwargs):
|
|
38
|
+
_config.update(kwargs)
|
|
39
|
+
yaml.dump(_config, open(CONFIG_PATH, "w"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_config_var(name: str, default=None) -> str:
|
|
43
|
+
return _config.get(name, default)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Load config
|
|
47
|
+
_config = load_config()
|
|
@@ -1,13 +1,20 @@
|
|
|
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
|
|
7
13
|
from .telegram import Telegram
|
|
14
|
+
from .tiktok import TikTok
|
|
8
15
|
from .x import X
|
|
9
16
|
|
|
10
|
-
RETRIEVAL_INTEGRATIONS = [X(), Telegram()]
|
|
17
|
+
RETRIEVAL_INTEGRATIONS = [X(), Telegram(), Bluesky(), TikTok()]
|
|
11
18
|
DOMAIN_TO_INTEGRATION = {domain: integration
|
|
12
19
|
for integration in RETRIEVAL_INTEGRATIONS
|
|
13
20
|
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)
|
|
@@ -7,11 +7,11 @@ from ezmm import MultimodalSequence, Image, Item, Video
|
|
|
7
7
|
from telethon import TelegramClient
|
|
8
8
|
from telethon.tl.types import Channel, User
|
|
9
9
|
|
|
10
|
-
from scrapemm.
|
|
10
|
+
from scrapemm.secrets import get_secret
|
|
11
11
|
from scrapemm.integrations.base import RetrievalIntegration
|
|
12
12
|
from scrapemm.util import get_domain
|
|
13
13
|
|
|
14
|
-
logger = logging.getLogger("
|
|
14
|
+
logger = logging.getLogger("scrapeMM")
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class Telegram(RetrievalIntegration):
|
|
@@ -21,9 +21,9 @@ class Telegram(RetrievalIntegration):
|
|
|
21
21
|
session_path = "temp/telegram"
|
|
22
22
|
|
|
23
23
|
def __init__(self):
|
|
24
|
-
api_id = int(
|
|
25
|
-
api_hash =
|
|
26
|
-
bot_token =
|
|
24
|
+
api_id = int(get_secret("telegram_api_id")) if get_secret("telegram_api_id") else None
|
|
25
|
+
api_hash = get_secret("telegram_api_hash")
|
|
26
|
+
bot_token = get_secret("telegram_bot_token")
|
|
27
27
|
|
|
28
28
|
if api_id and api_hash and bot_token:
|
|
29
29
|
self.client = TelegramClient(self.session_path, api_id, api_hash)
|