sproutkit-code 1.7.0 → 1.8.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/dist/index.js CHANGED
@@ -229,6 +229,12 @@ var require_db = __commonJS({
229
229
  // WITHOUT the client's password. Means handing the client full ownership
230
230
  // doesn't cost the agency its reporting.
231
231
  "report_key TEXT",
232
+ // `sproutkit audit` findings: how bad the business's EXISTING site is, and
233
+ // the specific problems to lead outreach with. Distinct from lead_kind's
234
+ // absence-based gaps.
235
+ "site_score INTEGER",
236
+ "site_issues TEXT",
237
+ "audited_at TEXT",
232
238
  // Reply loop: when we last contacted them, and the latest outcome. Kept on
233
239
  // the lead (as well as contact_log) so `followups` is a single cheap query.
234
240
  "last_contacted_at TEXT",
@@ -82790,6 +82796,389 @@ var require_booking_detect = __commonJS({
82790
82796
  }
82791
82797
  });
82792
82798
 
82799
+ // ../desktop/dist/main/lib/site-audit.js
82800
+ var require_site_audit = __commonJS({
82801
+ "../desktop/dist/main/lib/site-audit.js"(exports2) {
82802
+ "use strict";
82803
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
82804
+ if (k2 === void 0) k2 = k;
82805
+ var desc = Object.getOwnPropertyDescriptor(m, k);
82806
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
82807
+ desc = { enumerable: true, get: function() {
82808
+ return m[k];
82809
+ } };
82810
+ }
82811
+ Object.defineProperty(o, k2, desc);
82812
+ }) : (function(o, m, k, k2) {
82813
+ if (k2 === void 0) k2 = k;
82814
+ o[k2] = m[k];
82815
+ }));
82816
+ var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
82817
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
82818
+ }) : function(o, v) {
82819
+ o["default"] = v;
82820
+ });
82821
+ var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
82822
+ var ownKeys = function(o) {
82823
+ ownKeys = Object.getOwnPropertyNames || function(o2) {
82824
+ var ar = [];
82825
+ for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
82826
+ return ar;
82827
+ };
82828
+ return ownKeys(o);
82829
+ };
82830
+ return function(mod) {
82831
+ if (mod && mod.__esModule) return mod;
82832
+ var result = {};
82833
+ if (mod != null) {
82834
+ for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
82835
+ }
82836
+ __setModuleDefault(result, mod);
82837
+ return result;
82838
+ };
82839
+ })();
82840
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
82841
+ return mod && mod.__esModule ? mod : { "default": mod };
82842
+ };
82843
+ Object.defineProperty(exports2, "__esModule", { value: true });
82844
+ exports2.auditSite = auditSite;
82845
+ exports2.isRebuildProspect = isRebuildProspect;
82846
+ exports2.headlineIssues = headlineIssues;
82847
+ var axios_1 = __importDefault(require_axios());
82848
+ var tls = __importStar(require("tls"));
82849
+ var url_1 = require("url");
82850
+ var WEIGHT = {
82851
+ critical: 30,
82852
+ major: 15,
82853
+ minor: 6
82854
+ };
82855
+ async function checkTls(hostname) {
82856
+ return new Promise((resolve) => {
82857
+ let settled = false;
82858
+ const done = (r) => {
82859
+ if (settled)
82860
+ return;
82861
+ settled = true;
82862
+ socket.destroy();
82863
+ resolve(r);
82864
+ };
82865
+ const socket = tls.connect({ host: hostname, port: 443, servername: hostname, timeout: 12e3 }, () => {
82866
+ if (!socket.authorized) {
82867
+ done({
82868
+ ok: false,
82869
+ issue: {
82870
+ code: "tls_invalid",
82871
+ label: "The security certificate isn\u2019t valid \u2014 browsers warn visitors before they can see the site",
82872
+ severity: "critical",
82873
+ detail: socket.authorizationError ? String(socket.authorizationError) : void 0
82874
+ }
82875
+ });
82876
+ return;
82877
+ }
82878
+ const cert = socket.getPeerCertificate();
82879
+ if (!cert || !cert.valid_to) {
82880
+ done({ ok: true });
82881
+ return;
82882
+ }
82883
+ const days = Math.round((new Date(cert.valid_to).getTime() - Date.now()) / 864e5);
82884
+ if (days < 0) {
82885
+ done({
82886
+ ok: false,
82887
+ daysToExpiry: days,
82888
+ issue: {
82889
+ code: "tls_expired",
82890
+ label: "The security certificate has expired \u2014 visitors see a full-page warning",
82891
+ severity: "critical",
82892
+ detail: `expired ${Math.abs(days)} days ago`
82893
+ }
82894
+ });
82895
+ return;
82896
+ }
82897
+ if (days <= 14) {
82898
+ done({
82899
+ ok: true,
82900
+ daysToExpiry: days,
82901
+ issue: {
82902
+ code: "tls_expiring",
82903
+ label: "The security certificate expires within two weeks",
82904
+ severity: "major",
82905
+ detail: `${days} days left`
82906
+ }
82907
+ });
82908
+ return;
82909
+ }
82910
+ done({ ok: true, daysToExpiry: days });
82911
+ });
82912
+ socket.on("error", (err) => {
82913
+ const msg = err.message || "";
82914
+ const code = err.code ?? "";
82915
+ let issue;
82916
+ if (/certificate has expired|CERT_HAS_EXPIRED/i.test(msg + code)) {
82917
+ issue = {
82918
+ code: "tls_expired",
82919
+ label: "The security certificate has expired \u2014 visitors see a full-page browser warning before they reach the site",
82920
+ severity: "critical",
82921
+ detail: msg
82922
+ };
82923
+ } else if (/self.signed|SELF_SIGNED/i.test(msg + code)) {
82924
+ issue = {
82925
+ code: "tls_self_signed",
82926
+ label: "The security certificate isn\u2019t from a recognised authority, so browsers warn visitors away",
82927
+ severity: "critical",
82928
+ detail: msg
82929
+ };
82930
+ } else if (/altnames|hostname\/ip does not match|ERR_TLS_CERT_ALTNAME/i.test(msg + code)) {
82931
+ issue = {
82932
+ code: "tls_wrong_host",
82933
+ label: "The security certificate was issued for a different domain, so browsers reject it",
82934
+ severity: "critical",
82935
+ detail: msg
82936
+ };
82937
+ } else if (/ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ECONNRESET/i.test(msg + code)) {
82938
+ issue = {
82939
+ code: "tls_unavailable",
82940
+ label: "The site has no working HTTPS \u2014 Chrome labels it \u201CNot secure\u201D to every visitor",
82941
+ severity: "critical",
82942
+ detail: msg
82943
+ };
82944
+ } else {
82945
+ issue = {
82946
+ code: "tls_error",
82947
+ label: "Secure connections to the site fail",
82948
+ severity: "major",
82949
+ detail: msg
82950
+ };
82951
+ }
82952
+ done({ ok: false, issue });
82953
+ });
82954
+ socket.on("timeout", () => {
82955
+ done({
82956
+ ok: false,
82957
+ issue: {
82958
+ code: "tls_timeout",
82959
+ label: "Secure connections to the site time out",
82960
+ severity: "major"
82961
+ }
82962
+ });
82963
+ });
82964
+ });
82965
+ }
82966
+ function markupIssues(html, finalUrl) {
82967
+ const issues = [];
82968
+ const head = html.slice(0, 6e4);
82969
+ if (!/<meta[^>]+name=["']?viewport/i.test(head)) {
82970
+ issues.push({
82971
+ code: "not_mobile",
82972
+ label: "Not built for phones \u2014 the page loads zoomed out and has to be pinched to read",
82973
+ severity: "critical"
82974
+ });
82975
+ }
82976
+ const tableCount = (html.match(/<table/gi) ?? []).length;
82977
+ if (tableCount >= 3 || /<table[^>]*>[\s\S]{0,400}<table/i.test(html)) {
82978
+ issues.push({
82979
+ code: "table_layout",
82980
+ label: "Built with table layouts, a technique abandoned around 2010",
82981
+ severity: "major",
82982
+ detail: `${tableCount} tables`
82983
+ });
82984
+ }
82985
+ if (/<(font|center|marquee|blink)\b/i.test(html)) {
82986
+ const tag = html.match(/<(font|center|marquee|blink)\b/i)?.[1]?.toLowerCase();
82987
+ issues.push({
82988
+ code: "obsolete_tags",
82989
+ label: `Uses HTML tags (<${tag}>) that have been removed from the web standard`,
82990
+ severity: "major"
82991
+ });
82992
+ }
82993
+ if (/\.swf\b|application\/x-shockwave-flash|<embed[^>]+flash/i.test(html)) {
82994
+ issues.push({
82995
+ code: "flash",
82996
+ label: "Contains Flash content, which no browser has supported since 2020 \u2014 that part of the site is invisible",
82997
+ severity: "critical"
82998
+ });
82999
+ }
83000
+ const jq = html.match(/jquery[.-]?(\d+)\.(\d+)(?:\.(\d+))?(?:\.min)?\.js/i);
83001
+ if (jq) {
83002
+ const major = Number(jq[1]);
83003
+ if (major < 3) {
83004
+ issues.push({
83005
+ code: "old_jquery",
83006
+ label: "Runs a jQuery version with publicly known security vulnerabilities",
83007
+ severity: "major",
83008
+ detail: `jQuery ${jq[1]}.${jq[2]}${jq[3] ? "." + jq[3] : ""}`
83009
+ });
83010
+ }
83011
+ }
83012
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
83013
+ const years = [...html.matchAll(/(?:©|&copy;|copyright)\s*(?:\d{4}\s*[-–]\s*)?(\d{4})/gi)].map((m) => Number(m[1])).filter((y) => y >= 1995 && y <= year);
83014
+ if (years.length) {
83015
+ const newest = Math.max(...years);
83016
+ const age = year - newest;
83017
+ if (age >= 2) {
83018
+ issues.push({
83019
+ code: "stale_copyright",
83020
+ label: `The footer still says ${newest} \u2014 visitors read that as \u201Cpossibly closed down\u201D`,
83021
+ severity: age >= 5 ? "major" : "minor",
83022
+ detail: `${age} years out of date`
83023
+ });
83024
+ }
83025
+ }
83026
+ if (/under construction|coming soon|website is being (?:built|updated)/i.test(head)) {
83027
+ issues.push({
83028
+ code: "under_construction",
83029
+ label: "Still shows an \u201Cunder construction\u201D placeholder",
83030
+ severity: "critical"
83031
+ });
83032
+ }
83033
+ if (/this domain (?:is|may be) for sale|parked (?:free )?(?:at|by)|godaddy\.com\/domainfind|default web page/i.test(head)) {
83034
+ issues.push({
83035
+ code: "parked",
83036
+ label: "The domain shows a parking page rather than a real website",
83037
+ severity: "critical"
83038
+ });
83039
+ }
83040
+ if (finalUrl.startsWith("https://")) {
83041
+ const insecure = html.match(/(?:src|href)=["']http:\/\/(?!localhost)/gi);
83042
+ if (insecure && insecure.length >= 2) {
83043
+ issues.push({
83044
+ code: "mixed_content",
83045
+ label: "Loads images or scripts insecurely, so browsers block them and parts of the page break",
83046
+ severity: "major",
83047
+ detail: `${insecure.length} insecure references`
83048
+ });
83049
+ }
83050
+ }
83051
+ const title = html.match(/<title[^>]*>([\s\S]{0,200}?)<\/title>/i)?.[1]?.trim();
83052
+ if (!title) {
83053
+ issues.push({
83054
+ code: "no_title",
83055
+ label: "The page has no title, so search results show a bare web address",
83056
+ severity: "major"
83057
+ });
83058
+ } else if (/^(home|untitled|new page \d*|document|index|website)$/i.test(title)) {
83059
+ issues.push({
83060
+ code: "default_title",
83061
+ label: `The page title is still the template default (\u201C${title}\u201D), which is what Google shows`,
83062
+ severity: "major"
83063
+ });
83064
+ }
83065
+ if (!/<meta[^>]+name=["']?description/i.test(head)) {
83066
+ issues.push({
83067
+ code: "no_description",
83068
+ label: "No search description, so Google invents one from whatever text it finds",
83069
+ severity: "minor"
83070
+ });
83071
+ }
83072
+ if (/<frameset|<frame\b/i.test(html)) {
83073
+ issues.push({
83074
+ code: "frames",
83075
+ label: "Built with frames, which search engines cannot index properly",
83076
+ severity: "critical"
83077
+ });
83078
+ }
83079
+ return issues;
83080
+ }
83081
+ async function auditSite(websiteUrl) {
83082
+ const raw = (websiteUrl ?? "").trim();
83083
+ if (!raw)
83084
+ return { url: raw, reachable: false, score: 0, issues: [], error: "No URL" };
83085
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
83086
+ let hostname;
83087
+ try {
83088
+ hostname = new url_1.URL(withScheme).hostname;
83089
+ } catch {
83090
+ return { url: raw, reachable: false, score: 0, issues: [], error: "Unparseable URL" };
83091
+ }
83092
+ const issues = [];
83093
+ const listedAsHttp = /^http:\/\//i.test(raw);
83094
+ const [tlsResult, page] = await Promise.all([
83095
+ checkTls(hostname).catch(() => ({ ok: true })),
83096
+ fetchPage(withScheme)
83097
+ ]);
83098
+ if (tlsResult.issue)
83099
+ issues.push(tlsResult.issue);
83100
+ if (listedAsHttp && !tlsResult.issue) {
83101
+ issues.push({
83102
+ code: "listed_insecure",
83103
+ label: "The address the business gives out starts with http:// \u2014 visitors following it get a \u201CNot secure\u201D warning",
83104
+ severity: "major"
83105
+ });
83106
+ }
83107
+ if (!page.ok) {
83108
+ return {
83109
+ url: raw,
83110
+ reachable: false,
83111
+ score: scoreOf(issues),
83112
+ issues,
83113
+ error: page.error
83114
+ };
83115
+ }
83116
+ if (page.finalUrl.startsWith("http://") && !tlsResult.issue) {
83117
+ issues.push({
83118
+ code: "no_https_redirect",
83119
+ label: "The site falls back to an unencrypted connection instead of staying secure",
83120
+ severity: "major"
83121
+ });
83122
+ }
83123
+ issues.push(...markupIssues(page.html, page.finalUrl));
83124
+ const htmlKb = Math.round(page.html.length / 1024);
83125
+ if (htmlKb > 600) {
83126
+ issues.push({
83127
+ code: "heavy_page",
83128
+ label: "The page is unusually heavy, so it loads slowly on mobile data",
83129
+ severity: "minor",
83130
+ detail: `${htmlKb} KB of HTML`
83131
+ });
83132
+ }
83133
+ return { url: raw, reachable: true, score: scoreOf(issues), issues, htmlKb };
83134
+ }
83135
+ async function fetchPage(url) {
83136
+ try {
83137
+ const res = await axios_1.default.get(url, {
83138
+ timeout: 2e4,
83139
+ maxRedirects: 5,
83140
+ responseType: "text",
83141
+ // Some hosts serve a stripped page to a bare client.
83142
+ headers: {
83143
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
83144
+ Accept: "text/html,application/xhtml+xml"
83145
+ },
83146
+ maxContentLength: 5e6,
83147
+ validateStatus: () => true,
83148
+ // We want to inspect a broken-cert site's markup too; the cert problem is
83149
+ // reported separately by checkTls, so this doesn't hide anything.
83150
+ httpsAgent: void 0
83151
+ });
83152
+ if (res.status >= 400) {
83153
+ return { ok: false, error: `HTTP ${res.status}` };
83154
+ }
83155
+ const html = typeof res.data === "string" ? res.data : String(res.data ?? "");
83156
+ if (!html)
83157
+ return { ok: false, error: "Empty response" };
83158
+ const finalUrl = res.request?.res?.responseUrl || url;
83159
+ return { ok: true, html, finalUrl };
83160
+ } catch (e) {
83161
+ return { ok: false, error: e.message };
83162
+ }
83163
+ }
83164
+ function scoreOf(issues) {
83165
+ const total = issues.reduce((sum, i) => sum + WEIGHT[i.severity], 0);
83166
+ return Math.min(100, total);
83167
+ }
83168
+ function isRebuildProspect(audit) {
83169
+ if (!audit.issues.length)
83170
+ return false;
83171
+ const critical = audit.issues.filter((i) => i.severity === "critical").length;
83172
+ const major = audit.issues.filter((i) => i.severity === "major").length;
83173
+ return critical >= 1 || major >= 2;
83174
+ }
83175
+ function headlineIssues(audit, limit = 3) {
83176
+ const rank = { critical: 0, major: 1, minor: 2 };
83177
+ return [...audit.issues].sort((a, b) => rank[a.severity] - rank[b.severity]).slice(0, limit);
83178
+ }
83179
+ }
83180
+ });
83181
+
82793
83182
  // ../desktop/dist/main/lib/lead-search.js
82794
83183
  var require_lead_search = __commonJS({
82795
83184
  "../desktop/dist/main/lib/lead-search.js"(exports2) {
@@ -82800,11 +83189,14 @@ var require_lead_search = __commonJS({
82800
83189
  var db_1 = require_db();
82801
83190
  var places_1 = require_places();
82802
83191
  var booking_detect_1 = require_booking_detect();
83192
+ var site_audit_1 = require_site_audit();
82803
83193
  async function searchAndSaveLeadsStream(args, onLead) {
82804
83194
  const db2 = (0, db_1.getDb)();
82805
83195
  const cap = args.maxResults ?? 50;
82806
83196
  const seen = /* @__PURE__ */ new Set();
82807
83197
  const bookingMode = args.mode === "no_booking";
83198
+ const auditMode = args.mode === "outdated";
83199
+ const wantsSiteHavers = bookingMode || auditMode;
82808
83200
  let saved = 0;
82809
83201
  let found = 0;
82810
83202
  await (0, places_1.searchPlacesStream)({
@@ -82813,8 +83205,8 @@ var require_lead_search = __commonJS({
82813
83205
  types: args.types,
82814
83206
  center: args.center,
82815
83207
  radiusMeters: args.radiusMeters,
82816
- // Booking mode needs the businesses that DO have a website.
82817
- includeWithWebsite: bookingMode
83208
+ // Booking and audit modes need the businesses that DO have a website.
83209
+ includeWithWebsite: wantsSiteHavers
82818
83210
  }, async (batch) => {
82819
83211
  for (const r of batch) {
82820
83212
  found++;
@@ -82829,7 +83221,19 @@ var require_lead_search = __commonJS({
82829
83221
  let leadKind = "no_website";
82830
83222
  let bookingStatus = null;
82831
83223
  let bookingPlatform = null;
82832
- if (bookingMode) {
83224
+ let siteScore = null;
83225
+ let siteIssues = null;
83226
+ if (auditMode) {
83227
+ if (!r.website)
83228
+ continue;
83229
+ args.onProgress?.(`Auditing ${r.name}'s website\u2026`);
83230
+ const audit = await (0, site_audit_1.auditSite)(r.website);
83231
+ if (!(0, site_audit_1.isRebuildProspect)(audit))
83232
+ continue;
83233
+ siteScore = audit.score;
83234
+ siteIssues = JSON.stringify(audit.issues);
83235
+ leadKind = "outdated";
83236
+ } else if (bookingMode) {
82833
83237
  if (!r.website)
82834
83238
  continue;
82835
83239
  args.onProgress?.(`Checking ${r.name}'s booking setup\u2026`);
@@ -82843,8 +83247,8 @@ var require_lead_search = __commonJS({
82843
83247
  continue;
82844
83248
  }
82845
83249
  db2.run(`INSERT INTO leads
82846
- (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform)
82847
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)`, [
83250
+ (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform, site_score, site_issues, audited_at)
83251
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?, ?, ?, ?)`, [
82848
83252
  r.place_id,
82849
83253
  r.name,
82850
83254
  r.type,
@@ -82861,7 +83265,10 @@ var require_lead_search = __commonJS({
82861
83265
  r.business_status,
82862
83266
  leadKind,
82863
83267
  bookingStatus,
82864
- bookingPlatform
83268
+ bookingPlatform,
83269
+ siteScore,
83270
+ siteIssues,
83271
+ siteScore === null ? null : (/* @__PURE__ */ new Date()).toISOString()
82865
83272
  ]);
82866
83273
  const row = db2.get("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
82867
83274
  saved++;
@@ -95258,6 +95665,7 @@ var require_chat_tools = __commonJS({
95258
95665
  var lead_search_1 = require_lead_search();
95259
95666
  var places_1 = require_places();
95260
95667
  var booking_detect_1 = require_booking_detect();
95668
+ var site_audit_1 = require_site_audit();
95261
95669
  var followup_1 = require_followup();
95262
95670
  var invoicing_1 = require_invoicing();
95263
95671
  var agency_1 = require_agency();
@@ -95449,6 +95857,44 @@ var require_chat_tools = __commonJS({
95449
95857
  required: ["id"]
95450
95858
  }
95451
95859
  },
95860
+ {
95861
+ name: "audit_sites",
95862
+ description: "AUDIT existing websites in an area and save the bad ones as rebuild prospects. Unlike find_leads (which looks for businesses with NO website), this finds businesses that DO have a website and checks it for real, nameable faults: expired or missing HTTPS certificates, no mobile support, table layouts, Flash, obsolete jQuery, parked or under-construction pages, stale copyright years. Each prospect is scored (higher = worse) and its specific problems are stored so outreach can cite them. Use this when the user wants to find outdated, insecure or badly built websites to pitch a rebuild against. Costs the same Places lookup as find_leads plus one fetch per site.",
95863
+ input_schema: {
95864
+ type: "object",
95865
+ properties: {
95866
+ location: {
95867
+ type: "string",
95868
+ description: "Town, postcode, area or address to search, e.g. 'Leeds LS1' or 'Shoreditch, London'."
95869
+ },
95870
+ categories: {
95871
+ type: "array",
95872
+ items: { type: "string" },
95873
+ description: "Business types to search, e.g. ['barber shop','cafe','plumber']."
95874
+ },
95875
+ radiusMeters: {
95876
+ type: "number",
95877
+ description: "Search radius in metres. Defaults to 10000."
95878
+ },
95879
+ limit: {
95880
+ type: "number",
95881
+ description: "Maximum prospects to save. Defaults to 25."
95882
+ }
95883
+ },
95884
+ required: ["location", "categories"]
95885
+ }
95886
+ },
95887
+ {
95888
+ name: "audit_one_site",
95889
+ description: "Audit a SINGLE website by URL and report its faults, without saving anything. Use this when the user names a specific site and asks what's wrong with it, or to check a business's site before pitching them.",
95890
+ input_schema: {
95891
+ type: "object",
95892
+ properties: {
95893
+ url: { type: "string", description: "The website to audit, e.g. 'thebarbers.co.uk'." },
95894
+ id: { type: "number", description: "Or a lead id \u2014 audits that lead's website and stores the result." }
95895
+ }
95896
+ }
95897
+ },
95452
95898
  {
95453
95899
  name: "hand_over_site",
95454
95900
  description: "HAND OVER a finished site to the paying client: removes the DEMO/preview banner, optionally attaches the client's own domain, and returns every credential in one place (site URL, /admin panel + password, booking page + its password, and the DNS records the client must set at their registrar). This is the paid-deliverable step \u2014 the banner exists to stop clients using a free demo as their real site, so only run this when the user says the client has paid / signed off / is going live. Requires the lead to already have a built site or booking page.",
@@ -96241,6 +96687,77 @@ Interested?`;
96241
96687
  return { ok: false, error: stats.error, business: lead.name };
96242
96688
  return { ok: true, id, business: lead.name, url: base, ...stats.data };
96243
96689
  }
96690
+ case "audit_sites": {
96691
+ const location = String(input.location ?? "").trim();
96692
+ const categories = input.categories ?? [];
96693
+ if (!location || categories.length === 0) {
96694
+ return { ok: false, error: "Give me a location and at least one business category." };
96695
+ }
96696
+ const placesKey = getDecryptedSetting("places_key");
96697
+ if (!placesKey)
96698
+ throw new Error("Google Places API key not set in Settings");
96699
+ ctx.emitProgress?.({
96700
+ channel: "leads:searching",
96701
+ payload: { msg: `Auditing websites in ${location}\u2026` }
96702
+ });
96703
+ const matches = await (0, places_1.geocodeForward)(location, 1);
96704
+ const place = matches[0];
96705
+ const result = await (0, lead_search_1.searchAndSaveLeadsStream)({
96706
+ placesKey,
96707
+ city: location,
96708
+ types: categories,
96709
+ maxResults: Math.min(Number(input.limit ?? 25), 100),
96710
+ center: place ? { lat: place.lat, lng: place.lng } : void 0,
96711
+ radiusMeters: Number(input.radiusMeters ?? 1e4),
96712
+ mode: "outdated"
96713
+ }, (lead) => ctx.emitProgress?.({ channel: "leads:found", payload: { lead } }));
96714
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "audit_sites" } });
96715
+ const saved = db2.all(`SELECT id, name, website, site_score, site_issues
96716
+ FROM leads
96717
+ WHERE lead_kind = 'outdated' AND audited_at IS NOT NULL
96718
+ ORDER BY audited_at DESC, site_score DESC
96719
+ LIMIT ?`, [Number(input.limit ?? 25)]);
96720
+ return {
96721
+ ok: true,
96722
+ found: result.found,
96723
+ saved: result.saved,
96724
+ prospects: saved.map((r) => ({
96725
+ id: r.id,
96726
+ business: r.name,
96727
+ website: r.website,
96728
+ score: r.site_score,
96729
+ issues: JSON.parse(r.site_issues || "[]").slice(0, 3).map((i) => `${i.severity}: ${i.label}`)
96730
+ }))
96731
+ };
96732
+ }
96733
+ case "audit_one_site": {
96734
+ let url = input.url ?? "";
96735
+ let leadId = null;
96736
+ if (input.id != null) {
96737
+ leadId = Number(input.id);
96738
+ const lead = db2.get("SELECT id, name, website FROM leads WHERE id = ?", [leadId]);
96739
+ if (!lead)
96740
+ throw new Error(`Lead #${leadId} not found`);
96741
+ if (!lead.website)
96742
+ return { ok: false, error: `#${leadId} ${lead.name} has no website on record.` };
96743
+ url = lead.website;
96744
+ }
96745
+ if (!url)
96746
+ return { ok: false, error: "Give me a website URL or a lead id." };
96747
+ const audit = await (0, site_audit_1.auditSite)(url);
96748
+ if (leadId !== null) {
96749
+ db2.run("UPDATE leads SET site_score = ?, site_issues = ?, audited_at = ? WHERE id = ?", [audit.score, JSON.stringify(audit.issues), (/* @__PURE__ */ new Date()).toISOString(), leadId]);
96750
+ }
96751
+ return {
96752
+ ok: true,
96753
+ url: audit.url,
96754
+ reachable: audit.reachable,
96755
+ score: audit.score,
96756
+ worthRebuilding: (0, site_audit_1.isRebuildProspect)(audit),
96757
+ issues: audit.issues,
96758
+ error: audit.error
96759
+ };
96760
+ }
96244
96761
  case "hand_over_site": {
96245
96762
  const id = Number(input.id);
96246
96763
  const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
@@ -97274,7 +97791,7 @@ var require_package2 = __commonJS({
97274
97791
  "package.json"(exports2, module2) {
97275
97792
  module2.exports = {
97276
97793
  name: "sproutkit-code",
97277
- version: "1.7.0",
97794
+ version: "1.8.0",
97278
97795
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
97279
97796
  bin: {
97280
97797
  sproutkit: "./dist/index.js"
@@ -99146,6 +99663,135 @@ async function cmdReport(argv) {
99146
99663
  process.exitCode = 1;
99147
99664
  }
99148
99665
  }
99666
+ async function cmdAudit(argv) {
99667
+ const { flags, rest } = parseArgs(argv);
99668
+ const json = !!flags.json;
99669
+ const SEV_COLOUR = {
99670
+ critical: c.red,
99671
+ major: c.amber,
99672
+ minor: c.dim
99673
+ };
99674
+ const asId = Number(flags.id ?? rest[0]);
99675
+ const singleUrl = flags.url ? String(flags.url) : void 0;
99676
+ const looksLikeUrl = singleUrl || rest[0] && /\./.test(String(rest[0])) && !flags.categories;
99677
+ if (singleUrl || !Number.isNaN(asId) && rest.length === 1 && !flags.categories || looksLikeUrl) {
99678
+ const status2 = json ? void 0 : new Status();
99679
+ const target = singleUrl ?? (Number.isNaN(asId) ? String(rest[0]) : `lead #${asId}`);
99680
+ if (status2) status2.start(`Auditing ${target}`);
99681
+ try {
99682
+ const r = await runTool(
99683
+ "audit_one_site",
99684
+ Number.isNaN(asId) || singleUrl ? { url: singleUrl ?? String(rest[0]) } : { id: asId },
99685
+ makeCtx({ json, status: status2 })
99686
+ );
99687
+ if (r && r.ok === false) {
99688
+ if (status2) status2.done(false, r.error ?? "Couldn't audit it");
99689
+ if (json) emitJson(r);
99690
+ process.exitCode = 1;
99691
+ return;
99692
+ }
99693
+ if (status2) {
99694
+ status2.done(
99695
+ true,
99696
+ r.issues && r.issues.length ? `${r.issues.length} issue${r.issues.length === 1 ? "" : "s"} \xB7 score ${r.score}` : "No problems found"
99697
+ );
99698
+ }
99699
+ if (json) {
99700
+ emitJson(r);
99701
+ return;
99702
+ }
99703
+ say();
99704
+ say(` ${c.bold(String(r.url))}` + c.dim(` \xB7 score ${r.score}/100`));
99705
+ if (!r.issues?.length) {
99706
+ say(c.dim(" Nothing worth pitching against \u2014 this site is in decent shape."));
99707
+ say();
99708
+ return;
99709
+ }
99710
+ say();
99711
+ for (const i of r.issues) {
99712
+ const tint = SEV_COLOUR[i.severity] ?? c.dim;
99713
+ say(` ${tint("\u25CF")} ${i.label}${i.detail ? c.dim(` (${i.detail})`) : ""}`);
99714
+ }
99715
+ say();
99716
+ say(
99717
+ r.worthRebuilding ? c.green(" Worth approaching \u2014 these are concrete problems you can name.") : c.dim(" Minor only. Probably not worth a rebuild pitch.")
99718
+ );
99719
+ say();
99720
+ } catch (e) {
99721
+ if (status2) status2.done(false, "Audit failed");
99722
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
99723
+ process.exitCode = 1;
99724
+ }
99725
+ return;
99726
+ }
99727
+ const location = rest.join(" ").trim() || (flags.location ? String(flags.location) : "");
99728
+ if (!location) {
99729
+ const err = 'Usage: sproutkit audit "<area>" --categories "barber shop,cafe"\n single site: sproutkit audit <leadId> \xB7 sproutkit audit --url thebarbers.co.uk';
99730
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
99731
+ process.exitCode = 1;
99732
+ return;
99733
+ }
99734
+ const categories = flags.categories ? String(flags.categories).split(",").map((t) => t.trim()).filter(Boolean) : [];
99735
+ if (categories.length === 0) {
99736
+ const err = 'Which trades? e.g. --categories "barber shop,cafe,plumber"';
99737
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
99738
+ process.exitCode = 1;
99739
+ return;
99740
+ }
99741
+ const status = json ? void 0 : new Status();
99742
+ if (status) status.start(`Auditing websites in ${location}`);
99743
+ try {
99744
+ const r = await runTool(
99745
+ "audit_sites",
99746
+ {
99747
+ location,
99748
+ categories,
99749
+ radiusMeters: flags.radius ? Number(flags.radius) : void 0,
99750
+ limit: flags.limit ? Number(flags.limit) : void 0
99751
+ },
99752
+ makeCtx({ json, status })
99753
+ );
99754
+ if (r && r.ok === false) {
99755
+ if (status) status.done(false, r.error ?? "Audit failed");
99756
+ if (json) emitJson(r);
99757
+ process.exitCode = 1;
99758
+ return;
99759
+ }
99760
+ if (status) status.done(true, `${r.saved} rebuild prospect${r.saved === 1 ? "" : "s"} from ${r.found} checked`);
99761
+ autoSync();
99762
+ if (json) {
99763
+ emitJson(r);
99764
+ return;
99765
+ }
99766
+ const list = r.prospects ?? [];
99767
+ if (!list.length) {
99768
+ say();
99769
+ say(c.dim(` Checked ${r.found} businesses \u2014 none had a site bad enough to pitch against.`));
99770
+ say(c.dim(" That's a good sign for the area; try a different trade or a wider radius."));
99771
+ say();
99772
+ return;
99773
+ }
99774
+ say();
99775
+ say(c.dim(" Worst first \u2014 these are the easiest conversations to start."));
99776
+ say();
99777
+ for (const p of list) {
99778
+ say(` ${c.greenBold("#" + p.id)} ${c.bold(p.business)} ${c.dim(p.website.replace(/^https?:\/\//, ""))}`);
99779
+ say(` ${c.amber("score " + p.score + "/100")}`);
99780
+ for (const issue of p.issues) {
99781
+ const [sev, ...restText] = issue.split(": ");
99782
+ const tint = SEV_COLOUR[sev] ?? c.dim;
99783
+ say(` ${tint("\u25CF")} ${restText.join(": ")}`);
99784
+ }
99785
+ say();
99786
+ }
99787
+ say(c.dim(" Build one: sproutkit build <id> \xB7 see everything: sproutkit audit <id>"));
99788
+ say();
99789
+ } catch (e) {
99790
+ if (status) status.done(false, "Audit failed");
99791
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
99792
+ process.exitCode = 1;
99793
+ }
99794
+ }
99149
99795
  async function cmdRegenerate(argv) {
99150
99796
  const { flags, rest } = parseArgs(argv);
99151
99797
  const json = !!flags.json;
@@ -99408,6 +100054,8 @@ When a request is missing information the command needs, ask a SHORT, STRUCTURED
99408
100054
  COMMANDS:
99409
100055
  - sproutkit find "<area>" --categories "<type>,<type>" --json \u2014 find website-less leads
99410
100056
  - sproutkit find "<area>" --categories "\u2026" --booking --json \u2014 find businesses that HAVE a site but take bookings via WhatsApp/phone only (booking-page prospects)
100057
+ - sproutkit audit "<area>" --categories "\u2026" --json \u2014 find businesses whose EXISTING site is insecure/outdated
100058
+ - sproutkit audit <id> | --url <site> --json \u2014 audit one site, list its faults
99411
100059
  - sproutkit list [--status <s>] [--city <c>] [--limit 100] \u2014 formatted table; SHOW IT VERBATIM
99412
100060
  - sproutkit list --json \u2014 same data as JSON, for reading ids only
99413
100061
  - sproutkit build <id> --json [--instructions "..."] [--cms] [--multipage] \u2014 build a demo site (--multipage = real Home/Services/About/Contact pages, not one scroll)
@@ -99607,7 +100255,7 @@ async function main() {
99607
100255
  closeTty();
99608
100256
  return;
99609
100257
  }
99610
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
100258
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "audit" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
99611
100259
  const subArgs = process.argv.slice(3);
99612
100260
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
99613
100261
  const USAGE = {
@@ -99647,6 +100295,16 @@ async function main() {
99647
100295
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
99648
100296
  " --email <addr> owner email for booking notifications"
99649
100297
  ],
100298
+ audit: [
100299
+ 'sproutkit audit "<area>" --categories "barber shop,cafe"',
100300
+ " finds businesses that HAVE a website but whose site is costing them",
100301
+ " work \u2014 no HTTPS, unusable on phones, built in 2008 \u2014 and scores it",
100302
+ " --radius <m> search radius (default 10000)",
100303
+ " --limit <n> max prospects to save (default 25)",
100304
+ "",
100305
+ " one site: sproutkit audit <leadId>",
100306
+ " sproutkit audit --url thebarbers.co.uk"
100307
+ ],
99650
100308
  brand: [
99651
100309
  'sproutkit brand --name "Your Studio" --email you@x.com [--vat-rate 20]',
99652
100310
  " your agency identity \u2014 goes on invoices, letters, the site footer",
@@ -99724,6 +100382,7 @@ async function main() {
99724
100382
  else if (arg === "cms") await cmdCms(subArgs);
99725
100383
  else if (arg === "book") await cmdBook(subArgs);
99726
100384
  else if (arg === "handover") await cmdHandover(subArgs);
100385
+ else if (arg === "audit") await cmdAudit(subArgs);
99727
100386
  else if (arg === "brand") await cmdBrand(subArgs);
99728
100387
  else if (arg === "invoice") await cmdInvoice(subArgs);
99729
100388
  else if (arg === "letter") await cmdLetter(subArgs);
@@ -93,6 +93,17 @@ you can parse results reliably, then summarise them for the user in prose.
93
93
  - The banner exists to stop clients using a free demo as their real site, so
94
94
  do NOT run this just because a site looks finished.
95
95
 
96
+ - **Audit existing websites** — `sproutkit audit "<area>" --categories "barber shop,cafe" --json`
97
+ - The counterpart to `find`. Where `find` looks for the ABSENCE of a website,
98
+ `audit` looks at sites that DO exist and finds the ones costing their owner
99
+ work: expired or missing HTTPS, no mobile support, table layouts, Flash,
100
+ obsolete jQuery, parked/under-construction pages, stale copyright years.
101
+ - Each prospect is scored (higher = worse) and its specific faults are stored,
102
+ so outreach can name real problems rather than say "your site looks dated".
103
+ - Single site: `sproutkit audit <leadId>` or `sproutkit audit --url thebarbers.co.uk`.
104
+ - Sites with only minor issues are deliberately skipped — pitching a rebuild
105
+ over a missing meta description makes the agency look desperate.
106
+
96
107
  - **Set up the agency's identity** — `sproutkit brand --show`
97
108
  - Their name, logo, address, VAT and bank details. This is what appears on
98
109
  invoices and letters, in the site footer credit, on the demo banner and in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"