@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.
Files changed (116) hide show
  1. package/.env.example +42 -0
  2. package/README.md +110 -0
  3. package/apps/cli/src/index.js +286 -0
  4. package/apps/mcp-server/src/index.js +360 -0
  5. package/apps/worker/src/index.js +96 -0
  6. package/apps/worker/src/loop.js +46 -0
  7. package/bin/gwo.js +67 -0
  8. package/docs/BOOTSTRAP-NEXT-PROMPT.md +57 -0
  9. package/docs/GETTING-STARTED.he.md +63 -0
  10. package/docs/HOSTED-WORKER.md +36 -0
  11. package/docs/INSTALL.he.md +57 -0
  12. package/docs/PRODUCT-TECHNICAL-SPEC-v0.2.md +153 -0
  13. package/docs/PRODUCT-TECHNICAL-SPEC-v0.3.md +86 -0
  14. package/docs/PRODUCT-TECHNICAL-SPEC-v0.4.md +92 -0
  15. package/docs/PRODUCT-TECHNICAL-SPEC-v0.5.md +94 -0
  16. package/docs/PRODUCT-TECHNICAL-SPEC-v0.6.md +98 -0
  17. package/docs/SECURITY.md +69 -0
  18. package/docs/SITE-ADAPTERS.md +87 -0
  19. package/docs/adr/ADR-001-shared-mcp-core.md +27 -0
  20. package/docs/adr/ADR-002-policy-gated-writes.md +18 -0
  21. package/docs/adr/ADR-003-single-source-playbooks.md +18 -0
  22. package/docs/adr/ADR-004-native-typescript-runtime.md +16 -0
  23. package/docs/adr/ADR-005-two-token-publish.md +22 -0
  24. package/docs/adr/ADR-006-one-package-one-installer.md +20 -0
  25. package/hosts/claude/.claude-plugin/plugin.json +9 -0
  26. package/hosts/claude/.mcp.json +10 -0
  27. package/hosts/claude/README.md +7 -0
  28. package/hosts/claude/commands/gwo-add-site.md +7 -0
  29. package/hosts/claude/commands/gwo-daily.md +7 -0
  30. package/hosts/claude/commands/gwo-measure.md +7 -0
  31. package/hosts/claude/commands/gwo-monthly.md +7 -0
  32. package/hosts/claude/commands/gwo-onboard.md +7 -0
  33. package/hosts/claude/commands/gwo-scan.md +7 -0
  34. package/hosts/claude/commands/gwo-setup.md +7 -0
  35. package/hosts/claude/commands/gwo-triage.md +7 -0
  36. package/hosts/claude/commands/gwo-weekly.md +7 -0
  37. package/hosts/claude/skills/gwo-client-report-template/SKILL.md +36 -0
  38. package/hosts/claude/skills/gwo-content-brief/SKILL.md +60 -0
  39. package/hosts/claude/skills/gwo-daily/SKILL.md +47 -0
  40. package/hosts/claude/skills/gwo-measurement/SKILL.md +92 -0
  41. package/hosts/claude/skills/gwo-monthly/SKILL.md +30 -0
  42. package/hosts/claude/skills/gwo-onboarding/SKILL.md +27 -0
  43. package/hosts/claude/skills/gwo-operating-model/SKILL.md +76 -0
  44. package/hosts/claude/skills/gwo-setup/SKILL.md +42 -0
  45. package/hosts/claude/skills/gwo-triage/SKILL.md +37 -0
  46. package/hosts/claude/skills/gwo-weekly/SKILL.md +48 -0
  47. package/hosts/codex/.codex/config.template.toml +5 -0
  48. package/hosts/codex/.codex-plugin/plugin.json +30 -0
  49. package/hosts/codex/AGENTS.md +470 -0
  50. package/hosts/codex/README.md +13 -0
  51. package/hosts/codex/skills/gwo-client-report-template/SKILL.md +36 -0
  52. package/hosts/codex/skills/gwo-content-brief/SKILL.md +60 -0
  53. package/hosts/codex/skills/gwo-daily/SKILL.md +47 -0
  54. package/hosts/codex/skills/gwo-measurement/SKILL.md +92 -0
  55. package/hosts/codex/skills/gwo-monthly/SKILL.md +30 -0
  56. package/hosts/codex/skills/gwo-onboarding/SKILL.md +27 -0
  57. package/hosts/codex/skills/gwo-operating-model/SKILL.md +76 -0
  58. package/hosts/codex/skills/gwo-setup/SKILL.md +42 -0
  59. package/hosts/codex/skills/gwo-triage/SKILL.md +37 -0
  60. package/hosts/codex/skills/gwo-weekly/SKILL.md +48 -0
  61. package/package.json +24 -0
  62. package/packages/connectors/browser/src/index.js +122 -0
  63. package/packages/connectors/docx/src/index.js +122 -0
  64. package/packages/connectors/github/src/index.js +102 -0
  65. package/packages/connectors/google/src/auth.js +73 -0
  66. package/packages/connectors/google/src/discover.js +135 -0
  67. package/packages/connectors/google/src/ga4.js +42 -0
  68. package/packages/connectors/google/src/gsc.js +49 -0
  69. package/packages/connectors/google/src/gtm.js +261 -0
  70. package/packages/connectors/google/src/index.js +8 -0
  71. package/packages/connectors/google/src/oauth.js +198 -0
  72. package/packages/connectors/google/src/pagespeed.js +48 -0
  73. package/packages/connectors/google/src/token-store.js +89 -0
  74. package/packages/connectors/odoo/src/index.js +206 -0
  75. package/packages/connectors/repo/src/index.js +94 -0
  76. package/packages/connectors/resend/src/index.js +30 -0
  77. package/packages/connectors/web-crawler/src/a11y.js +91 -0
  78. package/packages/connectors/web-crawler/src/fetch.js +165 -0
  79. package/packages/connectors/web-crawler/src/index.js +69 -0
  80. package/packages/connectors/web-crawler/src/parse.js +157 -0
  81. package/packages/core/src/adapters.js +27 -0
  82. package/packages/core/src/admin.js +232 -0
  83. package/packages/core/src/content.js +213 -0
  84. package/packages/core/src/doctor.js +39 -0
  85. package/packages/core/src/escalate.js +36 -0
  86. package/packages/core/src/gtm-ops.js +311 -0
  87. package/packages/core/src/index.js +13 -0
  88. package/packages/core/src/operations.js +239 -0
  89. package/packages/core/src/paths.js +46 -0
  90. package/packages/core/src/registry.js +114 -0
  91. package/packages/core/src/report/monthly.js +272 -0
  92. package/packages/core/src/scan/render.js +40 -0
  93. package/packages/core/src/scan/rules.js +289 -0
  94. package/packages/core/src/scan/suppress.js +37 -0
  95. package/packages/core/src/types.js +10 -0
  96. package/packages/core/src/writes.js +50 -0
  97. package/packages/playbooks/00-operating-model.md +74 -0
  98. package/packages/playbooks/10-daily.md +45 -0
  99. package/packages/playbooks/20-weekly.md +46 -0
  100. package/packages/playbooks/30-monthly.md +28 -0
  101. package/packages/playbooks/40-triage.md +35 -0
  102. package/packages/playbooks/50-onboarding.md +25 -0
  103. package/packages/playbooks/60-content-brief.md +58 -0
  104. package/packages/playbooks/70-measurement.md +90 -0
  105. package/packages/playbooks/80-setup.md +40 -0
  106. package/packages/playbooks/90-client-report-template.md +34 -0
  107. package/packages/policy/src/index.js +115 -0
  108. package/packages/storage/src/index.js +131 -0
  109. package/policies/default.yaml +90 -0
  110. package/sites/schema.yaml +71 -0
  111. package/sites/secrets.example.yaml +6 -0
  112. package/tools/build-hosts.js +118 -0
  113. package/tools/build-package.js +43 -0
  114. package/tools/install.js +145 -0
  115. package/tools/schedule.ps1 +37 -0
  116. package/tools/smoke-mcp.js +61 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Word document writer.
