fullstackgtm 0.32.0 → 0.34.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.34.0] — 2026-06-18
9
+
10
+ ### Added
11
+
12
+ - **`discoverCompetitors(category, { llm, anchorUrl?, exclude? })`** — propose the
13
+ real vendor set for a category via the LLM (BYOK through `forcedToolCall`), so a
14
+ cold-start map needs only a category. Returns canonical homepages + a
15
+ category-specific `productUrl` per vendor; excludes the anchor and supplied hosts,
16
+ de-dupes by registrable domain, and instructs the model to skip acquired/defunct
17
+ brands. Pairs with `findCategoryPage` / `detectDrift` / `fetchLogoDataUri` (0.33).
18
+
19
+ ## [0.33.0] — 2026-06-18
20
+
21
+ ### Added
22
+
23
+ - **Market sourcing helpers (`marketSourcing.ts`)** — find the right page to
24
+ capture per vendor, detect acquired/redirected vendors, and extract brand logos,
25
+ with zero coupling to any transport:
26
+ - `pickCategoryPage(html, baseUrl, keywords)` — follow a vendor's own nav to its
27
+ category page (so multi-product companies like SAP/Salesforce are captured on
28
+ the product page, not the corporate homepage). Pure.
29
+ - `findCategoryPageInSitemap(rootUrl, keywords, fetchText?)` — sitemap fallback
30
+ for JS-mega-menu sites whose product links aren't in the rendered homepage.
31
+ - `findCategoryPage(...)` — nav-scan then sitemap, combined.
32
+ - `detectDrift(url, srcHost, resolve?)` / `resolveFinalUrl(url)` — skip vendors
33
+ whose site redirects to a different company (an acquired/defunct product).
34
+ - `extractLogoUrl(html, baseUrl)` + `fetchLogoDataUri(homepageUrl, html?, …)` —
35
+ a vendor logo as a self-contained `data:` URI for `MarketVendor.logo`.
36
+ - `categoryKeywords()` + `registrableDomain()` utilities.
37
+
38
+ Pure functions operate on already-fetched HTML; fetching helpers default to the
39
+ package's SSRF-guarded `assertPublicUrl` + `fetch` but accept an injectable
40
+ fetcher (testable offline, browser-render-friendly). 8 tests.
41
+
8
42
  ## [0.32.0] — 2026-06-18
9
43
 
10
44
  ### Added
package/dist/index.d.ts CHANGED
@@ -34,6 +34,7 @@ export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type Classi
34
34
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
35
35
  export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
36
36
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
37
+ export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, type FetchText, type FetchBytes, type ResolveUrl, type DiscoveredVendor, type DiscoverCompetitorsOptions, } from "./marketSourcing.ts";
37
38
  export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, type CronExpression, type CrontabIo, type ScheduleEntry, type ScheduleProvider, type ScheduleRunRecord, type ScheduleRunStore, type ScheduleRunTrigger, type ScheduleStore, } from "./schedule.ts";
38
39
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
39
40
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -34,5 +34,6 @@ export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
34
34
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
35
35
  export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
36
36
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
37
+ export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, } from "./marketSourcing.js";
37
38
  export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
38
39
  export { suggestValues } from "./suggest.js";
