312 lines
19 KiB
Python
312 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Luban OpenAI phone verification, one order at a time.
|
|
|
|
Designed for BOSS's OpenAI/Codex onboarding runs:
|
|
- buys only one Luban request at a time;
|
|
- waits >=3 minutes before rejecting unused orders;
|
|
- stops immediately on SMS submit failure and keeps page/order for retry;
|
|
- uses the phone-code submit method proven by the previous 5sim success;
|
|
- refuses to keep buying when OpenAI add-phone is in Bad Request/error state.
|
|
|
|
Secrets are read from ~/.hermes/env/codex_oauth_onboarding.env and never printed.
|
|
"""
|
|
import json, urllib.request, urllib.error, urllib.parse, websocket, itertools, time, pathlib, re, shlex, os, sys
|
|
|
|
CDP_PORT=os.environ.get('CDP_PORT','9228')
|
|
CDP=f'http://127.0.0.1:{CDP_PORT}'
|
|
ENV=pathlib.Path.home()/'.hermes/env/codex_oauth_onboarding.env'
|
|
env={}
|
|
for line in ENV.read_text(errors='ignore').splitlines():
|
|
s=line.strip()
|
|
if not s or s.startswith('#') or '=' not in s: continue
|
|
if s.startswith('export '): s=s[7:].strip()
|
|
k,v=s.split('=',1); k=k.strip(); v=v.strip()
|
|
try:
|
|
if v and v[0] in '"\'': v=shlex.split(v)[0]
|
|
except Exception: pass
|
|
env[k]=v.strip('"\'')
|
|
KEY=env.get('LUBAN_SMS_API_KEY') or env.get('LUBAN_API_KEY')
|
|
BASE=(env.get('LUBAN_SMS_API_BASE') or 'https://lubansms.com/v2/api').rstrip('/')
|
|
SERVICE=env.get('LUBAN_SMS_SERVICE_QUERY','OpenAI')
|
|
LANG=env.get('LUBAN_SMS_LANGUAGE','en')
|
|
POLL_SECONDS=max(180,int(os.environ.get('PHONE_POLL_SECONDS','220')))
|
|
MAX_BUYS=int(os.environ.get('MAX_BUYS','10'))
|
|
MIN_HOLD=int(os.environ.get('MIN_HOLD_SECONDS','180'))
|
|
STATE=pathlib.Path(os.environ.get('LUBAN_RUN_STATE','/tmp/luban_one_by_one_state.json'))
|
|
CODE_FILE=pathlib.Path(os.environ.get('LUBAN_CODE_FILE','/tmp/luban_one_by_one_sms_code.txt'))
|
|
if not KEY: raise SystemExit('missing_luban_key')
|
|
|
|
COUNTRY_MAP={
|
|
'France':('france','FR','法国','33'), 'Brazil':('brazil','BR','巴西','55'), 'Philippines':('philippines','PH','菲律宾','63'),
|
|
'Argentina':('argentina','AR','阿根廷','54'), 'Indonesia':('indonesia','ID','印度尼西亚','62'), 'Vietnam':('vietnam','VN','越南','84'),
|
|
'Netherlands':('netherlands','NL','荷兰','31'), 'Malaysia':('malaysia','MY','马来西亚','60'), 'Mexico':('mexico','MX','墨西哥','52'),
|
|
'Portugal':('portugal','PT','葡萄牙','351'), 'Romania':('romania','RO','罗马尼亚','40'), 'Germany':('germany','DE','德国','49'),
|
|
'Thailand':('thailand','TH','泰国','66'), 'Colombia':('colombia','CO','哥伦比亚','57'), 'Italy':('italy','IT','意大利','39'),
|
|
'Poland':('poland','PL','波兰','48'), 'Spain':('spain','ES','西班牙','34'), 'United Kingdom/England':('england','GB','英国','44'),
|
|
'England':('england','GB','英国','44'), 'United Kingdom':('england','GB','英国','44'), 'Chile':('chile','CL','智利','56'),
|
|
}
|
|
DEFAULT_PRIORITY=['France','Vietnam','Portugal','Romania','Germany','Netherlands','Argentina','Brazil','Philippines','Indonesia','Malaysia','Mexico']
|
|
PRIORITY=[x.strip() for x in os.environ.get('LUBAN_COUNTRY_PRIORITY',','.join(DEFAULT_PRIORITY)).split(',') if x.strip()]
|
|
|
|
BAD_PAGE_RE=re.compile(r'糟糕|出错|Bad request|unknown_error|this page isn|HTTP ERROR|currently unable', re.I)
|
|
|
|
def log(o):
|
|
o.setdefault('ts',int(time.time()))
|
|
print(json.dumps(o,ensure_ascii=False),flush=True)
|
|
|
|
def http_json(endpoint, **params):
|
|
params={k:v for k,v in params.items() if v is not None}; params['apikey']=KEY
|
|
url=BASE+'/'+endpoint.lstrip('/')+'?'+urllib.parse.urlencode(params)
|
|
req=urllib.request.Request(url,headers={'Accept':'application/json','User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'})
|
|
try:
|
|
with urllib.request.urlopen(req,timeout=35) as r:
|
|
txt=r.read().decode(errors='ignore'); st=r.status
|
|
except urllib.error.HTTPError as e:
|
|
txt=e.read().decode(errors='ignore'); st=e.code
|
|
except Exception as e:
|
|
return 0, {'error':type(e).__name__+': '+str(e)}
|
|
try: data=json.loads(txt)
|
|
except Exception: data={'raw':txt[:300]}
|
|
return st,data
|
|
|
|
def strict_code_from_provider(d):
|
|
if isinstance(d,dict):
|
|
for k in ['sms_code','code','verification_code']:
|
|
v=d.get(k)
|
|
if v:
|
|
m=re.search(r'(?<!\d)(\d{6})(?!\d)',str(v))
|
|
if m: return m.group(1)
|
|
for k in ['sms','msg','message','text','content']:
|
|
v=d.get(k)
|
|
if v:
|
|
m=re.search(r'(?<!\d)(\d{6})(?!\d)',str(v))
|
|
if m: return m.group(1)
|
|
m=re.search(r'(?<!\d)(\d{6})(?!\d)', json.dumps(d,ensure_ascii=False) if not isinstance(d,str) else d)
|
|
return m.group(1) if m else None
|
|
|
|
def cdp():
|
|
tabs=json.loads(urllib.request.urlopen(CDP+'/json/list',timeout=10).read().decode())
|
|
pages=[t for t in tabs if t.get('type')=='page' and 'auth.openai.com' in t.get('url','')] or [t for t in tabs if t.get('type')=='page']
|
|
if not pages: raise RuntimeError('no_cdp_page')
|
|
ws=websocket.create_connection(pages[0]['webSocketDebuggerUrl'],timeout=15,suppress_origin=True); cnt=itertools.count(1)
|
|
def call(m,p=None,timeout=60):
|
|
i=next(cnt); ws.send(json.dumps({'id':i,'method':m,'params':p or {}})); end=time.time()+timeout
|
|
while time.time()<end:
|
|
msg=json.loads(ws.recv())
|
|
if msg.get('id')==i:
|
|
if 'error' in msg: raise RuntimeError(msg['error'])
|
|
return msg.get('result',{})
|
|
raise TimeoutError(m)
|
|
call('Runtime.enable'); call('Page.enable')
|
|
def ev(e,timeout=90):
|
|
return call('Runtime.evaluate',{'expression':e,'returnByValue':True,'awaitPromise':True},timeout).get('result',{}).get('value')
|
|
return ws,call,ev
|
|
|
|
def page_state():
|
|
ws,call,ev=cdp()
|
|
try:
|
|
return ev("({href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,2600),hasSelect:!!document.querySelector('select'),hasTel:!!document.querySelector('input[type=tel]')})")
|
|
finally: ws.close()
|
|
|
|
def navigate_add_phone():
|
|
ws,call,ev=cdp()
|
|
try:
|
|
call('Page.navigate',{'url':'https://auth.openai.com/add-phone'}); time.sleep(8)
|
|
return ev("({href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,2600),hasSelect:!!document.querySelector('select'),hasTel:!!document.querySelector('input[type=tel]')})")
|
|
finally: ws.close()
|
|
|
|
def ensure_add_phone_ready(max_rounds=3):
|
|
last=None
|
|
for i in range(max_rounds):
|
|
st=page_state() if i==0 else navigate_add_phone()
|
|
last=st
|
|
text=(st.get('title','')+' '+st.get('body',''))
|
|
ready='add-phone' in st.get('href','') and st.get('hasSelect') and st.get('hasTel') and not BAD_PAGE_RE.search(text)
|
|
if ready:
|
|
return True, st
|
|
# Try one hard reload after a bad page before giving up.
|
|
ws,call,ev=cdp()
|
|
try:
|
|
call('Page.reload',{'ignoreCache':True}); time.sleep(6)
|
|
finally:
|
|
ws.close()
|
|
return False, last
|
|
|
|
def select_country_clear(iso,cname,dial):
|
|
ok_page, st = ensure_add_phone_ready()
|
|
if not ok_page:
|
|
return {'ok':False,'fatal':True,'error':'add_phone_not_ready','state':st}
|
|
ws,call,ev=cdp()
|
|
try:
|
|
gate=ev(f"""(async()=>{{
|
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
const sel=document.querySelector('select');
|
|
if(sel){{sel.value={json.dumps(iso)}; sel.dispatchEvent(new Event('input',{{bubbles:true}})); sel.dispatchEvent(new Event('change',{{bubbles:true}})); await sleep(1300);}}
|
|
const tel=document.querySelector('input[type=tel]');
|
|
if(tel){{tel.focus(); const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(tel,''); tel.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'deleteContentBackward'}})); tel.dispatchEvent(new Event('change',{{bubbles:true}}));}}
|
|
return {{href:location.href,title:document.title,countryValue:sel?.value,countryText:sel?.options?.[sel.selectedIndex]?.text,body:(document.body.innerText||'').slice(0,2200),hasSelect:!!sel,hasTel:!!tel}};
|
|
}})()""",90)
|
|
text=(gate.get('body','')+gate.get('countryText',''))
|
|
gate['ok']=gate.get('countryValue')==iso and (cname in text or ('+'+dial) in text) and not BAD_PAGE_RE.search(gate.get('title','')+' '+gate.get('body',''))
|
|
return gate
|
|
finally: ws.close()
|
|
|
|
def submit_phone(full,dial):
|
|
digits=re.sub(r'\D','',str(full)); local=digits
|
|
if local.startswith(dial): local=local[len(dial):]
|
|
if local.startswith('0'): local=local[1:]
|
|
ws,call,ev=cdp()
|
|
try:
|
|
return ev(f"""(async()=>{{
|
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
const tel=document.querySelector('input[type=tel]'); if(!tel) return {{href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,1500),reason:'no_tel'}};
|
|
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;
|
|
tel.focus(); setter.call(tel,''); tel.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'deleteContentBackward'}}));
|
|
setter.call(tel,{json.dumps(local)}); tel.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:{json.dumps(local)}}})); tel.dispatchEvent(new Event('change',{{bubbles:true}}));
|
|
await sleep(700);
|
|
const sel=document.querySelector('select'); const btn=[...document.querySelectorAll('button')].find(b=>(b.innerText||'').trim()==='继续')||[...document.querySelectorAll('button')].find(b=>/Continue|Next|继续/.test(b.innerText||''));
|
|
const before={{countryValue:sel?.value,countryText:sel?.options?.[sel.selectedIndex]?.text,telLen:tel?.value?.length,buttonDisabled:btn?.disabled}};
|
|
if(btn && !btn.disabled) btn.click();
|
|
await sleep(10000);
|
|
return {{before,href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,3000)}};
|
|
}})()""",170)
|
|
finally: ws.close()
|
|
|
|
def poll_sms(rid,start_ts):
|
|
end=start_ts+POLL_SECONDS; last={}
|
|
while time.time()<end:
|
|
st,d=http_json('getSms',request_id=rid); last=d if isinstance(d,dict) else {}
|
|
code=strict_code_from_provider(last)
|
|
if code: return code,last
|
|
log({'phase':'sms_poll_wait','request_id':rid,'http':st,'msg':last.get('msg'),'elapsed':int(time.time()-start_ts)})
|
|
time.sleep(10)
|
|
return None,last
|
|
|
|
def submit_code_corrected(code):
|
|
ws,call,ev=cdp()
|
|
try:
|
|
return ev(f"""(async()=>{{
|
|
const code={json.dumps(code)}; const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
const input=document.querySelector('input[autocomplete="one-time-code"], input[name="code"], input');
|
|
const before={{href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,1300),inputCount:document.querySelectorAll('input').length,hasInput:!!input,inputName:input?.name,inputAutocomplete:input?.autocomplete,inputMaxLength:input?.maxLength}};
|
|
if(!input) return {{ok:false,reason:'no_input',before}};
|
|
input.focus(); const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
|
|
setter.call(input,''); input.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'deleteContentBackward',data:null}}));
|
|
setter.call(input,code); input.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:code}})); input.dispatchEvent(new Event('change',{{bubbles:true}}));
|
|
await sleep(500); const afterInput={{valueLen:(input.value||'').length,valid:input.checkValidity?.(),disabled:input.disabled}};
|
|
const btn=[...document.querySelectorAll('button')].find(b=>(b.innerText||'').trim()==='继续') || [...document.querySelectorAll('button')].find(b=>/Continue|Next|Verify|验证|继续/.test(b.innerText||''));
|
|
const button={{exists:!!btn,text:btn?.innerText,disabled:btn?.disabled,ariaDisabled:btn?.getAttribute('aria-disabled')}};
|
|
if(btn && !btn.disabled) btn.click(); await sleep(12000);
|
|
return {{ok:true,before,afterInput,button,after:{{href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,2200)}}}};
|
|
}})()""",170)
|
|
finally: ws.close()
|
|
|
|
def reject_after_min_wait(rid,buy_ts):
|
|
# Keep trying until success/already released/wrong_status or a hard cap, because Luban can return "N seconds later".
|
|
deadline=time.time()+420
|
|
while True:
|
|
wait=max(0,MIN_HOLD-(time.time()-buy_ts))
|
|
if wait>0:
|
|
log({'phase':'release_wait_min_hold','request_id':rid,'sleep_seconds':int(wait)})
|
|
time.sleep(wait)
|
|
st,d=http_json('setStatus',request_id=rid,status='reject')
|
|
msg=str(d.get('msg') if isinstance(d,dict) else d)
|
|
ok=st==200 and isinstance(d,dict) and d.get('code')==0
|
|
already=('已释放' in msg or 'wrong_status' in msg)
|
|
log({'phase':'activation_reject','request_id':rid,'http':st,'ok':ok or already,'msg':msg[:120]})
|
|
if ok or already:
|
|
return True
|
|
m=re.search(r'(\d+)\s*秒',msg)
|
|
delay=min(max(int(m.group(1))+5,30),90) if m else 75
|
|
if time.time()+delay>deadline:
|
|
log({'phase':'activation_reject_give_up','request_id':rid,'msg':msg[:120]})
|
|
return False
|
|
time.sleep(delay)
|
|
|
|
def fetch_services():
|
|
rows=[]
|
|
for page in range(1,8):
|
|
st,d=http_json('List',service=SERVICE,language=LANG,page=page)
|
|
if st!=200 or not isinstance(d,dict) or d.get('code')!=0 or not isinstance(d.get('msg'),list): continue
|
|
for r in d['msg']:
|
|
cen=str(r.get('country_name_en') or r.get('country_name') or '')
|
|
name=str(r.get('service_name') or '')
|
|
if cen in COUNTRY_MAP and r.get('service_id') and ('openai' in name.lower() or 'chatgpt' in name.lower() or SERVICE.lower() in name.lower()): rows.append(r)
|
|
seen={}
|
|
for r in rows:
|
|
cen=str(r.get('country_name_en') or r.get('country_name') or '')
|
|
if cen not in seen: seen[cen]=r
|
|
def score(r):
|
|
cen=str(r.get('country_name_en') or r.get('country_name') or '')
|
|
pri=PRIORITY.index(cen) if cen in PRIORITY else 50
|
|
try: cost=float(r.get('cost'))
|
|
except Exception: cost=99
|
|
return (pri,cost,cen)
|
|
return sorted(seen.values(),key=score)
|
|
|
|
services=fetch_services()
|
|
log({'phase':'run_start','mode':'luban_one_by_one_v2','max_buys':MAX_BUYS,'poll_seconds':POLL_SECONDS,'min_hold':MIN_HOLD,'candidate_services':[{'service_id':r.get('service_id'),'country':r.get('country_name_en'),'cost':r.get('cost')} for r in services[:15]]})
|
|
summary={'bought':0,'direct_reject':0,'sms_timeout':0,'sms_received':0,'sms_submit_failed':0,'success':False,'submit_no_sms_page':0}
|
|
if not services:
|
|
log({'phase':'no_services'}); sys.exit(2)
|
|
for svc in services:
|
|
if summary['bought']>=MAX_BUYS or summary['success']: break
|
|
cen=str(svc.get('country_name_en') or svc.get('country_name') or '')
|
|
slug,iso,cname,dial=COUNTRY_MAP[cen]
|
|
gate=select_country_clear(iso,cname,dial)
|
|
log({'phase':'country_gate','country':cen,'ok':gate.get('ok'),'fatal':gate.get('fatal'),'countryValue':gate.get('countryValue'),'countryText':gate.get('countryText'),'href':gate.get('href') or (gate.get('state') or {}).get('href'),'title':gate.get('title') or (gate.get('state') or {}).get('title')})
|
|
if gate.get('fatal'):
|
|
summary['fatal_page_recovery_failed']=True
|
|
break
|
|
if not gate.get('ok'):
|
|
continue
|
|
st,d=http_json('getNumber',service_id=svc['service_id'])
|
|
rid=d.get('request_id') if isinstance(d,dict) else None; phone=d.get('number') if isinstance(d,dict) else None
|
|
if not (rid and phone):
|
|
log({'phase':'buy_fail','country':cen,'service_id':svc.get('service_id'),'http':st,'code':d.get('code') if isinstance(d,dict) else None,'msg':d.get('msg') if isinstance(d,dict) else str(d)[:160]})
|
|
continue
|
|
summary['bought']+=1; buy_ts=time.time()
|
|
STATE.write_text(json.dumps({'current_request_id':rid,'country':cen,'service_id':svc.get('service_id'),'phone_redacted':'[REDACTED]'+re.sub(r'\D','',str(phone))[-4:]},ensure_ascii=False,indent=2))
|
|
log({'phase':'activation_bought','buy_index':summary['bought'],'country':cen,'service_id':svc.get('service_id'),'request_id':rid,'phone_redacted':'[REDACTED]'+re.sub(r'\D','',str(phone))[-4:]})
|
|
res=submit_phone(phone,dial)
|
|
body=res.get('body',''); text=res.get('title','')+' '+body
|
|
rejected=any(x in body for x in ['无法向此电话号码发送验证码','电话号码无效']) or any(x in body.lower() for x in ['invalid phone','unable to send'])
|
|
bad_page=bool(BAD_PAGE_RE.search(text))
|
|
sms_page=('phone-verification' in res.get('href','')) or ('验证码' in body and '重新发送' in body and not rejected and not bad_page)
|
|
channel='whatsapp' if re.search(r'WhatsApp|Whats\s*App',body,re.I) else ('sms' if re.search(r'SMS|短信|text message',body,re.I) else 'unknown')
|
|
log({'phase':'phone_submitted','buy_index':summary['bought'],'country':cen,'request_id':rid,'href':res.get('href'),'title':res.get('title'),'rejected':rejected,'bad_page':bad_page,'sms_page':sms_page,'channel':channel})
|
|
if rejected or bad_page:
|
|
summary['direct_reject']+=1 if rejected else 0
|
|
summary['submit_no_sms_page']+=0 if rejected else 1
|
|
reject_after_min_wait(rid,buy_ts)
|
|
if bad_page:
|
|
okp, pst=ensure_add_phone_ready()
|
|
log({'phase':'post_bad_page_recovery','ok':okp,'href':pst.get('href') if isinstance(pst,dict) else None,'title':pst.get('title') if isinstance(pst,dict) else None})
|
|
if not okp: break
|
|
continue
|
|
if not sms_page:
|
|
summary['submit_no_sms_page']+=1
|
|
reject_after_min_wait(rid,buy_ts)
|
|
continue
|
|
code,last=poll_sms(rid,buy_ts)
|
|
if not code:
|
|
summary['sms_timeout']+=1
|
|
log({'phase':'sms_timeout','buy_index':summary['bought'],'country':cen,'request_id':rid,'last_msg':last.get('msg') if isinstance(last,dict) else None,'waited_seconds':int(time.time()-buy_ts)})
|
|
reject_after_min_wait(rid,buy_ts)
|
|
continue
|
|
summary['sms_received']+=1; CODE_FILE.write_text(code)
|
|
log({'phase':'sms_received','buy_index':summary['bought'],'country':cen,'request_id':rid,'elapsed':int(time.time()-buy_ts),'provider_fields':[k for k in (last or {}).keys() if k not in ('apikey','number')]})
|
|
res2=submit_code_corrected(code)
|
|
after=res2.get('after') or {}; before=res2.get('before') or {}
|
|
success='phone-verification' not in after.get('href','') and '代码不正确' not in after.get('body','') and 'incorrect' not in after.get('body','').lower()
|
|
log({'phase':'sms_code_submitted_corrected','success':bool(success),'request_id':rid,'country':cen,'href':after.get('href'),'title':after.get('title'),'input_meta':{k:before.get(k) for k in ['inputCount','hasInput','inputName','inputAutocomplete','inputMaxLength']},'button':res2.get('button'),'body_excerpt':(after.get('body') or '')[:500].replace(code,'[REDACTED_CODE]')})
|
|
if success:
|
|
summary.update({'success':True,'success_request_id':rid,'success_country':cen,'final_href':after.get('href')})
|
|
break
|
|
summary.update({'sms_submit_failed':summary['sms_submit_failed']+1,'held_request_id':rid,'held_country':cen,'final_href':after.get('href')})
|
|
log({'phase':'sms_submit_failed_hold_for_retry','request_id':rid,'country':cen,'action':'KEEP_PAGE_AND_ORDER_NO_RELEASE','code_file':str(CODE_FILE)})
|
|
log({'phase':'run_final','summary':summary})
|
|
sys.exit(4)
|
|
log({'phase':'run_final','summary':summary})
|
|
sys.exit(0 if summary.get('success') else 3)
|