Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Parse every JSON-LD block, keep each price attached to its plan, currency and billing-term fields, then compare the result with the pricing page’s visible labels and billing toggle. A price alone does not tell you whether a customer pays monthly or commits to a year. When the markup and page disagree—or the cadence is missing—keep the candidates separate and mark the cadence unresolved instead of guessing.

What JSON-LD can—and cannot—tell you about SaaS prices

JSON-LD is structured data embedded in a page, commonly inside a <script type="application/ld+json"> element. A pricing page may describe a plan and its offer there, but the markup is not guaranteed to include every plan, currency, billing choice or current promotion shown to visitors.

Schema.org provides vocabulary for prices and billing terms. Its PriceSpecification type represents a price or price range; UnitPriceSpecification extends it with fields such as billingDuration, billingIncrement, billingStart and priceType. A publisher can omit these fields, so their absence does not establish a monthly or annual cadence.

Google’s requirements are a separate matter. Its SoftwareApplication structured-data guidance shows an Offer with price and priceCurrency. For Google’s software-app rich result, Google lists name and offers.price as required, and recommends currency when the price is greater than zero. That does not mean every SaaS pricing page will mark up every plan or billing option, or that Schema.org support for a property guarantees a Google feature.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Capture the page and its context first

Before parsing, retain the requested URL, final URL after redirects, retrieval time, HTTP response status and a copy or hash of the response body. Record the locale or market when known: the same plan may have different prices or currencies across regions. If the request returns a block page, challenge or other non-pricing response, do not treat its contents as a valid offer.

Follow applicable site access rules. Also determine whether the relevant content is in the initial HTML or appears only after JavaScript runs. Whether a rendered browser is needed depends on the target page; a successful request for initial HTML does not prove that it contains the pricing state a visitor sees.

Parse every JSON-LD block without losing its graph context

Find every script element whose type is application/ld+json and parse each independently. A block may contain one object, an array, or an object with an @graph. Record the block index and any parse error rather than silently discarding malformed data. Google’s SoftwareApplication documentation includes an example of JSON-LD embedded in an HTML script.

This Python starter extracts entities that declare an offers property and preserves each offer alongside its parent entity and location in the parsed JSON. It deliberately returns raw values: normalization and deciding whether a value is the displayed monthly price require further checks.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import json
from bs4 import BeautifulSoup

html = open("page.html", encoding="utf-8").read()
soup = BeautifulSoup(html, "html.parser")


def walk(value, path="$", block_index=0):
    if isinstance(value, dict):
        if "offers" in value:
            yield value, path, block_index
        for key, child in value.items():
            yield from walk(child, f"{path}.{key}", block_index)
    elif isinstance(value, list):
        for index, child in enumerate(value):
            yield from walk(child, f"{path}[{index}]", block_index)


for block_index, script in enumerate(
    soup.find_all("script", attrs={"type": "application/ld+json"})
):
    try:
        document = json.loads(script.string or script.get_text())
    except (json.JSONDecodeError, TypeError) as error:
        print({"block_index": block_index, "parse_error": str(error)})
        continue

    for entity, entity_path, index in walk(document, block_index=block_index):
        offers = entity["offers"]
        if not isinstance(offers, list):
            offers = [offers]
        for offer_index, offer in enumerate(offers):
            print({
                "block_index": index,
                "entity_path": entity_path,
                "entity_type": entity.get("@type"),
                "entity_name": entity.get("name"),
                "entity_id": entity.get("@id"),
                "offer_index": offer_index,
                "offer": offer,
            })

The example assumes the response body has already been saved as page.html and uses Beautiful Soup to read the HTML. Keep malformed-block errors and the output’s block and entity paths with your records; production code should also retain the captured page’s provenance. Do not assume every offers value has the same shape: it can be an object or a list, and a price may appear directly on an offer or inside its priceSpecification.

Attach each price to the right plan and terms

Start with the entity that owns the offers relationship. Candidate types may include SoftwareApplication, Product or Service, but the type alone does not prove that an entity is the canonical SaaS plan. Preserve the entity’s name and @id when present, plus its path through the JSON-LD block. This avoids accidentally joining one plan’s price to another plan when a graph contains multiple offers.

For each offer, retain both direct offer fields and nested price-specification fields. Do not collapse them into one value at extraction time. Google’s Product snippet documentation says its processing uses offers.price when both that field and offers.priceSpecification encode an active price, ignoring the nested price for that feature. That is Google’s rule for its documented Product snippet—not a universal rule for your own scraper. Preserve both values and their source paths so your pipeline does not lose a conflict.

A useful record keeps the raw markup as well as any later normalized fields:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Plan/entity name and stable identifier or @id, if supplied.
  • Raw price and normalized numeric value, without discarding the original string.
  • Currency code and the exact JSON-LD path where it appeared.
  • billingDuration, billing increment, or other cadence text when supplied.
  • Per-user, per-seat, minimum-quantity or other price-basis qualifications when encoded.
  • Price type, validity window, setup cost and usage-based components when present.
  • JSON-LD block index, entity path, page URL and retrieval timestamp.

This is a practical record design, not a schema-mandated scraper format. Schema.org describes vocabulary fields; it does not require SaaS publishers to populate them consistently.

Resolve monthly versus annual billing from evidence, not the amount

A label such as “$X / month” can mean a monthly charge or a monthly equivalent for a yearly commitment. The page’s toggle may change what visitors see without changing server-delivered JSON-LD; markup may also expose only one offer. Do not multiply a monthly-looking amount by twelve or infer an annual commitment from the amount alone.

  1. Read the visible offer. Record the displayed price, currency, billing label and any nearby qualification, such as “billed annually.” Keep the exact wording and the page state in which it appeared.
  2. Check the billing toggle. Record the selected option and inspect each relevant state if the page lets visitors switch between monthly and annual pricing. Do not assume that changing the toggle updates the JSON-LD.
  3. Compare structured and visible values. Match an offer to a plan by its entity and plan details, then compare amount, currency and cadence. Keep the source of each observed value clear.
  4. Check checkout terms when accessible. Use them to clarify the charge interval or commitment if the pricing page’s wording is ambiguous. Do not represent a displayed monthly equivalent as a monthly subscription unless the evidence supports that interpretation.
  5. Preserve disagreement. If markup, visible labels or checkout terms conflict, store separate candidates and mark cadence unresolved until you can establish which offer applies. Missing duration is not proof of either monthly or annual billing.

This conservative approach reflects the limits of the available fields: Schema.org can describe billing duration, but neither Schema.org nor Google establishes that SaaS publishers consistently encode every pricing-plan state.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Normalize currency and numbers without erasing the source

Keep price paired with priceCurrency. Do not infer the currency from a symbol such as $ or £, which can represent more than one currency. Schema.org recommends explicit currency codes such as USD and a full stop as the decimal point in structured price values.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Preserve the raw price string even after normalization. Parse decimal separators with locale-aware rules only when the locale is known well enough to disambiguate them; otherwise flag the value for review. A comma and a period can serve different decimal or grouping roles in different locales, so guessing can change the amount.

Validate the captured data and Google’s interpretation separately

Run JSON parsing and checks of your expected data shape against the captured page. These confirm that your code parsed the content and found the fields it handles; they do not show that the fields match the live offer.

For Google-facing structured data, Google recommends testing with the Rich Results Test, correcting critical errors, and inspecting deployed pages with URL Inspection. Eligibility also depends on Google being able to access the page: it must not be blocked by robots.txt, marked noindex or require login. A valid test does not guarantee that a result will appear. After a markup change, Google says re-crawling and re-indexing may take several days; see its SoftwareApplication guidance.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.