跳到正文
DEV Community· AumCreate·· 2 小時前AI 評分53

僅供詢價商品的 Product Schema:沒有價格時應輸出甚麼

Product Schema for Inquiry-Only Products: What to Emit When There Is No Price

AI 導讀

僅供詢價的商品頁應輸出 Product、完全省略 offers,並用 additionalProperty 記錄可確認的商業條款,不得在 price 欄填入 0、N/A 或「price on request」。

正文

A product page with no price is not a broken product page — it is a different commercial model. The markup answer is short: emit Product for the item itself, omit offers entirely, and describe the commercial terms you can actually state with additionalProperty plus a link to the quote path. Do not put 0, "N/A", or "price on request" into a price field.

The cost of that decision is real: these URLs will not be eligible for the product rich result, because that feature is built around an offer with a real price. Take the trade deliberately and write it down in the ticket, or someone will "fix" it in six months by inventing a number.

Here is the process I run on B2B catalogues.

Step 1 — inventory the URL types, not the products

You are deciding markup per template, not per SKU. List the templates:

  • /products/{slug} — one item, quote only
  • /products/{family} — a family with variants
  • /products/{category} — listing
  • /resources/{slug} — datasheet, drawing, STEP file
  • /contact, /request-a-quote — the form

Five templates, five decisions. If you have forty thousand SKUs you still only have five decisions.

Step 2 — pick a type per template

Template Primary type offers?
Product detail, quote only Product No
Product detail, genuinely public price Product Yes — the real price only
Built-to-order / engineering service Service Only if a real price exists
Family page with variants ProductGroup + hasVariant Per variant
Category listing CollectionPage + ItemList No
Datasheet or CAD download TechArticle or DigitalDocument No
Quote form page ContactPage No

The listing template is where people overreach. A category page is not a Product. It is a page that lists products, and ItemList with itemListElement pointing at the detail URLs says exactly that.

Step 3 — the minimum honest Product node

{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://example.com/products/xyz-650v#product",
  "name": "XYZ-650V N-channel power MOSFET",
  "sku": "XYZ-650V-TO247",
  "mpn": "XYZ-650V",
  "brand": { "@type": "Brand", "name": "Example Semiconductor" },
  "category": "Discrete semiconductors > MOSFETs",
  "url": "https://example.com/products/xyz-650v",
  "image": [
    "https://example.com/media/xyz-650v-front.jpg",
    "https://example.com/media/xyz-650v-package.jpg"
  ],
  "description": "650 V N-channel power MOSFET in TO-247. Supplied against order; lead time and pricing are quoted per order quantity."
}

Notes on the fields that matter here:

  • sku vs mpn — sku is your catalogue identifier, mpn is the manufacturer's part number. A distributor usually has both and they differ. Emitting your internal SKU as mpn makes cross-reference systems match the wrong part.
  • image — absolute URLs that return an image. Not a relative path, not a data: URI, not a placeholder.
  • description — put the commercial reality in words: "quoted per order quantity". Other systems read this string, including the LLM-backed ones.
  • @id — a stable fragment per entity. You will reference it from other nodes, and duplicate @id values across blocks are how you end up with two contradictory Products on one page.

Step 4 — put the terms you can state into additionalProperty

PropertyValue is the honest place for the commercial facts that are not prices.

{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://example.com/products/xyz-650v#product",
  "name": "XYZ-650V N-channel power MOSFET",
  "sku": "XYZ-650V-TO247",
  "additionalProperty": [
    { "@type": "PropertyValue", "name": "Drain-source voltage", "value": "650", "unitText": "V" },
    { "@type": "PropertyValue", "name": "Drain current", "value": "40", "unitText": "A" },
    { "@type": "PropertyValue", "name": "Package", "value": "TO-247" },
    { "@type": "PropertyValue", "name": "Minimum order quantity", "value": "500", "unitText": "pieces" },
    { "@type": "PropertyValue", "name": "Quoted lead time", "value": "8-10", "unitText": "weeks" }
  ]
}

Rule: every property here must be visible on the page. Markup that contradicts the page is worse than no markup, because you have now made a machine-readable claim you cannot defend.

MOQ and lead time are the two most useful entries for B2B. They answer the first question a buyer has after "how much", and they are exactly the facts a chat agent or an AI answer engine needs to avoid saying "contact us for details" forever.

Step 5 — make "how to ask" machine-readable

Site-wide organization node, once, on every page:

{
  "@context": "https://schema.org",
  "@type": "Organization",
  "@id": "https://example.com/#organization",
  "name": "Example Semiconductor",
  "url": "https://example.com/",
  "contactPoint": [
    {
      "@type": "ContactPoint",
      "contactType": "sales",
      "email": "sales@example.com",
      "availableLanguage": ["en"],
      "areaServed": ["US", "DE", "SG"]
    }
  ]
}

