From 2df762565728cbc8a45b87ba5f5f220ac96c4ec7 Mon Sep 17 00:00:00 2001 From: lenafx Date: Sat, 6 Dec 2025 01:21:19 +0100 Subject: [PATCH] support for multiple users --- package-lock.json | 121 +++- package.json | 2 + server.js | 18 + src/api/anilist.ts | 91 +++ src/api/user/user.controller.ts | 174 ++++++ src/api/user/user.routes.ts | 15 + src/api/user/user.service.ts | 133 +++++ src/scripts/users.js | 688 +++++++++++++++++++++++ src/shared/database.js | 68 ++- src/shared/headless.js | 167 ++++-- src/views/views.routes.ts | 8 +- views/anime/{index.html => animes.html} | 2 +- views/books/books.html | 2 +- views/css/updateNotifier.css | 2 - views/css/users.css | 703 ++++++++++++++++++++++++ views/gallery/gallery.html | 2 +- views/gallery/image.html | 2 +- views/marketplace.html | 4 +- views/schedule.html | 2 +- views/users.html | 174 ++++++ 20 files changed, 2313 insertions(+), 65 deletions(-) create mode 100644 src/api/anilist.ts create mode 100644 src/api/user/user.controller.ts create mode 100644 src/api/user/user.routes.ts create mode 100644 src/api/user/user.service.ts create mode 100644 src/scripts/users.js rename views/anime/{index.html => animes.html} (98%) create mode 100644 views/css/users.css create mode 100644 views/users.html diff --git a/package-lock.json b/package-lock.json index f0ce3ec..3e0876c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,13 @@ "bindings": "^1.5.0", "dotenv": "^17.2.3", "fastify": "^5.6.2", + "jsonwebtoken": "^9.0.3", "node-addon-api": "^8.5.0", "playwright-chromium": "1.57.0", "sqlite3": "^5.1.7" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", "electron": "^39.2.5", "node-gyp": "^12.1.0", @@ -485,6 +487,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -495,6 +508,13 @@ "@types/node": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -819,6 +839,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1192,6 +1218,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron": { "version": "39.2.5", "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.5.tgz", @@ -2084,6 +2119,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2131,6 +2209,48 @@ ], "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -2420,7 +2540,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, "license": "MIT" }, "node_modules/napi-build-utils": { diff --git a/package.json b/package.json index 39f6a9d..1aa0174 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "bindings": "^1.5.0", "dotenv": "^17.2.3", "fastify": "^5.6.2", + "jsonwebtoken": "^9.0.3", "node-addon-api": "^8.5.0", "playwright-chromium": "1.57.0", "sqlite3": "^5.1.7" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", "electron": "^39.2.5", "node-gyp": "^12.1.0", diff --git a/server.js b/server.js index 6849693..a71cec3 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ const fastify = require('fastify')({ logger: true }); const path = require('path'); const { spawn } = require('child_process'); const fs = require('fs'); +const jwt = require("jsonwebtoken"); const { initHeadless } = require("./dist/shared/headless"); const { initDatabase } = require('./dist/shared/database'); const { loadExtensions } = require('./dist/shared/extensions'); @@ -16,6 +17,20 @@ const proxyRoutes = require('./dist/api/proxy/proxy.routes'); const extensionsRoutes = require('./dist/api/extensions/extensions.routes'); const galleryRoutes = require('./dist/api/gallery/gallery.routes'); const rpcRoutes = require('./dist/api/rpc/rpc.routes'); +const userRoutes = require('./dist/api/user/user.routes'); +const anilistRoute = require('./dist/api/anilist'); + + +fastify.addHook("preHandler", async (request) => { + const auth = request.headers.authorization; + if (!auth) return; + + try { + const token = auth.replace("Bearer ", ""); + request.user = jwt.verify(token, process.env.JWT_SECRET); + } catch (e) { + } +}); fastify.register(require('@fastify/static'), { root: path.join(__dirname, 'public'), @@ -42,6 +57,8 @@ fastify.register(proxyRoutes, { prefix: '/api' }); fastify.register(extensionsRoutes, { prefix: '/api' }); fastify.register(galleryRoutes, { prefix: '/api' }); fastify.register(rpcRoutes, { prefix: '/api' }); +fastify.register(userRoutes, { prefix: '/api' }); +fastify.register(anilistRoute, { prefix: '/api' }); function startCppScraper() { const exePath = path.join( @@ -83,6 +100,7 @@ const start = async () => { initDatabase("anilist"); initDatabase("favorites"); initDatabase("cache"); + initDatabase("userdata"); init() await loadExtensions(); diff --git a/src/api/anilist.ts b/src/api/anilist.ts new file mode 100644 index 0000000..f9cea42 --- /dev/null +++ b/src/api/anilist.ts @@ -0,0 +1,91 @@ +import { FastifyInstance } from "fastify"; +import { run } from "../shared/database"; + +async function anilist(fastify: FastifyInstance) { + fastify.get("/anilist", async (request, reply) => { + try { + const { code, state } = request.query as { code?: string; state?: string }; + + if (!code) return reply.status(400).send("No code"); + if (!state) return reply.status(400).send("No user state"); + + const userId = Number(state); + if (!userId || isNaN(userId)) { + return reply.status(400).send("Invalid user id"); + } + + const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: process.env.ANILIST_CLIENT_ID, + client_secret: process.env.ANILIST_CLIENT_SECRET, + redirect_uri: "http://localhost:54322/api/anilist", + code + }) + }); + + const tokenData = await tokenRes.json(); + + if (!tokenData.access_token) { + console.error("AniList token error:", tokenData); + return reply.status(500).send("Failed to get AniList token"); + } + + const userRes = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `${tokenData.token_type} ${tokenData.access_token}` + }, + body: JSON.stringify({ + query: `query { Viewer { id } }` + }) + }); + + const userData = await userRes.json(); + const anilistUserId = userData?.data?.Viewer?.id; + + if (!anilistUserId) { + console.error("AniList Viewer error:", userData); + return reply.status(500).send("Failed to fetch AniList user"); + } + + const expiresAt = new Date( + Date.now() + tokenData.expires_in * 1000 + ).toISOString(); + + await run( + ` + INSERT INTO UserIntegration + (user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + token_type = excluded.token_type, + anilist_user_id = excluded.anilist_user_id, + expires_at = excluded.expires_at + `, + [ + userId, + "AniList", + tokenData.access_token, + tokenData.refresh_token, + tokenData.token_type, + anilistUserId, + expiresAt + ], + "userdata" + ); + + return reply.redirect("http://localhost:54322/?anilist=success"); + } catch (e) { + console.error("AniList error:", e); + return reply.redirect("http://localhost:54322/?anilist=error"); + } + }); +} + +export default anilist; \ No newline at end of file diff --git a/src/api/user/user.controller.ts b/src/api/user/user.controller.ts new file mode 100644 index 0000000..bdff97c --- /dev/null +++ b/src/api/user/user.controller.ts @@ -0,0 +1,174 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import * as userService from './user.service'; +import jwt from "jsonwebtoken"; + +interface UserIdBody { userId: number; } +interface UserIdParams { id: string; } +interface CreateUserBody { username: string; profilePictureUrl?: string; } +interface UpdateUserBody { username?: string; profilePictureUrl?: string | null; password?: string; } +interface ConnectBody { accessToken: string; anilistUserId: number; expiresIn: number; } + +interface DBRunResult { changes: number; lastID: number; } + +export async function login(req: FastifyRequest, reply: FastifyReply) { + const { userId } = req.body as { userId: number }; + + if (!userId || typeof userId !== "number" || userId <= 0) { + return reply.code(400).send({ error: "Invalid userId provided" }); + } + + if (!await userService.userExists(userId)) { + return reply.code(404).send({ error: "User not found in local database" }); + } + + const token = jwt.sign( + { id: userId }, + process.env.JWT_SECRET!, + { expiresIn: "7d" } + ); + + return reply.code(200).send({ + success: true, + token + }); +} + +export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) { + try { + const users: any = await userService.getAllUsers(); + return { users }; + } catch (err) { + console.error("Get All Users Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to retrieve user list" }); + } +} + +export async function createUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { username, profilePictureUrl } = req.body as CreateUserBody; + + if (!username) { + return reply.code(400).send({ error: "Missing required field: username" }); + } + + const result: any = await userService.createUser(username, profilePictureUrl); + + return reply.code(201).send({ success: true, userId: result.lastID, username }); + } catch (err) { + + if ((err as Error).message.includes('SQLITE_CONSTRAINT')) { + return reply.code(409).send({ error: "Username already exists." }); + } + console.error("Create User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to create user" }); + } +} + +export async function getUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as UserIdParams; + const userId = parseInt(id, 10); + + const user: any = await userService.getUserById(userId); + + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + return { user }; + } catch (err) { + console.error("Get User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to retrieve user" }); + } +} + +export async function updateUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as UserIdParams; + const userId = parseInt(id, 10); + const updates = req.body as UpdateUserBody; + + if (Object.keys(updates).length === 0) { + return reply.code(400).send({ error: "No update fields provided" }); + } + + const result: DBRunResult = await userService.updateUser(userId, updates); + + if (result && result.changes > 0) { + return { success: true, message: "User updated successfully" }; + } else { + + return reply.code(404).send({ error: "User not found or nothing to update" }); + } + } catch (err) { + if ((err as Error).message.includes('SQLITE_CONSTRAINT')) { + return reply.code(409).send({ error: "Username already exists or is invalid." }); + } + console.error("Update User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to update user" }); + } +} + +export async function deleteUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const result = await userService.deleteUser(userId); + + if (result && result.changes > 0) { + return { success: true, message: "User deleted successfully" }; + } else { + return reply.code(404).send({ error: "User not found" }); + } + + } catch (err) { + console.error("Delete User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to delete user" }); + } +} + +export async function getIntegrationStatus(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const integration = await userService.getAniListIntegration(userId); + + return reply.code(200).send(integration); + + } catch (err) { + console.error("Get Integration Status Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to check integration status" }); + } +} + +export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const result = await userService.removeAniListIntegration(userId); + + if (result.changes === 0) { + return reply.code(404).send({ error: "AniList integration not found" }); + } + + return reply.send({ success: true }); + } catch (err) { + console.error("Disconnect AniList Error:", err); + return reply.code(500).send({ error: "Failed to disconnect AniList" }); + } +} diff --git a/src/api/user/user.routes.ts b/src/api/user/user.routes.ts new file mode 100644 index 0000000..0a1077d --- /dev/null +++ b/src/api/user/user.routes.ts @@ -0,0 +1,15 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './user.controller'; + +async function userRoutes(fastify: FastifyInstance) { + fastify.post("/login", controller.login); + fastify.get('/users', controller.getAllUsers); + fastify.post('/users', controller.createUser); + fastify.get('/users/:id', controller.getUser); + fastify.put('/users/:id', controller.updateUser); + fastify.delete('/users/:id', controller.deleteUser); + fastify.get('/users/:id/integration', controller.getIntegrationStatus); + fastify.delete('/users/:id/integration', controller.disconnectAniList); +} + +export default userRoutes; \ No newline at end of file diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts new file mode 100644 index 0000000..41d59e8 --- /dev/null +++ b/src/api/user/user.service.ts @@ -0,0 +1,133 @@ +import {queryAll, queryOne, run} from '../../shared/database'; + +const USER_DB_NAME = 'userdata'; + +interface User { id: number; username: string; profile_picture_url: string | null; } + +export async function userExists(id: number): Promise { + const sql = 'SELECT 1 FROM User WHERE id = ?'; + const row = queryOne(sql, [id], USER_DB_NAME); + return !!row; +} + +export async function createUser(username: string, profilePictureUrl?: string): Promise<{ lastID: number }> { + const sql = ` + INSERT INTO User (username, profile_picture_url) + VALUES (?, ?) + `; + const params = [username, profilePictureUrl || null]; + + const result = await run(sql, params, USER_DB_NAME); + + return { lastID: result.lastID }; +} + +export async function updateUser(userId: number, updates: any): Promise { + const fields: string[] = []; + const values: (string | number | null)[] = []; + + if (updates.username !== undefined) { + fields.push('username = ?'); + values.push(updates.username); + } + if (updates.profilePictureUrl !== undefined) { + + fields.push('profile_picture_url = ?'); + values.push(updates.profilePictureUrl); + } + if (updates.password !== undefined) { + + } + + if (fields.length === 0) { + return { changes: 0, lastID: userId }; + } + + const setClause = fields.join(', '); + const sql = `UPDATE User SET ${setClause} WHERE id = ?`; + values.push(userId); + + return await run(sql, values, USER_DB_NAME); +} + +export async function deleteUser(userId: number): Promise { + + await run( + `DELETE FROM ListEntry WHERE user_id = ?`, + [userId], + USER_DB_NAME + ); + + await run( + `DELETE FROM UserIntegration WHERE user_id = ?`, + [userId], + USER_DB_NAME + ); + + const result = await run( + `DELETE FROM User WHERE id = ?`, + [userId], + USER_DB_NAME + ); + + return result; +} + +export async function getAllUsers(): Promise { + const sql = 'SELECT id, username, profile_picture_url FROM User ORDER BY id'; + + const users = await queryAll(sql, [], USER_DB_NAME); + + return users.map((user: any) => ({ + id: user.id, + username: user.username, + profile_picture_url: user.profile_picture_url || null + })) as User[]; +} + +export async function getUserById(id: number): Promise { + const sql = ` + SELECT id, username, profile_picture_url, email + FROM User + WHERE id = ? + `; + + const user = await queryOne(sql, [id], USER_DB_NAME); + + if (!user) return null; + + return { + id: user.id, + username: user.username, + profile_picture_url: user.profile_picture_url || null + }; +} + +export async function getAniListIntegration(userId: number) { + const sql = ` + SELECT anilist_user_id, expires_at + FROM UserIntegration + WHERE user_id = ? AND platform = ? + `; + + const row = await queryOne(sql, [userId, "AniList"], USER_DB_NAME); + + if (!row) { + return { connected: false }; + } + + return { + connected: true, + anilistUserId: row.anilist_user_id, + expiresAt: row.expires_at + }; +} + +export async function removeAniListIntegration(userId: number) { + const sql = ` + DELETE FROM UserIntegration + WHERE user_id = ? AND platform = ? + `; + + return run(sql, [userId, "AniList"], USER_DB_NAME); +} diff --git a/src/scripts/users.js b/src/scripts/users.js new file mode 100644 index 0000000..35dc599 --- /dev/null +++ b/src/scripts/users.js @@ -0,0 +1,688 @@ +const API_BASE = '/api'; + +let users = []; +let selectedFile = null; +let currentUserId = null; + +const usersGrid = document.getElementById('usersGrid'); +const btnAddUser = document.getElementById('btnAddUser'); + +const modalCreateUser = document.getElementById('modalCreateUser'); +const closeCreateModal = document.getElementById('closeCreateModal'); +const cancelCreate = document.getElementById('cancelCreate'); +const createUserForm = document.getElementById('createUserForm'); +const usernameInput = document.getElementById('username'); +const avatarInput = document.getElementById('avatarInput'); +const avatarPreview = document.getElementById('avatarPreview'); +const avatarUploadArea = document.getElementById('avatarUploadArea'); + +const modalUserActions = document.getElementById('modalUserActions'); +const closeActionsModal = document.getElementById('closeActionsModal'); +const actionsModalTitle = document.getElementById('actionsModalTitle'); + +const modalEditUser = document.getElementById('modalEditUser'); +const closeEditModal = document.getElementById('closeEditModal'); +const cancelEdit = document.getElementById('cancelEdit'); +const editUserForm = document.getElementById('editUserForm'); +const editUsernameInput = document.getElementById('editUsername'); +const editAvatarPreview = document.getElementById('editAvatarPreview'); +const editAvatarUploadArea = document.getElementById('editAvatarUploadArea'); +const editAvatarInput = document.getElementById('editAvatarInput'); + +const modalAniList = document.getElementById('modalAniList'); +const closeAniListModal = document.getElementById('closeAniListModal'); +const aniListContent = document.getElementById('aniListContent'); + +const toastContainer = document.getElementById('userToastContainer'); + +const params = new URLSearchParams(window.location.search); +const anilistStatus = params.get("anilist"); + +if (anilistStatus === "success") { + showUserToast("✅ AniList connected successfully!"); +} + +if (anilistStatus === "error") { + showUserToast("❌ Failed to connect AniList"); +} +document.addEventListener('DOMContentLoaded', () => { + initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview'); + initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview'); + loadUsers(); + attachEventListeners(); +}); + +function authFetch(url, options = {}) { + const token = localStorage.getItem('token'); + const headers = { + ...(options.headers || {}), + }; + if (token) headers.Authorization = `Bearer ${token}`; + + return fetch(url, { ...options, headers }); +} + +function showUserToast(message, type = 'info') { + if (!toastContainer) return; + + const toast = document.createElement('div'); + toast.className = `wb-toast ${type}`; + toast.textContent = message; + + toastContainer.prepend(toast); + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => { + toast.classList.remove('show'); + toast.addEventListener('transitionend', () => toast.remove()); + }, 4000); +} + +function attachEventListeners() { + if (btnAddUser) btnAddUser.addEventListener('click', openCreateModal); + + if (closeCreateModal) closeCreateModal.addEventListener('click', closeModal); + if (cancelCreate) cancelCreate.addEventListener('click', closeModal); + if (closeAniListModal) closeAniListModal.addEventListener('click', closeModal); + if (closeActionsModal) closeActionsModal.addEventListener('click', closeModal); + if (closeEditModal) closeEditModal.addEventListener('click', closeModal); + if (cancelEdit) cancelEdit.addEventListener('click', closeModal); + + if (createUserForm) createUserForm.addEventListener('submit', handleCreateUser); + if (editUserForm) editUserForm.addEventListener('submit', handleEditUser); + + document.querySelectorAll('.modal-overlay').forEach(overlay => { + overlay.addEventListener('click', (e) => { + if (e.target.classList.contains('modal-overlay')) closeModal(); + }); + }); +} + +function initAvatarUpload(uploadAreaId, fileInputId, previewId) { + const uploadArea = document.getElementById(uploadAreaId); + const fileInput = document.getElementById(fileInputId); + const preview = document.getElementById(previewId); + + if (!uploadArea || !fileInput) return; + + uploadArea.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) handleFileSelect(file, previewId); + }); + + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('dragover'); + }); + + uploadArea.addEventListener('dragleave', () => { + uploadArea.classList.remove('dragover'); + }); + + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith('image/')) { + handleFileSelect(file, previewId); + } + }); +} + +function handleFileSelect(file, previewId) { + if (!file.type.startsWith('image/')) { + showUserToast('Please select an image file', 'error'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + showUserToast('Image size must be less than 5MB', 'error'); + return; + } + + selectedFile = file; + + const reader = new FileReader(); + reader.onload = (e) => { + const preview = document.getElementById(previewId); + if (preview) preview.innerHTML = `Avatar preview`; + }; + reader.readAsDataURL(file); +} + +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + }); +} + +async function loadUsers() { + try { + const res = await fetch(`${API_BASE}/users`); + if (!res.ok) throw new Error('Failed to fetch users'); + const data = await res.json(); + users = data.users || []; + renderUsers(); + } catch (err) { + console.error('Error loading users:', err); + showEmptyState(); + } +} + +async function createUser(username, profilePictureUrl) { + try { + const body = { username }; + if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl; + + const res = await fetch(`${API_BASE}/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error creating user'); + } + + return await res.json(); + } catch (err) { + throw err; + } +} + +async function updateUser(userId, username, profilePictureUrl) { + try { + const updates = { username }; + if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl; + + const res = await fetch(`${API_BASE}/users/${userId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error updating user'); + } + + return await res.json(); + } catch (err) { + throw err; + } +} + +async function deleteUser(userId) { + try { + const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error deleting user'); + } + return await res.json(); + } catch (err) { + throw err; + } +} + +function renderUsers() { + if (!usersGrid) return; + + if (users.length === 0) { + showEmptyState(); + return; + } + + usersGrid.innerHTML = ''; + users.forEach(user => { + const userCard = createUserCard(user); + usersGrid.appendChild(userCard); + }); +} + +function createUserCard(user) { + const card = document.createElement('div'); + card.className = 'user-card'; + + card.addEventListener('click', (e) => { + + if (!e.target.closest('.user-config-btn')) { + loginUser(user.id); + } + }); + + const avatarContent = user.profile_picture_url + ? `${user.username}` + : `
+ + + + +
`; + + card.innerHTML = ` +
${avatarContent}
+ + + `; + + const configBtn = card.querySelector('.user-config-btn'); + configBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openUserActionsModal(user.id); + }); + + return card; +} + +function showEmptyState() { + if (!usersGrid) return; + usersGrid.innerHTML = ` +
+ + + + + + +

No Users Yet

+

Create your first profile to get started

+
+ `; +} + +function openCreateModal() { + modalCreateUser.classList.add('active'); + if (usernameInput) usernameInput.focus(); + selectedFile = null; + if (avatarPreview) { + avatarPreview.innerHTML = ` + + + + + `; + } +} + +function closeModal() { + modalCreateUser.classList.remove('active'); + modalAniList.classList.remove('active'); + modalUserActions.classList.remove('active'); + modalEditUser.classList.remove('active'); + if (createUserForm) createUserForm.reset(); + if (editUserForm) editUserForm.reset(); + selectedFile = null; + const modalHeader = modalAniList.querySelector('.modal-header h2'); + if (modalHeader) modalHeader.textContent = 'AniList Integration'; +} + +async function handleCreateUser(e) { + e.preventDefault(); + + const username = document.getElementById('username').value.trim(); + if (!username) { + showUserToast('Please enter a username', 'error'); + return; + } + + const submitBtn = createUserForm.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Creating...'; + + try { + let profilePictureUrl = null; + if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile); + + await createUser(username, profilePictureUrl); + + closeModal(); + await loadUsers(); + showUserToast(`User ${username} created successfully!`, 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error creating user', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Create User'; + } +} + +function openUserActionsModal(userId) { + currentUserId = userId; + const user = users.find(u => u.id === userId); + if (!user) return; + + modalAniList.classList.remove('active'); + modalEditUser.classList.remove('active'); + + actionsModalTitle.textContent = `Manage ${user.username}`; + + const content = document.getElementById('actionsModalContent'); + if (!content) return; + + content.innerHTML = ` +
+ + + + +
+ `; + + modalUserActions.classList.add('active'); +} + +window.openEditModal = function(userId) { + currentUserId = userId; + modalUserActions.classList.remove('active'); + const user = users.find(u => u.id === userId); + if (!user) return; + + editUsernameInput.value = user.username || ''; + selectedFile = null; + + const avatarPlaceholder = ` + + + + + `; + + if (user.profile_picture_url) { + editAvatarPreview.innerHTML = `Avatar preview`; + } else { + editAvatarPreview.innerHTML = avatarPlaceholder; + } + + if (editAvatarInput) editAvatarInput.value = ''; + modalEditUser.classList.add('active'); +}; + +async function handleEditUser(e) { + e.preventDefault(); + + const user = users.find(u => u.id === currentUserId); + if (!user) return; + + const username = editUsernameInput.value.trim(); + if (!username) { + showUserToast('Please enter a username', 'error'); + return; + } + + const submitBtn = editUserForm.querySelector('.btn-primary'); + submitBtn.disabled = true; + submitBtn.textContent = 'Saving...'; + + try { + let profilePictureUrl; + if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile); + + await updateUser(currentUserId, username, profilePictureUrl); + + closeModal(); + await loadUsers(); + showUserToast('Profile updated successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error updating user', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Save Changes'; + } +} + +window.handleDeleteConfirmation = function(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + + closeModal(); + + showConfirmationModal( + 'Confirm Deletion', + `Are you absolutely sure you want to delete profile ${user.username}? This action cannot be undone.`, + `handleConfirmedDeleteUser(${userId})` + ); +}; + +window.handleConfirmedDeleteUser = async function(userId) { + closeModal(); + showUserToast('Deleting user...', 'info'); + + try { + await deleteUser(userId); + await loadUsers(); + showUserToast('User deleted successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast('Error deleting user', 'error'); + } +}; + +function showConfirmationModal(title, message, confirmAction) { + closeModal(); + + const modalHeader = modalAniList.querySelector('.modal-header h2'); + if (modalHeader) modalHeader.textContent = title; + + aniListContent.innerHTML = ` +
+ + + + + +

+ ${message} +

+
+ + +
+
+ `; + + modalAniList.classList.add('active'); +} + +function openAniListModal(userId) { + currentUserId = userId; + + aniListContent.innerHTML = `
Loading integration status...
`; + modalUserActions.classList.remove('active'); + modalEditUser.classList.remove('active'); + + getIntegrationStatus(userId).then(integration => { + aniListContent.innerHTML = ` +
+ ${integration.connected ? ` +
+
+ AniList +
+
+

Connected to AniList

+

User ID: ${integration.anilistUserId}

+

Expires: ${new Date(integration.expiresAt).toLocaleDateString()}

+
+
+ + ` : ` +
+

Connect with AniList

+

+ Sync your anime list by logging in with AniList. +

+ +
+ +
+ +

You will be redirected and then returned here.

+
+ `} +
+ `; + + modalAniList.classList.add('active'); + + }).catch(err => { + console.error(err); + aniListContent.innerHTML = `
Error loading integration status.
`; + modalAniList.classList.add('active'); + }); +} + +async function redirectToAniListLogin() { + try { + + const res = await fetch(`/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: currentUserId }) + }); + + if (!res.ok) throw new Error('Login failed before AniList redirect'); + + const data = await res.json(); + localStorage.setItem('token', data.token); + token = data.token; + + localStorage.setItem('anilist_link_user', currentUserId); + + const clientId = 32898; + const redirectUri = encodeURIComponent( + window.location.origin + '/api/anilist' + ); + + const state = encodeURIComponent(currentUserId); + + window.location.href = + `https://anilist.co/api/v2/oauth/authorize` + + `?client_id=${clientId}` + + `&response_type=code` + + `&redirect_uri=${redirectUri}` + + `&state=${state}`; + + } catch (err) { + console.error(err); + showUserToast('Error starting AniList login', 'error'); + } +} + +async function getIntegrationStatus(userId) { + try { + + const res = await fetch(`${API_BASE}/users/${userId}/integration`); + if (!res.ok) { + return { connected: false }; + } + return await res.json(); + } catch (err) { + console.error('getIntegrationStatus error', err); + return { connected: false }; + } +} + +async function disconnectAniList(userId) { + try { + + const res = await authFetch(`${API_BASE}/users/${userId}/integration`, { + method: 'DELETE' + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error disconnecting AniList'); + } + + return await res.json(); + } catch (err) { + throw err; + } +} + +window.handleDisconnectAniList = async function() { + if (!confirm('Are you sure you want to disconnect AniList?')) return; + + try { + await disconnectAniList(currentUserId); + showUserToast('AniList disconnected successfully', 'success'); + await openAniListModal(currentUserId); + } catch (err) { + console.error(err); + showUserToast('Error disconnecting AniList', 'error'); + } +}; + +async function loginUser(userId) { + try { + const res = await fetch(`${API_BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Login failed'); + } + + const data = await res.json(); + localStorage.setItem('token', data.token); + + window.location.href = '/anime'; + } catch (err) { + console.error('Login error', err); + showUserToast('Login failed', 'error'); + } +} + +window.openAniListModal = openAniListModal; +window.openEditModal = window.openEditModal; + +window.handleDeleteConfirmation = window.handleDeleteConfirmation; +window.handleConfirmedDeleteUser = window.handleConfirmedDeleteUser; +window.redirectToAniListLogin = redirectToAniListLogin; \ No newline at end of file diff --git a/src/shared/database.js b/src/shared/database.js index 3fed3a4..edab3d5 100644 --- a/src/shared/database.js +++ b/src/shared/database.js @@ -8,9 +8,70 @@ const databases = new Map(); const DEFAULT_PATHS = { anilist: path.join(process.cwd(), 'src', 'metadata', 'anilist_anime.db'), favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"), - cache: path.join(os.homedir(), "WaifuBoards", "cache.db") + cache: path.join(os.homedir(), "WaifuBoards", "cache.db"), + userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db") }; +async function ensureUserDataDB(dbPath) { + const dir = path.dirname(dbPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const db = new sqlite3.Database( + dbPath, + sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE + ); + + return new Promise((resolve, reject) => { + const schema = ` + -- Tabla 1: User + CREATE TABLE IF NOT EXISTS User ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + profile_picture_url TEXT, + email TEXT UNIQUE, + password_hash TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + -- Tabla 2: UserIntegration (✅ ACTUALIZADA) + CREATE TABLE IF NOT EXISTS UserIntegration ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + platform TEXT NOT NULL DEFAULT 'AniList', + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + token_type TEXT NOT NULL, + anilist_user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE + ); + + -- Tabla 3: ListEntry + CREATE TABLE IF NOT EXISTS ListEntry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + anime_id INTEGER NOT NULL, + external_id INTEGER, + source TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + score INTEGER, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, anime_id), + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE + ); + `; + + db.exec(schema, (err) => { + if (err) reject(err); + else resolve(true); + }); + }); +} + async function ensureExtensionsTable(db) { return new Promise((resolve, reject) => { db.exec(` @@ -128,6 +189,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) { } } + if (name === "userdata") { + ensureUserDataDB(finalPath) + .catch(err => console.error("Error creando userdata:", err)); + } + const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE); const db = new sqlite3.Database(finalPath, mode, (err) => { diff --git a/src/shared/headless.js b/src/shared/headless.js index 10865af..16bafb4 100644 --- a/src/shared/headless.js +++ b/src/shared/headless.js @@ -10,8 +10,6 @@ const BLOCK_LIST = [ "map", "cdn.ampproject.org", "googletagmanager" ]; -const ALLOWED_SCRIPTS = []; - async function initHeadless() { if (browser && browser.isConnected()) return; @@ -30,7 +28,6 @@ async function initHeadless() { "--mute-audio", "--no-first-run", "--no-zygote", - "--single-process", "--disable-software-rasterizer", "--disable-client-side-phishing-detection", "--no-default-browser-check", @@ -43,42 +40,103 @@ async function initHeadless() { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36" }); } catch (error) { - console.error("Error al inicializar browser:", error); + console.error("Error initializing browser:", error); throw error; } } +async function safeEvaluate(page, fn, ...args) { + const maxAttempts = 3; + + for (let i = 0; i < maxAttempts; i++) { + try { + // Checkeo de estado de página antes de intentar evaluar + if (page.isClosed()) { + throw new Error("Page is closed before evaluation"); + } + + return await Promise.race([ + page.evaluate(fn, ...args), + new Promise((_, reject) => + // Timeout más corto podría ser más seguro, e.g., 20000ms + setTimeout(() => reject(new Error("Evaluate timeout")), 30000) + ) + ]); + } catch (error) { + const errorMsg = (error.message || "").toLowerCase(); + const isLastAttempt = i === maxAttempts - 1; + + // Priorizar errores irrecuperables de contexto/página cerrada + if ( + page.isClosed() || + errorMsg.includes("closed") || + errorMsg.includes("target closed") || + errorMsg.includes("session closed") + ) { + console.error("Page context lost or closed, throwing fatal error."); + throw error; // Lanzar inmediatamente, no tiene sentido reintentar + } + + // Reintentar solo por errores transitorios de ejecución + if (!isLastAttempt && ( + errorMsg.includes("execution context was destroyed") || + errorMsg.includes("cannot find context") || + errorMsg.includes("timeout") + )) { + console.warn(`Evaluate attempt ${i + 1} failed, retrying...`, error.message); + await new Promise(r => setTimeout(r, 500 * (i + 1))); + continue; + } + + throw error; + } + } +} + async function turboScroll(page) { try { - await page.evaluate(() => { + + if (page.isClosed()) { + console.warn("Page closed, skipping scroll"); + return; + } + + await safeEvaluate(page, () => { return new Promise((resolve) => { - let last = 0; - let same = 0; - const timer = setInterval(() => { - const h = document.body.scrollHeight; - window.scrollTo(0, h); - if (h === last) { - same++; - if (same >= 5) { - clearInterval(timer); - resolve(); + let lastHeight = 0; + let sameCount = 0; + + const scrollInterval = setInterval(() => { + try { + const currentHeight = document.body.scrollHeight; + window.scrollTo(0, currentHeight); + + if (currentHeight === lastHeight) { + sameCount++; + if (sameCount >= 5) { + clearInterval(scrollInterval); + resolve(); + } + } else { + sameCount = 0; + lastHeight = currentHeight; } - } else { - same = 0; - last = h; + } catch (err) { + clearInterval(scrollInterval); + resolve(); } }, 20); - // Safety timeout setTimeout(() => { - clearInterval(timer); + clearInterval(scrollInterval); resolve(); }, 10000); }); }); + } catch (error) { - console.error("Error en turboScroll:", error.message); - // No lanzamos el error, continuamos + console.error("Error in turboScroll:", error.message); + } } @@ -90,7 +148,6 @@ async function scrape(url, handler, options = {}) { scrollToBottom = false, renderWaitTime = 0, loadImages = true, - blockScripts = true, retries = 3, retryDelay = 1000 } = options; @@ -101,7 +158,7 @@ async function scrape(url, handler, options = {}) { let page = null; try { - // Verificar que el browser esté activo + if (!browser || !browser.isConnected()) { await initHeadless(); } @@ -109,7 +166,10 @@ async function scrape(url, handler, options = {}) { page = await context.newPage(); const requests = []; - // Listener para requests + page.on("close", () => { + console.warn("Page closed unexpectedly"); + }); + page.on("request", req => { requests.push({ url: req.url(), @@ -118,7 +178,6 @@ async function scrape(url, handler, options = {}) { }); }); - // Route para bloquear recursos await page.route("**/*", (route) => { const req = route.request(); const resUrl = req.url().toLowerCase(); @@ -128,9 +187,7 @@ async function scrape(url, handler, options = {}) { type === "font" || type === "stylesheet" || type === "media" || - type === "manifest" || - type === "other" || - (blockScripts && type === "script" && !ALLOWED_SCRIPTS.some(k => resUrl.includes(k))) + type === "other" ) { return route.abort("blockedbyclient"); } @@ -148,66 +205,71 @@ async function scrape(url, handler, options = {}) { route.continue(); }); - // Navegar a la URL await page.goto(url, { waitUntil, timeout }); - // Esperar selector si se especifica + if (!page.isClosed()) { + await page.waitForTimeout(500); + } + if (waitSelector) { try { - await page.waitForSelector(waitSelector, { timeout: Math.min(timeout, 5000) }); + await page.waitForSelector(waitSelector, { + timeout: Math.min(timeout, 5000) + }); } catch (e) { - console.warn(`Selector '${waitSelector}' no encontrado, continuando...`); + console.warn(`Selector '${waitSelector}' not found, continuing...`); } } - // Scroll si es necesario if (scrollToBottom) { await turboScroll(page); } - // Tiempo de espera adicional para renderizado if (renderWaitTime > 0) { await page.waitForTimeout(renderWaitTime); } - // Ejecutar el handler personalizado - const result = await handler(page); + if (page.isClosed()) { + throw new Error("Page closed before handler execution"); + } + + const result = await handler(page, safeEvaluate); - // Cerrar la página antes de retornar await page.close(); return { result, requests }; } catch (error) { lastError = error; - console.error(`[Intento ${attempt}/${retries}] Error durante el scraping de ${url}:`, error.message); + console.error(`[Attempt ${attempt}/${retries}] Error scraping ${url}:`, error.message); - // Cerrar página si está abierta if (page && !page.isClosed()) { try { await page.close(); } catch (closeError) { - console.error("Error al cerrar página:", closeError.message); + console.error("Error closing page:", closeError.message); } } - // Si el browser está cerrado, limpiar referencias - if (error.message.includes("closed") || error.message.includes("Target closed")) { - console.log("Browser cerrado detectado, reiniciando..."); + if ( + error.message.includes("closed") || + error.message.includes("Target closed") || + error.message.includes("Session closed") + ) { + console.log("Browser closure detected, reinitializing..."); await closeScraper(); } - // Si no es el último intento, esperar antes de reintentar if (attempt < retries) { - const delay = retryDelay * attempt; // Backoff exponencial - console.log(`Reintentando en ${delay}ms...`); + const delay = retryDelay * attempt; + + console.log(`Retrying in ${delay}ms...`); await new Promise(r => setTimeout(r, delay)); } } } - // Si llegamos aquí, todos los intentos fallaron - console.error(`Todos los intentos fallaron para ${url}`); + console.error(`All attempts failed for ${url}`); throw lastError || new Error("Scraping failed after all retries"); } @@ -218,7 +280,7 @@ async function closeScraper() { context = null; } } catch (error) { - console.error("Error cerrando context:", error.message); + console.error("Error closing context:", error.message); } try { @@ -227,12 +289,13 @@ async function closeScraper() { browser = null; } } catch (error) { - console.error("Error cerrando browser:", error.message); + console.error("Error closing browser:", error.message); } } module.exports = { initHeadless, scrape, - closeScraper + closeScraper, + safeEvaluate }; \ No newline at end of file diff --git a/src/views/views.routes.ts b/src/views/views.routes.ts index d277397..0f470ab 100644 --- a/src/views/views.routes.ts +++ b/src/views/views.routes.ts @@ -3,9 +3,13 @@ import * as fs from 'fs'; import * as path from 'path'; async function viewsRoutes(fastify: FastifyInstance) { - fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => { - const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'index.html')); + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html')); reply.type('text/html').send(stream); }); diff --git a/views/anime/index.html b/views/anime/animes.html similarity index 98% rename from views/anime/index.html rename to views/anime/animes.html index 4968e01..cc167d6 100644 --- a/views/anime/index.html +++ b/views/anime/animes.html @@ -31,7 +31,7 @@