@nebulacomponents/citable 1.14.0 → 1.15.1
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 +73 -0
- package/README.md +1 -1
- package/dist/universal/.agents/skills/citable/SKILL.md +11 -6
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +4 -1
- package/dist/universal/.agents/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.agents/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.agents/skills/citable/manifest.json +10 -9
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +11 -6
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +4 -1
- package/dist/universal/.claude/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.claude/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.claude/skills/citable/manifest.json +10 -9
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/SKILL.md +11 -6
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +4 -1
- package/dist/universal/.cursor/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +10 -9
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/SKILL.md +11 -6
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +4 -1
- package/dist/universal/.gemini/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +10 -9
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.github/skills/citable/SKILL.md +11 -6
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +4 -1
- package/dist/universal/.github/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.github/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.github/skills/citable/manifest.json +10 -9
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/SKILL.md +11 -6
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +4 -1
- package/dist/universal/.kiro/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +10 -9
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/SKILL.md +11 -6
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +4 -1
- package/dist/universal/.opencode/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +10 -9
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +11 -6
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +4 -1
- package/dist/universal/.pi/agent/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +10 -9
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/SKILL.md +11 -6
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +4 -1
- package/dist/universal/.qoder/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +10 -9
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/SKILL.md +11 -6
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +4 -1
- package/dist/universal/.rovodev/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +10 -9
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/SKILL.md +11 -6
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +4 -1
- package/dist/universal/.trae/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.trae/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.trae/skills/citable/manifest.json +10 -9
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +11 -6
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +4 -1
- package/dist/universal/.trae-cn/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +10 -9
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/manifest.json +124 -112
- package/package.json +1 -1
- package/release/surfaces.json +2 -2
- package/schemas/observation.schema.json +1 -1
- package/schemas/schedule.schema.json +1 -1
- package/skill/SKILL.md +11 -6
- package/skill/commands/README.md +4 -1
- package/skill/commands/observe-and-act.md +12 -5
- package/skill/commands/reporting.md +57 -0
- package/src/cli/index.js +78 -11
- package/src/commands/connect.js +29 -0
- package/src/commands/context.js +15 -0
- package/src/commands/delivery.js +86 -2
- package/src/commands/monitor.js +35 -0
- package/src/commands/observe.js +3 -1
- package/src/commands/reportDashboard.js +21 -0
- package/src/commands/reportShareOfVoice.js +60 -0
- package/src/connectors/http.js +6 -3
- package/src/connectors/index.js +3 -1
- package/src/connectors/webflow.js +224 -0
- package/src/connectors/wordpress.js +226 -0
- package/src/detectors/geoReco.js +101 -0
- package/src/monitoring/alertDelivery.js +98 -0
- package/src/observations/common.js +10 -0
- package/src/observations/stance.js +261 -0
- package/src/reporting/dashboard.js +254 -0
- package/src/reporting/shareOfVoice.js +362 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
calculateShareOfVoice,
|
|
5
|
+
extractDomain,
|
|
6
|
+
loadCitationRuns,
|
|
7
|
+
renderShareOfVoiceHtml,
|
|
8
|
+
renderShareOfVoiceMarkdown,
|
|
9
|
+
} from '../reporting/shareOfVoice.js';
|
|
10
|
+
import { loadRegistries } from '../registries/index.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `citable report share-of-voice` — compute first-party and competitor citation share
|
|
14
|
+
* across recorded observation runs. Never invents competitor citations; unevidenced
|
|
15
|
+
* prompts and competitors are reported plainly.
|
|
16
|
+
*/
|
|
17
|
+
export function reportShareOfVoice(root, { since, last } = {}) {
|
|
18
|
+
if (last != null && (!Number.isInteger(last) || last < 1)) {
|
|
19
|
+
throw new Error('--last must be a positive integer');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { registries } = loadRegistries(root);
|
|
23
|
+
const competitors = registries.competitors?.entries || [];
|
|
24
|
+
|
|
25
|
+
const firstPartyDomains = new Set();
|
|
26
|
+
if (registries.config?.site?.base_url) {
|
|
27
|
+
const d = extractDomain(registries.config.site.base_url);
|
|
28
|
+
if (d) firstPartyDomains.add(d);
|
|
29
|
+
}
|
|
30
|
+
if (registries.pages?.entries) {
|
|
31
|
+
for (const p of registries.pages.entries) {
|
|
32
|
+
if (p.url) {
|
|
33
|
+
const d = extractDomain(p.url);
|
|
34
|
+
if (d) firstPartyDomains.add(d);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { included, skipped } = loadCitationRuns(root, { since, last });
|
|
40
|
+
const data = calculateShareOfVoice(included, competitors, firstPartyDomains);
|
|
41
|
+
|
|
42
|
+
const dir = path.join(root, '.citable', 'reports');
|
|
43
|
+
const pathMd = path.join(dir, 'share-of-voice.md');
|
|
44
|
+
const pathHtml = path.join(dir, 'share-of-voice.html');
|
|
45
|
+
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(pathMd, renderShareOfVoiceMarkdown(data, skipped));
|
|
48
|
+
fs.writeFileSync(pathHtml, renderShareOfVoiceHtml(data, skipped));
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
dir,
|
|
52
|
+
included: included.length,
|
|
53
|
+
skipped: skipped.length,
|
|
54
|
+
competitors_evaluated: competitors.length,
|
|
55
|
+
prompts_evaluated: data.prompts.length,
|
|
56
|
+
total_citations: data.total_citations_recorded,
|
|
57
|
+
path_md: pathMd,
|
|
58
|
+
path_html: pathHtml,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/connectors/http.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
2
2
|
|
|
3
|
-
export async function providerRequest(url, { token, fetchImpl = globalThis.fetch, method = 'GET', body, attempts = 3 } = {}) {
|
|
3
|
+
export async function providerRequest(url, { token, fetchImpl = globalThis.fetch, method = 'GET', body, headers = {}, attempts = 3, rawResponse = false } = {}) {
|
|
4
4
|
if (!token) throw Object.assign(new Error('authorization is not configured'), { connectorState: 'not_configured' });
|
|
5
5
|
let response;
|
|
6
6
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
7
7
|
response = await fetchImpl(url, {
|
|
8
8
|
method,
|
|
9
|
-
headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
9
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
10
10
|
body: body ? JSON.stringify(body) : undefined,
|
|
11
11
|
});
|
|
12
|
-
if (response.ok)
|
|
12
|
+
if (response.ok) {
|
|
13
|
+
if (rawResponse) return response;
|
|
14
|
+
return response.json();
|
|
15
|
+
}
|
|
13
16
|
const detail = (await response.text()).slice(0, 300);
|
|
14
17
|
if (!RETRYABLE.has(response.status) || attempt === attempts) {
|
|
15
18
|
const states = { 401: 'authorization_expired', 403: 'permission_denied', 429: 'quota_limited' };
|
package/src/connectors/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { gscConnector } from './gsc.js';
|
|
2
2
|
import { ga4Connector } from './ga4.js';
|
|
3
|
+
import { wordpressConnector } from './wordpress.js';
|
|
4
|
+
import { webflowConnector } from './webflow.js';
|
|
3
5
|
|
|
4
|
-
const CONNECTORS = new Map([gscConnector, ga4Connector].map((connector) => [connector.provider, connector]));
|
|
6
|
+
const CONNECTORS = new Map([gscConnector, ga4Connector, wordpressConnector, webflowConnector].map((connector) => [connector.provider, connector]));
|
|
5
7
|
|
|
6
8
|
export function getConnector(provider) {
|
|
7
9
|
const connector = CONNECTORS.get(String(provider).toLowerCase());
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { providerRequest } from './http.js';
|
|
2
|
+
import { sha256, nowIso } from '../shared/io.js';
|
|
3
|
+
|
|
4
|
+
const BASE = 'https://api.webflow.com/v2';
|
|
5
|
+
|
|
6
|
+
const METRICS = {
|
|
7
|
+
pages: { unit: 'count', value_type: 'integer' },
|
|
8
|
+
collections: { unit: 'count', value_type: 'integer' },
|
|
9
|
+
items: { unit: 'count', value_type: 'integer' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function computeWebflowPayloadHash(payload) {
|
|
13
|
+
const canonical = {
|
|
14
|
+
target_id: String(payload.target_id || payload.id),
|
|
15
|
+
site_id: String(payload.site_id || ''),
|
|
16
|
+
title: String(payload.title || ''),
|
|
17
|
+
slug: String(payload.slug || ''),
|
|
18
|
+
meta_title: String(payload.meta_title || ''),
|
|
19
|
+
meta_description: String(payload.meta_description || ''),
|
|
20
|
+
open_graph_title: String(payload.open_graph_title || ''),
|
|
21
|
+
open_graph_description: String(payload.open_graph_description || ''),
|
|
22
|
+
body: String(payload.body || ''),
|
|
23
|
+
};
|
|
24
|
+
return sha256(JSON.stringify(canonical));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const webflowConnector = {
|
|
28
|
+
provider: 'webflow',
|
|
29
|
+
defaultCredentialEnv: 'WEBFLOW_API_TOKEN',
|
|
30
|
+
readOnlyScopes: ['sites:read', 'pages:read', 'cms:read'],
|
|
31
|
+
writeScopes: ['pages:write', 'cms:write'],
|
|
32
|
+
|
|
33
|
+
describeMetrics() {
|
|
34
|
+
return METRICS;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async discoverProperties(context) {
|
|
38
|
+
const result = await providerRequest(`${BASE}/sites`, context);
|
|
39
|
+
const sites = result.sites || [];
|
|
40
|
+
return sites.map((site) => ({
|
|
41
|
+
property_id: site.id,
|
|
42
|
+
display_name: site.displayName || site.id,
|
|
43
|
+
custom_domains: (site.customDomains || []).map((d) => d.url || d.host || d),
|
|
44
|
+
time_zone: site.timeZone || null,
|
|
45
|
+
created_on: site.createdOn || null,
|
|
46
|
+
permission: 'administrator',
|
|
47
|
+
}));
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async validateConnection(connection, context) {
|
|
51
|
+
const site = await providerRequest(`${BASE}/sites/${encodeURIComponent(connection.property_id)}`, context);
|
|
52
|
+
const valid = Boolean(site && site.id === connection.property_id);
|
|
53
|
+
return {
|
|
54
|
+
valid,
|
|
55
|
+
site_id: site?.id || connection.property_id,
|
|
56
|
+
display_name: site?.displayName || null,
|
|
57
|
+
last_published: site?.lastPublished || null,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async sync(connection, metrics, { startDate, endDate, ...context }) {
|
|
62
|
+
const siteId = connection.property_id;
|
|
63
|
+
const rows = [];
|
|
64
|
+
const metricNames = new Set(metrics.map((m) => m.external_name));
|
|
65
|
+
|
|
66
|
+
if (metricNames.has('pages')) {
|
|
67
|
+
const pageResult = await providerRequest(`${BASE}/sites/${encodeURIComponent(siteId)}/pages`, context);
|
|
68
|
+
const pages = pageResult.pages || [];
|
|
69
|
+
const pageMetric = metrics.find((m) => m.external_name === 'pages');
|
|
70
|
+
rows.push({
|
|
71
|
+
metric: pageMetric,
|
|
72
|
+
value: pages.length,
|
|
73
|
+
dimensions: { date: endDate },
|
|
74
|
+
observed_at: `${endDate}T00:00:00.000Z`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (metricNames.has('collections') || metricNames.has('items')) {
|
|
79
|
+
const collectionsResult = await providerRequest(`${BASE}/sites/${encodeURIComponent(siteId)}/collections`, context);
|
|
80
|
+
const collections = collectionsResult.collections || [];
|
|
81
|
+
|
|
82
|
+
if (metricNames.has('collections')) {
|
|
83
|
+
const colMetric = metrics.find((m) => m.external_name === 'collections');
|
|
84
|
+
rows.push({
|
|
85
|
+
metric: colMetric,
|
|
86
|
+
value: collections.length,
|
|
87
|
+
dimensions: { date: endDate },
|
|
88
|
+
observed_at: `${endDate}T00:00:00.000Z`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (metricNames.has('items')) {
|
|
93
|
+
let totalItems = 0;
|
|
94
|
+
for (const col of collections) {
|
|
95
|
+
const itemsResult = await providerRequest(`${BASE}/collections/${encodeURIComponent(col.id)}/items?limit=100`, context);
|
|
96
|
+
totalItems += (itemsResult.items || []).length;
|
|
97
|
+
}
|
|
98
|
+
const itemMetric = metrics.find((m) => m.external_name === 'items');
|
|
99
|
+
rows.push({
|
|
100
|
+
metric: itemMetric,
|
|
101
|
+
value: totalItems,
|
|
102
|
+
dimensions: { date: endDate },
|
|
103
|
+
observed_at: `${endDate}T00:00:00.000Z`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
rows,
|
|
110
|
+
cursor: endDate,
|
|
111
|
+
limitations: [
|
|
112
|
+
'Webflow API enforces rate limits of 60 requests per minute.',
|
|
113
|
+
'Staged CMS changes require site publish to appear on live domains.',
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read Webflow page or collection item content and SEO metadata with hash integrity.
|
|
120
|
+
*/
|
|
121
|
+
async readContent(connection, targetId, context) {
|
|
122
|
+
if (!targetId) throw new Error('Webflow readContent requires targetId');
|
|
123
|
+
const page = await providerRequest(`${BASE}/pages/${encodeURIComponent(targetId)}`, context);
|
|
124
|
+
|
|
125
|
+
const payload = {
|
|
126
|
+
target_id: String(page.id),
|
|
127
|
+
site_id: page.siteId || connection.property_id,
|
|
128
|
+
title: page.title || '',
|
|
129
|
+
slug: page.slug || '',
|
|
130
|
+
meta_title: page.seo?.title || page.title || '',
|
|
131
|
+
meta_description: page.seo?.description || '',
|
|
132
|
+
open_graph_title: page.openGraph?.title || null,
|
|
133
|
+
open_graph_description: page.openGraph?.description || null,
|
|
134
|
+
body: page.body || '',
|
|
135
|
+
draft: Boolean(page.draft),
|
|
136
|
+
archived: Boolean(page.archived),
|
|
137
|
+
published: Boolean(page.published),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const contentHash = computeWebflowPayloadHash(payload);
|
|
141
|
+
return { ...payload, content_hash: contentHash };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Apply reviewed, hash-locked remediation to a Webflow page.
|
|
146
|
+
*/
|
|
147
|
+
async applyRemediation(connection, remediation, context = {}) {
|
|
148
|
+
if (!remediation.reviewer) {
|
|
149
|
+
throw new Error('CMS remediation requires a named human reviewer per governance rules');
|
|
150
|
+
}
|
|
151
|
+
if (!remediation.expected_hash) {
|
|
152
|
+
throw new Error('CMS remediation requires expected_hash for hash-locking');
|
|
153
|
+
}
|
|
154
|
+
if (!remediation.target_id) {
|
|
155
|
+
throw new Error('CMS remediation requires target_id');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const current = await this.readContent(connection, remediation.target_id, context);
|
|
159
|
+
if (current.content_hash !== remediation.expected_hash) {
|
|
160
|
+
throw new Error(`stale remediation refused for Webflow ${remediation.target_id}: content hash changed (${current.content_hash} !== ${remediation.expected_hash})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const updates = remediation.updates || {};
|
|
164
|
+
const candidatePayload = {
|
|
165
|
+
...current,
|
|
166
|
+
title: updates.title !== undefined ? updates.title : current.title,
|
|
167
|
+
slug: updates.slug !== undefined ? updates.slug : current.slug,
|
|
168
|
+
meta_title: updates.meta_title !== undefined ? updates.meta_title : current.meta_title,
|
|
169
|
+
meta_description: updates.meta_description !== undefined ? updates.meta_description : current.meta_description,
|
|
170
|
+
open_graph_title: updates.open_graph_title !== undefined ? updates.open_graph_title : current.open_graph_title,
|
|
171
|
+
open_graph_description: updates.open_graph_description !== undefined ? updates.open_graph_description : current.open_graph_description,
|
|
172
|
+
body: updates.body !== undefined ? updates.body : current.body,
|
|
173
|
+
};
|
|
174
|
+
const simulatedHash = computeWebflowPayloadHash(candidatePayload);
|
|
175
|
+
|
|
176
|
+
// Dry-run mode by default
|
|
177
|
+
if (!context.write) {
|
|
178
|
+
return {
|
|
179
|
+
status: 'proposed',
|
|
180
|
+
dry_run: true,
|
|
181
|
+
target_id: remediation.target_id,
|
|
182
|
+
before_hash: current.content_hash,
|
|
183
|
+
after_hash: simulatedHash,
|
|
184
|
+
updates,
|
|
185
|
+
reviewer: remediation.reviewer,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Live update via PATCH
|
|
190
|
+
const body = {};
|
|
191
|
+
if (updates.title !== undefined) body.title = updates.title;
|
|
192
|
+
if (updates.slug !== undefined) body.slug = updates.slug;
|
|
193
|
+
if (updates.body !== undefined) body.body = updates.body;
|
|
194
|
+
if (updates.meta_title !== undefined || updates.meta_description !== undefined) {
|
|
195
|
+
body.seo = {
|
|
196
|
+
title: updates.meta_title !== undefined ? updates.meta_title : current.meta_title,
|
|
197
|
+
description: updates.meta_description !== undefined ? updates.meta_description : current.meta_description,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (updates.open_graph_title !== undefined || updates.open_graph_description !== undefined) {
|
|
201
|
+
body.openGraph = {
|
|
202
|
+
title: updates.open_graph_title !== undefined ? updates.open_graph_title : current.open_graph_title,
|
|
203
|
+
description: updates.open_graph_description !== undefined ? updates.open_graph_description : current.open_graph_description,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await providerRequest(`${BASE}/pages/${encodeURIComponent(remediation.target_id)}`, {
|
|
208
|
+
...context,
|
|
209
|
+
method: 'PATCH',
|
|
210
|
+
body,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const updated = await this.readContent(connection, remediation.target_id, context);
|
|
214
|
+
return {
|
|
215
|
+
status: 'applied',
|
|
216
|
+
dry_run: false,
|
|
217
|
+
target_id: remediation.target_id,
|
|
218
|
+
before_hash: current.content_hash,
|
|
219
|
+
after_hash: updated.content_hash,
|
|
220
|
+
updated_at: nowIso(),
|
|
221
|
+
reviewer: remediation.reviewer,
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { providerRequest } from './http.js';
|
|
2
|
+
import { sha256, nowIso } from '../shared/io.js';
|
|
3
|
+
|
|
4
|
+
const METRICS = {
|
|
5
|
+
published_posts: { unit: 'count', value_type: 'integer' },
|
|
6
|
+
published_pages: { unit: 'count', value_type: 'integer' },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function normalizeBaseUrl(propertyId) {
|
|
10
|
+
if (!propertyId) throw new Error('WordPress property_id must be the base site or API URL');
|
|
11
|
+
return String(propertyId).replace(/\/+$/, '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function computeCmsPayloadHash(payload) {
|
|
15
|
+
const canonical = {
|
|
16
|
+
target_id: String(payload.target_id || payload.id),
|
|
17
|
+
title: String(payload.title || ''),
|
|
18
|
+
content: String(payload.content || ''),
|
|
19
|
+
excerpt: String(payload.excerpt || ''),
|
|
20
|
+
slug: String(payload.slug || ''),
|
|
21
|
+
status: String(payload.status || 'publish'),
|
|
22
|
+
meta: payload.meta || {},
|
|
23
|
+
schema_data: payload.schema_data || null,
|
|
24
|
+
};
|
|
25
|
+
return sha256(JSON.stringify(canonical));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const wordpressConnector = {
|
|
29
|
+
provider: 'wordpress',
|
|
30
|
+
defaultCredentialEnv: 'WORDPRESS_AUTH_TOKEN',
|
|
31
|
+
readOnlyScopes: [
|
|
32
|
+
'https://api.wordpress.org/auth/posts.readonly',
|
|
33
|
+
'https://api.wordpress.org/auth/pages.readonly',
|
|
34
|
+
],
|
|
35
|
+
writeScopes: [
|
|
36
|
+
'https://api.wordpress.org/auth/posts.edit',
|
|
37
|
+
'https://api.wordpress.org/auth/pages.edit',
|
|
38
|
+
],
|
|
39
|
+
|
|
40
|
+
describeMetrics() {
|
|
41
|
+
return METRICS;
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async discoverProperties(context) {
|
|
45
|
+
const targetUrl = context.property_id || context.siteUrl || context.url;
|
|
46
|
+
if (!targetUrl) {
|
|
47
|
+
return [{
|
|
48
|
+
property_id: 'https://example.test',
|
|
49
|
+
display_name: 'WordPress Site (configure with site URL)',
|
|
50
|
+
permission: 'administrator',
|
|
51
|
+
}];
|
|
52
|
+
}
|
|
53
|
+
const base = normalizeBaseUrl(targetUrl);
|
|
54
|
+
const siteInfo = await providerRequest(`${base}/wp-json`, context);
|
|
55
|
+
return [{
|
|
56
|
+
property_id: siteInfo.url || base,
|
|
57
|
+
display_name: siteInfo.name || base,
|
|
58
|
+
description: siteInfo.description || null,
|
|
59
|
+
home: siteInfo.home || null,
|
|
60
|
+
permission: 'administrator',
|
|
61
|
+
}];
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async validateConnection(connection, context) {
|
|
65
|
+
const base = normalizeBaseUrl(connection.property_id);
|
|
66
|
+
const user = await providerRequest(`${base}/wp-json/wp/v2/users/me?context=edit`, context);
|
|
67
|
+
const valid = Boolean(user && (user.id || user.name));
|
|
68
|
+
return {
|
|
69
|
+
valid,
|
|
70
|
+
user: user?.name || null,
|
|
71
|
+
roles: user?.roles || [],
|
|
72
|
+
capabilities: user?.capabilities || {},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async sync(connection, metrics, { startDate, endDate, ...context }) {
|
|
77
|
+
const base = normalizeBaseUrl(connection.property_id);
|
|
78
|
+
const rows = [];
|
|
79
|
+
const metricNames = new Set(metrics.map((m) => m.external_name));
|
|
80
|
+
|
|
81
|
+
if (metricNames.has('published_posts')) {
|
|
82
|
+
const posts = await providerRequest(`${base}/wp-json/wp/v2/posts?per_page=100&status=publish`, context);
|
|
83
|
+
const postMetric = metrics.find((m) => m.external_name === 'published_posts');
|
|
84
|
+
rows.push({
|
|
85
|
+
metric: postMetric,
|
|
86
|
+
value: Array.isArray(posts) ? posts.length : 0,
|
|
87
|
+
dimensions: { date: endDate },
|
|
88
|
+
observed_at: `${endDate}T00:00:00.000Z`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (metricNames.has('published_pages')) {
|
|
93
|
+
const pages = await providerRequest(`${base}/wp-json/wp/v2/pages?per_page=100&status=publish`, context);
|
|
94
|
+
const pageMetric = metrics.find((m) => m.external_name === 'published_pages');
|
|
95
|
+
rows.push({
|
|
96
|
+
metric: pageMetric,
|
|
97
|
+
value: Array.isArray(pages) ? pages.length : 0,
|
|
98
|
+
dimensions: { date: endDate },
|
|
99
|
+
observed_at: `${endDate}T00:00:00.000Z`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
rows,
|
|
105
|
+
cursor: endDate,
|
|
106
|
+
limitations: [
|
|
107
|
+
'WordPress REST API collection is bounded to accessible posts and pages.',
|
|
108
|
+
'Custom post types outside standard posts and pages require dedicated endpoint parameters.',
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read CMS page/post content, metadata, and structured data with hash integrity.
|
|
115
|
+
*/
|
|
116
|
+
async readContent(connection, targetId, context) {
|
|
117
|
+
if (!targetId) throw new Error('WordPress readContent requires targetId');
|
|
118
|
+
const base = normalizeBaseUrl(connection.property_id);
|
|
119
|
+
let item;
|
|
120
|
+
try {
|
|
121
|
+
item = await providerRequest(`${base}/wp-json/wp/v2/pages/${encodeURIComponent(targetId)}?context=edit`, context);
|
|
122
|
+
} catch {
|
|
123
|
+
// Fall back to posts if not a page
|
|
124
|
+
item = await providerRequest(`${base}/wp-json/wp/v2/posts/${encodeURIComponent(targetId)}?context=edit`, context);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const payload = {
|
|
128
|
+
target_id: String(item.id),
|
|
129
|
+
type: item.type || 'page',
|
|
130
|
+
url: item.link || '',
|
|
131
|
+
slug: item.slug || '',
|
|
132
|
+
status: item.status || 'publish',
|
|
133
|
+
title: item.title?.raw ?? item.title?.rendered ?? '',
|
|
134
|
+
content: item.content?.raw ?? item.content?.rendered ?? '',
|
|
135
|
+
excerpt: item.excerpt?.raw ?? item.excerpt?.rendered ?? '',
|
|
136
|
+
meta: item.meta || {},
|
|
137
|
+
schema_data: item.meta?.schema_data || null,
|
|
138
|
+
yoast_title: item.meta?._yoast_wpseo_title || null,
|
|
139
|
+
yoast_metadesc: item.meta?._yoast_wpseo_metadesc || null,
|
|
140
|
+
modified_gmt: item.modified_gmt || null,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const contentHash = computeCmsPayloadHash(payload);
|
|
144
|
+
return { ...payload, content_hash: contentHash };
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Apply reviewed, hash-locked remediation to WordPress page or post.
|
|
149
|
+
*/
|
|
150
|
+
async applyRemediation(connection, remediation, context = {}) {
|
|
151
|
+
if (!remediation.reviewer) {
|
|
152
|
+
throw new Error('CMS remediation requires a named human reviewer per governance rules');
|
|
153
|
+
}
|
|
154
|
+
if (!remediation.expected_hash) {
|
|
155
|
+
throw new Error('CMS remediation requires expected_hash for hash-locking');
|
|
156
|
+
}
|
|
157
|
+
if (!remediation.target_id) {
|
|
158
|
+
throw new Error('CMS remediation requires target_id');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const current = await this.readContent(connection, remediation.target_id, context);
|
|
162
|
+
if (current.content_hash !== remediation.expected_hash) {
|
|
163
|
+
throw new Error(`stale remediation refused for WordPress ${remediation.target_id}: content hash changed (${current.content_hash} !== ${remediation.expected_hash})`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const updates = remediation.updates || {};
|
|
167
|
+
const candidatePayload = {
|
|
168
|
+
...current,
|
|
169
|
+
title: updates.title !== undefined ? updates.title : current.title,
|
|
170
|
+
content: updates.content !== undefined ? updates.content : current.content,
|
|
171
|
+
excerpt: updates.excerpt !== undefined ? updates.excerpt : current.excerpt,
|
|
172
|
+
slug: updates.slug !== undefined ? updates.slug : current.slug,
|
|
173
|
+
status: updates.status !== undefined ? updates.status : current.status,
|
|
174
|
+
meta: updates.meta !== undefined ? updates.meta : current.meta,
|
|
175
|
+
schema_data: updates.schema_data !== undefined ? updates.schema_data : current.schema_data,
|
|
176
|
+
};
|
|
177
|
+
const simulatedHash = computeCmsPayloadHash(candidatePayload);
|
|
178
|
+
|
|
179
|
+
// Dry-run mode by default
|
|
180
|
+
if (!context.write) {
|
|
181
|
+
return {
|
|
182
|
+
status: 'proposed',
|
|
183
|
+
dry_run: true,
|
|
184
|
+
target_id: remediation.target_id,
|
|
185
|
+
before_hash: current.content_hash,
|
|
186
|
+
after_hash: simulatedHash,
|
|
187
|
+
updates,
|
|
188
|
+
reviewer: remediation.reviewer,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Live update
|
|
193
|
+
const base = normalizeBaseUrl(connection.property_id);
|
|
194
|
+
const endpoint = current.type === 'post' ? 'posts' : 'pages';
|
|
195
|
+
const body = {};
|
|
196
|
+
if (updates.title !== undefined) body.title = updates.title;
|
|
197
|
+
if (updates.content !== undefined) body.content = updates.content;
|
|
198
|
+
if (updates.excerpt !== undefined) body.excerpt = updates.excerpt;
|
|
199
|
+
if (updates.slug !== undefined) body.slug = updates.slug;
|
|
200
|
+
if (updates.status !== undefined) body.status = updates.status;
|
|
201
|
+
if (updates.meta !== undefined || updates.schema_data !== undefined) {
|
|
202
|
+
body.meta = {
|
|
203
|
+
...(current.meta || {}),
|
|
204
|
+
...(updates.meta || {}),
|
|
205
|
+
...(updates.schema_data !== undefined ? { schema_data: updates.schema_data } : {}),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await providerRequest(`${base}/wp-json/wp/v2/${endpoint}/${encodeURIComponent(remediation.target_id)}`, {
|
|
210
|
+
...context,
|
|
211
|
+
method: 'POST',
|
|
212
|
+
body,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const updated = await this.readContent(connection, remediation.target_id, context);
|
|
216
|
+
return {
|
|
217
|
+
status: 'applied',
|
|
218
|
+
dry_run: false,
|
|
219
|
+
target_id: remediation.target_id,
|
|
220
|
+
before_hash: current.content_hash,
|
|
221
|
+
after_hash: updated.content_hash,
|
|
222
|
+
updated_at: nowIso(),
|
|
223
|
+
reviewer: remediation.reviewer,
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
};
|
package/src/detectors/geoReco.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineDetector, indexTargets, registryPageFor, sitePageFor, pageSubject, entrySubject } from './framework.js';
|
|
2
|
+
import { classifyAnswerStance } from '../observations/stance.js';
|
|
2
3
|
|
|
3
4
|
const D = [];
|
|
4
5
|
|
|
@@ -223,6 +224,106 @@ D.push(defineDetector({
|
|
|
223
224
|
},
|
|
224
225
|
}));
|
|
225
226
|
|
|
227
|
+
D.push(defineDetector({
|
|
228
|
+
id: 'GEO-007', name: 'Unfavorable or distorted entity stance in recorded generative answers', namespace: 'GEO',
|
|
229
|
+
description: 'Recorded generative engine answers express an unfavorable, negative, or critically distorted stance toward a registered entity.',
|
|
230
|
+
discipline: ['geo', 'aeo'], severity: 'high', deterministic: false, requires: ['registries'],
|
|
231
|
+
finding_type: 'evidence_backed_semantic_finding',
|
|
232
|
+
impact: { representation: 'high', reputational: 'high', conversion: 'medium' },
|
|
233
|
+
applicable_requirement: 'GEO §6: accurate synthesis and entity representation; Rubric: narrative accuracy (§4 & §5)',
|
|
234
|
+
false_positive_conditions: ['Legitimate negative comparison where a product does not support a feature by design'],
|
|
235
|
+
remediation: 'Review the recorded answers against the narrative-accuracy rubric, identify whether the negative characterization reflects true product limitations or factual inaccuracies/outdated information, and publish authoritative corroborating evidence or file model corrections.',
|
|
236
|
+
unsafe_shortcuts: ['publishing fabricated positive reviews', 'manipulating third-party review sites', 'crawler prompt injection (GEO-001)'],
|
|
237
|
+
verification: 'Follow-up generative engine observations demonstrate neutral or favorable stance with no narrative-accuracy violations.',
|
|
238
|
+
review_required: true,
|
|
239
|
+
check(ctx) {
|
|
240
|
+
const entities = (ctx.registries?.entities?.entries || []).filter((e) => e.status !== 'retired');
|
|
241
|
+
if (!entities.length) return [];
|
|
242
|
+
const hits = [];
|
|
243
|
+
|
|
244
|
+
// 1. Check promptResults
|
|
245
|
+
for (const pr of ctx.promptResults || []) {
|
|
246
|
+
for (const e of entities) {
|
|
247
|
+
const names = [e.canonical_name, ...(e.aliases || [])].map((n) => n.toLowerCase());
|
|
248
|
+
const text = `${pr.prompt_text || ''} ${pr.answer_text || ''}`.toLowerCase();
|
|
249
|
+
const mentioned = names.some((n) => text.includes(n));
|
|
250
|
+
if (!mentioned) continue;
|
|
251
|
+
|
|
252
|
+
const classification = classifyAnswerStance(pr.answer_text || '', e, { reviewer: pr.evaluator });
|
|
253
|
+
const isNegativeSentiment = pr.sentiment === 'negative' || pr.recommendation_status === 'not_recommended';
|
|
254
|
+
if (classification.stance === 'unfavorable' || isNegativeSentiment) {
|
|
255
|
+
hits.push({
|
|
256
|
+
subject: entrySubject('entities', e.entity_id),
|
|
257
|
+
summary: `Generative engine ${pr.engine || 'unknown'} expresses unfavorable stance toward entity "${e.canonical_name}"`,
|
|
258
|
+
evidence: [
|
|
259
|
+
`engine: ${pr.engine || 'unknown'}`,
|
|
260
|
+
`prompt: ${pr.prompt_text || pr.prompt_id || 'unknown'}`,
|
|
261
|
+
`stance: unfavorable`,
|
|
262
|
+
`markers: ${classification.unfavorable_markers?.join(', ') || 'recorded negative sentiment'}`,
|
|
263
|
+
`excerpt: "${(classification.evidence_passages?.[0] || pr.answer_text || '').slice(0, 160)}"`,
|
|
264
|
+
`reviewer: ${pr.evaluator || 'unreviewed (semantic review required)'}`,
|
|
265
|
+
],
|
|
266
|
+
confidence: pr.evaluator ? 'confirmed' : 'medium',
|
|
267
|
+
finding_type: 'evidence_backed_semantic_finding',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. Check observations (stance or citation)
|
|
274
|
+
for (const obs of ctx.observations || []) {
|
|
275
|
+
if (obs.kind === 'stance' && obs.data?.stance === 'unfavorable') {
|
|
276
|
+
const e = entities.find((ent) => ent.entity_id === obs.data.entity_id);
|
|
277
|
+
if (e) {
|
|
278
|
+
hits.push({
|
|
279
|
+
subject: entrySubject('entities', e.entity_id),
|
|
280
|
+
summary: `Generative engine ${obs.data.engine || 'unknown'} expresses unfavorable stance toward entity "${e.canonical_name}"`,
|
|
281
|
+
evidence: [
|
|
282
|
+
`engine: ${obs.data.engine || 'unknown'}`,
|
|
283
|
+
`prompt: ${obs.data.prompt_text || obs.data.prompt_id || 'unknown'}`,
|
|
284
|
+
`stance: unfavorable`,
|
|
285
|
+
`markers: ${(obs.data.unfavorable_markers || []).join(', ') || 'unfavorable markers'}`,
|
|
286
|
+
`excerpt: "${(obs.data.evidence_passages?.[0] || '').slice(0, 160)}"`,
|
|
287
|
+
`reviewer: ${obs.data.reviewer || 'unreviewed (semantic review required)'}`,
|
|
288
|
+
],
|
|
289
|
+
confidence: obs.data.reviewer ? 'confirmed' : 'medium',
|
|
290
|
+
finding_type: 'evidence_backed_semantic_finding',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
} else if (obs.kind === 'citation' && obs.data?.answer_text) {
|
|
294
|
+
for (const e of entities) {
|
|
295
|
+
const classification = classifyAnswerStance(obs.data.answer_text, e);
|
|
296
|
+
if (classification.mentioned && classification.stance === 'unfavorable') {
|
|
297
|
+
hits.push({
|
|
298
|
+
subject: entrySubject('entities', e.entity_id),
|
|
299
|
+
summary: `Generative engine ${obs.data.provider || 'unknown'} expresses unfavorable stance toward entity "${e.canonical_name}"`,
|
|
300
|
+
evidence: [
|
|
301
|
+
`engine: ${obs.data.provider || 'unknown'}`,
|
|
302
|
+
`prompt: ${obs.data.prompt_text || obs.data.prompt_id || 'unknown'}`,
|
|
303
|
+
`stance: unfavorable`,
|
|
304
|
+
`markers: ${(classification.unfavorable_markers || []).join(', ')}`,
|
|
305
|
+
`excerpt: "${(classification.evidence_passages?.[0] || obs.data.answer_text).slice(0, 160)}"`,
|
|
306
|
+
`reviewer: unreviewed (semantic review required)`,
|
|
307
|
+
],
|
|
308
|
+
confidence: 'medium',
|
|
309
|
+
finding_type: 'evidence_backed_semantic_finding',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Deduplicate by entity and summary
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
return hits.filter((h) => {
|
|
319
|
+
const k = `${h.subject.identifier}|${h.summary}`;
|
|
320
|
+
if (seen.has(k)) return false;
|
|
321
|
+
seen.add(k);
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
}));
|
|
326
|
+
|
|
226
327
|
/* ---------------- RECO: recommendation eligibility ---------------- */
|
|
227
328
|
|
|
228
329
|
const RECO_CHECKS = [
|