mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor(string): split utilities by responsibility
This commit is contained in:
@@ -2,7 +2,12 @@ from typing import Union
|
||||
|
||||
|
||||
class DomUtils:
|
||||
"""提供 XML DOM 节点读取和创建辅助能力。"""
|
||||
"""提供不含业务状态的 XML/HTML DOM 基础能力。"""
|
||||
|
||||
@staticmethod
|
||||
def has_child_elements(element) -> bool:
|
||||
"""判断 DOM 元素是否存在且至少包含一个子元素。"""
|
||||
return element is not None and len(element) > 0
|
||||
|
||||
@staticmethod
|
||||
def tag_value(tag_item, tag_name: str, attname: str = "", default: Union[str, int] = None):
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""字节容量的解析与显示基础能力。"""
|
||||
|
||||
import bisect
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
|
||||
def parse_size(text: Union[str, int, float]) -> int:
|
||||
"""将带二进制容量单位的文本转换为字节数。"""
|
||||
if not text:
|
||||
return 0
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
normalized = text.replace(",", "").replace(" ", "").upper()
|
||||
size_text = re.sub(r"[KMGTPI]*B?", "", normalized, flags=re.IGNORECASE)
|
||||
try:
|
||||
size = float(size_text)
|
||||
except ValueError:
|
||||
return 0
|
||||
if "PB" in normalized or "PIB" in normalized:
|
||||
size *= 1024 ** 5
|
||||
elif "TB" in normalized or "TIB" in normalized:
|
||||
size *= 1024 ** 4
|
||||
elif "GB" in normalized or "GIB" in normalized:
|
||||
size *= 1024 ** 3
|
||||
elif "MB" in normalized or "MIB" in normalized:
|
||||
size *= 1024 ** 2
|
||||
elif "KB" in normalized or "KIB" in normalized:
|
||||
size *= 1024
|
||||
return round(size)
|
||||
|
||||
|
||||
def format_compact_size(size: Union[str, float, int], precision: int = 2) -> str:
|
||||
"""将字节数格式化为不带尾部 B 的紧凑容量描述。"""
|
||||
if size is None:
|
||||
return ""
|
||||
# 历史实现把 re.IGNORECASE 作为 count 位置参数传入;这里保留其最多替换两次、
|
||||
# 且仅匹配大写单位的实际行为,避免旧插件在边缘输入上发生变化。
|
||||
normalized = re.sub(r"\s|B|iB", "", str(size), count=re.IGNORECASE)
|
||||
if normalized.replace(".", "").isdigit():
|
||||
try:
|
||||
numeric_size = float(normalized)
|
||||
thresholds = [
|
||||
(1024 - 1, "K"),
|
||||
(1024 ** 2 - 1, "M"),
|
||||
(1024 ** 3 - 1, "G"),
|
||||
(1024 ** 4 - 1, "T"),
|
||||
]
|
||||
index = bisect.bisect_left(
|
||||
[threshold for threshold, _unit in thresholds], numeric_size
|
||||
) - 1
|
||||
if index == -1:
|
||||
return f"{numeric_size}B"
|
||||
threshold, unit = thresholds[index]
|
||||
return f"{round(numeric_size / (threshold + 1), precision)}{unit}"
|
||||
except ValueError:
|
||||
return ""
|
||||
if re.findall(r"[KMGTP]", normalized, re.IGNORECASE):
|
||||
return normalized
|
||||
return f"{normalized}B"
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""将字节数转换为带空格和完整单位的人类可读格式。"""
|
||||
if not size_bytes:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||||
size = float(size_bytes)
|
||||
unit_index = 0
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if unit_index == 0:
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
return f"{size:.2f} {units[unit_index]}"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""日期、时间戳和时长的无状态转换能力。"""
|
||||
|
||||
import bisect
|
||||
import datetime
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import dateparser
|
||||
import dateutil.parser
|
||||
|
||||
|
||||
def format_approx_duration(seconds: Union[str, int, float]) -> str:
|
||||
"""把秒数格式化为单一最大单位的近似时长。"""
|
||||
try:
|
||||
seconds = float(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
thresholds = [(0, "秒"), (60 - 1, "分"), (3600 - 1, "小时"), (86400 - 1, "天")]
|
||||
index = bisect.bisect_left(
|
||||
[threshold for threshold, _unit in thresholds], seconds
|
||||
) - 1
|
||||
if index == -1:
|
||||
return str(seconds)
|
||||
threshold, unit = thresholds[index]
|
||||
return f"{round(seconds / (threshold + 1))}{unit}"
|
||||
|
||||
|
||||
def format_duration(seconds: Union[str, int, float]) -> str:
|
||||
"""把秒数格式化为时分秒组合文本。"""
|
||||
hours = seconds // 3600
|
||||
remainder_seconds = seconds % 3600
|
||||
minutes = remainder_seconds // 60
|
||||
seconds = remainder_seconds % 60
|
||||
|
||||
result = f"{int(seconds)}秒"
|
||||
if minutes:
|
||||
result = f"{int(minutes)}分{result}"
|
||||
if hours:
|
||||
result = f"{int(hours)}时{result}"
|
||||
return result
|
||||
|
||||
|
||||
def parse_datetime(value: Any) -> Optional[datetime.datetime]:
|
||||
"""将常见日期表达解析为 datetime,无法解析时返回 None。"""
|
||||
try:
|
||||
return dateutil.parser.parse(value)
|
||||
except (TypeError, ValueError, dateutil.parser.ParserError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_datetime(value: str) -> str:
|
||||
"""把常见绝对或相对日期文本统一为本地日期时间格式。"""
|
||||
if not value:
|
||||
return value
|
||||
try:
|
||||
parsed = dateparser.parse(value)
|
||||
return parsed.strftime("%Y-%m-%d %H:%M:%S") if parsed else value
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return value
|
||||
|
||||
|
||||
def format_timestamp(timestamp: str, date_format: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""把 Unix 时间戳转换为指定格式的本地日期文本。"""
|
||||
if isinstance(timestamp, str) and not timestamp.isdigit():
|
||||
return timestamp
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(int(timestamp)).strftime(date_format)
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return timestamp
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> float:
|
||||
"""把日期表达转换为 Unix 时间戳,无法解析时返回零。"""
|
||||
if not value:
|
||||
return 0
|
||||
try:
|
||||
parsed = dateparser.parse(value)
|
||||
return parsed.timestamp() if parsed else 0
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 0
|
||||
|
||||
|
||||
def format_minutes(minutes: int) -> str:
|
||||
"""把分钟数格式化为小时和分钟组合文本。"""
|
||||
if not minutes:
|
||||
return ""
|
||||
hours, remaining_minutes = divmod(minutes, 60)
|
||||
if hours:
|
||||
return f"{hours}小时{remaining_minutes}分"
|
||||
return f"{remaining_minutes}分钟"
|
||||
|
||||
|
||||
def format_remaining(value: str) -> str:
|
||||
"""把本地日期时间文本格式化为距当前时间的剩余时长。"""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
target = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return value
|
||||
difference = target - datetime.datetime.now()
|
||||
seconds = difference.seconds
|
||||
days = difference.days
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
if days > 0:
|
||||
return f"{days}天{hours}小时{minutes}分钟"
|
||||
if hours > 0:
|
||||
return f"{hours}小时{minutes}分钟"
|
||||
if minutes > 0:
|
||||
return f"{minutes}分钟"
|
||||
return ""
|
||||
+234
-1
@@ -1,4 +1,8 @@
|
||||
"""无业务状态的中文分词与简繁转换工具。"""
|
||||
"""无业务状态的文本识别、清理、转换和分段能力。"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from typing import Generator, List, Optional, Union
|
||||
|
||||
from jieba_next import cut as jieba_next_cut
|
||||
from zhconv_rs import zhconv as _zhconv # pylint: disable=no-name-in-module
|
||||
@@ -14,3 +18,232 @@ def cut(text: str, HMM: bool = True, cut_all: bool = False) -> list[str]:
|
||||
def convert(text: str, target: str) -> str:
|
||||
"""使用 zhconv-rs 执行中文简繁转换,并隔离第三方包的函数名差异。"""
|
||||
return _zhconv(text, target)
|
||||
|
||||
|
||||
def contains_chinese(value: Union[str, list]) -> bool:
|
||||
"""判断文本或文本列表中是否包含中文字符。"""
|
||||
if not value:
|
||||
return False
|
||||
if isinstance(value, list):
|
||||
value = " ".join(value)
|
||||
return re.search(r"[\u4e00-\u9fff]", value) is not None
|
||||
|
||||
|
||||
def contains_japanese(value: str) -> bool:
|
||||
"""判断文本中是否包含平假名或片假名。"""
|
||||
return re.search(r"[\u3040-\u309F\u30A0-\u30FF]", value) is not None
|
||||
|
||||
|
||||
def contains_korean(value: str) -> bool:
|
||||
"""判断文本中是否包含韩文字符。"""
|
||||
return re.search(r"[\uAC00-\uD7FF]", value) is not None
|
||||
|
||||
|
||||
def is_all_chinese(value: str) -> bool:
|
||||
"""判断除空格外的全部字符是否都是中文。"""
|
||||
return all(character == " " or "\u4e00" <= character <= "\u9fff" for character in value)
|
||||
|
||||
|
||||
def is_english_word(value: str) -> bool:
|
||||
"""判断文本是否为不含空格的英文字母单词。"""
|
||||
return value.encode().isalpha()
|
||||
|
||||
|
||||
def parse_int(value: str) -> int:
|
||||
"""解析可能带千位分隔符的整数,无法解析时返回零。"""
|
||||
if value:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return 0
|
||||
try:
|
||||
return int(value.replace(",", ""))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def parse_float(value: str) -> float:
|
||||
"""解析可能带千位分隔符的浮点数,无法解析时返回零。"""
|
||||
if value:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
return float(value.replace(",", ""))
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def remove_punctuation(
|
||||
value: Union[list, str],
|
||||
replacement: str = "",
|
||||
allow_space: bool = False,
|
||||
) -> Union[list, str]:
|
||||
"""移除历史匹配规则使用的标点和零宽字符。"""
|
||||
punctuation = r"[、.。,,·::;;!!??'’\"“”()()\[\]【】「」\-—―\+\|\\_/&#~~]"
|
||||
if not value:
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [remove_punctuation(item) for item in value]
|
||||
normalized = re.sub(
|
||||
r"[\u200B-\u200D\uFEFF]",
|
||||
"",
|
||||
re.sub(punctuation, replacement, value, flags=re.IGNORECASE),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if not allow_space:
|
||||
return re.sub(r"\s+", "", normalized)
|
||||
return re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
|
||||
def normalize_upper(value: Optional[str]) -> str:
|
||||
"""移除历史匹配标点、空白并转换为大写。"""
|
||||
if not value:
|
||||
return ""
|
||||
return remove_punctuation(value).upper().strip()
|
||||
|
||||
|
||||
def sanitize_filename(value: str) -> Optional[str]:
|
||||
"""移除文件名中不允许使用的字符并替换英文冒号。"""
|
||||
if not value:
|
||||
return None
|
||||
return re.sub(r"[*?\\/\"<>~|]", "", value, flags=re.IGNORECASE).replace(":", ":")
|
||||
|
||||
|
||||
def random_string(length: int = 16) -> str:
|
||||
"""生成兼容历史字符集的指定长度随机字符串。"""
|
||||
alphabet = "ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789"
|
||||
last_index = len(alphabet) - 1
|
||||
return "".join(alphabet[random.randint(0, last_index)] for _index in range(length))
|
||||
|
||||
|
||||
def parse_bool(value, default: bool = False) -> bool:
|
||||
"""按历史配置规则把字符串或数值转换为布尔值。"""
|
||||
if isinstance(value, str) and not value:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value > 0
|
||||
return isinstance(value, str) and value.lower() in {"y", "true", "1", "yes", "on"}
|
||||
|
||||
|
||||
def cookiejar_to_string(cookiejar: dict) -> str:
|
||||
"""把键值形式的 CookieJar 序列化为 Cookie 请求头文本。"""
|
||||
return "; ".join("=".join(item) for item in cookiejar.items())
|
||||
|
||||
|
||||
def extract_named_ids(content: str, entries: List[dict]):
|
||||
"""从空格分隔文本中提取命名条目 ID,并返回剩余内容。"""
|
||||
if not content:
|
||||
return []
|
||||
identifiers = []
|
||||
content_parts = content.split()
|
||||
for entry in entries:
|
||||
if entry.get("name") in content_parts and entry.get("id") not in identifiers:
|
||||
identifiers.append(entry.get("id"))
|
||||
content = content.replace(entry.get("name"), "")
|
||||
return identifiers, re.sub(r"\s+", " ", content).strip()
|
||||
|
||||
|
||||
def format_amount(amount: object, currency: str = "$") -> str:
|
||||
"""使用千位分隔符和货币前缀格式化金额。"""
|
||||
if not amount:
|
||||
return "0"
|
||||
return currency + format(amount, ",")
|
||||
|
||||
|
||||
def count_words(value: str) -> int:
|
||||
"""统计中英文混合文本中的汉字数和英文单词数。"""
|
||||
if not value:
|
||||
return 0
|
||||
chinese_words = [
|
||||
word for word in re.findall(r"[\u4e00-\u9fa5]", value) if word.isalpha()
|
||||
]
|
||||
english_words = [word for word in re.findall(r"[a-zA-Z]+", value) if word.isalpha()]
|
||||
return len(chinese_words) + len(english_words)
|
||||
|
||||
|
||||
def split_by_bytes(value: str, max_length: int) -> Generator[str, None, None]:
|
||||
"""按 UTF-8 字节上限分段,优先保持换行和英文单词完整。"""
|
||||
if not value:
|
||||
yield ""
|
||||
lines = re.split("\n", value)
|
||||
buffer = ""
|
||||
for line in lines:
|
||||
if len(line.encode("utf-8")) > max_length:
|
||||
separator = ""
|
||||
if re.match(r"^[A-Za-z0-9.\s]+", line):
|
||||
parts = line.split()
|
||||
separator = " "
|
||||
else:
|
||||
parts = line
|
||||
part = ""
|
||||
for item in parts:
|
||||
if len((part + item).encode("utf-8")) > max_length:
|
||||
yield (buffer + part).strip()
|
||||
buffer = ""
|
||||
part = f"{separator}{item}"
|
||||
else:
|
||||
part = f"{part}{separator}{item}"
|
||||
if part:
|
||||
buffer += part
|
||||
elif len((buffer + "\n" + line).encode("utf-8")) > max_length:
|
||||
yield buffer.strip()
|
||||
buffer = line
|
||||
elif buffer:
|
||||
buffer = f"{buffer}\n{line}"
|
||||
else:
|
||||
buffer = line
|
||||
if buffer:
|
||||
yield buffer.strip()
|
||||
|
||||
|
||||
def title_case(value: Optional[str]) -> str:
|
||||
"""转换为标题大小写,并兼容空值。"""
|
||||
return value.title() if value else value
|
||||
|
||||
|
||||
def escape_markdown(value: str) -> str:
|
||||
"""转义 Markdown 保留字符,并保持历史二次转义语义。"""
|
||||
escaped = re.sub(r"([_*\[\]()~`>#+\-=|.!{}])", r"\\\1", value)
|
||||
return re.sub(r"\\\\([_*\[\]()~`>#+\-=|.!{}])", r"\1", escaped)
|
||||
|
||||
|
||||
def is_number(value: str) -> bool:
|
||||
"""判断文本能否转换为整数或浮点数。"""
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def common_prefix(first: str, second: str) -> str:
|
||||
"""返回两个字符串从首字符开始的公共前缀。"""
|
||||
if not first or not second:
|
||||
return ""
|
||||
prefix = []
|
||||
for first_character, second_character in zip(first, second):
|
||||
if first_character != second_character:
|
||||
break
|
||||
prefix.append(first_character)
|
||||
return "".join(prefix)
|
||||
|
||||
|
||||
def strip_optional(value) -> Optional[str]:
|
||||
"""去除可空值两端空白,并保持 None。"""
|
||||
return value.strip() if value is not None else None
|
||||
|
||||
|
||||
def natural_sort_key(value: str) -> List[Union[int, str]]:
|
||||
"""把文本拆成数字和小写文本片段,供自然排序使用。"""
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
return [
|
||||
int(part) if part.isdigit() else part.lower()
|
||||
for part in re.split(r"(\d+)", value)
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Tuple
|
||||
from urllib import parse
|
||||
@@ -70,6 +71,7 @@ class UrlUtils:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_mime_type(path_or_url: Union[str, Path], default_type: str = "application/octet-stream") -> str:
|
||||
"""
|
||||
@@ -135,3 +137,77 @@ class UrlUtils:
|
||||
return protocol, hostname, port, path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def split_netloc(url: str) -> Tuple[str, str]:
|
||||
"""返回 URL 的协议与网络位置,并兼容未带协议的历史输入。"""
|
||||
if not url:
|
||||
return "", ""
|
||||
if not url.startswith("http"):
|
||||
return "http", url
|
||||
address = urlparse(url)
|
||||
return address.scheme, address.netloc
|
||||
|
||||
|
||||
def second_level_label(url: str) -> str:
|
||||
"""返回不含端口的倒数第二级域名标签,IP 则保持原值。"""
|
||||
if not url:
|
||||
return ""
|
||||
_scheme, netloc = split_netloc(url)
|
||||
if not netloc:
|
||||
return ""
|
||||
labels = netloc.split(":")[0].split(".")
|
||||
return labels[-2] if len(labels) >= 2 else labels[0]
|
||||
|
||||
|
||||
def host_label(url: str) -> str:
|
||||
"""返回兼容历史语义的一级主机标签。"""
|
||||
if not url:
|
||||
return ""
|
||||
_scheme, netloc = split_netloc(url)
|
||||
if not netloc:
|
||||
return ""
|
||||
return netloc.split(".")[-2]
|
||||
|
||||
|
||||
def base_url(url: str) -> str:
|
||||
"""返回由协议和网络位置组成的根地址。"""
|
||||
if not url:
|
||||
return ""
|
||||
scheme, netloc = split_netloc(url)
|
||||
return f"{scheme}://{netloc}"
|
||||
|
||||
|
||||
def parse_address(
|
||||
address: str,
|
||||
include_scheme: bool = True,
|
||||
) -> Tuple[Optional[str], Optional[int]]:
|
||||
"""按历史规则从服务地址中提取域名文本和端口。"""
|
||||
if not address:
|
||||
return None, None
|
||||
address = address.rstrip("/")
|
||||
if include_scheme and not address.startswith("http"):
|
||||
address = f"http://{address}"
|
||||
elif not include_scheme and address.startswith("http"):
|
||||
address = address.split("://")[-1]
|
||||
parts = address.split(":")
|
||||
if len(parts) > 3:
|
||||
return None, None
|
||||
if len(parts) == 3:
|
||||
port = int(parts[-1])
|
||||
domain = ":".join(parts[:-1]).rstrip("/")
|
||||
elif len(parts) == 2:
|
||||
port = 443 if address.startswith("https") else 80
|
||||
domain = address
|
||||
else:
|
||||
return None, None
|
||||
return domain, port
|
||||
|
||||
|
||||
def is_link(value: str) -> bool:
|
||||
"""判断文本是否为受支持协议链接、IP 或域名形式。"""
|
||||
if not value:
|
||||
return False
|
||||
if re.match(r"^(http|https|ftp|ftps|sftp|ws|wss)://", value):
|
||||
return True
|
||||
return re.match(r"^[a-zA-Z0-9.-]+(\.[a-zA-Z]{2,})?$", value) is not None
|
||||
|
||||
Reference in New Issue
Block a user