Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- database.js +2 -26
- server.js +13 -14
database.js
CHANGED
@@ -4,16 +4,11 @@ import fetch from 'node-fetch';
|
|
4 |
import { createGunzip } from 'zlib';
|
5 |
import { createInterface } from 'readline';
|
6 |
|
7 |
-
// --- Константы ---
|
8 |
const DB_FILE = './movies_search.db';
|
9 |
const DATA_URL = 'https://huggingface.co/datasets/opex792/kinopoisk/resolve/main/consolidated/kinopoisk.jsonl.gz?download=true';
|
10 |
|
11 |
let db;
|
12 |
|
13 |
-
/**
|
14 |
-
* Инициализирует соединение с базой данных и создает необходимые таблицы,
|
15 |
-
* включая FTS5-таблицу для полнотекстового поиска.
|
16 |
-
*/
|
17 |
export async function initializeDatabase() {
|
18 |
db = await open({ filename: DB_FILE, driver: sqlite3.Database });
|
19 |
console.log('Подключение к SQLite (Search API) установлено.');
|
@@ -21,7 +16,6 @@ export async function initializeDatabase() {
|
|
21 |
await db.exec('PRAGMA journal_mode = WAL;');
|
22 |
await db.exec('PRAGMA synchronous = NORMAL;');
|
23 |
|
24 |
-
// Таблица для хранения полных данных о фильмах
|
25 |
await db.exec(`
|
26 |
CREATE TABLE IF NOT EXISTS movies (
|
27 |
id INTEGER PRIMARY KEY,
|
@@ -29,7 +23,6 @@ export async function initializeDatabase() {
|
|
29 |
);
|
30 |
`);
|
31 |
|
32 |
-
// Виртуальная FTS5 таблица для эффективного полнотекстового поиска
|
33 |
await db.exec(`
|
34 |
CREATE VIRTUAL TABLE IF NOT EXISTS movie_names USING fts5(
|
35 |
movie_id UNINDEXED,
|
@@ -39,13 +32,11 @@ export async function initializeDatabase() {
|
|
39 |
`);
|
40 |
|
41 |
console.log('Таблицы `movies` и `movie_names` (FTS5) готовы к работе.');
|
|
|
|
|
42 |
return db;
|
43 |
}
|
44 |
|
45 |
-
/**
|
46 |
-
* Полностью обновляет данные: скачивает, распаковывает и индексирует фильмы.
|
47 |
-
* Работает в потоковом режиме, чтобы не потреблять много оперативной памяти.
|
48 |
-
*/
|
49 |
export async function refreshData() {
|
50 |
console.log('Начало полной загрузки и индексации данных...');
|
51 |
|
@@ -56,7 +47,6 @@ export async function refreshData() {
|
|
56 |
response.body.pipe(gunzip);
|
57 |
const rl = createInterface({ input: gunzip, crlfDelay: Infinity });
|
58 |
|
59 |
-
// Очищаем таблицы перед новой загрузкой
|
60 |
await db.exec('BEGIN TRANSACTION;');
|
61 |
await db.exec('DELETE FROM movies;');
|
62 |
await db.exec('DELETE FROM movie_names;');
|
@@ -72,10 +62,8 @@ export async function refreshData() {
|
|
72 |
const movie = JSON.parse(line);
|
73 |
if (!movie.id) continue;
|
74 |
|
75 |
-
// 1. Сохраняем полный JSON объекта фильма
|
76 |
await movieStmt.run(movie.id, JSON.stringify(movie));
|
77 |
|
78 |
-
// 2. Индексируем все возможные названия для поиска
|
79 |
const names = new Set();
|
80 |
if (movie.name) names.add(movie.name);
|
81 |
if (movie.alternativeName) names.add(movie.alternativeName);
|
@@ -99,15 +87,7 @@ export async function refreshData() {
|
|
99 |
console.log(`✅ Процесс завершен. Всего загружено и проиндексировано ${processedCount} фильмов.`);
|
100 |
}
|
101 |
|
102 |
-
/**
|
103 |
-
* Выполняет полнотекстовый поиск по проиндексированным названиям с пагинацией.
|
104 |
-
* @param {string} queryString - Поисковый запрос.
|
105 |
-
* @param {number} page - Номер страницы.
|
106 |
-
* @param {number} limit - Количество результатов на странице.
|
107 |
-
* @returns {Promise<{docs: Array<object>, total: number}>} Объект с результатами и общим количеством.
|
108 |
-
*/
|
109 |
export async function searchByName(queryString, page = 1, limit = 10) {
|
110 |
-
// Этап 1: Получаем общее количество уникальных фильмов, соответствующих запросу
|
111 |
const countResult = await db.get(
|
112 |
`SELECT count(DISTINCT movie_id) as total FROM movie_names WHERE movie_names MATCH ?`,
|
113 |
[queryString]
|
@@ -118,7 +98,6 @@ export async function searchByName(queryString, page = 1, limit = 10) {
|
|
118 |
return { docs: [], total: 0 };
|
119 |
}
|
120 |
|
121 |
-
// Этап 2: Получаем ID фильмов для текущей страницы, отсортированные по релевантности (rank)
|
122 |
const offset = (page - 1) * limit;
|
123 |
const searchResults = await db.all(
|
124 |
`SELECT DISTINCT movie_id FROM movie_names WHERE movie_names MATCH ? ORDER BY rank BM25(1.0, 5.0) LIMIT ? OFFSET ?`,
|
@@ -129,17 +108,14 @@ export async function searchByName(queryString, page = 1, limit = 10) {
|
|
129 |
return { docs: [], total };
|
130 |
}
|
131 |
|
132 |
-
// Этап 3: Извлекаем полные данные для найденных ID из нашей локальной таблицы `movies`
|
133 |
const movieIds = searchResults.map(r => r.movie_id);
|
134 |
const placeholders = movieIds.map(() => '?').join(',');
|
135 |
const moviesData = await db.all(`SELECT data FROM movies WHERE id IN (${placeholders})`, movieIds);
|
136 |
|
137 |
-
// Восстанавливаем порядок, заданный поиском
|
138 |
const movieMap = new Map(moviesData.map(m => {
|
139 |
const movieJson = JSON.parse(m.data);
|
140 |
return [movieJson.id, movieJson];
|
141 |
}));
|
142 |
-
|
143 |
const docs = movieIds.map(id => movieMap.get(id));
|
144 |
|
145 |
return { docs, total };
|
|
|
4 |
import { createGunzip } from 'zlib';
|
5 |
import { createInterface } from 'readline';
|
6 |
|
|
|
7 |
const DB_FILE = './movies_search.db';
|
8 |
const DATA_URL = 'https://huggingface.co/datasets/opex792/kinopoisk/resolve/main/consolidated/kinopoisk.jsonl.gz?download=true';
|
9 |
|
10 |
let db;
|
11 |
|
|
|
|
|
|
|
|
|
12 |
export async function initializeDatabase() {
|
13 |
db = await open({ filename: DB_FILE, driver: sqlite3.Database });
|
14 |
console.log('Подключение к SQLite (Search API) установлено.');
|
|
|
16 |
await db.exec('PRAGMA journal_mode = WAL;');
|
17 |
await db.exec('PRAGMA synchronous = NORMAL;');
|
18 |
|
|
|
19 |
await db.exec(`
|
20 |
CREATE TABLE IF NOT EXISTS movies (
|
21 |
id INTEGER PRIMARY KEY,
|
|
|
23 |
);
|
24 |
`);
|
25 |
|
|
|
26 |
await db.exec(`
|
27 |
CREATE VIRTUAL TABLE IF NOT EXISTS movie_names USING fts5(
|
28 |
movie_id UNINDEXED,
|
|
|
32 |
`);
|
33 |
|
34 |
console.log('Таблицы `movies` и `movie_names` (FTS5) готовы к работе.');
|
35 |
+
|
36 |
+
// ИСПРАВЛЕНИЕ: Возвращаем объект базы данных для использования в других модулях.
|
37 |
return db;
|
38 |
}
|
39 |
|
|
|
|
|
|
|
|
|
40 |
export async function refreshData() {
|
41 |
console.log('Начало полной загрузки и индексации данных...');
|
42 |
|
|
|
47 |
response.body.pipe(gunzip);
|
48 |
const rl = createInterface({ input: gunzip, crlfDelay: Infinity });
|
49 |
|
|
|
50 |
await db.exec('BEGIN TRANSACTION;');
|
51 |
await db.exec('DELETE FROM movies;');
|
52 |
await db.exec('DELETE FROM movie_names;');
|
|
|
62 |
const movie = JSON.parse(line);
|
63 |
if (!movie.id) continue;
|
64 |
|
|
|
65 |
await movieStmt.run(movie.id, JSON.stringify(movie));
|
66 |
|
|
|
67 |
const names = new Set();
|
68 |
if (movie.name) names.add(movie.name);
|
69 |
if (movie.alternativeName) names.add(movie.alternativeName);
|
|
|
87 |
console.log(`✅ Процесс завершен. Всего загружено и проиндексировано ${processedCount} фильмов.`);
|
88 |
}
|
89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
90 |
export async function searchByName(queryString, page = 1, limit = 10) {
|
|
|
91 |
const countResult = await db.get(
|
92 |
`SELECT count(DISTINCT movie_id) as total FROM movie_names WHERE movie_names MATCH ?`,
|
93 |
[queryString]
|
|
|
98 |
return { docs: [], total: 0 };
|
99 |
}
|
100 |
|
|
|
101 |
const offset = (page - 1) * limit;
|
102 |
const searchResults = await db.all(
|
103 |
`SELECT DISTINCT movie_id FROM movie_names WHERE movie_names MATCH ? ORDER BY rank BM25(1.0, 5.0) LIMIT ? OFFSET ?`,
|
|
|
108 |
return { docs: [], total };
|
109 |
}
|
110 |
|
|
|
111 |
const movieIds = searchResults.map(r => r.movie_id);
|
112 |
const placeholders = movieIds.map(() => '?').join(',');
|
113 |
const moviesData = await db.all(`SELECT data FROM movies WHERE id IN (${placeholders})`, movieIds);
|
114 |
|
|
|
115 |
const movieMap = new Map(moviesData.map(m => {
|
116 |
const movieJson = JSON.parse(m.data);
|
117 |
return [movieJson.id, movieJson];
|
118 |
}));
|
|
|
119 |
const docs = movieIds.map(id => movieMap.get(id));
|
120 |
|
121 |
return { docs, total };
|
server.js
CHANGED
@@ -5,18 +5,17 @@ import { initializeDatabase, refreshData, searchByName } from './database.js';
|
|
5 |
|
6 |
// --- Константы ---
|
7 |
const PORT = process.env.PORT || 7860;
|
8 |
-
const API_VERSION = "1.4";
|
9 |
|
10 |
// --- Настройка приложения Express ---
|
11 |
const app = express();
|
12 |
app.use(cors());
|
13 |
app.use(express.json());
|
14 |
|
15 |
-
let isReady = false;
|
16 |
|
17 |
// --- Middleware для проверки готовности ---
|
18 |
app.use((req, res, next) => {
|
19 |
-
// Пропускаем health-check, чтобы всегда можно было проверить состояние
|
20 |
if (!isReady && req.path !== '/health') {
|
21 |
return res.status(503).json({ message: 'Сервис временно недоступен, идет инициализация данных.', statusCode: 503 });
|
22 |
}
|
@@ -28,7 +27,6 @@ app.get('/health', (req, res) => {
|
|
28 |
res.status(200).json({ status: isReady ? 'UP' : 'DOWN', message: isReady ? 'Сервис готов к работе' : 'Идет инициализация' });
|
29 |
});
|
30 |
|
31 |
-
// Эндпоинт для поиска, полностью совместимый с документацией
|
32 |
app.get(`/v${API_VERSION}/movie/search`, async (req, res) => {
|
33 |
const { query, page, limit } = req.query;
|
34 |
|
@@ -37,15 +35,12 @@ app.get(`/v${API_VERSION}/movie/search`, async (req, res) => {
|
|
37 |
}
|
38 |
|
39 |
const pageNum = parseInt(page, 10) || 1;
|
40 |
-
const limitNum = Math.min(parseInt(limit, 10) || 10, 250);
|
41 |
|
42 |
try {
|
43 |
const { docs, total } = await searchByName(query, pageNum, limitNum);
|
44 |
const pages = Math.ceil(total / limitNum);
|
45 |
-
|
46 |
-
// Формируем ответ в строгом соответствии со спецификацией
|
47 |
res.json({ docs, total, limit: limitNum, page: pageNum, pages });
|
48 |
-
|
49 |
} catch (e) {
|
50 |
console.error(`Ошибка при поиске по запросу "${query}":`, e);
|
51 |
res.status(500).json({ message: 'Внутренняя ошибка сервера при поиске.', error: e.message });
|
@@ -55,16 +50,21 @@ app.get(`/v${API_VERSION}/movie/search`, async (req, res) => {
|
|
55 |
// --- Логика запуска сервера ---
|
56 |
(async () => {
|
57 |
try {
|
58 |
-
|
|
|
59 |
|
60 |
const needsRebuild = process.argv.includes('--rebuild-index');
|
61 |
-
|
|
|
|
|
|
|
62 |
|
63 |
-
|
64 |
-
if (needsRebuild || count.count === 0) {
|
65 |
if (needsRebuild) console.log('Обнаружен флаг --rebuild-index. Запуск принудительной переиндексации.');
|
66 |
-
if (
|
67 |
await refreshData();
|
|
|
|
|
68 |
}
|
69 |
|
70 |
isReady = true;
|
@@ -75,7 +75,6 @@ app.get(`/v${API_VERSION}/movie/search`, async (req, res) => {
|
|
75 |
console.log(`URL для поиска: http://localhost:${PORT}/v${API_VERSION}/movie/search`);
|
76 |
});
|
77 |
|
78 |
-
// Планируем автоматическое обновление данных каждый день в 3:00 ночи
|
79 |
cron.schedule('0 3 * * *', async () => {
|
80 |
console.log('Начало планового ежедневного обновления данных...');
|
81 |
isReady = false;
|
|
|
5 |
|
6 |
// --- Константы ---
|
7 |
const PORT = process.env.PORT || 7860;
|
8 |
+
const API_VERSION = "1.4";
|
9 |
|
10 |
// --- Настройка приложения Express ---
|
11 |
const app = express();
|
12 |
app.use(cors());
|
13 |
app.use(express.json());
|
14 |
|
15 |
+
let isReady = false;
|
16 |
|
17 |
// --- Middleware для проверки готовности ---
|
18 |
app.use((req, res, next) => {
|
|
|
19 |
if (!isReady && req.path !== '/health') {
|
20 |
return res.status(503).json({ message: 'Сервис временно недоступен, идет инициализация данных.', statusCode: 503 });
|
21 |
}
|
|
|
27 |
res.status(200).json({ status: isReady ? 'UP' : 'DOWN', message: isReady ? 'Сервис готов к работе' : 'Идет инициализация' });
|
28 |
});
|
29 |
|
|
|
30 |
app.get(`/v${API_VERSION}/movie/search`, async (req, res) => {
|
31 |
const { query, page, limit } = req.query;
|
32 |
|
|
|
35 |
}
|
36 |
|
37 |
const pageNum = parseInt(page, 10) || 1;
|
38 |
+
const limitNum = Math.min(parseInt(limit, 10) || 10, 250);
|
39 |
|
40 |
try {
|
41 |
const { docs, total } = await searchByName(query, pageNum, limitNum);
|
42 |
const pages = Math.ceil(total / limitNum);
|
|
|
|
|
43 |
res.json({ docs, total, limit: limitNum, page: pageNum, pages });
|
|
|
44 |
} catch (e) {
|
45 |
console.error(`Ошибка при поиске по запросу "${query}":`, e);
|
46 |
res.status(500).json({ message: 'Внутренняя ошибка сервера при поиске.', error: e.message });
|
|
|
50 |
// --- Логика запуска сервера ---
|
51 |
(async () => {
|
52 |
try {
|
53 |
+
// ИСПРАВЛЕНИЕ: Получаем объект 'db' после инициализации.
|
54 |
+
const db = await initializeDatabase();
|
55 |
|
56 |
const needsRebuild = process.argv.includes('--rebuild-index');
|
57 |
+
|
58 |
+
// ИСПРАВЛЕНИЕ: Используем полученный 'db' для запроса.
|
59 |
+
const result = await db.get('SELECT COUNT(*) as count FROM movies');
|
60 |
+
const moviesCount = result.count;
|
61 |
|
62 |
+
if (needsRebuild || moviesCount === 0) {
|
|
|
63 |
if (needsRebuild) console.log('Обнаружен флаг --rebuild-index. Запуск принудительной переиндексации.');
|
64 |
+
if (moviesCount === 0) console.log('База данных пуста. Запуск первоначальной загрузки.');
|
65 |
await refreshData();
|
66 |
+
} else {
|
67 |
+
console.log(`В базе уже есть ${moviesCount} фильмов. Запуск в штатном режиме.`);
|
68 |
}
|
69 |
|
70 |
isReady = true;
|
|
|
75 |
console.log(`URL для поиска: http://localhost:${PORT}/v${API_VERSION}/movie/search`);
|
76 |
});
|
77 |
|
|
|
78 |
cron.schedule('0 3 * * *', async () => {
|
79 |
console.log('Начало планового ежедневного обновления данных...');
|
80 |
isReady = false;
|