东方小说

https://www.cndfzw.com

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

小说 东方小说
东方小说阅读网(cndfzw.com),小说发现、目录与正文解析。
二维码导入(APP尚未完成该功能)
// @uuid        019e120f-9e35-7f1c-a5bd-b820db5a3097
// @name        东方小说
// @version     1.0.5
// @author      Ethereal
// @url         https://www.cndfzw.com
// @logo        https://www.cndfzw.com/favicon.ico
// @enabled     true
// @description 东方小说阅读网(cndfzw.com),小说发现、目录与正文解析。
// @type        novel
// @tags        东方小说

var BASE = 'https://www.cndfzw.com';

var EXPLORE_CAT = {
  玄幻小说: '1',
  修真小说: '2',
  都市小说: '3',
  穿越小说: '4',
  网游小说: '5',
  言情小说: '6',
  科幻小说: '7'
};

var CHAPTER_MAX_PAGES = 40;

/*
  搜索:http 链 GET 建会话 → GET 带 q 取 sign → POST /api/search;失败或「太频繁」再走 fetch 取 SID + POST。
  common.js:有 Cookie 时 bgt 去尾一位;无 sign 不调 API。接口常空时用备用:/top/*、/quanben/、class/all 与分类。
  支持粘贴本站 /novel/分类/书号/ 或 /novel/书号.html 直达。
  速度与限流:短间隔 + 全局 POST 间隔;仍遇「太频繁」可调大 SEARCH_MIN_GAP_BETWEEN_POST_MS。
  全本/书库列表会轮换,少数书名在 SEARCH_EXACT_TITLE_DIR 中写死正文书路径,避免完全依赖分页扫到。
  备用扫描:榜单一次并发;书库/全本/分类列表按 SEARCH_FALLBACK_CONCURRENCY 分批 Promise.all 并发 GET。
*/
/** GET 会话页 / token 页后的短间隔(毫秒),兼顾 Cookie 写入与站点限流。 */
var SEARCH_DELAY_AFTER_GET_MS = 320;
var SEARCH_FALLBACK_MAX_PAGES = 20;
/** 全本列表 /quanben/ 单独页上限(与 class/all 分页节奏相同)。 */
var SEARCH_FALLBACK_QUANBEN_MAX_PAGES = 36;
var SEARCH_FALLBACK_MAX_RESULTS = 30;
var SEARCH_FALLBACK_REQUEST_BUDGET = 96;
/** 备用列表单批并发 GET 数(过大易触发站点限流)。 */
var SEARCH_FALLBACK_CONCURRENCY = 8;
/** true:API 无命中后再扫 class/1~7(更慢,覆盖面更大)。 */
var SEARCH_FALLBACK_SCAN_CATEGORIES = true;
/** 榜单/排行(表格 tr),热门书常在此而不在书库前几页。 */
var SEARCH_FALLBACK_TOP_TABLE_PATHS = [
  '/top/lastupdate/',
  '/top/monthvisit/',
  '/top/1.html'
];
var SEARCH_RETRY_GAP_MS = 5500;
var SEARCH_MAX_ATTEMPTS = 2;
var SEARCH_MIN_GAP_BETWEEN_POST_MS = 2200;
var SEARCH_CONFIG_NS = 'cndfzw';
var SEARCH_CONFIG_LAST_POST = 'lastSearchPostMs';
var SEARCH_BROWSER_UA =
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';

/** 精确书名 → 正文目录 URL(须带分类段 /novel/cid/bid/)。列表里未必再出现该书时仍可搜索。 */
var SEARCH_EXACT_TITLE_DIR = {
  '\u6597\u7834\u82cd\u7a79': '/novel/8/8884/'
};

var SEARCH_KEYS = [
  'vw',
  'abw',
  'ru',
  'jrt',
  'van',
  'fw',
  'cwl',
  'gpr',
  'uyoo',
  'tz',
  'euu',
  'tsn',
  'eju',
  'um',
  'fp',
  'dvm',
  'jpk',
  'deblkx',
  'ht',
  'azy',
  'sna',
  'wqx',
  'fpp',
  'rup',
  'jwj',
  'bgt',
  'qp',
  'yf',
  'cw',
  'wq',
  'sign'
];

function absUrl(href) {
  if (!href) {
    return '';
  }
  if (href.indexOf('http://') === 0 || href.indexOf('https://') === 0) {
    return href;
  }
  if (href.indexOf('//') === 0) {
    return 'https:' + href;
  }
  if (href.charAt(0) === '/') {
    return BASE + href;
  }
  return BASE + '/' + href;
}

function sleepMs(ms) {
  return new Promise(function (resolve) {
    if (typeof setTimeout === 'function') {
      setTimeout(resolve, ms);
    } else {
      resolve();
    }
  });
}

function searchReadLastPostMs() {
  try {
    var s = legado.config.read(SEARCH_CONFIG_NS, SEARCH_CONFIG_LAST_POST);
    if (!s) {
      return 0;
    }
    var n = parseInt(s, 10);
    return isNaN(n) ? 0 : n;
  } catch (e0) {
    return 0;
  }
}

function searchWriteLastPostMs() {
  try {
    legado.config.write(SEARCH_CONFIG_NS, SEARCH_CONFIG_LAST_POST, String(Date.now()));
  } catch (e1) {}
}

async function searchWaitGlobalPostGap() {
  var last = searchReadLastPostMs();
  if (!last) {
    return;
  }
  var need = SEARCH_MIN_GAP_BETWEEN_POST_MS - (Date.now() - last);
  if (need > 0 && need < 28000) {
    legado.log('search global gap wait ' + need + 'ms');
    await sleepMs(need);
  }
}

