Meta tags are the part of a page nobody looks at until a link preview shows up blank in Slack, or Search Console says 400 pages have duplicate titles. Browser extensions check one page at a time. This post builds a script that checks a whole list and writes one CSV row per page, with a plain list of what's wrong.
What gets checked
| Check | Why it matters |
|---|---|
Missing or long <title> (over ~60 chars) | Search results truncate it, and it's still the strongest on-page signal. |
| Missing description, or outside ~70-160 chars | Google often rewrites it anyway, but a missing one means it picks random page text. |
| No canonical, or more than one different canonical | Duplicate URLs (tracking params, www, trailing slashes) split ranking signals. |
noindex in meta robots or the X-Robots-Tag header | The classic "why did this page vanish" bug after a staging deploy. |
Zero, several or empty <h1> | Not a ranking rule, but a reliable sign of a template problem. |
Missing og:title / og:image, or an og:image that doesn't load | Blank or broken previews on Slack, LinkedIn, Facebook, iMessage and Discord. |
No twitter:card | X falls back to a tiny summary card. |
| Invalid JSON-LD, duplicate titles across the list | Structured data that doesn't parse is ignored; duplicate titles compete with each other. |
The script
# Usage: python3 meta_tags_check.py urls.txt # Bulk SEO meta tags + Open Graph checker: fetches each page, extracts title, description, canonical, robots, # h1, Open Graph / Twitter tags and JSON-LD types, flags common problems and writes meta_report.csv. Standard library only. import csv, json, sys, time, urllib.request from concurrent.futures import ThreadPoolExecutor from html.parser import HTMLParser from urllib.parse import urljoin UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' class Meta(HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) self.d = {'title': '', 'h1': [], 'canonical': [], 'jsonld': []} self.stack, self.buf = [], '' def handle_starttag(self, tag, attrs): a = {k.lower(): (v or '') for k, v in attrs} if tag == 'svg': self.stack.append('svg') if tag in ('title', 'h1') and 'svg' not in self.stack or tag == 'script' and a.get('type', '').lower() == 'application/ld+json': self.stack.append(tag); self.buf = '' elif tag == 'meta': key = (a.get('property') or a.get('name') or '').lower() if key and key not in self.d: self.d[key] = a.get('content', '').strip() elif tag == 'link' and 'canonical' in a.get('rel', '').lower().split(): self.d['canonical'].append(a.get('href', '')) def handle_data(self, data): if self.stack and self.stack[-1] in ('title', 'h1', 'script'): self.buf += data def handle_endtag(self, tag): if not self.stack or self.stack[-1] != tag: return self.stack.pop() text = ' '.join(self.buf.split()) if tag == 'title' and not self.d['title']: self.d['title'] = text elif tag == 'h1': self.d['h1'].append(text) elif tag == 'script': try: items = json.loads(self.buf) for it in items if isinstance(items, list) else items.get('@graph', [items]): t = it.get('@type') if isinstance(it, dict) else None self.d['jsonld'] += t if isinstance(t, list) else [t] if t else [] except ValueError: self.d['jsonld'].append('INVALID') def image_ok(url): try: req = urllib.request.Request(url, method='HEAD', headers={'User-Agent': UA}) with urllib.request.urlopen(req, timeout=10) as r: return r.status < 400 and r.headers.get('Content-Type', '').startswith('image/') except Exception: return False def check(url): try: with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': UA}), timeout=20) as r: final, html = r.url, r.read(2_000_000).decode(r.headers.get_content_charset() or 'utf-8', 'replace') xrobots = r.headers.get('X-Robots-Tag', '') except Exception as e: return {'url': url, 'error': f'{type(e).__name__}: {e}'[:120]} p = Meta(); p.feed(html); d = p.d title, desc, robots = d['title'], d.get('description', ''), (d.get('robots', '') + ' ' + xrobots).lower() og_image = urljoin(final, d.get('og:image', '')) if d.get('og:image') else '' problems = [] if not title: problems.append('missing title') elif len(title) > 60: problems.append(f'title {len(title)} chars') if not desc: problems.append('missing description') elif not 70 <= len(desc) <= 160: problems.append(f'description {len(desc)} chars') if not d['canonical']: problems.append('no canonical') elif len(set(d['canonical'])) > 1: problems.append('multiple canonicals') if 'noindex' in robots: problems.append('noindex') if len(d['h1']) != 1: problems.append(f"{len(d['h1'])} h1") elif not d['h1'][0]: problems.append('empty h1') for k in ('og:title', 'og:image'): if not d.get(k): problems.append(f'missing {k}') if og_image and not image_ok(og_image): problems.append('og:image broken') if not d.get('twitter:card'): problems.append('no twitter:card') if 'INVALID' in d['jsonld']: problems.append('invalid JSON-LD') return {'url': url, 'final_url': final, 'title': title, 'description': desc, 'canonical': ' '.join(d['canonical']), 'robots': robots.strip(), 'h1': ' | '.join(d['h1'])[:200], 'og_title': d.get('og:title', ''), 'og_image': og_image, 'twitter_card': d.get('twitter:card', ''), 'jsonld_types': ' '.join(sorted(set(map(str, d['jsonld'])))), 'problems': '; '.join(problems), 'error': ''} if __name__ == '__main__': urls = [l.strip() for l in open(sys.argv[1]) if l.strip() and not l.startswith('#')] t = time.time() with ThreadPoolExecutor(10) as pool: rows = list(pool.map(check, urls)) titles = {} for r in rows: if r.get('title'): titles.setdefault(r['title'], []).append(r['url']) for r in rows: if len(titles.get(r.get('title'), [])) > 1: r['problems'] = (r['problems'] + '; duplicate title').lstrip('; ') fields = ['url', 'final_url', 'title', 'description', 'canonical', 'robots', 'h1', 'og_title', 'og_image', 'twitter_card', 'jsonld_types', 'problems', 'error'] with open('meta_report.csv', 'w', newline='') as f: w = csv.DictWriter(f, fields); w.writeheader(); w.writerows(rows) for r in rows: flag = 'ERR' if r['error'] else '!!' if r['problems'] else 'ok' print(f"{flag:3} {r['url'][:34]:34} {r['error'] or r['problems'] or r['jsonld_types']}"[:150]) print(f'{len(rows)} URLs in {time.time() - t:.1f} s -> meta_report.csv') Three choices matter. HTMLParser from the standard library is enough here: meta tags are flat, so there's no need for BeautifulSoup. <title> elements inside inline <svg> icons are ignored, because many sites have several of them and a naive parser picks up "Close" or "Search" as the page title. And Open Graph tags are read from either property= or name=, since real sites use both (MDN uses name="og:title").
Results
$ python3 meta_tags_check.py urls.txt !! https://www.python.org description 52 chars; no canonical; 5 h1; no twitter:card !! https://github.com title 70 chars; description 186 chars !! https://example.com missing description; no canonical; 0 h1; missing og:title; missing og:image; no twitter:card !! https://www.bbc.com/news title 93 chars; description 249 chars; empty h1; missing og:image; no twitter:card ok https://developer.mozilla.org/en-U !! https://kubernetes.io description 559 chars; no canonical !! https://www.theguardian.com/intern description 62 chars; 0 h1; missing og:title; missing og:image; no twitter:card !! https://stripe.com 2 h1 ok https://www.allrecipes.com/recipe/ NewsArticle Recipe !! https://docs.python.org/3/ description 34 chars; no twitter:card ERR https://expired.badssl.com URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ 11 URLs in 0.5 s -> meta_report.csv What this sample shows:
- Big sites ship "problems" on purpose. BBC News has a 93-character title and a 249-character description, and the Guardian's international front has no Open Graph tags at all. Treat the length rules as a review list, not a verdict; on your own site they're usually worth fixing.
- Multiple
<h1>s are common. python.org uses five (one per carousel slide), Stripe two. Fine for users, but on a template you control it's often accidental. - Missing canonicals show up on well-run sites. python.org and kubernetes.io have none on their home pages. Not fatal, but on pages that are reachable through several URLs it's the cheapest duplicate-content fix there is.
- Structured data comes along for free. The Allrecipes recipe page declares
RecipeandNewsArticleJSON-LD, which is what powers its rich result.
Gotchas I hit
- Bots get different HTML. Some sites serve a challenge page or
403to unknown user agents, and the Facebook or Googlebot user agents get blocked from cloud IPs more often than a browser one. Send a browser User-Agent and treat challenge pages as "unknown", not "missing tags". - Check that
og:imageactually loads. A tag pointing at a deleted file is worse than no tag, because the preview breaks silently. The script does aHEADrequest and expects animage/*content type. Some CDNs rejectHEAD; fall back to a rangedGETif you see false alarms. - Relative
og:imageURLs exist. They're not valid per the Open Graph spec, but they're common; resolve them against the final URL before testing. - Cap the download. Some pages are several MB of inline JS. The script reads 2 MB, which always includes the
<head>. - JavaScript-rendered tags won't appear. Single-page apps that inject meta tags client-side look empty to a plain HTTP fetch, which is also roughly what most link-preview bots see. That's a real finding, not a script bug.
- Don't point it at internal addresses. If you expose this as a service that takes user input, block private and reserved IPs, or it becomes an SSRF tool.
Running it at scale
For a few hundred URLs the script is enough. If you'd rather not handle redirect tracking, charset detection, hreflang, Google rich-result required properties per schema.org type, microdata and RDFa, bot-challenge detection, Googlebot/Facebook/mobile user agents and a duplicate-title/description summary yourself, I also published it as a cloud tool: Meta Tags & Open Graph Checker on Apify (disclosure: I built it). It can return only the pages with problems, and costs $1 per 1,000 URLs checked; unreachable pages, bot-challenge pages, 4xx/5xx pages and invalid inputs are free.
Either way, run it before a launch and after every template change. A broken link preview is the first thing people see when they share your page.



