×
¥
查看详情

函数定义 async function fetchProducts(endpoint, { pageSize = 50, maxPages = 5, headers = {}, signal, onProgress } = {})

  • 参数说明
    • endpoint: 字符串,产品分页接口的起始地址(如 /products 或完整URL)
    • pageSize: 每页大小,默认 50。若起始URL未包含该参数则自动添加
    • maxPages: 最大抓取页数,默认 5
    • headers: 追加到每次请求的请求头对象
    • signal: 可选的 AbortSignal,用于整体任务取消
    • onProgress: 可选回调函数,每页完成时报告进度。形如 onProgress({ page, url, received, total, fromCache, retries, status })

代码实现

// 简单的全局(进程内)ETag缓存,按URL键控
const ETagCache = new Map(); // url -> { etag: string, body: any }

/**
 * 解析 Retry-After,返回等待毫秒数。如果不存在则按指数退避生成。
 * attempt 从 1 开始计数(第一次重试的序号)
 */
function computeRetryDelayMs(headers, attempt, baseMs = 400) {
  // 优先使用服务端提供的 Retry-After
  const retryAfter = headers?.get?.('Retry-After');
  if (retryAfter) {
    // 可能是秒,或HTTP日期
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds) && seconds >= 0) {
      return Math.max(0, seconds * 1000);
    }
    const dateMs = Date.parse(retryAfter);
    if (!Number.isNaN(dateMs)) {
      return Math.max(0, dateMs - Date.now());
    }
  }
  // 指数退避 + 抖动:base * 2^(attempt-1) + [0, 250)
  const exp = baseMs * Math.pow(2, attempt - 1);
  const jitter = Math.floor(Math.random() * 250);
  return exp + jitter;
}

/** 支持可取消的 sleep */
function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason || new DOMException('Aborted', 'AbortError'));
    const t = setTimeout(() => {
      cleanup();
      resolve();
    }, ms);
    const onAbort = () => {
      cleanup();
      reject(signal.reason || new DOMException('Aborted', 'AbortError'));
    };
    const cleanup = () => {
      clearTimeout(t);
      if (signal) signal.removeEventListener('abort', onAbort);
    };
    if (signal) signal.addEventListener('abort', onAbort, { once: true });
  });
}

/** 为相对/绝对URL安全地添加/覆盖查询参数 */
function ensureQueryParam(url, key, value) {
  const [path, query = ''] = url.split('?');
  const sp = new URLSearchParams(query);
  if (!sp.has(key)) sp.set(key, String(value));
  return `${path}?${sp.toString()}`;
}

/** 从响应中解析 next 链接(优先 body.next,其次 Link: <...>; rel=next) */
function getNextUrl(body, headers) {
  if (body && typeof body.next === 'string' && body.next) return body.next;
  const link = headers?.get?.('Link');
  if (link) {
    const m = link.match(/<([^>]+)>\s*;\s*rel="?next"?/i);
    if (m) return m[1];
  }
  return null;
}

/** 将原始产品数据规范化为 {id, name, price, updatedAt} */
function normalizeItem(raw) {
  const id = raw?.id ?? raw?.productId ?? raw?._id ?? null;
  const name = raw?.name ?? raw?.title ?? '';
  const priceVal = typeof raw?.price === 'number' ? raw.price : parseFloat(raw?.price);
  const updatedRaw = raw?.updatedAt ?? raw?.updated_at ?? raw?.modifiedAt ?? raw?.updated ?? raw?.modified ?? null;

  let updatedISO;
  if (updatedRaw) {
    const d = new Date(updatedRaw);
    updatedISO = Number.isFinite(d.getTime()) ? d.toISOString() : new Date(0).toISOString();
  } else {
    updatedISO = new Date(0).toISOString(); // 缺失时使用极早时间,排序置后
  }

  return {
    id,
    name,
    price: Number.isFinite(priceVal) ? priceVal : null,
    updatedAt: updatedISO,
  };
}

/**
 * 单请求封装:ETag缓存、8s超时、最多3次重试(网络错误/429/5xx)、支持 Retry-After。
 * 返回 { body, status, headers, fromCache, retries }
 */
async function fetchWithRetry(url, { headers = {}, signal, timeoutMs = 8000, maxRetries = 3 }) {
  let attempt = 0; // 含初次请求
  let lastErr = null;

  while (attempt <= maxRetries) {
    const controller = new AbortController();
    const composedSignal = controller.signal;
    // 绑定外部 signal -> 内部
    const onAbort = () => controller.abort(signal.reason || new DOMException('Aborted', 'AbortError'));
    if (signal) {
      if (signal.aborted) {
        throw signal.reason || new DOMException('Aborted', 'AbortError');
      }
      signal.addEventListener('abort', onAbort, { once: true });
    }

    // 设置超时
    const timeoutId = setTimeout(() => {
      controller.abort(new DOMException('Timeout', 'TimeoutError'));
    }, timeoutMs);

    try {
      attempt++;
      const reqHeaders = new Headers(headers);
      const cached = ETagCache.get(url);
      if (cached?.etag) {
        reqHeaders.set('If-None-Match', cached.etag);
      }

      const res = await fetch(url, { method: 'GET', headers: reqHeaders, signal: composedSignal });
      const { status, headers: resHeaders } = res;

      // 处理 304:使用缓存体
      if (status === 304) {
        clearTimeout(timeoutId);
        if (signal) signal.removeEventListener('abort', onAbort);
        if (cached?.body != null) {
          return { body: cached.body, status, headers: resHeaders, fromCache: true, retries: attempt - 1 };
        }
        // 接收到304但无缓存体,视为异常(不重试 or 作为一次错误后按指数退避重试)
        lastErr = new Error('304 received but cache body missing');
        // 尝试下一次重试(极少见的状态不同步)
      } else if (status >= 200 && status < 300) {
        // 正常 2xx
        const body = await res.json().catch(() => {
          throw new Error('Failed to parse JSON');
        });
        const etag = resHeaders.get('ETag') || resHeaders.get('Etag') || resHeaders.get('etag');
        if (etag) {
          ETagCache.set(url, { etag, body });
        }
        clearTimeout(timeoutId);
        if (signal) signal.removeEventListener('abort', onAbort);
        return { body, status, headers: resHeaders, fromCache: false, retries: attempt - 1 };
      } else if (status === 429 || (status >= 500 && status < 600)) {
        // 需要重试的状态码
        const delay = computeRetryDelayMs(resHeaders, attempt);
        clearTimeout(timeoutId);
        if (signal) signal.removeEventListener('abort', onAbort);
        if (attempt > maxRetries) {
          const text = await res.text().catch(() => '');
          throw new Error(`Request failed after retries: ${status} ${text?.slice(0, 160)}`);
        }
        await sleep(delay, signal);
        continue; // 下一次尝试
      } else {
        // 其他 4xx:不重试
        const text = await res.text().catch(() => '');
        clearTimeout(timeoutId);
        if (signal) signal.removeEventListener('abort', onAbort);
        throw new Error(`Request failed: ${status} ${text?.slice(0, 160)}`);
      }
    } catch (err) {
      clearTimeout(timeoutId);
      if (signal) signal.removeEventListener('abort', onAbort);

      // 取消直接抛出
      if (err?.name === 'AbortError') throw err;

      lastErr = err;
      if (attempt > maxRetries) {
        throw lastErr;
      }
      const delay = computeRetryDelayMs(null, attempt);
      await sleep(delay, signal);
      continue;
    }
  }

  // 理论上不会到达
  throw lastErr || new Error('Unknown fetch error');
}

/**
 * 主函数:分页抓取产品,重试/超时/并发/ETag缓存/标准化/去重/排序。
 * 返回 { items, total, fromCache, durationMs, errors }
 */
async function fetchProducts(endpoint, { pageSize = 50, maxPages = 5, headers = {}, signal, onProgress } = {}) {
  const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();

  // 并发上限
  const MAX_CONCURRENCY = 3;

  // 初始URL:注入 pageSize(若已存在则不改变)
  let firstUrl = ensureQueryParam(endpoint, 'pageSize', pageSize);

  // 结果汇总
  const byId = new Map(); // id -> normalizedItem(保留最新updatedAt)
  let anyFromCache = false;
  let serverTotal = null;
  const errors = [];

  // 队列 + 并发 worker
  const queue = [];
  const visited = new Set(); // 防止意外循环
  let pagesFetched = 0;

  // 入队初始页
  queue.push(firstUrl);
  visited.add(firstUrl);

  async function processPage(url, pageIndex) {
    const resp = await fetchWithRetry(url, { headers, signal, timeoutMs: 8000, maxRetries: 3 });
    const { body, status, headers: resHeaders, fromCache, retries } = resp;
    anyFromCache = anyFromCache || !!fromCache;

    // 解析产品列表
    const list = body?.items ?? body?.products ?? body?.data ?? [];
    const total = (typeof body?.total === 'number' && body.total >= 0) ? body.total : null;
    if (serverTotal == null && total != null) serverTotal = total;

    // 规范化 + 去重(按最新updatedAt保留)
    for (const raw of list) {
      const item = normalizeItem(raw);
      if (item.id == null) continue; // 跳过无ID项
      const prev = byId.get(item.id);
      if (!prev) {
        byId.set(item.id, item);
      } else {
        const prevTs = new Date(prev.updatedAt).getTime();
        const currTs = new Date(item.updatedAt).getTime();
        if (currTs >= prevTs) {
          byId.set(item.id, item);
        }
      }
    }

    // 进度回调
    if (typeof onProgress === 'function') {
      try {
        onProgress({
          page: pageIndex,
          url,
          received: Array.isArray(list) ? list.length : 0,
          total,
          fromCache: !!fromCache,
          retries,
          status,
        });
      } catch {
        // 保持稳健,忽略回调内部错误
      }
    }

    // 解析下一页
    const nextUrl = getNextUrl(body, resHeaders);
    return { nextUrl };
  }

  // Worker:从队列取任务直到满足条件或被取消
  async function worker(id) {
    while (!signal?.aborted && queue.length > 0 && pagesFetched < maxPages) {
      const url = queue.shift();
      const pageIndex = pagesFetched + 1;
      try {
        const { nextUrl } = await processPage(url, pageIndex);
        pagesFetched++;
        if (nextUrl && !visited.has(nextUrl) && pagesFetched < maxPages) {
          visited.add(nextUrl);
          queue.push(nextUrl);
        }
      } catch (err) {
        // 记录错误并继续处理队列中其他任务(线性分页通常不会再有后续)
        errors.push({
          url,
          message: String(err?.message || err),
          name: err?.name || 'Error',
        });
        // 失败页不增加 pagesFetched(因为没有成功处理该页)
        // 不再入队下一页,因为无法获取 next 链接
      }
    }
  }

  // 启动并发 worker(在单链分页上并发自然退化为 1,但逻辑仍健壮)
  const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, queue.length) }, (_, i) => worker(i));
  await Promise.all(workers);

  // 汇总与排序(按 updatedAt 倒序)
  const items = Array.from(byId.values()).sort(
    (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
  );

  const end = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
  return {
    items,
    total: serverTotal != null ? serverTotal : items.length,
    fromCache: anyFromCache,
    durationMs: Math.round(end - start),
    errors,
  };
}

