Compare commits

...

4 Commits

Author SHA1 Message Date
Hell13Cat 34f144d240 0.1.6s 2026-06-06 12:05:30 +03:00
Hell13Cat bd3036f058 0.1.5s 2026-06-06 11:21:47 +03:00
Hell13Cat 7fe9b73d49 0.1.4s 2026-06-06 10:32:38 +03:00
Hell13Cat 18a3e3d3f2 0.1.3b 2026-06-06 09:48:56 +03:00
14 changed files with 357 additions and 251 deletions
+2
View File
@@ -3,8 +3,10 @@
.vscode .vscode
old/ old/
data_test/ data_test/
tmp/
config_gb.json config_gb.json
migration.py migration.py
change_log.txt
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
+2 -2
View File
@@ -6,7 +6,7 @@
// @grant GM_registerMenuCommand // @grant GM_registerMenuCommand
// @grant GM_getValue // @grant GM_getValue
// @grant GM_setValue // @grant GM_setValue
// @version 1.0r // @version 1.1r
// @author https://t.me/Nyako_TW // @author https://t.me/Nyako_TW
// @downloadURL https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js // @downloadURL https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js
// @updateURL https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js // @updateURL https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js
@@ -107,7 +107,7 @@ if (originalImageLinkElement) {
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
data: JSON.stringify({ id: imageID }), data: JSON.stringify({ id: imageID , type: "download"}),
onload: function(response) { onload: function(response) {
console.log('Ответ от API:', response.responseText); console.log('Ответ от API:', response.responseText);
newDownloadButton.style.outline = "2px solid green"; // Успех newDownloadButton.style.outline = "2px solid green"; // Успех
-64
View File
@@ -1,64 +0,0 @@
{
"total_images_analyzed": 14003,
"average_weight": {
"bytes": 2980950.54,
"formatted": "2.84 MB"
},
"storage_stats": {
"folders": {
"img": "38.88 GB",
"json_db": "24.39 MB",
"thumb": "331.45 MB",
"logs": "14.44 MB"
},
"types_in_img": {
"images": "38.56 GB",
"videos": "140.80 MB",
"gifs": "181.30 MB",
"logs": "14.44 MB"
},
"grand_total": "39.24 GB"
},
"ratings_stats": {
"explicit": {
"count": 1605,
"size_formatted": "4.52 GB"
},
"sensitive": {
"count": 6623,
"size_formatted": "19.35 GB"
},
"questionable": {
"count": 1591,
"size_formatted": "4.72 GB"
},
"general": {
"count": 4184,
"size_formatted": "10.29 GB"
}
},
"resolution_stats": {
"largest": {
"filename": "5adc7e0e773417f9c379accf.png",
"md5": "5a63c28906dc7e0e7734c379accf",
"resolution": "9921x14031"
},
"smallest": {
"filename": "8fc614e712ad21d2151ccde8a3c87.gif",
"md5": "8fc614e712ae22151ccde8a3c87",
"resolution": "240x280"
}
},
"weight_stats": {
"largest": {
"filename": "4cf0a32c61c7e7914095d1fe.png",
"md5": "4ab6acf0a32c61c7e79fe",
"size_formatted": "65.21 MB"
},
"smallest": {
"filename": "2e1c8a5870b90c3f538d7.png",
"md5": "28c679ea3470b90c3f538d7",
"size_formatted": "6.49 KB"
}
}
}
+57 -43
View File
@@ -1,11 +1,14 @@
from system_module import db, gelbooru, logger, image_processor
from flask import Flask, render_template, jsonify, send_from_directory, render_template_string, request
import json import json
import requests import os
from pathlib import Path
import subprocess import subprocess
import traceback import traceback
import os from pathlib import Path
import requests
from flask import (Flask, jsonify, render_template, render_template_string,
request, send_from_directory, make_response)
from system_module import db, gelbooru, image_processor, logger, tasks
if Path("config_gb.json").exists(): if Path("config_gb.json").exists():
CONFIG_FILE = "config_gb.json" CONFIG_FILE = "config_gb.json"
@@ -27,6 +30,7 @@ THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"]
gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS) gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS)
log = logger.Logger() log = logger.Logger()
log.init_global_exception_hook()
database = db.DB(True) database = db.DB(True)
app = Flask(__name__) app = Flask(__name__)
@@ -49,6 +53,10 @@ def index():
def analytics(): def analytics():
return render_template("analytics.html") return render_template("analytics.html")
@app.route("/task")
def task():
return render_template("task.html")
@app.route("/post/<path:post_id>") @app.route("/post/<path:post_id>")
def post(post_id): def post(post_id):
return render_template("post.html") return render_template("post.html")
@@ -56,48 +64,15 @@ def post(post_id):
@app.route('/file/<path:filename>') @app.route('/file/<path:filename>')
def serve_image(filename): def serve_image(filename):
folder_path = find_file(filename, FILE_FOLDERS) folder_path = find_file(filename, FILE_FOLDERS)
return send_from_directory(folder_path, filename) if folder_path == None:
return make_response("Not found", 404)
else:
return send_from_directory(folder_path, filename)
@app.route('/thumb/<path:filename>') @app.route('/thumb/<path:filename>')
def serve_thumb(filename): def serve_thumb(filename):
return send_from_directory(THUMB_FOLDER, filename) return send_from_directory(THUMB_FOLDER, filename)
@app.route("/dw_api", methods=['POST'])
def dw_api():
data = request.get_json()
if not data or 'id' not in data:
return jsonify({'status': 'error', 'message': 'URL not get'}), 400
image_id = data["id"]
try:
data_get, url_file, file_name = gb.get_from_id(image_id)
if database.check_uni(data_get["md5"]):
message = f"File '{file_name}' already exists. Skipping."
log.send("warning", "all", message)
return jsonify({'status': 'skipped', 'message': message})
with requests.get(url_file, stream=True, headers=GB_HEADERS, timeout=30) as r:
r.raise_for_status()
content_type = r.headers.get('Content-Type', '')
if 'text/html' in content_type:
log.send("error", "all", "The server returned HTML instead of an image. Bot protection.")
return False
with open(FILE_DW_FOLDERS+"/"+file_name, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
database.add_raw(data_get)
res_data = image_processor.generate_thumb(FILE_DW_FOLDERS+"/"+file_name, THUMB_FOLDER, TARGET_MIN_SIZE)
if not res_data["status"]:
print(res_data["message"])
return jsonify({'status': 'success'})
except requests.exceptions.RequestException as e:
error_message = f"Network error {image_id}: {e}"
log.send("error", "all", error_message)
return jsonify({'status': 'error', 'message': error_message}), 500
except Exception as e:
traceback.print_exc()
error_message = f"Unknown error: {e}"
log.send("error", "all", error_message)
return jsonify({'status': 'error', 'message': error_message}), 500
@app.route("/api", methods=["GET", "POST"]) @app.route("/api", methods=["GET", "POST"])
def api(): def api():
data = request.get_json() data = request.get_json()
@@ -119,12 +94,51 @@ def api():
return jsonify(results_send) return jsonify(results_send)
elif data["type"] == "autocomplete": elif data["type"] == "autocomplete":
tag_slise = data["q"] tag_slise = data["q"]
result_autocomplete = database.get_autocomplete(tag_slise) result_autocomplete = database.get_top_tag(tag_slise, count_tag_get=10)
return jsonify(result_autocomplete) return jsonify(result_autocomplete)
elif data["type"] == "post": elif data["type"] == "post":
post_id = data["post_id"] post_id = data["post_id"]
result_post = database.get_from_id(post_id) result_post = database.get_from_id(post_id)
return jsonify(result_post) return jsonify(result_post)
elif data["type"] == "task":
if data["task"] == "analytic":
if tasks.calculate_analytic():
return "", 200
else:
return "", 500
elif data["type"] == "download":
if not data or 'id' not in data:
return jsonify({'status': 'error', 'message': 'URL not get'}), 400
image_id = data["id"]
try:
data_get, url_file, file_name = gb.get_from_id(image_id)
if database.check_uni(data_get["md5"]):
message = f"File '{file_name}' already exists. Skipping."
log.send("warning", "all", message)
return jsonify({'status': 'skipped', 'message': message})
with requests.get(url_file, stream=True, headers=GB_HEADERS, timeout=30) as r:
r.raise_for_status()
content_type = r.headers.get('Content-Type', '')
if 'text/html' in content_type:
log.send("error", "all", "The server returned HTML instead of an image. Bot protection.")
return False
with open(FILE_DW_FOLDERS+"/"+file_name, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
database.add_raw(data_get)
res_data = image_processor.generate_thumb(FILE_DW_FOLDERS+"/"+file_name, THUMB_FOLDER, TARGET_MIN_SIZE)
if not res_data["status"]:
print(res_data["message"])
return jsonify({'status': 'success'})
except requests.exceptions.RequestException as e:
error_message = f"Network error {image_id}: {e}"
log.send("error", "all", error_message)
return jsonify({'status': 'error', 'message': error_message}), 500
except Exception as e:
traceback.print_exc()
error_message = f"Unknown error: {e}"
log.send("error", "all", error_message)
return jsonify({'status': 'error', 'message': error_message}), 500
else: else:
pass pass
return "", 204 return "", 204
+9 -7
View File
@@ -1,8 +1,10 @@
import sqlite3
import os
import json import json
import os
import sqlite3
from datetime import datetime from datetime import datetime
from system_module import gelbooru
from system_module.gelbooru import gelbooru_date_parse
class DB: class DB:
def __init__(self, debug: bool): def __init__(self, debug: bool):
@@ -117,7 +119,7 @@ class DB:
"score": data_add["score"], "score": data_add["score"],
"timestamp": data_add["created_at"], "timestamp": data_add["created_at"],
"parsed_time": parsed_time, "parsed_time": parsed_time,
"display_date": gelbooru.gelbooru_date_parse(data_add["created_at"]), "display_date": gelbooru_date_parse(data_add["created_at"]),
"source": data_add["source"], "source": data_add["source"],
"width": data_add["width"], "width": data_add["width"],
"height": data_add["height"] "height": data_add["height"]
@@ -165,15 +167,15 @@ class DB:
cursor = self.conn.execute(query, (md5,)) cursor = self.conn.execute(query, (md5,))
return cursor.fetchone() is not None return cursor.fetchone() is not None
def get_autocomplete(self, tag_slise: str): def get_top_tag(self, tag_slise: str, count_tag_get: int = 9999999):
query = """ query = """
SELECT value AS tag, COUNT(*) AS count SELECT value AS tag, COUNT(*) AS count
FROM files, json_each(files.tags) FROM files, json_each(files.tags)
WHERE value LIKE ? WHERE value LIKE ?
GROUP BY value GROUP BY value
ORDER BY count DESC ORDER BY count DESC
LIMIT 10 LIMIT {count_tag_get}
""" """.format(count_tag_get=count_tag_get)
cursor = self.conn.execute(query, (f"{tag_slise.lower()}%",)) cursor = self.conn.execute(query, (f"{tag_slise.lower()}%",))
return [{"tag": row["tag"], "count": row["count"]} for row in cursor.fetchall()] return [{"tag": row["tag"], "count": row["count"]} for row in cursor.fetchall()]
-104
View File
@@ -1,104 +0,0 @@
from tinydb import TinyDB, Query
import os
import json
from collections import Counter
from system_module import gelbooru
from datetime import datetime
class DB:
def __init__(self, debug: bool):
if debug:
db_path = "data_test"
else:
db_path = "data"
if not os.path.exists(os.path.join(db_path, "db.json")):
json.dump({}, open(os.path.join(db_path, "db.json"), "w", encoding="utf-8"))
self.db = TinyDB(db_path+"/db.json")
self.File = Query()
def add(self, data_add: dict):
self.db.insert(data_add)
def add_raw(self, data_add: dict):
is_video = False
is_gif = False
if data_add["image"].split(".")[-1] in ["webm", "mp4", "avi"]:
is_video = True
if data_add["image"].split(".")[-1] == "gif":
is_gif = True
add_data = {
"md5": data_add["md5"],
"local_filename": data_add["image"],
"is_video": is_video,
"is_gif": is_gif,
"tags": data_add["tags"].split(" "),
"rating": data_add["rating"],
"score": data_add["score"],
"timestamp": data_add["created_at"],
"display_date": gelbooru.gelbooru_date_parse(data_add["created_at"]),
"source": data_add["source"],
"width": data_add["width"],
"height": data_add["height"]
}
self.db.insert(add_data)
def get_all(self):
sorted_records = sorted(
self.db.all(),
key=lambda x: datetime.strptime(
x["timestamp"],
"%a %b %d %H:%M:%S %z %Y"
),
reverse=True
)
return sorted_records
def get_search(self, rating_tmp: list[str], tags: list[str]):
mapping_rating = {
"e": "explicit",
"g": "general",
"s": "sensitive",
"q": "questionable"
}
rating = [mapping_rating.get(x, x) for x in rating_tmp]
if rating:
query = self.File.rating.one_of(rating)
if tags != [""]:
tag_query = self.File.tags.test(lambda t: set(tags).issubset(set(t or [])))
query = tag_query if query is None else query & tag_query
results = self.db.search(query) if query else self.db.all()
sorted_records = sorted(
results,
key=lambda x: datetime.strptime(
x["timestamp"],
"%a %b %d %H:%M:%S %z %Y"
),
reverse=True
)
return sorted_records
def get_unknow(self):
results = self.db.search(self.File.tags == [])
return results
def get_autocomplete(self, tag_slise: str):
counter = Counter()
for record in self.db.all():
counter.update(record.get("tags", []))
results = [
{
"tag": tag,
"count": count
}
for tag, count in counter.items()
if tag.lower().startswith(tag_slise)
]
results.sort(key=lambda x: x["count"], reverse=True)
top_10 = results[:10]
return top_10
def edit_data(self, id_file: str, edit_data: dict):
self.db.update(edit_data, self.File.md5 == id_file)
def get_from_id(self, id_file: str):
return self.db.get(self.File.md5 == id_file)
+8 -9
View File
@@ -1,8 +1,10 @@
import requests
from datetime import datetime
import os import os
from datetime import datetime
def gelbooru_date_parse(date_str): import requests
def gelbooru_date_parse(date_str: str):
if not date_str: if not date_str:
return 0, "Unknown" return 0, "Unknown"
try: try:
@@ -13,14 +15,14 @@ def gelbooru_date_parse(date_str):
return 0, "Unknown" return 0, "Unknown"
class GB: class GB:
def __init__(self, api_id, api_hash, headers, dw_folder): def __init__(self, api_id: str, api_hash: str, headers: dict[str, str], dw_folder: str):
self.api_id = api_id self.api_id = api_id
self.api_hash = api_hash self.api_hash = api_hash
self.dw_folder = dw_folder self.dw_folder = dw_folder
self.root_url = "https://gelbooru.com/index.php" self.root_url = "https://gelbooru.com/index.php"
self.HEADERS = headers self.HEADERS = headers
def get_from_id(self, id_post): def get_from_id(self, id_post: int):
params = {"page": "dapi", "s": "post", "q": "index", "json": 1, "id": id_post, "api_key":self.api_hash, "user_id":self.api_id} params = {"page": "dapi", "s": "post", "q": "index", "json": 1, "id": id_post, "api_key":self.api_hash, "user_id":self.api_id}
response = requests.get(self.root_url, params=params, headers=self.HEADERS, timeout=15) response = requests.get(self.root_url, params=params, headers=self.HEADERS, timeout=15)
print(response.url) print(response.url)
@@ -28,13 +30,10 @@ class GB:
file_name = response.json()["post"][0]["image"] file_name = response.json()["post"][0]["image"]
return response.json()["post"][0], url_file, file_name return response.json()["post"][0], url_file, file_name
def get_from_md5(self, md5): def get_from_md5(self, md5: str):
params = {"page": "dapi", "s": "post", "q": "index", "json": 1, "tags":"md5:"+md5, "api_key":self.api_hash, "user_id":self.api_id} params = {"page": "dapi", "s": "post", "q": "index", "json": 1, "tags":"md5:"+md5, "api_key":self.api_hash, "user_id":self.api_id}
response = requests.get(self.root_url, params=params, headers=self.HEADERS, timeout=15) response = requests.get(self.root_url, params=params, headers=self.HEADERS, timeout=15)
print(response.url) print(response.url)
url_file = response.json()["post"][0]["file_url"] url_file = response.json()["post"][0]["file_url"]
file_name = response.json()["post"][0]["image"] file_name = response.json()["post"][0]["image"]
return response.json()["post"][0], url_file, file_name return response.json()["post"][0], url_file, file_name
def get_binary_file(self, id_post):
pass
+6 -5
View File
@@ -1,6 +1,7 @@
import subprocess
from PIL import Image
import os import os
import subprocess
from PIL import Image
EXTENSIONS_IMG = ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp') EXTENSIONS_IMG = ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp')
EXTENSIONS_VID = ('mp4', 'webm') EXTENSIONS_VID = ('mp4', 'webm')
@@ -15,7 +16,7 @@ def check_ffmpeg():
except (FileNotFoundError, subprocess.CalledProcessError): except (FileNotFoundError, subprocess.CalledProcessError):
return False return False
def video_gen(input_path_file, output_path_file, TARGET_MIN_SIZE): def video_gen(input_path_file: str, output_path_file: str, TARGET_MIN_SIZE: int):
vf_scale = f"scale='if(gt(iw,ih),-1,{TARGET_MIN_SIZE})':'if(gt(iw,ih),{TARGET_MIN_SIZE},-1)'" vf_scale = f"scale='if(gt(iw,ih),-1,{TARGET_MIN_SIZE})':'if(gt(iw,ih),{TARGET_MIN_SIZE},-1)'"
cmd = ["ffmpeg", "-y", "-i", input_path_file, "-ss", "00:00:00", "-vframes", "1", "-vf", vf_scale, "-q:v", "2", output_path_file] cmd = ["ffmpeg", "-y", "-i", input_path_file, "-ss", "00:00:00", "-vframes", "1", "-vf", vf_scale, "-q:v", "2", output_path_file]
if os.name == 'nt': creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt': creationflags = subprocess.CREATE_NO_WINDOW
@@ -24,7 +25,7 @@ def video_gen(input_path_file, output_path_file, TARGET_MIN_SIZE):
if result.returncode != 0: if result.returncode != 0:
raise Exception(f"FFmpeg error: {result.stderr.decode('utf-8', errors='ignore')}") raise Exception(f"FFmpeg error: {result.stderr.decode('utf-8', errors='ignore')}")
def image_and_gif_gen(input_path_file, output_path_file, TARGET_MIN_SIZE): def image_and_gif_gen(input_path_file: str, output_path_file: str, TARGET_MIN_SIZE: int):
with Image.open(input_path_file) as img: with Image.open(input_path_file) as img:
img_rgba = img.convert("RGBA") img_rgba = img.convert("RGBA")
background = Image.new("RGB", img_rgba.size, (255, 255, 255)) background = Image.new("RGB", img_rgba.size, (255, 255, 255))
@@ -43,7 +44,7 @@ def image_and_gif_gen(input_path_file, output_path_file, TARGET_MIN_SIZE):
resized_img = final_img.resize((new_width, new_height), Image.Resampling.LANCZOS) resized_img = final_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
resized_img.save(output_path_file, "JPEG", quality=85) resized_img.save(output_path_file, "JPEG", quality=85)
def generate_thumb(input_path_file, THUMB_FOLDER, TARGET_MIN_SIZE): def generate_thumb(input_path_file: str, THUMB_FOLDER: str, TARGET_MIN_SIZE: int):
ext_file = input_path_file.split(".")[-1] ext_file = input_path_file.split(".")[-1]
if ext_file not in VALID_EXTENSIONS: if ext_file not in VALID_EXTENSIONS:
return {"status":False, "message":"Расширение не поддерживаеться!"} return {"status":False, "message":"Расширение не поддерживаеться!"}
+20 -14
View File
@@ -1,28 +1,34 @@
import sys
import logging import logging
class Logger: class Logger:
def __init__(self): def __init__(self):
self.logger_file = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.logger_cli = logging.getLogger(__name__) self.logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
file_handler = logging.FileHandler("logs.log", encoding="utf-8") file_handler = logging.FileHandler("logs.log", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
self.logger_file.setLevel(logging.DEBUG) self.logger.addHandler(file_handler)
self.logger_file.addHandler(file_handler) self.logger.addHandler(console_handler)
self.logger_cli.setLevel(logging.INFO) def init_global_exception_hook(self):
self.logger_cli.addHandler(console_handler) def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(
exc_type,
exc_value,
exc_traceback
)
return
self.logger.critical("ERROR:", exc_info=(exc_type, exc_value, exc_traceback))
sys.excepthook = handle_exception
def send(self, level_log, type_log, message): def send(self, level_log: str, message: str):
if type_log == "file": getattr(self.logger, level_log)(message)
getattr(self.logger_file, level_log)(message)
elif type_log == "cli":
getattr(self.logger_cli, level_log)(message)
else:
getattr(self.logger_file, level_log)(message)
getattr(self.logger_cli, level_log)(message)
+2
View File
@@ -0,0 +1,2 @@
def calculate_analytic():
pass
+2
View File
@@ -18,6 +18,7 @@
header { display: flex; align-items: center; background-color: var(--bg-header); padding: 10px 20px; border-bottom: 1px solid var(--border-color); gap: 15px; flex-wrap: wrap; } header { display: flex; align-items: center; background-color: var(--bg-header); padding: 10px 20px; border-bottom: 1px solid var(--border-color); gap: 15px; flex-wrap: wrap; }
.logo { font-weight: bold; font-size: 18px; color: white; text-decoration: none; } .logo { font-weight: bold; font-size: 18px; color: white; text-decoration: none; }
.analytics { color: #88bece; text-decoration: none; font-size: 12px; } .analytics { color: #88bece; text-decoration: none; font-size: 12px; }
.tasks { color: #88bece; text-decoration: none; font-size: 12px; }
.search-wrapper { flex-grow: 1; max-width: 400px; position: relative; } .search-wrapper { flex-grow: 1; max-width: 400px; position: relative; }
.search-wrapper input { width: 100%; background-color: var(--bg-input); border: 1px solid var(--border-color); color: white; padding: 6px 10px; border-radius: 3px; outline: none; } .search-wrapper input { width: 100%; background-color: var(--bg-input); border: 1px solid var(--border-color); color: white; padding: 6px 10px; border-radius: 3px; outline: none; }
@@ -66,6 +67,7 @@
<!-- <!--
<a class="analytics" href="/analytics">📊 Analytics</a> <a class="analytics" href="/analytics">📊 Analytics</a>
--> -->
<a class="tasks" href="/task">⚙️ Tasks</a>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags..."> <input type="text" id="searchInput" placeholder="Search tags...">
<ul id="autocompleteList" class="autocomplete-list"></ul> <ul id="autocompleteList" class="autocomplete-list"></ul>
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tasks - LocalBooru</title>
<style>
:root { --bg-main: #1e1e1e; --bg-header: #252525; --bg-panel: #222; --bg-box: #2a2a2a; --bg-input: #333; --text-main: #eee; --text-muted: #aaa; --border-color: #444; --link-color: #4da8da; --color-g: #4caf50; --color-s: #ff9800; --color-q: #ffeb3b; --color-e: #f44336; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background-color: var(--bg-main); color: var(--text-main); font-family: sans-serif; font-size: 13px; }
/* Header Styles */
header { display: flex; align-items: center; background-color: var(--bg-header); padding: 10px 20px; border-bottom: 1px solid var(--border-color); gap: 15px; flex-wrap: wrap; }
.logo { font-weight: bold; font-size: 18px; color: white; text-decoration: none; }
.nav-link { color: var(--link-color); text-decoration: none; font-size: 12px; font-weight: bold;}
/* Search Autocomplete Styles */
.search-wrapper { flex-grow: 1; max-width: 400px; position: relative; }
.search-wrapper input { width: 100%; background-color: var(--bg-input); border: 1px solid var(--border-color); color: white; padding: 6px 10px; border-radius: 3px; outline: none; }
.autocomplete-list { position: absolute; top: 100%; left: 0; right: 0; background-color: var(--bg-box); border: 1px solid var(--border-color); border-radius: 3px; margin-top: 4px; list-style: none; z-index: 1000; max-height: 250px; overflow-y: auto; box-shadow: 0 4px 10px rgba(0,0,0,0.5); display: none; }
.autocomplete-item { padding: 8px 10px; cursor: pointer; display: flex; justify-content: space-between; border-bottom: 1px solid #333; }
.autocomplete-item:hover { background-color: var(--bg-input); }
.autocomplete-count { color: var(--text-muted); font-size: 12px; }
/* Main Content & Tasks Styles */
main { padding: 20px; max-width: 1000px; margin: 0 auto; }
h1 { font-size: 20px; margin-bottom: 20px; color: white; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; }
.task-list { display: flex; flex-direction: column; gap: 15px; }
.task-card { background-color: var(--bg-panel); border: 1px solid var(--border-color); border-radius: 4px; padding: 15px 20px; display: flex; justify-content: space-between; align-items: center; gap: 20px; transition: background-color 0.2s; }
.task-card:hover { background-color: var(--bg-box); }
.task-info h3 { font-size: 16px; color: var(--link-color); margin-bottom: 5px; }
.task-info p { color: var(--text-muted); font-size: 13px; line-height: 1.4; }
.task-actions { display: flex; align-items: center; gap: 15px; min-width: 200px; justify-content: flex-end; }
.btn { background-color: var(--bg-input); border: 1px solid var(--border-color); color: white; padding: 8px 16px; border-radius: 3px; cursor: pointer; font-weight: bold; transition: all 0.2s; }
.btn:hover:not(:disabled) { background-color: #444; border-color: #555; }
.btn:active:not(:disabled) { background-color: #222; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.task-status { font-weight: bold; font-size: 13px; min-width: 90px; text-align: right; }
.status-success { color: var(--color-g); }
.status-error { color: var(--color-e); }
@media (max-width: 700px) {
header { flex-wrap: wrap; }
.search-wrapper { order: 3; min-width: 100%; margin-top: 10px;}
.task-card { flex-direction: column; align-items: flex-start; }
.task-actions { justify-content: flex-start; width: 100%; margin-top: 10px; }
}
</style>
</head>
<body>
<header>
<a href="/" class="logo">LocalBooru</a>
<div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags...">
<ul id="autocompleteList" class="autocomplete-list"></ul>
</div>
</header>
<main>
<h1>System Tasks</h1>
<div class="task-list">
<!-- ЗАДАЧА 1: Пересчёт аналитики -->
<div class="task-card">
<div class="task-info">
<h3>Пересчёт аналитики</h3>
<p>Обновляет статистику (количество файлов, занимаемое место). Рекомендуется запускать после массового добавления или удаления постов.</p>
</div>
<div class="task-actions">
<span class="task-status"></span>
<button class="btn" onclick="executeTask('analytic', this)">Запустить</button>
</div>
</div>
<!-- ШАБЛОН ДЛЯ БУДУЩИХ ЗАДАЧ (просто скопируйте блок ниже и измените 'task_name') -->
<!--
<div class="task-card">
<div class="task-info">
<h3>Новая задача</h3>
<p>Описание новой задачи.</p>
</div>
<div class="task-actions">
<span class="task-status"></span>
<button class="btn" onclick="executeTask('new_task_name', this)">Запустить</button>
</div>
</div>
-->
</div>
</main>
<script>
// === ЛОГИКА ЗАДАЧ ===
async function executeTask(taskName, btn) {
const statusSpan = btn.previousElementSibling;
// Состояние загрузки
btn.disabled = true;
btn.innerText = "Выполнение...";
statusSpan.innerText = "В процессе ⏳";
statusSpan.className = "task-status"; // сброс классов успеха/ошибки
try {
// Отправляем POST запрос на API
const response = await fetch('/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: "task",
task: taskName
})
});
// Обработка ответа (200 - успех, 500+ - ошибка)
if (response.ok) {
statusSpan.innerText = "Успешно ✔";
statusSpan.classList.add('status-success');
} else {
statusSpan.innerText = `Ошибка (${response.status}) ✖`;
statusSpan.classList.add('status-error');
}
} catch (error) {
// Ошибка сети или сервер недоступен
statusSpan.innerText = "Сбой сети ✖";
statusSpan.classList.add('status-error');
console.error("Task execution error:", error);
} finally {
// Возвращаем кнопку в исходное состояние
btn.disabled = false;
btn.innerText = "Запустить";
}
}
// === ЛОГИКА ПОИСКА (оставлена из предыдущей версии) ===
const searchInput = document.getElementById('searchInput');
const list = document.getElementById('autocompleteList');
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const words = searchInput.value.split(' ');
const currentWord = words[words.length - 1];
if (currentWord.length < 2) { list.style.display = 'none'; return; }
debounceTimer = setTimeout(async () => {
const req = { type: "autocomplete", q: currentWord };
const data = await mockAutocompleteAPI(req);
if (data.length === 0) { list.style.display = 'none'; return; }
list.innerHTML = data.map(item => `<li class="autocomplete-item" data-tag="${item.tag}"><span>${item.tag}</span><span class="autocomplete-count">${item.count}</span></li>`).join('');
list.style.display = 'block';
list.querySelectorAll('.autocomplete-item').forEach(li => {
li.addEventListener('click', () => {
words[words.length - 1] = li.getAttribute('data-tag');
searchInput.value = words.join(' ') + ' ';
list.style.display = 'none';
searchInput.focus();
});
});
}, 200);
});
document.addEventListener('click', (e) => { if (e.target !== searchInput && e.target !== list) list.style.display = 'none'; });
searchInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') window.location.href = `index.html?query=${searchInput.value.trim()}`; });
// Заглушка для поиска (оставьте или замените на ваш fetch)
function mockAutocompleteAPI(req) { return new Promise(res => { const db = [{tag: "long_hair", count: 8184}, {tag: "1girl", count: 11061}]; res(db.filter(t => t.tag.includes(req.q.toLowerCase()))); }); }
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"host":"https://git.nekono.su",
"user":"Nyako",
"repo":"Local-Booru"
}
+59
View File
@@ -0,0 +1,59 @@
import os
import json
import shutil
import tempfile
import zipfile
import urllib.request
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
EXCLUDE_PATHS = {
"data",
"config.json",
"logs.log",
"updater"
}
def load_config():
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def get_latest_release(cfg):
url = f"{cfg['host']}/api/v1/repos/{cfg['user']}/{cfg['repo']}/releases/latest"
with urllib.request.urlopen(url) as r:
return json.loads(r.read().decode("utf-8"))
def download(url, path):
urllib.request.urlretrieve(url, path)
def should_exclude(relative_path: str) -> bool:
parts = set(relative_path.split(os.sep))
return any(ex in parts for ex in EXCLUDE_PATHS)
def apply_update(src_root, target_root):
for root, dirs, files in os.walk(src_root):
rel_dir = os.path.relpath(root, src_root)
if rel_dir == ".":
rel_dir = ""
for file in files:
rel_path = os.path.normpath(os.path.join(rel_dir, file))
if should_exclude(rel_path):
continue
src_file = os.path.join(root, file)
dst_file = os.path.join(target_root, rel_path)
os.makedirs(os.path.dirname(dst_file), exist_ok=True)
shutil.copy2(src_file, dst_file)
cfg = load_config()
release = get_latest_release(cfg)
zip_url = release["zipball_url"]
tmp = tempfile.mkdtemp()
zip_path = os.path.join(tmp, "release.zip")
print("Downloading:", release["tag_name"])
download(zip_url, zip_path)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(tmp)
extracted_root = os.path.join(tmp, os.listdir(tmp)[0])
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
apply_update(extracted_root, project_root)
print("Update success!")