@bluessu/meal-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/dist/index.cjs ADDED
@@ -0,0 +1,953 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ BaseCafeteriaException: () => BaseCafeteriaException,
34
+ CafeteriaType: () => CafeteriaType,
35
+ HolidayException: () => HolidayException,
36
+ MealClient: () => MealClient,
37
+ MenuFetchException: () => MenuFetchException,
38
+ MenuParseException: () => MenuParseException,
39
+ buildDateRange: () => buildDateRange,
40
+ createMealClient: () => createMealClient,
41
+ defaultSettings: () => defaultSettings,
42
+ normalizeMenuDate: () => normalizeMenuDate
43
+ });
44
+ module.exports = __toCommonJS(index_exports);
45
+
46
+ // src/errors.ts
47
+ var BaseCafeteriaException = class extends Error {
48
+ constructor(targetDate, cafeteria, message, rawData, context = {}) {
49
+ const withContext = Object.keys(context).length > 0 ? `${message} | context=${JSON.stringify(context)}` : message;
50
+ super(`${cafeteria}(${targetDate}) ${withContext}`);
51
+ this.targetDate = targetDate;
52
+ this.cafeteria = cafeteria;
53
+ this.rawData = rawData;
54
+ this.name = this.constructor.name;
55
+ this.context = context;
56
+ }
57
+ };
58
+ var HolidayException = class extends BaseCafeteriaException {
59
+ };
60
+ var MenuFetchException = class extends BaseCafeteriaException {
61
+ };
62
+ var MenuParseException = class extends BaseCafeteriaException {
63
+ };
64
+
65
+ // src/domain.ts
66
+ var CafeteriaType = /* @__PURE__ */ ((CafeteriaType2) => {
67
+ CafeteriaType2["HAKSIK"] = "HAKSIK";
68
+ CafeteriaType2["DODAM"] = "DODAM";
69
+ CafeteriaType2["FACULTY"] = "FACULTY";
70
+ CafeteriaType2["DORMITORY"] = "DORMITORY";
71
+ return CafeteriaType2;
72
+ })(CafeteriaType || {});
73
+ var CafeteriaStatus = {
74
+ Open: "open",
75
+ Closed: "closed"
76
+ };
77
+ var normalizeMenuSlot = (slot) => {
78
+ const normalized = String(slot ?? "").replace(/\s+/g, "").trim();
79
+ if (normalized.includes("\uC870\uC2DD") || normalized.includes("\uC544\uCE68") || normalized.includes("breakfast")) {
80
+ return "breakfast";
81
+ }
82
+ if (normalized.includes("\uC911\uC2DD") || normalized.includes("\uC810\uC2EC") || normalized.includes("lunch")) {
83
+ return "lunch";
84
+ }
85
+ if (normalized.includes("\uC11D\uC2DD") || normalized.includes("\uC800\uB141") || normalized.includes("dinner")) {
86
+ return "dinner";
87
+ }
88
+ return void 0;
89
+ };
90
+ var createDailyMenu = (date, cafeteria, menus, status = CafeteriaStatus.Open) => {
91
+ return { date, cafeteria, status, ...menus };
92
+ };
93
+
94
+ // src/parsers/noopMenuParser.ts
95
+ var splitItems = (text) => text.split(/[\n\/,]/).flatMap((line) => line.split("&")).flatMap((line) => line.split(" ")).map((s) => s.replace(/\*/g, "").trim()).filter(Boolean);
96
+ var NoopMenuParser = class {
97
+ async parseMenu(raw) {
98
+ try {
99
+ const dailyMenu = createDailyMenu(raw.date, raw.cafeteria, {
100
+ breakfast: {},
101
+ lunch: {},
102
+ dinner: {}
103
+ });
104
+ for (const [slot, text] of Object.entries(raw.menuTexts)) {
105
+ const items = splitItems(text).filter((item) => /[가-힣]/.test(item));
106
+ const slotKey = normalizeMenuSlot(slot);
107
+ if (!slotKey) continue;
108
+ dailyMenu[slotKey] = {
109
+ ...dailyMenu[slotKey],
110
+ [slot]: [...new Set(items)]
111
+ };
112
+ }
113
+ return dailyMenu;
114
+ } catch (err) {
115
+ throw new MenuParseException(
116
+ raw.date,
117
+ raw.cafeteria,
118
+ "\uAE30\uBCF8 \uD30C\uC2F1 \uC2E4\uD328",
119
+ err
120
+ );
121
+ }
122
+ }
123
+ };
124
+
125
+ // src/parsers/gptMenuParser.ts
126
+ var SYSTEM_PROMPT = `\uB2F9\uC2E0\uC740 \uD55C\uAD6D \uB300\uD559 \uC2DD\uB2F9 \uBA54\uB274 \uB370\uC774\uD130\uB97C \uC815\uD655\uD558\uAC8C \uD30C\uC2F1\uD558\uB294 \uC804\uBB38\uAC00\uC785\uB2C8\uB2E4.
127
+ - \uBA54\uB274\uBA85\uB9CC \uCD94\uCD9C\uD574\uC11C \uC544\uB798 JSON \uD615\uC2DD\uC73C\uB85C \uBC18\uD658:
128
+ { "menus": ["\uBA54\uB274\uBA851", "\uBA54\uB274\uBA852"] }
129
+ - \uBA54\uB274\uBA85\uC740 \uD55C\uAE00\uB9CC \uC0AC\uC6A9
130
+ - \uBD88\uD544\uC694\uD55C \uC218\uC2DD\uC5B4 (\uC608: "\uB9DB\uC788\uB294", "\uC2E0\uC120\uD55C")\uB294 \uC81C\uAC70
131
+ - \uC911\uBCF5\uB41C \uBA54\uB274\uBA85\uC740 \uD55C \uBC88\uB9CC \uC791\uC131
132
+ - \uBA54\uB274\uBA85\uC774 \uC5C6\uB294 \uACBD\uC6B0 menus\uB294 \uBE48 \uBC30\uC5F4\uB85C \uBC18\uD658
133
+ - JSON \uC678 \uD14D\uC2A4\uD2B8\uB97C \uC808\uB300 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC74C
134
+ - \uBA54\uC778 \uBA54\uB274\uB97C \uAC00\uC7A5 \uC55E\uC5D0 \uBC30\uCE58
135
+ `;
136
+ var GPTMenuParser = class _GPTMenuParser {
137
+ static {
138
+ this.model = "gpt-5-nano";
139
+ }
140
+ constructor(apiKey) {
141
+ const openAI = this.resolveOpenAI(apiKey);
142
+ this.client = openAI;
143
+ }
144
+ async resolveOpenAI(apiKey) {
145
+ try {
146
+ const mod = await import("openai");
147
+ const OpenAIConstructor = mod.default ?? mod.OpenAI;
148
+ if (!OpenAIConstructor) {
149
+ throw new Error(
150
+ "openai \uBAA8\uB4C8\uC5D0\uC11C OpenAI \uD074\uB798\uC2A4 \uCD08\uAE30\uD654\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4."
151
+ );
152
+ }
153
+ return new OpenAIConstructor({ apiKey });
154
+ } catch (err) {
155
+ const isModuleNotFound = err instanceof Error && (err.message.includes("Cannot find module") || err.code === "ERR_MODULE_NOT_FOUND");
156
+ if (isModuleNotFound) {
157
+ throw new Error(
158
+ 'openai \uD328\uD0A4\uC9C0\uAC00 \uC124\uCE58\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. parser="gpt" \uC0AC\uC6A9 \uC2DC `pnpm add openai`\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.'
159
+ );
160
+ }
161
+ throw err;
162
+ }
163
+ }
164
+ sanitizeMenuName(menu) {
165
+ return String(menu).replace(/^[\s\-\*\u2022·•\d\.\)]+/g, "").replace(/\s+/g, " ").trim();
166
+ }
167
+ normalizeTextForParsing(text) {
168
+ const tokens = text.replace(/\s+/g, " ").trim().split(" ").filter((token) => token.length > 0);
169
+ if (tokens.length === 0) {
170
+ return "";
171
+ }
172
+ const maxPhraseLength = 12;
173
+ const normalized = [];
174
+ for (let i = 0; i < tokens.length; ) {
175
+ const remaining = tokens.length - i;
176
+ const maxLen = Math.min(maxPhraseLength, Math.floor(remaining / 2));
177
+ let matchedLen = 1;
178
+ for (let len = maxLen; len >= 1; len--) {
179
+ let isRepeated = true;
180
+ for (let j = 0; j < len; j++) {
181
+ if (tokens[i + j] !== tokens[i + len + j]) {
182
+ isRepeated = false;
183
+ break;
184
+ }
185
+ }
186
+ if (isRepeated) {
187
+ matchedLen = len;
188
+ break;
189
+ }
190
+ }
191
+ normalized.push(...tokens.slice(i, i + matchedLen));
192
+ i += matchedLen * 2;
193
+ }
194
+ return normalized.join(" ");
195
+ }
196
+ async parseMenuText(text) {
197
+ if (!text || text.trim().length === 0) {
198
+ return [];
199
+ }
200
+ const normalizedText = this.normalizeTextForParsing(text);
201
+ if (!normalizedText) {
202
+ return [];
203
+ }
204
+ const client = await this.client;
205
+ const result = await client.chat.completions.create({
206
+ model: _GPTMenuParser.model,
207
+ messages: [
208
+ { role: "system", content: SYSTEM_PROMPT },
209
+ {
210
+ role: "user",
211
+ content: `\uB2E4\uC74C \uD14D\uC2A4\uD2B8\uC5D0\uC11C \uBA54\uB274\uBA85\uB9CC \uCD94\uCD9C\uD558\uC138\uC694.
212
+
213
+ ${normalizedText}`
214
+ }
215
+ ],
216
+ response_format: { type: "json_object" }
217
+ });
218
+ if (!result?.choices || !result.choices.length) {
219
+ throw new Error("\uBAA8\uB378 \uC751\uB2F5 \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
220
+ }
221
+ const content = result.choices[0]?.message?.content;
222
+ if (!content) {
223
+ throw new Error("\uBAA8\uB378 \uC751\uB2F5\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.");
224
+ }
225
+ const parsed = JSON.parse(content);
226
+ const menus = parsed?.menus;
227
+ if (!Array.isArray(menus)) {
228
+ throw new Error("menus \uBC30\uC5F4\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
229
+ }
230
+ return menus.map((item) => this.sanitizeMenuName(String(item))).filter((item) => item.length > 0);
231
+ }
232
+ async parseMenu(raw) {
233
+ const menus = {
234
+ breakfast: {},
235
+ lunch: {},
236
+ dinner: {}
237
+ };
238
+ const errors = {};
239
+ await Promise.all(
240
+ Object.entries(raw.menuTexts).map(async ([slot, text]) => {
241
+ try {
242
+ const unique = /* @__PURE__ */ new Set();
243
+ const parsed = await this.parseMenuText(text);
244
+ const slotKey = normalizeMenuSlot(slot);
245
+ if (!slotKey) return;
246
+ parsed.forEach((menu) => unique.add(menu));
247
+ menus[slotKey][slot] = Array.from(unique);
248
+ } catch (err) {
249
+ errors[slot] = String(err);
250
+ }
251
+ })
252
+ );
253
+ if (Object.keys(errors).length > 0) {
254
+ throw new MenuParseException(
255
+ raw.date,
256
+ raw.cafeteria,
257
+ "\uC77C\uBD80 \uC2AC\uB86F \uD30C\uC2F1 \uC2E4\uD328",
258
+ JSON.stringify(errors)
259
+ );
260
+ }
261
+ return createDailyMenu(raw.date, raw.cafeteria, menus);
262
+ }
263
+ };
264
+
265
+ // src/repositories/scrapers/soongguriScraper.ts
266
+ var import_axios = __toESM(require("axios"));
267
+
268
+ // src/config.ts
269
+ var defaultSettings = {
270
+ soongguriBaseUrl: "http://m.soongguri.com/m_req/m_menu.php",
271
+ dormitoryBaseUrl: "https://ssudorm.ssu.ac.kr:444/SShostel/mall_main.php",
272
+ haksikRcd: 1,
273
+ dodamRcd: 2,
274
+ facultyRcd: 7,
275
+ timeoutMs: 15e3
276
+ };
277
+ var getRcd = (type, settings) => {
278
+ switch (type) {
279
+ case "HAKSIK" /* HAKSIK */:
280
+ return settings.haksikRcd;
281
+ case "DODAM" /* DODAM */:
282
+ return settings.dodamRcd;
283
+ case "FACULTY" /* FACULTY */:
284
+ return settings.facultyRcd;
285
+ default:
286
+ return 0;
287
+ }
288
+ };
289
+
290
+ // src/utils/parsing.ts
291
+ var cheerio = __toESM(require("cheerio"));
292
+ var normalizeText = (v) => v.replace(/\r/g, "").replace(/\n+/g, " ").replace(/\s+/g, " ").trim();
293
+ var parseTableToDict = (html) => {
294
+ const $ = cheerio.load(html);
295
+ const result = {};
296
+ const parseWithMenuClass = () => {
297
+ $("tr").each((_, tr) => {
298
+ const menuSlot = $(tr).find("td.menu_nm").first().text().trim();
299
+ if (!menuSlot) return;
300
+ const rowText = $(tr).find("*").contents().toArray().map((node) => $(node).text()).join(" ");
301
+ const cleaned = normalizeText(rowText);
302
+ result[menuSlot] = cleaned;
303
+ });
304
+ };
305
+ const parseFallbackRows = () => {
306
+ const slotKeywords = /조식|중식|석식|점심|저녁|아침/;
307
+ $("tr").each((_, tr) => {
308
+ const cells = $(tr).find("td, th").toArray();
309
+ if (cells.length < 2) return;
310
+ const key = normalizeText($(cells[0]).text());
311
+ if (!key || !slotKeywords.test(key)) return;
312
+ const values = cells.slice(1).map((cell) => normalizeText($(cell).text())).filter((value) => value.length > 0).join(" ");
313
+ if (!values) return;
314
+ result[key] = values;
315
+ });
316
+ };
317
+ parseWithMenuClass();
318
+ if (!Object.keys(result).length) {
319
+ parseFallbackRows();
320
+ }
321
+ return result;
322
+ };
323
+ var stripStringFromDict = (menuDict) => {
324
+ const out = {};
325
+ for (const [key, value] of Object.entries(menuDict)) {
326
+ out[key] = normalizeText(value);
327
+ }
328
+ return out;
329
+ };
330
+ var make2d = (tableHtml) => {
331
+ if (!tableHtml) return [];
332
+ let $ = cheerio.load(tableHtml);
333
+ let rows = $("tr");
334
+ if (!rows.length) {
335
+ $ = cheerio.load(`<table>${tableHtml}</table>`);
336
+ rows = $("tr");
337
+ }
338
+ const matrix = [];
339
+ const toSpan = (value) => {
340
+ const v = Number.parseInt(value ?? "1", 10);
341
+ return Number.isNaN(v) || v < 1 ? 1 : v;
342
+ };
343
+ rows.each((rIdx, tr) => {
344
+ const rowCells = $(tr).children("th,td");
345
+ if (!matrix[rIdx]) matrix[rIdx] = [];
346
+ let cIdx = 0;
347
+ rowCells.each((_, cell) => {
348
+ while (matrix[rIdx][cIdx] !== void 0) cIdx += 1;
349
+ const txt = normalizeText($(cell).text());
350
+ const colspan = toSpan($(cell).attr("colspan"));
351
+ const rowspan = toSpan($(cell).attr("rowspan"));
352
+ for (let cc = 0; cc < colspan; cc++) {
353
+ matrix[rIdx][cIdx + cc] = txt;
354
+ }
355
+ for (let rr = 1; rr < rowspan; rr++) {
356
+ const targetRow = rIdx + rr;
357
+ if (!matrix[targetRow]) matrix[targetRow] = [];
358
+ for (let cc = 0; cc < colspan; cc++) {
359
+ matrix[targetRow][cIdx + cc] = txt;
360
+ }
361
+ }
362
+ cIdx += colspan;
363
+ });
364
+ });
365
+ return matrix.map((row) => row.map((v) => normalizeText(v ?? "")));
366
+ };
367
+ var make2dFromHtml = (html) => {
368
+ const $ = cheerio.load(html);
369
+ const table = $("table.boxstyle02").first();
370
+ if (!table.length) return [];
371
+ const tableHtml = table.html();
372
+ if (!tableHtml) return [];
373
+ return make2d(tableHtml);
374
+ };
375
+
376
+ // src/repositories/scrapers/soongguriScraper.ts
377
+ var SoongguriScraper = class {
378
+ constructor(settings, cafeteriaType) {
379
+ this.settings = settings;
380
+ this.cafeteriaType = cafeteriaType;
381
+ }
382
+ async scrapeMenu(date) {
383
+ const normalizedDate = normalizeSgDate(date);
384
+ const url = `${this.settings.soongguriBaseUrl}?rcd=${getRcd(this.cafeteriaType, this.settings)}&sdt=${normalizedDate}`;
385
+ try {
386
+ const res = await import_axios.default.get(url, {
387
+ timeout: this.settings.timeoutMs,
388
+ responseType: "text",
389
+ validateStatus: (s) => s >= 200 && s < 300
390
+ });
391
+ const html = String(res.data);
392
+ const hasHoliday = html.includes("\uC624\uB298\uC740 \uC27D\uB2C8\uB2E4.") || html.includes("\uD734\uBB34");
393
+ if (hasHoliday) {
394
+ throw new HolidayException(
395
+ date,
396
+ this.cafeteriaType,
397
+ "\uD574\uB2F9\uC77C\uC740 \uD734\uBB34\uC77C\uC785\uB2C8\uB2E4.",
398
+ html,
399
+ {
400
+ endpoint: url,
401
+ operation: "scrape",
402
+ cafeteria: this.cafeteriaType,
403
+ timeoutMs: this.settings.timeoutMs
404
+ }
405
+ );
406
+ }
407
+ const parsed = parseTableToDict(html);
408
+ const menus = stripStringFromDict(parsed);
409
+ if (!Object.keys(menus).length) {
410
+ throw new MenuFetchException(
411
+ date,
412
+ this.cafeteriaType,
413
+ "\uBA54\uB274\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
414
+ { menus, html },
415
+ {
416
+ endpoint: url,
417
+ operation: "parse",
418
+ cafeteria: this.cafeteriaType,
419
+ timeoutMs: this.settings.timeoutMs
420
+ }
421
+ );
422
+ }
423
+ return {
424
+ date,
425
+ cafeteria: this.cafeteriaType,
426
+ menuTexts: menus
427
+ };
428
+ } catch (err) {
429
+ if (err instanceof HolidayException) {
430
+ throw err;
431
+ }
432
+ if (err instanceof BaseCafeteriaException) {
433
+ throw err;
434
+ }
435
+ const raw = err;
436
+ const statusCode = raw?.status ?? raw?.response?.status;
437
+ const statusText = raw?.response?.statusText;
438
+ throw new MenuFetchException(
439
+ date,
440
+ this.cafeteriaType,
441
+ "\uBA54\uB274 \uC218\uC9D1 \uC2E4\uD328",
442
+ err,
443
+ {
444
+ endpoint: url,
445
+ cafeteria: this.cafeteriaType,
446
+ statusCode,
447
+ statusText,
448
+ timeoutMs: this.settings.timeoutMs
449
+ }
450
+ );
451
+ }
452
+ }
453
+ };
454
+ var normalizeSgDate = (date) => {
455
+ const digits = date.replace(/\D/g, "").slice(0, 8);
456
+ return digits.length === 8 ? digits : date;
457
+ };
458
+
459
+ // src/repositories/scrapers/haksikScraper.ts
460
+ var HaksikScraper = class extends SoongguriScraper {
461
+ constructor(settings) {
462
+ super(settings, "HAKSIK" /* HAKSIK */);
463
+ }
464
+ };
465
+
466
+ // src/repositories/scrapers/dodamScraper.ts
467
+ var DodamScraper = class extends SoongguriScraper {
468
+ constructor(settings) {
469
+ super(settings, "DODAM" /* DODAM */);
470
+ }
471
+ };
472
+
473
+ // src/repositories/scrapers/facultyScraper.ts
474
+ var FacultyScraper = class extends SoongguriScraper {
475
+ constructor(settings) {
476
+ super(settings, "FACULTY" /* FACULTY */);
477
+ }
478
+ };
479
+
480
+ // src/repositories/scrapers/dormitoryScraper.ts
481
+ var import_axios2 = __toESM(require("axios"));
482
+ var DormitoryScraper = class {
483
+ constructor(baseUrl, timeoutMs = 15e3) {
484
+ this.baseUrl = baseUrl;
485
+ this.timeoutMs = timeoutMs;
486
+ }
487
+ async scrapeMenu(date) {
488
+ const dt = parseDate(date);
489
+ const targetDate = formatDateKey(dt);
490
+ const endpoint = this.baseUrl;
491
+ try {
492
+ const res = await import_axios2.default.get(this.baseUrl, {
493
+ params: {
494
+ viewform: "B0001_foodboard_list",
495
+ gyear: dt.getFullYear(),
496
+ gmonth: dt.getMonth() + 1,
497
+ gday: dt.getDate()
498
+ },
499
+ timeout: this.timeoutMs,
500
+ responseType: "arraybuffer",
501
+ validateStatus: (s) => s >= 200 && s < 300
502
+ });
503
+ const bytes = new Uint8Array(res.data);
504
+ const encoding = detectEncoding({
505
+ data: res.data,
506
+ headers: res.headers
507
+ });
508
+ const html = new TextDecoder(encoding).decode(bytes);
509
+ const matrix = make2dFromHtml(html);
510
+ const parsedRows = structureRows(matrix);
511
+ let matched;
512
+ for (const row of parsedRows) {
513
+ const dateStr = parseDateTokenKey(row["\uB0A0\uC9DC"]);
514
+ if (!dateStr) continue;
515
+ const menuTexts = extractMenuTexts(row);
516
+ if (Object.keys(menuTexts).length === 0) continue;
517
+ if (dateStr !== targetDate) continue;
518
+ matched = {
519
+ date: dateStr,
520
+ cafeteria: "DORMITORY" /* DORMITORY */,
521
+ menuTexts
522
+ };
523
+ break;
524
+ }
525
+ if (!matched) {
526
+ throw new MenuFetchException(
527
+ date,
528
+ "DORMITORY" /* DORMITORY */,
529
+ "\uC694\uCCAD\uD55C \uB0A0\uC9DC\uC758 \uBA54\uB274\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4",
530
+ void 0,
531
+ {
532
+ endpoint,
533
+ operation: "match",
534
+ cafeteria: "DORMITORY" /* DORMITORY */
535
+ }
536
+ );
537
+ }
538
+ return matched;
539
+ } catch (err) {
540
+ if (err instanceof HolidayException) {
541
+ throw err;
542
+ }
543
+ if (err instanceof BaseCafeteriaException) {
544
+ throw err;
545
+ }
546
+ const raw = err;
547
+ const statusCode = raw?.status ?? raw?.response?.status;
548
+ const statusText = raw?.response?.statusText;
549
+ throw new MenuFetchException(
550
+ date,
551
+ "DORMITORY" /* DORMITORY */,
552
+ "\uAE30\uC219\uC0AC \uBA54\uB274 \uD30C\uC2F1 \uC2E4\uD328",
553
+ err,
554
+ {
555
+ endpoint,
556
+ operation: "parse",
557
+ cafeteria: "DORMITORY" /* DORMITORY */,
558
+ timeoutMs: this.timeoutMs,
559
+ targetDate,
560
+ statusCode,
561
+ statusText
562
+ }
563
+ );
564
+ }
565
+ }
566
+ };
567
+ var detectEncoding = (res) => {
568
+ const header = String(res.headers["content-type"] ?? "").toLowerCase();
569
+ const headerCharset = header.match(/charset=([a-z0-9-]+)/i)?.[1];
570
+ if (headerCharset) {
571
+ const lower = headerCharset.toLowerCase();
572
+ if (lower.includes("utf-8") || lower.includes("utf8")) return "utf-8";
573
+ if (lower.includes("euc-kr") || lower.includes("cp949")) return "euc-kr";
574
+ }
575
+ const latin1Text = new TextDecoder("latin1").decode(new Uint8Array(res.data));
576
+ const metaCharset = latin1Text.match(
577
+ /<meta[^>]*charset\s*=\s*([a-z0-9-]+)/i
578
+ )?.[1];
579
+ if (metaCharset) {
580
+ const lower = metaCharset.toLowerCase();
581
+ if (lower.includes("utf-8") || lower.includes("utf8")) return "utf-8";
582
+ if (lower.includes("euc-kr") || lower.includes("cp949")) return "euc-kr";
583
+ }
584
+ return "euc-kr";
585
+ };
586
+ var parseDate = (date) => {
587
+ if (/^\d{8}$/.test(date)) {
588
+ const year = Number(date.slice(0, 4));
589
+ const month = Number(date.slice(4, 6));
590
+ const day = Number(date.slice(6, 8));
591
+ return new Date(year, month - 1, day);
592
+ }
593
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
594
+ const [year, month, day] = date.split("-");
595
+ return new Date(
596
+ Number(year),
597
+ Number(month) - 1,
598
+ Number(day)
599
+ );
600
+ }
601
+ return new Date(date);
602
+ };
603
+ var formatDateKey = (date) => {
604
+ const year = date.getFullYear();
605
+ const month = String(date.getMonth() + 1).padStart(2, "0");
606
+ const day = String(date.getDate()).padStart(2, "0");
607
+ return `${year}-${month}-${day}`;
608
+ };
609
+ var parseDateTokenKey = (value) => {
610
+ if (!value) return "";
611
+ const trimmed = value.split(/\s+/)[0];
612
+ const withDash = trimmed.replace(/[.]/g, "-");
613
+ if (/^\d{4}-\d{2}-\d{2}$/.test(withDash)) {
614
+ return withDash;
615
+ }
616
+ const compact = withDash.replace(/-/g, "");
617
+ if (/^\d{8}$/.test(compact)) {
618
+ const year = Number(compact.slice(0, 4));
619
+ const month = Number(compact.slice(4, 6));
620
+ const day = Number(compact.slice(6, 8));
621
+ const parsed = new Date(year, month - 1, day);
622
+ if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
623
+ return "";
624
+ }
625
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
626
+ }
627
+ if (/^\d{4}$/.test(compact)) {
628
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
629
+ const month = Number(compact.slice(0, 2));
630
+ const day = Number(compact.slice(2, 4));
631
+ const parsed = new Date(year, month - 1, day);
632
+ if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
633
+ return "";
634
+ }
635
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
636
+ }
637
+ if (/^\d{2}-\d{2}$/.test(withDash)) {
638
+ const [monthPart, dayPart] = withDash.split("-");
639
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
640
+ const month = Number(monthPart);
641
+ const day = Number(dayPart);
642
+ const parsed = new Date(year, month - 1, day);
643
+ if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
644
+ return "";
645
+ }
646
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
647
+ }
648
+ return "";
649
+ };
650
+ var structureRows = (matrix) => {
651
+ if (!matrix.length) return [];
652
+ const headers = matrix[0];
653
+ const dateCol = headers.findIndex((h) => h === "\uB0A0\uC9DC");
654
+ if (dateCol < 0) return [];
655
+ const colMap = /* @__PURE__ */ new Map();
656
+ for (let i = 0; i < headers.length; i++) {
657
+ if (headers[i] === "\uC870\uC2DD" || headers[i] === "\uC911\uC2DD" || headers[i] === "\uC11D\uC2DD") {
658
+ colMap.set(i, headers[i]);
659
+ }
660
+ }
661
+ const out = [];
662
+ for (let r = 1; r < matrix.length; r++) {
663
+ const row = matrix[r];
664
+ if (!row || !row[dateCol]) continue;
665
+ const dict = { \uB0A0\uC9DC: row[dateCol] || "" };
666
+ for (const [idx, key] of colMap) {
667
+ dict[key] = row[idx] || "";
668
+ }
669
+ out.push(dict);
670
+ }
671
+ return out;
672
+ };
673
+ var extractMenuTexts = (row) => {
674
+ const out = {};
675
+ ["\uC911\uC2DD", "\uC11D\uC2DD"].forEach((slot) => {
676
+ const value = row[slot];
677
+ if (!value) return;
678
+ const items = value.split("\r\n").map((x) => x.trim()).filter((x) => x.length > 0 && !x.includes("\uC6B4\uC601"));
679
+ if (items.length > 0) {
680
+ out[slot] = items.join(" ");
681
+ }
682
+ });
683
+ return out;
684
+ };
685
+
686
+ // src/services/scrapingService.ts
687
+ var FoodScrapingService = class {
688
+ constructor(settings = defaultSettings, parser, parserMode = "noop") {
689
+ this.settings = settings;
690
+ this.parser = parser;
691
+ this.parserMode = parserMode;
692
+ }
693
+ createScraper(cafeteriaType) {
694
+ if (cafeteriaType === "HAKSIK" /* HAKSIK */) {
695
+ return new HaksikScraper(this.settings);
696
+ }
697
+ if (cafeteriaType === "DODAM" /* DODAM */) {
698
+ return new DodamScraper(this.settings);
699
+ }
700
+ if (cafeteriaType === "FACULTY" /* FACULTY */) {
701
+ return new FacultyScraper(this.settings);
702
+ }
703
+ if (cafeteriaType === "DORMITORY" /* DORMITORY */) {
704
+ return new DormitoryScraper(
705
+ this.settings.dormitoryBaseUrl,
706
+ this.settings.timeoutMs
707
+ );
708
+ }
709
+ throw new Error(`Unsupported cafeteria: ${cafeteriaType}`);
710
+ }
711
+ async scrapeRawMenu(cafeteriaType, date) {
712
+ try {
713
+ const scraper = this.createScraper(cafeteriaType);
714
+ return await scraper.scrapeMenu(date);
715
+ } catch (err) {
716
+ if (err instanceof BaseCafeteriaException) throw err;
717
+ throw new MenuFetchException(
718
+ date,
719
+ cafeteriaType,
720
+ "scrape \uC2E4\uD328",
721
+ err,
722
+ {
723
+ targetDate: date,
724
+ cafeteria: cafeteriaType,
725
+ operation: "scrape"
726
+ }
727
+ );
728
+ }
729
+ }
730
+ async scrapeAndParseMenu(cafeteriaType, date) {
731
+ try {
732
+ const raw = await this.scrapeRawMenu(cafeteriaType, date);
733
+ return await this.parse(raw);
734
+ } catch (err) {
735
+ if (err instanceof HolidayException) {
736
+ return createDailyMenu(
737
+ date,
738
+ cafeteriaType,
739
+ {
740
+ breakfast: {},
741
+ lunch: {},
742
+ dinner: {}
743
+ },
744
+ CafeteriaStatus.Closed
745
+ );
746
+ }
747
+ throw err;
748
+ }
749
+ }
750
+ async parse(raw) {
751
+ try {
752
+ return await this.parser.parseMenu(raw);
753
+ } catch (err) {
754
+ if (err instanceof BaseCafeteriaException) {
755
+ err.context = {
756
+ ...err.context,
757
+ parserMode: this.parserMode,
758
+ operation: "parse",
759
+ targetDate: raw.date,
760
+ cafeteria: raw.cafeteria
761
+ };
762
+ throw err;
763
+ }
764
+ throw new MenuParseException(
765
+ raw.date,
766
+ raw.cafeteria,
767
+ "parse \uC2E4\uD328",
768
+ err,
769
+ {
770
+ parserMode: this.parserMode,
771
+ operation: "parse",
772
+ targetDate: raw.date,
773
+ cafeteria: raw.cafeteria
774
+ }
775
+ );
776
+ }
777
+ }
778
+ };
779
+
780
+ // src/client/dateUtils.ts
781
+ var MENU_DATE_COMPACT_RE = /^\d{8}$/;
782
+ var MENU_DATE_ISO_RE = /^\d{4}-\d{2}-\d{2}$/;
783
+ var toDate = (value) => {
784
+ if (MENU_DATE_COMPACT_RE.test(value)) {
785
+ const year = Number(value.slice(0, 4));
786
+ const month = Number(value.slice(4, 6));
787
+ const day = Number(value.slice(6, 8));
788
+ const date = new Date(year, month - 1, day);
789
+ const isValidDate = date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
790
+ if (!isValidDate) {
791
+ throw new RangeError(`invalid menu date: ${value}`);
792
+ }
793
+ return date;
794
+ }
795
+ if (MENU_DATE_ISO_RE.test(value)) {
796
+ const [y, m, d] = value.split("-");
797
+ const year = Number(y);
798
+ const month = Number(m);
799
+ const day = Number(d);
800
+ const date = new Date(year, month - 1, day);
801
+ const isValidDate = date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
802
+ if (!isValidDate) {
803
+ throw new RangeError(`invalid menu date: ${value}`);
804
+ }
805
+ return date;
806
+ }
807
+ throw new RangeError(`invalid menu date: ${value}`);
808
+ };
809
+ var normalizeMenuDate = (input) => {
810
+ let date;
811
+ if (input instanceof Date) {
812
+ date = input;
813
+ } else if (typeof input === "string") {
814
+ if (MENU_DATE_COMPACT_RE.test(input) || MENU_DATE_ISO_RE.test(input)) {
815
+ date = toDate(input);
816
+ } else {
817
+ date = new Date(input);
818
+ }
819
+ } else {
820
+ throw new TypeError("menu date must be a Date or string");
821
+ }
822
+ if (Number.isNaN(date.getTime())) {
823
+ throw new RangeError(`invalid menu date: ${String(input)}`);
824
+ }
825
+ const y = date.getFullYear();
826
+ const m = String(date.getMonth() + 1).padStart(2, "0");
827
+ const d = String(date.getDate()).padStart(2, "0");
828
+ return `${y}-${m}-${d}`;
829
+ };
830
+ var buildDateRange = (start, end) => {
831
+ const parse = (value) => {
832
+ if (!MENU_DATE_COMPACT_RE.test(value) && !MENU_DATE_ISO_RE.test(value)) {
833
+ throw new RangeError(`invalid menu date: ${value}`);
834
+ }
835
+ return toDate(value);
836
+ };
837
+ const startDate = parse(start);
838
+ const endDate = parse(end);
839
+ if (startDate > endDate) {
840
+ throw new RangeError(`start date must not be after end date: ${start} ~ ${end}`);
841
+ }
842
+ const out = [];
843
+ const cursor = new Date(startDate);
844
+ while (cursor <= endDate) {
845
+ out.push(normalizeMenuDate(cursor));
846
+ cursor.setDate(cursor.getDate() + 1);
847
+ }
848
+ return out;
849
+ };
850
+
851
+ // src/client/concurrency.ts
852
+ var runWithConcurrency = async (tasks, concurrency) => {
853
+ const limit = Math.max(1, Math.floor(concurrency) || 1);
854
+ const results = new Array(tasks.length);
855
+ let cursor = 0;
856
+ const worker = async () => {
857
+ while (true) {
858
+ const current = cursor;
859
+ cursor += 1;
860
+ if (current >= tasks.length) {
861
+ return;
862
+ }
863
+ results[current] = await tasks[current]();
864
+ }
865
+ };
866
+ await Promise.all(
867
+ Array.from({ length: Math.min(limit, tasks.length) }, () => worker())
868
+ );
869
+ return results;
870
+ };
871
+
872
+ // src/client/mealClient.ts
873
+ var DEFAULT_CONCURRENCY = 3;
874
+ var MealClient = class {
875
+ constructor(options = {}) {
876
+ const settings = {
877
+ ...defaultSettings,
878
+ ...options.settings ?? {}
879
+ };
880
+ let parser;
881
+ const parserMode = options.parser ?? "noop";
882
+ if (parserMode === "gpt") {
883
+ if (!options.gptApiKey) {
884
+ throw new Error("gpt parser requires gptApiKey");
885
+ }
886
+ parser = new GPTMenuParser(options.gptApiKey);
887
+ } else if (parserMode === "custom") {
888
+ if (!options.parserImpl) {
889
+ throw new Error("custom parser requires parserImpl");
890
+ }
891
+ parser = options.parserImpl;
892
+ } else {
893
+ parser = new NoopMenuParser();
894
+ }
895
+ this.service = new FoodScrapingService(settings, parser, parserMode);
896
+ }
897
+ getRawMenu(cafeteria, date) {
898
+ return this.service.scrapeRawMenu(cafeteria, normalizeMenuDate(date));
899
+ }
900
+ getDailyMenu(cafeteria, date) {
901
+ return this.service.scrapeAndParseMenu(cafeteria, normalizeMenuDate(date));
902
+ }
903
+ async getRawMenus(cafeteria, dates, options = {}) {
904
+ const normalizedDates = dates.map(normalizeMenuDate);
905
+ const concurrencyLimit = options.concurrency ?? DEFAULT_CONCURRENCY;
906
+ const tasks = normalizedDates.map(
907
+ (date) => () => this.service.scrapeRawMenu(cafeteria, date)
908
+ );
909
+ return runWithConcurrency(tasks, concurrencyLimit);
910
+ }
911
+ async getDailyMenus(cafeteria, dates, options = {}) {
912
+ const normalizedDates = dates.map(normalizeMenuDate);
913
+ const concurrencyLimit = options.concurrency ?? DEFAULT_CONCURRENCY;
914
+ const tasks = normalizedDates.map(
915
+ (date) => () => this.service.scrapeAndParseMenu(cafeteria, date)
916
+ );
917
+ return runWithConcurrency(tasks, concurrencyLimit);
918
+ }
919
+ async getRawMenusByRange(cafeteria, start, end, options = {}) {
920
+ const range = buildDateRange(
921
+ normalizeMenuDate(start),
922
+ normalizeMenuDate(end)
923
+ );
924
+ const startOffset = options.startInclusive === false ? 1 : 0;
925
+ const targetDates = range.slice(startOffset);
926
+ return this.getRawMenus(cafeteria, targetDates, options);
927
+ }
928
+ async getDailyMenusByRange(cafeteria, start, end, options = {}) {
929
+ const range = buildDateRange(
930
+ normalizeMenuDate(start),
931
+ normalizeMenuDate(end)
932
+ );
933
+ const startOffset = options.startInclusive === false ? 1 : 0;
934
+ const targetDates = range.slice(startOffset);
935
+ return this.getDailyMenus(cafeteria, targetDates, options);
936
+ }
937
+ };
938
+ var createMealClient = (options) => {
939
+ return new MealClient(options);
940
+ };
941
+ // Annotate the CommonJS export names for ESM import in node:
942
+ 0 && (module.exports = {
943
+ BaseCafeteriaException,
944
+ CafeteriaType,
945
+ HolidayException,
946
+ MealClient,
947
+ MenuFetchException,
948
+ MenuParseException,
949
+ buildDateRange,
950
+ createMealClient,
951
+ defaultSettings,
952
+ normalizeMenuDate
953
+ });