تخطَّ إلى المحتوى
ARAB CODEمجتمع المطورين العرب

مانهوا

javascriptv1MITنُشر ١ أغسطس ٢٠٢٦· ♥ 0 · ⬇ 1 · 💬 0

تحميل وبحث فصول مانهوا

// كود بحث وتحميل فصول مانهوا
// https://whatsapp.com/channel/0029Vb7Nq294Y9le1aAcTE0D
// تابعو القناة هننشر اكواد تانية "izana,uncel shawaza" 
import { generateWAMessageFromContent, prepareWAMessageMedia } from '@whiskeysockets/baileys'
import axios from 'axios'
import crypto from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import PDFDocument from 'pdfkit'
import sharp from 'sharp'

const API_BASE = 'https://engez.a7a.online/api/v1'
const MANHWA_ENDPOINT = `${API_BASE}/anime/manhwa`
const SELECT_SEPARATOR = '|'
const CACHE_TTL_MS = 3 * 60 * 1000
const DEFAULT_IMAGE = 'https://i.postimg.cc/bS01zQwK/upload-1767808833485.jpg'
const MAX_CHAPTERS_PER_MERGE = 15

const contextCache = new Map()

function makeToken() {
  return crypto.randomBytes(8).toString('hex')
}

function now() {
  return Date.now()
}

function isExpired(entry) {
  return !entry || (now() - entry.timestamp) > CACHE_TTL_MS
}

function setContext(data) {
  const token = makeToken()
  contextCache.set(token, { ...data, timestamp: now() })
  return token
}

function getContext(token) {
  const entry = contextCache.get(token)
  if (isExpired(entry)) {
    contextCache.delete(token)
    return null
  }
  return entry
}

function clearStaleContexts() {
  const ts = now()
  for (const [token, entry] of contextCache.entries()) {
    if (!entry || (ts - entry.timestamp) > CACHE_TTL_MS) {
      contextCache.delete(token)
    }
  }
}

function cleanText(text = '') {
  return String(text)
    .replace(/\s+/g, ' ')
    .replace(/\s*[-–—:|]+\s*$/g, '')
    .replace(/^\s*[-–—:|]+\s*/g, '')
    .trim()
}

