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.
@@ -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
+ ![Architecture](images/architecture.png "Overview")
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
+ ![Architecture](<https://cdn.example.com/architecture.png> "Overview")
70
+ ![Logo](<https://cdn.example.com/logo.png>)
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
+ ![Architecture](images/architecture.png "Overview")
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
+ ![Architecture](<https://cdn.example.com/architecture.png> "Overview")
50
+ ![Logo](<https://cdn.example.com/logo.png>)
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"![{image.alt}](<{replacements[image.source]}>{title})"
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))