import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import readline from "node:readline/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";

import dotenv from "dotenv";
import OpenAI from "openai";
import { chromium } from "playwright";
import { resolveStageRuntimeFromEnv } from "../core/llm-env.js";
import { runWithLlmDebugLog } from "../core/llm-debug-log.js";

import {
  antiDetectPause,
  attachVerificationMonitor,
  humanClick,
  humanScrollPage,
  humanType,
  waitForManualVerificationIfNeeded,
  waitForVerificationClear,
  randomPause,
  stepPause,
} from "./human.js";
import { ensure1688LoggedIn } from "./ensure-1688-login.js";
import { build1688ContactPageUrls, extractDetectedContacts, hasDetectedPhoneContact, pushDetectedContact } from "./contact-utils.js";
import { normalizeOfferRecord } from "./offer-normalizer.js";
import {
  buildKeywordHitRateFallbackSampleBlocks,
  collectKeywordHitRateFallbackSamples,
  deriveContactEnrichmentSkipReason,
  shouldRunKeywordHitRateLlmFallback,
} from "./search-hit-rate-fallback.js";
import {
  getRequiredPayloadString,
  renderPromptTemplate,
  resolvePromptBundle,
} from "../internal/prompt-runtime.js";
import { keywordMatchesSearchResult } from "./search-hit-utils.js";
import {
  looksLikeLoginInterstitial,
  pickBestSearchInputCandidate,
  pickBestSearchSubmitCandidate,
} from "./search-page-utils.js";
import { buildSearchQueryVariants } from "./search-query-utils.js";
import { build1688OfferSearchUrl, replaceRawQueryParam } from "./search-results-url-utils.js";
import {
  extractSearchResultCandidatesFromDocument,
  looksLikeSearchResultTitle,
  sanitizeSearchResultTitle,
} from "./search-result-extractor.js";
import { scanOfferContactsByNavigation } from "./contact-page-navigation.js";
import {
  resolveCaptchaRetryBackoffMs,
  resolveCaptchaRetryLimit,
} from "./search-plan-retry.js";
import {
  getSearchVisionPromptRuntimeScope,
  resolveSearchVisionModel,
} from "./search-vision-model.js";
import {
  buildSearchBrowserArgs,
  installLowCpuRouteFilters,
  resolveSearchResourceProfile,
} from "./low-cpu-runtime.js";

const SCRIPT_FILE = fileURLToPath(import.meta.url);
const SCRIPT_DIR = path.dirname(SCRIPT_FILE);
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, "../..");
const AUTOMATION_DIR = path.join(PROJECT_ROOT, "automation");

function resolveProfileDir() {
  const explicit = process.env.SEARCH_1688_PROFILE_DIR ?? "";
  if (explicit) {
    return path.isAbsolute(explicit) ? explicit : path.resolve(PROJECT_ROOT, explicit);
  }
  return path.join(AUTOMATION_DIR, ".browser", "1688-profile");
}

dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });

const DEFAULT_INPUT_PATH = path.join(AUTOMATION_DIR, "output", "search_plan_preview.json");
const DEFAULT_OUTPUT_ROOT = path.join(AUTOMATION_DIR, "output", "search-plan");
const PROFILE_DIR = resolveProfileDir();
const SEARCH_HOME_URL = "https://www.1688.com/";

const DEFAULT_RESULT_LIMIT = Number.parseInt(process.env.SEARCH_RESULT_LIMIT ?? process.env.RESULT_LIMIT ?? "10", 10);
const DEFAULT_VISUAL_MODEL = resolveSearchVisionModel();
const DEFAULT_VISUAL_THRESHOLD = Number.parseFloat(process.env.SEARCH_VALID_THRESHOLD ?? "0.75");
const DEFAULT_DELAY_BETWEEN_ROUNDS = Number.parseInt(process.env.SEARCH_DELAY_BETWEEN_ROUNDS ?? "500", 10);
const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.SEARCH_TIMEOUT ?? "20000", 10);
const DEFAULT_LLM_TIMEOUT_MS = Number.parseInt(process.env.SEARCH_LLM_TIMEOUT_MS ?? "25000", 10);
const DEFAULT_LLM_RETRY_MAX_ATTEMPTS = Number.parseInt(process.env.LLM_RETRY_MAX_ATTEMPTS ?? "4", 10);
const DEFAULT_LLM_RETRY_BASE_DELAY_MS = Number.parseInt(process.env.LLM_RETRY_BASE_DELAY_MS ?? "1500", 10);
const DEFAULT_PAGE_SCREENSHOT_LIMIT = Number.parseInt(process.env.SEARCH_PAGE_SCREENSHOT_LIMIT ?? "1", 10);
const DEFAULT_CONTACT_LIMIT = Number.parseInt(process.env.SEARCH_CONTACT_LIMIT ?? "10", 10);
const DEFAULT_CONTACT_TIMEOUT_MS = Number.parseInt(process.env.SEARCH_CONTACT_TIMEOUT ?? "8000", 10);
const DEFAULT_REQUIRE_PHONE_CONTACT = (process.env.SEARCH_REQUIRE_PHONE_CONTACT ?? "1") !== "0";
const DEFAULT_VALIDATION_RESULT_LIMIT = Number.parseInt(process.env.SEARCH_VALIDATION_RESULT_LIMIT ?? "60", 10);
const DEFAULT_VALIDATION_SCROLL_STEPS = Number.parseInt(process.env.SEARCH_VALIDATION_SCROLL_STEPS ?? "12", 10);
const DEFAULT_IMAGE_SAMPLE_COUNT = Number.parseInt(process.env.SEARCH_IMAGE_SAMPLE_COUNT ?? "12", 10);
const DEFAULT_IMAGE_HASH_TIMEOUT_MS = Number.parseInt(process.env.SEARCH_IMAGE_HASH_TIMEOUT_MS ?? "1500", 10);
const DEFAULT_NAME_HIT_THRESHOLD = Number.parseFloat(process.env.SEARCH_NAME_HIT_THRESHOLD ?? "0.45");
const DEFAULT_MATERIAL_HIT_THRESHOLD = Number.parseFloat(process.env.SEARCH_MATERIAL_HIT_THRESHOLD ?? "0.18");
const DEFAULT_UNRELATED_RATIO_LIMIT = Number.parseFloat(process.env.SEARCH_UNRELATED_RATIO_LIMIT ?? "0.45");
const DEFAULT_IMAGE_SIMILARITY_THRESHOLD = Number.parseFloat(process.env.SEARCH_IMAGE_SIMILARITY_THRESHOLD ?? "0.35");
const DEFAULT_SEMANTIC_TITLE_COUNT = Number.parseInt(process.env.SEARCH_SEMANTIC_TITLE_COUNT ?? "10", 10);
const DEFAULT_HIT_RATE_LLM_FALLBACK = (process.env.SEARCH_HIT_RATE_LLM_FALLBACK ?? "1") !== "0";
const DEFAULT_HIT_RATE_LLM_SAMPLE_COUNT = Number.parseInt(process.env.SEARCH_HIT_RATE_LLM_SAMPLE_COUNT ?? "18", 10);
const DEFAULT_HIT_RATE_LLM_MIN_SAMPLE_COUNT = Number.parseInt(process.env.SEARCH_HIT_RATE_LLM_MIN_SAMPLE_COUNT ?? "8", 10);
function resolveFixedRuntimePageCount(value = process.env.SEARCH_FIXED_RUNTIME_PAGE_COUNT) {
  const parsed = Number(String(value ?? "").trim());
  return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, 3) : 2;
}
const DEFAULT_FIXED_RUNTIME_PAGE_COUNT = resolveFixedRuntimePageCount();
const DEFAULT_FIXED_RUNTIME_RESULTS_PER_PAGE = 60;
const DEFAULT_CAPTCHA_RETRY_LIMIT = resolveCaptchaRetryLimit(process.env.SEARCH_CAPTCHA_RETRY_LIMIT ?? "3");
const DEFAULT_CAPTCHA_RETRY_BACKOFF_MS = resolveCaptchaRetryBackoffMs(process.env.SEARCH_CAPTCHA_RETRY_BACKOFF_MS ?? "1500");
const DEFAULT_HEADLESS = (process.env.SEARCH_HEADLESS ?? "0") === "1";
const SEARCH_RESULT_EXTRACTOR_SOURCE = extractSearchResultCandidatesFromDocument.toString();
const SEARCH_CHILD_EVENT_PREFIX = "__SEARCH_EVENT__:";

const SEARCH_PROMPT_KEYS = {
  semanticTitleValidation: "automation.search.semantic_title_validation.bundle",
  keywordRegeneration: "automation.search.keyword_regeneration.bundle",
  hitRateFallback: "automation.search.hit_rate_fallback.bundle",
  pageReview: "automation.search.page_review.bundle",
  candidateFilter: "automation.search.candidate_filter.bundle",
  bestImageSelection: "automation.search.best_image_selection.bundle",
};

function getSearchVisionDebugContext(label, params = {}) {
  return {
    scope: "automation",
    stage: "search_vision",
    label,
    provider: process.env.SEARCH_VISION_LLM_PROVIDER || process.env.LLM_PROVIDER || "",
    model: params.model || DEFAULT_VISUAL_MODEL,
  };
}

function createSearchVisionCompletion(client, label, params) {
  return runWithLlmDebugLog(
    getSearchVisionDebugContext(label, { model: params?.model }),
    params,
    () => client.chat.completions.create(params),
  );
}

const MATERIAL_TERMS = [
  "304不锈钢",
  "316不锈钢",
  "不锈钢",
  "钢化夹胶玻璃",
  "夹胶玻璃",
  "钢化玻璃",
  "玻璃",
  "铝合金",
  "镀锌钢",
  "镀锌",
  "碳钢",
  "钢",
  "塑料",
  "PVC",
  "PE",
  "HDPE",
  "PP",
  "ABS",
  "聚氨酯",
  "橡胶",
  "铜",
  "铝",
  "木",
  "石材"
];

const KEYWORD_STOP_TERMS = ["含", "满足", "要求", "用于", "带", "配件", "通用", "定制"];

function getPromptRuntimeScope(model = DEFAULT_VISUAL_MODEL) {
  return getSearchVisionPromptRuntimeScope(model);
}

async function resolveSearchPrompt(promptKey, expectedContentFormat) {
  return resolvePromptBundle(promptKey, {
    expectedContentFormat,
    ...getPromptRuntimeScope(),
  });
}

// 命令行参数只暴露本次调试必需的几个开关，避免把旧脚本的全部环境变量搬进来。
function nowStamp() {
  return new Date().toISOString().replace(/[:.]/g, "-");
}

function formatTs() {
  return new Date().toLocaleString("zh-CN", { hour12: false });
}

function log(message) {
  console.log(`[${formatTs()}] ${message}`);
}

function emitSearchProgress(message, itemProgressPercent, softCapPercent = null) {
  if (!process.env.SEARCH_TASK_ID) {
    return;
  }

  console.log(`${SEARCH_CHILD_EVENT_PREFIX}${JSON.stringify({
    type: "progress_update",
    taskId: process.env.SEARCH_TASK_ID,
    materialId: process.env.SEARCH_TASK_CURRENT_MATERIAL_ID ?? "",
    materialName: process.env.SEARCH_TASK_CURRENT_MATERIAL_NAME ?? "",
    message,
    itemProgressPercent,
    softCapPercent,
    stageStartedAt: new Date().toISOString(),
  })}`);
}

function emitLoginSessionReady(page) {
  if (!process.env.SEARCH_TASK_ID) {
    return;
  }

  console.log(`${SEARCH_CHILD_EVENT_PREFIX}${JSON.stringify({
    type: "login_session_ready",
    taskId: process.env.SEARCH_TASK_ID,
    accountId: process.env.SEARCH_1688_ACCOUNT_ID ?? "",
    pageUrl: String(page?.url?.() ?? ""),
    detectedAt: new Date().toISOString(),
  })}`);
}

function applyResultLimit(items, limit) {
  if (!Array.isArray(items)) {
    return [];
  }

  return Number.isInteger(limit) && limit > 0 ? items.slice(0, limit) : items;
}

