@instafy/cli 0.3.0 → 0.4.0

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.
@@ -4,7 +4,9 @@
4
4
  * data.json:
5
5
  * title, lede the page title and its one-line answer (the verdict, and the biggest unknown if there is one)
6
6
  * basis how it is ranked, one line: "Ranked by safety reviews, then price."
7
- * filter (only for a constraint the person stated): {key: "price", max: 300, label: "Under $300"}
7
+ * filter (only for a constraint the person stated): {key: "price", max: 300, label: "Under $300", unknown?:
8
+ * "Price unknown"}. Only a price the page shows counts: a row without one is never listed as under $300;
9
+ * it is listed apart, under "Price unknown".
8
10
  * items: [{ref: "option:a", rank, name, why (one short line), badge?, specs: [fact] (at most 3), price?: fact,
9
11
  * store?: {name, url, readable}, details?: [{label, fact}], sources?: [{name, url}]}]
10
12
  * badge: only for a label the person must notice. "Our pick" only when it is your own pick and "why" says why;
@@ -12,8 +14,9 @@
12
14
  * "BabyGearLab: best budget"), never a "Details" toggle. Nothing is added by itself.
13
15
  * price: the price as a fact (not in specs), with "asof" when the source dates it; a live check replaces it. A
14
16
  * review site's price is its list price: "list": true, so the row says "list price" and never reads as the
15
- * store's price now. When every price comes from one source on one date, one line under the list names the
16
- * source once ("List prices on BabyGearLab ↗ · seen 5 Aug"), and "Sources" does not name it again.
17
+ * store's price now. When one page on one date gives every price, one line under the list names it once
18
+ * ("List prices on BabyGearLab ↗ · seen 5 Aug"), and "Sources" does not name it again. A missing price reads
19
+ * "Price unknown", then why ({"note": "the store blocks price checks"}).
17
20
  * details: only facts the row does not already show.
18
21
  * unchecked: ["what was not checked", …]
19
22
  * fact: {"v": "$145", "src": "Example Store", "url": "https://…", "date": "2026-10-17", "est": false}
@@ -31,24 +34,36 @@
31
34
  render: render
32
35
  });
33
36
 
34
- function priceOf(item) { return site.live(item.ref, "price") || item.price || null; }
37
+ /** The price a row shows: a live check, else the seed price (also when the check found the store blocked). */
38
+ function priceOf(item) {
39
+ var live = site.live(item.ref, "price");
40
+ if (live && site.blocked(item.ref)) return item.price || live;
41
+ return live || item.price || null;
42
+ }
35
43
  function valueFor(item, key) {
36
44
  if (key === "price") return priceOf(item);
37
45
  var d = (item.details || []).filter(function (x) { return x.key === key; })[0];
38
46
  return d ? d.fact : null;
39
47
  }