3
+ *
4
+ * Builds a .docx from a small block model (headings, paragraphs, bullets,
5
+ * tables), either inside a company .dotx template so the result carries the
6
+ * template's header, footer, fonts and styles, or as a minimal standalone
7
+ * package when no template is configured. Only the document body is
8
+ * generated; everything else in a template is kept byte for byte.
9
+ *
10
+ * Right-to-left documents set <w:bidi/> on every paragraph, <w:rtl/> on
11
+ * every run and <w:bidiVisual/> on tables, which is what Word itself writes
12
+ * for Hebrew text.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import JSZip from 'jszip';
16
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
17
+ function run(text, rtl, bold = false) {
18
+ const props = [bold ? '<w:b/><w:bCs/>' : '', rtl ? '<w:rtl/>' : ''].join('');
19
+ return `<w:r>${props ? `<w:rPr>${props}</w:rPr>` : ''}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
20
+ }
21
+ function para(text, rtl, opts = {}) {
22
+ const pPr = [
23
+ opts.style ? `<w:pStyle w:val="${opts.style}"/>` : '',
24
+ opts.indent ? '<w:ind w:left="360" w:hanging="360"/>' : '',
25
+ rtl ? '<w:bidi/>' : '',
26
+ ].join('');
27
+ return `<w:p>${pPr ? `<w:pPr>${pPr}</w:pPr>` : ''}${run(text, rtl, opts.bold)}</w:p>`;
28
+ }
29
+ function table(header, rows, rtl) {
30
+ const cols = Math.max(header.length, ...rows.map((r) => r.length));
31
+ const width = Math.floor(9026 / Math.max(cols, 1));
32
+ const cell = (text, bold) => `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/></w:tcPr>${para(text, rtl, { bold })}</w:tc>`;
33
+ const row = (cells, bold) => `<w:tr>${Array.from({ length: cols }, (_, i) => cell(cells[i] ?? '', bold)).join('')}</w:tr>`;
34
+ return `<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>${rtl ? '<w:bidiVisual/>' : ''}` +
35
+ `<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/></w:tblPr>` +
36
+ `<w:tblGrid>${Array.from({ length: cols }, () => `<w:gridCol w:w="${width}"/>`).join('')}</w:tblGrid>` +
37
+ row(header, true) + rows.map((r) => row(r, false)).join('') + `</w:tbl>${para('', rtl)}`;
38
+ }
39
+ /** The body content (without <w:body> and without sectPr). Pure. */
40
+ export function bodyXml(model) {
41
+ const { rtl } = model;
42
+ const out = [];
43
+ for (const b of model.blocks) {
44
+ switch (b.kind) {
45
+ case 'title':
46
+ out.push(para(b.text, rtl, { style: 'Title' }));
47
+ break;
48
+ case 'subtitle':
49
+ out.push(para(b.text, rtl, { style: 'Subtitle' }));
50
+ break;
51
+ case 'heading':
52
+ out.push(para(b.text, rtl, { style: `Heading${b.level}` }));
53
+ break;
54
+ case 'paragraph':
55
+ out.push(para(b.text, rtl, { bold: b.bold }));
56
+ break;
57
+ case 'bullets':
58
+ for (const item of b.items)
59
+ out.push(para(`• ${item}`, rtl, { style: 'ListParagraph', indent: true }));
60
+ break;
61
+ case 'table':
62
+ out.push(table(b.header, b.rows, rtl));
63
+ break;
64
+ }
65
+ }
66
+ return out.join('');
67
+ }
68
+ const W_NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
69
+ /** Minimal styles for the standalone package: the same style ids the template uses. */
70
+ function standaloneStyles(rtl) {
71
+ const font = rtl ? 'Arial' : 'Calibri';
72
+ const h = (id, name, size, color) => `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="240" w:after="80"/></w:pPr><w:rPr><w:b/><w:bCs/><w:color w:val="${color}"/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/></w:rPr></w:style>`;
73
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles ${W_NS}>` +
74
+ `<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:cs="${font}"/><w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="en-IL" w:bidi="he-IL"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>` +
75
+ `<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>` +
76
+ h('Title', 'Title', 44, '1F3864') + h('Subtitle', 'Subtitle', 26, '595959') + h('Heading1', 'heading 1', 32, '1F3864') + h('Heading2', 'heading 2', 26, '2F5496') + h('Heading3', 'heading 3', 24, '2F5496') +
77
+ `<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="60"/></w:pPr></w:style>` +
78
+ `<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/><w:tblPr><w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="108" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>` +
79
+ `<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:basedOn w:val="TableNormal"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="BFBFBF"/><w:left w:val="single" w:sz="4" w:color="BFBFBF"/><w:bottom w:val="single" w:sz="4" w:color="BFBFBF"/><w:right w:val="single" w:sz="4" w:color="BFBFBF"/><w:insideH w:val="single" w:sz="4" w:color="BFBFBF"/><w:insideV w:val="single" w:sz="4" w:color="BFBFBF"/></w:tblBorders></w:tblPr></w:style>` +
80
+ `</w:styles>`;
81
+ }
82
+ const DOC_CT = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml';
83
+ /** Build the .docx package. Returns the file bytes. */
84
+ export async function buildDocx(model, opts = {}) {
85
+ const body = bodyXml(model);
86
+ if (opts.templatePath) {
87
+ const zip = await JSZip.loadAsync(readFileSync(opts.templatePath));
88
+ const docFile = zip.file('word/document.xml');
89
+ if (!docFile)
90
+ throw new Error(`${opts.templatePath}: no word/document.xml; not a Word template`);
91
+ const doc = await docFile.async('string');
92
+ const open = doc.indexOf('<w:body>'), sect = doc.lastIndexOf('<w:sectPr');
93
+ if (open < 0 || sect < 0)
94
+ throw new Error(`${opts.templatePath}: body or section properties not found`);
95
+ // Keep the template's section (page size, margins, header and footer references); replace its sample content.
96
+ zip.file('word/document.xml', doc.slice(0, open + '<w:body>'.length) + body + doc.slice(sect));
97
+ // A template part becomes a document part.
98
+ const ct = await zip.file('[Content_Types].xml').async('string');
99
+ zip.file('[Content_Types].xml', ct.replace(/wordprocessingml\.template\.main\+xml/, 'wordprocessingml.document.main+xml'));
100
+ const core = zip.file('docProps/core.xml');
101
+ if (core && model.title) {
102
+ const c = await core.async('string');
103
+ const titleXml = `<dc:title>${esc(model.title)}</dc:title>`;
104
+ zip.file('docProps/core.xml', /<dc:title>.*?<\/dc:title>/s.test(c) ? c.replace(/<dc:title>.*?<\/dc:title>/s, titleXml) : c.replace('</cp:coreProperties>', `${titleXml}</cp:coreProperties>`));
105
+ }
106
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
107
+ }
108
+ const zip = new JSZip();
109
+ zip.file('[Content_Types].xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="${DOC_CT}"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`);
110
+ zip.file('_rels/.rels', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>`);
111
+ zip.file('word/_rels/document.xml.rels', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`);
112
+ zip.file('word/styles.xml', standaloneStyles(model.rtl));
113
+ zip.file('docProps/core.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${esc(model.title ?? '')}</dc:title><dc:creator>Google Web Operations</dc:creator><dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created></cp:coreProperties>`);
114
+ zip.file('word/document.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document ${W_NS}><w:body>${body}<w:sectPr>${model.rtl ? '<w:bidi/>' : ''}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`);
115
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
116
+ }
117
+ /** Read the visible text of a .docx (for verification and tests). */
118
+ export async function docxText(bytes) {
119
+ const zip = await JSZip.loadAsync(bytes);
120
+ const doc = await zip.file('word/document.xml').async('string');
121
+ return doc.replace(/<\/w:p>/g, '\n').replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&amp;/g, '&');
122
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * GitHub: turn manifest changes in a site's local checkout into a pull request.
3
+ *
4
+ * Branch, commit and push use the git CLI in the checkout. The pull request
5
+ * is opened through the REST API with GITHUB_TOKEN (a fine-grained token with
6
+ * "Pull requests: write" and "Contents: write" on the site repositories).
7
+ * Policy: github.pr.create (approval required). Runs inside performWrite:
8
+ * before = clean/dirty state, after = the PR read back from the API.
9
+ */
10
+ import { execFileSync } from 'node:child_process';
11
+ import { performWrite } from "../../../core/src/writes.js";
12
+ import { getSite } from "../../../core/src/registry.js";
13
+ function git(cwd, ...args) {
14
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
15
+ }
16
+ /** owner/repo from a GitHub URL. Pure. */
17
+ export function parseRepo(url) {
18
+ const m = url.replace(/\.git$/, '').match(/github\.com[/:]([^/]+)\/([^/]+)$/);
19
+ if (!m)
20
+ throw new Error(`Not a GitHub repository URL: ${url}`);
21
+ return { owner: m[1], repo: m[2] };
22
+ }
23
+ /** Deterministic branch name for a site and day. Pure. */
24
+ export function branchName(siteId, date = new Date()) {
25
+ return `gwo/${siteId}/${date.toISOString().slice(0, 10)}`;
26
+ }
27
+ /** PR body from the site's recent audit entries. Pure. */
28
+ export function prBody(siteId, changes) {
29
+ return [
30
+ `Automated SEO metadata changes for **${siteId}** proposed by Google Web Operations.`,
31
+ '', 'Changes:', ...changes.map((c) => `- ${c}`), '',
32
+ 'Every change was validated, applied through the site adapter and verified by reading the stored value back. See the audit log for before/after values.',
33
+ '', '🤖 Generated with [Claude Code](https://claude.com/claude-code)',
34
+ ].join('\n');
35
+ }
36
+ async function ghJson(path, init = {}) {
37
+ const token = process.env['GITHUB_TOKEN']?.trim();
38
+ if (!token)
39
+ throw new Error('GITHUB_TOKEN is not set. Create a fine-grained token with Contents and Pull requests write access on the site repositories.');
40
+ const res = await fetch(`https://api.github.com${path}`, {
41
+ ...init,
42
+ headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28', ...(init.headers ?? {}) },
43
+ });
44
+ const body = await res.text();
45
+ if (!res.ok)
46
+ throw new Error(`GitHub ${res.status} ${path}: ${body.slice(0, 400)}`);
47
+ return JSON.parse(body);
48
+ }
49
+ /**
50
+ * Commit whatever changed in the checkout (normally gwo.seo.json), push a
51
+ * branch and open a pull request. Idempotent per day: reuses an open PR for
52
+ * the same branch.
53
+ */
54
+ export async function openPullRequest(siteIdOrHost, { approval, changes = [] } = {}) {
55
+ const site = getSite(siteIdOrHost);
56
+ const repo = site.write?.repo;
57
+ if (!repo?.local_path || !repo.url)
58
+ throw new Error(`Site ${site.site_id}: github needs write.repo.local_path and write.repo.url.`);
59
+ const cwd = repo.local_path, base = repo.branch ?? 'main';
60
+ const { owner, repo: name } = parseRepo(repo.url);
61
+ const branch = branchName(site.site_id);
62
+ const findOpenPr = async () => {
63
+ const prs = await ghJson(`/repos/${owner}/${name}/pulls?state=open&head=${owner}:${branch}`);
64
+ const p = prs[0];
65
+ return p ? { number: p.number, url: p.html_url, branch } : null;
66
+ };
67
+ return performWrite(site, {
68
+ action: 'github.pr.create',
69
+ operation: 'github.open_pull_request',
70
+ summary: `Opened pull request for ${site.site_id} from ${branch}`,
71
+ ...(approval ? { approval } : {}),
72
+ async before() {
73
+ const dirty = git(cwd, 'status', '--porcelain').split('\n').filter(Boolean);
74
+ return { dirty, branch, openPr: await findOpenPr() };
75
+ },
76
+ async execute(before) {
77
+ if (!before.dirty.length && before.openPr)
78
+ return; // nothing new, PR exists
79
+ if (!before.dirty.length)
80
+ throw new Error('Nothing to propose: the checkout has no changes.');
81
+ const current = git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
82
+ if (current !== branch) {
83
+ try {
84
+ git(cwd, 'checkout', '-b', branch);
85
+ }
86
+ catch {
87
+ git(cwd, 'checkout', branch);
88
+ }
89
+ }
90
+ git(cwd, 'add', '-A');
91
+ git(cwd, 'commit', '-m', `SEO metadata updates for ${site.site_id}\n\nProposed by Google Web Operations.\n\nCo-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`);
92
+ git(cwd, 'push', '-u', 'origin', branch);
93
+ if (!before.openPr) {
94
+ await ghJson(`/repos/${owner}/${name}/pulls`, { method: 'POST', body: JSON.stringify({
95
+ title: `SEO metadata updates (${new Date().toISOString().slice(0, 10)})`, head: branch, base, body: prBody(site.site_id, changes),
96
+ }) });
97
+ }
98
+ },
99
+ after: findOpenPr,
100
+ verify: (_b, after) => (after ? null : 'no open pull request found for the branch after push'),
101
+ });
102
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Google API authentication.
3
+ *
4
+ * Connector code only ever asks the active TokenProvider for a bearer token.
5
+ * Provider resolution order, decided once per process:
6
+ * 1. GWO_GOOGLE_AUTH=env force the development bridge
7
+ * 2. a stored OAuth authorisation (secrets/, see oauth.ts)
8
+ * 3. GOOGLE_ACCESS_TOKEN development bridge, expires hourly
9
+ * 4. none clear error naming the fix
10
+ */
11
+ /** Development bridge: GOOGLE_ACCESS_TOKEN from the environment. */
12
+ export const envTokenProvider = {
13
+ async accessToken() {
14
+ const token = process.env['GOOGLE_ACCESS_TOKEN']?.trim();
15
+ if (!token) {
16
+ throw new Error('Google access is not configured. Run "npm run cli -- auth login" once for the operations account, ' +
17
+ 'or set GOOGLE_ACCESS_TOKEN for a short development session.');
18
+ }
19
+ return token;
20
+ },
21
+ describe() { return process.env['GOOGLE_ACCESS_TOKEN'] ? 'env bearer token (development bridge)' : 'not configured'; },
22
+ };
23
+ let provider = null;
24
+ /** Replace the active provider (tests, or a future keychain-backed store). */
25
+ export function setTokenProvider(p) { provider = p; }
26
+ /** The active provider, resolving lazily so importing this module has no side effects. */
27
+ export function tokenProvider() {
28
+ if (provider)
29
+ return provider;
30
+ if (process.env['GWO_GOOGLE_AUTH'] !== 'env') {
31
+ try {
32
+ // Lazy import keeps the crawler-only paths free of any OAuth code.
33
+ const { makeOauthProvider } = require_oauth();
34
+ const oauth = makeOauthProvider();
35
+ if (oauth.status().authorised)
36
+ return (provider = oauth);
37
+ }
38
+ catch { /* fall through to env */ }
39
+ }
40
+ return (provider = envTokenProvider);
41
+ }
42
+ // Synchronous require shim for the lazy import above (ESM has no sync import;
43
+ // the module is small and already loaded by the time any Google tool runs).
44
+ import * as oauthModule from "./oauth.js";
45
+ function require_oauth() { return oauthModule; }
46
+ export class GoogleApiError extends Error {
47
+ status;
48
+ endpoint;
49
+ constructor(status, endpoint, body) {
50
+ super(`Google API ${status} at ${endpoint}: ${body.slice(0, 600)}`);
51
+ this.name = 'GoogleApiError';
52
+ this.status = status;
53
+ this.endpoint = endpoint;
54
+ }
55
+ }
56
+ /**
57
+ * Authenticated JSON request. Throws GoogleApiError on non-2xx.
58
+ * Some Google endpoints answer 204 with no body (sitemap submit, deletes);
59
+ * those resolve to null rather than failing on an empty parse.
60
+ */
61
+ export async function googleJson(url, init = {}) {
62
+ const token = await tokenProvider().accessToken();
63
+ const res = await fetch(url, {
64
+ ...init,
65
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...(init.headers ?? {}) },
66
+ });
67
+ const body = await res.text();
68
+ if (!res.ok)
69
+ throw new GoogleApiError(res.status, new URL(url).pathname, body);
70
+ if (res.status === 204 || body.trim() === '')
71
+ return null;
72
+ return JSON.parse(body);
73
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Property discovery: after consent, find which Search Console properties,
3
+ * GA4 properties and Tag Manager containers the operations account can see,
4
+ * and match them to registered sites by host.
5
+ *
6
+ * The matching is a pure function so it can be tested without Google.
7
+ * Applying the result to site YAML files preserves comments and layout.
8
+ */
9
+ import { readFileSync, writeFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { parseDocument } from 'yaml';
12
+ import { googleJson } from "./auth.js";
13
+ import { SITES_DIR } from "../../../core/src/paths.js";
14
+ import { listSites } from "../../../core/src/registry.js";
15
+ // ---------------------------------------------------------------- fetch inventory
16
+ export async function fetchInventory() {
17
+ const gsc = ((await googleJson('https://www.googleapis.com/webmasters/v3/sites')).siteEntry ?? []);
18
+ const summaries = await googleJson('https://analyticsadmin.googleapis.com/v1beta/accountSummaries?pageSize=200');
19
+ const ga4 = [];
20
+ for (const acc of summaries.accountSummaries ?? []) {
21
+ for (const p of acc.propertySummaries ?? []) {
22
+ let hosts = [];
23
+ try {
24
+ const streams = await googleJson(`https://analyticsadmin.googleapis.com/v1beta/${p.property}/dataStreams`);
25
+ hosts = (streams.dataStreams ?? []).map((s) => hostOf(s.webStreamData?.defaultUri)).filter((h) => !!h);
26
+ }
27
+ catch { /* no permission on streams; match by name later */ }
28
+ ga4.push({ property: p.property, displayName: p.displayName, account: acc.account, hosts });
29
+ }
30
+ }
31
+ const gtm = [];
32
+ try {
33
+ const accounts = await googleJson('https://tagmanager.googleapis.com/tagmanager/v2/accounts');
34
+ for (const a of accounts.account ?? []) {
35
+ const cs = await googleJson(`https://tagmanager.googleapis.com/tagmanager/v2/accounts/${a.accountId}/containers`);
36
+ for (const c of cs.container ?? [])
37
+ gtm.push({ accountId: a.accountId, containerId: c.containerId, publicId: c.publicId, name: c.name, domains: c.domainName ?? [] });
38
+ }
39
+ }
40
+ catch { /* GTM scope may not be granted yet */ }
41
+ return { gsc, ga4, gtm };
42
+ }
43
+ /** Fuzzy name match: a token of 4+ letters shared between the property name and the host label. */
44
+ export function nameMatchesHost(displayName, host) {
45
+ const label = (host.split('.')[0] ?? '').toLowerCase();
46
+ if (label.length < 4)
47
+ return false;
48
+ const tokens = displayName.toLowerCase().split(/[^a-z0-9֐-׿]+/).filter((t) => t.length >= 4);
49
+ return tokens.some((t) => label.includes(t) || t.includes(label));
50
+ }
51
+ export function hostOf(u) {
52
+ if (!u)
53
+ return null;
54
+ try {
55
+ return new URL(u.includes('://') ? u : `https://${u}`).host.toLowerCase().replace(/^www\./, '');
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /** Match inventory to sites by bare host (www stripped). Pure, testable. */
62
+ export function matchInventory(sites, inv) {
63
+ return sites.map((site) => {
64
+ const host = hostOf(site.production_url) ?? '';
65
+ const unmatched = [];
66
+ // Search Console: the domain property, else a URL-prefix property on the same host. Both exact.
67
+ const gscHit = inv.gsc.find((s) => s.siteUrl === `sc-domain:${host}`)
68
+ ?? inv.gsc.find((s) => !s.siteUrl.startsWith('sc-domain:') && hostOf(s.siteUrl) === host) ?? null;
69
+ if (!gscHit)
70
+ unmatched.push('search_console');
71
+ // GA4: exact by data-stream host; otherwise a name guess the operator must confirm.
72
+ const ga4ByHost = inv.ga4.find((p) => p.hosts.includes(host));
73
+ const ga4ByName = ga4ByHost ? undefined : inv.ga4.find((p) => nameMatchesHost(p.displayName, host));
74
+ const ga4Hit = ga4ByHost ?? ga4ByName ?? null;
75
+ if (!ga4Hit)
76
+ unmatched.push('ga4');
77
+ // GTM: exact by declared domain, exact by a public id the site file already knows.
78
+ const knownPublic = site.google.tag_manager?.public_id ?? null;
79
+ const gtmByDomain = inv.gtm.find((c) => c.domains.some((d) => hostOf(d) === host));
80
+ const gtmById = gtmByDomain ? undefined : (knownPublic ? inv.gtm.find((c) => c.publicId === knownPublic) : undefined);
81
+ const gtmHit = gtmByDomain ?? gtmById ?? null;
82
+ if (!gtmHit)
83
+ unmatched.push('tag_manager');
84
+ return {
85
+ site_id: site.site_id,
86
+ gsc: gscHit ? { property: gscHit.siteUrl, via: 'host', current: site.google.search_console?.property ?? null } : null,
87
+ ga4: ga4Hit ? { property_id: ga4Hit.property.replace(/^properties\//, ''), displayName: ga4Hit.displayName, via: ga4ByHost ? 'host' : 'name', current: site.google.ga4?.property_id ?? null } : null,
88
+ gtm: gtmHit ? { public_id: gtmHit.publicId, account_id: gtmHit.accountId, container_id: gtmHit.containerId, via: gtmByDomain ? 'host' : 'id', current: knownPublic } : null,
89
+ unmatched,
90
+ };
91
+ });
92
+ }
93
+ // ---------------------------------------------------------------- apply to YAML
94
+ /**
95
+ * Write matched ids into sites/<file>.yaml, keeping comments and order.
96
+ * Name-based guesses are never applied automatically; the operator sets them by hand
97
+ * after checking. Returns the files changed.
98
+ */
99
+ export function applyMatches(matches) {
100
+ const changed = [];
101
+ const byId = new Map(listSites({ includeDisabled: true }).map((s) => [s.site_id, s]));
102
+ for (const m of matches) {
103
+ const site = byId.get(m.site_id);
104
+ if (!site)
105
+ continue;
106
+ const file = path.join(SITES_DIR, site._file);
107
+ const doc = parseDocument(readFileSync(file, 'utf8'));
108
+ let touched = false;
109
+ const set = (p, v) => { if (doc.getIn(p) !== v) {
110
+ doc.setIn(p, v);
111
+ touched = true;
112
+ } };
113
+ if (m.gsc && m.gsc.via !== 'name')
114
+ set(['google', 'search_console', 'property'], m.gsc.property);
115
+ if (m.ga4 && m.ga4.via !== 'name')
116
+ set(['google', 'ga4', 'property_id'], m.ga4.property_id);
117
+ if (m.gtm && m.gtm.via !== 'name') {
118
+ set(['google', 'tag_manager', 'public_id'], m.gtm.public_id);
119
+ set(['google', 'tag_manager', 'account_id'], m.gtm.account_id);
120
+ set(['google', 'tag_manager', 'container_id'], m.gtm.container_id);
121
+ }
122
+ if (touched) {
123
+ writeFileSync(file, doc.toString());
124
+ changed.push(site._file);
125
+ }
126
+ }
127
+ return changed;
128
+ }
129
+ /** Fetch, match, and optionally apply. */
130
+ export async function discover({ apply = false } = {}) {
131
+ const inventory = await fetchInventory();
132
+ const matches = matchInventory(listSites({ includeDisabled: true }), inventory);
133
+ const applied = apply ? applyMatches(matches) : [];
134
+ return { inventory, matches, applied };
135
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Google Analytics 4. Data API for reports, Admin API for configuration.
3
+ * Read-only in v0.2; ga4.key_event.create is the planned first write.
4
+ */
5
+ import { googleJson } from "./auth.js";
6
+ const DATA = 'https://analyticsdata.googleapis.com/v1beta';
7
+ const ADMIN = 'https://analyticsadmin.googleapis.com/v1beta';
8
+ export function runReport(propertyId, body) {
9
+ return googleJson(`${DATA}/properties/${encodeURIComponent(propertyId)}:runReport`, {
10
+ method: 'POST', body: JSON.stringify({ limit: 100, ...body }),
11
+ });
12
+ }
13
+ export function runRealtime(propertyId, body) {
14
+ return googleJson(`${DATA}/properties/${encodeURIComponent(propertyId)}:runRealtimeReport`, {
15
+ method: 'POST', body: JSON.stringify({ limit: 50, ...body }),
16
+ });
17
+ }
18
+ export function listKeyEvents(propertyId) {
19
+ return googleJson(`${ADMIN}/properties/${encodeURIComponent(propertyId)}/keyEvents`);
20
+ }
21
+ export function listDataStreams(propertyId) {
22
+ return googleJson(`${ADMIN}/properties/${encodeURIComponent(propertyId)}/dataStreams`);
23
+ }
24
+ /**
25
+ * Mark an event as a key event (conversion). Policy: ga4.key_event.create
26
+ * (approval required). Needs the analytics.edit scope; without it Google
27
+ * answers 403 and the caller should run `auth login --scope ga4_write`.
28
+ */
29
+ export function createKeyEvent(propertyId, eventName, countingMethod = 'ONCE_PER_EVENT') {
30
+ return googleJson(`${ADMIN}/properties/${encodeURIComponent(propertyId)}/keyEvents`, {
31
+ method: 'POST', body: JSON.stringify({ eventName, countingMethod }),
32
+ });
33
+ }
34
+ /** Sessions, users, key events by channel for a window. Bounded output. */
35
+ export async function summariseTraffic(propertyId, startDate, endDate) {
36
+ return runReport(propertyId, {
37
+ dateRanges: [{ startDate, endDate }],
38
+ dimensions: [{ name: 'sessionDefaultChannelGroup' }],
39
+ metrics: [{ name: 'sessions' }, { name: 'totalUsers' }, { name: 'keyEvents' }, { name: 'engagementRate' }],
40
+ limit: 20,
41
+ });
42
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Google Search Console. Read-only in v0.2.
3
+ *
4
+ * Search Analytics can return up to 25,000 rows per call. The raw call is
5
+ * exposed for completeness, but hosts should prefer summarise*() helpers,
6
+ * which keep results inside a model's context budget.
7
+ */
8
+ import { googleJson } from "./auth.js";
9
+ const V3 = 'https://www.googleapis.com/webmasters/v3';
10
+ const V1 = 'https://searchconsole.googleapis.com/v1';
11
+ export function searchAnalytics(siteUrl, body) {
12
+ return googleJson(`${V3}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, {
13
+ method: 'POST', body: JSON.stringify({ rowLimit: 1000, ...body }),
14
+ });
15
+ }
16
+ export function listSitemaps(siteUrl) {
17
+ return googleJson(`${V3}/sites/${encodeURIComponent(siteUrl)}/sitemaps`);
18
+ }
19
+ /** Submit (or resubmit) a sitemap. Policy class: gsc.sitemap.submit (auto). */
20
+ export async function submitSitemap(siteUrl, feedpath) {
21
+ await googleJson(`${V3}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`, { method: 'PUT' });
22
+ }
23
+ export function inspectUrl(siteUrl, inspectionUrl, languageCode = 'en-US') {
24
+ return googleJson(`${V1}/urlInspection/index:inspect`, {
25
+ method: 'POST', body: JSON.stringify({ inspectionUrl, siteUrl, languageCode }),
26
+ });
27
+ }
28
+ /**
29
+ * Top queries and pages for a window, plus totals. Bounded output.
30
+ */
31
+ export async function summarisePerformance(siteUrl, startDate, endDate, top = 20) {
32
+ const [byQuery, byPage, totals] = await Promise.all([
33
+ searchAnalytics(siteUrl, { startDate, endDate, dimensions: ['query'], rowLimit: 5000 }),
34
+ searchAnalytics(siteUrl, { startDate, endDate, dimensions: ['page'], rowLimit: 5000 }),
35
+ searchAnalytics(siteUrl, { startDate, endDate, rowLimit: 1 }),
36
+ ]);
37
+ const shape = (rows = []) => rows.map((r) => ({ key: r.keys[0] ?? '', clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position }));
38
+ const q = shape(byQuery.rows), p = shape(byPage.rows), t = totals.rows?.[0];
39
+ return {
40
+ window: { startDate, endDate },
41
+ totals: t ? { clicks: t.clicks, impressions: t.impressions, ctr: t.ctr, position: t.position } : null,
42
+ queries: q.length, pages: p.length,
43
+ topQueriesByClicks: [...q].sort((a, b) => b.clicks - a.clicks).slice(0, top),
44
+ topPagesByClicks: [...p].sort((a, b) => b.clicks - a.clicks).slice(0, top),
45
+ // Position 4-15 with real impressions: the weekly playbook's "striking distance".
46
+ strikingDistance: q.filter((r) => r.position >= 4 && r.position <= 15 && r.impressions >= 50)
47
+ .sort((a, b) => b.impressions - a.impressions).slice(0, top),
48
+ };
49
+ }