#!/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'(?{{ 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(){{ 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)