polite-crawler 0.1.2 → 0.1.4

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.
Files changed (46) hide show
  1. package/README.md +555 -8
  2. package/build/cli-daemon-ZYRdy6EW.mjs +38 -0
  3. package/build/cli-daemon-ZYRdy6EW.mjs.map +1 -0
  4. package/build/cli-recipes-Bci6HREA.mjs +59 -0
  5. package/build/cli-recipes-Bci6HREA.mjs.map +1 -0
  6. package/build/cli.mjs +673 -26
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DK6U51_v.mjs +515 -0
  9. package/build/daemon-DK6U51_v.mjs.map +1 -0
  10. package/build/{open-Ca1lcWR3.d.mts → daemon-wwIWsUmU.d.mts} +919 -17
  11. package/build/{diff-Dk2eSvi-.mjs → diff-BUM91Rev.mjs} +2 -2
  12. package/build/{diff-Dk2eSvi-.mjs.map → diff-BUM91Rev.mjs.map} +1 -1
  13. package/build/{extract-DcwNrTK7.mjs → extract-CefB5Vfn.mjs} +2 -2
  14. package/build/{extract-DcwNrTK7.mjs.map → extract-CefB5Vfn.mjs.map} +1 -1
  15. package/build/hours.d.mts +26 -0
  16. package/build/hours.mjs +48 -1
  17. package/build/hours.mjs.map +1 -1
  18. package/build/index.d.mts +491 -11
  19. package/build/index.mjs +14 -9
  20. package/build/loopback-C_QYowSf.mjs +22 -0
  21. package/build/loopback-C_QYowSf.mjs.map +1 -0
  22. package/build/mcp.d.mts +158 -2
  23. package/build/mcp.mjs +567 -18
  24. package/build/mcp.mjs.map +1 -1
  25. package/build/ollama-BJxkjny4.mjs +160 -0
  26. package/build/ollama-BJxkjny4.mjs.map +1 -0
  27. package/build/{open-DnrWgkC0.mjs → open-CbpSCEZc.mjs} +1127 -16
  28. package/build/open-CbpSCEZc.mjs.map +1 -0
  29. package/build/recipe-GiFQojmo.mjs +279 -0
  30. package/build/recipe-GiFQojmo.mjs.map +1 -0
  31. package/build/recipe-product-Dellqudu.mjs +892 -0
  32. package/build/recipe-product-Dellqudu.mjs.map +1 -0
  33. package/build/{runtime-k8mo1_7U.mjs → runtime-D_r2SYge.mjs} +3 -3
  34. package/build/{runtime-k8mo1_7U.mjs.map → runtime-D_r2SYge.mjs.map} +1 -1
  35. package/build/transformers-BQMlaGj4.mjs +133 -0
  36. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  37. package/build/{duration-iIuMMofi.mjs → version-BPem-XZd.mjs} +10 -10
  38. package/build/version-BPem-XZd.mjs.map +1 -0
  39. package/build/{wacz-sfOLT8e9.mjs → wacz-QyzLp4mC.mjs} +3 -3
  40. package/build/{wacz-sfOLT8e9.mjs.map → wacz-QyzLp4mC.mjs.map} +1 -1
  41. package/build/{warc-VIPscWxY.mjs → warc-BHcaRKHw.mjs} +2 -2
  42. package/build/{warc-VIPscWxY.mjs.map → warc-BHcaRKHw.mjs.map} +1 -1
  43. package/build/webbotauth-CjJdTAfE.mjs.map +1 -1
  44. package/package.json +10 -2
  45. package/build/duration-iIuMMofi.mjs.map +0 -1
  46. package/build/open-DnrWgkC0.mjs.map +0 -1
