> ## Documentation Index
> Fetch the complete documentation index at: https://docs.context.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a sourced competitor comparison

> Collect comparable product and pricing facts, retain sources and observation times, and keep interpretation separate from evidence.

export const AgentSetupPrompt = ({children, variant = "setup"}) => {
  const isRecipe = variant === "recipe";
  const promptLabel = isRecipe ? "recipe prompt" : "setup prompt";
  const [copyState, setCopyState] = useState("idle");
  const contentRef = useRef(null);
  const detailsRef = useRef(null);
  const timerRef = useRef(null);
  const mountedRef = useRef(true);
  const pendingRef = useRef(false);
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      if (timerRef.current !== null) clearTimeout(timerRef.current);
    };
  }, []);
  const copyPrompt = useCallback(async () => {
    if (pendingRef.current) return;
    pendingRef.current = true;
    if (timerRef.current !== null) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    setCopyState("copying");
    try {
      const code = contentRef.current?.querySelector("pre code") ?? contentRef.current?.querySelector("pre");
      const prompt = code?.textContent;
      if (typeof prompt !== "string" || !prompt.trim()) {
        throw new Error("Prompt is not available.");
      }
      if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
        throw new Error("Clipboard access is not available.");
      }
      await navigator.clipboard.writeText(prompt);
      if (!mountedRef.current) return;
      setCopyState("copied");
      timerRef.current = setTimeout(() => {
        if (mountedRef.current) setCopyState("idle");
        timerRef.current = null;
      }, 4000);
    } catch {
      if (!mountedRef.current) return;
      if (detailsRef.current) detailsRef.current.open = true;
      setCopyState("error");
    } finally {
      pendingRef.current = false;
    }
  }, []);
  const feedback = copyState === "copied" ? `${isRecipe ? "Recipe" : "Setup"} prompt copied. Paste it into your coding agent.` : copyState === "error" ? "Couldn't copy automatically. The prompt is open below. Select and copy the text manually." : "";
  return <section className="agent-setup-prompt not-prose" aria-label={`Agent ${promptLabel}`}>
      <style>{`
        .agent-setup-prompt {
          --asp-surface: oklch(0.985 0.005 255);
          --asp-border: oklch(0.9 0.012 255);
          --asp-title: oklch(0.25 0.025 255);
          --asp-text: oklch(0.45 0.021 255);
          --asp-accent: oklch(0.49 0.2 264);
          --asp-accent-hover: oklch(0.44 0.19 264);
          --asp-on-accent: oklch(0.985 0.005 255);
          --asp-focus: oklch(0.6 0.19 255);
          margin: 1.5rem 0;
          border: 1px solid var(--asp-border);
          border-radius: 0.875rem;
          background: var(--asp-surface);
          color: var(--asp-title);
          font-family: inherit;
          min-width: 0;
        }
        .dark .agent-setup-prompt {
          --asp-surface: oklch(0.2 0.014 255);
          --asp-border: oklch(0.33 0.016 255);
          --asp-title: oklch(0.96 0.006 255);
          --asp-text: oklch(0.78 0.013 255);
          --asp-accent: oklch(0.73 0.15 255);
          --asp-accent-hover: oklch(0.8 0.11 255);
          --asp-on-accent: oklch(0.19 0.025 255);
          --asp-focus: oklch(0.78 0.14 255);
        }
        .agent-setup-prompt__header { padding: 1.125rem 1.25rem; }
        .agent-setup-prompt__row {
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
          gap: 1.25rem;
        }
        .agent-setup-prompt__intro { min-width: 0; }
        .agent-setup-prompt__title {
          margin: 0;
          font-size: 1.125rem;
          font-weight: 650;
          line-height: 1.4;
          color: var(--asp-title);
        }
        .agent-setup-prompt__subtitle {
          max-width: 65ch;
          margin: 0.375rem 0 0;
          font-size: 0.875rem;
          line-height: 1.6;
          color: var(--asp-text);
        }
        .agent-setup-prompt__copy {
          display: inline-flex;
          flex-shrink: 0;
          align-items: center;
          justify-content: center;
          gap: 0.5rem;
          min-height: 2.75rem;
          padding: 0.625rem 0.875rem;
          border: 1px solid transparent;
          border-radius: 0.5rem;
          background: var(--asp-accent);
          color: var(--asp-on-accent);
          font: inherit;
          font-size: 0.8125rem;
          font-weight: 600;
          line-height: 1.4;
          cursor: pointer;
        }
        .agent-setup-prompt__copy:hover:not(:disabled) {
          background: var(--asp-accent-hover);
        }
        .agent-setup-prompt__copy:disabled { cursor: wait; }
        .agent-setup-prompt__copy:focus-visible,
        .agent-setup-prompt__summary:focus-visible {
          outline: 2px solid var(--asp-focus);
          outline-offset: 3px;
        }
        .agent-setup-prompt__feedback {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
          line-height: 1.5;
          color: var(--asp-text);
        }
        .agent-setup-prompt__feedback:empty { margin: 0; }
        .agent-setup-prompt__details { border-top: 1px solid var(--asp-border); }
        .agent-setup-prompt__summary {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          padding: 0.75rem 1.25rem;
          border-radius: 0 0 0.875rem 0.875rem;
          font-size: 0.8125rem;
          font-weight: 500;
          line-height: 1.5;
          color: var(--asp-text);
          list-style: none;
          cursor: pointer;
        }
        .agent-setup-prompt__summary::-webkit-details-marker { display: none; }
        .agent-setup-prompt__summary:hover { color: var(--asp-title); }
        .agent-setup-prompt__details[open] .agent-setup-prompt__chevron {
          transform: rotate(90deg);
        }
        .agent-setup-prompt__content { min-width: 0; padding: 0 1.25rem 1rem; }
        .agent-setup-prompt__content > :first-child { margin-top: 0; }
        .agent-setup-prompt__content > :last-child { margin-bottom: 0; }
        .agent-setup-prompt__content pre {
          max-height: 32rem;
          overflow: auto;
          white-space: pre-wrap;
          overflow-wrap: anywhere;
        }
        @media (max-width: 640px) {
          .agent-setup-prompt__header { padding: 1rem; }
          .agent-setup-prompt__row { flex-direction: column; gap: 0.875rem; }
          .agent-setup-prompt__copy { width: 100%; }
          .agent-setup-prompt__summary { padding: 0.75rem 1rem; }
          .agent-setup-prompt__content { padding: 0 1rem 1rem; }
        }
      `}</style>

      <div className="agent-setup-prompt__header">
        <div className="agent-setup-prompt__row">
          <div className="agent-setup-prompt__intro">
            <h2 className="agent-setup-prompt__title">
              {isRecipe ? "Have your agent implement this recipe" : "Set up Context.dev"}
            </h2>
            <p className="agent-setup-prompt__subtitle">
              {isRecipe ? "Copy this prompt into your coding agent to build the recipe in your project." : "Paste into your coding agent. It will ask about your goal and project before making changes."}
            </p>
          </div>
          <button className="agent-setup-prompt__copy" type="button" onClick={copyPrompt} disabled={copyState === "copying"} aria-busy={copyState === "copying"}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true" focusable="false">
              {copyState === "copied" ? <path d="m5 12 4 4L19 6" strokeLinecap="round" strokeLinejoin="round" /> : <>
                  <rect x="8" y="8" width="12" height="12" rx="2" />
                  <path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
                </>}
            </svg>
            {copyState === "copied" ? "Copied" : copyState === "copying" ? "Copying…" : `Copy ${promptLabel}`}
          </button>
        </div>
        <p className="agent-setup-prompt__feedback" role="status" aria-live="polite" aria-atomic="true">
          {feedback}
        </p>
      </div>

      <details className="agent-setup-prompt__details" ref={detailsRef}>
        <summary className="agent-setup-prompt__summary">
          <svg className="agent-setup-prompt__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true" focusable="false">
            <path d="m9 5 7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          {`View ${promptLabel}`}
        </summary>
        <div className="agent-setup-prompt__content" ref={contentRef}>
          {children}
        </div>
      </details>
    </section>;
};

