#!/usr/bin/env python3 """阳光抽签 · 独立验证脚本(只用 Python 3 标准库,无需安装任何东西) 用法: python3 verify.py 方案.txt python3 verify.py 抽签凭证.txt python3 verify.py 方案.txt --value <依据数值> # 自行提供依据数值(如在行情软件里查到的点位) 脚本会: 1. 计算方案原文的 SHA-256,得到「方案指纹」,可与抽卡前公示的指纹比对; 2. 取回抽卡依据的数值:上证指数 / 伦敦金取东方财富分时数据(约可回查 5 个交易日), drand 取官方两个接口并核对 randomness = SHA256(signature); 3. 按方案中写明的规则算出每个人的签值并排序,打印抽卡结果。 本网站服务器计算结果时使用的就是这份文件里的函数。 """ import datetime import hashlib import json import re import sys import urllib.request HEADER = '阳光抽签方案 v2' HEADER_V1 = '阳光抽签方案 v1' CHAIN = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971' # drand quicknet GENESIS = 1692803367 # drand 第 1 轮的 Unix 时间(秒) PERIOD = 3 # 每 3 秒一轮 RULE = ('种子=SHA256(方案指纹+":"+依据数值);签值=SHA256(种子+"|"+组名+"|"+姓名);' '各组按签值从小到大取前N人(十六进制均为小写)') RULE_V1 = ('种子=SHA256(方案指纹+":"+开奖轮次randomness);签值=SHA256(种子+"|"+组名+"|"+姓名);' '各组按签值从小到大取前N人(十六进制均为小写)') PLAN_MARK = '===== 方案原文' ENDPOINTS = ['https://api.drand.sh', 'https://drand.cloudflare.com', 'https://api2.drand.sh', 'https://api3.drand.sh'] # 行情类取东方财富「分时」数据:标为 HH:MM 的点位即该分钟结束(HH:MM:00)时的价格 SOURCES = { 'sse': {'name': '上证指数', 'secid': '1.000001', 'what': '分时点位', 'vendor': '东方财富'}, 'xau': {'name': '伦敦金', 'secid': '122.XAU', 'what': '分时价格', 'vendor': '东方财富,现货黄金 XAU'}, } BJ = datetime.timezone(datetime.timedelta(hours=8)) def sha256(s): return hashlib.sha256(s.encode('utf-8')).hexdigest() def round_time(rnd): """drand 第 rnd 轮产生的 Unix 时间(秒)。""" return GENESIS + (rnd - 1) * PERIOD def beijing(ts): """Unix 秒 → 北京时间字符串(固定 UTC+8,与运行环境时区无关)。""" return datetime.datetime.fromtimestamp(ts, BJ).strftime('%Y-%m-%d %H:%M:%S') def minute_ts(s): """'YYYY-MM-DD HH:MM'(北京时间)→ Unix 秒。""" return int(datetime.datetime.strptime(s, '%Y-%m-%d %H:%M').replace(tzinfo=BJ).timestamp()) def source_text(src): if src['kind'] == 'drand': return f'drand 第 {src["round"]} 轮随机数(北京时间 {beijing(round_time(src["round"]))})' s = SOURCES[src['kind']] return f'{s["name"]} {src["minute"]} {s["what"]}({s["vendor"]})' def parse_source(text): m = re.fullmatch(r'drand 第 ([1-9]\d{0,11}) 轮随机数(北京时间 (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d))', text) if m: rnd = int(m.group(1)) if m.group(2) != beijing(round_time(rnd)): raise ValueError('drand 轮次与所注时间不符') return {'kind': 'drand', 'round': rnd, 'due': round_time(rnd)} m = re.match(r'(上证指数|伦敦金) (\d{4}-\d\d-\d\d \d\d:\d\d) ', text) try: kind = m and ('sse' if m.group(1) == '上证指数' else 'xau') due = minute_ts(m.group(2)) ok = beijing(due) == m.group(2) + ':00' and text == source_text({'kind': kind, 'minute': m.group(2)}) except (AttributeError, ValueError): ok = False if not ok: raise ValueError('抽卡依据格式不正确') return {'kind': kind, 'minute': m.group(2), 'due': due} def normalize_value(kind, v): """依据数值的规范写法:行情为至少两位小数的十进制数,drand 为 64 位小写十六进制。""" v = str(v or '').strip() if kind == 'drand': return v if re.fullmatch(r'[0-9a-f]{64}', v) else None m = re.fullmatch(r'(\d{1,9})(?:\.(\d{1,6}))?', v) return f'{m.group(1)}.{(m.group(2) or "").ljust(2, "0")}' if m else None def parse_plan(text): """严格解析方案原文;格式不对就报错,保证同一份方案只有一种读法。兼容 v1。""" if '\r' in text or not text.endswith('\n'): raise ValueError('方案必须使用 LF 换行,并以换行结尾') if re.search('[\x00-\x09\x0b-\x1f\x7f-\x9f\u2028\u2029\ufeff]', text): raise ValueError('方案中含有不可见的控制字符') lines = text[:-1].split('\n') def field(i, key): if i >= len(lines) or not lines[i].startswith(key + ':'): raise ValueError(f'第 {i + 1} 行应以「{key}:」开头') return lines[i][len(key) + 1:] if lines[0] == HEADER: version, source, i = 2, parse_source(field(2, '抽卡依据')), 3 elif lines[0] == HEADER_V1: version = 1 if field(2, '随机源') != f'drand quicknet {CHAIN}': raise ValueError('随机源不正确') m = re.fullmatch(r'([1-9]\d{0,11})(北京时间 (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d))', field(3, '开奖轮次')) if not m: raise ValueError('开奖轮次格式不正确') rnd = int(m.group(1)) if m.group(2) != beijing(round_time(rnd)): raise ValueError('开奖轮次与所注时间不符') source, i = {'kind': 'drand', 'round': rnd, 'due': round_time(rnd)}, 4 else: raise ValueError(f'第 1 行应为「{HEADER}」') title = field(1, '标题') witness = field(i, '现场随机数') if field(i + 1, '算法') != (RULE if version == 2 else RULE_V1): raise ValueError('算法说明与规则不一致') if not title.strip() or title != title.strip() or not witness or witness != witness.strip(): raise ValueError('标题或现场随机数为空或首尾有空格') groups, seen_names, i = [], set(), i + 2 while i < len(lines): if lines[i] != '': raise ValueError(f'第 {i + 1} 行应为空行') i += 1 m = re.fullmatch(r'【([^【】|]+)】抽取 ([1-9]\d*) 人,候选 ([1-9]\d*) 人', lines[i] if i < len(lines) else '') if not m: raise ValueError(f'第 {i + 1} 行应为分组标题,如「【综合管理部】抽取 2 人,候选 8 人」') name, k, n = m.group(1), int(m.group(2)), int(m.group(3)) names = lines[i + 1:i + 1 + n] if len(names) != n or any(not x or x != x.strip() or '|' in x or x.startswith('【') for x in names): raise ValueError(f'【{name}】的候选名单行数或内容不正确') if k > n: raise ValueError(f'【{name}】抽取人数多于候选人数') if name in (g['name'] for g in groups): raise ValueError(f'分组【{name}】重复') dup = seen_names.intersection(names) | {x for x in names if names.count(x) > 1} if dup: raise ValueError(f'候选人重复:{"、".join(sorted(dup))}') seen_names.update(names) groups.append({'name': name, 'k': k, 'names': names}) i += 1 + n if not groups: raise ValueError('方案中没有分组') return {'version': version, 'title': title, 'source': source, 'witness': witness, 'groups': groups} def compute(plan, fingerprint, value): """核心算法:每人签值 = SHA256(种子|组名|姓名),组内签值最小的 N 人被抽中。""" seed = sha256(fingerprint + ':' + value) out = [] for g in plan['groups']: lots = sorted(({'name': x, 'lot': sha256(f"{seed}|{g['name']}|{x}")} for x in g['names']), key=lambda e: e['lot']) out.append({'name': g['name'], 'k': g['k'], 'lots': lots, 'picked': [e['name'] for e in lots[:g['k']]]}) return {'seed': seed, 'groups': out} def check_beacon(b, rnd): """randomness 必须等于 SHA256(signature);BLS 签名本身可在网页「验证」页核对。""" ok = (b.get('round') == rnd and re.fullmatch(r'[0-9a-f]{64}', b.get('randomness', '')) and hashlib.sha256(bytes.fromhex(b.get('signature', ''))).hexdigest() == b['randomness']) if not ok: raise ValueError('drand 返回的数据不一致') return b def fetch_beacon(rnd, endpoints=ENDPOINTS, timeout=8): url = f'{endpoints[0]}/{CHAIN}/public/{rnd}' req = urllib.request.Request(url, headers={'User-Agent': 'fair-draw-verify/1'}) with urllib.request.urlopen(req, timeout=timeout) as r: return check_beacon(json.load(r), rnd) def fetch_minute(kind, minute, ndays=5, timeout=8): """东方财富分时数据中标为 minute 的那一分钟的价格(找不到返回 None)。""" url = (f'https://push2his.eastmoney.com/api/qt/stock/trends2/get?secid={SOURCES[kind]["secid"]}' f'&fields1=f1,f2&fields2=f51,f53&ndays={ndays}&iscr=0') req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/'}) with urllib.request.urlopen(req, timeout=timeout) as r: for row in (json.load(r).get('data') or {}).get('trends') or []: t, price = row.split(',')[:2] if t == minute: return normalize_value(kind, price) return None def extract_plan(raw): """凭证文件中「===== 方案原文」标记行之后直到文件末尾即为方案原文。""" if raw.startswith(HEADER + '\n') or raw.startswith(HEADER_V1 + '\n'): return raw idx = raw.find('\n' + PLAN_MARK) if idx < 0: raise ValueError('文件中找不到方案原文') return raw[raw.index('\n', idx + 1) + 1:] def main(argv): if not argv or argv[0] in ('-h', '--help'): print(__doc__) return 0 with open(argv[0], 'rb') as f: text = extract_plan(f.read().decode('utf-8')) fp = hashlib.sha256(text.encode('utf-8')).hexdigest() plan = parse_plan(text) src = plan['source'] print(f'标题:{plan["title"]}') print(f'方案指纹:{fp}') print(f'抽卡依据:{source_text(src)}') print(f'现场随机数:{plan["witness"]}') manual = argv[argv.index('--value') + 1] if '--value' in argv else ( argv[argv.index('--randomness') + 1] if '--randomness' in argv else None) if manual: value = normalize_value(src['kind'], manual.lower()) if not value: print('提供的依据数值格式不正确') return 1 print(f'依据数值(手动提供):{value}') elif src['kind'] == 'drand': seen = {} for ep in ENDPOINTS[:2]: try: seen[ep] = fetch_beacon(src['round'], [ep])['randomness'] print(f' {ep} → {seen[ep]}') except Exception as e: # noqa: BLE001 print(f' {ep} → 获取失败:{e}') if not seen: print('无法连接 drand,可改用 --value 手动提供该轮 randomness') return 1 if len(set(seen.values())) != 1: print('两个来源的随机数不一致!') return 1 value = next(iter(seen.values())) else: try: value = fetch_minute(src['kind'], src['minute']) except Exception as e: # noqa: BLE001 value = None print(f' 东方财富 → 获取失败:{e}') if not value: print('取不到该分钟的分时数据(东方财富约可回查 5 个交易日),' '可在行情软件中查到数值后用 --value 提供') return 1 print(f' 东方财富分时 {src["minute"]} → {value}') res = compute(plan, fp, value) print(f'种子:{res["seed"]}\n') for g in res['groups']: print(f'【{g["name"]}】抽取 {g["k"]} 人,候选 {len(g["lots"])} 人') for i, e in enumerate(g['lots']): if i == g['k']: print(' ---- 以上被抽中 ----') print(f' {i + 1:>3}. {e["lot"][:12]} {e["name"]}') print() return 0 if __name__ == '__main__': sys.exit(main(sys.argv[1:]))