How to Audit Your Site Against the Google Leak Signals
SEO & GEO Consultant | | 14 min read

A site audit against the Google leak signals is a technical SEO check that approximates, with your own pages, the signals named in the 2024 Content Warehouse leak. The audit covers site focus, template text, dates, titles and the place of key information, each with a short Python script.
What is a site audit against the Google leak signals?
A site audit against the Google leak signals is a set of proxy measurements, one for each leaked field that describes something visible in your HTML. The leaked files document about 2,596 modules and 14,014 attributes of Google's Content Warehouse application programming interface (API). HexDocs published them in March 2024 after a Google code bot added them to a public repository by mistake, and the leak drew attention in late May 2024.
The files are protocol buffer schemas, not source code, so they show neither weights nor thresholds and do not say which fields are live. Their newest date is August 2023. In its antitrust case, the US Department of Justice (DOJ) published a note of a February 2025 call with Google engineer HJ Kim. The note says the documents named ranking components without the specifics of curves and thresholds.
A Google spokesperson warned in May 2024 against assumptions based on out-of-context, outdated or incomplete information. Patrick Stox of Ahrefs wrote that a stored feature is not proof of use in ranking. The audit is still useful, because each field points to a page property you can fix for readers.
Which Google leak signals can you measure on your own site?
You can measure eight groups of Google leak signals on your own site, each through a proxy that reads the same property from your pages. A proxy is a value you compute to stand in for a score only Google sees. The topic proxy uses TF-IDF (term frequency-inverse document frequency) weights, and the rest are plain counts. The table maps each group to its HexDocs module and script.
| Signal | Module | Proxy measure | Script |
|---|---|---|---|
| siteFocusScore, siteRadius | QualityAuthorityTopicEmbeddingsVersionedItem | TF-IDF distance from the site average | radius.py |
| siteQualityStddev | QualityNsrNsrData | Spread of unique words per page | template.py |
| OriginalContentScore | PerDocData | Share of sentences other pages repeat | template.py |
| unreliableDatesScore, semanticDate, lastSignificantUpdate | QualityTimebasedDateUnreliability, PerDocData | Three page dates compared | dates.py |
| titlematchScore, Goldmine title candidates | QualityNsrNsrData, QualityPreviewRanklabTitle | Query terms in title and H1 | titles.py |
| numTokens | DocProperties | Query terms in the first 100 words | first100.py |
| avgTermWeight | DocProperties | Share of body words in bold | first100.py |
| clutterScore | QualityNsrNsrData | Third-party scripts and iframes | clutter.py |
How do you prepare your site for a Google leak signal audit?
You prepare your site for a Google leak signal audit by collecting the indexable pages, the XML sitemap and the target queries in one working folder. The four steps below build that folder.
- Download the pages into a folder named site with the
wgetcommand below, which saves each address that ends in a slash as index.html. For a static site, copy the files. - Save the sitemap as sitemap.xml.
- Write each page's target query from your keyword map or Search Console into queries.json, as in
{"/blog/sample-post/": "sample query"}. - Save common.py below, which every script imports.
wget --mirror --no-host-directories --directory-prefix=site --wait=1 \
--reject jpg,jpeg,png,gif,webp,svg,css,js,pdf https://example.com/
curl -L -o sitemap.xml https://example.com/sitemap.xml
# common.py: shared helpers for the audit scripts (example)
import html
import re
from pathlib import Path
# main_html keeps <main> (or <body>) without these blocks
DROP = r'(?is)<(script|style|nav|header|footer|aside|form)\b.*?</\1>'
def clean(fragment):
return ' '.join(html.unescape(re.sub(r'<[^>]+>', ' ', fragment)).split())
def main_html(source):
m = re.search(r'(?is)<main\b.*?</main>', source) or re.search(r'(?is)<body\b.*?</body>', source)
return re.sub(DROP, ' ', m.group(0) if m else source)
def first_tag(tag, source):
m = re.search(r'(?is)<%s\b[^>]*>(.*?)</%s>' % (tag, tag), source)
return clean(m.group(1)) if m else ''
def words(text):
return re.findall(r'\w+', text.lower())
def load_pages(folder='site'):
pages = {}
for f in sorted(Path(folder).rglob('*.html')):
rel = f.relative_to(folder).as_posix()
path = '/' + (rel[:-len('index.html')] if f.name == 'index.html' else rel)
src = f.read_text(encoding='utf-8', errors='replace')
main = main_html(src)
pages[path] = {'html': src, 'main': main, 'text': clean(main),
'title': first_tag('title', src), 'h1': first_tag('h1', src)}
return pages
The scripts read the HTML your server sends, so pages that build their text with JavaScript need a crawler that renders them.
How do you measure site focus and topic radius?
You measure site focus and topic radius by turning each page into a TF-IDF vector and comparing it with the average vector of the site. In the QualityAuthorityTopicEmbeddingsVersionedItem module, siteFocusScore is a number denoting how much a site is focused on one topic. The siteRadius field measures how far page embeddings deviate from the site embedding. Neither field has a known weight, and TF-IDF vectors stand in for Google's unpublished embeddings.
# radius.py: TF-IDF distance of each page from the site centre (example)
# distance = 1 - cosine similarity; the top 5 percent are printed
import math
from collections import Counter
from common import load_pages, words
pages = load_pages('site')
tf = {path: Counter(w for w in words(p['text']) if len(w) > 2) for path, p in pages.items()}
df = Counter(w for counts in tf.values() for w in counts)
n = len(tf)
def unit(vec):
norm = math.sqrt(sum(x * x for x in vec.values())) or 1.0
return {w: x / norm for w, x in vec.items()}
vec = {path: unit({w: c * math.log(n / df[w]) for w, c in counts.items()})
for path, counts in tf.items()}
centre = Counter()
for v in vec.values():
centre.update(v)
centre = unit(centre)
dist = {path: 1 - sum(x * centre.get(w, 0) for w, x in v.items()) for path, v in vec.items()}
mean = sum(dist.values()) / n
print(f'focus proxy {1 - mean:.3f} radius proxy {mean:.3f}')
for path in sorted(dist, key=dist.get, reverse=True)[:max(1, round(n * 0.05))]:
print(f'{dist[path]:.3f} {path}')
The first line shows a focus proxy (mean similarity) and a radius proxy (mean distance). The other lines list the 5 percent of pages furthest from the centre.
Link each outlier to a main topic and strengthen it. Merge it into the closest page with a 301 redirect when it fits no topic and gets no search traffic. The internal link graph in Gephi from my earlier post shows which pages link to it. Google's helpful content guidance asks a related question: does your site have a primary purpose or focus?
How do you measure the template text ratio?
You measure the template text ratio by counting how many sentences of a page appear on at least one other page of the site. PerDocData holds a field named OriginalContentScore, and its description says only pages with little content have it. The documents do not explain how the score is computed or used.
# template.py: share of sentences (6+ words) that other pages repeat,
# plus the spread of unique words per page (example)
import math
import re
from collections import Counter
from common import load_pages
pages = load_pages('site')
def sentences(text):
return {s.lower() for s in re.split(r'(?<=[.!?])\s+', text) if len(s.split()) >= 6}
sets = {path: sentences(p['text']) for path, p in pages.items()}
seen = Counter(s for group in sets.values() for s in group)
unique = {}
for path, group in sets.items():
ratio = sum(1 for s in group if seen[s] > 1) / len(group) if group else 0
unique[path] = sum(len(s.split()) for s in group if seen[s] == 1)
flag = 'template-heavy' if ratio > 0.5 else ''
print(f'{ratio:.2f} shared {unique[path]:4d} unique words {path} {flag}')
mean = sum(unique.values()) / len(unique)
spread = math.sqrt(sum((x - mean) ** 2 for x in unique.values()) / len(unique))
print(f'unique words per page: mean {mean:.0f}, standard deviation {spread:.0f}')
low = sorted(unique, key=unique.get)[:max(1, len(unique) // 10)]
print('lowest 10%:', ', '.join(low))
The script marks a page as template-heavy when more than half of its sentences appear elsewhere. Template-built pages, such as location pages, reach that level when only a name changes. Add information that belongs to that page alone, such as a local detail, a table or an example, or merge the page into a stronger one. Point a near-identical copy to the main version with a canonical tag, as covered in my guide to canonical URLs.
How do you measure the quality spread across your pages?
You measure the quality spread across your pages with the standard deviation of unique words per page, a rough page-level proxy. In the QualityNsrNsrData module, siteQualityStddev is an estimate of the spread of a site's page-level page quality (PQ) ratings. The Kim note describes Q*, Google's page quality signal, as largely static and largely related to the site rather than the query.
The last two lines of template.py print the mean, the deviation and the lowest 10 percent of pages. A high deviation means weak pages sit next to strong ones. Improve, merge or noindex that group first, after you check its search traffic.
How do you compare the date signals?
You compare the date signals by reading three dates per page (the visible date, the schema dateModified and the sitemap lastmod) and flagging any disagreement. In PerDocData, semanticDate is an estimated content date based on the document, its anchors and related documents, and lastSignificantUpdate holds the document's last significant update. QualityTimebasedDateUnreliability stores an unreliableDatesScore for each URL.
Google's sitemap documentation says Google uses lastmod when the value is consistently and verifiably accurate. The value should reflect the last significant update: changes to the main content, structured data or links count, but a new copyright date does not.
# dates.py: visible date, schema dateModified and sitemap lastmod (example)
import json
import re
import xml.etree.ElementTree as ET
from common import load_pages
SITE = 'https://example.com' # use a child sitemap if sitemap.xml is an index
NS = {'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
lastmod = {}
for url in ET.parse('sitemap.xml').getroot().findall('sm:url', NS):
loc = url.findtext('sm:loc', default='', namespaces=NS).strip()
lastmod[loc.replace(SITE, '', 1) or '/'] = url.findtext('sm:lastmod', default='', namespaces=NS).strip()[:10]
def schema_modified(src):
for block in re.findall(r'(?is)<script[^>]*application/ld\+json[^>]*>(.*?)</script>', src):
try:
data = json.loads(block)
except ValueError:
continue
for item in data if isinstance(data, list) else data.get('@graph', [data]):
if isinstance(item, dict) and item.get('dateModified'):
return item['dateModified'][:10]
return ''
for path, p in load_pages('site').items():
# visible date: the latest <time datetime="...">; adjust to your theme
shown = sorted(re.findall(r'''<time[^>]*datetime=["']?([^"'\s>]+)''', p['html']))
found = {'visible': shown[-1][:10] if shown else '',
'schema': schema_modified(p['html']), 'sitemap': lastmod.get(path, '')}
if len(set(found.values())) > 1:
print(path, found)
The script prints only the pages where the values differ. A mismatch means one source moved without the others, such as a template that writes the build date into dateModified. Take all three values from the date of the last significant change. Leave them alone after a typo fix, since the helpful content guidance warns against changing dates to make pages seem fresh when the content has not substantially changed.
How do you compare the title, H1 and target query?
You compare the title, H1 and target query by measuring what share of the query's terms appears in each of them. In QualityNsrNsrData, titlematchScore is a site signal that tells how well titles are matching user queries. The QualityPreviewRanklabTitle module describes one title candidate, scored in a system the documents call Goldmine. Its queryMatchFraction field divides the matched query terms by all query terms, the same ratio the script computes.
Google's title link documentation names the <title> element, headings such as <h1>, anchor text on the page and links pointing to the page among the sources of title links.
# titles.py: target query terms in the title and the H1 (example)
# exact word match, so "tag" and "tags" count as different terms
import json
import re
from collections import Counter
from common import load_pages, words
pages = load_pages('site')
queries = json.load(open('queries.json', encoding='utf-8'))
for path, query in queries.items():
terms = set(words(query))
if path not in pages or not terms:
continue
in_title = len(terms & set(words(pages[path]['title']))) / len(terms)
in_h1 = len(terms & set(words(pages[path]['h1']))) / len(terms)
print(f'title {in_title:4.0%} h1 {in_h1:4.0%} {path}')
parts = Counter(part for p in pages.values() for part in set(re.split(r'\s[|:-]\s', p['title'])))
print('most repeated title parts:', parts.most_common(3))
A score of 0% means the title or H1 contains no query term. In the last line, a short repeated site name is fine: Google suggests adding it at the start or end of each title with a delimiter. A long phrase repeated on every page is boilerplate.
Put the target query near the start of the title, repeat its main term in the H1 and use that term in internal anchor text.
How do you check that key information is in the first 100 words?
You check that key information is in the first 100 words by testing whether every term of the target query appears among the first 100 words of the body. DocProperties describes numTokens as the number of tokens, tags and punctuation in the tokenised content. The count is inexact, partly because Google truncates documents at a maximum cap that the documents do not state. The avgTermWeight field in the same module is the average weighted font size of a term in the document body.
# first100.py: word count, words before <main>, bold share and query terms
# missing from the first 100 words, per page (example)
import json
import re
from common import clean, load_pages, words
pages = load_pages('site')
queries = json.load(open('queries.json', encoding='utf-8'))
for path, query in queries.items():
if path not in pages:
continue
p = pages[path]
body = words(p['text'])
missing = [t for t in words(query) if t not in body[:100]]
src = p['html'].lower()
start, end = src.find('<body'), src.find('<main')
head = re.sub(r'(?is)<(script|style)\b.*?</\1>', ' ', src[start:end]) if 0 <= start < end else ''
bold = sum(len(words(clean(m[1]))) for m in re.findall(r'(?is)<(strong|b)\b[^>]*>(.*?)</\1>', p['main']))
print(f'{len(body):5d} words {len(words(clean(head))):4d} before main '
f'bold {bold / max(1, len(body)):.1%} missing: {" ".join(missing) or "-"} {path}')
A high count before <main> means menus or banners come first in the HTML. Rewrite an opening that misses a term so that it answers the query with the main term. Keep the template text before the content short, and reserve bold for the main term and key sentences.
How do you approximate clutterScore on your pages?
You approximate clutterScore on your pages by counting the third-party scripts and iframes each page loads. In QualityNsrNsrData, clutterScore is a delta site-level signal in Q* that penalises sites with a large number of distracting or annoying resources. The description does not define those resources, so compare your pages with each other, not with a fixed number.
# clutter.py: third-party scripts and iframes per page (example)
import re
from common import load_pages
HOST = 'example.com'
for path, p in load_pages('site').items():
hosts = re.findall(r'''(?i)<script[^>]+src=["']?(?:https?:)?//([^/"'\s>]+)''', p['html'])
third = [h for h in hosts if not h.endswith(HOST)]
iframes = len(re.findall(r'(?i)<iframe\b', p['html']))
print(f'{len(third):3d} third-party scripts {len(set(third)):2d} hosts {iframes:2d} iframes {path}')
Remove scripts and widgets you no longer use, load the rest only where they are needed and run the script again.
How do you interpret the leak audit results and what should you avoid?
You interpret the leak audit results as a list of pages to review, and you avoid reading them as Google's scores. Each proxy borrows the idea of a field, not its formula. Avoid these six mistakes.
- Reading the field list as a ranking factor list. A field shows that Google stores a value, not how it uses it.
- Comparing your numbers with Google's values. A radius proxy of 0.6 says nothing about your siteRadius.
- Faking clicks. A DOJ exhibit note of a January 2025 call with Google engineer Pandu Nayak says NavBoost uses the most recent 13 months of click data. Click bots send automated queries, which Google's spam policies call machine-generated traffic.
- Padding pages to a word count. The helpful content guidance says Google has no preferred word count.
- Moving dates without a significant change.
- Deleting outliers without checking their search traffic.
Fix a finding when the change helps a reader, following the published guidance summarised in my guide to SEO.
What does a Google leak signal audit checklist include?
A Google leak signal audit checklist includes one check per proxy and a second run after the fixes. The nine items follow the order of this post.
- The folder holds every indexable page, sitemap.xml and queries.json.
- Each outlier has a decision: link, strengthen, merge or redirect.
- No indexable page shares over half its sentences with other pages.
- The lowest 10 percent has an improvement plan.
- Visible date, dateModified and lastmod agree on every page.
- Titles and H1s carry the main term of the target query.
- Every query term appears in the first 100 words.
- Bold marks only the main term and key sentences.
- Every third-party script has a reason to load.
Rerun the scripts after each round of changes and compare the outputs. My access log analysis post shows how to confirm that Googlebot has fetched the changed pages.
Taha Yelkenci
SEO since 2010. Founder of rankZup. Got a question? Write to me →
Comments
Be the first to comment
← Previous post
You may also like
November 7, 2024 · 11 min
August 10, 2020 · 3 min
January 24, 2020 · 3 min