@danhachuel/thunderbolt 0.6.43 → 0.6.45

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.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import logging
4
5
  import re
5
6
  import uuid
6
7
  import zipfile
@@ -20,6 +21,9 @@ from integrations.instagram_public import fetch_public_instagram_posts, fetch_pu
20
21
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
21
22
 
22
23
 
24
+ LOGGER = logging.getLogger(__name__)
25
+
26
+
23
27
  def _clean(value: Any) -> str:
24
28
  return str(value or "").strip()
25
29
 
@@ -190,8 +194,8 @@ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
190
194
  elif "webp" in content_type:
191
195
  extension = ".webp"
192
196
  bundle.writestr(f"post-{index:02d}{extension}", response.content)
193
- except requests.RequestException:
194
- pass
197
+ except requests.RequestException as exc:
198
+ LOGGER.warning("Não foi possível descarregar a imagem do post Instagram %s: %s", url, exc)
195
199
  manifest.append(f"{index}. {_clean(post.get('url'))}\n{_clean(post.get('caption'))}\n")
196
200
  bundle.writestr("posts.txt", "\n".join(manifest))
197
201
  return archive.getvalue()
@@ -212,6 +216,8 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
212
216
  if load_clicked or refresh_clicked:
213
217
  target = max(10, len(posts)) if refresh_clicked else 10
214
218
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=target)
219
+ # Debug temporário removido após validação; manter para reactivar se necessário:
220
+ # st.write({"posts_ok": posts_ok, "posts_message": posts_message, "posts_count": len(loaded_posts)})
215
221
  if posts_ok:
216
222
  posts = loaded_posts
217
223
  _persist_instagram_posts(profile, posts)
@@ -386,7 +392,8 @@ def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any
386
392
  repository = get_repository(settings)
387
393
  items = [item for item in repository.list_influencers() if _clean(item.get("id")) != STANDALONE_CONTENT_INFLUENCER_ID]
388
394
  return items, repository
389
- except (InfluencerBackendError, Exception):
395
+ except Exception as exc:
396
+ LOGGER.warning("Não foi possível carregar as personagens: %s", exc, exc_info=True)
390
397
  return [], None
391
398
 
392
399
 
@@ -498,19 +505,39 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
498
505
  refresh_col, edit_col = st.columns(2)
499
506
  with refresh_col:
500
507
  if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
501
- refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
508
+ try:
509
+ refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
510
+ except Exception as exc:
511
+ st.error(f"Falha ao actualizar o perfil Instagram: {exc}")
512
+ refreshed_ok, refreshed_message, refreshed = False, str(exc), {}
502
513
  if refreshed_ok:
503
514
  current_posts = _stored_instagram_posts(profile)
504
- posts_ok, _, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
515
+ try:
516
+ posts_ok, posts_message, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
517
+ except Exception as exc:
518
+ posts_ok, posts_message, refreshed_posts = False, str(exc), []
505
519
  if posts_ok:
506
520
  _persist_instagram_posts(profile, refreshed_posts)
