causly-server 0.0.0-stage → 1.5.0

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 (91) hide show
  1. package/.env.example +120 -0
  2. package/CHANGELOG.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +205 -3
  5. package/bin/causly.js +192 -0
  6. package/index.js +229 -0
  7. package/package.json +59 -6
  8. package/packs/calendar.js +141 -0
  9. package/packs/command.js +22 -0
  10. package/packs/database.js +95 -0
  11. package/packs/directory.js +63 -0
  12. package/packs/discord.js +63 -0
  13. package/packs/docker.js +202 -0
  14. package/packs/files.js +106 -0
  15. package/packs/git.js +211 -0
  16. package/packs/github.js +321 -0
  17. package/packs/gmail.js +98 -0
  18. package/packs/leads.js +46 -0
  19. package/packs/linear.js +104 -0
  20. package/packs/loader.js +247 -0
  21. package/packs/manifest.json +1152 -0
  22. package/packs/meetings.js +141 -0
  23. package/packs/notion.js +185 -0
  24. package/packs/pipeline.js +151 -0
  25. package/packs/project.js +70 -0
  26. package/packs/registry.js +149 -0
  27. package/packs/reminders.js +57 -0
  28. package/packs/research.js +34 -0
  29. package/packs/secrets.js +77 -0
  30. package/packs/sentry.js +97 -0
  31. package/packs/seo.js +70 -0
  32. package/packs/slack.js +95 -0
  33. package/packs/stripe.js +127 -0
  34. package/packs/supabase.js +73 -0
  35. package/packs/telegram.js +70 -0
  36. package/packs/terraform.js +247 -0
  37. package/packs/vercel.js +114 -0
  38. package/packs/workflow.js +84 -0
  39. package/prompts/cofounder.js +66 -0
  40. package/prompts/playbooks.js +75 -0
  41. package/tools/backupOps.js +87 -0
  42. package/tools/boundaryAttest.js +184 -0
  43. package/tools/calendarOps.js +563 -0
  44. package/tools/cliOps.js +233 -0
  45. package/tools/commandOps.js +198 -0
  46. package/tools/dbOps.js +117 -0
  47. package/tools/directoryOps.js +96 -0
  48. package/tools/discordOps.js +133 -0
  49. package/tools/dockerOps.js +259 -0
  50. package/tools/envFile.js +89 -0
  51. package/tools/envLoader.js +85 -0
  52. package/tools/fileOps.js +125 -0
  53. package/tools/gitOps.js +170 -0
  54. package/tools/githubOps.js +357 -0
  55. package/tools/gmailOps.js +221 -0
  56. package/tools/googleAuth.js +51 -0
  57. package/tools/googleLogin.js +230 -0
  58. package/tools/leadOps.js +447 -0
  59. package/tools/linearOps.js +304 -0
  60. package/tools/logger.js +47 -0
  61. package/tools/meetingOps.js +697 -0
  62. package/tools/notionOps.js +215 -0
  63. package/tools/paths.js +36 -0
  64. package/tools/pipelineOps.js +511 -0
  65. package/tools/projectOps.js +192 -0
  66. package/tools/reminderAlerts.js +48 -0
  67. package/tools/reminderOps.js +129 -0
  68. package/tools/scheduler.js +46 -0
  69. package/tools/secretsOps.js +133 -0
  70. package/tools/security.js +488 -0
  71. package/tools/sentryOps.js +130 -0
  72. package/tools/seoOps.js +561 -0
  73. package/tools/services.js +295 -0
  74. package/tools/setupOps.js +245 -0
  75. package/tools/slackOps.js +151 -0
  76. package/tools/statusOps.js +104 -0
  77. package/tools/store.js +111 -0
  78. package/tools/stripeOps.js +299 -0
  79. package/tools/supabaseOps.js +94 -0
  80. package/tools/telegramOps.js +153 -0
  81. package/tools/terminal.js +124 -0
  82. package/tools/terraformOps.js +171 -0
  83. package/tools/toolWrapper.js +60 -0
  84. package/tools/toolsetOps.js +123 -0
  85. package/tools/transcribeOps.js +221 -0
  86. package/tools/updateCheck.js +58 -0
  87. package/tools/vercelOps.js +198 -0
  88. package/tools/verifyBoundaryAttest.js +25 -0
  89. package/tools/webOps.js +474 -0
  90. package/tools/workflowOps.js +371 -0
  91. package/tools/workspace.js +85 -0
