initialˆ

This commit is contained in:
2025-09-30 12:54:29 +08:00
commit acdf544b08
117 changed files with 20260 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

18
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="Mvc" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="Mvc" options="-parameters" />
</option>
</component>
</project>

18
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="reijm@100.113.74.91" uuid="eb3a679b-6a9b-48f5-975c-a92120bb0128">
<driver-ref>mongo.4</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
<jdbc-url>mongodb://100.113.74.91:27017/reijm</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/Mvc/src/main/java" charset="UTF-8" />
</component>
</project>

35
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="aliyun" />
<option name="name" value="Aliyun Maven" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jitpack.io" />
<option name="name" value="jitpack.io" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="spring-milestones" />
<option name="name" value="Spring Milestones" />
<option name="url" value="https://repo.spring.io/milestone" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

14
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/Mvc/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/Reijm.iml" filepath="$PROJECT_DIR$/Reijm.iml" />
</modules>
</component>
</project>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Mvc" vcs="Git" />
</component>
</project>

11
Reijm.iml Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="GENERAL_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/Mvc" isTestSource="false" />
</content>
<orderEntry type="jdk" jdkName="17" jdkType="JavaSDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

14
jm/.idea/jm.iml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.9 (jm)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@@ -0,0 +1,289 @@
from fastapi import FastAPI, HTTPException
from jmcomic import *
from typing import List, Union
import redis
from datetime import timedelta
from functools import wraps
import json
def cache_result(expire_time: timedelta):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 生成缓存键
cache_key = f"{func.__name__}:{json.dumps(kwargs)}"
# 尝试从缓存中获取数据
cached_data = redis_client.get(cache_key)
if cached_data:
return json.loads(cached_data)
# 如果缓存中没有数据,则调用函数获取数据
result = func(*args, **kwargs)
# 将结果存入缓存
redis_client.setex(cache_key, int(expire_time.total_seconds()), json.dumps(result))
return result
return wrapper
return decorator
# 配置Redis连接
redis_client = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True)
app = FastAPI()
# 初始化客户端
option = JmOption.default()
client = option.new_jm_client()
@app.post("/login/")
def login(username: str, password: str):
try:
client.login(username, password)
return {"message": "Login successful"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
from datetime import timedelta
# 设置缓存时间为3天
cache_time = timedelta(days=3)
@app.get("/search/")
@cache_result(cache_time)
def search_site(search_query: str, page: int = 1):
try:
page = client.search_site(search_query=search_query, page=page)
results = [{"album_id": album_id, "title": title} for album_id, title in page]
return results
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/album/{album_id}/")
@cache_result(cache_time)
def get_album_details(album_id: int):
try:
page = client.search_site(search_query=str(album_id))
album = page.single_album
# 存储所有图片的URL
image_urls = []
nums = []
# 遍历每个章节
for photo in album:
# 章节实体类
photo_detail = client.get_photo_detail(photo.photo_id, False)
# 遍历每个图片
for image in photo_detail:
# 图片实体类
image_urls.append(image.img_url)
nums.append(JmImageTool.get_num_by_url(image.scramble_id, image.img_url))
return {
"album_id": album.album_id,
"scramble_id": album.scramble_id,
"name": album.name,
"page_count": album.page_count,
"pub_date": album.pub_date,
"update_date": album.update_date,
"likes": album.likes,
"views": album.views,
"comment_count": album.comment_count,
"works": album.works,
"actors": album.actors,
"authors": album.authors,
"tags": album.tags,
"related_list": album.related_list,
"episode_list": album.episode_list,
"image_urls": image_urls,
"nums": nums
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/album/{album_id}/chapters/")
@cache_result(cache_time)
def get_album_chapters_paginated(album_id: int, page: int = 1, per_page: int = 5):
"""
分页获取专辑章节列表
:param album_id: 专辑ID
:param page: 页码从1开始
:param per_page: 每页章节数
"""
try:
page_result = client.search_site(search_query=str(album_id))
album = page_result.single_album
# 计算分页信息
total_chapters = len(album.photos)
total_pages = (total_chapters + per_page - 1) // per_page # 向上取整
if page < 1 or page > total_pages:
raise HTTPException(status_code=404, detail="Page out of range")
# 计算当前页的章节范围
start_index = (page - 1) * per_page
end_index = min(start_index + per_page, total_chapters)
# 获取当前页的章节信息
chapters = []
for i in range(start_index, end_index):
photo = album.photos[i]
chapters.append({
"chapter_index": i,
"chapter_id": photo.photo_id,
"title": photo.name,
"page_count": photo.page_count,
"pub_date": photo.pub_date
})
return {
"album_id": album.album_id,
"album_name": album.name,
"current_page": page,
"per_page": per_page,
"total_chapters": total_chapters,
"total_pages": total_pages,
"chapters": chapters
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/fast/{album_id}/")
# @cache_result(cache_time)
def get_album_details(album_id: int):
try:
page = client.search_site(search_query=str(album_id))
album = page.single_album
# 存储所有图片的URL
image_urls = []
nums = []
# 遍历每个章节
cut = 0
cut2 = 0
for photo in album:
if cut == 1:
break
cut = cut + 1
# 章节实体类
photo_detail = client.get_photo_detail(photo.photo_id, False)
# 遍历每个图片
for image in photo_detail:
# 图片实体类
if cut2 == 1:
break
cut2 = cut2 + 1
image_urls.append(image.img_url)
nums.append(JmImageTool.get_num_by_url(image.scramble_id, image.img_url))
return {
"album_id": album.album_id,
"scramble_id": album.scramble_id,
"name": album.name,
"page_count": album.page_count,
"pub_date": album.pub_date,
"update_date": album.update_date,
"likes": album.likes,
"views": album.views,
"comment_count": album.comment_count,
"works": album.works,
"actors": album.actors,
"authors": album.authors,
"tags": album.tags,
"related_list": album.related_list,
"episode_list": album.episode_list,
"image_urls": image_urls,
"nums": nums
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/favorites/")
@cache_result(cache_time)
def get_favorites(username: str):
try:
favorites = []
for page in client.favorite_folder_gen(username=username):
for aid, atitle in page:
favorites.append({"album_id": aid, "title": atitle})
return favorites
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/categories/")
@cache_result(cache_time)
def get_categories(page: int = 1, time: str = JmMagicConstants.TIME_ALL, category: str = JmMagicConstants.CATEGORY_ALL,
order_by: str = JmMagicConstants.ORDER_BY_LATEST):
try:
page = client.categories_filter(page=page, time=time, category=category, order_by=order_by)
results = [{"album_id": aid, "title": atitle} for aid, atitle in page]
return results
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/rankings/year")
@cache_result(cache_time)
def get_rankings_year(page: int):
try:
op = JmOption.default()
cl = op.new_jm_client()
page: JmCategoryPage = cl.categories_filter(
page=page,
time=JmMagicConstants.TIME_ALL,
category=JmMagicConstants.CATEGORY_ALL,
order_by=JmMagicConstants.ORDER_BY_LATEST,
)
results = [{"album_id": aid, "title": atitle} for aid, atitle in page]
return results
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/rankings/mouth")
@cache_result(cache_time)
def get_rankings_mouth(page: int):
try:
op = JmOption.default()
cl = op.new_jm_client()
page2: JmCategoryPage = cl.categories_filter(
page=page,
time=JmMagicConstants.TIME_MONTH,
category=JmMagicConstants.CATEGORY_ALL,
order_by=JmMagicConstants.ORDER_BY_LATEST,
)
results = [{"album_id": aid, "title": atitle} for aid, atitle in page2]
return results
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/rankings/week")
@cache_result(cache_time)
def get_rankings_week(page: int):
try:
op = JmOption.default()
cl = op.new_jm_client()
page2: JmCategoryPage = cl.categories_filter(
page=page,
time=JmMagicConstants.TIME_WEEK,
category=JmMagicConstants.CATEGORY_ALL,
order_by=JmMagicConstants.ORDER_BY_LATEST,
)
results = [{"album_id": aid, "title": atitle} for aid, atitle in page2]
return results
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# 启动API
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8982)

29
jm/src/jmcomic/__init__.py Executable file
View File

@@ -0,0 +1,29 @@
# 模块依赖关系如下:
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
__version__ = '2.5.35'
from .api import *
from .jm_plugin import *
# 下面进行注册组件(客户端、插件)
gb = dict(filter(lambda pair: isinstance(pair[1], type), globals().items()))
def register_jmcomic_component(variables: Dict[str, Any], method, valid_interface: type):
for v in variables.values():
if v != valid_interface and issubclass(v, valid_interface):
method(v)
# 注册客户端
register_jmcomic_component(gb,
JmModuleConfig.register_client,
JmcomicClient,
)
# 注册插件
register_jmcomic_component(gb,
JmModuleConfig.register_plugin,
JmOptionPlugin,
)

131
jm/src/jmcomic/api.py Executable file
View File

@@ -0,0 +1,131 @@
from .jm_downloader import *
__DOWNLOAD_API_RET = Tuple[JmAlbumDetail, JmDownloader]
def download_batch(download_api,
jm_id_iter: Union[Iterable, Generator],
option=None,
downloader=None,
) -> Set[__DOWNLOAD_API_RET]:
"""
批量下载 album / photo
一个album/photo对应一个线程对应一个option
:param download_api: 下载api
:param jm_id_iter: jmid (album_id, photo_id) 的迭代器
:param option: 下载选项所有的jmid共用一个option
:param downloader: 下载器类
"""
from common import multi_thread_launcher
if option is None:
option = JmModuleConfig.option_class().default()
result = set()
def callback(*ret):
result.add(ret)
multi_thread_launcher(
iter_objs=set(
JmcomicText.parse_to_jm_id(jmid)
for jmid in jm_id_iter
),
apply_each_obj_func=lambda aid: download_api(aid,
option,
downloader,
callback=callback,
),
wait_finish=True
)
return result
def download_album(jm_album_id,
option=None,
downloader=None,
callback=None,
check_exception=True,
) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]:
"""
下载一个本子album包含其所有的章节photo
当jm_album_id不是str或int时视为批量下载相当于调用 download_batch(download_album, jm_album_id, option, downloader)
:param jm_album_id: 本子的禁漫车号
:param option: 下载选项
:param downloader: 下载器类
:param callback: 返回值回调函数,可以拿到 album 和 downloader
:param check_exception: 是否检查异常, 如果为True会检查downloader是否有下载异常并上抛PartialDownloadFailedException
:return: 对于的本子实体类下载器如果是上述的批量情况返回值为download_batch的返回值
"""
if not isinstance(jm_album_id, (str, int)):
return download_batch(download_album, jm_album_id, option, downloader)
with new_downloader(option, downloader) as dler:
album = dler.download_album(jm_album_id)
if callback is not None:
callback(album, dler)
if check_exception:
dler.raise_if_has_exception()
return album, dler
def download_photo(jm_photo_id,
option=None,
downloader=None,
callback=None,
check_exception=True,
):
"""
下载一个章节photo参数同 download_album
"""
if not isinstance(jm_photo_id, (str, int)):
return download_batch(download_photo, jm_photo_id, option)
with new_downloader(option, downloader) as dler:
photo = dler.download_photo(jm_photo_id)
if callback is not None:
callback(photo, dler)
if check_exception:
dler.raise_if_has_exception()
return photo, dler
def new_downloader(option=None, downloader=None) -> JmDownloader:
if option is None:
option = JmModuleConfig.option_class().default()
if downloader is None:
downloader = JmModuleConfig.downloader_class()
return downloader(option)
def create_option_by_file(filepath):
return JmModuleConfig.option_class().from_file(filepath)
def create_option_by_env(env_name='JM_OPTION_PATH'):
from .cl import get_env
filepath = get_env(env_name, None)
ExceptionTool.require_true(filepath is not None,
f'未配置环境变量: {env_name}请配置为option的文件路径')
return create_option_by_file(filepath)
def create_option_by_str(text: str, mode=None):
if mode is None:
mode = PackerUtil.mode_yml
data = PackerUtil.unpack_by_str(text, mode)[0]
return JmModuleConfig.option_class().construct(data)
create_option = create_option_by_file

121
jm/src/jmcomic/cl.py Executable file
View File

@@ -0,0 +1,121 @@
"""
command-line usage
for example, download album 123 456, photo 333:
$ jmcomic 123 456 p333 --option="D:/option.yml"
"""
import os.path
from typing import List, Optional
def get_env(name, default):
import os
value = os.getenv(name, None)
if value is None or value == '':
return default
return value
class JmcomicUI:
def __init__(self) -> None:
self.option_path: Optional[str] = None
self.raw_id_list: List[str] = []
self.album_id_list: List[str] = []
self.photo_id_list: List[str] = []
def parse_arg(self):
import argparse
parser = argparse.ArgumentParser(prog='python -m jmcomic', description='JMComic Command Line Downloader')
parser.add_argument(
'id_list',
nargs='*',
help='input all album/photo ids that you want to download, separating them by spaces. '
'Need add a "p" prefix to indicate a photo id, such as `123 456 p333`.',
default=[],
)
parser.add_argument(
'--option',
help='path to the option file, you can also specify it by env `JM_OPTION_PATH`',
type=str,
default=get_env('JM_OPTION_PATH', ''),
)
args = parser.parse_args()
option = args.option
if len(option) == 0 or option == "''":
self.option_path = None
else:
self.option_path = os.path.abspath(option)
self.raw_id_list = args.id_list
self.parse_raw_id()
def parse_raw_id(self):
def parse(text):
from .jm_toolkit import JmcomicText
try:
return JmcomicText.parse_to_jm_id(text)
except Exception as e:
print(e.args[0])
exit(1)
for raw_id in self.raw_id_list:
if raw_id.startswith('p'):
self.photo_id_list.append(parse(raw_id[1:]))
elif raw_id.startswith('a'):
self.album_id_list.append(parse(raw_id[1:]))
else:
self.album_id_list.append(parse(raw_id))
def main(self):
self.parse_arg()
from .api import jm_log
jm_log('command_line',
f'start downloading...\n'
f'- using option: [{self.option_path or "default"}]\n'
f'to be downloaded: \n'
f'- album: {self.album_id_list}\n'
f'- photo: {self.photo_id_list}')
from .api import create_option, JmOption
if self.option_path is not None:
option = create_option(self.option_path)
else:
option = JmOption.default()
self.run(option)
def run(self, option):
from .api import download_album, download_photo
from common import MultiTaskLauncher
if len(self.album_id_list) == 0:
download_photo(self.photo_id_list, option)
elif len(self.photo_id_list) == 0:
download_album(self.album_id_list, option)
else:
# 同时下载album和photo
launcher = MultiTaskLauncher()
launcher.create_task(
target=download_album,
args=(self.album_id_list, option)
)
launcher.create_task(
target=download_photo,
args=(self.photo_id_list, option)
)
launcher.wait_finish()
def main():
JmcomicUI().main()

1176
jm/src/jmcomic/jm_client_impl.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,606 @@
from .jm_toolkit import *
"""
Response Entity
"""
class JmResp:
def __init__(self, resp):
ExceptionTool.require_true(not isinstance(resp, JmResp), f'重复包装: {resp}')
self.resp = resp
@property
def is_success(self) -> bool:
return self.http_code == 200 and len(self.content) != 0
@property
def is_not_success(self) -> bool:
return not self.is_success
@property
def content(self):
return self.resp.content
@property
def http_code(self):
return self.resp.status_code
@property
def text(self) -> str:
return self.resp.text
@property
def url(self) -> str:
return self.resp.url
def require_success(self):
if self.is_not_success:
ExceptionTool.raises_resp(self.error_msg(), self)
def error_msg(self):
return self.text
class JmImageResp(JmResp):
def error_msg(self):
msg = f'禁漫图片获取失败: [{self.url}]'
if self.http_code != 200:
msg += f'http状态码={self.http_code}'
if len(self.content) == 0:
msg += f',响应数据为空'
return msg
def transfer_to(self,
path,
scramble_id,
decode_image=True,
img_url=None,
):
img_url = img_url or self.url
if decode_image is False or scramble_id is None:
# 不解密图片,直接保存文件
JmImageTool.save_resp_img(
self,
path,
need_convert=suffix_not_equal(img_url[:img_url.find("?")], path),
)
else:
# 解密图片并保存文件
JmImageTool.decode_and_save(
JmImageTool.get_num_by_url(scramble_id, img_url),
JmImageTool.open_image(self.content),
path,
)
class JmJsonResp(JmResp):
@field_cache()
def json(self) -> Dict:
try:
return self.resp.json()
except Exception as e:
ExceptionTool.raises_resp(f'json解析失败: {e}', self, JsonResolveFailException)
def model(self) -> AdvancedDict:
return AdvancedDict(self.json())
class JmApiResp(JmJsonResp):
def __init__(self, resp, ts: str):
super().__init__(resp)
self.ts = ts
@property
def is_success(self) -> bool:
return super().is_success and self.json()['code'] == 200
@property
@field_cache()
def decoded_data(self) -> str:
return JmCryptoTool.decode_resp_data(self.encoded_data, self.ts)
@property
def encoded_data(self) -> str:
return self.json()['data']
@property
def res_data(self) -> Any:
self.require_success()
from json import loads
return loads(self.decoded_data)
@property
def model_data(self) -> AdvancedDict:
self.require_success()
return AdvancedDict(self.res_data)
# album-comment
class JmAlbumCommentResp(JmJsonResp):
def is_success(self) -> bool:
return super().is_success and self.json()['err'] is False
"""
Client Interface
"""
class JmDetailClient:
def get_album_detail(self, album_id) -> JmAlbumDetail:
raise NotImplementedError
def get_photo_detail(self,
photo_id,
fetch_album=True,
fetch_scramble_id=True,
) -> JmPhotoDetail:
raise NotImplementedError
def check_photo(self, photo: JmPhotoDetail):
"""
photo来源有两种:
1. album[?]
2. client.get_photo_detail(?)
其中,只有[2]是可以包含下载图片的url信息的。
本方法会检查photo是不是[1]
如果是[1],通过请求获取[2]然后把2中的一些重要字段更新到1中
:param photo: 被检查的JmPhotoDetail对象
"""
# 检查 from_album
if photo.from_album is None:
photo.from_album = self.get_album_detail(photo.album_id)
# 检查 page_arr 和 data_original_domain
if photo.page_arr is None or photo.data_original_domain is None:
new = self.get_photo_detail(photo.photo_id, False)
new.from_album = photo.from_album
photo.__dict__.update(new.__dict__)
class JmUserClient:
def login(self,
username: str,
password: str,
):
"""
1. 返回response响应对象
2. 保证当前client拥有登录cookies
"""
raise NotImplementedError
def album_comment(self,
video_id,
comment,
originator='',
status='true',
comment_id=None,
**kwargs,
) -> JmAlbumCommentResp:
"""
评论漫画/评论回复
:param video_id: album_id/photo_id
:param comment: 评论内容
:param status: 是否 "有劇透"
:param comment_id: 被回复评论的id
:param originator:
:returns: JmAcResp 对象
"""
raise NotImplementedError
def favorite_folder(self,
page=1,
order_by=JmMagicConstants.ORDER_BY_LATEST,
folder_id='0',
username='',
) -> JmFavoritePage:
"""
获取收藏了的漫画,文件夹默认是全部
:param folder_id: 文件夹id
:param page: 分页
:param order_by: 排序
:param username: 用户名
"""
raise NotImplementedError
def add_favorite_album(self,
album_id,
folder_id='0',
):
"""
把漫画加入收藏夹
"""
raise NotImplementedError
class JmImageClient:
# -- 下载图片 --
def download_image(self,
img_url: str,
img_save_path: str,
scramble_id: Optional[int] = None,
decode_image=True,
):
"""
下载JM的图片
:param img_url: 图片url
:param img_save_path: 图片保存位置
:param scramble_id: 图片所在photo的scramble_id
:param decode_image: 要保存的是解密后的图还是原图
"""
# 请求图片
resp = self.get_jm_image(img_url)
resp.require_success()
return self.save_image_resp(decode_image, img_save_path, img_url, resp, scramble_id)
# noinspection PyMethodMayBeStatic
def save_image_resp(self, decode_image, img_save_path, img_url, resp, scramble_id):
resp.transfer_to(img_save_path, scramble_id, decode_image, img_url)
def download_by_image_detail(self,
image: JmImageDetail,
img_save_path,
decode_image=True,
):
return self.download_image(
image.download_url,
img_save_path,
int(image.scramble_id),
decode_image=decode_image,
)
def get_jm_image(self, img_url) -> JmImageResp:
raise NotImplementedError
@classmethod
def img_is_not_need_to_decode(cls, data_original: str, _resp) -> bool:
# https://cdn-msp2.18comic.vip/media/photos/498976/00027.gif?v=1697541064
query_params_index = data_original.find('?')
if query_params_index != -1:
data_original = data_original[:query_params_index]
# https://cdn-msp2.18comic.vip/media/photos/498976/00027.gif
return data_original.endswith('.gif')
class JmSearchAlbumClient:
"""
搜尋的最佳姿勢?
【包含搜尋】
搜尋[+]全彩[空格][+]人妻,僅顯示全彩且是人妻的本本
範例:+全彩 +人妻
【排除搜尋】
搜尋全彩[空格][-]人妻,顯示全彩並排除人妻的本本
範例:全彩 -人妻
【我都要搜尋】
搜尋全彩[空格]人妻,會顯示所有包含全彩及人妻的本本
範例:全彩 人妻
"""
def search(self,
search_query: str,
page: int,
main_tag: int,
order_by: str,
time: str,
category: str,
sub_category: Optional[str],
) -> JmSearchPage:
"""
搜索【成人A漫】
网页端与移动端的搜索有差别:
- 移动端不支持 category, sub_category参数网页端支持全部参数
"""
raise NotImplementedError
def search_site(self,
search_query: str,
page: int = 1,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
sub_category: Optional[str] = None,
):
"""
对应禁漫的站内搜索
"""
return self.search(search_query, page, 0, order_by, time, category, sub_category)
def search_work(self,
search_query: str,
page: int = 1,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
sub_category: Optional[str] = None,
):
"""
搜索album的作品 work
"""
return self.search(search_query, page, 1, order_by, time, category, sub_category)
def search_author(self,
search_query: str,
page: int = 1,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
sub_category: Optional[str] = None,
):
"""
搜索album的作者 author
"""
return self.search(search_query, page, 2, order_by, time, category, sub_category)
def search_tag(self,
search_query: str,
page: int = 1,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
sub_category: Optional[str] = None,
):
"""
搜索album的标签 tag
"""
return self.search(search_query, page, 3, order_by, time, category, sub_category)
def search_actor(self,
search_query: str,
page: int = 1,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
sub_category: Optional[str] = None,
):
"""
搜索album的登场角色 actor
"""
return self.search(search_query, page, 4, order_by, time, category, sub_category)
class JmCategoryClient:
"""
该接口可以看作是对全体禁漫本子的排行,热门排行的功能也派生于此
月排行 = 分类【时间=月,排序=观看】
周排行 = 分类【时间=周,排序=观看】
日排行 = 分类【时间=周,排序=观看】
"""
def categories_filter(self,
page: int,
time: str,
category: str,
order_by: str,
sub_category: Optional[str] = None,
) -> JmCategoryPage:
"""
分类
:param page: 页码
:param time: 时间范围,默认是全部时间
:param category: 类别,默认是最新,即显示最新的禁漫本子
:param sub_category: 副分类,仅网页端有这功能
:param order_by: 排序方式,默认是观看数
"""
raise NotImplementedError
def month_ranking(self,
page: int,
category: str = JmMagicConstants.CATEGORY_ALL,
):
"""
月排行 = 分类【时间=月,排序=观看】
"""
return self.categories_filter(page,
JmMagicConstants.TIME_MONTH,
category,
JmMagicConstants.ORDER_BY_VIEW,
)
def week_ranking(self,
page: int,
category: str = JmMagicConstants.CATEGORY_ALL,
):
"""
周排行 = 分类【时间=周,排序=观看】
"""
return self.categories_filter(page,
JmMagicConstants.TIME_WEEK,
category,
JmMagicConstants.ORDER_BY_VIEW,
)
def day_ranking(self,
page: int,
category: str = JmMagicConstants.CATEGORY_ALL,
):
"""
日排行 = 分类【时间=日,排序=观看】
"""
return self.categories_filter(page,
JmMagicConstants.TIME_TODAY,
category,
JmMagicConstants.ORDER_BY_VIEW,
)
# noinspection PyAbstractClass
class JmcomicClient(
JmImageClient,
JmDetailClient,
JmUserClient,
JmSearchAlbumClient,
JmCategoryClient,
Postman,
):
client_key: None
def get_domain_list(self) -> List[str]:
"""
获取当前client的域名配置
"""
raise NotImplementedError
def set_domain_list(self, domain_list: List[str]):
"""
设置当前client的域名配置
"""
raise NotImplementedError
def set_cache_dict(self, cache_dict: Optional[Dict]):
raise NotImplementedError
def get_cache_dict(self) -> Optional[Dict]:
raise NotImplementedError
def of_api_url(self, api_path, domain):
raise NotImplementedError
def get_html_domain(self):
return JmModuleConfig.get_html_domain(self.get_root_postman())
def get_html_domain_all(self):
return JmModuleConfig.get_html_domain_all(self.get_root_postman())
def get_html_domain_all_via_github(self):
return JmModuleConfig.get_html_domain_all_via_github(self.get_root_postman())
# noinspection PyMethodMayBeStatic
def do_page_iter(self, params: dict, page: int, get_page_method):
from math import inf
def update(value: Optional[Dict], page: int, page_content: JmPageContent):
if value is None:
return page + 1, page_content.page_count
ExceptionTool.require_true(isinstance(value, dict), 'require dict params')
# 根据外界传递的参数更新params和page
page = value.get('page', page)
params.update(value)
return page, inf
total = inf
while page <= total:
params['page'] = page
page_content = get_page_method(**params)
value = yield page_content
page, total = update(value, page, page_content)
def favorite_folder_gen(self,
page=1,
order_by=JmMagicConstants.ORDER_BY_LATEST,
folder_id='0',
username='',
) -> Generator[JmFavoritePage, Dict, None]:
"""
见 search_gen
"""
params = {
'order_by': order_by,
'folder_id': folder_id,
'username': username,
}
yield from self.do_page_iter(params, page, self.favorite_folder)
def search_gen(self,
search_query: str,
main_tag=0,
page: int = 1,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
sub_category: Optional[str] = None,
) -> Generator[JmSearchPage, Dict, None]:
"""
搜索结果的生成器,支持下面这种调用方式:
```
for page in self.search_gen('无修正'):
# 每次循环page为新页的结果
pass
```
同时支持外界send参数可以改变搜索的设定例如
```
gen = client.search_gen('MANA')
for i, page in enumerate(gen):
print(page.page_count)
page = gen.send({
'search_query': '+MANA +无修正',
'page': 1
})
print(page.page_count)
break
```
"""
params = {
'search_query': search_query,
'main_tag': main_tag,
'order_by': order_by,
'time': time,
'category': category,
'sub_category': sub_category,
}
yield from self.do_page_iter(params, page, self.search)
def categories_filter_gen(self,
page: int = 1,
time: str = JmMagicConstants.TIME_ALL,
category: str = JmMagicConstants.CATEGORY_ALL,
order_by: str = JmMagicConstants.ORDER_BY_LATEST,
sub_category: Optional[str] = None,
) -> Generator[JmCategoryPage, Dict, None]:
"""
见 search_gen
"""
params = {
'time': time,
'category': category,
'order_by': order_by,
'sub_category': sub_category,
}
yield from self.do_page_iter(params, page, self.categories_filter)
def is_given_type(self, ctype: Type['JmcomicClient']) -> bool:
"""
Client代理的此方法会被路由到内部client的方法
ClientProxy(AClient()).is_given_type(AClient) is True
但是: ClientProxy(AClient()).client_key != AClient.client_key
"""
if isinstance(self, ctype):
return True
if self.client_key == ctype.client_key:
return True
return False

497
jm/src/jmcomic/jm_config.py Executable file
View File

@@ -0,0 +1,497 @@
from common import time_stamp, field_cache, ProxyBuilder
def shuffled(lines):
from random import shuffle
from common import str_to_list
ls = str_to_list(lines)
shuffle(ls)
return ls
def default_jm_logging(topic: str, msg: str):
from common import format_ts, current_thread
print('[{}] [{}]:【{}{}'.format(format_ts(), current_thread().name, topic, msg))
# 禁漫常量
class JmMagicConstants:
# 搜索参数-排序
ORDER_BY_LATEST = 'mr'
ORDER_BY_VIEW = 'mv'
ORDER_BY_PICTURE = 'mp'
ORDER_BY_LIKE = 'tf'
ORDER_MONTH_RANKING = 'mv_m'
ORDER_WEEK_RANKING = 'mv_w'
ORDER_DAY_RANKING = 'mv_t'
# 搜索参数-时间段
TIME_TODAY = 't'
TIME_WEEK = 'w'
TIME_MONTH = 'm'
TIME_ALL = 'a'
# 分类参数API接口的category
CATEGORY_ALL = '0' # 全部
CATEGORY_DOUJIN = 'doujin' # 同人
CATEGORY_SINGLE = 'single' # 单本
CATEGORY_SHORT = 'short' # 短篇
CATEGORY_ANOTHER = 'another' # 其他
CATEGORY_HANMAN = 'hanman' # 韩漫
CATEGORY_MEIMAN = 'meiman' # 美漫
CATEGORY_DOUJIN_COSPLAY = 'doujin_cosplay' # cosplay
CATEGORY_3D = '3D' # 3D
CATEGORY_ENGLISH_SITE = 'english_site' # 英文站
# 副分类
SUB_CHINESE = 'chinese' # 汉化,通用副分类
SUB_JAPANESE = 'japanese' # 日语,通用副分类
# 其他类CATEGORY_ANOTHER的副分类
SUB_ANOTHER_OTHER = 'other' # 其他漫画
SUB_ANOTHER_3D = '3d' # 3D
SUB_ANOTHER_COSPLAY = 'cosplay' # cosplay
# 同人SUB_CHINESE的副分类
SUB_DOUJIN_CG = 'CG' # CG
SUB_DOUJIN_CHINESE = SUB_CHINESE
SUB_DOUJIN_JAPANESE = SUB_JAPANESE
# 短篇CATEGORY_SHORT的副分类
SUB_SHORT_CHINESE = SUB_CHINESE
SUB_SHORT_JAPANESE = SUB_JAPANESE
# 单本CATEGORY_SINGLE的副分类
SUB_SINGLE_CHINESE = SUB_CHINESE
SUB_SINGLE_JAPANESE = SUB_JAPANESE
SUB_SINGLE_YOUTH = 'youth'
# 图片分割参数
SCRAMBLE_220980 = 220980
SCRAMBLE_268850 = 268850
SCRAMBLE_421926 = 421926 # 2023-02-08后改了图片切割算法
# 移动端API密钥
APP_TOKEN_SECRET = '18comicAPP'
APP_TOKEN_SECRET_2 = '18comicAPPContent'
APP_DATA_SECRET = '185Hcomic3PAPP7R'
APP_VERSION = '1.7.9'
# 模块级别共用配置
class JmModuleConfig:
# 网站相关
PROT = "https://"
JM_REDIRECT_URL = f'{PROT}jm365.work/3YeBdF' # 永久網域,怕走失的小伙伴收藏起来
JM_PUB_URL = f'{PROT}jmcomic-fb.vip'
JM_CDN_IMAGE_URL_TEMPLATE = PROT + 'cdn-msp.{domain}/media/photos/{photo_id}/{index:05}{suffix}' # index 从1开始
JM_IMAGE_SUFFIX = ['.jpg', '.webp', '.png', '.gif']
# JM的异常网页内容
JM_ERROR_RESPONSE_TEXT = {
"Could not connect to mysql! Please check your database settings!": "禁漫服务器内部报错",
"Restricted Access!": "禁漫拒绝你所在ip地区的访问你可以选择: 换域名/换代理",
}
# JM的异常网页code
JM_ERROR_STATUS_CODE = {
403: 'ip地区禁止访问/爬虫被识别',
500: '500: 禁漫服务器内部异常可能是服务器过载可以切换ip或稍后重试',
520: '520: Web server is returning an unknown error (禁漫服务器内部报错)',
524: '524: The origin web server timed out responding to this request. (禁漫服务器处理超时)',
}
# 分页大小
PAGE_SIZE_SEARCH = 80
PAGE_SIZE_FAVORITE = 20
# 图片分隔相关
SCRAMBLE_CACHE = {}
# 当本子没有作者名字时,顶替作者名字
DEFAULT_AUTHOR = 'default_author'
# cookies目前只在移动端使用因为移动端请求接口须携带但不会校验cookies的内容。
APP_COOKIES = None
# 移动端图片域名
DOMAIN_IMAGE_LIST = shuffled('''
cdn-msp.jmapiproxy1.cc
cdn-msp.jmapiproxy2.cc
cdn-msp2.jmapiproxy2.cc
cdn-msp3.jmapiproxy2.cc
cdn-msp.jmapinodeudzn.net
cdn-msp3.jmapinodeudzn.net
''')
# 移动端API域名
DOMAIN_API_LIST = shuffled('''
www.cdnmhwscc.vip
www.cdnblackmyth.club
www.cdnmhws.cc
www.cdnuc.vip
''')
APP_HEADERS_TEMPLATE = {
'Accept-Encoding': 'gzip, deflate',
'user-agent': 'Mozilla/5.0 (Linux; Android 9; V1938CT Build/PQ3A.190705.11211812; wv) AppleWebKit/537.36 (KHTML, '
'like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36',
}
APP_HEADERS_IMAGE = {
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'X-Requested-With': 'com.jiaohua_browser',
'Referer': PROT + DOMAIN_API_LIST[0],
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
}
# 网页端headers
HTML_HEADERS_TEMPLATE = {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,'
'application/signed-exchange;v=b3;q=0.7',
'accept-language': 'zh-CN,zh;q=0.9',
'cache-control': 'no-cache',
'dnt': '1',
'pragma': 'no-cache',
'priority': 'u=0, i',
'referer': 'https://18comic.vip/',
'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'document',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'none',
'sec-fetch-user': '?1',
'upgrade-insecure-requests': '1',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 '
'Safari/537.36',
}
# 网页端域名配置
# 无需配置默认为None需要的时候会发起请求获得
# 使用优先级:
# 1. DOMAIN_HTML_LIST
# 2. [DOMAIN_HTML]
DOMAIN_HTML = None
DOMAIN_HTML_LIST = None
# 模块级别的可重写类配置
CLASS_DOWNLOADER = None
CLASS_OPTION = None
CLASS_ALBUM = None
CLASS_PHOTO = None
CLASS_IMAGE = None
# 客户端注册表
REGISTRY_CLIENT = {}
# 插件注册表
REGISTRY_PLUGIN = {}
# 异常监听器
# key: 异常类
# value: 函数,参数只有异常对象,无需返回值
# 这个异常类或者这个异常的子类的实例将要被raise前你的listener方法会被调用
REGISTRY_EXCEPTION_LISTENER = {}
# 执行log的函数
EXECUTOR_LOG = default_jm_logging
# 使用固定时间戳
FLAG_USE_FIX_TIMESTAMP = True
# 移动端Client初始化cookies
FLAG_API_CLIENT_REQUIRE_COOKIES = True
# log开关标记
FLAG_ENABLE_JM_LOG = True
# log时解码url
FLAG_DECODE_URL_WHEN_LOGGING = True
# 当内置的版本号落后时使用最新的禁漫app版本号
FLAG_USE_VERSION_NEWER_IF_BEHIND = True
# 关联dir_rule的自定义字段与对应的处理函数
# 例如:
# Amyname -> JmModuleConfig.AFIELD_ADVICE['myname'] = lambda album: "自定义名称"
AFIELD_ADVICE = dict()
PFIELD_ADVICE = dict()
# 当发生 oserror: [Errno 36] File name too long 时,
# 把文件名限制在指定个字符以内
VAR_FILE_NAME_LENGTH_LIMIT = 100
@classmethod
def downloader_class(cls):
if cls.CLASS_DOWNLOADER is not None:
return cls.CLASS_DOWNLOADER
from .jm_downloader import JmDownloader
return JmDownloader
@classmethod
def option_class(cls):
if cls.CLASS_OPTION is not None:
return cls.CLASS_OPTION
from .jm_option import JmOption
return JmOption
@classmethod
def album_class(cls):
if cls.CLASS_ALBUM is not None:
return cls.CLASS_ALBUM
from .jm_entity import JmAlbumDetail
return JmAlbumDetail
@classmethod
def photo_class(cls):
if cls.CLASS_PHOTO is not None:
return cls.CLASS_PHOTO
from .jm_entity import JmPhotoDetail
return JmPhotoDetail
@classmethod
def image_class(cls):
if cls.CLASS_IMAGE is not None:
return cls.CLASS_IMAGE
from .jm_entity import JmImageDetail
return JmImageDetail
@classmethod
def client_impl_class(cls, client_key: str):
clazz_dict = cls.REGISTRY_CLIENT
clazz = clazz_dict.get(client_key, None)
if clazz is None:
from .jm_toolkit import ExceptionTool
ExceptionTool.raises(f'not found client impl class for key: "{client_key}"')
return clazz
@classmethod
@field_cache("DOMAIN_HTML")
def get_html_domain(cls, postman=None):
"""
由于禁漫的域名经常变化,调用此方法可以获取一个当前可用的最新的域名 domain
并且设置把 domain 设置为禁漫模块的默认域名。
这样一来,配置文件也不用配置域名了,一切都在运行时动态获取。
"""
from .jm_toolkit import JmcomicText
return JmcomicText.parse_to_jm_domain(cls.get_html_url(postman))
@classmethod
def get_html_url(cls, postman=None):
"""
访问禁漫的永久网域,从而得到一个可用的禁漫网址
:returns: https://jm-comic2.cc
"""
postman = postman or cls.new_postman(session=True)
url = postman.with_redirect_catching().get(cls.JM_REDIRECT_URL)
cls.jm_log('module.html_url', f'获取禁漫网页URL: [{cls.JM_REDIRECT_URL}] → [{url}]')
return url
@classmethod
@field_cache("DOMAIN_HTML_LIST")
def get_html_domain_all(cls, postman=None):
"""
访问禁漫发布页,得到所有的禁漫网页域名
:returns: ['18comic.vip', ..., 'jm365.xyz/ZNPJam'], 最后一个是【APP軟件下載】
"""
postman = postman or cls.new_postman(session=True)
resp = postman.get(cls.JM_PUB_URL)
if resp.status_code != 200:
from .jm_toolkit import ExceptionTool
ExceptionTool.raises_resp(f'请求失败访问禁漫发布页获取所有域名HTTP状态码为: {resp.status_code}', resp)
from .jm_toolkit import JmcomicText
domain_list = JmcomicText.analyse_jm_pub_html(resp.text)
cls.jm_log('module.html_domain_all', f'获取禁漫网页全部域名: [{resp.url}] → {domain_list}')
return domain_list
@classmethod
def get_html_domain_all_via_github(cls,
postman=None,
template='https://jmcmomic.github.io/go/{}.html',
index_range=(300, 309)
):
"""
通过禁漫官方的github号的repo获取最新的禁漫域名
https://github.com/jmcmomic/jmcmomic.github.io
"""
postman = postman or cls.new_postman(headers={
'authority': 'github.com',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 '
'Safari/537.36'
})
domain_set = set()
def fetch_domain(url):
resp = postman.get(url, allow_redirects=False)
text = resp.text
from .jm_toolkit import JmcomicText
for domain in JmcomicText.analyse_jm_pub_html(text):
if domain.startswith('jm365'):
continue
domain_set.add(domain)
from common import multi_thread_launcher
multi_thread_launcher(
iter_objs=[template.format(i) for i in range(*index_range)],
apply_each_obj_func=fetch_domain,
)
return domain_set
@classmethod
def new_html_headers(cls, domain='18comic.vip'):
"""
网页端的headers
"""
headers = cls.HTML_HEADERS_TEMPLATE.copy()
headers.update({
'authority': domain,
'origin': f'https://{domain}',
'referer': f'https://{domain}',
})
return headers
@classmethod
@field_cache()
def get_fix_ts_token_tokenparam(cls):
ts = time_stamp()
from .jm_toolkit import JmCryptoTool
token, tokenparam = JmCryptoTool.token_and_tokenparam(ts)
return ts, token, tokenparam
# noinspection PyUnusedLocal
@classmethod
def jm_log(cls, topic: str, msg: str):
if cls.FLAG_ENABLE_JM_LOG is True:
cls.EXECUTOR_LOG(topic, msg)
@classmethod
def disable_jm_log(cls):
cls.FLAG_ENABLE_JM_LOG = False
@classmethod
def new_postman(cls, session=False, **kwargs):
kwargs.setdefault('impersonate', 'chrome110')
kwargs.setdefault('headers', JmModuleConfig.new_html_headers())
kwargs.setdefault('proxies', JmModuleConfig.DEFAULT_PROXIES)
from common import Postmans
if session is True:
return Postmans.new_session(**kwargs)
return Postmans.new_postman(**kwargs)
# option 相关的默认配置
# 一般情况下建议使用option配置文件来定制配置
# 而如果只想修改几个简单常用的配置也可以下方的DEFAULT_XXX属性
JM_OPTION_VER = '2.1'
DEFAULT_CLIENT_IMPL = 'api' # 默认Client实现类型为网页端
DEFAULT_CLIENT_CACHE = None # 默认关闭Client缓存。缓存的配置详见 CacheRegistry
DEFAULT_PROXIES = ProxyBuilder.system_proxy() # 默认使用系统代理
DEFAULT_OPTION_DICT: dict = {
'log': None,
'dir_rule': {'rule': 'Bd_Pname', 'base_dir': None},
'download': {
'cache': True,
'image': {'decode': True, 'suffix': None},
'threading': {
'image': 30,
'photo': None,
},
},
'client': {
'cache': None, # see CacheRegistry
'domain': [],
'postman': {
'type': 'cffi',
'meta_data': {
'impersonate': 'chrome110',
'headers': None,
'proxies': None,
}
},
'impl': None,
'retry_times': 5,
},
'plugins': {
# 如果插件抛出参数校验异常只log。全局配置可以被插件的局部配置覆盖
# 可选值ignore忽略log打印日志raise抛异常
'valid': 'log',
},
}
@classmethod
def option_default_dict(cls) -> dict:
"""
返回JmOption.default()的默认配置字典。
这样做是为了支持外界自行覆盖option默认配置字典
"""
from copy import deepcopy
option_dict = deepcopy(cls.DEFAULT_OPTION_DICT)
# log
if option_dict['log'] is None:
option_dict['log'] = cls.FLAG_ENABLE_JM_LOG
# dir_rule.base_dir
dir_rule = option_dict['dir_rule']
if dir_rule['base_dir'] is None:
import os
dir_rule['base_dir'] = os.getcwd()
# client cache
client = option_dict['client']
if client['cache'] is None:
client['cache'] = cls.DEFAULT_CLIENT_CACHE
# client impl
if client['impl'] is None:
client['impl'] = cls.DEFAULT_CLIENT_IMPL
# postman proxies
meta_data = client['postman']['meta_data']
if meta_data['proxies'] is None:
# use system proxy by default
meta_data['proxies'] = cls.DEFAULT_PROXIES
# threading photo
dt = option_dict['download']['threading']
if dt['photo'] is None:
import os
dt['photo'] = os.cpu_count()
return option_dict
@classmethod
def register_plugin(cls, plugin_class):
from .jm_toolkit import ExceptionTool
ExceptionTool.require_true(getattr(plugin_class, 'plugin_key', None) is not None,
f'未配置plugin_key, class: {plugin_class}')
cls.REGISTRY_PLUGIN[plugin_class.plugin_key] = plugin_class
@classmethod
def register_client(cls, client_class):
from .jm_toolkit import ExceptionTool
ExceptionTool.require_true(getattr(client_class, 'client_key', None) is not None,
f'未配置client_key, class: {client_class}')
cls.REGISTRY_CLIENT[client_class.client_key] = client_class
@classmethod
def register_exception_listener(cls, etype, listener):
cls.REGISTRY_EXCEPTION_LISTENER[etype] = listener
jm_log = JmModuleConfig.jm_log
disable_jm_log = JmModuleConfig.disable_jm_log

350
jm/src/jmcomic/jm_downloader.py Executable file
View File

@@ -0,0 +1,350 @@
from .jm_option import *
def catch_exception(func):
from functools import wraps
@wraps(func)
def wrapper(self, *args, **kwargs):
self: JmDownloader
try:
return func(self, *args, **kwargs)
except Exception as e:
detail: JmBaseEntity = args[0]
if detail.is_image():
detail: JmImageDetail
jm_log('image.failed', f'图片下载失败: [{detail.download_url}], 异常: [{e}]')
self.download_failed_image.append((detail, e))
elif detail.is_photo():
detail: JmPhotoDetail
jm_log('photo.failed', f'章节下载失败: [{detail.id}], 异常: [{e}]')
self.download_failed_photo.append((detail, e))
raise e
return wrapper
# noinspection PyMethodMayBeStatic
class DownloadCallback:
def before_album(self, album: JmAlbumDetail):
jm_log('album.before',
f'本子获取成功: [{album.id}], '
f'作者: [{album.author}], '
f'章节数: [{len(album)}], '
f'总页数: [{album.page_count}], '
f'标题: [{album.name}], '
f'关键词: {album.tags}'
)
def after_album(self, album: JmAlbumDetail):
jm_log('album.after', f'本子下载完成: [{album.id}]')
def before_photo(self, photo: JmPhotoDetail):
jm_log('photo.before',
f'开始下载章节: {photo.id} ({photo.album_id}[{photo.index}/{len(photo.from_album)}]), '
f'标题: [{photo.name}], '
f'图片数为[{len(photo)}]'
)
def after_photo(self, photo: JmPhotoDetail):
jm_log('photo.after',
f'章节下载完成: [{photo.id}] ({photo.album_id}[{photo.index}/{len(photo.from_album)}])')
def before_image(self, image: JmImageDetail, img_save_path):
if image.exists:
jm_log('image.before',
f'图片已存在: {image.tag} ← [{img_save_path}]'
)
else:
jm_log('image.before',
f'图片准备下载: {image.tag}, [{image.img_url}] → [{img_save_path}]'
)
def after_image(self, image: JmImageDetail, img_save_path):
jm_log('image.after',
f'图片下载完成: {image.tag}, [{image.img_url}] → [{img_save_path}]')
class JmDownloader(DownloadCallback):
"""
JmDownloader = JmOption + 调度逻辑
"""
def __init__(self, option: JmOption) -> None:
self.option = option
self.client = option.build_jm_client()
# 下载成功的记录dict
self.download_success_dict: Dict[JmAlbumDetail, Dict[JmPhotoDetail, List[Tuple[str, JmImageDetail]]]] = {}
# 下载失败的记录list
self.download_failed_image: List[Tuple[JmImageDetail, BaseException]] = []
self.download_failed_photo: List[Tuple[JmPhotoDetail, BaseException]] = []
def download_album(self, album_id):
album = self.client.get_album_detail(album_id)
self.download_by_album_detail(album)
return album
def download_by_album_detail(self, album: JmAlbumDetail):
self.before_album(album)
if album.skip:
return
self.execute_on_condition(
iter_objs=album,
apply=self.download_by_photo_detail,
count_batch=self.option.decide_photo_batch_count(album)
)
self.after_album(album)
def download_photo(self, photo_id):
photo = self.client.get_photo_detail(photo_id)
self.download_by_photo_detail(photo)
return photo
@catch_exception
def download_by_photo_detail(self, photo: JmPhotoDetail):
self.client.check_photo(photo)
self.before_photo(photo)
if photo.skip:
return
self.execute_on_condition(
iter_objs=photo,
apply=self.download_by_image_detail,
count_batch=self.option.decide_image_batch_count(photo)
)
self.after_photo(photo)
@catch_exception
def download_by_image_detail(self, image: JmImageDetail):
img_save_path = self.option.decide_image_filepath(image)
image.save_path = img_save_path
image.exists = file_exists(img_save_path)
self.before_image(image, img_save_path)
if image.skip:
return
# let option decide use_cache and decode_image
use_cache = self.option.decide_download_cache(image)
decode_image = self.option.decide_download_image_decode(image)
# skip download
if use_cache is True and image.exists:
return
self.client.download_by_image_detail(
image,
img_save_path,
decode_image=decode_image,
)
self.after_image(image, img_save_path)
def execute_on_condition(self,
iter_objs: DetailEntity,
apply: Callable,
count_batch: int,
):
"""
调度本子/章节的下载
"""
iter_objs = self.do_filter(iter_objs)
count_real = len(iter_objs)
if count_real == 0:
return
if count_batch >= count_real:
# 一个图/章节 对应 一个线程
multi_thread_launcher(
iter_objs=iter_objs,
apply_each_obj_func=apply,
)
else:
# 创建batch个线程的线程池
thread_pool_executor(
iter_objs=iter_objs,
apply_each_obj_func=apply,
max_workers=count_batch,
)
# noinspection PyMethodMayBeStatic
def do_filter(self, detail: DetailEntity):
"""
该方法可用于过滤本子/章节,默认不会做过滤。
例如:
只想下载 本子的最新一章,返回 [album[-1]]
只想下载 章节的前10张图片返回 [photo[:10]]
:param detail: 可能是本子或者章节,需要自行使用 isinstance / detail.is_xxx 判断
:returns: 只想要下载的 本子的章节 或 章节的图片
"""
return detail
@property
def all_success(self) -> bool:
"""
是否成功下载了全部图片
该属性需要等到downloader的全部download_xxx方法完成后才有意义。
注意如果使用了filter机制例如通过filter只下载3张图片那么all_success也会为False
"""
if self.has_download_failures:
return False
for album, photo_dict in self.download_success_dict.items():
if len(album) != len(photo_dict):
return False
for photo, image_list in photo_dict.items():
if len(photo) != len(image_list):
return False
return True
@property
def has_download_failures(self):
return len(self.download_failed_image) != 0 or len(self.download_failed_photo) != 0
# 下面是回调方法
def before_album(self, album: JmAlbumDetail):
super().before_album(album)
self.download_success_dict.setdefault(album, {})
self.option.call_all_plugin(
'before_album',
album=album,
downloader=self,
)
def after_album(self, album: JmAlbumDetail):
super().after_album(album)
self.option.call_all_plugin(
'after_album',
album=album,
downloader=self,
)
def before_photo(self, photo: JmPhotoDetail):
super().before_photo(photo)
self.download_success_dict.setdefault(photo.from_album, {})
self.download_success_dict[photo.from_album].setdefault(photo, [])
self.option.call_all_plugin(
'before_photo',
photo=photo,
downloader=self,
)
def after_photo(self, photo: JmPhotoDetail):
super().after_photo(photo)
self.option.call_all_plugin(
'after_photo',
photo=photo,
downloader=self,
)
def before_image(self, image: JmImageDetail, img_save_path):
super().before_image(image, img_save_path)
self.option.call_all_plugin(
'before_image',
image=image,
downloader=self,
)
def after_image(self, image: JmImageDetail, img_save_path):
super().after_image(image, img_save_path)
photo = image.from_photo
album = photo.from_album
self.download_success_dict.get(album).get(photo).append((img_save_path, image))
self.option.call_all_plugin(
'after_image',
image=image,
downloader=self,
)
def raise_if_has_exception(self):
if not self.has_download_failures:
return
msg_ls = ['部分下载失败', '', '']
if len(self.download_failed_photo) != 0:
msg_ls[1] = f'{len(self.download_failed_photo)}个章节下载失败: {self.download_failed_photo}'
if len(self.download_failed_image) != 0:
msg_ls[2] = f'{len(self.download_failed_image)}个图片下载失败: {self.download_failed_image}'
ExceptionTool.raises(
'\n'.join(msg_ls),
{'downloader': self},
PartialDownloadFailedException,
)
# 下面是对with语法的支持
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
jm_log('dler.exception',
f'{self.__class__.__name__} Exit with exception: {exc_type, exc_val}'
)
@classmethod
def use(cls, *args, **kwargs):
"""
让本类替换JmModuleConfig.CLASS_DOWNLOADER
"""
JmModuleConfig.CLASS_DOWNLOADER = cls
class DoNotDownloadImage(JmDownloader):
"""
不会下载任何图片的Downloader用作测试
"""
def download_by_image_detail(self, image: JmImageDetail):
# ensure make dir
self.option.decide_image_filepath(image)
class JustDownloadSpecificCountImage(JmDownloader):
"""
只下载特定数量图片的Downloader用作测试
"""
from threading import Lock
count_lock = Lock()
count = 0
@catch_exception
def download_by_image_detail(self, image: JmImageDetail):
# ensure make dir
self.option.decide_image_filepath(image)
if self.try_countdown():
return super().download_by_image_detail(image)
def try_countdown(self):
if self.count < 0:
return False
with self.count_lock:
if self.count < 0:
return False
self.count -= 1
return self.count >= 0
@classmethod
def use(cls, count):
cls.count = count
super().use()

680
jm/src/jmcomic/jm_entity.py Executable file
View File

@@ -0,0 +1,680 @@
from functools import lru_cache
from common import *
from .jm_config import *
class Downloadable:
def __init__(self):
self.save_path: str = ''
self.exists: bool = False
self.skip = False
class JmBaseEntity:
def to_file(self, filepath):
from common import PackerUtil
PackerUtil.pack(self, filepath)
@classmethod
def is_image(cls):
return False
@classmethod
def is_photo(cls):
return False
@classmethod
def is_album(cls):
return False
@classmethod
def is_page(cls):
return False
class IndexedEntity:
def getindex(self, index: int):
raise NotImplementedError
def __len__(self):
raise NotImplementedError
def __getitem__(self, item) -> Any:
if isinstance(item, slice):
start = item.start or 0
stop = item.stop or len(self)
step = item.step or 1
return [self.getindex(index) for index in range(start, stop, step)]
elif isinstance(item, int):
return self.getindex(item)
else:
raise TypeError(f"Invalid item type for {self.__class__}")
def __iter__(self):
for index in range(len(self)):
yield self.getindex(index)
class DetailEntity(JmBaseEntity, IndexedEntity):
@property
def id(self) -> str:
raise NotImplementedError
@property
def title(self) -> str:
return getattr(self, 'name')
@property
def author(self):
raise NotImplementedError
@property
def oname(self) -> str:
"""
oname = original name
示例:
title"喂我吃吧 老師! [欶瀾漢化組] [BLVEFO9] たべさせて、せんせい! (ブルーアーカイブ) [中國翻譯] [無修正]"
oname"喂我吃吧 老師!"
:return: 返回本子的原始名称
"""
from .jm_toolkit import JmcomicText
oname = JmcomicText.parse_orig_album_name(self.title)
if oname is not None:
return oname
jm_log('entity', f'无法提取出原album名字: {self.title}')
return self.title
@property
def authoroname(self):
"""
authoroname = author + oname
个人认为识别度比较高的本子名称,一眼看去就能获取到本子的关键信息
具体格式: '【author】oname'
示例:
Pname喂我吃吧 老師! [欶瀾漢化組] [BLVEFO9] たべさせて、せんせい! (ブルーアーカイブ) [中國翻譯] [無修正]
Pauthoroname【BLVEFO9】喂我吃吧 老師!
:return: 返回作者名+本子原始名称,格式为: '【author】oname'
"""
return f'{self.author}{self.oname}'
@property
def idoname(self):
"""
类似 authoroname
:return: '[id] oname'
"""
return f'[{self.id}] {self.oname}'
def __str__(self):
return f'''{self.__class__.__name__}({self.__alias__()}-{self.id}: "{self.title}")'''
__repr__ = __str__
@classmethod
def __alias__(cls):
# "JmAlbumDetail" -> "album" (本子)
# "JmPhotoDetail" -> "photo" (章节)
cls_name = cls.__name__
return cls_name[cls_name.index("m") + 1: cls_name.rfind("Detail")].lower()
@classmethod
def get_dirname(cls, detail: 'DetailEntity', ref: str) -> str:
"""
该方法被 DirDule 调用,用于生成特定层次的文件夹
通常调用方式如下:
Atitle -> ref = 'title' -> DetailEntity.get_dirname(album, 'title')
该方法需要返回 ref 对应的文件夹名,默认实现直接返回 getattr(detail, 'title')
用户可重写此方法,来实现自定义文件夹名
v2.4.5: 此方法支持优先从 JmModuleConfig.XFIELD_ADVICE 中获取自定义函数并调用返回结果
:param detail: 本子/章节 实例
:param ref: 字段名
:returns: 文件夹名
"""
advice_func = (JmModuleConfig.AFIELD_ADVICE
if isinstance(detail, JmAlbumDetail)
else JmModuleConfig.PFIELD_ADVICE
).get(ref, None)
if advice_func is not None:
return advice_func(detail)
return getattr(detail, ref)
class JmImageDetail(JmBaseEntity, Downloadable):
def __init__(self,
aid,
scramble_id,
img_url,
img_file_name,
img_file_suffix,
from_photo=None,
query_params=None,
index=-1,
):
super().__init__()
if scramble_id is None or (isinstance(scramble_id, str) and scramble_id == ''):
from .jm_toolkit import ExceptionTool
ExceptionTool.raises(f'图片的scramble_id不能为空')
self.aid: str = str(aid)
self.scramble_id: str = str(scramble_id)
self.img_url: str = img_url
self.img_file_name: str = img_file_name # without suffix
self.img_file_suffix: str = img_file_suffix
self.from_photo: Optional[JmPhotoDetail] = from_photo
self.query_params: Optional[str] = query_params
self.index = index # 从1开始
@property
def filename_without_suffix(self):
return self.img_file_name
@property
def filename(self):
return self.img_file_name + self.img_file_suffix
@property
def is_gif(self):
return self.img_file_suffix == '.gif'
@property
def download_url(self) -> str:
"""
图片的下载路径
与 self.img_url 的唯一不同是,在最后会带上 ?{self.query_params}
:returns: 图片的下载路径
"""
if self.query_params is None:
return self.img_url
return f'{self.img_url}?{self.query_params}'
@classmethod
def of(cls,
photo_id: str,
scramble_id: str,
data_original: str,
from_photo=None,
query_params=None,
index=-1,
) -> 'JmImageDetail':
"""
该方法用于创建 JmImageDetail 对象
"""
# /xxx.yyy
# ↑ ↑
# x y
x = data_original.rfind('/')
y = data_original.rfind('.')
return JmImageDetail(
aid=photo_id,
scramble_id=scramble_id,
img_url=data_original,
img_file_name=data_original[x + 1:y],
img_file_suffix=data_original[y:],
from_photo=from_photo,
query_params=query_params,
index=index,
)
@property
def tag(self) -> str:
"""
this tag is used to print pretty info when logging
"""
return f'{self.aid}/{self.img_file_name}{self.img_file_suffix} [{self.index}/{len(self.from_photo)}]'
@classmethod
def is_image(cls):
return True
def __str__(self):
return f'''{self.__class__.__name__}(image-[{self.download_url}])'''
__repr__ = __str__
class JmPhotoDetail(DetailEntity, Downloadable):
def __init__(self,
photo_id,
name,
series_id,
sort,
tags='',
scramble_id='',
page_arr=None,
data_original_domain=None,
data_original_0=None,
author=None,
from_album=None,
):
super().__init__()
self.photo_id: str = str(photo_id)
self.scramble_id: str = str(scramble_id)
self.name: str = str(name).strip()
self.sort: int = int(sort)
self._tags: str = tags
self._series_id: int = int(series_id)
self._author: Optional[str] = author
self.from_album: Optional[JmAlbumDetail] = from_album
self.index = self.album_index
# 下面的属性和图片url有关
if isinstance(page_arr, str):
import json
page_arr = json.loads(page_arr)
# page_arr存放了该photo的所有图片文件名 img_name
self.page_arr: List[str] = page_arr
# 图片的cdn域名
self.data_original_domain: Optional[str] = data_original_domain
# 第一张图的URL
self.data_original_0 = data_original_0
# 2023-07-14
# 禁漫的图片url加上了一个参数v如果没有带上这个参数v图片会返回空数据
# 参数v的特点
# 1. 值似乎是该photo的更新时间的时间戳因此所有图片都使用同一个值
# 2. 值目前在网页端只在photo页面的图片标签的data-original属性出现
# 这里的模拟思路是获取到第一个图片标签的data-original
# 取出其query参数 → self.data_original_query_params, 该值未来会传递给 JmImageDetail
# self.data_original_query_params = self.get_data_original_query_params(data_original_0)
self.data_original_query_params = None
@property
def is_single_album(self) -> bool:
return self._series_id == 0
@property
def tags(self) -> List[str]:
if self.from_album is not None:
return self.from_album.tags
tag_str = self._tags
if ',' in tag_str:
# html
return tag_str.split(',')
else:
# api
return tag_str.split()
@property
def indextitle(self):
return f'{self.album_index}{self.name}'
@property
def album_id(self) -> str:
return self.photo_id if self.is_single_album else str(self._series_id)
@property
def album_index(self) -> int:
"""
返回这个章节在本子中的序号从1开始
"""
# 如果是单章本子JM给的sort为2。
# 这里返回1比较符合语义定义
if self.is_single_album and self.sort == 2:
return 1
return self.sort
@property
def author(self) -> str:
# 优先使用 from_album
if self.from_album is not None:
return self.from_album.author
if self._author is not None and self._author != '':
return self._author.strip()
# 使用默认
return JmModuleConfig.DEFAULT_AUTHOR
def create_image_detail(self, index) -> JmImageDetail:
# 校验参数
length = len(self.page_arr)
if index >= length:
raise IndexError(f'image index out of range for photo-{self.photo_id}: {index} >= {length}')
data_original = self.get_img_data_original(self.page_arr[index])
return JmModuleConfig.image_class().of(
self.photo_id,
self.scramble_id,
data_original,
from_photo=self,
query_params=self.data_original_query_params,
index=index + 1,
)
def get_img_data_original(self, img_name: str) -> str:
"""
根据图片名,生成图片的完整请求路径 URL
例如img_name = 01111.webp
返回https://cdn-msp2.18comic.org/media/photos/147643/01111.webp
"""
domain = self.data_original_domain
from .jm_toolkit import ExceptionTool
ExceptionTool.require_true(domain is not None, f'图片域名为空: {domain}')
return f'{JmModuleConfig.PROT}{domain}/media/photos/{self.photo_id}/{img_name}'
# noinspection PyMethodMayBeStatic
def get_data_original_query_params(self, data_original_0: Optional[str]) -> str:
if data_original_0 is None:
return f'v={time_stamp()}'
index = data_original_0.rfind('?')
if index == -1:
return f'v={time_stamp()}'
return data_original_0[index + 1:]
@property
def id(self):
return self.photo_id
@lru_cache(None)
def getindex(self, index) -> JmImageDetail:
return self.create_image_detail(index)
def __getitem__(self, item) -> Union[JmImageDetail, List[JmImageDetail]]:
return super().__getitem__(item)
def __len__(self):
return len(self.page_arr)
def __iter__(self) -> Generator[JmImageDetail, None, None]:
return super().__iter__()
@classmethod
def is_photo(cls):
return True
class JmAlbumDetail(DetailEntity, Downloadable):
def __init__(self,
album_id,
scramble_id,
name,
episode_list,
page_count,
pub_date,
update_date,
likes,
views,
comment_count,
works,
actors,
authors,
tags,
related_list=None,
):
super().__init__()
self.album_id: str = str(album_id)
self.scramble_id: str = str(scramble_id)
self.name: str = str(name).strip()
self.page_count: int = int(page_count) # 总页数
self.pub_date: str = pub_date # 发布日期
self.update_date: str = update_date # 更新日期
self.likes: str = likes # [1K] 點擊喜歡
self.views: str = views # [40K] 次觀看
self.comment_count: int = int(comment_count) # 评论数
self.works: List[str] = works # 作品
self.actors: List[str] = actors # 登場人物
self.tags: List[str] = tags # 標籤
self.authors: List[str] = authors # 作者
# 有的 album 没有章节,则自成一章。
episode_list: List[Tuple[str, str, str]]
if len(episode_list) == 0:
# photo_id, photo_index, photo_title, photo_pub_date
episode_list = [(album_id, "1", name)]
else:
episode_list = self.distinct_episode(episode_list)
self.episode_list = episode_list
self.related_list = related_list
@property
def author(self):
"""
作者
禁漫本子的作者标签可能有多个,全部作者请使用字段 self.author_list
"""
if len(self.authors) >= 1:
return self.authors[0]
return JmModuleConfig.DEFAULT_AUTHOR
@property
def id(self):
return self.album_id
@staticmethod
def distinct_episode(episode_list: list):
"""
去重章节
photo_id, photo_index, photo_title, photo_pub_date
"""
episode_list.sort(key=lambda e: int(e[1])) # 按照photo_index排序
ret = [episode_list[0]]
for i in range(1, len(episode_list)):
if ret[-1][1] != episode_list[i][1]:
ret.append(episode_list[i])
return ret
def create_photo_detail(self, index) -> JmPhotoDetail:
# 校验参数
length = len(self.episode_list)
if index >= length:
raise IndexError(f'photo index out of range for album-{self.album_id}: {index} >= {length}')
# ('212214', '81', '94 突然打來', '2020-08-29')
pid, pindex, pname = self.episode_list[index]
photo = JmModuleConfig.photo_class()(
photo_id=pid,
scramble_id=self.scramble_id,
name=pname,
series_id=self.album_id,
sort=pindex,
from_album=self,
)
return photo
@lru_cache(None)
def getindex(self, item) -> JmPhotoDetail:
return self.create_photo_detail(item)
def __getitem__(self, item) -> Union[JmPhotoDetail, List[JmPhotoDetail]]:
return super().__getitem__(item)
def __len__(self):
return len(self.episode_list)
def __iter__(self) -> Generator[JmPhotoDetail, None, None]:
return super().__iter__()
@classmethod
def is_album(cls):
return True
class JmPageContent(JmBaseEntity, IndexedEntity):
ContentItem = Tuple[str, Dict[str, Any]]
def __init__(self, content: List[ContentItem], total: int):
"""
content:
[
album_id, {title, tags, ...}
]
:param content: 分页数据
:param total: 总结果数
"""
self.content = content
self.total = total
@property
def page_count(self) -> int:
"""
页数
"""
page_size = self.page_size
import math
return math.ceil(int(self.total) / page_size)
@property
def page_size(self) -> int:
"""
页大小
"""
raise NotImplementedError
def iter_id(self) -> Generator[str, None, None]:
"""
返回 album_id 的迭代器
"""
for aid, ainfo in self.content:
yield aid
def iter_id_title(self) -> Generator[Tuple[str, str], None, None]:
"""
返回 album_id, album_title 的迭代器
"""
for aid, ainfo in self.content:
yield aid, ainfo['name']
def iter_id_title_tag(self) -> Generator[Tuple[str, str, List[str]], None, None]:
"""
返回 album_id, album_title, album_tags 的迭代器
"""
for aid, ainfo in self.content:
ainfo.setdefault('tags', [])
yield aid, ainfo['name'], ainfo['tags']
# 下面的方法实现方便的元素访问
def __len__(self):
return len(self.content)
def __iter__(self):
return self.iter_id_title()
def __getitem__(self, item) -> Union[ContentItem, List[ContentItem]]:
return super().__getitem__(item)
def getindex(self, index: int):
return self.content[index]
@classmethod
def is_page(cls):
return True
class JmSearchPage(JmPageContent):
@property
def page_size(self) -> int:
return JmModuleConfig.PAGE_SIZE_SEARCH
# 下面的方法是对单个album的包装
@property
def is_single_album(self):
return hasattr(self, 'album')
@property
def single_album(self) -> JmAlbumDetail:
return getattr(self, 'album')
@classmethod
def wrap_single_album(cls, album: JmAlbumDetail) -> 'JmSearchPage':
page = JmSearchPage([(
album.album_id, {
'name': album.name,
'tags': album.tags,
'scramble_id': album.scramble_id,
'page_count': album.page_count,
'pub_date': album.pub_date,
'update_date': album.update_date,
'likes': album.likes,
'views': album.views,
'comment_count': album.comment_count,
'works': album.works,
'actors': album.actors,
'authors': album.authors,
'related_list': album.related_list,
}
)], 1)
setattr(page, 'album', album)
return page
JmCategoryPage = JmSearchPage
class JmFavoritePage(JmPageContent):
def __init__(self, content, folder_list, total):
"""
:param content: 收藏夹一页数据
:param folder_list: 所有的收藏夹的信息
:param total: 收藏夹的收藏总数
"""
super().__init__(content, total)
self.folder_list = folder_list
@property
def page_size(self) -> int:
return JmModuleConfig.PAGE_SIZE_FAVORITE
def iter_folder_id_name(self) -> Generator[Tuple[str, str], None, None]:
"""
用户文件夹的迭代器
"""
for folder_info in self.folder_list:
fid, fname = folder_info['FID'], folder_info['name']
yield fid, fname

191
jm/src/jmcomic/jm_exception.py Executable file
View File

@@ -0,0 +1,191 @@
# 该文件存放jmcomic的异常机制设计和实现
from .jm_entity import *
class JmcomicException(Exception):
description = 'jmcomic 模块异常'
def __init__(self, msg: str, context: dict):
self.msg = msg
self.context = context
def from_context(self, key):
return self.context[key]
def __str__(self):
return self.msg
class ResponseUnexpectedException(JmcomicException):
description = '响应不符合预期异常'
@property
def resp(self):
return self.from_context(ExceptionTool.CONTEXT_KEY_RESP)
class RegularNotMatchException(JmcomicException):
description = '正则表达式不匹配异常'
@property
def resp(self):
"""
可能为None
"""
return self.context.get(ExceptionTool.CONTEXT_KEY_RESP, None)
@property
def error_text(self):
return self.from_context(ExceptionTool.CONTEXT_KEY_HTML)
@property
def pattern(self):
return self.from_context(ExceptionTool.CONTEXT_KEY_RE_PATTERN)
class JsonResolveFailException(ResponseUnexpectedException):
description = 'Json解析异常'
class MissingAlbumPhotoException(ResponseUnexpectedException):
description = '不存在本子或章节异常'
@property
def error_jmid(self) -> str:
return self.from_context(ExceptionTool.CONTEXT_KEY_MISSING_JM_ID)
class RequestRetryAllFailException(JmcomicException):
description = '请求重试全部失败异常'
class PartialDownloadFailedException(JmcomicException):
description = '部分章节或图片下载失败异常'
@property
def downloader(self):
return self.from_context(ExceptionTool.CONTEXT_KEY_DOWNLOADER)
class ExceptionTool:
"""
抛异常的工具
1: 能简化 if-raise 语句的编写
2: 有更好的上下文信息传递方式
"""
CONTEXT_KEY_RESP = 'resp'
CONTEXT_KEY_HTML = 'html'
CONTEXT_KEY_RE_PATTERN = 'pattern'
CONTEXT_KEY_MISSING_JM_ID = 'missing_jm_id'
CONTEXT_KEY_DOWNLOADER = 'downloader'
@classmethod
def raises(cls,
msg: str,
context: dict = None,
etype: Optional[Type[Exception]] = None,
):
"""
抛出异常
:param msg: 异常消息
:param context: 异常上下文数据
:param etype: 异常类型,默认使用 JmcomicException
"""
if context is None:
context = {}
if etype is None:
etype = JmcomicException
# 异常对象
e = etype(msg, context)
# 异常处理建议
cls.notify_all_listeners(e)
raise e
@classmethod
def raises_regex(cls,
msg: str,
html: str,
pattern: Pattern,
):
cls.raises(
msg,
{
cls.CONTEXT_KEY_HTML: html,
cls.CONTEXT_KEY_RE_PATTERN: pattern,
},
RegularNotMatchException,
)
@classmethod
def raises_resp(cls,
msg: str,
resp,
etype=ResponseUnexpectedException
):
cls.raises(
msg, {
cls.CONTEXT_KEY_RESP: resp
},
etype,
)
@classmethod
def raise_missing(cls,
resp,
jmid: str,
):
"""
抛出本子/章节的异常
:param resp: 响应对象
:param jmid: 禁漫本子/章节id
"""
from .jm_toolkit import JmcomicText
url = JmcomicText.format_album_url(jmid)
req_type = "本子" if "album" in url else "章节"
cls.raises(
(
f'请求的{req_type}不存在!({url})\n'
'原因可能为:\n'
f'1. id有误检查你的{req_type}id\n'
'2. 该漫画只对登录用户可见请配置你的cookies或者使用移动端Clientapi\n'
),
{
cls.CONTEXT_KEY_RESP: resp,
cls.CONTEXT_KEY_MISSING_JM_ID: jmid,
},
MissingAlbumPhotoException,
)
@classmethod
def require_true(cls, case: bool, msg: str):
if case:
return
cls.raises(msg)
@classmethod
def replace_old_exception_executor(cls, raises: Callable[[Callable, str, dict], None]):
old = cls.raises
def new(msg, context=None, _etype=None):
if context is None:
context = {}
raises(old, msg, context)
cls.raises = new
@classmethod
def notify_all_listeners(cls, e):
registry: Dict[Type, Callable[Type]] = JmModuleConfig.REGISTRY_EXCEPTION_LISTENER
if not registry:
return None
for accept_type, listener in registry.items():
if isinstance(e, accept_type):
listener(e)

670
jm/src/jmcomic/jm_option.py Executable file
View File

@@ -0,0 +1,670 @@
from .jm_client_impl import *
class CacheRegistry:
REGISTRY = {}
@classmethod
def level_option(cls, option, _client):
registry = cls.REGISTRY
registry.setdefault(option, {})
return registry[option]
@classmethod
def level_client(cls, _option, client):
registry = cls.REGISTRY
registry.setdefault(client, {})
return registry[client]
@classmethod
def enable_client_cache_on_condition(cls,
option: 'JmOption',
client: JmcomicClient,
cache: Union[None, bool, str, Callable],
):
"""
cache parameter
if None: no cache
if bool:
true: level_option
false: no cache
if str:
(invoke corresponding Cache class method)
:param option: JmOption
:param client: JmcomicClient
:param cache: config dsl
"""
if cache is None:
return
elif isinstance(cache, bool):
if cache is False:
return
else:
cache = cls.level_option
elif isinstance(cache, str):
func = getattr(cls, cache, None)
ExceptionTool.require_true(func is not None, f'未实现的cache配置名: {cache}')
cache = func
cache: Callable
client.set_cache_dict(cache(option, client))
class DirRule:
rule_sample = [
# 根目录 / Album-id / Photo-序号 /
'Bd_Aid_Pindex', # 禁漫网站的默认下载方式
# 根目录 / Album-作者 / Album-标题 / Photo-序号 /
'Bd_Aauthor_Atitle_Pindex',
# 根目录 / Photo-序号&标题 /
'Bd_Pindextitle',
# 根目录 / Photo-自定义类属性 /
'Bd_Aauthor_Atitle_Pcustomfield',
# 需要替换JmModuleConfig.CLASS_ALBUM / CLASS_PHOTO才能让自定义属性生效
]
Detail = Union[JmAlbumDetail, JmPhotoDetail, None]
RuleFunc = Callable[[Detail], str]
RuleSolver = Tuple[str, RuleFunc, str]
RuleSolverList = List[RuleSolver]
def __init__(self, rule: str, base_dir=None):
base_dir = JmcomicText.parse_to_abspath(base_dir)
self.base_dir = base_dir
self.rule_dsl = rule
self.solver_list = self.get_role_solver_list(rule, base_dir)
def decide_image_save_dir(self,
album: JmAlbumDetail,
photo: JmPhotoDetail,
) -> str:
path_ls = []
for solver in self.solver_list:
try:
ret = self.apply_rule_solver(album, photo, solver)
except BaseException as e:
# noinspection PyUnboundLocalVariable
jm_log('dir_rule', f'路径规则"{solver[2]}"的解析出错: {e}, album={album}, photo={photo}')
raise e
path_ls.append(str(ret))
return fix_filepath('/'.join(path_ls), is_dir=True)
def decide_album_root_dir(self, album: JmAlbumDetail) -> str:
path_ls = []
for solver in self.solver_list:
key, _, rule = solver
if key != 'Bd' and key != 'A':
continue
try:
ret = self.apply_rule_solver(album, None, solver)
except BaseException as e:
# noinspection PyUnboundLocalVariable
jm_log('dir_rule', f'路径规则"{rule}"的解析出错: {e}, album={album}')
raise e
path_ls.append(str(ret))
return fix_filepath('/'.join(path_ls), is_dir=True)
def get_role_solver_list(self, rule_dsl: str, base_dir: str) -> RuleSolverList:
"""
解析下载路径dsl得到一个路径规则解析列表
"""
rule_list = self.split_rule_dsl(rule_dsl)
solver_ls: List[DirRule.RuleSolver] = []
for rule in rule_list:
rule = rule.strip()
if rule == 'Bd':
solver_ls.append(('Bd', lambda _: base_dir, 'Bd'))
continue
rule_solver = self.get_rule_solver(rule)
if rule_solver is None:
ExceptionTool.raises(f'不支持的dsl: "{rule}" in "{rule_dsl}"')
solver_ls.append(rule_solver)
return solver_ls
# noinspection PyMethodMayBeStatic
def split_rule_dsl(self, rule_dsl: str) -> List[str]:
if rule_dsl == 'Bd':
return [rule_dsl]
if '/' in rule_dsl:
return rule_dsl.split('/')
if '_' in rule_dsl:
return rule_dsl.split('_')
ExceptionTool.raises(f'不支持的rule配置: "{rule_dsl}"')
@classmethod
def get_rule_solver(cls, rule: str) -> Optional[RuleSolver]:
# 检查dsl
if not rule.startswith(('A', 'P')):
return None
def solve_func(detail):
return fix_windir_name(str(DetailEntity.get_dirname(detail, rule[1:]))).strip()
return rule[0], solve_func, rule
@classmethod
def apply_rule_solver(cls, album, photo, rule_solver: RuleSolver) -> str:
"""
应用规则解析器(RuleSolver)
:param album: JmAlbumDetail
:param photo: JmPhotoDetail
:param rule_solver: Ptitle
:returns: photo.title
"""
def choose_detail(key):
if key == 'Bd':
return None
if key == 'A':
return album
if key == 'P':
return photo
key, func, _ = rule_solver
detail = choose_detail(key)
return func(detail)
@classmethod
def apply_rule_directly(cls, album, photo, rule: str) -> str:
return cls.apply_rule_solver(album, photo, cls.get_rule_solver(rule))
class JmOption:
def __init__(self,
dir_rule: Dict,
download: Dict,
client: Dict,
plugins: Dict,
filepath=None,
call_after_init_plugin=True,
):
# 路径规则配置
self.dir_rule = DirRule(**dir_rule)
# 客户端配置
self.client = AdvancedDict(client)
# 下载配置
self.download = AdvancedDict(download)
# 插件配置
self.plugins = AdvancedDict(plugins)
# 其他配置
self.filepath = filepath
# 需要主线程等待完成的插件
self.need_wait_plugins = []
if call_after_init_plugin:
self.call_all_plugin('after_init', safe=True)
def copy_option(self):
return self.__class__(
dir_rule={
'rule': self.dir_rule.rule_dsl,
'base_dir': self.dir_rule.base_dir,
},
download=self.download.src_dict,
client=self.client.src_dict,
plugins=self.plugins.src_dict,
filepath=self.filepath,
call_after_init_plugin=False
)
"""
下面是decide系列方法为了支持重写和增加程序动态性。
"""
# noinspection PyUnusedLocal
def decide_image_batch_count(self, photo: JmPhotoDetail):
return self.download.threading.image
# noinspection PyMethodMayBeStatic,PyUnusedLocal
def decide_photo_batch_count(self, album: JmAlbumDetail):
return self.download.threading.photo
# noinspection PyMethodMayBeStatic
def decide_image_filename(self, image: JmImageDetail) -> str:
"""
返回图片的文件名,不包含后缀
默认返回禁漫的图片文件名例如00001 (.jpg)
"""
return image.filename_without_suffix
def decide_image_suffix(self, image: JmImageDetail) -> str:
"""
返回图片的后缀,如果返回的后缀和原后缀不一致,则会进行图片格式转换
"""
# 动图则使用原后缀
if image.is_gif:
return image.img_file_suffix
# 非动图,以配置为先
return self.download.image.suffix or image.img_file_suffix
def decide_image_save_dir(self, photo, ensure_exists=True) -> str:
# 使用 self.dir_rule 决定 save_dir
save_dir = self.dir_rule.decide_image_save_dir(
photo.from_album,
photo
)
if ensure_exists:
save_dir = JmcomicText.try_mkdir(save_dir)
return save_dir
def decide_image_filepath(self, image: JmImageDetail, consider_custom_suffix=True) -> str:
# 以此决定保存文件夹、后缀、不包含后缀的文件名
save_dir = self.decide_image_save_dir(image.from_photo)
suffix = self.decide_image_suffix(image) if consider_custom_suffix else image.img_file_suffix
return os.path.join(save_dir, fix_windir_name(self.decide_image_filename(image)) + suffix)
def decide_download_cache(self, _image: JmImageDetail) -> bool:
return self.download.cache
def decide_download_image_decode(self, image: JmImageDetail) -> bool:
# .gif file needn't be decoded
if image.is_gif:
return False
return self.download.image.decode
"""
下面是创建对象相关方法
"""
@classmethod
def default_dict(cls) -> Dict:
return JmModuleConfig.option_default_dict()
@classmethod
def default(cls) -> 'JmOption':
"""
使用默认的 JmOption
"""
return cls.construct({})
@classmethod
def construct(cls, origdic: Dict, cover_default=True) -> 'JmOption':
dic = cls.merge_default_dict(origdic) if cover_default else origdic
# log
log = dic.pop('log', True)
if log is False:
disable_jm_log()
# version
version = dic.pop('version', None)
# noinspection PyTypeChecker
if version is not None and float(version) >= float(JmModuleConfig.JM_OPTION_VER):
# 版本号更高,跳过兼容代码
return cls(**dic)
# 旧版本option做兼容
cls.compatible_with_old_versions(dic)
return cls(**dic)
@classmethod
def compatible_with_old_versions(cls, dic):
"""
兼容旧的option版本
"""
# 1: 并发配置项
dt: dict = dic['download']['threading']
if 'batch_count' in dt:
batch_count = dt.pop('batch_count')
dt['image'] = batch_count
# 2: 插件配置项 plugin -> plugins
if 'plugin' in dic:
dic['plugins'] = dic.pop('plugin')
def deconstruct(self) -> Dict:
return {
'version': JmModuleConfig.JM_OPTION_VER,
'log': JmModuleConfig.FLAG_ENABLE_JM_LOG,
'dir_rule': {
'rule': self.dir_rule.rule_dsl,
'base_dir': self.dir_rule.base_dir,
},
'download': self.download.src_dict,
'client': self.client.src_dict,
'plugins': self.plugins.src_dict
}
"""
下面是文件IO方法
"""
@classmethod
def from_file(cls, filepath: str) -> 'JmOption':
dic: dict = PackerUtil.unpack(filepath)[0]
dic.setdefault('filepath', filepath)
return cls.construct(dic)
def to_file(self, filepath=None):
if filepath is None:
filepath = self.filepath
ExceptionTool.require_true(filepath is not None, "未指定JmOption的保存路径")
PackerUtil.pack(self.deconstruct(), filepath)
"""
下面是创建客户端的相关方法
"""
@field_cache()
def build_jm_client(self, **kwargs):
"""
该方法会首次调用会创建JmcomicClient对象
然后保存在self中
多次调用`不会`创建新的JmcomicClient对象
"""
return self.new_jm_client(**kwargs)
def new_jm_client(self, domain_list=None, impl=None, cache=None, **kwargs) -> Union[JmHtmlClient, JmApiClient]:
"""
创建新的Client客户端不同Client之间的元数据不共享
"""
from copy import deepcopy
# 所有需要用到的 self.client 配置项如下
postman_conf: dict = deepcopy(self.client.postman.src_dict) # postman dsl 配置
meta_data: dict = postman_conf['meta_data'] # 元数据
retry_times: int = self.client.retry_times # 重试次数
cache: str = cache if cache is not None else self.client.cache # 启用缓存
impl: str = impl or self.client.impl # client_key
if isinstance(impl, type):
# eg: impl = JmHtmlClient
# noinspection PyUnresolvedReferences
impl = impl.client_key
# start construct client
# domain
def decide_domain_list():
nonlocal domain_list
if domain_list is None:
domain_list = self.client.domain
if not isinstance(domain_list, (list, str)):
# dict
domain_list = domain_list.get(impl, [])
if isinstance(domain_list, str):
# multi-lines text
domain_list = str_to_list(domain_list)
# list or str
if len(domain_list) == 0:
domain_list = self.decide_client_domain(impl)
return domain_list
# support kwargs overwrite meta_data
if len(kwargs) != 0:
meta_data.update(kwargs)
# postman
postman = Postmans.create(data=postman_conf)
# client
clazz = JmModuleConfig.client_impl_class(impl)
if clazz == AbstractJmClient or not issubclass(clazz, AbstractJmClient):
raise NotImplementedError(clazz)
client: AbstractJmClient = clazz(
postman=postman,
domain_list=decide_domain_list(),
retry_times=retry_times,
)
# enable cache
CacheRegistry.enable_client_cache_on_condition(self, client, cache)
# noinspection PyTypeChecker
return client
def update_cookies(self, cookies: dict):
metadata: dict = self.client.postman.meta_data.src_dict
orig_cookies: Optional[Dict] = metadata.get('cookies', None)
if orig_cookies is None:
metadata['cookies'] = cookies
else:
orig_cookies.update(cookies)
metadata['cookies'] = orig_cookies
# noinspection PyMethodMayBeStatic
def decide_client_domain(self, client_key: str) -> List[str]:
def is_client_type(ctype) -> bool:
return self.client_key_is_given_type(client_key, ctype)
if is_client_type(JmApiClient):
# 移动端
return JmModuleConfig.DOMAIN_API_LIST
if is_client_type(JmHtmlClient):
# 网页端
domain_list = JmModuleConfig.DOMAIN_HTML_LIST
if domain_list is not None:
return domain_list
return [JmModuleConfig.get_html_domain()]
ExceptionTool.raises(f'没有配置域名且是无法识别的client类型: {client_key}')
@classmethod
def client_key_is_given_type(cls, client_key, ctype: Type[JmcomicClient]):
if client_key == ctype.client_key:
return True
clazz = JmModuleConfig.client_impl_class(client_key)
if issubclass(clazz, ctype):
return True
return False
@classmethod
def merge_default_dict(cls, user_dict, default_dict=None):
"""
深度合并两个字典
"""
if default_dict is None:
default_dict = cls.default_dict()
for key, value in user_dict.items():
if isinstance(value, dict) and isinstance(default_dict.get(key), dict):
default_dict[key] = cls.merge_default_dict(value, default_dict[key])
else:
default_dict[key] = value
return default_dict
# 下面的方法提供面向对象的调用风格
def download_album(self,
album_id,
downloader=None,
callback=None,
):
from .api import download_album
download_album(album_id, self, downloader, callback)
def download_photo(self,
photo_id,
downloader=None,
callback=None
):
from .api import download_photo
download_photo(photo_id, self, downloader, callback)
# 下面的方法为调用插件提供支持
def call_all_plugin(self, group: str, safe=True, **extra):
plugin_list: List[dict] = self.plugins.get(group, [])
if plugin_list is None or len(plugin_list) == 0:
return
# 保证 jm_plugin.py 被加载
from .jm_plugin import JmOptionPlugin
plugin_registry = JmModuleConfig.REGISTRY_PLUGIN
for pinfo in plugin_list:
key, kwargs = pinfo['plugin'], pinfo.get('kwargs', None) # kwargs为None
pclass: Optional[Type[JmOptionPlugin]] = plugin_registry.get(key, None)
ExceptionTool.require_true(pclass is not None, f'[{group}] 未注册的plugin: {key}')
try:
self.invoke_plugin(pclass, kwargs, extra, pinfo)
except BaseException as e:
if safe is True:
traceback_print_exec()
else:
raise e
def invoke_plugin(self, pclass, kwargs: Optional[Dict], extra: dict, pinfo: dict):
# 检查插件的参数类型
kwargs = self.fix_kwargs(kwargs)
# 把插件的配置数据kwargs和附加数据extra合并extra会覆盖kwargs
if len(extra) != 0:
kwargs.update(extra)
# 保证 jm_plugin.py 被加载
from .jm_plugin import JmOptionPlugin, PluginValidationException
pclass: Type[JmOptionPlugin]
plugin: Optional[JmOptionPlugin] = None
try:
# 构建插件对象
plugin: JmOptionPlugin = pclass.build(self)
# 设置日志开关
if pinfo.get('log', True) is not True:
plugin.log_enable = False
jm_log('plugin.invoke', f'调用插件: [{pclass.plugin_key}]')
# 调用插件功能
plugin.invoke(**kwargs)
except PluginValidationException as e:
# 插件抛出的参数校验异常
self.handle_plugin_valid_exception(e, pinfo, kwargs, plugin, pclass)
except JmcomicException as e:
# 模块内部异常通过不是插件抛出的而是插件调用了例如ClientClient请求失败抛出的
self.handle_plugin_jmcomic_exception(e, pinfo, kwargs, plugin, pclass)
except BaseException as e:
# 为插件兜底,捕获其他所有异常
self.handle_plugin_unexpected_error(e, pinfo, kwargs, plugin, pclass)
# noinspection PyMethodMayBeStatic,PyUnusedLocal
def handle_plugin_valid_exception(self, e, pinfo: dict, kwargs: dict, _plugin, _pclass):
from .jm_plugin import PluginValidationException
e: PluginValidationException
mode = pinfo.get('valid', self.plugins.valid)
if mode == 'ignore':
# ignore
return
if mode == 'log':
# log
jm_log('plugin.validation',
f'插件 [{e.plugin.plugin_key}] 参数校验异常:{e.msg}'
)
return
if mode == 'raise':
# raise
raise e
# 其他的mode可以通过继承+方法重写来扩展
# noinspection PyMethodMayBeStatic,PyUnusedLocal
def handle_plugin_unexpected_error(self, e, pinfo: dict, kwargs: dict, _plugin, pclass):
msg = str(e)
jm_log('plugin.error', f'插件 [{pclass.plugin_key}],运行遇到未捕获异常,异常信息: [{msg}]')
raise e
# noinspection PyMethodMayBeStatic,PyUnusedLocal
def handle_plugin_jmcomic_exception(self, e, pinfo: dict, kwargs: dict, _plugin, pclass):
msg = str(e)
jm_log('plugin.exception', f'插件 [{pclass.plugin_key}] 调用失败,异常信息: [{msg}]')
raise e
# noinspection PyMethodMayBeStatic
def fix_kwargs(self, kwargs: Optional[Dict]) -> Dict[str, Any]:
"""
kwargs将来要传给方法参数这要求kwargs的key是str类型
该方法检查kwargs的key的类型如果不是str尝试转为str不行则抛异常。
"""
if kwargs is None:
kwargs = {}
else:
ExceptionTool.require_true(
isinstance(kwargs, dict),
f'插件的kwargs参数必须为dict类型而不能是类型: {type(kwargs)}'
)
kwargs: dict
new_kwargs: Dict[str, Any] = {}
for k, v in kwargs.items():
if isinstance(v, str):
newv = JmcomicText.parse_dsl_text(v)
v = newv
if isinstance(k, str):
new_kwargs[k] = v
continue
if isinstance(k, (int, float)):
newk = str(k)
jm_log('plugin.kwargs', f'插件参数类型转换: {k} ({type(k)}) -> {newk} ({type(newk)})')
new_kwargs[newk] = v
continue
ExceptionTool.raises(
f'插件kwargs参数类型有误'
f'字段: {k}预期类型为str实际类型为{type(k)}'
)
return new_kwargs
def wait_all_plugins_finish(self):
from .jm_plugin import JmOptionPlugin
for plugin in self.need_wait_plugins:
plugin: JmOptionPlugin
plugin.wait_until_finish()

1222
jm/src/jmcomic/jm_plugin.py Executable file

File diff suppressed because it is too large Load Diff

927
jm/src/jmcomic/jm_toolkit.py Executable file
View File

@@ -0,0 +1,927 @@
from PIL import Image
from .jm_exception import *
class JmcomicText:
pattern_jm_domain = compile(r'https://([\w.-]+)')
pattern_jm_pa_id = [
(compile(r'(photos?|albums?)/(\d+)'), 2),
(compile(r'id=(\d+)'), 1),
]
pattern_html_jm_pub_domain = compile(r'[\w-]+\.\w+/?\w+')
pattern_html_photo_photo_id = compile(r'<meta property="og:url" content=".*?/photo/(\d+)/?.*?">')
pattern_html_photo_scramble_id = compile(r'var scramble_id = (\d+);')
pattern_html_photo_name = compile(r'<title>([\s\S]*?)\|.*</title>')
# pattern_html_photo_data_original_list = compile(r'data-original="(.*?)" id="album_photo_.+?"')
pattern_html_photo_data_original_domain = compile(r'src="https://(.*?)/media/albums/blank')
pattern_html_photo_data_original_0 = compile(r'data-original="(.*?)"[^>]*?id="album_photo[^>]*?data-page="0"')
pattern_html_photo_tags = compile(r'<meta name="keywords"[\s\S]*?content="(.*?)"')
pattern_html_photo_series_id = compile(r'var series_id = (\d+);')
pattern_html_photo_sort = compile(r'var sort = (\d+);')
pattern_html_photo_page_arr = compile(r'var page_arr = (.*?);')
pattern_html_album_album_id = compile(r'<span class="number">.*?JM(\d+)</span>')
pattern_html_album_scramble_id = compile(r'var scramble_id = (\d+);')
pattern_html_album_name = compile(r'<h1 class="book-name" id="book-name">([\s\S]*?)</h1>')
pattern_html_album_episode_list = compile(r'data-album="(\d+)"[^>]*>\s*?<li.*?>\s*?第(\d+)[话話]([\s\S]*?)<[\s\S]*?>')
pattern_html_album_page_count = compile(r'<span class="pagecount">.*?:(\d+)</span>')
pattern_html_album_pub_date = compile(r'>上架日期 : (.*?)</span>')
pattern_html_album_update_date = compile(r'>更新日期 : (.*?)</span>')
pattern_html_tag_a = compile(r'<a[^>]*?>\s*(\S*)\s*</a>')
# 作品
pattern_html_album_works = [
compile(r'<span itemprop="author" data-type="works">([\s\S]*?)</span>'),
pattern_html_tag_a,
]
# 登場人物
pattern_html_album_actors = [
compile(r'<span itemprop="author" data-type="actor">([\s\S]*?)</span>'),
pattern_html_tag_a,
]
# 标签
pattern_html_album_tags = [
compile(r'<span itemprop="genre" data-type="tags">([\s\S]*?)</span>'),
pattern_html_tag_a,
]
# 作者
pattern_html_album_authors = [
compile(r'作者: *<span itemprop="author" data-type="author">([\s\S]*?)</span>'),
pattern_html_tag_a,
]
# 點擊喜歡
pattern_html_album_likes = compile(r'<span id="albim_likes_\d+">(.*?)</span>')
# 觀看
pattern_html_album_views = compile(r'<span>(.*?)</span>\n *<span>(次觀看|观看次数|次观看次数)</span>')
# 評論(div)
pattern_html_album_comment_count = compile(r'<div class="badge"[^>]*?id="total_video_comments">(\d+)</div>'), 0
# 提取接口返回值信息
pattern_ajax_favorite_msg = compile(r'</button>(.*?)</div>')
@classmethod
def parse_to_jm_domain(cls, text: str):
if text.startswith(JmModuleConfig.PROT):
return cls.pattern_jm_domain.search(text)[1]
return text
@classmethod
def parse_to_jm_id(cls, text) -> str:
if isinstance(text, int):
return str(text)
ExceptionTool.require_true(isinstance(text, str), f"无法解析jm车号, 参数类型为: {type(text)}")
# 43210
if text.isdigit():
return text
# Jm43210
ExceptionTool.require_true(len(text) >= 2, f"无法解析jm车号, 文本太短: {text}")
# text: JM12341
c0 = text[0]
c1 = text[1]
if (c0 == 'J' or c0 == 'j') and (c1 == 'M' or c1 == 'm'):
# JM123456
return text[2:]
else:
# https://xxx/photo/412038
# https://xxx/album/?id=412038
for p, i in cls.pattern_jm_pa_id:
match = p.search(text)
if match is not None:
return match[i]
ExceptionTool.raises(f"无法解析jm车号, 文本为: {text}")
@classmethod
def analyse_jm_pub_html(cls, html: str, domain_keyword=('jm', 'comic')) -> List[str]:
domain_ls = cls.pattern_html_jm_pub_domain.findall(html)
return list(filter(
lambda domain: any(kw in domain for kw in domain_keyword),
domain_ls
))
@classmethod
def analyse_jm_photo_html(cls, html: str) -> JmPhotoDetail:
return cls.reflect_new_instance(
html,
"pattern_html_photo_",
JmModuleConfig.photo_class()
)
@classmethod
def analyse_jm_album_html(cls, html: str) -> JmAlbumDetail:
return cls.reflect_new_instance(
html,
"pattern_html_album_",
JmModuleConfig.album_class()
)
@classmethod
def reflect_new_instance(cls, html: str, cls_field_prefix: str, clazz: type):
def match_field(field_name: str, pattern: Union[Pattern, List[Pattern]], text):
if isinstance(pattern, list):
# 如果是 pattern 是 List[re.Pattern]
# 取最后一个 pattern 用于 match field
# 其他的 pattern 用来给文本缩小范围(相当于多次正则匹配)
last_pattern = pattern[len(pattern) - 1]
# 缩小文本
for i in range(0, len(pattern) - 1):
match: Match = pattern[i].search(text)
if match is None:
return None
text = match[0]
return last_pattern.findall(text)
if field_name.endswith("_list"):
return pattern.findall(text)
else:
match = pattern.search(text)
if match is not None:
return match[1]
return None
field_dict = {}
pattern_name: str
for pattern_name, pattern in cls.__dict__.items():
if not pattern_name.startswith(cls_field_prefix):
continue
# 支持如果不匹配,使用默认值
if isinstance(pattern, tuple):
pattern, default = pattern
else:
default = None
# 获取字段名和值
field_name = pattern_name[pattern_name.index(cls_field_prefix) + len(cls_field_prefix):]
field_value = match_field(field_name, pattern, html)
if field_value is None:
if default is None:
ExceptionTool.raises_regex(
f"文本没有匹配上字段:字段名为'{field_name}'pattern: [{pattern}]"
+ (f"\n响应文本=[{html}]" if len(html) < 200 else
f'响应文本过长(len={len(html)}),不打印'
),
html=html,
pattern=pattern,
)
else:
field_value = default
# 保存字段
field_dict[field_name] = field_value
return clazz(**field_dict)
@classmethod
def format_url(cls, path, domain):
ExceptionTool.require_true(isinstance(domain, str) and len(domain) != 0, '域名为空')
if domain.startswith(JmModuleConfig.PROT):
return f'{domain}{path}'
return f'{JmModuleConfig.PROT}{domain}{path}'
@classmethod
def format_album_url(cls, aid, domain='18comic.vip'):
"""
把album_id变为可访问的URL方便print打印后用浏览器访问
"""
return cls.format_url(f'/album/{aid}/', domain)
class DSLReplacer:
def __init__(self):
self.dsl_dict: Dict[Pattern, Callable[[Match], str]] = {}
def parse_dsl_text(self, text) -> str:
for pattern, replacer in self.dsl_dict.items():
text = pattern.sub(replacer, text)
return text
def add_dsl_and_replacer(self, dsl: str, replacer: Callable[[Match], str]):
pattern = compile(dsl)
self.dsl_dict[pattern] = replacer
@classmethod
def match_os_env(cls, match: Match) -> str:
name = match[1]
value = os.getenv(name, None)
ExceptionTool.require_true(value is not None, f'未配置环境变量: {name}')
return value
dsl_replacer = DSLReplacer()
@classmethod
def parse_to_abspath(cls, dsl_text: str) -> str:
return os.path.abspath(cls.parse_dsl_text(dsl_text))
@classmethod
def parse_dsl_text(cls, dsl_text: str) -> str:
return cls.dsl_replacer.parse_dsl_text(dsl_text)
bracket_map = {'(': ')',
'[': ']',
'': '',
'': '',
}
@classmethod
def parse_orig_album_name(cls, name: str, default=None):
word_list = cls.tokenize(name)
for word in word_list:
if word[0] in cls.bracket_map:
continue
return word
return default
@classmethod
def tokenize(cls, title: str) -> List[str]:
"""
繞道#2 [暴碧漢化組] [えーすけ123] よりみち#2 (COMIC 快樂天 2024年1月號) [中國翻譯] [DL版]
:return: ['繞道#2', '[暴碧漢化組]', '[えーすけ123]', 'よりみち#2', '(COMIC 快樂天 2024年1月號)', '[中國翻譯]', '[DL版]']
"""
title = title.strip()
ret = []
bracket_map = cls.bracket_map
char_list = []
i = 0
length = len(title)
def add(w=None):
if w is None:
w = ''.join(char_list).strip()
if w == '':
return
ret.append(w)
char_list.clear()
def find_right_pair(left_pair, i):
stack = [left_pair]
j = i + 1
while j < length and len(stack) != 0:
c = title[j]
if c in bracket_map:
stack.append(c)
elif c == bracket_map[stack[-1]]:
stack.pop()
j += 1
if len(stack) == 0:
return j
else:
return -1
while i < length:
c = title[i]
if c in bracket_map:
# 上一个单词结束
add()
# 定位右括号
j = find_right_pair(c, i)
if j == -1:
# 括号未闭合
char_list.append(c)
i += 1
continue
# 整个括号的单词结束
add(title[i:j])
# 移动指针
i = j
else:
char_list.append(c)
i += 1
add()
return ret
@classmethod
def to_zh_cn(cls, s):
import zhconv
return zhconv.convert(s, 'zh-cn')
@classmethod
def try_mkdir(cls, save_dir: str):
try:
mkdir_if_not_exists(save_dir)
except OSError as e:
if e.errno == 36:
# 目录名过长
limit = JmModuleConfig.VAR_FILE_NAME_LENGTH_LIMIT
jm_log('error', f'目录名过长,无法创建目录,强制缩短到{limit}个字符并重试')
save_dir = save_dir[0:limit]
return cls.try_mkdir(save_dir)
raise e
return save_dir
# 支持dsl: #{???} -> os.getenv(???)
JmcomicText.dsl_replacer.add_dsl_and_replacer(r'\$\{(.*?)\}', JmcomicText.match_os_env)
class PatternTool:
@classmethod
def match_or_default(cls, html: str, pattern: Pattern, default):
match = pattern.search(html)
return default if match is None else match[1]
@classmethod
def require_match(cls, html: str, pattern: Pattern, msg, rindex=1):
match = pattern.search(html)
if match is not None:
return match[rindex] if rindex is not None else match
ExceptionTool.raises_regex(
msg,
html=html,
pattern=pattern,
)
@classmethod
def require_not_match(cls, html: str, pattern: Pattern, *, msg_func):
match = pattern.search(html)
if match is None:
return
ExceptionTool.raises_regex(
msg_func(match),
html=html,
pattern=pattern,
)
class JmPageTool:
# 用来缩减html的长度
pattern_html_search_shorten_for = compile(r'<div class="well well-sm">([\s\S]*)<div class="row">')
# 用来提取搜索页面的album的信息
pattern_html_search_album_info_list = compile(
r'<a href="/album/(\d+)/[\s\S]*?title="(.*?)"([\s\S]*?)<div class="title-truncate tags .*>([\s\S]*?)</div>'
)
# 用来提取分类页面的album的信息
pattern_html_category_album_info_list = compile(
r'<a href="/album/(\d+)/[^>]*>[^>]*?'
r'title="(.*?)"[^>]*>[ \n]*</a>[ \n]*'
r'<div class="label-loveicon">([\s\S]*?)'
r'<div class="clearfix">'
)
# 用来查找tag列表
pattern_html_search_tags = compile(r'<a[^>]*?>(.*?)</a>')
# 查找错误,例如 [错误,關鍵字過短,請至少輸入兩個字以上。]
pattern_html_search_error = compile(r'<fieldset>\n<legend>(.*?)</legend>\n<div class=.*?>\n(.*?)\n</div>\n</fieldset>')
pattern_html_search_total = compile(r'class="text-white">(\d+)</span> A漫.'), 0
# 收藏页面的本子结果
pattern_html_favorite_content = compile(
r'<div id="favorites_album_[^>]*?>[\s\S]*?'
r'<a href="/album/(\d+)/[^"]*">[\s\S]*?'
r'<div class="video-title title-truncate">([^<]*?)'
r'</div>'
)
# 收藏夹的收藏总数
pattern_html_favorite_total = compile(r' : (\d+)[^/]*/\D*(\d+)')
# 所有的收藏夹
pattern_html_favorite_folder_list = [
compile(r'<select class="user-select" name="movefolder-fid">([\s\S]*)</select>'),
compile(r'<option value="(\d+)">([^<]*?)</option>')
]
@classmethod
def parse_html_to_search_page(cls, html: str) -> JmSearchPage:
# 1. 检查是否失败
PatternTool.require_not_match(
html,
cls.pattern_html_search_error,
msg_func=lambda match: '{}: {}'.format(match[1], match[2])
)
# 2. 缩小文本范围
html = PatternTool.require_match(
html,
cls.pattern_html_search_shorten_for,
msg='未匹配到搜索结果',
)
# 3. 提取结果
content = [] # content这个名字来源于api版搜索返回值
total = int(PatternTool.match_or_default(html, *cls.pattern_html_search_total)) # 总结果数
album_info_list = cls.pattern_html_search_album_info_list.findall(html)
for (album_id, title, _label_category_text, tag_text) in album_info_list:
# 从label_category_text中可以解析出label-category和label-sub
# 这里不作解析,因为没什么用...
tags = cls.pattern_html_search_tags.findall(tag_text)
content.append((
album_id, {
'name': title, # 改成name是为了兼容 parse_api_resp_to_page
'tags': tags
}
))
return JmSearchPage(content, total)
@classmethod
def parse_html_to_category_page(cls, html: str) -> JmSearchPage:
content = []
total = int(PatternTool.match_or_default(html, *cls.pattern_html_search_total))
album_info_list = cls.pattern_html_category_album_info_list.findall(html)
for (album_id, title, tag_text) in album_info_list:
tags = cls.pattern_html_search_tags.findall(tag_text)
content.append((
album_id, {
'name': title, # 改成name是为了兼容 parse_api_resp_to_page
'tags': tags
}
))
return JmSearchPage(content, total)
@classmethod
def parse_html_to_favorite_page(cls, html: str) -> JmFavoritePage:
total = int(PatternTool.require_match(
html,
cls.pattern_html_favorite_total,
'未匹配到收藏夹的本子总数',
))
# 收藏夹的本子结果
content = cls.pattern_html_favorite_content.findall(html)
content = [
(aid, {'name': atitle})
for aid, atitle in content
]
# 匹配收藏夹列表
p1, p2 = cls.pattern_html_favorite_folder_list
folder_list_text = PatternTool.require_match(html, p1, '未匹配到收藏夹列表')
folder_list_raw = p2.findall(folder_list_text)
folder_list = [{'name': fname, 'FID': fid} for fid, fname in folder_list_raw]
return JmFavoritePage(content, folder_list, total)
@classmethod
def parse_api_to_search_page(cls, data: AdvancedDict) -> JmSearchPage:
"""
model_data: {
"search_query": "MANA",
"total": "177",
"content": [
{
"id": "441923",
"author": "MANA",
"description": "",
"name": "[MANA] 神里绫华5",
"image": "",
"category": {
"id": "1",
"title": "同人"
},
"category_sub": {
"id": "1",
"title": "同人"
}
}
]
}
"""
total: int = int(data.total or 0) # 2024.1.5 data.total可能为None
content = cls.adapt_content(data.content)
return JmSearchPage(content, total)
@classmethod
def parse_api_to_favorite_page(cls, data: AdvancedDict) -> JmFavoritePage:
"""
{
"list": [
{
"id": "363859",
"author": "紺菓",
"description": "",
"name": "[無邪氣漢化組] (C99) [紺色果實 (紺菓)] サレンの樂しい夢 (プリンセスコネクト!Re:Dive) [中國翻譯]",
"latest_ep": null,
"latest_ep_aid": null,
"image": "",
"category": {
"id": "1",
"title": "同人"
},
"category_sub": {
"id": "1",
"title": "同人"
}
}
],
"folder_list": [
{
"0": "123",
"FID": "123",
"1": "456",
"UID": "456",
"2": "收藏夹名",
"name": "收藏夹名"
}
],
"total": "87",
"count": 20
}
"""
total: int = int(data.total)
# count: int = int(data.count)
content = cls.adapt_content(data.list)
folder_list = data.get('folder_list', [])
return JmFavoritePage(content, folder_list, total)
@classmethod
def adapt_content(cls, content):
def adapt_item(item: AdvancedDict):
item: dict = item.src_dict
item.setdefault('tags', [])
return item
content = [
(item.id, adapt_item(item)) for item in content
]
return content
class JmApiAdaptTool:
"""
本类负责把移动端的api返回值适配为标准的实体类
# album
{
"id": 123,
"name": "[狗野叉漢化]",
"author": [
"AREA188"
],
"images": [
"00004.webp"
],
"description": null,
"total_views": "41314",
"likes": "918",
"series": [],
"series_id": "0",
"comment_total": "5",
"tags": [
"全彩",
"中文"
],
"works": [],
"actors": [],
"related_list": [
{
"id": "333718",
"author": "been",
"description": "",
"name": "[been]The illusion of lies1[中國語][無修正][全彩]",
"image": ""
}
],
"liked": false,
"is_favorite": false
}
# photo
{
"id": 413446,
"series": [
{
"id": "487043",
"name": "第48話",
"sort": "48"
}
],
"tags": "慾望 調教 NTL 地鐵 戲劇",
"name": "癡漢成癮-第2話",
"images": [
"00047.webp"
],
"series_id": "400222",
"is_favorite": false,
"liked": false
}
"""
field_adapter = {
JmAlbumDetail: [
'likes',
'tags',
'works',
'actors',
'related_list',
'name',
('id', 'album_id'),
('author', 'authors'),
('total_views', 'views'),
('comment_total', 'comment_count'),
],
JmPhotoDetail: [
'name',
'series_id',
'tags',
('id', 'photo_id'),
('images', 'page_arr'),
]
}
@classmethod
def parse_entity(cls, data: dict, clazz: type):
adapter = cls.get_adapter(clazz)
fields = {}
for k in adapter:
if isinstance(k, str):
v = data[k]
fields[k] = v
elif isinstance(k, tuple):
k, rename_k = k
v = data[k]
fields[rename_k] = v
if issubclass(clazz, JmAlbumDetail):
cls.post_adapt_album(data, clazz, fields)
else:
cls.post_adapt_photo(data, clazz, fields)
return clazz(**fields)
@classmethod
def get_adapter(cls, clazz: type):
for k, v in cls.field_adapter.items():
if issubclass(clazz, k):
return v
ExceptionTool.raises(f'不支持的类型: {clazz}')
@classmethod
def post_adapt_album(cls, data: dict, _clazz: type, fields: dict):
series = data['series']
episode_list = []
for chapter in series:
chapter = AdvancedDict(chapter)
# photo_id, photo_index, photo_title, photo_pub_date
episode_list.append(
(chapter.id, chapter.sort, chapter.name)
)
fields['episode_list'] = episode_list
for it in 'scramble_id', 'page_count', 'pub_date', 'update_date':
fields[it] = '0'
@classmethod
def post_adapt_photo(cls, data: dict, _clazz: type, fields: dict):
# 1. 获取sort字段如果data['series']中没有使用默认值1
sort = 1
series: list = data['series'] # series中的sort从1开始
for chapter in series:
chapter = AdvancedDict(chapter)
if int(chapter.id) == int(data['id']):
sort = chapter.sort
break
fields['sort'] = sort
import random
fields['data_original_domain'] = random.choice(JmModuleConfig.DOMAIN_IMAGE_LIST)
class JmImageTool:
@classmethod
def save_resp_img(cls, resp: Any, filepath: str, need_convert=True):
"""
接收HTTP响应对象将其保存到图片文件.
如果需要改变图片的文件格式,比如 .jpg → .png则需要指定参数 neet_convert=True.
如果不需要改变图片的文件格式,使用 need_convert=False可以跳过PIL解析图片效率更高.
:param resp: JmImageResp
:param filepath: 图片文件路径
:param need_convert: 是否转换图片
"""
if need_convert is False:
cls.save_directly(resp, filepath)
else:
cls.save_image(cls.open_image(resp.content), filepath)
@classmethod
def save_image(cls, image: Image, filepath: str):
"""
保存图片
:param image: PIL.Image对象
:param filepath: 保存文件路径
"""
image.save(filepath)
@classmethod
def save_directly(cls, resp, filepath):
from common import save_resp_content
save_resp_content(resp, filepath)
@classmethod
def decode_and_save(cls,
num: int,
img_src: Image,
decoded_save_path: str
) -> None:
"""
解密图片并保存
:param num: 分割数,可以用 cls.calculate_segmentation_num 计算
:param img_src: 原始图片
:param decoded_save_path: 解密图片的保存路径
"""
# 无需解密,直接保存
if num == 0:
cls.save_image(img_src, decoded_save_path)
return
import math
w, h = img_src.size
# 创建新的解密图片
img_decode = Image.new("RGB", (w, h))
over = h % num
for i in range(num):
move = math.floor(h / num)
y_src = h - (move * (i + 1)) - over
y_dst = move * i
if i == 0:
move += over
else:
y_dst += over
img_decode.paste(
img_src.crop((
0, y_src,
w, y_src + move
)),
(
0, y_dst,
w, y_dst + move
)
)
# save every step result
# cls.save_image(img_decode, change_file_name(
# decoded_save_path,
# f'{of_file_name(decoded_save_path, trim_suffix=True)}_{i}{of_file_suffix(decoded_save_path)}'
# ))
# 保存到新的解密文件
cls.save_image(img_decode, decoded_save_path)
@classmethod
def open_image(cls, fp: Union[str, bytes]):
from io import BytesIO
fp = fp if isinstance(fp, str) else BytesIO(fp)
return Image.open(fp)
@classmethod
def get_num(cls, scramble_id, aid, filename: str) -> int:
"""
获得图片分割数
"""
scramble_id = int(scramble_id)
aid = int(aid)
if aid < scramble_id:
return 0
elif aid < JmMagicConstants.SCRAMBLE_268850:
return 10
else:
import hashlib
x = 10 if aid < JmMagicConstants.SCRAMBLE_421926 else 8
s = f"{aid}{filename}" # 拼接
s = s.encode()
s = hashlib.md5(s).hexdigest()
num = ord(s[-1])
num %= x
num = num * 2 + 2
return num
@classmethod
def get_num_by_url(cls, scramble_id, url) -> int:
"""
获得图片分割数
"""
return cls.get_num(
scramble_id,
aid=JmcomicText.parse_to_jm_id(url),
filename=of_file_name(url, True),
)
@classmethod
def get_num_by_detail(cls, detail: JmImageDetail) -> int:
"""
获得图片分割数
"""
return cls.get_num(detail.scramble_id, detail.aid, detail.img_file_name)
class JmCryptoTool:
"""
禁漫加解密相关逻辑
"""
@classmethod
def token_and_tokenparam(cls,
ts,
ver=None,
secret=None,
):
"""
计算禁漫接口的请求headers的token和tokenparam
:param ts: 时间戳
:param ver: app版本
:param secret: 密钥
:return (token, tokenparam)
"""
if ver is None:
ver = JmMagicConstants.APP_VERSION
if secret is None:
secret = JmMagicConstants.APP_TOKEN_SECRET
# tokenparam: 1700566805,1.6.3
tokenparam = '{},{}'.format(ts, ver)
# token: 81498a20feea7fbb7149c637e49702e3
token = cls.md5hex(f'{ts}{secret}')
return token, tokenparam
@classmethod
def decode_resp_data(cls,
data: str,
ts,
secret=None,
) -> str:
"""
解密接口返回值
:param data: resp.json()['data']
:param ts: 时间戳
:param secret: 密钥
:return: json格式的字符串
"""
if secret is None:
secret = JmMagicConstants.APP_DATA_SECRET
# 1. base64解码
import base64
data_b64 = base64.b64decode(data)
# 2. AES-ECB解密
key = cls.md5hex(f'{ts}{secret}').encode('utf-8')
from Crypto.Cipher import AES
data_aes = AES.new(key, AES.MODE_ECB).decrypt(data_b64)
# 3. 移除末尾的padding
data = data_aes[:-data_aes[-1]]
# 4. 解码为字符串 (json)
res = data.decode('utf-8')
return res
@classmethod
def md5hex(cls, key: str):
ExceptionTool.require_true(isinstance(key, str), 'key参数需为字符串')
from hashlib import md5
return md5(key.encode("utf-8")).hexdigest()

8
jm/src/pixiv/__init__.py Executable file
View File

@@ -0,0 +1,8 @@
from pixivpy3 import AppPixivAPI
api = AppPixivAPI()
# 作品推荐
json_result = api.illust_recommended()
print(json_result)
illust = json_result.illusts[0]
print(f">>> {illust.title}, origin url: {illust.image_urls.large}")

12
reijm-read/.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

35
reijm-read/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
# 环境变量文件
.env*
!.env.example
.env

8
reijm-read/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
reijm-read/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/unionvue.iml" filepath="$PROJECT_DIR$/.idea/unionvue.iml" />
</modules>
</component>
</project>

12
reijm-read/.idea/unionvue.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
reijm-read/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

27
reijm-read/LICENSE Normal file
View File

@@ -0,0 +1,27 @@
MIT License
Copyright (c) 2024 Reisa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Additional Terms:
1. The footer copyright notice and author attribution must be preserved.
2. Any modifications to the footer must maintain the original author's credit.
3. Commercial use requires explicit permission from the author.

120
reijm-read/README.md Normal file
View File

@@ -0,0 +1,120 @@
# ReisaWork0604
一个使用 Vue 3 + TypeScript + Vite 构建的现代化个人主页,具有博客文章展示、项目展示、联系表单等功能。
- Reisa 改编
## 特性
- 🚀 使用 Vue 3 + TypeScript + Vite 构建
- 🎨 支持深色模式
- 📱 响应式设计,支持移动端
- ⚡️ 快速加载和页面切换
- 🔍 SEO 友好
- 🌐 支持多语言
- 📝 Markdown 博客支持
- 📦 组件自动导入
- 🎯 TypeScript 类型安全
- 🔧 可配置的主题
## 技术栈
- Vue 3
- TypeScript
- Vite
- Vue Router
- TailwindCSS
- PostCSS
- ESLint + Prettier
- Husky + lint-staged
## 开发
```bash
# 克隆项目
git clone https://github.com/Spaso1/ReisaPage.git
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建生产版本
pnpm build
# 预览生产构建
pnpm preview
# 代码格式化
pnpm format
# 代码检查
pnpm lint
```
## 项目结构
```
├── public/ # 静态资源
├── src/
│ ├── assets/ # 项目资源
│ ├── components/ # 组件
│ ├── config/ # 配置文件
│ ├── layouts/ # 布局组件
│ ├── pages/ # 页面
│ ├── router/ # 路由配置
│ ├── styles/ # 样式文件
│ ├── types/ # TypeScript 类型
│ ├── utils/ # 工具函数
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── .env # 环境变量
├── index.html # HTML 模板
├── package.json # 项目配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
└── README.md # 项目说明
```
## 配置
### 站点配置
`src/config/site.ts` 中配置站点基本信息:
```typescript
export const siteConfig = {
name: "Your Site Name",
description: "Your site description",
// ...其他配置
};
```
### 主题配置
`src/config/theme.ts` 中配置主题相关选项:
```typescript
export const themeConfig = {
colors: {
primary: "#2196f3",
// ...其他颜色
},
// ...其他主题配置
};
```
## 部署
项目可以部署到任何静态网站托管服务:
```bash
# 构建项目
pnpm build
# 部署 dist 目录
```
## 许可证
[MIT](./LICENSE)

BIN
reijm-read/dist.zip Normal file

Binary file not shown.

BIN
reijm-read/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

58
reijm-read/index.html Normal file
View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./src/assets/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 基础 Meta 标签 -->
<title>Powered by Reisa</title>
<meta name="description" content="Union 官网" />
<meta name="keywords" content="ReisaPage,Vue,Vite,ServerMonitoring,FindMaimai,Maimai,Reisa,Spasol" />
<meta name="author" content="Reisa" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.godserver.cn" />
<meta property="og:title" content="Reisa Spasol" />
<meta property="og:description" content="Union 网站" />
<meta property="og:image" content="/src/assets/logo.png" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:site_name" content="Reisa Spasol" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Spaso1" />
<meta name="twitter:title" content="Reisa Spasol" />
<meta name="twitter:description" content="Reisa 个人网站" />
<meta name="twitter:image" content="/src/assets/logo.png" />
<!-- 主题色 -->
<meta name="theme-color" content="#42b983" />
<!-- Schema.org 结构化数据 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": " Powered by Reisa",
"url": "https://www.godserver.cn",
"logo": "/src/assets/logo.png",
"sameAs": ["https://github.com/Spaso1", "https://twitter.com/Spaso1"]
}
</script>
<!-- 字体预加载 -->
<link
rel="preload"
href="https://cdn.godserver.cn/resource/lxwk.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

71
reijm-read/package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "home-for-vue",
"version": "1.0.0",
"license": "MIT",
"author": {
"name": "Reisa Spasol",
"url": "https://www.godserver.cn/"
},
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"serve": "vite run"
},
"dependencies": {
"@emailjs/browser": "^4.4.1",
"axios": "^1.8.4",
"crypto-js": "^4.2.0",
"element-plus": "^2.9.9",
"jsqr": "^1.4.0",
"marked": "^15.0.10",
"mitt": "^3.0.1",
"otplib": "^12.0.1",
"pinia": "^2.1.7",
"qrcode.vue": "^3.6.0",
"rss-parser": "^3.13.0",
"uuid": "^11.1.0",
"vue": "^3.4.3",
"vue-router": "^4.2.5",
"vuetify": "^3.8.0-beta.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node18": "^18.2.2",
"@types/node": "^20.17.10",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.0",
"autoprefixer": "^10.4.16",
"cesium": "^1.129.0",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"imagemin": "^9.0.0",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-optipng": "^8.0.0",
"imagemin-pngquant": "^10.0.0",
"imagemin-svgo": "^11.0.1",
"noise": "~0.0.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.32",
"prettier": "^3.0.3",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.0",
"terser": "^5.37.0",
"typescript": "~5.3.0",
"vite": "^5.4.19",
"vite-plugin-cesium": "^1.2.23",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^1.1.8",
"vite-plugin-imagemin": "^0.6.1",
"vue-tsc": "^1.8.25"
}
}

