Dev.to · 13 min read

I Ran a JSON-LD Audit on 25 Pages of My Own Sites. 21 Say They Were Modified Today.

I Ran a JSON-LD Audit on 25 Pages of My Own Sites. 21 Say They Were Modified Today.

I run five small launch sites, and each one has a /studio blog. Back in September I made myself an index of every post, and one line in it has bugged me since: "the on-page datePublished schema is a placeholder on most sites. Every Aura++ post says 2024-06-01, every IndieHunt post 2024-01-15, every MakerHunt post 2025-04-04, SideHunt outputs [object Object]." That's a nice hook for a post. So this morning (8 Nov 2026, around 10:45 IST) I went to prove it before writing about it. I wrote a small script, pointed it at 25 URLs across the five sites, and it didn't find what my note said. The blog post dates are fine now. Real ISO timestamps, different on every post. The placeholder dates are still there, but they sit on the sitewide WebSite node, not the BlogPosting. And right next to them is the thing I'd actually call a bug: a dateModified that says "today" on 21 of the 25 pages. Either the post dates got fixed since September, or I read the wrong node when I wrote that note. I can't tell which from today's HTML. Doesn't matter much. The point is I nearly published a post about a bug that wasn't there, and the real problems were somewhere else. Here's the script, what it flagged, and what I'm going to delete. One thing first: schema doesn't rank you Structured data makes a page eligible for rich results. It doesn't push rankings up. Google's general structured data guidelines say it plainly: using structured data "enables a feature to be present, it does not guarantee that it will be present". Even a structured data manual action "doesn't affect how the page ranks in Google web search", it just removes rich result eligibility. So this audit is about not lying to machines, and not carrying dead markup around. It's not a ranking hack. The script It's about 120 lines of Node 18+, no dependencies. It reads a file of URLs, fetches each page, pulls every block, flattens @graphs and nested objects, and prints flags. What it checks: BlogPosting / Article dates: missing, unparseable ([object Object], garbage strings) or in the future the same datePublished on more than one post from the same host (the placeholder case) any node whose dateModified is within 36 hours of the fetch (generated per request or per day) attributes that aren't a valid date string a post URL (/blog/, /studio/, /post/) with no Article/BlogPosting at all more than one full top-level Organization definition, or Organization names that disagree FAQPage and HowTo markup aggregateRating an offers.price in schema that doesn't appear as $price in the visible text Usage: node jsonld-audit.mjs urls.txt jsonld-audit.mjs (full script, click to expand) #!/usr/bin/env node // jsonld-audit.mjs: pull JSON-LD from a list of URLs and flag common problems. // Usage: node jsonld-audit.mjs urls.txt (one URL per line, # for comments) // Node 18+ (uses global fetch). No dependencies. import { readFileSync } from "node:fs"; const file = process.argv[2]; if (!file) { console.error("usage: node jsonld-audit.mjs urls.txt"); process.exit(1); } const urls = readFileSync(file, "utf8").split("\n").map(s => s.trim()).filter(s => s && !s.startsWith("#")); const now = Date.now(); const decode = s => s.replace(/"/g, '"').replace(/&/g, "&").replace(//g, ">"); function blocks(html) { const re = /]*type=["']?application\/ld\+json["']?[^>]*>([\s\S]*?)/gi; const out = []; let m; while ((m = re.exec(html))) out.push(m[1].trim()); return out; } // collects every typed node; marks nodes that sit at the top of a block or @graph as top-level function flatten(x, acc = [], top = true) { if (Array.isArray(x)) { x.forEach(y => flatten(y, acc, top)); return acc; } if (x && typeof x === "object") { if (x["@type"]) { Object.defineProperty(x, "__top", { value: top }); acc.push(x); } for (const [k, v] of Object.entries(x)) { if (k === "@context" || typeof v !== "object") continue; flatten(v, acc, k === "@graph" ? true : false); } } return acc; } const types = n => [].concat(n["@type"] || []); function checkDate(v) { if (v === undefined) return null; if (typeof v !== "string") return `not a string (${JSON.stringify(v).slice(0, 40)})`; if (v.includes("[object")) return `unparseable "${v}"`; const t = Date.parse(v); if (Number.isNaN(t)) return `unparseable "${v}"`; if (t > now + 864e5) return `in the future "${v}"`; return null; } function visibleText(html) { return html.replace(/ datePublished -> [urls] let totalFlags = 0; for (const url of urls) { const flags = []; let res, html; try { res = await fetch(url, { headers: { "user-agent": "Mozilla/5.0 (jsonld-audit)" }, redirect: "follow" }); html = await res.text(); } catch (e) { console.log(`\n${url}\n FETCH ERROR ${e.message}`); continue; } const raw = blocks(html); const nodes = []; if (!res.ok) flags.push(`HTTP ${res.status}: results below are for an error page`); raw.forEach((b, i) => { try { flatten(JSON.parse(decode(b)), nodes); } catch (e) { flags.push(`block ${i + 1}: JSON parse error (${e.message.slice(0, 60)})`); } }); const typeList = nodes.map(n => types(n).join("/")); console.log(`\n${url}\n HTTP ${res.status} | ${raw.length} ld+json block(s) | types: ${typeList.join(", ") || "none"}`); // dates const host = new URL(url).host; for (const n of nodes) { if (!types(n).some(t => /Article|BlogPosting|NewsArticle/.test(t))) continue; console.log(` ${types(n)[0]} datePublished=${JSON.stringify(n.datePublished)} dateModified=${JSON.stringify(n.dateModified)}`); for (const k of ["datePublished", "dateModified"]) { const p = checkDate(n[k]); if (p) flags.push(`${k} ${p}`); } if (n.datePublished === undefined) flags.push("article has no datePublished"); if (n.dateModified === undefined) flags.push("article has no dateModified"); const key = JSON.stringify(n.datePublished); ((pubByHost[host] ||= {})[key] ||= []).push(url); } // visible vs schema const times = [...html.matchAll(/]*date[tT]ime=["']([^"']+)["']/gi)].map(m => m[1]); if (times.length) console.log(` visible : ${[...new Set(times)].join(", ")}`); const isoish = /^\d{4}-\d{2}(-\d{2})?([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/; const bad = [...new Set(times)].filter(t => !isoish.test(t)); if (bad.length) flags.push(` not a valid HTML date string: "${bad[0]}"`); // organizations: nested publisher/author objects are normal; flag repeated top-level definitions const orgs = nodes.filter(n => types(n).includes("Organization")); const topOrgs = orgs.filter(o => o.__top); const fullTop = topOrgs.filter(o => Object.keys(o).filter(k => !k.startsWith("@")).length > 2); if (orgs.length) console.log(` Organization nodes: ${orgs.length} (${topOrgs.length} top-level, ${orgs.length - topOrgs.length} nested as publisher/author)`); if (fullTop.length > 1) { const ids = fullTop.map(o => o["@id"] || "(no @id)"); if (ids.includes("(no @id)") || new Set(ids).size > 1) flags.push(`${fullTop.length} full top-level Organization definitions (ids: ${ids.join(", ")})`); } const names = new Set(orgs.map(o => o.name).filter(Boolean)); if (names.size > 1) flags.push(`Organization names disagree: ${[...names].join(" | ")}`); // post URL with no article markup if (/\/(blog|studio|posts?)\/[^/]+/.test(new URL(url).pathname) && !nodes.some(n => types(n).some(t => /Article|BlogPosting/.test(t)))) flags.push("looks like a post URL but has no Article/BlogPosting node"); // dateModified that tracks the request time (regenerated per request / per day) for (const n of nodes) { const t = Date.parse(n.dateModified); if (!Number.isNaN(t) && Math.abs(now - t) < 36 * 3600e3) flags.push(`${types(n)[0]} dateModified=${n.dateModified} is within 36h of fetch time (regenerated per request?)`); } // FAQ / HowTo for (const t of ["FAQPage", "HowTo"]) if (nodes.some(n => types(n).includes(t))) flags.push(`${t} markup present (check Google's current rich result support)`); // ratings if (nodes.some(n => n.aggregateRating || types(n).includes("AggregateRating"))) flags.push("aggregateRating present: must reflect reviews visible on this page"); // price vs visible const text = visibleText(html); for (const n of nodes) for (const off of [].concat(n.offers || [])) { if (!off || off.price === undefined) continue; const p = String(off.price); const num = Number(p); const shown = text.includes(`$${p}`) || (!Number.isNaN(num) && (text.includes(`$${num}`) || text.includes(`$${num.toFixed(2)}`))); console.log(` offer price in schema: ${p} ${off.priceCurrency || ""} | on visible page: ${shown ? "yes" : "NO"}`); if (!shown && num !== 0) flags.push(`schema price ${p} not found in visible text`); } flags.forEach(f => console.log(` FLAG ${f}`)); totalFlags += flags.length; } console.log("\n== cross-page checks =="); for (const [host, m] of Object.entries(pubByHost)) { for (const [d, list] of Object.entries(m)) { if (list.length > 1) { console.log(`FLAG ${host}: ${list.length} posts share datePublished=${d}`); totalFlags++; } } } console.log(`\n${urls.length} URL(s), ${totalFlags} flag(s)`); Before running it on my sites I tested it on three local fixture pages with every problem baked in: two Organization blocks with different names, "datePublished": "[object Object]", a 2031 dateModified, a SoftwareApplication priced 29 on a page that shows $19, a fake aggregateRating, a HowTo, one broken JSON block, and two posts sharing 2024-06-01. It caught all of them. Here's the cross-page part of that run: == cross-page checks == FLAG localhost:8799: 2 posts share datePublished="2024-06-01T00:00:00.000Z" 3 URL(s), 11 flag(s) Two honest limits. It reads raw HTML and doesn't run JavaScript, so markup injected by a tag manager after load is invisible to it. And the price check is a dumb string search. It's a smoke test, not a validator. What it found on my five sites 25 URLs: three recent posts per site, six older posts, three homepages and the Aura++ pricing page. 49 flags in total. Grouped: 12 not a valid HTML date string 5 FAQPage markup present 21 WebSite dateModified within 36h of fetch time 4 Organization dateModified within 36h of fetch time 3 WebPage dateModified within 36h of fetch time 4 looks like a post URL but has no Article/BlogPosting node And zero flags for: duplicate post dates, future dates, [object Object], aggregateRating, HowTo, or price mismatch. None of the pages put a price in schema at all, so that check had nothing to compare. I'm saying that so it doesn't look like the price check "passed". 1. The real post dates, and where the placeholders went A typical post today: https://indiehunt.io/studio/how-to-add-live-chat-without-being-on-call BlogPosting datePublished="2026-11-04T06:05:11.435Z" dateModified="2026-11-04T06:05:13.795Z" An Aura++ post from January has 2026-01-05T07:00:28.492Z. So the "every post says 2024-06-01" claim is dead. But the sitewide nodes on those same pages say: Aura++ WebSite datePublished 2024-06-01T00:00:00.000Z dateModified 2026-11-08T05:16:16.533Z IndieHunt WebSite datePublished 2024-01-15T08:00:00.000Z dateModified 2026-11-08T00:00:00.000Z MakerHunt WebSite datePublished 2025-04-04T00:00:00.000Z dateModified 2026-11-08T05:16:20.822Z EarlyHunt Organization datePublished 2024-06-01T00:00:00.000Z dateModified 2026-11-08T00:00:00.000Z WebSite (same two values) Those are the exact dates from my old note. I'm fairly sure that's what got misread. 2. dateModified is "right now" On MakerHunt I curled the same post twice, a few seconds apart. dateModified went from 05:13:27.264Z to 05:13:35.048Z. It's stamped with the request time. Aura++ does the same thing to the millisecond. IndieHunt and EarlyHunt round it to midnight UTC of the current day. On the three homepages I checked, the WebPage node does it too. How bad is this? Honestly, probably not very. Google's byline date doc recommends putting dates on a CreativeWork subtype like Article or BlogPosting, and I don't know that Google uses a WebSite node's dateModified for anything. But it's a false statement in machine-readable form, on every page, and the same guidelines say "Don't mark up irrelevant or misleading content". It's the structured data version of a footer that says "Updated today" through a new Date(). It's going. The fix is boring: hardcode it, or use the real last deploy or content-change time. Or just delete dateModified from WebSite. Nobody needs it there. This is the same trap as a canonical tag in a root layout. Whatever sits in the shared layout lands on every page, so one lazy value gets multiplied. I wrote about that version in a canonical in your Next.js root layout lands on every page. If you'd rather not run a script, the free schema markup checker on Posting Dude (another of my sites) will read a single URL's JSON-LD and list each type and property. Same limit as mine, it doesn't run JavaScript. 3. The tag is printing Date.toString() This one I didn't expect at all. On IndieHunt, SideHunt and EarlyHunt, the visible date looks fine ("November 4, 2026") but the attribute is: November 4, 2026 That's what you get when a JS Date object ends up in an attribute as-is: it gets turned into a string with toString(). It's not a valid HTML date string. Google's byline doc says to keep dates consistent between the visible version and the structured version. The JSON-LD and the visible text agree here, so this is more of an HTML correctness bug than an SEO fire. Still, it's a one-word fix: date.toISOString(). And it smells like a cousin of the old [object Object] note. Same class of bug, an object turned into a string where a value was expected. 4. MakerHunt posts have no Article markup at all All four MakerHunt Studio posts I checked (three from the last two weeks, one older) output only Organization and WebSite. No BlogPosting. The page shows a visible date, so Google can still guess it, but per Google's Article doc the markup can help it "show better title text, images, and date information". This is the only missing piece I'd actually add, not delete. 5. FAQPage on every SideHunt post All four SideHunt posts carry a FAQPage block, and so does the Aura++ pricing page. Google's Search Central changelog says the FAQ rich result "will no longer appear in Google Search starting May 7, 2026", and on June 15, 2026 it removed the FAQ docs because the feature "is no longer shown in Google Search results". HowTo went away for good back in September 2023. Google's 2023 post on the FAQ/HowTo changes also said structured data that isn't used "does not cause problems for Search". So this isn't a penalty. It's dead weight that keeps getting generated, and it's one more block that can drift from the visible FAQ. I'll remove it from the template. If you're still adding FAQPage to get the dropdowns back, they're not coming back. What wasn't a problem Organization duplicates, which I fully expected to find. Aura++ has two Organization nodes on every post, but both use @id: https://auraplusplus.com/#organization, one full, one a stub. That's how @id is supposed to work. The other sites have one top-level Organization plus a nested publisher. Normal. My first version of the script flagged all of these as duplicates, which was wrong, so I changed it to only flag repeated full top-level definitions. My cleanup list Remove dateModified from WebSite/Organization/WebPage, or set it from real data on three sites Add BlogPosting (headline, image, real datePublished/dateModified, author) to MakerHunt Studio Drop FAQPage from the SideHunt post template and the Aura++ pricing page Re-run the script, then check a few URLs in the Rich Results Test If you want the page-by-page version of what a SaaS site should have in the first place (homepage, pricing, blog), I wrote that up as which schema markup a SaaS website actually needs. The bigger lesson for me isn't about schema. My own notes said "placeholder dates", I believed them for six weeks, and a 120-line script disagreed in under a minute. Curl it before you write it down. What's the weirdest thing you've found in your own JSON-LD? Dates that change every request, an aggregateRating nobody remembers adding, something worse?

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News