removed scrapper
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
src/metadata/json.hpp linguist-detectable=false
|
|
||||||
38
server.js
38
server.js
@@ -1,7 +1,5 @@
|
|||||||
const fastify = require('fastify')({ logger: true });
|
const fastify = require('fastify')({ logger: true });
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const fs = require('fs');
|
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const { initHeadless } = require("./dist/shared/headless");
|
const { initHeadless } = require("./dist/shared/headless");
|
||||||
const { initDatabase } = require('./dist/shared/database');
|
const { initDatabase } = require('./dist/shared/database');
|
||||||
@@ -63,40 +61,6 @@ fastify.register(userRoutes, { prefix: '/api' });
|
|||||||
fastify.register(anilistRoute, { prefix: '/api' });
|
fastify.register(anilistRoute, { prefix: '/api' });
|
||||||
fastify.register(listRoutes, { prefix: '/api' });
|
fastify.register(listRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
function startCppScraper() {
|
|
||||||
const exePath = path.join(
|
|
||||||
__dirname,
|
|
||||||
'src',
|
|
||||||
'metadata',
|
|
||||||
process.platform === 'win32' ? 'anilist.exe' : 'anilist'
|
|
||||||
);
|
|
||||||
const dllPath = path.join(__dirname, 'src', 'metadata', 'binaries');
|
|
||||||
|
|
||||||
if (!fs.existsSync(exePath)) {
|
|
||||||
console.error(`❌ C++ Error: Could not find executable at: ${exePath}`);
|
|
||||||
console.error(" Did you compile it? (g++ src/metadata/anilist.cpp -o src/metadata/anilist.exe ...)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = { ...process.env };
|
|
||||||
env.PATH = `${dllPath};${env.PATH}`;
|
|
||||||
|
|
||||||
console.log("⚡ Starting WaifuBoard Scraper Engine (C++)...");
|
|
||||||
|
|
||||||
const scraper = spawn(exePath, [], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: __dirname,
|
|
||||||
env: env
|
|
||||||
});
|
|
||||||
|
|
||||||
scraper.on('error', (err) => {
|
|
||||||
console.error('❌ Failed to spawn C++ process:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
scraper.on('close', (code) => {
|
|
||||||
console.log(`✅ Scraper process finished with code ${code}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -111,8 +75,6 @@ const start = async () => {
|
|||||||
await fastify.listen({ port: 54322, host: '0.0.0.0' });
|
await fastify.listen({ port: 54322, host: '0.0.0.0' });
|
||||||
console.log(`Server running at http://localhost:54322`);
|
console.log(`Server running at http://localhost:54322`);
|
||||||
|
|
||||||
startCppScraper();
|
|
||||||
|
|
||||||
await initHeadless();
|
await initHeadless();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,50 +3,268 @@ import { queryAll, queryOne } from '../../shared/database';
|
|||||||
import {Anime, Episode, Extension, StreamData} from '../types';
|
import {Anime, Episode, Extension, StreamData} from '../types';
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TTL = 60 * 60 * 6;
|
||||||
|
|
||||||
|
const ANILIST_URL = "https://graphql.anilist.co";
|
||||||
|
|
||||||
|
const MEDIA_FIELDS = `
|
||||||
|
id
|
||||||
|
idMal
|
||||||
|
title { romaji english native userPreferred }
|
||||||
|
type
|
||||||
|
format
|
||||||
|
status
|
||||||
|
description
|
||||||
|
startDate { year month day }
|
||||||
|
endDate { year month day }
|
||||||
|
season
|
||||||
|
seasonYear
|
||||||
|
episodes
|
||||||
|
duration
|
||||||
|
chapters
|
||||||
|
volumes
|
||||||
|
countryOfOrigin
|
||||||
|
isLicensed
|
||||||
|
source
|
||||||
|
hashtag
|
||||||
|
trailer { id site thumbnail }
|
||||||
|
updatedAt
|
||||||
|
coverImage { extraLarge large medium color }
|
||||||
|
bannerImage
|
||||||
|
genres
|
||||||
|
synonyms
|
||||||
|
averageScore
|
||||||
|
popularity
|
||||||
|
isLocked
|
||||||
|
trending
|
||||||
|
favourites
|
||||||
|
isAdult
|
||||||
|
siteUrl
|
||||||
|
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult }
|
||||||
|
relations {
|
||||||
|
edges {
|
||||||
|
relationType
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title { romaji }
|
||||||
|
type
|
||||||
|
format
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
studios {
|
||||||
|
edges {
|
||||||
|
isMain
|
||||||
|
node { id name isAnimationStudio }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextAiringEpisode { airingAt timeUntilAiring episode }
|
||||||
|
externalLinks { id url site type language color icon notes }
|
||||||
|
rankings { id rank type format year season allTime context }
|
||||||
|
stats {
|
||||||
|
scoreDistribution { score amount }
|
||||||
|
statusDistribution { status amount }
|
||||||
|
}
|
||||||
|
recommendations(perPage: 7, sort: RATING_DESC) {
|
||||||
|
nodes {
|
||||||
|
mediaRecommendation {
|
||||||
|
id
|
||||||
|
title { romaji }
|
||||||
|
coverImage { medium }
|
||||||
|
format
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchAniList(query: string, variables: any) {
|
||||||
|
const res = await fetch(ANILIST_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query, variables })
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// GET BY ID (DB -> ANILIST)
|
||||||
|
// ===============================
|
||||||
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
|
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
|
||||||
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
|
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
|
||||||
|
|
||||||
if (!row) {
|
if (row) return JSON.parse(row.full_data);
|
||||||
return { error: "Anime not found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(row.full_data);
|
const query = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Media(id: $id, type: ANIME) { ${MEDIA_FIELDS} }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await fetchAniList(query, { id: Number(id) });
|
||||||
|
if (!data?.Media) return { error: "Anime not found" };
|
||||||
|
|
||||||
|
await queryOne(
|
||||||
|
"INSERT INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
data.Media.id,
|
||||||
|
data.Media.title?.english || data.Media.title?.romaji,
|
||||||
|
data.Media.updatedAt || 0,
|
||||||
|
JSON.stringify(data.Media)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.Media;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTrendingAnime(): Promise<Anime[]> {
|
export async function getTrendingAnime(): Promise<Anime[]> {
|
||||||
const rows = await queryAll("SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10");
|
const rows = await queryAll(
|
||||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
"SELECT full_data, updated_at FROM trending ORDER BY rank ASC LIMIT 10"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||||
|
if (!expired) {
|
||||||
|
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
Page(page: 1, perPage: 10) {
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await fetchAniList(query, {});
|
||||||
|
const list = data?.Page?.media || [];
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
await queryOne("DELETE FROM trending");
|
||||||
|
let rank = 1;
|
||||||
|
|
||||||
|
for (const anime of list) {
|
||||||
|
await queryOne(
|
||||||
|
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
[rank++, anime.id, JSON.stringify(anime), now]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopAiringAnime(): Promise<Anime[]> {
|
export async function getTopAiringAnime(): Promise<Anime[]> {
|
||||||
const rows = await queryAll("SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10");
|
const rows = await queryAll(
|
||||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
"SELECT full_data, updated_at FROM top_airing ORDER BY rank ASC LIMIT 10"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||||
|
if (!expired) {
|
||||||
|
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
Page(page: 1, perPage: 10) {
|
||||||
|
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await fetchAniList(query, {});
|
||||||
|
const list = data?.Page?.media || [];
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
await queryOne("DELETE FROM top_airing");
|
||||||
|
let rank = 1;
|
||||||
|
|
||||||
|
for (const anime of list) {
|
||||||
|
await queryOne(
|
||||||
|
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
[rank++, anime.id, JSON.stringify(anime), now]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAnimeLocal(query: string): Promise<Anime[]> {
|
export async function searchAnimeLocal(query: string): Promise<Anime[]> {
|
||||||
if (!query || query.length < 2) {
|
if (!query || query.length < 2) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// 1️⃣ BUSCAR EN LOCAL
|
||||||
|
// ======================
|
||||||
const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`;
|
const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`;
|
||||||
const rows = await queryAll(sql, [`%${query}%`]);
|
const rows = await queryAll(sql, [`%${query}%`]);
|
||||||
|
|
||||||
const results: Anime[] = rows.map((row: { full_data: string; }) => JSON.parse(row.full_data));
|
const localResults: Anime[] = rows
|
||||||
|
.map((r: { full_data: string }) => JSON.parse(r.full_data))
|
||||||
const cleanResults = results.filter(anime => {
|
.filter((anime: { title: { english: any; romaji: any; native: any; }; synonyms: any; }) => {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
const titles = [
|
const titles = [
|
||||||
anime.title.english,
|
anime.title?.english,
|
||||||
anime.title.romaji,
|
anime.title?.romaji,
|
||||||
anime.title.native,
|
anime.title?.native,
|
||||||
...(anime.synonyms || [])
|
...(anime.synonyms || [])
|
||||||
].filter(Boolean).map(t => t!.toLowerCase());
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(t => t!.toLowerCase());
|
||||||
|
|
||||||
return titles.some(t => t.includes(q));
|
return titles.some(t => t.includes(q));
|
||||||
});
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
return cleanResults.slice(0, 10);
|
// ✅ Si ya encontró suficiente en local → listo
|
||||||
|
if (localResults.length >= 5) {
|
||||||
|
return localResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// 2️⃣ FETCH ANILIST
|
||||||
|
// ======================
|
||||||
|
const gql = `
|
||||||
|
query ($search: String) {
|
||||||
|
Page(page: 1, perPage: 10) {
|
||||||
|
media(type: ANIME, search: $search) { ${MEDIA_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await fetchAniList(gql, { search: query });
|
||||||
|
const remoteResults: Anime[] = data?.Page?.media || [];
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// 3️⃣ GUARDAR EN DB
|
||||||
|
// ======================
|
||||||
|
for (const anime of remoteResults) {
|
||||||
|
await queryOne(
|
||||||
|
"INSERT OR IGNORE INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
anime.id,
|
||||||
|
anime.title?.english || anime.title?.romaji,
|
||||||
|
anime.updatedAt || 0,
|
||||||
|
JSON.stringify(anime)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...localResults];
|
||||||
|
|
||||||
|
for (const anime of remoteResults) {
|
||||||
|
if (!merged.find(a => a.id === anime.id)) {
|
||||||
|
merged.push(anime);
|
||||||
|
}
|
||||||
|
if (merged.length >= 10) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise<Anime | { error: string }> {
|
export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise<Anime | { error: string }> {
|
||||||
@@ -81,7 +299,7 @@ export async function getAnimeInfoExtension(ext: Extension | null, id: string):
|
|||||||
return { error: "not found" };
|
return { error: "not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string): Promise<Anime[]> {
|
export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string) {
|
||||||
if (!ext) return [];
|
if (!ext) return [];
|
||||||
|
|
||||||
if (ext.type === 'anime-board' && ext.search) {
|
if (ext.type === 'anime-board' && ext.search) {
|
||||||
|
|||||||
@@ -4,6 +4,62 @@ import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'
|
|||||||
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TTL = 60 * 60 * 6;
|
||||||
|
const ANILIST_URL = "https://graphql.anilist.co";
|
||||||
|
|
||||||
|
async function fetchAniList(query: string, variables: any) {
|
||||||
|
const res = await fetch(ANILIST_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`AniList error ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data;
|
||||||
|
}
|
||||||
|
const MEDIA_FIELDS = `
|
||||||
|
id
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
userPreferred
|
||||||
|
}
|
||||||
|
type
|
||||||
|
format
|
||||||
|
status
|
||||||
|
description
|
||||||
|
startDate { year month day }
|
||||||
|
endDate { year month day }
|
||||||
|
season
|
||||||
|
seasonYear
|
||||||
|
episodes
|
||||||
|
chapters
|
||||||
|
volumes
|
||||||
|
duration
|
||||||
|
genres
|
||||||
|
synonyms
|
||||||
|
averageScore
|
||||||
|
popularity
|
||||||
|
favourites
|
||||||
|
isAdult
|
||||||
|
siteUrl
|
||||||
|
coverImage {
|
||||||
|
extraLarge
|
||||||
|
large
|
||||||
|
medium
|
||||||
|
color
|
||||||
|
}
|
||||||
|
bannerImage
|
||||||
|
updatedAt
|
||||||
|
`;
|
||||||
|
|
||||||
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
||||||
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]);
|
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]);
|
||||||
@@ -50,14 +106,80 @@ export async function getBookById(id: string | number): Promise<Book | { error:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTrendingBooks(): Promise<Book[]> {
|
export async function getTrendingBooks(): Promise<Book[]> {
|
||||||
const rows = await queryAll("SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10");
|
const rows = await queryAll(
|
||||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
"SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||||
|
if (!expired) {
|
||||||
|
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPopularBooks(): Promise<Book[]> {
|
const query = `
|
||||||
const rows = await queryAll("SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10");
|
query {
|
||||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
Page(page: 1, perPage: 10) {
|
||||||
|
media(type: MANGA, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await fetchAniList(query, {});
|
||||||
|
const list = data?.Page?.media || [];
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
await queryOne("DELETE FROM trending_books");
|
||||||
|
|
||||||
|
let rank = 1;
|
||||||
|
for (const book of list) {
|
||||||
|
await queryOne(
|
||||||
|
"INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
[rank++, book.id, JSON.stringify(book), now]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getPopularBooks(): Promise<Book[]> {
|
||||||
|
const rows = await queryAll(
|
||||||
|
"SELECT full_data, updated_at FROM popular_books ORDER BY rank ASC LIMIT 10"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||||
|
if (!expired) {
|
||||||
|
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
Page(page: 1, perPage: 10) {
|
||||||
|
media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await fetchAniList(query, {});
|
||||||
|
const list = data?.Page?.media || [];
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
await queryOne("DELETE FROM popular_books");
|
||||||
|
|
||||||
|
let rank = 1;
|
||||||
|
for (const book of list) {
|
||||||
|
await queryOne(
|
||||||
|
"INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
[rank++, book.id, JSON.stringify(book), now]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function searchBooksLocal(query: string): Promise<Book[]> {
|
export async function searchBooksLocal(query: string): Promise<Book[]> {
|
||||||
if (!query || query.length < 2) {
|
if (!query || query.length < 2) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface StartDate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Anime {
|
export interface Anime {
|
||||||
|
updatedAt: any;
|
||||||
id: number | string;
|
id: number | string;
|
||||||
title: AnimeTitle;
|
title: AnimeTitle;
|
||||||
coverImage: CoverImage;
|
coverImage: CoverImage;
|
||||||
|
|||||||
Binary file not shown.
@@ -1,424 +0,0 @@
|
|||||||
#include <iostream>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
#include <thread>
|
|
||||||
#include <chrono>
|
|
||||||
#include <fstream>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <atomic>
|
|
||||||
#include <mutex>
|
|
||||||
#include <map>
|
|
||||||
#include <iomanip>
|
|
||||||
#include <sstream>
|
|
||||||
|
|
||||||
#ifdef _WIN32
|
|
||||||
#include <windows.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include <sqlite3.h>
|
|
||||||
#include <curl/curl.h>
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
|
|
||||||
using json = nlohmann::json;
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
|
|
||||||
struct AppState {
|
|
||||||
std::atomic<int> animePage{1};
|
|
||||||
std::atomic<int> animeTotalRemote{0};
|
|
||||||
|
|
||||||
std::atomic<int> mangaPage{1};
|
|
||||||
std::atomic<int> mangaTotalRemote{0};
|
|
||||||
|
|
||||||
std::string animeAction = "Initializing...";
|
|
||||||
std::string mangaAction = "Initializing...";
|
|
||||||
std::string featuredStatus = "Waiting...";
|
|
||||||
std::string lastLog = "";
|
|
||||||
|
|
||||||
std::mutex stateMutex;
|
|
||||||
};
|
|
||||||
|
|
||||||
AppState appState;
|
|
||||||
|
|
||||||
const std::string DB_PATH = "src/metadata/anilist_anime.db";
|
|
||||||
const int REQUESTS_PER_MINUTE = 20;
|
|
||||||
const int DELAY_MS = (60000 / REQUESTS_PER_MINUTE);
|
|
||||||
const int FEATURED_REFRESH_RATE_MS = 8 * 60 * 1000;
|
|
||||||
|
|
||||||
const std::string MEDIA_FIELDS = R"(
|
|
||||||
id idMal title { romaji english native userPreferred } type format status description
|
|
||||||
startDate { year month day } endDate { year month day } season seasonYear episodes
|
|
||||||
duration chapters volumes countryOfOrigin isLicensed source hashtag
|
|
||||||
trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color }
|
|
||||||
bannerImage genres synonyms averageScore popularity isLocked trending favourites
|
|
||||||
isAdult siteUrl tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult }
|
|
||||||
relations { edges { relationType node { id title { romaji } type format status } } }
|
|
||||||
studios { edges { isMain node { id name isAnimationStudio } } }
|
|
||||||
nextAiringEpisode { airingAt timeUntilAiring episode }
|
|
||||||
externalLinks { id url site type language color icon notes }
|
|
||||||
rankings { id rank type format year season allTime context }
|
|
||||||
stats { scoreDistribution { score amount } statusDistribution { status amount } }
|
|
||||||
recommendations(perPage: 7, sort: RATING_DESC) { nodes { mediaRecommendation { id title { romaji } coverImage { medium } format type } } }
|
|
||||||
)";
|
|
||||||
|
|
||||||
const std::string BULK_QUERY = R"(
|
|
||||||
query ($page: Int, $type: MediaType) {
|
|
||||||
Page(page: $page, perPage: 50) {
|
|
||||||
pageInfo { total currentPage lastPage hasNextPage }
|
|
||||||
media(type: $type, sort: ID) { )" + MEDIA_FIELDS + R"( }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
const std::string FEATURED_QUERY = R"(
|
|
||||||
query ($sort: [MediaSort], $type: MediaType, $status: MediaStatus) {
|
|
||||||
Page(page: 1, perPage: 20) {
|
|
||||||
media(type: $type, sort: $sort, status: $status, isAdult: false) { )" + MEDIA_FIELDS + R"( }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
void safeLog(const std::string& message) {
|
|
||||||
std::lock_guard<std::mutex> lock(appState.stateMutex);
|
|
||||||
appState.lastLog = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateAction(std::string type, std::string action) {
|
|
||||||
std::lock_guard<std::mutex> lock(appState.stateMutex);
|
|
||||||
if (type == "ANIME") appState.animeAction = action;
|
|
||||||
else appState.mangaAction = action;
|
|
||||||
}
|
|
||||||
|
|
||||||
class Database {
|
|
||||||
sqlite3* db;
|
|
||||||
std::mutex db_mutex;
|
|
||||||
|
|
||||||
public:
|
|
||||||
Database(const std::string& path) {
|
|
||||||
if (sqlite3_open(path.c_str(), &db)) {
|
|
||||||
safeLog("❌ Error: Can't open database at " + path);
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
~Database() { sqlite3_close(db); }
|
|
||||||
|
|
||||||
void init() {
|
|
||||||
std::lock_guard<std::mutex> lock(db_mutex);
|
|
||||||
char* errMsg = 0;
|
|
||||||
const char* sql =
|
|
||||||
"CREATE TABLE IF NOT EXISTS anime (id INTEGER PRIMARY KEY, title TEXT, updatedAt INTEGER, full_data JSON);"
|
|
||||||
"CREATE TABLE IF NOT EXISTS trending (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON);"
|
|
||||||
"CREATE TABLE IF NOT EXISTS top_airing (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON);"
|
|
||||||
"CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT, updatedAt INTEGER, full_data JSON);"
|
|
||||||
"CREATE TABLE IF NOT EXISTS trending_books (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON);"
|
|
||||||
"CREATE TABLE IF NOT EXISTS popular_books (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON);";
|
|
||||||
sqlite3_exec(db, sql, 0, 0, &errMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
void saveBatch(const std::string& table, const json& mediaList) {
|
|
||||||
std::lock_guard<std::mutex> lock(db_mutex);
|
|
||||||
sqlite3_exec(db, "BEGIN TRANSACTION", 0, 0, 0);
|
|
||||||
|
|
||||||
std::string sql = "INSERT INTO " + table + " (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?) "
|
|
||||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, updatedAt=excluded.updatedAt, full_data=excluded.full_data "
|
|
||||||
"WHERE updatedAt < excluded.updatedAt OR title != excluded.title";
|
|
||||||
|
|
||||||
sqlite3_stmt* stmt;
|
|
||||||
sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, 0);
|
|
||||||
|
|
||||||
for (const auto& media : mediaList) {
|
|
||||||
int id = media["id"].get<int>();
|
|
||||||
int updatedAt = media.value("updatedAt", 0);
|
|
||||||
|
|
||||||
std::string title = "Unknown";
|
|
||||||
if (media.contains("title")) {
|
|
||||||
if (media["title"].contains("english") && !media["title"]["english"].is_null())
|
|
||||||
title = media["title"]["english"];
|
|
||||||
else if (media["title"].contains("romaji") && !media["title"]["romaji"].is_null())
|
|
||||||
title = media["title"]["romaji"];
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string jsonDump = media.dump();
|
|
||||||
sqlite3_bind_int(stmt, 1, id);
|
|
||||||
sqlite3_bind_text(stmt, 2, title.c_str(), -1, SQLITE_STATIC);
|
|
||||||
sqlite3_bind_int(stmt, 3, updatedAt);
|
|
||||||
sqlite3_bind_text(stmt, 4, jsonDump.c_str(), -1, SQLITE_STATIC);
|
|
||||||
sqlite3_step(stmt);
|
|
||||||
sqlite3_reset(stmt);
|
|
||||||
}
|
|
||||||
sqlite3_finalize(stmt);
|
|
||||||
sqlite3_exec(db, "COMMIT", 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateFeatured(const std::string& table, const json& mediaList) {
|
|
||||||
std::lock_guard<std::mutex> lock(db_mutex);
|
|
||||||
sqlite3_exec(db, ("DELETE FROM " + table).c_str(), 0, 0, 0);
|
|
||||||
sqlite3_exec(db, "BEGIN TRANSACTION", 0, 0, 0);
|
|
||||||
|
|
||||||
sqlite3_stmt* stmt;
|
|
||||||
sqlite3_prepare_v2(db, ("INSERT INTO " + table + " (rank, id, full_data) VALUES (?, ?, ?)").c_str(), -1, &stmt, 0);
|
|
||||||
|
|
||||||
int rank = 1;
|
|
||||||
for (const auto& media : mediaList) {
|
|
||||||
int id = media["id"].get<int>();
|
|
||||||
std::string jsonDump = media.dump();
|
|
||||||
sqlite3_bind_int(stmt, 1, rank++);
|
|
||||||
sqlite3_bind_int(stmt, 2, id);
|
|
||||||
sqlite3_bind_text(stmt, 3, jsonDump.c_str(), -1, SQLITE_STATIC);
|
|
||||||
sqlite3_step(stmt);
|
|
||||||
sqlite3_reset(stmt);
|
|
||||||
}
|
|
||||||
sqlite3_finalize(stmt);
|
|
||||||
sqlite3_exec(db, "COMMIT", 0, 0, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
|
||||||
userp->append((char*)contents, size * nmemb);
|
|
||||||
return size * nmemb;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t HeaderCallback(char* buffer, size_t size, size_t nitems, std::map<std::string, std::string>* headers) {
|
|
||||||
std::string header(buffer, size * nitems);
|
|
||||||
size_t colon = header.find(':');
|
|
||||||
if (colon != std::string::npos) {
|
|
||||||
std::string key = header.substr(0, colon);
|
|
||||||
std::string value = header.substr(colon + 1);
|
|
||||||
value.erase(0, value.find_first_not_of(" \r\n"));
|
|
||||||
value.erase(value.find_last_not_of(" \r\n") + 1);
|
|
||||||
(*headers)[key] = value;
|
|
||||||
}
|
|
||||||
return size * nitems;
|
|
||||||
}
|
|
||||||
|
|
||||||
json fetchGraphQL(const std::string& query, const json& variables) {
|
|
||||||
CURL* curl;
|
|
||||||
CURLcode res;
|
|
||||||
std::string readBuffer;
|
|
||||||
std::map<std::string, std::string> responseHeaders;
|
|
||||||
char errbuf[CURL_ERROR_SIZE];
|
|
||||||
|
|
||||||
curl = curl_easy_init();
|
|
||||||
if (!curl) return nullptr;
|
|
||||||
|
|
||||||
json body;
|
|
||||||
body["query"] = query;
|
|
||||||
body["variables"] = variables;
|
|
||||||
std::string jsonStr = body.dump();
|
|
||||||
|
|
||||||
struct curl_slist* headers = NULL;
|
|
||||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
|
||||||
headers = curl_slist_append(headers, "Accept: application/json");
|
|
||||||
|
|
||||||
curl_easy_setopt(curl, CURLOPT_URL, "https://graphql.anilist.co");
|
|
||||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, jsonStr.c_str());
|
|
||||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &readBuffer);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, HeaderCallback);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &responseHeaders);
|
|
||||||
|
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
|
||||||
|
|
||||||
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf);
|
|
||||||
|
|
||||||
res = curl_easy_perform(curl);
|
|
||||||
|
|
||||||
long http_code = 0;
|
|
||||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
|
||||||
|
|
||||||
if (responseHeaders.count("X-RateLimit-Remaining")) {
|
|
||||||
try {
|
|
||||||
int remaining = std::stoi(responseHeaders["X-RateLimit-Remaining"]);
|
|
||||||
if (remaining < 10) {
|
|
||||||
int resetTime = std::stoi(responseHeaders["X-RateLimit-Reset"]);
|
|
||||||
auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
|
|
||||||
int waitSeconds = (resetTime - now) + 2;
|
|
||||||
if (waitSeconds > 0) {
|
|
||||||
safeLog("⚠️ Rate Limit! Sleeping " + std::to_string(waitSeconds) + "s");
|
|
||||||
std::this_thread::sleep_for(std::chrono::seconds(waitSeconds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
curl_easy_cleanup(curl);
|
|
||||||
curl_slist_free_all(headers);
|
|
||||||
|
|
||||||
if (res != CURLE_OK) {
|
|
||||||
std::string errorMsg = "❌ Curl Error: " + std::string(errbuf);
|
|
||||||
safeLog(errorMsg);
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (http_code != 200) {
|
|
||||||
if (http_code == 429) {
|
|
||||||
safeLog("⚠️ ABSOLUTE RATE LIMIT. Sleeping 1m.");
|
|
||||||
std::this_thread::sleep_for(std::chrono::minutes(1));
|
|
||||||
return fetchGraphQL(query, variables);
|
|
||||||
}
|
|
||||||
safeLog("❌ HTTP Error: " + std::to_string(http_code));
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
json j = json::parse(readBuffer);
|
|
||||||
return j.contains("data") ? j["data"]["Page"] : nullptr;
|
|
||||||
} catch (...) { return nullptr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
void startScraper(Database& db, std::string type, std::string tableName) {
|
|
||||||
int page = 1;
|
|
||||||
bool isCaughtUp = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (isCaughtUp) {
|
|
||||||
if (type == "ANIME") { appState.animePage = -1; }
|
|
||||||
else { appState.mangaPage = -1; }
|
|
||||||
updateAction(type, "Caught Up (Sleep 10m)");
|
|
||||||
std::this_thread::sleep_for(std::chrono::minutes(10));
|
|
||||||
page = 1;
|
|
||||||
isCaughtUp = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAction(type, "Fetching Page " + std::to_string(page) + "...");
|
|
||||||
json data = fetchGraphQL(BULK_QUERY, {{"page", page}, {"type", type}});
|
|
||||||
|
|
||||||
if (data.is_null() || !data.contains("media") || data["media"].empty()) {
|
|
||||||
if (!data.is_null() && data.contains("pageInfo") && !data["pageInfo"]["hasNextPage"].get<bool>()) {
|
|
||||||
isCaughtUp = true;
|
|
||||||
} else {
|
|
||||||
updateAction(type, "Fetch Failed. Retrying...");
|
|
||||||
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalRemote = data["pageInfo"]["total"].get<int>();
|
|
||||||
if (type == "ANIME") {
|
|
||||||
appState.animePage = page;
|
|
||||||
appState.animeTotalRemote = totalRemote;
|
|
||||||
} else {
|
|
||||||
appState.mangaPage = page;
|
|
||||||
appState.mangaTotalRemote = totalRemote;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAction(type, "Saving to DB...");
|
|
||||||
db.saveBatch(tableName, data["media"]);
|
|
||||||
|
|
||||||
if (data["pageInfo"]["hasNextPage"].get<bool>()) {
|
|
||||||
page++;
|
|
||||||
updateAction(type, "Waiting " + std::to_string(DELAY_MS) + "ms...");
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(DELAY_MS));
|
|
||||||
} else {
|
|
||||||
isCaughtUp = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void startFeaturedLoop(Database& db) {
|
|
||||||
while (true) {
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(appState.stateMutex);
|
|
||||||
appState.featuredStatus = "Refreshing...";
|
|
||||||
}
|
|
||||||
|
|
||||||
json animeTrending = fetchGraphQL(FEATURED_QUERY, {{"sort", "TRENDING_DESC"}, {"type", "ANIME"}});
|
|
||||||
if (!animeTrending.is_null()) db.updateFeatured("trending", animeTrending["media"]);
|
|
||||||
|
|
||||||
json animeTop = fetchGraphQL(FEATURED_QUERY, {{"sort", "SCORE_DESC"}, {"type", "ANIME"}, {"status", "RELEASING"}});
|
|
||||||
if (!animeTop.is_null()) db.updateFeatured("top_airing", animeTop["media"]);
|
|
||||||
|
|
||||||
json mangaTrending = fetchGraphQL(FEATURED_QUERY, {{"sort", "TRENDING_DESC"}, {"type", "MANGA"}});
|
|
||||||
if (!mangaTrending.is_null()) db.updateFeatured("trending_books", mangaTrending["media"]);
|
|
||||||
|
|
||||||
json mangaPop = fetchGraphQL(FEATURED_QUERY, {{"sort", "POPULARITY_DESC"}, {"type", "MANGA"}});
|
|
||||||
if (!mangaPop.is_null()) db.updateFeatured("popular_books", mangaPop["media"]);
|
|
||||||
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(appState.stateMutex);
|
|
||||||
appState.featuredStatus = "Idle";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(FEATURED_REFRESH_RATE_MS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string getStatusLine(int page, int total, std::string action) {
|
|
||||||
if (page == -1) return "Caught Up";
|
|
||||||
|
|
||||||
std::stringstream stream;
|
|
||||||
double percent = 0.0;
|
|
||||||
if (total > 0 && page > 0) {
|
|
||||||
percent = ((double)page * 50.0) / (double)total * 100.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
stream << "Pg " << page << " (" << std::fixed << std::setprecision(2) << percent << "%) - " << action;
|
|
||||||
return stream.str();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiThreadLoop() {
|
|
||||||
std::cout << "\n\n";
|
|
||||||
|
|
||||||
while(true) {
|
|
||||||
int aPage = appState.animePage;
|
|
||||||
int aTotal = appState.animeTotalRemote;
|
|
||||||
int mPage = appState.mangaPage;
|
|
||||||
int mTotal = appState.mangaTotalRemote;
|
|
||||||
|
|
||||||
std::string aAction, mAction, fStatus, log;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(appState.stateMutex);
|
|
||||||
aAction = appState.animeAction;
|
|
||||||
mAction = appState.mangaAction;
|
|
||||||
fStatus = appState.featuredStatus;
|
|
||||||
if (!appState.lastLog.empty()) {
|
|
||||||
log = appState.lastLog;
|
|
||||||
appState.lastLog = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!log.empty()) {
|
|
||||||
std::cout << "\r \r";
|
|
||||||
std::cout << log << std::endl;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "\r----------------------------------------------------------------\n"
|
|
||||||
<< " 📺 Anime: " << std::left << std::setw(45) << getStatusLine(aPage, aTotal, aAction) << "\n"
|
|
||||||
<< " 📖 Manga: " << std::left << std::setw(45) << getStatusLine(mPage, mTotal, mAction) << "\n"
|
|
||||||
<< " ✨ Feat : " << fStatus << "\n"
|
|
||||||
<< "----------------------------------------------------------------\x1b[4A" << std::flush;
|
|
||||||
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main() {
|
|
||||||
#ifdef _WIN32
|
|
||||||
SetConsoleOutputCP(65001);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
fs::path p(DB_PATH);
|
|
||||||
if (p.has_parent_path() && !fs::exists(p.parent_path())) {
|
|
||||||
fs::create_directories(p.parent_path());
|
|
||||||
}
|
|
||||||
|
|
||||||
Database db(DB_PATH);
|
|
||||||
|
|
||||||
std::cout << "⚡ Starting WaifuBoard Scraper Engine..." << std::endl;
|
|
||||||
|
|
||||||
std::thread featuredThread(startFeaturedLoop, std::ref(db));
|
|
||||||
std::thread animeThread(startScraper, std::ref(db), "ANIME", "anime");
|
|
||||||
std::thread mangaThread(startScraper, std::ref(db), "MANGA", "books");
|
|
||||||
std::thread dashboard(uiThreadLoop);
|
|
||||||
|
|
||||||
featuredThread.join();
|
|
||||||
animeThread.join();
|
|
||||||
mangaThread.join();
|
|
||||||
dashboard.join();
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
24596
src/metadata/json.hpp
24596
src/metadata/json.hpp
File diff suppressed because it is too large
Load Diff
@@ -2,179 +2,17 @@ const sqlite3 = require('sqlite3').verbose();
|
|||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB} = require('./schemas');
|
||||||
|
|
||||||
const databases = new Map();
|
const databases = new Map();
|
||||||
|
|
||||||
const DEFAULT_PATHS = {
|
const DEFAULT_PATHS = {
|
||||||
anilist: path.join(process.cwd(), 'src', 'metadata', 'anilist_anime.db'),
|
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
|
||||||
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.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")
|
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
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ListEntry (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
entry_id INTEGER NOT NULL,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
entry_type 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, entry_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(`
|
|
||||||
CREATE TABLE IF NOT EXISTS extension (
|
|
||||||
ext_name TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
metadata TEXT NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY(ext_name, id)
|
|
||||||
);
|
|
||||||
`, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureCacheTable(db) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS cache (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
result TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
ttl_ms INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
`, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureFavoritesDB(dbPath) {
|
|
||||||
const dir = path.dirname(dbPath);
|
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = fs.existsSync(dbPath);
|
|
||||||
|
|
||||||
const db = new sqlite3.Database(
|
|
||||||
dbPath,
|
|
||||||
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!exists) {
|
|
||||||
const schema = `
|
|
||||||
CREATE TABLE IF NOT EXISTS favorites (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
image_url TEXT NOT NULL,
|
|
||||||
thumbnail_url TEXT NOT NULL DEFAULT "",
|
|
||||||
tags TEXT NOT NULL DEFAULT "",
|
|
||||||
headers TEXT NOT NULL DEFAULT "",
|
|
||||||
provider TEXT NOT NULL DEFAULT "",
|
|
||||||
PRIMARY KEY (id, user_id)
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
db.exec(schema, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.all(`PRAGMA table_info(favorites)`, (err, cols) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
|
|
||||||
const hasHeaders = cols.some(c => c.name === "headers");
|
|
||||||
const hasProvider = cols.some(c => c.name === "provider");
|
|
||||||
const hasUserId = cols.some(c => c.name === "user_id");
|
|
||||||
|
|
||||||
const queries = [];
|
|
||||||
|
|
||||||
if (!hasHeaders) {
|
|
||||||
queries.push(`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasProvider) {
|
|
||||||
queries.push(`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasUserId) {
|
|
||||||
queries.push(`ALTER TABLE favorites ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queries.length === 0) {
|
|
||||||
return resolve(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(queries.join(";"), (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
||||||
if (databases.has(name)) {
|
if (databases.has(name)) {
|
||||||
return databases.get(name);
|
return databases.get(name);
|
||||||
@@ -212,8 +50,9 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
|||||||
databases.set(name, db);
|
databases.set(name, db);
|
||||||
|
|
||||||
if (name === "anilist") {
|
if (name === "anilist") {
|
||||||
ensureExtensionsTable(db)
|
ensureAnilistSchema(db)
|
||||||
.catch(err => console.error("Error creating extension table:", err));
|
.then(() => ensureExtensionsTable(db))
|
||||||
|
.catch(err => console.error("Error creating anilist schema:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === "cache") {
|
if (name === "cache") {
|
||||||
|
|||||||
230
src/shared/schemas.js
Normal file
230
src/shared/schemas.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ListEntry (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
entry_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
entry_type 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, entry_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(schema, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAnilistSchema(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS anime (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
updatedAt INTEGER,
|
||||||
|
cache_created_at INTEGER DEFAULT 0,
|
||||||
|
cache_ttl_ms INTEGER DEFAULT 0,
|
||||||
|
full_data JSON
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trending (
|
||||||
|
rank INTEGER PRIMARY KEY,
|
||||||
|
id INTEGER,
|
||||||
|
full_data JSON,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS top_airing (
|
||||||
|
rank INTEGER PRIMARY KEY,
|
||||||
|
id INTEGER,
|
||||||
|
full_data JSON,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
updatedAt INTEGER,
|
||||||
|
cache_created_at INTEGER DEFAULT 0,
|
||||||
|
cache_ttl_ms INTEGER DEFAULT 0,
|
||||||
|
full_data JSON
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trending_books (
|
||||||
|
rank INTEGER PRIMARY KEY,
|
||||||
|
id INTEGER,
|
||||||
|
full_data JSON,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS popular_books (
|
||||||
|
rank INTEGER PRIMARY KEY,
|
||||||
|
id INTEGER,
|
||||||
|
full_data JSON,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(schema, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExtensionsTable(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS extension (
|
||||||
|
ext_name TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(ext_name, id)
|
||||||
|
);
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCacheTable(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
ttl_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFavoritesDB(dbPath) {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = fs.existsSync(dbPath);
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(
|
||||||
|
dbPath,
|
||||||
|
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!exists) {
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
thumbnail_url TEXT NOT NULL DEFAULT "",
|
||||||
|
tags TEXT NOT NULL DEFAULT "",
|
||||||
|
headers TEXT NOT NULL DEFAULT "",
|
||||||
|
provider TEXT NOT NULL DEFAULT "",
|
||||||
|
PRIMARY KEY (id, user_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(schema, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.all(`PRAGMA table_info(favorites)`, (err, cols) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
const hasHeaders = cols.some(c => c.name === "headers");
|
||||||
|
const hasProvider = cols.some(c => c.name === "provider");
|
||||||
|
const hasUserId = cols.some(c => c.name === "user_id");
|
||||||
|
|
||||||
|
const queries = [];
|
||||||
|
|
||||||
|
if (!hasHeaders) {
|
||||||
|
queries.push(`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasProvider) {
|
||||||
|
queries.push(`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUserId) {
|
||||||
|
queries.push(`ALTER TABLE favorites ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queries.length === 0) {
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(queries.join(";"), (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureUserDataDB,
|
||||||
|
ensureAnilistSchema,
|
||||||
|
ensureExtensionsTable,
|
||||||
|
ensureCacheTable,
|
||||||
|
ensureFavoritesDB
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user