3A小说

https://www.aaawz.cc

ethereal-essence (13554) 16小时前 下载:305

小说 免费 小说 免费小说 API
3A小说网(aaawz.cc),免费小说站,JSON API 书源,正文 AES 加密。
二维码导入(APP尚未完成该功能)
// @uuid        019e1209-09f6-7401-ba45-2c5bd162c276
// @name        3A小说
// @version     1.2.5
// @author      Ethereal
// @url         https://www.aaawz.cc
// @type        novel
// @logo        https://www.aaawz.cc/favicon.ico
// @enabled     true
// @tags        免费,小说,免费小说,API
// @description 3A小说网(aaawz.cc),免费小说站,JSON API 书源,正文 AES 加密。

// ─── 内置测试 ─────────────────────────────────────────────────────────────

async function TEST(type) {
  if (type === '__list__') return ['search', 'explore'];

  if (type === 'search') {
    var results = await search('斗破苍穹', 1);
    if (!results || results.length < 1) return { passed: false, message: '搜索结果为空' };
    var found = false;
    for (var i = 0; i < results.length; i++) {
      if (results[i].author && results[i].author.indexOf('天蚕土豆') !== -1) { found = true; break; }
    }
    if (!found) return { passed: false, message: '搜索结果中未找到作者包含"天蚕土豆"的条目' };
    return { passed: true, message: '搜索"斗破苍穹"返回 ' + results.length + ' 条结果 ✓' };
  }

  if (type === 'explore') {
    var books = await explore(1, '推荐');
    if (!books || books.length < 1) return { passed: false, message: '发现页返回为空' };
    return { passed: true, message: '发现页返回 ' + books.length + ' 条结果 ✓' };
  }

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

// ─── 配置 ────────────────────────────────────────────────────────────────

var BASE = 'https://www.aaawz.cc';
var AES_KEY = '123#2^0@0vm@08.b5%$1[A]1&4115s((';

// ─── HTTP(带超时/重试)────────────────────────────────────────────────────

function mergeHeaders(a, b) {
  var out = {};
  var k;
  if (a) for (k in a) out[k] = a[k];
  if (b) for (k in b) out[k] = b[k];
  return out;
}

function defaultHeaders(extra) {
  // 鸿蒙端/部分网络栈在无 UA 时更容易遇到 TLS/EOF 等异常,统一补默认请求头
  var base = {
    'User-Agent': 'Mozilla/5.0 (Linux; Android 12; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
    'Accept': 'application/json, text/plain, */*',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.6',
    'Cache-Control': 'no-cache',
    'Pragma': 'no-cache',
  };
  return mergeHeaders(base, extra || {});
}

async function httpGet(url, headers, timeoutSecs) {
  try {
    return await legado.http.get(url, defaultHeaders(headers));
  } catch (e1) {
    legado.log('[httpGet] retry once: ' + url + ' err=' + e1);
    try {
      return await legado.http.get(url, defaultHeaders(headers));
    } catch (e2) {
      legado.log('[httpGet] fallback to browser.run: ' + url + ' err=' + e2);
      // 仅在宿主支持 browser API 时启用兜底,避免鸿蒙端缺少接口导致卡死
      if (legado && legado.browser && legado.browser.run) {
        return browserFetchText(url, 'GET', '', defaultHeaders(headers), timeoutSecs || 30);
      }
      throw e2;
    }
  }
}

async function httpPost(url, body, headers, timeoutSecs) {
  try {
    return await legado.http.post(url, body, defaultHeaders(headers));
  } catch (e1) {
    legado.log('[httpPost] retry once: ' + url + ' err=' + e1);
    try {
      return await legado.http.post(url, body, defaultHeaders(headers));
    } catch (e2) {
      legado.log('[httpPost] fallback to browser.run: ' + url + ' err=' + e2);
      if (legado && legado.browser && legado.browser.run) {
        return browserFetchText(url, 'POST', body || '', defaultHeaders(headers), timeoutSecs || 30);
      }
      throw e2;
    }
  }
}

function browserFetchText(url, method, body, headers, timeoutSecs) {
  // 通过探测 WebView 发起 fetch,规避部分站点对直连 HTTP 客户端的限制
  // 注意:这里只返回 text,由上层 parseApiResponse 决定是否需要 LZ 解压/JSON.parse
  var u = '' + url;
  var m = (method || 'GET').toUpperCase();
  var h = headers || {};
  var b = body || '';
  var to = timeoutSecs || 30;

  var code = [
    '(async function(){',
    '  var url=' + JSON.stringify(u) + ';',
    '  var method=' + JSON.stringify(m) + ';',
    '  var headers=' + JSON.stringify(h) + ';',
    '  var body=' + JSON.stringify(b) + ';',
    '  var ctrl = new AbortController();',
    '  var t = setTimeout(function(){ try{ ctrl.abort(); }catch(e){} }, ' + (to * 1000) + ');',
    '  try {',
    '    var init = { method: method, headers: headers, signal: ctrl.signal };',
    '    if (method !== "GET" && method !== "HEAD") init.body = body;',
    '    var resp = await fetch(url, init);',
    '    return await resp.text();',
    '  } finally {',
    '    try{ clearTimeout(t); }catch(e){}',
    '  }',
    '})()',
  ].join('\n');

  // 以主站首页作为上下文更稳(同源、Cookie/UA/指纹更像浏览器)
  return legado.browser.run(BASE + '/', code, { visible: false, waitUntil: 'load' });
}

// ─── LZ-String Base64 解压 ─────────────────────────────────────────────

/**
 * LZ-String decompressFromBase64 的 ES5 实现(基于官方算法改写为单函数)。
 * 3A小说 API 返回的数据通常为 LZ-String Base64 压缩结果。
 */
function decompressFromBase64(input) {
  if (input === null || input === undefined) return '';
  if (input === '') return null;

  var keyStrBase64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
  function getBaseValue(alphabet, character) {
    return alphabet.indexOf(character);
  }

  function _decompress(length, resetValue, getNextValue) {
    var dictionary = [];
    var next;
    var enlargeIn = 4;
    var dictSize = 4;
    var numBits = 3;
    var entry = '';
    var result = [];
    var i;
    var w;
    var bits, resb, maxpower, power;
    var c;
    var data = { val: getNextValue(0), position: resetValue, index: 1 };

    for (i = 0; i < 3; i += 1) {
      dictionary[i] = i;
    }

    bits = 0;
    maxpower = Math.pow(2, 2);
    power = 1;
    while (power !== maxpower) {
      resb = data.val & data.position;
      data.position >>= 1;
      if (data.position === 0) {
        data.position = resetValue;
        data.val = getNextValue(data.index++);
      }
      bits |= (resb > 0 ? 1 : 0) * power;
      power <<= 1;
    }

    switch (bits) {
      case 0:
        bits = 0;
        maxpower = Math.pow(2, 8);
        power = 1;
        while (power !== maxpower) {
          resb = data.val & data.position;
          data.position >>= 1;
          if (data.position === 0) {
            data.position = resetValue;
            data.val = getNextValue(data.index++);
          }
          bits |= (resb > 0 ? 1 : 0) * power;
          power <<= 1;
        }
        c = String.fromCharCode(bits);
        break;
      case 1:
        bits = 0;
        maxpower = Math.pow(2, 16);
        power = 1;
        while (power !== maxpower) {
          resb = data.val & data.position;
          data.position >>= 1;
          if (data.position === 0) {
            data.position = resetValue;
            data.val = getNextValue(data.index++);
          }
          bits |= (resb > 0 ? 1 : 0) * power;
          power <<= 1;
        }
        c = String.fromCharCode(bits);
        break;
      case 2:
        return '';
      default:
        return '';
    }

    dictionary[3] = c;
    w = c;
    result.push(c);

    while (true) {
      if (data.index > length) {
        return '';
      }

      bits = 0;
      maxpower = Math.pow(2, numBits);
      power = 1;
      while (power !== maxpower) {
        resb = data.val & data.position;
        data.position >>= 1;
        if (data.position === 0) {
          data.position = resetValue;
          data.val = getNextValue(data.index++);
        }
        bits |= (resb > 0 ? 1 : 0) * power;
        power <<= 1;
      }

      switch (c = bits) {
        case 0:
          bits = 0;
          maxpower = Math.pow(2, 8);
          power = 1;
          while (power !== maxpower) {
            resb = data.val & data.position;
            data.position >>= 1;
            if (data.position === 0) {
              data.position = resetValue;
              data.val = getNextValue(data.index++);
            }
            bits |= (resb > 0 ? 1 : 0) * power;
            power <<= 1;
          }

          dictionary[dictSize++] = String.fromCharCode(bits);
          c = dictSize - 1;
          enlargeIn--;
          break;
        case 1:
          bits = 0;
          maxpower = Math.pow(2, 16);
          power = 1;
          while (power !== maxpower) {
            resb = data.val & data.position;
            data.position >>= 1;
            if (data.position === 0) {
              data.position = resetValue;
              data.val = getNextValue(data.index++);
            }
            bits |= (resb > 0 ? 1 : 0) * power;
            power <<= 1;
          }

          dictionary[dictSize++] = String.fromCharCode(bits);
          c = dictSize - 1;
          enlargeIn--;
          break;
        case 2:
          return result.join('');
      }

      if (enlargeIn === 0) {
        enlargeIn = Math.pow(2, numBits);
        numBits++;
      }

      if (dictionary[c]) {
        entry = dictionary[c];
      } else {
        if (c === dictSize) {
          entry = w + w.charAt(0);
        } else {
          return '';
        }
      }
      result.push(entry);

      // Add w+entry[0] to the dictionary.
      dictionary[dictSize++] = w + entry.charAt(0);
      enlargeIn--;

      w = entry;

      if (enlargeIn === 0) {
        enlargeIn = Math.pow(2, numBits);
        numBits++;
      }
    }
  }

  return _decompress(input.length, 32, function (index) {
    return getBaseValue(keyStrBase64, input.charAt(index));
  });
}

/**
 * 解析 API 响应:先 LZ 解压,再 JSON.parse
 */
function parseApiResponse(raw) {
  if (!raw) return null;
  var text = ('' + raw).replace(/^\s+|\s+$/g, '');
  if (!text) return null;

  // 优先尝试站点默认的 LZ-String Base64 压缩格式
  try {
    var json = decompressFromBase64(text);
    if (json) return JSON.parse(json);
  } catch (e1) {}

  // 兼容接口直接返回 JSON 文本(未压缩)
  try {
    return JSON.parse(text);
  } catch (e2) {}

  legado.log('[parseApiResponse] 解析失败,返回 null');
  return null;
}

// ─── 工具 ────────────────────────────────────────────────────────────────

function normalizeUrl(u) {
  if (!u) return '';
  var s = ('' + u).replace(/^\s+|\s+$/g, '');
  if (!s) return '';
  // 兼容协议相对 //cdn.xxx/...
  if (s.indexOf('//') === 0) return 'https:' + s;
  // 已是绝对地址
  if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s;
  // 兼容相对路径 /xxx/yyy.jpg
  if (s.charAt(0) === '/') return BASE + s;
  // 兼容相对路径 xxx/yyy.jpg(无前导 /)
  return BASE + '/' + s;
}

function buildCoverUrl(tid, siteid) {
  if (!tid) return '';
  var tidNum = parseInt(tid, 10);
  var folder = tidNum % 100;
  // 以网页实际路径规则为准:/bookimg/{siteid}/{tid%100}/{tid}.jpg
  return normalizeUrl('/bookimg/' + siteid + '/' + folder + '/' + tid + '.jpg');
}

function isPlaceholderCover(url) {
  if (!url) return true;
  var u = '' + url;
  return u.indexOf('/static/img/img.jpg') !== -1;
}

function pickCoverUrl(imgurl, tid, siteid) {
  // 优先相信接口返回的站内封面路径(/bookimg/...);其次再用规则拼
  var u = (imgurl || '').toString();
  if (u) {
    // 站内封面:更稳定、无外链防盗链
    if (u.indexOf('/bookimg/') !== -1 || u.indexOf('aaawz.cc') !== -1 || u.indexOf('//') === 0 || u.charAt(0) === '/') {
      var c1 = normalizeUrl(u);
      if (c1 && !isPlaceholderCover(c1)) return c1;
    }
  }
  var c2 = buildCoverUrl(tid, siteid);
  if (c2 && !isPlaceholderCover(c2)) return c2;
  // 最后再尝试把 imgurl 当作普通 URL 规范化(可能是相对路径或完整外链)
  var c3 = normalizeUrl(u);
  if (c3 && !isPlaceholderCover(c3)) return c3;
  return '';
}

async function extractCoverFromWeb(tid, siteid) {
  if (!tid || !siteid) return '';
  var pageUrl = BASE + '/#/book/' + tid + '/' + siteid;
  var code = [
    '(async function(){',
    '  function sleep(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }',
    '  // 等 SPA 渲染',
    '  for (var i=0;i<20;i++){',
    // 该站封面 img 通常具备 onerror 回退到 /static/img/img.jpg 的特征
    '    var img = document.querySelector(\"img[src*=\\\"/bookimg/\\\"][onerror*=\\\"/static/img/img.jpg\\\"]\")',
    '          || document.querySelector(\"img[src*=\\\"/bookimg/\\\"]\")',
    '          || document.querySelector(\"img[onerror*=\\\"/static/img/img.jpg\\\"][src]\")',
    '          || document.querySelector(\"img[src]\");',
    '    if (img && img.getAttribute && img.getAttribute(\"src\")) {',
    '      return img.getAttribute(\"src\");',
    '    }',
    '    await sleep(250);',
    '  }',
    '  return \"\";',
    '})()',
  ].join('\\n');
  try {
    var src = await legado.browser.run(pageUrl, code, { visible: false, waitUntil: 'networkidle' });
    var url = normalizeUrl(src);
    if (url && !isPlaceholderCover(url)) return url;
  } catch (e) {
    legado.log('[extractCoverFromWeb] failed tid=' + tid + ' siteid=' + siteid + ' err=' + e);
  }
  return '';
}

function normalizeTextKey(s) {
  if (!s) return '';
  // 去掉空白与常见分隔符,降低“同名不同写法”导致的重复
  return ('' + s)
    .replace(/<\/?em>/g, '')
    .replace(/\s+/g, '')
    .replace(/[·•・\-—_]/g, '')
    .toLowerCase();
}

function pickBetterBook(a, b) {
  // 返回更“像主源”的那条(尽量保留章节/封面更完整的数据)
  function score(x) {
    if (!x) return 0;
    var s = 0;
    if (x.lastChapter) s += 3;
    if (x.coverUrl) s += 2;
    if (x.author) s += 1;
    if (x.name) s += 1;
    return s;
  }
  return score(b) > score(a) ? b : a;
}

// ─── 搜索 ─────────────────────────────────────────────────────────────────

async function search(keyword, page) {
  legado.log('[search] keyword=' + keyword + ' page=' + page);

  var url = BASE + '/api-search';
  var body = 'keyword=' + encodeURIComponent(keyword) + '&page=' + (page || 1) + '&size=10';
  var raw = await httpPost(url, body, {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Accept': 'application/json, text/plain, */*',
    'Origin': BASE,
    'Referer': BASE + '/',
  }, 60);

  var resp = parseApiResponse(raw);
  if (!resp || !resp.data || !resp.data.books) return [];

  var booksArr = resp.data.books;
  var map = {};
  var books = [];
  for (var i = 0; i < booksArr.length; i++) {
    var b = booksArr[i];
    var name = (b.articlename || '').replace(/<\/?em>/g, '');
    var author = (b.author || '').replace(/<\/?em>/g, '');
    var item = {
      name: name,
      author: author,
      bookUrl: BASE + '/api-info-' + b.tid + '-' + b.siteid,
      coverUrl: pickCoverUrl(b.imgurl, b.tid, b.siteid),
      lastChapter: b.lastchapter || '',
      kind: '',
    };

    // 同一本书在不同站点源(siteid)会重复出现:用 “书名+作者” 去重
    var key = normalizeTextKey(name) + '|' + normalizeTextKey(author);
    if (!key || key === '|') {
      books.push(item);
      continue;
    }

    if (!map[key]) {
      map[key] = item;
      books.push(item);
    } else {
      var better = pickBetterBook(map[key], item);
      // 如更优则替换(保持 books 数组中的同一个引用更新)
      if (better !== map[key]) {
        map[key].name = better.name;
        map[key].author = better.author;
        map[key].bookUrl = better.bookUrl;
        map[key].coverUrl = better.coverUrl;
        map[key].lastChapter = better.lastChapter;
        map[key].kind = better.kind;
      }
    }
  }

  legado.log('[search] found=' + books.length);
  return books;
}

// ─── 书籍详情 ─────────────────────────────────────────────────────────────

async function bookInfo(bookUrl) {
  legado.log('[bookInfo] url=' + bookUrl);

  var raw = await httpGet(bookUrl, {
    'Accept': 'application/json, text/plain, */*',
    'Origin': BASE,
    'Referer': BASE + '/',
  }, 60);

  var resp = parseApiResponse(raw);
  if (!resp) return { name: '', author: '', coverUrl: '', intro: '', lastChapter: '', kind: '', tocUrl: bookUrl };

  // 从 URL 提取 tid 和 siteid
  var urlMatch = bookUrl.match(/api-info-(\d+)-(\d+)/);
  var tid = urlMatch ? urlMatch[1] : (resp.tid || '');
  var siteid = urlMatch ? urlMatch[2] : (resp.siteid || '');

  var coverUrl = pickCoverUrl(resp.imgurl, tid, siteid);
  if (!coverUrl) {
    // 详情页兜底:从网页 DOM 抓真实封面(部分书籍 API 字段缺失/不一致)
    coverUrl = await extractCoverFromWeb(tid, siteid);
  }

  return {
    name: resp.articlename || '',
    author: resp.author || '',
    coverUrl: coverUrl,
    intro: resp.intro || '',
    lastChapter: resp.lastchapter || '',
    kind: '',
    tocUrl: BASE + '/api-chapterlist-' + tid + '-' + siteid,
  };
}

// ─── 章节列表 ─────────────────────────────────────────────────────────────

async function chapterList(tocUrl) {
  legado.log('[chapterList] url=' + tocUrl);

  // 兼容:部分端(鸿蒙)可能会把 bookUrl(api-info-*)直接传给 chapterList
  // 这里自动转换为真正的目录接口 api-chapterlist-<tid>-<siteid>
  var mInfo = (tocUrl || '').match(/api-info-(\d+)-(\d+)/);
  if (mInfo) {
    tocUrl = BASE + '/api-chapterlist-' + mInfo[1] + '-' + mInfo[2];
    legado.log('[chapterList] redirect api-info -> ' + tocUrl);
  }

  var raw = await httpGet(tocUrl, {
    'Accept': 'application/json, text/plain, */*',
    'Origin': BASE,
    'Referer': BASE + '/',
  }, 60);

  var resp = parseApiResponse(raw);
  if (!resp) return [];

  // 再兜底一次:如果拿到的是“详情对象”(仍然是 api-info 的返回),则根据 tid/siteid 再去拉目录
  if (!Array.isArray(resp)) {
    if (resp.tid && resp.siteid) {
      var toc2 = BASE + '/api-chapterlist-' + resp.tid + '-' + resp.siteid;
      legado.log('[chapterList] resp is object, refetch toc: ' + toc2);
      var raw2 = await httpGet(toc2, { 'Origin': BASE, 'Referer': BASE + '/' }, 60);
      resp = parseApiResponse(raw2);
    }
  }
  if (!Array.isArray(resp)) return [];

  // 从 URL 提取 tid 和 siteid 用于构造章节内容 URL
  var urlMatch = tocUrl.match(/api-chapterlist-(\d+)-(\d+)/);
  var tid = urlMatch ? urlMatch[1] : '';
  var siteid = urlMatch ? urlMatch[2] : '';

  var chapters = [];
  for (var i = 0; i < resp.length; i++) {
    var ch = resp[i];
    chapters.push({
      name: ch.title || '',
      url: BASE + '/api-chapter-' + tid + '-' + siteid + '-' + ch.cid,
    });
  }

  legado.log('[chapterList] total=' + chapters.length);
  return chapters;
}

// ─── 正文 ─────────────────────────────────────────────────────────────────

async function chapterContent(chapterUrl) {
  legado.log('[content] url=' + chapterUrl);

  var raw = (await httpGet(chapterUrl, {
    'Origin': BASE,
    'Referer': BASE + '/',
  }, 60)).replace(/\s/g, '');

  var content = '';

  if (chapterUrl.indexOf('-chapter-') !== -1) {
    // 正文接口协议:响应为 base64( IV[0..16] || AES-CBC-PKCS7密文 )
    // 1. 用 base64ByteSlice 从二进制流中分离 IV 和密文(避免 UTF-8 截断)
    // 2. 用 aesDecryptB64Iv 以 base64 格式传入 IV 做标准 AES-CBC 解密
    // 3. 解密结果再做 LZ-String Base64 解压得到最终正文
    try {
      var ivB64     = legado.base64ByteSlice(raw, 0, 16);
      var cipherB64 = legado.base64ByteSlice(raw, 16);
      var plaintext = await legado.aesDecryptB64Iv(cipherB64, AES_KEY, ivB64, 'CBC');
      content = decompressFromBase64(plaintext.replace(/\s/g, ''));
      if (!content) throw new Error('LZ 解压结果为空');
    } catch (e) {
      legado.log('[content] 正文解密失败: ' + e);
      content = '';
    }
  } else {
    // 非章节接口(理论上不会走到这里):直接 LZ 解压
    try {
      content = decompressFromBase64(raw) || '';
    } catch (e) {
      legado.log('[content] LZ 解压失败: ' + e);
      content = '';
    }
  }

  if (!content) {
    // 兜底:返回可见错误,避免阅读端一直“排版中”
    return '(正文获取失败)\n\n请稍后重试,或切换网络/节点。';
  }

  // 清理 HTML 标签
  content = content.replace(/<br\s*\/?>/gi, '\n');
  content = content.replace(/<\/?p[^>]*>/gi, '\n');
  content = content.replace(/<[^>]+>/g, '');
  content = content.replace(/&nbsp;/g, ' ');

  // 按段落拆分并过滤空行
  var lines = content.split('\n');
  var paragraphs = [];
  for (var i = 0; i < lines.length; i++) {
    var text = lines[i].replace(/^\s+|\s+$/g, '');
    if (text) {
      paragraphs.push(text);
    }
  }

  return paragraphs.join('\n\n');
}

// ─── 发现页 ──────────────────────────────────────────────────────────────
// 接口:/api-list-{page},返回按热度排序的书籍数组,无分类过滤。

async function explore(page, category) {
  // category === 'GETALL':返回分类名列表
  if (!category || category === 'GETALL') {
    return ['推荐'];
  }

  // category === '推荐':返回热门排行书籍列表(无其他分类)
  var p = page || 1;
  var url = BASE + '/api-list-' + p;
  legado.log('[explore] category=' + category + ' page=' + p + ' url=' + url);

  var raw = await httpGet(url, {
    'Accept': 'application/json, text/plain, */*',
    'Origin': BASE,
    'Referer': BASE + '/',
  }, 60);

  var resp = parseApiResponse(raw);
  if (!Array.isArray(resp)) return [];

  var books = [];
  for (var i = 0; i < resp.length; i++) {
    var b = resp[i];
    books.push({
      name: b.articlename || '',
      author: b.author || '',
      bookUrl: BASE + '/api-info-' + b.tid + '-' + b.siteid,
      coverUrl: pickCoverUrl(b.imgurl, b.tid, b.siteid),
      lastChapter: b.lastchapter || '',
      kind: '',
    });
  }

  legado.log('[explore] found=' + books.length);
  return books;
}
广告