禁漫天堂

https://www.cdnhth.club

zpccool (13551) 1天前 下载:615

漫画 漫画 18禁 禁漫 JM
禁漫天堂(JMComic)移动端 API,提供漫画搜索、详情、目录及图片阅读。图片经服务端分割打乱,阅读时需解密还原。
二维码导入(APP尚未完成该功能)
// @name        禁漫天堂
// @version     1.0.0
// @uuid        jinmantiantang
// @author      Ai
// @url         https://www.cdnhth.club
// @logo        https://cdn-msp.jmapiproxy1.cc/media/logo/round_logo.png
// @type        comic
// @enabled     true
// @tags        漫画,18禁,禁漫,JM
// @description 禁漫天堂(JMComic)移动端 API,提供漫画搜索、详情、目录及图片阅读。图片经服务端分割打乱,阅读时需解密还原。

// ─── 内置测试 ────────────────────────────────────────────────────
async function TEST(type) {
  if (type === "__list__") return ["search", "explore", "bookInfo", "chapterList", "chapterContent"];

  if (type === "search") {
    var results = await search("MANA", 1);
    if (!results || results.length < 1) return { passed: false, message: "搜索结果为空" };
    return { passed: true, message: '搜索"MANA"返回 ' + results.length + " 条结果 ✓" };
  }

  if (type === "explore") {
    var cats = await explore(1, "");
    if (!cats || cats.length < 1) return { passed: false, message: "发现页分类为空" };
    var books = await explore(1, cats[0]);
    if (!books || books.length < 1) return { passed: false, message: "发现页 [" + cats[0] + "] 返回为空" };
    return { passed: true, message: "发现页 [" + cats[0] + "]: " + books.length + " 条结果 ✓" };
  }

  if (type === "bookInfo") {
    var r = await bookInfo("jm://302560");
    return { passed: !!r.name, message: "bookInfo name=" + r.name };
  }

  if (type === "chapterList") {
    var r = await chapterList("jm://302560");
    return { passed: r.length > 0, message: "chapterList cnt=" + r.length + " first=" + (r[0] ? r[0].name : "N/A") };
  }

  if (type === "chapterContent") {
    var r = await chapterContent("jmphoto://302560/302560");
    return { passed: r.length > 10, message: "chapterContent len=" + r.length + " (图片URL列表含混淆参数)" };
  }

  return { passed: false, message: "未知测试类型: " + type };
}

// ─── 配置 / 常量 ──────────────────────────────────────────────────
var API_DOMAINS = ["www.cdnhth.club", "www.cdngwc.cc", "www.cdngwc.net", "www.cdngwc.club", "www.cdnhjk.cc"];
var IMAGE_DOMAINS = [
  "cdn-msp.jmapiproxy1.cc",
  "cdn-msp.jmapiproxy2.cc",
  "cdn-msp2.jmapiproxy2.cc",
  "cdn-msp3.jmapiproxy2.cc",
  "cdn-msp.jmapinodeudzn.net",
];

var APP_VERSION = "2.0.19";
var APP_TOKEN_SECRET = "18comicAPP";
var APP_TOKEN_SECRET_2 = "18comicAPPContent";
var APP_DATA_SECRET = "185Hcomic3PAPP7R";

var SCRAMBLE_220980 = 220980;
var SCRAMBLE_268850 = 268850;
var SCRAMBLE_421926 = 421926;

var UA =
  "Mozilla/5.0 (Linux; Android 9; V1938CT Build/PQ3A.190705.11211812; wv) " +
  "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36";

// ─── 工具函数 ──────────────────────────────────────────────────────

/** 当前 Unix 时间戳(秒) */
function timeStamp() {
  return String(Math.floor(Date.now() / 1000));
}

/** 生成请求 headers(含 token) */
async function apiHeaders(secret) {
  var ts = timeStamp();
  var s = secret || APP_TOKEN_SECRET;
  var token = await legado.md5(ts + s);
  var tokenparam = ts + "," + APP_VERSION;
  return {
    "Accept-Encoding": "gzip, deflate",
    "user-agent": UA,
    token: token,
    tokenparam: tokenparam,
  };
}

/** 图片请求 headers */
function imageHeaders() {
  return {
    Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
    "X-Requested-With": "com.JMComic3.app",
    Referer: "https://" + API_DOMAINS[0],
    "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
    "user-agent": UA,
  };
}

