(function (root, factory) {
  "use strict";
  const config = typeof module === "object" && module.exports
    ? require("./capture-config.js")
    : root.SDMCaptureConfig;
  const priceParser = typeof module === "object" && module.exports
    ? require("./price-parser.js")
    : root.SDMPriceParser;
  const api = factory(config, priceParser);
  if (typeof module === "object" && module.exports) module.exports = api;
  root.SDMAmazonParser = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function (config, priceParser) {
  "use strict";

  const { normalizeAsin, normalizeHost, siteForHost, canonicalProductUrl } = config;
  const HTML_ENTITIES = {
    amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " ",
    pound: "£", euro: "€", yen: "¥", ndash: "–", mdash: "—"
  };

  function clean(value) {
    return String(value == null ? "" : value).replace(/\s+/g, " ").trim();
  }

  function multiline(value) {
    return String(value == null ? "" : value)
      .replace(/\r\n?/g, "\n")
      .replace(/[ \t]+\n/g, "\n")
      .replace(/\n{3,}/g, "\n\n")
      .trim();
  }

  function unique(values) {
    return [...new Set((values || []).map(clean).filter(Boolean))];
  }

  function escapeRegExp(value) {
    return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  }

  function decodeHtml(value) {
    return String(value == null ? "" : value)
      .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
      .replace(/&#(\d+);?/g, (_, number) => String.fromCodePoint(parseInt(number, 10)))
      .replace(/&([a-z]+);/gi, (match, name) => HTML_ENTITIES[name.toLowerCase()] || match)
      .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
      .replace(/\\\//g, "/");
  }

  function stripTags(value, keepLines = false) {
    let text = String(value == null ? "" : value)
      .replace(/<script\b[\s\S]*?<\/script>/gi, " ")
      .replace(/<style\b[\s\S]*?<\/style>/gi, " ")
      .replace(/<(?:br|\/p|\/div|\/li|\/tr|\/h[1-6])\s*\/?>/gi, "\n")
      .replace(/<[^>]+>/g, " ");
    text = decodeHtml(text);
    return keepLines ? multiline(text) : clean(text);
  }

  function attributeValue(tag, name) {
    const escaped = escapeRegExp(name);
    const match = String(tag || "").match(new RegExp(`\\b${escaped}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i"));
    return match ? decodeHtml(match[2]) : "";
  }

  function blockById(html, id) {
    const escaped = escapeRegExp(id);
    const match = String(html || "").match(new RegExp(`<([a-z][\\w:-]*)\\b[^>]*\\bid\\s*=\\s*(["'])${escaped}\\2[^>]*>([\\s\\S]*?)<\\/\\1>`, "i"));
    return match ? match[3] : "";
  }

  function startTagById(html, id) {
    const escaped = escapeRegExp(id);
    return String(html || "").match(new RegExp(`<[^>]+\\bid\\s*=\\s*(["'])${escaped}\\1[^>]*>`, "i"))?.[0] || "";
  }

  function inputValue(html, names) {
    for (const name of names) {
      const escaped = escapeRegExp(name);
      const tags = String(html || "").match(/<input\b[^>]*>/gi) || [];
      for (const tag of tags) {
        const tagName = attributeValue(tag, "name") || attributeValue(tag, "id");
        if (new RegExp(`^${escaped}$`, "i").test(tagName)) {
          const value = attributeValue(tag, "value");
          if (value) return value;
        }
      }
    }
    return "";
  }

  function metaContent(html, key) {
    const tags = String(html || "").match(/<meta\b[^>]*>/gi) || [];
    const wanted = String(key || "").toLowerCase();
    for (const tag of tags) {
      const identity = (attributeValue(tag, "property") || attributeValue(tag, "name") || attributeValue(tag, "itemprop")).toLowerCase();
      if (identity === wanted) return attributeValue(tag, "content");
    }
    return "";
  }

  function canonicalHref(html) {
    const tags = String(html || "").match(/<link\b[^>]*>/gi) || [];
    for (const tag of tags) {
      if (attributeValue(tag, "rel").toLowerCase() === "canonical") return attributeValue(tag, "href");
    }
    return "";
  }

  function balancedAt(source, start) {
    const opener = source[start];
    const closer = opener === "{" ? "}" : opener === "[" ? "]" : "";
    if (!closer) return "";
    let depth = 0;
    let quote = "";
    let escaped = false;
    for (let index = start; index < source.length; index++) {
      const char = source[index];
      if (quote) {
        if (escaped) escaped = false;
        else if (char === "\\") escaped = true;
        else if (char === quote) quote = "";
        continue;
      }
      if (char === '"' || char === "'") quote = char;
      else if (char === opener) depth += 1;
      else if (char === closer && --depth === 0) return source.slice(start, index + 1);
    }
    return "";
  }

  function balancedValue(source, token) {
    const at = source.indexOf(token);
    if (at < 0) return "";
    const colon = source.indexOf(":", at + token.length);
    if (colon < 0 || colon - at > 140) return "";
    const objectAt = source.indexOf("{", colon + 1);
    const arrayAt = source.indexOf("[", colon + 1);
    let start = -1;
    if (objectAt >= 0 && arrayAt >= 0) start = Math.min(objectAt, arrayAt);
    else start = Math.max(objectAt, arrayAt);
    if (start < 0 || start - colon > 80) return "";
    return balancedAt(source, start);
  }

  function parseJsonValue(source, tokens) {
    for (const token of tokens) {
      const raw = balancedValue(source, token);
      if (!raw) continue;
      try { return JSON.parse(raw); } catch {}
    }
    return null;
  }

  function fullImageUrl(value) {
    const url = decodeHtml(value).replace(/\\u0026/gi, "&").replace(/\\u003d/gi, "=").replace(/\\\//g, "/");
    if (!/^https?:\/\//i.test(url)) return "";
    return url.replace(/\._[^/]+_(?=\.(?:jpe?g|png|webp)(?:\?|$))/i, "");
  }

  function extractImages(html) {
    const images = [];
    const add = value => {
      const url = fullImageUrl(value);
      if (url && !images.includes(url)) images.push(url);
    };
    const imageKeyPattern = /["'](?:hiRes|large|mainUrl|landingImageUrl)["']\s*:\s*["'](https?:\\?\/\\?\/[^"']+)["']/gi;
    let match;
    // Product pages contain recommendation/ad widgets with the same hiRes and
    // colorImages property names as the main gallery.  Only scan scripts that
    // identify themselves as the product image block (or a plain variation
    // payload); otherwise unrelated products can inflate imageCount and stop
    // the rendered-page fallback from running.
    const scripts = String(html || "").match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || [];
    const ownedScripts = scripts.filter(script => {
      if (/recommend|related|sims|carousel|adplacements|sp_detail/i.test(script)) return false;
      return /ImageBlock(?:ATF|BTF)|imageBlockVariations|["']colorImages["']/i.test(script);
    });
    for (const script of ownedScripts) {
      imageKeyPattern.lastIndex = 0;
      while ((match = imageKeyPattern.exec(script))) add(match[1]);
    }

    const imageTags = String(html || "").match(/<img\b[^>]*>/gi) || [];
    for (const tag of imageTags) {
      const isProductImage = /\bid=["'](?:landingImage|imgBlkFront)["']/i.test(tag);
      if (!isProductImage) continue;
      const dynamic = attributeValue(tag, "data-a-dynamic-image");
      if (dynamic) {
        try { Object.keys(JSON.parse(dynamic)).forEach(add); } catch {}
      }
      add(attributeValue(tag, "data-old-hires"));
      add(attributeValue(tag, "src"));
    }
    add(metaContent(html, "og:image"));
    return images.slice(0, 20);
  }

  function rowsFromTables(html) {
    const rows = [];
    const rowMatches = String(html || "").match(/<tr\b[^>]*>[\s\S]*?<\/tr>/gi) || [];
    for (const rowHtml of rowMatches) {
      const cells = [...rowHtml.matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/gi)].map(match => stripTags(match[1]));
      if (cells.length >= 2) rows.push({ label: clean(cells[0]).replace(/[:：]\s*$/, ""), value: clean(cells.slice(1).join(" ")) });
    }
    const bullets = blockById(html, "detailBullets_feature_div") || blockById(html, "detailBulletsWrapper_feature_div");
    const items = bullets.match(/<li\b[^>]*>[\s\S]*?<\/li>/gi) || [];
    for (const item of items) {
      const text = stripTags(item);
      const parts = text.split(/[:：]/);
      if (parts.length >= 2) rows.push({ label: clean(parts.shift()), value: clean(parts.join(":")) });
    }
    return rows;
  }

  function rowValue(rows, patterns) {
    const match = rows.find(row => patterns.some(pattern => pattern.test(row.label)));
    return match ? match.value : "";
  }

  function extractBrand(html, rows) {
    const fromRows = rowValue(rows, [/^brand$/i, /^品牌$/i, /^marke$/i, /^marque$/i, /^marca$/i]);
    if (fromRows) return fromRows;
    const byline = stripTags(blockById(html, "bylineInfo"));
    return clean(byline
      .replace(/^Visit the\s+/i, "")
      .replace(/\s+Store$/i, "")
      .replace(/^Brand\s*[:：]\s*/i, "")
      .replace(/^品牌\s*[:：]\s*/i, ""));
  }

  function cleanTitle(rawTitle, brand) {
    let title = clean(rawTitle);
    if (brand) {
      const escaped = escapeRegExp(brand);
      title = title.replace(new RegExp(`^(?:${escaped})\\s*[-|:：–—]?\\s*`, "i"), "");
    }
    return clean(title);
  }

  function extractBulletPoints(html) {
    const block = blockById(html, "feature-bullets") || blockById(html, "productFactsDesktopExpander");
    const bullets = [...block.matchAll(/<li\b[^>]*>[\s\S]*?(?:class=["'][^"']*a-list-item[^"']*["'][^>]*>([\s\S]*?)<\/span>|<\/li>)/gi)]
      .map(match => stripTags(match[1] || match[0]))
      .filter(value => value && !/make sure this fits|see more product details/i.test(value));
    return unique(bullets).slice(0, 10);
  }

  function extractProductHighlight(html, rows = []) {
    const titleSection = blockById(html, "titleSection");
    if (titleSection) {
      const withoutTitle = titleSection.replace(/<([a-z][\w:-]*)\b[^>]*\bid\s*=\s*["']productTitle["'][^>]*>[\s\S]*?<\/\1>/i, "");
      const text = stripTags(withoutTitle);
      if (text && text.length >= 15 && text.length <= 300) return text;
    }
    return rowValue(rows, [
      /^product highlights?$/i, /^special features?$/i, /^key features?$/i,
      /^produ[ck]tmerkmale$/i, /^caract[eé]ristiques sp[eé]ciales?$/i,
      /^caracter[ií]sticas especiales?$/i, /^caratteristiche speciali$/i,
      /^产品亮点$/i, /^特殊功能$/i,
    ]);
  }

  function normalizeDimensionUnit(value) {
    const unit = clean(value).toLowerCase().replace(/\./g, "");
    if (/^(?:mm|millimet(?:er|re)s?)$/.test(unit)) return "millimeters";
    if (/^(?:cm|centimet(?:er|re)s?)$/.test(unit)) return "centimeters";
    if (/^(?:m|met(?:er|re)s?)$/.test(unit)) return "meters";
    if (/^(?:in|inch|inches)$/.test(unit)) return "inches";
    if (/^(?:ft|foot|feet)$/.test(unit)) return "feet";
    return "";
  }

  function parseItemDimensions(value) {
    const text = clean(value);
    const unitMatch = text.match(/\b(mm|millimet(?:er|re)s?|cm|centimet(?:er|re)s?|m|met(?:er|re)s?|in|inch(?:es)?|ft|foot|feet)\.?\b/i);
    const unit = normalizeDimensionUnit(unitMatch?.[1]);
    if (!unit) return null;
    const labeled = {};
    for (const match of text.matchAll(/(\d+(?:[.,]\d+)?)\s*([DLWH])(?=\s*(?:x|×|by|\b))/gi)) {
      const valueNumber = Number(match[1].replace(",", "."));
      const axis = match[2].toUpperCase();
      if (valueNumber > 0) labeled[axis === "D" || axis === "L" ? "depth" : axis === "W" ? "width" : "height"] = valueNumber;
    }
    if (labeled.depth && labeled.width && labeled.height) return { ...labeled, unit, source: text };
    const plain = text.match(/^\s*(\d+(?:[.,]\d+)?)\s*(?:x|×|by)\s*(\d+(?:[.,]\d+)?)\s*(?:x|×|by)\s*(\d+(?:[.,]\d+)?)/i);
    if (!plain) return null;
    const numbers = plain.slice(1).map(number => Number(number.replace(",", ".")));
    return numbers.every(number => number > 0)
      ? { depth: numbers[0], width: numbers[1], height: numbers[2], unit, source: text }
      : null;
  }

  function extractedSpecifications(rows) {
    const seen = new Set();
    return rows.filter(row => {
      const key = `${clean(row.label).toLowerCase()}|${clean(row.value).toLowerCase()}`;
      if (!row.label || !row.value || seen.has(key)) return false;
      seen.add(key);
      return true;
    }).slice(0, 100).map(row => ({ label: clean(row.label), value: clean(row.value) }));
  }

  function extractPhysicalSpecifications(rows) {
    const dimensionsText = rowValue(rows, [
      /^product dimensions?$/i, /^item dimensions?$/i, /^dimensions?$/i,
      /^produktabmessungen$/i, /^dimensions? (?:du produit|de l['’]article)$/i,
      /^dimensiones? (?:del producto|del art[ií]culo)$/i, /^dimensioni (?:del prodotto|dell['’]articolo)$/i,
      /^商品尺寸$/i, /^产品尺寸$/i,
    ]);
    return {
      specifications: extractedSpecifications(rows),
      dimensionsText,
      dimensions: parseItemDimensions(dimensionsText),
      material: rowValue(rows, [/^frame material$/i, /^base material$/i, /^material$/i, /^材质$/i, /^框架材质$/i]),
      color: rowValue(rows, [/^colou?r$/i, /^farbe$/i, /^couleur$/i, /^colore$/i, /^颜色$/i]),
      style: rowValue(rows, [/^style$/i, /^stil$/i, /^stile$/i, /^estilo$/i, /^风格$/i]),
      size: rowValue(rows, [/^size$/i, /^gr[oö][ßs]e$/i, /^taille$/i, /^taglia$/i, /^tama[nñ]o$/i, /^尺寸$/i]),
    };
  }

  function numberValue(value) {
    const match = clean(value).replace(/[\s,]/g, "").match(/-?\d+(?:\.\d+)?/);
    return match ? Number(match[0]) : "";
  }

  function integerValue(value) {
    const match = clean(value).replace(/[^\d]/g, "");
    return match ? Number(match) : "";
  }

  function currencyFromText(text, fallback) {
    const value = String(text || "");
    if (/CA\$|CDN\$/i.test(value)) return "CAD";
    if (/AU\$/i.test(value)) return "AUD";
    if (/MX\$/i.test(value)) return "MXN";
    if (/R\$/i.test(value)) return "BRL";
    if (/€/.test(value)) return "EUR";
    if (/£/.test(value)) return "GBP";
    if (/￥|¥/.test(value)) return fallback === "CNY" ? "CNY" : "JPY";
    if (/₹/.test(value)) return "INR";
    return fallback || "USD";
  }

  function extractPrice(html, site) {
    const amountMeta = metaContent(html, "product:price:amount");
    const currencyMeta = metaContent(html, "product:price:currency");
    if (amountMeta) return { price: priceParser.parseMoney(amountMeta, currencyMeta || site.currency), currency: currencyMeta || site.currency };
    const ids = ["twister-plus-price-data-price", "priceblock_ourprice", "priceblock_dealprice", "priceblock_saleprice"];
    let text = "";
    for (const id of ids) {
      text = stripTags(blockById(html, id));
      if (text) break;
    }
    if (!text) {
      const match = html.match(/class=["'][^"']*(?:a-offscreen|priceToPay)[^"']*["'][^>]*>([^<]+)</i)
        || html.match(/["']priceAmount["']\s*:\s*([\d.,]+)/i);
      text = match?.[1] || "";
    }
    const currency = currencyMeta || currencyFromText(text, site.currency);
    return { price: priceParser.parseMoney(text, currency), currency };
  }

  function extractSeller(html) {
    const sellerTag = startTagById(html, "sellerProfileTriggerId");
    const sellerName = stripTags(blockById(html, "sellerProfileTriggerId")) || stripTags(blockById(html, "merchant-info"));
    const href = attributeValue(sellerTag, "href");
    const idMatch = `${href} ${html}`.match(/(?:[?&](?:seller|me)=|["']merchantId["']\s*:\s*["'])([A-Z0-9]{8,20})/i);
    const countText = stripTags(blockById(html, "olp_feature_div")) || stripTags(blockById(html, "buybox-see-all-buying-choices"));
    const countMatch = countText.match(/(\d[\d,.]*)\s+(?:new|used|offers?|sellers?|卖家|angebote|offres?)/i);
    return {
      sellerId: idMatch?.[1] || "",
      sellerName: clean(sellerName.replace(/ships from[\s\S]*$/i, "")),
      sellerCount: countMatch ? integerValue(countMatch[1]) : ""
    };
  }

  function extractCategories(html) {
    const block = blockById(html, "wayfinding-breadcrumbs_feature_div") || blockById(html, "wayfinding-breadcrumbs_container");
    const breadcrumbs = [];
    let categoryId = "";
    for (const match of block.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
      const value = stripTags(match[2]);
      if (value) breadcrumbs.push(value);
      categoryId = attributeValue(match[1], "href").match(/[?&]node=([^&#]+)/i)?.[1] || categoryId;
    }
    return { breadcrumbs: unique(breadcrumbs), categoryId };
  }

  function extractRanks(rows) {
    const value = rowValue(rows, [
      /best sellers rank/i, /bestseller-rang/i, /clasificaci[oó]n/i, /classement/i,
      /posizione nella classifica/i, /売れ筋ランキング/i, /bestsellerlijst/i,
      /ranking najlepiej/i, /rangordning/i
    ]);
    if (!value) return [];
    const ranks = [];
    const pattern = /(?:#|Nr\.?\s*|n[º°]\s*|位\s*)?([\d.,]+)\s*(?:in|en|dans|i|w|位)?\s*([^#\n(]+?)(?=(?:#|Nr\.?|n[º°]|$))/gi;
    let match;
    while ((match = pattern.exec(value))) {
      const rank = integerValue(match[1]);
      const category = clean(match[2]);
      if (rank && category) ranks.push({ rank, category });
    }
    if (!ranks.length) {
      const rank = integerValue(value.match(/[\d.,]+/)?.[0]);
      if (rank) ranks.push({ rank, category: clean(value.replace(/[\d#.,]+/, "")) });
    }
    return ranks.slice(0, 8);
  }

  function isColorDimension(name) {
    if (/colou?r|\u989c\u8272|\u984f\u8272|farbe|couleur|colore|coloris|kleur/i.test(clean(name))) return true;
    return /colou?r|颜色|顏色|farbe|couleur|colore|coloris|kleur/i.test(clean(name));
  }

  function canonicalVariantDimensions(dimensionNames, values) {
    const names = Array.isArray(dimensionNames) ? dimensionNames.map(clean) : [];
    const normalizedValues = Array.isArray(values) ? values.map(clean) : [];
    const colors = [], secondary = [], attributes = {};
    normalizedValues.forEach((value, index) => {
      if (!value) return;
      const name = names[index] || `dimension_${index + 1}`;
      attributes[name] = value;
      (isColorDimension(name) ? colors : secondary).push(value);
    });
    return { color: unique(colors).join(" / "), size: unique(secondary).join(" / "), attributes };
  }

  function extractVariantData(html, selectedAsin) {
    const display = parseJsonValue(html, ['"dimensionValuesDisplayData"', "'dimensionValuesDisplayData'", "dimensionValuesDisplayData"]);
    const dimensions = parseJsonValue(html, ['"dimensions"', "'dimensions'", "dimensions"]);
    const parentAsin = normalizeAsin(
      html.match(/["']parentAsin["']\s*:\s*["']([A-Z0-9]{10})["']/i)?.[1]
      || inputValue(html, ["parentASIN", "parentAsin"])
    );
    const rows = [];
    if (display && typeof display === "object") {
      const dimensionNames = Array.isArray(dimensions) ? dimensions.map(clean) : [];
      for (const [rawAsin, rawValues] of Object.entries(display)) {
        const asin = normalizeAsin(rawAsin);
        const values = Array.isArray(rawValues) ? rawValues.map(clean) : [];
        if (!asin) continue;
        const canonical = canonicalVariantDimensions(dimensionNames, values);
        rows.push({
          asin,
          parentAsin: parentAsin || selectedAsin,
          color: canonical.color,
          size: canonical.size,
          attributes: canonical.attributes,
          options: values,
          selected: asin === selectedAsin,
          relationshipType: "variation"
        });
      }
    }
    const selectedRow = rows.find(row => row.selected);
    const currentColor = stripTags(blockById(html, "variation_color_name")).replace(/^.*[:：]\s*/, "")
      || html.match(/["']color_name["']\s*:\s*["']([^"']+)["']/i)?.[1] || selectedRow?.color || "";
    const currentSize = stripTags(blockById(html, "variation_size_name")).replace(/^.*[:：]\s*/, "")
      || html.match(/["']size_name["']\s*:\s*["']([^"']+)["']/i)?.[1] || selectedRow?.size || "";
    return { parentAsin, variantRows: rows, currentColor: clean(currentColor), currentSize: clean(currentSize) };
  }

  function fulfillmentType(html) {
    const text = stripTags([
      blockById(html, "fulfillerInfoFeature_feature_div"),
      blockById(html, "merchant-info"),
      blockById(html, "tabular-buybox"),
      blockById(html, "desktop_qualifiedBuyBox")
    ].join(" "));
    const soldByAmazon = /sold by\s+amazon|vendu par\s+amazon|verkauf durch\s+amazon|販売元\s*amazon/i.test(text);
    const shipsByAmazon = /ships from\s+amazon|dispatches from\s+amazon|fulfilled by amazon|expédié par amazon|versand durch amazon|amazon.co.jp が発送/i.test(text);
    if (soldByAmazon) return "Amazon";
    return shipsByAmazon ? "FBA" : "FBM";
  }

  function pageFailure(html, status) {
    const lower = String(html || "").toLowerCase();
    const hasProductIdentity = /id=["']producttitle["']|property=["']og:title["']|(?:id|name)=["']asin["']/i.test(lower);
    if (status === 404 || /sorry! we couldn['’]t find that page|page not found|looking for something/i.test(lower)) {
      return { code: "PRODUCT_NOT_FOUND", message: "商品不存在或页面已下架", retryable: false };
    }
    if (/validatecaptcha|captchacharacters|robot check|enter the characters you see below/i.test(lower)) {
      return { code: "CAPTCHA_REQUIRED", message: "亚马逊要求验证码，需在该站点完成验证后重试", retryable: false };
    }
    if (/continue shopping/.test(lower) && !/producttitle/.test(lower)) {
      return { code: "CONTINUE_SHOPPING", message: "亚马逊返回 Continue Shopping 页面", retryable: true };
    }
    if (/\/ap\/signin|authentication required|sign in to continue/.test(lower) && !/producttitle/.test(lower)) {
      return { code: "AMAZON_LOGIN_REQUIRED", message: "亚马逊登录状态失效，请登录后重试", retryable: false };
    }
    if (status === 429) return { code: "RATE_LIMITED", message: "亚马逊请求过于频繁", retryable: true };
    if (status >= 500 || (!hasProductIdentity && /service unavailable|sorry, something went wrong/.test(lower))) {
      return { code: "AMAZON_UNAVAILABLE", message: `亚马逊暂时不可用${status ? `（HTTP ${status}）` : ""}`, retryable: true };
    }
    return null;
  }

  function parseAmazonProduct(html, context = {}) {
    const source = String(html || "");
    const domain = normalizeHost(context.domain || context.url || "");
    const site = siteForHost(domain);
    if (!site) return { ok: false, code: "UNSUPPORTED_SITE", error: "不支持的亚马逊站点", retryable: false };
    const failure = pageFailure(source, Number(context.status || 0));
    if (failure) return { ok: false, ...failure, error: failure.message };

    const canonical = canonicalHref(source) || context.url || "";
    const asin = normalizeAsin(
      inputValue(source, ["ASIN", "asin"])
      || source.match(/id=["']twister-plus-asin["'][^>]*value=["']([A-Z0-9]{10})/i)?.[1]
      || context.asin
      || config.asinFromUrl(canonical)
      || source.match(/["']asin["']\s*:\s*["']([A-Z0-9]{10})["']/i)?.[1]
    );
    const rawTitle = stripTags(blockById(source, "productTitle")) || decodeHtml(metaContent(source, "og:title"));
    if (!asin || !rawTitle) {
      return { ok: false, code: "STRUCTURE_CHANGED", error: "未能从商品详情 HTML 识别 ASIN 或标题，页面结构可能已变化", retryable: false };
    }

    const rows = rowsFromTables(source);
    const brand = extractBrand(source, rows);
    const title = cleanTitle(rawTitle, brand);
    const images = extractImages(source);
    const bulletPoints = extractBulletPoints(source);
    const productHighlight = extractProductHighlight(source, rows);
    const physical = extractPhysicalSpecifications(rows);
    const description = stripTags(blockById(source, "productDescription"), true);
    const aPlusDescription = stripTags(
      blockById(source, "aplus")
      || blockById(source, "aplus_feature_div")
      || blockById(source, "aplusBrandStory_feature_div"),
      true
    );
    const price = extractPrice(source, site);
    const seller = extractSeller(source);
    const categories = extractCategories(source);
    const ranks = extractRanks(rows);
    const variants = extractVariantData(source, asin);
    const ratingText = stripTags(blockById(source, "acrPopover"))
      || attributeValue(startTagById(source, "acrPopover"), "title")
      || metaContent(source, "rating");
    const reviewText = stripTags(blockById(source, "acrCustomerReviewText"));
    const firstAvailableDate = rowValue(rows, [
      /date first available/i, /amazon.*since/i, /disponible.*partir/i, /prodotto.*amazon/i,
      /取り扱い開始日/i, /datum eerste beschikbaarheid/i, /första tillgängliga/i, /data pierwszej/i
    ]);
    const warnings = [];
    if (!images.length) warnings.push("未识别到高清商品图片");
    if (!bulletPoints.length) warnings.push("未识别到五点描述");
    if (price.price === "") warnings.push("未识别到价格");
    if (!description && !aPlusDescription) warnings.push("未识别到普通描述或 A+ 描述");
    const productUrl = canonicalProductUrl(domain, asin);
    const mainRank = ranks[0] || null;
    const subRank = ranks[1] || null;
    const product = {
      site: site.site,
      country: site.country,
      countries: [site.country],
      domain,
      asin,
      parentAsin: variants.parentAsin,
      url: productUrl,
      sourceUrl: productUrl,
      rawTitle: clean(rawTitle),
      cleanedTitle: title,
      title,
      productHighlight,
      specifications: physical.specifications,
      productDimensions: physical.dimensionsText,
      itemDepth: physical.dimensions?.depth || "",
      itemWidth: physical.dimensions?.width || "",
      itemHeight: physical.dimensions?.height || "",
      itemDimensionUnit: physical.dimensions?.unit || "",
      material: physical.material,
      color: physical.color,
      style: physical.style,
      brand,
      image: images[0] || "",
      images,
      bulletPoints,
      description,
      aPlusDescription,
      price: price.price,
      currency: price.currency,
      seller: seller.sellerName,
      sellerId: seller.sellerId,
      sellerName: seller.sellerName,
      sellerCount: seller.sellerCount,
      category: categories.breadcrumbs.at(-1) || "",
      categoryPath: categories.breadcrumbs.join("/"),
      categoryId: categories.categoryId,
      breadcrumbs: categories.breadcrumbs,
      ranks,
      mainRank,
      subRank,
      rating: numberValue(ratingText),
      reviews: integerValue(reviewText),
      firstAvailableDate,
      fulfillmentType: fulfillmentType(source),
      currentColor: variants.currentColor || physical.color,
      currentSize: variants.currentSize || physical.size,
      variants: variants.variantRows.map(row => ({
        ...row,
        value: [row.color, row.size].filter(Boolean).join(" / "),
        dimension: "组合变体",
        images: [],
        image: ""
      })),
      variantRows: variants.variantRows,
      captureSource: "易联跨境 Chrome 扩展后台详情采集",
      dataCompleteness: warnings.length ? "部分完整" : "完整",
      collectedAt: new Date().toISOString()
    };
    return { ok: true, product, warnings, partial: warnings.length > 0 };
  }

  return {
    clean, multiline, unique, decodeHtml, stripTags, fullImageUrl, pageFailure,
    parseAmazonProduct, extractVariantData, extractImages, rowsFromTables, parseItemDimensions
  };
});
