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 +0 -0
- jmcomic_ai/cli.py +205 -0
- jmcomic_ai/core.py +374 -0
- jmcomic_ai/mcp/__init__.py +0 -0
- jmcomic_ai/mcp/server.py +137 -0
- jmcomic_ai/skills/__init__.py +0 -0
- jmcomic_ai/skills/jmcomic/SKILL.md +125 -0
- jmcomic_ai/skills/jmcomic/option_schema.json +616 -0
- jmcomic_ai/skills/jmcomic/resources/examples.md +352 -0
- jmcomic_ai/skills/jmcomic/resources/reference.md +335 -0
- jmcomic_ai/skills/jmcomic/scripts/usage_example.py +14 -0
- jmcomic_ai/skills/manager.py +90 -0
- jmcomic_ai-0.0.1.dist-info/METADATA +262 -0
- jmcomic_ai-0.0.1.dist-info/RECORD +17 -0
- jmcomic_ai-0.0.1.dist-info/WHEEL +4 -0
- jmcomic_ai-0.0.1.dist-info/entry_points.txt +3 -0
- jmcomic_ai-0.0.1.dist-info/licenses/LICENSE +21 -0
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
|