/** 解密 API 返回数据(AES-256-ECB) */
async function decryptData(data, ts) {
  var key = await legado.md5(ts + APP_DATA_SECRET);
  return await legado.aesDecrypt(data, key, "", "ECB");
}

/** 调用 API 并解密返回 JSON 对象 */
async function apiGet(path, params) {
  var ts = timeStamp();
  var token = await legado.md5(ts + APP_TOKEN_SECRET);
  var tokenparam = ts + "," + APP_VERSION;
  var headers = {
    "Accept-Encoding": "gzip, deflate",
    "user-agent": UA,
    token: token,
    tokenparam: tokenparam,
  };

  var url = "https://" + API_DOMAINS[0] + path;
  if (params) {
    var parts = [];
    var keys = Object.keys(params);
    for (var i = 0; i < keys.length; i++) {
      parts.push(encodeURIComponent(keys[i]) + "=" + encodeURIComponent(params[keys[i]]));
    }
    if (parts.length > 0) url += "?" + parts.join("&");
  }

  legado.log("[apiGet] url=" + url);
  var resp = await legado.http.get(url, headers);

  // 解析 JSON 外壳
  var json;
  try {
    json = JSON.parse(resp);
  } catch (e) {
    legado.log("[apiGet] JSON解析失败: " + String(e) + " resp=" + String(resp).substring(0, 200));
    return null;
  }

  if (!json || !json.data) {
    legado.log("[apiGet] 无data字段, code=" + (json ? json.code : "null"));
    return null;
  }

  // 解密 data 字段
  var decrypted;
  try {
    decrypted = await decryptData(json.data, ts);
  } catch (e) {
    legado.log("[apiGet] 解密失败: " + String(e));
    return null;
  }

  try {
    return JSON.parse(decrypted);
  } catch (e) {
    legado.log("[apiGet] 解密后JSON解析失败: " + String(e) + " text=" + String(decrypted).substring(0, 200));
    return null;
  }
}

/** 获取封面 URL */
function coverUrl(albumId) {
  return "https://" + IMAGE_DOMAINS[0] + "/media/albums/" + albumId + "_3x4.jpg";
}

/** 获取图片下载域名 */
function imageDomain() {
  return IMAGE_DOMAINS[0];
}

// ─── 搜索 ──────────────────────────────────────────────────────────
async function search(keyword, page) {
  legado.log("[search] keyword=" + keyword + " page=" + page);
  var data = await apiGet("/search", {
    search_query: keyword,
    main_tag: "0",
    page: String(page),
    o: "mr",
    t: "a",
  });

  if (!data) return [];

  // 处理直接搜索车号时的重定向
  if (data.redirect_aid) {
    legado.log("[search] 重定向到 album: " + data.redirect_aid);
    var info = await apiGet("/album", { id: data.redirect_aid });
    if (!info) return [];
    return [
      {
        name: info.name || "",
        author: info.author && info.author.length > 0 ? info.author.join(", ") : "",
        coverUrl: coverUrl(data.redirect_aid),
        bookUrl: "jm://" + data.redirect_aid,
        description: (info.description || "").substring(0, 100),
      },
    ];
  }

  var content = data.content || [];
  legado.log("[search] 结果数: " + content.length + " / 总数: " + (data.total || 0));

  var results = [];
  for (var i = 0; i < content.length; i++) {
    var item = content[i];
    var aid = String(item.id || "");
    if (!aid) continue;
    results.push({
      name: item.name || "",
      author: item.author || "",
      coverUrl: coverUrl(aid),
      bookUrl: "jm://" + aid,
      description: "",
    });
  }
  return results;
}

