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 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())