@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.
- package/.claude/settings.local.json +5 -0
- package/.serper-cache.json +1 -0
- package/CHANGELOG.md +13 -0
- package/README.md +98 -0
- package/SPEC.md +16 -0
- package/candidates.txt +1 -0
- package/completed.md +352 -0
- package/config.json +16 -0
- package/eslint.config.mjs +4 -0
- package/package.json +31 -0
- package/results.md +15 -0
- package/scan.ts +307 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
["shopify.co.uk"]
|
package/CHANGELOG.md
ADDED
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
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);
|