citable-cli 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citable/__init__.py +693 -0
- citable/hosted.py +338 -0
- citable/track.py +370 -0
- citable/watch.py +293 -0
- citable_cli-0.4.1.dist-info/METADATA +201 -0
- citable_cli-0.4.1.dist-info/RECORD +10 -0
- citable_cli-0.4.1.dist-info/WHEEL +5 -0
- citable_cli-0.4.1.dist-info/entry_points.txt +2 -0
- citable_cli-0.4.1.dist-info/licenses/LICENSE +21 -0
- citable_cli-0.4.1.dist-info/top_level.txt +1 -0
citable/__init__.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""citable: can ChatGPT, Claude and Perplexity actually read and cite your site?
|
|
3
|
+
|
|
4
|
+
One file, zero dependencies, Python 3.9+.
|
|
5
|
+
|
|
6
|
+
citable example.com human-readable report, score out of 100
|
|
7
|
+
citable example.com --json machine-readable
|
|
8
|
+
citable example.com --llms-txt print a starter llms.txt built from the homepage
|
|
9
|
+
citable mcp run as an MCP server over stdio (tools: check_site, draft_llms_txt)
|
|
10
|
+
citable watch a.com b.com weekly watch: remembers results in a GitHub issue, comments on regressions
|
|
11
|
+
citable track a.com "best X" prompt tracking: does each AI engine cite you for the prompts that matter
|
|
12
|
+
citable hosted run the paid tier: Polar subscribers in, first report / alert / weekly digest emails out
|
|
13
|
+
|
|
14
|
+
It does not guess from robots.txt alone: it fetches the homepage *as* each AI bot and reports
|
|
15
|
+
edge blocks (Cloudflare, WAFs) that robots.txt never mentions.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import concurrent.futures
|
|
20
|
+
import html
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import ssl
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
__version__ = "0.4.1"
|
|
32
|
+
__all__ = ["check_site", "draft_llms_txt", "parse_robots", "robots_verdict", "main"]
|
|
33
|
+
|
|
34
|
+
TIMEOUT = 12
|
|
35
|
+
BROWSER_UA = (
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# name, vendor, role, live user-agent string (None = robots.txt only)
|
|
41
|
+
# role "answer": fetches pages to answer or cite in a live conversation. Blocking these means no citations.
|
|
42
|
+
# role "train": builds training corpora. Blocking these is a legitimate choice; reported, not penalised.
|
|
43
|
+
BOTS: List[Tuple[str, str, str, Optional[str]]] = [
|
|
44
|
+
("OAI-SearchBot", "OpenAI", "answer",
|
|
45
|
+
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot"),
|
|
46
|
+
("ChatGPT-User", "OpenAI", "answer",
|
|
47
|
+
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot"),
|
|
48
|
+
("Claude-SearchBot", "Anthropic", "answer",
|
|
49
|
+
"Mozilla/5.0 (compatible; Claude-SearchBot/1.0; +https://www.anthropic.com/claude-searchbot)"),
|
|
50
|
+
("Claude-User", "Anthropic", "answer",
|
|
51
|
+
"Mozilla/5.0 (compatible; Claude-User/1.0; +https://www.anthropic.com/claude-user)"),
|
|
52
|
+
("PerplexityBot", "Perplexity", "answer",
|
|
53
|
+
"Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"),
|
|
54
|
+
("Perplexity-User", "Perplexity", "answer",
|
|
55
|
+
"Mozilla/5.0 (compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)"),
|
|
56
|
+
("DuckAssistBot", "DuckDuckGo", "answer", None),
|
|
57
|
+
("Bingbot", "Microsoft (Copilot)", "answer", None),
|
|
58
|
+
("Googlebot", "Google (AI Overviews)", "answer", None),
|
|
59
|
+
("GPTBot", "OpenAI", "train",
|
|
60
|
+
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot"),
|
|
61
|
+
("ClaudeBot", "Anthropic", "train",
|
|
62
|
+
"Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)"),
|
|
63
|
+
("Google-Extended", "Google (Gemini)", "train", None),
|
|
64
|
+
("Applebot-Extended", "Apple", "train", None),
|
|
65
|
+
("CCBot", "Common Crawl", "train", None),
|
|
66
|
+
("meta-externalagent", "Meta", "train", None),
|
|
67
|
+
("Amazonbot", "Amazon", "train", None),
|
|
68
|
+
("Bytespider", "ByteDance", "train", None),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
SSL_CTX = ssl.create_default_context()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ----------------------------------------------------------------------------- fetching
|
|
75
|
+
|
|
76
|
+
def fetch(url: str, ua: str = BROWSER_UA, accept: str = "*/*", max_bytes: int = 2_000_000) -> Dict[str, Any]:
|
|
77
|
+
"""GET a URL. Never raises; returns a dict with status (0 on network error), final_url, headers, body, ms."""
|
|
78
|
+
req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": accept, "Accept-Language": "en"})
|
|
79
|
+
t0 = time.time()
|
|
80
|
+
out: Dict[str, Any] = {"url": url, "status": 0, "final_url": url, "headers": {}, "body": "", "error": None, "ms": 0}
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT, context=SSL_CTX) as r:
|
|
83
|
+
raw = r.read(max_bytes)
|
|
84
|
+
out.update(status=r.status, final_url=r.geturl(), headers={k.lower(): v for k, v in r.headers.items()})
|
|
85
|
+
except urllib.error.HTTPError as e:
|
|
86
|
+
raw = b""
|
|
87
|
+
try:
|
|
88
|
+
raw = e.read(max_bytes)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
out.update(status=e.code, final_url=e.geturl() or url, headers={k.lower(): v for k, v in e.headers.items()})
|
|
92
|
+
except Exception as e: # DNS, TLS, timeout, refused
|
|
93
|
+
raw = b""
|
|
94
|
+
out["error"] = f"{type(e).__name__}: {e}"
|
|
95
|
+
out["ms"] = int((time.time() - t0) * 1000)
|
|
96
|
+
ctype = out["headers"].get("content-type", "")
|
|
97
|
+
m = re.search(r"charset=([\w-]+)", ctype)
|
|
98
|
+
enc = m.group(1) if m else "utf-8"
|
|
99
|
+
try:
|
|
100
|
+
out["body"] = raw.decode(enc, errors="replace")
|
|
101
|
+
except LookupError:
|
|
102
|
+
out["body"] = raw.decode("utf-8", errors="replace")
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalise_url(target: str) -> str:
|
|
107
|
+
target = target.strip()
|
|
108
|
+
if not re.match(r"^https?://", target, re.I):
|
|
109
|
+
target = "https://" + target
|
|
110
|
+
p = urllib.parse.urlsplit(target)
|
|
111
|
+
path = p.path or "/"
|
|
112
|
+
return urllib.parse.urlunsplit((p.scheme.lower(), p.netloc.lower(), path, p.query, ""))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def origin(url: str) -> str:
|
|
116
|
+
p = urllib.parse.urlsplit(url)
|
|
117
|
+
return f"{p.scheme}://{p.netloc}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ----------------------------------------------------------------------------- robots.txt
|
|
121
|
+
|
|
122
|
+
def parse_robots(text: str) -> Dict[str, Any]:
|
|
123
|
+
"""Parse robots.txt into groups [{agents:[...], rules:[(allow:bool, pattern)]}] and sitemaps."""
|
|
124
|
+
groups: List[Dict[str, Any]] = []
|
|
125
|
+
sitemaps: List[str] = []
|
|
126
|
+
cur: Optional[Dict[str, Any]] = None
|
|
127
|
+
last_was_agent = False
|
|
128
|
+
for raw in text.splitlines():
|
|
129
|
+
line = raw.split("#", 1)[0].strip()
|
|
130
|
+
if not line or ":" not in line:
|
|
131
|
+
continue
|
|
132
|
+
field, _, value = line.partition(":")
|
|
133
|
+
field = field.strip().lower()
|
|
134
|
+
value = value.strip()
|
|
135
|
+
if field == "user-agent":
|
|
136
|
+
if cur is None or not last_was_agent:
|
|
137
|
+
cur = {"agents": [], "rules": []}
|
|
138
|
+
groups.append(cur)
|
|
139
|
+
cur["agents"].append(value.lower())
|
|
140
|
+
last_was_agent = True
|
|
141
|
+
elif field in ("allow", "disallow"):
|
|
142
|
+
last_was_agent = False
|
|
143
|
+
if cur is None:
|
|
144
|
+
continue
|
|
145
|
+
cur["rules"].append((field == "allow", value))
|
|
146
|
+
elif field == "sitemap":
|
|
147
|
+
last_was_agent = False
|
|
148
|
+
if value:
|
|
149
|
+
sitemaps.append(value)
|
|
150
|
+
else:
|
|
151
|
+
last_was_agent = False
|
|
152
|
+
return {"groups": groups, "sitemaps": sitemaps}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _pattern_to_regex(pattern: str) -> "re.Pattern[str]":
|
|
156
|
+
parts = []
|
|
157
|
+
for ch in pattern:
|
|
158
|
+
if ch == "*":
|
|
159
|
+
parts.append(".*")
|
|
160
|
+
elif ch == "$":
|
|
161
|
+
parts.append("$")
|
|
162
|
+
else:
|
|
163
|
+
parts.append(re.escape(ch))
|
|
164
|
+
return re.compile("^" + "".join(parts))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def robots_verdict(robots: Dict[str, Any], bot: str, path: str = "/") -> str:
|
|
168
|
+
"""'allow', 'block' or 'unspecified' for a bot and path, RFC 9309 style (longest match, allow wins ties)."""
|
|
169
|
+
bot_l = bot.lower()
|
|
170
|
+
specific = [g for g in robots["groups"] if any(a != "*" and (a == bot_l or a in bot_l or bot_l in a) for a in g["agents"])]
|
|
171
|
+
chosen = specific or [g for g in robots["groups"] if "*" in g["agents"]]
|
|
172
|
+
if not chosen:
|
|
173
|
+
return "unspecified"
|
|
174
|
+
best: Optional[Tuple[int, bool]] = None
|
|
175
|
+
for g in chosen:
|
|
176
|
+
for allow, pattern in g["rules"]:
|
|
177
|
+
if pattern == "":
|
|
178
|
+
if not allow: # "Disallow:" (empty) means allow everything
|
|
179
|
+
cand = (0, True)
|
|
180
|
+
else:
|
|
181
|
+
continue
|
|
182
|
+
elif _pattern_to_regex(pattern).match(path):
|
|
183
|
+
cand = (len(pattern), allow)
|
|
184
|
+
else:
|
|
185
|
+
continue
|
|
186
|
+
if best is None or cand[0] > best[0] or (cand[0] == best[0] and cand[1] and not best[1]):
|
|
187
|
+
best = cand
|
|
188
|
+
if best is None:
|
|
189
|
+
return "allow"
|
|
190
|
+
return "allow" if best[1] else "block"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ----------------------------------------------------------------------------- HTML helpers
|
|
194
|
+
|
|
195
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _strip_html(body: str) -> str:
|
|
199
|
+
body = re.sub(r"(?is)<(script|style|noscript|svg|template)[^>]*>.*?</\1>", " ", body)
|
|
200
|
+
body = _TAG_RE.sub(" ", body)
|
|
201
|
+
return html.unescape(re.sub(r"\s+", " ", body)).strip()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _meta(body: str, name: str) -> Optional[str]:
|
|
205
|
+
for attr in ("name", "property"):
|
|
206
|
+
m = re.search(
|
|
207
|
+
rf'(?is)<meta\b[^>]*\b{attr}\s*=\s*["\']?{re.escape(name)}["\']?[^>]*\bcontent\s*=\s*["\']([^"\']*)["\']', body
|
|
208
|
+
) or re.search(
|
|
209
|
+
rf'(?is)<meta\b[^>]*\bcontent\s*=\s*["\']([^"\']*)["\'][^>]*\b{attr}\s*=\s*["\']?{re.escape(name)}["\']?', body
|
|
210
|
+
)
|
|
211
|
+
if m:
|
|
212
|
+
return html.unescape(m.group(1)).strip()
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _title(body: str) -> Optional[str]:
|
|
217
|
+
m = re.search(r"(?is)<title[^>]*>(.*?)</title>", body)
|
|
218
|
+
return html.unescape(re.sub(r"\s+", " ", m.group(1))).strip() if m else None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _canonical(body: str) -> Optional[str]:
|
|
222
|
+
m = re.search(r'(?is)<link\b[^>]*\brel\s*=\s*["\']?canonical["\']?[^>]*\bhref\s*=\s*["\']([^"\']+)["\']', body) or re.search(
|
|
223
|
+
r'(?is)<link\b[^>]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*\brel\s*=\s*["\']?canonical["\']?', body
|
|
224
|
+
)
|
|
225
|
+
return m.group(1).strip() if m else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _lang(body: str) -> Optional[str]:
|
|
229
|
+
m = re.search(r'(?is)<html\b[^>]*\blang\s*=\s*["\']?([a-zA-Z-]+)', body)
|
|
230
|
+
return m.group(1) if m else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _jsonld(body: str) -> List[Dict[str, Any]]:
|
|
234
|
+
out: List[Dict[str, Any]] = []
|
|
235
|
+
for m in re.finditer(r'(?is)<script\b[^>]*type\s*=\s*["\']application/ld\+json["\'][^>]*>(.*?)</script>', body):
|
|
236
|
+
txt = m.group(1).strip()
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(txt)
|
|
239
|
+
except Exception:
|
|
240
|
+
continue
|
|
241
|
+
items = data if isinstance(data, list) else [data]
|
|
242
|
+
for it in items:
|
|
243
|
+
if isinstance(it, dict):
|
|
244
|
+
if "@graph" in it and isinstance(it["@graph"], list):
|
|
245
|
+
out.extend(x for x in it["@graph"] if isinstance(x, dict))
|
|
246
|
+
else:
|
|
247
|
+
out.append(it)
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _jsonld_types(items: List[Dict[str, Any]]) -> List[str]:
|
|
252
|
+
types: List[str] = []
|
|
253
|
+
for it in items:
|
|
254
|
+
t = it.get("@type")
|
|
255
|
+
if isinstance(t, list):
|
|
256
|
+
types.extend(str(x) for x in t)
|
|
257
|
+
elif t:
|
|
258
|
+
types.append(str(t))
|
|
259
|
+
return types
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _links(body: str, base: str) -> List[Tuple[str, str]]:
|
|
263
|
+
"""Same-origin links from the page: [(absolute_url, anchor_text)] deduped, in order."""
|
|
264
|
+
seen = set()
|
|
265
|
+
out: List[Tuple[str, str]] = []
|
|
266
|
+
host = urllib.parse.urlsplit(base).netloc
|
|
267
|
+
for m in re.finditer(r'(?is)<a\b[^>]*\bhref\s*=\s*["\']([^"\'#]+)["\'][^>]*>(.*?)</a>', body):
|
|
268
|
+
href, text = m.group(1).strip(), _strip_html(m.group(2))
|
|
269
|
+
if href.startswith(("mailto:", "tel:", "javascript:")):
|
|
270
|
+
continue
|
|
271
|
+
absu = urllib.parse.urljoin(base, href)
|
|
272
|
+
p = urllib.parse.urlsplit(absu)
|
|
273
|
+
if p.netloc != host or p.scheme not in ("http", "https"):
|
|
274
|
+
continue
|
|
275
|
+
clean = urllib.parse.urlunsplit((p.scheme, p.netloc, p.path.rstrip("/") or "/", "", ""))
|
|
276
|
+
if clean in seen or clean.rstrip("/") == base.rstrip("/"):
|
|
277
|
+
continue
|
|
278
|
+
if re.search(r"\.(png|jpe?g|gif|svg|pdf|zip|css|js)$", p.path, re.I):
|
|
279
|
+
continue
|
|
280
|
+
seen.add(clean)
|
|
281
|
+
out.append((clean, text[:80]))
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ----------------------------------------------------------------------------- llms.txt
|
|
286
|
+
|
|
287
|
+
def validate_llms_txt(resp: Dict[str, Any]) -> Dict[str, Any]:
|
|
288
|
+
body = resp["body"]
|
|
289
|
+
ctype = resp["headers"].get("content-type", "")
|
|
290
|
+
looks_html = body.lstrip()[:1] == "<" or "text/html" in ctype
|
|
291
|
+
present = resp["status"] == 200 and body.strip() != "" and not looks_html
|
|
292
|
+
lines = body.splitlines()
|
|
293
|
+
return {
|
|
294
|
+
"present": present,
|
|
295
|
+
"status": resp["status"],
|
|
296
|
+
"soft_404": resp["status"] == 200 and looks_html,
|
|
297
|
+
"bytes": len(body.encode("utf-8", errors="replace")),
|
|
298
|
+
"has_h1": present and any(l.startswith("# ") for l in lines[:5]),
|
|
299
|
+
"has_summary": present and any(l.startswith("> ") for l in lines),
|
|
300
|
+
"link_count": (len(re.findall(r"\[[^\]]+\]\((https?://[^)]+|/[^)]*)\)", body))
|
|
301
|
+
or len(re.findall(r"(?m)^\s*(?:[-*]\s*)?https?://\S+", body))) if present else 0,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ----------------------------------------------------------------------------- the check
|
|
306
|
+
|
|
307
|
+
def _check(id_: str, title: str, status: str, points: int, max_points: int, detail: str, fix: str = "") -> Dict[str, Any]:
|
|
308
|
+
return {"id": id_, "title": title, "status": status, "points": points, "max": max_points, "detail": detail, "fix": fix}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_site(target: str, live: bool = True) -> Dict[str, Any]:
|
|
312
|
+
"""Run every check against a site. Returns a dict with score, grade, checks, bots and page facts."""
|
|
313
|
+
url = normalise_url(target)
|
|
314
|
+
base = origin(url)
|
|
315
|
+
checks: List[Dict[str, Any]] = []
|
|
316
|
+
|
|
317
|
+
# 1. baseline fetch as a browser
|
|
318
|
+
home = fetch(url, accept="text/html,*/*")
|
|
319
|
+
if home["status"] == 0:
|
|
320
|
+
return {
|
|
321
|
+
"url": url, "reachable": False, "error": home["error"], "score": 0, "grade": "F",
|
|
322
|
+
"checks": [_check("reachable", "Site reachable", "fail", 0, 100, home["error"] or "no response",
|
|
323
|
+
"Make sure the host resolves and serves HTTPS.")],
|
|
324
|
+
"bots": {}, "page": {}, "llms_txt": {}, "version": __version__,
|
|
325
|
+
}
|
|
326
|
+
final = home["final_url"]
|
|
327
|
+
body = home["body"]
|
|
328
|
+
base = origin(final)
|
|
329
|
+
|
|
330
|
+
# HTTPS
|
|
331
|
+
if final.startswith("https://"):
|
|
332
|
+
checks.append(_check("https", "HTTPS", "pass", 5, 5, f"Served over HTTPS ({home['status']} in {home['ms']} ms)."))
|
|
333
|
+
else:
|
|
334
|
+
checks.append(_check("https", "HTTPS", "fail", 0, 5, f"Final URL is {final}.", "Serve the site over HTTPS and redirect HTTP to it."))
|
|
335
|
+
|
|
336
|
+
# response time
|
|
337
|
+
if home["ms"] > 4000:
|
|
338
|
+
checks.append(_check("speed", "Response time", "warn", 1, 3, f"Homepage took {home['ms']} ms.", "AI crawlers give up on slow pages; get the HTML under 2 s."))
|
|
339
|
+
else:
|
|
340
|
+
checks.append(_check("speed", "Response time", "pass", 3, 3, f"Homepage HTML in {home['ms']} ms."))
|
|
341
|
+
|
|
342
|
+
# 2. robots.txt and 3. live bot fetches, in parallel
|
|
343
|
+
robots_resp = None
|
|
344
|
+
live_results: Dict[str, Dict[str, Any]] = {}
|
|
345
|
+
# Sites hosted on a sub-path (GitHub project pages, docs portals) can only publish llms.txt and a sitemap
|
|
346
|
+
# under that path, so those two are also tried there. robots.txt is root-only by spec.
|
|
347
|
+
sub = urllib.parse.urlsplit(final).path.rstrip("/")
|
|
348
|
+
sub_base = base + sub if sub else None
|
|
349
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
|
350
|
+
f_robots = ex.submit(fetch, base + "/robots.txt", BROWSER_UA, "text/plain,*/*")
|
|
351
|
+
f_llms = ex.submit(fetch, base + "/llms.txt", BROWSER_UA, "text/plain,*/*")
|
|
352
|
+
f_llms_sub = ex.submit(fetch, sub_base + "/llms.txt", BROWSER_UA, "text/plain,*/*") if sub_base else None
|
|
353
|
+
f_llms_full = ex.submit(fetch, base + "/llms-full.txt", BROWSER_UA, "text/plain,*/*", 200_000)
|
|
354
|
+
f_live = {}
|
|
355
|
+
if live:
|
|
356
|
+
for name, _vendor, _role, ua in BOTS:
|
|
357
|
+
if ua:
|
|
358
|
+
f_live[name] = ex.submit(fetch, final, ua, "text/html,*/*", 300_000)
|
|
359
|
+
robots_resp = f_robots.result()
|
|
360
|
+
llms_resp = f_llms.result()
|
|
361
|
+
llms_full_resp = f_llms_full.result()
|
|
362
|
+
llms_location = base + "/llms.txt"
|
|
363
|
+
if f_llms_sub is not None and not validate_llms_txt(llms_resp)["present"]:
|
|
364
|
+
alt = f_llms_sub.result()
|
|
365
|
+
if validate_llms_txt(alt)["present"]:
|
|
366
|
+
llms_resp, llms_location = alt, sub_base + "/llms.txt"
|
|
367
|
+
for name, fut in f_live.items():
|
|
368
|
+
live_results[name] = fut.result()
|
|
369
|
+
|
|
370
|
+
robots = parse_robots(robots_resp["body"]) if robots_resp["status"] == 200 else {"groups": [], "sitemaps": []}
|
|
371
|
+
robots_present = robots_resp["status"] == 200
|
|
372
|
+
|
|
373
|
+
bots: Dict[str, Dict[str, Any]] = {}
|
|
374
|
+
answer_blocked_robots: List[str] = []
|
|
375
|
+
answer_blocked_edge: List[str] = []
|
|
376
|
+
train_blocked: List[str] = []
|
|
377
|
+
for name, vendor, role, ua in BOTS:
|
|
378
|
+
verdict = robots_verdict(robots, name) if robots_present else "unspecified"
|
|
379
|
+
entry: Dict[str, Any] = {"vendor": vendor, "role": role, "robots": verdict}
|
|
380
|
+
lr = live_results.get(name)
|
|
381
|
+
if lr is not None:
|
|
382
|
+
entry["live_status"] = lr["status"]
|
|
383
|
+
entry["live_blocked"] = (home["status"] == 200 and lr["status"] in (401, 403, 405, 406, 429, 503, 0))
|
|
384
|
+
if lr["status"] == 0:
|
|
385
|
+
entry["live_error"] = lr["error"]
|
|
386
|
+
if verdict == "block":
|
|
387
|
+
(answer_blocked_robots if role == "answer" else train_blocked).append(name)
|
|
388
|
+
if entry.get("live_blocked") and role == "answer":
|
|
389
|
+
answer_blocked_edge.append(name)
|
|
390
|
+
bots[name] = entry
|
|
391
|
+
train_blocked_edge = [n for n, e in bots.items() if e["role"] == "train" and e.get("live_blocked")]
|
|
392
|
+
|
|
393
|
+
if not robots_present:
|
|
394
|
+
checks.append(_check("robots", "robots.txt allows answer engines", "pass", 15, 15,
|
|
395
|
+
f"No robots.txt ({robots_resp['status']}), so every crawler is allowed by default."))
|
|
396
|
+
elif answer_blocked_robots:
|
|
397
|
+
checks.append(_check("robots", "robots.txt allows answer engines", "fail", 0, 15,
|
|
398
|
+
"robots.txt blocks: " + ", ".join(answer_blocked_robots) + ".",
|
|
399
|
+
"These bots fetch pages to answer live questions and cite you. Remove the Disallow for them "
|
|
400
|
+
"(you can still block training bots like GPTBot and ClaudeBot separately)."))
|
|
401
|
+
else:
|
|
402
|
+
checks.append(_check("robots", "robots.txt allows answer engines", "pass", 15, 15,
|
|
403
|
+
"OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Bingbot and Googlebot are allowed."))
|
|
404
|
+
|
|
405
|
+
if live:
|
|
406
|
+
if answer_blocked_edge:
|
|
407
|
+
det = ", ".join(f"{n} got HTTP {bots[n]['live_status']}" for n in answer_blocked_edge)
|
|
408
|
+
also_robots = all(n in answer_blocked_robots for n in answer_blocked_edge)
|
|
409
|
+
checks.append(_check("edge", "Answer engines not blocked at the edge", "fail", 0, 20,
|
|
410
|
+
f"Fetching the homepage as a bot: {det}; a browser got {home['status']}.",
|
|
411
|
+
("Your CDN or WAF (Cloudflare 'Block AI bots', bot-fight mode, a rate limiter) is refusing them"
|
|
412
|
+
+ (" on top of the robots.txt block." if also_robots else " even though robots.txt allows them.")
|
|
413
|
+
+ " Add these user agents to the allow list.")))
|
|
414
|
+
else:
|
|
415
|
+
checks.append(_check("edge", "Answer engines not blocked at the edge", "pass", 20, 20,
|
|
416
|
+
"Homepage returned the same status to OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, "
|
|
417
|
+
"PerplexityBot and Perplexity-User as to a browser."))
|
|
418
|
+
if train_blocked or train_blocked_edge:
|
|
419
|
+
checks.append(_check("training", "Training crawlers", "info", 0, 0,
|
|
420
|
+
"Blocked (your choice, no penalty): " + ", ".join(sorted(set(train_blocked + train_blocked_edge))) + "."))
|
|
421
|
+
else:
|
|
422
|
+
checks.append(_check("training", "Training crawlers", "info", 0, 0,
|
|
423
|
+
"GPTBot, ClaudeBot, Google-Extended, CCBot and friends are allowed. Block them in robots.txt if you do not want to be training data; it does not affect citations."))
|
|
424
|
+
|
|
425
|
+
# 4. llms.txt
|
|
426
|
+
llms = validate_llms_txt(llms_resp)
|
|
427
|
+
llms["full_present"] = llms_full_resp["status"] == 200 and llms_full_resp["body"].lstrip()[:1] != "<"
|
|
428
|
+
if llms["present"]:
|
|
429
|
+
quality = sum([llms["has_h1"], llms["has_summary"], llms["link_count"] > 0])
|
|
430
|
+
pts = {3: 15, 2: 12, 1: 8, 0: 5}[quality]
|
|
431
|
+
missing = [x for x, ok in (("an H1 title", llms["has_h1"]), ("a '> summary' line", llms["has_summary"]), ("markdown links to key pages", llms["link_count"] > 0)) if not ok]
|
|
432
|
+
llms["url"] = llms_location
|
|
433
|
+
checks.append(_check("llms_txt", "/llms.txt", "pass" if quality == 3 else "warn", pts, 15,
|
|
434
|
+
f"Present at {llms_location} ({llms['bytes']} bytes, {llms['link_count']} links" + (", llms-full.txt too" if llms["full_present"] else "") + ").",
|
|
435
|
+
("Add " + " and ".join(missing) + ". Run `citable " + target + " --llms-txt` for a draft.") if missing else ""))
|
|
436
|
+
else:
|
|
437
|
+
why = "returns HTML (a soft 404)" if llms["soft_404"] else f"returns HTTP {llms['status']}"
|
|
438
|
+
checks.append(_check("llms_txt", "/llms.txt", "fail", 0, 15, f"/llms.txt {why}.",
|
|
439
|
+
f"Publish a plain-text /llms.txt: an H1, a one-line '> summary', then links to your key pages. "
|
|
440
|
+
f"`citable {target} --llms-txt` prints a draft from your homepage."))
|
|
441
|
+
|
|
442
|
+
# 5. homepage facts
|
|
443
|
+
title = _title(body)
|
|
444
|
+
desc = _meta(body, "description")
|
|
445
|
+
canonical = _canonical(body)
|
|
446
|
+
lang = _lang(body)
|
|
447
|
+
robots_meta = (_meta(body, "robots") or "").lower()
|
|
448
|
+
og_title = _meta(body, "og:title")
|
|
449
|
+
og_desc = _meta(body, "og:description")
|
|
450
|
+
ld = _jsonld(body)
|
|
451
|
+
ld_types = _jsonld_types(ld)
|
|
452
|
+
text = _strip_html(body)
|
|
453
|
+
words = len(text.split())
|
|
454
|
+
scripts = len(re.findall(r"(?i)<script\b", body))
|
|
455
|
+
h1s = len(re.findall(r"(?i)<h1\b", body))
|
|
456
|
+
|
|
457
|
+
if "noindex" in robots_meta or "noindex" in home["headers"].get("x-robots-tag", "").lower():
|
|
458
|
+
checks.append(_check("noindex", "No noindex", "fail", 0, 10, "The homepage carries a noindex directive.", "Remove noindex from the meta robots tag or X-Robots-Tag header."))
|
|
459
|
+
else:
|
|
460
|
+
checks.append(_check("noindex", "No noindex", "pass", 2, 2, "No noindex directive on the homepage."))
|
|
461
|
+
|
|
462
|
+
if title and desc and 10 <= len(title) <= 70 and 50 <= len(desc) <= 200:
|
|
463
|
+
checks.append(_check("meta", "Title and description", "pass", 8, 8, f"Title: {title!r} ({len(title)} chars); description {len(desc)} chars."))
|
|
464
|
+
elif title and desc:
|
|
465
|
+
checks.append(_check("meta", "Title and description", "warn", 5, 8, f"Title {len(title)} chars, description {len(desc)} chars.",
|
|
466
|
+
"Keep the title under 70 characters and the description between 50 and 200: answer engines quote these verbatim."))
|
|
467
|
+
else:
|
|
468
|
+
checks.append(_check("meta", "Title and description", "fail", 0, 8,
|
|
469
|
+
("Missing <title>. " if not title else "") + ("Missing meta description." if not desc else ""),
|
|
470
|
+
"Add a plain-language <title> and <meta name=description>: they are the first thing an answer engine reads."))
|
|
471
|
+
|
|
472
|
+
if canonical:
|
|
473
|
+
checks.append(_check("canonical", "Canonical URL", "pass", 4, 4, f"Canonical: {canonical}"))
|
|
474
|
+
else:
|
|
475
|
+
checks.append(_check("canonical", "Canonical URL", "warn", 1, 4, "No <link rel=canonical>.", "Add a canonical link so citations point at one URL, not five variants."))
|
|
476
|
+
|
|
477
|
+
if ld_types:
|
|
478
|
+
rich = any(t in ld_types for t in ("Organization", "WebSite", "Article", "FAQPage", "Product", "SoftwareApplication", "Person", "LocalBusiness", "HowTo", "BlogPosting", "NewsArticle"))
|
|
479
|
+
checks.append(_check("schema", "Structured data (JSON-LD)", "pass" if rich else "warn", 10 if rich else 6, 10,
|
|
480
|
+
"Types: " + ", ".join(sorted(set(ld_types))) + ".",
|
|
481
|
+
"" if rich else "Add Organization or WebSite schema with name, url and description."))
|
|
482
|
+
else:
|
|
483
|
+
checks.append(_check("schema", "Structured data (JSON-LD)", "fail", 0, 10, "No JSON-LD found.",
|
|
484
|
+
"Add a JSON-LD block with @type Organization (or WebSite), name, url, description and sameAs. "
|
|
485
|
+
"FAQPage blocks get quoted directly in answers."))
|
|
486
|
+
|
|
487
|
+
if words >= 250:
|
|
488
|
+
checks.append(_check("content", "Content readable without JavaScript", "pass", 15, 15, f"{words} words of visible text in the raw HTML, {h1s} h1."))
|
|
489
|
+
elif words >= 80:
|
|
490
|
+
checks.append(_check("content", "Content readable without JavaScript", "warn", 7, 15, f"Only {words} words in the raw HTML ({scripts} scripts).",
|
|
491
|
+
"Most AI crawlers do not run JavaScript. Server-render or prerender the main copy."))
|
|
492
|
+
else:
|
|
493
|
+
checks.append(_check("content", "Content readable without JavaScript", "fail", 0, 15,
|
|
494
|
+
f"Only {words} words in the raw HTML with {scripts} script tags: this looks like a client-rendered shell.",
|
|
495
|
+
"AI crawlers (and citations) only see server-rendered HTML. Prerender or SSR the pages you want cited."))
|
|
496
|
+
|
|
497
|
+
if og_title or og_desc:
|
|
498
|
+
checks.append(_check("og", "Open Graph", "pass", 3, 3, "og:title/og:description present."))
|
|
499
|
+
else:
|
|
500
|
+
checks.append(_check("og", "Open Graph", "warn", 1, 3, "No Open Graph tags.", "Add og:title and og:description; several engines use them as the snippet."))
|
|
501
|
+
|
|
502
|
+
# 6. sitemap
|
|
503
|
+
sitemap_url = robots["sitemaps"][0] if robots.get("sitemaps") else base + "/sitemap.xml"
|
|
504
|
+
sm = fetch(sitemap_url, BROWSER_UA, "application/xml,text/xml,*/*", 200_000)
|
|
505
|
+
sm_ok = sm["status"] == 200 and ("<urlset" in sm["body"] or "<sitemapindex" in sm["body"])
|
|
506
|
+
if not sm_ok and sub_base and not robots.get("sitemaps"):
|
|
507
|
+
alt_sm = fetch(sub_base + "/sitemap.xml", BROWSER_UA, "application/xml,text/xml,*/*", 200_000)
|
|
508
|
+
if alt_sm["status"] == 200 and ("<urlset" in alt_sm["body"] or "<sitemapindex" in alt_sm["body"]):
|
|
509
|
+
sitemap_url, sm, sm_ok = sub_base + "/sitemap.xml", alt_sm, True
|
|
510
|
+
if sm_ok:
|
|
511
|
+
checks.append(_check("sitemap", "Sitemap", "pass", 5, 5, f"{sitemap_url} is a valid sitemap" + (" (listed in robots.txt)." if robots.get("sitemaps") else " (default path; not listed in robots.txt).")))
|
|
512
|
+
else:
|
|
513
|
+
checks.append(_check("sitemap", "Sitemap", "warn", 0, 5, f"{sitemap_url} returned {sm['status']}" + ("" if sm["status"] != 200 else " but is not XML") + ".",
|
|
514
|
+
"Publish /sitemap.xml and add a `Sitemap:` line to robots.txt."))
|
|
515
|
+
|
|
516
|
+
if lang:
|
|
517
|
+
checks.append(_check("lang", "Language declared", "pass", 2, 2, f"<html lang={lang}>."))
|
|
518
|
+
else:
|
|
519
|
+
checks.append(_check("lang", "Language declared", "warn", 0, 2, "No lang attribute on <html>.", "Add <html lang=\"en\"> (or your language)."))
|
|
520
|
+
|
|
521
|
+
max_total = sum(c["max"] for c in checks)
|
|
522
|
+
got = sum(c["points"] for c in checks)
|
|
523
|
+
score = int(round(100 * got / max_total)) if max_total else 0
|
|
524
|
+
grade = "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 60 else "D" if score >= 40 else "F"
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
"url": url, "final_url": final, "reachable": True, "score": score, "grade": grade,
|
|
528
|
+
"checks": checks, "bots": bots,
|
|
529
|
+
"page": {"title": title, "description": desc, "canonical": canonical, "lang": lang, "words": words,
|
|
530
|
+
"jsonld_types": sorted(set(ld_types)), "h1_count": h1s, "status": home["status"], "ms": home["ms"],
|
|
531
|
+
"last_modified": home["headers"].get("last-modified")},
|
|
532
|
+
"llms_txt": llms, "robots_txt_present": robots_present, "sitemap": sitemap_url if sm_ok else None,
|
|
533
|
+
"version": __version__, "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def draft_llms_txt(target: str, max_links: int = 25) -> str:
|
|
538
|
+
"""Build a starter llms.txt from the homepage: H1 title, '> summary', and same-site links with anchor text."""
|
|
539
|
+
url = normalise_url(target)
|
|
540
|
+
home = fetch(url, accept="text/html,*/*")
|
|
541
|
+
if home["status"] == 0:
|
|
542
|
+
return f"# {urllib.parse.urlsplit(url).netloc}\n\n> (could not fetch the homepage: {home['error']})\n"
|
|
543
|
+
body, final = home["body"], home["final_url"]
|
|
544
|
+
host = urllib.parse.urlsplit(final).netloc
|
|
545
|
+
ld = _jsonld(body)
|
|
546
|
+
name = None
|
|
547
|
+
for it in ld:
|
|
548
|
+
if it.get("@type") in ("Organization", "WebSite") and it.get("name"):
|
|
549
|
+
name = str(it["name"])
|
|
550
|
+
break
|
|
551
|
+
name = name or _meta(body, "og:site_name") or (_title(body) or host).split(" | ")[0].split(" - ")[0].strip() or host
|
|
552
|
+
summary = _meta(body, "description") or _meta(body, "og:description") or f"{name} ({host})."
|
|
553
|
+
links = _links(body, final)
|
|
554
|
+
lines = [f"# {name}", "", f"> {summary}", "",
|
|
555
|
+
f"This file follows the llms.txt convention (https://llmstxt.org). Site: {final}", "", "## Key pages", ""]
|
|
556
|
+
if not links:
|
|
557
|
+
lines.append(f"- [{name}]({final}): homepage")
|
|
558
|
+
for u, text in links[:max_links]:
|
|
559
|
+
label = text or u.rsplit("/", 1)[-1].replace("-", " ").replace("_", " ") or u
|
|
560
|
+
lines.append(f"- [{label}]({u})")
|
|
561
|
+
lines += ["", "## Optional", "", "- [Sitemap](" + origin(final) + "/sitemap.xml)", ""]
|
|
562
|
+
return "\n".join(lines)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# ----------------------------------------------------------------------------- report
|
|
566
|
+
|
|
567
|
+
def format_report(r: Dict[str, Any]) -> str:
|
|
568
|
+
out = [f"citable {__version__}: {r['url']}"]
|
|
569
|
+
if not r.get("reachable"):
|
|
570
|
+
out.append(f" unreachable: {r.get('error')}")
|
|
571
|
+
return "\n".join(out)
|
|
572
|
+
out.append(f" score {r['score']}/100 grade {r['grade']} (final URL {r['final_url']})")
|
|
573
|
+
out.append("")
|
|
574
|
+
marks = {"pass": "PASS", "warn": "WARN", "fail": "FAIL", "info": "INFO"}
|
|
575
|
+
for c in r["checks"]:
|
|
576
|
+
pts = f"{c['points']:>2}/{c['max']:<2}" if c["max"] else " "
|
|
577
|
+
out.append(f" [{marks[c['status']]}] {pts} {c['title']}: {c['detail']}")
|
|
578
|
+
if c["fix"] and c["status"] in ("warn", "fail"):
|
|
579
|
+
out.append(f" fix: {c['fix']}")
|
|
580
|
+
out.append("")
|
|
581
|
+
out.append(" Bot Vendor Role robots.txt live fetch")
|
|
582
|
+
for name, e in r["bots"].items():
|
|
583
|
+
live = ""
|
|
584
|
+
if "live_status" in e:
|
|
585
|
+
live = f"HTTP {e['live_status']}" if e["live_status"] else f"error ({e.get('live_error', '')[:40]})"
|
|
586
|
+
if e.get("live_blocked"):
|
|
587
|
+
live += " <- BLOCKED"
|
|
588
|
+
out.append(f" {name:<19} {e['vendor']:<22} {e['role']:<7} {e['robots']:<12} {live}")
|
|
589
|
+
out.append("")
|
|
590
|
+
out.append(" Watch this site weekly and get an email when a bot gets blocked or llms.txt breaks: https://github.com/genzagents/citable#watch")
|
|
591
|
+
return "\n".join(out)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ----------------------------------------------------------------------------- MCP server (stdio)
|
|
595
|
+
|
|
596
|
+
MCP_TOOLS = [
|
|
597
|
+
{
|
|
598
|
+
"name": "check_site",
|
|
599
|
+
"description": "Check whether AI answer engines (ChatGPT search, Claude, Perplexity) can read and cite a website. "
|
|
600
|
+
"Fetches robots.txt, llms.txt, sitemap and the homepage as each AI bot, scores 0-100 and lists fixes.",
|
|
601
|
+
"inputSchema": {"type": "object", "properties": {
|
|
602
|
+
"url": {"type": "string", "description": "Site URL or domain, e.g. example.com"},
|
|
603
|
+
"live": {"type": "boolean", "description": "Also fetch the homepage as each AI bot to detect WAF/CDN blocks (default true)", "default": True},
|
|
604
|
+
}, "required": ["url"]},
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
"name": "draft_llms_txt",
|
|
608
|
+
"description": "Draft a starter /llms.txt for a website from its homepage: title, summary and links to key pages.",
|
|
609
|
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "Site URL or domain"}}, "required": ["url"]},
|
|
610
|
+
},
|
|
611
|
+
]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _mcp_send(obj: Dict[str, Any]) -> None:
|
|
615
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
616
|
+
sys.stdout.flush()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def mcp_serve() -> None:
|
|
620
|
+
"""Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No dependencies."""
|
|
621
|
+
for line in sys.stdin:
|
|
622
|
+
line = line.strip()
|
|
623
|
+
if not line:
|
|
624
|
+
continue
|
|
625
|
+
try:
|
|
626
|
+
msg = json.loads(line)
|
|
627
|
+
except Exception:
|
|
628
|
+
_mcp_send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
629
|
+
continue
|
|
630
|
+
method, mid, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
|
|
631
|
+
if mid is None: # notification
|
|
632
|
+
continue
|
|
633
|
+
try:
|
|
634
|
+
if method == "initialize":
|
|
635
|
+
result = {"protocolVersion": params.get("protocolVersion", "2025-06-18"),
|
|
636
|
+
"capabilities": {"tools": {}},
|
|
637
|
+
"serverInfo": {"name": "citable", "version": __version__}}
|
|
638
|
+
elif method == "ping":
|
|
639
|
+
result = {}
|
|
640
|
+
elif method == "tools/list":
|
|
641
|
+
result = {"tools": MCP_TOOLS}
|
|
642
|
+
elif method == "tools/call":
|
|
643
|
+
name, args = params.get("name"), params.get("arguments") or {}
|
|
644
|
+
if name == "check_site":
|
|
645
|
+
r = check_site(args["url"], live=bool(args.get("live", True)))
|
|
646
|
+
result = {"content": [{"type": "text", "text": format_report(r)}], "structuredContent": r, "isError": False}
|
|
647
|
+
elif name == "draft_llms_txt":
|
|
648
|
+
result = {"content": [{"type": "text", "text": draft_llms_txt(args["url"])}], "isError": False}
|
|
649
|
+
else:
|
|
650
|
+
raise KeyError(f"unknown tool {name}")
|
|
651
|
+
else:
|
|
652
|
+
_mcp_send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}})
|
|
653
|
+
continue
|
|
654
|
+
_mcp_send({"jsonrpc": "2.0", "id": mid, "result": result})
|
|
655
|
+
except Exception as e:
|
|
656
|
+
_mcp_send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32603, "message": f"{type(e).__name__}: {e}"}})
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ----------------------------------------------------------------------------- CLI
|
|
660
|
+
|
|
661
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
662
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
663
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
664
|
+
print(__doc__.strip())
|
|
665
|
+
return 0
|
|
666
|
+
if argv[0] in ("-V", "--version"):
|
|
667
|
+
print(__version__)
|
|
668
|
+
return 0
|
|
669
|
+
if argv[0] == "mcp":
|
|
670
|
+
mcp_serve()
|
|
671
|
+
return 0
|
|
672
|
+
if argv[0] in ("watch", "track", "hosted"):
|
|
673
|
+
import importlib
|
|
674
|
+
import os
|
|
675
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # bare-script runs
|
|
676
|
+
return importlib.import_module("citable." + argv[0]).main(argv[1:])
|
|
677
|
+
target = argv[0]
|
|
678
|
+
flags = set(argv[1:])
|
|
679
|
+
if "--llms-txt" in flags:
|
|
680
|
+
print(draft_llms_txt(target), end="")
|
|
681
|
+
return 0
|
|
682
|
+
r = check_site(target, live="--no-live" not in flags)
|
|
683
|
+
if "--json" in flags:
|
|
684
|
+
print(json.dumps(r, indent=2))
|
|
685
|
+
else:
|
|
686
|
+
print(format_report(r))
|
|
687
|
+
if not r.get("reachable"):
|
|
688
|
+
return 2
|
|
689
|
+
return 0 if r["score"] >= int(dict(f.split("=", 1) for f in flags if f.startswith("--min-score=")).get("--min-score", 0)) else 1
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
if __name__ == "__main__":
|
|
693
|
+
sys.exit(main())
|