bdpan-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.
- bdpan_cli/__init__.py +15 -0
- bdpan_cli/_vendor/__init__.py +9 -0
- bdpan_cli/_vendor/openapi_client/__init__.py +27 -0
- bdpan_cli/_vendor/openapi_client/api/__init__.py +6 -0
- bdpan_cli/_vendor/openapi_client/api/auth_api.py +649 -0
- bdpan_cli/_vendor/openapi_client/api/fileinfo_api.py +737 -0
- bdpan_cli/_vendor/openapi_client/api/filemanager_api.py +669 -0
- bdpan_cli/_vendor/openapi_client/api/fileupload_api.py +599 -0
- bdpan_cli/_vendor/openapi_client/api/multimediafile_api.py +391 -0
- bdpan_cli/_vendor/openapi_client/api/userinfo_api.py +315 -0
- bdpan_cli/_vendor/openapi_client/api_client.py +864 -0
- bdpan_cli/_vendor/openapi_client/configuration.py +454 -0
- bdpan_cli/_vendor/openapi_client/exceptions.py +197 -0
- bdpan_cli/_vendor/openapi_client/model/__init__.py +5 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_authorization_code_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_device_code_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_device_token_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_refresh_token_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/quotaresponse.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/uinforesponse.py +284 -0
- bdpan_cli/_vendor/openapi_client/model_utils.py +2039 -0
- bdpan_cli/_vendor/openapi_client/rest.py +385 -0
- bdpan_cli/_version.py +3 -0
- bdpan_cli/account.py +78 -0
- bdpan_cli/apply_scrape_rename.py +99 -0
- bdpan_cli/auth.py +218 -0
- bdpan_cli/cli.py +1428 -0
- bdpan_cli/config.py +26 -0
- bdpan_cli/dedup.py +310 -0
- bdpan_cli/downloader.py +178 -0
- bdpan_cli/file_manager.py +389 -0
- bdpan_cli/foldering.py +181 -0
- bdpan_cli/logger.py +101 -0
- bdpan_cli/mcp_server.py +547 -0
- bdpan_cli/media_info.py +251 -0
- bdpan_cli/ops.py +173 -0
- bdpan_cli/organize.py +108 -0
- bdpan_cli/preview_scrape.py +182 -0
- bdpan_cli/scraper.py +505 -0
- bdpan_cli/uploader.py +391 -0
- bdpan_cli-0.1.0.dist-info/METADATA +167 -0
- bdpan_cli-0.1.0.dist-info/RECORD +44 -0
- bdpan_cli-0.1.0.dist-info/WHEEL +4 -0
- bdpan_cli-0.1.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""批量刮削预览并生成方案 A 对照表。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from bdpan_cli.auth import TokenManager
|
|
12
|
+
from bdpan_cli.config import load_config
|
|
13
|
+
from bdpan_cli.file_manager import FileManager
|
|
14
|
+
from bdpan_cli.scraper import MovieScraper
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# 方案 A 规范匹配模式(包含 4 位年份与 tt 或 tmdb ID,不含压制发布组标签)
|
|
19
|
+
FORMAT_PATTERN = re.compile(
|
|
20
|
+
r"^.+\.(19\d{2}|20[0-3]\d)\.(tt\d{7,8}|tmdb\d+)(\.[A-Za-z0-9_ -]+)*$"
|
|
21
|
+
)
|
|
22
|
+
NOISE_CHECK = re.compile(
|
|
23
|
+
r"\b(1080p|2160p|720p|BluRay|WEB-DL|WEBRip|x264|x265|HEVC|REMUX|DTS)\b",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_already_conforming(name: str) -> bool:
|
|
29
|
+
"""判断目录名是否已经严格符合方案 A 规范。"""
|
|
30
|
+
return bool(FORMAT_PATTERN.match(name) and not NOISE_CHECK.search(name))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_edition(name: str) -> str:
|
|
34
|
+
"""从杂乱文件名中嗅探版本标签,防止同名多版本冲突。"""
|
|
35
|
+
editions = []
|
|
36
|
+
if "AI_UPSCALE" in name:
|
|
37
|
+
editions.append("Extended.AI-Upscale" if re.search(r"加长|Extended|Collector", name, re.I) else "AI-Upscale")
|
|
38
|
+
elif re.search(r"加长|Extended|Collector", name, re.IGNORECASE):
|
|
39
|
+
editions.append("Extended.Collector" if ("收藏" in name or "Collector" in name) else "Extended")
|
|
40
|
+
if re.search(r"删减", name):
|
|
41
|
+
editions.append("Cut")
|
|
42
|
+
if re.search(r"测试", name):
|
|
43
|
+
editions.append("Beta")
|
|
44
|
+
if re.search(r"BONUS", name, re.IGNORECASE):
|
|
45
|
+
editions.append("Bonus")
|
|
46
|
+
if re.search(r"Redux", name, re.IGNORECASE):
|
|
47
|
+
editions.append("Redux")
|
|
48
|
+
if "BONZO" in name:
|
|
49
|
+
editions.append("Remux-BONZO")
|
|
50
|
+
elif "RARBG" in name and "Walter.Mitty" in name:
|
|
51
|
+
editions.append("Remux-RARBG")
|
|
52
|
+
return ".".join(editions)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_preview(
|
|
56
|
+
target_dir: str = "/影视/电影/watched",
|
|
57
|
+
output_file: str = "watched_scrape_preview.json",
|
|
58
|
+
skip_conforming: bool = True,
|
|
59
|
+
limit: int | None = None,
|
|
60
|
+
name_format: str | None = None,
|
|
61
|
+
) -> list[dict[str, Any]]:
|
|
62
|
+
config = load_config()
|
|
63
|
+
token = TokenManager(config).get_access_token()
|
|
64
|
+
mgr = FileManager(token)
|
|
65
|
+
scraper = MovieScraper()
|
|
66
|
+
|
|
67
|
+
entries = mgr.list_dir(target_dir)
|
|
68
|
+
if limit:
|
|
69
|
+
entries = entries[:limit]
|
|
70
|
+
|
|
71
|
+
logger.info(
|
|
72
|
+
"=== 正在为 %s 目录下 %d 部电影生成预演表 (模板: %s, 跳过规范项: %s) ===",
|
|
73
|
+
target_dir,
|
|
74
|
+
len(entries),
|
|
75
|
+
name_format or "方案 A (默认)",
|
|
76
|
+
skip_conforming and not name_format,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
mapping: list[dict[str, Any]] = []
|
|
80
|
+
skipped_count = 0
|
|
81
|
+
ok_count = 0
|
|
82
|
+
fail_count = 0
|
|
83
|
+
|
|
84
|
+
for i, e in enumerate(entries, 1):
|
|
85
|
+
raw_name = e.get("server_filename")
|
|
86
|
+
if not raw_name:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# 检查是否已符合规范(未指定自定义模板时才生效)
|
|
90
|
+
if not name_format and skip_conforming and is_already_conforming(raw_name):
|
|
91
|
+
skipped_count += 1
|
|
92
|
+
tt_match = re.search(r"\b(tt\d{7,8}|tmdb\d+)\b", raw_name, re.I)
|
|
93
|
+
record = {
|
|
94
|
+
"index": i,
|
|
95
|
+
"parent_dir": target_dir,
|
|
96
|
+
"src_path": f"{target_dir}/{raw_name}",
|
|
97
|
+
"raw_name": raw_name,
|
|
98
|
+
"target_name": raw_name,
|
|
99
|
+
"imdb_id": tt_match.group(1).lower() if tt_match else None,
|
|
100
|
+
"tmdb_id": None,
|
|
101
|
+
"media_type": "movie",
|
|
102
|
+
"status": "SKIP_CONFORMING",
|
|
103
|
+
"reason": "已符合方案 A 规范,无需更名",
|
|
104
|
+
}
|
|
105
|
+
mapping.append(record)
|
|
106
|
+
logger.info("[%2d/%d] [SKIP] %s (已合规)", i, len(entries), raw_name)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
edition = detect_edition(raw_name)
|
|
110
|
+
meta = scraper.scrape_folder_name(raw_name, edition_hint=edition)
|
|
111
|
+
if meta:
|
|
112
|
+
target_name = meta.format_name(name_format)
|
|
113
|
+
tmdb_id = meta.tmdb_id
|
|
114
|
+
mtype = meta.media_type
|
|
115
|
+
imdb = meta.imdb_id
|
|
116
|
+
status = "OK"
|
|
117
|
+
ok_count += 1
|
|
118
|
+
else:
|
|
119
|
+
target_name = "【未匹配到,待人工确认】"
|
|
120
|
+
tmdb_id = None
|
|
121
|
+
mtype = None
|
|
122
|
+
imdb = None
|
|
123
|
+
status = "FAIL"
|
|
124
|
+
fail_count += 1
|
|
125
|
+
|
|
126
|
+
record = {
|
|
127
|
+
"index": i,
|
|
128
|
+
"parent_dir": target_dir,
|
|
129
|
+
"src_path": f"{target_dir}/{raw_name}",
|
|
130
|
+
"raw_name": raw_name,
|
|
131
|
+
"target_name": target_name,
|
|
132
|
+
"imdb_id": imdb,
|
|
133
|
+
"tmdb_id": tmdb_id,
|
|
134
|
+
"media_type": mtype,
|
|
135
|
+
"status": status,
|
|
136
|
+
}
|
|
137
|
+
mapping.append(record)
|
|
138
|
+
logger.info("[%2d/%d] [%s] %s -> %s", i, len(entries), status, raw_name, target_name)
|
|
139
|
+
|
|
140
|
+
# 检查 target_name 冲突
|
|
141
|
+
name_seen: dict[str, list[int]] = {}
|
|
142
|
+
for item in mapping:
|
|
143
|
+
tname = item["target_name"]
|
|
144
|
+
if item["status"] == "OK":
|
|
145
|
+
name_seen.setdefault(tname, []).append(item["index"])
|
|
146
|
+
|
|
147
|
+
for tname, indices in name_seen.items():
|
|
148
|
+
if len(indices) > 1:
|
|
149
|
+
logger.warning("发现重名冲突目标: %s 出现在索引 %s", tname, indices)
|
|
150
|
+
for idx in indices:
|
|
151
|
+
mapping[idx - 1]["status"] = "CONFLICT"
|
|
152
|
+
mapping[idx - 1]["reason"] = f"与其他 {len(indices) - 1} 个目录目标重名"
|
|
153
|
+
|
|
154
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
155
|
+
json.dump(mapping, f, ensure_ascii=False, indent=2)
|
|
156
|
+
|
|
157
|
+
logger.info("=== 预演完成,已保存至 %s ===", output_file)
|
|
158
|
+
logger.info(
|
|
159
|
+
"统计: 总数=%d | 已合规跳过=%d | 成功匹配=%d | 失败=%d",
|
|
160
|
+
len(entries),
|
|
161
|
+
skipped_count,
|
|
162
|
+
ok_count,
|
|
163
|
+
fail_count,
|
|
164
|
+
)
|
|
165
|
+
return mapping
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
parser = argparse.ArgumentParser(description="批量电影刮削预演")
|
|
170
|
+
parser.add_argument("--dir", default="/影视/电影/watched", help="目标目录")
|
|
171
|
+
parser.add_argument("--output", default="watched_scrape_preview.json", help="输出 json 路径")
|
|
172
|
+
parser.add_argument("--no-skip", action="store_true", help="不跳过已合规项")
|
|
173
|
+
parser.add_argument("--limit", type=int, default=None, help="最大处理数量(测试用)")
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
generate_preview(
|
|
177
|
+
target_dir=args.dir,
|
|
178
|
+
output_file=args.output,
|
|
179
|
+
skip_conforming=not args.no_skip,
|
|
180
|
+
limit=args.limit,
|
|
181
|
+
)
|
|
182
|
+
|
bdpan_cli/scraper.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""电影元数据刮削与目录名规范化模块。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_TMDB_TOKEN = (
|
|
16
|
+
"eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIyMzc1ZGIzOTYwYWVhMWI1OTA1NWMwZmM3ZDcwYjYwZiIsInN1YiI6IjYwYmNhZTk0NGE0YmY2MDA1OWJhNWE1ZSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.DU51juQWlAIIfZ2lK99b3zi-c5vgc4jAwVz5h2WjOP8"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MovieMetadata:
|
|
22
|
+
"""电影元数据模型。"""
|
|
23
|
+
|
|
24
|
+
title: str # 中文译名 / 主标题
|
|
25
|
+
original_title: str # 外文原名 / 英文名
|
|
26
|
+
year: str # 上映年份 (YYYY)
|
|
27
|
+
imdb_id: str # ttXXXXXXX
|
|
28
|
+
tmdb_id: int | None = None
|
|
29
|
+
media_type: str = "movie" # movie 或 tv
|
|
30
|
+
overview: str = "" # 剧情简介 (zh-CN 优先)
|
|
31
|
+
poster_path: str = "" # TMDB 海报相对路径
|
|
32
|
+
edition: str = "" # 特别版本标识 (若有)
|
|
33
|
+
|
|
34
|
+
def format_name(self, template: str | None = None) -> str:
|
|
35
|
+
"""生成规范目录名。未指定 template 时采用默认方案 A;支持自定义命名模板。"""
|
|
36
|
+
if not template:
|
|
37
|
+
def sanitize(text: str) -> str:
|
|
38
|
+
cleaned = re.sub(r'[\/\\:\*\?"<>\|\s_,]+', ".", text.strip())
|
|
39
|
+
cleaned = re.sub(r"\.{2,}", ".", cleaned)
|
|
40
|
+
return cleaned.strip(".")
|
|
41
|
+
|
|
42
|
+
zh = sanitize(self.title)
|
|
43
|
+
en = sanitize(self.original_title)
|
|
44
|
+
yr = self.year.strip()
|
|
45
|
+
imdb = self.imdb_id.strip() if self.imdb_id else (f"tmdb{self.tmdb_id}" if self.tmdb_id else "")
|
|
46
|
+
|
|
47
|
+
# 如果英文名跟中文名完全一样或全为汉字,则仅保留中文名
|
|
48
|
+
is_en_pure_chinese = bool(re.fullmatch(r"[\u4e00-\u9fa5·.]+", en))
|
|
49
|
+
if zh.lower() == en.lower() or not en or is_en_pure_chinese:
|
|
50
|
+
base = f"{zh}.{yr}.{imdb}".strip(".")
|
|
51
|
+
else:
|
|
52
|
+
base = f"{zh}.{en}.{yr}.{imdb}".strip(".")
|
|
53
|
+
|
|
54
|
+
if self.edition:
|
|
55
|
+
base = f"{base}.{sanitize(self.edition)}"
|
|
56
|
+
return base
|
|
57
|
+
|
|
58
|
+
# 自定义模板模式
|
|
59
|
+
def sanitize_val(text: str) -> str:
|
|
60
|
+
cleaned = re.sub(r'[\/\\:\*\?"<>\|]+', "", text.strip())
|
|
61
|
+
return cleaned.strip()
|
|
62
|
+
|
|
63
|
+
zh = sanitize_val(self.title)
|
|
64
|
+
en = sanitize_val(self.original_title)
|
|
65
|
+
yr = self.year.strip()
|
|
66
|
+
imdb = self.imdb_id.strip() if self.imdb_id else ""
|
|
67
|
+
tmdb = str(self.tmdb_id) if self.tmdb_id else ""
|
|
68
|
+
best_id = imdb if imdb else (f"tmdb{tmdb}" if tmdb else "")
|
|
69
|
+
edition = sanitize_val(self.edition) if self.edition else ""
|
|
70
|
+
|
|
71
|
+
mapping = {
|
|
72
|
+
"title": zh,
|
|
73
|
+
"original_title": en,
|
|
74
|
+
"year": yr,
|
|
75
|
+
"imdb_id": imdb,
|
|
76
|
+
"tmdb_id": tmdb,
|
|
77
|
+
"id": best_id,
|
|
78
|
+
"edition": edition,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class SafeDict(dict):
|
|
82
|
+
def __missing__(self, key: str) -> str:
|
|
83
|
+
return "{" + key + "}"
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
formatted = template.format_map(SafeDict(mapping))
|
|
87
|
+
except ValueError:
|
|
88
|
+
# 兼容非标准括号转义
|
|
89
|
+
formatted = template
|
|
90
|
+
for k, v in mapping.items():
|
|
91
|
+
formatted = formatted.replace(f"{{{k}}}", v)
|
|
92
|
+
|
|
93
|
+
# 消除连续冗余点号和两端空白
|
|
94
|
+
cleaned = re.sub(r"\.{2,}", ".", formatted).strip()
|
|
95
|
+
return cleaned
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def formatted_name(self) -> str:
|
|
99
|
+
"""生成方案 A 规范目录名:[中文译名].[英文原名].[上映年份].[IMDb ID]"""
|
|
100
|
+
return self.format_name()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MovieScraper:
|
|
104
|
+
"""TMDB + 豆瓣电影刮削器,支持以 IMDb ID 或标题检索,语言优先 zh-CN,次选 en-US。"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, tmdb_token: str = DEFAULT_TMDB_TOKEN) -> None:
|
|
107
|
+
self.tmdb_token = tmdb_token
|
|
108
|
+
|
|
109
|
+
def _http_get(self, url: str) -> dict[str, Any]:
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
url,
|
|
112
|
+
headers={
|
|
113
|
+
"Authorization": f"Bearer {self.tmdb_token}",
|
|
114
|
+
"Accept": "application/json",
|
|
115
|
+
"User-Agent": "bd-net-disc-mgr/1.0",
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
with urllib.request.urlopen(req, timeout=12) as resp:
|
|
119
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
120
|
+
|
|
121
|
+
def _query_douban_title(self, keyword: str) -> str | None:
|
|
122
|
+
"""从豆瓣建议接口补全中文片名。"""
|
|
123
|
+
url = f"https://movie.douban.com/j/subject_suggest?q={urllib.parse.quote(keyword)}"
|
|
124
|
+
req = urllib.request.Request(
|
|
125
|
+
url,
|
|
126
|
+
headers={
|
|
127
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
128
|
+
"Accept": "application/json",
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
133
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
134
|
+
if data and isinstance(data, list):
|
|
135
|
+
return data[0].get("title")
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def parse_folder_name(self, name: str) -> dict[str, str | None]:
|
|
141
|
+
"""解析杂乱的原目录名,提取 IMDb ID、中文片名、外文片名和年份。"""
|
|
142
|
+
# 0. 清理前缀包装,如 "热门 [极速车王 2019]" 与推广后缀 "@公众号..."
|
|
143
|
+
cleaned_name = re.sub(r"@.*$", "", name)
|
|
144
|
+
cleaned_name = re.sub(r"【.*?】", " ", cleaned_name)
|
|
145
|
+
cleaned_name = re.sub(r"^\s*\d{1,2}[\s._\-]+", "", cleaned_name)
|
|
146
|
+
if re.match(r"^(热门|电影|推荐|资源|分享)\s*\[", cleaned_name):
|
|
147
|
+
bracket_m = re.search(r"\[(.*?)\]", cleaned_name)
|
|
148
|
+
if bracket_m:
|
|
149
|
+
cleaned_name = bracket_m.group(1)
|
|
150
|
+
# 拆分附着在单词或汉字后的分辨率与画质标签 (如 NoBody2160p -> NoBody 2160p, 竖锯4K -> 竖锯 4K)
|
|
151
|
+
cleaned_name = re.sub(
|
|
152
|
+
r"(?<=[A-Za-z\u4e00-\u9fa5])(?=(?:4K|2K|8K|\d{3,4}p)\b)",
|
|
153
|
+
" ",
|
|
154
|
+
cleaned_name,
|
|
155
|
+
flags=re.IGNORECASE,
|
|
156
|
+
)
|
|
157
|
+
# 移除 S01 / S02
|
|
158
|
+
cleaned_name = re.sub(r"\bS\d{1,2}\b", " ", cleaned_name)
|
|
159
|
+
# 移除末尾 Team 标识 (如 PHDTeam)
|
|
160
|
+
cleaned_name = re.sub(r"\b[A-Za-z0-9]+Team\b", " ", cleaned_name)
|
|
161
|
+
|
|
162
|
+
# 1. 优先提取已有 tt\d+
|
|
163
|
+
tt_match = re.search(r"\b(tt\d{7,8})\b", cleaned_name, re.IGNORECASE)
|
|
164
|
+
imdb_id = tt_match.group(1).lower() if tt_match else None
|
|
165
|
+
|
|
166
|
+
# 2. 提取 4 位年份 (1920-2035)
|
|
167
|
+
# 优先提取括号年份 (2018) 或 [2018]
|
|
168
|
+
year = None
|
|
169
|
+
paren = re.search(r"[\(\[]\s*(19\d{2}|20[0-3]\d)\s*[\)\]]", cleaned_name)
|
|
170
|
+
if paren:
|
|
171
|
+
year = paren.group(1)
|
|
172
|
+
else:
|
|
173
|
+
all_years = list(re.finditer(r"\b(19\d{2}|20[0-3]\d)\b", cleaned_name))
|
|
174
|
+
if len(all_years) == 1:
|
|
175
|
+
year = all_years[0].group(1)
|
|
176
|
+
elif len(all_years) > 1:
|
|
177
|
+
# 如果第一个年份位于开头(0..4),且紧接着有第二个年份(如 1917.2019),第一个是片名,第二个是年份
|
|
178
|
+
if all_years[0].start() == 0 and all_years[1].start() <= 10:
|
|
179
|
+
year = all_years[1].group(1)
|
|
180
|
+
else:
|
|
181
|
+
year = all_years[-1].group(1)
|
|
182
|
+
|
|
183
|
+
# 3. 提取中文字符串
|
|
184
|
+
noise_pattern = re.compile(
|
|
185
|
+
r"\b(1080p|2160p|720p|480p|BluRay|Blu-ray|WEB-DL|WEBRip|HDTV|REMUX|UHD|HDR|DV|DDP\d?\.?\d?|"
|
|
186
|
+
r"Atmos|x264|x265|HEVC|H264|H265|DTS|AAC|TrueHD|FLAC|10bit|60fps|60FPS|REPACK|EXTENDED|"
|
|
187
|
+
r"Director'?s\.?Cut|KORSUB|BDRip|WEB|4K|2K|8K|DCP|Theatrical|Trailer|Hi10p|AC3|BD|DOCU|DVD|Re)\b",
|
|
188
|
+
re.IGNORECASE,
|
|
189
|
+
)
|
|
190
|
+
cleaned_zh = re.sub(
|
|
191
|
+
r"\[.*?(?:字幕|音轨|版本|高码|帧率|双语|国语|原盘|简|繁|英|4K|1080|2160|蓝光).*?\]|\(.*?(?:字幕|音轨|删减).*?\)",
|
|
192
|
+
" ",
|
|
193
|
+
cleaned_name,
|
|
194
|
+
)
|
|
195
|
+
cleaned_zh = noise_pattern.sub(" ", cleaned_zh)
|
|
196
|
+
cleaned_zh = re.sub(r"[._]+", " ", cleaned_zh)
|
|
197
|
+
zh_candidates = re.findall(r"[\u4e00-\u9fa50-9::·\-—]{2,}", cleaned_zh)
|
|
198
|
+
tag_words = {
|
|
199
|
+
"测试版", "删减版", "合集", "加长收藏版", "蓝光原盘", "中英双字", "国语音轨",
|
|
200
|
+
"高码版", "国语配音", "特效字幕", "内封简英双字", "国语中字", "简繁字幕", "国语", "粤语配音",
|
|
201
|
+
}
|
|
202
|
+
zh_title = None
|
|
203
|
+
for cand in zh_candidates:
|
|
204
|
+
cand_clean = cand.strip("::·-— ")
|
|
205
|
+
if any("\u4e00" <= ch <= "\u9fa5" for ch in cand_clean) and cand_clean not in tag_words:
|
|
206
|
+
zh_title = cand_clean
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
# 4. 提取外文主要名称
|
|
210
|
+
text = re.sub(r"\[.*?\]|\(.*?\)", " ", cleaned_name)
|
|
211
|
+
text = re.sub(r"[._]+", " ", text)
|
|
212
|
+
|
|
213
|
+
def _clean_cand(raw: str) -> str:
|
|
214
|
+
cand = noise_pattern.sub(" ", raw)
|
|
215
|
+
cand = re.sub(r"[\u4e00-\u9fa5·::(\(\))\[\]【】、]+", " ", cand)
|
|
216
|
+
cand = re.sub(r"[-@][A-Za-z0-9_]+$", "", cand)
|
|
217
|
+
if " - " in cand:
|
|
218
|
+
after_dash = cand.split(" - ")[-1].strip()
|
|
219
|
+
if after_dash and re.search(r"[A-Za-z]", after_dash):
|
|
220
|
+
cand = after_dash
|
|
221
|
+
cand = re.sub(r"\b(JAPANESE|KOREAN|GERMAN|FRENCH|ITALIAN|RUSSIAN|CHINESE)\b", " ", cand, flags=re.I)
|
|
222
|
+
return " ".join(cand.split())
|
|
223
|
+
|
|
224
|
+
def _is_valid_en(w: str) -> bool:
|
|
225
|
+
if not w:
|
|
226
|
+
return False
|
|
227
|
+
has_word = bool(re.search(r"\b[A-Za-z]{2,}\b", w))
|
|
228
|
+
is_valid_num = bool(w.isdigit() and len(w) >= 3 and w != year)
|
|
229
|
+
return has_word or is_valid_num
|
|
230
|
+
|
|
231
|
+
en_title = None
|
|
232
|
+
if year and year in text:
|
|
233
|
+
parts = text.split(year)
|
|
234
|
+
before = parts[0].strip()
|
|
235
|
+
after = parts[1].strip() if len(parts) > 1 else ""
|
|
236
|
+
if before:
|
|
237
|
+
words = _clean_cand(before)
|
|
238
|
+
if _is_valid_en(words):
|
|
239
|
+
en_title = words
|
|
240
|
+
elif after:
|
|
241
|
+
words = _clean_cand(after)
|
|
242
|
+
if _is_valid_en(words):
|
|
243
|
+
en_title = words
|
|
244
|
+
|
|
245
|
+
if not en_title:
|
|
246
|
+
noise_m = noise_pattern.search(text)
|
|
247
|
+
cand_raw = text[:noise_m.start()] if noise_m else text
|
|
248
|
+
words = _clean_cand(cand_raw)
|
|
249
|
+
if _is_valid_en(words):
|
|
250
|
+
en_title = words
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"imdb_id": imdb_id,
|
|
254
|
+
"year": year,
|
|
255
|
+
"zh_title": zh_title,
|
|
256
|
+
"en_title": en_title,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
def _extract_detail_meta(
|
|
260
|
+
self,
|
|
261
|
+
media_type: str,
|
|
262
|
+
tmdb_id: int,
|
|
263
|
+
item: dict[str, Any],
|
|
264
|
+
imdb_id_hint: str = "",
|
|
265
|
+
fallback_zh: str | None = None,
|
|
266
|
+
fallback_en: str | None = None,
|
|
267
|
+
) -> MovieMetadata:
|
|
268
|
+
"""从 TMDB 详情与 translations 中提炼最优质的中文与英文名。"""
|
|
269
|
+
detail_url = f"https://api.themoviedb.org/3/{media_type}/{tmdb_id}?append_to_response=external_ids,translations&language=zh-CN"
|
|
270
|
+
try:
|
|
271
|
+
detail = self._http_get(detail_url)
|
|
272
|
+
except Exception:
|
|
273
|
+
detail = item
|
|
274
|
+
|
|
275
|
+
imdb_id = (
|
|
276
|
+
detail.get("external_ids", {}).get("imdb_id")
|
|
277
|
+
or detail.get("imdb_id")
|
|
278
|
+
or imdb_id_hint
|
|
279
|
+
or ""
|
|
280
|
+
)
|
|
281
|
+
rel_date = (
|
|
282
|
+
detail.get("release_date")
|
|
283
|
+
or detail.get("first_air_date")
|
|
284
|
+
or item.get("release_date")
|
|
285
|
+
or item.get("first_air_date")
|
|
286
|
+
or ""
|
|
287
|
+
)
|
|
288
|
+
year = rel_date[:4] if rel_date else ""
|
|
289
|
+
overview = detail.get("overview") or item.get("overview") or ""
|
|
290
|
+
poster_path = detail.get("poster_path") or item.get("poster_path") or ""
|
|
291
|
+
|
|
292
|
+
# 解析多语言 translations
|
|
293
|
+
trans_list = detail.get("translations", {}).get("translations", [])
|
|
294
|
+
zh_cn_title = None
|
|
295
|
+
zh_other_title = None
|
|
296
|
+
en_trans_title = None
|
|
297
|
+
for t in trans_list:
|
|
298
|
+
iso = t.get("iso_3166_1")
|
|
299
|
+
iso_lang = t.get("iso_639_1")
|
|
300
|
+
data = t.get("data", {})
|
|
301
|
+
title_or_name = (data.get("title") or data.get("name") or "").strip()
|
|
302
|
+
if not title_or_name:
|
|
303
|
+
continue
|
|
304
|
+
if iso == "CN" and not zh_cn_title:
|
|
305
|
+
zh_cn_title = title_or_name
|
|
306
|
+
elif iso_lang == "zh" and not zh_other_title:
|
|
307
|
+
zh_other_title = title_or_name
|
|
308
|
+
elif iso in ("US", "GB") and iso_lang == "en" and not en_trans_title:
|
|
309
|
+
en_trans_title = title_or_name
|
|
310
|
+
|
|
311
|
+
zh_trans_title = zh_cn_title or zh_other_title
|
|
312
|
+
|
|
313
|
+
# 决定中文名
|
|
314
|
+
zh_title = (
|
|
315
|
+
zh_trans_title
|
|
316
|
+
or detail.get("title")
|
|
317
|
+
or detail.get("name")
|
|
318
|
+
or item.get("title")
|
|
319
|
+
or item.get("name")
|
|
320
|
+
or fallback_zh
|
|
321
|
+
or ""
|
|
322
|
+
)
|
|
323
|
+
# 如果依然没有中文字符(例如韩文原名),尝试用英文名或原名从豆瓣建议接口补齐
|
|
324
|
+
if not re.search(r"[\u4e00-\u9fa5]", zh_title):
|
|
325
|
+
douban_q = en_trans_title or fallback_en or detail.get("original_title") or fallback_zh or ""
|
|
326
|
+
douban_zh = self._query_douban_title(douban_q)
|
|
327
|
+
if douban_zh:
|
|
328
|
+
zh_title = douban_zh
|
|
329
|
+
|
|
330
|
+
# 决定英文名 / 原名
|
|
331
|
+
orig_title = (
|
|
332
|
+
detail.get("original_title")
|
|
333
|
+
or detail.get("original_name")
|
|
334
|
+
or item.get("original_title")
|
|
335
|
+
or item.get("original_name")
|
|
336
|
+
or ""
|
|
337
|
+
).strip()
|
|
338
|
+
is_orig_latin = bool(
|
|
339
|
+
orig_title
|
|
340
|
+
and re.search(r"[A-Za-z]", orig_title)
|
|
341
|
+
and not re.search(r"[\uAC00-\uD7A3\u3040-\u30FF\u0400-\u04FF\u4E00-\u9FA5]", orig_title)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# 英文名策略:
|
|
345
|
+
# 1. 若原名本身就是拉丁/英文原名(如 Toy Story, Contact),直接采信原名
|
|
346
|
+
if is_orig_latin:
|
|
347
|
+
en_title = orig_title
|
|
348
|
+
# 2. 否则优先选用经校验为英语的翻译名称
|
|
349
|
+
elif en_trans_title and re.search(r"[A-Za-z]", en_trans_title):
|
|
350
|
+
en_title = en_trans_title
|
|
351
|
+
# 3. 再次选用有效英文 fallback_en
|
|
352
|
+
elif fallback_en and re.search(r"\b[A-Za-z]{2,}\b", fallback_en):
|
|
353
|
+
en_title = fallback_en
|
|
354
|
+
else:
|
|
355
|
+
en_title = en_trans_title or orig_title or fallback_en or ""
|
|
356
|
+
|
|
357
|
+
return MovieMetadata(
|
|
358
|
+
title=zh_title,
|
|
359
|
+
original_title=en_title,
|
|
360
|
+
year=year,
|
|
361
|
+
imdb_id=imdb_id,
|
|
362
|
+
tmdb_id=tmdb_id,
|
|
363
|
+
media_type=media_type,
|
|
364
|
+
overview=overview,
|
|
365
|
+
poster_path=poster_path,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def fetch_by_imdb_id(
|
|
369
|
+
self,
|
|
370
|
+
imdb_id: str,
|
|
371
|
+
fallback_zh: str | None = None,
|
|
372
|
+
fallback_en: str | None = None,
|
|
373
|
+
) -> MovieMetadata | None:
|
|
374
|
+
"""通过 IMDb ID 在 TMDB 精准反查电影/剧集详情。"""
|
|
375
|
+
url = f"https://api.themoviedb.org/3/find/{imdb_id}?external_source=imdb_id&language=zh-CN"
|
|
376
|
+
try:
|
|
377
|
+
data = self._http_get(url)
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.warning("通过 IMDb ID %s 查询 TMDB 失败: %s", imdb_id, e)
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
results = data.get("movie_results", [])
|
|
383
|
+
media_type = "movie"
|
|
384
|
+
if not results:
|
|
385
|
+
results = data.get("tv_results", [])
|
|
386
|
+
media_type = "tv"
|
|
387
|
+
if not results:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
item = results[0]
|
|
391
|
+
return self._extract_detail_meta(
|
|
392
|
+
media_type=media_type,
|
|
393
|
+
tmdb_id=item["id"],
|
|
394
|
+
item=item,
|
|
395
|
+
imdb_id_hint=imdb_id,
|
|
396
|
+
fallback_zh=fallback_zh,
|
|
397
|
+
fallback_en=fallback_en,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def search_movie(
|
|
401
|
+
self,
|
|
402
|
+
title: str,
|
|
403
|
+
year: str | None = None,
|
|
404
|
+
fallback_zh: str | None = None,
|
|
405
|
+
fallback_en: str | None = None,
|
|
406
|
+
) -> MovieMetadata | None:
|
|
407
|
+
"""通过标题和年份在 TMDB 搜索电影,并按 vote_count 排序命中代表作。"""
|
|
408
|
+
encoded_q = urllib.parse.quote(title)
|
|
409
|
+
url = f"https://api.themoviedb.org/3/search/movie?query={encoded_q}&language=zh-CN"
|
|
410
|
+
if year:
|
|
411
|
+
url += f"&primary_release_year={year}"
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
data = self._http_get(url)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
logger.warning("搜索 TMDB 失败 [%s, %s]: %s", title, year, e)
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
results = data.get("results", [])
|
|
420
|
+
if not results and year:
|
|
421
|
+
# 尝试去掉年份再搜
|
|
422
|
+
url_no_yr = f"https://api.themoviedb.org/3/search/movie?query={encoded_q}&language=zh-CN"
|
|
423
|
+
try:
|
|
424
|
+
data = self._http_get(url_no_yr)
|
|
425
|
+
results = data.get("results", [])
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
media_type = "movie"
|
|
430
|
+
if not results:
|
|
431
|
+
tv_url = f"https://api.themoviedb.org/3/search/tv?query={encoded_q}&language=zh-CN"
|
|
432
|
+
if year:
|
|
433
|
+
tv_url += f"&first_air_date_year={year}"
|
|
434
|
+
try:
|
|
435
|
+
tv_data = self._http_get(tv_url)
|
|
436
|
+
results = tv_data.get("results", [])
|
|
437
|
+
if not results and year:
|
|
438
|
+
tv_url_no_yr = f"https://api.themoviedb.org/3/search/tv?query={encoded_q}&language=zh-CN"
|
|
439
|
+
tv_data = self._http_get(tv_url_no_yr)
|
|
440
|
+
results = tv_data.get("results", [])
|
|
441
|
+
if results:
|
|
442
|
+
media_type = "tv"
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
if not results:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
# 核心优化:年份匹配优先,有效投票数优先,完全匹配标题优先,其次按 vote_count 与 popularity 综合排序
|
|
450
|
+
def rank_key(item_dict: dict[str, Any]) -> tuple[int, int, int, int, float]:
|
|
451
|
+
t = (item_dict.get("title") or item_dict.get("name") or "").strip().lower()
|
|
452
|
+
ot = (item_dict.get("original_title") or item_dict.get("original_name") or "").strip().lower()
|
|
453
|
+
q = title.strip().lower()
|
|
454
|
+
is_exact = 1 if (t == q or ot == q) else 0
|
|
455
|
+
rel_year = (item_dict.get("release_date") or item_dict.get("first_air_date") or "")[:4]
|
|
456
|
+
year_matches = 1 if (year and rel_year == str(year)) else 0
|
|
457
|
+
votes = item_dict.get("vote_count", 0)
|
|
458
|
+
has_votes = 1 if votes >= 5 else 0
|
|
459
|
+
return (year_matches, has_votes, is_exact, votes, item_dict.get("popularity", 0.0))
|
|
460
|
+
|
|
461
|
+
sorted_results = sorted(results, key=rank_key, reverse=True)
|
|
462
|
+
best_item = sorted_results[0]
|
|
463
|
+
return self._extract_detail_meta(
|
|
464
|
+
media_type=media_type,
|
|
465
|
+
tmdb_id=best_item["id"],
|
|
466
|
+
item=best_item,
|
|
467
|
+
fallback_zh=fallback_zh,
|
|
468
|
+
fallback_en=fallback_en,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def scrape_folder_name(self, folder_name: str, edition_hint: str = "") -> MovieMetadata | None:
|
|
472
|
+
"""主入口:输入目录名,刮削返回标准 MovieMetadata。"""
|
|
473
|
+
parsed = self.parse_folder_name(folder_name)
|
|
474
|
+
|
|
475
|
+
meta: MovieMetadata | None = None
|
|
476
|
+
# 1. 优先命中 IMDb ID
|
|
477
|
+
if parsed.get("imdb_id"):
|
|
478
|
+
meta = self.fetch_by_imdb_id(
|
|
479
|
+
parsed["imdb_id"],
|
|
480
|
+
fallback_zh=parsed.get("zh_title"),
|
|
481
|
+
fallback_en=parsed.get("en_title"),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# 2. 特殊中文片名优先搜索
|
|
485
|
+
if not meta and parsed.get("zh_title"):
|
|
486
|
+
meta = self.search_movie(
|
|
487
|
+
parsed["zh_title"],
|
|
488
|
+
parsed.get("year"),
|
|
489
|
+
fallback_zh=parsed.get("zh_title"),
|
|
490
|
+
fallback_en=parsed.get("en_title"),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# 3. 英文名搜索
|
|
494
|
+
if not meta and parsed.get("en_title"):
|
|
495
|
+
meta = self.search_movie(
|
|
496
|
+
parsed["en_title"],
|
|
497
|
+
parsed.get("year"),
|
|
498
|
+
fallback_zh=parsed.get("zh_title"),
|
|
499
|
+
fallback_en=parsed.get("en_title"),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if meta and edition_hint:
|
|
503
|
+
meta.edition = edition_hint
|
|
504
|
+
|
|
505
|
+
return meta
|