jmcomic-ai 0.0.1__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.
jmcomic_ai/__init__.py ADDED
File without changes
jmcomic_ai/cli.py ADDED
@@ -0,0 +1,205 @@
1
+ from enum import Enum
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from jmcomic_ai.core import JmcomicService
7
+
8
+ app = typer.Typer(
9
+ name="jmcomic-ai",
10
+ help="JMComic AI Agent Interface",
11
+ add_completion=False,
12
+ )
13
+
14
+
15
+ class TransportType(str, Enum):
16
+ stdio = "stdio" # Standard I/O (for local subprocess mode)
17
+ sse = "sse" # Server-Sent Events (recommended for Claude Desktop)
18
+ http = "http" # streamable_http
19
+
20
+
21
+ @app.command()
22
+ def mcp(
23
+ transport: TransportType = typer.Argument(
24
+ TransportType.sse, help="Transport mode: 'sse' (default), 'stdio', or 'http'"
25
+ ),
26
+ option: Path | None = typer.Option(None, "--option", help="Path to jmcomic option file"),
27
+ port: int = typer.Option(8000, help="Port for server (ignored for stdio)"),
28
+ host: str = typer.Option("127.0.0.1", help="Host for server (ignored for stdio)"),
29
+ ):
30
+ """
31
+ Start the MCP Server.
32
+
33
+ Transport modes:
34
+ - sse: Server-Sent Events (default, recommended for Claude Desktop)
35
+ - stdio: Standard Input/Output (for local subprocess mode)
36
+ - http: Streamable HTTP (for production deployments, horizontal scaling)
37
+
38
+ Note: SSE transport is deprecated in MCP spec, but still supported by Claude Desktop.
39
+ Consider using 'http' (Streamable HTTP) for new deployments.
40
+ """
41
+ # Defer import to avoid circular dependency or early loading
42
+ from jmcomic_ai.mcp.server import run_server
43
+
44
+ # Initialize service to check/ensure option availability
45
+ # We pass str(option) because JmcomicService expects str or None
46
+ service = JmcomicService(str(option) if option else None)
47
+
48
+ transport_value: str = transport.value
49
+ typer.echo(f"Starting MCP Server ({transport_value}) using option: {service.option_path}", err=True)
50
+
51
+ # Print configuration hint
52
+ import json
53
+
54
+ if transport == TransportType.stdio:
55
+ config = {"mcpServers": {"jmcomic-ai": {"command": "jmai", "args": ["mcp", "stdio"]}}}
56
+ typer.echo("\n--- Claude Desktop Config (stdio) ---", err=True)
57
+ typer.echo(json.dumps(config, indent=2), err=True)
58
+ typer.echo("--------------------------------------\n", err=True)
59
+
60
+ elif transport == TransportType.sse:
61
+ config = {"mcpServers": {"jmcomic-ai": {"url": f"http://{host}:{port}/sse"}}}
62
+ typer.echo("\n--- Claude Desktop Config (SSE) ---", err=True)
63
+ typer.echo(json.dumps(config, indent=2), err=True)
64
+ typer.echo("Note: SSE is deprecated in MCP spec but supported by Claude Desktop.", err=True)
65
+ typer.echo("-----------------------------------\n", err=True)
66
+
67
+ elif transport == TransportType.http:
68
+ config = {"mcpServers": {"jmcomic-ai": {"url": f"http://{host}:{port}/mcp"}}}
69
+ typer.echo("\n--- Claude Desktop Config (Streamable HTTP) ---", err=True)
70
+ typer.echo(json.dumps(config, indent=2), err=True)
71
+ typer.echo("-------------------------------------------\n", err=True)
72
+
73
+ run_server(transport_value, service, host=host, port=port)
74
+
75
+
76
+ skills_app = typer.Typer(name="skills", help="Manage generic skills resources")
77
+ app.add_typer(skills_app, name="skills")
78
+
79
+
80
+ @skills_app.command("install")
81
+ def install_skills(
82
+ target_dir: Path | None = typer.Argument(
83
+ None, help="Directory to install skills into. Defaults to ~/.claude/skills/jmcomic"
84
+ ),
85
+ force: bool = typer.Option(False, "--force", "-f", help="Force overwrite existing files"),
86
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
87
+ ):
88
+ """
89
+ Install built-in skill definitions (SKILL.md, etc.) to a directory.
90
+ """
91
+ from jmcomic_ai.skills.manager import SkillManager
92
+
93
+ manager = SkillManager()
94
+
95
+ if target_dir is None:
96
+ # Default to ~/.claude/skills/jmcomic (Standard for Claude Desktop)
97
+ target_dir = Path.home() / ".claude" / "skills" / "jmcomic"
98
+
99
+ typer.echo(f"Target directory: {target_dir.resolve()}")
100
+
101
+ # 1. Confirmation (unless -y is passed)
102
+ if not yes:
103
+ if not typer.confirm("Proceed with installation?", default=True):
104
+ typer.echo("Installation cancelled.")
105
+ return
106
+
107
+ if not target_dir.exists():
108
+ target_dir.mkdir(parents=True, exist_ok=True)
109
+
110
+ # 2. Duplicate Check / Conflict Handing
111
+ if force:
112
+ manager.install(target_dir, overwrite=True)
113
+ else:
114
+ if manager.has_conflicts(target_dir):
115
+ typer.echo("Warning: Some skill files already exist in the target directory.")
116
+ if yes or typer.confirm("Overwrite existing files?"):
117
+ manager.install(target_dir, overwrite=True)
118
+ else:
119
+ typer.echo("Skipping existing files.")
120
+ manager.install(target_dir, overwrite=False)
121
+ else:
122
+ manager.install(target_dir)
123
+
124
+ typer.echo("Skills installed successfully.")
125
+
126
+
127
+ @skills_app.command("uninstall")
128
+ def uninstall_skills(
129
+ target_dir: Path | None = typer.Argument(
130
+ None, help="Directory to uninstall skills from. Defaults to ~/.claude/skills/jmcomic"
131
+ ),
132
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
133
+ ):
134
+ """
135
+ Uninstall skills from the directory.
136
+ """
137
+ from jmcomic_ai.skills.manager import SkillManager
138
+
139
+ manager = SkillManager()
140
+
141
+ if target_dir is None:
142
+ # Default to ~/.claude/skills/jmcomic
143
+ target_dir = Path.home() / ".claude" / "skills" / "jmcomic"
144
+
145
+ if not target_dir.exists():
146
+ typer.echo(f"Target directory {target_dir} does not exist.")
147
+ return
148
+
149
+ if yes or typer.confirm(f"Are you sure you want to delete skills from {target_dir}?", default=True):
150
+ manager.uninstall(target_dir)
151
+ typer.echo("Skills uninstalled successfully.")
152
+
153
+
154
+ # Option 命令组
155
+ option_app = typer.Typer(name="option", help="Manage jmcomic option (configuration)")
156
+ app.add_typer(option_app, name="option")
157
+
158
+
159
+ @option_app.command("show")
160
+ def option_show():
161
+ """Show current option file path and content"""
162
+ service = JmcomicService()
163
+ typer.echo(f"Option file: {service.option_path}")
164
+ typer.echo("---")
165
+ if service.option_path.exists():
166
+ typer.echo(service.option_path.read_text(encoding="utf-8"))
167
+ else:
168
+ typer.echo("Option file does not exist yet.")
169
+
170
+
171
+ @option_app.command("path")
172
+ def option_path():
173
+ """Print option file path"""
174
+ service = JmcomicService()
175
+ typer.echo(service.option_path)
176
+
177
+
178
+ @option_app.command("edit")
179
+ def option_edit():
180
+ """Open option file in default editor"""
181
+ import platform
182
+ import subprocess
183
+
184
+ service = JmcomicService()
185
+ path = str(service.option_path)
186
+
187
+ if not service.option_path.exists():
188
+ typer.echo(f"Option file does not exist: {path}")
189
+ typer.echo("It will be created when you first use the service.")
190
+ return
191
+
192
+ try:
193
+ if platform.system() == "Windows":
194
+ subprocess.run(["notepad", path])
195
+ elif platform.system() == "Darwin":
196
+ subprocess.run(["open", "-e", path])
197
+ else:
198
+ subprocess.run(["xdg-open", path])
199
+ except Exception as e:
200
+ typer.echo(f"Failed to open editor: {e}")
201
+ typer.echo(f"Please manually edit: {path}")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ app()
jmcomic_ai/core.py ADDED
@@ -0,0 +1,374 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from jmcomic import (
7
+ JmAlbumDetail,
8
+ JmCategoryPage,
9
+ JmcomicClient,
10
+ JmcomicText,
11
+ JmMagicConstants,
12
+ JmModuleConfig,
13
+ JmOption,
14
+ JmPageContent,
15
+ JmSearchPage,
16
+ create_option_by_file,
17
+ )
18
+
19
+
20
+ class JmcomicService:
21
+ ENV_OPTION_PATH = "JM_OPTION_PATH"
22
+ DEFAULT_OPTION_PATH = Path.home() / ".jmcomic" / "option.yml"
23
+
24
+ def __init__(self, option_path: str | None = None):
25
+ self.option_path = self._resolve_option_path(option_path)
26
+ self.option = self._load_option()
27
+ self.client = self.option.build_jm_client()
28
+ self._setup_logging()
29
+ self._ensure_init()
30
+
31
+ def _resolve_option_path(self, cli_path: str | None) -> Path:
32
+ # 1. CLI Argument
33
+ if cli_path:
34
+ return Path(cli_path).resolve()
35
+
36
+ # 2. Environment Variable
37
+ env_path = os.getenv(self.ENV_OPTION_PATH)
38
+ if env_path:
39
+ return Path(env_path).resolve()
40
+
41
+ # 3. Default Path
42
+ return self.DEFAULT_OPTION_PATH
43
+
44
+ def _load_option(self) -> JmOption:
45
+ if not self.option_path.exists():
46
+ # Generate default if not exists
47
+ self.option_path.parent.mkdir(parents=True, exist_ok=True)
48
+ default_option = JmModuleConfig.option_class().default()
49
+ default_option.to_file(str(self.option_path))
50
+ return default_option
51
+
52
+ return create_option_by_file(str(self.option_path))
53
+
54
+ def _ensure_init(self):
55
+ """Ensure necessary initialization"""
56
+ pass
57
+
58
+ def _setup_logging(self):
59
+ """Setup logging to file in current working directory"""
60
+ log_file = Path.cwd() / "jmcomic_ai.log"
61
+ print(f"[*] Logging to file: {log_file}")
62
+
63
+ logging.basicConfig(
64
+ filename=str(log_file), level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
65
+ )
66
+ self.logger = logging.getLogger("jmcomic_ai")
67
+ self.logger.info(f"Logging initialized. Log file: {log_file}")
68
+
69
+ def reload_option(self):
70
+ """
71
+ [not a tool]
72
+ """
73
+ self.option = self._load_option()
74
+
75
+ def update_option(self, option_updates: dict[str, Any]) -> str:
76
+ """
77
+ Update JMComic option and save to file.
78
+
79
+ This tool updates the JMComic option with the provided settings,
80
+ validates the new option, and persists it to the option file.
81
+
82
+ Args:
83
+ option_updates: Dictionary containing option updates to merge.
84
+ Supports nested updates for client, download, dir_rule, etc.
85
+
86
+ Returns:
87
+ Success message with file path, or error message if validation fails.
88
+
89
+ Example:
90
+ option_updates = {
91
+ "client": {"impl": "api"},
92
+ "download": {"threading": {"image": 50}}
93
+ }
94
+ """
95
+ try:
96
+ # 1. 获取当前配置
97
+ current_option = self.option.deconstruct()
98
+
99
+ # 2. 合并配置
100
+ merged_option = JmOption.merge_default_dict(option_updates, current_option)
101
+
102
+ # 3. 验证配置(construct 会校验)
103
+ new_option = JmOption.construct(merged_option)
104
+
105
+ # 4. 保存到文件
106
+ new_option.to_file(str(self.option_path))
107
+
108
+ # 5. 更新内存中的 option
109
+ self.option = new_option
110
+
111
+ self.logger.info("option updated successfully")
112
+ return f"option updated and saved to {self.option_path}"
113
+
114
+ except Exception as e:
115
+ self.logger.error(f"option update failed: {str(e)}")
116
+ return f"option update failed: {str(e)}"
117
+
118
+ def get_client(self) -> JmcomicClient:
119
+ """
120
+ [not a tool]
121
+ """
122
+ return self.client
123
+
124
+ # --- Data Conversion Helper Methods ---
125
+
126
+ def _parse_search_page(self, page: JmPageContent) -> list[dict[str, Any]]:
127
+ """Parse JmSearchPage/JmCategoryPage content to dictionary list"""
128
+ results = []
129
+
130
+ # 使用 jmcomic 提供的迭代器获取 id, title, tags
131
+ for album_id, title, tags in page.iter_id_title_tag():
132
+ album_id = str(album_id)
133
+ results.append(
134
+ {
135
+ "id": album_id,
136
+ "title": str(title),
137
+ "tags": tags,
138
+ "cover_url": JmcomicText.get_album_cover_url(album_id),
139
+ }
140
+ )
141
+ return results
142
+
143
+ def _parse_album_detail(self, album: JmAlbumDetail) -> dict[str, Any]:
144
+ """Convert JmAlbumDetail object to dictionary"""
145
+ # Strictly use object attributes as defined in JmAlbumDetail source
146
+ return {
147
+ "id": str(album.album_id),
148
+ "title": str(album.name),
149
+ "author": str(album.author),
150
+ "likes": album.likes,
151
+ "views": album.views,
152
+ "category": "0", # JmAlbumDetail does not have a category field
153
+ "tags": album.tags,
154
+ "actors": album.actors,
155
+ "description": str(album.description),
156
+ "chapter_count": len(album),
157
+ "update_time": str(album.update_date),
158
+ "cover_url": JmcomicText.get_album_cover_url(album.album_id),
159
+ }
160
+
161
+ # --- Business Methods ---
162
+
163
+ def search_album(
164
+ self,
165
+ keyword: str,
166
+ page: int = 1,
167
+ main_tag: int = 0,
168
+ order_by: str = "latest",
169
+ time_range: str = "all",
170
+ category: str = "all",
171
+ ) -> list[dict[str, Any]]:
172
+ """
173
+ Search for albums/comics with advanced filtering options.
174
+
175
+ Args:
176
+ keyword: Search query string (supports album ID, title, author, tags, etc.)
177
+ page: Page number, starting from 1 (default: 1)
178
+ main_tag: Search scope - 0 (站内), 1 (作品), 2 (作者), 3 (标签), 4 (角色) (default: 0)
179
+ order_by: Sort order - 网页: mr (最新), mv (观看), mp (图片), tf (点赞); API: time, views, likes (default: "latest")
180
+ time_range: Time filter - all (全部), today (今天), week (本周), month (本月) (default: "all")
181
+ category: Category filter - "all" or specific category CID (default: "all")
182
+
183
+ Returns:
184
+ List of album dictionaries, each containing: id, title, tags, cover_url.
185
+ """
186
+ client = self.get_client()
187
+
188
+ # Call core search method
189
+ search_page: JmSearchPage = client.search(
190
+ keyword,
191
+ page=page,
192
+ main_tag=main_tag,
193
+ order_by=order_by,
194
+ time=time_range,
195
+ category=category,
196
+ sub_category=None,
197
+ )
198
+
199
+ self.logger.info(f"Search finished: keyword={keyword}, results={len(search_page)}")
200
+ return self._parse_search_page(search_page)
201
+
202
+ def get_ranking(self, period: str = "day", page: int = 1) -> list[dict[str, Any]]:
203
+ """
204
+ Get trending/popular albums from ranking lists.
205
+
206
+ Args:
207
+ period: Ranking period - "day" (日榜), "week" (周榜), "month" (月榜) (default: "day")
208
+ page: Page number, starting from 1 (default: 1)
209
+
210
+ Returns:
211
+ List of ranked album dictionaries: id, title, tags, cover_url.
212
+ """
213
+ client = self.get_client()
214
+ search_page: JmCategoryPage
215
+ if period == "day":
216
+ search_page = client.day_ranking(page)
217
+ elif period == "week":
218
+ search_page = client.week_ranking(page)
219
+ elif period == "month":
220
+ search_page = client.month_ranking(page)
221
+ else:
222
+ return []
223
+
224
+ return self._parse_search_page(search_page)
225
+
226
+ async def download_album(self, album_id: str) -> str:
227
+ """
228
+ Download an entire album/comic in the background.
229
+
230
+ This is a non-blocking operation that starts the download task asynchronously.
231
+ The actual download happens in the background and logs progress to jmcomic_ai.log.
232
+
233
+ Args:
234
+ album_id: The album ID to download (e.g., "123456")
235
+
236
+ Returns:
237
+ Confirmation message that download has started.
238
+
239
+ Note:
240
+ Download location is determined by the dir_rule option.
241
+ Check jmcomic_ai.log for download progress and completion status.
242
+ """
243
+ import asyncio
244
+
245
+ def _bg_download():
246
+ try:
247
+ self.logger.info(f"Starting download for album {album_id}")
248
+ self.option.download_album(album_id)
249
+ self.logger.info(f"Download completed for album {album_id}")
250
+ except Exception as e:
251
+ self.logger.error(f"Download failed for album {album_id}: {str(e)}")
252
+
253
+ # 使用 asyncio.create_task 将下载任务提交到后台执行
254
+ asyncio.create_task(asyncio.to_thread(_bg_download))
255
+
256
+ return f"Download started for album {album_id} (Background Task)"
257
+
258
+ def download_photo(self, photo_id: str) -> str:
259
+ """
260
+ Download a specific chapter/photo from an album.
261
+
262
+ Args:
263
+ photo_id: The chapter/photo ID to download (e.g., "123456")
264
+
265
+ Returns:
266
+ Confirmation message that download has started.
267
+ """
268
+ self.option.download_photo(photo_id)
269
+ return f"Download started for photo {photo_id}"
270
+
271
+ def login(self, username: str, password: str) -> str:
272
+ """
273
+ Authenticate with JMComic account to access premium features.
274
+
275
+ Login is required to access favorite lists, premium content, and user-specific features.
276
+ Session cookies are automatically saved for subsequent requests.
277
+
278
+ Args:
279
+ username: JMComic account username
280
+ password: JMComic account password
281
+
282
+ Returns:
283
+ Success message with username, or error message if login fails.
284
+ """
285
+ client = self.get_client()
286
+ try:
287
+ client.login(username, password)
288
+ self.logger.info(f"Successfully logged in as {username}")
289
+ return f"Successfully logged in as {username}"
290
+ except Exception as e:
291
+ self.logger.error(f"Login failed for {username}: {str(e)}")
292
+ return f"Login failed: {str(e)}"
293
+
294
+ def get_album_detail(self, album_id: str) -> dict[str, Any]:
295
+ """
296
+ Retrieve comprehensive details about a specific album/comic.
297
+
298
+ Args:
299
+ album_id: The album ID (e.g., "123456")
300
+
301
+ Returns:
302
+ Album dictionary containing: id, title, author, likes, views, category,
303
+ tags, actors, description, chapter_count, update_time, cover_url.
304
+ """
305
+ client = self.get_client()
306
+ album = client.get_album_detail(album_id)
307
+ return self._parse_album_detail(album)
308
+
309
+ def get_category_list(
310
+ self,
311
+ category: str = JmMagicConstants.CATEGORY_ALL,
312
+ page: int = 1,
313
+ sort_by: str = JmMagicConstants.ORDER_BY_LATEST,
314
+ ) -> list[dict[str, Any]]:
315
+ """
316
+ Browse albums by category with sorting options.
317
+
318
+ Args:
319
+ category: Category filter. Available categories:
320
+ - "0" or CATEGORY_ALL: 全部 (All)
321
+ - "doujin" or CATEGORY_DOUJIN: 同人 (Doujin)
322
+ - "single" or CATEGORY_SINGLE: 单本 (Single Volume)
323
+ - "short" or CATEGORY_SHORT: 短篇 (Short Story)
324
+ - "another" or CATEGORY_ANOTHER: 其他 (Other)
325
+ - "hanman" or CATEGORY_HANMAN: 韩漫 (Korean Comics)
326
+ - "meiman" or CATEGORY_MEIMAN: 美漫 (American Comics)
327
+ - "doujin_cosplay" or CATEGORY_DOUJIN_COSPLAY: Cosplay
328
+ - "3D" or CATEGORY_3D: 3D
329
+ - "english_site" or CATEGORY_ENGLISH_SITE: 英文站 (English Site)
330
+ (default: "0")
331
+ page: Page number, starting from 1 (default: 1)
332
+ sort_by: Sort order. Available options:
333
+ - "mr" or ORDER_BY_LATEST: 最新 (Latest)
334
+ - "mv" or ORDER_BY_VIEW: 观看数 (Most Viewed)
335
+ - "mp" or ORDER_BY_PICTURE: 图片数 (Most Pictures)
336
+ - "tf" or ORDER_BY_LIKE: 点赞数 (Most Liked)
337
+ (default: "mr")
338
+
339
+ Returns:
340
+ List of album dictionaries matching the category and sort criteria.
341
+ """
342
+ client = self.get_client()
343
+
344
+ search_page: JmCategoryPage = client.categories_filter(
345
+ page=page, time=JmMagicConstants.TIME_ALL, category=category, order_by=sort_by, sub_category=None
346
+ )
347
+ return self._parse_search_page(search_page)
348
+
349
+ def download_cover(self, album_id: str) -> str:
350
+ """
351
+ Download the cover image of a specific album.
352
+
353
+ The cover image is saved to the 'covers' subdirectory within the configured base directory.
354
+
355
+ Args:
356
+ album_id: The album ID (e.g., "123456")
357
+
358
+ Returns:
359
+ Success message with the saved file path.
360
+ """
361
+ client = self.get_client()
362
+ # Verify album exists
363
+ client.get_album_detail(album_id)
364
+
365
+ # 使用 .base_dir 属性而非 .get() 方法
366
+ cover_dir = Path(self.option.dir_rule.base_dir) / "covers"
367
+ cover_dir.mkdir(parents=True, exist_ok=True)
368
+ cover_path = cover_dir / f"{album_id}.jpg"
369
+
370
+ # 确保路径是字符串类型传递给 download_album_cover
371
+ client.download_album_cover(album_id, str(cover_path))
372
+
373
+ self.logger.info(f"Cover downloaded for album {album_id} to {cover_path}")
374
+ return f"Cover downloaded to {cover_path}"
File without changes