ebag 0.0.2
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 +89 -0
- package/dist/cli/format.d.ts +12 -0
- package/dist/cli/format.js +354 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +447 -0
- package/dist/lib/auth.d.ts +3 -0
- package/dist/lib/auth.js +17 -0
- package/dist/lib/cart.d.ts +4 -0
- package/dist/lib/cart.js +46 -0
- package/dist/lib/client.d.ts +13 -0
- package/dist/lib/client.js +96 -0
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.js +90 -0
- package/dist/lib/cookies.d.ts +2 -0
- package/dist/lib/cookies.js +30 -0
- package/dist/lib/index.d.ts +9 -0
- package/dist/lib/index.js +25 -0
- package/dist/lib/lists.d.ts +4 -0
- package/dist/lib/lists.js +41 -0
- package/dist/lib/orders.d.ts +14 -0
- package/dist/lib/orders.js +266 -0
- package/dist/lib/products.d.ts +2 -0
- package/dist/lib/products.js +8 -0
- package/dist/lib/search.d.ts +7 -0
- package/dist/lib/search.js +181 -0
- package/dist/lib/slots.d.ts +6 -0
- package/dist/lib/slots.js +55 -0
- package/dist/lib/types.d.ts +115 -0
- package/dist/lib/types.js +2 -0
- package/package.json +53 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PRODUCT_CACHE_TTL_MS = void 0;
|
|
4
|
+
exports.isProductCacheFresh = isProductCacheFresh;
|
|
5
|
+
exports.searchProducts = searchProducts;
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
const client_1 = require("./client");
|
|
8
|
+
const lists_1 = require("./lists");
|
|
9
|
+
const DEFAULT_FACETS = [
|
|
10
|
+
'brand_name_bg',
|
|
11
|
+
'country_of_origin_bg',
|
|
12
|
+
'hierarchical_categories_bg.lv1',
|
|
13
|
+
];
|
|
14
|
+
exports.PRODUCT_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
15
|
+
function isProductCacheFresh(entry, now = Date.now(), ttlMs = exports.PRODUCT_CACHE_TTL_MS) {
|
|
16
|
+
const cachedAt = Date.parse(entry.cachedAt);
|
|
17
|
+
if (!Number.isFinite(cachedAt))
|
|
18
|
+
return false;
|
|
19
|
+
return now - cachedAt < ttlMs;
|
|
20
|
+
}
|
|
21
|
+
function normalizeProductFromDetail(data) {
|
|
22
|
+
const id = Number(data.id);
|
|
23
|
+
const name = String(data.name || '');
|
|
24
|
+
const nameEn = data.name_en ? String(data.name_en) : undefined;
|
|
25
|
+
const price = data.price ? String(data.price) : undefined;
|
|
26
|
+
const pricePromo = data.price_promo ? String(data.price_promo) : undefined;
|
|
27
|
+
const currentPrice = data.current_price ? String(data.current_price) : undefined;
|
|
28
|
+
const mainImageId = data.main_image_id ? String(data.main_image_id) : undefined;
|
|
29
|
+
const imageUrl = mainImageId
|
|
30
|
+
? `https://www.ebag.bg/products/images/${mainImageId}/200/webp`
|
|
31
|
+
: undefined;
|
|
32
|
+
const urlSlug = data.url_slug ? String(data.url_slug) : undefined;
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
name,
|
|
36
|
+
nameEn,
|
|
37
|
+
price,
|
|
38
|
+
pricePromo,
|
|
39
|
+
currentPrice,
|
|
40
|
+
imageUrl,
|
|
41
|
+
urlSlug,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function normalizeProductFromAlgolia(hit) {
|
|
45
|
+
const id = Number(hit.id);
|
|
46
|
+
const name = String(hit.name_bg || '');
|
|
47
|
+
const nameEn = hit.name_en ? String(hit.name_en) : undefined;
|
|
48
|
+
const price = hit.price ? String(hit.price) : undefined;
|
|
49
|
+
const pricePromo = hit.price_promo ? String(hit.price_promo) : undefined;
|
|
50
|
+
const currentPrice = hit.current_price ? String(hit.current_price) : undefined;
|
|
51
|
+
const imageUrl = hit.product_image_absolute_url
|
|
52
|
+
? String(hit.product_image_absolute_url)
|
|
53
|
+
: undefined;
|
|
54
|
+
const urlSlug = hit.url_slug_bg ? String(hit.url_slug_bg) : undefined;
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
name,
|
|
58
|
+
nameEn,
|
|
59
|
+
price,
|
|
60
|
+
pricePromo,
|
|
61
|
+
currentPrice,
|
|
62
|
+
imageUrl,
|
|
63
|
+
urlSlug,
|
|
64
|
+
source: 'algolia',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function safeLower(value) {
|
|
68
|
+
return value.toLocaleLowerCase('bg-BG');
|
|
69
|
+
}
|
|
70
|
+
async function fetchProductDetail(config, session, productId) {
|
|
71
|
+
const result = await (0, client_1.requestEbag)(config, session, `/products/${productId}/json`);
|
|
72
|
+
return normalizeProductFromDetail(result.data);
|
|
73
|
+
}
|
|
74
|
+
async function mapWithConcurrency(items, limit, mapper) {
|
|
75
|
+
const results = [];
|
|
76
|
+
let index = 0;
|
|
77
|
+
async function worker() {
|
|
78
|
+
while (index < items.length) {
|
|
79
|
+
const current = items[index];
|
|
80
|
+
index += 1;
|
|
81
|
+
results.push(await mapper(current));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
|
|
85
|
+
await Promise.all(workers);
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
async function getProductWithCache(config, session, cache, productId) {
|
|
89
|
+
const cached = cache.products[String(productId)];
|
|
90
|
+
if (cached) {
|
|
91
|
+
if ('product' in cached) {
|
|
92
|
+
const entry = cached;
|
|
93
|
+
if (isProductCacheFresh(entry)) {
|
|
94
|
+
return entry.product;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const product = await fetchProductDetail(config, session, productId);
|
|
99
|
+
cache.products[String(productId)] = {
|
|
100
|
+
product,
|
|
101
|
+
cachedAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
return product;
|
|
104
|
+
}
|
|
105
|
+
async function searchInLists(config, session, query) {
|
|
106
|
+
if (!session.cookies) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const lists = await (0, lists_1.getLists)(config, session);
|
|
110
|
+
const productIdToLists = new Map();
|
|
111
|
+
for (const list of lists) {
|
|
112
|
+
for (const product of list.products || []) {
|
|
113
|
+
const existing = productIdToLists.get(product.productId) || [];
|
|
114
|
+
existing.push(list.name);
|
|
115
|
+
productIdToLists.set(product.productId, existing);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const cache = (0, config_1.loadCache)();
|
|
119
|
+
const products = await mapWithConcurrency([...productIdToLists.keys()], 5, async (productId) => getProductWithCache(config, session, cache, productId));
|
|
120
|
+
cache.updatedAt = new Date().toISOString();
|
|
121
|
+
(0, config_1.saveCache)(cache);
|
|
122
|
+
const needle = safeLower(query);
|
|
123
|
+
return products
|
|
124
|
+
.map((product) => ({
|
|
125
|
+
...product,
|
|
126
|
+
source: 'list',
|
|
127
|
+
listNames: productIdToLists.get(product.id) || [],
|
|
128
|
+
}))
|
|
129
|
+
.filter((product) => {
|
|
130
|
+
const name = safeLower(product.name || '');
|
|
131
|
+
const nameEn = product.nameEn ? safeLower(product.nameEn) : '';
|
|
132
|
+
return name.includes(needle) || nameEn.includes(needle);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function searchAlgolia(config, query, page, hitsPerPage) {
|
|
136
|
+
const params = new URLSearchParams({
|
|
137
|
+
clickAnalytics: 'true',
|
|
138
|
+
facets: JSON.stringify(DEFAULT_FACETS),
|
|
139
|
+
filters: '',
|
|
140
|
+
highlightPostTag: '__/ais-highlight__',
|
|
141
|
+
highlightPreTag: '__ais-highlight__',
|
|
142
|
+
maxValuesPerFacet: '50',
|
|
143
|
+
page: String(page),
|
|
144
|
+
query,
|
|
145
|
+
hitsPerPage: String(hitsPerPage),
|
|
146
|
+
});
|
|
147
|
+
const body = {
|
|
148
|
+
requests: [
|
|
149
|
+
{
|
|
150
|
+
indexName: 'products',
|
|
151
|
+
params: params.toString(),
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const result = await (0, client_1.requestAlgolia)(config, body);
|
|
156
|
+
const hits = result.data.results?.[0]?.hits || [];
|
|
157
|
+
return hits.map(normalizeProductFromAlgolia);
|
|
158
|
+
}
|
|
159
|
+
async function searchProducts(config, session, query, options = {}) {
|
|
160
|
+
const page = options.page ?? 0;
|
|
161
|
+
const limit = options.limit ?? 20;
|
|
162
|
+
const localResults = await searchInLists(config, session, query);
|
|
163
|
+
const hitsPerPage = Math.max(limit, 20);
|
|
164
|
+
const remoteResults = await searchAlgolia(config, query, page, hitsPerPage);
|
|
165
|
+
const merged = [...localResults];
|
|
166
|
+
const seen = new Set(localResults.map((p) => p.id));
|
|
167
|
+
for (const item of remoteResults) {
|
|
168
|
+
if (!seen.has(item.id)) {
|
|
169
|
+
merged.push(item);
|
|
170
|
+
seen.add(item.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
query,
|
|
175
|
+
results: merged.slice(0, limit),
|
|
176
|
+
sourceCounts: {
|
|
177
|
+
list: localResults.length,
|
|
178
|
+
algolia: remoteResults.length,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DeliverySlot } from './types';
|
|
2
|
+
export declare function normalizeSlots(data: Record<string, unknown>): DeliverySlot[];
|
|
3
|
+
export declare function formatSlotTime(value: number): string;
|
|
4
|
+
export declare function formatSlotRange(start: number, end: number): string;
|
|
5
|
+
export declare function formatLoadPercent(value: number): string;
|
|
6
|
+
export declare function sortSlots(a: DeliverySlot, b: DeliverySlot): number;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeSlots = normalizeSlots;
|
|
4
|
+
exports.formatSlotTime = formatSlotTime;
|
|
5
|
+
exports.formatSlotRange = formatSlotRange;
|
|
6
|
+
exports.formatLoadPercent = formatLoadPercent;
|
|
7
|
+
exports.sortSlots = sortSlots;
|
|
8
|
+
function normalizeSlots(data) {
|
|
9
|
+
const slots = [];
|
|
10
|
+
for (const [date, entries] of Object.entries(data)) {
|
|
11
|
+
if (!Array.isArray(entries))
|
|
12
|
+
continue;
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (!entry || typeof entry !== 'object')
|
|
15
|
+
continue;
|
|
16
|
+
const slot = entry;
|
|
17
|
+
const start = Number(slot.start);
|
|
18
|
+
const end = Number(slot.end);
|
|
19
|
+
if (!slot.key || Number.isNaN(start) || Number.isNaN(end))
|
|
20
|
+
continue;
|
|
21
|
+
slots.push({
|
|
22
|
+
date,
|
|
23
|
+
key: slot.key,
|
|
24
|
+
start,
|
|
25
|
+
end,
|
|
26
|
+
isAvailable: Boolean(slot.is_available),
|
|
27
|
+
loadPercent: Number(slot.load_percent ?? 0),
|
|
28
|
+
cutoffAfter: slot.cutoff_after ?? null,
|
|
29
|
+
isPharmacyRestricted: slot.is_pharmacy_restricted,
|
|
30
|
+
isBakeryRestricted: slot.is_bakery_restricted,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return slots;
|
|
35
|
+
}
|
|
36
|
+
function formatSlotTime(value) {
|
|
37
|
+
const padded = String(Math.trunc(value)).padStart(4, '0');
|
|
38
|
+
const hours = padded.slice(0, 2);
|
|
39
|
+
const minutes = padded.slice(2);
|
|
40
|
+
return `${hours}:${minutes}`;
|
|
41
|
+
}
|
|
42
|
+
function formatSlotRange(start, end) {
|
|
43
|
+
return `${formatSlotTime(start)}–${formatSlotTime(end)}`;
|
|
44
|
+
}
|
|
45
|
+
function formatLoadPercent(value) {
|
|
46
|
+
if (!Number.isFinite(value))
|
|
47
|
+
return '0%';
|
|
48
|
+
const rounded = Math.round(value * 10) / 10;
|
|
49
|
+
return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%`;
|
|
50
|
+
}
|
|
51
|
+
function sortSlots(a, b) {
|
|
52
|
+
if (a.date !== b.date)
|
|
53
|
+
return a.date.localeCompare(b.date);
|
|
54
|
+
return a.start - b.start;
|
|
55
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type ProductSummary = {
|
|
2
|
+
id: number;
|
|
3
|
+
name: string;
|
|
4
|
+
nameEn?: string;
|
|
5
|
+
price?: string;
|
|
6
|
+
pricePromo?: string;
|
|
7
|
+
currentPrice?: string;
|
|
8
|
+
currency?: string;
|
|
9
|
+
imageUrl?: string;
|
|
10
|
+
urlSlug?: string;
|
|
11
|
+
source?: 'list' | 'algolia';
|
|
12
|
+
listNames?: string[];
|
|
13
|
+
};
|
|
14
|
+
export type ProductDetail = Record<string, unknown>;
|
|
15
|
+
export type ListProduct = {
|
|
16
|
+
productId: number;
|
|
17
|
+
quantity: number;
|
|
18
|
+
};
|
|
19
|
+
export type ListSummary = {
|
|
20
|
+
id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
type?: string;
|
|
23
|
+
publicId?: string | null;
|
|
24
|
+
isReadOnly?: boolean;
|
|
25
|
+
products?: ListProduct[];
|
|
26
|
+
};
|
|
27
|
+
export type SearchResult = {
|
|
28
|
+
query: string;
|
|
29
|
+
results: ProductSummary[];
|
|
30
|
+
sourceCounts: {
|
|
31
|
+
list: number;
|
|
32
|
+
algolia: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export type Config = {
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
algolia?: {
|
|
38
|
+
appId: string;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
host?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export type Session = {
|
|
44
|
+
cookies?: string;
|
|
45
|
+
updatedAt?: string;
|
|
46
|
+
userAgent?: string;
|
|
47
|
+
};
|
|
48
|
+
export type ProductCacheEntry = {
|
|
49
|
+
product: ProductSummary;
|
|
50
|
+
cachedAt: string;
|
|
51
|
+
};
|
|
52
|
+
export type Cache = {
|
|
53
|
+
products: Record<string, ProductSummary | ProductCacheEntry>;
|
|
54
|
+
orders?: Record<string, {
|
|
55
|
+
status?: number;
|
|
56
|
+
updatedAt?: string;
|
|
57
|
+
detail: OrderDetail;
|
|
58
|
+
}>;
|
|
59
|
+
updatedAt?: string;
|
|
60
|
+
};
|
|
61
|
+
export type DeliverySlot = {
|
|
62
|
+
date: string;
|
|
63
|
+
key: string;
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
isAvailable: boolean;
|
|
67
|
+
loadPercent: number;
|
|
68
|
+
cutoffAfter?: string | null;
|
|
69
|
+
isPharmacyRestricted?: boolean;
|
|
70
|
+
isBakeryRestricted?: boolean;
|
|
71
|
+
};
|
|
72
|
+
export type OrderSummary = {
|
|
73
|
+
id: string;
|
|
74
|
+
shippingDate?: string;
|
|
75
|
+
timeSlotStart?: number;
|
|
76
|
+
timeSlotEnd?: number;
|
|
77
|
+
timeSlotDisplay?: string;
|
|
78
|
+
status?: number;
|
|
79
|
+
statusText?: string | null;
|
|
80
|
+
finalAmount?: string;
|
|
81
|
+
finalAmountEur?: string;
|
|
82
|
+
additionalOrdersCount?: number;
|
|
83
|
+
phone?: string;
|
|
84
|
+
};
|
|
85
|
+
export type OrderItem = {
|
|
86
|
+
id?: number;
|
|
87
|
+
name: string;
|
|
88
|
+
quantity?: string;
|
|
89
|
+
unit?: string;
|
|
90
|
+
price?: string;
|
|
91
|
+
priceEur?: string;
|
|
92
|
+
regularPrice?: string;
|
|
93
|
+
regularPriceEur?: string;
|
|
94
|
+
group?: string;
|
|
95
|
+
};
|
|
96
|
+
export type OrderDetail = {
|
|
97
|
+
id: string;
|
|
98
|
+
status?: number;
|
|
99
|
+
statusText?: string | null;
|
|
100
|
+
shippingDate?: string;
|
|
101
|
+
timeSlotDisplay?: string;
|
|
102
|
+
address?: string;
|
|
103
|
+
totals: {
|
|
104
|
+
total?: string;
|
|
105
|
+
totalEur?: string;
|
|
106
|
+
totalPaid?: string;
|
|
107
|
+
totalPaidEur?: string;
|
|
108
|
+
discount?: string;
|
|
109
|
+
discountEur?: string;
|
|
110
|
+
tip?: string;
|
|
111
|
+
tipEur?: string;
|
|
112
|
+
};
|
|
113
|
+
items: OrderItem[];
|
|
114
|
+
additionalOrders?: OrderDetail[];
|
|
115
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ebag",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Unofficial CLI for interacting with ebag.bg using cookie-based auth.",
|
|
5
|
+
"main": "dist/lib/index.js",
|
|
6
|
+
"types": "dist/lib/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ebag": "dist/cli/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json",
|
|
12
|
+
"capture": "node scripts/capture.mjs",
|
|
13
|
+
"local-install": "npm run build && TARBALL=$(npm pack --silent) && npm i -g \"$TARBALL\"",
|
|
14
|
+
"prepublishOnly": "npm run build",
|
|
15
|
+
"start": "node dist/cli/index.js",
|
|
16
|
+
"test:unit": "npm run build && node tests/format.test.mjs && node tests/cookie.test.mjs && node tests/slots.test.mjs && node tests/orders.test.mjs && node tests/search-cache.test.mjs",
|
|
17
|
+
"test:e2e": "npm run build && node tests/e2e.mjs",
|
|
18
|
+
"test": "npm run test:unit && npm run test:e2e"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ebag",
|
|
22
|
+
"ebag.bg",
|
|
23
|
+
"grocery",
|
|
24
|
+
"supermarket",
|
|
25
|
+
"shopping",
|
|
26
|
+
"delivery",
|
|
27
|
+
"cli",
|
|
28
|
+
"bulgaria",
|
|
29
|
+
"store"
|
|
30
|
+
],
|
|
31
|
+
"license": "GPL-3.0-or-later",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/nb/ebag.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": "https://github.com/nb/ebag/issues",
|
|
37
|
+
"homepage": "https://github.com/nb/ebag#readme",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"type": "commonjs",
|
|
42
|
+
"files": [
|
|
43
|
+
"dist"
|
|
44
|
+
],
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^25.0.9",
|
|
47
|
+
"playwright": "^1.57.0",
|
|
48
|
+
"typescript": "^5.9.3"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"commander": "^14.0.2"
|
|
52
|
+
}
|
|
53
|
+
}
|