jwf-common-python 1.0.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.
- jwf_common_python/__init__.py +14 -0
- jwf_common_python/db_utils/__init__.py +6 -0
- jwf_common_python/db_utils/clickhouse.py +124 -0
- jwf_common_python/db_utils/elasticsearch.py +196 -0
- jwf_common_python/db_utils/mysql.py +64 -0
- jwf_common_python/eudic/__init__.py +5 -0
- jwf_common_python/eudic/wordbook.py +211 -0
- jwf_common_python/file_storage/__init__.py +2 -0
- jwf_common_python/file_storage/file_interface.py +29 -0
- jwf_common_python/file_storage/local.py +50 -0
- jwf_common_python/file_storage/tencent_cos.py +65 -0
- jwf_common_python/geography/__init__.py +8 -0
- jwf_common_python/geography/district_amap.py +181 -0
- jwf_common_python/geography/search_amap.py +84 -0
- jwf_common_python/github/__init__.py +5 -0
- jwf_common_python/github/webhooks.py +87 -0
- jwf_common_python/language_utils/__init__.py +34 -0
- jwf_common_python/language_utils/core.py +203 -0
- jwf_common_python/language_utils/files.py +52 -0
- jwf_common_python/language_utils/web.py +117 -0
- jwf_common_python/llm/__init__.py +1 -0
- jwf_common_python/llm/aliyun_qwen.py +10 -0
- jwf_common_python/llm/azure_gpt.py +66 -0
- jwf_common_python/llm/baidu_ernie.py +112 -0
- jwf_common_python/message_queue/__init__.py +7 -0
- jwf_common_python/message_queue/interface.py +49 -0
- jwf_common_python/message_queue/kafka.py +145 -0
- jwf_common_python/message_queue/rabbitmq.py +113 -0
- jwf_common_python/oauth2/__init__.py +0 -0
- jwf_common_python/oauth2/google.py +158 -0
- jwf_common_python/parse_subtitle/__init__.py +6 -0
- jwf_common_python/parse_subtitle/aa.vtt +2364 -0
- jwf_common_python/parse_subtitle/parse_srt_file.py +60 -0
- jwf_common_python/pdf/__init__.py +0 -0
- jwf_common_python/pdf/aaa.py +38 -0
- jwf_common_python/pdf/local_parse.py +259 -0
- jwf_common_python/sms/__init__.py +1 -0
- jwf_common_python/sms/interface.py +7 -0
- jwf_common_python/sms/tencent_sms.py +117 -0
- jwf_common_python/translate/__init__.py +152 -0
- jwf_common_python/translate/_youdao.py +234 -0
- jwf_common_python/translate/microsoft.py +696 -0
- jwf_common_python/translate/tencent.py +194 -0
- jwf_common_python/tts/__init__.py +0 -0
- jwf_common_python/tts/microsoft.py +87 -0
- jwf_common_python/utils/__init__.py +0 -0
- jwf_common_python/utils/image_utils.py +18 -0
- jwf_common_python/utils/tencent_utils.py +51 -0
- jwf_common_python/utils/utils.py +46 -0
- jwf_common_python/video/__init__.py +5 -0
- jwf_common_python/video/merge.py +276 -0
- jwf_common_python/weather/__init__.py +0 -0
- jwf_common_python/weather/amap.py +127 -0
- jwf_common_python/weather/wanwei.py +58 -0
- jwf_common_python/youdao/__init__.py +13 -0
- jwf_common_python/youdao/wordbook.py +88 -0
- jwf_common_python-1.0.0.dist-info/METADATA +669 -0
- jwf_common_python-1.0.0.dist-info/RECORD +60 -0
- jwf_common_python-1.0.0.dist-info/WHEEL +4 -0
- jwf_common_python-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""腾讯云旧版 TMT 文本翻译的兼容封装。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from jwf_common_python.translate import SentenceInfo, Translate, WordInfo
|
|
8
|
+
|
|
9
|
+
SDK_REQUIREMENT = "tencentcloud-sdk-python-common>=3.0.1042,<4"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TencentTranslate(Translate):
|
|
13
|
+
"""兼容已有 TMT 账号,SDK 在实例化时加载,请求失败直接抛出异常。
|
|
14
|
+
|
|
15
|
+
使用官方 CommonClient 调用 TextTranslate,无需安装 TMT 产品 SDK。
|
|
16
|
+
该接口已发布下线公告,状态见 README。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
secret_id: str,
|
|
22
|
+
secret_key: str,
|
|
23
|
+
*,
|
|
24
|
+
region: str = "ap-shanghai",
|
|
25
|
+
endpoint: str = "tmt.tencentcloudapi.com",
|
|
26
|
+
project_id: int = 0,
|
|
27
|
+
timeout: float = 30,
|
|
28
|
+
token: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
for name, value in (
|
|
31
|
+
("secret_id", secret_id),
|
|
32
|
+
("secret_key", secret_key),
|
|
33
|
+
("region", region),
|
|
34
|
+
("endpoint", endpoint),
|
|
35
|
+
):
|
|
36
|
+
if not isinstance(value, str) or not value.strip():
|
|
37
|
+
raise ValueError(f"{name} 不能为空")
|
|
38
|
+
if (
|
|
39
|
+
isinstance(project_id, bool)
|
|
40
|
+
or not isinstance(project_id, int)
|
|
41
|
+
or project_id < 0
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("project_id 必须为非负整数")
|
|
44
|
+
if timeout <= 0:
|
|
45
|
+
raise ValueError("timeout 必须大于 0")
|
|
46
|
+
if token is not None and (not isinstance(token, str) or not token.strip()):
|
|
47
|
+
raise ValueError("token 必须为非空字符串或 None")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from tencentcloud.common.common_client import CommonClient
|
|
51
|
+
from tencentcloud.common.credential import Credential
|
|
52
|
+
from tencentcloud.common.profile.client_profile import ClientProfile
|
|
53
|
+
from tencentcloud.common.profile.http_profile import HttpProfile
|
|
54
|
+
except ImportError as exc:
|
|
55
|
+
raise ImportError(f"请先安装腾讯云公共 SDK:pip install '{SDK_REQUIREMENT}'") from exc
|
|
56
|
+
|
|
57
|
+
profile = ClientProfile()
|
|
58
|
+
profile.httpProfile = HttpProfile(endpoint=endpoint, reqTimeout=timeout)
|
|
59
|
+
self.client = CommonClient(
|
|
60
|
+
"tmt",
|
|
61
|
+
"2018-03-21",
|
|
62
|
+
Credential(secret_id, secret_key, token=token),
|
|
63
|
+
region,
|
|
64
|
+
profile,
|
|
65
|
+
)
|
|
66
|
+
self.project_id = project_id
|
|
67
|
+
self._closed = False
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _language_code(code: str) -> str:
|
|
71
|
+
if not isinstance(code, str) or not code.strip():
|
|
72
|
+
raise ValueError("语言代码不能为空")
|
|
73
|
+
return {"zh-CN": "zh"}.get(code, code)
|
|
74
|
+
|
|
75
|
+
def translate(
|
|
76
|
+
self,
|
|
77
|
+
word_or_sentence: str,
|
|
78
|
+
source_language_code: str = "en",
|
|
79
|
+
target_language_code: str = "zh",
|
|
80
|
+
*,
|
|
81
|
+
untranslated_text: str | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""返回完整 Response 响应体,包括 TargetText、Source、Target、RequestId。
|
|
84
|
+
|
|
85
|
+
支持源语言 auto、项目通用的 zh-CN 别名和原生语言代码。
|
|
86
|
+
每次创建独立参数字典;不打印、不写文件、不吞异常。
|
|
87
|
+
"""
|
|
88
|
+
if self._closed:
|
|
89
|
+
raise RuntimeError("腾讯翻译客户端已关闭")
|
|
90
|
+
if not isinstance(word_or_sentence, str) or not word_or_sentence.strip():
|
|
91
|
+
raise ValueError("待翻译文本不能为空")
|
|
92
|
+
source = self._language_code(source_language_code)
|
|
93
|
+
target = self._language_code(target_language_code)
|
|
94
|
+
if target == "auto":
|
|
95
|
+
raise ValueError("目标语言不能为 auto")
|
|
96
|
+
if untranslated_text is not None and not isinstance(untranslated_text, str):
|
|
97
|
+
raise TypeError("untranslated_text 必须为字符串或 None")
|
|
98
|
+
|
|
99
|
+
params = {
|
|
100
|
+
"SourceText": word_or_sentence,
|
|
101
|
+
"Source": source,
|
|
102
|
+
"Target": target,
|
|
103
|
+
"ProjectId": self.project_id,
|
|
104
|
+
}
|
|
105
|
+
if untranslated_text is not None:
|
|
106
|
+
params["UntranslatedText"] = untranslated_text
|
|
107
|
+
response = self.client.call_json("TextTranslate", params)
|
|
108
|
+
result = response.get("Response") if isinstance(response, dict) else None
|
|
109
|
+
if not isinstance(result, dict) or not isinstance(
|
|
110
|
+
result.get("TargetText"), str
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("腾讯翻译响应缺少有效的 TargetText")
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
def translate_word(
|
|
116
|
+
self,
|
|
117
|
+
word: str,
|
|
118
|
+
source_language_code: str = "en",
|
|
119
|
+
target_language_code: str = "zh",
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""通过文本接口翻译单词,附带原文 word,供标准化函数使用。"""
|
|
122
|
+
return {
|
|
123
|
+
**self.translate(word, source_language_code, target_language_code),
|
|
124
|
+
"word": word,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def translate_sentence(
|
|
128
|
+
self,
|
|
129
|
+
sentence: str,
|
|
130
|
+
source_language_code: str = "en",
|
|
131
|
+
target_language_code: str = "zh",
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""翻译句子,附带原文 sentence,供标准化函数使用。"""
|
|
134
|
+
return {
|
|
135
|
+
**self.translate(sentence, source_language_code, target_language_code),
|
|
136
|
+
"sentence": sentence,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def format_word_response(cls, response: dict) -> WordInfo:
|
|
141
|
+
"""格式化 translate_word 的结果,保留原始响应于 extended_info。
|
|
142
|
+
|
|
143
|
+
TMT 不提供词典元数据:词性 Other、频率 0.0、空例句/变体/音频均为
|
|
144
|
+
标准结构占位值;normalized 字段使用原文和译文,不额外推断词形。
|
|
145
|
+
"""
|
|
146
|
+
word = response["word"]
|
|
147
|
+
translation = response["TargetText"]
|
|
148
|
+
return {
|
|
149
|
+
"normalized_word": word,
|
|
150
|
+
"display_word": word,
|
|
151
|
+
"translations": [
|
|
152
|
+
{
|
|
153
|
+
"normalized": translation,
|
|
154
|
+
"display": translation,
|
|
155
|
+
"pos_tag": ("Other", "其他"),
|
|
156
|
+
"frequency": 0.0,
|
|
157
|
+
"examples": [],
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
"shapes": [],
|
|
161
|
+
"audio_data": b"",
|
|
162
|
+
"extended_info": dict(response),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def format_sentence_response(cls, response: dict) -> SentenceInfo:
|
|
167
|
+
"""格式化 translate_sentence 的结果;文本接口不提供音频。"""
|
|
168
|
+
return {
|
|
169
|
+
"sentence": response["sentence"],
|
|
170
|
+
"translations": [response["TargetText"]],
|
|
171
|
+
"audio_data": b"",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def download_audio_file(cls, url: str) -> bytes:
|
|
176
|
+
raise NotImplementedError("TMT 文本翻译不提供音频,请使用独立的 TTS 服务")
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
if not self._closed:
|
|
180
|
+
try:
|
|
181
|
+
# SDK 没有公共 close 接口;新版使用 Session,旧版逐请求发送。
|
|
182
|
+
session = getattr(self.client.request.conn, "_session", None)
|
|
183
|
+
if session is not None:
|
|
184
|
+
session.close()
|
|
185
|
+
finally:
|
|
186
|
+
self._closed = True
|
|
187
|
+
|
|
188
|
+
def __enter__(self):
|
|
189
|
+
if self._closed:
|
|
190
|
+
raise RuntimeError("腾讯翻译客户端已关闭")
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
194
|
+
self.close()
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
# 定义支持的语言类型
|
|
5
|
+
LanguageType = Literal["en", "zh"]
|
|
6
|
+
# 定义tts语音风格配置
|
|
7
|
+
AudioStyle = {
|
|
8
|
+
"en":{
|
|
9
|
+
# 输出音频风格: 当前是美国英语,Andrew Multilingual
|
|
10
|
+
"language_code": "en-US",
|
|
11
|
+
"name": "en-US-AndrewMultilingualNeural"
|
|
12
|
+
},
|
|
13
|
+
"zh":{
|
|
14
|
+
"language_code": "zh-CN",
|
|
15
|
+
# 云健
|
|
16
|
+
"name": "zh-CN-YunjianNeural"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class TTSMicrosoft:
|
|
21
|
+
"""[参考](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/index-text-to-speech)
|
|
22
|
+
[获取指定区域的语音列表,比如日本西](https://japanwest.tts.speech.microsoft.com/cognitiveservices/voices/list)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, key: str, location_region: str, language: LanguageType = "en") -> None:
|
|
26
|
+
# 查看自己的服务
|
|
27
|
+
self.location_region = location_region
|
|
28
|
+
self.key = key
|
|
29
|
+
self.language = language
|
|
30
|
+
# app name
|
|
31
|
+
self.user_agent = "language_backend"
|
|
32
|
+
|
|
33
|
+
# 获取token的url, 该url可能不存在
|
|
34
|
+
self.access_token_url = f"https://{self.location_region}.api.cognitive.microsoft.com/sts/v1.0/issueToken"
|
|
35
|
+
# 文本转语音的url, 该url可能不存在
|
|
36
|
+
self.txt_to_speech_url = f"https://{self.location_region}.tts.speech.microsoft.com/cognitiveservices/v1"
|
|
37
|
+
|
|
38
|
+
# 输出格式,当前是: 非流式,24赫兹
|
|
39
|
+
self.output_format = "riff-24khz-16bit-mono-pcm"
|
|
40
|
+
|
|
41
|
+
# 输出音频风格是男性
|
|
42
|
+
self.gender = "Male"
|
|
43
|
+
|
|
44
|
+
# 音频风格
|
|
45
|
+
self.language_code = AudioStyle[self.language]["language_code"]
|
|
46
|
+
self.name = AudioStyle[self.language]["name"]
|
|
47
|
+
print(self.language_code, self.name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_access_token(self):
|
|
51
|
+
"""获取转换文本时,携带的access token"""
|
|
52
|
+
request = requests.post(
|
|
53
|
+
self.access_token_url,
|
|
54
|
+
headers={
|
|
55
|
+
"Ocp-Apim-Subscription-Key": self.key,
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
return request.text
|
|
59
|
+
|
|
60
|
+
def generate_xml_data(self, text: str):
|
|
61
|
+
"""将要转换的文本封装为xml格式"""
|
|
62
|
+
data = f"""
|
|
63
|
+
<speak version='1.0' xml:lang='{self.language_code}'>
|
|
64
|
+
<voice xml:lang='{self.language_code}' xml:gender='{self.gender}'
|
|
65
|
+
name='{self.name}'>
|
|
66
|
+
{text}
|
|
67
|
+
</voice>
|
|
68
|
+
</speak>
|
|
69
|
+
"""
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
def convert_text_to_speech(self, text: str) -> bytes:
|
|
73
|
+
"""将文本转换为语音"""
|
|
74
|
+
r = requests.post(
|
|
75
|
+
self.txt_to_speech_url,
|
|
76
|
+
headers={
|
|
77
|
+
"Content-Type": "application/ssml+xml",
|
|
78
|
+
"X-Microsoft-OutputFormat": self.output_format,
|
|
79
|
+
"User-Agent": self.user_agent,
|
|
80
|
+
"Authorization": f"Bearer {self.fetch_access_token()}",
|
|
81
|
+
},
|
|
82
|
+
data=self.generate_xml_data(text),
|
|
83
|
+
)
|
|
84
|
+
print(self.generate_xml_data(text))
|
|
85
|
+
print(r.status_code, r.content)
|
|
86
|
+
|
|
87
|
+
return r.content
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from PIL import Image, ImageDraw
|
|
5
|
+
except ImportError as exc:
|
|
6
|
+
raise ImportError(
|
|
7
|
+
"Couldn't import pil. pip install --upgrade tencentcloud"
|
|
8
|
+
) from exc
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_image(width: int, height: int, color: tuple[int, int, int]) -> bytes:
|
|
12
|
+
# TODO 导入检测
|
|
13
|
+
image = Image.new("RGB", (width, height), color)
|
|
14
|
+
# create a memory buffer to store image
|
|
15
|
+
buffer = io.BytesIO()
|
|
16
|
+
image.save(buffer, format="PNG")
|
|
17
|
+
image_data = buffer.getvalue()
|
|
18
|
+
return image_data
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# 参考签名工具
|
|
8
|
+
def generate_tencent_key(secret_key,key,secret_id,method="get",**kwargs) -> str:
|
|
9
|
+
# 1. KeyTime
|
|
10
|
+
current_time = datetime.datetime.now()
|
|
11
|
+
future_time = current_time + timedelta(minutes=30)
|
|
12
|
+
|
|
13
|
+
start = current_time.timestamp()
|
|
14
|
+
end = future_time.timestamp()
|
|
15
|
+
|
|
16
|
+
key_time = f"{int(start)};{int(end)}"
|
|
17
|
+
print(f"步骤一: key_time: {key_time}\n")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# 2. SignKey
|
|
21
|
+
sign_key = hmac.new(
|
|
22
|
+
secret_key.encode("utf-8"), key_time.encode("utf-8"), hashlib.sha1
|
|
23
|
+
).hexdigest()
|
|
24
|
+
print(f"步骤二: sign_key: {sign_key}\n")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# 5. http_string
|
|
28
|
+
http_string = f"{method}\n/{key}\n\n\n"
|
|
29
|
+
|
|
30
|
+
# http_string = f"put\n/{key}\n\n\n"
|
|
31
|
+
print(f"步骤五: http_string: {repr(http_string)}\n") # get\n/picture.jpg\n\n\n
|
|
32
|
+
|
|
33
|
+
# 6. StringToSign
|
|
34
|
+
sha1 = hashlib.sha1()
|
|
35
|
+
sha1.update(http_string.encode("utf-8"))
|
|
36
|
+
http_string_sha1 = sha1.hexdigest()
|
|
37
|
+
string_to_sign = f"sha1\n{key_time}\n{http_string_sha1}\n"
|
|
38
|
+
print(f"步骤六: string_to_sign: {repr(string_to_sign)}\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# 7. Signature
|
|
42
|
+
signature = hmac.new(
|
|
43
|
+
sign_key.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1
|
|
44
|
+
).hexdigest()
|
|
45
|
+
print(f"步骤七: signature: {signature}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# 8. authorization
|
|
49
|
+
authorization = f"q-sign-algorithm=sha1&q-ak={secret_id}&q-sign-time={key_time}&q-key-time={key_time}&q-header-list=&q-url-param-list=&q-signature={signature}"
|
|
50
|
+
print(f"authorizaton: {authorization}\n")
|
|
51
|
+
return authorization
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import smtplib
|
|
3
|
+
from email.mime.text import MIMEText
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def calculate_relative_value(input_data: list, db_data: list) -> float:
|
|
7
|
+
"""计算input_data中db_data所占的比例"""
|
|
8
|
+
# 去重
|
|
9
|
+
input_data_unique: set = set(input_data)
|
|
10
|
+
db_data_unique: set = set(db_data)
|
|
11
|
+
# 获取交集.
|
|
12
|
+
# 获取input_data中db_data的数据
|
|
13
|
+
intersection: set = input_data_unique.intersection(db_data_unique)
|
|
14
|
+
|
|
15
|
+
# 获取input_data中db_data所占的比例
|
|
16
|
+
result: float = len(intersection) / len(input_data_unique)
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SendEmail:
|
|
21
|
+
def __init__(self, config: dict) -> None:
|
|
22
|
+
if not set(config).issubset({"host", "port", "user", "passwd"}):
|
|
23
|
+
raise ValueError("config must contain host, user, pass, sender")
|
|
24
|
+
self.host = config["host"]
|
|
25
|
+
self.user = config["user"]
|
|
26
|
+
self.port = config["port"]
|
|
27
|
+
self.passwd = config["passwd"]
|
|
28
|
+
|
|
29
|
+
def send_txt_message(
|
|
30
|
+
self, receivers: list, subject: str, content: str
|
|
31
|
+
) -> tuple[bool, str]:
|
|
32
|
+
"""发送文本邮件, 返回发送状态和错误信息"""
|
|
33
|
+
message = MIMEText(content, "plain", "utf-8")
|
|
34
|
+
message["From"] = f"{self.user}"
|
|
35
|
+
message["To"] = ",".join(receivers)
|
|
36
|
+
message["Subject"] = subject
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
smtpObj = smtplib.SMTP_SSL(self.host, self.port)
|
|
40
|
+
smtpObj.login(self.user, self.passwd)
|
|
41
|
+
smtpObj.sendmail(self.user, receivers, message.as_string())
|
|
42
|
+
logging.info(f"mail has been send successfully. {receivers}")
|
|
43
|
+
return True, "success"
|
|
44
|
+
except smtplib.SMTPException as e:
|
|
45
|
+
logging.error(e)
|
|
46
|
+
return False, str(e)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""合并本地视频,不拼接 Shell 命令、不修改源文件或全局日志。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _timeout(value: float | None) -> None:
|
|
19
|
+
if value is not None and (
|
|
20
|
+
isinstance(value, bool)
|
|
21
|
+
or not isinstance(value, (int, float))
|
|
22
|
+
or not math.isfinite(value)
|
|
23
|
+
or value <= 0
|
|
24
|
+
):
|
|
25
|
+
raise ValueError("timeout 必须为有限的正数或 None")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _binary(name: str | Path) -> str:
|
|
29
|
+
executable = shutil.which(os.fspath(name))
|
|
30
|
+
if executable is None:
|
|
31
|
+
raise FileNotFoundError(f"未找到可执行文件 {name},请安装 FFmpeg 或传入完整路径")
|
|
32
|
+
return executable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _input_file(path: str | Path) -> Path:
|
|
36
|
+
source = Path(path).expanduser().resolve(strict=True)
|
|
37
|
+
if not source.is_file():
|
|
38
|
+
raise ValueError(f"输入不是普通文件:{source}")
|
|
39
|
+
return source
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run(command: list[str], timeout: float | None):
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
command,
|
|
45
|
+
stdin=subprocess.DEVNULL,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
errors="replace",
|
|
50
|
+
check=True,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
# 命令均使用 error 日志级别;有些读取错误会记录日志但仍返回 0。
|
|
54
|
+
if result.stderr.strip():
|
|
55
|
+
raise RuntimeError(f"FFmpeg 工具报告错误:{result.stderr.strip()}")
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _probe(path: Path, binary: str, timeout: float | None) -> dict[str, Any]:
|
|
60
|
+
result = _run(
|
|
61
|
+
[
|
|
62
|
+
binary,
|
|
63
|
+
"-v",
|
|
64
|
+
"error",
|
|
65
|
+
"-show_streams",
|
|
66
|
+
"-show_format",
|
|
67
|
+
"-of",
|
|
68
|
+
"json",
|
|
69
|
+
str(path),
|
|
70
|
+
],
|
|
71
|
+
timeout,
|
|
72
|
+
)
|
|
73
|
+
metadata = json.loads(result.stdout)
|
|
74
|
+
streams = metadata.get("streams") if isinstance(metadata, dict) else None
|
|
75
|
+
if not isinstance(streams, list) or not all(
|
|
76
|
+
isinstance(stream, dict) for stream in streams
|
|
77
|
+
):
|
|
78
|
+
raise ValueError(f"ffprobe 响应缺少有效 streams:{path}")
|
|
79
|
+
if not any(stream.get("codec_type") == "video" for stream in streams):
|
|
80
|
+
raise ValueError(f"文件不包含视频流:{path}")
|
|
81
|
+
return metadata
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def probe_video(
|
|
85
|
+
path: str | Path,
|
|
86
|
+
*,
|
|
87
|
+
ffprobe: str | Path = "ffprobe",
|
|
88
|
+
timeout: float | None = 30,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""返回 ffprobe 完整 JSON;缺少工具、无视频流及执行失败均抛出异常。"""
|
|
91
|
+
_timeout(timeout)
|
|
92
|
+
return _probe(_input_file(path), _binary(ffprobe), timeout)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _stream_signature(stream: dict[str, Any]) -> tuple:
|
|
96
|
+
fields = ["codec_type", "codec_name", "time_base"]
|
|
97
|
+
if stream.get("codec_type") == "video":
|
|
98
|
+
fields += [
|
|
99
|
+
"profile",
|
|
100
|
+
"width",
|
|
101
|
+
"height",
|
|
102
|
+
"pix_fmt",
|
|
103
|
+
"r_frame_rate",
|
|
104
|
+
"sample_aspect_ratio",
|
|
105
|
+
"field_order",
|
|
106
|
+
]
|
|
107
|
+
elif stream.get("codec_type") == "audio":
|
|
108
|
+
fields += ["sample_fmt", "sample_rate", "channels", "channel_layout"]
|
|
109
|
+
return tuple(stream.get(field) for field in fields)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _publish(staged: Path, output: Path, overwrite: bool) -> None:
|
|
113
|
+
if overwrite:
|
|
114
|
+
staged.replace(output)
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
# 同一文件系统上独占创建最终路径,避免覆盖并发产生的文件。
|
|
118
|
+
os.link(staged, output)
|
|
119
|
+
return
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
if exc.errno not in {errno.EPERM, errno.ENOTSUP, errno.EXDEV}:
|
|
122
|
+
raise
|
|
123
|
+
# 不支持硬链接的文件系统使用独占复制,失败时清理本次新建的输出。
|
|
124
|
+
created = False
|
|
125
|
+
try:
|
|
126
|
+
with output.open("xb") as destination:
|
|
127
|
+
created = True
|
|
128
|
+
with staged.open("rb") as source:
|
|
129
|
+
shutil.copyfileobj(source, destination)
|
|
130
|
+
except BaseException:
|
|
131
|
+
if created:
|
|
132
|
+
output.unlink(missing_ok=True)
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def merge_videos(
|
|
137
|
+
inputs: Iterable[str | Path],
|
|
138
|
+
output_path: str | Path,
|
|
139
|
+
*,
|
|
140
|
+
overwrite: bool = False,
|
|
141
|
+
ffmpeg: str | Path = "ffmpeg",
|
|
142
|
+
ffprobe: str | Path = "ffprobe",
|
|
143
|
+
timeout: float | None = None,
|
|
144
|
+
) -> Path:
|
|
145
|
+
"""按输入顺序无重编码合并,返回最终输出的绝对路径。
|
|
146
|
+
|
|
147
|
+
检查流数量/顺序、编码、时间基、分辨率、帧率及音频参数,不自动转码。
|
|
148
|
+
输出父目录必须已存在;默认不覆盖,输出不能是任何输入的别名。
|
|
149
|
+
先在输出父目录的独立临时目录中生成并探测结果,再提交到目标路径。
|
|
150
|
+
timeout 对每个子进程分别生效。CalledProcessError.stderr 保留 FFmpeg 错误。
|
|
151
|
+
"""
|
|
152
|
+
_timeout(timeout)
|
|
153
|
+
if not isinstance(overwrite, bool):
|
|
154
|
+
raise TypeError("overwrite 必须为布尔值")
|
|
155
|
+
if isinstance(inputs, (str, Path)):
|
|
156
|
+
raise TypeError("inputs 必须为路径的可迭代对象,例如 [first, second]")
|
|
157
|
+
sources = [_input_file(path) for path in inputs]
|
|
158
|
+
if not sources:
|
|
159
|
+
raise ValueError("至少需要一个输入视频")
|
|
160
|
+
for source in sources:
|
|
161
|
+
if "\n" in str(source) or "\r" in str(source):
|
|
162
|
+
raise ValueError("视频路径不能包含换行符")
|
|
163
|
+
output = Path(output_path).expanduser().resolve()
|
|
164
|
+
if not output.suffix:
|
|
165
|
+
raise ValueError("输出路径需要扩展名,以指定封装格式")
|
|
166
|
+
if not output.parent.is_dir():
|
|
167
|
+
raise FileNotFoundError(f"输出父目录不存在:{output.parent}")
|
|
168
|
+
if any(
|
|
169
|
+
source == output or (output.exists() and source.samefile(output))
|
|
170
|
+
for source in sources
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("输出路径不能是输入文件或其别名")
|
|
173
|
+
if output.exists():
|
|
174
|
+
if not output.is_file():
|
|
175
|
+
raise ValueError("输出路径不是普通文件")
|
|
176
|
+
if not overwrite:
|
|
177
|
+
raise FileExistsError(output)
|
|
178
|
+
ffmpeg_binary = _binary(ffmpeg)
|
|
179
|
+
ffprobe_binary = _binary(ffprobe)
|
|
180
|
+
first_info = _probe(sources[0], ffprobe_binary, timeout)
|
|
181
|
+
signature = [_stream_signature(stream) for stream in first_info["streams"]]
|
|
182
|
+
for source in sources[1:]:
|
|
183
|
+
info = _probe(source, ffprobe_binary, timeout)
|
|
184
|
+
if [_stream_signature(stream) for stream in info["streams"]] != signature:
|
|
185
|
+
raise ValueError(f"音视频流参数不一致,不能直接合并:{source}")
|
|
186
|
+
|
|
187
|
+
with tempfile.TemporaryDirectory(
|
|
188
|
+
prefix=".video-merge-", dir=output.parent
|
|
189
|
+
) as folder:
|
|
190
|
+
workspace = Path(folder)
|
|
191
|
+
manifest = workspace / "inputs.ffconcat"
|
|
192
|
+
lines = ["ffconcat version 1.0"]
|
|
193
|
+
for source in sources:
|
|
194
|
+
escaped = source.as_posix().replace("'", "'\\''")
|
|
195
|
+
lines.append(f"file '{escaped}'")
|
|
196
|
+
manifest.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
197
|
+
staged = workspace / ("merged" + output.suffix)
|
|
198
|
+
_run(
|
|
199
|
+
[
|
|
200
|
+
ffmpeg_binary,
|
|
201
|
+
"-nostdin",
|
|
202
|
+
"-hide_banner",
|
|
203
|
+
"-loglevel",
|
|
204
|
+
"error",
|
|
205
|
+
"-n",
|
|
206
|
+
"-f",
|
|
207
|
+
"concat",
|
|
208
|
+
"-safe",
|
|
209
|
+
"0",
|
|
210
|
+
"-i",
|
|
211
|
+
str(manifest),
|
|
212
|
+
"-map",
|
|
213
|
+
"0",
|
|
214
|
+
"-c",
|
|
215
|
+
"copy",
|
|
216
|
+
str(staged),
|
|
217
|
+
],
|
|
218
|
+
timeout,
|
|
219
|
+
)
|
|
220
|
+
if not staged.is_file() or not staged.stat().st_size:
|
|
221
|
+
raise RuntimeError("FFmpeg 未生成有效输出文件")
|
|
222
|
+
merged = _probe(staged, ffprobe_binary, timeout)
|
|
223
|
+
|
|
224
|
+
def codecs(info):
|
|
225
|
+
return [
|
|
226
|
+
(stream.get("codec_type"), stream.get("codec_name"))
|
|
227
|
+
for stream in info["streams"]
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
if codecs(merged) != codecs(first_info):
|
|
231
|
+
raise RuntimeError("合并输出的音视频流与输入不一致")
|
|
232
|
+
_publish(staged, output, overwrite)
|
|
233
|
+
return output
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def merge_directory(
|
|
237
|
+
input_directory: str | Path,
|
|
238
|
+
output_path: str | Path,
|
|
239
|
+
*,
|
|
240
|
+
extensions: Iterable[str] = (".mp4",),
|
|
241
|
+
**options,
|
|
242
|
+
) -> Path:
|
|
243
|
+
"""合并目录首层的视频,按自然文件名排序,排除已有目标文件。
|
|
244
|
+
|
|
245
|
+
默认选择 .mp4(不区分扩展名大小写);例如 clip2.mp4 排在 clip10.mp4 前。
|
|
246
|
+
可用 extensions 选择其他封装格式,options 同 merge_videos。
|
|
247
|
+
"""
|
|
248
|
+
directory = Path(input_directory).expanduser().resolve(strict=True)
|
|
249
|
+
if not directory.is_dir():
|
|
250
|
+
raise NotADirectoryError(directory)
|
|
251
|
+
if isinstance(extensions, str):
|
|
252
|
+
raise TypeError("extensions 必须为扩展名的可迭代对象")
|
|
253
|
+
suffixes = set()
|
|
254
|
+
for suffix in extensions:
|
|
255
|
+
if not isinstance(suffix, str) or not suffix.lstrip("."):
|
|
256
|
+
raise ValueError("扩展名不能为空")
|
|
257
|
+
suffixes.add("." + suffix.lstrip(".").lower())
|
|
258
|
+
if not suffixes:
|
|
259
|
+
raise ValueError("extensions 不能为空")
|
|
260
|
+
output = Path(output_path).expanduser().resolve()
|
|
261
|
+
sources = [
|
|
262
|
+
path
|
|
263
|
+
for path in directory.iterdir()
|
|
264
|
+
if path.is_file()
|
|
265
|
+
and path.suffix.lower() in suffixes
|
|
266
|
+
and path.resolve() != output
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
def natural_key(path):
|
|
270
|
+
parts = tuple(
|
|
271
|
+
int(part) if part.isdigit() else part
|
|
272
|
+
for part in re.split(r"(\d+)", path.name.casefold())
|
|
273
|
+
)
|
|
274
|
+
return parts, path.name.casefold(), path.name
|
|
275
|
+
|
|
276
|
+
return merge_videos(sorted(sources, key=natural_key), output, **options)
|
|
File without changes
|