// ─── 详情 ──────────────────────────────────────────────────────────
async function bookInfo(bookUrl) {
  var aid = bookUrl.replace("jm://", "");
  legado.log("[bookInfo] aid=" + aid);

  var data = await apiGet("/album", { id: aid });
  if (!data) {
    return { name: "", author: "", coverUrl: "", description: "", tocUrl: bookUrl };
  }

  var authors = "";
  if (data.author) {
    if (typeof data.author === "string") {
      authors = data.author;
    } else if (data.author.length) {
      authors = data.author.join(", ");
    }
  }

  var tagList = data.tags || [];
  if (typeof tagList === "string") {
    tagList = tagList.split(/[,\s]+/);
  }

  var actors = data.actors || [];
  var works = data.works || [];
  var desc = (data.description || "").replace(/<[^>]+>/g, "").trim();
  if (tagList.length > 0) desc = "标签: " + tagList.join(", ") + "\n" + desc;
  if (actors.length > 0) desc = "角色: " + actors.join(", ") + "\n" + desc;
  if (works.length > 0) desc = "作品: " + works.join(", ") + "\n" + desc;

  legado.log("[bookInfo] name=" + data.name + " author=" + authors + " episodes=" + ((data.series || []).length || "单章"));

  return {
    name: data.name || "",
    author: authors,
    coverUrl: coverUrl(aid),
    description: desc,
    tocUrl: bookUrl,
    latestChapter: "",
  };
}

// ─── 目录 ──────────────────────────────────────────────────────────
async function chapterList(tocUrl) {
  var aid = tocUrl.replace("jm://", "");
  legado.log("[chapterList] aid=" + aid);

  var data = await apiGet("/album", { id: aid });
  if (!data) return [];

  var series = data.series || [];
  var chapters = [];

  if (series.length === 0) {
    // 单章本子
    chapters.push({
      name: data.name || "正篇",
      url: "jmphoto://" + aid + "/" + aid,
    });
  } else {
    // 按 sort 正序排列
    series.sort(function (a, b) {
      return parseInt(a.sort) - parseInt(b.sort);
    });
    for (var i = 0; i < series.length; i++) {
      var ep = series[i];
      chapters.push({
        name: ep.name || "第" + ep.sort + "话",
        url: "jmphoto://" + aid + "/" + ep.id,
      });
    }
  }

  legado.log("[chapterList] 共 " + chapters.length + " 章");
  return chapters;
}

// ─── 正文(图片列表) ──────────────────────────────────────────────
async function chapterContent(chapterUrl) {
  // chapterUrl: jmphoto://albumId/photoId
  var parts = chapterUrl.replace("jmphoto://", "").split("/");
  var albumId = parts[0];
  var photoId = parts[1] || albumId;
  legado.log("[chapterContent] albumId=" + albumId + " photoId=" + photoId);

  var data = await apiGet("/chapter", { id: photoId });
  if (!data) return JSON.stringify([]);

  var images = data.images || [];
  legado.log("[chapterContent] 图片数: " + images.length);

  // 获取 scramble_id(从 /chapter_view_template)
  var scrambleId = await fetchScrambleId(photoId);
  legado.log("[chapterContent] scramble_id=" + scrambleId);

  var domain = imageDomain();
  var urls = [];
  for (var i = 0; i < images.length; i++) {
    var imgName = images[i]; // e.g. "00001.webp"
    var imgUrl = "https://" + domain + "/media/photos/" + photoId + "/" + imgName;

    // 计算分割数,编码到 URL fragment 供 processImage 使用
    // 注意:JM 算法使用 photoId(章节ID)而非 albumId 参与 md5 计算
    var num = await getScrambleNum(scrambleId, photoId, imgName);
    if (num > 0) {
      imgUrl += "#jm_scramble=" + num;
    }

    urls.push(imgUrl);
  }

  return JSON.stringify(urls);
}

// ─── 图片处理回调(每张图片下载后由引擎自动调用) ───────────────────
/**
 * 对下载完成的图片进行解密还原。
 * JM 的图片经过水平分割打乱,需要根据分割数 (num) 将条带重新排列。
 *
 * @param {string} srcHandle - Rust 预解码的图片句柄(如 "I0")
 * @param {number} pageIndex - 0-based 页码
 * @param {string} imageUrl  - 原始 URL(含 #jm_scramble=num fragment)
 * @returns {string|null} 处理后的图片句柄,返回 null 表示无需处理
 */
function processImage(srcHandle, pageIndex, imageUrl) {
  // 从 URL fragment 提取分割数
  var num = 0;
  var hashIdx = imageUrl.indexOf("#jm_scramble=");
  if (hashIdx !== -1) {
    num = parseInt(imageUrl.substring(hashIdx + 13));
  }

  if (!num || num <= 0) {
    return null; // 无需解密
  }

  // jmDecode:单次 Boa→Rust 调用完成全部条带还原(memcpy 级别,消耗 srcHandle)
  return legado.image.jmDecode(srcHandle, num);
}