function safeFileName(text = '') {
  return cleanText(text)
    .replace(/[\\/:*?"<>|]/g, '_')
    .replace(/\s+/g, ' ')
    .trim()
}

// ─── genres راجعة كـ array من objects {id, name, series_count}، مش
// array نصوص بسيطة زي tags بتاعة الأنمي — لازم .name صريح
function genreNames(genres = []) {
  return genres.map((g) => g.name).filter(Boolean).join('، ')
}

function describeSeriesItem(item, index) {
  const title = cleanText(item.title || 'بدون عنوان')
  const status = item.status || 'غير معروف'
  const rating = item.rating != null ? Number(item.rating).toFixed(1) : 'غير معروف'
  return {
    header: `مانهوا #${index + 1}`,
    title,
    description: `الحالة: ${status} • التقييم: ${rating}`
  }
}

function describeChapterItem(item, index) {
  const chapterNum = item.chapter || String(index + 1)
  const title = cleanText(item.title || `الفصل ${chapterNum}`)
  return {
    header: `فصل #${chapterNum}`,
    title,
    description: `👁️ ${item.views || 0} مشاهدة`
  }
}

async function sendSelectList(conn, chat, quoted, { title, footer, sections, posterUrl }) {
  let mediaMessage = null
  try {
    mediaMessage = await prepareWAMessageMedia(
      { image: { url: posterUrl || DEFAULT_IMAGE } },
      { upload: conn.waUploadToServer }
    )
  } catch {
    try {
      mediaMessage = await prepareWAMessageMedia(
        { image: { url: DEFAULT_IMAGE } },
        { upload: conn.waUploadToServer }
      )
    } catch {
      mediaMessage = null
    }
  }

  try {
    const msgContent = {
      viewOnceMessage: {
        message: {
          interactiveMessage: {
            body: { text: title },
            footer: { text: footer },
            header: mediaMessage ? {
              hasMediaAttachment: true,
              imageMessage: mediaMessage.imageMessage
            } : { hasMediaAttachment: false, title },
            nativeFlowMessage: {
              buttons: [{
                name: 'single_select',
                buttonParamsJson: JSON.stringify({
                  title: '🔏 مانهوا 🔏',
                  sections
                })
              }]
            }
          }
        }
      }
    }
    const waMsg = generateWAMessageFromContent(chat, msgContent, { userJid: conn.user.jid, quoted })
    await conn.relayMessage(chat, waMsg.message, { messageId: waMsg.key.id })
  } catch (e) {
    let txt = `${title}\n\n`
    for (const section of sections) {
      for (const row of section.rows) {
        txt += `${row.header}\n${row.title}\n${row.id}\n\n`
      }
    }
    await conn.sendMessage(chat, { text: txt }, { quoted })
  }
}

async function fetchSearchResults(query) {
  clearStaleContexts()

  const apiUrl = `${MANHWA_ENDPOINT}?action=${encodeURIComponent('بحث')}&q=${encodeURIComponent(query)}`
  const { data } = await axios.get(apiUrl, { timeout: 30000 })

  if (!data || data.success !== true) {
    throw new Error('لم يتم العثور على نتائج لهذا البحث')
  }

  const results = data.response?.results
  if (!Array.isArray(results) || results.length === 0) {
    throw new Error('لا توجد نتائج مطابقة')
  }

  return results
}

async function fetchChapters(seriesId) {
  clearStaleContexts()

  const apiUrl = `${MANHWA_ENDPOINT}?action=${encodeURIComponent('فصول')}&id=${encodeURIComponent(seriesId)}`
  const { data } = await axios.get(apiUrl, { timeout: 30000 })

  if (!data || data.success !== true) {
    throw new Error('لم يتم العثور على فصول لهذه المانهوا')
  }

  const results = data.response?.results
  if (!Array.isArray(results) || results.length === 0) {
    throw new Error('لا توجد فصول متاحة لهذه المانهوا')
  }

  return results
}

// ─── بيرجّع الفصل الكامل (بما فيه الصور)، بعكس fetchChapters اللي
// بترجّع بيانات وصفية بس من غير images (زي ما تأكدنا من استجابة الـ API)
async function fetchChapterFull(chapterId) {
  const apiUrl = `${MANHWA_ENDPOINT}?action=${encodeURIComponent('فصل')}&id=${encodeURIComponent(chapterId)}`
  const { data } = await axios.get(apiUrl, { timeout: 30000 })

  if (!data || data.success !== true || !data.response) {
    throw new Error('لم يتم العثور على هذا الفصل')
  }

  const chapter = data.response
  if (!Array.isArray(chapter.images) || chapter.images.length === 0) {
    throw new Error('لا توجد صور في هذا الفصل')
  }

  return chapter
}

// ─── تحويل مجموعة فصول لملف PDF واحد، كل صورة صفحة، مع ضغط jpeg
// (نفس منطق الهاندلر الأصلي بالظبط: sharp + pdfkit)
async function createChaptersPDF(chapters, outputPath) {
  const doc = new PDFDocument({ autoFirstPage: false, margin: 0 })
  const stream = fs.createWriteStream(outputPath)
  doc.pipe(stream)
  let addedPages = 0

  for (const chap of chapters) {
    // images راجعة كـ [{image, order}, ...] — نرتبها بالـ order احتياطاً
    // (الاستجابة الفعلية جات مرتبة أصلاً، لكن منعتمدش على ترتيب المصدر)
    const sortedImages = [...(chap.images || [])].sort((a, b) => (a.order || 0) - (b.order || 0))

    for (const img of sortedImages) {
      try {
        const res = await axios.get(img.image, {
          responseType: 'arraybuffer',
          timeout: 30000,
          headers: { 'User-Agent': 'Mozilla/5.0' }
        })
        const buffer = await sharp(Buffer.from(res.data))
          .jpeg({ quality: 45, mozjpeg: true })
          .toBuffer()
        const meta = await sharp(buffer).metadata()
        if (!meta.width || !meta.height) continue
        doc.addPage({ size: [meta.width, meta.height] })
        doc.image(buffer, 0, 0, { width: meta.width, height: meta.height })
        addedPages++
      } catch (e) {
        console.error(`Skipping image: ${img.image}`, e.message)
      }
    }
  }

  if (addedPages === 0) {
    doc.destroy()
    throw new Error('لم يتم إضافة أي صفحات للـ PDF، فشل تحميل كل الصور.')
  }
  doc.end()
  return new Promise((resolve, reject) => {
    stream.on('finish', resolve)
    stream.on('error', reject)
  })
}

async function sendSearchResults(conn, m, usedPrefix, query, results) {
  const token = setContext({ type: 'search', query, results })

  const rows = results.slice(0, 20).map((item, index) => {
    const info = describeSeriesItem(item, index)
    return {
      header: info.header,
      title: info.title,
      description: info.description,
      id: `${usedPrefix}مانهوا search${SELECT_SEPARATOR}${token}${SELECT_SEPARATOR}${index}`
    }
  })

  const posterUrl = results[0]?.poster?.medium || results[0]?.poster?.thumbnail || DEFAULT_IMAGE

  await sendSelectList(conn, m.chat, m, {
    title: `*🔎 نتائج البحث عن:* ${query}`,
    footer: 'اختر المانهوا المطلوبة من القائمة:',
    posterUrl,
    sections: [
      {
        title: '📥 نتائج البحث',
        highlight_label: `📥 ${rows.length} نتيجة`,
        rows
      }
    ]
  })
}

async function sendChaptersList(conn, m, usedPrefix, seriesTitle, chapters, seriesId, posterUrl) {
  const token = setContext({
    type: 'chapters',
    seriesTitle,
    seriesId,
    chapters
  })

  const rows = chapters.slice(0, 30).map((item, index) => {
    const info = describeChapterItem(item, index)
    return {
      header: info.header,
      title: info.title,
      description: info.description,
      id: `${usedPrefix}مانهوا chapter${SELECT_SEPARATOR}${token}${SELECT_SEPARATOR}${index}`
    }
  })

  await sendSelectList(conn, m.chat, m, {
    title: `*📖 المانهوا:* ${seriesTitle}\n*📚 عدد الفصول:* ${chapters.length}`,
    footer: 'اختر الفصل المطلوب من القائمة:',
    posterUrl,
    sections: [
      {
        title: '📥 الفصول المتاحة',
        highlight_label: `📥 ${rows.length} فصل`,
        rows
      }
    ]
  })
}

async function sendChapterPDF(conn, chat, quoted, { chapter, seriesTitle, usedPrefix }) {
  const pdfPath = path.join(os.tmpdir(), `manhwa_${Date.now()}.pdf`)
  try {
    await createChaptersPDF([chapter], pdfPath)
  } catch (e) {
    throw new Error(`فشل إنشاء PDF: ${e.message}`)
  }

  const chapterLabel = cleanText(chapter.title || `الفصل ${chapter.chapter}`)
  const { documentMessage } = await prepareWAMessageMedia(
    {
      document: { url: pdfPath },
      mimetype: 'application/pdf',
      fileName: `${safeFileName(seriesTitle)} - ${safeFileName(chapterLabel)}.pdf`
    },
    { upload: conn.waUploadToServer }
  )

  const buttons = []
  if (chapter.navigation?.prev) {
    buttons.push({
      name: 'quick_reply',
      buttonParamsJson: JSON.stringify({
        display_text: '⬅️ الفصل السابق',
        id: `${usedPrefix}مانهوا chapterid${SELECT_SEPARATOR}${chapter.navigation.prev}`
      })
    })
  }
  if (chapter.navigation?.next) {
    buttons.push({
      name: 'quick_reply',
      buttonParamsJson: JSON.stringify({
        display_text: 'الفصل التالي ➡️',
        id: `${usedPrefix}مانهوا chapterid${SELECT_SEPARATOR}${chapter.navigation.next}`
      })
    })
  }

  const msgContent = {
    viewOnceMessage: {
      message: {
        interactiveMessage: {
          body: {
            text: `📖 مانهوا: *${seriesTitle}*\n🔖 الفصل: *${chapterLabel}*\n👁️ المشاهدات: ${chapter.views || 0}\n🖼️ عدد الصور: ${chapter.images.length}`
          },
          footer: { text: '🔏 AppSwat Manhwa 🔏' },
          header: { hasMediaAttachment: true, documentMessage },
          nativeFlowMessage: { buttons }
        }
      }
    }
  }
  const waMsg = generateWAMessageFromContent(chat, msgContent, { userJid: conn.user.jid, quoted })
  await conn.relayMessage(chat, waMsg.message, { messageId: waMsg.key.id })

  fs.unlink(pdfPath, () => {})
}

const handler = async (m, { conn, args, usedPrefix, command }) => {
  const rawInput = args.join(' ').trim()

  if (!rawInput) {
    return conn.sendMessage(
      m.chat,
      {
        text: `╭─❖ *بحث وفصول المانهوا* ❖─╮\n│\n│ أرسل اسم المانهوا للبحث:\n│ ${usedPrefix}${command} solo leveling\n│\n│ أو اختر من القوائم بعد البحث.\n│\n╰────────────────╯`
      },
      { quoted: m }
    )
  }

  const parts = rawInput.split(SELECT_SEPARATOR)
  const action = parts[0]?.trim()?.toLowerCase()

  try {
    if (action === 'search') {
      const token = parts[1]
      const index = parseInt(parts[2], 10)

      const ctx = getContext(token)
      if (!ctx || ctx.type !== 'search') {
        return conn.sendMessage(
          m.chat,
          { text: '❌ انتهت جلسة البحث، أعد إرسال اسم المانهوا.' },
          { quoted: m }
        )
      }

      const selected = ctx.results[index]
      if (!selected) {
        return conn.sendMessage(
          m.chat,
          { text: '❌ الاختيار غير موجود، أعد المحاولة.' },
          { quoted: m }
        )
      }

      await conn.sendMessage(m.chat, { text: '⏳ جاري جلب الفصول...' }, { quoted: m })

      const seriesId = selected.id
      const seriesTitle = cleanText(selected.title || 'بدون عنوان')
      const posterUrl = selected.poster?.medium || selected.poster?.thumbnail || DEFAULT_IMAGE
      const chapters = await fetchChapters(seriesId)
      await sendChaptersList(conn, m, usedPrefix, seriesTitle, chapters, seriesId, posterUrl)
      return
    }

    if (action === 'chapter') {
      const token = parts[1]
      const index = parseInt(parts[2], 10)

      const ctx = getContext(token)
      if (!ctx || ctx.type !== 'chapters') {
        return conn.sendMessage(
          m.chat,
          { text: '❌ انتهت جلسة الفصول، أعد اختيار المانهوا.' },
          { quoted: m }
        )
      }

      const chapterMeta = ctx.chapters[index]
      if (!chapterMeta) {
        return conn.sendMessage(
          m.chat,
          { text: '❌ الفصل غير موجود، أعد المحاولة.' },
          { quoted: m }
        )
      }

      await conn.sendMessage(m.chat, { text: '⏳ جاري تحميل صور الفصل وتجهيز PDF...' }, { quoted: m })

      const chapter = await fetchChapterFull(chapterMeta.id)
      await sendChapterPDF(conn, m.chat, m, {
        chapter,
        seriesTitle: cleanText(ctx.seriesTitle || 'بدون عنوان'),
        usedPrefix
      })
      return
    }

    // اختيار مباشر بالـ id (زر "الفصل التالي/السابق" بعد ما استلمنا الـ
    // PDF — هنا مفيش context قديم نرجعله، الـ id بييجي صريح في الأمر)
    if (action === 'chapterid') {
      const chapterId = parts[1]
      if (!chapterId) {
        return conn.sendMessage(
          m.chat,
          { text: '❌ رقم الفصل غير صالح.' },
          { quoted: m }
        )
      }

      await conn.sendMessage(m.chat, { text: '⏳ جاري تحميل صور الفصل وتجهيز PDF...' }, { quoted: m })

      const chapter = await fetchChapterFull(chapterId)
      const seriesTitle = cleanText(chapter.serie?.title || 'بدون عنوان')
      await sendChapterPDF(conn, m.chat, m, { chapter, seriesTitle, usedPrefix })
      return
    }

    await conn.sendMessage(m.chat, { text: '⏳ جاري البحث عن المانهوا...' }, { quoted: m })

    const results = await fetchSearchResults(rawInput)
    await sendSearchResults(conn, m, usedPrefix, rawInput, results)
  } catch (e) {
    console.error('manhwa handler error:', e)
    const msg = e?.response?.data?.error || e?.message || 'حدث خطأ غير متوقع'
    await conn.sendMessage(
      m.chat,
      { text: `❌ ${msg}\n\nتأكد من الاسم وحاول مرة أخرى.` },
      { quoted: m }
    )
  }
}

handler.command = /^(مانهوا|manhwa|بحث_مانهوا|فصول_مانهوا)$/i
handler.help = ['مانهوا <اسم>']
handler.tags = ['downloader']

export default handler

التعليقات (0)

سجّل الدخول للتعليق.