feat(cmcc): 中国移动签到有礼自动签到(Python3 重放素材链路)

协议链路 2026-11-07 全实验裁决后投产:
- 重放 autoLogin 素材换会话(命中即停 + 15 败长静默防风暴冷却)
- appTokenLogin 换 QWHD_SESSION_TOKEN(token=原始 Set-Cookie 全值)
- mark31/domark 签到 + 幂等(markstatus 先行)
- 出口 IP 无关/素材无小时级寿命 已实测证伪,云青龙可直接部署
- 通知走 webhook+pushplus 双通道(与 common.js 契约同名的 Python 实现)
- 素材获取:保持登录态等自动续期抓一次即可,无每日仪式
This commit is contained in:
2026-11-07 16:48:50 +08:00
parent 23d35cd942
commit 27e3c95585
2 changed files with 546 additions and 0 deletions

60
cmcc/README.md Normal file
View File

@@ -0,0 +1,60 @@
# 中国移动自动签到(scrip_cmcc_mark.py)
中国移动 APP「签到有礼」自动签到,青龙 Python3 零第三方依赖(只用 requests,面板自带)。
协议底座(全部实测,2026-11-07):重放抓包素材 `autoLogin` 换 APP 会话 →
`appTokenLogin` 换 `QWHD_SESSION_TOKEN` → `mark31/domark` 签到。
三大前提已实验裁决:出口 IP 无关、素材寿命是「冷却」机制而非小时数、
抓素材需登录态但**一次登录长期收**(已登录冷启动不发 autoLogin,无每日仪式)。
> **文件名必须带 `scrip_` 前缀**:订阅白名单规则(根 README)。
> 元数据头两行 `// cron:` / `// name:`(Python 用 `#`)已按仓库约定写好,
> 新增后跑 `node _probe_meta.js` 需把 `.py` 加入扫描范围(当前探针只扫 js,
> Python 侧由本 README 说明兜底)。
## 环境变量
| 变量 | 必填 | 说明 |
|---|---|---|
| `CMCC_AUTOLOGIN_CURL` | ✅(与 CMCC_TOKEN 二选一) | 整段 cURL(手机抓包导出 autoLogin 请求)。多号用 `CMCC_AUTOLOGIN_CURL_2` / `_3` … 编号变量,**每号一份素材**(素材与账号一对一) |
| `CMCC_TOKEN` | 备选 | 直通 token(仅当天调试;TTL≈30min,隔日必死) |
| `CMCC_NOTIFY_ON_SUCCESS` | 可选 | `1` = 签到成功也推送(默认只推失败) |
| `WEBHOOK_URL` / `PUSH_PLUS_TOKEN` 等 | 可选 | 通知通道,与仓库 `common.js` 契约同名同义(Python 侧同语义实现) |
## 素材怎么抓(一次登录,长期收)
1. 手机保持 APP **登录态**(不用清数据——已实测清数据后冷启动不发 autoLogin)
2. 开抓包(Reqable/HttpCanary/mitmproxy),在 APP 里触发一次登录或等它自动续期
3. 找 `client.app.coc.10086.cn` 的 `uamrandcodelogin/autoLogin` 请求
(**响应必须含 `Set-Cookie: JSESSIONID`**,没有的是失败记录不是素材)
4. 导出为 cURL(含 header + body),整段贴进 `CMCC_AUTOLOGIN_CURL`
5. **抓完立刻关手机代理**(`settings put global http_proxy :0`,否则 APP 断网)
素材失效的信号是**连续多次全 64B**(会话层死)——这时才需要重抓。
88B ≠ 失效(素材活着只是没命中,稍后再跑);236B ≠ 封号(慢节拍通道,快节拍重试)。
## 定时
```
40 8 * * * # 主任务(脚本头注已写,订阅自动建任务用)
0 12 * * * # 建议手动补一条错峰补签——命中即停模式下 30 发全败概率 ≈36%,双 cron 把日失败率压到 <13%
```
同一日重复运行幂等(`HAVE_MARKED`/markstatus 先行判断),多补一条 cron 无副作用。
## 退出码
| 码 | 含义 |
|---|---|
| 0 | 成功 / 今日已签 |
| 1 | 签到动作失败(复核未通过) |
| 2 | 素材问题(重放未命中 / cURL 解析损坏) |
| 3 | 换票失败([C]/[D] 层) |
## 实现关键坑(改动前必读)
1. **token 必须用原始合并 Set-Cookie 全值**(`split("; Secure")` 前段,含 Comment/Path 属性碎片)——结构化重建三段式实测报「APP登录状态已过期」
2. **[A] 重放必须无状态 `requests.post`**——Session 的 `prepare_cookies` 会用 jar 覆盖素材 Cookie 头 → 64B
3. **Set-Cookie 名字判定必须 `re.findall`**——服务端把 JSESSIONID+UID+ticketID 合并在同一条头里,`re.match` 取首个恒漏 UID
4. **命中即停**——命中后立刻冷却(连打全 88B),循环里多打是纯浪费还喂风暴
5. sid 正则必须 `[A-Za-z0-9]+`——大写版截断尾部随机串 → 换票 404(但 code 仍 SUCCESS,判据是 url 含 404)