@@ -0,0 +1,91 @@
1
+ import { type LlmCallOptions } from "./llm.ts";
2
+ /** Fetch a text resource (sitemap, robots.txt); null on any failure. */
3
+ export type FetchText = (url: string) => Promise<string | null>;
4
+ /** Fetch raw bytes (a logo image); null on any failure. */
5
+ export type FetchBytes = (url: string) => Promise<{
6
+ contentType: string;
7
+ bytes: Uint8Array;
8
+ } | null>;
9
+ /** Resolve a URL's final destination after redirects. */
10
+ export type ResolveUrl = (url: string) => Promise<{
11
+ finalUrl: string;
12
+ status: number;
13
+ }>;
14
+ /**
15
+ * Best-effort registrable domain (eTLD+1) for comparing vendor identity across a
16
+ * redirect. Heuristic (no full public-suffix list): last two labels, or three for
17
+ * a known multi-label suffix. Good enough to catch "spiff.com → salesforce.com".
18
+ */
19
+ export declare function registrableDomain(host: string): string;
20
+ /** Significant category words for matching pages/links (drops generic filler). */
21
+ export declare function categoryKeywords(category: string): string[];
22
+ /**
23
+ * Conglomerate page-selection: when a vendor's homepage isn't about the category,
24
+ * follow its own nav to the category page. Scans same-registrable-domain internal
25
+ * links and returns the one whose anchor text / path best matches the category
26
+ * keywords (anchor text weighted 2×, path 1×; requires ≥2 to avoid false hits).
27
+ * Returns null if no link is a clear match. Pure — operates on the given HTML.
28
+ */
29
+ export declare function pickCategoryPage(html: string, baseUrl: string, keywords: string[]): string | null;
30
+ /**
31
+ * Pull a canonical logo URL out of a homepage: apple-touch-icon → og:image →
32
+ * rel=icon, resolved to an absolute URL. Pure — operates on the given HTML.
33
+ */
34
+ export declare function extractLogoUrl(html: string, baseUrl: string): string | null;
35
+ /**
36
+ * Follow redirects (SSRF-guarded, re-validated each hop) and return the final URL
37
+ * + status WITHOUT downloading the body. Used to detect identity drift.
38
+ */
39
+ export declare const resolveFinalUrl: ResolveUrl;
40
+ /**
41
+ * Detect vendor identity drift: does this URL (or its www/apex sibling) redirect
42
+ * to a DIFFERENT registrable domain? Returns the drifted-to host, else null.
43
+ * Catches acquired/defunct products (e.g. www.spiff.com → salesforce.com) even
44
+ * when the apex itself errors. Only a 2xx/3xx landing on another domain counts —
45
+ * a status code or a throw is NOT drift (real sites block bare requests).
46
+ */
47
+ export declare function detectDrift(url: string, srcHost: string, resolve?: ResolveUrl): Promise<string | null>;
48
+ /**
49
+ * Fallback for JS-nav conglomerates whose product links aren't in the rendered
50
+ * homepage: find the category page from the vendor's sitemap. Bounded (≤6 sitemap
51
+ * fetches, ≤20k URLs), same-registrable-domain, plain XML only; media sitemaps
52
+ * skipped, non-English locales de-prioritized, /products/ preferred; requires the
53
+ * path to hit the keywords so locale/blog false-positives are rejected.
54
+ */
55
+ export declare function findCategoryPageInSitemap(rootUrl: string, keywords: string[], fetchText?: FetchText): Promise<string | null>;
56
+ /**
57
+ * Find a vendor's category-specific page: scan its (already-fetched) homepage nav,
58
+ * then fall back to the sitemap. Returns the page URL, or null to keep the homepage.
59
+ */
60
+ export declare function findCategoryPage(homepageHtml: string, homepageUrl: string, category: string, fetchText?: FetchText): Promise<string | null>;
61
+ /**
62
+ * A vendor logo as a self-contained `data:` URI — the form `MarketVendor.logo`
63
+ * renders and the report serves under a strict `img-src data:` CSP. Prefers the
64
+ * page-declared logo (from the given homepage HTML), then a favicon service.
65
+ * Bounded to small raster/SVG (≤50KB).
66
+ */
67
+ export declare function fetchLogoDataUri(homepageUrl: string, html?: string, fetchBytes?: FetchBytes): Promise<string | null>;
68
+ /** A vendor proposed by `discoverCompetitors`. */
69
+ export type DiscoveredVendor = {
70
+ name: string;
71
+ /** Canonical homepage. */
72
+ url: string;
73
+ /** The page most specific to the category — the product page for multi-product
74
+ * companies, else the homepage. Use as the capture seed. */
75
+ productUrl: string;
76
+ };
77
+ export type DiscoverCompetitorsOptions = {
78
+ llm: LlmCallOptions;
79
+ /** The user's own company: competitors are listed for it and it's excluded. */
80
+ anchorUrl?: string;
81
+ /** Vendor hosts already in the set, to exclude from results. */
82
+ exclude?: string[];
83
+ };
84
+ /**
85
+ * Propose the real vendor set for a category via the LLM — so a cold-start map
86
+ * needs only a category, not a hand-built vendor list. Returns canonical homepages
87
+ * plus a category-specific `productUrl` per vendor; excludes the anchor + any
88
+ * supplied hosts, de-dupes by registrable domain, and instructs the model to skip
89
+ * acquired/defunct brands. BYOK via the package's `forcedToolCall`.
90
+ */
91
+ export declare function discoverCompetitors(category: string, options: DiscoverCompetitorsOptions): Promise<DiscoveredVendor[]>;
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Market sourcing — find the *right* page to capture for each vendor, detect
3
+ * acquired/redirected vendors, and extract brand logos. These raise the quality
4
+ * of a cold-start map and are useful to every consumer (CLI, MCP, or a hosted
5
+ * service), with zero coupling to any transport.
6
+ *
7
+ * Design:
8
+ * - **Pure functions** (`pickCategoryPage`, `extractLogoUrl`, `categoryKeywords`,
9
+ * `registrableDomain`) operate on already-fetched HTML / strings. The caller
10
+ * fetches the page with whatever it likes — the hosted service injects a
11
+ * browser-rendering fetch for JS-walled homepages; the CLI uses the default.
12
+ * - **Fetching helpers** (`resolveFinalUrl`, `detectDrift`,
13
+ * `findCategoryPageInSitemap`, `fetchLogoDataUri`) default to the package's
14
+ * SSRF-guarded `assertPublicUrl` + global `fetch`, but each accepts an
15
+ * injectable fetcher so they stay testable offline and transport-agnostic.
16
+ * Sitemaps / redirects / logo bytes are plain resources — they never need a
17
+ * headless browser, so the default fetch is sufficient even in the hosted layer.
18
+ */
19
+ import { assertPublicUrl } from "./market.js";
20
+ import { DEFAULT_MODELS, forcedToolCall } from "./llm.js";
21
+ const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
22
+ const FETCH_TIMEOUT_MS = 15_000;
23
+ const MAX_REDIRECTS = 5;
24
+ function hostOf(url) {
25
+ try {
26
+ return new URL(url).hostname.replace(/^www\./, "");
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ // ── Pure helpers ────────────────────────────────────────────────────────────
33
+ // A few common multi-label public suffixes so "bbc.co.uk" → "bbc.co.uk", not "co.uk".
34
+ const MULTI_LABEL_TLDS = new Set([
35
+ "co.uk", "com.au", "co.nz", "co.jp", "com.br", "co.in", "com.sg", "co.za", "com.mx", "com.cn",
36
+ ]);
37
+ /**
38
+ * Best-effort registrable domain (eTLD+1) for comparing vendor identity across a
39
+ * redirect. Heuristic (no full public-suffix list): last two labels, or three for
40
+ * a known multi-label suffix. Good enough to catch "spiff.com → salesforce.com".
41
+ */
42
+ export function registrableDomain(host) {
43
+ const h = String(host || "").toLowerCase().replace(/\.$/, "");
44
+ const labels = h.split(".");
45
+ if (labels.length <= 2)
46
+ return h;
47
+ const lastTwo = labels.slice(-2).join(".");
48
+ return MULTI_LABEL_TLDS.has(lastTwo) ? labels.slice(-3).join(".") : lastTwo;
49
+ }
50
+ const CATEGORY_STOPWORDS = new Set([
51
+ "software", "platform", "platforms", "tool", "tools", "system", "systems", "management",
52
+ "solution", "solutions", "app", "apps", "service", "services", "suite", "the", "for", "and", "of",
53
+ ]);
54
+ /** Significant category words for matching pages/links (drops generic filler). */
55
+ export function categoryKeywords(category) {
56
+ return [
57
+ ...new Set(String(category || "")
58
+ .toLowerCase()
59
+ .split(/[^a-z0-9]+/)
60
+ .filter((w) => w.length >= 3 && !CATEGORY_STOPWORDS.has(w))),
61
+ ];
62
+ }
63
+ /**
64
+ * Conglomerate page-selection: when a vendor's homepage isn't about the category,
65
+ * follow its own nav to the category page. Scans same-registrable-domain internal
66
+ * links and returns the one whose anchor text / path best matches the category
67
+ * keywords (anchor text weighted 2×, path 1×; requires ≥2 to avoid false hits).
68
+ * Returns null if no link is a clear match. Pure — operates on the given HTML.
69
+ */
70
+ export function pickCategoryPage(html, baseUrl, keywords) {
71
+ if (!html || !keywords.length)
72
+ return null;
73
+ let baseHost;
74
+ try {
75
+ baseHost = registrableDomain(new URL(baseUrl).hostname);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const re = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
81
+ const seen = new Set();
82
+ let best = null;
83
+ let bestScore = 0;
84
+ let m;
85
+ while ((m = re.exec(html))) {
86
+ let u;
87
+ try {
88
+ u = new URL(m[1], baseUrl);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ if (u.protocol !== "http:" && u.protocol !== "https:")
94
+ continue;
95
+ if (registrableDomain(u.hostname) !== baseHost)
96
+ continue; // internal links only
97
+ const key = u.origin + u.pathname;
98
+ if (seen.has(key))
99
+ continue;
100
+ seen.add(key);
101
+ const text = m[2].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
102
+ const path = u.pathname.toLowerCase();
103
+ let score = 0;
104
+ for (const kw of keywords) {
105
+ if (text.includes(kw))
106
+ score += 2;
107
+ if (path.includes(kw))
108
+ score += 1;
109
+ }
110
+ if (score > bestScore) {
111
+ bestScore = score;
112
+ best = u.toString();
113
+ }
114
+ }
115
+ return bestScore >= 2 ? best : null;
116
+ }
117
+ /**
118
+ * Pull a canonical logo URL out of a homepage: apple-touch-icon → og:image →
119
+ * rel=icon, resolved to an absolute URL. Pure — operates on the given HTML.
120
+ */
121
+ export function extractLogoUrl(html, baseUrl) {
122
+ if (!html)
123
+ return null;
124
+ const abs = (href) => {
125
+ try {
126
+ return new URL(href, baseUrl).toString();
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ };
132
+ const hrefOf = (tag) => {
133
+ const mm = tag.match(/href=["']([^"']+)["']/i) || tag.match(/content=["']([^"']+)["']/i);
134
+ return mm ? abs(mm[1]) : null;
135
+ };
136
+ let mm = html.match(/<link[^>]+rel=["'][^"']*apple-touch-icon[^"']*["'][^>]*>/i);
137
+ if (mm) {
138
+ const u = hrefOf(mm[0]);
139
+ if (u)
140
+ return u;
141
+ }
142
+ mm = html.match(/<meta[^>]+property=["']og:image["'][^>]*>/i);
143
+ if (mm) {
144
+ const u = hrefOf(mm[0]);
145
+ if (u)
146
+ return u;
147
+ }
148
+ mm = html.match(/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*>/i);
149
+ if (mm) {
150
+ const u = hrefOf(mm[0]);
151
+ if (u)
152
+ return u;
153
+ }
154
+ return null;
155
+ }
156
+ // ── Default SSRF-guarded fetchers ────────────────────────────────────────────
157
+ /** Manual-redirect fetch that re-validates every hop against the SSRF guard. */
158
+ async function guardedFetch(url) {
159
+ let current = url;
160
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
161
+ await assertPublicUrl(current);
162
+ const res = await fetch(current, {
163
+ redirect: "manual",
164
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
165
+ headers: { "User-Agent": USER_AGENT },
166
+ });
167
+ const location = res.headers.get("location");
168
+ if (res.status >= 300 && res.status < 400 && location) {
169
+ try {
170
+ await res.body?.cancel();
171
+ }
172
+ catch {
173
+ /* ignore */
174
+ }
175
+ current = new URL(location, current).toString();
176
+ continue;
177
+ }
178
+ return res;
179
+ }
180
+ return null;
181
+ }
182
+ const defaultFetchText = async (url) => {
183
+ try {
184
+ const res = await guardedFetch(url);
185
+ return res && res.ok ? await res.text() : null;
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ };
191
+ const defaultFetchBytes = async (url) => {
192
+ try {
193
+ const res = await guardedFetch(url);
194
+ if (!res || !res.ok)
195
+ return null;
196
+ const contentType = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
197
+ const bytes = new Uint8Array(await res.arrayBuffer());
198
+ return { contentType, bytes };
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ };
204
+ /**
205
+ * Follow redirects (SSRF-guarded, re-validated each hop) and return the final URL
206
+ * + status WITHOUT downloading the body. Used to detect identity drift.
207
+ */
208
+ export const resolveFinalUrl = async (rawUrl) => {
209
+ let current = rawUrl;
210
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
211
+ await assertPublicUrl(current);
212
+ const res = await fetch(current, {
213
+ redirect: "manual",
214
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
215
+ headers: { "User-Agent": USER_AGENT },
216
+ });
217
+ try {
218
+ await res.body?.cancel();
219
+ }
220
+ catch {
221
+ /* ignore */
222
+ }
223
+ const location = res.headers.get("location");
224
+ if (res.status >= 300 && res.status < 400 && location) {
225
+ current = new URL(location, current).toString();
226
+ continue;
227
+ }
228
+ return { finalUrl: current, status: res.status };
229
+ }
230
+ return { finalUrl: current, status: 0 };
231
+ };
232
+ // ── Fetching helpers ─────────────────────────────────────────────────────────
233
+ /**
234
+ * Detect vendor identity drift: does this URL (or its www/apex sibling) redirect
235
+ * to a DIFFERENT registrable domain? Returns the drifted-to host, else null.
236
+ * Catches acquired/defunct products (e.g. www.spiff.com → salesforce.com) even
237
+ * when the apex itself errors. Only a 2xx/3xx landing on another domain counts —
238
+ * a status code or a throw is NOT drift (real sites block bare requests).
239
+ */
240
+ export async function detectDrift(url, srcHost, resolve = resolveFinalUrl) {
241
+ if (!srcHost)
242
+ return null;
243
+ const tries = [url];
244
+ try {
245
+ const u = new URL(url);
246
+ const sibling = u.hostname.startsWith("www.")
247
+ ? url.replace("://www.", "://")
248
+ : url.replace(`://${u.hostname}`, `://www.${u.hostname}`);
249
+ if (sibling !== url)
250
+ tries.push(sibling);
251
+ }
252
+ catch {
253
+ /* ignore */
254
+ }
255
+ for (const t of tries) {
256
+ try {
257
+ const { finalUrl, status } = await resolve(t);
258
+ if (status < 200 || status >= 400)
259
+ continue;
260
+ const finalHost = hostOf(finalUrl);
261
+ if (finalHost && registrableDomain(finalHost) !== registrableDomain(srcHost))
262
+ return finalHost;
263
+ }
264
+ catch {
265
+ /* a throw isn't a drift signal */
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+ /**
271
+ * Fallback for JS-nav conglomerates whose product links aren't in the rendered
272
+ * homepage: find the category page from the vendor's sitemap. Bounded (≤6 sitemap
273
+ * fetches, ≤20k URLs), same-registrable-domain, plain XML only; media sitemaps
274
+ * skipped, non-English locales de-prioritized, /products/ preferred; requires the
275
+ * path to hit the keywords so locale/blog false-positives are rejected.
276
+ */
277
+ export async function findCategoryPageInSitemap(rootUrl, keywords, fetchText = defaultFetchText) {
278
+ let root;
279
+ try {
280
+ root = new URL(rootUrl);
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ if (!keywords.length)
286
+ return null;
287
+ const rootDom = registrableDomain(root.hostname);
288
+ const sameDomain = (u) => registrableDomain(hostOf(u) || "") === rootDom;
289
+ const pathScore = (u) => {
290
+ let path;
291
+ try {
292
+ path = new URL(u).pathname.toLowerCase();
293
+ }
294
+ catch {
295
+ return 0;
296
+ }
297
+ let s = 0;
298
+ for (const kw of keywords)
299
+ if (path.includes(kw))
300
+ s += 1;
301
+ if (s > 0) {
302
+ if (/\/(product|solution)s?\//.test(path))
303
+ s += 0.5; // prefer product pages
304
+ const loc = path.match(/^\/([a-z]{2})(?:-[a-z]{2})?\//); // de-prioritize non-English locales
305
+ if (loc && !["en", "us"].includes(loc[1]))
306
+ s -= 0.6;
307
+ }
308
+ return s;
309
+ };
310
+ const candidates = new Set([`${root.origin}/sitemap.xml`, `${root.origin}/sitemap_index.xml`]);
311
+ const robots = await fetchText(`${root.origin}/robots.txt`);
312
+ if (robots) {
313
+ for (const mm of robots.slice(0, 100_000).matchAll(/^\s*sitemap:\s*(\S+)/gim)) {
314
+ try {
315
+ candidates.add(new URL(mm[1].trim(), root.origin).toString());
316
+ }
317
+ catch {
318
+ /* skip */
319
+ }
320
+ }
321
+ }
322
+ const queue = [...candidates];
323
+ const seen = new Set();
324
+ let best = null;
325
+ let bestScore = 0;
326
+ let fetched = 0;
327
+ let scanned = 0;
328
+ while (queue.length && fetched < 6 && scanned < 20_000) {
329
+ const sm = queue.shift();
330
+ if (seen.has(sm) || !sameDomain(sm))
331
+ continue;
332
+ seen.add(sm);
333
+ if (sm.endsWith(".gz"))
334
+ continue; // skip compressed sitemaps
335
+ const xml = await fetchText(sm);
336
+ if (!xml)
337
+ continue;
338
+ fetched++;
339
+ const body = xml.slice(0, 5_000_000);
340
+ const locs = [...body.matchAll(/<loc>\s*([^<\s]+?)\s*<\/loc>/gi)].map((mm) => mm[1].replace(/&amp;/g, "&"));
341
+ if (/<sitemapindex/i.test(body)) {
342
+ const ranked = locs
343
+ .filter(sameDomain)
344
+ .filter((l) => !/(pdf|video|image|img|news|siteimprove)/i.test(l))
345
+ .map((l) => [pathScore(l) + (/product|solution/i.test(l) ? 1 : 0), l])
346
+ .sort((a, b) => b[0] - a[0]);
347
+ for (const [, l] of ranked.slice(0, 5))
348
+ queue.push(l);
349
+ }
350
+ else {
351
+ for (const l of locs) {
352
+ scanned++;
353
+ if (!sameDomain(l))
354
+ continue;
355
+ const s = pathScore(l);
356
+ if (s > bestScore) {
357
+ bestScore = s;
358
+ best = l;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ return bestScore >= Math.min(2, keywords.length) ? best : null;
364
+ }
365
+ /**
366
+ * Find a vendor's category-specific page: scan its (already-fetched) homepage nav,
367
+ * then fall back to the sitemap. Returns the page URL, or null to keep the homepage.
368
+ */
369
+ export async function findCategoryPage(homepageHtml, homepageUrl, category, fetchText = defaultFetchText) {
370
+ const keywords = categoryKeywords(category);
371
+ if (!keywords.length)
372
+ return null;
373
+ const nav = pickCategoryPage(homepageHtml, homepageUrl, keywords);
374
+ if (nav)
375
+ return nav;
376
+ return findCategoryPageInSitemap(homepageUrl, keywords, fetchText);
377
+ }
378
+ /**
379
+ * A vendor logo as a self-contained `data:` URI — the form `MarketVendor.logo`
380
+ * renders and the report serves under a strict `img-src data:` CSP. Prefers the
381
+ * page-declared logo (from the given homepage HTML), then a favicon service.
382
+ * Bounded to small raster/SVG (≤50KB).
383
+ */
384
+ export async function fetchLogoDataUri(homepageUrl, html, fetchBytes = defaultFetchBytes) {
385
+ const host = hostOf(homepageUrl);
386
+ if (!host)
387
+ return null;
388
+ const candidates = [];
389
+ if (html) {
390
+ const fromPage = extractLogoUrl(html, homepageUrl);
391
+ if (fromPage)
392
+ candidates.push(fromPage);
393
+ }
394
+ candidates.push(`https://www.google.com/s2/favicons?domain=${host}&sz=64`);
395
+ for (const url of candidates) {
396
+ const got = await fetchBytes(url);
397
+ if (!got)
398
+ continue;
399
+ if (!got.contentType.startsWith("image/"))
400
+ continue;
401
+ if (got.bytes.length === 0 || got.bytes.length > 50_000)
402
+ continue;
403
+ return `data:${got.contentType};base64,${Buffer.from(got.bytes).toString("base64")}`;
404
+ }
405
+ return null;
406
+ }
407
+ const DISCOVERY_SCHEMA = {
408
+ type: "object",
409
+ required: ["competitors"],
410
+ properties: {
411
+ competitors: {
412
+ type: "array",
413
+ description: "Real vendors competing in this category today, each with its canonical https homepage URL.",
414
+ items: {
415
+ type: "object",
416
+ required: ["name", "url"],
417
+ properties: {
418
+ name: { type: "string" },
419
+ url: { type: "string", description: "Canonical homepage, https://domain, no tracking path." },
420
+ productUrl: {
421
+ type: "string",
422
+ description: "URL of the page on THIS vendor's site most specific to the category. For a multi-product company (e.g. SAP, Oracle) this is the product/solution page for this category, NOT the corporate homepage. For a focused single-product vendor, repeat the homepage.",
423
+ },
424
+ },
425
+ },
426
+ },
427
+ },
428
+ };
429
+ /**
430
+ * Propose the real vendor set for a category via the LLM — so a cold-start map
431
+ * needs only a category, not a hand-built vendor list. Returns canonical homepages
432
+ * plus a category-specific `productUrl` per vendor; excludes the anchor + any
433
+ * supplied hosts, de-dupes by registrable domain, and instructs the model to skip
434
+ * acquired/defunct brands. BYOK via the package's `forcedToolCall`.
435
+ */
436
+ export async function discoverCompetitors(category, options) {
437
+ const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
438
+ const anchorHost = options.anchorUrl ? hostOf(options.anchorUrl) : null;
439
+ const anchorNote = anchorHost
440
+ ? `The user's own company is ${anchorHost} — list its direct competitors and do NOT include ${anchorHost} itself.`
441
+ : "";
442
+ const prompt = `List the most significant vendors competing in the category "${category}" today.
443
+ ${anchorNote}
444
+ Rules:
445
+ - Real companies only, each with its canonical https homepage URL (domain root, no tracking path).
446
+ - 7-9 vendors: a mix of established leaders and notable challengers.
447
+ - EXCLUDE vendors that have been acquired and folded into another brand, or are defunct — i.e. anything whose own site now redirects to a different company. (Name the current independent players instead.)
448
+ - For each, also give productUrl: the page most specific to "${category}". For a big multi-product company, that's its product/solution page for THIS category, not the corporate homepage.
449
+ - No duplicates.`;
450
+ const result = (await forcedToolCall(prompt, "discover_competitors", DISCOVERY_SCHEMA, model, options.llm));
451
+ const excluded = new Set((options.exclude ?? []).map(hostOf).filter((h) => Boolean(h)));
452
+ if (anchorHost)
453
+ excluded.add(anchorHost);
454
+ const seenDomain = new Set();
455
+ const out = [];
456
+ for (const c of result?.competitors ?? []) {
457
+ let u;
458
+ try {
459
+ u = new URL(String(c.url));
460
+ }
461
+ catch {
462
+ continue;
463
+ }
464
+ if (u.protocol !== "http:" && u.protocol !== "https:")
465
+ continue;
466
+ const host = u.hostname.replace(/^www\./, "");
467
+ if (excluded.has(host))
468
+ continue;
469
+ const dom = registrableDomain(host);
470
+ if (seenDomain.has(dom))
471
+ continue;
472
+ seenDomain.add(dom);
473
+ let productUrl = u.toString();
474
+ if (c.productUrl) {
475
+ try {
476
+ const p = new URL(String(c.productUrl));
477
+ if (p.protocol === "http:" || p.protocol === "https:")
478
+ productUrl = p.toString();
479
+ }
480
+ catch {
481
+ /* keep homepage */
482
+ }
483
+ }
484
+ out.push({ name: c.name || host, url: u.toString(), productUrl });
485
+ }
486
+ return out;
487
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/index.ts CHANGED
@@ -291,6 +291,23 @@ export {
291
291
  type VendorScale,
292
292
  } from "./marketScale.ts";
293
293
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
294
+ export {
295
+ registrableDomain,
296
+ categoryKeywords,
297
+ pickCategoryPage,
298
+ extractLogoUrl,
299
+ resolveFinalUrl,
300
+ detectDrift,
301
+ findCategoryPageInSitemap,
302
+ findCategoryPage,
303
+ fetchLogoDataUri,
304
+ discoverCompetitors,
305
+ type FetchText,
306
+ type FetchBytes,
307
+ type ResolveUrl,
308
+ type DiscoveredVendor,
309
+ type DiscoverCompetitorsOptions,
310
+ } from "./marketSourcing.ts";
294
311
  export {
295
312
  computeMissedFirings,
296
313
  createFileScheduleRunStore,
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Market sourcing — find the *right* page to capture for each vendor, detect
3
+ * acquired/redirected vendors, and extract brand logos. These raise the quality
4
+ * of a cold-start map and are useful to every consumer (CLI, MCP, or a hosted
5
+ * service), with zero coupling to any transport.
6
+ *
7
+ * Design:
8
+ * - **Pure functions** (`pickCategoryPage`, `extractLogoUrl`, `categoryKeywords`,
9
+ * `registrableDomain`) operate on already-fetched HTML / strings. The caller
10
+ * fetches the page with whatever it likes — the hosted service injects a
11
+ * browser-rendering fetch for JS-walled homepages; the CLI uses the default.
12
+ * - **Fetching helpers** (`resolveFinalUrl`, `detectDrift`,
13
+ * `findCategoryPageInSitemap`, `fetchLogoDataUri`) default to the package's
14
+ * SSRF-guarded `assertPublicUrl` + global `fetch`, but each accepts an
15
+ * injectable fetcher so they stay testable offline and transport-agnostic.
16
+ * Sitemaps / redirects / logo bytes are plain resources — they never need a
17
+ * headless browser, so the default fetch is sufficient even in the hosted layer.
18
+ */
19
+ import { assertPublicUrl } from "./market.ts";
20
+ import { DEFAULT_MODELS, forcedToolCall, type LlmCallOptions } from "./llm.ts";
21
+
22
+ const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
23
+ const FETCH_TIMEOUT_MS = 15_000;
24
+ const MAX_REDIRECTS = 5;
25
+
26
+ /** Fetch a text resource (sitemap, robots.txt); null on any failure. */
27
+ export type FetchText = (url: string) => Promise<string | null>;
28
+ /** Fetch raw bytes (a logo image); null on any failure. */
29
+ export type FetchBytes = (url: string) => Promise<{ contentType: string; bytes: Uint8Array } | null>;
30
+ /** Resolve a URL's final destination after redirects. */
31
+ export type ResolveUrl = (url: string) => Promise<{ finalUrl: string; status: number }>;
32
+
33
+ function hostOf(url: string): string | null {
34
+ try {
35
+ return new URL(url).hostname.replace(/^www\./, "");
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // ── Pure helpers ────────────────────────────────────────────────────────────
42
+
43
+ // A few common multi-label public suffixes so "bbc.co.uk" → "bbc.co.uk", not "co.uk".
44
+ const MULTI_LABEL_TLDS = new Set([
45
+ "co.uk", "com.au", "co.nz", "co.jp", "com.br", "co.in", "com.sg", "co.za", "com.mx", "com.cn",
46
+ ]);
47
+
48
+ /**
49
+ * Best-effort registrable domain (eTLD+1) for comparing vendor identity across a
50
+ * redirect. Heuristic (no full public-suffix list): last two labels, or three for
51
+ * a known multi-label suffix. Good enough to catch "spiff.com → salesforce.com".
52
+ */
53
+ export function registrableDomain(host: string): string {
54
+ const h = String(host || "").toLowerCase().replace(/\.$/, "");
55
+ const labels = h.split(".");
56
+ if (labels.length <= 2) return h;
57
+ const lastTwo = labels.slice(-2).join(".");
58
+ return MULTI_LABEL_TLDS.has(lastTwo) ? labels.slice(-3).join(".") : lastTwo;
59
+ }
60
+
61
+ const CATEGORY_STOPWORDS = new Set([
62
+ "software", "platform", "platforms", "tool", "tools", "system", "systems", "management",
63
+ "solution", "solutions", "app", "apps", "service", "services", "suite", "the", "for", "and", "of",
64
+ ]);
65
+
66
+ /** Significant category words for matching pages/links (drops generic filler). */
67
+ export function categoryKeywords(category: string): string[] {
68
+ return [
69
+ ...new Set(
70
+ String(category || "")
71
+ .toLowerCase()
72
+ .split(/[^a-z0-9]+/)
73
+ .filter((w) => w.length >= 3 && !CATEGORY_STOPWORDS.has(w)),
74
+ ),
75
+ ];
76
+ }
77
+
78
+ /**
79
+ * Conglomerate page-selection: when a vendor's homepage isn't about the category,
80
+ * follow its own nav to the category page. Scans same-registrable-domain internal
81
+ * links and returns the one whose anchor text / path best matches the category
82
+ * keywords (anchor text weighted 2×, path 1×; requires ≥2 to avoid false hits).
83
+ * Returns null if no link is a clear match. Pure — operates on the given HTML.
84
+ */
85
+ export function pickCategoryPage(html: string, baseUrl: string, keywords: string[]): string | null {
86
+ if (!html || !keywords.length) return null;
87
+ let baseHost: string;
88
+ try {
89
+ baseHost = registrableDomain(new URL(baseUrl).hostname);
90
+ } catch {
91
+ return null;
92
+ }
93
+ const re = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
94
+ const seen = new Set<string>();
95
+ let best: string | null = null;
96
+ let bestScore = 0;
97
+ let m: RegExpExecArray | null;
98
+ while ((m = re.exec(html))) {
99
+ let u: URL;
100
+ try {
101
+ u = new URL(m[1], baseUrl);
102
+ } catch {
103
+ continue;
104
+ }
105
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
106
+ if (registrableDomain(u.hostname) !== baseHost) continue; // internal links only
107
+ const key = u.origin + u.pathname;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ const text = m[2].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
111
+ const path = u.pathname.toLowerCase();
112
+ let score = 0;
113
+ for (const kw of keywords) {
114
+ if (text.includes(kw)) score += 2;
115
+ if (path.includes(kw)) score += 1;
116
+ }
117
+ if (score > bestScore) {
118
+ bestScore = score;
119
+ best = u.toString();
120
+ }
121
+ }
122
+ return bestScore >= 2 ? best : null;
123
+ }
124
+
125
+ /**
126
+ * Pull a canonical logo URL out of a homepage: apple-touch-icon → og:image →
127
+ * rel=icon, resolved to an absolute URL. Pure — operates on the given HTML.
128
+ */
129
+ export function extractLogoUrl(html: string, baseUrl: string): string | null {
130
+ if (!html) return null;
131
+ const abs = (href: string): string | null => {
132
+ try {
133
+ return new URL(href, baseUrl).toString();
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+ const hrefOf = (tag: string): string | null => {
139
+ const mm = tag.match(/href=["']([^"']+)["']/i) || tag.match(/content=["']([^"']+)["']/i);
140
+ return mm ? abs(mm[1]) : null;
141
+ };
142
+ let mm = html.match(/<link[^>]+rel=["'][^"']*apple-touch-icon[^"']*["'][^>]*>/i);
143
+ if (mm) {
144
+ const u = hrefOf(mm[0]);
145
+ if (u) return u;
146
+ }
147
+ mm = html.match(/<meta[^>]+property=["']og:image["'][^>]*>/i);
148
+ if (mm) {
149
+ const u = hrefOf(mm[0]);
150
+ if (u) return u;
151
+ }
152
+ mm = html.match(/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*>/i);
153
+ if (mm) {
154
+ const u = hrefOf(mm[0]);
155
+ if (u) return u;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // ── Default SSRF-guarded fetchers ────────────────────────────────────────────
161
+
162
+ /** Manual-redirect fetch that re-validates every hop against the SSRF guard. */
163
+ async function guardedFetch(url: string): Promise<Response | null> {
164
+ let current = url;
165
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
166
+ await assertPublicUrl(current);
167
+ const res = await fetch(current, {
168
+ redirect: "manual",
169
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
170
+ headers: { "User-Agent": USER_AGENT },
171
+ });
172
+ const location = res.headers.get("location");
173
+ if (res.status >= 300 && res.status < 400 && location) {
174
+ try {
175
+ await res.body?.cancel();
176
+ } catch {
177
+ /* ignore */
178
+ }
179
+ current = new URL(location, current).toString();
180
+ continue;
181
+ }
182
+ return res;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ const defaultFetchText: FetchText = async (url) => {
188
+ try {
189
+ const res = await guardedFetch(url);
190
+ return res && res.ok ? await res.text() : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ };
195
+
196
+ const defaultFetchBytes: FetchBytes = async (url) => {
197
+ try {
198
+ const res = await guardedFetch(url);
199
+ if (!res || !res.ok) return null;
200
+ const contentType = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
201
+ const bytes = new Uint8Array(await res.arrayBuffer());
202
+ return { contentType, bytes };
203
+ } catch {
204
+ return null;
205
+ }
206
+ };
207
+
208
+ /**
209
+ * Follow redirects (SSRF-guarded, re-validated each hop) and return the final URL
210
+ * + status WITHOUT downloading the body. Used to detect identity drift.
211
+ */
212
+ export const resolveFinalUrl: ResolveUrl = async (rawUrl) => {
213
+ let current = rawUrl;
214
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
215
+ await assertPublicUrl(current);
216
+ const res = await fetch(current, {
217
+ redirect: "manual",
218
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
219
+ headers: { "User-Agent": USER_AGENT },
220
+ });
221
+ try {
222
+ await res.body?.cancel();
223
+ } catch {
224
+ /* ignore */
225
+ }
226
+ const location = res.headers.get("location");
227
+ if (res.status >= 300 && res.status < 400 && location) {
228
+ current = new URL(location, current).toString();
229
+ continue;
230
+ }
231
+ return { finalUrl: current, status: res.status };
232
+ }
233
+ return { finalUrl: current, status: 0 };
234
+ };
235
+
236
+ // ── Fetching helpers ─────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Detect vendor identity drift: does this URL (or its www/apex sibling) redirect
240
+ * to a DIFFERENT registrable domain? Returns the drifted-to host, else null.
241
+ * Catches acquired/defunct products (e.g. www.spiff.com → salesforce.com) even
242
+ * when the apex itself errors. Only a 2xx/3xx landing on another domain counts —
243
+ * a status code or a throw is NOT drift (real sites block bare requests).
244
+ */
245
+ export async function detectDrift(
246
+ url: string,
247
+ srcHost: string,
248
+ resolve: ResolveUrl = resolveFinalUrl,
249
+ ): Promise<string | null> {
250
+ if (!srcHost) return null;
251
+ const tries = [url];
252
+ try {
253
+ const u = new URL(url);
254
+ const sibling = u.hostname.startsWith("www.")
255
+ ? url.replace("://www.", "://")
256
+ : url.replace(`://${u.hostname}`, `://www.${u.hostname}`);
257
+ if (sibling !== url) tries.push(sibling);
258
+ } catch {
259
+ /* ignore */
260
+ }
261
+ for (const t of tries) {
262
+ try {
263
+ const { finalUrl, status } = await resolve(t);
264
+ if (status < 200 || status >= 400) continue;
265
+ const finalHost = hostOf(finalUrl);
266
+ if (finalHost && registrableDomain(finalHost) !== registrableDomain(srcHost)) return finalHost;
267
+ } catch {
268
+ /* a throw isn't a drift signal */
269
+ }
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * Fallback for JS-nav conglomerates whose product links aren't in the rendered
276
+ * homepage: find the category page from the vendor's sitemap. Bounded (≤6 sitemap
277
+ * fetches, ≤20k URLs), same-registrable-domain, plain XML only; media sitemaps
278
+ * skipped, non-English locales de-prioritized, /products/ preferred; requires the
279
+ * path to hit the keywords so locale/blog false-positives are rejected.
280
+ */
281
+ export async function findCategoryPageInSitemap(
282
+ rootUrl: string,
283
+ keywords: string[],
284
+ fetchText: FetchText = defaultFetchText,
285
+ ): Promise<string | null> {
286
+ let root: URL;
287
+ try {
288
+ root = new URL(rootUrl);
289
+ } catch {
290
+ return null;
291
+ }
292
+ if (!keywords.length) return null;
293
+ const rootDom = registrableDomain(root.hostname);
294
+ const sameDomain = (u: string) => registrableDomain(hostOf(u) || "") === rootDom;
295
+ const pathScore = (u: string): number => {
296
+ let path: string;
297
+ try {
298
+ path = new URL(u).pathname.toLowerCase();
299
+ } catch {
300
+ return 0;
301
+ }
302
+ let s = 0;
303
+ for (const kw of keywords) if (path.includes(kw)) s += 1;
304
+ if (s > 0) {
305
+ if (/\/(product|solution)s?\//.test(path)) s += 0.5; // prefer product pages
306
+ const loc = path.match(/^\/([a-z]{2})(?:-[a-z]{2})?\//); // de-prioritize non-English locales
307
+ if (loc && !["en", "us"].includes(loc[1])) s -= 0.6;
308
+ }
309
+ return s;
310
+ };
311
+
312
+ const candidates = new Set([`${root.origin}/sitemap.xml`, `${root.origin}/sitemap_index.xml`]);
313
+ const robots = await fetchText(`${root.origin}/robots.txt`);
314
+ if (robots) {
315
+ for (const mm of robots.slice(0, 100_000).matchAll(/^\s*sitemap:\s*(\S+)/gim)) {
316
+ try {
317
+ candidates.add(new URL(mm[1].trim(), root.origin).toString());
318
+ } catch {
319
+ /* skip */
320
+ }
321
+ }
322
+ }
323
+
324
+ const queue = [...candidates];
325
+ const seen = new Set<string>();
326
+ let best: string | null = null;
327
+ let bestScore = 0;
328
+ let fetched = 0;
329
+ let scanned = 0;
330
+ while (queue.length && fetched < 6 && scanned < 20_000) {
331
+ const sm = queue.shift() as string;
332
+ if (seen.has(sm) || !sameDomain(sm)) continue;
333
+ seen.add(sm);
334
+ if (sm.endsWith(".gz")) continue; // skip compressed sitemaps
335
+ const xml = await fetchText(sm);
336
+ if (!xml) continue;
337
+ fetched++;
338
+ const body = xml.slice(0, 5_000_000);
339
+ const locs = [...body.matchAll(/<loc>\s*([^<\s]+?)\s*<\/loc>/gi)].map((mm) => mm[1].replace(/&amp;/g, "&"));
340
+ if (/<sitemapindex/i.test(body)) {
341
+ const ranked = locs
342
+ .filter(sameDomain)
343
+ .filter((l) => !/(pdf|video|image|img|news|siteimprove)/i.test(l))
344
+ .map((l): [number, string] => [pathScore(l) + (/product|solution/i.test(l) ? 1 : 0), l])
345
+ .sort((a, b) => b[0] - a[0]);
346
+ for (const [, l] of ranked.slice(0, 5)) queue.push(l);
347
+ } else {
348
+ for (const l of locs) {
349
+ scanned++;
350
+ if (!sameDomain(l)) continue;
351
+ const s = pathScore(l);
352
+ if (s > bestScore) {
353
+ bestScore = s;
354
+ best = l;
355
+ }
356
+ }
357
+ }
358
+ }
359
+ return bestScore >= Math.min(2, keywords.length) ? best : null;
360
+ }
361
+
362
+ /**
363
+ * Find a vendor's category-specific page: scan its (already-fetched) homepage nav,
364
+ * then fall back to the sitemap. Returns the page URL, or null to keep the homepage.
365
+ */
366
+ export async function findCategoryPage(
367
+ homepageHtml: string,
368
+ homepageUrl: string,
369
+ category: string,
370
+ fetchText: FetchText = defaultFetchText,
371
+ ): Promise<string | null> {
372
+ const keywords = categoryKeywords(category);
373
+ if (!keywords.length) return null;
374
+ const nav = pickCategoryPage(homepageHtml, homepageUrl, keywords);
375
+ if (nav) return nav;
376
+ return findCategoryPageInSitemap(homepageUrl, keywords, fetchText);
377
+ }
378
+
379
+ /**
380
+ * A vendor logo as a self-contained `data:` URI — the form `MarketVendor.logo`
381
+ * renders and the report serves under a strict `img-src data:` CSP. Prefers the
382
+ * page-declared logo (from the given homepage HTML), then a favicon service.
383
+ * Bounded to small raster/SVG (≤50KB).
384
+ */
385
+ export async function fetchLogoDataUri(
386
+ homepageUrl: string,
387
+ html?: string,
388
+ fetchBytes: FetchBytes = defaultFetchBytes,
389
+ ): Promise<string | null> {
390
+ const host = hostOf(homepageUrl);
391
+ if (!host) return null;
392
+ const candidates: string[] = [];
393
+ if (html) {
394
+ const fromPage = extractLogoUrl(html, homepageUrl);
395
+ if (fromPage) candidates.push(fromPage);
396
+ }
397
+ candidates.push(`https://www.google.com/s2/favicons?domain=${host}&sz=64`);
398
+ for (const url of candidates) {
399
+ const got = await fetchBytes(url);
400
+ if (!got) continue;
401
+ if (!got.contentType.startsWith("image/")) continue;
402
+ if (got.bytes.length === 0 || got.bytes.length > 50_000) continue;
403
+ return `data:${got.contentType};base64,${Buffer.from(got.bytes).toString("base64")}`;
404
+ }
405
+ return null;
406
+ }
407
+
408
+ // ── Competitor discovery ─────────────────────────────────────────────────────
409
+
410
+ /** A vendor proposed by `discoverCompetitors`. */
411
+ export type DiscoveredVendor = {
412
+ name: string;
413
+ /** Canonical homepage. */
414
+ url: string;
415
+ /** The page most specific to the category — the product page for multi-product
416
+ * companies, else the homepage. Use as the capture seed. */
417
+ productUrl: string;
418
+ };
419
+
420
+ export type DiscoverCompetitorsOptions = {
421
+ llm: LlmCallOptions;
422
+ /** The user's own company: competitors are listed for it and it's excluded. */
423
+ anchorUrl?: string;
424
+ /** Vendor hosts already in the set, to exclude from results. */
425
+ exclude?: string[];
426
+ };
427
+
428
+ const DISCOVERY_SCHEMA = {
429
+ type: "object",
430
+ required: ["competitors"],
431
+ properties: {
432
+ competitors: {
433
+ type: "array",
434
+ description: "Real vendors competing in this category today, each with its canonical https homepage URL.",
435
+ items: {
436
+ type: "object",
437
+ required: ["name", "url"],
438
+ properties: {
439
+ name: { type: "string" },
440
+ url: { type: "string", description: "Canonical homepage, https://domain, no tracking path." },
441
+ productUrl: {
442
+ type: "string",
443
+ description:
444
+ "URL of the page on THIS vendor's site most specific to the category. For a multi-product company (e.g. SAP, Oracle) this is the product/solution page for this category, NOT the corporate homepage. For a focused single-product vendor, repeat the homepage.",
445
+ },
446
+ },
447
+ },
448
+ },
449
+ },
450
+ };
451
+
452
+ /**
453
+ * Propose the real vendor set for a category via the LLM — so a cold-start map
454
+ * needs only a category, not a hand-built vendor list. Returns canonical homepages
455
+ * plus a category-specific `productUrl` per vendor; excludes the anchor + any
456
+ * supplied hosts, de-dupes by registrable domain, and instructs the model to skip
457
+ * acquired/defunct brands. BYOK via the package's `forcedToolCall`.
458
+ */
459
+ export async function discoverCompetitors(
460
+ category: string,
461
+ options: DiscoverCompetitorsOptions,
462
+ ): Promise<DiscoveredVendor[]> {
463
+ const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
464
+ const anchorHost = options.anchorUrl ? hostOf(options.anchorUrl) : null;
465
+ const anchorNote = anchorHost
466
+ ? `The user's own company is ${anchorHost} — list its direct competitors and do NOT include ${anchorHost} itself.`
467
+ : "";
468
+ const prompt = `List the most significant vendors competing in the category "${category}" today.
469
+ ${anchorNote}
470
+ Rules:
471
+ - Real companies only, each with its canonical https homepage URL (domain root, no tracking path).
472
+ - 7-9 vendors: a mix of established leaders and notable challengers.
473
+ - EXCLUDE vendors that have been acquired and folded into another brand, or are defunct — i.e. anything whose own site now redirects to a different company. (Name the current independent players instead.)
474
+ - For each, also give productUrl: the page most specific to "${category}". For a big multi-product company, that's its product/solution page for THIS category, not the corporate homepage.
475
+ - No duplicates.`;
476
+ const result = (await forcedToolCall(prompt, "discover_competitors", DISCOVERY_SCHEMA, model, options.llm)) as {
477
+ competitors?: Array<{ name?: string; url?: string; productUrl?: string }>;
478
+ };
479
+
480
+ const excluded = new Set((options.exclude ?? []).map(hostOf).filter((h): h is string => Boolean(h)));
481
+ if (anchorHost) excluded.add(anchorHost);
482
+ const seenDomain = new Set<string>();
483
+ const out: DiscoveredVendor[] = [];
484
+ for (const c of result?.competitors ?? []) {
485
+ let u: URL;
486
+ try {
487
+ u = new URL(String(c.url));
488
+ } catch {
489
+ continue;
490
+ }
491
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
492
+ const host = u.hostname.replace(/^www\./, "");
493
+ if (excluded.has(host)) continue;
494
+ const dom = registrableDomain(host);
495
+ if (seenDomain.has(dom)) continue;
496
+ seenDomain.add(dom);
497
+ let productUrl = u.toString();
498
+ if (c.productUrl) {
499
+ try {
500
+ const p = new URL(String(c.productUrl));
501
+ if (p.protocol === "http:" || p.protocol === "https:") productUrl = p.toString();
502
+ } catch {
503
+ /* keep homepage */
504
+ }
505
+ }
506
+ out.push({ name: c.name || host, url: u.toString(), productUrl });
507
+ }
508
+ return out;
509
+ }