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/track.py ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """citable track: does ChatGPT, Claude or Perplexity actually cite your site when people ask the
3
+ questions you want to be the answer to? Ask each engine your prompts, record which domains it cites,
4
+ and keep a GitHub issue up to date so you get an email the week a citation is lost.
5
+
6
+ python3 -m citable.track DOMAIN "best X for Y" "how do I Z" [--engines=claude-code,anthropic,openai,perplexity]
7
+ [--prompts-file=prompts.txt] [--brand="Name"] [--json] [--issue] [--repo=OWNER/NAME]
8
+
9
+ Engines and what they need:
10
+ claude-code the `claude` CLI on PATH (Claude Code, uses its WebSearch tool; no API key)
11
+ anthropic ANTHROPIC_API_KEY (Messages API with the web_search server tool; --model, default claude-opus-5-5)
12
+ openai OPENAI_API_KEY (Responses API with the web_search tool; --openai-model, default gpt-5)
13
+ perplexity PERPLEXITY_API_KEY (chat completions, model sonar; --perplexity-model)
14
+ With no --engines, every engine whose key (or CLI) is present runs. Raw HTTP on purpose: zero dependencies.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import concurrent.futures
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from collections import Counter
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
32
+ from citable import __version__ # noqa: E402
33
+ from citable.watch import TITLE_PREFIX, find_issue, gh, parse_state # noqa: E402
34
+
35
+ URL_RE = re.compile(r"https?://[^\s<>()\[\]\"']+")
36
+ ENGINES = ("claude-code", "anthropic", "openai", "perplexity")
37
+
38
+
39
+ # ----------------------------------------------------------------------------- pure logic (tested)
40
+
41
+ def norm_host(url_or_host: str) -> str:
42
+ h = url_or_host.strip().lower()
43
+ if "://" in h:
44
+ h = urllib.parse.urlsplit(h).netloc
45
+ h = h.split("@")[-1].split(":")[0]
46
+ return h[4:] if h.startswith("www.") else h
47
+
48
+
49
+ def same_site(url: str, domain: str) -> bool:
50
+ h, d = norm_host(url), norm_host(domain)
51
+ return h == d or h.endswith("." + d)
52
+
53
+
54
+ def urls_in(text: str) -> List[str]:
55
+ return [u.rstrip(".,;:!?") for u in URL_RE.findall(text or "")]
56
+
57
+
58
+ def judge(domain: str, text: str, cited_urls: List[str], brand: str = "") -> Dict[str, Any]:
59
+ """Was the site cited (a URL on the domain), or merely mentioned (domain or brand in the text)?"""
60
+ all_urls = list(dict.fromkeys(cited_urls + urls_in(text)))
61
+ ours = [u for u in all_urls if same_site(u, domain)]
62
+ hosts = Counter(norm_host(u) for u in all_urls if not same_site(u, domain))
63
+ low = (text or "").lower()
64
+ mentioned = norm_host(domain) in low or (bool(brand) and brand.lower() in low)
65
+ return {"cited": bool(ours), "mentioned": mentioned or bool(ours), "our_urls": ours[:5],
66
+ "other_hosts": hosts.most_common(10), "excerpt": re.sub(r"\s+", " ", (text or "")[:280])}
67
+
68
+
69
+ def parse_anthropic(resp: Dict[str, Any]) -> Tuple[str, List[str]]:
70
+ """Text plus cited URLs from a Messages API response that used the web_search server tool."""
71
+ text, cited = [], []
72
+ for block in resp.get("content", []):
73
+ if block.get("type") == "text":
74
+ text.append(block.get("text", ""))
75
+ for c in block.get("citations") or []:
76
+ if c.get("url"):
77
+ cited.append(c["url"])
78
+ return "\n".join(text), cited
79
+
80
+
81
+ def parse_openai(resp: Dict[str, Any]) -> Tuple[str, List[str]]:
82
+ """Text plus url_citation annotations from a Responses API result."""
83
+ text, cited = [], []
84
+ for item in resp.get("output", []):
85
+ if item.get("type") != "message":
86
+ continue
87
+ for part in item.get("content", []):
88
+ if part.get("type") == "output_text":
89
+ text.append(part.get("text", ""))
90
+ for a in part.get("annotations") or []:
91
+ if a.get("type") == "url_citation" and a.get("url"):
92
+ cited.append(a["url"])
93
+ return "\n".join(text), cited
94
+
95
+
96
+ def parse_perplexity(resp: Dict[str, Any]) -> Tuple[str, List[str]]:
97
+ text = ""
98
+ try:
99
+ text = resp["choices"][0]["message"]["content"] or ""
100
+ except (KeyError, IndexError, TypeError):
101
+ pass
102
+ cited = [u for u in resp.get("citations") or [] if isinstance(u, str)]
103
+ cited += [r.get("url") for r in resp.get("search_results") or [] if isinstance(r, dict) and r.get("url")]
104
+ return text, list(dict.fromkeys(cited))
105
+
106
+
107
+ def visibility(results: List[Dict[str, Any]]) -> int:
108
+ ok = [r for r in results if not r.get("error")]
109
+ return round(100 * sum(1 for r in ok if r["cited"]) / len(ok)) if ok else 0
110
+
111
+
112
+ def snapshot(domain: str, results: List[Dict[str, Any]]) -> Dict[str, Any]:
113
+ pairs = {f"{r['engine']}|{r['prompt']}": ("error" if r.get("error") else r["cited"]) for r in results}
114
+ return {"domain": norm_host(domain), "visibility": visibility(results), "pairs": pairs}
115
+
116
+
117
+ def diff(prev: Optional[Dict[str, Any]], cur: Dict[str, Any]) -> Tuple[List[str], List[str]]:
118
+ worse, better = [], []
119
+ if not prev:
120
+ return worse, better
121
+ pp = prev.get("pairs", {})
122
+ for key, now in cur["pairs"].items():
123
+ before = pp.get(key)
124
+ if before is None or now == "error" or before == "error":
125
+ continue
126
+ engine, prompt = key.split("|", 1)
127
+ if before and not now:
128
+ worse.append(f"{engine} no longer cites you for \"{prompt}\".")
129
+ elif now and not before:
130
+ better.append(f"{engine} now cites you for \"{prompt}\".")
131
+ if cur["visibility"] < prev.get("visibility", 0):
132
+ worse.append(f"Visibility fell from {prev['visibility']}% to {cur['visibility']}%.")
133
+ elif cur["visibility"] > prev.get("visibility", 0):
134
+ better.append(f"Visibility rose from {prev['visibility']}% to {cur['visibility']}%.")
135
+ return worse, better
136
+
137
+
138
+ # ----------------------------------------------------------------------------- engines
139
+
140
+ def _post(url: str, headers: Dict[str, str], body: Dict[str, Any], timeout: int = 180) -> Dict[str, Any]:
141
+ req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), method="POST",
142
+ headers={"content-type": "application/json", "user-agent": f"citable/{__version__}", **headers})
143
+ try:
144
+ with urllib.request.urlopen(req, timeout=timeout) as r:
145
+ return json.loads(r.read().decode("utf-8", errors="replace"))
146
+ except urllib.error.HTTPError as e:
147
+ detail = e.read().decode("utf-8", errors="replace")[:300]
148
+ raise RuntimeError(f"HTTP {e.code}: {detail}") from None
149
+
150
+
151
+ def ask_anthropic(prompt: str, opts: Dict[str, str]) -> Tuple[str, List[str]]:
152
+ resp = _post("https://api.anthropic.com/v1/messages",
153
+ {"x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01",
154
+ "anthropic-beta": "server-side-fallback-2026-07-01"},
155
+ {"model": opts.get("model") or "claude-opus-5-5", "max_tokens": 4096,
156
+ "output_config": {"effort": "low"}, "fallbacks": "default",
157
+ "tools": [{"type": "web_search_20260209", "name": "web_search", "max_uses": 5}],
158
+ "messages": [{"role": "user", "content": prompt}]})
159
+ if resp.get("stop_reason") == "refusal":
160
+ raise RuntimeError("refused: " + str((resp.get("stop_details") or {}).get("category")))
161
+ return parse_anthropic(resp)
162
+
163
+
164
+ def ask_openai(prompt: str, opts: Dict[str, str]) -> Tuple[str, List[str]]:
165
+ resp = _post("https://api.openai.com/v1/responses",
166
+ {"authorization": "Bearer " + os.environ["OPENAI_API_KEY"]},
167
+ {"model": opts.get("openai_model") or "gpt-5", "tools": [{"type": "web_search"}], "input": prompt})
168
+ return parse_openai(resp)
169
+
170
+
171
+ def ask_perplexity(prompt: str, opts: Dict[str, str]) -> Tuple[str, List[str]]:
172
+ resp = _post("https://api.perplexity.ai/chat/completions",
173
+ {"authorization": "Bearer " + os.environ["PERPLEXITY_API_KEY"]},
174
+ {"model": opts.get("perplexity_model") or "sonar", "messages": [{"role": "user", "content": prompt}]})
175
+ return parse_perplexity(resp)
176
+
177
+
178
+ def ask_claude_code(prompt: str, opts: Dict[str, str]) -> Tuple[str, List[str]]:
179
+ cmd = [shutil.which("claude") or "claude", "-p",
180
+ prompt + "\n\nSearch the web before answering. Answer the way you would for any user, then end with a line "
181
+ "'Sources:' followed by the URLs you relied on, one per line.",
182
+ "--allowedTools", "WebSearch", "--output-format", "json"]
183
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=600,
184
+ cwd=opts.get("cwd") or os.path.expanduser("~"))
185
+ if p.returncode != 0:
186
+ raise RuntimeError(f"claude exited {p.returncode}: {p.stderr.strip()[:200]}")
187
+ try:
188
+ d = json.loads(p.stdout)
189
+ except ValueError:
190
+ raise RuntimeError("claude returned no JSON: " + p.stdout[:200]) from None
191
+ if d.get("is_error"):
192
+ raise RuntimeError("claude error: " + str(d.get("result"))[:200])
193
+ text = d.get("result") or ""
194
+ return text, urls_in(text)
195
+
196
+
197
+ ASK = {"anthropic": ask_anthropic, "openai": ask_openai, "perplexity": ask_perplexity, "claude-code": ask_claude_code}
198
+
199
+
200
+ def available_engines() -> List[str]:
201
+ out = []
202
+ if shutil.which("claude"):
203
+ out.append("claude-code")
204
+ for name, var in (("anthropic", "ANTHROPIC_API_KEY"), ("openai", "OPENAI_API_KEY"), ("perplexity", "PERPLEXITY_API_KEY")):
205
+ if os.environ.get(var):
206
+ out.append(name)
207
+ return out
208
+
209
+
210
+ def run_one(domain: str, prompt: str, engine: str, brand: str, opts: Dict[str, str]) -> Dict[str, Any]:
211
+ base = {"engine": engine, "prompt": prompt}
212
+ try:
213
+ text, cited = ASK[engine](prompt, opts)
214
+ except Exception as e: # noqa: BLE001 - every failure becomes a row, the run goes on
215
+ return {**base, "error": str(e)[:300], "cited": False, "mentioned": False, "our_urls": [], "other_hosts": [], "excerpt": ""}
216
+ return {**base, **judge(domain, text, cited, brand)}
217
+
218
+
219
+ def track(domain: str, prompts: List[str], engines: List[str], brand: str = "", opts: Optional[Dict[str, str]] = None,
220
+ workers: int = 4) -> Dict[str, Any]:
221
+ opts = opts or {}
222
+ jobs = [(p, e) for p in prompts for e in engines]
223
+ results: List[Dict[str, Any]] = [{}] * len(jobs)
224
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
225
+ futs = {ex.submit(run_one, domain, p, e, brand, opts): i for i, (p, e) in enumerate(jobs)}
226
+ for f in concurrent.futures.as_completed(futs):
227
+ results[futs[f]] = f.result()
228
+ competitors: Counter = Counter()
229
+ for r in results:
230
+ for host, n in r.get("other_hosts", []):
231
+ competitors[host] += 1 # count answers, not links
232
+ return {"domain": norm_host(domain), "brand": brand, "prompts": prompts, "engines": engines, "results": results,
233
+ "visibility": visibility(results), "competitors": competitors.most_common(10), "version": __version__}
234
+
235
+
236
+ # ----------------------------------------------------------------------------- output
237
+
238
+ def render_markdown(t: Dict[str, Any], run_url: str = "") -> str:
239
+ out = [f"## citable track: {t['domain']}", "",
240
+ f"**Visibility {t['visibility']}%**: share of prompt-and-engine pairs where an answer linked to your site."
241
+ + (f" [run]({run_url})" if run_url else ""), "",
242
+ "| Prompt | " + " | ".join(t["engines"]) + " |", "|---|" + "---|" * len(t["engines"])]
243
+ by = {(r["engine"], r["prompt"]): r for r in t["results"]}
244
+ for p in t["prompts"]:
245
+ cells = []
246
+ for e in t["engines"]:
247
+ r = by.get((e, p), {})
248
+ cells.append("error" if r.get("error") else "**cited**" if r.get("cited") else "mentioned" if r.get("mentioned") else "no")
249
+ out.append(f"| {p} | " + " | ".join(cells) + " |")
250
+ if t["competitors"]:
251
+ out += ["", "**Who gets cited instead** (answers citing them): " + ", ".join(f"{h} ({n})" for h, n in t["competitors"][:8])]
252
+ errs = [r for r in t["results"] if r.get("error")]
253
+ if errs:
254
+ out += ["", "**Errors**", ""] + [f"- {r['engine']}, \"{r['prompt']}\": {r['error']}" for r in errs[:10]]
255
+ return "\n".join(out)
256
+
257
+
258
+ def render_body(t: Dict[str, Any], snap: Dict[str, Any], run_url: str) -> str:
259
+ return (render_markdown(t, run_url) + "\n\n<sub>Kept up to date by [citable track](https://github.com/genzagents/citable#track) "
260
+ f"{__version__}. It comments here when a citation is lost or gained, so subscribe to this issue to get the email.</sub>\n\n"
261
+ f"<!-- citable-state {json.dumps(snap, separators=(',', ':'))} -->")
262
+
263
+
264
+ def upsert_issue(repo: str, t: Dict[str, Any], snap: Dict[str, Any], notify: str, run_url: str, label: str) -> Dict[str, Any]:
265
+ title = TITLE_PREFIX.replace("watch", "track") + t["domain"]
266
+ existing = find_issue(repo, title)
267
+ prev = parse_state(existing["body"]) if existing else None
268
+ worse, better = diff(prev, snap)
269
+ body = render_body(t, snap, run_url)
270
+ if existing is None:
271
+ try:
272
+ gh("label", "create", label, "--repo", repo, "--color", "5319e7", "--description", "citable results", "--force")
273
+ except RuntimeError:
274
+ pass
275
+ out = gh("issue", "create", "--repo", repo, "--title", title, "--body", body, "--label", label)
276
+ return {"number": int(out.strip().rsplit("/", 1)[-1]), "created": True, "worse": worse, "better": better}
277
+ number = int(existing["number"])
278
+ gh("issue", "edit", str(number), "--repo", repo, "--body", body)
279
+ if worse or better:
280
+ if worse and existing.get("state", "").upper() == "CLOSED":
281
+ gh("issue", "reopen", str(number), "--repo", repo)
282
+ lines = []
283
+ if worse:
284
+ lines.append((f"{notify} " if notify else "") + f"**Lost citations on {t['domain']}**")
285
+ lines += [f"- {w}" for w in worse]
286
+ if better:
287
+ lines.append("**Gained**")
288
+ lines += [f"- {b}" for b in better]
289
+ lines.append(f"\nVisibility now {t['visibility']}%." + (f" Full report: {run_url}" if run_url else ""))
290
+ gh("issue", "comment", str(number), "--repo", repo, "--body", "\n".join(lines))
291
+ return {"number": number, "created": False, "worse": worse, "better": better}
292
+
293
+
294
+ # ----------------------------------------------------------------------------- entry point
295
+
296
+ def _flag(flags: List[str], name: str, default: str = "") -> str:
297
+ for f in flags:
298
+ if f.startswith(name + "="):
299
+ return f.split("=", 1)[1]
300
+ return default
301
+
302
+
303
+ def main(argv: Optional[List[str]] = None) -> int:
304
+ argv = list(sys.argv[1:] if argv is None else argv)
305
+ if not argv or argv[0] in ("-h", "--help"):
306
+ print(__doc__.strip())
307
+ return 0
308
+ flags = [a for a in argv if a.startswith("--")]
309
+ args = [a for a in argv if not a.startswith("--")]
310
+ domain, prompts = args[0], [p.strip() for p in args[1:] if p.strip()]
311
+ pf = _flag(flags, "--prompts-file")
312
+ if pf:
313
+ with open(pf, encoding="utf-8") as f:
314
+ prompts += [l.strip() for l in f if l.strip() and not l.startswith("#")]
315
+ if not prompts:
316
+ print("track: give at least one prompt (positional or --prompts-file)", file=sys.stderr)
317
+ return 2
318
+ engines = [e for e in _flag(flags, "--engines").split(",") if e] or available_engines()
319
+ bad = [e for e in engines if e not in ENGINES]
320
+ if bad:
321
+ print(f"track: unknown engine(s) {bad}; choose from {ENGINES}", file=sys.stderr)
322
+ return 2
323
+ if not engines:
324
+ print("track: no engine available: install Claude Code (claude on PATH) or set ANTHROPIC_API_KEY, OPENAI_API_KEY or PERPLEXITY_API_KEY", file=sys.stderr)
325
+ return 2
326
+ opts = {"model": _flag(flags, "--model"), "openai_model": _flag(flags, "--openai-model"),
327
+ "perplexity_model": _flag(flags, "--perplexity-model")}
328
+ t = track(domain, prompts, engines, _flag(flags, "--brand"), opts, workers=int(_flag(flags, "--workers", "4")))
329
+ repo = _flag(flags, "--repo", os.environ.get("GITHUB_REPOSITORY", ""))
330
+ run_url = ""
331
+ if os.environ.get("GITHUB_RUN_ID") and repo:
332
+ run_url = f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
333
+ md = render_markdown(t, run_url)
334
+ snap = snapshot(domain, t["results"])
335
+ regressed = False
336
+ if "--issue" in flags:
337
+ if not repo:
338
+ print("track: --issue needs --repo or GITHUB_REPOSITORY", file=sys.stderr)
339
+ else:
340
+ notify = _flag(flags, "--notify")
341
+ notify = ("@" + notify) if notify and not notify.startswith("@") else notify
342
+ try:
343
+ res = upsert_issue(repo, t, snap, notify, run_url, _flag(flags, "--label", "citable"))
344
+ t["issue"] = f"https://github.com/{repo}/issues/{res['number']}"
345
+ regressed = bool(res["worse"])
346
+ for w in res["worse"]:
347
+ print(f" LOST: {w}", file=sys.stderr)
348
+ for b in res["better"]:
349
+ print(f" gained: {b}", file=sys.stderr)
350
+ except RuntimeError as e:
351
+ print(f"track: {e}", file=sys.stderr)
352
+ if "--json" in flags:
353
+ print(json.dumps(t, indent=2))
354
+ else:
355
+ print(md)
356
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
357
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
358
+ f.write(md + "\n")
359
+ if os.environ.get("GITHUB_OUTPUT"):
360
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
361
+ f.write(f"visibility={t['visibility']}\ncitations_lost={'true' if regressed else 'false'}\n")
362
+ min_vis = int(_flag(flags, "--min-visibility", "0"))
363
+ if t["visibility"] < min_vis:
364
+ print(f"visibility {t['visibility']}% is below --min-visibility={min_vis}", file=sys.stderr)
365
+ return 1
366
+ return 1 if (regressed and "--no-fail-on-regression" not in flags) else 0
367
+
368
+
369
+ if __name__ == "__main__":
370
+ sys.exit(main())
citable/watch.py ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """citable watch: check one or more sites, remember the result in a GitHub issue, and raise the alarm
3
+ (an issue comment, which GitHub emails to everyone subscribed) the moment something regresses:
4
+ an answer bot gets blocked, /llms.txt disappears, the site goes down, or the score drops.
5
+
6
+ python3 -m citable.watch example.com other.com [--min-score=N] [--no-live] [--no-issue]
7
+ [--repo=OWNER/NAME] [--notify=@login]
8
+
9
+ Designed for GitHub Actions (see action.yml and docs/watch.yml) but runs anywhere `gh` is logged in.
10
+ Environment used when present: GH_TOKEN, GITHUB_REPOSITORY, GITHUB_STEP_SUMMARY, GITHUB_OUTPUT,
11
+ GITHUB_SERVER_URL, GITHUB_RUN_ID. Zero dependencies beyond the `gh` CLI for the issue part.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ import urllib.parse
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24
+ from citable import __version__, check_site, format_report # noqa: E402
25
+
26
+ STATE_RE = re.compile(r"<!-- citable-state (\{.*?\}) -->", re.S)
27
+ TITLE_PREFIX = "citable watch: "
28
+
29
+
30
+ # ----------------------------------------------------------------------------- pure logic (tested)
31
+
32
+ def snapshot(r: Dict[str, Any]) -> Dict[str, Any]:
33
+ """The few facts worth comparing between runs."""
34
+ bots: Dict[str, str] = {}
35
+ for name, e in r.get("bots", {}).items():
36
+ if e.get("role") != "answer":
37
+ continue
38
+ bots[name] = "edge" if e.get("live_blocked") else ("robots" if e.get("robots") == "block" else "ok")
39
+ return {
40
+ "score": int(r.get("score", 0)),
41
+ "grade": r.get("grade", "F"),
42
+ "reachable": bool(r.get("reachable")),
43
+ "bots": bots,
44
+ "llms_txt": bool(r.get("llms_txt", {}).get("present")),
45
+ "failing": sorted(c["id"] for c in r.get("checks", []) if c.get("status") == "fail"),
46
+ "titles": {c["id"]: c["title"] for c in r.get("checks", [])},
47
+ "checked_at": r.get("checked_at"),
48
+ }
49
+
50
+
51
+ def diff(prev: Optional[Dict[str, Any]], cur: Dict[str, Any]) -> Tuple[List[str], List[str]]:
52
+ """Returns (worse, better): human sentences describing what changed since the previous snapshot."""
53
+ worse: List[str] = []
54
+ better: List[str] = []
55
+ if not prev:
56
+ return worse, better
57
+ if prev.get("reachable") and not cur["reachable"]:
58
+ worse.append("The site is unreachable (it was reachable last time).")
59
+ return worse, better
60
+ if not prev.get("reachable") and cur["reachable"]:
61
+ better.append("The site is reachable again.")
62
+ pbots = prev.get("bots", {})
63
+ for bot, state in cur["bots"].items():
64
+ before = pbots.get(bot, "ok")
65
+ if state != "ok" and before == "ok":
66
+ how = "at the edge (CDN/WAF returns an error to it)" if state == "edge" else "by robots.txt"
67
+ worse.append(f"{bot} is now blocked {how}.")
68
+ elif state == "ok" and before != "ok":
69
+ better.append(f"{bot} is no longer blocked.")
70
+ if prev.get("llms_txt") and not cur["llms_txt"]:
71
+ worse.append("/llms.txt is gone (it was present last time).")
72
+ elif not prev.get("llms_txt") and cur["llms_txt"]:
73
+ better.append("/llms.txt is now present.")
74
+ titles = {**prev.get("titles", {}), **cur.get("titles", {})}
75
+ for cid in sorted(set(cur["failing"]) - set(prev.get("failing", []))):
76
+ if cid in ("edge", "robots", "llms_txt"):
77
+ continue # already reported above in plainer words
78
+ worse.append(f"Now failing: {titles.get(cid, cid)}.")
79
+ for cid in sorted(set(prev.get("failing", [])) - set(cur["failing"])):
80
+ if cid in ("edge", "robots", "llms_txt"):
81
+ continue
82
+ better.append(f"Fixed: {titles.get(cid, cid)}.")
83
+ if cur["score"] < prev.get("score", 0):
84
+ worse.append(f"Score fell from {prev['score']} to {cur['score']}.")
85
+ elif cur["score"] > prev.get("score", 0):
86
+ better.append(f"Score rose from {prev['score']} to {cur['score']}.")
87
+ return worse, better
88
+
89
+
90
+ def parse_state(body: str) -> Optional[Dict[str, Any]]:
91
+ m = STATE_RE.search(body or "")
92
+ if not m:
93
+ return None
94
+ try:
95
+ return json.loads(m.group(1))
96
+ except ValueError:
97
+ return None
98
+
99
+
100
+ def host_of(url: str) -> str:
101
+ u = url if "://" in url else "https://" + url
102
+ p = urllib.parse.urlsplit(u)
103
+ return (p.netloc + (p.path.rstrip("/") if p.path not in ("", "/") else "")).lower()
104
+
105
+
106
+ def issue_title(url: str) -> str:
107
+ return TITLE_PREFIX + host_of(url)
108
+
109
+
110
+ def render_body(r: Dict[str, Any], snap: Dict[str, Any], run_url: str = "") -> str:
111
+ out = [f"## {issue_title(r['url'])}", ""]
112
+ if not r.get("reachable"):
113
+ out.append(f"**Unreachable**: {r.get('error')}")
114
+ else:
115
+ line = f"**Score {r['score']}/100, grade {r['grade']}** · checked {r.get('checked_at', '')}"
116
+ if run_url:
117
+ line += f" · [run]({run_url})"
118
+ out += [line, "", "| Answer bot | robots.txt | live fetch |", "|---|---|---|"]
119
+ for name, e in r["bots"].items():
120
+ if e.get("role") != "answer":
121
+ continue
122
+ live = ""
123
+ if "live_status" in e:
124
+ live = f"HTTP {e['live_status']}" if e["live_status"] else "error"
125
+ if e.get("live_blocked"):
126
+ live += " **BLOCKED**"
127
+ out.append(f"| {name} | {e['robots']} | {live} |")
128
+ bad = [c for c in r["checks"] if c["status"] in ("fail", "warn")]
129
+ if bad:
130
+ out += ["", "**Needs fixing**", ""]
131
+ for c in bad:
132
+ fix = f" Fix: {c['fix']}" if c.get("fix") else ""
133
+ out.append(f"- [{c['status'].upper()}] {c['title']}: {c['detail']}{fix}")
134
+ out += ["", f"<sub>Kept up to date by [citable watch](https://github.com/genzagents/citable#watch) "
135
+ f"{__version__}. It comments here only when something changes, so subscribe to this issue "
136
+ f"to get an email the moment an AI bot is blocked or your llms.txt breaks.</sub>",
137
+ "", f"<!-- citable-state {json.dumps(snap, separators=(',', ':'))} -->"]
138
+ return "\n".join(out)
139
+
140
+
141
+ def render_comment(worse: List[str], better: List[str], r: Dict[str, Any], notify: str, run_url: str) -> str:
142
+ out = []
143
+ if worse:
144
+ mention = f"{notify} " if notify else ""
145
+ out.append(f"{mention}**Something regressed on {host_of(r['url'])}**")
146
+ out += [f"- {w}" for w in worse]
147
+ if better:
148
+ out.append("**Improved**" if not worse else "")
149
+ out += [f"- {b}" for b in better]
150
+ if r.get("reachable"):
151
+ out.append(f"\nScore now {r['score']}/100 ({r['grade']}).")
152
+ if run_url:
153
+ out.append(f"Full report: {run_url}")
154
+ return "\n".join(x for x in out if x is not None)
155
+
156
+
157
+ # ----------------------------------------------------------------------------- GitHub glue (gh CLI)
158
+
159
+ def gh(*args: str, input_: Optional[str] = None) -> str:
160
+ p = subprocess.run(["gh", *args], capture_output=True, text=True, input=input_)
161
+ if p.returncode != 0:
162
+ raise RuntimeError(f"gh {' '.join(args[:2])} failed: {p.stderr.strip()[:400]}")
163
+ return p.stdout
164
+
165
+
166
+ def find_issue(repo: str, title: str, label: str = "citable") -> Optional[Dict[str, Any]]:
167
+ # Label listing first: it reads the issues directly, while search lags new issues by seconds to
168
+ # minutes, so two runs close together (push + dispatch) used to open duplicate issues. The search
169
+ # pass still finds an issue whose label someone removed. On duplicates the oldest wins.
170
+ found: List[Dict[str, Any]] = []
171
+ for extra in (["--label", label], ["--search", f'"{title}" in:title']):
172
+ raw = gh("issue", "list", "--repo", repo, "--state", "all", "--limit", "100", *extra,
173
+ "--json", "number,title,body,state")
174
+ found = [it for it in json.loads(raw or "[]") if it["title"].strip().lower() == title.lower()]
175
+ if found:
176
+ break
177
+ return min(found, key=lambda it: int(it["number"])) if found else None
178
+
179
+
180
+ def upsert_issue(repo: str, r: Dict[str, Any], snap: Dict[str, Any], notify: str, run_url: str,
181
+ label: str = "citable") -> Dict[str, Any]:
182
+ title = issue_title(r["url"])
183
+ body = render_body(r, snap, run_url)
184
+ existing = find_issue(repo, title, label)
185
+ prev = parse_state(existing["body"]) if existing else None
186
+ worse, better = diff(prev, snap)
187
+ if existing is None:
188
+ try:
189
+ gh("label", "create", label, "--repo", repo, "--color", "5319e7",
190
+ "--description", "citable watch results", "--force")
191
+ except RuntimeError:
192
+ pass
193
+ out = gh("issue", "create", "--repo", repo, "--title", title, "--body", body, "--label", label)
194
+ number = int(out.strip().rsplit("/", 1)[-1])
195
+ return {"number": number, "created": True, "worse": worse, "better": better}
196
+ number = int(existing["number"])
197
+ gh("issue", "edit", str(number), "--repo", repo, "--body", body)
198
+ if worse or better:
199
+ if worse and existing.get("state", "").upper() == "CLOSED":
200
+ gh("issue", "reopen", str(number), "--repo", repo)
201
+ gh("issue", "comment", str(number), "--repo", repo, "--body", render_comment(worse, better, r, notify, run_url))
202
+ return {"number": number, "created": False, "worse": worse, "better": better}
203
+
204
+
205
+ # ----------------------------------------------------------------------------- entry point
206
+
207
+ def _flag(flags: List[str], name: str, default: str = "") -> str:
208
+ for f in flags:
209
+ if f.startswith(name + "="):
210
+ return f.split("=", 1)[1]
211
+ return default
212
+
213
+
214
+ def main(argv: Optional[List[str]] = None) -> int:
215
+ argv = list(sys.argv[1:] if argv is None else argv)
216
+ if not argv or argv[0] in ("-h", "--help"):
217
+ print(__doc__.strip())
218
+ return 0
219
+ flags = [a for a in argv if a.startswith("--")]
220
+ sites: List[str] = []
221
+ for a in argv:
222
+ if not a.startswith("--"):
223
+ sites += [s for s in re.split(r"[\s,]+", a) if s]
224
+ min_score = int(_flag(flags, "--min-score", "0"))
225
+ live = "--no-live" not in flags
226
+ use_issue = "--no-issue" not in flags
227
+ repo = _flag(flags, "--repo", os.environ.get("GITHUB_REPOSITORY", ""))
228
+ notify = _flag(flags, "--notify", "")
229
+ if notify and not notify.startswith("@"):
230
+ notify = "@" + notify
231
+ label = _flag(flags, "--label", "citable")
232
+ fail_on_regression = "--no-fail-on-regression" not in flags
233
+ run_url = ""
234
+ if os.environ.get("GITHUB_RUN_ID") and repo:
235
+ run_url = f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
236
+
237
+ summary_lines = [f"## citable watch {__version__}", "", "| Site | Score | Changed | Issue |", "|---|---|---|---|"]
238
+ regressed = changed = False
239
+ min_seen = 100
240
+ rc = 0
241
+ for site in sites:
242
+ r = check_site(site, live=live)
243
+ snap = snapshot(r)
244
+ print(format_report(r))
245
+ print()
246
+ issue_cell = "off"
247
+ worse: List[str] = []
248
+ better: List[str] = []
249
+ if use_issue:
250
+ if not repo:
251
+ print("watch: no --repo and no GITHUB_REPOSITORY; skipping the issue (use --no-issue to silence)", file=sys.stderr)
252
+ else:
253
+ try:
254
+ res = upsert_issue(repo, r, snap, notify, run_url, label)
255
+ worse, better = res["worse"], res["better"]
256
+ issue_cell = f"[#{res['number']}](https://github.com/{repo}/issues/{res['number']})" + (" (new)" if res["created"] else "")
257
+ except RuntimeError as e:
258
+ issue_cell = f"error: {e}"
259
+ print(f"watch: {e}", file=sys.stderr)
260
+ if worse:
261
+ regressed = True
262
+ for w in worse:
263
+ print(f" REGRESSION: {w}")
264
+ if better:
265
+ for b in better:
266
+ print(f" improved: {b}")
267
+ changed = changed or bool(worse or better)
268
+ if r.get("reachable"):
269
+ min_seen = min(min_seen, int(r["score"]))
270
+ else:
271
+ min_seen = 0
272
+ if not worse:
273
+ rc = max(rc, 2)
274
+ score_cell = f"{r['score']}/100 ({r['grade']})" if r.get("reachable") else "unreachable"
275
+ change_cell = "; ".join(worse) if worse else ("; ".join(better) if better else "no")
276
+ summary_lines.append(f"| {host_of(site)} | {score_cell} | {change_cell} | {issue_cell} |")
277
+
278
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
279
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
280
+ f.write("\n".join(summary_lines) + "\n")
281
+ if os.environ.get("GITHUB_OUTPUT"):
282
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
283
+ f.write(f"score={min_seen}\nregressed={'true' if regressed else 'false'}\nchanged={'true' if changed else 'false'}\n")
284
+ if regressed and fail_on_regression:
285
+ rc = max(rc, 1)
286
+ if min_seen < min_score:
287
+ print(f"score {min_seen} is below --min-score={min_score}")
288
+ rc = max(rc, 1)
289
+ return rc
290
+
291
+
292
+ if __name__ == "__main__":
293
+ sys.exit(main())