40
- function fits(data, item) {
48
+ /** "Price unknown": the heading of the rows the filter cannot place. */
49
+ function unknownTitle(data) {
41
50
  var f = data.filter;
42
- if (!f) return true;
43
- var n = site.num(valueFor(item, f.key));
44
- if (n == null) return true;
45
- return (f.max == null || n <= f.max) && (f.min == null || n >= f.min);
51
+ if (f.unknown) return f.unknown;
52
+ if (f.key === "price") return "Price unknown";
53
+ var d = (data.items || []).map(function (i) { return (i.details || []).filter(function (x) { return x.key === f.key; })[0]; }).filter(Boolean)[0];
54
+ return ((d && d.label) || f.key) + " unknown";
46
55
  }
56
+ function lowerFirst(text) { text = String(text || ""); return text.charAt(0).toLowerCase() + text.slice(1); }
47
57
  function render(data, st) {
48
58
  site.head(data); // title, lede and the basis line ("Ranked by …") come first, before any button
49
59
  var items = (data.items || []).slice().sort(function (a, b) { return (a.rank || 0) - (b.rank || 0); });
50
- var shown = st.show === "all" ? items : items.filter(function (i) { return fits(data, i); });
51
- var hidden = items.length - shown.length;
60
+ // the stated limit shows the rows within it, then the rows nobody could place ("Price unknown", never counted as
61
+ // within it); "All" is the whole ranking
62
+ var parts = st.show === "all" || !data.filter ? { "in": items, unknown: [], out: [] }
63
+ : site.byFilter(items, data.filter, function (i) { return valueFor(i, data.filter.key); });
64
+ var shown = parts["in"];
65
+ var apart = parts.unknown;
66
+ var hidden = parts.out.length;
52
67
  var priced = items.some(function (i) { return i.price || i.store; });
53
68
  var shared = sharedPrice(items);
54
69
 
@@ -72,7 +87,14 @@
72
87
  // today's price is ("Open a store for today's price."), instead of a box about checks nobody was offered
73
88
  var inline = !!(shared && stores && site.storesBlocked(items));
74
89
  if (inline) stores = null;
75
- wrap.appendChild(h("ol", { "class": "list ranked" }, shown.map(function (item) { return row(item, st, priced, shared); })));
90
+ if (shown.length) wrap.appendChild(h("ol", { "class": "list ranked" }, shown.map(function (item) { return row(item, st, priced, shared); })));
91
+ else wrap.appendChild(h("p", { "class": "cap", text: "None is known to be " + lowerFirst(data.filter.label || "within the limit") + "." }));
92
+ if (apart.length) {
93
+ wrap.appendChild(h("section", { "class": "group", attrs: { id: "group-unknown", "aria-label": unknownTitle(data) } },
94
+ h("div", { "class": "group-head" }, h("h2", { text: unknownTitle(data) }),
95
+ h("span", { "class": "cap", text: "Not counted as " + lowerFirst(data.filter.label || "within the limit") })),
96
+ h("ol", { "class": "list ranked" }, apart.map(function (item) { return row(item, st, priced, shared); }))));
97
+ }
76
98
  if (hidden > 0) {
77
99
  wrap.appendChild(h("p", { "class": "cap" }, hidden + " hidden · ",
78
100
  h("button", { "class": "link-btn", text: "Show all", attrs: { type: "button", "data-key": "show-all" }, on: { click: function () { site.setState({ show: "all" }); } } })));
@@ -93,11 +115,12 @@
93
115
  site.notes(items.length ? data.unchecked : [], items.length ? sources : []);
94
116
  }
95
117
 
96
- /** The price fact every priced row shares (same source and date), else null. */
118
+ /** The price fact every priced row shares (one page on one date), else null. */
97
119
  function sharedPrice(items) {
98
120
  var facts = items.map(function (i) { return i.price; }).filter(Boolean);
99
121
  if (facts.length < 2) return null;
100
- var key = function (f) { return f && f.src && !f.est && f.v != null ? [f.src, f.asof || f.date || "", f.list ? "list" : ""].join("|") : null; };
122
+ var page = function (f) { var u = site.safeUrl(f.url); return u ? u.split("#")[0] : "src:" + f.src; };
123
+ var key = function (f) { return f && f.src && !f.est && site.valueText(f) ? [page(f), f.asof || f.date || "", f.list ? "list" : ""].join("|") : null; };
101
124
  var k = key(facts[0]);
102
125
  return k && facts.every(function (f) { return key(f) === k; }) ? facts[0] : null;
103
126
  }
@@ -149,7 +172,7 @@
149
172
  item.why ? h("p", { "class": "why", text: item.why }) : null,
150
173
  specs.length ? h("p", { "class": "cap", text: specs.join(" · ") }) : null,
151
174
  // every priced row has the same price line, with or without a store
152
- priced ? site.trust(live || item.price || null, { label: "Price", price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: item.price || null, shared: !live && !!shared && !!item.price }) : null,
175
+ priced ? site.trust(live || item.price || null, { label: "Price", unknown: "Unknown", price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: item.price || null, shared: !live && !!shared && !!item.price }) : null,
153
176
  open && hasMore(item) ? more(item) : null));
