moviefinder-cli 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.
- moviefinder_cli/__init__.py +5 -0
- moviefinder_cli/__main__.py +5 -0
- moviefinder_cli/cache.py +125 -0
- moviefinder_cli/cli.py +82 -0
- moviefinder_cli/markdown.py +129 -0
- moviefinder_cli/mcp/__init__.py +1 -0
- moviefinder_cli/mcp/server.py +143 -0
- moviefinder_cli/models.py +53 -0
- moviefinder_cli/rrdynb.py +329 -0
- moviefinder_cli/server.py +78 -0
- moviefinder_cli/service.py +182 -0
- moviefinder_cli-0.1.0.dist-info/METADATA +211 -0
- moviefinder_cli-0.1.0.dist-info/RECORD +16 -0
- moviefinder_cli-0.1.0.dist-info/WHEEL +4 -0
- moviefinder_cli-0.1.0.dist-info/entry_points.txt +3 -0
- moviefinder_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
moviefinder_cli/cache.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqliteCache:
|
|
9
|
+
def __init__(self, db_path: str = "data/moviefinder.sqlite3") -> None:
|
|
10
|
+
self.db_path = Path(db_path)
|
|
11
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
self._ensure_schema()
|
|
13
|
+
|
|
14
|
+
def _connect(self) -> sqlite3.Connection:
|
|
15
|
+
connection = sqlite3.connect(str(self.db_path))
|
|
16
|
+
connection.row_factory = sqlite3.Row
|
|
17
|
+
return connection
|
|
18
|
+
|
|
19
|
+
def _ensure_schema(self) -> None:
|
|
20
|
+
with self._connect() as connection:
|
|
21
|
+
connection.execute(
|
|
22
|
+
"""
|
|
23
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
24
|
+
namespace TEXT NOT NULL,
|
|
25
|
+
cache_key TEXT NOT NULL,
|
|
26
|
+
payload TEXT NOT NULL,
|
|
27
|
+
created_at REAL NOT NULL,
|
|
28
|
+
expires_at REAL NOT NULL,
|
|
29
|
+
PRIMARY KEY (namespace, cache_key)
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
)
|
|
33
|
+
connection.execute(
|
|
34
|
+
"""
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at
|
|
36
|
+
ON cache_entries(expires_at)
|
|
37
|
+
"""
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def get_json(self, namespace: str, cache_key: str) -> Tuple[Optional[Any], bool]:
|
|
41
|
+
payload, hit, _expired = self.get_json_with_meta(namespace, cache_key)
|
|
42
|
+
return payload, hit
|
|
43
|
+
|
|
44
|
+
def get_json_with_meta(
|
|
45
|
+
self, namespace: str, cache_key: str, allow_expired: bool = False
|
|
46
|
+
) -> Tuple[Optional[Any], bool, bool]:
|
|
47
|
+
now = time.time()
|
|
48
|
+
with self._connect() as connection:
|
|
49
|
+
row = connection.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT payload, expires_at
|
|
52
|
+
FROM cache_entries
|
|
53
|
+
WHERE namespace = ? AND cache_key = ?
|
|
54
|
+
""",
|
|
55
|
+
(namespace, cache_key),
|
|
56
|
+
).fetchone()
|
|
57
|
+
if row is None:
|
|
58
|
+
return None, False, False
|
|
59
|
+
expired = float(row["expires_at"]) < now
|
|
60
|
+
if expired and not allow_expired:
|
|
61
|
+
return None, False, True
|
|
62
|
+
return json.loads(row["payload"]), True, expired
|
|
63
|
+
|
|
64
|
+
def set_json(
|
|
65
|
+
self, namespace: str, cache_key: str, value: Any, ttl_seconds: int
|
|
66
|
+
) -> None:
|
|
67
|
+
now = time.time()
|
|
68
|
+
with self._connect() as connection:
|
|
69
|
+
connection.execute(
|
|
70
|
+
"""
|
|
71
|
+
INSERT INTO cache_entries
|
|
72
|
+
(namespace, cache_key, payload, created_at, expires_at)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?)
|
|
74
|
+
ON CONFLICT(namespace, cache_key) DO UPDATE SET
|
|
75
|
+
payload = excluded.payload,
|
|
76
|
+
created_at = excluded.created_at,
|
|
77
|
+
expires_at = excluded.expires_at
|
|
78
|
+
""",
|
|
79
|
+
(
|
|
80
|
+
namespace,
|
|
81
|
+
cache_key,
|
|
82
|
+
json.dumps(value, ensure_ascii=False),
|
|
83
|
+
now,
|
|
84
|
+
now + ttl_seconds,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def iter_json(
|
|
89
|
+
self, namespace: str, allow_expired: bool = False
|
|
90
|
+
) -> List[Tuple[str, Any, bool]]:
|
|
91
|
+
now = time.time()
|
|
92
|
+
with self._connect() as connection:
|
|
93
|
+
rows = connection.execute(
|
|
94
|
+
"""
|
|
95
|
+
SELECT cache_key, payload, expires_at
|
|
96
|
+
FROM cache_entries
|
|
97
|
+
WHERE namespace = ?
|
|
98
|
+
ORDER BY created_at DESC
|
|
99
|
+
""",
|
|
100
|
+
(namespace,),
|
|
101
|
+
).fetchall()
|
|
102
|
+
|
|
103
|
+
entries: List[Tuple[str, Any, bool]] = []
|
|
104
|
+
for row in rows:
|
|
105
|
+
expired = float(row["expires_at"]) < now
|
|
106
|
+
if expired and not allow_expired:
|
|
107
|
+
continue
|
|
108
|
+
entries.append((str(row["cache_key"]), json.loads(row["payload"]), expired))
|
|
109
|
+
return entries
|
|
110
|
+
|
|
111
|
+
def clear(self) -> None:
|
|
112
|
+
with self._connect() as connection:
|
|
113
|
+
connection.execute("DELETE FROM cache_entries")
|
|
114
|
+
|
|
115
|
+
def stats(self) -> Dict[str, int]:
|
|
116
|
+
with self._connect() as connection:
|
|
117
|
+
rows = connection.execute(
|
|
118
|
+
"""
|
|
119
|
+
SELECT namespace, COUNT(*) AS count
|
|
120
|
+
FROM cache_entries
|
|
121
|
+
GROUP BY namespace
|
|
122
|
+
ORDER BY namespace
|
|
123
|
+
"""
|
|
124
|
+
).fetchall()
|
|
125
|
+
return {str(row["namespace"]): int(row["count"]) for row in rows}
|
moviefinder_cli/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from .markdown import (
|
|
7
|
+
render_cache_stats_markdown,
|
|
8
|
+
render_error_markdown,
|
|
9
|
+
render_search_markdown,
|
|
10
|
+
)
|
|
11
|
+
from .server import run_server
|
|
12
|
+
from .service import MovieFinderService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _add_output_format(parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--format",
|
|
18
|
+
choices=("markdown", "json"),
|
|
19
|
+
default="markdown",
|
|
20
|
+
help="Output format. Defaults to markdown.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="moviefinder")
|
|
26
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
|
|
28
|
+
search_parser = subparsers.add_parser("search", help="Search rrdynb resources")
|
|
29
|
+
search_parser.add_argument("keyword")
|
|
30
|
+
search_parser.add_argument("--limit", type=int, default=10)
|
|
31
|
+
search_parser.add_argument("--refresh", action="store_true")
|
|
32
|
+
_add_output_format(search_parser)
|
|
33
|
+
|
|
34
|
+
cache_parser = subparsers.add_parser(
|
|
35
|
+
"cache-stats", help="Show SQLite cache entry counts"
|
|
36
|
+
)
|
|
37
|
+
_add_output_format(cache_parser)
|
|
38
|
+
|
|
39
|
+
serve_parser = subparsers.add_parser("serve", help="Start HTTP API")
|
|
40
|
+
serve_parser.add_argument("--host", default="127.0.0.1")
|
|
41
|
+
serve_parser.add_argument("--port", type=int, default=8000)
|
|
42
|
+
|
|
43
|
+
args = parser.parse_args()
|
|
44
|
+
|
|
45
|
+
if args.command == "serve":
|
|
46
|
+
run_server(args.host, args.port)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
service = MovieFinderService()
|
|
51
|
+
if args.command == "search":
|
|
52
|
+
payload = service.search_movies(args.keyword, args.limit, args.refresh)
|
|
53
|
+
elif args.command == "cache-stats":
|
|
54
|
+
payload = {"cache": service.cache_stats()}
|
|
55
|
+
else:
|
|
56
|
+
parser.error("unknown command")
|
|
57
|
+
return
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
print(_format_error(str(exc), args.format), file=sys.stderr)
|
|
60
|
+
raise SystemExit(1)
|
|
61
|
+
|
|
62
|
+
print(_format_payload(args.command, payload, args.format))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_payload(command: str, payload: Dict[str, Any], output_format: str) -> str:
|
|
66
|
+
if output_format == "json":
|
|
67
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
68
|
+
if command == "search":
|
|
69
|
+
return render_search_markdown(payload)
|
|
70
|
+
if command == "cache-stats":
|
|
71
|
+
return render_cache_stats_markdown(payload)
|
|
72
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_error(message: str, output_format: str) -> str:
|
|
76
|
+
if output_format == "json":
|
|
77
|
+
return json.dumps({"error": message}, ensure_ascii=False, indent=2)
|
|
78
|
+
return render_error_markdown(message)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, List
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def render_search_markdown(payload: Dict[str, Any]) -> str:
|
|
5
|
+
lines: List[str] = []
|
|
6
|
+
keyword = payload.get("keyword") or ""
|
|
7
|
+
items = payload.get("items") or []
|
|
8
|
+
cache = payload.get("cache") or {}
|
|
9
|
+
|
|
10
|
+
lines.append(f"# 搜索结果:{_escape_heading(keyword)}")
|
|
11
|
+
lines.append("")
|
|
12
|
+
cache_label = "过期缓存兜底" if cache.get("stale") else ("命中" if cache.get("hit") else "未命中")
|
|
13
|
+
lines.extend(
|
|
14
|
+
[
|
|
15
|
+
f"- 来源:{_plain(payload.get('source'))}",
|
|
16
|
+
f"- 搜索页:{_link(payload.get('source_url'))}",
|
|
17
|
+
f"- 结果数:{payload.get('total', len(items))}",
|
|
18
|
+
f"- 缓存:{cache_label}",
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
if cache.get("warning"):
|
|
22
|
+
lines.append(f"- 警告:{_plain(cache.get('warning'))}")
|
|
23
|
+
|
|
24
|
+
errors = payload.get("errors") or []
|
|
25
|
+
if errors:
|
|
26
|
+
lines.append(f"- 详情解析错误:{len(errors)} 条")
|
|
27
|
+
|
|
28
|
+
if not items:
|
|
29
|
+
lines.append("")
|
|
30
|
+
lines.append("没有搜索到结果。")
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
|
|
33
|
+
for index, item in enumerate(items, start=1):
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append(f"## {index}. {_escape_heading(item.get('title') or '未命名影片')}")
|
|
36
|
+
lines.append("")
|
|
37
|
+
lines.extend(
|
|
38
|
+
_table(
|
|
39
|
+
["字段", "值"],
|
|
40
|
+
[
|
|
41
|
+
("分类", item.get("category")),
|
|
42
|
+
("发布日期", item.get("published_at")),
|
|
43
|
+
("豆瓣评分", item.get("douban_score")),
|
|
44
|
+
("IMDB 评分", item.get("imdb_score")),
|
|
45
|
+
("IMDb ID", item.get("imdb_id")),
|
|
46
|
+
("详情页", _link(item.get("detail_url"))),
|
|
47
|
+
("海报", _link(item.get("poster_url"))),
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
summary = item.get("summary")
|
|
53
|
+
if summary:
|
|
54
|
+
lines.append("")
|
|
55
|
+
lines.append("### 简介")
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.append(_paragraph(summary))
|
|
58
|
+
|
|
59
|
+
resources = item.get("resources") or []
|
|
60
|
+
lines.append("")
|
|
61
|
+
lines.append("### 网盘资源")
|
|
62
|
+
lines.append("")
|
|
63
|
+
if resources:
|
|
64
|
+
rows = []
|
|
65
|
+
for resource in resources:
|
|
66
|
+
rows.append(
|
|
67
|
+
(
|
|
68
|
+
resource.get("platform"),
|
|
69
|
+
_link(resource.get("url")),
|
|
70
|
+
resource.get("access_code"),
|
|
71
|
+
resource.get("url_password"),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
lines.extend(_table(["平台", "地址", "提取码", "URL 密码"], rows))
|
|
75
|
+
else:
|
|
76
|
+
lines.append("未解析到网盘资源。")
|
|
77
|
+
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_cache_stats_markdown(payload: Dict[str, Any]) -> str:
|
|
82
|
+
cache = payload.get("cache") or {}
|
|
83
|
+
lines = ["# 缓存统计", ""]
|
|
84
|
+
if not cache:
|
|
85
|
+
lines.append("暂无缓存记录。")
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
rows = [(namespace, count) for namespace, count in sorted(cache.items())]
|
|
88
|
+
lines.extend(_table(["命名空间", "数量"], rows))
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_error_markdown(message: str) -> str:
|
|
93
|
+
return "\n".join(["# 错误", "", _paragraph(message)])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _table(headers: List[str], rows: Iterable[Iterable[Any]]) -> List[str]:
|
|
97
|
+
table = [
|
|
98
|
+
"| " + " | ".join(_cell(header) for header in headers) + " |",
|
|
99
|
+
"| " + " | ".join("---" for _ in headers) + " |",
|
|
100
|
+
]
|
|
101
|
+
for row in rows:
|
|
102
|
+
table.append("| " + " | ".join(_cell(value) for value in row) + " |")
|
|
103
|
+
return table
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cell(value: Any) -> str:
|
|
107
|
+
text = _plain(value)
|
|
108
|
+
return text.replace("|", "\\|").replace("\n", "<br>")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _plain(value: Any) -> str:
|
|
112
|
+
if value is None or value == "":
|
|
113
|
+
return "-"
|
|
114
|
+
return str(value).strip()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _paragraph(value: Any) -> str:
|
|
118
|
+
return " ".join(_plain(value).split())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _escape_heading(value: Any) -> str:
|
|
122
|
+
return _plain(value).replace("#", "\\#").strip()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _link(url: Any) -> str:
|
|
126
|
+
value = _plain(url)
|
|
127
|
+
if value == "-":
|
|
128
|
+
return "-"
|
|
129
|
+
return f"[打开]({value})"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MovieFinder MCP integration."""
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""MovieFinder MCP server.
|
|
2
|
+
|
|
3
|
+
The server exposes rrdynb movie search to Agent clients through MCP.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from moviefinder_cli import __version__
|
|
14
|
+
from moviefinder_cli.markdown import (
|
|
15
|
+
render_cache_stats_markdown,
|
|
16
|
+
render_search_markdown,
|
|
17
|
+
)
|
|
18
|
+
from moviefinder_cli.service import MovieFinderService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP(
|
|
22
|
+
name="moviefinder",
|
|
23
|
+
instructions="""MovieFinder MCP - Search movie resources from rrdynb.
|
|
24
|
+
|
|
25
|
+
Tools available:
|
|
26
|
+
- search_movies(query, limit=5, refresh=False, output_format="markdown"): Search movies and return synopsis, ratings, detail URLs, and cloud-drive resource links.
|
|
27
|
+
- movie_cache_stats(output_format="markdown"): Show local SQLite cache counts.
|
|
28
|
+
|
|
29
|
+
Recommended workflow:
|
|
30
|
+
1. Use search_movies() with a specific movie or TV title.
|
|
31
|
+
2. Read the Markdown output and choose the most relevant result by title/year.
|
|
32
|
+
3. Use the returned detail URL and cloud-drive links as source references.
|
|
33
|
+
|
|
34
|
+
Notes:
|
|
35
|
+
- MovieFinder only reads public pages and does not download media files.
|
|
36
|
+
- If the live search page is blocked temporarily, cached results may be returned with a warning.
|
|
37
|
+
""",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("moviefinder_cli.mcp")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def search_movies(
|
|
45
|
+
query: str,
|
|
46
|
+
limit: int = 5,
|
|
47
|
+
refresh: bool = False,
|
|
48
|
+
output_format: str = "markdown",
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Search movie resources by keyword.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
query: Movie, TV, anime, actor, or title keyword.
|
|
54
|
+
limit: Maximum number of results to return. Values above 50 are capped.
|
|
55
|
+
refresh: Force live refresh instead of using fresh cache.
|
|
56
|
+
output_format: "markdown" or "json".
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Search results containing synopsis, Douban/IMDB ratings, and cloud-drive links.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
payload = MovieFinderService().search_movies(query, limit, refresh)
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
return _format_error(exc, output_format)
|
|
65
|
+
|
|
66
|
+
if output_format == "json":
|
|
67
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
68
|
+
return render_search_markdown(payload)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool()
|
|
72
|
+
def movie_cache_stats(output_format: str = "markdown") -> str:
|
|
73
|
+
"""Return local MovieFinder cache statistics.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
output_format: "markdown" or "json".
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Cache entry counts grouped by namespace.
|
|
80
|
+
"""
|
|
81
|
+
payload = {"cache": MovieFinderService().cache_stats()}
|
|
82
|
+
if output_format == "json":
|
|
83
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
84
|
+
return render_cache_stats_markdown(payload)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> None:
|
|
88
|
+
parser = argparse.ArgumentParser(
|
|
89
|
+
description="MovieFinder MCP Server",
|
|
90
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--transport",
|
|
94
|
+
"-t",
|
|
95
|
+
choices=["stdio", "sse"],
|
|
96
|
+
default=os.environ.get("MOVIEFINDER_MCP_TRANSPORT", "stdio"),
|
|
97
|
+
help="Transport protocol (default: stdio)",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--host",
|
|
101
|
+
"-H",
|
|
102
|
+
default=os.environ.get("MOVIEFINDER_MCP_HOST", "127.0.0.1"),
|
|
103
|
+
help="Host to bind for SSE (default: 127.0.0.1)",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--port",
|
|
107
|
+
"-p",
|
|
108
|
+
type=int,
|
|
109
|
+
default=int(os.environ.get("MOVIEFINDER_MCP_PORT", "8000")),
|
|
110
|
+
help="Port for SSE transport (default: 8000)",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--debug",
|
|
114
|
+
action="store_true",
|
|
115
|
+
default=os.environ.get("MOVIEFINDER_MCP_DEBUG", "").lower() == "true",
|
|
116
|
+
help="Enable debug logging",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
args = parser.parse_args()
|
|
120
|
+
|
|
121
|
+
if args.debug:
|
|
122
|
+
logging.basicConfig(
|
|
123
|
+
level=logging.DEBUG,
|
|
124
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
125
|
+
)
|
|
126
|
+
logger.setLevel(logging.DEBUG)
|
|
127
|
+
logger.debug("Starting MovieFinder MCP v%s", __version__)
|
|
128
|
+
|
|
129
|
+
if args.transport == "stdio":
|
|
130
|
+
mcp.run()
|
|
131
|
+
else:
|
|
132
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_error(exc: Exception, output_format: str) -> str:
|
|
136
|
+
message = str(exc)
|
|
137
|
+
if output_format == "json":
|
|
138
|
+
return json.dumps({"error": message}, ensure_ascii=False, indent=2)
|
|
139
|
+
return f"# 错误\n\n{message}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass, field
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ResourceLink:
|
|
7
|
+
platform: str
|
|
8
|
+
url: str
|
|
9
|
+
label: str = ""
|
|
10
|
+
access_code: Optional[str] = None
|
|
11
|
+
url_password: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
14
|
+
return asdict(self)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MovieResult:
|
|
19
|
+
title: str
|
|
20
|
+
detail_url: str
|
|
21
|
+
category: Optional[str] = None
|
|
22
|
+
published_at: Optional[str] = None
|
|
23
|
+
summary: Optional[str] = None
|
|
24
|
+
intro: Optional[str] = None
|
|
25
|
+
douban_score: Optional[str] = None
|
|
26
|
+
imdb_score: Optional[str] = None
|
|
27
|
+
imdb_id: Optional[str] = None
|
|
28
|
+
poster_url: Optional[str] = None
|
|
29
|
+
resources: List[ResourceLink] = field(default_factory=list)
|
|
30
|
+
source: str = "rrdynb"
|
|
31
|
+
|
|
32
|
+
def merge_detail(self, detail: "MovieResult") -> "MovieResult":
|
|
33
|
+
self.title = detail.title or self.title
|
|
34
|
+
self.published_at = detail.published_at or self.published_at
|
|
35
|
+
self.summary = detail.summary or self.summary
|
|
36
|
+
self.intro = detail.intro or self.intro
|
|
37
|
+
self.imdb_id = detail.imdb_id or self.imdb_id
|
|
38
|
+
self.poster_url = detail.poster_url or self.poster_url
|
|
39
|
+
self.resources = detail.resources or self.resources
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
data = asdict(self)
|
|
44
|
+
data["resources"] = [resource.to_dict() for resource in self.resources]
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MovieResult":
|
|
49
|
+
payload = dict(data)
|
|
50
|
+
payload["resources"] = [
|
|
51
|
+
ResourceLink(**resource) for resource in payload.get("resources", [])
|
|
52
|
+
]
|
|
53
|
+
return cls(**payload)
|