错误处理

  • 网络错误、5xx、429:
    • 最多 3 次重试(指数退避:约 400ms、800ms、1600ms,包含随机抖动)
    • 若响应包含 Retry-After,则优先使用该值(支持秒与HTTP日期)
  • 304 Not Modified:
    • 命中缓存时返回缓存体,并标记 fromCache=true
    • 若收到 304 时缓存缺失(极少见状态不同步),将按重试策略处理
  • 超时:
    • 每次请求 8s 超时(AbortController)
    • 若调用方传入 signal,取消会立即中止所有等待与请求
  • 其他 4xx:
    • 不重试,直接记录错误
  • onProgress 回调内抛错被安全忽略,不影响主流程

使用示例

// 取消控制(可选)
const controller = new AbortController();

// 进度回调(可选)
function onProgress(evt) {
  // evt: { page, url, received, total, fromCache, retries, status }
  console.log(`Page #${evt.page} status=${evt.status} items=${evt.received} total=${evt.total ?? 'n/a'} cache=${evt.fromCache} retries=${evt.retries}`);
}

(async () => {
  try {
    const result = await fetchProducts('/products', {
      pageSize: 100,
      maxPages: 5,
      headers: { Accept: 'application/json' },
      signal: controller.signal,
      onProgress,
    });

    console.log('Total:', result.total);
    console.log('Duration(ms):', result.durationMs);
    console.log('From cache:', result.fromCache);
    console.log('Errors:', result.errors);
    console.log('First item:', result.items[0]); // {id,name,price,updatedAt}
  } catch (e) {
    console.error('Fetch failed:', e);
  }
})();

技术要点

  • 异步与重试策略
    • 使用 async/await 保持可读性,单独封装 fetchWithRetry 提升复用性与可测试性
    • 对网络错误、429 与 5xx 进行指数退避重试,并尊重服务端 Retry-After
    • 重试等待通过 sleep 实现,并支持外部 AbortSignal 取消
  • 超时与取消
    • 每次请求创建独立 AbortController 并设置 8s 超时
    • 合并外部 signal:外部取消会中止所有请求与等待
  • ETag 缓存
    • 以 URL 为键缓存 {etag, body},命中时发送 If-None-Match
    • 收到 304 时使用缓存体,减少带宽与延迟
  • 并发分页
    • 任务队列 + worker 设计,最大并发 3
    • 单链式 next 会自然退化为并发 1,但在存在分支或预取场景也能稳定工作
  • 数据规范化与去重
    • 统一为 {id,name,price,updatedAt},缺失字段安全处理
    • 按 id 去重,并保留最新 updatedAt 的记录
    • 按 updatedAt 倒序排序,便于展示最新变更
  • 返回结构
    • {items, total, fromCache, durationMs, errors}
    • total 优先使用服务端 total,否则使用去重后的 items.length
  • 兼容性与最佳实践
    • 使用原生 fetch、Headers、AbortController 与 URLSearchParams
    • 不依赖第三方库,避免安全风险
    • 所有回调与错误都进行边界处理,保证稳健性与可维护性

函数定义

async function scrapeArticles(urls, { concurrency = 5, timeoutMs = 6000, cacheTTL = 600000, signal } = {})

  • 参数说明

    • urls: string[],待抓取的 URL 列表(仅支持 http/https)
    • concurrency: number,并发上限(默认 5)
    • timeoutMs: number,单请求超时毫秒数(默认 6000ms)
    • cacheTTL: number,内存缓存 TTL(默认 10 分钟 = 600000ms)
    • signal: AbortSignal,可选的外部取消信号,用于整体任务取消
  • 返回值

    • Promise<{ list: Array<{ url: string; title: string | null; publishedAt: string | null; length: number }>, failed: Array<{ url: string; reason: string }> }>
    • list:去重后的成功结果集合(依据 canonical 或规范化 URL)
    • failed:失败的 URL 与失败原因

注意:若传入的 AbortSignal 触发,将尽可能中止正在执行的请求;函数不会抛错而是把已完成和失败的请求通过 allSettled 汇总并返回(失败中会标记 Abort)。

代码实现

// 模块级内存缓存(基于 TTL)。键为规范化 URL 或 canonical 规范化 URL。
const __articleCache = new Map(); // key -> { value, expiresAt }

/**
 * 规范化 URL:
 * - 只接受 http/https
 * - 小写协议与主机名,移除 hash
 * - 清理默认端口(:80 / :443)
 * - 规范路径(去除多余斜杠与根路径尾部斜杠)
 * - 搜索参数排序且移除常见追踪参数
 */
function normalizeUrl(input) {
  let u;
  try {
    u = new URL(input);
  } catch {
    throw new Error(`Invalid URL: ${input}`);
  }
  if (!/^https?:$/.test(u.protocol)) {
    throw new Error(`Unsupported protocol (only http/https): ${input}`);
  }
  u.protocol = u.protocol.toLowerCase();
  u.hostname = u.hostname.toLowerCase();

  // remove default ports
  if ((u.protocol === 'http:' && u.port === '80') || (u.protocol === 'https:' && u.port === '443')) {
    u.port = '';
  }

  // remove hash
  u.hash = '';

  // normalize path: collapse multiple slashes
  u.pathname = u.pathname.replace(/\/{2,}/g, '/');
  // remove trailing slash except root
  if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
    u.pathname = u.pathname.slice(0, -1);
  }

  // clean & sort search params
  const trackingParams = new Set([
    'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
    'fbclid', 'gclid', 'yclid', 'mc_cid', 'mc_eid'
  ]);
  const params = Array.from(u.searchParams.entries())
    .filter(([k]) => !trackingParams.has(k.toLowerCase()))
    .sort(([a], [b]) => a.localeCompare(b));
  u.search = '';
  for (const [k, v] of params) u.searchParams.append(k, v);

  return u.toString();
}

/** 简易 HTML 实体解码(覆盖常见实体与数字实体) */
function decodeHtmlEntities(str) {
  if (!str) return str;
  const map = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
  return str
    .replace(/&(#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, t) => {
      if (t[0] === '#') {
        const code = t[1].toLowerCase() === 'x' ? parseInt(t.slice(2), 16) : parseInt(t.slice(1), 10);
        if (!Number.isNaN(code)) return String.fromCodePoint(code);
        return m;
      }
      return map[t] ?? m;
    })
    .trim();
}

/** 从标签中提取纯文本(粗略移除内部标签) */
function stripTags(html) {
  return decodeHtmlEntities(
    html
      .replace(/<script[\s\S]*?<\/script>/gi, '')
      .replace(/<style[\s\S]*?<\/style>/gi, '')
      .replace(/<[^>]+>/g, ' ')
      .replace(/\s+/g, ' ')
      .trim()
  );
}

/** 获取首个匹配的开始/结束标签内容 */
function getFirstTagInnerHTML(html, tag) {
  const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
  const m = html.match(re);
  return m ? m[1] : null;
}

/** 解析 <title> 与 <h1> */
function extractTitle(html) {
  const titleRaw = getFirstTagInnerHTML(html, 'title');
  if (titleRaw) {
    const t = stripTags(titleRaw);
    if (t) return t;
  }
  const h1Raw = getFirstTagInnerHTML(html, 'h1');
  if (h1Raw) {
    const t = stripTags(h1Raw);
    if (t) return t;
  }
  return null;
}

