@jsm-mit/sultana-agent-tools-package 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/dist/booksy/fetch-page.d.ts +25 -0
- package/dist/booksy/fetch-page.d.ts.map +1 -0
- package/dist/booksy/fetch-page.js +93 -0
- package/dist/booksy/import-tools.d.ts +22 -0
- package/dist/booksy/import-tools.d.ts.map +1 -0
- package/dist/booksy/import-tools.js +413 -0
- package/dist/booksy/nuxt-payload.d.ts +15 -0
- package/dist/booksy/nuxt-payload.d.ts.map +1 -0
- package/dist/booksy/nuxt-payload.js +96 -0
- package/dist/booksy/parse-salon.d.ts +61 -0
- package/dist/booksy/parse-salon.d.ts.map +1 -0
- package/dist/booksy/parse-salon.js +212 -0
- package/dist/booksy/to-sultana.d.ts +62 -0
- package/dist/booksy/to-sultana.d.ts.map +1 -0
- package/dist/booksy/to-sultana.js +77 -0
- package/dist/booksy/tools.d.ts +57 -0
- package/dist/booksy/tools.d.ts.map +1 -0
- package/dist/booksy/tools.js +227 -0
- package/dist/customer/create-customer-tools.d.ts +4 -2
- package/dist/customer/create-customer-tools.d.ts.map +1 -1
- package/dist/customer/create-customer-tools.js +7 -3
- package/dist/customer/persona.d.ts +5 -3
- package/dist/customer/persona.d.ts.map +1 -1
- package/dist/customer/persona.js +21 -1
- package/dist/customer/texts.d.ts +79 -0
- package/dist/customer/texts.d.ts.map +1 -0
- package/dist/customer/texts.js +115 -0
- package/dist/customer/tools.d.ts +3 -0
- package/dist/customer/tools.d.ts.map +1 -1
- package/dist/customer/tools.js +80 -63
- package/dist/ic-salon-core-port.d.ts +4 -0
- package/dist/ic-salon-core-port.d.ts.map +1 -1
- package/dist/ic-salon-core-port.js +19 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/persona.d.ts +5 -0
- package/dist/persona.d.ts.map +1 -1
- package/dist/persona.js +17 -0
- package/package.json +2 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Booksy profile is a Nuxt page, and Nuxt ships its state as a `devalue` payload: one flat JSON
|
|
3
|
+
* array in which every object value and every array element is an INDEX into that same array.
|
|
4
|
+
* Only the entries themselves hold literal values.
|
|
5
|
+
*
|
|
6
|
+
* This is the reader for that format — small on purpose, so the package takes no dependency for
|
|
7
|
+
* a single page type.
|
|
8
|
+
*/
|
|
9
|
+
/** The raw payload array of a Nuxt page, or `null` when the page has none. */
|
|
10
|
+
export declare function extractNuxtPayload(html: string): unknown[] | null;
|
|
11
|
+
/** The index of the first plain object in the payload that has every one of `keys`. */
|
|
12
|
+
export declare function findPayloadObject(payload: unknown[], keys: string[]): number;
|
|
13
|
+
/** Resolves the entry at `index` into a plain value — references followed, wrappers unwrapped. */
|
|
14
|
+
export declare function hydratePayload(payload: unknown[], index: number): unknown;
|
|
15
|
+
//# sourceMappingURL=nuxt-payload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nuxt-payload.d.ts","sourceRoot":"","sources":["../../src/booksy/nuxt-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAYH,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,EAAE,GAAG,IAAI,CAUjE;AAED,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAQ5E;AAED,kGAAkG;AAClG,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAgCzE"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Booksy profile is a Nuxt page, and Nuxt ships its state as a `devalue` payload: one flat JSON
|
|
3
|
+
* array in which every object value and every array element is an INDEX into that same array.
|
|
4
|
+
* Only the entries themselves hold literal values.
|
|
5
|
+
*
|
|
6
|
+
* This is the reader for that format — small on purpose, so the package takes no dependency for
|
|
7
|
+
* a single page type.
|
|
8
|
+
*/
|
|
9
|
+
const PAYLOAD_PATTERN = /<script[^>]*id="__NUXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
|
|
10
|
+
/** Negative indexes are devalue's constants: -1 undefined, -2 hole, -3 NaN, -4 Infinity, -5 -Infinity, -6 -0. */
|
|
11
|
+
const NEGATIVE_CONSTANTS = {
|
|
12
|
+
[-3]: Number.NaN,
|
|
13
|
+
[-4]: Number.POSITIVE_INFINITY,
|
|
14
|
+
[-5]: Number.NEGATIVE_INFINITY,
|
|
15
|
+
[-6]: -0,
|
|
16
|
+
};
|
|
17
|
+
/** The raw payload array of a Nuxt page, or `null` when the page has none. */
|
|
18
|
+
export function extractNuxtPayload(html) {
|
|
19
|
+
const match = PAYLOAD_PATTERN.exec(html);
|
|
20
|
+
if (!match)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(match[1]);
|
|
24
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The index of the first plain object in the payload that has every one of `keys`. */
|
|
31
|
+
export function findPayloadObject(payload, keys) {
|
|
32
|
+
return payload.findIndex((entry) => typeof entry === "object" &&
|
|
33
|
+
entry !== null &&
|
|
34
|
+
!Array.isArray(entry) &&
|
|
35
|
+
keys.every((key) => key in entry));
|
|
36
|
+
}
|
|
37
|
+
/** Resolves the entry at `index` into a plain value — references followed, wrappers unwrapped. */
|
|
38
|
+
export function hydratePayload(payload, index) {
|
|
39
|
+
const done = new Map();
|
|
40
|
+
const hydrate = (at) => {
|
|
41
|
+
if (typeof at !== "number" || !Number.isInteger(at))
|
|
42
|
+
return undefined;
|
|
43
|
+
if (at < 0)
|
|
44
|
+
return NEGATIVE_CONSTANTS[at];
|
|
45
|
+
if (at >= payload.length)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (done.has(at))
|
|
48
|
+
return done.get(at);
|
|
49
|
+
const entry = payload[at];
|
|
50
|
+
if (typeof entry !== "object" || entry === null)
|
|
51
|
+
return entry;
|
|
52
|
+
if (Array.isArray(entry)) {
|
|
53
|
+
// A string in the first place marks a typed value: ["Date", iso], ["Set", …], and
|
|
54
|
+
// Nuxt's own ["Reactive", index], ["Ref", index] wrappers.
|
|
55
|
+
if (typeof entry[0] === "string")
|
|
56
|
+
return hydrateTyped(entry[0], entry.slice(1), hydrate);
|
|
57
|
+
const list = [];
|
|
58
|
+
done.set(at, list);
|
|
59
|
+
for (const element of entry)
|
|
60
|
+
list.push(hydrate(element));
|
|
61
|
+
return list;
|
|
62
|
+
}
|
|
63
|
+
const record = {};
|
|
64
|
+
done.set(at, record);
|
|
65
|
+
for (const [key, value] of Object.entries(entry))
|
|
66
|
+
record[key] = hydrate(value);
|
|
67
|
+
return record;
|
|
68
|
+
};
|
|
69
|
+
return hydrate(index);
|
|
70
|
+
}
|
|
71
|
+
function hydrateTyped(type, rest, hydrate) {
|
|
72
|
+
switch (type) {
|
|
73
|
+
case "Date":
|
|
74
|
+
return typeof rest[0] === "string" ? rest[0] : undefined;
|
|
75
|
+
case "Set":
|
|
76
|
+
return rest.map(hydrate);
|
|
77
|
+
case "Map": {
|
|
78
|
+
const pairs = [];
|
|
79
|
+
for (let at = 0; at + 1 < rest.length; at += 2)
|
|
80
|
+
pairs.push([hydrate(rest[at]), hydrate(rest[at + 1])]);
|
|
81
|
+
return pairs;
|
|
82
|
+
}
|
|
83
|
+
case "null": {
|
|
84
|
+
const record = {};
|
|
85
|
+
for (let at = 0; at + 1 < rest.length; at += 2)
|
|
86
|
+
record[String(rest[at])] = hydrate(rest[at + 1]);
|
|
87
|
+
return record;
|
|
88
|
+
}
|
|
89
|
+
case "BigInt":
|
|
90
|
+
case "RegExp":
|
|
91
|
+
return typeof rest[0] === "string" ? rest[0] : undefined;
|
|
92
|
+
default:
|
|
93
|
+
// Reactive, ShallowReactive, Ref, ShallowRef, NuxtError…: one index to the real value.
|
|
94
|
+
return hydrate(rest[0]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Booksy profile as the page shows it — a faithful picture, nothing adapted to Sultana yet.
|
|
3
|
+
* Reviews and customer names are on the page too; they are never read.
|
|
4
|
+
*/
|
|
5
|
+
export interface BooksySalon {
|
|
6
|
+
/** Booksy's numeric business id, as text. `null` when only the JSON-LD fallback was readable. */
|
|
7
|
+
id: string | null;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
address: string;
|
|
11
|
+
city: string;
|
|
12
|
+
latitude: number | null;
|
|
13
|
+
longitude: number | null;
|
|
14
|
+
openHours: BooksyOpenHours[];
|
|
15
|
+
staff: BooksyStaffer[];
|
|
16
|
+
categories: BooksyServiceCategory[];
|
|
17
|
+
images: BooksyImage[];
|
|
18
|
+
/** `"json-ld"` has no durations, no categories and one photo — a degraded read. */
|
|
19
|
+
source: "nuxt-payload" | "json-ld";
|
|
20
|
+
}
|
|
21
|
+
export interface BooksyOpenHours {
|
|
22
|
+
/** 0 = Monday … 6 = Sunday — Sultana's numbering, not Booksy's (theirs starts on Sunday). */
|
|
23
|
+
day: number;
|
|
24
|
+
from: string;
|
|
25
|
+
till: string;
|
|
26
|
+
}
|
|
27
|
+
export interface BooksyStaffer {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
photoUrl: string | null;
|
|
31
|
+
}
|
|
32
|
+
export interface BooksyServiceCategory {
|
|
33
|
+
name: string;
|
|
34
|
+
services: BooksyService[];
|
|
35
|
+
}
|
|
36
|
+
export interface BooksyService {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
stafferIds: string[];
|
|
40
|
+
variants: BooksyVariant[];
|
|
41
|
+
photoUrls: string[];
|
|
42
|
+
}
|
|
43
|
+
/** Booksy's price kinds: a fixed price, "from", "varies", free, or not shown. */
|
|
44
|
+
export type BooksyPriceType = "fixed" | "starts_at" | "varies" | "free" | "hidden";
|
|
45
|
+
export interface BooksyVariant {
|
|
46
|
+
label: string;
|
|
47
|
+
price: number | null;
|
|
48
|
+
priceType: BooksyPriceType;
|
|
49
|
+
durationMinutes: number | null;
|
|
50
|
+
}
|
|
51
|
+
export type BooksyImageKind = "logo" | "cover" | "salon" | "inspiration" | "service" | "staff";
|
|
52
|
+
export interface BooksyImage {
|
|
53
|
+
kind: BooksyImageKind;
|
|
54
|
+
url: string;
|
|
55
|
+
width: number | null;
|
|
56
|
+
height: number | null;
|
|
57
|
+
/** The service or the person a photo belongs to. */
|
|
58
|
+
caption: string | null;
|
|
59
|
+
}
|
|
60
|
+
export declare function parseBooksySalon(html: string): BooksySalon;
|
|
61
|
+
//# sourceMappingURL=parse-salon.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-salon.d.ts","sourceRoot":"","sources":["../../src/booksy/parse-salon.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,MAAM,WAAW,WAAW;IACxB,iGAAiG;IACjG,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,UAAU,EAAE,qBAAqB,EAAE,CAAC;IACpC,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,mFAAmF;IACnF,MAAM,EAAE,cAAc,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,eAAe;IAC5B,6FAA6F;IAC7F,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,qBAAqB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,SAAS,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,iFAAiF;AACjF,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,WAAW,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEnF,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,eAAe,CAAC;IAC3B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG,aAAa,GAAG,SAAS,GAAG,OAAO,CAAC;AAE/F,MAAM,WAAW,WAAW;IACxB,IAAI,EAAE,eAAe,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,oDAAoD;IACpD,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAID,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAU1D"}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { BooksyReadError } from "./fetch-page.js";
|
|
2
|
+
import { extractNuxtPayload, findPayloadObject, hydratePayload } from "./nuxt-payload.js";
|
|
3
|
+
const PRICE_TYPES = { X: "fixed", S: "starts_at", V: "varies", F: "free", D: "hidden" };
|
|
4
|
+
export function parseBooksySalon(html) {
|
|
5
|
+
const salon = fromNuxtPayload(html) ?? fromJsonLd(html);
|
|
6
|
+
if (!salon) {
|
|
7
|
+
throw new BooksyReadError("unavailable", "Strona Booksy nie zawiera danych salonu w znanym formacie — Booksy mogło zmienić stronę.");
|
|
8
|
+
}
|
|
9
|
+
return salon;
|
|
10
|
+
}
|
|
11
|
+
function fromNuxtPayload(html) {
|
|
12
|
+
const payload = extractNuxtPayload(html);
|
|
13
|
+
if (!payload)
|
|
14
|
+
return null;
|
|
15
|
+
const at = findPayloadObject(payload, ["name", "serviceCategories", "openHours"]);
|
|
16
|
+
if (at < 0)
|
|
17
|
+
return null;
|
|
18
|
+
const business = asRecord(hydratePayload(payload, at));
|
|
19
|
+
const name = asText(business.name);
|
|
20
|
+
if (name === "")
|
|
21
|
+
return null;
|
|
22
|
+
const location = asRecord(business.location);
|
|
23
|
+
const coordinate = asRecord(location.coordinate);
|
|
24
|
+
const staff = asList(business.staff)
|
|
25
|
+
.map(asRecord)
|
|
26
|
+
.map((person) => ({ id: asId(person.id), name: asText(person.name), photoUrl: asUrl(person.photoUrl) }))
|
|
27
|
+
.filter((person) => person.name !== "");
|
|
28
|
+
const categories = asList(business.serviceCategories)
|
|
29
|
+
.map(asRecord)
|
|
30
|
+
.map((category) => ({
|
|
31
|
+
name: asText(category.name),
|
|
32
|
+
services: asList(category.services).map(asRecord).map(toService).filter(isBookableService),
|
|
33
|
+
}))
|
|
34
|
+
.filter((category) => category.services.length > 0);
|
|
35
|
+
return {
|
|
36
|
+
id: asId(business.id) || null,
|
|
37
|
+
name,
|
|
38
|
+
description: asText(business.description),
|
|
39
|
+
address: asText(location.address),
|
|
40
|
+
city: asText(location.city),
|
|
41
|
+
latitude: asNumber(coordinate.latitude),
|
|
42
|
+
longitude: asNumber(coordinate.longitude),
|
|
43
|
+
openHours: asList(business.openHours).map(asRecord).flatMap(toOpenHours),
|
|
44
|
+
staff,
|
|
45
|
+
categories,
|
|
46
|
+
images: [...toBusinessImages(asRecord(business.images)), ...toServiceImages(categories), ...toStaffImages(staff)],
|
|
47
|
+
source: "nuxt-payload",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function toService(service) {
|
|
51
|
+
return {
|
|
52
|
+
name: asText(service.name),
|
|
53
|
+
description: asText(service.description),
|
|
54
|
+
stafferIds: asList(service.stafferId).map(asId),
|
|
55
|
+
variants: asList(service.variants)
|
|
56
|
+
.map(asRecord)
|
|
57
|
+
.map((variant) => ({
|
|
58
|
+
label: asText(variant.label),
|
|
59
|
+
price: asNumber(variant.price),
|
|
60
|
+
priceType: PRICE_TYPES[asText(variant.type)] ?? "fixed",
|
|
61
|
+
durationMinutes: asNumber(variant.duration),
|
|
62
|
+
})),
|
|
63
|
+
photoUrls: asList(service.photos)
|
|
64
|
+
.map(asRecord)
|
|
65
|
+
.map((photo) => asUrl(photo.url))
|
|
66
|
+
.filter((url) => url !== null),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function isBookableService(service) {
|
|
70
|
+
return service.name !== "" && service.variants.length > 0;
|
|
71
|
+
}
|
|
72
|
+
function toOpenHours(entry) {
|
|
73
|
+
const booksyDay = asNumber(entry.dayOfWeek);
|
|
74
|
+
const from = asText(entry.openFrom);
|
|
75
|
+
const till = asText(entry.openTill);
|
|
76
|
+
if (booksyDay === null || booksyDay < 0 || booksyDay > 6 || from === "" || till === "")
|
|
77
|
+
return [];
|
|
78
|
+
// Booksy counts from Sunday (0); Sultana counts from Monday (0).
|
|
79
|
+
return [{ day: (booksyDay + 6) % 7, from, till }];
|
|
80
|
+
}
|
|
81
|
+
const BUSINESS_IMAGE_KINDS = [
|
|
82
|
+
["logo", "logo"],
|
|
83
|
+
["cover", "cover"],
|
|
84
|
+
["bizPhoto", "salon"],
|
|
85
|
+
["inspiration", "inspiration"],
|
|
86
|
+
];
|
|
87
|
+
function toBusinessImages(images) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const result = [];
|
|
90
|
+
for (const [key, kind] of BUSINESS_IMAGE_KINDS) {
|
|
91
|
+
for (const image of asList(images[key]).map(asRecord)) {
|
|
92
|
+
const url = asUrl(image.image);
|
|
93
|
+
// The cover is often repeated among the salon photos.
|
|
94
|
+
if (url === null || seen.has(url))
|
|
95
|
+
continue;
|
|
96
|
+
seen.add(url);
|
|
97
|
+
result.push({
|
|
98
|
+
kind,
|
|
99
|
+
url,
|
|
100
|
+
width: asNumber(image.width),
|
|
101
|
+
height: asNumber(image.height),
|
|
102
|
+
caption: asText(image.description) || null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function toServiceImages(categories) {
|
|
109
|
+
return categories.flatMap((category) => category.services.flatMap((service) => service.photoUrls.map((url) => ({ kind: "service", url, width: null, height: null, caption: service.name }))));
|
|
110
|
+
}
|
|
111
|
+
function toStaffImages(staff) {
|
|
112
|
+
return staff.flatMap((person) => person.photoUrl ? [{ kind: "staff", url: person.photoUrl, width: null, height: null, caption: person.name }] : []);
|
|
113
|
+
}
|
|
114
|
+
// --- JSON-LD fallback --------------------------------------------------------------------------
|
|
115
|
+
const JSON_LD_PATTERN = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g;
|
|
116
|
+
const SCHEMA_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
117
|
+
function fromJsonLd(html) {
|
|
118
|
+
for (const match of html.matchAll(JSON_LD_PATTERN)) {
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(match[1]);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const business = asRecord(parsed);
|
|
127
|
+
if (!("makesOffer" in business) && !("openingHoursSpecification" in business))
|
|
128
|
+
continue;
|
|
129
|
+
const name = asText(business.name);
|
|
130
|
+
if (name === "")
|
|
131
|
+
continue;
|
|
132
|
+
const address = asRecord(business.address);
|
|
133
|
+
const geo = asRecord(business.geo);
|
|
134
|
+
const staff = asList(business.employee)
|
|
135
|
+
.map(asRecord)
|
|
136
|
+
.map((person, index) => ({ id: `ld-${index}`, name: asText(person.name), photoUrl: asUrl(person.image) }))
|
|
137
|
+
.filter((person) => person.name !== "");
|
|
138
|
+
const services = asList(business.makesOffer)
|
|
139
|
+
.map(asRecord)
|
|
140
|
+
.map((offer) => ({
|
|
141
|
+
name: asText(offer.name),
|
|
142
|
+
description: "",
|
|
143
|
+
stafferIds: [],
|
|
144
|
+
variants: [{ label: "", price: asNumber(offer.price), priceType: "fixed", durationMinutes: null }],
|
|
145
|
+
photoUrls: [],
|
|
146
|
+
}))
|
|
147
|
+
.filter(isBookableService);
|
|
148
|
+
const images = [];
|
|
149
|
+
const logo = asUrl(business.logo);
|
|
150
|
+
const cover = asUrl(business.image);
|
|
151
|
+
if (logo)
|
|
152
|
+
images.push({ kind: "logo", url: logo, width: null, height: null, caption: null });
|
|
153
|
+
if (cover)
|
|
154
|
+
images.push({ kind: "cover", url: cover, width: null, height: null, caption: null });
|
|
155
|
+
return {
|
|
156
|
+
id: null,
|
|
157
|
+
name,
|
|
158
|
+
description: asText(business.description),
|
|
159
|
+
address: asText(address.streetAddress),
|
|
160
|
+
city: asText(address.addressLocality),
|
|
161
|
+
latitude: asNumber(geo.latitude),
|
|
162
|
+
longitude: asNumber(geo.longitude),
|
|
163
|
+
openHours: asList(business.openingHoursSpecification).map(asRecord).flatMap(toOpenHoursFromSchema),
|
|
164
|
+
staff,
|
|
165
|
+
categories: services.length > 0 ? [{ name: "", services }] : [],
|
|
166
|
+
images: [...images, ...toStaffImages(staff)],
|
|
167
|
+
source: "json-ld",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function toOpenHoursFromSchema(entry) {
|
|
173
|
+
const from = asText(entry.opens);
|
|
174
|
+
const till = asText(entry.closes);
|
|
175
|
+
if (from === "" || till === "")
|
|
176
|
+
return [];
|
|
177
|
+
const days = Array.isArray(entry.dayOfWeek) ? entry.dayOfWeek : [entry.dayOfWeek];
|
|
178
|
+
return days.flatMap((dayName) => {
|
|
179
|
+
// Schema.org allows both "Monday" and "https://schema.org/Monday".
|
|
180
|
+
const day = SCHEMA_DAYS.indexOf(asText(dayName).split("/").pop() ?? "");
|
|
181
|
+
return day < 0 ? [] : [{ day, from, till }];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// --- forgiving readers: the page is somebody else's and may change shape -----------------------
|
|
185
|
+
function asRecord(value) {
|
|
186
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
187
|
+
}
|
|
188
|
+
function asList(value) {
|
|
189
|
+
return Array.isArray(value) ? value : [];
|
|
190
|
+
}
|
|
191
|
+
/** Owners paste emoji and pictures into names; what survives as U+FFFC ("object here") or a
|
|
192
|
+
* run of spaces is noise in a service list. Line breaks stay — a description has paragraphs. */
|
|
193
|
+
function asText(value) {
|
|
194
|
+
if (typeof value !== "string")
|
|
195
|
+
return "";
|
|
196
|
+
return value
|
|
197
|
+
.replace(/[\uFFFC\uFFFD]/g, "")
|
|
198
|
+
.replace(/[^\S\n]+/g, " ")
|
|
199
|
+
.replace(/ ?\n ?/g, "\n")
|
|
200
|
+
.trim();
|
|
201
|
+
}
|
|
202
|
+
function asId(value) {
|
|
203
|
+
return typeof value === "number" || typeof value === "string" ? String(value) : "";
|
|
204
|
+
}
|
|
205
|
+
function asNumber(value) {
|
|
206
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
|
|
207
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
208
|
+
}
|
|
209
|
+
function asUrl(value) {
|
|
210
|
+
const text = asText(value);
|
|
211
|
+
return text.startsWith("https://") ? text : null;
|
|
212
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ServiceWriteInput } from "../salon-core-port.js";
|
|
2
|
+
import type { BooksyImageKind, BooksySalon } from "./parse-salon.js";
|
|
3
|
+
/**
|
|
4
|
+
* A Booksy profile recast into what Sultana stores — every value already in Sultana's units and
|
|
5
|
+
* limits, so a write tool takes it as it is. What Sultana cannot hold is not dropped silently: it
|
|
6
|
+
* goes to `notes`, for the agent to tell the owner.
|
|
7
|
+
*/
|
|
8
|
+
export interface SultanaSalonDraft {
|
|
9
|
+
source: {
|
|
10
|
+
url: string;
|
|
11
|
+
booksyId: string | null;
|
|
12
|
+
degraded: boolean;
|
|
13
|
+
};
|
|
14
|
+
salon: SultanaSalonDraftProfile;
|
|
15
|
+
/** Seven entries, Monday first. */
|
|
16
|
+
weeklyHours: SultanaDraftDay[];
|
|
17
|
+
services: SultanaServiceDraft[];
|
|
18
|
+
workers: SultanaWorkerDraft[];
|
|
19
|
+
photos: SultanaPhotoDraft[];
|
|
20
|
+
}
|
|
21
|
+
export interface SultanaSalonDraftProfile {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
address: string;
|
|
25
|
+
city: string;
|
|
26
|
+
latitude: number | null;
|
|
27
|
+
longitude: number | null;
|
|
28
|
+
}
|
|
29
|
+
export interface SultanaDraftDay {
|
|
30
|
+
/** 0 = Monday … 6 = Sunday. */
|
|
31
|
+
day: number;
|
|
32
|
+
dayName: string;
|
|
33
|
+
/** The `ranges` text of `set_weekly_hours` ("9:00-17:00"); empty means closed. */
|
|
34
|
+
ranges: string;
|
|
35
|
+
}
|
|
36
|
+
/** `ServiceWriteInput` plus what helps to finish it. `serviceTypeIds` and `workerIds` are empty:
|
|
37
|
+
* the ids live in Sultana, and the agent resolves them (`find_service_type`, `list_workers`). */
|
|
38
|
+
export interface SultanaServiceDraft extends Omit<ServiceWriteInput, "durationMinutes"> {
|
|
39
|
+
/** `null` only after a degraded read — such a service cannot be written until the owner gives a time. */
|
|
40
|
+
durationMinutes: number | null;
|
|
41
|
+
/** The service on Booksy this came from — several drafts share it when it had variants. */
|
|
42
|
+
booksyService: string;
|
|
43
|
+
/** The category on Booksy — a hint for choosing the service type. */
|
|
44
|
+
booksyCategory: string;
|
|
45
|
+
description: string;
|
|
46
|
+
/** First names of the people who do it on Booksy. */
|
|
47
|
+
workerNames: string[];
|
|
48
|
+
notes: string[];
|
|
49
|
+
}
|
|
50
|
+
export interface SultanaWorkerDraft {
|
|
51
|
+
name: string;
|
|
52
|
+
photoUrl: string | null;
|
|
53
|
+
}
|
|
54
|
+
export interface SultanaPhotoDraft {
|
|
55
|
+
kind: BooksyImageKind;
|
|
56
|
+
url: string;
|
|
57
|
+
width: number | null;
|
|
58
|
+
height: number | null;
|
|
59
|
+
caption: string | null;
|
|
60
|
+
}
|
|
61
|
+
export declare function toSultanaDraft(salon: BooksySalon, url: string): SultanaSalonDraft;
|
|
62
|
+
//# sourceMappingURL=to-sultana.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-sultana.d.ts","sourceRoot":"","sources":["../../src/booksy/to-sultana.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE/D,OAAO,KAAK,EAAe,eAAe,EAAE,WAAW,EAAgC,MAAM,kBAAkB,CAAC;AAEhH;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAC9B,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;IACpE,KAAK,EAAE,wBAAwB,CAAC;IAChC,mCAAmC;IACnC,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,MAAM,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC5B,+BAA+B;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;CAClB;AAED;iGACiG;AACjG,MAAM,WAAW,mBAAoB,SAAQ,IAAI,CAAC,iBAAiB,EAAE,iBAAiB,CAAC;IACnF,yGAAyG;IACzG,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2FAA2F;IAC3F,aAAa,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,eAAe,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAID,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,iBAAiB,CA6BjF"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { normalizeDuration, normalizePrice } from "../tools/args.js";
|
|
2
|
+
const DAY_NAMES_PL = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"];
|
|
3
|
+
export function toSultanaDraft(salon, url) {
|
|
4
|
+
const staffNames = new Map(salon.staff.map((person) => [person.id, person.name]));
|
|
5
|
+
return {
|
|
6
|
+
source: { url, booksyId: salon.id, degraded: salon.source !== "nuxt-payload" },
|
|
7
|
+
salon: {
|
|
8
|
+
name: salon.name,
|
|
9
|
+
description: salon.description,
|
|
10
|
+
address: salon.address,
|
|
11
|
+
city: salon.city,
|
|
12
|
+
latitude: salon.latitude,
|
|
13
|
+
longitude: salon.longitude,
|
|
14
|
+
},
|
|
15
|
+
weeklyHours: DAY_NAMES_PL.map((dayName, day) => ({
|
|
16
|
+
day,
|
|
17
|
+
dayName,
|
|
18
|
+
ranges: salon.openHours
|
|
19
|
+
.filter((hours) => hours.day === day)
|
|
20
|
+
.map((hours) => `${hours.from}-${hours.till}`)
|
|
21
|
+
.join(","),
|
|
22
|
+
})),
|
|
23
|
+
services: salon.categories.flatMap((category) => category.services.flatMap((service) => service.variants.map((variant) => toServiceDraft(category.name, service, variant, staffNames)))),
|
|
24
|
+
workers: salon.staff.map((person) => ({ name: person.name, photoUrl: person.photoUrl })),
|
|
25
|
+
photos: salon.images.map(toPhotoDraft),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Booksy nests price-and-time variants under one service; Sultana's service has one price and
|
|
29
|
+
* one time, so every variant becomes a service of its own. */
|
|
30
|
+
function toServiceDraft(category, service, variant, staffNames) {
|
|
31
|
+
const notes = [];
|
|
32
|
+
let pricePln = 0;
|
|
33
|
+
if (variant.price === null) {
|
|
34
|
+
notes.push("Booksy nie podaje ceny — wpisano 0 zł, zapytaj właścicielkę o cenę");
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const price = normalizePrice(Math.max(variant.price, 0));
|
|
38
|
+
pricePln = price.value;
|
|
39
|
+
if (price.note)
|
|
40
|
+
notes.push(price.note);
|
|
41
|
+
}
|
|
42
|
+
if (variant.priceType === "starts_at")
|
|
43
|
+
notes.push(`na Booksy cena „od ${pricePln} zł” — Sultana ma tylko cenę stałą`);
|
|
44
|
+
if (variant.priceType === "varies")
|
|
45
|
+
notes.push("na Booksy cena zmienna — Sultana ma tylko cenę stałą");
|
|
46
|
+
if (variant.priceType === "hidden")
|
|
47
|
+
notes.push("na Booksy cena ukryta");
|
|
48
|
+
let durationMinutes = null;
|
|
49
|
+
if (variant.durationMinutes === null) {
|
|
50
|
+
notes.push("Booksy nie podaje czasu trwania — zapytaj właścicielkę");
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Booksy allows a zero-length add-on; Sultana's shortest bookable time is 5 minutes.
|
|
54
|
+
const duration = normalizeDuration(Math.max(variant.durationMinutes, 5));
|
|
55
|
+
durationMinutes = duration.value;
|
|
56
|
+
if (variant.durationMinutes < 5)
|
|
57
|
+
notes.push(`czas ${variant.durationMinutes} min podniesiono do 5 min`);
|
|
58
|
+
if (duration.note)
|
|
59
|
+
notes.push(duration.note);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
name: variant.label === "" ? service.name : `${service.name} — ${variant.label}`,
|
|
63
|
+
pricePln,
|
|
64
|
+
durationMinutes,
|
|
65
|
+
active: true,
|
|
66
|
+
serviceTypeIds: [],
|
|
67
|
+
workerIds: [],
|
|
68
|
+
booksyService: service.name,
|
|
69
|
+
booksyCategory: category,
|
|
70
|
+
description: service.description,
|
|
71
|
+
workerNames: service.stafferIds.flatMap((id) => staffNames.get(id) ?? []),
|
|
72
|
+
notes,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function toPhotoDraft(image) {
|
|
76
|
+
return { kind: image.kind, url: image.url, width: image.width, height: image.height, caption: image.caption };
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type AgentTool, type ToolResult } from "../types.js";
|
|
2
|
+
import { type FetchFn } from "./fetch-page.js";
|
|
3
|
+
import { type BooksyImageKind } from "./parse-salon.js";
|
|
4
|
+
import { type SultanaSalonDraft } from "./to-sultana.js";
|
|
5
|
+
/** One photo handed to the host. The package never touches a disk or a canister on its own — the
|
|
6
|
+
* host decides where a copied photo lives (a sandbox folder today, the salon's asset library later). */
|
|
7
|
+
export interface BooksyPhotoFile {
|
|
8
|
+
/** Booksy's business id, or `"unknown"` after a degraded read — a folder name for the host. */
|
|
9
|
+
salonKey: string;
|
|
10
|
+
kind: BooksyImageKind;
|
|
11
|
+
/** Safe as a file name: `salon-03.jpeg`; a service or staff photo also names its owner —
|
|
12
|
+
* `service-07-makijaz-okolicznosciowy.jpeg` — so a portfolio stays sorted by service. */
|
|
13
|
+
fileName: string;
|
|
14
|
+
contentType: string;
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
sourceUrl: string;
|
|
17
|
+
caption: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface BooksyPhotoSink {
|
|
20
|
+
/** Stores the photo and returns where it is — a path or an id the agent can name. */
|
|
21
|
+
save(photo: BooksyPhotoFile): Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export interface CreateBooksyReaderToolsOptions {
|
|
24
|
+
fetchFn?: FetchFn;
|
|
25
|
+
/** A reader that outlives the turn. A host that builds its tools again for every message
|
|
26
|
+
* passes one, so the confirming call does not fetch the profile a second time. */
|
|
27
|
+
reader?: BooksyProfileReader;
|
|
28
|
+
/** Without a sink there is no `download_booksy_photos` tool in the set. */
|
|
29
|
+
photoSink?: BooksyPhotoSink;
|
|
30
|
+
}
|
|
31
|
+
export declare const URL_FIELD: {
|
|
32
|
+
type: string;
|
|
33
|
+
description: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Read-only tools over a PUBLIC Booksy profile: they fetch one page and hand back its content in
|
|
37
|
+
* the shape Sultana's write tools take. Nothing here writes to a salon, so nothing here asks for
|
|
38
|
+
* a confirmation. Reviews and customer names are never read.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createBooksyReaderTools(options?: CreateBooksyReaderToolsOptions): AgentTool[];
|
|
41
|
+
/** Reads and recasts a whole profile — for a host or a script that wants the draft without a model. */
|
|
42
|
+
export declare function readBooksySalonDraft(url: string, fetchFn?: FetchFn): Promise<SultanaSalonDraft>;
|
|
43
|
+
/**
|
|
44
|
+
* Every Booksy tool reads the same page — fetch it once. Profiles are public, so one reader can
|
|
45
|
+
* serve a whole host; a profile is remembered for ten minutes, the last fifty of them.
|
|
46
|
+
*/
|
|
47
|
+
export declare class BooksyProfileReader {
|
|
48
|
+
private readonly fetchFn?;
|
|
49
|
+
private readonly now;
|
|
50
|
+
private readonly drafts;
|
|
51
|
+
constructor(fetchFn?: FetchFn | undefined, now?: () => number);
|
|
52
|
+
read(rawUrl: string): Promise<SultanaSalonDraft>;
|
|
53
|
+
}
|
|
54
|
+
export declare function degradedSuffix(draft: SultanaSalonDraft): string;
|
|
55
|
+
/** `execute` never throws: every failure becomes a result the agent can read. */
|
|
56
|
+
export declare function guarded(fallbackSummary: string, run: () => Promise<ToolResult>): Promise<ToolResult>;
|
|
57
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/booksy/tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAW,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EAAoE,KAAK,OAAO,EAAE,MAAM,iBAAiB,CAAC;AACjH,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EAA0C,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEjG;wGACwG;AACxG,MAAM,WAAW,eAAe;IAC5B,+FAA+F;IAC/F,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,eAAe,CAAC;IACtB;6FACyF;IACzF,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC5B,qFAAqF;IACrF,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,8BAA8B;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;sFACkF;IAClF,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,2EAA2E;IAC3E,SAAS,CAAC,EAAE,eAAe,CAAC;CAC/B;AAED,eAAO,MAAM,SAAS;;;CAGrB,CAAC;AAOF;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,8BAAmC,GAAG,SAAS,EAAE,CAMjG;AAED,uGAAuG;AACvG,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAErG;AAQD;;;GAGG;AACH,qBAAa,mBAAmB;IAIxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAJxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4E;gBAG9E,OAAO,CAAC,EAAE,OAAO,YAAA,EACjB,GAAG,GAAE,MAAM,MAAiB;IAGjD,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;CAoBnD;AA6ID,wBAAgB,cAAc,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,CAI/D;AA0BD,iFAAiF;AACjF,wBAAsB,OAAO,CAAC,eAAe,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,CAS1G"}
|