image-migrate-picgo 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.
- image_migrate_picgo-0.1.0/LICENSE +21 -0
- image_migrate_picgo-0.1.0/PKG-INFO +168 -0
- image_migrate_picgo-0.1.0/README.md +148 -0
- image_migrate_picgo-0.1.0/pyproject.toml +37 -0
- image_migrate_picgo-0.1.0/pyproject.toml.orig +31 -0
- image_migrate_picgo-0.1.0/src/image_migrate_picgo/__init__.py +20 -0
- image_migrate_picgo-0.1.0/src/image_migrate_picgo/cli.py +86 -0
- image_migrate_picgo-0.1.0/src/image_migrate_picgo/core.py +279 -0
- image_migrate_picgo-0.1.0/src/image_migrate_picgo/uploaders.py +117 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 张文超
|
|
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,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: image-migrate-picgo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Migrate images in one Markdown file through PicGo
|
|
5
|
+
Keywords: markdown,picgo,image,migration
|
|
6
|
+
Author: left0ver
|
|
7
|
+
Author-email: left0ver <1614674987@qq.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: markdown-it-py>=4.0,<5
|
|
17
|
+
Requires-Dist: typer>=0.21,<1
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="assets/logo.svg" alt="image-migrate-picgo logo" width="128">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<h1 align="center">image-migrate-picgo</h1>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square" alt="Python 3.11+">
|
|
29
|
+
<img src="https://img.shields.io/badge/PicGo-Server%20%7C%20CLI-blue?style=flat-square" alt="PicGo Server and CLI">
|
|
30
|
+
<img src="https://img.shields.io/badge/Images-Markdown%20%7C%20HTML-informational?style=flat-square" alt="Markdown and HTML images">
|
|
31
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="MIT License"></a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
English | <a href="README.zh-CN.md">简体中文</a>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
Move every image in a Markdown file to your image host with one command. `image-migrate-picgo` uploads the images through [PicGo](https://github.com/Molunerfinn/PicGo) and rewrites their links to the uploaded URLs.
|
|
39
|
+
|
|
40
|
+
It is useful when you want to:
|
|
41
|
+
|
|
42
|
+
- publish notes that reference local images,
|
|
43
|
+
- move images from one image host to another,
|
|
44
|
+
- replace inline Base64 (Data URL) images with regular links.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **Every image source**: local files, HTTP/HTTPS URLs, and image Data URLs.
|
|
49
|
+
- **Every image syntax**: inline Markdown images, reference-style images, and HTML `<img>` tags.
|
|
50
|
+
- **Two upload methods**: PicGo Server (the PicGo desktop app) or PicGo CLI (`picgo` from npm).
|
|
51
|
+
- **Safe rewriting**: code blocks, inline code, and ordinary links stay untouched. The file is written only after every upload succeeds.
|
|
52
|
+
- **No duplicate uploads**: an image referenced several times is uploaded once.
|
|
53
|
+
|
|
54
|
+
## Example
|
|
55
|
+
|
|
56
|
+
Before:
|
|
57
|
+
|
|
58
|
+
```markdown
|
|
59
|
+

|
|
60
|
+
![Logo][logo]
|
|
61
|
+
<img src="https://old-host.example.com/screenshot.png" width="600">
|
|
62
|
+
|
|
63
|
+
[logo]: ./images/logo.png
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
After running `image-migrate-picgo notes.md`:
|
|
67
|
+
|
|
68
|
+
```markdown
|
|
69
|
+

|
|
70
|
+

|
|
71
|
+
<img src="https://cdn.example.com/screenshot.png" width="600">
|
|
72
|
+
|
|
73
|
+
[logo]: ./images/logo.png
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
Requires Python 3.11 or newer.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install image-migrate-picgo
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You also need an image host configured in either PicGo Server or PicGo CLI (see [Upload methods](#upload-methods)).
|
|
85
|
+
|
|
86
|
+
## Quick start
|
|
87
|
+
|
|
88
|
+
1. Open the PicGo desktop app, configure an image host, and turn on **PicGo Server** in its settings.
|
|
89
|
+
2. Migrate a file:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
image-migrate-picgo notes.md
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> [!WARNING]
|
|
96
|
+
> By default the Markdown file is **updated in place**. Commit or back it up first, or write the result to a new file with `--output`:
|
|
97
|
+
>
|
|
98
|
+
> ```bash
|
|
99
|
+
> image-migrate-picgo notes.md --output notes.migrated.md
|
|
100
|
+
> ```
|
|
101
|
+
|
|
102
|
+
## Upload methods
|
|
103
|
+
|
|
104
|
+
### PicGo Server (default)
|
|
105
|
+
|
|
106
|
+
Uses the HTTP server built into the PicGo desktop app, at `http://127.0.0.1:36677/upload` by default.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Custom address
|
|
110
|
+
image-migrate-picgo notes.md --server-url http://127.0.0.1:36677/upload
|
|
111
|
+
|
|
112
|
+
# Server protected by a secret
|
|
113
|
+
image-migrate-picgo notes.md --server-secret <secret>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### PicGo CLI
|
|
117
|
+
|
|
118
|
+
Uses the [PicGo core](https://github.com/PicGo/PicGo-Core) command line tool. No desktop app is needed, which suits servers and CI.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm install picgo -g
|
|
122
|
+
|
|
123
|
+
# Configure an uploader (an interactive wizard)
|
|
124
|
+
image-migrate-picgo --configure
|
|
125
|
+
|
|
126
|
+
# Upload through PicGo CLI
|
|
127
|
+
image-migrate-picgo notes.md --method cli
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The configuration is saved to `~/.picgo/config.json` by default. If the configuration file does not exist, `--method cli` starts the wizard automatically.
|
|
131
|
+
|
|
132
|
+
## CLI options
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
image-migrate-picgo [OPTIONS] [MARKDOWN]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Option | Description | Default |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `MARKDOWN` | Markdown file to migrate | — |
|
|
141
|
+
| `-o`, `--output PATH` | Write the result to this file instead of updating `MARKDOWN` | Update in place |
|
|
142
|
+
| `--method [server\|cli]` | Upload method | `server` |
|
|
143
|
+
| `--server-url TEXT` | PicGo Server upload URL | `http://127.0.0.1:36677/upload` |
|
|
144
|
+
| `--server-secret TEXT` | PicGo Server secret, sent as a Bearer token | — |
|
|
145
|
+
| `--picgo-command PATH` | PicGo CLI executable | `picgo` on `PATH` |
|
|
146
|
+
| `--picgo-config PATH` | PicGo CLI configuration file | `~/.picgo/config.json` |
|
|
147
|
+
| `--configure` | Run the PicGo uploader wizard and exit | — |
|
|
148
|
+
|
|
149
|
+
## Python API
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from image_migrate_picgo import CliUploader, ServerUploader, migrate_markdown
|
|
153
|
+
|
|
154
|
+
result = migrate_markdown(
|
|
155
|
+
"notes.md",
|
|
156
|
+
ServerUploader(), # or CliUploader()
|
|
157
|
+
output_path="notes.migrated.md", # omit to update notes.md in place
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
print(result.output_path) # where the result was written
|
|
161
|
+
print(result.migrated_images) # image references rewritten
|
|
162
|
+
print(result.uploaded_images) # unique images uploaded
|
|
163
|
+
print(result.urls) # uploaded URLs
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## LICENSE
|
|
167
|
+
|
|
168
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.svg" alt="image-migrate-picgo logo" width="128">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">image-migrate-picgo</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square" alt="Python 3.11+">
|
|
9
|
+
<img src="https://img.shields.io/badge/PicGo-Server%20%7C%20CLI-blue?style=flat-square" alt="PicGo Server and CLI">
|
|
10
|
+
<img src="https://img.shields.io/badge/Images-Markdown%20%7C%20HTML-informational?style=flat-square" alt="Markdown and HTML images">
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="MIT License"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
English | <a href="README.zh-CN.md">简体中文</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
Move every image in a Markdown file to your image host with one command. `image-migrate-picgo` uploads the images through [PicGo](https://github.com/Molunerfinn/PicGo) and rewrites their links to the uploaded URLs.
|
|
19
|
+
|
|
20
|
+
It is useful when you want to:
|
|
21
|
+
|
|
22
|
+
- publish notes that reference local images,
|
|
23
|
+
- move images from one image host to another,
|
|
24
|
+
- replace inline Base64 (Data URL) images with regular links.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Every image source**: local files, HTTP/HTTPS URLs, and image Data URLs.
|
|
29
|
+
- **Every image syntax**: inline Markdown images, reference-style images, and HTML `<img>` tags.
|
|
30
|
+
- **Two upload methods**: PicGo Server (the PicGo desktop app) or PicGo CLI (`picgo` from npm).
|
|
31
|
+
- **Safe rewriting**: code blocks, inline code, and ordinary links stay untouched. The file is written only after every upload succeeds.
|
|
32
|
+
- **No duplicate uploads**: an image referenced several times is uploaded once.
|
|
33
|
+
|
|
34
|
+
## Example
|
|
35
|
+
|
|
36
|
+
Before:
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+

|
|
40
|
+
![Logo][logo]
|
|
41
|
+
<img src="https://old-host.example.com/screenshot.png" width="600">
|
|
42
|
+
|
|
43
|
+
[logo]: ./images/logo.png
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
After running `image-migrate-picgo notes.md`:
|
|
47
|
+
|
|
48
|
+
```markdown
|
|
49
|
+

|
|
50
|
+

|
|
51
|
+
<img src="https://cdn.example.com/screenshot.png" width="600">
|
|
52
|
+
|
|
53
|
+
[logo]: ./images/logo.png
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
Requires Python 3.11 or newer.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install image-migrate-picgo
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
You also need an image host configured in either PicGo Server or PicGo CLI (see [Upload methods](#upload-methods)).
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
1. Open the PicGo desktop app, configure an image host, and turn on **PicGo Server** in its settings.
|
|
69
|
+
2. Migrate a file:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
image-migrate-picgo notes.md
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> [!WARNING]
|
|
76
|
+
> By default the Markdown file is **updated in place**. Commit or back it up first, or write the result to a new file with `--output`:
|
|
77
|
+
>
|
|
78
|
+
> ```bash
|
|
79
|
+
> image-migrate-picgo notes.md --output notes.migrated.md
|
|
80
|
+
> ```
|
|
81
|
+
|
|
82
|
+
## Upload methods
|
|
83
|
+
|
|
84
|
+
### PicGo Server (default)
|
|
85
|
+
|
|
86
|
+
Uses the HTTP server built into the PicGo desktop app, at `http://127.0.0.1:36677/upload` by default.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Custom address
|
|
90
|
+
image-migrate-picgo notes.md --server-url http://127.0.0.1:36677/upload
|
|
91
|
+
|
|
92
|
+
# Server protected by a secret
|
|
93
|
+
image-migrate-picgo notes.md --server-secret <secret>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### PicGo CLI
|
|
97
|
+
|
|
98
|
+
Uses the [PicGo core](https://github.com/PicGo/PicGo-Core) command line tool. No desktop app is needed, which suits servers and CI.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm install picgo -g
|
|
102
|
+
|
|
103
|
+
# Configure an uploader (an interactive wizard)
|
|
104
|
+
image-migrate-picgo --configure
|
|
105
|
+
|
|
106
|
+
# Upload through PicGo CLI
|
|
107
|
+
image-migrate-picgo notes.md --method cli
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The configuration is saved to `~/.picgo/config.json` by default. If the configuration file does not exist, `--method cli` starts the wizard automatically.
|
|
111
|
+
|
|
112
|
+
## CLI options
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
image-migrate-picgo [OPTIONS] [MARKDOWN]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Option | Description | Default |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| `MARKDOWN` | Markdown file to migrate | — |
|
|
121
|
+
| `-o`, `--output PATH` | Write the result to this file instead of updating `MARKDOWN` | Update in place |
|
|
122
|
+
| `--method [server\|cli]` | Upload method | `server` |
|
|
123
|
+
| `--server-url TEXT` | PicGo Server upload URL | `http://127.0.0.1:36677/upload` |
|
|
124
|
+
| `--server-secret TEXT` | PicGo Server secret, sent as a Bearer token | — |
|
|
125
|
+
| `--picgo-command PATH` | PicGo CLI executable | `picgo` on `PATH` |
|
|
126
|
+
| `--picgo-config PATH` | PicGo CLI configuration file | `~/.picgo/config.json` |
|
|
127
|
+
| `--configure` | Run the PicGo uploader wizard and exit | — |
|
|
128
|
+
|
|
129
|
+
## Python API
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from image_migrate_picgo import CliUploader, ServerUploader, migrate_markdown
|
|
133
|
+
|
|
134
|
+
result = migrate_markdown(
|
|
135
|
+
"notes.md",
|
|
136
|
+
ServerUploader(), # or CliUploader()
|
|
137
|
+
output_path="notes.migrated.md", # omit to update notes.md in place
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
print(result.output_path) # where the result was written
|
|
141
|
+
print(result.migrated_images) # image references rewritten
|
|
142
|
+
print(result.uploaded_images) # unique images uploaded
|
|
143
|
+
print(result.urls) # uploaded URLs
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## LICENSE
|
|
147
|
+
|
|
148
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "image-migrate-picgo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Migrate images in one Markdown file through PicGo"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"markdown-it-py>=4.0,<5",
|
|
9
|
+
"typer>=0.21,<1",
|
|
10
|
+
]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = [
|
|
14
|
+
"markdown",
|
|
15
|
+
"picgo",
|
|
16
|
+
"image",
|
|
17
|
+
"migration",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "left0ver"
|
|
30
|
+
email = "1614674987@qq.com"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
image-migrate-picgo = "image_migrate_picgo.cli:app"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.12.8,<0.13.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "image-migrate-picgo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Migrate images in one Markdown file through PicGo"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "left0ver", email = "1614674987@qq.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"markdown-it-py>=4.0,<5",
|
|
12
|
+
"typer>=0.21,<1",
|
|
13
|
+
]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
keywords = ["markdown", "picgo", "image", "migration"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
image-migrate-picgo = "image_migrate_picgo.cli:app"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.12.8,<0.13.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .core import MigrationResult, image_sources, migrate_markdown, rewrite_markdown
|
|
2
|
+
from .uploaders import (
|
|
3
|
+
CliUploader,
|
|
4
|
+
ServerUploader,
|
|
5
|
+
Uploader,
|
|
6
|
+
configure_picgo_cli,
|
|
7
|
+
default_picgo_config,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CliUploader",
|
|
12
|
+
"MigrationResult",
|
|
13
|
+
"ServerUploader",
|
|
14
|
+
"Uploader",
|
|
15
|
+
"configure_picgo_cli",
|
|
16
|
+
"default_picgo_config",
|
|
17
|
+
"image_sources",
|
|
18
|
+
"migrate_markdown",
|
|
19
|
+
"rewrite_markdown",
|
|
20
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .core import migrate_markdown
|
|
8
|
+
from .uploaders import (
|
|
9
|
+
CliUploader,
|
|
10
|
+
ServerUploader,
|
|
11
|
+
configure_picgo_cli,
|
|
12
|
+
default_picgo_config,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
DEFAULT_PICGO_CONFIG = default_picgo_config()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UploadMethod(str, Enum):
|
|
19
|
+
server = "server"
|
|
20
|
+
cli = "cli"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
help="通过 PicGo 迁移单个 Markdown 文件中的图片",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
pretty_exceptions_show_locals=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command(epilog=f"PicGo CLI 默认配置文件:{DEFAULT_PICGO_CONFIG}")
|
|
32
|
+
def main(
|
|
33
|
+
markdown: Annotated[Path | None, typer.Argument(help="Markdown 文件路径")] = None,
|
|
34
|
+
output: Annotated[
|
|
35
|
+
Path | None,
|
|
36
|
+
typer.Option("--output", "-o", help="输出路径,默认更新原文件"),
|
|
37
|
+
] = None,
|
|
38
|
+
method: Annotated[
|
|
39
|
+
UploadMethod, typer.Option(help="上传方式")
|
|
40
|
+
] = UploadMethod.server,
|
|
41
|
+
server_url: Annotated[
|
|
42
|
+
str,
|
|
43
|
+
typer.Option(help="PicGo Server 上传地址"),
|
|
44
|
+
] = "http://127.0.0.1:36677/upload",
|
|
45
|
+
server_secret: Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
typer.Option(help="PicGo Server 访问密钥"),
|
|
48
|
+
] = None,
|
|
49
|
+
picgo_command: Annotated[
|
|
50
|
+
Path | None,
|
|
51
|
+
typer.Option(help="PicGo CLI 可执行文件,默认从 PATH 查找 picgo"),
|
|
52
|
+
] = None,
|
|
53
|
+
picgo_config: Annotated[
|
|
54
|
+
Path,
|
|
55
|
+
typer.Option(help="PicGo CLI 配置文件"),
|
|
56
|
+
] = DEFAULT_PICGO_CONFIG,
|
|
57
|
+
configure: Annotated[
|
|
58
|
+
bool,
|
|
59
|
+
typer.Option("--configure", help="启动 PicGo uploader 配置向导"),
|
|
60
|
+
] = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
if configure:
|
|
63
|
+
config = configure_picgo_cli(picgo_command, picgo_config)
|
|
64
|
+
typer.echo(f"PicGo 配置已保存:{config}")
|
|
65
|
+
return
|
|
66
|
+
if markdown is None:
|
|
67
|
+
raise typer.BadParameter("需要提供 Markdown 文件路径", param_hint="markdown")
|
|
68
|
+
|
|
69
|
+
uploader = (
|
|
70
|
+
ServerUploader(server_url, secret=server_secret)
|
|
71
|
+
if method is UploadMethod.server
|
|
72
|
+
else CliUploader(picgo_command, config=picgo_config)
|
|
73
|
+
)
|
|
74
|
+
result = migrate_markdown(
|
|
75
|
+
markdown,
|
|
76
|
+
uploader,
|
|
77
|
+
output_path=output,
|
|
78
|
+
)
|
|
79
|
+
typer.echo(
|
|
80
|
+
f"已迁移 {result.migrated_images} 处图片,"
|
|
81
|
+
f"上传 {result.uploaded_images} 个文件:{result.output_path}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
app()
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from html import escape
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import TemporaryDirectory
|
|
9
|
+
from urllib.parse import unquote
|
|
10
|
+
from urllib.request import urlopen
|
|
11
|
+
|
|
12
|
+
from markdown_it import MarkdownIt
|
|
13
|
+
from markdown_it.rules_inline import backtick as parse_backtick
|
|
14
|
+
from markdown_it.rules_inline import image as parse_image
|
|
15
|
+
|
|
16
|
+
from .uploaders import Uploader
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class MigrationResult:
|
|
21
|
+
source_path: Path
|
|
22
|
+
output_path: Path
|
|
23
|
+
migrated_images: int
|
|
24
|
+
uploaded_images: int
|
|
25
|
+
urls: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class _Image:
|
|
30
|
+
source: str
|
|
31
|
+
alt: str
|
|
32
|
+
title: str | None
|
|
33
|
+
start: int
|
|
34
|
+
end: int
|
|
35
|
+
attributes: tuple[tuple[str, str | None], ...] | None = None
|
|
36
|
+
self_closing: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _HtmlImageParser(HTMLParser):
|
|
40
|
+
def __init__(self, content: str) -> None:
|
|
41
|
+
super().__init__(convert_charrefs=True)
|
|
42
|
+
self.images: list[_Image] = []
|
|
43
|
+
self.line_offsets = [0]
|
|
44
|
+
for line in content.splitlines(keepends=True):
|
|
45
|
+
self.line_offsets.append(self.line_offsets[-1] + len(line))
|
|
46
|
+
|
|
47
|
+
def _record(
|
|
48
|
+
self,
|
|
49
|
+
tag: str,
|
|
50
|
+
attributes: list[tuple[str, str | None]],
|
|
51
|
+
self_closing: bool,
|
|
52
|
+
) -> None:
|
|
53
|
+
if tag != "img":
|
|
54
|
+
return
|
|
55
|
+
source = next((value for name, value in attributes if name == "src"), None)
|
|
56
|
+
if not source:
|
|
57
|
+
return
|
|
58
|
+
line, column = self.getpos()
|
|
59
|
+
start = self.line_offsets[line - 1] + column
|
|
60
|
+
self.images.append(
|
|
61
|
+
_Image(
|
|
62
|
+
source=source,
|
|
63
|
+
alt=next(
|
|
64
|
+
(value or "" for name, value in attributes if name == "alt"),
|
|
65
|
+
"",
|
|
66
|
+
),
|
|
67
|
+
title=next(
|
|
68
|
+
(value for name, value in attributes if name == "title"),
|
|
69
|
+
None,
|
|
70
|
+
),
|
|
71
|
+
start=start,
|
|
72
|
+
end=start + len(self.get_starttag_text()),
|
|
73
|
+
attributes=tuple(attributes),
|
|
74
|
+
self_closing=self_closing,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def handle_starttag(
|
|
79
|
+
self,
|
|
80
|
+
tag: str,
|
|
81
|
+
attrs: list[tuple[str, str | None]],
|
|
82
|
+
) -> None:
|
|
83
|
+
self._record(tag, attrs, False)
|
|
84
|
+
|
|
85
|
+
def handle_startendtag(
|
|
86
|
+
self,
|
|
87
|
+
tag: str,
|
|
88
|
+
attrs: list[tuple[str, str | None]],
|
|
89
|
+
) -> None:
|
|
90
|
+
self._record(tag, attrs, True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_image_with_span(state, silent: bool) -> bool:
|
|
94
|
+
start = state.pos
|
|
95
|
+
token_count = len(state.tokens)
|
|
96
|
+
matched = parse_image(state, silent)
|
|
97
|
+
if matched and not silent and len(state.tokens) > token_count:
|
|
98
|
+
state.tokens[-1].meta.update(source_start=start, source_end=state.pos)
|
|
99
|
+
return matched
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_backtick_with_span(state, silent: bool) -> bool:
|
|
103
|
+
start = state.pos
|
|
104
|
+
token_count = len(state.tokens)
|
|
105
|
+
matched = parse_backtick(state, silent)
|
|
106
|
+
if matched and not silent and len(state.tokens) > token_count:
|
|
107
|
+
state.tokens[-1].meta.update(source_start=start, source_end=state.pos)
|
|
108
|
+
return matched
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_markdown = MarkdownIt("commonmark")
|
|
112
|
+
_markdown.inline.ruler.at("image", _parse_image_with_span)
|
|
113
|
+
_markdown.inline.ruler.at("backticks", _parse_backtick_with_span)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _images(content: str) -> list[_Image]:
|
|
117
|
+
line_offsets = [0]
|
|
118
|
+
for line in content.splitlines(keepends=True):
|
|
119
|
+
line_offsets.append(line_offsets[-1] + len(line))
|
|
120
|
+
|
|
121
|
+
images: list[_Image] = []
|
|
122
|
+
protected: list[tuple[int, int]] = []
|
|
123
|
+
for token in _markdown.parse(content):
|
|
124
|
+
if token.type in {"fence", "code_block"} and token.map is not None:
|
|
125
|
+
protected.append((line_offsets[token.map[0]], line_offsets[token.map[1]]))
|
|
126
|
+
continue
|
|
127
|
+
if token.type != "inline" or token.children is None or token.map is None:
|
|
128
|
+
continue
|
|
129
|
+
block_start = line_offsets[token.map[0]]
|
|
130
|
+
block_end = line_offsets[token.map[1]]
|
|
131
|
+
inline_start = content.find(token.content, block_start, block_end)
|
|
132
|
+
fallback_cursor = block_start
|
|
133
|
+
for child in token.children:
|
|
134
|
+
if child.type not in {"image", "code_inline"}:
|
|
135
|
+
continue
|
|
136
|
+
relative_start = child.meta["source_start"]
|
|
137
|
+
relative_end = child.meta["source_end"]
|
|
138
|
+
if inline_start < 0:
|
|
139
|
+
markup = token.content[relative_start:relative_end]
|
|
140
|
+
start = content.find(markup, fallback_cursor, block_end)
|
|
141
|
+
if start < 0:
|
|
142
|
+
raise ValueError("无法确定 Markdown 图片的位置")
|
|
143
|
+
end = start + len(markup)
|
|
144
|
+
fallback_cursor = end
|
|
145
|
+
else:
|
|
146
|
+
start = inline_start + relative_start
|
|
147
|
+
end = inline_start + relative_end
|
|
148
|
+
if child.type == "code_inline":
|
|
149
|
+
protected.append((start, end))
|
|
150
|
+
continue
|
|
151
|
+
images.append(
|
|
152
|
+
_Image(
|
|
153
|
+
source=child.attrGet("src"),
|
|
154
|
+
alt=child.content,
|
|
155
|
+
title=child.attrGet("title"),
|
|
156
|
+
start=start,
|
|
157
|
+
end=end,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
html_parser = _HtmlImageParser(content)
|
|
162
|
+
html_parser.feed(content)
|
|
163
|
+
html_parser.close()
|
|
164
|
+
protected.extend((image.start, image.end) for image in images)
|
|
165
|
+
images.extend(
|
|
166
|
+
image
|
|
167
|
+
for image in html_parser.images
|
|
168
|
+
if not any(image.start < end and start < image.end for start, end in protected)
|
|
169
|
+
)
|
|
170
|
+
return sorted(images, key=lambda image: image.start)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def image_sources(content: str) -> list[str]:
|
|
174
|
+
return [image.source for image in _images(content)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def rewrite_markdown(content: str, replacements: Mapping[str, str]) -> str:
|
|
178
|
+
edits: list[tuple[int, int, str]] = []
|
|
179
|
+
for image in _images(content):
|
|
180
|
+
if image.source not in replacements:
|
|
181
|
+
continue
|
|
182
|
+
title = ""
|
|
183
|
+
if image.title:
|
|
184
|
+
escaped_title = image.title.replace("\\", "\\\\").replace('"', '\\"')
|
|
185
|
+
title = f' "{escaped_title}"'
|
|
186
|
+
if image.attributes is None:
|
|
187
|
+
markup = f""
|
|
188
|
+
else:
|
|
189
|
+
attributes = " ".join(
|
|
190
|
+
name
|
|
191
|
+
if value is None
|
|
192
|
+
else f'{name}="{escape(replacements[image.source] if name == "src" else value, quote=True)}"'
|
|
193
|
+
for name, value in image.attributes
|
|
194
|
+
)
|
|
195
|
+
ending = " />" if image.self_closing else ">"
|
|
196
|
+
markup = f"<img {attributes}{ending}"
|
|
197
|
+
edits.append((image.start, image.end, markup))
|
|
198
|
+
|
|
199
|
+
for start, end, markup in reversed(edits):
|
|
200
|
+
content = content[:start] + markup + content[end:]
|
|
201
|
+
return content
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _decode_data_image(
|
|
205
|
+
source: str,
|
|
206
|
+
directory: Path,
|
|
207
|
+
index: int,
|
|
208
|
+
) -> Path:
|
|
209
|
+
with urlopen(source) as response:
|
|
210
|
+
content_type = response.headers.get_content_type()
|
|
211
|
+
if not content_type.startswith("image/"):
|
|
212
|
+
raise ValueError("Data URL 的媒体类型需要为 image/*")
|
|
213
|
+
target = (
|
|
214
|
+
directory
|
|
215
|
+
/ f"data-{index}{mimetypes.guess_extension(content_type) or '.img'}"
|
|
216
|
+
)
|
|
217
|
+
target.write_bytes(response.read())
|
|
218
|
+
return target
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _upload_source(
|
|
222
|
+
source: str,
|
|
223
|
+
markdown_path: Path,
|
|
224
|
+
directory: Path,
|
|
225
|
+
index: int,
|
|
226
|
+
) -> str | Path:
|
|
227
|
+
if source.startswith("data:"):
|
|
228
|
+
return _decode_data_image(source, directory, index)
|
|
229
|
+
if source.startswith(("http://", "https://", "//")):
|
|
230
|
+
return f"https:{source}" if source.startswith("//") else source
|
|
231
|
+
path = Path(unquote(source.split("#", 1)[0].split("?", 1)[0])).expanduser()
|
|
232
|
+
if not path.is_absolute():
|
|
233
|
+
path = markdown_path.parent / path
|
|
234
|
+
return path.resolve(strict=True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def migrate_markdown(
|
|
238
|
+
markdown_path: str | Path,
|
|
239
|
+
uploader: Uploader,
|
|
240
|
+
*,
|
|
241
|
+
output_path: str | Path | None = None,
|
|
242
|
+
) -> MigrationResult:
|
|
243
|
+
source_path = Path(markdown_path).expanduser().resolve(strict=True)
|
|
244
|
+
target_path = (
|
|
245
|
+
Path(output_path).expanduser().resolve()
|
|
246
|
+
if output_path is not None
|
|
247
|
+
else source_path
|
|
248
|
+
)
|
|
249
|
+
content = source_path.read_text(encoding="utf-8")
|
|
250
|
+
all_sources = image_sources(content)
|
|
251
|
+
sources = list(dict.fromkeys(all_sources))
|
|
252
|
+
|
|
253
|
+
if sources:
|
|
254
|
+
with (
|
|
255
|
+
TemporaryDirectory(prefix=".image-migrate-picgo-", dir=source_path.parent)
|
|
256
|
+
if any(source.startswith("data:") for source in sources)
|
|
257
|
+
else nullcontext(source_path.parent)
|
|
258
|
+
) as directory:
|
|
259
|
+
upload_sources = [
|
|
260
|
+
_upload_source(source, source_path, Path(directory), index)
|
|
261
|
+
for index, source in enumerate(sources)
|
|
262
|
+
]
|
|
263
|
+
urls = tuple(uploader.upload(upload_sources))
|
|
264
|
+
if len(urls) != len(sources):
|
|
265
|
+
raise RuntimeError("PicGo 返回的 URL 数量与图片数量不一致")
|
|
266
|
+
replacements = dict(zip(sources, urls, strict=True))
|
|
267
|
+
migrated = rewrite_markdown(content, replacements)
|
|
268
|
+
else:
|
|
269
|
+
urls = ()
|
|
270
|
+
migrated = content
|
|
271
|
+
|
|
272
|
+
target_path.write_text(migrated, encoding="utf-8")
|
|
273
|
+
return MigrationResult(
|
|
274
|
+
source_path=source_path,
|
|
275
|
+
output_path=target_path,
|
|
276
|
+
migrated_images=len(all_sources),
|
|
277
|
+
uploaded_images=len(sources),
|
|
278
|
+
urls=urls,
|
|
279
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_picgo_config() -> Path:
|
|
12
|
+
return Path.home() / ".picgo" / "config.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _picgo_command(command: str | Path | None) -> str:
|
|
16
|
+
candidate = "picgo" if command is None else str(Path(command).expanduser())
|
|
17
|
+
executable = shutil.which(candidate)
|
|
18
|
+
if executable is None:
|
|
19
|
+
raise FileNotFoundError(
|
|
20
|
+
f"没有找到 PicGo CLI:{candidate}。请安装 PicGo CLI 或使用 --picgo-command 指定路径"
|
|
21
|
+
)
|
|
22
|
+
return str(Path(executable).resolve(strict=True))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure_picgo_cli(
|
|
26
|
+
command: str | Path | None = None,
|
|
27
|
+
config: str | Path | None = None,
|
|
28
|
+
) -> Path:
|
|
29
|
+
picgo = _picgo_command(command)
|
|
30
|
+
config_path = (
|
|
31
|
+
default_picgo_config() if config is None else Path(config).expanduser()
|
|
32
|
+
)
|
|
33
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
subprocess.run(
|
|
35
|
+
[picgo, "--config", str(config_path), "set", "uploader"],
|
|
36
|
+
check=True,
|
|
37
|
+
)
|
|
38
|
+
return config_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Uploader(Protocol):
|
|
42
|
+
def upload(self, sources: Sequence[str | Path]) -> Sequence[str]: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_urls(values: object, expected: int) -> list[str]:
|
|
46
|
+
if not isinstance(values, list) or len(values) != expected:
|
|
47
|
+
raise RuntimeError("PicGo 返回的 URL 数量与图片数量不一致")
|
|
48
|
+
if not all(
|
|
49
|
+
isinstance(value, str) and urlsplit(value).scheme in {"http", "https"}
|
|
50
|
+
for value in values
|
|
51
|
+
):
|
|
52
|
+
raise RuntimeError("PicGo 返回了无效的图片 URL")
|
|
53
|
+
return values
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ServerUploader:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
url: str = "http://127.0.0.1:36677/upload",
|
|
60
|
+
*,
|
|
61
|
+
secret: str | None = None,
|
|
62
|
+
timeout: float = 60,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.url = url
|
|
65
|
+
self.secret = secret
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
|
|
68
|
+
def upload(self, sources: Sequence[str | Path]) -> list[str]:
|
|
69
|
+
body = json.dumps({"list": [str(source) for source in sources]}).encode()
|
|
70
|
+
headers = {"Content-Type": "application/json"}
|
|
71
|
+
if self.secret:
|
|
72
|
+
headers["Authorization"] = f"Bearer {self.secret}"
|
|
73
|
+
request = Request(self.url, data=body, headers=headers, method="POST")
|
|
74
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
75
|
+
result = json.loads(response.read())
|
|
76
|
+
if result.get("success") is not True:
|
|
77
|
+
raise RuntimeError(result.get("message", "PicGo Server 上传失败"))
|
|
78
|
+
return _validate_urls(result.get("result"), len(sources))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CliUploader:
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
command: str | Path | None = None,
|
|
85
|
+
*,
|
|
86
|
+
config: str | Path | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.command = _picgo_command(command)
|
|
89
|
+
config_path = (
|
|
90
|
+
default_picgo_config() if config is None else Path(config).expanduser()
|
|
91
|
+
)
|
|
92
|
+
if not config_path.is_file():
|
|
93
|
+
print(f"没有找到 PicGo 配置文件,将启动配置向导:{config_path}")
|
|
94
|
+
configure_picgo_cli(self.command, config_path)
|
|
95
|
+
if not config_path.is_file():
|
|
96
|
+
raise FileNotFoundError(f"PicGo 配置文件尚未创建:{config_path}")
|
|
97
|
+
self.config = str(config_path)
|
|
98
|
+
|
|
99
|
+
def upload(self, sources: Sequence[str | Path]) -> list[str]:
|
|
100
|
+
command = [self.command, "--config", self.config]
|
|
101
|
+
command.extend(("upload", *(str(source) for source in sources)))
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
command,
|
|
104
|
+
check=True,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
)
|
|
108
|
+
lines = (result.stdout + "\n" + result.stderr).splitlines()
|
|
109
|
+
urls = [
|
|
110
|
+
line.strip()
|
|
111
|
+
for line in lines
|
|
112
|
+
if urlsplit(line.strip()).scheme in {"http", "https"}
|
|
113
|
+
]
|
|
114
|
+
if not urls:
|
|
115
|
+
output = "\n".join(line for line in lines if line.strip())
|
|
116
|
+
raise RuntimeError(output or "PicGo CLI 没有返回图片 URL")
|
|
117
|
+
return _validate_urls(urls, len(sources))
|