polite-crawler 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +293 -2
- package/build/browser-gJ_h4AhB.mjs +511 -0
- package/build/browser-gJ_h4AhB.mjs.map +1 -0
- package/build/classify-CrfyXcwo.d.mts +51 -0
- package/build/classify.d.mts +2 -0
- package/build/classify.mjs +116 -0
- package/build/classify.mjs.map +1 -0
- package/build/cli.mjs +348 -0
- package/build/cli.mjs.map +1 -0
- package/build/hours.d.mts +113 -0
- package/build/hours.mjs +905 -0
- package/build/hours.mjs.map +1 -0
- package/build/html-nWYFVsRU.d.mts +98 -0
- package/build/html-oNpGAHwi.mjs +1038 -0
- package/build/html-oNpGAHwi.mjs.map +1 -0
- package/build/html.d.mts +2 -0
- package/build/html.mjs +1 -0
- package/build/index.d.mts +131 -0
- package/build/index.mjs +8 -0
- package/build/mcp.d.mts +40 -0
- package/build/mcp.mjs +480 -0
- package/build/mcp.mjs.map +1 -0
- package/build/open-CJR4b44v.mjs +4888 -0
- package/build/open-CJR4b44v.mjs.map +1 -0
- package/build/open-CZqwY7GH.d.mts +1461 -0
- package/build/politeness-C5bZ1OKM.d.mts +274 -0
- package/build/politeness.d.mts +2 -0
- package/build/politeness.mjs +372 -0
- package/build/politeness.mjs.map +1 -0
- package/build/robots-R-qaEBOb.d.mts +144 -0
- package/build/robots.d.mts +2 -0
- package/build/robots.mjs +369 -0
- package/build/robots.mjs.map +1 -0
- package/build/signals-BHpy2xb2.d.mts +46 -0
- package/build/signals.d.mts +2 -0
- package/build/signals.mjs +113 -0
- package/build/signals.mjs.map +1 -0
- package/build/version-rVx8_y1_.mjs +11 -0
- package/build/version-rVx8_y1_.mjs.map +1 -0
- package/package.json +131 -3
- package/record.schema.json +198 -0
package/build/hours.mjs
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
//#region src/hours.ts
|
|
2
|
+
const DAYS = [
|
|
3
|
+
"mon",
|
|
4
|
+
"tue",
|
|
5
|
+
"wed",
|
|
6
|
+
"thu",
|
|
7
|
+
"fri",
|
|
8
|
+
"sat",
|
|
9
|
+
"sun"
|
|
10
|
+
];
|
|
11
|
+
const DAY_WORDS = {
|
|
12
|
+
m: "mon",
|
|
13
|
+
mo: "mon",
|
|
14
|
+
mon: "mon",
|
|
15
|
+
monday: "mon",
|
|
16
|
+
mondays: "mon",
|
|
17
|
+
tu: "tue",
|
|
18
|
+
tue: "tue",
|
|
19
|
+
tues: "tue",
|
|
20
|
+
tuesday: "tue",
|
|
21
|
+
tuesdays: "tue",
|
|
22
|
+
w: "wed",
|
|
23
|
+
we: "wed",
|
|
24
|
+
wed: "wed",
|
|
25
|
+
weds: "wed",
|
|
26
|
+
wednesday: "wed",
|
|
27
|
+
wednesdays: "wed",
|
|
28
|
+
th: "thu",
|
|
29
|
+
thu: "thu",
|
|
30
|
+
thur: "thu",
|
|
31
|
+
thurs: "thu",
|
|
32
|
+
thursday: "thu",
|
|
33
|
+
thursdays: "thu",
|
|
34
|
+
f: "fri",
|
|
35
|
+
fr: "fri",
|
|
36
|
+
fri: "fri",
|
|
37
|
+
friday: "fri",
|
|
38
|
+
fridays: "fri",
|
|
39
|
+
sa: "sat",
|
|
40
|
+
sat: "sat",
|
|
41
|
+
saturday: "sat",
|
|
42
|
+
saturdays: "sat",
|
|
43
|
+
su: "sun",
|
|
44
|
+
sun: "sun",
|
|
45
|
+
sunday: "sun",
|
|
46
|
+
sundays: "sun"
|
|
47
|
+
};
|
|
48
|
+
const dayOf = (word) => {
|
|
49
|
+
const clean = word.toLowerCase().replace(/\.$/, "");
|
|
50
|
+
return DAY_WORDS[clean] ?? DAY_WORDS[clean.replace(/s$/, "")] ?? DAY_WORDS[clean.slice(0, 3)];
|
|
51
|
+
};
|
|
52
|
+
/** This function gives the days from one day to another, with the two days. It continues past Sunday (Fri-Mon). */
|
|
53
|
+
const dayRange = (from, to) => {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (let index = DAYS.indexOf(from);; index = (index + 1) % 7) {
|
|
56
|
+
out.push(DAYS[index]);
|
|
57
|
+
if (DAYS[index] === to || out.length === 7) return out;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
61
|
+
const hhmm = (hour, minute) => `${pad(hour % 24)}:${pad(minute)}`;
|
|
62
|
+
const minutesOf = (clock) => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));
|
|
63
|
+
/** This function gives the minutes from the start of the day. A close at or before the open is on the next day ("17:00-02:00" ends at 26:00). */
|
|
64
|
+
const spanOf = ([open, close]) => {
|
|
65
|
+
const start = minutesOf(open);
|
|
66
|
+
const end = minutesOf(close);
|
|
67
|
+
return [start, end <= start ? end + 1440 : end];
|
|
68
|
+
};
|
|
69
|
+
const fromMinutes = (minutes) => hhmm(Math.floor(minutes / 60), minutes % 60);
|
|
70
|
+
/** This function gives the length of an interval in minutes: 1440 for open 24 hours. */
|
|
71
|
+
const lengthOf = (open, close) => {
|
|
72
|
+
const length = (close - open + 1440) % 1440;
|
|
73
|
+
return length === 0 ? 1440 : length;
|
|
74
|
+
};
|
|
75
|
+
const ALL_DAY = ["00:00", "00:00"];
|
|
76
|
+
/** No real day has more intervals than this. More is a list of something else, and the reader stops. */
|
|
77
|
+
const MAX_INTERVALS = 8;
|
|
78
|
+
const addInterval = (week, days, interval) => {
|
|
79
|
+
for (const day of days) {
|
|
80
|
+
let list = week[day] ?? [];
|
|
81
|
+
if (list.length >= MAX_INTERVALS) continue;
|
|
82
|
+
if (!list.some(([open, close]) => open === interval[0] && close === interval[1])) {
|
|
83
|
+
const [start, end] = spanOf(interval);
|
|
84
|
+
const overlaps = (other) => {
|
|
85
|
+
const [otherStart, otherEnd] = spanOf(other);
|
|
86
|
+
return otherStart <= end && start <= otherEnd;
|
|
87
|
+
};
|
|
88
|
+
const [first, last] = list.filter(overlaps).reduce(([low, high], other) => {
|
|
89
|
+
const [otherStart, otherEnd] = spanOf(other);
|
|
90
|
+
return [Math.min(low, otherStart), Math.max(high, otherEnd)];
|
|
91
|
+
}, [start, end]);
|
|
92
|
+
const merged = [fromMinutes(first), fromMinutes(last - first >= 1440 ? first : last)];
|
|
93
|
+
list = [...list.filter((other) => !overlaps(other)), merged];
|
|
94
|
+
}
|
|
95
|
+
list.sort((a, b) => a[0].localeCompare(b[0]));
|
|
96
|
+
week[day] = list;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/** This function closes the days that nothing has stated. */
|
|
100
|
+
const closeDays = (week, days) => {
|
|
101
|
+
for (const day of days) if (week[day] === void 0) week[day] = [];
|
|
102
|
+
};
|
|
103
|
+
/** These words name a month. */
|
|
104
|
+
const MONTH = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?";
|
|
105
|
+
const OSM_DAYS = /^(Mo|Tu|We|Th|Fr|Sa|Su)(?:-(Mo|Tu|We|Th|Fr|Sa|Su))?$/i;
|
|
106
|
+
const NAMED_DAYS = /^([a-z]{2,9})(?:-([a-z]{2,9}))?$/i;
|
|
107
|
+
const OSM_TIME = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})\+?$/;
|
|
108
|
+
/** A rule for dates, not for a week: months, weeks, a year, an nth weekday, Easter. */
|
|
109
|
+
const DATED_RULE = new RegExp(`\\b${MONTH}(?![a-z])|\\bweek\\b|\\[|\\beaster\\b|\\b\\d{4}\\b`, "i");
|
|
110
|
+
const MAX_COMPACT_CHARS = 2e3;
|
|
111
|
+
/** This function reads one OSM interval, "09:00-17:00", or gives undefined. A close past 24:00 is on the next day. */
|
|
112
|
+
const osmInterval = (span) => {
|
|
113
|
+
const time = OSM_TIME.exec(span);
|
|
114
|
+
if (time === null) return void 0;
|
|
115
|
+
const [openHour, openMinute, closeHour, closeMinute] = [
|
|
116
|
+
time[1],
|
|
117
|
+
time[2],
|
|
118
|
+
time[3],
|
|
119
|
+
time[4]
|
|
120
|
+
].map(Number);
|
|
121
|
+
if (openMinute > 59 || closeMinute > 59 || openHour > 24 || closeHour > 48) return void 0;
|
|
122
|
+
return [hhmm(openHour, openMinute), hhmm(closeHour, closeMinute)];
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* This function reads the `opening_hours` notation of OpenStreetMap, for example
|
|
126
|
+
* "Mo-Th 11:00-23:00; Fr,Sa 11:00-01:00; Su off". The `openingHours` of schema.org uses the same
|
|
127
|
+
* notation. A later rule replaces an earlier rule for the same days, as OpenStreetMap specifies.
|
|
128
|
+
* The reader leaves out a rule for dates or holidays, and the days of a rule that it cannot read
|
|
129
|
+
* stay unknown. In both cases the result is `partial`.
|
|
130
|
+
*/
|
|
131
|
+
const fromCompact = (value, from = "osm") => {
|
|
132
|
+
if (value.length > MAX_COMPACT_CHARS) return void 0;
|
|
133
|
+
const text = value.replace(/\s+/g, " ").trim();
|
|
134
|
+
if (text.length === 0) return void 0;
|
|
135
|
+
const week = {};
|
|
136
|
+
if (text === "24/7") {
|
|
137
|
+
addInterval(week, DAYS, ALL_DAY);
|
|
138
|
+
return {
|
|
139
|
+
week,
|
|
140
|
+
from,
|
|
141
|
+
raw: value,
|
|
142
|
+
inferred: false
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
let partial = false;
|
|
146
|
+
let inferred = false;
|
|
147
|
+
const rules = text.replace(/"[^"]*"/g, " ").replace(/(\d) (?=(?:Mo|Tu|We|Th|Fr|Sa|Su|PH|SH)\b)/gi, "$1; ").split(/ ?(?:;|\|\|) ?/).flatMap((rule) => rule.split(/(?<=\d:\d{2}\+?) ?, ?(?=(?:Mo|Tu|We|Th|Fr|Sa|Su|PH|SH)\b)/i).map((part, index) => ({
|
|
148
|
+
part: part.trim(),
|
|
149
|
+
additional: index > 0
|
|
150
|
+
}))).filter(({ part }) => part.length > 0);
|
|
151
|
+
let read = false;
|
|
152
|
+
for (const { part: rule, additional } of rules) {
|
|
153
|
+
if (DATED_RULE.test(rule)) {
|
|
154
|
+
partial = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const parts = /^[\s,]*([A-Za-z]{1,9}(?:-[A-Za-z]{1,9})?(?: ?, ?[A-Za-z]{1,9}(?:-[A-Za-z]{1,9})?)*)? ?:? ?(.*)$/s.exec(rule);
|
|
158
|
+
let dayPart = (parts?.[1] ?? "").replace(/\s/g, "");
|
|
159
|
+
let timePart = (parts?.[2] ?? "").trim();
|
|
160
|
+
if (timePart.length === 0 && /^(off|closed|open)$/i.test(dayPart)) {
|
|
161
|
+
timePart = dayPart;
|
|
162
|
+
dayPart = "";
|
|
163
|
+
}
|
|
164
|
+
let days = [...DAYS];
|
|
165
|
+
if (dayPart.length > 0) {
|
|
166
|
+
days = [];
|
|
167
|
+
let holidays = false;
|
|
168
|
+
let unreadable = false;
|
|
169
|
+
for (const piece of dayPart.split(",").filter((part) => part.length > 0)) {
|
|
170
|
+
if (/^(PH|SH)$/i.test(piece)) {
|
|
171
|
+
holidays = true;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const range = OSM_DAYS.exec(piece) ?? NAMED_DAYS.exec(piece);
|
|
175
|
+
const first = range === null ? void 0 : dayOf(range[1]);
|
|
176
|
+
const last = range === null ? void 0 : dayOf(range[2] ?? range[1]);
|
|
177
|
+
if (first === void 0 || last === void 0) {
|
|
178
|
+
unreadable = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
days.push(...dayRange(first, last));
|
|
182
|
+
}
|
|
183
|
+
if (unreadable) {
|
|
184
|
+
partial = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (days.length === 0) {
|
|
188
|
+
if (holidays) partial = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (timePart.length === 0 || /^open$/i.test(timePart)) {
|
|
193
|
+
partial = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!additional) for (const day of days) delete week[day];
|
|
197
|
+
if (/^(off|closed)$/i.test(timePart)) {
|
|
198
|
+
for (const day of days) delete week[day];
|
|
199
|
+
closeDays(week, days);
|
|
200
|
+
read = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const intervals = [];
|
|
204
|
+
for (const span of timePart.split(/ ?, ?/).filter((part) => part.length > 0)) {
|
|
205
|
+
const interval = osmInterval(span);
|
|
206
|
+
if (interval !== void 0) {
|
|
207
|
+
intervals.push(interval);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const parts = splitRange(span);
|
|
211
|
+
const range = parts === void 0 ? void 0 : readRange(parts[0], parts[1]);
|
|
212
|
+
if (range === void 0) {
|
|
213
|
+
intervals.length = 0;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
inferred ||= range.inferred;
|
|
217
|
+
intervals.push(range.interval);
|
|
218
|
+
}
|
|
219
|
+
if (intervals.length === 0) {
|
|
220
|
+
for (const day of days) delete week[day];
|
|
221
|
+
partial = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
for (const interval of intervals) addInterval(week, days, interval);
|
|
225
|
+
read = true;
|
|
226
|
+
}
|
|
227
|
+
if (!read || Object.keys(week).length === 0) return void 0;
|
|
228
|
+
return {
|
|
229
|
+
week,
|
|
230
|
+
from,
|
|
231
|
+
raw: value,
|
|
232
|
+
inferred,
|
|
233
|
+
...partial ? { partial } : {}
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
const SCHEMA_DAY = /(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/i;
|
|
237
|
+
/** This function reads a schema.org time: "17:00", "17:00:00", "17:00:00-05:00" or "5:00 PM". */
|
|
238
|
+
const clockOf = (value) => {
|
|
239
|
+
if (typeof value !== "string") return void 0;
|
|
240
|
+
const raw = value.trim().slice(0, 40);
|
|
241
|
+
const match = /^(\d{1,2})(?::(\d{2}))?(?::\d{2}(?:\.\d+)?)?\s*(a\.?m\.?|p\.?m\.?)?\s*(?:z|[+-]\d{2}:?\d{2})?$/i.exec(raw);
|
|
242
|
+
if (match === null) return void 0;
|
|
243
|
+
let hour = Number(match[1]);
|
|
244
|
+
const minute = Number(match[2] ?? 0);
|
|
245
|
+
if (minute > 59 || hour > 24 || hour === 24 && minute > 0) return void 0;
|
|
246
|
+
const meridiem = match[3]?.toLowerCase().startsWith("p") === true ? "pm" : match[3] === void 0 ? void 0 : "am";
|
|
247
|
+
if (meridiem !== void 0) {
|
|
248
|
+
if (hour === 0 || hour > 12) return void 0;
|
|
249
|
+
hour = meridiem === "am" ? hour % 12 : hour % 12 + 12;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
minutes: hour * 60 + minute,
|
|
253
|
+
raw
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
/** This function gives the days of a `dayOfWeek`: a name, a URL, an `@id` or a short name. */
|
|
257
|
+
const daysOfSpec = (raw) => {
|
|
258
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
259
|
+
const days = [];
|
|
260
|
+
let holidays = false;
|
|
261
|
+
for (const value of values) {
|
|
262
|
+
const name = typeof value === "string" ? value : value !== null && typeof value === "object" ? value["@id"] : void 0;
|
|
263
|
+
if (typeof name !== "string") continue;
|
|
264
|
+
if (/PublicHolidays$/i.test(name.trim())) {
|
|
265
|
+
holidays = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const full = SCHEMA_DAY.exec(name.trim());
|
|
269
|
+
const day = full === null ? dayOf(name.trim().replace(/^.*[/#:]/, "")) : dayOf(full[1]);
|
|
270
|
+
if (day !== void 0) days.push(day);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
days,
|
|
274
|
+
holidays
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
/** The text of a date of schema.org, or an empty string for a value that is not text. */
|
|
278
|
+
const dateText = (value) => typeof value === "string" ? value : "";
|
|
279
|
+
const dateOf = (value) => {
|
|
280
|
+
if (typeof value !== "string") return void 0;
|
|
281
|
+
const time = Date.parse(value.trim().slice(0, 40));
|
|
282
|
+
return Number.isNaN(time) ? void 0 : time;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* This function reads the hours of one JSON-LD object, for example a `Restaurant`. It reads the
|
|
286
|
+
* `openingHoursSpecification` first, and the `openingHours` when there is no specification. In
|
|
287
|
+
* schema.org, a closed day has the same opening and closing time, 00:00, and 00:00 to 24:00 is
|
|
288
|
+
* open 24 hours.
|
|
289
|
+
*
|
|
290
|
+
* A specification for less than a week (`validFrom` to `validThrough`) is a holiday: the reader
|
|
291
|
+
* leaves it out. When specifications hold for different seasons, the option `at` picks the
|
|
292
|
+
* season of that date. With no date, the reader keeps the specifications with no season, or else
|
|
293
|
+
* the first season, and it names the season.
|
|
294
|
+
*/
|
|
295
|
+
const fromSchemaOrg = (record, options = {}) => {
|
|
296
|
+
const specs = record["openingHoursSpecification"];
|
|
297
|
+
const list = (Array.isArray(specs) ? specs : specs !== void 0 && specs !== null ? [specs] : []).slice(0, 200).filter((spec) => spec !== null && typeof spec === "object");
|
|
298
|
+
if (list.length > 0) {
|
|
299
|
+
let partial = false;
|
|
300
|
+
const at = options.at === void 0 ? void 0 : new Date(options.at).getTime();
|
|
301
|
+
const windowed = list.map((entry) => {
|
|
302
|
+
const fromDate = dateOf(entry["validFrom"]);
|
|
303
|
+
const throughDate = dateOf(entry["validThrough"]);
|
|
304
|
+
return {
|
|
305
|
+
entry,
|
|
306
|
+
key: fromDate === void 0 && throughDate === void 0 ? void 0 : `${dateText(entry["validFrom"])}/${dateText(entry["validThrough"])}`,
|
|
307
|
+
fromDate,
|
|
308
|
+
throughDate,
|
|
309
|
+
short: fromDate !== void 0 && throughDate !== void 0 && throughDate - fromDate < 6048e5
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
if (windowed.some(({ short }) => short)) partial = true;
|
|
313
|
+
const seasonal = windowed.filter(({ short }) => !short);
|
|
314
|
+
let season;
|
|
315
|
+
let chosen = seasonal.filter(({ key }) => key === void 0);
|
|
316
|
+
if (at !== void 0 && !Number.isNaN(at)) {
|
|
317
|
+
const now = seasonal.filter(({ key, fromDate, throughDate }) => key !== void 0 && (fromDate ?? -Infinity) <= at && at <= (throughDate ?? Infinity) + 86399999);
|
|
318
|
+
if (now.length > 0) {
|
|
319
|
+
season = now[0].key;
|
|
320
|
+
chosen = [...chosen, ...now.filter(({ key }) => key === season)];
|
|
321
|
+
}
|
|
322
|
+
} else if (chosen.length === 0) {
|
|
323
|
+
season = seasonal.find(({ key }) => key !== void 0)?.key;
|
|
324
|
+
chosen = seasonal.filter(({ key }) => key === season);
|
|
325
|
+
}
|
|
326
|
+
if (seasonal.some(({ key }) => key !== void 0 && key !== season)) partial = true;
|
|
327
|
+
const week = {};
|
|
328
|
+
for (const { entry } of chosen) {
|
|
329
|
+
const { days, holidays } = daysOfSpec(entry["dayOfWeek"]);
|
|
330
|
+
if (holidays) partial = true;
|
|
331
|
+
const opens = clockOf(entry["opens"]);
|
|
332
|
+
const closes = clockOf(entry["closes"]);
|
|
333
|
+
if (days.length === 0 || opens === void 0 || closes === void 0) {
|
|
334
|
+
if (days.length > 0) partial = true;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (opens.minutes === 0 && (closes.raw.startsWith("24:00") || closes.minutes === 1439)) addInterval(week, days, ALL_DAY);
|
|
338
|
+
else if (opens.minutes === closes.minutes) {
|
|
339
|
+
if (opens.minutes === 0) {
|
|
340
|
+
for (const day of days) delete week[day];
|
|
341
|
+
closeDays(week, days);
|
|
342
|
+
} else partial = true;
|
|
343
|
+
} else addInterval(week, days, [fromMinutes(opens.minutes), fromMinutes(closes.minutes % 1440)]);
|
|
344
|
+
}
|
|
345
|
+
if (Object.keys(week).length > 0) return {
|
|
346
|
+
week,
|
|
347
|
+
from: "jsonld",
|
|
348
|
+
raw: JSON.stringify(list).slice(0, 500),
|
|
349
|
+
inferred: false,
|
|
350
|
+
...partial ? { partial } : {},
|
|
351
|
+
...season !== void 0 ? { season } : {}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const compact = record["openingHours"];
|
|
355
|
+
const strings = (Array.isArray(compact) ? compact : [compact]).filter((value) => typeof value === "string").slice(0, 50);
|
|
356
|
+
if (strings.length === 0) return void 0;
|
|
357
|
+
const joined = strings.join("; ");
|
|
358
|
+
const parsed = fromCompact(joined, "jsonld") ?? textFallback(joined);
|
|
359
|
+
return parsed === void 0 ? void 0 : {
|
|
360
|
+
...parsed,
|
|
361
|
+
raw: strings.join(", ")
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
/** This function reads an `openingHours` value that is not in the compact notation ("Monday-Friday 9am-5pm"). */
|
|
365
|
+
const textFallback = (value) => {
|
|
366
|
+
const [found] = fromText(value.replace(/;/g, "\n"));
|
|
367
|
+
if (found === void 0) return void 0;
|
|
368
|
+
const { label: _label, ...hours } = found;
|
|
369
|
+
return {
|
|
370
|
+
...hours,
|
|
371
|
+
from: "jsonld"
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
const DAY_WORD = "(?:mon(?:day)?s?|tue(?:s(?:day)?)?s?|wed(?:s|nesday)?s?|thu(?:r(?:s(?:day)?)?)?s?|fri(?:day)?s?|sat(?:urday)?s?|sun(?:day)?s?)\\.?";
|
|
375
|
+
/** These short day names count only in a span with a dash ("M-F", "Tu-Th", "Mo-Fr"). */
|
|
376
|
+
const SHORT_DAY = "(?:mo|tu|we|th|fr|sa|su|m|w|f)";
|
|
377
|
+
/** A dash of any kind joins a range. U+FFFD is a dash that a wrong decoder lost. */
|
|
378
|
+
const DASH = "[-\\u2010-\\u2015\\u2212~\\u301c\\ufffd]";
|
|
379
|
+
const RANGE_SEP = `\\s?(?:${DASH}|to|thru|through|until|till|['’]til|til)\\s?`;
|
|
380
|
+
const TIME = "(?:12\\s?(?:midnight|noon|mn|nn)\\b|\\d{1,2}(?:[:.]\\d{2}|h(?:\\d{2})?(?![a-z]))?(?:\\s?(?:a\\.?m\\.?|p\\.?m\\.?))?|noon|midnight)";
|
|
381
|
+
const TOKEN = new RegExp([
|
|
382
|
+
`(?<allday>\\b(?:open\\s)?(?<!(?:within|in|for|last|past|next|every|after|than)\\s)(?:24\\s?(?:hours|hrs?|h)\\b(?!\\s?(?:notice|in advance|advance|prior|before|ahead|later|turnaround|delivery|of|to|from))|24\\s?\\/\\s?7\\b|around the clock))`,
|
|
383
|
+
`(?<every>\\b(?:daily|every\\s?day|everyday|7\\s?days(?:\\s?a\\s?week)?|seven\\s?days(?:\\s?a\\s?week)?)\\b)`,
|
|
384
|
+
`(?<weekdays>\\bweekdays?\\b)`,
|
|
385
|
+
`(?<weekends>\\bweekends?\\b)`,
|
|
386
|
+
`(?<except>\\b(?:except(?:\\s(?:on|for))?|but(?:\\snot)?(?:\\son)?|excluding|other\\sthan|apart\\sfrom)(?=\\s(?:${DAY_WORD}|weekends?|weekdays?)(?![a-z])))`,
|
|
387
|
+
`(?<span>\\b(?:${DAY_WORD}${RANGE_SEP}${DAY_WORD}|(?:${DAY_WORD}|${SHORT_DAY})\\s?${DASH}\\s?(?:${DAY_WORD}|${SHORT_DAY}))(?![a-z]))`,
|
|
388
|
+
`(?<day>\\b${DAY_WORD}(?![a-z])(?!-(?!closed)[a-z])(?!['’]s\\b))`,
|
|
389
|
+
`(?<times>(?<![\\d:.$€£¥])${TIME}${RANGE_SEP}${TIME}(?!\\d))`,
|
|
390
|
+
`(?<clock>(?<![\\d:.$€£¥])(?:12\\s?(?:midnight|noon)\\b|\\d{1,2}(?:[:.]\\d{2})?\\s?(?:a\\.?m\\.?|p\\.?m\\.?)|noon|midnight)(?![a-z\\d]))`,
|
|
391
|
+
`(?<closed>\\bclosed\\b)`
|
|
392
|
+
].join("|"), "gi");
|
|
393
|
+
const readClock = (value) => {
|
|
394
|
+
const text = value.trim().toLowerCase();
|
|
395
|
+
if (/^(?:12\s?)?(?:noon|nn)$/.test(text)) return {
|
|
396
|
+
hour: 12,
|
|
397
|
+
minute: 0,
|
|
398
|
+
meridiem: "pm",
|
|
399
|
+
h24: false
|
|
400
|
+
};
|
|
401
|
+
if (/^(?:12\s?)?(?:midnight|mn)$/.test(text)) return {
|
|
402
|
+
hour: 12,
|
|
403
|
+
minute: 0,
|
|
404
|
+
meridiem: "am",
|
|
405
|
+
h24: false
|
|
406
|
+
};
|
|
407
|
+
const match = /^(\d{1,2})(?:[:.](\d{2})|(h)(\d{2})?)?\s?(a\.?m\.?|p\.?m\.?)?$/.exec(text);
|
|
408
|
+
if (match === null) return void 0;
|
|
409
|
+
const hour = Number(match[1]);
|
|
410
|
+
const minute = Number(match[2] ?? match[4] ?? 0);
|
|
411
|
+
if (hour > 24 || minute > 59 || hour === 24 && minute > 0) return void 0;
|
|
412
|
+
let meridiem = match[5] === void 0 ? void 0 : match[5].startsWith("a") ? "am" : "pm";
|
|
413
|
+
if (meridiem !== void 0 && (hour === 0 || hour > 12)) meridiem = void 0;
|
|
414
|
+
return {
|
|
415
|
+
hour,
|
|
416
|
+
minute,
|
|
417
|
+
meridiem,
|
|
418
|
+
h24: meridiem === void 0 && (hour > 12 || hour === 0 || match[1].startsWith("0") || match[3] !== void 0)
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
const to24 = (clock, meridiem) => meridiem === "am" ? clock.hour === 12 ? 0 : clock.hour : clock.hour === 12 ? 12 : clock.hour + 12;
|
|
422
|
+
const flip = (meridiem) => meridiem === "am" ? "pm" : "am";
|
|
423
|
+
/** The longest opening that a guess can give, in minutes. */
|
|
424
|
+
const MAX_GUESS_MINUTES = 960;
|
|
425
|
+
/** This function reads a range with a 24-hour time. A bare number on the other side is the reading that makes sense. */
|
|
426
|
+
const read24 = (a, b) => {
|
|
427
|
+
const options = (clock) => clock.h24 || clock.meridiem !== void 0 ? [clock.meridiem === void 0 ? clock.hour % 24 : to24(clock, clock.meridiem)] : clock.hour === 12 ? [12, 0] : [clock.hour, clock.hour + 12];
|
|
428
|
+
const candidates = [];
|
|
429
|
+
for (const [aIndex, openHour] of options(a).entries()) for (const [bIndex, closeHour] of options(b).entries()) {
|
|
430
|
+
const open = openHour * 60 + a.minute;
|
|
431
|
+
const close = closeHour * 60 + b.minute;
|
|
432
|
+
candidates.push({
|
|
433
|
+
open,
|
|
434
|
+
close,
|
|
435
|
+
length: lengthOf(open, close),
|
|
436
|
+
written: aIndex === 0 && bIndex === 0
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (a.h24 && b.h24) {
|
|
440
|
+
const [only] = candidates;
|
|
441
|
+
if (only === void 0 || only.close <= only.open && only.close > 720) return void 0;
|
|
442
|
+
return {
|
|
443
|
+
interval: [fromMinutes(only.open), fromMinutes(only.close)],
|
|
444
|
+
inferred: false
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const sensible = candidates.filter(({ length }) => length >= 60 && length <= MAX_GUESS_MINUTES).sort((x, y) => Number(y.written) - Number(x.written));
|
|
448
|
+
const best = sensible.find(({ close, open, length }) => close > open && length >= 180) ?? sensible.find(({ length }) => length >= 180) ?? sensible.find(({ close, open }) => close > open) ?? sensible[0];
|
|
449
|
+
if (best === void 0) return void 0;
|
|
450
|
+
return {
|
|
451
|
+
interval: [fromMinutes(best.open), fromMinutes(best.close)],
|
|
452
|
+
inferred: !best.written
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* This function reads a range of two times, as a restaurant writes it:
|
|
457
|
+
*
|
|
458
|
+
* - "5-10pm" is 17:00-22:00, and "11-3pm" is 11:00-15:00. The opening gets the am or pm of the
|
|
459
|
+
* close, but not when that puts the opening after the close.
|
|
460
|
+
* - "11-1am" is 11:00-01:00, because 23:00-01:00 is too short to be the meaning. The result is
|
|
461
|
+
* then `inferred`.
|
|
462
|
+
* - "11am-10" closes at 22:00.
|
|
463
|
+
* - With no am, pm or 24-hour time ("11-9"), an opening before 7 is pm, but "6-2" is 06:00-14:00.
|
|
464
|
+
* A close at or before the opening is pm, and "7-12" closes at noon. The result is then
|
|
465
|
+
* `inferred`, and it is not longer than 16 hours.
|
|
466
|
+
* - "21-14" is no range: a 24-hour opening past midnight does not end after noon.
|
|
467
|
+
*/
|
|
468
|
+
const readRange = (start, end) => {
|
|
469
|
+
const a = readClock(start);
|
|
470
|
+
const b = readClock(end);
|
|
471
|
+
if (a === void 0 || b === void 0) return void 0;
|
|
472
|
+
if (a.h24 || b.h24) return read24(a, b);
|
|
473
|
+
let open;
|
|
474
|
+
let close;
|
|
475
|
+
let inferred = false;
|
|
476
|
+
if (a.meridiem !== void 0 && b.meridiem !== void 0) {
|
|
477
|
+
open = to24(a, a.meridiem);
|
|
478
|
+
close = to24(b, b.meridiem);
|
|
479
|
+
} else if (b.meridiem !== void 0) {
|
|
480
|
+
close = to24(b, b.meridiem);
|
|
481
|
+
const same = to24(a, b.meridiem);
|
|
482
|
+
const other = to24(a, flip(b.meridiem));
|
|
483
|
+
const closeAt = close * 60 + b.minute;
|
|
484
|
+
if (same * 60 + a.minute < closeAt) {
|
|
485
|
+
open = same;
|
|
486
|
+
if (a.hour === 12 && b.meridiem === "am" && lengthOf(same * 60 + a.minute, closeAt) <= 120) {
|
|
487
|
+
open = 12;
|
|
488
|
+
inferred = true;
|
|
489
|
+
}
|
|
490
|
+
} else if (lengthOf(other * 60 + a.minute, closeAt) <= 180 && lengthOf(same * 60 + a.minute, closeAt) <= 1080) {
|
|
491
|
+
open = same;
|
|
492
|
+
inferred = true;
|
|
493
|
+
} else open = other;
|
|
494
|
+
} else if (a.meridiem !== void 0) {
|
|
495
|
+
open = to24(a, a.meridiem);
|
|
496
|
+
const same = to24(b, a.meridiem);
|
|
497
|
+
close = same > open ? same : to24(b, flip(a.meridiem));
|
|
498
|
+
} else {
|
|
499
|
+
inferred = true;
|
|
500
|
+
open = a.hour === 12 ? 12 : a.hour >= 7 || a.hour >= 5 && b.hour >= 1 && b.hour <= 3 ? a.hour : a.hour + 12;
|
|
501
|
+
if (b.hour === 12) close = open < 12 ? 12 : 0;
|
|
502
|
+
else if (open < 12) close = b.hour <= a.hour || b.hour - open < 3 ? b.hour + 12 : b.hour;
|
|
503
|
+
else close = b.hour + 12 > open ? b.hour + 12 : b.hour;
|
|
504
|
+
}
|
|
505
|
+
const openAt = open * 60 + a.minute;
|
|
506
|
+
const closeAt = close * 60 + b.minute;
|
|
507
|
+
if (inferred && lengthOf(openAt, closeAt) > MAX_GUESS_MINUTES) return void 0;
|
|
508
|
+
return {
|
|
509
|
+
interval: [fromMinutes(openAt % 1440), fromMinutes(closeAt % 1440)],
|
|
510
|
+
inferred
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
const splitRange = (value) => {
|
|
514
|
+
const match = new RegExp(`^(${TIME})${RANGE_SEP}(${TIME})$`, "i").exec(value.replace(/\s+/g, " ").trim().slice(0, 80));
|
|
515
|
+
return match === null ? void 0 : [match[1], match[2]];
|
|
516
|
+
};
|
|
517
|
+
/** This function gives the days of a token and how specific it is: 0 for "daily", 1 for a span, 2 for one day. */
|
|
518
|
+
const daysOfToken = (groups) => {
|
|
519
|
+
if (groups["every"] !== void 0) return {
|
|
520
|
+
days: [...DAYS],
|
|
521
|
+
spec: 0
|
|
522
|
+
};
|
|
523
|
+
if (groups["weekdays"] !== void 0) return {
|
|
524
|
+
days: [
|
|
525
|
+
"mon",
|
|
526
|
+
"tue",
|
|
527
|
+
"wed",
|
|
528
|
+
"thu",
|
|
529
|
+
"fri"
|
|
530
|
+
],
|
|
531
|
+
spec: 0
|
|
532
|
+
};
|
|
533
|
+
if (groups["weekends"] !== void 0) return {
|
|
534
|
+
days: ["sat", "sun"],
|
|
535
|
+
spec: 0
|
|
536
|
+
};
|
|
537
|
+
if (groups["span"] !== void 0) {
|
|
538
|
+
const [from, to] = groups["span"].split(new RegExp(RANGE_SEP, "i"));
|
|
539
|
+
const first = from === void 0 ? void 0 : dayOf(from.trim());
|
|
540
|
+
const last = to === void 0 ? void 0 : dayOf(to.trim());
|
|
541
|
+
return first === void 0 || last === void 0 ? void 0 : {
|
|
542
|
+
days: dayRange(first, last),
|
|
543
|
+
spec: 1
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (groups["day"] !== void 0) {
|
|
547
|
+
const day = dayOf(groups["day"]);
|
|
548
|
+
return day === void 0 ? void 0 : {
|
|
549
|
+
days: [day],
|
|
550
|
+
spec: 2
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
/** A date names one day of the year, not a day of the week: "Dec 24", "24 December", "12/24", "2026-12-24". */
|
|
555
|
+
const DATE = new RegExp(`\\b${MONTH}\\s?\\d{1,2}(?:st|nd|rd|th)?\\b(?!:\\d|\\s?[ap]\\.?m\\b)|\\b\\d{1,2}(?:st|nd|rd|th)?\\s(?:of\\s)?${MONTH}(?![a-z])|(?<![\\d/])\\d{1,2}\\/\\d{1,2}(?:\\/\\d{2,4})?(?![\\d/])|\\b\\d{4}-\\d{2}-\\d{2}\\b`, "i");
|
|
556
|
+
const DATE_RANGE = new RegExp(`\\b${MONTH}(?:\\s?\\d{1,2}(?:st|nd|rd|th)?)?${RANGE_SEP}${MONTH}(?:\\s?\\d{1,2}(?:st|nd|rd|th)?)?(?![a-z])`, "i");
|
|
557
|
+
/** These words tell that a line is for one occasion, not for the week. */
|
|
558
|
+
const OCCASION = /\b(?:holidays?|christmas|xmas|thanksgiving|easter|hanukkah|new year['’]?s?|nye|memorial day|labou?r day|independence day|valentine['’]?s|mother['’]?s day|father['’]?s day|private (?:events?|parties|party|hire|dining)|special events?|events? only|today|tonight)\b/i;
|
|
559
|
+
/**
|
|
560
|
+
* These words name a service or an event beside the opening hours ("Happy hour 6-8pm", "Delivery
|
|
561
|
+
* 6pm-9pm", "Live music 9pm-12am"). Some lines have no day and name a service before their hours.
|
|
562
|
+
* Under a line with days and hours, such a line is not the week. A meal ("Lunch", "Dinner") or
|
|
563
|
+
* "Kitchen" stays a part of the opening hours.
|
|
564
|
+
*/
|
|
565
|
+
const SERVICE = /\b(?:happy hours?|delivery|deliveries|take[- ]?out|take[- ]?away|pick[- ]?up|curbside|drive[- ]?(?:thru|through)|live (?:music|jazz|band)|music|dj|karaoke|trivia|bingo|open mic|brunch|bottomless)\b/i;
|
|
566
|
+
const SEASON = /^(?:summer|winter|spring|autumn|fall|off[- ]?season|high season|low season|peak season)\b|\b(?:summer|winter|spring|autumn|fall|off[- ]?season)(?=\s?(?:hours|schedule|season|:|\())/i;
|
|
567
|
+
/** This function names the season of a line ("Summer", "June-August"), or gives undefined. */
|
|
568
|
+
const seasonOf = (line) => (SEASON.exec(line) ?? DATE_RANGE.exec(line))?.[0].trim();
|
|
569
|
+
/** The longest line that can hold hours. A longer line is prose, and the reader does not read it. */
|
|
570
|
+
const MAX_LINE = 1e3;
|
|
571
|
+
/** A block of hours longer than this is a list of something else. */
|
|
572
|
+
const MAX_BLOCK_LINES = 100;
|
|
573
|
+
const normalize = (line) => line.normalize("NFKC").replace(/[\u200b-\u200d\u2060\ufeff\u00ad]/g, "").replace(/\s+/g, " ").trim();
|
|
574
|
+
const tokensOf = (line, at) => line.length > MAX_LINE ? [] : [...line.matchAll(TOKEN)].map((match) => {
|
|
575
|
+
const groups = match.groups ?? {};
|
|
576
|
+
const days = daysOfToken(groups);
|
|
577
|
+
return {
|
|
578
|
+
groups,
|
|
579
|
+
line: at,
|
|
580
|
+
start: match.index ?? 0,
|
|
581
|
+
end: (match.index ?? 0) + match[0].length,
|
|
582
|
+
days: days?.days,
|
|
583
|
+
spec: days?.spec ?? 2
|
|
584
|
+
};
|
|
585
|
+
});
|
|
586
|
+
/** A range with no am, pm, minutes or "h" is a bare range ("5-10"): it can be a price, an age or a score. */
|
|
587
|
+
const isStrong = (times) => /[ap]\.?m|[:.]\d{2}|noon|midnight|\dh/i.test(times);
|
|
588
|
+
/** The words that join days or a day and its hours, and say nothing more. */
|
|
589
|
+
const JOINERS = /^(?:[\s,&/:;.()|–—-]|\band\b|\bor\b|\bfrom\b|\bopen\b|\bhours\b)*$/i;
|
|
590
|
+
/**
|
|
591
|
+
* This function reads the hours in the text of a page. It reads the lines with days and times as
|
|
592
|
+
* one stream. Thus "Monday – Thursday" on one line and "11:30am – 9pm" on the next line go
|
|
593
|
+
* together. A list of days ("Mon, Wed & Fri") waits for a time or for "closed".
|
|
594
|
+
*
|
|
595
|
+
* Each group of these lines is a candidate. Its label is the short line above it ("Hours",
|
|
596
|
+
* "Dinner", "Happy hour"). A page often shows more than one schedule, and the choice between them
|
|
597
|
+
* is a judgment, not a parse. A season ("Summer:", "June-August") starts a new candidate.
|
|
598
|
+
*
|
|
599
|
+
* A line about a date or an occasion ("Dec 24: 9am-2pm", "New Year's Eve 8pm-1am", "Private events
|
|
600
|
+
* 11am-3pm") is not the week. A line with no day that names a service before its hours ("Happy hour
|
|
601
|
+
* 6-8pm") under a line with days and hours is not the week either. The reader leaves it out, and
|
|
602
|
+
* the candidate is `partial`.
|
|
603
|
+
*/
|
|
604
|
+
const fromText = (text) => {
|
|
605
|
+
const original = text.split(/\n+/).map((line) => line.trim());
|
|
606
|
+
const lines = original.map((line) => line.length > MAX_LINE ? line : normalize(line));
|
|
607
|
+
const out = [];
|
|
608
|
+
/** A season or the size can cut a block from the block before it. Then the line above the block is not its label. */
|
|
609
|
+
let block;
|
|
610
|
+
const flush = () => {
|
|
611
|
+
if (block === void 0) return;
|
|
612
|
+
const current = block;
|
|
613
|
+
block = void 0;
|
|
614
|
+
const above = current.cut ? "" : lines[current.start - 1] ?? "";
|
|
615
|
+
const label = current.cut ? void 0 : original[current.start - 1];
|
|
616
|
+
const week = {};
|
|
617
|
+
let inferred = false;
|
|
618
|
+
let partial = false;
|
|
619
|
+
const closedBy = /* @__PURE__ */ new Map();
|
|
620
|
+
const openedBy = /* @__PURE__ */ new Map();
|
|
621
|
+
const heading = /hour|open/i.test(above);
|
|
622
|
+
const perLine = current.lines.map((index, at) => tokensOf(lines[index], at));
|
|
623
|
+
const oneOff = current.lines.map((index, at) => {
|
|
624
|
+
const line = lines[index].replace(/\b24 ?\/ ?7\b/g, " ");
|
|
625
|
+
const onLine = perLine[at];
|
|
626
|
+
const weekly = onLine.some((token) => token.days !== void 0);
|
|
627
|
+
const date = DATE_RANGE.test(line) ? null : DATE.exec(line);
|
|
628
|
+
const value = onLine.find((token) => token.groups["times"] !== void 0 || token.groups["clock"] !== void 0);
|
|
629
|
+
const above = at > 0 ? perLine[at - 1] : [];
|
|
630
|
+
const service = !weekly && value !== void 0 && SERVICE.test(line.slice(0, value.start)) && above.some((token) => token.days !== void 0) && above.some((token) => token.groups["times"] !== void 0 || token.groups["closed"] !== void 0);
|
|
631
|
+
return date !== null && (value === void 0 || date.index < value.start) || !weekly && OCCASION.test(line) || service;
|
|
632
|
+
});
|
|
633
|
+
const tokens = [];
|
|
634
|
+
const breaks = /* @__PURE__ */ new Set();
|
|
635
|
+
perLine.forEach((onLine, at) => {
|
|
636
|
+
if (oneOff[at]) {
|
|
637
|
+
partial = true;
|
|
638
|
+
breaks.add(at);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const line = lines[current.lines[at]];
|
|
642
|
+
const dayTokens = onLine.filter((token) => token.days !== void 0);
|
|
643
|
+
const broad = dayTokens.length >= 2 || dayTokens.some((token) => token.spec < 2) || heading || /^(?:hours|open)\b/i.test(line);
|
|
644
|
+
const daysOnlyAbove = at > 0 && !breaks.has(at - 1) && perLine[at - 1].length > 0 && perLine[at - 1].every((token) => token.days !== void 0);
|
|
645
|
+
onLine.forEach((token, index) => {
|
|
646
|
+
const times = token.groups["times"];
|
|
647
|
+
if (times !== void 0 && !isStrong(times) && !broad) {
|
|
648
|
+
const before = onLine[index - 1];
|
|
649
|
+
const after = onLine[index + 1];
|
|
650
|
+
if (!(before?.days !== void 0 && JOINERS.test(line.slice(before.end, token.start)) || after?.days !== void 0 && JOINERS.test(line.slice(token.end, after.start)) || index === 0 && daysOnlyAbove && JOINERS.test(line.slice(0, token.start)))) return;
|
|
651
|
+
}
|
|
652
|
+
tokens.push(token);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
const isDays = (token) => token?.days !== void 0;
|
|
656
|
+
const isValue = (token) => token.groups["times"] !== void 0 || token.groups["closed"] !== void 0 || token.groups["allday"] !== void 0;
|
|
657
|
+
const dangling = /* @__PURE__ */ new Set();
|
|
658
|
+
perLine.forEach((onLine, at) => {
|
|
659
|
+
const last = onLine.at(-1);
|
|
660
|
+
if (last?.days === void 0) return;
|
|
661
|
+
const line = lines[current.lines[at]];
|
|
662
|
+
if (!JOINERS.test(line.slice(last.end))) dangling.add(at);
|
|
663
|
+
});
|
|
664
|
+
const valueFirst = /* @__PURE__ */ new Set();
|
|
665
|
+
const firstOnLine = /* @__PURE__ */ new Map();
|
|
666
|
+
const lineHasDays = /* @__PURE__ */ new Set();
|
|
667
|
+
for (const token of tokens) {
|
|
668
|
+
if (isDays(token)) lineHasDays.add(token.line);
|
|
669
|
+
if (!firstOnLine.has(token.line) && (isDays(token) || isValue(token))) firstOnLine.set(token.line, token);
|
|
670
|
+
}
|
|
671
|
+
for (const [line, first] of firstOnLine) if (isValue(first) && lineHasDays.has(line)) valueFirst.add(line);
|
|
672
|
+
const apply = (value, days, spec, explicit) => {
|
|
673
|
+
for (const day of days) {
|
|
674
|
+
if (value === "closed") {
|
|
675
|
+
if (!explicit) {
|
|
676
|
+
if (week[day] === void 0) {
|
|
677
|
+
week[day] = [];
|
|
678
|
+
closedBy.set(day, -1);
|
|
679
|
+
}
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if ((openedBy.get(day) ?? -1) > spec) continue;
|
|
683
|
+
week[day] = [];
|
|
684
|
+
openedBy.delete(day);
|
|
685
|
+
closedBy.set(day, Math.max(closedBy.get(day) ?? -1, spec));
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const closed = closedBy.get(day);
|
|
689
|
+
if (closed !== void 0 && closed >= spec) continue;
|
|
690
|
+
if (closed !== void 0) {
|
|
691
|
+
week[day] = [];
|
|
692
|
+
closedBy.delete(day);
|
|
693
|
+
}
|
|
694
|
+
addInterval(week, [day], value);
|
|
695
|
+
openedBy.set(day, Math.max(openedBy.get(day) ?? -1, spec));
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
const valueOf = (token) => {
|
|
699
|
+
if (token.groups["closed"] !== void 0) return {
|
|
700
|
+
value: "closed",
|
|
701
|
+
inferred: false
|
|
702
|
+
};
|
|
703
|
+
if (token.groups["allday"] !== void 0) return {
|
|
704
|
+
value: ALL_DAY,
|
|
705
|
+
inferred: false
|
|
706
|
+
};
|
|
707
|
+
const parts = token.groups["times"] === void 0 ? void 0 : splitRange(token.groups["times"]);
|
|
708
|
+
const range = parts === void 0 ? void 0 : readRange(parts[0], parts[1]);
|
|
709
|
+
return range === void 0 ? void 0 : {
|
|
710
|
+
value: range.interval,
|
|
711
|
+
inferred: range.inferred
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
/** This function gives the tokens of one kind in sequence from `start`, each on a separate line. */
|
|
715
|
+
const runOf = (start, kind) => {
|
|
716
|
+
let end = start;
|
|
717
|
+
while (tokens[end + 1] !== void 0 && kind(tokens[end + 1]) && tokens[end + 1].line !== tokens[end].line && !breaks.has(tokens[end + 1].line - 1)) end += 1;
|
|
718
|
+
return end;
|
|
719
|
+
};
|
|
720
|
+
let currentDays = [];
|
|
721
|
+
let currentSpec = 2;
|
|
722
|
+
let lastWasDay = false;
|
|
723
|
+
let pending = [];
|
|
724
|
+
let excepting = false;
|
|
725
|
+
let previous;
|
|
726
|
+
for (let at = 0; at < tokens.length; at += 1) {
|
|
727
|
+
const token = tokens[at];
|
|
728
|
+
if (previous !== void 0 && token.line !== previous.line) {
|
|
729
|
+
excepting = false;
|
|
730
|
+
let broken = dangling.has(previous.line) && lastWasDay;
|
|
731
|
+
for (let line = previous.line; line < token.line && !broken; line += 1) broken = breaks.has(line);
|
|
732
|
+
if (broken) {
|
|
733
|
+
currentDays = [];
|
|
734
|
+
lastWasDay = false;
|
|
735
|
+
pending = [];
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
previous = token;
|
|
739
|
+
if (token.groups["except"] !== void 0) {
|
|
740
|
+
excepting = true;
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const days = token.days;
|
|
744
|
+
if (days !== void 0) {
|
|
745
|
+
if (excepting) {
|
|
746
|
+
apply("closed", days, 2, true);
|
|
747
|
+
currentDays = currentDays.filter((day) => !days.includes(day));
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (!lastWasDay && pending.length === 0) {
|
|
751
|
+
const lastDay = runOf(at, isDays);
|
|
752
|
+
const firstValue = tokens[lastDay + 1];
|
|
753
|
+
if (lastDay > at && firstValue !== void 0 && isValue(firstValue) && firstValue.line !== tokens[lastDay].line) {
|
|
754
|
+
const lastValue = runOf(lastDay + 1, isValue);
|
|
755
|
+
const dayRun = tokens.slice(at, lastDay + 1);
|
|
756
|
+
const valueRun = tokens.slice(lastDay + 1, lastValue + 1);
|
|
757
|
+
if (valueRun.length >= 2) {
|
|
758
|
+
if (valueRun.length === dayRun.length) dayRun.forEach((dayToken, index) => {
|
|
759
|
+
const read = valueOf(valueRun[index]);
|
|
760
|
+
if (read === void 0) return;
|
|
761
|
+
inferred ||= read.inferred;
|
|
762
|
+
apply(read.value, dayToken.days, dayToken.spec, true);
|
|
763
|
+
});
|
|
764
|
+
at = lastValue;
|
|
765
|
+
previous = tokens[at];
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
currentDays = lastWasDay ? [.../* @__PURE__ */ new Set([...currentDays, ...days])] : [...days];
|
|
771
|
+
currentSpec = lastWasDay ? Math.min(currentSpec, token.spec) : token.spec;
|
|
772
|
+
lastWasDay = true;
|
|
773
|
+
const next = tokens[at + 1];
|
|
774
|
+
if (pending.length > 0 && !(isDays(next) && next.line === token.line)) {
|
|
775
|
+
for (const read of pending) {
|
|
776
|
+
inferred ||= read.inferred;
|
|
777
|
+
apply(read.value, currentDays, currentSpec, true);
|
|
778
|
+
}
|
|
779
|
+
pending = [];
|
|
780
|
+
lastWasDay = false;
|
|
781
|
+
}
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
excepting = false;
|
|
785
|
+
const wasDay = lastWasDay;
|
|
786
|
+
lastWasDay = false;
|
|
787
|
+
let read = isValue(token) ? valueOf(token) : void 0;
|
|
788
|
+
if (read === void 0 && token.groups["clock"] !== void 0 && wasDay) {
|
|
789
|
+
const close = tokens[at + 1]?.groups["clock"];
|
|
790
|
+
const range = close === void 0 ? void 0 : readRange(token.groups["clock"], close);
|
|
791
|
+
if (range !== void 0) {
|
|
792
|
+
read = {
|
|
793
|
+
value: range.interval,
|
|
794
|
+
inferred: range.inferred
|
|
795
|
+
};
|
|
796
|
+
at += 1;
|
|
797
|
+
previous = tokens[at];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (read === void 0) continue;
|
|
801
|
+
const next = tokens[at + 1];
|
|
802
|
+
if (isDays(next) && next.line === tokens[at].line && (valueFirst.has(token.line) || read.value === "closed" && !wasDay)) {
|
|
803
|
+
pending.push(read);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (currentDays.length > 0) {
|
|
807
|
+
inferred ||= read.inferred;
|
|
808
|
+
apply(read.value, currentDays, currentSpec, wasDay);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (Object.keys(week).length === 0 && !tokens.some(isDays)) {
|
|
812
|
+
const ranges = tokens.filter((token) => token.groups["times"] !== void 0 || token.groups["allday"] !== void 0);
|
|
813
|
+
const body = current.lines.map((index) => lines[index]);
|
|
814
|
+
const opens = !/\b(today|tonight|now)\b/i.test(`${above} ${body.join(" ")}`) && (heading || /^open\b/i.test(body[0] ?? ""));
|
|
815
|
+
const said = new Set(ranges.map((token) => (token.groups["times"] ?? "24h").replace(/\s+/g, "")));
|
|
816
|
+
const first = ranges[0] === void 0 ? void 0 : valueOf(ranges[0]);
|
|
817
|
+
if (opens && said.size === 1 && first !== void 0 && first.value !== "closed") {
|
|
818
|
+
addInterval(week, DAYS, first.value);
|
|
819
|
+
inferred = !/24 ?\/ ?7/.test(ranges[0].groups["allday"] ?? "");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (Object.keys(week).length === 0) return;
|
|
823
|
+
const season = current.season ?? (label !== void 0 ? seasonOf(above) : void 0);
|
|
824
|
+
out.push({
|
|
825
|
+
week,
|
|
826
|
+
from: "text",
|
|
827
|
+
raw: current.lines.map((index) => original[index]).join("\n"),
|
|
828
|
+
inferred,
|
|
829
|
+
...partial ? { partial } : {},
|
|
830
|
+
...season !== void 0 ? { season } : {},
|
|
831
|
+
label: label !== void 0 && label.length > 0 && label.length <= 40 ? label : null
|
|
832
|
+
});
|
|
833
|
+
};
|
|
834
|
+
lines.forEach((line, index) => {
|
|
835
|
+
const tokens = tokensOf(line, 0);
|
|
836
|
+
const names = tokens.some((token) => token.days !== void 0 || token.groups["closed"] !== void 0 || token.groups["allday"] !== void 0);
|
|
837
|
+
const clock = tokens.some((token) => token.groups["clock"] !== void 0 || token.groups["times"] !== void 0 && isStrong(token.groups["times"]));
|
|
838
|
+
const ranges = tokens.filter((token) => token.groups["times"] !== void 0).length;
|
|
839
|
+
if ((names || clock) && (line.length <= 160 || ranges >= 2)) {
|
|
840
|
+
const season = seasonOf(line);
|
|
841
|
+
const cut = block !== void 0 && (season !== void 0 || block.lines.length >= MAX_BLOCK_LINES);
|
|
842
|
+
if (cut) flush();
|
|
843
|
+
block ??= {
|
|
844
|
+
start: index,
|
|
845
|
+
lines: [],
|
|
846
|
+
season,
|
|
847
|
+
cut
|
|
848
|
+
};
|
|
849
|
+
block.lines.push(index);
|
|
850
|
+
} else flush();
|
|
851
|
+
});
|
|
852
|
+
flush();
|
|
853
|
+
return out;
|
|
854
|
+
};
|
|
855
|
+
const LABEL = {
|
|
856
|
+
mon: "Mon",
|
|
857
|
+
tue: "Tue",
|
|
858
|
+
wed: "Wed",
|
|
859
|
+
thu: "Thu",
|
|
860
|
+
fri: "Fri",
|
|
861
|
+
sat: "Sat",
|
|
862
|
+
sun: "Sun"
|
|
863
|
+
};
|
|
864
|
+
const sameDay = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
865
|
+
/** This function writes a week as text, for example `Mon–Thu 11:00–22:00, Fri–Sat 11:00–23:00`. */
|
|
866
|
+
const describeWeek = (week) => {
|
|
867
|
+
const parts = [];
|
|
868
|
+
let index = 0;
|
|
869
|
+
while (index < 7) {
|
|
870
|
+
const day = DAYS[index];
|
|
871
|
+
const value = week[day];
|
|
872
|
+
let end = index;
|
|
873
|
+
while (end + 1 < 7 && sameDay(week[DAYS[end + 1]], value)) end += 1;
|
|
874
|
+
const span = end === index ? LABEL[day] : `${LABEL[day]}–${LABEL[DAYS[end]]}`;
|
|
875
|
+
if (value !== void 0) parts.push(value.length === 0 ? `${span} closed` : `${span} ${value.map(([open, close]) => open === "00:00" && close === "00:00" ? "open 24 hours" : `${open}–${close}`).join(", ")}`);
|
|
876
|
+
index = end + 1;
|
|
877
|
+
}
|
|
878
|
+
return parts.join("; ");
|
|
879
|
+
};
|
|
880
|
+
/**
|
|
881
|
+
* This function gives one interval for each day (`hours_week`), and null for a closed day. One
|
|
882
|
+
* interval cannot show a day with two intervals and a gap between them. Thus such a day is not in
|
|
883
|
+
* `hours_week`, and `split` names it.
|
|
884
|
+
*/
|
|
885
|
+
const toHoursWeek = (week) => {
|
|
886
|
+
const hoursWeek = {};
|
|
887
|
+
const split = [];
|
|
888
|
+
for (const day of DAYS) {
|
|
889
|
+
const value = week[day];
|
|
890
|
+
if (value === void 0) continue;
|
|
891
|
+
if (value.length === 0) hoursWeek[day] = null;
|
|
892
|
+
else if (value.length === 1) hoursWeek[day] = value[0];
|
|
893
|
+
else split.push(day);
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
hours_week: hoursWeek,
|
|
897
|
+
split
|
|
898
|
+
};
|
|
899
|
+
};
|
|
900
|
+
/** This function gives the days where two readings disagree, from the days that the two readings state. */
|
|
901
|
+
const disagreements = (a, b) => DAYS.filter((day) => a[day] !== void 0 && b[day] !== void 0 && !sameDay(a[day], b[day]));
|
|
902
|
+
//#endregion
|
|
903
|
+
export { DAYS, dayRange, describeWeek, disagreements, fromCompact, fromSchemaOrg, fromText, readRange, toHoursWeek };
|
|
904
|
+
|
|
905
|
+
//# sourceMappingURL=hours.mjs.map
|