AdSense approval

Low Value Content on AdSense: Find the Pages That Cost You

A script that checks every URL in your sitemap, a keep, enrich, merge or delete matrix, and one thin page rewritten, so you fix the right pages before reapplying.

An open notebook with a pen lying on a dark surface
Photo: Miguel Á. Padriñán on Pexels

A refusal does not say which page AdSense objected to. It comes back as a short label, which publishers report as “low value content”, while Google’s own help page for a site that is not ready talks about too little useful content and a poor experience (we go through its 4 causes in AdSense rejection reasons decoded). Neither names a URL, so the weak pages have to be found by measurement, and that is a job for a script first and a careful read second.

What Google’s rule is aimed at

Google’s policy on ads on screens without publisher content, which the program calls valuable inventory, is specific about what it excludes. Ads are not allowed on screens with no content or low-value content, on pages under construction, and on screens used for alerts, navigation or other behavioral purposes. Dead ends such as thank-you, exit and error pages are named, your content must be the focal point of the page, and auto-generated content that nobody reviewed or curated is listed among the problems. The self-assessment page for new applicants is less technical and names 3 areas: unique content, navigation people can use, and an experience worth reading.

As far as we can find, Google publishes no word count, no share of thin pages a site may carry and no list of the URLs a reviewer opens. Length alone proves nothing: a 120-word contact page can serve a visitor perfectly, while a 900-word page produced from a template for the 40th city can fail the rule. What matters is whether the page gives a person what they came for. These are the page types we look for first.

Page typeWhy it fails the ruleWhat to check
Empty tag and category archivesA list of titles with no text, built for the site and not for a readerOpen each archive: does it introduce the topic and list enough posts to justify a visit?
Near-identical pages by city or keywordSame text with one word swapped, so no new information per page; generating content in bulk with automation to manipulate rankings breaks Google’s spam policiesPut 2 pages side by side and swap the keyword in your head. If nothing else changes, one page is enough
Thank-you, exit and error pagesNamed by Google as dead endsKeep ads off them and keep them out of the sitemap
”Coming soon” and under-construction pagesNamed by GoogleFinish them or take them down
Pages that only list linksScreens used for navigation are excludedAdd real text, or fold them into a page that already has some
Copied or unreviewed generated textUnreviewed auto-generated content is on Google’s list, and copied text can break the intellectual property policySearch one distinctive sentence in quotes, then read the page aloud

Pull every URL from the sitemap and measure it

Your sitemap is the list of pages you told search engines matter, so audit it first. The script below reads a sitemap or a sitemap index, requests each URL 4 at a time, and prints the shortest pages first with the HTTP status, the word count, the number of H1 tags, any flags and the title. It needs Node 18 or later and nothing else. Save it as audit-sitemap.mjs.

// audit-sitemap.mjs
// Lists every URL of a sitemap with its HTTP status, visible word count, H1 count and title,
// shortest pages first. Node 18 or later, no dependencies.
//
//   node audit-sitemap.mjs https://example.com/sitemap.xml [minWords]

const [sitemap, minArg] = process.argv.slice(2);
const MIN_WORDS = Number(minArg) || 300; // our own "short" line, not a Google number

const get = (url, redirect = 'follow') =>
  fetch(url, {
    redirect, // 'manual' keeps 301/302 visible instead of following them
    headers: { 'user-agent': 'sitemap-content-audit/1.0' },
    signal: AbortSignal.timeout(15000),
  });

// Reads a sitemap, or a sitemap index, and returns every page URL it lists.
async function urlsFrom(url, depth = 0) {
  const res = await get(url);
  const xml = await res.text();
  if (!res.ok) throw new Error(`${url} answered HTTP ${res.status}`);
  const locs = [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1].replaceAll('&amp;', '&'));
  if (!/<sitemapindex/i.test(xml)) return locs;
  if (depth >= 2) return [];
  return (await Promise.all(locs.map((l) => urlsFrom(l, depth + 1)))).flat();
}

