
Gr Seo Patrol
- 110 installs
- 66 repo stars
- Updated August 4, 2026
- gingiris-1031/gingiris-skills
Run daily SEO/GEO patrol: track SERP rankings, check indexation and llms.txt, fix canonicals, inject internal links, and verify GA4 tags.
About
A daily SEO/GEO patrol skill that tracks keyword rankings, checks indexation and GA4 tags, fixes canonicals, and injects internal links to rescue posts. A developer uses it to run an SEO daily report and diagnose ranking drops.
- Daily report script plus single-keyword drop diagnosis flow
- Canonical merge fixes and internal-link injection rescue
Gr Seo Patrol by the numbers
- 110 all-time installs (skills.sh)
- Ranked #1,123 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gingiris-1031/gingiris-skills --skill gr-seo-patrolAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 110 |
|---|---|
| repo stars | ★ 66 |
| Last updated | August 4, 2026 |
| Repository | gingiris-1031/gingiris-skills ↗ |
What it does
Run daily SEO/GEO patrol: track SERP rankings, check indexation and llms.txt, fix canonicals, inject internal links, and verify GA4 tags.
Files
gr-seo-patrol — SEO/GEO 日常巡逻
什么时候用
| 场景 | 动作 |
|---|---|
| "跑今日 SEO 日报" | 执行完整巡逻(scripts/daily-report.py) |
| "某关键词排名掉了" | 单词诊断(scripts/diag-keyword.py) |
| "修 canonical" | 批量合并(scripts/canonical-fix.py) |
| "救一下这篇文章" | 社媒救援(scripts/rescue-post.py)—— title 前置 + 3 个高权重内链 |
| "检查 GA4 部署" | 扫 HTML 里的 G-XXXXX tag |
| "查 llms.txt" | HTTP 200 + 字节数 |
---
核心流程
1. 日报模式
输入:无(全自动) 或 指定关键词清单 输出:
- 关键词排名 diff 表(今日 vs 昨日基线)
- Google 索引数
- llms.txt 状态
- GA4 覆盖状态
- 🚨 异常提示(跌出 top 100、整体刷新)
2. 单词诊断
输入:1 个关键词 过程: 1. 跑 4-5 个 long-tail 变体 SERP 2. 检查目标 URL 是否 HTTP 200 3. 用 site: 查询确认是否索引 4. 对比 3 天前的数据(如果有历史) 输出:雪崩 / 个别 / SERP 刷新 / 死链 四选一
3. Canonical 批量修复
输入:同主题的 N 篇文章 + 1 个 master URL 过程:替换每篇 frontmatter 里的 canonical_url: → master,GitHub API PUT 安全护栏:
- 只改
_posts/下的 .md - 每篇 commit 独立(方便回滚)
- 跳过 ja/ko(hreflang 替代,保持 self-canonical)
4. 社媒救援
输入:1 个雪崩 URL 动作:
- a. 重写 title:主关键词前置,长度 ≤ 60 字
- b. 从 top-3 高权重文章注入内链(带 anchor text = 目标关键词)
- c. 记录修改前后到日志
---
API 依赖
| Service | Env var | 用途 |
|---|---|---|
| DataForSEO | DATAFORSEO_B64 | SERP 查询 |
| GitHub PAT | GITHUB_TOKEN | 读写 _posts/ |
完整 key 模板见 docs/api-keys-template.md。
---
SERP 查询模板
import urllib.request, json
def serp(kw, loc=2840, lang="en", depth=100):
key = os.environ["DATAFORSEO_B64"]
payload = json.dumps([{"keyword": kw, "location_code": loc,
"language_code": lang, "device": "desktop",
"depth": depth}]).encode()
req = urllib.request.Request(
"https://api.dataforseo.com/v3/serp/google/organic/live/advanced",
data=payload,
headers={"Authorization": f"Basic {key}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=40) as r:
return json.loads(r.read())常用 location_code:
- 2840 US / 2826 UK / 2392 JP / 2410 KR / 2156 CN(无效,中国不返回)
---
执行脚本
实际可执行的脚本在 scripts/ 下:
daily-report.py— 完整日报canonical-fix.py— canonical 批量合并rescue-post.py— 社媒文章救援diag-keyword.py— 单关键词诊断
每个脚本独立可跑。调用时优先用 Bash 工具,不要重写脚本。
---
输出规范
1. 先给表格,再给结论 2. 差异用箭头:#6 ↑ / #19 ↓↓ 3. 超过 3 项异常 → 单独开"🚨 异常"一节 4. 所有排名必须带 location_code,不要假设
---
级联推荐
- 发现 cannibalization(同一关键词多篇排名接近) →
gr-blog-post做 canonical 整合 - 发现 N 个关键词同日跌出 top 100 → 先等 24h,不要硬改内容
- 发现新关键词机会(长尾 top 30) →
gr-blog-post扩写
---
反模式
- ❌ 不要用
grep/find扫_posts/,改用 GitHub Contents API - ❌ 不要在生产环境直接
git push—— 用 Contents API 的 PUT - ❌ 不要一次 serp 50 个关键词 —— 批量过大易触发 rate limit,按 6-10 个一批
- ❌ canonical 改到 master 后不要立刻 force Google 重新索引 —— 等 3-7 天自然爬取
---
Monthly Full-Site Audit Workflow
Validated 2026-05-07 on 58 pages: caught 43 SERP-truncating titles + 36 schema warns + 27 stop-word slugs in a single 30-min pass. Single layout-level fix (commit 24a0410e) resolved 20 of 43 title issues.Run once per month before phase2-monthly-checkpoint. Output: HTML report + machine-readable findings.json archived to gingiris-skills/data/audit-{YYYY-MM-DD}.json.
Stage 1 — Discovery (5 min)
import urllib.request, re
sm = urllib.request.urlopen("https://gingiris.github.io/growth-tools/sitemap.xml").read().decode()
urls = [u for u in re.findall(r"<loc>([^<]+)</loc>", sm) if "/blog/" in u]
# typically 50-70 URLsStage 2 — Parallel Audit (20 min for 60 pages, 4 threads)
Use the adopted seo-audit-skill scripts in scripts/:
# For each URL — run 2 scripts in parallel
python3 scripts/check-page.py URL --timeout 20 # title, H1, meta, canonical, slug, alt, keyword placement
python3 scripts/check-schema.py URL --timeout 20 # JSON-LD validationOr batch with Python's concurrent.futures.ThreadPoolExecutor(max_workers=4). Don't go higher than 4 — GitHub Pages CDN throttles aggressive parallel hits.
Each script outputs structured JSON envelope:
{"field": {"status": "pass|warn|fail|info", "detail": "...", "llm_review_required": false}}Stage 3 — Aggregate Findings
Bucket by category:
- Title length > 70 chars (SERP truncation risk)
- H1 length > 70 chars (mobile readability)
- Meta description < 80 or > 170 chars
- Schema warns by @type (BlogPosting, Article, Organization)
- Canonical issues (mismatch with final URL)
- Slug issues (stop words, uppercase, missing keyword)
- Image alt text missing on content images
Save aggregated counts + per-issue URL lists to findings.json.
Stage 4 — Layered Fix Strategy (HIGH ROI ORDER)
1️⃣ Layout-level fixes first (1 commit, fixes 20+ pages):
- Schema bugs in
_layouts/default.html - Site-name suffix in
<title>tag - Missing
dateModifiedfromlast_modified_atfrontmatter - Organization / Publisher / contactPoint completeness
2️⃣ Config-level fixes (1 commit, fixes site-wide):
_config.yml— logo URL (must be absolute), twitter, social, author structure
3️⃣ Per-article batch fixes (1 commit per file, parallelizable):
- Trim long titles while preserving keyword (target ≤ 70 chars, ideal 50-60)
- Trim long H1s (target ≤ 70 chars)
- Expand short meta descriptions (target 120-160 chars)
- Add missing Citable Statistics blocks for top GSC-impression pages
4️⃣ Skip these (low ROI):
- Slug stop words (changing breaks 301)
- Old articles with <50 imp/month (low traffic = low fix priority)
Stage 5 — Verify (after Jekyll rebuild ~60-90s)
Re-run check-schema.py on a sample page. Confirm status: pass for at least: Article · BlogPosting · Organization · FAQPage
Stage 6 — Archive
Commit findings.json to gingiris-skills/data/audit-{YYYY-MM-DD}.json for trend tracking. Add 2-5 atoms documenting any new lessons learned.
---
HARD RULE (anti-hallucination guardrail)
Adopted from JeffLi1993/seo-audit-skill — strict whitelist pattern.
⛔ Output ONLY the checks defined in the audit script's JSON envelope.
- Do NOT add "bonus" checks not in the script output
- Do NOT contradict the script's
statusfield unless you have additional observable evidence - Do NOT invent metrics like "EEAT score 89" — third-party scoring tools are unofficial (per Google's 2026 guidance)
- Do NOT include checks marked
llm_review_required: falsein your judgment commentary — the script'sstatusis final - If
llm_review_required: true, make explicit judgment, document reasoning, then update status
The script envelope is the single source of truth. Treat it as a strict whitelist.
---
Companion skill
For single-page audits (not full-site), the same scripts power [JeffLi1993/seo-audit-skill](https://github.com/JeffLi1993/seo-audit-skill) which produces a polished HTML audit report. Install as a complementary skill if you want client-presentable per-page audits.
#!/usr/bin/env python3
"""Batch-update canonical_url in Jekyll _posts to point at a master URL.
Usage:
GITHUB_TOKEN=xxx GR_REPO=Gingiris/growth-tools \\
python canonical-fix.py \\
--master https://gingiris.github.io/growth-tools/blog/2026/03/25/master-post/ \\
--posts 2026-03-18-a.md 2026-03-29-b.md ...
Safety:
- Only touches files under _posts/
- One commit per file (easy to revert)
- Dry-run by default; pass --apply to write
"""
import argparse, base64, json, os, re, sys, urllib.request
GH = os.environ.get("GITHUB_TOKEN")
REPO = os.environ.get("GR_REPO", "Gingiris/growth-tools")
def gh(path, method="GET", body=None):
req = urllib.request.Request(
f"https://api.github.com/repos/{REPO}/contents/{path}",
method=method,
data=body,
headers={"Authorization": f"token {GH}",
"User-Agent": "gr-seo-patrol",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def main():
p = argparse.ArgumentParser()
p.add_argument("--master", required=True)
p.add_argument("--posts", nargs="+", required=True,
help="Filenames inside _posts/ (with or without prefix)")
p.add_argument("--apply", action="store_true")
args = p.parse_args()
if not GH:
sys.exit("GITHUB_TOKEN env var missing")
for name in args.posts:
path = name if name.startswith("_posts/") else f"_posts/{name}"
if not path.endswith(".md"):
print(f"SKIP {path}: not .md"); continue
try:
m = gh(path)
except urllib.error.HTTPError as e:
print(f"SKIP {path}: {e.code}"); continue
content = base64.b64decode(m["content"]).decode("utf-8")
new, n = re.subn(r"^(canonical_url:\s*).*$",
f"\\1{args.master}", content,
count=1, flags=re.MULTILINE)
if n == 0:
print(f"SKIP {path}: no canonical_url line"); continue
if new == content:
print(f"SKIP {path}: already correct"); continue
if not args.apply:
print(f"[dry] would update {path}"); continue
payload = json.dumps({
"message": "fix: canonical -> master",
"content": base64.b64encode(new.encode()).decode(),
"sha": m["sha"]}).encode()
r = gh(path, "PUT", payload)
print(f"OK {path}: {r['commit']['sha'][:8]}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Adapted from JeffLi1993/seo-audit-skill (https://github.com/JeffLi1993/seo-audit-skill)
# Original license: see https://github.com/JeffLi1993/seo-audit-skill/blob/HEAD/LICENSE
# Pulled into gingiris-skills @ 2026-05-07 for gr-seo-patrol single-page audit capability.
"""
Page-level SEO checks: H1, title tag, meta description, and canonical.
Uses Python stdlib html.parser — no BeautifulSoup required.
Outputs structured JSON to stdout for direct agent consumption.
Usage:
python check-page.py https://example.com
python check-page.py https://example.com --timeout 20
Output example (JSON):
{
"url": "https://example.com/",
"final_url": "https://example.com/",
"http_status": 200,
"redirect_chain": [],
"h1": {
"status": "pass",
"count": 1,
"values": ["Best Running Shoes 2025"],
"detail": "Single H1 found."
},
"title": {
"status": "pass",
"value": "Best Running Shoes 2025 | Free Shipping",
"length": 42,
"detail": "Title is 42 characters — within recommended range (50-60)."
},
"meta_description": {
"status": "pass",
"value": "Shop the best running shoes...",
"length": 138,
"detail": "Meta description is 138 characters — within recommended range (120-160)."
},
"canonical": {
"status": "pass",
"value": "https://example.com/",
"matches_final_url": true,
"detail": "Self-referencing canonical present."
}
}
Dependencies:
pip install requests
(HTML parsing uses Python stdlib html.parser — no extra packages needed)
"""
import argparse
import ipaddress
import json
import socket
import sys
from html.parser import HTMLParser
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# ── HTTP fetch ────────────────────────────────────────────────────────────────
_DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ClaudeSEO/1.2"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
}
# 关键词过滤用停用词表(替代长度阈值,避免误杀 SEO/AI/CRM 等短缩写词)
_STOP_WORDS = frozenset({
"a", "an", "the", "and", "or", "but", "not", "no",
"in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
"is", "are", "was", "were", "be",
"it", "its", "this", "that",
})
def _fetch(url: str, timeout: int) -> tuple[Optional[int], Optional[str], str, list[dict], Optional[str]]:
"""
Fetch a page with SSRF protection.
Returns (status_code, content, final_url, redirect_chain, error).
"""
parsed = urlparse(url)
# SSRF protection: block private, loopback, and reserved IPs
try:
hostname = parsed.hostname or ""
resolved_ip = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback or ip.is_reserved:
return None, None, url, [], f"Blocked: resolves to private IP ({resolved_ip})"
except (socket.gaierror, ValueError):
pass
try:
session = requests.Session()
session.max_redirects = 5
resp = session.get(url, headers=_DEFAULT_HEADERS, timeout=timeout, allow_redirects=True)
redirect_chain = [
{"url": r.url, "status_code": r.status_code} for r in resp.history
]
return resp.status_code, resp.text, resp.url, redirect_chain, None
except requests.exceptions.Timeout:
return None, None, url, [], f"Timed out after {timeout}s"
except requests.exceptions.TooManyRedirects:
return None, None, url, [], "Too many redirects (max 5)"
except requests.exceptions.SSLError as e:
return None, None, url, [], f"SSL error: {e}"
except requests.exceptions.ConnectionError as e:
return None, None, url, [], f"Connection error: {e}"
except requests.exceptions.RequestException as e:
return None, None, url, [], f"Request failed: {e}"
# ── HTML parser (stdlib, no external dependencies) ────────────────────────────
class _SEOParser(HTMLParser):
"""
Lightweight SEO element extractor.
Extracts <title>, <h1>, <meta name="description">, and <link rel="canonical">.
Does not build a full DOM tree — single-pass scan only.
"""
def __init__(self) -> None:
super().__init__()
# title state
self.title: Optional[str] = None
self._in_title = False
self._title_buf = ""
# h1 state
self.h1_values: list[str] = []
self._in_h1 = False
self._h1_depth = 0 # tracks nesting depth to handle inline tags inside <h1>
self._h1_buf = ""
# meta description
self.meta_description: Optional[str] = None
# canonical
self.canonical: Optional[str] = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
attrs_dict = {k.lower(): (v or "") for k, v in attrs}
if tag == "title" and self.title is None:
self._in_title = True
self._title_buf = ""
elif tag == "h1":
self._in_h1 = True
self._h1_depth += 1
self._h1_buf = ""
elif tag == "meta":
name = attrs_dict.get("name", "").lower()
if name == "description" and self.meta_description is None:
self.meta_description = attrs_dict.get("content", "")
elif tag == "link":
rel = attrs_dict.get("rel", "").lower()
if "canonical" in rel and self.canonical is None:
self.canonical = attrs_dict.get("href", "")
def handle_endtag(self, tag: str) -> None:
if tag == "title" and self._in_title:
self._in_title = False
self.title = self._title_buf.strip() or None
elif tag == "h1" and self._in_h1:
self._h1_depth -= 1
if self._h1_depth <= 0:
self._in_h1 = False
self._h1_depth = 0
text = self._h1_buf.strip()
if text:
self.h1_values.append(text)
def handle_data(self, data: str) -> None:
if self._in_title:
self._title_buf += data
if self._in_h1:
self._h1_buf += data
# ── Check functions ───────────────────────────────────────────────────────────
def _check_h1(h1_values: list[str], keyword: Optional[str] = None) -> dict:
"""
H1 checks — two-layer design:
Layer 1 (script): mechanical checks
- Uniqueness: exactly one H1
- Non-empty content
- Length: warn if < 5 chars (brand-only) or > 70 chars
- Keyword match: full / partial / none / unverified
Layer 2 (LLM, triggered by llm_review_required=True):
- When keyword_match == "partial": agent must judge semantic intent alignment.
Script cannot determine if "Best Personal AI" covers intent for "AI computer".
Agent reads h1_text + keyword and makes the call.
Output fields:
keyword_match : "full" | "partial" | "none" | "unverified"
llm_review_required: True when keyword_match == "partial" — agent must do semantic review
"""
count = len(h1_values)
if count == 0:
return {
"status": "fail",
"count": 0,
"values": [],
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "No H1 tag found. Every page should have exactly one H1 containing the primary keyword.",
}
if count > 1:
return {
"status": "fail",
"count": count,
"values": h1_values,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": (
f"{count} H1 tags found. Multiple H1s dilute heading hierarchy "
f"and make it harder for crawlers to identify the primary topic. "
f"Keep exactly one H1; include the primary keyword or a natural variant."
),
}
# Exactly one H1 — run content checks
h1_text = h1_values[0]
if not h1_text.strip():
return {
"status": "fail",
"count": 1,
"values": h1_values,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "H1 tag present but content is empty.",
}
length = len(h1_text)
issues: list[str] = []
# Length thresholds
if length < 5:
issues.append(
f'H1 is very short ({length} chars): "{h1_text}". '
"Likely brand-name only — add the primary keyword to signal page topic."
)
elif length > 70:
issues.append(
f"H1 is {length} characters — consider trimming to under 70 for readability."
)
# Keyword match — script does string-level detection only
if keyword:
h1_lower = h1_text.lower()
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower in h1_lower
partial_match = not full_match and any(w in h1_lower for w in kw_words)
if full_match:
keyword_match = "full"
llm_review_required = False
keyword_note = f'Primary keyword "{keyword}" found in H1 (full match).'
elif partial_match:
# Script stops here — agent must decide if this is a valid natural variant
keyword_match = "partial"
llm_review_required = True
keyword_note = (
f'Partial string match for "{keyword}" in H1: "{h1_text}". '
"Script cannot determine semantic intent alignment. "
"LLM review required: does this H1 cover the search intent of the keyword?"
)
issues.append(keyword_note)
else:
keyword_match = "none"
llm_review_required = False
issues.append(
f'Primary keyword "{keyword}" not found in H1: "{h1_text}". '
"Homepage best practice: brand + primary keyword. "
"Other pages: primary keyword or a natural variant."
)
else:
keyword_match = "unverified"
llm_review_required = False
issues.append(
"No --keyword provided. Keyword presence in H1 not checked. "
"Pass --keyword <term> to enable this check."
)
if issues:
return {
"status": "warn",
"count": 1,
"values": h1_values,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": " | ".join(issues),
}
return {
"status": "pass",
"count": 1,
"values": h1_values,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": f'Single H1 found: "{h1_text}". {keyword_note if keyword else ""}'.strip(),
}
def _check_title(title: Optional[str], keyword: Optional[str] = None) -> dict:
"""
Title tag checks — two-layer design:
Layer 1 (script): mechanical checks
- Presence
- Length: recommended 50-60 chars
- Keyword match: full / partial / none / unverified
- Keyword position: "start" (within first 30 chars) / "middle" / "absent"
Layer 2 (LLM, triggered by llm_review_required=True):
- Is the title grammatically correct and naturally readable?
- If keyword_match == "partial": does it semantically cover the intent?
- If keyword_position != "start": should it be moved to the front?
Best practice: lead with primary keyword for highest SEO weight.
Output fields:
keyword_match : "full" | "partial" | "none" | "unverified"
keyword_position : "start" | "middle" | "absent" | "unverified"
llm_review_required: True when quality or semantic judgment is needed
"""
if not title:
return {
"status": "fail",
"value": None,
"length": 0,
"keyword_match": "unverified",
"keyword_position": "unverified",
"llm_review_required": False,
"detail": "No <title> tag found. Title is a critical on-page SEO element.",
}
length = len(title)
issues: list[str] = []
notes: list[str] = []
# Length check
if length < 10:
issues.append(f"Title is only {length} characters — likely a placeholder or too short.")
elif length > 60:
issues.append(f"Title is {length} characters — may be truncated in SERPs (recommended 50-60).")
elif length < 50:
issues.append(f"Title is {length} characters — slightly short (recommended 50-60).")
else:
notes.append(f"Length {length} chars — within recommended range (50-60).")
# Keyword checks
llm_review_required = False
if keyword:
title_lower = title.lower()
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower in title_lower
partial_match = not full_match and any(w in title_lower for w in kw_words)
if full_match:
keyword_match = "full"
# Check position: keyword should appear in first 30 chars (high SEO weight zone)
pos = title_lower.find(kw_lower)
if pos <= 30:
keyword_position = "start"
notes.append(f'Keyword "{keyword}" leads the title — good SEO positioning.')
else:
keyword_position = "middle"
issues.append(
f'Keyword "{keyword}" found at position {pos} — '
"best practice is to lead with the primary keyword (within first 30 chars)."
)
llm_review_required = True
elif partial_match:
keyword_match = "partial"
keyword_position = "middle"
llm_review_required = True
issues.append(
f'Partial match for "{keyword}" in title: "{title}". '
"Script cannot determine semantic intent alignment. "
"LLM review required: does this title cover the keyword's search intent? "
"Is it grammatically natural?"
)
else:
keyword_match = "none"
keyword_position = "absent"
issues.append(
f'Primary keyword "{keyword}" not found in title. '
"Lead with the primary keyword for strongest SEO signal."
)
else:
keyword_match = "unverified"
keyword_position = "unverified"
llm_review_required = True
issues.append(
"No --keyword provided. Keyword presence and position in title not checked. "
"LLM review required: verify title starts with the primary keyword and reads naturally."
)
detail_parts = issues + notes
status = "fail" if length < 10 else ("warn" if issues else "pass")
return {
"status": status,
"value": title,
"length": length,
"keyword_match": keyword_match,
"keyword_position": keyword_position,
"llm_review_required": llm_review_required,
"detail": " | ".join(detail_parts),
}
def _check_meta_description(meta_desc: Optional[str], keyword: Optional[str] = None) -> dict:
"""
Meta description checks — two-layer design:
Layer 1 (script): mechanical checks
- Presence
- Length: recommended 120-160 chars
- Keyword match: full / partial / none / unverified
Layer 2 (LLM, always required when content is present):
Script cannot judge writing quality. LLM must evaluate:
- Is it 1-2 complete sentences (not fragments)?
- Does it mention a concrete result, not vague fluff?
Good: "Cut design time by 60% with AI-powered templates"
Bad: "The best tool for all your design needs"
- Does it naturally include the primary keyword or a synonym?
- Is keyword usage natural — not stuffed?
Rule: keyword or close synonym should appear once, not repeated.
- Is it more specific than what a typical competitor would write?
Output fields:
keyword_match : "full" | "partial" | "none" | "unverified"
llm_review_required: always True when content is present
"""
if meta_desc is None:
return {
"status": "fail",
"value": None,
"length": 0,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "No <meta name='description'> found. Missing meta descriptions reduce SERP snippet quality.",
}
if not meta_desc.strip():
return {
"status": "warn",
"value": "",
"length": 0,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "Meta description tag present but content is empty.",
}
length = len(meta_desc)
issues: list[str] = []
notes: list[str] = []
# Length check
if length < 70:
issues.append(f"Length {length} chars — too short (recommended 120-160).")
elif length > 160:
issues.append(f"Length {length} chars — may be truncated in SERPs (recommended <= 160).")
elif length < 120:
issues.append(f"Length {length} chars — slightly short (recommended 120-160).")
else:
notes.append(f"Length {length} chars — within recommended range (120-160).")
# Keyword match check
if keyword:
desc_lower = meta_desc.lower()
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower in desc_lower
partial_match = not full_match and any(w in desc_lower for w in kw_words)
if full_match:
keyword_match = "full"
notes.append(f'Keyword "{keyword}" present in meta description.')
elif partial_match:
keyword_match = "partial"
issues.append(
f'Partial match for "{keyword}" in meta description. '
"LLM review required: check if a synonym covers the intent naturally."
)
else:
keyword_match = "none"
issues.append(
f'Keyword "{keyword}" not found in meta description. '
"Include the primary keyword or a natural synonym once."
)
else:
keyword_match = "unverified"
notes.append("No --keyword provided. Keyword presence not checked.")
# Quality judgment is always LLM-only — script always sets this flag when content exists
llm_review_required = True
notes.append(
"LLM review required: (1) complete sentence? (2) mentions concrete result not vague fluff? "
"(3) keyword used naturally, not stuffed? (4) more specific than a typical competitor?"
)
status = "warn" if issues else "pass"
detail_parts = issues + notes
return {
"status": status,
"value": meta_desc,
"length": length,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": " | ".join(detail_parts),
}
def _check_canonical(canonical: Optional[str], final_url: str) -> dict:
"""Canonical tag check: presence and whether it points to the correct URL."""
if not canonical:
return {
"status": "warn",
"value": None,
"matches_final_url": False,
"detail": (
"No <link rel='canonical'> found. "
"Without a canonical tag, duplicate content issues may arise."
),
}
# Normalize comparison by stripping trailing slashes
canonical_norm = canonical.rstrip("/")
final_norm = final_url.rstrip("/")
matches = canonical_norm == final_norm
if matches:
return {
"status": "pass",
"value": canonical,
"matches_final_url": True,
"detail": "Self-referencing canonical present.",
}
# Canonical points to a different URL — may be cross-domain canonical or misconfiguration
return {
"status": "warn",
"value": canonical,
"matches_final_url": False,
"detail": (
f"Canonical points to a different URL: {canonical}. "
f"Final page URL is: {final_url}. "
"Verify this is intentional (cross-domain canonical) and not a misconfiguration."
),
}
def _check_url_slug(url: str, keyword: Optional[str] = None) -> dict:
"""
URL slug checks — two-layer design:
Layer 1 (script): mechanical checks
- Homepage detection: skip check if path is "/" or empty
- Lowercase only (no uppercase letters)
- Hyphens as word separator (not underscores or spaces)
- No special characters (only a-z, 0-9, hyphens, slashes)
- Stop word presence: a, the, and, of, or, in, on, at, to, for, with, by
- Repeated slug words (keyword stuffing signal)
- Segment length: warn if any segment > 60 chars
Layer 2 (LLM, triggered by llm_review_required=True):
- Does the slug contain the primary keyword or a natural variant?
- Does the path hierarchy make sense? (/category/primary-keyword)
- Is it human-readable and concise?
Best practice: /category/primary-keyword — hierarchical, short, no stop words.
Output fields:
slug : extracted path (e.g. "/blog/best-running-shoes")
is_homepage : True if path is "/" or "" — check skipped
keyword_match : "full" | "partial" | "none" | "unverified"
llm_review_required: True when keyword or readability judgment is needed
"""
parsed = urlparse(url)
path = parsed.path.rstrip("/") or "/"
# Homepage — no slug to check
if path in ("/", ""):
return {
"status": "pass",
"slug": "/",
"is_homepage": True,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "Homepage detected — URL slug check not applicable.",
}
slug = path
segments = [s for s in path.split("/") if s]
issues: list[str] = []
notes: list[str] = []
# Lowercase check
if slug != slug.lower():
issues.append(
f'Slug contains uppercase letters: "{slug}". '
"Use all-lowercase for canonical URL consistency."
)
slug_lower = slug.lower()
# Underscore check (hyphens preferred)
if "_" in slug_lower:
issues.append(
"Underscores found in slug — use hyphens instead. "
"Google treats hyphens as word separators; underscores join words."
)
# Special characters (allow only a-z, 0-9, hyphens, slashes)
import re as _re
if _re.search(r"[^a-z0-9\-/]", slug_lower):
issues.append(
"Slug contains special characters (spaces, %, ?, etc.). "
"Use only lowercase letters, numbers, and hyphens."
)
# Stop words
slug_stop_words = {"a", "the", "and", "of", "or", "in", "on", "at", "to", "for", "with", "by", "from"}
all_slug_words: list[str] = []
for seg in segments:
all_slug_words.extend(seg.split("-"))
found_stop = [w for w in all_slug_words if w in slug_stop_words]
if found_stop:
issues.append(
f"Stop words in slug: {found_stop}. "
"Remove unnecessary stop words (a, the, and, of…) to keep the slug concise."
)
# Repeated words (stuffing signal)
word_counts: dict[str, int] = {}
for w in all_slug_words:
if len(w) > 2:
word_counts[w] = word_counts.get(w, 0) + 1
repeated = [w for w, c in word_counts.items() if c > 1]
if repeated:
issues.append(
f"Repeated words in slug: {repeated}. "
"Each meaningful word should appear once — avoid keyword stuffing in URLs."
)
# Segment length
long_segments = [s for s in segments if len(s) > 60]
if long_segments:
issues.append(
f"Slug segment(s) too long (> 60 chars): {long_segments}. "
"Keep each path segment short and focused."
)
# Keyword match
llm_review_required = False
if keyword:
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower.replace(" ", "-") in slug_lower or kw_lower in slug_lower
partial_match = not full_match and any(w in slug_lower for w in kw_words)
if full_match:
keyword_match = "full"
notes.append(f'Primary keyword "{keyword}" found in slug.')
elif partial_match:
keyword_match = "partial"
llm_review_required = True
issues.append(
f'Partial match for "{keyword}" in slug: "{slug}". '
"LLM review required: does the slug reflect the keyword's primary intent? "
"Best practice: /category/primary-keyword."
)
else:
keyword_match = "none"
llm_review_required = True
issues.append(
f'Primary keyword "{keyword}" not found in slug: "{slug}". '
"Include the primary keyword in the slug. "
"Recommended structure: /category/primary-keyword."
)
else:
keyword_match = "unverified"
llm_review_required = True
notes.append(
"No --keyword provided. Keyword presence in slug not checked. "
"LLM review required: verify slug contains the primary keyword and follows "
"/category/primary-keyword hierarchy."
)
status = "warn" if issues else "pass"
detail_parts = issues + notes
return {
"status": status,
"slug": slug,
"is_homepage": False,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": " | ".join(detail_parts),
}
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Run page-level SEO checks (H1, title, meta description, canonical) and output JSON."
)
parser.add_argument("url", help="Target page URL")
parser.add_argument("--timeout", "-t", type=int, default=20, help="Request timeout in seconds")
parser.add_argument("--keyword", "-k", help="Primary keyword to verify in H1 and title (optional)")
args = parser.parse_args()
url = args.url
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
status_code, content, final_url, redirect_chain, error = _fetch(url, args.timeout)
base_result: dict = {
"url": url,
"final_url": final_url,
"http_status": status_code,
"redirect_chain": redirect_chain,
}
if error:
base_result["error"] = error
print(json.dumps(base_result, indent=2, ensure_ascii=False))
sys.exit(1)
if status_code != 200:
base_result["error"] = f"Page returned HTTP {status_code} — cannot perform on-page checks."
print(json.dumps(base_result, indent=2, ensure_ascii=False))
sys.exit(1)
if not content:
base_result["error"] = "Page returned empty body."
print(json.dumps(base_result, indent=2, ensure_ascii=False))
sys.exit(1)
# Parse HTML and run all checks
seo_parser = _SEOParser()
seo_parser.feed(content)
output = {
**base_result,
"url_slug": _check_url_slug(final_url, keyword=args.keyword),
"title": _check_title(seo_parser.title, keyword=args.keyword),
"meta_description": _check_meta_description(seo_parser.meta_description, keyword=args.keyword),
"h1": _check_h1(seo_parser.h1_values, keyword=args.keyword),
"canonical": _check_canonical(seo_parser.canonical, final_url),
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# Exit with code 1 if any check is fail
has_failure = any(
output[key]["status"] == "fail"
for key in ("url_slug", "title", "meta_description", "h1", "canonical")
)
sys.exit(1 if has_failure else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Adapted from JeffLi1993/seo-audit-skill (https://github.com/JeffLi1993/seo-audit-skill)
# Original license: see https://github.com/JeffLi1993/seo-audit-skill/blob/HEAD/LICENSE
# Pulled into gingiris-skills @ 2026-05-07 for gr-seo-patrol single-page audit capability.
"""
JSON-LD structured data validator for SEO auditing.
Extracts <script type="application/ld+json"> blocks, validates @type and
required fields per Schema.org spec. Page type inference is heuristic —
always sets llm_review_required for agent confirmation.
Usage:
python scripts/check-schema.py https://example.com
python scripts/check-schema.py --file page.html
Output: JSON with found schemas, validation results, and LLM review flags.
Dependencies:
pip install requests
"""
import argparse
import ipaddress
import json
import socket
import sys
from html.parser import HTMLParser
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print(
"Error: requests library required. Install with: pip install requests",
file=sys.stderr,
)
sys.exit(1)
_DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ClaudeSEO/1.2"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
}
# 各 @type 必填字段(缺失 → fail)
REQUIRED_FIELDS: dict[str, list[str]] = {
"WebSite": ["name", "url"],
"Organization": ["name", "url", "logo"],
"Article": ["headline", "datePublished", "author", "image"],
"BlogPosting": ["headline", "datePublished", "author", "image"],
"NewsArticle": ["headline", "datePublished", "author", "image"],
"Product": ["name", "image", "offers"],
"FAQPage": ["mainEntity"],
"HowTo": ["name", "step"],
"LocalBusiness": ["name", "address", "telephone"],
}
# 推荐字段(缺失 → warn)
RECOMMENDED_FIELDS: dict[str, list[str]] = {
"Article": ["dateModified", "publisher"],
"BlogPosting": ["dateModified", "publisher"],
"NewsArticle": ["dateModified", "publisher"],
"Product": ["aggregateRating", "brand", "description"],
"Organization": ["sameAs", "contactPoint"],
"WebSite": ["potentialAction"],
}
# 嵌套字段要求:父字段 → 子字段列表
NESTED_REQUIREMENTS: dict[str, dict[str, list[str]]] = {
"Product": {"offers": ["price", "priceCurrency"]},
"FAQPage": {"mainEntity": ["name", "acceptedAnswer"]},
"HowTo": {"step": ["text"]},
}
# 页面类型 → 期望 @type 映射
PAGE_TYPE_EXPECTED: dict[str, list[str]] = {
"homepage": ["WebSite", "Organization"],
"article": ["Article", "BlogPosting", "NewsArticle"],
"product": ["Product"],
"faq": ["FAQPage"],
"howto": ["HowTo"],
"local_business": ["LocalBusiness"],
}
# ── HTTP fetch ────────────────────────────────────────────────────────────────
def _safe_fetch(
url: str, timeout: int
) -> tuple[Optional[int], Optional[str], Optional[str]]:
"""Fetch URL with SSRF protection. Returns (status, content, error)."""
parsed = urlparse(url)
try:
hostname = parsed.hostname or ""
resolved_ip = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback or ip.is_reserved:
return None, None, f"Blocked: resolves to private IP ({resolved_ip})"
except (socket.gaierror, ValueError):
pass
try:
resp = requests.get(
url, headers=_DEFAULT_HEADERS, timeout=timeout, allow_redirects=True
)
return resp.status_code, resp.text, None
except requests.exceptions.RequestException as exc:
return None, None, str(exc)
# ── HTML parser ───────────────────────────────────────────────────────────────
class _JsonLdExtractor(HTMLParser):
"""Single-pass extractor for <script type="application/ld+json"> blocks."""
def __init__(self) -> None:
super().__init__()
self.blocks: list[str] = []
self._in_jsonld = False
self._buf = ""
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
if tag == "script":
attrs_dict = {k.lower(): (v or "") for k, v in attrs}
if attrs_dict.get("type", "").lower() == "application/ld+json":
self._in_jsonld = True
self._buf = ""
def handle_endtag(self, tag: str) -> None:
if tag == "script" and self._in_jsonld:
self._in_jsonld = False
content = self._buf.strip()
if content:
self.blocks.append(content)
def handle_data(self, data: str) -> None:
if self._in_jsonld:
self._buf += data
# ── Schema helpers ────────────────────────────────────────────────────────────
def _flatten_schemas(raw_blocks: list[str]) -> list[dict]:
"""Parse JSON-LD blocks and flatten @graph arrays into individual schemas."""
schemas: list[dict] = []
for text in raw_blocks:
try:
parsed = json.loads(text)
except json.JSONDecodeError:
continue
if isinstance(parsed, list):
schemas.extend(item for item in parsed if isinstance(item, dict))
elif isinstance(parsed, dict):
graph = parsed.get("@graph")
if isinstance(graph, list):
schemas.extend(item for item in graph if isinstance(item, dict))
else:
schemas.append(parsed)
return schemas
def _get_types(schema: dict) -> list[str]:
"""Extract @type as a normalized list."""
raw = schema.get("@type")
if isinstance(raw, str):
return [raw]
if isinstance(raw, list):
return [t for t in raw if isinstance(t, str)]
return []
def _field_present(schema: dict, field: str) -> bool:
"""Check if a field exists and is non-empty."""
value = schema.get(field)
if value is None:
return False
if isinstance(value, str) and not value.strip():
return False
if isinstance(value, list) and len(value) == 0:
return False
return True
def _validate_nested(schema: dict, schema_type: str) -> list[str]:
"""Validate nested field requirements, return list of missing dotted paths."""
issues: list[str] = []
nested_reqs = NESTED_REQUIREMENTS.get(schema_type, {})
for parent_field, sub_fields in nested_reqs.items():
parent = schema.get(parent_field)
if parent is None:
continue
items = parent if isinstance(parent, list) else [parent]
if not items:
continue
first = items[0]
if not isinstance(first, dict):
continue
for sf in sub_fields:
if not _field_present(first, sf):
issues.append(f"{parent_field}.{sf}")
return issues
def _validate_schema(schema: dict) -> dict:
"""Validate a single schema against required and recommended fields."""
types = _get_types(schema)
if not types:
return {
"types": [],
"status": "warn",
"fields_present": [],
"fields_missing": [],
"recommended_missing": [],
"nested_issues": [],
"detail": "JSON-LD block found but missing @type.",
}
primary = types[0]
required = REQUIRED_FIELDS.get(primary, [])
recommended = RECOMMENDED_FIELDS.get(primary, [])
present = [f for f in required if _field_present(schema, f)]
missing = [f for f in required if not _field_present(schema, f)]
rec_missing = [f for f in recommended if not _field_present(schema, f)]
nested = _validate_nested(schema, primary)
if primary not in REQUIRED_FIELDS:
return {
"types": types,
"status": "info",
"fields_present": present,
"fields_missing": missing,
"recommended_missing": rec_missing,
"nested_issues": nested,
"detail": f"{primary} — no required-field ruleset defined.",
}
if missing:
detail = f"{primary}: missing required: {', '.join(missing)}."
elif rec_missing or nested:
parts = []
if rec_missing:
parts.append(f"missing recommended: {', '.join(rec_missing)}")
if nested:
parts.append(f"incomplete nested: {', '.join(nested)}")
detail = f"{primary}: {'; '.join(parts)}."
else:
detail = f"{primary}: all required fields present."
status = "fail" if missing else ("warn" if (rec_missing or nested) else "pass")
return {
"types": types,
"status": status,
"fields_present": present,
"fields_missing": missing,
"recommended_missing": rec_missing,
"nested_issues": nested,
"detail": detail,
}
def _infer_page_type(url: str) -> str:
"""Heuristic page type guess from URL pattern. LLM should confirm."""
path = urlparse(url).path.lower().rstrip("/")
if path in ("", "/"):
return "homepage"
if any(s in path for s in ("/blog", "/article", "/post", "/news", "/story")):
return "article"
if any(s in path for s in ("/product", "/item", "/shop/", "/store/")):
return "product"
if "/faq" in path or "/questions" in path:
return "faq"
if any(s in path for s in ("/how-to", "/howto", "/guide")):
return "howto"
return "unknown"
# ── Main check ────────────────────────────────────────────────────────────────
def check_schema(html: str, url: str = "") -> dict:
"""Extract, parse, and validate JSON-LD from HTML content."""
extractor = _JsonLdExtractor()
try:
extractor.feed(html)
except Exception:
return {
"url": url,
"status": "error",
"schemas": [],
"detail": "Failed to parse HTML for JSON-LD extraction.",
"llm_review_required": False,
}
inferred = _infer_page_type(url)
expected = PAGE_TYPE_EXPECTED.get(inferred, [])
if not extractor.blocks:
if inferred == "unknown" or not expected:
return {
"url": url,
"status": "info",
"schemas": [],
"inferred_page_type": inferred,
"expected_types": expected,
"detail": "No JSON-LD found. Page type unclear — may not require structured data.",
"llm_review_required": True,
}
return {
"url": url,
"status": "fail",
"schemas": [],
"inferred_page_type": inferred,
"expected_types": expected,
"detail": (
f"No JSON-LD found. Inferred page type: {inferred} — "
f"expected: {', '.join(expected)}."
),
"llm_review_required": True,
}
all_schemas = _flatten_schemas(extractor.blocks)
if not all_schemas:
return {
"url": url,
"status": "warn",
"schemas": [],
"detail": "JSON-LD script tags found but none contained valid JSON.",
"llm_review_required": False,
}
validated = [_validate_schema(s) for s in all_schemas]
found_types = list({t for v in validated for t in v["types"][:1] if t})
statuses = [v["status"] for v in validated]
overall = "fail" if "fail" in statuses else ("warn" if "warn" in statuses else "pass")
has_expected = bool(expected) and any(t in found_types for t in expected)
has_conflicts = len(found_types) > 2
detail_parts = [f"Found {len(all_schemas)} JSON-LD block(s): {', '.join(found_types)}."]
if has_conflicts:
detail_parts.append(f"Potential type conflict: {found_types}.")
for v in validated:
if v["status"] in ("fail", "warn"):
detail_parts.append(v["detail"])
return {
"url": url,
"status": overall,
"schemas": validated,
"found_types": found_types,
"inferred_page_type": inferred,
"expected_types": expected,
"has_expected_type": has_expected,
"has_type_conflicts": has_conflicts,
"detail": " ".join(detail_parts),
"llm_review_required": True,
}
# ── CLI entry point ──────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Validate JSON-LD structured data on a page and output JSON."
)
parser.add_argument("url", nargs="?", help="Target page URL")
parser.add_argument(
"--file", "-f", help="Local HTML file path (skip HTTP fetch)"
)
parser.add_argument(
"--timeout", "-t", type=int, default=20, help="Request timeout in seconds"
)
args = parser.parse_args()
if not args.url and not args.file:
parser.error("Provide a URL or --file <path>")
if args.file:
try:
with open(args.file, "r", encoding="utf-8") as f:
html = f.read()
except OSError as exc:
print(json.dumps({"error": str(exc)}, indent=2))
sys.exit(1)
url = args.file
else:
url = args.url or ""
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
status_code, html, error = _safe_fetch(url, args.timeout)
if error or not html:
err_msg = error or f"HTTP {status_code}"
print(json.dumps({"url": url, "status": "error", "error": err_msg}, indent=2))
sys.exit(1)
result = check_schema(html, url=url)
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.exit(1 if result["status"] == "fail" else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Adapted from JeffLi1993/seo-audit-skill (https://github.com/JeffLi1993/seo-audit-skill)
# Original license: see https://github.com/JeffLi1993/seo-audit-skill/blob/HEAD/LICENSE
# Pulled into gingiris-skills @ 2026-05-07 for gr-seo-patrol single-page audit capability.
"""
Site-level SEO checks: robots.txt and sitemap.xml.
Outputs structured JSON to stdout so the agent can consume results directly
without needing to interpret raw HTTP responses or parse formats manually.
Usage:
python check-site.py https://example.com
python check-site.py https://example.com --timeout 15
Output example (JSON):
{
"origin": "https://example.com",
"robots": {
"status": "pass",
"http_status": 200,
"disallow_all": false,
"googlebot_blocked": false,
"sitemap_directive": "https://example.com/sitemap.xml",
"detail": "robots.txt found. No critical blocking rules detected."
},
"sitemap": {
"status": "pass",
"http_status": 200,
"url_count": 42,
"is_index": false,
"detail": "sitemap.xml found with 42 URLs."
}
}
Dependencies:
pip install requests
"""
import argparse
import ipaddress
import json
import re
import socket
import sys
import xml.etree.ElementTree as ET
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# Same UA as fetch-page.py for consistent request fingerprinting
_DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ClaudeSEO/1.2"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
}
def _safe_fetch(url: str, timeout: int) -> tuple[Optional[int], Optional[str], Optional[str]]:
"""
Internal helper: fetch a URL safely with SSRF protection.
Returns (status_code, content, error_message).
"""
parsed = urlparse(url)
# SSRF protection: block private, loopback, and reserved IPs
try:
hostname = parsed.hostname or ""
resolved_ip = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback or ip.is_reserved:
return None, None, f"Blocked: resolves to private IP ({resolved_ip})"
except (socket.gaierror, ValueError):
pass
try:
resp = requests.get(url, headers=_DEFAULT_HEADERS, timeout=timeout, allow_redirects=True)
return resp.status_code, resp.text, None
except requests.exceptions.Timeout:
return None, None, f"Timed out after {timeout}s"
except requests.exceptions.SSLError as e:
return None, None, f"SSL error: {e}"
except requests.exceptions.ConnectionError as e:
return None, None, f"Connection error: {e}"
except requests.exceptions.RequestException as e:
return None, None, f"Request failed: {e}"
def _parse_robots_groups(content: str) -> tuple[list[dict], list[str]]:
"""
Parse robots.txt into directive groups per RFC 9309.
Consecutive User-Agent lines merge into one group.
Returns (groups, sitemap_directives).
"""
groups: list[dict] = []
current_group: Optional[dict] = None
prev_key = ""
sitemap_directives: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip().lower()
value = value.strip()
if key == "user-agent":
if prev_key == "user-agent" and current_group is not None:
current_group["agents"].append(value.lower())
else:
current_group = {
"agents": [value.lower()],
"allow": [],
"disallow": [],
}
groups.append(current_group)
prev_key = "user-agent"
elif key in ("allow", "disallow") and current_group is not None:
if value:
current_group[key].append(value)
prev_key = key
elif key == "sitemap":
if value:
sitemap_directives.append(value)
prev_key = key
else:
prev_key = key
return groups, sitemap_directives
def _group_blocks_all(group: dict) -> bool:
"""
Check if a group fully blocks the site.
Disallow: / alone blocks everything, but Allow: sub-paths means partial access.
Per RFC 9309, the most specific (longest) matching rule wins.
"""
has_root_block = any(rule in ("/", "/*") for rule in group.get("disallow", []))
if not has_root_block:
return False
# Allow rules for sub-paths override the root block → not a full block
has_allow = any(r and r != "/" for r in group.get("allow", []))
return not has_allow
def _find_group_for_agent(groups: list[dict], agent: str) -> Optional[dict]:
"""Find the group matching a specific user agent name."""
agent_lower = agent.lower()
for group in groups:
if agent_lower in group["agents"]:
return group
return None
def check_robots(origin: str, timeout: int) -> dict:
"""
Check robots.txt with RFC 9309-compliant group parsing.
Handles Allow overrides, multi-agent groups, and multiple Sitemap directives.
"""
url = f"{origin}/robots.txt"
status_code, content, error = _safe_fetch(url, timeout)
result: dict = {
"status": "error",
"http_status": status_code,
"disallow_all": False,
"googlebot_blocked": False,
"sitemap_directives": [],
"sitemap_directive": None,
"detail": "",
}
if error:
result["detail"] = f"Fetch error: {error}"
return result
if status_code != 200:
result["status"] = "fail"
result["detail"] = f"robots.txt returned HTTP {status_code}. File may not exist or is inaccessible."
return result
if not content:
result["status"] = "warn"
result["detail"] = "robots.txt returned HTTP 200 but body is empty."
return result
groups, sitemap_directives = _parse_robots_groups(content)
result["sitemap_directives"] = sitemap_directives
result["sitemap_directive"] = sitemap_directives[0] if sitemap_directives else None
# Evaluate blocking: Googlebot uses its own group if present, else inherits *
wildcard_group = _find_group_for_agent(groups, "*")
wildcard_blocked = _group_blocks_all(wildcard_group) if wildcard_group else False
googlebot_group = _find_group_for_agent(groups, "googlebot")
if googlebot_group:
googlebot_blocked = _group_blocks_all(googlebot_group)
else:
googlebot_blocked = wildcard_blocked
result["disallow_all"] = wildcard_blocked or googlebot_blocked
result["googlebot_blocked"] = googlebot_blocked
issues: list[str] = []
if googlebot_blocked and wildcard_blocked:
issues.append("Disallow: / blocks all crawlers including Googlebot.")
elif googlebot_blocked:
issues.append("Googlebot is explicitly blocked by its own Disallow: / rule.")
elif wildcard_blocked:
issues.append(
"Disallow: / for User-Agent: * blocks most crawlers "
"(Googlebot not specifically overridden)."
)
elif wildcard_group and any(d in ("/", "/*") for d in wildcard_group.get("disallow", [])):
# Disallow: / exists but Allow rules partially override it
allow_paths = [r for r in wildcard_group.get("allow", []) if r and r != "/"]
if allow_paths:
paths_str = ", ".join(allow_paths[:5])
issues.append(
f"Disallow: / present but partially overridden by Allow: {paths_str}. "
"Site is not fully blocked."
)
if not sitemap_directives:
issues.append("No Sitemap: directive found in robots.txt.")
if googlebot_blocked:
result["status"] = "fail"
elif issues:
result["status"] = "warn"
else:
result["status"] = "pass"
if issues:
result["detail"] = " ".join(issues)
else:
sitemap_note = (
f" Sitemap: {', '.join(sitemap_directives)}."
if sitemap_directives else ""
)
result["detail"] = f"robots.txt found. No critical blocking rules detected.{sitemap_note}"
return result
def _parse_sitemap_xml(content: str, source_url: str) -> dict:
"""Parse and validate a single sitemap XML document."""
try:
root = ET.fromstring(content)
except ET.ParseError as e:
return {"status": "fail", "detail": f"Sitemap at {source_url} is not valid XML: {e}"}
tag = re.sub(r"\{.*?\}", "", root.tag).lower()
if tag == "sitemapindex":
child_count = sum(
1 for child in root
if re.sub(r"\{.*?\}", "", child.tag).lower() == "sitemap"
)
return {
"status": "pass" if child_count > 0 else "warn",
"is_index": True,
"url_count": child_count,
"detail": (
f"Sitemap index at {source_url} with {child_count} child sitemap(s)."
if child_count > 0
else f"Sitemap index at {source_url} contains no child sitemaps."
),
}
if tag == "urlset":
url_count = sum(
1 for child in root
if re.sub(r"\{.*?\}", "", child.tag).lower() == "url"
)
return {
"status": "pass" if url_count > 0 else "warn",
"is_index": False,
"url_count": url_count,
"detail": (
f"Sitemap at {source_url} with {url_count} URL(s)."
if url_count > 0
else f"Sitemap at {source_url} contains no <url> entries."
),
}
return {"status": "warn", "detail": f"Sitemap at {source_url} has unexpected root element: <{tag}>."}
def check_sitemap(
origin: str, timeout: int, sitemap_urls: Optional[list[str]] = None
) -> dict:
"""
Check sitemap for accessibility and valid XML structure.
Tries URLs declared in robots.txt first, then falls back to {origin}/sitemap.xml.
"""
candidates: list[str] = list(sitemap_urls) if sitemap_urls else []
default_url = f"{origin}/sitemap.xml"
if default_url not in candidates:
candidates.append(default_url)
result: dict = {
"status": "error",
"http_status": None,
"url_count": 0,
"is_index": False,
"checked_url": None,
"detail": "",
}
for candidate in candidates:
status_code, content, error = _safe_fetch(candidate, timeout)
result["checked_url"] = candidate
result["http_status"] = status_code
if error or status_code == 404 or status_code is None:
continue
if status_code != 200:
result["status"] = "warn"
result["detail"] = f"Sitemap at {candidate} returned HTTP {status_code}."
return result
if not content:
continue
parsed = _parse_sitemap_xml(content, candidate)
result["status"] = parsed["status"]
result["is_index"] = parsed.get("is_index", False)
result["url_count"] = parsed.get("url_count", 0)
result["detail"] = parsed["detail"]
return result
# None of the candidates were accessible
checked = ", ".join(candidates)
result["status"] = "fail"
result["detail"] = (
f"No accessible sitemap found. Checked: {checked}. "
"Ensure a valid XML sitemap exists and is referenced in robots.txt."
)
return result
def normalize_origin(url: str) -> str:
"""Extract the origin (scheme + host) from a URL for constructing robots.txt and sitemap paths."""
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}"
def main() -> None:
parser = argparse.ArgumentParser(
description="Run site-level SEO checks (robots.txt + sitemap.xml) and output JSON."
)
parser.add_argument("url", help="Target URL or domain (e.g. https://example.com)")
parser.add_argument("--timeout", "-t", type=int, default=15, help="Request timeout in seconds")
args = parser.parse_args()
origin = normalize_origin(args.url)
robots_result = check_robots(origin, args.timeout)
sitemap_urls = robots_result.get("sitemap_directives", [])
sitemap_result = check_sitemap(origin, args.timeout, sitemap_urls=sitemap_urls)
output = {
"origin": origin,
"robots": robots_result,
"sitemap": sitemap_result,
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# Exit with code 1 if any check is fail or error — useful for CI integration
has_failure = any(
r["status"] in ("fail", "error") for r in [robots_result, sitemap_result]
)
sys.exit(1 if has_failure else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Adapted from JeffLi1993/seo-audit-skill (https://github.com/JeffLi1993/seo-audit-skill)
# Original license: see https://github.com/JeffLi1993/seo-audit-skill/blob/HEAD/LICENSE
# Pulled into gingiris-skills @ 2026-05-07 for gr-seo-patrol single-page audit capability.
"""
OG Tags + Twitter Card 社交标签验证脚本。
从 HTML 中提取 Open Graph 和 Twitter Card meta 标签,
验证关键字段的存在性、长度、一致性。
Usage:
python scripts/check-social.py https://example.com
python scripts/check-social.py --file page.html
Output: JSON — og / twitter_card 两组检查结果 + 整体 status。
Dependencies:
pip install requests
"""
import argparse
import ipaddress
import json
import socket
import sys
from html.parser import HTMLParser
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print(
"Error: requests library required. Install with: pip install requests",
file=sys.stderr,
)
sys.exit(1)
_DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ClaudeSEO/1.2"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
}
# OG 字段长度阈值
_OG_TITLE_MAX = 95
_OG_DESC_MAX = 200
# Twitter 字段长度阈值
_TW_TITLE_MAX = 70
_TW_DESC_MAX = 200
# 合法的 twitter:card 类型
_VALID_CARD_TYPES = frozenset({
"summary", "summary_large_image", "app", "player",
})
# ── HTTP fetch ────────────────────────────────────────────────────────────────
def _safe_fetch(
url: str, timeout: int
) -> tuple[Optional[int], Optional[str], Optional[str]]:
"""带 SSRF 防护的 HTTP 请求,返回 (status, content, error)。"""
parsed = urlparse(url)
try:
hostname = parsed.hostname or ""
resolved_ip = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback or ip.is_reserved:
return None, None, f"Blocked: resolves to private IP ({resolved_ip})"
except (socket.gaierror, ValueError):
pass
try:
resp = requests.get(
url, headers=_DEFAULT_HEADERS, timeout=timeout, allow_redirects=True
)
return resp.status_code, resp.text, None
except requests.exceptions.RequestException as exc:
return None, None, str(exc)
# ── HTML parser ───────────────────────────────────────────────────────────────
class _MetaExtractor(HTMLParser):
"""单次遍历提取 <meta> 标签中的 OG / Twitter / canonical 信息。"""
def __init__(self) -> None:
super().__init__()
self.og: dict[str, str] = {}
self.twitter: dict[str, str] = {}
self.canonical: Optional[str] = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
if tag == "meta":
attrs_dict = {k.lower(): (v or "") for k, v in attrs}
prop = attrs_dict.get("property", "").lower()
name = attrs_dict.get("name", "").lower()
content = attrs_dict.get("content", "")
# OG 标签: <meta property="og:xxx" content="...">
if prop.startswith("og:"):
key = prop[3:] # 去掉 "og:" 前缀
if key and content:
self.og[key] = content
# Twitter 标签: <meta name="twitter:xxx" content="...">
if name.startswith("twitter:"):
key = name[8:] # 去掉 "twitter:" 前缀
if key and content:
self.twitter[key] = content
# Canonical: <link rel="canonical" href="...">
if tag == "link":
attrs_dict = {k.lower(): (v or "") for k, v in attrs}
if attrs_dict.get("rel", "").lower() == "canonical":
href = attrs_dict.get("href", "").strip()
if href:
self.canonical = href
# ── OG 验证 ───────────────────────────────────────────────────────────────────
def _check_og(og: dict[str, str], canonical: Optional[str]) -> dict:
"""验证 Open Graph 标签完整性和质量。"""
issues: list[str] = []
warnings: list[str] = []
fields: dict[str, dict] = {}
# og:title — 必须存在
title = og.get("title", "")
if not title:
issues.append("og:title missing.")
fields["title"] = {"present": False, "status": "fail"}
else:
f: dict = {"present": True, "value": title, "length": len(title), "status": "pass"}
if len(title) > _OG_TITLE_MAX:
f["status"] = "warn"
warnings.append(f"og:title is {len(title)} chars (max {_OG_TITLE_MAX}).")
fields["title"] = f
# og:description — 推荐存在
desc = og.get("description", "")
if not desc:
warnings.append("og:description missing.")
fields["description"] = {"present": False, "status": "warn"}
else:
f = {"present": True, "value": desc, "length": len(desc), "status": "pass"}
if len(desc) > _OG_DESC_MAX:
f["status"] = "warn"
warnings.append(f"og:description is {len(desc)} chars (max {_OG_DESC_MAX}).")
fields["description"] = f
# og:image — 必须存在且为有效 URL
image = og.get("image", "")
if not image:
issues.append("og:image missing — social shares will lack a preview image.")
fields["image"] = {"present": False, "status": "fail"}
else:
f = {"present": True, "value": image, "status": "pass"}
if not image.startswith(("http://", "https://")):
f["status"] = "warn"
warnings.append("og:image is not an absolute URL.")
fields["image"] = f
# og:type — 推荐存在
og_type = og.get("type", "")
if not og_type:
warnings.append("og:type missing (defaults to 'website').")
fields["type"] = {"present": False, "status": "warn"}
else:
fields["type"] = {"present": True, "value": og_type, "status": "pass"}
# og:url — 推荐存在,且应与 canonical 一致
og_url = og.get("url", "")
if not og_url:
warnings.append("og:url missing.")
fields["url"] = {"present": False, "status": "warn"}
else:
f = {"present": True, "value": og_url, "status": "pass"}
if canonical and og_url.rstrip("/") != canonical.rstrip("/"):
f["status"] = "warn"
f["canonical_mismatch"] = True
warnings.append(
f"og:url ({og_url}) differs from canonical ({canonical})."
)
fields["url"] = f
# 综合状态
if issues:
status = "fail"
elif warnings:
status = "warn"
else:
status = "pass"
detail_parts = []
if issues:
detail_parts.extend(issues)
if warnings:
detail_parts.extend(warnings)
if not detail_parts:
detail_parts.append("All OG tags present and valid.")
return {
"status": status,
"fields": fields,
"issues": issues,
"warnings": warnings,
"detail": " ".join(detail_parts),
}
# ── Twitter Card 验证 ─────────────────────────────────────────────────────────
def _check_twitter(
twitter: dict[str, str], og: dict[str, str]
) -> dict:
"""验证 Twitter Card 标签,考虑 OG fallback 机制。"""
issues: list[str] = []
warnings: list[str] = []
fields: dict[str, dict] = {}
# twitter:card — 必须存在
card = twitter.get("card", "")
if not card:
issues.append("twitter:card missing — no Twitter Card will render.")
fields["card"] = {"present": False, "status": "fail"}
else:
f: dict = {"present": True, "value": card, "status": "pass"}
if card not in _VALID_CARD_TYPES:
f["status"] = "warn"
warnings.append(f"twitter:card value '{card}' is non-standard.")
fields["card"] = f
# twitter:title — 可 fallback 到 og:title
tw_title = twitter.get("title", "")
og_title = og.get("title", "")
if tw_title:
f = {"present": True, "value": tw_title, "length": len(tw_title), "status": "pass"}
if len(tw_title) > _TW_TITLE_MAX:
f["status"] = "warn"
warnings.append(f"twitter:title is {len(tw_title)} chars (max {_TW_TITLE_MAX}).")
fields["title"] = f
elif og_title:
fields["title"] = {
"present": False, "fallback": "og:title",
"fallback_value": og_title, "status": "pass",
}
else:
warnings.append("twitter:title missing and no og:title fallback.")
fields["title"] = {"present": False, "status": "warn"}
# twitter:description — 可 fallback 到 og:description
tw_desc = twitter.get("description", "")
og_desc = og.get("description", "")
if tw_desc:
f = {"present": True, "value": tw_desc, "length": len(tw_desc), "status": "pass"}
if len(tw_desc) > _TW_DESC_MAX:
f["status"] = "warn"
warnings.append(
f"twitter:description is {len(tw_desc)} chars (max {_TW_DESC_MAX})."
)
fields["description"] = f
elif og_desc:
fields["description"] = {
"present": False, "fallback": "og:description",
"fallback_value": og_desc, "status": "pass",
}
else:
warnings.append("twitter:description missing and no og:description fallback.")
fields["description"] = {"present": False, "status": "warn"}
# twitter:image — 可 fallback 到 og:image
tw_image = twitter.get("image", "")
og_image = og.get("image", "")
if tw_image:
fields["image"] = {"present": True, "value": tw_image, "status": "pass"}
elif og_image:
fields["image"] = {
"present": False, "fallback": "og:image",
"fallback_value": og_image, "status": "pass",
}
else:
warnings.append("twitter:image missing and no og:image fallback.")
fields["image"] = {"present": False, "status": "warn"}
# 综合状态
if issues:
status = "fail"
elif warnings:
status = "warn"
else:
status = "pass"
detail_parts = []
if issues:
detail_parts.extend(issues)
if warnings:
detail_parts.extend(warnings)
if not detail_parts:
detail_parts.append("Twitter Card valid with all fields present or fallback available.")
return {
"status": status,
"fields": fields,
"issues": issues,
"warnings": warnings,
"detail": " ".join(detail_parts),
}
# ── Main check ────────────────────────────────────────────────────────────────
def check_social(html: str, url: str = "") -> dict:
"""提取并验证 OG + Twitter Card 社交标签。"""
extractor = _MetaExtractor()
try:
extractor.feed(html)
except Exception:
return {
"url": url,
"status": "error",
"og": {},
"twitter_card": {},
"detail": "Failed to parse HTML for meta tag extraction.",
}
og_result = _check_og(extractor.og, extractor.canonical)
tw_result = _check_twitter(extractor.twitter, extractor.og)
# 综合两组检查的最终状态
statuses = [og_result["status"], tw_result["status"]]
if "fail" in statuses:
overall = "fail"
elif "warn" in statuses:
overall = "warn"
else:
overall = "pass"
return {
"url": url,
"status": overall,
"og": og_result,
"twitter_card": tw_result,
"detail": f"OG: {og_result['status']}. Twitter Card: {tw_result['status']}.",
}
# ── CLI entry point ──────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Validate OG Tags + Twitter Card on a page and output JSON."
)
parser.add_argument("url", nargs="?", help="Target page URL")
parser.add_argument(
"--file", "-f", help="Local HTML file path (skip HTTP fetch)"
)
parser.add_argument(
"--timeout", "-t", type=int, default=20, help="Request timeout in seconds"
)
args = parser.parse_args()
if not args.url and not args.file:
parser.error("Provide a URL or --file <path>")
if args.file:
try:
with open(args.file, "r", encoding="utf-8") as f:
html = f.read()
except OSError as exc:
print(json.dumps({"error": str(exc)}, indent=2))
sys.exit(1)
url = args.url or args.file
else:
url = args.url or ""
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
status_code, html, error = _safe_fetch(url, args.timeout)
if error or not html:
err_msg = error or f"HTTP {status_code}"
print(json.dumps({"url": url, "status": "error", "error": err_msg}, indent=2))
sys.exit(1)
result = check_social(html, url=url)
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.exit(1 if result["status"] == "fail" else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Daily SEO/GEO patrol — gingiris-skills / gr-seo-patrol.
Env vars required:
DATAFORSEO_B64 Basic-auth b64 for DataForSEO
GITHUB_TOKEN PAT for repo reads (optional but recommended)
GR_KEYWORDS comma-separated keyword overrides (optional)
GR_SITE site domain (default: gingiris.github.io/growth-tools)
GR_GA4_ID GA4 measurement ID to verify (default: G-QKPQFJD1FH)
"""
import os, sys, json, time, urllib.request, urllib.error
API = os.environ.get("DATAFORSEO_B64")
SITE = os.environ.get("GR_SITE", "gingiris.github.io/growth-tools")
GA4 = os.environ.get("GR_GA4_ID", "G-QKPQFJD1FH")
DEFAULT_KEYWORDS = [
("product hunt launch playbook", 2840, "en"),
("best social media listening tools", 2840, "en"),
("github stars history", 2840, "en"),
("open source marketing", 2840, "en"),
("how to launch on hacker news", 2840, "en"),
("GEO generative engine optimization", 2840, "en"),
]
TARGETS = [f"https://{SITE}/", f"https://{SITE}/en/",
f"https://{SITE}/ja/", f"https://{SITE}/ko/"]
def serp(kw, loc, lang):
if not API:
return {"error": "DATAFORSEO_B64 missing"}
payload = json.dumps([{"keyword": kw, "location_code": loc,
"language_code": lang, "device": "desktop",
"depth": 100}]).encode()
req = urllib.request.Request(
"https://api.dataforseo.com/v3/serp/google/organic/live/advanced",
data=payload,
headers={"Authorization": f"Basic {API}",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=40) as r:
return json.loads(r.read())
except Exception as e:
return {"error": str(e)}
def find_ranks(data, site):
hits = []
try:
for it in data["tasks"][0]["result"][0]["items"]:
if site in (it.get("url") or ""):
hits.append({"rank": it.get("rank_absolute"),
"url": it.get("url"),
"title": (it.get("title") or "")[:80]})
except Exception:
pass
return hits
def ga4_check():
out = []
for u in TARGETS:
try:
req = urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as r:
body = r.read().decode("utf-8", errors="replace")
out.append({"url": u, "status": r.status,
"has_ga4": GA4 in body,
"has_gtag_js": "gtag/js" in body})
except Exception as e:
out.append({"url": u, "error": str(e)})
return out
def indexed_count():
d = serp(f"site:{SITE}", 2840, "en")
try:
res = d["tasks"][0]["result"][0]
return {"items_count": res.get("items_count"),
"se_results_count": res.get("se_results_count")}
except Exception as e:
return {"error": str(e)}
def llms_txt():
try:
req = urllib.request.Request(f"https://{SITE}/llms.txt",
headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as r:
body = r.read().decode("utf-8", errors="replace")
return {"status": r.status, "len": len(body)}
except Exception as e:
return {"error": str(e)}
def main():
from datetime import date
report = {"date": str(date.today()), "site": SITE,
"ga4": ga4_check(),
"llms_txt": llms_txt(),
"indexed": indexed_count(),
"ranks": []}
kws = DEFAULT_KEYWORDS
override = os.environ.get("GR_KEYWORDS")
if override:
kws = [(k.strip(), 2840, "en") for k in override.split(",") if k.strip()]
for kw, loc, lang in kws:
print(f"[serp] {kw}", file=sys.stderr)
d = serp(kw, loc, lang)
report["ranks"].append({"keyword": kw, "hits": find_ranks(d, SITE)})
time.sleep(1)
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
print()
if __name__ == "__main__":
main()
gr-seo-patrol scripts
Original scripts (gingiris-built)
daily-report.py— DataForSEO SERP rank patrol (gingiris.github.io + dev.to/iris1031)canonical-fix.py— Batch canonical URL fix on Jekyll _postsrescue-post.py— Social rescue (title rewrite + internal link injection)
Adopted from JeffLi1993/seo-audit-skill (2026-05-07)
Single-page deep audit capability — the 4 scripts below validate per-page SEO health.
check-page.py— H1, title, meta description, canonical, slug, alt text, keyword placementcheck-schema.py— JSON-LD validation (Article, FAQPage, BreadcrumbList, Organization, etc)check-site.py— robots.txt + sitemap.xml validation, redirect chaincheck-social.py— OG tags + Twitter Card validation
All adapted scripts retain their original license terms. Original repo: https://github.com/JeffLi1993/seo-audit-skill
Usage
# Single-page audit
python3 scripts/check-page.py URL --keyword "primary keyword"
python3 scripts/check-schema.py URL
python3 scripts/check-site.py URL
python3 scripts/check-social.py URLDependencies
pip install requests— for the 4 adopted scripts- gingiris originals (daily-report, canonical-fix, rescue-post) use Python stdlib only
#!/usr/bin/env python3
"""Rescue a SERP-crashed post:
1) Rewrite title so primary keyword is front-loaded
2) Inject 3 internal links from high-authority posts
Usage:
GITHUB_TOKEN=xxx GR_REPO=Gingiris/growth-tools \\
python rescue-post.py \\
--target 2026-04-02-best-social-media-listening-tools.md \\
--url https://gingiris.github.io/growth-tools/blog/2026/04/02/.../ \\
--title "Best Social Listening Tools 2026: Free & Multilingual for Startups" \\
--anchor "best social listening tools for startups" \\
--referrers 2026-03-25-ph-master.md 2026-03-30-reddit.md 2026-04-03-saas.md \\
--apply
"""
import argparse, base64, json, os, re, sys, urllib.request
GH = os.environ.get("GITHUB_TOKEN")
REPO = os.environ.get("GR_REPO", "Gingiris/growth-tools")
def gh(path, method="GET", body=None):
req = urllib.request.Request(
f"https://api.github.com/repos/{REPO}/contents/{path}",
method=method, data=body,
headers={"Authorization": f"token {GH}",
"User-Agent": "gr-seo-patrol",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def rewrite_title(path, new_title, apply):
m = gh(path)
content = base64.b64decode(m["content"]).decode("utf-8")
new = re.sub(r"^title:\s*.*$", f'title: "{new_title}"',
content, count=1, flags=re.MULTILINE)
if new == content:
print(f" [title] no change"); return
if not apply:
print(f" [title dry] would rewrite {path}"); return
payload = json.dumps({
"message": "seo: front-load primary keyword in title",
"content": base64.b64encode(new.encode()).decode(),
"sha": m["sha"]}).encode()
r = gh(path, "PUT", payload)
print(f" [title ok] {r['commit']['sha'][:8]}")
def inject_link(path, url, anchor, apply):
m = gh(path)
body = base64.b64decode(m["content"]).decode("utf-8")
if url in body:
print(f" [link skip] {path} (already linked)"); return
block = (f"\n\n> **Related reading:** [{anchor.capitalize()}]({url}) — "
f"how to monitor brand mentions & competitor buzz without a $500/mo tool.\n")
parts = body.split("\n## ", 2)
if len(parts) >= 3:
new = parts[0] + "\n## " + parts[1] + block + "\n## " + parts[2]
elif len(parts) == 2:
new = parts[0] + "\n## " + parts[1] + block
else:
new = body + block
if not apply:
print(f" [link dry] would link from {path}"); return
payload = json.dumps({
"message": f"seo: internal link (anchor: {anchor})",
"content": base64.b64encode(new.encode()).decode(),
"sha": m["sha"]}).encode()
r = gh(path, "PUT", payload)
print(f" [link ok] {path}: {r['commit']['sha'][:8]}")
def main():
p = argparse.ArgumentParser()
p.add_argument("--target", required=True, help="file under _posts/")
p.add_argument("--url", required=True, help="target post public URL")
p.add_argument("--title", required=True, help="new title")
p.add_argument("--anchor", required=True, help="internal-link anchor text")
p.add_argument("--referrers", nargs="+", required=True,
help="referrer filenames under _posts/")
p.add_argument("--apply", action="store_true")
args = p.parse_args()
if not GH:
sys.exit("GITHUB_TOKEN missing")
tgt = args.target if args.target.startswith("_posts/") else f"_posts/{args.target}"
print(f"[1] title rewrite: {tgt}")
try:
rewrite_title(tgt, args.title, args.apply)
except urllib.error.HTTPError as e:
print(f" [title err] {e.code}")
print(f"[2] internal links from {len(args.referrers)} referrers")
for r in args.referrers:
rp = r if r.startswith("_posts/") else f"_posts/{r}"
try:
inject_link(rp, args.url, args.anchor, args.apply)
except urllib.error.HTTPError as e:
print(f" [link err] {rp}: {e.code}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Batch-update canonical_url in Jekyll _posts to point at a master URL.
Usage:
GITHUB_TOKEN=xxx GR_REPO=Gingiris/growth-tools \\
python canonical-fix.py \\
--master https://gingiris.github.io/growth-tools/blog/2026/03/25/master-post/ \\
--posts 2026-03-18-a.md 2026-03-29-b.md ...
Safety:
- Only touches files under _posts/
- One commit per file (easy to revert)
- Dry-run by default; pass --apply to write
"""
import argparse, base64, json, os, re, sys, urllib.request
GH = os.environ.get("GITHUB_TOKEN")
REPO = os.environ.get("GR_REPO", "Gingiris/growth-tools")
def gh(path, method="GET", body=None):
req = urllib.request.Request(
f"https://api.github.com/repos/{REPO}/contents/{path}",
method=method,
data=body,
headers={"Authorization": f"token {GH}",
"User-Agent": "gr-seo-patrol",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def main():
p = argparse.ArgumentParser()
p.add_argument("--master", required=True)
p.add_argument("--posts", nargs="+", required=True,
help="Filenames inside _posts/ (with or without prefix)")
p.add_argument("--apply", action="store_true")
args = p.parse_args()
if not GH:
sys.exit("GITHUB_TOKEN env var missing")
for name in args.posts:
path = name if name.startswith("_posts/") else f"_posts/{name}"
if not path.endswith(".md"):
print(f"SKIP {path}: not .md"); continue
try:
m = gh(path)
except urllib.error.HTTPError as e:
print(f"SKIP {path}: {e.code}"); continue
content = base64.b64decode(m["content"]).decode("utf-8")
new, n = re.subn(r"^(canonical_url:\s*).*$",
f"\\1{args.master}", content,
count=1, flags=re.MULTILINE)
if n == 0:
print(f"SKIP {path}: no canonical_url line"); continue
if new == content:
print(f"SKIP {path}: already correct"); continue
if not args.apply:
print(f"[dry] would update {path}"); continue
payload = json.dumps({
"message": "fix: canonical -> master",
"content": base64.b64encode(new.encode()).decode(),
"sha": m["sha"]}).encode()
r = gh(path, "PUT", payload)
print(f"OK {path}: {r['commit']['sha'][:8]}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Adapted from JeffLi1993/seo-audit-skill (https://github.com/JeffLi1993/seo-audit-skill)
# Original license: see https://github.com/JeffLi1993/seo-audit-skill/blob/HEAD/LICENSE
# Pulled into gingiris-skills @ 2026-05-07 for gr-seo-patrol single-page audit capability.
"""
Page-level SEO checks: H1, title tag, meta description, and canonical.
Uses Python stdlib html.parser — no BeautifulSoup required.
Outputs structured JSON to stdout for direct agent consumption.
Usage:
python check-page.py https://example.com
python check-page.py https://example.com --timeout 20
Output example (JSON):
{
"url": "https://example.com/",
"final_url": "https://example.com/",
"http_status": 200,
"redirect_chain": [],
"h1": {
"status": "pass",
"count": 1,
"values": ["Best Running Shoes 2025"],
"detail": "Single H1 found."
},
"title": {
"status": "pass",
"value": "Best Running Shoes 2025 | Free Shipping",
"length": 42,
"detail": "Title is 42 characters — within recommended range (50-60)."
},
"meta_description": {
"status": "pass",
"value": "Shop the best running shoes...",
"length": 138,
"detail": "Meta description is 138 characters — within recommended range (120-160)."
},
"canonical": {
"status": "pass",
"value": "https://example.com/",
"matches_final_url": true,
"detail": "Self-referencing canonical present."
}
}
Dependencies:
pip install requests
(HTML parsing uses Python stdlib html.parser — no extra packages needed)
"""
import argparse
import ipaddress
import json
import socket
import sys
from html.parser import HTMLParser
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# ── HTTP fetch ────────────────────────────────────────────────────────────────
_DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ClaudeSEO/1.2"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
}
# 关键词过滤用停用词表(替代长度阈值,避免误杀 SEO/AI/CRM 等短缩写词)
_STOP_WORDS = frozenset({
"a", "an", "the", "and", "or", "but", "not", "no",
"in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
"is", "are", "was", "were", "be",
"it", "its", "this", "that",
})
def _fetch(url: str, timeout: int) -> tuple[Optional[int], Optional[str], str, list[dict], Optional[str]]:
"""
Fetch a page with SSRF protection.
Returns (status_code, content, final_url, redirect_chain, error).
"""
parsed = urlparse(url)
# SSRF protection: block private, loopback, and reserved IPs
try:
hostname = parsed.hostname or ""
resolved_ip = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback or ip.is_reserved:
return None, None, url, [], f"Blocked: resolves to private IP ({resolved_ip})"
except (socket.gaierror, ValueError):
pass
try:
session = requests.Session()
session.max_redirects = 5
resp = session.get(url, headers=_DEFAULT_HEADERS, timeout=timeout, allow_redirects=True)
redirect_chain = [
{"url": r.url, "status_code": r.status_code} for r in resp.history
]
return resp.status_code, resp.text, resp.url, redirect_chain, None
except requests.exceptions.Timeout:
return None, None, url, [], f"Timed out after {timeout}s"
except requests.exceptions.TooManyRedirects:
return None, None, url, [], "Too many redirects (max 5)"
except requests.exceptions.SSLError as e:
return None, None, url, [], f"SSL error: {e}"
except requests.exceptions.ConnectionError as e:
return None, None, url, [], f"Connection error: {e}"
except requests.exceptions.RequestException as e:
return None, None, url, [], f"Request failed: {e}"
# ── HTML parser (stdlib, no external dependencies) ────────────────────────────
class _SEOParser(HTMLParser):
"""
Lightweight SEO element extractor.
Extracts <title>, <h1>, <meta name="description">, and <link rel="canonical">.
Does not build a full DOM tree — single-pass scan only.
"""
def __init__(self) -> None:
super().__init__()
# title state
self.title: Optional[str] = None
self._in_title = False
self._title_buf = ""
# h1 state
self.h1_values: list[str] = []
self._in_h1 = False
self._h1_depth = 0 # tracks nesting depth to handle inline tags inside <h1>
self._h1_buf = ""
# meta description
self.meta_description: Optional[str] = None
# canonical
self.canonical: Optional[str] = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
attrs_dict = {k.lower(): (v or "") for k, v in attrs}
if tag == "title" and self.title is None:
self._in_title = True
self._title_buf = ""
elif tag == "h1":
self._in_h1 = True
self._h1_depth += 1
self._h1_buf = ""
elif tag == "meta":
name = attrs_dict.get("name", "").lower()
if name == "description" and self.meta_description is None:
self.meta_description = attrs_dict.get("content", "")
elif tag == "link":
rel = attrs_dict.get("rel", "").lower()
if "canonical" in rel and self.canonical is None:
self.canonical = attrs_dict.get("href", "")
def handle_endtag(self, tag: str) -> None:
if tag == "title" and self._in_title:
self._in_title = False
self.title = self._title_buf.strip() or None
elif tag == "h1" and self._in_h1:
self._h1_depth -= 1
if self._h1_depth <= 0:
self._in_h1 = False
self._h1_depth = 0
text = self._h1_buf.strip()
if text:
self.h1_values.append(text)
def handle_data(self, data: str) -> None:
if self._in_title:
self._title_buf += data
if self._in_h1:
self._h1_buf += data
# ── Check functions ───────────────────────────────────────────────────────────
def _check_h1(h1_values: list[str], keyword: Optional[str] = None) -> dict:
"""
H1 checks — two-layer design:
Layer 1 (script): mechanical checks
- Uniqueness: exactly one H1
- Non-empty content
- Length: warn if < 5 chars (brand-only) or > 70 chars
- Keyword match: full / partial / none / unverified
Layer 2 (LLM, triggered by llm_review_required=True):
- When keyword_match == "partial": agent must judge semantic intent alignment.
Script cannot determine if "Best Personal AI" covers intent for "AI computer".
Agent reads h1_text + keyword and makes the call.
Output fields:
keyword_match : "full" | "partial" | "none" | "unverified"
llm_review_required: True when keyword_match == "partial" — agent must do semantic review
"""
count = len(h1_values)
if count == 0:
return {
"status": "fail",
"count": 0,
"values": [],
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "No H1 tag found. Every page should have exactly one H1 containing the primary keyword.",
}
if count > 1:
return {
"status": "fail",
"count": count,
"values": h1_values,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": (
f"{count} H1 tags found. Multiple H1s dilute heading hierarchy "
f"and make it harder for crawlers to identify the primary topic. "
f"Keep exactly one H1; include the primary keyword or a natural variant."
),
}
# Exactly one H1 — run content checks
h1_text = h1_values[0]
if not h1_text.strip():
return {
"status": "fail",
"count": 1,
"values": h1_values,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "H1 tag present but content is empty.",
}
length = len(h1_text)
issues: list[str] = []
# Length thresholds
if length < 5:
issues.append(
f'H1 is very short ({length} chars): "{h1_text}". '
"Likely brand-name only — add the primary keyword to signal page topic."
)
elif length > 70:
issues.append(
f"H1 is {length} characters — consider trimming to under 70 for readability."
)
# Keyword match — script does string-level detection only
if keyword:
h1_lower = h1_text.lower()
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower in h1_lower
partial_match = not full_match and any(w in h1_lower for w in kw_words)
if full_match:
keyword_match = "full"
llm_review_required = False
keyword_note = f'Primary keyword "{keyword}" found in H1 (full match).'
elif partial_match:
# Script stops here — agent must decide if this is a valid natural variant
keyword_match = "partial"
llm_review_required = True
keyword_note = (
f'Partial string match for "{keyword}" in H1: "{h1_text}". '
"Script cannot determine semantic intent alignment. "
"LLM review required: does this H1 cover the search intent of the keyword?"
)
issues.append(keyword_note)
else:
keyword_match = "none"
llm_review_required = False
issues.append(
f'Primary keyword "{keyword}" not found in H1: "{h1_text}". '
"Homepage best practice: brand + primary keyword. "
"Other pages: primary keyword or a natural variant."
)
else:
keyword_match = "unverified"
llm_review_required = False
issues.append(
"No --keyword provided. Keyword presence in H1 not checked. "
"Pass --keyword <term> to enable this check."
)
if issues:
return {
"status": "warn",
"count": 1,
"values": h1_values,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": " | ".join(issues),
}
return {
"status": "pass",
"count": 1,
"values": h1_values,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": f'Single H1 found: "{h1_text}". {keyword_note if keyword else ""}'.strip(),
}
def _check_title(title: Optional[str], keyword: Optional[str] = None) -> dict:
"""
Title tag checks — two-layer design:
Layer 1 (script): mechanical checks
- Presence
- Length: recommended 50-60 chars
- Keyword match: full / partial / none / unverified
- Keyword position: "start" (within first 30 chars) / "middle" / "absent"
Layer 2 (LLM, triggered by llm_review_required=True):
- Is the title grammatically correct and naturally readable?
- If keyword_match == "partial": does it semantically cover the intent?
- If keyword_position != "start": should it be moved to the front?
Best practice: lead with primary keyword for highest SEO weight.
Output fields:
keyword_match : "full" | "partial" | "none" | "unverified"
keyword_position : "start" | "middle" | "absent" | "unverified"
llm_review_required: True when quality or semantic judgment is needed
"""
if not title:
return {
"status": "fail",
"value": None,
"length": 0,
"keyword_match": "unverified",
"keyword_position": "unverified",
"llm_review_required": False,
"detail": "No <title> tag found. Title is a critical on-page SEO element.",
}
length = len(title)
issues: list[str] = []
notes: list[str] = []
# Length check
if length < 10:
issues.append(f"Title is only {length} characters — likely a placeholder or too short.")
elif length > 60:
issues.append(f"Title is {length} characters — may be truncated in SERPs (recommended 50-60).")
elif length < 50:
issues.append(f"Title is {length} characters — slightly short (recommended 50-60).")
else:
notes.append(f"Length {length} chars — within recommended range (50-60).")
# Keyword checks
llm_review_required = False
if keyword:
title_lower = title.lower()
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower in title_lower
partial_match = not full_match and any(w in title_lower for w in kw_words)
if full_match:
keyword_match = "full"
# Check position: keyword should appear in first 30 chars (high SEO weight zone)
pos = title_lower.find(kw_lower)
if pos <= 30:
keyword_position = "start"
notes.append(f'Keyword "{keyword}" leads the title — good SEO positioning.')
else:
keyword_position = "middle"
issues.append(
f'Keyword "{keyword}" found at position {pos} — '
"best practice is to lead with the primary keyword (within first 30 chars)."
)
llm_review_required = True
elif partial_match:
keyword_match = "partial"
keyword_position = "middle"
llm_review_required = True
issues.append(
f'Partial match for "{keyword}" in title: "{title}". '
"Script cannot determine semantic intent alignment. "
"LLM review required: does this title cover the keyword's search intent? "
"Is it grammatically natural?"
)
else:
keyword_match = "none"
keyword_position = "absent"
issues.append(
f'Primary keyword "{keyword}" not found in title. '
"Lead with the primary keyword for strongest SEO signal."
)
else:
keyword_match = "unverified"
keyword_position = "unverified"
llm_review_required = True
issues.append(
"No --keyword provided. Keyword presence and position in title not checked. "
"LLM review required: verify title starts with the primary keyword and reads naturally."
)
detail_parts = issues + notes
status = "fail" if length < 10 else ("warn" if issues else "pass")
return {
"status": status,
"value": title,
"length": length,
"keyword_match": keyword_match,
"keyword_position": keyword_position,
"llm_review_required": llm_review_required,
"detail": " | ".join(detail_parts),
}
def _check_meta_description(meta_desc: Optional[str], keyword: Optional[str] = None) -> dict:
"""
Meta description checks — two-layer design:
Layer 1 (script): mechanical checks
- Presence
- Length: recommended 120-160 chars
- Keyword match: full / partial / none / unverified
Layer 2 (LLM, always required when content is present):
Script cannot judge writing quality. LLM must evaluate:
- Is it 1-2 complete sentences (not fragments)?
- Does it mention a concrete result, not vague fluff?
Good: "Cut design time by 60% with AI-powered templates"
Bad: "The best tool for all your design needs"
- Does it naturally include the primary keyword or a synonym?
- Is keyword usage natural — not stuffed?
Rule: keyword or close synonym should appear once, not repeated.
- Is it more specific than what a typical competitor would write?
Output fields:
keyword_match : "full" | "partial" | "none" | "unverified"
llm_review_required: always True when content is present
"""
if meta_desc is None:
return {
"status": "fail",
"value": None,
"length": 0,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "No <meta name='description'> found. Missing meta descriptions reduce SERP snippet quality.",
}
if not meta_desc.strip():
return {
"status": "warn",
"value": "",
"length": 0,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "Meta description tag present but content is empty.",
}
length = len(meta_desc)
issues: list[str] = []
notes: list[str] = []
# Length check
if length < 70:
issues.append(f"Length {length} chars — too short (recommended 120-160).")
elif length > 160:
issues.append(f"Length {length} chars — may be truncated in SERPs (recommended <= 160).")
elif length < 120:
issues.append(f"Length {length} chars — slightly short (recommended 120-160).")
else:
notes.append(f"Length {length} chars — within recommended range (120-160).")
# Keyword match check
if keyword:
desc_lower = meta_desc.lower()
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower in desc_lower
partial_match = not full_match and any(w in desc_lower for w in kw_words)
if full_match:
keyword_match = "full"
notes.append(f'Keyword "{keyword}" present in meta description.')
elif partial_match:
keyword_match = "partial"
issues.append(
f'Partial match for "{keyword}" in meta description. '
"LLM review required: check if a synonym covers the intent naturally."
)
else:
keyword_match = "none"
issues.append(
f'Keyword "{keyword}" not found in meta description. '
"Include the primary keyword or a natural synonym once."
)
else:
keyword_match = "unverified"
notes.append("No --keyword provided. Keyword presence not checked.")
# Quality judgment is always LLM-only — script always sets this flag when content exists
llm_review_required = True
notes.append(
"LLM review required: (1) complete sentence? (2) mentions concrete result not vague fluff? "
"(3) keyword used naturally, not stuffed? (4) more specific than a typical competitor?"
)
status = "warn" if issues else "pass"
detail_parts = issues + notes
return {
"status": status,
"value": meta_desc,
"length": length,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": " | ".join(detail_parts),
}
def _check_canonical(canonical: Optional[str], final_url: str) -> dict:
"""Canonical tag check: presence and whether it points to the correct URL."""
if not canonical:
return {
"status": "warn",
"value": None,
"matches_final_url": False,
"detail": (
"No <link rel='canonical'> found. "
"Without a canonical tag, duplicate content issues may arise."
),
}
# Normalize comparison by stripping trailing slashes
canonical_norm = canonical.rstrip("/")
final_norm = final_url.rstrip("/")
matches = canonical_norm == final_norm
if matches:
return {
"status": "pass",
"value": canonical,
"matches_final_url": True,
"detail": "Self-referencing canonical present.",
}
# Canonical points to a different URL — may be cross-domain canonical or misconfiguration
return {
"status": "warn",
"value": canonical,
"matches_final_url": False,
"detail": (
f"Canonical points to a different URL: {canonical}. "
f"Final page URL is: {final_url}. "
"Verify this is intentional (cross-domain canonical) and not a misconfiguration."
),
}
def _check_url_slug(url: str, keyword: Optional[str] = None) -> dict:
"""
URL slug checks — two-layer design:
Layer 1 (script): mechanical checks
- Homepage detection: skip check if path is "/" or empty
- Lowercase only (no uppercase letters)
- Hyphens as word separator (not underscores or spaces)
- No special characters (only a-z, 0-9, hyphens, slashes)
- Stop word presence: a, the, and, of, or, in, on, at, to, for, with, by
- Repeated slug words (keyword stuffing signal)
- Segment length: warn if any segment > 60 chars
Layer 2 (LLM, triggered by llm_review_required=True):
- Does the slug contain the primary keyword or a natural variant?
- Does the path hierarchy make sense? (/category/primary-keyword)
- Is it human-readable and concise?
Best practice: /category/primary-keyword — hierarchical, short, no stop words.
Output fields:
slug : extracted path (e.g. "/blog/best-running-shoes")
is_homepage : True if path is "/" or "" — check skipped
keyword_match : "full" | "partial" | "none" | "unverified"
llm_review_required: True when keyword or readability judgment is needed
"""
parsed = urlparse(url)
path = parsed.path.rstrip("/") or "/"
# Homepage — no slug to check
if path in ("/", ""):
return {
"status": "pass",
"slug": "/",
"is_homepage": True,
"keyword_match": "unverified",
"llm_review_required": False,
"detail": "Homepage detected — URL slug check not applicable.",
}
slug = path
segments = [s for s in path.split("/") if s]
issues: list[str] = []
notes: list[str] = []
# Lowercase check
if slug != slug.lower():
issues.append(
f'Slug contains uppercase letters: "{slug}". '
"Use all-lowercase for canonical URL consistency."
)
slug_lower = slug.lower()
# Underscore check (hyphens preferred)
if "_" in slug_lower:
issues.append(
"Underscores found in slug — use hyphens instead. "
"Google treats hyphens as word separators; underscores join words."
)
# Special characters (allow only a-z, 0-9, hyphens, slashes)
import re as _re
if _re.search(r"[^a-z0-9\-/]", slug_lower):
issues.append(
"Slug contains special characters (spaces, %, ?, etc.). "
"Use only lowercase letters, numbers, and hyphens."
)
# Stop words
slug_stop_words = {"a", "the", "and", "of", "or", "in", "on", "at", "to", "for", "with", "by", "from"}
all_slug_words: list[str] = []
for seg in segments:
all_slug_words.extend(seg.split("-"))
found_stop = [w for w in all_slug_words if w in slug_stop_words]
if found_stop:
issues.append(
f"Stop words in slug: {found_stop}. "
"Remove unnecessary stop words (a, the, and, of…) to keep the slug concise."
)
# Repeated words (stuffing signal)
word_counts: dict[str, int] = {}
for w in all_slug_words:
if len(w) > 2:
word_counts[w] = word_counts.get(w, 0) + 1
repeated = [w for w, c in word_counts.items() if c > 1]
if repeated:
issues.append(
f"Repeated words in slug: {repeated}. "
"Each meaningful word should appear once — avoid keyword stuffing in URLs."
)
# Segment length
long_segments = [s for s in segments if len(s) > 60]
if long_segments:
issues.append(
f"Slug segment(s) too long (> 60 chars): {long_segments}. "
"Keep each path segment short and focused."
)
# Keyword match
llm_review_required = False
if keyword:
kw_lower = keyword.lower().strip()
kw_words = [w for w in kw_lower.split() if w not in _STOP_WORDS]
full_match = kw_lower.replace(" ", "-") in slug_lower or kw_lower in slug_lower
partial_match = not full_match and any(w in slug_lower for w in kw_words)
if full_match:
keyword_match = "full"
notes.append(f'Primary keyword "{keyword}" found in slug.')
elif partial_match:
keyword_match = "partial"
llm_review_required = True
issues.append(
f'Partial match for "{keyword}" in slug: "{slug}". '
"LLM review required: does the slug reflect the keyword's primary intent? "
"Best practice: /category/primary-keyword."
)
else:
keyword_match = "none"
llm_review_required = True
issues.append(
f'Primary keyword "{keyword}" not found in slug: "{slug}". '
"Include the primary keyword in the slug. "
"Recommended structure: /category/primary-keyword."
)
else:
keyword_match = "unverified"
llm_review_required = True
notes.append(
"No --keyword provided. Keyword presence in slug not checked. "
"LLM review required: verify slug contains the primary keyword and follows "
"/category/primary-keyword hierarchy."
)
status = "warn" if issues else "pass"
detail_parts = issues + notes
return {
"status": status,
"slug": slug,
"is_homepage": False,
"keyword_match": keyword_match,
"llm_review_required": llm_review_required,
"detail": " | ".join(detail_parts),
}
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Run page-level SEO checks (H1, title, meta description, canonical) and output JSON."
)
parser.add_argument("url", help="Target page URL")
parser.add_argument("--timeout", "-t", type=int, default=20, help="Request timeout in seconds")
parser.add_argument("--keyword", "-k", help="Primary keyword to verify in H1 and title (optional)")
args = parser.parse_args()
url = args.url
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
status_code, content, final_url, redirect_chain, error = _fetch(url, args.timeout)
base_result: dict = {
"url": url,
"final_url": final_url,
"http_status": status_code,
"redirect_chain": redirect_chain,
}
if error:
base_result["error"] = error
print(json.dumps(base_result, indent=2, ensure_ascii=False))
sys.exit(1)
if status_code != 200:
base_result["error"] = f"Page returned HTTP {status_code} — cannot perform on-page checks."
print(json.dumps(base_result, indent=2, ensure_ascii=False))
sys.exit(1)
if not content:
base_result["error"] = "Page returned empty body."
print(json.dumps(base_result, indent=2, ensure_ascii=False))
sys.exit(1)
# Parse HTML and run all checks
seo_parser = _SEOParser()
seo_parser.feed(content)
output = {
**base_result,
"url_slug": _check_url_slug(final_url, keyword=args.keyword),
"title": _check_title(seo_parser.title, keyword=args.keyword),
"meta_description": _check_meta_description(seo_parser.meta_description, keyword=args.keyword),
"h1": _check_h1(seo_parser.h1_values, keyword=args.keyword),
"canonical": _check_canonical(seo_parser.canonical, final_url),
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# Exit with code 1 if any check is fail
has_failure = any(
output[key]["status"] == "fail"
for key in ("url_slug", "title", "meta_description", "h1", "canonical")
)
sys.exit(1 if has_failure else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Daily SEO/GEO patrol — gingiris-skills / gr-seo-patrol.
Env vars required:
DATAFORSEO_B64 Basic-auth b64 for DataForSEO
GITHUB_TOKEN PAT for repo reads (optional but recommended)
GR_KEYWORDS comma-separated keyword overrides (optional)
GR_SITE site domain (default: gingiris.github.io/growth-tools)
GR_GA4_ID GA4 measurement ID to verify (default: G-QKPQFJD1FH)
"""
import os, sys, json, time, urllib.request, urllib.error
API = os.environ.get("DATAFORSEO_B64")
SITE = os.environ.get("GR_SITE", "gingiris.github.io/growth-tools")
GA4 = os.environ.get("GR_GA4_ID", "G-QKPQFJD1FH")
DEFAULT_KEYWORDS = [
("product hunt launch playbook", 2840, "en"),
("best social media listening tools", 2840, "en"),
("github stars history", 2840, "en"),
("open source marketing", 2840, "en"),
("how to launch on hacker news", 2840, "en"),
("GEO generative engine optimization", 2840, "en"),
]
TARGETS = [f"https://{SITE}/", f"https://{SITE}/en/",
f"https://{SITE}/ja/", f"https://{SITE}/ko/"]
def serp(kw, loc, lang):
if not API:
return {"error": "DATAFORSEO_B64 missing"}
payload = json.dumps([{"keyword": kw, "location_code": loc,
"language_code": lang, "device": "desktop",
"depth": 100}]).encode()
req = urllib.request.Request(
"https://api.dataforseo.com/v3/serp/google/organic/live/advanced",
data=payload,
headers={"Authorization": f"Basic {API}",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=40) as r:
return json.loads(r.read())
except Exception as e:
return {"error": str(e)}
def find_ranks(data, site):
hits = []
try:
for it in data["tasks"][0]["result"][0]["items"]:
if site in (it.get("url") or ""):
hits.append({"rank": it.get("rank_absolute"),
"url": it.get("url"),
"title": (it.get("title") or "")[:80]})
except Exception:
pass
return hits
def ga4_check():
out = []
for u in TARGETS:
try:
req = urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as r:
body = r.read().decode("utf-8", errors="replace")
out.append({"url": u, "status": r.status,
"has_ga4": GA4 in body,
"has_gtag_js": "gtag/js" in body})
except Exception as e:
out.append({"url": u, "error": str(e)})
return out
def indexed_count():
d = serp(f"site:{SITE}", 2840, "en")
try:
res = d["tasks"][0]["result"][0]
return {"items_count": res.get("items_count"),
"se_results_count": res.get("se_results_count")}
except Exception as e:
return {"error": str(e)}
def llms_txt():
try:
req = urllib.request.Request(f"https://{SITE}/llms.txt",
headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as r:
body = r.read().decode("utf-8", errors="replace")
return {"status": r.status, "len": len(body)}
except Exception as e:
return {"error": str(e)}
def main():
from datetime import date
report = {"date": str(date.today()), "site": SITE,
"ga4": ga4_check(),
"llms_txt": llms_txt(),
"indexed": indexed_count(),
"ranks": []}
kws = DEFAULT_KEYWORDS
override = os.environ.get("GR_KEYWORDS")
if override:
kws = [(k.strip(), 2840, "en") for k in override.split(",") if k.strip()]
for kw, loc, lang in kws:
print(f"[serp] {kw}", file=sys.stderr)
d = serp(kw, loc, lang)
report["ranks"].append({"keyword": kw, "hits": find_ranks(d, SITE)})
time.sleep(1)
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
print()
if __name__ == "__main__":
main()
gr-seo-patrol scripts
Original scripts (gingiris-built)
daily-report.py— DataForSEO SERP rank patrol (gingiris.github.io + dev.to/iris1031)canonical-fix.py— Batch canonical URL fix on Jekyll _postsrescue-post.py— Social rescue (title rewrite + internal link injection)
Adopted from JeffLi1993/seo-audit-skill (2026-05-07)
Single-page deep audit capability — the 4 scripts below validate per-page SEO health.
check-page.py— H1, title, meta description, canonical, slug, alt text, keyword placementcheck-schema.py— JSON-LD validation (Article, FAQPage, BreadcrumbList, Organization, etc)check-site.py— robots.txt + sitemap.xml validation, redirect chaincheck-social.py— OG tags + Twitter Card validation
All adapted scripts retain their original license terms. Original repo: https://github.com/JeffLi1993/seo-audit-skill
Usage
# Single-page audit
python3 scripts/check-page.py URL --keyword "primary keyword"
python3 scripts/check-schema.py URL
python3 scripts/check-site.py URL
python3 scripts/check-social.py URLDependencies
pip install requests— for the 4 adopted scripts- gingiris originals (daily-report, canonical-fix, rescue-post) use Python stdlib only