@@ -0,0 +1,561 @@
1
+ // SEO and content tools built on free sources. No new dependencies.
2
+ //
3
+ // seo_keyword_ideas keyword ideas from search autocomplete (Google, Bing, DuckDuckGo)
4
+ // seo_page_audit on-page SEO audit of a live URL
5
+ // seo_content_check readability and SEO check of a draft (markdown or plain text)
6
+ // seo_search_console your own site's real queries, clicks and rankings (Google Search Console)
7
+ //
8
+ // Autocomplete shows what people really type, but it carries NO search volume.
9
+ import { fetchRaw, htmlToText, decodeEntities, metaContent, cleanInline, UNTRUSTED_NOTICE } from "./webOps.js";
10
+ import { googleCredentials, getAccessToken } from "./googleAuth.js";
11
+
12
+ const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
13
+ const round1 = (n) => Math.round(n * 10) / 10;
14
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+
17
+ // ---------------------------------------------------------------- text helpers
18
+
19
+ export const wordsOf = (text) => String(text).match(/[\p{L}\p{N}][\p{L}\p{N}'\u2019-]*/gu) || [];
20
+
21
+ /** Occurrences of a word or phrase, ignoring case and matching whole words only. */
22
+ export function countKeyword(text, keyword) {
23
+ const phrase = wordsOf(keyword).map(escapeRegExp).join("\\s+");
24
+ if (!phrase) return 0;
25
+ return (String(text).match(new RegExp(`(?<![\\p{L}\\p{N}])${phrase}(?![\\p{L}\\p{N}])`, "giu")) || []).length;
26
+ }
27
+
28
+ function countSyllables(word) {
29
+ let w = word.toLowerCase().replace(/[^a-z]/g, "");
30
+ if (!w) return 0;
31
+ if (w.length <= 3) return 1;
32
+ w = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, "");
33
+ const groups = w.match(/[aeiouy]{1,2}/g);
34
+ return Math.max(1, groups ? groups.length : 1);
35
+ }
36
+
37
+ function splitSentences(plain) {
38
+ return plain
39
+ .split(/\n+/)
40
+ .flatMap((line) => line.split(/(?<=[.!?])\s+/))
41
+ .map((s) => s.trim())
42
+ .filter((s) => wordsOf(s).length > 0);
43
+ }
44
+
45
+ /** Flesch reading ease (approximate, English only). */
46
+ export function readingEase(plain) {
47
+ const words = wordsOf(plain);
48
+ const sentences = splitSentences(plain);
49
+ if (!words.length || !sentences.length) return null;
50
+ const syllables = words.reduce((n, w) => n + countSyllables(w), 0);
51
+ const score = 206.835 - 1.015 * (words.length / sentences.length) - 84.6 * (syllables / words.length);
52
+ const rounded = round1(clamp(score, 0, 100));
53
+ const level = rounded >= 70 ? "easy" : rounded >= 50 ? "standard" : rounded >= 30 ? "difficult" : "very difficult";
54
+ return { score: rounded, level };
55
+ }
56
+
57
+ export function markdownToText(md) {
58
+ return String(md)
59
+ .replace(/```[\s\S]*?```/g, " ")
60
+ .replace(/`([^`]*)`/g, "$1")
61
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
62
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
63
+ .replace(/<[^>]+>/g, " ")
64
+ .replace(/^\s{0,3}#{1,6}\s+/gm, "")
65
+ .replace(/^\s{0,3}>\s?/gm, "")
66
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "")
67
+ .replace(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/gm, "")
68
+ .replace(/[*_~]{1,3}/g, "");
69
+ }
70
+
71
+ function keywordReport(keyword, { title, meta, h1s = [], headings = [], body, url }) {
72
+ const words = wordsOf(body);
73
+ const occurrences = countKeyword(body, keyword);
74
+ const keywordWords = wordsOf(keyword).length || 1;
75
+ const has = (text) => (text ? countKeyword(text, keyword) > 0 : false);
76
+ const report = {
77
+ keyword,
78
+ occurrences,
79
+ density_percent: words.length ? Math.round(((occurrences * keywordWords) / words.length) * 10000) / 100 : 0,
80
+ in_title: has(title),
81
+ in_meta_description: has(meta),
82
+ in_h1: h1s.some(has),
83
+ in_any_heading: headings.some((h) => has(h.text)),
84
+ in_first_100_words: has(words.slice(0, 100).join(" ")),
85
+ };
86
+ if (url) {
87
+ const slug = keyword.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, "-");
88
+ let path = url;
89
+ try {
90
+ path = decodeURIComponent(new URL(url).pathname).toLowerCase();
91
+ } catch {
92
+ // keep the raw value
93
+ }
94
+ report.in_url = path.includes(slug);
95
+ }
96
+ return report;
97
+ }
98
+
99
+ function keywordIssues(r, { hasTitle, hasMeta }) {
100
+ const issues = [];
101
+ if (r.occurrences === 0) issues.push({ level: "warning", message: `The keyword "${r.keyword}" does not appear in the text.` });
102
+ if (hasTitle && !r.in_title) issues.push({ level: "warning", message: "The keyword is not in the title." });
103
+ if (!r.in_h1 && r.occurrences > 0) issues.push({ level: "info", message: "The keyword is not in the H1 heading." });
104
+ if (!r.in_first_100_words && r.occurrences > 0) issues.push({ level: "info", message: "The keyword is not in the first 100 words." });
105
+ if (hasMeta && !r.in_meta_description) issues.push({ level: "info", message: "The keyword is not in the meta description." });
106
+ if (r.density_percent > 3) issues.push({ level: "warning", message: `Keyword density is ${r.density_percent}%, which looks like stuffing. Aim for roughly 0.5-2%.` });
107
+ else if (r.occurrences > 0 && r.occurrences < 3 && r.density_percent < 0.3) {
108
+ issues.push({ level: "info", message: "The keyword appears only a few times. Work it in naturally where it fits." });
109
+ }
110
+ return issues;
111
+ }
112
+
113
+ const lengthIssue = (label, text, min, max) => {
114
+ if (!text) return { level: "warning", message: `${label} is missing.` };
115
+ if (text.length > max) return { level: "warning", message: `${label} is ${text.length} characters (aim for ${min}-${max}); it may be cut off in results.` };
116
+ if (text.length < min) return { level: "info", message: `${label} is only ${text.length} characters (aim for ${min}-${max}).` };
117
+ return null;
118
+ };
119
+
120
+ const summarize = (issues) => {
121
+ const warnings = issues.filter((i) => i.level === "warning").length;
122
+ const tips = issues.length - warnings;
123
+ return `${warnings} warning${warnings === 1 ? "" : "s"}, ${tips} tip${tips === 1 ? "" : "s"}`;
124
+ };
125
+
126
+ // ---------------------------------------------------------------- seo_content_check
127
+
128
+ /** Readability and SEO check of a draft written in markdown or plain text. */
129
+ export function contentCheck({ text, keyword, title, meta_description } = {}) {
130
+ const md = String(text ?? "");
131
+ if (!md.trim()) throw new Error("text is required.");
132
+ const kw = keyword ? String(keyword).trim() : "";
133
+
134
+ const headings = [...md.matchAll(/^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/gm)].map((m) => ({ level: m[1].length, text: m[2].trim() }));
135
+ const h1s = headings.filter((h) => h.level === 1).map((h) => h.text);
136
+ const plain = markdownToText(md);
137
+ const words = wordsOf(plain);
138
+ const sentences = splitSentences(plain);
139
+ const sentenceLengths = sentences.map((s) => wordsOf(s).length);
140
+ const headingLines = new Set(headings.map((h) => h.text));
141
+ const paragraphs = markdownToText(md.replace(/^ {0,3}#{1,6}\s+.*$/gm, ""))
142
+ .split(/\n\s*\n/)
143
+ .map((p) => p.trim())
144
+ .filter(Boolean);
145
+ const paragraphLengths = paragraphs.map((p) => wordsOf(p).length);
146
+ const links = [...md.matchAll(/(?<!!)\[[^\]]*\]\(([^)\s]+)[^)]*\)/g)].map((m) => m[1]);
147
+ const images = [...md.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)];
148
+
149
+ const metrics = {
150
+ words: words.length,
151
+ reading_minutes: words.length ? Math.max(1, Math.round(words.length / 200)) : 0,
152
+ sentences: sentences.length,
153
+ avg_sentence_words: sentences.length ? round1(words.length / sentences.length) : 0,
154
+ longest_sentence_words: Math.max(0, ...sentenceLengths),
155
+ long_sentences: sentenceLengths.filter((n) => n > 30).length,
156
+ paragraphs: paragraphs.length,
157
+ longest_paragraph_words: Math.max(0, ...paragraphLengths),
158
+ reading_ease: readingEase(plain),
159
+ headings: { count: headings.length, h1: h1s, outline: headings.slice(0, 40) },
160
+ links: { total: links.length, external: links.filter((l) => /^https?:\/\//i.test(l)).length },
161
+ images: { total: images.length, missing_alt: images.filter((m) => !m[1].trim()).length },
162
+ };
163
+
164
+ const issues = [];
165
+ if (metrics.words < 300) issues.push({ level: "warning", message: `Only ${metrics.words} words. Short pages are often too thin to rank.` });
166
+ if (metrics.words > 300 && headings.length === 0) issues.push({ level: "warning", message: "No headings. Break the text up with H2/H3 headings." });
167
+ if (h1s.length > 1) issues.push({ level: "warning", message: `${h1s.length} H1 headings. Use exactly one.` });
168
+ if (metrics.avg_sentence_words > 25) issues.push({ level: "warning", message: `Sentences average ${metrics.avg_sentence_words} words. Shorter sentences read better.` });
169
+ if (metrics.long_sentences > 0) issues.push({ level: "info", message: `${metrics.long_sentences} sentence(s) over 30 words.` });
170
+ if (metrics.reading_ease && metrics.reading_ease.score < 50) {
171
+ issues.push({ level: "info", message: `Reading ease is ${metrics.reading_ease.score} (${metrics.reading_ease.level}). Simpler words and shorter sentences would help.` });
172
+ }
173
+ if (metrics.longest_paragraph_words > 120) issues.push({ level: "info", message: `A paragraph runs ${metrics.longest_paragraph_words} words. Split long paragraphs.` });
174
+ if (metrics.images.missing_alt > 0) issues.push({ level: "warning", message: `${metrics.images.missing_alt} image(s) without alt text.` });
175
+ if (metrics.words > 300 && metrics.links.total === 0) issues.push({ level: "info", message: "No links. Add links to related pages and sources." });
176
+ if (title !== undefined) {
177
+ const t = lengthIssue("The title", String(title).trim(), 30, 60);
178
+ if (t) issues.push(t);
179
+ }
180
+ if (meta_description !== undefined) {
181
+ const m = lengthIssue("The meta description", String(meta_description).trim(), 70, 160);
182
+ if (m) issues.push(m);
183
+ }
184
+
185
+ let keywordResult;
186
+ if (kw) {
187
+ keywordResult = keywordReport(kw, {
188
+ title: title === undefined ? undefined : String(title),
189
+ meta: meta_description === undefined ? undefined : String(meta_description),
190
+ h1s,
191
+ headings: headings.filter((h) => headingLines.has(h.text)),
192
+ body: plain,
193
+ });
194
+ issues.push(...keywordIssues(keywordResult, { hasTitle: title !== undefined, hasMeta: meta_description !== undefined }));
195
+ }
196
+
197
+ return { metrics, ...(keywordResult ? { keyword: keywordResult } : {}), issues, summary: summarize(issues), note: "Reading ease is approximate and tuned for English." };
198
+ }
199
+
200
+ // ---------------------------------------------------------------- seo_page_audit
201
+
202
+ const attr = (tag, name) => {
203
+ const m = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
204
+ return m ? decodeEntities(m[1] ?? m[2] ?? "") : undefined;
205
+ };
206
+
207
+ /** On-page SEO analysis of an HTML document. */
208
+ export function analyzeHtml(html, pageUrl, keyword) {
209
+ const url = new URL(pageUrl);
210
+ const host = url.hostname.replace(/^www\./, "");
211
+ const clean = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|noscript|svg|template)\b[\s\S]*?<\/\1>/gi, " ");
212
+ const parsed = htmlToText(html, pageUrl);
213
+
214
+ const headings = [...clean.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)]
215
+ .map((m) => ({ level: Number(m[1]), text: cleanInline(m[2]) }))
216
+ .filter((h) => h.text);
217
+ const h1s = headings.filter((h) => h.level === 1).map((h) => h.text);
218
+
219
+ const canonicalTag = /<link\b[^>]*\brel\s*=\s*["']canonical["'][^>]*>/i.exec(clean)?.[0];
220
+ const canonical = canonicalTag ? attr(canonicalTag, "href") : undefined;
221
+ const robots = metaContent(html, "robots") || undefined;
222
+ const lang = /<html\b[^>]*\blang\s*=\s*["']([^"']+)["']/i.exec(html)?.[1];
223
+ const description = parsed.description;
224
+
225
+ const imgTags = clean.match(/<img\b[^>]*>/gi) || [];
226
+ const images = {
227
+ total: imgTags.length,
228
+ missing_alt: imgTags.filter((t) => attr(t, "alt") === undefined).length,
229
+ empty_alt: imgTags.filter((t) => attr(t, "alt") === "").length,
230
+ };
231
+
232
+ let internal = 0;
233
+ let external = 0;
234
+ for (const m of clean.matchAll(/<a\b[^>]*>/gi)) {
235
+ const href = attr(m[0], "href");
236
+ if (!href || href.startsWith("#") || /^(mailto|tel|javascript):/i.test(href)) continue;
237
+ try {
238
+ const link = new URL(href, pageUrl);
239
+ if (!/^https?:$/.test(link.protocol)) continue;
240
+ if (link.hostname.replace(/^www\./, "") === host) internal++;
241
+ else external++;
242
+ } catch {
243
+ // skip malformed links
244
+ }
245
+ }
246
+
247
+ const structuredTypes = [];
248
+ for (const m of html.matchAll(/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
249
+ try {
250
+ const walk = (n) => {
251
+ if (Array.isArray(n)) n.forEach(walk);
252
+ else if (n && typeof n === "object") {
253
+ for (const t of [].concat(n["@type"] ?? [])) if (typeof t === "string" && !structuredTypes.includes(t)) structuredTypes.push(t);
254
+ if (n["@graph"]) walk(n["@graph"]);
255
+ }
256
+ };
257
+ walk(JSON.parse(m[1]));
258
+ } catch {
259
+ // ignore invalid JSON-LD
260
+ }
261
+ }
262
+
263
+ const bodyWords = wordsOf(parsed.text);
264
+ const result = {
265
+ title: { text: parsed.title, length: parsed.title.length },
266
+ meta_description: { text: description, length: description.length },
267
+ canonical,
268
+ robots,
269
+ lang,
270
+ viewport: metaContent(html, "viewport") || undefined,
271
+ headings: { h1: h1s, count: headings.length, outline: headings.slice(0, 40) },
272
+ words: bodyWords.length,
273
+ reading_minutes: bodyWords.length ? Math.max(1, Math.round(bodyWords.length / 200)) : 0,
274
+ images,
275
+ links: { internal, external },
276
+ structured_data: structuredTypes,
277
+ social: {
278
+ og_title: metaContent(html, "og:title") || undefined,
279
+ og_description: metaContent(html, "og:description") || undefined,
280
+ og_image: metaContent(html, "og:image") || undefined,
281
+ twitter_card: metaContent(html, "twitter:card") || undefined,
282
+ },
283
+ };
284
+
285
+ const issues = [];
286
+ const t = lengthIssue("The title", parsed.title, 30, 60);
287
+ if (t) issues.push(t);
288
+ const d = lengthIssue("The meta description", description, 70, 160);
289
+ if (d) issues.push(d);
290
+ if (h1s.length === 0) issues.push({ level: "warning", message: "No H1 heading." });
291
+ if (h1s.length > 1) issues.push({ level: "warning", message: `${h1s.length} H1 headings. Use exactly one.` });
292
+ if (/noindex/i.test(robots || "")) issues.push({ level: "warning", message: "The page is set to noindex, so search engines will not list it." });
293
+ if (!canonical) issues.push({ level: "info", message: "No canonical link." });
294
+ if (!lang) issues.push({ level: "info", message: "The <html> tag has no lang attribute." });
295
+ if (!result.viewport) issues.push({ level: "warning", message: "No viewport meta tag, so the page may not be mobile friendly." });
296
+ if (images.missing_alt > 0) issues.push({ level: "warning", message: `${images.missing_alt} image(s) without an alt attribute.` });
297
+ if (result.words < 300) issues.push({ level: "warning", message: `Only ${result.words} words of text. Short pages are often too thin to rank.` });
298
+ if (!result.social.og_title && !result.social.og_image) issues.push({ level: "info", message: "No Open Graph tags, so link previews on social sites will be plain." });
299
+ if (internal === 0) issues.push({ level: "info", message: "No internal links." });
300
+
301
+ let keywordResult;
302
+ if (keyword) {
303
+ keywordResult = keywordReport(String(keyword).trim(), {
304
+ title: parsed.title,
305
+ meta: description,
306
+ h1s,
307
+ headings,
308
+ body: parsed.text,
309
+ url: pageUrl,
310
+ });
311
+ issues.push(...keywordIssues(keywordResult, { hasTitle: true, hasMeta: true }));
312
+ }
313
+
314
+ return { ...result, ...(keywordResult ? { keyword: keywordResult } : {}), issues, summary: summarize(issues) };
315
+ }
316
+
317
+ /** Fetch a live page and audit its on-page SEO. */
318
+ export async function pageAudit({ url, keyword } = {}, deps = {}) {
319
+ const get = deps.fetchRaw ?? fetchRaw;
320
+ if (!url) throw new Error("url is required.");
321
+ const started = Date.now();
322
+ const page = await get(/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`);
323
+ const responseMs = Date.now() - started;
324
+ if (page.kind !== "html") throw new Error(`${url} did not return a web page (HTTP ${page.status}).`);
325
+ const audit = analyzeHtml(page.raw, page.final_url, keyword);
326
+ if (page.status >= 400) audit.issues.unshift({ level: "warning", message: `The page answered HTTP ${page.status}.` });
327
+ return {
328
+ url,
329
+ final_url: page.final_url,
330
+ status: page.status,
331
+ response_ms: responseMs,
332
+ ...audit,
333
+ summary: summarize(audit.issues),
334
+ notice: UNTRUSTED_NOTICE,
335
+ };
336
+ }
337
+
338
+ // ---------------------------------------------------------------- seo_keyword_ideas
339
+
340
+ const QUESTION_PREFIXES = ["how to", "what is", "why", "can", "is", "best"];
341
+ const MODIFIER_SUFFIXES = ["vs", "for", "with", "near me", "cost", "tools", "template", "examples"];
342
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz".split("");
343
+
344
+ export function buildQueries(seed, expand) {
345
+ const s = seed.trim();
346
+ const queries = [s];
347
+ if (expand === "questions" || expand === "all") {
348
+ for (const p of QUESTION_PREFIXES) queries.push(`${p} ${s}`);
349
+ for (const m of MODIFIER_SUFFIXES) queries.push(`${s} ${m}`);
350
+ }
351
+ if (expand === "alphabet" || expand === "all") for (const l of ALPHABET) queries.push(`${s} ${l}`);
352
+ return queries;
353
+ }
354
+
355
+ /** Parse an OpenSearch-style suggestion response: [query, [suggestions], ..., { "google:suggestrelevance": [...] }]. */
356
+ export function parseSuggest(raw) {
357
+ let data;
358
+ try {
359
+ data = JSON.parse(raw);
360
+ } catch {
361
+ return [];
362
+ }
363
+ const list = Array.isArray(data?.[1]) ? data[1] : [];
364
+ const relevance = data?.[4]?.["google:suggestrelevance"];
365
+ return list
366
+ .map((phrase, i) => ({ phrase, rank: i + 1, relevance: Array.isArray(relevance) ? relevance[i] : undefined }))
367
+ .filter((s) => typeof s.phrase === "string" && s.phrase.trim());
368
+ }
369
+
370
+ const enc = encodeURIComponent;
371
+ const SUGGEST_SOURCES = {
372
+ google: (q, { country, language }) => `https://suggestqueries.google.com/complete/search?client=chrome&q=${enc(q)}&hl=${language}&gl=${country}`,
373
+ bing: (q) => `https://api.bing.com/osjson.aspx?query=${enc(q)}`,
374
+ duckduckgo: (q) => `https://duckduckgo.com/ac/?q=${enc(q)}&type=list`,
375
+ };
376
+
377
+ export function classifyIntent(keyword) {
378
+ if (/^(how|what|why|when|where|who|which|can|is|are|does|do|should|will)\b/i.test(keyword)) return "question";
379
+ if (/\b(vs\.?|versus|alternatives?|compare|comparison)\b/i.test(keyword)) return "comparison";
380
+ if (/\b(best|top|review|reviews|pricing|price|cost|cheap|buy|hire|hiring|service|services|agency|near me|rates?|quote)\b/i.test(keyword)) return "commercial";
381
+ return "general";
382
+ }
383
+
384
+ /** Keyword ideas from search autocomplete. No search volume: rank and agreement between sources are the signals. */
385
+ export async function keywordIdeas({ seed, expand = "questions", sources = ["google", "bing"], country = "us", language = "en", limit = 50 } = {}, deps = {}) {
386
+ const get = deps.fetchRaw ?? fetchRaw;
387
+ const wait = deps.sleep ?? sleep;
388
+ const seedText = String(seed ?? "").trim();
389
+ if (!seedText) throw new Error("seed is required.");
390
+ if (seedText.length > 100) throw new Error("seed is too long (100 characters max).");
391
+ if (!["none", "questions", "alphabet", "all"].includes(expand)) throw new Error("expand must be none, questions, alphabet or all.");
392
+ const chosen = [...new Set(sources.map((s) => String(s).toLowerCase()))];
393
+ const unknown = chosen.filter((s) => !SUGGEST_SOURCES[s]);
394
+ if (unknown.length || !chosen.length) throw new Error(`sources must be from: ${Object.keys(SUGGEST_SOURCES).join(", ")}.`);
395
+ if (!/^[a-z]{2}$/i.test(country) || !/^[a-z]{2}(-[a-z]{2})?$/i.test(language)) throw new Error("country and language must be codes like us and en.");
396
+ const max = clamp(Math.trunc(limit) || 50, 1, 200);
397
+
398
+ const queries = buildQueries(seedText, expand);
399
+ const jobs = queries.flatMap((query) => chosen.map((source) => ({ query, source })));
400
+ const found = new Map(); // lowercase phrase -> aggregate
401
+ const errors = [];
402
+ let succeeded = 0;
403
+
404
+ for (let i = 0; i < jobs.length; i += 4) {
405
+ const batch = jobs.slice(i, i + 4);
406
+ await Promise.all(
407
+ batch.map(async ({ query, source }) => {
408
+ try {
409
+ const res = await get(SUGGEST_SOURCES[source](query, { country: country.toLowerCase(), language: language.toLowerCase() }));
410
+ if (res.status !== 200 || res.kind === "binary") throw new Error(`HTTP ${res.status}`);
411
+ succeeded++;
412
+ for (const s of parseSuggest(res.raw)) {
413
+ const key = s.phrase.toLowerCase().trim();
414
+ if (key === query.toLowerCase()) continue;
415
+ const entry = found.get(key) ?? { keyword: s.phrase.trim(), sources: new Set(), best_rank: Infinity, google_relevance: undefined };
416
+ entry.sources.add(source);
417
+ entry.best_rank = Math.min(entry.best_rank, s.rank);
418
+ if (source === "google" && typeof s.relevance === "number") {
419
+ entry.google_relevance = Math.max(entry.google_relevance ?? 0, s.relevance);
420
+ }
421
+ found.set(key, entry);
422
+ }
423
+ } catch (err) {
424
+ errors.push({ source, query, error: err.message });
425
+ }
426
+ })
427
+ );
428
+ if (i + 4 < jobs.length) await wait(150);
429
+ }
430
+ if (!succeeded) throw new Error(`No suggestions could be fetched (${errors[0]?.error ?? "unknown error"}).`);
431
+
432
+ const all = [...found.values()]
433
+ .map((e) => ({
434
+ keyword: e.keyword,
435
+ intent: classifyIntent(e.keyword),
436
+ words: wordsOf(e.keyword).length,
437
+ sources: [...e.sources].sort(),
438
+ best_rank: e.best_rank,
439
+ ...(e.google_relevance !== undefined ? { google_relevance: e.google_relevance } : {}),
440
+ }))
441
+ .sort(
442
+ (a, b) =>
443
+ b.sources.length - a.sources.length ||
444
+ (b.google_relevance ?? -1) - (a.google_relevance ?? -1) ||
445
+ a.best_rank - b.best_rank ||
446
+ a.keyword.localeCompare(b.keyword)
447
+ );
448
+
449
+ const byIntent = {};
450
+ for (const k of all) byIntent[k.intent] = (byIntent[k.intent] ?? 0) + 1;
451
+ return {
452
+ seed: seedText,
453
+ expand,
454
+ sources: chosen,
455
+ requests: jobs.length,
456
+ failed_requests: errors.length,
457
+ total_found: all.length,
458
+ by_intent: byIntent,
459
+ keywords: all.slice(0, max),
460
+ note: "Autocomplete shows what people really type, but there is no search volume here. Treat best_rank (1 is most popular) and how many sources agree as rough demand signals.",
461
+ };
462
+ }
463
+
464
+ // ---------------------------------------------------------------- seo_search_console
465
+
466
+ const GSC_API = "https://www.googleapis.com/webmasters/v3";
467
+ const GSC_DIMENSIONS = ["query", "page", "country", "device", "date"];
468
+ const isoDay = (ms) => new Date(ms).toISOString().slice(0, 10);
469
+
470
+ /**
471
+ * Real search performance of your own site from Google Search Console.
472
+ * Without `site`, lists the properties this account can read.
473
+ * Needs GSC_REFRESH_TOKEN (scope webmasters.readonly) and GSC_CLIENT_ID/GSC_CLIENT_SECRET
474
+ * (or the GMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET of the same OAuth client).
475
+ */
476
+ export async function searchConsole(
477
+ { site, days = 28, dimensions, limit = 25, query_contains, page_contains, quick_wins = false, min_impressions } = {},
478
+ deps = {}
479
+ ) {
480
+ const fetchFn = deps.fetch ?? globalThis.fetch;
481
+ const now = deps.now ?? Date.now;
482
+ const creds = googleCredentials({
483
+ refreshTokenEnv: "GSC_REFRESH_TOKEN",
484
+ clientIdEnvs: ["GSC_CLIENT_ID", "GMAIL_CLIENT_ID"],
485
+ clientSecretEnvs: ["GSC_CLIENT_SECRET", "GMAIL_CLIENT_SECRET"],
486
+ });
487
+ const token = await getAccessToken(creds, { fetch: fetchFn, now });
488
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
489
+
490
+ const call = async (url, init) => {
491
+ const res = await fetchFn(url, { ...init, headers, signal: AbortSignal.timeout(20_000) });
492
+ const data = await res.json().catch(() => ({}));
493
+ if (!res.ok) {
494
+ const message = data?.error?.message || `HTTP ${res.status}`;
495
+ if (res.status === 401) throw new Error("Google rejected the access token. Check GSC_REFRESH_TOKEN.");
496
+ if (res.status === 403) {
497
+ throw new Error(`Search Console refused the request: ${message}. The refresh token needs the webmasters.readonly scope, and this Google account needs access to the property.`);
498
+ }
499
+ throw new Error(`Search Console error: ${message}`);
500
+ }
501
+ return data;
502
+ };
503
+
504
+ if (!site) {
505
+ const data = await call(`${GSC_API}/sites`, { method: "GET" });
506
+ const sites = (data.siteEntry || []).map((s) => ({ site: s.siteUrl, permission: s.permissionLevel }));
507
+ return { sites, hint: "Pass one of these as `site` (for example sc-domain:example.com or https://example.com/)." };
508
+ }
509
+
510
+ const dims = dimensions?.length ? dimensions : quick_wins ? ["query", "page"] : ["query"];
511
+ const badDim = dims.find((d) => !GSC_DIMENSIONS.includes(d));
512
+ if (badDim) throw new Error(`Unknown dimension "${badDim}". Use: ${GSC_DIMENSIONS.join(", ")}.`);
513
+ if (quick_wins && !dims.includes("query")) throw new Error("quick_wins needs the query dimension.");
514
+
515
+ const span = clamp(Math.trunc(days) || 28, 1, 90);
516
+ const end = now() - 2 * 86_400_000; // Search Console data lags by a couple of days
517
+ const start = end - (span - 1) * 86_400_000;
518
+ const filters = [];
519
+ if (query_contains) filters.push({ dimension: "query", operator: "contains", expression: String(query_contains) });
520
+ if (page_contains) filters.push({ dimension: "page", operator: "contains", expression: String(page_contains) });
521
+ const max = clamp(Math.trunc(limit) || 25, 1, 1000);
522
+
523
+ const data = await call(`${GSC_API}/sites/${encodeURIComponent(site)}/searchAnalytics/query`, {
524
+ method: "POST",
525
+ body: JSON.stringify({
526
+ startDate: isoDay(start),
527
+ endDate: isoDay(end),
528
+ dimensions: dims,
529
+ rowLimit: quick_wins ? 1000 : max,
530
+ startRow: 0,
531
+ ...(filters.length ? { dimensionFilterGroups: [{ groupType: "and", filters }] } : {}),
532
+ }),
533
+ });
534
+
535
+ let rows = (data.rows || []).map((row) => ({
536
+ ...Object.fromEntries(dims.map((d, i) => [d, row.keys?.[i]])),
537
+ clicks: row.clicks,
538
+ impressions: row.impressions,
539
+ ctr_percent: round1((row.ctr || 0) * 100),
540
+ position: round1(row.position || 0),
541
+ }));
542
+ let mode = "top rows";
543
+ if (quick_wins) {
544
+ const floor = Number.isFinite(min_impressions) ? min_impressions : 20;
545
+ rows = rows
546
+ .filter((r) => r.position >= 4 && r.position <= 20 && r.impressions >= floor)
547
+ .sort((a, b) => b.impressions - a.impressions)
548
+ .slice(0, max);
549
+ mode = `quick wins: positions 4-20 with at least ${floor} impressions, most impressions first`;
550
+ }
551
+ return {
552
+ site,
553
+ period: { start: isoDay(start), end: isoDay(end) },
554
+ dimensions: dims,
555
+ mode,
556
+ count: rows.length,
557
+ totals: { clicks: rows.reduce((n, r) => n + r.clicks, 0), impressions: rows.reduce((n, r) => n + r.impressions, 0) },
558
+ rows,
559
+ note: "Google returns its top rows, not necessarily every query. Positions are averages. Dates are in Pacific time.",
560
+ };
561
+ }