8137
reijm-read/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,22 @@
{
"name": "ReisaSpasol | MaimaiDX",
"short_name": "ReisaSpasol",
"description": "专注于Java、Spring Boot、微服务等后端技术开发的个人作品集网站",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"icons": [
{
"src": "./assets/icon.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./assets/icon.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: <%= config.siteUrl %>/sitemap.xml

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc><%= config.siteUrl %>/</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc><%= config.siteUrl %>/blog</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc><%= config.siteUrl %>/skills</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc><%= config.siteUrl %>/contact</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

148
reijm-read/src/App.vue Normal file
View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { useRoute, useRouter, type RouteMeta } from "vue-router";
import { RouterView } from "vue-router";
import TheHeader from "./components/layout/TheHeader.vue";
import TheFooter from "./components/layout/TheFooter.vue";
import PageTransition from "./components/PageTransition.vue";
import Toast from "./components/ui/Toast.vue";
import Modal from "./components/ui/Modal.vue";
import type { NoticeButton } from "./types/notice";
import { siteConfig } from "@/config";
import { siteInfo } from "./config/site-info";
import { printConsoleInfo } from "@/utils/console";
const route = useRoute();
const router = useRouter();
// 是否为开发环境
const isDev = import.meta.env.DEV;
document.documentElement.classList.add("dark-mode");
// 监听路由变化更新页面标题和描述
watch(
() => route.meta,
(meta: RouteMeta) => {
if (meta.title) {
document.title = `${meta.title} | ${siteConfig.name}`;
}
if (meta.description) {
document
.querySelector('meta[name="description"]')
?.setAttribute("content", meta.description as string);
}
if (meta.keywords) {
document
.querySelector('meta[name="keywords"]')
?.setAttribute("content", meta.keywords as string);
}
// 更新 Open Graph 标签
document
.querySelector('meta[property="og:title"]')
?.setAttribute("content", meta.title as string);
document
.querySelector('meta[property="og:description"]')
?.setAttribute("content", meta.description as string);
},
);
const showNotice = ref(false);
// 处理按钮点击
const handleNoticeAction = (button: NoticeButton) => {
const now = Date.now();
// 处理按钮动作
switch (button.action) {
case "close":
showNotice.value = false;
break;
case "navigate":
showNotice.value = false;
if (button.to) {
router.push(button.to);
}
break;
case "link":
if (button.href) {
window.open(button.href, "_blank");
}
showNotice.value = false;
break;
case "custom":
if (button.handler) {
button.handler();
}
showNotice.value = false;
break;
}
};
import { computed } from 'vue'
// 计算是否应该隐藏头部
const shouldHideHeader = computed(() => {
return route.meta.hideHeader === true
})
onMounted(() => {
// 打印控制台信息
printConsoleInfo({
text: siteInfo.text,
version: siteInfo.version,
link: siteInfo.link,
});
});
</script>
<template>
<div class="min-h-screen flex flex-col loading='lazy' dark:bg-gray-900/50">
<TheHeader v-if="!shouldHideHeader" />
<main class="flex-grow" :class="{ 'pt-0': shouldHideHeader, 'pt-16': !shouldHideHeader }">
<router-view v-slot="{ Component }">
<PageTransition :name="(route.meta.transition as string) || 'fade'">
<component :is="Component" />
</PageTransition>
</router-view>
</main>
<!-- <TheFooter />-->
<Toast />
</div>
</template>
<style scoped>
.min-h-screen {
position: relative; /* 添加相对定位 */
background-image: url('@/assets/a.jpg');
background-size: cover; /* 背景图片覆盖整个元素 */
background-position: center; /* 背景图片居中 */
background-repeat: no-repeat; /* 防止背景图片重复 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.dark .min-h-screen::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5); /* 半透明黑色遮罩 */
z-index: -1;
}
.min-h-screen::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: inherit; /* 继承背景图片 */
background-size: cover; /* 背景图片覆盖整个元素 */
background-position: center; /* 背景图片居中 */
background-repeat: no-repeat; /* 防止背景图片重复 */
opacity: 0.3; /* 调整透明度 */
z-index: -1; /* 确保伪元素在内容下方 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

BIN
reijm-read/src/assets/a.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,48 @@
:root {
/* 主色调 */
--color-primary: #3b82f6;
--color-primary-dark: #2563eb;
--color-primary-light: #60a5fa;
--color-primary-10: rgba(59, 130, 246, 0.1);
/* 背景色 */
--color-bg-main: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f3f4f6;
/* 文本颜色 */
--color-text-primary: #111827;
--color-text-secondary: #4b5563;
--color-text-tertiary: #6b7280;
/* 边框颜色 */
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
/* 卡片阴影 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
/* 深色模式 */
:root[class~="dark"] {
/* 背景色 */
--color-bg-main: #111827;
--color-bg-secondary: #1f2937;
--color-bg-tertiary: #374151;
/* 文本颜色 */
--color-text-primary: #f9fafb;
--color-text-secondary: #e5e7eb;
--color-text-tertiary: #d1d5db;
/* 边框颜色 */
--color-border: #374151;
--color-border-light: #1f2937;
/* 卡片阴影 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
}

View File

@@ -0,0 +1,72 @@
@import "./colors.css";
@import "./variables.css";
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* 添加字体定义 */
@font-face {
font-family: "LXWK";
font-weight: 100 900;
font-display: swap;
font-style: normal;
src: url("https://cdn.jsdmirror.com/gh/acanyo/mmm.sd@master/assets/font/lxwk.woff2")
format("woff2");
}
@layer base {
html {
font-family:
"LXWK",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
scroll-behavior: smooth;
-webkit-tap-highlight-color: transparent;
}
body {
@apply bg-main text-primary antialiased;
}
/* 移动端优化 */
@media (max-width: 768px) {
html {
font-size: 14px;
}
}
/* 移动端点击态优化 */
@media (hover: none) {
.hover\:scale-105:active {
transform: scale(1.02);
}
}
}
@layer components {
.btn-primary {
@apply inline-block px-6 py-3 bg-primary text-white rounded-lg
hover:bg-primary-dark transition-colors duration-300;
}
.btn-secondary {
@apply inline-block px-6 py-3 border-2 border-primary text-primary rounded-lg
hover:bg-primary hover:text-white transition-colors duration-300;
}
.card {
@apply bg-main border border-light rounded-2xl shadow-sm
hover:shadow-md transition-all duration-300;
}
}
/* 移动端滚动优化 */
.smooth-scroll {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}

View File

@@ -0,0 +1,16 @@
:root {
--font-family-custom: "LXWK";
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-family: var(--font-family-custom), var(--font-family-system);
}
/* 添加字体定义 */
@font-face {
font-family: "LXWK";
font-weight: 100 900;
font-display: swap;
font-style: normal;
src: url("https://cdn.jsdmirror.com/gh/acanyo/mmm.sd@master/assets/font/lxwk.woff2")
format("woff2");
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
defineProps<{
name?: string;
}>();
</script>
<template>
<transition :name="name || 'fade'" mode="out-in" appear>
<slot></slot>
</transition>
</template>
<style>
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 滑动 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 缩放 */
.scale-enter-active,
.scale-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.95);
}
/* 弹跳 */
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0.95);
opacity: 0;
}
50% {
transform: scale(1.05);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="progress-bar-container">
<div class="progress-bar-label">
<span>{{ label }}</span>
<span class="progress-bar-value">{{ value }}%</span>
</div>
<div class="progress-bar">
<div
class="progress-bar-fill"
:style="{ width: `${value}%`, opacity: isVisible ? 1 : 0 }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
interface ProgressBarProps {
label: string;
value: number;
}
const props = defineProps<ProgressBarProps>();
const isVisible = ref(false);
onMounted(() => {
setTimeout(() => {
isVisible.value = true;
}, 500);
});
</script>
<style scoped>
.progress-bar-container {
@apply mb-4;
}
.progress-bar-label {
@apply flex justify-between items-center mb-1;
}
.progress-bar-value {
@apply font-medium text-blue-500;
}
.progress-bar {
@apply h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden relative;
}
.progress-bar-fill {
@apply h-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 transition-all duration-1000 ease-out;
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
const isDark = ref(false);
const toggleTheme = () => {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
localStorage.setItem("theme", isDark.value ? "dark" : "light");
};
onMounted(() => {
// 检查系统主题偏好
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// 获取保存的主题设置
const savedTheme = localStorage.getItem("theme");
// 如果有保存的主题设置就使用,否则跟随系统
isDark.value = savedTheme === "dark" || (!savedTheme && prefersDark);
document.documentElement.classList.toggle("dark", isDark.value);
// 监听系统主题变化
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
if (!localStorage.getItem("theme")) {
isDark.value = e.matches;
document.documentElement.classList.toggle("dark", isDark.value);
}
});
});
</script>
<template>
<button
@click="toggleTheme"
class="relative w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200"
aria-label="切换主题"
>
<div class="absolute inset-0 flex items-center justify-center">
<transition name="theme-toggle" mode="out-in">
<!-- 暗色主题图标 -->
<svg
v-if="isDark"
key="dark"
class="w-5 h-5 text-yellow-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
/>
</svg>
<!-- 亮色主题图标 -->
<svg
v-else
key="light"
class="w-5 h-5 text-gray-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
/>
</svg>
</transition>
</div>
</button>
</template>
<style scoped>
.theme-toggle-enter-active,
.theme-toggle-leave-active {
transition: all 0.3s ease;
}
.theme-toggle-enter-from,
.theme-toggle-leave-to {
opacity: 0;
transform: rotate(30deg) scale(0.8);
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<canvas
ref="canvas"
class="fixed inset-0 pointer-events-none z-[100] transition-opacity duration-1000"
:class="{ 'opacity-0': shouldFadeOut }"
></canvas>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
interface Props {
enabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
enabled: true,
});
const canvas = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
let confetti: Confetti[] = [];
let animationId: number;
const shouldFadeOut = ref(false);
interface Confetti {
x: number;
y: number;
rotation: number;
rotationSpeed: number;
vx: number;
vy: number;
width: number;
height: number;
color: string;
opacity: number;
}
// 彩带颜色 - 使用亮色系
const colors = [
"#FF69B4", // 粉红
"#87CEEB", // 天蓝
"#98FB98", // 浅绿
"#DDA0DD", // 梅红
"#F0E68C", // 卡其
"#FFB6C1", // 浅粉
"#87CEFA", // 浅天蓝
"#FFA07A", // 浅鲑鱼色
];
const createConfetti = () => {
const x = Math.random() * canvas.value!.width;
const y = canvas.value!.height;
return {
x,
y,
rotation: Math.random() * 360,
rotationSpeed: (Math.random() - 0.5) * 2,
vx: (Math.random() - 0.5) * 3,
vy: -Math.random() * 15 - 10, // 向上的初始速度
width: Math.random() * 10 + 5,
height: Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
opacity: 1,
};
};
const drawConfetti = (confetti: Confetti) => {
if (!ctx) return;
ctx.save();
ctx.translate(confetti.x, confetti.y);
ctx.rotate((confetti.rotation * Math.PI) / 180);
ctx.globalAlpha = confetti.opacity;
ctx.fillStyle = confetti.color;
ctx.fillRect(
-confetti.width / 2,
-confetti.height / 2,
confetti.width,
confetti.height,
);
ctx.restore();
};
const animate = () => {
if (!ctx || !canvas.value) return;
// 清除画布,保持透明背景
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
// 更新和绘制所有彩带
for (let i = confetti.length - 1; i >= 0; i--) {
const conf = confetti[i];
// 更新位置
conf.x += conf.vx;
conf.y += conf.vy;
conf.vy += 0.2; // 重力
conf.rotation += conf.rotationSpeed;
// 轻微的左右摆动
conf.vx += (Math.random() - 0.5) * 0.1;
// 减少透明度
conf.opacity -= 0.005;
// 如果彩带消失或飞出屏幕,则移除
if (conf.opacity <= 0 || conf.y > canvas.value.height) {
confetti.splice(i, 1);
continue;
}
drawConfetti(conf);
}
// 持续添加新的彩带
if (Math.random() < 0.1) {
for (let i = 0; i < 3; i++) {
confetti.push(createConfetti());
}
}
animationId = requestAnimationFrame(animate);
};
const resizeCanvas = () => {
if (!canvas.value || !ctx) return;
canvas.value.width = window.innerWidth;
canvas.value.height = window.innerHeight;
};
onMounted(() => {
if (!canvas.value) return;
ctx = canvas.value.getContext("2d");
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// 初始发射一批彩带
for (let i = 0; i < 50; i++) {
confetti.push(createConfetti());
}
animate();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeCanvas);
if (animationId) {
cancelAnimationFrame(animationId);
}
});
setTimeout(() => {
shouldFadeOut.value = true;
}, 3000);
</script>