function extractSidCookieFromFetchResponse(res) {
  try {
    var raw = '';
    if (res && res.headers && typeof res.headers.get === 'function') {
      raw = res.headers.get('set-cookie') || res.headers.get('Set-Cookie') || '';
    }
    if (!raw && res && res.headers && typeof res.headers.forEach === 'function') {
      res.headers.forEach(function (v, k) {
        if (String(k).toLowerCase() === 'set-cookie') {
          raw = raw ? raw + '; ' + v : v;
        }
      });
    }
    var m = String(raw).match(/SID=([^;\s,]+)/);
    return m ? 'SID=' + m[1] : '';
  } catch (e3) {
    return '';
  }
}

function parseInlineAnti(html) {
  var out = {};
  var re = /var\s+(\w+)\s*=\s*"([^"]*)"/g;
  var m;
  while (true) {
    m = re.exec(html);
    if (!m) {
      break;
    }
    out[m[1]] = m[2];
  }
  return out;
}

function metaContent(doc, prop) {
  var el = legado.dom.select(doc, 'meta[property="' + prop + '"]');
  if (!el) {
    return '';
  }
  return legado.dom.attr(el, 'content') || '';
}

function isChapterHref(href) {
  return /^\/novel\/\d+\/\d+\/\d+\.html$/.test(href);
}

