0.2.0b #1

Merged
Nyako merged 9 commits from dev into main 2026-06-06 12:34:50 +00:00
8 changed files with 193 additions and 173 deletions
Showing only changes of commit 2c2aa65947 - Show all commits
+1
View File
@@ -53,6 +53,7 @@ Config explanation:
| `file`-`repos` | `["data/files"]` | List of directories where source files are stored |
| `file`-`dw` | `"data/files"` | Directory for new downloads |
| `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.
2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js).
+1
View File
@@ -54,6 +54,7 @@
| `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы |
| `file`-`dw` | `"data/files"` | Каталог хранения загрузок |
| `file`-`thumb` | `"data/thumb"` | Каталог обложек |
| `file`-`data` | `"data"` | Каталог базы данных |
1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы!
+6 -3
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import requests
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
@@ -26,12 +26,13 @@ GB_HEADERS = CONFIG_ROOT["gb"]["headers"]
FILE_FOLDERS = CONFIG_ROOT["file"]["repos"]
FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"]
THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"]
DATA_FOLDER = CONFIG_ROOT["file"]["data"]
gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS)
log = logger.Logger()
log.init_global_exception_hook()
database = db.DB(True)
database = db.DB(DATA_FOLDER)
app = Flask(__name__)
if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!")
@@ -54,7 +55,7 @@ def post(post_id):
@app.route('/file/<path:filename>')
def serve_image(filename):
folder_path = task.find_file(filename, FILE_FOLDERS)
folder_path = tasks.find_file(filename, FILE_FOLDERS)
if folder_path == None:
return make_response("Not found", 404)
else:
@@ -91,6 +92,8 @@ def api():
post_id = data["post_id"]
result_post = database.get_from_id(post_id)
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":
if data["task"] == "analytic":
if tasks.calculate_analytic(database, CONFIG_ROOT):
+1 -5
View File
@@ -7,11 +7,7 @@ from system_module.gelbooru import gelbooru_date_parse
class DB:
def __init__(self, debug: bool):
if debug:
db_path = "data_test"
else:
db_path = "data"
def __init__(self, db_path: str):
if not os.path.exists(db_path):
os.makedirs(db_path)
+92 -22
View File
@@ -2,13 +2,13 @@ import os
import json
from pathlib import Path
def find_file(filename, file_folders):
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 folder_path
return str(folder_path) if is_folder else str(file_path)
return None
def get_size_format(b, factor=1024, suffix="B"):
@@ -36,9 +36,10 @@ def get_file_list_size(file_list: list[str]):
def get_size_edge(type_searсh: bool, file_list: list[str]):
if type_searсh:
return min(file_list, key=os.path.getsize)
result = min(file_list, key=lambda f: Path(f).stat().st_size)
else:
return max(file_list, key=os.path.getsize)
result = max(file_list, key=lambda f: Path(f).stat().st_size)
return str(result)
def calculate_analytic(database, config):
all_db = database.get_all()
@@ -50,8 +51,9 @@ def calculate_analytic(database, config):
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"])
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)
@@ -88,11 +90,78 @@ def calculate_analytic(database, config):
}
}
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_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("data", "db.sqlite")])
weight_stats_smallest = get_size_edge(True, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
weight_stats_largest = get_size_edge(False, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
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,
@@ -117,29 +186,30 @@ def calculate_analytic(database, config):
"ratings_stats": ratings_stats,
"resolution_stats": {
"largest": {
"filename": max_res["filename"],
"md5": max_res["md5"],
"resolution": f'{max_res["width"]}x{max_res["height"]}'
"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": min_res["filename"],
"md5": min_res["md5"],
"resolution": f'{min_res["width"]}x{min_res["height"]}'
"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": weight_stats_largest,
"md5": max_weight["md5"],
"size_formatted": get_size_format(max_weight["size"])
"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": weight_stats_smallest,
"md5": min_weight["md5"],
"size_formatted": get_size_format(min_weight["size"])
"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)
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: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: 700px) { .dashboard-grid, .left-col { grid-template-columns: 1fr; } header { flex-wrap: wrap; } .search-wrapper { order: 3; min-width: 100%; margin-top: 10px;} }
</style>
</head>
<body>
<header>
<a href="index.html" 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>
<a href="/" class="logo">LocalBooru</a>
</header>
<main>
<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="left-col">
<div class="panel">
@@ -68,7 +67,7 @@
<div class="data-list" id="storageContainer"></div>
</div>
<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>
<div class="panel">
@@ -78,125 +77,119 @@
</div>
<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="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>
<!-- Заменено, так как в новом JSON нет top_pairs -->
<div class="panel">
<div class="panel-header">🔗 Top 30 Tag Pairs</div>
<div class="table-header"><span>Pair</span><span>Count</span></div>
<div class="data-list" id="pairsContainer"></div>
<div class="panel-header">️ System Overview</div>
<div class="data-list" id="overviewContainer"></div>
</div>
</div>
</main>
<script>
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()}`; });
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() {
const data = await mockAnalyticsAPI({ type: "analytics" });
document.getElementById('ratingContainer').innerHTML = Object.keys(data.ratings).map(k => `
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div>
<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('');
try {
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: "analytic" })
});
const s = data.storage;
document.getElementById('storageContainer').innerHTML = `
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
<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>
<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>
`;
if (!response.ok) throw new Error("Network response was not ok");
const data = await response.json();
// Скрываем лоадер, показываем сетку
document.getElementById('loadingIndicator').style.display = 'none';
document.getElementById('analyticsGrid').style.display = 'grid';
const m = data.media;
document.getElementById('mediaContainer').innerHTML = `
<div class="data-row"><span>IMG:</span><span class="val">${m.img_size}</span></div>
<div class="data-row"><span>VID:</span><span class="val">${m.vid_size}</span></div>
<div class="data-row"><span>GIF:</span><span class="val">${m.gif_size}</span></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>
`;
// 1. Рейтинги
const rStats = data.ratings_stats;
document.getElementById('ratingContainer').innerHTML = Object.keys(rStats).map(k => `
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</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;
document.getElementById('extremesContainer').innerHTML = `
<div class="ext-block"><b>Largest:</b> ${e.largest.val}<br>(<a href="post.html?id=${e.largest.id}">${e.largest.file}</a>)</div>
<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="ext-block"><b>Heaviest:</b> ${e.heaviest.val}<br>(<a href="post.html?id=${e.heaviest.id}">${e.heaviest.file}</a>)</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>
`;
// 2. Хранилище (Storage)
const s = data.storage_stats.folders;
document.getElementById('storageContainer').innerHTML = `
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
<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>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();
document.getElementById('analyticsGrid').style.display = 'grid';
// 4. Рекорды (Extremes)
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() {
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;
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.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();
</script>
</body>
-2
View File
@@ -64,9 +64,7 @@
<header>
<a href="/" class="logo">LocalBooru</a>
<!--
<a class="analytics" href="/analytics">📊 Analytics</a>
-->
<a class="tasks" href="/task">⚙️ Tasks</a>
<div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags...">
-42
View File
@@ -56,10 +56,6 @@
<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>
@@ -139,44 +135,6 @@
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>