megaloader 0.1.0__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.
- megaloader-0.1.0/MANIFEST.in +2 -0
- megaloader-0.1.0/PKG-INFO +213 -0
- megaloader-0.1.0/megaloader/__init__.py +87 -0
- megaloader-0.1.0/megaloader/exceptions.py +14 -0
- megaloader-0.1.0/megaloader/item.py +32 -0
- megaloader-0.1.0/megaloader/plugin.py +94 -0
- megaloader-0.1.0/megaloader/plugins/__init__.py +74 -0
- megaloader-0.1.0/megaloader/plugins/bunkr.py +147 -0
- megaloader-0.1.0/megaloader/plugins/cyberdrop.py +116 -0
- megaloader-0.1.0/megaloader/plugins/fanbox.py +165 -0
- megaloader-0.1.0/megaloader/plugins/fapello.py +84 -0
- megaloader-0.1.0/megaloader/plugins/gofile.py +105 -0
- megaloader-0.1.0/megaloader/plugins/pixeldrain.py +51 -0
- megaloader-0.1.0/megaloader/plugins/pixiv.py +135 -0
- megaloader-0.1.0/megaloader/plugins/rule34.py +174 -0
- megaloader-0.1.0/megaloader/plugins/thothub_to.py +164 -0
- megaloader-0.1.0/megaloader/plugins/thothub_vip.py +114 -0
- megaloader-0.1.0/megaloader/plugins/thotslife.py +66 -0
- megaloader-0.1.0/megaloader/py.typed +0 -0
- megaloader-0.1.0/megaloader.egg-info/PKG-INFO +213 -0
- megaloader-0.1.0/megaloader.egg-info/SOURCES.txt +42 -0
- megaloader-0.1.0/megaloader.egg-info/dependency_links.txt +1 -0
- megaloader-0.1.0/megaloader.egg-info/requires.txt +10 -0
- megaloader-0.1.0/megaloader.egg-info/top_level.txt +1 -0
- megaloader-0.1.0/pyproject.toml +63 -0
- megaloader-0.1.0/readme.md +194 -0
- megaloader-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: megaloader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for extracting downloadable content metadata from file hosting platforms
|
|
5
|
+
Author-email: DURAND Malo <malo.durand@epitech.eu>
|
|
6
|
+
Maintainer-email: David Duran <dadch1404@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.14.2
|
|
11
|
+
Requires-Dist: lxml>=6.0.2
|
|
12
|
+
Requires-Dist: requests>=2.32.5
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.18.2; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest>=8.4.2; extra == "dev"
|
|
16
|
+
Requires-Dist: requests-mock>=1.12.1; extra == "dev"
|
|
17
|
+
Requires-Dist: types-beautifulsoup4>=4.12.0.20250516; extra == "dev"
|
|
18
|
+
Requires-Dist: types-requests>=2.32.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# [pkg]: megaloader (core)
|
|
21
|
+
|
|
22
|
+
[](https://badge.fury.io/py/megaloader)
|
|
23
|
+
[](https://github.com/totallynotdavid/megaloader/actions/workflows/codeql.yml)
|
|
24
|
+
[](https://github.com/totallynotdavid/megaloader/actions/workflows/checks.yml)
|
|
25
|
+
[](https://codecov.io/gh/totallynotdavid/megaloader)
|
|
26
|
+
|
|
27
|
+
Library for extracting downloadable content metadata from file hosting
|
|
28
|
+
platforms. Provides automatic URL detection and a plugin architecture for
|
|
29
|
+
multi-platform support.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install megaloader
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The library has minimal dependencies: `requests` for HTTP, `beautifulsoup4` and
|
|
38
|
+
`lxml` for HTML parsing.
|
|
39
|
+
|
|
40
|
+
## Basic usage
|
|
41
|
+
|
|
42
|
+
Call `extract()` with any supported URL. The function detects the platform
|
|
43
|
+
automatically and returns a generator of file metadata:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from megaloader import extract
|
|
47
|
+
|
|
48
|
+
for item in extract("https://pixeldrain.com/l/abc123"):
|
|
49
|
+
print(f"{item.filename} - {item.download_url}")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each item contains the download URL, filename, and optional metadata like
|
|
53
|
+
collection name and file size. Network requests happen lazily during iteration.
|
|
54
|
+
|
|
55
|
+
## Downloading files
|
|
56
|
+
|
|
57
|
+
The library extracts metadata only. Use `requests` or similar to download:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import requests
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from megaloader import extract
|
|
63
|
+
|
|
64
|
+
def download_file(item, output_dir):
|
|
65
|
+
dest = Path(output_dir) / item.filename
|
|
66
|
+
response = requests.get(item.download_url, headers=item.headers, stream=True)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
|
|
69
|
+
with open(dest, 'wb') as f:
|
|
70
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
71
|
+
f.write(chunk)
|
|
72
|
+
|
|
73
|
+
for item in extract("https://pixeldrain.com/l/abc123"):
|
|
74
|
+
download_file(item, "./downloads")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The `headers` attribute contains any required HTTP headers for the download
|
|
78
|
+
request.
|
|
79
|
+
|
|
80
|
+
## Supported platforms
|
|
81
|
+
|
|
82
|
+
Four core platforms receive active development. Seven extended platforms are
|
|
83
|
+
maintained best-effort and may break without immediate fixes.
|
|
84
|
+
|
|
85
|
+
**Core platforms**:
|
|
86
|
+
|
|
87
|
+
Bunkr (bunkr.si, bunkr.la, bunkr.is, bunkr.ru, bunkr.su), PixelDrain
|
|
88
|
+
(pixeldrain.com), Cyberdrop (cyberdrop.cr, cyberdrop.me, cyberdrop.to), GoFile
|
|
89
|
+
(gofile.io).
|
|
90
|
+
|
|
91
|
+
**Extended platforms**:
|
|
92
|
+
|
|
93
|
+
Fanbox ({creator}.fanbox.cc), Pixiv (pixiv.net), Rule34 (rule34.xxx), ThotsLife
|
|
94
|
+
(thotslife.com), ThotHub.VIP (thothub.vip), ThotHub.TO (thothub.to), Fapello
|
|
95
|
+
(fapello.com).
|
|
96
|
+
|
|
97
|
+
All platforms support albums, galleries, or lists. Single-file URLs work where
|
|
98
|
+
applicable.
|
|
99
|
+
|
|
100
|
+
Extended platforms marked as working as of November 2025.
|
|
101
|
+
|
|
102
|
+
## Platform-specific features
|
|
103
|
+
|
|
104
|
+
GoFile supports password-protected folders through the password parameter:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
items = extract("https://gofile.io/d/folder", password="secret123")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Fanbox and Pixiv require session cookies for full results. Without
|
|
111
|
+
authentication, only limited data is returned:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
items = extract("https://creator.fanbox.cc", session_id="your_session_cookie")
|
|
115
|
+
items = extract("https://pixiv.net/artworks/12345", session_id="your_session_cookie")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Rule34 accepts optional API credentials for higher rate limits:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
items = extract(
|
|
122
|
+
"https://rule34.xxx/index.php?page=post&s=list&tags=example",
|
|
123
|
+
api_key="your_api_key",
|
|
124
|
+
user_id="your_user_id"
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Authentication improves results but is not required.
|
|
129
|
+
|
|
130
|
+
<!-- prettier-ignore -->
|
|
131
|
+
> [!WARNING]
|
|
132
|
+
> Free-tier accounts on Pixiv and Fanbox may still return incomplete file sets.
|
|
133
|
+
|
|
134
|
+
## Working with items
|
|
135
|
+
|
|
136
|
+
The `DownloadItem` dataclass contains file metadata:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
for item in extract(url):
|
|
140
|
+
item.download_url # Direct download URL (required)
|
|
141
|
+
item.filename # Original filename (required)
|
|
142
|
+
item.collection_name # Album/gallery name (optional)
|
|
143
|
+
item.source_id # Platform-specific ID (optional)
|
|
144
|
+
item.size_bytes # File size in bytes (optional)
|
|
145
|
+
item.headers # Required HTTP headers (optional)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Required fields are always populated. Optional fields may be `None` depending on
|
|
149
|
+
platform and content type.
|
|
150
|
+
|
|
151
|
+
## Direct plugin usage
|
|
152
|
+
|
|
153
|
+
Import plugin classes directly when you need fine-grained control or want to
|
|
154
|
+
force a specific plugin:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from megaloader.plugins import Cyberdrop
|
|
158
|
+
|
|
159
|
+
plugin = Cyberdrop("https://cyberdrop.me/a/album_id")
|
|
160
|
+
items = list(plugin.extract())
|
|
161
|
+
print(f"Found {len(items)} files")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This bypasses automatic detection. Useful when a platform introduces new domains
|
|
165
|
+
before the package updates.
|
|
166
|
+
|
|
167
|
+
## Error handling
|
|
168
|
+
|
|
169
|
+
Handle extraction failures as needed:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from megaloader import extract, ExtractionError, UnsupportedDomainError
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
items = list(extract(url))
|
|
176
|
+
except UnsupportedDomainError:
|
|
177
|
+
print("Platform not supported")
|
|
178
|
+
except ExtractionError as e:
|
|
179
|
+
print(f"Extraction failed: {e}")
|
|
180
|
+
except ValueError:
|
|
181
|
+
print("Invalid URL format")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Network failures raise `ExtractionError`. Unsupported URLs raise
|
|
185
|
+
`UnsupportedDomainError`. Malformed URLs raise `ValueError`.
|
|
186
|
+
|
|
187
|
+
## API reference
|
|
188
|
+
|
|
189
|
+
The `extract()` function takes a URL and platform-specific options. Returns a
|
|
190
|
+
generator of `DownloadItem` objects. Raises `ValueError` for invalid URLs,
|
|
191
|
+
`UnsupportedDomainError` when no plugin exists, `ExtractionError` on network or
|
|
192
|
+
parsing failures.
|
|
193
|
+
|
|
194
|
+
The `DownloadItem` dataclass has required fields `download_url` and `filename`.
|
|
195
|
+
Optional fields are `collection_name`, `source_id`, `size_bytes`, and `headers`.
|
|
196
|
+
|
|
197
|
+
The `BasePlugin` abstract class defines the plugin interface. Override
|
|
198
|
+
`extract()` to yield items. Override `_configure_session()` to add custom
|
|
199
|
+
headers or authentication. The `session` property provides a configured requests
|
|
200
|
+
session. The `url` and `options` properties contain constructor arguments.
|
|
201
|
+
|
|
202
|
+
Exception hierarchy: `ExtractionError` for network and parsing failures,
|
|
203
|
+
`UnsupportedDomainError` for unknown domains, both inherit from `Exception`.
|
|
204
|
+
|
|
205
|
+
## Contributing
|
|
206
|
+
|
|
207
|
+
The project welcomes contributions. Install dependencies with `uv sync` from the
|
|
208
|
+
repository root. Run `uv run ruff format .` and `uv run mypy packages/core`
|
|
209
|
+
before committing. See the repository contributing guide for plugin development
|
|
210
|
+
details.
|
|
211
|
+
|
|
212
|
+
Report bugs and request features through GitHub Discussions. Include Python
|
|
213
|
+
version, error messages, and problematic URLs.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Megaloader - Extract downloadable content metadata from file hosting platforms.
|
|
3
|
+
|
|
4
|
+
Basic usage:
|
|
5
|
+
import megaloader as mgl
|
|
6
|
+
|
|
7
|
+
for item in mgl.extract(url):
|
|
8
|
+
print(item.url, item.filename)
|
|
9
|
+
|
|
10
|
+
# With plugin-specific options
|
|
11
|
+
items = mgl.extract(url, password="secret")
|
|
12
|
+
items = mgl.extract(url, session_id="cookie_value")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import urllib.parse
|
|
17
|
+
|
|
18
|
+
from collections.abc import Generator
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from megaloader.exceptions import ExtractionError, UnsupportedDomainError
|
|
22
|
+
from megaloader.item import DownloadItem
|
|
23
|
+
from megaloader.plugins import get_plugin_class
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__version__ = "0.2.0"
|
|
30
|
+
__all__ = ["DownloadItem", "ExtractionError", "UnsupportedDomainError", "extract"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract(url: str, **options: Any) -> Generator[DownloadItem, None, None]:
|
|
34
|
+
"""
|
|
35
|
+
Extract downloadable items from a URL.
|
|
36
|
+
|
|
37
|
+
Returns a generator that yields items lazily as they're discovered.
|
|
38
|
+
Network requests happen during iteration, not at call time.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
url: The source URL to extract from
|
|
42
|
+
**options: Plugin-specific options:
|
|
43
|
+
- password: str (Gofile)
|
|
44
|
+
- session_id: str (Fanbox, Pixiv)
|
|
45
|
+
- api_key: str (Rule34)
|
|
46
|
+
- user_id: str (Rule34)
|
|
47
|
+
|
|
48
|
+
Yields:
|
|
49
|
+
DownloadItem: Metadata for each downloadable file
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: Invalid URL format
|
|
53
|
+
UnsupportedDomainError: No plugin available for domain
|
|
54
|
+
ExtractionError: Network or parsing failure
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> for item in extract("https://pixeldrain.com/l/abc123"):
|
|
58
|
+
... print(item.download_url, item.filename)
|
|
59
|
+
"""
|
|
60
|
+
if not url or not url.strip():
|
|
61
|
+
msg = "URL cannot be empty"
|
|
62
|
+
raise ValueError(msg)
|
|
63
|
+
|
|
64
|
+
url = url.strip()
|
|
65
|
+
parsed = urllib.parse.urlparse(url)
|
|
66
|
+
|
|
67
|
+
if not parsed.netloc:
|
|
68
|
+
msg = f"Invalid URL: Could not parse domain from '{url}'"
|
|
69
|
+
raise ValueError(msg)
|
|
70
|
+
|
|
71
|
+
plugin_class = get_plugin_class(parsed.netloc)
|
|
72
|
+
if plugin_class is None:
|
|
73
|
+
raise UnsupportedDomainError(parsed.netloc)
|
|
74
|
+
|
|
75
|
+
logger.debug(
|
|
76
|
+
"Initializing %s for domain '%s'", plugin_class.__name__, parsed.netloc
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
plugin = plugin_class(url, **options)
|
|
81
|
+
yield from plugin.extract()
|
|
82
|
+
except (UnsupportedDomainError, ValueError):
|
|
83
|
+
raise
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.debug("Extraction failed for %s: %s", url, e, exc_info=True)
|
|
86
|
+
msg = f"Failed to extract from {url}: {e}"
|
|
87
|
+
raise ExtractionError(msg) from e
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class MegaloaderError(Exception):
|
|
2
|
+
"""Base exception for all megaloader errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ExtractionError(MegaloaderError):
|
|
6
|
+
"""Failed to extract items from URL due to network or parsing error."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnsupportedDomainError(MegaloaderError):
|
|
10
|
+
"""No plugin available for this domain."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, domain: str) -> None:
|
|
13
|
+
super().__init__(f"No plugin found for domain: {domain}")
|
|
14
|
+
self.domain = domain
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class DownloadItem:
|
|
6
|
+
"""
|
|
7
|
+
Represents a single downloadable file with metadata.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
download_url: Direct URL to download the file
|
|
11
|
+
filename: Original filename (may need sanitization for filesystem)
|
|
12
|
+
collection_name: Optional grouping (album/gallery/user)
|
|
13
|
+
source_id: Optional unique identifier from the source platform
|
|
14
|
+
headers: Optional HTTP headers required for download (e.g., Referer)
|
|
15
|
+
size_bytes: Optional file size in bytes
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
download_url: str
|
|
19
|
+
filename: str
|
|
20
|
+
collection_name: str | None = None
|
|
21
|
+
source_id: str | None = None
|
|
22
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
size_bytes: int | None = None
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
"""Validate required fields."""
|
|
27
|
+
if not self.download_url:
|
|
28
|
+
msg = "download_url cannot be empty"
|
|
29
|
+
raise ValueError(msg)
|
|
30
|
+
if not self.filename:
|
|
31
|
+
msg = "filename cannot be empty"
|
|
32
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from requests.adapters import HTTPAdapter
|
|
10
|
+
from urllib3.util.retry import Retry
|
|
11
|
+
|
|
12
|
+
from megaloader.item import DownloadItem
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BasePlugin(ABC):
|
|
19
|
+
"""
|
|
20
|
+
Base class for site-specific extractors.
|
|
21
|
+
|
|
22
|
+
Credential handling convention:
|
|
23
|
+
1. Explicit **kwargs take precedence (e.g., password="secret")
|
|
24
|
+
2. Environment variables as fallback (PLUGIN_NAME_*)
|
|
25
|
+
3. Fail gracefully if required credentials missing
|
|
26
|
+
|
|
27
|
+
Subclasses should override _configure_session() to add:
|
|
28
|
+
- Authentication headers/cookies
|
|
29
|
+
- Site-specific headers (Referer, Origin)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
DEFAULT_HEADERS: ClassVar[dict[str, str]] = {
|
|
33
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def __init__(self, url: str, **options: Any) -> None:
|
|
37
|
+
if not url.strip():
|
|
38
|
+
msg = "URL must be a non-empty string"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
|
|
41
|
+
self.url = url.strip()
|
|
42
|
+
self.options = options
|
|
43
|
+
self._session: requests.Session | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def session(self) -> requests.Session:
|
|
47
|
+
"""Lazily create session with retry logic and default headers."""
|
|
48
|
+
if self._session is None:
|
|
49
|
+
self._session = self._create_session()
|
|
50
|
+
self._configure_session(self._session)
|
|
51
|
+
return self._session
|
|
52
|
+
|
|
53
|
+
def _create_session(self) -> requests.Session:
|
|
54
|
+
"""Create session with retry strategy for transient failures."""
|
|
55
|
+
session = requests.Session()
|
|
56
|
+
session.headers.update(self.DEFAULT_HEADERS)
|
|
57
|
+
|
|
58
|
+
# Retry on common transient errors
|
|
59
|
+
retry_strategy = Retry(
|
|
60
|
+
total=3,
|
|
61
|
+
backoff_factor=1,
|
|
62
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
63
|
+
allowed_methods=["GET", "POST"],
|
|
64
|
+
)
|
|
65
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
66
|
+
session.mount("https://", adapter)
|
|
67
|
+
session.mount("http://", adapter)
|
|
68
|
+
|
|
69
|
+
return session
|
|
70
|
+
|
|
71
|
+
def _configure_session(self, session: requests.Session) -> None: # noqa: B027
|
|
72
|
+
"""
|
|
73
|
+
Override to add plugin-specific headers/cookies.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
session.headers["Referer"] = f"https://{self.domain}/"
|
|
77
|
+
if api_key := os.getenv("PLUGIN_API_KEY"):
|
|
78
|
+
session.headers["Authorization"] = f"Bearer {api_key}"
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def extract(self) -> Generator[DownloadItem, None, None]:
|
|
83
|
+
"""
|
|
84
|
+
Extract downloadable items from the URL.
|
|
85
|
+
|
|
86
|
+
Yields items as they're discovered (lazy evaluation).
|
|
87
|
+
Should handle pagination, nested galleries, etc.
|
|
88
|
+
|
|
89
|
+
Yields:
|
|
90
|
+
DownloadItem: Each file found at the URL
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ExtractionError: On network/parsing failures
|
|
94
|
+
"""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from megaloader.plugin import BasePlugin
|
|
2
|
+
from megaloader.plugins.bunkr import Bunkr
|
|
3
|
+
from megaloader.plugins.cyberdrop import Cyberdrop
|
|
4
|
+
from megaloader.plugins.fanbox import Fanbox
|
|
5
|
+
from megaloader.plugins.fapello import Fapello
|
|
6
|
+
from megaloader.plugins.gofile import Gofile
|
|
7
|
+
from megaloader.plugins.pixeldrain import PixelDrain
|
|
8
|
+
from megaloader.plugins.pixiv import Pixiv
|
|
9
|
+
from megaloader.plugins.rule34 import Rule34
|
|
10
|
+
from megaloader.plugins.thothub_to import ThothubTO
|
|
11
|
+
from megaloader.plugins.thothub_vip import ThothubVIP
|
|
12
|
+
from megaloader.plugins.thotslife import Thotslife
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PLUGIN_REGISTRY: dict[str, type[BasePlugin]] = {
|
|
16
|
+
"bunkr.si": Bunkr,
|
|
17
|
+
"bunkr.la": Bunkr,
|
|
18
|
+
"bunkr.is": Bunkr,
|
|
19
|
+
"bunkr.ru": Bunkr,
|
|
20
|
+
"bunkr.su": Bunkr,
|
|
21
|
+
"cyberdrop.cr": Cyberdrop,
|
|
22
|
+
"cyberdrop.me": Cyberdrop,
|
|
23
|
+
"cyberdrop.to": Cyberdrop,
|
|
24
|
+
"fanbox.cc": Fanbox,
|
|
25
|
+
"fapello.com": Fapello,
|
|
26
|
+
"gofile.io": Gofile,
|
|
27
|
+
"pixeldrain.com": PixelDrain,
|
|
28
|
+
"pixiv.net": Pixiv,
|
|
29
|
+
"rule34.xxx": Rule34,
|
|
30
|
+
"thothub.ch": ThothubTO,
|
|
31
|
+
"thothub.to": ThothubTO,
|
|
32
|
+
"thothub.vip": ThothubVIP,
|
|
33
|
+
"thotslife.com": Thotslife,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Domains that support subdomains (e.g., creator.fanbox.cc)
|
|
37
|
+
SUBDOMAIN_SUPPORTED: set[str] = {"fanbox.cc"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_plugin_class(domain: str) -> type[BasePlugin] | None:
|
|
41
|
+
"""
|
|
42
|
+
Resolve domain to plugin class.
|
|
43
|
+
|
|
44
|
+
Resolution order:
|
|
45
|
+
1. Exact match in PLUGIN_REGISTRY
|
|
46
|
+
2. Subdomain match for supported domains
|
|
47
|
+
3. Partial match (fallback for domain variations)
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
domain: Normalized domain from URL (e.g., "pixiv.net")
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Plugin class or None if unsupported
|
|
54
|
+
"""
|
|
55
|
+
domain = domain.lower().strip()
|
|
56
|
+
|
|
57
|
+
# Exact match
|
|
58
|
+
if domain in PLUGIN_REGISTRY:
|
|
59
|
+
return PLUGIN_REGISTRY[domain]
|
|
60
|
+
|
|
61
|
+
# Subdomain support (e.g., creator.fanbox.cc -> fanbox.cc)
|
|
62
|
+
for base_domain in SUBDOMAIN_SUPPORTED:
|
|
63
|
+
if domain.endswith(f".{base_domain}") and base_domain in PLUGIN_REGISTRY:
|
|
64
|
+
return PLUGIN_REGISTRY[base_domain]
|
|
65
|
+
|
|
66
|
+
# Partial match fallback (e.g., www.pixiv.net -> pixiv.net)
|
|
67
|
+
for registered_domain, plugin_class in PLUGIN_REGISTRY.items():
|
|
68
|
+
if registered_domain in domain:
|
|
69
|
+
return plugin_class
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["PLUGIN_REGISTRY", "get_plugin_class"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import html
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from urllib.parse import quote, urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from megaloader.item import DownloadItem
|
|
13
|
+
from megaloader.plugin import BasePlugin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Bunkr(BasePlugin):
|
|
20
|
+
"""Extract files from Bunkr albums and individual file pages."""
|
|
21
|
+
|
|
22
|
+
API_BASE = "https://apidl.bunkr.ru/api/_001_v2"
|
|
23
|
+
|
|
24
|
+
def extract(self) -> Generator[DownloadItem, None, None]:
|
|
25
|
+
path = urlparse(self.url).path
|
|
26
|
+
|
|
27
|
+
if path.startswith("/a/"):
|
|
28
|
+
logger.debug("Processing album")
|
|
29
|
+
yield from self._extract_album()
|
|
30
|
+
elif path.startswith("/f/"):
|
|
31
|
+
logger.debug("Processing single file")
|
|
32
|
+
yield from self._extract_file(self.url)
|
|
33
|
+
else:
|
|
34
|
+
logger.warning("Unrecognized Bunkr URL format")
|
|
35
|
+
|
|
36
|
+
def _extract_album(self) -> Generator[DownloadItem, None, None]:
|
|
37
|
+
"""Extract all files from an album page."""
|
|
38
|
+
try:
|
|
39
|
+
response = self.session.get(self.url, allow_redirects=True, timeout=30)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
except Exception:
|
|
42
|
+
logger.exception("Failed to fetch album page")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
file_links = re.findall(r'href="(/f/[^"]+)"', response.text)
|
|
46
|
+
|
|
47
|
+
if not file_links:
|
|
48
|
+
logger.warning("No files found in album")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
seen_urls = set()
|
|
52
|
+
for link in file_links:
|
|
53
|
+
# Skip template variables
|
|
54
|
+
if "file.slug" in link or "+" in link:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
file_url = urljoin(response.url, link)
|
|
58
|
+
if file_url in seen_urls:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
seen_urls.add(file_url)
|
|
62
|
+
yield from self._extract_file(file_url)
|
|
63
|
+
|
|
64
|
+
def _extract_file(self, file_url: str) -> Generator[DownloadItem, None, None]:
|
|
65
|
+
"""Extract download URL from a file page."""
|
|
66
|
+
try:
|
|
67
|
+
response = self.session.get(file_url, timeout=30)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
except requests.RequestException:
|
|
70
|
+
logger.debug("Failed to fetch file page %s", file_url, exc_info=True)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Find download button
|
|
74
|
+
download_match = re.search(
|
|
75
|
+
r'<a[^>]+class="[^"]*btn-main[^"]*"[^>]+href="([^"]+)"[^>]*>Download</a>',
|
|
76
|
+
response.text,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if not download_match:
|
|
80
|
+
logger.debug("No download button found for %s", file_url)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
download_page_url = urljoin(file_url, download_match.group(1))
|
|
84
|
+
|
|
85
|
+
# Extract file ID from download page URL
|
|
86
|
+
if match := re.search(r"/file/(\w+)", download_page_url):
|
|
87
|
+
file_id = match.group(1)
|
|
88
|
+
else:
|
|
89
|
+
logger.debug("Could not extract file ID from %s", download_page_url)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
filename = self._extract_filename(response.text) or f"bunkr_file_{file_id}"
|
|
93
|
+
|
|
94
|
+
if direct_url := self._fetch_direct_url(file_id, filename):
|
|
95
|
+
yield DownloadItem(
|
|
96
|
+
download_url=direct_url,
|
|
97
|
+
filename=filename,
|
|
98
|
+
source_id=file_id,
|
|
99
|
+
headers={"Referer": "https://get.bunkrr.su/"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _extract_filename(self, content: str) -> str | None:
|
|
103
|
+
"""Extract original filename from page metadata."""
|
|
104
|
+
# Try og:title meta tag
|
|
105
|
+
if match := re.search(r'<meta property="og:title" content="([^"]+)"', content):
|
|
106
|
+
return html.unescape(match.group(1)).strip()
|
|
107
|
+
|
|
108
|
+
# Try JavaScript variable
|
|
109
|
+
if match := re.search(r'var ogname\s*=\s*"([^"]+)"', content):
|
|
110
|
+
return html.unescape(match.group(1)).strip()
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def _fetch_direct_url(self, file_id: str, filename: str) -> str | None:
|
|
115
|
+
"""Get direct CDN URL using Bunkr's API."""
|
|
116
|
+
try:
|
|
117
|
+
response = self.session.post(
|
|
118
|
+
self.API_BASE,
|
|
119
|
+
json={"id": file_id},
|
|
120
|
+
timeout=30,
|
|
121
|
+
)
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
data = response.json()
|
|
124
|
+
|
|
125
|
+
# Decrypt the URL
|
|
126
|
+
timestamp = data["timestamp"]
|
|
127
|
+
encrypted_b64 = data["url"]
|
|
128
|
+
|
|
129
|
+
# Generate time-based decryption key
|
|
130
|
+
key_str = f"SECRET_KEY_{math.floor(timestamp / 3600)}"
|
|
131
|
+
key_bytes = key_str.encode("utf-8")
|
|
132
|
+
|
|
133
|
+
# XOR decrypt
|
|
134
|
+
encrypted_bytes = base64.b64decode(encrypted_b64)
|
|
135
|
+
decrypted = bytearray(
|
|
136
|
+
encrypted_bytes[i] ^ key_bytes[i % len(key_bytes)]
|
|
137
|
+
for i in range(len(encrypted_bytes))
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
base_url = decrypted.decode("utf-8")
|
|
141
|
+
return f"{base_url}?n={quote(filename)}"
|
|
142
|
+
|
|
143
|
+
except Exception: # noqa: BLE001
|
|
144
|
+
logger.debug(
|
|
145
|
+
"Failed to fetch direct URL for file ID %s", file_id, exc_info=True
|
|
146
|
+
)
|
|
147
|
+
return None
|