function chapterIdFromUrl(url) {
  var m = String(url).match(/\/(\d+)\.html(?:\?|$|#)/);
  if (!m) {
    return 0;
  }
  return parseInt(m[1], 10) || 0;
}

function chapterUrlBase(url) {
  var s = String(url).split('#')[0];
  var q = s.indexOf('?');
  return q >= 0 ? s.substring(0, q) : s;
}

function extractContentParagraphs(doc) {
  var ps = legado.dom.selectAll(doc, '#content p');
  var lines = [];
  var i;
  if (ps.length > 0) {
    for (i = 0; i < ps.length; i++) {
      var line = legado.dom.text(ps[i]).trim();
      if (!line) {
        continue;
      }
      if (line.indexOf('东方小说网欢迎您') >= 0) {
        continue;
      }
      if (line.indexOf('强烈推荐:') === 0 || line.indexOf('强烈推荐:') === 0) {
        continue;
      }
      lines.push(line);
    }
    return lines.join('\n\n');
  }
  var box = legado.dom.select(doc, '#content');
  if (!box) {
    return '';
  }
  legado.dom.remove(box, 'script');
  return legado.dom.text(box).trim();
}

function findNextPageHref(doc, baseUrl) {
  var bid = chapterIdFromUrl(baseUrl);
  if (!bid) {
    return '';
  }
  var links = legado.dom.selectAll(doc, 'a');
  for (var j = 0; j < links.length; j++) {
    var label = legado.dom.text(links[j]).replace(/\s+/g, '');
    if (label.indexOf('下一页') < 0 && label !== '下页') {
      continue;
    }
    var href = legado.dom.attr(links[j], 'href') || '';
    if (!href || href.indexOf('javascript:') === 0) {
      continue;
    }
    var abs = absUrl(href);
    if (chapterIdFromUrl(abs) !== bid) {
      continue;
    }
    return abs;
  }
  return '';
}

function stripHtmlTags(s) {
  return String(s).replace(/<[^>]+>/g, '').trim();
}

/** 分类/书库列表 dd 内「作者:…」与「更新时间:…」常无换行,作者必须在「更新\s*时间」或「作品分类」前截断。 */
function extractAuthorFromListPText(pText) {
  if (!pText) {
    return '';
  }
  var s = String(pText).replace(/\u00a0/g, ' ').trim();
  var am = s.match(/作者[::]\s*/);
  if (!am || am.index == null) {
    return '';
  }
  var start = am.index + am[0].length;
  var rest = s.substring(start);
  var endRel = rest.search(/更新\s*时间|作品分类|作品状态/);
  var seg = endRel >= 0 ? rest.substring(0, endRel) : rest;
  return seg
    .replace(/\s+/g, ' ')
    .replace(/^[|·\s]+|[|·\s]+$/g, '')
    .trim();
}

function pickStr(row, keys) {
  for (var i = 0; i < keys.length; i++) {
    var v = row[keys[i]];
    if (v != null && String(v).trim() !== '') {
      return String(v).trim();
    }
  }
  return '';
}

function coverFromNovelListUrl(anyUrl) {
  if (!anyUrl) {
    return '';
  }
  var s = String(anyUrl);
  var m = s.match(/\/novel\/(\d+)\/(\d+)(?:\/|\.html|\?|#|$)/);
  if (!m) {
    return '';
  }
  return BASE + '/img/' + m[1] + '/' + m[2] + '.jpg';
}

/** 粘贴 https://www.cndfzw.com/novel/8/8884/ 或 /novel/8884.html 等本站阅读/介绍地址。 */
function parseCndfzwNovelUserInput(raw) {
  var t = String(raw || '').trim();
  if (!t) {
    return null;
  }
  t = t.split('#')[0].split('?')[0].trim();
  var m = t.match(/^(?:https?:\/\/)?(?:www\.|m\.)?cndfzw\.com(\/novel\/\d+\/\d+)\/?$/i);
  if (m) {
    var path = m[1];
    if (path.charAt(path.length - 1) !== '/') {
      path += '/';
    }
    return { kind: 'index', fetchUrl: BASE + path, bookUrl: BASE + path };
  }
  m = t.match(/^(?:https?:\/\/)?(?:www\.|m\.)?cndfzw\.com(\/novel\/\d+\.html)$/i);
  if (m) {
    return { kind: 'intro', fetchUrl: BASE + m[1], bookUrl: '' };
  }
  if (/^\/novel\/\d+\/\d+\/?$/i.test(t)) {
    var norm = t.replace(/\/?$/, '/');
    return { kind: 'index', fetchUrl: BASE + norm, bookUrl: BASE + norm };
  }
  if (/^\/novel\/\d+\.html$/i.test(t)) {
    return { kind: 'intro', fetchUrl: BASE + t, bookUrl: '' };
  }
  return null;
}

function extractReadDirFromIntroDoc(doc) {
  var links = legado.dom.selectAll(doc, 'a[href]');
  var i;
  for (i = 0; i < links.length; i++) {
    var href = legado.dom.attr(links[i], 'href') || '';
    if (/^\/novel\/\d+\/\d+\/?$/.test(href)) {
      return absUrl(href.replace(/\/?$/, '') + '/');
    }
  }
  return '';
}

function parseBookTitleAuthorFromNovelIndexDoc(doc) {
  var name = '';
  var h1 = legado.dom.select(doc, '#bookinfo h1');
  if (!h1) {
    h1 = legado.dom.select(doc, '#title h1');
  }
  if (h1) {
    name = legado.dom.text(h1).trim();
  }
  if (!name) {
    var h2a = legado.dom.select(doc, '#title h2 a');
    if (h2a) {
      name = legado.dom.text(h2a).trim();
    }
  }
  var author = '';
  var authEl = legado.dom.select(doc, '#bookinfo address.author');
  if (!authEl) {
    authEl = legado.dom.select(doc, 'address.author');
  }
  if (authEl) {
    var at = legado.dom.text(authEl).trim();
    var am = at.match(/作者[::\s]*([^\s\r\n]+)/);
    author = am ? am[1].trim() : at.replace(/^作者[::]\s*/, '').trim();
  }
  return { name: name, author: author };
}

function latestChapterFromNovelIndexDoc(doc) {
  var dds = legado.dom.selectAll(doc, 'dl.book dd');
  var di;
  for (di = 0; di < dds.length; di++) {
    if (legado.dom.text(dds[di]).indexOf('最新章节') >= 0) {
      var la = legado.dom.select(dds[di], 'a[href*=".html"]');
      if (la) {
        return legado.dom.text(la).trim();
      }
    }
  }
  return '';
}

function searchExactTitleBookUrl(keyword) {
  var k = String(keyword || '').trim();
  if (!k || !Object.prototype.hasOwnProperty.call(SEARCH_EXACT_TITLE_DIR, k)) {
    return '';
  }
  var path = SEARCH_EXACT_TITLE_DIR[k];
  if (!path || path.indexOf('/novel/') !== 0) {
    return '';
  }
  return BASE + path.replace(/\/?$/, '/');
}

async function searchFromPastedNovelUrl(keyword) {
  var p = parseCndfzwNovelUserInput(keyword);
  if (!p) {
    return [];
  }
  legado.log('search pasted novel url');
  var hdr = {
    Referer: BASE + '/',
    Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'User-Agent': SEARCH_BROWSER_UA
  };
  try {
    var bookUrl = '';
    var doc = null;
    if (p.kind === 'index') {
      bookUrl = p.bookUrl.replace(/\/?$/, '/');
      var htmlIdx = await legado.http.get(bookUrl, hdr);
      doc = legado.dom.parse(htmlIdx);
    } else {
      var htmlIntro = await legado.http.get(p.fetchUrl, hdr);
      var docIntro = legado.dom.parse(htmlIntro);
      bookUrl = extractReadDirFromIntroDoc(docIntro);
      legado.dom.free(docIntro);
      if (!bookUrl) {
        return [];
      }
      bookUrl = bookUrl.replace(/\/?$/, '/');
      if (bookUrl.indexOf('http') !== 0) {
        bookUrl = absUrl(bookUrl);
      }
      var htmlRead = await legado.http.get(bookUrl, hdr);
      doc = legado.dom.parse(htmlRead);
    }
    var meta = parseBookTitleAuthorFromNovelIndexDoc(doc);
    var lastChapter = latestChapterFromNovelIndexDoc(doc);
    legado.dom.free(doc);
    var name = meta.name || '(解析书名失败)';
    return [
      {
        name: name,
        author: meta.author || '',
        bookUrl: bookUrl,
        coverUrl: coverFromNovelListUrl(bookUrl),
        kind: '本站直达',
        lastChapter: lastChapter,
        latestChapter: lastChapter
      }
    ];
  } catch (ePaste) {
    legado.log('search pasted url fail');
    return [];
  }
}

async function explore(page, category) {
  legado.log('explore ' + category + ' p=' + page);
  if (category === 'GETALL') {
    return [
      '玄幻小说',
      '修真小说',
      '都市小说',
      '穿越小说',
      '网游小说',
      '言情小说',
      '科幻小说'
    ];
  }
  var cid = EXPLORE_CAT[category];
  if (!cid) {
    return [];
  }
  var url = BASE + '/class/' + cid + '/';
  if (page > 1) {
    url += '?p=' + page;
  }
  var html = await legado.http.get(url);
  var doc = legado.dom.parse(html);
  var items = [];
  var blocks = legado.dom.selectAll(doc, 'dl.eachitem');
  for (var i = 0; i < blocks.length; i++) {
    var cap = legado.dom.select(blocks[i], 'a.caption');
    if (!cap) {
      continue;
    }
    var name = legado.dom.text(cap).trim();
    var bookUrl = absUrl(legado.dom.attr(cap, 'href'));
    var img = legado.dom.select(blocks[i], 'dd.img img');
    var coverUrl = img ? absUrl(legado.dom.attr(img, 'src')) : '';
    var pEl = legado.dom.select(blocks[i], 'dd.text p');
    if (!pEl) {
      pEl = legado.dom.select(blocks[i], 'dd.text');
    }
    var pText = pEl ? legado.dom.text(pEl) : '';
    var author = extractAuthorFromListPText(pText);
    var lastChapter = '';
    var kind = category;
    var lc = legado.dom.select(blocks[i], 'dd.text p a[style*="5EB4D8"]');
    if (lc) {
      lastChapter = legado.dom.text(lc).trim();
    }
    items.push({
      name: name,
      author: author,
      bookUrl: bookUrl,
      coverUrl: coverUrl,
      kind: kind,
      lastChapter: lastChapter,
      latestChapter: lastChapter
    });
  }
  return items;
}

async function bookInfo(bookUrl) {
  legado.log('bookInfo ' + bookUrl);
  var html = await legado.http.get(bookUrl);
  var doc = legado.dom.parse(html);
  var name = metaContent(doc, 'og:novel:book_name');
  var author = metaContent(doc, 'og:novel:author');
  var coverUrl = metaContent(doc, 'og:image');
  var intro = metaContent(doc, 'og:description');
  var kind = metaContent(doc, 'og:novel:category');
  var lastChapter = metaContent(doc, 'og:novel:latest_chapter_name');
  if (!name) {
    var h1 = legado.dom.select(doc, '#bookinfo h1');
    name = h1 ? legado.dom.text(h1).trim() : '';
  }
  return {
    name: name,
    author: author,
    bookUrl: bookUrl,
    coverUrl: absUrl(coverUrl),
    intro: intro,
    kind: kind,
    lastChapter: lastChapter,
    latestChapter: lastChapter,
    tocUrl: bookUrl
  };
}

async function chapterList(tocUrl) {
  legado.log('chapterList ' + tocUrl);
  var html = await legado.http.get(tocUrl);
  var doc = legado.dom.parse(html);
  var links = legado.dom.selectAll(doc, 'dl.book dd a');
  var chapters = [];
  var seen = {};
  for (var i = 0; i < links.length; i++) {
    var href = legado.dom.attr(links[i], 'href') || '';
    if (!isChapterHref(href)) {
      continue;
    }
    var url = absUrl(href);
    if (seen[url]) {
      continue;
    }
    seen[url] = 1;
    chapters.push({
      name: legado.dom.text(links[i]).trim(),
      url: url
    });
  }
  chapters.sort(function (a, b) {
    return chapterIdFromUrl(a.url) - chapterIdFromUrl(b.url);
  });
  return chapters;
}

async function chapterContent(chapterUrl) {
  legado.log('chapterContent ' + chapterUrl);
  var base = chapterUrlBase(chapterUrl);
  var chunks = [];
  var seenNorm = {};
  var seenFetch = {};

  function mergeChunk(text) {
    var n = text.replace(/\s+/g, '');
    if (!n) {
      return false;
    }
    if (seenNorm[n]) {
      return false;
    }
    seenNorm[n] = 1;
    chunks.push(text);
    return true;
  }

  seenFetch[base] = 1;
  var html0 = await legado.http.get(base);
  var doc0 = legado.dom.parse(html0);
  legado.dom.remove(doc0, '#content script');
  var t0 = extractContentParagraphs(doc0);
  legado.dom.free(doc0);
  if (!mergeChunk(t0)) {
    return '';
  }

  var page;
  for (page = 2; page <= CHAPTER_MAX_PAGES; page++) {
    var pu = base + '?page=' + page;
    if (seenFetch[pu]) {
      break;
    }
    seenFetch[pu] = 1;
    var htmlP = await legado.http.get(pu);
    var docP = legado.dom.parse(htmlP);
    legado.dom.remove(docP, '#content script');
    var tP = extractContentParagraphs(docP);
    legado.dom.free(docP);
    if (!mergeChunk(tP)) {
      break;
    }
  }

  var docL = legado.dom.parse(html0);
  var walk = findNextPageHref(docL, base);
  legado.dom.free(docL);
  var guard = 0;
  while (walk && guard < CHAPTER_MAX_PAGES) {
    guard++;
    if (seenFetch[walk]) {
      break;
    }
    seenFetch[walk] = 1;
    var htmlW = await legado.http.get(walk);
    var docW = legado.dom.parse(htmlW);
    legado.dom.remove(docW, '#content script');
    var tW = extractContentParagraphs(docW);
    legado.dom.free(docW);
    if (!mergeChunk(tW)) {
      break;
    }
    var docW2 = legado.dom.parse(htmlW);
    walk = findNextPageHref(docW2, base);
    legado.dom.free(docW2);
  }

  return chunks.join('\n\n').trim();
}

function coerceSearchResultArray(obj) {
  var out = [];
  var k;
  for (k in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, k)) {
      out.push(obj[k]);
    }
  }
  return out;
}

/** 兼容 data.search 为 []、对象映射或其它字段名。code!==0 时返回 null。 */
function searchApiExtractRows(json) {
  if (!json || json.code !== 0) {
    return null;
  }
  var d = json.data;
  if (d == null) {
    return [];
  }
  if (Array.isArray(d)) {
    return d;
  }
  if (typeof d !== 'object') {
    return [];
  }
  var keys = ['search', 'list', 'books', 'rows', 'items'];
  var ki;
  for (ki = 0; ki < keys.length; ki++) {
    var key = keys[ki];
    if (!Object.prototype.hasOwnProperty.call(d, key)) {
      continue;
    }
    var raw = d[key];
    if (raw == null) {
      continue;
    }
    if (Array.isArray(raw)) {
      return raw;
    }
    if (typeof raw === 'object') {
      return coerceSearchResultArray(raw);
    }
  }
  return [];
}

function buildSearchFormBody(keyword, vars) {
  var parts = [];
  parts.push('q=' + encodeURIComponent(keyword));
  for (var i = 0; i < SEARCH_KEYS.length; i++) {
    var k = SEARCH_KEYS[i];
    var v = vars[k];
    if (v === undefined || v === null) {
      v = '';
    }
    parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
  }
  return parts.join('&');
}

/** 与 common.js search_before 一致:document.cookie 非空时去掉 bgt 最后一位(有 SID 等 Cookie 时与浏览器一致)。 */
function applySearchBeforeLikeBrowser(vars, hasSidCookie) {
  var o = {};
  var key;
  for (key in vars) {
    if (Object.prototype.hasOwnProperty.call(vars, key)) {
      o[key] = vars[key];
    }
  }
  if (hasSidCookie && o.bgt && String(o.bgt).length > 0) {
    var b = String(o.bgt);
    o.bgt = b.substring(0, b.length - 1);
  }
  return o;
}

function mapSearchApiRow(row) {
  var rawBook = pickStr(row, [
    'book_list_url',
    'bookListUrl',
    'book_url',
    'bookUrl',
    'list_url',
    'listUrl',
    'url'
  ]);
  var bookUrl = absUrl(rawBook);
  var name = stripHtmlTags(pickStr(row, ['title', 'book_name', 'bookName', 'name']));
  var rawCover = pickStr(row, [
    'cover',
    'cover_url',
    'coverUrl',
    'img',
    'imgurl',
    'img_url',
    'imgUrl',
    'book_img',
    'bookImg',
    'pic',
    'thumb',
    'image',
    'poster'
  ]);
  var coverGuess =
    coverFromNovelListUrl(bookUrl) || coverFromNovelListUrl(rawBook);
  var coverUrl = (rawCover ? absUrl(rawCover) : '') || coverGuess;
  var latest = pickStr(row, [
    'latest_chapter_name',
    'latestChapterName',
    'latest_chapter',
    'latestChapter',
    'lastchapter',
    'lastChapter'
  ]);
  var author = stripHtmlTags(
    pickStr(row, [
      'author',
      'author_name',
      'authorName',
      'writer',
      'pen_name',
      'penName',
      'book_author',
      'bookAuthor',
      'auth',
      'zuozhe'
    ])
  );
  if (!author && row && typeof row === 'object') {
    var ua = row.user_author || row.UserAuthor;
    if (ua != null && String(ua).trim()) {
      author = stripHtmlTags(String(ua));
    }
  }
  return {
    name: name,
    author: author,
    bookUrl: bookUrl,
    coverUrl: coverUrl,
    kind: row.cate_name || row.cateName || row.kind || '',
    lastChapter: latest,
    latestChapter: latest
  };
}

/** 从长到短尝试前缀,便于「斗破苍穹」无全书时再匹配「斗破」等同站衍生书。 */
function keywordFallbackVariants(raw) {
  var s = String(raw || '').trim();
  var list = [];
  var seen = {};
  var t = s;
  while (t.length >= 2) {
    if (!seen[t]) {
      seen[t] = 1;
      list.push(t);
    }
    if (t.length === 2) {
      break;
    }
    t = t.substring(0, t.length - 1);
  }
  return list;
}

function blockMatchesKeyword(block, kw) {
  var cap = legado.dom.select(block, 'a.caption');
  if (!cap) {
    return null;
  }
  var name = legado.dom.text(cap).trim();
  var pEl = legado.dom.select(block, 'dd.text p');
  if (!pEl) {
    pEl = legado.dom.select(block, 'dd.text');
  }
  var pText = pEl ? legado.dom.text(pEl) : '';
  var author = extractAuthorFromListPText(pText);
  if (name.indexOf(kw) >= 0) {
    return { name: name, author: author, cap: cap, pText: pText };
  }
  if (author && author.indexOf(kw) >= 0) {
    return { name: name, author: author, cap: cap, pText: pText };
  }
  return null;
}

function pushFallbackBlock(out, seen, block, hit) {
  var bookUrl = absUrl(legado.dom.attr(hit.cap, 'href'));
  if (!bookUrl || seen[bookUrl]) {
    return;
  }
  seen[bookUrl] = 1;
  var img = legado.dom.select(block, 'dd.img img');
  var coverUrl = img ? absUrl(legado.dom.attr(img, 'src')) : '';
  var lc = legado.dom.select(block, 'dd.text p a[style*="5EB4D8"]');
  var lastChapter = lc ? legado.dom.text(lc).trim() : '';
  var guessCover = coverUrl || coverFromNovelListUrl(bookUrl);
  out.push({
    name: hit.name,
    author: hit.author,
    bookUrl: bookUrl,
    coverUrl: guessCover,
    kind: '在线书库',
    lastChapter: lastChapter,
    latestChapter: lastChapter
  });
}

/** variants 已按从长到短;每条书目取最先命中的更长关键词,同一页只请求一次。 */
function blockMatchLongestVariant(block, variants) {
  var vi;
  for (vi = 0; vi < variants.length; vi++) {
    var hit = blockMatchesKeyword(block, variants[vi]);
    if (hit) {
      return hit;
    }
  }
  return null;
}

function isNovelBookIndexHref(href) {
  return /^\/novel\/\d+\/\d+\/?$/.test(String(href || ''));
}

function topTableRowAuthorGuess(tr) {
  var tds = legado.dom.selectAll(tr, 'td');
  if (!tds.length) {
    return '';
  }
  var ai;
  var bookIdx = -1;
  var lj;
  for (ai = 0; ai < tds.length; ai++) {
    var links = legado.dom.selectAll(tds[ai], 'a[href]');
    for (lj = 0; lj < links.length; lj++) {
      var href = legado.dom.attr(links[lj], 'href') || '';
      if (isNovelBookIndexHref(href)) {
        bookIdx = ai;
        break;
      }
    }
    if (bookIdx >= 0) {
      break;
    }
  }
  if (bookIdx >= 0) {
    for (ai = bookIdx + 1; ai < tds.length; ai++) {
      var subas = legado.dom.selectAll(tds[ai], 'a');
      var tx = legado.dom.text(tds[ai]).trim();
      if (!tx || tx.length > 24) {
        continue;
      }
      if (/^\[[^\]]+\]$/.test(tx)) {
        continue;
      }
      if (/^\d+$/.test(tx)) {
        continue;
      }
      if (/^\d{1,2}-\d{1,2}$/.test(tx)) {
        continue;
      }
      if (tx === '完结' || tx === '连载') {
        continue;
      }
      if (subas.length === 0) {
        return tx;
      }
      if (subas.length === 1 && tx.length <= 20) {
        return tx;
      }
    }
  }
  for (ai = tds.length - 1; ai >= 0; ai--) {
    var links2 = legado.dom.selectAll(tds[ai], 'a');
    if (links2.length) {
      continue;
    }
    var tx2 = legado.dom.text(tds[ai]).trim();
    if (!tx2 || tx2.length > 22) {
      continue;
    }
    if (/^\[[^\]]+\]$/.test(tx2)) {
      continue;
    }
    if (/^\d+$/.test(tx2)) {
      continue;
    }
    if (/^\d{1,2}-\d{1,2}$/.test(tx2)) {
      continue;
    }
    if (tx2 === '完结' || tx2 === '连载') {
      continue;
    }
    return tx2;
  }
  return '';
}

