markdown-image-downloader 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright 2024 mufidu
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.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.1
2
+ Name: markdown-image-downloader
3
+ Version: 0.1.0
4
+ Summary: A tool to download and organize images referenced in markdown files
5
+ Author-email: mufidu <mufid.to@gmail.com>
6
+ License: The MIT License (MIT)
7
+
8
+ Copyright 2024 mufidu
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/mufidu/markdown-image-downloader
28
+ Project-URL: Source, https://github.com/mufidu/markdown-image-downloader.git
29
+ Keywords: markdown,image,downloader,automation
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python
32
+ Requires-Python: >=3.9
33
+ Description-Content-Type: text/markdown
34
+ License-File: LICENSE
35
+ Requires-Dist: pillow>=11.0.0
36
+ Requires-Dist: tqdm>=4.66.5
37
+
38
+ # Markdown Image Downloader
39
+
40
+ A Python script that automatically downloads and manages images referenced in markdown files, storing them locally in an `_attachments` folder. This script is particularly useful for maintaining local copies of images in markdown documentation and ensuring consistent image availability.
41
+
42
+ Or just for Obsidian's Readwise export, which I made this for.
43
+
44
+ Previously hosted on [GitHub Gist](https://gist.github.com/mufidu/f7b795f844f1ee4dc78e55123d5a398b). Moved here to allow for easier maintenance and contributions, if any.
45
+
46
+ ## TODO
47
+
48
+ - [ ] Publish to PyPI
49
+
50
+ ## Features
51
+
52
+ - Uses custom HTTP headers to avoid download blocks
53
+ - Downloads images from URLs referenced in markdown files
54
+ - Creates local copies of images in an `_attachments` directory
55
+ - Automatically updates links in the markdown files with new local image paths
56
+ - Compresses large images to reduce storage space
57
+ - Supports multithreaded concurrent downloads
58
+ - Uses rate limit to prevent server overload and download blocks
59
+ - Progress bar for tracking download status
60
+ - Maintains detailed logging of error operations
61
+ - Sanitizes filenames for cross-platform compatibility
62
+ - Supports for rerunning the script without re-downloading images
63
+
64
+ ## Requirements
65
+
66
+ ```
67
+ Python
68
+ PIL (Pillow) for image processing
69
+ tqdm for progress bar
70
+ ```
71
+
72
+ ## Installation
73
+
74
+ 1. Clone this repository or download the script
75
+ ```bash
76
+ git clone https://github.com/mufidu/markdown-image-downloader
77
+ ```
78
+ 2. Install required packages:
79
+ ```bash
80
+ pip install Pillow tqdm
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ Run the script from the command line, providing the folder containing your markdown files as an argument:
86
+
87
+ ```bash
88
+ python src/index.py <folder_name>
89
+ ```
90
+
91
+ ### Example
92
+ ```bash
93
+ python src/index.py ../Readwise/Articles
94
+ ```
95
+
96
+ This will:
97
+ 1. Scan all markdown files in the `../Readwise/Articles` folder
98
+ 2. Download any images referenced in the markdown files
99
+ 3. Store them in `../Readwise/Articles/_attachments`
100
+ 4. Update the markdown files to reference the local copies
101
+
102
+ ## How It Works
103
+
104
+ 1. **Scanning**: The script scans all `.md` files in the specified folder for image references.
105
+ 2. **Downloading**: For each image URL found:
106
+ - Downloads the image if it's not already in `_attachments`
107
+ - Compresses images larger than 500KB while maintaining quality
108
+ - Generates unique filenames based on content hash
109
+ 3. **Organization**: Creates an `_attachments` folder to store all images
110
+ 4. **Updating**: Updates markdown files to reference the local copies in `_attachments`
111
+
112
+ ## Features in Detail
113
+
114
+ ### Image Compression
115
+ - Automatically compresses large images
116
+ - Maintains reasonable quality through progressive compression
117
+ - Converts RGBA images to RGB with white background
118
+
119
+ ### Filename Handling
120
+ - Preserves original filenames
121
+ - Sanitizes filenames for cross-platform compatibility
122
+
123
+ ### Concurrent Processing
124
+ - Uses ThreadPoolExecutor for parallel downloads
125
+ - Includes progress bar for tracking downloads
126
+ - Implements rate limiting to prevent server overload
127
+
128
+ ### Error Handling
129
+ - Comprehensive logging of all operations
130
+ - Graceful handling of download failures
131
+ - Skips already processed images
132
+
133
+ ## Logging
134
+
135
+ The script creates detailed logs in a `logs` directory:
136
+ - Location: `./logs/image_downloader.log`
137
+ - Includes timestamps, operation details, and error messages
138
+ - New log file created for each run
139
+
140
+ ## Limitations
141
+
142
+ - Only processes image links in markdown format: `![alt](url)`
143
+ - Requires internet connection for downloading external images
144
+ - May be rate-limited or just straight denied by some servers
145
+ - SVG files are downloaded but not compressed
146
+
147
+ ## Contributing
148
+
149
+ Feel free to submit issues, fork the repository, and create pull requests for any improvements.
150
+
151
+ ## License
152
+
153
+ This project is available under the MIT License.
@@ -0,0 +1,116 @@
1
+ # Markdown Image Downloader
2
+
3
+ A Python script that automatically downloads and manages images referenced in markdown files, storing them locally in an `_attachments` folder. This script is particularly useful for maintaining local copies of images in markdown documentation and ensuring consistent image availability.
4
+
5
+ Or just for Obsidian's Readwise export, which I made this for.
6
+
7
+ Previously hosted on [GitHub Gist](https://gist.github.com/mufidu/f7b795f844f1ee4dc78e55123d5a398b). Moved here to allow for easier maintenance and contributions, if any.
8
+
9
+ ## TODO
10
+
11
+ - [ ] Publish to PyPI
12
+
13
+ ## Features
14
+
15
+ - Uses custom HTTP headers to avoid download blocks
16
+ - Downloads images from URLs referenced in markdown files
17
+ - Creates local copies of images in an `_attachments` directory
18
+ - Automatically updates links in the markdown files with new local image paths
19
+ - Compresses large images to reduce storage space
20
+ - Supports multithreaded concurrent downloads
21
+ - Uses rate limit to prevent server overload and download blocks
22
+ - Progress bar for tracking download status
23
+ - Maintains detailed logging of error operations
24
+ - Sanitizes filenames for cross-platform compatibility
25
+ - Supports for rerunning the script without re-downloading images
26
+
27
+ ## Requirements
28
+
29
+ ```
30
+ Python
31
+ PIL (Pillow) for image processing
32
+ tqdm for progress bar
33
+ ```
34
+
35
+ ## Installation
36
+
37
+ 1. Clone this repository or download the script
38
+ ```bash
39
+ git clone https://github.com/mufidu/markdown-image-downloader
40
+ ```
41
+ 2. Install required packages:
42
+ ```bash
43
+ pip install Pillow tqdm
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ Run the script from the command line, providing the folder containing your markdown files as an argument:
49
+
50
+ ```bash
51
+ python src/index.py <folder_name>
52
+ ```
53
+
54
+ ### Example
55
+ ```bash
56
+ python src/index.py ../Readwise/Articles
57
+ ```
58
+
59
+ This will:
60
+ 1. Scan all markdown files in the `../Readwise/Articles` folder
61
+ 2. Download any images referenced in the markdown files
62
+ 3. Store them in `../Readwise/Articles/_attachments`
63
+ 4. Update the markdown files to reference the local copies
64
+
65
+ ## How It Works
66
+
67
+ 1. **Scanning**: The script scans all `.md` files in the specified folder for image references.
68
+ 2. **Downloading**: For each image URL found:
69
+ - Downloads the image if it's not already in `_attachments`
70
+ - Compresses images larger than 500KB while maintaining quality
71
+ - Generates unique filenames based on content hash
72
+ 3. **Organization**: Creates an `_attachments` folder to store all images
73
+ 4. **Updating**: Updates markdown files to reference the local copies in `_attachments`
74
+
75
+ ## Features in Detail
76
+
77
+ ### Image Compression
78
+ - Automatically compresses large images
79
+ - Maintains reasonable quality through progressive compression
80
+ - Converts RGBA images to RGB with white background
81
+
82
+ ### Filename Handling
83
+ - Preserves original filenames
84
+ - Sanitizes filenames for cross-platform compatibility
85
+
86
+ ### Concurrent Processing
87
+ - Uses ThreadPoolExecutor for parallel downloads
88
+ - Includes progress bar for tracking downloads
89
+ - Implements rate limiting to prevent server overload
90
+
91
+ ### Error Handling
92
+ - Comprehensive logging of all operations
93
+ - Graceful handling of download failures
94
+ - Skips already processed images
95
+
96
+ ## Logging
97
+
98
+ The script creates detailed logs in a `logs` directory:
99
+ - Location: `./logs/image_downloader.log`
100
+ - Includes timestamps, operation details, and error messages
101
+ - New log file created for each run
102
+
103
+ ## Limitations
104
+
105
+ - Only processes image links in markdown format: `![alt](url)`
106
+ - Requires internet connection for downloading external images
107
+ - May be rate-limited or just straight denied by some servers
108
+ - SVG files are downloaded but not compressed
109
+
110
+ ## Contributing
111
+
112
+ Feel free to submit issues, fork the repository, and create pull requests for any improvements.
113
+
114
+ ## License
115
+
116
+ This project is available under the MIT License.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "markdown-image-downloader"
3
+ version = "0.1.0"
4
+ description = "A tool to download and organize images referenced in markdown files"
5
+ authors = [{ name = "mufidu", email = "mufid.to@gmail.com" }]
6
+ dependencies = ["pillow>=11.0.0", "tqdm>=4.66.5"]
7
+ readme = "README.md"
8
+ requires-python = ">= 3.9"
9
+ license = { file = "LICENSE" }
10
+ classifiers = [
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python",
13
+ ]
14
+ keywords = ["markdown", "image", "downloader", "automation"]
15
+ urls.Homepage = "https://github.com/mufidu/markdown-image-downloader"
16
+ urls.Source = "https://github.com/mufidu/markdown-image-downloader.git"
17
+ scripts = { "markdown-image-downloader" = "src.index:main" }
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=42", "wheel"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.hatch.metadata]
24
+ allow-direct-references = true
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/index"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,365 @@
1
+ import io
2
+ import logging
3
+ import mimetypes
4
+ import os
5
+ import re
6
+ import sys
7
+ import time
8
+ import urllib.parse
9
+ import urllib.request
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from pathlib import Path
12
+ from typing import Dict, List, Tuple
13
+ from urllib.error import HTTPError, URLError
14
+
15
+ from PIL import Image
16
+ from tqdm import tqdm
17
+
18
+
19
+ class ImageDownloader:
20
+ def __init__(self, folder_name: str):
21
+ self.folder_name = Path(folder_name)
22
+ self.attachment_dir_name = "_attachments"
23
+ self.attachments_dir = self.folder_name / self.attachment_dir_name
24
+ self.headers = {
25
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
26
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
27
+ "Accept-Language": "en-US,en;q=0.5",
28
+ "Connection": "keep-alive",
29
+ }
30
+ self.timeout = 30
31
+ self.setup_logging()
32
+
33
+ def setup_logging(self) -> None:
34
+ """Configure logging settings."""
35
+ script_dir = Path(__file__).parent
36
+ log_dir = script_dir / "logs"
37
+ log_dir.mkdir(exist_ok=True)
38
+
39
+ log_file = log_dir / "image_downloader.log"
40
+
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s - %(levelname)s - %(message)s",
44
+ handlers=[
45
+ logging.FileHandler(log_file, mode="w"), # Overwrite log file each run
46
+ logging.StreamHandler(),
47
+ ],
48
+ )
49
+ logging.info(f"Logging to: {log_file}")
50
+
51
+ def setup_directories(self) -> None:
52
+ """Create necessary directories if they don't exist."""
53
+ self.attachments_dir.mkdir(parents=True, exist_ok=True)
54
+
55
+ def get_markdown_files(self) -> List[Path]:
56
+ """Get all markdown files in the specified folder."""
57
+ return list(self.folder_name.glob("*.md"))
58
+
59
+ def is_attachment_path(self, url: str) -> bool:
60
+ """Check if the URL is already pointing to the _attachments folder."""
61
+ # Handle URL-encoded paths
62
+ decoded_url = urllib.parse.unquote(url)
63
+
64
+ # Normalize path separators
65
+ normalized_url = decoded_url.replace("\\", "/").lower()
66
+
67
+ # List of patterns that indicate an attachment
68
+ attachment_patterns = [
69
+ f"/{self.attachment_dir_name}/", # Unix-style path
70
+ f"\\{self.attachment_dir_name}\\", # Windows-style path
71
+ f"{self.attachment_dir_name}/", # Relative path
72
+ f"./{self.attachment_dir_name}/", # Explicit relative path
73
+ ]
74
+
75
+ # Check if it's a URL or a local path
76
+ parsed_url = urllib.parse.urlparse(normalized_url)
77
+
78
+ # If it's a local path (no scheme or network location)
79
+ if not parsed_url.scheme and not parsed_url.netloc:
80
+ # Check if it matches any attachment patterns
81
+ return any(
82
+ pattern.lower() in normalized_url for pattern in attachment_patterns
83
+ )
84
+
85
+ return False
86
+
87
+ def get_file_extension(self, url: str, content_type: str = None) -> str:
88
+ """Determine file extension based on URL and content type."""
89
+ # Parse the URL and get the path
90
+ parsed_url = urllib.parse.urlparse(url)
91
+
92
+ # Split the path to separate actual path from query parameters
93
+ path = parsed_url.path
94
+
95
+ # Find the last occurrence of a period in the path (before query parameters)
96
+ ext = ""
97
+ path_parts = path.split("/")[-1].split(".")
98
+ if len(path_parts) > 1:
99
+ ext = f".{path_parts[-1].lower()}"
100
+
101
+ # Check if it's a valid image extension
102
+ valid_image_extensions = {
103
+ ".jpg",
104
+ ".jpeg",
105
+ ".png",
106
+ ".gif",
107
+ ".bmp",
108
+ ".webp",
109
+ ".tiff",
110
+ ".svg",
111
+ }
112
+ if ext not in valid_image_extensions:
113
+ ext = ""
114
+
115
+ # If no valid extension found in URL, try content type
116
+ if not ext and content_type:
117
+ ext = mimetypes.guess_extension(content_type)
118
+ if ext:
119
+ return ext
120
+
121
+ # If still no extension, default to .jpg
122
+ return ext or ".jpg"
123
+
124
+ def compress_image(self, image_data: bytes, max_size_kb: int = 500) -> bytes:
125
+ """Compress image while maintaining reasonable quality."""
126
+ try:
127
+ # Open image from bytes
128
+ img = Image.open(io.BytesIO(image_data))
129
+
130
+ # Convert RGBA to RGB if necessary
131
+ if img.mode == "RGBA":
132
+ bg = Image.new("RGB", img.size, (255, 255, 255))
133
+ bg.paste(img, mask=img.split()[3])
134
+ img = bg
135
+
136
+ # Initial quality
137
+ quality = 75
138
+ output = io.BytesIO()
139
+
140
+ # Save with progressive option for JPEGs
141
+ img.save(
142
+ output, format="JPEG", quality=quality, optimize=True, progressive=True
143
+ )
144
+
145
+ # Reduce quality until file size is under max_size_kb
146
+ while output.tell() > max_size_kb * 1024 and quality > 20:
147
+ output = io.BytesIO()
148
+ quality -= 5
149
+ img.save(
150
+ output,
151
+ format="JPEG",
152
+ quality=quality,
153
+ optimize=True,
154
+ progressive=True,
155
+ )
156
+
157
+ return output.getvalue()
158
+
159
+ except Exception as e:
160
+ logging.error(f"Error compressing image: {str(e)}")
161
+ return image_data
162
+
163
+ def sanitize_filename(self, filename: str) -> str:
164
+ """Sanitize filename to remove illegal characters."""
165
+ # Replace illegal characters with underscore
166
+ illegal_chars = r'[<>:"/\\|?*%]'
167
+ sanitized = re.sub(illegal_chars, "_", filename)
168
+ # Remove any consecutive underscores
169
+ sanitized = re.sub(r"_+", "_", sanitized)
170
+ # Remove leading/trailing underscores
171
+ sanitized = sanitized.strip("_")
172
+ return sanitized if sanitized else "image"
173
+
174
+ def get_markdown_filename(self, img_url: str) -> str:
175
+ """Extract and sanitize filename from URL or generate one if not present."""
176
+ # Decode the URL first
177
+ decoded_url = urllib.parse.unquote(img_url)
178
+
179
+ # Parse the URL and get the path
180
+ parsed_url = urllib.parse.urlparse(decoded_url)
181
+ path = parsed_url.path
182
+
183
+ # Extract filename from path, ignoring query parameters
184
+ filename = os.path.basename(path.split("?")[0])
185
+
186
+ if not filename:
187
+ # If no filename in URL, use the domain name or 'image'
188
+ filename = parsed_url.netloc.split(".")[0] or "image"
189
+
190
+ # Sanitize the filename
191
+ base_name, ext = os.path.splitext(filename)
192
+ if not ext:
193
+ ext = ".jpg" # Default extension if none is present
194
+
195
+ sanitized_base = self.sanitize_filename(base_name)
196
+ sanitized_ext = self.sanitize_filename(ext)
197
+
198
+ return f"{sanitized_base}{sanitized_ext}"
199
+
200
+ def download_image(self, img_url: str, file_name: str) -> Tuple[str, str, bool]:
201
+ """Download a single image and return the new local path."""
202
+ try:
203
+ # Skip if already in _attachments folder
204
+ if self.is_attachment_path(img_url):
205
+ logging.info(
206
+ f"Skipping {img_url} as it's already in _attachments folder"
207
+ )
208
+ return img_url, img_url, True
209
+
210
+ # Check if the URL is relative to the markdown file location
211
+ if not urllib.parse.urlparse(img_url).scheme:
212
+ # Convert relative path to absolute path
213
+ absolute_path = (Path(self.folder_name) / img_url).resolve()
214
+ if absolute_path.exists():
215
+ # If it's a local file, just copy it to attachments
216
+ with open(absolute_path, "rb") as f:
217
+ content = f.read()
218
+ else:
219
+ raise FileNotFoundError(f"Local file not found: {absolute_path}")
220
+ else:
221
+ # Download from URL
222
+ req = urllib.request.Request(img_url, headers=self.headers)
223
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
224
+ content = response.read()
225
+
226
+ # Compress the image
227
+ compressed_content = self.compress_image(content)
228
+
229
+ # Use the same filename as in the markdown link, but sanitized
230
+ filename = self.get_markdown_filename(img_url)
231
+
232
+ local_path = self.attachments_dir / filename
233
+
234
+ with open(local_path, "wb") as f:
235
+ f.write(compressed_content)
236
+
237
+ # Convert space to %20 in the filename
238
+ # This is necessary for the image to display in the markdown preview
239
+ filename = filename.replace(" ", "%20")
240
+
241
+ return img_url, f"_attachments/{filename}", True
242
+
243
+ except (HTTPError, URLError) as e:
244
+ logging.error(f"Failed to download {img_url} from {file_name}: {str(e)}")
245
+ return img_url, "", False
246
+ except Exception as e:
247
+ logging.error(
248
+ f"Unexpected error downloading {img_url} from {file_name}: {str(e)}"
249
+ )
250
+ return img_url, "", False
251
+
252
+ def get_all_images(self) -> Dict[Path, List[Tuple[str, str]]]:
253
+ """Get all images from all markdown files."""
254
+ files_images = {}
255
+ for file_path in self.get_markdown_files():
256
+ try:
257
+ with open(file_path, "r", encoding="utf-8") as f:
258
+ content = f.read()
259
+ # Find all image references, including those with escaped brackets and parentheses
260
+ images = re.findall(
261
+ r"!\[((?:[^\]\\]|\\.)*)\]\(((?:[^)\\]|\\.)*)\)", content
262
+ )
263
+ if images:
264
+ # Filter out images that are already in _attachments
265
+ images = [
266
+ (alt, url)
267
+ for alt, url in images
268
+ if not self.is_attachment_path(url)
269
+ ]
270
+ if (
271
+ images
272
+ ): # Only add to files_images if there are images to process
273
+ files_images[file_path] = images
274
+ except Exception as e:
275
+ logging.error(f"Error reading file {file_path}: {str(e)}")
276
+ return files_images
277
+
278
+ def process_files(self, files_images: Dict[Path, List[Tuple[str, str]]]) -> None:
279
+ """Process all files and their images with a single progress bar."""
280
+ total_images = sum(len(images) for images in files_images.values())
281
+ if total_images == 0:
282
+ logging.info("No new images to process")
283
+ return
284
+
285
+ logging.info(
286
+ f"Found {total_images} images to process across {len(files_images)} files"
287
+ )
288
+
289
+ with ThreadPoolExecutor(max_workers=5) as executor:
290
+ # Create futures for all images
291
+ futures = []
292
+ file_image_map = {} # Map to track which file each future belongs to
293
+
294
+ for file_path, images in files_images.items():
295
+ with open(file_path, "r", encoding="utf-8") as f:
296
+ content = f.read()
297
+
298
+ for img in images:
299
+ future = executor.submit(
300
+ self.download_image, img[1], file_path.name
301
+ )
302
+ futures.append(future)
303
+ file_image_map[future] = (file_path, content)
304
+
305
+ # Process all downloads with a single progress bar
306
+ replacements = {} # file_path -> list of (old_url, new_path) tuples
307
+ with tqdm(
308
+ total=total_images, desc="Downloading and compressing images"
309
+ ) as pbar:
310
+ for future in as_completed(futures):
311
+ file_path, content = file_image_map[future]
312
+ old_url, new_path, success = future.result()
313
+
314
+ if success and old_url != new_path:
315
+ if file_path not in replacements:
316
+ replacements[file_path] = []
317
+ replacements[file_path].append((old_url, new_path))
318
+
319
+ pbar.update(1)
320
+ time.sleep(1) # Small delay between downloads
321
+
322
+ # Update all files with their replacements
323
+ for file_path, replacement_list in replacements.items():
324
+ try:
325
+ with open(file_path, "r", encoding="utf-8") as f:
326
+ content = f.read()
327
+
328
+ for old_url, new_path in replacement_list:
329
+ content = content.replace(old_url, new_path)
330
+
331
+ with open(file_path, "w", encoding="utf-8") as f:
332
+ f.write(content)
333
+
334
+ except Exception as e:
335
+ logging.error(f"Error updating file {file_path}: {str(e)}")
336
+
337
+ def run(self) -> None:
338
+ """Main execution method."""
339
+ try:
340
+ self.setup_directories()
341
+ files_images = self.get_all_images()
342
+
343
+ if not files_images:
344
+ logging.info("No new images to download")
345
+ return
346
+
347
+ self.process_files(files_images)
348
+
349
+ except Exception as e:
350
+ logging.error(f"Fatal error: {str(e)}")
351
+
352
+
353
+ def main():
354
+ if len(sys.argv) != 2:
355
+ print("Usage: python download_imgs.py <folder_name>")
356
+ sys.exit(1)
357
+
358
+ FOLDER_NAME = sys.argv[1]
359
+
360
+ downloader = ImageDownloader(FOLDER_NAME)
361
+ downloader.run()
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.1
2
+ Name: markdown-image-downloader
3
+ Version: 0.1.0
4
+ Summary: A tool to download and organize images referenced in markdown files
5
+ Author-email: mufidu <mufid.to@gmail.com>
6
+ License: The MIT License (MIT)
7
+
8
+ Copyright 2024 mufidu
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/mufidu/markdown-image-downloader
28
+ Project-URL: Source, https://github.com/mufidu/markdown-image-downloader.git
29
+ Keywords: markdown,image,downloader,automation
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python
32
+ Requires-Python: >=3.9
33
+ Description-Content-Type: text/markdown
34
+ License-File: LICENSE
35
+ Requires-Dist: pillow>=11.0.0
36
+ Requires-Dist: tqdm>=4.66.5
37
+
38
+ # Markdown Image Downloader
39
+
40
+ A Python script that automatically downloads and manages images referenced in markdown files, storing them locally in an `_attachments` folder. This script is particularly useful for maintaining local copies of images in markdown documentation and ensuring consistent image availability.
41
+
42
+ Or just for Obsidian's Readwise export, which I made this for.
43
+
44
+ Previously hosted on [GitHub Gist](https://gist.github.com/mufidu/f7b795f844f1ee4dc78e55123d5a398b). Moved here to allow for easier maintenance and contributions, if any.
45
+
46
+ ## TODO
47
+
48
+ - [ ] Publish to PyPI
49
+
50
+ ## Features
51
+
52
+ - Uses custom HTTP headers to avoid download blocks
53
+ - Downloads images from URLs referenced in markdown files
54
+ - Creates local copies of images in an `_attachments` directory
55
+ - Automatically updates links in the markdown files with new local image paths
56
+ - Compresses large images to reduce storage space
57
+ - Supports multithreaded concurrent downloads
58
+ - Uses rate limit to prevent server overload and download blocks
59
+ - Progress bar for tracking download status
60
+ - Maintains detailed logging of error operations
61
+ - Sanitizes filenames for cross-platform compatibility
62
+ - Supports for rerunning the script without re-downloading images
63
+
64
+ ## Requirements
65
+
66
+ ```
67
+ Python
68
+ PIL (Pillow) for image processing
69
+ tqdm for progress bar
70
+ ```
71
+
72
+ ## Installation
73
+
74
+ 1. Clone this repository or download the script
75
+ ```bash
76
+ git clone https://github.com/mufidu/markdown-image-downloader
77
+ ```
78
+ 2. Install required packages:
79
+ ```bash
80
+ pip install Pillow tqdm
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ Run the script from the command line, providing the folder containing your markdown files as an argument:
86
+
87
+ ```bash
88
+ python src/index.py <folder_name>
89
+ ```
90
+
91
+ ### Example
92
+ ```bash
93
+ python src/index.py ../Readwise/Articles
94
+ ```
95
+
96
+ This will:
97
+ 1. Scan all markdown files in the `../Readwise/Articles` folder
98
+ 2. Download any images referenced in the markdown files
99
+ 3. Store them in `../Readwise/Articles/_attachments`
100
+ 4. Update the markdown files to reference the local copies
101
+
102
+ ## How It Works
103
+
104
+ 1. **Scanning**: The script scans all `.md` files in the specified folder for image references.
105
+ 2. **Downloading**: For each image URL found:
106
+ - Downloads the image if it's not already in `_attachments`
107
+ - Compresses images larger than 500KB while maintaining quality
108
+ - Generates unique filenames based on content hash
109
+ 3. **Organization**: Creates an `_attachments` folder to store all images
110
+ 4. **Updating**: Updates markdown files to reference the local copies in `_attachments`
111
+
112
+ ## Features in Detail
113
+
114
+ ### Image Compression
115
+ - Automatically compresses large images
116
+ - Maintains reasonable quality through progressive compression
117
+ - Converts RGBA images to RGB with white background
118
+
119
+ ### Filename Handling
120
+ - Preserves original filenames
121
+ - Sanitizes filenames for cross-platform compatibility
122
+
123
+ ### Concurrent Processing
124
+ - Uses ThreadPoolExecutor for parallel downloads
125
+ - Includes progress bar for tracking downloads
126
+ - Implements rate limiting to prevent server overload
127
+
128
+ ### Error Handling
129
+ - Comprehensive logging of all operations
130
+ - Graceful handling of download failures
131
+ - Skips already processed images
132
+
133
+ ## Logging
134
+
135
+ The script creates detailed logs in a `logs` directory:
136
+ - Location: `./logs/image_downloader.log`
137
+ - Includes timestamps, operation details, and error messages
138
+ - New log file created for each run
139
+
140
+ ## Limitations
141
+
142
+ - Only processes image links in markdown format: `![alt](url)`
143
+ - Requires internet connection for downloading external images
144
+ - May be rate-limited or just straight denied by some servers
145
+ - SVG files are downloaded but not compressed
146
+
147
+ ## Contributing
148
+
149
+ Feel free to submit issues, fork the repository, and create pull requests for any improvements.
150
+
151
+ ## License
152
+
153
+ This project is available under the MIT License.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/index.py
5
+ src/markdown_image_downloader.egg-info/PKG-INFO
6
+ src/markdown_image_downloader.egg-info/SOURCES.txt
7
+ src/markdown_image_downloader.egg-info/dependency_links.txt
8
+ src/markdown_image_downloader.egg-info/entry_points.txt
9
+ src/markdown_image_downloader.egg-info/requires.txt
10
+ src/markdown_image_downloader.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ markdown-image-downloader = src.index:main
@@ -0,0 +1,2 @@
1
+ pillow>=11.0.0
2
+ tqdm>=4.66.5