僅供詢價商品的 Product Schema:沒有價格時應輸出甚麼
Product Schema for Inquiry-Only Products: What to Emit When There Is No Price
僅供詢價的商品頁應輸出 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:
-
skuvsmpn—skuis your catalogue identifier,mpnis the manufacturer's part number. A distributor usually has both and they differ. Emitting your internal SKU asmpnmakes cross-reference systems match the wrong part. -
image— absolute URLs that return an image. Not a relative path, not adata: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@idvalues 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
Productto 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
PropertyValuetypos and wrong@typenames. - 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", orpriceCurrencywith no price. -
aggregateRatingyou do not have. It is the fastest way to get markup ignored site-wide. -
availability: InStockon a made-to-order item. -
AggregateOfferwithlowPrice: 0standing 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
Productwhile your template also emitsProduct, with different@idvalues, on the same URL.
The short version
- Product detail, quote only:
Product, nooffers. - Put MOQ, lead time and real technical attributes in
additionalProperty. - One organization node site-wide with a
contactPointof typesales. - Variants via
ProductGroup+hasVariant, unique@idper node. - 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