function tableRowPickBookAnchor(tr) {
  var as = legado.dom.selectAll(tr, 'a[href]');
  var i;
  for (i = 0; i < as.length; i++) {
    var href = legado.dom.attr(as[i], 'href') || '';
    if (!isNovelBookIndexHref(href)) {
      continue;
    }
    var name = legado.dom.text(as[i]).trim();
    if (!name) {
      continue;
    }
    return { el: as[i], href: href, name: name };
  }
  return null;
}

function tableRowMatchesKeyword(tr, kw) {
  var pick = tableRowPickBookAnchor(tr);
  if (!pick) {
    return null;
  }
  var author = topTableRowAuthorGuess(tr);
  if (pick.name.indexOf(kw) >= 0) {
    return { name: pick.name, author: author, cap: pick.el, pText: '', tr: tr };
  }
  if (author && author.indexOf(kw) >= 0) {
    return { name: pick.name, author: author, cap: pick.el, pText: '', tr: tr };
  }
  return null;
}

function tableRowMatchLongestVariant(tr, variants) {
  var vi;
  for (vi = 0; vi < variants.length; vi++) {
    var hit = tableRowMatchesKeyword(tr, variants[vi]);
    if (hit) {
      return hit;
    }
  }
  return null;
}

function tableRowChapterTitle(tr) {
  if (!tr) {
    return '';
  }
  var candidates = legado.dom.selectAll(tr, 'a[href]');
  var ci;
  for (ci = 0; ci < candidates.length; ci++) {
    var href = legado.dom.attr(candidates[ci], 'href') || '';
    if (/^\/novel\/\d+\/\d+\/\d+\.html$/.test(href)) {
      return legado.dom.text(candidates[ci]).trim();
    }
  }
  return '';
}

