behance-dl 0.1.0__py3-none-any.whl
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.
- behance_dl/__init__.py +18 -0
- behance_dl/__main__.py +10 -0
- behance_dl/cli.py +170 -0
- behance_dl/client.py +204 -0
- behance_dl/errors.py +15 -0
- behance_dl/gallery.py +115 -0
- behance_dl/models.py +79 -0
- behance_dl/naming.py +17 -0
- behance_dl/selection.py +114 -0
- behance_dl/storage.py +184 -0
- behance_dl-0.1.0.dist-info/METADATA +152 -0
- behance_dl-0.1.0.dist-info/RECORD +15 -0
- behance_dl-0.1.0.dist-info/WHEEL +4 -0
- behance_dl-0.1.0.dist-info/entry_points.txt +2 -0
- behance_dl-0.1.0.dist-info/licenses/LICENSE +21 -0
behance_dl/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Download the images of a public Behance project gallery.
|
|
2
|
+
|
|
3
|
+
Behance inlines the whole project into the gallery page: every module plus every CDN
|
|
4
|
+
variant of each image with its pixel width. The downloader reads that payload, picks one
|
|
5
|
+
variant per image, and fetches the files concurrently. The payload itself is kept in
|
|
6
|
+
project.json, embeds such as prototypes and videos go to embeds.json because they are not
|
|
7
|
+
images, and manifest.json records which variant produced each saved file so a re-run only
|
|
8
|
+
fetches what is missing.
|
|
9
|
+
|
|
10
|
+
Files are meant for offline viewing of published work. Respect the creator's licence and
|
|
11
|
+
Behance's terms before redistributing anything.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
behance_dl/__main__.py
ADDED
behance_dl/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Command line interface for the Behance gallery downloader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import http.client
|
|
7
|
+
import io
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.error
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .client import BehanceClient
|
|
15
|
+
from .errors import BehanceError
|
|
16
|
+
from .models import GalleryPlan
|
|
17
|
+
from .selection import plan_gallery
|
|
18
|
+
from .storage import load_saved, save_gallery
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def print_plan_summary(plan: GalleryPlan) -> None:
|
|
22
|
+
"""Report what a gallery will produce."""
|
|
23
|
+
project = plan.project
|
|
24
|
+
print(f"{project.name or project.id} (id {project.id})")
|
|
25
|
+
print(f"{len(plan.images)} images, {len(plan.embeds)} embeds, target {plan.destination}")
|
|
26
|
+
missing = plan.expected_images - len(plan.images)
|
|
27
|
+
if missing > 0:
|
|
28
|
+
print(f"note: {missing} images carry no usable CDN variant")
|
|
29
|
+
for embed in plan.embeds:
|
|
30
|
+
if not embed.source_url:
|
|
31
|
+
print(f"note: module {embed.position} is an embed without a link")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_plan_assets(plan: GalleryPlan) -> None:
|
|
35
|
+
"""List the planned images and embeds with their sources."""
|
|
36
|
+
for asset in plan.images:
|
|
37
|
+
print(f" {asset.file_name} ({asset.width}px) <- {asset.url}")
|
|
38
|
+
for embed in plan.embeds:
|
|
39
|
+
print(f" {embed.position:03d} embed <- {embed.source_url}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="behance-dl",
|
|
45
|
+
description="Download the images of a public Behance project gallery.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"galleries",
|
|
49
|
+
nargs="+",
|
|
50
|
+
metavar="URL",
|
|
51
|
+
help="Behance gallery URL or bare project id",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"-o",
|
|
55
|
+
"--output",
|
|
56
|
+
type=Path,
|
|
57
|
+
default=Path(),
|
|
58
|
+
help="folder that receives one subfolder per gallery (default: current folder)",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"-W",
|
|
62
|
+
"--max-width",
|
|
63
|
+
type=positive_int,
|
|
64
|
+
default=None,
|
|
65
|
+
metavar="PX",
|
|
66
|
+
help="largest image at or below this width in pixels, else the smallest available "
|
|
67
|
+
"(default: the largest available)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-j",
|
|
71
|
+
"--workers",
|
|
72
|
+
type=positive_int,
|
|
73
|
+
default=4,
|
|
74
|
+
help="parallel downloads (default: 4)",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--attempts",
|
|
78
|
+
type=positive_int,
|
|
79
|
+
default=3,
|
|
80
|
+
help="attempts per request before giving up (default: 3)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--timeout",
|
|
84
|
+
type=positive_float,
|
|
85
|
+
default=30.0,
|
|
86
|
+
help="per-request timeout in seconds (default: 30)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--force",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="ignore the recorded downloads and fetch every image again",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--dry-run",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="list what would be downloaded without writing files",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--version",
|
|
100
|
+
action="version",
|
|
101
|
+
version=f"%(prog)s {__version__}",
|
|
102
|
+
)
|
|
103
|
+
return parser.parse_args(argv)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def positive_int(value: str) -> int:
|
|
107
|
+
try:
|
|
108
|
+
number = int(value)
|
|
109
|
+
except ValueError as error:
|
|
110
|
+
raise argparse.ArgumentTypeError(f"{value!r} is not a whole number") from error
|
|
111
|
+
if number < 1:
|
|
112
|
+
raise argparse.ArgumentTypeError(f"{value!r} must be at least 1")
|
|
113
|
+
return number
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def positive_float(value: str) -> float:
|
|
117
|
+
try:
|
|
118
|
+
number = float(value)
|
|
119
|
+
except ValueError as error:
|
|
120
|
+
raise argparse.ArgumentTypeError(f"{value!r} is not a number") from error
|
|
121
|
+
if number <= 0:
|
|
122
|
+
raise argparse.ArgumentTypeError(f"{value!r} must be greater than 0")
|
|
123
|
+
return number
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def relax_console_encoding() -> None:
|
|
127
|
+
"""Keep non-Latin project names printable on consoles with a narrow encoding."""
|
|
128
|
+
for stream in (sys.stdout, sys.stderr):
|
|
129
|
+
if isinstance(stream, io.TextIOWrapper):
|
|
130
|
+
stream.reconfigure(errors="replace")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
134
|
+
"""Run the downloads and return the process exit code."""
|
|
135
|
+
args = parse_args(argv)
|
|
136
|
+
relax_console_encoding()
|
|
137
|
+
client = BehanceClient(timeout=args.timeout, attempts=args.attempts)
|
|
138
|
+
exit_code = 0
|
|
139
|
+
try:
|
|
140
|
+
for gallery in args.galleries:
|
|
141
|
+
try:
|
|
142
|
+
plan = plan_gallery(client, gallery, args.max_width, args.output)
|
|
143
|
+
print_plan_summary(plan)
|
|
144
|
+
if args.dry_run:
|
|
145
|
+
print_plan_assets(plan)
|
|
146
|
+
continue
|
|
147
|
+
saved = {} if args.force else load_saved(plan.destination)
|
|
148
|
+
failed = save_gallery(client, plan, saved, workers=args.workers)
|
|
149
|
+
status = 1 if failed else 0
|
|
150
|
+
except BehanceError as error:
|
|
151
|
+
print(f"error: {error}", file=sys.stderr)
|
|
152
|
+
status = 1
|
|
153
|
+
except urllib.error.HTTPError as error:
|
|
154
|
+
print(
|
|
155
|
+
f"error: {gallery}: Behance answered {error.code} {error.reason}",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
status = 1
|
|
159
|
+
except (OSError, http.client.HTTPException) as error:
|
|
160
|
+
print(f"error: {gallery}: {error}", file=sys.stderr)
|
|
161
|
+
status = 1
|
|
162
|
+
exit_code = max(exit_code, status)
|
|
163
|
+
except KeyboardInterrupt:
|
|
164
|
+
print("interrupted", file=sys.stderr)
|
|
165
|
+
return 130
|
|
166
|
+
return exit_code
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
sys.exit(main())
|
behance_dl/client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""HTTP access to Behance, including its bot challenge and transient-failure retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import http.cookiejar
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import ssl
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
from .errors import DownloadError, GalleryError
|
|
19
|
+
|
|
20
|
+
CHALLENGE_COOKIE = "js_challenge_value"
|
|
21
|
+
USER_AGENT = (
|
|
22
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
23
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
24
|
+
)
|
|
25
|
+
RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
26
|
+
TRANSFER_BLOCK_SIZE = 64 * 1024
|
|
27
|
+
MAX_BACKOFF_SECONDS = 30.0
|
|
28
|
+
|
|
29
|
+
_CHALLENGE_VALUE = re.compile(r"js_challenge_value=([0-9a-fA-F]{16,})")
|
|
30
|
+
_MAGIC_SUFFIXES = (
|
|
31
|
+
(b"\x89PNG\r\n\x1a\n", ".png"),
|
|
32
|
+
(b"\xff\xd8\xff", ".jpg"),
|
|
33
|
+
(b"GIF87a", ".gif"),
|
|
34
|
+
(b"GIF89a", ".gif"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BehanceClient:
|
|
41
|
+
"""HTTP session that satisfies Behance's cookie challenge and retries transient failures."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
attempts: int = 3,
|
|
48
|
+
user_agent: str = USER_AGENT,
|
|
49
|
+
) -> None:
|
|
50
|
+
if attempts < 1:
|
|
51
|
+
raise ValueError("attempts must be at least 1")
|
|
52
|
+
if timeout <= 0:
|
|
53
|
+
raise ValueError("timeout must be greater than 0")
|
|
54
|
+
self._timeout = timeout
|
|
55
|
+
self._attempts = attempts
|
|
56
|
+
self._user_agent = user_agent
|
|
57
|
+
self._jar = http.cookiejar.CookieJar()
|
|
58
|
+
self._opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self._jar))
|
|
59
|
+
|
|
60
|
+
def get_text(self, url: str) -> str:
|
|
61
|
+
"""Return the decoded body of a text resource."""
|
|
62
|
+
return self._retry(lambda: self._read_text(url))
|
|
63
|
+
|
|
64
|
+
def download(self, url: str, destination: Path) -> Path:
|
|
65
|
+
"""Save a remote image and return where it landed, named after its real format."""
|
|
66
|
+
return self._retry(lambda: self._transfer(url, destination))
|
|
67
|
+
|
|
68
|
+
def _read_text(self, url: str) -> str:
|
|
69
|
+
with self._open(url) as response:
|
|
70
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
71
|
+
return response.read().decode(charset, "replace")
|
|
72
|
+
|
|
73
|
+
def _transfer(self, url: str, destination: Path) -> Path:
|
|
74
|
+
partial = destination.with_name(destination.name + ".part")
|
|
75
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
try:
|
|
77
|
+
return self._store_image(url, partial, destination)
|
|
78
|
+
except BaseException:
|
|
79
|
+
partial.unlink(missing_ok=True)
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
def _store_image(self, url: str, partial: Path, destination: Path) -> Path:
|
|
83
|
+
expected_size, content_type = self._copy_body(url, partial)
|
|
84
|
+
if not content_type.startswith("image/"):
|
|
85
|
+
raise DownloadError(f"{url} answered {content_type} instead of an image")
|
|
86
|
+
suffix = detected_suffix(partial)
|
|
87
|
+
if suffix is None:
|
|
88
|
+
raise DownloadError(f"{url} did not return a recognised image format")
|
|
89
|
+
received_size = partial.stat().st_size
|
|
90
|
+
if expected_size and expected_size.isdecimal() and received_size != int(expected_size):
|
|
91
|
+
raise DownloadError(f"{url} stopped after {received_size} of {expected_size} bytes")
|
|
92
|
+
final_name = destination.with_suffix(suffix)
|
|
93
|
+
partial.replace(final_name)
|
|
94
|
+
return final_name
|
|
95
|
+
|
|
96
|
+
def _copy_body(self, url: str, partial: Path) -> tuple[str | None, str]:
|
|
97
|
+
"""Stream a response body into the partial file and report its length and media type."""
|
|
98
|
+
with self._open(url) as response:
|
|
99
|
+
expected_size = response.headers.get("Content-Length")
|
|
100
|
+
content_type = response.headers.get_content_type()
|
|
101
|
+
with partial.open("wb") as handle:
|
|
102
|
+
shutil.copyfileobj(response, handle, TRANSFER_BLOCK_SIZE)
|
|
103
|
+
return expected_size, content_type
|
|
104
|
+
|
|
105
|
+
def _open(self, url: str) -> http.client.HTTPResponse:
|
|
106
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
107
|
+
try:
|
|
108
|
+
return self._opener.open(self._request(url), timeout=self._timeout)
|
|
109
|
+
except urllib.error.HTTPError as error:
|
|
110
|
+
if error.code != 403 or not self._solve_challenge(error, host):
|
|
111
|
+
raise
|
|
112
|
+
try:
|
|
113
|
+
return self._opener.open(self._request(url), timeout=self._timeout)
|
|
114
|
+
except urllib.error.HTTPError as error:
|
|
115
|
+
if error.code == 403 and self._solve_challenge(error, host):
|
|
116
|
+
raise GalleryError(f"{url} keeps answering Behance's bot challenge") from error
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
def _request(self, url: str) -> urllib.request.Request:
|
|
120
|
+
return urllib.request.Request(url, headers=self._headers())
|
|
121
|
+
|
|
122
|
+
def _retry(self, action: Callable[[], T]) -> T:
|
|
123
|
+
for attempt in range(1, self._attempts):
|
|
124
|
+
try:
|
|
125
|
+
return action()
|
|
126
|
+
except urllib.error.HTTPError as error:
|
|
127
|
+
if error.code not in RETRYABLE_STATUS:
|
|
128
|
+
raise
|
|
129
|
+
retry_after = error.headers.get("Retry-After")
|
|
130
|
+
error.close()
|
|
131
|
+
self._pause(attempt, retry_after)
|
|
132
|
+
except (
|
|
133
|
+
urllib.error.URLError,
|
|
134
|
+
http.client.HTTPException,
|
|
135
|
+
TimeoutError,
|
|
136
|
+
ConnectionError,
|
|
137
|
+
ssl.SSLError,
|
|
138
|
+
DownloadError,
|
|
139
|
+
):
|
|
140
|
+
self._pause(attempt, None)
|
|
141
|
+
return action()
|
|
142
|
+
|
|
143
|
+
def _pause(self, attempt: int, retry_after: str | None) -> None:
|
|
144
|
+
requested = parse_retry_after(retry_after)
|
|
145
|
+
if requested is not None:
|
|
146
|
+
time.sleep(min(requested, MAX_BACKOFF_SECONDS))
|
|
147
|
+
else:
|
|
148
|
+
time.sleep(min(2**attempt, MAX_BACKOFF_SECONDS))
|
|
149
|
+
|
|
150
|
+
def _solve_challenge(self, error: urllib.error.HTTPError, host: str) -> bool:
|
|
151
|
+
with error:
|
|
152
|
+
body = error.read().decode("utf-8", "replace")
|
|
153
|
+
match = _CHALLENGE_VALUE.search(body)
|
|
154
|
+
if match is None:
|
|
155
|
+
return False
|
|
156
|
+
self._jar.set_cookie(challenge_cookie(match.group(1), host))
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
def _headers(self) -> dict[str, str]:
|
|
160
|
+
return {"User-Agent": self._user_agent, "Accept-Language": "en-US,en;q=0.9"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def challenge_cookie(value: str, host: str) -> http.cookiejar.Cookie:
|
|
164
|
+
"""Build the cookie Behance's challenge page asks the browser to set."""
|
|
165
|
+
return http.cookiejar.Cookie(
|
|
166
|
+
version=0,
|
|
167
|
+
name=CHALLENGE_COOKIE,
|
|
168
|
+
value=value,
|
|
169
|
+
port=None,
|
|
170
|
+
port_specified=False,
|
|
171
|
+
domain=host,
|
|
172
|
+
domain_specified=True,
|
|
173
|
+
domain_initial_dot=False,
|
|
174
|
+
path="/",
|
|
175
|
+
path_specified=True,
|
|
176
|
+
secure=True,
|
|
177
|
+
expires=None,
|
|
178
|
+
discard=True,
|
|
179
|
+
comment=None,
|
|
180
|
+
comment_url=None,
|
|
181
|
+
rest={},
|
|
182
|
+
rfc2109=False,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
187
|
+
"""Read a Retry-After delay in seconds, ignoring dates and negative values."""
|
|
188
|
+
try:
|
|
189
|
+
seconds = float(value)
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
return None
|
|
192
|
+
return seconds if seconds >= 0 else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def detected_suffix(path: Path) -> str | None:
|
|
196
|
+
"""Return the extension matching the file's magic bytes, if it is an image we know."""
|
|
197
|
+
with path.open("rb") as handle:
|
|
198
|
+
head = handle.read(16)
|
|
199
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
200
|
+
return ".webp"
|
|
201
|
+
for magic, suffix in _MAGIC_SUFFIXES:
|
|
202
|
+
if head.startswith(magic):
|
|
203
|
+
return suffix
|
|
204
|
+
return None
|
behance_dl/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Errors the downloader raises on purpose."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BehanceError(RuntimeError):
|
|
7
|
+
"""Base class for every failure the downloader reports to the caller."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GalleryError(BehanceError):
|
|
11
|
+
"""Raised when a Behance page cannot be read or resolved into downloadable content."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DownloadError(BehanceError):
|
|
15
|
+
"""Raised when a remote image cannot be saved as a complete, genuine file."""
|
behance_dl/gallery.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Reading a gallery page and the project data Behance inlines in it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .client import BehanceClient
|
|
13
|
+
from .errors import GalleryError
|
|
14
|
+
from .models import EmbedAsset, GalleryProject
|
|
15
|
+
|
|
16
|
+
BEHANCE_ORIGIN = "https://www.behance.net"
|
|
17
|
+
|
|
18
|
+
_STORE_STATE = re.compile(
|
|
19
|
+
r'<script type="application/json" id="beconfig-store_state">(.*?)</script>',
|
|
20
|
+
re.DOTALL,
|
|
21
|
+
)
|
|
22
|
+
_GALLERY_PATH = re.compile(r"/gallery/(\d+)")
|
|
23
|
+
_IFRAME_SOURCE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
|
24
|
+
_EMBED_MODULE_TYPES = frozenset({"EmbedModule", "VideoModule"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_behance_host(host: str) -> bool:
|
|
28
|
+
"""Report whether a host belongs to Behance or one of its subdomains."""
|
|
29
|
+
return host == "behance.net" or host.endswith(".behance.net")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_behance_url(url: str) -> bool:
|
|
33
|
+
"""Report whether a payload URL points at Behance over https."""
|
|
34
|
+
parts = urllib.parse.urlsplit(url)
|
|
35
|
+
return parts.scheme == "https" and is_behance_host(parts.hostname or "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_gallery_id(gallery: str) -> int:
|
|
39
|
+
"""Return the numeric project id of a Behance gallery URL or bare id."""
|
|
40
|
+
candidate = gallery.strip()
|
|
41
|
+
if candidate.isdecimal():
|
|
42
|
+
return int(candidate)
|
|
43
|
+
parts = urllib.parse.urlsplit(candidate)
|
|
44
|
+
host = parts.hostname or ""
|
|
45
|
+
if host and not is_behance_host(host):
|
|
46
|
+
raise GalleryError(f"{gallery!r} is not a Behance gallery URL")
|
|
47
|
+
match = _GALLERY_PATH.search(parts.path)
|
|
48
|
+
if match is None:
|
|
49
|
+
raise GalleryError(f"{gallery!r} is not a Behance gallery URL")
|
|
50
|
+
return int(match.group(1))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_project(client: BehanceClient, gallery_id: int) -> GalleryProject:
|
|
54
|
+
"""Load the project payload embedded in a gallery page."""
|
|
55
|
+
# Behance answers 404 for /gallery/{id} alone and ignores whatever slug follows it.
|
|
56
|
+
page = client.get_text(f"{BEHANCE_ORIGIN}/gallery/{gallery_id}/gallery")
|
|
57
|
+
match = _STORE_STATE.search(page)
|
|
58
|
+
project_state: Any = None
|
|
59
|
+
if match is not None:
|
|
60
|
+
try:
|
|
61
|
+
state = json.loads(match.group(1))
|
|
62
|
+
except (ValueError, RecursionError) as error:
|
|
63
|
+
raise GalleryError(f"gallery {gallery_id}: unreadable project data") from error
|
|
64
|
+
project_state = state.get("project") if isinstance(state, dict) else None
|
|
65
|
+
payload = project_state.get("project") if isinstance(project_state, dict) else None
|
|
66
|
+
if not isinstance(payload, dict) or not payload:
|
|
67
|
+
raise GalleryError(
|
|
68
|
+
f"gallery {gallery_id}: the page carries no public project data "
|
|
69
|
+
"(removed, private, or behind a login)"
|
|
70
|
+
)
|
|
71
|
+
modules = payload.get("modules") or payload.get("allModules") or []
|
|
72
|
+
if not isinstance(modules, list) or not all(isinstance(module, dict) for module in modules):
|
|
73
|
+
raise GalleryError(f"gallery {gallery_id}: unexpected project data")
|
|
74
|
+
return GalleryProject(
|
|
75
|
+
id=gallery_id,
|
|
76
|
+
name=str(payload.get("name") or ""),
|
|
77
|
+
slug=str(payload.get("slug") or ""),
|
|
78
|
+
modules=tuple(modules),
|
|
79
|
+
payload=payload,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def image_sources(project: GalleryProject) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
84
|
+
"""Yield the file prefix and image payload of every image in the project."""
|
|
85
|
+
for position, module in enumerate(project.modules, start=1):
|
|
86
|
+
kind = module.get("__typename")
|
|
87
|
+
if kind == "ImageModule":
|
|
88
|
+
yield f"{position:03d}", module
|
|
89
|
+
elif kind == "MediaCollectionModule":
|
|
90
|
+
components = module.get("components") or []
|
|
91
|
+
for index, component in enumerate(components, start=1):
|
|
92
|
+
yield f"{position:03d}_{index:02d}", component
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def embed_assets(project: GalleryProject) -> list[EmbedAsset]:
|
|
96
|
+
"""Collect the modules that point at external media instead of hosting an image."""
|
|
97
|
+
assets: list[EmbedAsset] = []
|
|
98
|
+
for position, module in enumerate(project.modules, start=1):
|
|
99
|
+
if module.get("__typename") not in _EMBED_MODULE_TYPES:
|
|
100
|
+
continue
|
|
101
|
+
markup = str(
|
|
102
|
+
module.get("fluidEmbed") or module.get("originalEmbed") or module.get("embed") or ""
|
|
103
|
+
)
|
|
104
|
+
match = _IFRAME_SOURCE.search(markup)
|
|
105
|
+
assets.append(
|
|
106
|
+
EmbedAsset(
|
|
107
|
+
position=position,
|
|
108
|
+
module_id=int(module.get("id") or 0),
|
|
109
|
+
caption=str(module.get("caption") or ""),
|
|
110
|
+
source_url=html.unescape(match.group(1)) if match else "",
|
|
111
|
+
width=int(module.get("originalWidth") or module.get("width") or 0),
|
|
112
|
+
height=int(module.get("originalHeight") or module.get("height") or 0),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
return assets
|
behance_dl/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Values that describe a gallery, its assets, and what a run has already saved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .naming import safe_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GalleryProject:
|
|
15
|
+
"""A Behance project reduced to what downloading it needs."""
|
|
16
|
+
|
|
17
|
+
id: int
|
|
18
|
+
name: str
|
|
19
|
+
slug: str
|
|
20
|
+
modules: tuple[dict[str, Any], ...]
|
|
21
|
+
payload: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def folder_name(self) -> str:
|
|
25
|
+
slug = safe_name(self.slug, "")
|
|
26
|
+
return f"{self.id}_{slug}" if slug else str(self.id)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ImageAsset:
|
|
31
|
+
"""One downloadable image and the CDN variant chosen for it."""
|
|
32
|
+
|
|
33
|
+
file_prefix: str
|
|
34
|
+
url: str
|
|
35
|
+
width: int
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def file_name(self) -> str:
|
|
39
|
+
cdn_name = Path(urllib.parse.urlsplit(self.url).path).name
|
|
40
|
+
return f"{self.file_prefix}_{safe_name(cdn_name, 'image')}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class EmbedAsset:
|
|
45
|
+
"""A module that is saved as a link instead of a file: an embed, a video, or a prototype."""
|
|
46
|
+
|
|
47
|
+
position: int
|
|
48
|
+
module_id: int
|
|
49
|
+
caption: str
|
|
50
|
+
source_url: str
|
|
51
|
+
width: int
|
|
52
|
+
height: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class GalleryPlan:
|
|
57
|
+
"""What one gallery will produce before anything is written."""
|
|
58
|
+
|
|
59
|
+
project: GalleryProject
|
|
60
|
+
images: tuple[ImageAsset, ...]
|
|
61
|
+
embeds: tuple[EmbedAsset, ...]
|
|
62
|
+
destination: Path
|
|
63
|
+
expected_images: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class SavedImage:
|
|
68
|
+
"""An image recorded in a gallery folder, and the CDN URL it came from."""
|
|
69
|
+
|
|
70
|
+
url: str
|
|
71
|
+
path: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class StoredImage:
|
|
76
|
+
"""Where an image ended up and whether this run wrote it."""
|
|
77
|
+
|
|
78
|
+
path: Path
|
|
79
|
+
written: bool
|
behance_dl/naming.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Turning remote and user-supplied text into safe file names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
UNSAFE_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_name(value: str, fallback: str) -> str:
|
|
11
|
+
"""Reduce arbitrary text to one file name component that cannot escape a folder."""
|
|
12
|
+
return UNSAFE_CHARACTERS.sub("-", value).strip(".-") or fallback
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_plain_file_name(value: str) -> bool:
|
|
16
|
+
"""Report whether text is a single safe file name rather than a path."""
|
|
17
|
+
return bool(value) and value not in {".", ".."} and UNSAFE_CHARACTERS.search(value) is None
|
behance_dl/selection.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Choosing which CDN variant of each image to download."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import BehanceClient
|
|
10
|
+
from .errors import GalleryError
|
|
11
|
+
from .gallery import embed_assets, fetch_project, image_sources, is_behance_url, parse_gallery_id
|
|
12
|
+
from .models import GalleryPlan, ImageAsset
|
|
13
|
+
|
|
14
|
+
_SOURCE_BUCKET = "source"
|
|
15
|
+
_STILL_BUCKET_SUFFIX = "_still"
|
|
16
|
+
_OPTIMISED_BUCKET = "_opt_"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def bucket(url: str) -> str:
|
|
20
|
+
"""Return the CDN bucket a project image URL was built from."""
|
|
21
|
+
segments = urllib.parse.urlsplit(url).path.split("/")
|
|
22
|
+
if "project_modules" in segments:
|
|
23
|
+
index = segments.index("project_modules") + 1
|
|
24
|
+
if index < len(segments):
|
|
25
|
+
return segments[index]
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bucket_rank(bucket_name: str) -> int:
|
|
30
|
+
"""Rank equally wide variants, preferring the least processed copy."""
|
|
31
|
+
if bucket_name == _SOURCE_BUCKET:
|
|
32
|
+
return 3
|
|
33
|
+
if _OPTIMISED_BUCKET in bucket_name:
|
|
34
|
+
return 1
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def candidate_sizes(image_source: dict[str, Any]) -> list[tuple[int, int, str]]:
|
|
39
|
+
"""Collect (width, bucket rank, URL) for the variants worth downloading.
|
|
40
|
+
|
|
41
|
+
Stills are dropped when an animated variant exists, because a still bucket holds only
|
|
42
|
+
the first frame of an animated image.
|
|
43
|
+
"""
|
|
44
|
+
sizes = image_source.get("imageSizes") or {}
|
|
45
|
+
variants: list[tuple[int, str, str]] = []
|
|
46
|
+
for variant in sizes.get("allAvailable") or []:
|
|
47
|
+
url = str(variant.get("url") or "")
|
|
48
|
+
if not is_behance_url(url):
|
|
49
|
+
continue
|
|
50
|
+
if variant.get("type") == "WEBP" or "_webp" in url:
|
|
51
|
+
continue
|
|
52
|
+
variants.append((int(variant.get("width") or 0), bucket(url), url))
|
|
53
|
+
animated = [entry for entry in variants if not entry[1].endswith(_STILL_BUCKET_SUFFIX)]
|
|
54
|
+
chosen = animated or variants
|
|
55
|
+
if chosen:
|
|
56
|
+
return [(width, bucket_rank(name), url) for width, name, url in chosen]
|
|
57
|
+
preview = str((sizes.get("size_disp") or {}).get("url") or image_source.get("src") or "")
|
|
58
|
+
if not is_behance_url(preview):
|
|
59
|
+
return []
|
|
60
|
+
return [(int(image_source.get("width") or 0), 0, preview)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def select_image_url(image_source: dict[str, Any], max_width: int | None) -> tuple[int, str] | None:
|
|
64
|
+
"""Pick the widest variant at or below max_width, else the smallest available.
|
|
65
|
+
|
|
66
|
+
Without max_width the widest variant wins.
|
|
67
|
+
"""
|
|
68
|
+
ordered = sorted(candidate_sizes(image_source), reverse=True)
|
|
69
|
+
if not ordered:
|
|
70
|
+
return None
|
|
71
|
+
if max_width is None:
|
|
72
|
+
width, _rank, url = ordered[0]
|
|
73
|
+
return width, url
|
|
74
|
+
within_limit = [entry for entry in ordered if entry[0] <= max_width]
|
|
75
|
+
width, _rank, url = (
|
|
76
|
+
within_limit[0] if within_limit else min(ordered, key=lambda entry: (entry[0], -entry[1]))
|
|
77
|
+
)
|
|
78
|
+
return width, url
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def image_asset(
|
|
82
|
+
image_source: dict[str, Any], file_prefix: str, max_width: int | None
|
|
83
|
+
) -> ImageAsset | None:
|
|
84
|
+
"""Build the asset for one image, or return None when it offers no usable variant."""
|
|
85
|
+
selected = select_image_url(image_source, max_width)
|
|
86
|
+
if selected is None:
|
|
87
|
+
return None
|
|
88
|
+
width, url = selected
|
|
89
|
+
return ImageAsset(file_prefix=file_prefix, url=url, width=width)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def plan_gallery(
|
|
93
|
+
client: BehanceClient, gallery: str, max_width: int | None, output_root: Path
|
|
94
|
+
) -> GalleryPlan:
|
|
95
|
+
"""Fetch one gallery and resolve every asset it will produce."""
|
|
96
|
+
gallery_id = parse_gallery_id(gallery)
|
|
97
|
+
project = fetch_project(client, gallery_id)
|
|
98
|
+
try:
|
|
99
|
+
sources = list(image_sources(project))
|
|
100
|
+
images: list[ImageAsset] = []
|
|
101
|
+
for file_prefix, image_source in sources:
|
|
102
|
+
asset = image_asset(image_source, file_prefix, max_width)
|
|
103
|
+
if asset is not None:
|
|
104
|
+
images.append(asset)
|
|
105
|
+
embeds = embed_assets(project)
|
|
106
|
+
except (TypeError, ValueError, OverflowError, AttributeError) as error:
|
|
107
|
+
raise GalleryError(f"gallery {gallery_id}: unexpected project data") from error
|
|
108
|
+
return GalleryPlan(
|
|
109
|
+
project=project,
|
|
110
|
+
images=tuple(images),
|
|
111
|
+
embeds=tuple(embeds),
|
|
112
|
+
destination=output_root / project.folder_name,
|
|
113
|
+
expected_images=len(sources),
|
|
114
|
+
)
|
behance_dl/storage.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Writing gallery files and recording which variant produced each saved image."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .client import BehanceClient
|
|
14
|
+
from .models import GalleryPlan, ImageAsset, SavedImage, StoredImage
|
|
15
|
+
from .naming import is_plain_file_name
|
|
16
|
+
|
|
17
|
+
MANIFEST_NAME = "manifest.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_saved(destination: Path) -> dict[str, SavedImage]:
|
|
21
|
+
"""Read the record of files already written to a gallery folder."""
|
|
22
|
+
manifest = destination / MANIFEST_NAME
|
|
23
|
+
try:
|
|
24
|
+
entries = json.loads(manifest.read_text(encoding="utf-8"))
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return {}
|
|
27
|
+
except (OSError, ValueError) as error:
|
|
28
|
+
print(f"warning: ignoring unreadable {manifest}: {error}", file=sys.stderr)
|
|
29
|
+
return {}
|
|
30
|
+
if not isinstance(entries, dict):
|
|
31
|
+
print(f"warning: ignoring {manifest}: it does not hold an object", file=sys.stderr)
|
|
32
|
+
return {}
|
|
33
|
+
saved: dict[str, SavedImage] = {}
|
|
34
|
+
for name, entry in entries.items():
|
|
35
|
+
if not isinstance(entry, dict):
|
|
36
|
+
continue
|
|
37
|
+
url, path = entry.get("url"), entry.get("path")
|
|
38
|
+
if isinstance(url, str) and isinstance(path, str) and is_recorded_name(name, path):
|
|
39
|
+
saved[name] = SavedImage(url=url, path=path)
|
|
40
|
+
return saved
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_recorded_name(name: str, path: str) -> bool:
|
|
44
|
+
"""Report whether a recorded file is a plain file name sharing the planned stem."""
|
|
45
|
+
return (
|
|
46
|
+
is_plain_file_name(name) and is_plain_file_name(path) and Path(path).stem == Path(name).stem
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_gallery(
|
|
51
|
+
client: BehanceClient,
|
|
52
|
+
plan: GalleryPlan,
|
|
53
|
+
saved: dict[str, SavedImage],
|
|
54
|
+
*,
|
|
55
|
+
workers: int,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Write one gallery's metadata and images, returning how many images failed."""
|
|
58
|
+
destination = plan.destination
|
|
59
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
write_json(destination / "project.json", plan.project.payload)
|
|
61
|
+
if plan.embeds:
|
|
62
|
+
write_json(destination / "embeds.json", [asdict(embed) for embed in plan.embeds])
|
|
63
|
+
|
|
64
|
+
record = dict(saved)
|
|
65
|
+
|
|
66
|
+
def remember(name: str, entry: SavedImage) -> None:
|
|
67
|
+
previous = record.get(name)
|
|
68
|
+
if previous is not None and previous.path != entry.path:
|
|
69
|
+
remove_superseded(destination / previous.path)
|
|
70
|
+
record[name] = entry
|
|
71
|
+
try:
|
|
72
|
+
write_json(
|
|
73
|
+
destination / MANIFEST_NAME,
|
|
74
|
+
{key: asdict(value) for key, value in record.items()},
|
|
75
|
+
)
|
|
76
|
+
except OSError as error:
|
|
77
|
+
print(f"warning: could not record {name} in {MANIFEST_NAME}: {error}", file=sys.stderr)
|
|
78
|
+
|
|
79
|
+
failed = download_images(
|
|
80
|
+
client,
|
|
81
|
+
plan.images,
|
|
82
|
+
destination,
|
|
83
|
+
saved,
|
|
84
|
+
workers=workers,
|
|
85
|
+
on_saved=remember,
|
|
86
|
+
)
|
|
87
|
+
if failed:
|
|
88
|
+
print(f"{failed} of {len(plan.images)} images failed", file=sys.stderr)
|
|
89
|
+
else:
|
|
90
|
+
print(f"done: {len(plan.images)} images in {destination}")
|
|
91
|
+
return failed
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def download_images(
|
|
95
|
+
client: BehanceClient,
|
|
96
|
+
assets: Sequence[ImageAsset],
|
|
97
|
+
destination: Path,
|
|
98
|
+
saved: dict[str, SavedImage],
|
|
99
|
+
*,
|
|
100
|
+
workers: int,
|
|
101
|
+
on_saved: Callable[[str, SavedImage], None],
|
|
102
|
+
) -> int:
|
|
103
|
+
"""Download the assets that are missing, reporting each result and returning failures."""
|
|
104
|
+
total = len(assets)
|
|
105
|
+
failed = 0
|
|
106
|
+
reported: set[str] = set()
|
|
107
|
+
pool = ThreadPoolExecutor(max_workers=workers)
|
|
108
|
+
pending: dict[Future[StoredImage], ImageAsset] = {}
|
|
109
|
+
try:
|
|
110
|
+
pending = {
|
|
111
|
+
pool.submit(save_image, client, asset, destination, saved.get(asset.file_name)): asset
|
|
112
|
+
for asset in assets
|
|
113
|
+
}
|
|
114
|
+
for completed, future in enumerate(as_completed(pending), start=1):
|
|
115
|
+
asset = pending[future]
|
|
116
|
+
name = asset.file_name
|
|
117
|
+
try:
|
|
118
|
+
stored = future.result()
|
|
119
|
+
# A single failed image must not abort the remaining ones.
|
|
120
|
+
except Exception as error:
|
|
121
|
+
failed += 1
|
|
122
|
+
print(f"[{completed}/{total}] failed {name}: {error}", file=sys.stderr)
|
|
123
|
+
continue
|
|
124
|
+
on_saved(name, SavedImage(url=asset.url, path=stored.path.name))
|
|
125
|
+
reported.add(name)
|
|
126
|
+
status = "saved " if stored.written else "exists"
|
|
127
|
+
print(f"[{completed}/{total}] {status} {stored.path.name}")
|
|
128
|
+
except BaseException:
|
|
129
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
130
|
+
raise
|
|
131
|
+
finally:
|
|
132
|
+
pool.shutdown(wait=True)
|
|
133
|
+
report_finished(pending, reported, on_saved)
|
|
134
|
+
return failed
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def report_finished(
|
|
138
|
+
pending: dict[Future[StoredImage], ImageAsset],
|
|
139
|
+
reported: set[str],
|
|
140
|
+
on_saved: Callable[[str, SavedImage], None],
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Record downloads that finished without being reported, such as after an interrupt."""
|
|
143
|
+
for future, asset in pending.items():
|
|
144
|
+
name = asset.file_name
|
|
145
|
+
if name in reported or future.cancelled() or not future.done():
|
|
146
|
+
continue
|
|
147
|
+
if future.exception() is not None:
|
|
148
|
+
continue
|
|
149
|
+
stored = future.result()
|
|
150
|
+
on_saved(name, SavedImage(url=asset.url, path=stored.path.name))
|
|
151
|
+
reported.add(name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save_image(
|
|
155
|
+
client: BehanceClient,
|
|
156
|
+
asset: ImageAsset,
|
|
157
|
+
directory: Path,
|
|
158
|
+
recorded: SavedImage | None,
|
|
159
|
+
) -> StoredImage:
|
|
160
|
+
"""Reuse a recorded download or fetch the asset again."""
|
|
161
|
+
if recorded is not None and recorded.url == asset.url:
|
|
162
|
+
existing = directory / recorded.path
|
|
163
|
+
if existing.is_file() and existing.stat().st_size > 0:
|
|
164
|
+
return StoredImage(path=existing, written=False)
|
|
165
|
+
return StoredImage(path=client.download(asset.url, directory / asset.file_name), written=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def remove_superseded(path: Path) -> None:
|
|
169
|
+
"""Drop a file a new download replaced, warning instead of failing that download."""
|
|
170
|
+
try:
|
|
171
|
+
path.unlink(missing_ok=True)
|
|
172
|
+
except OSError as error:
|
|
173
|
+
print(f"warning: could not remove {path}: {error}", file=sys.stderr)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_json(path: Path, data: Any) -> None:
|
|
177
|
+
"""Write JSON so a reader never sees a half-written file."""
|
|
178
|
+
temporary = path.with_name(path.name + ".part")
|
|
179
|
+
try:
|
|
180
|
+
temporary.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
181
|
+
temporary.replace(path)
|
|
182
|
+
except BaseException:
|
|
183
|
+
temporary.unlink(missing_ok=True)
|
|
184
|
+
raise
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: behance-dl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download the images of public Behance project galleries.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kevinkwee/behance-dl
|
|
6
|
+
Project-URL: Repository, https://github.com/kevinkwee/behance-dl
|
|
7
|
+
Project-URL: Issues, https://github.com/kevinkwee/behance-dl/issues
|
|
8
|
+
Project-URL: Releases, https://github.com/kevinkwee/behance-dl/releases
|
|
9
|
+
Author-email: Michael Elian Kevin <19345886+kevinkwee@users.noreply.github.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: behance,cli,downloader,gallery,portfolio
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# behance-dl
|
|
35
|
+
|
|
36
|
+
Download the images of a public [Behance](https://www.behance.net) project gallery.
|
|
37
|
+
|
|
38
|
+
`behance-dl` reads the project data Behance inlines in the gallery page, picks the best CDN
|
|
39
|
+
variant of every image, and downloads them in parallel with resume support. It keeps the
|
|
40
|
+
gallery order, records which variant produced each file, and logs the content it cannot
|
|
41
|
+
download as files (prototypes, video players) so nothing is silently lost.
|
|
42
|
+
|
|
43
|
+
No API key, account, or browser automation is required.
|
|
44
|
+
|
|
45
|
+
> Not affiliated with Behance or Adobe. Use it for offline viewing of published work, and
|
|
46
|
+
> respect each creator's licence before redistributing anything.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
pip install behance-dl
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires Python 3.9 or newer. No third-party runtime dependencies.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
behance-dl "https://www.behance.net/gallery/123456789/Sample-Gallery"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Every gallery argument can be a full Behance URL, with or without query parameters, or a
|
|
63
|
+
bare project id:
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
behance-dl 123456789
|
|
67
|
+
behance-dl "https://www.behance.net/gallery/123456789/Sample-Gallery?l=22"
|
|
68
|
+
behance-dl 123456789 234567890 345678901 -o downloads
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Options
|
|
72
|
+
|
|
73
|
+
| Option | Meaning |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `-o`, `--output DIR` | Folder that receives one subfolder per gallery. Default: current folder. |
|
|
76
|
+
| `-W`, `--max-width PX` | Largest image at or below this width, else the smallest available. Default: the largest available. |
|
|
77
|
+
| `-j`, `--workers N` | Parallel downloads. Default: 4. |
|
|
78
|
+
| `--attempts N` | Attempts per request before giving up. Default: 3. |
|
|
79
|
+
| `--timeout SECONDS` | Per-request timeout. Default: 30. |
|
|
80
|
+
| `--force` | Ignore the saved record and fetch every image again. |
|
|
81
|
+
| `--dry-run` | List what would be downloaded without writing anything. |
|
|
82
|
+
| `--version` | Print the version and exit. |
|
|
83
|
+
|
|
84
|
+
### What a gallery folder contains
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
123456789_Sample-Gallery/
|
|
88
|
+
001_0a1b2c3123456789.5f6d7e8f9a0b1.png
|
|
89
|
+
002_4d5e6f7123456789.5f6d7e9a1c2d3.png
|
|
90
|
+
003_8a9b0c1123456789.5f6d7eab2e3f4.jpg
|
|
91
|
+
embeds.json # prototypes and video players that are links, not files
|
|
92
|
+
manifest.json # which CDN variant produced each saved file
|
|
93
|
+
project.json # the full project payload Behance served
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Files are prefixed with their position in the gallery, so a plain alphabetical listing
|
|
97
|
+
follows the project order. The extension is taken from the file's magic bytes, because
|
|
98
|
+
Behance's CDN sometimes serves JPEG data under a `.png` name.
|
|
99
|
+
|
|
100
|
+
`embeds.json` records modules that cannot be downloaded as images, such as Figma prototypes
|
|
101
|
+
and Adobe video players, with their source URL, caption and dimensions.
|
|
102
|
+
|
|
103
|
+
## Re-running, resume and width changes
|
|
104
|
+
|
|
105
|
+
`manifest.json` records the URL each file came from. A re-run only fetches what is missing,
|
|
106
|
+
and a run with a different `-W` re-fetches the affected images instead of leaving files at
|
|
107
|
+
the old size. An interrupted run keeps whatever already landed, so `Ctrl+C` followed by the
|
|
108
|
+
same command continues where it stopped.
|
|
109
|
+
|
|
110
|
+
Delete `manifest.json` (or pass `--force`) to fetch everything again.
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
1. `GET https://www.behance.net/gallery/{id}/gallery`. Behance answers 404 for an id with no
|
|
115
|
+
trailing slug and ignores the slug itself, so any slug works.
|
|
116
|
+
2. Behance answers 403 with a cookie challenge
|
|
117
|
+
(`document.cookie = "js_challenge_value=..."; location.reload()`). The client sets the
|
|
118
|
+
cookie and retries once. This is not a CAPTCHA and needs no credentials.
|
|
119
|
+
3. The project payload is read from `<script id="beconfig-store_state">`. It lists every
|
|
120
|
+
module and every CDN variant of each image with its pixel width.
|
|
121
|
+
4. Variants are filtered and ranked: WebP copies are skipped so the saved file matches the
|
|
122
|
+
original format, `_still` buckets are dropped when an animated variant exists (a still
|
|
123
|
+
bucket holds only the first frame of a GIF), and equal widths prefer `source` over a
|
|
124
|
+
plain bucket over an `_opt_` copy.
|
|
125
|
+
5. Images are downloaded in a thread pool, each to a `.part` file that is verified against
|
|
126
|
+
`Content-Length`, checked for an image content type and real magic bytes, then renamed
|
|
127
|
+
into place.
|
|
128
|
+
|
|
129
|
+
`MediaCollectionModule` carousels are expanded into one file per component. Only URLs on
|
|
130
|
+
`https://*.behance.net` are ever requested, so a tampered payload cannot point the tool at
|
|
131
|
+
a local file or a third-party host.
|
|
132
|
+
|
|
133
|
+
## Limitations
|
|
134
|
+
|
|
135
|
+
- Only public projects. Private, removed, or login-gated projects report a clear error.
|
|
136
|
+
- Images only. Prototypes and video modules are recorded as links in `embeds.json`.
|
|
137
|
+
- Behance can change its page format at any time; the payload shape is checked and a
|
|
138
|
+
mismatch is reported as an error instead of producing an empty folder.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```console
|
|
143
|
+
python -m venv .venv
|
|
144
|
+
.venv\Scripts\activate # Windows
|
|
145
|
+
pip install -e ".[dev]"
|
|
146
|
+
pytest
|
|
147
|
+
ruff check .
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
behance_dl/__init__.py,sha256=oobH2KSoxxZrjied3ta9ZcWmhB9aCMXo5VnWVGcs0bA,771
|
|
2
|
+
behance_dl/__main__.py,sha256=1-88gMWIaNhyN5zHQsOfgezG0kNgu_xc6GF7bfh-8j4,174
|
|
3
|
+
behance_dl/cli.py,sha256=RD_BEcgIq7VJS6iuJWS8427xqohnhoPs8qWhScFw3BA,5679
|
|
4
|
+
behance_dl/client.py,sha256=0m4KPoFNJL7kKN3aIOILgbnyAFSZDGJQpk457mvzYE4,7718
|
|
5
|
+
behance_dl/errors.py,sha256=qZlMBPXVTp4mQewLnORYRh1rfiH-YT8G8K6GabNDWWQ,457
|
|
6
|
+
behance_dl/gallery.py,sha256=MtsGFjzpWnDSxvX0-Xp8GBq8wgY14XV49COLWXKSw7w,4817
|
|
7
|
+
behance_dl/models.py,sha256=_8NJ2GgoBp3Kdw6uCuNoDEIDaFm4fM-U5F6kbUJfo8Y,1851
|
|
8
|
+
behance_dl/naming.py,sha256=xOCZuMllpvaEKw0LHK1jELd0wuwdRnAz0UXwsiVfS6E,610
|
|
9
|
+
behance_dl/selection.py,sha256=y6tITBdxdeCpwQLONb2xHTKTUr1R9tKt7gN8QpyGOLQ,4336
|
|
10
|
+
behance_dl/storage.py,sha256=cVGBVX4REyzBtDnoMqhEKd8Xf6KFLlfHepPnbRxiYJQ,6699
|
|
11
|
+
behance_dl-0.1.0.dist-info/METADATA,sha256=5P1aq-D8xqD7x0Fj3pHaYf_zBHTthx-Ad3v3PLYiWX0,6249
|
|
12
|
+
behance_dl-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
behance_dl-0.1.0.dist-info/entry_points.txt,sha256=QaV2VxcCV6VXqxDNZQmDWOHReS72kVFZnLR5MC2nWKQ,51
|
|
14
|
+
behance_dl-0.1.0.dist-info/licenses/LICENSE,sha256=1uqWHBlCFIMf2cb2LszZXaK4lOmLbJjDRpqZGOFhnvQ,1097
|
|
15
|
+
behance_dl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Elian Kevin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|