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/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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())