function pushFallbackTableRow(out, seen, hit) {
  var bookUrl = absUrl(legado.dom.attr(hit.cap, 'href'));
  if (!bookUrl || seen[bookUrl]) {
    return;
  }
  seen[bookUrl] = 1;
  var guessCover = coverFromNovelListUrl(bookUrl);
  var lastChapter = tableRowChapterTitle(hit.tr);
  out.push({
    name: hit.name,
    author: hit.author || '',
    bookUrl: bookUrl,
    coverUrl: guessCover,
    kind: '榜单/书库',
    lastChapter: lastChapter,
    latestChapter: lastChapter
  });
}

function searchFallbackConsumeTopTableHtml(html, variants, seen, out) {
  if (!html) {
    return;
  }
  var doc = legado.dom.parse(html);
  var rows = legado.dom.selectAll(doc, 'tr');
  var ri;
  for (ri = 0; ri < rows.length; ri++) {
    var hit = tableRowMatchLongestVariant(rows[ri], variants);
    if (!hit) {
      continue;
    }
    pushFallbackTableRow(out, seen, hit);
    if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
      break;
    }
  }
  legado.dom.free(doc);
}

function searchFallbackConsumeEachitemHtml(html, variants, seen, out) {
  if (!html) {
    return;
  }
  var doc = legado.dom.parse(html);
  var blocks = legado.dom.selectAll(doc, 'dl.eachitem');
  var bi;
  for (bi = 0; bi < blocks.length; bi++) {
    var hit = blockMatchLongestVariant(blocks[bi], variants);
    if (!hit) {
      continue;
    }
    pushFallbackBlock(out, seen, blocks[bi], hit);
    if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
      break;
    }
  }
  legado.dom.free(doc);
}

