Files
Astral/netdelivery.py
Txiaochen 4959487a71 docs(README): 补全主脚本用法,新增 netdelivery 章节
- 新增「主脚本一览」总表(副作用 / 是否需要二维码)
- upsert.py:独立成章,--extra-data 归位
- buy_ticket.py:补参数表 + 三道安全检查说明
- use_ticket.py:补参数表 + 真机消耗票券的报文依据
- reincarnate.py:补参数表 + 流程/分批/续关/playlog 合成原理
- netdelivery.py:新增章节(ALL.Net DLI,查/下 .app 与 .opt)
- 新增「常见坑与注意事项」(二维码 / 账号状态 / 功能票 / 副作用 /
  日志 / 网络 / 不入库清单)
- 「将来」改为「已完成 / 将来」,列出 v0 全部交付
- 项目框架补上 netdelivery.py 与 download/
2026-10-07 01:22:38 +08:00

704 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
================================================================================
【DLI 是什么】
DLI = **Download Instruction**(配信指示書)。
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
POST http://at.sys-allnet.cn/net/delivery/instruction
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
服务器回:
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
INSTALL2=https://.../SDGB_A005_..._0.opt
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
只是文件名,不是下载项。
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
日志 "Net deliver param - dli uri : %s." @0x63c3f8
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
【实测结论】(2026-10-06)
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
title_ver<=1.54 -> uri = null (服务器不认这个版本)
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
【用法】
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
python netdelivery.py A63E01E9580 # 指定 Keychip
python netdelivery.py --list # 只列清单,不下载
python netdelivery.py --all # 全下(不交互)
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
python netdelivery.py --out D:\SDGB2026\update
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
import requests
import urllib3
from authv1 import (
DEFAULT_HOST,
DEFAULT_TIMEOUT,
AllnetError,
delivery_instruction,
parse_keychip,
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 下载器用的 UA(实测有效)
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
CHUNK = 1 << 16
PROBE_TIMEOUT = 30
DOWNLOAD_TIMEOUT = 60
# 包类型判定(按扩展名)
KIND_BY_EXT = {
".app": "app",
".opt": "opt",
".pack": "pack",
}
KIND_TEXT = {
"app": "本体/补丁",
"opt": "选项包",
"pack": "资源包",
"?": "未知",
}
# =============================================================================
# HTTP
# =============================================================================
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
"""
GET,顺带处理 CDN 证书问题。
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
(只影响这一次请求,不改变全局设置)。
"""
merged = dict(DOWNLOAD_HEADERS)
if headers:
merged.update(headers)
try:
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
verify=not insecure)
except requests.exceptions.SSLError:
if insecure:
raise
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
verify=False)
# =============================================================================
# 配信指示書解析
# =============================================================================
def parse_instruction(text):
"""
解析配信指示書 INI。
返回 (common, installs):
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
"""
common = {}
installs = []
section = ""
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith(";") or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip().upper()
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().upper()
value = value.strip().strip('"')
if section in ("", "COMMON"):
common.setdefault(key, value)
if key.startswith("INSTALL") and value:
installs.append({"section": section or "COMMON", "key": key, "url": value})
return common, installs
def guess_kind(url):
"""按扩展名猜包类型。"""
name = url.split("?")[0].rsplit("/", 1)[-1]
ext = os.path.splitext(name)[1].lower()
return KIND_BY_EXT.get(ext, "?")
def package_name(url):
return url.split("?")[0].rsplit("/", 1)[-1]
def human_size(num):
if num is None:
return "?"
value = float(num)
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
value /= 1024.0
return "?"
# =============================================================================
# 采集:DLI -> 指示書 -> 包清单
# =============================================================================
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
kv = {}
for part in text.strip().split("&"):
if "=" in part:
k, v = part.split("=", 1)
kv[k.strip()] = v
result = kv.get("result", "")
if result != "1":
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
uri = kv.get("uri", "").strip()
if uri in ("", "null"):
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
parts = uri.split("|")
patch_url = parts[0].strip() if len(parts) > 0 else ""
option_url = parts[1].strip() if len(parts) > 1 else ""
for bad in ("null", "none"):
if patch_url.lower() == bad:
patch_url = ""
if option_url.lower() == bad:
option_url = ""
return patch_url, option_url, text.strip()
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
"""
走完整条链路,返回 (items, meta)。
items 每一项:
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
"""
patch_url, option_url, dli_raw = fetch_instruction_urls(
client_id, title_ver=title_ver, host=host, timeout=timeout)
items = []
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
"instructions": [], "infos": {}}
for info_url in (patch_url, option_url):
if not info_url:
continue
info_name = package_name(info_url)
resp = None
for attempt in range(1, 4):
try:
resp = http_get(info_url, timeout=timeout, insecure=insecure)
if resp.status_code < 500:
break
except Exception:
resp = None
time.sleep(1.5 * attempt)
if resp is None:
meta["instructions"].append({"url": info_url,
"error": "拉取失败(已重试 3 次)"})
continue
if resp.status_code != 200:
meta["instructions"].append({"url": info_url, "status": resp.status_code,
"error": "HTTP %d" % resp.status_code})
continue
text = resp.content.decode("utf-8", "replace")
common, installs = parse_instruction(text)
meta["instructions"].append({"url": info_url, "status": 200,
"name": info_name, "common": common})
meta["infos"][info_name] = text
seen = set()
for entry in installs:
if entry["url"] in seen:
continue
seen.add(entry["url"])
items.append({
"kind": guess_kind(entry["url"]),
"section": entry["section"],
"key": entry["key"],
"url": entry["url"],
"name": package_name(entry["url"]),
"size": None,
"note": "",
"info_name": info_name,
})
for i, item in enumerate(items, 1):
item["idx"] = i
return items, meta
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
"""探每个包的大小 / 可用性(就地写回 item)。"""
for item in items:
for attempt in range(1, 3):
try:
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
stream=True, timeout=timeout, insecure=insecure)
status = resp.status_code
content_range = resp.headers.get("Content-Range", "")
content_length = resp.headers.get("Content-Length")
resp.close()
if status == 206 and "/" in content_range:
item["size"] = int(content_range.rsplit("/", 1)[-1])
item["note"] = ""
elif status == 200 and content_length and content_length.isdigit():
item["size"] = int(content_length)
item["note"] = ""
elif status in (404, 504):
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
else:
item["note"] = "HTTP %d" % status
break
except Exception as exc:
item["note"] = "探测失败:%s" % exc
if attempt < 2:
time.sleep(1.5)
continue
# =============================================================================
# 展示与选择
# =============================================================================
def print_packages(items):
if not items:
print(" (指示書里没有任何 INSTALL 项)")
return
print("")
print(" 序号 类型 大小 来源段/键 文件名")
print(" " + "-" * 92)
for item in items:
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
print(" [%2d] %-4s %-12s %-20s %s" % (
item["idx"], item["kind"], size,
item["section"] + "/" + item["key"], item["name"]))
if item["note"] and item["size"] is None:
print(" %s" % item["note"])
def prompt_selection(items):
"""
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
"""
print("")
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
print(" a = 全选 n = 都不选 q = 退出")
while True:
try:
raw = input(" 请选择要下载的包 > ").strip()
except (EOFError, KeyboardInterrupt):
print("")
return None
if not raw:
continue
low = raw.lower()
if low in ("q", "quit", "exit"):
return None
if low in ("a", "all"):
return list(range(len(items)))
if low in ("n", "none"):
return []
picked, bad = [], []
for token in re.split(r"[,,、\s]+", raw):
if not token:
continue
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if span:
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
for i in range(low_i, high_i + 1):
if 1 <= i <= len(items):
picked.append(i - 1)
else:
bad.append(str(i))
continue
if token.isdigit():
i = int(token)
if 1 <= i <= len(items):
picked.append(i - 1)
else:
bad.append(token)
else:
bad.append(token)
if bad:
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
continue
ordered, seen = [], set()
for i in picked:
if i not in seen:
seen.add(i)
ordered.append(i)
if not ordered:
print(" [x] 没有选中任何项")
continue
return ordered
# =============================================================================
# 下载
# =============================================================================
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
"""
带断点续传的下载。返回 (ok, 说明)。
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
"""
part = dest + ".part"
total = None
for attempt in range(1, retries + 1):
done = os.path.getsize(part) if os.path.exists(part) else 0
headers = {}
if done:
headers["Range"] = "bytes=%d-" % done
try:
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
insecure=insecure)
except Exception as exc:
if attempt == retries:
return False, "连接失败:%s" % exc
time.sleep(2)
continue
if resp.status_code not in (200, 206):
resp.close()
if resp.status_code in (404, 504):
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
if attempt == retries:
return False, "HTTP %d" % resp.status_code
time.sleep(2)
continue
# 服务器忽略 Range 直接给了全量 -> 从头写
if done and resp.status_code == 200:
done = 0
content_range = resp.headers.get("Content-Range", "")
if "/" in content_range:
try:
total = int(content_range.rsplit("/", 1)[-1])
except ValueError:
total = None
elif resp.headers.get("Content-Length", "").isdigit():
total = done + int(resp.headers["Content-Length"])
mode = "ab" if done else "wb"
started = time.time()
written = done
last_tick = 0.0
last_shown = -1
try:
with open(part, mode) as fp:
for chunk in resp.iter_content(CHUNK):
if not chunk:
continue
fp.write(chunk)
written += len(chunk)
now = time.time()
if now - last_tick >= 0.3:
last_tick = now
last_shown = written
show_progress(written, total, now - started)
except Exception as exc:
resp.close()
if attempt == retries:
return False, "写入中断:%s" % exc
time.sleep(2)
continue
finally:
resp.close()
if written == last_shown:
sys.stdout.write("\n") # 收尾换行,避免重复打一行
else:
show_progress(written, total, max(time.time() - started, 0.001), final=True)
if total is not None and written != total:
if attempt == retries:
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
time.sleep(2)
continue
if os.path.exists(dest):
os.remove(dest)
os.replace(part, dest)
return True, "%s(%s)" % (human_size(written), dest)
return False, "重试 %d 次仍失败" % retries
def show_progress(done, total, elapsed, final=False):
speed = done / elapsed if elapsed > 0 else 0
if total:
percent = done * 100.0 / total
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
human_size(total), human_size(speed))
else:
line = " %s %s/s" % (human_size(done), human_size(speed))
sys.stdout.write("\r" + line + ("\n" if final else ""))
sys.stdout.flush()
# =============================================================================
# CLI
# =============================================================================
def build_parser():
p = argparse.ArgumentParser(
prog="netdelivery.py",
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python netdelivery.py # 查完让你多选\n"
" python netdelivery.py --list # 只列清单\n"
" python netdelivery.py --all # 全下\n"
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
),
)
p.add_argument("keychip", nargs="?",
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
p.add_argument("--ver", default=None,
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
p.add_argument("--host", default=DEFAULT_HOST,
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
p.add_argument("--out", default=DEFAULT_OUT_DIR,
help="下载目录(默认 <脚本目录>/download)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
p.add_argument("--all", action="store_true", help="全选(不交互)")
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
return p
def resolve_keychip(cli_value):
if cli_value:
return cli_value
try:
import config # noqa: F401
value = getattr(config, "KEYCHIP_ID", "")
if value:
return value
except Exception:
pass
return ""
def resolve_title_ver(cli_value):
if cli_value:
return str(cli_value)
try:
import config # noqa: F401
return str(getattr(config, "MAI_ENCODING", "1.55"))
except Exception:
return "1.55"
def parse_only(text, count):
"""把 --only 的文本解析成 0 基下标列表。"""
picked, seen = [], set()
for token in re.split(r"[,,、\s]+", text or ""):
if not token:
continue
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if span:
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
candidates = range(low_i, high_i + 1)
elif token.isdigit():
candidates = [int(token)]
else:
raise ValueError("--only 里有无效项:%r" % token)
for i in candidates:
if not (1 <= i <= count):
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
if i - 1 not in seen:
seen.add(i - 1)
picked.append(i - 1)
return picked
def save_instructions(meta, out_dir):
"""把配信指示書原样存一份,便于事后核对。"""
os.makedirs(out_dir, exist_ok=True)
saved = []
for name, text in meta["infos"].items():
path = os.path.join(out_dir, name)
with open(path, "w", encoding="utf-8", newline="") as fp:
fp.write(text)
saved.append(path)
return saved
def main(argv=None):
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
args = build_parser().parse_args(argv)
title_ver = resolve_title_ver(args.ver)
raw_keychip = resolve_keychip(args.keychip)
if not raw_keychip:
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
return 2
try:
client_id = parse_keychip(raw_keychip)
except ValueError as exc:
print("Keychip 解析失败:%s" % exc)
return 2
print("=== netdelivery (ALL.Net DLI) ===")
print(" clientId : %s" % client_id)
print(" title_ver : %s" % title_ver)
print(" server : %s" % args.host)
print(" 正在查询配信指示書 ...")
try:
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
timeout=args.timeout, insecure=args.insecure)
except (AllnetError, ValueError) as exc:
print("查询失败:%s" % exc)
return 1
for entry in meta["instructions"]:
if entry.get("status") == 200:
common = entry.get("common", {})
print(" 指示書 %s" % entry["name"])
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
"RELEASE_TIME", "RELEASE_TYPE"):
if key in common:
print(" %-14s %s" % (key + ":", common[key]))
else:
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
if not items:
print("没有可下载的包。")
return 0
if not args.no_probe:
print(" 正在探测包大小 ...")
probe_all(items, insecure=args.insecure)
print_packages(items)
if args.list:
print("")
print(" 直链:")
for item in items:
print(" [%2d] %s" % (item["idx"], item["url"]))
return 0
# ---- 决定下哪些 ----
if args.only:
try:
chosen = parse_only(args.only, len(items))
except ValueError as exc:
print("参数错误:%s" % exc)
return 2
elif args.all:
chosen = list(range(len(items)))
elif not sys.stdin or not sys.stdin.isatty():
print("")
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
return 0
else:
chosen = prompt_selection(items)
if chosen is None:
print(" 已取消。")
return 0
if not chosen:
print(" 没有选中任何项,退出。")
return 0
out_dir = args.out
os.makedirs(out_dir, exist_ok=True)
for path in save_instructions(meta, out_dir):
print(" [指示書] %s" % path)
print("")
print(" 下载目录:%s" % out_dir)
failed = []
for index in chosen:
item = items[index]
dest = os.path.join(out_dir, item["name"])
print("")
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
item["name"], KIND_TEXT.get(item["kind"], "?")))
if item["size"] is None and item["note"]:
print(" 跳过:%s" % item["note"])
failed.append((item, item["note"]))
continue
started = time.time()
ok, message = download_file(item["url"], dest, insecure=args.insecure)
if ok:
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
else:
print(" 失败:%s" % message)
failed.append((item, message))
print("")
if failed:
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
for item, message in failed:
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
return 1
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
return 0
if __name__ == "__main__":
sys.exit(main())