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/hosted.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""citable hosted: the paid tier, with no server to run.
|
|
3
|
+
|
|
4
|
+
A subscriber pays on Polar and types their site(s), prompts and brand into the checkout's custom fields.
|
|
5
|
+
This runner (a cron or launchd job on any always-on machine) polls Polar for active subscriptions, runs
|
|
6
|
+
the watch (and the prompt tracking) for each, keeps a small state file per subscriber, and emails:
|
|
7
|
+
|
|
8
|
+
* a first report within one run of paying (so the product lands minutes after the card does),
|
|
9
|
+
* an alert the moment something regresses (an answer bot blocked, llms.txt gone, site down, score drop,
|
|
10
|
+
an engine that stopped citing them),
|
|
11
|
+
* a weekly digest otherwise.
|
|
12
|
+
|
|
13
|
+
python3 -m citable.hosted run # one pass against Polar + the email provider
|
|
14
|
+
python3 -m citable.hosted run --subscribers=f.json # one pass against a local list (no Polar)
|
|
15
|
+
python3 -m citable.hosted run --dry-run # never sends: prints every email it would send
|
|
16
|
+
python3 -m citable.hosted launchd # prints a launchd plist that runs `run` hourly
|
|
17
|
+
|
|
18
|
+
Environment:
|
|
19
|
+
POLAR_ACCESS_TOKEN organisation access token (subscriptions:read). Required unless --subscribers.
|
|
20
|
+
POLAR_PRODUCT_ID optional: only subscriptions of this product.
|
|
21
|
+
POLAR_SANDBOX=1 use sandbox-api.polar.sh.
|
|
22
|
+
RESEND_API_KEY email provider key. Required unless --dry-run.
|
|
23
|
+
CITABLE_FROM sender, e.g. "citable <watch@example.com>" (a verified Resend domain).
|
|
24
|
+
CITABLE_STATE_DIR where state lives (default ~/.citable-hosted).
|
|
25
|
+
CITABLE_LIMITS "sites,prompts" per subscriber (default "5,10", what the page promises).
|
|
26
|
+
ANTHROPIC_API_KEY / OPENAI_API_KEY / PERPLEXITY_API_KEY: engines for prompt tracking (citable.track).
|
|
27
|
+
|
|
28
|
+
Polar custom field slugs read from each subscription's custom_field_data: `site` (or sites/domain/url;
|
|
29
|
+
one per line or comma), `prompts` (one per line), `brand` (optional). Zero dependencies.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import datetime as dt
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
import urllib.error
|
|
39
|
+
import urllib.request
|
|
40
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
43
|
+
from citable import __version__, check_site, format_report # noqa: E402
|
|
44
|
+
from citable import track as T # noqa: E402
|
|
45
|
+
from citable import watch as W # noqa: E402
|
|
46
|
+
|
|
47
|
+
ACTIVE = {"active", "trialing", "past_due"}
|
|
48
|
+
WEEK = 7 * 24 * 3600
|
|
49
|
+
SITE_KEYS = ("site", "sites", "domain", "domains", "website", "url", "urls")
|
|
50
|
+
PROMPT_KEYS = ("prompts", "prompt", "queries", "questions")
|
|
51
|
+
BRAND_KEYS = ("brand", "brand_name", "company")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------------------- pure logic (tested)
|
|
55
|
+
|
|
56
|
+
def _split(text: Any) -> List[str]:
|
|
57
|
+
if not isinstance(text, str):
|
|
58
|
+
return []
|
|
59
|
+
return [s.strip() for s in re.split(r"[\n,;]+", text) if s.strip()]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pick(data: Dict[str, Any], keys: Tuple[str, ...]) -> Any:
|
|
63
|
+
low = {str(k).lower().strip(): v for k, v in (data or {}).items()}
|
|
64
|
+
for k in keys:
|
|
65
|
+
if k in low and low[k] not in (None, ""):
|
|
66
|
+
return low[k]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def limits() -> Tuple[int, int]:
|
|
71
|
+
raw = os.environ.get("CITABLE_LIMITS", "5,10")
|
|
72
|
+
try:
|
|
73
|
+
a, b = [int(x) for x in raw.split(",")]
|
|
74
|
+
return max(1, a), max(0, b)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return 5, 10
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalise(sub: Dict[str, Any], max_sites: int = 5, max_prompts: int = 10) -> Optional[Dict[str, Any]]:
|
|
80
|
+
"""Polar subscription -> {id, email, name, sites, prompts, brand}. None when not active or no email/site."""
|
|
81
|
+
if sub.get("status") not in ACTIVE:
|
|
82
|
+
return None
|
|
83
|
+
customer = sub.get("customer") or {}
|
|
84
|
+
email = (customer.get("email") or sub.get("email") or "").strip()
|
|
85
|
+
data = sub.get("custom_field_data") or {}
|
|
86
|
+
sites = [W.host_of(s) for s in _split(_pick(data, SITE_KEYS))][:max_sites]
|
|
87
|
+
sites = list(dict.fromkeys(s for s in sites if "." in s))
|
|
88
|
+
if not email or not sites:
|
|
89
|
+
return None
|
|
90
|
+
prompts = _split(_pick(data, PROMPT_KEYS))[:max_prompts]
|
|
91
|
+
brand = _pick(data, BRAND_KEYS)
|
|
92
|
+
return {
|
|
93
|
+
"id": str(sub.get("id") or email),
|
|
94
|
+
"email": email,
|
|
95
|
+
"name": (customer.get("name") or "").strip(),
|
|
96
|
+
"sites": sites,
|
|
97
|
+
"prompts": prompts,
|
|
98
|
+
"brand": str(brand).strip() if isinstance(brand, str) else "",
|
|
99
|
+
"period_end": sub.get("current_period_end") or "",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def decide(state: Optional[Dict[str, Any]], worse: List[str], better: List[str], now: float, tracked: bool = False) -> str:
|
|
104
|
+
"""What to email this run: 'first', 'alert', 'digest' or ''. A fresh prompt-tracking run is always reported."""
|
|
105
|
+
if not state or not state.get("last_report_at"):
|
|
106
|
+
return "first"
|
|
107
|
+
if worse or better:
|
|
108
|
+
return "alert"
|
|
109
|
+
if tracked or now - float(state.get("last_report_at", 0)) >= WEEK:
|
|
110
|
+
return "digest"
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def subject(kind: str, sub: Dict[str, Any], results: List[Dict[str, Any]], worse: List[str]) -> str:
|
|
115
|
+
hosts = ", ".join(sub["sites"])
|
|
116
|
+
if kind == "first":
|
|
117
|
+
return f"Your first citable report: {hosts}"
|
|
118
|
+
if kind == "alert":
|
|
119
|
+
return f"citable alert: {worse[0] if worse else 'something improved'}"
|
|
120
|
+
scores = " / ".join(f"{r['score']}" if r.get("reachable") else "down" for r in results)
|
|
121
|
+
return f"citable weekly: {hosts} ({scores})"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_email(kind: str, sub: Dict[str, Any], results: List[Dict[str, Any]], worse: List[str], better: List[str],
|
|
125
|
+
tracks: List[Dict[str, Any]]) -> Tuple[str, str]:
|
|
126
|
+
"""(text, html) bodies. Plain text first; HTML is the same text in a readable wrapper (no dependencies)."""
|
|
127
|
+
name = sub.get("name") or "there"
|
|
128
|
+
lines: List[str] = [f"Hi {name},", ""]
|
|
129
|
+
if kind == "first":
|
|
130
|
+
lines += ["This is your first citable report. From now on you get an email the moment something regresses",
|
|
131
|
+
"(an answer bot blocked, llms.txt gone, the site down, a score drop, an engine that stops citing you)",
|
|
132
|
+
"and a digest once a week otherwise.", ""]
|
|
133
|
+
if worse:
|
|
134
|
+
lines += ["WHAT GOT WORSE"] + [f" - {w}" for w in worse] + [""]
|
|
135
|
+
if better:
|
|
136
|
+
lines += ["WHAT GOT BETTER"] + [f" - {b}" for b in better] + [""]
|
|
137
|
+
for r in results:
|
|
138
|
+
lines += [format_report(r).rstrip(), ""]
|
|
139
|
+
for t in tracks:
|
|
140
|
+
lines += [T.render_markdown(t).rstrip(), ""]
|
|
141
|
+
lines += ["--", f"citable {__version__} hosted watch. Reply to this email to change your sites or prompts;",
|
|
142
|
+
"manage or cancel the subscription from the Polar receipt. https://genzagents.github.io/citable/"]
|
|
143
|
+
text = "\n".join(lines)
|
|
144
|
+
esc = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
145
|
+
html = ("<div style=\"font-family:ui-monospace,Menlo,monospace;font-size:13px;line-height:1.45;white-space:pre-wrap;"
|
|
146
|
+
"max-width:760px\">" + esc + "</div>")
|
|
147
|
+
return text, html
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ----------------------------------------------------------------------------- I/O
|
|
151
|
+
|
|
152
|
+
def _http(method: str, url: str, headers: Dict[str, str], body: Optional[Dict[str, Any]] = None, timeout: int = 60) -> Dict[str, Any]:
|
|
153
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
154
|
+
req = urllib.request.Request(url, data=data, method=method, headers={"Accept": "application/json", **headers})
|
|
155
|
+
if data is not None:
|
|
156
|
+
req.add_header("Content-Type", "application/json")
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
159
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
160
|
+
except urllib.error.HTTPError as e:
|
|
161
|
+
raise RuntimeError(f"{method} {url} -> {e.code}: {e.read().decode('utf-8', 'replace')[:300]}") from None
|
|
162
|
+
return json.loads(raw) if raw.strip() else {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def polar_subscriptions() -> List[Dict[str, Any]]:
|
|
166
|
+
token = os.environ.get("POLAR_ACCESS_TOKEN")
|
|
167
|
+
if not token:
|
|
168
|
+
raise RuntimeError("POLAR_ACCESS_TOKEN is not set (or pass --subscribers=file.json)")
|
|
169
|
+
base = "https://sandbox-api.polar.sh" if os.environ.get("POLAR_SANDBOX") else "https://api.polar.sh"
|
|
170
|
+
product = os.environ.get("POLAR_PRODUCT_ID", "")
|
|
171
|
+
out: List[Dict[str, Any]] = []
|
|
172
|
+
page = 1
|
|
173
|
+
while True:
|
|
174
|
+
url = f"{base}/v1/subscriptions/?limit=100&page={page}" + (f"&product_id={product}" if product else "")
|
|
175
|
+
res = _http("GET", url, {"Authorization": f"Bearer {token}"})
|
|
176
|
+
items = res.get("items") or []
|
|
177
|
+
out += items
|
|
178
|
+
if page >= int((res.get("pagination") or {}).get("max_page") or 1) or not items:
|
|
179
|
+
return out
|
|
180
|
+
page += 1
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def send_email(to: str, subj: str, text: str, html: str) -> str:
|
|
184
|
+
key = os.environ.get("RESEND_API_KEY")
|
|
185
|
+
sender = os.environ.get("CITABLE_FROM", "citable <onboarding@resend.dev>")
|
|
186
|
+
if not key:
|
|
187
|
+
raise RuntimeError("RESEND_API_KEY is not set (use --dry-run to print emails instead)")
|
|
188
|
+
res = _http("POST", "https://api.resend.com/emails", {"Authorization": f"Bearer {key}"},
|
|
189
|
+
{"from": sender, "to": [to], "subject": subj, "text": text, "html": html})
|
|
190
|
+
return str(res.get("id", ""))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def state_dir() -> str:
|
|
194
|
+
d = os.environ.get("CITABLE_STATE_DIR") or os.path.join(os.path.expanduser("~"), ".citable-hosted")
|
|
195
|
+
os.makedirs(d, exist_ok=True)
|
|
196
|
+
return d
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_state(sub_id: str) -> Optional[Dict[str, Any]]:
|
|
200
|
+
p = os.path.join(state_dir(), re.sub(r"[^A-Za-z0-9_.-]", "_", sub_id) + ".json")
|
|
201
|
+
if not os.path.exists(p):
|
|
202
|
+
return None
|
|
203
|
+
with open(p, encoding="utf-8") as f:
|
|
204
|
+
return json.load(f)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def save_state(sub_id: str, state: Dict[str, Any]) -> None:
|
|
208
|
+
p = os.path.join(state_dir(), re.sub(r"[^A-Za-z0-9_.-]", "_", sub_id) + ".json")
|
|
209
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
210
|
+
json.dump(state, f, indent=1, sort_keys=True)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def process(sub: Dict[str, Any], now: float, live: bool = True, do_track: bool = True,
|
|
214
|
+
engines: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
215
|
+
"""Run one subscriber: check every site, track when due, diff against state, return what to send (not sent)."""
|
|
216
|
+
state = load_state(sub["id"]) or {}
|
|
217
|
+
results: List[Dict[str, Any]] = []
|
|
218
|
+
worse: List[str] = []
|
|
219
|
+
better: List[str] = []
|
|
220
|
+
new_checks: Dict[str, Any] = {}
|
|
221
|
+
for site in sub["sites"]:
|
|
222
|
+
r = check_site(site, live=live)
|
|
223
|
+
results.append(r)
|
|
224
|
+
snap = W.snapshot(r)
|
|
225
|
+
w, b = W.diff((state.get("checks") or {}).get(site), snap)
|
|
226
|
+
worse += [f"{site}: {x}" for x in w]
|
|
227
|
+
better += [f"{site}: {x}" for x in b]
|
|
228
|
+
new_checks[site] = snap
|
|
229
|
+
tracks: List[Dict[str, Any]] = []
|
|
230
|
+
new_tracks: Dict[str, Any] = dict(state.get("tracks") or {})
|
|
231
|
+
engines = engines if engines is not None else T.available_engines()
|
|
232
|
+
due = not state.get("last_track_at") or now - float(state.get("last_track_at", 0)) >= WEEK
|
|
233
|
+
if do_track and sub["prompts"] and engines and due:
|
|
234
|
+
for site in sub["sites"]:
|
|
235
|
+
t = T.track(site, sub["prompts"], engines, brand=sub.get("brand", ""))
|
|
236
|
+
tracks.append(t)
|
|
237
|
+
snap = T.snapshot(site, t["results"])
|
|
238
|
+
w, b = T.diff(new_tracks.get(site), snap)
|
|
239
|
+
worse += [f"{site}: {x}" for x in w]
|
|
240
|
+
better += [f"{site}: {x}" for x in b]
|
|
241
|
+
new_tracks[site] = snap
|
|
242
|
+
kind = decide(state, worse, better, now, tracked=bool(tracks))
|
|
243
|
+
new_state = {**state, "email": sub["email"], "sites": sub["sites"], "checks": new_checks, "tracks": new_tracks,
|
|
244
|
+
"last_run_at": now, "version": __version__}
|
|
245
|
+
if tracks:
|
|
246
|
+
new_state["last_track_at"] = now
|
|
247
|
+
return {"kind": kind, "results": results, "tracks": tracks, "worse": worse, "better": better, "state": new_state}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run(subscribers: List[Dict[str, Any]], dry_run: bool, live: bool = True, do_track: bool = True,
|
|
251
|
+
now: Optional[float] = None, out=sys.stdout) -> int:
|
|
252
|
+
now = now if now is not None else dt.datetime.now(dt.timezone.utc).timestamp()
|
|
253
|
+
sent = 0
|
|
254
|
+
for sub in subscribers:
|
|
255
|
+
try:
|
|
256
|
+
res = process(sub, now, live=live, do_track=do_track)
|
|
257
|
+
except Exception as e: # noqa: BLE001 - one subscriber's failure must not stop the others
|
|
258
|
+
print(f"hosted: {sub['email']}: {e}", file=sys.stderr)
|
|
259
|
+
continue
|
|
260
|
+
kind = res["kind"]
|
|
261
|
+
if kind:
|
|
262
|
+
subj = subject(kind, sub, res["results"], res["worse"])
|
|
263
|
+
text, html = render_email(kind, sub, res["results"], res["worse"], res["better"], res["tracks"])
|
|
264
|
+
if dry_run:
|
|
265
|
+
print(f"=== would send [{kind}] to {sub['email']}: {subj}\n{text}\n", file=out)
|
|
266
|
+
else:
|
|
267
|
+
msg_id = send_email(sub["email"], subj, text, html)
|
|
268
|
+
print(f"hosted: sent [{kind}] to {sub['email']} ({msg_id}): {subj}", file=out)
|
|
269
|
+
res["state"]["last_report_at"] = now
|
|
270
|
+
res["state"]["last_kind"] = kind
|
|
271
|
+
sent += 1
|
|
272
|
+
else:
|
|
273
|
+
print(f"hosted: {sub['email']}: no change, next digest in "
|
|
274
|
+
f"{max(0, int((WEEK - (now - float(res['state'].get('last_report_at', now)))) / 3600))}h", file=out)
|
|
275
|
+
save_state(sub["id"], res["state"])
|
|
276
|
+
print(f"hosted: {len(subscribers)} subscriber(s), {sent} email(s){' (dry run)' if dry_run else ''}", file=out)
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
LAUNCHD = """<?xml version="1.0" encoding="UTF-8"?>
|
|
281
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
282
|
+
<plist version="1.0"><dict>
|
|
283
|
+
<key>Label</key><string>io.genzagents.citable.hosted</string>
|
|
284
|
+
<key>ProgramArguments</key><array>
|
|
285
|
+
<string>{python}</string><string>-m</string><string>citable.hosted</string><string>run</string>
|
|
286
|
+
</array>
|
|
287
|
+
<key>WorkingDirectory</key><string>{cwd}</string>
|
|
288
|
+
<key>EnvironmentVariables</key><dict>
|
|
289
|
+
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
290
|
+
</dict>
|
|
291
|
+
<key>StartInterval</key><integer>3600</integer>
|
|
292
|
+
<key>RunAtLoad</key><true/>
|
|
293
|
+
<key>StandardOutPath</key><string>{home}/.citable-hosted/run.log</string>
|
|
294
|
+
<key>StandardErrorPath</key><string>{home}/.citable-hosted/run.err</string>
|
|
295
|
+
</dict></plist>
|
|
296
|
+
<!-- Secrets: put POLAR_ACCESS_TOKEN, RESEND_API_KEY, CITABLE_FROM and engine keys in the EnvironmentVariables dict
|
|
297
|
+
(or source them from the keychain in a wrapper script). Install:
|
|
298
|
+
cp this ~/Library/LaunchAgents/io.genzagents.citable.hosted.plist && launchctl load -w ~/Library/LaunchAgents/io.genzagents.citable.hosted.plist -->
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _flag(flags: List[str], name: str, default: str = "") -> str:
|
|
303
|
+
for f in flags:
|
|
304
|
+
if f.startswith(name + "="):
|
|
305
|
+
return f.split("=", 1)[1]
|
|
306
|
+
return default
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
310
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
311
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
312
|
+
print(__doc__.strip())
|
|
313
|
+
return 0
|
|
314
|
+
cmd, flags = argv[0], [a for a in argv[1:] if a.startswith("--")]
|
|
315
|
+
if cmd == "launchd":
|
|
316
|
+
print(LAUNCHD.format(python=sys.executable, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
317
|
+
home=os.path.expanduser("~")))
|
|
318
|
+
return 0
|
|
319
|
+
if cmd != "run":
|
|
320
|
+
print(f"unknown command {cmd!r}; see --help", file=sys.stderr)
|
|
321
|
+
return 2
|
|
322
|
+
max_sites, max_prompts = limits()
|
|
323
|
+
src = _flag(flags, "--subscribers")
|
|
324
|
+
if src:
|
|
325
|
+
with open(src, encoding="utf-8") as f:
|
|
326
|
+
raw = json.load(f)
|
|
327
|
+
raw = raw.get("items", raw) if isinstance(raw, dict) else raw
|
|
328
|
+
else:
|
|
329
|
+
raw = polar_subscriptions()
|
|
330
|
+
subs = [s for s in (normalise(x, max_sites, max_prompts) for x in raw) if s]
|
|
331
|
+
skipped = len(raw) - len(subs)
|
|
332
|
+
if skipped:
|
|
333
|
+
print(f"hosted: {skipped} subscription(s) skipped (not active, or no email/site in custom fields)", file=sys.stderr)
|
|
334
|
+
return run(subs, dry_run="--dry-run" in flags, live="--no-live" not in flags, do_track="--no-track" not in flags)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
sys.exit(main())
|