On the product page, reference it rather than repeating it:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://example.com/products/xyz-650v#product",
  "name": "XYZ-650V N-channel power MOSFET",
  "manufacturer": { "@id": "https://example.com/#organization" }
}

Optionally, point at the quote path:

"potentialAction": {
  "@type": "CommunicateAction",
  "name": "Request a quote",
  "target": "https://example.com/request-a-quote?part=XYZ-650V"
}

Be honest about what this does: no search feature renders it today. It exists so that anything parsing the page — including agent-style readers — can find the path from a part number to a human. If that is not a goal you have, drop it and keep the node smaller.

Step 6 — the cases where an offer is correct

If the price is genuinely public and printed on the page, emit it, and make sure the two match exactly.

{
  "@type": "Offer",
  "price": "0.42",
  "priceCurrency": "USD",
  "url": "https://example.com/products/xyz-650v",
  "eligibleQuantity": {
    "@type": "QuantitativeValue",
    "minValue": 10000,
    "unitText": "pieces"
  }
}

Tiered pricing: either emit the real entry price with the quantity condition attached, as above, or emit nothing. Never emit AggregateOffer with lowPrice: "0" to mean "call us" — zero is a number, and it will be read as one.

Step 7 — variants

A family page is not five Products scattered in five blocks. It is one group with variants:

{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "@id": "https://example.com/products/xyz-family#group",
  "name": "XYZ 650 V MOSFET family",
  "productGroupID": "XYZ-650V",
  "variesBy": ["https://schema.org/model"],
  "hasVariant": [
    {
      "@type": "Product",
      "@id": "https://example.com/products/xyz-650v-to247#product",
      "name": "XYZ-650V in TO-247",
      "sku": "XYZ-650V-TO247",
      "isVariantOf": { "@id": "https://example.com/products/xyz-family#group" }
    },
    {
      "@type": "Product",
      "@id": "https://example.com/products/xyz-650v-d2pak#product",
      "name": "XYZ-650V in D2PAK",
      "sku": "XYZ-650V-D2PAK",
      "isVariantOf": { "@id": "https://example.com/products/xyz-family#group" }
    }
  ]
}

Each variant still keeps its own URL, so each variant page emits its own Product node. The family page emits the group. No node appears twice, and every @id is unique.

Step 8 — validate, and define what "pass" means

Three checks, and they answer different questions:

  • Rich Results Test — will Google show a rich result. For inquiry-only pages, expect Product to be detected and merchant-listing eligibility to be absent. That is the expected result, not a defect.
  • Schema Markup Validator — is the vocabulary valid. This is where you catch PropertyValue typos and wrong @type names.
  • Search Console → Enhancements — you may see no product report entries at all for these URLs. Also expected.

Write the expected outcome into the ticket. The most common regression I see is a later "SEO fix" that adds "price": "0" because a crawler report looked empty.

A quick local check that every page emits exactly one Product node and that the JSON parses:

// node check-jsonld.mjs https://example.com/products/xyz-650v
const url = process.argv[2];
const html = await (await fetch(url)).text();
const blocks = [...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)];

console.log(`${blocks.length} JSON-LD block(s)`);
const types = [];

for (const [, raw] of blocks) {
  let data;
  try {
    data = JSON.parse(raw.trim());
  } catch (err) {
    console.log('  ! invalid JSON:', err.message);
    continue;
  }
  const walk = (node) => {
    if (Array.isArray(node)) return node.forEach(walk);
    if (node && typeof node === 'object') {
      if (node['@type']) types.push(node['@type']);
      Object.values(node).forEach(walk);
    }
  };
  walk(data);
}

console.log('types:', types.join(', '));
console.log('Product nodes:', types.filter((t) => t === 'Product').length);

Run it against ten representative URLs in CI. Two Product nodes on one page is the failure mode that actually costs money.

Failure list

  • "price": "0", "price": "N/A", or priceCurrency with no price.
  • aggregateRating you do not have. It is the fastest way to get markup ignored site-wide.
  • availability: InStock on a made-to-order item.
  • AggregateOffer with lowPrice: 0 standing in for "contact sales".
  • Relative or data: image URLs.
  • JSON built by string concatenation. One " inside a product description and the block stops parsing. Use a JSON encoder.
  • Stale markup left behind after a re-platform, now describing products that no longer exist.
  • A plugin emitting Product while your template also emits Product, with different @id values, on the same URL.

The short version

  1. Product detail, quote only: Product, no offers.
  2. Put MOQ, lead time and real technical attributes in additionalProperty.
  3. One organization node site-wide with a contactPoint of type sales.
  4. Variants via ProductGroup + hasVariant, unique @id per node.
  5. Accept the lost rich result, and record that decision.

Disclosure: I work at AumCreate. We ship this markup on WordPress with AumViso, a free plugin that also handles llms.txt, sitemaps, meta and internal links — the schema module is the part relevant to this article.

來源:DEV Community · dev.to