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