nosp 0.7.5__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.
- nosp/__init__.py +2 -0
- nosp/cache.py +104 -0
- nosp/config.py +34 -0
- nosp/core.py +41 -0
- nosp/database.py +451 -0
- nosp/err.py +5 -0
- nosp/file.py +381 -0
- nosp/http.py +380 -0
- nosp/lazy.py +12 -0
- nosp/monitor.py +333 -0
- nosp/mq.py +204 -0
- nosp/parse.py +399 -0
- nosp/spider.py +100 -0
- nosp/utils.py +86 -0
- nosp-0.7.5.dist-info/METADATA +67 -0
- nosp-0.7.5.dist-info/RECORD +18 -0
- nosp-0.7.5.dist-info/WHEEL +5 -0
- nosp-0.7.5.dist-info/top_level.txt +1 -0
nosp/__init__.py
ADDED
nosp/cache.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional, Callable, Any, Dict, TypeVar, Generic
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
from nosp.config import RedisConfig
|
|
7
|
+
from nosp.database import Redis
|
|
8
|
+
from nosp.utils import get_md5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheBaseModel:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._on_change: Optional[Callable[[str, str], None]] = None
|
|
14
|
+
self._on_get: Optional[Callable[[str], str]] = None
|
|
15
|
+
self._live = False
|
|
16
|
+
|
|
17
|
+
def __init_subclass__(cls):
|
|
18
|
+
cls._on_change: Optional[Callable[[str, str], None]] = None
|
|
19
|
+
cls._on_get: Optional[Callable[[str], str]] = None
|
|
20
|
+
cls._live = False
|
|
21
|
+
|
|
22
|
+
def on_change(self, callback: Callable[[str, str], None]) -> None:
|
|
23
|
+
"""注册变更回调"""
|
|
24
|
+
self._on_change = callback
|
|
25
|
+
|
|
26
|
+
def on_get(self, callback: Callable[[str], str]) -> None:
|
|
27
|
+
"""注册Get回调"""
|
|
28
|
+
self._on_get = callback
|
|
29
|
+
|
|
30
|
+
def __setattr__(self, key: str, value: Any):
|
|
31
|
+
super().__setattr__(key, value)
|
|
32
|
+
if not key.startswith("_") and hasattr(self, '_on_change') and self._on_change is not None:
|
|
33
|
+
self._on_change(key, str(value))
|
|
34
|
+
|
|
35
|
+
def __getattribute__(self, key: str) -> Any:
|
|
36
|
+
if key.startswith("_"):
|
|
37
|
+
return super().__getattribute__(key)
|
|
38
|
+
|
|
39
|
+
if hasattr(self, '_live') and self._live and hasattr(self, '_on_get') and self._on_get is not None:
|
|
40
|
+
try:
|
|
41
|
+
result = self._on_get(key)
|
|
42
|
+
if result is None and super().__getattribute__(key) is not None:
|
|
43
|
+
return super().__getattribute__(key)
|
|
44
|
+
return self._on_get(key)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.warning(f"on_get({key}) failed: {e}")
|
|
47
|
+
|
|
48
|
+
return super().__getattribute__(key)
|
|
49
|
+
|
|
50
|
+
def set(self, data: Dict[str, Any]):
|
|
51
|
+
annotations = self.__class__.__annotations__
|
|
52
|
+
for k, v in data.items():
|
|
53
|
+
if k in annotations:
|
|
54
|
+
setattr(self, k, v)
|
|
55
|
+
|
|
56
|
+
def get(self) -> Dict[str, str]:
|
|
57
|
+
return {
|
|
58
|
+
k: str(getattr(self, k))
|
|
59
|
+
for k in self._class__.__annotations__
|
|
60
|
+
if hasattr(self, k)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
T = TypeVar('T', bound=CacheBaseModel)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Cache(Generic[T]):
|
|
68
|
+
def __init__(self, cfg: RedisConfig, db=15, data_type=None, live=False, key=None):
|
|
69
|
+
self.key = key if key else get_md5(sys.modules.get("__main__").__file__)
|
|
70
|
+
self.redis = Redis.simple(cfg, db=db)
|
|
71
|
+
self.data: Optional[T] = None
|
|
72
|
+
if data_type is not None:
|
|
73
|
+
self.data: Optional[T] = data_type()
|
|
74
|
+
self._load()
|
|
75
|
+
self.data._live = live
|
|
76
|
+
|
|
77
|
+
def _save_to_redis(self, key, value):
|
|
78
|
+
"""私有方法:保存数据到 Redis"""
|
|
79
|
+
try:
|
|
80
|
+
self.redis.r.hset(self.key, key, value)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning(f"Save failed: {e}")
|
|
83
|
+
|
|
84
|
+
def _on_get(self, key: str) -> str:
|
|
85
|
+
return self.redis.r.hget(self.key, key)
|
|
86
|
+
|
|
87
|
+
def _load(self):
|
|
88
|
+
if self.data is None:
|
|
89
|
+
return None
|
|
90
|
+
data = self.redis.r.hgetall(self.key)
|
|
91
|
+
if data and isinstance(data, dict):
|
|
92
|
+
try:
|
|
93
|
+
self.data.set(data)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.warning(f"Load failed: {e}")
|
|
96
|
+
self.data.on_change(self._save_to_redis)
|
|
97
|
+
self.data.on_get(self._on_get)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def get(self, key: str) -> str:
|
|
101
|
+
return self.redis.r.hget(self.key, key)
|
|
102
|
+
|
|
103
|
+
def set(self, key: str, value):
|
|
104
|
+
return self.redis.r.hset(self.key, key, value)
|
nosp/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class MysqlConfig(object):
|
|
2
|
+
def __init__(self, host: str, port: int, username: str, password: str, db: str, charset: str = 'utf8mb4'):
|
|
3
|
+
self.host = host
|
|
4
|
+
self.port = port
|
|
5
|
+
self.username = username
|
|
6
|
+
self.password = password
|
|
7
|
+
self.db = db
|
|
8
|
+
self.charset = charset
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RedisConfig(object):
|
|
12
|
+
def __init__(self, host: str, port: int, password: str, db: int):
|
|
13
|
+
self.host = host
|
|
14
|
+
self.port = port
|
|
15
|
+
self.password = password
|
|
16
|
+
self.db = db
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RabbitMQConfig(object):
|
|
20
|
+
def __init__(self, host: str, port: int, username: str, password: str, virtual_host: str = '/'):
|
|
21
|
+
self.host = host
|
|
22
|
+
self.port = port
|
|
23
|
+
self.username = username
|
|
24
|
+
self.password = password
|
|
25
|
+
self.virtual_host = virtual_host
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OssConfig(object):
|
|
29
|
+
def __init__(self, access_key: str, access_secret: str, endpoint: str, bucket_name: str, region: str = None):
|
|
30
|
+
self.access_key = access_key
|
|
31
|
+
self.access_secret = access_secret
|
|
32
|
+
self.endpoint = endpoint
|
|
33
|
+
self.bucket_name = bucket_name
|
|
34
|
+
self.region = region
|
nosp/core.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_path():
|
|
7
|
+
"""
|
|
8
|
+
向上查找 [config.py or pyproject.toml] 所在目录,并将其添加到 sys.path 中。
|
|
9
|
+
"""
|
|
10
|
+
current_dir = Path(sys.modules.get("__main__").__file__).resolve().parent
|
|
11
|
+
root_dir = None
|
|
12
|
+
for parent in [current_dir, *current_dir.parents]:
|
|
13
|
+
if (parent / "config.py").exists() or (parent / "pyproject.toml").exists():
|
|
14
|
+
root_dir = parent
|
|
15
|
+
break
|
|
16
|
+
if root_dir is not None:
|
|
17
|
+
root_path = str(root_dir)
|
|
18
|
+
if sys.path[0] != root_path:
|
|
19
|
+
sys.path.insert(0, root_path)
|
|
20
|
+
print(f"[load_path] 添加路径到 sys.path: {root_path}")
|
|
21
|
+
else:
|
|
22
|
+
print("[load_path] 未找到 config.py 或 pyproject.toml,未修改 sys.path。")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_env(filepath='.env'):
|
|
27
|
+
"""
|
|
28
|
+
从指定路径读取 .env 文件,并将变量加载到 os.environ 中。
|
|
29
|
+
:param filepath: .env 文件路径,默认为当前目录下的 .env
|
|
30
|
+
"""
|
|
31
|
+
if not os.path.isfile(filepath):
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
with open(filepath, encoding='utf-8') as file:
|
|
35
|
+
for line in file:
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if not line or line.startswith('#'):
|
|
38
|
+
continue
|
|
39
|
+
if '=' in line:
|
|
40
|
+
key, value = line.split('=', 1)
|
|
41
|
+
os.environ[key] = value.strip('"\' ')
|
nosp/database.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@author: noybzy
|
|
3
|
+
@time: 2024/9/19 上午1:02
|
|
4
|
+
@file: database.py
|
|
5
|
+
@describe: 数据库操作 mysql,redis
|
|
6
|
+
@updated: 2025/2/14
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from typing import Union, TYPE_CHECKING, Optional
|
|
11
|
+
|
|
12
|
+
import pymysql
|
|
13
|
+
import pymysql.cursors
|
|
14
|
+
from dbutils.pooled_db import PooledDB
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from .config import MysqlConfig, RedisConfig
|
|
18
|
+
from .lazy import LazyLoader
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import redis
|
|
22
|
+
else:
|
|
23
|
+
redis = LazyLoader("redis")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnManager:
|
|
27
|
+
def __init__(self, sql_pool, kv=False):
|
|
28
|
+
self.kv = kv
|
|
29
|
+
self.sql_pool = sql_pool
|
|
30
|
+
self.conn: Union[pymysql.connections.Connection, None] = None
|
|
31
|
+
self.cursor: Union[pymysql.cursors.Cursor, None] = None
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> 'ConnManager':
|
|
34
|
+
self.conn = self.sql_pool.connection()
|
|
35
|
+
if self.kv:
|
|
36
|
+
self.cursor = self.conn.cursor(cursor=pymysql.cursors.DictCursor)
|
|
37
|
+
else:
|
|
38
|
+
self.cursor = self.conn.cursor()
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
42
|
+
if self.cursor:
|
|
43
|
+
self.cursor.close()
|
|
44
|
+
if self.conn:
|
|
45
|
+
self.conn.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Mysql(object):
|
|
49
|
+
def __init__(self, host: str = '127.0.0.1', username: str = 'root', password: str = '123456', db: str = 'test',
|
|
50
|
+
drop_column: list = None, pool_num: int = 4, max_pool: int = None, port: int = 3306, monitor=None,
|
|
51
|
+
test=False):
|
|
52
|
+
# monitor 历史遗留问题,后续计划删掉
|
|
53
|
+
if drop_column is None:
|
|
54
|
+
drop_column = ["id", "updated", 'created', 'isonline', 'islocal']
|
|
55
|
+
self.host = host
|
|
56
|
+
self.port = port
|
|
57
|
+
self.username = username
|
|
58
|
+
self.password = password
|
|
59
|
+
self.db = db
|
|
60
|
+
self.drop_column = drop_column
|
|
61
|
+
self.pool_num = pool_num
|
|
62
|
+
self.sql_pool = PooledDB(
|
|
63
|
+
creator=pymysql, # 使用链接数据库的模块
|
|
64
|
+
maxconnections=max_pool, # 连接池允许的最大连接数,0和None表示不限制连接数
|
|
65
|
+
mincached=self.pool_num, # 初始化时,连接池中至少创建的空闲的连接,0表示不创建
|
|
66
|
+
# maxcached=5, # 连接池中空闲的最多连接数,0和None表示不限制
|
|
67
|
+
# maxshared=3, # 连接池中最多共享的连接数量,0和None表示全部共享
|
|
68
|
+
blocking=True, # 连接池中如果没有可用连接后,阻塞等待,而不是报错
|
|
69
|
+
# maxusage=None, # 一个连接最多被重复使用的次数,None表示无限制
|
|
70
|
+
# setsession=[], # 开始会话前执行的命令列表
|
|
71
|
+
# ping=0, # ping MySQL服务端,检查服务是否可用
|
|
72
|
+
host=self.host,
|
|
73
|
+
port=self.port,
|
|
74
|
+
user=self.username,
|
|
75
|
+
password=self.password,
|
|
76
|
+
database=self.db,
|
|
77
|
+
charset='utf8mb4'
|
|
78
|
+
)
|
|
79
|
+
self.sql_pool.connection()
|
|
80
|
+
self.table_name_list = self.get_db_name() # 获取所有表名
|
|
81
|
+
self.column_list = {}
|
|
82
|
+
self.test = test
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def simple(config: MysqlConfig, pool_num=4, db: str = None, monitor=None, max_pool: int = None,
|
|
86
|
+
test=False) -> 'Mysql':
|
|
87
|
+
"""
|
|
88
|
+
创建Mysql
|
|
89
|
+
:param max_pool: 最大连接数
|
|
90
|
+
:param config: 连接配置
|
|
91
|
+
:param pool_num: 线程池数量
|
|
92
|
+
:param db: 数据库
|
|
93
|
+
:param monitor: 监控
|
|
94
|
+
:return:
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
if db is None:
|
|
98
|
+
db = config.db
|
|
99
|
+
mysql = Mysql(
|
|
100
|
+
host=config.host,
|
|
101
|
+
port=config.port,
|
|
102
|
+
username=config.username,
|
|
103
|
+
password=config.password,
|
|
104
|
+
db=db,
|
|
105
|
+
pool_num=pool_num,
|
|
106
|
+
max_pool=max_pool,
|
|
107
|
+
test=test
|
|
108
|
+
)
|
|
109
|
+
return mysql
|
|
110
|
+
|
|
111
|
+
def get_db_name(self, db: str = None) -> list:
|
|
112
|
+
"""
|
|
113
|
+
获取指定库的所有表
|
|
114
|
+
:return:
|
|
115
|
+
"""
|
|
116
|
+
if db is None:
|
|
117
|
+
db = self.db
|
|
118
|
+
|
|
119
|
+
sql = "select table_name from information_schema.tables where table_schema='{}'".format(db)
|
|
120
|
+
with ConnManager(self.sql_pool) as cm:
|
|
121
|
+
cm.cursor.execute(sql)
|
|
122
|
+
db_list = cm.cursor.fetchall()
|
|
123
|
+
db_list = [i[0] for i in db_list]
|
|
124
|
+
return db_list
|
|
125
|
+
|
|
126
|
+
def get_table_column_list(self, table_name: str) -> dict:
|
|
127
|
+
"""
|
|
128
|
+
获取指定表的所有字段
|
|
129
|
+
:param table_name: 表名
|
|
130
|
+
:return:
|
|
131
|
+
"""
|
|
132
|
+
column_list = self.column_list.get(table_name)
|
|
133
|
+
if column_list:
|
|
134
|
+
return column_list
|
|
135
|
+
else:
|
|
136
|
+
sql = 'select column_name,data_type from information_schema.columns where table_name=%s and table_schema=%s'
|
|
137
|
+
with ConnManager(self.sql_pool) as cm:
|
|
138
|
+
cm.cursor.execute(sql, (table_name, self.db))
|
|
139
|
+
column_list = cm.cursor.fetchall()
|
|
140
|
+
insert_columns = {}
|
|
141
|
+
for i in column_list:
|
|
142
|
+
if i[0] in self.drop_column:
|
|
143
|
+
continue
|
|
144
|
+
insert_columns[i[0]] = i[1]
|
|
145
|
+
self.column_list[table_name] = insert_columns
|
|
146
|
+
return insert_columns
|
|
147
|
+
|
|
148
|
+
def execute_sql(self, sql, params=None, kv=True) -> Union[list, int]:
|
|
149
|
+
"""
|
|
150
|
+
执行sql语句
|
|
151
|
+
:param sql: sql代码
|
|
152
|
+
:param params: 参数
|
|
153
|
+
:param kv: 查询结果是否转成字典
|
|
154
|
+
:return: 返回查询结果 | 受影响行数
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
with ConnManager(self.sql_pool, kv=kv) as cm:
|
|
158
|
+
cm.cursor.execute(sql, params)
|
|
159
|
+
if sql.strip().lower().startswith("select"):
|
|
160
|
+
result = cm.cursor.fetchall() # 查询语句返回所有结果
|
|
161
|
+
return result
|
|
162
|
+
else:
|
|
163
|
+
# 非查询语句,返回受影响的行数
|
|
164
|
+
cm.conn.commit()
|
|
165
|
+
return cm.cursor.rowcount
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f'执行sql失败:{e.__class__.__name__}|{e.args}>>{sql}, {params}')
|
|
168
|
+
raise e
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def wrapper_value(key, value, insert_columns):
|
|
172
|
+
if value is None:
|
|
173
|
+
if insert_columns.get(key) in ('datetime', 'date', 'time', 'year'):
|
|
174
|
+
return None
|
|
175
|
+
else:
|
|
176
|
+
return ''
|
|
177
|
+
else:
|
|
178
|
+
if isinstance(value, bool):
|
|
179
|
+
return value
|
|
180
|
+
else:
|
|
181
|
+
return str(value)
|
|
182
|
+
|
|
183
|
+
def insert_data(self, item: dict, table_name: str, replace: bool = False) -> int:
|
|
184
|
+
"""
|
|
185
|
+
插入单条数据(默认表里的字段不允许为空[除 'datetime', 'date', 'time', 'year' 字段],['bigint','int', 'decimal' 字段为None时取得默认值] )
|
|
186
|
+
:param item: 数据
|
|
187
|
+
:param table_name: 表名
|
|
188
|
+
:param replace: 是否使用replace,默认insert
|
|
189
|
+
:return: 返回成功的行数
|
|
190
|
+
"""
|
|
191
|
+
if item:
|
|
192
|
+
insert_columns = self.get_table_column_list(table_name)
|
|
193
|
+
# 过滤字段,并移除 'int' 或 'decimal' 类型为 None 的字段 (采用数据库默认值)
|
|
194
|
+
filtered_item = {
|
|
195
|
+
k: v for k, v in item.items() if
|
|
196
|
+
k in insert_columns.keys() and not (
|
|
197
|
+
(v is None or v == '') and insert_columns[k] in ('bigint', 'int', 'decimal', 'tinyint'))
|
|
198
|
+
}
|
|
199
|
+
if not filtered_item:
|
|
200
|
+
logger.warning(f"没有找到可插入的字段: {item}")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
insert_columns_new = ['`%s`' % i for i in filtered_item.keys()]
|
|
204
|
+
# 选择 SQL 操作:INSERT 或 REPLACE
|
|
205
|
+
operation = "REPLACE" if replace else "INSERT"
|
|
206
|
+
columns = ', '.join(insert_columns_new)
|
|
207
|
+
placeholders = ', '.join(['%s'] * len(filtered_item))
|
|
208
|
+
sql = f"{operation} INTO {table_name} ({columns}) VALUES ({placeholders})"
|
|
209
|
+
|
|
210
|
+
values_list = []
|
|
211
|
+
for key in filtered_item.keys():
|
|
212
|
+
value = filtered_item.get(key)
|
|
213
|
+
values_list.append(self.wrapper_value(key, value, insert_columns))
|
|
214
|
+
values = tuple(values_list)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
with ConnManager(self.sql_pool) as cm:
|
|
218
|
+
cm.cursor.execute(sql, values)
|
|
219
|
+
if self.test:
|
|
220
|
+
cm.conn.rollback()
|
|
221
|
+
logger.warning(f'[TEST MODE] {operation} {table_name} would affect rows: {cm.cursor.rowcount}')
|
|
222
|
+
return 0
|
|
223
|
+
cm.conn.commit() # 提交事务
|
|
224
|
+
logger.success(f'{operation} {table_name} rows:{cm.cursor.rowcount} successfully')
|
|
225
|
+
rowcount = cm.cursor.rowcount
|
|
226
|
+
return rowcount
|
|
227
|
+
except pymysql.IntegrityError as e:
|
|
228
|
+
error_code, error_message = e.args # 获取错误码和错误信息
|
|
229
|
+
if error_code == 1062:
|
|
230
|
+
# 提取唯一键冲突的详细信息
|
|
231
|
+
duplicate_key = error_message.split("for key")[1].strip().replace('\'', '').replace('\"',
|
|
232
|
+
'') if "for key" in error_message else "Unknown key"
|
|
233
|
+
duplicate_value = item.get(duplicate_key.split('.')[-1])
|
|
234
|
+
logger.warning(f"IntegrityError: {duplicate_key}: {duplicate_value}")
|
|
235
|
+
else:
|
|
236
|
+
logger.warning(f"IntegrityError: {e.args}")
|
|
237
|
+
return 0
|
|
238
|
+
except pymysql.ProgrammingError as e:
|
|
239
|
+
logger.error(f'sql 语法错误 {e.__class__.__name__},sql:{sql}')
|
|
240
|
+
raise e
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f'insert error {e.__class__.__name__}')
|
|
243
|
+
raise e
|
|
244
|
+
|
|
245
|
+
else:
|
|
246
|
+
logger.warning('插入为空')
|
|
247
|
+
|
|
248
|
+
def insert_data_many(self, item_list: list[dict], table_name: str, batch_size: int = 500, replace: bool = False,
|
|
249
|
+
ignore: bool = False) -> int:
|
|
250
|
+
"""
|
|
251
|
+
批量插入数据(默认表里的字段不允许为空[除 'datetime', 'date', 'time', 'year' 字段],注意['bigint','int', 'decimal' 字段为None时请手动设置默认值] )
|
|
252
|
+
:param item_list: 数据列表
|
|
253
|
+
:param table_name: 表名
|
|
254
|
+
:param batch_size: 批量插入大小
|
|
255
|
+
:param replace: 是否使用replace,默认insert
|
|
256
|
+
:param ignore: 是否忽略掉插入错误
|
|
257
|
+
:return:
|
|
258
|
+
"""
|
|
259
|
+
# 批量插入不过滤插入参数
|
|
260
|
+
|
|
261
|
+
if not item_list:
|
|
262
|
+
logger.warning("插入的数据列表为空")
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
# 获取表的列名
|
|
266
|
+
insert_columns = self.get_table_column_list(table_name)
|
|
267
|
+
if not insert_columns:
|
|
268
|
+
logger.warning(f"表 {table_name} 的列名列表为空")
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
insert_columns_new = ['`%s`' % i for i in insert_columns.keys()]
|
|
272
|
+
# 生成批量插入的 SQL 和数据
|
|
273
|
+
operation = "REPLACE" if replace else "INSERT"
|
|
274
|
+
if ignore:
|
|
275
|
+
operation = operation + ' ' + 'ignore '
|
|
276
|
+
columns = ', '.join(insert_columns_new)
|
|
277
|
+
placeholders = ', '.join(['%s'] * len(insert_columns))
|
|
278
|
+
sql = f"{operation} INTO {table_name} ({columns}) VALUES ({placeholders})"
|
|
279
|
+
|
|
280
|
+
rows = []
|
|
281
|
+
for item in item_list:
|
|
282
|
+
values_list = []
|
|
283
|
+
for key in insert_columns.keys():
|
|
284
|
+
value = item.get(key)
|
|
285
|
+
values_list.append(self.wrapper_value(key, value, insert_columns))
|
|
286
|
+
rows.append(tuple(values_list))
|
|
287
|
+
total_inserted = 0
|
|
288
|
+
try:
|
|
289
|
+
with ConnManager(self.sql_pool) as cm:
|
|
290
|
+
# 批量插入,每次插入 batch_size 条记录
|
|
291
|
+
for i in range(0, len(rows), batch_size):
|
|
292
|
+
batch = rows[i:i + batch_size]
|
|
293
|
+
try:
|
|
294
|
+
cm.cursor.executemany(sql, batch) # 批量插入
|
|
295
|
+
cm.conn.commit() # 提交事务
|
|
296
|
+
inserted_rows = cm.cursor.rowcount
|
|
297
|
+
total_inserted += inserted_rows
|
|
298
|
+
logger.success(f"insert {inserted_rows}/{len(batch)} rows into {table_name} successfully.")
|
|
299
|
+
except pymysql.IntegrityError as e:
|
|
300
|
+
# 捕获批量插入时的唯一键冲突错误
|
|
301
|
+
logger.warning(f"IntegrityError during batch insert,尝试单条插入")
|
|
302
|
+
cm.conn.rollback()
|
|
303
|
+
for item in item_list[i:i + batch_size]:
|
|
304
|
+
try:
|
|
305
|
+
# 逐条插入调用 insert_data 方法
|
|
306
|
+
self.insert_data(item, table_name, replace)
|
|
307
|
+
except Exception as individual_e:
|
|
308
|
+
logger.error(f"insert error during individual inserts: {individual_e}")
|
|
309
|
+
raise individual_e
|
|
310
|
+
except pymysql.ProgrammingError as e:
|
|
311
|
+
logger.error(f"SQL 语法错误: {e}, SQL: {sql}")
|
|
312
|
+
raise e
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Insert error: {e.__class__.__name__} - {str(e)}")
|
|
315
|
+
raise e
|
|
316
|
+
return total_inserted
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Message(object):
|
|
320
|
+
|
|
321
|
+
def __init__(self, r, queue_name, queue_name_tmp, data):
|
|
322
|
+
self.r = r
|
|
323
|
+
self.queue_name = queue_name
|
|
324
|
+
self.queue_name_tmp = queue_name_tmp
|
|
325
|
+
self.data = data
|
|
326
|
+
|
|
327
|
+
def ack(self):
|
|
328
|
+
self.r.lrem(self.queue_name_tmp, 0, self.data)
|
|
329
|
+
|
|
330
|
+
def nack(self):
|
|
331
|
+
"""
|
|
332
|
+
原子性的否定确认 - 通过Lua脚本保证操作原子性
|
|
333
|
+
"""
|
|
334
|
+
lua_script = """
|
|
335
|
+
if redis.call('LREM', KEYS[2], 0, ARGV[1]) > 0 then
|
|
336
|
+
return redis.call('LPUSH', KEYS[1], ARGV[1])
|
|
337
|
+
else
|
|
338
|
+
return 0
|
|
339
|
+
end
|
|
340
|
+
"""
|
|
341
|
+
result = self.r.eval(lua_script, 2, self.queue_name, self.queue_name_tmp, self.data)
|
|
342
|
+
if result == 0:
|
|
343
|
+
raise ValueError(f"消息 {self.data} 未在临时队列 {self.queue_name_tmp} 中找到")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class Redis(object):
|
|
347
|
+
def __init__(self, host: str, port: int, password: str, db: int, decode_responses: bool = False):
|
|
348
|
+
self.host = host
|
|
349
|
+
self.port = port
|
|
350
|
+
self.password = password
|
|
351
|
+
self.db = db
|
|
352
|
+
self.r = redis.StrictRedis(host=host, port=port, password=password, db=db, decode_responses=decode_responses)
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def simple(config: RedisConfig, db: int = None, decode_responses: bool = True) -> 'Redis':
|
|
356
|
+
"""
|
|
357
|
+
使用配置创建Redis对象
|
|
358
|
+
:param config: 配置
|
|
359
|
+
:param db: 数据库
|
|
360
|
+
:param decode_responses: 是否自动解析字节
|
|
361
|
+
:return:
|
|
362
|
+
"""
|
|
363
|
+
if not db:
|
|
364
|
+
db = config.db
|
|
365
|
+
return Redis(
|
|
366
|
+
host=config.host,
|
|
367
|
+
port=config.port,
|
|
368
|
+
password=config.password,
|
|
369
|
+
db=db,
|
|
370
|
+
decode_responses=decode_responses
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def flush(self, dst: str):
|
|
374
|
+
"""
|
|
375
|
+
使用Lua脚本原子性转移整个列表内容
|
|
376
|
+
"""
|
|
377
|
+
lua_script = """
|
|
378
|
+
local items = redis.call('LRANGE', KEYS[1], 0, -1)
|
|
379
|
+
if #items > 0 then
|
|
380
|
+
redis.call('DEL', KEYS[1])
|
|
381
|
+
return redis.call('LPUSH', KEYS[2], unpack(items))
|
|
382
|
+
end
|
|
383
|
+
return 0
|
|
384
|
+
"""
|
|
385
|
+
src = f'{dst}_tmp'
|
|
386
|
+
self.r.eval(lua_script, 2, src, dst)
|
|
387
|
+
|
|
388
|
+
def pop_message(self, queue_name: str, wait: bool = False) -> Optional[Message]:
|
|
389
|
+
temp_queue_name = f'{queue_name}_tmp'
|
|
390
|
+
if not wait:
|
|
391
|
+
data = self.r.rpoplpush(queue_name, temp_queue_name)
|
|
392
|
+
else:
|
|
393
|
+
data = self.r.brpoplpush(queue_name, temp_queue_name)
|
|
394
|
+
|
|
395
|
+
if data:
|
|
396
|
+
return Message(self.r, queue_name, temp_queue_name, data)
|
|
397
|
+
|
|
398
|
+
def push(self, queue_name: str, data: Union[dict, str]):
|
|
399
|
+
if isinstance(data, dict):
|
|
400
|
+
data_str = json.dumps(data, ensure_ascii=False)
|
|
401
|
+
self.r.lpush(queue_name, data_str)
|
|
402
|
+
else:
|
|
403
|
+
self.r.lpush(queue_name, data)
|
|
404
|
+
|
|
405
|
+
def pop(self, queue_name: str, decode_json: bool = False) -> Union[dict, str]:
|
|
406
|
+
data_str = self.r.rpop(queue_name)
|
|
407
|
+
if decode_json:
|
|
408
|
+
return json.loads(data_str)
|
|
409
|
+
return data_str
|
|
410
|
+
|
|
411
|
+
def delay_push(self, queue_name: str, data: Union[dict, str], delay: int = 125):
|
|
412
|
+
"""
|
|
413
|
+
延迟添加数据到队列(配合delay_pop用)
|
|
414
|
+
:param queue_name: 队列名称
|
|
415
|
+
:param data: 数据
|
|
416
|
+
:param delay: 延迟执行时间(单位:秒)
|
|
417
|
+
"""
|
|
418
|
+
if isinstance(data, dict):
|
|
419
|
+
data_str = json.dumps(data, ensure_ascii=False)
|
|
420
|
+
else:
|
|
421
|
+
data_str = data
|
|
422
|
+
execute_time = time.time() + delay # 计算任务的执行时间戳
|
|
423
|
+
self.r.zadd(queue_name, {data_str: execute_time})
|
|
424
|
+
|
|
425
|
+
def delay_pop(self, queue_name: str, delay=125, wait: bool = False) -> Union[str, None]:
|
|
426
|
+
"""
|
|
427
|
+
延迟获取队列数据
|
|
428
|
+
:param queue_name: 队列名称
|
|
429
|
+
:param delay: 延迟时间
|
|
430
|
+
:param wait: 是否等待获取
|
|
431
|
+
:return:
|
|
432
|
+
"""
|
|
433
|
+
while True:
|
|
434
|
+
current_time = time.time()
|
|
435
|
+
task_info = self.r.zpopmin(queue_name, 1)
|
|
436
|
+
if task_info:
|
|
437
|
+
task, score = task_info[0]
|
|
438
|
+
if score <= current_time:
|
|
439
|
+
new_time = current_time + delay
|
|
440
|
+
self.r.zadd(queue_name, {task: new_time})
|
|
441
|
+
return task
|
|
442
|
+
else:
|
|
443
|
+
self.r.zadd(queue_name, {task: score})
|
|
444
|
+
if wait:
|
|
445
|
+
logger.debug(f'等待队列 {queue_name}')
|
|
446
|
+
time.sleep(5)
|
|
447
|
+
continue
|
|
448
|
+
else:
|
|
449
|
+
return None
|
|
450
|
+
else:
|
|
451
|
+
return None
|