/** 解析 <link rel="canonical" href="...">(返回相对或绝对 URL 字符串) */
function extractCanonical(html) {
  // 捕获所有 link 标签,解析 rel 和 href
  const links = html.match(/<link\b[^>]*>/gi) || [];
  for (const tag of links) {
    // 解析属性
    const attrs = {};
    tag.replace(/(\w[\w:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g, (_, name, _v, v1, v2, v3) => {
      attrs[name.toLowerCase()] = (v1 ?? v2 ?? v3 ?? '').trim();
      return _;
    });
    const rel = (attrs.rel || '').toLowerCase();
    if (rel.split(/\s+/).includes('canonical') && attrs.href) {
      return attrs.href.trim();
    }
  }
  return null;
}

/** 解析 meta[property="article:published_time"] 或 meta[name="article:published_time"] */
function extractPublishedTimeFromMeta(html) {
  const metas = html.match(/<meta\b[^>]*>/gi) || [];
  for (const tag of metas) {
    const attrs = {};
    tag.replace(/(\w[\w:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g, (_, name, _v, v1, v2, v3) => {
      attrs[name.toLowerCase()] = (v1 ?? v2 ?? v3 ?? '').trim();
      return _;
    });
    const prop = (attrs.property || attrs.name || '').toLowerCase();
    if (prop === 'article:published_time' && attrs.content) {
      return attrs.content.trim();
    }
  }
  return null;
}

/** 从首个 <p> 文本中尽力提取日期时间(支持 ISO 与常见中文格式) */
function extractPublishedTimeFromFirstParagraph(html) {
  const p = getFirstTagInnerHTML(html, 'p');
  if (!p) return null;
  const text = stripTags(p);

  // 1) 直接尝试 Date.parse 可识别格式(包含 ISO/RFC)
  const direct = Date.parse(text);
  if (!Number.isNaN(direct)) return new Date(direct).toISOString();

  // 2) 匹配 YYYY-MM-DD( HH:MM(:SS)?(Z|±HH:MM)?)
  const isoLike = text.match(/(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?(?:Z|([+-]\d{2}:?\d{2}))?)?/);
  if (isoLike) {
    const [ , Y, M, D, h='0', m='0', s='0', tz] = isoLike;
    const base = new Date(Date.UTC(+Y, +M - 1, +D, +h, +m, +s));
    if (tz && tz !== 'Z') {
      // 处理时区偏移
      const m2 = tz.match(/([+-]\d{2}):?(\d{2})/);
      if (m2) {
        const offsetMin = (parseInt(m2[1], 10) * 60) + parseInt(m2[2], 10);
        // UTC = local - offset => 已按 UTC 构建了 base,无需额外处理
        // 但 isoLike 在无 tz 时按 UTC 处理;有 tz 时我们已用 UTC 构造,再按 offset 调整
        base.setUTCMinutes(base.getUTCMinutes() - offsetMin);
      }
    }
    return base.toISOString();
  }

  // 3) 中文日期:YYYY年MM月DD日 HH:MM(:SS)?
  const zh = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/);
  if (zh) {
    const [ , Y, M, D, h='0', m='0', s='0'] = zh;
    const d = new Date(+Y, +M - 1, +D, +h, +m, +s);
    return new Date(d.getTime()).toISOString();
  }

  return null;
}

/** 提取 HTML 信息:title、canonical、publishedAt(ISO string 或 null) */
function parseHtml(html, baseUrl) {
  const title = extractTitle(html);

  let publishedAt = extractPublishedTimeFromMeta(html);
  if (publishedAt) {
    const ts = Date.parse(publishedAt);
    publishedAt = Number.isNaN(ts) ? null : new Date(ts).toISOString();
  } else {
    publishedAt = extractPublishedTimeFromFirstParagraph(html);
  }

  const canonicalHref = extractCanonical(html);
  let canonical = null;
  if (canonicalHref) {
    try {
      const abs = new URL(canonicalHref, baseUrl).toString();
      canonical = abs;
    } catch {
      // ignore bad canonical
    }
  }

  return { title, canonical, publishedAt };
}

/** TTL 缓存:读取 */
function cacheGet(key) {
  const hit = __articleCache.get(key);
  if (hit && hit.expiresAt > Date.now()) return hit.value;
  if (hit) __articleCache.delete(key);
  return null;
}

/** TTL 缓存:写入 */
function cacheSet(key, value, ttl) {
  __articleCache.set(key, { value, expiresAt: Date.now() + ttl });
}

/** 组合多个 AbortSignal(支持 AbortSignal.any 的环境;无则降级) */
function combineSignals(signals) {
  const valid = signals.filter(Boolean);
  if (valid.length === 0) return undefined;
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
    return AbortSignal.any(valid);
  }
  const controller = new AbortController();
  const onAbort = (e) => {
    try { controller.abort(e?.target?.reason); } catch { controller.abort(); }
    cleanup();
  };
  const cleanup = () => valid.forEach(s => s.removeEventListener('abort', onAbort));
  let alreadyAborted = false;
  for (const s of valid) {
    if (s.aborted) {
      alreadyAborted = true;
      try { controller.abort(s.reason); } catch { controller.abort(); }
      break;
    }
  }
  if (!alreadyAborted) valid.forEach(s => s.addEventListener('abort', onAbort, { once: true }));
  return controller.signal;
}

/** 基于并发的 limiter */
function createLimiter(limit, outerSignal) {
  let active = 0;
  const queue = [];
  const runNext = () => {
    if (outerSignal?.aborted) return;
    if (active >= limit || queue.length === 0) return;
    const task = queue.shift();
    active++;
    task().finally(() => {
      active--;
      runNext();
    });
  };
  return (fn) => new Promise((resolve) => {
    const wrapped = () => fn().then(resolve, resolve); // 将错误留待 allSettled 处理
    queue.push(wrapped);
    runNext();
  });
}

/** 单 URL 抓取与解析 */
async function fetchOne(originalUrl, { timeoutMs, cacheTTL, outerSignal }) {
  const normInput = normalizeUrl(originalUrl);

  // 命中缓存
  const cached = cacheGet(normInput);
  if (cached) return cached;

  // 每请求单独超时控制
  const timeoutCtrl = new AbortController();
  const tId = setTimeout(() => {
    try { timeoutCtrl.abort(new Error('Timeout')); } catch { timeoutCtrl.abort(); }
  }, timeoutMs);

  const combinedSignal = combineSignals([timeoutCtrl.signal, outerSignal]);

  try {
    const res = await fetch(originalUrl, {
      method: 'GET',
      headers: {
        'Accept': 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
        // 可选 UA,部分站点对默认 UA 严格
        'User-Agent': 'scrapeArticles/1.0 (+https://example.com)'
      },
      redirect: 'follow',
      // gzip/br/deflate 由 fetch 实现自动协商与解压
      signal: combinedSignal
    });

    if (!res.ok) {
      throw new Error(`HTTP ${res.status} ${res.statusText}`);
    }

    const finalUrl = res.url || originalUrl;
    const html = await res.text();

    const { title, canonical, publishedAt } = parseHtml(html, finalUrl);

    // 使用 canonical 或最终跳转后的 URL 进行规范化
    const finalNorm = normalizeUrl(canonical ? new URL(canonical, finalUrl).toString() : finalUrl);

    const result = {
      url: finalNorm,
      title: title || null,
      publishedAt: publishedAt || null,
      // 以字符长度衡量,如需字节可用 Buffer.byteLength(html, 'utf8')(Node 环境)
      length: html.length
    };

    // 写入缓存(输入规范化 URL 与 canonical 规范化 URL 都写入,提升命中率)
    cacheSet(normInput, result, cacheTTL);
    cacheSet(finalNorm, result, cacheTTL);

    return result;
  } finally {
    clearTimeout(tId);
  }
}

/**
 * 主函数:批量抓取文章标题与时间(限流、超时、缓存、去重、allSettled 汇总)
 */
async function scrapeArticles(urls, { concurrency = 5, timeoutMs = 6000, cacheTTL = 600000, signal } = {}) {
  if (!Array.isArray(urls)) throw new Error('urls must be an array of strings');
  if (concurrency <= 0) throw new Error('concurrency must be > 0');

  // 预过滤非法/重复输入(按规范化 URL 去重)
  const uniqueInputs = [];
  const seen = new Set();
  for (const u of urls) {
    if (typeof u !== 'string' || !u.trim()) continue;
    try {
      const n = normalizeUrl(u);
      if (!seen.has(n)) {
        seen.add(n);
        uniqueInputs.push({ original: u, normalized: n });
      }
    } catch {
      // 跳过非法 URL(最终会在 failed 中体现)
      uniqueInputs.push({ original: u, normalized: null });
    }
  }

  const limiter = createLimiter(concurrency, signal);
  const tasks = uniqueInputs.map(({ original, normalized }) =>
    limiter(async () => {
      if (!normalized) {
        // 预规范化失败
        return { status: 'rejected', reason: new Error('Invalid URL'), original };
      }
      try {
        const value = await fetchOne(original, { timeoutMs, cacheTTL, outerSignal: signal });
        return { status: 'fulfilled', value, original };
      } catch (err) {
        return { status: 'rejected', reason: err, original };
      }
    })
  );

  // 等待任务全部 settle(内部每项已包装为 fulfilled/rejected 结构)
  const settled = await Promise.all(tasks);

  // 汇总:去重(基于 canonical 或规范化 URL)
  const finalList = [];
  const finalFailed = [];

  const dedupe = new Set();
  for (const s of settled) {
    if (s.status === 'fulfilled') {
      const item = s.value;
      const key = normalizeUrl(item.url);
      if (!dedupe.has(key)) {
        dedupe.add(key);
        finalList.push(item);
      }
    } else {
      const reason = s.reason;
      const msg = (reason && (reason.message || String(reason))) || 'Unknown error';
      finalFailed.push({
        url: s.original,
        reason: msg
      });
    }
  }

  return { list: finalList, failed: finalFailed };
}

错误处理

  • 输入校验:urls 必须为字符串数组;concurrency > 0;不支持非 http/https 协议
  • 超时控制:每个请求通过 AbortController 实现 timeoutMs 超时,中止 fetch
  • 取消控制:可接收外部 AbortSignal;与超时信号组合,任一触发即中止请求
  • 网络/HTTP 错误:非 2xx/3xx 响应抛出 HTTP 错误并记入 failed
  • HTML 解析容错:采用健壮的正则提取,若 title 或时间缺失,返回 null 而非抛错
  • 缓存过期:TTL 到期自动清理;命中后直接返回缓存结果,减少重复请求

注意:函数最终使用 allSettled 风格汇总,无论个别请求失败或被中止,均返回 { list, failed },不会整体抛错(除非参数校验失败)。

使用示例

(async () => {
  const urls = [
    'https://example.com/article/123',
    'https://example.com/article/123?utm_source=twitter',
    'https://blog.example.org/post/abc',
    'https://news.example.com/path#section',
    'invalid-url'
  ];

  const controller = new AbortController();
  // 可选:在 10 秒后取消整个批量任务
  // setTimeout(() => controller.abort(new Error('User aborted')), 10000);

  const { list, failed } = await scrapeArticles(urls, {
    concurrency: 4,
    timeoutMs: 6000,
    cacheTTL: 10 * 60 * 1000,
    signal: controller.signal
  });

  console.log('Success:', list);
  console.log('Failed:', failed);
})();
  • 预期输出说明
    • list 中每项包含:
      • url:canonical 规范化 URL(若页面提供),否则为最终跳转后的规范化 URL
      • title:页面标题或首个 h1(若 title 缺失)
      • publishedAt:优先取 meta[property="article:published_time"],否则尝试从首个段落解析时间(ISO 字符串)
      • length:HTML 文本的字符长度
    • failed 包含无法请求/解析的 URL 与失败原因(如超时、HTTP 错误、无效 URL、被取消等)

技术要点

  • 并发控制:自实现轻量 limiter,避免额外依赖;保证同一时间最多 concurrency 个请求
  • 取消与超时:使用 AbortController;通过 combineSignals 合并外部 signal 与超时 signal,任一触发即中断 fetch
  • allSettled 汇总:每个任务内部已包裹为 fulfilled/rejected 结构,最终统一汇总为 { list, failed }
  • 去重策略:优先使用页面 canonical 链接规范化;无 canonical 时以最终跳转 URL 的规范化结果为去重键
  • 规范化 URL:去除 hash、处理默认端口、排序查询参数、清理常见追踪参数,尽量将等价 URL 归一
  • 内存缓存(TTL):输入规范化 URL 与 canonical 规范化 URL 同步写入缓存,提升命中率;减少重复抓取与压力
  • 解析策略:
    • 标题:先取 ,缺失时取首个 <h1></li> <li>时间:优先 meta[property="article:published_time"];缺失时尝试从首段文本解析常见时间格式(ISO/中文日期)</li> <li>HTML 解析采用正则提取常见标签,避免引入重型 DOM 解析库,满足中等复杂度抓取任务</li> </ul> </li> <li>兼容性: <ul> <li>需运行在支持 fetch 与 AbortController 的环境(Node.js 18+ 或现代浏览器)</li> <li>gzip/br 自动协商由 fetch 实现;无需显式设置 Accept-Encoding</li> </ul> </li> <li>安全与健壮性: <ul> <li>限制协议为 http/https,拒绝其他协议</li> <li>对错误进行分门别类的捕获与记录,避免影响整体任务</li> <li>避免使用已废弃 API 与不必要的第三方依赖</li> </ul> </li> </ul> <p>如果你需要扩展:支持重试策略、更多日期格式、内容字节长度统计、或将缓存替换为 LRU 等,我可以进一步完善。</p> </div> </div> </div> <div class="prompt-examples-v4-tab-content " id="example-2"> <div class="prompt-examples-v4-output"> <button class="prompt-examples-v4-popup-btn" onclick="showExamplePopup(2, 'CSV批量上报', this)" title="在弹窗中查看完整内容"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/> </svg> </button> <div class="html-content-display prompt-examples-v4-output-text"> <p>函数定义 async function processCsvAndUpload(csvPath, endpoint, { batchSize = 100, concurrency = 4, signal, onProgress } = {})</p> <ul> <li>参数说明 <ul> <li>csvPath: string。本地 UTF-8 CSV 文件路径,首行为表头。</li> <li>endpoint: string。批量上报的 HTTP(S) JSON 接口地址(POST)。</li> <li>options: <ul> <li>batchSize: number,分批大小,默认 100。</li> <li>concurrency: number,同时并发发送的批量请求数上限,默认 4。</li> <li>signal: AbortSignal,可选。用于全程取消操作。</li> <li>onProgress: function,可选。形如 onProgress({ linesRead, batchesDispatched, batchesCompleted, batchesSucceeded, batchesFailed, recordsProcessed })。</li> </ul> </li> </ul> </li> <li>返回值 <ul> <li>Promise<{ total, batchesSucceeded, batchesFailed, skipped, elapsedMs, files }></li> <li>total: 成功进入上报流程的记录数(不含表头,不含被跳过的空行/损坏行)。</li> <li>batchesSucceeded: 成功上报(HTTP 2xx)的批次数。</li> <li>batchesFailed: 达到重试上限仍失败的批次数。</li> <li>skipped: 本地解析阶段跳过的行数(空行/无效行/字段数不匹配)。</li> <li>elapsedMs: 总耗时(毫秒)。</li> <li>files: { success: string, errors: string },分别为 success.ndjson 与 errors.ndjson 的绝对路径。</li> </ul> </li> </ul> <p>代码实现 注意:该实现仅依赖 Node.js 内置模块(Node.js >= 18,内置 fetch),无第三方依赖。</p> <pre><code class="language-js">// Node.js >= 18 import fs from 'node:fs'; import path from 'node:path'; import { once } from 'node:events'; export async function processCsvAndUpload( csvPath, endpoint, { batchSize = 100, concurrency = 4, signal, onProgress } = {} ) { assertPositiveInteger(batchSize, 'batchSize'); assertPositiveInteger(concurrency, 'concurrency'); if (typeof endpoint !== 'string' || !/^https?:\/\//i.test(endpoint)) { throw new TypeError('endpoint must be a valid HTTP/HTTPS URL string'); } if (signal?.aborted) throw abortError(); const start = Date.now(); const dir = path.resolve(path.dirname(csvPath)); const successPath = path.join(dir, 'success.ndjson'); const errorsPath = path.join(dir, 'errors.ndjson'); // 输出文件写入流(NDJSON) const successStream = fs.createWriteStream(successPath, { flags: 'w', encoding: 'utf8', highWaterMark: 1 << 20 }); const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w', encoding: 'utf8', highWaterMark: 1 << 20 }); // 捕获写入流错误 const streamErrorHandler = (err) => { // 将任何写入错误升级为操作失败 throw err; }; successStream.on('error', streamErrorHandler); errorsStream.on('error', streamErrorHandler); const stats = { linesRead: 0, // 读到的有效数据行(不含表头) recordsProcessed: 0, // 进入批处理的记录数 batchesDispatched: 0, batchesCompleted: 0, batchesSucceeded: 0, batchesFailed: 0, skipped: 0, }; const notify = () => { if (typeof onProgress === 'function') { try { onProgress({ linesRead: stats.linesRead, batchesDispatched: stats.batchesDispatched, batchesCompleted: stats.batchesCompleted, batchesSucceeded: stats.batchesSucceeded, batchesFailed: stats.batchesFailed, recordsProcessed: stats.recordsProcessed, }); } catch { // 忽略用户回调中的异常,避免影响主流程 } } }; // 简单的并发控制器(不创建队列:当活跃任务达到 concurrency 时,读入会等待最早完成的任务) const active = new Set(); async function runLimited(fn) { while (active.size >= concurrency) { if (signal?.aborted) throw abortError(); await Promise.race(active); } let p; p = (async () => { try { return await fn(); } finally { active.delete(p); } })(); active.add(p); return p; } // 写入 NDJSON 的工具函数(处理背压) async function writeNdjson(stream, obj) { const line = JSON.stringify(obj) + '\n'; if (!stream.write(line)) { await once(stream, 'drain'); } } // 解析 Retry-After 头(秒或日期) function parseRetryAfter(headerValue) { if (!headerValue) return null; const secs = Number(headerValue); if (Number.isFinite(secs)) return secs * 1000; const date = new Date(headerValue); const delta = date.getTime() - Date.now(); return Number.isFinite(delta) && delta > 0 ? delta : null; } // 带指数退避重试的 POST async function postWithRetry(url, jsonBody, { signal, maxRetries = 3, baseDelayMs = 500 }) { let attempt = 0; // 最多 attempt = 0..maxRetries,成功或遇到不可重试错误则提前返回 while (attempt <= maxRetries) { if (signal?.aborted) throw abortError(); try { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/plain, */*', }, body: jsonBody, // 将外部 signal 直接传入:可在请求中途取消 signal, keepalive: true, }); const status = res.status; if (res.ok) { // 尝试读取少量响应信息(非强制) let responseSnippet = ''; try { responseSnippet = await res.text(); if (responseSnippet.length > 2048) responseSnippet = responseSnippet.slice(0, 2048); } catch { /* ignore body read errors */ } return { ok: true, status, responseSnippet }; } // 可重试的状态码:5xx/429 if ((status >= 500 && status <= 599) || status === 429) { if (attempt === maxRetries) { const bodyText = await safeReadText(res); return { ok: false, retryable: true, status, bodyText, }; } // 计算等待时间:Retry-After 优先,其次指数退避 + 抖动 const retryAfterMs = parseRetryAfter(res.headers.get('retry-after')); const backoff = retryAfterMs ?? Math.min(30000, baseDelayMs * 2 ** attempt) + Math.floor(Math.random() * 250); await sleep(backoff, signal); attempt += 1; continue; } // 不可重试的 4xx/其它错误 const bodyText = await safeReadText(res); return { ok: false, retryable: false, status, bodyText }; } catch (err) { // fetch 抛出(网络错误/超时/取消) if (isAbortError(err)) throw err; if (attempt === maxRetries) { return { ok: false, retryable: true, status: 0, bodyText: String(err?.message ?? err) }; } const backoff = Math.min(30000, baseDelayMs * 2 ** attempt) + Math.floor(Math.random() * 250); await sleep(backoff, signal); attempt += 1; } } // 正常不会走到此处 return { ok: false, retryable: true, status: 0, bodyText: 'unknown error' }; } function isAbortError(err) { return (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')); } function abortError() { // 在 Node18+ 中 DOMException 可能不可用,退回 Error try { // eslint-disable-next-line no-undef return new DOMException('The operation was aborted', 'AbortError'); } catch { const e = new Error('The operation was aborted'); e.name = 'AbortError'; return e; } } async function sleep(ms, signal) { if (ms <= 0) return; await new Promise((resolve, reject) => { const t = setTimeout(resolve, ms); const onAbort = () => { clearTimeout(t); reject(abortError()); }; if (signal) { if (signal.aborted) { onAbort(); } else { signal.addEventListener('abort', onAbort, { once: true }); } } }); } async function safeReadText(res) { try { const t = await res.text(); return t.length > 4096 ? t.slice(0, 4096) : t; } catch { return ''; } } function assertPositiveInteger(n, name) { if (!Number.isInteger(n) || n <= 0) { throw new TypeError(`${name} must be a positive integer`); } } // CSV 流式解析(RFC4180 近似实现) async function* readCsvRecords(filePath, { signal }) { const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1 << 20 }); if (signal) { const onAbort = () => stream.destroy(abortError()); if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); } let buf = ''; let i = 0; let inQuotes = false; let field = ''; let record = []; let sawAny = false; for await (const chunk of stream) { if (!sawAny && chunk.charCodeAt(0) === 0xFEFF) { // 去除 UTF-8 BOM buf += chunk.slice(1); } else { buf += chunk; } sawAny = true; while (i < buf.length) { const ch = buf[i]; if (inQuotes) { if (ch === '"') { const next = buf[i + 1]; if (next === '"') { field += '"'; // 转义双引号 i += 2; continue; } inQuotes = false; i += 1; continue; } field += ch; i += 1; continue; } else { if (ch === '"') { inQuotes = true; i += 1; continue; } if (ch === ',') { record.push(field); field = ''; i += 1; continue; } if (ch === '\n' || ch === '\r') { // 结束一条记录 record.push(field); field = ''; // 跳过 CRLF if (ch === '\r' && buf[i + 1] === '\n') i += 2; else i += 1; yield record; record = []; continue; } field += ch; i += 1; } } // 移除已消费段,保留尾部未完成字段/记录 buf = ''; i = 0; } // 文件结束,输出最后一条(若存在) if (inQuotes) { // 未闭合引号,作为损坏行交给上层处理(会被当作字段数不匹配) } if (field.length > 0 || record.length > 0) { record.push(field); yield record; } } // 表头唯一化(重复列名添加后缀 _2, _3,...) function uniquifyHeaders(headers) { const map = new Map(); return headers.map((h0) => { let h = String(h0 ?? '').trim(); if (h.startsWith('\uFEFF')) h = h.slice(1); // 再次保险去BOM const count = (map.get(h) ?? 0) + 1; map.set(h, count); return count === 1 ? h : `${h}_${count}`; }); } // 主流程:读取 -> 组批 -> 并发上报 const batch = []; let batchId = 0; let rowNumber = 0; // 数据行号(不含表头,首条数据行为 1) let headers = null; let headerLen = 0; const reader = readCsvRecords(csvPath, { signal }); try { // 读取表头 const first = await reader.next(); if (first.done) { await closeStreams(); return finalizeResult(); } headers = uniquifyHeaders(first.value); headerLen = headers.length; // 持续读取数据行 for await (const rec of reader) { if (signal?.aborted) throw abortError(); // 跳过完全空行 if (rec.length === 1 && rec[0].trim() === '') { stats.skipped += 1; await writeNdjson(errorsStream, { status: 'error', reason: 'empty_line', rowNumber: rowNumber + 1, // 预增前的下一行 }); continue; } rowNumber += 1; stats.linesRead += 1; // 字段对齐:严格要求字段数一致,不一致则跳过并记录 if (rec.length !== headerLen) { stats.skipped += 1; await writeNdjson(errorsStream, { status: 'error', reason: 'field_count_mismatch', rowNumber, expected: headerLen, actual: rec.length, raw: rec, }); // 每 1000 行或错误发生时可通知 if (rowNumber % 1000 === 0) notify(); continue; } const obj = {}; for (let i = 0; i < headerLen; i += 1) { obj[headers[i]] = rec[i]; } batch.push({ rowNumber, data: obj }); stats.recordsProcessed += 1; if (batch.length >= batchSize) { const toSend = batch.splice(0, batch.length); stats.batchesDispatched += 1; const myBatchId = ++batchId; // 并发受限执行 runLimited(() => handleBatch(myBatchId, toSend)).catch(async (err) => { // 将内部错误冒泡:取消时会是 AbortError,其他错误也终止流程 // 尽快关闭流并重抛 await closeStreams().catch(() => {}); throw err; }); // 每发送一个批次就通知一次 notify(); } if (rowNumber % 1000 === 0) { notify(); } } // 发送最后不足一批的数据 if (batch.length > 0) { const toSend = batch.splice(0, batch.length); stats.batchesDispatched += 1; const myBatchId = ++batchId; runLimited(() => handleBatch(myBatchId, toSend)).catch(async (err) => { await closeStreams().catch(() => {}); throw err; }); notify(); } // 等待所有进行中的任务完成 while (active.size > 0) { if (signal?.aborted) throw abortError(); await Promise.race(active); } await closeStreams(); return finalizeResult(); } catch (err) { // 主流程异常(包含取消) await closeStreams().catch(() => {}); throw err; } async function handleBatch(myBatchId, items) { if (signal?.aborted) throw abortError(); // 准备 JSON body(仅发送 data 对象数组) const payload = items.map((it) => it.data); const jsonBody = JSON.stringify(payload); const res = await postWithRetry(endpoint, jsonBody, { signal, maxRetries: 3, baseDelayMs: 500 }); stats.batchesCompleted += 1; if (res.ok) { stats.batchesSucceeded += 1; // 对成功的每条记录写入 success.ndjson(保留可观测性) // 如需降采样,可考虑仅写入批级记录 for (const it of items) { await writeNdjson(successStream, { status: 'success', batchId: myBatchId, rowNumber: it.rowNumber, endpoint, responseStatus: 200, }); } } else { stats.batchesFailed += 1; // 将整个批次写入死信文件(逐条记录,包含失败原因) const reason = res.retryable ? 'network_or_server' : 'client_error'; const message = res.bodyText ? truncate(res.bodyText, 2048) : undefined; for (const it of items) { await writeNdjson(errorsStream, { status: 'error', reason, batchId: myBatchId, rowNumber: it.rowNumber, endpoint, httpStatus: res.status, message, data: it.data, }); } } notify(); } function truncate(s, n) { return s && s.length > n ? s.slice(0, n) : s; } async function closeStreams() { await Promise.all([ new Promise((resolve) => successStream.end(resolve)), new Promise((resolve) => errorsStream.end(resolve)), ]); } function finalizeResult() { const elapsedMs = Date.now() - start; return { total: stats.recordsProcessed, batchesSucceeded: stats.batchesSucceeded, batchesFailed: stats.batchesFailed, skipped: stats.skipped, elapsedMs, files: { success: successPath, errors: errorsPath, }, }; } } </code></pre> <p>错误处理</p> <ul> <li>全链路取消支持:通过 options.signal 将 AbortSignal 传入 CSV 读取、重试等待与 fetch 请求中。一旦外部取消,立即抛出 AbortError 并中止流程。</li> <li>CSV 解析异常: <ul> <li>引号未闭合、字段数与表头不一致、空行等,均计入 skipped,并写入 errors.ndjson,便于后续排查。</li> </ul> </li> <li>网络/服务端错误: <ul> <li>对 5xx 与 429 进行最多 3 次指数退避重试,并支持 Retry-After 头;网络异常(如 ECONNRESET)同样重试。</li> <li>超过重试上限仍失败的批次,将整批写入 errors.ndjson(逐记录记录),并计入 batchesFailed。</li> </ul> </li> <li>客户端错误(如 4xx 非 429):视为不可重试错误,直接写入 errors.ndjson。</li> <li>写入流错误:监听 success.ndjson 与 errors.ndjson 的 error 事件,任何写入异常都会终止流程并抛出。</li> </ul> <p>使用示例</p> <pre><code class="language-js">import { processCsvAndUpload } from './processCsvAndUpload.js'; const ac = new AbortController(); (async () => { try { const result = await processCsvAndUpload( './data/input.csv', 'https://api.example.com/ingest/batch', { batchSize: 200, concurrency: 4, signal: ac.signal, onProgress: ({ linesRead, batchesDispatched, batchesCompleted, batchesSucceeded, batchesFailed, recordsProcessed }) => { console.log( `lines=${linesRead}, dispatched=${batchesDispatched}, completed=${batchesCompleted}, ok=${batchesSucceeded}, fail=${batchesFailed}, records=${recordsProcessed}` ); }, } ); console.log('Done:', result); // 预期输出: // { // total: 12345, // batchesSucceeded: 60, // batchesFailed: 2, // skipped: 10, // elapsedMs: 9876, // files: { success: '/abs/path/success.ndjson', errors: '/abs/path/errors.ndjson' } // } } catch (e) { if (e.name === 'AbortError') { console.error('Canceled by user'); } else { console.error('Failed:', e); } } })(); // 在需要时取消 // ac.abort(); </code></pre> <p>技术要点</p> <ul> <li>流式 CSV 解析: <ul> <li>自实现近 RFC4180 的解析器,逐字符处理引号、转义双引号、CRLF/LF 行尾,避免一次性读入整个文件。</li> <li>去除 UTF-8 BOM;对重复列名自动唯一化,保证对象键无冲突。</li> </ul> </li> <li>分批与并发: <ul> <li>使用轻量并发控制器(Set + Promise.race)限制并发数,实现读入与上传的流水线式处理,减少内存占用。</li> <li>读入端仅在达成批次时才等待并发空位,有效平衡 I/O 与网络的吞吐。</li> </ul> </li> <li>可靠性: <ul> <li>对 5xx/429/网络错误进行指数退避重试,并优先尊重 Retry-After;加入抖动防止同步重试。</li> <li>将不可重试错误与超过重试上限的请求统一写入 errors.ndjson(逐记录),提升可观测性和后续补偿操作的便利性。</li> <li>成功写入 success.ndjson(逐记录),若数据量极大可调整为批级记录以降低 I/O。</li> </ul> </li> <li>取消与背压: <ul> <li>将 AbortSignal 贯穿 CSV 读取、sleep、fetch;任何阶段取消都会快速失败。</li> <li>NDJSON 写入考虑背压,使用 drain 事件进行 await,避免内存暴涨。</li> </ul> </li> <li>统计与进度: <ul> <li>提供 linesRead、recordsProcessed、批次派发/完成/成功/失败等指标;默认在派发批次、完成批次与每 1000 行时进行回调。</li> </ul> </li> <li>兼容性与安全: <ul> <li>使用 Node.js 内置 fetch(Node >= 18),无第三方依赖。</li> <li>避免使用弃用 API;严格校验输入参数,防止错误使用。</li> <li>HTTP 请求限定为 POST JSON,设置合理的 Accept/Content-Type,避免注入风险。</li> </ul> </li> <li>可扩展建议: <ul> <li>如果服务端支持部分成功反馈,可在成功回包中解析并区分记录级成功/失败,细化 success/errors 记录。</li> <li>如需严格内存控制,可在并发达到上限时暂停文件读取(当前设计通过等待最早完成任务达到类似效果;必要时可接入 stream.pause/resume)。</li> </ul> </li> </ul> </div> </div> </div> </div> </div> </section> <div id="examplePopupModal" class="prompt-examples-v4-modal"> <div class="prompt-examples-v4-modal-content"> <div class="prompt-examples-v4-modal-header"> <h3 id="popupTitle">示例详情</h3> <button class="prompt-examples-v4-modal-close" onclick="closeExamplePopup()"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18" y1="6" x2="6" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line> </svg> </button> </div> <div class="prompt-examples-v4-modal-body"> <div id="popupContent" class="prompt-examples-v4-popup-content"></div> </div> </div> </div> <script> // 弹窗功能JavaScript function showExamplePopup(exampleIndex, title, buttonElement) { const modal = document.getElementById('examplePopupModal'); const popupTitle = document.getElementById('popupTitle'); const popupContent = document.getElementById('popupContent'); // 设置弹窗标题 popupTitle.textContent = title; // 获取对应示例的内容 const tabContent = document.getElementById('example-' + exampleIndex); const outputText = tabContent.querySelector('.prompt-examples-v4-output-text'); if (outputText) { // 克隆内容到弹窗中 popupContent.innerHTML = outputText.innerHTML; } else { popupContent.innerHTML = '<p>暂无内容</p>'; } // 显示弹窗 modal.classList.add('show'); document.body.style.overflow = 'hidden'; // 防止背景滚动 } function closeExamplePopup() { const modal = document.getElementById('examplePopupModal'); modal.classList.remove('show'); document.body.style.overflow = ''; // 恢复背景滚动 } // 点击背景关闭弹窗 document.addEventListener('DOMContentLoaded', function() { const modal = document.getElementById('examplePopupModal'); if (modal) { modal.addEventListener('click', function(e) { if (e.target === modal) { closeExamplePopup(); } }); } // ESC键关闭弹窗 document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { closeExamplePopup(); } }); }); </script> <script> document.addEventListener('DOMContentLoaded', function() { // Tab切换功能 const tabs = document.querySelectorAll('.prompt-examples-v4-tab'); const contents = document.querySelectorAll('.prompt-examples-v4-tab-content'); tabs.forEach(tab => { tab.addEventListener('click', function() { const targetId = this.getAttribute('data-tab'); // 移除所有active状态 tabs.forEach(t => t.classList.remove('active')); contents.forEach(c => c.classList.remove('active')); // 添加当前active状态 this.classList.add('active'); const targetContent = document.getElementById(targetId); if (targetContent) { targetContent.classList.add('active'); } }); }); }); </script> <section class="solved-problems-v4"> <div class="solved-problems-v4-container"> <h2 class="solved-problems-v4-title">解决的问题</h2> <div class="solved-problems-v4-content"> <div class="solved-problems-v4-item"> <div class="html-content-display solved-problems-v4-item-text"> <p>为前端/全栈/Node 开发者与技术团队提供一键式的异步函数生成体验:基于简单的任务描述,自动产出符合最佳实践的 async/await 代码、完善的错误处理、清晰的使用示例与关键说明;显著缩短开发与评审时间、降低线上故障风险、统一团队编码风格;覆盖数据获取、文件读写与服务请求等高频场景,帮助个人快速交付、团队高质量复用,并推动从试用到持续付费的转化。</p> </div> </div> </div> </div> </section> <style> .solved-problems-v4-item > .solved-problems-v4-item-text { background-color: transparent !important; border: none !important; padding: 0 !important; } </style> <div class="suitable-users-v4"> <div class="suitable-users-v4-container"> <h2 class="suitable-users-v4-title">适用用户</h2> <div class="suitable-users-v4-list"> <div class="suitable-users-v4-card" style="background-color: #E3F2FD;"> <div class="suitable-users-v4-card-content"> <h3 class="suitable-users-v4-card-title">前端工程师</h3> <p class="suitable-users-v4-card-description">在新页面快速搭建数据请求与渲染逻辑;为列表、图片等多源数据并发加载;为上传下载加入超时与重试;统一错误提示与空态处理。</p> </div> </div> <div class="suitable-users-v4-card" style="background-color: #F1F8E9;"> <div class="suitable-users-v4-card-content"> <h3 class="suitable-users-v4-card-title">Node.js后端开发</h3> <p class="suitable-users-v4-card-description">封装与外部服务的异步调用;为批量任务设置并发限制与队列;完善日志与异常分级;减少手写样板,缩短交付周期。</p> </div> </div> <div class="suitable-users-v4-card" style="background-color: #FFFDE7;"> <div class="suitable-users-v4-card-content"> <h3 class="suitable-users-v4-card-title">技术负责人/架构师</h3> <p class="suitable-users-v4-card-description">沉淀团队通用模板与约定;统一超时、重试、超限策略;把控代码可维护性与规范;显著降低线上故障率。</p> </div> </div> </div> </div> </div> <section class="feature-summary-v4"> <div class="feature-summary-v4-container"> <h2 class="feature-summary-v4-title">特征总结</h2> <div class="feature-summary-v4-content"> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">一键生成符合最佳实践的异步函数,内置注释与说明,直接拷贝可用。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">自动加上超时与重试策略,网络不稳也能稳妥返回,显著减少线上告警。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">内建错误分类与兜底提示,异常可追踪可定位,排查成本更可控更透明。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">支持并发与队列控制,批量请求不阻塞,稳定提升整体吞吐与响应速度。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">可按业务场景定制参数与模板,团队统一规范,跨项目快速复用落地。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">自动生成使用示例与调用说明,新人也能即刻上手,减少口头传授成本。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">针对数据获取、文件处理等常见任务,给出稳健方案,避免重复造轮子。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">结合边界条件检查与输入校验,预防隐形缺陷,降低不可预期风险。</span> </div> <div class="feature-summary-v4-item"> <span class="feature-summary-v4-bullet">•</span> <span class="feature-summary-v4-text">输出结构清晰、注释标准,代码易读易测,方便后续扩展与维护工作。</span> </div> </div> </div> </section> <section class="usage-guide-v4"> <div class="usage-guide-v4-container"> <h2 class="usage-guide-v4-title">如何使用购买的提示词模板</h2> <div class="usage-guide-v4-content"> <div class="usage-guide-v4-item"> <div class="usage-guide-v4-text"> <h4 class="usage-guide-v4-subtitle">1. 直接在外部 Chat 应用中使用</h4> <p class="usage-guide-v4-description">将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。</p> <div class="usage-guide-v4-icons"> <a href="https://chat.deepseek.com/" target="_blank" class="usage-guide-v4-icon-item"> <img src="https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/apiDefaultIconADI20240909773605e9f9b1.png" alt="DeepSeek" class="usage-guide-v4-icon"> </a> <a href="https://www.doubao.com/chat/" target="_blank" class="usage-guide-v4-icon-item"> <img src="https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/apiDefaultIconADI20240909336605e9c295.png" alt="豆包" class="usage-guide-v4-icon"> </a> <a href="https://www.tongyi.com/" target="_blank" class="usage-guide-v4-icon-item"> <img src="https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/apiDefaultIcon9f8a29a9-4e4f-49ab-ac0d-7985823a6f0c.png" alt="通义" class="usage-guide-v4-icon"> </a> <a href="https://www.kimi.com/" target="_blank" class="usage-guide-v4-icon-item"> <img src=" https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/LOGO_IMG/579c8d7f234543b2832c95c5ddb38515" alt="Kimi" class="usage-guide-v4-icon"> </a> <a href="https://chatgpt.com/" target="_blank" class="usage-guide-v4-icon-item"> <img src=" https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/LOGO_IMG/cc5204a95abd46b2b603c9c0101b4ef0" alt="ChatGPT" class="usage-guide-v4-icon"> </a> <a href="https://grok.com/" target="_blank" class="usage-guide-v4-icon-item"> <img src="https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/LOGO_IMG/b84a84ef3daf43649d3fdbee8f170992" alt="Grok" class="usage-guide-v4-icon"> </a> <a href="https://gemini.google.com/" target="_blank" class="usage-guide-v4-icon-item"> <img src="https://explinks-prod-apis.oss-cn-beijing.aliyuncs.com/LOGO_IMG/57840102146847898b70184c9ee87aff" alt="Gemini" class="usage-guide-v4-icon"> </a> </div> </div> </div> <div class="usage-guide-v4-item"> <div class="usage-guide-v4-text"> <h4 class="usage-guide-v4-subtitle">2. 发布为 API 接口调用</h4> <p class="usage-guide-v4-description">把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。 </div> </div> <div class="usage-guide-v4-item"> <div class="usage-guide-v4-text"> <h4 class="usage-guide-v4-subtitle">3. 在 MCP Client 中配置使用</h4> <p class="usage-guide-v4-description">在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。 </p> </div> </div> </div> </div> </section> </div> <div class="prompt-detail-v4-right"> <div class="prompt-info-right"> <div class="chip-list"> <a href="/search?biz_cat_slug=code_generation" class="chip chip-member"> 代码生成 </a> <a href="/search?ai_ability=text_to_text" class="chip"> 文生文 </a> <span class="chip">AI提示词</span> </div> <h1 class="prompt-actions-title">异步函数生成器</h1> <div class="meta-row"> <div class="author-info"> <img src="https://static.explinks.com/prompt/static/images/common/explinks_logo.jpg" alt="幂简官方" class="author-avatar"> <a href="" class="author-name">幂简官方</a> </div> <div class="stats-info"> <div class="stat-item"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1228 1024" width="16" height="16"><path d="M614.4 1024C276.48 1024 0 798.72 0 512S276.48 0 614.4 0s614.4 225.28 614.4 512-276.48 512-614.4 512z m0-102.4c286.72 0 512-184.32 512-409.6s-225.28-409.6-512-409.6S102.4 286.72 102.4 512s225.28 409.6 512 409.6z m0-153.6c-143.36 0-256-112.64-256-256s112.64-256 256-256 256 112.64 256 256-112.64 256-256 256z m0-102.4c87.04 0 153.6-66.56 153.6-153.6s-66.56-153.6-153.6-153.6-153.6 66.56-153.6 153.6 153.6 153.6z" fill="#8a8a8a"/></svg> <span>78</span> </div> <div class="stat-item"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024" width="16" height="16"><path d="M772.7 217.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z" fill="#8a8a8a"/><path d="M415.8 679.9c5.9 0 11.5-1.6 16.2-4.5l231.1-134.6c10.9-5.2 18.5-16.3 18.5-29.2 0-11.9-6.4-22.3-16-27.8L439.7 352.2c-5.8-6.7-14.4-10.9-23.9-10.9-17.6 0-31.8 14.4-31.8 32.1 0 0.6 0 1.2 0.1 1.8l-0.4 0.2 0.5 269c-0.1 1.1-0.2 2.2-0.2 3.4 0 17.7 14.3 32.1 31.8 32.1z" fill="#8a8a8a"/><path d="M909.8 306.6c-5.4-10.5-16.3-17.8-28.9-17.8-17.8 0-32.2 14.4-32.2 32.1 0 6 1.7 11.7 4.6 16.5l-0.1 0.1c26.9 52.4 42.1 111.8 42.1 174.7 0 211.6-171.6 383.2-383.2 383.2S128.8 723.8 128.8 512.2 300.4 129.1 512 129.1c62.5 0 121.5 15 173.6 41.5l0.2-0.4c4.6 2.6 10 4.1 15.7 4.1 17.8 0 32.2-14.4 32.2-32.1 0-13.1-7.9-24.4-19.3-29.4C653.6 81.9 584.9 64.5 512 64.5 264.7 64.5 64.3 265 64.3 512.2S264.7 959.9 512 959.9s447.7-200.4 447.7-447.7c0-74.1-18-144-49.9-205.6z" fill="#8a8a8a"/></svg> <span>6</span> </div> </div> <div class="update-time"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="16" height="16"><path d="M512 929.959184c-230.4 0-417.959184-187.559184-417.959184-417.959184s187.559184-417.959184 417.959184-417.959184 417.959184 187.559184 417.959184 417.959184-187.559184 417.959184-417.959184 417.959184z m0-794.122449c-207.412245 0-376.163265 168.75102-376.163265 376.163265s168.75102 376.163265 376.163265 376.163265 376.163265-168.75102 376.163265-376.163265-168.75102-376.163265-376.163265-376.163265z"/><path d="M718.367347 538.122449h-208.979592c-11.493878 0-20.897959-9.404082-20.897959-20.897959s9.404082-20.897959 20.897959-20.897959h208.979592c11.493878 0 20.897959 9.404082 20.897959 20.897959s-9.404082 20.897959-20.897959 20.897959z"/><path d="M509.387755 538.122449c-11.493878 0-20.897959-9.404082-20.897959-20.897959V256c0-11.493878 9.404082-20.897959 20.897959-20.897959s20.897959 9.404082 20.897959 20.897959v261.22449c0 11.493878-9.404082 20.897959-20.897959 20.897959z"/></svg> <span>Dec 8, 2025</span> </div> </div> <div class="intro-text"> 本提示词专为JavaScript开发场景设计,能够根据具体异步任务需求生成符合最佳实践的async/await函数代码。通过结构化参数输入,自动构建包含错误处理、性能优化和技术说明的高质量异步解决方案,适用于数据获取、文件操作、API调用等多种异步场景,帮助开发者快速实现可靠且易于维护的异步编程逻辑。 </div> <div class="content-wrapper" id="contentWrapper"> <div class="copy-box locked" id="contentBox"> <div class="copy-content" id="promptContentArea"> </div> <div class="copy-actions"> <a class="copy-btn hidden" id="copyBtn" href="javascript:void(0)" aria-label="复制提示词"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" aria-hidden="true" width="16" height="16"> <path fill="currentColor" d="M320 128h448v448H320z"/> <path fill="currentColor" d="M256 256h448v448H256z"/> </svg> <span>复制提示词</span> </a> <a class="view-content-btn" id="viewContentBtn" href="javascript:void(0)"> 查看提示词内容 </a> </div> <div class="copy-overlay" id="blurOverlay"></div> </div> <div class="content-fade-overlay hidden" id="contentFadeOverlay"></div> <a class="toggle-expand-btn hidden" id="toggleExpandBtn" href="javascript:void(0)">展开全部 ▼</a> </div> <a href="/user/vip" target="_blank" class="member-promo-card"> <div class="promo-text"> <div class="promo-title">成为会员,解锁全站资源</div> <div class="promo-subtitle">复制与查看不限次 · 持续更新权益</div> </div> <span class="promo-link-arrow">→</span> </a> </div> <script> document.addEventListener('DOMContentLoaded', function() { loadPromptContent(); }); async function loadPromptContent() { const slug = 'js_async_function_generator'; const contentArea = document.getElementById('promptContentArea'); const viewBtn = document.getElementById('viewContentBtn'); const copyBtn = document.getElementById('copyBtn'); const overlay = document.getElementById('blurOverlay'); const contentBox = document.getElementById('contentBox'); // 获取登录 token const token = (function() { const m = document.cookie.match(/(?:^|; )Admin-Token=([^;]+)/); return m ? decodeURIComponent(m[1]) : null; })(); const headers = { 'Content-Type': 'application/json' }; if (token) { headers['Authorization'] = token.indexOf('Bearer ') === 0 ? token : ('Bearer ' + token); } try { // 请求开始前禁用按钮 if (viewBtn) { viewBtn.style.pointerEvents = 'none'; viewBtn.style.opacity = '0.6'; viewBtn.textContent = '加载中...'; } const baseUrl = "https://prompts-api.explinks.com"; const response = await fetch(baseUrl + '/prompt-detail/content/' + slug, { method: 'POST', headers: headers, credentials: 'include' }); const data = await response.json(); if (data.success) { const content = data.data.content; const accessLevel = data.data.accessLevel; const loggedIn = data.data.loggedIn; contentArea.textContent = content; if (accessLevel === 'full') { // 完整权限 contentBox.classList.remove('locked'); overlay.style.display = 'none'; viewBtn.style.display = 'none'; copyBtn.classList.remove('hidden'); // 绑定复制事件 copyBtn.onclick = function(e) { e.preventDefault(); copyTextToClipboard(content, () => showToast('复制成功')); }; // 长内容折叠处理 const fadeOverlay = document.getElementById('contentFadeOverlay'); const toggleBtn = document.getElementById('toggleExpandBtn'); const MAX_COLLAPSED_HEIGHT = 200; requestAnimationFrame(function() { if (contentArea.scrollHeight > MAX_COLLAPSED_HEIGHT) { contentBox.classList.add('collapsed'); fadeOverlay.classList.remove('hidden'); toggleBtn.classList.remove('hidden'); let expanded = false; toggleBtn.onclick = function(e) { e.preventDefault(); expanded = !expanded; if (expanded) { contentBox.classList.remove('collapsed'); fadeOverlay.classList.add('hidden'); toggleBtn.textContent = '收起 ▲'; } else { contentBox.classList.add('collapsed'); fadeOverlay.classList.remove('hidden'); toggleBtn.textContent = '展开全部 ▼'; } }; } }); } else if (!loggedIn) { // 未登录:引导登录 contentBox.classList.add('locked'); overlay.style.display = 'block'; viewBtn.style.pointerEvents = 'auto'; viewBtn.style.opacity = '1'; viewBtn.textContent = '登录后查看完整内容'; viewBtn.href = 'javascript:void(0)'; viewBtn.onclick = function(e) { e.preventDefault(); if (typeof openLoginDialog === 'function') { openLoginDialog(function() { // 登录成功后重新加载内容 loadPromptContent(); }); } }; } else { // 已登录但无权限:引导开会员 contentBox.classList.add('locked'); overlay.style.display = 'block'; viewBtn.style.pointerEvents = 'auto'; viewBtn.style.opacity = '1'; viewBtn.textContent = '查看提示词内容'; viewBtn.href = '/user/vip'; viewBtn.onclick = null; // 清除之前的登录点击事件 } } } catch (e) { console.error('Failed to load content', e); // 请求失败,恢复按钮状态 if (viewBtn) { viewBtn.style.pointerEvents = 'auto'; viewBtn.style.opacity = '1'; viewBtn.textContent = '加载失败,重试'; viewBtn.onclick = function() { loadPromptContent(); }; } } } // Toast 提示 function showToast(message) { let toast = document.getElementById('toast-notification'); if (!toast) { toast = document.createElement('div'); toast.id = 'toast-notification'; toast.className = 'toast-notification'; toast.innerHTML = '<i class="fa fa-check-circle"></i> <span></span>'; document.body.appendChild(toast); } toast.querySelector('span').textContent = message; toast.classList.add('show'); if (toast.timeoutId) clearTimeout(toast.timeoutId); toast.timeoutId = setTimeout(() => { toast.classList.remove('show'); }, 2000); } // 复制内容 function copyTextToClipboard(text, onSuccess) { const handleSuccess = () => { if (onSuccess && typeof onSuccess === 'function') { onSuccess(); } }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(handleSuccess); } else { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-9999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); handleSuccess(); } catch (err) { console.error('无法复制', err); } document.body.removeChild(textArea); } } </script> </div> </div> </div> <!-- 相关推荐模块 --> <div class="member-cta-section"> <div class="member-cta-container"> <div class="member-cta-layout"> <div class="member-cta-left"> <div class="member-cta-title">提示词宝典 · 终身会员</div> <p class="member-cta-desc">一次支付永久解锁,全站资源与持续更新;商业项目无限次使用</p> <div class="member-cta-stats" aria-label="会员数据概览"> <div class="member-cta-stat"> <div class="stat-number">420 +</div> <div class="stat-label">品类</div> </div> <div class="member-cta-stat"> <div class="stat-number">8200 +</div> <div class="stat-label">模板数量</div> </div> <div class="member-cta-stat"> <div class="stat-number">17000 +</div> <div class="stat-label">会员数量</div> </div> </div> </div> <div class="member-cta-right"> <div class="member-cta-actions"> <a class="cta-btn-primary" href="/user/vip" target="_blank"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/> <path d="M9 12l2 2 4-4"/> </svg> 成为会员 </a> </div> </div> </div> </div> </div> <script src="https://static.explinks.com/prompt/static/js/home/zone-section.js?v=1772075947586"></script> <div id="footer"> <div class="footer-container"> <div class="footer-content"> <div class="footer-section"> <h5 class="footer-section-title"> 热门提示词</h5> <ul class="footer-section-links"> <li> <a href="https://prompts.explinks.com/quick_keyword_generator" target="_blank">快速关键词生成助手</a> </li> <li> <a href="https://prompts.explinks.com/content_strategy_guide" target="_blank">内容营销策略制定</a> </li> <li> <a href="https://prompts.explinks.com/rapid_test_scenario" target="_blank">快速测试场景生成器</a> </li> <li> <a href="https://prompts.explinks.com/novel_writing_guide" target="_blank">小说创作策略指南</a> </li> <li> <a href="https://prompts.explinks.com/seo_keyword_generator" target="_blank">SEO优化关键词生成助手</a> </li> <li> <a href="https://prompts.explinks.com/article_title_generator" target="_blank">文章标题生成器</a> </li> </ul> </div> <div class="footer-section"> <h5 class="footer-section-title"> 热门角色</h5> <ul class="footer-section-links"> <li> <a href="https://prompts.explinks.com/category/content_creator" target="_blank">内容创作者</a> </li> <li> <a href="https://prompts.explinks.com/category/developer" target="_blank">开发者</a> </li> <li> <a href="https://prompts.explinks.com/category/product_manager" target="_blank">产品经理</a> </li> <li> <a href="https://prompts.explinks.com/category/business_consultant" target="_blank">商业顾问</a> </li> <li> <a href="https://prompts.explinks.com/category/marketing_personnel" target="_blank">市场营销</a> </li> <li> <a href="https://prompts.explinks.com/category/business_owner" target="_blank">企业管理者</a> </li> <li> <a href="https://prompts.explinks.com/category/seo_expert" target="_blank">SEO专家</a> </li> <li> <a href="https://prompts.explinks.com/category/data_analyst" target="_blank">数据分析师</a> </li> </ul> </div> <div class="footer-section"> <h5 class="footer-section-title"> 热门业务</h5> <ul class="footer-section-links"> <li> <a href="https://prompts.explinks.com/category/developer?biz_cat_slug=code" target="_blank">代码</a> </li> <li> <a href="https://prompts.explinks.com/category/content_creator?biz_cat_slug=content_creation" target="_blank">内容创作</a> </li> <li> <a href="https://prompts.explinks.com/category/human_resources_personnel?biz_cat_slug=human_resources" target="_blank">人力资源</a> </li> <li> <a href="https://prompts.explinks.com/category/data_analyst?biz_cat_slug=data_analysis" target="_blank">数据分析</a> </li> <li> <a href="https://prompts.explinks.com/category/writer?biz_cat_slug=creative_writing" target="_blank">创意写作</a> </li> <li> <a href="https://prompts.explinks.com/category/illustrator?biz_cat_slug=art" target="_blank">艺术插画</a> </li> </ul> </div> <div class="footer-section"> <h5 class="footer-section-title"> 大模型API</h5> <ul class="footer-section-links"> <li> <a href="https://www.explinks.com/api/ai_deepseek_brand" target="_blank">DeepSeek</a> </li> <li> <a href="https://www.explinks.com/api/ai_openai_brand" target="_blank">OpenAI</a> </li> <li> <a href="https://www.explinks.com/api/ai_anthropic_brand" target="_blank">Claude</a> </li> <li> <a href="https://www.explinks.com/api/ai_gemini_brand" target="_blank">Gemini</a> </li> <li> <a href="https://www.explinks.com/api/ai_grok_brand" target="_blank">Grok</a> </li> <li> <a href="https://www.explinks.com/api/ai_tongyi_brand" target="_blank">Qwen</a> </li> </ul> </div> <div class="footer-section"> <h5 class="footer-section-title"> 使用我们的提示词工具</h5> <ul class="footer-section-links"> <li> <a target="_blank">提示词API化工具(敬请期待)</a> </li> <li> <a href="https://tools.explinks.com/prompt-generator" target="_blank">提示词应用工具</a> </li> <li> <a href="https://console.explinks.com/myHome/prompts" target="_blank">我的提示词库</a> </li> <li> <a href="https://prompts.explinks.com/packs/partners" target="_blank">加入分销计划,零成本获得收益</a> </li> </ul> </div> </div> <div class="footer-bottom"> <div class="footer-brand"> <div class="footer-brand-logo"> <a href="https://www.explinks.com/" target="_blank"> <figure class="footer-logo-wrapper"> <img decoding="async" src="https://cdn.explinks.com/wp-content/uploads/2023/12/image-e1703756327221.png" alt="幂简集成ICON" class="footer-logo-img"/> </figure> </a> </div> </div> <div class="footer-bottom-left"> <div class="footer-copyright"> <p>Copyright © 2024 All Rights Reserved <a href="https://www.explinks.com/company/about" target="_blank">北京蜜堂有信科技有限公司</a></p> </div> <div class="footer-address"> <p>公司地址: 北京市朝阳区光华路和乔大厦C座1508</p> </div> </div> <div class="footer-bottom-right"> <div class="footer-license-info"> <div class="footer-license-item"> <p>增值电信业务经营许可证:京B2-20191889</p> </div> <div class="footer-license-icon"> <img decoding="async" src="https://cdn.explinks.com/wp-content/uploads/2023/12/police.png" alt="icon" class="footer-police-icon"/> </div> <div class="footer-license-item"> <p><a href="https://beian.miit.gov.cn/" target="_blank" rel="nofollow">京ICP备18034931号-7</a></p> </div> </div> <div class="footer-feedback"> <p>意见反馈:010-53324933,mtyy@miitang.com</p> </div> </div> </div> </div> </div> <div class="sidebar-components-container"> <div class="sidebar"> <button class="sidebar-button" title="反馈问题" id="feedbackButton"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="10"/> <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <line x1="12" y1="17" x2="12" y2="17"/> </svg> </button> <button class="sidebar-button up-button" title="返回顶部"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M12 19V5"/> <path d="M5 12l7-7 7 7"/> </svg> </button> </div> <div class="toast" id="comingSoonToast"> <div class="toast-content"> <span>敬请期待...</span> <button class="toast-close">×</button> </div> </div> <div class="modal" id="feedbackModal"> <div class="modal-content"> <div class="modal-header"> <h3 class="modal-title">反馈问题</h3> <button class="modal-close">×</button> </div> <form id="feedbackForm"> <div class="form-group"> <label class="form-label" for="description">描述 <span class="required">*</span></label> <textarea class="form-control" id="description" maxlength="200" required placeholder="请在此描述您要反馈的问题(200字以内)"></textarea> </div> <div class="form-group"> <label class="form-label">截图</label> <div class="image-upload" id="imageUpload"> <input type="file" id="imageInput" multiple accept="image/*" style="display: none;"> <p>点击或拖拽图片到此处上传(最多5张)</p> </div> <div class="image-preview" id="imagePreview"></div> </div> <div class="form-group"> <label class="form-label" for="contact">联系方式</label> <input type="text" class="form-control" id="contact" placeholder="QQ/邮箱/任选其一"> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" id="cancelButton">取消</button> <button type="submit" class="btn btn-primary">确定</button> </div> </form> </div> </div> <script> document.addEventListener('DOMContentLoaded', () => { const upButton = document.querySelector('.up-button'); const toast = document.getElementById('comingSoonToast'); const toastClose = document.querySelector('.toast-close'); const feedbackButton = document.getElementById('feedbackButton'); const feedbackModal = document.getElementById('feedbackModal'); const modalClose = feedbackModal.querySelector('.modal-close'); const cancelButton = document.getElementById('cancelButton'); const feedbackForm = document.getElementById('feedbackForm'); const imageUpload = document.getElementById('imageUpload'); const imageInput = document.getElementById('imageInput'); const imagePreview = document.getElementById('imagePreview'); let toastTimeout; // 返回顶部按钮逻辑 window.addEventListener('scroll', () => { if (window.scrollY > 300) { upButton.classList.add('show'); } else { upButton.classList.remove('show'); } }); upButton.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); // 显示提示框函数 function showToast() { clearTimeout(toastTimeout); toast.classList.add('show'); toastTimeout = setTimeout(() => { toast.classList.remove('show'); }, 3000); } // 关闭提示框 toastClose.addEventListener('click', () => { toast.classList.remove('show'); clearTimeout(toastTimeout); }); // // 为三个按钮添加点击事件 // const comingSoonButtons = [ // document.querySelector('button[title="AI助理"]'), // document.querySelector('button[title="最近浏览"]'), // document.querySelector('button[title="对比列表"]') // ]; // // comingSoonButtons.forEach(button => { // button.addEventListener('click', showToast); // }); // 反馈模态框相关逻辑 function openModal() { feedbackModal.classList.add('show'); document.body.style.overflow = 'hidden'; } function closeModal() { feedbackModal.classList.remove('show'); document.body.style.overflow = ''; feedbackForm.reset(); imagePreview.innerHTML = ''; } feedbackButton.addEventListener('click', openModal); modalClose.addEventListener('click', closeModal); cancelButton.addEventListener('click', closeModal); // 点击模态框外部关闭 feedbackModal.addEventListener('click', (e) => { if (e.target === feedbackModal) { closeModal(); } }); // 图片上传相关逻辑 imageUpload.addEventListener('click', () => { imageInput.click(); }); imageInput.addEventListener('change', handleImageUpload); function handleImageUpload() { const files = Array.from(imageInput.files); const existingImages = imagePreview.querySelectorAll('.image-container'); const totalImages = existingImages.length + files.length; if (totalImages > 5) { alert('最多只能上传5张图片'); return; } files.forEach(file => { if (!file.type.startsWith('image/')) { return; } const reader = new FileReader(); reader.onload = (e) => { const container = document.createElement('div'); container.className = 'image-container'; const img = document.createElement('img'); img.src = e.target.result; container.appendChild(img); const deleteBtn = document.createElement('button'); deleteBtn.className = 'image-delete'; deleteBtn.innerHTML = '×'; deleteBtn.title = '删除图片'; deleteBtn.onclick = function () { if (confirm('确定要删除这张图片吗?')) { container.remove(); } }; container.appendChild(deleteBtn); imagePreview.appendChild(container); }; reader.readAsDataURL(file); }); } // 拖拽上传 imageUpload.addEventListener('dragover', (e) => { e.preventDefault(); imageUpload.style.borderColor = '#4a90e2'; }); imageUpload.addEventListener('dragleave', () => { imageUpload.style.borderColor = '#ddd'; }); imageUpload.addEventListener('drop', (e) => { e.preventDefault(); imageUpload.style.borderColor = '#ddd'; const files = Array.from(e.dataTransfer.files); if (files.length > 5) { alert('最多只能上传5张图片'); return; } imageInput.files = e.dataTransfer.files; handleImageUpload(); }); // 表单提交 feedbackForm.addEventListener('submit', async (e) => { e.preventDefault(); const description = document.getElementById('description').value.trim(); const contact = document.getElementById('contact').value.trim(); // if (!description || !contact) { if (!description) { alert('请填写反馈内容!'); return; } // 收集图片数据 const images = []; const imageElements = imagePreview.querySelectorAll('img'); imageElements.forEach(img => { images.push(img.src); }); // 准备要提交的数据 const formData = { description, contact, images }; try { const response = await fetch('https://api.explinks.com/feedback/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); if (response.ok) { alert('反馈提交成功!'); closeModal(); } else { throw new Error('提交失败'); } } catch (error) { alert('提交失败,请稍后重试!'); console.error('提交表单时出错:', error); } }); }); </script> </div> <script src="https://static.explinks.com/prompt/static/js/statistics/index.js?v=1772075947586"></script> <script src="https://static.explinks.com/hub/static/script/baidu-analytics.js?v=1772075947586"></script> <script charset="UTF-8" id="LA_COLLECT" src="//sdk.51.la/js-sdk-pro.min.js"></script> <script>LA.init({id: "3NS00J5GfuD7Tvg6", ck: "3NS00J5GfuD7Tvg6"})</script> <script> (function(){ var h = window.location.hostname; var d = ''; try { var p = h.split('.'); if (p.length >= 2) { d = '.' + p.slice(-2).join('.'); } } catch (e) {} var isIp = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h); var domainAttr = (!isIp && h !== 'localhost' && d) ? ('; domain=' + d) : ''; function getCookie(name){ var cs = document.cookie ? document.cookie.split('; ') : []; for (var i=0;i<cs.length;i++){ var parts = cs[i].split('='); var key = parts.shift(); var value = parts.join('='); if (key === name){ return decodeURIComponent(value || ''); } } return null; } function delCookie(name){ document.cookie = name + '=; path=/; Max-Age=0; SameSite=Lax' + domainAttr; } window.clearPromotionCookie = function(){ delCookie('promotion-code'); delCookie('promotion-code-set'); }; var setTs = getCookie('promotion-code-set'); var setNum = Number(setTs); if (getCookie('promotion-code') && setTs) { var ttl = 7200; document.cookie = 'promotion-code-set=' + encodeURIComponent(setTs) + '; path=/; Max-Age=' + ttl + '; SameSite=Lax' + domainAttr; } if (setTs && String(setNum) === setTs) { fetch('https://prompts-api.explinks.com/api/user/should-clear-promotion-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setTimeMillis: setTs, referer: document.referrer || '' }) }) .then(function(r){ return r ? r.json() : null; }) .then(function(res){ if (res && (res.success === true || res.code === 200) && (res.data === true || res.data === 'true')) { window.clearPromotionCookie(); } }) .catch(function(){}); } var u = new URL(window.location.href); var v = (u.searchParams.get('ref') || u.searchParams.get('REF') || '').split('?')[0].trim(); if (v) { (function(){ var api = 'https://prompts-api.explinks.com/api/user/validate-promotion-code'; var payload = { promotionCode: v, referer: document.referrer || '' }; fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(function(r){ return r ? r.json() : null; }) .then(function(res){ if (res && (res.success === true || res.code === 200) && (res.data === true || res.data === 'true')) { var ttl = 7200; var setTime = Date.now(); document.cookie = 'promotion-code=' + encodeURIComponent(v) + '; path=/; Max-Age=' + ttl + '; SameSite=Lax' + domainAttr; document.cookie = 'promotion-code-set=' + encodeURIComponent(String(setTime)) + '; path=/; Max-Age=' + ttl + '; SameSite=Lax' + domainAttr; } }) .catch(function(){}); })(); } })(); </script> <script src="https://static.explinks.com/prompt/static/js/statistics/view-count.js?v=1772075947586"></script> <script> // 页面加载完成后自动记录浏览量 ViewCountStatistics.autoRecordViewCount('js_async_function_generator'); </script> <script src="https://static.explinks.com/prompt/static/js/prompt-detail-v4/countdown.js?v=1772075947586"></script> <!-- Highlight.js JavaScript --> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script> // 初始化代码高亮 document.addEventListener('DOMContentLoaded', function () { hljs.highlightAll(); }); </script> </body> </html>