function normalizeOfferDetailUrl(url) {
  const normalizedUrl = normalizeUrl(url);
  if (!normalizedUrl) {
    return "";
  }

  try {
    const parsed = new URL(normalizedUrl);
    const offerIdMatch = parsed.pathname.match(/\/offer\/(\d+)\.html/i);
    if (offerIdMatch?.[1]) {
      return `${parsed.protocol}//${parsed.host}/offer/${offerIdMatch[1]}.html`;
    }

    const offerId = parsed.searchParams.get("offerId") || parsed.searchParams.get("id") || "";
    if (offerId) {
      return `https://detail.1688.com/offer/${offerId}.html`;
    }

    parsed.search = "";
    parsed.hash = "";
    return parsed.toString();
  } catch {
    return String(normalizedUrl).split(/[?#]/, 1)[0] ?? normalizedUrl;
  }
}

function buildFixedRuntimeDeferredResult(result) {
  return {
    ...result,
    title: normalizeSpace(result?.title ?? ""),
    offerUrl: normalizeOfferDetailUrl(result?.offerUrl ?? ""),
    contactPageUrl: result?.contactPageUrl ?? "",
    detectedContacts: [],
    offerScreenshotPath: result?.offerScreenshotPath ?? "",
    hasPhoneContact: false,
    qualificationStatus: "candidate",
    contactCollectionReason: "fixed_runtime_deferred",
  };
}

function resolveInterRoundDelayRange(baseMs) {
  if (!Number.isInteger(baseMs) || baseMs < 500 || baseMs > 2000) {
    return { min: 500, max: 2000 };
  }

  return {
    min: baseMs,
    max: Math.min(2000, baseMs + 900)
  };
}

function resolveScanLimit(totalCount, limit) {
  if (!Number.isInteger(totalCount) || totalCount <= 0) {
    return 0;
  }

  return Number.isInteger(limit) && limit > 0 ? Math.min(totalCount, limit) : totalCount;
}

function sanitizeFileName(value) {
  return String(value ?? "")
    .replace(/[\\/:*?"<>|]/g, "_")
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, 80);
}

function normalizeUrl(url) {
  if (!url) {
    return "";
  }

  if (url.startsWith("//")) {
    return `https:${url}`;
  }

  return url;
}

function isResultsUrl(url) {
  return url.includes("/offer_search.htm") || url.includes("/page/pcpps.html");
}

function normalizeKeywordToken(value) {
  return normalizeSpace(value).toLowerCase();
}

function dedupeList(values) {
  const seen = new Set();
  const output = [];

  for (const value of values) {
    const normalized = normalizeKeywordToken(value);
    if (!normalized || seen.has(normalized)) {
      continue;
    }

    seen.add(normalized);
    output.push(normalizeSpace(value));
  }

  return output;
}

function extractMaterialTermsFromKeywordText(text) {
  const normalized = normalizeSpace(text);
  return dedupeList(MATERIAL_TERMS.filter((term) => normalized.includes(term)));
}

function looksLikeSpecToken(token) {
  return /\d/.test(token) || /(mm|cm|m|米|kg|kg\/|㎡|m2|φ|Φ)/i.test(token);
}

function buildKeywordRules(searchRow) {
  const primaryTokens = String(searchRow.primary_keyword ?? "")
    .split(/\s+/)
    .map((item) => normalizeSpace(item))
    .filter(Boolean);
  const altTokens = String(searchRow.alt_keywords ?? "")
    .split(",")
    .slice(0, 6)
    .flatMap((item) => item.split(/\s+/))
    .map((item) => normalizeSpace(item))
    .filter(Boolean);

  const materialTerms = extractMaterialTermsFromKeywordText([String(searchRow.primary_keyword ?? ""), String(searchRow.alt_keywords ?? "")].join(" "));
  const nameKeywords = dedupeList([...primaryTokens, ...altTokens]).filter((token) => {
    if (token.length < 2) {
      return false;
    }

    if (KEYWORD_STOP_TERMS.some((term) => token.startsWith(term))) {
      return false;
    }

    if (looksLikeSpecToken(token)) {
      return false;
    }

    if (materialTerms.some((term) => token.includes(term) || term.includes(token))) {
      return false;
    }

    return true;
  });

  return {
    nameKeywords: nameKeywords.slice(0, 12),
    materialTerms
  };
}


  const SEARCH_SERVICE_ERROR_PATTERN = /500\s*网络错误|网络错误|请联系客服|错误id[:：]?\s*[a-z0-9]+/i;
  const SEARCH_INTERCEPT_PATTERN = /验证码拦截|拖动下方滑块完成验证|请按住滑块|通过验证以确保正常访问|x5step=1|\/punish\b/i;
  const ROUND_FAILURE_REASON = {
    captchaIntercept: "captcha_intercept",
    searchServiceError: "search_service_error",
    loginIntercept: "login_intercept",
    roundFailed: "round_failed",
  };
  const OFFER_LINK_SELECTOR = [
    'a[href*="offerId="]',
    'a[href*="detail.1688.com/offer/"]',
    'a[href*="detail.m.1688.com/page/index.html"]'
  ].join(', ');
  const OFFER_IMAGE_SELECTOR = [
    'a[href*="offerId="] img',
    'a[href*="detail.1688.com/offer/"] img',
    'a[href*="detail.m.1688.com/page/index.html"] img'
  ].join(', ');

  function isSearchServiceError(error) {
    const message = error instanceof Error ? error.message : String(error);
    return SEARCH_SERVICE_ERROR_PATTERN.test(message);
  }

  function isCaptchaInterceptError(error) {
    const message = error instanceof Error ? error.message : String(error);
    return SEARCH_INTERCEPT_PATTERN.test(message);
  }

  function classifyRoundFailureReason(error, currentUrl = "") {
    const message = error instanceof Error ? error.message : String(error);

    if (SEARCH_INTERCEPT_PATTERN.test(message)) {
      return ROUND_FAILURE_REASON.captchaIntercept;
    }

    if (SEARCH_SERVICE_ERROR_PATTERN.test(message)) {
      return ROUND_FAILURE_REASON.searchServiceError;
    }

    if ((typeof currentUrl === "string" && isLoginUrl(currentUrl)) || /登录|login/i.test(message)) {
      return ROUND_FAILURE_REASON.loginIntercept;
    }

    return ROUND_FAILURE_REASON.roundFailed;
  }

  async function closePageSafely(page) {
    if (!page || page.isClosed()) {
      return;
    }

    await waitForVerificationClear(page, { logPrefix: "[search-plan][关闭前验证码]" }).catch(() => false);

    const timeoutMs = 5000;

    await Promise.race([
      page.close().catch(() => {}),
      sleep(timeoutMs).then(() => {
        throw new Error(`page_close_timeout_${timeoutMs}`);
      })
    ]).catch(() => {});
  }

  async function waitForDomContentLoadedSafely(page, timeoutMs, label) {
    const waitTimeoutMs = Math.max(3000, Number.isInteger(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS);

    try {
      await page.waitForLoadState("domcontentloaded", { timeout: waitTimeoutMs });
      return true;
    } catch (error) {
      log(`等待页面就绪超时（${label}）: ${error instanceof Error ? error.message : String(error)}`);
      return false;
    }
  }

  async function readSearchServiceError(page, response = null) {
    let pageText = "";

    try {
      pageText = await page.locator("body").innerText({ timeout: 2000 });
    } catch {
      pageText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
    }

    const normalizedText = normalizeSpace(pageText);
    const matchedLine = normalizedText
      .split(/(?<=。)|\n/)
      .map((item) => normalizeSpace(item))
      .find((item) => SEARCH_SERVICE_ERROR_PATTERN.test(item));

    if (matchedLine) {
      return matchedLine;
    }

    if (response && !response.ok() && response.status() >= 500) {
      return `HTTP ${response.status()} 搜索页服务异常`;
    }

    return "";
  }

  async function readSearchInterceptError(page) {
    const pageUrl = String(page?.url?.() ?? "");
    let pageText = "";

    try {
      pageText = await page.locator("body").innerText({ timeout: 2000 });
    } catch {
      pageText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
    }

    const normalizedText = normalizeSpace(pageText);
    if (SEARCH_INTERCEPT_PATTERN.test(pageUrl) || SEARCH_INTERCEPT_PATTERN.test(normalizedText)) {
      const matchedLine = normalizedText
        .split(/(?<=。)|\n/)
        .map((item) => normalizeSpace(item))
        .find((item) => SEARCH_INTERCEPT_PATTERN.test(item));

      return matchedLine || "1688 搜索被验证码/风控拦截";
    }

    return "";
  }

  async function assertSearchPageReady(page, response = null) {
    const verificationResolved = await waitForVerificationClear(page, { logPrefix: "[search-plan][验证码]" });
    if (verificationResolved) {
      await waitForDomContentLoadedSafely(page, 5000, "captcha_clear");
    }

    const interceptError = await readSearchInterceptError(page);
    if (interceptError) {
      throw new Error(interceptError);
    }

    const serviceError = await readSearchServiceError(page, response);
    if (serviceError) {
      throw new Error(serviceError);
    }
  }
function extractKeywordTokens(primaryKeyword) {
  return String(primaryKeyword ?? "")
    .split(/\s+/)
    .map((item) => normalizeSpace(item))
    .filter((token) => token.length >= 2 && !looksLikeSpecToken(token));
}

/**
 * 当一个 token 整体未命中标题时，尝试找出其中的有效子串和干扰子串。
 * 例如「AB CD」→ 「CD」命中标题，「AB」是干扰项。
 */
function splitNoiseFromToken(token, titlesNormalized) {
  const normalized = normalizeKeywordToken(token);

  // 找出所有在标题中出现的子串（2字符以上），按长度降序
  const hittingSubstrings = [];
  for (let len = Math.min(normalized.length - 1, 6); len >= 2; len--) {
    for (let start = 0; start + len <= normalized.length; start++) {
      const sub = normalized.slice(start, start + len);
      if (titlesNormalized.some((t) => t.includes(sub))) {
        hittingSubstrings.push(sub);
      }
    }
  }

  if (hittingSubstrings.length === 0) {
    // 整个 token 都不在标题里，整体作为干扰词
    return { found: [], noise: token };
  }

  // 用最长命中子串标记有效部分，剩余部分为干扰
  const foundParts = [];
  let remaining = normalized;
  for (const sub of hittingSubstrings) {
    if (remaining.includes(sub)) {
      foundParts.push(sub);
      remaining = remaining.replace(sub, "");
    }
  }

  const noisePart = remaining.trim();
  return {
    found: foundParts,
    noise: noisePart.length >= 2 ? noisePart : null
  };
}

function fallbackSemanticInspection(searchRow, topResults) {
  const titles = topResults.map((item) => normalizeKeywordToken(item.title));
  const titleCount = titles.length;

  if (titleCount === 0) {
    return {
      inspected_title_count: 0,
      found_terms: [],
      interference_terms: [],
      noise_term_count: 0,
      noise_terms_detected: false,
      should_stop_progressive: false,
      reason: "no_titles",
      method: "semantic_title_fallback",
      rawResponse: ""
    };
  }

  const tokens = extractKeywordTokens(searchRow.primary_keyword);
  const foundTerms = [];
  const interferenceTerms = [];

  for (const token of tokens) {
    const normalizedToken = normalizeKeywordToken(token);
    const hitCount = titles.filter((title) => title.includes(normalizedToken)).length;
    if (hitCount > 0) {
      foundTerms.push(token);
    } else {
      // 整体未命中：尝试子串分析，找出有效部分和干扰部分
      const { found, noise } = splitNoiseFromToken(token, titles);
      foundTerms.push(...found);
      if (noise) {
        interferenceTerms.push(noise);
      }
    }
  }

  const dedupedFoundTerms = dedupeList(foundTerms);
  const dedupedInterferenceTerms = dedupeList(interferenceTerms);
  const noiseDetected = dedupedInterferenceTerms.length > 0 && dedupedFoundTerms.length > 0;

  return {
    inspected_title_count: titleCount,
    found_terms: dedupedFoundTerms,
    interference_terms: dedupedInterferenceTerms,
    noise_term_count: dedupedInterferenceTerms.length,
    noise_terms_detected: noiseDetected,
    should_stop_progressive: noiseDetected,
    reason: noiseDetected ? "semantic_noise_detected" : "semantic_noise_not_detected",
    method: "semantic_title_fallback",
    rawResponse: ""
  };
}

async function evaluateTopTitleSemantics(client, searchRow, topResults) {
  const titles = topResults.map((item) => normalizeSpace(item.title)).filter(Boolean);

  if (titles.length === 0) {
    return {
      ...fallbackSemanticInspection(searchRow, topResults),
      reason: "semantic_no_titles",
      method: "semantic_title_fallback",
      rawResponse: ""
    };
  }

  if (!client) {
    return {
      ...fallbackSemanticInspection(searchRow, topResults),
      reason: "semantic_client_missing",
      method: "semantic_title_fallback",
      rawResponse: ""
    };
  }

  const tokens = extractKeywordTokens(searchRow.primary_keyword);
  const promptBundle = await resolveSearchPrompt(
    SEARCH_PROMPT_KEYS.semanticTitleValidation,
    "chat_bundle",
  );
  const prompt = renderPromptTemplate(
    getRequiredPayloadString(promptBundle, "user_template"),
    {
      primary_keyword: searchRow.primary_keyword,
      keyword_tokens: tokens.join("、"),
      titles_inline: titles.map((title, index) => `${index + 1}. ${title}`).join(" | "),
    },
  );
  const systemPrompt = getRequiredPayloadString(promptBundle, "system");

  try {
    const completion = await createSearchVisionCompletion(client, "semanticTitleValidation", {
      model: DEFAULT_VISUAL_MODEL,
      temperature: 0,
      messages: [
        {
          role: "system",
          content: systemPrompt
        },
        {
          role: "user",
          content: prompt
        }
      ],
      response_format: { type: "json_object" }
    });

    const rawResponse = completion.choices?.[0]?.message?.content ?? "";
    const parsed = tryParseJsonObject(rawResponse);

    if (!parsed) {
      return {
        ...fallbackSemanticInspection(searchRow, topResults),
        reason: "semantic_parse_failed",
        method: "semantic_title_ai_fallback",
        rawResponse
      };
    }

    const interferenceTerms = Array.isArray(parsed.interference_terms)
      ? parsed.interference_terms.map((item) => normalizeSpace(String(item))).filter(Boolean)
      : [];

    return {
      inspected_title_count: titles.length,
      found_terms: Array.isArray(parsed.found_terms) ? parsed.found_terms.map((item) => normalizeSpace(String(item))).filter(Boolean) : [],
      interference_terms: interferenceTerms,
      noise_term_count: interferenceTerms.length,
      noise_terms_detected: Boolean(parsed.noise_terms_detected) || interferenceTerms.length > 0,
      should_stop_progressive: Boolean(parsed.should_stop_progressive),
      reason: String(parsed.reason ?? "semantic_title_result"),
      method: "semantic_title_ai",
      rawResponse
    };
  } catch (error) {
    return {
      ...fallbackSemanticInspection(searchRow, topResults),
      reason: "semantic_ai_error",
      method: "semantic_title_ai_fallback",
      rawResponse: error instanceof Error ? error.message : String(error)
    };
  }
}

async function regenerateKeywords(client, currentRow, previousRoundSummaries) {
  if (!client) {
    return {
      regenerated_keywords: [],
      blocked_terms: ["风亭"],
      reason: "llm_unavailable"
    };
  }

  const historyText = previousRoundSummaries
    .slice(-5)
    .map(
      (item) =>
        `轮次=${item.search_round}; 词=${item.primary_keyword}; 有效=${item.is_valid_search}; 前10标题语义=${item.semantic_reason || "无"}; 文本=${item.name_keyword_hit_rate}/${item.material_hit_rate}/${item.unrelated_ratio}`
    )
    .join("\n");

  const promptBundle = await resolveSearchPrompt(
    SEARCH_PROMPT_KEYS.keywordRegeneration,
    "chat_bundle",
  );
  const prompt = renderPromptTemplate(
    getRequiredPayloadString(promptBundle, "user_template"),
    {
      primary_keyword: currentRow.primary_keyword,
      history_text: historyText || "无",
    },
  );
  const systemPrompt = getRequiredPayloadString(promptBundle, "system");

  const completion = await createSearchVisionCompletion(client, "keywordRegeneration", {
    model: DEFAULT_VISUAL_MODEL,
    temperature: 0.1,
    messages: [
      {
        role: "system",
        content: systemPrompt
      },
      {
        role: "user",
        content: prompt
      }
    ],
    response_format: { type: "json_object" }
  });

  const rawResponse = completion.choices?.[0]?.message?.content ?? "";
  const parsed = tryParseJsonObject(rawResponse);

  if (!parsed) {
    return {
      regenerated_keywords: [],
      blocked_terms: ["风亭"],
      reason: "regeneration_parse_failed",
      rawResponse
    };
  }

  return {
    regenerated_keywords: Array.isArray(parsed.regenerated_keywords)
      ? dedupeList(parsed.regenerated_keywords.map((item) => normalizeSpace(String(item)))).slice(0, 3)
      : [],
    blocked_terms: Array.isArray(parsed.blocked_terms)
      ? dedupeList(parsed.blocked_terms.map((item) => normalizeSpace(String(item))))
      : [],
    reason: String(parsed.reason ?? "regenerated_keywords_ready"),
    rawResponse
  };
}

function buildDynamicSearchRows(materialId, nextRoundStart, keywords, existingKeywords) {
  const existing = new Set(existingKeywords.map((item) => normalizeSpace(item)));
  const uniqueKeywords = dedupeList(keywords).filter((item) => !existing.has(item));

  return uniqueKeywords.map((keyword, index) => ({
    material_id: materialId,
    primary_keyword: keyword,
    alt_keywords: uniqueKeywords.filter((item) => item !== keyword).join(", "),
    search_round: nextRoundStart + index,
    search_strategy: "regenerated_after_semantic_reject",
    keyword_source: "dynamic_ai_regenerated",
    confirmed: null,
    best_keyword: "",
    estimated_price_range: "",
    category: ""
  }));
}

function evaluateTextRules(searchRow, results) {
  const rules = buildKeywordRules(searchRow);
  const total = results.length;

  if (total === 0) {
    return {
      isValid: false,
      name_keyword_hit_rate: 0,
      material_hit_rate: 0,
      unrelated_ratio: 1,
      matched_name_count: 0,
      matched_material_count: 0,
      unrelated_count: 0,
      name_keywords: rules.nameKeywords,
      material_terms: rules.materialTerms,
      reason: "no_validation_results"
    };
  }

  let matchedNameCount = 0;
  let matchedMaterialCount = 0;
  let unrelatedCount = 0;

  for (const result of results) {
    const title = normalizeKeywordToken(result.title);
    const nameHit = rules.nameKeywords.some((keyword) => title.includes(normalizeKeywordToken(keyword)));
    const materialHit = rules.materialTerms.some((term) => title.includes(normalizeKeywordToken(term)));

    if (nameHit) {
      matchedNameCount += 1;
    }

    if (materialHit) {
      matchedMaterialCount += 1;
    }

    if (!nameHit && !materialHit) {
      unrelatedCount += 1;
    }
  }

  const nameKeywordHitRate = matchedNameCount / total;
  const materialHitRate = rules.materialTerms.length > 0 ? matchedMaterialCount / total : 1;
  const unrelatedRatio = unrelatedCount / total;
  const isValid =
    nameKeywordHitRate >= DEFAULT_NAME_HIT_THRESHOLD &&
    materialHitRate >= DEFAULT_MATERIAL_HIT_THRESHOLD &&
    unrelatedRatio <= DEFAULT_UNRELATED_RATIO_LIMIT;

  return {
    isValid,
    name_keyword_hit_rate: Number(nameKeywordHitRate.toFixed(2)),
    material_hit_rate: Number(materialHitRate.toFixed(2)),
    unrelated_ratio: Number(unrelatedRatio.toFixed(2)),
    matched_name_count: matchedNameCount,
    matched_material_count: matchedMaterialCount,
    unrelated_count: unrelatedCount,
    name_keywords: rules.nameKeywords,
    material_terms: rules.materialTerms,
    reason: isValid ? "text_rules_passed" : "text_rules_rejected"
  };
}

async function collectImageSimilarity(page, sampleCount = DEFAULT_IMAGE_SAMPLE_COUNT) {
  const imageLocators = page.locator(OFFER_IMAGE_SELECTOR);
  const total = Math.min(await imageLocators.count(), sampleCount);
  const snapshots = [];

  for (let index = 0; index < total; index += 1) {
    const locator = imageLocators.nth(index);
    const visible = await locator.isVisible().catch(() => false);

    if (!visible) {
      continue;
    }

    try {
      const buffer = await locator.screenshot({ type: "png", animations: "disabled", timeout: 3000 });
      snapshots.push(`data:image/png;base64,${buffer.toString("base64")}`);
    } catch {
      // 单张缩略图失败不影响整轮判断。
    }
  }

  if (snapshots.length < 4) {
    return {
      isValid: false,
      sample_count: snapshots.length,
      dominant_cluster_ratio: 0,
      reason: "insufficient_image_samples"
    };
  }

  const hashes = await page.evaluate(async ({ dataUrls, hashTimeoutMs }) => {
    function hammingDistance(left, right) {
      let distance = 0;
      for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
        if (left[index] !== right[index]) {
          distance += 1;
        }
      }
      return distance + Math.abs(left.length - right.length);
    }

    async function computeHash(dataUrl) {
      return new Promise((resolve, reject) => {
        const image = new Image();
        const timer = window.setTimeout(() => {
          image.onload = null;
          image.onerror = null;
          reject(new Error("image_hash_timeout"));
        }, hashTimeoutMs);

        image.onload = () => {
          window.clearTimeout(timer);
          const canvas = document.createElement("canvas");
          canvas.width = 8;
          canvas.height = 8;
          const context = canvas.getContext("2d", { willReadFrequently: true });

          if (!context) {
            reject(new Error("canvas_context_unavailable"));
            return;
          }

          context.drawImage(image, 0, 0, 8, 8);
          const { data } = context.getImageData(0, 0, 8, 8);
          const grayValues = [];

          for (let index = 0; index < data.length; index += 4) {
            const gray = Math.round(data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114);
            grayValues.push(gray);
          }

          const average = grayValues.reduce((sum, value) => sum + value, 0) / grayValues.length;
          const hash = grayValues.map((value) => (value >= average ? "1" : "0")).join("");
          resolve(hash);
        };
        image.onerror = () => {
          window.clearTimeout(timer);
          reject(new Error("image_decode_failed"));
        };
        image.src = dataUrl;
      });
    }

    const output = [];

    for (const dataUrl of dataUrls) {
      try {
        output.push(await computeHash(dataUrl));
      } catch {
        // 忽略单张缩略图失败。
      }
    }

    return {
      hashes: output,
      hammingDistanceSource: hammingDistance.toString()
    };
  }, { dataUrls: snapshots, hashTimeoutMs: DEFAULT_IMAGE_HASH_TIMEOUT_MS });

  const hammingDistance = new Function(`return (${hashes.hammingDistanceSource});`)();
  let bestClusterSize = 0;

  for (const leftHash of hashes.hashes) {
    const clusterSize = hashes.hashes.filter((rightHash) => hammingDistance(leftHash, rightHash) <= 10).length;
    bestClusterSize = Math.max(bestClusterSize, clusterSize);
  }

  const dominantClusterRatio = hashes.hashes.length > 0 ? bestClusterSize / hashes.hashes.length : 0;

  return {
    isValid: dominantClusterRatio >= DEFAULT_IMAGE_SIMILARITY_THRESHOLD,
    sample_count: hashes.hashes.length,
    dominant_cluster_ratio: Number(dominantClusterRatio.toFixed(2)),
    reason: dominantClusterRatio >= DEFAULT_IMAGE_SIMILARITY_THRESHOLD ? "image_similarity_passed" : "image_similarity_rejected"
  };
}

async function evaluateSearchValidity(client, page, screenshotPath, searchRow, validationResults, threshold) {
  const [visual, textRules, imageRules] = await Promise.all([
    evaluateSearchPageWithVision(client, screenshotPath, searchRow, validationResults, threshold),
    Promise.resolve(evaluateTextRules(searchRow, validationResults)),
    collectImageSimilarity(page)
  ]);

  const isValidSearch = visual.isValidSearch && textRules.isValid && imageRules.isValid;

  return {
    isValidSearch,
    confidence: visual.confidence,
    reason: isValidSearch
      ? "vision_text_image_all_passed"
      : [visual.isValidSearch ? null : "vision_failed", textRules.isValid ? null : "text_failed", imageRules.isValid ? null : "image_failed"]
          .filter(Boolean)
          .join(","),
    method: "vision_text_image_review",
    rawResponse: visual.rawResponse,
    threshold,
    visual,
    text_rules: textRules,
    image_rules: imageRules
  };
}

function normalizeSpace(value) {
  return String(value ?? "")
    .replace(/\u00a0/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function clampPositiveInt(value, fallback) {
  return Number.isInteger(value) && value > 0 ? value : fallback;
}

async function collectPageText(page) {
  const payload = await page.evaluate(() => {
    const bodyText = document.body?.innerText ?? "";
    const hrefValues = Array.from(document.querySelectorAll("a[href]"))
      .map((node) => node.getAttribute("href") ?? "")
      .filter(Boolean);
    const telValues = Array.from(document.querySelectorAll('a[href^="tel:"], a[href^="mailto:"]'))
      .map((node) => node.getAttribute("href") ?? "")
      .filter(Boolean);

    return {
      bodyText,
      hrefValues,
      telValues
    };
  });

  return [payload.bodyText, ...(payload.hrefValues ?? []), ...(payload.telValues ?? [])].join("\n");
}

function buildContactPageUrls(merchantUrl) {
  return build1688ContactPageUrls(merchantUrl);
}

async function openPageForContactScan(context, url, timeoutMs) {
  const normalizedUrl = normalizeUrl(url);
  if (!normalizedUrl) {
    return { page: null, status: "skipped", reason: "missing_url" };
  }

  const page = await context.newPage();

  try {
    await waitForVerificationClear(page, { logPrefix: "[contact-scan][导航前验证码]" });
    const response = await page.goto(normalizedUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });
    await waitForVerificationClear(page, { logPrefix: "[contact-scan][验证码]" });
    await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs }).catch(() => {});
    await antiDetectPause();

    // 上下滚动两轮，触发懒加载联系方式内容
    for (let round = 0; round < 2; round += 1) {
      await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
      await randomPause(600, 1000);
      await page.evaluate(() => window.scrollTo(0, 0));
      await randomPause(300, 600);
    }

    // 等待联系方式关键词出现，超时则继续
    await page.waitForFunction(
      () => /电话|手机|联系|contact/i.test(document.body.innerText),
      { timeout: 4000 }
    ).catch(() => {});

    await randomPause(200, 400);

    if (isLoginUrl(page.url())) {
      return { page, status: "blocked", reason: "login_required" };
    }

    if (response && !response.ok()) {
      return { page, status: "error", reason: `http_${response.status()}` };
    }

    return { page, status: "ok", reason: "" };
  } catch (error) {
    await closePageSafely(page);
    return {
      page: null,
      status: "error",
      reason: error instanceof Error ? error.message : String(error)
    };
  }
}

async function scanContactsFromUrl(context, url, source, timeoutMs, screenshotPath = "") {
  const opened = await openPageForContactScan(context, url, timeoutMs);

  if (!opened.page) {
    return {
      detectedContacts: [],
      status: opened.status,
      reason: opened.reason,
      screenshotPath: ""
    };
  }

  try {
    let savedScreenshotPath = "";
    if (screenshotPath && opened.status === "ok") {
      try {
        await opened.page.screenshot({ path: screenshotPath, type: "jpeg", quality: 75 });
        savedScreenshotPath = screenshotPath;
      } catch {
        savedScreenshotPath = "";
      }
    }
    const text = await collectPageText(opened.page);
    return {
      detectedContacts: extractDetectedContacts(text, source),
      status: opened.status,
      reason: opened.reason,
      screenshotPath: savedScreenshotPath
    };
  } finally {
    await closePageSafely(opened.page);
  }
}

function filterResultsWithDetectedPhoneContacts(results) {
  const filteredResults = [];
  let removedCount = 0;

  for (const result of Array.isArray(results) ? results : []) {
    if (hasDetectedPhoneContact(result?.detectedContacts)) {
      filteredResults.push(result);
    } else {
      removedCount += 1;
    }
  }

  return {
    results: filteredResults,
    removedCount,
  };
}

function buildEnrichedContactResult(result, overrides = {}) {
  const detectedContacts = Array.isArray(overrides.detectedContacts)
    ? overrides.detectedContacts
    : Array.isArray(result?.detectedContacts)
      ? result.detectedContacts
      : [];
  const hasPhoneContact = hasDetectedPhoneContact(detectedContacts);
  const previousContact = result?.contact && typeof result.contact === "object" ? result.contact : {};
  const wangwangUrl = normalizeSpace(
    overrides.wangwangUrl
    ?? overrides.wangwang_url
    ?? result?.wangwangUrl
    ?? result?.wangwang_url
    ?? previousContact.url
    ?? "",
  );

  return {
    ...result,
    ...overrides,
    title: normalizeSpace(overrides.title ?? result?.title ?? ""),
    contact: {
      ...previousContact,
      type: wangwangUrl ? "wangwang" : "unknown",
      url: wangwangUrl,
    },
    wangwangUrl,
    contactPageUrl: overrides.contactPageUrl ?? result?.contactPageUrl ?? "",
    detectedContacts,
    offerScreenshotPath: overrides.offerScreenshotPath ?? result?.offerScreenshotPath ?? "",
    hasPhoneContact,
    qualificationStatus: hasPhoneContact ? "qualified" : "rejected_no_phone",
    contactCollectionReason: String(overrides.contactCollectionReason ?? result?.contactCollectionReason ?? ""),
  };
}

async function enrichResultsWithContacts(context, results, options) {
  const strictRequirePhone = options.requirePhoneContact !== false;
  const configuredContactLimit = Number.isInteger(options.contactLimit) ? options.contactLimit : DEFAULT_CONTACT_LIMIT;
  const sourceCandidateCount = Number.isInteger(options.sourceCandidateCount) && options.sourceCandidateCount >= 0
    ? options.sourceCandidateCount
    : results.length;
  const targetQualifiedCount = Number.isInteger(options.targetQualifiedCount) && options.targetQualifiedCount > 0
    ? options.targetQualifiedCount
    : 0;
  const scanLimit = strictRequirePhone
    ? results.length
    : resolveScanLimit(results.length, Math.max(configuredContactLimit, targetQualifiedCount));
  const enrichedResults = [];
  const qualifiedResults = [];
  let scannedCount = 0;
  let matchedProductCount = 0;
  let detectedContactsCount = 0;
  let blockedCount = 0;
  let failedPagesCount = 0;
  let navigationFailedCount = 0;
  const seenMerchantKeys = options.seenMerchantKeys instanceof Set ? options.seenMerchantKeys : null;

  for (let index = 0; index < results.length; index += 1) {
    const result = results[index];
    const contactPageUrls = buildContactPageUrls(result.merchantUrl);

    // ── 跨轮商家去重：同一商家在多轮搜索中只访问一次 ──
    const merchantKey = String(result.merchantUrl || result.merchantName || result.offerUrl || result.title || "").replace(/\s+/g, " ").trim().toLowerCase();
    if (seenMerchantKeys && merchantKey && seenMerchantKeys.has(merchantKey)) {
      enrichedResults.push(buildEnrichedContactResult(result, {
        contactPageUrl: contactPageUrls[0] ?? "",
        detectedContacts: [],
        offerScreenshotPath: "",
        wangwangUrl: result.contact?.url ?? "",
        contactCollectionReason: "skipped_cross_round_duplicate",
      }));
      continue;
    }

    if (index >= scanLimit) {
      enrichedResults.push({
        ...result,
        contactPageUrl: contactPageUrls[0] ?? "",
        detectedContacts: null,
        offerScreenshotPath: ""
      });
      continue;
    }

    // 注册为已访问，避免后续轮次重复扫描同一商家
    if (seenMerchantKeys && merchantKey) {
      seenMerchantKeys.add(merchantKey);
    }

    scannedCount += 1;
    const mergedContacts = [];
    const seen = new Set();

    const offerScreenshotPath = options.offerScreenshotPath
      ? options.offerScreenshotPath
      : options.screenshotDir
        ? path.join(options.screenshotDir, `offer-${index + 1}.jpg`)
        : "";
    const navigationScan = await scanOfferContactsByNavigation(context, {
      offerUrl: result.offerUrl,
      timeoutMs: options.contactTimeoutMs,
      screenshotPath: offerScreenshotPath,
      merchantName: result.merchantName,
      currentTitle: result.title,
    });

    for (const contact of navigationScan.detectedContacts) {
      pushDetectedContact(mergedContacts, seen, contact.type, contact.value, contact.source, contact.context);
    }

    if (navigationScan.status === "blocked") {
      blockedCount += 1;
    } else if (navigationScan.status === "error") {
      failedPagesCount += 1;
      navigationFailedCount += 1;
    }

    let contactPageUrl = navigationScan.contactPageUrl || contactPageUrls[0] || "";
    if (!hasDetectedPhoneContact(mergedContacts)) {
      for (const candidateUrl of contactPageUrls) {
        const contactPageScan = await scanContactsFromUrl(context, candidateUrl, "contact_page", options.contactTimeoutMs);
        if (candidateUrl) {
          contactPageUrl = candidateUrl;
        }

        for (const contact of contactPageScan.detectedContacts) {
          pushDetectedContact(mergedContacts, seen, contact.type, contact.value, contact.source, contact.context);
        }

        if (contactPageScan.status === "blocked") {
          blockedCount += 1;
        } else if (contactPageScan.status === "error") {
          failedPagesCount += 1;
        }

        if (hasDetectedPhoneContact(mergedContacts)) {
          break;
        }
      }
    }

    if (mergedContacts.length > 0) {
      matchedProductCount += 1;
      detectedContactsCount += mergedContacts.length;
    }

    const enrichedResult = buildEnrichedContactResult(result, {
      title: navigationScan.preferredTitle || result.title,
      contactPageUrl,
      detectedContacts: mergedContacts,
      offerScreenshotPath: navigationScan.screenshotPath ?? "",
      wangwangUrl: navigationScan.wangwangUrl || result.contact?.url || "",
      contactCollectionReason: navigationScan.reason,
    });

    enrichedResults.push(enrichedResult);
    if (enrichedResult.hasPhoneContact) {
      qualifiedResults.push(enrichedResult);
      if (strictRequirePhone && targetQualifiedCount > 0 && qualifiedResults.length >= targetQualifiedCount) {
        break;
      }
    }
  }

  const rejectedNoPhoneCount = enrichedResults.filter((item) => item.qualificationStatus === "rejected_no_phone").length;
  const shortageReason = strictRequirePhone && targetQualifiedCount > 0 && qualifiedResults.length < targetQualifiedCount
    ? (sourceCandidateCount < targetQualifiedCount
      ? "insufficient_source_candidates"
      : navigationFailedCount > 0
        ? "contact_navigation_failed"
        : "source_exhausted_before_target")
    : "";

  return {
    enrichedResults,
    qualifiedResults,
    enrichmentSummary: {
      contact_limit: scanLimit,
      source_candidate_count: sourceCandidateCount,
      scanned_products_count: scannedCount,
      matched_products_count: matchedProductCount,
      detected_contacts_count: detectedContactsCount,
      blocked_pages_count: blockedCount,
      failed_pages_count: failedPagesCount,
      qualified_result_count: qualifiedResults.length,
      rejected_no_phone_count: rejectedNoPhoneCount,
      target_result_count: targetQualifiedCount,
      shortage_reason: shortageReason,
      navigation_failed_count: navigationFailedCount,
    }
  };
}

function looksLikeTitle(text) {
  return looksLikeSearchResultTitle(text);
}

function parseBooleanArg(value) {
  return value === "1" || value === "true";
}

function parseArgs(argv) {
  const options = {
    inputPath: DEFAULT_INPUT_PATH,
    outputRoot: DEFAULT_OUTPUT_ROOT,
    materialIds: [],
    maxRound: null,
    resultLimit: DEFAULT_RESULT_LIMIT,
    threshold: DEFAULT_VISUAL_THRESHOLD,
    delayBetweenRoundsMs: DEFAULT_DELAY_BETWEEN_ROUNDS,
    timeoutMs: DEFAULT_TIMEOUT_MS,
    pageScreenshotLimit: DEFAULT_PAGE_SCREENSHOT_LIMIT,
    contactLimit: DEFAULT_CONTACT_LIMIT,
    contactTimeoutMs: DEFAULT_CONTACT_TIMEOUT_MS,
    requirePhoneContact: DEFAULT_REQUIRE_PHONE_CONTACT,
    validationResultLimit: DEFAULT_VALIDATION_RESULT_LIMIT,
    validationScrollSteps: DEFAULT_VALIDATION_SCROLL_STEPS,
    semanticTitleCount: DEFAULT_SEMANTIC_TITLE_COUNT,
    stageMode: "default",
    strictHitOnly: false,
    alwaysEnrichContacts: false,
    brandGuardKeyword: "",
    captchaRetryLimit: DEFAULT_CAPTCHA_RETRY_LIMIT,
    captchaRetryBackoffMs: DEFAULT_CAPTCHA_RETRY_BACKOFF_MS,
    headless: DEFAULT_HEADLESS,
    keepBrowserOpen: false
  };

  for (const arg of argv) {
    if (!arg.startsWith("--")) {
      continue;
    }

    const [flag, rawValue] = arg.slice(2).split("=", 2);
    const value = rawValue ?? "";

    if (flag === "input" && value) {
      options.inputPath = path.resolve(process.cwd(), value);
    } else if (flag === "output" && value) {
      options.outputRoot = path.resolve(process.cwd(), value);
    } else if (flag === "material-id" && value) {
      options.materialIds = value
        .split(",")
        .map((item) => item.trim())
        .filter(Boolean);
    } else if (flag === "max-round" && value) {
      options.maxRound = Number.parseInt(value, 10);
    } else if (flag === "result-limit" && value) {
      options.resultLimit = Number.parseInt(value, 10);
    } else if (flag === "threshold" && value) {
      options.threshold = Number.parseFloat(value);
    } else if (flag === "delay-ms" && value) {
      options.delayBetweenRoundsMs = Number.parseInt(value, 10);
    } else if (flag === "timeout-ms" && value) {
      options.timeoutMs = Number.parseInt(value, 10);
    } else if (flag === "validation-result-limit" && value) {
      options.validationResultLimit = Number.parseInt(value, 10);
    } else if (flag === "validation-scroll-steps" && value) {
      options.validationScrollSteps = Number.parseInt(value, 10);
    } else if (flag === "semantic-title-count" && value) {
      options.semanticTitleCount = Number.parseInt(value, 10);
    } else if (flag === "contact-limit" && value) {
      options.contactLimit = Number.parseInt(value, 10);
    } else if ((flag === "contact-timeout" || flag === "contact-timeout-ms") && value) {
      options.contactTimeoutMs = Number.parseInt(value, 10);
    } else if (flag === "require-phone-contact" && value) {
      options.requirePhoneContact = parseBooleanArg(value);
    } else if (flag === "stage-mode" && value) {
      options.stageMode = value === "configured"
        ? "configured"
        : value === "fixed_runtime"
          ? "fixed_runtime"
          : "default";
    } else if (flag === "strict-hit-only" && value) {
      options.strictHitOnly = parseBooleanArg(value);
    } else if (flag === "always-enrich-contacts" && value) {
      options.alwaysEnrichContacts = parseBooleanArg(value);
    } else if (flag === "brand-guard-keyword" && value) {
      options.brandGuardKeyword = normalizeSpace(value);
    } else if (flag === "captcha-retry-limit" && value) {
      options.captchaRetryLimit = Number.parseInt(value, 10);
    } else if ((flag === "captcha-retry-backoff" || flag === "captcha-retry-backoff-ms") && value) {
      options.captchaRetryBackoffMs = Number.parseInt(value, 10);
    } else if (flag === "headless" && value) {
      options.headless = parseBooleanArg(value);
    } else if (flag === "keep-browser-open") {
      options.keepBrowserOpen = true;
    }
  }

  return options;
}

async function ensureDir(target) {
  await mkdir(target, { recursive: true });
}

async function closeContextSafely(context, label = "context") {
  if (!context) {
    return;
  }

  for (const page of context.pages()) {
    await waitForVerificationClear(page, { logPrefix: `[${label}][关闭前验证码]` }).catch(() => false);
  }

  const timeoutMs = 5000;

  try {
    await Promise.race([
      context.close(),
      sleep(timeoutMs).then(() => {
        throw new Error(`${label}_close_timeout`);
      })
    ]);
  } catch (error) {
    log(`关闭浏览器上下文超时或失败（${label}）: ${error instanceof Error ? error.message : String(error)}`);
  }
}

function normalizeAiKeywordAnalysis(record) {
  const rawAnalysis = record?.ai_keyword_analysis ?? record;
  if (!rawAnalysis || typeof rawAnalysis !== "object") {
    return null;
  }

  const llmAnalysis = record?.llm_analysis ?? {};
  const keywordParameterTerms = Array.isArray(llmAnalysis?.keyword_parameter_terms)
    ? llmAnalysis.keyword_parameter_terms.map((t) => normalizeSpace(String(t))).filter(Boolean)
    : [];
  const alternativeTerms = Array.isArray(llmAnalysis?.alternative_terms)
    ? llmAnalysis.alternative_terms.map((t) => normalizeSpace(String(t))).filter(Boolean)
    : [];

  return {
    suggested_keyword: normalizeSpace(rawAnalysis.suggested_keyword),
    product_type: normalizeSpace(rawAnalysis.product_type),
    structure_summary: normalizeSpace(rawAnalysis.structure_summary),
    component_roles: normalizeSpace(rawAnalysis.component_roles),
    purchase_type: normalizeSpace(rawAnalysis.purchase_type),
    keyword_parameter_terms: keywordParameterTerms,
    alternative_terms: alternativeTerms
  };
}

function buildAiKeywordAnalysisSummary(aiKeywordAnalysis) {
  if (!aiKeywordAnalysis) {
    return "";
  }

  const summaryParts = [
    aiKeywordAnalysis.suggested_keyword ? `推荐词=${aiKeywordAnalysis.suggested_keyword}` : "",
    aiKeywordAnalysis.product_type ? `商品类型=${aiKeywordAnalysis.product_type}` : "",
    aiKeywordAnalysis.structure_summary ? `结构摘要=${aiKeywordAnalysis.structure_summary}` : "",
    aiKeywordAnalysis.component_roles ? `组件角色=${aiKeywordAnalysis.component_roles}` : "",
    aiKeywordAnalysis.purchase_type ? `购买方式=${aiKeywordAnalysis.purchase_type}` : ""
  ].filter(Boolean);

  return summaryParts.join("；");
}

async function loadAiKeywordAnalysisMap(inputPath) {
  const aiKeywordAnalysisMap = new Map();
  const keywordProcessPath = path.join(path.dirname(inputPath), "keyword-process.json");

  try {
    const rawText = await readFile(keywordProcessPath, "utf8");
    const parsed = JSON.parse(rawText);

    const records = Array.isArray(parsed) ? parsed : [parsed];
    for (const record of records) {
      const materialId = String(record?.material_id ?? "").trim();
      const aiKeywordAnalysis = normalizeAiKeywordAnalysis(record);

      if (!materialId || !aiKeywordAnalysis) {
        continue;
      }

      aiKeywordAnalysisMap.set(materialId, aiKeywordAnalysis);
    }
  } catch {
    return aiKeywordAnalysisMap;
  }

  return aiKeywordAnalysisMap;
}

async function loadSearchPlanRows(inputPath) {
  const rawText = await readFile(inputPath, "utf8");
  const parsed = JSON.parse(rawText);

  if (!Array.isArray(parsed)) {
    throw new Error("搜索计划 JSON 必须是数组");
  }

  const normalizedRows = parsed
    .filter(
      (item) =>
        typeof item?.material_id === "string" && item.material_id.trim() &&
        typeof item?.primary_keyword === "string" &&
        item?.keyword_source !== "ai_alternative"
    )
    .sort((left, right) => {
      if (left.material_id !== right.material_id) {
        return String(left.material_id).localeCompare(String(right.material_id));
      }

      return Number(left.search_round ?? 0) - Number(right.search_round ?? 0);
    });

  const dedupedRows = [];
  const seen = new Set();

  for (const row of normalizedRows) {
    const dedupeKey = [
      String(row.material_id ?? ""),
      Number(row.search_round ?? 0),
      String(row.search_strategy ?? ""),
      String(row.keyword_source ?? ""),
      String(row.primary_keyword ?? "")
    ].join("::");

    if (seen.has(dedupeKey)) {
      continue;
    }

    seen.add(dedupeKey);
    dedupedRows.push(row);
  }

  return dedupedRows;
}

// 每个 material_id 作为一组轮次顺序执行，命中有效搜索后仅停止当前物料的后续轮次。
function groupRows(rows, materialIds, maxRound) {
  const groups = new Map();

  for (const row of rows) {
    if (materialIds.length > 0 && !materialIds.includes(row.material_id)) {
      continue;
    }

    if (Number.isInteger(maxRound) && Number(row.search_round ?? 0) > maxRound) {
      continue;
    }

    if (!groups.has(row.material_id)) {
      groups.set(row.material_id, []);
    }

    groups.get(row.material_id).push(row);
  }

  return groups;
}

async function waitForSearchResultsPage(context, sourcePage, knownPages, timeoutMs) {
  const startedAt = Date.now();
  const knownPageSet = new Set(knownPages);

  while (Date.now() - startedAt < timeoutMs) {
    await waitForVerificationClear(sourcePage, { logPrefix: "[search-results-wait][验证码]" }).catch(() => false);

    if (!sourcePage.isClosed() && isResultsUrl(sourcePage.url())) {
      await sourcePage.bringToFront().catch(() => {});
      return sourcePage;
    }

    const pages = context.pages().filter((page) => !page.isClosed());
    for (const page of pages) {
      await waitForVerificationClear(page, { logPrefix: "[search-results-wait][验证码]" }).catch(() => false);
    }

    const newResultPages = pages.filter((page) => isResultsUrl(page.url()) && !knownPageSet.has(page));
    const latestNewResultPage = newResultPages.at(-1) ?? null;

    if (latestNewResultPage) {
      await latestNewResultPage.bringToFront().catch(() => {});
      return latestNewResultPage;
    }

    const latestAnyResultPage = pages.filter((page) => isResultsUrl(page.url())).at(-1) ?? null;

    if (latestAnyResultPage) {
      await latestAnyResultPage.bringToFront().catch(() => {});
      return latestAnyResultPage;
    }

    await randomPause(250, 450);
  }

  throw new Error("未检测到 1688 搜索结果页");
}

async function readVisibleTextInputCandidates(page) {
  const locator = page.locator('input[type="search"], input[type="text"], input:not([type])');
  const total = await locator.count();
  const candidates = [];

  for (let index = 0; index < total; index += 1) {
    const candidate = locator.nth(index);
    const meta = await candidate.evaluate((input, domIndex) => {
      const rect = input.getBoundingClientRect();
      const style = window.getComputedStyle(input);
      const visible = style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
      return {
        domIndex,
        visible,
        id: input.id || "",
        name: input.getAttribute("name") || "",
        placeholder: input.getAttribute("placeholder") || "",
        ariaLabel: input.getAttribute("aria-label") || "",
        className: input.className || "",
        autocomplete: input.getAttribute("autocomplete") || "",
        title: input.getAttribute("title") || "",
        formAction: input.form?.action || input.closest("form")?.action || "",
        rect: {
          x: rect.x,
          y: rect.y,
          width: rect.width,
          height: rect.height,
        },
      };
    }, index).catch(() => null);

    if (!meta?.visible) {
      continue;
    }

    candidates.push({ locator: candidate, meta });
  }

  return candidates;
}

async function readVisibleSubmitCandidates(page) {
  const locator = page.locator('button, input[type="submit"], input[type="button"], [role="button"]');
  const total = await locator.count();
  const candidates = [];

  for (let index = 0; index < total; index += 1) {
    const candidate = locator.nth(index);
    const meta = await candidate.evaluate((element, domIndex) => {
      const rect = element.getBoundingClientRect();
      const style = window.getComputedStyle(element);
      const visible = style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
      const inputValue = "value" in element && typeof element.value === "string" ? element.value : "";
      return {
        domIndex,
        visible,
        text: element.innerText || element.textContent || "",
        value: inputValue,
        ariaLabel: element.getAttribute("aria-label") || "",
        title: element.getAttribute("title") || "",
        name: element.getAttribute("name") || "",
        className: element.className || "",
        role: element.getAttribute("role") || "",
        type: element.getAttribute("type") || "",
        formAction: element.form?.action || element.closest("form")?.action || "",
        rect: {
          x: rect.x,
          y: rect.y,
          width: rect.width,
          height: rect.height,
        },
      };
    }, index).catch(() => null);

    if (!meta?.visible) {
      continue;
    }

    candidates.push({ locator: candidate, meta });
  }

  return candidates;
}

async function pageBodyText(page) {
  try {
    return await page.locator("body").innerText({ timeout: 1200 });
  } catch {
    try {
      return await page.evaluate(() => document.body?.innerText ?? "");
    } catch {
      return "";
    }
  }
}

async function isVisibleLoginInterstitial(page) {
  const [inputCandidates, bodyText] = await Promise.all([
    readVisibleTextInputCandidates(page),
    pageBodyText(page),
  ]);

  return looksLikeLoginInterstitial({
    url: page.url(),
    bodyText,
    inputCandidates: inputCandidates.map((candidate) => candidate.meta),
  });
}

async function findVisibleSearchBox(page, timeoutMs = 12000) {
  const startedAt = Date.now();

  while (Date.now() - startedAt < timeoutMs) {
    const candidates = await readVisibleTextInputCandidates(page);
    const selected = pickBestSearchInputCandidate(candidates.map((candidate) => candidate.meta));

    if (selected) {
      const matched = candidates.find((candidate) => candidate.meta.domIndex === selected.domIndex);
      if (matched) {
        return matched.locator;
      }
    }

    await randomPause(200, 400);
  }

  return null;
}

function isLoginUrl(url) {
  return url.includes("login.taobao.com") || url.includes("login.1688.com");
}

async function findVisibleSearchSubmitControl(page, inputRect = null) {
  const candidates = await readVisibleSubmitCandidates(page);
  const selected = pickBestSearchSubmitCandidate(
    candidates.map((candidate) => candidate.meta),
    inputRect,
  );

  if (!selected) {
    return null;
  }

  return candidates.find((candidate) => candidate.meta.domIndex === selected.domIndex)?.locator ?? null;
}

async function ensureSearchKeywordValue(page, searchBox, query, options = {}) {
  const humanized = options.humanized !== false;

  if (humanized) {
    await humanType(page, searchBox, query);
  } else {
    await searchBox.fill(query).catch(() => {});
  }

  let currentValue = await searchBox.inputValue().catch(() => "");
  if (normalizeSpace(currentValue) === normalizeSpace(query)) {
    return true;
  }

  await searchBox.fill(query).catch(() => {});
  currentValue = await searchBox.inputValue().catch(() => "");
  if (normalizeSpace(currentValue) === normalizeSpace(query)) {
    return true;
  }

  await searchBox.evaluate((input, nextValue) => {
    input.focus();
    const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value");
    if (descriptor?.set) {
      descriptor.set.call(input, nextValue);
    } else {
      input.value = nextValue;
    }
    input.dispatchEvent(new Event("input", { bubbles: true }));
    input.dispatchEvent(new Event("change", { bubbles: true }));
  }, query).catch(() => {});

  currentValue = await searchBox.inputValue().catch(() => "");
  return normalizeSpace(currentValue) === normalizeSpace(query);
}

async function submitSearchQuery(page, searchBox, options = {}) {
  const inputRect = await searchBox.boundingBox().catch(() => null);

  if (options.forceFormSubmit === true) {
    const submittedViaForm = await searchBox.evaluate((input) => {
      const form = input.form ?? input.closest("form");
      if (!form) {
        return "";
      }

      if (typeof form.requestSubmit === "function") {
        form.requestSubmit();
        return "form";
      }

      if (typeof form.submit === "function") {
        form.submit();
        return "form";
      }

      return "";
    }).catch(() => "");

    if (submittedViaForm) {
      return submittedViaForm;
    }
  }

  const searchButton = await findVisibleSearchSubmitControl(page, inputRect);
  if (searchButton) {
    await humanClick(page, searchButton).catch(async () => {
      await searchButton.click({ delay: 80 }).catch(() => {});
    });
    return "button";
  }

  await page.keyboard.press("Enter").catch(() => {});
  return "enter";
}

function createManualLoginBlockedError(headless) {
  const mode = headless ? "无界面" : "非交互";
  return new Error(
    `检测到 1688 登录页，但当前为${mode}模式，无法手工登录。请先运行 automation/internal/login-1688.js 完成登录并写入 ${PROFILE_DIR}，然后重试自动搜索。`
  );
}

async function promptForManualLogin(page, targetUrl, options = {}) {
  if (options.headless || !input.isTTY || !output.isTTY) {
    throw createManualLoginBlockedError(Boolean(options.headless));
  }

  const rl = readline.createInterface({ input, output });
  console.log("\n检测到登录页。请在已打开的浏览器中完成 1688/淘宝登录，完成后回到终端按 Enter 继续。\n");

  try {
    await rl.question("");
  } finally {
    rl.close();
  }

  await waitForVerificationClear(page, { logPrefix: "[manual-login][返回前验证码]" }).catch(() => false);
  await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
  await waitForVerificationClear(page, { logPrefix: "[manual-login][返回后验证码]" }).catch(() => false);
  await antiDetectPause();
}

/**
 * 检查搜索结果页的 URL 是否包含目标关键词。
 * 若 URL 中 keywords 参数为空或缺失，则直接通过 URL 导航到搜索结果页。
 * 这可以规避 1688 首页 React 框架下 form.submit() 丢失关键词的问题。
 */
async function ensureSearchUrlContainsKeyword(page, keyword) {
  const currentUrl = page.url();

  try {
    const urlObj = new URL(currentUrl);
    const urlKeywords = urlObj.searchParams.get("keywords") ?? "";
    if (normalizeSpace(urlKeywords).length > 0) {
      return page;
    }
  } catch {
    // URL 解析失败，继续尝试修复
  }

  const directUrl = build1688OfferSearchUrl(keyword);
  log(`搜索提交后 URL 未包含关键词 (${currentUrl.slice(0, 120)})，直接导航: ${directUrl}`);
  await waitForVerificationClear(page, { logPrefix: "[keyword-fix][导航前验证码]" });
  await page.goto(directUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
  await antiDetectPause();
  await waitForVerificationClear(page, { logPrefix: "[keyword-fix][验证码]" });
  await page.waitForSelector(OFFER_LINK_SELECTOR, { timeout: 15000 }).catch(() => {});
  return page;
}

async function attemptKeywordSearch(context, page, query, timeoutMs) {
  if (isLoginUrl(page.url()) || (await isVisibleLoginInterstitial(page))) {
    return { resultsPage: null, loginPage: page, submittedVia: "login_interstitial" };
  }

  const searchBox = await findVisibleSearchBox(page);

  if (!searchBox) {
    const loginRequired = await isVisibleLoginInterstitial(page);
    return { resultsPage: null, loginPage: loginRequired ? page : null, submittedVia: "search_box_missing" };
  }

  const knownPages = context.pages().slice();
  await ensureSearchKeywordValue(page, searchBox, query, { humanized: true });
  const submittedVia = await submitSearchQuery(page, searchBox);
  await antiDetectPause();

  const loginPage = context.pages().find((candidate) => isLoginUrl(candidate.url())) ?? null;
  if (loginPage) {
    return { resultsPage: null, loginPage, submittedVia };
  }

  const resultsPage = await waitForSearchResultsPage(context, page, knownPages, timeoutMs).catch(() => null);
  return { resultsPage, loginPage: null, submittedVia };
}

async function forceKeywordSearchSubmission(context, page, query, timeoutMs) {
  if (isLoginUrl(page.url()) || (await isVisibleLoginInterstitial(page))) {
    return { resultsPage: null, loginPage: page, submittedVia: "login_interstitial" };
  }

  const searchBox = await findVisibleSearchBox(page);
  if (!searchBox) {
    const loginRequired = await isVisibleLoginInterstitial(page);
    return { resultsPage: null, loginPage: loginRequired ? page : null, submittedVia: "search_box_missing" };
  }

  const knownPages = context.pages().slice();
  await ensureSearchKeywordValue(page, searchBox, query, { humanized: false });
  const submittedVia = await submitSearchQuery(page, searchBox, { forceFormSubmit: true });
  await antiDetectPause();

  const loginPage = context.pages().find((candidate) => isLoginUrl(candidate.url())) ?? null;
  if (loginPage) {
    return { resultsPage: null, loginPage, submittedVia };
  }

  const resultsPage = await waitForSearchResultsPage(context, page, knownPages, timeoutMs).catch(() => null);
  return { resultsPage, loginPage: null, submittedVia };
}

async function openSearchResultsForSingleKeyword(context, query, timeoutMs, options = {}) {
  const reusablePage = options.searchEntryPage && !options.searchEntryPage.isClosed?.()
    ? options.searchEntryPage
    : null;
  if (reusablePage && Object.prototype.hasOwnProperty.call(options, "searchEntryPage")) {
    options.searchEntryPage = null;
  }

  const page = reusablePage ?? await context.newPage();
  let resultsPage = null;

  try {
    if (!page.url().includes("1688.com") || isLoginUrl(page.url())) {
      await waitForVerificationClear(page, { logPrefix: "[search-entry][导航前验证码]" });
      await page.goto(SEARCH_HOME_URL, { waitUntil: "domcontentloaded" });
    }
    await page.setViewportSize({ width: 1440, height: 960 });
    await waitForVerificationClear(page, { logPrefix: "[search-entry][验证码]" }).catch(() => false);
    await antiDetectPause();
    await stepPause("search_home_ready");

    const firstAttempt = await attemptKeywordSearch(context, page, query, Math.min(timeoutMs, 9000));
    if (firstAttempt.resultsPage) {
      resultsPage = firstAttempt.resultsPage;
      await waitForDomContentLoadedSafely(resultsPage, timeoutMs, `results:${query}`);
      await antiDetectPause();
      await assertSearchPageReady(resultsPage);
      resultsPage = await ensureSearchUrlContainsKeyword(resultsPage, query);
      await stepPause("search_results_ready");

      if (resultsPage !== page) {
        await closePageSafely(page);
      }

      return {
        page: resultsPage,
        resolvedKeyword: query
      };
    }

    if (firstAttempt.loginPage) {
      await promptForManualLogin(firstAttempt.loginPage, SEARCH_HOME_URL, options);
      if (!reusablePage) {
        await closePageSafely(page);
      }
      return openSearchResultsForSingleKeyword(context, query, timeoutMs, options);
    }

    log(`关键词=${query} 首页搜索未触发结果页，改用站内表单强制提交`);
    await waitForVerificationClear(page, { logPrefix: "[search-entry][强制提交前验证码]" });
    await page.goto(SEARCH_HOME_URL, { waitUntil: "domcontentloaded" });
    await waitForVerificationClear(page, { logPrefix: "[search-entry][强制提交验证码]" });
    await antiDetectPause();
    await stepPause("search_home_force_submit_ready");

    const forcedAttempt = await forceKeywordSearchSubmission(context, page, query, timeoutMs);
    if (forcedAttempt.resultsPage) {
      resultsPage = forcedAttempt.resultsPage;
      await waitForDomContentLoadedSafely(resultsPage, timeoutMs, `results-forced:${query}`);
      await antiDetectPause();
      await assertSearchPageReady(resultsPage);
      resultsPage = await ensureSearchUrlContainsKeyword(resultsPage, query);
      await stepPause("search_results_forced_ready");

      if (resultsPage !== page) {
        await closePageSafely(page);
      }

      return {
        page: resultsPage,
        resolvedKeyword: query,
      };
    }

    if (forcedAttempt.loginPage) {
      await promptForManualLogin(forcedAttempt.loginPage, SEARCH_HOME_URL, options);
      if (!reusablePage) {
        await closePageSafely(page);
      }
      return openSearchResultsForSingleKeyword(context, query, timeoutMs, options);
    }

    throw new Error(`1688 站内搜索未触发结果页：${query}`);
  } catch (error) {
    if (resultsPage && resultsPage !== page) {
      await closePageSafely(resultsPage);
    }

    if (!reusablePage) {
      await closePageSafely(page);
    }
    throw error;
  }
}

async function openSearchResultsForKeyword(context, query, timeoutMs, options = {}) {
  const keywordVariants = buildSearchQueryVariants(query, { strict: options.strictQueryVariant === true });
  let lastError = null;
  const attemptedKeywords = [];
  let searchEntryAttempts = 0;

  for (let index = 0; index < keywordVariants.length; index += 1) {
    const variant = keywordVariants[index];
    attemptedKeywords.push(variant);

    if (index > 0) {
      log(`关键词=${query} 命中服务异常，改用规避词重试：${variant}`);
    }

    try {
      searchEntryAttempts += 1;
      const opened = await openSearchResultsForSingleKeyword(context, variant, timeoutMs, options);
      return {
        ...opened,
        attemptedKeywords: attemptedKeywords.slice(),
        searchEntryAttempts,
        captchaRetryCount: 0,
      };
    } catch (error) {
      lastError = error;
    }

    if (isCaptchaInterceptError(lastError)) {
      throw lastError;
    }

    if (!isSearchServiceError(lastError) || index >= keywordVariants.length - 1) {
      throw lastError;
    }
  }

  throw lastError ?? new Error("打开 1688 搜索结果页失败");
}

function shouldUseStrictQueryVariant(searchRow) {
  return String(searchRow?.search_strategy ?? "").startsWith("configured_round_");
}

function shouldStrictlySelectKeywordHits(options) {
  return options?.strictHitOnly === true;
}

function shouldEnrichContactsForConfiguredStage(options) {
  return options?.alwaysEnrichContacts === true;
}

function isFixedRuntimeStage(options) {
  return options?.stageMode === "fixed_runtime";
}

// 搜索结果提取支持两种用途：保存前 N 条结果，以及为整页有效性判断加载更多商品卡片。
async function extractTopResults(page, limit, options = {}) {
  const scrollSteps = Number.isInteger(options.scrollSteps) ? options.scrollSteps : Math.max(4, Math.ceil(limit / 5));

  await assertSearchPageReady(page);

  try {
    await page.waitForSelector(OFFER_LINK_SELECTOR, { timeout: 15000 });
  } catch (error) {
    await assertSearchPageReady(page);
    throw error;
  }

  await humanScrollPage(page, scrollSteps);
  await page.mouse.wheel(0, -1200).catch(() => {});
  await stepPause("search_results_scrolled");

  const rawResults = await page.evaluate(
    ({ take, extractorSource }) => {
      const extractSearchResultCandidates = new Function(`return (${extractorSource});`)();
      return extractSearchResultCandidates(document, take, { visibleOnly: true });
    },
    { take: limit, extractorSource: SEARCH_RESULT_EXTRACTOR_SOURCE }
  );

  if (rawResults.length === 0) {
    const diagnostics = await page.evaluate(() => {
      const allAnchors = document.querySelectorAll("a[href]").length;
      const offerAnchors = Array.from(document.querySelectorAll("a[href]")).filter(
        (a) => a.href.includes("detail.1688.com/offer/") || a.href.includes("offerId=") || a.href.includes("detail.m.1688.com/page/index.html")
      ).length;
      const bodySnippet = (document.body?.innerText ?? "").slice(0, 300);
      return { allAnchors, offerAnchors, bodySnippet };
    }).catch(() => ({ allAnchors: -1, offerAnchors: -1, bodySnippet: "(evaluate失败)" }));
    log(`[extractTopResults] ⚠️ 零结果诊断 | URL=${page.url()} | 页面总链接=${diagnostics.allAnchors} | offer链接=${diagnostics.offerAnchors} | 页面文本前300字: ${diagnostics.bodySnippet}`);
  }

  return rawResults
    .map((item, index) =>
      normalizeOfferRecord(
        {
          ...item,
          imageUrl: normalizeUrl(item.imageUrl)
        },
        index + 1
      )
    )
    .map((item) => ({
      ...item,
      title: (() => {
        const normalizedTitle = sanitizeSearchResultTitle(item.title, item.merchantName);
        const fallbackCardTitle = sanitizeSearchResultTitle(item.rawCardText, item.merchantName);
        if (looksLikeTitle(normalizedTitle)) {
          return normalizedTitle;
        }
        if (looksLikeTitle(fallbackCardTitle)) {
          return fallbackCardTitle;
        }
        return normalizedTitle || fallbackCardTitle || `1688结果-${item.index}`;
      })()
    }));
}

async function extractResultsAcrossPages(page, limit, options = {}) {
  const pageCount = Number.isInteger(options.pageCount) && options.pageCount > 0
    ? options.pageCount
    : DEFAULT_FIXED_RUNTIME_PAGE_COUNT;
  const perPageLimit = Number.isInteger(options.perPageLimit) && options.perPageLimit > 0
    ? options.perPageLimit
    : DEFAULT_FIXED_RUNTIME_RESULTS_PER_PAGE;
  const totalLimit = Number.isInteger(limit) && limit > 0
    ? limit
    : pageCount * perPageLimit;
  const baseUrl = replaceRawQueryParam(page.url(), "beginPage", null);
  const mergedResults = [];
  const seenOfferUrls = new Set();

  for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
    if (pageNumber > 1) {
      const nextUrl = replaceRawQueryParam(baseUrl, "beginPage", String(pageNumber));
      await waitForVerificationClear(page, { logPrefix: "[fixed-runtime][翻页前验证码]" });
      await page.goto(nextUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
      await antiDetectPause();
      await waitForVerificationClear(page, { logPrefix: "[fixed-runtime][验证码]" });
      await page.waitForSelector(OFFER_LINK_SELECTOR, { timeout: 15000 }).catch(() => {});
    }

    const pageResults = await extractTopResults(
      page,
      Math.min(perPageLimit, totalLimit - mergedResults.length),
      { scrollSteps: options.scrollSteps },
    );

    if (pageResults.length === 0) {
      break;
    }

    for (const result of pageResults) {
      const offerUrl = normalizeOfferDetailUrl(result.offerUrl ?? "");
      const dedupeKey = offerUrl || normalizeSpace(result.title ?? "") || `${pageNumber}-${result.index}`;
      if (!dedupeKey || seenOfferUrls.has(dedupeKey)) {
        continue;
      }

      seenOfferUrls.add(dedupeKey);
      mergedResults.push({
        ...result,
        offerUrl: offerUrl || result.offerUrl,
      });
    }

    if (mergedResults.length >= totalLimit) {
      break;
    }
  }

  return mergedResults;
}

async function saveImage(imageUrl, targetPath) {
  if (!imageUrl) {
    return "";
  }

  const response = await fetch(normalizeUrl(imageUrl), {
    headers: {
      "user-agent":
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
    }
  });

  if (!response.ok) {
    throw new Error(`下载图片失败: ${response.status}`);
  }

  const buffer = Buffer.from(await response.arrayBuffer());
  await writeFile(targetPath, buffer);
  return targetPath;
}

// AI 视觉判断只看当前轮次第一页截图，不和前后轮做交叉推理，保证每轮只有一次视觉调用。
function buildVisionClient() {
  const runtime = resolveStageRuntimeFromEnv("search_vision", process.env);
  if (!runtime) {
    return null;
  }

  return new OpenAI({
    apiKey: runtime.apiKey,
    baseURL: runtime.baseUrl,
    maxRetries: 0,
    timeout: DEFAULT_LLM_TIMEOUT_MS
  });
}

function tryParseJsonObject(text) {
  if (!text) {
    return null;
  }

  const match = text.match(/\{[\s\S]*\}/);
  if (!match) {
    return null;
  }

  try {
    return JSON.parse(match[0]);
  } catch {
    return null;
  }
}

function isLlmRateLimitError(error) {
  if (!error || typeof error !== "object") {
    return false;
  }

  const candidate = error;
  const status = typeof candidate.status === "number"
    ? candidate.status
    : typeof candidate.response?.status === "number"
      ? candidate.response.status
      : null;
  const code = typeof candidate.code === "string" ? candidate.code.toLowerCase() : "";
  const errorType = typeof candidate.type === "string" ? candidate.type.toLowerCase() : "";
  const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";

  return status === 429
    || code === "429"
    || code === "rate_limit_exceeded"
    || errorType.includes("rate_limit")
    || message.includes("rate limit")
    || message.includes("速率限制")
    || message.includes("请求频率")
    || message.includes("429");
}

async function createChatCompletionWithRetry(client, label, params) {
  const maxAttempts = Number.isInteger(DEFAULT_LLM_RETRY_MAX_ATTEMPTS) && DEFAULT_LLM_RETRY_MAX_ATTEMPTS > 0
    ? DEFAULT_LLM_RETRY_MAX_ATTEMPTS
    : 4;
  const baseDelayMs = Number.isInteger(DEFAULT_LLM_RETRY_BASE_DELAY_MS) && DEFAULT_LLM_RETRY_BASE_DELAY_MS > 0
    ? DEFAULT_LLM_RETRY_BASE_DELAY_MS
    : 1500;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      return await createSearchVisionCompletion(client, label, params);
    } catch (error) {
      if (!isLlmRateLimitError(error) || attempt >= maxAttempts) {
        throw error;
      }

      const delayMs = baseDelayMs * 2 ** (attempt - 1) + Math.floor(Math.random() * 300);
      log(`[LLM] ${label} 命中速率限制，${delayMs}ms 后重试 (${attempt + 1}/${maxAttempts})`);
      await sleep(delayMs);
    }
  }

  throw new Error(`[LLM] ${label} 重试耗尽`);
}

async function estimateKeywordHitRateWithLlm(client, keyword, sampleResults, options = {}) {
  const promptBundle = await resolveSearchPrompt(
    SEARCH_PROMPT_KEYS.hitRateFallback,
    "chat_bundle",
  );
  const prompt = renderPromptTemplate(
    getRequiredPayloadString(promptBundle, "user_template"),
    {
      keyword: normalizeSpace(keyword),
      sample_count: sampleResults.length,
      sample_blocks: buildKeywordHitRateFallbackSampleBlocks(sampleResults),
    },
  );
  const systemPrompt = getRequiredPayloadString(promptBundle, "system");

  try {
    const completion = await createChatCompletionWithRetry(client, `keywordHitRate:${keyword}`, {
      model: DEFAULT_VISUAL_MODEL,
      temperature: 0,
      messages: [
        {
          role: "system",
          content: systemPrompt
        },
        {
          role: "user",
          content: prompt
        }
      ],
      response_format: { type: "json_object" }
    });

    const rawResponse = completion.choices?.[0]?.message?.content ?? "";
    const parsed = tryParseJsonObject(rawResponse);
    if (!parsed) {
      return {
        applied: false,
        source: "local",
        reason: "llm_fallback_parse_failed",
        sample_count: sampleResults.length,
        confidence: null,
        reasoning_summary: "",
        raw_response: rawResponse,
      };
    }

    const parsedSampleCount = Number(parsed.sample_count ?? sampleResults.length);
    const sampleCount = Number.isFinite(parsedSampleCount) && parsedSampleCount > 0
      ? Math.round(parsedSampleCount)
      : sampleResults.length;
    const parsedEstimatedHitCount = Number(parsed.estimated_hit_count ?? parsed.hit_count ?? Number.NaN);
    let estimatedHitRate = Number(parsed.estimated_hit_rate ?? parsed.hit_rate ?? Number.NaN);
    if (!Number.isFinite(estimatedHitRate) && Number.isFinite(parsedEstimatedHitCount) && sampleCount > 0) {
      estimatedHitRate = parsedEstimatedHitCount / sampleCount;
    }

    if (!Number.isFinite(estimatedHitRate)) {
      return {
        applied: false,
        source: "local",
        reason: "llm_fallback_invalid_rate",
        sample_count: sampleResults.length,
        confidence: null,
        reasoning_summary: "",
        raw_response: rawResponse,
      };
    }

    const boundedHitRate = Math.max(0, Math.min(1, estimatedHitRate));
    const totalCount = Number.isFinite(Number(options.totalCount)) && Number(options.totalCount) > 0
      ? Number(options.totalCount)
      : sampleResults.length;
    const estimatedHitCount = Math.max(0, Math.min(totalCount, Math.round(boundedHitRate * totalCount)));
    const rawConfidence = Number(parsed.confidence ?? Number.NaN);

    return {
      applied: boundedHitRate > 0,
      source: boundedHitRate > 0 ? "llm_fallback" : "local",
      reason: boundedHitRate > 0 ? "llm_semantic_recovered" : "llm_semantic_zero",
      hit_rate: Number(boundedHitRate.toFixed(4)),
      hit_count: estimatedHitCount,
      sample_count: sampleResults.length,
      confidence: Number.isFinite(rawConfidence) ? Number(Math.max(0, Math.min(1, rawConfidence)).toFixed(2)) : null,
      reasoning_summary: normalizeSpace(parsed.reasoning_summary ?? parsed.reason ?? ""),
      raw_response: rawResponse,
    };
  } catch (error) {
    return {
      applied: false,
      source: "local",
      reason: "llm_fallback_error",
      sample_count: sampleResults.length,
      confidence: null,
      reasoning_summary: error instanceof Error ? error.message : String(error),
      raw_response: "",
    };
  }
}

function titleHeuristic(results) {
  const tokenCounts = new Map();

  for (const result of results) {
    const normalized = String(result.title ?? "")
      .replace(/[（）()【】\[\]，,。.!！？?\-_/]+/g, " ")
      .split(/\s+/)
      .map((item) => item.trim())
      .filter((item) => item.length >= 2);

    const uniqueTokens = new Set(normalized);
    for (const token of uniqueTokens) {
      tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1);
    }
  }

  const repeated = [...tokenCounts.values()].filter((count) => count >= Math.max(3, Math.ceil(results.length * 0.5)));
  const ratio = results.length === 0 ? 0 : Math.min(1, repeated.length / Math.max(1, Math.ceil(results.length / 3)));

  return {
    isValidSearch: ratio >= 0.6,
    confidence: Number(ratio.toFixed(2)),
    reason: repeated.length > 0 ? "fallback_title_similarity" : "fallback_no_repeated_tokens",
    method: "title_fallback"
  };
}

