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 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
@@ -0,0 +1,10 @@
1
+ """Entry point for python -m behance_dl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ behance-dl = behance_dl.cli:main
@@ -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.