pagegraph 0.6.0 → 0.7.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 +226 -5
- package/dist/audit.js +1 -1
- package/dist/checks-6noG_xTS.d.ts +313 -0
- package/dist/cli.js +1980 -609
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/index.d.ts +2 -75
- package/dist/index.js +47 -13
- package/dist/index.js.map +1 -1
- package/dist/{links-sGbLkl-7.js → links-BFJc-pJ9.js} +152 -18
- package/dist/links-BFJc-pJ9.js.map +1 -0
- package/package.json +1 -1
- package/dist/checks-BfsQtKga.d.ts +0 -163
- package/dist/links-sGbLkl-7.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import * as NFS from "node:fs";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import * as NodeChildProcess from "node:child_process";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { PassThrough } from "node:stream";
|
|
@@ -20616,7 +20616,7 @@ const Boolean$5 = class extends ASTNodeImpl {
|
|
|
20616
20616
|
* @category constructors
|
|
20617
20617
|
* @since 4.0.0
|
|
20618
20618
|
*/
|
|
20619
|
-
const boolean = /*#__PURE__*/ new Boolean$5();
|
|
20619
|
+
const boolean$1 = /*#__PURE__*/ new Boolean$5();
|
|
20620
20620
|
/**
|
|
20621
20621
|
* Constructs a {@link BigInt}.
|
|
20622
20622
|
*
|
|
@@ -25834,7 +25834,7 @@ const Number$2 = /*#__PURE__*/ make$14(number);
|
|
|
25834
25834
|
* @category schemas
|
|
25835
25835
|
* @since 4.0.0
|
|
25836
25836
|
*/
|
|
25837
|
-
const Boolean$4 = /*#__PURE__*/ make$14(boolean);
|
|
25837
|
+
const Boolean$4 = /*#__PURE__*/ make$14(boolean$1);
|
|
25838
25838
|
/**
|
|
25839
25839
|
* Schema for `bigint` values. Validates that the input is `typeof` `"bigint"`.
|
|
25840
25840
|
*
|
|
@@ -26424,6 +26424,28 @@ const makeFilter = makeFilter$1;
|
|
|
26424
26424
|
*/
|
|
26425
26425
|
const Finite$3 = /*#__PURE__*/ make$14(finite);
|
|
26426
26426
|
/**
|
|
26427
|
+
* Creates a greater-than-or-equal-to (`>=`) check for any ordered type from an
|
|
26428
|
+
* `Order.Order` instance.
|
|
26429
|
+
*
|
|
26430
|
+
* @category validation
|
|
26431
|
+
* @since 4.0.0
|
|
26432
|
+
*/
|
|
26433
|
+
function makeIsGreaterThanOrEqualTo(options) {
|
|
26434
|
+
const gte = isGreaterThanOrEqualTo$1(options.order);
|
|
26435
|
+
const formatter = options.formatter ?? format$2;
|
|
26436
|
+
return (minimum, annotations) => {
|
|
26437
|
+
return makeFilter((input) => gte(input, minimum), {
|
|
26438
|
+
expected: `a value greater than or equal to ${formatter(minimum)}`,
|
|
26439
|
+
arbitraryConstraint: {
|
|
26440
|
+
order: options.order,
|
|
26441
|
+
minimum
|
|
26442
|
+
},
|
|
26443
|
+
...options.annotate?.(minimum),
|
|
26444
|
+
...annotations
|
|
26445
|
+
});
|
|
26446
|
+
};
|
|
26447
|
+
}
|
|
26448
|
+
/**
|
|
26427
26449
|
* Creates an inclusive or exclusive range check for any ordered type from an
|
|
26428
26450
|
* `Order.Order` instance.
|
|
26429
26451
|
*
|
|
@@ -26458,6 +26480,35 @@ function encodeNumberPayload(number) {
|
|
|
26458
26480
|
return number;
|
|
26459
26481
|
}
|
|
26460
26482
|
/**
|
|
26483
|
+
* Validates that a number is greater than or equal to the specified value
|
|
26484
|
+
* (inclusive).
|
|
26485
|
+
*
|
|
26486
|
+
* **Details**
|
|
26487
|
+
*
|
|
26488
|
+
* JSON Schema:
|
|
26489
|
+
*
|
|
26490
|
+
* This check corresponds to the `minimum` constraint in JSON Schema.
|
|
26491
|
+
*
|
|
26492
|
+
* Arbitrary:
|
|
26493
|
+
*
|
|
26494
|
+
* During arbitrary generation, this applies a `minimum` constraint
|
|
26495
|
+
* to ensure generated numbers are greater than or equal to the specified value.
|
|
26496
|
+
*
|
|
26497
|
+
* @category validation
|
|
26498
|
+
* @since 4.0.0
|
|
26499
|
+
*/
|
|
26500
|
+
const isGreaterThanOrEqualTo = /*#__PURE__*/ makeIsGreaterThanOrEqualTo({
|
|
26501
|
+
order: Number$6,
|
|
26502
|
+
annotate: (minimum) => ({
|
|
26503
|
+
representation: {
|
|
26504
|
+
id: "effect/schema/isGreaterThanOrEqualTo",
|
|
26505
|
+
payload: { minimum: encodeNumberPayload(minimum) }
|
|
26506
|
+
},
|
|
26507
|
+
toJsonSchema: () => ({ minimum }),
|
|
26508
|
+
toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualTo(${format$2(minimum)})` })
|
|
26509
|
+
})
|
|
26510
|
+
});
|
|
26511
|
+
/**
|
|
26461
26512
|
* Validates that a number is within a specified range. The range boundaries can
|
|
26462
26513
|
* be inclusive or exclusive based on the provided options.
|
|
26463
26514
|
*
|
|
@@ -26538,6 +26589,19 @@ function isInt(annotations) {
|
|
|
26538
26589
|
* @since 3.10.0
|
|
26539
26590
|
*/
|
|
26540
26591
|
const Int$4 = /*#__PURE__*/ Number$2.check(/*#__PURE__*/ isInt());
|
|
26592
|
+
/**
|
|
26593
|
+
* Schema for non-negative safe integers, including zero.
|
|
26594
|
+
*
|
|
26595
|
+
* **When to use**
|
|
26596
|
+
*
|
|
26597
|
+
* Use when you need a count, index, or size that cannot be negative.
|
|
26598
|
+
*
|
|
26599
|
+
* @see {@link Int} for safe integers that may be negative
|
|
26600
|
+
*
|
|
26601
|
+
* @category schemas
|
|
26602
|
+
* @since 4.0.0
|
|
26603
|
+
*/
|
|
26604
|
+
const Natural = /*#__PURE__*/ Int$4.check(/*#__PURE__*/ isGreaterThanOrEqualTo(0));
|
|
26541
26605
|
const getErrorOptionsKey = (options) => (options?.includeStack === true ? 1 : 0) | (options?.excludeCause === true ? 2 : 0);
|
|
26542
26606
|
const getErrorOptions = (key) => {
|
|
26543
26607
|
switch (key) {
|
|
@@ -34612,7 +34676,7 @@ const runWith = (command, config) => {
|
|
|
34612
34676
|
};
|
|
34613
34677
|
//#endregion
|
|
34614
34678
|
//#region package.json
|
|
34615
|
-
var version = "0.
|
|
34679
|
+
var version = "0.7.0";
|
|
34616
34680
|
//#endregion
|
|
34617
34681
|
//#region src/core/projections.ts
|
|
34618
34682
|
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -34928,19 +34992,17 @@ const globToRegExp = (glob) => {
|
|
|
34928
34992
|
return new RegExp(`^${source}$`);
|
|
34929
34993
|
};
|
|
34930
34994
|
/**
|
|
34931
|
-
* Enforce contextual-link coverage rules
|
|
34932
|
-
* "money" pages each needs `minInbound` incoming `related` edges. Only
|
|
34933
|
-
* `related` edges count — breadcrumb ancestry is navigation, not context.
|
|
34995
|
+
* Enforce contextual-link coverage rules over an explicit surface.
|
|
34934
34996
|
*
|
|
34935
34997
|
* A rule that matches no sitemap-eligible page is itself a violation: a typo or
|
|
34936
34998
|
* a rule aimed at a noindex page would otherwise pass silently forever.
|
|
34937
34999
|
*/
|
|
34938
|
-
|
|
35000
|
+
const checkCoverageRules = (surface, rules) => {
|
|
34939
35001
|
const violations = [];
|
|
34940
35002
|
for (const rule of rules) {
|
|
34941
35003
|
const matcher = globToRegExp(rule.path);
|
|
34942
|
-
const matched =
|
|
34943
|
-
const eligible =
|
|
35004
|
+
const matched = surface.paths.filter((path) => matcher.test(path));
|
|
35005
|
+
const eligible = surface.eligiblePaths.filter((path) => matcher.test(path));
|
|
34944
35006
|
if (eligible.length === 0) {
|
|
34945
35007
|
violations.push({
|
|
34946
35008
|
severity: "structural",
|
|
@@ -34950,19 +35012,55 @@ function checkCoverage(graph, rules) {
|
|
|
34950
35012
|
});
|
|
34951
35013
|
continue;
|
|
34952
35014
|
}
|
|
34953
|
-
for (const
|
|
34954
|
-
const inbound =
|
|
35015
|
+
for (const path of eligible) {
|
|
35016
|
+
const inbound = surface.inbound(path);
|
|
34955
35017
|
if (inbound >= rule.minInbound) continue;
|
|
34956
35018
|
violations.push({
|
|
34957
35019
|
severity: "structural",
|
|
34958
35020
|
rule: "inbound-link-coverage",
|
|
34959
|
-
path
|
|
34960
|
-
message: `"${
|
|
34961
|
-
fix: `Add ${rule.minInbound - inbound} contextual (
|
|
35021
|
+
path,
|
|
35022
|
+
message: `"${path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
|
|
35023
|
+
fix: `Add ${rule.minInbound - inbound} contextual link(s) pointing at "${path}".`
|
|
34962
35024
|
});
|
|
34963
35025
|
}
|
|
34964
35026
|
}
|
|
34965
35027
|
return violations;
|
|
35028
|
+
};
|
|
35029
|
+
/** The sitemap-eligible path set of a declared graph, in graph insertion order. */
|
|
35030
|
+
const eligiblePathsOf = (graph) => [...graph.nodes.values()].filter(isSitemapEligible).map((node) => node.path);
|
|
35031
|
+
/**
|
|
35032
|
+
* Enforce contextual-link coverage against the *declared* graph: only `related`
|
|
35033
|
+
* edges count — breadcrumb ancestry is navigation, not context.
|
|
35034
|
+
*/
|
|
35035
|
+
function checkCoverage(graph, rules) {
|
|
35036
|
+
return checkCoverageRules({
|
|
35037
|
+
paths: [...graph.nodes.keys()],
|
|
35038
|
+
eligiblePaths: eligiblePathsOf(graph),
|
|
35039
|
+
inbound: (path) => graph.edges.filter((edge) => edge.type === "related" && edge.to === path).length
|
|
35040
|
+
}, rules);
|
|
35041
|
+
}
|
|
35042
|
+
/**
|
|
35043
|
+
* Enforce contextual-link coverage against the *rendered* graph: only same-origin
|
|
35044
|
+
* body-region anchors count. Nav, header, and footer anchors are site chrome, so
|
|
35045
|
+
* a page linked solely from them has no contextual inbound link — the same
|
|
35046
|
+
* distinction the declared pass draws by counting `related` edges alone.
|
|
35047
|
+
*
|
|
35048
|
+
* The target universe is the declared graph's sitemap-eligible pages: the globs
|
|
35049
|
+
* in `seo.config.ts` are written against declared paths, and the crawl is what
|
|
35050
|
+
* supplies the served edges. Pass the crawl's `internalEdges` (each edge carries
|
|
35051
|
+
* its `region`).
|
|
35052
|
+
*/
|
|
35053
|
+
function checkRenderedCoverage(graph, renderedEdges, rules) {
|
|
35054
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
35055
|
+
for (const edge of renderedEdges) {
|
|
35056
|
+
if (edge.region !== "body") continue;
|
|
35057
|
+
inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
|
|
35058
|
+
}
|
|
35059
|
+
return checkCoverageRules({
|
|
35060
|
+
paths: [...graph.nodes.keys()],
|
|
35061
|
+
eligiblePaths: eligiblePathsOf(graph),
|
|
35062
|
+
inbound: (path) => inbound.get(path) ?? 0
|
|
35063
|
+
}, rules);
|
|
34966
35064
|
}
|
|
34967
35065
|
/** Structural violations fail `pagegraph check`; editorial-only stays green. */
|
|
34968
35066
|
const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
|
|
@@ -35017,7 +35115,7 @@ const CONFIG_FILENAMES = [
|
|
|
35017
35115
|
"seo.config.js",
|
|
35018
35116
|
"seo.config.mjs"
|
|
35019
35117
|
];
|
|
35020
|
-
const messageOf$
|
|
35118
|
+
const messageOf$2 = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
35021
35119
|
/** First `seo.config.*` at or above `from`, or undefined at the filesystem root. */
|
|
35022
35120
|
const findConfigFile = (from) => {
|
|
35023
35121
|
let directory = resolve(from);
|
|
@@ -35045,7 +35143,7 @@ const loadConfigFile = (configPath) => gen(function* () {
|
|
|
35045
35143
|
yield* logDebug(`Loading SEO config from ${configPath}`);
|
|
35046
35144
|
const module = yield* tryPromise({
|
|
35047
35145
|
try: () => import(pathToFileURL(configPath).href),
|
|
35048
|
-
catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$
|
|
35146
|
+
catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$2(cause)}` })
|
|
35049
35147
|
});
|
|
35050
35148
|
if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, disallow, loadGraph }).` });
|
|
35051
35149
|
return module.default;
|
|
@@ -35080,8 +35178,8 @@ const acquireGraph = (config) => gen(function* () {
|
|
|
35080
35178
|
yield* logDebug("Loading the SEO graph…");
|
|
35081
35179
|
const loaded = yield* acquireRelease(tryPromise({
|
|
35082
35180
|
try: () => config.loadGraph(),
|
|
35083
|
-
catch: (cause) => new SeoCliError({ message: messageOf$
|
|
35084
|
-
}), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$
|
|
35181
|
+
catch: (cause) => new SeoCliError({ message: messageOf$2(cause) })
|
|
35182
|
+
}), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$2(defect)}`))));
|
|
35085
35183
|
yield* logDebug(`Loaded SEO graph: ${loaded.graph.nodes.size} nodes, ${loaded.graph.edges.length} edges.`);
|
|
35086
35184
|
return loaded.graph;
|
|
35087
35185
|
});
|
|
@@ -35289,6 +35387,17 @@ const renderLinksReport = (report) => {
|
|
|
35289
35387
|
lines.push(` rendered not declared ${report.declared.renderedNotDeclared.length}`);
|
|
35290
35388
|
for (const edge of report.declared.renderedNotDeclared) lines.push(edgeLine(edge));
|
|
35291
35389
|
}
|
|
35390
|
+
if (report.coverage !== void 0) {
|
|
35391
|
+
const { coverage } = report;
|
|
35392
|
+
lines.push("", `Rendered coverage (${coverage.rules} rule(s)):`);
|
|
35393
|
+
if (coverage.ok) lines.push(" ✓ every coverage rule is satisfied by the served anchors.");
|
|
35394
|
+
else for (const violation of coverage.violations) {
|
|
35395
|
+
const where = violation.path ? ` ${violation.path}` : "";
|
|
35396
|
+
lines.push(` ✗ [${violation.rule}]${where}`);
|
|
35397
|
+
lines.push(` ${violation.message}`);
|
|
35398
|
+
if (violation.fix) lines.push(` fix: ${violation.fix}`);
|
|
35399
|
+
}
|
|
35400
|
+
}
|
|
35292
35401
|
if (crawl.failures.length > 0) {
|
|
35293
35402
|
lines.push("", `${crawl.failures.length} page(s) could not be fetched:`);
|
|
35294
35403
|
for (const failure of crawl.failures) lines.push(` ${failure.url} ${failure.error}`);
|
|
@@ -35306,8 +35415,8 @@ const VERDICT_LABELS = {
|
|
|
35306
35415
|
review: "Review"
|
|
35307
35416
|
};
|
|
35308
35417
|
const decisionLine = (record) => [
|
|
35309
|
-
` ${record.
|
|
35310
|
-
` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)}`,
|
|
35418
|
+
` ${record.from} ${record.direction === "both" ? "↔" : "→"} ${record.to}`,
|
|
35419
|
+
` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)} · direction ${record.direction} · relevance ${record.relevance.label}`,
|
|
35311
35420
|
` "${record.sourceText}"`
|
|
35312
35421
|
];
|
|
35313
35422
|
const verdictBlock = (verdict, records) => {
|
|
@@ -35323,8 +35432,9 @@ const verdictBlock = (verdict, records) => {
|
|
|
35323
35432
|
*/
|
|
35324
35433
|
const renderLinksDecideReport = (report) => {
|
|
35325
35434
|
const { counts } = report;
|
|
35435
|
+
const budget = report.budget === null ? "none" : `${report.budget}/source`;
|
|
35326
35436
|
const lines = [
|
|
35327
|
-
`${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
|
|
35437
|
+
`${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)} · budget ${budget} · dropped ${report.dropped}`,
|
|
35328
35438
|
"",
|
|
35329
35439
|
` recommend ${counts.recommend}`,
|
|
35330
35440
|
` present ${counts.present}`,
|
|
@@ -36214,6 +36324,30 @@ const breadthFirstDepth = (edges, root) => {
|
|
|
36214
36324
|
return depth;
|
|
36215
36325
|
};
|
|
36216
36326
|
/**
|
|
36327
|
+
* Assemble a {@link RenderedGraph} from page/edge sets. Edges are already
|
|
36328
|
+
* same-origin and path-keyed by the callers, so this is the shared, pure tail of
|
|
36329
|
+
* {@link buildRenderedGraph} and {@link renderedGraphFromEdges}.
|
|
36330
|
+
*/
|
|
36331
|
+
const graphFromEdges = (nodes, edges, root) => {
|
|
36332
|
+
const rootKey = root.replace(/\/+$/, "") || "/";
|
|
36333
|
+
const internalEdges = edges;
|
|
36334
|
+
const contextualEdges = edges.filter((edge) => edge.region === "body");
|
|
36335
|
+
const incoming = new Set(internalEdges.map((edge) => edge.to));
|
|
36336
|
+
const orphans = [...nodes].filter((path) => path !== rootKey && !incoming.has(path)).sort();
|
|
36337
|
+
const depthByPath = breadthFirstDepth(internalEdges, rootKey);
|
|
36338
|
+
let maxDepth = null;
|
|
36339
|
+
for (const value of depthByPath.values()) if (maxDepth === null || value > maxDepth) maxDepth = value;
|
|
36340
|
+
return {
|
|
36341
|
+
nodes: [...nodes],
|
|
36342
|
+
edges,
|
|
36343
|
+
internalEdges,
|
|
36344
|
+
contextualEdges,
|
|
36345
|
+
orphans,
|
|
36346
|
+
depthByPath,
|
|
36347
|
+
maxDepth
|
|
36348
|
+
};
|
|
36349
|
+
};
|
|
36350
|
+
/**
|
|
36217
36351
|
* Build the rendered graph from crawled pages. `origin` fixes the graph's
|
|
36218
36352
|
* same-origin boundary — pages and anchors on another origin are dropped, not
|
|
36219
36353
|
* silently folded in — and `root` is the normalized path the depth BFS starts
|
|
@@ -36222,7 +36356,6 @@ const breadthFirstDepth = (edges, root) => {
|
|
|
36222
36356
|
*/
|
|
36223
36357
|
const buildRenderedGraph = (origin, pages, root = "/") => {
|
|
36224
36358
|
const graphOrigin = new URL(origin).origin;
|
|
36225
|
-
const rootKey = root.replace(/\/+$/, "") || "/";
|
|
36226
36359
|
const edges = [];
|
|
36227
36360
|
const nodes = /* @__PURE__ */ new Set();
|
|
36228
36361
|
for (const page of pages) {
|
|
@@ -36252,21 +36385,39 @@ const buildRenderedGraph = (origin, pages, root = "/") => {
|
|
|
36252
36385
|
});
|
|
36253
36386
|
}
|
|
36254
36387
|
}
|
|
36255
|
-
|
|
36256
|
-
|
|
36257
|
-
|
|
36258
|
-
|
|
36259
|
-
|
|
36260
|
-
|
|
36261
|
-
|
|
36262
|
-
|
|
36263
|
-
|
|
36264
|
-
|
|
36265
|
-
|
|
36266
|
-
|
|
36267
|
-
|
|
36268
|
-
|
|
36269
|
-
|
|
36388
|
+
return graphFromEdges(nodes, edges, root);
|
|
36389
|
+
};
|
|
36390
|
+
/** A URL or an already-normalized graph path key both collapse to the path key. */
|
|
36391
|
+
const pathKeyOf = (value) => {
|
|
36392
|
+
try {
|
|
36393
|
+
return normalizePath(new URL(value));
|
|
36394
|
+
} catch {
|
|
36395
|
+
return (value.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "") || "/";
|
|
36396
|
+
}
|
|
36397
|
+
};
|
|
36398
|
+
/**
|
|
36399
|
+
* Rebuild a {@link RenderedGraph} from a rendered-edge set alone — the artifact
|
|
36400
|
+
* `pagegraph links verify --emit-rendered` writes. Path keys are normalized the
|
|
36401
|
+
* same way as a crawl, and `nodes` carries the rendered page paths the artifact
|
|
36402
|
+
* recorded, so an edge-less page (a redirect target with no internal links) stays
|
|
36403
|
+
* a node and its orphan/depth report survives replay. Without `nodes` the node set
|
|
36404
|
+
* falls back to the edge endpoints.
|
|
36405
|
+
*/
|
|
36406
|
+
const renderedGraphFromEdges = (edges, root = "/", nodes = []) => {
|
|
36407
|
+
const nodeSet = new Set(nodes.map(pathKeyOf));
|
|
36408
|
+
const kept = [];
|
|
36409
|
+
for (const edge of edges) {
|
|
36410
|
+
const from = pathKeyOf(edge.from);
|
|
36411
|
+
const to = pathKeyOf(edge.to);
|
|
36412
|
+
nodeSet.add(from);
|
|
36413
|
+
nodeSet.add(to);
|
|
36414
|
+
kept.push({
|
|
36415
|
+
from,
|
|
36416
|
+
to,
|
|
36417
|
+
region: edge.region
|
|
36418
|
+
});
|
|
36419
|
+
}
|
|
36420
|
+
return graphFromEdges(nodeSet, kept, root);
|
|
36270
36421
|
};
|
|
36271
36422
|
const edgeKey = (edge) => `${edge.from}\u0000${edge.to}`;
|
|
36272
36423
|
const normalizeEdge = (edge) => ({
|
|
@@ -36289,6 +36440,97 @@ const diffLinkGraph = (declared, rendered) => {
|
|
|
36289
36440
|
renderedContextualCount: rendered.contextualEdges.length
|
|
36290
36441
|
};
|
|
36291
36442
|
};
|
|
36443
|
+
const ARTIFACT_KIND = "links-rendered";
|
|
36444
|
+
const REGIONS = /* @__PURE__ */ new Set([
|
|
36445
|
+
"nav",
|
|
36446
|
+
"footer",
|
|
36447
|
+
"header",
|
|
36448
|
+
"body"
|
|
36449
|
+
]);
|
|
36450
|
+
const decodeArtifactEdges = (input) => {
|
|
36451
|
+
if (!Array.isArray(input)) throw new Error("`edges` must be an array of { from, to, region } edges");
|
|
36452
|
+
return input.map((item) => {
|
|
36453
|
+
if (item === null || typeof item !== "object") throw new Error("each artifact edge must be an object with string `from` and `to`");
|
|
36454
|
+
const { from, to, region } = item;
|
|
36455
|
+
if (typeof from !== "string" || typeof to !== "string") throw new Error("each artifact edge must have string `from` and `to`");
|
|
36456
|
+
if (region !== void 0 && !REGIONS.has(region)) throw new Error(`artifact edge region must be one of nav, footer, header, body (received ${JSON.stringify(region)})`);
|
|
36457
|
+
return {
|
|
36458
|
+
from,
|
|
36459
|
+
to,
|
|
36460
|
+
region: region ?? "body"
|
|
36461
|
+
};
|
|
36462
|
+
});
|
|
36463
|
+
};
|
|
36464
|
+
const decodeArtifactNodes = (input) => {
|
|
36465
|
+
if (input === void 0) return [];
|
|
36466
|
+
if (!Array.isArray(input) || !input.every((path) => typeof path === "string")) throw new Error("rendered-edge artifact `nodes` must be an array of paths");
|
|
36467
|
+
return input;
|
|
36468
|
+
};
|
|
36469
|
+
const decodeArtifactFailures = (input) => {
|
|
36470
|
+
if (input === void 0) return [];
|
|
36471
|
+
if (!Array.isArray(input)) throw new Error("rendered-edge artifact `crawl.failures` must be an array");
|
|
36472
|
+
return input.map((item) => {
|
|
36473
|
+
if (item === null || typeof item !== "object") throw new Error("each artifact failure must be an object with string `url` and `error`");
|
|
36474
|
+
const { url, error } = item;
|
|
36475
|
+
if (typeof url !== "string" || typeof error !== "string") throw new Error("each artifact failure must have string `url` and `error`");
|
|
36476
|
+
return {
|
|
36477
|
+
url,
|
|
36478
|
+
error
|
|
36479
|
+
};
|
|
36480
|
+
});
|
|
36481
|
+
};
|
|
36482
|
+
const requiredAbsoluteUrl = (value, field) => {
|
|
36483
|
+
if (typeof value !== "string" || value === "") throw new Error(`rendered-edge artifact must carry a non-empty string \`${field}\``);
|
|
36484
|
+
try {
|
|
36485
|
+
new URL(value);
|
|
36486
|
+
} catch {
|
|
36487
|
+
throw new Error(`rendered-edge artifact \`${field}\` must be an absolute URL (received ${JSON.stringify(value)})`);
|
|
36488
|
+
}
|
|
36489
|
+
return value;
|
|
36490
|
+
};
|
|
36491
|
+
const requiredInteger = (value, field, minimum) => {
|
|
36492
|
+
if (!Number.isSafeInteger(value) || value < minimum) throw new Error(`rendered-edge artifact \`${field}\` must be an integer >= ${minimum} (received ${JSON.stringify(value)})`);
|
|
36493
|
+
return value;
|
|
36494
|
+
};
|
|
36495
|
+
/**
|
|
36496
|
+
* Decode a rendered-edge artifact from user input. Throws on any other shape —
|
|
36497
|
+
* this is user input, not a provider payload. `root`, `bodyTruncated`,
|
|
36498
|
+
* `truncatedPages`, `failures`, and `nodes` are optional so a hand-written edge
|
|
36499
|
+
* dump that only carries provenance can still be replayed.
|
|
36500
|
+
*/
|
|
36501
|
+
const decodeRenderedEdgeArtifact = (input) => {
|
|
36502
|
+
if (input === null || typeof input !== "object") throw new Error("rendered-edge artifact must be an object");
|
|
36503
|
+
const value = input;
|
|
36504
|
+
if (value["kind"] !== ARTIFACT_KIND) throw new Error(`rendered-edge artifact \`kind\` must be "${ARTIFACT_KIND}" (received ${JSON.stringify(value["kind"])})`);
|
|
36505
|
+
if (value["schemaVersion"] !== 1) throw new Error(`unsupported rendered-edge artifact schemaVersion: ${JSON.stringify(value["schemaVersion"])}`);
|
|
36506
|
+
const origin = requiredAbsoluteUrl(value["origin"], "origin");
|
|
36507
|
+
const seed = requiredAbsoluteUrl(value["seed"], "seed");
|
|
36508
|
+
const crawl = value["crawl"];
|
|
36509
|
+
if (crawl === null || typeof crawl !== "object") throw new Error("rendered-edge artifact must carry a `crawl` object");
|
|
36510
|
+
const provenance = crawl;
|
|
36511
|
+
const truncatedPages = provenance["truncatedPages"];
|
|
36512
|
+
if (truncatedPages !== void 0 && (!Array.isArray(truncatedPages) || !truncatedPages.every((url) => typeof url === "string"))) throw new Error("rendered-edge artifact `truncatedPages` must be an array of URLs");
|
|
36513
|
+
const bodyTruncated = provenance["bodyTruncated"];
|
|
36514
|
+
if (bodyTruncated !== void 0 && typeof bodyTruncated !== "boolean") throw new Error("rendered-edge artifact `bodyTruncated` must be a boolean");
|
|
36515
|
+
if (typeof provenance["truncated"] !== "boolean") throw new Error("rendered-edge artifact `crawl.truncated` must be a boolean");
|
|
36516
|
+
return {
|
|
36517
|
+
kind: ARTIFACT_KIND,
|
|
36518
|
+
schemaVersion: 1,
|
|
36519
|
+
origin,
|
|
36520
|
+
seed,
|
|
36521
|
+
crawl: {
|
|
36522
|
+
pages: requiredInteger(provenance["pages"], "crawl.pages", 0),
|
|
36523
|
+
root: typeof provenance["root"] === "string" ? provenance["root"] : pathKeyOf(seed),
|
|
36524
|
+
limit: requiredInteger(provenance["limit"], "crawl.limit", 1),
|
|
36525
|
+
truncated: provenance["truncated"],
|
|
36526
|
+
bodyTruncated: bodyTruncated === true,
|
|
36527
|
+
truncatedPages: truncatedPages ?? [],
|
|
36528
|
+
failures: decodeArtifactFailures(provenance["failures"])
|
|
36529
|
+
},
|
|
36530
|
+
nodes: decodeArtifactNodes(value["nodes"]),
|
|
36531
|
+
edges: decodeArtifactEdges(value["edges"])
|
|
36532
|
+
};
|
|
36533
|
+
};
|
|
36292
36534
|
//#endregion
|
|
36293
36535
|
//#region src/audit/network.ts
|
|
36294
36536
|
const blockedIpv4Addresses = new BlockList();
|
|
@@ -37571,7 +37813,7 @@ const requestTimeoutMs$1 = Int("request-timeout-ms").pipe(withDescription$2("HTT
|
|
|
37571
37813
|
const scannerTimeoutMs = Int("scanner-timeout-ms").pipe(withDescription$2("Lighthouse and hosted scanner timeout in milliseconds"), withDefault(18e4));
|
|
37572
37814
|
const maxBodyBytes$1 = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes"), withDefault(2e6));
|
|
37573
37815
|
const outputDir = String$2("output-dir").pipe(withDescription$2("Atomically write timestamped JSON and Markdown artifacts"), optional$1);
|
|
37574
|
-
const positive$
|
|
37816
|
+
const positive$2 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
37575
37817
|
const auditCommand = make$9("audit", {
|
|
37576
37818
|
urls,
|
|
37577
37819
|
json: jsonFlag,
|
|
@@ -37599,11 +37841,11 @@ const auditCommand = make$9("audit", {
|
|
|
37599
37841
|
description: "Audit a local app"
|
|
37600
37842
|
}
|
|
37601
37843
|
]), withHandler(fn("SeoCli.audit")(function* (options) {
|
|
37602
|
-
const checkedRuns = yield* positive$
|
|
37603
|
-
const checkedConcurrency = yield* positive$
|
|
37604
|
-
const checkedRequestTimeout = yield* positive$
|
|
37605
|
-
const checkedScannerTimeout = yield* positive$
|
|
37606
|
-
const checkedMaxBody = yield* positive$
|
|
37844
|
+
const checkedRuns = yield* positive$2("runs", options.runs);
|
|
37845
|
+
const checkedConcurrency = yield* positive$2("concurrency", options.concurrency);
|
|
37846
|
+
const checkedRequestTimeout = yield* positive$2("request-timeout-ms", options.requestTimeoutMs);
|
|
37847
|
+
const checkedScannerTimeout = yield* positive$2("scanner-timeout-ms", options.scannerTimeoutMs);
|
|
37848
|
+
const checkedMaxBody = yield* positive$2("max-body-bytes", options.maxBodyBytes);
|
|
37607
37849
|
const scanners = [
|
|
37608
37850
|
makeHttpScanner({
|
|
37609
37851
|
allowPrivate: options.allowPrivate,
|
|
@@ -37653,300 +37895,6 @@ const auditCommand = make$9("audit", {
|
|
|
37653
37895
|
if (structural.length > 0 || http.every((result) => result.status === "error")) return yield* new SeoCliError({ message: `${structural.length} structural finding(s); see the report above.` });
|
|
37654
37896
|
})));
|
|
37655
37897
|
//#endregion
|
|
37656
|
-
//#region src/cli/commands/diff.ts
|
|
37657
|
-
const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
|
|
37658
|
-
const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
|
|
37659
|
-
const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
|
|
37660
|
-
const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
|
|
37661
|
-
return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
|
|
37662
|
-
});
|
|
37663
|
-
const diffCommand = make$9("diff", {
|
|
37664
|
-
before,
|
|
37665
|
-
after,
|
|
37666
|
-
json: jsonFlag
|
|
37667
|
-
}).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
|
|
37668
|
-
command: "pagegraph diff before.json after.json",
|
|
37669
|
-
description: "Render a human-readable semantic comparison"
|
|
37670
|
-
}, {
|
|
37671
|
-
command: "pagegraph diff before.json after.json --json",
|
|
37672
|
-
description: "Emit one versioned JSON diff on stdout"
|
|
37673
|
-
}]), withHandler(fn("SeoCli.diff")(function* (options) {
|
|
37674
|
-
const beforeReport = yield* readAuditReport("before", options.before);
|
|
37675
|
-
const afterReport = yield* readAuditReport("after", options.after);
|
|
37676
|
-
const comparison = compareAuditReports(beforeReport, afterReport);
|
|
37677
|
-
if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
|
|
37678
|
-
if (options.json) yield* printJson(comparison.diff);
|
|
37679
|
-
else yield* printText(renderAuditDiff(comparison.diff));
|
|
37680
|
-
if (comparison.diff.outcome === "regressed") {
|
|
37681
|
-
const summary = comparison.diff.summary;
|
|
37682
|
-
return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
|
|
37683
|
-
}
|
|
37684
|
-
})));
|
|
37685
|
-
//#endregion
|
|
37686
|
-
//#region src/cli/serialize.ts
|
|
37687
|
-
const serializeCrumb = (crumb) => {
|
|
37688
|
-
if (crumb === void 0) return void 0;
|
|
37689
|
-
return typeof crumb === "function" ? "(dynamic)" : crumb;
|
|
37690
|
-
};
|
|
37691
|
-
/** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
|
|
37692
|
-
const serializeNode = (node) => ({
|
|
37693
|
-
path: node.path,
|
|
37694
|
-
kind: node.kind,
|
|
37695
|
-
source: node.source,
|
|
37696
|
-
policy: {
|
|
37697
|
-
kind: node.policy.kind,
|
|
37698
|
-
crumb: serializeCrumb(node.policy.crumb),
|
|
37699
|
-
sitemap: node.policy.sitemap,
|
|
37700
|
-
robots: node.policy.robots,
|
|
37701
|
-
related: node.policy.related,
|
|
37702
|
-
link: node.policy.link,
|
|
37703
|
-
redirectTo: node.policy.redirectTo
|
|
37704
|
-
},
|
|
37705
|
-
instance: node.instance
|
|
37706
|
-
});
|
|
37707
|
-
/** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
|
|
37708
|
-
const serializeGraph = (graph) => ({
|
|
37709
|
-
nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
|
|
37710
|
-
edges: graph.edges
|
|
37711
|
-
});
|
|
37712
|
-
const graphCommand = make$9("graph", {
|
|
37713
|
-
format: Literals("format", [
|
|
37714
|
-
"tree",
|
|
37715
|
-
"mermaid",
|
|
37716
|
-
"json"
|
|
37717
|
-
]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
|
|
37718
|
-
orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
|
|
37719
|
-
}).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
|
|
37720
|
-
{
|
|
37721
|
-
command: "pagegraph graph",
|
|
37722
|
-
description: "The graph as an indented path tree"
|
|
37723
|
-
},
|
|
37724
|
-
{
|
|
37725
|
-
command: "pagegraph graph --format mermaid",
|
|
37726
|
-
description: "A Mermaid diagram of nodes and edges"
|
|
37727
|
-
},
|
|
37728
|
-
{
|
|
37729
|
-
command: "pagegraph graph --orphans",
|
|
37730
|
-
description: "Only nodes with no incoming edge"
|
|
37731
|
-
},
|
|
37732
|
-
{
|
|
37733
|
-
command: "pagegraph graph --format json",
|
|
37734
|
-
description: "The serialized graph on stdout"
|
|
37735
|
-
}
|
|
37736
|
-
]), withHandler(fnUntraced(function* ({ format, orphans }) {
|
|
37737
|
-
const config = yield* loadSeoConfig;
|
|
37738
|
-
const graph = yield* scoped(acquireGraph(config));
|
|
37739
|
-
if (format === "json") {
|
|
37740
|
-
const serialized = serializeGraph(graph);
|
|
37741
|
-
if (!orphans) return yield* printJson(serialized);
|
|
37742
|
-
const orphanSet = orphanPaths(graph);
|
|
37743
|
-
return yield* printJson({
|
|
37744
|
-
nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
|
|
37745
|
-
edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
|
|
37746
|
-
});
|
|
37747
|
-
}
|
|
37748
|
-
if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
|
|
37749
|
-
return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
|
|
37750
|
-
})));
|
|
37751
|
-
//#endregion
|
|
37752
|
-
//#region src/core/inspect-html.ts
|
|
37753
|
-
const ENTITIES = {
|
|
37754
|
-
"&": "&",
|
|
37755
|
-
"<": "<",
|
|
37756
|
-
">": ">",
|
|
37757
|
-
""": "\"",
|
|
37758
|
-
"'": "'",
|
|
37759
|
-
"'": "'"
|
|
37760
|
-
};
|
|
37761
|
-
const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
|
|
37762
|
-
/** Pull double/single-quoted attributes off a single tag string. */
|
|
37763
|
-
const parseAttrs = (tag) => {
|
|
37764
|
-
const attrs = {};
|
|
37765
|
-
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
37766
|
-
let match;
|
|
37767
|
-
while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
|
|
37768
|
-
return attrs;
|
|
37769
|
-
};
|
|
37770
|
-
/** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
|
|
37771
|
-
const typeName = (value) => {
|
|
37772
|
-
if (typeof value === "string") return value;
|
|
37773
|
-
if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
|
|
37774
|
-
return "unknown";
|
|
37775
|
-
};
|
|
37776
|
-
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37777
|
-
/** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
|
|
37778
|
-
const collectItems = (parsed) => {
|
|
37779
|
-
if (Array.isArray(parsed)) return parsed.filter(isObject);
|
|
37780
|
-
if (isObject(parsed)) {
|
|
37781
|
-
if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
|
|
37782
|
-
return [parsed];
|
|
37783
|
-
}
|
|
37784
|
-
return [];
|
|
37785
|
-
};
|
|
37786
|
-
const countQuestions = (mainEntity) => {
|
|
37787
|
-
if (!Array.isArray(mainEntity)) return 0;
|
|
37788
|
-
return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
|
|
37789
|
-
};
|
|
37790
|
-
const validateItemListElements = (value) => {
|
|
37791
|
-
if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
|
|
37792
|
-
const errors = [];
|
|
37793
|
-
value.forEach((entry, index) => {
|
|
37794
|
-
if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
|
|
37795
|
-
errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
|
|
37796
|
-
return;
|
|
37797
|
-
}
|
|
37798
|
-
if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
|
|
37799
|
-
if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
|
|
37800
|
-
});
|
|
37801
|
-
return errors;
|
|
37802
|
-
};
|
|
37803
|
-
/** Minimal per-type validation — enough to catch an empty or malformed block. */
|
|
37804
|
-
const validateItem = (item) => {
|
|
37805
|
-
const type = typeName(item["@type"]);
|
|
37806
|
-
const errors = [];
|
|
37807
|
-
if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
|
|
37808
|
-
if (!item["headline"]) errors.push("Article is missing `headline`.");
|
|
37809
|
-
if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
|
|
37810
|
-
} else if (type === "FAQPage") {
|
|
37811
|
-
if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
|
|
37812
|
-
} else if (type === "BreadcrumbList") {
|
|
37813
|
-
const items = item["itemListElement"];
|
|
37814
|
-
if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
|
|
37815
|
-
} else if (type === "ItemList") {
|
|
37816
|
-
errors.push(...validateItemListElements(item["itemListElement"]));
|
|
37817
|
-
const items = item["itemListElement"];
|
|
37818
|
-
if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
|
|
37819
|
-
}
|
|
37820
|
-
return {
|
|
37821
|
-
type,
|
|
37822
|
-
valid: errors.length === 0,
|
|
37823
|
-
errors
|
|
37824
|
-
};
|
|
37825
|
-
};
|
|
37826
|
-
const validateLdJson = (raw) => {
|
|
37827
|
-
let parsed;
|
|
37828
|
-
try {
|
|
37829
|
-
parsed = JSON.parse(raw);
|
|
37830
|
-
} catch (cause) {
|
|
37831
|
-
return [{
|
|
37832
|
-
type: "unparseable",
|
|
37833
|
-
valid: false,
|
|
37834
|
-
errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
|
|
37835
|
-
}];
|
|
37836
|
-
}
|
|
37837
|
-
const items = collectItems(parsed);
|
|
37838
|
-
if (items.length === 0) return [{
|
|
37839
|
-
type: "unknown",
|
|
37840
|
-
valid: false,
|
|
37841
|
-
errors: ["No JSON-LD object found in block."]
|
|
37842
|
-
}];
|
|
37843
|
-
return items.map(validateItem);
|
|
37844
|
-
};
|
|
37845
|
-
/** Parse a rendered HTML document's `<head>` into a report. Pure. */
|
|
37846
|
-
const inspectHtml = (url, status, html) => {
|
|
37847
|
-
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
37848
|
-
const head = headMatch ? headMatch[1] : html;
|
|
37849
|
-
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
37850
|
-
const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
|
|
37851
|
-
const og = {};
|
|
37852
|
-
const twitter = {};
|
|
37853
|
-
let description;
|
|
37854
|
-
let robots;
|
|
37855
|
-
for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
37856
|
-
const attrs = parseAttrs(tag);
|
|
37857
|
-
const content = attrs["content"];
|
|
37858
|
-
if (content === void 0) continue;
|
|
37859
|
-
const property = attrs["property"];
|
|
37860
|
-
const name = attrs["name"];
|
|
37861
|
-
if (property?.startsWith("og:")) og[property] = content;
|
|
37862
|
-
else if (name?.startsWith("twitter:")) twitter[name] = content;
|
|
37863
|
-
else if (name === "description") description = content;
|
|
37864
|
-
else if (name === "robots") robots = content;
|
|
37865
|
-
}
|
|
37866
|
-
let canonical;
|
|
37867
|
-
for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
|
|
37868
|
-
const attrs = parseAttrs(tag);
|
|
37869
|
-
if (attrs["rel"] === "canonical") canonical = attrs["href"];
|
|
37870
|
-
}
|
|
37871
|
-
const jsonLd = [];
|
|
37872
|
-
const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
37873
|
-
let scriptMatch;
|
|
37874
|
-
while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
|
|
37875
|
-
const issues = [];
|
|
37876
|
-
if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
|
|
37877
|
-
if (!title) issues.push("Missing <title>.");
|
|
37878
|
-
if (!description) issues.push("Missing meta description.");
|
|
37879
|
-
if (!canonical) issues.push("Missing canonical link.");
|
|
37880
|
-
for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
|
|
37881
|
-
return {
|
|
37882
|
-
url,
|
|
37883
|
-
status,
|
|
37884
|
-
title,
|
|
37885
|
-
description,
|
|
37886
|
-
canonical,
|
|
37887
|
-
robots,
|
|
37888
|
-
og,
|
|
37889
|
-
twitter,
|
|
37890
|
-
jsonLd,
|
|
37891
|
-
issues
|
|
37892
|
-
};
|
|
37893
|
-
};
|
|
37894
|
-
/** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
|
|
37895
|
-
const hasBlockingIssues = (report) => report.issues.length > 0;
|
|
37896
|
-
//#endregion
|
|
37897
|
-
//#region src/cli/live-inspect.ts
|
|
37898
|
-
/**
|
|
37899
|
-
* `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
|
|
37900
|
-
* validator in `../inspect-html`. The fetch is all that lives here — the
|
|
37901
|
-
* validation is a library capability, not a CLI one, so it stays out of the
|
|
37902
|
-
* Effect-bearing half of the package.
|
|
37903
|
-
*/
|
|
37904
|
-
/** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
|
|
37905
|
-
const fetchAndInspect = (url) => tryPromise({
|
|
37906
|
-
try: async () => {
|
|
37907
|
-
const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
|
|
37908
|
-
const html = await response.text();
|
|
37909
|
-
return inspectHtml(url, response.status, html);
|
|
37910
|
-
},
|
|
37911
|
-
catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
37912
|
-
});
|
|
37913
|
-
const inspectCommand = make$9("inspect", {
|
|
37914
|
-
target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
|
|
37915
|
-
live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
|
|
37916
|
-
json: jsonFlag
|
|
37917
|
-
}).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
|
|
37918
|
-
{
|
|
37919
|
-
command: "pagegraph inspect /pricing",
|
|
37920
|
-
description: "The graph node, policy, and edges for a path"
|
|
37921
|
-
},
|
|
37922
|
-
{
|
|
37923
|
-
command: "pagegraph inspect https://example.com/pricing --live",
|
|
37924
|
-
description: "Fetch the page and validate its head tags + JSON-LD"
|
|
37925
|
-
},
|
|
37926
|
-
{
|
|
37927
|
-
command: "pagegraph inspect /blog/some-post --json",
|
|
37928
|
-
description: "The node report as JSON"
|
|
37929
|
-
}
|
|
37930
|
-
]), withHandler(fnUntraced(function* ({ target, live, json }) {
|
|
37931
|
-
if (live) {
|
|
37932
|
-
const report = yield* fetchAndInspect(target);
|
|
37933
|
-
if (json) yield* printJson(report);
|
|
37934
|
-
else yield* printText(renderLiveReport(report));
|
|
37935
|
-
if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
|
|
37936
|
-
return;
|
|
37937
|
-
}
|
|
37938
|
-
const config = yield* loadSeoConfig;
|
|
37939
|
-
const report = inspectNode(yield* scoped(acquireGraph(config)), target);
|
|
37940
|
-
if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
|
|
37941
|
-
if (json) return yield* printJson({
|
|
37942
|
-
node: serializeNode(report.node),
|
|
37943
|
-
inSitemap: report.inSitemap,
|
|
37944
|
-
incoming: report.incoming,
|
|
37945
|
-
outgoing: report.outgoing
|
|
37946
|
-
});
|
|
37947
|
-
return yield* printText(renderNodeReport(report));
|
|
37948
|
-
})));
|
|
37949
|
-
//#endregion
|
|
37950
37898
|
//#region node_modules/effect/dist/unstable/http/HttpBody.js
|
|
37951
37899
|
const TypeId$9 = "~effect/http/HttpBody";
|
|
37952
37900
|
const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError";
|
|
@@ -40734,7 +40682,7 @@ const invalidOutput = (description) => make$5({
|
|
|
40734
40682
|
reason: new InvalidOutputError({ description })
|
|
40735
40683
|
});
|
|
40736
40684
|
const isFiniteNumber = (value) => isNumber(value) && Number.isFinite(value);
|
|
40737
|
-
const isUnitInterval = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
|
|
40685
|
+
const isUnitInterval$1 = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
|
|
40738
40686
|
const validateDistribution = (key, labels, answer) => {
|
|
40739
40687
|
const raw = answer.probabilities;
|
|
40740
40688
|
if (!isObject$1(raw)) return invalidOutput(`Provider returned no probabilities for decision "${key}"`);
|
|
@@ -40742,7 +40690,7 @@ const validateDistribution = (key, labels, answer) => {
|
|
|
40742
40690
|
let total = 0;
|
|
40743
40691
|
for (const label of labels) {
|
|
40744
40692
|
const value = raw[label];
|
|
40745
|
-
if (!isUnitInterval(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
|
|
40693
|
+
if (!isUnitInterval$1(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
|
|
40746
40694
|
probabilities[label] = value;
|
|
40747
40695
|
total += value;
|
|
40748
40696
|
}
|
|
@@ -40751,7 +40699,7 @@ const validateDistribution = (key, labels, answer) => {
|
|
|
40751
40699
|
};
|
|
40752
40700
|
const validateConfidence = (key, answer) => {
|
|
40753
40701
|
if (answer.confidence === void 0) return;
|
|
40754
|
-
return isUnitInterval(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
|
|
40702
|
+
return isUnitInterval$1(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
|
|
40755
40703
|
};
|
|
40756
40704
|
const validateAnswer = (key, decision, answer) => {
|
|
40757
40705
|
if (!isObject$1(answer)) return invalidOutput(`Provider returned no answer for decision "${key}"`);
|
|
@@ -40787,7 +40735,7 @@ const validateAnswer = (key, decision, answer) => {
|
|
|
40787
40735
|
};
|
|
40788
40736
|
}
|
|
40789
40737
|
case "Probability":
|
|
40790
|
-
if (!isUnitInterval(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
|
|
40738
|
+
if (!isUnitInterval$1(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
|
|
40791
40739
|
return { probability: answer.probability };
|
|
40792
40740
|
}
|
|
40793
40741
|
};
|
|
@@ -41052,6 +41000,1501 @@ const make$1 = /*#__PURE__*/ fnUntraced(function* (options) {
|
|
|
41052
41000
|
*/
|
|
41053
41001
|
const layer = (options) => effect(DecisionModel, make$1(options));
|
|
41054
41002
|
//#endregion
|
|
41003
|
+
//#region node_modules/effect/dist/unstable/ai/Decision.js
|
|
41004
|
+
/**
|
|
41005
|
+
* Brand for decision definitions.
|
|
41006
|
+
*
|
|
41007
|
+
* @category type IDs
|
|
41008
|
+
* @since 4.0.0
|
|
41009
|
+
*/
|
|
41010
|
+
const TypeId = "~effect/ai/Decision";
|
|
41011
|
+
/**
|
|
41012
|
+
* Creates a classification decision from labelled criteria.
|
|
41013
|
+
* Throws if fewer than two labels are supplied.
|
|
41014
|
+
*
|
|
41015
|
+
* **Example** (Choosing a department)
|
|
41016
|
+
*
|
|
41017
|
+
* ```ts
|
|
41018
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41019
|
+
*
|
|
41020
|
+
* const department = Decision.classify({
|
|
41021
|
+
* instructions: "Which team should handle this",
|
|
41022
|
+
* criteria: {
|
|
41023
|
+
* billing: "payments",
|
|
41024
|
+
* technical: "bugs",
|
|
41025
|
+
* sales: "pricing"
|
|
41026
|
+
* }
|
|
41027
|
+
* })
|
|
41028
|
+
* ```
|
|
41029
|
+
*
|
|
41030
|
+
* @see {@link rate} for an ordered scale
|
|
41031
|
+
* @see {@link probability} for a single yes or no likelihood
|
|
41032
|
+
*
|
|
41033
|
+
* @category constructors
|
|
41034
|
+
* @since 4.0.0
|
|
41035
|
+
*/
|
|
41036
|
+
const classify = (options) => {
|
|
41037
|
+
if (Object.keys(options.criteria).length < 2) throw new Error("Decision.classify: criteria must contain at least two labels");
|
|
41038
|
+
return {
|
|
41039
|
+
_tag: "Classify",
|
|
41040
|
+
instructions: options.instructions,
|
|
41041
|
+
criteria: options.criteria
|
|
41042
|
+
};
|
|
41043
|
+
};
|
|
41044
|
+
/**
|
|
41045
|
+
* Creates a rating decision from an ordered list of criteria.
|
|
41046
|
+
* Throws if fewer than two levels or duplicate levels are supplied.
|
|
41047
|
+
*
|
|
41048
|
+
* **Example** (Rating frustration)
|
|
41049
|
+
*
|
|
41050
|
+
* ```ts
|
|
41051
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41052
|
+
*
|
|
41053
|
+
* const frustration = Decision.rate({
|
|
41054
|
+
* instructions: "How frustrated",
|
|
41055
|
+
* criteria: ["calm", "frustrated", "angry"]
|
|
41056
|
+
* })
|
|
41057
|
+
* ```
|
|
41058
|
+
*
|
|
41059
|
+
* @see {@link classify} for unordered labels
|
|
41060
|
+
*
|
|
41061
|
+
* @category constructors
|
|
41062
|
+
* @since 4.0.0
|
|
41063
|
+
*/
|
|
41064
|
+
const rate = (options) => {
|
|
41065
|
+
if (options.criteria.length < 2) throw new Error("Decision.rate: criteria must contain at least two levels");
|
|
41066
|
+
if (new Set(options.criteria).size !== options.criteria.length) throw new Error("Decision.rate: criteria must contain distinct levels");
|
|
41067
|
+
return {
|
|
41068
|
+
_tag: "Rate",
|
|
41069
|
+
instructions: options.instructions,
|
|
41070
|
+
criteria: options.criteria
|
|
41071
|
+
};
|
|
41072
|
+
};
|
|
41073
|
+
/**
|
|
41074
|
+
* Creates a probability decision from descriptions of both outcomes.
|
|
41075
|
+
*
|
|
41076
|
+
* **Example** (Estimating urgency)
|
|
41077
|
+
*
|
|
41078
|
+
* ```ts
|
|
41079
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41080
|
+
*
|
|
41081
|
+
* const urgent = Decision.probability({
|
|
41082
|
+
* instructions: "The message is time-sensitive",
|
|
41083
|
+
* criteria: {
|
|
41084
|
+
* false: "No time pressure",
|
|
41085
|
+
* true: "Needs action now"
|
|
41086
|
+
* }
|
|
41087
|
+
* })
|
|
41088
|
+
* ```
|
|
41089
|
+
*
|
|
41090
|
+
* @see {@link classify} for more than two outcomes
|
|
41091
|
+
*
|
|
41092
|
+
* @category constructors
|
|
41093
|
+
* @since 4.0.0
|
|
41094
|
+
*/
|
|
41095
|
+
const probability = (options) => ({
|
|
41096
|
+
_tag: "Probability",
|
|
41097
|
+
instructions: options.instructions,
|
|
41098
|
+
criteria: options.criteria
|
|
41099
|
+
});
|
|
41100
|
+
/**
|
|
41101
|
+
* Creates a decision definition from an input schema and named decisions.
|
|
41102
|
+
* `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
|
|
41103
|
+
* calling the provider. Answer keys and types are inferred from the decisions.
|
|
41104
|
+
* Throws if `decisions` is empty.
|
|
41105
|
+
*
|
|
41106
|
+
* **Example** (Defining ticket triage)
|
|
41107
|
+
*
|
|
41108
|
+
* ```ts
|
|
41109
|
+
* import { Schema } from "effect"
|
|
41110
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41111
|
+
*
|
|
41112
|
+
* const Ticket = Schema.Struct({
|
|
41113
|
+
* subject: Schema.String,
|
|
41114
|
+
* body: Schema.String
|
|
41115
|
+
* })
|
|
41116
|
+
*
|
|
41117
|
+
* const TicketTriage = Decision.make({
|
|
41118
|
+
* input: Ticket,
|
|
41119
|
+
* decisions: {
|
|
41120
|
+
* department: Decision.classify({
|
|
41121
|
+
* instructions: "Which team should handle this",
|
|
41122
|
+
* criteria: { billing: "payments", technical: "bugs" }
|
|
41123
|
+
* }),
|
|
41124
|
+
* urgent: Decision.probability({
|
|
41125
|
+
* instructions: "The message is time-sensitive",
|
|
41126
|
+
* criteria: { false: "No time pressure", true: "Needs action now" }
|
|
41127
|
+
* })
|
|
41128
|
+
* }
|
|
41129
|
+
* })
|
|
41130
|
+
* ```
|
|
41131
|
+
*
|
|
41132
|
+
* @see {@link Definition} for the returned shape
|
|
41133
|
+
*
|
|
41134
|
+
* @category constructors
|
|
41135
|
+
* @since 4.0.0
|
|
41136
|
+
*/
|
|
41137
|
+
const make = (options) => {
|
|
41138
|
+
if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
|
|
41139
|
+
return {
|
|
41140
|
+
[TypeId]: TypeId,
|
|
41141
|
+
input: options.input,
|
|
41142
|
+
decisions: options.decisions
|
|
41143
|
+
};
|
|
41144
|
+
};
|
|
41145
|
+
//#endregion
|
|
41146
|
+
//#region src/decide/cache.ts
|
|
41147
|
+
/**
|
|
41148
|
+
* Answer cache keyed by input hash. One JSON file per key under `--cache <dir>`.
|
|
41149
|
+
*
|
|
41150
|
+
* Only model answers are cached — verdicts are recomputed from answers and the
|
|
41151
|
+
* current threshold, so raising or lowering `--threshold` never serves a stale
|
|
41152
|
+
* verdict. Cache files are plain data: a missing or unreadable file is a miss,
|
|
41153
|
+
* never an error that fails the run.
|
|
41154
|
+
*/
|
|
41155
|
+
/** Read cached answers for `key`, or `undefined` on any miss (absent or unreadable). */
|
|
41156
|
+
const cacheGet = (cacheDir, key) => {
|
|
41157
|
+
const file = join(cacheDir, `${key}.json`);
|
|
41158
|
+
if (!existsSync(file)) return void 0;
|
|
41159
|
+
try {
|
|
41160
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
41161
|
+
} catch {
|
|
41162
|
+
return;
|
|
41163
|
+
}
|
|
41164
|
+
};
|
|
41165
|
+
/** Persist answers for `key`; creates `cacheDir` on first write. */
|
|
41166
|
+
const cachePut = (cacheDir, key, answers) => {
|
|
41167
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
41168
|
+
writeFileSync(join(cacheDir, `${key}.json`), `${JSON.stringify(answers, null, 2)}\n`, "utf8");
|
|
41169
|
+
};
|
|
41170
|
+
//#endregion
|
|
41171
|
+
//#region src/decide/run.ts
|
|
41172
|
+
/**
|
|
41173
|
+
* The decision runner. A family supplies its input schema, a definition built
|
|
41174
|
+
* from the input (families with dynamic labels build one per input), a way to
|
|
41175
|
+
* reference the input in a report, and a pure `evaluate` that maps the model's
|
|
41176
|
+
* answers to a verdict. The runner hashes, caches, dispatches with bounded
|
|
41177
|
+
* concurrency, and buckets confident answers apart from `review`.
|
|
41178
|
+
*
|
|
41179
|
+
* This module reaches Effect and the provider, so it is CLI-internal: the
|
|
41180
|
+
* library entries stay Effect-free and only the bundled `pagegraph` bin ships it.
|
|
41181
|
+
*/
|
|
41182
|
+
const isUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
41183
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41184
|
+
/** A probability distribution over exactly `labels`: unit-range, summing to 1. */
|
|
41185
|
+
const isDistribution = (labels, value) => {
|
|
41186
|
+
if (!isPlainObject(value) || Object.keys(value).length !== labels.length) return false;
|
|
41187
|
+
let total = 0;
|
|
41188
|
+
for (const label of labels) {
|
|
41189
|
+
const probability = value[label];
|
|
41190
|
+
if (!isUnitInterval(probability)) return false;
|
|
41191
|
+
total += probability;
|
|
41192
|
+
}
|
|
41193
|
+
return Math.abs(total - 1) <= 1e-6;
|
|
41194
|
+
};
|
|
41195
|
+
/**
|
|
41196
|
+
* Whether cached answers have the shape `DecisionModel` would have validated —
|
|
41197
|
+
* the same labels, ranges, and distributions. A cache file is only trusted when
|
|
41198
|
+
* it round-trips through this check, so a partially malformed entry cannot
|
|
41199
|
+
* produce a confident verdict.
|
|
41200
|
+
*/
|
|
41201
|
+
const validCachedAnswers = (decisions, answers) => {
|
|
41202
|
+
if (!isPlainObject(answers)) return false;
|
|
41203
|
+
for (const key of Object.keys(decisions)) {
|
|
41204
|
+
const answer = answers[key];
|
|
41205
|
+
if (!isPlainObject(answer)) return false;
|
|
41206
|
+
const decision = decisions[key];
|
|
41207
|
+
switch (decision._tag) {
|
|
41208
|
+
case "Probability":
|
|
41209
|
+
if (!isUnitInterval(answer.probability)) return false;
|
|
41210
|
+
break;
|
|
41211
|
+
case "Classify":
|
|
41212
|
+
if (typeof answer.label !== "string" || !Object.hasOwn(decision.criteria, answer.label) || !isDistribution(Object.keys(decision.criteria), answer.probabilities)) return false;
|
|
41213
|
+
break;
|
|
41214
|
+
case "Rate": if (typeof answer.rating !== "number" || !Number.isFinite(answer.rating) || answer.rating < 0 || answer.rating > decision.criteria.length - 1 || typeof answer.label !== "string" || !decision.criteria.includes(answer.label) || !isDistribution(decision.criteria, answer.probabilities)) return false;
|
|
41215
|
+
}
|
|
41216
|
+
}
|
|
41217
|
+
return true;
|
|
41218
|
+
};
|
|
41219
|
+
/** A probability is confident when it is at or beyond `threshold` on either side. */
|
|
41220
|
+
const isConfident$1 = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
|
|
41221
|
+
/** True when a probability sits inside the review band `(1 - threshold, threshold)`. */
|
|
41222
|
+
const inReviewBand = (probability, threshold) => !isConfident$1(probability, threshold);
|
|
41223
|
+
/** True when any probability sits inside the review band. */
|
|
41224
|
+
const anyInReviewBand = (probabilities, threshold) => probabilities.some((probability) => inReviewBand(probability, threshold));
|
|
41225
|
+
/** sha256 of the canonical input JSON — deterministic for a schema-decoded input. */
|
|
41226
|
+
const inputHash = (input) => createHash("sha256").update(JSON.stringify(input)).digest("hex");
|
|
41227
|
+
/**
|
|
41228
|
+
* Cache identity. Answers depend on the family and the model as well as the
|
|
41229
|
+
* input, so keying by the input hash alone would let a different family or model
|
|
41230
|
+
* reuse incompatible answers. The answer-schema version guards shape changes.
|
|
41231
|
+
*/
|
|
41232
|
+
const cacheKey = (family, model, answerHash) => createHash("sha256").update(`${family}\u0000${model}\u0000v1\u0000${answerHash}`).digest("hex");
|
|
41233
|
+
/** Group records into resolved/review buckets and count each verdict. */
|
|
41234
|
+
const buildDecisionReport = (options) => {
|
|
41235
|
+
const verdicts = {};
|
|
41236
|
+
const resolved = [];
|
|
41237
|
+
const review = [];
|
|
41238
|
+
for (const record of options.records) {
|
|
41239
|
+
verdicts[record.verdict] = (verdicts[record.verdict] ?? 0) + 1;
|
|
41240
|
+
if (record.review) review.push(record);
|
|
41241
|
+
else resolved.push(record);
|
|
41242
|
+
}
|
|
41243
|
+
return {
|
|
41244
|
+
kind: "decide",
|
|
41245
|
+
schemaVersion: 1,
|
|
41246
|
+
family: options.family,
|
|
41247
|
+
model: options.model,
|
|
41248
|
+
threshold: options.threshold,
|
|
41249
|
+
counts: {
|
|
41250
|
+
inputs: options.records.length,
|
|
41251
|
+
resolved: resolved.length,
|
|
41252
|
+
review: review.length
|
|
41253
|
+
},
|
|
41254
|
+
verdicts,
|
|
41255
|
+
resolved,
|
|
41256
|
+
review
|
|
41257
|
+
};
|
|
41258
|
+
};
|
|
41259
|
+
/**
|
|
41260
|
+
* Answer every input through the ambient `DecisionModel` and bucket the results.
|
|
41261
|
+
* One provider call answers every decision for an input; calls run with bounded
|
|
41262
|
+
* concurrency, and a warm cache skips the provider entirely.
|
|
41263
|
+
*/
|
|
41264
|
+
const runDecisions = (options) => gen(function* () {
|
|
41265
|
+
const records = yield* forEach$1(options.inputs, (input, index) => gen(function* () {
|
|
41266
|
+
const hash = inputHash(input);
|
|
41267
|
+
const key = cacheKey(options.family.name, options.model, hash);
|
|
41268
|
+
const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
|
|
41269
|
+
let answers;
|
|
41270
|
+
let usage;
|
|
41271
|
+
let verdict;
|
|
41272
|
+
if (cached !== void 0) {
|
|
41273
|
+
const decisions = options.family.definitionFor(input).decisions;
|
|
41274
|
+
if (validCachedAnswers(decisions, cached)) {
|
|
41275
|
+
answers = cached;
|
|
41276
|
+
verdict = options.family.evaluate(input, cached, options.threshold);
|
|
41277
|
+
}
|
|
41278
|
+
}
|
|
41279
|
+
if (verdict === void 0) {
|
|
41280
|
+
const response = yield* decide(options.family.definitionFor(input), { input });
|
|
41281
|
+
answers = response.answers;
|
|
41282
|
+
usage = {
|
|
41283
|
+
inputTokens: response.usage.inputTokens,
|
|
41284
|
+
outputTokens: response.usage.outputTokens
|
|
41285
|
+
};
|
|
41286
|
+
if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
|
|
41287
|
+
verdict = options.family.evaluate(input, answers, options.threshold);
|
|
41288
|
+
}
|
|
41289
|
+
return {
|
|
41290
|
+
decisionId: `${options.family.name}:${index}`,
|
|
41291
|
+
schemaVersion: 1,
|
|
41292
|
+
family: options.family.name,
|
|
41293
|
+
model: options.model,
|
|
41294
|
+
threshold: options.threshold,
|
|
41295
|
+
inputHash: hash,
|
|
41296
|
+
inputRef: options.family.inputRef(input),
|
|
41297
|
+
verdict,
|
|
41298
|
+
review: verdict === "review",
|
|
41299
|
+
answers,
|
|
41300
|
+
...usage === void 0 ? {} : { usage }
|
|
41301
|
+
};
|
|
41302
|
+
}), { concurrency: options.concurrency ?? 4 });
|
|
41303
|
+
return buildDecisionReport({
|
|
41304
|
+
family: options.family.name,
|
|
41305
|
+
model: options.model,
|
|
41306
|
+
threshold: options.threshold,
|
|
41307
|
+
records
|
|
41308
|
+
});
|
|
41309
|
+
});
|
|
41310
|
+
/** Decode raw batch inputs against a family's schema. Throws on the first invalid input. */
|
|
41311
|
+
/** `decodeUnknownSync` with an unconstrained schema type; the family types the result. */
|
|
41312
|
+
const decodeUnknown = decodeUnknownSync;
|
|
41313
|
+
const decodeFamilyInputs = (family, raw) => raw.map((value) => decodeUnknown(family.input)(value));
|
|
41314
|
+
//#endregion
|
|
41315
|
+
//#region src/decide/families/authority.ts
|
|
41316
|
+
/**
|
|
41317
|
+
* The authority family: is this a real link opportunity, a spam trap, or not
|
|
41318
|
+
* worth outreach?
|
|
41319
|
+
*
|
|
41320
|
+
* Three probabilities judge the domain and one classification places its fit.
|
|
41321
|
+
* A confident spam verdict is `spam`, a confident legitimate-and-worth-while fit
|
|
41322
|
+
* is `accept`, and everything uncertain is `review` — link outreach is never
|
|
41323
|
+
* auto-applied.
|
|
41324
|
+
*/
|
|
41325
|
+
const AUTHORITY_FIT_CRITERIA = {
|
|
41326
|
+
directory: "A directory or listing page that accepts submissions.",
|
|
41327
|
+
partner: "A partner or integration page that links relevant products.",
|
|
41328
|
+
editorial: "Editorial coverage: an article or guide that could cite this page.",
|
|
41329
|
+
community: "A community space: forum, Q&A, or discussion group.",
|
|
41330
|
+
none: "No genuine fit; outreach here would be noise."
|
|
41331
|
+
};
|
|
41332
|
+
/** One link target plus the evidence the model reads. */
|
|
41333
|
+
const AuthorityInput = Struct({
|
|
41334
|
+
domain: String$6,
|
|
41335
|
+
url: String$6,
|
|
41336
|
+
anchor: String$6,
|
|
41337
|
+
/** The surrounding copy on our page that would carry the link. */
|
|
41338
|
+
context: String$6,
|
|
41339
|
+
targetPath: String$6,
|
|
41340
|
+
/** Referring domains, when known. */
|
|
41341
|
+
rd: optional$3(Number$2),
|
|
41342
|
+
/** Extra observed signals, e.g. `["no-outbound", "paid-links"]`. */
|
|
41343
|
+
signals: optional$3(ArraySchema(String$6))
|
|
41344
|
+
});
|
|
41345
|
+
/** One provider call answers the three probabilities and the fit classification. */
|
|
41346
|
+
const AuthorityDecision = make({
|
|
41347
|
+
input: AuthorityInput,
|
|
41348
|
+
decisions: {
|
|
41349
|
+
legitimate: probability({
|
|
41350
|
+
instructions: "The target is a legitimate site a reader would trust: real editorial or community content, not a link farm, PBN, or paid-link network.",
|
|
41351
|
+
criteria: {
|
|
41352
|
+
false: "The target looks like spam, a link farm, or a paid-link network.",
|
|
41353
|
+
true: "The target is a legitimate site."
|
|
41354
|
+
}
|
|
41355
|
+
}),
|
|
41356
|
+
spam: probability({
|
|
41357
|
+
instructions: "The target shows spam signals: thin auto-generated content, exact-match anchor farms, or a link-selling footprint.",
|
|
41358
|
+
criteria: {
|
|
41359
|
+
false: "No meaningful spam signals.",
|
|
41360
|
+
true: "Clear spam or link-selling signals."
|
|
41361
|
+
}
|
|
41362
|
+
}),
|
|
41363
|
+
outreachWorth: probability({
|
|
41364
|
+
instructions: "Outreach to this target is worth the effort: a human could plausibly place a reader-useful link.",
|
|
41365
|
+
criteria: {
|
|
41366
|
+
false: "Outreach is not worth the effort.",
|
|
41367
|
+
true: "Outreach could place a useful link."
|
|
41368
|
+
}
|
|
41369
|
+
}),
|
|
41370
|
+
fit: classify({
|
|
41371
|
+
instructions: "Classify the relationship this target could support.",
|
|
41372
|
+
criteria: AUTHORITY_FIT_CRITERIA
|
|
41373
|
+
})
|
|
41374
|
+
}
|
|
41375
|
+
});
|
|
41376
|
+
/**
|
|
41377
|
+
* `spam` when spam is confident; `accept` only when legitimate, outreach-worthy,
|
|
41378
|
+
* and a non-`none` fit are all confident; otherwise `review`.
|
|
41379
|
+
*/
|
|
41380
|
+
const classifyAuthorityVerdict = (answers, threshold) => {
|
|
41381
|
+
if (anyInReviewBand([
|
|
41382
|
+
answers.legitimate.probability,
|
|
41383
|
+
answers.spam.probability,
|
|
41384
|
+
answers.outreachWorth.probability
|
|
41385
|
+
], threshold)) return "review";
|
|
41386
|
+
if (answers.spam.probability >= threshold) return "spam";
|
|
41387
|
+
const fitProbability = answers.fit.probabilities[answers.fit.label] ?? 0;
|
|
41388
|
+
if (answers.fit.label === "none" || fitProbability < threshold || answers.legitimate.probability < threshold || answers.outreachWorth.probability < threshold) return "review";
|
|
41389
|
+
return "accept";
|
|
41390
|
+
};
|
|
41391
|
+
const authorityFamily = {
|
|
41392
|
+
name: "authority",
|
|
41393
|
+
input: AuthorityInput,
|
|
41394
|
+
definitionFor: () => AuthorityDecision,
|
|
41395
|
+
inputRef: (input) => input.domain,
|
|
41396
|
+
evaluate: (_input, answers, threshold) => classifyAuthorityVerdict(answers, threshold)
|
|
41397
|
+
};
|
|
41398
|
+
//#endregion
|
|
41399
|
+
//#region src/decide/families/content.ts
|
|
41400
|
+
/**
|
|
41401
|
+
* The content family: does this page carry enough original substance?
|
|
41402
|
+
*
|
|
41403
|
+
* Seven probabilities score the page's editorial quality and one rating places
|
|
41404
|
+
* its overall value on `thin | adequate | strong`. A confident page with a
|
|
41405
|
+
* `thin` value or any failed probability is `flag`; an uncertain answer is
|
|
41406
|
+
* `review`. Nothing is rewritten here — the verdict is the plan.
|
|
41407
|
+
*/
|
|
41408
|
+
/** Ordered value scale from least to most substance. */
|
|
41409
|
+
const CONTENT_VALUES = [
|
|
41410
|
+
"thin",
|
|
41411
|
+
"adequate",
|
|
41412
|
+
"strong"
|
|
41413
|
+
];
|
|
41414
|
+
/** One page plus the evidence the rubric reads. */
|
|
41415
|
+
const ContentInput = Struct({
|
|
41416
|
+
url: String$6,
|
|
41417
|
+
/** The query the page targets; intent rubrics cannot be judged without it. */
|
|
41418
|
+
query: String$6,
|
|
41419
|
+
title: String$6,
|
|
41420
|
+
h1: String$6,
|
|
41421
|
+
first150Words: String$6,
|
|
41422
|
+
headings: ArraySchema(String$6),
|
|
41423
|
+
wordCount: Natural,
|
|
41424
|
+
/** schema.org types present on the page, e.g. `["Article", "FAQPage"]`. */
|
|
41425
|
+
structuredData: ArraySchema(String$6),
|
|
41426
|
+
/** The topic the page must stay inside; drifting outside it is a failure. */
|
|
41427
|
+
categoryLock: String$6,
|
|
41428
|
+
/** Sibling pages' intents, needed to judge `distinctIntent`. */
|
|
41429
|
+
siblingIntents: optional$3(ArraySchema(String$6)),
|
|
41430
|
+
competitorExcerpts: optional$3(ArraySchema(String$6))
|
|
41431
|
+
});
|
|
41432
|
+
const boolean = (instructions, falseText, trueText) => probability({
|
|
41433
|
+
instructions,
|
|
41434
|
+
criteria: {
|
|
41435
|
+
false: falseText,
|
|
41436
|
+
true: trueText
|
|
41437
|
+
}
|
|
41438
|
+
});
|
|
41439
|
+
/** One provider call answers the seven rubric probabilities and the value rating. */
|
|
41440
|
+
const ContentDecision = make({
|
|
41441
|
+
input: ContentInput,
|
|
41442
|
+
decisions: {
|
|
41443
|
+
answersFirst: boolean("The first 150 words answer the reader's question directly, before background or preamble.", "The opening delays the answer.", "The opening answers the question up front."),
|
|
41444
|
+
originalValue: boolean("The page adds original value — first-hand experience, data, or a distinct point of view — beyond restating common knowledge.", "The page restates what every other result already says.", "The page adds information a reader cannot get elsewhere."),
|
|
41445
|
+
competitiveSubstance: boolean("The page has enough depth and specificity to compete with the current results for this topic.", "The page is shallower or less specific than the competition.", "The page matches or exceeds the competition's substance."),
|
|
41446
|
+
descriptiveTitle: boolean("The title is descriptive and specific to the page's content rather than vague or clickbait.", "The title is vague or misleading.", "The title describes the page's actual content."),
|
|
41447
|
+
structuredDataMatches: boolean("The page's structured data matches its content type and the supplied query's intent.", "Structured data is missing, wrong, or mismatched.", "Structured data correctly describes the page."),
|
|
41448
|
+
categoryConsistency: boolean("The page stays inside its locked category; its headings and body do not drift into an adjacent topic.", "The page drifts outside the locked category.", "The page stays inside the locked category."),
|
|
41449
|
+
distinctIntent: boolean("The page serves a distinct intent rather than duplicating a sibling page's intent. Judge only the sibling intents supplied; if none are supplied, treat this as unresolved.", "The page duplicates a sibling page's intent.", "The page serves an intent no supplied sibling covers."),
|
|
41450
|
+
value: rate({
|
|
41451
|
+
instructions: "Overall reader value of this page: thin restates what exists, adequate is competitive, strong is the best available answer.",
|
|
41452
|
+
criteria: CONTENT_VALUES
|
|
41453
|
+
})
|
|
41454
|
+
}
|
|
41455
|
+
});
|
|
41456
|
+
/** The seven rubric probabilities that decide pass/flag once confidence is settled. */
|
|
41457
|
+
const RUBRIC_KEYS = [
|
|
41458
|
+
"answersFirst",
|
|
41459
|
+
"originalValue",
|
|
41460
|
+
"competitiveSubstance",
|
|
41461
|
+
"descriptiveTitle",
|
|
41462
|
+
"structuredDataMatches",
|
|
41463
|
+
"categoryConsistency",
|
|
41464
|
+
"distinctIntent"
|
|
41465
|
+
];
|
|
41466
|
+
/** Evidence present by default; a caller with no evidence can still test the band logic. */
|
|
41467
|
+
const ALL_EVIDENCE = {
|
|
41468
|
+
distinctIntent: true,
|
|
41469
|
+
competitiveSubstance: true
|
|
41470
|
+
};
|
|
41471
|
+
/**
|
|
41472
|
+
* `flag` when the value rating is `thin` or any rubric probability is
|
|
41473
|
+
* confidently false; `review` when any probability (rubric or value
|
|
41474
|
+
* distribution) is inside the band, or an evidence-dependent rubric is
|
|
41475
|
+
* confidently positive without its evidence; `pass` otherwise. A confident
|
|
41476
|
+
* negative on an unevidenced rubric stays `flag` — it is the conservative side.
|
|
41477
|
+
*/
|
|
41478
|
+
const classifyContentVerdict = (answers, threshold, evidence = ALL_EVIDENCE) => {
|
|
41479
|
+
const rubric = RUBRIC_KEYS.map((key) => answers[key].probability);
|
|
41480
|
+
const distribution = Object.values(answers.value.probabilities);
|
|
41481
|
+
if (anyInReviewBand([...rubric, ...distribution], threshold)) return "review";
|
|
41482
|
+
if (!evidence.distinctIntent && answers.distinctIntent.probability >= threshold) return "review";
|
|
41483
|
+
if (!evidence.competitiveSubstance && answers.competitiveSubstance.probability >= threshold) return "review";
|
|
41484
|
+
if (answers.value.label === "thin") return "flag";
|
|
41485
|
+
if (rubric.some((probability) => probability <= 1 - threshold)) return "flag";
|
|
41486
|
+
return "pass";
|
|
41487
|
+
};
|
|
41488
|
+
const contentFamily = {
|
|
41489
|
+
name: "content",
|
|
41490
|
+
input: ContentInput,
|
|
41491
|
+
definitionFor: () => ContentDecision,
|
|
41492
|
+
inputRef: (input) => input.url,
|
|
41493
|
+
evaluate: (input, answers, threshold) => classifyContentVerdict(answers, threshold, {
|
|
41494
|
+
distinctIntent: input.siblingIntents !== void 0 && input.siblingIntents.length > 0,
|
|
41495
|
+
competitiveSubstance: input.competitorExcerpts !== void 0 && input.competitorExcerpts.length > 0
|
|
41496
|
+
})
|
|
41497
|
+
};
|
|
41498
|
+
/** One query plus the candidate pages already on the site. */
|
|
41499
|
+
const FitInput = Struct({
|
|
41500
|
+
query: String$6,
|
|
41501
|
+
candidates: ArraySchema(Struct({
|
|
41502
|
+
id: String$6,
|
|
41503
|
+
title: String$6,
|
|
41504
|
+
excerpt: String$6
|
|
41505
|
+
}))
|
|
41506
|
+
});
|
|
41507
|
+
const candidateCriteria$1 = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.excerpt}`.slice(0, 255)]));
|
|
41508
|
+
/** One provider call answers `bestPage`, `competition`, and `needsNewPage`. */
|
|
41509
|
+
const makeFitDecision = (input) => make({
|
|
41510
|
+
input: FitInput,
|
|
41511
|
+
decisions: {
|
|
41512
|
+
bestPage: classify({
|
|
41513
|
+
instructions: "Choose the candidate page that best targets this query, or the closest starting point to update. Use the page's title and excerpt.",
|
|
41514
|
+
criteria: candidateCriteria$1(input.candidates)
|
|
41515
|
+
}),
|
|
41516
|
+
competition: probability({
|
|
41517
|
+
instructions: "Two or more of the supplied pages already target this query, so they risk cannibalizing each other.",
|
|
41518
|
+
criteria: {
|
|
41519
|
+
false: "At most one page targets the query.",
|
|
41520
|
+
true: "Multiple pages target the query."
|
|
41521
|
+
}
|
|
41522
|
+
}),
|
|
41523
|
+
needsNewPage: probability({
|
|
41524
|
+
instructions: "None of the supplied pages can serve this query well; a new page is the right answer.",
|
|
41525
|
+
criteria: {
|
|
41526
|
+
false: "An existing page can serve this query.",
|
|
41527
|
+
true: "No existing page can serve this query; a new page is needed."
|
|
41528
|
+
}
|
|
41529
|
+
})
|
|
41530
|
+
}
|
|
41531
|
+
});
|
|
41532
|
+
/** Reject inputs whose candidate ids cannot become provider labels. */
|
|
41533
|
+
const validateFitInput = (input) => {
|
|
41534
|
+
if (input.candidates.length < 2) return "decide fit needs at least two candidates: bestPage compares them.";
|
|
41535
|
+
const long = input.candidates.find((candidate) => candidate.id.length > 255);
|
|
41536
|
+
if (long !== void 0) return `decide fit candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
|
|
41537
|
+
if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide fit candidate ids must be unique: they become provider labels.";
|
|
41538
|
+
};
|
|
41539
|
+
/**
|
|
41540
|
+
* `gap` wins over `cannibalized` when both are confident: a new page is the more
|
|
41541
|
+
* decisive answer. `map` needs a confident `bestPage`; otherwise it is `review`.
|
|
41542
|
+
*/
|
|
41543
|
+
const classifyFitVerdict = (answers, threshold) => {
|
|
41544
|
+
if (anyInReviewBand([answers.competition.probability, answers.needsNewPage.probability], threshold)) return "review";
|
|
41545
|
+
if (answers.needsNewPage.probability >= threshold) return "gap";
|
|
41546
|
+
if (answers.competition.probability >= threshold) return "cannibalized";
|
|
41547
|
+
return (answers.bestPage.probabilities[answers.bestPage.label] ?? 0) >= threshold ? "map" : "review";
|
|
41548
|
+
};
|
|
41549
|
+
const fitFamily = {
|
|
41550
|
+
name: "fit",
|
|
41551
|
+
input: FitInput,
|
|
41552
|
+
definitionFor: makeFitDecision,
|
|
41553
|
+
inputRef: (input) => `query:${input.query}`,
|
|
41554
|
+
evaluate: (_input, answers, threshold) => classifyFitVerdict(answers, threshold),
|
|
41555
|
+
validate: validateFitInput
|
|
41556
|
+
};
|
|
41557
|
+
/** One page plus the metadata candidates to rank. */
|
|
41558
|
+
const MetaInput = Struct({
|
|
41559
|
+
url: String$6,
|
|
41560
|
+
intent: String$6,
|
|
41561
|
+
categoryLock: String$6,
|
|
41562
|
+
candidates: ArraySchema(Struct({
|
|
41563
|
+
id: String$6,
|
|
41564
|
+
title: String$6,
|
|
41565
|
+
description: String$6
|
|
41566
|
+
}))
|
|
41567
|
+
});
|
|
41568
|
+
const candidateCriteria = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.description}`.slice(0, 255)]));
|
|
41569
|
+
/** One provider call answers `best` and the four concern probabilities. */
|
|
41570
|
+
const makeMetaDecision = (input) => make({
|
|
41571
|
+
input: MetaInput,
|
|
41572
|
+
decisions: {
|
|
41573
|
+
best: classify({
|
|
41574
|
+
instructions: "Choose the candidate title/description that best serves the page's intent and stays inside its category. Judge the supplied candidates only.",
|
|
41575
|
+
criteria: candidateCriteria(input.candidates)
|
|
41576
|
+
}),
|
|
41577
|
+
intentFit: probability({
|
|
41578
|
+
instructions: "The chosen candidate's promise matches the page's intent.",
|
|
41579
|
+
criteria: {
|
|
41580
|
+
false: "The candidate promises something the page does not deliver.",
|
|
41581
|
+
true: "The candidate matches the page's intent."
|
|
41582
|
+
}
|
|
41583
|
+
}),
|
|
41584
|
+
lengthOk: probability({
|
|
41585
|
+
instructions: "The chosen candidate fits search-result limits: a title near 60 characters, a description near 160.",
|
|
41586
|
+
criteria: {
|
|
41587
|
+
false: "The candidate is too short or truncated.",
|
|
41588
|
+
true: "The candidate fits the result limits."
|
|
41589
|
+
}
|
|
41590
|
+
}),
|
|
41591
|
+
categoryDrift: probability({
|
|
41592
|
+
instructions: "The chosen candidate drifts outside the page's locked category.",
|
|
41593
|
+
criteria: {
|
|
41594
|
+
false: "The candidate stays inside the category.",
|
|
41595
|
+
true: "The candidate drifts outside the category."
|
|
41596
|
+
}
|
|
41597
|
+
}),
|
|
41598
|
+
clickbait: probability({
|
|
41599
|
+
instructions: "The chosen candidate uses clickbait or overpromises relative to the page's content.",
|
|
41600
|
+
criteria: {
|
|
41601
|
+
false: "The candidate is accurate and measured.",
|
|
41602
|
+
true: "The candidate overpromises or uses clickbait."
|
|
41603
|
+
}
|
|
41604
|
+
})
|
|
41605
|
+
}
|
|
41606
|
+
});
|
|
41607
|
+
/** Reject inputs whose candidate ids cannot become provider labels. */
|
|
41608
|
+
const validateMetaInput = (input) => {
|
|
41609
|
+
if (input.candidates.length < 2) return "decide meta needs at least two candidates: best ranks them.";
|
|
41610
|
+
const long = input.candidates.find((candidate) => candidate.id.length > 255);
|
|
41611
|
+
if (long !== void 0) return `decide meta candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
|
|
41612
|
+
if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide meta candidate ids must be unique: they become provider labels.";
|
|
41613
|
+
};
|
|
41614
|
+
/**
|
|
41615
|
+
* A clean candidate is `choose:<id>`. Any review-band probability, a low-probability
|
|
41616
|
+
* pick, confident drift, clickbait, or a mismatch on intent/length is `review`.
|
|
41617
|
+
*/
|
|
41618
|
+
const classifyMetaVerdict = (answers, threshold) => {
|
|
41619
|
+
if (anyInReviewBand([
|
|
41620
|
+
answers.intentFit.probability,
|
|
41621
|
+
answers.lengthOk.probability,
|
|
41622
|
+
answers.categoryDrift.probability,
|
|
41623
|
+
answers.clickbait.probability
|
|
41624
|
+
], threshold)) return "review";
|
|
41625
|
+
if ((answers.best.probabilities[answers.best.label] ?? 0) < threshold) return "review";
|
|
41626
|
+
if (answers.intentFit.probability < threshold || answers.lengthOk.probability < threshold || answers.categoryDrift.probability >= threshold || answers.clickbait.probability >= threshold) return "review";
|
|
41627
|
+
return `choose:${answers.best.label}`;
|
|
41628
|
+
};
|
|
41629
|
+
const metaFamily = {
|
|
41630
|
+
name: "meta",
|
|
41631
|
+
input: MetaInput,
|
|
41632
|
+
definitionFor: makeMetaDecision,
|
|
41633
|
+
inputRef: (input) => input.url,
|
|
41634
|
+
evaluate: (_input, answers, threshold) => classifyMetaVerdict(answers, threshold),
|
|
41635
|
+
validate: validateMetaInput
|
|
41636
|
+
};
|
|
41637
|
+
//#endregion
|
|
41638
|
+
//#region src/decide/families/serp.ts
|
|
41639
|
+
/**
|
|
41640
|
+
* The serp family: is our page the shape this results page rewards?
|
|
41641
|
+
*
|
|
41642
|
+
* The model classifies the dominant SERP format and answers three probabilities
|
|
41643
|
+
* about our page. A confident match is `aligned`, a confident miss is `mismatch`,
|
|
41644
|
+
* and any answer inside the confidence band is `review` — the format question is
|
|
41645
|
+
* the one a human is most often needed to settle.
|
|
41646
|
+
*/
|
|
41647
|
+
const SERP_FORMAT_CRITERIA = {
|
|
41648
|
+
definition: "A definition or explainer page: what X is and how it works.",
|
|
41649
|
+
tool: "An interactive tool, calculator, or generator.",
|
|
41650
|
+
comparison: "A comparison of options: X vs Y, or the best X.",
|
|
41651
|
+
listicle: "A ranked or numbered list of items.",
|
|
41652
|
+
docs: "Product, API, or reference documentation.",
|
|
41653
|
+
vendorLanding: "A vendor's marketing or landing page for its own product.",
|
|
41654
|
+
forum: "A community discussion: forum, Reddit, or Stack Overflow.",
|
|
41655
|
+
other: "None of the above."
|
|
41656
|
+
};
|
|
41657
|
+
/** One ranked result in the saved SERP snapshot. */
|
|
41658
|
+
const SerpItem = Struct({
|
|
41659
|
+
type: String$6,
|
|
41660
|
+
rank: Number$2,
|
|
41661
|
+
domain: String$6,
|
|
41662
|
+
title: optional$3(String$6),
|
|
41663
|
+
snippet: optional$3(String$6)
|
|
41664
|
+
});
|
|
41665
|
+
/** Our candidate page, plus the SERP snapshot for the query. */
|
|
41666
|
+
const SerpInput = Struct({
|
|
41667
|
+
query: String$6,
|
|
41668
|
+
ourPage: Struct({
|
|
41669
|
+
url: String$6,
|
|
41670
|
+
title: String$6,
|
|
41671
|
+
kind: String$6,
|
|
41672
|
+
firstWords: String$6
|
|
41673
|
+
}),
|
|
41674
|
+
serpItems: ArraySchema(SerpItem)
|
|
41675
|
+
});
|
|
41676
|
+
/** One provider call answers the SERP format and the three fit probabilities. */
|
|
41677
|
+
const SerpDecision = make({
|
|
41678
|
+
input: SerpInput,
|
|
41679
|
+
decisions: {
|
|
41680
|
+
serpFormat: classify({
|
|
41681
|
+
instructions: "Classify the dominant format of this results page from the ranking results. Weigh the top results most heavily.",
|
|
41682
|
+
criteria: SERP_FORMAT_CRITERIA
|
|
41683
|
+
}),
|
|
41684
|
+
ourFormatFit: probability({
|
|
41685
|
+
instructions: "Our page's format matches how this results page is won: the same shape a top result uses, not merely a related topic.",
|
|
41686
|
+
criteria: {
|
|
41687
|
+
false: "Our format is the wrong shape for this SERP.",
|
|
41688
|
+
true: "Our format is the shape this SERP rewards."
|
|
41689
|
+
}
|
|
41690
|
+
}),
|
|
41691
|
+
intentMatch: probability({
|
|
41692
|
+
instructions: "Our page satisfies the query's dominant intent: the reader's task after landing, not the literal keywords.",
|
|
41693
|
+
criteria: {
|
|
41694
|
+
false: "The query wants something our page does not provide.",
|
|
41695
|
+
true: "Our page provides what the query asks for."
|
|
41696
|
+
}
|
|
41697
|
+
}),
|
|
41698
|
+
titlePatternMatch: probability({
|
|
41699
|
+
instructions: "Our title follows the pattern the ranking titles use, including the qualifiers and framing a searcher expects.",
|
|
41700
|
+
criteria: {
|
|
41701
|
+
false: "Our title does not follow the ranking pattern.",
|
|
41702
|
+
true: "Our title follows the ranking pattern."
|
|
41703
|
+
}
|
|
41704
|
+
})
|
|
41705
|
+
}
|
|
41706
|
+
});
|
|
41707
|
+
/**
|
|
41708
|
+
* `aligned` needs both intent and format confident-positive; anything else that
|
|
41709
|
+
* is confident is `mismatch`. `titlePatternMatch` only widens the review band.
|
|
41710
|
+
*/
|
|
41711
|
+
const classifySerpVerdict = (answers, threshold) => {
|
|
41712
|
+
const probabilities = [
|
|
41713
|
+
answers.ourFormatFit.probability,
|
|
41714
|
+
answers.intentMatch.probability,
|
|
41715
|
+
answers.titlePatternMatch.probability
|
|
41716
|
+
];
|
|
41717
|
+
if (anyInReviewBand(probabilities, threshold)) return "review";
|
|
41718
|
+
return answers.intentMatch.probability >= threshold && answers.ourFormatFit.probability >= threshold ? "aligned" : "mismatch";
|
|
41719
|
+
};
|
|
41720
|
+
const serpFamily = {
|
|
41721
|
+
name: "serp",
|
|
41722
|
+
input: SerpInput,
|
|
41723
|
+
definitionFor: () => SerpDecision,
|
|
41724
|
+
inputRef: (input) => `${input.ourPage.url} · query:${input.query}`,
|
|
41725
|
+
evaluate: (_input, answers, threshold) => classifySerpVerdict(answers, threshold)
|
|
41726
|
+
};
|
|
41727
|
+
//#endregion
|
|
41728
|
+
//#region src/decide/inputs.ts
|
|
41729
|
+
/**
|
|
41730
|
+
* Batch input reading for `pagegraph decide <family>`. A batch is either a JSON
|
|
41731
|
+
* array, a `{ "inputs": [...] }` envelope, or newline-delimited JSON (one input
|
|
41732
|
+
* per non-empty line) — from a file argument or stdin. A TTY with no file is an
|
|
41733
|
+
* error: the command must never appear to hang waiting for input that will not
|
|
41734
|
+
* come from a pipe.
|
|
41735
|
+
*/
|
|
41736
|
+
/** Parse a batch of raw inputs from text. Throws a plain `Error` on malformed input. */
|
|
41737
|
+
const decodeInputsText = (text) => {
|
|
41738
|
+
const trimmed = text.trim();
|
|
41739
|
+
if (trimmed.length === 0) throw new Error("no input provided");
|
|
41740
|
+
let parsed;
|
|
41741
|
+
try {
|
|
41742
|
+
parsed = JSON.parse(trimmed);
|
|
41743
|
+
} catch (error) {
|
|
41744
|
+
const lines = trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
41745
|
+
if (lines.length > 1) try {
|
|
41746
|
+
return lines.map((line) => JSON.parse(line));
|
|
41747
|
+
} catch {
|
|
41748
|
+
throw error;
|
|
41749
|
+
}
|
|
41750
|
+
throw error;
|
|
41751
|
+
}
|
|
41752
|
+
if (Array.isArray(parsed)) return parsed;
|
|
41753
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
41754
|
+
const inputs = parsed.inputs;
|
|
41755
|
+
if (Array.isArray(inputs)) return inputs;
|
|
41756
|
+
return [parsed];
|
|
41757
|
+
}
|
|
41758
|
+
throw new Error("expected a JSON array, a { \"inputs\": [...] } envelope, or JSONL");
|
|
41759
|
+
};
|
|
41760
|
+
//#endregion
|
|
41761
|
+
//#region src/decide/render.ts
|
|
41762
|
+
/** One review-queue entry: what was asked, how it landed, and the raw answers. */
|
|
41763
|
+
const reviewLine = (record) => [` ${record.inputRef} · ${record.verdict}`, ` ${JSON.stringify(record.answers)}`].join("\n");
|
|
41764
|
+
/**
|
|
41765
|
+
* Human `decide` report: the batch header, a verdict breakdown, and the review
|
|
41766
|
+
* queue — the only part a human has to act on.
|
|
41767
|
+
*/
|
|
41768
|
+
const renderDecideReport = (report) => {
|
|
41769
|
+
const lines = [
|
|
41770
|
+
`${report.counts.inputs} input(s) · family ${report.family} · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
|
|
41771
|
+
"",
|
|
41772
|
+
` resolved ${report.counts.resolved}`,
|
|
41773
|
+
` review ${report.counts.review}`,
|
|
41774
|
+
""
|
|
41775
|
+
];
|
|
41776
|
+
const verdicts = Object.entries(report.verdicts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
41777
|
+
if (verdicts.length > 0) {
|
|
41778
|
+
lines.push("Verdicts:", "");
|
|
41779
|
+
for (const [verdict, count] of verdicts) lines.push(` ${verdict.padEnd(20)} ${count}`);
|
|
41780
|
+
lines.push("");
|
|
41781
|
+
}
|
|
41782
|
+
if (report.review.length === 0) lines.push("No inputs need review.");
|
|
41783
|
+
else {
|
|
41784
|
+
lines.push(`Review queue (${report.review.length}):`, "");
|
|
41785
|
+
for (const record of report.review) lines.push(reviewLine(record), "");
|
|
41786
|
+
lines.pop();
|
|
41787
|
+
}
|
|
41788
|
+
return lines.join("\n").trimEnd();
|
|
41789
|
+
};
|
|
41790
|
+
//#endregion
|
|
41791
|
+
//#region src/links/decide.ts
|
|
41792
|
+
/**
|
|
41793
|
+
* Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
|
|
41794
|
+
*
|
|
41795
|
+
* A candidate is a `(source → destination)` pair with the source copy. The model
|
|
41796
|
+
* answers two probabilities — does the source have a genuine reader-facing
|
|
41797
|
+
* reason to link to the destination, and is descriptive anchor text already in
|
|
41798
|
+
* the copy — and this module maps them to a verdict. Anything the model is not
|
|
41799
|
+
* confident about lands in the `review` bucket; nothing is applied automatically.
|
|
41800
|
+
*
|
|
41801
|
+
* This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
|
|
41802
|
+
* the library entries stay Effect-free and only the bundled `pagegraph` bin
|
|
41803
|
+
* ships it.
|
|
41804
|
+
*/
|
|
41805
|
+
/** Default probability boundary between a confident and an uncertain answer. */
|
|
41806
|
+
const DEFAULT_LINK_THRESHOLD = .7;
|
|
41807
|
+
/** Ordered relevance scale for budget selection. */
|
|
41808
|
+
const LINK_RELEVANCE = [
|
|
41809
|
+
"irrelevant",
|
|
41810
|
+
"useful",
|
|
41811
|
+
"essential"
|
|
41812
|
+
];
|
|
41813
|
+
/** One proposed contextual link, plus the source copy the model reads. */
|
|
41814
|
+
const LinkCandidate = Struct({
|
|
41815
|
+
sourceUrl: String$6,
|
|
41816
|
+
destinationUrl: String$6,
|
|
41817
|
+
sourceText: String$6,
|
|
41818
|
+
/** Descriptive anchor text already present for the destination, when known. */
|
|
41819
|
+
existingAnchor: optional$3(String$6)
|
|
41820
|
+
});
|
|
41821
|
+
/**
|
|
41822
|
+
* The four decisions every candidate answers in one provider call: two
|
|
41823
|
+
* probabilities, a direction classification, and a relevance rating. The
|
|
41824
|
+
* probabilities decide the verdict; direction and relevance inform the plan and
|
|
41825
|
+
* the per-source budget.
|
|
41826
|
+
*/
|
|
41827
|
+
const LinkDecision = make({
|
|
41828
|
+
input: LinkCandidate,
|
|
41829
|
+
decisions: {
|
|
41830
|
+
realReason: probability({
|
|
41831
|
+
instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
|
|
41832
|
+
criteria: {
|
|
41833
|
+
false: "No genuine reason; the link would be noise or only keyword overlap.",
|
|
41834
|
+
true: "A reader of the source would plausibly want the destination next."
|
|
41835
|
+
}
|
|
41836
|
+
}),
|
|
41837
|
+
anchorPresent: probability({
|
|
41838
|
+
instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
|
|
41839
|
+
criteria: {
|
|
41840
|
+
false: "No existing anchor to the destination in the copy.",
|
|
41841
|
+
true: "Descriptive anchor text to the destination is already in the copy."
|
|
41842
|
+
}
|
|
41843
|
+
}),
|
|
41844
|
+
direction: classify({
|
|
41845
|
+
instructions: "Which direction between the candidate pair deserves a link: source → destination, destination → source, or both.",
|
|
41846
|
+
criteria: {
|
|
41847
|
+
a_to_b: "The source page should link to the destination page.",
|
|
41848
|
+
b_to_a: "The destination page should link to the source page.",
|
|
41849
|
+
both: "Both directions are reader-useful."
|
|
41850
|
+
}
|
|
41851
|
+
}),
|
|
41852
|
+
relevance: rate({
|
|
41853
|
+
instructions: "How relevant this link is to the reader: irrelevant is noise, useful is a sensible next step, essential is a link the page is incomplete without.",
|
|
41854
|
+
criteria: LINK_RELEVANCE
|
|
41855
|
+
})
|
|
41856
|
+
}
|
|
41857
|
+
});
|
|
41858
|
+
/** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
|
|
41859
|
+
const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
|
|
41860
|
+
/**
|
|
41861
|
+
* Map the two probabilities to a verdict. A probability inside the review band
|
|
41862
|
+
* makes the whole candidate uncertain, so the confident half of the pair can
|
|
41863
|
+
* never auto-resolve it. Requires `threshold > 0.5`, so the review band
|
|
41864
|
+
* `(1 - threshold, threshold)` is non-empty.
|
|
41865
|
+
*
|
|
41866
|
+
* - `present` — descriptive anchor already in the copy; nothing to add.
|
|
41867
|
+
* - `recommend` — a real reason and no existing anchor; add a contextual link.
|
|
41868
|
+
* - `skip` — no real reason; leave the copy alone.
|
|
41869
|
+
* - `review` — at least one answer is inside the band; a human decides.
|
|
41870
|
+
*/
|
|
41871
|
+
const classifyVerdict = (realReason, anchorPresent, threshold) => {
|
|
41872
|
+
if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
|
|
41873
|
+
if (anchorPresent >= threshold) return "present";
|
|
41874
|
+
if (realReason >= threshold) return "recommend";
|
|
41875
|
+
return "skip";
|
|
41876
|
+
};
|
|
41877
|
+
/**
|
|
41878
|
+
* Keep the top `budget` candidates per outbound page by relevance rating,
|
|
41879
|
+
* preserving the original order. The outbound page is `record.from`, which
|
|
41880
|
+
* honors `direction` — grouping by the candidate's `sourceUrl` would misspend the
|
|
41881
|
+
* quota when the model recommends the reverse direction. Ties keep their
|
|
41882
|
+
* original order (stable sort). Pages with fewer candidates pass through.
|
|
41883
|
+
*/
|
|
41884
|
+
const applyBudget = (records, budget) => {
|
|
41885
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
41886
|
+
for (const record of records) {
|
|
41887
|
+
const bucket = bySource.get(record.from);
|
|
41888
|
+
if (bucket === void 0) bySource.set(record.from, [record]);
|
|
41889
|
+
else bucket.push(record);
|
|
41890
|
+
}
|
|
41891
|
+
const kept = /* @__PURE__ */ new Set();
|
|
41892
|
+
for (const bucket of bySource.values()) {
|
|
41893
|
+
const top = [...bucket].sort((a, b) => b.relevance.rating - a.relevance.rating).slice(0, budget);
|
|
41894
|
+
for (const record of top) kept.add(record);
|
|
41895
|
+
}
|
|
41896
|
+
return records.filter((record) => kept.has(record));
|
|
41897
|
+
};
|
|
41898
|
+
/** Group records into resolved/review buckets and count each verdict. */
|
|
41899
|
+
const buildLinksDecideReport = (options) => {
|
|
41900
|
+
const counts = {
|
|
41901
|
+
recommend: 0,
|
|
41902
|
+
present: 0,
|
|
41903
|
+
skip: 0,
|
|
41904
|
+
review: 0
|
|
41905
|
+
};
|
|
41906
|
+
const resolved = [];
|
|
41907
|
+
const review = [];
|
|
41908
|
+
for (const record of options.records) {
|
|
41909
|
+
counts[record.verdict] += 1;
|
|
41910
|
+
if (record.verdict === "review") review.push(record);
|
|
41911
|
+
else resolved.push(record);
|
|
41912
|
+
}
|
|
41913
|
+
return {
|
|
41914
|
+
kind: "links-decide",
|
|
41915
|
+
schemaVersion: 1,
|
|
41916
|
+
model: options.model,
|
|
41917
|
+
threshold: options.threshold,
|
|
41918
|
+
budget: options.budget ?? null,
|
|
41919
|
+
dropped: options.dropped ?? 0,
|
|
41920
|
+
counts: {
|
|
41921
|
+
candidates: options.records.length,
|
|
41922
|
+
...counts
|
|
41923
|
+
},
|
|
41924
|
+
resolved,
|
|
41925
|
+
review
|
|
41926
|
+
};
|
|
41927
|
+
};
|
|
41928
|
+
/**
|
|
41929
|
+
* The endpoints a `recommend` verdict actually directs. `b_to_a` reverses the
|
|
41930
|
+
* candidate pair, so the report names the direction the model chose rather than
|
|
41931
|
+
* always printing source → destination.
|
|
41932
|
+
*/
|
|
41933
|
+
const endpoints = (candidate, direction) => direction === "b_to_a" ? {
|
|
41934
|
+
from: candidate.destinationUrl,
|
|
41935
|
+
to: candidate.sourceUrl
|
|
41936
|
+
} : {
|
|
41937
|
+
from: candidate.sourceUrl,
|
|
41938
|
+
to: candidate.destinationUrl
|
|
41939
|
+
};
|
|
41940
|
+
const toRecord = (candidate, answers, threshold) => {
|
|
41941
|
+
const realReason = answers.realReason.probability;
|
|
41942
|
+
const anchorPresent = answers.anchorPresent.probability;
|
|
41943
|
+
const direction = answers.direction.label;
|
|
41944
|
+
return {
|
|
41945
|
+
sourceUrl: candidate.sourceUrl,
|
|
41946
|
+
destinationUrl: candidate.destinationUrl,
|
|
41947
|
+
sourceText: candidate.sourceText,
|
|
41948
|
+
existingAnchor: candidate.existingAnchor ?? null,
|
|
41949
|
+
realReason,
|
|
41950
|
+
anchorPresent,
|
|
41951
|
+
direction,
|
|
41952
|
+
relevance: {
|
|
41953
|
+
rating: answers.relevance.rating,
|
|
41954
|
+
label: answers.relevance.label,
|
|
41955
|
+
probabilities: answers.relevance.probabilities
|
|
41956
|
+
},
|
|
41957
|
+
...endpoints(candidate, direction),
|
|
41958
|
+
verdict: classifyVerdict(realReason, anchorPresent, threshold)
|
|
41959
|
+
};
|
|
41960
|
+
};
|
|
41961
|
+
/**
|
|
41962
|
+
* Answer every candidate through the ambient `DecisionModel` and bucket the
|
|
41963
|
+
* results. The model answers both decisions in one call per candidate; candidates
|
|
41964
|
+
* are dispatched with bounded concurrency. A warm cache skips the provider.
|
|
41965
|
+
*/
|
|
41966
|
+
const decideLinks = (candidates, options) => gen(function* () {
|
|
41967
|
+
const records = yield* forEach$1(candidates, (candidate) => gen(function* () {
|
|
41968
|
+
const hash = inputHash(candidate);
|
|
41969
|
+
const key = cacheKey("links", options.model, hash);
|
|
41970
|
+
const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
|
|
41971
|
+
if (cached !== void 0 && validCachedAnswers(LinkDecision.decisions, cached)) return toRecord(candidate, cached, options.threshold);
|
|
41972
|
+
const { answers } = yield* decide(LinkDecision, { input: candidate });
|
|
41973
|
+
if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
|
|
41974
|
+
return toRecord(candidate, answers, options.threshold);
|
|
41975
|
+
}), { concurrency: options.concurrency ?? 4 });
|
|
41976
|
+
const selected = options.budget === void 0 ? records : applyBudget(records, options.budget);
|
|
41977
|
+
return buildLinksDecideReport({
|
|
41978
|
+
model: options.model,
|
|
41979
|
+
threshold: options.threshold,
|
|
41980
|
+
records: selected,
|
|
41981
|
+
budget: options.budget,
|
|
41982
|
+
dropped: records.length - selected.length
|
|
41983
|
+
});
|
|
41984
|
+
});
|
|
41985
|
+
/** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
|
|
41986
|
+
const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
|
|
41987
|
+
//#endregion
|
|
41988
|
+
//#region src/cli/commands/decide.ts
|
|
41989
|
+
/**
|
|
41990
|
+
* `pagegraph decide <family>` — the decision runner over a batch. Each family
|
|
41991
|
+
* subcommand reads a batch of inputs, answers every decision for an input in one
|
|
41992
|
+
* provider call, and separates confident verdicts from `review`.
|
|
41993
|
+
*
|
|
41994
|
+
* `decide links` is the links family alias of the legacy `pagegraph links decide`.
|
|
41995
|
+
* Effect and the TypeSafe provider live only in this CLI-internal command; the
|
|
41996
|
+
* library entries stay Effect-free.
|
|
41997
|
+
*/
|
|
41998
|
+
/** Default probability boundary between a confident and an uncertain answer. */
|
|
41999
|
+
const DEFAULT_DECIDE_THRESHOLD = .7;
|
|
42000
|
+
const messageOf$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
42001
|
+
const positive$1 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
42002
|
+
const checkThreshold$1 = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
|
|
42003
|
+
const decideFile$1 = String$1("file").pipe(withDescription("JSON batch: an array, { inputs: [...] }, or JSONL (default: stdin)"), optional);
|
|
42004
|
+
const decideThresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_DECIDE_THRESHOLD));
|
|
42005
|
+
const modelFlag$1 = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
|
|
42006
|
+
const concurrencyFlag$1 = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
|
|
42007
|
+
const cacheFlag = String$2("cache").pipe(withDescription$2("Reuse model answers by input hash; directory for cache files"), optional$1);
|
|
42008
|
+
const reviewOutFlag = String$2("review-out").pipe(withDescription$2("Write only below-threshold records to this JSON file"), optional$1);
|
|
42009
|
+
const budgetFlag$1 = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
|
|
42010
|
+
/** Read the batch text from a file argument or stdin; a TTY with no file is an error. */
|
|
42011
|
+
const readInputsText = (file) => try_({
|
|
42012
|
+
try: () => {
|
|
42013
|
+
if (file !== void 0) return readFileSync(file, "utf8");
|
|
42014
|
+
if (process.stdin.isTTY) throw new Error("no input file given and stdin is a TTY");
|
|
42015
|
+
return readFileSync(0, "utf8");
|
|
42016
|
+
},
|
|
42017
|
+
catch: (cause) => new SeoCliError({ message: `Could not read inputs: ${messageOf$1(cause)}` })
|
|
42018
|
+
});
|
|
42019
|
+
/** Parse the batch text, then schema-decode every input against the family. */
|
|
42020
|
+
const parseInputs = (family, text) => try_({
|
|
42021
|
+
try: () => decodeFamilyInputs(family, decodeInputsText(text)),
|
|
42022
|
+
catch: (cause) => new SeoCliError({ message: `Invalid inputs: ${messageOf$1(cause)}` })
|
|
42023
|
+
});
|
|
42024
|
+
/** Apply a family's optional input guard to every decoded input. */
|
|
42025
|
+
const validateInputs = (family, inputs) => gen(function* () {
|
|
42026
|
+
if (family.validate === void 0) return;
|
|
42027
|
+
for (const input of inputs) {
|
|
42028
|
+
const problem = family.validate(input);
|
|
42029
|
+
if (problem !== void 0) return yield* new SeoCliError({ message: problem });
|
|
42030
|
+
}
|
|
42031
|
+
});
|
|
42032
|
+
const requireKey = (family) => process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "" ? fail$3(new SeoCliError({ message: `TYPESAFE_API_KEY is not set; \`pagegraph decide ${family}\` answers through TypeSafe System One.` })) : void_;
|
|
42033
|
+
/** TypeSafe System One decision model over the shared fetch HTTP client. */
|
|
42034
|
+
const typeSafeDecisionModel$1 = (model$2) => model(model$2).pipe(provide$2(layerConfig()), provide$2(layer$1));
|
|
42035
|
+
const decisionErrorMessage$1 = (error) => error._tag === "AiError" ? new SeoCliError({ message: `Decision request failed (${error.reason._tag}): ${error.message}` }) : new SeoCliError({ message: `TypeSafe configuration failed: ${error.message}` });
|
|
42036
|
+
/** Write the below-threshold records to `file`, creating its parent directory. */
|
|
42037
|
+
const writeReviewRecords = (file, payload) => file === void 0 ? void_ : try_({
|
|
42038
|
+
try: () => {
|
|
42039
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
42040
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
42041
|
+
},
|
|
42042
|
+
catch: (cause) => new SeoCliError({ message: `Could not write review records to ${file}: ${messageOf$1(cause)}` })
|
|
42043
|
+
});
|
|
42044
|
+
/** Run one family over a decoded batch and print its report. */
|
|
42045
|
+
const runFamily = (family, options) => gen(function* () {
|
|
42046
|
+
const threshold = yield* checkThreshold$1(options.threshold);
|
|
42047
|
+
const concurrency = yield* positive$1("concurrency", options.concurrency);
|
|
42048
|
+
const text = yield* readInputsText(getOrUndefined$1(options.file));
|
|
42049
|
+
const inputs = yield* parseInputs(family, text);
|
|
42050
|
+
yield* validateInputs(family, inputs);
|
|
42051
|
+
yield* requireKey(family.name);
|
|
42052
|
+
const report = yield* runDecisions({
|
|
42053
|
+
family,
|
|
42054
|
+
inputs,
|
|
42055
|
+
model: options.model,
|
|
42056
|
+
threshold,
|
|
42057
|
+
concurrency,
|
|
42058
|
+
cacheDir: getOrUndefined$1(options.cache)
|
|
42059
|
+
}).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
|
|
42060
|
+
yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
|
|
42061
|
+
family: report.family,
|
|
42062
|
+
model: report.model,
|
|
42063
|
+
threshold: report.threshold,
|
|
42064
|
+
review: report.review
|
|
42065
|
+
});
|
|
42066
|
+
if (options.json) yield* printJson(report);
|
|
42067
|
+
else yield* printText(renderDecideReport(report));
|
|
42068
|
+
});
|
|
42069
|
+
/** Parse the legacy link-candidate batch (array or `{ inputs }`). */
|
|
42070
|
+
const parseLinks = (text) => try_({
|
|
42071
|
+
try: () => decodeLinkCandidates(decodeInputsText(text)),
|
|
42072
|
+
catch: (cause) => new SeoCliError({ message: `Invalid candidates: ${messageOf$1(cause)}` })
|
|
42073
|
+
});
|
|
42074
|
+
/** `decide links` — the links family alias over the legacy `links decide` report. */
|
|
42075
|
+
const runLinks = (options) => gen(function* () {
|
|
42076
|
+
const threshold = yield* checkThreshold$1(options.threshold);
|
|
42077
|
+
const concurrency = yield* positive$1("concurrency", options.concurrency);
|
|
42078
|
+
const budget = yield* positive$1("budget", options.budget);
|
|
42079
|
+
const text = yield* readInputsText(getOrUndefined$1(options.file));
|
|
42080
|
+
const candidates = yield* parseLinks(text);
|
|
42081
|
+
yield* requireKey("links");
|
|
42082
|
+
const report = yield* decideLinks(candidates, {
|
|
42083
|
+
model: options.model,
|
|
42084
|
+
threshold,
|
|
42085
|
+
concurrency,
|
|
42086
|
+
cacheDir: getOrUndefined$1(options.cache),
|
|
42087
|
+
budget
|
|
42088
|
+
}).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
|
|
42089
|
+
yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
|
|
42090
|
+
family: "links",
|
|
42091
|
+
model: report.model,
|
|
42092
|
+
threshold: report.threshold,
|
|
42093
|
+
review: report.review
|
|
42094
|
+
});
|
|
42095
|
+
if (options.json) yield* printJson(report);
|
|
42096
|
+
else yield* printText(renderLinksDecideReport(report));
|
|
42097
|
+
});
|
|
42098
|
+
const decideCommand = (config) => make$9(config.name, {
|
|
42099
|
+
file: decideFile$1,
|
|
42100
|
+
json: jsonFlag,
|
|
42101
|
+
threshold: decideThresholdFlag,
|
|
42102
|
+
model: modelFlag$1,
|
|
42103
|
+
concurrency: concurrencyFlag$1,
|
|
42104
|
+
cache: cacheFlag,
|
|
42105
|
+
reviewOut: reviewOutFlag
|
|
42106
|
+
}).pipe(withDescription$1(config.description), withExamples(config.examples), withHandler((options) => config.run(options)));
|
|
42107
|
+
/** Build a family subcommand from its family and help text. */
|
|
42108
|
+
const familySubcommand = (family, config) => decideCommand({
|
|
42109
|
+
name: family.name,
|
|
42110
|
+
description: config.description,
|
|
42111
|
+
examples: config.examples,
|
|
42112
|
+
run: (options) => runFamily(family, options)
|
|
42113
|
+
});
|
|
42114
|
+
const serpCommand = familySubcommand(serpFamily, {
|
|
42115
|
+
description: "Classify the SERP format and score our page's fit, intent, and title (aligned | mismatch | review)",
|
|
42116
|
+
examples: [{
|
|
42117
|
+
command: "pagegraph decide serp serp.json",
|
|
42118
|
+
description: "Decide a saved SERP snapshot and print the reviewable plan"
|
|
42119
|
+
}, {
|
|
42120
|
+
command: "cat serp.json | pagegraph decide serp --json | jq",
|
|
42121
|
+
description: "Read the SERP batch from stdin and emit versioned JSON"
|
|
42122
|
+
}]
|
|
42123
|
+
});
|
|
42124
|
+
const contentCommand = familySubcommand(contentFamily, {
|
|
42125
|
+
description: "Score a page's content rubric and place its value on thin | adequate | strong (pass | flag | review)",
|
|
42126
|
+
examples: [{
|
|
42127
|
+
command: "pagegraph decide content page.json",
|
|
42128
|
+
description: "Decide a page's content quality and print the reviewable plan"
|
|
42129
|
+
}, {
|
|
42130
|
+
command: "cat page.json | pagegraph decide content --json | jq",
|
|
42131
|
+
description: "Read the content batch from stdin and emit versioned JSON"
|
|
42132
|
+
}]
|
|
42133
|
+
});
|
|
42134
|
+
const fitCommand = familySubcommand(fitFamily, {
|
|
42135
|
+
description: "Choose the best existing page for a query, or flag cannibalization or a gap (map | cannibalized | gap | review)",
|
|
42136
|
+
examples: [{
|
|
42137
|
+
command: "pagegraph decide fit candidates.json",
|
|
42138
|
+
description: "Rank existing pages for a query and print the reviewable plan"
|
|
42139
|
+
}, {
|
|
42140
|
+
command: "cat candidates.json | pagegraph decide fit --json | jq",
|
|
42141
|
+
description: "Read the fit batch from stdin and emit versioned JSON"
|
|
42142
|
+
}]
|
|
42143
|
+
});
|
|
42144
|
+
const metaCommand = familySubcommand(metaFamily, {
|
|
42145
|
+
description: "Rank supplied title/description candidates for a page (choose:<id> | review)",
|
|
42146
|
+
examples: [{
|
|
42147
|
+
command: "pagegraph decide meta meta.json",
|
|
42148
|
+
description: "Rank metadata candidates and print the chosen one or a review"
|
|
42149
|
+
}, {
|
|
42150
|
+
command: "cat meta.json | pagegraph decide meta --json | jq",
|
|
42151
|
+
description: "Read the meta batch from stdin and emit versioned JSON"
|
|
42152
|
+
}]
|
|
42153
|
+
});
|
|
42154
|
+
const authorityCommand = familySubcommand(authorityFamily, {
|
|
42155
|
+
description: "Judge a link target's legitimacy, spam, and outreach worth, plus its fit (accept | spam | review)",
|
|
42156
|
+
examples: [{
|
|
42157
|
+
command: "pagegraph decide authority targets.json",
|
|
42158
|
+
description: "Judge a batch of link targets and print the reviewable plan"
|
|
42159
|
+
}, {
|
|
42160
|
+
command: "cat targets.json | pagegraph decide authority --json | jq",
|
|
42161
|
+
description: "Read the authority batch from stdin and emit versioned JSON"
|
|
42162
|
+
}]
|
|
42163
|
+
});
|
|
42164
|
+
const linksDecideCommand$1 = make$9("links", {
|
|
42165
|
+
file: decideFile$1,
|
|
42166
|
+
json: jsonFlag,
|
|
42167
|
+
threshold: decideThresholdFlag,
|
|
42168
|
+
model: modelFlag$1,
|
|
42169
|
+
concurrency: concurrencyFlag$1,
|
|
42170
|
+
cache: cacheFlag,
|
|
42171
|
+
reviewOut: reviewOutFlag,
|
|
42172
|
+
budget: budgetFlag$1
|
|
42173
|
+
}).pipe(withDescription$1("Answer the links family: real reason, anchor present, direction, and relevance per candidate"), withExamples([
|
|
42174
|
+
{
|
|
42175
|
+
command: "pagegraph decide links candidates.json",
|
|
42176
|
+
description: "Answer a candidate batch and print the reviewable plan"
|
|
42177
|
+
},
|
|
42178
|
+
{
|
|
42179
|
+
command: "pagegraph decide links candidates.json --budget 2",
|
|
42180
|
+
description: "Keep the top two candidates per source by relevance"
|
|
42181
|
+
},
|
|
42182
|
+
{
|
|
42183
|
+
command: "cat candidates.json | pagegraph decide links --json | jq",
|
|
42184
|
+
description: "Read candidates from stdin and emit versioned JSON"
|
|
42185
|
+
}
|
|
42186
|
+
]), withHandler((options) => runLinks(options)));
|
|
42187
|
+
/**
|
|
42188
|
+
* `pagegraph decide` groups the decision families. Each family answers one batch
|
|
42189
|
+
* of inputs through TypeSafe System One; confident verdicts resolve, answers in
|
|
42190
|
+
* the confidence band go to review, and nothing is applied automatically.
|
|
42191
|
+
*/
|
|
42192
|
+
const decideCommandGroup = make$9("decide").pipe(withDescription$1("Answer calibrated SEO decisions over a batch with Jev"), withExamples([{
|
|
42193
|
+
command: "pagegraph decide links candidates.json --json | jq",
|
|
42194
|
+
description: "Decide a link-candidate batch and emit versioned JSON"
|
|
42195
|
+
}]), withSubcommands([
|
|
42196
|
+
serpCommand,
|
|
42197
|
+
contentCommand,
|
|
42198
|
+
fitCommand,
|
|
42199
|
+
metaCommand,
|
|
42200
|
+
authorityCommand,
|
|
42201
|
+
linksDecideCommand$1
|
|
42202
|
+
]));
|
|
42203
|
+
//#endregion
|
|
42204
|
+
//#region src/cli/commands/diff.ts
|
|
42205
|
+
const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
|
|
42206
|
+
const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
|
|
42207
|
+
const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
|
|
42208
|
+
const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
|
|
42209
|
+
return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
|
|
42210
|
+
});
|
|
42211
|
+
const diffCommand = make$9("diff", {
|
|
42212
|
+
before,
|
|
42213
|
+
after,
|
|
42214
|
+
json: jsonFlag
|
|
42215
|
+
}).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
|
|
42216
|
+
command: "pagegraph diff before.json after.json",
|
|
42217
|
+
description: "Render a human-readable semantic comparison"
|
|
42218
|
+
}, {
|
|
42219
|
+
command: "pagegraph diff before.json after.json --json",
|
|
42220
|
+
description: "Emit one versioned JSON diff on stdout"
|
|
42221
|
+
}]), withHandler(fn("SeoCli.diff")(function* (options) {
|
|
42222
|
+
const beforeReport = yield* readAuditReport("before", options.before);
|
|
42223
|
+
const afterReport = yield* readAuditReport("after", options.after);
|
|
42224
|
+
const comparison = compareAuditReports(beforeReport, afterReport);
|
|
42225
|
+
if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
|
|
42226
|
+
if (options.json) yield* printJson(comparison.diff);
|
|
42227
|
+
else yield* printText(renderAuditDiff(comparison.diff));
|
|
42228
|
+
if (comparison.diff.outcome === "regressed") {
|
|
42229
|
+
const summary = comparison.diff.summary;
|
|
42230
|
+
return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
|
|
42231
|
+
}
|
|
42232
|
+
})));
|
|
42233
|
+
//#endregion
|
|
42234
|
+
//#region src/cli/serialize.ts
|
|
42235
|
+
const serializeCrumb = (crumb) => {
|
|
42236
|
+
if (crumb === void 0) return void 0;
|
|
42237
|
+
return typeof crumb === "function" ? "(dynamic)" : crumb;
|
|
42238
|
+
};
|
|
42239
|
+
/** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
|
|
42240
|
+
const serializeNode = (node) => ({
|
|
42241
|
+
path: node.path,
|
|
42242
|
+
kind: node.kind,
|
|
42243
|
+
source: node.source,
|
|
42244
|
+
policy: {
|
|
42245
|
+
kind: node.policy.kind,
|
|
42246
|
+
crumb: serializeCrumb(node.policy.crumb),
|
|
42247
|
+
sitemap: node.policy.sitemap,
|
|
42248
|
+
robots: node.policy.robots,
|
|
42249
|
+
related: node.policy.related,
|
|
42250
|
+
link: node.policy.link,
|
|
42251
|
+
redirectTo: node.policy.redirectTo
|
|
42252
|
+
},
|
|
42253
|
+
instance: node.instance
|
|
42254
|
+
});
|
|
42255
|
+
/** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
|
|
42256
|
+
const serializeGraph = (graph) => ({
|
|
42257
|
+
nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
|
|
42258
|
+
edges: graph.edges
|
|
42259
|
+
});
|
|
42260
|
+
const graphCommand = make$9("graph", {
|
|
42261
|
+
format: Literals("format", [
|
|
42262
|
+
"tree",
|
|
42263
|
+
"mermaid",
|
|
42264
|
+
"json"
|
|
42265
|
+
]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
|
|
42266
|
+
orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
|
|
42267
|
+
}).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
|
|
42268
|
+
{
|
|
42269
|
+
command: "pagegraph graph",
|
|
42270
|
+
description: "The graph as an indented path tree"
|
|
42271
|
+
},
|
|
42272
|
+
{
|
|
42273
|
+
command: "pagegraph graph --format mermaid",
|
|
42274
|
+
description: "A Mermaid diagram of nodes and edges"
|
|
42275
|
+
},
|
|
42276
|
+
{
|
|
42277
|
+
command: "pagegraph graph --orphans",
|
|
42278
|
+
description: "Only nodes with no incoming edge"
|
|
42279
|
+
},
|
|
42280
|
+
{
|
|
42281
|
+
command: "pagegraph graph --format json",
|
|
42282
|
+
description: "The serialized graph on stdout"
|
|
42283
|
+
}
|
|
42284
|
+
]), withHandler(fnUntraced(function* ({ format, orphans }) {
|
|
42285
|
+
const config = yield* loadSeoConfig;
|
|
42286
|
+
const graph = yield* scoped(acquireGraph(config));
|
|
42287
|
+
if (format === "json") {
|
|
42288
|
+
const serialized = serializeGraph(graph);
|
|
42289
|
+
if (!orphans) return yield* printJson(serialized);
|
|
42290
|
+
const orphanSet = orphanPaths(graph);
|
|
42291
|
+
return yield* printJson({
|
|
42292
|
+
nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
|
|
42293
|
+
edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
|
|
42294
|
+
});
|
|
42295
|
+
}
|
|
42296
|
+
if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
|
|
42297
|
+
return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
|
|
42298
|
+
})));
|
|
42299
|
+
//#endregion
|
|
42300
|
+
//#region src/core/inspect-html.ts
|
|
42301
|
+
const ENTITIES = {
|
|
42302
|
+
"&": "&",
|
|
42303
|
+
"<": "<",
|
|
42304
|
+
">": ">",
|
|
42305
|
+
""": "\"",
|
|
42306
|
+
"'": "'",
|
|
42307
|
+
"'": "'"
|
|
42308
|
+
};
|
|
42309
|
+
const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
|
|
42310
|
+
/** Pull double/single-quoted attributes off a single tag string. */
|
|
42311
|
+
const parseAttrs = (tag) => {
|
|
42312
|
+
const attrs = {};
|
|
42313
|
+
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
42314
|
+
let match;
|
|
42315
|
+
while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
|
|
42316
|
+
return attrs;
|
|
42317
|
+
};
|
|
42318
|
+
/** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
|
|
42319
|
+
const typeName = (value) => {
|
|
42320
|
+
if (typeof value === "string") return value;
|
|
42321
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
|
|
42322
|
+
return "unknown";
|
|
42323
|
+
};
|
|
42324
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42325
|
+
/** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
|
|
42326
|
+
const collectItems = (parsed) => {
|
|
42327
|
+
if (Array.isArray(parsed)) return parsed.filter(isObject);
|
|
42328
|
+
if (isObject(parsed)) {
|
|
42329
|
+
if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
|
|
42330
|
+
return [parsed];
|
|
42331
|
+
}
|
|
42332
|
+
return [];
|
|
42333
|
+
};
|
|
42334
|
+
const countQuestions = (mainEntity) => {
|
|
42335
|
+
if (!Array.isArray(mainEntity)) return 0;
|
|
42336
|
+
return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
|
|
42337
|
+
};
|
|
42338
|
+
const validateItemListElements = (value) => {
|
|
42339
|
+
if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
|
|
42340
|
+
const errors = [];
|
|
42341
|
+
value.forEach((entry, index) => {
|
|
42342
|
+
if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
|
|
42343
|
+
errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
|
|
42344
|
+
return;
|
|
42345
|
+
}
|
|
42346
|
+
if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
|
|
42347
|
+
if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
|
|
42348
|
+
});
|
|
42349
|
+
return errors;
|
|
42350
|
+
};
|
|
42351
|
+
/** Minimal per-type validation — enough to catch an empty or malformed block. */
|
|
42352
|
+
const validateItem = (item) => {
|
|
42353
|
+
const type = typeName(item["@type"]);
|
|
42354
|
+
const errors = [];
|
|
42355
|
+
if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
|
|
42356
|
+
if (!item["headline"]) errors.push("Article is missing `headline`.");
|
|
42357
|
+
if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
|
|
42358
|
+
} else if (type === "FAQPage") {
|
|
42359
|
+
if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
|
|
42360
|
+
} else if (type === "BreadcrumbList") {
|
|
42361
|
+
const items = item["itemListElement"];
|
|
42362
|
+
if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
|
|
42363
|
+
} else if (type === "ItemList") {
|
|
42364
|
+
errors.push(...validateItemListElements(item["itemListElement"]));
|
|
42365
|
+
const items = item["itemListElement"];
|
|
42366
|
+
if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
|
|
42367
|
+
}
|
|
42368
|
+
return {
|
|
42369
|
+
type,
|
|
42370
|
+
valid: errors.length === 0,
|
|
42371
|
+
errors
|
|
42372
|
+
};
|
|
42373
|
+
};
|
|
42374
|
+
const validateLdJson = (raw) => {
|
|
42375
|
+
let parsed;
|
|
42376
|
+
try {
|
|
42377
|
+
parsed = JSON.parse(raw);
|
|
42378
|
+
} catch (cause) {
|
|
42379
|
+
return [{
|
|
42380
|
+
type: "unparseable",
|
|
42381
|
+
valid: false,
|
|
42382
|
+
errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
|
|
42383
|
+
}];
|
|
42384
|
+
}
|
|
42385
|
+
const items = collectItems(parsed);
|
|
42386
|
+
if (items.length === 0) return [{
|
|
42387
|
+
type: "unknown",
|
|
42388
|
+
valid: false,
|
|
42389
|
+
errors: ["No JSON-LD object found in block."]
|
|
42390
|
+
}];
|
|
42391
|
+
return items.map(validateItem);
|
|
42392
|
+
};
|
|
42393
|
+
/** Parse a rendered HTML document's `<head>` into a report. Pure. */
|
|
42394
|
+
const inspectHtml = (url, status, html) => {
|
|
42395
|
+
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
42396
|
+
const head = headMatch ? headMatch[1] : html;
|
|
42397
|
+
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
42398
|
+
const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
|
|
42399
|
+
const og = {};
|
|
42400
|
+
const twitter = {};
|
|
42401
|
+
let description;
|
|
42402
|
+
let robots;
|
|
42403
|
+
for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
42404
|
+
const attrs = parseAttrs(tag);
|
|
42405
|
+
const content = attrs["content"];
|
|
42406
|
+
if (content === void 0) continue;
|
|
42407
|
+
const property = attrs["property"];
|
|
42408
|
+
const name = attrs["name"];
|
|
42409
|
+
if (property?.startsWith("og:")) og[property] = content;
|
|
42410
|
+
else if (name?.startsWith("twitter:")) twitter[name] = content;
|
|
42411
|
+
else if (name === "description") description = content;
|
|
42412
|
+
else if (name === "robots") robots = content;
|
|
42413
|
+
}
|
|
42414
|
+
let canonical;
|
|
42415
|
+
for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
|
|
42416
|
+
const attrs = parseAttrs(tag);
|
|
42417
|
+
if (attrs["rel"] === "canonical") canonical = attrs["href"];
|
|
42418
|
+
}
|
|
42419
|
+
const jsonLd = [];
|
|
42420
|
+
const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
42421
|
+
let scriptMatch;
|
|
42422
|
+
while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
|
|
42423
|
+
const issues = [];
|
|
42424
|
+
if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
|
|
42425
|
+
if (!title) issues.push("Missing <title>.");
|
|
42426
|
+
if (!description) issues.push("Missing meta description.");
|
|
42427
|
+
if (!canonical) issues.push("Missing canonical link.");
|
|
42428
|
+
for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
|
|
42429
|
+
return {
|
|
42430
|
+
url,
|
|
42431
|
+
status,
|
|
42432
|
+
title,
|
|
42433
|
+
description,
|
|
42434
|
+
canonical,
|
|
42435
|
+
robots,
|
|
42436
|
+
og,
|
|
42437
|
+
twitter,
|
|
42438
|
+
jsonLd,
|
|
42439
|
+
issues
|
|
42440
|
+
};
|
|
42441
|
+
};
|
|
42442
|
+
/** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
|
|
42443
|
+
const hasBlockingIssues = (report) => report.issues.length > 0;
|
|
42444
|
+
//#endregion
|
|
42445
|
+
//#region src/cli/live-inspect.ts
|
|
42446
|
+
/**
|
|
42447
|
+
* `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
|
|
42448
|
+
* validator in `../inspect-html`. The fetch is all that lives here — the
|
|
42449
|
+
* validation is a library capability, not a CLI one, so it stays out of the
|
|
42450
|
+
* Effect-bearing half of the package.
|
|
42451
|
+
*/
|
|
42452
|
+
/** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
|
|
42453
|
+
const fetchAndInspect = (url) => tryPromise({
|
|
42454
|
+
try: async () => {
|
|
42455
|
+
const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
|
|
42456
|
+
const html = await response.text();
|
|
42457
|
+
return inspectHtml(url, response.status, html);
|
|
42458
|
+
},
|
|
42459
|
+
catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
42460
|
+
});
|
|
42461
|
+
const inspectCommand = make$9("inspect", {
|
|
42462
|
+
target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
|
|
42463
|
+
live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
|
|
42464
|
+
json: jsonFlag
|
|
42465
|
+
}).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
|
|
42466
|
+
{
|
|
42467
|
+
command: "pagegraph inspect /pricing",
|
|
42468
|
+
description: "The graph node, policy, and edges for a path"
|
|
42469
|
+
},
|
|
42470
|
+
{
|
|
42471
|
+
command: "pagegraph inspect https://example.com/pricing --live",
|
|
42472
|
+
description: "Fetch the page and validate its head tags + JSON-LD"
|
|
42473
|
+
},
|
|
42474
|
+
{
|
|
42475
|
+
command: "pagegraph inspect /blog/some-post --json",
|
|
42476
|
+
description: "The node report as JSON"
|
|
42477
|
+
}
|
|
42478
|
+
]), withHandler(fnUntraced(function* ({ target, live, json }) {
|
|
42479
|
+
if (live) {
|
|
42480
|
+
const report = yield* fetchAndInspect(target);
|
|
42481
|
+
if (json) yield* printJson(report);
|
|
42482
|
+
else yield* printText(renderLiveReport(report));
|
|
42483
|
+
if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
|
|
42484
|
+
return;
|
|
42485
|
+
}
|
|
42486
|
+
const config = yield* loadSeoConfig;
|
|
42487
|
+
const report = inspectNode(yield* scoped(acquireGraph(config)), target);
|
|
42488
|
+
if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
|
|
42489
|
+
if (json) return yield* printJson({
|
|
42490
|
+
node: serializeNode(report.node),
|
|
42491
|
+
inSitemap: report.inSitemap,
|
|
42492
|
+
incoming: report.incoming,
|
|
42493
|
+
outgoing: report.outgoing
|
|
42494
|
+
});
|
|
42495
|
+
return yield* printText(renderNodeReport(report));
|
|
42496
|
+
})));
|
|
42497
|
+
//#endregion
|
|
41055
42498
|
//#region src/audit/crawl.ts
|
|
41056
42499
|
/** Anchors that are never HTML documents; skipped so they do not consume the budget. */
|
|
41057
42500
|
const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
|
|
@@ -41325,316 +42768,237 @@ const decodeRenderedEdges = (input) => {
|
|
|
41325
42768
|
});
|
|
41326
42769
|
};
|
|
41327
42770
|
//#endregion
|
|
41328
|
-
//#region node_modules/effect/dist/unstable/ai/Decision.js
|
|
41329
|
-
/**
|
|
41330
|
-
* Brand for decision definitions.
|
|
41331
|
-
*
|
|
41332
|
-
* @category type IDs
|
|
41333
|
-
* @since 4.0.0
|
|
41334
|
-
*/
|
|
41335
|
-
const TypeId = "~effect/ai/Decision";
|
|
41336
|
-
/**
|
|
41337
|
-
* Creates a probability decision from descriptions of both outcomes.
|
|
41338
|
-
*
|
|
41339
|
-
* **Example** (Estimating urgency)
|
|
41340
|
-
*
|
|
41341
|
-
* ```ts
|
|
41342
|
-
* import { Decision } from "effect/unstable/ai"
|
|
41343
|
-
*
|
|
41344
|
-
* const urgent = Decision.probability({
|
|
41345
|
-
* instructions: "The message is time-sensitive",
|
|
41346
|
-
* criteria: {
|
|
41347
|
-
* false: "No time pressure",
|
|
41348
|
-
* true: "Needs action now"
|
|
41349
|
-
* }
|
|
41350
|
-
* })
|
|
41351
|
-
* ```
|
|
41352
|
-
*
|
|
41353
|
-
* @see {@link classify} for more than two outcomes
|
|
41354
|
-
*
|
|
41355
|
-
* @category constructors
|
|
41356
|
-
* @since 4.0.0
|
|
41357
|
-
*/
|
|
41358
|
-
const probability = (options) => ({
|
|
41359
|
-
_tag: "Probability",
|
|
41360
|
-
instructions: options.instructions,
|
|
41361
|
-
criteria: options.criteria
|
|
41362
|
-
});
|
|
41363
|
-
/**
|
|
41364
|
-
* Creates a decision definition from an input schema and named decisions.
|
|
41365
|
-
* `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
|
|
41366
|
-
* calling the provider. Answer keys and types are inferred from the decisions.
|
|
41367
|
-
* Throws if `decisions` is empty.
|
|
41368
|
-
*
|
|
41369
|
-
* **Example** (Defining ticket triage)
|
|
41370
|
-
*
|
|
41371
|
-
* ```ts
|
|
41372
|
-
* import { Schema } from "effect"
|
|
41373
|
-
* import { Decision } from "effect/unstable/ai"
|
|
41374
|
-
*
|
|
41375
|
-
* const Ticket = Schema.Struct({
|
|
41376
|
-
* subject: Schema.String,
|
|
41377
|
-
* body: Schema.String
|
|
41378
|
-
* })
|
|
41379
|
-
*
|
|
41380
|
-
* const TicketTriage = Decision.make({
|
|
41381
|
-
* input: Ticket,
|
|
41382
|
-
* decisions: {
|
|
41383
|
-
* department: Decision.classify({
|
|
41384
|
-
* instructions: "Which team should handle this",
|
|
41385
|
-
* criteria: { billing: "payments", technical: "bugs" }
|
|
41386
|
-
* }),
|
|
41387
|
-
* urgent: Decision.probability({
|
|
41388
|
-
* instructions: "The message is time-sensitive",
|
|
41389
|
-
* criteria: { false: "No time pressure", true: "Needs action now" }
|
|
41390
|
-
* })
|
|
41391
|
-
* }
|
|
41392
|
-
* })
|
|
41393
|
-
* ```
|
|
41394
|
-
*
|
|
41395
|
-
* @see {@link Definition} for the returned shape
|
|
41396
|
-
*
|
|
41397
|
-
* @category constructors
|
|
41398
|
-
* @since 4.0.0
|
|
41399
|
-
*/
|
|
41400
|
-
const make = (options) => {
|
|
41401
|
-
if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
|
|
41402
|
-
return {
|
|
41403
|
-
[TypeId]: TypeId,
|
|
41404
|
-
input: options.input,
|
|
41405
|
-
decisions: options.decisions
|
|
41406
|
-
};
|
|
41407
|
-
};
|
|
41408
|
-
//#endregion
|
|
41409
|
-
//#region src/links/decide.ts
|
|
41410
|
-
/**
|
|
41411
|
-
* Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
|
|
41412
|
-
*
|
|
41413
|
-
* A candidate is a `(source → destination)` pair with the source copy. The model
|
|
41414
|
-
* answers two probabilities — does the source have a genuine reader-facing
|
|
41415
|
-
* reason to link to the destination, and is descriptive anchor text already in
|
|
41416
|
-
* the copy — and this module maps them to a verdict. Anything the model is not
|
|
41417
|
-
* confident about lands in the `review` bucket; nothing is applied automatically.
|
|
41418
|
-
*
|
|
41419
|
-
* This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
|
|
41420
|
-
* the library entries stay Effect-free and only the bundled `pagegraph` bin
|
|
41421
|
-
* ships it.
|
|
41422
|
-
*/
|
|
41423
|
-
/** Default probability boundary between a confident and an uncertain answer. */
|
|
41424
|
-
const DEFAULT_LINK_THRESHOLD = .7;
|
|
41425
|
-
/** One proposed contextual link, plus the source copy the model reads. */
|
|
41426
|
-
const LinkCandidate = Struct({
|
|
41427
|
-
sourceUrl: String$6,
|
|
41428
|
-
destinationUrl: String$6,
|
|
41429
|
-
sourceText: String$6,
|
|
41430
|
-
/** Descriptive anchor text already present for the destination, when known. */
|
|
41431
|
-
existingAnchor: optional$3(String$6)
|
|
41432
|
-
});
|
|
41433
|
-
/**
|
|
41434
|
-
* The two decisions every candidate answers. Both are probabilities, so the
|
|
41435
|
-
* model returns a calibrated number rather than generated prose.
|
|
41436
|
-
*/
|
|
41437
|
-
const LinkDecision = make({
|
|
41438
|
-
input: LinkCandidate,
|
|
41439
|
-
decisions: {
|
|
41440
|
-
realReason: probability({
|
|
41441
|
-
instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
|
|
41442
|
-
criteria: {
|
|
41443
|
-
false: "No genuine reason; the link would be noise or only keyword overlap.",
|
|
41444
|
-
true: "A reader of the source would plausibly want the destination next."
|
|
41445
|
-
}
|
|
41446
|
-
}),
|
|
41447
|
-
anchorPresent: probability({
|
|
41448
|
-
instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
|
|
41449
|
-
criteria: {
|
|
41450
|
-
false: "No existing anchor to the destination in the copy.",
|
|
41451
|
-
true: "Descriptive anchor text to the destination is already in the copy."
|
|
41452
|
-
}
|
|
41453
|
-
})
|
|
41454
|
-
}
|
|
41455
|
-
});
|
|
41456
|
-
/** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
|
|
41457
|
-
const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
|
|
41458
|
-
/**
|
|
41459
|
-
* Map the two probabilities to a verdict. A probability inside the review band
|
|
41460
|
-
* makes the whole candidate uncertain, so the confident half of the pair can
|
|
41461
|
-
* never auto-resolve it. Requires `threshold > 0.5`, so the review band
|
|
41462
|
-
* `(1 - threshold, threshold)` is non-empty.
|
|
41463
|
-
*
|
|
41464
|
-
* - `present` — descriptive anchor already in the copy; nothing to add.
|
|
41465
|
-
* - `recommend` — a real reason and no existing anchor; add a contextual link.
|
|
41466
|
-
* - `skip` — no real reason; leave the copy alone.
|
|
41467
|
-
* - `review` — at least one answer is inside the band; a human decides.
|
|
41468
|
-
*/
|
|
41469
|
-
const classifyVerdict = (realReason, anchorPresent, threshold) => {
|
|
41470
|
-
if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
|
|
41471
|
-
if (anchorPresent >= threshold) return "present";
|
|
41472
|
-
if (realReason >= threshold) return "recommend";
|
|
41473
|
-
return "skip";
|
|
41474
|
-
};
|
|
41475
|
-
/** Group records into resolved/review buckets and count each verdict. */
|
|
41476
|
-
const buildLinksDecideReport = (options) => {
|
|
41477
|
-
const counts = {
|
|
41478
|
-
recommend: 0,
|
|
41479
|
-
present: 0,
|
|
41480
|
-
skip: 0,
|
|
41481
|
-
review: 0
|
|
41482
|
-
};
|
|
41483
|
-
const resolved = [];
|
|
41484
|
-
const review = [];
|
|
41485
|
-
for (const record of options.records) {
|
|
41486
|
-
counts[record.verdict] += 1;
|
|
41487
|
-
if (record.verdict === "review") review.push(record);
|
|
41488
|
-
else resolved.push(record);
|
|
41489
|
-
}
|
|
41490
|
-
return {
|
|
41491
|
-
kind: "links-decide",
|
|
41492
|
-
schemaVersion: 1,
|
|
41493
|
-
model: options.model,
|
|
41494
|
-
threshold: options.threshold,
|
|
41495
|
-
counts: {
|
|
41496
|
-
candidates: options.records.length,
|
|
41497
|
-
...counts
|
|
41498
|
-
},
|
|
41499
|
-
resolved,
|
|
41500
|
-
review
|
|
41501
|
-
};
|
|
41502
|
-
};
|
|
41503
|
-
/**
|
|
41504
|
-
* Answer every candidate through the ambient `DecisionModel` and bucket the
|
|
41505
|
-
* results. The model answers both decisions in one call per candidate; candidates
|
|
41506
|
-
* are dispatched with bounded concurrency.
|
|
41507
|
-
*/
|
|
41508
|
-
const decideLinks = (candidates, options) => gen(function* () {
|
|
41509
|
-
const records = yield* forEach$1(candidates, (candidate) => decide(LinkDecision, { input: candidate }).pipe(map$5(({ answers }) => {
|
|
41510
|
-
const realReason = answers.realReason.probability;
|
|
41511
|
-
const anchorPresent = answers.anchorPresent.probability;
|
|
41512
|
-
return {
|
|
41513
|
-
sourceUrl: candidate.sourceUrl,
|
|
41514
|
-
destinationUrl: candidate.destinationUrl,
|
|
41515
|
-
sourceText: candidate.sourceText,
|
|
41516
|
-
existingAnchor: candidate.existingAnchor ?? null,
|
|
41517
|
-
realReason,
|
|
41518
|
-
anchorPresent,
|
|
41519
|
-
verdict: classifyVerdict(realReason, anchorPresent, options.threshold)
|
|
41520
|
-
};
|
|
41521
|
-
})), { concurrency: options.concurrency ?? 4 });
|
|
41522
|
-
return buildLinksDecideReport({
|
|
41523
|
-
model: options.model,
|
|
41524
|
-
threshold: options.threshold,
|
|
41525
|
-
records
|
|
41526
|
-
});
|
|
41527
|
-
});
|
|
41528
|
-
/** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
|
|
41529
|
-
const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
|
|
41530
|
-
//#endregion
|
|
41531
42771
|
//#region src/cli/commands/links.ts
|
|
41532
42772
|
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
41533
42773
|
const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
41534
|
-
const urlArg = String$1("url").pipe(withDescription("Absolute http(s) URL of the site homepage to crawl"));
|
|
42774
|
+
const urlArg = String$1("url").pipe(withDescription("Absolute http(s) URL of the site homepage to crawl (omit with --rendered)"), optional);
|
|
41535
42775
|
const limitFlag = Int("limit").pipe(withDescription$2("Maximum pages to fetch, including the homepage"), withDefault(100));
|
|
41536
42776
|
const allowPrivate = Boolean$1("allow-private").pipe(withDescription$2("Allow localhost and private addresses (local development only)"), withDefault(false));
|
|
41537
42777
|
const requestTimeoutMs = Int("request-timeout-ms").pipe(withDescription$2("HTTP request timeout in milliseconds"), withDefault(15e3));
|
|
41538
42778
|
const maxBodyBytes = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes per page"), withDefault(2e6));
|
|
41539
42779
|
/**
|
|
41540
|
-
*
|
|
41541
|
-
*
|
|
41542
|
-
*
|
|
41543
|
-
*
|
|
41544
|
-
*
|
|
42780
|
+
* Load the app's declared graph when a `seo.config.ts` exists and derive the two
|
|
42781
|
+
* things `verify` needs from it: the declared `related` edges for the diff, and —
|
|
42782
|
+
* under `--assert-coverage` — the rendered coverage assertion. Both are computed
|
|
42783
|
+
* inside one scoped graph acquisition so the Vite loader is acquired once.
|
|
42784
|
+
*
|
|
42785
|
+
* Without `--assert-coverage` the declared graph is optional enrichment: a
|
|
42786
|
+
* missing or broken config, an invalid origin, or an origin mismatch each become a
|
|
42787
|
+
* warning, because verifying served HTML works on any deployed site. Under
|
|
42788
|
+
* `--assert-coverage` every one of those is a hard failure — an assertion must
|
|
42789
|
+
* know which pages the rules target and that this crawl is the same site, and a
|
|
42790
|
+
* truncated crawl is refused before the graph is even loaded.
|
|
41545
42791
|
*/
|
|
41546
|
-
const
|
|
41547
|
-
|
|
41548
|
-
|
|
42792
|
+
const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState) => gen(function* () {
|
|
42793
|
+
if (assertCoverage) {
|
|
42794
|
+
if (crawlState.truncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage on a truncated crawl; re-run with a higher --limit." });
|
|
42795
|
+
if (crawlState.failures > 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: ${crawlState.failures} page(s) failed to fetch, so their anchors are missing.` });
|
|
42796
|
+
if (crawlState.bodyTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage when a crawled body was truncated; raise --max-body-bytes." });
|
|
42797
|
+
}
|
|
42798
|
+
const warnings = [];
|
|
42799
|
+
const config = yield* loadSeoConfigOptional.pipe(catchTag("SeoCliError", (error) => assertCoverage ? fail$3(error) : sync(() => {
|
|
42800
|
+
warnings.push(`Ignoring declared graph: ${error.message}`);
|
|
42801
|
+
})));
|
|
42802
|
+
if (config === void 0) {
|
|
42803
|
+
if (assertCoverage) return yield* new SeoCliError({ message: "Coverage assertion needs a seo.config.ts declaring `coverage` rules." });
|
|
42804
|
+
return {
|
|
42805
|
+
declaredEdges: void 0,
|
|
42806
|
+
coverage: void 0,
|
|
42807
|
+
warnings
|
|
42808
|
+
};
|
|
42809
|
+
}
|
|
41549
42810
|
let configOrigin;
|
|
41550
42811
|
try {
|
|
41551
42812
|
configOrigin = new URL(config.origin).origin;
|
|
41552
42813
|
} catch {
|
|
42814
|
+
const message = `Ignoring declared graph: seo.config.ts origin "${config.origin}" is not a valid URL.`;
|
|
42815
|
+
if (assertCoverage) return yield* new SeoCliError({ message });
|
|
42816
|
+
warnings.push(message);
|
|
41553
42817
|
return {
|
|
41554
|
-
|
|
41555
|
-
|
|
42818
|
+
declaredEdges: void 0,
|
|
42819
|
+
coverage: void 0,
|
|
42820
|
+
warnings
|
|
41556
42821
|
};
|
|
41557
42822
|
}
|
|
41558
|
-
const
|
|
41559
|
-
return {
|
|
41560
|
-
|
|
41561
|
-
graph
|
|
41562
|
-
|
|
41563
|
-
|
|
42823
|
+
const rules = config.coverage ?? [];
|
|
42824
|
+
if (assertCoverage && rules.length === 0) return yield* new SeoCliError({ message: "seo.config.ts declares no `coverage` rules; nothing to assert." });
|
|
42825
|
+
if (configOrigin !== crawlOrigin) {
|
|
42826
|
+
const mismatch = `declared graph origin ${configOrigin} differs from crawled origin ${crawlOrigin}`;
|
|
42827
|
+
if (assertCoverage) return yield* new SeoCliError({ message: `Cannot assert coverage: ${mismatch}.` });
|
|
42828
|
+
warnings.push(`${mismatch.charAt(0).toUpperCase()}${mismatch.slice(1)}; skipping the declared-vs-rendered diff.`);
|
|
42829
|
+
return {
|
|
42830
|
+
declaredEdges: void 0,
|
|
42831
|
+
coverage: void 0,
|
|
42832
|
+
warnings
|
|
42833
|
+
};
|
|
42834
|
+
}
|
|
42835
|
+
return yield* scoped(gen(function* () {
|
|
42836
|
+
const graph = yield* acquireGraph(config);
|
|
42837
|
+
return {
|
|
42838
|
+
declaredEdges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
|
|
41564
42839
|
from: edge.from,
|
|
41565
42840
|
to: edge.to
|
|
41566
|
-
}))
|
|
41567
|
-
|
|
41568
|
-
|
|
41569
|
-
|
|
41570
|
-
|
|
41571
|
-
|
|
41572
|
-
}
|
|
42841
|
+
})),
|
|
42842
|
+
coverage: assertCoverage ? {
|
|
42843
|
+
rules: rules.length,
|
|
42844
|
+
violations: checkRenderedCoverage(graph, renderedEdges, rules)
|
|
42845
|
+
} : void 0,
|
|
42846
|
+
warnings
|
|
42847
|
+
};
|
|
42848
|
+
}));
|
|
42849
|
+
});
|
|
42850
|
+
/** Read and decode a saved rendered-edge artifact; the file is user input. */
|
|
42851
|
+
const readRenderedArtifact = (file) => try_({
|
|
42852
|
+
try: () => decodeRenderedEdgeArtifact(JSON.parse(readFileSync(file, "utf8"))),
|
|
42853
|
+
catch: (cause) => new SeoCliError({ message: `Could not read rendered artifact from ${file}: ${messageOf(cause)}` })
|
|
42854
|
+
});
|
|
42855
|
+
/** Write a rendered-edge artifact, creating its parent directory when needed. */
|
|
42856
|
+
const writeRenderedArtifact = (file, artifact) => try_({
|
|
42857
|
+
try: () => {
|
|
42858
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
42859
|
+
writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
|
|
42860
|
+
},
|
|
42861
|
+
catch: (cause) => new SeoCliError({ message: `Could not write rendered artifact to ${file}: ${messageOf(cause)}` })
|
|
42862
|
+
});
|
|
41573
42863
|
const linksVerifyCommand = make$9("verify", {
|
|
41574
42864
|
url: urlArg,
|
|
41575
42865
|
json: jsonFlag,
|
|
41576
42866
|
limit: limitFlag,
|
|
41577
42867
|
allowPrivate,
|
|
41578
42868
|
requestTimeoutMs,
|
|
41579
|
-
maxBodyBytes
|
|
42869
|
+
maxBodyBytes,
|
|
42870
|
+
emitRendered: String$2("emit-rendered").pipe(withDescription$2("Write the crawled rendered-edge artifact to this path (crawl mode)"), optional$1),
|
|
42871
|
+
rendered: String$2("rendered").pipe(withDescription$2("Replay a saved rendered-edge artifact instead of crawling"), optional$1),
|
|
42872
|
+
assertCoverage: Boolean$1("assert-coverage").pipe(withDescription$2("Assert seo.config.ts coverage rules against the rendered anchors; exit 1 on unmet (refuses truncated crawls)"), withDefault(false))
|
|
41580
42873
|
}).pipe(withDescription$1("Crawl served HTML and report homepage depth, rendered orphans, and the declared-vs-rendered gap"), withExamples([
|
|
41581
42874
|
{
|
|
41582
42875
|
command: "pagegraph links verify https://example.com",
|
|
41583
42876
|
description: "Crawl the homepage and report the rendered link graph"
|
|
41584
42877
|
},
|
|
41585
42878
|
{
|
|
41586
|
-
command: "pagegraph links verify https://example.com --limit 25",
|
|
41587
|
-
description: "Bound the crawl to 25 pages"
|
|
42879
|
+
command: "pagegraph links verify https://example.com --limit 25 --json | jq",
|
|
42880
|
+
description: "Bound the crawl to 25 pages and emit versioned JSON"
|
|
42881
|
+
},
|
|
42882
|
+
{
|
|
42883
|
+
command: "pagegraph links verify https://example.com --emit-rendered rendered.json",
|
|
42884
|
+
description: "Crawl once and save the rendered-edge artifact"
|
|
42885
|
+
},
|
|
42886
|
+
{
|
|
42887
|
+
command: "pagegraph links verify --rendered rendered.json --assert-coverage",
|
|
42888
|
+
description: "Assert seo.config.ts coverage offline against a saved crawl (exit 1 on unmet)"
|
|
41588
42889
|
},
|
|
41589
42890
|
{
|
|
41590
|
-
command: "pagegraph links verify https://example.com --
|
|
41591
|
-
description: "
|
|
42891
|
+
command: "pagegraph links verify https://example.com --assert-coverage",
|
|
42892
|
+
description: "Crawl and assert coverage against the served anchors in one pass"
|
|
41592
42893
|
},
|
|
41593
42894
|
{
|
|
41594
42895
|
command: "pagegraph links verify http://localhost:3000 --allow-private",
|
|
41595
42896
|
description: "Verify a local app"
|
|
41596
42897
|
}
|
|
41597
42898
|
]), withHandler(fn("SeoCli.linksVerify")(function* (options) {
|
|
41598
|
-
const
|
|
41599
|
-
const
|
|
41600
|
-
const
|
|
41601
|
-
|
|
41602
|
-
|
|
41603
|
-
limit: checkedLimit,
|
|
41604
|
-
timeoutMs: checkedTimeout,
|
|
41605
|
-
maxBodyBytes: checkedMaxBody,
|
|
41606
|
-
allowPrivate: options.allowPrivate
|
|
41607
|
-
}),
|
|
41608
|
-
catch: (cause) => new SeoCliError({ message: `Could not crawl ${options.url}: ${messageOf(cause)}` })
|
|
41609
|
-
});
|
|
41610
|
-
if (crawl.pages.length === 0) {
|
|
41611
|
-
const reason = crawl.failures[0]?.error ?? "no pages fetched";
|
|
41612
|
-
return yield* new SeoCliError({ message: `No page could be crawled at ${options.url}: ${reason}` });
|
|
41613
|
-
}
|
|
41614
|
-
const graph = buildRenderedGraph(crawl.origin, crawl.pages, crawl.root);
|
|
42899
|
+
const emitPath = getOrUndefined$1(options.emitRendered);
|
|
42900
|
+
const artifactPath = getOrUndefined$1(options.rendered);
|
|
42901
|
+
const url = getOrUndefined$1(options.url);
|
|
42902
|
+
if (emitPath !== void 0 && artifactPath !== void 0) return yield* new SeoCliError({ message: "--emit-rendered writes a fresh crawl; --rendered replays one. Use one or the other." });
|
|
42903
|
+
if (url === void 0 && artifactPath === void 0) return yield* new SeoCliError({ message: "Provide a homepage URL to crawl, or --rendered <path> to replay a saved crawl." });
|
|
41615
42904
|
const warnings = [];
|
|
41616
|
-
|
|
41617
|
-
|
|
41618
|
-
let
|
|
41619
|
-
|
|
41620
|
-
|
|
41621
|
-
|
|
41622
|
-
|
|
41623
|
-
|
|
41624
|
-
|
|
41625
|
-
|
|
41626
|
-
|
|
41627
|
-
|
|
41628
|
-
|
|
42905
|
+
let origin;
|
|
42906
|
+
let seed;
|
|
42907
|
+
let graph;
|
|
42908
|
+
let renderedEdges;
|
|
42909
|
+
let crawlReport;
|
|
42910
|
+
if (artifactPath !== void 0) {
|
|
42911
|
+
const artifact = yield* readRenderedArtifact(artifactPath);
|
|
42912
|
+
if (url !== void 0) {
|
|
42913
|
+
let requestedOrigin;
|
|
42914
|
+
try {
|
|
42915
|
+
requestedOrigin = new URL(url).origin;
|
|
42916
|
+
} catch {
|
|
42917
|
+
return yield* new SeoCliError({ message: `Not a valid URL: ${url}` });
|
|
42918
|
+
}
|
|
42919
|
+
if (requestedOrigin !== new URL(artifact.origin).origin) return yield* new SeoCliError({ message: `--rendered artifact origin ${artifact.origin} does not match ${url}.` });
|
|
42920
|
+
}
|
|
42921
|
+
origin = artifact.origin;
|
|
42922
|
+
seed = artifact.seed;
|
|
42923
|
+
graph = renderedGraphFromEdges(artifact.edges, artifact.crawl.root, artifact.nodes);
|
|
42924
|
+
renderedEdges = artifact.edges;
|
|
42925
|
+
crawlReport = {
|
|
42926
|
+
pages: artifact.crawl.pages,
|
|
42927
|
+
limit: artifact.crawl.limit,
|
|
42928
|
+
truncated: artifact.crawl.truncated,
|
|
42929
|
+
failures: artifact.crawl.failures,
|
|
42930
|
+
bodyTruncated: artifact.crawl.bodyTruncated,
|
|
42931
|
+
truncatedPages: artifact.crawl.truncatedPages
|
|
42932
|
+
};
|
|
42933
|
+
} else {
|
|
42934
|
+
const checkedLimit = yield* positive("limit", options.limit);
|
|
42935
|
+
const checkedTimeout = yield* positive("request-timeout-ms", options.requestTimeoutMs);
|
|
42936
|
+
const checkedMaxBody = yield* positive("max-body-bytes", options.maxBodyBytes);
|
|
42937
|
+
const crawl = yield* tryPromise({
|
|
42938
|
+
try: () => crawlRenderedPages(url, {
|
|
42939
|
+
limit: checkedLimit,
|
|
42940
|
+
timeoutMs: checkedTimeout,
|
|
42941
|
+
maxBodyBytes: checkedMaxBody,
|
|
42942
|
+
allowPrivate: options.allowPrivate
|
|
42943
|
+
}),
|
|
42944
|
+
catch: (cause) => new SeoCliError({ message: `Could not crawl ${url}: ${messageOf(cause)}` })
|
|
42945
|
+
});
|
|
42946
|
+
if (crawl.pages.length === 0) return yield* new SeoCliError({ message: `No page could be crawled at ${url}: ${crawl.failures[0]?.error ?? "no pages fetched"}` });
|
|
42947
|
+
graph = buildRenderedGraph(crawl.origin, crawl.pages, crawl.root);
|
|
42948
|
+
renderedEdges = graph.internalEdges;
|
|
42949
|
+
origin = crawl.origin;
|
|
42950
|
+
seed = crawl.seed;
|
|
42951
|
+
crawlReport = {
|
|
41629
42952
|
pages: crawl.pages.length,
|
|
41630
42953
|
limit: crawl.limit,
|
|
41631
42954
|
truncated: crawl.truncated,
|
|
41632
42955
|
failures: crawl.failures,
|
|
41633
42956
|
bodyTruncated: crawl.truncatedBodies.length > 0,
|
|
41634
42957
|
truncatedPages: crawl.truncatedBodies
|
|
41635
|
-
}
|
|
42958
|
+
};
|
|
42959
|
+
if (emitPath !== void 0) {
|
|
42960
|
+
const artifact = {
|
|
42961
|
+
kind: "links-rendered",
|
|
42962
|
+
schemaVersion: 1,
|
|
42963
|
+
origin: crawl.origin,
|
|
42964
|
+
seed: crawl.seed,
|
|
42965
|
+
crawl: {
|
|
42966
|
+
pages: crawl.pages.length,
|
|
42967
|
+
root: crawl.root,
|
|
42968
|
+
limit: crawl.limit,
|
|
42969
|
+
truncated: crawl.truncated,
|
|
42970
|
+
bodyTruncated: crawl.truncatedBodies.length > 0,
|
|
42971
|
+
truncatedPages: crawl.truncatedBodies,
|
|
42972
|
+
failures: crawl.failures
|
|
42973
|
+
},
|
|
42974
|
+
nodes: graph.nodes,
|
|
42975
|
+
edges: graph.internalEdges
|
|
42976
|
+
};
|
|
42977
|
+
yield* writeRenderedArtifact(emitPath, artifact);
|
|
42978
|
+
yield* logInfo(`Wrote rendered edges to ${emitPath}`);
|
|
42979
|
+
}
|
|
42980
|
+
}
|
|
42981
|
+
if (crawlReport.truncatedPages.length > 0) warnings.push(`${crawlReport.truncatedPages.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
|
|
42982
|
+
const analysis = yield* analyzeDeclared(origin, renderedEdges, options.assertCoverage, {
|
|
42983
|
+
truncated: crawlReport.truncated,
|
|
42984
|
+
bodyTruncated: crawlReport.bodyTruncated,
|
|
42985
|
+
failures: crawlReport.failures.length
|
|
42986
|
+
});
|
|
42987
|
+
warnings.push(...analysis.warnings);
|
|
42988
|
+
const diff = analysis.declaredEdges === void 0 ? void 0 : diffLinkGraph(analysis.declaredEdges, graph);
|
|
42989
|
+
const coverage = analysis.coverage === void 0 ? void 0 : {
|
|
42990
|
+
rules: analysis.coverage.rules,
|
|
42991
|
+
ok: analysis.coverage.violations.length === 0,
|
|
42992
|
+
violations: analysis.coverage.violations
|
|
42993
|
+
};
|
|
42994
|
+
const report = {
|
|
42995
|
+
kind: "links-verify",
|
|
42996
|
+
schemaVersion: 1,
|
|
42997
|
+
origin,
|
|
42998
|
+
seed,
|
|
42999
|
+
crawl: crawlReport,
|
|
41636
43000
|
rendered: {
|
|
41637
|
-
pages:
|
|
43001
|
+
pages: crawlReport.pages,
|
|
41638
43002
|
internalEdges: graph.internalEdges.length,
|
|
41639
43003
|
contextualEdges: graph.contextualEdges.length,
|
|
41640
43004
|
maxDepth: graph.maxDepth,
|
|
@@ -41646,15 +43010,18 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
41646
43010
|
declaredNotRendered: diff.declaredNotRendered,
|
|
41647
43011
|
renderedNotDeclared: diff.renderedNotDeclared
|
|
41648
43012
|
},
|
|
43013
|
+
...coverage === void 0 ? {} : { coverage },
|
|
41649
43014
|
warnings
|
|
41650
43015
|
};
|
|
41651
43016
|
if (options.json) yield* printJson(report);
|
|
41652
43017
|
else yield* printText(renderLinksReport(report));
|
|
43018
|
+
if (coverage !== void 0 && !coverage.ok) return yield* new SeoCliError({ message: `${coverage.violations.length} rendered coverage violation(s) — the served anchors do not satisfy seo.config.ts.` });
|
|
41653
43019
|
})));
|
|
41654
43020
|
const decideFile = String$1("file").pipe(withDescription("JSON file with an array of link candidates (default: stdin)"), optional);
|
|
41655
43021
|
const thresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_LINK_THRESHOLD));
|
|
41656
43022
|
const modelFlag = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
|
|
41657
43023
|
const concurrencyFlag = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
|
|
43024
|
+
const budgetFlag = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
|
|
41658
43025
|
const checkThreshold = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
|
|
41659
43026
|
/** Read the candidate JSON from a file argument or stdin; a TTY with no file is an error. */
|
|
41660
43027
|
const readCandidatesText = (file) => try_({
|
|
@@ -41678,8 +43045,9 @@ const linksDecideCommand = make$9("decide", {
|
|
|
41678
43045
|
json: jsonFlag,
|
|
41679
43046
|
threshold: thresholdFlag,
|
|
41680
43047
|
model: modelFlag,
|
|
41681
|
-
concurrency: concurrencyFlag
|
|
41682
|
-
|
|
43048
|
+
concurrency: concurrencyFlag,
|
|
43049
|
+
budget: budgetFlag
|
|
43050
|
+
}).pipe(withDescription$1("Answer real-reason, anchor-present, direction, and relevance for candidate links; below-threshold candidates go to review"), withExamples([
|
|
41683
43051
|
{
|
|
41684
43052
|
command: "pagegraph links decide candidates.json",
|
|
41685
43053
|
description: "Decide a candidate set and print the reviewable plan"
|
|
@@ -41695,13 +43063,15 @@ const linksDecideCommand = make$9("decide", {
|
|
|
41695
43063
|
]), withHandler(fn("SeoCli.linksDecide")(function* (options) {
|
|
41696
43064
|
const threshold = yield* checkThreshold(options.threshold);
|
|
41697
43065
|
const concurrency = yield* positive("concurrency", options.concurrency);
|
|
43066
|
+
const budget = yield* positive("budget", options.budget);
|
|
41698
43067
|
const text = yield* readCandidatesText(getOrUndefined$1(options.file));
|
|
41699
43068
|
const candidates = yield* parseCandidates(text);
|
|
41700
43069
|
if (process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "") return yield* new SeoCliError({ message: "TYPESAFE_API_KEY is not set; `pagegraph links decide` answers through TypeSafe System One." });
|
|
41701
43070
|
const report = yield* decideLinks(candidates, {
|
|
41702
43071
|
model: options.model,
|
|
41703
43072
|
threshold,
|
|
41704
|
-
concurrency
|
|
43073
|
+
concurrency,
|
|
43074
|
+
budget
|
|
41705
43075
|
}).pipe(provide(typeSafeDecisionModel(options.model)), mapError(decisionErrorMessage));
|
|
41706
43076
|
if (options.json) yield* printJson(report);
|
|
41707
43077
|
else yield* printText(renderLinksDecideReport(report));
|
|
@@ -41864,6 +43234,7 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect and audit a TanS
|
|
|
41864
43234
|
}
|
|
41865
43235
|
]), withSubcommands([
|
|
41866
43236
|
auditCommand,
|
|
43237
|
+
decideCommandGroup,
|
|
41867
43238
|
diffCommand,
|
|
41868
43239
|
graphCommand,
|
|
41869
43240
|
inspectCommand,
|