@instafy/cli 0.3.1 → 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.
- package/README.md +3 -1
- package/dist/bind/bin/instafy-bind.mjs +4 -1
- package/dist/bind/src/check.mjs +195 -8
- package/dist/bind/src/starters/compare/app.js +62 -37
- package/dist/bind/src/starters/compare/data.json +1 -1
- package/dist/bind/src/starters/ranked/app.js +38 -15
- package/dist/bind/src/starters/ranked/data.json +2 -2
- package/dist/bind/src/starters/site.js +129 -27
- package/dist/cli.js +6413 -3465
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,9 @@ Run Instafy spaces locally and connect them back to Instafy Studio — from any
|
|
|
56
56
|
- `instafy tunnel logs <tunnelId> --follow` — tail tunnel logs.
|
|
57
57
|
- `instafy tunnel stop <tunnelId>` — stop + revoke a tunnel.
|
|
58
58
|
- `instafy preview check --port <port>` — load a local site in a headless browser and list its errors, failed requests and a blank page, with desktop and phone screenshots. Local only; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#previews) for exit codes and settings.
|
|
59
|
-
- `instafy web read <url…> --json` — read up to 6 public pages over plain HTTP and get their text, links and structured data with provenance, wrapped as untrusted page content. robots.txt (including AI-agent user agents), skill-
|
|
59
|
+
- `instafy web read <url…> --json` — read up to 6 public pages over plain HTTP and get their text, links and structured data with provenance, wrapped as untrusted page content. robots.txt (including AI-agent user agents), the web-research skill's `no-agents` link-only sites (built in, plus the space's additions) and translators', caches' and archives' copies of them, private-address checks at every redirect, stop rules and per-turn caps are enforced; a `no-reuse` site is read and marked `noReuse`; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#reading-web-pages-instafy-web-read).
|
|
60
|
+
- `instafy web search "<query>" --json` — find links to read, in Octo's turns only, with the space's own search key (the web-research skill's providers: Brave, then Kagi). Results are untrusted leads: a link-only site's result keeps only its link and site name, keys go only to their provider's host, nothing is cached, and the room keeps only the query and the count; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#searching-for-links-instafy-web-search).
|
|
61
|
+
- `instafy web link <site> [name=value…] --json` — the link the person opens in their own tab for a `no-agents` site (guided mode): the site's search template from the web-research skill's list, filled with checked, URL-encoded values, or its home page and what to type there when it bars deep links. Reads nothing.
|
|
60
62
|
- `instafy secrets list` — list space secret metadata (names/descriptions, never values).
|
|
61
63
|
- `instafy secrets get <name-or-id>` — inspect one space secret metadata entry.
|
|
62
64
|
- `instafy secrets put <name> --value ...` — create/update a space secret.
|
|
@@ -59,7 +59,10 @@ init Write a starter bound.json, index.html and handlers/hello.mjs. --star
|
|
|
59
59
|
files are kept; --force overwrites them.
|
|
60
60
|
check Does the site show every item the request named (each product, stop or entry)? Reads the text of its
|
|
61
61
|
HTML pages and every value in its data files (data.json, data/**/*.json), and lists the items it does
|
|
62
|
-
not show
|
|
62
|
+
not show. On a page of options (the compare and ranked starters' data.json) it also lists values that
|
|
63
|
+
only say nothing ("—", "n/a"), values without the page and day they were read, and a filter that
|
|
64
|
+
would count a row whose value is unknown; and it notes the rows a filter lists apart. Exit 1 when an
|
|
65
|
+
item is missing or a value needs fixing, 2 when there is nothing to check. Items: --expect, plus an
|
|
63
66
|
"expect" array in data.json. Run it before telling anyone the page is done.
|
|
64
67
|
command Push a command (select, highlight, toast, reload) to every open viewer. Uses the
|
|
65
68
|
admin token in .bind/admin-token.
|
package/dist/bind/src/check.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// every string in its data files (data.json, data/**/*.json, records included). Items come from --expect and from
|
|
5
5
|
// an "expect" array in data.json. Text that only says an item is unavailable ("Not found yet: Tokyo", a data file's
|
|
6
6
|
// "missing" list) does not count as showing it: builders are told to say on the page what they could not add, and
|
|
7
|
-
// that line must not pass the check.
|
|
7
|
+
// that line must not pass the check. On a page of options it also checks the values (below). Pure helpers are
|
|
8
|
+
// exported for the tests.
|
|
8
9
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
@@ -157,20 +158,206 @@ export function checkSite(dir, expect = []) {
|
|
|
157
158
|
return { items, shown, missing, files: site.parts.map((p) => p.file) };
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
// ---------- the values of a page of options (the compare and ranked starters' data.json) ----------
|
|
162
|
+
// Each value on such a page names the page it was read on and the day ("checked 27 Oct on maker.com"), and a value
|
|
163
|
+
// nobody found says so with why; a dash or "n/a" is not a value. A filter from a stated limit ("under €600") counts
|
|
164
|
+
// only values the page shows: the starters' site.byFilter lists a row whose value is unknown apart ("Price
|
|
165
|
+
// unknown"), never as within the limit. These rules read the data the same way site.js draws it.
|
|
166
|
+
|
|
167
|
+
/** A value that only says nothing is known: blank, dashes, "?", "n/a", "unknown", "TBD" (site.js: no value). */
|
|
168
|
+
export const NO_VALUE = /^(?:[\s\-\u2010-\u2015?.\u00b7\u2026/]*|n\s*\/\s*a|unknown|tb[ad])$/i;
|
|
169
|
+
const valueOf = (fact) => (fact && typeof fact === "object" && fact.v != null ? String(fact.v) : "");
|
|
170
|
+
const hasValue = (fact) => !!valueOf(fact) && !NO_VALUE.test(valueOf(fact).trim());
|
|
171
|
+
const httpsUrl = (url) => {
|
|
172
|
+
try {
|
|
173
|
+
return new URL(String(url)).protocol === "https:";
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The first number in a text, read the way shops write it (site.js: numberIn): a ".", ",", space or "'" before
|
|
180
|
+
* exactly three digits groups thousands ("1.299", "1 299", "1,299" are 1299), and the last "." or "," is the decimal
|
|
181
|
+
* mark otherwise ("1.299,00" is 1299, "549,99" is 549.99, "4.6" is 4.6). */
|
|
182
|
+
export function numberIn(text) {
|
|
183
|
+
const m = /(-?)(\d+)((?:[.,'\u2019 \u00a0\u2009\u202f]\d+)*)/.exec(String(text ?? ""));
|
|
184
|
+
if (!m) return null;
|
|
185
|
+
const head = m[2];
|
|
186
|
+
const parts = m[3] ? m[3].match(/[.,'\u2019 \u00a0\u2009\u202f]\d+/g) : [];
|
|
187
|
+
const decimalMark = (p) => p[0] === "." || p[0] === ",";
|
|
188
|
+
let dec = -1;
|
|
189
|
+
parts.forEach((p, i) => {
|
|
190
|
+
if (decimalMark(p)) dec = i;
|
|
191
|
+
});
|
|
192
|
+
// the last "." or "," groups thousands when three digits follow it, one to three (not just 0) lead, and no other kind
|
|
193
|
+
// of decimal mark comes before it: "1.299", "1,299,000"
|
|
194
|
+
if (dec >= 0 && parts[dec].length === 4 && head.length <= 3 && head !== "0" && parts.slice(0, dec).every((p) => !decimalMark(p) || p[0] === parts[dec][0])) dec = -1;
|
|
195
|
+
let whole = head;
|
|
196
|
+
for (const part of parts.slice(0, dec >= 0 ? dec : parts.length)) {
|
|
197
|
+
// a mark that does not group thousands ends the number: "2 3-year plans" is 2
|
|
198
|
+
if (head.length > 3 || part.length !== 4) return Number(m[1] + whole);
|
|
199
|
+
whole += part.slice(1);
|
|
200
|
+
}
|
|
201
|
+
return Number(m[1] + whole + (dec >= 0 ? `.${parts[dec].slice(1)}` : ""));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The number a page shows for a seed fact (site.known): a value with its source, or an estimate; else null. */
|
|
205
|
+
export function knownNumber(fact) {
|
|
206
|
+
if (!hasValue(fact) || !(fact.src || fact.est)) return null;
|
|
207
|
+
if (typeof fact.n === "number") return fact.n;
|
|
208
|
+
return numberIn(valueOf(fact));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Is this data.json a page of options: compare (columns of facts per item) or ranked (a price and specs per item)? */
|
|
212
|
+
export function isOptionsData(data) {
|
|
213
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.items)) return false;
|
|
214
|
+
return Array.isArray(data.columns) || data.items.some((i) => i && typeof i === "object" && ("price" in i || "specs" in i || "rank" in i));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Every value of a page of options: [{item, ref, label, key, fact, price}]. Compare: each column of each item;
|
|
218
|
+
* ranked: each item's price (when it has one or a store), specs and details. */
|
|
219
|
+
export function optionCells(data) {
|
|
220
|
+
const out = [];
|
|
221
|
+
if (!isOptionsData(data)) return out;
|
|
222
|
+
const columns = Array.isArray(data.columns) ? data.columns.filter((c) => c && typeof c === "object" && c.key) : null;
|
|
223
|
+
for (const item of data.items) {
|
|
224
|
+
if (!item || typeof item !== "object") continue;
|
|
225
|
+
const base = { item: String(item.name ?? item.ref ?? "an item"), ref: item.ref };
|
|
226
|
+
if (columns) {
|
|
227
|
+
for (const c of columns.slice(0, 5)) out.push({ ...base, label: String(c.label ?? c.key), key: c.key, fact: item.facts?.[c.key], price: c.type === "price" });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if ("price" in item || item.store) out.push({ ...base, label: "Price", key: "price", fact: item.price, price: true });
|
|
231
|
+
for (const fact of Array.isArray(item.specs) ? item.specs : []) out.push({ ...base, label: "spec", key: null, fact });
|
|
232
|
+
for (const d of Array.isArray(item.details) ? item.details : []) out.push({ ...base, label: String(d?.label ?? d?.key ?? "detail"), key: d?.key ?? null, fact: d?.fact });
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The newest live record per ref and attribute in the site's record files (what a check wrote): Map ref -> record[]. */
|
|
238
|
+
function liveRecords(root) {
|
|
239
|
+
const byRef = new Map();
|
|
240
|
+
const dir = path.join(root, "data", "records");
|
|
241
|
+
let files = [];
|
|
242
|
+
try {
|
|
243
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
244
|
+
} catch {
|
|
245
|
+
return byRef;
|
|
246
|
+
}
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
let value;
|
|
249
|
+
try {
|
|
250
|
+
value = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
251
|
+
} catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
for (const r of Array.isArray(value?.records) ? value.records : []) {
|
|
255
|
+
if (!r || typeof r !== "object" || typeof r.ref !== "string") continue;
|
|
256
|
+
(byRef.get(r.ref) || byRef.set(r.ref, []).get(r.ref)).push(r);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return byRef;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Does a check record give the row a value for `key` that the page shows (a priced offer, or a claim that found it)? */
|
|
263
|
+
function liveValue(records, key) {
|
|
264
|
+
const list = (records || []).filter((r) => (key === "price" && r.type === "offer" && typeof r.price?.amount === "number") || (r.type === "claim" && r.attr === key));
|
|
265
|
+
const newest = list.sort((a, b) => (Date.parse(b.prov?.observed_at) || 0) - (Date.parse(a.prov?.observed_at) || 0))[0];
|
|
266
|
+
if (!newest) return null;
|
|
267
|
+
if (newest.type === "offer") return newest.price.amount;
|
|
268
|
+
if (["uncertain", "not_found", "error"].includes(newest.status) || newest.value == null) return null;
|
|
269
|
+
return NO_VALUE.test(String(newest.value).trim()) ? null : numberIn(String(newest.value));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The values of a site's page of options: problems to fix before the card, and notes for the reply. */
|
|
273
|
+
export function checkValues(dir) {
|
|
274
|
+
const root = path.resolve(dir);
|
|
275
|
+
const problems = [];
|
|
276
|
+
const notes = [];
|
|
277
|
+
let data;
|
|
278
|
+
try {
|
|
279
|
+
data = JSON.parse(fs.readFileSync(path.join(root, "data.json"), "utf8"));
|
|
280
|
+
} catch {
|
|
281
|
+
return { checked: false, problems, notes };
|
|
282
|
+
}
|
|
283
|
+
if (!isOptionsData(data)) return { checked: false, problems, notes };
|
|
284
|
+
for (const cell of optionCells(data)) {
|
|
285
|
+
const { fact } = cell;
|
|
286
|
+
const where = `${cell.item} · ${cell.label}`;
|
|
287
|
+
if (!fact || typeof fact !== "object") continue; // drawn "Unknown": nothing was found, and the page says so
|
|
288
|
+
if (fact.v != null && !hasValue(fact)) {
|
|
289
|
+
problems.push(`${where}: "${valueOf(fact).trim() || " "}" says nothing. Leave out "v" and say why in "note" ("no price on maker.com's page"), or give the page you read that does not list it ({"src", "url", "date"}); the page then says "Unknown".`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!hasValue(fact) || fact.est) continue;
|
|
293
|
+
const lacks = [!fact.src && '"src"', !httpsUrl(fact.url) && '"url" (the page)', !fact.date && !fact.asof && '"date" (the day)'].filter(Boolean);
|
|
294
|
+
const list = lacks.length > 1 ? `${lacks.slice(0, -1).join(", ")} or ${lacks[lacks.length - 1]}` : lacks[0];
|
|
295
|
+
if (lacks.length) problems.push(`${where}: "${valueOf(fact)}" has no ${list}: add where you read it and when ("checked 27 Oct on maker.com"), or leave the value out.${fact.src ? "" : " Without a source the page shows it as not checked."}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const filter = data.filter;
|
|
299
|
+
if (filter && typeof filter === "object") {
|
|
300
|
+
const label = String(filter.label || "the filter");
|
|
301
|
+
const columns = Array.isArray(data.columns) ? data.columns : null;
|
|
302
|
+
const isNumber = (v) => v != null && v !== "" && Number.isFinite(Number(v));
|
|
303
|
+
if (!filter.key) problems.push(`the filter "${label}" has no "key".`);
|
|
304
|
+
if (!filter.label) problems.push('the filter has no "label" (what the person asked for: "€600 or less").');
|
|
305
|
+
else if (columns && !columns.some((c) => c && c.key === filter.key)) problems.push(`the filter's key "${filter.key}" is not a column, so "${label}" filters nothing.`);
|
|
306
|
+
if (!isNumber(filter.max) && !isNumber(filter.min)) problems.push(`the filter "${label}" has no number in "max" or "min".`);
|
|
307
|
+
let app = "";
|
|
308
|
+
try {
|
|
309
|
+
app = fs.readFileSync(path.join(root, "app.js"), "utf8");
|
|
310
|
+
} catch {
|
|
311
|
+
/* a page without app.js draws no filter */
|
|
312
|
+
}
|
|
313
|
+
if (app && !/\bsite\.byFilter\s*\(|\bsite\.within\s*\(/.test(app)) {
|
|
314
|
+
problems.push(`data.json has a filter, and app.js applies it without site.byFilter: a row whose value is unknown may be listed as "${label}". Use the starter's filter code (site.byFilter), which lists such rows apart.`);
|
|
315
|
+
}
|
|
316
|
+
if (filter.key) {
|
|
317
|
+
const live = liveRecords(root);
|
|
318
|
+
// per item, as the page's filter reads it: its value for the key (none counts as unknown), or a check's
|
|
319
|
+
const factOf = (item) => columns ? item.facts?.[filter.key]
|
|
320
|
+
: filter.key === "price" ? item.price : (Array.isArray(item.details) ? item.details : []).find((d) => d?.key === filter.key)?.fact;
|
|
321
|
+
const unknown = data.items
|
|
322
|
+
.filter((item) => item && typeof item === "object")
|
|
323
|
+
.filter((item) => knownNumber(factOf(item)) == null && liveValue(live.get(item.ref), filter.key) == null)
|
|
324
|
+
.map((item) => String(item.name ?? item.ref ?? "an item"));
|
|
325
|
+
if (unknown.length) {
|
|
326
|
+
const col = columns?.find((c) => c && c.key === filter.key);
|
|
327
|
+
const title = filter.unknown || `${col?.label ?? (filter.key === "price" ? "Price" : filter.key)} unknown`;
|
|
328
|
+
notes.push(`${unknown.length} item(s) have no ${String(col?.label ?? filter.key).toLowerCase()} the page can show: ${unknown.join(", ")}. The page lists them apart ("${title}"), never as "${label}"; say so in the lede and the reply, with why for each.`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { checked: true, problems, notes };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The CLI's lines and exit code: 0 every item shown and every value fine, 1 an item missing or a value to fix, 2
|
|
336
|
+
* nothing to check. */
|
|
161
337
|
export function checkReport(dir, expect, { json = false } = {}) {
|
|
162
338
|
const result = checkSite(dir, expect);
|
|
163
|
-
|
|
339
|
+
const values = checkValues(dir);
|
|
340
|
+
const bad = result.missing.length > 0 || values.problems.length > 0;
|
|
341
|
+
if (!result.items.length && !values.problems.length) {
|
|
164
342
|
const message = 'nothing to check: pass --expect "<item>,<item>" (each item the request named) or put "expect": [...] in data.json';
|
|
165
|
-
return { exit: 2, lines: [json ? JSON.stringify({ ok: false, error: message }) : message] };
|
|
343
|
+
return { exit: 2, lines: [json ? JSON.stringify({ ok: false, error: message, values }) : message, ...(json ? [] : values.notes.map((n) => `NOTE: ${n}`))] };
|
|
166
344
|
}
|
|
167
|
-
if (json) return { exit:
|
|
345
|
+
if (json) return { exit: bad ? 1 : 0, lines: [JSON.stringify({ ok: !bad, ...result, values })] };
|
|
168
346
|
const lines = [];
|
|
169
347
|
if (result.missing.length) {
|
|
170
348
|
lines.push(`MISSING: the request named ${result.missing.length} item(s) the site does not show: ${result.missing.join(", ")}.`);
|
|
171
349
|
lines.push("Add each one to the page (its data file), or say plainly in the reply which are missing and why.");
|
|
172
350
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
351
|
+
if (values.problems.length) {
|
|
352
|
+
lines.push(`VALUES: ${values.problems.length} value(s) to fix in data.json:`);
|
|
353
|
+
for (const problem of values.problems) lines.push(`- ${problem}`);
|
|
354
|
+
}
|
|
355
|
+
for (const note of values.notes) lines.push(`NOTE: ${note}`);
|
|
356
|
+
if (result.items.length) {
|
|
357
|
+
lines.push(`${result.shown.length} of ${result.items.length} expected item(s) shown${result.shown.length ? `: ${result.shown.map((s) => s.item).join(", ")}` : ""}.`);
|
|
358
|
+
lines.push(`Read ${result.files.length} file(s): the HTML text and the data files (what a script renders from elsewhere is not seen).`);
|
|
359
|
+
} else {
|
|
360
|
+
lines.push('No items to check: pass --expect "<item>,<item>" (each item the request named) or put "expect": [...] in data.json.');
|
|
361
|
+
}
|
|
362
|
+
return { exit: bad ? 1 : 0, lines };
|
|
176
363
|
}
|
|
@@ -10,16 +10,20 @@
|
|
|
10
10
|
* changed, then missing ones). Otherwise rows keep the order of "items", before and after a
|
|
11
11
|
* price check: put them in the order the lede talks about them.
|
|
12
12
|
* columns: [{key, label, type: "price"|"number"|"text"}] at most 5; a "price" column can be checked live
|
|
13
|
-
* filter (only for a constraint the person stated): {key, max, label: "Under $160", rest: "over $160"
|
|
13
|
+
* filter (only for a constraint the person stated): {key, max, label: "Under $160", rest: "over $160",
|
|
14
|
+
* unknown?: "Price unknown"}. Only a value the page shows counts: a row whose value is
|
|
15
|
+
* unknown is never listed as under $160; it is listed apart, under "Price unknown".
|
|
14
16
|
* items: [{ref: "option:a", name, why (one short line), badge?: "Previous model", note?, facts: {key: fact},
|
|
15
17
|
* store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}]}]
|
|
16
18
|
* unchecked: ["what was not checked", …] listed once in the footer
|
|
17
19
|
* fact: {"v": "$145", "src": "Example Store", "url": "https://…", "date": "2026-10-17", "asof"?: "2026-08-05", "est": false}
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Each value cites its page and day ("checked 17 Oct on maker.com ↗"), and no cell is blank: a source you read that
|
|
21
|
+
* does not give the value is the fact without "v" ({"src", "url", "date"}): "Not listed · checked 17 Oct on
|
|
22
|
+
* maker.com ↗"; any other missing value reads "Unknown", with {"note": "the store blocks price checks"} saying why.
|
|
23
|
+
* Never a dash.
|
|
24
|
+
* When one page gives most of a column on one date, the cells show just the value and one line under the list cites
|
|
25
|
+
* that page; so does a row whose own values come from one other page ("Option C (Support and Ride): checked 16 Oct on
|
|
26
|
+
* fleetfeet.com ↗"). Any other cell keeps its own caption.
|
|
23
27
|
* Query: ?sort=<key>&show=all&open=<slug>. Rows: id="row-<slug>", data-ref="<ref>".
|
|
24
28
|
* SPDX-License-Identifier: AGPL-3.0-only */
|
|
25
29
|
(function () {
|
|
@@ -60,7 +64,12 @@
|
|
|
60
64
|
if (live && site.blocked(item.ref)) return seed; // a blocked check keeps the seed value
|
|
61
65
|
return live || seed;
|
|
62
66
|
}
|
|
63
|
-
/**
|
|
67
|
+
/** A fact's page and day: two values share a citation only when one page on one day gives both. */
|
|
68
|
+
function citeKey(f) {
|
|
69
|
+
var page = site.safeUrl(f.url);
|
|
70
|
+
return [page ? page.split("#")[0] : "src:" + f.src, f.date || "", f.asof || ""].join("|");
|
|
71
|
+
}
|
|
72
|
+
/** Per column: the page most of its cells share ({src, date, asof, url}), when two or more cells and over half do. */
|
|
64
73
|
function sharedSources(data, items) {
|
|
65
74
|
var out = {};
|
|
66
75
|
columns(data).forEach(function (c) {
|
|
@@ -68,8 +77,8 @@
|
|
|
68
77
|
var count = {};
|
|
69
78
|
items.forEach(function (i) {
|
|
70
79
|
var f = seedOf(i, c);
|
|
71
|
-
if (!f || !f.src || f.est || f
|
|
72
|
-
var k =
|
|
80
|
+
if (!f || !f.src || f.est || !site.valueText(f)) return;
|
|
81
|
+
var k = citeKey(f);
|
|
73
82
|
count[k] = count[k] || { n: 0, fact: f };
|
|
74
83
|
count[k].n++;
|
|
75
84
|
});
|
|
@@ -81,11 +90,11 @@
|
|
|
81
90
|
function isShared(shared, item, col) {
|
|
82
91
|
var s = shared[col.key];
|
|
83
92
|
var f = seedOf(item, col);
|
|
84
|
-
return !!(s && f && f.src && !f.est &&
|
|
93
|
+
return !!(s && f && f.src && !f.est && citeKey(f) === s.key);
|
|
85
94
|
}
|
|
86
|
-
/** Per row: the one
|
|
87
|
-
*
|
|
88
|
-
* ("Option C (Support and Ride):
|
|
95
|
+
/** Per row: the one page its own cells share ({key, fact, labels}), when two or more of its cells outside the price
|
|
96
|
+
* column come from one page on one date that is not their column's. The line under the list cites it once
|
|
97
|
+
* ("Option C (Support and Ride): checked 16 Oct on fleetfeet.com ↗"), so the row is not a stack of links. */
|
|
89
98
|
function rowSources(data, items, shared) {
|
|
90
99
|
var out = {};
|
|
91
100
|
items.forEach(function (i) {
|
|
@@ -94,9 +103,9 @@
|
|
|
94
103
|
if (isPrice(c) || isShared(shared, i, c)) return;
|
|
95
104
|
var f = seedOf(i, c);
|
|
96
105
|
if (!f || !f.src || f.est) return;
|
|
97
|
-
var k =
|
|
106
|
+
var k = citeKey(f);
|
|
98
107
|
(by[k] = by[k] || { key: k, fact: f, labels: [], values: 0 }).labels.push(c.label);
|
|
99
|
-
if (f
|
|
108
|
+
if (site.valueText(f)) by[k].values++;
|
|
100
109
|
});
|
|
101
110
|
var keys = Object.keys(by);
|
|
102
111
|
if (keys.length === 1 && by[keys[0]].labels.length >= 2 && by[keys[0]].values >= 1) out[i.ref] = by[keys[0]];
|
|
@@ -106,14 +115,14 @@
|
|
|
106
115
|
function ownRow(rows, item, col) {
|
|
107
116
|
var r = rows[item.ref];
|
|
108
117
|
var f = seedOf(item, col);
|
|
109
|
-
return !!(r && f && f.src && !f.est && !isPrice(col) &&
|
|
118
|
+
return !!(r && f && f.src && !f.est && !isPrice(col) && citeKey(f) === r.key);
|
|
110
119
|
}
|
|
111
|
-
/**
|
|
120
|
+
/** A missing value reads "Unknown" (with label: "Price unknown", where no column header or fact label says what). */
|
|
112
121
|
function cell(item, col, src, label) {
|
|
113
122
|
if (isPrice(col)) {
|
|
114
|
-
return site.trust(valueOf(item, col), { price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: seedOf(item, col), label: label });
|
|
123
|
+
return site.trust(valueOf(item, col), { price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: seedOf(item, col), label: label, unknown: "Unknown" });
|
|
115
124
|
}
|
|
116
|
-
return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label });
|
|
125
|
+
return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label, unknown: "Unknown" });
|
|
117
126
|
}
|
|
118
127
|
function hasMore(item) { return !!(item.note || (item.more && item.more.length) || (item.sources && item.sources.length)); }
|
|
119
128
|
|
|
@@ -125,22 +134,28 @@
|
|
|
125
134
|
var def = (data.sort || []).filter(function (s) { return s.key === st.sort; })[0] || (data.sort || [])[0];
|
|
126
135
|
if (!def) return items;
|
|
127
136
|
var col = colOf(data, def.key) || { key: def.key };
|
|
128
|
-
var tier = function (i) { var v = valueOf(i, col); return site.
|
|
137
|
+
var tier = function (i) { var v = valueOf(i, col); return site.known(v) == null ? 2 : site.old(v, isPrice(col)) ? 1 : 0; };
|
|
129
138
|
return items.slice().sort(function (a, b) {
|
|
130
139
|
var ta = tier(a), tb = tier(b);
|
|
131
140
|
if (ta !== tb) return ta - tb;
|
|
132
141
|
if (ta === 2) return 0;
|
|
133
|
-
var x = site.
|
|
142
|
+
var x = site.known(valueOf(a, col)), y = site.known(valueOf(b, col));
|
|
134
143
|
return def.dir === "desc" ? y - x : x - y;
|
|
135
144
|
});
|
|
136
145
|
}
|
|
137
|
-
|
|
146
|
+
/** The rows by the page's one filter: {in, unknown, out}. A row whose value is unknown is never "in". */
|
|
147
|
+
function byFilter(data, items) {
|
|
148
|
+
var f = data.filter;
|
|
149
|
+
var col = f ? colOf(data, f.key) || { key: f.key } : null;
|
|
150
|
+
return site.byFilter(items, f, function (i) { return valueOf(i, col); });
|
|
151
|
+
}
|
|
152
|
+
/** "Price unknown": the heading of the rows the filter cannot place. */
|
|
153
|
+
function unknownTitle(data) {
|
|
138
154
|
var f = data.filter;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (n == null) return true;
|
|
142
|
-
return (f.max == null || n <= f.max) && (f.min == null || n >= f.min);
|
|
155
|
+
var col = colOf(data, f.key);
|
|
156
|
+
return f.unknown || ((col ? col.label : f.key) + " unknown");
|
|
143
157
|
}
|
|
158
|
+
function lowerFirst(text) { text = String(text || ""); return text.charAt(0).toLowerCase() + text.slice(1); }
|
|
144
159
|
|
|
145
160
|
function render(data, st) {
|
|
146
161
|
site.head(data);
|
|
@@ -152,8 +167,12 @@
|
|
|
152
167
|
items = lastOrder.map(function (ref) { return all.filter(function (i) { return i.ref === ref; })[0]; }).filter(Boolean);
|
|
153
168
|
}
|
|
154
169
|
lastOrder = items.map(function (i) { return i.ref; });
|
|
155
|
-
|
|
156
|
-
|
|
170
|
+
// "All" is one list; the stated limit shows the rows within it, then the rows nobody could place ("Price unknown",
|
|
171
|
+
// never counted as within it), and one line for the rest
|
|
172
|
+
var parts = st.show === "all" || !data.filter ? { "in": items, unknown: [], out: [] } : byFilter(data, items);
|
|
173
|
+
var shown = parts["in"];
|
|
174
|
+
var apart = parts.unknown;
|
|
175
|
+
var hidden = parts.out.length;
|
|
157
176
|
var hasPrice = columns(data).some(isPrice);
|
|
158
177
|
var cols = sharedSources(data, all);
|
|
159
178
|
var shared = { cols: cols, rows: rowSources(data, all, cols) };
|
|
@@ -179,7 +198,15 @@
|
|
|
179
198
|
} else {
|
|
180
199
|
var stores = hasPrice ? site.priceNotice(all) : null;
|
|
181
200
|
wideNow = isWide();
|
|
182
|
-
var wrap = h("div", { "class": "stack" }
|
|
201
|
+
var wrap = h("div", { "class": "stack" });
|
|
202
|
+
if (shown.length) wrap.appendChild(wideNow ? table(data, shown, st, shared) : cards(data, shown, st, shared));
|
|
203
|
+
else wrap.appendChild(h("p", { "class": "cap", text: "None is known to be " + lowerFirst(data.filter.label || "within the limit") + "." }));
|
|
204
|
+
if (apart.length) {
|
|
205
|
+
wrap.appendChild(h("section", { "class": "group", attrs: { id: "group-unknown", "aria-label": unknownTitle(data) } },
|
|
206
|
+
h("div", { "class": "group-head" }, h("h2", { text: unknownTitle(data) }),
|
|
207
|
+
h("span", { "class": "cap", text: "Not counted as " + lowerFirst(data.filter.label || "within the limit") })),
|
|
208
|
+
wideNow ? table(data, apart, st, shared) : cards(data, apart, st, shared)));
|
|
209
|
+
}
|
|
183
210
|
var lines = [];
|
|
184
211
|
if (hidden > 0) {
|
|
185
212
|
lines.push(h("p", { "class": "cap hidden-line" }, hidden + " more " + (data.filter.rest || "hidden") + " · ",
|
|
@@ -215,12 +242,12 @@
|
|
|
215
242
|
var def = (data.sort || []).filter(function (s) { return s.key === st.sort; })[0] || (data.sort || [])[0];
|
|
216
243
|
if (!def) return null;
|
|
217
244
|
var col = colOf(data, def.key) || { key: def.key, label: def.key };
|
|
218
|
-
var n = items.filter(function (i) { return site.
|
|
245
|
+
var n = items.filter(function (i) { return site.known(valueOf(i, col)) == null; }).length;
|
|
219
246
|
if (!n || n === items.length) return null;
|
|
220
247
|
return h("p", { "class": "cap", text: n + " without " + (/^[aeiou]/i.test(col.label) ? "an " : "a ") + String(col.label).toLowerCase() + (n === 1 ? " is" : " are") + " listed last." });
|
|
221
248
|
}
|
|
222
|
-
/** "Support and Heel drop:
|
|
223
|
-
* of those columns has its own source. */
|
|
249
|
+
/** "Support and Heel drop: checked 16 Oct on runningshoesguru.com ↗", plus ", unless a value says otherwise" when a
|
|
250
|
+
* cell of those columns has its own source. */
|
|
224
251
|
function sharedLine(data, src, items) {
|
|
225
252
|
var groups = {};
|
|
226
253
|
var and = function (list) { return list.length > 1 ? list.slice(0, -1).join(", ") + " and " + list[list.length - 1] : list[0]; };
|
|
@@ -230,18 +257,16 @@
|
|
|
230
257
|
var g = groups[s.key] = groups[s.key] || { fact: s.fact, labels: [], others: false };
|
|
231
258
|
g.labels.push(c.label);
|
|
232
259
|
// a value with its own source that no row line names keeps its own caption: "unless a value says otherwise"
|
|
233
|
-
if (items.some(function (i) { var f = seedOf(i, c); return f && f.src && f
|
|
260
|
+
if (items.some(function (i) { var f = seedOf(i, c); return f && f.src && site.valueText(f) && !isShared(src.cols, i, c) && !ownRow(src.rows, i, c); })) g.others = true;
|
|
234
261
|
});
|
|
235
262
|
var out = Object.keys(groups).map(function (k) {
|
|
236
263
|
var g = groups[k];
|
|
237
|
-
|
|
238
|
-
return h("p", { "class": "cap" }, and(g.labels) + ": ", site.link(g.fact.url, g.fact.src), when ? [" · ", h("span", { "class": "nowrap", text: when })] : "", g.others ? ", unless a value says otherwise" : "");
|
|
264
|
+
return h("p", { "class": "cap" }, and(g.labels) + ": ", site.cite(g.fact), g.others ? ", unless a value says otherwise" : "");
|
|
239
265
|
});
|
|
240
266
|
items.forEach(function (i) {
|
|
241
267
|
var r = src.rows[i.ref];
|
|
242
268
|
if (!r) return;
|
|
243
|
-
|
|
244
|
-
out.push(h("p", { "class": "cap" }, i.name + " (" + and(r.labels) + "): ", site.link(r.fact.url, r.fact.src), when ? [" · ", h("span", { "class": "nowrap", text: when })] : ""));
|
|
269
|
+
out.push(h("p", { "class": "cap" }, i.name + " (" + and(r.labels) + "): ", site.cite(r.fact)));
|
|
245
270
|
});
|
|
246
271
|
return out.length ? h("div", null, out) : null;
|
|
247
272
|
}
|
|
@@ -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
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
/** "Price unknown": the heading of the rows the filter cannot place. */
|
|
49
|
+
function unknownTitle(data) {
|
|
41
50
|
var f = data.filter;
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return (
|
|
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
|
-
|
|
51
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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": "
|
|
109
|
+
"why": "Well reviewed, by fewer people",
|
|
110
110
|
"specs": [
|
|
111
111
|
{
|
|
112
112
|
"v": "9.8 oz",
|