feat(web): sync translated manga galleries, clean titles, synopsis integration, original source links, anthology badges, and mobile-first reader UI

This commit is contained in:
Renato
2026-07-24 01:59:40 +02:00
parent 269762557e
commit 72e503d7bc
22 changed files with 824 additions and 300 deletions
+3
View File
@@ -58,6 +58,9 @@ export const api = {
return fetchApi<ApiResponse<PaginatedResponse<GalleryItem>>>(`/galleries?${params}`)
},
translated: () =>
fetchApi<ApiResponse<{ data: { gid: string; title: string; raw_name: string; has_mobi: boolean; pages: number }[] }>>("/galleries/translated"),
get: (gid: string) =>
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
+1 -1
View File
@@ -39,7 +39,7 @@ export async function cacheCover(gid: string, proxyUrl: string): Promise<string
export function getCoverPath(gid: string): string | null {
ensureDir()
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(gid))
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(`${gid}.`))
if (files.length === 0) return null
return path.join(COVERS_DIR, files[0])
}
+11 -3
View File
@@ -30,6 +30,7 @@ function migrate(db: Database.Database) {
num_pages INTEGER DEFAULT 0,
source TEXT,
cover_url TEXT,
url TEXT,
summary TEXT,
slug TEXT UNIQUE NOT NULL,
published INTEGER DEFAULT 0,
@@ -43,6 +44,10 @@ function migrate(db: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
`)
try {
db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
} catch {}
}
export interface Post {
@@ -56,6 +61,7 @@ export interface Post {
num_pages: number
source: string | null
cover_url: string | null
url: string | null
summary: string | null
slug: string
published: number
@@ -74,6 +80,7 @@ export interface PostInput {
num_pages?: number
source?: string
cover_url?: string
url?: string
summary?: string
slug: string
published?: number
@@ -117,8 +124,8 @@ export function createPost(input: PostInput): Post {
const published = input.published ?? 1
const publishedAt = published ? "datetime('now')" : null
const stmt = d.prepare(`
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, summary, slug, published, published_at)
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @summary, @slug, @published, ${publishedAt})
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at)
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
`)
const result = stmt.run({
gid: input.gid,
@@ -130,6 +137,7 @@ export function createPost(input: PostInput): Post {
num_pages: input.num_pages || 0,
source: input.source || null,
cover_url: input.cover_url || null,
url: input.url || null,
summary: input.summary || null,
slug: input.slug,
published,
@@ -157,7 +165,7 @@ export function updatePost(id: number, updates: Partial<PostInput & { published:
if (fields.length === 0) return getPostById(id)
fields.push("updated_at = datetime('now')")
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = @id`).run(values)
return getPostById(id)
}
+61 -27
View File
@@ -1,7 +1,7 @@
import "server-only"
import { api } from "./api"
import { slugify } from "./slug"
import { createPost, getPostByGid } from "./db"
import { cleanTitle, slugify } from "./slug"
import { createPost, getPostByGid, updatePost } from "./db"
import { cacheCover } from "./cover-cache"
let intervalId: ReturnType<typeof setInterval> | null = null
@@ -24,38 +24,72 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
let newPosts = 0
try {
const res = await api.galleries.list("completed", 1, 100)
const galleries = res.data?.data || res.data || []
const allGids = new Set<string>()
for (const g of galleries) {
try {
const res = await api.galleries.list("completed", 1, 100)
const galleries = res.data?.data || res.data || []
for (const g of galleries) {
if (g.gid) allGids.add(g.gid)
}
} catch {}
try {
const transRes = await api.galleries.translated()
const translated = (transRes as any)?.data?.data || (transRes as any)?.data || []
for (const t of translated) {
if (t.gid) allGids.add(t.gid)
}
} catch {}
for (const gid of allGids) {
try {
const existing = getPostByGid(g.gid)
if (existing) continue
const sumRes = await api.galleries.summary(g.gid)
const sumRes = await api.galleries.summary(gid)
const summary = sumRes?.data
if (!summary) continue
if (!summary || !summary.title) continue
const slug = slugify(summary.title, g.gid)
const cleanT = cleanTitle(summary.title)
const slug = slugify(cleanT, gid)
const finalSummary = summary.synopsis && summary.synopsis.trim() !== ""
? summary.synopsis
: generateSummary(summary)
const sourceUrl = summary.url || (summary.source === "nhentai" ? `https://nhentai.net/g/${gid}/` : (summary.source === "ehentai" ? `https://e-hentai.org/g/${gid}/` : undefined))
await createPost({
gid: g.gid,
title: summary.title,
title_jpn: summary.title_jpn,
artist: summary.artist,
parody: summary.parody,
tags: summary.tags,
num_pages: summary.num_pages,
source: summary.source,
cover_url: summary.cover?.external_url,
summary: generateSummary(summary),
slug,
published: 1,
})
const existing = getPostByGid(gid)
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
if (!existing) {
await createPost({
gid,
title: cleanT,
title_jpn: summary.title_jpn,
artist: summary.artist,
parody: summary.parody,
tags: summary.tags,
num_pages: summary.num_pages,
source: summary.source,
cover_url: summary.cover?.external_url,
url: sourceUrl,
summary: finalSummary,
slug,
published: 1,
})
newPosts++
} else {
updatePost(existing.id, {
title: cleanT,
title_jpn: summary.title_jpn || undefined,
artist: summary.artist || undefined,
parody: summary.parody || undefined,
tags: summary.tags,
num_pages: summary.num_pages,
source: summary.source || undefined,
url: sourceUrl || existing.url || undefined,
summary: finalSummary,
})
}
newPosts++
const port = process.env.PORT || "3000"
await cacheCover(gid, `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`)
} catch {
continue
}
+31 -5
View File
@@ -1,8 +1,34 @@
export function cleanTitle(raw: string): string {
if (!raw) return ""
let title = raw
// 1. Remove dual-language subtitle after '|'
if (title.includes("|")) {
title = title.split("|")[0].trim()
}
// 2. Remove language / format tags in brackets or braces
title = title.replace(/\s*[\(\[\{](?:spanish|english|chinese|japanese|korean|digital|decensored|uncensored|censored|colorized|colored|full color|doujins\.com|hennojin|sample|raw)[\)\]\}]/gi, "")
// 3. Remove leading circle / artist in brackets
title = title.replace(/^\s*\[[^\]]+\]\s*/, "")
// 4. Remove trailing parody in parentheses if present
title = title.replace(/\s*\([^)]+\)\s*$/, "")
// 5. Remove trailing numbers/ID in parens or brackets
title = title.replace(/\s*[\(\[]\d+[\)\]]\s*$/, "")
title = title.trim()
return title || raw
}
export function slugify(text: string, gid?: string): string {
let slug = text
const cleaned = cleanTitle(text)
let slug = cleaned
.toLowerCase()
.replace(/\[.*?\]/g, "")
.replace(/\(.*?\)/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
@@ -11,11 +37,11 @@ export function slugify(text: string, gid?: string): string {
.slice(0, 80)
if (!slug) {
slug = `gallery`
slug = "gallery"
}
if (gid) {
slug = `${slug}-${gid.slice(0, 8)}`
slug = `${slug}-${gid}`
}
return slug
+1
View File
@@ -27,6 +27,7 @@ export interface GallerySummary {
tags: string[]
artist: string
parody: string
synopsis?: string
cover: {
local_endpoint: string
local_available: boolean