function searchHttpGetSafe(url) {
  return legado.http.get(url).catch(function () {
    return '';
  });
}

async function searchFallbackScanTopTablePaths(variants, seen, out, budgetRef) {
  var urls = [];
  var pi;
  for (pi = 0; pi < SEARCH_FALLBACK_TOP_TABLE_PATHS.length; pi++) {
    if (budgetRef.n <= 0 || out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
      break;
    }
    budgetRef.n--;
    urls.push(BASE + SEARCH_FALLBACK_TOP_TABLE_PATHS[pi]);
  }
  if (urls.length === 0) {
    return;
  }
  var htmls = await Promise.all(
    urls.map(function (u) {
      return searchHttpGetSafe(u);
    })
  );
  var hi;
  for (hi = 0; hi < htmls.length; hi++) {
    searchFallbackConsumeTopTableHtml(htmls[hi], variants, seen, out);
    if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
      break;
    }
  }
}

async function searchFallbackScanListPathMulti(listPath, variants, seen, out, budgetRef, maxPagesOverride) {
  var maxPages =
    typeof maxPagesOverride === 'number' && maxPagesOverride > 0
      ? maxPagesOverride
      : SEARCH_FALLBACK_MAX_PAGES;
  var page = 1;
  while (
    page <= maxPages &&
    budgetRef.n > 0 &&
    out.length < SEARCH_FALLBACK_MAX_RESULTS
  ) {
    var room = Math.min(SEARCH_FALLBACK_CONCURRENCY, budgetRef.n, maxPages - page + 1);
    if (room <= 0) {
      break;
    }
    var urls = [];
    var bi;
    for (bi = 0; bi < room; bi++) {
      var pg = page + bi;
      var url = BASE + listPath;
      if (pg > 1) {
        url += '?p=' + pg;
      }
      urls.push(url);
      budgetRef.n--;
    }
    var htmls = await Promise.all(
      urls.map(function (u) {
        return searchHttpGetSafe(u);
      })
    );
    var ui;
    for (ui = 0; ui < htmls.length; ui++) {
      searchFallbackConsumeEachitemHtml(htmls[ui], variants, seen, out);
      if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
        break;
      }
    }
    page += room;
  }
}