View File

@@ -0,0 +1,82 @@
<template>
<footer
class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
ref="footerRef"
>
<div class="container mx-auto px-4 py-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<!-- 左侧版权信息 -->
<div
class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400"
ref="copyrightRef"
>
<span>© {{ currentYear }}</span>
<a
href="https://www.godserver.cn/"
target="_blank"
class="font-medium hover:text-blue-500 transition-colors"
>
Reisa
</a>
<span>. All rights reserved. 鄂ICP备2021014649号-2</span>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
import type { RouterLinkProps } from "vue-router";
import { useRouter } from "vue-router";
import { footerConfig } from "@/config/footer";
import { createCopyrightGuard } from "@/utils/copyright";
import { siteConfig } from "@/config/site";
const router = useRouter();
const footerRef = ref<HTMLElement | null>(null);
const copyrightRef = ref<HTMLElement | null>(null);
const guard = createCopyrightGuard;
// 定期检查版权信息
let intervalId: number;
let randomInterval: number;
const currentYear = computed(() => new Date().getFullYear());
onMounted(() => {
// 初始检查
guard(copyrightRef.value);
// 随机间隔检查
const check = () => {
guard(copyrightRef.value);
randomInterval = window.setTimeout(check, Math.random() * 2000 + 1000);
};
check();
// 固定间隔检查
intervalId = window.setInterval(() => guard(copyrightRef.value), 1000);
// 添加DOM变化监听
const observer = new MutationObserver(() => guard(copyrightRef.value));
if (copyrightRef.value) {
observer.observe(copyrightRef.value, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
}
});
onBeforeUnmount(() => {
if (intervalId) {
window.clearInterval(intervalId);
}
if (randomInterval) {
window.clearTimeout(randomInterval);
}
});
</script>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import ThemeToggle from "@/components/ThemeToggle.vue";
import eventBus from "@/eventBus";
import axios from "axios";
const route = useRoute();
const isMenuOpen = ref(false);
// 用户信息
const closeMenu = () => {
isMenuOpen.value = false;
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeMenu();
}
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest(".mobile-menu") && !target.closest(".menu-button")) {
closeMenu();
}
};
let refreshInterval: number | null = null;
// 新增刷新token的函数
const refreshToken = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await axios.post('/api/user/ref', {
data: token,
timestamp: Date.now()
}, {
headers: {
'Token' : token,
'Content-Type': 'application/json'
}
});
} catch (error) {
console.error('Refresh token failed:', error);
}
};
// 在组件挂载时添加事件监听
onMounted(() => {
// 每30秒刷新一次token
refreshInterval = window.setInterval(refreshToken, 30000);
window.addEventListener("click", handleClickOutside);
window.addEventListener("keydown", handleKeydown);});
// 在组件卸载时移除事件监听,防止内存泄漏
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
window.removeEventListener("click", handleClickOutside);
window.removeEventListener("keydown", handleKeydown);});
const navItems = [
{ name: "首页", path: "/" },
{ name: "用户", path: "/user" },
];
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
</script>
<template>
<header class="fixed w-full top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
<nav class="container mx-auto px-4 py-3 md:py-4">
<div class="flex items-center justify-between">
<router-link to="/" class="logo-link flex items-center space-x-2">
<span
class="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 bg-clip-text text-transparent bg-[length:200%_auto] hover:animate-gradient whitespace-nowrap"
>
Union
</span>
</router-link>
<!-- 桌面端导航 -->
<div class="hidden md:flex items-center space-x-6">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-link"
:class="{ 'text-primary': route.path === item.path }"
>
{{ item.name }}
</router-link>
<ThemeToggle />
</div>
<!-- 移动端菜单按钮 -->
<div class="md:hidden flex items-center space-x-2">
<ThemeToggle />
<button
class="menu-button p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
@click.stop="toggleMenu"
aria-label="Toggle menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-if="!isMenuOpen"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- 移动端导航菜单 -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-show="isMenuOpen" class="mobile-menu md:hidden">
<div class="py-2 space-y-1">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="block px-4 py-2 text-base hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
:class="{
'bg-primary/10 text-primary': route.path === item.path,
}"
@click="closeMenu"
>
{{ item.name }}
</router-link>
</div>
</div>
</transition>
</nav>
</header>
</template>
<style scoped>
.nav-link {
@apply text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary transition-colors;
}
.mobile-menu {
@apply absolute top-full left-0 right-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm
border-t border-gray-200 dark:border-gray-700 shadow-lg;
}
/* 移动端导航链接悬停效果 */
@media (hover: hover) {
.mobile-menu .router-link-active {
@apply bg-primary-10 text-primary;
}
}
/* Logo 悬停动画 */
.logo-link {
@apply inline-block py-1;
}
.logo-link:hover span:first-child {
@apply transform scale-105 transition-transform duration-300;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.hover\:animate-gradient:hover {
animation: gradient 3s linear infinite;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import PageTransition from "@/components/PageTransition.vue";
defineProps<{
title: string;
description?: string;
}>();
</script>
<template>
<div class="container mx-auto px-4 py-12">
<PageTransition name="bounce">
<div class="max-w-4xl mx-auto text-center mb-12">
<h1 class="text-4xl font-bold mb-4">{{ title }}</h1>
<p v-if="description" class="text-gray-600 dark:text-gray-300">
{{ description }}
</p>
</div>
</PageTransition>
<PageTransition name="fade">
<div class="max-w-6xl mx-auto">
<slot></slot>
</div>
</PageTransition>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
const props = defineProps<{
src: string;
alt: string;
width?: string | number;
height?: string | number;
}>();
const isLoaded = ref(false);
const observer = ref<IntersectionObserver | null>(null);
const imgRef = ref<HTMLImageElement | null>(null);
onMounted(() => {
observer.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && imgRef.value) {
imgRef.value.src = props.src;
isLoaded.value = true;
observer.value?.disconnect();
}
});
});
if (imgRef.value) {
observer.value.observe(imgRef.value);
}
});
</script>
<template>
<div class="relative overflow-hidden">
<img
ref="imgRef"
:alt="alt"
:width="width"
:height="height"
class="transition-opacity duration-300"
:class="{ 'opacity-0': !isLoaded, 'opacity-100': isLoaded }"
loading="lazy"
/>
<div
v-if="!isLoaded"
class="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse"
></div>
</div>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import Fireworks from "@/components/effects/Fireworks.vue";
import { noticeConfig } from "@/config/notice";
interface Props {
show: boolean;
title?: string;
width?: string;
maskClosable?: boolean;
showClose?: boolean;
showFireworks?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
show: false,
title: "",
width: "400px",
maskClosable: true,
showClose: true,
showFireworks: false,
});
const emit = defineEmits<{
(e: "update:show", value: boolean): void;
}>();
const modalRef = ref<HTMLElement | null>(null);
const showFireworks = ref(false);
// 处理点击遮罩层关闭
const handleMaskClick = (e: MouseEvent) => {
if (props.maskClosable && e.target === modalRef.value) {
emit("update:show", false);
}
};
// 处理ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape" && props.show) {
emit("update:show", false);
}
};
// 监听键盘事件
watch(
() => props.show,
(val: boolean) => {
if (val) {
document.addEventListener("keydown", handleKeydown);
} else {
document.removeEventListener("keydown", handleKeydown);
}
},
);
// 监听显示状态
watch(
() => props.show,
(newVal: boolean) => {
if (newVal && props.showFireworks && noticeConfig.showFireworks) {
showFireworks.value = true;
setTimeout(() => {
showFireworks.value = false;
}, 3000);
}
},
);
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="fixed inset-0 z-[90]">
<Fireworks v-if="showFireworks" />
<div
ref="modalRef"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click="handleMaskClick"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl transform transition-all"
:style="{ width }"
>
<!-- 标题栏 -->
<div
v-if="title || showClose"
class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700"
>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ title }}
</h3>
<button
v-if="showClose"
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
@click="emit('update:show', false)"
>
<span class="sr-only">关闭</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- 内容区 -->
<div class="px-6 py-4">
<slot></slot>
</div>
<!-- 按钮区 -->
<div
v-if="$slots.footer"
class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 rounded-b-lg"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from {
opacity: 0;
transform: scale(0.9);
}
.modal-leave-to {
opacity: 0;
transform: scale(1.1);
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from "vue";
interface Tab {
id: string;
label: string;
icon?: string;
}
const props = defineProps<{
tabs: Tab[];
modelValue: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const activeTab = ref(props.modelValue);
const switchTab = (tabId: string) => {
activeTab.value = tabId;
emit("update:modelValue", tabId);
};
</script>
<template>
<div class="flex" :class="$attrs.class">
<button
v-for="tab in tabs"
:key="tab.id"
@click="$emit('update:modelValue', tab.id)"
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-300 min-w-[120px] justify-center"
:class="[
modelValue === tab.id
? 'bg-white dark:bg-gray-800 shadow-md text-primary dark:text-primary-light'
: 'text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-light',
]"
>
<span class="text-lg">{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const isVisible = ref(false);
const message = ref("");
const type = ref<"success" | "error" | "info">("info");
let timeoutId: number;
const show = (
text: string,
messageType: "success" | "error" | "info" = "info",
) => {
clearTimeout(timeoutId);
message.value = text;
type.value = messageType;
isVisible.value = true;
timeoutId = window.setTimeout(() => {
isVisible.value = false;
}, 3000);
};
// 创建全局方法
const toast = {
show,
success: (text: string) => show(text, "success"),
error: (text: string) => show(text, "error"),
info: (text: string) => show(text, "info"),
};
// 挂载到全局
window.$toast = toast;
onUnmounted(() => {
clearTimeout(timeoutId);
});
</script>
<template>
<Teleport to="body">
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-2 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform translate-y-2 opacity-0"
>
<div
v-show="isVisible"
class="fixed bottom-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg"
:class="{
'bg-green-500 text-white': type === 'success',
'bg-red-500 text-white': type === 'error',
'bg-blue-500 text-white': type === 'info',
}"
>
{{ message }}
</div>
</transition>
</Teleport>
</template>

View File

@@ -0,0 +1,89 @@
<template>
<Transition name="fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-6"
>
<div class="text-center">
<span class="text-5xl block mb-4"></span>
<h3 class="text-2xl font-bold mb-4">友情提示</h3>
<div class="space-y-3 text-gray-600 dark:text-gray-300">
<p>为了确保最佳的浏览体验我们暂时禁用了以下功能</p>
<ul class="text-left list-disc list-inside space-y-2">
<li>开发者工具 (F12)</li>
<li>查看源代码 (Ctrl/Cmd + U)</li>
<li>检查元素 (Ctrl/Cmd + Shift + C)</li>
</ul>
<p class="mt-4 text-sm">
如果您是开发者需要调试请访问我们的
<a
href="https://github.com/your-repo"
target="_blank"
class="text-primary hover:underline"
>
GitHub 仓库
</a>
</p>
</div>
</div>
<div class="flex justify-center space-x-4">
<button
@click="close"
class="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
我知道了
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from "vue";
const isOpen = ref(false);
const open = () => {
isOpen.value = true;
document.addEventListener("keydown", handleEscape);
};
const close = () => {
isOpen.value = false;
document.removeEventListener("keydown", handleEscape);
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
close();
}
};
defineExpose({
open,
close,
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: scale(1);
}
</style>

View File

@@ -0,0 +1,42 @@
export interface BlogPost {
title: string;
category: string;
date: Date;
link: string;
data: string;
summary: string; // 添加简介字段
}
export const blogPosts: BlogPost[] = [
{
title: "FindMaimaiDX",
data: `
# FindMaimai
`,
summary: "FindMaimai是一个基于Java开发全平台客户端",
link: "https://example.com/vue3-features",
date: new Date("2023-10-01"),
category: "Maimai",
},
{
title: "TypeScript 在大型项目中的应用",
data: `## TypeScript 在大型项目中的应用
探讨如何在大型项目中使用 TypeScript 提高代码质量和开发效率。
### 类型检查
TypeScript 提供了强大的类型检查功能,帮助开发者减少运行时错误。
### 代码重构
使用 TypeScript 可以更容易地进行代码重构,提高代码的可维护性。`,
link: "https://example.com/typescript-large-projects",
summary: "TypeScript 在大型项目中的应用",
date: new Date("2023-09-15"),
category: "TypeScript",
},
// 添加更多博客文章...
];

View File

@@ -0,0 +1,25 @@
import emailjs from "@emailjs/browser";
interface EmailConfig {
serviceId: string;
templateId: string;
publicKey: string;
}
export const emailConfig: EmailConfig = {
serviceId: import.meta.env.VITE_EMAILJS_SERVICE_ID,
templateId: import.meta.env.VITE_EMAILJS_TEMPLATE_ID,
publicKey: import.meta.env.VITE_EMAILJS_PUBLIC_KEY,
};
export const initEmailJS = () => {
emailjs.init(emailConfig.publicKey);
};
if (
!emailConfig.serviceId ||
!emailConfig.templateId ||
!emailConfig.publicKey
) {
throw new Error("Missing required EmailJS configuration");
}

View File

@@ -0,0 +1,17 @@
interface FontConfig {
enabled: boolean;
name: string;
url: string;
preload?: boolean;
display?: "auto" | "block" | "swap" | "fallback" | "optional";
weights?: string;
}
export const fontConfig: FontConfig = {
enabled: true,
name: "LXWK",
url: "https://cdn.jsdmirror.com/gh/acanyo/mmm.sd@master/assets/font/lxwk.woff2",
preload: true,
display: "swap",
weights: "100 900",
};

View File

@@ -0,0 +1,39 @@
interface FooterLink {
text: string;
to?: string; // 内部路由
href?: string; // 外部链接
target?: string;
}
interface FooterConfig {
links: FooterLink[];
provider: {
name: string;
link: string;
logo: string;
text: string;
};
}
export const footerConfig: FooterConfig = {
links: [
{
text: "博客",
href: "https://www.godserver.cn",
target: "_blank",
},
{
text: "GitHub",
href: "https://github.com/Spaso1",
target: "_blank",
},
],
provider: {
name: "Aliyun",
link: "https://www.aliyun.com/",
logo: "https://avatars.githubusercontent.com/u/172407636?v=4",
text: "提供 CDN 加速 / 云存储服务",
},
};

View File

@@ -0,0 +1,46 @@
import { siteConfig } from "./site";
// 导出所有配置
export { siteConfig };
// 合并基础配置
export const config = {
...siteConfig,
siteUrl: "https://www.godserver.cn", // 默认值
};
// 生成 sitemap.xml 内容
export const generateSitemap = (
siteUrl: string,
) => `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${siteUrl}/</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${siteUrl}/blog</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>${siteUrl}/skills</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>${siteUrl}/contact</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>`;
// 生成 robots.txt 内容
export const generateRobots = (siteUrl: string) => `User-agent: *
Allow: /
Sitemap: ${siteUrl}/sitemap.xml`;

View File

@@ -0,0 +1,11 @@
export interface Tab {
id: string;
label: string;
icon: string;
}
export const tabs: Tab[] = [
{ id: "projects", label: "项目展示", icon: "🎨" },
{ id: "tools", label: "在线工具", icon: "🛠" },
{ id: "bookmarks", label: "网址导航", icon: "🔖" },
];

View File

@@ -0,0 +1,53 @@
import type { NoticeButton, NoticeConfig } from "../types/notice";
interface ExtendedNoticeButton extends NoticeButton {
type: "primary" | "secondary" | "danger";
}
interface ExtendedNoticeConfig extends NoticeConfig {
enabled: boolean;
showFireworks: boolean;
defaultShowAfter?: number | "refresh" | null;
buttons: ExtendedNoticeButton[];
}
export const noticeConfig: ExtendedNoticeConfig = {
id: "site_notice_v1",
enabled: true,
showFireworks: true,
title: "网站公告",
content: `
<div class="text-center">
<p class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
🎉 网站改版升级公告
</p>
<div class="text-gray-600 dark:text-gray-400 space-y-2">
<p>网站已完成改版升级,新增以下功能:</p>
<ul class="list-disc list-inside">
<li>全新的深色模式支持</li>
<li>性能优化与体验提升</li>
<li>更多实用工具正在开发中</li>
</ul>
</div>
</div>
`,
width: "500px",
maskClosable: true,
showClose: true,
defaultShowAfter: null,
buttons: [
{
text: "稍后查看",
type: "secondary",
action: "close",
showAfter: "refresh",
},
{
text: "立即体验",
type: "primary",
action: "navigate",
to: "/projects",
showAfter: 3 * 60 * 60 * 1000,
},
],
};

View File

@@ -0,0 +1,41 @@
export interface Project {
id: number;
title: string;
description: string;
tags: string[];
image: string;
link?: string;
status: "completed" | "developing" | "planning";
}
export const projects: Project[] = [
{
id: 2,
title: "FindMaimaiUltra",
description:
"全新重构版本 Powered By Reisa",
tags: ["技术分享", "Blog", "Markdown","舞萌DX","中二节奏","B50","查分器","旅行"],
image: "https://picsum.photos/800/600?random=3",
link: "https://github.com/Spaso1/FindMaimaiDX_Phone",
status: "completed",
},
{
id: 3,
title: "EasyTop",
description: "服务状态监控页面,实时监控各项服务的运行状态。",
tags: ["监控", "服务状态", "实时数据"],
image: "https://picsum.photos/800/600?random=4",
link: "https://github.com/Spaso1/EasyTop",
status: "completed",
},
{
id: 4,
title: "AsTrip",
description:
"旅行规划软件",
tags: ["数据分析", "统计", "开源","旅行"],
image: "https://picsum.photos/800/600?random=5",
link: "https://github.com/Spaso1/Astrip",
status: "completed",
},
];

View File

@@ -0,0 +1,7 @@
interface RssConfig {
url: string;
}
export const rssConfig: RssConfig = {
url: "https://www.godserver.cn/rss.xml", // 直接使用完整 URL
};

View File

@@ -0,0 +1,20 @@
interface SiteInfo {
enabled: boolean;
text: string;
link: string;
position?: "top" | "bottom";
theme?: "dark" | "light";
style?: string;
linkStyle?: string;
version?: string;
}
export const siteInfo: SiteInfo = {
enabled: true,
text: "一个使用 Vue 3 + TypeScript + Vite 构建的现代化个人主页,具有博客文章展示、项目展示、联系表单等功能。",
version: "V.2.3",
link: "https://github.com/Spaso1/ReisaPage",
position: "bottom",
theme: "dark",
style: "position: fixed; bottom: 0; left: 0; width: 100%; z-index: 1000;",
};

View File

@@ -0,0 +1,39 @@
export const siteConfig = {
// 基本信息
name: "Powered by Reisa", // 作者名称
title: "FindMaimaiDX开发者 学生", // 职位头衔
siteName: "ReisaSpasol | MaimaiDX", // 网站标题
siteDescription:
"专注于Java、Spring Boot、微服务等后端技术开发的个人作品集网站", // 网站描述
author: "ReisaSpasol", // 作者信息
// 图片资源配置
images: {
logo: "./assets/icon.png", // 网站Logo
icon: "./assets/icon.png", // 网站图标
avatar: "./assets/icon.png", // 个人头像
ogImage: "./assets/icon.png", // 社交分享图片
},
// 个性化配置
slogan: "Use FindMaimai!", // 个性签名
skills: ["Java", "Spring Boot", "MySQL", "Vue", "Docker", "Git","FindMaimai"], // 技能标签
// SEO 相关配置
language: "zh-CN", // 网站语言
themeColor: "#4F46E5", // 主题色
twitterHandle: "@Spasolmodlic", // Twitter账号
githubHandle: "Spaso1", // GitHub账号
// Schema.org 结构化数据
organization: {
name: "Reisa", // 组织名称
logo: "./assets/icon.png", // 组织Logo
},
// 社交媒体链接
social: {
github: "https://github.com/acanyo", // GitHub主页
email: "astralpath@163.com", // 联系邮箱
},
};

View File

@@ -0,0 +1,33 @@
import JsonFormatterView from "@/views/tools/JsonFormatterView.vue";
import TimestampView from "@/views/tools/TimestampView.vue";
export interface Tool {
id: number;
title: string;
description: string;
tags: string[];
image: string;
component: any;
status: "completed" | "developing" | "planning";
}
export const tools: Tool[] = [
{
id: 1,
title: "JSON 格式化工具",
description: "在线 JSON 格式化工具,支持压缩、美化、验证和转换等功能",
tags: ["JSON", "格式化", "在线工具"],
image: "https://picsum.photos/800/600?random=1",
component: JsonFormatterView,
status: "completed",
},
{
id: 2,
title: "时间戳转换器",
description: "时间戳与日期格式互转工具,支持多种格式和时区设置",
tags: ["时间戳", "日期转换", "时区"],
image: "https://picsum.photos/800/600?random=2",
component: TimestampView,
status: "completed",
},
];

167
reijm-read/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,167 @@
/// <reference types="vite/client" />
declare global {
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly VITE_APP_DESCRIPTION: string;
readonly VITE_APP_KEYWORDS: string;
readonly VITE_APP_AUTHOR: string;
readonly VITE_APP_URL: string;
readonly VITE_APP_LOGO: string;
readonly VITE_APP_GITHUB: string;
readonly VITE_APP_TWITTER: string;
readonly VITE_APP_TWITTER_URL: string;
readonly VITE_APP_THEME_COLOR: string;
readonly VITE_EMAILJS_SERVICE_ID: string;
readonly VITE_EMAILJS_TEMPLATE_ID: string;
readonly VITE_EMAILJS_PUBLIC_KEY: string;
readonly VITE_SITE_URL: string;
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
readonly hot?: {
readonly data: any;
accept(): void;
accept(cb: (mod: any) => void): void;
accept(dep: string, cb: (mod: any) => void): void;
accept(deps: string[], cb: (mods: any[]) => void): void;
prune(cb: () => void): void;
dispose(cb: (data: any) => void): void;
decline(): void;
invalidate(): void;
on(event: string, cb: (...args: any[]) => void): void;
};
readonly glob: (glob: string) => Record<string, () => Promise<any>>;
}
}
// Vue 组件类型声明
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
// Vue 宏命令类型声明
declare module "vue" {
import type { DefineComponent, Ref } from "vue";
// 生命周期钩子
export declare const onMounted: (cb: () => void) => void;
export declare const onBeforeMount: (cb: () => void) => void;
export declare const onBeforeUnmount: (cb: () => void) => void;
export declare const onUnmounted: (cb: () => void) => void;
export declare const onActivated: (cb: () => void) => void;
export declare const onDeactivated: (cb: () => void) => void;
export declare const onBeforeUpdate: (cb: () => void) => void;
export declare const onUpdated: (cb: () => void) => void;
export declare const onErrorCaptured: (cb: (err: unknown) => void) => void;
// 组合式 API
export declare const createApp: any;
export declare const ref: <T>(value: T) => Ref<T>;
export declare const computed: <T>(getter: () => T) => Ref<T>;
export declare const watch: typeof import("vue").watch;
export declare const watchEffect: (effect: () => void) => void;
export declare const reactive: <T extends object>(target: T) => T;
export declare const readonly: <T extends object>(target: T) => Readonly<T>;
// 组件相关
export declare const defineProps: {
<T extends Record<string, any>>(): Readonly<T>;
<T extends Record<string, any>>(props: T): Readonly<T>;
};
export declare const defineEmits: {
<T extends Record<string, any>>(): T;
<T extends Record<string, any>>(emits: T): T;
};
export declare const defineExpose: (exposed?: Record<string, any>) => void;
export declare const withDefaults: <
Props,
Defaults extends { [K in keyof Props]?: Props[K] },
>(
props: Props,
defaults: Defaults,
) => {
[K in keyof Props]: K extends keyof Defaults ? Defaults[K] : Props[K];
};
}
// 第三方模块声明
declare module "vite" {
import type { UserConfig, Plugin } from "vite";
export interface ViteConfig extends UserConfig {
plugins?: Plugin[];
}
export const defineConfig: <T extends ViteConfig>(config: T) => T;
}
declare module "vue-router" {
import type { Component } from "vue";
export interface RouteMeta {
title?: string;
description?: string;
keywords?: string;
transition?: string;
requiresAuth?: boolean;
[key: string]: any;
}
export interface RouteRecordRaw {
path: string;
name?: string;
component?: Component | (() => Promise<Component>);
components?: { [key: string]: Component };
redirect?: string | { name: string };
meta?: RouteMeta;
children?: RouteRecordRaw[];
}
export interface Router {
push(to: string | { name: string; params?: any }): Promise<void>;
}
export interface Route {
meta: RouteMeta;
params: Record<string, string>;
query: Record<string, string>;
hash: string;
path: string;
fullPath: string;
matched: RouteRecordRaw[];
}
// 添加 RouterLink 组件类型
export interface RouterLinkProps {
to: string | { name: string; params?: Record<string, any> };
replace?: boolean;
activeClass?: string;
exactActiveClass?: string;
custom?: boolean;
ariaCurrentValue?: string;
}
export const RouterLink: Component<RouterLinkProps>;
export const RouterView: Component;
export const createRouter: any;
export const createWebHistory: any;
export const useRoute: () => Route;
export const useRouter: () => Router;
}
declare module "@emailjs/browser" {
const emailjs: any;
export default emailjs;
}
declare module "vite-plugin-compression";
declare module "vite-plugin-image-optimizer";