async function evaluateSearchPageWithVision(client, screenshotPath, searchRow, results, threshold) {
  if (!client) {
    return {
      ...titleHeuristic(results),
      rawResponse: "",
      threshold
    };
  }

  const screenshotBuffer = await readFile(screenshotPath);
  const base64Image = screenshotBuffer.toString("base64");
  const altKeywords = String(searchRow.alt_keywords ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean)
    .slice(0, 5)
    .join(" | ");

  const promptBundle = await resolveSearchPrompt(
    SEARCH_PROMPT_KEYS.pageReview,
    "multimodal_bundle",
  );
  const prompt = renderPromptTemplate(
    getRequiredPayloadString(promptBundle, "user_template"),
    {
      primary_keyword: searchRow.primary_keyword,
      alt_keywords: altKeywords || "无",
      result_count: results.length,
      sample_titles: results
        .slice(0, 20)
        .map((item) => item.title)
        .filter(Boolean)
        .join(" | ") || "无",
      threshold: threshold.toFixed(2),
    },
  );
  const systemPrompt = getRequiredPayloadString(promptBundle, "system");

  const completion = await createSearchVisionCompletion(client, "pageReview", {
    model: DEFAULT_VISUAL_MODEL,
    temperature: 0,
    messages: [
      {
        role: "system",
        content: systemPrompt
      },
      {
        role: "user",
        content: [
          { type: "text", text: prompt },
          {
            type: "image_url",
            image_url: {
              url: `data:image/jpeg;base64,${base64Image}`
            }
          }
        ]
      }
    ]
  });

  const rawResponse = completion.choices?.[0]?.message?.content ?? "";
  const parsed = tryParseJsonObject(rawResponse);

  if (!parsed || typeof parsed.is_valid_search !== "boolean") {
    return {
      ...titleHeuristic(results),
      rawResponse,
      threshold,
      reason: "vision_parse_failed"
    };
  }

  return {
    isValidSearch: parsed.is_valid_search && Number(parsed.confidence ?? 0) >= threshold,
    confidence: Number(parsed.confidence ?? 0),
    reason: String(parsed.reason ?? "vision_result"),
    method: "vision_page_review",
    rawResponse,
    threshold
  };
}

