#!/usr/bin/env bash # AEO 上线后自查脚本 —— 对照 Osmani 六层 (addyosmani.com/blog/agentic-engine-optimization/) # 用法: bash aeo-check.sh example.com [docs路径, 默认 /] # 只读、只发 GET;输出每项 PASS/FAIL/WARN + 原始证据。不接受形容词,判据全在下面写死。 set -u DOMAIN="${1:?用法: aeo-check.sh [docs-path]}" DOCS="${2:-/}" BASE="https://${DOMAIN}" UA_LIST="GPTBot ChatGPT-User OAI-SearchBot ClaudeBot Claude-User Claude-SearchBot anthropic-ai Google-Extended PerplexityBot Perplexity-User" TMP="$(mktemp -d)" c() { curl -sL --max-time 20 "$@"; } # 存在性判定:200 且 content-type 不是 text/html(SPA 兜底页对任意路径都回 200,是假阳性) exists() { local ct; ct=$(c -o /dev/null -w '%{http_code} %{content_type}' "$1"); case "$ct" in 200\ *text/html*) echo "假阳性(200 但 HTML 兜底)";; 200*) echo "存在 [$ct]";; *) echo "无 [${ct%% *}]";; esac; } tok() { python -c "import sys;print(len(sys.stdin.read())//4)"; } # Osmani: chars/4 粗估 echo "== AEO check: ${BASE} $(date -u +%FT%TZ) ==" # L1 robots.txt echo; echo "[L1 robots.txt]" RC=$(c -o "$TMP/robots" -w '%{http_code}' "$BASE/robots.txt") if [ "$RC" = "200" ]; then echo " robots.txt: 200 ($(wc -c <"$TMP/robots") bytes)" for ua in $UA_LIST; do if grep -qi "User-agent: *${ua}" "$TMP/robots" && awk -v ua="$ua" 'BEGIN{IGNORECASE=1} $0 ~ "User-agent: *"ua {f=1;next} /^User-agent:/{f=0} f && /Disallow: *\/ *$/ {found=1} END{exit !found}' "$TMP/robots"; then echo " FAIL ${ua} 被整站 Disallow" fi done awk 'BEGIN{IGNORECASE=1} /User-agent: *\*/{f=1;next} /^User-agent:/{f=0} f && /Disallow: *\/ *$/ {print " FAIL User-agent:* 整站 Disallow(所有 agent 都进不来)"}' "$TMP/robots" echo " PASS (无上列 AI UA 被整站封锁即通过;名单需对照各家官方 UA 文档更新)" else echo " WARN robots.txt HTTP ${RC}(无文件=默认放行,但也没声明许可)" fi # agent-permissions.json(Osmani 原文有、极客公园版没有) echo " agent-permissions.json: $(exists "$BASE/agent-permissions.json") (可选)" # L2 llms.txt echo; echo "[L2 llms.txt]" RC=$(c -o "$TMP/llms" -w '%{http_code}' "$BASE/llms.txt") if [ "$RC" = "200" ]; then T=$(tok <"$TMP/llms"); N=$(grep -cE '^\s*-\s*\[' "$TMP/llms") echo " llms.txt: 200, ~${T} tokens, ${N} 条目" [ "$T" -le 5000 ] && echo " PASS ≤5000 tokens" || echo " FAIL 索引本身 >5000 tokens" grep -qiE '[0-9]+ ?k? ?tokens' "$TMP/llms" && echo " PASS 条目带 token 数" || echo " WARN 条目未标 token 数" else echo " FAIL llms.txt HTTP ${RC}" fi # L3 skill.md echo; echo "[L3 skill.md]" FOUND=0 for p in /skill.md /.well-known/skill.md /docs/skill.md /SKILL.md; do RC=$(c -o "$TMP/skill" -w '%{http_code}' "$BASE$p") if [ "$RC" = "200" ] && grep -qiE '^(#|---|name:)' "$TMP/skill"; then FOUND=1; echo " skill.md: 200 at $p (~$(tok <"$TMP/skill") tokens)" for sec in "can accomplish|能做|capabilit" "input|输入" "constraint|limit|限制" "doc|文档"; do grep -qiE "$sec" "$TMP/skill" && echo " PASS 含 [$sec]" || echo " FAIL 缺 [$sec]" done; break fi done [ $FOUND = 0 ] && echo " FAIL 未找到 skill.md(试过 /skill.md /.well-known/skill.md /docs/skill.md)" # L4 内容可解析:Markdown 直达 + 无 JS 也有正文 echo; echo "[L4 content]" PAGE="$BASE$DOCS" c -A "Mozilla/5.0" -o "$TMP/page.html" "$PAGE" HB=$(wc -c <"$TMP/page.html") TXT=$(python - "$TMP/page.html" <<'PY' import re,sys,html s=open(sys.argv[1],encoding='utf-8',errors='ignore').read() s=re.sub(r'|||','',s,flags=re.S|re.I) s=html.unescape(re.sub(r'<[^>]+>',' ',s)); s=re.sub(r'\s+',' ',s).strip() print(len(s)) PY ) echo " ${PAGE}: HTML ${HB} bytes, 去标签后正文 ${TXT} chars (~$((TXT/4)) tokens)" [ "$TXT" -ge 400 ] && echo " PASS 不执行 JS 也有正文(≥400 chars)" || echo " FAIL 无 JS 正文≈空,疑 SPA 渲染 → agent 拿到空页" [ "$HB" -gt 0 ] && echo " HTML/正文 膨胀比: $(python -c "print(round($HB/max($TXT,1),1))")x (>3x 说明标签噪音重,需提供 .md)" for md in "${DOCS%/}.md" "${DOCS%/}/index.md" "${DOCS}?format=md"; do RC=$(c -o "$TMP/md" -w '%{http_code}' "$BASE$md") if [ "$RC" = "200" ] && head -c 2000 "$TMP/md" | grep -qE '^#|^\- |^\* '; then echo " PASS Markdown 直达: $md"; MDOK=1; break; fi done [ "${MDOK:-0}" = 0 ] && echo " FAIL 无 .md 直达路径" PT=$((TXT/4)) [ "$PT" -le 30000 ] && echo " PASS 单页 ≤30K tokens (Osmani 硬上限)" || echo " FAIL 单页 ${PT} tokens >30K,需分块" # L5 token 元数据 echo; echo "[L5 token metadata]" H=$(c -I -A "Mozilla/5.0" "$PAGE" | grep -iE '^x-token|token-count' || true) M=$(grep -ioE ']*token[^>]*>' "$TMP/page.html" | head -3 || true) { [ -n "$H" ] || [ -n "$M" ]; } && { echo " PASS"; echo " $H $M"; } || echo " FAIL 无 token 数 header/meta(实现: chars/4 → 或 X-Token-Count)" # L6 Copy for AI / AGENTS.md / MCP echo; echo "[L6 UX bridge]" grep -qiE 'copy (for|to) ai|copy as markdown' "$TMP/page.html" && echo " PASS Copy for AI 按钮" || echo " WARN 未见 Copy for AI 按钮(低成本项)" echo " AGENTS.md: $(exists "$BASE/AGENTS.md") (仓库有则应有)" echo " /mcp: $(exists "$BASE/mcp") (可选,Osmani 清单项;MCP 端点通常回 JSON/SSE 或 4xx,HTML=兜底)" # Agent 视角首屏:前 500 tokens 能否答「是什么/能干什么/怎么开始」—— 机器只能给你看,判断你自己做 echo; echo "[首 500 tokens 原文 —— 人工判三问]" python - "$TMP/page.html" <<'PY' import re,sys,html s=open(sys.argv[1],encoding='utf-8',errors='ignore').read() s=re.sub(r'|||','',s,flags=re.S|re.I) s=html.unescape(re.sub(r'<[^>]+>',' ',s)); s=re.sub(r'\s+',' ',s).strip() print(" "+s[:2000]) PY rm -rf "$TMP"