154
177
  }
155
178
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "title": "Top three options",
3
- "lede": "Option A is the best all-round pick. Option C costs least, but its price was not checked.",
3
+ "lede": "Option A is the best all-round pick. Option C's price is unknown: its store blocks price checks.",
4
4
  "basis": "Ranked by review scores, then price.",
5
5
  "items": [
6
6
  {
@@ -106,7 +106,7 @@
106
106
  "ref": "option:option-c",
107
107
  "rank": 3,
108
108
  "name": "Option C",
109
- "why": "The lowest price, fewer reviews",
109
+ "why": "Well reviewed, by fewer people",
110
110
  "specs": [
111
111
  {
112
112
  "v": "9.8 oz",
@@ -9,12 +9,18 @@
9
9
  * - Rows: give each row id="row-<slug>" and data-ref="<kind>:<slug>" (site.rowAttrs(ref)). A "select" command from
10
10
  * chat scrolls to the row and marks it; "highlight" fades the rows it names. "toast" and "reload" are the client's.
11
11
  * - Facts: one shape everywhere, {"v": "8.9 oz", "src": "Maker", "url": "https://…", "date": "2026-10-17", "est": false}.
12
- * Optional "asof": the source's own date, shown in place of "date" ("seen 5 Aug"); a price with an "asof" over 30
13
- * days old is muted and says "may have changed", like a live price past its time.
14
- * site.trust(fact | record, opts) draws the one trust line: "Source ↗ · seen 17 Oct", "at Store ↗ · checked 4 min
15
- * ago", "Estimate", "Price not checked" (nobody checked it), "Not listed · Source ↗" (a fact with a source and no
16
- * value: the source was read and does not give it). A price fact with "list": true is that source's list price,
17
- * never a store's price now: "$900 · list price on BabyGearLab ↗ · seen 5 Aug".
12
+ * "url" is the page it was read on and "date" the day it was read; each value cites them: "checked 17 Oct on
13
+ * maker.com ↗" (a price: "seen 17 Oct on shop.com ↗"). Optional "asof": the source's own date, shown in place of
14
+ * "date" ("seen 5 Aug on …"); a price with an "asof" over 30 days old is muted and says "may have changed", like a
15
+ * live price past its time. A value that only says nothing is known ("—", "n/a", "?", "unknown") is no value.
16
+ * site.trust(fact | record, opts) draws the one trust line: "checked 17 Oct on maker.com ↗", "at Store ↗ · checked
17
+ * 4 min ago", "Estimate", "Price not checked" (nobody checked it), "Not listed · checked 16 Oct on maker.com ↗" (a
18
+ * fact with a source and no value: the source was read and does not give it); in a table of options a value nobody
19
+ * found reads opts.unknown, "Unknown · why" ("Price unknown"). A price fact with "list": true is that source's
20
+ * list price, never a store's price now: "$900 · list price on BabyGearLab ↗ · seen 5 Aug".
21
+ * - Filters: site.within(value, filter) is "in", "out" or "unknown" for the page's one filter ({key, max, min}), and
22
+ * site.byFilter(rows, filter, valueOf) splits rows into {in, unknown, out}. Only a value the page shows counts: a
23
+ * row without one is never "under $140"; the starters list it apart ("Price unknown").
18
24
  * - Live checks: site.checkAll(rows) is the page's one primary button ("Check all prices", with two or more checkable
19
25
  * rows). trust(value, {ref, intent, store, seed}) ends a row's caption in its one quiet action: "Check price" (only
20
26
  * without "Check all prices"), "Try again" after an error, "Open store ↗" when the store can't be read. While a
@@ -147,24 +153,82 @@
147
153
  }
148
154
  }