// 每一轮先保存页面级产物；商品级结果只在有效搜索或最终最佳图挑选时再落盘。
async function saveRoundArtifacts(runDir, searchRow, page, pageScreenshotLimit) {
  const roundTag = `material-${searchRow.material_id}-round-${searchRow.search_round}`;
  const roundsDir = path.join(runDir, "rounds");
  const imagesDir = path.join(runDir, "images");
  await ensureDir(imagesDir);

  const resultsPath = path.join(roundsDir, `${roundTag}-results.json`);

  const pageScreenshotPath = path.join(imagesDir, `${roundTag}-page.jpg`);
  if (pageScreenshotLimit > 0) {
    await page.screenshot({ path: pageScreenshotPath, type: "jpeg", quality: 70, fullPage: false });
  }

  return {
    resultsPath,
    pageScreenshotPath: pageScreenshotLimit > 0 ? pageScreenshotPath : "",
    sampleImagePath: "",
    sampleImageError: ""
  };
}

function inferImageExtensionFromUrl(imageUrl) {
  try {
    return path.extname(new URL(normalizeUrl(imageUrl || "")).pathname) || ".jpg";
  } catch {
    return ".jpg";
  }
}

async function persistRoundResults(resultsPath, results) {
  await ensureDir(path.dirname(resultsPath));
  await writeFile(resultsPath, JSON.stringify(results, null, 2), "utf8");
}

