-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproduct_scraper.js
More file actions
239 lines (199 loc) · 8.31 KB
/
Copy pathproduct_scraper.js
File metadata and controls
239 lines (199 loc) · 8.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import "dotenv/config";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
);
// Categories and search terms to scrape
// Each term generates multiple pages of results from Open Food Facts
const SEARCH_TERMS = [
// Dairy
"milk", "cheese", "yogurt", "butter", "cream", "eggs", "sour cream",
"cottage cheese", "cream cheese",
// Produce
"apples", "bananas", "oranges", "strawberries", "blueberries",
"grapes", "lemons", "limes", "avocado", "tomatoes", "potatoes",
"onions", "carrots", "broccoli", "spinach", "lettuce", "peppers",
"garlic", "celery", "cucumber", "mushrooms", "corn",
// Meat
"chicken breast", "ground beef", "ground turkey", "pork chops",
"bacon", "sausage", "salmon", "shrimp", "deli turkey", "deli ham",
"hot dogs", "steak",
// Bakery
"bread", "buns", "tortillas", "bagels", "muffins", "rolls",
"pita bread", "english muffins", "croissants",
// Pantry
"pasta", "rice", "spaghetti sauce", "olive oil", "canola oil",
"flour", "sugar", "peanut butter", "jelly", "canned tomatoes",
"canned beans", "chicken broth", "soup", "cereal", "oatmeal",
"granola", "honey", "syrup", "vinegar", "soy sauce", "ketchup",
"mustard", "mayonnaise", "salsa", "hot sauce",
// Beverages
"orange juice", "apple juice", "coffee", "tea", "sparkling water",
"soda", "lemonade", "almond milk", "oat milk", "coconut water",
// Frozen
"frozen pizza", "frozen vegetables", "ice cream", "frozen fruit",
"frozen meals", "frozen waffles", "frozen fries",
// Snacks
"chips", "crackers", "cookies", "granola bars", "popcorn",
"pretzels", "nuts", "trail mix", "dried fruit",
// Household
"paper towels", "toilet paper", "dish soap", "laundry detergent",
"trash bags", "aluminum foil", "plastic wrap", "sponges",
// Baby
"baby food", "baby formula", "diapers", "baby wipes",
// Pet
"dog food", "cat food", "cat litter",
// Breakfast
"pancake mix", "cereal bars", "instant oatmeal",
// Condiments
"salt", "pepper", "cinnamon", "garlic powder", "cumin",
"paprika", "italian seasoning", "ranch dressing", "salad dressing",
];
function mapCategory(tags) {
if (!tags || tags.length === 0) return "Grocery";
const joined = tags.join(" ").toLowerCase();
if (joined.includes("dairy") || joined.includes("milk") || joined.includes("cheese") || joined.includes("yogurt")) return "Dairy";
if (joined.includes("meat") || joined.includes("poultry") || joined.includes("beef") || joined.includes("chicken") || joined.includes("pork")) return "Meat";
if (joined.includes("fruit") || joined.includes("vegetable") || joined.includes("produce") || joined.includes("fresh")) return "Produce";
if (joined.includes("bread") || joined.includes("baked") || joined.includes("bakery")) return "Bakery";
if (joined.includes("frozen")) return "Frozen";
if (joined.includes("beverage") || joined.includes("drink") || joined.includes("juice") || joined.includes("coffee") || joined.includes("tea") || joined.includes("water")) return "Beverages";
if (joined.includes("snack") || joined.includes("chip") || joined.includes("cookie") || joined.includes("cracker")) return "Snacks";
if (joined.includes("cereal") || joined.includes("breakfast")) return "Breakfast";
if (joined.includes("condiment") || joined.includes("sauce") || joined.includes("spice") || joined.includes("seasoning")) return "Condiments";
if (joined.includes("pasta") || joined.includes("rice") || joined.includes("grain") || joined.includes("bean") || joined.includes("canned")) return "Pantry";
if (joined.includes("cleaning") || joined.includes("household") || joined.includes("paper")) return "Household";
if (joined.includes("baby")) return "Baby";
if (joined.includes("pet")) return "Pet";
return "Grocery";
}
const CATEGORY_BASE_PRICES = {
Dairy: 4.29, Produce: 2.99, Meat: 6.49, Bakery: 3.29,
Pantry: 3.49, Beverages: 4.99, Frozen: 4.49, Snacks: 3.99,
Breakfast: 4.49, Condiments: 3.29, Household: 6.99,
Baby: 8.99, Pet: 7.99, Grocery: 3.99,
};
async function fetchProducts(searchTerm, page = 1, retries = 3) {
const url =
`https://world.openfoodfacts.org/cgi/search.pl?` +
`search_terms=${encodeURIComponent(searchTerm)}` +
`&search_simple=1` +
`&action=process` +
`&json=1` +
`&page=${page}` +
`&page_size=20` +
`&countries_tags_en=united-states` +
`&fields=product_name,brands,quantity,categories_tags_en,code`;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) {
throw new Error(`OFF API error: ${res.status}`);
}
return (await res.json()).products || [];
} catch (err) {
console.log(` Attempt ${attempt}/${retries} failed: ${err.message}`);
if (attempt === retries) return [];
// Be respectful to the free API
await new Promise((r) => setTimeout(r, 2000));
}
}
return [];
}
function parseProduct(raw) {
if (!raw.product_name || raw.product_name.trim().length < 3) return null;
const brand = raw.brands?.split(",")[0]?.trim();
const qty = raw.quantity;
let name = raw.product_name.trim();
// Prepend brand if not already in the name
if (brand && !name.toLowerCase().includes(brand.toLowerCase())) {
name = `${brand} ${name}`;
}
// Append quantity/size if present
if (qty && !name.includes(qty)) {
name = `${name} (${qty})`;
}
// Skip names that are too long or clearly garbage data
if (name.length > 120) return null;
const category = mapCategory(raw.categories_tags_en);
const basePrice = CATEGORY_BASE_PRICES[category] || 3.99;
return {
name,
category,
base_price: basePrice,
upc: raw.code || null,
};
}
async function upsertProducts(products) {
if (products.length === 0) return 0;
// Deduplicate by name within the batch
const seen = new Set();
const unique = products.filter((p) => {
const key = p.name.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const chunkSize = 100;
let total = 0;
for (let i = 0; i < unique.length; i += chunkSize) {
const chunk = unique.slice(i, i + chunkSize);
// Check which ones already exist
const names = chunk.map((p) => p.name);
const { data: existing } = await supabase
.from("products")
.select("name")
.in("name", names);
const existingNames = new Set((existing || []).map((e) => e.name.toLowerCase()));
const newProducts = chunk.filter((p) => !existingNames.has(p.name.toLowerCase()));
if (newProducts.length > 0) {
const { error } = await supabase.from("products").insert(newProducts);
if (error) {
console.error(` Error inserting chunk:`, error.message);
} else {
total += newProducts.length;
}
}
}
return total;
}
async function run() {
console.log(`[product_scraper] Starting at ${new Date().toISOString()}`);
console.log(`[product_scraper] ${SEARCH_TERMS.length} search terms to process\n`);
let totalNew = 0;
let totalProcessed = 0;
for (let t = 0; t < SEARCH_TERMS.length; t++) {
const term = SEARCH_TERMS[t];
console.log(`[${t + 1}/${SEARCH_TERMS.length}] Searching: "${term}"`);
try {
const allRaw = [];
let page = 1;
const maxPages = 3;
while (page <= maxPages) {
const raw = await fetchProducts(term, page);
allRaw.push(...raw);
// Stop if we got fewer results than page size (no more pages)
if (raw.length < 20) break;
page++;
await new Promise((r) => setTimeout(r, 2000));
}
const parsed = allRaw.map(parseProduct).filter(Boolean);
totalProcessed += parsed.length;
const inserted = await upsertProducts(parsed);
totalNew += inserted;
console.log(` Found ${allRaw.length} raw → ${parsed.length} valid → ${inserted} new (${page} page${page > 1 ? "s" : ""})`);
// Be respectful to the free API
await new Promise((r) => setTimeout(r, 2000));
} catch (err) {
console.error(` Error for "${term}":`, err.message);
}
}
console.log(`\n[product_scraper] Complete.`);
console.log(` Total processed: ${totalProcessed}`);
console.log(` Total new products added: ${totalNew}`);
}
run().catch((err) => {
console.error("[product_scraper] Fatal error:", err);
process.exit(1);
});