A Search Console Email Said 3 Problems. We Found 720 — And Three Hacked Client Sites.

A Google Search Console email landed at 6:00am saying yourcontentfactory.com had 3 Videos structured data issues. Non-critical. The kind of email most people archive.

By the end of the morning we had fixed 592 broken video objects on a site Google never emailed about, and found three client sites serving Japanese product spam to Googlebot while showing a blank error page to every human.

This is the write-up of how one ignorable email turned into a security incident, and what we changed so it never takes a morning again.

What the email actually said

Three issues, all filed under “non-critical”:

  • Datetime property "uploadDate" is missing a timezone
  • Invalid datetime value for "uploadDate"
  • Missing field "description"

Google is explicit that non-critical means “won’t stop you appearing in Search” — and then adds that some of these get reclassified as critical later. That is a deadline with no date on it.

Rule one: the email reports a sample, not a total

The first move was not to fix 3 things. It was to find out how many things there were.

Every one of our sites runs Rank Math, which publishes a video-sitemap.xml — the same crawlable inventory the SEO Tree depends on. Pull it, fetch every URL, parse every application/ld+json block, walk the graph for VideoObject, check the fields Google checks.

On yourcontentfactory.com: 46 pages, 141 VideoObjects, 128 broken. Not 3.

Site Video pages VideoObjects Violations found
blitzmetrics.com 672 825 592
yourcontentfactory.com 46 141 128

blitzmetrics.com was worse and Google had never emailed about it. It is the property that carries every Content Factory video we publish, so the blast radius was our own shop window. 580 of its videos were missing thumbnailUrl — and unlike the three issues in the email, thumbnailUrl is required. Those 580 videos could not produce a video rich result at all. A real loss nobody had a ticket for.

What the defect looked like

A live VideoObject from the site, exactly as Googlebot received it:

{
  "@type": "VideoObject",
  "name": "811  VA Development Plan 10 16 2017",
  "description": "Test",
  "uploadDate": "2018-10-01",
  "embedUrl": "https://www.youtube.com/embed/ZwDaeimdgD0",
  "duration": "PT1M24S"
}

"uploadDate": "2018-10-01" is a date. Schema.org wants a DateTime with an offset. And "description": "Test" is somebody’s placeholder from 2022 that had been public ever since.

Three shapes were in the data across 966 objects:

  • 2018-10-01 — bare date, 135 of them. “Missing a timezone.”
  • 2023-12-18 20:46:14 — MySQL datetime, space instead of T. “Invalid datetime value.”
  • 2023-08-08T11:02:09-07:00 — correct. What the other two should look like.

The trap that makes the obvious fix wrong

The obvious fix is to append an offset. Our sites are Pacific time, so append -07:00 and move on.

That is wrong for about five months of every year.

America/Los_Angeles is -07:00 in July and -08:00 in December. Stamp a December video with -07:00 and you have not fixed the schema, you have replaced a warning with a false statement about when the video published. Google will accept it. It is still wrong.

The offset has to be resolved per date, through a real timezone database — wp_timezone() in PHP, zoneinfo in Python. Our test suite asserts both boundaries directly:

'2018-10-01' -> '2018-10-01T00:00:00-07:00'   PDT, summer
'2018-11-24' -> '2018-11-24T00:00:00-08:00'   PST, after the switch
'2023-03-12' -> '2023-03-12T00:00:00-08:00'   spring-forward day, midnight is still PST
'2023-11-05' -> '2023-11-05T00:00:00-07:00'   fall-back day, midnight is still PDT

The suite also caught a bug we wrote ourselves. Fed the MySQL zero date 0000-00-00, our first version produced -001-11-30T00:00:00-07:52 — a negative year and a 7-hour-52-minute offset, because that is what Los Angeles time actually was before standard time zones existed in 1883. PHP was being technically correct. The rule now is that anything unparseable drops the property. A missing uploadDate is a warning. A wrong one is a lie.

Fix the output layer, then the data layer

Repairing 966 stored rows fixes today. It does nothing about the next import, the next agent, or the next time Rank Math auto-detects a YouTube embed and stores the date its own way.

So the fix went in as a plugin that hooks rank_math/json_ld and wpseo_schema_graph, walks the entire schema graph, and normalises every VideoObject on the way out. It is what Googlebot reads, it runs on every request, and it cannot regress.

It does not invent anything:

  • uploadDate keeps the asserted instant and gains only the site’s real, DST-correct offset
  • description is backfilled from the post’s own excerpt, then its meta description, then the first prose in the body — site-owned content, never generated
  • thumbnailUrl is derived from the video’s own YouTube id: https://i.ytimg.com/vi/<id>/maxresdefault.jpg

The last 30 taught us more than the first 562

First deploy took blitzmetrics.com from 592 violations to 30. The last 30 split into two causes worth knowing about.

Hand-authored schema bypasses the plugin filter entirely. One page had two JSON-LD blocks: Rank Math’s, which the guard fixed, and a second one with no class attribute that someone had pasted directly into the editor years ago. Rank Math’s filter cannot see schema Rank Math did not generate. If you only hook the SEO plugin, hand-written blocks keep failing forever and you will never work out why. The guard now also rewrites application/ld+json found inside post content — and leaves malformed JSON exactly as authored rather than guessing.

Self-hosted video has no derivable poster frame. The rest were .mp4 files in wp-content/uploads. No YouTube id, so nothing to derive. The fallback chain is now featured image, then the first image actually rendered in the post body, then the page’s Open Graph image. All real, all specific to that page, none invented.

