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.
- markdown_image_downloader-0.1.0/LICENSE +21 -0
- markdown_image_downloader-0.1.0/PKG-INFO +153 -0
- markdown_image_downloader-0.1.0/README.md +116 -0
- markdown_image_downloader-0.1.0/pyproject.toml +27 -0
- markdown_image_downloader-0.1.0/setup.cfg +4 -0
- markdown_image_downloader-0.1.0/src/index.py +365 -0
- markdown_image_downloader-0.1.0/src/markdown_image_downloader.egg-info/PKG-INFO +153 -0
- markdown_image_downloader-0.1.0/src/markdown_image_downloader.egg-info/SOURCES.txt +10 -0
- markdown_image_downloader-0.1.0/src/markdown_image_downloader.egg-info/dependency_links.txt +1 -0
- markdown_image_downloader-0.1.0/src/markdown_image_downloader.egg-info/entry_points.txt +2 -0
- markdown_image_downloader-0.1.0/src/markdown_image_downloader.egg-info/requires.txt +2 -0
- markdown_image_downloader-0.1.0/src/markdown_image_downloader.egg-info/top_level.txt +2 -0
|
@@ -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: ``
|
|
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: ``
|
|
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,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: ``
|
|
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 @@
|
|
|
1
|
+
|