MogensR commited on
Commit
8a0c470
·
1 Parent(s): e510a3d

Create examples/use-cases/e-commerce/product_autmomation.py

Browse files
examples/use-cases/e-commerce/product_autmomation.py ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ BackgroundFX Pro - E-commerce Product Image Automation
4
+
5
+ Automates product photography workflow for e-commerce platforms:
6
+ - Batch process product images
7
+ - Apply consistent backgrounds
8
+ - Generate multiple sizes for different platforms
9
+ - Create transparent PNGs for overlays
10
+ - Generate marketing variations
11
+ """
12
+
13
+ import os
14
+ import json
15
+ import csv
16
+ from pathlib import Path
17
+ from typing import List, Dict, Any, Optional, Tuple
18
+ from datetime import datetime
19
+ import asyncio
20
+ import aiohttp
21
+ from PIL import Image
22
+ import pandas as pd
23
+
24
+ # BackgroundFX API Configuration
25
+ API_KEY = os.getenv("BACKGROUNDFX_API_KEY")
26
+ API_URL = "https://api.backgroundfx.pro/v1"
27
+
28
+
29
+ class EcommerceProcessor:
30
+ """
31
+ Automated product image processor for e-commerce platforms.
32
+ """
33
+
34
+ # Platform-specific image requirements
35
+ PLATFORM_SPECS = {
36
+ 'shopify': {
37
+ 'main': (2048, 2048),
38
+ 'thumbnail': (100, 100),
39
+ 'collection': (600, 600),
40
+ 'cart': (100, 100),
41
+ 'formats': ['jpg', 'webp']
42
+ },
43
+ 'amazon': {
44
+ 'main': (2000, 2000),
45
+ 'zoom': (1600, 1600),
46
+ 'swatch': (30, 30),
47
+ 'formats': ['jpg']
48
+ },
49
+ 'ebay': {
50
+ 'main': (1600, 1600),
51
+ 'gallery': (800, 800),
52
+ 'thumbnail': (140, 140),
53
+ 'formats': ['jpg']
54
+ },
55
+ 'woocommerce': {
56
+ 'catalog': (300, 300),
57
+ 'single': (600, 600),
58
+ 'thumbnail': (150, 150),
59
+ 'formats': ['jpg', 'png']
60
+ }
61
+ }
62
+
63
+ # Background presets for different product categories
64
+ BACKGROUND_PRESETS = {
65
+ 'electronics': '#FFFFFF',
66
+ 'fashion': 'linear-gradient(180deg, #F5F5F5, #FFFFFF)',
67
+ 'jewelry': '#000000',
68
+ 'furniture': '#F8F8F8',
69
+ 'cosmetics': 'linear-gradient(45deg, #FFE5E5, #FFF0F5)',
70
+ 'sports': '#E8F4FD',
71
+ 'toys': 'linear-gradient(135deg, #667eea, #764ba2)',
72
+ 'food': '#FFF8DC'
73
+ }
74
+
75
+ def __init__(self, api_key: str):
76
+ """Initialize the processor with API credentials."""
77
+ self.api_key = api_key
78
+ self.session = None
79
+ self.stats = {
80
+ 'processed': 0,
81
+ 'failed': 0,
82
+ 'total_time': 0
83
+ }
84
+
85
+ async def __aenter__(self):
86
+ """Async context manager entry."""
87
+ self.session = aiohttp.ClientSession(
88
+ headers={'Authorization': f'Bearer {self.api_key}'}
89
+ )
90
+ return self
91
+
92
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
93
+ """Async context manager exit."""
94
+ if self.session:
95
+ await self.session.close()
96
+
97
+ async def process_product_catalog(
98
+ self,
99
+ csv_path: str,
100
+ output_dir: str,
101
+ platform: str = 'shopify'
102
+ ) -> Dict[str, Any]:
103
+ """
104
+ Process entire product catalog from CSV.
105
+
106
+ CSV Format:
107
+ sku,image_path,category,title,background_override
108
+ """
109
+ print(f"📦 Processing product catalog for {platform}")
110
+
111
+ # Read product catalog
112
+ df = pd.read_csv(csv_path)
113
+ total_products = len(df)
114
+
115
+ results = []
116
+
117
+ for idx, row in df.iterrows():
118
+ print(f"\n[{idx+1}/{total_products}] Processing {row['sku']}...")
119
+
120
+ try:
121
+ result = await self.process_product(
122
+ image_path=row['image_path'],
123
+ sku=row['sku'],
124
+ category=row.get('category', 'general'),
125
+ output_dir=output_dir,
126
+ platform=platform,
127
+ custom_background=row.get('background_override')
128
+ )
129
+ results.append(result)
130
+ self.stats['processed'] += 1
131
+
132
+ except Exception as e:
133
+ print(f" ❌ Failed: {e}")
134
+ self.stats['failed'] += 1
135
+ results.append({
136
+ 'sku': row['sku'],
137
+ 'status': 'failed',
138
+ 'error': str(e)
139
+ })
140
+
141
+ # Generate report
142
+ report = self.generate_report(results, output_dir)
143
+
144
+ return {
145
+ 'total': total_products,
146
+ 'processed': self.stats['processed'],
147
+ 'failed': self.stats['failed'],
148
+ 'report': report
149
+ }
150
+
151
+ async def process_product(
152
+ self,
153
+ image_path: str,
154
+ sku: str,
155
+ category: str,
156
+ output_dir: str,
157
+ platform: str,
158
+ custom_background: Optional[str] = None
159
+ ) -> Dict[str, Any]:
160
+ """Process single product image."""
161
+
162
+ start_time = datetime.now()
163
+
164
+ # Step 1: Remove background
165
+ processed_image = await self.remove_background(image_path)
166
+
167
+ # Step 2: Apply appropriate background
168
+ background = custom_background or self.BACKGROUND_PRESETS.get(
169
+ category, '#FFFFFF'
170
+ )
171
+ final_image = await self.apply_background(
172
+ processed_image['id'],
173
+ background
174
+ )
175
+
176
+ # Step 3: Generate platform-specific sizes
177
+ platform_images = await self.generate_platform_images(
178
+ final_image['url'],
179
+ sku,
180
+ platform,
181
+ output_dir
182
+ )
183
+
184
+ # Step 4: Create marketing variations
185
+ marketing_images = await self.create_marketing_variations(
186
+ processed_image['id'],
187
+ sku,
188
+ output_dir
189
+ )
190
+
191
+ elapsed = (datetime.now() - start_time).total_seconds()
192
+
193
+ return {
194
+ 'sku': sku,
195
+ 'status': 'success',
196
+ 'processing_time': elapsed,
197
+ 'platform_images': platform_images,
198
+ 'marketing_images': marketing_images,
199
+ 'original': image_path,
200
+ 'processed': final_image['url']
201
+ }
202
+
203
+ async def remove_background(self, image_path: str) -> Dict[str, Any]:
204
+ """Remove background from product image."""
205
+
206
+ with open(image_path, 'rb') as f:
207
+ data = aiohttp.FormData()
208
+ data.add_field('file', f, filename=Path(image_path).name)
209
+ data.add_field('quality', 'ultra')
210
+ data.add_field('model', 'u2net') # Best for products
211
+ data.add_field('return_mask', 'true')
212
+
213
+ async with self.session.post(
214
+ f"{API_URL}/process/remove-background",
215
+ data=data
216
+ ) as response:
217
+ response.raise_for_status()
218
+ return await response.json()
219
+
220
+ async def apply_background(
221
+ self,
222
+ image_id: str,
223
+ background: str
224
+ ) -> Dict[str, Any]:
225
+ """Apply background to processed image."""
226
+
227
+ payload = {
228
+ 'image_id': image_id,
229
+ 'background': background,
230
+ 'blend_mode': 'normal'
231
+ }
232
+
233
+ async with self.session.post(
234
+ f"{API_URL}/process/replace-background",
235
+ json=payload
236
+ ) as response:
237
+ response.raise_for_status()
238
+ return await response.json()
239
+
240
+ async def generate_platform_images(
241
+ self,
242
+ image_url: str,
243
+ sku: str,
244
+ platform: str,
245
+ output_dir: str
246
+ ) -> List[Dict[str, str]]:
247
+ """Generate platform-specific image sizes."""
248
+
249
+ specs = self.PLATFORM_SPECS.get(platform, self.PLATFORM_SPECS['shopify'])
250
+ platform_dir = Path(output_dir) / platform / sku
251
+ platform_dir.mkdir(parents=True, exist_ok=True)
252
+
253
+ generated = []
254
+
255
+ # Download processed image
256
+ async with self.session.get(image_url) as response:
257
+ image_data = await response.read()
258
+
259
+ # Open with PIL
260
+ from io import BytesIO
261
+ img = Image.open(BytesIO(image_data))
262
+
263
+ # Generate each required size
264
+ for size_name, dimensions in specs.items():
265
+ if size_name == 'formats':
266
+ continue
267
+
268
+ # Resize image
269
+ resized = self.resize_image(img, dimensions)
270
+
271
+ # Save in required formats
272
+ for format_ext in specs['formats']:
273
+ output_path = platform_dir / f"{sku}_{size_name}.{format_ext}"
274
+
275
+ if format_ext == 'webp':
276
+ resized.save(output_path, 'WEBP', quality=90)
277
+ else:
278
+ resized.save(output_path, 'JPEG', quality=95)
279
+
280
+ generated.append({
281
+ 'type': size_name,
282
+ 'format': format_ext,
283
+ 'path': str(output_path),
284
+ 'dimensions': dimensions
285
+ })
286
+
287
+ print(f" ✅ Generated {len(generated)} platform images")
288
+ return generated
289
+
290
+ async def create_marketing_variations(
291
+ self,
292
+ image_id: str,
293
+ sku: str,
294
+ output_dir: str
295
+ ) -> List[Dict[str, str]]:
296
+ """Create marketing variations with different backgrounds."""
297
+
298
+ marketing_dir = Path(output_dir) / 'marketing' / sku
299
+ marketing_dir.mkdir(parents=True, exist_ok=True)
300
+
301
+ variations = [
302
+ {'name': 'lifestyle', 'bg': 'https://example.com/lifestyle-bg.jpg'},
303
+ {'name': 'seasonal_summer', 'bg': 'linear-gradient(to bottom, #87CEEB, #98FB98)'},
304
+ {'name': 'seasonal_winter', 'bg': 'linear-gradient(to bottom, #B0E0E6, #FFFFFF)'},
305
+ {'name': 'premium', 'bg': 'linear-gradient(45deg, #FFD700, #FFA500)'},
306
+ {'name': 'minimal', 'bg': '#F5F5F5'},
307
+ {'name': 'dark', 'bg': '#1a1a1a'},
308
+ {'name': 'transparent', 'bg': 'transparent'}
309
+ ]
310
+
311
+ generated = []
312
+
313
+ for variation in variations:
314
+ try:
315
+ result = await self.apply_background(image_id, variation['bg'])
316
+
317
+ # Download and save
318
+ async with self.session.get(result['url']) as response:
319
+ image_data = await response.read()
320
+
321
+ output_path = marketing_dir / f"{sku}_{variation['name']}.png"
322
+ with open(output_path, 'wb') as f:
323
+ f.write(image_data)
324
+
325
+ generated.append({
326
+ 'name': variation['name'],
327
+ 'path': str(output_path),
328
+ 'background': variation['bg']
329
+ })
330
+
331
+ except Exception as e:
332
+ print(f" ⚠️ Failed to create {variation['name']}: {e}")
333
+
334
+ print(f" ✅ Created {len(generated)} marketing variations")
335
+ return generated
336
+
337
+ def resize_image(
338
+ self,
339
+ img: Image.Image,
340
+ target_size: Tuple[int, int]
341
+ ) -> Image.Image:
342
+ """
343
+ Resize image maintaining aspect ratio and adding padding if needed.
344
+ """
345
+ # Calculate aspect ratios
346
+ img_ratio = img.width / img.height
347
+ target_ratio = target_size[0] / target_size[1]
348
+
349
+ if img_ratio > target_ratio:
350
+ # Image is wider - fit to width
351
+ new_width = target_size[0]
352
+ new_height = int(new_width / img_ratio)
353
+ else:
354
+ # Image is taller - fit to height
355
+ new_height = target_size[1]
356
+ new_width = int(new_height * img_ratio)
357
+
358
+ # Resize image
359
+ resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
360
+
361
+ # Create new image with padding
362
+ final = Image.new('RGBA', target_size, (255, 255, 255, 0))
363
+
364
+ # Calculate position to center the resized image
365
+ x = (target_size[0] - new_width) // 2
366
+ y = (target_size[1] - new_height) // 2
367
+
368
+ # Paste resized image
369
+ final.paste(resized, (x, y), resized if resized.mode == 'RGBA' else None)
370
+
371
+ return final
372
+
373
+ def generate_report(
374
+ self,
375
+ results: List[Dict[str, Any]],
376
+ output_dir: str
377
+ ) -> str:
378
+ """Generate processing report."""
379
+
380
+ report_path = Path(output_dir) / f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
381
+
382
+ successful = [r for r in results if r.get('status') == 'success']
383
+ failed = [r for r in results if r.get('status') == 'failed']
384
+
385
+ report = {
386
+ 'timestamp': datetime.now().isoformat(),
387
+ 'summary': {
388
+ 'total': len(results),
389
+ 'successful': len(successful),
390
+ 'failed': len(failed),
391
+ 'success_rate': f"{(len(successful) / len(results) * 100):.1f}%",
392
+ 'total_processing_time': sum(r.get('processing_time', 0) for r in successful),
393
+ 'average_time_per_product': sum(r.get('processing_time', 0) for r in successful) / len(successful) if successful else 0
394
+ },
395
+ 'successful_products': successful,
396
+ 'failed_products': failed
397
+ }
398
+
399
+ with open(report_path, 'w') as f:
400
+ json.dump(report, f, indent=2)
401
+
402
+ print(f"\n📊 Report saved to: {report_path}")
403
+ return str(report_path)
404
+
405
+
406
+ class ShopifyIntegration:
407
+ """
408
+ Direct Shopify integration for automated product image updates.
409
+ """
410
+
411
+ def __init__(self, shop_url: str, access_token: str, backgroundfx_key: str):
412
+ """Initialize Shopify integration."""
413
+ self.shop_url = shop_url
414
+ self.access_token = access_token
415
+ self.processor = EcommerceProcessor(backgroundfx_key)
416
+ self.shopify_api = f"https://{shop_url}/admin/api/2024-01"
417
+
418
+ async def sync_product_images(self, product_id: str = None):
419
+ """
420
+ Sync product images with Shopify store.
421
+ """
422
+ # Get products from Shopify
423
+ products = await self.get_shopify_products(product_id)
424
+
425
+ for product in products:
426
+ print(f"\n📦 Processing: {product['title']} (ID: {product['id']})")
427
+
428
+ for image in product['images']:
429
+ # Download original image
430
+ original_path = await self.download_image(image['src'])
431
+
432
+ # Process with BackgroundFX
433
+ async with self.processor as proc:
434
+ result = await proc.process_product(
435
+ image_path=original_path,
436
+ sku=product['variants'][0]['sku'],
437
+ category=product['product_type'].lower(),
438
+ output_dir='shopify_processed',
439
+ platform='shopify'
440
+ )
441
+
442
+ # Update Shopify with processed images
443
+ if result['status'] == 'success':
444
+ await self.update_shopify_image(
445
+ product['id'],
446
+ image['id'],
447
+ result['processed']
448
+ )
449
+
450
+ async def get_shopify_products(self, product_id: Optional[str] = None):
451
+ """Fetch products from Shopify."""
452
+ endpoint = f"{self.shopify_api}/products"
453
+ if product_id:
454
+ endpoint += f"/{product_id}"
455
+
456
+ async with aiohttp.ClientSession() as session:
457
+ async with session.get(
458
+ endpoint + ".json",
459
+ headers={'X-Shopify-Access-Token': self.access_token}
460
+ ) as response:
461
+ data = await response.json()
462
+ return data['products'] if 'products' in data else [data['product']]
463
+
464
+ async def download_image(self, url: str) -> str:
465
+ """Download image from Shopify CDN."""
466
+ async with aiohttp.ClientSession() as session:
467
+ async with session.get(url) as response:
468
+ content = await response.read()
469
+
470
+ # Save to temp file
471
+ temp_path = Path('temp') / f"shopify_{datetime.now().timestamp()}.jpg"
472
+ temp_path.parent.mkdir(exist_ok=True)
473
+
474
+ with open(temp_path, 'wb') as f:
475
+ f.write(content)
476
+
477
+ return str(temp_path)
478
+
479
+ async def update_shopify_image(
480
+ self,
481
+ product_id: str,
482
+ image_id: str,
483
+ new_image_url: str
484
+ ):
485
+ """Update product image in Shopify."""
486
+ endpoint = f"{self.shopify_api}/products/{product_id}/images/{image_id}.json"
487
+
488
+ payload = {
489
+ 'image': {
490
+ 'id': image_id,
491
+ 'src': new_image_url
492
+ }
493
+ }
494
+
495
+ async with aiohttp.ClientSession() as session:
496
+ async with session.put(
497
+ endpoint,
498
+ json=payload,
499
+ headers={'X-Shopify-Access-Token': self.access_token}
500
+ ) as response:
501
+ if response.status == 200:
502
+ print(f" ✅ Updated image in Shopify")
503
+ else:
504
+ print(f" ❌ Failed to update Shopify: {response.status}")
505
+
506
+
507
+ async def main():
508
+ """
509
+ Main execution function with examples.
510
+ """
511
+ print("=" * 60)
512
+ print("BackgroundFX Pro - E-commerce Automation")
513
+ print("=" * 60)
514
+
515
+ # Example 1: Process product catalog from CSV
516
+ print("\n📋 Example 1: Batch Process Product Catalog")
517
+
518
+ async with EcommerceProcessor(API_KEY) as processor:
519
+ # Create sample CSV
520
+ sample_csv = "products.csv"
521
+ with open(sample_csv, 'w', newline='') as f:
522
+ writer = csv.writer(f)
523
+ writer.writerow(['sku', 'image_path', 'category', 'title', 'background_override'])
524
+ writer.writerow(['PROD-001', 'images/shoe.jpg', 'fashion', 'Running Shoe', ''])
525
+ writer.writerow(['PROD-002', 'images/watch.jpg', 'electronics', 'Smart Watch', '#000000'])
526
+ writer.writerow(['PROD-003', 'images/bag.jpg', 'fashion', 'Leather Bag', ''])
527
+
528
+ results = await processor.process_product_catalog(
529
+ csv_path=sample_csv,
530
+ output_dir='output/ecommerce',
531
+ platform='shopify'
532
+ )
533
+
534
+ print(f"\n✅ Processed {results['processed']} products")
535
+ print(f"❌ Failed: {results['failed']}")
536
+ print(f"📊 Report: {results['report']}")
537
+
538
+ # Example 2: Single product with multiple platforms
539
+ print("\n📦 Example 2: Multi-Platform Product Processing")
540
+
541
+ platforms = ['shopify', 'amazon', 'ebay']
542
+
543
+ async with EcommerceProcessor(API_KEY) as processor:
544
+ for platform in platforms:
545
+ print(f"\n Processing for {platform}...")
546
+
547
+ result = await processor.process_product(
548
+ image_path='images/product.jpg',
549
+ sku='MULTI-001',
550
+ category='electronics',
551
+ output_dir='output/multiplatform',
552
+ platform=platform
553
+ )
554
+
555
+ if result['status'] == 'success':
556
+ print(f" ✅ Generated {len(result['platform_images'])} images")
557
+
558
+ # Example 3: Shopify Integration
559
+ print("\n🛍️ Example 3: Direct Shopify Integration")
560
+
561
+ if os.getenv('SHOPIFY_SHOP_URL') and os.getenv('SHOPIFY_ACCESS_TOKEN'):
562
+ integration = ShopifyIntegration(
563
+ shop_url=os.getenv('SHOPIFY_SHOP_URL'),
564
+ access_token=os.getenv('SHOPIFY_ACCESS_TOKEN'),
565
+ backgroundfx_key=API_KEY
566
+ )
567
+
568
+ await integration.sync_product_images()
569
+ else:
570
+ print(" ⚠️ Set SHOPIFY_SHOP_URL and SHOPIFY_ACCESS_TOKEN to test integration")
571
+
572
+ # Example 4: A/B Testing Different Backgrounds
573
+ print("\n🧪 Example 4: A/B Testing Backgrounds")
574
+
575
+ test_backgrounds = [
576
+ {'name': 'white', 'bg': '#FFFFFF'},
577
+ {'name': 'gradient', 'bg': 'linear-gradient(180deg, #F5F5F5, #FFFFFF)'},
578
+ {'name': 'colored', 'bg': '#E8F4FD'},
579
+ {'name': 'lifestyle', 'bg': 'https://example.com/kitchen-bg.jpg'}
580
+ ]
581
+
582
+ async with EcommerceProcessor(API_KEY) as processor:
583
+ # Remove background once
584
+ processed = await processor.remove_background('images/product.jpg')
585
+
586
+ # Test different backgrounds
587
+ for test in test_backgrounds:
588
+ result = await processor.apply_background(
589
+ processed['id'],
590
+ test['bg']
591
+ )
592
+ print(f" ✅ Created variant: {test['name']}")
593
+
594
+ print("\n" + "=" * 60)
595
+ print("E-commerce automation complete!")
596
+ print("=" * 60)
597
+
598
+
599
+ if __name__ == "__main__":
600
+ # Check API key
601
+ if not API_KEY:
602
+ print("❌ Please set BACKGROUNDFX_API_KEY environment variable")
603
+ exit(1)
604
+
605
+ # Run async main
606
+ asyncio.run(main())