scrapeMM 0.6.2__tar.gz → 0.6.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scrapemm-0.6.2/scrapeMM.egg-info → scrapemm-0.6.4}/PKG-INFO +39 -11
- {scrapemm-0.6.2 → scrapemm-0.6.4}/README.md +13 -0
- scrapemm-0.6.4/pyproject.toml +62 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4/scrapeMM.egg-info}/PKG-INFO +39 -11
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapeMM.egg-info/SOURCES.txt +0 -1
- scrapemm-0.6.4/scrapeMM.egg-info/requires.txt +28 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/__init__.py +1 -1
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/decodo/decodo.py +14 -24
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/fb.py +47 -14
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/instagram.py +0 -1
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/perma_cc.py +175 -14
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/telegram.py +3 -3
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/x.py +1 -1
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/retrieval.py +11 -2
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/secrets.py +6 -8
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/util.py +28 -6
- scrapemm-0.6.4/scripts/example.py +11 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/testing/test_retrieval.py +0 -1
- scrapemm-0.6.2/pyproject.toml +0 -44
- scrapemm-0.6.2/requirements.txt +0 -25
- scrapemm-0.6.2/scrapeMM.egg-info/requires.txt +0 -12
- scrapemm-0.6.2/scripts/example.py +0 -10
- {scrapemm-0.6.2 → scrapemm-0.6.4}/.gitignore +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/LICENSE +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/common/__init__.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/common/exceptions.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/common/scraping_response.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/__init__.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/common.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/images.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/media.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/requests.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/util.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/download/videos.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/__init__.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/archive_org.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/archive_today.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/base.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/bluesky.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/decodo/__init__.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/decodo/html_postprocessor.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/firecrawl/__init__.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/firecrawl/firecrawl.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/firecrawl/no_bot_domains.txt +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/mvault.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/tiktok.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/youtube.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/scrapemm/integrations/ytdlp.py +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/setup.cfg +0 -0
- {scrapemm-0.6.2 → scrapemm-0.6.4}/testing/test_utils.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.6.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.6.4
|
|
4
|
+
Summary: Multimodal scraper for social media, internet archives, 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
|
|
@@ -9,18 +9,33 @@ Project-URL: Issues, https://github.com/multimodal-ai-lab/scrapeMM
|
|
|
9
9
|
Requires-Python: >=3.10
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Requires-Dist:
|
|
13
|
-
Requires-Dist:
|
|
14
|
-
Requires-Dist: tweepy
|
|
15
|
-
Requires-Dist: markdownify
|
|
16
|
-
Requires-Dist: platformdirs
|
|
17
|
-
Requires-Dist: PyYAML
|
|
12
|
+
Requires-Dist: aiohttp~=3.14.1
|
|
13
|
+
Requires-Dist: async_lru
|
|
18
14
|
Requires-Dist: atproto
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
15
|
+
Requires-Dist: beautifulsoup4
|
|
16
|
+
Requires-Dist: backports.zstd
|
|
17
|
+
Requires-Dist: brotli
|
|
21
18
|
Requires-Dist: cryptography
|
|
22
|
-
Requires-Dist:
|
|
19
|
+
Requires-Dist: curl_cffi~=0.15.0
|
|
20
|
+
Requires-Dist: ezmm~=0.5.1
|
|
21
|
+
Requires-Dist: firecrawl-py~=4.32.0
|
|
22
|
+
Requires-Dist: markdownify~=1.2.3
|
|
23
|
+
Requires-Dist: oauthlib
|
|
24
|
+
Requires-Dist: pip-system-certs
|
|
25
|
+
Requires-Dist: platformdirs~=4.10.0
|
|
23
26
|
Requires-Dist: playwright
|
|
27
|
+
Requires-Dist: playwright-stealth
|
|
28
|
+
Requires-Dist: prompt_toolkit
|
|
29
|
+
Requires-Dist: PyYAML~=6.0.1
|
|
30
|
+
Requires-Dist: requests~=2.34.2
|
|
31
|
+
Requires-Dist: telethon~=1.44.0
|
|
32
|
+
Requires-Dist: TikTokResearchApi~=1.0.4
|
|
33
|
+
Requires-Dist: tqdm~=4.68.4
|
|
34
|
+
Requires-Dist: tweepy~=4.17.0
|
|
35
|
+
Requires-Dist: yt-dlp[curl-cffi,default]
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest; extra == "test"
|
|
38
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
24
39
|
Dynamic: license-file
|
|
25
40
|
|
|
26
41
|
# scrapeMM: Multimodal Web Retrieval
|
|
@@ -33,6 +48,19 @@ This library aims to help developers and researchers to easily access multimodal
|
|
|
33
48
|
In Conda, you can install it with `conda install -c conda-forge ffmpeg`.
|
|
34
49
|
* **If you want to scrape Perma.cc archive records or Facebook photos**, you'll need to install playwright with `pip install playwright` and running `playwright install`.
|
|
35
50
|
|
|
51
|
+
## Configure
|
|
52
|
+
To set the API secrets, run
|
|
53
|
+
```python
|
|
54
|
+
from scrapemm import configure_secrets
|
|
55
|
+
configure_secrets()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To set the Firecrawl URL, run
|
|
59
|
+
```python
|
|
60
|
+
from scrapemm import update_config
|
|
61
|
+
update_config(firecrawl_url="your_url")
|
|
62
|
+
```
|
|
63
|
+
|
|
36
64
|
## Usage
|
|
37
65
|
```python
|
|
38
66
|
from scrapemm import retrieve
|
|
@@ -8,6 +8,19 @@ This library aims to help developers and researchers to easily access multimodal
|
|
|
8
8
|
In Conda, you can install it with `conda install -c conda-forge ffmpeg`.
|
|
9
9
|
* **If you want to scrape Perma.cc archive records or Facebook photos**, you'll need to install playwright with `pip install playwright` and running `playwright install`.
|
|
10
10
|
|
|
11
|
+
## Configure
|
|
12
|
+
To set the API secrets, run
|
|
13
|
+
```python
|
|
14
|
+
from scrapemm import configure_secrets
|
|
15
|
+
configure_secrets()
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
To set the Firecrawl URL, run
|
|
19
|
+
```python
|
|
20
|
+
from scrapemm import update_config
|
|
21
|
+
update_config(firecrawl_url="your_url")
|
|
22
|
+
```
|
|
23
|
+
|
|
11
24
|
## Usage
|
|
12
25
|
```python
|
|
13
26
|
from scrapemm import retrieve
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scrapeMM"
|
|
3
|
+
version = "0.6.4"
|
|
4
|
+
description = "Multimodal scraper for social media, internet archives, and the open web."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{name = "Mark Rothermel", email = "mark.rothermel@tu-darmstadt.de"}]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"aiohttp~=3.14.1",
|
|
11
|
+
"async_lru",
|
|
12
|
+
"atproto",
|
|
13
|
+
"beautifulsoup4",
|
|
14
|
+
"backports.zstd", # For better content parsing with Firecrawl
|
|
15
|
+
"brotli", # Required to read brotli-encoded media
|
|
16
|
+
"cryptography",
|
|
17
|
+
"curl_cffi~=0.15.0", # Provides browser binaries for impersonation
|
|
18
|
+
"ezmm~=0.5.1",
|
|
19
|
+
"firecrawl-py~=4.32.0",
|
|
20
|
+
"markdownify~=1.2.3",
|
|
21
|
+
"oauthlib",
|
|
22
|
+
"pip-system-certs", # For better SSL certificate handling
|
|
23
|
+
"platformdirs~=4.10.0",
|
|
24
|
+
"playwright",
|
|
25
|
+
"playwright-stealth",
|
|
26
|
+
"prompt_toolkit", # For handling user terminal input
|
|
27
|
+
"PyYAML~=6.0.1",
|
|
28
|
+
"requests~=2.34.2",
|
|
29
|
+
"telethon~=1.44.0",
|
|
30
|
+
"TikTokResearchApi~=1.0.4",
|
|
31
|
+
"tqdm~=4.68.4",
|
|
32
|
+
"tweepy~=4.17.0",
|
|
33
|
+
"yt-dlp[default,curl-cffi]",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
test = [
|
|
38
|
+
"pytest",
|
|
39
|
+
"pytest-asyncio",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
|
|
44
|
+
Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["setuptools"]
|
|
48
|
+
build-backend = "setuptools.build_meta"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
namespaces = false # Disable implicit namespaces
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
"*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = [
|
|
58
|
+
"testing/",
|
|
59
|
+
]
|
|
60
|
+
asyncio_mode = "auto"
|
|
61
|
+
asyncio_default_test_loop_scope = "session"
|
|
62
|
+
asyncio_default_fixture_loop_scope = "session"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.6.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.6.4
|
|
4
|
+
Summary: Multimodal scraper for social media, internet archives, 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
|
|
@@ -9,18 +9,33 @@ Project-URL: Issues, https://github.com/multimodal-ai-lab/scrapeMM
|
|
|
9
9
|
Requires-Python: >=3.10
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Requires-Dist:
|
|
13
|
-
Requires-Dist:
|
|
14
|
-
Requires-Dist: tweepy
|
|
15
|
-
Requires-Dist: markdownify
|
|
16
|
-
Requires-Dist: platformdirs
|
|
17
|
-
Requires-Dist: PyYAML
|
|
12
|
+
Requires-Dist: aiohttp~=3.14.1
|
|
13
|
+
Requires-Dist: async_lru
|
|
18
14
|
Requires-Dist: atproto
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
15
|
+
Requires-Dist: beautifulsoup4
|
|
16
|
+
Requires-Dist: backports.zstd
|
|
17
|
+
Requires-Dist: brotli
|
|
21
18
|
Requires-Dist: cryptography
|
|
22
|
-
Requires-Dist:
|
|
19
|
+
Requires-Dist: curl_cffi~=0.15.0
|
|
20
|
+
Requires-Dist: ezmm~=0.5.1
|
|
21
|
+
Requires-Dist: firecrawl-py~=4.32.0
|
|
22
|
+
Requires-Dist: markdownify~=1.2.3
|
|
23
|
+
Requires-Dist: oauthlib
|
|
24
|
+
Requires-Dist: pip-system-certs
|
|
25
|
+
Requires-Dist: platformdirs~=4.10.0
|
|
23
26
|
Requires-Dist: playwright
|
|
27
|
+
Requires-Dist: playwright-stealth
|
|
28
|
+
Requires-Dist: prompt_toolkit
|
|
29
|
+
Requires-Dist: PyYAML~=6.0.1
|
|
30
|
+
Requires-Dist: requests~=2.34.2
|
|
31
|
+
Requires-Dist: telethon~=1.44.0
|
|
32
|
+
Requires-Dist: TikTokResearchApi~=1.0.4
|
|
33
|
+
Requires-Dist: tqdm~=4.68.4
|
|
34
|
+
Requires-Dist: tweepy~=4.17.0
|
|
35
|
+
Requires-Dist: yt-dlp[curl-cffi,default]
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest; extra == "test"
|
|
38
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
24
39
|
Dynamic: license-file
|
|
25
40
|
|
|
26
41
|
# scrapeMM: Multimodal Web Retrieval
|
|
@@ -33,6 +48,19 @@ This library aims to help developers and researchers to easily access multimodal
|
|
|
33
48
|
In Conda, you can install it with `conda install -c conda-forge ffmpeg`.
|
|
34
49
|
* **If you want to scrape Perma.cc archive records or Facebook photos**, you'll need to install playwright with `pip install playwright` and running `playwright install`.
|
|
35
50
|
|
|
51
|
+
## Configure
|
|
52
|
+
To set the API secrets, run
|
|
53
|
+
```python
|
|
54
|
+
from scrapemm import configure_secrets
|
|
55
|
+
configure_secrets()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To set the Firecrawl URL, run
|
|
59
|
+
```python
|
|
60
|
+
from scrapemm import update_config
|
|
61
|
+
update_config(firecrawl_url="your_url")
|
|
62
|
+
```
|
|
63
|
+
|
|
36
64
|
## Usage
|
|
37
65
|
```python
|
|
38
66
|
from scrapemm import retrieve
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
aiohttp~=3.14.1
|
|
2
|
+
async_lru
|
|
3
|
+
atproto
|
|
4
|
+
beautifulsoup4
|
|
5
|
+
backports.zstd
|
|
6
|
+
brotli
|
|
7
|
+
cryptography
|
|
8
|
+
curl_cffi~=0.15.0
|
|
9
|
+
ezmm~=0.5.1
|
|
10
|
+
firecrawl-py~=4.32.0
|
|
11
|
+
markdownify~=1.2.3
|
|
12
|
+
oauthlib
|
|
13
|
+
pip-system-certs
|
|
14
|
+
platformdirs~=4.10.0
|
|
15
|
+
playwright
|
|
16
|
+
playwright-stealth
|
|
17
|
+
prompt_toolkit
|
|
18
|
+
PyYAML~=6.0.1
|
|
19
|
+
requests~=2.34.2
|
|
20
|
+
telethon~=1.44.0
|
|
21
|
+
TikTokResearchApi~=1.0.4
|
|
22
|
+
tqdm~=4.68.4
|
|
23
|
+
tweepy~=4.17.0
|
|
24
|
+
yt-dlp[curl-cffi,default]
|
|
25
|
+
|
|
26
|
+
[test]
|
|
27
|
+
pytest
|
|
28
|
+
pytest-asyncio
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .common import APP_NAME, set_wait_on_rate_limit, RateLimitError, ContentNotFoundError, logger
|
|
1
|
+
from .common import APP_NAME, set_wait_on_rate_limit, RateLimitError, ContentNotFoundError, logger, update_config
|
|
2
2
|
from .integrations import Telegram, X
|
|
3
3
|
from .retrieval import retrieve
|
|
4
4
|
from .secrets import configure_secrets
|
|
@@ -26,23 +26,21 @@ class Decodo:
|
|
|
26
26
|
DECODO_API_URL = "https://scraper-api.decodo.com/v2/scrape"
|
|
27
27
|
|
|
28
28
|
def __init__(self):
|
|
29
|
-
self.
|
|
30
|
-
self.password = None
|
|
29
|
+
self.basic_auth_token = None
|
|
31
30
|
self.n_scrapes = 0
|
|
32
31
|
|
|
33
|
-
def
|
|
32
|
+
def _load_token(self):
|
|
34
33
|
"""Loads Decodo credentials from the secrets manager."""
|
|
35
|
-
self.
|
|
36
|
-
self.password = get_secret("decodo_password")
|
|
34
|
+
self.basic_auth_token = get_secret("decodo_token")
|
|
37
35
|
|
|
38
|
-
if self.
|
|
39
|
-
logger.info("✅ Decodo
|
|
36
|
+
if self.basic_auth_token:
|
|
37
|
+
logger.info("✅ Decodo token set.")
|
|
40
38
|
else:
|
|
41
|
-
logger.warning("⚠️ Decodo
|
|
39
|
+
logger.warning("⚠️ Decodo auth token not found. Please configure it in secrets.")
|
|
42
40
|
|
|
43
|
-
def
|
|
41
|
+
def _has_token(self) -> bool:
|
|
44
42
|
"""Checks if Decodo credentials are available."""
|
|
45
|
-
return bool(self.
|
|
43
|
+
return bool(self.basic_auth_token)
|
|
46
44
|
|
|
47
45
|
async def scrape(
|
|
48
46
|
self, url: str,
|
|
@@ -66,10 +64,10 @@ class Decodo:
|
|
|
66
64
|
Returns:
|
|
67
65
|
MultimodalSequence containing the scraped content, or None if scraping failed
|
|
68
66
|
"""
|
|
69
|
-
if not self.
|
|
70
|
-
self.
|
|
67
|
+
if not self._has_token():
|
|
68
|
+
self._load_token()
|
|
71
69
|
|
|
72
|
-
if not self.
|
|
70
|
+
if not self._has_token():
|
|
73
71
|
logger.warning("Cannot scrape with Decodo: credentials not configured.")
|
|
74
72
|
return None
|
|
75
73
|
|
|
@@ -124,6 +122,7 @@ class Decodo:
|
|
|
124
122
|
"""
|
|
125
123
|
headers = {
|
|
126
124
|
'Content-Type': 'application/json',
|
|
125
|
+
'Authorization': f'Basic {self.basic_auth_token}',
|
|
127
126
|
}
|
|
128
127
|
|
|
129
128
|
# Build request payload
|
|
@@ -141,9 +140,6 @@ class Decodo:
|
|
|
141
140
|
if use_premium_proxy:
|
|
142
141
|
payload["proxy_pool"] = "premium"
|
|
143
142
|
|
|
144
|
-
# Create basic auth
|
|
145
|
-
auth = aiohttp.BasicAuth(self.username, self.password)
|
|
146
|
-
|
|
147
143
|
# Retry loop with exponential backoff
|
|
148
144
|
for attempt in range(max_retries + 1):
|
|
149
145
|
try:
|
|
@@ -151,12 +147,11 @@ class Decodo:
|
|
|
151
147
|
self.DECODO_API_URL,
|
|
152
148
|
json=payload,
|
|
153
149
|
headers=headers,
|
|
154
|
-
auth=auth,
|
|
155
150
|
timeout=aiohttp.ClientTimeout(total=timeout)
|
|
156
151
|
) as response:
|
|
157
152
|
# Validate response health
|
|
158
153
|
if response.status != 200:
|
|
159
|
-
logger.debug("Communication with Decodo API failed.")
|
|
154
|
+
logger.debug(f"Communication with Decodo API failed. Status code: {response.status}")
|
|
160
155
|
|
|
161
156
|
if response.status == 429: # Rate limit
|
|
162
157
|
if attempt >= max_retries:
|
|
@@ -233,12 +228,7 @@ class Decodo:
|
|
|
233
228
|
except aiohttp.ClientError:
|
|
234
229
|
logger.error(f"Network error while scraping with Decodo.")
|
|
235
230
|
raise
|
|
236
|
-
except asyncio.TimeoutError:
|
|
237
|
-
if attempt >= max_retries:
|
|
238
|
-
raise
|
|
239
|
-
else:
|
|
240
|
-
logger.debug(f"Timeout while scraping with Decodo. Retrying...")
|
|
241
|
-
except RateLimitError:
|
|
231
|
+
except (RateLimitError, asyncio.TimeoutError):
|
|
242
232
|
raise
|
|
243
233
|
except Exception as e:
|
|
244
234
|
logger.error(f"Unexpected error while scraping with Decodo: {e}")
|
|
@@ -28,17 +28,42 @@ LIKE_COMMENT_SHARE_SVG_REGEX = (
|
|
|
28
28
|
)
|
|
29
29
|
FB_PHOTO_HREF_REGEX = r'href="(https://www\.facebook\.com/photo/[^"]*)"'
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
() => {
|
|
33
|
-
const img = document.querySelector('img[data-visualcompletion="media-vc-image"]');
|
|
34
|
-
return img ? img.getAttribute('src') : null;
|
|
35
|
-
}
|
|
36
|
-
""".strip()
|
|
37
|
-
|
|
38
|
-
JS_GET_OG_IMAGE = """
|
|
31
|
+
JS_GET_PHOTO_IMAGE = """
|
|
39
32
|
() => {
|
|
33
|
+
// Strategy 1: data-visualcompletion attribute (legacy)
|
|
34
|
+
let img = document.querySelector('img[data-visualcompletion="media-vc-image"]');
|
|
35
|
+
if (img) return img.getAttribute('src');
|
|
36
|
+
|
|
37
|
+
// Strategy 2: Large image inside the photo theater/spotlight viewer
|
|
38
|
+
const viewerSelectors = [
|
|
39
|
+
'[role="dialog"] img[src*="scontent"]',
|
|
40
|
+
'[data-pagelet="MediaViewerPhoto"] img',
|
|
41
|
+
'[role="main"] img[src*="scontent"]',
|
|
42
|
+
'img[alt][src*="fbcdn"]',
|
|
43
|
+
'img[alt][src*="scontent"]',
|
|
44
|
+
];
|
|
45
|
+
for (const sel of viewerSelectors) {
|
|
46
|
+
const candidates = document.querySelectorAll(sel);
|
|
47
|
+
for (const c of candidates) {
|
|
48
|
+
const src = c.getAttribute('src') || '';
|
|
49
|
+
const w = c.naturalWidth || c.width || 0;
|
|
50
|
+
if (src && w > 200) return src;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Strategy 3: Largest image on the page with a CDN src
|
|
55
|
+
const allImgs = Array.from(document.querySelectorAll('img[src*="scontent"], img[src*="fbcdn"]'));
|
|
56
|
+
if (allImgs.length > 0) {
|
|
57
|
+
allImgs.sort((a, b) => (b.naturalWidth || 0) - (a.naturalWidth || 0));
|
|
58
|
+
const best = allImgs[0];
|
|
59
|
+
if (best && (best.naturalWidth || 0) > 100) return best.getAttribute('src');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Strategy 4: og:image meta tag
|
|
40
63
|
const og = document.querySelector('meta[property="og:image"]');
|
|
41
|
-
|
|
64
|
+
if (og) return og.getAttribute('content');
|
|
65
|
+
|
|
66
|
+
return null;
|
|
42
67
|
}
|
|
43
68
|
""".strip()
|
|
44
69
|
|
|
@@ -118,7 +143,7 @@ class Facebook(RetrievalIntegration):
|
|
|
118
143
|
url,
|
|
119
144
|
platform="Facebook",
|
|
120
145
|
# cookiefile=self.cookie_file.as_posix(),
|
|
121
|
-
impersonate=ImpersonateTarget("chrome", "
|
|
146
|
+
impersonate=ImpersonateTarget("chrome", "146"),
|
|
122
147
|
**kwargs,
|
|
123
148
|
)
|
|
124
149
|
|
|
@@ -148,13 +173,21 @@ class Facebook(RetrievalIntegration):
|
|
|
148
173
|
try:
|
|
149
174
|
await page.goto(url, timeout=30000)
|
|
150
175
|
await page.wait_for_load_state("domcontentloaded")
|
|
176
|
+
# Wait for network to settle so images are rendered
|
|
177
|
+
try:
|
|
178
|
+
await page.wait_for_load_state("networkidle", timeout=10000)
|
|
179
|
+
except PlaywrightTimeoutError:
|
|
180
|
+
pass
|
|
181
|
+
# Give React/JS a moment to render the photo
|
|
182
|
+
await page.wait_for_timeout(2000)
|
|
151
183
|
except PlaywrightTimeoutError:
|
|
152
|
-
raise
|
|
184
|
+
raise TimeoutError("Timed out loading Facebook photo page.")
|
|
185
|
+
|
|
186
|
+
image_url = await page.evaluate(JS_GET_PHOTO_IMAGE)
|
|
153
187
|
|
|
154
|
-
|
|
188
|
+
except TimeoutError:
|
|
189
|
+
raise
|
|
155
190
|
|
|
156
|
-
if not image_url:
|
|
157
|
-
image_url = await page.evaluate(JS_GET_OG_IMAGE)
|
|
158
191
|
finally:
|
|
159
192
|
html = await page.content()
|
|
160
193
|
await browser.close()
|
|
@@ -7,7 +7,6 @@ from yt_dlp import DownloadError
|
|
|
7
7
|
from scrapemm.common.exceptions import RateLimitError
|
|
8
8
|
from scrapemm.integrations.base import RetrievalIntegration
|
|
9
9
|
from scrapemm.integrations.ytdlp import get_content_with_ytdlp
|
|
10
|
-
from scrapemm.util import get_domain
|
|
11
10
|
|
|
12
11
|
logger = logging.getLogger("scrapeMM")
|
|
13
12
|
|
|
@@ -1,22 +1,96 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
from typing import Optional
|
|
4
|
+
from urllib.parse import urlparse, parse_qs
|
|
3
5
|
|
|
4
6
|
import aiohttp
|
|
5
7
|
from ezmm import MultimodalSequence
|
|
6
8
|
from playwright.async_api import Error, TimeoutError, async_playwright
|
|
9
|
+
from playwright_stealth import Stealth
|
|
7
10
|
|
|
8
11
|
from scrapemm.download.common import HEADERS
|
|
9
12
|
from scrapemm.integrations.base import RetrievalIntegration
|
|
13
|
+
from scrapemm.integrations.decodo import decodo
|
|
14
|
+
from scrapemm.secrets import get_secret
|
|
10
15
|
from scrapemm.util import to_multimodal_sequence
|
|
11
16
|
|
|
12
17
|
logger = logging.getLogger("scrapeMM")
|
|
13
18
|
|
|
14
|
-
|
|
15
19
|
# Limits for inlining media as data URIs to avoid excessive memory usage
|
|
16
20
|
MAX_IMAGE_BYTES = 15 * 1024 * 1024 # 15 MB
|
|
17
21
|
MAX_VIDEO_BYTES = 25 * 1024 * 1024 # 25 MB
|
|
18
22
|
INLINE_CONCURRENCY = 6
|
|
19
23
|
|
|
24
|
+
PERMA_CC_API_BASE = "https://api.perma.cc/v1/public/archives"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _extract_guid(url: str) -> Optional[str]:
|
|
28
|
+
"""Extract the Perma.cc GUID (e.g. 'N8MR-NS96') from a perma.cc URL."""
|
|
29
|
+
match = re.search(r"perma\.cc/([A-Za-z0-9]+-[A-Za-z0-9]+)", url)
|
|
30
|
+
return match.group(1) if match else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_screenshot_request(url: str) -> bool:
|
|
34
|
+
"""Check if the URL explicitly requests the screenshot view (?type=image)."""
|
|
35
|
+
parsed = urlparse(url)
|
|
36
|
+
params = parse_qs(parsed.query)
|
|
37
|
+
return params.get("type", [None])[0] == "image"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _get_archive_metadata(guid: str, session: aiohttp.ClientSession) -> Optional[dict]:
|
|
41
|
+
"""Fetch archive metadata from Perma.cc's public API."""
|
|
42
|
+
api_url = f"{PERMA_CC_API_BASE}/{guid}/"
|
|
43
|
+
try:
|
|
44
|
+
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
|
45
|
+
if resp.status == 200:
|
|
46
|
+
return await resp.json()
|
|
47
|
+
else:
|
|
48
|
+
logger.debug(f"Perma.cc API returned status {resp.status} for GUID {guid}")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.debug(f"Failed to fetch Perma.cc API metadata: {e}")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_iframe_src(html: str) -> Optional[str]:
|
|
55
|
+
"""Extract the rejouer.perma.cc iframe src URL from the Perma.cc wrapper HTML."""
|
|
56
|
+
# Match iframe with class "archive-iframe" or src containing rejouer.perma.cc
|
|
57
|
+
patterns = [
|
|
58
|
+
re.compile(r'<iframe[^>]+class=["\'][^"\']*archive-iframe[^"\']*["\'][^>]+src=["\']([^"\']+)["\']', re.IGNORECASE),
|
|
59
|
+
re.compile(r'<iframe[^>]+src=["\']([^"\']*rejouer\.perma\.cc[^"\']+)["\']', re.IGNORECASE),
|
|
60
|
+
]
|
|
61
|
+
for pattern in patterns:
|
|
62
|
+
match = pattern.search(html)
|
|
63
|
+
if match:
|
|
64
|
+
url = match.group(1)
|
|
65
|
+
# Unescape HTML entities
|
|
66
|
+
url = url.replace("&", "&")
|
|
67
|
+
return url
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_screenshot_img_url(html: str, guid: str) -> Optional[str]:
|
|
72
|
+
"""Extract the actual screenshot image URL from the Perma.cc screenshot page HTML.
|
|
73
|
+
|
|
74
|
+
The page embeds the screenshot in an <img> tag whose src points to
|
|
75
|
+
something like: https://rejouer.perma.cc/replay-web-page/w/.../mp_/file:///GUID/cap.png
|
|
76
|
+
"""
|
|
77
|
+
# Look for img src containing the GUID or cap.png
|
|
78
|
+
patterns = [
|
|
79
|
+
# Match src with the GUID in the path
|
|
80
|
+
re.compile(r'<img[^>]+src=["\']([^"\']*' + re.escape(guid) + r'[^"\']*cap\.png[^"\']*)["\']', re.IGNORECASE),
|
|
81
|
+
# Match any src on rejouer.perma.cc
|
|
82
|
+
re.compile(r'<img[^>]+src=["\']([^"\']*rejouer\.perma\.cc[^"\']*)["\']', re.IGNORECASE),
|
|
83
|
+
# Match any src containing cap.png
|
|
84
|
+
re.compile(r'<img[^>]+src=["\']([^"\']*cap\.png[^"\']*)["\']', re.IGNORECASE),
|
|
85
|
+
# Fallback: any img src that looks like a full URL
|
|
86
|
+
re.compile(r'<img[^>]+src=["\'](https?://[^"\']+)["\']', re.IGNORECASE),
|
|
87
|
+
]
|
|
88
|
+
for pattern in patterns:
|
|
89
|
+
match = pattern.search(html)
|
|
90
|
+
if match:
|
|
91
|
+
return match.group(1)
|
|
92
|
+
return None
|
|
93
|
+
|
|
20
94
|
|
|
21
95
|
class PermaCC(RetrievalIntegration):
|
|
22
96
|
name = "Perma.cc"
|
|
@@ -27,14 +101,95 @@ class PermaCC(RetrievalIntegration):
|
|
|
27
101
|
self.connected = True
|
|
28
102
|
|
|
29
103
|
async def _get(self, url: str, **kwargs) -> Optional[MultimodalSequence]:
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
104
|
+
guid = _extract_guid(url)
|
|
105
|
+
if not guid:
|
|
106
|
+
raise RuntimeError(f"Could not extract Perma.cc GUID from URL: {url}")
|
|
107
|
+
|
|
108
|
+
async with aiohttp.ClientSession(headers=HEADERS) as session:
|
|
109
|
+
# Fetch archive metadata to get the original URL
|
|
110
|
+
metadata = await _get_archive_metadata(guid, session)
|
|
111
|
+
original_url = None
|
|
112
|
+
if metadata:
|
|
113
|
+
# Top-level "url" field
|
|
114
|
+
original_url = metadata.get("url")
|
|
115
|
+
# Fallback: extract from captures array (role=primary)
|
|
116
|
+
if not original_url:
|
|
117
|
+
for capture in metadata.get("captures", []):
|
|
118
|
+
if capture.get("role") == "primary" and capture.get("url"):
|
|
119
|
+
original_url = capture["url"]
|
|
120
|
+
break
|
|
121
|
+
logger.debug(f"Perma.cc metadata keys: {list(metadata.keys())}, original_url={original_url}")
|
|
122
|
+
|
|
123
|
+
# Strategy 1: Scrape the ORIGINAL URL via Decodo (media URLs will be normal)
|
|
124
|
+
if original_url:
|
|
125
|
+
logger.debug(f"Perma.cc archive of: {original_url}")
|
|
126
|
+
result = await _scrape_original_via_decodo(original_url, session)
|
|
127
|
+
if result and _has_media(result):
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
# Strategy 2: Scrape the Perma.cc page itself via Decodo and download media
|
|
131
|
+
html = await _get_html_via_decodo(url, session)
|
|
132
|
+
if html:
|
|
133
|
+
result = await to_multimodal_sequence(html, remove_urls=False, session=session, url=url)
|
|
134
|
+
if result and _has_media(result):
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
# Strategy 3: Use Playwright with media inlining (handles session-bound URLs)
|
|
138
|
+
html = await get_record_html(url)
|
|
139
|
+
if html:
|
|
140
|
+
return await to_multimodal_sequence(html, remove_urls=False, session=session, url=url)
|
|
141
|
+
|
|
142
|
+
# Strategy 4: Return Decodo text-only result if we got one earlier
|
|
143
|
+
if original_url:
|
|
144
|
+
result = await _scrape_original_via_decodo(original_url, session)
|
|
145
|
+
if result:
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
raise RuntimeError("Failed to retrieve Perma.cc record.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _has_media(seq: MultimodalSequence) -> bool:
|
|
152
|
+
"""Check if a MultimodalSequence contains any images or videos."""
|
|
153
|
+
return bool(seq.images or seq.videos)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _scrape_original_via_decodo(
|
|
157
|
+
original_url: str, session: aiohttp.ClientSession
|
|
158
|
+
) -> Optional[MultimodalSequence]:
|
|
159
|
+
"""Scrape the original archived URL via Decodo. Media URLs on the original
|
|
160
|
+
site are normal HTTP URLs that can be downloaded directly."""
|
|
161
|
+
try:
|
|
162
|
+
result = await decodo.scrape(
|
|
163
|
+
original_url,
|
|
164
|
+
remove_urls=False,
|
|
165
|
+
session=session,
|
|
166
|
+
format="multimodal_sequence",
|
|
167
|
+
enable_js=True,
|
|
168
|
+
timeout=30,
|
|
169
|
+
)
|
|
170
|
+
if isinstance(result, MultimodalSequence):
|
|
171
|
+
return result
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.debug(f"Decodo scrape of original URL failed: {e}")
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _get_html_via_decodo(url: str, session: aiohttp.ClientSession) -> Optional[str]:
|
|
178
|
+
"""Fetch Perma.cc page HTML using Decodo proxy service to bypass Cloudflare."""
|
|
179
|
+
try:
|
|
180
|
+
result = await decodo.scrape(
|
|
181
|
+
url,
|
|
182
|
+
remove_urls=False,
|
|
183
|
+
session=session,
|
|
184
|
+
format="html",
|
|
185
|
+
enable_js=True,
|
|
186
|
+
timeout=30,
|
|
187
|
+
)
|
|
188
|
+
if isinstance(result, str) and result.strip():
|
|
189
|
+
return result
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.debug(f"Decodo scrape failed for Perma.cc: {e}")
|
|
192
|
+
return None
|
|
38
193
|
|
|
39
194
|
|
|
40
195
|
async def _inline_media_in_frame(frame, image_limit: int = MAX_IMAGE_BYTES, video_limit: int = MAX_VIDEO_BYTES,
|
|
@@ -269,11 +424,17 @@ async def _inline_media_in_frame(frame, image_limit: int = MAX_IMAGE_BYTES, vide
|
|
|
269
424
|
|
|
270
425
|
|
|
271
426
|
async def get_record_html(url: str) -> str | None:
|
|
272
|
-
"""Retrieves the HTML of the record saved by Perma.cc
|
|
273
|
-
and returns any contained media as data URIs (base64-encoded)."""
|
|
274
|
-
async with async_playwright() as p:
|
|
275
|
-
browser = await p.chromium.launch(
|
|
276
|
-
|
|
427
|
+
"""Fallback: Retrieves the HTML of the record saved by Perma.cc using Playwright.
|
|
428
|
+
Loads the contents dynamically and returns any contained media as data URIs (base64-encoded)."""
|
|
429
|
+
async with Stealth().use_async(async_playwright()) as p:
|
|
430
|
+
browser = await p.chromium.launch(
|
|
431
|
+
headless=False,
|
|
432
|
+
args=["--window-position=-10000,-10000"],
|
|
433
|
+
)
|
|
434
|
+
context = await browser.new_context(
|
|
435
|
+
accept_downloads=False,
|
|
436
|
+
user_agent=HEADERS["User-Agent"],
|
|
437
|
+
)
|
|
277
438
|
page = await context.new_page()
|
|
278
439
|
|
|
279
440
|
try:
|
|
@@ -29,14 +29,14 @@ class Telegram(RetrievalIntegration):
|
|
|
29
29
|
bot_token = get_secret("telegram_bot_token")
|
|
30
30
|
|
|
31
31
|
if api_id and api_hash and bot_token:
|
|
32
|
-
self.client = TelegramClient(self.session_path, api_id, api_hash)
|
|
33
32
|
try:
|
|
34
|
-
|
|
33
|
+
self.client = TelegramClient(self.session_path, api_id, api_hash)
|
|
35
34
|
except sqlite3.OperationalError: # Database is locked from an interrupted previous session
|
|
36
35
|
# Remove the database file and try again
|
|
37
36
|
journal_path = Path(self.session_path + ".session-journal")
|
|
38
37
|
journal_path.unlink(missing_ok=True)
|
|
39
|
-
|
|
38
|
+
self.client = TelegramClient(self.session_path, api_id, api_hash)
|
|
39
|
+
await self.client.start(bot_token=bot_token) # Returns a coroutine b/c event loop exists already
|
|
40
40
|
self.connected = True
|
|
41
41
|
logger.info("✅ Successfully connected to Telegram.")
|
|
42
42
|
else:
|
|
@@ -162,7 +162,7 @@ Likes: {metrics['like_count']} - Retweets: {metrics['retweet_count']} - Replies:
|
|
|
162
162
|
properties_text += "\n- Protected"
|
|
163
163
|
if user.withheld:
|
|
164
164
|
properties_text += "\n- Withheld"
|
|
165
|
-
if user.parody:
|
|
165
|
+
if hasattr(user, "parody") and user.parody:
|
|
166
166
|
properties_text += "\n- Marked as parody"
|
|
167
167
|
|
|
168
168
|
text = f"""**Profile on X**
|
|
@@ -41,6 +41,8 @@ BEST_METHODS = {
|
|
|
41
41
|
"perma.cc": ["integrations"],
|
|
42
42
|
"mvau.lt": ["integrations"],
|
|
43
43
|
"archive.org": ["integrations"],
|
|
44
|
+
"verafiles.org": ["decodo", "firecrawl"],
|
|
45
|
+
"youturn.in": ["decodo"],
|
|
44
46
|
# Archive.today:
|
|
45
47
|
"archive.today": ["integrations", "decodo"],
|
|
46
48
|
"archive.is": ["integrations", "decodo"],
|
|
@@ -49,6 +51,8 @@ BEST_METHODS = {
|
|
|
49
51
|
"archive.li": ["integrations", "decodo"],
|
|
50
52
|
"archive.fo": ["integrations", "decodo"],
|
|
51
53
|
"archive.md": ["integrations", "decodo"],
|
|
54
|
+
# More special cases
|
|
55
|
+
"washingtonpost.com": ["decodo"],
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
|
|
@@ -185,7 +189,7 @@ async def _retrieve_single(
|
|
|
185
189
|
session=session, format=format, actions=actions),
|
|
186
190
|
"decodo": lambda: decodo.scrape(url, remove_urls, session,
|
|
187
191
|
format=format,
|
|
188
|
-
timeout=
|
|
192
|
+
timeout=15 if prioritize == "speed" else 60,
|
|
189
193
|
max_retries=1 if prioritize == "speed" else 5),
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -220,6 +224,11 @@ async def _retrieve_single(
|
|
|
220
224
|
errors[method_name] = e
|
|
221
225
|
result = None
|
|
222
226
|
|
|
227
|
+
except TimeoutError as e:
|
|
228
|
+
logger.warning(f"Timeout while retrieving with method '{method_name}': {e}")
|
|
229
|
+
errors[method_name] = e
|
|
230
|
+
result = None
|
|
231
|
+
|
|
223
232
|
except IPBannedError as e:
|
|
224
233
|
logger.error(e)
|
|
225
234
|
errors[method_name] = e
|
|
@@ -227,7 +236,7 @@ async def _retrieve_single(
|
|
|
227
236
|
|
|
228
237
|
except Exception as e:
|
|
229
238
|
logger.warning(f"Error while retrieving with method '{method_name}': {e}")
|
|
230
|
-
errors[method_name] = e
|
|
239
|
+
errors[method_name] = f"{type(e).__name__}: {e}"
|
|
231
240
|
result = None
|
|
232
241
|
|
|
233
242
|
if result is not None:
|
|
@@ -9,8 +9,8 @@ from cryptography.hazmat.backends import default_backend
|
|
|
9
9
|
from cryptography.hazmat.primitives import hashes
|
|
10
10
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
11
11
|
|
|
12
|
-
from scrapemm.common import get_config_var, update_config,
|
|
13
|
-
from scrapemm.util import
|
|
12
|
+
from scrapemm.common import get_config_var, update_config, CONFIG_DIR
|
|
13
|
+
from scrapemm.util import get_user_input
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger("scrapeMM")
|
|
16
16
|
|
|
@@ -23,8 +23,7 @@ SECRETS = {
|
|
|
23
23
|
"bluesky_password": "Bluesky password",
|
|
24
24
|
"tiktok_client_key": "TikTok client key",
|
|
25
25
|
"tiktok_client_secret": "TikTok client secret",
|
|
26
|
-
"
|
|
27
|
-
"decodo_password": "Decodo Web Scraping API password",
|
|
26
|
+
"decodo_token": "Decodo Web Scraping API basic authentication token",
|
|
28
27
|
"youtube_cookie": "YouTube cookie string",
|
|
29
28
|
"facebook_cookie": "Facebook cookie string",
|
|
30
29
|
}
|
|
@@ -144,9 +143,9 @@ def override_secret(key_name: str):
|
|
|
144
143
|
when nothing entered."""
|
|
145
144
|
description = SECRETS[key_name]
|
|
146
145
|
if key_name in ["youtube_cookie", "facebook_cookie"]:
|
|
147
|
-
user_input =
|
|
146
|
+
user_input = get_user_input(f"Please enter the {description} (Alt+Enter to submit):", multiline=True)
|
|
148
147
|
else:
|
|
149
|
-
user_input =
|
|
148
|
+
user_input = get_user_input(f"Please enter the {description} (leave empty to skip):")
|
|
150
149
|
if user_input:
|
|
151
150
|
set_secret(key_name, user_input)
|
|
152
151
|
|
|
@@ -171,8 +170,7 @@ def configure_secrets(all_keys: bool = False):
|
|
|
171
170
|
|
|
172
171
|
update_config(api_keys_configured=True)
|
|
173
172
|
|
|
174
|
-
logger.info("API keys configured successfully! If you want to change them,
|
|
175
|
-
f"{CONFIG_PATH.as_posix()} and set 'api_keys_configured' to 'false' or "
|
|
173
|
+
logger.info("API keys configured successfully! If you want to change them, "
|
|
176
174
|
f"run scrapemm.secrets.configure_secrets().")
|
|
177
175
|
|
|
178
176
|
|
|
@@ -38,7 +38,7 @@ def get_domain(url: str, keep_subdomain: bool = False) -> Optional[str]:
|
|
|
38
38
|
async def run_with_semaphore(tasks: Iterable[Awaitable],
|
|
39
39
|
limit: int,
|
|
40
40
|
show_progress: bool = True,
|
|
41
|
-
progress_description: str | None = None) ->
|
|
41
|
+
progress_description: str | None = None) -> tuple:
|
|
42
42
|
"""
|
|
43
43
|
Runs asynchronous tasks with a concurrency limit.
|
|
44
44
|
|
|
@@ -76,10 +76,32 @@ def read_urls_from_file(file_path):
|
|
|
76
76
|
return f.read().splitlines()
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
def get_user_input(prompt: str, multiline: bool = False) -> str:
|
|
80
|
+
"""Prompts the user for input.
|
|
81
|
+
- Single-line: uses standard input(); Enter submits, empty input skips.
|
|
82
|
+
- Multiline: uses prompt_toolkit; Enter adds newline, Alt+Enter submits.
|
|
83
|
+
"""
|
|
84
|
+
if multiline:
|
|
85
|
+
from prompt_toolkit import prompt as pt_prompt
|
|
86
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
87
|
+
|
|
88
|
+
print(prompt)
|
|
89
|
+
|
|
90
|
+
bindings = KeyBindings()
|
|
91
|
+
|
|
92
|
+
@bindings.add('escape', 'enter')
|
|
93
|
+
def _submit(event):
|
|
94
|
+
event.current_buffer.validate_and_handle()
|
|
95
|
+
|
|
96
|
+
result = pt_prompt(
|
|
97
|
+
">>> ",
|
|
98
|
+
multiline=True,
|
|
99
|
+
key_bindings=bindings,
|
|
100
|
+
prompt_continuation="... ",
|
|
101
|
+
)
|
|
102
|
+
return result
|
|
103
|
+
else:
|
|
104
|
+
return input(f"{prompt} ")
|
|
83
105
|
|
|
84
106
|
|
|
85
107
|
MAX_MEDIA_PER_PAGE = 32
|
|
@@ -127,7 +149,7 @@ async def resolve_media_hyperlinks(
|
|
|
127
149
|
|
|
128
150
|
# Try to download media for each URL
|
|
129
151
|
tasks = [download_medium(u, session=session, headers={"Referer": url}) for u in hrefs_urls.values()]
|
|
130
|
-
media:
|
|
152
|
+
media: tuple[Item | None] = await run_with_semaphore(tasks, limit=100, show_progress=False)
|
|
131
153
|
|
|
132
154
|
href_media = dict(zip(hrefs_urls.keys(), media))
|
|
133
155
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from scrapemm import retrieve
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
if __name__ == "__main__":
|
|
5
|
+
url = "https://perma.cc/ZD7Z-B3U7?type=image"
|
|
6
|
+
result = asyncio.run(retrieve(url))
|
|
7
|
+
print(f"Retrieval time: {result.retrieval_time:.2f} seconds")
|
|
8
|
+
if result.errors:
|
|
9
|
+
print(result.errors)
|
|
10
|
+
else:
|
|
11
|
+
print(result.content)
|
|
@@ -9,7 +9,6 @@ from scrapemm import retrieve
|
|
|
9
9
|
@pytest.mark.parametrize("url", [
|
|
10
10
|
"https://www.vishvasnews.com/viral/fact-check-upsc-has-not-reduced-the-maximum-age-limit-for-ias-and-ips-exams/",
|
|
11
11
|
"https://health.medicaldialogues.in/fact-check/brain-health-fact-check/fact-check-is-sprite-the-best-remedy-for-headaches-in-the-world-140368",
|
|
12
|
-
"https://www.washingtonpost.com/politics/2024/05/15/bidens-false-claim-that-inflation-was-9-percent-when-he-took-office/",
|
|
13
12
|
"https://assamese.factcrescendo.com/viral-claim-that-the-video-shows-the-incident-from-uttar-pradesh-and-the-youth-on-the-bike-and-the-youth-being-beaten-and-taken-away-by-the-police-are-the-same-youth-named-abdul-is-false/",
|
|
14
13
|
# "https://factuel.afp.com/doc.afp.com.43ZN7NP",
|
|
15
14
|
"https://leadstories.com/365cb414b83e29d26fecae374d55c743a3eac4c7.png",
|
scrapemm-0.6.2/pyproject.toml
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "scrapeMM"
|
|
3
|
-
version = "0.6.2"
|
|
4
|
-
description = "Flexible multimodal scraper for social media and the open web."
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.10"
|
|
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
|
-
"firecrawl-py",
|
|
21
|
-
"playwright"
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
[project.urls]
|
|
25
|
-
Homepage = "https://github.com/multimodal-ai-lab/scrapeMM"
|
|
26
|
-
Issues = "https://github.com/multimodal-ai-lab/scrapeMM"
|
|
27
|
-
|
|
28
|
-
[build-system]
|
|
29
|
-
requires = ["setuptools"]
|
|
30
|
-
build-backend = "setuptools.build_meta"
|
|
31
|
-
|
|
32
|
-
[tool.setuptools.packages.find]
|
|
33
|
-
namespaces = false # Disable implicit namespaces
|
|
34
|
-
|
|
35
|
-
[tool.setuptools.package-data]
|
|
36
|
-
"*" = ["*.txt"] # Include all .txt files contained in any (sub)package in the build
|
|
37
|
-
|
|
38
|
-
[tool.pytest.ini_options]
|
|
39
|
-
testpaths = [
|
|
40
|
-
"testing/",
|
|
41
|
-
]
|
|
42
|
-
asyncio_mode = "auto"
|
|
43
|
-
asyncio_default_test_loop_scope = "session"
|
|
44
|
-
asyncio_default_fixture_loop_scope = "session"
|
scrapemm-0.6.2/requirements.txt
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
pytest~=8.4.1
|
|
2
|
-
aiohttp~=3.13.5
|
|
3
|
-
requests~=2.32.4
|
|
4
|
-
ezMM~=0.5.1
|
|
5
|
-
Telethon~=1.40.0
|
|
6
|
-
PyYAML~=6.0.1
|
|
7
|
-
tweepy~=4.16.0
|
|
8
|
-
markdownify~=1.1.0
|
|
9
|
-
tqdm~=4.66.3
|
|
10
|
-
pytest-asyncio
|
|
11
|
-
async_lru
|
|
12
|
-
oauthlib
|
|
13
|
-
platformdirs~=4.3.8
|
|
14
|
-
atproto
|
|
15
|
-
TikTokResearchApi~=1.0.4
|
|
16
|
-
yt-dlp[default,curl-cffi]
|
|
17
|
-
curl_cffi~=0.14.0b5 # yt-dlp doesn't support 0.15+ yet
|
|
18
|
-
cryptography
|
|
19
|
-
firecrawl-py~=4.10.4
|
|
20
|
-
brotli # Required to read brotli-encoded media
|
|
21
|
-
playwright
|
|
22
|
-
playwright-stealth
|
|
23
|
-
beautifulsoup4
|
|
24
|
-
pip-system-certs # For better SSL certificate handling
|
|
25
|
-
backports.zstd # For better content parsing with Firecrawl
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
from scrapemm import retrieve
|
|
2
|
-
import asyncio
|
|
3
|
-
|
|
4
|
-
if __name__ == "__main__":
|
|
5
|
-
url = "https://www.snopes.com/fact-check/biden-bounty-maduro/"
|
|
6
|
-
result = asyncio.run(retrieve(url, methods=["firecrawl"]))
|
|
7
|
-
if result.errors:
|
|
8
|
-
print(result.errors)
|
|
9
|
-
else:
|
|
10
|
-
print(result.content)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|