@aiguru/google-web-operations 0.6.4
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/.env.example +42 -0
- package/README.md +110 -0
- package/apps/cli/src/index.js +286 -0
- package/apps/mcp-server/src/index.js +360 -0
- package/apps/worker/src/index.js +96 -0
- package/apps/worker/src/loop.js +46 -0
- package/bin/gwo.js +67 -0
- package/docs/BOOTSTRAP-NEXT-PROMPT.md +57 -0
- package/docs/GETTING-STARTED.he.md +63 -0
- package/docs/HOSTED-WORKER.md +36 -0
- package/docs/INSTALL.he.md +57 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.2.md +153 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.3.md +86 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.4.md +92 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.5.md +94 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.6.md +98 -0
- package/docs/SECURITY.md +69 -0
- package/docs/SITE-ADAPTERS.md +87 -0
- package/docs/adr/ADR-001-shared-mcp-core.md +27 -0
- package/docs/adr/ADR-002-policy-gated-writes.md +18 -0
- package/docs/adr/ADR-003-single-source-playbooks.md +18 -0
- package/docs/adr/ADR-004-native-typescript-runtime.md +16 -0
- package/docs/adr/ADR-005-two-token-publish.md +22 -0
- package/docs/adr/ADR-006-one-package-one-installer.md +20 -0
- package/hosts/claude/.claude-plugin/plugin.json +9 -0
- package/hosts/claude/.mcp.json +10 -0
- package/hosts/claude/README.md +7 -0
- package/hosts/claude/commands/gwo-add-site.md +7 -0
- package/hosts/claude/commands/gwo-daily.md +7 -0
- package/hosts/claude/commands/gwo-measure.md +7 -0
- package/hosts/claude/commands/gwo-monthly.md +7 -0
- package/hosts/claude/commands/gwo-onboard.md +7 -0
- package/hosts/claude/commands/gwo-scan.md +7 -0
- package/hosts/claude/commands/gwo-setup.md +7 -0
- package/hosts/claude/commands/gwo-triage.md +7 -0
- package/hosts/claude/commands/gwo-weekly.md +7 -0
- package/hosts/claude/skills/gwo-client-report-template/SKILL.md +36 -0
- package/hosts/claude/skills/gwo-content-brief/SKILL.md +60 -0
- package/hosts/claude/skills/gwo-daily/SKILL.md +47 -0
- package/hosts/claude/skills/gwo-measurement/SKILL.md +92 -0
- package/hosts/claude/skills/gwo-monthly/SKILL.md +30 -0
- package/hosts/claude/skills/gwo-onboarding/SKILL.md +27 -0
- package/hosts/claude/skills/gwo-operating-model/SKILL.md +76 -0
- package/hosts/claude/skills/gwo-setup/SKILL.md +42 -0
- package/hosts/claude/skills/gwo-triage/SKILL.md +37 -0
- package/hosts/claude/skills/gwo-weekly/SKILL.md +48 -0
- package/hosts/codex/.codex/config.template.toml +5 -0
- package/hosts/codex/.codex-plugin/plugin.json +30 -0
- package/hosts/codex/AGENTS.md +470 -0
- package/hosts/codex/README.md +13 -0
- package/hosts/codex/skills/gwo-client-report-template/SKILL.md +36 -0
- package/hosts/codex/skills/gwo-content-brief/SKILL.md +60 -0
- package/hosts/codex/skills/gwo-daily/SKILL.md +47 -0
- package/hosts/codex/skills/gwo-measurement/SKILL.md +92 -0
- package/hosts/codex/skills/gwo-monthly/SKILL.md +30 -0
- package/hosts/codex/skills/gwo-onboarding/SKILL.md +27 -0
- package/hosts/codex/skills/gwo-operating-model/SKILL.md +76 -0
- package/hosts/codex/skills/gwo-setup/SKILL.md +42 -0
- package/hosts/codex/skills/gwo-triage/SKILL.md +37 -0
- package/hosts/codex/skills/gwo-weekly/SKILL.md +48 -0
- package/package.json +24 -0
- package/packages/connectors/browser/src/index.js +122 -0
- package/packages/connectors/docx/src/index.js +122 -0
- package/packages/connectors/github/src/index.js +102 -0
- package/packages/connectors/google/src/auth.js +73 -0
- package/packages/connectors/google/src/discover.js +135 -0
- package/packages/connectors/google/src/ga4.js +42 -0
- package/packages/connectors/google/src/gsc.js +49 -0
- package/packages/connectors/google/src/gtm.js +261 -0
- package/packages/connectors/google/src/index.js +8 -0
- package/packages/connectors/google/src/oauth.js +198 -0
- package/packages/connectors/google/src/pagespeed.js +48 -0
- package/packages/connectors/google/src/token-store.js +89 -0
- package/packages/connectors/odoo/src/index.js +206 -0
- package/packages/connectors/repo/src/index.js +94 -0
- package/packages/connectors/resend/src/index.js +30 -0
- package/packages/connectors/web-crawler/src/a11y.js +91 -0
- package/packages/connectors/web-crawler/src/fetch.js +165 -0
- package/packages/connectors/web-crawler/src/index.js +69 -0
- package/packages/connectors/web-crawler/src/parse.js +157 -0
- package/packages/core/src/adapters.js +27 -0
- package/packages/core/src/admin.js +232 -0
- package/packages/core/src/content.js +213 -0
- package/packages/core/src/doctor.js +39 -0
- package/packages/core/src/escalate.js +36 -0
- package/packages/core/src/gtm-ops.js +311 -0
- package/packages/core/src/index.js +13 -0
- package/packages/core/src/operations.js +239 -0
- package/packages/core/src/paths.js +46 -0
- package/packages/core/src/registry.js +114 -0
- package/packages/core/src/report/monthly.js +272 -0
- package/packages/core/src/scan/render.js +40 -0
- package/packages/core/src/scan/rules.js +289 -0
- package/packages/core/src/scan/suppress.js +37 -0
- package/packages/core/src/types.js +10 -0
- package/packages/core/src/writes.js +50 -0
- package/packages/playbooks/00-operating-model.md +74 -0
- package/packages/playbooks/10-daily.md +45 -0
- package/packages/playbooks/20-weekly.md +46 -0
- package/packages/playbooks/30-monthly.md +28 -0
- package/packages/playbooks/40-triage.md +35 -0
- package/packages/playbooks/50-onboarding.md +25 -0
- package/packages/playbooks/60-content-brief.md +58 -0
- package/packages/playbooks/70-measurement.md +90 -0
- package/packages/playbooks/80-setup.md +40 -0
- package/packages/playbooks/90-client-report-template.md +34 -0
- package/packages/policy/src/index.js +115 -0
- package/packages/storage/src/index.js +131 -0
- package/policies/default.yaml +90 -0
- package/sites/schema.yaml +71 -0
- package/sites/secrets.example.yaml +6 -0
- package/tools/build-hosts.js +118 -0
- package/tools/build-package.js +43 -0
- package/tools/install.js +145 -0
- package/tools/schedule.ps1 +37 -0
- package/tools/smoke-mcp.js +61 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { parseA11y } from "./a11y.js";
|
|
2
|
+
const strip = (s) => s == null ? null : s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
3
|
+
const decode = (s) => s == null ? null : s
|
|
4
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"').replace(/�?39;|'/g, "'").replace(/ /g, ' ')
|
|
6
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
|
7
|
+
/** Read one attribute from a raw tag string. */
|
|
8
|
+
function attr(tag, name) {
|
|
9
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
|
10
|
+
if (!m)
|
|
11
|
+
return null;
|
|
12
|
+
return decode(m[2] ?? m[3] ?? m[4] ?? null);
|
|
13
|
+
}
|
|
14
|
+
function metaContent(html, key, which = 'name') {
|
|
15
|
+
const re = new RegExp(`<meta\\b[^>]*\\b${which}\\s*=\\s*["']?${key}["']?[^>]*>`, 'gi');
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const m of html.matchAll(re)) {
|
|
18
|
+
const v = attr(m[0], 'content');
|
|
19
|
+
if (v !== null)
|
|
20
|
+
out.push(v);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function collectTypes(node, acc = []) {
|
|
25
|
+
if (!node)
|
|
26
|
+
return acc;
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
node.forEach((n) => collectTypes(n, acc));
|
|
29
|
+
return acc;
|
|
30
|
+
}
|
|
31
|
+
if (typeof node !== 'object')
|
|
32
|
+
return acc;
|
|
33
|
+
const obj = node;
|
|
34
|
+
const t = obj['@type'];
|
|
35
|
+
if (typeof t === 'string')
|
|
36
|
+
acc.push(t);
|
|
37
|
+
else if (Array.isArray(t))
|
|
38
|
+
acc.push(...t.filter((x) => typeof x === 'string'));
|
|
39
|
+
for (const k of Object.keys(obj))
|
|
40
|
+
if (k !== '@type')
|
|
41
|
+
collectTypes(obj[k], acc);
|
|
42
|
+
return acc;
|
|
43
|
+
}
|
|
44
|
+
export function safeAbs(href, base) {
|
|
45
|
+
if (!href)
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
return new URL(href, base).toString();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function safeOrigin(u) {
|
|
55
|
+
if (!u)
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
return new URL(u).origin;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Parse a document into a flat signal record. */
|
|
65
|
+
export function parseHtml(html, url) {
|
|
66
|
+
const h = html || '';
|
|
67
|
+
const head = (h.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i)?.[1]) ?? h;
|
|
68
|
+
const htmlTag = h.match(/<html\b[^>]*>/i)?.[0] ?? null;
|
|
69
|
+
const lang = htmlTag ? attr(htmlTag, 'lang') : null;
|
|
70
|
+
const dir = htmlTag ? attr(htmlTag, 'dir') : null;
|
|
71
|
+
const title = decode(strip(h.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? null));
|
|
72
|
+
const descriptions = metaContent(head, 'description');
|
|
73
|
+
const description = descriptions[0] ?? null;
|
|
74
|
+
const robotsMeta = [...metaContent(head, 'robots'), ...metaContent(head, 'googlebot')];
|
|
75
|
+
const robotsTokens = robotsMeta.flatMap((v) => v.split(',').map((t) => t.trim().toLowerCase()));
|
|
76
|
+
const links = [...head.matchAll(/<link\b[^>]*>/gi)].map((m) => m[0]);
|
|
77
|
+
const canonicalTags = links.filter((t) => (attr(t, 'rel') ?? '').toLowerCase().split(/\s+/).includes('canonical'));
|
|
78
|
+
const canonical = canonicalTags[0] ? attr(canonicalTags[0], 'href') : null;
|
|
79
|
+
const alternates = links
|
|
80
|
+
.filter((t) => attr(t, 'hreflang'))
|
|
81
|
+
.map((t) => ({ hreflang: attr(t, 'hreflang'), href: safeAbs(attr(t, 'href'), url) }));
|
|
82
|
+
const og = {};
|
|
83
|
+
for (const m of head.matchAll(/<meta\b[^>]*property\s*=\s*["']og:([^"']+)["'][^>]*>/gi)) {
|
|
84
|
+
const v = attr(m[0], 'content');
|
|
85
|
+
const key = m[1];
|
|
86
|
+
if (v !== null && key && og[key] === undefined)
|
|
87
|
+
og[key] = v;
|
|
88
|
+
}
|
|
89
|
+
const twitter = {};
|
|
90
|
+
for (const m of head.matchAll(/<meta\b[^>]*name\s*=\s*["']twitter:([^"']+)["'][^>]*>/gi)) {
|
|
91
|
+
const v = attr(m[0], 'content');
|
|
92
|
+
const key = m[1];
|
|
93
|
+
if (v !== null && key && twitter[key] === undefined)
|
|
94
|
+
twitter[key] = v;
|
|
95
|
+
}
|
|
96
|
+
const jsonld = [];
|
|
97
|
+
for (const m of h.matchAll(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
98
|
+
const raw = (m[1] ?? '').trim();
|
|
99
|
+
try {
|
|
100
|
+
jsonld.push({ ok: true, types: collectTypes(JSON.parse(raw)), bytes: raw.length });
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
jsonld.push({ ok: false, types: [], bytes: raw.length, error: String(e.message ?? e) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const microdata = [...h.matchAll(/itemtype\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1] ?? '');
|
|
107
|
+
const headings = { h1: [], h2: [], h3: [], h4: [] };
|
|
108
|
+
for (const lvl of ['h1', 'h2', 'h3', 'h4']) {
|
|
109
|
+
headings[lvl] = [...h.matchAll(new RegExp(`<${lvl}\\b[^>]*>([\\s\\S]*?)<\\/${lvl}>`, 'gi'))]
|
|
110
|
+
.map((m) => decode(strip(m[1] ?? '')))
|
|
111
|
+
.filter((x) => !!x);
|
|
112
|
+
}
|
|
113
|
+
const imgTags = [...h.matchAll(/<img\b[^>]*>/gi)].map((m) => m[0]);
|
|
114
|
+
const images = {
|
|
115
|
+
total: imgTags.length,
|
|
116
|
+
missingAlt: imgTags.filter((t) => attr(t, 'alt') === null).length,
|
|
117
|
+
emptyAlt: imgTags.filter((t) => attr(t, 'alt') === '').length,
|
|
118
|
+
lazy: imgTags.filter((t) => (attr(t, 'loading') ?? '').toLowerCase() === 'lazy').length,
|
|
119
|
+
};
|
|
120
|
+
const origin = safeOrigin(url);
|
|
121
|
+
let internal = 0, external = 0, nofollow = 0;
|
|
122
|
+
for (const a of [...h.matchAll(/<a\b[^>]*>/gi)].map((m) => m[0])) {
|
|
123
|
+
const href = attr(a, 'href');
|
|
124
|
+
if (!href || /^(#|mailto:|tel:|javascript:)/i.test(href))
|
|
125
|
+
continue;
|
|
126
|
+
const abs = safeAbs(href, url);
|
|
127
|
+
if (!abs)
|
|
128
|
+
continue;
|
|
129
|
+
if (safeOrigin(abs) === origin)
|
|
130
|
+
internal++;
|
|
131
|
+
else
|
|
132
|
+
external++;
|
|
133
|
+
if ((attr(a, 'rel') ?? '').toLowerCase().includes('nofollow'))
|
|
134
|
+
nofollow++;
|
|
135
|
+
}
|
|
136
|
+
// Text that exists without JavaScript: the only text most AI crawlers see.
|
|
137
|
+
const bodyText = decode(strip(h.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' '))) ?? '';
|
|
138
|
+
const uniq = (re) => [...new Set([...h.matchAll(re)].map((m) => m[0]))];
|
|
139
|
+
return {
|
|
140
|
+
url, lang, dir,
|
|
141
|
+
title, titleLength: title?.length ?? 0,
|
|
142
|
+
description, descriptionLength: description?.length ?? 0, descriptionCount: descriptions.length,
|
|
143
|
+
robotsTokens, noindex: robotsTokens.includes('noindex'), nofollowPage: robotsTokens.includes('nofollow'),
|
|
144
|
+
canonical, canonicalAbs: canonical ? safeAbs(canonical, url) : null, canonicalCount: canonicalTags.length,
|
|
145
|
+
alternates, og, twitter,
|
|
146
|
+
jsonld, jsonldTypes: [...new Set(jsonld.flatMap((j) => j.types))], microdata,
|
|
147
|
+
headings, images,
|
|
148
|
+
links: { internal, external, nofollow, total: internal + external },
|
|
149
|
+
textLength: bodyText.length, wordCount: bodyText ? bodyText.split(/\s+/).length : 0,
|
|
150
|
+
analytics: {
|
|
151
|
+
ga4: uniq(/\bG-[A-Z0-9]{8,12}\b/g),
|
|
152
|
+
gtm: uniq(/\bGTM-[A-Z0-9]{5,9}\b/g),
|
|
153
|
+
ua: uniq(/\bUA-\d{4,}-\d+\b/g),
|
|
154
|
+
},
|
|
155
|
+
a11y: parseA11y(h),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class NoAdapter {
|
|
2
|
+
kind = 'none';
|
|
3
|
+
siteId;
|
|
4
|
+
constructor(siteId) { this.siteId = siteId; }
|
|
5
|
+
describe() { return `no write adapter configured for ${this.siteId}`; }
|
|
6
|
+
async ping() { throw new Error(this.describe()); }
|
|
7
|
+
async listPages() { throw new Error(this.describe()); }
|
|
8
|
+
async readPageSeo() { throw new Error(this.describe()); }
|
|
9
|
+
async writePageSeo() { throw new Error(this.describe()); }
|
|
10
|
+
async readPageSchema() { throw new Error(this.describe()); }
|
|
11
|
+
async writePageSchema() { throw new Error(this.describe()); }
|
|
12
|
+
}
|
|
13
|
+
/** Build the adapter declared in the site file. Lazy imports keep crawler-only paths light. */
|
|
14
|
+
export async function adapterFor(site) {
|
|
15
|
+
switch (site.write?.adapter) {
|
|
16
|
+
case 'odoo': {
|
|
17
|
+
const { OdooAdapter } = await import("../../connectors/odoo/src/index.js");
|
|
18
|
+
return OdooAdapter.fromSite(site);
|
|
19
|
+
}
|
|
20
|
+
case 'repo': {
|
|
21
|
+
const { RepoAdapter } = await import("../../connectors/repo/src/index.js");
|
|
22
|
+
return RepoAdapter.fromSite(site);
|
|
23
|
+
}
|
|
24
|
+
default:
|
|
25
|
+
return new NoAdapter(site.site_id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Administration from the chat: setup status, Google authorisation, property
|
|
3
|
+
* discovery, and registry edits (add a site, set a field, record a site
|
|
4
|
+
* decision). Nothing here touches a client site; it changes this system's
|
|
5
|
+
* own configuration, and every change is recorded in the audit log under
|
|
6
|
+
* the site it concerns (or "_registry" for global setup).
|
|
7
|
+
*
|
|
8
|
+
* Secrets are never accepted as values. The OAuth client is installed from
|
|
9
|
+
* a file path the operator downloaded; site credentials go into
|
|
10
|
+
* sites/<host>.secrets.yaml by hand, and setupStatus() says when they are
|
|
11
|
+
* missing.
|
|
12
|
+
*/
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { parseDocument } from 'yaml';
|
|
16
|
+
import { enforce } from "../../policy/src/index.js";
|
|
17
|
+
import * as storage from "../../storage/src/index.js";
|
|
18
|
+
import { discover, login, makeOauthProvider, setTokenProvider, SCOPES_FOR, SECRETS_DIR, CLIENT_FILE } from "../../connectors/google/src/index.js";
|
|
19
|
+
import { SITES_DIR } from "./paths.js";
|
|
20
|
+
import { getSite, listSites } from "./registry.js";
|
|
21
|
+
const REGISTRY = { site_id: '_registry', policy: { profile: 'standard-client-site' } };
|
|
22
|
+
const record = (siteId, operation, summary, data) => storage.appendAudit({ siteId, operation, status: 'success', risk: 'auto', summary, ...(data !== undefined ? { data } : {}) });
|
|
23
|
+
function siteSetup(s) {
|
|
24
|
+
const missing = [];
|
|
25
|
+
const gsc = s.google.search_console?.property ?? null, ga4 = s.google.ga4?.property_id ?? null, gtm = s.google.tag_manager?.public_id ?? null;
|
|
26
|
+
if (!gsc)
|
|
27
|
+
missing.push('Search Console property (grant access, then google_discover_apply)');
|
|
28
|
+
if (!ga4)
|
|
29
|
+
missing.push('GA4 property id (grant access, then google_discover_apply)');
|
|
30
|
+
if (!gtm)
|
|
31
|
+
missing.push('Tag Manager container (none registered; fine if the site has no container)');
|
|
32
|
+
const adapter = s.write?.adapter ?? 'none';
|
|
33
|
+
let adapterConfigured = false;
|
|
34
|
+
const secretsFile = existsSync(path.join(SITES_DIR, s._file.replace(/\.yaml$/, '.secrets.yaml')));
|
|
35
|
+
if (adapter === 'odoo') {
|
|
36
|
+
adapterConfigured = !!(s.write.odoo?.url && s.write.odoo?.db);
|
|
37
|
+
if (!adapterConfigured)
|
|
38
|
+
missing.push('write.odoo.url and write.odoo.db');
|
|
39
|
+
if (!secretsFile)
|
|
40
|
+
missing.push(`sites/${s._file.replace(/\.yaml$/, '.secrets.yaml')} with odoo.login and odoo.api_key (by hand, never through chat)`);
|
|
41
|
+
}
|
|
42
|
+
else if (adapter === 'repo') {
|
|
43
|
+
adapterConfigured = !!s.write.repo?.local_path;
|
|
44
|
+
if (!adapterConfigured)
|
|
45
|
+
missing.push('write.repo.local_path (a local checkout with gwo.seo.json)');
|
|
46
|
+
}
|
|
47
|
+
else if (s.tier === 'full')
|
|
48
|
+
missing.push('write adapter (odoo or repo) for a full-tier site');
|
|
49
|
+
return {
|
|
50
|
+
id: s.site_id, name: s.name, host: s.production_url, enabled: s.enabled, platform: s.platform, tier: s.tier,
|
|
51
|
+
searchConsole: gsc, ga4PropertyId: ga4, gtm, adapter, adapterConfigured, secretsFile,
|
|
52
|
+
suppressions: s.findings?.suppress?.length ?? 0, missing,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Everything an operator needs to know to finish setting the system up, in one read. */
|
|
56
|
+
export function setupStatus() {
|
|
57
|
+
const o = makeOauthProvider().status();
|
|
58
|
+
const has = (s) => o.scopes.some((x) => x === s || x.endsWith(s));
|
|
59
|
+
const writeScopes = { ga4_write: has(SCOPES_FOR.ga4_write), gtm_edit: has(SCOPES_FOR.gtm_edit), gtm_publish: has(SCOPES_FOR.gtm_publish) };
|
|
60
|
+
const env = Object.fromEntries(['RESEND_API_KEY', 'GWO_REPORT_FROM', 'GWO_REPORT_TO', 'GITHUB_TOKEN', 'GWO_DOCX_TEMPLATE', 'GOOGLE_PAGESPEED_API_KEY']
|
|
61
|
+
.map((k) => [k, !!process.env[k]?.trim()]));
|
|
62
|
+
const sites = listSites({ includeDisabled: true }).map(siteSetup);
|
|
63
|
+
const next = [];
|
|
64
|
+
if (!o.clientFile)
|
|
65
|
+
next.push('Create a "Desktop app" OAuth client in Google Cloud (project "Google Web Operations"), download its JSON, then google_setup_oauth_client with the downloaded file path.');
|
|
66
|
+
else if (!o.authorised)
|
|
67
|
+
next.push('google_auth_login: the browser opens for consent with the operations account.');
|
|
68
|
+
else {
|
|
69
|
+
if (!writeScopes.ga4_write)
|
|
70
|
+
next.push('Optional: google_auth_login with scopes ["ga4_write"] before creating key events.');
|
|
71
|
+
if (!writeScopes.gtm_edit || !writeScopes.gtm_publish)
|
|
72
|
+
next.push('Optional: google_auth_login with scopes ["gtm_edit", "gtm_publish"] before staging Tag Manager changes.');
|
|
73
|
+
}
|
|
74
|
+
if (!sites.length)
|
|
75
|
+
next.push('ops_add_site for the first site.');
|
|
76
|
+
if (o.authorised && sites.some((s) => !s.searchConsole || !s.ga4PropertyId))
|
|
77
|
+
next.push('google_discover_apply once the client has added the operations account to Search Console and GA4.');
|
|
78
|
+
for (const s of sites)
|
|
79
|
+
for (const m of s.missing)
|
|
80
|
+
if (!/discover_apply|none registered/.test(m))
|
|
81
|
+
next.push(`${s.id}: ${m}`);
|
|
82
|
+
if (!env.RESEND_API_KEY)
|
|
83
|
+
next.push('Optional: RESEND_API_KEY, GWO_REPORT_FROM, GWO_REPORT_TO in .env for emailed worker reports.');
|
|
84
|
+
if (!env.GWO_DOCX_TEMPLATE)
|
|
85
|
+
next.push('Optional: GWO_DOCX_TEMPLATE in .env for client reports in the company template.');
|
|
86
|
+
return { google: { clientFile: o.clientFile, authorised: o.authorised, email: o.email, scopes: o.scopes, writeScopes }, env, sites, nextSteps: next };
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------- Google
|
|
89
|
+
/** Copy a downloaded OAuth client JSON into secrets/. Validates the shape; never returns the secret. */
|
|
90
|
+
export function installOauthClient(fromPath) {
|
|
91
|
+
enforce(REGISTRY, 'google.auth.setup');
|
|
92
|
+
if (!existsSync(fromPath))
|
|
93
|
+
throw new Error(`File not found: ${fromPath}`);
|
|
94
|
+
const raw = JSON.parse(readFileSync(fromPath, 'utf8'));
|
|
95
|
+
const c = raw.installed;
|
|
96
|
+
if (!c?.client_id || !c.token_uri) {
|
|
97
|
+
throw new Error(raw.web ? 'This is a "Web application" client. Create a "Desktop app" client instead; the loopback flow needs it.' : 'Not a Google OAuth client JSON (expected an "installed" section with client_id and token_uri).');
|
|
98
|
+
}
|
|
99
|
+
mkdirSync(SECRETS_DIR, { recursive: true });
|
|
100
|
+
copyFileSync(fromPath, CLIENT_FILE);
|
|
101
|
+
record('_registry', 'google.auth.setup', `Installed OAuth client ${c.client_id.slice(0, 12)}… from ${fromPath}`);
|
|
102
|
+
return { clientId: c.client_id, installedAt: CLIENT_FILE };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Run the consent flow from a chat host. Opens the browser and waits up to
|
|
106
|
+
* `waitMs`; if consent takes longer the flow keeps running in the background
|
|
107
|
+
* and the token is stored when it completes, so the caller can poll
|
|
108
|
+
* setupStatus(). Scope names are the short ones from SCOPES_FOR or full URLs.
|
|
109
|
+
*/
|
|
110
|
+
export async function authLogin(scopes = [], { waitMs = 90_000 } = {}) {
|
|
111
|
+
enforce(REGISTRY, 'google.auth.login');
|
|
112
|
+
const resolved = scopes.map((s) => SCOPES_FOR[s] ?? s);
|
|
113
|
+
const pending = login({ scopes: resolved, open: true, log: () => { } }).then((r) => {
|
|
114
|
+
setTokenProvider(makeOauthProvider()); // drop the "no authorisation" the process may have cached
|
|
115
|
+
record('_registry', 'google.auth.login', `Google authorisation stored for ${r.email ?? 'unknown account'} with ${r.scopes.length} scopes`, { scopes: r.scopes });
|
|
116
|
+
return r;
|
|
117
|
+
});
|
|
118
|
+
const timeout = new Promise((res) => setTimeout(() => res(null), waitMs).unref());
|
|
119
|
+
const r = await Promise.race([pending, timeout]);
|
|
120
|
+
if (!r) {
|
|
121
|
+
pending.catch(() => { });
|
|
122
|
+
return { status: 'waiting', message: `The browser is open for consent. Finish it, then call ops_setup_status; the token is stored automatically.` };
|
|
123
|
+
}
|
|
124
|
+
return { status: 'authorised', email: r.email, scopes: r.scopes, message: `Authorised as ${r.email ?? 'unknown account'}; token stored (${r.store}).` };
|
|
125
|
+
}
|
|
126
|
+
/** Discover properties and write the ids of confident matches into the site files. */
|
|
127
|
+
export async function discoverApply() {
|
|
128
|
+
enforce(REGISTRY, 'registry.site.update');
|
|
129
|
+
const r = await discover({ apply: true });
|
|
130
|
+
for (const f of r.applied)
|
|
131
|
+
record('_registry', 'registry.site.update', `Discovery wrote Google ids into ${f}`);
|
|
132
|
+
return { applied: r.applied, matches: r.matches, inventory: { searchConsole: r.inventory.gsc.length, ga4: r.inventory.ga4.length, tagManager: r.inventory.gtm.length } };
|
|
133
|
+
}
|
|
134
|
+
const q = (s) => JSON.stringify(s);
|
|
135
|
+
const list = (xs) => `[${xs.map(q).join(', ')}]`;
|
|
136
|
+
/** Host without www; site id is its first label. "doritgrinberg.co.il" -> "doritgrinberg". */
|
|
137
|
+
export function siteIdFor(url) {
|
|
138
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
139
|
+
return { host, id: host.split('.')[0] };
|
|
140
|
+
}
|
|
141
|
+
/** Render a new site file with the same comments as sites/schema.yaml. Pure. */
|
|
142
|
+
export function renderSiteFile(n) {
|
|
143
|
+
const u = new URL(n.url);
|
|
144
|
+
const { host, id } = siteIdFor(n.url);
|
|
145
|
+
const production = `${u.protocol}//${host}`;
|
|
146
|
+
const alt = n.altHosts ?? [`${u.protocol}//www.${host}`];
|
|
147
|
+
const locales = n.locales?.length ? n.locales : ['he'];
|
|
148
|
+
const adapter = n.adapter ?? (n.platform === 'odoo' ? 'odoo' : n.platform === 'nextjs' ? 'repo' : 'none');
|
|
149
|
+
const write = adapter === 'odoo'
|
|
150
|
+
? `write:\n adapter: odoo\n odoo: # login + api_key go in ${host}.secrets.yaml\n url: ${n.odoo?.url ?? production}\n db: ${n.odoo?.db ?? 'CHANGE_ME'}\n website_id: ${n.odoo?.websiteId ?? 'null'}\n`
|
|
151
|
+
: adapter === 'repo'
|
|
152
|
+
? `write:\n adapter: repo\n repo: # the checkout must contain gwo.seo.json\n url: ${n.repo?.url ?? 'null'}\n branch: ${n.repo?.branch ?? 'main'}\n local_path: ${n.repo?.localPath ? q(n.repo.localPath) : 'null'}\n`
|
|
153
|
+
: 'write:\n adapter: none # advisory: every change is a proposal\n';
|
|
154
|
+
const text = `# ${n.name}. Registered ${new Date().toISOString().slice(0, 10)} through ops_add_site.\n` +
|
|
155
|
+
`# Credentials go in sites/${host}.secrets.yaml (gitignored), never here.\n\n` +
|
|
156
|
+
`site_id: ${id}\nname: ${q(n.name)}\nenabled: true\nproduction_url: ${production}\nalt_hosts:\n${alt.map((a) => ` - ${a}`).join('\n')}\n` +
|
|
157
|
+
`locales: ${list(locales)}\ndefault_locale: ${n.defaultLocale ?? locales[0]}\ntimezone: Asia/Jerusalem\nplatform: ${n.platform ?? 'custom'}\ntier: ${n.tier ?? 'full'}\n\n` +
|
|
158
|
+
`seo:\n sitemap: /sitemap.xml\n robots: /robots.txt\n\n` +
|
|
159
|
+
`google: # filled by google_discover_apply once access is granted\n search_console:\n property: null\n ga4:\n measurement_id: null\n property_id: null\n tag_manager:\n public_id: null\n\n` +
|
|
160
|
+
write + `\nconversions:\n primary_event: ${n.primaryEvent ?? 'generate_lead'}\n\n` +
|
|
161
|
+
`findings: # site decisions; each needs a reason (ops_add_suppression)\n suppress: []\n\n` +
|
|
162
|
+
`policy:\n profile: standard-client-site\n\ncadence:\n daily: true\n weekly: true\n monthly: true\n\n` +
|
|
163
|
+
`contacts:\n owner: ${q(n.owner ?? '')}\n report_to: []\n` +
|
|
164
|
+
(n.notes ? `\nnotes: ${q(n.notes)}\n` : '');
|
|
165
|
+
return { file: `${host}.yaml`, text, id };
|
|
166
|
+
}
|
|
167
|
+
/** Create sites/<host>.yaml. Refuses to overwrite. Returns the loaded site. */
|
|
168
|
+
export function addSite(n) {
|
|
169
|
+
enforce(REGISTRY, 'registry.site.create');
|
|
170
|
+
const { file, text, id } = renderSiteFile(n);
|
|
171
|
+
const full = path.join(SITES_DIR, file);
|
|
172
|
+
if (existsSync(full))
|
|
173
|
+
throw new Error(`${file} already exists. Use ops_update_site to change it.`);
|
|
174
|
+
const clash = listSites({ includeDisabled: true }).find((s) => s.site_id === id);
|
|
175
|
+
if (clash)
|
|
176
|
+
throw new Error(`site id "${id}" is already used by ${clash._file}.`);
|
|
177
|
+
mkdirSync(SITES_DIR, { recursive: true });
|
|
178
|
+
writeFileSync(full, text);
|
|
179
|
+
const site = getSite(id);
|
|
180
|
+
record(id, 'registry.site.create', `Registered ${site.production_url} as ${id} (${site.platform}, ${site.tier}, adapter ${site.write.adapter})`);
|
|
181
|
+
return { file, site };
|
|
182
|
+
}
|
|
183
|
+
const PROTECTED_PATHS = /^(site_id|policy)(\.|$)/;
|
|
184
|
+
/**
|
|
185
|
+
* Set one field in a site file by dotted path, keeping comments. Values are
|
|
186
|
+
* parsed as JSON when they look like it ("123", "true", "[\"he\",\"en\"]"),
|
|
187
|
+
* otherwise kept as strings. site_id and policy are not editable here.
|
|
188
|
+
*/
|
|
189
|
+
export function updateSite(siteIdOrHost, dotted, value) {
|
|
190
|
+
enforce(REGISTRY, 'registry.site.update');
|
|
191
|
+
if (PROTECTED_PATHS.test(dotted))
|
|
192
|
+
throw new Error(`${dotted} is not editable from chat. Edit the file by hand; policy can only be tightened.`);
|
|
193
|
+
if (/secret|api_key|password|token/i.test(dotted))
|
|
194
|
+
throw new Error('Credentials never go into a site file. Put them in sites/<host>.secrets.yaml by hand.');
|
|
195
|
+
const site = getSite(siteIdOrHost);
|
|
196
|
+
const file = path.join(SITES_DIR, site._file);
|
|
197
|
+
const doc = parseDocument(readFileSync(file, 'utf8'));
|
|
198
|
+
const p = dotted.split('.');
|
|
199
|
+
const before = doc.getIn(p, true) instanceof Object ? JSON.parse(JSON.stringify(doc.getIn(p))) : doc.getIn(p);
|
|
200
|
+
let v = value;
|
|
201
|
+
if (typeof value === 'string') {
|
|
202
|
+
try {
|
|
203
|
+
v = JSON.parse(value);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
v = value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
doc.setIn(p, v);
|
|
210
|
+
writeFileSync(file, doc.toString());
|
|
211
|
+
record(site.site_id, 'registry.site.update', `Set ${dotted}: ${JSON.stringify(before ?? null)} -> ${JSON.stringify(v)}`);
|
|
212
|
+
return { file: site._file, path: dotted, before: before ?? null, after: v };
|
|
213
|
+
}
|
|
214
|
+
/** Record a site decision: a finding that is intentional here. */
|
|
215
|
+
export function addSuppression(siteIdOrHost, s) {
|
|
216
|
+
enforce(REGISTRY, 'registry.site.update');
|
|
217
|
+
if (!s.rule.trim() || !s.reason.trim())
|
|
218
|
+
throw new Error('A suppression needs a rule id and a reason.');
|
|
219
|
+
const site = getSite(siteIdOrHost);
|
|
220
|
+
const file = path.join(SITES_DIR, site._file);
|
|
221
|
+
const doc = parseDocument(readFileSync(file, 'utf8'));
|
|
222
|
+
const current = (doc.getIn(['findings', 'suppress']) ? JSON.parse(JSON.stringify(doc.getIn(['findings', 'suppress']))) : []);
|
|
223
|
+
const entry = { rule: s.rule.trim(), ...(s.path ? { path: s.path.trim() } : {}), reason: s.reason.trim() };
|
|
224
|
+
const dup = current.find((x) => x.rule === entry.rule && (x.path ?? '') === (entry.path ?? ''));
|
|
225
|
+
if (dup)
|
|
226
|
+
throw new Error(`Already suppressed: ${entry.rule}${entry.path ? ` on ${entry.path}` : ''} ("${dup.reason}").`);
|
|
227
|
+
const next = [...current, entry];
|
|
228
|
+
doc.setIn(['findings', 'suppress'], next);
|
|
229
|
+
writeFileSync(file, doc.toString());
|
|
230
|
+
record(site.site_id, 'registry.site.update', `Suppressed ${entry.rule}${entry.path ? ` on ${entry.path}` : ''}: ${entry.reason}`);
|
|
231
|
+
return { file: site._file, suppressions: next };
|
|
232
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content writes: meta descriptions, titles and structured data.
|
|
3
|
+
*
|
|
4
|
+
* The agent writes the text (see the gwo-content-brief playbook). This
|
|
5
|
+
* module validates it, runs it through performWrite() against the site's
|
|
6
|
+
* adapter, and verifies by reading the stored value back. Live-page
|
|
7
|
+
* confirmation is a separate step because CDNs and build pipelines can lag.
|
|
8
|
+
*/
|
|
9
|
+
import { performWrite } from "./writes.js";
|
|
10
|
+
import { adapterFor } from "./adapters.js";
|
|
11
|
+
import { getSite } from "./registry.js";
|
|
12
|
+
import { DESC_BOUNDS, isHebrew } from "./scan/rules.js";
|
|
13
|
+
/** Validator bounds for titles. The brief aims at 50 to 60; these are the hard limits. */
|
|
14
|
+
export const TITLE_BOUNDS = { min: 15, max: 65 };
|
|
15
|
+
/** Hard ceiling on one page's managed JSON-LD, serialised. Larger blocks slow every page load. */
|
|
16
|
+
export const SCHEMA_MAX_BYTES = 20_000;
|
|
17
|
+
function commonTextProblems(text, problems) {
|
|
18
|
+
const t = text.trim();
|
|
19
|
+
if (t !== text)
|
|
20
|
+
problems.push({ code: 'whitespace', message: 'Leading or trailing whitespace.' });
|
|
21
|
+
if (/[\r\n\t]/.test(t))
|
|
22
|
+
problems.push({ code: 'linebreak', message: 'Line breaks or tabs are not allowed.' });
|
|
23
|
+
if (/<[^>]+>/.test(t))
|
|
24
|
+
problems.push({ code: 'html', message: 'HTML tags are not allowed.' });
|
|
25
|
+
const latinLetters = t.replace(/[^A-Za-z]/g, '');
|
|
26
|
+
if (latinLetters.length >= 20 && latinLetters === latinLetters.toUpperCase())
|
|
27
|
+
problems.push({ code: 'caps', message: 'All-caps text.' });
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate a meta description against the content brief.
|
|
32
|
+
* Length by script, no stuffing, no shouting, no line breaks, no leading/trailing junk.
|
|
33
|
+
*/
|
|
34
|
+
export function validateMetaDescription(text) {
|
|
35
|
+
const problems = [];
|
|
36
|
+
if (!text.trim()) {
|
|
37
|
+
problems.push({ code: 'empty', message: 'Description is empty.' });
|
|
38
|
+
return problems;
|
|
39
|
+
}
|
|
40
|
+
const t = commonTextProblems(text, problems);
|
|
41
|
+
const b = isHebrew(t) ? DESC_BOUNDS.hebrew : DESC_BOUNDS.latin;
|
|
42
|
+
if (t.length < b.min)
|
|
43
|
+
problems.push({ code: 'short', message: `${t.length} characters; minimum ${b.min} for ${isHebrew(t) ? 'Hebrew' : 'Latin'} text.` });
|
|
44
|
+
if (t.length > b.max)
|
|
45
|
+
problems.push({ code: 'long', message: `${t.length} characters; maximum ${b.max} for ${isHebrew(t) ? 'Hebrew' : 'Latin'} text.` });
|
|
46
|
+
// Keyword stuffing: any token of 4+ letters repeated more than twice.
|
|
47
|
+
const counts = new Map();
|
|
48
|
+
for (const w of t.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((x) => x.length >= 4))
|
|
49
|
+
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
50
|
+
const stuffed = [...counts].filter(([, n]) => n > 2).map(([w]) => w);
|
|
51
|
+
if (stuffed.length)
|
|
52
|
+
problems.push({ code: 'stuffing', message: `Repeated terms: ${stuffed.join(', ')}. Keyword stuffing lowers visibility.` });
|
|
53
|
+
return problems;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Validate a page title against the content brief: 15 to 65 characters, one
|
|
57
|
+
* line, no HTML, no shouting, at most one brand separator, no term repeated.
|
|
58
|
+
*/
|
|
59
|
+
export function validateTitle(text) {
|
|
60
|
+
const problems = [];
|
|
61
|
+
if (!text.trim()) {
|
|
62
|
+
problems.push({ code: 'empty', message: 'Title is empty.' });
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
const t = commonTextProblems(text, problems);
|
|
66
|
+
if (t.length < TITLE_BOUNDS.min)
|
|
67
|
+
problems.push({ code: 'short', message: `${t.length} characters; minimum ${TITLE_BOUNDS.min}.` });
|
|
68
|
+
if (t.length > TITLE_BOUNDS.max)
|
|
69
|
+
problems.push({ code: 'long', message: `${t.length} characters; maximum ${TITLE_BOUNDS.max}. Search shows about 60.` });
|
|
70
|
+
const separators = (t.match(/\s[|–—-]\s/g) ?? []).length;
|
|
71
|
+
if (separators > 1)
|
|
72
|
+
problems.push({ code: 'separators', message: 'More than one separator. One "primary | brand" split at most.' });
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const w of t.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((x) => x.length >= 4))
|
|
75
|
+
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
76
|
+
const repeated = [...counts].filter(([, n]) => n > 1).map(([w]) => w);
|
|
77
|
+
if (repeated.length)
|
|
78
|
+
problems.push({ code: 'stuffing', message: `Repeated terms: ${repeated.join(', ')}. A title says each thing once.` });
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Validate JSON-LD blocks: a non-empty array of objects, each with a
|
|
83
|
+
* schema.org @context and a @type, no script-closing sequence, bounded size.
|
|
84
|
+
* Checks the shape, not the vocabulary; the brief owns what to say.
|
|
85
|
+
*/
|
|
86
|
+
export function validateJsonLd(blocks) {
|
|
87
|
+
const problems = [];
|
|
88
|
+
if (!Array.isArray(blocks))
|
|
89
|
+
return [{ code: 'shape', message: 'Expected an array of JSON-LD objects.' }];
|
|
90
|
+
if (!blocks.length)
|
|
91
|
+
return [{ code: 'empty', message: 'No blocks. To remove structured data, call with an explicit empty list and say so.' }];
|
|
92
|
+
blocks.forEach((b, i) => {
|
|
93
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
94
|
+
problems.push({ code: 'shape', message: `Block ${i} is not an object.` });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const o = b;
|
|
98
|
+
const ctx = o['@context'];
|
|
99
|
+
const ctxOk = typeof ctx === 'string' ? /schema\.org\/?$/i.test(ctx) : !!ctx && typeof ctx === 'object';
|
|
100
|
+
if (!ctxOk)
|
|
101
|
+
problems.push({ code: 'context', message: `Block ${i}: @context must be "https://schema.org".` });
|
|
102
|
+
const type = o['@type'];
|
|
103
|
+
if (!(typeof type === 'string' && type.trim()) && !(Array.isArray(type) && type.length && type.every((x) => typeof x === 'string'))) {
|
|
104
|
+
problems.push({ code: 'type', message: `Block ${i}: @type is missing.` });
|
|
105
|
+
}
|
|
106
|
+
if (type === 'FAQPage') {
|
|
107
|
+
const main = o['mainEntity'];
|
|
108
|
+
if (!Array.isArray(main) || !main.length)
|
|
109
|
+
problems.push({ code: 'faq', message: `Block ${i}: FAQPage needs a non-empty mainEntity array of Question items.` });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const text = JSON.stringify(blocks);
|
|
113
|
+
if (/<\/script/i.test(text))
|
|
114
|
+
problems.push({ code: 'script', message: 'A value contains "</script"; it would end the script element.' });
|
|
115
|
+
if (text.length > SCHEMA_MAX_BYTES)
|
|
116
|
+
problems.push({ code: 'long', message: `${text.length} bytes serialised; maximum ${SCHEMA_MAX_BYTES}.` });
|
|
117
|
+
return problems;
|
|
118
|
+
}
|
|
119
|
+
async function resolve(siteIdOrHost, opts) {
|
|
120
|
+
const site = getSite(siteIdOrHost);
|
|
121
|
+
return { site, adapter: opts.adapter ?? (await adapterFor(site)) };
|
|
122
|
+
}
|
|
123
|
+
const seoSummary = (s) => (s ? { ref: s.ref, title: s.title, description: s.description } : null);
|
|
124
|
+
/**
|
|
125
|
+
* Set a page's meta description.
|
|
126
|
+
* Empty field -> policy action content.meta_description.fill_empty (auto).
|
|
127
|
+
* Existing text -> content.meta_description.update (approval required).
|
|
128
|
+
* The class is chosen from the current stored value, never from the caller.
|
|
129
|
+
*/
|
|
130
|
+
export async function setMetaDescription(siteIdOrHost, pagePath, text, opts = {}) {
|
|
131
|
+
const problems = validateMetaDescription(text);
|
|
132
|
+
if (problems.length)
|
|
133
|
+
return { status: 'invalid', problems };
|
|
134
|
+
const { site, adapter } = await resolve(siteIdOrHost, opts);
|
|
135
|
+
const current = await adapter.readPageSeo(pagePath);
|
|
136
|
+
if (!current)
|
|
137
|
+
throw new Error(`${adapter.kind} adapter knows no page at ${pagePath} for ${site.site_id}.`);
|
|
138
|
+
const isEmpty = !current.description || !current.description.trim();
|
|
139
|
+
const action = isEmpty ? 'content.meta_description.fill_empty' : 'content.meta_description.update';
|
|
140
|
+
return performWrite(site, {
|
|
141
|
+
action,
|
|
142
|
+
operation: 'content.set_meta_description',
|
|
143
|
+
summary: `${isEmpty ? 'Filled empty' : 'Updated'} meta description on ${pagePath} (${current.ref})`,
|
|
144
|
+
...(opts.approval ? { approval: opts.approval } : {}),
|
|
145
|
+
before: async () => current,
|
|
146
|
+
execute: () => adapter.writePageSeo(pagePath, { description: text.trim() }),
|
|
147
|
+
after: () => adapter.readPageSeo(pagePath),
|
|
148
|
+
verify: (_b, after) => (after?.description?.trim() === text.trim() ? null : `stored value is ${JSON.stringify(after?.description ?? null)}`),
|
|
149
|
+
summarise: seoSummary,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Set a page's title. Policy action content.title.update, approval required
|
|
154
|
+
* always: a worse title on a ranking page is the most expensive edit there is.
|
|
155
|
+
*/
|
|
156
|
+
export async function setTitle(siteIdOrHost, pagePath, text, opts = {}) {
|
|
157
|
+
const problems = validateTitle(text);
|
|
158
|
+
if (problems.length)
|
|
159
|
+
return { status: 'invalid', problems };
|
|
160
|
+
const { site, adapter } = await resolve(siteIdOrHost, opts);
|
|
161
|
+
const current = await adapter.readPageSeo(pagePath);
|
|
162
|
+
if (!current)
|
|
163
|
+
throw new Error(`${adapter.kind} adapter knows no page at ${pagePath} for ${site.site_id}.`);
|
|
164
|
+
return performWrite(site, {
|
|
165
|
+
action: 'content.title.update',
|
|
166
|
+
operation: 'content.set_title',
|
|
167
|
+
summary: `Set title on ${pagePath} (${current.ref}): ${JSON.stringify(current.title)} -> ${JSON.stringify(text.trim())}`,
|
|
168
|
+
...(opts.approval ? { approval: opts.approval } : {}),
|
|
169
|
+
before: async () => current,
|
|
170
|
+
execute: () => adapter.writePageSeo(pagePath, { title: text.trim() }),
|
|
171
|
+
after: () => adapter.readPageSeo(pagePath),
|
|
172
|
+
verify: (_b, after) => (after?.title?.trim() === text.trim() ? null : `stored value is ${JSON.stringify(after?.title ?? null)}`),
|
|
173
|
+
summarise: seoSummary,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Order-insensitive, key-sorted serialisation so verification ignores formatting. */
|
|
177
|
+
export function canonicalJson(v) {
|
|
178
|
+
if (Array.isArray(v))
|
|
179
|
+
return `[${v.map(canonicalJson).join(',')}]`;
|
|
180
|
+
if (v && typeof v === 'object') {
|
|
181
|
+
const o = v;
|
|
182
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
|
|
183
|
+
}
|
|
184
|
+
return JSON.stringify(v);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Replace the managed JSON-LD blocks on a page. Policy action content.schema.update
|
|
188
|
+
* (approval required on every site). Pass an empty list to remove them; that
|
|
189
|
+
* skips validation deliberately, since removing is a shape-free operation.
|
|
190
|
+
*/
|
|
191
|
+
export async function setSchema(siteIdOrHost, pagePath, blocks, opts = {}) {
|
|
192
|
+
if (blocks.length) {
|
|
193
|
+
const problems = validateJsonLd(blocks);
|
|
194
|
+
if (problems.length)
|
|
195
|
+
return { status: 'invalid', problems };
|
|
196
|
+
}
|
|
197
|
+
const { site, adapter } = await resolve(siteIdOrHost, opts);
|
|
198
|
+
const current = await adapter.readPageSchema(pagePath);
|
|
199
|
+
if (current === null)
|
|
200
|
+
throw new Error(`${adapter.kind} adapter knows no page at ${pagePath} for ${site.site_id}.`);
|
|
201
|
+
const types = (bs) => bs.map((b) => String(b['@type'] ?? '?')).join(', ') || 'none';
|
|
202
|
+
return performWrite(site, {
|
|
203
|
+
action: 'content.schema.update',
|
|
204
|
+
operation: 'content.set_schema',
|
|
205
|
+
summary: `${blocks.length ? 'Set' : 'Removed'} structured data on ${pagePath}: ${types(current)} -> ${types(blocks)}`,
|
|
206
|
+
...(opts.approval ? { approval: opts.approval } : {}),
|
|
207
|
+
before: async () => current,
|
|
208
|
+
execute: () => adapter.writePageSchema(pagePath, blocks),
|
|
209
|
+
after: () => adapter.readPageSchema(pagePath),
|
|
210
|
+
verify: (_b, after) => (after && canonicalJson(after) === canonicalJson(blocks) ? null : `stored blocks are ${after ? types(after) : 'unreadable'}`),
|
|
211
|
+
summarise: (bs) => (bs ? bs.map((b) => ({ type: b['@type'] ?? null, bytes: JSON.stringify(b).length })) : null),
|
|
212
|
+
});
|
|
213
|
+
}
|