@@ -0,0 +1,892 @@
1
+ import { i as durationMs } from "./version-BPem-XZd.mjs";
2
+ import { S as archivedOf, a as fetchPage, c as isHeldBack, n as HELD_BACK, p as recordOverride } from "./runtime-D_r2SYge.mjs";
3
+ import { o as decodeBody, r as typesOf, t as extractPage } from "./html-DkQUAE_d.mjs";
4
+ import { c as runtimeOf, r as deadlineOf } from "./recipe-GiFQojmo.mjs";
5
+ import { DAYS, describeWeek, disagreements, fromCompact, fromSchemaOrg, fromText } from "./hours.mjs";
6
+ //#region src/recipe-venue.ts
7
+ /** These are the limits of the venue recipe. */
8
+ const VENUE_LIMITS = {
9
+ pages: 3,
10
+ maxPages: 8,
11
+ menus: 8,
12
+ readingsPerPage: 20,
13
+ links: 200,
14
+ nearMetres: 150
15
+ };
16
+ const STOP_WORDS = /* @__PURE__ */ new Set([
17
+ "the",
18
+ "a",
19
+ "an",
20
+ "and",
21
+ "of",
22
+ "at"
23
+ ]);
24
+ /** This function gives the words of a text in one form: lower case, no accents, and `&` as `and`. */
25
+ const wordsOf = (text) => text.toLowerCase().normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\p{L}\p{N} ]/gu, " ").split(/\s+/).filter((word) => word.length > 0 && !STOP_WORDS.has(word));
26
+ /**
27
+ * This function tells if a page names a venue: the page has the first word of the name and 60
28
+ * percent of its words. A domain that a venue let go can show a different business. It gives
29
+ * undefined for a name with no word.
30
+ */
31
+ const namesVenue = (name, page) => {
32
+ const words = wordsOf(name);
33
+ if (words.length === 0) return void 0;
34
+ const onPage = new Set(wordsOf(`${page.title} ${page.text}`));
35
+ const named = new Set(words);
36
+ const found = [...named].filter((word) => onPage.has(word)).length;
37
+ return onPage.has(words[0]) && found / named.size >= .6;
38
+ };
39
+ /** The words of a link that tell what its page holds, with their weights. */
40
+ const LINK_WORDS = [
41
+ [/hours|horario|horaires|opening/, 6],
42
+ [/menu|food|drink|wine|cocktail/, 4],
43
+ [/contact|location|visit|find.?us|directions/, 3],
44
+ [/about|info/, 2],
45
+ [/lunch|dinner|brunch|breakfast/, 2]
46
+ ];
47
+ /** The hosts of review and social sites: a menu there is a different page about the venue. */
48
+ const SOCIAL = /(?:^|\.)(?:yelp|tripadvisor|facebook|instagram|twitter|x|tiktok|google|apple|spotify)\.[a-z.]+$/i;
49
+ /** The files that hold no hours as text. */
50
+ const FILE = /\.(?:pdf|jpe?g|png|gif|webp|svg|mp4)(?:[?#]|$)/i;
51
+ const hostOf = (url) => {
52
+ try {
53
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
54
+ } catch {
55
+ return;
56
+ }
57
+ };
58
+ const withoutHash = (url) => url.replace(/#.*$/s, "").replace(/\/$/, "");
59
+ /**
60
+ * This function ranks the links of a page by the words of their text and path. The links to
61
+ * the hours or a menu come first. It keeps only the links on the site of `home`,
62
+ * and it leaves out the files (a PDF, an image) and the home page itself.
63
+ */
64
+ const rankHoursLinks = (links, home) => {
65
+ const site = hostOf(home);
66
+ const self = withoutHash(home);
67
+ const seen = /* @__PURE__ */ new Set();
68
+ const scored = [];
69
+ for (const link of links.slice(0, VENUE_LIMITS.links)) {
70
+ if (typeof link?.href !== "string" || hostOf(link.href) !== site || FILE.test(link.href)) continue;
71
+ const key = withoutHash(link.href);
72
+ if (key === self || seen.has(key)) continue;
73
+ let path = "";
74
+ try {
75
+ path = new URL(link.href).pathname;
76
+ } catch {
77
+ continue;
78
+ }
79
+ const words = `${typeof link.text === "string" ? link.text : ""} ${path}`.toLowerCase();
80
+ const score = LINK_WORDS.reduce((sum, [pattern, weight]) => sum + (pattern.test(words) ? weight : 0), 0);
81
+ if (score <= 0) continue;
82
+ seen.add(key);
83
+ scored.push({
84
+ link,
85
+ score
86
+ });
87
+ }
88
+ return scored.sort((a, b) => b.score - a.score).map((entry) => entry.link);
89
+ };
90
+ const EARTH_METRES = 6371e3;
91
+ /** This function gives the distance in metres between two points on the earth (the haversine formula). */
92
+ const metresBetween = (a, b) => {
93
+ const rad = Math.PI / 180;
94
+ const dLat = (b.lat - a.lat) * rad;
95
+ const dLon = (b.lon - a.lon) * rad;
96
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
97
+ return 2 * EARTH_METRES * Math.asin(Math.min(1, Math.sqrt(h)));
98
+ };
99
+ const pointOf = (lat, lon) => {
100
+ const la = typeof lat === "string" ? Number.parseFloat(lat) : lat;
101
+ const lo = typeof lon === "string" ? Number.parseFloat(lon) : lon;
102
+ return typeof la === "number" && typeof lo === "number" && Number.isFinite(la) && Number.isFinite(lo) && Math.abs(la) <= 90 && Math.abs(lo) <= 180 ? {
103
+ lat: la,
104
+ lon: lo
105
+ } : void 0;
106
+ };
107
+ /** The words of a street that tell nothing about which street: directions and kinds of road. */
108
+ const STREET_NOISE = /* @__PURE__ */ new Set([
109
+ "n",
110
+ "s",
111
+ "e",
112
+ "w",
113
+ "north",
114
+ "south",
115
+ "east",
116
+ "west",
117
+ "st",
118
+ "street",
119
+ "ave",
120
+ "avenue",
121
+ "rd",
122
+ "road",
123
+ "blvd",
124
+ "boulevard",
125
+ "dr",
126
+ "drive",
127
+ "ln",
128
+ "lane",
129
+ "ct",
130
+ "court",
131
+ "pl",
132
+ "place",
133
+ "sq",
134
+ "square",
135
+ "hwy",
136
+ "highway",
137
+ "pkwy",
138
+ "parkway",
139
+ "way",
140
+ "ter",
141
+ "terrace",
142
+ "suite",
143
+ "ste",
144
+ "unit"
145
+ ]);
146
+ /** This function gives the number of a street address and the words that name its street. */
147
+ const streetOf = (address) => {
148
+ const words = wordsOf((address.split(",")[0] ?? address).slice(0, 200));
149
+ const number = words.find((word) => /^\d+[a-z]?$/.test(word));
150
+ if (number === void 0) return void 0;
151
+ return {
152
+ number,
153
+ words: new Set(words.filter((word) => !/^\d/.test(word) && !STREET_NOISE.has(word)))
154
+ };
155
+ };
156
+ const addressText = (value) => {
157
+ if (typeof value === "string") return value;
158
+ if (Array.isArray(value)) return addressText(value[0]);
159
+ if (typeof value === "object" && value !== null) {
160
+ const street = value["streetAddress"];
161
+ return typeof street === "string" ? street : void 0;
162
+ }
163
+ };
164
+ /**
165
+ * This function tells if a JSON-LD location record is the venue. The record must have the same
166
+ * number on a street that shares a word, or a position near the venue (`VENUE_LIMITS.nearMetres`).
167
+ */
168
+ const locationMatches = (record, location) => {
169
+ const geo = record["geo"];
170
+ const at = typeof geo === "object" && geo !== null ? pointOf(geo["latitude"], geo["longitude"]) : void 0;
171
+ const ours = pointOf(location.lat, location.lon);
172
+ if (at !== void 0 && ours !== void 0 && metresBetween(at, ours) <= VENUE_LIMITS.nearMetres) return true;
173
+ const theirs = addressText(record["address"]);
174
+ if (theirs === void 0 || location.address === void 0) return false;
175
+ const a = streetOf(theirs);
176
+ const b = streetOf(location.address);
177
+ return a !== void 0 && b !== void 0 && a.number === b.number && [...a.words].some((word) => b.words.has(word));
178
+ };
179
+ const located = (record) => record["address"] !== void 0 || record["geo"] !== void 0;
180
+ /** The types of JSON-LD records that have the hours of a venue. */
181
+ const HOURS_TYPES = /Restaurant|LocalBusiness|FoodEstablishment|BarOrPub|CafeOrCoffeeShop|Bakery|Store|Place|Winery|Brewery|NightClub|Museum|TouristAttraction|Library|EntertainmentBusiness/;
182
+ /**
183
+ * A label that names a schedule that is not the opening hours. The line above a schedule is often a
184
+ * different thing, for example a phone number, and such a line tells nothing.
185
+ */
186
+ const OTHER_SCHEDULE = /brunch|lunch|dinner|breakfast|happy|kitchen|bar|deliver|take.?out|pick.?up|patio|holiday|season|summer|winter|special|late|menu/i;
187
+ /**
188
+ * A label of a service beside the opening hours, or of an office. Its hours are a different
189
+ * schedule, also when the label says "hours" ("Happy hour", "Kitchen hours", "Office hours").
190
+ */
191
+ const SERVICE = /happy|kitchen|deliver|take.?out|pick.?up|brunch|holiday|special|patio|bar|late.?night|office/i;
192
+ const rankOf = (from, label) => {
193
+ if (from === "jsonld") return 0;
194
+ if (from === "osm") return 2;
195
+ const text = label ?? "";
196
+ if (SERVICE.test(text)) return 4;
197
+ if (/hour|open/i.test(text)) return 1;
198
+ return OTHER_SCHEDULE.test(text) ? 4 : 3;
199
+ };
200
+ const reading = (from, hours, label, source) => {
201
+ const rank = rankOf(from, label);
202
+ return {
203
+ from,
204
+ rank,
205
+ schedule: rank <= 3 ? "opening" : "other",
206
+ label,
207
+ hours: describeWeek(hours.week),
208
+ week: hours.week,
209
+ inferred: hours.inferred,
210
+ partial: hours.partial === true,
211
+ season: hours.season ?? null,
212
+ raw: hours.raw,
213
+ path: source.path,
214
+ url: source.url,
215
+ doc: source.doc,
216
+ capture: source.capture,
217
+ fetched_at: source.fetched_at,
218
+ ...source.archived === void 0 ? {} : { archived: source.archived }
219
+ };
220
+ };
221
+ const recordsOf = (meta) => {
222
+ const jsonld = meta["jsonld"];
223
+ return Array.isArray(jsonld) ? jsonld.filter((record) => record !== null && typeof record === "object" && !Array.isArray(record)) : [];
224
+ };
225
+ const linksOf = (meta) => {
226
+ const links = meta["links"];
227
+ return Array.isArray(links) ? links.filter((link) => link !== null && typeof link === "object" && typeof link.href === "string") : [];
228
+ };
229
+ /**
230
+ * This function reads the hours of one stored document, or of one version of it. It is pure, and
231
+ * it sends no request. Thus it also reads a version at a time (`versionAtTime`) or an archived copy.
232
+ *
233
+ * - It reads each JSON-LD record of a venue type with `fromSchemaOrg`, and the text with `fromText`.
234
+ * - On a page that lists more than one location, it reads only the location that matches
235
+ * `location`. With no `location`, it reads none of them, and a note says so.
236
+ * - It gives the first `telephone` and the menu links that are not on a review or social site.
237
+ */
238
+ const venueReadingsOf = (source, options = {}) => {
239
+ let steps = 0;
240
+ const notes = [];
241
+ const archived = archivedOf(source.meta);
242
+ const where = {
243
+ url: archived?.original ?? source.url,
244
+ doc: source.doc ?? null,
245
+ capture: source.capture ?? null,
246
+ fetched_at: source.fetched_at ?? null,
247
+ ...archived === void 0 ? {} : { archived }
248
+ };
249
+ const all = recordsOf(source.meta);
250
+ steps += all.length;
251
+ const venues = all.map((record, index) => ({
252
+ record,
253
+ index
254
+ })).filter(({ record }) => typesOf(record).some((type) => HOURS_TYPES.test(type)));
255
+ let usable = venues;
256
+ const placed = venues.filter(({ record }) => located(record));
257
+ if (placed.length > 1) {
258
+ const location = options.location;
259
+ const ours = location === void 0 ? [] : placed.filter(({ record }) => locationMatches(record, location));
260
+ if (ours.length === 0) notes.push(location === void 0 ? `${where.url ?? "the page"} lists ${String(placed.length)} locations: give location to read the hours of one` : `${where.url ?? "the page"} lists ${String(placed.length)} locations, and none matched the location: their hours were not read`);
261
+ usable = [...ours, ...venues.filter(({ record }) => !located(record))];
262
+ }
263
+ const readings = [];
264
+ let phone = null;
265
+ for (const { record, index } of usable) {
266
+ steps += 1;
267
+ const hours = fromSchemaOrg(record);
268
+ if (hours !== void 0 && readings.length < VENUE_LIMITS.readingsPerPage) readings.push(reading("jsonld", hours, null, {
269
+ ...where,
270
+ path: `jsonld[${String(index)}]`
271
+ }));
272
+ const telephone = record["telephone"];
273
+ if (phone === null && typeof telephone === "string" && telephone.trim().length > 0) phone = telephone.trim().slice(0, 100);
274
+ }
275
+ steps += source.text.length;
276
+ for (const hours of fromText(source.text)) {
277
+ if (readings.length >= VENUE_LIMITS.readingsPerPage) {
278
+ notes.push(`${where.url ?? "the page"} gives more than ${String(VENUE_LIMITS.readingsPerPage)} readings: the rest were not read`);
279
+ break;
280
+ }
281
+ readings.push(reading("text", hours, hours.label, {
282
+ ...where,
283
+ path: null
284
+ }));
285
+ }
286
+ const menus = [];
287
+ for (const link of linksOf(source.meta).slice(0, VENUE_LIMITS.links)) {
288
+ steps += 1;
289
+ if (menus.length >= VENUE_LIMITS.menus) break;
290
+ const text = typeof link.text === "string" ? link.text.slice(0, 200) : "";
291
+ const host = hostOf(link.href);
292
+ if (host === void 0 || SOCIAL.test(host) || !/menu/i.test(`${text} ${link.href}`)) continue;
293
+ if (menus.some((menu) => menu.url === link.href)) continue;
294
+ menus.push({
295
+ url: link.href,
296
+ text
297
+ });
298
+ }
299
+ return {
300
+ readings,
301
+ phone,
302
+ menus,
303
+ notes,
304
+ steps
305
+ };
306
+ };
307
+ const answered$1 = (page) => page.outcome === "ok" || page.outcome === "not-modified" || page.archived !== void 0;
308
+ /**
309
+ * This function reads the hours of a venue from its site. It fetches the home page and the pages
310
+ * that most likely hold hours or a menu. Each one is a fetch of the crawler. Thus robots.txt, the
311
+ * pace, the stops, the capture log and the rule for a held-back page apply. It renders a page only
312
+ * when the page needs JavaScript, as `fetch` does.
313
+ *
314
+ * It gives each reading with its source, the best reading, and the days where the readings
315
+ * disagree. It writes nothing but the capture log. To choose the true hours is the decision of
316
+ * the application.
317
+ */
318
+ const readVenue = async (target, url, options = {}) => {
319
+ const runtime = runtimeOf(target);
320
+ const extra = options.pages ?? VENUE_LIMITS.pages;
321
+ if (!Number.isInteger(extra) || extra < 0 || extra > VENUE_LIMITS.maxPages) throw new RangeError(`pages must be a whole number from 0 to ${String(VENUE_LIMITS.maxPages)}, not ${String(extra)}`);
322
+ let site;
323
+ try {
324
+ site = new URL(url);
325
+ } catch {
326
+ throw new TypeError(`readVenue needs the URL of a site, not ${JSON.stringify(url)}`);
327
+ }
328
+ if (site.protocol !== "http:" && site.protocol !== "https:") throw new TypeError(`readVenue needs an http or https URL, not ${url}`);
329
+ const maxAge = options.maxAge ?? "24h";
330
+ const job = options.job ?? "venue";
331
+ const userProvided = options.userProvided ?? false;
332
+ const pageOptions = {
333
+ render: options.render ?? "auto",
334
+ maxAgeMs: maxAge === "auto" ? "auto" : durationMs(maxAge),
335
+ onBehalfOf: options.onBehalfOf,
336
+ job,
337
+ signal: options.signal,
338
+ scope: options.scope,
339
+ labels: options.labels,
340
+ deadline: deadlineOf(runtime, options.budget),
341
+ fallback: options.fallback,
342
+ archiveDespiteRobots: options.archiveDespiteRobots
343
+ };
344
+ const notes = [];
345
+ const readings = [];
346
+ const pages = [];
347
+ const menus = [];
348
+ const captures = /* @__PURE__ */ new Set();
349
+ let phone = null;
350
+ let heldBack = false;
351
+ if (options.osm !== void 0) {
352
+ const osm = typeof options.osm === "string" ? { value: options.osm } : options.osm;
353
+ const hours = typeof osm.value === "string" ? fromCompact(osm.value, "osm") : void 0;
354
+ if (hours === void 0) notes.push(`the OpenStreetMap hours cannot be read: ${JSON.stringify(String(osm.value).slice(0, 200))}`);
355
+ else {
356
+ readings.push(reading("osm", hours, null, {
357
+ url: osm.url ?? null,
358
+ doc: osm.doc ?? null,
359
+ capture: osm.capture ?? null,
360
+ fetched_at: osm.fetched_at ?? null,
361
+ path: null
362
+ }));
363
+ if (typeof osm.capture === "string") captures.add(osm.capture);
364
+ }
365
+ }
366
+ /** This function fetches one page, and gives its document when the recipe can read it. */
367
+ const readPage = async (pageUrl) => {
368
+ const page = await fetchPage(runtime, pageUrl, pageOptions);
369
+ const doc = page.doc === null ? void 0 : runtime.index.get(page.doc);
370
+ const held = doc !== void 0 && isHeldBack(doc, userProvided);
371
+ pages.push({
372
+ url: pageUrl,
373
+ doc: page.doc,
374
+ outcome: page.outcome,
375
+ status: page.status,
376
+ rendered: page.rendered,
377
+ capture: page.capture,
378
+ fetched_at: page.fetched_at,
379
+ from_cache: page.from_cache,
380
+ held_back: held,
381
+ ...page.archived === void 0 ? {} : { archived: page.archived },
382
+ note: page.note
383
+ });
384
+ if (!answered$1(page)) notes.push(`${pageUrl}: ${page.outcome}${page.note === null ? "" : ` (${page.note})`}`);
385
+ if (doc === void 0) return void 0;
386
+ if (held) {
387
+ heldBack = true;
388
+ return;
389
+ }
390
+ if (doc.ai_input === "no") await recordOverride(runtime, doc, job);
391
+ captures.add(doc.capture);
392
+ return doc;
393
+ };
394
+ const readDoc = (doc) => {
395
+ const read = venueReadingsOf(doc, { location: options.location });
396
+ readings.push(...read.readings);
397
+ notes.push(...read.notes);
398
+ phone ??= read.phone;
399
+ for (const menu of read.menus) if (menus.length < VENUE_LIMITS.menus && !menus.some((known) => known.url === menu.url)) menus.push(menu);
400
+ };
401
+ let homeUrl = null;
402
+ let namesVenueResult = null;
403
+ const home = await readPage(site.href);
404
+ if (home !== void 0) {
405
+ homeUrl = archivedOf(home.meta)?.original ?? home.url ?? site.href;
406
+ if (options.name !== void 0) {
407
+ namesVenueResult = namesVenue(options.name, home) ?? null;
408
+ if (namesVenueResult === false) notes.push(`${homeUrl} does not name ${options.name}: the domain can belong to a different business now`);
409
+ }
410
+ if (namesVenueResult === false && options.requireName === true) notes.push("requireName: the recipe read no hours from a site that does not name the venue");
411
+ else {
412
+ readDoc(home);
413
+ for (const link of rankHoursLinks(linksOf(home.meta), homeUrl).slice(0, extra)) {
414
+ if (options.signal?.aborted === true) break;
415
+ const doc = await readPage(link.href);
416
+ if (doc !== void 0) readDoc(doc);
417
+ }
418
+ }
419
+ }
420
+ const moved = homeUrl !== null && hostOf(homeUrl) !== hostOf(site.href) ? homeUrl : null;
421
+ if (moved !== null) notes.push(`${site.href} now goes to ${moved}`);
422
+ if (heldBack) notes.push(HELD_BACK);
423
+ const firstOf = /* @__PURE__ */ new Map();
424
+ for (const one of readings) {
425
+ const key = `${one.from}|${one.label ?? ""}|${one.hours}|${one.season ?? ""}`;
426
+ if (!firstOf.has(key)) firstOf.set(key, one);
427
+ }
428
+ const unique = [...firstOf.values()].sort((a, b) => a.rank - b.rank || Number(a.inferred) - Number(b.inferred));
429
+ const disagree = /* @__PURE__ */ new Set();
430
+ const whole = unique.filter((one) => one.rank <= 2);
431
+ for (let i = 0; i < whole.length; i += 1) for (let j = i + 1; j < whole.length; j += 1) for (const day of disagreements(whole[i].week, whole[j].week)) disagree.add(day);
432
+ const best = unique.find((one) => one.schedule === "opening") ?? null;
433
+ const fromSite = unique.find((one) => one.from !== "osm" && one.schedule === "opening" && !one.inferred) ?? null;
434
+ return {
435
+ url: site.href,
436
+ home: homeUrl,
437
+ moved,
438
+ names_venue: namesVenueResult,
439
+ pages,
440
+ readings: unique,
441
+ best,
442
+ from_site: fromSite,
443
+ disagree: DAYS.filter((day) => disagree.has(day)),
444
+ menus,
445
+ phone,
446
+ captures: [...captures],
447
+ held_back: heldBack,
448
+ note: notes.length > 0 ? notes.join("; ") : null
449
+ };
450
+ };
451
+ //#endregion
452
+ //#region src/recipe-product.ts
453
+ /** These are the limits of the product reader. A page with more is hostile or broken, and the reader stops there. */
454
+ const PRODUCT_LIMITS = {
455
+ records: 500,
456
+ offers: 200,
457
+ variants: 200,
458
+ depth: 2,
459
+ priceChars: 64
460
+ };
461
+ const PRODUCT_TYPES = /^(?:Product|ProductGroup|IndividualProduct|ProductModel|SomeProducts)$/;
462
+ /** A list price or a struck-through price is the old price, not the price. */
463
+ const WAS_PRICE = /ListPrice|StrikethroughPrice|MSRP|SRP/i;
464
+ const GTIN_KEYS = [
465
+ "gtin13",
466
+ "gtin12",
467
+ "gtin14",
468
+ "gtin8",
469
+ "gtin"
470
+ ];
471
+ /** Two symbols that name one currency only. The other symbols (`$`, `¥`) name more than one. */
472
+ const SYMBOLS = {
473
+ "€": "EUR",
474
+ "£": "GBP"
475
+ };
476
+ let knownCurrencies;
477
+ /** This function tells if a code is an ISO 4217 currency that the runtime knows. */
478
+ const isCurrency = (code) => {
479
+ if (!/^[A-Z]{3}$/.test(code)) return false;
480
+ if (knownCurrencies === void 0) try {
481
+ knownCurrencies = new Set(Intl.supportedValuesOf("currency"));
482
+ } catch {
483
+ knownCurrencies = /* @__PURE__ */ new Set();
484
+ }
485
+ return knownCurrencies.size === 0 || knownCurrencies.has(code);
486
+ };
487
+ /** This function gives the number of digits after the decimal point of a currency: 2 for EUR, 0 for JPY. */
488
+ const fractionDigits = (currency) => {
489
+ if (currency === void 0) return void 0;
490
+ try {
491
+ return new Intl.NumberFormat("en", {
492
+ style: "currency",
493
+ currency
494
+ }).resolvedOptions().maximumFractionDigits;
495
+ } catch {
496
+ return;
497
+ }
498
+ };
499
+ /**
500
+ * This function reads an amount as a shop writes it: `19.99`, `1,299.00`, `1.299,00`, `$19.99` or
501
+ * the number 19.99. The last separator is the decimal point, with one exception. One kind of
502
+ * separator, each followed by three digits, separates thousands: `1.299` is 1299. For a
503
+ * currency with three decimal digits, one point with three digits is the decimal point. A text
504
+ * with two numbers (a range), a negative amount or zero gives undefined.
505
+ */
506
+ const readAmount = (value, currency) => {
507
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : void 0;
508
+ if (typeof value !== "string" || value.length > PRODUCT_LIMITS.priceChars) return void 0;
509
+ const match = /^[^\d-]*?(\d(?:[\d.,'   ]*\d)?)[^\d]*$/.exec(value.trim());
510
+ if (match === null) return void 0;
511
+ let digits = match[1].replace(/['   ]/g, "");
512
+ const last = Math.max(digits.lastIndexOf("."), digits.lastIndexOf(","));
513
+ if (last !== -1) {
514
+ const groups = new Set(digits.match(/[.,]/g)).size === 1 && /^[1-9]\d{0,2}(?:[.,]\d{3})+$/.test(digits);
515
+ const decimalThree = groups && fractionDigits(currency) === 3 && /^\d+\.\d{3}$/.test(digits);
516
+ digits = groups && !decimalThree ? digits.replace(/[.,]/g, "") : `${digits.slice(0, last).replace(/[.,]/g, "")}.${digits.slice(last + 1)}`;
517
+ }
518
+ const amount = Number(digits);
519
+ return Number.isFinite(amount) && amount > 0 ? amount : void 0;
520
+ };
521
+ const list = (value, limit) => (Array.isArray(value) ? value : value === void 0 || value === null ? [] : [value]).slice(0, limit);
522
+ const objects = (value, limit) => list(value, limit).filter((item) => typeof item === "object" && item !== null && !Array.isArray(item));
523
+ /** This function gives a name, written as a text or as an object that has one. */
524
+ const nameOf = (value) => {
525
+ for (const item of list(value, 20)) {
526
+ if (typeof item === "string" && item.trim().length > 0) return item.trim().slice(0, 500);
527
+ if (typeof item === "object" && item !== null) {
528
+ const named = item["name"];
529
+ if (typeof named === "string" && named.trim().length > 0) return named.trim().slice(0, 500);
530
+ }
531
+ }
532
+ return null;
533
+ };
534
+ const textOf = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim().slice(0, 200) : typeof value === "number" && Number.isFinite(value) ? String(value) : null;
535
+ const imageOf = (value) => {
536
+ for (const item of list(value, 20)) {
537
+ if (typeof item === "string" && /^https?:/i.test(item)) return item;
538
+ const url = typeof item === "object" && item !== null ? item["url"] : void 0;
539
+ if (typeof url === "string" && /^https?:/i.test(url)) return url;
540
+ }
541
+ return null;
542
+ };
543
+ const emptyOffers = () => ({
544
+ prices: [],
545
+ unknownCurrencies: /* @__PURE__ */ new Set(),
546
+ availability: /* @__PURE__ */ new Set(),
547
+ sellers: /* @__PURE__ */ new Set(),
548
+ unstated: false,
549
+ steps: 0
550
+ });
551
+ /** This function reads the offers of a product into `read`, and the offers of its variants to `depth` levels. */
552
+ const readOffers = (product, read, depth) => {
553
+ const money = (value, currency) => {
554
+ const written = typeof currency === "string" ? currency.trim() : "";
555
+ const code = SYMBOLS[written] ?? written.toUpperCase();
556
+ const amount = readAmount(value, code.length > 0 ? code : void 0);
557
+ if (amount === void 0) return;
558
+ if (code.length === 0) {
559
+ read.unstated = true;
560
+ return;
561
+ }
562
+ if (!isCurrency(code)) {
563
+ read.unknownCurrencies.add(written.slice(0, 20));
564
+ return;
565
+ }
566
+ read.prices.push({
567
+ amount,
568
+ currency: code,
569
+ raw: String(value).slice(0, PRODUCT_LIMITS.priceChars)
570
+ });
571
+ };
572
+ const offer = (o, level) => {
573
+ read.steps += 1;
574
+ const spec = objects(o["priceSpecification"], PRODUCT_LIMITS.offers).find((entry) => {
575
+ const kind = entry["priceType"];
576
+ return !(typeof kind === "string" && WAS_PRICE.test(kind));
577
+ });
578
+ const currency = o["priceCurrency"] ?? spec?.["priceCurrency"];
579
+ money(o["price"] ?? spec?.["price"], currency);
580
+ money(o["lowPrice"], currency);
581
+ money(o["highPrice"], currency);
582
+ const availability = typeof o["availability"] === "string" ? o["availability"].trim().replace(/^.*[/#:]/, "").slice(0, 60) : void 0;
583
+ if (availability !== void 0 && availability.length > 0) read.availability.add(availability);
584
+ const seller = nameOf(o["seller"]);
585
+ if (seller !== null) read.sellers.add(seller);
586
+ if (level < PRODUCT_LIMITS.depth) for (const inner of objects(o["offers"], PRODUCT_LIMITS.offers)) offer(inner, level + 1);
587
+ };
588
+ for (const o of objects(product["offers"], PRODUCT_LIMITS.offers)) offer(o, 0);
589
+ if (depth <= 0) return;
590
+ for (const variant of objects(product["hasVariant"], PRODUCT_LIMITS.variants)) {
591
+ read.steps += 1;
592
+ readOffers(variant, read, depth - 1);
593
+ }
594
+ };
595
+ const availabilityOf = (read) => read.availability.size === 0 ? null : read.availability.size === 1 || !read.availability.has("InStock") ? [...read.availability][0] : "InStock";
596
+ const pathKey = (value, page) => {
597
+ if (value.startsWith("#")) return void 0;
598
+ try {
599
+ const url = new URL(value, page);
600
+ return `${url.hostname.toLowerCase().replace(/^www\./, "")}${url.pathname.replace(/\/$/, "")}`;
601
+ } catch {
602
+ return;
603
+ }
604
+ };
605
+ /** This function gives the size of a record as JSON, or 0 for a record that JSON cannot write (a nesting too deep). */
606
+ const sizeOf = (record) => {
607
+ try {
608
+ return JSON.stringify(record).length;
609
+ } catch {
610
+ return 0;
611
+ }
612
+ };
613
+ /**
614
+ * This function picks the product of a page from its JSON-LD records. A page often has more
615
+ * records of products: the related products, the products that others bought. It picks:
616
+ *
617
+ * 1. The product whose `url` or `@id` is the page (the path, with no query).
618
+ * 2. Else the largest product with offers or variants.
619
+ * 3. Else the largest product.
620
+ *
621
+ * It also reads the `mainEntity` of a record (a `WebPage` or an `ItemPage` that names its product).
622
+ */
623
+ const pickProduct = (records, page) => {
624
+ let steps = 0;
625
+ const products = [];
626
+ for (const record of records.slice(0, PRODUCT_LIMITS.records)) {
627
+ steps += 1;
628
+ if (typesOf(record).some((type) => PRODUCT_TYPES.test(type))) products.push(record);
629
+ for (const inner of objects(record["mainEntity"], 20)) {
630
+ steps += 1;
631
+ if (typesOf(inner).some((type) => PRODUCT_TYPES.test(type))) products.push(inner);
632
+ }
633
+ }
634
+ const here = pathKey(page, page);
635
+ const own = products.find((product) => [product["url"], product["@id"]].some((value) => typeof value === "string" && here !== void 0 && pathKey(value, page) === here));
636
+ if (own !== void 0) return {
637
+ product: own,
638
+ steps
639
+ };
640
+ const selling = products.filter((product) => list(product["offers"], 1).length > 0 || list(product["hasVariant"], 1).length > 0);
641
+ const pool = selling.length > 0 ? selling : products;
642
+ let best;
643
+ let size = -1;
644
+ for (const product of pool) {
645
+ const length = sizeOf(product);
646
+ steps += length;
647
+ if (length > size) {
648
+ best = product;
649
+ size = length;
650
+ }
651
+ }
652
+ return {
653
+ product: best,
654
+ steps
655
+ };
656
+ };
657
+ /** This function gives the variant that a URL names: the `variant` parameter of Shopify, or a `sku` or `gtin` parameter. */
658
+ const pickVariant = (product, page) => {
659
+ let params;
660
+ try {
661
+ params = new URL(page).searchParams;
662
+ } catch {
663
+ return;
664
+ }
665
+ const variantId = params.get("variant");
666
+ const code = params.get("sku") ?? params.get("gtin");
667
+ if (variantId === null && code === null) return void 0;
668
+ const names = (value) => {
669
+ if (typeof value !== "string" || variantId === null) return false;
670
+ try {
671
+ return new URL(value, page).searchParams.get("variant") === variantId || value.endsWith(`/${variantId}`);
672
+ } catch {
673
+ return false;
674
+ }
675
+ };
676
+ return objects(product["hasVariant"], PRODUCT_LIMITS.variants).find((variant) => {
677
+ const offers = objects(variant["offers"], PRODUCT_LIMITS.offers);
678
+ const codes = [
679
+ variant["sku"],
680
+ variant["gtin"],
681
+ variant["gtin13"],
682
+ variant["gtin12"],
683
+ variant["productID"]
684
+ ].map(textOf).filter((one) => one !== null);
685
+ return code !== null && codes.includes(code) || [
686
+ variant["@id"],
687
+ variant["url"],
688
+ ...offers.map((offer) => offer["url"])
689
+ ].some(names) || variantId !== null && codes.includes(variantId);
690
+ });
691
+ };
692
+ const byAmount = (a, b) => a.amount - b.amount;
693
+ /**
694
+ * This function reads the Product data of a page from its JSON-LD records. It is pure, and it sends
695
+ * no request. Thus it also reads a version at a time, or the stored JSON-LD of a document.
696
+ *
697
+ * - The prices are in the first currency of the offers. A price with no currency is not a price.
698
+ * - A list price or a struck-through price is not the price.
699
+ * - A currency must be an ISO 4217 code (`€` and `£` count too). The notes name each other one.
700
+ */
701
+ const productOf = (records, page) => {
702
+ const picked = pickProduct(records, page);
703
+ const product = picked.product;
704
+ if (product === void 0) return {
705
+ found: false,
706
+ name: null,
707
+ brand: null,
708
+ sku: null,
709
+ gtin: null,
710
+ price: null,
711
+ price_to: null,
712
+ availability: null,
713
+ availabilities: [],
714
+ variant: null,
715
+ seller: null,
716
+ image: null,
717
+ notes: ["no schema.org Product data on the page: a list of products, or a shop that does not publish it"],
718
+ steps: picked.steps
719
+ };
720
+ const read = emptyOffers();
721
+ readOffers(product, read, PRODUCT_LIMITS.depth);
722
+ const currency = read.prices[0]?.currency;
723
+ const sorted = read.prices.filter((price) => price.currency === currency).sort(byAmount);
724
+ const low = sorted[0] ?? null;
725
+ const high = sorted.at(-1) ?? null;
726
+ const gtin = GTIN_KEYS.map((key) => textOf(product[key])).find((value) => value !== null);
727
+ const chosen = pickVariant(product, page);
728
+ let variant = null;
729
+ let steps = picked.steps + read.steps;
730
+ if (chosen !== void 0) {
731
+ const own = emptyOffers();
732
+ readOffers(chosen, own, 0);
733
+ steps += own.steps;
734
+ const prices = own.prices.filter((price) => price.currency === currency).sort(byAmount);
735
+ variant = {
736
+ name: nameOf(chosen["name"]),
737
+ sku: textOf(chosen["sku"]),
738
+ price: prices[0] ?? null,
739
+ availability: availabilityOf(own)
740
+ };
741
+ }
742
+ const notes = [
743
+ read.unknownCurrencies.size > 0 ? `a currency that is not an ISO 4217 code: ${[...read.unknownCurrencies].join(", ")}` : null,
744
+ read.unstated && sorted.length === 0 ? "the page gives a price with no currency, so the price is not read as money" : sorted.length === 0 ? "the page names the product and gives no price" : null,
745
+ new Set(read.prices.map((price) => price.currency)).size > 1 ? `the offers have more than one currency: the prices are in ${currency ?? ""}` : null,
746
+ /[?&](?:variant|sku|gtin)=/.test(page) && chosen === void 0 ? "the URL names a variant that the Product data does not list: the price is the whole range" : null
747
+ ].filter((note) => note !== null);
748
+ return {
749
+ found: true,
750
+ name: nameOf(product["name"]),
751
+ brand: nameOf(product["brand"]),
752
+ sku: textOf(product["sku"]),
753
+ gtin: gtin ?? null,
754
+ price: low,
755
+ price_to: high !== null && low !== null && high.amount > low.amount ? high : null,
756
+ availability: availabilityOf(read),
757
+ availabilities: [...read.availability],
758
+ variant,
759
+ seller: read.sellers.size === 0 ? null : [...read.sellers].join(", "),
760
+ image: imageOf(product["image"]),
761
+ notes,
762
+ steps
763
+ };
764
+ };
765
+ const WALLS = /* @__PURE__ */ new Set([
766
+ "blocked",
767
+ "challenge",
768
+ "payment-required"
769
+ ]);
770
+ /** This function tells if the site refused the crawler. */
771
+ const wallOf = (page) => WALLS.has(page.outcome) || page.outcome === "skipped" && /^(?:robots\.txt disallows|host stopped)/.test(page.note ?? "") || page.doc === null && /\bbot check\b/.test(page.note ?? "");
772
+ const answered = (page) => page.doc !== null && (page.outcome === "ok" || page.outcome === "not-modified" || page.archived !== void 0);
773
+ /**
774
+ * This function gives the JSON-LD records of a document from its stored body. The index keeps a
775
+ * part of the JSON-LD of a large page only, and a product with many variants can be larger. An
776
+ * archived copy gives the JSON-LD that the index keeps.
777
+ */
778
+ const recordsOfDoc = async (runtime, doc) => {
779
+ const kept = Array.isArray(doc.meta["jsonld"]) ? doc.meta["jsonld"] : [];
780
+ if (doc.blob === null || archivedOf(doc.meta) !== void 0) return kept;
781
+ const body = await runtime.blobs.get(doc.blob);
782
+ if (body === void 0) return kept;
783
+ const at = doc.url ?? "";
784
+ const type = doc.rendered ? "text/html; charset=utf-8" : runtime.index.latest(at, "plain")?.content_type ?? null;
785
+ try {
786
+ return extractPage(decodeBody(body, type), at).jsonld;
787
+ } catch {
788
+ return kept;
789
+ }
790
+ };
791
+ /**
792
+ * This function reads a product page. It fetches the page through the crawler: robots.txt, the
793
+ * pace, the stops, the capture log and the rule for a held-back page apply. When the plain page has
794
+ * no Product data, it renders the page once, because some shops write that data with JavaScript.
795
+ *
796
+ * A shop that refuses the crawler gives `wall: true` and a note. The recipe writes nothing but the
797
+ * capture log. `extract` can check each value against the stored page.
798
+ */
799
+ const readProduct = async (target, url, options = {}) => {
800
+ const runtime = runtimeOf(target);
801
+ try {
802
+ const parsed = new URL(url);
803
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new TypeError(url);
804
+ } catch {
805
+ throw new TypeError(`readProduct needs an http or https URL, not ${JSON.stringify(url)}`);
806
+ }
807
+ const maxAge = options.maxAge ?? "24h";
808
+ const job = options.job ?? "product";
809
+ const userProvided = options.userProvided ?? false;
810
+ const render = options.render ?? "auto";
811
+ const deadline = deadlineOf(runtime, options.budget);
812
+ const pageOptions = (mode) => ({
813
+ render: mode,
814
+ maxAgeMs: maxAge === "auto" ? "auto" : durationMs(maxAge),
815
+ onBehalfOf: options.onBehalfOf,
816
+ job,
817
+ signal: options.signal,
818
+ scope: options.scope,
819
+ labels: options.labels,
820
+ deadline,
821
+ fallback: options.fallback,
822
+ archiveDespiteRobots: options.archiveDespiteRobots
823
+ });
824
+ let page = await fetchPage(runtime, url, pageOptions(render));
825
+ const notes = [];
826
+ const result = (from, read, more = {}) => {
827
+ const all = [
828
+ from.note,
829
+ ...notes,
830
+ ...read?.notes ?? []
831
+ ].filter((part) => part !== null && part !== void 0 && part.length > 0);
832
+ return {
833
+ url,
834
+ found: read?.found ?? false,
835
+ verified: read?.found === true && read.name !== null,
836
+ name: read?.name ?? null,
837
+ brand: read?.brand ?? null,
838
+ sku: read?.sku ?? null,
839
+ gtin: read?.gtin ?? null,
840
+ price: read?.price ?? null,
841
+ price_to: read?.price_to ?? null,
842
+ availability: read?.availability ?? null,
843
+ availabilities: read?.availabilities ?? [],
844
+ variant: read?.variant ?? null,
845
+ seller: read?.seller ?? null,
846
+ image: read?.image ?? null,
847
+ wall: wallOf(from),
848
+ outcome: from.outcome,
849
+ status: from.status,
850
+ rendered: from.rendered,
851
+ doc: from.doc,
852
+ capture: from.capture,
853
+ fetched_at: from.fetched_at,
854
+ captures: [...from.captures],
855
+ from_cache: from.from_cache,
856
+ held_back: more.held_back ?? false,
857
+ ...from.archived === void 0 ? {} : { archived: from.archived },
858
+ note: all.length > 0 ? [...new Set(all)].join("; ") : null
859
+ };
860
+ };
861
+ if (!answered(page)) {
862
+ const wall = wallOf(page);
863
+ notes.push(wall ? `the shop refused the crawler (${page.outcome}): the recipe read nothing, and it does not try a different identity` : `the page gave no document (${page.outcome})`);
864
+ return result(page, void 0);
865
+ }
866
+ const held = (from) => {
867
+ const doc = from.doc === null ? void 0 : runtime.index.get(from.doc);
868
+ return doc !== void 0 && isHeldBack(doc, userProvided) ? doc : void 0;
869
+ };
870
+ if (held(page) !== void 0) {
871
+ notes.push(HELD_BACK);
872
+ return result(page, void 0, { held_back: true });
873
+ }
874
+ const docOf = (from) => from.doc === null ? void 0 : runtime.index.get(from.doc);
875
+ let doc = docOf(page);
876
+ let read = doc === void 0 ? void 0 : productOf(await recordsOfDoc(runtime, doc), url);
877
+ if (read?.found !== true && !page.rendered && render === "auto" && page.archived === void 0) {
878
+ const rendered = await fetchPage(runtime, url, pageOptions("always"));
879
+ const shown = docOf(rendered);
880
+ if (rendered.rendered && answered(rendered) && shown !== void 0 && held(rendered) === void 0) {
881
+ page = rendered;
882
+ doc = shown;
883
+ read = productOf(await recordsOfDoc(runtime, shown), url);
884
+ } else if (rendered.note !== null && !rendered.rendered) notes.push(`the render gave nothing more: ${rendered.note}`);
885
+ }
886
+ if (userProvided && doc?.ai_input === "no") await recordOverride(runtime, doc, job);
887
+ return result(page, read);
888
+ };
889
+ //#endregion
890
+ export { readAmount as a, locationMatches as c, readVenue as d, venueReadingsOf as f, productOf as i, namesVenue as l, pickProduct as n, readProduct as o, pickVariant as r, VENUE_LIMITS as s, PRODUCT_LIMITS as t, rankHoursLinks as u };
891
+
892
+ //# sourceMappingURL=recipe-product-Dellqudu.mjs.map