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:
@@ -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}`),
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface GallerySummary {
|
||||
tags: string[]
|
||||
artist: string
|
||||
parody: string
|
||||
synopsis?: string
|
||||
cover: {
|
||||
local_endpoint: string
|
||||
local_available: boolean
|
||||
|
||||
Reference in New Issue
Block a user