/** 获取 scramble_id */
async function fetchScrambleId(photoId) {
  var ts = timeStamp();
  var token = await legado.md5(ts + APP_TOKEN_SECRET_2);
  var tokenparam = ts + "," + APP_VERSION;
  var headers = {
    "Accept-Encoding": "gzip, deflate",
    "user-agent": UA,
    token: token,
    tokenparam: tokenparam,
  };

  var url =
    "https://" + API_DOMAINS[0] + "/chapter_view_template" + "?id=" + photoId + "&mode=vertical&page=0&app_img_shunt=1&express=off" + "&v=" + ts;

  legado.log("[fetchScrambleId] url=" + url);
  var resp;
  try {
    resp = await legado.http.get(url, headers);
  } catch (e) {
    legado.log("[fetchScrambleId] 请求失败############");
    legado.log("[fetchScrambleId] 请求失败############");
    legado.log("[fetchScrambleId] 请求失败############");
    legado.log("[fetchScrambleId] 请求失败############");
    legado.log("[fetchScrambleId] 请求失败############");
    legado.log("[fetchScrambleId] 请求失败: " + String(e));
    return String(SCRAMBLE_220980);
  }

  var match = /var scramble_id = (\d+);/.exec(resp);
  if (match) {
    return match[1];
  }
  legado.log("[fetchScrambleId] 未匹配到 scramble_id,使用默认值");
  legado.log("[fetchScrambleId] 未匹配到 scramble_id,使用默认值");
  legado.log("[fetchScrambleId] 未匹配到 scramble_id,使用默认值");
  legado.log("[fetchScrambleId] 未匹配到 scramble_id,使用默认值");
  legado.log("[fetchScrambleId] 未匹配到 scramble_id,使用默认值");
  return String(SCRAMBLE_220980);
}

/**
 * 计算图片分割数
 * 参考: JMComic-Crawler-Python JmImageTool.get_num()
 * 注意: 第二个参数为章节 photoId,不是专辑 albumId
 */
async function getScrambleNum(scrambleId, photoId, filename) {
  scrambleId = parseInt(scrambleId);
  photoId = parseInt(photoId);

  // 去掉文件后缀获取纯文件名
  var dotIdx = filename.lastIndexOf(".");
  var fname = dotIdx > 0 ? filename.substring(0, dotIdx) : filename;

  if (photoId < scrambleId) {
    return 0;
  }
  if (photoId < SCRAMBLE_268850) {
    return 10;
  }

  var x = photoId < SCRAMBLE_421926 ? 10 : 8;
  var s = String(photoId) + fname;
  var md5hex = await legado.md5(s);
  var lastChar = md5hex.charCodeAt(md5hex.length - 1);
  var num = lastChar % x;
  num = num * 2 + 2;
  return num;
}

// ─── 发现页 ────────────────────────────────────────────────────────
var EXPLORE_CATEGORIES = {
  同人: "doujin",
  单本: "single",
  短篇: "short",
  其他: "another",
  韩漫: "hanman",
  美漫: "meiman",
  Cosplay: "doujin_cosplay",
  "3D": "3D",
};

async function explore(page, category) {
  legado.log("[explore] page=" + page + " category=" + (category || "(empty)"));

  // 无分类或未匹配时返回分类名列表
  var catCode = category ? EXPLORE_CATEGORIES[category] : null;
  if (!catCode) {
    var cats = [];
    for (var k in EXPLORE_CATEGORIES) {
      cats.push(k);
    }
    return cats;
  }

  var data = await apiGet("/categories/filter", {
    page: String(page),
    order: "",
    c: catCode,
    o: "mv",
  });

  if (!data) return [];

  var content = data.content || [];
  legado.log("[explore] " + category + " 结果数: " + content.length);

  var results = [];
  for (var i = 0; i < content.length; i++) {
    var item = content[i];
    var aid = String(item.id || "");
    if (!aid) continue;
    results.push({
      name: item.name || "",
      author: item.author || "",
      coverUrl: coverUrl(aid),
      bookUrl: "jm://" + aid,
      description: "",
    });
  }
  return results;
}
广告