Site Video pages VideoObjects Violations before After
blitzmetrics.com 672 825 → 760 592 0
yourcontentfactory.com 46 141 128 0

Both verified with cache-busting across every video page, not a sample. That mattered: the first post-deploy scan of blitzmetrics.com reported 121 violations that no longer existed, because Cloudflare was still serving 10-minute-old HTML with x-cache: HIT. If you verify a schema fix without a cache-buster, you are grading yesterday’s page.

The install itself has a wrinkle worth writing down. WordPress core REST can install plugins only from the wordpress.org repo, never an arbitrary zip, and a browser extension cannot read a path off a build sandbox. The route that works: POST the zip to /wp/v2/media over an Application Password, then from a logged-in tab fetch() that now same-origin URL into a FormData POST to update.php?action=upload-plugin with the page nonce, activate over REST, and delete the media item so the zip is not left sitting there publicly downloadable.

The part nobody was looking for

Sweeping the fleet meant requesting /video-sitemap.xml on every site we run. Most returned 404. Three returned a sitemap with 1,733 URLs that looked like this:

https://philmershon.com/product/category/33168110021760
https://philmershon.com/shop/manufacturer-site?&transition=top3090822001760
https://dylan-haugen.com/product-similar-image/?13537212071230

Phil Mershon does not sell products. Neither does Dylan Haugen. And theathletespotlight.com is one of our own Spotlight Network verticals.

Fetching one of those URLs in a browser returns HTTP 500. So does the homepage. All three sites look like a routine outage.

Fetch the same URL as Googlebot and you get HTTP 200 and a fully built spam storefront, canonical tag rewritten to the client’s own domain.

User-Agent Status Bytes
Googlebot/2.1 200 137,840
bingbot/2.0 200 137,840
hello google bot (nonsense string) 200 137,839
GoogleOther 500 0
AhrefsBot 500 0
Chrome 126 500 0
curl 500 0

The gate is a substring match on google or bing plus bot. GoogleOther contains “Google” and gets nothing, because it has no “bot” in it. A made-up string with both words gets the full payload. That is a hand-rolled cloaker, not a hosting default and not a WAF rule.

The 500 is not a side effect. It is the disguise. A site that returns 500 to its owner gets a hosting ticket, not a malware scan, and the attacker keeps a clean crawl going for however long that takes.

Why our own monitor would not have caught it

We already run a fleet uptime monitor — one of the agents catalogued in The System. It was built after ~150 of our sites went to HTTP 500 on July 21. It checks status codes.

It would have caught these three as “down”. Then its own diagnosis table would have sent whoever was on it to check wp-content/debug.log and bisect network-activated plugins — because a PHP fatal and a cloaker produce the identical status code from a human’s browser.

A status-code monitor cannot see a cloaker by construction. It only ever asks one identity’s question.

So the monitor now asks twice. Every domain, every run:

  1. Fetch / as Chrome. Fetch / as Googlebot. Different status or different <title> is a finding.
  2. Fetch an impossible path ending in .xml. Nothing legitimate answers /blitz-canary-9174-do-not-create.xml with application/xml. Something that does is intercepting by file extension in front of WordPress.

Two extra requests per site. Across 176 domains — the full managed fleet plus every property on the Asset Tracker — both fingerprints fired on exactly three and on none of the other 173.

They also produced two false positives worth documenting: two sites answered Googlebot with HTTP 202 and a 169-byte body. That is a bot-challenge page from their CDN, not a cloaker. A status split alone is not enough — you have to look at what actually came back.

Four things worth stealing

A Search Console email reports a sample. Three reported, 128 real on the first site, 592 on a site Google never mentioned. Never scope the work from the notification.

Fix the output layer before the data layer. Repaired rows are a snapshot. A filter on the way out is a guarantee — and it catches the hand-written schema you did not know existed.

Timezones are a correctness problem, not a formatting problem. Appending a fixed offset passes the validator and puts half your dates an hour off.

Ask every site two questions, not one. The gap between what a human sees and what Googlebot sees is where the whole cloaking attack lives. If you only ever fetch as yourself, that gap is invisible — and it will not show up as a security alert, it will show up as an outage.

Both lessons are now in the skills themselves, not just in this post. fleet-uptime-monitor runs the cloak check before the plugin-bisect branch, and a new video-schema-hygiene skill carries the measure → guard → repair → verify loop. They ship in the Skill Pack Library, so the next agent starts here instead of at a 6:00am email.

Written to the meta article standard: what broke, what we measured, what we changed, and what you can steal. Same reflex that produced the blog card thumbnails standard — find the defect once, fix it at the generator, write it down so it cannot come back.

If you have never checked your own video schema, the Quick Audit is where to start.

Related: One Site, One Platform — why the fleet sites that never crashed were in worse shape than the ones that did.

Dennis Yu
Dennis Yu
Dennis Yu is the CEO of Local Service Spotlight, a platform that amplifies the reputations of contractors and local service businesses using the Content Factory process. He is a former search engine engineer who has spent a billion dollars on Google and Facebook ads for Nike, Quiznos, Ashley Furniture, Red Bull, State Farm, and other brands. Dennis has achieved 25% of his goal of creating a million digital marketing jobs by partnering with universities, professional organizations, and agencies. Through Local Service Spotlight, he teaches the Dollar a Day strategy and Content Factory training to help local service businesses enhance their existing local reputation and make the phone ring. Dennis coaches young adult agency owners serving plumbers, AC technicians, landscapers, roofers, electricians, and believes there should be a standard in measuring local marketing efforts, much like doctors and plumbers must be certified.