function buildSummaryTopResults(results, limit = 10) {
  return (Array.isArray(results) ? results : []).slice(0, limit).map((item, index) => ({
    index: Number(item.index ?? index + 1),
    title: normalizeSpace(item.title),
    offer_url: item.offerUrl ?? "",
    image_url: normalizeUrl(item.imageUrl ?? ""),
    merchant_name: normalizeSpace(item.merchantName ?? ""),
    merchant_url: item.merchantUrl ?? "",
    wangwang_url: item.contact?.url ?? item.wangwangUrl ?? "",
    offer_screenshot_path: item.offerScreenshotPath ?? "",
    detected_contacts: Array.isArray(item.detectedContacts) ? item.detectedContacts : item.detectedContacts === null ? null : [],
    contact_page_url: item.contactPageUrl ?? "",
    has_phone_contact: Boolean(item.hasPhoneContact),
    qualification_status: String(item.qualificationStatus ?? ""),
    contact_collection_reason: String(item.contactCollectionReason ?? ""),
  }));
}

function buildBestImageCandidateScore(candidate) {
  return (
    (candidate.title_hits_name ? 40 : 0) +
    (candidate.title_hits_material ? 20 : 0) +
    (candidate.is_valid_search ? 10 : 0) +
    Number(candidate.visual_confidence ?? 0) * 3 -
    (Number(candidate.result_index ?? 1) - 1) * 0.5
  );
}