View File

@@ -0,0 +1,9 @@
import mitt from 'mitt';
type Events = {
'refresh-user-info': void;
};
const eventBus = mitt<Events>();
export default eventBus;

14
reijm-read/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./assets/styles/main.css";
import { initFontLoading } from "./utils/font";
const app = createApp(App);
app.use(router);
app.mount("#app");
// 初始化字体加载
initFontLoading().then(() => {
console.log("Font initialization complete");
});

View File

@@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "home",
component: () => import("@/views/HomeView.vue"),
meta: {
title: "ReiJM",
},
},
{
path: "/user",
name: "user",
component: () => import("@/views/User.vue"),
meta: {
title: "user",
}
},
{
path: "/manga/:id",
name: "manga",
component: () => import("@/views/Manga.vue"),
meta: {
title: "manga",
hideHeader: true // 添加此元信息
}
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
return { top: 0 };
},
});
// 路由标题
router.beforeEach((to, from, next) => {
document.title = `${to.meta.title || "首页"} | Reisa`;
next();
});
export default router;

View File

@@ -0,0 +1,51 @@
export interface BlogPost {
title: string;
link: string;
content: string;
creator: string;
pubDate: string;
categories?: string[];
description?: string;
}
export async function fetchBlogPosts(rssUrl: string): Promise<BlogPost[]> {
try {
const response = await fetch(rssUrl, {
headers: {
Accept: "application/xml, text/xml, */*",
},
});
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const items = xmlDoc.querySelectorAll("item");
return Array.from(items).map((item) => {
const getElementText = (tagName: string) =>
item.querySelector(tagName)?.textContent?.trim() || "";
const getCleanContent = (content: string) => {
return content.replace("<![CDATA[", "").replace("]]>", "");
};
const description = getElementText("description");
const content = description.includes("CDATA")
? getCleanContent(description)
: description;
return {
title: getCleanContent(getElementText("title")),
link: getElementText("link"),
content: content,
creator: "Reisa",
pubDate: getElementText("pubDate"),
categories: [getElementText("category")].filter(Boolean),
description: content,
};
});
} catch (error) {
console.error("获取博客文章失败:", error);
return [];
}
}