149
155
  function isRecord(x) { return !!(x && typeof x === "object" && x.prov && x.type); }
150
- /** The text of a fact or record value ("" when there is none). */
156
+ /** A value that only says nothing is known: blank, dashes, "?", "n/a", "unknown", "TBD". */
157
+ var NO_VALUE = /^(?:[\s\-\u2010-\u2015?.\u00b7\u2026/]*|n\s*\/\s*a|unknown|tb[ad])$/i;
158
+ /** The text of a fact or record value ("" when there is none, or it only says nothing is known: a dash in a table
159
+ * is never drawn as if it were a value). */
151
160
  function valueText(x) {
152
161
  if (!x) return "";
162
+ var text;
153
163
  if (isRecord(x)) {
154
164
  if (x.type === "offer") return money(x.price);
155
165
  if (x.value === true) return "Yes";
156
166
  if (x.value === false) return "No";
157
- return x.value == null ? "" : String(x.value);
167
+ text = x.value == null ? "" : String(x.value);
168
+ } else {
169
+ text = x.v == null ? "" : String(x.v);
170
+ }
171
+ return NO_VALUE.test(text.trim()) ? "" : text;
172
+ }
173
+ /** The first number in a text, read the way shops write it: a ".", ",", space or "'" before exactly three digits
174
+ * groups thousands ("1.299", "1 299", "1'299", "1,299" are 1299), and the last "." or "," is the decimal mark
175
+ * otherwise ("1.299,00" and "1,299.00" are 1299, "549,99" is 549.99, "4.6" is 4.6, "0.125" is 0.125). */
176
+ function numberIn(text) {
177
+ var m = /(-?)(\d+)((?:[.,'’    ]\d+)*)/.exec(String(text == null ? "" : text));
178
+ if (!m) return null;
179
+ var head = m[2];
180
+ var parts = m[3] ? m[3].match(/[.,'’    ]\d+/g) : [];
181
+ var mark = function (p) { return p.charAt(0); };
182
+ var dec = -1;
183
+ parts.forEach(function (p, i) { if (mark(p) === "." || mark(p) === ",") dec = i; });
184
+ // the last "." or "," groups thousands when three digits follow it, one to three (not just 0) lead, and no other
185
+ // kind of decimal mark comes before it: "1.299", "1,299,000"
186
+ if (dec >= 0 && parts[dec].length === 4 && head.length <= 3 && head !== "0" &&
187
+ parts.slice(0, dec).every(function (p) { return (mark(p) !== "." && mark(p) !== ",") || mark(p) === mark(parts[dec]); })) dec = -1;
188
+ var whole = head;
189
+ var end = dec >= 0 ? dec : parts.length;
190
+ for (var i = 0; i < end; i++) {
191
+ // a mark that does not group thousands ends the number: "2 3-year plans" is 2
192
+ if (head.length > 3 || parts[i].length !== 4) return Number(m[1] + whole);
193
+ whole += parts[i].slice(1);
158
194
  }
159
- return x.v == null ? "" : String(x.v);
195
+ return Number(m[1] + whole + (dec >= 0 ? "." + parts[dec].slice(1) : ""));
160
196
  }
161
- /** A number to sort by: fact.n, an offer's amount, else the first number in the value text. */
197
+ /** A number to sort by: fact.n, an offer's amount, else the first number in the value text (numberIn). */
162
198
  function num(x) {
163
199
  if (!x) return null;
164
200
  if (isRecord(x) && x.type === "offer") return x.price && typeof x.price.amount === "number" ? x.price.amount : null;
165
201
  if (typeof x.n === "number") return x.n;
166
- var m = /-?\d[\d,]*(?:\.\d+)?/.exec(valueText(x));
167
- return m ? Number(m[0].replace(/,/g, "")) : null;
202
+ return numberIn(valueText(x));
203
+ }
204
+ /** The number of a value the page shows, else null. A value counts only when its trust line draws it: a live value,
205
+ * an estimate, or a fact with its source. An unsourced fact (drawn "Not checked"), a check that could not read it,
206
+ * and a dash are unknown, so no filter or sort counts them. */
207
+ function known(x) {
208
+ if (!x) return null;
209
+ if (isRecord(x)) {
210
+ if (x.type === "claim" && (x.status === "uncertain" || x.status === "not_found" || x.status === "error")) return null;
211
+ return valueText(x) ? num(x) : null;
212
+ }
213
+ if (!valueText(x) || !(x.src || x.est)) return null;
214
+ return num(x);
215
+ }
216
+ /** Where a value stands against the page's one filter ({key, max?, min?}): "in", "out", or "unknown" when the page
217
+ * has no number for it. Unknown is never "in": a row without a price is not "under $140". */
218
+ function within(x, filter) {
219
+ if (!filter) return "in";
220
+ var n = known(x);
221
+ if (n == null) return "unknown";
222
+ var max = filter.max == null || filter.max === "" ? null : Number(filter.max);
223
+ var min = filter.min == null || filter.min === "" ? null : Number(filter.min);
224
+ return (max == null || n <= max) && (min == null || n >= min) ? "in" : "out";
225
+ }
226
+ /** Rows split by the page's one filter, each part in the rows' own order: {in, unknown, out}. Without a filter every
227
+ * row is "in". valueOf(row) gives the row's value for the filter's key. */
228
+ function byFilter(rows, filter, valueOf) {
229
+ var parts = { "in": [], unknown: [], out: [] };
230
+ (rows || []).forEach(function (r) { parts[within(filter ? valueOf(r) : null, filter)].push(r); });
231
+ return parts;
168
232
  }
169
233
 
170
234
  // ---------- live values ----------
@@ -189,6 +253,30 @@
189
253
  var when = x && (x.asof || x.date);
190
254
  return when && date(when) ? "seen " + date(when) : "";
191
255
  }
256
+ /** Where and when a value was read, as one caption part: "checked 17 Oct on maker.com ↗" (the host links the page it
257
+ * was read on). A price, or a date the source gives ("asof"), is "seen": "seen 5 Aug on reviews.example ↗", never
258
+ * "checked", so it does not read as today's price. Without a page link it names the source: "Maker · checked 17
259
+ * Oct". o.price: the value is a price. */
260
+ function citeParts(x, o) {
261
+ o = o || {};
262
+ if (!x) return [];
263
+ var when = x.asof || x.date;
264
+ var day = when && date(when) ? ((o.price || x.asof ? "seen " : "checked ") + date(when)) : "";
265
+ var site = host(x.url);
266
+ if (site) return [h("span", { "class": "cite" }, day ? day + " on " : "", link(x.url, site))];
267
+ var out = [];
268
+ if (x.src) out.push(String(x.src));
269
+ if (day) out.push(h("span", { "class": "age", text: day }));
270
+ return out;
271
+ }
272
+ /** cite() as one inline piece, for a line under a list ("Weight: checked 17 Oct on maker.com ↗"). */
273
+ function cite(x, o) {
274
+ var parts = citeParts(x, o);
275
+ if (!parts.length) return null;
276
+ var el = h("span", null);
277
+ parts.forEach(function (p, i) { if (i) el.appendChild(document.createTextNode(" · ")); append(el, p); });
278
+ return el;
279
+ }
192
280
  function newest(list) {
193
281
  return list.slice().sort(function (a, b) { return (Date.parse(b.prov.observed_at) || 0) - (Date.parse(a.prov.observed_at) || 0); })[0] || null;
194
282
  }
@@ -233,12 +321,14 @@
233
321
 
234
322
  /**
235
323
  * The one trust line for a fact or a live record: the value, then a quiet caption.
236
- * value · source ↗ · seen 17 Oct (a seed fact; the source's own date when it gives one)
324
+ * value · checked 17 Oct on maker.com ↗ (a seed fact: the page it was read on and the day; "seen 5 Aug on …"
325
+ * for a price or the source's own date)
237
326
  * value at Store ↗ · checked 4 min ago (a live check; " · may have changed" once it is old)
238
327
  * value · Estimate · how ↗
239
328
  * Price not checked · reason · where to check ↗
240
- * opts: label ("Price": says what was not checked), price (a price: "at <store>"), shared (the source is said once
241
- * for the whole column: value only), and for a row that can be checked live: ref, intent, store, params. Then the
329
+ * opts: label ("Price": says what was not checked), unknown (the word for a missing value: "Unknown" in a table of
330
+ * options, "To confirm" on a checklist), price (a price: "at <store>"), shared (the source is said once for the
331
+ * whole column: value only), and for a row that can be checked live: ref, intent, store, params. Then the
242
332
  * caption ends in the row's one action: "Check price" (only when there is no "Check all prices"), "Try again" after
243
333
  * an error, "Open store ↗" when the store can't be read. While a check runs the value stays and the caption says
244
334
  * "Checking…".
@@ -259,17 +349,21 @@
259
349
  var value = valueText(x);
260
350
  // "Not checked" has one meaning on every page: nobody checked it. A checklist says "To confirm" (opts.unknown),
261
351
  // because there "checked" is the tick.
352
+ // A table of options says "Unknown" ("Price unknown") for a value nobody found, then why; "Not listed" when the
353
+ // page that was read does not give it.
354
+ var word = opts.unknown || "Not checked";
262
355
  var notChecked = function (note, url) {
263
- add((opts.label ? opts.label + " not checked" : (opts.unknown || "Not checked")) + (usefulNote(note) ? " · " + usefulNote(note) : ""));
356
+ add((opts.label ? opts.label + " " + word.toLowerCase() : word) + (usefulNote(note) ? " · " + usefulNote(note) : ""));
264
357
  if (safeUrl(url)) add(link(url, "where to check", "act"));
265
358
  };
266
- // looked for and not there: the source was read and does not give it ("Not listed · Maker ↗ · seen 16 Oct"); the
267
- // link to the page that was read stays even when the source is named once for the column (then without the date)
359
+ // looked for and not there: the source was read and does not give it ("Not listed · checked 16 Oct on maker.com
360
+ // ↗"); the link to the page that was read stays even when the source is named once for the column (then without
361
+ // the date)
268
362
  var notListed = function (note, src, url, fact) {
269
363
  add((opts.label ? opts.label + " not listed" : "Not listed") + (usefulNote(note) ? " · " + usefulNote(note) : ""));
270
- if (src) add(safeUrl(url) ? link(url, src) : src);
271
- var seen = !opts.shared && src ? seenText(fact) : "";
272
- if (seen) add(h("span", { "class": "age", text: seen }));
364
+ if (fact && !opts.shared) citeParts(fact, { price: opts.price }).forEach(add);
365
+ else if (safeUrl(url)) add(link(url, host(url) || src));
366
+ else if (src) add(src);
273
367
  };
274
368
  var failed = live && !pending && checks.errors.has(opts.ref);
275
369
 
@@ -285,6 +379,9 @@
285
379
  } else if (x.type === "claim" && x.status === "error") {
286
380
  value = "";
287
381
  add(h("span", null, icon("error"), "Couldn't check" + (usefulNote(x.note) ? " · " + usefulNote(x.note) : "")));
382
+ } else if (!value) {
383
+ // a record whose value only says nothing is known ("—") is unknown, never a caption without a value
384
+ notChecked(x.note);
288
385
  } else if (ESTIMATE_BASIS.indexOf(x.basis) >= 0) {
289
386
  add("Estimate");
290
387
  if (safeUrl(url)) add(link(url, "how"));
@@ -293,7 +390,7 @@
293
390
  add(h("span", { "class": "age", text: "checked " + ago(x.prov.observed_at) }));
294
391
  }
295
392
  if (expired(x) && value) { el.classList.add("is-old"); add("may have changed"); }
296
- } else if (x && x.est) {
393
+ } else if (x && x.est && value) {
297
394
  // an estimate names its basis: a source to check it against ("Estimate · Taiwan Railway ↗") or how ↗
298
395
  add("Estimate");
299
396
  if (x.src) add(link(x.url, x.src)); else if (safeUrl(x.url)) add(link(x.url, "how"));
@@ -305,9 +402,14 @@
305
402
  // under the list names the source: next to "Open Amazon ↗" a bare "$900" reads as Amazon's price today
306
403
  var list = opts.price && !atStore && x.list;
307
404
  if (!opts.shared) {
308
- add(atStore ? h("span", null, "at ", link(x.url, x.src)) : list ? h("span", null, "list price on ", link(x.url, x.src)) : link(x.url, x.src));
309
- var when = seenText(x);
310
- if (when) add(h("span", { "class": "age", text: when }));
405
+ if (atStore || list) {
406
+ add(atStore ? h("span", null, "at ", link(x.url, x.src)) : h("span", null, "list price on ", link(x.url, x.src)));
407
+ var when = seenText(x);
408
+ if (when) add(h("span", { "class": "age", text: when }));
409
+ } else {
410
+ // every value cites the page it was read on and the day: "checked 17 Oct on maker.com ↗"
411
+ citeParts(x, { price: opts.price }).forEach(add);
412
+ }
311
413
  if (seedOld) add("may have changed");
312
414
  } else if (list) {
313
415
  add("list price");
@@ -872,10 +974,10 @@
872
974
  }
873
975
 
874
976
  window.site = {
875
- version: "1.4.0",
977
+ version: "1.5.0",
876
978
  h: h, append: append, icon: icon, link: link, slug: slug, rowAttrs: rowAttrs, safeUrl: safeUrl,
877
979
  date: date, dayDate: dayDate, ago: ago, money: money, num: num, valueText: valueText, host: host, seen: seenText,
878
- old: isOld, expired: expired,
980
+ cite: cite, known: known, within: within, byFilter: byFilter, old: isOld, expired: expired,
879
981
  live: live, trust: trust, rowAction: rowAction, checkButton: checkButton, checkAll: checkAll, canCheck: canCheck,
880
982
  anyPending: anyPending, priceNotice: priceNotice, storesBlocked: storesBlocked, blocked: blockedRecord, tidy: tidyCaptions, remember: remember, recall: recall,
881
983
  state: state, setState: setState, onState: onState, render: render, schedule: schedule,
@@ -5,7 +5,13 @@
5
5
  import { isUnavailable, pageFetch } from "./page-fetch.mjs";
6
6
 
7
7
  export const USER_AGENT = "Instafy-Agent/1.0 (+https://instafy.dev; reads one public page on a person's request)";
8
- export const AI_AGENTS = ["gptbot", "chatgpt-user", "oai-searchbot", "claudebot", "claude-user", "anthropic-ai", "perplexitybot", "ccbot", "google-extended"];
8
+ // The AI crawlers and the user-initiated AI fetchers (the closest match to a read on a person's request). A site that
9
+ // keeps any of them out of a path is not read there.
10
+ export const AI_AGENTS = [
11
+ "gptbot", "chatgpt-user", "oai-searchbot", "claudebot", "claude-user", "claude-searchbot", "anthropic-ai", "perplexitybot",
12
+ "perplexity-user", "ccbot", "google-extended", "applebot-extended", "meta-externalagent", "meta-externalfetcher",
13
+ "bytespider", "amazonbot", "cohere-ai", "mistralai-user", "duckassistbot",
14
+ ];
9
15
 
10
16
  /** robots.txt → [{ agents: [lowercase], rules: [{ allow, path }] }] */
11
17
  export function parseRobots(txt) {