function dedupeBestImageCandidates(candidates) {
  const seen = new Set();
  const output = [];

  for (const candidate of candidates) {
    const key = normalizeSpace(candidate.offer_url || candidate.image_url || `${candidate.search_round}-${candidate.result_index}`);
    if (!key || seen.has(key)) {
      continue;
    }

    seen.add(key);
    output.push(candidate);
  }

  return output;
}

function pickBestImageCandidateHeuristic(candidates) {
  return [...candidates].sort((left, right) => buildBestImageCandidateScore(right) - buildBestImageCandidateScore(left))[0] ?? null;
}

async function filterCandidatesByAiRelevance(client, candidates, aiKeywordAnalysis) {
  if (!client || !aiKeywordAnalysis || candidates.length === 0) {
    return candidates;
  }

  // 取 search_round 最小的前 2 个轮次的候选（最多 20 条）
  const sortedRounds = [...new Set(candidates.map((c) => c.search_round))].sort((a, b) => a - b);
  const top2Rounds = new Set(sortedRounds.slice(0, 2));
  const pool = candidates.filter((c) => top2Rounds.has(c.search_round));

  const componentRolesText = typeof aiKeywordAnalysis.component_roles === "string"
    ? aiKeywordAnalysis.component_roles
    : JSON.stringify(aiKeywordAnalysis.component_roles ?? {});

  const titlesText = pool.map((c, i) => `${i + 1}. ${c.title ?? ""}`).join("\n");
  const promptBundle = await resolveSearchPrompt(
    SEARCH_PROMPT_KEYS.candidateFilter,
    "chat_bundle",
  );
  const prompt = renderPromptTemplate(
    getRequiredPayloadString(promptBundle, "user_template"),
    {
      product_type: aiKeywordAnalysis.product_type ?? "",
      structure_summary: aiKeywordAnalysis.structure_summary ?? "",
      component_roles: componentRolesText,
      suggested_keyword: aiKeywordAnalysis.suggested_keyword ?? "",
      candidate_titles_text: titlesText,
    },
  );
  const systemPrompt = getRequiredPayloadString(promptBundle, "system");

  try {
    const completion = await createSearchVisionCompletion(client, "candidateFilter", {
      model: DEFAULT_VISUAL_MODEL,
      temperature: 0,
      messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: prompt }
      ],
      response_format: { type: "json_object" }
    });

    const rawResponse = completion.choices?.[0]?.message?.content ?? "";
    const parsed = tryParseJsonObject(rawResponse);
    const selected = Array.isArray(parsed?.selected) ? parsed.selected : [];
    const validIndices = selected
      .map((v) => Number(v))
      .filter((v) => Number.isInteger(v) && v >= 1 && v <= pool.length);

    if (validIndices.length === 0) {
      return candidates;
    }

    // 只返回 LLM 选中的条目，不追加未选中的
    const selectedCandidates = validIndices.map((i) => pool[i - 1]);
    return selectedCandidates;
  } catch {
    return candidates;
  }
}

async function selectBestImageCandidateWithAi(client, candidates) {
  if (!client || candidates.length === 0) {
    return null;
  }

  const shortlisted = candidates.slice(0, 6);

  const promptBundle = await resolveSearchPrompt(
    SEARCH_PROMPT_KEYS.bestImageSelection,
    "multimodal_bundle",
  );
  const introText = renderPromptTemplate(
    getRequiredPayloadString(promptBundle, "intro_template"),
    {},
  );
  const candidateTextTemplate = getRequiredPayloadString(promptBundle, "candidate_text_template");
  const systemPrompt = getRequiredPayloadString(promptBundle, "system");

  const content = [
    {
      type: "text",
      text: introText,
    }
  ];

  shortlisted.forEach((candidate, index) => {
    content.push({
      type: "text",
      text: renderPromptTemplate(candidateTextTemplate, {
        candidate_index: index + 1,
        title: candidate.title ?? "",
        search_round: candidate.search_round ?? "",
        primary_keyword: candidate.primary_keyword ?? "",
        is_valid_search: candidate.is_valid_search ? "是" : "否",
        visual_confidence: Number(candidate.visual_confidence ?? 0).toFixed(2),
        ai_keyword_analysis_summary: candidate.ai_keyword_analysis_summary ?? "无",
      })
    });

    if (candidate.image_url) {
      content.push({
        type: "image_url",
        image_url: {
          url: normalizeUrl(candidate.image_url)
        }
      });
    }
  });

  const completion = await createSearchVisionCompletion(client, "bestImageSelection", {
    model: DEFAULT_VISUAL_MODEL,
    temperature: 0,
    messages: [
      {
        role: "system",
        content: systemPrompt
      },
      {
        role: "user",
        content
      }
    ],
    response_format: { type: "json_object" }
  });

  const rawResponse = completion.choices?.[0]?.message?.content ?? "";
  const parsed = tryParseJsonObject(rawResponse);
  const selectedIndex = Number(parsed?.selected_index ?? 0);

  if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > shortlisted.length) {
    return null;
  }

  return {
    candidate: shortlisted[selectedIndex - 1],
    method: "ai_visual_selection",
    reason: String(parsed?.reason ?? "ai_selected_best_image"),
    usedAiKeywordAnalysis: Boolean(shortlisted[selectedIndex - 1]?.ai_keyword_analysis_summary)
  };
}

async function finalizeBestMatchImage(client, runDir, materialId, candidates, options, aiKeywordAnalysis = null) {
  const uniqueCandidates = dedupeBestImageCandidates(candidates).filter((candidate) => candidate.image_url);

  if (uniqueCandidates.length === 0) {
    return null;
  }

  // LLM 标题筛选：从前两轮候选中筛出符合采购需求的商品，排在前面
  const filteredCandidates = await filterCandidatesByAiRelevance(client, uniqueCandidates, aiKeywordAnalysis);

  let picked = null;

  try {
    picked = await selectBestImageCandidateWithAi(client, filteredCandidates);
  } catch {
    picked = null;
  }

  const chosenCandidate = picked?.candidate ?? pickBestImageCandidateHeuristic(filteredCandidates);
  if (!chosenCandidate?.image_url) {
    return null;
  }

  const imagesDir = path.join(runDir, "images");
  await ensureDir(imagesDir);

  const imagePath = path.join(imagesDir, `material-${materialId}-best-match${inferImageExtensionFromUrl(chosenCandidate.image_url)}`);
  await saveImage(chosenCandidate.image_url, imagePath);

  const bestMatch = {
    material_id: materialId,
    image_path: imagePath,
    offer_url: chosenCandidate.offer_url,
    title: chosenCandidate.title,
    merchant_name: chosenCandidate.merchant_name ?? "",
    merchant_url: chosenCandidate.merchant_url ?? "",
    wangwang_url: chosenCandidate.wangwang_url ?? "",
    selected_from_round: chosenCandidate.search_round,
    selected_from_keyword: chosenCandidate.primary_keyword,
    ai_keyword_analysis_used: Boolean(chosenCandidate.ai_keyword_analysis_summary),
    ai_keyword_analysis_summary: chosenCandidate.ai_keyword_analysis_summary ?? "",
    method: picked?.method ?? "heuristic_fallback",
    reason: picked?.reason ?? "heuristic_best_image",
    fallback_used: !picked,
    fallback_reason: picked ? "" : "ai_selection_unavailable_or_invalid"
  };

  // 选出 LLM 筛选后排在前面的 10 个 runner-up（排除已选 best match）
  const chosenKey = chosenCandidate.offer_url || chosenCandidate.image_url;
  const extendedCandidates = applyResultLimit(
    filteredCandidates.filter((c) => (c.offer_url || c.image_url) !== chosenKey),
    options.resultLimit
  );

  return { bestMatch, extendedCandidates };
}

async function generateExtendedBestMatches(context, runDir, materialId, extendedCandidates, options) {
  const extDir = path.join(runDir, "extended-best-matches");
  const extImagesDir = path.join(extDir, "images");
  const extScreenshotsDir = path.join(extDir, "offer-screenshots");
  await ensureDir(extImagesDir);
  await ensureDir(extScreenshotsDir);

  const records = [];

  for (let i = 0; i < extendedCandidates.length; i += 1) {
    const candidate = extendedCandidates[i];
    const rank = i + 1;

    let imagePath = "";
    try {
      imagePath = path.join(extImagesDir, `material-${materialId}-extra-${rank}${inferImageExtensionFromUrl(candidate.image_url)}`);
      await saveImage(candidate.image_url, imagePath);
    } catch {
      imagePath = "";
    }

    // 复用 enrichResultsWithContacts 的逻辑，构造 camelCase 格式的 record
    const extOfferScreenshotPath = path.join(extScreenshotsDir, `offer-${rank}.jpg`);
    const recordForEnrich = {
      offerUrl: candidate.offer_url ?? "",
      imageUrl: candidate.image_url ?? "",
      merchantName: candidate.merchant_name ?? "",
      merchantUrl: candidate.merchant_url ?? "",
      title: candidate.title ?? "",
      index: rank
    };
    const enriched = await enrichResultsWithContacts(context, [recordForEnrich], { ...options, offerScreenshotPath: extOfferScreenshotPath });
    const detectedContacts = enriched.enrichedResults[0]?.detectedContacts ?? [];
    const offerScreenshotPath = enriched.enrichedResults[0]?.offerScreenshotPath ?? "";

    records.push({
      rank,
      image_path: imagePath,
      offer_url: candidate.offer_url ?? "",
      image_url: candidate.image_url ?? "",
      title: candidate.title ?? "",
      merchant_name: candidate.merchant_name ?? "",
      merchant_url: candidate.merchant_url ?? "",
      wangwang_url: candidate.wangwang_url ?? "",
      offer_screenshot_path: offerScreenshotPath,
      score: buildBestImageCandidateScore(candidate),
      selected_from_round: candidate.search_round,
      selected_from_keyword: candidate.primary_keyword ?? "",
      detected_contacts: detectedContacts,
      contact_page_url: enriched.enrichedResults[0]?.contactPageUrl ?? ""
    });
  }

  const reportPath = path.join(extDir, "extended-best-matches-report.json");
  await writeFile(reportPath, JSON.stringify({
    material_id: materialId,
    generated_at: new Date().toISOString(),
    count: records.length,
    records
  }, null, 2), "utf8");

  return reportPath;
}

// ── 关键词命中率：跨页统计搜索词在商品标题中的出现率 ────────────────
const DEFAULT_HIT_RATE_MAX_MERCHANTS = Number.parseInt(process.env.SEARCH_HIT_RATE_MAX_MERCHANTS ?? "1000", 10);
const DEFAULT_HIT_RATE_RESULTS_PER_PAGE = 60;