79
reijm-read/src/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,8 @@
export interface BlogPost {
title: string;
link: string;
date: Date;
description: string;
category?: string;
image?: string;
}

17
reijm-read/src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
export {}; // 确保文件被视为模块
interface Window {
$toast: {
show: (text: string, type?: "success" | "error" | "info") => void;
success: (text: string) => void;
error: (text: string) => void;
info: (text: string) => void;
};
liquidGlass?: {
destroy: () => void;
};
}
declare module '*.js' {
const content: any;
export default content;
}

View File

@@ -0,0 +1,19 @@
export interface NoticeButton {
text: string;
type?: string;
action: "close" | "navigate" | "link" | "custom";
to?: string;
href?: string;
handler?: () => void;
showAfter?: number | "refresh" | null;
}
export interface NoticeConfig {
id: string;
title: string;
content: string;
width?: string;
maskClosable?: boolean;
showClose?: boolean;
buttons: NoticeButton[];
}

View File

@@ -0,0 +1,77 @@
interface ConsoleInfo {
text: string;
version: string | undefined;
link: string;
style?: string;
}
export const printConsoleInfo = (info: ConsoleInfo) => {
// 标题样式
const titleStyle = [
"background: linear-gradient(45deg, #2193b0, #6dd5ed)",
"color: white",
"padding: 12px 20px",
"border-radius: 4px 0 0 4px",
"font-weight: bold",
"font-size: 13px",
"text-shadow: 0 1px 1px rgba(0,0,0,0.2)",
"box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1)",
].join(";");
// 版本样式
const versionStyle = [
"background: linear-gradient(45deg, #6dd5ed, #2193b0)",
"color: white",
"padding: 12px 20px",
"font-weight: bold",
"font-size: 13px",
"text-shadow: 0 1px 1px rgba(0,0,0,0.2)",
"box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1)",
].join(";");
// 链接样式
const linkStyle = [
"background: linear-gradient(45deg, #2193b0, #6dd5ed)",
"color: white",
"padding: 12px 20px",
"border-radius: 0 4px 4px 0",
"font-weight: bold",
"font-size: 13px",
"text-shadow: 0 1px 1px rgba(0,0,0,0.2)",
"box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1)",
].join(";");
// 主信息
console.log(
`%c ${info.text} %c ${info.version || ""} %c ${info.link} `,
titleStyle,
versionStyle,
linkStyle,
);
// 欢迎信息
const welcomeStyle = [
"color: #2193b0",
"font-size: 14px",
"font-weight: bold",
"padding: 12px 20px",
"margin: 20px 0",
"border: 2px solid #2193b0",
"border-radius: 4px",
"background: rgba(33,147,176,0.1)",
"text-shadow: 0 1px 1px rgba(255,255,255,0.8)",
].join(";");
console.log("%c欢迎访问我的个人主页", welcomeStyle);
// 装饰线
const lineStyle = [
"font-size: 1px",
"padding: 0",
"margin: 4px 0",
"line-height: 1px",
"background: linear-gradient(to right, #2193b0, #6dd5ed)",
].join(";");
console.log("%c ", `${lineStyle}; padding: 2px 125px;`);
};

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright (c) 2024 Reisa
*
* This file is part of the project and must retain the author's credit.
* Modifications to this file must maintain original attribution.
* Commercial use requires explicit permission.
*/
// 使用一个自执行函数来增加混淆难度
export const createCopyrightGuard = (() => {
const key = btoa("Reisa" + new Date().getFullYear());
return (element: HTMLElement | null) => {
if (!element) return false;
// 随机检查函数
const checks = [
() => element.textContent?.includes("©"),
() => element.textContent?.includes("Reisa"),
() => element.textContent?.includes("All rights"),
() => element.querySelector("a")?.href.includes("godserver.cn"),
() => !element.textContent?.includes("Modified"),
() => element.children.length >= 3,
];
// 随机打乱检查顺序
const shuffledChecks = checks.sort(() => Math.random() - 0.5);
// 执行所有检查
const isValid = shuffledChecks.every((check) => {
try {
return check();
} catch {
return false;
}
});
return isValid;
};
})();

