Compare commits

12 Commits

Author SHA1 Message Date
Nyako 50108add2e 0.2.7s 2026-06-12 17:19:38 +03:00
Nyako 8ef06b55ef 0.2.6s 2026-06-12 17:17:44 +03:00
Nyako d743cd4394 0.2.5s 2026-06-12 17:16:27 +03:00
Nyako 60b79efb8d 0.2.4s 2026-06-12 17:10:05 +03:00
Nyako ed39d2b56a 0.2.3s 2026-06-12 16:46:37 +03:00
Nyako 1db8e46064 0.2.2s 2026-06-12 15:31:03 +03:00
Nyako 627d3d55cc 0.2.1s 2026-06-06 22:19:53 +03:00
Nyako b2b2145c37 0.2.0b 2026-06-06 15:30:28 +03:00
Nyako 21e37776f9 0.1.10s 2026-06-06 15:07:26 +03:00
Nyako 2c2aa65947 0.1.9s 2026-06-06 14:52:48 +03:00
Nyako 36a044c8da 0.1.8s 2026-06-06 13:30:10 +03:00
Nyako d1bc3fe836 0.1.7s 2026-06-06 12:50:35 +03:00
20 changed files with 628 additions and 317 deletions
+6
View File
@@ -1,4 +1,8 @@
# ---> Python # ---> Python
# DEV
install.ps1
up/
# Secure data # Secure data
.vscode .vscode
old/ old/
@@ -7,6 +11,8 @@ tmp/
config_gb.json config_gb.json
migration.py migration.py
change_log.txt change_log.txt
.python-version
uv.lock
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install
RUN apt-get install ffmpeg
RUN pip install uv
COPY pyproject.toml uv.lock* ./
RUN uv sync --frozen
COPY . .
ENV PYTHONUNBUFFERED=1
CMD ["uv", "run", "python", "run.py"]
+1 -1
View File
@@ -95,7 +95,7 @@ if (originalImageLinkElement) {
// Получаем настроенный пользователем корневой путь // Получаем настроенный пользователем корневой путь
const apiRoot = GM_getValue('nyakonya_gb_api_root', 'http://127.0.2.1'); const apiRoot = GM_getValue('nyakonya_gb_api_root', 'http://127.0.2.1');
// Формируем корректный URL для запроса к dw_api // Формируем корректный URL для запроса к dw_api
const apiUrl = apiRoot.endsWith('/') ? `${apiRoot}dw_api` : `${apiRoot}/dw_api`; const apiUrl = apiRoot.endsWith('/') ? `${apiRoot}dw_api` : `${apiRoot}/api`;
console.log(`Отправка URL на локальный API: ${imageUrl} (Endpoint: ${apiUrl})`); console.log(`Отправка URL на локальный API: ${imageUrl} (Endpoint: ${apiUrl})`);
const originalOutline = newDownloadButton.style.outline; const originalOutline = newDownloadButton.style.outline;
+2 -1
View File
@@ -16,6 +16,7 @@
"file":{ "file":{
"repos":["data/files"], "repos":["data/files"],
"dw":"data/files", "dw":"data/files",
"thumb":"data/thumb" "thumb":"data/thumb",
"data":"data"
} }
} }
+29
View File
@@ -0,0 +1,29 @@
### 0.2.0b
> **Fixes**
1. Added 404 error handling when the source file is not found.
> **Changes**
1. Merged `/dw_api` and `/api` into a single `/api` endpoint.
> **New**
1. Added a system module for background tasks.
2. Added an analytics page.
> **Code**
1. Deleted the legacy `db_old.py` file.
2. Sorted and refactored imports.
3. Added type hinting for input data in `system_module` functions.
4. Removed `data/analytic.json`.
5. Updated the logic for retrieving popular tags to support analytics.
- 5.1. Renamed `get_autocomplete` function to `get_top_tags`.
- 5.2. Added an optional `limit` argument to the `get_top_tags` function.
6. Removed the `get_binary_file()` function from the `gelbooru` module as it was unused.
7. Refactored the logging module.
8. Added comprehensive error logging.
9. Added the `/task` endpoint.
10. Implemented new logic for passing the database path via the configuration file.
+29
View File
@@ -0,0 +1,29 @@
### 0.2.0b
> **Иправления**
1. Добавлен вывод 404 если исходный файл не найден
> **Изменения**
1. /dw_api и /api объединены в /api
> **Новое**
1. Системный модуль с задачами
2. Страница с аналитикой
> **Код**
1. Удалён старый файл db_old.py
2. Отсортированы и изменены импорты
3. Добавлена типизация входных данных в функции расположенные в system_module
4. Удалён data/analytic.json
5. Изменена логика получения списка популярных тегов для адаптации к аналитике
- 5.1. Функция get_autocomplete переименована в get_top_tag
- 5.1. Для функции get_top_tag добален необязательный аргумент для лимита выдачи
6. Функция get_binary_file() из модуля gelbooru удалена из-за отсусвия факта использования
7. Переписан модуль логов
8. Добавлено полное логирование ошибок
9. Добавлен эндпойнт /task
10. Новая логика передачи пути к базе данных через конфиг
+7 -3
View File
@@ -8,7 +8,7 @@
## Prerequisites ## Prerequisites
- Python 3.13.13 or higher - Python 3.13 or higher
- ffmpeg 2026-01-29 (required if you intend to store video files) - ffmpeg 2026-01-29 (required if you intend to store video files)
- git (optional) - git (optional)
@@ -36,7 +36,10 @@ Run the following command in the folder where the site will be located:
### Installing dependencies ### Installing dependencies
In the terminal, navigate to the folder where the site is located and run: In the terminal, navigate to the folder where the site is located and run:
`pip install -r requirements.txt` ```bash
pip install uv
uv sync
```
### Configuring `config.json` ### Configuring `config.json`
@@ -53,6 +56,7 @@ Config explanation:
| `file`-`repos` | `["data/files"]` | List of directories where source files are stored | | `file`-`repos` | `["data/files"]` | List of directories where source files are stored |
| `file`-`dw` | `"data/files"` | Directory for new downloads | | `file`-`dw` | `"data/files"` | Directory for new downloads |
| `file`-`thumb` | `"data/thumb"` | Directory for thumbnails | | `file`-`thumb` | `"data/thumb"` | Directory for thumbnails |
| `file`-`data` | `"data"` | Directory for database |
1. You must obtain your Gelbooru User ID and Hash and add them to the config file for the system to function correctly. 1. You must obtain your Gelbooru User ID and Hash and add them to the config file for the system to function correctly.
2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js). 2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js).
@@ -63,4 +67,4 @@ Config explanation:
### Launch ### Launch
Simply run: `python run.py` Simply run: `uv run run.py`
+63 -45
View File
@@ -6,63 +6,81 @@
- **ОЗУ:** 512 МБ и выше - **ОЗУ:** 512 МБ и выше
- **Диск:** 512 МБ минимум + место под данные - **Диск:** 512 МБ минимум + место под данные
## Требуеться заранее установить ## Установка и запуск
- Python 3.13.13 и выше Мы предлагаем два способа установки и запуска:
- ffmpeg 2026-01-29 (Если вы будете хранить видео)
- git (по желанию)
## Инструкиция - Через uv (Рекомендуеться для Windows)
- Через Docker (Рекомендуеться для Linux)
Приготовив все программы вам надо настроить booru ### Установка через UV
### Скачиваем пакет 1. Подготовка необходимых программ
- Python 3.13 и выше
- ffmpeg 2026-01-29
- git (по желанию)
2. Скачиваем галерею
- Если установлен git:
```bash
git clone https://git.nekono.su/Nyako/Local-Booru.git
cd Local-Booru
```
- Если git не установлен:
У вас есть два варианта в зависимости от установленного git ![1](./img/1.png)
#### Используя CLI сделать в папке где будет размещен сайт ![2](./img/2.png)
Делаем распаковку в папку где будет сайт и открываем корневую папку в CMD
3. Производим установку зависимостей
```bash
pip install uv
uv sync
```
4. При успешной устанвке производим
```bash
uv run run.py
```
`git clone https://git.nekono.su/Nyako/Local-Booru.git .` ### Установка через Docker
#### Используя веб-сайт 1. Подготовка необходимых программ
```bash
apt-get update && apt-get install
apt-get install git
```
2. Клонирование репозитория
```bash
git clone https://git.nekono.su/Nyako/Local-Booru.git
cd Local-Booru
```
3. Отредактируйте файл config.json. Вставив всё необходимое.
4. Запустите галерею
```bash
docker compose up --build
```
1. Скачиваем архив по кнопке: ## Объяснение config.json
![1](./img/1.png)
![2](./img/2.png)
2. Делаем распаковку в папку где будет сайт
### Производим установку зависимостей
Используя CLI сделать в папке где будет размещен сайт:
`pip install -r requirements.txt`
### Делаем конфигурацию файла config.json
Объяснение конфига: Объяснение конфига:
| Ключ | По умолчанию | Пояснение | | Ключ | По умолчанию | Пояснение | Заметки |
|----------|----------|----------| |----------|----------|----------| - |
| `server`-`host` | `"127.0.2.1"` | Хост запуска сервера | | `server`-`host` | `"127.0.2.1"` | Хост запуска сервера | Игнорируеться в docker |
| `server`-`port` | `80` | Порт запуска сервера | | `server`-`port` | `80` | Порт запуска сервера | Игнорируеться в docker |
| `server`-`count_page` | `25` | Количество элементов на странице | | `server`-`count_page` | `25` | Количество элементов на странице | - |
| `server`-`thumb_size` | `250` | Размер превью в пикселях по широкой стороне | | `server`-`thumb_size` | `250` | Размер превью в пикселях по широкой стороне | - |
| `gb`-`id` | `""` | ID пользователя Gelbooru | | `gb`-`id` | `""` | ID пользователя Gelbooru | - |
| `gb`-`hash` | `""` | Hash пользователя Gelbooru | | `gb`-`hash` | `""` | Hash пользователя Gelbooru | - |
| `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы | | `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы | - |
| `file`-`dw` | `"data/files"` | Каталог хранения загрузок | | `file`-`dw` | `"data/files"` | Каталог хранения загрузок | - |
| `file`-`thumb` | `"data/thumb"` | Каталог обложек | | `file`-`thumb` | `"data/thumb"` | Каталог обложек | - |
| `file`-`data` | `"data"` | Каталог базы данных | - |
## Скрипт для браузера
1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы! 1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы!
2. Установить скрипт в [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js) 2. Установить скрипт в [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js)
3. Если вы меняли Host или Port либо делаете запуск через Docker
#### Если вы меняли Host или Port - Открыть любой пост
- Открыть любой пост - Ввести корневой путь в конфигурации скрипта на тот что у вас
- Ввести корневой путь в конфигурации скрипта на тот что у вас
### Запуск
Просто выполните `python run.py`
+21
View File
@@ -0,0 +1,21 @@
version: "3.9"
services:
app:
build: .
container_name: local_booru
ports:
- "5000:5000"
volumes:
- .:/app
- ./data:/app/data
- ./tmp:/app/tmp
- ./config.json:/app/config.json:ro
environment:
- PYTHONUNBUFFERED=1
- RUNNING_IN_DOCKER=1
restart: unless-stopped
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "local-booru"
version = "0.1.0"
description = "None"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"blinker==1.9.0",
"certifi==2026.5.20",
"charset-normalizer==3.4.7",
"click==8.4.1",
"colorama==0.4.6",
"Flask==3.1.3",
"idna==3.17",
"itsdangerous==2.2.0",
"Jinja2==3.1.6",
"MarkupSafe==3.0.3",
"pillow==12.2.0",
"requests==2.34.2",
"urllib3==2.7.0",
"Werkzeug==3.1.8"
]
-14
View File
@@ -1,14 +0,0 @@
blinker==1.9.0
certifi==2026.5.20
charset-normalizer==3.4.7
click==8.4.1
colorama==0.4.6
Flask==3.1.3
idna==3.17
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
pillow==12.2.0
requests==2.34.2
urllib3==2.7.0
Werkzeug==3.1.8
+16 -16
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import requests import requests
from flask import (Flask, jsonify, render_template, render_template_string, from flask import (Flask, jsonify, render_template, render_template_string,
request, send_from_directory, make_response) request, send_from_directory, make_response, send_file)
from system_module import db, gelbooru, image_processor, logger, tasks from system_module import db, gelbooru, image_processor, logger, tasks
@@ -16,8 +16,12 @@ else:
CONFIG_FILE = "config.json" CONFIG_FILE = "config.json"
CONFIG_ROOT = json.load(open(CONFIG_FILE, "r", encoding="utf-8")) CONFIG_ROOT = json.load(open(CONFIG_FILE, "r", encoding="utf-8"))
CONFIG_HOST = CONFIG_ROOT["server"]["host"] if os.getenv("RUNNING_IN_DOCKER") == "1":
CONFIG_PORT = CONFIG_ROOT["server"]["port"] CONFIG_HOST = CONFIG_ROOT["server"]["host"]
CONFIG_PORT = CONFIG_ROOT["server"]["port"]
else:
CONFIG_HOST = "0.0.0.0"
CONFIG_PORT = 5000
PAGINATION_COUNT = CONFIG_ROOT["server"]["count_page"] PAGINATION_COUNT = CONFIG_ROOT["server"]["count_page"]
TARGET_MIN_SIZE = CONFIG_ROOT["server"]["thumb_size"] TARGET_MIN_SIZE = CONFIG_ROOT["server"]["thumb_size"]
GB_ID = CONFIG_ROOT["gb"]["id"] GB_ID = CONFIG_ROOT["gb"]["id"]
@@ -26,23 +30,14 @@ GB_HEADERS = CONFIG_ROOT["gb"]["headers"]
FILE_FOLDERS = CONFIG_ROOT["file"]["repos"] FILE_FOLDERS = CONFIG_ROOT["file"]["repos"]
FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"] FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"]
THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"] THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"]
DATA_FOLDER = CONFIG_ROOT["file"]["data"]
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() log.init_global_exception_hook()
database = db.DB(True) database = db.DB(DATA_FOLDER)
app = Flask(__name__) app = Flask(__name__)
def find_file(filename, file_folders):
for folder in file_folders:
folder_path = Path(folder)
if not folder_path.exists():
continue
for file_path in folder_path.rglob(filename):
return folder_path
return None
if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!") if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!")
@app.route("/") @app.route("/")
@@ -63,7 +58,7 @@ 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 = tasks.find_file(filename, FILE_FOLDERS)
if folder_path == None: if folder_path == None:
return make_response("Not found", 404) return make_response("Not found", 404)
else: else:
@@ -100,12 +95,17 @@ def api():
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"] == "analytic":
return send_file(os.path.join(DATA_FOLDER, "analytic.json"), mimetype="application/json")
elif data["type"] == "task": elif data["type"] == "task":
if data["task"] == "analytic": if data["task"] == "analytic":
if tasks.calculate_analytic(): if tasks.calculate_analytic(database, CONFIG_ROOT):
return "", 200 return "", 200
else: else:
return "", 500 return "", 500
if data["task"] == "not_found_file":
list_md5_not_found = tasks.get_files_not_found(database, CONFIG_ROOT)
return jsonify({'list': list_md5_not_found}), 200
elif data["type"] == "download": elif data["type"] == "download":
if not data or 'id' not in data: if not data or 'id' not in data:
return jsonify({'status': 'error', 'message': 'URL not get'}), 400 return jsonify({'status': 'error', 'message': 'URL not get'}), 400
+1 -5
View File
@@ -7,11 +7,7 @@ from system_module.gelbooru import gelbooru_date_parse
class DB: class DB:
def __init__(self, debug: bool): def __init__(self, db_path: str):
if debug:
db_path = "data_test"
else:
db_path = "data"
if not os.path.exists(db_path): if not os.path.exists(db_path):
os.makedirs(db_path) os.makedirs(db_path)
-2
View File
@@ -25,7 +25,6 @@ class GB:
def get_from_id(self, id_post: int): 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)
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
@@ -33,7 +32,6 @@ class GB:
def get_from_md5(self, md5: str): 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)
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
+224 -2
View File
@@ -1,2 +1,224 @@
def calculate_analytic(): import os
pass import json
from pathlib import Path
def find_file(filename: str, file_folders: list[str], is_folder: bool = True):
for folder in file_folders:
folder_path = Path(folder)
if not folder_path.exists():
continue
for file_path in folder_path.rglob(filename):
return str(folder_path) if is_folder else str(file_path)
return None
def get_size_format(b: int, factor=1024, suffix="B"):
for unit in ["", "K", "M", "G", "T"]:
if b < factor:
return f"{b:.2f} {unit}{suffix}"
b /= factor
return f"{b:.2f} P{suffix}"
def get_dir_size(path: str):
total = 0
if os.path.exists(path):
for f in os.listdir(path):
fp = os.path.join(path, f)
if os.path.isfile(fp):
total += os.path.getsize(fp)
return total
def get_file_list_size(file_list: list[str]):
total = 0
for fp in file_list:
if os.path.isfile(fp):
total += os.path.getsize(fp)
return total
def get_size_edge(type_search: bool, file_list: list[str]):
if type_search:
result = min(file_list, key=lambda f: Path(f).stat().st_size)
else:
result = max(file_list, key=lambda f: Path(f).stat().st_size)
return str(result)
def get_files_not_found(database, config):
all_db = database.get_all()
list_md5 = []
for one_post in all_db:
file_path = find_file(one_post["local_filename"], config["file"]["repos"], is_folder=False)
if file_path == None:
list_md5.append(one_post["md5"])
return list_md5
def calculate_analytic(database, config):
all_db = database.get_all()
explicit_db = database.get_search(["e"], [])
sensitive_db = database.get_search(["s"], [])
questionable_db = database.get_search(["q"], [])
general_db = database.get_search(["g"], [])
rating_list_file_path = {"explicit":[], "sensitive":[], "questionable":[], "general":[]}
type_list_file_path = {"images":[], "videos":[], "gifs":[]}
files_not_found = 0
for one_post in all_db:
file_path = find_file(one_post["local_filename"], config["file"]["repos"], is_folder=False)
if file_path != None:
file_path = str(file_path)
rating_list_file_path[one_post["rating"]].append(file_path)
if one_post["is_gif"] == 1:
type_list_file_path["gifs"].append(file_path)
elif one_post["is_video"] == 1:
type_list_file_path["videos"].append(file_path)
else:
type_list_file_path["images"].append(file_path)
else:
files_not_found += 1
file_size_dict = {"explicit":get_file_list_size(rating_list_file_path["explicit"]),
"sensitive":get_file_list_size(rating_list_file_path["sensitive"]),
"questionable":get_file_list_size(rating_list_file_path["questionable"]),
"general":get_file_list_size(rating_list_file_path["general"])
}
type_size_dict = {"images":get_file_list_size(type_list_file_path["images"]),
"videos":get_file_list_size(type_list_file_path["videos"]),
"gifs":get_file_list_size(type_list_file_path["gifs"])}
ratings_stats = {
"explicit": {
"count": len(explicit_db),
"size_formatted": get_size_format(file_size_dict["explicit"])
},
"sensitive": {
"count": len(sensitive_db),
"size_formatted": get_size_format(file_size_dict["sensitive"])
},
"questionable": {
"count": len(questionable_db),
"size_formatted": get_size_format(file_size_dict["questionable"])
},
"general": {
"count": len(general_db),
"size_formatted": get_size_format(file_size_dict["general"])
}
}
total_file_data_size = file_size_dict["general"]+file_size_dict["questionable"]+file_size_dict["sensitive"]+file_size_dict["explicit"]
total_thumb_size = get_dir_size(config["file"]["thumb"])
total_log_size = get_file_list_size(["logs.log"])
total_db_size = get_file_list_size([os.path.join(config["file"]["data"], "db.sqlite")])
weight_stats_smallest_file = get_size_edge(True, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
weight_stats_largest_file = get_size_edge(False, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
weight_stats_data = {
"smallest":{
"file":"",
"md5":"",
"size":0
},
"largest":{
"file":"",
"md5":"",
"size":0
}
}
resolution_stats_data = {
"smallest":{
"file":"",
"md5":"",
"res":{
"width":0,
"height":0,
"total":99999999999999999999999999
}
},
"largest":{
"file":"",
"md5":"",
"res":{
"width":0,
"height":0,
"total":0
}
}
}
for one_post_scan in all_db:
if one_post_scan["local_filename"] == os.path.basename(weight_stats_smallest_file):
weight_stats_data["smallest"] = {
"file":os.path.basename(weight_stats_smallest_file),
"md5":one_post_scan["md5"],
"size":get_file_list_size([weight_stats_smallest_file])
}
if one_post_scan["local_filename"] == os.path.basename(weight_stats_largest_file):
weight_stats_data["largest"] = {
"file":os.path.basename(weight_stats_largest_file),
"md5":one_post_scan["md5"],
"size":get_file_list_size([weight_stats_largest_file])
}
print(get_file_list_size([os.path.basename(weight_stats_largest_file)]))
one_post_res_total = one_post_scan["width"]*one_post_scan["height"]
if resolution_stats_data["largest"]["res"]["total"] < one_post_res_total:
resolution_stats_data["largest"] = {
"file":one_post_scan["local_filename"],
"md5":one_post_scan["md5"],
"res":{
"width":one_post_scan["width"],
"height":one_post_scan["height"],
"total":one_post_res_total
}
}
if resolution_stats_data["smallest"]["res"]["total"] > one_post_res_total:
resolution_stats_data["smallest"] = {
"file":one_post_scan["local_filename"],
"md5":one_post_scan["md5"],
"res":{
"width":one_post_scan["width"],
"height":one_post_scan["height"],
"total":one_post_res_total
}
}
result = {
"total_images_analyzed": len(all_db),
"files_not_found":files_not_found,
"average_weight": {
"bytes": round(total_file_data_size/(len(rating_list_file_path["explicit"])+len(rating_list_file_path["sensitive"])+len(rating_list_file_path["questionable"])+len(rating_list_file_path["general"])), 2),
"formatted": get_size_format(round(total_file_data_size/(len(rating_list_file_path["explicit"])+len(rating_list_file_path["sensitive"])+len(rating_list_file_path["questionable"])+len(rating_list_file_path["general"])), 2))
},
"storage_stats": {
"folders": {
"data": get_size_format(total_file_data_size),
"db": get_size_format(total_db_size),
"thumb": get_size_format(total_thumb_size),
"logs": get_size_format(total_log_size)
},
"types": {
"images": get_size_format(type_size_dict["images"]),
"videos": get_size_format(type_size_dict["videos"]),
"gifs": get_size_format(type_size_dict["gifs"])
},
"grand_total": get_size_format(total_file_data_size+total_thumb_size+total_log_size+total_db_size)
},
"ratings_stats": ratings_stats,
"resolution_stats": {
"largest": {
"filename": resolution_stats_data["largest"]["file"],
"md5": resolution_stats_data["largest"]["md5"],
"resolution": str(resolution_stats_data["largest"]["res"]["width"]) + " x " + str(resolution_stats_data["largest"]["res"]["height"])
},
"smallest": {
"filename": resolution_stats_data["smallest"]["file"],
"md5": resolution_stats_data["smallest"]["md5"],
"resolution": str(resolution_stats_data["smallest"]["res"]["width"]) + " x " + str(resolution_stats_data["smallest"]["res"]["height"])
}
},
"weight_stats": {
"largest": {
"filename": os.path.basename(weight_stats_data["largest"]["file"]),
"md5": weight_stats_data["largest"]["md5"],
"size_formatted": get_size_format(weight_stats_data["largest"]["size"])
},
"smallest": {
"filename": os.path.basename(weight_stats_data["smallest"]["file"]),
"md5": weight_stats_data["smallest"]["md5"],
"size_formatted": get_size_format(weight_stats_data["smallest"]["size"])
}
},
"top_tag":database.get_top_tag("")
}
with open(os.path.join(config["file"]["data"], "analytic.json"), 'w', encoding='utf-8') as out_f:
json.dump(result, out_f, indent=4, ensure_ascii=False)
return True
+92 -99
View File
@@ -41,22 +41,21 @@
.ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; } .ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; }
.ext-block a:hover { text-decoration: underline; } .ext-block a:hover { text-decoration: underline; }
.loader { color: var(--text-muted); font-size: 16px; }
@media (max-width: 1000px) { .dashboard-grid { grid-template-columns: 1fr 1fr; } .left-col { grid-column: 1/-1; display: grid; grid-template-columns: 1fr 1fr; } } @media (max-width: 1000px) { .dashboard-grid { grid-template-columns: 1fr 1fr; } .left-col { grid-column: 1/-1; display: grid; grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .dashboard-grid, .left-col { grid-template-columns: 1fr; } header { flex-wrap: wrap; } .search-wrapper { order: 3; min-width: 100%; margin-top: 10px;} } @media (max-width: 700px) { .dashboard-grid, .left-col { grid-template-columns: 1fr; } header { flex-wrap: wrap; } .search-wrapper { order: 3; min-width: 100%; margin-top: 10px;} }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<a href="index.html" class="logo">LocalBooru</a> <a href="/" class="logo">LocalBooru</a>
<a href="analytics.html" class="analytics-link">📊 Analytics</a>
<div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags...">
<ul id="autocompleteList" class="autocomplete-list"></ul>
</div>
</header> </header>
<main> <main>
<h1>Database Analytics</h1> <h1>Database Analytics</h1>
<div id="loadingIndicator" class="loader">Fetching data from API...</div>
<div class="dashboard-grid" id="analyticsGrid" style="display: none;"> <div class="dashboard-grid" id="analyticsGrid" style="display: none;">
<div class="left-col"> <div class="left-col">
<div class="panel"> <div class="panel">
@@ -68,7 +67,7 @@
<div class="data-list" id="storageContainer"></div> <div class="data-list" id="storageContainer"></div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header">🎨 Media Types (in img/)</div> <div class="panel-header">🎨 Media Types</div>
<div class="data-list" id="mediaContainer"></div> <div class="data-list" id="mediaContainer"></div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -78,125 +77,119 @@
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-header">🏷️ All Tags List</div> <div class="panel-header">🏷️ Top Tags List</div>
<div class="table-header"><span>Tag</span><span>Count</span></div> <div class="table-header"><span>Tag</span><span>Count</span></div>
<div class="data-list" id="tagsListContainer"></div> <div class="data-list" id="tagsListContainer"></div>
<div class="pagination"><button class="page-btn" onclick="prevTagPage()">&lsaquo;</button><span id="tagPageInfo" style="font-weight:bold; padding:0 10px;"></span><button class="page-btn" onclick="nextTagPage()">&rsaquo;</button></div> <div class="pagination">
<button class="page-btn" onclick="prevTagPage()">&lsaquo;</button>
<span id="tagPageInfo" style="font-weight:bold; padding:0 10px;"></span>
<button class="page-btn" onclick="nextTagPage()">&rsaquo;</button>
</div>
</div> </div>
<!-- Заменено, так как в новом JSON нет top_pairs -->
<div class="panel"> <div class="panel">
<div class="panel-header">🔗 Top 30 Tag Pairs</div> <div class="panel-header">️ System Overview</div>
<div class="table-header"><span>Pair</span><span>Count</span></div> <div class="data-list" id="overviewContainer"></div>
<div class="data-list" id="pairsContainer"></div>
</div> </div>
</div> </div>
</main> </main>
<script> <script>
const searchInput = document.getElementById('searchInput');
const list = document.getElementById('autocompleteList');
let debounceTimer; 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()}`; });
let allTags = [], currentTagPage = 1, tagsPerPage = 22; let allTags = [], currentTagPage = 1, tagsPerPage = 22;
// Укорачиваем длинные имена файлов (хэши) для красивого отображения
const shortenName = (name) => name.length > 20 ? name.substring(0, 10) + '...' + name.substring(name.length - 6) : name;
async function fetchAnalytics() { async function fetchAnalytics() {
const data = await mockAnalyticsAPI({ type: "analytics" }); try {
const response = await fetch('/api', {
document.getElementById('ratingContainer').innerHTML = Object.keys(data.ratings).map(k => ` method: 'POST',
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})"> headers: { 'Content-Type': 'application/json' },
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div> body: JSON.stringify({ type: "analytic" })
<div style="color:var(--text-muted); margin-bottom:3px;">${data.ratings[k].count} items</div> });
<div style="font-weight:bold;">${data.ratings[k].size}</div>
</div>
`).join('');
const s = data.storage; if (!response.ok) throw new Error("Network response was not ok");
document.getElementById('storageContainer').innerHTML = `
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div> const data = await response.json();
<div class="data-row"><span>DB:</span><span class="val">${s.db}</span></div>
<div class="data-row"><span>Thumb:</span><span class="val">${s.thumb}</span></div> // Скрываем лоадер, показываем сетку
<div class="data-row"><span>Img:</span><span class="val">${s.main}</span></div> document.getElementById('loadingIndicator').style.display = 'none';
<div class="data-row" style="color:var(--color-g); font-weight:bold; border-top:1px solid #444; border-bottom:none; padding-top:8px;"><span>GRAND TOTAL:</span><span>${s.total}</span></div> document.getElementById('analyticsGrid').style.display = 'grid';
`;
const m = data.media; // 1. Рейтинги
document.getElementById('mediaContainer').innerHTML = ` const rStats = data.ratings_stats;
<div class="data-row"><span>IMG:</span><span class="val">${m.img_size}</span></div> document.getElementById('ratingContainer').innerHTML = Object.keys(rStats).map(k => `
<div class="data-row"><span>VID:</span><span class="val">${m.vid_size}</span></div> <div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
<div class="data-row"><span>GIF:</span><span class="val">${m.gif_size}</span></div> <div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:5px;">Total: <b>${m.total_items}</b> | Avg: <b>${m.avg_weight}</b></div> <div style="color:var(--text-muted); margin-bottom:3px;">${rStats[k].count} items</div>
`; <div style="font-weight:bold;">${rStats[k].size_formatted}</div>
</div>
`).join('');
const e = data.extremes; // 2. Хранилище (Storage)
document.getElementById('extremesContainer').innerHTML = ` const s = data.storage_stats.folders;
<div class="ext-block"><b>Largest:</b> ${e.largest.val}<br>(<a href="post.html?id=${e.largest.id}">${e.largest.file}</a>)</div> document.getElementById('storageContainer').innerHTML = `
<div class="ext-block"><b>Smallest:</b> ${e.smallest.val}<br>(<a href="post.html?id=${e.smallest.id}">${e.smallest.file}</a>)</div> <div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
<div class="ext-block"><b>Heaviest:</b> ${e.heaviest.val}<br>(<a href="post.html?id=${e.heaviest.id}">${e.heaviest.file}</a>)</div> <div class="data-row"><span>DB:</span><span class="val">${s.db}</span></div>
<div class="ext-block"><b>Lightest:</b> ${e.lightest.val}<br>(<a href="post.html?id=${e.lightest.id}">${e.lightest.file}</a>)</div> <div class="data-row"><span>Thumb:</span><span class="val">${s.thumb}</span></div>
`; <div class="data-row"><span>Data (Img):</span><span class="val">${s.data}</span></div>
<div class="data-row" style="color:var(--color-g); font-weight:bold; border-top:1px solid #444; border-bottom:none; padding-top:8px;"><span>GRAND TOTAL:</span><span>${data.storage_stats.grand_total}</span></div>
`;
document.getElementById('pairsContainer').innerHTML = data.top_pairs.map(p => `<div class="tag-row"><a href="index.html?query=${p.pair.replace(/ \+ /g, '+')}">${p.pair}</a> <span>${p.count}</span></div>`).join(''); // 3. Медиа типы
const m = data.storage_stats.types;
document.getElementById('mediaContainer').innerHTML = `
<div class="data-row"><span>IMG:</span><span class="val">${m.images}</span></div>
<div class="data-row"><span>VID:</span><span class="val">${m.videos}</span></div>
<div class="data-row"><span>GIF:</span><span class="val">${m.gifs}</span></div>
<div style="font-size:11px; color:var(--text-muted); margin-top:5px;">Total: <b>${data.total_images_analyzed}</b> | Avg: <b>${data.average_weight.formatted}</b></div>
`;
allTags = data.all_tags; renderTagsPage(); // 4. Рекорды (Extremes)
document.getElementById('analyticsGrid').style.display = 'grid'; const res = data.resolution_stats;
const weight = data.weight_stats;
document.getElementById('extremesContainer').innerHTML = `
<div class="ext-block"><b>Largest Res:</b> ${res.largest.resolution}<br>(<a href="post/${res.largest.md5}">${shortenName(res.largest.filename)}</a>)</div>
<div class="ext-block"><b>Smallest Res:</b> ${res.smallest.resolution}<br>(<a href="post/${res.smallest.md5}">${shortenName(res.smallest.filename)}</a>)</div>
<div class="ext-block"><b>Heaviest:</b> ${weight.largest.size_formatted}<br>(<a href="post/${weight.largest.md5}">${shortenName(weight.largest.filename)}</a>)</div>
<div class="ext-block"><b>Lightest:</b> ${weight.smallest.size_formatted}<br>(<a href="post/${weight.smallest.md5}">${shortenName(weight.smallest.filename)}</a>)</div>
`;
// 5. System Overview (вместо Top Pairs)
document.getElementById('overviewContainer').innerHTML = `
<div class="data-row"><span>Images Analyzed:</span><span class="val" style="font-weight:bold;">${data.total_images_analyzed}</span></div>
<div class="data-row"><span>Files Not Found:</span><span class="val" style="font-weight:bold; color: ${data.files_not_found > 0 ? 'var(--color-e)' : 'inherit'};">${data.files_not_found}</span></div>
`;
// 6. Теги
allTags = data.top_tag;
renderTagsPage();
} catch (error) {
console.error('Error fetching analytics:', error);
document.getElementById('loadingIndicator').innerHTML = `<span style="color: var(--color-e)">Failed to load data. See console for details.</span>`;
}
} }
// Пагинация тегов
function renderTagsPage() { function renderTagsPage() {
document.getElementById('tagPageInfo').innerText = `${currentTagPage} / ${Math.ceil(allTags.length / tagsPerPage)}`; if (!allTags || allTags.length === 0) return;
const totalPages = Math.ceil(allTags.length / tagsPerPage) || 1;
document.getElementById('tagPageInfo').innerText = `${currentTagPage} / ${totalPages}`;
const start = (currentTagPage - 1) * tagsPerPage; const start = (currentTagPage - 1) * tagsPerPage;
document.getElementById('tagsListContainer').innerHTML = allTags.slice(start, start + tagsPerPage).map(t => `<div class="tag-row"><a href="index.html?query=${t.name}">${t.name}</a> <span>${t.count}</span></div>`).join(''); document.getElementById('tagsListContainer').innerHTML = allTags.slice(start, start + tagsPerPage).map(t =>
`<div class="tag-row"><a href="index.html?query=${encodeURIComponent(t.tag)}">${t.tag}</a> <span>${t.count}</span></div>`
).join('');
} }
window.prevTagPage = () => { if(currentTagPage > 1) { currentTagPage--; renderTagsPage(); }}; window.prevTagPage = () => { if(currentTagPage > 1) { currentTagPage--; renderTagsPage(); }};
window.nextTagPage = () => { if(currentTagPage < Math.ceil(allTags.length/tagsPerPage)) { currentTagPage++; renderTagsPage(); }}; window.nextTagPage = () => { if(currentTagPage < Math.ceil(allTags.length/tagsPerPage)) { currentTagPage++; renderTagsPage(); }};
// --- ЗАГЛУШКИ --- // Запуск
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()))); }); }
function mockAnalyticsAPI() {
return new Promise(res => { setTimeout(() => { res({
ratings: { explicit: { count: 1605, size: "4.52 GB" }, sensitive: { count: 6623, size: "19.35 GB" }, questionable: { count: 1591, size: "4.72 GB" }, general: { count: 4184, size: "10.29 GB" } },
storage: { logs: "14.44 MB", db: "24.39 MB", thumb: "331.45 MB", main: "38.88 GB", total: "39.24 GB" },
media: { img_size: "38.56 GB", vid_size: "140.80 MB", gif_size: "181.30 MB", total_items: 14003, avg_weight: "2.84 MB" },
extremes: {
largest: { val: "9921x14031", id: "5a63c", file: "5a63c28906dc7e0e...png" },
smallest: { val: "240x280", id: "8fc61", file: "8fc614e712ae2ad...gif" },
heaviest: { val: "65.21 MB", id: "4ab6a", file: "4ab6acf0a32c61...png" },
lightest: { val: "6.49 KB", id: "28c67", file: "28c679ea3483e1...png" }
},
all_tags: Array.from({length: 100}, (_,i) => ({name:`tag_${i}`, count:100-i})),
top_pairs: [{ pair: "1girl + highres", count: 10236 }, { pair: "1girl + solo", count: 8902 }]
}); }, 300);});
}
fetchAnalytics(); fetchAnalytics();
</script> </script>
</body> </body>
-2
View File
@@ -64,9 +64,7 @@
<header> <header>
<a href="/" class="logo">LocalBooru</a> <a href="/" class="logo">LocalBooru</a>
<!--
<a class="analytics" href="/analytics">📊 Analytics</a> <a class="analytics" href="/analytics">📊 Analytics</a>
-->
<a class="tasks" href="/task">⚙️ Tasks</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...">
+105 -63
View File
@@ -17,20 +17,18 @@
/* Search Autocomplete Styles */ /* Search Autocomplete Styles */
.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; }
.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 Content & Tasks Styles */
main { padding: 20px; max-width: 1000px; margin: 0 auto; } 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; } 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-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; } /* Добавил flex-wrap: wrap чтобы результаты могли падать на новую строку */
.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; flex-wrap: wrap; transition: background-color 0.2s; }
.task-card:hover { background-color: var(--bg-box); } .task-card:hover { background-color: var(--bg-box); }
.task-info { flex: 1; min-width: 250px; }
.task-info h3 { font-size: 16px; color: var(--link-color); margin-bottom: 5px; } .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-info p { color: var(--text-muted); font-size: 13px; line-height: 1.4; }
@@ -44,6 +42,10 @@
.task-status { font-weight: bold; font-size: 13px; min-width: 90px; text-align: right; } .task-status { font-weight: bold; font-size: 13px; min-width: 90px; text-align: right; }
.status-success { color: var(--color-g); } .status-success { color: var(--color-g); }
.status-error { color: var(--color-e); } .status-error { color: var(--color-e); }
/* Стили для вывода результатов */
.task-result-box { width: 100%; margin-top: 10px; border-top: 1px solid var(--border-color); padding-top: 15px; display: none; }
.task-result-box textarea { width: 100%; height: 150px; background-color: var(--bg-input); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 3px; padding: 10px; margin-top: 10px; resize: vertical; outline: none; font-family: monospace; }
@media (max-width: 700px) { @media (max-width: 700px) {
header { flex-wrap: wrap; } header { flex-wrap: wrap; }
@@ -56,10 +58,6 @@
<body> <body>
<header> <header>
<a href="/" class="logo">LocalBooru</a> <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> </header>
<main> <main>
@@ -79,48 +77,46 @@
</div> </div>
</div> </div>
<!-- ШАБЛОН ДЛЯ БУДУЩИХ ЗАДАЧ (просто скопируйте блок ниже и измените 'task_name') --> <!-- ЗАДАЧА 2: Поиск недоступных файлов -->
<!--
<div class="task-card"> <div class="task-card">
<div class="task-info"> <div class="task-info">
<h3>Новая задача</h3> <h3>Поиск недоступных файлов</h3>
<p>Описание новой задачи.</p> <p>Ищет посты, для которых отсутствуют физические файлы на диске. Выводит список ссылок на экран и предлагает скачать их в формате .txt.</p>
</div> </div>
<div class="task-actions"> <div class="task-actions">
<span class="task-status"></span> <span class="task-status"></span>
<button class="btn" onclick="executeTask('new_task_name', this)">Запустить</button> <button class="btn" onclick="executeNotFoundFilesTask(this)">Запустить</button>
</div>
<!-- Блок вывода результатов (изначально скрыт) -->
<div class="task-result-box" id="result-box-not-found">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Найдено битых ссылок: <b id="not-found-count" style="color: var(--color-e);">0</b></span>
<button class="btn" id="btn-download-txt" style="display: none;">Скачать .txt</button>
</div>
<textarea id="not-found-textarea" readonly></textarea>
</div> </div>
</div> </div>
-->
</div> </div>
</main> </main>
<script> <script>
// === ЛОГИКА ЗАДАЧ === // === БАЗОВАЯ ЛОГИКА ЗАДАЧ (БЕЗ ВОЗВРАТА ДАННЫХ) ===
async function executeTask(taskName, btn) { async function executeTask(taskName, btn) {
const statusSpan = btn.previousElementSibling; const statusSpan = btn.previousElementSibling;
// Состояние загрузки
btn.disabled = true; btn.disabled = true;
btn.innerText = "Выполнение..."; btn.innerText = "Выполнение...";
statusSpan.innerText = "В процессе ⏳"; statusSpan.innerText = "В процессе ⏳";
statusSpan.className = "task-status"; // сброс классов успеха/ошибки statusSpan.className = "task-status";
try { try {
// Отправляем POST запрос на API
const response = await fetch('/api', { const response = await fetch('/api', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json' body: JSON.stringify({ type: "task", task: taskName })
},
body: JSON.stringify({
type: "task",
task: taskName
})
}); });
// Обработка ответа (200 - успех, 500+ - ошибка)
if (response.ok) { if (response.ok) {
statusSpan.innerText = "Успешно ✔"; statusSpan.innerText = "Успешно ✔";
statusSpan.classList.add('status-success'); statusSpan.classList.add('status-success');
@@ -129,54 +125,100 @@
statusSpan.classList.add('status-error'); statusSpan.classList.add('status-error');
} }
} catch (error) { } catch (error) {
// Ошибка сети или сервер недоступен
statusSpan.innerText = "Сбой сети ✖"; statusSpan.innerText = "Сбой сети ✖";
statusSpan.classList.add('status-error'); statusSpan.classList.add('status-error');
console.error("Task execution error:", error); console.error("Task execution error:", error);
} finally { } finally {
// Возвращаем кнопку в исходное состояние
btn.disabled = false; btn.disabled = false;
btn.innerText = "Запустить"; btn.innerText = "Запустить";
} }
} }
// === ЛОГИКА ЗАДАЧИ: Поиск недоступных файлов ===
async function executeNotFoundFilesTask(btn) {
const statusSpan = btn.previousElementSibling;
// Элементы интерфейса для вывода
const resultBox = document.getElementById('result-box-not-found');
const textarea = document.getElementById('not-found-textarea');
const countSpan = document.getElementById('not-found-count');
const downloadBtn = document.getElementById('btn-download-txt');
btn.disabled = true;
btn.innerText = "Выполнение...";
statusSpan.innerText = "В процессе ⏳";
statusSpan.className = "task-status";
// Скрываем прошлый результат перед новым запуском
resultBox.style.display = 'none';
// === ЛОГИКА ПОИСКА (оставлена из предыдущей версии) === try {
const searchInput = document.getElementById('searchInput'); const response = await fetch('/api', {
const list = document.getElementById('autocompleteList'); method: 'POST',
let debounceTimer; headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: "task", task: "not_found_file" })
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'; }); if (response.ok) {
searchInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') window.location.href = `index.html?query=${searchInput.value.trim()}`; }); const data = await response.json();
const md5List = data.list || [];
statusSpan.innerText = "Успешно ✔";
statusSpan.classList.add('status-success');
// Заглушка для поиска (оставьте или замените на ваш fetch) if (md5List.length > 0) {
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()))); }); } // Формируем ссылки
const linksArray = md5List.map(md5 => `/post/${md5}`);
const linksText = linksArray.join('\n');
// Выводим на экран
countSpan.innerText = md5List.length;
textarea.value = linksText;
resultBox.style.display = 'block';
// Настраиваем скачивание
downloadBtn.style.display = 'block';
downloadBtn.onclick = () => downloadFileAsTxt(linksText, 'not_found_files.txt');
// Сразу инициируем скачивание
downloadFileAsTxt(linksText, 'not_found_files.txt');
} else {
// Если битых файлов нет
countSpan.innerText = '0';
textarea.value = "Недоступных файлов не обнаружено.";
downloadBtn.style.display = 'none';
resultBox.style.display = 'block';
}
} 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 = "Запустить";
}
}
// === ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ДЛЯ СКАЧИВАНИЯ TXT ===
function downloadFileAsTxt(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script> </script>
</body> </body>
</html> </html>
-5
View File
@@ -1,5 +0,0 @@
{
"host":"https://git.nekono.su",
"user":"Nyako",
"repo":"Local-Booru"
}
-59
View File
@@ -1,59 +0,0 @@
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!")