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.
Files changed (60) hide show
  1. jwf_common_python/__init__.py +14 -0
  2. jwf_common_python/db_utils/__init__.py +6 -0
  3. jwf_common_python/db_utils/clickhouse.py +124 -0
  4. jwf_common_python/db_utils/elasticsearch.py +196 -0
  5. jwf_common_python/db_utils/mysql.py +64 -0
  6. jwf_common_python/eudic/__init__.py +5 -0
  7. jwf_common_python/eudic/wordbook.py +211 -0
  8. jwf_common_python/file_storage/__init__.py +2 -0
  9. jwf_common_python/file_storage/file_interface.py +29 -0
  10. jwf_common_python/file_storage/local.py +50 -0
  11. jwf_common_python/file_storage/tencent_cos.py +65 -0
  12. jwf_common_python/geography/__init__.py +8 -0
  13. jwf_common_python/geography/district_amap.py +181 -0
  14. jwf_common_python/geography/search_amap.py +84 -0
  15. jwf_common_python/github/__init__.py +5 -0
  16. jwf_common_python/github/webhooks.py +87 -0
  17. jwf_common_python/language_utils/__init__.py +34 -0
  18. jwf_common_python/language_utils/core.py +203 -0
  19. jwf_common_python/language_utils/files.py +52 -0
  20. jwf_common_python/language_utils/web.py +117 -0
  21. jwf_common_python/llm/__init__.py +1 -0
  22. jwf_common_python/llm/aliyun_qwen.py +10 -0
  23. jwf_common_python/llm/azure_gpt.py +66 -0
  24. jwf_common_python/llm/baidu_ernie.py +112 -0
  25. jwf_common_python/message_queue/__init__.py +7 -0
  26. jwf_common_python/message_queue/interface.py +49 -0
  27. jwf_common_python/message_queue/kafka.py +145 -0
  28. jwf_common_python/message_queue/rabbitmq.py +113 -0
  29. jwf_common_python/oauth2/__init__.py +0 -0
  30. jwf_common_python/oauth2/google.py +158 -0
  31. jwf_common_python/parse_subtitle/__init__.py +6 -0
  32. jwf_common_python/parse_subtitle/aa.vtt +2364 -0
  33. jwf_common_python/parse_subtitle/parse_srt_file.py +60 -0
  34. jwf_common_python/pdf/__init__.py +0 -0
  35. jwf_common_python/pdf/aaa.py +38 -0
  36. jwf_common_python/pdf/local_parse.py +259 -0
  37. jwf_common_python/sms/__init__.py +1 -0
  38. jwf_common_python/sms/interface.py +7 -0
  39. jwf_common_python/sms/tencent_sms.py +117 -0
  40. jwf_common_python/translate/__init__.py +152 -0
  41. jwf_common_python/translate/_youdao.py +234 -0
  42. jwf_common_python/translate/microsoft.py +696 -0
  43. jwf_common_python/translate/tencent.py +194 -0
  44. jwf_common_python/tts/__init__.py +0 -0
  45. jwf_common_python/tts/microsoft.py +87 -0
  46. jwf_common_python/utils/__init__.py +0 -0
  47. jwf_common_python/utils/image_utils.py +18 -0
  48. jwf_common_python/utils/tencent_utils.py +51 -0
  49. jwf_common_python/utils/utils.py +46 -0
  50. jwf_common_python/video/__init__.py +5 -0
  51. jwf_common_python/video/merge.py +276 -0
  52. jwf_common_python/weather/__init__.py +0 -0
  53. jwf_common_python/weather/amap.py +127 -0
  54. jwf_common_python/weather/wanwei.py +58 -0
  55. jwf_common_python/youdao/__init__.py +13 -0
  56. jwf_common_python/youdao/wordbook.py +88 -0
  57. jwf_common_python-1.0.0.dist-info/METADATA +669 -0
  58. jwf_common_python-1.0.0.dist-info/RECORD +60 -0
  59. jwf_common_python-1.0.0.dist-info/WHEEL +4 -0
  60. 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,6 @@
1
+ """数据库工具;后端依赖在实例化或使用对应功能时加载。"""
2
+
3
+ from .clickhouse import ClickhouseOperator
4
+ from .elasticsearch import ElasticsearchHandle
5
+
6
+ __all__ = ["ClickhouseOperator", "ElasticsearchHandle"]
@@ -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,5 @@
1
+ """欧路词典生词本工具。"""
2
+
3
+ from .wordbook import EudicClient
4
+
5
+ __all__ = ["EudicClient"]
@@ -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,2 @@
1
+ from .local import LocalStorage
2
+ from .tencent_cos import TencentCos
@@ -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