View File

@@ -0,0 +1,10 @@
declare module '@/utils/encryptionUtil' {
const encryptionUtil: {
methods: {
encryptAndCompress(data: string): string;
decompressAndDecrypt(encryptedData: string, key?: any, iv?: any): string;
removePadding(byteArray: Uint8Array): Uint8Array;
};
};
export default encryptionUtil;
}

View File

@@ -0,0 +1,121 @@
import CryptoJS from 'crypto-js';
import pako from 'pako';
const key2 = CryptoJS.enc.Utf8.parse(',Lscj312.;[]sc`1dsajcjc;;wislacx'); // 32字节的密钥
const iv2 = CryptoJS.enc.Utf8.parse(',>ew:[7890;,wd[2'); // 16字节的IV
// 新增PKCS5Padding移除函数更健壮
function removePadding(data) {
if (data.length === 0) return data;
const paddingLength = data[data.length - 1];
if (data.length < paddingLength) {
console.warn("Padding length greater than data length");
return data;
}
return data.slice(0, data.length - paddingLength);
}
// 辅助函数打印ArrayBuffer内容调试用
function arrayBufferToString(buffer) {
return [...new Uint8Array(buffer)].map(b => b.toString(16).padStart(2, '0')).join(' ');
}
export default {
methods: {
encryptAndCompress(data) {
try {
// 压缩数据
const dataArray = new TextEncoder().encode(data);
const compressed = pako.gzip(dataArray);
// 加密数据
const wordArray = CryptoJS.lib.WordArray.create(compressed);
const encrypted = CryptoJS.AES.encrypt(wordArray, key2, {
iv: iv2,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString();
} catch (error) {
console.error("Encryption error:", error);
throw error;
}
},
decompressAndDecrypt(encryptedData, key = key2, iv = iv2) {
try {
if (!encryptedData || encryptedData.length < 16) {
throw new Error("Invalid or empty encrypted data");
}
// 确保 Base64 格式正确
const properB64Str = encryptedData.trim().replace(/\s+/g, '');
if (!/^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/.test(properB64Str)) {
throw new Error("Invalid Base64 string");
}
// 解密数据
const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(properB64Str) });
const decrypted = CryptoJS.AES.decrypt(cipherParams, key, {
iv: iv,
padding: CryptoJS.pad.Pkcs7
});
if (!decrypted || !decrypted.words) {
throw new Error("Decryption failed or returned invalid data");
}
function wordArrayToUint8Array(wordArray) {
const byteArray = [];
const words = wordArray.words;
const sigBytes = wordArray.sigBytes;
for (let i = 0; i < sigBytes; i++) {
byteArray.push((words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff);
}
return new Uint8Array(byteArray);
}
const compressedArray = wordArrayToUint8Array(decrypted);
const debugHex = [...compressedArray.slice(0, 32)].map(b => b.toString(16).padStart(2, '0')).join(' ');
// 移除PKCS7 Padding
const decryptedWithoutPadding = removePadding(compressedArray);
// 手动验证是否是合法的GZIP格式
if (decryptedWithoutPadding.length < 10 ||
(decryptedWithoutPadding[0] !== 0x1f || decryptedWithoutPadding[1] !== 0x8b)) {
// 调试输出前32字节
const debugHexPostPadding = [...decryptedWithoutPadding.slice(0, 32)].map(b => b.toString(16).padStart(2, '0')).join('');
return '';
}
// 解压数据
let decompressed;
try {
decompressed = pako.ungzip(decryptedWithoutPadding);
} catch (e) {
console.error("GZIP decompression failed", e);
throw new Error("Failed to decompress data - invalid gzip format");
}
if (!decompressed || decompressed.length === 0) {
throw new Error("Empty decompressed data");
}
// 解压成功,返回解压后的文本数据
return new TextDecoder().decode(decompressed);
} catch (error) {
console.error("Decryption error:", error);
throw error;
}
},
// 移除PKCS7 Padding的函数
removePadding(byteArray) {
// 假设是PKCS7 padding最后一个字节的值即为填充的字节数
const paddingLength = byteArray[byteArray.length - 1];
return byteArray.slice(0, byteArray.length - paddingLength);
}
}
};

View File

@@ -0,0 +1,21 @@
export const initFontLoading = async () => {
try {
// 等待字体加载
await document.fonts.load('1em "LXWK"');
// 检查字体是否加载成功
const isLoaded = document.fonts.check('1em "LXWK"');
console.log("Font loaded:", isLoaded);
if (!isLoaded) {
// 如果字体加载失败,使用系统字体
document.documentElement.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
}
} catch (error) {
console.error("Font loading error:", error);
// 出错时使用系统字体
document.documentElement.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
}
};

View File

@@ -0,0 +1,292 @@
// Vanilla JS Liquid Glass Effect - Paste into browser console
// Created by Shu Ding (https://github.com/shuding/liquid-glass) in 2025.
(function() {
'use strict';
// Check if liquid glass already exists and destroy it
if (window.liquidGlass) {
window.liquidGlass.destroy();
console.log('Previous liquid glass effect removed.');
}
// Utility functions
function smoothStep(a, b, t) {
t = Math.max(0, Math.min(1, (t - a) / (b - a)));
return t * t * (3 - 2 * t);
}
function length(x, y) {
return Math.sqrt(x * x + y * y);
}
function roundedRectSDF(x, y, width, height, radius) {
const qx = Math.abs(x) - width + radius;
const qy = Math.abs(y) - height + radius;
return Math.min(Math.max(qx, qy), 0) + length(Math.max(qx, 0), Math.max(qy, 0)) - radius;
}
function texture(x, y) {
return { type: 't', x, y };
}
// Generate unique ID
function generateId() {
return 'liquid-glass-' + Math.random().toString(36).substr(2, 9);
}
// Main Shader class
class Shader {
constructor(options = {}) {
this.width = options.width || 100;
this.height = options.height || 100;
this.fragment = options.fragment || ((uv) => texture(uv.x, uv.y));
this.canvasDPI = 1;
this.id = generateId();
this.offset = 10; // Viewport boundary offset
this.mouse = { x: 0, y: 0 };
this.mouseUsed = false;
this.createElement();
this.setupEventListeners();
this.updateShader();
}
createElement() {
// Create container
this.container = document.createElement('div');
this.container.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: ${this.width}px;
height: ${this.height}px;
overflow: hidden;
border-radius: 150px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25), 0 -10px 25px inset rgba(0, 0, 0, 0.15);
cursor: grab;
backdrop-filter: url(#${this.id}_filter) blur(0.25px) contrast(1.2) brightness(1.05) saturate(1.1);
z-index: 9999;
pointer-events: auto;
`;
// Create SVG filter
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
this.svg.setAttribute('width', '0');
this.svg.setAttribute('height', '0');
this.svg.style.cssText = `
position: fixed;
top: 0;
left: 0;
pointer-events: none;
z-index: 9998;
`;
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
filter.setAttribute('id', `${this.id}_filter`);
filter.setAttribute('filterUnits', 'userSpaceOnUse');
filter.setAttribute('colorInterpolationFilters', 'sRGB');
filter.setAttribute('x', '0');
filter.setAttribute('y', '0');
filter.setAttribute('width', this.width.toString());
filter.setAttribute('height', this.height.toString());
this.feImage = document.createElementNS('http://www.w3.org/2000/svg', 'feImage');
this.feImage.setAttribute('id', `${this.id}_map`);
this.feImage.setAttribute('width', this.width.toString());
this.feImage.setAttribute('height', this.height.toString());
this.feDisplacementMap = document.createElementNS('http://www.w3.org/2000/svg', 'feDisplacementMap');
this.feDisplacementMap.setAttribute('in', 'SourceGraphic');
this.feDisplacementMap.setAttribute('in2', `${this.id}_map`);
this.feDisplacementMap.setAttribute('xChannelSelector', 'R');
this.feDisplacementMap.setAttribute('yChannelSelector', 'G');
filter.appendChild(this.feImage);
filter.appendChild(this.feDisplacementMap);
defs.appendChild(filter);
this.svg.appendChild(defs);
// Create canvas for displacement map (hidden)
this.canvas = document.createElement('canvas');
this.canvas.width = this.width * this.canvasDPI;
this.canvas.height = this.height * this.canvasDPI;
this.canvas.style.display = 'none';
this.context = this.canvas.getContext('2d');
}
constrainPosition(x, y) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate boundaries with offset
const minX = this.offset;
const maxX = viewportWidth - this.width - this.offset;
const minY = this.offset;
const maxY = viewportHeight - this.height - this.offset;
// Constrain position
const constrainedX = Math.max(minX, Math.min(maxX, x));
const constrainedY = Math.max(minY, Math.min(maxY, y));
return { x: constrainedX, y: constrainedY };
}
setupEventListeners() {
let isDragging = false;
let startX, startY, initialX, initialY;
this.container.addEventListener('mousedown', (e) => {
isDragging = true;
this.container.style.cursor = 'grabbing';
startX = e.clientX;
startY = e.clientY;
const rect = this.container.getBoundingClientRect();
initialX = rect.left;
initialY = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// Calculate new position
const newX = initialX + deltaX;
const newY = initialY + deltaY;
// Constrain position within viewport bounds
const constrained = this.constrainPosition(newX, newY);
this.container.style.left = constrained.x + 'px';
this.container.style.top = constrained.y + 'px';
this.container.style.transform = 'none';
}
// Update mouse position for shader
const rect = this.container.getBoundingClientRect();
this.mouse.x = (e.clientX - rect.left) / rect.width;
this.mouse.y = (e.clientY - rect.top) / rect.height;
if (this.mouseUsed) {
this.updateShader();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
this.container.style.cursor = 'grab';
});
// Handle window resize to maintain constraints
window.addEventListener('resize', () => {
const rect = this.container.getBoundingClientRect();
const constrained = this.constrainPosition(rect.left, rect.top);
if (rect.left !== constrained.x || rect.top !== constrained.y) {
this.container.style.left = constrained.x + 'px';
this.container.style.top = constrained.y + 'px';
this.container.style.transform = 'none';
}
});
}
updateShader() {
const mouseProxy = new Proxy(this.mouse, {
get: (target, prop) => {
this.mouseUsed = true;
return target[prop];
}
});
this.mouseUsed = false;
const w = this.width * this.canvasDPI;
const h = this.height * this.canvasDPI;
const data = new Uint8ClampedArray(w * h * 4);
let maxScale = 0;
const rawValues = [];
for (let i = 0; i < data.length; i += 4) {
const x = (i / 4) % w;
const y = Math.floor(i / 4 / w);
const pos = this.fragment(
{ x: x / w, y: y / h },
mouseProxy
);
const dx = pos.x * w - x;
const dy = pos.y * h - y;
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy));
rawValues.push(dx, dy);
}
maxScale *= 0.5;
let index = 0;
for (let i = 0; i < data.length; i += 4) {
const r = rawValues[index++] / maxScale + 0.5;
const g = rawValues[index++] / maxScale + 0.5;
data[i] = r * 255;
data[i + 1] = g * 255;
data[i + 2] = 0;
data[i + 3] = 255;
}
this.context.putImageData(new ImageData(data, w, h), 0, 0);
this.feImage.setAttributeNS('http://www.w3.org/1999/xlink', 'href', this.canvas.toDataURL());
this.feDisplacementMap.setAttribute('scale', (maxScale / this.canvasDPI).toString());
}
appendTo(parent) {
parent.appendChild(this.svg);
parent.appendChild(this.container);
}
destroy() {
this.svg.remove();
this.container.remove();
this.canvas.remove();
}
}
// Create the liquid glass effect
function createLiquidGlass() {
// Create shader
const shader = new Shader({
width: 300,
height: 200,
fragment: (uv, mouse) => {
const ix = uv.x - 0.5;
const iy = uv.y - 0.5;
const distanceToEdge = roundedRectSDF(
ix,
iy,
0.3,
0.2,
0.6
);
const displacement = smoothStep(0.8, 0, distanceToEdge - 0.15);
const scaled = smoothStep(0, 1, displacement);
return texture(ix * scaled + 0.5, iy * scaled + 0.5);
}
});
// Add to page
shader.appendTo(document.body);
console.log('Liquid Glass effect created! Drag the glass around the page.');
// Return shader instance so it can be removed if needed
window.liquidGlass = shader;
}
// Initialize
createLiquidGlass();
})();

Some files were not shown because too many files have changed in this diff Show More