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,14 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
class CommonPackageManager:
|
|
4
|
+
"""公共包管理器"""
|
|
5
|
+
|
|
6
|
+
def _check_dependencies(self, module_names: list[str]):
|
|
7
|
+
"""检查依赖项是否已安装"""
|
|
8
|
+
for module_name in module_names:
|
|
9
|
+
try:
|
|
10
|
+
setattr(self, module_name, importlib.import_module(module_name))
|
|
11
|
+
except ImportError:
|
|
12
|
+
print(
|
|
13
|
+
f"Missing required dependencies. use pip install {module_name} to install it."
|
|
14
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""ClickHouse 查询和 DataFrame 写入工具。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pandas import DataFrame
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pandas():
|
|
12
|
+
try:
|
|
13
|
+
import pandas
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise ImportError("DataFrame 功能需要:pip install pandas numpy") from exc
|
|
16
|
+
return pandas
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClickhouseOperator:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
host: str,
|
|
23
|
+
port: int | str = 9000,
|
|
24
|
+
user: str = "default",
|
|
25
|
+
password: str = "",
|
|
26
|
+
database: str = "default",
|
|
27
|
+
*,
|
|
28
|
+
settings: dict[str, Any] | None = None,
|
|
29
|
+
**client_options,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""使用原生 TCP 协议,client_options 可配置 TLS、连接超时等。
|
|
32
|
+
|
|
33
|
+
默认启用 NumPy,与原 DataFrame 写入行为一致。
|
|
34
|
+
仅执行普通 SQL 时可传 settings={"use_numpy": False},无需 NumPy/pandas。
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(host, str) or not host.strip():
|
|
37
|
+
raise ValueError("host 不能为空")
|
|
38
|
+
port = int(port)
|
|
39
|
+
if not 1 <= port <= 65535:
|
|
40
|
+
raise ValueError("port 必须在 1 到 65535 之间")
|
|
41
|
+
try:
|
|
42
|
+
from clickhouse_driver import Client
|
|
43
|
+
except ImportError as exc:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"请先安装 ClickHouse 依赖:pip install clickhouse-driver numpy"
|
|
46
|
+
) from exc
|
|
47
|
+
|
|
48
|
+
self.host = host
|
|
49
|
+
self.port = port
|
|
50
|
+
self.user = user
|
|
51
|
+
self.password = password
|
|
52
|
+
self.database = database
|
|
53
|
+
self._closed = False
|
|
54
|
+
self.client = Client(
|
|
55
|
+
host=host,
|
|
56
|
+
port=port,
|
|
57
|
+
user=user,
|
|
58
|
+
password=password,
|
|
59
|
+
database=database,
|
|
60
|
+
settings={"use_numpy": True, **(settings or {})},
|
|
61
|
+
**client_options,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _ensure_open(self) -> None:
|
|
65
|
+
if self._closed:
|
|
66
|
+
raise RuntimeError("ClickHouse 客户端已关闭")
|
|
67
|
+
|
|
68
|
+
def execute(self, sql: str, params=None, **options):
|
|
69
|
+
"""执行 SQL 并返回原始结果,params 交由驱动绑定,不拼接参数。"""
|
|
70
|
+
self._ensure_open()
|
|
71
|
+
return self.client.execute(sql, params=params, **options)
|
|
72
|
+
|
|
73
|
+
def fetch_all_tables(self) -> set[str]:
|
|
74
|
+
"""返回当前数据库的表名,无需创建 DataFrame。"""
|
|
75
|
+
return {row[0] for row in self.execute("SHOW TABLES")}
|
|
76
|
+
|
|
77
|
+
def fetch_specify_sql_data(
|
|
78
|
+
self, sql: str, replace_nonwords: bool = True, *, params=None, **options
|
|
79
|
+
) -> tuple[DataFrame, list[str], int]:
|
|
80
|
+
"""返回 (DataFrame, 列名, 行数),保留旧接口并增加查询参数绑定。"""
|
|
81
|
+
self._ensure_open()
|
|
82
|
+
_pandas()
|
|
83
|
+
data = self.client.query_dataframe(
|
|
84
|
+
sql, params=params, replace_nonwords=replace_nonwords, **options
|
|
85
|
+
)
|
|
86
|
+
return data, data.columns.to_list(), len(data)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def convert_datatype_for_ck(data: DataFrame) -> DataFrame:
|
|
90
|
+
"""复制后将 category 列转为字符串,缺失值保留为 None,不修改原数据。"""
|
|
91
|
+
pd = _pandas()
|
|
92
|
+
if not isinstance(data, pd.DataFrame):
|
|
93
|
+
raise TypeError("data 必须为 pandas.DataFrame")
|
|
94
|
+
result = data.copy(deep=True)
|
|
95
|
+
for column, dtype in result.dtypes.items():
|
|
96
|
+
if isinstance(dtype, pd.CategoricalDtype):
|
|
97
|
+
values = result[column].astype(object).map(str, na_action="ignore")
|
|
98
|
+
result[column] = values.where(values.notna(), None)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def insert_specify_sql(self, sql_part: str, data: DataFrame, **options) -> int:
|
|
102
|
+
"""按指定 INSERT SQL 写入 DataFrame,返回驱动报告的写入行数。
|
|
103
|
+
|
|
104
|
+
不自动转换数据;需要转换 category 时先调用 convert_datatype_for_ck。
|
|
105
|
+
"""
|
|
106
|
+
self._ensure_open()
|
|
107
|
+
pd = _pandas()
|
|
108
|
+
if not isinstance(data, pd.DataFrame):
|
|
109
|
+
raise TypeError("data 必须为 pandas.DataFrame")
|
|
110
|
+
return self.client.insert_dataframe(sql_part, data, **options)
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
if not self._closed:
|
|
114
|
+
try:
|
|
115
|
+
self.client.disconnect()
|
|
116
|
+
finally:
|
|
117
|
+
self._closed = True
|
|
118
|
+
|
|
119
|
+
def __enter__(self):
|
|
120
|
+
self._ensure_open()
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
124
|
+
self.close()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""通用 Elasticsearch 工具,不依赖 Django 或业务模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ElasticsearchHandle:
|
|
10
|
+
def __init__(self, hosts, index: str, **client_options) -> None:
|
|
11
|
+
"""显式传入 hosts、索引名;认证、TLS、超时通过 client_options 传入。"""
|
|
12
|
+
if not hosts:
|
|
13
|
+
raise ValueError("hosts 不能为空")
|
|
14
|
+
if not isinstance(index, str) or not index.strip():
|
|
15
|
+
raise ValueError("index 不能为空")
|
|
16
|
+
try:
|
|
17
|
+
from elasticsearch import Elasticsearch
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"请先安装 Elasticsearch 依赖:pip install elasticsearch"
|
|
21
|
+
) from exc
|
|
22
|
+
|
|
23
|
+
self.index = index
|
|
24
|
+
self._closed = False
|
|
25
|
+
self.elasticsearch = Elasticsearch(
|
|
26
|
+
hosts=hosts, **{"request_timeout": 30, **client_options}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _ensure_open(self) -> None:
|
|
30
|
+
if self._closed:
|
|
31
|
+
raise RuntimeError("Elasticsearch 客户端已关闭")
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _document(content: Mapping[str, Any]) -> dict[str, Any]:
|
|
35
|
+
if not isinstance(content, Mapping):
|
|
36
|
+
raise TypeError("文档内容必须为 Mapping")
|
|
37
|
+
return dict(content)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _validate_id(doc_id) -> None:
|
|
41
|
+
if (
|
|
42
|
+
isinstance(doc_id, bool)
|
|
43
|
+
or not isinstance(doc_id, (str, int))
|
|
44
|
+
or (isinstance(doc_id, str) and not doc_id.strip())
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("doc_id 必须为非空字符串或整数")
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _validate_bulk_options(id_field: str, chunk_size: int) -> None:
|
|
50
|
+
if not isinstance(id_field, str) or not id_field.strip():
|
|
51
|
+
raise ValueError("id_field 不能为空")
|
|
52
|
+
if not isinstance(chunk_size, int) or chunk_size <= 0:
|
|
53
|
+
raise ValueError("chunk_size 必须为正整数")
|
|
54
|
+
|
|
55
|
+
def create_index(self, *, mappings=None, settings=None) -> dict[str, Any]:
|
|
56
|
+
"""创建索引并返回完整响应体;索引已存在时保留 SDK 的冲突异常。"""
|
|
57
|
+
self._ensure_open()
|
|
58
|
+
body = self.elasticsearch.indices.create(
|
|
59
|
+
index=self.index, mappings=mappings, settings=settings
|
|
60
|
+
).body
|
|
61
|
+
if not body.get("acknowledged"):
|
|
62
|
+
raise RuntimeError("Elasticsearch 未确认索引创建")
|
|
63
|
+
return body
|
|
64
|
+
|
|
65
|
+
def init_data(
|
|
66
|
+
self,
|
|
67
|
+
documents: Iterable[Mapping[str, Any]],
|
|
68
|
+
*,
|
|
69
|
+
mappings=None,
|
|
70
|
+
settings=None,
|
|
71
|
+
id_field: str = "id",
|
|
72
|
+
chunk_size: int = 500,
|
|
73
|
+
refresh: bool | str = False,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""索引不存在时创建,然后批量创建传入文档,不查询任何业务数据库。
|
|
76
|
+
|
|
77
|
+
已存在的文档 ID 会引发冲突,不覆盖原文档。批量导入不是事务,
|
|
78
|
+
失败前已成功写入的文档不会回滚。
|
|
79
|
+
"""
|
|
80
|
+
self._ensure_open()
|
|
81
|
+
self._validate_bulk_options(id_field, chunk_size)
|
|
82
|
+
index_response = None
|
|
83
|
+
if not self.elasticsearch.indices.exists(index=self.index):
|
|
84
|
+
index_response = self.create_index(mappings=mappings, settings=settings)
|
|
85
|
+
count, errors = self.bulk_create(
|
|
86
|
+
documents, id_field=id_field, chunk_size=chunk_size, refresh=refresh
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
"index_response": index_response,
|
|
90
|
+
"success_count": count,
|
|
91
|
+
"errors": errors,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def bulk_create(
|
|
95
|
+
self,
|
|
96
|
+
documents: Iterable[Mapping[str, Any]],
|
|
97
|
+
*,
|
|
98
|
+
id_field: str = "id",
|
|
99
|
+
chunk_size: int = 500,
|
|
100
|
+
refresh: bool | str = False,
|
|
101
|
+
) -> tuple[int, list[dict[str, Any]]]:
|
|
102
|
+
"""分批消费可迭代文档,返回 (成功数, 错误列表);SDK 失败时抛异常。"""
|
|
103
|
+
self._ensure_open()
|
|
104
|
+
self._validate_bulk_options(id_field, chunk_size)
|
|
105
|
+
from elasticsearch.helpers import bulk
|
|
106
|
+
|
|
107
|
+
def actions():
|
|
108
|
+
for record in documents:
|
|
109
|
+
document = self._document(record)
|
|
110
|
+
doc_id = document.get(id_field)
|
|
111
|
+
self._validate_id(doc_id)
|
|
112
|
+
yield {
|
|
113
|
+
"_op_type": "create",
|
|
114
|
+
"_index": self.index,
|
|
115
|
+
"_id": doc_id,
|
|
116
|
+
"_source": document,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return bulk(
|
|
120
|
+
self.elasticsearch,
|
|
121
|
+
actions(),
|
|
122
|
+
chunk_size=chunk_size,
|
|
123
|
+
raise_on_error=True,
|
|
124
|
+
raise_on_exception=True,
|
|
125
|
+
refresh=refresh,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def create_data(
|
|
129
|
+
self, doc_id, content: Mapping[str, Any], **options
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
self._ensure_open()
|
|
132
|
+
self._validate_id(doc_id)
|
|
133
|
+
return self.elasticsearch.create(
|
|
134
|
+
index=self.index, id=doc_id, document=self._document(content), **options
|
|
135
|
+
).body
|
|
136
|
+
|
|
137
|
+
def update_data(
|
|
138
|
+
self, doc_id, content: Mapping[str, Any], **options
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""content 为待更新字段字典,不需要再包一层 doc。"""
|
|
141
|
+
self._ensure_open()
|
|
142
|
+
self._validate_id(doc_id)
|
|
143
|
+
return self.elasticsearch.update(
|
|
144
|
+
index=self.index, id=doc_id, doc=self._document(content), **options
|
|
145
|
+
).body
|
|
146
|
+
|
|
147
|
+
def delete_doc(self, doc_id, **options) -> dict[str, Any]:
|
|
148
|
+
self._ensure_open()
|
|
149
|
+
self._validate_id(doc_id)
|
|
150
|
+
return self.elasticsearch.delete(index=self.index, id=doc_id, **options).body
|
|
151
|
+
|
|
152
|
+
def search(
|
|
153
|
+
self, query: Mapping[str, Any] | None = None, **options
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""执行查询并返回完整响应体,支持 size、from_、sort 等 SDK 参数。"""
|
|
156
|
+
self._ensure_open()
|
|
157
|
+
return self.elasticsearch.search(
|
|
158
|
+
index=self.index,
|
|
159
|
+
query={"match_all": {}} if query is None else self._document(query),
|
|
160
|
+
**options,
|
|
161
|
+
).body
|
|
162
|
+
|
|
163
|
+
def search_doc(
|
|
164
|
+
self, key: str, *, field: str = "content", size: int = 10, offset: int = 0
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""全文匹配指定字段并返回结果,包含高亮、总数等原始字段。"""
|
|
167
|
+
if not isinstance(key, str) or not key.strip():
|
|
168
|
+
raise ValueError("key 不能为空")
|
|
169
|
+
if not isinstance(field, str) or not field.strip():
|
|
170
|
+
raise ValueError("field 不能为空")
|
|
171
|
+
if size <= 0 or offset < 0:
|
|
172
|
+
raise ValueError("size 必须大于 0,offset 不能小于 0")
|
|
173
|
+
return self.search(
|
|
174
|
+
{"match": {field: key}},
|
|
175
|
+
highlight={"fields": {field: {}}},
|
|
176
|
+
size=size,
|
|
177
|
+
from_=offset,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def show_info(self) -> dict[str, Any]:
|
|
181
|
+
"""保留旧接口:返回索引默认搜索页,不代表遍历所有文档。"""
|
|
182
|
+
return self.search()
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
if not self._closed:
|
|
186
|
+
try:
|
|
187
|
+
self.elasticsearch.close()
|
|
188
|
+
finally:
|
|
189
|
+
self._closed = True
|
|
190
|
+
|
|
191
|
+
def __enter__(self):
|
|
192
|
+
self._ensure_open()
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
196
|
+
self.close()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from urllib.parse import quote_plus
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pymysql # noqa
|
|
6
|
+
from sqlalchemy import create_engine
|
|
7
|
+
except ImportError as exc:
|
|
8
|
+
raise ImportError(
|
|
9
|
+
"Couldn't import pandas sqlalchemy. \
|
|
10
|
+
pip install --upgrade pandas sqlalchemy pymysql"
|
|
11
|
+
) from exc
|
|
12
|
+
from pandas import DataFrame, Series
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MysqlOperator:
|
|
16
|
+
def __init__(self, **kwargs) -> None:
|
|
17
|
+
if not {"host", "port", "user", "password", "database"}.issubset(kwargs.keys()):
|
|
18
|
+
raise ValueError("host,port,user,password,database 必须提供")
|
|
19
|
+
self.host = kwargs["host"]
|
|
20
|
+
self.port = kwargs["port"]
|
|
21
|
+
self.user = kwargs["user"]
|
|
22
|
+
self.password = quote_plus(kwargs["password"])
|
|
23
|
+
self.database = kwargs["database"]
|
|
24
|
+
_ = f"{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
|
|
25
|
+
c = f"mysql+pymysql://{_}"
|
|
26
|
+
self.engine = create_engine(c)
|
|
27
|
+
|
|
28
|
+
def fetch_all_tables(self) -> set[str]:
|
|
29
|
+
"""获取数据库中所有的表名"""
|
|
30
|
+
sql_query = """SELECT table_name AS tables FROM information_schema.tables
|
|
31
|
+
WHERE table_schema = DATABASE();
|
|
32
|
+
"""
|
|
33
|
+
df: DataFrame = pd.read_sql(sql_query, self.engine)
|
|
34
|
+
names: Series[str] = df["tables"]
|
|
35
|
+
names_set: set[str] = set(names.to_list())
|
|
36
|
+
return names_set
|
|
37
|
+
|
|
38
|
+
def fetch_specify_sql_data(self, sql: str) -> tuple[DataFrame, list[str]]:
|
|
39
|
+
"""执行`指定插叙sql`,获取DataFrame类型的插叙结果
|
|
40
|
+
|
|
41
|
+
返回如下: 结果dataframe, 列名组成list
|
|
42
|
+
|
|
43
|
+
replace_noneword False 表示不自动转换列名
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
data: DataFrame = pd.read_sql(sql, self.engine)
|
|
47
|
+
columns: list[str] = data.columns.to_list()
|
|
48
|
+
# print(type(data), columns, len(data))
|
|
49
|
+
return (data, columns)
|
|
50
|
+
|
|
51
|
+
# def execute_sql(self, sql: str, parameters) -> None:
|
|
52
|
+
# """执行sql"""
|
|
53
|
+
# with self.engine.connect() as connection:
|
|
54
|
+
# connection.execute(sql, parameters)
|
|
55
|
+
|
|
56
|
+
# def delete_auto_increment_column(self, df: DataFrame, pk: str = "id") -> DataFrame:
|
|
57
|
+
# """删除dataframe中的自增列"""
|
|
58
|
+
# df = df.drop(columns=pk)
|
|
59
|
+
# return df
|
|
60
|
+
|
|
61
|
+
def insert_database_mysql(self, df: DataFrame, table_name: str) -> int:
|
|
62
|
+
"""将dataframe中的数据插入到mysql中"""
|
|
63
|
+
row: int = df.to_sql(table_name, self.engine, if_exists="append", index=False)
|
|
64
|
+
return row
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""欧路 OpenAPI 生词本客户端,不打印、不读写文件、不退出进程。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
BASE_URL = "https://api.frdic.com/api/open/v1/studylist"
|
|
12
|
+
MAX_PAGE = 50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _TokenAuth(requests.auth.AuthBase):
|
|
16
|
+
def __init__(self, token: str) -> None:
|
|
17
|
+
self._token = token
|
|
18
|
+
|
|
19
|
+
def __call__(self, request):
|
|
20
|
+
request.headers["Authorization"] = self._token
|
|
21
|
+
return request
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EudicClient:
|
|
25
|
+
def __init__(
|
|
26
|
+
self, token: str, *, language: str = "en", timeout: float = 30
|
|
27
|
+
) -> None:
|
|
28
|
+
"""token 为完整 Authorization 请求头值,例如 NIS 开头的授权字符串。"""
|
|
29
|
+
if not isinstance(token, str) or not token.strip():
|
|
30
|
+
raise ValueError("token 不能为空")
|
|
31
|
+
if language not in ("en", "fr", "de", "es"):
|
|
32
|
+
raise ValueError("language 必须为 en、fr、de 或 es")
|
|
33
|
+
if (
|
|
34
|
+
isinstance(timeout, bool)
|
|
35
|
+
or not isinstance(timeout, (int, float))
|
|
36
|
+
or not math.isfinite(timeout)
|
|
37
|
+
or timeout <= 0
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("timeout 必须为有限的正数")
|
|
40
|
+
self.language = language
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self._closed = False
|
|
43
|
+
self._session = requests.Session()
|
|
44
|
+
# 显式认证,避免 .netrc 中的 BasicAuth 覆盖传入 Token。
|
|
45
|
+
self._session.auth = _TokenAuth(token)
|
|
46
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
47
|
+
|
|
48
|
+
def _ensure_open(self) -> None:
|
|
49
|
+
if self._closed:
|
|
50
|
+
raise RuntimeError("欧路客户端已关闭")
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _validate_book_id(book_id: str | int) -> None:
|
|
54
|
+
if (
|
|
55
|
+
isinstance(book_id, bool)
|
|
56
|
+
or not isinstance(book_id, (str, int))
|
|
57
|
+
or (isinstance(book_id, str) and not book_id.strip())
|
|
58
|
+
or (isinstance(book_id, int) and book_id < 0)
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("book_id 必须为非空字符串或非负整数,0 表示默认生词本")
|
|
61
|
+
|
|
62
|
+
def _request(
|
|
63
|
+
self, method: str, path: str, *, expected_status: int, **kwargs
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
self._ensure_open()
|
|
66
|
+
with self._session.request(
|
|
67
|
+
method,
|
|
68
|
+
f"{BASE_URL}/{path}",
|
|
69
|
+
timeout=self.timeout,
|
|
70
|
+
allow_redirects=False,
|
|
71
|
+
**kwargs,
|
|
72
|
+
) as response:
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
if response.status_code != expected_status:
|
|
75
|
+
raise requests.HTTPError(
|
|
76
|
+
f"欧路接口返回非预期状态码 {response.status_code},预期 {expected_status}",
|
|
77
|
+
response=response,
|
|
78
|
+
)
|
|
79
|
+
body = response.json()
|
|
80
|
+
if not isinstance(body, dict):
|
|
81
|
+
raise ValueError("欧路接口响应必须为 JSON 对象")
|
|
82
|
+
return body
|
|
83
|
+
|
|
84
|
+
def create_book(self, name: str) -> dict[str, Any]:
|
|
85
|
+
"""创建指定名称的生词本,返回完整响应;ID 位于 data.id。"""
|
|
86
|
+
self._ensure_open()
|
|
87
|
+
if not isinstance(name, str) or not name.strip():
|
|
88
|
+
raise ValueError("name 不能为空")
|
|
89
|
+
body = self._request(
|
|
90
|
+
"POST",
|
|
91
|
+
"category",
|
|
92
|
+
expected_status=201,
|
|
93
|
+
json={"language": self.language, "name": name},
|
|
94
|
+
)
|
|
95
|
+
data = body.get("data")
|
|
96
|
+
if not isinstance(data, dict) or "id" not in data:
|
|
97
|
+
raise ValueError("创建生词本的响应缺少 data.id")
|
|
98
|
+
self._validate_book_id(data["id"])
|
|
99
|
+
return body
|
|
100
|
+
|
|
101
|
+
def list_books(self) -> dict[str, Any]:
|
|
102
|
+
"""返回所有生词本的完整响应,生词本列表位于 data。"""
|
|
103
|
+
body = self._request(
|
|
104
|
+
"GET", "category", expected_status=200, params={"language": self.language}
|
|
105
|
+
)
|
|
106
|
+
if not isinstance(body.get("data"), list):
|
|
107
|
+
raise ValueError("生词本列表响应缺少 data 数组")
|
|
108
|
+
return body
|
|
109
|
+
|
|
110
|
+
def add_words(
|
|
111
|
+
self, book_id: str | int, words: Iterable[str], *, star: int | None = None
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""向指定生词本写入 1~1000 个单词,返回完整响应。
|
|
114
|
+
|
|
115
|
+
默认保留已有单词星级,使用服务端兼容的 words 字段;传入 star=1~5
|
|
116
|
+
时使用推荐的 items 字段,显式设置本批单词星级。不自动创建生词本。
|
|
117
|
+
"""
|
|
118
|
+
self._ensure_open()
|
|
119
|
+
self._validate_book_id(book_id)
|
|
120
|
+
if star is not None and (
|
|
121
|
+
isinstance(star, bool) or not isinstance(star, int) or not 1 <= star <= 5
|
|
122
|
+
):
|
|
123
|
+
raise ValueError("star 必须为 1 到 5 的整数或 None")
|
|
124
|
+
if isinstance(words, (str, bytes, Mapping)):
|
|
125
|
+
raise TypeError("words 必须为单词字符串的可迭代对象")
|
|
126
|
+
clean_words = []
|
|
127
|
+
for word in words:
|
|
128
|
+
if not isinstance(word, str) or not word.strip():
|
|
129
|
+
raise ValueError("单词必须为非空字符串")
|
|
130
|
+
clean_words.append(word.strip())
|
|
131
|
+
if len(clean_words) > 1000:
|
|
132
|
+
raise ValueError("单次最多导入 1000 个单词")
|
|
133
|
+
if not clean_words:
|
|
134
|
+
raise ValueError("words 不能为空")
|
|
135
|
+
body = {"category_id": book_id, "language": self.language}
|
|
136
|
+
if star is None:
|
|
137
|
+
body["words"] = clean_words
|
|
138
|
+
else:
|
|
139
|
+
body["items"] = [{"word": word, "star": star} for word in clean_words]
|
|
140
|
+
return self._request("POST", "words", expected_status=201, json=body)
|
|
141
|
+
|
|
142
|
+
def get_words(
|
|
143
|
+
self,
|
|
144
|
+
book_id: str | int,
|
|
145
|
+
*,
|
|
146
|
+
page: int = 0,
|
|
147
|
+
page_size: int = 100,
|
|
148
|
+
sort: str = "asc",
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""获取一页完整响应,页码从 0 开始;单词、释义等原始字段位于 data。"""
|
|
151
|
+
self._ensure_open()
|
|
152
|
+
self._validate_book_id(book_id)
|
|
153
|
+
if (
|
|
154
|
+
isinstance(page, bool)
|
|
155
|
+
or not isinstance(page, int)
|
|
156
|
+
or not 0 <= page <= MAX_PAGE
|
|
157
|
+
):
|
|
158
|
+
raise ValueError(f"page 必须为 0 到 {MAX_PAGE} 的整数")
|
|
159
|
+
if (
|
|
160
|
+
isinstance(page_size, bool)
|
|
161
|
+
or not isinstance(page_size, int)
|
|
162
|
+
or not 1 <= page_size <= 100
|
|
163
|
+
):
|
|
164
|
+
raise ValueError("page_size 必须为 1 到 100 的整数")
|
|
165
|
+
if sort not in ("asc", "desc"):
|
|
166
|
+
raise ValueError("sort 必须为 asc 或 desc")
|
|
167
|
+
body = self._request(
|
|
168
|
+
"GET",
|
|
169
|
+
"words",
|
|
170
|
+
expected_status=200,
|
|
171
|
+
params={
|
|
172
|
+
"category_id": book_id,
|
|
173
|
+
"language": self.language,
|
|
174
|
+
"page": page,
|
|
175
|
+
"page_size": page_size,
|
|
176
|
+
"sort": sort,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
if not isinstance(body.get("data"), list):
|
|
180
|
+
raise ValueError("单词列表响应缺少 data 数组")
|
|
181
|
+
return body
|
|
182
|
+
|
|
183
|
+
def export_book(
|
|
184
|
+
self, book_id: str | int, *, page_size: int = 100, sort: str = "asc"
|
|
185
|
+
) -> list[dict[str, Any]]:
|
|
186
|
+
"""从第 0 页导出,返回每页完整响应,不写文件。
|
|
187
|
+
|
|
188
|
+
短页/空页表示导出结束。若第 50 页仍为满页,受接口上限限制无法确认
|
|
189
|
+
是否完整,此时抛出 RuntimeError;请求失败也不返回部分结果冒充完整导出。
|
|
190
|
+
"""
|
|
191
|
+
pages = []
|
|
192
|
+
for page in range(MAX_PAGE + 1):
|
|
193
|
+
body = self.get_words(book_id, page=page, page_size=page_size, sort=sort)
|
|
194
|
+
pages.append(body)
|
|
195
|
+
if len(body["data"]) < page_size:
|
|
196
|
+
return pages
|
|
197
|
+
raise RuntimeError("已达到欧路接口分页上限,无法确认已导出全部单词")
|
|
198
|
+
|
|
199
|
+
def close(self) -> None:
|
|
200
|
+
if not self._closed:
|
|
201
|
+
try:
|
|
202
|
+
self._session.close()
|
|
203
|
+
finally:
|
|
204
|
+
self._closed = True
|
|
205
|
+
|
|
206
|
+
def __enter__(self):
|
|
207
|
+
self._ensure_open()
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
211
|
+
self.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import ByteString
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Storage(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def __init__(self, **kwargs) -> None:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def save(self, filename: str, content: ByteString) -> tuple[bool, str]:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def load(self, filename: str) -> tuple[bool, bytes]:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def download_audio_file(self, url: str) -> bytes:
|
|
22
|
+
try:
|
|
23
|
+
response = requests.get(url=url)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
raise ValueError(f"download error: {e}")
|
|
26
|
+
if response.status_code != 200:
|
|
27
|
+
raise ValueError(f"download failed,status code: {response.status_code}")
|
|
28
|
+
logging.info(f"download success: {url}")
|
|
29
|
+
return response.content
|