@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,206 @@
1
+ const SEO_FIELDS = ['website_meta_title', 'website_meta_description', 'website_meta_keywords', 'website_meta_og_img'];
2
+ const PATCH_TO_FIELD = {
3
+ title: 'website_meta_title', description: 'website_meta_description', keywords: 'website_meta_keywords', ogImage: 'website_meta_og_img',
4
+ };
5
+ /** Minimal JSON-RPC 2.0 client for Odoo's /jsonrpc endpoint. */
6
+ export class OdooClient {
7
+ uid = null;
8
+ id = 0;
9
+ c;
10
+ fetchImpl;
11
+ constructor(c, fetchImpl = fetch) { this.c = c; this.fetchImpl = fetchImpl; }
12
+ async rpc(service, method, args) {
13
+ const res = await this.fetchImpl(new URL('/jsonrpc', this.c.url).toString(), {
14
+ method: 'POST', headers: { 'content-type': 'application/json' },
15
+ body: JSON.stringify({ jsonrpc: '2.0', id: ++this.id, method: 'call', params: { service, method, args } }),
16
+ });
17
+ if (!res.ok)
18
+ throw new Error(`Odoo HTTP ${res.status} at ${this.c.url}`);
19
+ const body = (await res.json());
20
+ if (body.error) {
21
+ const d = body.error.data;
22
+ throw new Error(`Odoo: ${d?.message ?? body.error.message}${d?.name ? ` (${d.name})` : ''}`);
23
+ }
24
+ return body.result;
25
+ }
26
+ async authenticate() {
27
+ if (this.uid)
28
+ return this.uid;
29
+ const uid = await this.rpc('common', 'authenticate', [this.c.db, this.c.login, this.c.apiKey, {}]);
30
+ if (!uid)
31
+ throw new Error(`Odoo: authentication failed for ${this.c.login} on ${this.c.db}. Check the API key and that the user is active.`);
32
+ return (this.uid = uid);
33
+ }
34
+ async execute(model, method, args = [], kwargs = {}) {
35
+ const uid = await this.authenticate();
36
+ return this.rpc('object', 'execute_kw', [this.c.db, uid, this.c.apiKey, model, method, args, kwargs]);
37
+ }
38
+ searchRead(model, domain, fields, kwargs = {}) {
39
+ return this.execute(model, 'search_read', [domain], { fields, ...kwargs });
40
+ }
41
+ version() { return this.rpc('common', 'version', []); }
42
+ }
43
+ // ---------------------------------------------------------------- JSON-LD in HTML / QWeb arch (pure)
44
+ /** Marker attribute on the script element this system manages. */
45
+ export const SCHEMA_MARK = 'data-gwo="schema"';
46
+ const MANAGED_RE = /<script\b[^>]*\bdata-gwo\s*=\s*["']schema["'][^>]*>([\s\S]*?)<\/script>\s*/gi;
47
+ /**
48
+ * Serialise blocks for embedding in a QWeb arch, which is parsed as XML:
49
+ * the characters XML cares about are written as JSON escapes, which every
50
+ * JSON parser reads back unchanged.
51
+ */
52
+ export function serialiseSchema(blocks) {
53
+ const json = JSON.stringify(blocks.length === 1 ? blocks[0] : blocks);
54
+ return json.replace(/&/g, '\\u0026').replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
55
+ }
56
+ /** The managed JSON-LD blocks in an arch or HTML fragment. Unparseable managed blocks are skipped. */
57
+ export function extractSchema(html) {
58
+ const out = [];
59
+ for (const m of html.matchAll(MANAGED_RE)) {
60
+ try {
61
+ const parsed = JSON.parse((m[1] ?? '').trim());
62
+ out.push(...(Array.isArray(parsed) ? parsed : [parsed]));
63
+ }
64
+ catch { /* leave it out; verification will report the mismatch */ }
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Replace the managed block. Removes every existing managed script; with
70
+ * blocks, inserts one script:
71
+ * - in a page arch, before `<div id="wrap">` (outside the editor-owned
72
+ * structure) or, failing that, before the QWeb root's closing `</t>`;
73
+ * - in an HTML fragment (blog content), appended at the end.
74
+ * Other markup is untouched.
75
+ */
76
+ export function injectSchema(html, blocks) {
77
+ const stripped = html.replace(MANAGED_RE, '');
78
+ if (!blocks.length)
79
+ return stripped;
80
+ const script = `<script type="application/ld+json" ${SCHEMA_MARK}>${serialiseSchema(blocks)}</script>\n`;
81
+ const wrap = stripped.search(/<div\b[^>]*\bid\s*=\s*["']wrap["']/i);
82
+ if (wrap >= 0)
83
+ return stripped.slice(0, wrap) + script + stripped.slice(wrap);
84
+ const root = stripped.match(/<\/t>\s*$/);
85
+ if (root && root.index !== undefined)
86
+ return stripped.slice(0, root.index) + script + stripped.slice(root.index);
87
+ return stripped.replace(/\s*$/, '\n') + script;
88
+ }
89
+ const str = (v) => (v ? String(v) : null);
90
+ export class OdooAdapter {
91
+ kind = 'odoo';
92
+ client;
93
+ c;
94
+ siteId;
95
+ constructor(client, c, siteId) { this.client = client; this.c = c; this.siteId = siteId; }
96
+ /** Build from a site record: connection from write.odoo, credentials from the secrets file. */
97
+ static fromSite(site, fetchImpl) {
98
+ const cfg = site.write.odoo ?? {};
99
+ const sec = site._secrets?.['odoo'] ?? {};
100
+ const missing = [
101
+ !cfg.url && 'write.odoo.url', !cfg.db && 'write.odoo.db',
102
+ !sec.login && 'odoo.login (secrets)', !sec.api_key && 'odoo.api_key (secrets)',
103
+ ].filter(Boolean);
104
+ if (missing.length)
105
+ throw new Error(`Site ${site.site_id}: Odoo adapter needs ${missing.join(', ')}.`);
106
+ const conn = { url: cfg.url, db: cfg.db, login: sec.login, apiKey: sec.api_key, websiteId: cfg.website_id ?? null };
107
+ return new OdooAdapter(new OdooClient(conn, fetchImpl), conn, site.site_id);
108
+ }
109
+ describe() { return `Odoo ${this.c.url} db=${this.c.db} user=${this.c.login}${this.c.websiteId ? ` website_id=${this.c.websiteId}` : ''}`; }
110
+ async ping() {
111
+ const v = await this.client.version();
112
+ const uid = await this.client.authenticate();
113
+ return { ok: true, detail: `Odoo ${v.server_version ?? '?'}, authenticated as uid ${uid}` };
114
+ }
115
+ websiteDomain() {
116
+ return this.c.websiteId ? [['website_id', 'in', [this.c.websiteId, false]]] : [];
117
+ }
118
+ async listPages() {
119
+ const pages = await this.client.searchRead('website.page', this.websiteDomain(), ['url', 'name', 'is_published'], { limit: 500, order: 'url' });
120
+ const posts = await this.client.searchRead('blog.post', [], ['name', 'website_url', 'is_published'], { limit: 500 }).catch(() => []);
121
+ // Products exist only with the eCommerce app; a missing model is not an error.
122
+ const products = await this.client.searchRead('product.template', [['website_published', '=', true]], ['name', 'website_url', 'is_published'], { limit: 500 }).catch(() => []);
123
+ return [
124
+ ...pages.map((p) => ({ path: p.url, ref: `website.page:${p.id}`, name: p.name, published: p.is_published ?? null })),
125
+ ...posts.map((p) => ({ path: p.website_url, ref: `blog.post:${p.id}`, name: p.name ?? null, published: p.is_published ?? null })),
126
+ ...products.map((p) => ({ path: p.website_url, ref: `product.template:${p.id}`, name: p.name ?? null, published: p.is_published ?? null })),
127
+ ];
128
+ }
129
+ /** Resolve a path to (model, id). Blog and shop URLs end in "-<id>"; pages match website.page.url exactly. */
130
+ async locate(path) {
131
+ const clean = path.replace(/[?#].*$/, '').replace(/\/+$/, '') || '/';
132
+ const blog = clean.match(/^\/blog\/.*-(\d+)$/);
133
+ if (blog)
134
+ return { model: 'blog.post', id: Number(blog[1]) };
135
+ const product = clean.match(/^\/shop\/(?!category\/).*-(\d+)$/);
136
+ if (product)
137
+ return { model: 'product.template', id: Number(product[1]) };
138
+ const hits = await this.client.searchRead('website.page', [['url', '=', clean], ...this.websiteDomain()], ['id'], { limit: 1 });
139
+ return hits[0] ? { model: 'website.page', id: hits[0].id } : null;
140
+ }
141
+ async readPageSeo(path) {
142
+ const loc = await this.locate(path);
143
+ if (!loc)
144
+ return null;
145
+ const extra = loc.model === 'website.page' ? ['url', 'name', 'is_published', 'website_indexed'] : ['website_url', 'name', 'is_published'];
146
+ const [r] = await this.client.execute(loc.model, 'read', [[loc.id], [...SEO_FIELDS, ...extra]]);
147
+ if (!r)
148
+ return null;
149
+ return {
150
+ path: r.url ?? r.website_url ?? path, ref: `${loc.model}:${loc.id}`, name: r.name ?? null,
151
+ title: str(r.website_meta_title), description: str(r.website_meta_description),
152
+ keywords: str(r.website_meta_keywords), ogImage: str(r.website_meta_og_img),
153
+ indexed: r.website_indexed ?? null, published: r.is_published ?? null,
154
+ };
155
+ }
156
+ async writePageSeo(path, patch) {
157
+ const loc = await this.locate(path);
158
+ if (!loc)
159
+ throw new Error(`Odoo: no page at ${path}`);
160
+ const vals = {};
161
+ for (const [k, field] of Object.entries(PATCH_TO_FIELD)) {
162
+ if (patch[k] !== undefined)
163
+ vals[field] = patch[k] === null ? false : patch[k];
164
+ }
165
+ if (!Object.keys(vals).length)
166
+ return;
167
+ await this.client.execute(loc.model, 'write', [[loc.id], vals]);
168
+ }
169
+ /**
170
+ * Where a page's markup lives: the view arch for website.page, the HTML
171
+ * content for blog.post. Returns the model, record id, field and markup.
172
+ */
173
+ async markup(path) {
174
+ const loc = await this.locate(path);
175
+ if (!loc)
176
+ return null;
177
+ if (loc.model === 'blog.post') {
178
+ const [r] = await this.client.execute('blog.post', 'read', [[loc.id], ['content']]);
179
+ return r ? { model: 'blog.post', id: loc.id, field: 'content', html: r.content || '' } : null;
180
+ }
181
+ if (loc.model === 'product.template') {
182
+ // The product's website description is the HTML block the shop page renders; JSON-LD rides in it like in a blog post.
183
+ const [r] = await this.client.execute('product.template', 'read', [[loc.id], ['website_description']]);
184
+ return r ? { model: 'product.template', id: loc.id, field: 'website_description', html: r.website_description || '' } : null;
185
+ }
186
+ const [p] = await this.client.execute('website.page', 'read', [[loc.id], ['view_id']]);
187
+ const viewId = p?.view_id ? p.view_id[0] : null;
188
+ if (!viewId)
189
+ throw new Error(`Odoo: page ${path} has no view (website.page:${loc.id}.view_id is empty).`);
190
+ const [v] = await this.client.execute('ir.ui.view', 'read', [[viewId], ['arch']]);
191
+ return v ? { model: 'ir.ui.view', id: viewId, field: 'arch', html: v.arch || '' } : null;
192
+ }
193
+ async readPageSchema(path) {
194
+ const m = await this.markup(path);
195
+ return m ? extractSchema(m.html) : null;
196
+ }
197
+ async writePageSchema(path, blocks) {
198
+ const m = await this.markup(path);
199
+ if (!m)
200
+ throw new Error(`Odoo: no page at ${path}`);
201
+ const next = injectSchema(m.html, blocks);
202
+ if (next === m.html)
203
+ return;
204
+ await this.client.execute(m.model, 'write', [[m.id], { [m.field]: next }]);
205
+ }
206
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Repository write adapter for sites AI Guru develops itself.
3
+ *
4
+ * Convention: the site keeps its SEO metadata in `gwo.seo.json` at the
5
+ * repository root, keyed by path, and its build reads that file (for Next.js,
6
+ * from generateMetadata). The adapter reads and writes that file in a local
7
+ * checkout declared as `write.repo.local_path`. Committing and opening a pull
8
+ * request is the GitHub connector's job; this adapter only changes the
9
+ * working tree, and the write loop verifies by reading the file back.
10
+ *
11
+ * Structured data: an entry's `schema` array holds the JSON-LD blocks the
12
+ * page should emit. The site's layout renders each as a
13
+ * <script type="application/ld+json"> element.
14
+ *
15
+ * Sites without the manifest are still `full` tier, but changes to them are
16
+ * proposals for a developer until the manifest is adopted.
17
+ */
18
+ import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ export const MANIFEST = 'gwo.seo.json';
21
+ export class RepoAdapter {
22
+ kind = 'repo';
23
+ file;
24
+ localPath;
25
+ siteId;
26
+ constructor(localPath, siteId) {
27
+ this.localPath = localPath;
28
+ this.siteId = siteId;
29
+ this.file = path.join(localPath, MANIFEST);
30
+ }
31
+ static fromSite(site) {
32
+ const lp = site.write?.repo?.local_path;
33
+ if (!lp)
34
+ throw new Error(`Site ${site.site_id}: repo adapter needs write.repo.local_path (a local checkout).`);
35
+ return new RepoAdapter(lp, site.site_id);
36
+ }
37
+ describe() { return `repository checkout ${this.localPath} (${MANIFEST})`; }
38
+ async ping() {
39
+ if (!existsSync(this.localPath) || !statSync(this.localPath).isDirectory())
40
+ throw new Error(`Repo path does not exist: ${this.localPath}`);
41
+ if (!existsSync(this.file)) {
42
+ throw new Error(`${MANIFEST} not found in ${this.localPath}. Adopt the manifest convention (see docs/SITE-ADAPTERS.md) or treat changes as developer proposals.`);
43
+ }
44
+ return { ok: true, detail: `${MANIFEST} with ${Object.keys(this.read()).length} entries` };
45
+ }
46
+ read() {
47
+ return existsSync(this.file) ? JSON.parse(readFileSync(this.file, 'utf8')) : {};
48
+ }
49
+ write(m) {
50
+ // Stable key order keeps diffs reviewable.
51
+ const sorted = Object.fromEntries(Object.keys(m).sort().map((k) => [k, m[k]]));
52
+ writeFileSync(this.file, JSON.stringify(sorted, null, 2) + '\n');
53
+ }
54
+ entry(p) {
55
+ const e = this.read()[p];
56
+ if (!e)
57
+ throw new Error(`${MANIFEST} has no entry for ${p}; add the page to the manifest first.`);
58
+ return e;
59
+ }
60
+ async listPages() {
61
+ return Object.entries(this.read()).map(([p, e]) => ({ path: p, ref: MANIFEST, name: e.name ?? null, published: e.published ?? null }));
62
+ }
63
+ async readPageSeo(p) {
64
+ const e = this.read()[p];
65
+ if (!e)
66
+ return null;
67
+ return { path: p, ref: MANIFEST, name: e.name ?? null, title: e.title ?? null, description: e.description ?? null,
68
+ keywords: e.keywords ?? null, ogImage: e.ogImage ?? null, indexed: e.indexed ?? null, published: e.published ?? null };
69
+ }
70
+ async writePageSeo(p, patch) {
71
+ const m = this.read();
72
+ const e = m[p];
73
+ if (!e)
74
+ throw new Error(`${MANIFEST} has no entry for ${p}; add the page to the manifest first.`);
75
+ for (const k of ['title', 'description', 'keywords', 'ogImage']) {
76
+ if (patch[k] !== undefined)
77
+ e[k] = patch[k];
78
+ }
79
+ this.write(m);
80
+ }
81
+ async readPageSchema(p) {
82
+ const e = this.read()[p];
83
+ return e ? (e.schema ?? []) : null;
84
+ }
85
+ async writePageSchema(p, blocks) {
86
+ const m = this.read();
87
+ const e = m[p] ?? this.entry(p);
88
+ if (blocks.length)
89
+ e.schema = blocks;
90
+ else
91
+ delete e.schema;
92
+ this.write(m);
93
+ }
94
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resend: delivery of run summaries by email.
3
+ * Configuration by environment only: RESEND_API_KEY, GWO_REPORT_FROM, GWO_REPORT_TO
4
+ * (comma-separated). Without a key, isConfigured() is false and callers print instead.
5
+ */
6
+ export function isConfigured() {
7
+ return !!(process.env['RESEND_API_KEY']?.trim() && process.env['GWO_REPORT_FROM']?.trim() && process.env['GWO_REPORT_TO']?.trim());
8
+ }
9
+ export function describe() {
10
+ if (!isConfigured())
11
+ return 'Resend not configured (RESEND_API_KEY, GWO_REPORT_FROM, GWO_REPORT_TO)';
12
+ return `Resend from ${process.env['GWO_REPORT_FROM']} to ${process.env['GWO_REPORT_TO']}`;
13
+ }
14
+ /** Send one email. Returns the Resend message id. */
15
+ export async function send(mail, fetchImpl = fetch) {
16
+ const key = process.env['RESEND_API_KEY']?.trim();
17
+ const from = process.env['GWO_REPORT_FROM']?.trim();
18
+ const to = mail.to ?? (process.env['GWO_REPORT_TO'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
19
+ if (!key || !from || !to.length)
20
+ throw new Error(describe());
21
+ const res = await fetchImpl('https://api.resend.com/emails', {
22
+ method: 'POST',
23
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
24
+ body: JSON.stringify({ from, to, subject: mail.subject, text: mail.text, ...(mail.html ? { html: mail.html } : {}) }),
25
+ });
26
+ const body = await res.text();
27
+ if (!res.ok)
28
+ throw new Error(`Resend ${res.status}: ${body.slice(0, 400)}`);
29
+ return JSON.parse(body);
30
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Accessibility signals from static HTML, for the IS 5568 rule area.
3
+ *
4
+ * IS 5568 is anchored to WCAG 2.0 AA and adds Israeli requirements: an
5
+ * accessibility statement, Hebrew with RTL declared, a contact route. What a
6
+ * crawler can judge from markup alone is the structural half: labels, names,
7
+ * headings, landmarks, zoom, focus order, the statement link. Contrast and
8
+ * keyboard behaviour need a rendered page and stay out of scope here.
9
+ *
10
+ * Regex-based like the rest of the parser; counts are conservative (an
11
+ * element is only counted as a fault when no accessible-name mechanism is
12
+ * present at all).
13
+ */
14
+ const attr = (tag, name) => {
15
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
16
+ return m ? (m[2] ?? m[3] ?? m[4] ?? '') : null;
17
+ };
18
+ const text = (s) => s.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
19
+ const has = (tag, name) => attr(tag, name) !== null && (attr(tag, name) ?? '').trim() !== '';
20
+ /** Link texts that say nothing about the destination. */
21
+ const GENERIC = /^(click here|read more|more|here|learn more|link|לחץ כאן|לחצו כאן|קרא עוד|קראו עוד|עוד|כאן|לפרטים|המשך)$/i;
22
+ export function parseA11y(html) {
23
+ const h = html || '';
24
+ const body = (h.match(/<body\b[^>]*>([\s\S]*)<\/body>/i)?.[1]) ?? h;
25
+ // Headings in order.
26
+ const headingOrder = [...body.matchAll(/<h([1-6])\b[^>]*>/gi)].map((m) => Number(m[1]));
27
+ let skippedLevels = 0;
28
+ for (let i = 1; i < headingOrder.length; i++)
29
+ if (headingOrder[i] - headingOrder[i - 1] > 1)
30
+ skippedLevels++;
31
+ // Form controls: needs a <label for=id>, a wrapping <label>, aria-label, aria-labelledby or title.
32
+ const labelFor = new Set([...body.matchAll(/<label\b[^>]*\bfor\s*=\s*["']?([^"'\s>]+)/gi)].map((m) => m[1].toLowerCase()));
33
+ const wrapped = new Set();
34
+ for (const m of body.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi))
35
+ for (const c of (m[1] ?? '').matchAll(/<(?:input|select|textarea)\b[^>]*>/gi))
36
+ wrapped.add(c[0]);
37
+ const controls = [...body.matchAll(/<(?:input|select|textarea)\b[^>]*>/gi)].map((m) => m[0])
38
+ .filter((t) => !/\btype\s*=\s*["']?(hidden|submit|button|reset|image)\b/i.test(t));
39
+ const unlabelled = controls.filter((t) => {
40
+ if (wrapped.has(t))
41
+ return false;
42
+ const id = attr(t, 'id');
43
+ if (id && labelFor.has(id.toLowerCase()))
44
+ return false;
45
+ return !has(t, 'aria-label') && !has(t, 'aria-labelledby') && !has(t, 'title');
46
+ }).length;
47
+ // Links: an accessible name from text, aria-label, title, or an image alt inside.
48
+ const anchors = [...body.matchAll(/<a\b[^>]*>([\s\S]*?)<\/a>/gi)];
49
+ let empty = 0, generic = 0, skipLink = false, statement = false;
50
+ for (const m of anchors) {
51
+ const tag = m[0].match(/^<a\b[^>]*>/i)?.[0] ?? '';
52
+ const inner = m[1] ?? '';
53
+ const href = attr(tag, 'href') ?? '';
54
+ const imgAlt = [...inner.matchAll(/<img\b[^>]*>/gi)].map((x) => attr(x[0], 'alt') ?? '').find((a) => a.trim());
55
+ const name = (text(inner) || attr(tag, 'aria-label') || attr(tag, 'title') || imgAlt || '').trim();
56
+ if (!name) {
57
+ if (href && !/^(#|javascript:)/i.test(href))
58
+ empty++;
59
+ continue;
60
+ }
61
+ if (GENERIC.test(name))
62
+ generic++;
63
+ if (/^#./.test(href) && /דלג|skip/i.test(name))
64
+ skipLink = true;
65
+ if (/נגישות|accessibility/i.test(name) || /accessib|negishut|nagishut/i.test(href))
66
+ statement = true;
67
+ }
68
+ const buttons = [...body.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/gi)];
69
+ const buttonsWithoutName = buttons.filter((m) => {
70
+ const tag = m[0].match(/^<button\b[^>]*>/i)?.[0] ?? '';
71
+ const inner = m[1] ?? '';
72
+ const imgAlt = [...inner.matchAll(/<img\b[^>]*>/gi)].some((x) => (attr(x[0], 'alt') ?? '').trim());
73
+ return !text(inner) && !has(tag, 'aria-label') && !has(tag, 'aria-labelledby') && !has(tag, 'title') && !imgAlt;
74
+ }).length;
75
+ const iframesWithoutTitle = [...body.matchAll(/<iframe\b[^>]*>/gi)].filter((m) => !has(m[0], 'title') && !has(m[0], 'aria-label')).length;
76
+ const viewport = [...h.matchAll(/<meta\b[^>]*\bname\s*=\s*["']viewport["'][^>]*>/gi)].map((m) => attr(m[0], 'content') ?? '').join(';').toLowerCase();
77
+ const maxScale = viewport.match(/maximum-scale\s*=\s*([\d.]+)/)?.[1];
78
+ const zoomBlocked = /user-scalable\s*=\s*(no|0)\b/.test(viewport) || (maxScale !== undefined && Number(maxScale) < 2);
79
+ const positiveTabindex = [...body.matchAll(/\btabindex\s*=\s*["']?(\d+)/gi)].filter((m) => Number(m[1]) > 0).length;
80
+ const ids = [...body.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
81
+ const duplicateIds = ids.length - new Set(ids).size;
82
+ const autoplayMedia = [...body.matchAll(/<(?:video|audio)\b[^>]*\bautoplay\b[^>]*>/gi)].length;
83
+ const mainLandmark = /<main\b/i.test(body) || /\brole\s*=\s*["']main["']/i.test(body);
84
+ return {
85
+ headingOrder, skippedLevels,
86
+ inputs: { total: controls.length, unlabelled },
87
+ links: { total: anchors.length, empty, generic },
88
+ buttonsWithoutName, iframesWithoutTitle, zoomBlocked, positiveTabindex, duplicateIds, autoplayMedia,
89
+ mainLandmark, skipLink, accessibilityStatementLink: statement,
90
+ };
91
+ }
@@ -0,0 +1,165 @@
1
+ const UA_DEFAULT = 'Mozilla/5.0 (compatible; GWebOps/0.2; +https://aiguru.co.il/) AppleWebKit/537.36 Chrome/124 Safari/537.36';
2
+ /** AI answer-engine crawlers we probe robots.txt for. */
3
+ export const AI_AGENTS = [
4
+ 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
5
+ 'ClaudeBot', 'Claude-Web', 'anthropic-ai',
6
+ 'PerplexityBot', 'Perplexity-User',
7
+ 'Google-Extended', 'Applebot-Extended',
8
+ 'CCBot', 'Bytespider', 'Amazonbot', 'meta-externalagent',
9
+ ];
10
+ export const SEARCH_AGENTS = ['Googlebot', 'Bingbot'];
11
+ /** One request, no redirect following. */
12
+ async function hop(url, ua, timeout) {
13
+ const ctrl = new AbortController();
14
+ const timer = setTimeout(() => ctrl.abort(), timeout);
15
+ const t0 = Date.now();
16
+ try {
17
+ const res = await fetch(url, {
18
+ redirect: 'manual',
19
+ signal: ctrl.signal,
20
+ headers: { 'user-agent': ua, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
21
+ });
22
+ const headers = {};
23
+ res.headers.forEach((v, k) => { headers[k] = v; });
24
+ return { res, status: res.status, headers, location: headers['location'] ?? null, ms: Date.now() - t0 };
25
+ }
26
+ finally {
27
+ clearTimeout(timer);
28
+ }
29
+ }
30
+ /** Fetch a URL, following up to maxHops redirects and recording each. */
31
+ export async function get(url, opts = {}) {
32
+ const { ua = UA_DEFAULT, timeout = 30_000, maxHops = 10, body = true } = opts;
33
+ const chain = [];
34
+ let current = url;
35
+ let totalMs = 0;
36
+ for (let i = 0; i <= maxHops; i++) {
37
+ let h;
38
+ try {
39
+ h = await hop(current, ua, timeout);
40
+ }
41
+ catch (e) {
42
+ const err = e;
43
+ return { ok: false, finalUrl: current, status: 0, chain, headers: {}, body: null, ms: totalMs, bytes: 0,
44
+ error: err.name === 'AbortError' ? 'timeout' : String(err.message ?? err) };
45
+ }
46
+ totalMs += h.ms;
47
+ chain.push({ url: current, status: h.status, location: h.location, ms: h.ms });
48
+ const redirecting = h.status >= 300 && h.status < 400 && h.location;
49
+ if (!redirecting) {
50
+ let text = null;
51
+ let bytes = 0;
52
+ if (body) {
53
+ try {
54
+ text = await h.res.text();
55
+ bytes = Buffer.byteLength(text, 'utf8');
56
+ }
57
+ catch { /* unreadable body */ }
58
+ }
59
+ return { ok: h.status >= 200 && h.status < 300, finalUrl: current, status: h.status, chain,
60
+ headers: h.headers, body: text, ms: totalMs, bytes, error: null };
61
+ }
62
+ current = new URL(h.location, current).toString();
63
+ }
64
+ return { ok: false, finalUrl: current, status: 0, chain, headers: {}, body: null, ms: totalMs, bytes: 0,
65
+ error: `redirect loop or more than ${maxHops} hops` };
66
+ }
67
+ // ---------------------------------------------------------------- robots.txt
68
+ /** Fetch and parse robots.txt into per-agent rule groups. */
69
+ export async function robots(origin, opts = {}) {
70
+ const url = new URL('/robots.txt', origin).toString();
71
+ const r = await get(url, opts);
72
+ const out = { ok: r.ok, status: r.status, url, raw: r.body ?? '', sitemaps: [], groups: [] };
73
+ if (!r.ok || !r.body)
74
+ return out;
75
+ let group = null;
76
+ let lastWasAgent = false;
77
+ for (const rawLine of r.body.split(/\r?\n/)) {
78
+ const line = rawLine.replace(/#.*$/, '').trim();
79
+ if (!line)
80
+ continue;
81
+ const idx = line.indexOf(':');
82
+ if (idx < 0)
83
+ continue;
84
+ const field = line.slice(0, idx).trim().toLowerCase();
85
+ const value = line.slice(idx + 1).trim();
86
+ if (field === 'sitemap') {
87
+ out.sitemaps.push(value);
88
+ continue;
89
+ }
90
+ if (field === 'user-agent') {
91
+ if (!group || !lastWasAgent) {
92
+ group = { agents: [], allow: [], disallow: [], crawlDelay: null };
93
+ out.groups.push(group);
94
+ }
95
+ group.agents.push(value);
96
+ lastWasAgent = true;
97
+ continue;
98
+ }
99
+ lastWasAgent = false;
100
+ if (!group) {
101
+ group = { agents: ['*'], allow: [], disallow: [], crawlDelay: null };
102
+ out.groups.push(group);
103
+ }
104
+ if (field === 'allow')
105
+ group.allow.push(value);
106
+ else if (field === 'disallow')
107
+ group.disallow.push(value);
108
+ else if (field === 'crawl-delay')
109
+ group.crawlDelay = Number(value) || null;
110
+ }
111
+ return out;
112
+ }
113
+ /** Longest-match robots evaluation for one agent and path (RFC 9309 semantics). */
114
+ export function robotsAllows(parsed, agent, pathname = '/') {
115
+ if (!parsed.groups.length)
116
+ return { allowed: true, rule: null, matchedAgent: null };
117
+ const lower = agent.toLowerCase();
118
+ const chosen = parsed.groups.find((g) => g.agents.some((a) => a.toLowerCase() === lower)) ??
119
+ parsed.groups.find((g) => g.agents.includes('*')) ?? null;
120
+ if (!chosen)
121
+ return { allowed: true, rule: null, matchedAgent: null };
122
+ const longest = (patterns) => {
123
+ let best = null;
124
+ for (const p of patterns) {
125
+ if (p === '')
126
+ continue;
127
+ const re = new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\$$/, '$'));
128
+ if (re.test(pathname) && (!best || p.length > best.length))
129
+ best = p;
130
+ }
131
+ return best;
132
+ };
133
+ const a = longest(chosen.allow);
134
+ const d = longest(chosen.disallow);
135
+ const agentLabel = chosen.agents[0] ?? '*';
136
+ if (chosen.disallow.includes('') && !d)
137
+ return { allowed: true, rule: 'Disallow: (empty)', matchedAgent: agentLabel };
138
+ if (d && (!a || d.length > a.length))
139
+ return { allowed: false, rule: `Disallow: ${d}`, matchedAgent: agentLabel };
140
+ return { allowed: true, rule: a ? `Allow: ${a}` : null, matchedAgent: agentLabel };
141
+ }
142
+ // ---------------------------------------------------------------- sitemap
143
+ /** Fetch a sitemap or sitemap index (recursing two levels into indexes). */
144
+ export async function sitemap(url, opts = {}, depth = 0) {
145
+ const r = await get(url, opts);
146
+ const out = { url, ok: r.ok, status: r.status, urls: [], indexes: [], error: r.error };
147
+ if (!r.ok || !r.body)
148
+ return out;
149
+ const isIndex = /<sitemapindex[\s>]/i.test(r.body);
150
+ const locs = [...r.body.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => m[1] ?? '');
151
+ const lastmods = [...r.body.matchAll(/<lastmod>\s*([^<]+?)\s*<\/lastmod>/gi)].map((m) => m[1] ?? null);
152
+ if (isIndex) {
153
+ out.indexes = locs;
154
+ if (depth < 2) {
155
+ for (const child of locs.slice(0, 50)) {
156
+ const sub = await sitemap(child, opts, depth + 1);
157
+ out.urls.push(...sub.urls);
158
+ }
159
+ }
160
+ }
161
+ else {
162
+ out.urls = locs.map((loc, i) => ({ loc, lastmod: lastmods[i] ?? null }));
163
+ }
164
+ return out;
165
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Web crawler connector: fetch pages and site-level resources for a site.
3
+ * Pure I/O and parsing. No rules live here; those are in core/scan.
4
+ */
5
+ import * as F from "./fetch.js";
6
+ import { parseHtml } from "./parse.js";
7
+ export { AI_AGENTS, SEARCH_AGENTS, get, robots, robotsAllows, sitemap } from "./fetch.js";
8
+ export { parseHtml, safeAbs, safeOrigin } from "./parse.js";
9
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
+ /** Fetch and parse one page. */
11
+ export async function fetchPage(url, opts = {}) {
12
+ const r = await F.get(url, { timeout: 30_000, ...opts });
13
+ const page = {
14
+ url, finalUrl: r.finalUrl, status: r.status, chain: r.chain,
15
+ ms: r.ms, bytes: r.bytes, error: r.error, signals: null,
16
+ };
17
+ const ct = r.headers['content-type'] ?? 'text/html';
18
+ if (r.ok && r.body && /text\/html|application\/xhtml/i.test(ct)) {
19
+ page.signals = parseHtml(r.body, r.finalUrl || url);
20
+ }
21
+ return page;
22
+ }
23
+ /** Fetch many pages with bounded concurrency and a politeness delay. */
24
+ export async function fetchPages(urls, { concurrency = 4, delayMs = 250 } = {}) {
25
+ const out = new Array(urls.length);
26
+ let next = 0;
27
+ async function worker() {
28
+ while (next < urls.length) {
29
+ const i = next++;
30
+ if (i)
31
+ await sleep(delayMs);
32
+ out[i] = await fetchPage(urls[i]);
33
+ }
34
+ }
35
+ await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
36
+ return out;
37
+ }
38
+ /** Robots verdicts for search engines and AI crawlers at the site root. */
39
+ export async function checkAgents(robotsFile) {
40
+ const out = [];
41
+ for (const agent of F.SEARCH_AGENTS)
42
+ out.push({ kind: 'search', agent, ...F.robotsAllows(robotsFile, agent, '/') });
43
+ for (const agent of F.AI_AGENTS)
44
+ out.push({ kind: 'ai', agent, ...F.robotsAllows(robotsFile, agent, '/') });
45
+ return out;
46
+ }
47
+ /**
48
+ * Probe host variants with a single hop each: does https://www, https://,
49
+ * and http:// answer 200 (a split) or redirect (correct)?
50
+ */
51
+ export async function probeHosts(site) {
52
+ const origin = new URL(site.production_url).origin;
53
+ const u = new URL(origin);
54
+ const candidates = new Set([origin]);
55
+ for (const h of site.alt_hosts) {
56
+ try {
57
+ candidates.add(new URL(h).origin);
58
+ }
59
+ catch { /* skip malformed */ }
60
+ }
61
+ candidates.add(`http://${u.host}`);
62
+ candidates.add(u.host.startsWith('www.') ? `https://${u.host.slice(4)}` : `https://www.${u.host}`);
63
+ const out = [];
64
+ for (const h of candidates) {
65
+ const r = await F.get(h + '/', { maxHops: 0, body: false, timeout: 15_000 });
66
+ out.push({ url: h + '/', status: r.status, location: r.chain[0]?.location ?? null, error: r.error });
67
+ }
68
+ return out;
69
+ }