feat: traduccion de titulos al espanol (title_es) + card limpia (fecha en vez de tags/min) + poller traduce nuevos
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Traducción masiva de títulos existentes al español.
|
||||
// Usa el proxy free-ide local (deepseek-v4-flash-free con fallback).
|
||||
// Corre standalone: node translate_existing.js
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database("./data/worst-scan.db");
|
||||
|
||||
// Asegurar columna title_es (misma migracion que src/lib/db.ts)
|
||||
try {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT");
|
||||
} catch {} // ya existe
|
||||
|
||||
const PROXY_URL = "http://127.0.0.1:6446/v1";
|
||||
const PROXY_KEY = "tw0rxo314kvga9lfbp2nj5sde7hiq6uc";
|
||||
|
||||
const SYSTEM_PROMPT = `Sos un traductor de títulos de manga al español latinoamericano.
|
||||
Recibís UN título de manga (en inglés, japonés romanizado, katakana, kanji o chino).
|
||||
Devolvés SOLO la traducción al español, sin comillas, sin corchetes de autor,
|
||||
sin explicaciones, sin "Traducción:" ni puntos finales.
|
||||
Mantené el tono del título original.
|
||||
Normas:
|
||||
- No traduzcas nombres propios (personajes, artistas, marcas).
|
||||
- Si el título ya está en español o es un nombre propio, devolvelo tal cual.
|
||||
- Traducí términos como "Haha no Hi" -> "Día de la Madre".
|
||||
- Si hay números de capítulo/volumen/año, mantenelos.
|
||||
- Respondé en UNA línea.`;
|
||||
|
||||
function isProbablySpanish(t) {
|
||||
if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false;
|
||||
const spanishWords = ["el ", "la ", "los ", "las ", " de ", " y ", " un ", " una ", " para ", "con ", "por "];
|
||||
let count = 0;
|
||||
for (const w of spanishWords) if (t.toLowerCase().includes(w)) count++;
|
||||
return count >= 3;
|
||||
}
|
||||
|
||||
async function translate(title) {
|
||||
const clean = (title || "").trim();
|
||||
if (!clean) return null;
|
||||
if (isProbablySpanish(clean)) return clean;
|
||||
|
||||
for (const model of ["deepseek-v4-flash-free", "big-pickle"]) {
|
||||
try {
|
||||
const res = await fetch(`${PROXY_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PROXY_KEY}` },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: clean },
|
||||
],
|
||||
max_tokens: 200,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (!content || !content.trim()) continue;
|
||||
return content.trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const limit = parseInt(process.env.LIMIT || "0", 10);
|
||||
const posts = db.prepare("SELECT id, gid, title, title_es FROM posts WHERE published=1 OR published=0").all();
|
||||
let toTranslate = posts.filter(p => !p.title_es);
|
||||
if (limit > 0) toTranslate = toTranslate.slice(0, limit);
|
||||
console.log(`Total posts: ${posts.length} | sin traducción: ${toTranslate.length}${limit ? ` (probando ${limit})` : ""}`);
|
||||
|
||||
let ok = 0, fail = 0, skip = 0;
|
||||
for (let i = 0; i < toTranslate.length; i++) {
|
||||
const p = toTranslate[i];
|
||||
const t = await translate(p.title);
|
||||
if (t) {
|
||||
db.prepare("UPDATE posts SET title_es = ?, updated_at = datetime('now') WHERE id = ?").run(t, p.id);
|
||||
ok++;
|
||||
console.log(`[${i + 1}/${toTranslate.length}] ✓ ${p.title.slice(0, 40)} -> ${t.slice(0, 50)}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(`[${i + 1}/${toTranslate.length}] ✗ ${p.title.slice(0, 40)} (sin traducción)`);
|
||||
}
|
||||
// Delay pequeño entre requests para no saturar el proxy
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
console.log(`\n=== RESULTADO: ${ok} traducidos, ${fail} fallaron, ${skip} omitidos ===`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error("ERROR:", e.message); process.exit(1); });
|
||||
@@ -4,6 +4,7 @@ import type { Metadata } from "next"
|
||||
import { getPostBySlug, getAllPosts, getAuthorSlugByName } from "@/lib/db"
|
||||
import { PostCard } from "@/components/post-card"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import { displayTitle } from "@/lib/title"
|
||||
import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -19,7 +20,7 @@ export async function generateMetadata({
|
||||
const post = getPostBySlug(slug)
|
||||
if (!post) return { title: "No encontrado" }
|
||||
|
||||
const title = post.title
|
||||
const title = displayTitle(post)
|
||||
const description =
|
||||
post.summary && !post.summary.startsWith("**") && post.summary.length > 40
|
||||
? post.summary.slice(0, 160)
|
||||
@@ -81,6 +82,9 @@ export default async function PostDetailPage({
|
||||
// Enlaces cruzados: autor -> página de autor, parodia -> franquicia.
|
||||
const authorSlug = post.artist ? getAuthorSlugByName(post.artist) : null
|
||||
|
||||
// Título a mostrar (traducción al español si existe).
|
||||
const title = displayTitle(post)
|
||||
|
||||
return (
|
||||
<div className="pb-16 md:pb-0">
|
||||
<Link
|
||||
@@ -113,7 +117,7 @@ export default async function PostDetailPage({
|
||||
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold leading-tight">{post.title}</h1>
|
||||
<h1 className="text-lg sm:text-xl font-bold leading-tight">{title}</h1>
|
||||
{post.title_jpn && (
|
||||
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getPostsPage, getAllPosts } from "@/lib/db"
|
||||
import { InfinitePostGrid } from "@/components/infinite-grid"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { displayTitle } from "@/lib/title"
|
||||
import { BookOpen, Sparkles, ArrowRight } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -60,7 +61,7 @@ export default async function PublicHomePage() {
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight leading-tight line-clamp-3">
|
||||
{latest.title}
|
||||
{displayTitle(latest)}
|
||||
</h1>
|
||||
{latest.artist && (
|
||||
<p className="text-sm text-[var(--muted)]">por {latest.artist}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAllPosts } from "@/lib/db"
|
||||
import { displayTitle } from "@/lib/title"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -15,8 +16,9 @@ export async function GET() {
|
||||
(p.summary && !p.summary.startsWith("**") && p.summary.length > 30
|
||||
? p.summary.slice(0, 300)
|
||||
: `${p.num_pages} páginas`) || `${p.num_pages} páginas`
|
||||
const title = displayTitle(p)
|
||||
return ` <item>
|
||||
<title><![CDATA[${p.title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]></title>
|
||||
<title><![CDATA[${title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]></title>
|
||||
<link>https://worstscan.xyz/p/${p.slug}</link>
|
||||
<guid isPermaLink="false">worstscan-${p.gid}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import Link from "next/link"
|
||||
import type { Post } from "@/lib/db"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import { BookOpen, Clock } from "lucide-react"
|
||||
import { displayTitle } from "@/lib/title"
|
||||
|
||||
// Card estilo "pin" (Pinterest/manga sites): portada dominante, hover con zoom
|
||||
// y overlay de lectura, metadata compacta. Image-first para retención visual.
|
||||
// Card limpia: portada dominante, título (traducido al español si existe),
|
||||
// fecha de publicación. Sin tags ni artista (ya están en el panel lateral).
|
||||
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
||||
const isAnthology =
|
||||
post.tags?.some((t) =>
|
||||
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
|
||||
) || post.num_pages >= 100
|
||||
|
||||
// Duraciones de lectura aproximadas para dar contexto (hook de curiosidad).
|
||||
const readMinutes = post.num_pages > 0 ? Math.max(1, Math.round(post.num_pages / 30)) : null
|
||||
const title = displayTitle(post)
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
@@ -23,24 +22,18 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
||||
>
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
alt={title}
|
||||
className="h-32 w-24 flex-shrink-0 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-4">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{post.title}
|
||||
{title}
|
||||
</h3>
|
||||
{post.artist && (
|
||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||
)}
|
||||
<div className="flex flex-1 items-end gap-2">
|
||||
{isAnthology && (
|
||||
<span className="text-[9px] font-semibold text-[var(--accent)] uppercase">Tomo</span>
|
||||
)}
|
||||
{post.source && (
|
||||
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
|
||||
)}
|
||||
{post.published_at && (
|
||||
<span className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</span>
|
||||
)}
|
||||
@@ -59,7 +52,7 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
||||
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -87,23 +80,13 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadatos */}
|
||||
{/* Metadatos: solo título (traducido) + fecha */}
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
<h3 className="line-clamp-2 min-h-[2.6rem] text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{post.title}
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--muted)]">
|
||||
<span className="truncate">{post.artist || post.parody || post.source || "worst-scan"}</span>
|
||||
{readMinutes && (
|
||||
<span className="flex-shrink-0">{readMinutes} min</span>
|
||||
)}
|
||||
</div>
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||
))}
|
||||
</div>
|
||||
{post.published_at && (
|
||||
<p className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
+11
-2
@@ -112,12 +112,18 @@ function migrate(db: Database.Database) {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN series_id INTEGER REFERENCES series(id)")
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_posts_series ON posts(series_id)")
|
||||
} catch {}
|
||||
|
||||
// title_es: traducción del título al español (para la web en español).
|
||||
try {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number
|
||||
gid: string
|
||||
title: string
|
||||
title_es: string | null
|
||||
title_jpn: string | null
|
||||
artist: string | null
|
||||
parody: string | null
|
||||
@@ -137,6 +143,7 @@ export interface Post {
|
||||
export interface PostInput {
|
||||
gid: string
|
||||
title: string
|
||||
title_es?: string
|
||||
title_jpn?: string
|
||||
artist?: string
|
||||
parody?: string
|
||||
@@ -200,12 +207,13 @@ export function createPost(input: PostInput): Post {
|
||||
}
|
||||
|
||||
const stmt = d.prepare(`
|
||||
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})
|
||||
INSERT INTO posts (gid, title, title_es, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at)
|
||||
VALUES (@gid, @title, @title_es, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
|
||||
`)
|
||||
const result = stmt.run({
|
||||
gid: input.gid,
|
||||
title: input.title,
|
||||
title_es: input.title_es || null,
|
||||
title_jpn: input.title_jpn || null,
|
||||
artist: input.artist || null,
|
||||
parody: input.parody || null,
|
||||
@@ -225,6 +233,7 @@ export function createPost(input: PostInput): Post {
|
||||
// por nombres de columna y el bypass de `published`/columnas internas.
|
||||
const UPDATE_FIELDS = new Set([
|
||||
"title",
|
||||
"title_es",
|
||||
"title_jpn",
|
||||
"artist",
|
||||
"parody",
|
||||
|
||||
+4
-2
@@ -1,4 +1,5 @@
|
||||
import type { Post } from "./db"
|
||||
import { displayTitle } from "./title"
|
||||
|
||||
// Notificación a Discord vía Webhook.
|
||||
// Si DISCORD_WEBHOOK_URL no está configurada, no hace nada (silencioso).
|
||||
@@ -7,15 +8,16 @@ const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || ""
|
||||
export async function notifyNewPost(post: Post): Promise<boolean> {
|
||||
if (!WEBHOOK_URL) return false
|
||||
|
||||
const title = displayTitle(post)
|
||||
const description = post.summary && !post.summary.startsWith("**") && post.summary.length > 40
|
||||
? post.summary.slice(0, 200)
|
||||
: `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}`
|
||||
|
||||
const payload = {
|
||||
content: `📚 **Nuevo manga disponible:** ${post.title}`,
|
||||
content: `📚 **Nuevo manga disponible:** ${title}`,
|
||||
embeds: [
|
||||
{
|
||||
title: post.title,
|
||||
title,
|
||||
url: `https://worstscan.xyz/p/${post.slug}`,
|
||||
description,
|
||||
color: 0x6366f1, // indigo, acorde al tema
|
||||
|
||||
+20
-2
@@ -6,6 +6,7 @@ import { cacheCover } from "./cover-cache"
|
||||
import { notifyNewPost } from "./discord"
|
||||
import { moderateTags, moderationReasonText } from "./moderation"
|
||||
import { notifyQuarantine } from "./discord-moderation"
|
||||
import { translateTitle } from "./translate"
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
const POLL_INTERVAL_MS = 60_000
|
||||
@@ -67,9 +68,17 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||
if (!existing) {
|
||||
// Los posts moderados se crean en cuarentena (published=0)
|
||||
const isPublished = moderated ? 0 : 1
|
||||
|
||||
// Traducir el título al español (si no está ya en español).
|
||||
let titleEs: string | null = null
|
||||
try {
|
||||
titleEs = await translateTitle(cleanT)
|
||||
} catch {}
|
||||
|
||||
await createPost({
|
||||
gid,
|
||||
title: cleanT,
|
||||
title_es: titleEs || undefined,
|
||||
title_jpn: summary.title_jpn,
|
||||
artist: summary.artist,
|
||||
parody: summary.parody,
|
||||
@@ -96,11 +105,19 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||
await notifyNewPost(created).catch(() => {})
|
||||
}
|
||||
} else {
|
||||
// Post existente: si se actualizó y ahora tiene tags dudosos,
|
||||
// pasarlo a cuarentena (despublicar).
|
||||
// Post existente: si no tiene traducción, intentar traducirlo ahora.
|
||||
let titleEs = existing.title_es
|
||||
if (!titleEs) {
|
||||
try {
|
||||
titleEs = await translateTitle(cleanT)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Si se actualizó y ahora tiene tags dudosos, pasarlo a cuarentena.
|
||||
if (moderated) {
|
||||
updatePost(existing.id, {
|
||||
title: cleanT,
|
||||
title_es: titleEs || undefined,
|
||||
title_jpn: summary.title_jpn || undefined,
|
||||
artist: summary.artist || undefined,
|
||||
parody: summary.parody || undefined,
|
||||
@@ -116,6 +133,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||
} else {
|
||||
updatePost(existing.id, {
|
||||
title: cleanT,
|
||||
title_es: titleEs || undefined,
|
||||
title_jpn: summary.title_jpn || undefined,
|
||||
artist: summary.artist || undefined,
|
||||
parody: summary.parody || undefined,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Post } from "./db"
|
||||
|
||||
// Devuelve el título a mostrar: la traducción al español si existe,
|
||||
// o el título original como fallback.
|
||||
export function displayTitle(
|
||||
post: Pick<Post, "title" | "title_es">,
|
||||
): string {
|
||||
return (post.title_es && post.title_es.trim()) || post.title
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import "server-only"
|
||||
|
||||
// Traducción de títulos de manga al español usando el proxy free-ide local.
|
||||
// Prefiere deepseek-v4-flash-free (probado, devuelve contenido limpio) con
|
||||
// fallback a big-pickle. No depende de ninguna API externa de pago.
|
||||
|
||||
const PROXY_URL = process.env.FREE_IDE_PROXY_URL || "http://127.0.0.1:6446/v1"
|
||||
const PROXY_KEY = process.env.FREE_IDE_PROXY_KEY || "tw0rxo314kvga9lfbp2nj5sde7hiq6uc"
|
||||
|
||||
const SYSTEM_PROMPT = `Sos un traductor de títulos de manga al español latinoamericano.
|
||||
Recibís UN título de manga (en inglés, japonés romanizado, katakana, kanji o chino).
|
||||
Devolvés SOLO la traducción al español, sin comillas, sin corchetes de autor,
|
||||
sin explicaciones, sin "Traducción:" ni puntos finales.
|
||||
Mantené el tono del título original.
|
||||
Normas:
|
||||
- No traduzcas nombres propios (personajes, artistas, marcas).
|
||||
- Si el título ya está en español o es un nombre propio, devolvelo tal cual.
|
||||
- Traducí términos como "Haha no Hi" -> "Día de la Madre".
|
||||
- Si hay números de capítulo/volumen/año, mantenelos.
|
||||
- Respondé en UNA línea.`
|
||||
|
||||
// Llama al proxy y devuelve el texto traducido (o null si falla).
|
||||
export async function translateTitle(title: string): Promise<string | null> {
|
||||
const clean = (title || "").trim()
|
||||
if (!clean) return null
|
||||
|
||||
// Si ya parece estar en español, no traducir (heurística ligera).
|
||||
if (isProbablySpanish(clean)) return clean
|
||||
|
||||
const models = ["deepseek-v4-flash-free", "big-pickle"]
|
||||
|
||||
for (const model of models) {
|
||||
try {
|
||||
const res = await fetch(`${PROXY_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${PROXY_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: clean },
|
||||
],
|
||||
max_tokens: 200,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90000),
|
||||
})
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
const content: string | undefined = data?.choices?.[0]?.message?.content
|
||||
if (!content || !content.trim()) continue
|
||||
return content.trim().replace(/^["']|["']$/g, "")
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Heurística: detecta si el título ya está mayormente en español (para no
|
||||
// re-traducir nombres propios o títulos ya en español).
|
||||
function isProbablySpanish(t: string): boolean {
|
||||
// Si tiene caracteres CJK (japonés/chino) claramente no está en español.
|
||||
if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false
|
||||
// Palabras "muy en español" que indican que no necesita traducción.
|
||||
const spanishWords = ["el ", "la ", "los ", "las ", " de ", " y ", " un ", " una ", " para ", "con ", "por "]
|
||||
let count = 0
|
||||
for (const w of spanishWords) if (t.toLowerCase().includes(w)) count++
|
||||
// Si tiene 3+ marcadores de español, asumimos que ya está en español.
|
||||
return count >= 3
|
||||
}
|
||||
Reference in New Issue
Block a user