@nebulacomponents/citable 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +16 -4
- package/dist/universal/.agents/skills/citable/SKILL.md +7 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +5 -2
- package/dist/universal/.agents/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.agents/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.agents/skills/citable/manifest.json +11 -8
- package/dist/universal/.agents/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.agents/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.claude/skills/citable/SKILL.md +7 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +5 -2
- package/dist/universal/.claude/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.claude/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.claude/skills/citable/manifest.json +11 -8
- package/dist/universal/.claude/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.claude/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +7 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +5 -2
- package/dist/universal/.cursor/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.cursor/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +11 -8
- package/dist/universal/.cursor/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.cursor/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +7 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +5 -2
- package/dist/universal/.gemini/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.gemini/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +11 -8
- package/dist/universal/.gemini/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.gemini/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.github/skills/citable/SKILL.md +7 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +5 -2
- package/dist/universal/.github/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.github/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.github/skills/citable/manifest.json +11 -8
- package/dist/universal/.github/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.github/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +7 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +5 -2
- package/dist/universal/.kiro/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.kiro/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +11 -8
- package/dist/universal/.kiro/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.kiro/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +7 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +5 -2
- package/dist/universal/.opencode/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.opencode/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +11 -8
- package/dist/universal/.opencode/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.opencode/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +7 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +5 -2
- package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.pi/agent/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +11 -8
- package/dist/universal/.pi/agent/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +7 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +5 -2
- package/dist/universal/.qoder/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.qoder/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +11 -8
- package/dist/universal/.qoder/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.qoder/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +7 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +5 -2
- package/dist/universal/.rovodev/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.rovodev/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +11 -8
- package/dist/universal/.rovodev/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +7 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +5 -2
- package/dist/universal/.trae/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.trae/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.trae/skills/citable/manifest.json +11 -8
- package/dist/universal/.trae/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.trae/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +7 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +5 -2
- package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +5 -5
- package/dist/universal/.trae-cn/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +11 -8
- package/dist/universal/.trae-cn/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/manifest.json +135 -99
- package/package.json +9 -1
- package/schemas/observation.schema.json +20 -0
- package/schemas/remediation-spec.schema.json +28 -0
- package/skill/SKILL.md +7 -1
- package/skill/commands/README.md +5 -2
- package/skill/commands/measurement.md +5 -5
- package/skill/commands/observe-and-act.md +40 -0
- package/skill/references/capability-boundaries.md +8 -8
- package/src/cli/index.js +39 -0
- package/src/commands/applyRemediation.js +57 -0
- package/src/commands/monitor.js +40 -0
- package/src/commands/observe.js +235 -0
- package/src/evidence/run.js +1 -1
- package/src/observations/common.js +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulacomponents/citable",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Production-grade SEO, AEO, and GEO agent skill with auditable detection, claim governance, evidence validation, and multi-agent installation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"seo",
|
|
@@ -88,5 +88,13 @@
|
|
|
88
88
|
"ajv-formats": "^3.0.1",
|
|
89
89
|
"js-yaml": "^5.2.1",
|
|
90
90
|
"node-html-parser": "^9.0.0"
|
|
91
|
+
},
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"playwright": ">=1.50.0"
|
|
94
|
+
},
|
|
95
|
+
"peerDependenciesMeta": {
|
|
96
|
+
"playwright": {
|
|
97
|
+
"optional": true
|
|
98
|
+
}
|
|
91
99
|
}
|
|
92
100
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "citable://schemas/observation.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Collected observation envelope",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["observation_id", "kind", "state", "collected_at", "collection_method", "confidence", "source", "evidence_hash", "data"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"observation_id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"kind": { "type": "string", "enum": ["render", "index", "citation", "citation_review", "crawler_log", "passage", "canonical_freshness", "performance", "corroboration"] },
|
|
11
|
+
"state": { "type": "string", "enum": ["observed", "not_observed", "not_evidenced", "incomplete", "review_required", "failed"] },
|
|
12
|
+
"collected_at": { "type": "string", "format": "date-time" },
|
|
13
|
+
"collection_method": { "type": "string", "enum": ["live_api", "browser", "owner_import", "synthetic_fetch", "static_analysis", "human_review"] },
|
|
14
|
+
"confidence": { "type": "string", "enum": ["confirmed", "high", "medium", "low", "unknown"] },
|
|
15
|
+
"source": { "type": "string", "minLength": 1 },
|
|
16
|
+
"evidence_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
17
|
+
"data": { "type": "object" },
|
|
18
|
+
"limitations": { "type": "array", "items": { "type": "string" } }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "citable://schemas/remediation-spec.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Guarded remediation specification",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["source_run_id", "operations"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"source_run_id": { "type": "string" },
|
|
10
|
+
"operations": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["operation_id", "file", "find", "replace", "expected_file_hash", "finding_ids"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"operation_id": { "type": "string" },
|
|
18
|
+
"file": { "type": "string", "minLength": 1 },
|
|
19
|
+
"find": { "type": "string", "minLength": 1 },
|
|
20
|
+
"replace": { "type": "string" },
|
|
21
|
+
"expected_file_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
22
|
+
"finding_ids": { "type": "array", "minItems": 1, "items": { "type": "string" } },
|
|
23
|
+
"reviewer": { "type": "string" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: >
|
|
|
7
7
|
generative engine optimization, structured data governance, claim substantiation,
|
|
8
8
|
crawler policy, entity consistency, content discoverability, or wants a site audited
|
|
9
9
|
for how search and AI systems will retrieve, understand, cite, or recommend it.
|
|
10
|
-
version: 1.
|
|
10
|
+
version: 1.4.0
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Citable — search & generative discoverability governance
|
|
@@ -85,6 +85,9 @@ citable schema --target <dir|url>
|
|
|
85
85
|
citable validate [registries|claims|evidence|schema|links]
|
|
86
86
|
citable compare-snapshots [runA runB]
|
|
87
87
|
citable action-plan [run-id] # ordered actions, blockers, semantic gates, verification
|
|
88
|
+
citable observe <mode> [options] # render/index/citation/log/passage/consensus/performance evidence
|
|
89
|
+
citable apply --input <spec> [--write] # reviewed, hash-locked remediation; dry run by default
|
|
90
|
+
citable monitor [runA runB] # observation regression alerts
|
|
88
91
|
```
|
|
89
92
|
|
|
90
93
|
Audit scopes: `technical seo aeo geo architecture entity claims evidence schema
|
|
@@ -126,6 +129,9 @@ mandatory semantic reviews make the relevant posture `not_established`.
|
|
|
126
129
|
Consult `references/capability-boundaries.md` before claiming any observation;
|
|
127
130
|
it identifies which states the current CLI can collect and which require
|
|
128
131
|
operator data or future adapters.
|
|
132
|
+
Use `commands/observe-and-act.md` for collector prerequisites, evidence labels,
|
|
133
|
+
the controlled citation adapter protocol, remediation refusal conditions, and
|
|
134
|
+
monitoring interpretation.
|
|
129
135
|
|
|
130
136
|
## Command workflows
|
|
131
137
|
|
package/skill/commands/README.md
CHANGED
|
@@ -21,11 +21,14 @@ intent, not executability, so statuses are strict:
|
|
|
21
21
|
| /citable validate, validate-claims, validate-evidence, validate-schema, validate-links | implemented |
|
|
22
22
|
| /citable compare-snapshots / validate-regression | implemented |
|
|
23
23
|
| /citable action-plan | implemented — writes ordered, source-run-bound action artifacts outside immutable evidence packages |
|
|
24
|
+
| /citable observe | implemented — render, index, citation, crawler-log, passage, consensus, performance, and corroboration evidence |
|
|
25
|
+
| /citable apply | implemented — reviewed, hash-locked source replacements; dry run by default |
|
|
26
|
+
| /citable monitor | implemented — compares immutable observation runs and emits evidence-linked alerts |
|
|
24
27
|
| /citable ingest, map-site, map-queries, map-prompts, map-entities, map-evidence | specified (ingest.md) |
|
|
25
28
|
| /citable optimize-page | specified (optimize-page.md) — requires source-to-render mapping, claim-preserving rewrites, build execution, rollback; none of that is proven yet |
|
|
26
29
|
| /citable create-page, answer-block, architect, interlink, consolidate, metadata | specified (page-work.md) |
|
|
27
|
-
| /citable validate-render |
|
|
28
|
-
| /citable measure seo/aeo/geo, test-prompts, monitor-crawlers, monitor-contradictions |
|
|
30
|
+
| /citable validate-render | partially implemented by `observe render`; mobile/cross-browser and interaction exploration remain |
|
|
31
|
+
| /citable measure seo/aeo/geo, test-prompts, monitor-crawlers, monitor-contradictions | partially implemented by observation imports, Google/CrUX live APIs, custom citation adapters, and monitor comparisons |
|
|
29
32
|
|
|
30
33
|
Shared refusal conditions (all commands): any action that would fabricate
|
|
31
34
|
facts, citations, evidence, authorship, reviews, corroboration, or hidden
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
command: /citable measure seo|aeo|geo, test-prompts, monitor-crawlers, monitor-contradictions
|
|
3
|
-
purpose: Observation and measurement workflows
|
|
3
|
+
purpose: Observation and measurement workflows with exact collector and product-mode boundaries.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Honest capability statement
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
Citable ships Google URL Inspection, CrUX field collection, normalized owner
|
|
9
|
+
imports, and a disclosed custom citation-adapter protocol. It does not claim
|
|
10
|
+
Bing AI Performance automation or equivalence between an API adapter and a
|
|
11
|
+
consumer answer product. Preserve collector and product mode on every result.
|
|
12
12
|
|
|
13
13
|
# measure seo
|
|
14
14
|
Inputs the operator exports (Search Console/Bing/analytics CSV or JSON).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
command: /citable observe|apply|monitor
|
|
3
|
+
purpose: Collect external and rendered evidence, apply reviewed source changes, and detect regressions without collapsing unlike evidence states.
|
|
4
|
+
failure_behaviour: unavailable dependency, credential, source run, reviewer, exact hash, or unique replacement -> incomplete or fail; never infer success
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Observe, apply, and monitor
|
|
8
|
+
|
|
9
|
+
## Collection modes
|
|
10
|
+
|
|
11
|
+
- `observe render --target <URL>` uses optional Playwright. Browser evidence is
|
|
12
|
+
not search-index evidence.
|
|
13
|
+
- `observe index --input <JSON>` imports owner exports. With `--target`,
|
|
14
|
+
`--site-url`, and `GSC_ACCESS_TOKEN`, it calls Google URL Inspection, which
|
|
15
|
+
reports the indexed version rather than performing a live indexability test.
|
|
16
|
+
- `observe citations --input <JSON>` imports complete controlled observations.
|
|
17
|
+
With `--endpoint`, the input is a versioned prompt corpus and each prompt runs
|
|
18
|
+
1-20 times (default 3) through the disclosed adapter protocol. An API is not
|
|
19
|
+
presumed equivalent to a provider's consumer search experience.
|
|
20
|
+
- `observe logs --input <JSON>` imports server/CDN records. User-agent matches
|
|
21
|
+
without IP verification remain low confidence.
|
|
22
|
+
- `observe passages|consensus --target <dir|URL>` performs static extraction.
|
|
23
|
+
- `observe performance` imports evidence or calls CrUX with `CRUX_API_KEY`.
|
|
24
|
+
- `observe corroboration --input <JSON>` records observable external mentions;
|
|
25
|
+
controlled or unverified sources do not become authority claims.
|
|
26
|
+
|
|
27
|
+
## Guarded remediation
|
|
28
|
+
|
|
29
|
+
`apply --input remediation-spec.json` is a dry run. Every operation binds to an
|
|
30
|
+
existing audit run and finding IDs, stays inside repository source, names a
|
|
31
|
+
reviewer, matches the exact file hash, and identifies exactly one source string.
|
|
32
|
+
`--write` applies only after all operations validate. Build, tests, semantic
|
|
33
|
+
review, re-audit, and snapshot comparison remain mandatory afterward.
|
|
34
|
+
|
|
35
|
+
## Monitoring
|
|
36
|
+
|
|
37
|
+
`monitor [runA runB]` reports state changes, missing observations, index loss,
|
|
38
|
+
canonical disagreement, and citation-presence changes. Alerts are evidence
|
|
39
|
+
changes, not causal explanations or ranking conclusions. Schedule it in CI at
|
|
40
|
+
the cadence defined by lifecycle and experiment records.
|
|
@@ -6,18 +6,18 @@ Use the narrowest supported state. Never promote evidence across rows.
|
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| Allowed by policy | Implemented | Effective robots rule plus crawler-purpose registry decision |
|
|
8
8
|
| Synthetic fetch succeeded | Partial | Captured status, redirects, headers, body from the declared user agent; label identity simulated |
|
|
9
|
-
| Verified crawler reached edge |
|
|
10
|
-
| Observed in production logs |
|
|
11
|
-
| Indexed |
|
|
9
|
+
| Verified crawler reached edge | Imported evidence | `observe logs` record plus provider-published IP verification where supported |
|
|
10
|
+
| Observed in production logs | Imported evidence | Timestamped `observe logs` artifact with URL, status, bytes, latency, cache result, UA, and verification result |
|
|
11
|
+
| Indexed | Google API or imported evidence | `observe index`; Google inspection covers the indexed version, other engines require owner exports |
|
|
12
12
|
| Returned by retrieval | Operator supplied | Provider observation containing retrieved source URL |
|
|
13
|
-
| Cited |
|
|
14
|
-
| Materially supports answer | Human review required |
|
|
13
|
+
| Cited | Import or disclosed adapter | Complete `observe citations` answer with citation URL and product/adapter mode |
|
|
14
|
+
| Materially supports answer | Human review required | `citation_review` maps answer claim to source passage and names the reviewer |
|
|
15
15
|
| Recommended | Operator supplied + review | Explicit recommendation, constraints, provider/mode/locale/time |
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
URL audit mode alone does not prove crawler identity, WAF allowlisting,
|
|
18
18
|
production-log access, index presence, selected canonical, rendered DOM parity,
|
|
19
|
-
or provider citation behavior.
|
|
20
|
-
|
|
19
|
+
or provider citation behavior. Observation collectors can supply narrower
|
|
20
|
+
evidence for these states; unavailable inputs remain `not_evidenced`.
|
|
21
21
|
|
|
22
22
|
Primary-source lineage for these boundaries:
|
|
23
23
|
|
package/src/cli/index.js
CHANGED
|
@@ -12,6 +12,9 @@ import { compareSnapshots } from '../commands/compareSnapshots.js';
|
|
|
12
12
|
import { isInstallerCommand, runInstallerCommand } from '../installer/index.js';
|
|
13
13
|
import { selfUpgradeCommand, selfUpgradeExitCode } from '../commands/selfUpgrade.js';
|
|
14
14
|
import { actionPlan } from '../commands/actionPlan.js';
|
|
15
|
+
import { observe } from '../commands/observe.js';
|
|
16
|
+
import { applyRemediation } from '../commands/applyRemediation.js';
|
|
17
|
+
import { monitor } from '../commands/monitor.js';
|
|
15
18
|
|
|
16
19
|
const HELP = `citable — SEO / AEO / GEO audit, remediation, validation, and governance
|
|
17
20
|
|
|
@@ -34,12 +37,23 @@ Commands
|
|
|
34
37
|
validate [mode] registries (default) | claims | evidence | schema | links
|
|
35
38
|
compare-snapshots [a b] Regression diff between two audit runs
|
|
36
39
|
action-plan [run] Turn audit findings into ordered remediation work
|
|
40
|
+
observe <mode> Collect render, index, citation, log, passage,
|
|
41
|
+
consensus, performance, or corroboration evidence
|
|
42
|
+
apply Apply a reviewed, hash-locked remediation spec
|
|
43
|
+
monitor [runA runB] Compare observation runs and emit regression alerts
|
|
37
44
|
self-upgrade Check for a newer version and upgrade the npx cache
|
|
38
45
|
|
|
39
46
|
Options
|
|
40
47
|
--target <dir|url> Built output directory or deployed URL to audit
|
|
41
48
|
--base-url <url> Base URL for path resolution of a built output dir
|
|
42
49
|
--ref-date <YYYY-MM-DD> Reference date for expiry/staleness checks (default: today)
|
|
50
|
+
--input <file> Import file or remediation specification
|
|
51
|
+
--provider <name> Provider label for imported observations
|
|
52
|
+
--api-key <key> API key (prefer provider environment variables)
|
|
53
|
+
--site-url <property> Search Console property for live URL inspection
|
|
54
|
+
--access-token <token> OAuth token (prefer provider environment variables)
|
|
55
|
+
--endpoint <url> Controlled citation adapter endpoint
|
|
56
|
+
--repeat <count> Repetitions per prompt for citation experiments
|
|
43
57
|
--write Persist registry changes (map-claims, substantiate)
|
|
44
58
|
--json Machine-readable output only
|
|
45
59
|
|
|
@@ -55,6 +69,14 @@ function parseArgs(argv) {
|
|
|
55
69
|
else if (a === '--target') args.target = argv[++i];
|
|
56
70
|
else if (a === '--base-url') args.baseUrl = argv[++i];
|
|
57
71
|
else if (a === '--ref-date') args.refDate = argv[++i];
|
|
72
|
+
else if (a === '--input') args.input = argv[++i];
|
|
73
|
+
else if (a === '--provider') args.provider = argv[++i];
|
|
74
|
+
else if (a === '--api-key') args.apiKey = argv[++i];
|
|
75
|
+
else if (a === '--site-url') args.siteUrl = argv[++i];
|
|
76
|
+
else if (a === '--access-token') args.accessToken = argv[++i];
|
|
77
|
+
else if (a === '--endpoint') args.endpoint = argv[++i];
|
|
78
|
+
else if (a === '--repeat') args.repeat = Number(argv[++i]);
|
|
79
|
+
else if (a === '--timeout') args.timeout = Number(argv[++i]);
|
|
58
80
|
else if (a === '--force') args.force = true;
|
|
59
81
|
else args._.push(a);
|
|
60
82
|
}
|
|
@@ -128,6 +150,23 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
128
150
|
out(args, `action-plan: ${r.summary.total_actions} action(s) [ready:${r.summary.ready} blocked:${r.summary.blocked}]\nPlan: ${path.join(r.dir, 'action-plan.md')}\nSource audit: ${r.source_run_id}`, r);
|
|
129
151
|
break;
|
|
130
152
|
}
|
|
153
|
+
case 'observe': {
|
|
154
|
+
const mode = args._[0];
|
|
155
|
+
const r = await observe(root, mode, args);
|
|
156
|
+
out(args, `observe ${mode}: ${r.summary.total} observation(s) [${Object.entries(r.summary.by_state).map(([k, v]) => `${k}:${v}`).join(' ')}]\nEvidence package: ${r.dir}\nStatus: ${r.manifest.status}`, r);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'apply': {
|
|
160
|
+
const r = applyRemediation(root, args);
|
|
161
|
+
out(args, `apply: ${r.operations.length} operation(s) ${r.write ? 'applied' : 'proposed (dry run)'}\nEvidence package: ${r.dir}\nSource audit: ${r.source_run_id}`, r);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'monitor': {
|
|
165
|
+
const r = monitor(root, { runA: args._[0], runB: args._[1] });
|
|
166
|
+
out(args, `monitor ${r.run_a} → ${r.run_b}: ${r.summary.alerts} alert(s), ${r.summary.critical_or_high} critical/high\nReport: ${path.join(r.dir, 'latest.json')}`, r);
|
|
167
|
+
if (r.summary.critical_or_high > 0) process.exitCode = 1;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
131
170
|
case 'self-upgrade': {
|
|
132
171
|
const output = await selfUpgradeCommand(argv.slice(1));
|
|
133
172
|
console.log(output);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRun } from '../evidence/run.js';
|
|
4
|
+
import { readJson, sha256 } from '../shared/io.js';
|
|
5
|
+
import { validateAgainst } from '../shared/schemaValidator.js';
|
|
6
|
+
|
|
7
|
+
function inside(root, candidate) {
|
|
8
|
+
const base = path.resolve(root) + path.sep;
|
|
9
|
+
const resolved = path.resolve(root, candidate);
|
|
10
|
+
if (!resolved.startsWith(base) || resolved.includes(`${path.sep}.git${path.sep}`) || resolved.includes(`${path.sep}.citable${path.sep}`)) {
|
|
11
|
+
throw new Error(`remediation file escapes editable source boundary: ${candidate}`);
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function applyRemediation(root, { input, write = false } = {}) {
|
|
17
|
+
if (!input || !fs.existsSync(input)) throw new Error('apply requires --input <remediation-spec.json>');
|
|
18
|
+
const raw = fs.readFileSync(input, 'utf8');
|
|
19
|
+
const spec = readJson(input);
|
|
20
|
+
const valid = validateAgainst('remediation-spec.schema.json', spec);
|
|
21
|
+
if (!valid.valid) throw new Error(`remediation spec invalid: ${valid.errors.join('; ')}`);
|
|
22
|
+
const manifestFile = path.join(root, '.citable', 'runs', spec.source_run_id, 'manifest.json');
|
|
23
|
+
if (!fs.existsSync(manifestFile)) throw new Error(`source run not found: ${spec.source_run_id}`);
|
|
24
|
+
const findingsFile = path.join(root, '.citable', 'runs', spec.source_run_id, 'findings.json');
|
|
25
|
+
if (!fs.existsSync(findingsFile)) throw new Error(`source run has no findings: ${spec.source_run_id}`);
|
|
26
|
+
const sourceFindingIds = new Set(readJson(findingsFile).map((finding) => finding.finding_id));
|
|
27
|
+
const results = [];
|
|
28
|
+
const pending = [];
|
|
29
|
+
for (const operation of spec.operations) {
|
|
30
|
+
const unknownFindings = operation.finding_ids.filter((id) => !sourceFindingIds.has(id));
|
|
31
|
+
if (unknownFindings.length) throw new Error(`operation ${operation.operation_id} references findings outside source run: ${unknownFindings.join(', ')}`);
|
|
32
|
+
const file = inside(root, operation.file);
|
|
33
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`remediation target is not a file: ${operation.file}`);
|
|
34
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
35
|
+
if (sha256(before) !== operation.expected_file_hash) throw new Error(`stale remediation refused for ${operation.file}: file hash changed`);
|
|
36
|
+
if (!operation.reviewer) throw new Error(`reviewer required for operation ${operation.operation_id}`);
|
|
37
|
+
const matches = before.split(operation.find).length - 1;
|
|
38
|
+
if (matches !== 1) throw new Error(`operation ${operation.operation_id} expected exactly one match, observed ${matches}`);
|
|
39
|
+
const after = before.replace(operation.find, operation.replace);
|
|
40
|
+
pending.push({ file, before, after });
|
|
41
|
+
results.push({ operation_id: operation.operation_id, file: operation.file, finding_ids: operation.finding_ids, reviewer: operation.reviewer, before_hash: sha256(before), after_hash: sha256(after), status: write ? 'applied' : 'proposed' });
|
|
42
|
+
}
|
|
43
|
+
if (write) {
|
|
44
|
+
const written = [];
|
|
45
|
+
try {
|
|
46
|
+
for (const change of pending) { fs.writeFileSync(change.file, change.after); written.push(change); }
|
|
47
|
+
} catch (error) {
|
|
48
|
+
for (const change of written.reverse()) fs.writeFileSync(change.file, change.before);
|
|
49
|
+
throw new Error(`remediation write failed and prior writes were rolled back: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const run = createRun(root, { command: 'apply', argv: process.argv.slice(2), target: { kind: 'source', location: root, environment: 'local' } });
|
|
53
|
+
run.addInput('remediation_spec', raw);
|
|
54
|
+
run.writeArtifact('remediation/results.json', { source_run_id: spec.source_run_id, write, operations: results });
|
|
55
|
+
run.manifest.warnings.push(write ? 'Source changes applied; build, test, re-audit, and snapshot comparison remain required.' : 'Dry run only; no source files were changed.');
|
|
56
|
+
return { runId: run.runId, dir: run.finalize('completed_with_warnings'), source_run_id: spec.source_run_id, write, operations: results };
|
|
57
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson, writeJson, nowIso } from '../shared/io.js';
|
|
4
|
+
|
|
5
|
+
function observations(dir) {
|
|
6
|
+
const folder = path.join(dir, 'observations');
|
|
7
|
+
if (!fs.existsSync(folder)) return [];
|
|
8
|
+
return fs.readdirSync(folder).filter((f) => f.endsWith('.json')).sort().map((f) => readJson(path.join(folder, f)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function key(item) {
|
|
12
|
+
const d = item.data || {};
|
|
13
|
+
if (item.kind === 'citation') return [item.kind, d.provider, d.product_mode, d.prompt_id, d.run_index].join(':');
|
|
14
|
+
return [item.kind, d.url || d.citation_url || d.prompt_id || d.timestamp || item.observation_id].join(':');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function monitor(root, { runA, runB } = {}) {
|
|
18
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
19
|
+
if (!fs.existsSync(runsDir)) throw new Error('no runs available to monitor');
|
|
20
|
+
const candidates = fs.readdirSync(runsDir).filter((r) => fs.existsSync(path.join(runsDir, r, 'observations'))).sort();
|
|
21
|
+
const b = runB || candidates.at(-1), a = runA || candidates.at(-2);
|
|
22
|
+
if (!a || !b) throw new Error('monitor requires two observation runs');
|
|
23
|
+
const before = new Map(observations(path.join(runsDir, a)).map((o) => [key(o), o]));
|
|
24
|
+
const after = new Map(observations(path.join(runsDir, b)).map((o) => [key(o), o]));
|
|
25
|
+
const alerts = [];
|
|
26
|
+
for (const [k, current] of after) {
|
|
27
|
+
const previous = before.get(k);
|
|
28
|
+
if (!previous) alerts.push({ severity: 'informational', type: 'new_observation', key: k, current_state: current.state });
|
|
29
|
+
else if (previous.state !== current.state) alerts.push({ severity: ['failed', 'not_observed'].includes(current.state) ? 'high' : 'medium', type: 'state_change', key: k, previous_state: previous.state, current_state: current.state });
|
|
30
|
+
if (current.kind === 'index' && previous?.data?.indexed === true && current.data.indexed === false) alerts.push({ severity: 'high', type: 'index_loss', key: k });
|
|
31
|
+
if (current.kind === 'canonical_freshness' && previous?.data?.canonical_consensus === true && current.data.canonical_consensus === false) alerts.push({ severity: 'high', type: 'canonical_regression', key: k });
|
|
32
|
+
if (current.kind === 'citation' && previous?.data?.property_cited === true && current.data.property_cited === false) alerts.push({ severity: 'medium', type: 'citation_presence_change', key: k });
|
|
33
|
+
}
|
|
34
|
+
for (const k of before.keys()) if (!after.has(k)) alerts.push({ severity: 'medium', type: 'observation_missing', key: k });
|
|
35
|
+
const result = { generated_at: nowIso(), run_a: a, run_b: b, summary: { alerts: alerts.length, critical_or_high: alerts.filter((x) => ['critical', 'high'].includes(x.severity)).length }, alerts };
|
|
36
|
+
const dir = path.join(root, '.citable', 'monitoring');
|
|
37
|
+
writeJson(path.join(dir, `${a}--${b}.json`), result);
|
|
38
|
+
writeJson(path.join(dir, 'latest.json'), result);
|
|
39
|
+
return { ...result, dir };
|
|
40
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { buildContext } from './context.js';
|
|
2
|
+
import { envelope, observationRun, readInput } from '../observations/common.js';
|
|
3
|
+
import { sha256 } from '../shared/io.js';
|
|
4
|
+
import { fetchUrl } from '../crawler/fetch.js';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
|
|
7
|
+
const originOf = (value) => { try { return new URL(value).origin; } catch { return null; } };
|
|
8
|
+
const words = (text) => String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
9
|
+
|
|
10
|
+
function canonicalReview(raw, targetOrigin) {
|
|
11
|
+
const citations = raw.citations || [];
|
|
12
|
+
return citations.map((citation, index) => {
|
|
13
|
+
const url = typeof citation === 'string' ? citation : citation.url;
|
|
14
|
+
const canonical = typeof citation === 'object' ? citation.canonical_url || url : url;
|
|
15
|
+
const supports = typeof citation === 'object' ? citation.support_status || 'review_required' : 'review_required';
|
|
16
|
+
return {
|
|
17
|
+
citation_url: url, canonical_url: canonical, citation_order: index + 1,
|
|
18
|
+
first_party: Boolean(targetOrigin && originOf(canonical) === targetOrigin),
|
|
19
|
+
support_status: supports,
|
|
20
|
+
answer_claim: typeof citation === 'object' ? citation.answer_claim || null : null,
|
|
21
|
+
source_passage: typeof citation === 'object' ? citation.source_passage || null : null,
|
|
22
|
+
reviewer: typeof citation === 'object' ? citation.reviewer || null : null,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function observeRender(root, options) {
|
|
28
|
+
if (!options.target || !/^https?:\/\//.test(options.target)) throw new Error('render requires --target <http(s) URL>');
|
|
29
|
+
let playwright;
|
|
30
|
+
try { playwright = await import('playwright'); } catch {
|
|
31
|
+
const item = envelope('render', { url: options.target }, { method: 'browser', source: 'playwright', state: 'not_evidenced', confidence: 'unknown', limitations: ['Optional Playwright dependency is not installed.'] });
|
|
32
|
+
return observationRun(root, 'observe render', options.target, [item], { incomplete: ['Rendered DOM capture unavailable: install Playwright and a Chromium browser.'] });
|
|
33
|
+
}
|
|
34
|
+
const initial = await fetchUrl(options.target, { timeoutMs: options.timeout || 30000, maxRetries: 1 });
|
|
35
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
36
|
+
try {
|
|
37
|
+
const capture = async (name, viewport, isMobile = false) => {
|
|
38
|
+
const context = await browser.newContext({ viewport, isMobile });
|
|
39
|
+
const page = await context.newPage();
|
|
40
|
+
const failures = [];
|
|
41
|
+
page.on('requestfailed', (request) => failures.push({ url: request.url(), error: request.failure()?.errorText || 'unknown' }));
|
|
42
|
+
const response = await page.goto(options.target, { waitUntil: 'networkidle', timeout: options.timeout || 30000 });
|
|
43
|
+
const html = await page.content(), text = await page.locator('body').innerText(), screenshot = await page.screenshot({ fullPage: true });
|
|
44
|
+
const result = { name, final_url: page.url(), status: response?.status() ?? null, viewport, html, text, screenshot, failed_requests: failures };
|
|
45
|
+
await context.close();
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
const desktop = await capture('desktop', { width: 1280, height: 900 });
|
|
49
|
+
const mobile = await capture('mobile', { width: 390, height: 844 }, true);
|
|
50
|
+
const initialText = initial.body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
51
|
+
const data = {
|
|
52
|
+
url: options.target, initial: { final_url: initial.url, status: initial.status, html_hash: sha256(initial.body), text_hash: sha256(initialText), word_count: words(initialText).length, redirect_chain: initial.redirectChain },
|
|
53
|
+
desktop: { final_url: desktop.final_url, status: desktop.status, html_hash: sha256(desktop.html), text_hash: sha256(desktop.text), word_count: words(desktop.text).length, failed_requests: desktop.failed_requests },
|
|
54
|
+
mobile: { final_url: mobile.final_url, status: mobile.status, html_hash: sha256(mobile.html), text_hash: sha256(mobile.text), word_count: words(mobile.text).length, failed_requests: mobile.failed_requests },
|
|
55
|
+
parity: { initial_to_desktop_word_ratio: words(desktop.text).length ? Number((words(initialText).length / words(desktop.text).length).toFixed(3)) : null, mobile_to_desktop_word_ratio: words(desktop.text).length ? Number((words(mobile.text).length / words(desktop.text).length).toFixed(3)) : null },
|
|
56
|
+
};
|
|
57
|
+
const item = envelope('render', data, { method: 'browser', source: 'playwright/chromium', raw: `${initial.body}\n${desktop.html}\n${mobile.html}` });
|
|
58
|
+
return observationRun(root, 'observe render', options.target, [item], { artifacts: { 'initial/response.html': initial.body, 'rendered/desktop-dom.html': desktop.html, 'rendered/desktop-text.txt': desktop.text, 'rendered/mobile-dom.html': mobile.html, 'rendered/mobile-text.txt': mobile.text, 'screenshots/desktop.png': desktop.screenshot, 'screenshots/mobile.png': mobile.screenshot } });
|
|
59
|
+
} finally { await browser.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function observeIndex(root, options) {
|
|
63
|
+
if (!options.input) {
|
|
64
|
+
if (!options.target || !options.siteUrl) throw new Error('live index observation requires --target <URL> and --site-url <Search Console property>');
|
|
65
|
+
const token = options.accessToken || process.env.GSC_ACCESS_TOKEN;
|
|
66
|
+
if (!token) {
|
|
67
|
+
const item = envelope('index', { engine: 'google', url: options.target }, { method: 'live_api', source: 'Google Search Console URL Inspection API', state: 'not_evidenced', confidence: 'unknown', limitations: ['GSC_ACCESS_TOKEN is not configured.'] });
|
|
68
|
+
return observationRun(root, 'observe index', options.target, [item], { incomplete: ['Google index inspection unavailable: GSC_ACCESS_TOKEN is not configured.'] });
|
|
69
|
+
}
|
|
70
|
+
const response = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: options.target, siteUrl: options.siteUrl, languageCode: options.locale || 'en-US' }) });
|
|
71
|
+
const raw = await response.text();
|
|
72
|
+
if (!response.ok) throw new Error(`Search Console returned ${response.status}: ${raw.slice(0, 300)}`);
|
|
73
|
+
const value = JSON.parse(raw).inspectionResult?.indexStatusResult || {};
|
|
74
|
+
const data = { engine: 'google', url: options.target, indexed: value.verdict === 'PASS', selected_canonical: value.googleCanonical || null, declared_canonical: value.userCanonical || null, last_crawl: value.lastCrawlTime || null, fetch_state: value.pageFetchState || null, raw_provider_result: value };
|
|
75
|
+
return observationRun(root, 'observe index', options.target, [envelope('index', data, { method: 'live_api', source: 'Google Search Console URL Inspection API', raw })], { rawInputs: { gsc_response: raw } });
|
|
76
|
+
}
|
|
77
|
+
const input = readInput(options.input);
|
|
78
|
+
const rows = Array.isArray(input.value) ? input.value : input.value.items || [input.value];
|
|
79
|
+
const observations = rows.map((row) => envelope('index', {
|
|
80
|
+
engine: row.engine || options.provider || 'unknown', url: row.url || row.inspectionUrl,
|
|
81
|
+
indexed: row.indexed ?? row.verdict === 'PASS', selected_canonical: row.selected_canonical || row.googleCanonical || null,
|
|
82
|
+
declared_canonical: row.declared_canonical || row.userCanonical || null, last_crawl: row.last_crawl || row.lastCrawlTime || null,
|
|
83
|
+
fetch_state: row.fetch_state || row.pageFetchState || null, raw_provider_result: row,
|
|
84
|
+
}, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: 'confirmed' }));
|
|
85
|
+
return observationRun(root, 'observe index', input.file, observations, { rawInputs: { index_export: input.raw } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function observeCitations(root, options) {
|
|
89
|
+
const input = readInput(options.input);
|
|
90
|
+
let rows = Array.isArray(input.value) ? input.value : input.value.observations || [input.value];
|
|
91
|
+
let method = 'owner_import';
|
|
92
|
+
if (options.endpoint) {
|
|
93
|
+
const prompts = input.value.prompts || (Array.isArray(input.value) ? input.value : []);
|
|
94
|
+
if (!prompts.length) throw new Error('citation runner input must contain a prompts array');
|
|
95
|
+
if (!/^https:\/\//.test(options.endpoint) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(options.endpoint)) throw new Error('citation adapter endpoint must use HTTPS (loopback HTTP is allowed for testing)');
|
|
96
|
+
const repeat = Number.isInteger(options.repeat) && options.repeat > 0 && options.repeat <= 20 ? options.repeat : 3;
|
|
97
|
+
rows = [];
|
|
98
|
+
for (const prompt of prompts) for (let runIndex = 1; runIndex <= repeat; runIndex++) {
|
|
99
|
+
const response = await fetch(options.endpoint, { method: 'POST', headers: { 'content-type': 'application/json', ...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}) }, body: JSON.stringify({ prompt_id: prompt.prompt_id, prompt_text: prompt.prompt_text, locale: prompt.locale || input.value.locale || 'en-US', run_index: runIndex, runs_in_series: repeat }) });
|
|
100
|
+
const rawResponse = await response.text();
|
|
101
|
+
if (!response.ok) throw new Error(`citation adapter returned ${response.status}: ${rawResponse.slice(0, 300)}`);
|
|
102
|
+
const result = JSON.parse(rawResponse);
|
|
103
|
+
rows.push({ ...result, prompt_id: prompt.prompt_id, prompt_text: prompt.prompt_text, locale: prompt.locale || input.value.locale || 'en-US', provider: result.provider || options.provider || 'custom-adapter', product_mode: result.product_mode || 'api-adapter', run_index: runIndex, runs_in_series: repeat });
|
|
104
|
+
}
|
|
105
|
+
method = 'live_api';
|
|
106
|
+
}
|
|
107
|
+
const targetOrigin = options.target ? originOf(options.target) : input.value.target_origin || null;
|
|
108
|
+
const observations = [];
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const reviews = canonicalReview(row, targetOrigin);
|
|
111
|
+
observations.push(envelope('citation', {
|
|
112
|
+
prompt_id: row.prompt_id, prompt_text: row.prompt_text, provider: row.provider || row.engine,
|
|
113
|
+
product_mode: row.product_mode || row.interface || 'unknown', locale: row.locale || 'unknown',
|
|
114
|
+
answer_text: row.answer_text || '', citations: reviews, run_index: row.run_index || 1,
|
|
115
|
+
property_cited: reviews.some((r) => r.first_party),
|
|
116
|
+
}, { method, source: options.endpoint || input.file, raw: JSON.stringify(row) }));
|
|
117
|
+
for (const review of reviews) observations.push(envelope('citation_review', review, {
|
|
118
|
+
method: review.reviewer ? 'human_review' : 'static_analysis', source: options.endpoint || input.file,
|
|
119
|
+
state: review.support_status === 'review_required' ? 'review_required' : 'observed',
|
|
120
|
+
confidence: review.reviewer ? 'confirmed' : 'low', raw: JSON.stringify(review),
|
|
121
|
+
limitations: review.reviewer ? [] : ['Material support requires a named human reviewer.'],
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
return observationRun(root, 'observe citations', input.file, observations, { rawInputs: { citation_results: input.raw } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function observeLogs(root, options) {
|
|
128
|
+
const input = readInput(options.input);
|
|
129
|
+
const rows = Array.isArray(input.value) ? input.value : input.value.requests || [];
|
|
130
|
+
const ranges = input.value.provider_ranges || {};
|
|
131
|
+
const verify = (row) => {
|
|
132
|
+
const cidrs = ranges[row.user_agent] || [];
|
|
133
|
+
if (!row.source_ip || !cidrs.length) return { verified: false, method: null };
|
|
134
|
+
const family = net.isIP(row.source_ip);
|
|
135
|
+
if (!family) return { verified: false, method: 'invalid source IP' };
|
|
136
|
+
const block = new net.BlockList();
|
|
137
|
+
for (const cidr of cidrs) {
|
|
138
|
+
const [network, prefix] = cidr.split('/');
|
|
139
|
+
const rangeFamily = net.isIP(network);
|
|
140
|
+
if (rangeFamily === family && Number.isInteger(Number(prefix))) block.addSubnet(network, Number(prefix), family === 4 ? 'ipv4' : 'ipv6');
|
|
141
|
+
}
|
|
142
|
+
return { verified: block.check(row.source_ip, family === 4 ? 'ipv4' : 'ipv6'), method: `matched imported ${row.user_agent} CIDR set` };
|
|
143
|
+
};
|
|
144
|
+
const observations = rows.map((row) => {
|
|
145
|
+
const identity = verify(row);
|
|
146
|
+
return envelope('crawler_log', {
|
|
147
|
+
timestamp: row.timestamp, url: row.url, user_agent: row.user_agent, source_ip: row.source_ip,
|
|
148
|
+
status: row.status, bytes: row.bytes ?? null, latency_ms: row.latency_ms ?? null,
|
|
149
|
+
cache_status: row.cache_status ?? null, region: row.region ?? null,
|
|
150
|
+
identity_verified: identity.verified, verification_method: identity.method,
|
|
151
|
+
}, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: identity.verified ? 'high' : 'low', limitations: identity.verified ? ['CIDR ranges were imported with the log evidence; their provider currency must be established by source metadata.'] : ['User-agent strings alone do not verify crawler identity.'] });
|
|
152
|
+
});
|
|
153
|
+
return observationRun(root, 'observe logs', input.file, observations, { rawInputs: { server_logs: input.raw }, incomplete: observations.some((o) => !o.data.identity_verified) ? ['Some crawler identities are not IP-verified.'] : [] });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function observePassages(root, options) {
|
|
157
|
+
const ctx = await buildContext(root, options);
|
|
158
|
+
if (!ctx.site) throw new Error('passages requires --target <dir|url>');
|
|
159
|
+
const observations = [];
|
|
160
|
+
for (const page of ctx.site.pages) {
|
|
161
|
+
const rawWords = words(page.text).length;
|
|
162
|
+
for (let i = 0; i < page.paragraphs.length; i++) {
|
|
163
|
+
const passage = page.paragraphs.slice(i, i + 3).join(' ');
|
|
164
|
+
const count = words(passage).length;
|
|
165
|
+
if (count < 30) continue;
|
|
166
|
+
const dependencies = [];
|
|
167
|
+
if (/\b(this|that|these|those|it|they|above|below|here)\b/i.test(passage.slice(0, 100))) dependencies.push('possible external referent');
|
|
168
|
+
observations.push(envelope('passage', { url: page.url, passage_index: i, text: passage, word_count: count, independently_extractable: count >= 100 && count <= 300 && dependencies.length === 0, dependencies, content_to_noise_ratio: page.rawHtml.length ? Number((page.text.length / page.rawHtml.length).toFixed(3)) : null }, { method: 'static_analysis', source: page.sourceFile || page.url, raw: passage, confidence: 'medium' }));
|
|
169
|
+
}
|
|
170
|
+
if (!page.paragraphs.length) observations.push(envelope('passage', { url: page.url, word_count: rawWords, independently_extractable: false, dependencies: ['no paragraph passages extracted'] }, { method: 'static_analysis', source: page.sourceFile || page.url, state: 'not_observed', confidence: 'confirmed' }));
|
|
171
|
+
}
|
|
172
|
+
return observationRun(root, 'observe passages', options.target, observations);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function observeConsensus(root, options) {
|
|
176
|
+
const ctx = await buildContext(root, options);
|
|
177
|
+
if (!ctx.site) throw new Error('consensus requires --target <dir|url>');
|
|
178
|
+
const sitemapRows = ctx.site.sitemaps.flatMap((s) => s.parsed?.urls || []);
|
|
179
|
+
const sitemapUrls = new Set(sitemapRows.map((u) => u.loc));
|
|
180
|
+
const observations = ctx.site.pages.map((page) => {
|
|
181
|
+
const declared = page.canonicals[0] || null;
|
|
182
|
+
const sitemapDate = sitemapRows.find((row) => row.loc === page.url)?.lastmod || null;
|
|
183
|
+
const visibleDate = page.metas['article:modified_time']?.[0] || page.metas['date.modified']?.[0] || page.metas['last-modified']?.[0] || null;
|
|
184
|
+
const signals = { final_url: page.url, html_canonical: declared, open_graph_url: page.ogUrl, sitemap_present: sitemapUrls.has(page.url), last_modified_header: page.headers['last-modified'] || null, sitemap_lastmod: sitemapDate, visible_or_meta_modified: visibleDate };
|
|
185
|
+
const urls = [page.url, declared, page.ogUrl].filter(Boolean);
|
|
186
|
+
const dates = [signals.last_modified_header, sitemapDate, visibleDate].filter(Boolean).map((value) => String(value).slice(0, 10));
|
|
187
|
+
return envelope('canonical_freshness', { url: page.url, signals, canonical_consensus: new Set(urls).size <= 1, date_consensus: dates.length >= 2 ? new Set(dates).size === 1 : null, engine_selected_canonical: null }, { method: 'static_analysis', source: page.sourceFile || page.url, raw: JSON.stringify(signals), confidence: 'high', limitations: ['Engine-selected canonical and content-difference date require external observations.'] });
|
|
188
|
+
});
|
|
189
|
+
return observationRun(root, 'observe consensus', options.target, observations);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function observePerformance(root, options) {
|
|
193
|
+
if (options.input) {
|
|
194
|
+
const input = readInput(options.input);
|
|
195
|
+
const rows = Array.isArray(input.value) ? input.value : [input.value];
|
|
196
|
+
const observations = rows.map((row) => {
|
|
197
|
+
const provider = row.provider || (row.lighthouseVersion || row.categories ? 'Lighthouse' : row.record ? 'CrUX' : 'unknown');
|
|
198
|
+
const evidenceType = /lighthouse/i.test(provider) ? 'lab' : /crux|chrome ux/i.test(provider) ? 'field' : 'imported_unknown';
|
|
199
|
+
return envelope('performance', { ...row, provider, evidence_type: evidenceType }, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), limitations: evidenceType === 'imported_unknown' ? ['Performance evidence was not identified as field or lab data.'] : [] });
|
|
200
|
+
});
|
|
201
|
+
return observationRun(root, 'observe performance', input.file, observations, { rawInputs: { performance_export: input.raw } });
|
|
202
|
+
}
|
|
203
|
+
if (!options.target) throw new Error('performance requires --target <URL> or --input <json>');
|
|
204
|
+
const key = options.apiKey || process.env.CRUX_API_KEY;
|
|
205
|
+
if (!key) {
|
|
206
|
+
const item = envelope('performance', { url: options.target, provider: 'CrUX' }, { method: 'live_api', source: 'CrUX API', state: 'not_evidenced', confidence: 'unknown', limitations: ['CRUX_API_KEY is not configured.'] });
|
|
207
|
+
return observationRun(root, 'observe performance', options.target, [item], { incomplete: ['CrUX collection unavailable: CRUX_API_KEY is not configured.'] });
|
|
208
|
+
}
|
|
209
|
+
const response = await fetch(`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${encodeURIComponent(key)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: options.target }) });
|
|
210
|
+
const raw = await response.text();
|
|
211
|
+
if (!response.ok) throw new Error(`CrUX API returned ${response.status}: ${raw.slice(0, 300)}`);
|
|
212
|
+
const data = JSON.parse(raw);
|
|
213
|
+
return observationRun(root, 'observe performance', options.target, [envelope('performance', { ...data, provider: 'CrUX', evidence_type: 'field' }, { method: 'live_api', source: 'CrUX API', raw })], { rawInputs: { crux_response: raw } });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function observeCorroboration(root, options) {
|
|
217
|
+
const input = readInput(options.input);
|
|
218
|
+
const rows = Array.isArray(input.value) ? input.value : input.value.mentions || [];
|
|
219
|
+
const observations = rows.map((row) => envelope('corroboration', row, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: row.independent === true ? 'high' : 'low', limitations: row.independent === true ? [] : ['Source independence is not established.'] }));
|
|
220
|
+
return observationRun(root, 'observe corroboration', input.file, observations, { rawInputs: { corroboration_export: input.raw } });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function observe(root, mode, options = {}) {
|
|
224
|
+
switch (mode) {
|
|
225
|
+
case 'render': return observeRender(root, options);
|
|
226
|
+
case 'index': return observeIndex(root, options);
|
|
227
|
+
case 'citations': return observeCitations(root, options);
|
|
228
|
+
case 'logs': return observeLogs(root, options);
|
|
229
|
+
case 'passages': return observePassages(root, options);
|
|
230
|
+
case 'consensus': return observeConsensus(root, options);
|
|
231
|
+
case 'performance': return observePerformance(root, options);
|
|
232
|
+
case 'corroboration': return observeCorroboration(root, options);
|
|
233
|
+
default: throw new Error('observe mode must be render, index, citations, logs, passages, consensus, performance, or corroboration');
|
|
234
|
+
}
|
|
235
|
+
}
|