async function searchFallbackScanCategoriesConcurrent(variants, seen, out, budgetRef) {
  var cidList = ['1', '2', '3', '4', '5', '6', '7'];
  var maxPg = SEARCH_FALLBACK_MAX_PAGES;
  var pg;
  for (pg = 1; pg <= maxPg; pg++) {
    if (out.length >= SEARCH_FALLBACK_MAX_RESULTS || budgetRef.n <= 0) {
      break;
    }
    var urls = [];
    var ci;
    for (ci = 0; ci < cidList.length; ci++) {
      if (budgetRef.n <= 0 || out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
        break;
      }
      var u = BASE + '/class/' + cidList[ci] + '/';
      if (pg > 1) {
        u += '?p=' + pg;
      }
      urls.push(u);
      budgetRef.n--;
    }
    if (urls.length === 0) {
      break;
    }
    var htmls = await Promise.all(
      urls.map(function (u) {
        return searchHttpGetSafe(u);
      })
    );
    var ui;
    for (ui = 0; ui < htmls.length; ui++) {
      searchFallbackConsumeEachitemHtml(htmls[ui], variants, seen, out);
      if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
        break;
      }
    }
  }
}

async function searchFallbackLibraryByKeyword(keyword) {
  var variants = keywordFallbackVariants(keyword);
  if (variants.length === 0) {
    return [];
  }
  var out = [];
  var seen = {};
  var budgetRef = { n: SEARCH_FALLBACK_REQUEST_BUDGET };
  await searchFallbackScanTopTablePaths(variants, seen, out, budgetRef);
  if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
    return out;
  }
  await searchFallbackScanListPathMulti(
    '/quanben/',
    variants,
    seen,
    out,
    budgetRef,
    SEARCH_FALLBACK_QUANBEN_MAX_PAGES
  );
  if (out.length >= SEARCH_FALLBACK_MAX_RESULTS) {
    return out;
  }
  await searchFallbackScanListPathMulti(
    '/class/all.html',
    variants,
    seen,
    out,
    budgetRef
  );
  if (out.length > 0) {
    legado.log('search fallback ' + out.length);
    return out;
  }
  if (budgetRef.n <= 0 || !SEARCH_FALLBACK_SCAN_CATEGORIES) {
    return out;
  }
  await searchFallbackScanCategoriesConcurrent(variants, seen, out, budgetRef);
  if (out.length > 0) {
    legado.log('search fallback ' + out.length);
  }
  return out;
}

async function finalizeSearchRowsFromApi(arr, keyword) {
  var out = [];
  var j;
  for (j = 0; j < arr.length; j++) {
    out.push(mapSearchApiRow(arr[j]));
  }
  if (out.length === 0) {
    legado.log('search api empty, fallback');
    out = await searchFallbackLibraryByKeyword(keyword);
  }
  return out;
}

/** 同 legado.http 栈 GET→POST,Cookie 随 Set-Cookie 带到后续请求。 */
async function searchTryHttpPipeline(keyword, searchResultsUrl) {
  var hdrBase = {
    Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    Referer: BASE + '/',
    'User-Agent': SEARCH_BROWSER_UA
  };
  try {
    await legado.http.get(BASE + '/user/search.html', hdrBase);
  } catch (ePrim) {
    legado.log('search http prime skip');
  }
  await sleepMs(SEARCH_DELAY_AFTER_GET_MS);
  var htmlQ = '';
  try {
    htmlQ = await legado.http.get(searchResultsUrl, {
      Accept: hdrBase.Accept,
      'Accept-Language': hdrBase['Accept-Language'],
      Referer: BASE + '/user/search.html',
      'User-Agent': SEARCH_BROWSER_UA
    });
  } catch (ePage) {
    legado.log('search http q-page fail');
    return { ok: false, reason: 'httppage' };
  }
  var vars = parseInlineAnti(htmlQ);
  if (!vars.sign) {
    return { ok: false, reason: 'nosign' };
  }
  await sleepMs(SEARCH_DELAY_AFTER_GET_MS);
  await searchWaitGlobalPostGap();
  var hdrPost = {
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    Referer: searchResultsUrl,
    'X-Requested-With': 'XMLHttpRequest',
    Origin: BASE,
    Accept: 'application/json, text/plain, */*',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'User-Agent': SEARCH_BROWSER_UA
  };
  var varsPost = applySearchBeforeLikeBrowser(vars, true);
  var body = buildSearchFormBody(keyword, varsPost);
  var jsonText = '';
  try {
    jsonText = await legado.http.post(BASE + '/api/search', body, hdrPost);
  } catch (ePost) {
    legado.log('search http post fail');
    return { ok: false, reason: 'httppost' };
  }
  searchWriteLastPostMs();
  var json;
  try {
    json = JSON.parse(jsonText);
  } catch (eJson) {
    return { ok: false, reason: 'json' };
  }
  var msg = json && json.msg ? String(json.msg) : '';
  if (msg.indexOf('频繁') >= 0) {
    return { ok: false, reason: 'frequent' };
  }
  var extracted = searchApiExtractRows(json);
  if (extracted === null) {
    return { ok: false, reason: 'api', msg: msg };
  }
  return { ok: true, rows: extracted };
}