function readHtml(html, url) {
  const first = (re) => (html.match(re) || [])[1] || '';
  const title = first(/<title[^>]*>([\s\S]*?)<\/title>/i)
    .replace(/&#39;|&apos;/g, "'")
    .replace(/&quot;/g, '"')
    .replace(/&amp;/g, '&')
    .replace(/\s+/g, ' ')
    .trim();
  const h1s = (html.match(/<h1[\s>]/gi) || []).length;
  const robotsTag = (html.match(/<meta[^>]+name=["']robots["'][^>]*>/i) || [''])[0];
  const canonicalTag = (html.match(/<link[^>]+rel=["']canonical["'][^>]*>/i) || [''])[0];
  const href = (canonicalTag.match(/href=(["'])(.*?)\1/i) || [])[2];
  // Count words in <main> when there is one, otherwise in <body>, minus menus and scripts.
  const region = (html.match(/<main[\s\S]*?<\/main>/i) || html.match(/<body[\s\S]*<\/body>/i) || [html])[0];
  const words = region
    .replace(/<(script|style|noscript|svg|nav|header|footer|aside)\b[\s\S]*?<\/\1>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&[a-z#0-9]+;/gi, ' ')
    .split(/\s+/)
    .filter(Boolean).length;
  return {
    title,
    h1s,
    words,
    noindex: /noindex/i.test(robotsTag),
    canonicalElsewhere: href ? new URL(href, url).href !== url : false,
  };
}

async function inspect(url) {
  const row = { url, status: 0, words: 0, h1s: 0, title: '', flags: [] };
  try {
    const res = await get(url, 'manual');
    row.status = res.status;
    const body = await res.text();
    if (res.status >= 300 && res.status < 400) row.flags.push('redirect');
    else if (res.status !== 200) row.flags.push('error');
    else if (!(res.headers.get('content-type') || '').includes('html')) row.flags.push('not-html');
    else {
      const page = readHtml(body, url);
      Object.assign(row, { words: page.words, h1s: page.h1s, title: page.title });
      if (page.words < MIN_WORDS) row.flags.push('short');
      if (page.h1s !== 1) row.flags.push(page.h1s ? 'multi-h1' : 'no-h1');
      if (page.noindex || /noindex/i.test(res.headers.get('x-robots-tag') || '')) row.flags.push('noindex');
      if (page.canonicalElsewhere) row.flags.push('canonical-elsewhere');
    }
  } catch (err) {
    row.status = 'ERR';
    row.flags.push(err.name === 'TimeoutError' ? 'timeout' : 'unreachable');
  }
  return row;
}

async function main() {
  if (!sitemap) throw new Error('Usage: node audit-sitemap.mjs <sitemap-url> [min-words]');
  const urls = [...new Set(await urlsFrom(sitemap))];
  const rows = [];
  let next = 0;
  // Four requests at a time keeps the load on the site negligible.
  await Promise.all(
    Array.from({ length: 4 }, async () => {
      while (next < urls.length) rows.push(await inspect(urls[next++]));
    })
  );

  // The same title on several URLs is a sign of templated pages.
  const seen = new Map();
  for (const r of rows) if (r.title) seen.set(r.title, (seen.get(r.title) || 0) + 1);
  for (const r of rows) if (seen.get(r.title) > 1) r.flags.push('duplicate-title');

  rows.sort((a, b) => a.words - b.words);
  console.log('STATUS  WORDS  H1  FLAGS'.padEnd(54) + 'URL  |  TITLE');
  for (const r of rows) {
    const cols = [String(r.status).padEnd(7), String(r.words).padStart(5), String(r.h1s).padStart(2)];
    console.log(`${cols[0]} ${cols[1]}  ${cols[2]}  ${r.flags.join(',').padEnd(34)} ${r.url}  |  ${r.title.slice(0, 50)}`);
  }
  console.log(`\n${rows.length} URLs, ${rows.filter((r) => r.flags.length).length} with at least one flag.`);
}

main().catch((err) => {
  console.error(err.message);
  process.exitCode = 1;
});

Run it with node audit-sitemap.mjs https://example.com/sitemap.xml 300. The optional last argument is the word count under which a page is flagged short. The default of 300 is our own line, not a Google number, so raise it for articles and lower it for contact pages. Here is a shortened version of what it printed on 7 October 2026 for our own site, before the relaunch:

STATUS  WORDS  H1  FLAGS                              URL  |  TITLE
200       118   1  short                              https://greenlightmysite.com/contact/  |  Contact GreenlightMySite
200       137   1  short                              https://greenlightmysite.com/fr/contact/  |  Contacter GreenlightMySite
200       210   1  short                              https://greenlightmysite.com/terms/  |  Terms of use
200       232   1  short                              https://greenlightmysite.com/fr/terms/  |  Conditions d'utilisation
200       300   1                                     https://greenlightmysite.com/about/  |  About GreenlightMySite
...
14 URLs, 4 with at least one flag.

The 4 flagged pages were the contact and terms pages in both languages, at 118 and 210 words in English. The script counts only the main content area, so its figures run lower than a whole-page count: compare your pages with each other, not with a number from another tool. Those pages went on our relaunch rewrite list.

Beyond short, each flag points to a specific action. redirect, error and noindex mean the URL should not be in the sitemap at all. canonical-elsewhere means the sitemap lists a page that names a different URL as its original. duplicate-title usually marks templated pages that deserve a side-by-side comparison, and no-h1 or multi-h1 are structure problems rather than content ones.

The script has limits you should know about. It reads the HTML your server sends, so a page that builds its text with JavaScript after loading looks almost empty to it; check those by hand before you act. Word counts also mean little for Chinese or Japanese, which put no spaces between words. Run it on sites you own or manage: 4 requests at a time is gentle, but it is still a crawler.

Read the flagged pages, and a sample of the rest

The script produces candidates, nothing more. A page can pass every flag and still have nothing to say, so spend an hour reading. Take the flagged pages, the 5 longest and 5 random others, and ask 3 questions of each. They are ours, adapted from Google’s self-assessment.

  1. If someone arrives from a search on this page’s topic, do they leave with what they came for?
  2. Does the page say something the top results for that topic do not: your own measurements, an example you built, a mistake you made? This is where real expertise shows or fails to show.
  3. Would you be comfortable if it were the first page a reviewer opened?

Then ask what each page is for. If the honest answer is “to hold an ad” or “to hold a link”, it belongs in the delete column. Finish with the SEO checker on the pages you plan to keep: it audits one page at a time, including the title, the headings and the canonical tag.

Decide: keep, enrich, merge, noindex or delete

Give every flagged URL one verdict from this matrix and write it in a spreadsheet next to the URL.

VerdictUse it whenHowWatch out for
KeepThe page serves a reader and says something specific, even if it is short (contact, legal notice)Leave it. Add a sentence if its purpose is unclearShort is not the same as thin
EnrichThe topic is right but the page stops at the obviousAdd what only you can: measurements, a worked example, a table, the mistakes you have seenPadding to reach a word count adds length, not information
MergeSeveral pages cover one topic in fragments, or are near-duplicatesFold the best parts into one page and send the others to it with a 301Redirect to the closest equivalent, not to the homepage
NoindexThe page helps visitors but has no reason to appear in search: thank-you pages, internal search resultsAdd <meta name="robots" content="noindex">, leave the page crawlable and drop it from the sitemapA noindex tag is only seen if Google may fetch the page
DeleteNobody needs it: placeholders, empty archives, stale copiesReturn 404 or 410, remove internal links and the sitemap entryCheck what links to it first

Two points from Google’s documentation shape this table. A 301 is a strong signal that a page has moved, which is why merging works, and both 404 and 410 take a URL out of the index. And blocking a page in robots.txt is not a way to hide it: the URL can stay indexed, shown without a snippet, and a crawler that cannot read the page cannot read a noindex on it either.

Google does not say how noindex pages count when a site is reviewed, and we have no evidence either way. Our order of preference follows from that: delete or merge pages that serve nobody, enrich those with a real topic, and keep noindex for pages that help visitors but not search. After a merge, update the internal links so they point at the surviving page rather than at a redirect.

A thin page, rewritten

Imagine a calculator site with a page for its compound interest calculator. This is a made-up example, but the pattern is real. The page as it stands, 40 words:

Use our free compound interest calculator to see how your savings grow over time. Enter your starting amount, the interest rate and the number of years. Our calculator is fast, easy and free. Try it and get your result instantly.

It describes the tool and teaches nothing. Any calculator site could paste it onto its own page, and it never says what to do with the result. Here is the outline we would give the rewrite, and 3 sentences from it, again as an illustration:

  • What the calculator computes, with the formula A = P(1 + r/n)^(nt) written out and each letter explained.
  • A worked example with real numbers.
  • How the compounding frequency changes the answer.
  • The slips that skew results: wrong units, ignored fees, inflation left out.
  • What the calculator does not cover, such as tax.

With 1,000 invested at 5% a year and interest added once a year, the balance after 10 years is 1,628.89. The first year adds 50.00 of interest and the tenth adds 77.57, because the interest itself now earns interest. Switch the same 5% to monthly compounding and the 10-year balance becomes 1,647.01, a gain of 18.12 for changing nothing but the schedule.

The figures are checkable by anyone with a spreadsheet, which is the point: the page now holds information that the generic version could not, and a reader can verify it. Notice that the rewrite did not need a long page, only content that the template lacked. Once your inventory is clean, the 3-week plan covers the second application.

Sources

Who stands behind this

GreenlightMySite is run by Stéphane Cardon, a network and cybersecurity engineer. We write from running real sites through real reviews, and every technical claim here is either linked to a primary source or measured with our own tools.

About · Editorial policy · LinkedIn