scrapeMM 0.3.0__tar.gz → 0.3.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.3.0/scrapeMM.egg-info → scrapemm-0.3.2}/PKG-INFO +2 -1
- {scrapemm-0.3.0 → scrapemm-0.3.2}/pyproject.toml +2 -1
- {scrapemm-0.3.0 → scrapemm-0.3.2/scrapeMM.egg-info}/PKG-INFO +2 -1
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapeMM.egg-info/requires.txt +1 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/retrieval.py +23 -9
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/scraping/decodo.py +19 -19
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/scraping/firecrawl.py +6 -2
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/scraping/util.py +5 -3
- scrapemm-0.3.2/scripts/example.py +7 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/testing/test_retrieval.py +21 -7
- scrapemm-0.3.0/scripts/example.py +0 -8
- {scrapemm-0.3.0 → scrapemm-0.3.2}/.gitignore +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/LICENSE +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/README.md +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/requirements.txt +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapeMM.egg-info/SOURCES.txt +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapeMM.egg-info/dependency_links.txt +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapeMM.egg-info/top_level.txt +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/__init__.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/common.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/__init__.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/base.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/bluesky.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/fb.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/instagram.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/telegram.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/tiktok.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/x.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/integrations/youtube.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/scraping/__init__.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/scraping/no_bot_domains.txt +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/scraping/ytdlp.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/secrets.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/scrapemm/util.py +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.2}/setup.cfg +0 -0
- {scrapemm-0.3.0 → scrapemm-0.3.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.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -19,6 +19,7 @@ Requires-Dist: atproto
|
|
|
19
19
|
Requires-Dist: TikTokResearchApi
|
|
20
20
|
Requires-Dist: yt-dlp
|
|
21
21
|
Requires-Dist: cryptography
|
|
22
|
+
Requires-Dist: firecrawl-py
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
# scrapeMM: Multimodal Web Retrieval
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "scrapeMM"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.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"
|
|
@@ -17,6 +17,7 @@ dependencies = [
|
|
|
17
17
|
"TikTokResearchApi",
|
|
18
18
|
"yt-dlp",
|
|
19
19
|
"cryptography",
|
|
20
|
+
"firecrawl-py"
|
|
20
21
|
]
|
|
21
22
|
|
|
22
23
|
[project.urls]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapeMM
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -19,6 +19,7 @@ Requires-Dist: atproto
|
|
|
19
19
|
Requires-Dist: TikTokResearchApi
|
|
20
20
|
Requires-Dist: yt-dlp
|
|
21
21
|
Requires-Dist: cryptography
|
|
22
|
+
Requires-Dist: firecrawl-py
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
# scrapeMM: Multimodal Web Retrieval
|
|
@@ -18,8 +18,9 @@ async def retrieve(
|
|
|
18
18
|
remove_urls: bool = False,
|
|
19
19
|
show_progress: bool = True,
|
|
20
20
|
actions: list[dict] = None,
|
|
21
|
-
methods: list[str] = None
|
|
22
|
-
|
|
21
|
+
methods: list[str] = None,
|
|
22
|
+
format: str = "multimodal_sequence",
|
|
23
|
+
) -> Optional[MultimodalSequence | str] | list[Optional[MultimodalSequence | str]]:
|
|
23
24
|
"""Main function of this repository. Downloads the contents present at the given URL(s).
|
|
24
25
|
For each URL, returns a MultimodalSequence containing text, images, and videos.
|
|
25
26
|
Returns None if the corresponding URL is not supported or if retrieval failed.
|
|
@@ -37,6 +38,9 @@ async def retrieve(
|
|
|
37
38
|
- "firecrawl" (Firecrawl scraping service)
|
|
38
39
|
- "decodo" (Decodo Web Scraping API)
|
|
39
40
|
You can specify any subset in any order, e.g., ["decodo", "firecrawl"] or ["integrations"].
|
|
41
|
+
:param format: The format of the output. Available formats:
|
|
42
|
+
- "multimodal_sequence" (MultimodalSequence containing parsed and downloaded media from the page)
|
|
43
|
+
- "html" (string containing the raw HTML code of the page, not compatible with 'integrations' method)
|
|
40
44
|
"""
|
|
41
45
|
if methods is None:
|
|
42
46
|
methods = METHODS
|
|
@@ -62,7 +66,7 @@ async def retrieve(
|
|
|
62
66
|
urls_unique = set(urls_to_retrieve)
|
|
63
67
|
|
|
64
68
|
# Retrieve URLs concurrently
|
|
65
|
-
tasks = [_retrieve_single(url, remove_urls, session, methods, actions) for url in urls_unique]
|
|
69
|
+
tasks = [_retrieve_single(url, remove_urls, session, methods, actions, format) for url in urls_unique]
|
|
66
70
|
results = await run_with_semaphore(tasks, limit=20, show_progress=show_progress and len(urls_to_retrieve) > 1,
|
|
67
71
|
progress_description="Retrieving URLs...")
|
|
68
72
|
|
|
@@ -80,21 +84,27 @@ async def _retrieve_single(
|
|
|
80
84
|
session: aiohttp.ClientSession,
|
|
81
85
|
methods: list[str],
|
|
82
86
|
actions: list[dict] = None,
|
|
83
|
-
|
|
87
|
+
format: str = "multimodal_sequence",
|
|
88
|
+
) -> Optional[MultimodalSequence | str]:
|
|
84
89
|
try:
|
|
85
90
|
# Ensure URL is a string
|
|
86
91
|
url = str(url)
|
|
87
92
|
|
|
93
|
+
# Ensure compatibility with methods
|
|
94
|
+
if format == "html" and "integrations" in methods:
|
|
95
|
+
methods.remove("integrations")
|
|
96
|
+
|
|
88
97
|
# Try to download as medium
|
|
89
|
-
if
|
|
90
|
-
|
|
98
|
+
if format != "html":
|
|
99
|
+
if medium := await download_item(url, session=session):
|
|
100
|
+
return MultimodalSequence(medium)
|
|
91
101
|
|
|
92
102
|
# Define available retrieval methods
|
|
93
103
|
method_map = {
|
|
94
104
|
"integrations": lambda: retrieve_via_integration(url, session),
|
|
95
105
|
"firecrawl": lambda: fire.scrape(url, remove_urls=remove_urls,
|
|
96
|
-
session=session, actions=actions),
|
|
97
|
-
"decodo": lambda: decodo.scrape(url, remove_urls, session),
|
|
106
|
+
session=session, format=format, actions=actions),
|
|
107
|
+
"decodo": lambda: decodo.scrape(url, remove_urls, session, format=format),
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
# Try each method in the specified order until one succeeds
|
|
@@ -104,7 +114,11 @@ async def _retrieve_single(
|
|
|
104
114
|
continue
|
|
105
115
|
|
|
106
116
|
logger.debug(f"Trying method: {method_name}")
|
|
107
|
-
|
|
117
|
+
try:
|
|
118
|
+
result = await method_map[method_name]()
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Error while retrieving with method '{method_name}': {e}")
|
|
121
|
+
result = None
|
|
108
122
|
|
|
109
123
|
if result is not None:
|
|
110
124
|
logger.debug(f"Successfully retrieved with method: {method_name}")
|
|
@@ -39,8 +39,9 @@ class Decodo:
|
|
|
39
39
|
async def scrape(self, url: str,
|
|
40
40
|
remove_urls: bool,
|
|
41
41
|
session: aiohttp.ClientSession,
|
|
42
|
+
format: str,
|
|
42
43
|
enable_js: bool = True,
|
|
43
|
-
timeout: int = 30) -> Optional[MultimodalSequence]:
|
|
44
|
+
timeout: int = 30) -> Optional[MultimodalSequence | str]:
|
|
44
45
|
"""Downloads the contents of the specified webpage using Decodo's API.
|
|
45
46
|
|
|
46
47
|
Args:
|
|
@@ -70,7 +71,10 @@ class Decodo:
|
|
|
70
71
|
html = await self._call_decodo(url, session, enable_js=False, timeout=timeout)
|
|
71
72
|
|
|
72
73
|
if html:
|
|
73
|
-
|
|
74
|
+
if format == "html":
|
|
75
|
+
return html
|
|
76
|
+
else:
|
|
77
|
+
return await to_multimodal_sequence(html, remove_urls=remove_urls, session=session)
|
|
74
78
|
return None
|
|
75
79
|
|
|
76
80
|
async def _call_decodo(self, url: str,
|
|
@@ -107,8 +111,6 @@ class Decodo:
|
|
|
107
111
|
# Create basic auth
|
|
108
112
|
auth = aiohttp.BasicAuth(self.username, self.password)
|
|
109
113
|
|
|
110
|
-
# logger.debug(f"Decodo request payload: {payload}")
|
|
111
|
-
|
|
112
114
|
try:
|
|
113
115
|
async with session.post(
|
|
114
116
|
self.DECODO_API_URL,
|
|
@@ -117,26 +119,17 @@ class Decodo:
|
|
|
117
119
|
auth=auth,
|
|
118
120
|
timeout=aiohttp.ClientTimeout(total=timeout)
|
|
119
121
|
) as response:
|
|
120
|
-
|
|
122
|
+
# Validate response health
|
|
121
123
|
if response.status != 200:
|
|
122
|
-
|
|
123
|
-
try:
|
|
124
|
-
error_body = await response.json()
|
|
125
|
-
error_msg = error_body.get('message', error_body)
|
|
126
|
-
except:
|
|
127
|
-
error_msg = await response.text()
|
|
128
|
-
|
|
129
|
-
logger.info(
|
|
130
|
-
f"Failed to scrape {url} with Decodo\n"
|
|
131
|
-
f"Status code: {response.status} - Reason: {response.reason}\n"
|
|
132
|
-
f"Error details: {error_msg}"
|
|
133
|
-
)
|
|
134
|
-
|
|
124
|
+
logger.debug("Communication with Decodo API failed.")
|
|
135
125
|
match response.status:
|
|
126
|
+
case 400:
|
|
127
|
+
logger.debug("Error 400: Bad request. If you use JavaScript, make sure you have the "
|
|
128
|
+
"Advanced plan subscription.")
|
|
136
129
|
case 401:
|
|
137
130
|
logger.error("Error 401: Unauthorized. Check your Decodo credentials.")
|
|
138
131
|
case 402:
|
|
139
|
-
logger.
|
|
132
|
+
logger.error("Error 402: Payment required. Check your Decodo subscription.")
|
|
140
133
|
case 403:
|
|
141
134
|
logger.debug("Error 403: Forbidden.")
|
|
142
135
|
case 408:
|
|
@@ -153,6 +146,13 @@ class Decodo:
|
|
|
153
146
|
# Parse response
|
|
154
147
|
json_response = await response.json()
|
|
155
148
|
|
|
149
|
+
# Validate if scrape was successful
|
|
150
|
+
if json_response.get("status") == "failed":
|
|
151
|
+
status_code = json_response.get("status_code")
|
|
152
|
+
message = json_response.get("message")
|
|
153
|
+
logger.info(f"Decodo failed to scrape. Error {status_code}: {message}")
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
156
|
# Extract HTML content from results
|
|
157
157
|
if "results" in json_response and len(json_response["results"]) > 0:
|
|
158
158
|
result = json_response["results"][0]
|
|
@@ -65,7 +65,8 @@ class Firecrawl:
|
|
|
65
65
|
url: str,
|
|
66
66
|
remove_urls: bool,
|
|
67
67
|
session: aiohttp.ClientSession,
|
|
68
|
-
|
|
68
|
+
format: str,
|
|
69
|
+
**kwargs) -> Optional[MultimodalSequence | str]:
|
|
69
70
|
if is_no_bot_site(url):
|
|
70
71
|
return None
|
|
71
72
|
|
|
@@ -84,7 +85,10 @@ class Firecrawl:
|
|
|
84
85
|
html = document.html
|
|
85
86
|
|
|
86
87
|
if html:
|
|
87
|
-
|
|
88
|
+
if format == "html":
|
|
89
|
+
return html
|
|
90
|
+
else:
|
|
91
|
+
return await to_multimodal_sequence(html, remove_urls=remove_urls, session=session)
|
|
88
92
|
|
|
89
93
|
|
|
90
94
|
fire = Firecrawl()
|
|
@@ -6,6 +6,7 @@ from typing import Optional
|
|
|
6
6
|
|
|
7
7
|
import aiohttp
|
|
8
8
|
import requests
|
|
9
|
+
from PIL import UnidentifiedImageError
|
|
9
10
|
from ezmm import MultimodalSequence, download_item, Item, Image, Video
|
|
10
11
|
from markdownify import markdownify as md
|
|
11
12
|
from requests.exceptions import ReadTimeout, ConnectionError, RetryError
|
|
@@ -159,7 +160,8 @@ def from_base64(b64_data: str, mime_type: str = "image/jpeg") -> Optional[Item]:
|
|
|
159
160
|
else:
|
|
160
161
|
raise ValueError(f"Unsupported media type: {mime_type}")
|
|
161
162
|
except binascii.Error: # base64 validation failed
|
|
162
|
-
return
|
|
163
|
+
return None
|
|
164
|
+
except UnidentifiedImageError: # Pillow could not identify image format
|
|
165
|
+
return None
|
|
163
166
|
except Exception as e:
|
|
164
|
-
logger.
|
|
165
|
-
f"{type(e).__name__}: {e}")
|
|
167
|
+
logger.debug(f"Error decoding {mime_type} base64 data. \n {type(e).__name__}: {e}")
|
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from ezmm import MultimodalSequence
|
|
2
3
|
|
|
3
4
|
from scrapemm import retrieve
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
@pytest.mark.asyncio
|
|
7
8
|
@pytest.mark.parametrize("url", [
|
|
8
|
-
"https://www.zeit.de/politik/deutschland/2025-07/spionage-iran-festnahme-anschlag-juden-berlin-daenemark",
|
|
9
|
-
"https://factnameh.com/fa/fact-checks/2025-04-16-araghchi-witkoff-fake-photo",
|
|
10
|
-
"https://www.thip.media/health-news-fact-check/fact-check-can-a-kalava-on-the-wrist-prevent-paralysis/74724/",
|
|
11
9
|
"https://www.vishvasnews.com/viral/fact-check-upsc-has-not-reduced-the-maximum-age-limit-for-ias-and-ips-exams/",
|
|
12
|
-
"https://www.thip.media/health-news-fact-check/fact-check-does-wrapping-body-with-banana-leaves-help-with-obesity-and-indigestion/71333/",
|
|
13
10
|
"https://health.medicaldialogues.in/fact-check/brain-health-fact-check/fact-check-is-sprite-the-best-remedy-for-headaches-in-the-world-140368",
|
|
14
11
|
"https://www.washingtonpost.com/politics/2024/05/15/bidens-false-claim-that-inflation-was-9-percent-when-he-took-office/",
|
|
15
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/",
|
|
16
13
|
"https://factuel.afp.com/doc.afp.com.43ZN7NP",
|
|
17
14
|
"https://leadstories.com/365cb414b83e29d26fecae374d55c743a3eac4c7.png",
|
|
18
15
|
])
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
@pytest.mark.parametrize("method", ["firecrawl", "decodo"])
|
|
17
|
+
async def test_generic_retrieval(url, method):
|
|
18
|
+
result = await retrieve(url, methods=[method])
|
|
19
|
+
print(result)
|
|
20
|
+
assert result
|
|
21
|
+
assert isinstance(result, MultimodalSequence)
|
|
22
|
+
assert result.has_images()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
@pytest.mark.parametrize("url", [
|
|
27
|
+
"https://www.zeit.de/politik/deutschland/2025-07/spionage-iran-festnahme-anschlag-juden-berlin-daenemark",
|
|
28
|
+
"https://factnameh.com/fa/fact-checks/2025-04-16-araghchi-witkoff-fake-photo",
|
|
29
|
+
"https://www.thip.media/health-news-fact-check/fact-check-can-a-kalava-on-the-wrist-prevent-paralysis/74724/",
|
|
30
|
+
])
|
|
31
|
+
@pytest.mark.parametrize("method", ["firecrawl", "decodo"])
|
|
32
|
+
async def test_html_retrieval(url, method):
|
|
33
|
+
result = await retrieve(url, format="html", methods=[method])
|
|
21
34
|
print(result)
|
|
22
35
|
assert result
|
|
36
|
+
assert isinstance(result, str)
|
|
23
37
|
|
|
24
38
|
|
|
25
39
|
@pytest.mark.asyncio
|
|
@@ -75,7 +89,7 @@ async def test_instagram_retrieval(url):
|
|
|
75
89
|
@pytest.mark.parametrize("url", [
|
|
76
90
|
"https://www.facebook.com/reel/1089214926521000", # video
|
|
77
91
|
"https://www.facebook.com/reel/3466446073497470", # video, restricted for misinformation
|
|
78
|
-
"https://www.facebook.com/groups/1973976962823632/posts/3992825270938781/",
|
|
92
|
+
"https://www.facebook.com/groups/1973976962823632/posts/3992825270938781/", # video, restricted for misinformation
|
|
79
93
|
"https://www.facebook.com/photo/?fbid=1721085455188778&set=a.107961589834514&_rdc=1&_rdr", # image
|
|
80
94
|
])
|
|
81
95
|
async def test_facebook_retrieval(url):
|
|
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
|