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 ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+
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"![{match.group(1)}]({image.local_path})", 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))
magicmd/detect.py ADDED
@@ -0,0 +1,8 @@
1
+ from urllib.parse import urlparse
2
+
3
+ from magicmd.platforms.registry import match_platform_by_host
4
+
5
+
6
+ def detect_platform(url: str) -> str:
7
+ host = urlparse(url).netloc.lower()
8
+ return match_platform_by_host(host)