/** fetch 取 SID + http 拉 q 页 + fetch POST(仅作回退)。 */
async function searchTryFetchSidPipeline(keyword, searchResultsUrl) {
  var keysPageUrl = BASE + '/user/search.html';
  var attempt;
  for (attempt = 0; attempt < SEARCH_MAX_ATTEMPTS; attempt++) {
    if (attempt > 0) {
      legado.log('search fetch retry after rate limit, attempt ' + (attempt + 1));
      await sleepMs(SEARCH_RETRY_GAP_MS);
      await searchWaitGlobalPostGap();
    }
    var keysRes = await searchFetchKeysPage(keysPageUrl);
    var sidCookie = keysRes.sidCookie;
    var htmlQ = '';
    try {
      htmlQ = await searchFetchTokenHtmlHttp(searchResultsUrl, sidCookie);
    } catch (e4) {
      legado.log('search q-page http failed');
    }
    var vars = parseInlineAnti(htmlQ);
    if (!vars.sign) {
      return { ok: false };
    }
    if (!sidCookie) {
      legado.log('search missing SID (fetch Set-Cookie)');
    }
    await sleepMs(SEARCH_DELAY_AFTER_GET_MS);
    var varsPost = applySearchBeforeLikeBrowser(vars, !!sidCookie);
    var body = buildSearchFormBody(keyword, varsPost);
    var jsonText = await searchFetchPostApi(body, searchResultsUrl, sidCookie);
    searchWriteLastPostMs();
    var json;
    try {
      json = JSON.parse(jsonText);
    } catch (e1) {
      return { ok: false };
    }
    var extracted = searchApiExtractRows(json);
    if (extracted !== null) {
      return { ok: true, rows: extracted };
    }
    var msg = json && json.msg ? String(json.msg) : '';
    legado.log('search api fail ' + msg);
    if (msg.indexOf('频繁') < 0) {
      return { ok: false };
    }
  }
  return { ok: false };
}

/** fetch 短 URL:只取 SID(响应体可丢弃,绝不用于 POST 的 sign)。 */
async function searchFetchKeysPage(shortKeysUrl) {
  var hdr = {
    Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    Referer: BASE + '/',
    'User-Agent': SEARCH_BROWSER_UA
  };
  if (typeof fetch === 'function') {
    var res = await fetch(shortKeysUrl, {
      headers: hdr,
      credentials: 'include'
    });
    var html = await res.text();
    var sidCookie = extractSidCookieFromFetchResponse(res);
    return { html: html, sidCookie: sidCookie };
  }
  var htmlOnly = await legado.http.get(shortKeysUrl, hdr);
  return { html: htmlOnly, sidCookie: '' };
}

/** 带完整 q 的搜索结果页:Referer 指向搜索入口页更贴近浏览器。 */
async function searchFetchTokenHtmlHttp(searchResultsUrl, sidCookie) {
  var hdr = {
    Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    Referer: BASE + '/user/search.html',
    'User-Agent': SEARCH_BROWSER_UA
  };
  if (sidCookie) {
    hdr.Cookie = sidCookie;
  }
  return await legado.http.get(searchResultsUrl, hdr);
}

async function searchFetchPostApi(body, refererUrl, sidCookie) {
  var hdr = {
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    Referer: refererUrl,
    'X-Requested-With': 'XMLHttpRequest',
    Origin: BASE,
    Accept: 'application/json, text/plain, */*',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'User-Agent': SEARCH_BROWSER_UA
  };
  if (sidCookie) {
    hdr.Cookie = sidCookie;
  }
  if (typeof fetch === 'function') {
    var res = await fetch(BASE + '/api/search', {
      method: 'POST',
      headers: hdr,
      body: body,
      credentials: 'include'
    });
    return await res.text();
  }
  return await legado.http.post(BASE + '/api/search', body, hdr);
}

async function search(keyword, page) {
  legado.log('search ' + keyword + ' p=' + page);
  if (page > 1) {
    return [];
  }
  var pasted = await searchFromPastedNovelUrl(keyword);
  if (pasted.length > 0) {
    return pasted;
  }
  var knownBookUrl = searchExactTitleBookUrl(keyword);
  if (knownBookUrl) {
    var knownOut = await searchFromPastedNovelUrl(knownBookUrl);
    if (knownOut.length > 0) {
      return knownOut;
    }
  }
  await searchWaitGlobalPostGap();
  var searchResultsUrl =
    BASE + '/user/search.html?q=' + encodeURIComponent(keyword);
  var httpR = await searchTryHttpPipeline(keyword, searchResultsUrl);
  if (!httpR.ok) {
    if (httpR.reason === 'nosign') {
      return await searchFallbackLibraryByKeyword(keyword);
    }
    legado.log('search http path not ok (' + httpR.reason + '), try fetch-SID');
    await sleepMs(120);
    await searchWaitGlobalPostGap();
    var fetchR = await searchTryFetchSidPipeline(keyword, searchResultsUrl);
    if (fetchR.ok) {
      return await finalizeSearchRowsFromApi(fetchR.rows, keyword);
    }
    return await searchFallbackLibraryByKeyword(keyword);
  }
  return await finalizeSearchRowsFromApi(httpR.rows, keyword);
}

async function TEST(type) {
  if (type === '__list__') {
    return ['search', 'explore'];
  }
  if (type === 'search') {
    var r = await search('武道人仙', 1);
    if (!r || r.length < 1) {
      return { passed: false, message: '搜索无结果' };
    }
    return { passed: true, message: '搜索返回 ' + r.length + ' 条' };
  }
  if (type === 'explore') {
    var b = await explore(1, '玄幻小说');
    if (!b || b.length < 1) {
      return { passed: false, message: '发现页为空' };
    }
    return { passed: true, message: '发现页 ' + b.length + ' 条' };
  }
  return { passed: false, message: '未知: ' + type };
}
广告