mallmaverick-store-scraper 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/README.md +225 -0
- package/package.json +41 -0
- package/src/brandSiteFallback.js +272 -0
- package/src/browser.js +234 -0
- package/src/deterministic.js +235 -0
- package/src/discovery.js +298 -0
- package/src/externalFollow.js +89 -0
- package/src/hoursParser.js +313 -0
- package/src/hoursPipeline.js +151 -0
- package/src/imageExtraction.js +331 -0
- package/src/llmExtract.js +99 -0
- package/src/logoExtraction.js +130 -0
- package/src/main.js +330 -0
- package/src/mallContext.js +201 -0
- package/src/mcp-server.js +425 -0
- package/src/openai-proxy.js +52 -0
- package/src/output.js +21 -0
- package/src/retryStrategy.js +60 -0
- package/src/storeExtractor.js +239 -0
- package/src/storeModel.js +147 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MCP server for mall-scraper-mcp.
|
|
6
|
+
*
|
|
7
|
+
* Exposes scraping tools via the Model Context Protocol so Claude Desktop /
|
|
8
|
+
* Claude Code can drive directory scrapes conversationally. Communicates over
|
|
9
|
+
* stdio.
|
|
10
|
+
*
|
|
11
|
+
* Tools:
|
|
12
|
+
* - scrape_directory : full per-store extraction across a directory listing.
|
|
13
|
+
* - get_store_hours : run just the hours pipeline on a single store URL.
|
|
14
|
+
* - validate_image_url : HEAD-check that a URL returns a real image.
|
|
15
|
+
*
|
|
16
|
+
* Env vars (auth):
|
|
17
|
+
* - MALL_SCRAPER_PROXY_URL + MALL_SCRAPER_TOKEN (recommended for shared use)
|
|
18
|
+
* - OPENAI_API_KEY (direct OpenAI, dev fallback)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
require('dotenv').config();
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { URL } = require('url');
|
|
25
|
+
const http = require('http');
|
|
26
|
+
const https = require('https');
|
|
27
|
+
|
|
28
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
29
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
30
|
+
const {
|
|
31
|
+
CallToolRequestSchema,
|
|
32
|
+
ListToolsRequestSchema,
|
|
33
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
34
|
+
|
|
35
|
+
const { launchBrowser, newPage, loadPageWithStrategy, attachXhrInterceptor } = require('./browser');
|
|
36
|
+
const { discoverStores } = require('./discovery');
|
|
37
|
+
const { getMallContext } = require('./mallContext');
|
|
38
|
+
const { extractHours } = require('./hoursPipeline');
|
|
39
|
+
const { classifyImages, pickImages } = require('./imageExtraction');
|
|
40
|
+
const { fetchBrandLogo } = require('./brandSiteFallback');
|
|
41
|
+
const { extractPhone, extractSocials, extractWebsite, detectStatusFlags } = require('./deterministic');
|
|
42
|
+
const { StoreExtractor } = require('./storeExtractor');
|
|
43
|
+
const { mergeExtracted, storesToCSV } = require('./storeModel');
|
|
44
|
+
const { createOpenAIClient, describeCredentials } = require('./openai-proxy');
|
|
45
|
+
|
|
46
|
+
// --- silent logger (anything to stdout would corrupt MCP framing) ---
|
|
47
|
+
const logs = [];
|
|
48
|
+
const logger = {
|
|
49
|
+
info: (...a) => logs.push(`[info] ${a.join(' ')}`),
|
|
50
|
+
warn: (...a) => logs.push(`[warn] ${a.join(' ')}`),
|
|
51
|
+
error: (...a) => logs.push(`[error] ${a.join(' ')}`),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const TOOLS = [
|
|
55
|
+
{
|
|
56
|
+
name: 'scrape_directory',
|
|
57
|
+
description:
|
|
58
|
+
'Scrape a shopping-mall store directory and return per-store records ' +
|
|
59
|
+
'(name, hours, phone, logo, brand image, categories, etc.). Use this ' +
|
|
60
|
+
'when the user wants to capture a directory like ' +
|
|
61
|
+
'https://grasslands.ca/store-directory/.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
directory_url: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'Full URL to the directory listing page.',
|
|
68
|
+
},
|
|
69
|
+
max_stores: {
|
|
70
|
+
type: 'number',
|
|
71
|
+
description: 'Max number of stores to scrape (0 = all). Default 10.',
|
|
72
|
+
default: 10,
|
|
73
|
+
},
|
|
74
|
+
concurrency: {
|
|
75
|
+
type: 'number',
|
|
76
|
+
description: 'Parallel pages. Default 2; max 5.',
|
|
77
|
+
default: 2,
|
|
78
|
+
},
|
|
79
|
+
model: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'OpenAI model. Default gpt-5.4-mini.',
|
|
82
|
+
default: 'gpt-5.4-mini',
|
|
83
|
+
},
|
|
84
|
+
write_csv: {
|
|
85
|
+
type: 'boolean',
|
|
86
|
+
description: 'Also write a CSV + JSON to extracted_stores/. Default true.',
|
|
87
|
+
default: true,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
required: ['directory_url'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'get_store_hours',
|
|
95
|
+
description:
|
|
96
|
+
'Run only the layered hours pipeline on a single store page. Cheap, ' +
|
|
97
|
+
'fast — useful for debugging when a store\'s hours look wrong.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
store_url: { type: 'string', description: 'Store detail page URL.' },
|
|
102
|
+
mall_root_url: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: 'Optional mall homepage URL — enables sync-with-mall-hours layer.',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ['store_url'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'validate_image_url',
|
|
112
|
+
description:
|
|
113
|
+
'HEAD-check a URL: returns whether it serves a real image, with ' +
|
|
114
|
+
'content-type, size, and final URL after redirects. Use when a logo ' +
|
|
115
|
+
'isn\'t loading in your CMS — confirms whether the URL itself is bad.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
url: { type: 'string', description: 'URL to check.' },
|
|
120
|
+
},
|
|
121
|
+
required: ['url'],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const server = new Server(
|
|
127
|
+
{ name: 'mall-scraper-mcp', version: '0.1.0' },
|
|
128
|
+
{ capabilities: { tools: {} } }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
132
|
+
|
|
133
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
134
|
+
const { name, arguments: args } = req.params;
|
|
135
|
+
try {
|
|
136
|
+
switch (name) {
|
|
137
|
+
case 'scrape_directory': return await handleScrapeDirectory(args || {});
|
|
138
|
+
case 'get_store_hours': return await handleGetStoreHours(args || {});
|
|
139
|
+
case 'validate_image_url': return await handleValidateImageUrl(args || {});
|
|
140
|
+
default:
|
|
141
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return errorResult(`Tool ${name} failed: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Tool implementations
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
async function handleScrapeDirectory({ directory_url, max_stores = 10, concurrency = 2, model = 'gpt-5.4-mini', write_csv = true }) {
|
|
153
|
+
if (!directory_url) return errorResult('directory_url is required');
|
|
154
|
+
const creds = describeCredentials();
|
|
155
|
+
if (creds.mode === 'none') {
|
|
156
|
+
return errorResult(
|
|
157
|
+
'No OpenAI credentials available. Set MALL_SCRAPER_PROXY_URL + ' +
|
|
158
|
+
'MALL_SCRAPER_TOKEN, or OPENAI_API_KEY.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const client = createOpenAIClient();
|
|
163
|
+
const browser = await launchBrowser({ headless: true });
|
|
164
|
+
const extractor = new StoreExtractor({ client, model, useVision: false, logger });
|
|
165
|
+
const conc = Math.min(5, Math.max(1, parseInt(concurrency, 10) || 2));
|
|
166
|
+
const max = Math.max(0, parseInt(max_stores, 10) || 0);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const mallRoot = new URL(directory_url).origin;
|
|
170
|
+
const mallContext = await getMallContext(browser, mallRoot);
|
|
171
|
+
const { storeUrls: allUrls, logoMap } = await discoverStores(browser, directory_url, logger);
|
|
172
|
+
const storeCardLogos = Array.from(logoMap.values());
|
|
173
|
+
const urls = max > 0 ? allUrls.slice(0, max) : allUrls;
|
|
174
|
+
|
|
175
|
+
const stores = [];
|
|
176
|
+
let mmId = 1;
|
|
177
|
+
// Sequential within the MCP context (concurrency adds nondeterminism that's
|
|
178
|
+
// less useful here than a clear per-store progress trail in the result).
|
|
179
|
+
const pLimit = require('p-limit')(conc);
|
|
180
|
+
const tasks = urls.map((url) => pLimit(async () => {
|
|
181
|
+
const myId = mmId++;
|
|
182
|
+
const directoryLogoUrl = logoMap.get(url.replace(/\/+$/, '').toLowerCase()) || null;
|
|
183
|
+
const store = await scrapeOneStore({
|
|
184
|
+
url, mmId: myId, browser, client, model, extractor,
|
|
185
|
+
directoryLogoUrl, mallContext, mallOrigin: mallRoot, storeCardLogos,
|
|
186
|
+
});
|
|
187
|
+
if (store) stores.push(store);
|
|
188
|
+
return store;
|
|
189
|
+
}));
|
|
190
|
+
await Promise.all(tasks);
|
|
191
|
+
stores.sort((a, b) => a.mm_id - b.mm_id);
|
|
192
|
+
|
|
193
|
+
let writtenPaths = null;
|
|
194
|
+
if (write_csv) {
|
|
195
|
+
writtenPaths = writeResults(directory_url, stores);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const bySource = {};
|
|
199
|
+
for (const s of stores) {
|
|
200
|
+
const k = s.hours_source || '(none)';
|
|
201
|
+
bySource[k] = (bySource[k] || 0) + 1;
|
|
202
|
+
}
|
|
203
|
+
const usage = extractor.getUsageSummary();
|
|
204
|
+
|
|
205
|
+
const summary = {
|
|
206
|
+
directory_url,
|
|
207
|
+
stores_extracted: stores.length,
|
|
208
|
+
hours_layer_breakdown: bySource,
|
|
209
|
+
llm_usage: usage,
|
|
210
|
+
written_files: writtenPaths,
|
|
211
|
+
auth_mode: creds.mode,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
content: [
|
|
216
|
+
{ type: 'text', text: JSON.stringify(summary, null, 2) },
|
|
217
|
+
{ type: 'text', text: '\nStores:\n' + JSON.stringify(stores, null, 2) },
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
} finally {
|
|
221
|
+
try { await browser.close(); } catch (_) {}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function handleGetStoreHours({ store_url, mall_root_url }) {
|
|
226
|
+
if (!store_url) return errorResult('store_url is required');
|
|
227
|
+
const browser = await launchBrowser({ headless: true });
|
|
228
|
+
try {
|
|
229
|
+
const mallContext = mall_root_url
|
|
230
|
+
? await getMallContext(browser, mall_root_url)
|
|
231
|
+
: { canonical: '', mallSocials: {}, mallEcosystemDomains: [], mallChromeImages: [] };
|
|
232
|
+
|
|
233
|
+
const page = await newPage(browser);
|
|
234
|
+
try {
|
|
235
|
+
const data = await loadPageWithStrategy(page, store_url, { attempt: 1 });
|
|
236
|
+
const links = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]'))
|
|
237
|
+
.map(a => ({ href: a.href, text: (a.innerText || '').trim() })).filter(o => o.href));
|
|
238
|
+
const result = await extractHours({
|
|
239
|
+
url: store_url, text: data.text, html: data.html, jsonLd: data.jsonLd,
|
|
240
|
+
metaTags: data.metaTags, links,
|
|
241
|
+
}, {
|
|
242
|
+
mallContext,
|
|
243
|
+
client: null, // skip LLM layer for the lightweight tool
|
|
244
|
+
model: null,
|
|
245
|
+
browser,
|
|
246
|
+
mallOrigin: mall_root_url ? new URL(mall_root_url).origin : null,
|
|
247
|
+
logger,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
content: [{
|
|
252
|
+
type: 'text',
|
|
253
|
+
text: JSON.stringify({
|
|
254
|
+
store_url,
|
|
255
|
+
store_hours: result.canonical,
|
|
256
|
+
source: result.source,
|
|
257
|
+
confidence: result.confidence,
|
|
258
|
+
sync_with_centre_hours: result.sync_with_centre_hours,
|
|
259
|
+
}, null, 2),
|
|
260
|
+
}],
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
try { await page.close(); } catch (_) {}
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
try { await browser.close(); } catch (_) {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleValidateImageUrl({ url }) {
|
|
271
|
+
if (!url) return Promise.resolve(errorResult('url is required'));
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
let finalUrl = url;
|
|
274
|
+
let redirects = 0;
|
|
275
|
+
const attempt = (u) => {
|
|
276
|
+
let parsed;
|
|
277
|
+
try { parsed = new URL(u); } catch { return resolve(errorResult(`Invalid URL: ${u}`)); }
|
|
278
|
+
const mod = parsed.protocol === 'https:' ? https : http;
|
|
279
|
+
const req = mod.request(u, {
|
|
280
|
+
method: 'HEAD',
|
|
281
|
+
timeout: 10000,
|
|
282
|
+
headers: {
|
|
283
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
|
284
|
+
'Accept': 'image/*,*/*;q=0.5',
|
|
285
|
+
},
|
|
286
|
+
}, (res) => {
|
|
287
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirects < 4) {
|
|
288
|
+
redirects++;
|
|
289
|
+
try { finalUrl = new URL(res.headers.location, u).toString(); return attempt(finalUrl); }
|
|
290
|
+
catch { /* fall through to report */ }
|
|
291
|
+
}
|
|
292
|
+
const ct = (res.headers['content-type'] || '').toLowerCase();
|
|
293
|
+
const cl = parseInt(res.headers['content-length'] || '0', 10) || null;
|
|
294
|
+
const isImage = ct.startsWith('image/');
|
|
295
|
+
resolve({
|
|
296
|
+
content: [{
|
|
297
|
+
type: 'text',
|
|
298
|
+
text: JSON.stringify({
|
|
299
|
+
url, final_url: finalUrl, status: res.statusCode,
|
|
300
|
+
content_type: ct, content_length: cl,
|
|
301
|
+
is_image: isImage && res.statusCode === 200,
|
|
302
|
+
verdict: isImage && res.statusCode === 200
|
|
303
|
+
? `OK — serves a real image (${ct}, ${cl ?? '?'} B)`
|
|
304
|
+
: `BAD — status ${res.statusCode}, content-type "${ct}". CMS upload will likely fail.`,
|
|
305
|
+
}, null, 2),
|
|
306
|
+
}],
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
req.on('error', (e) => resolve(errorResult(`HEAD failed: ${e.message}`)));
|
|
310
|
+
req.on('timeout', () => { req.destroy(); resolve(errorResult('HEAD request timed out')); });
|
|
311
|
+
req.end();
|
|
312
|
+
};
|
|
313
|
+
attempt(url);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Per-store scrape (port of main.js processStoreWithRetry, no-retry version)
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
async function scrapeOneStore({
|
|
322
|
+
url, mmId, browser, client, model, extractor,
|
|
323
|
+
directoryLogoUrl, mallContext, mallOrigin, storeCardLogos,
|
|
324
|
+
}) {
|
|
325
|
+
const page = await newPage(browser);
|
|
326
|
+
const { interceptedJson } = await attachXhrInterceptor(page, { directoryMode: false });
|
|
327
|
+
try {
|
|
328
|
+
const data = await loadPageWithStrategy(page, url, { attempt: 1 });
|
|
329
|
+
const links = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]'))
|
|
330
|
+
.map(a => ({ href: a.href, text: (a.innerText || '').trim() })).filter(o => o.href));
|
|
331
|
+
|
|
332
|
+
const urlSlug = (() => {
|
|
333
|
+
try {
|
|
334
|
+
const parts = new URL(url).pathname.replace(/\/$/, '').split('/').filter(Boolean);
|
|
335
|
+
return parts[parts.length - 1] || '';
|
|
336
|
+
} catch { return ''; }
|
|
337
|
+
})();
|
|
338
|
+
|
|
339
|
+
const name = data.h1 || slugToName(urlSlug) || data.title || '';
|
|
340
|
+
const hours = await extractHours({
|
|
341
|
+
url, text: data.text, html: data.html, jsonLd: data.jsonLd,
|
|
342
|
+
metaTags: data.metaTags, links,
|
|
343
|
+
}, { mallContext, client, model, browser, mallOrigin, logger });
|
|
344
|
+
|
|
345
|
+
const phone = extractPhone(data.text, data.jsonLd);
|
|
346
|
+
const socials = extractSocials(links, mallContext.mallSocials);
|
|
347
|
+
const website = extractWebsite(links, mallOrigin, name, mallContext.mallEcosystemDomains || []);
|
|
348
|
+
const flagsFromText = detectStatusFlags(data.text);
|
|
349
|
+
|
|
350
|
+
const rawCands = await classifyImages(page, url, {
|
|
351
|
+
storeName: name,
|
|
352
|
+
mallName: mallContext.mallName || '',
|
|
353
|
+
mallEcosystem: mallContext.mallEcosystemDomains || [],
|
|
354
|
+
mallChromeImages: mallContext.mallChromeImages || [],
|
|
355
|
+
storeCardLogos: storeCardLogos || [],
|
|
356
|
+
});
|
|
357
|
+
const picks = pickImages(rawCands, { directoryLogoUrl, storeName: name });
|
|
358
|
+
let logoUrl = picks.logo_image_url || '';
|
|
359
|
+
const isGifUrl = /\.gif(\?|$)/i.test(logoUrl);
|
|
360
|
+
if ((!logoUrl || isGifUrl) && website) {
|
|
361
|
+
try {
|
|
362
|
+
const fallback = await fetchBrandLogo(browser, website, name, { logger });
|
|
363
|
+
if (fallback && fallback.url) {
|
|
364
|
+
if (!logoUrl || (isGifUrl && !/\.gif(\?|$)/i.test(fallback.url))) logoUrl = fallback.url;
|
|
365
|
+
}
|
|
366
|
+
} catch (_) {}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const llmInput = {
|
|
370
|
+
url, urlSlug, h1: data.h1, title: data.title,
|
|
371
|
+
textContent: data.text, jsonLd: data.jsonLd, metaTags: data.metaTags,
|
|
372
|
+
interceptedJson: interceptedJson.slice(0, 3),
|
|
373
|
+
};
|
|
374
|
+
const { fields: llmFields } = await extractor.extract(llmInput, hours.canonical);
|
|
375
|
+
|
|
376
|
+
return mergeExtracted(mmId, {
|
|
377
|
+
name, website, phone, ...socials, ...flagsFromText, ...llmFields,
|
|
378
|
+
logo_image_url: logoUrl,
|
|
379
|
+
brand_image_url: picks.brand_image_url || '',
|
|
380
|
+
store_front_image_url: picks.store_front_image_url || '',
|
|
381
|
+
store_hours: hours.canonical,
|
|
382
|
+
hours_source: hours.source || '',
|
|
383
|
+
hours_confidence: hours.confidence,
|
|
384
|
+
sync_with_centre_hours: hours.sync_with_centre_hours || false,
|
|
385
|
+
});
|
|
386
|
+
} finally {
|
|
387
|
+
try { await page.close(); } catch (_) {}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function slugToName(slug) {
|
|
392
|
+
if (!slug) return '';
|
|
393
|
+
return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function writeResults(directoryUrl, stores) {
|
|
397
|
+
const outDir = path.join(process.cwd(), 'extracted_stores');
|
|
398
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
399
|
+
const host = new URL(directoryUrl).hostname.replace(/^www\./, '');
|
|
400
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
401
|
+
const base = path.join(outDir, `stores_v5_${host}_${ts}`);
|
|
402
|
+
fs.writeFileSync(`${base}.json`, JSON.stringify(stores, null, 2));
|
|
403
|
+
fs.writeFileSync(`${base}.csv`, storesToCSV(stores));
|
|
404
|
+
return { json: `${base}.json`, csv: `${base}.csv` };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function errorResult(message) {
|
|
408
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Start
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
async function main() {
|
|
416
|
+
const transport = new StdioServerTransport();
|
|
417
|
+
await server.connect(transport);
|
|
418
|
+
// Server is now running; stdio loop is alive for the life of the process.
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
main().catch((err) => {
|
|
422
|
+
// Errors here happen before stdio is up — safe to write to stderr.
|
|
423
|
+
console.error('Fatal:', err.stack || err.message);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { OpenAI } = require('openai');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build an OpenAI client that either:
|
|
7
|
+
* (a) Hits api.openai.com directly with OPENAI_API_KEY — used for local dev
|
|
8
|
+
* and as a fallback.
|
|
9
|
+
* (b) Hits the Cloudflare Worker proxy at MALL_SCRAPER_PROXY_URL with the
|
|
10
|
+
* shared secret MALL_SCRAPER_TOKEN. The Worker injects the real OpenAI
|
|
11
|
+
* key server-side so coworkers never see it.
|
|
12
|
+
*
|
|
13
|
+
* Priority: if BOTH proxy env vars are set, use the proxy; otherwise fall back
|
|
14
|
+
* to direct OpenAI.
|
|
15
|
+
*/
|
|
16
|
+
function createOpenAIClient() {
|
|
17
|
+
const proxyUrl = process.env.MALL_SCRAPER_PROXY_URL;
|
|
18
|
+
const proxyToken = process.env.MALL_SCRAPER_TOKEN;
|
|
19
|
+
const directKey = process.env.OPENAI_API_KEY;
|
|
20
|
+
|
|
21
|
+
if (proxyUrl && proxyToken) {
|
|
22
|
+
return new OpenAI({
|
|
23
|
+
apiKey: 'proxy-managed',
|
|
24
|
+
baseURL: proxyUrl.replace(/\/+$/, '') + '/v1',
|
|
25
|
+
defaultHeaders: { 'X-Mall-Scraper-Token': proxyToken },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (directKey) {
|
|
30
|
+
return new OpenAI({ apiKey: directKey });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
throw new Error(
|
|
34
|
+
'No OpenAI credentials available. Set either MALL_SCRAPER_PROXY_URL + ' +
|
|
35
|
+
'MALL_SCRAPER_TOKEN (recommended), or OPENAI_API_KEY for direct access.'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Quick env probe used by the MCP server and CLI to give a clear error message
|
|
41
|
+
* before any scraping starts.
|
|
42
|
+
*/
|
|
43
|
+
function describeCredentials() {
|
|
44
|
+
const proxyUrl = process.env.MALL_SCRAPER_PROXY_URL;
|
|
45
|
+
const proxyToken = process.env.MALL_SCRAPER_TOKEN;
|
|
46
|
+
const directKey = process.env.OPENAI_API_KEY;
|
|
47
|
+
if (proxyUrl && proxyToken) return { mode: 'proxy', endpoint: proxyUrl };
|
|
48
|
+
if (directKey) return { mode: 'direct', endpoint: 'https://api.openai.com' };
|
|
49
|
+
return { mode: 'none', endpoint: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { createOpenAIClient, describeCredentials };
|
package/src/output.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { URL } = require('url');
|
|
6
|
+
const { storesToCSV } = require('./storeModel');
|
|
7
|
+
|
|
8
|
+
function writeResults(directoryUrl, stores) {
|
|
9
|
+
const outDir = path.join(__dirname, '..', 'extracted_stores');
|
|
10
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
11
|
+
|
|
12
|
+
const host = new URL(directoryUrl).hostname.replace(/^www\./, '');
|
|
13
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
14
|
+
const base = path.join(outDir, `stores_v5_${host}_${ts}`);
|
|
15
|
+
|
|
16
|
+
fs.writeFileSync(`${base}.json`, JSON.stringify(stores, null, 2));
|
|
17
|
+
fs.writeFileSync(`${base}.csv`, storesToCSV(stores));
|
|
18
|
+
return { json: `${base}.json`, csv: `${base}.csv` };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { writeResults };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-store retry with escalating wait strategies.
|
|
5
|
+
*
|
|
6
|
+
* attempt 1: domcontentloaded + single scroll
|
|
7
|
+
* attempt 2: domcontentloaded + scroll + clickExpandables (2s wait)
|
|
8
|
+
* attempt 3: networkidle2 + scroll + clickExpandables (3s wait)
|
|
9
|
+
*
|
|
10
|
+
* Each attempt runs the full pipeline (hours + deterministic + LLM).
|
|
11
|
+
* Accept on first attempt whose combined confidence >= threshold.
|
|
12
|
+
* Return the best result if all attempts fall short.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MAX_ATTEMPTS = 3;
|
|
16
|
+
const DEFAULT_THRESHOLD = 0.80;
|
|
17
|
+
|
|
18
|
+
async function scrapeWithRetry({ runOnce, threshold = DEFAULT_THRESHOLD, logger }) {
|
|
19
|
+
let best = null;
|
|
20
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
21
|
+
let result;
|
|
22
|
+
try {
|
|
23
|
+
result = await runOnce(attempt);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (logger) logger.warn(` ⚠ Attempt ${attempt} failed: ${err.message}`);
|
|
26
|
+
if (attempt === MAX_ATTEMPTS) break;
|
|
27
|
+
await sleep(1500 * attempt);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!best || result.combinedConfidence > best.combinedConfidence) best = result;
|
|
31
|
+
if (result.combinedConfidence >= threshold) {
|
|
32
|
+
if (attempt > 1 && logger) {
|
|
33
|
+
logger.info(` ✅ Accepted on attempt ${attempt} (conf ${pct(result.combinedConfidence)})`);
|
|
34
|
+
}
|
|
35
|
+
return { ...result, attempt, strategy: label(attempt), needs_review: false };
|
|
36
|
+
}
|
|
37
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
38
|
+
if (logger) logger.info(` ⚡ Conf ${pct(result.combinedConfidence)} < ${pct(threshold)} — retrying`);
|
|
39
|
+
await sleep(800 * attempt);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!best) {
|
|
43
|
+
return {
|
|
44
|
+
store: null, combinedConfidence: 0,
|
|
45
|
+
attempt: MAX_ATTEMPTS, strategy: 'failed', needs_review: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
best.attempt = best.attempt || MAX_ATTEMPTS;
|
|
49
|
+
best.strategy = label(best.attempt);
|
|
50
|
+
best.needs_review = best.combinedConfidence < threshold;
|
|
51
|
+
return best;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function label(a) {
|
|
55
|
+
return a === 1 ? 'baseline' : a === 2 ? 'deeper' : a === 3 ? 'network_focus' : `attempt_${a}`;
|
|
56
|
+
}
|
|
57
|
+
function pct(n) { return `${(n * 100).toFixed(0)}%`; }
|
|
58
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
59
|
+
|
|
60
|
+
module.exports = { scrapeWithRetry, DEFAULT_THRESHOLD, MAX_ATTEMPTS };
|