async function computeKeywordHitRate(page, keyword, options = {}) {
  const maxMerchants = Number.isInteger(options.maxMerchants) && options.maxMerchants > 0
    ? options.maxMerchants
    : DEFAULT_HIT_RATE_MAX_MERCHANTS;
  if (!normalizeSpace(keyword)) {
    return {
      hit_count: 0,
      total_count: 0,
      hit_rate: 0,
      pages_scanned: 0,
      total_pages: 0,
      source: "local",
      reason: "empty_keyword",
      sample_count: 0,
      llm_confidence: null,
      llm_reasoning_summary: "",
      llm_raw_response: "",
    };
  }

  const maxPages = Math.ceil(maxMerchants / DEFAULT_HIT_RATE_RESULTS_PER_PAGE);
  let canonicalUrl = page.url();

  // 确保基准 URL 包含关键词，防止在空关键词 URL 上翻页
  try {
    const urlObj = new URL(canonicalUrl);
    const urlKeywords = urlObj.searchParams.get("keywords") ?? "";
    if (normalizeSpace(urlKeywords).length === 0) {
      const fixedUrl = build1688OfferSearchUrl(keyword);
      log(`[hit-rate] canonicalUrl 缺少关键词 (${canonicalUrl.slice(0, 120)})，修正为: ${fixedUrl}`);
      await waitForVerificationClear(page, { logPrefix: "[hit-rate][导航前验证码]" });
      await page.goto(fixedUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
      await antiDetectPause();
      await waitForVerificationClear(page, { logPrefix: "[hit-rate][验证码]" });
      await page.waitForSelector(OFFER_LINK_SELECTOR, { timeout: 15000 }).catch(() => {});
      await humanScrollPage(page, 6);
      canonicalUrl = page.url();
    }
  } catch {
    // URL 解析失败，继续使用原始 URL
  }

  let fallbackSamples = [];
  const llmSampleCount = clampPositiveInt(options.llmSampleCount, DEFAULT_HIT_RATE_LLM_SAMPLE_COUNT);
  const llmMinSampleCount = clampPositiveInt(options.llmMinSampleCount, DEFAULT_HIT_RATE_LLM_MIN_SAMPLE_COUNT);

  try {
    const firstPageUrl = replaceRawQueryParam(canonicalUrl, "beginPage", null);
    if (firstPageUrl !== canonicalUrl) {
      await waitForVerificationClear(page, { logPrefix: "[hit-rate][重置前验证码]" });
      await page.goto(firstPageUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
      await antiDetectPause();
      await waitForVerificationClear(page, { logPrefix: "[hit-rate][验证码]" });
      await page.waitForSelector(OFFER_LINK_SELECTOR, { timeout: 15000 }).catch(() => {});
      await humanScrollPage(page, 6);
      canonicalUrl = firstPageUrl;
    }
  } catch (error) {
    log(`[hit-rate] 重置到第一页失败: ${error instanceof Error ? error.message : String(error)}`);
  }

  // 读取当前页面的总页数
  const totalPages = await page.evaluate(() => {
    const bodyText = document.body?.innerText ?? "";
    const match = bodyText.match(/共\s*(\d+)\s*页/);
    return match ? parseInt(match[1], 10) : 1;
  }).catch(() => 1);

  const pagesToScan = Math.min(totalPages, maxPages);
  let hitCount = 0;
  let totalCount = 0;
  let scannedPages = 0;
  const baseUrl = canonicalUrl;

  for (let pageNum = 1; pageNum <= pagesToScan; pageNum += 1) {
    if (pageNum > 1) {
      try {
        const nextUrl = replaceRawQueryParam(baseUrl, "beginPage", String(pageNum));
        await waitForVerificationClear(page, { logPrefix: "[hit-rate][翻页前验证码]" });
        await page.goto(nextUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
        await antiDetectPause();
        await waitForVerificationClear(page, { logPrefix: "[hit-rate][验证码]" });
        await page.waitForSelector(OFFER_LINK_SELECTOR, { timeout: 15000 }).catch(() => {});
        await humanScrollPage(page, 6);
      } catch (error) {
        log(`[hit-rate] 翻页到第 ${pageNum} 页失败: ${error instanceof Error ? error.message : String(error)}`);
        break;
      }
    }

    const pageResults = await page.evaluate(
      ({ extractorSource, take }) => {
        const extract = new Function(`return (${extractorSource});`)();
        return extract(document, take, { visibleOnly: true }).map((item) => ({
          title: item.title ?? "",
          rawCardText: item.rawCardText ?? "",
          highlightTexts: Array.isArray(item.highlightTexts) ? item.highlightTexts : [],
        }));
      },
      { extractorSource: SEARCH_RESULT_EXTRACTOR_SOURCE, take: DEFAULT_HIT_RATE_RESULTS_PER_PAGE }
    ).catch(() => []);

    fallbackSamples = collectKeywordHitRateFallbackSamples(fallbackSamples, pageResults, llmSampleCount);

    if (pageResults.length === 0 && pageNum > 1) {
      break;
    }

    if (pageResults.length > 0) {
      scannedPages += 1;
    }

    for (const item of pageResults) {
      totalCount += 1;
      if (keywordMatchesSearchResult(keyword, item)) {
        hitCount += 1;
      }
      if (totalCount >= maxMerchants) {
        break;
      }
    }

    if (totalCount >= maxMerchants) {
      break;
    }
  }

  let hitRate = totalCount > 0 ? hitCount / totalCount : 0;
  let hitRateSource = "local";
  let hitRateReason = totalCount > 0 ? (hitCount > 0 ? "local_token_match" : "local_zero_hit") : "no_results";
  let llmConfidence = null;
  let llmReasoningSummary = "";
  let llmRawResponse = "";

  const fallbackDecision = shouldRunKeywordHitRateLlmFallback({
    enabled: options.enableLlmFallback,
    client: options.client,
    totalCount,
    localHitCount: hitCount,
    localHitRate: hitRate,
    samples: fallbackSamples,
    minSampleCount: llmMinSampleCount,
  });

  if (fallbackDecision.shouldRun) {
    const fallbackResult = await estimateKeywordHitRateWithLlm(options.client, keyword, fallbackSamples, { totalCount });
    hitRateReason = fallbackResult.reason;
    llmConfidence = fallbackResult.confidence;
    llmReasoningSummary = fallbackResult.reasoning_summary ?? "";
    llmRawResponse = fallbackResult.raw_response ?? "";

    if (fallbackResult.applied) {
      hitRate = fallbackResult.hit_rate;
      hitCount = fallbackResult.hit_count;
      hitRateSource = fallbackResult.source;
    }
  } else if (fallbackDecision.reason && fallbackDecision.reason !== "local_hits_present") {
    hitRateReason = fallbackDecision.reason;
  }

  log(
    `[hit-rate] 关键词="${keyword}" | 命中=${hitCount}/${totalCount} | 命中率=${(hitRate * 100).toFixed(1)}% | 扫描页=${scannedPages}/${totalPages} | 来源=${hitRateSource}${hitRateReason ? ` | 原因=${hitRateReason}` : ""}`
  );

  return {
    hit_count: hitCount,
    total_count: totalCount,
    hit_rate: Number(hitRate.toFixed(4)),
    pages_scanned: scannedPages,
    total_pages: totalPages,
    source: hitRateSource,
    reason: hitRateReason,
    sample_count: fallbackSamples.length,
    llm_confidence: llmConfidence,
    llm_reasoning_summary: llmReasoningSummary,
    llm_raw_response: llmRawResponse,
  };
}

// ── 命中商家优先选择：标题包含搜索词所有 token 的商家排前面 ──────
function selectKeywordHitMerchants(validationResults, keyword, limit, options = {}) {
  if (!normalizeSpace(keyword) || validationResults.length === 0) {
    return applyResultLimit(validationResults, limit);
  }

  const hitItems = [];
  const missItems = [];

  for (const item of validationResults) {
    if (keywordMatchesSearchResult(keyword, item)) {
      hitItems.push(item);
    } else {
      missItems.push(item);
    }
  }

  if (shouldStrictlySelectKeywordHits(options) && hitItems.length > 0) {
    return applyResultLimit(hitItems, limit);
  }

  // 命中商家优先，不足时用未命中的补充到 limit
  const combined = [...hitItems, ...missItems];
  return applyResultLimit(combined, limit);
}

// 单轮执行失败不会中断整批，只把错误写进本轮摘要并继续下一轮。
async function runSingleRound(context, client, runDir, searchRow, options, aiKeywordAnalysis = null) {
  const startedAt = new Date().toISOString();
  let resultsPage = null;
  const aiKeywordAnalysisSummary = buildAiKeywordAnalysisSummary(aiKeywordAnalysis);
  const fixedRuntimeStage = isFixedRuntimeStage(options);
  let executedKeyword = searchRow.primary_keyword;
  let attemptedKeywords = [searchRow.primary_keyword];

  try {
    const openedResults = await openSearchResultsForKeyword(
      context,
      searchRow.primary_keyword,
      options.timeoutMs,
      { ...options, strictQueryVariant: shouldUseStrictQueryVariant(searchRow) },
    );
    resultsPage = openedResults.page;
    executedKeyword = openedResults.resolvedKeyword;
    attemptedKeywords = Array.isArray(openedResults.attemptedKeywords) && openedResults.attemptedKeywords.length > 0
      ? openedResults.attemptedKeywords
      : [executedKeyword];
    const validationResults = fixedRuntimeStage
      ? await extractResultsAcrossPages(resultsPage, options.validationResultLimit, {
        scrollSteps: options.validationScrollSteps,
        pageCount: DEFAULT_FIXED_RUNTIME_PAGE_COUNT,
        perPageLimit: DEFAULT_FIXED_RUNTIME_RESULTS_PER_PAGE,
      })
      : await extractTopResults(resultsPage, options.validationResultLimit, {
        scrollSteps: options.validationScrollSteps
      });
    const results = fixedRuntimeStage
      ? validationResults
      : selectKeywordHitMerchants(validationResults, searchRow.primary_keyword, options.validationResultLimit, {
        strictHitOnly: options.strictHitOnly,
      });
    const artifacts = await saveRoundArtifacts(runDir, searchRow, resultsPage, options.pageScreenshotLimit);

    let visual = fixedRuntimeStage
      ? {
        isValidSearch: false,
        confidence: 0,
        reason: validationResults.length === 0 ? "no_results" : "fixed_runtime_collect_all_rounds",
        method: validationResults.length === 0 ? "none" : "fixed_runtime",
        rawResponse: "",
        threshold: options.threshold,
        visual: null,
        text_rules: null,
        image_rules: null,
      }
      : {
        isValidSearch: false,
        confidence: 0,
        reason: validationResults.length === 0 ? "no_results" : "visual_check_skipped",
        method: validationResults.length === 0 ? "none" : "skipped",
        rawResponse: "",
        threshold: options.threshold
      };

    if (!fixedRuntimeStage && validationResults.length > 0 && artifacts.pageScreenshotPath) {
      try {
        visual = await evaluateSearchValidity(
          client,
          resultsPage,
          artifacts.pageScreenshotPath,
          searchRow,
          validationResults,
          options.threshold
        );
      } catch (error) {
        const fallback = titleHeuristic(validationResults);
        visual = {
          ...fallback,
          rawResponse: error instanceof Error ? error.message : String(error),
          threshold: options.threshold,
          reason: "vision_error_fallback"
        };
      }
    }

    let finalResults = results;
    let qualifiedResults = [];
    let enrichmentSummary = null;
    let semanticSummary = null;
    let contactEnrichmentAttempted = false;
    const shouldForceContactEnrichment = shouldEnrichContactsForConfiguredStage(options);

    if (!fixedRuntimeStage && !visual.isValidSearch && searchRow.keyword_source === "progressive") {
      semanticSummary = await evaluateTopTitleSemantics(client, searchRow, results.slice(0, options.semanticTitleCount));
    }

    // 关键词命中率：跨页扫描统计搜索词在商品标题中的出现率
    let keywordHitRateStats = fixedRuntimeStage ? {
      hit_count: 0,
      total_count: validationResults.length,
      hit_rate: 0,
      pages_scanned: Math.min(DEFAULT_FIXED_RUNTIME_PAGE_COUNT, Math.ceil(validationResults.length / DEFAULT_FIXED_RUNTIME_RESULTS_PER_PAGE)),
      total_pages: DEFAULT_FIXED_RUNTIME_PAGE_COUNT,
      source: "fixed_runtime",
      reason: "fixed_runtime_deferred",
      sample_count: 0,
      llm_confidence: null,
      llm_reasoning_summary: "",
      llm_raw_response: "",
    } : {
      hit_count: 0,
      total_count: 0,
      hit_rate: 0,
      pages_scanned: 0,
      total_pages: 0,
      source: "local",
      reason: validationResults.length > 0 ? "local_zero_hit" : "no_validation_results",
      sample_count: 0,
      llm_confidence: null,
      llm_reasoning_summary: "",
      llm_raw_response: "",
    };
    if (!fixedRuntimeStage && validationResults.length > 0) {
      try {
        keywordHitRateStats = await computeKeywordHitRate(resultsPage, executedKeyword, {
          client,
          enableLlmFallback: DEFAULT_HIT_RATE_LLM_FALLBACK,
          llmSampleCount: DEFAULT_HIT_RATE_LLM_SAMPLE_COUNT,
          llmMinSampleCount: DEFAULT_HIT_RATE_LLM_MIN_SAMPLE_COUNT,
        });
      } catch (error) {
        log(`[hit-rate] 命中率计算失败: ${error instanceof Error ? error.message : String(error)}`);
        keywordHitRateStats.reason = "hit_rate_calculation_failed";
      }
    }

    let brandKeywordHitRateStats = {
      hit_count: 0,
      total_count: 0,
      hit_rate: 0,
      pages_scanned: 0,
      total_pages: 0,
      source: "local",
      reason: validationResults.length > 0 ? "local_zero_hit" : "no_validation_results",
      sample_count: 0,
      llm_confidence: null,
      llm_reasoning_summary: "",
      llm_raw_response: "",
    };
    if (!fixedRuntimeStage && validationResults.length > 0 && options.brandGuardKeyword) {
      try {
        brandKeywordHitRateStats = await computeKeywordHitRate(resultsPage, options.brandGuardKeyword, {
          client,
          enableLlmFallback: false,
        });
      } catch (error) {
        log(`[brand-hit-rate] 品牌命中率计算失败: ${error instanceof Error ? error.message : String(error)}`);
        brandKeywordHitRateStats.reason = "brand_hit_rate_calculation_failed";
      }
    }

    const brandZeroHitStop =
      !fixedRuntimeStage
      && Boolean(options.brandGuardKeyword)
      && brandKeywordHitRateStats.total_count > 0
      && brandKeywordHitRateStats.hit_rate === 0;

    if (brandZeroHitStop) {
      finalResults = [];
    }

    const contactEnrichmentSkipReason = fixedRuntimeStage
      ? "fixed_runtime_deferred"
      : deriveContactEnrichmentSkipReason({
        brandZeroHitStop,
        resultCount: results.length,
        isValidSearch: visual.isValidSearch,
        shouldForceContactEnrichment,
      });

    // 配置型阶段即使未通过 valid-search 判定，也要抓取该阶段命中的商家联系方式。
    if (fixedRuntimeStage) {
      finalResults = results.map((item) => buildFixedRuntimeDeferredResult(item));
      qualifiedResults = [...finalResults];
      enrichmentSummary = {
        contact_limit: 0,
        source_candidate_count: validationResults.length,
        scanned_products_count: 0,
        matched_products_count: 0,
        detected_contacts_count: 0,
        blocked_pages_count: 0,
        failed_pages_count: 0,
        qualified_result_count: finalResults.length,
        rejected_no_phone_count: 0,
        target_result_count: validationResults.length,
        shortage_reason: "",
        navigation_failed_count: 0,
      };
    } else if (!contactEnrichmentSkipReason) {
      contactEnrichmentAttempted = true;
      // 有效轮次：先用 LLM 按采购需求筛选重排，再抓联系方式和持久化
      let sortedResults = results;

      if (visual.isValidSearch) {
        const resultsAsSelectionCandidates = results.map((item, index) => ({
          search_round: searchRow.search_round,
          title: item.title,
          offer_url: item.offerUrl,
          image_url: item.imageUrl,
          merchant_name: item.merchantName,
          merchant_url: item.merchantUrl,
          result_index: Number(item.index ?? index + 1)
        }));
        const filteredForRound = await filterCandidatesByAiRelevance(client, resultsAsSelectionCandidates, aiKeywordAnalysis);
        // 把 LLM 筛选后的排序映射回原始 results 数组顺序
        const filteredOfferUrls = filteredForRound.map((c) => c.offer_url).filter(Boolean);
        sortedResults = [
          ...filteredOfferUrls.map((url) => results.find((r) => r.offerUrl === url)).filter(Boolean),
          ...results.filter((r) => !filteredOfferUrls.includes(r.offerUrl))
        ];
      }

      finalResults = sortedResults;

      // 联系方式抓取作为增强步骤：失败不影响结果持久化
      try {
        const offerScreenshotsDir = path.join(runDir, "offer-screenshots");
        await ensureDir(offerScreenshotsDir);
        const enriched = await enrichResultsWithContacts(context, sortedResults, {
          ...options,
          screenshotDir: offerScreenshotsDir,
          sourceCandidateCount: validationResults.length,
          targetQualifiedCount: options.resultLimit,
        });
        finalResults = enriched.enrichedResults;
        qualifiedResults = enriched.qualifiedResults;
        enrichmentSummary = enriched.enrichmentSummary;
      } catch (contactError) {
        log(`[contact-enrichment] ⚠️ 联系方式抓取异常，保留原始结果: ${contactError instanceof Error ? contactError.message : String(contactError)}`);
        finalResults = sortedResults.map((item) => buildEnrichedContactResult(item, {
          detectedContacts: [],
          contactPageUrl: "",
          offerScreenshotPath: "",
          contactCollectionReason: "contact_enrichment_failed",
        }));
        qualifiedResults = [];
      }
    } else if (!brandZeroHitStop) {
      finalResults = results.map((item) => buildEnrichedContactResult(item, {
        detectedContacts: [],
        contactPageUrl: "",
        offerScreenshotPath: "",
        contactCollectionReason: contactEnrichmentSkipReason || "contact_enrichment_skipped",
      }));
      qualifiedResults = [];
    }

    const noContactCount = fixedRuntimeStage
      ? 0
      : finalResults.filter((item) => item.qualificationStatus === "rejected_no_phone").length;
    if (noContactCount > 0) {
      log(`[contact-enrichment] ${noContactCount} 个商家无电话/手机（仅审计保留，不计入成功结果）`);
    }

    // 每轮都持久化结果（不论是否 valid），保证跨轮累计入库
    if (results.length > 0 || brandZeroHitStop) {
      await persistRoundResults(artifacts.resultsPath, finalResults);
    } else {
      artifacts.resultsPath = "";
    }

    const selectionCandidates = finalResults.map((item, index) => ({
      material_id: searchRow.material_id,
      search_round: searchRow.search_round,
      primary_keyword: executedKeyword,
      title: item.title,
      offer_url: item.offerUrl,
      image_url: item.imageUrl,
      merchant_name: item.merchantName,
      merchant_url: item.merchantUrl,
      wangwang_url: item.contact?.url ?? "",
      result_index: Number(item.index ?? index + 1),
      is_valid_search: visual.isValidSearch,
      visual_confidence: visual.confidence,
      name_keyword_hit_rate: visual.text_rules?.name_keyword_hit_rate ?? 0,
      material_hit_rate: visual.text_rules?.material_hit_rate ?? 0,
      unrelated_ratio: visual.text_rules?.unrelated_ratio ?? 0,
      image_similarity_ratio: visual.image_rules?.dominant_cluster_ratio ?? 0,
      ai_keyword_analysis_summary: aiKeywordAnalysisSummary
    }));

    const summaryTopResults = fixedRuntimeStage
      ? finalResults
      : (qualifiedResults.length > 0 ? qualifiedResults : finalResults);
    const summaryResultCount = fixedRuntimeStage ? finalResults.length : qualifiedResults.length;

    const summary = {
      material_id: searchRow.material_id,
      primary_keyword: searchRow.primary_keyword,
      executed_keyword: executedKeyword,
      keyword_retry_used: executedKeyword !== searchRow.primary_keyword,
      attempted_keywords: attemptedKeywords,
      search_round: searchRow.search_round,
      search_strategy: searchRow.search_strategy,
      keyword_source: searchRow.keyword_source,
      stage: searchRow.keyword_source === "dynamic_ai_regenerated" ? "dynamic_ai" : "progressive",
      model_used: DEFAULT_VISUAL_MODEL,
      started_at: startedAt,
      finished_at: new Date().toISOString(),
      entered_results_page: true,
      results_count: summaryResultCount,
      validation_results_count: validationResults.length,
      source_candidate_count: validationResults.length,
      qualified_result_count: summaryResultCount,
      rejected_no_phone_count: fixedRuntimeStage ? 0 : noContactCount,
      target_result_count: fixedRuntimeStage ? validationResults.length : options.resultLimit,
      shortage_reason: fixedRuntimeStage
        ? ""
        : (enrichmentSummary?.shortage_reason ?? (options.requirePhoneContact && qualifiedResults.length < options.resultLimit
          ? (validationResults.length < options.resultLimit ? "insufficient_source_candidates" : "source_exhausted_before_target")
          : "")),
      top_title: summaryTopResults[0]?.title ?? "",
      top_offer_url: summaryTopResults[0]?.offerUrl ?? "",
      top_results: buildSummaryTopResults(summaryTopResults),
      results_path: artifacts.resultsPath,
      page_screenshot_path: artifacts.pageScreenshotPath,
      sample_image_path: "",
      sample_image_error: "",
      stop_reason: fixedRuntimeStage
        ? (validationResults.length > 0 ? "fixed_runtime_continue" : "no_results")
        : (brandZeroHitStop ? "brand_zero_hit_stop" : (visual.isValidSearch ? "vision_text_image_majority_match" : "continue_next_round")),
      is_valid_search: fixedRuntimeStage ? false : visual.isValidSearch,
      visual_confidence: visual.confidence,
      visual_reason: visual.reason,
      visual_method: visual.method,
      visual_raw_response: visual.rawResponse,
      visual_rule_passed: visual.visual?.isValidSearch ?? false,
      visual_rule_reason: visual.visual?.reason ?? "",
      semantic_used: Boolean(semanticSummary),
      semantic_method: semanticSummary?.method ?? "",
      semantic_reason: semanticSummary?.reason ?? "",
      semantic_found_terms: semanticSummary?.found_terms ?? [],
      semantic_interference_terms: semanticSummary?.interference_terms ?? [],
      semantic_noise_term_count: semanticSummary?.noise_term_count ?? 0,
      semantic_noise_terms_detected: semanticSummary?.noise_terms_detected ?? false,
      semantic_should_stop_progressive: semanticSummary?.should_stop_progressive ?? false,
      semantic_raw_response: semanticSummary?.rawResponse ?? "",
      name_keyword_hit_rate: visual.text_rules?.name_keyword_hit_rate ?? 0,
      material_hit_rate: visual.text_rules?.material_hit_rate ?? 0,
      unrelated_ratio: visual.text_rules?.unrelated_ratio ?? 0,
      text_rule_passed: visual.text_rules?.isValid ?? false,
      text_rule_reason: visual.text_rules?.reason ?? "",
      image_similarity_ratio: visual.image_rules?.dominant_cluster_ratio ?? 0,
      image_similarity_sample_count: visual.image_rules?.sample_count ?? 0,
      image_rule_passed: visual.image_rules?.isValid ?? false,
      image_rule_reason: visual.image_rules?.reason ?? "",
      ai_keyword_analysis_available: Boolean(aiKeywordAnalysisSummary),
      contact_limit: enrichmentSummary?.contact_limit ?? 0,
      contact_enrichment_attempted: fixedRuntimeStage ? false : contactEnrichmentAttempted,
      contact_enrichment_skip_reason: contactEnrichmentSkipReason,
      require_phone_contact: fixedRuntimeStage ? false : options.requirePhoneContact,
      scanned_products_count: enrichmentSummary?.scanned_products_count ?? 0,
      matched_products_count: enrichmentSummary?.matched_products_count ?? 0,
      detected_contacts_count: enrichmentSummary?.detected_contacts_count ?? 0,
      blocked_pages_count: enrichmentSummary?.blocked_pages_count ?? 0,
      failed_pages_count: enrichmentSummary?.failed_pages_count ?? 0,
      brand_keyword: fixedRuntimeStage ? "" : (options.brandGuardKeyword || ""),
      brand_keyword_hit_rate: brandKeywordHitRateStats.hit_rate,
      brand_keyword_hit_count: brandKeywordHitRateStats.hit_count,
      brand_keyword_hit_rate_total_count: brandKeywordHitRateStats.total_count,
      brand_zero_hit_stop: brandZeroHitStop,
      keyword_hit_rate: keywordHitRateStats.hit_rate,
      keyword_hit_rate_hit_count: keywordHitRateStats.hit_count,
      keyword_hit_rate_total_count: keywordHitRateStats.total_count,
      keyword_hit_rate_pages_scanned: keywordHitRateStats.pages_scanned,
      keyword_hit_rate_total_pages: keywordHitRateStats.total_pages,
      keyword_hit_rate_source: keywordHitRateStats.source,
      keyword_hit_rate_reason: keywordHitRateStats.reason,
      keyword_hit_rate_sample_count: keywordHitRateStats.sample_count,
      keyword_hit_rate_llm_confidence: keywordHitRateStats.llm_confidence,
      keyword_hit_rate_llm_reasoning: keywordHitRateStats.llm_reasoning_summary,
      keyword_hit_rate_llm_raw_response: keywordHitRateStats.llm_raw_response,
      error: ""
    };

    await closePageSafely(resultsPage);

    return {
      summary,
      selectionCandidates
    };
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : String(error);
    const errorStack = error instanceof Error ? error.stack : "";
    const currentUrl = (() => { try { return resultsPage ? resultsPage.url() : "(无页面)"; } catch { return "(URL获取失败)"; } })();
    const isLogin = typeof currentUrl === "string" && isLoginUrl(currentUrl);
    const roundFailureReason = classifyRoundFailureReason(error, currentUrl);
    log(`[runSingleRound] ❌ 整轮异常 | material_id=${searchRow.material_id} round=${searchRow.search_round} keyword="${executedKeyword}" | URL=${currentUrl} | 是否登录页=${isLogin} | 错误: ${errorMessage}`);
    if (errorStack) {
      log(`[runSingleRound] Stack: ${errorStack}`);
    }
    const summary = {
      material_id: searchRow.material_id,
      primary_keyword: searchRow.primary_keyword,
      executed_keyword: executedKeyword,
      keyword_retry_used: executedKeyword !== searchRow.primary_keyword,
      attempted_keywords: attemptedKeywords,
      search_round: searchRow.search_round,
      search_strategy: searchRow.search_strategy,
      keyword_source: searchRow.keyword_source,
      stage: searchRow.keyword_source === "dynamic_ai_regenerated" ? "dynamic_ai" : "progressive",
      model_used: DEFAULT_VISUAL_MODEL,
      started_at: startedAt,
      finished_at: new Date().toISOString(),
      entered_results_page: Boolean(resultsPage),
      results_count: 0,
      validation_results_count: 0,
      source_candidate_count: 0,
      qualified_result_count: 0,
      rejected_no_phone_count: 0,
      target_result_count: options.resultLimit,
      shortage_reason: "",
      top_title: "",
      top_offer_url: "",
      top_results: [],
      results_path: "",
      page_screenshot_path: "",
      sample_image_path: "",
      sample_image_error: "",
      stop_reason: "continue_after_error",
      round_failure_reason: roundFailureReason,
      round_failure_detail: errorMessage,
      is_valid_search: false,
      visual_confidence: 0,
      visual_reason: roundFailureReason,
      visual_method: "none",
      visual_raw_response: "",
      visual_rule_passed: false,
      visual_rule_reason: "",
      semantic_used: false,
      semantic_method: "",
      semantic_reason: "",
      semantic_found_terms: [],
      semantic_interference_terms: [],
      semantic_noise_term_count: 0,
      semantic_noise_terms_detected: false,
      semantic_should_stop_progressive: false,
      semantic_raw_response: "",
      name_keyword_hit_rate: 0,
      material_hit_rate: 0,
      unrelated_ratio: 0,
      text_rule_passed: false,
      text_rule_reason: "",
      image_similarity_ratio: 0,
      image_similarity_sample_count: 0,
      image_rule_passed: false,
      image_rule_reason: "",
      ai_keyword_analysis_available: Boolean(aiKeywordAnalysisSummary),
      contact_limit: 0,
      contact_enrichment_attempted: false,
      contact_enrichment_skip_reason: "",
      require_phone_contact: options.requirePhoneContact,
      scanned_products_count: 0,
      matched_products_count: 0,
      detected_contacts_count: 0,
      blocked_pages_count: 0,
      failed_pages_count: 0,
      brand_keyword: options.brandGuardKeyword || "",
      brand_keyword_hit_rate: 0,
      brand_keyword_hit_count: 0,
      brand_keyword_hit_rate_total_count: 0,
      brand_zero_hit_stop: false,
      keyword_hit_rate: 0,
      keyword_hit_rate_hit_count: 0,
      keyword_hit_rate_total_count: 0,
      keyword_hit_rate_pages_scanned: 0,
      keyword_hit_rate_total_pages: 0,
      keyword_hit_rate_source: "local",
      keyword_hit_rate_reason: roundFailureReason,
      keyword_hit_rate_sample_count: 0,
      keyword_hit_rate_llm_confidence: null,
      keyword_hit_rate_llm_reasoning: "",
      keyword_hit_rate_llm_raw_response: "",
      error: `${errorMessage}${isLogin ? " [登录页拦截]" : ""}${currentUrl !== "(无页面)" ? ` [URL: ${currentUrl}]` : ""}`
    };

    await closePageSafely(resultsPage);

    return {
      summary,
      selectionCandidates: []
    };
  }
}

function buildExecutionOverview(runSummary) {
  const rounds = Array.isArray(runSummary.rounds) ? runSummary.rounds : [];
  const validRound = rounds.find((item) => item.is_valid_search) ?? null;
  const totalResults = rounds.reduce((sum, item) => sum + Number(item.results_count ?? 0), 0);
  const totalScannedProducts = rounds.reduce((sum, item) => sum + Number(item.scanned_products_count ?? 0), 0);
  const totalMatchedProducts = rounds.reduce((sum, item) => sum + Number(item.matched_products_count ?? 0), 0);
  const totalDetectedContacts = rounds.reduce((sum, item) => sum + Number(item.detected_contacts_count ?? 0), 0);
  const totalContactEnrichmentRounds = rounds.filter((item) => item.contact_enrichment_attempted).length;
  const totalSkippedContactEnrichmentRounds = rounds.filter((item) => item.contact_enrichment_skip_reason).length;
  const totalLlmHitRateFallbackRounds = rounds.filter((item) => item.keyword_hit_rate_source === "llm_fallback").length;
  const totalErrors = rounds.filter((item) => item.error).length;
  const startedAtMs = Date.parse(runSummary.started_at ?? "");
  const finishedAtMs = Date.parse(runSummary.finished_at ?? "");

  return {
    purpose: "search_plan_run_report",
    material_ids_count: Array.isArray(runSummary.material_ids) ? runSummary.material_ids.length : 0,
    executed_rounds_count: rounds.length,
    valid_search_hits: rounds.filter((item) => item.is_valid_search).length,
    final_status: validRound ? "stopped_on_valid_search" : totalErrors > 0 ? "completed_with_errors" : "completed_without_valid_search",
    total_results_collected: totalResults,
    total_scanned_products: totalScannedProducts,
    total_matched_products: totalMatchedProducts,
    total_detected_contacts: totalDetectedContacts,
    total_contact_enrichment_rounds: totalContactEnrichmentRounds,
    total_contact_enrichment_skipped_rounds: totalSkippedContactEnrichmentRounds,
    total_llm_hit_rate_fallback_rounds: totalLlmHitRateFallbackRounds,
    total_best_match_images: Array.isArray(runSummary.best_match_images) ? runSummary.best_match_images.length : 0,
    total_error_rounds: totalErrors,
    stopped_at: validRound
      ? {
          material_id: validRound.material_id,
          search_round: validRound.search_round,
          primary_keyword: validRound.primary_keyword,
          reason: validRound.stop_reason
        }
      : null,
    duration_seconds:
      Number.isFinite(startedAtMs) && Number.isFinite(finishedAtMs) && finishedAtMs >= startedAtMs
        ? Number(((finishedAtMs - startedAtMs) / 1000).toFixed(1))
        : null
  };
}

async function main() {
  const options = parseArgs(process.argv.slice(2));
  const rows = await loadSearchPlanRows(options.inputPath);
  const aiKeywordAnalysisMap = await loadAiKeywordAnalysisMap(options.inputPath);
  const groupedRows = groupRows(rows, options.materialIds, options.maxRound);

  if (groupedRows.size === 0) {
    throw new Error("没有匹配到可执行的搜索计划记录");
  }

  const runDir = path.join(options.outputRoot, `run-${nowStamp()}`);
  await ensureDir(runDir);
  await ensureDir(PROFILE_DIR);

  const client = buildVisionClient();
  const context = await chromium.launchPersistentContext(PROFILE_DIR, {
    channel: "chrome",
    headless: options.headless,
    locale: "zh-CN",
    viewport: { width: 1440, height: 960 },
    args: buildSearchBrowserArgs()
  });
  await installLowCpuRouteFilters(context);
  const verificationMonitor = attachVerificationMonitor(context, { logPrefix: "[search-plan][验证码]" });
  emitSearchProgress("正在检查 1688 登录态...", 8, 18);
  const loginWarmup = await ensure1688LoggedIn(context, {
    timeoutMs: options.timeoutMs,
    headless: options.headless,
    accountName: process.env.LOGIN_1688_USER,
    password: process.env.LOGIN_1688_PASS,
  });
  if (!loginWarmup.ok) {
    throw new Error(loginWarmup.reason || "搜索前登录态预热失败");
  }
  await waitForVerificationClear(loginWarmup.page, { logPrefix: "[search-plan][登录交接验证码]" }).catch(() => false);
  await waitForManualVerificationIfNeeded(loginWarmup.page).catch(() => false);
  emitLoginSessionReady(loginWarmup.page);
  emitSearchProgress("登录态已确认，开始搜索...", 18, 30);
  log(`搜索前登录态预热完成: ${loginWarmup.status}，首轮搜索将复用当前页面 ${loginWarmup.page.url()}`);

  const runSummary = {
    report_type: "1688_search_plan_run_summary",
    report_version: 3,
    report_purpose: "记录一次搜索计划驱动的 1688 搜索执行结果与命中情况",
    started_at: new Date().toISOString(),
    input_path: options.inputPath,
    output_dir: runDir,
    headless: options.headless,
    resource_profile: resolveSearchResourceProfile(),
    result_limit: options.resultLimit,
    search_result_limit: options.resultLimit,
    validation_result_limit: options.validationResultLimit,
    semantic_title_count: options.semanticTitleCount,
    contact_limit: options.contactLimit,
    model_used: DEFAULT_VISUAL_MODEL,
    hit_rate_llm_fallback_enabled: DEFAULT_HIT_RATE_LLM_FALLBACK,
    hit_rate_llm_sample_count: DEFAULT_HIT_RATE_LLM_SAMPLE_COUNT,
    hit_rate_llm_min_sample_count: DEFAULT_HIT_RATE_LLM_MIN_SAMPLE_COUNT,
    threshold: options.threshold,
    material_ids: [...groupedRows.keys()],
    rounds: [],
    best_match_images: [],
    execution_overview: null
  };

  log(`输入文件: ${options.inputPath}`);
  log(`输出目录: ${runDir}`);
  log(`执行 material_id: ${[...groupedRows.keys()].join(", ")}`);
  log(`浏览器模式: ${options.headless ? "无界面" : "有界面"}`);
  log(`视觉判定: ${client ? `启用 ${DEFAULT_VISUAL_MODEL}` : "未配置 LLM，自动回退标题相似度判断"}`);
  log(`ai_keyword_analysis: ${aiKeywordAnalysisMap.size > 0 ? `已加载 ${aiKeywordAnalysisMap.size} 条同批次上下文` : "未找到同批次 keyword-process.json，best match 将仅使用候选信息与回退规则"}`);

  try {
    // 首轮搜索必须复用登录预热后的 page，避免通过滑块后的页面被关闭再重开。
    let warmupSearchEntryPage = loginWarmup.page;
    for (const [materialId, originalGroup] of groupedRows.entries()) {
      const group = [...originalGroup];
      let regenerationUsed = false;
      const bestImageCandidates = [];
      const seenMerchantKeys = new Set();
      const materialOptions = { ...options, seenMerchantKeys, searchEntryPage: warmupSearchEntryPage };
      warmupSearchEntryPage = null;
      log(`开始处理 material_id=${materialId}，共 ${group.length} 轮`);

      for (let index = 0; index < group.length; index += 1) {
        const row = group[index];
        const aiKeywordAnalysis = aiKeywordAnalysisMap.get(row.material_id) ?? null;
        log(`轮次 ${index + 1}/${group.length} | search_round=${row.search_round} | 关键词=${row.primary_keyword}`);

        const roundResult = await runSingleRound(context, client, runDir, row, materialOptions, aiKeywordAnalysis);
        const roundSummary = roundResult.summary;
        bestImageCandidates.push(...roundResult.selectionCandidates);
        runSummary.rounds.push(roundSummary);

        log(
          `保存前 ${roundSummary.results_count} 条结果 | 整页判定样本=${roundSummary.validation_results_count} | 有效搜索=${roundSummary.is_valid_search ? "是" : "否"} | ` +
            `判定=${roundSummary.visual_reason}${roundSummary.keyword_retry_used ? ` | 实际搜索词=${roundSummary.executed_keyword}` : ""} | 语义=${roundSummary.semantic_used ? `${roundSummary.semantic_noise_terms_detected ? `干扰词=${(roundSummary.semantic_interference_terms || []).join("/")}` : "未触发"}` : "未检"} | 文本=${roundSummary.text_rule_passed ? "通过" : "未过"}(${roundSummary.name_keyword_hit_rate}/${roundSummary.material_hit_rate}/${roundSummary.unrelated_ratio}) | ` +
            `图片=${roundSummary.image_rule_passed ? "通过" : "未过"}(${roundSummary.image_similarity_ratio})${roundSummary.detected_contacts_count > 0 ? ` | 联系方式=${roundSummary.detected_contacts_count}` : ""}${
              roundSummary.scanned_products_count > 0 ? ` | 扫描商品=${roundSummary.scanned_products_count}` : ""
            }${roundSummary.contact_enrichment_skip_reason ? ` | 联系方式跳过=${roundSummary.contact_enrichment_skip_reason}` : ""}${roundSummary.keyword_hit_rate_total_count > 0 ? ` | 命中率=${(roundSummary.keyword_hit_rate * 100).toFixed(1)}%(${roundSummary.keyword_hit_rate_hit_count}/${roundSummary.keyword_hit_rate_total_count})[${roundSummary.keyword_hit_rate_source}]` : ""}${roundSummary.brand_keyword ? ` | 品牌命中率=${(Number(roundSummary.brand_keyword_hit_rate ?? 0) * 100).toFixed(1)}%` : ""}${roundSummary.sample_image_path ? ` | 样本图=${roundSummary.sample_image_path}` : ""}${
              roundSummary.error ? ` | 错误=${roundSummary.error}` : ""
            }`
        );

        if (roundSummary.brand_zero_hit_stop) {
          log(`material_id=${materialId} 品牌词“${roundSummary.brand_keyword || ""}”命中率为 0%，停止后续阶段。`);
          break;
        }

        if (
          roundSummary.semantic_should_stop_progressive &&
          row.keyword_source === "progressive" &&
          !regenerationUsed
        ) {
          log(
            `检测到干扰项=${(roundSummary.semantic_interference_terms || []).join("/") || "无"}，停止剩余 progressive 轮次，直接转图搜。`
          );
          break;
        }

        const summaryPath = path.join(runDir, "run-summary.json");
        await writeFile(summaryPath, JSON.stringify(runSummary, null, 2), "utf8");

        if (roundSummary.is_valid_search) {
          log(`material_id=${materialId} 在第 ${row.search_round} 轮命中有效搜索，停止后续轮次。`);
          break;
        }

        if (index < group.length - 1) {
          const { min: roundDelayMinMs, max: roundDelayMaxMs } = resolveInterRoundDelayRange(options.delayBetweenRoundsMs);
          await antiDetectPause(roundDelayMinMs, roundDelayMaxMs);
        }
      }

      if (isFixedRuntimeStage(options)) {
        continue;
      }

      const finalizeResult = await finalizeBestMatchImage(
        client,
        runDir,
        materialId,
        bestImageCandidates,
        options,
        aiKeywordAnalysisMap.get(materialId) ?? null
      );
      const bestMatchImage = finalizeResult?.bestMatch ?? null;
      const extendedCandidates = finalizeResult?.extendedCandidates ?? [];

      if (bestMatchImage) {
        runSummary.best_match_images.push(bestMatchImage);
        log(`material_id=${materialId} 已保存最佳参考图: ${bestMatchImage.image_path}`);
      }

      if (extendedCandidates.length > 0) {
        try {
          const extReportPath = await generateExtendedBestMatches(context, runDir, materialId, extendedCandidates, materialOptions);
          log(`material_id=${materialId} 已生成扩展最佳匹配报告 (${extendedCandidates.length} 条): ${extReportPath}`);
        } catch (extError) {
          log(`material_id=${materialId} 扩展最佳匹配生成失败: ${extError instanceof Error ? extError.message : String(extError)}`);
        }
      }
    }

    runSummary.finished_at = new Date().toISOString();
    runSummary.execution_overview = buildExecutionOverview(runSummary);
    const summaryPath = path.join(runDir, "run-summary.json");
    await writeFile(summaryPath, JSON.stringify(runSummary, null, 2), "utf8");
    log(`执行完成，汇总文件: ${summaryPath}`);
  } finally {
    verificationMonitor.dispose();
    if (!options.keepBrowserOpen) {
      await closeContextSafely(context, "search_plan");
    }
  }
}

main()
  .then(() => {
    process.exit(0);
  })
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
