magicmd 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- magicmd/__init__.py +2 -0
- magicmd/assets.py +149 -0
- magicmd/cli.py +422 -0
- magicmd/config.py +78 -0
- magicmd/detect.py +8 -0
- magicmd/diagnostics.py +118 -0
- magicmd/fetchers/__init__.py +1 -0
- magicmd/fetchers/browser.py +51 -0
- magicmd/fetchers/http.py +17 -0
- magicmd/models.py +53 -0
- magicmd/output.py +58 -0
- magicmd/platforms/__init__.py +1 -0
- magicmd/platforms/base.py +19 -0
- magicmd/platforms/csdn.py +76 -0
- magicmd/platforms/generic.py +54 -0
- magicmd/platforms/juejin.py +95 -0
- magicmd/platforms/registry.py +73 -0
- magicmd/platforms/shared/__init__.py +0 -0
- magicmd/platforms/shared/content.py +440 -0
- magicmd/platforms/shared/markdown.py +95 -0
- magicmd/platforms/shared/metadata.py +38 -0
- magicmd/platforms/wechat.py +57 -0
- magicmd/quality.py +199 -0
- magicmd/renderers/__init__.py +1 -0
- magicmd/renderers/markdown.py +62 -0
- magicmd/templates/magicmd.example.toml +41 -0
- magicmd-0.1.0.dist-info/METADATA +315 -0
- magicmd-0.1.0.dist-info/RECORD +31 -0
- magicmd-0.1.0.dist-info/WHEEL +4 -0
- magicmd-0.1.0.dist-info/entry_points.txt +2 -0
- magicmd-0.1.0.dist-info/licenses/LICENSE +21 -0
magicmd/__init__.py
ADDED
magicmd/assets.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from magicmd.models import Article, ImageAsset
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def infer_image_extension(url: str, content_type: str = "") -> str:
|
|
12
|
+
match = re.search(r"wx_fmt=(\w+)", url) or re.search(r"\.(\w{3,4})(?:\?|$)", url)
|
|
13
|
+
if match:
|
|
14
|
+
ext = match.group(1).lower().replace("jpeg", "jpg")
|
|
15
|
+
if ext != "other":
|
|
16
|
+
return ext
|
|
17
|
+
if "jpeg" in content_type:
|
|
18
|
+
return "jpg"
|
|
19
|
+
if "png" in content_type:
|
|
20
|
+
return "png"
|
|
21
|
+
if "gif" in content_type:
|
|
22
|
+
return "gif"
|
|
23
|
+
if "webp" in content_type:
|
|
24
|
+
return "webp"
|
|
25
|
+
return "png"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _separate_markdown_images(markdown: str) -> str:
|
|
29
|
+
image = r"!\[[^\]]*\]\([^)\n]+\)"
|
|
30
|
+
markdown = re.sub(r"!\[\]\(\)", "", markdown)
|
|
31
|
+
markdown = re.sub(rf"([^\[\n])({image})", r"\1\n\n\2", markdown)
|
|
32
|
+
markdown = re.sub(rf"({image})([^\]\n])", r"\1\n\n\2", markdown)
|
|
33
|
+
markdown = re.sub(rf"({image})\s*({image})", r"\1\n\n\2", markdown)
|
|
34
|
+
return re.sub(r"\n{4,}", "\n\n\n", markdown).strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rewrite_markdown_image_links(markdown: str, images: list[ImageAsset]) -> str:
|
|
38
|
+
result = markdown
|
|
39
|
+
for image in images:
|
|
40
|
+
if not image.local_path:
|
|
41
|
+
continue
|
|
42
|
+
pattern = re.compile(r"!\[([^\]]*)\]\(" + re.escape(image.source_url) + r"\)")
|
|
43
|
+
result = pattern.sub(lambda match: f"", result)
|
|
44
|
+
return _separate_markdown_images(result)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _video_transport():
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _image_transport():
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _unescape_markdown_url(url: str) -> str:
|
|
56
|
+
return re.sub(r"\\([_&=?.:/+\-])", r"\1", url)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _infer_video_extension(url: str, content_type: str = "") -> str:
|
|
60
|
+
match = re.search(r"\.(mp4|mov|webm|m4v)(?:\?|$)", url, re.I)
|
|
61
|
+
if match:
|
|
62
|
+
return match.group(1).lower()
|
|
63
|
+
if "webm" in content_type:
|
|
64
|
+
return "webm"
|
|
65
|
+
if "quicktime" in content_type:
|
|
66
|
+
return "mov"
|
|
67
|
+
return "mp4"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def download_videos(article: Article, package_dir: Path, video_dir_name: str = "videos") -> Article:
|
|
71
|
+
matches = list(re.finditer(r"\[视频\]\((https?://[^)\n]+)\)", article.content_markdown))
|
|
72
|
+
if not matches:
|
|
73
|
+
return article
|
|
74
|
+
|
|
75
|
+
video_dir = package_dir / video_dir_name
|
|
76
|
+
video_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
markdown = article.content_markdown
|
|
78
|
+
warnings = list(article.extraction.warnings)
|
|
79
|
+
seen: dict[str, str] = {}
|
|
80
|
+
transport = _video_transport()
|
|
81
|
+
client_kwargs = {"timeout": 30.0, "follow_redirects": True}
|
|
82
|
+
if transport is not None:
|
|
83
|
+
client_kwargs["transport"] = transport
|
|
84
|
+
|
|
85
|
+
with httpx.Client(**client_kwargs) as client:
|
|
86
|
+
for match in matches:
|
|
87
|
+
markdown_url = match.group(1)
|
|
88
|
+
url = _unescape_markdown_url(markdown_url)
|
|
89
|
+
if url not in seen:
|
|
90
|
+
try:
|
|
91
|
+
response = client.get(
|
|
92
|
+
url,
|
|
93
|
+
headers={
|
|
94
|
+
"Referer": article.source_url,
|
|
95
|
+
"User-Agent": "Mozilla/5.0 MagicMD",
|
|
96
|
+
"Accept": "video/mp4,video/*,*/*",
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
ext = _infer_video_extension(url, response.headers.get("content-type", ""))
|
|
101
|
+
local_path = f"{video_dir_name}/video_{len(seen) + 1:03d}.{ext}"
|
|
102
|
+
(package_dir / local_path).write_bytes(response.content)
|
|
103
|
+
seen[url] = local_path
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
warnings.append(f"video_download_failed:{url}:{exc}")
|
|
106
|
+
seen[url] = url
|
|
107
|
+
markdown = markdown.replace(f"[视频]({markdown_url})", f"[视频]({seen[url]})")
|
|
108
|
+
|
|
109
|
+
next_article = article.model_copy(update={"content_markdown": markdown})
|
|
110
|
+
next_article.extraction.warnings = warnings
|
|
111
|
+
return next_article
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def download_images(
|
|
115
|
+
article: Article,
|
|
116
|
+
package_dir: Path,
|
|
117
|
+
image_dir_name: str = "images",
|
|
118
|
+
filename_pattern: str = "img_{index:03d}.{ext}",
|
|
119
|
+
) -> Article:
|
|
120
|
+
if not article.images:
|
|
121
|
+
return article
|
|
122
|
+
image_dir = package_dir / image_dir_name
|
|
123
|
+
image_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
next_images: list[ImageAsset] = []
|
|
125
|
+
warnings = list(article.extraction.warnings)
|
|
126
|
+
transport = _image_transport()
|
|
127
|
+
client_kwargs = {"timeout": 20.0, "follow_redirects": True}
|
|
128
|
+
if transport is not None:
|
|
129
|
+
client_kwargs["transport"] = transport
|
|
130
|
+
with httpx.Client(**client_kwargs) as client:
|
|
131
|
+
for index, image in enumerate(article.images, start=1):
|
|
132
|
+
url = image.source_url if not image.source_url.startswith("//") else f"https:{image.source_url}"
|
|
133
|
+
try:
|
|
134
|
+
response = client.get(url, headers={"Referer": article.source_url})
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
ext = infer_image_extension(url, response.headers.get("content-type", ""))
|
|
137
|
+
filename = filename_pattern.format(index=index, ext=ext)
|
|
138
|
+
local_path = f"{image_dir_name}/{filename}"
|
|
139
|
+
(package_dir / local_path).write_bytes(response.content)
|
|
140
|
+
next_images.append(image.model_copy(update={"local_path": local_path}))
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
warnings.append(f"image_download_failed:{url}:{exc}")
|
|
143
|
+
next_images.append(image)
|
|
144
|
+
next_article = article.model_copy(update={"images": next_images})
|
|
145
|
+
next_article.content_markdown = rewrite_markdown_image_links(
|
|
146
|
+
next_article.content_markdown, next_images
|
|
147
|
+
)
|
|
148
|
+
next_article.extraction.warnings = warnings
|
|
149
|
+
return next_article
|
magicmd/cli.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from magicmd import __version__
|
|
17
|
+
from magicmd.config import load_config
|
|
18
|
+
from magicmd.detect import detect_platform
|
|
19
|
+
from magicmd.diagnostics import (
|
|
20
|
+
build_doctor_report,
|
|
21
|
+
render_doctor_report,
|
|
22
|
+
save_debug_html,
|
|
23
|
+
save_extraction_report,
|
|
24
|
+
)
|
|
25
|
+
from magicmd.fetchers.browser import fetch_browser
|
|
26
|
+
from magicmd.fetchers.http import fetch_http
|
|
27
|
+
from magicmd.output import write_article_files, write_article_package
|
|
28
|
+
from magicmd.platforms.registry import get_platform_adapter
|
|
29
|
+
from magicmd.quality import (
|
|
30
|
+
build_failure_quality,
|
|
31
|
+
build_package_quality,
|
|
32
|
+
build_skipped_quality,
|
|
33
|
+
write_batch_report,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(help="Convert public article links into Markdown packages.", no_args_is_help=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _version_callback(value: bool):
|
|
40
|
+
if value:
|
|
41
|
+
typer.echo(f"MagicMD {__version__}")
|
|
42
|
+
raise typer.Exit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def main(
|
|
47
|
+
version: bool = typer.Option(
|
|
48
|
+
False,
|
|
49
|
+
"--version",
|
|
50
|
+
callback=_version_callback,
|
|
51
|
+
is_eager=True,
|
|
52
|
+
help="Show MagicMD version and exit.",
|
|
53
|
+
),
|
|
54
|
+
):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConversionStageError(click.ClickException):
|
|
59
|
+
def __init__(self, stage: str, error: Exception):
|
|
60
|
+
self.stage = stage
|
|
61
|
+
self.original_error = error
|
|
62
|
+
super().__init__(f"{stage}: {error}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ProgressReporter:
|
|
66
|
+
def __init__(self, enabled: bool = False, console: Console | None = None):
|
|
67
|
+
self.enabled = enabled
|
|
68
|
+
self.console = console or Console(no_color=False)
|
|
69
|
+
|
|
70
|
+
def run(self, index: int, total: int, message: str, operation):
|
|
71
|
+
if not self.enabled:
|
|
72
|
+
return operation()
|
|
73
|
+
status_text = f"[cyan]⠋ [{index}/{total}] {message}...[/cyan]"
|
|
74
|
+
with self.console.status(status_text, spinner="dots"):
|
|
75
|
+
result = operation()
|
|
76
|
+
line = Text()
|
|
77
|
+
line.append("✓", style="green")
|
|
78
|
+
line.append(f" [{index}/{total}] {message}")
|
|
79
|
+
self.console.print(line)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_conversion_stage(
|
|
84
|
+
progress: ProgressReporter,
|
|
85
|
+
stage: str,
|
|
86
|
+
index: int,
|
|
87
|
+
total: int,
|
|
88
|
+
message: str,
|
|
89
|
+
operation,
|
|
90
|
+
):
|
|
91
|
+
try:
|
|
92
|
+
return progress.run(index, total, message, operation)
|
|
93
|
+
except ConversionStageError:
|
|
94
|
+
raise
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise ConversionStageError(stage, exc) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_article(platform: str, html: str, url: str):
|
|
100
|
+
try:
|
|
101
|
+
adapter = get_platform_adapter(platform)
|
|
102
|
+
except KeyError:
|
|
103
|
+
adapter = get_platform_adapter("generic")
|
|
104
|
+
return adapter.parser(html, url)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch_for_platform(url: str, platform: str, config_path: Optional[Path] = None) -> str:
|
|
108
|
+
config = load_config(config_path)
|
|
109
|
+
platform_config = config.platforms.get(platform)
|
|
110
|
+
if platform_config and platform_config.browser == "camoufox":
|
|
111
|
+
return fetch_browser(
|
|
112
|
+
url,
|
|
113
|
+
wait_selector=platform_config.wait_selector,
|
|
114
|
+
timeout_ms=config.fetch.browser_timeout_seconds * 1000,
|
|
115
|
+
attempts=config.fetch.browser_attempts,
|
|
116
|
+
)
|
|
117
|
+
return fetch_http(url, timeout_seconds=config.fetch.timeout_seconds, user_agent=config.fetch.user_agent)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def entrypoint():
|
|
121
|
+
if len(sys.argv) > 1 and sys.argv[1].startswith(("http://", "https://")):
|
|
122
|
+
sys.argv.insert(1, "convert")
|
|
123
|
+
try:
|
|
124
|
+
app(standalone_mode=False)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
if hasattr(exc, "show") and hasattr(exc, "exit_code"):
|
|
127
|
+
exc.show()
|
|
128
|
+
raise SystemExit(exc.exit_code) from exc
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_output(output: Path | None, config_path: Optional[Path]) -> Path:
|
|
133
|
+
if output is not None:
|
|
134
|
+
return output
|
|
135
|
+
return Path(load_config(config_path).output.directory)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ensure_platform_enabled(platform: str, config_path: Optional[Path]) -> None:
|
|
139
|
+
config = load_config(config_path)
|
|
140
|
+
platform_config = config.platforms.get(platform)
|
|
141
|
+
if platform_config and not platform_config.enabled:
|
|
142
|
+
raise click.ClickException(f"Platform disabled: {platform}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _batch_context(url: str, platform: str, config_path: Optional[Path]) -> dict[str, Any]:
|
|
146
|
+
config = load_config(config_path)
|
|
147
|
+
resolved_platform = detect_platform(url) if platform == "auto" else platform
|
|
148
|
+
platform_config = config.platforms.get(resolved_platform)
|
|
149
|
+
fetcher = platform_config.browser if platform_config else "http"
|
|
150
|
+
max_attempts = config.fetch.browser_attempts if fetcher == "camoufox" else 1
|
|
151
|
+
return {
|
|
152
|
+
"platform": resolved_platform,
|
|
153
|
+
"fetcher": fetcher,
|
|
154
|
+
"max_attempts": max_attempts,
|
|
155
|
+
"retry_enabled": max_attempts > 1,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _decorate_batch_result(
|
|
160
|
+
item: dict[str, Any],
|
|
161
|
+
context: dict[str, Any],
|
|
162
|
+
elapsed_ms: int,
|
|
163
|
+
stage: str,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
result = dict(item)
|
|
166
|
+
result.update(context)
|
|
167
|
+
result["elapsed_ms"] = elapsed_ms
|
|
168
|
+
result["stage"] = _quality_failure_stage(result, stage) if result.get("status") == "fail" else stage
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _quality_failure_stage(item: dict[str, Any], fallback: str) -> str:
|
|
173
|
+
error = str(item.get("error") or "")
|
|
174
|
+
if error.endswith("_content_not_found"):
|
|
175
|
+
return "parse"
|
|
176
|
+
return fallback
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _find_existing_package(output: Path, url: str) -> Path | None:
|
|
180
|
+
if not output.exists():
|
|
181
|
+
return None
|
|
182
|
+
for metadata_path in sorted(output.glob("*/metadata.json")):
|
|
183
|
+
package_dir = metadata_path.parent
|
|
184
|
+
if not (package_dir / "article.md").exists():
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
continue
|
|
190
|
+
if not isinstance(metadata, dict):
|
|
191
|
+
continue
|
|
192
|
+
if url in {metadata.get("source_url"), metadata.get("canonical_url")}:
|
|
193
|
+
return package_dir
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _should_save_debug_html(debug: bool, save_mode: str, warnings: list[str]) -> bool:
|
|
198
|
+
normalized = save_mode.lower()
|
|
199
|
+
return debug or normalized == "always" or (normalized == "on_failure" and bool(warnings))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def convert_url(
|
|
203
|
+
url: str,
|
|
204
|
+
output: Path,
|
|
205
|
+
platform: str = "auto",
|
|
206
|
+
config_path: Optional[Path] = None,
|
|
207
|
+
debug: bool = False,
|
|
208
|
+
overwrite: bool = False,
|
|
209
|
+
download_images_enabled: bool = True,
|
|
210
|
+
show_progress: bool = False,
|
|
211
|
+
) -> Path:
|
|
212
|
+
progress = ProgressReporter(show_progress)
|
|
213
|
+
config = load_config(config_path)
|
|
214
|
+
resolved_platform = _run_conversion_stage(
|
|
215
|
+
progress,
|
|
216
|
+
"detect",
|
|
217
|
+
1,
|
|
218
|
+
6,
|
|
219
|
+
"Detecting platform",
|
|
220
|
+
lambda: detect_platform(url) if platform == "auto" else platform,
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
_ensure_platform_enabled(resolved_platform, config_path)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
raise ConversionStageError("detect", exc) from exc
|
|
226
|
+
html = _run_conversion_stage(
|
|
227
|
+
progress,
|
|
228
|
+
"fetch",
|
|
229
|
+
2,
|
|
230
|
+
6,
|
|
231
|
+
f"Fetching article ({resolved_platform})",
|
|
232
|
+
lambda: fetch_for_platform(url, resolved_platform, config_path),
|
|
233
|
+
)
|
|
234
|
+
article = _run_conversion_stage(
|
|
235
|
+
progress,
|
|
236
|
+
"parse",
|
|
237
|
+
3,
|
|
238
|
+
6,
|
|
239
|
+
"Parsing article",
|
|
240
|
+
lambda: parse_article(resolved_platform, html, url),
|
|
241
|
+
)
|
|
242
|
+
package_dir = _run_conversion_stage(
|
|
243
|
+
progress,
|
|
244
|
+
"write",
|
|
245
|
+
4,
|
|
246
|
+
6,
|
|
247
|
+
"Writing Markdown package",
|
|
248
|
+
lambda: write_article_package(
|
|
249
|
+
article,
|
|
250
|
+
output,
|
|
251
|
+
overwrite=overwrite or config.output.overwrite,
|
|
252
|
+
markdown_config=config.markdown,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
if _should_save_debug_html(debug, config.output.save_debug_html, article.extraction.warnings):
|
|
256
|
+
save_debug_html(package_dir, html)
|
|
257
|
+
if download_images_enabled and config.images.download:
|
|
258
|
+
from magicmd.assets import download_images, download_videos
|
|
259
|
+
|
|
260
|
+
article = _run_conversion_stage(
|
|
261
|
+
progress,
|
|
262
|
+
"media",
|
|
263
|
+
5,
|
|
264
|
+
6,
|
|
265
|
+
"Downloading media",
|
|
266
|
+
lambda: download_videos(
|
|
267
|
+
download_images(
|
|
268
|
+
article,
|
|
269
|
+
package_dir,
|
|
270
|
+
config.images.directory,
|
|
271
|
+
config.images.filename_pattern,
|
|
272
|
+
),
|
|
273
|
+
package_dir,
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
write_article_files(article, package_dir, markdown_config=config.markdown)
|
|
277
|
+
else:
|
|
278
|
+
progress.run(5, 6, "Skipping image download", lambda: article)
|
|
279
|
+
_run_conversion_stage(
|
|
280
|
+
progress,
|
|
281
|
+
"report",
|
|
282
|
+
6,
|
|
283
|
+
6,
|
|
284
|
+
"Saving extraction report",
|
|
285
|
+
lambda: save_extraction_report(package_dir, article.to_metadata()["extraction"]),
|
|
286
|
+
)
|
|
287
|
+
return package_dir
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.command()
|
|
291
|
+
def convert(
|
|
292
|
+
url: str,
|
|
293
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory."),
|
|
294
|
+
platform: str = typer.Option("auto", "--platform", help="auto, wechat, juejin, csdn, generic."),
|
|
295
|
+
config_path: Optional[Path] = typer.Option(None, "--config", help="Config file path."),
|
|
296
|
+
no_images: bool = typer.Option(False, "--no-images", help="Do not download images."),
|
|
297
|
+
debug: bool = typer.Option(False, "--debug", help="Save debug HTML."),
|
|
298
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite output package."),
|
|
299
|
+
):
|
|
300
|
+
resolved_output = _resolve_output(output, config_path)
|
|
301
|
+
package_dir = convert_url(
|
|
302
|
+
url,
|
|
303
|
+
resolved_output,
|
|
304
|
+
platform=platform,
|
|
305
|
+
config_path=config_path,
|
|
306
|
+
debug=debug,
|
|
307
|
+
overwrite=overwrite,
|
|
308
|
+
download_images_enabled=not no_images,
|
|
309
|
+
show_progress=True,
|
|
310
|
+
)
|
|
311
|
+
quality = build_package_quality(url, package_dir)
|
|
312
|
+
if quality["status"] == "fail":
|
|
313
|
+
raise click.ClickException(
|
|
314
|
+
f"Extraction failed: {quality.get('error')}. Debug package saved at: {package_dir}"
|
|
315
|
+
)
|
|
316
|
+
typer.echo(f"Created output package: {package_dir}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@app.command()
|
|
320
|
+
def batch(
|
|
321
|
+
file: Path,
|
|
322
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory."),
|
|
323
|
+
platform: str = typer.Option("auto", "--platform", help="auto, wechat, juejin, csdn, generic."),
|
|
324
|
+
config_path: Optional[Path] = typer.Option(None, "--config", help="Config file path."),
|
|
325
|
+
no_images: bool = typer.Option(False, "--no-images", help="Do not download images."),
|
|
326
|
+
debug: bool = typer.Option(False, "--debug", help="Save debug HTML."),
|
|
327
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite output package."),
|
|
328
|
+
skip_existing: bool = typer.Option(False, "--skip-existing", help="Skip URLs already present in output metadata."),
|
|
329
|
+
):
|
|
330
|
+
resolved_output = _resolve_output(output, config_path)
|
|
331
|
+
urls = [
|
|
332
|
+
line.strip()
|
|
333
|
+
for line in file.read_text(encoding="utf-8").splitlines()
|
|
334
|
+
if line.strip() and not line.strip().startswith("#")
|
|
335
|
+
]
|
|
336
|
+
results = []
|
|
337
|
+
for url in urls:
|
|
338
|
+
started_at = perf_counter()
|
|
339
|
+
context = _batch_context(url, platform, config_path)
|
|
340
|
+
try:
|
|
341
|
+
if skip_existing:
|
|
342
|
+
existing_package = _find_existing_package(resolved_output, url)
|
|
343
|
+
if existing_package:
|
|
344
|
+
elapsed_ms = int((perf_counter() - started_at) * 1000)
|
|
345
|
+
results.append(
|
|
346
|
+
_decorate_batch_result(
|
|
347
|
+
build_skipped_quality(url, existing_package),
|
|
348
|
+
context,
|
|
349
|
+
elapsed_ms,
|
|
350
|
+
"skip",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
typer.echo(f"SKIP {url} -> {existing_package}")
|
|
354
|
+
continue
|
|
355
|
+
package_dir = convert_url(
|
|
356
|
+
url,
|
|
357
|
+
resolved_output,
|
|
358
|
+
platform=platform,
|
|
359
|
+
config_path=config_path,
|
|
360
|
+
debug=debug,
|
|
361
|
+
overwrite=overwrite,
|
|
362
|
+
download_images_enabled=not no_images,
|
|
363
|
+
show_progress=True,
|
|
364
|
+
)
|
|
365
|
+
elapsed_ms = int((perf_counter() - started_at) * 1000)
|
|
366
|
+
results.append(
|
|
367
|
+
_decorate_batch_result(
|
|
368
|
+
build_package_quality(url, package_dir),
|
|
369
|
+
context,
|
|
370
|
+
elapsed_ms,
|
|
371
|
+
"complete",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
typer.echo(f"OK {url} -> {package_dir}")
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
elapsed_ms = int((perf_counter() - started_at) * 1000)
|
|
377
|
+
stage = exc.stage if isinstance(exc, ConversionStageError) else "convert"
|
|
378
|
+
results.append(
|
|
379
|
+
_decorate_batch_result(
|
|
380
|
+
build_failure_quality(url, exc),
|
|
381
|
+
context,
|
|
382
|
+
elapsed_ms,
|
|
383
|
+
stage,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
typer.echo(f"FAIL {url}: {exc}", err=True)
|
|
387
|
+
report_paths = write_batch_report(results, resolved_output)
|
|
388
|
+
typer.echo(f"Batch report: {report_paths['markdown']}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
config_app = typer.Typer(help="Manage MagicMD config.")
|
|
392
|
+
app.add_typer(config_app, name="config")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@config_app.command("init")
|
|
396
|
+
def config_init(path: Path = typer.Option(Path(".magicmd.toml"), "--path", help="Config path.")):
|
|
397
|
+
if path.exists():
|
|
398
|
+
typer.echo(f"Config already exists: {path}")
|
|
399
|
+
return
|
|
400
|
+
package_template = resources.files("magicmd").joinpath("templates/magicmd.example.toml")
|
|
401
|
+
if package_template.is_file():
|
|
402
|
+
path.write_text(package_template.read_text(encoding="utf-8"), encoding="utf-8")
|
|
403
|
+
typer.echo(f"Created config: {path}")
|
|
404
|
+
return
|
|
405
|
+
example = Path(__file__).resolve().parents[2] / ".magicmd.example.toml"
|
|
406
|
+
if example.exists():
|
|
407
|
+
shutil.copyfile(example, path)
|
|
408
|
+
else:
|
|
409
|
+
path.write_text("[output]\ndirectory = \"output\"\n", encoding="utf-8")
|
|
410
|
+
typer.echo(f"Created config: {path}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@app.command()
|
|
414
|
+
def doctor(
|
|
415
|
+
config_path: Optional[Path] = typer.Option(None, "--config", help="Config file path."),
|
|
416
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory to check."),
|
|
417
|
+
):
|
|
418
|
+
report = build_doctor_report(config_path=config_path, output_dir=output)
|
|
419
|
+
typer.echo(render_doctor_report(report), nl=False)
|
|
420
|
+
if not report["ok"]:
|
|
421
|
+
typer.echo("MagicMD doctor found issues.", err=True)
|
|
422
|
+
raise typer.Exit(1)
|
magicmd/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from magicmd.platforms.registry import platform_adapters
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OutputConfig(BaseModel):
|
|
12
|
+
directory: str = "output"
|
|
13
|
+
overwrite: bool = False
|
|
14
|
+
save_debug_html: str = "on_failure"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MarkdownConfig(BaseModel):
|
|
18
|
+
template: str = "default"
|
|
19
|
+
front_matter: str = "yaml"
|
|
20
|
+
include_source_block: bool = True
|
|
21
|
+
heading_offset: int = 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImagesConfig(BaseModel):
|
|
25
|
+
download: bool = True
|
|
26
|
+
directory: str = "images"
|
|
27
|
+
filename_pattern: str = "img_{index:03d}.{ext}"
|
|
28
|
+
concurrency: int = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FetchConfig(BaseModel):
|
|
32
|
+
timeout_seconds: int = 20
|
|
33
|
+
browser_timeout_seconds: int = 15
|
|
34
|
+
browser_attempts: int = 2
|
|
35
|
+
user_agent: str = "default"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PlatformConfig(BaseModel):
|
|
39
|
+
enabled: bool = True
|
|
40
|
+
browser: str = "http"
|
|
41
|
+
wait_selector: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MagicMDConfig(BaseModel):
|
|
45
|
+
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
46
|
+
markdown: MarkdownConfig = Field(default_factory=MarkdownConfig)
|
|
47
|
+
images: ImagesConfig = Field(default_factory=ImagesConfig)
|
|
48
|
+
fetch: FetchConfig = Field(default_factory=FetchConfig)
|
|
49
|
+
platforms: dict[str, PlatformConfig] = Field(
|
|
50
|
+
default_factory=lambda: {
|
|
51
|
+
adapter.name: PlatformConfig(
|
|
52
|
+
browser=adapter.default_browser,
|
|
53
|
+
wait_selector=adapter.default_wait_selector,
|
|
54
|
+
)
|
|
55
|
+
for adapter in platform_adapters()
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
61
|
+
merged = dict(base)
|
|
62
|
+
for key, value in override.items():
|
|
63
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
64
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
65
|
+
else:
|
|
66
|
+
merged[key] = value
|
|
67
|
+
return merged
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_config(path: str | Path | None = None) -> MagicMDConfig:
|
|
71
|
+
default = MagicMDConfig().model_dump()
|
|
72
|
+
if not path:
|
|
73
|
+
return MagicMDConfig.model_validate(default)
|
|
74
|
+
config_path = Path(path)
|
|
75
|
+
if not config_path.exists():
|
|
76
|
+
return MagicMDConfig.model_validate(default)
|
|
77
|
+
loaded = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
78
|
+
return MagicMDConfig.model_validate(_deep_merge(default, loaded))
|