@genduo/desk 0.0.0-stage → 0.4.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 +186 -2
- package/api_client.py +65 -0
- package/bin/genduo-desk.js +54 -0
- package/cli.py +632 -0
- package/collect_access.py +162 -0
- package/collect_ads.py +255 -0
- package/ingest_util.py +136 -0
- package/keychain_util.py +115 -0
- package/local_config.py +53 -0
- package/package.json +42 -4
- package/rms_ads_parse.py +99 -0
- package/rms_browser_login.py +213 -0
- package/run.py +303 -0
- package/scripts/postinstall.js +23 -0
package/local_config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""本机非机密配置(shop / base URL)。不存 token / 密码。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SHOPS = ["0RT1", "3RT1", "5RT1", "3RT2", "5RT2", "7RT2"]
|
|
11
|
+
DEFAULT_BASE = "https://dataanalyst.jp"
|
|
12
|
+
VERSION = "0.4.0"
|
|
13
|
+
HERE = Path(__file__).resolve().parent
|
|
14
|
+
# 优先用户级配置,其次包内 state(POC 兼容)
|
|
15
|
+
USER_CFG = Path.home() / ".config" / "dataanalyst-agent" / "config.json"
|
|
16
|
+
STATE_CFG = HERE / "state" / "config.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def config_path() -> Path:
|
|
20
|
+
if USER_CFG.is_file():
|
|
21
|
+
return USER_CFG
|
|
22
|
+
return STATE_CFG
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load() -> dict:
|
|
26
|
+
path = config_path()
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return {
|
|
29
|
+
"shop": os.environ.get("DATAANALYST_SHOP", "").upper() or None,
|
|
30
|
+
"base": os.environ.get("DATAANALYST_BASE", DEFAULT_BASE).rstrip("/"),
|
|
31
|
+
}
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
shop = (data.get("shop") or os.environ.get("DATAANALYST_SHOP") or "").upper() or None
|
|
34
|
+
base = (data.get("base") or os.environ.get("DATAANALYST_BASE") or DEFAULT_BASE).rstrip("/")
|
|
35
|
+
return {"shop": shop, "base": base}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save(shop: str, base: str = DEFAULT_BASE) -> Path:
|
|
39
|
+
shop_u = shop.upper()
|
|
40
|
+
if shop_u not in SHOPS:
|
|
41
|
+
raise ValueError(f"非法店铺: {shop}(允许: {', '.join(SHOPS)})")
|
|
42
|
+
path = USER_CFG
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
payload = {"shop": shop_u, "base": base.rstrip("/")}
|
|
45
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
46
|
+
encoding="utf-8")
|
|
47
|
+
os.chmod(path, 0o600)
|
|
48
|
+
# 同步一份到 state,方便 POC 路径发现
|
|
49
|
+
STATE_CFG.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
STATE_CFG.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
51
|
+
encoding="utf-8")
|
|
52
|
+
os.chmod(STATE_CFG, 0o600)
|
|
53
|
+
return path
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genduo/desk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Local Mac desk agent CLI for genduo",
|
|
5
|
+
"bin": {
|
|
6
|
+
"genduo-desk": "bin/genduo-desk.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"scripts/",
|
|
11
|
+
"cli.py",
|
|
12
|
+
"api_client.py",
|
|
13
|
+
"keychain_util.py",
|
|
14
|
+
"local_config.py",
|
|
15
|
+
"run.py",
|
|
16
|
+
"rms_browser_login.py",
|
|
17
|
+
"README.md",
|
|
18
|
+
"collect_access.py",
|
|
19
|
+
"collect_ads.py",
|
|
20
|
+
"ingest_util.py",
|
|
21
|
+
"rms_ads_parse.py"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"postinstall": "node scripts/postinstall.js"
|
|
25
|
+
},
|
|
26
|
+
"os": [
|
|
27
|
+
"darwin"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"preferGlobal": true,
|
|
33
|
+
"license": "UNLICENSED",
|
|
34
|
+
"private": false,
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"genduo",
|
|
40
|
+
"macos",
|
|
41
|
+
"cli",
|
|
42
|
+
"desk"
|
|
43
|
+
]
|
|
44
|
+
}
|
package/rms_ads_parse.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""RPP 报表 ZIP/CSV 解析。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import io
|
|
8
|
+
import re
|
|
9
|
+
import zipfile
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
YM_RE = re.compile(r"(\d{4})年(\d{1,2})月")
|
|
14
|
+
DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _num(v):
|
|
18
|
+
if v is None:
|
|
19
|
+
return None
|
|
20
|
+
s = str(v).replace(",", "").replace("%", "").replace("円", "").strip()
|
|
21
|
+
if not s or s in ("-", "--"):
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return float(s)
|
|
25
|
+
except ValueError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _period(value: str) -> tuple[date, date] | None:
|
|
30
|
+
m = YM_RE.search(value or "")
|
|
31
|
+
if m:
|
|
32
|
+
y, mo = int(m.group(1)), int(m.group(2))
|
|
33
|
+
start = date(y, mo, 1)
|
|
34
|
+
nxt = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
|
|
35
|
+
return start, date.fromordinal(nxt.toordinal() - 1)
|
|
36
|
+
m = DATE_RE.search(value or "")
|
|
37
|
+
if m:
|
|
38
|
+
d = date(*map(int, m.groups()))
|
|
39
|
+
return d, d
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(buf: bytes) -> str:
|
|
44
|
+
for enc in ("cp932", "utf-8-sig", "utf-8"):
|
|
45
|
+
try:
|
|
46
|
+
return buf.decode(enc)
|
|
47
|
+
except UnicodeDecodeError:
|
|
48
|
+
continue
|
|
49
|
+
return buf.decode("cp932", "replace")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_zip(buf: bytes, ad_type: str, shop: str) -> list[dict]:
|
|
53
|
+
with zipfile.ZipFile(io.BytesIO(buf)) as z:
|
|
54
|
+
names = [n for n in z.namelist() if n.lower().endswith(".csv")]
|
|
55
|
+
if not names:
|
|
56
|
+
return []
|
|
57
|
+
text = _text(z.read(names[0]))
|
|
58
|
+
rows = list(csv.reader(io.StringIO(text)))
|
|
59
|
+
header_idx = next((i for i, r in enumerate(rows)
|
|
60
|
+
if r and "コントロールカラム" in r[0]), None)
|
|
61
|
+
if header_idx is None:
|
|
62
|
+
raise ValueError("找不到表头(コントロールカラム)")
|
|
63
|
+
header = [h.strip() for h in rows[header_idx]]
|
|
64
|
+
idx = {h: i for i, h in enumerate(header)}
|
|
65
|
+
|
|
66
|
+
def col(row, name):
|
|
67
|
+
i = idx.get(name)
|
|
68
|
+
return row[i] if i is not None and i < len(row) else None
|
|
69
|
+
|
|
70
|
+
out = []
|
|
71
|
+
for row in rows[header_idx + 1:]:
|
|
72
|
+
if not row or not any(x.strip() for x in row):
|
|
73
|
+
continue
|
|
74
|
+
d = col(row, "日付")
|
|
75
|
+
period = _period(d or "")
|
|
76
|
+
if not period:
|
|
77
|
+
continue
|
|
78
|
+
item = (col(row, "商品管理番号") or "").strip()
|
|
79
|
+
keyword = (col(row, "キーワード") or "").strip()
|
|
80
|
+
if ad_type == "rpp_item" and not item:
|
|
81
|
+
continue
|
|
82
|
+
if ad_type == "rpp_keyword" and not keyword:
|
|
83
|
+
continue
|
|
84
|
+
start, end = period
|
|
85
|
+
out.append({
|
|
86
|
+
"shop": shop, "ad_type": ad_type, "period_type": "monthly",
|
|
87
|
+
"period_start": start, "period_end": end,
|
|
88
|
+
"item_number": item, "item_name": col(row, "商品名"),
|
|
89
|
+
"keyword": keyword,
|
|
90
|
+
"clicks": int(float(col(row, "クリック数(合計)") or 0)),
|
|
91
|
+
"cost": _num(col(row, "実績額(合計)")),
|
|
92
|
+
"sales": _num(col(row, "売上金額(合計720時間)")),
|
|
93
|
+
"orders": int(float(col(row, "売上件数(合計720時間)") or 0)),
|
|
94
|
+
"cpc": _num(col(row, "CPC実績(合計)")),
|
|
95
|
+
"ctr": _num(col(row, "CTR(%)")),
|
|
96
|
+
"roas": _num(col(row, "ROAS(合計720時間)(%)")),
|
|
97
|
+
"cvr": _num(col(row, "CVR(合計720時間)(%)")),
|
|
98
|
+
})
|
|
99
|
+
return out
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""R-Login 浏览器登录(Playwright)并保存会话。
|
|
4
|
+
|
|
5
|
+
NOTE (npm / shop CLI):
|
|
6
|
+
源真相仍是 monorepo `tools/rms_browser_login.py`(VPS 路径也用那份)。
|
|
7
|
+
本文件是 collector-agent 的 vendored 副本,供 `npm i -g dataanalyst-agent`
|
|
8
|
+
后门店机无 monorepo 也能跑 `--verify-rms`。打包前若源文件有变,请同步拷贝。
|
|
9
|
+
|
|
10
|
+
Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
|
|
11
|
+
1. glogin 填 R-Login ID / 密码(第一组);
|
|
12
|
+
2. Rakuten 会员填邮箱 / 密码(第二组);
|
|
13
|
+
3. R-Login 提示页「次へ」→ RMS 主菜单「RMSを利用します」。
|
|
14
|
+
|
|
15
|
+
凭据从 env 文件或 macOS Keychain 读,不打印。会话保存为 600 的 storage_state。
|
|
16
|
+
|
|
17
|
+
用法:
|
|
18
|
+
# VPS / env 文件
|
|
19
|
+
PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright \\
|
|
20
|
+
python tools/rms_browser_login.py --shop 7RT2 \\
|
|
21
|
+
--env-file /etc/dataanalyst/rms.env \\
|
|
22
|
+
--out /etc/dataanalyst/rms_session/7RT2.json
|
|
23
|
+
|
|
24
|
+
# 本机 Keychain(service: dataanalyst/rms/<shop>-rlogin 与 -ai)
|
|
25
|
+
python tools/rms_browser_login.py --shop 7RT2 --from-keychain \\
|
|
26
|
+
--out tools/collector-agent/state/7RT2.json
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
# Prefer existing PLAYWRIGHT_BROWSERS_PATH; only default to VPS path if present.
|
|
38
|
+
_vps_browsers = "/opt/ms-playwright"
|
|
39
|
+
if "PLAYWRIGHT_BROWSERS_PATH" not in os.environ and Path(_vps_browsers).is_dir():
|
|
40
|
+
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = _vps_browsers
|
|
41
|
+
|
|
42
|
+
from playwright.sync_api import TimeoutError as PWTimeout # noqa: E402
|
|
43
|
+
from playwright.sync_api import sync_playwright # noqa: E402
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_env(path: str) -> dict:
|
|
47
|
+
out = {}
|
|
48
|
+
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if line and not line.startswith("#") and "=" in line:
|
|
51
|
+
k, v = line.split("=", 1)
|
|
52
|
+
out[k.strip()] = v.strip()
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def keychain_get(service: str) -> tuple[str, str]:
|
|
57
|
+
"""Return (account, password) from macOS Keychain. Never prints password."""
|
|
58
|
+
meta = subprocess.check_output(
|
|
59
|
+
["security", "find-generic-password", "-s", service],
|
|
60
|
+
stderr=subprocess.DEVNULL,
|
|
61
|
+
).decode("utf-8", errors="replace")
|
|
62
|
+
m = re.search(r'"acct"<blob>="([^"]*)"', meta)
|
|
63
|
+
if not m:
|
|
64
|
+
raise SystemExit(f"Keychain 缺 account: {service}")
|
|
65
|
+
account = m.group(1)
|
|
66
|
+
password = subprocess.check_output(
|
|
67
|
+
["security", "find-generic-password", "-s", service, "-w"],
|
|
68
|
+
stderr=subprocess.DEVNULL,
|
|
69
|
+
).decode("utf-8", errors="replace").rstrip("\n")
|
|
70
|
+
if not password:
|
|
71
|
+
raise SystemExit(f"Keychain 缺 password: {service}")
|
|
72
|
+
return account, password
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_from_keychain(shop: str) -> dict:
|
|
76
|
+
shop_u = shop.upper()
|
|
77
|
+
login_id, password = keychain_get(f"dataanalyst/rms/{shop_u}-rlogin")
|
|
78
|
+
sso_user, sso_pass = keychain_get(f"dataanalyst/rms/{shop_u}-ai")
|
|
79
|
+
prefix = f"RMS_{shop_u}_"
|
|
80
|
+
return {
|
|
81
|
+
prefix + "LOGIN_ID": login_id,
|
|
82
|
+
prefix + "PASSWORD": password,
|
|
83
|
+
prefix + "SHOP_USER": sso_user,
|
|
84
|
+
prefix + "SHOP_PASSWORD": sso_pass,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _try_click(page, role: str, name: str, timeout: int = 4000) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
page.get_by_role(role, name=name).first.click(timeout=timeout)
|
|
91
|
+
return True
|
|
92
|
+
except PWTimeout:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def login(shop: str, env: dict, out: str, headless: bool = True,
|
|
97
|
+
screenshot: str | None = None) -> None:
|
|
98
|
+
prefix = f"RMS_{shop.upper()}_"
|
|
99
|
+
login_id = env.get(prefix + "LOGIN_ID")
|
|
100
|
+
password = env.get(prefix + "PASSWORD")
|
|
101
|
+
sso_user = env.get(prefix + "SHOP_USER")
|
|
102
|
+
sso_pass = env.get(prefix + "SHOP_PASSWORD")
|
|
103
|
+
missing = [k for k, v in {
|
|
104
|
+
"LOGIN_ID": login_id, "PASSWORD": password,
|
|
105
|
+
"SHOP_USER": sso_user, "SHOP_PASSWORD": sso_pass,
|
|
106
|
+
}.items() if not v]
|
|
107
|
+
if missing:
|
|
108
|
+
raise SystemExit(f"{shop} 缺少凭据: {prefix}{','.join(missing)}")
|
|
109
|
+
|
|
110
|
+
with sync_playwright() as p:
|
|
111
|
+
browser = p.chromium.launch(headless=headless,
|
|
112
|
+
args=["--no-sandbox", "--disable-dev-shm-usage"])
|
|
113
|
+
ctx = browser.new_context(locale="ja-JP", viewport={"width": 1440, "height": 1000},
|
|
114
|
+
user_agent=("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
115
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
116
|
+
"Chrome/124.0.0.0 Safari/537.36"))
|
|
117
|
+
page = ctx.new_page()
|
|
118
|
+
page.goto("https://glogin.rms.rakuten.co.jp/?sp_id=1",
|
|
119
|
+
wait_until="domcontentloaded", timeout=60000)
|
|
120
|
+
page.fill("#rlogin-username-ja", login_id)
|
|
121
|
+
page.fill("#rlogin-password-ja", password)
|
|
122
|
+
page.evaluate("document.querySelector('button[name=submit]')?.click()")
|
|
123
|
+
page.wait_for_timeout(3000)
|
|
124
|
+
|
|
125
|
+
page.fill("input[name=username]", sso_user)
|
|
126
|
+
page.press("input[name=username]", "Enter")
|
|
127
|
+
page.wait_for_timeout(3000)
|
|
128
|
+
page.fill("input[name=password]", sso_pass)
|
|
129
|
+
page.press("input[name=password]", "Enter")
|
|
130
|
+
page.wait_for_timeout(5000)
|
|
131
|
+
|
|
132
|
+
_try_click(page, "button", "次へ")
|
|
133
|
+
page.wait_for_timeout(2500)
|
|
134
|
+
try:
|
|
135
|
+
page.get_by_text("RMSを利用します").click(timeout=5000)
|
|
136
|
+
except PWTimeout:
|
|
137
|
+
pass
|
|
138
|
+
page.wait_for_timeout(3000)
|
|
139
|
+
|
|
140
|
+
url = page.url
|
|
141
|
+
title = page.title()
|
|
142
|
+
print("url", url)
|
|
143
|
+
print("title", title)
|
|
144
|
+
body_snip = ""
|
|
145
|
+
try:
|
|
146
|
+
body_snip = page.inner_text("body")[:500]
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
# Shop name check (no secrets)
|
|
150
|
+
if "IORI" in body_snip or "iori" in body_snip.lower():
|
|
151
|
+
print("shop_hint", "IORI living (matched)")
|
|
152
|
+
else:
|
|
153
|
+
# try common shop name selectors without dumping full body
|
|
154
|
+
for sel in ["text=IORI living", "text=IORI"]:
|
|
155
|
+
try:
|
|
156
|
+
if page.locator(sel).count():
|
|
157
|
+
print("shop_hint", "IORI living (locator)")
|
|
158
|
+
break
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
else:
|
|
162
|
+
print("shop_hint", "not found in first 500 chars of body")
|
|
163
|
+
|
|
164
|
+
if "mainmenu.rms.rakuten.co.jp" not in url:
|
|
165
|
+
if screenshot:
|
|
166
|
+
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
page.screenshot(path=screenshot, full_page=True)
|
|
168
|
+
print("screenshot", screenshot)
|
|
169
|
+
raise SystemExit("未进入 RMS 主菜单,可能遇到 2FA/验证码;请查看截图")
|
|
170
|
+
|
|
171
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
ctx.storage_state(path=out)
|
|
173
|
+
os.chmod(out, 0o600)
|
|
174
|
+
print("saved", out)
|
|
175
|
+
if screenshot:
|
|
176
|
+
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
page.screenshot(path=screenshot, full_page=False)
|
|
178
|
+
os.chmod(screenshot, 0o600)
|
|
179
|
+
print("screenshot", screenshot)
|
|
180
|
+
browser.close()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main() -> int:
|
|
184
|
+
ap = argparse.ArgumentParser(description="R-Login 浏览器登录并保存会话")
|
|
185
|
+
ap.add_argument("--shop", required=True)
|
|
186
|
+
ap.add_argument("--env-file", default="/etc/dataanalyst/rms.env")
|
|
187
|
+
ap.add_argument("--from-keychain", action="store_true",
|
|
188
|
+
help="从 macOS Keychain 读 dataanalyst/rms/<SHOP>-rlogin 与 -ai")
|
|
189
|
+
ap.add_argument("--out", help="默认 tools/collector-agent/state/<shop>.json(本机)")
|
|
190
|
+
ap.add_argument("--screenshot", help="可选截图路径(不含密钥)")
|
|
191
|
+
ap.add_argument("--headed", action="store_true")
|
|
192
|
+
a = ap.parse_args()
|
|
193
|
+
out = a.out or f"tools/collector-agent/state/{a.shop.upper()}.json"
|
|
194
|
+
try:
|
|
195
|
+
if a.from_keychain:
|
|
196
|
+
print("cred_source", "keychain")
|
|
197
|
+
env = load_from_keychain(a.shop)
|
|
198
|
+
# Confirm accounts only (no passwords)
|
|
199
|
+
prefix = f"RMS_{a.shop.upper()}_"
|
|
200
|
+
print("rlogin_id_set", bool(env.get(prefix + "LOGIN_ID")))
|
|
201
|
+
print("sso_user_set", bool(env.get(prefix + "SHOP_USER")))
|
|
202
|
+
else:
|
|
203
|
+
print("cred_source", "env-file")
|
|
204
|
+
env = load_env(a.env_file)
|
|
205
|
+
login(a.shop, env, out, headless=not a.headed, screenshot=a.screenshot)
|
|
206
|
+
except Exception as e: # noqa: BLE001
|
|
207
|
+
print(f"登录失败: {type(e).__name__}: {e}", file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
sys.exit(main())
|