SEO Checker за Astro сайтове с Python
Python скрипт за проверка на title, seoTitle и meta description в Markdown frontmatter и в генерирания HTML на Astro проект. Показва точни дължини и граници.
Зависимости
python3pyyamlpython-frontmatterbeautifulsoup4lxml Употреба
seo --source | seo --dist | seo --both Локален инструмент, който проверява title, seoTitle и meta description преди и след build. Работи директно от папката на проекта и дава ясни съобщения от типа:
title (72): … → ❌ твърде дълъг (трябва ≤ 60) seoTitle (54): … → ✓
Какво прави
Скриптът има два режима:
| Режим | Какво проверява | Кога се ползва |
|---|---|---|
--source | Frontmatter в .md / .mdx файлове | Преди astro build |
--dist | Генерирания HTML в dist/ | След astro build |
--both | И двете | Пълна проверка |
Проверява:
- Дължина на
title(препоръчително 50–60 символа) - Дължина на
seoTitle(ако съществува) - Дължина на
description(препоръчително 120–160 символа) - Липсващи title / description
- Брой H1 елементи (в HTML режима)
Зависимости
python3
pyyaml
python-frontmatter
beautifulsoup4
lxml
Инсталация (веднъж)
# 1. Създаваме директория и виртуална среда
mkdir -p ~/skript
python3 -m venv ~/skript/.venv
source ~/skript/.venv/bin/activate
# 2. Инсталираме пакетите
pip install pyyaml python-frontmatter beautifulsoup4 lxml
# 3. Записваме requirements.txt (за възпроизводимост)
cat > ~/skript/requirements.txt << 'EOF'
pyyaml>=6.0
python-frontmatter>=1.1.0
beautifulsoup4>=4.12.0
lxml>=5.0.0
EOF
Копираме скрипта (виж кода по-долу) в ~/skript/check_seo_local.py и му даваме права:
chmod +x ~/skript/check_seo_local.py
Удобен wrapper (seo)
mkdir -p ~/bin
cat > ~/bin/seo << 'EOF'
#!/bin/bash
/home/fedia/skript/.venv/bin/python /home/fedia/skript/check_seo_local.py "$@"
EOF
chmod +x ~/bin/seo
Увери се, че ~/bin е в PATH (в ~/.bashrc):
export PATH="$HOME/bin:$PATH"
След source ~/.bashrc командата seo работи от всяка папка.
Употреба
От папката на всеки Astro проект:
# Само frontmatter (най-бързо)
seo --source
# Само след build
seo --dist
# Пълна проверка
seo --both
Препоръчителен workflow
- Пишеш/редактираш статия
seo --source→ оправяш title, seoTitle и descriptionnpm run build(илиastro build)seo --dist→ проверяваш какво реално е влязло в HTML-а- Deploy
Какво означават съобщенията
| Съобщение | Значение |
|---|---|
✓ | Всичко е в норма |
⚠ title твърде къс | Под 30 символа |
⚠ title малко дълъг | 61–70 символа |
❌ title твърде дълъг | Над 70 символа (почти сигурно се реже в Google) |
❌ Липсва title | Няма title в frontmatter / HTML |
Същото за seoTitle и description | 120–160 е идеалният диапазон за description |
Пример за изход
✗ src/content/scripts/mikrotik-backup.md
title ( 72): MikroTik Backup Automation – пълно ръководство за автоматичен backup...
seoTitle ( 54): MikroTik Backup Automation
description (148): Автоматичен backup на MikroTik рутери през SSH...
❌ title твърде дълъг: 72 знака (трябва ≤ 60, над 70 почти сигурно се реже)
Код
#!/home/fedia/skript/.venv/bin/python
"""
Локална SEO проверка за Astro сайтове
Проверява title + seoTitle + description
"""
import argparse
import sys
from pathlib import Path
import frontmatter
from bs4 import BeautifulSoup
# ====================== НАСТРОЙКИ ======================
TITLE_MIN = 30
TITLE_MAX = 60
TITLE_WARN = 70
DESC_MIN = 120
DESC_MAX = 160
DESC_WARN = 180
CONTENT_GLOBS = [
"src/content/**/*.md",
"src/content/**/*.mdx",
"src/pages/**/*.md",
"src/pages/**/*.mdx",
"src/pages/articles/**/*.md",
"src/pages/blog/**/*.md",
"src/pages/tutorials/**/*.md",
"content/**/*.md",
"content/**/*.mdx",
]
DIST_GLOBS = [
"dist/**/*.html",
]
# =======================================================
def check_length(name: str, value: str, min_len: int, max_len: int, warn_len: int):
issues = []
length = len(value) if value else 0
if not value or not value.strip():
issues.append(f"❌ Липсва {name}")
return issues, length
if length < min_len:
issues.append(f"⚠ {name} твърде къс: {length} знака (препоръчително ≥ {min_len})")
elif length > warn_len:
issues.append(
f"❌ {name} твърде дълъг: {length} знака "
f"(трябва ≤ {max_len}, над {warn_len} почти сигурно се реже)"
)
elif length > max_len:
issues.append(f"⚠ {name} малко дълъг: {length} знака (идеално {min_len}–{max_len})")
return issues, length
def check_frontmatter(file_path: Path):
try:
post = frontmatter.load(file_path)
except Exception as e:
return {
"file": str(file_path),
"issues": [f"❌ Не може да се прочете frontmatter: {e}"],
"ok": False,
}
data = post.metadata
issues = []
# --- title ---
title = str(data.get("title") or "").strip()
t_issues, t_len = check_length("title", title, TITLE_MIN, TITLE_MAX, TITLE_WARN)
issues.extend(t_issues)
# --- seoTitle ---
seo_title = str(
data.get("seoTitle")
or data.get("seo_title")
or (data.get("seo") or {}).get("title")
or ""
).strip()
st_len = 0
if seo_title:
st_issues, st_len = check_length("seoTitle", seo_title, TITLE_MIN, TITLE_MAX, TITLE_WARN)
issues.extend(st_issues)
# --- description ---
description = str(
data.get("description")
or (data.get("seo") or {}).get("description")
or data.get("excerpt")
or data.get("summary")
or ""
).strip()
d_issues, d_len = check_length("description", description, DESC_MIN, DESC_MAX, DESC_WARN)
issues.extend(d_issues)
return {
"file": str(file_path),
"title": title,
"title_len": t_len,
"seoTitle": seo_title,
"seoTitle_len": st_len,
"description": description,
"desc_len": d_len,
"issues": issues,
"ok": not any(i.startswith("❌") for i in issues),
}
def check_html(file_path: Path):
try:
html = file_path.read_text(encoding="utf-8", errors="ignore")
soup = BeautifulSoup(html, "html.parser")
except Exception as e:
return {
"file": str(file_path),
"issues": [f"❌ Не може да се прочете HTML: {e}"],
"ok": False,
}
title_tag = soup.find("title")
title = title_tag.get_text(strip=True) if title_tag else ""
meta = soup.find("meta", attrs={"name": "description"})
description = meta["content"].strip() if meta and meta.get("content") else ""
if not title:
og = soup.find("meta", property="og:title")
title = og["content"].strip() if og and og.get("content") else ""
if not description:
og = soup.find("meta", property="og:description")
description = og["content"].strip() if og and og.get("content") else ""
t_issues, t_len = check_length("title", title, TITLE_MIN, TITLE_MAX, TITLE_WARN)
d_issues, d_len = check_length("description", description, DESC_MIN, DESC_MAX, DESC_WARN)
issues = t_issues + d_issues
h1s = [h.get_text(strip=True) for h in soup.find_all("h1")]
if not h1s:
issues.append("⚠ Липсва H1")
elif len(h1s) > 1:
issues.append(f"⚠ Има {len(h1s)} H1 елемента")
return {
"file": str(file_path),
"title": title,
"title_len": t_len,
"seoTitle": "",
"seoTitle_len": 0,
"description": description,
"desc_len": d_len,
"issues": issues,
"ok": not any(i.startswith("❌") for i in issues),
}
def find_files(root: Path, patterns: list[str]) -> list[Path]:
files = []
for pattern in patterns:
files.extend(root.glob(pattern))
return sorted(set(files))
def print_result(res: dict):
status = "✓" if res.get("ok") and not res.get("issues") else "✗"
print(f"{status} {res['file']}")
# title
if res.get("title"):
t = res["title"]
print(f" title ({res['title_len']:3d}): {t[:80]}{'...' if len(t) > 80 else ''}")
else:
print(f" title ( 0): — липсва")
# seoTitle
if res.get("seoTitle"):
st = res["seoTitle"]
print(f" seoTitle ({res['seoTitle_len']:3d}): {st[:80]}{'...' if len(st) > 80 else ''}")
else:
print(f" seoTitle ( 0): — няма")
# description
if res.get("description"):
d = res["description"]
print(f" description ({res['desc_len']:3d}): {d[:80]}{'...' if len(d) > 80 else ''}")
else:
print(f" description ( 0): — липсва")
for issue in res.get("issues", []):
print(f" {issue}")
print()
def main():
parser = argparse.ArgumentParser(description="Локална SEO проверка за Astro проекти")
parser.add_argument("path", nargs="?", default=".", help="Път до проекта")
parser.add_argument("--source", action="store_true", help="Само Markdown/MDX")
parser.add_argument("--dist", action="store_true", help="Само dist/")
parser.add_argument("--both", action="store_true", help="И source, и dist")
args = parser.parse_args()
root = Path(args.path).resolve()
if not root.exists():
print(f"Папката не съществува: {root}")
sys.exit(1)
print(f"\n📁 Проект: {root}\n")
do_source = args.source or args.both or (not args.source and not args.dist)
do_dist = args.dist or args.both or (not args.source and not args.dist)
all_results = []
if do_source:
print("=" * 60)
print(" 1. Проверка на frontmatter (Markdown / MDX)")
print("=" * 60)
files = find_files(root, CONTENT_GLOBS)
if not files:
print(" Не са намерени .md / .mdx файлове.\n")
else:
print(f" Намерени {len(files)} файла\n")
for f in files:
res = check_frontmatter(f)
all_results.append(res)
print_result(res)
if do_dist:
print("=" * 60)
print(" 2. Проверка на генерирания HTML (dist/)")
print("=" * 60)
dist_files = find_files(root, DIST_GLOBS)
article_html = [
f for f in dist_files
if any(x in str(f).lower() for x in ["/articles/", "/blog/", "/tutorials/", "/posts/", "/scripts/"])
or (f.parent != root / "dist" and f.name == "index.html")
]
if not article_html:
article_html = [f for f in dist_files if f.name == "index.html"]
if not article_html:
print(" Не е намерена папка dist/ или няма HTML. Пусни първо build.\n")
else:
print(f" Намерени {len(article_html)} HTML файла\n")
for f in article_html:
res = check_html(f)
all_results.append(res)
print_result(res)
print("=" * 60)
print(" ОБОБЩЕНИЕ")
print("=" * 60)
total = len(all_results)
with_critical = sum(1 for r in all_results if any(i.startswith("❌") for i in r.get("issues", [])))
with_any = sum(1 for r in all_results if r.get("issues"))
print(f"Общо проверени: {total}")
print(f"С критични грешки: {with_critical}")
print(f"С предупреждения: {with_any - with_critical}")
print(f"Чисти: {total - with_any}")
if with_critical > 0:
sys.exit(1)
if __name__ == "__main__":
main()
Как да обясниш на колега за 30 секунди
„От папката на сайта пускаш
seo --sourceпреди build иseo --distслед build.
Скриптът ти казва точно колко символа е title, seoTitle и description и дали са в норма.
Всичко е локално, без интернет, работи за трите ни Astro сайта.“
Защо локално, а не crawl на живия сайт
- Много по-бързо
- Работи офлайн
- Хваща грешките преди deploy
- Може да се пуска в CI (exit code 1 при критични грешки)