Initial sanitized code sync
This commit is contained in:
589
tools/cf_email_subdomain.py
Normal file
589
tools/cf_email_subdomain.py
Normal file
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cloudflare 二级域名邮箱 DNS 批量配置工具
|
||||
|
||||
功能:选择域名,批量为二级域名创建邮箱所需的 DNS 记录(MX + SPF + DKIM)
|
||||
支持并发创建、自动跳过已存在的记录
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
# ============ 配置 ============
|
||||
CF_API_TOKEN = "nJWYVC1s7P9PpEdELjRTlJaYSMs0XOML1auCv6R8"
|
||||
CF_API_BASE = "https://api.cloudflare.com/client/v4"
|
||||
PROXY = "http://192.168.1.2:10810"
|
||||
MAX_CONCURRENCY = 5 # 最大并发数(Cloudflare 限流,不宜太高)
|
||||
MAX_RETRIES = 3 # 限流重试次数
|
||||
|
||||
# Cloudflare Email Routing MX 记录
|
||||
MX_RECORDS = [
|
||||
{"content": "route1.mx.cloudflare.net", "priority": 10},
|
||||
{"content": "route2.mx.cloudflare.net", "priority": 20},
|
||||
{"content": "route3.mx.cloudflare.net", "priority": 30},
|
||||
]
|
||||
|
||||
SPF_VALUE = "v=spf1 include:_spf.mx.cloudflare.net ~all"
|
||||
|
||||
|
||||
def sync_client():
|
||||
return httpx.Client(
|
||||
proxy=PROXY,
|
||||
headers={
|
||||
"Authorization": f"Bearer {CF_API_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def async_client():
|
||||
return httpx.AsyncClient(
|
||||
proxy=PROXY,
|
||||
headers={
|
||||
"Authorization": f"Bearer {CF_API_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def api_get_sync(client, path, params=None):
|
||||
resp = client.get(f"{CF_API_BASE}{path}", params=params)
|
||||
data = resp.json()
|
||||
if not data["success"]:
|
||||
print(f" API 错误: {data['errors']}")
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def list_zones(client):
|
||||
data = api_get_sync(client, "/zones", {"per_page": 50})
|
||||
if not data:
|
||||
return []
|
||||
return data["result"]
|
||||
|
||||
|
||||
def get_existing_records(client, zone_id):
|
||||
"""获取 zone 下所有 DNS 记录,用于去重"""
|
||||
all_records = []
|
||||
page = 1
|
||||
while True:
|
||||
data = api_get_sync(client, f"/zones/{zone_id}/dns_records", {
|
||||
"per_page": 100,
|
||||
"page": page,
|
||||
})
|
||||
if not data:
|
||||
break
|
||||
all_records.extend(data["result"])
|
||||
if page >= data["result_info"]["total_pages"]:
|
||||
break
|
||||
page += 1
|
||||
return all_records
|
||||
|
||||
|
||||
def get_dkim_record(client, zone_id, domain):
|
||||
data = api_get_sync(client, f"/zones/{zone_id}/dns_records", {
|
||||
"type": "TXT",
|
||||
"name": f"cf2024-1._domainkey.{domain}",
|
||||
})
|
||||
if data and data["result"]:
|
||||
return data["result"][0]["content"]
|
||||
return None
|
||||
|
||||
|
||||
def build_records_for_subdomain(domain, subdomain, dkim_value=None):
|
||||
"""构建一个子域名所需的所有 DNS 记录"""
|
||||
full_sub = f"{subdomain}.{domain}"
|
||||
records = []
|
||||
|
||||
for mx in MX_RECORDS:
|
||||
records.append({
|
||||
"type": "MX",
|
||||
"name": full_sub,
|
||||
"content": mx["content"],
|
||||
"priority": mx["priority"],
|
||||
"ttl": 1,
|
||||
"proxied": False,
|
||||
"comment": f"Email routing for {full_sub}",
|
||||
})
|
||||
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": full_sub,
|
||||
"content": SPF_VALUE,
|
||||
"ttl": 1,
|
||||
"proxied": False,
|
||||
"comment": f"SPF for {full_sub}",
|
||||
})
|
||||
|
||||
if dkim_value:
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": f"cf2024-1._domainkey.{full_sub}",
|
||||
"content": dkim_value,
|
||||
"ttl": 1,
|
||||
"proxied": False,
|
||||
"comment": f"DKIM for {full_sub}",
|
||||
})
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def filter_existing(records_to_create, existing_records):
|
||||
"""过滤掉已存在的记录"""
|
||||
existing_set = set()
|
||||
for r in existing_records:
|
||||
key = (r["type"], r["name"], r["content"])
|
||||
existing_set.add(key)
|
||||
|
||||
new_records = []
|
||||
skipped = 0
|
||||
for r in records_to_create:
|
||||
key = (r["type"], r["name"], r["content"])
|
||||
if key in existing_set:
|
||||
skipped += 1
|
||||
else:
|
||||
new_records.append(r)
|
||||
|
||||
return new_records, skipped
|
||||
|
||||
|
||||
async def create_record_async(sem, client, zone_id, record):
|
||||
"""并发创建单条 DNS 记录,带限流重试"""
|
||||
async with sem:
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{CF_API_BASE}/zones/{zone_id}/dns_records",
|
||||
json=record,
|
||||
)
|
||||
data = resp.json()
|
||||
if data["success"]:
|
||||
label = record["type"]
|
||||
if record["type"] == "TXT":
|
||||
if "spf1" in record["content"]:
|
||||
label = "TXT(SPF)"
|
||||
elif "DKIM" in record.get("comment", ""):
|
||||
label = "TXT(DKIM)"
|
||||
print(f" OK {label:10s} {record['name']} -> {record['content'][:50]}")
|
||||
return "ok"
|
||||
else:
|
||||
# 检查是否限流 (code 971)
|
||||
is_throttle = any(e.get("code") == 971 for e in data.get("errors", []))
|
||||
if is_throttle and attempt < MAX_RETRIES:
|
||||
print(f" THROTTLE {record['name']} 等待 5min 后重试 ({attempt}/{MAX_RETRIES})...")
|
||||
await asyncio.sleep(300)
|
||||
continue
|
||||
print(f" FAIL {record['type']:10s} {record['name']} 错误: {data['errors']}")
|
||||
return "throttle" if is_throttle else "fail"
|
||||
except Exception as e:
|
||||
if attempt < MAX_RETRIES:
|
||||
wait = attempt * 2
|
||||
print(f" ERR {record['name']} {e} 重试 ({attempt}/{MAX_RETRIES})...")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
print(f" ERR {record['type']:10s} {record['name']} 异常: {e}")
|
||||
return "fail"
|
||||
return "fail"
|
||||
|
||||
|
||||
async def batch_create(zone_id, records):
|
||||
"""批量并发创建 DNS 记录,每批之间等待 1 秒"""
|
||||
total_success = 0
|
||||
total_failed = 0
|
||||
async with async_client() as client:
|
||||
for i in range(0, len(records), MAX_CONCURRENCY):
|
||||
batch = records[i:i + MAX_CONCURRENCY]
|
||||
sem = asyncio.Semaphore(MAX_CONCURRENCY)
|
||||
tasks = [create_record_async(sem, client, zone_id, r) for r in batch]
|
||||
results = await asyncio.gather(*tasks)
|
||||
total_success += sum(1 for r in results if r == "ok")
|
||||
total_failed += sum(1 for r in results if r != "ok")
|
||||
# 如果这批有限流,等 30 秒;否则等 1 秒
|
||||
had_throttle = any(r == "throttle" for r in results)
|
||||
if i + MAX_CONCURRENCY < len(records):
|
||||
if had_throttle:
|
||||
print(" 检测到限流,等待 5min...")
|
||||
await asyncio.sleep(300)
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
return total_success, total_failed
|
||||
|
||||
|
||||
def find_incomplete_subdomains(existing_records, domain, has_dkim=False):
|
||||
"""找出邮箱 DNS 记录不完整的子域名"""
|
||||
# 收集所有子域名的记录
|
||||
sub_records = {} # subdomain -> {"mx": set(), "spf": bool, "dkim": bool}
|
||||
mx_targets = {mx["content"] for mx in MX_RECORDS}
|
||||
|
||||
for r in existing_records:
|
||||
name = r["name"]
|
||||
# 跳过主域本身的记录
|
||||
if name == domain:
|
||||
continue
|
||||
# 只关注 .domain 结尾的子域名
|
||||
if not name.endswith(f".{domain}"):
|
||||
continue
|
||||
|
||||
# 提取子域名部分
|
||||
sub_part = name[: -(len(domain) + 1)]
|
||||
|
||||
# DKIM 记录: cf2024-1._domainkey.sub.domain
|
||||
if sub_part.startswith("cf2024-1._domainkey."):
|
||||
actual_sub = sub_part[len("cf2024-1._domainkey."):]
|
||||
if actual_sub not in sub_records:
|
||||
sub_records[actual_sub] = {"mx": set(), "spf": False, "dkim": False, "ids": []}
|
||||
sub_records[actual_sub]["dkim"] = True
|
||||
sub_records[actual_sub]["ids"].append(r["id"])
|
||||
continue
|
||||
|
||||
# MX / TXT(SPF) 记录
|
||||
if sub_part not in sub_records:
|
||||
sub_records[sub_part] = {"mx": set(), "spf": False, "dkim": False, "ids": []}
|
||||
|
||||
if r["type"] == "MX" and r["content"] in mx_targets:
|
||||
sub_records[sub_part]["mx"].add(r["content"])
|
||||
sub_records[sub_part]["ids"].append(r["id"])
|
||||
elif r["type"] == "TXT" and "spf1" in r.get("content", ""):
|
||||
sub_records[sub_part]["spf"] = True
|
||||
sub_records[sub_part]["ids"].append(r["id"])
|
||||
|
||||
# 判断完整性: 需要 3 MX + 1 SPF (+ 可选 DKIM)
|
||||
incomplete = {}
|
||||
complete = {}
|
||||
for sub, info in sub_records.items():
|
||||
mx_count = len(info["mx"])
|
||||
is_complete = mx_count == 3 and info["spf"]
|
||||
if has_dkim:
|
||||
is_complete = is_complete and info["dkim"]
|
||||
|
||||
if not is_complete and info["ids"]: # 有记录但不完整
|
||||
incomplete[sub] = info
|
||||
elif is_complete:
|
||||
complete[sub] = info
|
||||
|
||||
return incomplete, complete
|
||||
|
||||
|
||||
def delete_records_sync(client, zone_id, record_ids):
|
||||
"""同步删除 DNS 记录"""
|
||||
success = 0
|
||||
failed = 0
|
||||
for rid in record_ids:
|
||||
resp = client.delete(f"{CF_API_BASE}/zones/{zone_id}/dns_records/{rid}")
|
||||
data = resp.json()
|
||||
if data["success"]:
|
||||
success += 1
|
||||
else:
|
||||
print(f" 删除失败 {rid}: {data['errors']}")
|
||||
failed += 1
|
||||
return success, failed
|
||||
|
||||
|
||||
def cleanup_incomplete(client, zones):
|
||||
"""检查并清理不完整的子域名邮箱记录"""
|
||||
# 选择域名
|
||||
print(f"\n选择要检查的域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:")
|
||||
while True:
|
||||
choice = input(">>> ").strip().lower()
|
||||
if choice == "all":
|
||||
selected = zones[:]
|
||||
break
|
||||
parts = [p.strip() for p in choice.split(",") if p.strip()]
|
||||
if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts:
|
||||
selected = [zones[int(p) - 1] for p in parts]
|
||||
break
|
||||
print("无效选择。")
|
||||
|
||||
total_deleted = 0
|
||||
total_incomplete = 0
|
||||
|
||||
for zone in selected:
|
||||
zone_id = zone["id"]
|
||||
domain = zone["name"]
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" 检查域名: {domain}")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
existing = get_existing_records(client, zone_id)
|
||||
incomplete, complete = find_incomplete_subdomains(existing, domain)
|
||||
|
||||
print(f" 完整子域名: {len(complete)} 个")
|
||||
print(f" 不完整子域名: {len(incomplete)} 个")
|
||||
|
||||
if not incomplete:
|
||||
print(" 没有不完整的记录,跳过。")
|
||||
continue
|
||||
|
||||
for sub, info in sorted(incomplete.items()):
|
||||
mx_count = len(info["mx"])
|
||||
print(f" {sub}.{domain} MX:{mx_count}/3 SPF:{'有' if info['spf'] else '无'} DKIM:{'有' if info['dkim'] else '无'} 记录数:{len(info['ids'])}")
|
||||
|
||||
total_incomplete += len(incomplete)
|
||||
|
||||
if total_incomplete == 0:
|
||||
print("\n所有域名记录都是完整的。")
|
||||
return
|
||||
|
||||
confirm = input(f"\n确认删除以上 {total_incomplete} 个不完整子域名的所有相关记录?[y/N]: ").strip().lower()
|
||||
if confirm != "y":
|
||||
print("已取消。")
|
||||
return
|
||||
|
||||
# 执行删除
|
||||
for zone in selected:
|
||||
zone_id = zone["id"]
|
||||
domain = zone["name"]
|
||||
existing = get_existing_records(client, zone_id)
|
||||
incomplete, _ = find_incomplete_subdomains(existing, domain)
|
||||
|
||||
if not incomplete:
|
||||
continue
|
||||
|
||||
print(f"\n 删除 {domain} 的不完整记录...")
|
||||
all_ids = []
|
||||
for info in incomplete.values():
|
||||
all_ids.extend(info["ids"])
|
||||
|
||||
success, failed = delete_records_sync(client, zone_id, all_ids)
|
||||
total_deleted += success
|
||||
print(f" 删除完成: 成功 {success}, 失败 {failed}")
|
||||
|
||||
print(f"\n总计删除: {total_deleted} 条记录")
|
||||
|
||||
|
||||
def export_email_suffixes(client, zones):
|
||||
"""导出解析完整的可用邮箱后缀列表"""
|
||||
print(f"\n选择要导出的域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:")
|
||||
while True:
|
||||
choice = input(">>> ").strip().lower()
|
||||
if choice == "all":
|
||||
selected = zones[:]
|
||||
break
|
||||
parts = [p.strip() for p in choice.split(",") if p.strip()]
|
||||
if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts:
|
||||
selected = [zones[int(p) - 1] for p in parts]
|
||||
break
|
||||
print("无效选择。")
|
||||
|
||||
all_suffixes = []
|
||||
|
||||
for zone in selected:
|
||||
zone_id = zone["id"]
|
||||
domain = zone["name"]
|
||||
print(f"\n 检查 {domain} ...")
|
||||
|
||||
existing = get_existing_records(client, zone_id)
|
||||
incomplete, complete = find_incomplete_subdomains(existing, domain)
|
||||
|
||||
# 主域如果有完整邮箱记录也算
|
||||
main_mx = set()
|
||||
main_spf = False
|
||||
for r in existing:
|
||||
if r["name"] == domain:
|
||||
if r["type"] == "MX" and r["content"] in {mx["content"] for mx in MX_RECORDS}:
|
||||
main_mx.add(r["content"])
|
||||
elif r["type"] == "TXT" and "spf1" in r.get("content", ""):
|
||||
main_spf = True
|
||||
if len(main_mx) == 3 and main_spf:
|
||||
all_suffixes.append(domain)
|
||||
|
||||
# 完整的子域名
|
||||
for sub in sorted(complete.keys()):
|
||||
all_suffixes.append(f"{sub}.{domain}")
|
||||
|
||||
print(f" 完整: {len(complete)} 个子域名, 不完整: {len(incomplete)} 个")
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" 可用邮箱后缀: {len(all_suffixes)} 个")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
# 打印数组格式
|
||||
import json
|
||||
print(f"\n{json.dumps(all_suffixes, ensure_ascii=False)}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 55)
|
||||
print(" Cloudflare 二级域名邮箱 DNS 批量配置工具 (并发版)")
|
||||
print("=" * 55)
|
||||
|
||||
client = sync_client()
|
||||
|
||||
# 主菜单
|
||||
print("\n功能选择:")
|
||||
print(" [1] 批量创建子域名邮箱 DNS")
|
||||
print(" [2] 检查并清理不完整的记录")
|
||||
print(" [3] 导出可用邮箱后缀列表")
|
||||
|
||||
while True:
|
||||
mode = input("\n选择功能 [1-3]: ").strip()
|
||||
if mode in ("1", "2", "3"):
|
||||
break
|
||||
print("无效选择。")
|
||||
|
||||
# 1. 列出域名
|
||||
print("\n获取域名列表...")
|
||||
zones = list_zones(client)
|
||||
if not zones:
|
||||
print("没有找到域名,请检查 API Token 权限。")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n找到 {len(zones)} 个域名:")
|
||||
for i, z in enumerate(zones):
|
||||
print(f" [{i + 1}] {z['name']} (状态: {z['status']})")
|
||||
|
||||
# 功能 2: 清理不完整记录
|
||||
if mode == "2":
|
||||
cleanup_incomplete(client, zones)
|
||||
client.close()
|
||||
return
|
||||
|
||||
# 功能 3: 导出可用邮箱后缀
|
||||
if mode == "3":
|
||||
export_email_suffixes(client, zones)
|
||||
client.close()
|
||||
return
|
||||
|
||||
# 2. 选择域名(支持多选)
|
||||
print(f"\n选择域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:")
|
||||
while True:
|
||||
choice = input(">>> ").strip().lower()
|
||||
if choice == "all":
|
||||
selected_zones = zones[:]
|
||||
break
|
||||
parts = [p.strip() for p in choice.split(",") if p.strip()]
|
||||
if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts:
|
||||
selected_zones = [zones[int(p) - 1] for p in parts]
|
||||
break
|
||||
print("无效选择,请重试。")
|
||||
|
||||
print(f"\n已选择 {len(selected_zones)} 个域名:")
|
||||
for z in selected_zones:
|
||||
print(f" - {z['name']}")
|
||||
|
||||
# 3. 输入二级域名数量和前缀
|
||||
while True:
|
||||
count = input("\n要创建多少个二级域名邮箱?: ").strip()
|
||||
if count.isdigit() and int(count) > 0:
|
||||
count = int(count)
|
||||
break
|
||||
print("请输入正整数。")
|
||||
|
||||
print("\n前缀模式:")
|
||||
print(" [1] 数字前缀 (mail1, mail2, mail3...)")
|
||||
print(" [2] 字母前缀 (a, b, c...)")
|
||||
print(" [3] 自定义前缀 (输入逗号分隔的列表)")
|
||||
|
||||
while True:
|
||||
prefix_choice = input("\n选择前缀模式 [1-3]: ").strip()
|
||||
if prefix_choice in ("1", "2", "3"):
|
||||
break
|
||||
print("无效选择。")
|
||||
|
||||
subdomains = []
|
||||
if prefix_choice == "1":
|
||||
prefix = input("输入前缀 (默认 mail): ").strip() or "mail"
|
||||
subdomains = [f"{prefix}{i}" for i in range(1, count + 1)]
|
||||
elif prefix_choice == "2":
|
||||
if count > 26:
|
||||
print("字母前缀最多支持 26 个。")
|
||||
sys.exit(1)
|
||||
subdomains = [chr(ord('a') + i) for i in range(count)]
|
||||
else:
|
||||
custom = input("输入前缀列表 (逗号分隔): ").strip()
|
||||
subdomains = [s.strip() for s in custom.split(",") if s.strip()]
|
||||
if len(subdomains) != count:
|
||||
print(f"警告:输入了 {len(subdomains)} 个前缀,与数量 {count} 不匹配,使用实际输入的。")
|
||||
count = len(subdomains)
|
||||
|
||||
# 4. DKIM 策略
|
||||
print("\nDKIM 策略:")
|
||||
print(" [1] 复制主域 DKIM 记录")
|
||||
print(" [2] 跳过 DKIM (只创建 MX + SPF)")
|
||||
|
||||
while True:
|
||||
dkim_choice = input("\n选择 DKIM 策略 [1-2]: ").strip()
|
||||
if dkim_choice in ("1", "2"):
|
||||
break
|
||||
print("无效选择。")
|
||||
|
||||
# 5. 对每个域名执行
|
||||
grand_total_success = 0
|
||||
grand_total_skipped = 0
|
||||
grand_total_failed = 0
|
||||
all_created_emails = []
|
||||
|
||||
for zi, zone in enumerate(selected_zones):
|
||||
zone_id = zone["id"]
|
||||
domain = zone["name"]
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" [{zi+1}/{len(selected_zones)}] 处理域名: {domain}")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
# DKIM
|
||||
dkim_value = None
|
||||
if dkim_choice == "1":
|
||||
print(" 获取主域 DKIM 记录...")
|
||||
dkim_value = get_dkim_record(client, zone_id, domain)
|
||||
if dkim_value:
|
||||
print(f" 找到 DKIM 记录 (长度: {len(dkim_value)})")
|
||||
else:
|
||||
print(" 未找到主域 DKIM 记录,将跳过 DKIM。")
|
||||
|
||||
# 构建记录
|
||||
all_records = []
|
||||
for sub in subdomains:
|
||||
all_records.extend(build_records_for_subdomain(domain, sub, dkim_value))
|
||||
print(f" 待创建: {len(all_records)} 条记录")
|
||||
|
||||
# 去重
|
||||
print(" 检查已有 DNS 记录 (去重)...")
|
||||
existing = get_existing_records(client, zone_id)
|
||||
print(f" 已有记录: {len(existing)} 条")
|
||||
|
||||
new_records, skipped = filter_existing(all_records, existing)
|
||||
print(f" 跳过已存在: {skipped} 条")
|
||||
print(f" 需新建: {len(new_records)} 条")
|
||||
|
||||
grand_total_skipped += skipped
|
||||
|
||||
if not new_records:
|
||||
print(" 所有记录已存在,跳过此域名。")
|
||||
continue
|
||||
|
||||
# 并发创建
|
||||
print(f" 开始并发创建 (并发数: {MAX_CONCURRENCY})...")
|
||||
start_time = time.time()
|
||||
success, failed = asyncio.run(batch_create(zone_id, new_records))
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
grand_total_success += success
|
||||
grand_total_failed += failed
|
||||
|
||||
print(f" 完成!成功: {success}, 失败: {failed}, 耗时: {elapsed:.1f}s")
|
||||
|
||||
for sub in subdomains:
|
||||
all_created_emails.append(f"*@{sub}.{domain}")
|
||||
|
||||
client.close()
|
||||
|
||||
# 6. 总汇总
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" 全部完成!")
|
||||
print(f" 域名数: {len(selected_zones)} 个")
|
||||
print(f" 子域名数: {count} x {len(selected_zones)} = {count * len(selected_zones)} 个")
|
||||
print(f" 成功: {grand_total_success} 条")
|
||||
print(f" 跳过: {grand_total_skipped} 条")
|
||||
print(f" 失败: {grand_total_failed} 条")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
if grand_total_success > 0:
|
||||
print("\n注意: 还需要在 Cloudflare Email Routing 中配置转发规则才能收邮件。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
99
tools/team_invite_analysis.md
Normal file
99
tools/team_invite_analysis.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Team 邀请小号流程分析报告
|
||||
|
||||
## 1. 邀请流程 (Phase-7)
|
||||
|
||||
完整流程在 `cmd/gptplus/main.go` 第 582-718 行:
|
||||
|
||||
```
|
||||
Team 创建成功 → 获取 workspace token → 循环 invite_count 次:
|
||||
1. 创建新 HTTP client (带独立代理 session)
|
||||
2. 调用 auth.Register() 注册新账号 ← 消耗一个邮箱
|
||||
3. 调用 chatgpt.InviteToTeam() 发送邀请 (POST /backend-api/accounts/{id}/invites)
|
||||
4. 成员登录获取 token
|
||||
5. 调用 auth.ObtainCodexTokens() 获取 Team 作用域 token (最多重试 3 次)
|
||||
6. 保存成员 auth 文件
|
||||
```
|
||||
|
||||
邀请 API: `POST /backend-api/accounts/{team_account_id}/invites`
|
||||
- payload: `{"email_addresses": ["xxx@outlook.com"], "role": "standard-user", "resend_emails": true}`
|
||||
- 需要 workspace-scoped access token
|
||||
|
||||
**注意:当前流程只发送邀请,没有"接受邀请"的步骤。** 成员注册后直接通过 `ObtainCodexTokens` 获取 Team token,假设邀请自动生效(因为是 admin 邀请已注册邮箱)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 小号获取邮件的逻辑
|
||||
|
||||
### 当前行为
|
||||
小号注册时调用的是**同一个 `emailProv`**(第 618 行):
|
||||
```go
|
||||
memberRegResult, memberRegErr := auth.Register(ctx, memberClient, emailProv, memberPassword)
|
||||
```
|
||||
|
||||
`auth.Register()` 内部会调用 `emailProv.CreateMailbox()` 获取邮箱,然后调用 `emailProv.WaitForVerificationCode()` 获取 OTP。
|
||||
|
||||
### 有没有问题?
|
||||
**之前有问题(已修复):** `WaitForVerificationCode` 使用的 `$filter` 查询被 Outlook REST API 拒绝(InefficientFilter),导致永远拿不到 OTP。现在已改为无 filter + 客户端过滤。
|
||||
|
||||
**潜在问题:** 无 filter 模式下拉取最近 10 封邮件,如果多个小号同时注册,收件箱里会有多封 OTP 邮件。由于有 `notBefore` 时间过滤 + `$orderby=ReceivedDateTime+desc`(最新优先),且代码取到第一个匹配的 OTP 就返回,所以**正常情况下不会混淆**。但如果两个小号在同一秒内收到 OTP,理论上可能取错(概率极低,因为是串行注册)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 小号是否遵循邮箱设定(Outlook)?
|
||||
|
||||
**是的。** 小号使用的是同一个 `emailProv` 实例:
|
||||
|
||||
- 主账号和所有小号共享同一个 `OutlookProvider`
|
||||
- 每次 `CreateMailbox()` 调用会返回 `accounts[nextIdx]` 并递增 `nextIdx`
|
||||
- 所以小号注册时会**按顺序消耗 outmail.txt 中的 Outlook 账号**
|
||||
|
||||
如果配置是 `email.provider: "outlook"`,所有小号都会用 Outlook 邮箱。
|
||||
|
||||
---
|
||||
|
||||
## 4. 邮箱消耗顺序问题(核心问题)
|
||||
|
||||
### 场景
|
||||
- outmail.txt 有 10 个邮箱 (索引 0-9)
|
||||
- `invite_count: 4`
|
||||
- 第一次运行:邮箱 #0 注册 Plus → Team 成功 → 邀请 4 个小号
|
||||
|
||||
### 当前行为
|
||||
```
|
||||
邮箱 #0 → 主账号注册 (CreateMailbox, nextIdx: 0→1)
|
||||
邮箱 #1 → 小号1注册 (CreateMailbox, nextIdx: 1→2)
|
||||
邮箱 #2 → 小号2注册 (CreateMailbox, nextIdx: 2→3)
|
||||
邮箱 #3 → 小号3注册 (CreateMailbox, nextIdx: 3→4)
|
||||
邮箱 #4 → 小号4注册 (CreateMailbox, nextIdx: 4→5)
|
||||
```
|
||||
|
||||
**一次完整运行消耗 5 个邮箱(1 主 + 4 小号)。**
|
||||
|
||||
### 下一次运行会怎样?
|
||||
|
||||
**会从邮箱 #0 重新开始,不是 #5。**
|
||||
|
||||
原因:`OutlookProvider.nextIdx` 是内存变量,每次程序启动时初始化为 0。没有持久化记录哪些邮箱已经用过。
|
||||
|
||||
```go
|
||||
// NewOutlookProvider 每次创建时 nextIdx = 0
|
||||
return &OutlookProvider{
|
||||
accounts: accounts,
|
||||
}, nil
|
||||
```
|
||||
|
||||
### 后果
|
||||
- 第二次运行会尝试用邮箱 #0 注册,但 #0 已经注册过 → **注册失败**
|
||||
- 如果 OpenAI 返回的是 "Failed to register username" 而不是 "email already exists",程序无法区分
|
||||
- 需要手动从 outmail.txt 删除已用过的邮箱,或者实现已用邮箱的持久化追踪
|
||||
|
||||
---
|
||||
|
||||
## 5. 问题汇总与建议
|
||||
|
||||
| # | 问题 | 严重度 | 建议 |
|
||||
|---|------|--------|------|
|
||||
| 1 | **nextIdx 不持久化**:重启后从 #0 开始,已用邮箱会重复注册失败 | 🔴 高 | 用文件记录已用邮箱索引,或注册成功后从 outmail.txt 中移除 |
|
||||
| 2 | **无接受邀请步骤**:依赖 ObtainCodexTokens 自动加入 | 🟡 中 | 目前看起来可行,但可能需要验证在某些情况下是否需要显式 accept |
|
||||
| 3 | **OTP 串号风险**:多小号共用收件箱时理论上可能取错 OTP | 🟢 低 | 串行注册 + notBefore 过滤已足够,暂不需处理 |
|
||||
| 4 | **Outlook filter 已修复** | ✅ | 已改为无 filter + 客户端过滤 |
|
||||
161
tools/test_outlook.go
Normal file
161
tools/test_outlook.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
msTokenURL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||
outlookMailURL = "https://outlook.office.com/api/v2.0/me/mailFolders/inbox/messages"
|
||||
)
|
||||
|
||||
func main() {
|
||||
clientID := "9e5f94bc-e8a4-4e73-b8be-63364c29d753"
|
||||
refreshToken := "M.C502_SN1.0.U.-Crvsmbp5ATQIkZ3hocv9p7Z2X5!OXVJHdHa72LcvvI0vL89DBJ3O6CU!j07BU3A9kAJlFQcYVE*wmp!riMOJXBdeMvgB9xuF0duYbb8*M8qCnYGkX196K0z5CMhkHOOSahXbHkWN0cI685XA4z27rPYPOcGylHZdax1K0wF6N4kSxe*eoXn0dYL9wMPe1oC3vQ62B1EYECpaqqpfIKfKqhBpkbQ74up272uhiMvlRkYiD6ZCxL1aSeHg*aRiXGP4XfWWojKHN5rBqcXCXTRTGqw4r1Zij!RDXSBPBucjlL2uUSgPUu4H!UzEIQntGU2DeTGqecwk*XiJhLiic*RbrQliU9wQ!8J36Cc2grdSbeF9TeCHp!H6FC!qS40rekL31ZPFSunD8sJ*dQTOxPH9qC9ErHeoHvREFh0ypy4pa!!h5!15vvtwAPZgEcxcg2qSiA$$"
|
||||
email := "frher9601@outlook.com"
|
||||
|
||||
fmt.Printf("=== Outlook REST API - No-Filter Strategy Test ===\n")
|
||||
fmt.Printf("Account: %s\n\n", email)
|
||||
|
||||
// Step 1: Get access token
|
||||
fmt.Println("[1] Exchanging refresh token...")
|
||||
accessToken, err := exchangeToken(clientID, refreshToken)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(" OK: access_token (%d chars)\n\n", len(accessToken))
|
||||
|
||||
// Step 2: Fetch recent emails WITHOUT any filter (the fix)
|
||||
fmt.Println("[2] Fetching recent 10 emails (no $filter)...")
|
||||
reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,BodyPreview,ReceivedDateTime,From",
|
||||
outlookMailURL)
|
||||
|
||||
req, _ := http.NewRequest("GET", reqURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf(" FAILED (HTTP %d): %s\n", resp.StatusCode, string(body)[:min(len(body), 300)])
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Value []struct {
|
||||
Subject string `json:"Subject"`
|
||||
BodyPreview string `json:"BodyPreview"`
|
||||
ReceivedDateTime string `json:"ReceivedDateTime"`
|
||||
From struct {
|
||||
EmailAddress struct {
|
||||
Address string `json:"Address"`
|
||||
} `json:"EmailAddress"`
|
||||
} `json:"From"`
|
||||
} `json:"value"`
|
||||
}
|
||||
json.Unmarshal(body, &result)
|
||||
fmt.Printf(" OK: %d emails returned\n\n", len(result.Value))
|
||||
|
||||
// Step 3: Client-side filter simulation
|
||||
fmt.Println("[3] Client-side filtering (simulating fixed code)...")
|
||||
notBefore := time.Now().Add(-48 * time.Hour) // last 48h
|
||||
otpRegexp := regexp.MustCompile(`\b(\d{6})\b`)
|
||||
|
||||
foundOTP := ""
|
||||
for i, e := range result.Value {
|
||||
sender := strings.ToLower(e.From.EmailAddress.Address)
|
||||
preview := e.BodyPreview
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:80] + "..."
|
||||
}
|
||||
fmt.Printf(" [%d] from=%-45s subj=%q\n", i, sender, e.Subject)
|
||||
|
||||
// Filter: must be from OpenAI
|
||||
if !strings.Contains(sender, "openai") {
|
||||
fmt.Printf(" -> SKIP (not openai)\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter: time check
|
||||
if e.ReceivedDateTime != "" {
|
||||
if t, err := time.Parse("2006-01-02T15:04:05Z", e.ReceivedDateTime); err == nil {
|
||||
if t.Before(notBefore) {
|
||||
fmt.Printf(" -> SKIP (too old: %s)\n", e.ReceivedDateTime)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract OTP
|
||||
if m := otpRegexp.FindStringSubmatch(e.Subject); len(m) >= 2 {
|
||||
foundOTP = m[1]
|
||||
fmt.Printf(" -> OTP from subject: %s\n", foundOTP)
|
||||
break
|
||||
}
|
||||
if m := otpRegexp.FindStringSubmatch(e.BodyPreview); len(m) >= 2 {
|
||||
foundOTP = m[1]
|
||||
fmt.Printf(" -> OTP from body: %s\n", foundOTP)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" -> OpenAI email but no OTP found\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if foundOTP != "" {
|
||||
fmt.Printf("=== SUCCESS: OTP = %s ===\n", foundOTP)
|
||||
} else {
|
||||
fmt.Println("=== No OTP found (no recent OpenAI email in inbox) ===")
|
||||
}
|
||||
}
|
||||
|
||||
func exchangeToken(clientID, refreshToken string) (string, error) {
|
||||
data := url.Values{
|
||||
"client_id": {clientID},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.PostForm(msTokenURL, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
cut := len(body)
|
||||
if cut > 300 {
|
||||
cut = 300
|
||||
}
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:cut])
|
||||
}
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
json.Unmarshal(body, &tokenResp)
|
||||
if tokenResp.AccessToken == "" {
|
||||
return "", fmt.Errorf("no access_token in response")
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
292
tools/validate_cards.py
Normal file
292
tools/validate_cards.py
Normal file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Card Pre-Validator — Generate and validate cards via Stripe /v1/tokens.
|
||||
Saves valid cards to a file for use by the main gptplus pipeline.
|
||||
|
||||
Usage:
|
||||
python validate_cards.py --count 3000 --concurrency 20 --output valid_cards.txt
|
||||
python validate_cards.py --count 1000 --pk pk_live_xxx --bin 6258142602
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
print("需要安装 aiohttp: pip install aiohttp")
|
||||
sys.exit(1)
|
||||
|
||||
# ─── Default Config ───
|
||||
|
||||
DEFAULT_BINS = ["6258142602", "62581717"]
|
||||
DEFAULT_COUNTRY = "KR"
|
||||
DEFAULT_CURRENCY = "KRW"
|
||||
STRIPE_TOKENS_URL = "https://api.stripe.com/v1/tokens"
|
||||
|
||||
# Korean address data for random generation
|
||||
KOREAN_CITIES = [
|
||||
("Seoul", "서울특별시", "11"),
|
||||
("Busan", "부산광역시", "26"),
|
||||
("Incheon", "인천광역시", "28"),
|
||||
("Daegu", "대구광역시", "27"),
|
||||
("Daejeon", "대전광역시", "30"),
|
||||
("Gwangju", "광주광역시", "29"),
|
||||
("Suwon", "경기도", "41"),
|
||||
("Seongnam", "경기도", "41"),
|
||||
]
|
||||
|
||||
KOREAN_STREETS = [
|
||||
"Gangnam-daero", "Teheran-ro", "Sejong-daero", "Eulji-ro",
|
||||
"Jongno", "Hangang-daero", "Dongho-ro", "Yeoksam-ro",
|
||||
"Apgujeong-ro", "Sinsa-dong", "Nonhyeon-ro", "Samseong-ro",
|
||||
]
|
||||
|
||||
KOREAN_NAMES = [
|
||||
"Kim Min Jun", "Lee Seo Yeon", "Park Ji Hoon", "Choi Yuna",
|
||||
"Jung Hyun Woo", "Kang Soo Jin", "Yoon Tae Yang", "Lim Ha Eun",
|
||||
"Han Seung Ho", "Shin Ye Rin", "Song Jae Min", "Hwang Mi Rae",
|
||||
"Bae Dong Hyun", "Jeon So Hee", "Ko Young Jun", "Seo Na Yeon",
|
||||
]
|
||||
|
||||
KOREAN_ZIPS = [
|
||||
"06010", "06134", "06232", "06611", "07258", "08502", "04778",
|
||||
"05502", "03181", "02578", "01411", "04516", "05854", "06775",
|
||||
]
|
||||
|
||||
|
||||
# ─── Card Generation ───
|
||||
|
||||
def luhn_check_digit(partial: str) -> str:
|
||||
"""Calculate Luhn check digit for a partial card number."""
|
||||
digits = [int(d) for d in partial]
|
||||
# Double every second digit from right (starting from the rightmost)
|
||||
for i in range(len(digits) - 1, -1, -2):
|
||||
digits[i] *= 2
|
||||
if digits[i] > 9:
|
||||
digits[i] -= 9
|
||||
total = sum(digits)
|
||||
check = (10 - (total % 10)) % 10
|
||||
return str(check)
|
||||
|
||||
|
||||
def generate_card(bins: list[str]) -> dict:
|
||||
"""Generate a random card number with valid Luhn checksum."""
|
||||
bin_prefix = random.choice(bins)
|
||||
total_len = 16
|
||||
fill_len = total_len - len(bin_prefix) - 1 # -1 for check digit
|
||||
|
||||
partial = bin_prefix + "".join([str(random.randint(0, 9)) for _ in range(fill_len)])
|
||||
check = luhn_check_digit(partial)
|
||||
number = partial + check
|
||||
|
||||
# Random expiry 12-48 months from now
|
||||
future = datetime.now() + timedelta(days=random.randint(365, 365 * 4))
|
||||
exp_month = f"{future.month:02d}"
|
||||
exp_year = str(future.year)
|
||||
|
||||
# Random CVC
|
||||
cvc = f"{random.randint(0, 999):03d}"
|
||||
|
||||
# Random Korean identity
|
||||
city_data = random.choice(KOREAN_CITIES)
|
||||
name = random.choice(KOREAN_NAMES)
|
||||
street_num = random.randint(1, 300)
|
||||
street = random.choice(KOREAN_STREETS)
|
||||
zip_code = random.choice(KOREAN_ZIPS)
|
||||
|
||||
return {
|
||||
"number": number,
|
||||
"exp_month": exp_month,
|
||||
"exp_year": exp_year,
|
||||
"cvc": cvc,
|
||||
"name": name,
|
||||
"country": DEFAULT_COUNTRY,
|
||||
"currency": DEFAULT_CURRENCY,
|
||||
"address": f"{street_num} {street}",
|
||||
"city": city_data[0],
|
||||
"state": city_data[2],
|
||||
"postal_code": zip_code,
|
||||
}
|
||||
|
||||
|
||||
# ─── Stripe Validation ───
|
||||
|
||||
def _rotate_proxy(proxy_template: str) -> str:
|
||||
"""Generate a unique proxy URL by replacing session placeholder with random value.
|
||||
e.g. 'http://USER-session-{SESSION}-xxx:pass@host:port' → unique session per request."""
|
||||
if not proxy_template:
|
||||
return ""
|
||||
if "{SESSION}" in proxy_template:
|
||||
session_id = f"{random.randint(100000, 999999)}"
|
||||
return proxy_template.replace("{SESSION}", session_id)
|
||||
# If no {SESSION} placeholder, append random session to avoid same IP
|
||||
return proxy_template
|
||||
|
||||
|
||||
async def validate_card(session: aiohttp.ClientSession, card: dict, pk: str, proxy_template: str, semaphore: asyncio.Semaphore) -> tuple[dict, bool, str]:
|
||||
"""Validate a card via Stripe /v1/tokens. Each request gets a unique proxy IP."""
|
||||
async with semaphore:
|
||||
# Rotate proxy — each request gets a different IP
|
||||
proxy = _rotate_proxy(proxy_template)
|
||||
|
||||
data = {
|
||||
"card[number]": card["number"],
|
||||
"card[exp_month]": card["exp_month"],
|
||||
"card[exp_year]": card["exp_year"],
|
||||
"card[cvc]": card["cvc"],
|
||||
"card[name]": card["name"],
|
||||
"card[address_country]": card["country"],
|
||||
"card[address_line1]": card["address"],
|
||||
"card[address_city]": card["city"],
|
||||
"card[address_state]": card["state"],
|
||||
"card[address_zip]": card["postal_code"],
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://js.stripe.com",
|
||||
"Referer": "https://js.stripe.com/",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(
|
||||
f"{STRIPE_TOKENS_URL}?key={pk}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
proxy=proxy if proxy else None,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
body = await resp.json()
|
||||
|
||||
if resp.status == 200 and "id" in body:
|
||||
return card, True, ""
|
||||
|
||||
# Extract error
|
||||
err = body.get("error", {})
|
||||
code = err.get("code", "unknown")
|
||||
msg = err.get("message", str(body))
|
||||
return card, False, f"{code}: {msg}"
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return card, False, "timeout"
|
||||
except Exception as e:
|
||||
return card, False, str(e)
|
||||
|
||||
|
||||
def card_to_line(card: dict) -> str:
|
||||
"""Format card as pipe-delimited line (same format as success_cards.txt)."""
|
||||
return "|".join([
|
||||
card["number"], card["exp_month"], card["exp_year"], card["cvc"],
|
||||
card["name"], card["country"], card["currency"],
|
||||
card["address"], card["city"], card["state"], card["postal_code"],
|
||||
])
|
||||
|
||||
|
||||
# ─── Main ───
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Card Pre-Validator via Stripe /v1/tokens")
|
||||
parser.add_argument("--count", type=int, default=1000, help="Number of cards to generate and test")
|
||||
parser.add_argument("--concurrency", type=int, default=20, help="Concurrent validation requests")
|
||||
parser.add_argument("--output", type=str, default="valid_cards.txt", help="Output file for valid cards")
|
||||
parser.add_argument("--pk", type=str, required=True, help="Stripe publishable key (pk_live_xxx)")
|
||||
parser.add_argument("--bin", type=str, default="", help="Custom BIN prefix (comma-separated for multiple)")
|
||||
parser.add_argument("--proxy", type=str, default="", help="Korean proxy URL (e.g. http://user:pass@host:port)")
|
||||
args = parser.parse_args()
|
||||
|
||||
bins = DEFAULT_BINS
|
||||
if args.bin:
|
||||
bins = [b.strip() for b in args.bin.split(",")]
|
||||
|
||||
proxy = args.proxy or ""
|
||||
|
||||
print(f"=== Card Pre-Validator ===")
|
||||
print(f"生成: {args.count} 张")
|
||||
print(f"并发: {args.concurrency}")
|
||||
print(f"BIN: {bins}")
|
||||
print(f"代理: {proxy if proxy else '无 (直连)'}")
|
||||
print(f"输出: {args.output}")
|
||||
print()
|
||||
|
||||
# Generate all cards first
|
||||
print(f"▶ 生成 {args.count} 张卡...")
|
||||
cards = [generate_card(bins) for _ in range(args.count)]
|
||||
print(f"✓ 生成完毕")
|
||||
|
||||
# Validate concurrently
|
||||
semaphore = asyncio.Semaphore(args.concurrency)
|
||||
valid_cards = []
|
||||
invalid_count = 0
|
||||
error_counts = {}
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = [validate_card(session, card, args.pk, proxy, semaphore) for card in cards]
|
||||
|
||||
print(f"▶ 开始验证 (并发={args.concurrency})...")
|
||||
|
||||
done = 0
|
||||
for coro in asyncio.as_completed(tasks):
|
||||
card, is_valid, err_msg = await coro
|
||||
done += 1
|
||||
|
||||
if is_valid:
|
||||
valid_cards.append(card)
|
||||
last4 = card["number"][-4:]
|
||||
print(f" ✓ [{done}/{args.count}] ...{last4} 有效 (累计有效: {len(valid_cards)})")
|
||||
else:
|
||||
invalid_count += 1
|
||||
# Track error types
|
||||
err_type = err_msg.split(":")[0] if ":" in err_msg else err_msg
|
||||
error_counts[err_type] = error_counts.get(err_type, 0) + 1
|
||||
|
||||
# Print progress every 100 or on interesting errors
|
||||
if done % 100 == 0 or done == args.count:
|
||||
elapsed = time.time() - start_time
|
||||
rate = done / elapsed if elapsed > 0 else 0
|
||||
print(f" → [{done}/{args.count}] 有效={len(valid_cards)} 无效={invalid_count} ({rate:.1f}/s)")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Save valid cards
|
||||
if valid_cards:
|
||||
with open(args.output, "w") as f:
|
||||
for card in valid_cards:
|
||||
f.write(card_to_line(card) + "\n")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(f"╔══════════════════════════════════════╗")
|
||||
print(f"║ 验证结果汇总 ║")
|
||||
print(f"╠══════════════════════════════════════╣")
|
||||
print(f"║ 总计: {args.count:>6} 耗时: {elapsed:.1f}s ║")
|
||||
print(f"║ 有效: {len(valid_cards):>6} ({len(valid_cards)/args.count*100:.1f}%) ║")
|
||||
print(f"║ 无效: {invalid_count:>6} ║")
|
||||
print(f"║ 速率: {args.count/elapsed:.1f}/s ║")
|
||||
print(f"║ 输出: {args.output:<30s}║")
|
||||
print(f"╚══════════════════════════════════════╝")
|
||||
|
||||
if error_counts:
|
||||
print(f"\n错误分布:")
|
||||
for err, count in sorted(error_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {err}: {count}")
|
||||
|
||||
print(f"\n有效卡片已保存到 {args.output}")
|
||||
print(f"在 config.yaml 中使用:")
|
||||
print(f" card:")
|
||||
print(f" provider: file")
|
||||
print(f" file:")
|
||||
print(f" path: {args.output}")
|
||||
print(f" default_country: KR")
|
||||
print(f" default_currency: KRW")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user