507
- updated = update_channel(profile_id, {**refreshed, "id": profile_id, "country": _profile_country(refreshed) or _profile_country(profile), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
521
+ else:
522
+ st.warning(f"Perfil actualizado, mas os posts não foram actualizados: {posts_message}")
523
+ # Debug temporário removido após validação; manter para reactivar se necessário:
524
+ # st.write({"profile_ok": refreshed_ok, "profile_message": refreshed_message, "profile_data": refreshed})
525
+ # st.write({"posts_ok": posts_ok, "posts_message": posts_message, "posts_count": len(refreshed_posts)})
526
+ try:
527
+ updated = update_channel(profile_id, {**refreshed, "id": profile_id, "country": _profile_country(refreshed) or _profile_country(profile), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
528
+ except (OSError, PermissionError) as exc:
529
+ st.error(f"Não foi possível guardar os dados no Windows: {exc}")
530
+ return
531
+ except Exception as exc:
532
+ st.error(f"Falha ao guardar os dados da conta Instagram: {exc}")
533
+ return
508
534
  if updated is None:
509
- st.error("A conta Instagram já não existe no armazenamento local.")
535
+ st.error("A conta Instagram já não existe no armazenamento local ou não foi possível escrever o ficheiro.")
510
536
  return
511
537
  st.success("Métricas Instagram actualizadas.")
512
538
  st.rerun()
513
- st.warning(refreshed_message)
539
+ else:
540
+ st.error(f"Não foi possível actualizar o perfil Instagram: {refreshed_message}")
514
541
  with edit_col:
515
542
  if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
516
543
  for field in ("name", "handle", "bio", "country", "language", "posts", "followers", "following"):
@@ -2,22 +2,106 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  import json
5
+ import logging
5
6
  import os
7
+ import sys
6
8
  import shutil
7
9
  import subprocess
8
10
  from dataclasses import dataclass
9
11
  from datetime import datetime, timezone
10
12
  from html import unescape
13
+ from pathlib import Path
11
14
  from typing import Any, Mapping
12
15
  from urllib.parse import urlparse
13
16
 
14
17
  import requests
15
18
 
19
+ try:
20
+ import streamlit as st
21
+ except ImportError: # pragma: no cover - Streamlit is a runtime dependency
22
+ st = None
23
+
24
+
25
+ LOGGER = logging.getLogger(__name__)
26
+ _WINDOWS_PLAYWRIGHT_CHECKED = False
27
+
16
28
 
17
29
  ABOUT_ACCOUNT_URL = 'https://i.instagram.com/api/v1/bloks/apps/com.instagram.interactions.about_this_account/'
18
30
  ABOUT_ACCOUNT_BLOKS_VERSION = '8ca96ca267e30c02cf90888d91eeff09627f0e3fd2bd9df472278c9a6c022cbb'
19
31
 
20
32
 
33
+ def _load_dotenv_compat() -> None:
34
+ """Load .env when python-dotenv exists, otherwise parse simple KEY=VALUE lines."""
35
+ try:
36
+ from dotenv import load_dotenv
37
+ load_dotenv()
38
+ return
39
+ except ImportError:
40
+ LOGGER.debug('python-dotenv não está instalado; será usado o parser .env compatível.')
41
+ env_path = Path(__file__).resolve().parents[1] / '.env'
42
+ if not env_path.is_file():
43
+ return
44
+ try:
45
+ for raw_line in env_path.read_text(encoding='utf-8').splitlines():
46
+ line = raw_line.strip()
47
+ if not line or line.startswith('#') or '=' not in line:
48
+ continue
49
+ key, value = line.split('=', 1)
50
+ value = value.strip().strip('"').strip("'")
51
+ os.environ.setdefault(key.strip(), value)
52
+ except OSError as exc:
53
+ LOGGER.warning('Não foi possível ler %s: %s', env_path, exc)
54
+
55
+
56
+ def _streamlit_message(level: str, message: str, key: str) -> None:
57
+ """Show a runtime diagnostic once when Streamlit is available."""
58
+ if st is None:
59
+ return
60
+ try:
61
+ shown_key = f'_instagram_debug_{key}'
62
+ if st.session_state.get(shown_key):
63
+ return
64
+ st.session_state[shown_key] = True
65
+ getattr(st, level)(message)
66
+ except Exception as exc: # Streamlit may be imported outside an active app context.
67
+ LOGGER.debug('Não foi possível mostrar diagnóstico Streamlit: %s', exc)
68
+
69
+
70
+ def _ensure_windows_playwright() -> None:
71
+ """Ensure Chromium exists on Windows before using the Playwright fallback."""
72
+ global _WINDOWS_PLAYWRIGHT_CHECKED
73
+ if os.name != 'nt' or _WINDOWS_PLAYWRIGHT_CHECKED:
74
+ return
75
+ _WINDOWS_PLAYWRIGHT_CHECKED = True
76
+ _load_dotenv_compat()
77
+ try:
78
+ completed = subprocess.run(
79
+ [sys.executable, '-m', 'playwright', 'install', 'chromium'],
80
+ capture_output=True,
81
+ text=True,
82
+ encoding='utf-8',
83
+ errors='replace',
84
+ timeout=180,
85
+ check=False,
86
+ )
87
+ if completed.returncode != 0:
88
+ detail = (completed.stderr or completed.stdout or 'sem detalhes').strip()[-1000:]
89
+ message = f'Não foi possível instalar o Chromium do Playwright no Windows: {detail}'
90
+ LOGGER.warning(message)
91
+ _streamlit_message('error', message + ' Execute: python -m playwright install chromium', 'playwright_install_error')
92
+ else:
93
+ LOGGER.info('Chromium do Playwright verificado/instalado no Windows.')
94
+ except (OSError, subprocess.SubprocessError) as exc:
95
+ message = f'Falha ao executar a instalação do Chromium no Windows: {exc}'
96
+ LOGGER.warning(message)
97
+ _streamlit_message('error', message + ' Execute: python -m playwright install chromium', 'playwright_install_exception')
98
+
99
+
100
+ _load_dotenv_compat()
101
+ if os.name == 'nt':
102
+ _ensure_windows_playwright()
103
+
104
+
21
105
  def _instagram_cookies() -> dict[str, str]:
22
106
  cookies = {}
23
107
  sessionid = os.getenv('INSTAGRAM_SESSIONID') or os.getenv('IG_SESSIONID')
@@ -130,6 +214,8 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
130
214
 
131
215
 
132
216
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
217
+ _ensure_windows_playwright()
218
+ cookies = _instagram_cookies()
133
219
  headers = _instagram_headers()
134
220
  headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
135
221
  # The www host is the current public web endpoint. The legacy i host is
@@ -179,11 +265,14 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
179
265
  for request_header in request_headers:
180
266
  for endpoint in endpoints:
181
267
  try:
182
- response = requests.get(endpoint, headers=request_header, timeout=15)
268
+ response = requests.get(endpoint, headers=request_header, cookies=cookies or None, timeout=15)
269
+ LOGGER.debug('Instagram profile URL=%s status=%s content_type=%s', endpoint, response.status_code, response.headers.get('content-type', ''))
183
270
  if response.status_code >= 400:
184
271
  continue
185
272
  payload = response.json()
273
+ LOGGER.debug('Instagram profile URL=%s response=JSON', endpoint)
186
274
  except (requests.RequestException, ValueError, AttributeError, StopIteration):
275
+ LOGGER.warning('Falha na requisição JSON do perfil Instagram URL=%s', endpoint, exc_info=True)
187
276
  if best_user:
188
277
  return best_user
189
278
  continue
@@ -195,6 +284,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
195
284
 
196
285
  curl = shutil.which('curl')
197
286
  if curl:
287
+ LOGGER.debug('Fallback curl disponível em %s', curl)
198
288
  for endpoint in endpoints:
199
289
  try:
200
290
  completed = subprocess.run(
@@ -207,13 +297,18 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
207
297
  check=False,
208
298
  )
209
299
  payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
300
+ LOGGER.debug('Instagram curl URL=%s returncode=%s stdout_bytes=%s stderr=%s', endpoint, completed.returncode, len(completed.stdout or ''), (completed.stderr or '').strip()[-500:])
210
301
  user = extract_user(payload)
211
302
  if isinstance(user, dict):
212
303
  best_user = merge_profile(best_user, user)
213
304
  if profile_quality(best_user) >= 4:
214
305
  return best_user
215
306
  except (OSError, subprocess.SubprocessError, ValueError):
307
+ LOGGER.warning('Falha no fallback curl do perfil Instagram URL=%s', endpoint, exc_info=True)
216
308
  continue
309
+ else:
310
+ LOGGER.warning('curl não está disponível no PATH; a tentar Playwright.')
311
+ _streamlit_message('warning', 'curl não foi encontrado no PATH do Windows; será tentado o fallback Playwright.', 'curl_missing')
217
312
 
218
313
  # Em algumas instalações Windows o Instagram devolve a página HTML aos
219
314
  # clientes HTTP, embora entregue o JSON completo a um navegador Chromium.
@@ -227,6 +322,8 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
227
322
  user_agent=headers['User-Agent'],
228
323
  extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9'},
229
324
  )
325
+ if cookies:
326
+ page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
230
327
  for endpoint in endpoints:
231
328
  try:
232
329
  response = page.goto(endpoint, wait_until='domcontentloaded', timeout=30000)
@@ -242,8 +339,9 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
242
339
  continue
243
340
  finally:
244
341
  browser.close()
245
- except (ImportError, OSError, RuntimeError):
246
- pass
342
+ except Exception as exc:
343
+ LOGGER.warning('Falha no fallback Playwright do perfil Instagram: %s', exc, exc_info=True)
344
+ _streamlit_message('error', f'Fallback Playwright do perfil Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'profile_playwright_error')
247
345
  return best_user
248
346
 
249
347
 
@@ -294,13 +392,29 @@ def _fetch_instagram_about_country(user_id: Any) -> str:
294
392
  }
295
393
  try:
296
394
  response = requests.post(ABOUT_ACCOUNT_URL, data=payload, headers=_instagram_headers(), cookies=cookies, timeout=20)
395
+ LOGGER.debug('Instagram about-account URL=%s status=%s content_type=%s', ABOUT_ACCOUNT_URL, response.status_code, response.headers.get('content-type', ''))
297
396
  if response.status_code >= 400:
298
397
  return ''
299
- return _country_from_bloks(response.json())
300
- except (requests.RequestException, ValueError, TypeError):
398
+ country = _country_from_bloks(response.json())
399
+ LOGGER.debug('Instagram about-account country_found=%s', bool(country))
400
+ return country
401
+ except (requests.RequestException, ValueError, TypeError) as exc:
402
+ LOGGER.warning('Falha ao consultar país público do Instagram: %s', exc, exc_info=True)
301
403
  return ''
302
404
 
303
405
 
406
+ def _country_from_bio_fallback(bio: str) -> str:
407
+ patterns = (
408
+ r'\b(?:from|based in|located in|living in)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .-]{1,60})',
409
+ r'\b(?:de|baseado em|morando em|localizado em)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .-]{1,60})',
410
+ )
411
+ for pattern in patterns:
412
+ match = re.search(pattern, bio or '', flags=re.IGNORECASE)
413
+ if match:
414
+ return match.group(1).strip(' .,!;:')
415
+ return ''
416
+
417
+
304
418
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
305
419
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
306
420
  following = _profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
@@ -310,6 +424,8 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
310
424
  media = media if media is not None else _profile_metric(user, 'posts', 'post_count', 'media_count')
311
425
  biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
312
426
  country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
427
+ country = country or _country_from_bio_fallback(biography)
428
+ LOGGER.debug('Instagram profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(biography), followers, following, media, country or '—')
313
429
  return {
314
430
  'id': f"instagram_{reference['username']}",
315
431
  **reference,
@@ -387,6 +503,7 @@ def _profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
387
503
 
388
504
 
389
505
  def fetch_public_instagram_profile(source: str) -> IntegrationResult:
506
+ LOGGER.debug('fetch_public_instagram_profile source=%s', source)
390
507
  try:
391
508
  reference = normalize_instagram_reference(source)
392
509
  except ValueError as exc:
@@ -397,7 +514,9 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
397
514
  try:
398
515
  response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=12)
399
516
  except requests.RequestException as exc:
517
+ LOGGER.warning('Falha HTTP no perfil Instagram URL=%s: %s', reference['url'], exc, exc_info=True)
400
518
  return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
519
+ LOGGER.debug('Instagram HTML profile URL=%s status=%s content_type=%s response=HTML', reference['url'], response.status_code, response.headers.get('content-type', ''))
401
520
  if response.status_code >= 400:
402
521
  return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code}. Confirme o @handle ou use o cadastro manual.', reference | {'status_code': response.status_code})
403
522
  title = _meta(response.text, 'og:title') or reference['username']
@@ -422,6 +541,8 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
422
541
  embedded_country = extract_public_instagram_country(document)
423
542
  if embedded_country:
424
543
  break
544
+ embedded_country = embedded_country or _country_from_bio_fallback(description)
545
+ LOGGER.debug('Instagram HTML profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(description), followers, following, posts, embedded_country or '—')
425
546
  data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': normalize_instagram_bio(description), 'bio_raw': description, 'country': embedded_country, 'avatar_url': avatar_url, 'subscriber_count': followers, 'following_count': following, 'post_count': posts, 'public_lookup': True, 'metrics_source': 'instagram_public_page', 'last_public_lookup_at': datetime.now(timezone.utc).isoformat()}
426
547
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
427
548
 
@@ -484,6 +605,8 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
484
605
 
485
606
 
486
607
  def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
608
+ LOGGER.debug('fetch_public_instagram_posts source=%s limit=%s', source, limit)
609
+ _ensure_windows_playwright()
487
610
  try:
488
611
  reference = normalize_instagram_reference(source)
489
612
  except ValueError as exc:
@@ -498,13 +621,16 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
498
621
  if post:
499
622
  api_posts.append(post)
500
623
  if api_posts:
624
+ LOGGER.debug('Instagram API posts extracted=%s response=JSON', len(api_posts))
501
625
  return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
502
626
  if api_user.get('is_private'):
503
627
  return IntegrationResult(False, 'Esta conta é privada. O Instagram não disponibiliza posts sem uma sessão autenticada.', reference | {'posts': [], 'is_private': True})
504
628
  try:
505
- response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=15)
629
+ response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, cookies=_instagram_cookies() or None, timeout=15)
506
630
  except requests.RequestException as exc:
631
+ LOGGER.warning('Falha HTTP nos posts Instagram URL=%s: %s', reference['url'], exc, exc_info=True)
507
632
  return IntegrationResult(False, f'Não foi possível consultar os posts públicos do Instagram: {exc}', reference)
633
+ LOGGER.debug('Instagram HTML posts URL=%s status=%s content_type=%s response=HTML', reference['url'], response.status_code, response.headers.get('content-type', ''))
508
634
  if response.status_code >= 400:
509
635
  return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code} ao consultar os posts.', reference | {'status_code': response.status_code})
510
636
  posts: list[dict[str, Any]] = []
@@ -530,6 +656,9 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
530
656
  user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
531
657
  extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9'},
532
658
  )
659
+ cookies = _instagram_cookies()
660
+ if cookies:
661
+ page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
533
662
  page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
534
663
  browser_document = page.content()
535
664
  for document in _embedded_json_documents(browser_document):
@@ -545,8 +674,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
545
674
  break
546
675
  finally:
547
676
  browser.close()
548
- except (ImportError, OSError, RuntimeError, TimeoutError):
549
- pass
677
+ except Exception as exc:
678
+ LOGGER.warning('Falha no fallback Playwright dos posts Instagram: %s', exc, exc_info=True)
679
+ _streamlit_message('error', f'Fallback Playwright dos posts Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'posts_playwright_error')
680
+ LOGGER.debug('Instagram posts extracted=%s limit=%s', len(posts), limit)
550
681
  return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': posts[:max(1, int(limit))]})
551
682
 
552
683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.43",
3
+ "version": "0.6.45",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",