mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor(string): split utilities by responsibility
This commit is contained in:
@@ -21,7 +21,7 @@ from app.schemas.types import (
|
||||
MediaType,
|
||||
)
|
||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||
from app.domain.string import StringUtils
|
||||
from app.foundation import temporal as time_tools
|
||||
|
||||
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
|
||||
ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
|
||||
@@ -779,7 +779,7 @@ class TorrentInfo:
|
||||
"""
|
||||
if not self.freedate:
|
||||
return ""
|
||||
return StringUtils.diff_time_str(self.freedate)
|
||||
return time_tools.format_remaining(self.freedate)
|
||||
|
||||
def pub_minutes(self) -> float:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""剧集编号列表的业务显示规则。"""
|
||||
|
||||
from typing import List
|
||||
|
||||
|
||||
def compact_numbers(numbers: List[int]) -> str:
|
||||
"""把连续剧集编号压缩为逗号分隔的数字区间。"""
|
||||
numbers.sort()
|
||||
result = []
|
||||
start = numbers[0]
|
||||
end = numbers[0]
|
||||
for number in numbers[1:]:
|
||||
if number == end + 1:
|
||||
end = number
|
||||
continue
|
||||
result.append(str(start) if start == end else f"{start}-{end}")
|
||||
start = end = number
|
||||
result.append(str(start) if start == end else f"{start}-{end}")
|
||||
return ",".join(result)
|
||||
|
||||
|
||||
def format_ranges(numbers: List[int]) -> str:
|
||||
"""把剧集编号格式化为带 E 前缀和中文顿号的连续区间。"""
|
||||
if not numbers:
|
||||
return ""
|
||||
if len(numbers) == 1:
|
||||
return f"E{numbers[0]:02d}"
|
||||
numbers.sort()
|
||||
ranges = []
|
||||
start = numbers[0]
|
||||
end = numbers[0]
|
||||
for number in numbers[1:]:
|
||||
if number == end + 1:
|
||||
end = number
|
||||
continue
|
||||
ranges.append(f"E{start:02d}" if start == end else f"E{start:02d}-E{end:02d}")
|
||||
start = end = number
|
||||
ranges.append(f"E{start:02d}" if start == end else f"E{start:02d}-E{end:02d}")
|
||||
return "、".join(ranges)
|
||||
@@ -1,7 +1,7 @@
|
||||
import regex as re
|
||||
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.string import StringUtils
|
||||
from app.foundation import text as text_tools
|
||||
|
||||
AUXILIARY_CN_STEM_FULLMATCH_RE = re.compile(
|
||||
r"^(双语|字幕|特效|内封|外挂|官译|简体|繁体|繁中|简中|中英|简英|多语|"
|
||||
@@ -27,7 +27,7 @@ def should_use_parent_title_for_file_stem(
|
||||
return False
|
||||
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
|
||||
return False
|
||||
if not StringUtils.is_all_chinese(stem):
|
||||
if not text_tools.is_all_chinese(stem):
|
||||
return False
|
||||
if len(stem) > 16:
|
||||
return False
|
||||
|
||||
@@ -6,7 +6,8 @@ import anitopy
|
||||
from app.domain.meta.customization import CustomizationMatcher
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.domain.string import StringUtils
|
||||
from app.domain import title as title_rules
|
||||
from app.foundation import text as text_tools
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -64,11 +65,11 @@ class MetaAnime(MetaBase):
|
||||
if anitopy_info:
|
||||
# 名称
|
||||
name = anitopy_info.get("anime_title")
|
||||
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
||||
if not name or name in self._anime_no_words or (len(name) < 5 and not text_tools.contains_chinese(name)):
|
||||
anitopy_info = anitopy.parse("[ANIME]" + title)
|
||||
if anitopy_info:
|
||||
name = anitopy_info.get("anime_title")
|
||||
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
||||
if not name or name in self._anime_no_words or (len(name) < 5 and not text_tools.contains_chinese(name)):
|
||||
name_match = BRACKET_TITLE_RE.search(title)
|
||||
if name_match and name_match.group(1):
|
||||
name = name_match.group(1).strip()
|
||||
@@ -78,12 +79,12 @@ class MetaAnime(MetaBase):
|
||||
# 按/拆分中英文
|
||||
if name.find("/") != -1:
|
||||
names = name.split("/")
|
||||
if StringUtils.is_chinese(names[0]):
|
||||
if text_tools.contains_chinese(names[0]):
|
||||
self.cn_name = names[0]
|
||||
if len(names) > 1:
|
||||
self.en_name = names[1]
|
||||
_split_flag = False
|
||||
elif StringUtils.is_chinese(names[-1]):
|
||||
elif text_tools.contains_chinese(names[-1]):
|
||||
self.cn_name = names[-1]
|
||||
if len(names) > 1:
|
||||
self.en_name = names[0]
|
||||
@@ -103,19 +104,19 @@ class MetaAnime(MetaBase):
|
||||
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||
elif lastword_type == "en":
|
||||
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||
elif StringUtils.is_chinese(word):
|
||||
elif text_tools.contains_chinese(word):
|
||||
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||
lastword_type = "cn"
|
||||
else:
|
||||
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||
lastword_type = "en"
|
||||
if self.cn_name:
|
||||
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
||||
_, self.cn_name, _, _, _, _ = title_rules.parse_search_keyword(self.cn_name)
|
||||
if self.cn_name:
|
||||
self.cn_name = self._name_nostring_pattern.sub('', self.cn_name).strip()
|
||||
if self.en_name:
|
||||
self.en_name = self._name_nostring_pattern.sub('', self.en_name).strip().title()
|
||||
self._name = StringUtils.str_title(self.en_name)
|
||||
self._name = text_tools.title_case(self.en_name)
|
||||
# 年份
|
||||
year = anitopy_info.get("anime_year")
|
||||
if str(year).isdigit():
|
||||
@@ -271,7 +272,7 @@ class MetaAnime(MetaBase):
|
||||
else:
|
||||
titles.append("%s%s" % (left_char, name.split("/")[0].strip()))
|
||||
elif name:
|
||||
if StringUtils.is_chinese(name) and not StringUtils.is_all_chinese(name):
|
||||
if text_tools.contains_chinese(name) and not text_tools.is_all_chinese(name):
|
||||
if not NUMERIC_BRACKET_RE.search(name):
|
||||
name = MIXED_CHINESE_TOKEN_RE.sub('', name).strip()
|
||||
if not name or name.strip().isdigit():
|
||||
|
||||
@@ -8,7 +8,7 @@ import regex as re
|
||||
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.domain.string import StringUtils
|
||||
from app.foundation import text as text_tools
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -128,7 +128,7 @@ class MetaBase(object):
|
||||
"""
|
||||
返回名称
|
||||
"""
|
||||
if self.cn_name and StringUtils.is_all_chinese(self.cn_name):
|
||||
if self.cn_name and text_tools.is_all_chinese(self.cn_name):
|
||||
return self.cn_name
|
||||
elif self.en_name:
|
||||
return self.en_name
|
||||
@@ -141,7 +141,7 @@ class MetaBase(object):
|
||||
"""
|
||||
设置名称
|
||||
"""
|
||||
if StringUtils.is_all_chinese(name):
|
||||
if text_tools.is_all_chinese(name):
|
||||
self.cn_name = name
|
||||
else:
|
||||
self.en_name = name
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.domain.meta.customization import CustomizationMatcher
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.schemas.types import MediaType
|
||||
from app.domain.string import StringUtils
|
||||
from app.foundation import text as text_tools
|
||||
from app.domain.tokens import Tokens
|
||||
from app.domain.meta.streamingplatform import StreamingPlatforms
|
||||
from app.domain.meta.runtime import get_media_extensions
|
||||
@@ -218,7 +218,7 @@ class MetaVideo(MetaBase):
|
||||
self.init_subtitle(self.subtitle)
|
||||
# 去掉名字中不需要的干扰字符,过短的纯数字不要
|
||||
self.cn_name = self.__fix_name(self.cn_name)
|
||||
self.en_name = StringUtils.str_title(self.__fix_name(self.en_name))
|
||||
self.en_name = text_tools.title_case(self.__fix_name(self.en_name))
|
||||
# 处理part
|
||||
if self.part and self.part.upper() == "PART":
|
||||
self.part = None
|
||||
@@ -245,7 +245,7 @@ class MetaVideo(MetaBase):
|
||||
if not description:
|
||||
return None
|
||||
titles = DESCRIPTION_SPLIT_RE.split(description)
|
||||
if StringUtils.is_chinese(titles[0]):
|
||||
if text_tools.contains_chinese(titles[0]):
|
||||
return titles[0]
|
||||
return None
|
||||
|
||||
@@ -308,7 +308,7 @@ class MetaVideo(MetaBase):
|
||||
if token in self._name_se_words:
|
||||
self._last_token_type = 'name_se_words'
|
||||
return
|
||||
if StringUtils.is_chinese(token):
|
||||
if text_tools.contains_chinese(token):
|
||||
# 含有中文,直接做为标题(连着的数字或者英文会保留),且不再取用后面出现的中文
|
||||
self._last_token_type = "cnname"
|
||||
if not self.cn_name:
|
||||
|
||||
+39
-3
@@ -1,6 +1,42 @@
|
||||
from lxml import etree
|
||||
|
||||
from app.domain.string import StringUtils
|
||||
from app.foundation.dom import DomUtils
|
||||
from app.foundation.url import split_netloc
|
||||
|
||||
|
||||
_SPECIAL_SITE_DOMAINS = (
|
||||
"u2.dmhy.org",
|
||||
"pt.ecust.pp.ua",
|
||||
"pt.gtkpw.xyz",
|
||||
"pt.gtk.pw",
|
||||
)
|
||||
|
||||
|
||||
def urls_match(first: str, second: str) -> bool:
|
||||
"""判断两个地址是否指向忽略 www 前缀后的同一站点。"""
|
||||
if not first or not second:
|
||||
return False
|
||||
if first.startswith("http"):
|
||||
_scheme, first = split_netloc(first)
|
||||
if second.startswith("http"):
|
||||
_scheme, second = split_netloc(second)
|
||||
return first.replace("www.", "") == second.replace("www.", "")
|
||||
|
||||
|
||||
def extract_domain(url: str) -> str:
|
||||
"""按 MoviePilot 站点规则提取用于匹配的注册域名。"""
|
||||
if not url:
|
||||
return ""
|
||||
for domain in _SPECIAL_SITE_DOMAINS:
|
||||
if domain in url:
|
||||
return domain
|
||||
_scheme, netloc = split_netloc(url)
|
||||
if not netloc:
|
||||
return ""
|
||||
labels = netloc.split(".")
|
||||
if len(labels) > 3:
|
||||
return netloc
|
||||
return ".".join(labels[-2:])
|
||||
|
||||
|
||||
class SiteUtils:
|
||||
@@ -16,7 +52,7 @@ class SiteUtils:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
if not DomUtils.has_child_elements(html):
|
||||
return False
|
||||
# 存在明显的密码输入框,说明未登录
|
||||
if html.xpath("//input[@type='password']"):
|
||||
@@ -49,7 +85,7 @@ class SiteUtils:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
if not DomUtils.has_child_elements(html):
|
||||
return False
|
||||
# 站点签到支持的识别XPATH
|
||||
xpaths = [
|
||||
|
||||
@@ -1,903 +0,0 @@
|
||||
import bisect
|
||||
import datetime
|
||||
import hashlib
|
||||
import random
|
||||
import re
|
||||
from typing import Union, Tuple, Optional, Any, List, Generator
|
||||
from urllib import parse
|
||||
|
||||
import cn2an
|
||||
import dateparser
|
||||
import dateutil.parser
|
||||
|
||||
from app.foundation.version import compare_version as compare_versions
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
_special_domains = [
|
||||
'u2.dmhy.org',
|
||||
'pt.ecust.pp.ua',
|
||||
'pt.gtkpw.xyz',
|
||||
'pt.gtk.pw'
|
||||
]
|
||||
|
||||
_max_media_title_words = 10
|
||||
_min_media_title_length = 2
|
||||
_non_media_title_pattern = re.compile(r"^#|^请[问帮你]|[??]$|^继续$")
|
||||
_chat_intent_pattern = re.compile(r"帮我|请问|怎么|如何|为什么|可以|能否|推荐|介绍|谢谢|想看|找一下|搜一下")
|
||||
_media_feature_pattern = re.compile(
|
||||
r"第\s*[0-9一二三四五六七八九十百零]+\s*[季集]|S\d{1,2}(?:E\d{1,4})?|E\d{1,4}|(?:19|20)\d{2}",
|
||||
re.IGNORECASE
|
||||
)
|
||||
_media_separator_pattern = re.compile(r"[\s\-_.::·'\"()\[\]【】]+")
|
||||
_media_sentence_punctuation_pattern = re.compile(r"[,。!?!?,;;]")
|
||||
_media_title_char_pattern = re.compile(r"[\u4e00-\u9fffA-Za-z]")
|
||||
|
||||
|
||||
class StringUtils:
|
||||
"""提供媒体命名场景需要的字符串解析和格式化能力。"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def num_filesize(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)
|
||||
text = text.replace(",", "").replace(" ", "").upper()
|
||||
size = re.sub(r"[KMGTPI]*B?", "", text, flags=re.IGNORECASE)
|
||||
try:
|
||||
size = float(size)
|
||||
except ValueError:
|
||||
return 0
|
||||
if text.find("PB") != -1 or text.find("PIB") != -1:
|
||||
size *= 1024 ** 5
|
||||
elif text.find("TB") != -1 or text.find("TIB") != -1:
|
||||
size *= 1024 ** 4
|
||||
elif text.find("GB") != -1 or text.find("GIB") != -1:
|
||||
size *= 1024 ** 3
|
||||
elif text.find("MB") != -1 or text.find("MIB") != -1:
|
||||
size *= 1024 ** 2
|
||||
elif text.find("KB") != -1 or text.find("KIB") != -1:
|
||||
size *= 1024
|
||||
return round(size)
|
||||
|
||||
@staticmethod
|
||||
def str_timelong(time_sec: Union[str, int, float]) -> str:
|
||||
"""
|
||||
将数字转换为时间描述
|
||||
"""
|
||||
if not isinstance(time_sec, int) or not isinstance(time_sec, float):
|
||||
try:
|
||||
time_sec = float(time_sec)
|
||||
except ValueError:
|
||||
return ""
|
||||
d = [(0, '秒'), (60 - 1, '分'), (3600 - 1, '小时'), (86400 - 1, '天')]
|
||||
s = [x[0] for x in d]
|
||||
index = bisect.bisect_left(s, time_sec) - 1
|
||||
if index == -1:
|
||||
return str(time_sec)
|
||||
else:
|
||||
b, u = d[index]
|
||||
return str(round(time_sec / (b + 1))) + u
|
||||
|
||||
@staticmethod
|
||||
def str_secends(time_sec: Union[str, int, float]) -> str:
|
||||
"""
|
||||
将秒转为时分秒字符串
|
||||
"""
|
||||
hours = time_sec // 3600
|
||||
remainder_seconds = time_sec % 3600
|
||||
minutes = remainder_seconds // 60
|
||||
seconds = remainder_seconds % 60
|
||||
|
||||
time: str = str(int(seconds)) + '秒'
|
||||
if minutes:
|
||||
time = str(int(minutes)) + '分' + time
|
||||
if hours:
|
||||
time = str(int(hours)) + '时' + time
|
||||
return time
|
||||
|
||||
@staticmethod
|
||||
def is_chinese(word: Union[str, list]) -> bool:
|
||||
"""
|
||||
判断是否含有中文
|
||||
"""
|
||||
if not word:
|
||||
return False
|
||||
if isinstance(word, list):
|
||||
word = " ".join(word)
|
||||
chn = re.compile(r'[\u4e00-\u9fff]')
|
||||
if chn.search(word):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_japanese(word: str) -> bool:
|
||||
"""
|
||||
判断是否含有日文
|
||||
"""
|
||||
jap = re.compile(r'[\u3040-\u309F\u30A0-\u30FF]')
|
||||
if jap.search(word):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_korean(word: str) -> bool:
|
||||
"""
|
||||
判断是否包含韩文
|
||||
"""
|
||||
kor = re.compile(r'[\uAC00-\uD7FF]')
|
||||
if kor.search(word):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_all_chinese(word: str) -> bool:
|
||||
"""
|
||||
判断是否全是中文
|
||||
"""
|
||||
for ch in word:
|
||||
if ch == ' ':
|
||||
continue
|
||||
if '\u4e00' <= ch <= '\u9fff':
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_english_word(word: str) -> bool:
|
||||
"""
|
||||
判断是否为英文单词,有空格时返回False
|
||||
"""
|
||||
return word.encode().isalpha()
|
||||
|
||||
@staticmethod
|
||||
def str_int(text: str) -> int:
|
||||
"""
|
||||
web字符串转int
|
||||
:param text:
|
||||
:return:
|
||||
"""
|
||||
if text:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
return int(text.replace(',', ''))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def str_float(text: str) -> float:
|
||||
"""
|
||||
web字符串转float
|
||||
:param text:
|
||||
:return:
|
||||
"""
|
||||
if text:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
try:
|
||||
text = text.replace(',', '')
|
||||
if text:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def clear(text: Union[list, str], replace_word: str = "",
|
||||
allow_space: bool = False) -> Union[list, str]:
|
||||
"""
|
||||
忽略特殊字符
|
||||
"""
|
||||
# 需要忽略的特殊字符
|
||||
CONVERT_EMPTY_CHARS = r"[、.。,,·::;;!!??'’\"“”()()\[\]【】「」\-—―\+\|\\_/&#~~]"
|
||||
if not text:
|
||||
return text
|
||||
if not isinstance(text, list):
|
||||
text = re.sub(r"[\u200B-\u200D\uFEFF]",
|
||||
"",
|
||||
re.sub(r"%s" % CONVERT_EMPTY_CHARS, replace_word, text),
|
||||
flags=re.IGNORECASE)
|
||||
if not allow_space:
|
||||
return re.sub(r"\s+", "", text)
|
||||
else:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
else:
|
||||
return [StringUtils.clear(x) for x in text]
|
||||
|
||||
@staticmethod
|
||||
def clear_upper(text: Optional[str]) -> str:
|
||||
"""
|
||||
去除特殊字符,同时大写
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
return StringUtils.clear(text).upper().strip()
|
||||
|
||||
@staticmethod
|
||||
def str_filesize(size: Union[str, float, int], pre: int = 2) -> str:
|
||||
"""
|
||||
将字节计算为文件大小描述(带单位的格式化后返回)
|
||||
"""
|
||||
if size is None:
|
||||
return ""
|
||||
size = re.sub(r"\s|B|iB", "", str(size), re.I)
|
||||
if size.replace(".", "").isdigit():
|
||||
try:
|
||||
size = float(size)
|
||||
d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]
|
||||
s = [x[0] for x in d]
|
||||
index = bisect.bisect_left(s, size) - 1 # noqa
|
||||
if index == -1:
|
||||
return str(size) + "B"
|
||||
else:
|
||||
b, u = d[index]
|
||||
return str(round(size / (b + 1), pre)) + u
|
||||
except ValueError:
|
||||
return ""
|
||||
if re.findall(r"[KMGTP]", size, re.I):
|
||||
return size
|
||||
else:
|
||||
return size + "B"
|
||||
|
||||
@staticmethod
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""
|
||||
将字节转换为人类可读格式
|
||||
"""
|
||||
if not size_bytes or size_bytes == 0:
|
||||
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]}"
|
||||
|
||||
@staticmethod
|
||||
def url_equal(url1: str, url2: str) -> bool:
|
||||
"""
|
||||
比较两个地址是否为同一个网站
|
||||
"""
|
||||
if not url1 or not url2:
|
||||
return False
|
||||
if url1.startswith("http"):
|
||||
url1 = parse.urlparse(url1).netloc
|
||||
if url2.startswith("http"):
|
||||
url2 = parse.urlparse(url2).netloc
|
||||
if url1.replace("www.", "") == url2.replace("www.", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_url_netloc(url: str) -> Tuple[str, str]:
|
||||
"""
|
||||
获取URL的协议和域名部分
|
||||
"""
|
||||
if not url:
|
||||
return "", ""
|
||||
if not url.startswith("http"):
|
||||
return "http", url
|
||||
addr = parse.urlparse(url)
|
||||
return addr.scheme, addr.netloc
|
||||
|
||||
@staticmethod
|
||||
def get_url_domain(url: str) -> str:
|
||||
"""
|
||||
获取URL的域名部分,只保留最后两级
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
for domain in _special_domains:
|
||||
if domain in url:
|
||||
return domain
|
||||
_, netloc = StringUtils.get_url_netloc(url)
|
||||
if netloc:
|
||||
locs = netloc.split(".")
|
||||
if len(locs) > 3:
|
||||
return netloc
|
||||
return ".".join(locs[-2:])
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_url_sld(url: str) -> str:
|
||||
"""
|
||||
获取URL的二级域名部分,不含端口,若为IP则返回IP
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
_, netloc = StringUtils.get_url_netloc(url)
|
||||
if not netloc:
|
||||
return ""
|
||||
netloc = netloc.split(":")[0].split(".")
|
||||
if len(netloc) >= 2:
|
||||
return netloc[-2]
|
||||
return netloc[0]
|
||||
|
||||
@staticmethod
|
||||
def get_url_host(url: str) -> str:
|
||||
"""
|
||||
获取URL的一级域名
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
_, netloc = StringUtils.get_url_netloc(url)
|
||||
if not netloc:
|
||||
return ""
|
||||
return netloc.split(".")[-2]
|
||||
|
||||
@staticmethod
|
||||
def get_base_url(url: str) -> str:
|
||||
"""
|
||||
获取URL根地址
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
scheme, netloc = StringUtils.get_url_netloc(url)
|
||||
return f"{scheme}://{netloc}"
|
||||
|
||||
@staticmethod
|
||||
def clear_file_name(name: str) -> Optional[str]:
|
||||
"""移除文件名中不允许使用的字符。"""
|
||||
if not name:
|
||||
return None
|
||||
return re.sub(r"[*?\\/\"<>~|]", "", name, flags=re.IGNORECASE).replace(":", ":")
|
||||
|
||||
@staticmethod
|
||||
def generate_random_str(randomlength: int = 16) -> str:
|
||||
"""
|
||||
生成一个指定长度的随机字符串
|
||||
"""
|
||||
random_str = ''
|
||||
base_str = 'ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789'
|
||||
length = len(base_str) - 1
|
||||
for i in range(randomlength):
|
||||
random_str += base_str[random.randint(0, length)]
|
||||
return random_str
|
||||
|
||||
@staticmethod
|
||||
def get_time(date: Any) -> Optional[datetime.datetime]:
|
||||
"""将常见日期表达解析为 datetime,无法解析时返回 None。"""
|
||||
try:
|
||||
return dateutil.parser.parse(date)
|
||||
except dateutil.parser.ParserError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def unify_datetime_str(datetime_str: str) -> str:
|
||||
"""
|
||||
日期时间格式化 统一转成 2020-10-14 07:48:04 这种格式
|
||||
# 场景1: 带有时区的日期字符串 eg: Sat, 15 Oct 2022 14:02:54 +0800
|
||||
# 场景2: 中间带T的日期字符串 eg: 2020-10-14T07:48:04
|
||||
# 场景3: 中间带T的日期字符串 eg: 2020-10-14T07:48:04.208
|
||||
# 场景4: 日期字符串以GMT结尾 eg: Fri, 14 Oct 2022 07:48:04 GMT
|
||||
# 场景5: 日期字符串以UTC结尾 eg: Fri, 14 Oct 2022 07:48:04 UTC
|
||||
# 场景6: 日期字符串以Z结尾 eg: Fri, 14 Oct 2022 07:48:04Z
|
||||
# 场景7: 日期字符串为相对时间 eg: 1 month, 2 days ago
|
||||
:param datetime_str:
|
||||
:return:
|
||||
"""
|
||||
# 传入的参数如果是None 或者空字符串 直接返回
|
||||
if not datetime_str:
|
||||
return datetime_str
|
||||
|
||||
try:
|
||||
return dateparser.parse(datetime_str).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return datetime_str
|
||||
|
||||
@staticmethod
|
||||
def format_timestamp(timestamp: str, date_format: str = '%Y-%m-%d %H:%M:%S') -> str:
|
||||
"""
|
||||
时间戳转日期
|
||||
:param timestamp:
|
||||
:param date_format:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(timestamp, str) and not timestamp.isdigit():
|
||||
return timestamp
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(int(timestamp)).strftime(date_format)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return timestamp
|
||||
|
||||
@staticmethod
|
||||
def str_to_timestamp(date_str: str) -> float:
|
||||
"""
|
||||
日期转时间戳
|
||||
:param date_str:
|
||||
:return:
|
||||
"""
|
||||
if not date_str:
|
||||
return 0
|
||||
try:
|
||||
return dateparser.parse(date_str).timestamp()
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def to_bool(text: str, default_val: bool = False) -> bool:
|
||||
"""
|
||||
字符串转bool
|
||||
:param text: 要转换的值
|
||||
:param default_val: 默认值
|
||||
:return:
|
||||
"""
|
||||
if isinstance(text, str) and not text:
|
||||
return default_val
|
||||
if isinstance(text, bool):
|
||||
return text
|
||||
if isinstance(text, int) or isinstance(text, float):
|
||||
return True if text > 0 else False
|
||||
if isinstance(text, str) and text.lower() in ['y', 'true', '1', 'yes', 'on']:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def str_from_cookiejar(cj: dict) -> str:
|
||||
"""
|
||||
将cookiejar转换为字符串
|
||||
:param cj:
|
||||
:return:
|
||||
"""
|
||||
return '; '.join(['='.join(item) for item in cj.items()])
|
||||
|
||||
@staticmethod
|
||||
def get_idlist(content: str, dicts: List[dict]):
|
||||
"""
|
||||
从字符串中提取id列表
|
||||
:param content: 字符串
|
||||
:param dicts: 字典
|
||||
:return:
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
id_list = []
|
||||
content_list = content.split()
|
||||
for dic in dicts:
|
||||
if dic.get('name') in content_list and dic.get('id') not in id_list:
|
||||
id_list.append(dic.get('id'))
|
||||
content = content.replace(dic.get('name'), '')
|
||||
return id_list, re.sub(r'\s+', ' ', content).strip()
|
||||
|
||||
@staticmethod
|
||||
def md5_hash(data: Any) -> str:
|
||||
"""
|
||||
MD5 HASH
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
return hashlib.md5(str(data).encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def str_timehours(minutes: int) -> str:
|
||||
"""
|
||||
将分钟转换成小时和分钟
|
||||
:param minutes:
|
||||
:return:
|
||||
"""
|
||||
if not minutes:
|
||||
return ""
|
||||
hours = minutes // 60
|
||||
minutes = minutes % 60
|
||||
if hours:
|
||||
return "%s小时%s分" % (hours, minutes)
|
||||
else:
|
||||
return "%s分钟" % minutes
|
||||
|
||||
@staticmethod
|
||||
def str_amount(amount: object, curr="$") -> str:
|
||||
"""
|
||||
格式化显示金额
|
||||
"""
|
||||
if not amount:
|
||||
return "0"
|
||||
return curr + format(amount, ",")
|
||||
|
||||
@staticmethod
|
||||
def count_words(text: str) -> int:
|
||||
"""
|
||||
计算字符串中包含的单词或汉字的数量,需要兼容中英文混合的情况
|
||||
:param text: 要计算的字符串
|
||||
:return: 字符串中包含的词数量
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
# 使用正则表达式匹配汉字和英文单词
|
||||
chinese_pattern = '[\u4e00-\u9fa5]'
|
||||
english_pattern = '[a-zA-Z]+'
|
||||
|
||||
# 匹配汉字和英文单词
|
||||
chinese_matches = re.findall(chinese_pattern, text)
|
||||
english_matches = re.findall(english_pattern, text)
|
||||
|
||||
# 过滤掉空格和数字
|
||||
chinese_words = [word for word in chinese_matches if word.isalpha()]
|
||||
english_words = [word for word in english_matches if word.isalpha()]
|
||||
|
||||
# 计算汉字和英文单词的数量
|
||||
chinese_count = len(chinese_words)
|
||||
english_count = len(english_words)
|
||||
|
||||
return chinese_count + english_count
|
||||
|
||||
@staticmethod
|
||||
def is_media_title_like(text: str) -> bool:
|
||||
"""
|
||||
判断文本是否像影视剧名称
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
if not text:
|
||||
return False
|
||||
if _non_media_title_pattern.search(text) \
|
||||
or StringUtils.count_words(text) > _max_media_title_words:
|
||||
return False
|
||||
if "://" in text or text.startswith("magnet:?"):
|
||||
return False
|
||||
if _chat_intent_pattern.search(text):
|
||||
return False
|
||||
if _media_sentence_punctuation_pattern.search(text):
|
||||
return False
|
||||
|
||||
# 先移除季/集/年份等媒体特征,再移除分隔符,只保留核心名称用于最终判定
|
||||
candidate = _media_feature_pattern.sub("", text)
|
||||
candidate = _media_separator_pattern.sub("", candidate)
|
||||
return len(candidate) >= _min_media_title_length and _media_title_char_pattern.search(candidate) is not None
|
||||
|
||||
@staticmethod
|
||||
def split_text(text: str, max_length: int) -> Generator:
|
||||
"""
|
||||
把文本拆分为固定字节长度的数组,优先按换行拆分,避免单词内拆分
|
||||
"""
|
||||
if not text:
|
||||
yield ''
|
||||
# 分行
|
||||
lines = re.split('\n', text)
|
||||
buf = ''
|
||||
for line in lines:
|
||||
if len(line.encode('utf-8')) > max_length:
|
||||
# 超长行继续拆分
|
||||
blank = ""
|
||||
if re.match(r'^[A-Za-z0-9.\s]+', line):
|
||||
# 英文行按空格拆分
|
||||
parts = line.split()
|
||||
blank = " "
|
||||
else:
|
||||
# 中文行按字符拆分
|
||||
parts = line
|
||||
part = ''
|
||||
for p in parts:
|
||||
if len((part + p).encode('utf-8')) > max_length:
|
||||
# 超长则Yield
|
||||
yield (buf + part).strip()
|
||||
buf = ''
|
||||
part = f"{blank}{p}"
|
||||
else:
|
||||
part = f"{part}{blank}{p}"
|
||||
if part:
|
||||
# 将最后的部分追加到buf
|
||||
buf += part
|
||||
else:
|
||||
if len((buf + "\n" + line).encode('utf-8')) > max_length:
|
||||
# buf超长则Yield
|
||||
yield buf.strip()
|
||||
buf = line
|
||||
else:
|
||||
# 短行直接追加到buf
|
||||
if buf:
|
||||
buf = f"{buf}\n{line}"
|
||||
else:
|
||||
buf = line
|
||||
if buf:
|
||||
# 处理文本末尾剩余部分
|
||||
yield buf.strip()
|
||||
|
||||
@staticmethod
|
||||
def get_keyword(content: str) \
|
||||
-> Tuple[Optional[MediaType], Optional[str], Optional[int], Optional[int], Optional[str], Optional[str]]:
|
||||
"""
|
||||
从搜索关键字中拆分中年份、季、集、类型
|
||||
"""
|
||||
if not content:
|
||||
return None, None, None, None, None, None
|
||||
|
||||
# 去掉查询中的电影或电视剧关键字
|
||||
mtype = MediaType.TV if re.search(r'^(电视剧|动漫|\s+电视剧|\s+动漫)', content) else None
|
||||
content = re.sub(r'^(电影|电视剧|动漫|\s+电影|\s+电视剧|\s+动漫)', '', content).strip()
|
||||
|
||||
# 稍微切一下剧集吧
|
||||
season_num = None
|
||||
episode_num = None
|
||||
season_re = re.search(r'第\s*([0-9一二三四五六七八九十]+)\s*季', content, re.IGNORECASE)
|
||||
if season_re:
|
||||
mtype = MediaType.TV
|
||||
season_num = int(cn2an.cn2an(season_re.group(1), mode='smart'))
|
||||
|
||||
episode_re = re.search(r'第\s*([0-9一二三四五六七八九十百零]+)\s*集', content, re.IGNORECASE)
|
||||
if episode_re:
|
||||
mtype = MediaType.TV
|
||||
episode_num = int(cn2an.cn2an(episode_re.group(1), mode='smart'))
|
||||
if episode_num and not season_num:
|
||||
season_num = 1
|
||||
|
||||
year_re = re.search(r'[\s(]+(\d{4})[\s)]*', content)
|
||||
year = year_re.group(1) if year_re else None
|
||||
|
||||
key_word = re.sub(
|
||||
r'第\s*[0-9一二三四五六七八九十]+\s*季|第\s*[0-9一二三四五六七八九十百零]+\s*集|[\s(]+(\d{4})[\s)]*', '',
|
||||
content, flags=re.IGNORECASE).strip()
|
||||
key_word = re.sub(r'\s+', ' ', key_word) if key_word else year
|
||||
|
||||
return mtype, key_word, season_num, episode_num, year, content
|
||||
|
||||
@staticmethod
|
||||
def str_title(s: Optional[str]) -> str:
|
||||
"""
|
||||
大写首字母兼容None
|
||||
"""
|
||||
return s.title() if s else s
|
||||
|
||||
@staticmethod
|
||||
def escape_markdown(content: str) -> str:
|
||||
"""
|
||||
Escapes Markdown characters in a string of Markdown.
|
||||
|
||||
Credits to: simonsmh
|
||||
|
||||
:param content: The string of Markdown to escape.
|
||||
:type content: :obj:`str`
|
||||
|
||||
:return: The escaped string.
|
||||
:rtype: :obj:`str`
|
||||
"""
|
||||
|
||||
parses = re.sub(r"([_*\[\]()~`>#+\-=|.!{}])", r"\\\1", content)
|
||||
reparse = re.sub(r"\\\\([_*\[\]()~`>#+\-=|.!{}])", r"\1", parses)
|
||||
return reparse
|
||||
|
||||
@staticmethod
|
||||
def get_domain_address(address: str, prefix: bool = True) -> Tuple[Optional[str], Optional[int]]:
|
||||
"""
|
||||
从地址中获取域名和端口号
|
||||
:param address: 地址
|
||||
:param prefix:返回域名是否要包含协议前缀
|
||||
"""
|
||||
if not address:
|
||||
return None, None
|
||||
# 去掉末尾的/
|
||||
address = address.rstrip("/")
|
||||
if prefix and not address.startswith("http"):
|
||||
# 如果需要包含协议前缀,但地址不包含协议前缀,则添加
|
||||
address = "http://" + address
|
||||
elif not prefix and address.startswith("http"):
|
||||
# 如果不需要包含协议前缀,但地址包含协议前缀,则去掉
|
||||
address = address.split("://")[-1]
|
||||
# 拆分域名和端口号
|
||||
parts = address.split(":")
|
||||
if len(parts) > 3:
|
||||
# 处理不希望包含多个冒号的情况(除了协议后的冒号)
|
||||
return None, None
|
||||
elif 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
|
||||
|
||||
@staticmethod
|
||||
def str_series(array: List[int]) -> str:
|
||||
"""
|
||||
将季集列表转化为字符串简写
|
||||
"""
|
||||
|
||||
# 确保数组按照升序排列
|
||||
array.sort()
|
||||
|
||||
result = []
|
||||
start = array[0]
|
||||
end = array[0]
|
||||
|
||||
for i in range(1, len(array)):
|
||||
if array[i] == end + 1:
|
||||
end = array[i]
|
||||
else:
|
||||
if start == end:
|
||||
result.append(str(start))
|
||||
else:
|
||||
result.append(f"{start}-{end}")
|
||||
start = array[i]
|
||||
end = array[i]
|
||||
|
||||
# 处理最后一个序列
|
||||
if start == end:
|
||||
result.append(str(start))
|
||||
else:
|
||||
result.append(f"{start}-{end}")
|
||||
|
||||
return ",".join(result)
|
||||
|
||||
@staticmethod
|
||||
def format_ep(nums: List[int]) -> str:
|
||||
"""
|
||||
将剧集列表格式化为连续区间
|
||||
"""
|
||||
if not nums:
|
||||
return ""
|
||||
if len(nums) == 1:
|
||||
return f"E{nums[0]:02d}"
|
||||
# 将数组升序排序
|
||||
nums.sort()
|
||||
formatted_ranges = []
|
||||
start = nums[0]
|
||||
end = nums[0]
|
||||
|
||||
for i in range(1, len(nums)):
|
||||
if nums[i] == end + 1:
|
||||
end = nums[i]
|
||||
else:
|
||||
if start == end:
|
||||
formatted_ranges.append(f"E{start:02d}")
|
||||
else:
|
||||
formatted_ranges.append(f"E{start:02d}-E{end:02d}")
|
||||
start = end = nums[i]
|
||||
|
||||
if start == end:
|
||||
formatted_ranges.append(f"E{start:02d}")
|
||||
else:
|
||||
formatted_ranges.append(f"E{start:02d}-E{end:02d}")
|
||||
|
||||
formatted_string = "、".join(formatted_ranges)
|
||||
return formatted_string
|
||||
|
||||
@staticmethod
|
||||
def is_number(text: str) -> bool:
|
||||
"""
|
||||
判断字符是否为可以转换为整数或者浮点数
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
float(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def find_common_prefix(str1: str, str2: str) -> str:
|
||||
"""返回两个字符串从首字符开始的公共前缀。"""
|
||||
if not str1 or not str2:
|
||||
return ''
|
||||
common_prefix = []
|
||||
min_len = min(len(str1), len(str2))
|
||||
|
||||
for i in range(min_len):
|
||||
if str1[i] == str2[i]:
|
||||
common_prefix.append(str1[i])
|
||||
else:
|
||||
break
|
||||
|
||||
return ''.join(common_prefix)
|
||||
|
||||
@staticmethod
|
||||
def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \
|
||||
-> Tuple[Optional[bool], str | Exception] | Optional[bool]:
|
||||
"""兼容旧 StringUtils API,并转交基础版本比较能力。"""
|
||||
return compare_versions(v1, compare_type, v2, verbose)
|
||||
|
||||
@staticmethod
|
||||
def diff_time_str(time_str: str):
|
||||
"""
|
||||
输入YYYY-MM-DD HH24:MI:SS 格式的时间字符串,返回距离现在的剩余时间:xx天xx小时xx分钟
|
||||
"""
|
||||
if not time_str:
|
||||
return ''
|
||||
try:
|
||||
time_obj = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
return time_str
|
||||
now = datetime.datetime.now()
|
||||
diff = time_obj - now
|
||||
diff_seconds = diff.seconds
|
||||
diff_days = diff.days
|
||||
diff_hours = diff_seconds // 3600
|
||||
diff_minutes = (diff_seconds % 3600) // 60
|
||||
if diff_days > 0:
|
||||
return f'{diff_days}天{diff_hours}小时{diff_minutes}分钟'
|
||||
elif diff_hours > 0:
|
||||
return f'{diff_hours}小时{diff_minutes}分钟'
|
||||
elif diff_minutes > 0:
|
||||
return f'{diff_minutes}分钟'
|
||||
else:
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def safe_strip(value) -> Optional[str]:
|
||||
"""
|
||||
去除字符串两端的空白字符
|
||||
:return: 如果输入值不是 None,返回去除空白字符后的字符串,否则返回 None
|
||||
"""
|
||||
return value.strip() if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def is_valid_html_element(elem) -> bool:
|
||||
"""
|
||||
检查elem是否为有效的HTML元素。元素必须为非None并且具有非零长度。
|
||||
|
||||
:param elem: 要检查的HTML元素
|
||||
:return: 如果elem有效(非None且长度大于0),返回True;否则返回False
|
||||
"""
|
||||
return elem is not None and len(elem) > 0
|
||||
|
||||
@staticmethod
|
||||
def is_link(text: str) -> bool:
|
||||
"""
|
||||
检查文件是否为链接地址,支持各类协议
|
||||
:param text: 要检查的文本
|
||||
:return: 如果URL有效,返回True;否则返回False
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
# 检查是否以http、https、ftp等协议开头
|
||||
if re.match(r'^(http|https|ftp|ftps|sftp|ws|wss)://', text):
|
||||
return True
|
||||
# 检查是否为IP地址或域名
|
||||
if re.match(r'^[a-zA-Z0-9.-]+(\.[a-zA-Z]{2,})?$', text):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_magnet_link(content: Union[str, bytes]) -> bool:
|
||||
"""
|
||||
判断内容是否为磁力链接
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
if isinstance(content, str) and content.startswith("magnet:"):
|
||||
return True
|
||||
if isinstance(content, bytes) and content.startswith(b"magnet:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def natural_sort_key(text: str) -> List[Union[int, str]]:
|
||||
"""
|
||||
自然排序
|
||||
将字符串拆分为数字和非数字部分,数字部分转换为整数,非数字部分转换为小写字母
|
||||
:param text: 要处理的字符串
|
||||
:return 用于排序的数字和字符串列表
|
||||
"""
|
||||
if text is None:
|
||||
return []
|
||||
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
|
||||
return [int(part) if part.isdigit() else part.lower() for part in re.split(r'(\d+)', text)]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""媒体标题候选判断和搜索关键字解析规则。"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cn2an
|
||||
|
||||
from app.foundation.text import count_words
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
_MAX_TITLE_WORDS = 10
|
||||
_MIN_TITLE_LENGTH = 2
|
||||
_NON_TITLE_PATTERN = re.compile(r"^#|^请[问帮你]|[??]$|^继续$")
|
||||
_CHAT_INTENT_PATTERN = re.compile(r"帮我|请问|怎么|如何|为什么|可以|能否|推荐|介绍|谢谢|想看|找一下|搜一下")
|
||||
_MEDIA_FEATURE_PATTERN = re.compile(
|
||||
r"第\s*[0-9一二三四五六七八九十百零]+\s*[季集]|S\d{1,2}(?:E\d{1,4})?|E\d{1,4}|(?:19|20)\d{2}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MEDIA_SEPARATOR_PATTERN = re.compile(r"[\s\-_.::·'\"()\[\]【】]+")
|
||||
_SENTENCE_PUNCTUATION_PATTERN = re.compile(r"[,。!?!?,;;]")
|
||||
_TITLE_CHARACTER_PATTERN = re.compile(r"[\u4e00-\u9fffA-Za-z]")
|
||||
|
||||
|
||||
def is_media_title_like(value: str) -> bool:
|
||||
"""判断短文本是否具备影视标题特征而不是对话或链接。"""
|
||||
if not value:
|
||||
return False
|
||||
normalized = re.sub(r"\s+", " ", value).strip()
|
||||
if not normalized:
|
||||
return False
|
||||
if _NON_TITLE_PATTERN.search(normalized) or count_words(normalized) > _MAX_TITLE_WORDS:
|
||||
return False
|
||||
if "://" in normalized or normalized.startswith("magnet:?"):
|
||||
return False
|
||||
if _CHAT_INTENT_PATTERN.search(normalized):
|
||||
return False
|
||||
if _SENTENCE_PUNCTUATION_PATTERN.search(normalized):
|
||||
return False
|
||||
|
||||
candidate = _MEDIA_FEATURE_PATTERN.sub("", normalized)
|
||||
candidate = _MEDIA_SEPARATOR_PATTERN.sub("", candidate)
|
||||
return (
|
||||
len(candidate) >= _MIN_TITLE_LENGTH
|
||||
and _TITLE_CHARACTER_PATTERN.search(candidate) is not None
|
||||
)
|
||||
|
||||
|
||||
def parse_search_keyword(
|
||||
content: str,
|
||||
) -> Tuple[Optional[MediaType], Optional[str], Optional[int], Optional[int], Optional[str], Optional[str]]:
|
||||
"""从搜索文本中提取媒体类型、标题、季、集和年份。"""
|
||||
if not content:
|
||||
return None, None, None, None, None, None
|
||||
|
||||
media_type = MediaType.TV if re.search(r"^(电视剧|动漫|\s+电视剧|\s+动漫)", content) else None
|
||||
content = re.sub(r"^(电影|电视剧|动漫|\s+电影|\s+电视剧|\s+动漫)", "", content).strip()
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
season_match = re.search(r"第\s*([0-9一二三四五六七八九十]+)\s*季", content, re.IGNORECASE)
|
||||
if season_match:
|
||||
media_type = MediaType.TV
|
||||
season = int(cn2an.cn2an(season_match.group(1), mode="smart"))
|
||||
|
||||
episode_match = re.search(
|
||||
r"第\s*([0-9一二三四五六七八九十百零]+)\s*集",
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if episode_match:
|
||||
media_type = MediaType.TV
|
||||
episode = int(cn2an.cn2an(episode_match.group(1), mode="smart"))
|
||||
if episode and not season:
|
||||
season = 1
|
||||
|
||||
year_match = re.search(r"[\s(]+(\d{4})[\s)]*", content)
|
||||
year = year_match.group(1) if year_match else None
|
||||
keyword = re.sub(
|
||||
r"第\s*[0-9一二三四五六七八九十]+\s*季|"
|
||||
r"第\s*[0-9一二三四五六七八九十百零]+\s*集|"
|
||||
r"[\s(]+(\d{4})[\s)]*",
|
||||
"",
|
||||
content,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
keyword = re.sub(r"\s+", " ", keyword) if keyword else year
|
||||
return media_type, keyword, season, episode, year, content
|
||||
@@ -0,0 +1,14 @@
|
||||
"""种子链接内容的纯领域判断规则。"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
|
||||
def is_magnet_link(content: Union[str, bytes]) -> bool:
|
||||
"""判断字符串或字节内容是否为磁力链接。"""
|
||||
if not content:
|
||||
return False
|
||||
if isinstance(content, str):
|
||||
return content.startswith("magnet:")
|
||||
if isinstance(content, bytes):
|
||||
return content.startswith(b"magnet:")
|
||||
return False
|
||||
Reference in New Issue
Block a user