koala73/worldmonitor · error

duplicate FAQ question: ' + question

Error message

 duplicate FAQ question: ' + question

What it means

applyComparisonNarrative normalizes each FAQ question (trimmed, lowercased) into a Set and throws if the same question appears twice within a page's merged FAQ list. Duplicate questions would produce duplicate FAQPage JSON-LD entries and hurt SEO.

Solutions

  1. Find the duplicated question named in the error and remove or reword one instance.
  2. Prefer removing duplicates from extraFaqs rather than the base page faqs.
  3. Reword one question so the trimmed/lowercase forms differ while keeping 8-12 total.
  4. Add a local check that dedupes/casefolds questions before building the narrative.

Example fix

// before
faqs: [['Is it free?', 'Yes']], extraFaqs: [['  is it free?  ', 'Absolutely']]
// after
faqs: [['Is it free?', 'Yes']], extraFaqs: [['What does the free tier include?', 'Map layers and alerts.']]
Defensive patterns

Strategy: validation

Validate before calling

const seen = new Set();
for (const [q] of [...page.faqs, ...(narrative.extraFaqs ?? [])]) {
  const k = String(q).trim().toLowerCase();
  if (seen.has(k)) throw new Error(`duplicate FAQ: ${q}`);
  seen.add(k);
}

Type guard

const faqsUnique = (faqs) => new Set(faqs.map(([q]) => String(q).trim().toLowerCase())).size === faqs.length;

Try / catch

try {
  page = applyComparisonNarrative(page);
} catch (e) {
  if (String(e.message).includes('duplicate FAQ question')) {
    console.error('Dedupe/reword this FAQ:', e.message);
  } else throw e;
}

Prevention

When it happens

Trigger: The merged FAQ list for a slug (page.faqs plus narrative.extraFaqs) contains two questions that are identical after trim/lowercase, e.g. 'Is it free?' and ' is it free? '.

Common situations: Adding an extraFaq that restates a question already on the base page; copy-pasting a FAQ into two narrative sections; near-identical wording that differs only by case or whitespace.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/1bf435623d38b6c0. Report an issue: GitHub.

Appendix: source

Thrown at scripts/build-comparison-pages.mjs:457

      ['What does travel risk intelligence cost?', 'World Monitor is free without signup, with Pro from $39.99/month. Assistance providers such as International SOS and Crisis24 negotiate enterprise retainers with undisclosed list pricing.'],
    ],
  },
];

function applyComparisonNarrative(page) {
  const narrative = COMPARISON_NARRATIVES[page.slug];
  if (!narrative) {
    throw new Error('Missing comparison narrative for ' + page.slug);
  }
  const faqs = [...page.faqs, ...(narrative.extraFaqs ?? [])];
  if (faqs.length < 8 || faqs.length > 12) {
    throw new Error(page.slug + ' FAQ count must be 8-12, got ' + faqs.length);
  }
  const faqNames = new Set();
  for (const [question] of faqs) {
    const key = String(question).trim().toLowerCase();
    if (faqNames.has(key)) {
      throw new Error(page.slug + ' duplicate FAQ question: ' + question);
    }
    faqNames.add(key);
  }
  const merged = { ...page, ...narrative, faqs };
  delete merged.extraFaqs;
  const whyBody = (merged.whyWeWinBody ?? []).join(' ').replace(/\s+/g, ' ').trim();
  if (!whyBody) {
    throw new Error(page.slug + ' is missing whyWeWinBody');
  }
  if (whyBody === merged.whyWeWin.replace(/\s+/g, ' ').trim()) {
    throw new Error(page.slug + ' whyWeWinBody must not repeat whyWeWin');
  }
  if (merged.heading && !(merged.headingProse?.length || merged.competitorProfiles?.length)) {
    throw new Error(page.slug + ' H2 "' + merged.heading + '" has no following prose');
  }
  if (!merged.evaluationHeading || !merged.evaluationProse?.length) {
    throw new Error(page.slug + ' is missing the evaluation section');
  }

View on GitHub (pinned to 7d06c8633d)