Назад към каталога
Илюстрация на автоматизация: терминал, зъбчати колела и конвейер от задачи
Автоматизация ⭐ Препоръчан v1.1.0 • 25.10.2026 г. • 🐍 python

SEO Checker за Astro сайтове с Python

Python скрипт за проверка на title, seoTitle и meta description в Markdown frontmatter и в генерирания HTML на Astro проект. Показва точни дължини и граници.

Зависимости

python3pyyamlpython-frontmatterbeautifulsoup4lxml

Употреба

seo --source | seo --dist | seo --both
SEOAstroPythonFrontmatterMeta tagsQuality

Локален инструмент, който проверява title, seoTitle и meta description преди и след build. Работи директно от папката на проекта и дава ясни съобщения от типа:

title (72): … → ❌ твърде дълъг (трябва ≤ 60) seoTitle (54): … → ✓

Какво прави

Скриптът има два режима:

РежимКакво проверяваКога се ползва
--sourceFrontmatter в .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

  1. Пишеш/редактираш статия
  2. seo --source → оправяш title, seoTitle и description
  3. npm run build (или astro build)
  4. seo --dist → проверяваш какво реално е влязло в HTML-а
  5. Deploy

Какво означават съобщенията

СъобщениеЗначение
✓Всичко е в норма
⚠ title твърде късПод 30 символа
⚠ title малко дълъг61–70 символа
❌ title твърде дълъгНад 70 символа (почти сигурно се реже в Google)
❌ Липсва titleНяма title в frontmatter / HTML
Същото за seoTitle и description120–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 при критични грешки)