486
cmcc/scrip_cmcc_mark.py Normal file
View File

@@ -0,0 +1,486 @@
#!/usr/bin/env python3
# cron: 40 8 * * *
# name: 中国移动签到
"""
中国移动 APP「签到有礼」自动签到(青龙 Python3,纯 requests)
上面两行是青龙订阅自动建任务用的元数据注解(约定见仓库根 README):
- cron 独占一行、后面不跟任何注释
- 全文不得再出现任务名/cron 的行内连写(青龙按全文第一个命中提取)
协议链路(全部实测,2026-11-07):
[A] 重放抓包素材 autoLogin(原样,一字符不改)→ 492B 命中 → Set-Cookie 全值 = 会话
★ 实测:素材寿命上限是「命中/风暴后冷却」机制,不是小时数——
34h 老素材仍首发命中;命中后立刻冷却(连打全 88B)→ 命中即停
★ 实测:出口 IP 无关(AWS 出口同样命中);慢节拍(60s+)会被推向 236B
[B] GET 活动页 → 302 → 正则 sid=([A-Za-z0-9]+)(大写版会截断尾部随机串)
[C] POST appTokenLogin?sid=… body={"token": sess}
★ token 必须是原始合并 Set-Cookie 全值(含 Comment/Path 属性碎片)——
结构化重建三段式实测报「APP登录状态已过期」
[D] GET data.url → QWHD_SESSION_TOKEN(实测 ≥108min)
[E] /user/info 验活(content-type 含 json 判据)→ mark31/markstatus → domark → 复核
环境变量:
CMCC_AUTOLOGIN_CURL 整段 cURL(手机抓包 → Reqable/HttpCanary 导出 autoLogin 为 cURL)
CMCC_AUTOLOGIN_CURL_2/3 多号:编号变量主方案(每号一份素材,素材与账号一对一)
CMCC_TOKEN 直通 token(仅当天调试;TTL≈30min,隔日必死)
CMCC_NOTIFY_ON_SUCCESS "1" = 成功也推送(默认只推失败)
素材怎么抓(保持 APP 登录态,无需清数据/无需每日短信):
1. 手机开抓包代理(Reqable/HttpCanary 或 mitmproxy)
2. APP 里触发一次登录或等它自动续期 → 抓 client.app.coc.10086.cn 的
uamrandcodelogin/autoLogin 请求(响应须含 Set-Cookie: JSESSIONID)
3. 导出为 cURL,整段贴进 CMCC_AUTOLOGIN_CURL
4. 抓完关代理!(settings put global http_proxy :0,否则 APP 断网)
退出码:0 成功/已签;1 签到失败;2 素材/cURL 问题;3 换票失败
"""
import os
import re
import sys
import json
import time
import shlex
try:
import fcntl # Linux(青龙)生效;Windows 降级跳过
_HAVE_FCNTL = True
except ImportError:
_HAVE_FCNTL = False
from datetime import datetime, timedelta, timezone
import requests
# ---------------------------------------------------------------- 常量
UA_WEB = ("Mozilla/5.0 (Linux; Android 11; MI 9 Build/RKQ1.200826.002; wv) AppleWebKit/537.36 "
"(KHTML, like Gecko) Version/4.0 Chrome/109.0.5414.86 MQQBrowser/6.2 TBS/047115 "
"Mobile Safari/537.36 leadeon/12.0.9/CMCCIT")
ACT_URL = "https://wx.10086.cn/qwhdhub/qwhdmark/1021122301?channelId=P00000132551&yx=1391563104"
BASE = "https://wx.10086.cn/qwhdhub/api/mark"
SSO = "https://wx.10086.cn/qwhdsso"
UTC8 = timezone(timedelta(hours=8)) # ★ 显式 UTC+8:青龙容器时区可能不是 +8
STRIP_HEADERS = ("content-length", "accept-encoding", "host", "connection")
REPLAY_MAX = 30 # 命中即停:实测中位 1~9 发;30 发封顶,由错峰补签 cron 兜底
REPLAY_GAP = 5.0 # 3~5s 快节拍(慢节拍推向 236B 通道)
WALL_LIMIT = 6 * 60 # [A] 墙钟上限(含长静默),防止极端情况拖死任务
BACKOFF_AFTER = 15 # 连续 15 败 → 一次 10min 静默(防风暴冷却,只插一次)
BACKOFF_SECS = 600
THROTTLE = 1.2 # 业务请求间隔
MASK = lambda s: (s[:3] + "****" + s[-4:]) if s and len(s) >= 7 else (s or "?")
def now8():
return datetime.now(UTC8)
def today_str():
return now8().strftime("%Y%m%d")
def log(msg):
print("[%s] %s" % (now8().strftime("%H:%M:%S"), msg), flush=True)
# ---------------------------------------------------------------- 通知
# 复用仓库通知约定:webhook + pushplus 双通道,任一成功即 ok,永不抛错。
# 不用青龙 sendNotify.js —— deps 复制顺序决定它每轮被面板自带版覆盖(见仓库 common.js 头注)。
# Python 侧无法 require JS 的 common.js → 在这里实现同一契约的轻量版。
def send_notify(title, content):
hook = os.environ.get("WEBHOOK_URL", "").strip()
token = os.environ.get("PUSH_PLUS_TOKEN", "").strip()
topic = os.environ.get("PUSH_PLUS_USER", "").strip()
sent = False
if hook:
try:
body = os.environ.get("WEBHOOK_BODY", "").strip()
if body:
body = body.replace("$title", title).replace("$content", content)
else:
body = json.dumps({"title": title, "content": content}, ensure_ascii=False)
hdrs = {"Content-Type": os.environ.get("WEBHOOK_CONTENT_TYPE", "application/json")}
for ln in os.environ.get("WEBHOOK_HEADERS", "").splitlines():
if ":" in ln:
k, v = ln.split(":", 1)
hdrs[k.strip()] = v.strip()
r = requests.post(hook, data=body.encode("utf-8"), headers=hdrs, timeout=15)
ok = 200 <= r.status_code < 300
sent = sent or ok
log("[通知] webhook HTTP %d %s" % (r.status_code, "成功" if ok else "失败"))
except Exception as e:
log("[通知] webhook 异常 %r" % e)
if token:
try:
payload = {"token": token, "title": title, "content": content,
"template": os.environ.get("PUSH_PLUS_TEMPLATE", "txt")}
if topic:
payload["topic"] = topic
r = requests.post("https://www.pushplus.plus/send", json=payload, timeout=15)
j = r.json() if "json" in r.headers.get("content-type", "") else {}
ok = j.get("code") == 200
sent = sent or ok
log("[通知] pushplus code=%s %s" % (j.get("code"), "成功" if ok else (j.get("msg") or "")))
except Exception as e:
log("[通知] pushplus 异常 %r" % e)
if not hook and not token:
log("[通知] 未配置 WEBHOOK_URL / PUSH_PLUS_TOKEN,本轮不发推送")
return sent
# ---------------------------------------------------------------- cURL 解析
def parse_curl(raw):
"""整段 cURL → {url, headers, body}。续行/CRLF/shlex/剥头/解析后自检。"""
text = raw.replace("\r\n", "\n").replace("\r", "\n")
merged, buf = [], ""
for ln in text.split("\n"):
s = ln.rstrip()
if s.endswith("\\"):
buf += s[:-1] + " "
else:
merged.append(buf + s)
buf = ""
if buf.strip():
merged.append(buf)
cmdline = " ".join(m.strip() for m in merged if m.strip()).replace("^", " ")
tokens = shlex.split(cmdline, posix=True)
url, headers, body = None, {}, None
i = 0
while i < len(tokens):
t = tokens[i]
tl = t.lower()
if tl == "curl":
i += 1
elif t in ("-H", "--header") and i + 1 < len(tokens):
hv = tokens[i + 1]
if ":" in hv:
k, v = hv.split(":", 1)
headers[k.strip()] = v.strip() # 同名头取最后
i += 2
elif t in ("-d", "--data", "--data-raw", "--data-binary") and i + 1 < len(tokens):
body = tokens[i + 1]
i += 2
elif t.startswith("http://") or t.startswith("https://"):
url = t
i += 1
else:
i += 1
if not url or not body:
raise ValueError("cURL 里没解析到 URL 或 body")
headers = {k: v for k, v in headers.items() if k.lower() not in STRIP_HEADERS}
problems = [] # 解析后自检:坏 cURL 在这里就报,别到线上当素材失效
if "autoLogin" not in url:
problems.append("URL 不含 autoLogin")
if not headers.get("xs"):
problems.append("缺 xs 头")
if not headers.get("cookie"):
problems.append("缺 cookie 头")
if not (1300 <= len(body) <= 1700):
problems.append("body 长度 %d 不在 1472/1516±10%% 内" % len(body))
if problems:
raise ValueError("cURL 自检失败: " + "; ".join(problems))
return {"url": url, "headers": headers, "body": body}
def load_seeds():
"""多号:编号变量主方案;单变量按空行分片兜底(逐片校验)。"""
seeds = []
for suffix in [""] + ["_%d" % i for i in range(2, 21)]:
v = os.environ.get("CMCC_AUTOLOGIN_CURL" + suffix, "").strip()
if v:
try:
seeds.append(("CMCC_AUTOLOGIN_CURL" + suffix, parse_curl(v)))
except ValueError as e:
log("!! %s 解析失败: %s" % (suffix or "(主)", e))
if not seeds:
blob = os.environ.get("CMCC_AUTOLOGIN_CURL", "")
for idx, part in enumerate(p.strip() for p in re.split(r"\n\s*\n", blob) if p.strip()):
try:
seeds.append(("CMCC_AUTOLOGIN_CURL#%d" % (idx + 1), parse_curl(part)))
except ValueError as e:
log("!! 分片 %d 解析失败: %s" % (idx + 1, e))
return seeds
# ---------------------------------------------------------------- [A] 重放
def set_cookie_names(resp):
"""Set-Cookie 全量名字。服务端把 JSESSIONID+UID+ticketID 合并在同一条头里,
必须 findall 全量提(re.match 取首个恒漏 UID → 命中判定永远失败)。"""
try:
raw = ",".join(resp.raw.headers.getlist("Set-Cookie"))
except Exception:
raw = resp.headers.get("set-cookie", "") or ""
return re.findall(r"([A-Za-z0-9_\-]+)\s*=", raw)
def classify(resp):
"""五形态:492命中 / 64会话不存在 / 88业务未中 / 236账号层(慢节拍通道) / 403/150网关拒。"""
ct = (resp.headers.get("content-type", "") or "").lower()
n = len(resp.content)
names = set_cookie_names(resp)
if resp.status_code == 403 or "text/html" in ct:
return "403_gateway"
if "JSESSIONID" in names and "UID" in names and "text/json" in ct and 400 <= n <= 550:
return "492_hit"
if n == 64:
return "64_no_session"
if n == 88:
return "88_business_miss"
if n == 236:
return "236_account_layer"
if n == 150:
return "150_gateway_reject"
return "other_%dB" % n
def replay_auto_login(seed, stats):
"""重放至 492 命中,命中即停。无状态 requests.post ——
★ Session 的 prepare_cookies 会用 jar 无条件覆盖手工 Cookie 头,素材 cookie 一被污染就 64B。"""
start = time.time()
tried = 0
backed = False
while tried < REPLAY_MAX and time.time() - start < WALL_LIMIT:
tried += 1
try:
r = requests.post(seed["url"], data=seed["body"].encode("utf-8"),
headers=seed["headers"], timeout=30)
except Exception as e:
stats["net_error"] = stats.get("net_error", 0) + 1
log("[A] #%d 网络错误 %r" % (tried, e))
time.sleep(REPLAY_GAP)
continue
form = classify(r)
stats[form] = stats.get(form, 0) + 1
if form == "492_hit":
try:
raw_list = r.raw.headers.getlist("Set-Cookie")
except Exception:
raw_list = [r.headers.get("set-cookie", "")]
blob = "; ".join(x.strip() for x in raw_list if x.strip())
sess = blob.split("; Secure")[0].strip() # ★ 原始全值(含 Comment/Path 碎片)
log("[A] 第 %d 发命中(统计=%s)" % (tried, stats))
return sess, tried
if tried % 5 == 0 or form.startswith(("403", "150")):
log("[A] 第 %d 发 %s" % (tried, form))
if tried >= BACKOFF_AFTER and not backed:
log("[A] 连续 %d 败 → 静默 %ds 防风暴冷却" % (tried, BACKOFF_SECS))
time.sleep(BACKOFF_SECS)
backed = True
else:
time.sleep(REPLAY_GAP)
return None, tried
# ---------------------------------------------------------------- [B]~[D] 换票
def mint_token(sess, stats):
s = requests.Session()
s.headers.update({"user-agent": UA_WEB,
"x-requested-with": "com.greenpoint.android.mc10086.activity"})
for _ in range(3): # [C] 失败先重建 sid 快重试,不盲目整轮重放
try:
r = s.get(ACT_URL, timeout=30, allow_redirects=True)
m = re.search(r"sid=([A-Za-z0-9]+)", r.text)
except Exception as e:
log("[B] 活动页请求异常 %r" % e)
time.sleep(2)
continue
if not m:
log("[B] 没取到 sid")
time.sleep(2)
continue
sid = m.group(1)
try:
r2 = s.post("%s/appTokenLogin?sid=%s" % (SSO, sid), json={"token": sess},
timeout=30,
headers={"content-type": "application/json;charset=UTF-8",
"referer": r.url, "origin": "https://wx.10086.cn"})
j = r2.json()
except Exception as e:
log("[C] 异常 %r" % e)
time.sleep(2)
continue
url = (j.get("data") or {}).get("url", "")
log("[C] %s %s" % (j.get("code"), j.get("msg", "")))
if "404" in url or not url: # ★ 判据是 url 含 404,code 仍是 SUCCESS
time.sleep(2)
continue
try:
s.get(url, timeout=30, allow_redirects=True)
except Exception as e:
log("[D] 异常 %r" % e)
continue
tok = next((c.value for c in s.cookies if c.name == "QWHD_SESSION_TOKEN"), None)
if tok:
log("[D] token=%s…%s" % (tok[:6], tok[-4:]))
return tok
stats["mint_fail"] = stats.get("mint_fail", 0) + 1
return None
# ---------------------------------------------------------------- [E] 签到
class Mark:
def __init__(self, token):
self.s = requests.Session()
self.s.headers.update({
"user-agent": UA_WEB,
"x-requested-with": "XMLHttpRequest",
"content-type": "application/json;charset=UTF-8",
"origin": "https://wx.10086.cn",
"referer": ACT_URL,
})
self.s.cookies.set("QWHD_SESSION_TOKEN", token, domain="wx.10086.cn")
def post(self, path, body=None):
time.sleep(THROTTLE)
try:
r = self.s.post(BASE + path, data=json.dumps(body or {}), timeout=30)
except Exception as e:
return {"_error": repr(e)}
if "json" not in r.headers.get("content-type", "").lower():
# ★ token 失效判据:返回 13KB SSO HTML 而非 JSON(HTTP 仍 200)
return {"_token_invalid": True, "_len": len(r.content)}
try:
return r.json()
except Exception:
return {"_raw": r.text[:200]}
def whoami(self):
j = self.post("/user/info", {"appVersion": "", "miniVersion": ""})
if j.get("_token_invalid"):
return None, None
d = j.get("data") or {}
return j.get("code"), d.get("nickName") or ""
def state(self):
j = self.post("/mark31/markstatus")
if j.get("_token_invalid"):
return None
d = j.get("data") or {}
today = today_str()
e = next((x for x in (d.get("markstatus") or []) if x.get("date") == today), None)
return {"signed": bool(e and e.get("status") == "1"),
"days": (d.get("userinfo") or {}).get("accumulateTimes")}
def mark(self):
return self.post("/mark31/domark", {"date": today_str()})
# ---------------------------------------------------------------- 单号流程
def run_one_account(name, payload, stats):
"""payload: str=CMCC_TOKEN 直通;dict=素材。返回 (exit_code, 摘要)。"""
acct = {"name": name, "mask": "?", "ok": False, "detail": ""}
if isinstance(payload, str):
tok = payload
log("[%s] CMCC_TOKEN 直通(仅当天调试)" % name)
else:
sess, tried = replay_auto_login(payload, stats)
if not sess:
acct["detail"] = ("autoLogin %d 发未命中(%s)。诊断:64B多→素材/会话死,重抓;"
"88B多→素材活着未中,1小时后重跑;236B→通道异常,快节拍重试;"
"403/150→cURL 解析损坏" % (tried, stats))
return 2, acct
tok = mint_token(sess, stats)
if not tok:
acct["detail"] = "换票失败([C] 3 次重建 sid 未成)"
return 3, acct
m = Mark(tok)
code, nick = m.whoami()
if code is None:
acct["detail"] = "token 失效(HTML 非 JSON)"
return 3, acct
if code != "SUCCESS":
acct["detail"] = "验活返回 %s" % code
return 3, acct
acct["mask"] = MASK(nick)
log("[%s] 用户 %s" % (name, acct["mask"]))
st = m.state()
if st is None:
acct["detail"] = "markstatus 时 token 已失效"
return 3, acct
log("[%s] 今日已签=%s 累计=%s天" % (name, st["signed"], st["days"]))
if st["signed"]:
acct["ok"] = True
acct["detail"] = "今日已签到,幂等跳过(累计 %s 天)" % st["days"]
return 0, acct
mk = m.mark()
if mk.get("_token_invalid"):
acct["detail"] = "domark 时 token 失效"
return 3, acct
log("[%s] 签到 code=%s status=%s msg=%s" % (name, mk.get("code"), mk.get("status"), mk.get("msg")))
st2 = m.state()
if st2 and st2["signed"]:
acct["ok"] = True
acct["detail"] = "签到成功:累计 %s→%s 天(%s/%s)" % (
st["days"], st2["days"], mk.get("code"), mk.get("status"))
return 0, acct
acct["detail"] = "复核未通过:%s/%s" % (mk.get("code"), mk.get("status"))
return 1, acct
# ---------------------------------------------------------------- 主流程
def main():
if _HAVE_FCNTL:
try:
lf = open("/tmp/cmcc_mark.lock", "w")
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception:
log("已有实例在跑(flock 冲突),退出防双进程重放")
return 0
stats = {}
results = []
print("【中国移动签到】", flush=True)
accounts = []
tok = os.environ.get("CMCC_TOKEN", "").strip()
if tok:
accounts.append(("token-direct", tok))
accounts.extend(("acct%d" % (i + 1), seed_dict)
for i, (_label, seed_dict) in enumerate(load_seeds()))
if not accounts:
print("未配置 CMCC_AUTOLOGIN_CURL。抓包导出 autoLogin 为 cURL 贴进环境变量(见文件头说明)。", flush=True)
return 2
for idx, (name, payload) in enumerate(accounts):
log("=== %s 开始 ===" % name)
rc, acct = run_one_account(name, payload, stats)
results.append((rc, acct))
log("=== %s 结束 rc=%d ===" % (name, rc))
if idx < len(accounts) - 1:
log("多号间静默 60s(防累计重放触发风暴冷却)")
time.sleep(60)
print("\n========== 汇总 ==========")
lines = []
for rc, a in results:
mark = "✅" if rc == 0 else "❌"
lines.append("%s %s(%s): %s" % (mark, a["name"], a["mask"], a["detail"]))
print(lines[-1])
print("形态统计: %s" % stats, flush=True)
if any(rc != 0 for rc, _ in results):
send_notify("中国移动签到失败 %d/%d" % (sum(1 for rc, _ in results if rc != 0), len(results)),
"\n".join(lines))
elif os.environ.get("CMCC_NOTIFY_ON_SUCCESS", "").strip() == "1":
send_notify("中国移动签到成功 %d/%d" % (len(results), len(results)), "\n".join(lines))
return max(rc for rc, _ in results) if results else 2
if __name__ == "__main__":
sys.exit(main())