@cutting/performance-scout 0.1.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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "allow": ["Bash(lighthouse:*)", "WebSearch"]
4
+ }
5
+ }
@@ -0,0 +1 @@
1
+ ["shopify.co.uk"]
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # performance-scout
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5637cb3: add performance-scout to npm
8
+
9
+ ## 1.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 859cfd7: add performance-scout to npm
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # Performance Scout
2
+
3
+ A TypeScript CLI tool that discovers UK Shopify sites and tests their performance using Lighthouse.
4
+
5
+ ## Features
6
+
7
+ - Discovers candidate Shopify sites from a local file OR Serper API
8
+ - Verifies sites are actually Shopify stores
9
+ - Runs Lighthouse performance tests (desktop and mobile)
10
+ - Filters sites below a configurable performance threshold
11
+ - Outputs results as a markdown table
12
+
13
+ ## Setup
14
+
15
+ ### Prerequisites
16
+
17
+ - Node.js (v18 or higher recommended)
18
+ - npm or yarn
19
+ - Chrome/Chromium (required by Lighthouse)
20
+
21
+ ### Installation
22
+
23
+ 1. Install dependencies:
24
+
25
+ ```bash
26
+ npm install
27
+ ```
28
+
29
+ 2. Set your Serper API key (required only if using `--use-serper`):
30
+
31
+ ```bash
32
+ export SERPER_API_KEY=your_api_key_here
33
+ ```
34
+
35
+ ### Configuration
36
+
37
+ Edit `config.json` to adjust settings:
38
+
39
+ - `THRESHOLD`: Performance score threshold (0-100). Sites scoring below this on desktop OR mobile are included in results. Default: 50
40
+ - `MAX_RESULTS`: Maximum number of results to collect. Default: 10
41
+
42
+ ### Candidates File
43
+
44
+ Add domains (one per line) to `candidates.txt`:
45
+
46
+ ```
47
+ example.co.uk
48
+ shop.example.com
49
+ another-shop.co.uk
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Scan using candidates.txt (default)
55
+
56
+ ```bash
57
+ npm run scan
58
+ ```
59
+
60
+ ### Scan using Serper API
61
+
62
+ ```bash
63
+ npm run scan:serper
64
+ ```
65
+
66
+ Or with the flag directly:
67
+
68
+ ```bash
69
+ npm run scan -- --use-serper
70
+ ```
71
+
72
+ The tool will:
73
+
74
+ 1. Load candidates from either `candidates.txt` (default) or Serper API (if `--use-serper` flag is used)
75
+ 2. Verify each candidate is a Shopify site
76
+ 3. Run Lighthouse tests (desktop and mobile) on verified sites
77
+ 4. Keep only sites scoring below the threshold
78
+ 5. Stop after finding `MAX_RESULTS` qualifying sites
79
+ 6. Output a markdown table to `results.md` and console
80
+
81
+ ## Output
82
+
83
+ Results are saved to `results.md` in the following format:
84
+
85
+ | Site | Desktop Perf | Mobile Perf |
86
+ | ---------------- | ------------ | ----------- |
87
+ | example.co.uk | 45 | 38 |
88
+ | shop.example.com | 52 | 41 |
89
+
90
+ Performance scores range from 0-100, where higher is better.
91
+
92
+ ## Notes
93
+
94
+ - Built with TypeScript and runs using tsx (no build step required)
95
+ - Serper API results are cached in `.serper-cache.json` to avoid repeated API calls
96
+ - Lighthouse tests can take 30-60 seconds per site
97
+ - Sites that cannot be reached or verified as Shopify stores are skipped
98
+ - If fewer than `MAX_RESULTS` qualifying sites are found, the tool outputs what it has
package/SPEC.md ADDED
@@ -0,0 +1,16 @@
1
+ Create a Node.js (ESM) CLI tool that:
2
+
3
+ - Discovers candidate UK Shopify sites:
4
+ - Primary: read candidate domains from candidates.txt (one per line, domains only)
5
+ - Optional: if SERPER_API_KEY exists, fetch more candidates via Serper “search” API for query: '"cdn.shopify.com" site:.uk' and add unique domains.
6
+ - For each candidate, verify Shopify by fetching https://<domain>/ and checking for common Shopify signals (cdn.shopify.com, myshopify.com, /cdn/shop, “Powered by Shopify”).
7
+ - Run Lighthouse CLI twice per accepted site:
8
+ - Desktop: lighthouse https://<domain> --preset=desktop --only-categories=performance --output json --quiet --chrome-flags="--headless"
9
+ - Mobile: lighthouse https://<domain> --only-categories=performance --output json --quiet --chrome-flags="--headless"
10
+ - Parse the JSON from stdout, extract categories.performance.score, convert to 0–100.
11
+ - Keep only sites where (desktop < THRESHOLD) OR (mobile < THRESHOLD), where THRESHOLD comes from config.json (default 50).
12
+ - Stop once you have 10 rows (configurable MAX_RESULTS in config.json).
13
+ - Output a markdown table:
14
+ | Site | Desktop Perf | Mobile Perf |
15
+ - Add package.json scripts: "scan": "node ./scan.mjs"
16
+ - Include README with setup + how to run.
package/candidates.txt ADDED
@@ -0,0 +1 @@
1
+ lisaeldridge.com
package/completed.md ADDED
@@ -0,0 +1,352 @@
1
+ shopify.co.uk
2
+ dakine.co.uk
3
+ irockersup.co.uk
4
+ cocoandblu.co.uk
5
+ llcompany.co.uk
6
+ mitchum.co.uk
7
+ sweetify.uk
8
+ vicegolf.co.uk
9
+ toast.co.uk
10
+ urbancan.co.uk
11
+ shop.hants.gov.uk
12
+ pinterest.co.uk
13
+ lashify.uk
14
+ besidemealways.co.uk
15
+ abbotshill.herts.sch.uk
16
+ coolframes.co.uk
17
+ styleaddict.uk
18
+ sugarrushsweeties.co.uk
19
+ childsplayclothing.co.uk
20
+ nicholataylorson.co.uk
21
+ lisaeldridge.com
22
+ tropicskincare.com
23
+ dunelondon.com
24
+ pelacase.ca
25
+ kyliejennercosmetics.co.uk
26
+ limely.co.uk
27
+ izie.co.uk
28
+ davinci-gourmet.co.uk
29
+ thejammyvegan.co.uk
30
+ leathermissalcovers.co.uk
31
+ wwf.org.uk
32
+ getcrooked.co.uk
33
+ diabetes.org.uk
34
+ committees.parliament.uk
35
+ muckandmelt.co.uk
36
+ sweetsforall.co.uk
37
+ paperlanterncompany.co.uk
38
+ play.diabetes.org.uk
39
+ munchkin.co.uk
40
+ foiltickets.co.uk
41
+ eatsleeplive.co.uk
42
+ firsttactical.co.uk
43
+ foxproject.org.uk
44
+ seamlesspos.co.uk
45
+ stcatherineschool.co.uk
46
+ shop.the-connaught.co.uk
47
+ emmabridgewater.co.uk
48
+ kraken-signs.co.uk
49
+ craftcellar.co.uk
50
+ exhibitioncourthotel4.co.uk
51
+ vouchergate.co.uk
52
+ strategycore.co.uk
53
+ aya.co.uk
54
+ richardtaunton.ac.uk
55
+ cotswoldlavender.co.uk
56
+ cumulus.hosiene.co.uk
57
+ gitlab.cim.rhul.ac.uk
58
+ bartshealth.nhs.uk
59
+ hub.qmplus.qmul.ac.uk
60
+ yumove.co.uk
61
+ basschat.co.uk
62
+ speakerpoint.co.uk
63
+ tornadomotorsport.co.uk
64
+ threadtype.co.uk
65
+ ohgm.co.uk
66
+ s1000xr.uk
67
+ niococktails.co.uk
68
+ lisou.co.uk
69
+ support.cleancanvas.co.uk
70
+ freelancer.co.uk
71
+ rmweb.co.uk
72
+ insigneart.co.uk
73
+ theoodie.co.uk
74
+ napapijri.co.uk
75
+ herbalplan.co.uk
76
+ exampaperspractice.co.uk
77
+ packagingwise.co.uk
78
+ stevemadden.co.uk
79
+ brycelandsco.co.uk
80
+ cremeofnature.co.uk
81
+ g-heat.co.uk
82
+ digraphics.co.uk
83
+ walkersworkwear.co.uk
84
+ malayacosmetics.co.uk
85
+ shepherdandwoodward.co.uk
86
+ smdesigns.co.uk
87
+ source-vintage.co.uk
88
+ igmaynard.co.uk
89
+ daydress.co.uk
90
+ super73.co.uk
91
+ diabetes.co.uk
92
+ hanro.co.uk
93
+ saltbeerfactory.co.uk
94
+ sodastream.co.uk
95
+ dermalogica.co.uk
96
+ suffolklatchcompany.co.uk
97
+ rwa.org.uk
98
+ hambledonvineyard.co.uk
99
+ gray-nicolls.co.uk
100
+ 1881distillery.co.uk
101
+ ecoscent.co.uk
102
+ brotherscider.co.uk
103
+ watchesofwales.co.uk
104
+ welovecornhole.co.uk
105
+ plouise.co.uk
106
+ kingkraft.co.uk
107
+ crumbsanddoilies.co.uk
108
+ pretavoir.co.uk
109
+ thecandlebrand.co.uk
110
+ k18hair.co.uk
111
+ eshvi.co.uk
112
+ butlerscheeses.co.uk
113
+ michaelspiers.co.uk
114
+ pubstuff.co.uk
115
+ lowa.co.uk
116
+ womag.co.uk
117
+ free-stuff.co.uk
118
+ morrison-sporrans.co.uk
119
+ blumkit.co.uk
120
+ direct-drainage.co.uk
121
+ mancoco.co.uk
122
+ hyper-creative.co.uk
123
+ omnitub.co.uk
124
+ hardtimesclothing.co.uk
125
+ bluettipower.co.uk
126
+ italianostucco.co.uk
127
+ firepit.co.uk
128
+ chubbsafesonline.co.uk
129
+ impulsefragrances.co.uk
130
+ tomscakes.co.uk
131
+ jacksgolf.co.uk
132
+ truggery.co.uk
133
+ skyflite.co.uk
134
+ crusadergifts.co.uk
135
+ persephonebooks.co.uk
136
+ ginamarie.co.uk
137
+ lilydogtreats.co.uk
138
+ bradleysmoker.co.uk
139
+ skullcandy.co.uk
140
+ newbie.uk
141
+ realagency.co.uk
142
+ beastmaker.co.uk
143
+ songmicshome.co.uk
144
+ guernseysurfschool.co.uk
145
+ stop-n-go.co.uk
146
+ justrug.co.uk
147
+ buttermilk.co.uk
148
+ janeausten.co.uk
149
+ homefire.co.uk
150
+ eletewater.co.uk
151
+ soek.co.uk
152
+ nordicoutdoor.co.uk
153
+ newtoncomm.co.uk
154
+ gingerandwhite.uk
155
+ prendas.co.uk
156
+ egw.co.uk
157
+ prettyshiny.co.uk
158
+ styling.uk
159
+ onlinecyclinggear.uk
160
+ lovisa.co.uk
161
+ gep.co.uk
162
+ brickabrac.co.uk
163
+ lakelandales.co.uk
164
+ rokit.co.uk
165
+ coolcomponents.co.uk
166
+ extreme-pop.co.uk
167
+ aranhufenia.co.uk
168
+ shoplavenderblue.uk
169
+ idbrandedltd.co.uk
170
+ affiliatecompare.co.uk
171
+ jeanstore.co.uk
172
+ whipmats.co.uk
173
+ finsur.co.uk
174
+ topology.dbk-test.sites.k-hosting.co.uk
175
+ elementman.co.uk
176
+ wahoogifts.co.uk
177
+ knownsource.co.uk
178
+ dolcevalentina.co.uk
179
+ fenwick.co.uk
180
+ bestgymequipment.co.uk
181
+ roofingoutlet.co.uk
182
+ vapefiend.co.uk
183
+ subprintsolutions.co.uk
184
+ cakeandflowers.co.uk
185
+ modelboatmayhem.co.uk
186
+ themissingbean.co.uk
187
+ sierranevadashop.co.uk
188
+ cocopzazz.co.uk
189
+ owenscott.co.uk
190
+ rohleder.co.uk
191
+ fastdecor.co.uk
192
+ julychild.co.uk
193
+ firstmats.co.uk
194
+ sweetz4u.co.uk
195
+ thesweetiejarargyll.co.uk
196
+ cobragolf.co.uk
197
+ pedelecs.co.uk
198
+ snowheads.co.uk
199
+ lafromagerie.co.uk
200
+ mhsweets.co.uk
201
+ jolieleather.co.uk
202
+ oatco.co.uk
203
+ nailberry.co.uk
204
+ printpanoramics.co.uk
205
+ sontuosostone.co.uk
206
+ greenandgable.co.uk
207
+ scoopeeze.co.uk
208
+ shop.the-berkeley.co.uk
209
+ poochandmutt.co.uk
210
+ bornprimitive.co.uk
211
+ odyl.co.uk
212
+ godivaboutique.co.uk
213
+ roalex.co.uk
214
+ thelongship.co.uk
215
+ winchester.ac.uk
216
+ abdn.ac.uk
217
+ annadavies.co.uk
218
+ eprints.whiterose.ac.uk
219
+ tours.eca.ed.ac.uk
220
+ sirri.co.uk
221
+ kateyang.co.uk
222
+ deuscustoms.co.uk
223
+ temperleylondon.uk
224
+ studio.co.uk
225
+ blushboutiqueessex.co.uk
226
+ sarasbeads.co.uk
227
+ theglovestore.co.uk
228
+ palladiumboots.co.uk
229
+ amazon.co.uk
230
+ lighthouseclothing.co.uk
231
+ stovesupermarket.co.uk
232
+ dancingleopard.co.uk
233
+ ellaandjo.co.uk
234
+ complementaryfitness.co.uk
235
+ heroturfs.co.uk
236
+ legion-fitness-equipment.co.uk
237
+ reformerlab.co.uk
238
+ nuudcare.co.uk
239
+ jllfitness.co.uk
240
+ iron-neck.co.uk
241
+ applications2.napier.ac.uk
242
+ norfolkfootball.co.uk
243
+ forum.bloodcancer.org.uk
244
+ wchc.nhs.uk
245
+ tims.nhs.uk
246
+ solgar.co.uk
247
+ humberandnorthyorkshire.org.uk
248
+ exercisebooksdirect.co.uk
249
+ ballet-boutique.co.uk
250
+ fallainfitness.co.uk
251
+ madeinheene.hee.nhs.uk
252
+ kegel8.co.uk
253
+ workoutforless.co.uk
254
+ buyinsulation.co.uk
255
+ themossway.co.uk
256
+ brfonline.org.uk
257
+ nurokor.co.uk
258
+ yuum.co.uk
259
+ theorthofitstore.co.uk
260
+ thecornishfishmonger.co.uk
261
+ savecobradford.co.uk
262
+ discount-supplements.co.uk
263
+ virtualracinguk.co.uk
264
+ killercandy.co.uk
265
+ alexdavispcs.co.uk
266
+ sole-mate.uk
267
+ luvtoo.co.uk
268
+ sunnygo.co.uk
269
+ ferristale.co.uk
270
+ allforhealthcare.co.uk
271
+ drheffs.co.uk
272
+ gethlth.co.uk
273
+ pinhoepharmacy.co.uk
274
+ smallpetselect.co.uk
275
+ tmcwakefield.co.uk
276
+ sweetcures.co.uk
277
+ yo-yodesk.co.uk
278
+ youreyehealth.co.uk
279
+ asthmaandlung.org.uk
280
+ oxfordhealth.nhs.uk
281
+ shop.bounceandbella.co.uk
282
+ nichs.org.uk
283
+ cellumauk.co.uk
284
+ camdenandislingtontalkingtherapies.nhs.uk
285
+ niassembly.gov.uk
286
+ mandatorytraining.co.uk
287
+ spiral.imperial.ac.uk
288
+ angelwatchco.co.uk
289
+ gp-training.hee.nhs.uk
290
+ rcp.ac.uk
291
+ pureadmin.qub.ac.uk
292
+ pure.manchester.ac.uk
293
+ orca.cardiff.ac.uk
294
+ reader.health.org.uk
295
+ kclpure.kcl.ac.uk
296
+ csp.org.uk
297
+ iow.gov.uk
298
+ democracy.brent.gov.uk
299
+ core.ac.uk
300
+ balancecoffee.co.uk
301
+ st-giles.walsall.sch.uk
302
+ acutemedjournal.co.uk
303
+ sbcskincare.co.uk
304
+ lauramercier.co.uk
305
+ psoriasis-association.org.uk
306
+ assets.publishing.service.gov.uk
307
+ wildwoodpets.co.uk
308
+ nibandnoble.co.uk
309
+ additionallengths.co.uk
310
+ holyfamilyhighschool.co.uk
311
+ england.nhs.uk
312
+ council.lancashire.gov.uk
313
+ wheybetter.co.uk
314
+ adph.org.uk
315
+ glostext.gloucestershire.gov.uk
316
+ purdeys.co.uk
317
+ boopbeauty.co.uk
318
+ ash.org.uk
319
+ vybey.co.uk
320
+ farmologie.co.uk
321
+ morishsnacks.co.uk
322
+ bareminerals.co.uk
323
+ wigglywigglers.co.uk
324
+ doghealth.co.uk
325
+ medscope.co.uk
326
+ ebay.co.uk
327
+ avocadoninja.co.uk
328
+ artshape.co.uk
329
+ treflachfarm.co.uk
330
+ aquaskincare.co.uk
331
+ sassyjaxcreative.co.uk
332
+ nice.org.uk
333
+ somavedic.uk
334
+ bidfoodcateringsupplies.co.uk
335
+ volaresports.co.uk
336
+ tlcsport.co.uk
337
+ infinityclub.uk
338
+ messyweekend.co.uk
339
+ app.whering.co.uk
340
+ mentalriders.co.uk
341
+ nuevoonline.uk
342
+ love2shopoffers.co.uk
343
+ flamingorock.co.uk
344
+ gymbagessentials.co.uk
345
+ sculptedbyaimee.co.uk
346
+ hfcosmetics.co.uk
347
+ shopsafe.co.uk
348
+ gracebeautybox.co.uk
349
+ magnitone.co.uk
350
+ honestyforyourskin.co.uk
351
+ bombacious.co.uk
352
+ ireneforteskincare.co.uk
package/config.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "DESKTOP_THRESHOLD": 40,
3
+ "MOBILE_THRESHOLD": 30,
4
+ "SEO_THRESHOLD": 50,
5
+ "MAX_RESULTS": 100,
6
+ "crawledVerticals": [
7
+ "fashion",
8
+ "fitness",
9
+ "health",
10
+ "sports",
11
+ "shoes",
12
+ "cosmetics",
13
+ "food",
14
+ "skincard"
15
+ ]
16
+ }
@@ -0,0 +1,4 @@
1
+ import defaultConfig from '@cutting/eslint-config/eslint';
2
+
3
+ /** @type {import("eslint").Linter.Config} */
4
+ export default [{ ignores: ['tools'] }, ...defaultConfig];
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@cutting/performance-scout",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "CLI tool to discover and test UK Shopify sites for performance",
9
+ "keywords": [
10
+ "shopify",
11
+ "lighthouse",
12
+ "performance",
13
+ "cli"
14
+ ],
15
+ "author": "",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "lighthouse": "^13.0.1",
19
+ "serpapi": "^2.2.1"
20
+ },
21
+ "devDependencies": {
22
+ "tsx": "^4.21.0"
23
+ },
24
+ "volta": {
25
+ "extends": "../../package.json"
26
+ },
27
+ "scripts": {
28
+ "scan": "tsx ./scan.ts",
29
+ "scan:serper": "tsx ./scan.ts --use-serper"
30
+ }
31
+ }
package/results.md ADDED
@@ -0,0 +1,15 @@
1
+ | Site | Desktop Perf | Mobile Perf | SEO |
2
+ | -------------------------- | ------------ | ----------- | --- |
3
+ | bareminerals.co.uk | 38 | 6 | 92 |
4
+ | pretavoir.co.uk | 48 | 9 | 69 |
5
+ | boopbeauty.co.uk | 73 | 10 | 85 |
6
+ | bradleysmoker.co.uk | 77 | 17 | 85 |
7
+ | lisaeldridge.com | 42 | 20 | 92 |
8
+ | rokit.co.uk | 44 | 22 | 77 |
9
+ | mandatorytraining.co.uk | 60 | 24 | 75 |
10
+ | songmicshome.co.uk | 31 | 26 | 69 |
11
+ | lighthouseclothing.co.uk | 72 | 26 | 92 |
12
+ | bornprimitive.co.uk | 51 | 27 | 92 |
13
+ | lovisa.co.uk | 36 | 29 | 92 |
14
+ | tropicskincare.com | 55 | 30 | 92 |
15
+ | kyliejennercosmetics.co.uk | 43 | 33 | 100 |
package/scan.ts ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import { execFile } from 'child_process';
4
+ import { existsSync } from 'fs';
5
+ import fs from 'fs/promises';
6
+ import type { Result as LHResult } from 'lighthouse';
7
+ import path from 'path';
8
+ import { getJson } from 'serpapi';
9
+ import { fileURLToPath } from 'url';
10
+ import { promisify } from 'util';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ interface Config {
18
+ DESKTOP_THRESHOLD: number;
19
+ MOBILE_THRESHOLD: number;
20
+ SEO_THRESHOLD: number;
21
+ MAX_RESULTS: number;
22
+ crawledVerticals: string[];
23
+ }
24
+
25
+ const config: Config = JSON.parse(await fs.readFile(path.join(__dirname, 'config.json'), 'utf-8'));
26
+ const DESKTOP_THRESHOLD = config.DESKTOP_THRESHOLD;
27
+ const MOBILE_THRESHOLD = config.MOBILE_THRESHOLD;
28
+ const SEO_THRESHOLD = config.SEO_THRESHOLD;
29
+ const MAX_RESULTS = config.MAX_RESULTS;
30
+
31
+ const COMPLETED_FILE = path.join(__dirname, 'completed.md');
32
+ const CONFIG_FILE = path.join(__dirname, 'config.json');
33
+
34
+ const useSerper = process.argv.includes('--use-serper');
35
+ const verticalArg = process.argv.find((arg) => arg.startsWith('--vertical='));
36
+ const vertical = verticalArg ? verticalArg.split('=')[1] : null;
37
+
38
+ async function loadCompletedDomains(): Promise<Set<string>> {
39
+ if (!existsSync(COMPLETED_FILE)) {
40
+ return new Set();
41
+ }
42
+ const content = await fs.readFile(COMPLETED_FILE, 'utf-8');
43
+ const domains = content
44
+ .split('\n')
45
+ .map((line) => line.trim())
46
+ .filter((line) => line.length > 0);
47
+ return new Set(domains);
48
+ }
49
+
50
+ async function addCompletedDomain(domain: string): Promise<void> {
51
+ await fs.appendFile(COMPLETED_FILE, `${domain}\n`);
52
+ }
53
+
54
+ async function loadCandidatesFromFile(): Promise<string[]> {
55
+ const candidatesPath = path.join(__dirname, 'candidates.txt');
56
+ if (!existsSync(candidatesPath)) {
57
+ return [];
58
+ }
59
+ const content = await fs.readFile(candidatesPath, 'utf-8');
60
+ return content
61
+ .split('\n')
62
+ .map((line) => line.trim())
63
+ .filter((line) => line.length > 0);
64
+ }
65
+
66
+ async function saveConfig(): Promise<void> {
67
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
68
+ }
69
+
70
+ async function fetchSerperCandidates(completedDomains: Set<string>): Promise<string[]> {
71
+ const apiKey = process.env.SERPER_API_KEY;
72
+ if (!apiKey) {
73
+ throw new Error('SERPER_API_KEY environment variable is required');
74
+ }
75
+
76
+ if (!vertical) {
77
+ throw new Error('--vertical=<keyword> is required when using --use-serper');
78
+ }
79
+
80
+ if (config.crawledVerticals.includes(vertical)) {
81
+ console.log(`Vertical "${vertical}" already crawled, exiting.`);
82
+ process.exit(0);
83
+ }
84
+
85
+ console.log(`Fetching candidates from Serper API for vertical: ${vertical}...`);
86
+
87
+ let query = `"cdn.shopify.com" ${vertical} site:.uk`;
88
+ for (const domain of completedDomains) {
89
+ query += ` -site:${domain}`;
90
+ }
91
+
92
+ console.log(`Query: ${query}`);
93
+ console.log(`Query length: ${query.length} characters`);
94
+
95
+ const domains: string[] = [];
96
+ const resultsPerPage = 10;
97
+ const totalRequests = Math.ceil(MAX_RESULTS / resultsPerPage);
98
+
99
+ for (let i = 0; i < totalRequests && domains.length < MAX_RESULTS; i++) {
100
+ const start = i * resultsPerPage;
101
+ console.log(`Fetching results ${start + 1}-${start + resultsPerPage}...`);
102
+
103
+ const response = await getJson({
104
+ engine: 'google',
105
+ q: query,
106
+ api_key: apiKey,
107
+ num: resultsPerPage,
108
+ start,
109
+ });
110
+
111
+ if (response.organic_results) {
112
+ for (const result of response.organic_results) {
113
+ if (result.link && domains.length < MAX_RESULTS) {
114
+ try {
115
+ const url = new URL(result.link);
116
+ const domain = url.hostname.replace(/^www\./, '');
117
+ if (!domains.includes(domain)) {
118
+ domains.push(domain);
119
+ }
120
+ } catch (e) {
121
+ console.error(`Invalid URL from Serper: ${result.link}`, e instanceof Error ? e.message : String(e));
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ if (!response.organic_results || response.organic_results.length === 0) {
128
+ console.log(`No more results after ${start} results`);
129
+ break;
130
+ }
131
+ }
132
+
133
+ console.log(`Fetched ${domains.length} domains from Serper API`);
134
+ return domains;
135
+ }
136
+
137
+ async function verifyShopify(domain: string): Promise<boolean> {
138
+ try {
139
+ const url = `https://${domain}/`;
140
+ const controller = new AbortController();
141
+ const timeout = setTimeout(() => controller.abort(), 10000);
142
+
143
+ const response = await fetch(url, {
144
+ signal: controller.signal,
145
+ headers: {
146
+ 'User-Agent': 'Mozilla/5.0 (compatible; PerformanceScout/1.0)',
147
+ },
148
+ });
149
+
150
+ clearTimeout(timeout);
151
+
152
+ if (!response.ok) {
153
+ return false;
154
+ }
155
+
156
+ const html = await response.text();
157
+ const lowerHtml = html.toLowerCase();
158
+
159
+ const hasShopifySignal =
160
+ lowerHtml.includes('cdn.shopify.com') ||
161
+ lowerHtml.includes('myshopify.com') ||
162
+ lowerHtml.includes('/cdn/shop') ||
163
+ lowerHtml.includes('powered by shopify');
164
+
165
+ return hasShopifySignal;
166
+ } catch (error) {
167
+ console.error(error);
168
+ console.error(`Error verifying ${domain}:`, error instanceof Error ? error.message : String(error));
169
+ return false;
170
+ }
171
+ }
172
+
173
+ async function runLighthouse(
174
+ domain: string,
175
+ isDesktop: boolean = false,
176
+ ): Promise<{ performance: number | null; seo: number | null }> {
177
+ const url = `https://${domain}`;
178
+ const args = [url, '--only-categories=performance,seo', '--output=json', '--quiet', '--chrome-flags=--headless'];
179
+
180
+ if (isDesktop) {
181
+ args.push('--preset=desktop');
182
+ }
183
+
184
+ try {
185
+ const { stdout } = await execFileAsync('lighthouse', args, { maxBuffer: 10 * 1024 * 1024 });
186
+ const result = JSON.parse(stdout) as LHResult;
187
+ const perfScore = result.categories?.performance?.score;
188
+ const seoScore = result.categories?.seo?.score;
189
+ return {
190
+ performance: perfScore !== null && perfScore !== undefined ? Math.round(perfScore * 100) : null,
191
+ seo: seoScore !== null && seoScore !== undefined ? Math.round(seoScore * 100) : null,
192
+ };
193
+ } catch (error) {
194
+ console.error(
195
+ `Lighthouse error for ${domain} (${isDesktop ? 'desktop' : 'mobile'}):`,
196
+ error instanceof Error ? error.message : String(error),
197
+ );
198
+ return { performance: null, seo: null };
199
+ }
200
+ }
201
+
202
+ async function testSite(domain: string) {
203
+ console.log(`Testing ${domain}...`);
204
+
205
+ const [desktopResult, mobileResult] = await Promise.all([runLighthouse(domain, true), runLighthouse(domain, false)]);
206
+
207
+ return {
208
+ domain,
209
+ desktopScore: desktopResult.performance,
210
+ mobileScore: mobileResult.performance,
211
+ seoScore: mobileResult.seo,
212
+ };
213
+ }
214
+
215
+ async function main(): Promise<void> {
216
+ console.log('Performance Scout - Starting scan...\n');
217
+
218
+ const completedDomains = await loadCompletedDomains();
219
+ console.log(`Loaded ${completedDomains.size} completed domains\n`);
220
+
221
+ let allCandidates: string[];
222
+ if (useSerper) {
223
+ console.log('Using Serper API as candidate source\n');
224
+ allCandidates = await fetchSerperCandidates(completedDomains);
225
+ } else {
226
+ console.log('Using candidates.txt as candidate source\n');
227
+ allCandidates = await loadCandidatesFromFile();
228
+ }
229
+
230
+ console.log(`Total candidates to check: ${allCandidates.length}\n`);
231
+
232
+ const outputPath = path.join(__dirname, 'results.md');
233
+ const fileExists = existsSync(outputPath);
234
+ const needsHeader = !fileExists || (await fs.readFile(outputPath, 'utf-8')).length === 0;
235
+
236
+ if (needsHeader) {
237
+ await fs.writeFile(
238
+ outputPath,
239
+ '| Site | Desktop Perf | Mobile Perf | SEO |\n|------|--------------|-------------|-----|\n',
240
+ );
241
+ }
242
+
243
+ const results: Awaited<ReturnType<typeof testSite>>[] = [];
244
+
245
+ for (const domain of allCandidates) {
246
+ if (useSerper && completedDomains.has(domain)) {
247
+ console.log(`${domain} already completed, skipping.\n`);
248
+ continue;
249
+ }
250
+
251
+ console.log(`Verifying ${domain} is a Shopify site...`);
252
+ const isShopify = await verifyShopify(domain);
253
+
254
+ if (!isShopify) {
255
+ console.log(`${domain} is not a Shopify site, skipping.\n`);
256
+ if (!completedDomains.has(domain)) {
257
+ await addCompletedDomain(domain);
258
+ completedDomains.add(domain);
259
+ }
260
+ continue;
261
+ }
262
+
263
+ console.log(`${domain} verified as Shopify site.`);
264
+ const testResult = await testSite(domain);
265
+
266
+ if (!completedDomains.has(domain)) {
267
+ await addCompletedDomain(domain);
268
+ completedDomains.add(domain);
269
+ }
270
+
271
+ const desktop = testResult.desktopScore !== null ? testResult.desktopScore : 'N/A';
272
+ const mobile = testResult.mobileScore !== null ? testResult.mobileScore : 'N/A';
273
+ const seo = testResult.seoScore !== null ? testResult.seoScore : 'N/A';
274
+
275
+ const meetsThreshold =
276
+ testResult.desktopScore !== null &&
277
+ testResult.mobileScore !== null &&
278
+ testResult.seoScore !== null &&
279
+ (testResult.desktopScore < DESKTOP_THRESHOLD ||
280
+ testResult.mobileScore < MOBILE_THRESHOLD ||
281
+ testResult.seoScore < SEO_THRESHOLD);
282
+
283
+ if (meetsThreshold) {
284
+ results.push(testResult);
285
+ const row = `| ${testResult.domain} | ${desktop} | ${mobile} | ${seo} |\n`;
286
+ console.log(`Writing to ${outputPath}: ${row.trim()}`);
287
+ await fs.appendFile(outputPath, row);
288
+ console.log(
289
+ `Added ${domain} to results (${results.length}) - Desktop: ${desktop}, Mobile: ${mobile}, SEO: ${seo}\n`,
290
+ );
291
+ } else {
292
+ console.log(`${domain} scores too high, skipping - Desktop: ${desktop}, Mobile: ${mobile}, SEO: ${seo}\n`);
293
+ }
294
+ }
295
+
296
+ if (useSerper && vertical) {
297
+ config.crawledVerticals.push(vertical);
298
+ await saveConfig();
299
+ console.log(`Added "${vertical}" to crawled verticals.`);
300
+ }
301
+
302
+ console.log(`\n=== Scan Complete ===`);
303
+ console.log(`Total results: ${results.length}`);
304
+ console.log(`Results saved to ${outputPath}`);
305
+ }
306
+
307
+ main().catch(console.error);