<AgentSetupPrompt variant="recipe">
  ```text Recipe prompt theme={null}
  Implement this recipe in my project:
  https://docs.context.dev/use-cases/competitor-research

  Read the recipe and linked API guides, inspect this project's stack, and build the complete flow using its existing conventions.

  Build a comparison from a reviewed competitor list using selected product and pricing pages. Extract nullable facts with source URLs and observation times. Compare only compatible currencies, units, billing periods, commitments, variants, markets, and conditions. Keep unknown prices distinct from zero, show stale or failed sources, and separate sourced facts from analysis.

  Reuse existing Context.dev configuration and keep secret API keys on the server. Add focused tests, run the relevant checks, and document setup and how to try the result.
  ```
</AgentSetupPrompt>

Build a comparison that readers can check against the original pages. Start with a reviewed competitor list, extract a consistent set of facts, then compare only compatible plans, units, and markets.

Context.dev provides source discovery, page content, structured extraction, and change monitoring. Competitor selection, strategic analysis, and the comparison interface belong to your application.

## Review the comparison scope

Accept a list of product or company URLs, or use [Search](/api-reference/web-scraping/search) to propose candidates. Confirm their identities and relevance before adding them to the comparison. A similar name or a search result alone does not establish that two products compete.

Write down the comparison question, geography, and intended product variant. For a pricing comparison, collect both the displayed billing basis and any conditions such as annual commitment, seat minimums, usage tiers, promotions, or taxes.

## Extract a fixed set of facts

