初始化
This commit is contained in:
BIN
utils/__pycache__/clash.cpython-312.pyc
Normal file
BIN
utils/__pycache__/clash.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/db.cpython-312.pyc
Normal file
BIN
utils/__pycache__/db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/files.cpython-312.pyc
Normal file
BIN
utils/__pycache__/files.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/formats.cpython-312.pyc
Normal file
BIN
utils/__pycache__/formats.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/messages.cpython-312.pyc
Normal file
BIN
utils/__pycache__/messages.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/redis.cpython-312.pyc
Normal file
BIN
utils/__pycache__/redis.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/uploads.cpython-312.pyc
Normal file
BIN
utils/__pycache__/uploads.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/wpdata.cpython-312.pyc
Normal file
BIN
utils/__pycache__/wpdata.cpython-312.pyc
Normal file
Binary file not shown.
64
utils/clash.py
Normal file
64
utils/clash.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import requests
|
||||
import random
|
||||
|
||||
|
||||
|
||||
def change_proxy():
|
||||
port = '17650'
|
||||
secret = '7ce2f241-d6a1-4b69-a742-96201072187a'
|
||||
selector = '🚀 节点选择'
|
||||
|
||||
port = port
|
||||
secret = secret
|
||||
|
||||
headers_secret = {
|
||||
'Authorization': 'Bearer {}'.format(secret)
|
||||
}
|
||||
|
||||
url_all_proxies_info = 'http://127.0.0.1:{}/proxies'.format(port)
|
||||
url_all_proxies_info = requests.get(url_all_proxies_info,headers=headers_secret).json()
|
||||
url_all_proxies = 'http://127.0.0.1:{}/proxies/{}'.format(port,selector)
|
||||
|
||||
res_proxies = requests.get(url_all_proxies,headers=headers_secret).json()
|
||||
# res_proxies = res_proxies['proxies'][selector]
|
||||
proxy_name_list = res_proxies['all'][3:-3]
|
||||
now_proxy = res_proxies['now']
|
||||
print('现在的代理是{}'.format(now_proxy))
|
||||
# if now_proxy not in ['DIRECT','REJECT','账号邮箱看最新的地址','NETV2','自动选择','故障转移']:
|
||||
# proxy_name_list.remove(now_proxy)
|
||||
|
||||
shift = []
|
||||
continue_proxys = ['♻️ 自动选择', '仅海外用户']
|
||||
for proxy_name in proxy_name_list:
|
||||
is_continue = False
|
||||
for continue_proxy in continue_proxys:
|
||||
if continue_proxy in proxy_name:
|
||||
is_continue = True
|
||||
break
|
||||
if is_continue:
|
||||
continue
|
||||
# if '香港' not in proxy_name or '日本' not in proxy_name or '俄罗斯' not in proxy_name:
|
||||
# continue
|
||||
shift.append(proxy_name)
|
||||
proxy_name_list = shift
|
||||
|
||||
while True:
|
||||
random_proxy = random.choice(proxy_name_list)
|
||||
delay = url_all_proxies_info['proxies'][random_proxy]['history'][-1]['delay']
|
||||
if delay!=0:
|
||||
break
|
||||
print('随机选择的代理为{}'.format(random_proxy))
|
||||
data = {
|
||||
"name": random_proxy
|
||||
}
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
'Authorization': 'Bearer {}'.format(secret)
|
||||
}
|
||||
|
||||
res = requests.put(url=url_all_proxies,json=data,headers=headers)
|
||||
print('切换代理请求的状态码为{}'.format(res.status_code))
|
||||
if res.status_code == 204:
|
||||
print('切换代理成功!现在的代理为{}'.format(random_proxy))
|
||||
if __name__ == '__main__':
|
||||
change_proxy()
|
||||
672
utils/db.py
Normal file
672
utils/db.py
Normal file
@@ -0,0 +1,672 @@
|
||||
#############################################################################
|
||||
|
||||
# Author: Cerys
|
||||
# Update: 2026-03-06
|
||||
|
||||
#############################################################################
|
||||
|
||||
import sqlite3
|
||||
import pymysql
|
||||
import copy
|
||||
import functools
|
||||
import re
|
||||
from pymysql.constants import CLIENT
|
||||
from pymysql.cursors import DictCursor
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from contextlib import contextmanager
|
||||
|
||||
# ================= 统一执行装饰器 =================
|
||||
def db_execute_wrapper(func):
|
||||
"""
|
||||
统一执行装饰器:
|
||||
1. 负责自动获取/检查连接
|
||||
2. 自动处理非事务状态下的 commit 和 rollback
|
||||
3. 针对 MySQL 处理 Gone Away 断连重连重试机制
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
self.__connect__()
|
||||
try:
|
||||
res = func(self, *args, **kwargs)
|
||||
return res
|
||||
except Exception as e:
|
||||
# 针对 MySQL 的长连接断开 (OperationalError) 尝试进行一次重试
|
||||
if getattr(self, '_is_mysql', False) and not self._in_transaction:
|
||||
if isinstance(e, pymysql.err.OperationalError):
|
||||
try:
|
||||
# 【修复】废弃 reconnect=True,直接销毁并重新初始化连接
|
||||
self.conn = None
|
||||
self.__connect__()
|
||||
|
||||
res = func(self, *args, **kwargs)
|
||||
return res
|
||||
except Exception:
|
||||
if self.conn:
|
||||
self.conn.rollback()
|
||||
raise # 【修复】直接 raise,保留完整的错误堆栈 Traceback
|
||||
|
||||
# 常规错误回滚
|
||||
if getattr(self, 'conn', None) and not getattr(self, '_in_transaction', False):
|
||||
self.conn.rollback()
|
||||
raise # 【修复】直接 raise,保留完整的错误堆栈 Traceback
|
||||
return wrapper
|
||||
# =================================================
|
||||
|
||||
class BaseDriver:
|
||||
"""
|
||||
基类:负责定义接口类型,让 VS Code 能识别返回值
|
||||
"""
|
||||
def __init__(self):
|
||||
self.conn = None
|
||||
# 子类需设置这两个属性
|
||||
self.param_mark = '?'
|
||||
self._is_mysql = False
|
||||
self._transaction_depth = 0
|
||||
|
||||
# -------------- 核心执行方法 (清晰直白) --------------
|
||||
|
||||
@db_execute_wrapper
|
||||
def fetch_all(self, sql: str, params: tuple = ()) -> List[dict]:
|
||||
"""查询多条:返回字典列表"""
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
return []
|
||||
# 兼容性处理:如果是对象(SQLite Row)则转dict,如果是dict(MySQL)则直接用
|
||||
return [dict(row) for row in rows] if rows else []
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@db_execute_wrapper
|
||||
def fetch_one(self, sql: str, params: tuple = ()) -> Optional[dict]:
|
||||
"""查询单条:返回字典 或 None"""
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql, params)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@db_execute_wrapper
|
||||
def execute(self, sql: str, params: tuple = ()) -> int:
|
||||
"""增删改:返回影响行数"""
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql, params)
|
||||
if not self._in_transaction:
|
||||
self.conn.commit()
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@db_execute_wrapper
|
||||
def execute_insert(self, sql: str, params: tuple = ()) -> int:
|
||||
"""插入:返回自增ID"""
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql, params)
|
||||
if not self._in_transaction:
|
||||
self.conn.commit()
|
||||
return cursor.lastrowid
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@db_execute_wrapper
|
||||
def execute_many(self, sql: str, params_list: List[tuple]) -> int:
|
||||
"""批量执行:返回影响行数"""
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.executemany(sql, params_list)
|
||||
if not self._in_transaction:
|
||||
self.conn.commit()
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@db_execute_wrapper
|
||||
def execute_raw(self, sql: str, params: tuple = ()) -> Union[List[dict], int]:
|
||||
"""混合执行:根据是否返回结果集自动判断"""
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql, params)
|
||||
# 如果有 description 说明是 SELECT 类查询
|
||||
if cursor.description:
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows] if rows else []
|
||||
else:
|
||||
if not self._in_transaction:
|
||||
self.conn.commit()
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@db_execute_wrapper
|
||||
def execute_script(self, sql_script: str) -> None:
|
||||
"""简单的脚本执行,不支持复杂的存储过程分隔符"""
|
||||
self.__connect__()
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
# 开启多语句支持通常需要在 connect 时指定 client_flag
|
||||
# 或者手动分割
|
||||
statements = [s.strip() for s in sql_script.split(';') if s.strip()]
|
||||
for sql in statements:
|
||||
cursor.execute(sql)
|
||||
if not self._in_transaction:
|
||||
self.conn.commit()
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
class MysqlDriver(BaseDriver):
|
||||
def __init__(self, host, port, user, password, database, charset='utf8mb4'):
|
||||
super().__init__()
|
||||
self.param_mark = '%s' # MySQL 占位符
|
||||
self.quote_mark = '`'
|
||||
self.config = {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'password': password,
|
||||
'database': database,
|
||||
'charset': charset,
|
||||
'autocommit': False,
|
||||
'cursorclass': DictCursor,
|
||||
'client_flag': CLIENT.MULTI_STATEMENTS
|
||||
}
|
||||
self._is_mysql = True
|
||||
self.__connect__()
|
||||
|
||||
@property
|
||||
def _in_transaction(self) -> bool:
|
||||
"""判断当前是否处于事务中"""
|
||||
return self._transaction_depth > 0
|
||||
|
||||
def __connect__(self) -> None:
|
||||
if self.conn is None:
|
||||
self.conn = pymysql.connect(**self.config)
|
||||
else:
|
||||
try:
|
||||
# 【修复】去除已在 PyMySQL 新版废弃的 reconnect=True 参数
|
||||
self.conn.ping(reconnect=False)
|
||||
except Exception:
|
||||
self.conn = pymysql.connect(**self.config)
|
||||
|
||||
# =============== 事务控制方法 ===============
|
||||
def begin(self) -> None:
|
||||
self.__connect__()
|
||||
if self._transaction_depth == 0:
|
||||
self.conn.begin()
|
||||
self._transaction_depth += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
if self._transaction_depth > 0:
|
||||
self._transaction_depth -= 1
|
||||
if self._transaction_depth == 0 and self.conn:
|
||||
self.conn.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
# 安全清理嵌套层级,防止局部回滚后引发后续误提交
|
||||
if self._transaction_depth > 0:
|
||||
self._transaction_depth = 0
|
||||
if self.conn:
|
||||
self.conn.rollback()
|
||||
# ============================================
|
||||
|
||||
def close(self) -> None:
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
|
||||
|
||||
class SqliteDriver(BaseDriver):
|
||||
def __init__(self, db_path: str):
|
||||
super().__init__()
|
||||
self.param_mark = '?' # SQLite 占位符
|
||||
self.quote_mark = '"'
|
||||
self.db_path = db_path
|
||||
self._is_mysql = False
|
||||
self.__connect__()
|
||||
|
||||
@property
|
||||
def _in_transaction(self) -> bool:
|
||||
return self._transaction_depth > 0
|
||||
|
||||
def __connect__(self) -> None:
|
||||
if not self.conn:
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=10.0)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute('PRAGMA journal_mode=WAL;')
|
||||
self.conn.execute('PRAGMA synchronous=NORMAL;')
|
||||
|
||||
@db_execute_wrapper
|
||||
def execute_script(self, sql_script: str) -> None:
|
||||
"""SQLite 原生支持脚本执行,更安全且无需手动分割"""
|
||||
# 注意:sqlite3.Cursor.executescript 不遵循 commit 逻辑,它会直接提交
|
||||
# 所以这里不需要 conn.commit(),但为了保持一致性逻辑,还是走装饰器
|
||||
cursor = self.conn.cursor()
|
||||
try:
|
||||
cursor.executescript(sql_script)
|
||||
# SQLite executescript 会自动 commit,不需要手动再 commit
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
# =============== 事务控制方法 ===============
|
||||
def begin(self) -> None:
|
||||
self.__connect__()
|
||||
self._transaction_depth += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
if self._transaction_depth > 0:
|
||||
self._transaction_depth -= 1
|
||||
if self._transaction_depth == 0 and self.conn:
|
||||
self.conn.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
if self._transaction_depth > 0:
|
||||
self._transaction_depth = 0
|
||||
if self.conn:
|
||||
self.conn.rollback()
|
||||
# ============================================
|
||||
|
||||
def close(self) -> None:
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
|
||||
|
||||
class Query:
|
||||
"""查询构建器:负责拼装 SQL,且支持链式调用不可变对象"""
|
||||
def __init__(self, driver, table_name: str):
|
||||
self.driver = driver
|
||||
self.table_name = table_name
|
||||
self._wheres: List[tuple] = [] # 【修改】存为 tuple: (逻辑符, 语句) 支持 OR
|
||||
self._params: List[Any] = []
|
||||
self._orders: List[str] = []
|
||||
self._joins: List[str] = []
|
||||
self._limit: Optional[int] = None
|
||||
self._offset: Optional[int] = None
|
||||
|
||||
self.mark = getattr(driver, 'param_mark', '?')
|
||||
self.quote = getattr(driver, 'quote_mark', '')
|
||||
|
||||
def __copy_instance(self) -> 'Query':
|
||||
"""【核心】创建当前对象的深拷贝,用于链式调用不污染原对象"""
|
||||
new_query = copy.copy(self)
|
||||
new_query._wheres = self._wheres[:]
|
||||
new_query._params = self._params[:]
|
||||
new_query._orders = self._orders[:]
|
||||
new_query._joins = self._joins[:]
|
||||
return new_query
|
||||
|
||||
def _q(self, field: str) -> str:
|
||||
field = field.strip()
|
||||
if field == '*' or '(' in field or ')' in field:
|
||||
return field
|
||||
|
||||
# 简单的防止注入校验
|
||||
if re.search(r'[;\'"\s\-]', field):
|
||||
raise ValueError(f"非法字段名: {field}")
|
||||
|
||||
# 支持 table.column 格式
|
||||
if '.' in field:
|
||||
table, col = field.split('.', 1)
|
||||
return f"{self.quote}{table}{self.quote}.{self.quote}{col}{self.quote}"
|
||||
|
||||
return f"{self.quote}{field}{self.quote}"
|
||||
|
||||
# ================= 链式构建方法 (返回新对象) =================
|
||||
|
||||
def where(self, key: str, value: Any, operator: str = '=') -> 'Query':
|
||||
"""
|
||||
单条件判断
|
||||
|
||||
:param key: 字段名
|
||||
:param value: 值
|
||||
:param operator: 运算符
|
||||
|
||||
"""
|
||||
new_q = self.__copy_instance()
|
||||
condition = f"{self._q(key)} {operator} {self.mark}"
|
||||
new_q._wheres.append(("AND", condition))
|
||||
new_q._params.append(value)
|
||||
return new_q
|
||||
|
||||
def or_where(self, key: str, value: Any, operator: str = '=') -> 'Query':
|
||||
"""
|
||||
OR 条件判断
|
||||
方法同where
|
||||
"""
|
||||
new_q = self.__copy_instance()
|
||||
condition = f"{self._q(key)} {operator} {self.mark}"
|
||||
new_q._wheres.append(("OR", condition))
|
||||
new_q._params.append(value)
|
||||
return new_q
|
||||
|
||||
def where_all(self, condition: Dict[str, Any], operator: str = '=', logic: str = 'AND') -> 'Query':
|
||||
"""
|
||||
多条件判断
|
||||
|
||||
:param condition:param condition: { 字段名: 值 }
|
||||
:param operator: 运算符 (传入字典建议保持默认 `=`)
|
||||
:param logic: 逻辑运算符内部连接(AND/OR)
|
||||
|
||||
"""
|
||||
new_q = self.__copy_instance()
|
||||
clauses = []
|
||||
for key, value in condition.items():
|
||||
clauses.append(f"{self._q(key)} {operator} {self.mark}")
|
||||
new_q._params.append(value)
|
||||
|
||||
if clauses:
|
||||
full_condition = f"({f' {logic.upper()} '.join(clauses)})"
|
||||
# 作为一个整体条件加入,默认外部通过 AND 连接
|
||||
new_q._wheres.append(("AND", full_condition))
|
||||
return new_q
|
||||
|
||||
def where_in(self, key: str, values: List[Any]) -> 'Query':
|
||||
"""
|
||||
IN 语句支持
|
||||
|
||||
:param key: 字段名
|
||||
:param values: 匹配值列表
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("whereIn 的 values 参数不能为空列表")
|
||||
new_q = self.__copy_instance()
|
||||
placeholders = ", ".join([self.mark] * len(values))
|
||||
condition = f"{self._q(key)} IN ({placeholders})"
|
||||
new_q._wheres.append(("AND", condition))
|
||||
new_q._params.extend(values)
|
||||
return new_q
|
||||
|
||||
def where_raw(self, sql: str, params: tuple = ()) -> 'Query':
|
||||
"""
|
||||
原生条件语句 (支持复杂逻辑查询)
|
||||
注意:调用方需自行通过 params 传递参数以防注入
|
||||
|
||||
:param sql: SQL 字符串,如 "age > ? AND (status = ? OR role = ?)"
|
||||
:param params: 对应占位符的参数元组
|
||||
"""
|
||||
new_q = self.__copy_instance()
|
||||
new_q._wheres.append(("AND", f"({sql})"))
|
||||
new_q._params.extend(params)
|
||||
return new_q
|
||||
|
||||
def join(self, table: str, on: str, join_type: str = 'LEFT') -> 'Query':
|
||||
"""
|
||||
链表查询
|
||||
|
||||
:param table: 要链接的表名
|
||||
:param on: 链接条件
|
||||
:param join_type: 链接类型(LEFT/INNER/RIGHT)
|
||||
|
||||
"""
|
||||
new_q = self.__copy_instance()
|
||||
# 注意: join 的 on 条件由开发者自己写 raw sql,需自行注意安全
|
||||
new_q._joins.append(f"{join_type.upper()} JOIN {self._q(table)} ON {on}")
|
||||
return new_q
|
||||
|
||||
def order(self, field: str, direction: str = 'ASC') -> 'Query':
|
||||
"""
|
||||
排序
|
||||
|
||||
:param field: 排序字段
|
||||
:param direction: 排序方向(ASC/DESC)
|
||||
|
||||
"""
|
||||
direction = direction.upper()
|
||||
if direction not in ('ASC', 'DESC'):
|
||||
raise ValueError("排序方向只能是 ASC 或 DESC")
|
||||
|
||||
new_q = self.__copy_instance()
|
||||
# _q() 内部已经加上了正则校验,防止 order by 注入
|
||||
new_q._orders.append(f"{self._q(field)} {direction}")
|
||||
return new_q
|
||||
|
||||
def limit(self, limit: int, offset: int = 0) -> 'Query':
|
||||
"""
|
||||
分页
|
||||
|
||||
:param limit: 每页数量
|
||||
:param offset: 第几页 (从0开始)
|
||||
|
||||
"""
|
||||
new_q = self.__copy_instance()
|
||||
new_q._limit = int(limit)
|
||||
new_q._offset = int(offset)
|
||||
return new_q
|
||||
|
||||
# ================= 终结方法 (执行并返回结果) =================
|
||||
|
||||
def select(self, fields: str = '*') -> List[dict]:
|
||||
"""
|
||||
执行查询返回列表
|
||||
|
||||
:param fields: 查询字段
|
||||
|
||||
"""
|
||||
sql = self._build_select_sql(fields)
|
||||
return self.driver.fetch_all(sql, tuple(self._params))
|
||||
|
||||
def find(self, fields: str = '*') -> Optional[dict]:
|
||||
"""
|
||||
执行查询返回单条
|
||||
|
||||
:param fields: 查询字段
|
||||
|
||||
"""
|
||||
temp_q = self.__copy_instance()
|
||||
temp_q._limit = 1
|
||||
temp_q._offset = 0
|
||||
|
||||
sql = temp_q._build_select_sql(fields)
|
||||
return self.driver.fetch_one(sql, tuple(temp_q._params))
|
||||
|
||||
def count(self) -> int:
|
||||
"""统计数量"""
|
||||
sql = self._build_select_sql("COUNT(*) AS cnt")
|
||||
res = self.driver.fetch_one(sql, tuple(self._params))
|
||||
return res.get('cnt', 0) if res else 0
|
||||
|
||||
def insert(self, data: Dict[str, Any], replace: bool = False) -> int:
|
||||
"""
|
||||
插入单条数据
|
||||
|
||||
:param data: { 字段名: 值 }
|
||||
:param replace: 是否使用 REPLACE INTO(存在则覆盖)
|
||||
:return: 影响行数/自增主键ID
|
||||
|
||||
"""
|
||||
keys = [self._q(k) for k in data.keys()]
|
||||
values = list(data.values())
|
||||
placeholders = ",".join([self.mark] * len(values))
|
||||
columns = ",".join(keys)
|
||||
|
||||
action = "REPLACE" if replace else "INSERT"
|
||||
sql = f"{action} INTO {self._q(self.table_name)} ({columns}) VALUES ({placeholders})"
|
||||
return self.driver.execute_insert(sql, tuple(values))
|
||||
|
||||
def insert_all(self, data: List[Dict[str, Any]], replace: bool = False) -> int:
|
||||
"""
|
||||
批量插入
|
||||
每条数据key结构必须相同
|
||||
|
||||
:param data: [{ 字段名: 值 }, ...]
|
||||
:param replace: 是否使用 REPLACE INTO(存在则覆盖)
|
||||
:return: 影响行数
|
||||
|
||||
"""
|
||||
if not data: return 0
|
||||
|
||||
raw_keys = list(data[0].keys())
|
||||
keys = [self._q(k) for k in raw_keys]
|
||||
columns = ",".join(keys)
|
||||
placeholders = ",".join([self.mark] * len(keys))
|
||||
|
||||
params_list = [tuple(d.get(k) for k in raw_keys) for d in data]
|
||||
|
||||
action = "REPLACE" if replace else "INSERT"
|
||||
sql = f"{action} INTO {self._q(self.table_name)} ({columns}) VALUES ({placeholders})"
|
||||
|
||||
return self.driver.execute_many(sql, params_list)
|
||||
|
||||
def update(self, data: Dict[str, Any]) -> int:
|
||||
"""
|
||||
更新符合条件的所有行数据
|
||||
|
||||
:param data: { 字段名: 值 }
|
||||
:return: 影响行数
|
||||
|
||||
"""
|
||||
if not data:
|
||||
return 0 # 避免生成无效 SQL
|
||||
|
||||
set_clauses = []
|
||||
set_params = []
|
||||
|
||||
for key, value in data.items():
|
||||
set_clauses.append(f"{self._q(key)} = {self.mark}")
|
||||
set_params.append(value)
|
||||
|
||||
set_sql = ", ".join(set_clauses)
|
||||
where_sql = self._build_where_sql()
|
||||
if not where_sql:
|
||||
raise ValueError("WHERE 条件不存在,为防止全表更新操作已被拦截!")
|
||||
|
||||
full_params = set_params + self._params
|
||||
sql = f"UPDATE {self._q(self.table_name)} SET {set_sql} {where_sql}"
|
||||
return self.driver.execute(sql, tuple(full_params))
|
||||
|
||||
def delete(self) -> int:
|
||||
"""
|
||||
删除符合条件的所有行数据
|
||||
:return: 影响行数
|
||||
|
||||
"""
|
||||
where_sql = self._build_where_sql()
|
||||
if not where_sql:
|
||||
raise ValueError("WHERE 条件不存在,为防止全表删除操作已被拦截!")
|
||||
sql = f"DELETE FROM {self._q(self.table_name)} {where_sql}"
|
||||
return self.driver.execute(sql, tuple(self._params))
|
||||
|
||||
# ================= 内部辅助方法 =================
|
||||
|
||||
def _build_where_sql(self) -> str:
|
||||
if not self._wheres:
|
||||
return ""
|
||||
|
||||
# 【修改】适配新的 Tuple(logic, condition) 结构以支持 OR
|
||||
where_parts = []
|
||||
for i, (logic, condition) in enumerate(self._wheres):
|
||||
if i == 0:
|
||||
where_parts.append(condition) # 首个条件不需要 AND/OR 前缀
|
||||
else:
|
||||
where_parts.append(f"{logic} {condition}")
|
||||
|
||||
return "WHERE " + " ".join(where_parts)
|
||||
|
||||
def _build_select_sql(self, fields: str) -> str:
|
||||
parts = [f"SELECT {fields} FROM {self._q(self.table_name)}"]
|
||||
|
||||
if self._joins:
|
||||
parts.extend(self._joins)
|
||||
|
||||
where_sql = self._build_where_sql()
|
||||
if where_sql:
|
||||
parts.append(where_sql)
|
||||
|
||||
if self._orders:
|
||||
parts.append("ORDER BY " + ", ".join(self._orders))
|
||||
|
||||
if self._limit is not None:
|
||||
parts.append(f"LIMIT {self._limit}")
|
||||
|
||||
if self._offset is not None:
|
||||
parts.append(f"OFFSET {self._offset}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
class Db:
|
||||
"""数据库入口"""
|
||||
def __init__(self, db_path: str, config: dict = None, db_type: str = None):
|
||||
config = config or {}
|
||||
|
||||
if db_type is None:
|
||||
if db_path.lower() == 'mysql' or 'host' in config:
|
||||
db_type = 'mysql'
|
||||
else:
|
||||
db_type = 'sqlite'
|
||||
|
||||
if db_type == 'mysql':
|
||||
self.driver = MysqlDriver(**config)
|
||||
elif db_type == 'sqlite':
|
||||
self.driver = SqliteDriver(db_path) # 【修复】采用正确的命名
|
||||
else:
|
||||
raise ValueError("db_type 参数错误: 应为 'mysql' 或 'sqlite'")
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
"""
|
||||
事务上下文管理器
|
||||
with db.transaction():
|
||||
db.table('A').insert(...)
|
||||
db.table('B').update(...)
|
||||
"""
|
||||
self.driver.begin()
|
||||
try:
|
||||
yield self
|
||||
self.driver.commit()
|
||||
except Exception:
|
||||
self.driver.rollback()
|
||||
raise # 【修复】直接 raise 避免截断真实报错的 Traceback
|
||||
|
||||
def table(self, table_name: str) -> Query:
|
||||
"""
|
||||
获取表操作对象
|
||||
|
||||
:param table_name: 表名
|
||||
|
||||
"""
|
||||
return Query(self.driver, table_name)
|
||||
|
||||
def execute_raw(self, sql: str, params: tuple = ()) -> Union[List[dict], int]:
|
||||
"""
|
||||
执行原生 SQL
|
||||
|
||||
:param sql: SQL 语句
|
||||
:param params: 参数
|
||||
|
||||
"""
|
||||
return self.driver.execute_raw(sql, params)
|
||||
|
||||
def execute_many_raw(self, sql: str, params_list: List[tuple]) -> int:
|
||||
"""
|
||||
批量执行原生 SQL
|
||||
|
||||
:param sql: SQL 语句
|
||||
:param params_list: 参数列表
|
||||
|
||||
"""
|
||||
return self.driver.execute_many(sql, params_list)
|
||||
|
||||
def execute_script(self, sql_script: str) -> None:
|
||||
"""
|
||||
执行多条 SQL 语句(如建表)
|
||||
|
||||
:param sql_script: SQL 脚本
|
||||
|
||||
"""
|
||||
self.driver.execute_script(sql_script)
|
||||
|
||||
def close(self) -> None:
|
||||
self.driver.close()
|
||||
|
||||
# Context Manager 支持
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
24
utils/files.py
Normal file
24
utils/files.py
Normal file
@@ -0,0 +1,24 @@
|
||||
def save_line(filepath, text):
|
||||
with open(filepath, 'a+', encoding='utf-8') as f:
|
||||
f.writelines(text+'\n')
|
||||
|
||||
def load_lines(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
return text.split('\n')
|
||||
|
||||
def save_text(filepath, text):
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
|
||||
def add_text(filepath, text):
|
||||
with open(filepath, 'a+', encoding='utf-8') as f:
|
||||
f.writelines(text+'\n')
|
||||
|
||||
def save_list(filepath, lists: list):
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(lists))
|
||||
|
||||
def add_list(filepath, lists: list):
|
||||
with open(filepath, 'a+', encoding='utf-8') as f:
|
||||
f.write('\n'.join(lists)+'\n')
|
||||
104
utils/formats.py
Normal file
104
utils/formats.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import hashlib
|
||||
from bs4 import BeautifulSoup
|
||||
import time
|
||||
import random
|
||||
import datetime
|
||||
import re
|
||||
|
||||
def url_to_spu(url: str, length: int = 36) -> str:
|
||||
h = hashlib.sha256(url.encode('utf-8')).digest()
|
||||
int_val = int.from_bytes(h, 'big')
|
||||
chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
base36 = ''
|
||||
while int_val > 0:
|
||||
int_val, idx = divmod(int_val, 36)
|
||||
base36 = chars[idx] + base36
|
||||
base36 = base36.zfill(50)
|
||||
return base36[:length]
|
||||
|
||||
# 工具函数,几乎每个站点的采集都会用到
|
||||
def remove_unwanted_tags(html_content):
|
||||
html_content = str(html_content)
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
for tag in soup(['button', 'img', 'a', 'script', 'svg', 'video']):
|
||||
tag.decompose()
|
||||
return str(soup)
|
||||
|
||||
def clean_html(html):
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# 遍历所有标签
|
||||
for tag in soup(['button', 'a', 'script']):
|
||||
tag.attrs = {} # 直接清空所有属性
|
||||
|
||||
return str(soup)
|
||||
|
||||
def de_repeat_urls(filepath):
|
||||
index = {}
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
old_data = text.split('\n')
|
||||
with open(f"{filepath}_old.txt", 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(old_data))
|
||||
|
||||
new_data = []
|
||||
for line in old_data:
|
||||
line = '#'.join(line.split('#')[:-1])
|
||||
if line in index:
|
||||
continue
|
||||
new_data.append(line)
|
||||
index[line] = None
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(new_data))
|
||||
|
||||
def getTime(timeStamp = False, format: str = '%Y-%m-%d %H:%M:%S'):
|
||||
"""
|
||||
获取时间
|
||||
Author Cerys
|
||||
ChangeTime 2023-11-10
|
||||
|
||||
@param timeStamp 指定时间戳
|
||||
@param fotmat 指定格式
|
||||
|
||||
return str
|
||||
"""
|
||||
|
||||
if timeStamp != False:
|
||||
time = datetime.datetime.fromtimestamp(timeStamp)
|
||||
else:
|
||||
time = datetime.datetime.now()
|
||||
formatTime = time.strftime(format)
|
||||
|
||||
return formatTime
|
||||
|
||||
def formatName(name: str, types: bool = False):
|
||||
"""
|
||||
下划线字符串命名转大驼峰或小驼峰
|
||||
Author Cerys
|
||||
ChangeTime 2023-11-10
|
||||
|
||||
@param name 要转换的字符串
|
||||
@param types 是否转为大驼峰
|
||||
|
||||
return str
|
||||
"""
|
||||
|
||||
strLists = name.split('_')
|
||||
if types:
|
||||
return strLists[0].title() + ''.join(x.title() for x in strLists[1:])
|
||||
else:
|
||||
return strLists[0] + ''.join(x.title() for x in strLists[1:])
|
||||
|
||||
def formatMd5(text: str = '', is_random: bool = False, attach: str = ''):
|
||||
if is_random:
|
||||
text = f"{time.time()}_{random.randint(10000, 99999)}"
|
||||
md5 = hashlib.md5(f"{text}_{attach}".encode()).hexdigest()
|
||||
return md5
|
||||
|
||||
def re_search(text: str, rule: str) -> str:
|
||||
match = re.search(rule, text)
|
||||
if match:
|
||||
url_path = match.group(1)
|
||||
return url_path
|
||||
else:
|
||||
return ""
|
||||
18
utils/messages.py
Normal file
18
utils/messages.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import datetime
|
||||
from colorama import Fore, Style, init
|
||||
|
||||
init()
|
||||
|
||||
def __getTime__():
|
||||
time = datetime.datetime.now()
|
||||
formatTime = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
return formatTime
|
||||
|
||||
def sendInfo(message: str):
|
||||
print(f"[{__getTime__()}] {message}")
|
||||
|
||||
def sendWarn(message):
|
||||
print(Fore.YELLOW + f"[{__getTime__()}] 提示: {message}" + Style.RESET_ALL)
|
||||
|
||||
def sendError(message: str):
|
||||
print(Fore.RED + f"[{__getTime__()}] 错误: {message}" + Style.RESET_ALL)
|
||||
27
utils/uploads.py
Normal file
27
utils/uploads.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import io
|
||||
import time
|
||||
import random
|
||||
import requests
|
||||
|
||||
def upload_image(image2_data, domain: str):
|
||||
image_stream = io.BytesIO(image2_data)
|
||||
headers = {
|
||||
'Token': 'fangzhouxinghe'
|
||||
}
|
||||
data = {
|
||||
'domain': domain
|
||||
}
|
||||
files = {
|
||||
'file': (f'{time.time()}_{random.randint(10000, 99999)}.jpg', image_stream, 'image/jpeg')
|
||||
}
|
||||
|
||||
response = requests.post('https://fangzhouxinghe.com/uploads.php', data=data, headers=headers, files=files)
|
||||
response_data = response.json()
|
||||
|
||||
if response_data['code'] != 200:
|
||||
raise Exception(response_data['msg'])
|
||||
return response_data['data']
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(upload_image(open(r"test\50137.jpg", 'rb').read(), 'test'))
|
||||
828
utils/wpdata.py
Normal file
828
utils/wpdata.py
Normal file
@@ -0,0 +1,828 @@
|
||||
import pandas as pd
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
import concurrent.futures
|
||||
import time
|
||||
|
||||
class Wpdata:
|
||||
def __init__(self, data_path: str, input_excel: str, goods_url_path: str, upload_workers: int = 10, upload_image_domain:str = "https://www.fzcaiji.com/upload.php", column_name:str = "商品图片*"):
|
||||
self.__data_path__ = data_path
|
||||
self.__input_excel__ = input_excel
|
||||
self.__goods_url_path__ = goods_url_path
|
||||
|
||||
self.__upload_workers__ = upload_workers
|
||||
self.__upload_image_domain__ = upload_image_domain
|
||||
self.__column_name__ = column_name
|
||||
|
||||
self.__output_csv__ = f"{input_excel}.csv"
|
||||
self.__images_txt__ = f"{data_path}_images.txt"
|
||||
|
||||
# 原始数据转换
|
||||
def transform_excel_to_csv(self):
|
||||
def match_variant_count(number, group_df, variant_1, title, text, spu, data_rows, variant_2=None,variant_3=None):
|
||||
# 提取当前spu分组的价格
|
||||
sale_prices = group_df[(group_df['商品属性*'] == 'P') | (group_df['商品属性*'] == 'S')]['商品售价*'].tolist()
|
||||
origin_prices = group_df[(group_df['商品属性*'] == 'P') | (group_df['商品属性*'] == 'S')]['商品原价'].tolist()
|
||||
|
||||
# 提取对应属性的图片,并统计
|
||||
M_images = group_df[(group_df['商品属性*'] == 'M') | (group_df['商品属性*'] == 'S')]['商品图片*'].tolist()
|
||||
image_list = [url.strip() for url in M_images[0].split(',')]
|
||||
|
||||
group_df
|
||||
# 获取当前分组的URL
|
||||
if 'url' in group_df:
|
||||
url = group_df['url'].tolist()[0]
|
||||
else:
|
||||
url = group_df['URL'].tolist()[0]
|
||||
|
||||
match number:
|
||||
case 3:
|
||||
# 提取当前spu分组属性为P的值
|
||||
all_variants_first = group_df[(group_df['商品属性*'] == 'P')]['款式1'].tolist()
|
||||
all_variants_second = group_df[(group_df['商品属性*'] == 'P')]['款式2'].tolist()
|
||||
all_variants_three = group_df[(group_df['商品属性*'] == 'P')]['款式3'].tolist()
|
||||
|
||||
all_variants_count = max(len(all_variants_first), len(all_variants_second), len(all_variants_three))
|
||||
|
||||
if all_variants_count >= len(image_list):
|
||||
|
||||
# 计算需要扩大的长度,并进行扩大
|
||||
expand_length = all_variants_count - len(image_list)
|
||||
expanded_image_list = image_list + [None] * expand_length
|
||||
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
for k, variant in enumerate(all_variants_first):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": variant_1,
|
||||
"Option1 Value": all_variants_first[k],
|
||||
"Option2 Name": variant_2,
|
||||
"Option2 Value": all_variants_second[k],
|
||||
"Option3 Name": variant_3,
|
||||
"Option3 Value": all_variants_second[k],
|
||||
"Variant SKU": f"{spu}{str(k)}",
|
||||
"Variant Grams": "0",
|
||||
"Variant Inventory Tracker": "shopify",
|
||||
"Variant Inventory Qty": "1000",
|
||||
"Variant Inventory Policy": "continue",
|
||||
"Variant Fulfillment Service": "manual",
|
||||
"Variant Price": sale_prices[k],
|
||||
"Variant Compare At Price": origin_prices[k],
|
||||
"Variant Requires Shipping": "TRUE",
|
||||
"Variant Taxable": "TRUE",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": expanded_image_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if expanded_image_list[
|
||||
k] is not None else "", # 海象运算符
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if all_variants_first[k] else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
|
||||
else: # 变体少于图片数量
|
||||
# 计算扩大长度
|
||||
expand_length = len(image_list) - all_variants_count
|
||||
|
||||
expanded_all_variants_first_count = all_variants_first + [None] * expand_length
|
||||
expanded_all_variants_two_count = all_variants_second + [None] * expand_length
|
||||
expanded_all_variants_three_count = all_variants_three + [None] * expand_length
|
||||
# 扩大价格长度(为了统一价格)
|
||||
expanded_sale_prices = sale_prices + [None] * expand_length
|
||||
expanded_origin_prices = origin_prices + [None] * expand_length
|
||||
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
|
||||
for k, variant in enumerate(image_list):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": variant_1 if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Option1 Value": expanded_all_variants_first_count[k],
|
||||
"Option2 Name": variant_2 if expanded_all_variants_two_count[k] is not None else "",
|
||||
"Option2 Value": expanded_all_variants_two_count[k],
|
||||
"Option3 Name": variant_3 if expanded_all_variants_three_count[k] is not None else "",
|
||||
"Option3 Value": expanded_all_variants_three_count[k],
|
||||
"Variant SKU": f"{spu}{str(k)}" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Grams": "0" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Inventory Tracker": "shopify" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Inventory Qty": "1000" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Inventory Policy": "continue" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Fulfillment Service": "manual" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Price": expanded_sale_prices[k],
|
||||
"Variant Compare At Price": expanded_origin_prices[k],
|
||||
"Variant Requires Shipping": "TRUE" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Taxable": "TRUE" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": image_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if image_list[k] is not None else "",
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if k == 0 else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
|
||||
case 2:
|
||||
# 提取当前spu分组属性为P的值
|
||||
all_variants_first = group_df[(group_df['商品属性*'] == 'P')]['款式1'].tolist()
|
||||
all_variants_second = group_df[(group_df['商品属性*'] == 'P')]['款式2'].tolist()
|
||||
|
||||
all_variants_count = max(len(all_variants_first), len(all_variants_second))
|
||||
|
||||
if all_variants_count >= len(image_list):
|
||||
|
||||
# 计算需要扩大的长度,并进行扩大
|
||||
expand_length = all_variants_count - len(image_list)
|
||||
expanded_image_list = image_list + [None] * expand_length
|
||||
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
for k, variant in enumerate(all_variants_first):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": variant_1,
|
||||
"Option1 Value": all_variants_first[k],
|
||||
"Option2 Name": variant_2,
|
||||
"Option2 Value": all_variants_second[k],
|
||||
"Option3 Name": "",
|
||||
"Option3 Value": "",
|
||||
"Variant SKU": f"{spu}{str(k)}",
|
||||
"Variant Grams": "0",
|
||||
"Variant Inventory Tracker": "shopify",
|
||||
"Variant Inventory Qty": "1000",
|
||||
"Variant Inventory Policy": "continue",
|
||||
"Variant Fulfillment Service": "manual",
|
||||
"Variant Price": sale_prices[k],
|
||||
"Variant Compare At Price": origin_prices[k],
|
||||
"Variant Requires Shipping": "TRUE",
|
||||
"Variant Taxable": "TRUE",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": expanded_image_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if expanded_image_list[
|
||||
k] is not None else "", # 海象运算符
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if all_variants_first[k] else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
|
||||
else: # 变体少于图片数量
|
||||
# 计算扩大长度
|
||||
expand_length = len(image_list) - all_variants_count
|
||||
|
||||
expanded_all_variants_first_count = all_variants_first + [None] * expand_length
|
||||
expanded_all_variants_two_count = all_variants_second + [None] * expand_length
|
||||
|
||||
# 扩大价格长度(为了统一价格)
|
||||
expanded_sale_prices = sale_prices + [None] * expand_length
|
||||
expanded_origin_prices = origin_prices + [None] * expand_length
|
||||
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
|
||||
for k, variant in enumerate(image_list):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": variant_1 if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Option1 Value": expanded_all_variants_first_count[k],
|
||||
"Option2 Name": variant_2 if expanded_all_variants_two_count[k] is not None else "",
|
||||
"Option2 Value": expanded_all_variants_two_count[k],
|
||||
"Option3 Name": "",
|
||||
"Option3 Value": "",
|
||||
"Variant SKU": f"{spu}{str(k)}" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Grams": "0" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Inventory Tracker": "shopify" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Inventory Qty": "1000" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Inventory Policy": "continue" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Fulfillment Service": "manual" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Price": expanded_sale_prices[k],
|
||||
"Variant Compare At Price": expanded_origin_prices[k],
|
||||
"Variant Requires Shipping": "TRUE" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Taxable": "TRUE" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": image_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if image_list[k] is not None else "",
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if k == 0 else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
|
||||
case 1:
|
||||
all_variants = group_df[(group_df['商品属性*'] == 'P')]['款式1'].tolist()
|
||||
|
||||
all_variants_count = len(all_variants)
|
||||
|
||||
if all_variants_count >= len(image_list):
|
||||
# 计算扩大长度
|
||||
expand_length = all_variants_count - len(image_list)
|
||||
expanded_list = image_list + [None] * expand_length
|
||||
|
||||
# 扩大价格
|
||||
expanded_sale_prices = sale_prices + [None] * expand_length
|
||||
expanded_origin_prices = origin_prices + [None] * expand_length
|
||||
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
for k, variant in enumerate(all_variants):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": variant_1,
|
||||
"Option1 Value": all_variants[k],
|
||||
"Option2 Name": "",
|
||||
"Option2 Value": "",
|
||||
"Option3 Name": "",
|
||||
"Option3 Value": "",
|
||||
"Variant SKU": f"{spu}{str(k)}",
|
||||
"Variant Grams": "0",
|
||||
"Variant Inventory Tracker": "shopify",
|
||||
"Variant Inventory Qty": "1000",
|
||||
"Variant Inventory Policy": "continue",
|
||||
"Variant Fulfillment Service": "manual",
|
||||
"Variant Price": expanded_sale_prices[k],
|
||||
"Variant Compare At Price": expanded_origin_prices[k],
|
||||
"Variant Requires Shipping": "TRUE",
|
||||
"Variant Taxable": "TRUE",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": expanded_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if expanded_list[k] is not None else "",
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if all_variants[k] else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
|
||||
else: # 变体少于图片数量
|
||||
# 计算扩大长度
|
||||
expand_length = len(image_list) - all_variants_count
|
||||
expanded_all_variants_first_count = all_variants + [None] * expand_length
|
||||
|
||||
# 扩大价格
|
||||
expanded_sale_prices = sale_prices + [None] * expand_length
|
||||
expanded_origin_prices = origin_prices + [None] * expand_length
|
||||
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
|
||||
for k, variant in enumerate(image_list):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": variant_1 if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Option1 Value": expanded_all_variants_first_count[k],
|
||||
"Option2 Name": "",
|
||||
"Option2 Value": "",
|
||||
"Option3 Name": "",
|
||||
"Option3 Value": "",
|
||||
"Variant SKU": f"{spu}{str(k)}" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Grams": "0" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Inventory Tracker": "shopify" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Inventory Qty": "1000" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Inventory Policy": "continue" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Fulfillment Service": "manual" if expanded_all_variants_first_count[
|
||||
k] is not None else "",
|
||||
"Variant Price": expanded_sale_prices[k],
|
||||
"Variant Compare At Price": expanded_origin_prices[k],
|
||||
"Variant Requires Shipping": "TRUE" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Taxable": "TRUE" if expanded_all_variants_first_count[k] is not None else "",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": image_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if image_list[k] is not None else "",
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if k == 0 else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
|
||||
case 0:
|
||||
Handle = title[0].replace(' ', '-').replace('/', '-').lower()
|
||||
image_counter = 0
|
||||
for k, variant in enumerate(image_list):
|
||||
rows = {
|
||||
"Handle": Handle,
|
||||
"Title": title[0] if k == 0 else "",
|
||||
"Body (HTML)": text if k == 0 else "",
|
||||
"Vendor": "",
|
||||
"Type": "",
|
||||
"Tags": "",
|
||||
"Published": "TRUE" if k == 0 else "",
|
||||
"Option1 Name": "variant",
|
||||
"Option1 Value": "default",
|
||||
"Option2 Name": "",
|
||||
"Option2 Value": "",
|
||||
"Option3 Name": "",
|
||||
"Option3 Value": "",
|
||||
"Variant SKU": f"{spu}" if k == 0 else "",
|
||||
"Variant Grams": "0" if k == 0 else "",
|
||||
"Variant Inventory Tracker": "shopify" if k == 0 else "",
|
||||
"Variant Inventory Qty": "1000" if k == 0 else "",
|
||||
"Variant Inventory Policy": "continue" if k == 0 else "",
|
||||
"Variant Fulfillment Service": "manual" if k == 0 else "",
|
||||
"Variant Price": sale_prices[0] if k == 0 else "",
|
||||
"Variant Compare At Price": origin_prices[0] if k == 0 else "",
|
||||
"Variant Requires Shipping": "TRUE" if k == 0 else "",
|
||||
"Variant Taxable": "TRUE" if k == 0 else "",
|
||||
"Variant Barcode": "",
|
||||
"Image Src": image_list[k],
|
||||
"Image Position": (image_counter := image_counter + 1) if image_list[k] is not None else "",
|
||||
"Image Alt Text": "",
|
||||
"Gift Card": "FALSE" if k == 0 else "",
|
||||
"SEO Title": title[0] if k == 0 else "",
|
||||
"SEO Description": title[0] if k == 0 else "",
|
||||
"Google Shopping / Google Product Category": "",
|
||||
"Google Shopping / Gender": "",
|
||||
"Google Shopping / Age Group": "",
|
||||
"Google Shopping / MPN": "",
|
||||
"Google Shopping / AdWords Grouping": "",
|
||||
"Google Shopping / AdWords Labels": "",
|
||||
"Google Shopping / Condition": "",
|
||||
"Google Shopping / Custom Product": "",
|
||||
"Google Shopping / Custom Label 0": "",
|
||||
"Google Shopping / Custom Label 测试.txt": "",
|
||||
"Google Shopping / Custom Label 2": "",
|
||||
"Google Shopping / Custom Label 3": "",
|
||||
"Google Shopping / Custom Label 4": "",
|
||||
"Variant Image": "",
|
||||
"IsDraft": "",
|
||||
"Variant Weight Unit": "Kg" if k == 0 else "",
|
||||
"Variant Tax Code": "",
|
||||
"Cost per item": "",
|
||||
"Status": "activate" if k == 0 else "",
|
||||
"Collection": "",
|
||||
"url": url
|
||||
}
|
||||
data_rows.append(rows)
|
||||
data_rows = []
|
||||
df = pd.read_excel(self.__input_excel__)
|
||||
|
||||
# 以相同商品spu为一组
|
||||
grouped = df.groupby('商品spu')
|
||||
|
||||
data_rows = []
|
||||
|
||||
for spu, group_df in grouped:
|
||||
print(spu)
|
||||
group_df = group_df.fillna('')
|
||||
# 获取商品属性为M或S的商品标题
|
||||
title = group_df[(group_df['商品属性*'] == 'M') | (group_df['商品属性*'] == 'S')]['商品标题*'].tolist()
|
||||
description = group_df[(group_df['商品属性*'] == 'M') | (group_df['商品属性*'] == 'S')]['商品描述'].tolist()
|
||||
description.append('')
|
||||
text = BeautifulSoup(description[0], 'html.parser').get_text()
|
||||
|
||||
variant_name_one = group_df[(group_df['商品属性*'] == 'M') | (group_df['商品属性*'] == 'S')]['款式1'].tolist()[
|
||||
0]
|
||||
variant_name_two = group_df[(group_df['商品属性*'] == 'M') | (group_df['商品属性*'] == 'S')]['款式2'].tolist()[
|
||||
0]
|
||||
variant_name_three = \
|
||||
group_df[(group_df['商品属性*'] == 'M') | (group_df['商品属性*'] == 'S')]['款式3'].tolist()[0]
|
||||
variant_count = 0
|
||||
|
||||
if variant_name_one:
|
||||
variant_count += 1
|
||||
if variant_name_two:
|
||||
variant_count += 1
|
||||
if variant_name_three:
|
||||
variant_count += 1
|
||||
|
||||
# 统计个数
|
||||
match_variant_count(variant_count, group_df, variant_name_one, title, text, spu, data_rows, variant_name_two,variant_name_three)
|
||||
new_df = pd.DataFrame(data_rows)
|
||||
new_df.to_csv(self.__output_csv__, index=False, encoding="utf-8")
|
||||
|
||||
return self
|
||||
|
||||
# 数据分割
|
||||
def split_csv(self, csv_path: str = "", goods_url_path: str = ""):
|
||||
if csv_path == "":
|
||||
csv_path = self.__output_csv__
|
||||
if goods_url_path == "":
|
||||
goods_url_path = self.__goods_url_path__
|
||||
|
||||
with open(goods_url_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
dict_all = {}
|
||||
for i in lines:
|
||||
url = i.strip()
|
||||
if url == '':
|
||||
continue
|
||||
album = url.rsplit('#', 1)[1]
|
||||
third_album = album.split('/')[-1].lstrip() # 最后一个专辑
|
||||
# 全部存进字典
|
||||
if third_album not in dict_all:
|
||||
dict_all[third_album] = []
|
||||
|
||||
dict_all[third_album].append(url.rsplit('#',1)[0])
|
||||
# print(dict_all)
|
||||
|
||||
# 测试.txt. 读取 CSV 文件
|
||||
df = pd.read_csv(csv_path) # 替换为你的 CSV 文件路径
|
||||
|
||||
output_dir = f"{self.__data_path__}/已分类数据"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# 2. 遍历字典的每个键(分类)
|
||||
for category, urls in dict_all.items():
|
||||
# 3. 筛选出 url 列中包含在当前分类的 URL 的行
|
||||
filtered_df = df[df['url'].isin(urls)]
|
||||
|
||||
# 4. 如果筛选后的 DataFrame 不为空,则删除 'url' 列并保存到 CSV
|
||||
if not filtered_df.empty:
|
||||
filtered_df = filtered_df.drop(columns=['url']) # 删除 url 列
|
||||
output_filename = fr"{output_dir}/{category}.csv"
|
||||
filtered_df.to_csv(output_filename, index=False)
|
||||
print(f"已保存 {len(filtered_df)} 条数据到 {output_filename}(已删除 url 列)")
|
||||
else:
|
||||
print(f"分类 {category} 无匹配数据,跳过保存")
|
||||
return self
|
||||
|
||||
# 获取原始图片
|
||||
def get_excel_images_to_txt(self):
|
||||
def split_image_urls(cell: str):
|
||||
"""按 ',http' / ',https' 分割字符串,自动补上 'http'。不校验后缀。"""
|
||||
if not isinstance(cell, str) or not cell.strip():
|
||||
return []
|
||||
|
||||
s = re.sub(r',\s*(https?://)', r'||SEP||\1', cell.strip(), flags=re.IGNORECASE)
|
||||
parts = s.split('||SEP||')
|
||||
|
||||
urls = []
|
||||
for part in parts:
|
||||
part = part.strip().lstrip(',').strip()
|
||||
if not part:
|
||||
continue
|
||||
m = re.search(r'https?://', part, re.IGNORECASE)
|
||||
if m:
|
||||
part = part[m.start():]
|
||||
urls.append(part)
|
||||
return urls
|
||||
|
||||
"""从 Excel 中读取指定列,返回每行的图片 URL 列表。"""
|
||||
df = pd.read_excel(self.__input_excel__)
|
||||
urls_list = []
|
||||
for val in df[self.__column_name__].fillna(''):
|
||||
urls_list.append(split_image_urls(val))
|
||||
|
||||
"""展开 + 去重 + 保存到 txt 文件"""
|
||||
all_urls = set() # 用 set 去重
|
||||
for row_urls in urls_list:
|
||||
for url in row_urls:
|
||||
all_urls.add(url.strip())
|
||||
|
||||
# 写入文件
|
||||
with open(self.__images_txt__, "w", encoding="utf-8") as f:
|
||||
for url in sorted(all_urls):
|
||||
f.write(url + "\n")
|
||||
|
||||
print(f"✅ 共提取 {len(all_urls)} 张图片,已保存到 {self.__images_txt__}")
|
||||
return self
|
||||
|
||||
# 上传原始图片
|
||||
def update_images(self):
|
||||
def upload_image(img_url: str):
|
||||
"""下载并上传单张图片"""
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Referer": "https://www.google.com/"
|
||||
}
|
||||
try:
|
||||
# 通过 requests 下载图片(带代理和请求头)
|
||||
resp_img = requests.get(img_url, timeout=15, headers=HEADERS)
|
||||
resp_img.raise_for_status()
|
||||
img_data = BytesIO(resp_img.content)
|
||||
|
||||
# 生成哈希文件名
|
||||
hash_name = hashlib.sha1(img_url.encode("utf-8")).hexdigest()
|
||||
|
||||
# 获取扩展名(安全处理)
|
||||
ext = img_url.split("?")[0].split(".")[-1].lower()
|
||||
if len(ext) > 5 or "/" in ext:
|
||||
ext = "jpg"
|
||||
|
||||
# 上传
|
||||
files = {"file": (f"{hash_name}.{ext}", img_data, f"image/{ext}")}
|
||||
data = {"source_url": img_url}
|
||||
resp = requests.post(self.__upload_image_domain__, files=files, data=data, timeout=30)
|
||||
|
||||
# 打印结果
|
||||
try:
|
||||
js = resp.json()
|
||||
print(f"✅ {img_url} -> {js.get('url') or js}")
|
||||
except Exception:
|
||||
print(f"❌ {img_url} -> 上传失败: {resp.text[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ 下载或上传异常: {img_url} -> {e}")
|
||||
# 读取本地 txt 文件
|
||||
with open(self.__images_txt__, "r", encoding="utf-8") as f:
|
||||
urls = [x.strip() for x in f if x.strip()]
|
||||
|
||||
print(f"共读取到 {len(urls)} 张图片。开始上传...")
|
||||
|
||||
start = time.time()
|
||||
# 使用线程池并发上传
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=self.__upload_workers__) as executor:
|
||||
list(executor.map(upload_image, urls))
|
||||
|
||||
print(f"\n全部上传完成,用时 {time.time() - start:.2f} 秒。")
|
||||
|
||||
return self
|
||||
|
||||
# 替换原始图片
|
||||
def replace_excel_image(self):
|
||||
"""
|
||||
读取 Excel 和 txt,将原图片 URL 替换为新图片 URL,保存到新 Excel
|
||||
"""
|
||||
def get_uploaded_image_url_php_style(img_url: str) -> str:
|
||||
"""
|
||||
模拟 PHP 上传脚本的路径生成逻辑:
|
||||
- hash = sha1(source_url)
|
||||
- 一级目录 = hash 前 1 字符
|
||||
- 二级目录 = hash 第 2 字符
|
||||
- 文件名 = hash + 扩展名
|
||||
"""
|
||||
# 计算 sha1
|
||||
hash_name = hashlib.sha1(img_url.encode('utf-8')).hexdigest()
|
||||
|
||||
# 取扩展名(与 PHP 一致:从文件名里提取,默认 jpg)
|
||||
ext = os.path.splitext(img_url.split('?')[0])[1].lstrip('.').lower()
|
||||
if not ext or len(ext) > 5 or "/" in ext:
|
||||
ext = 'jpg'
|
||||
|
||||
# 两层目录
|
||||
dir1 = hash_name[0]
|
||||
dir2 = hash_name[1]
|
||||
|
||||
# 拼接最终 URL
|
||||
new_url = f"{self.__upload_image_domain__}/{dir1}/{dir2}/{hash_name}.{ext}"
|
||||
|
||||
return new_url
|
||||
|
||||
def split_image_urls(cell: str):
|
||||
"""按 ',http' / ',https' 分割字符串,自动补上 'http'。不校验后缀。"""
|
||||
if not isinstance(cell, str) or not cell.strip():
|
||||
return []
|
||||
|
||||
s = re.sub(r',\s*(https?://)', r'||SEP||\1', cell.strip(), flags=re.IGNORECASE)
|
||||
parts = s.split('||SEP||')
|
||||
|
||||
urls = []
|
||||
for part in parts:
|
||||
part = part.strip().lstrip(',').strip()
|
||||
if not part:
|
||||
continue
|
||||
m = re.search(r'https?://', part, re.IGNORECASE)
|
||||
if m:
|
||||
part = part[m.start():]
|
||||
urls.append(part)
|
||||
# cell = ',' + cell
|
||||
# cell = cell.split(',https://')
|
||||
# urls = []
|
||||
# for image_url in cell:
|
||||
# if '.svg' in image_url:
|
||||
# continue
|
||||
# if image_url == "":
|
||||
# continue
|
||||
# urls.append(('https://'+image_url).strip())
|
||||
return urls
|
||||
|
||||
# 读取 txt 中的原图片链接
|
||||
with open(self.__images_txt__, "r", encoding="utf-8") as f:
|
||||
original_urls = [line.strip() for line in f if line.strip()]
|
||||
|
||||
# 生成原图片到新图片的映射
|
||||
url_mapping = {}
|
||||
for old_url in original_urls:
|
||||
new_url = get_uploaded_image_url_php_style(old_url)
|
||||
url_mapping[old_url] = new_url
|
||||
|
||||
print(f"📋 共加载 {len(url_mapping)} 个图片映射关系")
|
||||
|
||||
# 读取 Excel
|
||||
df = pd.read_excel(self.__input_excel__)
|
||||
|
||||
# 替换图片列
|
||||
replaced_count = 0
|
||||
for idx, cell_value in enumerate(df[self.__column_name__].fillna('')):
|
||||
if not cell_value:
|
||||
continue
|
||||
|
||||
# 分割出该单元格的所有图片 URL
|
||||
old_urls = split_image_urls(cell_value)
|
||||
|
||||
# 替换为新 URL
|
||||
new_urls = []
|
||||
for old_url in old_urls:
|
||||
if old_url in url_mapping:
|
||||
new_urls.append(url_mapping[old_url])
|
||||
replaced_count += 1
|
||||
else:
|
||||
new_urls.append(old_url) # 如果找不到映射,保持原样
|
||||
|
||||
# 拼接回去(用逗号分隔)
|
||||
df.at[idx, self.__column_name__] = ','.join(new_urls)
|
||||
|
||||
output_excel = f"{self.__input_excel__}(new_image).xlsx"
|
||||
# 保存到新 Excel
|
||||
df.to_excel(output_excel, index=False)
|
||||
print(f"✅ 替换完成!共替换 {replaced_count} 个图片链接")
|
||||
print(f"✅ 已保存到 {output_excel}")
|
||||
self.__input_excel__ = output_excel
|
||||
|
||||
# 一键运行
|
||||
def run(self, replace_image: bool = False):
|
||||
if replace_image:
|
||||
self.get_excel_images_to_txt().update_images().replace_excel_image()
|
||||
self.transform_excel_to_csv().split_csv()
|
||||
|
||||
if __name__ == "__main__":
|
||||
wpdata = Wpdata(
|
||||
data_path="data", # 已分类数据文件夹保存路径
|
||||
input_excel=r"data\jamesavery\info\goods(sort)_2025-12-24.xlsx", # 原始excel路径
|
||||
goods_url_path=r"data\jamesavery\urls\goods_2025-12-19.txt_old.txt" # 商品txt路径
|
||||
)
|
||||
wpdata.run(
|
||||
# replace_image=True # 是否上传图片
|
||||
)
|
||||
Reference in New Issue
Block a user