@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,46 @@
1
+ /**
2
+ * Where things live.
3
+ *
4
+ * Two layouts, decided once at startup:
5
+ *
6
+ * checkout the repository itself (has .git, not under node_modules):
7
+ * sites/, .gwo-data/, secrets/ and .env sit next to the code.
8
+ * installed the package under a global node_modules (npm i -g), or any
9
+ * copy without .git: operator state lives in GWO_HOME, default
10
+ * ~/.gwo, so upgrading the package never touches sites, data
11
+ * or secrets.
12
+ *
13
+ * Code paths (playbooks, hosts, the default policy) always come from the
14
+ * package. Each state path can still be overridden by its own variable.
15
+ * Everything resolves from this file's location, never from the working
16
+ * directory: the MCP server is launched by hosts from arbitrary directories.
17
+ */
18
+ import { existsSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import path from 'node:path';
21
+ /** Package root: packages/core/src -> ../../.. */
22
+ export const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
23
+ const env = (name) => process.env[name]?.trim() || undefined;
24
+ const underNodeModules = /[\\/]node_modules[\\/]/.test(ROOT);
25
+ /** Operator state directory. */
26
+ export const HOME_DIR = env('GWO_HOME') ?? (!underNodeModules && existsSync(path.join(ROOT, '.git')) ? ROOT : path.join(homedir(), '.gwo'));
27
+ export const IS_CHECKOUT = HOME_DIR === ROOT;
28
+ // Operator configuration from <home>/.env (gitignored; see .env.example), and
29
+ // from <root>/.env in a checkout. Loaded here because every app imports this
30
+ // module first and hosts start the MCP server with no environment of their
31
+ // own. Variables already set in the process win.
32
+ for (const f of [...new Set([path.join(HOME_DIR, '.env'), path.join(ROOT, '.env')])]) {
33
+ try {
34
+ if (existsSync(f))
35
+ process.loadEnvFile(f);
36
+ }
37
+ catch { /* unreadable .env: run without it */ }
38
+ }
39
+ export const SITES_DIR = env('GWO_SITES_DIR') ?? path.join(HOME_DIR, 'sites');
40
+ export const DATA_DIR = env('GWO_DATA_DIR') ?? path.join(HOME_DIR, '.gwo-data');
41
+ export const SECRETS_DIR = env('GWO_SECRETS_DIR') ?? path.join(HOME_DIR, 'secrets');
42
+ /** The operator may keep a tightened copy of the policy in the home directory; otherwise the package's. */
43
+ export const POLICY_FILE = env('GWO_POLICY_FILE')
44
+ ?? (existsSync(path.join(HOME_DIR, 'policies', 'default.yaml')) ? path.join(HOME_DIR, 'policies', 'default.yaml') : path.join(ROOT, 'policies', 'default.yaml'));
45
+ export const PLAYBOOKS_DIR = path.join(ROOT, 'packages', 'playbooks');
46
+ export const HOSTS_DIR = path.join(ROOT, 'hosts');
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Site registry: one YAML file per site under sites/.
3
+ *
4
+ * Registry files hold references (ids, hosts, property names) and policy.
5
+ * Credentials live in sites/<host>.secrets.yaml, which is gitignored and
6
+ * merged in at load time under a separate key so they never leak into
7
+ * public views.
8
+ */
9
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { parse } from 'yaml';
12
+ import { SITES_DIR } from "./paths.js";
13
+ const REQUIRED = ['site_id', 'name', 'production_url'];
14
+ /** Fill defaults and validate the few fields everything else depends on. */
15
+ function normalise(raw, file) {
16
+ for (const k of REQUIRED) {
17
+ if (raw[k] === undefined || raw[k] === null || raw[k] === '') {
18
+ throw new Error(`${file}: missing required field "${k}"`);
19
+ }
20
+ }
21
+ const productionUrl = String(raw.production_url);
22
+ try {
23
+ new URL(productionUrl);
24
+ }
25
+ catch {
26
+ throw new Error(`${file}: production_url is not a URL`);
27
+ }
28
+ const seo = raw.seo ?? {};
29
+ const policy = raw.policy ?? {};
30
+ const cadence = raw.cadence ?? {};
31
+ return {
32
+ site_id: String(raw.site_id),
33
+ name: String(raw.name),
34
+ enabled: raw.enabled !== false,
35
+ production_url: productionUrl,
36
+ alt_hosts: raw.alt_hosts ?? [],
37
+ locales: raw.locales ?? [],
38
+ default_locale: String(raw.default_locale ?? raw.locales?.[0] ?? 'he'),
39
+ timezone: String(raw.timezone ?? 'Asia/Jerusalem'),
40
+ platform: raw.platform ?? 'custom',
41
+ tier: raw.tier ?? 'full',
42
+ seo: {
43
+ sitemap: seo.sitemap ?? '/sitemap.xml',
44
+ robots: seo.robots ?? '/robots.txt',
45
+ ...(seo.trailing_slash !== undefined ? { trailing_slash: seo.trailing_slash } : {}),
46
+ },
47
+ google: raw.google ?? {},
48
+ write: raw.write ?? { adapter: 'none' },
49
+ ...(raw.conversions ? { conversions: raw.conversions } : {}),
50
+ ...(raw.findings ? { findings: raw.findings } : {}),
51
+ policy: {
52
+ profile: policy.profile ?? 'standard-client-site',
53
+ ...(policy.overrides ? { overrides: policy.overrides } : {}),
54
+ },
55
+ cadence: { daily: cadence.daily ?? true, weekly: cadence.weekly ?? true, monthly: cadence.monthly ?? true },
56
+ ...(raw.contacts ? { contacts: raw.contacts } : {}),
57
+ ...(raw.notes ? { notes: String(raw.notes) } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * A site file is any *.yaml in sites/ that is not documentation, a template,
62
+ * an example, or a secrets file. Exported so tests can pin the rule.
63
+ */
64
+ export function isSiteFile(name) {
65
+ return name.endsWith('.yaml')
66
+ && !name.endsWith('.secrets.yaml')
67
+ && !name.endsWith('.example.yaml')
68
+ && !name.startsWith('schema')
69
+ && !name.startsWith('_');
70
+ }
71
+ /** Load all sites. Disabled sites are excluded unless asked for. */
72
+ export function listSites({ includeDisabled = false } = {}) {
73
+ if (!existsSync(SITES_DIR))
74
+ return [];
75
+ const files = readdirSync(SITES_DIR).filter(isSiteFile);
76
+ const out = [];
77
+ for (const file of files) {
78
+ const full = path.join(SITES_DIR, file);
79
+ const raw = parse(readFileSync(full, 'utf8'));
80
+ const site = { ...normalise(raw, file), _file: file };
81
+ const secretsFile = full.replace(/\.yaml$/, '.secrets.yaml');
82
+ if (existsSync(secretsFile)) {
83
+ site._secrets = parse(readFileSync(secretsFile, 'utf8'));
84
+ }
85
+ if (site.enabled || includeDisabled)
86
+ out.push(site);
87
+ }
88
+ return out.sort((a, b) => a.site_id.localeCompare(b.site_id));
89
+ }
90
+ /** Find one site by id or by any of its hosts. */
91
+ export function getSite(idOrHost) {
92
+ const all = listSites({ includeDisabled: true });
93
+ const needle = idOrHost.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
94
+ const hit = all.find((s) => s.site_id.toLowerCase() === needle ||
95
+ hostOf(s.production_url) === needle ||
96
+ s.alt_hosts.some((h) => hostOf(h) === needle));
97
+ if (!hit) {
98
+ throw new Error(`Unknown site "${idOrHost}". Known: ${all.map((s) => s.site_id).join(', ') || '(none)'}`);
99
+ }
100
+ return hit;
101
+ }
102
+ /** The site record without secrets or loader metadata. Safe to return to a host. */
103
+ export function publicView(site) {
104
+ const { _secrets: _s, _file: _f, ...rest } = site;
105
+ return rest;
106
+ }
107
+ function hostOf(u) {
108
+ try {
109
+ return new URL(u).host.toLowerCase();
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Monthly client report.
3
+ *
4
+ * Three parts, kept separate so each is testable on its own:
5
+ * collectMonthly() gathers the month's numbers: scan health now and
6
+ * a month ago, Search Console and GA4 totals for
7
+ * this month and last, and what the audit log says
8
+ * was done, blocked or proposed.
9
+ * composeClientReport() turns numbers plus the agent's narrative into the
10
+ * client-facing document model (Hebrew or English),
11
+ * following the gwo-client-report-template playbook:
12
+ * three questions, one small table, no jargon. Pure.
13
+ * renderClientReport() writes the .docx (inside the company template when
14
+ * GWO_DOCX_TEMPLATE is set) and a Markdown twin.
15
+ */
16
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { ga4, gsc } from "../../../connectors/google/src/index.js";
19
+ import { buildDocx } from "../../../connectors/docx/src/index.js";
20
+ import * as storage from "../../../storage/src/index.js";
21
+ import { DATA_DIR } from "../paths.js";
22
+ import { getSite } from "../registry.js";
23
+ /** "YYYY-MM" -> this month and the previous one, as inclusive ISO date windows. */
24
+ export function monthWindows(month) {
25
+ const m = month.match(/^(\d{4})-(\d{2})$/);
26
+ if (!m)
27
+ throw new Error(`month must be YYYY-MM, got "${month}"`);
28
+ const y = Number(m[1]), mo = Number(m[2]) - 1;
29
+ const iso = (d) => d.toISOString().slice(0, 10);
30
+ const first = new Date(Date.UTC(y, mo, 1)), last = new Date(Date.UTC(y, mo + 1, 0));
31
+ const pFirst = new Date(Date.UTC(y, mo - 1, 1)), pLast = new Date(Date.UTC(y, mo, 0));
32
+ return { month: { start: iso(first), end: iso(last) }, previous: { start: iso(pFirst), end: iso(pLast) }, label: first };
33
+ }
34
+ /** The month before the current one: the one a report is normally written for. */
35
+ export function defaultMonth(now = new Date()) {
36
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
37
+ return d.toISOString().slice(0, 7);
38
+ }
39
+ const problems = (s) => s.blocker + s.high + s.medium;
40
+ export async function collectMonthly(site, month) {
41
+ const windows = monthWindows(month);
42
+ const scan = storage.loadScan(site.site_id);
43
+ const snaps = storage.listSnapshots(site.site_id);
44
+ const beforeName = [...snaps].reverse().find((n) => n < windows.month.start);
45
+ const beforeSnap = beforeName ? storage.loadSnapshot(site.site_id, beforeName) : null;
46
+ const data = {
47
+ siteId: site.site_id, siteName: site.name, month, windows,
48
+ health: {
49
+ now: scan ? { pages: scan.pagesCrawled, summary: scan.summary, suppressed: scan.suppressed?.count ?? 0 } : null,
50
+ before: beforeSnap?.findingsBySeverity ?? null,
51
+ },
52
+ search: null, searchError: null, traffic: null, trafficError: null,
53
+ activity: { scans: 0, writes: {}, blocked: 0, proposed: 0 },
54
+ };
55
+ const property = site.google.search_console?.property;
56
+ if (property) {
57
+ try {
58
+ const totals = async (w) => {
59
+ const r = await gsc.summarisePerformance(property, w.start, w.end, 5);
60
+ return { clicks: r.totals?.clicks ?? 0, impressions: r.totals?.impressions ?? 0, position: r.totals?.position ?? null };
61
+ };
62
+ data.search = { now: await totals(windows.month), before: await totals(windows.previous) };
63
+ }
64
+ catch (e) {
65
+ data.searchError = e.message;
66
+ }
67
+ }
68
+ const pid = site.google.ga4?.property_id;
69
+ if (pid) {
70
+ try {
71
+ const sum = async (w) => {
72
+ const r = (await ga4.summariseTraffic(pid, w.start, w.end));
73
+ let sessions = 0, keyEvents = 0;
74
+ for (const row of r.rows ?? []) {
75
+ sessions += Number(row.metricValues?.[0]?.value ?? 0);
76
+ keyEvents += Number(row.metricValues?.[2]?.value ?? 0);
77
+ }
78
+ return { sessions, keyEvents };
79
+ };
80
+ data.traffic = { now: await sum(windows.month), before: await sum(windows.previous) };
81
+ }
82
+ catch (e) {
83
+ data.trafficError = e.message;
84
+ }
85
+ }
86
+ const inMonth = (r) => r.at >= windows.month.start && r.at < `${windows.month.end}T23:59:59.999Z`;
87
+ for (const r of storage.readAudit(site.site_id, { limit: 10_000 }).filter(inMonth)) {
88
+ if (r.operation === 'web.scan')
89
+ data.activity.scans++;
90
+ else if (r.status === 'success' && r.verified)
91
+ data.activity.writes[r.operation] = (data.activity.writes[r.operation] ?? 0) + 1;
92
+ else if (r.status === 'blocked')
93
+ data.activity.blocked++;
94
+ else if (r.status === 'proposed')
95
+ data.activity.proposed++;
96
+ }
97
+ return data;
98
+ }
99
+ const T = {
100
+ he: {
101
+ title: (name) => `דוח חודשי: ${name}`,
102
+ by: 'AI Guru, תפעול אתרים',
103
+ how: 'איך האתר מתפקד', did: 'מה עשינו החודש', need: 'מה אנחנו צריכים מכם', next: 'החודש הבא',
104
+ cols: ['', 'החודש', 'חודש שעבר', 'שינוי'],
105
+ rows: ['ביקורים מגוגל', 'הופעות בתוצאות החיפוש', 'מיקום ממוצע'],
106
+ noSearch: 'נתוני החיפוש של גוגל עדיין לא מחוברים לדוח הזה. נחבר אותם ברגע שהגישה תאושר.',
107
+ traffic: (s, k, ps) => `בסך הכול ${fmt(s)} ביקורים באתר החודש (${fmt(ps)} בחודש שעבר), מתוכם ${fmt(k)} פניות שנמדדו.`,
108
+ health: (pages, now, before) => `בבדיקה הטכנית האחרונה נסרקו ${fmt(pages)} עמודים. ${now === 0 ? 'לא נמצאו בעיות שדורשות טיפול' : `${fmt(now)} נושאים דורשים טיפול`}${before !== null ? ` (${fmt(before)} בתחילת החודש)` : ''}.`,
109
+ autoHeadline: (c, p) => c === 0 && p === 0 ? 'האתר עדיין בשלב בניית הנוכחות בגוגל: התנועה מחיפוש נמוכה, וזה צפוי בשלב הזה.' : c >= p ? `יותר ביקורים מגוגל מאשר בחודש שעבר: ${fmt(c)} לעומת ${fmt(p)}.` : `פחות ביקורים מגוגל מאשר בחודש שעבר: ${fmt(c)} לעומת ${fmt(p)}. פירוט בהמשך.`,
110
+ scans: (n) => `ביצענו ${fmt(n)} בדיקות אוטומטיות של האתר, כולל מעקב יומי אחרי שינויים ותקלות.`,
111
+ ops: {
112
+ 'gsc.submit_sitemap': () => 'הגשנו לגוגל מפת אתר מעודכנת.',
113
+ 'content.set_meta_description': (n) => `כתבנו תיאורים לתוצאות החיפוש עבור ${fmt(n)} עמודים.`,
114
+ 'content.set_title': (n) => `שיפרנו כותרות ב-${fmt(n)} עמודים.`,
115
+ 'content.set_schema': (n) => `הוספנו נתונים מובנים שעוזרים לגוגל ולמנועי AI להבין ${fmt(n)} עמודים.`,
116
+ 'ga4.create_key_event': () => 'הגדרנו את מדידת הפניות באנליטיקס.',
117
+ 'gtm.publish': () => 'עדכנו את מערך המדידה באתר.',
118
+ 'gtm.workspace.create': null, 'gtm.stage.tag': null, 'gtm.stage.trigger': null, 'gtm.stage.variable': null,
119
+ 'github.open_pr': (n) => `הגשנו ${fmt(n)} חבילות שינויים לאישור המפתח.`,
120
+ },
121
+ otherOp: (n) => `בוצעו ${fmt(n)} שינויים נוספים באתר, כולם אומתו.`,
122
+ nothingDone: 'החודש עקבנו אחרי האתר ולא נדרש שינוי.',
123
+ pending: (n) => `${fmt(n)} שינויים מוצעים ממתינים לאישורכם. נשמח לעבור עליהם יחד.`,
124
+ nothingNeeded: 'אין צורך בדבר החודש.',
125
+ defaultNext: 'נמשיך במעקב היומי ובשיפורים השבועיים, ונעדכן על כל שינוי מהותי.',
126
+ },
127
+ en: {
128
+ title: (name) => `Monthly report: ${name}`,
129
+ by: 'AI Guru, web operations',
130
+ how: 'How the site is doing', did: 'What we did this month', need: 'What we need from you', next: 'Next month',
131
+ cols: ['', 'This month', 'Last month', 'Change'],
132
+ rows: ['Visits from Google', 'Times shown in results', 'Average position'],
133
+ noSearch: 'Google Search data is not connected to this report yet. It will be as soon as access is granted.',
134
+ traffic: (s, k, ps) => `${fmt(s, 'en')} visits in total this month (${fmt(ps, 'en')} last month), of which ${fmt(k, 'en')} measured enquiries.`,
135
+ health: (pages, now, before) => `The last technical check covered ${fmt(pages, 'en')} pages. ${now === 0 ? 'Nothing needs attention' : `${fmt(now, 'en')} items need attention`}${before !== null ? ` (${fmt(before, 'en')} at the start of the month)` : ''}.`,
136
+ autoHeadline: (c, p) => c === 0 && p === 0 ? 'The site is still building its presence on Google: search traffic is low, which is expected at this stage.' : c >= p ? `More visits from Google than last month: ${fmt(c, 'en')} against ${fmt(p, 'en')}.` : `Fewer visits from Google than last month: ${fmt(c, 'en')} against ${fmt(p, 'en')}. Details below.`,
137
+ scans: (n) => `We ran ${fmt(n, 'en')} automated checks of the site, including daily monitoring for changes and faults.`,
138
+ ops: {
139
+ 'gsc.submit_sitemap': () => 'Submitted an updated sitemap to Google.',
140
+ 'content.set_meta_description': (n) => `Wrote search-result descriptions for ${fmt(n, 'en')} pages.`,
141
+ 'content.set_title': (n) => `Improved titles on ${fmt(n, 'en')} pages.`,
142
+ 'content.set_schema': (n) => `Added structured data that helps Google and AI engines understand ${fmt(n, 'en')} pages.`,
143
+ 'ga4.create_key_event': () => 'Set up enquiry measurement in Analytics.',
144
+ 'gtm.publish': () => 'Updated the site\'s measurement setup.',
145
+ 'gtm.workspace.create': null, 'gtm.stage.tag': null, 'gtm.stage.trigger': null, 'gtm.stage.variable': null,
146
+ 'github.open_pr': (n) => `Submitted ${fmt(n, 'en')} change sets for the developer to approve.`,
147
+ },
148
+ otherOp: (n) => `${fmt(n, 'en')} further changes were made to the site, all verified.`,
149
+ nothingDone: 'This month we monitored the site and no change was needed.',
150
+ pending: (n) => `${fmt(n, 'en')} proposed changes are waiting for your approval. We would be glad to go through them with you.`,
151
+ nothingNeeded: 'Nothing this month.',
152
+ defaultNext: 'We continue the daily monitoring and weekly improvements, and will flag anything material.',
153
+ },
154
+ };
155
+ function fmt(n, lang = 'he') {
156
+ return new Intl.NumberFormat(lang === 'he' ? 'he-IL' : 'en-GB', { maximumFractionDigits: 1 }).format(n);
157
+ }
158
+ function delta(now, before, lang, invert = false) {
159
+ if (before === 0 && now === 0)
160
+ return '–';
161
+ const d = now - before;
162
+ const good = invert ? d < 0 : d > 0;
163
+ const sign = d > 0 ? '+' : '';
164
+ const pct = before ? ` (${sign}${Math.round((d / before) * 100)}%)` : '';
165
+ return `${sign}${fmt(d, lang)}${pct}${d === 0 ? '' : good ? ' ▲' : ' ▼'}`;
166
+ }
167
+ function monthLabel(d, lang) {
168
+ return new Intl.DateTimeFormat(lang === 'he' ? 'he' : 'en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(d);
169
+ }
170
+ /** Client-facing document from numbers plus narrative. Pure. */
171
+ export function composeClientReport(data, narrative, lang) {
172
+ const t = T[lang];
173
+ const blocks = [];
174
+ const title = t.title(data.siteName);
175
+ blocks.push({ kind: 'title', text: title });
176
+ blocks.push({ kind: 'subtitle', text: `${monthLabel(data.windows.label, lang)} · ${t.by}` });
177
+ // 1. How the site is doing
178
+ blocks.push({ kind: 'heading', level: 1, text: t.how });
179
+ const s = data.search;
180
+ blocks.push({ kind: 'paragraph', text: narrative.headline ?? (s ? t.autoHeadline(s.now.clicks, s.before.clicks) : t.noSearch) });
181
+ if (s) {
182
+ blocks.push({ kind: 'table', header: t.cols, rows: [
183
+ [t.rows[0], fmt(s.now.clicks, lang), fmt(s.before.clicks, lang), delta(s.now.clicks, s.before.clicks, lang)],
184
+ [t.rows[1], fmt(s.now.impressions, lang), fmt(s.before.impressions, lang), delta(s.now.impressions, s.before.impressions, lang)],
185
+ [t.rows[2], s.now.position === null ? '–' : fmt(s.now.position, lang), s.before.position === null ? '–' : fmt(s.before.position, lang),
186
+ s.now.position !== null && s.before.position !== null ? delta(s.now.position, s.before.position, lang, true) : '–'],
187
+ ] });
188
+ }
189
+ if (data.traffic)
190
+ blocks.push({ kind: 'paragraph', text: t.traffic(data.traffic.now.sessions, data.traffic.now.keyEvents, data.traffic.before.sessions) });
191
+ if (data.health.now) {
192
+ blocks.push({ kind: 'paragraph', text: t.health(data.health.now.pages, problems(data.health.now.summary), data.health.before ? problems(data.health.before) : null) });
193
+ }
194
+ // 2. What we did
195
+ blocks.push({ kind: 'heading', level: 1, text: t.did });
196
+ let did = narrative.did;
197
+ if (!did) {
198
+ did = [];
199
+ if (data.activity.scans)
200
+ did.push(t.scans(data.activity.scans));
201
+ let other = 0;
202
+ for (const [op, n] of Object.entries(data.activity.writes)) {
203
+ const phrase = t.ops[op];
204
+ if (phrase === null)
205
+ continue; // internal step, covered by its parent operation
206
+ if (phrase)
207
+ did.push(phrase(n));
208
+ else
209
+ other += n;
210
+ }
211
+ if (other)
212
+ did.push(t.otherOp(other));
213
+ if (!did.length)
214
+ did.push(t.nothingDone);
215
+ }
216
+ blocks.push({ kind: 'bullets', items: did });
217
+ // 3. What we need
218
+ blocks.push({ kind: 'heading', level: 1, text: t.need });
219
+ const need = narrative.need ?? (data.activity.blocked + data.activity.proposed ? [t.pending(data.activity.blocked + data.activity.proposed)] : [t.nothingNeeded]);
220
+ blocks.push({ kind: 'bullets', items: need });
221
+ // 4. Next month
222
+ blocks.push({ kind: 'heading', level: 1, text: t.next });
223
+ blocks.push({ kind: 'paragraph', text: narrative.next ?? t.defaultNext });
224
+ return { title, lang, blocks, markdown: toMarkdown(blocks) };
225
+ }
226
+ function toMarkdown(blocks) {
227
+ const out = [];
228
+ for (const b of blocks) {
229
+ switch (b.kind) {
230
+ case 'title':
231
+ out.push(`# ${b.text}`, '');
232
+ break;
233
+ case 'subtitle':
234
+ out.push(`*${b.text}*`, '');
235
+ break;
236
+ case 'heading':
237
+ out.push(`${'#'.repeat(b.level + 1)} ${b.text}`, '');
238
+ break;
239
+ case 'paragraph':
240
+ out.push(b.text, '');
241
+ break;
242
+ case 'bullets':
243
+ out.push(...b.items.map((i) => `- ${i}`), '');
244
+ break;
245
+ case 'table':
246
+ out.push(`| ${b.header.join(' | ')} |`, `|${b.header.map(() => '---').join('|')}|`, ...b.rows.map((r) => `| ${r.join(' | ')} |`), '');
247
+ break;
248
+ }
249
+ }
250
+ return out.join('\n').trim() + '\n';
251
+ }
252
+ /** Collect, compose, write .docx and .md under .gwo-data/<site>/reports/. */
253
+ export async function renderClientReport(siteIdOrHost, opts = {}) {
254
+ const site = getSite(siteIdOrHost);
255
+ const month = opts.month ?? defaultMonth();
256
+ const lang = opts.lang ?? (site.default_locale.toLowerCase().startsWith('he') ? 'he' : 'en');
257
+ const data = await collectMonthly(site, month);
258
+ const report = composeClientReport(data, opts.narrative ?? {}, lang);
259
+ const templatePath = opts.templatePath ?? process.env['GWO_DOCX_TEMPLATE']?.trim() ?? '';
260
+ const template = templatePath && existsSync(templatePath) ? templatePath : undefined;
261
+ const bytes = await buildDocx({ blocks: report.blocks, rtl: lang === 'he', title: report.title }, template ? { templatePath: template } : {});
262
+ const dir = opts.outDir ?? path.join(DATA_DIR, site.site_id, 'reports');
263
+ mkdirSync(dir, { recursive: true });
264
+ const docx = path.join(dir, `${month}-client.docx`), md = path.join(dir, `${month}-client.md`);
265
+ writeFileSync(docx, bytes);
266
+ writeFileSync(md, report.markdown);
267
+ storage.appendAudit({ siteId: site.site_id, operation: 'report.client_monthly', status: 'observed', risk: 'auto',
268
+ summary: `Client report for ${month} (${lang}) written to ${docx}${template ? ' in the company template' : ' without a template'}`,
269
+ data: { search: data.search, traffic: data.traffic, activity: data.activity } });
270
+ return { docx, md, template: template ?? null, lang, month, markdown: report.markdown,
271
+ notes: [data.searchError && `Search Console: ${data.searchError}`, data.trafficError && `GA4: ${data.trafficError}`].filter(Boolean) };
272
+ }
@@ -0,0 +1,40 @@
1
+ export function renderScan(scan, { maxGroups = 40 } = {}) {
2
+ const s = scan.summary;
3
+ const lines = [];
4
+ lines.push(`# Site scan: ${scan.origin}`);
5
+ lines.push(`Crawled ${scan.pagesCrawled} pages in ${(scan.durationMs / 1000).toFixed(1)}s at ${scan.at}`);
6
+ lines.push(`Sitemap: ${scan.sitemap.ok ? `${scan.sitemap.count} URLs` : `unreachable (${scan.sitemap.status})`}`);
7
+ lines.push(`Findings: ${s.blocker} blocker, ${s.high} high, ${s.medium} medium, ${s.low} low, ${s.info} info`);
8
+ const sup = scan.suppressed ?? { count: 0, rules: {} };
9
+ if (sup.count)
10
+ lines.push(`Suppressed by site decisions: ${sup.count} (${Object.entries(sup.rules).map(([id, n]) => `${id} x${n}`).join(', ')})`);
11
+ lines.push('');
12
+ const blocked = scan.robotsChecks.filter((c) => c.kind === 'ai' && !c.allowed).map((c) => c.agent);
13
+ lines.push(`AI crawlers: ${blocked.length ? `blocked ${blocked.join(', ')}` : 'none blocked'}; llms.txt: ${scan.llmsTxt.ok ? 'present' : 'absent'}`);
14
+ const live = scan.hostProbe.filter((h) => h.status >= 200 && h.status < 300).map((h) => h.url);
15
+ lines.push(`Host variants answering 200: ${live.join(', ') || 'none (root redirects)'}`);
16
+ lines.push('');
17
+ const grouped = new Map();
18
+ for (const x of scan.findings)
19
+ grouped.set(x.id, [...(grouped.get(x.id) ?? []), x]);
20
+ const entries = [...grouped.entries()];
21
+ const shown = entries.slice(0, maxGroups);
22
+ lines.push('## Findings (grouped by rule, most severe first; low and info are opportunities, not faults)');
23
+ for (const [id, group] of shown) {
24
+ const x = group[0];
25
+ const scope = x.url ? `${group.length} page${group.length > 1 ? 's' : ''}` : 'site-wide';
26
+ lines.push(`- [${x.severity.toUpperCase()}] ${x.title} (${id}; ${scope})`);
27
+ lines.push(` ${x.detail}`);
28
+ if (x.evidence)
29
+ lines.push(` evidence: ${x.evidence.split('\n')[0]?.slice(0, 160)}`);
30
+ if (x.fix)
31
+ lines.push(` fix: ${x.fix}`);
32
+ if (x.url && group.length > 1 && group.length <= 6)
33
+ lines.push(` pages: ${group.map((g) => g.url).join(', ')}`);
34
+ else if (x.url && group.length > 6)
35
+ lines.push(` pages: ${group.slice(0, 3).map((g) => g.url).join(', ')} and ${group.length - 3} more`);
36
+ }
37
+ if (entries.length > shown.length)
38
+ lines.push(`... ${entries.length - shown.length} more rule groups in the saved scan file.`);
39
+ return lines.join('\n');
40
+ }