Use a server-side key from the [Quickstart](/quickstart). Read selected pricing or product pages with [Markdown](/guides/scrape-websites-to-markdown), or send the following body through [Extract](/guides/extract-structured-data-from-websites#make-an-extraction-request). The extraction guide includes cURL and every SDK.

```json Extract request body theme={null}
{
  "url": "https://example.com/pricing",
  "maxPages": 1,
  "maxDepth": 0,
  "factCheck": true,
  "schema": {
    "type": "object",
    "properties": {
      "plan": { "type": ["string", "null"], "description": "The exact plan name being compared." },
      "amount": { "type": ["number", "null"], "description": "Displayed price amount; null for unstated or contact-sales pricing." },
      "currency": { "type": ["string", "null"], "description": "Explicit currency code; null when ambiguous." },
      "billing_basis": { "type": ["string", "null"], "description": "Stated period and commitment, such as per month billed annually." },
      "unit": { "type": ["string", "null"], "description": "What the price buys, such as one seat or one account." },
      "conditions": { "type": ["string", "null"], "description": "Visible minimums, usage limits, geography, taxes, or promotional conditions." }
    },
    "required": ["plan", "amount", "currency", "billing_basis", "unit", "conditions"],
    "additionalProperties": false
  }
}
```

Name the intended plan in `instructions` when the page contains several. Use nullable fields and `factCheck: true` to keep unsupported information unknown. Store `urls_analyzed`, the original extracted values, cache metadata, and the time the request completed.

The analyzed URLs describe the request's evidence set. For a claim that needs an exact source, verify a supporting excerpt and retain the specific URL with that field. Review partial coverage and blocked-page counts before accepting a row.

## Normalize without hiding commercial differences

Convert the raw result into a reviewed application model. Keep annual commitments and monthly billing in different groups, even when both pages display a monthly equivalent.

```typescript comparison.ts theme={null}
type Offer = {
  id: string;
  company: string;
  plan: string;
  amount: number | null;
  currency: string | null;
  billingBasis: "monthly" | "annual" | "monthly_equivalent_annual_commitment" | null;
  unit: string | null;
  market: string | null;
  variant: string | null;
  conditions: string | null;
  sourceUrl: string;
  observedAt: string;
  status: "current" | "stale" | "failed";
};

export function comparableGroup(offer: Offer): string | null {
  if (offer.status !== "current" || offer.amount === null ||
      !Number.isFinite(offer.amount) || offer.amount < 0 ||
      !offer.currency || !offer.billingBasis || !offer.unit ||
      !offer.market || !offer.variant || !offer.conditions) return null;
  return JSON.stringify([
    offer.currency, offer.billingBasis, offer.unit, offer.market, offer.variant,
    offer.conditions,
  ]);
}

export function comparisonRows(offers: Offer[]) {
  return offers.map((offer) => ({
    company: offer.company,
    plan: offer.plan,
    price: offer.amount === null ? "Unknown" :
      `${offer.amount} ${offer.currency ?? "(currency unknown)"}`,
    billingBasis: offer.billingBasis ?? "Unknown",
    unit: offer.unit ?? "Unknown",
    conditions: offer.conditions ?? "Not established",
    group: comparableGroup(offer),
    sourceUrl: offer.sourceUrl,
    observedAt: offer.observedAt,
    status: offer.status,
  }));
}
```

Assign normalized fields such as `market` and `variant` only after review. The grouping function is conservative: identical keys identify rows eligible for further comparison, not proof that their features or service levels are equivalent. Keep rows without a group visible as incomplete rather than ranking them as the cheapest offer.

If you convert currency, save the exchange-rate source, date, and original amount. If you normalize a package price, retain the package quantity and the conversion rule. A simple comparison can avoid these conversions and show the original prices with their stated basis.

## Separate facts from interpretation

Render a table with the plan, observed price, commercial conditions, source link, observation time, and freshness state. Keep analytical conclusions in a separate section that cites the rows it uses.

| Output         | Example treatment                                         |
| -------------- | --------------------------------------------------------- |
| Extracted fact | A stated price, with its billing basis and source         |
| Missing fact   | “Not stated” or “Could not verify,” never zero            |
| Interpretation | An explicitly labeled assessment based on selected facts  |
| Failed refresh | Last accepted value marked stale, plus the failed attempt |

A generated opinion should not be written back into extracted-fact fields. This workflow also does not supply SEO or GEO visibility metrics, ranking lift, or a validated competitor ranking; those require separate data and analysis.

## Refresh and review changes

Use [Monitors](/guides/monitor-website-changes) on the reviewed source pages, or run intentional periodic extractions. A detected change can queue a new observation for the affected row. Compare it with the previous snapshot before accepting changes to plan identity, currency, or billing basis.

Store attempts independently from accepted facts, as in the [dataset workflow](/use-cases/structured-web-datasets). A timeout must not erase the last known price or be described as a price removal.

Try two compatible offers, one annual-commitment offer, an unstated price, and a failed refresh. Check that only compatible rows share a group, every fact has a source and time, and no interpretation appears as an extracted fact.

<CardGroup cols={2}>
  <Card title="Structured datasets" icon="database" href="/use-cases/structured-web-datasets">
    Persist observations and refresh records without duplicates.
  </Card>

  <Card title="Website change digests" icon="bell" href="/use-cases/website-change-digests">
    Turn source changes into a reviewable research feed.
  </Card>
</CardGroup>
